4. 实时OLAP存储模型与索引原理

实时OLAP的查询性能,往往在数据写入的那一刻就已经被决定了。存储模型决定了数据在磁盘上如何排列,索引决定了查询时能跳过多少无关数据。理解列式存储、LSM-Tree、倒排索引、Bitmap索引、ZoneMap和Bloom Filter这六种机制,是判断一个实时OLAP引擎"为什么快、哪里会慢"的基础。

列式存储:把"读得少"变成物理事实

行式存储把一行记录的所有字段连续存放,适合"取整行"的事务场景;列式存储把同一列的值连续存放,适合"只取少数列、扫描大量行"的分析场景。假设一张订单表有50个字段、10亿行,查询只需要 city、amount、ts 三列。行式存储必须把50列全部读入再丢弃47列,I/O放大约16倍;列式存储只读这三列的列文件,I/O量约为行式的1/16。

列式存储还带来两个直接收益:同一列数据类型一致,压缩率远高于行式(低基数枚举列用字典编码,时间戳用Delta编码,浮点用 Gorilla 类编码);压缩后的数据更小,扫描时CPU解压与内存带宽压力同步下降。代价是点查单行需要拼接多个列文件,因此实时OLAP通常配合主键索引或行号映射来弥补。

LSM-Tree:把随机写变成顺序写

实时写入要求高吞吐,而磁盘随机写是瓶颈。LSM-Tree(Log-Structured Merge Tree)的核心思路是:写入先追加到内存中的 MemTable(通常用跳表实现),同时写 WAL 保证持久性;MemTable 写满后冻结为 Immutable MemTable,后台刷成磁盘上的有序文件(SSTable/Data File);后台再按层级做 Compaction,把多个有序文件归并成更大的有序文件。

这套结构带来三个必须理解的后果。第一,写入是顺序追加,吞吐高、延迟低。第二,同一主键的新版本可能存在于多个层级,查询需要按"MemTable → L0 → L1 → …"的顺序查找并做版本合并,读放大由此产生。第三,Compaction 会消耗大量 I/O 和 CPU,若策略不当,会与查询争抢资源,表现为查询延迟毛刺。因此实时OLAP调优中,Compaction 策略(层级大小、触发阈值、并发度)与查询性能是强耦合的。

索引体系:从粗到细的过滤链

列存 + LSM 解决了"存得下、写得快",但查询仍需在大量数据文件中定位目标行。索引的作用是逐层缩小扫描范围,典型过滤链是:分区裁剪 → ZoneMap 跳过 → Bloom Filter 排除 → 主键/倒排/Bitmap 精确定位。

ZoneMap(区域映射)是最粗粒度但成本最低的索引。每个数据文件或数据块记录每列的 min/max(以及是否含 null)。查询条件 ts between '2024-05-01' and '2024-05-02' 时,若某块的 ts 最大值小于区间下界,整块直接跳过。ZoneMap 对有序或近似有序的列(时间、自增ID)效果极好,对随机分布的列几乎无效。

Bloom Filter(布隆过滤器)解决等值判断。它用多个哈希函数把元素映射到位数组,查询时若任一位为0,则元素一定不存在,可安全跳过该文件;若全为1,则可能存在,需回读确认。它只保证"不存在"的判定绝对正确,存在"假阳性"。因此 Bloom Filter 适合高基数列(如 user_id、order_id)的等值过滤,能显著减少无效文件读取,但不适合范围查询。

倒排索引面向"值 → 行集合"的映射,常用于文本或标签类字段。对 city 列建立倒排:"杭州" → [1,5,9,…],查询 city='杭州' 时直接取行号集合,无需扫描列数据。倒排索引适合低基数到中等基数的枚举、标签、关键词字段。

