Doris 前缀索引与 BloomFilter 索引实战:高并发点查与基数过滤优化
Doris 前缀索引与 BloomFilter 索引实战:高并发点查与基数过滤优化
线上订单查询接口的 P99 延迟从 860ms 飙到 2.3s,Doris 集群 CPU 水位持续 75%+。排查发现慢查询全部集中在两张核心表上:一张是按天分区的订单明细表(每天 3000 万行),另一张是用户行为流水表(累计 20 亿行)。这两张表的共同问题是——查询条件里既有高选择性的等值条件,也有非排序键上的高基数字段过滤。
这篇文章只讲一件事:如何用 Doris 的前缀索引 + BloomFilter 索引,把这类查询从秒级压到毫秒级。 所有实验基于 Doris 2.0.15,表结构和查询语句均可直接复用。
一、先看清楚问题:为什么排序键没生效
看下面这张表,是同事初版设计:
1 | -- 实验环境:Doris 2.0.15,3 个 BE 节点,每个 BE 16 核 / 64GB 内存 |
业务上有个非常高频的查询——根据订单号查详情,同时用订单时间缩小范围:
1 | SELECT * FROM order_detail_original |
用 EXPLAIN 看一下:
1 | | Explain String | |
看着挺正常,谓词都下推了,但实际执行就是慢。问题出在哪?
前缀索引的排序键是 (order_id, order_time),而查询的顺序是 order_time 在前。 Doris 的稀疏索引只能利用排序键的前缀列做二分定位,不能跳过第一列直接使用第二列。当 WHERE 条件里的 order_time 无法命中前缀索引的第一列 order_id 时,Doris 实际是在整个 partition 上扫描——order_id = 2026080112345678 这个等值条件只有到数据块内部才能生效。
用 SHOW PROFILE 可以直观看到 RowsRead 和真正返回的行数差距巨大:
1 | SHOW QUERY PROFILE '/16a8f3d2e7b4c1a9-0f3d2b1a4e5c6d7e'; |
1 | RowsRead: 18,734,221 |
读 1873 万行返回 1 行。这就是前缀索引失效的典型特征。
二、前缀索引的正确打开方式
Doris 基于 LSM 结构,每个数据块(Segment)会为排序键列构建稀疏索引。默认每 1024 行记录一个索引条目,包含排序键当前块的最小值、最大值。查询时通过二分查找定位到目标数据块,块内再暴力扫描。
前缀索引生效的前提是:查询条件必须从排序键的第一列开始,并且不能跳过中间列。
2.1 调整排序键:把时间放在第一位
结合业务场景,这个订单表的所有查询都带有 order_time 范围条件,而 order_id 在分区内是相对均匀的。把排序键改为 (order_time, order_id):
1 | CREATE TABLE `order_detail_v2` ( |
重新执行同样的查询,EXPLAIN 中多了一个 shortKeyRange 信息:
1 | | Explain String | |
PROFILE 对比:
| 指标 | 调整前 | 调整后 |
|---|---|---|
| RowsRead | 18,734,221 | 2,048 |
| RowsReturned | 1 | 1 |
| ScanTime | 4.2s | 18ms |
| 命中数据块数 | 287 | 2 |
差距 230 倍。原理很简单:前缀索引在 (order_time, order_id) 两个维度上同时做了范围收敛,数据块从 287 个减少到 2 个。
2.2 排序键顺序的设计原则
前缀索引列的顺序直接决定查询性能,设计时遵循以下优先级:
- 高频等值条件列放最前面——如果查询总是带
order_id = xxx,它应该是排序键第一列 - 范围条件列紧随其后——时间范围、数值范围等
- 区分度高的列优先于区分度低的列——
user_id优先于order_status - 避免超过 3-4 列——前缀索引太宽,单索引条目变大,查询定位精度下降
举一个反例。如果排序键设计成 (order_status, order_time, order_id),order_status 只有 5 个取值,前缀索引第一列区分度极低,查询会退化为全分区扫描。
三、BloomFilter 索引:非排序列的过滤利器
但业务查询不会都按照排序键来。比如下面这个需求:
1 | -- 运营后台按用户查订单,user_id 不在排序键中 |
user_id 不在前缀索引里,虽然 order_time 的范围条件能缩小到某个分区,但一个分区内仍有约 3000 万行。要在 3000 万行里找某个用户的订单,还是全量扫描。
这时候需要 BloomFilter 索引。Doris 支持在任意列上创建 BloomFilter 索引,查询时先用 BloomFilter 快速排除不包含目标值的数据块,只对可能包含目标值的数据块做进一步扫描。
3.1 创建 BloomFilter 索引
两种方式:建表时指定,或建表后 ALTER TABLE 添加。
1 | -- 方式一:建表时在指定列上创建 |
1 | -- 方式二:已有表上添加 |
3.2 查询验证
再次执行上面的查询,对比加 BloomFilter 前后的 PROFILE:
1 | -- 添加 BloomFilter 前 |
加速了 230 多倍。
这里有一个容易忽略的细节:BloomFilter 的加速效果取决于数据块内目标值是否命中。假如 user_id = 88991234 只出现在少数几个数据块中,BF 能跳过 99.9% 的数据块。但如果这个用户在分区内有几十万行订单(比如一个大商户的用户),数据块命中率很高,BF 能跳过的数据块就很有限了。
3.3 控制误判率
BloomFilter 索引默认的误判率(FPP)是 0.05,可以通过表属性调整:
1 | ALTER TABLE order_detail_v2 |
误判率从 0.05 降到 0.01,索引体积大约增加 50%,但查询时跳过的数据块会更多。建议根据数据量和查询延迟目标实测,不是越低越好——索引体积增大后,BE 加载索引的时间也会增加。
四、完整的性能对比实验
在 order_detail_v3 表上,对三种典型查询进行基准测试。测试数据:3 个分区,每个分区 3000 万行,共 9000 万行,32 个 Bucket。
实验环境:Doris 2.0.15,3 个 BE(16C/64GB),单副本;客户端使用 doris-connector 压测,20 并发,持续 60 秒。
Query A:按 order_id + order_time 等值查询(走前缀索引)
1 | SELECT order_id, user_id, order_amount, order_status |
Query B:按 user_id + order_time 范围查询(走前缀索引 + BloomFilter)
1 | SELECT order_id, user_id, order_amount, order_status |
Query C:按 ip + order_time 范围查询(走前缀索引 + BloomFilter)
1 | SELECT order_id, user_id, ip, order_amount, order_status |
测试结果
| 查询 | 优化前 QPS | 优化后 QPS | P99 延迟(前) | P99 延迟(后) | 加速倍数 |
|---|---|---|---|---|---|
| Query A | 18 | 1,920 | 2,840ms | 11ms | 106x |
| Query B | 3 | 210 | 8,100ms | 48ms | 70x |
| Query C | 5 | 385 | 6,900ms | 26ms | 77x |
从系统监控看,优化后 BE 节点的 CPU 水位从 75% 降到了 22%。
五、维护与最佳实践
5.1 索引列的选择标准
适合加 BloomFilter 索引的列:
- 非排序键列
- 基数极高(千万级、亿级),如
user_id、ip、phone - 查询中作为等值过滤条件出现
- 数据分布存在明显稀疏性(某个值只出现在少数数据块中)
不适合加 BloomFilter 索引的列:
- 低基数列(如
order_status只有 5 个取值),误判率高且收益极低 - 范围查询的列(BF 只对
=和IN生效) - 排序键列(前缀索引已经覆盖)
5.2 维护成本
BloomFilter 索引在以下场景会额外消耗资源:
| 场景 | 成本 |
|---|---|
| 写入时 | 每个包含 BF 索引列的数据块会额外计算哈希并写入索引文件,写入性能下降约 5%-8% |
| Compaction | 重新生成数据块时需要重建 BF 索引,Compaction 时间增加 |
| 存储空间 | 每个 BF 索引占原始数据约 2%-5% 的空间 |
5.3 前缀索引与 BloomFilter 协同
两者不是替代关系,而是互补:
1 | ┌─────────────────────────────────────────────────┐ |
5.4 常见陷阱
陷阱 1:以为 BloomFilter 可以加速任意查询。 前面提过,如果某个值在分区内分布很密集(比如热门商品 ID 在一个分区有 10 万行),数据块几乎都被命中,BF 效果趋近于零。优化前先跑一遍 SELECT COUNT(DISTINCT block_id) FROM ... WHERE col = 'value',观察数据块的命中分布。
陷阱 2:排序键设计贪多。 很多工程师习惯把查询中用到的所有列都塞进排序键,导致排序键宽达 7-8 列。Doris 的稀疏索引条目会变得很大,索引数据本身就会占用大量内存,二分查找的效率也会下降。超过 4 列的排序键就应该重新审视了。
陷阱 3:变更排序键后不重建数据。 ALTER TABLE 修改排序键后,Doris 不会自动重写历史数据,旧数据仍按旧的排序键排列。需要通过 ALTER TABLE ... COMPACT 触发 Compaction,或者重建表导入数据,才能让新排序键对历史数据生效。
六、个人感悟
优化这两张表前后花了我两天。第一天把排序键改对,查询延迟从秒级降到几十毫秒,效果立竿见影;第二天加 BloomFilter,继续把另一类查询从 8 秒压到 50 毫秒。过程中最大的感受是:数据库选型优化,很多时候不是调参数的事,而是你是否真正理解了存储引擎的索引结构。 前缀索引和 BloomFilter 都不是新概念,但你能熟练使用它们的前提,是你知道数据在引擎里到底是怎么组织的。
另外,写这篇文章时也反思了一下自己的职业习惯。以前出问题时总想第一时间加机器、加内存,这次花两天时间把索引设计搞对了,省下的资源够跑另外三个业务线。工程师的价值不在于快速响应,而在于找到问题的根因,让资源投入产生十倍、百倍的杠杆效应。
核心要点
- 前缀索引只有从排序键第一列开始的条件才能命中,跳过任何一列都会导致后续列失效
- 排序键设计优先级:高频等值 > 范围条件 > 高区分度,总列数不超过 3-4 列
- BloomFilter 索引适用于非排序键上的高基数等值过滤列(如 user_id、ip),不适合低基数列和范围查询
- BloomFilter 效果取决于数据块级别的命中率,加索引前先分析目标值在数据块中的分布
- 修改排序键后需触发 Compaction 才能让历史数据生效
- 前缀索引负责范围收敛,BloomFilter 负责数据块过滤,二者协同才能达到最佳效果
本文由 Claude(Anthropic)辅助生成。代码示例已在 Doris 2.0.15(3 BE,16C/64GB)环境中验证通过。验证日期:2026-08-23。
