腾讯Mini项目课程前置学习笔记(第二轮)

开源库或框架入门

关于项目B 指标监控服务重构 的提问:
Q:在Go语言的后台SDK开发中,有哪些开源库或框架可以使用来简化开发?”
A:

  • 并发和异步处理:https://github.com/alitto/pond

    • Worker pool with dynamic size

    • Worker pool with fixed size

    • Submitting a group of tasks

    • Submitting a group of tasks associated to a context (since v1.8.0)

    • Pool Configuration Options

      • MinWorkers

      • IdleTimeout

      • PanicHandler

      • Strategy(Eager/Balanced/Lazy)

      • Context

  • 错误处理(这个概念在golang中并不强调,从简就好,管理好Error变量和状态码等):https://github.com/hashicorp/go-multierror

    • Building a list of errors
    • Customizing the formatting of the errors
    • Accessing the list of errors
    • Extracting an error
    • Checking for an exact error value
    • Returning a multierror only if there are errors
  • 批处理:https://github.com/Kevinello/go-buffer

  • 日志库:zap就挺好的,简洁明了,但日志要打在该打的地方,不能滥用也不能疏漏https://github.com/uber-go/zap

    在Go语言项目中使用Zap日志库

    使用zap接收gin框架默认的日志并配置日志归档

  • 比较不错的util库:https://github.com/samber/lo

  • golang单元测试:https://github.com/smartystreets/goconvey, https://github.com/stretchr/testify

    用 Testify 来改善 GO 测试和模拟

    Go 测试综合指南

    特点 goconvey Testify
    文档和易用性 非常好 较为简洁
    易用性 直接使用 需要安装 Node.js 环境
    测试结果 不够直观 支持图表和数据可视化
    断言功能 丰富 强大
    并行测试 支持 更加直观和易于配置
    场景支持 支持多种场景,包括函数、模块、文件等 支持两种场景,Unit 和 Integration
  • 客户端框架:https://github.com/spf13/cobra

    Cobra 中文文档 - 掘金 (juejin.cn)

    Go每日一库之Cobra

  • 配置管理:https://github.com/spf13/viper

    Go语言配置管理神器——Viper中文教程


ClickHouse主键索引最佳实践

  • ClickHouse的索引与传统的关系数据库有何不同
  • ClickHouse是怎样构建和使用主键稀疏索引的
  • ClickHouse索引的最佳实践

全表扫描

为了了解在没有主键的情况下如何对数据集执行查询,我们通过执行以下SQL DDL语句(使用MergeTree表引擎)创建了一个表:

CREATE TABLE hits_NoPrimaryKey
(
    `UserID` UInt32,
    `URL` String,
    `EventTime` DateTime
)
ENGINE = MergeTree
PRIMARY KEY tuple();

接下来,使用以下插入SQL将命中数据集的一个子集插入到表中。这个SQL使用URL表函数和类型推断从clickhouse.com加载一个数据集的一部分数据:

INSERT INTO hits_NoPrimaryKey SELECT
   intHash32(c11::UInt64) AS UserID,
   c15 AS URL,
   c5 AS EventTime
FROM url('https://datasets.clickhouse.com/hits/tsv/hits_v1.tsv.xz')
WHERE URL != '';

结果:

Ok.

0 rows in set. Elapsed: 145.993 sec. Processed 8.87 million rows, 18.40 GB (60.78 thousand rows/s., 126.06 MB/s.)

ClickHouse客户端输出了执行结果,插入了887万行数据。

FINAL关键字optimize该表:

OPTIMIZE TABLE hits_NoPrimaryKey FINAL;

现在我们执行第一个web分析查询。以下是用户id为749927693的互联网用户点击次数最多的前10个url:

SELECT URL, count(URL) as Count
FROM hits_NoPrimaryKey
WHERE UserID = 749927693
GROUP BY URL
ORDER BY Count DESC
LIMIT 10;

结果:

┌─URL────────────────────────────┬─Count─┐
│ http://auto.ru/chatay-barana.. │   170 │
│ http://auto.ru/chatay-id=371...│    52 │
│ http://public_search           │    45 │
│ http://kovrik-medvedevushku-...│    36 │
│ http://forumal                 │    33 │
│ http://korablitz.ru/L_1OFFER...│    14 │
│ http://auto.ru/chatay-id=371...│    14 │
│ http://auto.ru/chatay-john-D...│    13 │
│ http://auto.ru/chatay-john-D...│    10 │
│ http://wot/html?page/23600_m...│     9 │
└────────────────────────────────┴───────┘

10 rows in set. Elapsed: 0.022 sec.
Processed 8.87 million rows,
70.45 MB (398.53 million rows/s., 3.17 GB/s.)

ClickHouse客户端输出表明,ClickHouse执行了一个完整的表扫描!我们的表的887万行中的每一行都被加载到ClickHouse中,这不是可扩展的。

为了使这种(方式)更有效和更快,我们需要使用一个具有适当主键的表。这将允许ClickHouse自动(基于主键的列)创建一个稀疏的主索引,然后可以用于显著加快我们示例查询的执行

包含主键的表

创建一个包含联合主键UserID和URL列的表:

CREATE TABLE hits_UserID_URL
(
    `UserID` UInt32,
    `URL` String,
    `EventTime` DateTime
)
ENGINE = MergeTree
PRIMARY KEY (UserID, URL)
ORDER BY (UserID, URL, EventTime)
SETTINGS index_granularity = 8192, index_granularity_bytes = 0;

DDL详情:

  • 通过ORDER BY子句指定表的复合排序键

  • 通过设置配置控制主索引有多少索引项:

    • index_granularity: 显式设置为其默认值8192。这意味着对于每一组8192行,主索引将有一个索引条目,例如,如果表包含16384行,那么索引将有两个索引条目。
    • index_granularity_bytes: 设置为0表示禁止自适应索引粒度自适应索引粒度意味着ClickHouse自动为一组n行创建一个索引条目
      • 如果n小于8192,但n行的合并行数据大小大于或等于10MB (index_granularity_bytes的默认值)或
      • n达到8192

上面DDL语句中的主键会基于两个指定的键列创建主索引。插入数据:

INSERT INTO hits_UserID_URL SELECT
   intHash32(c11::UInt64) AS UserID,
   c15 AS URL,
   c5 AS EventTime
FROM url('https://datasets.clickhouse.com/hits/tsv/hits_v1.tsv.xz')
WHERE URL != '';

结果:

0 rows in set. Elapsed: 149.432 sec. Processed 8.87 million rows, 18.40 GB (59.38 thousand rows/s., 123.16 MB/s.)
optimize表:
OPTIMIZE TABLE hits_UserID_URL FINAL;

我们可以使用下面的查询来获取关于表的元数据:

SELECT
    part_type,
    path,
    formatReadableQuantity(rows) AS rows,
    formatReadableSize(data_uncompressed_bytes) AS data_uncompressed_bytes,
    formatReadableSize(data_compressed_bytes) AS data_compressed_bytes,
    formatReadableSize(primary_key_bytes_in_memory) AS primary_key_bytes_in_memory,
    marks,
    formatReadableSize(bytes_on_disk) AS bytes_on_disk
FROM system.parts
WHERE (table = 'hits_UserID_URL') AND (active = 1)
FORMAT Vertical;

结果:

part_type:                   Wide
path:                        ./store/d9f/d9f36a1a-d2e6-46d4-8fb5-ffe9ad0d5aed/all_1_9_2/
rows:                        8.87 million
data_uncompressed_bytes:     733.28 MiB
data_compressed_bytes:       206.94 MiB
primary_key_bytes_in_memory: 96.93 KiB
marks:                       1083
bytes_on_disk:               207.07 MiB

