少读放大:列为什么更薄,整叠为什么可以不读
这一页是整体讲解 · 少读的放大。少读已经按漏斗说过:先扔整天,再扔整叠,最后只开用到的列。这里把后两步里容易混成一件事的两样机制拆开。
主张:压缩回答「已经决定要读的列,盘上为什么更薄」。跳过回答「哪些叠可以整叠不打开」。两件事都发生在把 fee 送进 CPU 之前。
按「少掉的是什么」分成两格。第一格少掉字节。第二格少掉整叠行。
仍用同一句查询。表按天分成一本本 part,行按 (date, slot_id) 排好序。文中的行数是教学用量级,用来把漏斗的每一步对齐。
SELECT sum(fee) FROM ads_click WHERE date = '2026-10-11' AND slot_id = 1001 AND campaign_id = 99
1. 少掉字节:打开的列在盘上更薄
主张:同列的值排在一起,所以能压扁。压扁只减少要读的字节,不决定打开哪一列。
这句 SQL 只用 date、slot_id、campaign_id、fee。列存为每列单独放一个文件,其余 76 列的文件不打开。打开的四列里,同一列的值种类少、形状整齐,压完之后盘上比明文短。查询读到的是压扁之后的那段,解压之后才拿去过滤和求和。
边界。先决定打开哪四列,再谈这四列有多薄。整天的 part 放不放下,是少读漏斗的第一步,这一页从「留下的那一天」讲起。
2. 少掉整叠:先证明不可能命中,再决定打不打开
主张:对每一叠先做一次便宜的判断。能证明「这叠里不可能有要的行」,这一整叠的列文件都不读。
一天若有 1 亿行,默认大约每 8192 行成一叠,大约一万多叠。不可能把每一叠都打开再看有没有计划 99。引擎用两种已经准备好的摘要来证明。
2.1 排序目录先把范围收成一段
主张:行按 (date, slot_id) 排好,目录只记每叠第一行的键,所以能把「这一天且广告位 1001」收成连续的大约 20 叠。
这 20 叠大约 16 万行,是排序自己带来的。目录问的是:这一段排好的键,对不对得上昨天和 1001。对不上的叠到此为止。
2.2 目录够不着的列,用最小和最大来证明
主张:campaign_id 不在排序键里。写入时为每一叠记下计划号的最小值和最大值,查询只用这两个数判断 99 会不会出现。
最小值 1、最大值 15,99 夹不进去,这一叠不读。最小值 90、最大值 110,99 可能在里面,这一叠才打开,解压后再逐值过滤。若某一叠里计划号从 1 散到 10000,两个数夹得住几乎所有查询,这张便利贴就扔不掉它。图里绿色是看完两个数就放下的叠,橙色是还要读盘的叠。
2.3 值很散时,改问「这叠里一定没有」
主张:像 user_id 这种几乎每人不同的列,一叠里的最小值和最大值会拉得很开,范围判断扔不掉叠。换成一张小图:能证明某个 ID 一定不在,才把整叠放下。
写入时把这一叠里的用户号哈希进一张位图。查询 user_id = 555 时,只看 555 对应的那几位。有一位是空的,这一叠不可能有 555,用户号和费用的文件都不打开。几位碰巧都亮着,只能说可能在,仍然要读盘再逐值比较。这张图会冤枉一些实际没有 555 的叠,不会把真正有 555 的叠判成没有。它也不告诉你 555 在第几行。
2.4 两种证明,做的是同一件事
主张:最小最大和「一定不在」都是跳数索引里的一种证明。跳数索引是总称。它们和排序目录都能扔掉整叠,准备方式和提问方式不同。
排序目录跟着 ORDER BY 来,不用另建。跳数索引要在建表时写上,在写入每一本 part 时按叠算好,放进一份很小的旁路文件。查询先读这份摘要,再决定打不打开列文件。本页每张便利贴大约盖住一叠;一张贴盖住几叠,是另一个参数,放在文末对照里。下图把两种证明摆在同一排叠上:绿的整叠不读,橙的还要解压后精确过滤。
2.5 便利贴在写入时算好,查询时只读这张摘要
主张:最小最大和「一定不在」都不是查询时把整列重算一遍。写入一本 part 时就算好,查询只拿这份小摘要做判断。
插入数据或后台把小本合并成大本时,引擎按叠扫 campaign_id 或 user_id,把结果写进旁路文件。查询昨天的费用时,先读排序目录和这些便利贴,跳过的叠连压扁的列文件都不打开。打开的只有没被放下的那些叠,而且只打开查询用到的列。下面两张图把「写入时记下什么」和「查询时怎么用」拆开。
名称对照:建表写法与参数
排序目录来自 ORDER BY,文件 primary.idx。便利贴来自 INDEX … TYPE …,文件 skp_idx_*.idx。已有数据要补便利贴时,先 ADD INDEX,再 MATERIALIZE INDEX。查询可能从缓存读这些摘要,也可能流式读,不要求一次把整份载入。
CREATE TABLE ads_click
(
date Date,
slot_id UInt32,
campaign_id UInt32,
user_id UInt64,
fee Float64,
INDEX idx_campaign campaign_id TYPE minmax GRANULARITY 1,
INDEX idx_user user_id TYPE bloom_filter(0.01) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY date
ORDER BY (date, slot_id)
SETTINGS index_granularity = 8192;
| 名字 | 对应上面哪一句 |
|---|---|
index_granularity = 8192 |
一叠大约多少行。还受 index_granularity_bytes 约束 |
GRANULARITY 1 |
一张便利贴大约盖住一叠。N 更大时,一张贴盖住更多叠,一次放下的块也更大 |
minmax / bloom_filter(0.01) |
两种 TYPE。0.01 是这张「一定不在」图大约 1% 的冤枉读盘;图越小,冤枉越多 |
set、tokenbf_v1、ngrambf_v1 |
同一类便利贴的另几种证明。枚举值用集合;字符串子串用后两种。版本支持范围不同 |
2.5.1 最小最大:写入时记下两个数
写入 campaign_id 时,按叠只留下最小值和最大值。查询 campaign_id = 99 时,最大值还小于 99,或最小值已经大于 99,整叠跳过;99 落在两个数之间,才打开压缩块再精确过滤。campaign_id % 10 = 3 这种条件用不上这两个数。后台合并出新本时,会按新的叠重算一遍。
2.5.2 「一定不在」:写入时点亮几位
同样在写 part 时算。每一叠不存最小值和最大值,而是把这叠里的 user_id 哈希进一张小位图。查询只探测 555 需要的那几位:有空位就整叠不读,位都亮着才读盘。范围条件如 user_id > 1000 用不上这张图。字符串要找子串时,用的是同一类便利贴里另一种 TYPE,名字在上面的对照里。
2.6 哪一种证明更容易把整叠放下
主张:选哪一种,只看这列在一叠里长什么样,哪套测试更容易证明「不可能命中」。
已经在 ORDER BY 里的 date、slot_id,排序目录已经在收范围,不必再贴一张同样的便利贴。一叠里比较扎堆的值,例如计划号、状态码,两个数就够把许多叠放下。几乎每人不同的 ID,范围会拉满,改问「一定不在」。一叠里只出现少数几个枚举值时,直接记下这叠出现过的集合,比拉一个从最小到最大的范围更贴。要在字符串里找一段子串,整值哈希对不上这种条件,改用按词或按字符片段建的那类图。
数字是教学用量级:一天 1 亿行、一叠约 8192 行、广告位 1001 约 20 叠、计划 99 再剩 2 到 3 叠。用来对齐上面的漏斗。