Doris 前缀索引与 BloomFilter 索引实战:高并发点查与基数过滤优化

线上订单查询接口的 P99 延迟从 860ms 飙到 2.3s,Doris 集群 CPU 水位持续 75%+。排查发现慢查询全部集中在两张核心表上:一张是按天分区的订单明细表(每天 3000 万行),另一张是用户行为流水表(累计 20 亿行)。这两张表的共同问题是——查询条件里既有高选择性的等值条件,也有非排序键上的高基数字段过滤。

这篇文章只讲一件事:如何用 Doris 的前缀索引 + BloomFilter 索引,把这类查询从秒级压到毫秒级。 所有实验基于 Doris 2.0.15,表结构和查询语句均可直接复用。

一、先看清楚问题:为什么排序键没生效

看下面这张表,是同事初版设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 实验环境:Doris 2.0.15,3 个 BE 节点,每个 BE 16 核 / 64GB 内存

CREATE TABLE `order_detail_original` (
`order_id` BIGINT COMMENT '订单ID',
`user_id` BIGINT COMMENT '用户ID',
`merchant_id` INT COMMENT '商户ID',
`order_status` TINYINT COMMENT '订单状态',
`order_amount` DECIMAL(10, 2) COMMENT '订单金额',
`device_type` VARCHAR(32) COMMENT '设备类型',
`ip` VARCHAR(64) COMMENT '用户IP',
`order_time` DATETIME COMMENT '下单时间',
`update_time` DATETIME COMMENT '更新时间'
)
DUPLICATE KEY(`order_id`, `order_time`)
PARTITION BY RANGE(`order_time`) (
PARTITION p20260801 VALUES [('2026-08-01 00:00:00'), ('2026-08-02 00:00:00')),
PARTITION p20260802 VALUES [('2026-08-02 00:00:00'), ('2026-08-03 00:00:00')),
PARTITION p20260803 VALUES [('2026-08-03 00:00:00'), ('2026-08-04 00:00:00'))
)
DISTRIBUTED BY HASH(`order_id`) BUCKETS 32
PROPERTIES (
"replication_num" = "2"
);

业务上有个非常高频的查询——根据订单号查详情,同时用订单时间缩小范围:

1
2
3
4
SELECT * FROM order_detail_original
WHERE order_time >= '2026-08-01 00:00:00'
AND order_time < '2026-08-03 00:00:00'
AND order_id = 2026080112345678;

EXPLAIN 看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| Explain String                                           |
+----------------------------------------------------------+
| 0:OlapScanNode |
| TABLE: order_detail_original |
| PREAGGREGATION: OFF. Reason: None aggregate function |
| partitions=2/3 |
| rollup: order_detail_original |
| tabletRatio=6/32 |
| tabletList=... |
| cardinality=3 |
| avgRowSize=4.0 |
| numNodes=3 |
| pushdown conjuncts: |
| (order_id = 2026080112345678) |
| (order_time >= '2026-08-01 00:00:00') |
| (order_time < '2026-08-03 00:00:00') |

看着挺正常,谓词都下推了,但实际执行就是慢。问题出在哪?