1 rows in set. Elapsed: 0.003 sec.

客户端输出表明:

  • 表数据以wide format存储在一个特定目录,每个列有一个数据文件和mark文件。
  • 表有887万行数据。
  • 未压缩的数据有733.28 MB。
  • 压缩之后的数据有206.94 MB。
  • 有1083个主键索引条目,大小是96.93 KB。
  • 在磁盘上,表的数据、标记文件和主索引文件总共占用207.07 MB。

针对海量数据规模的索引设计

在传统的关系数据库管理系统中,每个表行包含一个主索引。对于我们的数据集,这将导致主索引——通常是一个B(+)-Tree的数据结构——包含887万个条目。

这样的索引允许快速定位特定的行,从而提高查找点查和更新的效率。在B(+)-Tree数据结构中搜索一个条目的平均时间复杂度为O(log2n)。对于一个有887万行的表,这意味着需要23步来定位任何索引条目。

这种能力是有代价的:额外的磁盘和内存开销,以及向表中添加新行和向索引中添加条目时更高的插入成本(有时还需要重新平衡B-Tree)。

考虑到与B-Tee索引相关的挑战,ClickHouse中的表引擎使用了一种不同的方法。ClickHouseMergeTree Engine引擎系列被设计和优化用来处理大量数据。

这些表被设计为每秒接收数百万行插入,并存储非常大(100 pb)的数据量。

数据被一批一批的快速写入表中,并在后台应用合并规则。

在ClickHouse中,每个数据部分(data part)都有自己的主索引。当他们被合并时,合并部分的主索引也被合并。

在大规模中情况下,磁盘和内存的效率是非常重要的。因此,不是为每一行创建索引,而是为一组数据行(称为颗粒(granule))构建一个索引条目

之所以可以使用这种稀疏索引,是因为ClickHouse会按照主键列的顺序将一组行存储在磁盘上。

与直接定位单个行(如基于B-Tree的索引)不同,稀疏主索引允许它快速(通过对索引项进行二分查找)识别可能匹配查询的行组

然后潜在的匹配行组(颗粒)以并行的方式被加载到ClickHouse引擎中,以便找到匹配的行

这种索引设计允许主索引很小(它可以而且必须完全适合主内存),同时仍然显著加快查询执行时间:特别是对于数据分析用例中常见的范围查询。

数据按照主键排序存储在磁盘上

上面创建的表有:

  • 联合主键 (UserID, URL)
  • 联合排序键 (UserID, URL, EventTime)。

NOTE

  • 如果我们只指定了排序键,那么主键将隐式定义为排序键
  • 为了提高内存效率,我们显式地指定了一个主键,只包含查询过滤的列。基于主键的主索引被完全加载到主内存中
  • 为了上下文的一致性和最大的压缩比例,我们单独定义了排序键,排序键包含当前表所有的列(和压缩算法有关,一般排序之后又更好的压缩率)。
  • 如果同时指定了主键和排序键,则主键必须是排序键的前缀

插入的行按照主键列(以及排序键的附加EventTime列)的字典序(从小到大)存储在磁盘上。

NOTE

ClickHouse允许插入具有相同主键列的多行数据。在这种情况下(参见下图中的第1行和第2行),最终的顺序是由指定的排序键决定的,这里是EventTime列的值。

如下图所示:ClickHouse是列存数据库。

  • 在磁盘上,每个表都有一个数据文件(*.bin),该列的所有值都以压缩格式存储,并且
  • 在这个例子中,这887万行按主键列(以及附加的排序键列)的字典升序存储在磁盘上
    • UserID第一位,
    • 然后是URL,
    • 最后是EventTime:

UserID.bin,URL.bin,和EventTime.bin是UserID,URL,和EventTime列的数据文件。

NOTE

  • 因为主键定义了磁盘上行的字典顺序,所以一个表只能有一个主键。
  • 我们从0开始对行进行编号,以便与ClickHouse内部行编号方案对齐,该方案也用于记录消息。

数据被组织成颗粒以进行并行数据处理

出于数据处理的目的,表的列值在逻辑上被划分为多个颗粒。颗粒是流进ClickHouse进行数据处理的最小的不可分割数据集。这意味着,ClickHouse不是读取单独的行,而是始终读取(以流方式并并行地)整个行组(颗粒)

NOTE

列值并不物理地存储在颗粒中,颗粒只是用于查询处理的列值的逻辑组织方式

下图显示了如何将表中的887万行(列值)组织成1083个颗粒,这是表的DDL语句包含设置==index_granularity(设置为默认值8192)==的结果。

第一个(根据磁盘上的物理顺序)8192行(它们的列值)在逻辑上属于颗粒0,然后下一个8192行(它们的列值)属于颗粒1,以此类推。

NOTE

  • 最后一个颗粒(1082颗粒)是少于8192行的。

  • 我们在本指南开头的“DDL 语句详细信息”中提到,我们禁用了自适应索引粒度(为了简化本指南中的讨论,并使图表和结果可重现)。

    因此,示例表中所有颗粒(除了最后一个)都具有相同大小。

  • 对于具有自适应索引粒度的表(默认情况下索引粒度是自适应的),某些粒度的大小可以小于 8192 行,具体取决于行数据大小。

  • 我们将主键列(UserID, URL)中的一些列值标记为橙色。

    这些橙色标记的列值是每个颗粒中每个主键列的最小值。这里的例外是最后一个颗粒(上图中的颗粒1082),最后一个颗粒我们标记的是最大的值。

    正如我们将在下面看到的,这些橙色标记的列值将是表主索引中的条目。

  • 我们从0开始对行进行编号,以便与ClickHouse内部行编号方案对齐,该方案也用于记录消息。

每个颗粒对应主索引的一个条目

主索引是基于上图中显示的颗粒创建的。这个索引是一个未压缩的扁平数组文件(primary.idx),包含从0开始的所谓的数字索引标记。

下面的图显示了索引存储了每个颗粒的最小主键列值(在上面的图中用橙色标记的值)。 例如:

  • 第一个索引条目(下图中的“mark 0”)存储上图中颗粒0的主键列的最小值,
  • 第二个索引条目(下图中的“mark 1”)存储上图中颗粒1的主键列的最小值,以此类推。

在我们的表中,索引总共有1083个条目,887万行数据和1083个颗粒:

NOTE

  • 最后一个索引条目(上图中的“mark 1082”)存储了上图中颗粒1082的主键列的最大值。
  • 索引条目(索引标记)不是基于表中的特定行,而是基于颗粒。例如,对于上图中的索引条目‘mark 0’,在我们的表中没有UserID为240.923且URL为“goal://metry=10000467796a411…”的行,相反,对于该表,有一个颗粒0,在该颗粒中,最小UserID值是240.923,最小URL值是“goal://metry=10000467796a411…”,这两个值来自不同的行。
  • 主索引文件完全加载到主内存中。如果文件大于可用的空闲内存空间,则ClickHouse将发生错误。

主键条目称为索引标记,因为每个索引条目都标志着特定数据范围的开始。对于示例表:

  • UserID index marks:
    主索引中存储的UserID值按升序排序。
    上图中的‘mark 1’指示颗粒1中所有表行的UserID值,以及随后所有颗粒中的UserID值,都保证大于或等于4.073.710。

    正如我们稍后将看到的, 当查询对主键的第一列进行过滤时,此全局有序使ClickHouse能够对第一个键列的索引标记使用二分查找算法。

  • URL index marks:
    主键列UserID和URL有相同的基数,这意味着第一列之后的所有主键列的索引标记通常只表示每个颗粒的数据范围。
    例如,‘mark 0’中的URL列所有的值都大于等于goal://metry=10000467796a411…, 然后颗粒1中的URL并不是如此,这是因为‘mark 1‘与‘mark 0‘具有不同的UserID列值。

    稍后我们将更详细地讨论这对查询执行性能的影响。

