4、索引优化(下):增量索引与全量索引的更新策略,以及如何避免索引膨胀导致的性能下降

4.1 全量重建 vs 增量更新:这不是二选一

聊到索引更新,很多新手第一反应就是「全量重建」。嗯,这确实是最简单粗暴的方式——把整个索引删了,重新跑一遍。但你想过没有,如果你的数据量是 10 亿条,全量重建一次可能要跑 3 个小时。这 3 个小时里,搜索服务怎么办?

我个人习惯是:全量索引做地基,增量索引做装修。两者不是替代关系,是配合关系。

核心原则:

  • 全量索引:低频执行(比如每天凌晨一次),保证数据最终一致性
  • 增量索引:实时或准实时执行,保证数据新鲜度
  • 两者通过「段合并」机制融合到一起

我在项目中遇到过一种情况:某电商平台要求商品上架后 30 秒内能被搜到。如果只做全量重建,那根本不可能。所以我们采用了「增量写入 + 定期全量合并」的策略。说白了,就是让新数据先进入一个小的增量段,搜索时同时查全量段和增量段,然后定期把小段合并到大段里。

4.2 增量索引的三种常见模式

增量索引怎么做?我总结了三种模式,你可以根据业务场景选。

模式 原理 延迟 适用场景
实时写入 数据变更后直接写入索引 秒级 评论、动态等高频更新场景
定时批量 每 N 分钟拉取增量数据,批量构建 分钟级 商品、文章等可接受短暂延迟
日志回放 监听 binlog / oplog,回放变更 秒级 与数据库强一致要求的场景

我个人比较推荐「日志回放」模式。为什么?因为它不会丢数据。我曾经踩过一个坑:用定时批量模式,结果某次定时任务挂了,整整 2 小时的数据没进索引,用户搜不到新商品,业务方直接炸了。从那以后,我对于核心业务场景,一律上日志回放。

小技巧:如果你用 Elasticsearch,可以开启 translog 持久化。即使节点挂了,重启后也能从 translog 恢复未刷盘的增量数据。这招我用了好几年,稳得很。

4.3 全量索引的「优雅重建」策略

全量重建不是不能做,而是要注意方式。你想想看,如果直接删索引重建,那重建期间搜索怎么办?

我建议用「双缓冲」策略:

  1. 准备阶段:在后台构建一份新的全量索引,不影响线上服务
  2. 切换阶段:新索引构建完成后,原子性地切换索引别名
  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 压力。

嗯,索引优化这个话题,说起来简单,做起来全是细节。但只要你理解了「全量做地基、增量做装修、合并做维护」这个核心思路,再配合监控和调优,索引膨胀这个问题,其实没那么可怕。