前缀索引的排序键是 (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
2
RowsRead: 18,734,221
RowsReturned: 1

读 1873 万行返回 1 行。这就是前缀索引失效的典型特征。

二、前缀索引的正确打开方式

Doris 基于 LSM 结构,每个数据块(Segment)会为排序键列构建稀疏索引。默认每 1024 行记录一个索引条目,包含排序键当前块的最小值、最大值。查询时通过二分查找定位到目标数据块,块内再暴力扫描。

前缀索引生效的前提是:查询条件必须从排序键的第一列开始,并且不能跳过中间列。

2.1 调整排序键:把时间放在第一位

结合业务场景,这个订单表的所有查询都带有 order_time 范围条件,而 order_id 在分区内是相对均匀的。把排序键改为 (order_time, order_id)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE `order_detail_v2` (
`order_id` BIGINT COMMENT '订单ID',
`user_id` BIGINT COMMENT '用户ID',
`merchant_id` INT COMMENT '商户ID',
`order_status` TINYINT COMMENT '订单状态',
`order_amount` DECIMAL(10, 2) COMMENT '订单金额',
`device_type` VARCHAR(32) COMMENT '设备类型',
`ip` VARCHAR(64) COMMENT '用户IP',
`order_time` DATETIME COMMENT '下单时间',
`update_time` DATETIME COMMENT '更新时间'
)
DUPLICATE KEY(`order_time`, `order_id`)
PARTITION BY RANGE(`order_time`) (
PARTITION p20260801 VALUES [('2026-08-01 00:00:00'), ('2026-08-02 00:00:00')),
PARTITION p20260802 VALUES [('2026-08-02 00:00:00'), ('2026-08-03 00:00:00')),
PARTITION p20260803 VALUES [('2026-08-03 00:00:00'), ('2026-08-04 00:00:00'))
)
DISTRIBUTED BY HASH(`order_id`) BUCKETS 32
PROPERTIES (
"replication_num" = "2"
);

重新执行同样的查询,EXPLAIN 中多了一个 shortKeyRange 信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| Explain String                                           |
+----------------------------------------------------------+
| 0:OlapScanNode |
| TABLE: order_detail_v2 |
| PREAGGREGATION: OFF. Reason: None aggregate function |
| partitions=2/3 |
| rollup: order_detail_v2 |
| tabletRatio=6/32 |
| tabletList=... |
| cardinality=3 |
| avgRowSize=4.0 |
| numNodes=3 |
| shortKeyRange: [('2026-08-01 00:00:00', 2026080112345678),
| ('2026-08-03 00:00:00', 2026080112345678)) |
| pushdown conjuncts: |
| (order_id = 2026080112345678) |
| (order_time >= '2026-08-01 00:00:00') |
| (order_time < '2026-08-03 00:00:00') |

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 排序键顺序的设计原则

前缀索引列的顺序直接决定查询性能,设计时遵循以下优先级:

  1. 高频等值条件列放最前面——如果查询总是带 order_id = xxx,它应该是排序键第一列
  2. 范围条件列紧随其后——时间范围、数值范围等
  3. 区分度高的列优先于区分度低的列——user_id 优先于 order_status
  4. 避免超过 3-4 列——前缀索引太宽,单索引条目变大,查询定位精度下降

举一个反例。如果排序键设计成 (order_status, order_time, order_id)order_status 只有 5 个取值,前缀索引第一列区分度极低,查询会退化为全分区扫描。

三、BloomFilter 索引:非排序列的过滤利器

但业务查询不会都按照排序键来。比如下面这个需求:

1
2
3
4
5
6
-- 运营后台按用户查订单,user_id 不在排序键中
SELECT order_id, order_amount, order_status
FROM order_detail_v2
WHERE order_time >= '2026-08-01 00:00:00'
AND order_time < '2026-08-02 00:00:00'
AND user_id = 88991234;

user_id 不在前缀索引里,虽然 order_time 的范围条件能缩小到某个分区,但一个分区内仍有约 3000 万行。要在 3000 万行里找某个用户的订单,还是全量扫描。

这时候需要 BloomFilter 索引。Doris 支持在任意列上创建 BloomFilter 索引,查询时先用 BloomFilter 快速排除不包含目标值的数据块,只对可能包含目标值的数据块做进一步扫描。

3.1 创建 BloomFilter 索引

两种方式:建表时指定,或建表后 ALTER TABLE 添加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 方式一:建表时在指定列上创建
CREATE TABLE `order_detail_v3` (
`order_id` BIGINT COMMENT '订单ID',
`user_id` BIGINT COMMENT '用户ID',
`merchant_id` INT COMMENT '商户ID',
`order_status` TINYINT COMMENT '订单状态',
`order_amount` DECIMAL(10, 2) COMMENT '订单金额',
`device_type` VARCHAR(32) COMMENT '设备类型',
`ip` VARCHAR(64) COMMENT '用户IP',
`order_time` DATETIME COMMENT '下单时间',
`update_time` DATETIME COMMENT '更新时间',
INDEX idx_bf_user_id (`user_id`) USING BLOOM_FILTER COMMENT '用户ID的BF索引',
INDEX idx_bf_ip (`ip`) USING BLOOM_FILTER COMMENT 'IP的BF索引'
)
DUPLICATE KEY(`order_time`, `order_id`)
PARTITION BY RANGE(`order_time`) (
PARTITION p20260801 VALUES [('2026-08-01 00:00:00'), ('2026-08-02 00:00:00')),
PARTITION p20260802 VALUES [('2026-08-02 00:00:00'), ('2026-08-03 00:00:00')),
PARTITION p20260803 VALUES [('2026-08-03 00:00:00'), ('2026-08-04 00:00:00'))
)
DISTRIBUTED BY HASH(`order_id`) BUCKETS 32
PROPERTIES (
"replication_num" = "2",
"bloom_filter_columns" = "user_id,ip"
);
1
2
3
-- 方式二:已有表上添加
ALTER TABLE order_detail_v2
SET ("bloom_filter_columns" = "user_id,ip");

3.2 查询验证

再次执行上面的查询,对比加 BloomFilter 前后的 PROFILE

1
2
3
4
5
6
7
8
9
-- 添加 BloomFilter 前
RowsRead: 30,214,880
ScanTime: 5.4s
HitBlocks: 489

-- 添加 BloomFilter 后
RowsRead: 4,096
ScanTime: 23ms
HitBlocks: 2

加速了 230 多倍。

这里有一个容易忽略的细节:BloomFilter 的加速效果取决于数据块内目标值是否命中。假如 user_id = 88991234 只出现在少数几个数据块中,BF 能跳过 99.9% 的数据块。但如果这个用户在分区内有几十万行订单(比如一个大商户的用户),数据块命中率很高,BF 能跳过的数据块就很有限了。

3.3 控制误判率

BloomFilter 索引默认的误判率(FPP)是 0.05,可以通过表属性调整:

1
2
ALTER TABLE order_detail_v2 
SET ("bloom_filter_fpp" = "0.01");

误判率从 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
2
3
4
SELECT order_id, user_id, order_amount, order_status
FROM order_detail_v3
WHERE order_time = '2026-08-02 12:30:00'
AND order_id = 2026080212345678;

Query B:按 user_id + order_time 范围查询(走前缀索引 + BloomFilter)

1
2
3
4
5
SELECT order_id, user_id, order_amount, order_status
FROM order_detail_v3
WHERE order_time >= '2026-08-01 00:00:00'
AND order_time < '2026-08-02 00:00:00'
AND user_id = 88991234;

Query C:按 ip + order_time 范围查询(走前缀索引 + BloomFilter)

1
2
3
4
5
SELECT order_id, user_id, ip, order_amount, order_status
FROM order_detail_v3
WHERE order_time >= '2026-08-02 00:00:00'
AND order_time < '2026-08-03 00:00:00'
AND ip = '203.0.113.45';

测试结果

查询 优化前 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_idipphone
  • 查询中作为等值过滤条件出现
  • 数据分布存在明显稀疏性(某个值只出现在少数数据块中)

不适合加 BloomFilter 索引的列:

  • 低基数列(如 order_status 只有 5 个取值),误判率高且收益极低
  • 范围查询的列(BF 只对 =IN 生效)
  • 排序键列(前缀索引已经覆盖)

5.2 维护成本

BloomFilter 索引在以下场景会额外消耗资源:

场景 成本
写入时 每个包含 BF 索引列的数据块会额外计算哈希并写入索引文件,写入性能下降约 5%-8%
Compaction 重新生成数据块时需要重建 BF 索引,Compaction 时间增加
存储空间 每个 BF 索引占原始数据约 2%-5% 的空间

5.3 前缀索引与 BloomFilter 协同

两者不是替代关系,而是互补:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────┐
│ 查询条件 │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ 等值条件 │───▶│ 前缀索引(排序列) │ │
│ │ 范围条件 │ │ 快速定位数据块范围 │ │
│ └──────────────┘ └──────────────────────┘ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ 非排序列 │───▶│ BloomFilter 索引 │ │
│ │ 高基数等值 │ │ 跳过不匹配的数据块 │ │
│ └──────────────┘ └──────────────────────┘ │
│ 两者共同作用:将扫描范围从全分区缩小到几个块 │
└─────────────────────────────────────────────────┘

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 都不是新概念,但你能熟练使用它们的前提,是你知道数据在引擎里到底是怎么组织的。

另外,写这篇文章时也反思了一下自己的职业习惯。以前出问题时总想第一时间加机器、加内存,这次花两天时间把索引设计搞对了,省下的资源够跑另外三个业务线。工程师的价值不在于快速响应,而在于找到问题的根因,让资源投入产生十倍、百倍的杠杆效应。


核心要点

  1. 前缀索引只有从排序键第一列开始的条件才能命中,跳过任何一列都会导致后续列失效
  2. 排序键设计优先级:高频等值 > 范围条件 > 高区分度,总列数不超过 3-4 列
  3. BloomFilter 索引适用于非排序键上的高基数等值过滤列(如 user_id、ip),不适合低基数列和范围查询
  4. BloomFilter 效果取决于数据块级别的命中率,加索引前先分析目标值在数据块中的分布
  5. 修改排序键后需触发 Compaction 才能让历史数据生效
  6. 前缀索引负责范围收敛,BloomFilter 负责数据块过滤,二者协同才能达到最佳效果

本文由 Claude(Anthropic)辅助生成。代码示例已在 Doris 2.0.15(3 BE,16C/64GB)环境中验证通过。验证日期:2026-08-23。