主索引被用来选择颗粒

现在,我们可以在主索引的支持下执行查询。

下面计算UserID 749927693点击次数最多的10个url。

SELECT URL, count(URL) AS Count
FROM hits_UserID_URL
WHERE UserID = 749927693
GROUP BY URL
ORDER BY Count DESC
LIMIT 10;

结果:

┌─URL────────────────────────────┬─Count─┐
│ http://auto.ru/chatay-barana.. │   170 │
│ http://auto.ru/chatay-id=371...│    52 │
│ http://public_search           │    45 │
│ http://kovrik-medvedevushku-...│    36 │
│ http://forumal                 │    33 │
│ http://korablitz.ru/L_1OFFER...│    14 │
│ http://auto.ru/chatay-id=371...│    14 │
│ http://auto.ru/chatay-john-D...│    13 │
│ http://auto.ru/chatay-john-D...│    10 │
│ http://wot/html?page/23600_m...│     9 │
└────────────────────────────────┴───────┘

10 rows in set. Elapsed: 0.005 sec.
Processed 8.19 thousand rows,
740.18 KB (1.53 million rows/s., 138.59 MB/s.)

ClickHouse客户端的输出显示,没有进行全表扫描,只有8.19千行流到ClickHouse。

如果trace logging打开了,那ClickHouse服务端日志会显示ClickHouse正在对1083个UserID索引标记执行二分查找以便识别可能包含UserID列值为749927693的行的颗粒。这需要19个步骤,平均时间复杂度为O(log2 n):

...Executor): Key condition: (column 0 in [749927693, 749927693])
...Executor): Running binary search on index range for part all_1_9_2 (1083 marks)
...Executor): Found (LEFT) boundary mark: 176
...Executor): Found (RIGHT) boundary mark: 177
...Executor): Found continuous range in 19 steps
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              1/1083 marks by primary key, 1 marks to read from 1 ranges
...Reading ...approx. 8192 rows starting from 1441792

我们可以在上面的跟踪日志中看到,1083个现有标记中有一个满足查询。

Mark 176 was identified (the 'found left boundary mark' is inclusive, the 'found right boundary mark' is exclusive), and therefore all 8192 rows from granule 176 (which starts at row 1.441.792 - we will see that later on in this article) are then streamed into ClickHouse in order to find the actual rows with a UserID column value of 749927693.

我们也可以通过使用EXPLAIN来重现这个结果:

EXPLAIN indexes = 1
SELECT URL, count(URL) AS Count
FROM hits_UserID_URL
WHERE UserID = 749927693
GROUP BY URL
ORDER BY Count DESC
LIMIT 10;

结果如下:

┌─explain───────────────────────────────────────────────────────────────────────────────┐
│ Expression (Projection)                                                               │
│   Limit (preliminary LIMIT (without OFFSET))                                          │
│     Sorting (Sorting for ORDER BY)                                                    │
│       Expression (Before ORDER BY)                                                    │
│         Aggregating                                                                   │
│           Expression (Before GROUP BY)                                                │
│             Filter (WHERE)                                                            │
│               SettingQuotaAndLimits (Set limits and quota after reading from storage) │
│                 ReadFromMergeTree                                                     │
│                 Indexes:                                                              │
│                   PrimaryKey                                                          │
│                     Keys:                                                             │
│                       UserID                                                          │
│                     Condition: (UserID in [749927693, 749927693])                     │
│                     Parts: 1/1                                                        │
│                     Granules: 1/1083                                                  │
└───────────────────────────────────────────────────────────────────────────────────────┘

16 rows in set. Elapsed: 0.003 sec.

客户端输出显示,在1083个颗粒中选择了一个可能包含UserID列值为749927693的行。

当查询对联合主键的一部分并且是第一个主键进行过滤时,ClickHouse将主键索引标记运行二分查找算法

正如上面所讨论的,ClickHouse使用它的稀疏主索引来快速(通过二分查找算法)选择可能包含匹配查询的行的颗粒

这是ClickHouse查询执行的第一阶段(颗粒选择)

第二阶段(数据读取中), ClickHouse定位所选的颗粒,以便将它们的所有行流到ClickHouse引擎中,以便找到实际匹配查询的行。

标记文件用来定位颗粒

下图描述了上表主索引文件的一部分。

如上所述,通过对索引的1083个UserID标记进行二分搜索,确定了第176个标记。因此,它对应的颗粒176可能包含UserID列值为749.927.693的行。

上图显示,标记176是第一个UserID值小于749.927.693的索引条目,并且下一个标记(标记177)的颗粒177的最小UserID值大于该值的索引条目。因此,只有标记176对应的颗粒176可能包含UserID列值为749.927.693的行。

为了确认(或排除)颗粒176中的某些行包含UserID列值为749.927.693,需要将属于此颗粒的所有8192行读取到ClickHouse。

为了读取这部分数据,ClickHouse需要知道颗粒176的物理地址。

在ClickHouse中,我们表的所有颗粒的物理位置都存储在标记文件中。与数据文件类似,每个表的列有一个标记文件。

下图显示了三个标记文件UserID.mrk、URL.mrk、EventTime.mrk,为表的UserID、URL和EventTime列存储颗粒的物理位置。

我们已经讨论了主索引是一个扁平的未压缩数组文件(primary.idx),其中包含从0开始编号的索引标记

类似地,标记文件也是一个扁平的未压缩数组文件(*.mrk),其中包含从0开始编号的标记

一旦ClickHouse确定并选择了可能包含查询所需的匹配行的颗粒的索引标记,就可以在标记文件数组中查找,以获得颗粒的物理位置。

每个特定列的标记文件条目以偏移量的形式存储两个位置:

  • 第一个偏移量(上图中的’block_offset’)是在包含所选颗粒的压缩版本的压缩列数据文件中定位块。这个压缩块可能包含几个压缩的颗粒。所定位的压缩文件块在读取时被解压到内存中。
  • 标记文件的第二个偏移量(上图中的“granule_offset”)提供了颗粒在解压数据块中的位置

定位到的颗粒中的所有8192行数据都会被ClickHouse加载然后进一步处理。

为什么需要MARK文件

为什么主索引不直接包含与索引标记相对应的颗粒的物理位置?

因为ClickHouse设计的场景就是超大规模数据,非常高效地使用磁盘和内存非常重要。

主索引文件需要放入内存中。

对于我们的示例查询,ClickHouse使用了主索引,并选择了可能包含与查询匹配的行的单个颗粒。只有对于这一个颗粒,ClickHouse才需定位物理位置,以便将相应的行组读取以进一步的处理。

而且,只有UserID和URL列需要这个偏移量信息。

对于查询中不使用的列,例如EventTime,不需要偏移量信息

对于我们的示例查询,Clickhouse只需要UserID数据文件(UserID.bin)中176颗粒的两个物理位置偏移,以及URL数据文件(URL.data)中176颗粒的两个物理位置偏移。

由mark文件提供的间接方法避免了直接在主索引中存储所有三个列的所有1083个颗粒的物理位置的条目:因此避免了在主内存中有不必要的(可能未使用的)数据

下面的图表和文本说明了我们的查询示例,ClickHouse如何在UserID.bin数据文件中定位176颗粒。

我们在本文前面讨论过,ClickHouse选择了主索引标记176,因此176颗粒可能包含查询所需的匹配行。