Bitmap 索引是倒排的一种紧凑实现:每个取值对应一个位图,第 i 位为1表示第 i 行取该值。多个条件的交并差可直接用位运算完成,例如 city='杭州' AND status='已支付' 就是两个位图按位与。Bitmap 在低基数列上空间效率极高,且支持快速多条件组合;但高基数列会产生大量稀疏位图,空间和构建成本上升,通常需要 Roaring Bitmap 等压缩结构。

实时OLAP 存储与索引过滤链 写入路径(左→右)与查询过滤路径(右→左)在 LSM 层级上交汇 写入路径 写入请求 INSERT / UPSERT WAL 顺序追加 持久化保障 MemTable(跳表) 内存有序,可查 Immutable 冻结待刷盘 Flush → SSTable 磁盘有序文件 LSM 层级(读放大来源) L0 多个小文件,键范围重叠,需逐个探测 … L1 键范围不重叠,二分定位单个文件 L2 更大文件,Compaction 归并产生 刷盘 查询过滤链(逐层缩小扫描范围) 分区裁剪 按日期/范围 排除整分区 ZoneMap min/max 跳过 范围查询友好 Bloom Filter 等值排除文件 有假阳性 倒排索引 值 → 行号集合 标签/关键词 Bitmap 索引 位运算交并差 低基数高效 回读 确认 过滤越靠前,跳过数据越多;索引选择需匹配列基数与查询谓词类型

索引选择:按列基数与谓词类型匹配

六种机制并非越多越好,关键是匹配。下面这张对照表给出常见场景的选型依据(参数为示例性经验值,实际需按数据分布压测确认)。

列特征 典型谓词 推荐索引 原因
时间戳、自增ID 范围 BETWEEN ZoneMap + 分区裁剪 数据近似有序,min/max 裁剪率高
高基数 ID(user_id) 等值 = Bloom Filter 位图过稀疏,Bloom 空间效率更高
低基数枚举(city、status) 等值 + 多条件 AND/OR Bitmap 索引 位运算直接完成集合组合
标签、关键词 包含、IN 倒排索引 值到行集合的映射天然适配
随机分布数值 范围 ZoneMap 效果有限 块间 min/max 重叠严重,裁剪率低

一个可观察的调优示例

假设订单表按天分区,单日数据约 2 亿行,写入后形成 40 个 L0 文件、8 个 L1 文件。查询为:

SELECT city, sum(amount)
FROM orders
WHERE ts >= '2024-05-01 00:00:00'
  AND ts <  '2024-05-02 00:00:00'
  AND city = '杭州'
  AND status = '已支付'
GROUP BY city;

执行时依次发生:分区裁剪只保留 5 月 1 日分区;ZoneMap 用 ts 的 min/max 跳过该分区内不覆盖目标时段的块;对 city 和 status 两个低基数列,Bitmap 索引分别取出"杭州"位图和"已支付"位图,按位与得到候选行号;最后只回读这些行号对应的 amount 列数据做聚合。若把 city 换成高基数的 user_id,Bitmap 会退化为大量稀疏位图,此时应改用 Bloom Filter 先排除不含该 user_id 的文件,再回读确认。

判断索引是否生效,重点看三个指标:扫描行数 / 总行数(裁剪率)、读取的数据文件数、以及 Compaction 期间的查询 P99 延迟。裁剪率长期低于 30% 时,应检查列是否真的有序、ZoneMap 粒度是否过粗、或索引列与谓词是否匹配。
常见误区:给所有列都建索引。索引会增大写入放大和存储占用,高基数列上的 Bitmap 甚至可能比原始列数据更大。正确做法是先看查询谓词分布,再按"基数 × 谓词类型"选一种主索引,避免重复建设。

把六种机制串起来看:列式存储决定"读多少",LSM-Tree 决定"写多快、读要合并几层",ZoneMap、Bloom Filter、倒排、Bitmap 共同构成从粗到细的过滤链。实时OLAP的性能优化,本质上就是让这条过滤链在正确的列上、以正确的粒度生效,并让 Compaction 与查询之间的资源竞争保持在可控范围。