少读放大:列为什么更薄,整叠为什么可以不读

这一页是整体讲解 · 少读的放大。少读已经按漏斗说过:先扔整天,再扔整叠,最后只开用到的列。这里把后两步里容易混成一件事的两样机制拆开。

主张:压缩回答「已经决定要读的列,盘上为什么更薄」。跳过回答「哪些叠可以整叠不打开」。两件事都发生在把 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 列的文件不打开。打开的四列里,同一列的值种类少、形状整齐,压完之后盘上比明文短。查询读到的是压扁之后的那段,解压之后才拿去过滤和求和。

不打开 整叠跳过 这一叠要读 压扁之后的字节
行式:命中行也要搬整行 80 列 date slot campaign fee os ip ua json …(其余 72 列也在这一行里) 磁盘吐出:整行宽度 × 命中行数。只要 fee,UA/JSON 也跟着来。 列存:只打开 4 个列文件 os ip ua … 76 列不打开 要读的 4 列:明文块 → 盘上压缩块 date 压 slot 压 campaign 压 fee 压 同列才好压:os 几乎只有 android/ios,fee 都是数。盘上读的是蓝块高度,不是橙块高度。
左边灰的是不打开的列。右边橙块是明文高度,蓝块是盘上实际要读的高度。

边界。先决定打开哪四列,再谈这四列有多薄。整天的 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,两个数夹得住几乎所有查询,这张便利贴就扔不掉它。图里绿色是看完两个数就放下的叠,橙色是还要读盘的叠。

全部 granule(示意,不是 12207 个格子) 灰:主键对不上,不读。橙:date=昨天 且 slot=1001 的连续一段,约 20 个 granule。 这 20 个 granule 的 campaign_id 便利贴(minmax) G1 跳过 行 0–8191 min=1 max=15 99 不可能在 不读盘 G2 跳过 行 8192–16383 min=20 max=80 99 不可能在 不读盘 G3 要读 行 16384–24575 min=90 max=110 99 可能在 解压后再精确过滤 G4 要读 行 24576–32767 min=99 max=99 这段全是 99 读 campaign + fee G5 跳过 行 32768–40959 min=200 max=800 99 不可能在 不读盘 漏斗:1 亿行 → 主键约 16 万行(20 granule)→ minmax 约 2 万行(2~3 granule)→ 解压后精确 WHERE → sum(fee) 条越来越短:全表 → 主键命中 → 真正读盘。若某 granule 里 campaign 从 1 到 10000 都有,minmax 失效,这一叠必须扫。
上条是排序目录留下的一段。下面五张卡片是这段里计划号的最小和最大:夹不住 99 的不读盘。

2.3 值很散时,改问「这叠里一定没有」

主张:像 user_id 这种几乎每人不同的列,一叠里的最小值和最大值会拉得很开,范围判断扔不掉叠。换成一张小图:能证明某个 ID 一定不在,才把整叠放下。

写入时把这一叠里的用户号哈希进一张位图。查询 user_id = 555 时,只看 555 对应的那几位。有一位是空的,这一叠不可能有 555,用户号和费用的文件都不打开。几位碰巧都亮着,只能说可能在,仍然要读盘再逐值比较。这张图会冤枉一些实际没有 555 的叠,不会把真正有 555 的叠判成没有。它也不告诉你 555 在第几行。

G3 布隆:一定不在 bitmap 里三个哈希位都对不上 555 结论:这一叠没有 555 动作:不读 user_id.bin / fee.bin 不会漏掉真进行(证伪) G7 布隆:可能在 哈希位碰巧全亮(或真有 555) 结论:不能扔掉 动作:读盘,逐值比较 允许误报,多扫一点 布隆不做什么 不告诉你 555 在第几行 不能当主键用 只负责快速扔掉「一定没有」的叠 跳过的叠连压扁的列文件都不读。要读的那几块,仍以压扁后的字节读入,解压后再精确过滤。
左卡能证明一定没有,整叠不读。中卡只能说可能有,要读盘。右卡说明这张图不负责指出行号。

2.4 两种证明,做的是同一件事

主张:最小最大和「一定不在」都是跳数索引里的一种证明。跳数索引是总称。它们和排序目录都能扔掉整叠,准备方式和提问方式不同。

排序目录跟着 ORDER BY 来,不用另建。跳数索引要在建表时写上,在写入每一本 part 时按叠算好,放进一份很小的旁路文件。查询先读这份摘要,再决定打不打开列文件。本页每张便利贴大约盖住一叠;一张贴盖住几叠,是另一个参数,放在文末对照里。下图把两种证明摆在同一排叠上:绿的整叠不读,橙的还要解压后精确过滤。