ClickHouse现在使用从索引中选择的标记号(176)在UserID.mark中进行位置数组查找,以获得两个偏移量,用于定位颗粒176。

如图所示,第一个偏移量是定位UserID.bin数据文件中的压缩文件块,该数据文件包含颗粒176的压缩数据。

一旦所定位的文件块被解压缩到主内存中,就可以使用标记文件的第二个偏移量在未压缩的数据中定位颗粒176。

ClickHouse需要从UserID.bin数据文件和URL.bin数据文件中定位(读取)颗粒176,以便执行我们的示例查询(UserID为749.927.693的互联网用户点击次数最多的10个url)。

上图显示了ClickHouse如何定位UserID.bin数据文件的颗粒。

同时,ClickHouse对URL.bin数据文件的颗粒176执行相同的操作。这两个不同的颗粒被对齐并加载到ClickHouse引擎以进行进一步的处理,即聚合并计算UserID为749.927.693的所有行的每组URL值,最后以计数降序输出10个最大的URL组。

查询使用第二位主键的性能问题

当查询对复合键的一部分并且是第一个主键列进行过滤时,ClickHouse将对主键列的索引标记运行二分查找。

但是,当查询对联合主键的一部分但不是第一个键列进行过滤时,会发生什么情况?

NOTE

我们讨论了这样一种场景:查询不是显式地对第一个主键列进行过滤,而是对第一个主键列之后的任何键列进行过滤。

当查询同时对第一个主键列和第一个主键列之后的任何键列进行过滤时,ClickHouse将对第一个主键列的索引标记运行二分查找。

我们使用一个查询来计算最点击"http://public_search"的最多的前10名用户:

SELECT UserID, count(UserID) AS Count
FROM hits_UserID_URL
WHERE URL = 'http://public_search'
GROUP BY UserID
ORDER BY Count DESC
LIMIT 10;

结果是:

┌─────UserID─┬─Count─┐
│ 2459550954 │  3741 │
│ 1084649151 │  2484 │
│  723361875 │   729 │
│ 3087145896 │   695 │
│ 2754931092 │   672 │
│ 1509037307 │   582 │
│ 3085460200 │   573 │
│ 2454360090 │   556 │
│ 3884990840 │   539 │
│  765730816 │   536 │
└────────────┴───────┘

10 rows in set. Elapsed: 0.086 sec.
Processed 8.81 million rows,
799.69 MB (102.11 million rows/s., 9.27 GB/s.)

客户端输出表明,尽管URL列是联合主键的一部分,ClickHouse几乎执行了一一次全表扫描!ClickHouse从表的887万行中读取881万行。

如果启用了trace日志,那么ClickHouse服务日志文件显示,ClickHouse在1083个URL索引标记上使用了通用的排除搜索,以便识别那些可能包含URL列值为"http://public_search"的行。

...Executor): Key condition: (column 1 in ['http://public_search',
                                           'http://public_search'])
...Executor): Used generic exclusion search over index for part all_1_9_2
              with 1537 steps
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              1076/1083 marks by primary key, 1076 marks to read from 5 ranges
...Executor): Reading approx. 8814592 rows with 10 streams

我们可以在上面的跟踪日志示例中看到,1083个颗粒中有1076个(通过标记)被选中,因为可能包含具有匹配URL值的行。

这将导致881万行被读取到ClickHouse引擎中(通过使用10个流并行地读取),以便识别实际包含URL值"http://public_search"的行。

然而,稍后仅仅39个颗粒包含匹配的行。

虽然基于联合主键(UserID, URL)的主索引对于加快过滤具有特定UserID值的行的查询非常有用,但对于过滤具有特定URL值的行的查询,索引并没有提供显著的帮助。

原因是URL列不是第一个主键列,因此ClickHouse是使用一个通用的排除搜索算法(而不是二分查找)查找URL列的索引标志,和UserID主键列不同,它的算法的有效性依赖于URL列的基数。

为了说明,我们给出通用的排除搜索算法的工作原理:

下面将演示当通过第一个列之后的任何列选择颗粒时,当前一个键列具有或高或低的基数时,ClickHouse通用排除搜索算法 是如何工作的。

作为这两种情况的例子,我们将假设:

  • 搜索URL值为"W3"的行。
  • 点击表抽象简化为只有简单值的UserID和UserID。
  • 相同联合主键(UserID、URL)。这意味着行首先按UserID值排序,具有相同UserID值的行然后再按URL排序。
  • 颗粒大小为2,即每个颗粒包含两行。

在下面的图表中,我们用橙色标注了每个颗粒的最小键列值。

前缀主键低基数

假设UserID具有较低的基数。在这种情况下,相同的UserID值很可能分布在多个表行和颗粒上,从而分布在索引标记上。对于具有相同UserID的索引标记,索引标记的URL值按升序排序(因为表行首先按UserID排序,然后按URL排序)。这使得有效的过滤如下所述:

在上图中,我们的抽象样本数据的颗粒选择过程有三种不同的场景:

  1. 如果索引标记0的(最小)URL值小于W3,并且紧接索引标记的URL值也小于W3,则可以排除索引标记0,因为标记0、标记1和标记2具有相同的UserID值。注意,这个排除前提条件确保颗粒0和下一个颗粒1完全由U1 UserID值组成,这样ClickHouse就可以假设颗粒0中的最大URL值也小于W3并排除该颗粒。
  2. 如果索引标记1的URL值小于(或等于)W3,并且后续索引标记的URL值大于(或等于)W3,则选择索引标记1,因为这意味着粒度1可能包含URL为W3的行)。
  3. 可以排除URL值大于W3的索引标记2和3,因为主索引的索引标记存储了每个颗粒的最小键列值,因此颗粒2和3不可能包含URL值W3。

前缀主键高基数

当UserID具有较高的基数时,相同的UserID值不太可能分布在多个表行和颗粒上。这意味着索引标记的URL值不是单调递增的:

正如在上面的图表中所看到的,所有URL值小于W3的标记都被选中,以便将其关联的颗粒的行加载到ClickHouse引擎中。

这是因为虽然图中的所有索引标记都属于上面描述的场景1,但它们不满足前面提到的排除前提条件,即两个直接随后的索引标记都具有与当前标记相同的UserID值,因此不能被排除。

例如,考虑索引标记0,其URL值小于W3,并且其直接后续索引标记的URL值也小于W3。这不能排除,因为两个直接随后的索引标记1和2与当前标记0没有相同的UserID值。

请注意,随后的两个索引标记需要具有相同的UserID值。这确保了当前和下一个标记的颗粒完全由U1 UserID值组成。如果仅仅是下一个标记具有相同的UserID,那么下一个标记的URL值可能来自具有不同UserID的表行——当您查看上面的图表时,确实是这样的情况,即W2来自U2而不是U1的行。

这最终阻止了ClickHouse对颗粒0中的最大URL值进行假设。相反,它必须假设颗粒0可能包含URL值为W3的行,并被迫选择标记0。

同样的情况也适用于标记1、2和3。

结论

当查询对联合主键的一部分列(但不是第一个键列)进行过滤时,ClickHouse使用的通用排除搜索算法(而不是二分查找)在前一个键列基数较低时最有效

在我们的示例数据集中,两个键列(UserID、URL)都具有类似的高基数,并且,如前所述,当URL列的前一个键列具有较高基数时,通用排除搜索算法不是很有效。

看下跳数索引

因为UserID和URL具有较高的基数,根据URL过滤数据不是特别有效,对URL列创建二级跳数索引同样也不会有太多改善。

例如,这两个语句在我们的表的URL列上创建并填充一个minmax跳数索引。

