Segment 合并
Segment 合并:什么时候该 force merge
Lucene 的不可变 Segment 设计带来了一系列并发读写的好处,但也留下了一个坑——随着写入,Segment 数量不断增长,查询需要遍历越来越多的 Segment,性能也随之下降。Segment Merge 就是解决这个问题的后台机制。但 Merge 本身是一把双刃剑,本文讲清楚什么时候该主动干预、什么时候该放任自流。
Lucene Tiered Merge Policy 工作原理
ES 默认使用 Tiered Merge Policy(分层合并策略),核心思想是将大小相近的 Segment 合并在一起,逐步形成层级结构。
写入流程:
1. 新文档先写入 buffer → refresh 成小 Segment(约几 MB)
2. Tiered Merge Policy 按大小和数量选择一组 Segment 合并 → 生成更大的 Segment
3. 合并后删除旧 Segment(实际上只是标记删除,Lucene 的删除文件)
合并决策参数:
# index 级设置
index.merge.policy.max_merged_segment: 5gb # 单个 Segment 最大 5GB
index.merge.policy.segments_per_tier: 10 # 每层最多 10 个 Segment
index.merge.policy.max_merge_at_once: 10 # 一次合并最多选 10 个 Segment
index.merge.policy.floor_segment: 2mb # 小于此值的 Segment 优先合并
index.merge.scheduler.max_thread_count: 4 # 同时最多 4 个 merge 线程
Tiered Merge Policy 的合并决策逻辑:
- 按 Segment 大小排序,去重(跳过正在合并的)。
- 计算"合并预算":允许的最大 Segment 数 = segments_per_tier 的倍数。
- 从最小的 Segment 开始,选择大小相近的一组(hit ratio 判定),直到总大小超过 max_merged_segment 或数量超过 max_merge_at_once。
- 如果遍历完还有剩余 Segment 超出预算,强制选一组合并(即使大小不匹配)。
Merge 对搜索性能的影响
正面影响:
每多一个 Segment,一次查询就要多遍历一个"微型倒排索引"。假设一个 50GB 的分片有 30 个 Segment,查询需要:
- 打开 30 个 SegmentReader
- 在每个 Segment 的倒排表中查找 term
- 合并 30 路结果
- 对合并后的结果评分排序
Merge 后只剩 5 个 Segment,开销降低约 80%。
GET /my_index/_segments
{
"verbose": true
}
返回结果中 num_search_segments 越少越好。一个健康分片的搜索 Segment 应该在 10 个左右。
负面影响:
Merge 本质上是重写索引文件:读取 N 个旧 Segment → 按 merge policy 重新排序归并 → 写入新 Segment。这是一个纯 IO 操作:
merge_io_volume = sum(selected_segments_size) # 读
+ result_segment_size # 写
如果合并的 Segment 总大小是 10GB,就要读 10GB、写约 10GB,磁盘 IO 冲击可观。
force merge 的正确使用场景
force merge API 强制将分片内所有 Segment 合并到指定数量:
POST /my_index/_forcemerge?max_num_segments=1
适用场景:
场景 1:只读索引优化
// ILM 策略:索引进入 cold 阶段后 force merge
PUT /_ilm/policy/logs_policy
{
"policy": {
"phases": {
"hot": { "actions": { "rollover": { "max_size": "50gb", "max_age": "1d" } } },
"warm": { "min_age": "2d", "actions": { "shrink": { "number_of_shards": 1 } } },
"cold": {
"min_age": "7d",
"actions": {
"forcemerge": { "max_num_segments": 1 },
"freeze": {}
}
},
"delete": { "min_age": "30d", "actions": { "delete": {} } }
}
}
}
7 天前的日志几乎不再写入,force merge 到 1 个 Segment,搜索性能达到最优且无副作用。
场景 2:归档索引
不再接收写入的历史数据索引,在归档前一次性 force merge:
POST /archive_2023/_forcemerge?max_num_segments=1&only_expunge_deletes=true
only_expunge_deletes=true:只合并有删除标记的 Segment(用于清理大量软删除)。only_expunge_deletes=false(默认):合并全部 Segment。
场景 3:大量删除后释放磁盘
POST /my_index/_forcemerge?only_expunge_deletes=true
ES 的删除是标记删除(.del 文件),不会立即释放空间。如果通过 delete_by_query 删除了 50% 的数据:
_forcemerge?only_expunge_deletes=true会将剩下的 50% 合并成新 Segment,释放被删除文档占用的磁盘空间。- 注意:这也会带来大量 IO,要在低峰期执行。
绝对不要在生产写入索引上 force merge
将 force merge 用在持续写入的索引上是生产事故的最常见操作失误之一。
原因:
IO 风暴:force merge 到 1 个 Segment 意味着把所有 Segment 全部重写。如果分片是 100GB,就是 100GB 读 + 100GB 写。此时索引持续写入增量的 translog,IO 争抢下写入延迟可能从 5ms 飙升到 500ms。
Merge 循环陷阱:force merge 生成一个超大 Segment 后,后续写入会再次生成小 Segment。Tiered Merge Policy 又会尝试将这些小 Segment 与超大 Segment 合并——但超大 Segment 可能超过
max_merged_segment,导致某些小 Segment 长时间无法合并,搜索退化。GC 开销:超大 Segment 合并期间,JVM 需要持有大量临时 buffer,可能触发 GC。
踩坑案例:
某运维在生产订单索引上定期执行 _forcemerge?max_num_segments=1,认为能"提升搜索性能"。结果每次执行时,磁盘 IO 使用率接近 100%,写入请求超时率从 0 飙升到 20%。恰逢双十一,导致大量订单创建失败。最终方案是移除定期 force merge,让 Tiered Merge Policy 自然地做增量合并。
限制 Merge 的 IO 影响
如果担心正常 Merge 影响写入性能,可以限制 Merge 的 IO 吞吐:
PUT /_cluster/settings
{
"persistent": {
"indices.store.throttle.max_bytes_per_sec": "20mb"
}
}
默认值是 20mb,即每个节点所有 Merge 操作的 IO 速率不超过 20MB/s。对于 SSD 集群可以适当调高,对于 HDD 或混合负载集群可以调低。
也可以按类型分开限制:
PUT /_cluster/settings
{
"persistent": {
"indices.store.throttle.max_bytes_per_sec": "40mb",
"indices.store.throttle.type": "merge"
}
}
force merge 的最佳实践 Checklist
| # | 实践 | 说明 |
|---|---|---|
| 1 | 只在只读索引上做 | force merge 前确保索引停止写入 |
| 2 | 低峰期执行 | 凌晨 3 点比白天 3 点好得多 |
| 3 | 控制并发 | _forcemerge 是异步的,一次不要对多个索引同时执行 |
| 4 | 限速 | 配合 max_bytes_per_sec 限制 IO 带宽 |
| 5 | 一个 Segment 不是银弹 | max_num_segments=1 对超大索引不现实,5-10 个也可接受 |
| 6 | 监控磁盘空间 | force merge 需要额外的临时空间(双写) |
| 7 | 配合 ILM 自动化 | 让 ILM 在 warm/cold 阶段自动做 force merge |
小结
Segment Merge 是 ES 自动运维的一部分,绝大多数时候不需要人工介入。记住两条铁律:force merge 只用于只读/归档索引,限制 Merge IO 避免影响业务写入。如果你的索引还在持续写入而查询变慢了,应该优先从 Query Cache、filter 优化、分片规划等方向排查,而不是寄希望于 force merge。