本页示例 GRANULARITY 1:一贴约一叠 granule G1 ~8192行 G2 ~8192行 G3 ~8192行 G4 ~8192行 G5 ~8192行 minmax 问:99 会落在 [min,max] 里吗?不在 → 滤掉这一块 滤掉 G1 滤掉 G2 留下 G3 留下 G4 滤掉 G5 布隆问:555 一定不在这叠吗?一定不在 → 同样滤掉这一块 滤掉 G1 留下 G2 滤掉 G3 留下 G4 滤掉 G5 绿 = 整块不读压缩列文件。橙 = 这块还要解压,再在块内按行精确过滤。N=1 时一块≈一叠;两种索引都不会在这一步定位到某一行。
同一排叠上,两种证明都可以把整叠放下。这一步只决定读不读,不指出行在叠里的第几行。

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 这种条件用不上这两个数。后台合并出新本时,会按新的叠重算一遍。

写 part:对 campaign_id 这一列按叠统计 INSERT 进来的 campaign_id:1,3,8,…,15 | 20…80 | 90…110 | 99,99,… | 200…800 ↓ 本页 GRANULARITY 1:按默认约 8192 行切开,每叠算两个数,写入 skp_idx_idx_campaign.idx G1 min=1 max=15 G2 min=20 max=80 G3 min=90 max=110 G4 min=99 max=99 G5 min=200 max=800 SELECT 时:WHERE campaign_id = 99 先读上面 5 对整数。规则:max < 99 或 min > 99 → 跳过整叠;min ≤ 99 ≤ max → 打开压缩块再精确过滤。 G1 跳过 G2 跳过 G3 读盘 G4 读盘 G5 跳过 适用:=、IN、>、BETWEEN。campaign_id % 10 = 3 这种用不上 minmax。 若一叠里值从 1 到 10000 都有,算出来仍是 min=1 max=10000,设置了也跳不动。 后台 merge 把小 part 合成大 part 时,会按新的叠重算 min/max。
上半是写入时留下的两个数,下半是查询 99 时哪些叠还要读盘。

2.5.2 「一定不在」:写入时点亮几位

同样在写 part 时算。每一叠不存最小值和最大值,而是把这叠里的 user_id 哈希进一张小位图。查询只探测 555 需要的那几位:有空位就整叠不读,位都亮着才读盘。范围条件如 user_id > 1000 用不上这张图。字符串要找子串时,用的是同一类便利贴里另一种 TYPE,名字在上面的对照里。

写 part:user_id 叠进布隆图 G3 的约 8192 个 user_id 对每个 ID 做 k 次哈希,点亮 bitmap 对应位 图:■ □ ■ ■ □ ■ □ □ … 555 的三位里有一位是 □ → 写入「555 不在这叠」的能力 文件:skp_idx_idx_user.idx G7 的约 8192 个 user_id 同样点亮一批位(可能含 555,或只是撞哈希) 图:■ ■ ■ ■ □ ■ ■ □ … 555 需要的位碰巧全是 ■ 查询时只能说「可能在」 SELECT user_id = 555:先问图,再决定读不读 .bin G3:需要的位有 □ → 一定不在 不打开 user_id.bin / fee.bin 这一段 按设计不应漏掉真有 555 的叠 G7:需要的位全 ■ → 可能在 读压缩块,逐值比较(可能误报) 0.01 表示大约 1% 的冤枉读盘 布隆吃 =、IN;user_id > 1000 这种范围条件用不上。字符串整值哈希用 bloom_filter;LIKE '%x%' 才用 tokenbf_v1 / ngrambf_v1。 跳过的叠连压扁的列文件都不读。要读的叠仍先解压,再精确过滤。
写入时点亮位图。查询时只看 555 对应的几位,决定这叠打不打开。

2.6 哪一种证明更容易把整叠放下

主张:选哪一种,只看这列在一叠里长什么样,哪套测试更容易证明「不可能命中」。

已经在 ORDER BY 里的 date、slot_id,排序目录已经在收范围,不必再贴一张同样的便利贴。一叠里比较扎堆的值,例如计划号、状态码,两个数就够把许多叠放下。几乎每人不同的 ID,范围会拉满,改问「一定不在」。一叠里只出现少数几个枚举值时,直接记下这叠出现过的集合,比拉一个从最小到最大的范围更贴。要在字符串里找一段子串,整值哈希对不上这种条件,改用按词或按字符片段建的那类图。

数字是教学用量级:一天 1 亿行、一叠约 8192 行、广告位 1001 约 20 叠、计划 99 再剩 2 到 3 叠。用来对齐上面的漏斗。