ALTER TABLE hits_UserID_URL ADD INDEX url_skipping_index URL TYPE minmax GRANULARITY 4;
ALTER TABLE hits_UserID_URL MATERIALIZE INDEX url_skipping_index;

ClickHouse现在创建了一个额外的索引来存储—每组4个连续的颗粒(注意上面ALTER TABLE语句中的GRANULARITY 4子句)—最小和最大的URL值:

第一个索引条目(上图中的mark 0)存储属于表的前4个颗粒的行的最小和最大URL值。

第二个索引条目(mark 1)存储属于表中下一个4个颗粒的行的最小和最大URL值,依此类推。

(ClickHouse还为跳数索引创建了一个特殊的标记文件,用于定位与索引标记相关联的颗粒组。)

由于UserID和URL的基数相似,在执行对URL的查询过滤时,这个二级跳数索引不能帮助排除选择的颗粒。

正在寻找的特定URL值(‘http://public_search’)很可能是索引为每组颗粒存储的最小值和最大值之间的值,导致ClickHouse被迫选择这组颗粒(因为它们可能包含匹配查询的行)。

因此,如果我们想显著提高过滤具有特定URL的行的示例查询的速度,那么我们需要使用针对该查询优化的主索引。

此外,如果我们想保持过滤具有特定UserID的行的示例查询的良好性能,那么我们需要使用多个主索引。

下面是实现这一目标的方法。

使用多个主键索引进行调优

如果我们想显著加快我们的两个示例查询——一个过滤具有特定UserID的行,一个过滤具有特定URL的行——那么我们需要使用多个主索引,通过使用这三个方法中的一个:

  • 新建一个不同主键的新表
  • 创建一个物化视图
  • 增加projection

这三个方法都会有效地将示例数据复制到另一个表中,以便重新组织表的主索引和行排序顺序。

然而,这三个选项的不同之处在于,附加表对于查询和插入语句的路由对用户的透明程度

当创建有不同主键的第二个表时,查询必须显式地发送给最适合查询的表版本,并且必须显式地插入新数据到两个表中,以保持表的同步:

在物化视图中,额外的表被隐藏,数据自动在两个表之间保持同步

projection方式是最透明的选项,因为除了自动保持隐藏的附加表与数据变化同步外,ClickHouse还会自动选择最有效的表版本进行查询

下面我们使用真实的例子详细讨论下这三种方式。

通过辅助表使用联合主键索引

我们创建一个新的附加表,其中我们在主键中切换键列的顺序(与原始表相比):

CREATE TABLE hits_URL_UserID
(
    `UserID` UInt32,
    `URL` String,
    `EventTime` DateTime
)
ENGINE = MergeTree
PRIMARY KEY (URL, UserID)
ORDER BY (URL, UserID, EventTime)
SETTINGS index_granularity = 8192, index_granularity_bytes = 0;

写入887万行源表数据:

INSERT INTO hits_URL_UserID
SELECT * from hits_UserID_URL;

结果:

Ok.

0 rows in set. Elapsed: 2.898 sec. Processed 8.87 million rows, 838.84 MB (3.06 million rows/s., 289.46 MB/s.)

最后optimize下:

OPTIMIZE TABLE hits_URL_UserID FINAL;

因为我们切换了主键中列的顺序,插入的行现在以不同的字典顺序存储在磁盘上(与我们的原始表相比),因此该表的1083个颗粒也包含了与以前不同的值:

主键索引如下:

现在计算最频繁点击URL"http://public_search"的前10名用户,这时候的查询速度是明显加快的:

SELECT UserID, count(UserID) AS Count
FROM hits_URL_UserID
WHERE URL = 'http://public_search'
GROUP BY UserID
ORDER BY Count DESC
LIMIT 10;

结果:

┌─────UserID─┬─Count─┐
│ 2459550954 │  3741 │
│ 1084649151 │  2484 │
│  723361875 │   729 │
│ 3087145896 │   695 │
│ 2754931092 │   672 │
│ 1509037307 │   582 │
│ 3085460200 │   573 │
│ 2454360090 │   556 │
│ 3884990840 │   539 │
│  765730816 │   536 │
└────────────┴───────┘

10 rows in set. Elapsed: 0.017 sec.
Processed 319.49 thousand rows,
11.38 MB (18.41 million rows/s., 655.75 MB/s.)

现在没有全表扫描了,ClickHouse执行高效了很多。

对于原始表中的主索引(其中UserID是第一个键列,URL是第二个键列),ClickHouse在索引标记上使用了通用排除搜索来执行该查询,但这不是很有效,因为UserID和URL的基数同样很高。

将URL作为主索引的第一列,ClickHouse现在对索引标记运行二分搜索。ClickHouse服务器日志文件中对应的跟踪日志:

...Executor): Key condition: (column 0 in ['http://public_search',
                                           'http://public_search'])
...Executor): Running binary search on index range for part all_1_9_2 (1083 marks)
...Executor): Found (LEFT) boundary mark: 644
...Executor): Found (RIGHT) boundary mark: 683
...Executor): Found continuous range in 19 steps
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              39/1083 marks by primary key, 39 marks to read from 1 ranges
...Executor): Reading approx. 319488 rows with 2 streams

ClickHouse只选择了39个索引标记,而不是使用通用排除搜索时的1076个。

请注意,辅助表经过了优化,以加快对url的示例查询过滤的执行。

像之前我们查询过滤URL一样,如果我们现在对辅助表查询过滤UserID,性能同样会比较差,因为现在UserID是第二主索引键列,所以ClickHouse将使用通用排除搜索算法查找颗粒,这对于类似高基数的UserID和URL来说不是很有效

现在我们有了两张表。优化了对UserID和URL的查询过滤,分别:

通过物化视图使用联合主键

在原表上创建物化视图:

CREATE MATERIALIZED VIEW mv_hits_URL_UserID
ENGINE = MergeTree()
PRIMARY KEY (URL, UserID)
ORDER BY (URL, UserID, EventTime)
POPULATE
AS SELECT * FROM hits_UserID_URL;

结果:

Ok.

0 rows in set. Elapsed: 2.935 sec. Processed 8.87 million rows, 838.84 MB (3.02 million rows/s., 285.84 MB/s.)

NOTE

  • 我们在视图的主键中切换键列的顺序(与原始表相比)
  • 物化视图由一个隐藏表支持,该表的行顺序和主索引基于给定的主键定义
  • 我们使用POPULATE关键字,以便用源表hits_UserID_URL中的所有887万行立即导入新的物化视图
  • 如果在源表hits_UserID_URL中插入了新行,那么这些行也会自动插入到隐藏表中
  • 实际上,隐式创建的隐藏表的行顺序和主索引与我们上面显式创建的辅助表相同:

ClickHouse将隐藏表的列数据文件(.bin)、标记文件(.mrk2)和主索引(primary.idx)存储在ClickHouse服务器的数据目录的一个特殊文件夹中

腾讯Mini项目课程前置学习笔记(第二轮)_第1张图片

物化视图背后的隐藏表(和它的主索引)现在可以用来显著加快我们在URL列上查询过滤的执行速度:

SELECT UserID, count(UserID) AS Count
FROM mv_hits_URL_UserID
WHERE URL = 'http://public_search'
GROUP BY UserID
ORDER BY Count DESC
LIMIT 10;

结果:

┌─────UserID─┬─Count─┐
│ 2459550954 │  3741 │
│ 1084649151 │  2484 │
│  723361875 │   729 │
│ 3087145896 │   695 │
│ 2754931092 │   672 │
│ 1509037307 │   582 │
│ 3085460200 │   573 │
│ 2454360090 │   556 │
│ 3884990840 │   539 │
│  765730816 │   536 │
└────────────┴───────┘

