4、索引优化(下):增量索引与全量索引的更新策略,以及如何避免索引膨胀导致的性能下降
4.1 全量重建 vs 增量更新:这不是二选一
聊到索引更新,很多新手第一反应就是「全量重建」。嗯,这确实是最简单粗暴的方式——把整个索引删了,重新跑一遍。但你想过没有,如果你的数据量是 10 亿条,全量重建一次可能要跑 3 个小时。这 3 个小时里,搜索服务怎么办?
我个人习惯是:全量索引做地基,增量索引做装修。两者不是替代关系,是配合关系。
核心原则:
- 全量索引:低频执行(比如每天凌晨一次),保证数据最终一致性
- 增量索引:实时或准实时执行,保证数据新鲜度
- 两者通过「段合并」机制融合到一起
我在项目中遇到过一种情况:某电商平台要求商品上架后 30 秒内能被搜到。如果只做全量重建,那根本不可能。所以我们采用了「增量写入 + 定期全量合并」的策略。说白了,就是让新数据先进入一个小的增量段,搜索时同时查全量段和增量段,然后定期把小段合并到大段里。
4.2 增量索引的三种常见模式
增量索引怎么做?我总结了三种模式,你可以根据业务场景选。
| 模式 | 原理 | 延迟 | 适用场景 |
|---|---|---|---|
| 实时写入 | 数据变更后直接写入索引 | 秒级 | 评论、动态等高频更新场景 |
| 定时批量 | 每 N 分钟拉取增量数据,批量构建 | 分钟级 | 商品、文章等可接受短暂延迟 |
| 日志回放 | 监听 binlog / oplog,回放变更 | 秒级 | 与数据库强一致要求的场景 |
我个人比较推荐「日志回放」模式。为什么?因为它不会丢数据。我曾经踩过一个坑:用定时批量模式,结果某次定时任务挂了,整整 2 小时的数据没进索引,用户搜不到新商品,业务方直接炸了。从那以后,我对于核心业务场景,一律上日志回放。
小技巧:如果你用 Elasticsearch,可以开启 translog 持久化。即使节点挂了,重启后也能从 translog 恢复未刷盘的增量数据。这招我用了好几年,稳得很。
4.3 全量索引的「优雅重建」策略
全量重建不是不能做,而是要注意方式。你想想看,如果直接删索引重建,那重建期间搜索怎么办?
我建议用「双缓冲」策略:
- 准备阶段:在后台构建一份新的全量索引,不影响线上服务
- 切换阶段:新索引构建完成后,原子性地切换索引别名
- 清理阶段:旧索引保留一段时间(比如 30 分钟),确认无误后删除
# 伪代码示例:双缓冲切换
# 1. 构建新索引
curl -X PUT "localhost:9200/products_v2" -H 'Content-Type: application/json' -d'{...}'
# 2. 数据导入完成后,原子切换别名
curl -X POST "localhost:9200/_aliases" -H 'Content-Type: application/json' -d'
{
"actions": [
{"remove": {"index": "products_v1", "alias": "products"}},
{"add": {"index": "products_v2", "alias": "products"}}
]
}'
# 3. 确认无误后删除旧索引
curl -X DELETE "localhost:9200/products_v1"
这样做的好处是:切换是毫秒级的,用户完全无感知。我在某社交平台做过一次全量重建,数据量 50 亿,重建花了 4 小时,但切换只用了 200 毫秒。业务方甚至没发现我们在做重建。
4.4 索引膨胀:那个悄悄吃掉你性能的恶魔
索引膨胀,说白了就是索引文件越来越大,但有效数据并没有那么多。为什么会这样?
- 频繁的增量更新:每次更新都会产生新的段,段多了,查询时就要合并多个段的结果
- 删除标记堆积:删除文档不是真的删除,只是打了个标记,段文件里还占着空间
- 字段冗余:存了太多不需要索引的字段,或者字段类型选得太大
我记得有一次线上告警:某搜索集群的查询延迟从 20ms 飙升到了 800ms。查了半天,发现是因为增量更新太频繁,产生了 2000 多个小段。每次查询都要合并 2000 多个段的结果,不慢才怪。
警告:不要以为「段越多越好」。段的数量和查询性能成反比。一般来说,单个节点上的段数控制在 50 以内比较健康。超过 100 就要警惕了。
4.5 如何控制索引膨胀?我的三板斧
控制索引膨胀,我总结了三个核心手段:
4.5.1 段合并策略调优
段合并是 Lucene / Elasticsearch 自带的机制,但默认参数不一定适合你的场景。
| 参数 | 默认值 | 我推荐的调优值 | 说明 |
|---|---|---|---|
index.merge.policy.segments_per_tier |
10 | 5-8 | 每层允许的段数,越小合并越积极 |
index.merge.policy.max_merged_segment |
5GB | 10GB-50GB | 最大段的大小,越大合并越少 |
index.merge.scheduler.max_thread_count |
Math.max(1, Math.min(4, Runtime.getRuntime().availableProcessors() / 2)) | 1-2 | 合并线程数,IO 密集型场景不宜过高 |
我个人习惯把 segments_per_tier 设小一点,让合并更积极。虽然合并本身会消耗 IO,但换来的是查询性能的稳定。你想想看,是让合并时慢一点好,还是让查询时慢 10 倍好?
4.5.2 定期强制合并
对于增量更新频繁的场景,我建议在低峰期执行一次强制合并:
# 强制合并到 1 个段
POST /my_index/_forcemerge?max_num_segments=1
注意:强制合并非常消耗 IO,千万别在高峰期做。我一般安排在凌晨 3-4 点,配合全量重建一起做。
经验之谈:强制合并后,记得执行一次 _refresh,让合并后的段对搜索可见。不然你合并了半天,用户搜的还是旧数据。
4.5.3 删除标记的物理清理
删除文档后,索引文件并不会变小。只有通过段合并,被删除的文档才会真正从磁盘上消失。
所以我的建议是:不要频繁删除文档。如果业务上确实需要删除,可以攒一批,然后触发一次合并。我曾经见过一个团队,每秒钟删除 1000 条数据,结果索引文件越来越大,查询越来越慢。后来改成每小时批量删除一次,配合合并,问题就解决了。
4.6 实战:一个完整的索引更新策略示例
说了这么多,来个完整的例子吧。假设你有一个新闻搜索系统,数据量 1 亿条,每天新增 100 万条,更新 50 万条。
# 1. 每天凌晨 2:00 执行全量重建(双缓冲模式)
# 2. 全量重建期间,增量数据写入临时索引
# 3. 全量重建完成后,合并增量数据
# 4. 白天每 5 分钟执行一次增量更新(日志回放模式)
# 5. 每 2 小时执行一次段合并(非高峰期)
# 6. 每天凌晨 4:00 执行一次强制合并
# 增量更新脚本示例(伪代码)
def incremental_update():
# 从消息队列读取增量数据
batch = read_from_kafka("news_changes", batch_size=5000)
# 批量写入索引
bulk_index(batch)
# 触发 refresh,让数据可见
refresh_index("news_index")
# 如果段数超过阈值,触发合并
if get_segment_count("news_index") > 50:
force_merge("news_index", max_segments=10)
这套策略我在多个项目里验证过,效果不错。当然,具体参数要根据你的硬件配置和业务压力来调。没有银弹,只有不断试错。
4.7 避坑指南
最后,分享几个我踩过的坑:
- 不要同时做全量重建和增量写入:我曾经这么干过,结果索引文件损坏,花了 2 天恢复。正确的做法是:全量重建时,增量数据先缓存,重建完成后再回放。
- 段合并不要设得太激进:把
max_merged_segment设得太大,会导致合并时 IO 打满,影响线上查询。我建议从 10GB 开始试,逐步调大。 - 监控段的数量:这是最直观的指标。我一般会在 Grafana 上加一个「段数量」的监控面板,超过阈值就告警。
- 不要迷信「实时」:很多业务其实不需要实时更新。能接受 5 分钟延迟的,就别上秒级更新。实时是有代价的——更多的段、更多的合并、更高的 IO 压力。
嗯,索引优化这个话题,说起来简单,做起来全是细节。但只要你理解了「全量做地基、增量做装修、合并做维护」这个核心思路,再配合监控和调优,索引膨胀这个问题,其实没那么可怕。