【DIY系列:自己动手写分布式搜索引擎】第26篇:索引优化与段合并:为什么段越少越快
<! —
title: “【DIY系列:自己动手写分布式搜索引擎】第26篇:索引优化与段合并:为什么段越少越快”
series: “DIY系列:自己动手写分布式搜索引擎”
episode: 026
publish_date: “2026-10-17”
author: “AI Coder”
tags: [“段合并”, “MergePolicy”, “forceMerge”, “Lucene优化”, “TieredMergePolicy”, “索引性能”]
description: “段只会越写越多,小段泛滥会拖垮查询(打开文件句柄多、每次查询要扫多个段)。本文讲清 Lucene 的自动段合并策略 TieredMergePolicy,手动 forceMerge 的利与弊,以及为什么线上严禁随便 optimize。”
prev_article: “文章025_创建与维护索引库:增删改与提交.md”
next_article: “文章027_灵活索引与定制存储结构.md”
keywords: “段合并,MergePolicy,TieredMergePolicy,forceMerge,Lucene优化,索引性能”
—>
上一篇【第25篇】创建与维护索引库:增删改与提交
下一篇【第27篇】灵活索引与定制存储结构
摘要
上篇说了,Lucene 一提交就冒一个新段,段只会越来越多。段一多,查询就肉:每次搜索要把所有段都扫一遍,打开的文件句柄哗哗涨,被删文档还占着空间不释放。Lucene 用"段合并"把一堆小段并成大段,顺手把删除标记清掉、把空间腾出来。本文讲自动合并的策略 TieredMergePolicy,手动 forceMerge 的甜头与雷区,尤其是线上那条铁律:别手贱去 optimize。
一、段太多的代价,肉眼可见
想象一个极端情况:你每 add 一篇就 commit 一次,写了十万篇,生成十万个段。现在来一个查询,Lucene 要:
一次查询要扫的段: [_0] [_1] [_2] ... [_99999] ├─ 每个段都要打开一组文件(.doc/.pos/.tim...) ├─ 每个段都要独立查词典、取倒排、合并结果 └─ 系统文件句柄哗哗被占,OS 都看不下去三个实打实的坏处:
查询变慢。段越多,同样的词要在越多地方查一遍,最后还要把各段的结果归并去重(同一文档可能在多个段里以不同版本出现,得合并)。段数上去,延迟线性涨。
文件句柄爆炸。一个段不是单个文件,是 .doc/.pos/.tim/.fdt 等一堆。上千段就是上万个文件描述符,Linux 默认单进程上限很容易被吃满,然后就开始报"Too many open files"。
空间回收不了。被标记删除的文档占着老段,段不合并就永远清不掉,磁盘只增不减。
所以"段越少、越大(在合理范围),查询越快"是个基本事实。但段也不能无限大,不然合并一次代价惊人。平衡点靠合并策略拿捏。
二、段合并在做什么
把两个或几个相邻的小段,读出里面活着的文档,重写进一个更大的新段,然后丢掉旧段。这个过程顺手完成了三件事:
【一次合并 = 三连击】 读旧段(只取"活着且没被删"的文档) │ ▼ 写成一个大新段 _NEW │ ├─ 旧段被删文档 → 彻底消失(空间释放) ├─ 段数量减少 → 查询要扫的段变少 └─ 索引结构重排 → 更紧凑 删掉旧段文件注意合并是后台默默进行的,不阻塞你的写入和查询。Lucene 有个独立的合并线程(或线程池)盯着,达到策略条件就开干。
三、自动合并策略:TieredMergePolicy
Lucene 默认用的是 TieredMergePolicy(分层合并策略)。它的思路有点像收拾房间:把大小相近的段归为一层,优先合并同一层里的一小撮段,既控制单次合并的量,又让段大小呈阶梯分布,避免一会合并俩小段、一会合并俩大段的混乱。
几个关键旋钮(都在 IndexWriterConfig / MergePolicy 上可调):
| 参数 | 含义 | 默认值附近 |
|---|---|---|
| maxMergeAtOnce | 一次最多合并几个段 | 10 |
| segmentsPerTier | 每层允许的段数(控制层数) | 10 |
| maxMergedSegmentMB | 合并出来的段最大多大 | 5 GB |
| floorSegmentMB | 小于它的段算"底层",合并更有利 | 2 MB |
【分层合并的直觉】 段大小: 2M 2M 3M 9M 10M 40M 40M 分层: [小层] [中层] [大层] 合并: 先把小层几个并成 7M,再和中层凑,逐步长大 目标: 段大小阶梯分布,单次合并量可控这套策略一般不用你动,开箱即合理。真要调,也是先盯着实际段数量和合并频率,再有的放矢,别上来就瞎拧参数。
四、手动 forceMerge:甜头与雷区
有时候你想主动把索引"压实",比如一次性批量建完索引、后续只读不写,这时候可以:
writer.forceMerge(1);// 强行合并到只剩 1 个段forceMerge(老版本叫 optimize)会把所有段最终并成 N 个段。只读场景(比如离线建好一份索引分发到多台机器当字典)这么干很香:段少、查询快、文件少。
但线上正在被高频写入的库,严禁随便 forceMerge。原因很硬:
它和写入打架。forceMerge 一个大索引会大量读旧段、写新段,占满 IO 和 CPU,你的正常写入和查询被拖到怀疑人生。我见过一次线上手滑 optimize,整节点 IO 打满,搜索延迟从几十毫秒飙到几秒。
大段合并本身极慢且耗资源。一个几十 GB 的段合并,可能跑几十分钟,期间临时空间翻倍(新旧段共存),磁盘直接被吃撑。
合并完段变大,后续小改又要重合并。你辛辛苦苦并成一个巨段,结果来了点新数据又得把它拆进来重并,纯属给自己找事。
【forceMerge 决策表】 场景 建议 离线建好只读库 可以,合并到 1 段,查询最优 线上高频写库 千万别,IO 打满 + 资源暴涨 删除量巨大后 谨慎,可低峰期小批量,别并到 1一句话:forceMerge 是给"终态"用的,不是给"活跃态"用的。
五、被删文档与合并的纠缠
回头接上篇的删除话题。你 delete 了一堆文档,它们只占标记不占实际查询命中,但占磁盘。只有合并才会把它们从物理上抹掉。所以如果你发现索引目录异常大、且删除操作很多,本质问题是"该合并了"。
Lucene 的合并策略其实会优先挑"删除比例高"的段来合并,因为合并这种段收益最大(能腾出最多空间)。这也是为什么有时候你没手动干预,删除后的空间也会慢慢回收,全靠后台合并在干活。
本篇小结
段只增不减是 Lucene 的常态,段泛滥会拖慢查询、吃光文件句柄、卡住空间回收。段合并在后台把小段并大段,顺手清掉删除标记、释放空间,靠 TieredMergePolicy 分层自动进行,基本不用管。forceMerge 是把双刃剑,只读库用它压实很香,线上活跃库碰都别碰,否则 IO 打满悔青肠子。下一篇我们换个角度,看看 Lucene 怎么把"索引存哪"这件事也抽象出来,让你能把索引塞进内存、HDFS 甚至自定义介质。
上一篇【第25篇】创建与维护索引库:增删改与提交
下一篇【第27篇】灵活索引与定制存储结构