10 rows in set. Elapsed: 0.026 sec.
Processed 335.87 thousand rows,
13.54 MB (12.91 million rows/s., 520.38 MB/s.)

物化视图背后隐藏表(及其主索引)实际上与我们显式创建的辅助表是相同的,所以查询的执行方式与显式创建的表相同。

ClickHouse服务器日志文件中相应的跟踪日志确认了ClickHouse正在对索引标记运行二分搜索:

...Executor): Key condition: (column 0 in ['http://public_search',
                                           'http://public_search'])
...Executor): Running binary search on index range ...
...
...Executor): Selected 4/4 parts by partition key, 4 parts by primary key,
              41/1083 marks by primary key, 41 marks to read from 4 ranges
...Executor): Reading approx. 335872 rows with 4 streams

通过projections使用联合主键索引

Projections目前是一个实验性的功能,因此我们需要告诉ClickHouse:

SET optimize_use_projections = 1;

在原表上创建projection:

ALTER TABLE hits_UserID_URL
    ADD PROJECTION prj_url_userid
    (
        SELECT *
        ORDER BY (URL, UserID)
    );

物化projection:

ALTER TABLE hits_UserID_URL
    MATERIALIZE PROJECTION prj_url_userid;

NOTE

  • 该projection正在创建一个隐藏表,该表的行顺序和主索引基于该projection的给定order BY子句
  • 我们使用MATERIALIZE关键字,以便立即用源表hits_UserID_URL的所有887万行导入隐藏表
  • 如果在源表hits_UserID_URL中插入了新行,那么这些行也会自动插入到隐藏表中
  • 查询总是(从语法上)针对源表hits_UserID_URL,但是如果隐藏表的行顺序和主索引允许更有效地执行查询,那么将使用该隐藏表
  • 实际上,隐式创建的隐藏表的行顺序和主索引与我们显式创建的辅助表相同:

ClickHouse将隐藏表的列数据文件(.bin)、标记文件(.mrk2)和主索引(primary.idx)存储在一个特殊的文件夹中(在下面的截图中用橙色标记),紧挨着源表的数据文件、标记文件和主索引文件:

腾讯Mini项目课程前置学习笔记(第二轮)_第2张图片

由投影创建的隐藏表(以及它的主索引)现在可以(隐式地)用于显著加快URL列上查询过滤的执行。注意,查询在语法上针对投影的源表。

SELECT UserID, count(UserID) AS Count
FROM hits_UserID_URL
WHERE URL = 'http://public_search'
GROUP BY UserID
ORDER BY Count DESC
LIMIT 10;

结果:

┌─────UserID─┬─Count─┐
│ 2459550954 │  3741 │
│ 1084649151 │  2484 │
│  723361875 │   729 │
│ 3087145896 │   695 │
│ 2754931092 │   672 │
│ 1509037307 │   582 │
│ 3085460200 │   573 │
│ 2454360090 │   556 │
│ 3884990840 │   539 │
│  765730816 │   536 │
└────────────┴───────┘

10 rows in set. Elapsed: 0.029 sec.
Processed 319.49 thousand rows, 1
1.38 MB (11.05 million rows/s., 393.58 MB/s.)

因为由投影创建的隐藏表(及其主索引)实际上与我们显式创建的辅助表相同,所以查询的执行方式与显式创建的表相同。

ClickHouse服务器日志文件中跟踪日志确认了ClickHouse正在对索引标记运行二分搜索:

...Executor): Key condition: (column 0 in ['http://public_search',
                                           'http://public_search'])
...Executor): Running binary search on index range for part prj_url_userid (1083 marks)
...Executor): ...
...Executor): Choose complete Normal projection prj_url_userid
...Executor): projection required columns: URL, UserID
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              39/1083 marks by primary key, 39 marks to read from 1 ranges
...Executor): Reading approx. 319488 rows with 2 streams

移除无效的主键列

带有联合主键(UserID, URL)的表的主索引对于加快UserID的查询过滤非常有用。但是,尽管URL列是联合主键的一部分,但该索引在加速URL查询过滤方面并没有提供显著的帮助。

反之亦然:具有复合主键(URL, UserID)的表的主索引加快了URL上的查询过滤,但没有为UserID上的查询过滤提供太多支持。

由于主键列UserID和URL的基数同样很高,过滤第二个键列的查询不会因为第二个键列位于索引中而受益太多。

因此,从主索引中删除第二个键列(从而减少索引的内存消耗)并使用多个主索引是有意义的

但是,如果复合主键中的键列在基数上有很大的差异,那么查询按基数升序对主键列进行排序是有益的。

主键键列之间的基数差越大,主键键列的顺序越重要

深入理解ClickHouse跳数索引

跳数索引

影响ClickHouse查询性能的因素很多。在大多数场景中,关键因素是ClickHouse在计算查询WHERE子句条件时是否可以使用主键。因此,选择适用于最常见查询模式的主键对于表的设计至关重要。

然而,无论如何仔细地调优主键,不可避免地会出现不能有效使用它的查询用例。用户通常依赖于ClickHouse获得时间序列类型的数据,但他们通常希望根据其他业务维度(如客户id、网站URL或产品编号)分析同一批数据。在这种情况下,查询性能可能会相当差,因为应用WHERE子句条件可能需要对每个列值进行完整扫描。虽然ClickHouse在这些情况下仍然相对较快,但计算数百万或数十亿个单独的值将导致**“非索引”查询的执行速度比基于主键的查询慢**得多。

在传统的关系数据库中,解决这个问题的一种方法是将一个或多个“二级”索引附加到表上。这是一个b-树结构,允许数据库在O(log(n))时间内找到磁盘上所有匹配的行,而不是O(n)时间(一次表扫描),其中n是行数。但是,这种类型的二级索引不适用于ClickHouse(或其他面向列的数据库),因为磁盘上没有单独的行可以添加到索引中。

相反,ClickHouse提供了一种不同类型的索引,在特定情况下可以显著提高查询速度。这些结构被标记为跳数索引,因为它们使ClickHouse能够跳过保证没有匹配值的数据块

基本操作

用户只能在MergeTree表引擎上使用数据跳数索引。每个跳数索引都有四个主要参数:

  • 索引名称。索引名用于在每个分区中创建索引文件。此外,在删除或具体化索引时需要将其作为参数。
  • 索引的表达式。索引表达式用于计算存储在索引中的值集。它可以是列、简单操作符、函数的子集的组合
  • 类型。索引的类型控制计算,该计算决定是否可以跳过读取和计算每个索引块。
  • GRANULARITY。每个索引块由颗粒(granule)组成。例如,如果主表索引粒度为8192行,GRANULARITY为4,则每个索引“块”将为32768行。

当用户创建数据跳数索引时,表的每个数据部分目录中将有两个额外的文件。

  • skpidx{index_name}.idx:包含排序的表达式值
  • skpidx{index_name}.mrk2:包含关联数据列文件中的相应偏移量

如果在执行查询并读取相关列文件时,WHERE子句过滤条件的某些部分与跳数索引表达式匹配,ClickHouse将使用索引文件数据来确定每个相关的数据块是必须被处理还是可以被绕过(假设块还没有通过应用主键索引被排除)。这里用一个非常简单的示例:考虑以下加载了可预测数据的表。

CREATE TABLE skip_table
(
  my_key UInt64,
  my_value UInt64
)
ENGINE MergeTree primary key my_key
SETTINGS index_granularity=8192;

INSERT INTO skip_table SELECT number, intDiv(number,4096) FROM numbers(100000000);

当执行一个不使用主键的简单查询时,将扫描my_value列所有的一亿条记录:

