ClickHouse 整体怎么记
1. 纲领
四层各管查询路径上的一段。先读每层开头的主张;层里的格回答的是同一个问题的不同侧面。
2. 贯穿例子
下面每一层都用同一句查询来钉住。表 ads_click 有 80 列,一天几十亿行。这句只要昨天、一个广告位、一个计划的费用合计,真正用到的只有 date、slot_id、campaign_id、fee 四列。
SELECT sum(fee) FROM ads_click WHERE date = yesterday() AND slot_id = 1001 AND campaign_id = 99
对照着看:行式存储常常把命中行里的 80 列一起搬出来。这里从读盘开始就只碰这四列。
3. 四层路径
一次查询从左往右走完四层:先少碰字节,再让留下的字节连着读,再在内存里算成 sum(fee),全程不为「改某一行并立刻加锁」付钱。图只标出顺序,每一层的机制在下面展开。
4. 少读
主张:在任何加法发生之前,先少打开东西。表按天分成许多本,每本里的行再分成许多叠,每叠里每列各是一个文件。这一层只回答:这句查询哪些天、哪些叠、哪些列可以不打开。
按「少打开哪一种东西」分成三格。三格是漏斗:先扔整天,再在留下的那天里扔整叠,最后在留下的叠里只开用到的列。
4.1 先扔整天
主张:查询只要昨天时,前天那一本文件夹一次都不打开。
ads_click 按 date 分成一天一本。每本上记着自己的日期范围。引擎先看这个范围:对不上昨天的本,里面的列文件都不碰。这一格发生在打开任何一列之前。
4.2 再扔整叠
主张:在留下的那一本里,先证明某一叠不可能含有要的行,这一整叠就不用读。
行在本子里按 (date, slot_id) 排好序,同一天、同一广告位挤在一起。稀疏主键像章节目录,只记下每叠第一行的键,所以能把「昨天且广告位 1001」收成连续的一段叠,其余叠放下。
campaign_id 不在这组排序键里,目录帮不上。写入时给每一叠另贴一张便利贴,记下这叠计划号的最小值和最大值。查询要 99:最小值到最大值里夹不住 99,整叠不读。排序目录和这张便利贴都能扔叠,问的问题不同。目录问「这一段排好的键对不对得上」;便利贴问「这叠里有没有可能出现 99」。
4.3 最后只开用到的列
主张:决定要读的那些叠里,只打开这句查询用到的列。压缩让每一列在盘上更薄,不决定打开哪一列。
80 列里只用到四列,其余 76 列的文件不打开。打开的四列里,同列的值排在一起,压完之后盘上的字节更少。解压之后,才在叠里面做精确的 WHERE,把仍然对不上的行去掉。
边界。解压后立刻过滤,仍是少读:少做后面的计算。一条指令同时加多个 fee,归批算。这些字节在盘上是否连成一段,归顺读。
名称对照(读完上面再查)
一天一本叫 part。一叠默认约 8192 行,叫 granule;叠有多大还受 index_granularity_bytes 约束。每本上的日期范围在 minmax_date.idx,那是分区表达式自己的最小最大,和用户建的跳数索引各是一份文件。排序目录是 primary.idx。便利贴是跳数索引,文件 skp_idx_*.idx;minmax、布隆是它的两种 TYPE。一张便利贴盖住几叠,看 GRANULARITY N,N=1 时大约一叠一张。列文件是 .bin。
5. 顺读
主张:少读已经决定要读哪些字节之后,这一层让这些字节在盘上连成一段。读的时候少回头,磁盘可以一直往前吐。
按「为什么能连着读」分成三格。三格叠在一起:写入时不改旧文件,排序把要的行挤到一起,后台再把许多小文件订成少数大文件。
5.1 写入只往后追加
主张:新数据写成一本新的、写完就不再改的文件。查询读的是已经写完的文件。
一次 INSERT 落成一本新 part,不回头改昨天那本里的某一行。所以读昨天的 fee 时,文件内容是稳定的,扫描可以一路向前。
5.2 排序把要的行挤在一起
主张:ORDER BY (date, slot_id) 让同一广告位的行挨着放,过滤约等于扫一段连续字节。
广告位 1001 的点击若散落在文件各处,每命中一次就要重新定位。排好序之后,少读留下的那一段叠在盘上是相连的,定位一次,后面是大段顺序读。
5.3 后台把小文件订成大文件
主张:许多本小 part 会在后台合并成少数大 part,顺序扫描的段落因此更长。
每次插入都新开一本,文件会越来越多,每本都要单独开头。合并把同一天的小本订成大本,要读的 fee 更少被切成碎片。
边界。定位到某一叠,仍然要先跳到文件里的那个位置;快是因为跳完之后是大段顺扫。改历史数据会重写受影响的那一本,那是别挡里「更新要重写」的事。
名称对照(读完上面再查)
一本写完不再改的文件目录叫 part。后台合并叫 merge。很小的 part 可以把各列收在同一个文件里,大了再拆成一列一个文件;分界看 min_bytes_for_wide_part 和 min_rows_for_wide_part。这只影响一本内部怎么摆列,不改变「要读的行排在一起」这件事。
6. 批算
主张:列块已经在内存里之后,CPU 要做的事是把这些数算完,并且让多个核、多台机器同时算。少读和顺读已经决定读哪些字节;这一层只回答这些字节怎样变成 sum(fee)。
按「计算怎么摊开」分成两格。第一格问一颗核一次算多少个数。第二格问这些数分给哪些核、哪些机器。两格回答的问题不同。
6.1 单核怎么算
主张:一次调度处理一整段同一列的值,一条指令同时加其中的多个数。
fee 进内存后是排好的一串数。引擎把这一串切成块,块在过滤、求和这些步骤之间传递。求 sum(fee) 时,一块里的几个数放进 CPU 的宽寄存器,一条加法指令一起加完。若拆成一行一个对象再加,每加一个数都要重新进入函数,时间花在调度上。这一格要记住的原理是:一次调度摊到一整块数上。
6.2 谁和谁一起算
主张:同一条查询把互不重叠的数据分给多个执行者,各自先算出部分结果,再合成一个答案。
一颗核只有一条计算流水线。昨天的 fee 被切成互不重叠的几段,每段交给一个核,各得到一个部分和,最后把部分和相加,仍是同一个 sum(fee)。部分和越晚合并,并行保持得越久。
数据若已经拆在多台机器上,执行者从「核」换成「节点」,算法不变:每台先算自己磁盘上的和,收到查询的那台再把这些和加起来。网上传送的是算完的和,fee 列留在各自的磁盘上。
边界。用主键和跳数扔掉整段 granule,发生在这些数进入计算之前,归少读。跨分片把另一张表的行拉过来做 JOIN,归别挡。物化视图是查询发出之前就把这种求和做完,看板读的是算好的小表。
名称对照(读完上面再查)
内存里的一块叫 Block,其中一列是 IColumn,整数列就是连续数组。一条指令同时处理多个数,是 SIMD;运行时按 CPU 选择编译好的内核。多个核各算一段,在计划里叫泳道,条数上限是 max_threads(默认约等于核数,数据少就少开)。把各段的部分和加成总数的算子是 GroupStateMerge。多台机器上,Distributed 表把 sum(fee) 推到各分片做本地和,发起节点再加一次。ClickHouse Cloud 里,并行副本扮演分片这个角色。
7. 别挡
主张:分析查询在跑的时候,不为「改某一行、立刻对所有人可见、并和别的写入互相锁住」这条路径付钱。少读、顺读、批算都假定数据以追加和快照的方式待在那里。
按「省下了哪种代价」分成三格。一格是读写不必按行互锁。一格是改历史要重写整本,而不是改一个格子。一格是计算留在数据旁边,少把行搬到另一台机器上再算。
7.1 读写不必按行互锁
主张:正在插入的新行和正在跑的查询,各看各的数据,不用为了某一行互相等待。
INSERT 写成一本新 part。SELECT 开始时拿一份「现在有哪些本」的快照,只读这些已经写完的本。新写入的行不插进这条正在跑的 sum(fee),查询也不把正在插入的行锁住。后台合并小本、重写历史,仍会占用 CPU、磁盘和内存。省下的是行与行之间的等待。
7.2 改历史要重写整本
主张:要改正文里已经写下的行,默认是重写受影响的那一本,而不是只改其中一个格子。
传统的更新会把涉及到的 part 按新内容再写一遍。后来可以用轻量删除:先在内部标出哪些行作废,查询时跳过它们,等后台合并时再物理去掉。两种都做得到修改。代价是重写整本,或者先打标记、等合并时再去掉。
7.3 计算留在数据旁边
主张:sum(fee) 在存放这些行的机器上算完,再把和传出去。把另一张表的行拉过来做 JOIN,要在机器之间搬行。
所以分析表常常做成宽表:查询要用的列放在同一张表里,少做跨表、跨机器的拼接。多核一起加 fee 仍是批算;这里说的是行本身要不要离开它的磁盘。
边界。多个核各算一段部分和,归批算。少打开 76 列,归少读。服务进程是一个 clickhouse-server 加线程池,许多查询分用这些线程;这是运行形态,不改变上面三格的代价。
名称对照(读完上面再查)
查询开始时固定下来的那份 part 列表,是基于版本化 part 的快照。重写历史的操作叫 mutation。轻量删除先写一张作废标记,合并时再物理删除。跨分片把行拉去 JOIN,属于这里的「搬行」;各分片本地算完再加总,属于批算。
8. 各层对照放在哪
文件名、默认行数、参数都在对应层的「名称对照」里。它们帮助把上面的说法对上官方名词,不另成一层口诀。
9. 细图与磁盘文件
少读放大:列为什么更薄,整叠为什么可以不读 — 压缩与跳过各回答一个问题。
MergeTree part 里常见文件(查阅用)
20261011_1_1_0/ ├── partition.dat ├── minmax_date.idx # 分区表达式 part 级 min/max ├── primary.idx # 稀疏主键 ├── date.bin / date.mrk2 ├── … 各列 .bin + .mrk2 ├── skp_idx_idx_campaign.idx # 跳数 INDEX 名 idx_campaign └── checksums.txt
查询按需用 primary.idx 与 WHERE 对得上的 skp_idx_*.idx,再打开没被扔掉的列 .bin。不是 80 列各一份 idx,也不是表上全部 INDEX。
10. 自测
1. 四口诀各管路径上哪一段?
少读:先扔掉整天和整叠,再只打开用到的列。顺读:留下的字节在盘上连成一段。批算:进内存之后按块算,并可分给多个核、多台机器。别挡:不为改一行、按行加锁付钱。
2. 稀疏主键和跳数索引差在哪?
主键是按 (date, slot_id) 排好序之后自带的目录,把昨天且广告位 1001 收成一段叠。跳数索引是另外贴上的便利贴,用来证明某一叠里不可能出现计划 99。两样都能让整叠不读,问的问题不同。
3. 批算两张图各说什么?
左:一颗核一次加一整段 fee。右:多个核或多台机器各算一部分的和,再加成同一个总数。扔掉 granule 归少读;跨分片 JOIN 归别挡。
4. 第二页和本页什么关系?
那一页把本页「少读」里的两件事画开:打开的列为什么更薄,以及怎样证明一整叠可以不读。它仍是少读,不另成第五层。