BM25
解决「搜到之后怎么打分」;写入侧要先回答:index()
返回后,同一 JVM 里的 Searcher 何时能看见这条文档?
在 Lucene 里,这不是数据库 COMMIT
的单点语义,而是 IndexWriter
缓冲、flush 产生新 Segment,再通过
NRT(Near-Real-Time)Reader
把内存与磁盘上的段视图暴露给查询线程。
本文是系列第 7 篇,钉住 Lucene 库层的 NRT
契约;Elasticsearch 的 refresh
在此基础上还有分片与 translog 一层,留到 第
12 篇。
版本锚定:Apache Lucene 9.x / 10.x(
IndexWriter、DirectoryReader、NRT API);机制以官方 Near real-time search 与IndexWriter文档为准。本篇不粘贴未在本机执行的 segment 文件列表。
一、最小故事:一次 addDocument 去哪了
应用调用 indexWriter.addDocument(doc)
时,文档先进入 RAM 缓冲,经 Analyzer
产出词项,再写入内存中的段缓冲结构。此时:
- 磁盘上可能还没有对应的新 Segment 文件;
- 持有旧
DirectoryReader的IndexSearcher看不到 这条文档; - 调用
indexWriter.commit()会把缓冲 flush 成新 Segment 并fsync,但 NRT 不要求 commit 才能搜。
近实时 的关键
API:DirectoryReader.open(IndexWriter writer)(或
openIfChanged)返回一个能看见 已 flush
段 + 当前 writer 未提交状态 的 reader。这就是嵌入式
Lucene 里「刚写就能搜」的库层含义。
sequenceDiagram
participant App
participant IW as IndexWriter
participant RAM as RAM buffer
participant Disk as Segment files
participant SR as Searcher / Reader
App->>IW: addDocument
IW->>RAM: analyze + buffer
Note over SR: old reader: not visible
IW->>Disk: flush (RAM full or flush())
IW->>SR: openIfChanged(reader)
Note over SR: new reader: flushed docs visible
App->>IW: commit (optional)
IW->>Disk: fsync + write commit point
二、IndexWriter 内部:缓冲、flush 与 Segment
2.1 RAM 缓冲与触发 flush
IndexWriter 维护文档缓冲,受
RAMBufferSizeMB(默认约 16MB)与
maxBufferedDocs 等参数约束。缓冲满或显式
flush() 时,writer 把当前缓冲
固化为新的 immutable Segment 写入
Directory(文件形态依 codec 而定,见 第 5
篇)。
| 事件 | 磁盘 Segment | 未提交数据 | 旧 Reader |
|---|---|---|---|
addDocument |
不变 | 在 RAM 增长 | 不可见 |
flush / 缓冲满 |
新增段 | 可能仍有未 flush 缓冲 | 需 openIfChanged |
commit |
持久化提交点 | 清空已提交部分 | 新 commit 后打开可见 |
rollback |
回滚未提交 | 丢弃 | 视实现打开新 reader |
要点:flush 产生段,commit 写 提交点 并持久化;NRT 搜索依赖 flush,不依赖 commit。
2.2 Segment 不可变与 docID
每个 Segment 内 docID 从 0 递增分配;段一旦 flush
完成,其倒排与正排文件 不可原地修改(删除用
liveDocs 标记,见 第
8 篇)。全局 docID 是 reader
把多段按规则拼接后的逻辑编号——查询时
IndexSearcher 在段上分别建 Scorer
再合并。
flowchart TB
subgraph iw ["IndexWriter"]
buf["RAM buffer"]
pend["pending deletes"]
end
subgraph disk ["Directory"]
s0["Segment_0"]
s1["Segment_1"]
s2["Segment_2 new flush"]
end
buf -->|"flush"| s2
iw --> s0
iw --> s1
dr["DirectoryReader"] --> s0
dr --> s1
dr --> s2
三、NRT Reader:DirectoryReader.open(IndexWriter)
3.1 API 语义
官方 NRT 路径推荐模式:
- 单线程(或受控)
IndexWriter持续addDocument/updateDocument; - 查询侧周期性调用
DirectoryReader.openIfChanged(reader, writer); - 若返回非
null,用新 reader 构造IndexSearcher并 关闭旧 reader(引用计数归零后释放文件句柄)。
open(IndexWriter) 创建的 reader 与 writer
共享同一索引视图:writer 后续 flush
的段会出现在下一次 openIfChanged 的 reader
里,无需 commit()。
这与「每次搜索 open(Directory) 只读已 commit
点」的批处理模式不同;NRT 是 读写并发
的默认生产姿势。
3.2 SearcherManager 与引用管理
手写 openIfChanged 容易在并发下泄漏 reader
或搜索到半关闭段。Lucene 提供
SearcherManager(基于
ReferenceManager)封装 获取
IndexSearcher → 用毕
release
的引用计数,并与后台线程或事件触发
maybeRefresh() 配合。
工程上应遵守:
- 刷新频率 与业务「可搜延迟」对齐——每次 refresh 打开新 reader 有固定开销(加载段元数据、预热词典);
- 旧查询 仍持有旧 reader
时,旧段文件不能被 delete,直到引用释放——writer 侧
merge删除的旧文件依赖此机制。
3.3 「刚写入即可搜」的精确含义
| 表述 | 库层 Lucene | 常见误读 |
|---|---|---|
刚 addDocument 返回 |
不一定可搜 | 仍在 RAM 缓冲 |
已 flush 且 reader 已 refresh |
可搜(未 commit 亦可) | 误以为要等 commit |
| 崩溃未 commit | 未 commit 的 flush 段可能丢 | 与 ES translog 不同 |
Lucene 不保证 addDocument
返回瞬间可搜;保证的是 flush + refresh
之后,在打开该 reader 的 IndexSearcher
上可搜。缓冲未满时,可见性延迟取决于何时触发 flush。
3.4 典型嵌入代码结构(示意)
下列 Java 片段展示 NRT 刷新循环 的核心调用关系(摘自 Lucene 官方 NRT 示例的常见写法,删减异常处理):
// Lucene 9.x/10.x — illustrative NRT loop
IndexWriter writer = new IndexWriter(dir, config);
DirectoryReader reader = DirectoryReader.open(writer);
SearcherManager manager = new SearcherManager(writer, null);
// indexing thread
writer.addDocument(doc);
manager.maybeRefresh(); // or maybeRefreshBlocking()
// search thread
IndexSearcher searcher = manager.acquire();
try {
TopDocs hits = searcher.search(query, 10);
} finally {
manager.release(searcher);
}maybeRefresh() 内部即
openIfChanged + 替换 IndexSearcher
引用。生产应处理 AlreadyClosedException、writer
单线程约束、以及 合并导致的 refresh
耗时尖峰。
3.5 缓冲与可见性延迟的定量关系(机制,非 benchmark)
设
RAMBufferSizeMB = R,单文档平均索引字节量为
\(s\),则触发 flush
的文档数约为 \(R /
s\)(忽略分析膨胀与删除开销)。在达到 flush
之前,任何 refresh 都看不到尚未 flush
的文档。因此:
- 减小 \(R\) → 更频繁 flush → 更低可见性延迟,但更多小段 → merge / 查询开销上升(第 8 篇);
- 增大 \(R\) → 相反。
没有全局最优 \(R\);日志型高吞吐写入常配合 bulk 批次 + 接受秒级 refresh(ES 层),而非把 \(R\) 压到零。
四、与 commit、merge 的交互
commit():写segments_N提交点,使 新进程DirectoryReader.open(directory)也能看到已提交段;对 同进程 NRT 不是可见性前提。merge:后台把多个小段合成大段,更新 liveDocs;合并期间 reader 可能多次openIfChanged。大 merge 会抬高 refresh 延迟与 I/O——属 第 8 篇 主题。rollback():丢弃 writer 未提交状态;已 commit 历史保留。
stateDiagram-v2
[*] --> Buffering: addDocument
Buffering --> Flushed: RAM full / flush
Flushed --> Buffering: continue add
Flushed --> Committed: commit
Committed --> Buffering: add more
Flushed --> Merging: background merge
Merging --> Committed: merge + commit optional
五、对照 Elasticsearch:库 NRT vs 服务 refresh
Elasticsearch 每个分片内嵌 Lucene
IndexWriter,但客户端写入不直接碰 Lucene
API:
| 层 | 机制 | 「可搜」触发 |
|---|---|---|
| Lucene 库 | flush + openIfChanged |
应用主动 refresh searcher |
| ES 分片 | 同上 + 分片 translog | refresh(默认约 1s)打开新 reader |
| ES 集群 | 主副本复制 | 副本 refresh 滞后于主 |
因此 ES 的「近实时」= Lucene NRT + 分布式 refresh
调度
+(可选)refresh=wait_for,不是单次
index() 的强一致读。崩溃恢复还依赖
translog,见第 12 篇。
搜索引擎架构 讨论的「写入后搜不到」在运维上常是 refresh 间隔 / 未 refresh 的搜索;理解库层 flush 有助于区分 缓冲未 flush 与 分片未 refresh。
5.1 常见误解
- 「commit 之后才能 search」。对 NRT 同进程 错误;commit 面向持久提交点与新进程冷启动。
- 「flush 等于 ES flush」。ES flush 侧重 持久化 Lucene commit + 清 translog;Lucene flush 只是 RAM → 新 Segment,语义更窄。
- 「refresh 越频繁越好」。每次打开新 reader 有 CPU 与文件缓存成本;过高 refresh 是生产慢查询与 第 17 篇 的常见根因之一。
5.2 冷启动 vs 热 NRT
| 场景 | 打开索引方式 | 看见未 commit 数据? |
|---|---|---|
| 同进程 NRT | open(IndexWriter) /
openIfChanged |
是(已 flush) |
| 新进程只读 | DirectoryReader.open(directory) |
否,仅到最后 commit |
| ES 节点重启 | 分片恢复 translog + 段文件 | 依赖 translog 回放(第 12 篇) |
运维「重启后丢最近写入」常是 未 flush 到磁盘 或 translog 未 fsync 的组合,不能笼统归咎于 Lucene NRT。
六、开放问题与工程间隙
- 统一「写入可见性 SLA」:Lucene
只提供机制,不承诺毫秒级;云盘 + 小
refresh_interval下尾延迟仍受 I/O 与 merge 拖累。 - 嵌入式 vs 外部搜索服务:自管
SearcherManager的插件(Solr/Lucene 应用)与 ES 托管 refresh 的运维边界,选型见 第 18 篇。 - 向量字段与倒排同 Writer:混合索引在同一
IndexWriter上 flush,NRT 语义是否双向一致,见 第 15 篇。
6.1 IndexWriterConfig 关键项(查阅用)
| 配置项 | 默认量级 | 影响 |
|---|---|---|
RAMBufferSizeMB |
~16 | 缓冲越大,flush 越少、可见性延迟越长 |
maxBufferedDocs |
-1(常不限) | 与 RAM 二选一触发 flush |
openMode |
CREATE_OR_APPEND |
决定索引目录是否新建 |
mergePolicy |
TieredMergePolicy |
段形与查询/merge 成本(第 8 篇) |
commitOnClose |
true | 进程退出时是否自动 commit |
调优应 先观测段数与 refresh 延迟,再动
RAMBufferSizeMB;与 ES
index.refresh_interval 是不同层的旋钮。
七、小结
三句话小结
IndexWriter先 RAM 缓冲,再 flush 成不可变 Segment;可搜性来自 flush,不是addDocument返回本身。DirectoryReader.open(IndexWriter)/openIfChanged是库层 NRT 契约:未 commit 的已 flush 段对刷新后的 Searcher 可见。- Elasticsearch refresh 在分片内触发同类 reader 打开;排障时要分开 缓冲、flush、commit、refresh 四层。
下一篇看段合并与删除如何推迟空间回收:MergePolicy 与删除。
参考资料
规范 / 文档
- Apache Lucene 9.x/10.x Guide:Near-real-time
search、
IndexWriter、SearcherManager、ReferenceManager。 - Elasticsearch 8.x Reference:Near real-time search(与 Lucene flush/refresh 对照阅读)。
站内
返回 系列目录 | 上一篇:BM25 | 下一篇:MergePolicy 与删除
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【全文检索引擎】Lucene · BM25 · Segment · Elasticsearch NRT
补齐搜索架构叙事与 RAG/向量引擎之间的全文检索内核层:以 Lucene 9.x/10.x 拆解 Analyzer、FST、postings、BM25 与 IndexWriter,并以 Elasticsearch 8.x 拆解分片、refresh/translog 与查询路径;OpenSearch 对照与选型收束。
【全文检索引擎】全文引擎全景:架构叙事与 Lucene 内核之间的一层
定位 Lucene 9.x/10.x 库内核与 Elasticsearch 8.x 服务层相对 architecture/42、storage/29、向量引擎与 PG GIN 的分工;以最小索引故事建立坐标系,交代倒排索引学术谱系与 18 篇路线图。
【全文检索引擎】refresh · flush · translog:近实时与持久化边界
用保证矩阵拆解 Elasticsearch 8.x 的 refresh、flush、translog 各自承诺什么:可搜窗口、磁盘持久化、崩溃恢复;对照 refresh_interval 与 wait_for,并在单节点 8.15 上实测写入可见性。
【全文检索引擎】BM25 与 Similarity:公式如何落到 Lucene
从 Robertson & Zaragoza (2009) 的 BM25 公式出发,拆解 Lucene BM25Similarity 如何把 TF 饱和、文档长度归一与 IDF 落到 postings 与 norms;对照 TF-IDF 工程差异,并划定与学习排序的生产边界。