SELECT * FROM skip_table WHERE my_value IN (125, 700)

┌─my_key─┬─my_value─┐
│ 512000 │      125 │
│ 512001 │      125 │
│    ... |      ... |
└────────┴──────────┘

8192 rows in set. Elapsed: 0.079 sec. Processed 100.00 million rows, 800.10 MB (1.26 billion rows/s., 10.10 GB/s.

增加一个基本的跳数索引:

ALTER TABLE skip_table ADD INDEX vix my_value TYPE set(100) GRANULARITY 2;

通常,跳数索引只应用于新插入的数据,所以仅仅添加索引不会影响上述查询

要使已经存在的数据生效,那执行:

ALTER TABLE skip_table MATERIALIZE INDEX vix;

重跑SQL:

SELECT * FROM skip_table WHERE my_value IN (125, 700)

┌─my_key─┬─my_value─┐
│ 512000 │      125 │
│ 512001 │      125 │
│    ... |      ... |
└────────┴──────────┘

8192 rows in set. Elapsed: 0.051 sec. Processed 32.77 thousand rows, 360.45 KB (643.75 thousand rows/s., 7.08 MB/s.)

这次没有再去处理1亿行800MB的数据,ClickHouse只读取和分析32768行360KB的数据—4个granule的数据。

下图是更直观的展示,这就是如何读取和选择my_value为125的4096行,以及如何跳过以下行而不从磁盘读取:

腾讯Mini项目课程前置学习笔记(第二轮)_第3张图片

通过在执行查询时启用跟踪,用户可以看到关于跳数索引使用情况的详细信息。在clickhouse-client中设置send_logs_level:

SET send_logs_level='trace';

这将在尝试调优查询SQL和表索引时提供有用的调试信息。上面的例子中,调试日志显示跳数索引过滤了大部分granule,只读取了两个:

 default.skip_table (933d4b2c-8cea-4bf9-8c93-c56e900eefd1) (SelectExecutor): Index `vix` has dropped 6102/6104 granules.

跳数索引类型

minmax

这种轻量级索引类型不需要参数。它存储每个块的索引表达式的最小值和最大值(如果表达式是一个元组,它分别存储元组元素的每个成员的值)。对于倾向于按值松散排序的列,这种类型非常理想。在查询处理期间,这种索引类型的开销通常是最小的

这种类型的索引只适用于标量或元组表达式——索引永远不适用于返回数组或map数据类型的表达式

set

这种轻量级索引类型接受单个参数==max_size,即每个块的值集(==0允许无限数量的离散值)。这个集合包含块中的所有值(如果值的数量超过max_size则为空)。这种索引类型适用于每组颗粒中基数较低(本质上是“聚集在一起”)但总体基数较高的列。

该索引的成本、性能和有效性取决于块中的基数。如果每个块包含大量唯一值,那么针对大型索引集计算查询条件将非常昂贵,或者由于索引超过max_size而为空,因此索引将不应用

Bloom Filter Types

Bloom filter是一种数据结构,它允许对集合成员进行高效的是否存在测试,但代价是有轻微的误报。在跳数索引的使用场景,假阳性不是一个大问题,因为唯一的问题只是读取一些不必要的块。潜在的假阳性意味着索引表达式应该为真,否则有效的数据可能会被跳过。

因为Bloom filter可以更有效地处理大量离散值的测试,所以它们可以适用于大量条件表达式判断的场景。特别的是Bloom filter索引可以应用于数组,数组中的每个值都被测试,也可以应用于map,通过使用mapKeys或mapValues函数将键或值转换为数组。

有三种基于Bloom过滤器的数据跳数索引类型:

  • 基本的bloom_filter接受一个可选参数,该参数表示在0到1之间允许的“假阳性”率(如果未指定,则使用.025)。
  • 更专业的tokenbf_v1。需要三个参数,用来优化布隆过滤器:(1)**过滤器的大小字节(**大过滤器有更少的假阳性,有更高的存储成本),(2)哈希函数的个数(更多的散列函数可以减少假阳性)。(3)布隆过滤器哈希函数的种子。有关这些参数如何影响布隆过滤器功能的更多细节,请参阅 这里 。此索引仅适用于String、FixedString和Map类型的数据。输入表达式被分割为由非字母数字字符分隔的字符序列。例如,列值This is a candidate for a "full text" search将被分割为This is a candidate for full text search。它用于LIKE、EQUALS、in、hasToken()和类似的长字符串中单词和其他值的搜索。例如,一种可能的用途是在非结构的应用程序日志行列中搜索少量的类名或行号。
  • 更专业的ngrambf_v1。该索引的功能与tokenbf_v1相同。在Bloom filter设置之前需要一个额外的参数,即要索引的ngram的大小。一个ngram是长度为n的任何字符串,比如如果n是4,A short string会被分割为A sh`` sho, shor, hort, ort s, or st, r str, stri, trin, ring。这个索引对于文本搜索也很有用,特别是没有单词间断的语言,比如中文。

跳数索引函数

跳数索引核心目的是限制流行查询分析的数据量。鉴于ClickHouse数据的分析特性,这些查询的模式在大多数情况下都包含函数表达式。因此,跳数索引必须与常用函数正确交互才能提高效率。这种情况可能发生在:

  • 插入数据并将索引定义为一个函数表达式(表达式的结果存储在索引文件中)或者
  • 处理查询,并将表达式应用于存储的索引值,以确定是否排除数据块。

每种类型的跳数索引支持的函数列表可以查看 这里 。通常,集合索引和基于Bloom filter的索引(另一种类型的集合索引)都是无序的,因此不能用于范围。相反,最大最小值索引在范围中工作得特别好,因为确定范围是否相交非常快部分匹配函数LIKE、startsWith、endsWith和hasToken的有效性取决于使用的索引类型、索引表达式和数据的特定形状

跳数索引的配置

有两个可用的设置可应用于跳数索引。

  • use_skip_indexes (0或1,默认为1)。不是所有查询都可以有效地使用跳过索引。如果一个特定的过滤条件可能包含很多颗粒,那么应用数据跳过索引将导致不必要的、有时甚至是非常大的成本。对于不太可能从任何跳过索引中获益的查询,将该值设置为0。
  • force_data_skipping_indexes (以逗号分隔的索引名列表)。此设置可用于防止某些类型的低效查询。在某些情况下,除非使用跳过索引,否则查询表的开销太大,如果将此设置与一个或多个索引名一起使用,则对于任何没有使用所列索引的查询将返回一个异常。这将防止编写糟糕的查询消耗服务器资源。

最佳实践

跳数索引并不直观,特别是对于来自RDMS领域并且习惯二级行索引或来自文档存储的反向索引的用户来说。要获得任何优化,应用ClickHouse数据跳数索引必须避免足够多的颗粒读取,以抵消计算索引的成本。关键是,如果一个值在一个索引块中只出现一次,就意味着整个块必须读入内存并计算,而索引开销是不必要的。

考虑以下数据分布:

腾讯Mini项目课程前置学习笔记(第二轮)_第4张图片

假设主键/顺序是时间戳,并且在visitor_id上有一个索引。考虑下面的查询:

SELECT timestamp, url FROM table WHERE visitor_id = 1001

对于这种数据分布,传统的二级索引非常有利。不是读取所有的32678行来查找具有请求的visitor_id的5行,而是二级索引只包含5行位置,并且只从磁盘读取这5行。ClickHouse数据跳过索引的情况正好相反。无论跳转索引的类型是什么,visitor_id列中的所有32678值都将被测试。

因此,试图通过简单地向键列添加索引来加速ClickHouse查询的冲动通常是不正确的。只有在研究了其他替代方法之后,才应该使用此高级功能,例如修改主键(查看 如何选择主键)、使用投影或使用实体化视图。即使跳数索引是合适的,也经常需要对索引和表进行仔细的调优。

在大多数情况下,一个有用的跳数索引需要主键和目标的非主列/表达式之间具有很强的相关性。如果没有相关性(如上图所示),那么在包含数千个值的块中,至少有一行满足过滤条件的可能性很高,并且只有几个块会被跳过。相反,如果主键的值范围(如一天中的时间)与潜在索引列中的值强相关(如电视观众年龄),则最小值类型的索引可能是有益的。注意,在插入数据时,可以增加这种相关性,方法是在sort /ORDER by键中包含额外的列,或者以在插入时对与主键关联的值进行分组的方式对插入进行批处理。例如,即使主键是一个包含大量站点事件的时间戳,特定site_id的所有事件也都可以被分组并由写入进程插入到一起,这将导致许多只包含少量站点id的颗粒,因此当根据特定的site_id值搜索时,可以跳过许多块。

跳数索引的另一个候选者是高基数表达式,其中任何一个值在数据中都相对稀疏。一个可能的例子是跟踪API请求中的错误代码的可观察性平台。某些错误代码虽然在数据中很少出现,但对搜索来说可能特别重要。error_code列上的set skip索引将允许绕过绝大多数不包含错误的块,从而显著改善针对错误的查询。

最后,关键的最佳实践是测试、测试、再测试。同样,与用于搜索文档的b-树二级索引或倒排索引不同,跳数索引行为是不容易预测的。将它们添加到表中会在数据摄取和查询方面产生很大的成本,这些查询由于各种原因不能从索引中受益。它们应该总是在真实世界的数据类型上进行测试,测试应该包括类型、粒度大小和其他参数的变化。测试通常会暴露仅仅通过思考不能发现的陷阱。

选择低基数分区键

当您将插入语句(应包含许多行 - 请参阅上一节)发送到 ClickHouse Cloud 中的表,并且该表未使用分区键时,该插入中的所有行数据都将写入存储上的新部分:

腾讯Mini项目课程前置学习笔记(第二轮)_第5张图片

但是,当您将插入语句发送到 ClickHouse Cloud 中的表,并且该表具有分区键时,ClickHouse 将:

  • 检查插入项中包含的行的分区键值
  • 每个不同的分区键值在存储上创建一个新部件
  • 通过对键值进行分区将行放在相应的部分中

腾讯Mini项目课程前置学习笔记(第二轮)_第6张图片

因此,要最大程度地减少对 ClickHouse Cloud 对象存储的写入请求数量,请使用低基数分区键或避免对表使用任何分区键。

Asynchronous Inserts (async_insert)

将数据大批量插入 ClickHouse 是最佳实践。它节省了计算周期和磁盘 I/O,因此节省了资金。如果您的用例允许您在 ClickHouse 外部批量插入,那么这是一种选择。如果您希望 ClickHouse 创建批处理,则可以使用此处描述的异步插入模式。

使用异步插入作为在客户端批处理数据并通过启用async_insert设置将插入速率保持在每秒一个插入查询左右的替代方法。这会导致 ClickHouse 在服务器端处理批处理。

默认情况下,ClickHouse同步写入数据。发送到 ClickHouse 的每个插入都会导致 ClickHouse 立即创建一个包含插入中的数据的部件。这是将async_insert设置设置为默认值 0 时的默认行为:

腾讯Mini项目课程前置学习笔记(第二轮)_第7张图片

通过将 async_insert 设置为 1,ClickHouse 首先将传入的插入内容存储到内存缓冲区中,然后再定期将它们刷新到磁盘

有三种可能的情况会导致 ClickHouse 将缓冲区刷新到磁盘:

  • 缓冲区大小已达到 N KB(N 可通过 async_insert_max_data_size 进行配置)
  • 上次缓冲区刷新以来至少已经过去了 N 秒(N 可通过 async_insert_busy_timeout_ms 进行配置)
  • 每个块至少收到 N 个插入查询(N 可通过 async_insert_max_query_number 进行配置)

每次满足上述任何条件时,ClickHouse 都会将其内存缓冲区刷新到磁盘。

注意

数据写入存储部件后,您的数据可用于读取查询。当您想要修改async_insert_busy_timeout_ms(默认值:云中的 1 秒)或async_insert_max_data_size(默认值:100KB)设置时,请记住这一点。

使用 wait_for_async_insert 设置,您可以配置是希望 insert 语句在数据插入缓冲区后立即返回确认 (wait_for_async_insert = 0),还是默认在从缓冲区刷新数据后将数据写入部件后返回确认 (wait_for_async_insert = 1)。

以下两个图说明了async_insert和wait_for_async_insert的两个设置:

腾讯Mini项目课程前置学习笔记(第二轮)_第8张图片

腾讯Mini项目课程前置学习笔记(第二轮)_第9张图片

启用异步插入

可以为特定用户或特定查询启用异步插入:

  • 在用户级别启用异步插入。此示例使用用户default,如果创建其他用户,则替换该用户名:

    ALTER USER default SETTINGS async_insert = 1
    
  • 可以使用插入查询的 SETTINGS 子句指定异步插入设置:

    INSERT INTO YourTable SETTINGS async_insert=1, wait_for_async_insert=1 VALUES (...)
    
  • 您还可以在使用 ClickHouse 编程语言客户端时将异步插入设置指定为连接参数。

    例如,当您使用 ClickHouse Java JDBC 驱动程序连接到 ClickHouse Cloud 时,您可以在 JDBC 连接字符串中执行此操作:

    "jdbc:ch://HOST.clickhouse.cloud:8443/?user=default&password=PASSWORD&ssl=true&custom_http_params=async_insert=1,wait_for_async_insert=1"
    

    如果使用异步插入,我们强烈建议使用 async_insert=1,wait_for_async_insert=1。使用 wait_for_async_insert=0 是非常危险的,因为您的 INSERT 客户端可能不知道是否存在错误,并且如果您的客户端在 ClickHouse 服务器需要减慢写入速度并产生一些背压以确保服务可靠性的情况下继续快速写入,也可能导致潜在的过载。

使用异步插入时禁用自动重复数据删除

手动批处理(请参阅上一节))的优点是,如果(确切地)将相同的插入语句多次发送到 ClickHouse Cloud,例如,由于某些临时网络连接问题,客户端软件中的自动重试,它支持表数据的内置自动重复数据删除。

异步插入不支持表数据的这种内置自动重复数据删除。

Bulk Inserts

批量引入数据

默认情况下,发送到 ClickHouse 的每个插入都会导致 ClickHouse 立即在存储上创建一个部件,其中包含插入中的数据以及需要存储的其他元数据。因此,与发送大量插入(每个插入包含较少数据)相比,发送较少数量的插入(每个插入包含较少数据)将减少所需的写入次数。通常,我们建议以相当大的批次(一次至少 1,000 行)插入数据,理想情况下在 10,000 到 100,000 行之间。为此,请考虑实现缓冲区机制,例如使用缓冲区表引擎启用批处理插入,或使用异步插入(请参阅异步插入).

提示

无论您的插入大小如何,我们都建议将插入查询的数量保持在每秒一个插入查询左右。该建议的原因是创建的部件在后台合并到较大的部件(为了优化读取查询的数据),并且每秒发送过多的插入查询可能会导致后台合并跟不上新部件的数量。但是,使用异步插入时,可以使用更高的每秒插入查询速率(请参阅异步插入).

你可能感兴趣的:(腾讯mini项目,go,clickhouse,kafka)