用过 Elasticsearch 的同学大概率都听过 "把过滤条件放 filter 里更快",但很少有人说清背后到底快在哪。今天我们就用一个电商订单索引的真实场景,一步步拆解 Filter 的 Bitset 缓存工作原理,让你彻底搞懂这个性能优化的关键!
一、先搭个场景:电商订单索引
假设我们有一个orders索引,存了 10 条订单数据(文档 ID 1~10),字段结构很简单:
user_id:用户 ID(keyword 类型,精准匹配)order_time:下单时间(date 类型,时间戳存储)status:订单状态(keyword 类型:paid/unpaid/refunded)
现在要查 "用户 1001 在 2025 年 6 月之后下的订单",对应的 DSL 是:
json
{
"query": {
"bool": {
"filter": [
{ "term": { "user_id": "1001" } },
{ "range": { "order_time": { "gte": 1748419000 } } }
]
}
}
}
接下来,我们就跟着这个查询,看看 Bitset 缓存是怎么工作的。
二、第一次查询:从零生成 Bitset
当这个查询第一次执行时,ES 还没有任何缓存,需要走完整的 "扫描 - 生成 - 缓存" 流程。
1. 第一步:为user_id=1001生成 Bitset
Bitset(位图)是个超轻量的二进制结构 ------每一位对应一个文档 ID,匹配标 1,不匹配标 0。
- ES 先扫
user_id的倒排索引,发现文档 2、5、7、9 属于用户 1001; - 生成 Bitset:
0 1 0 0 1 0 1 0 1 0(第 2、5、7、9 位是 1); - 把这个 Bitset 存进内存缓存,贴上标签:
user_id:1001。
2. 第二步:为order_time>=1748419000生成 Bitset
同样的逻辑,处理时间范围条件:
- 扫
order_time的倒排索引,找到文档 3、5、7、8、10 符合时间要求; - 生成 Bitset:
0 0 1 0 1 0 1 1 0 1(第 3、5、7、8、10 位是 1); - 缓存这个 Bitset,标签:
order_time:>=1748419000。
3. 第三步:位运算组合出最终结果
两个 Bitset 都有了,ES 会做AND 位运算(只有两位都为 1 才保留):
plaintext
user_id=1001的Bitset:0 1 0 0 1 0 1 0 1 0
order_time>=...的Bitset:0 0 1 0 1 0 1 1 0 1
AND运算结果:0 0 0 0 1 0 1 0 0 0
一眼就能看出,只有文档 5、7 同时满足两个条件 ------ 这比逐行检查文档快了不止一个量级!
三、第二次查询:直接复用缓存,速度起飞
如果几分钟后,你又执行了一模一样的过滤条件,事情就简单了:
- ES 直接从内存里拽出之前缓存的两个 Bitset,不用再扫倒排索引;
- 重复 AND 位运算,瞬间得到结果;
- 返回文档 5、7。
整个过程跳过了最耗时的 "扫描索引" 步骤,响应时间可能从几十毫秒降到几毫秒 ------ 这就是缓存的威力!
四、数据变了怎么办?Bitset 的智能更新
如果这时候新增了一条文档 11(user_id=1001,order_time=1748500000),Bitset 会怎么处理?
- ES 不会删掉旧缓存,而是增量更新 :把
user_id:1001的 Bitset 第 11 位设为 1,order_time:>=...的 Bitset 第 11 位也设为 1; - 下次查询时,结果里就会自动包含文档 11,既保证了准确性,又没浪费缓存。
五、Bitset 为啥这么牛?核心优势拆解
- 极致轻量化:1000 万个文档的 Bitset 只占约 1.2MB 内存(10^7 位 = 10^7/8/1024 ≈ 1220KB),亿级文档也才几十 MB;
- 运算超快:位运算是 CPU 原生支持的操作,比遍历文档快几个数量级;
- 缓存复用率高:缓存的是 "条件规则" 而非 "查询结果",只要过滤条件相同,不管查什么字段都能复用。
六、缓存的生命周期:何时过期?占用多少内存?
1. 缓存的内存占用规则
Bitset 缓存并非独立存在,而是属于 ES 的Node Query Cache (节点级查询缓存),其内存上限由参数indices.queries.cache.size控制:
- 默认值为 JVM 堆内存的 10%(比如 JVM 堆是 31GB,缓存上限约 3.1GB);
- 也可手动设置固定值(如
512mb),避免占用过多堆内存。
以 1000 万文档的 Bitset 为例,单个条件的 Bitset 仅占 1.2MB 左右,3.1GB 的缓存空间足以存储数百万个过滤条件的 Bitset,完全满足常规业务需求。
2. 缓存何时 "过期"?
Bitset 缓存没有固定过期时间 ,而是遵循LRU(最近最少使用)淘汰策略:
- 高频使用的过滤条件(如
user_id=1001)会长期驻留内存,反复复用; - 低频使用的条件,当缓存空间不足时,会被优先淘汰;
- 若索引数据发生变更(新增 / 删除 / 更新文档),ES 会增量更新 受影响的 Bitset,而非直接淘汰 ------ 比如修改了文档 5 的
user_id,仅把user_id:1001的 Bitset 第 5 位从 1 改为 0,其余位保持不变。
只有当缓存达到内存上限,且该 Bitset 长期未被访问时,才会被 LRU 机制清理。
七、和 must 比,filter 到底快在哪?
很多人问 "为啥 must 里的条件不能用 Bitset?"------ 因为must要算相关性评分(_score),必须逐文档计算;而filter只需要 "是 / 否" 的判断,刚好适配 Bitset 的二进制特性。
简单说:
must:既要匹配,又要评分 → 慢;filter:只匹配,不评分 → 能用 Bitset 缓存 → 快!
八、最后划重点:Filter 缓存的实用建议
- 过滤条件必放 filter :尤其是
term/range/terms这类不影响评分的条件; - 缓存不用手动管:ES 会用 LRU 策略自动管理内存,默认占 JVM 堆的 10%,无需手动清理;
- 高频条件收益最大:比如按用户 ID、时间范围的过滤,缓存命中率极高;低频条件则可能被淘汰,无需纠结;
- 手动调整缓存大小 :若业务有大量高频过滤条件,可适当调高
indices.queries.cache.size(如设为 20%),提升缓存命中率。