从零构建现代搜索引擎(12):更新、删除与崩溃恢复
前面十一篇建立的索引有一个隐含假设:文档集合是静态的。建完索引就不变了。但实际场景中,文档会增加、修改、删除。一篇文档改了标题,搜索结果应该反映新标题;一篇文档被删除,搜索不应该返回它。
在倒排索引上直接修改代价很高——删除一篇文档意味着遍历它包含的每个词项的 posting list,逐一移除对应的 docID。10,000 篇文档的索引里删除一篇,可能要修改上千个 posting list。
本篇解决三个问题:怎样标记删除、怎样安全地提交新版本索引、怎样在崩溃后恢复到一致状态。
标记删除:Tombstone
不直接修改 Posting List
直接从 posting list 中移除 docID 需要:
- 找到该文档包含的所有词项(需要正向索引或重新分析文档)
- 在每个词项的 posting list 中定位并删除对应 docID
- 如果 posting list 用了 delta 编码,删除一个元素需要重新编码后续所有 gap
这个操作的代价与文档包含的词项数成正比——对一篇包含 1,500 个不同词项的文档,要修改 1,500 个 posting list。如果频繁删除,索引性能不可接受。
Tombstone 位图
换一种思路:不改 posting list,只在旁边放一个标记。
1 | |
查询时加一个检查:
1 | |
开销:每个文档一个 bit。10,000 篇文档的 tombstone 位图只需要 1.25 KB。
更新 = 删除 + 插入
更新文档不是原地修改,而是:
- 标记旧版本为已删除(在 tombstone 位图中置位)
- 在新段中写入新版本(分配新的 docID)
这意味着同一篇文档可能有多个版本散布在不同的段中,但只有最新版本未被标记删除。
Lucene 的做法完全一样:IndexWriter.updateDocument() 内部就是 deleteDocuments(term) + addDocument(doc)。每个段有一个 .liv 文件记录存活文档的位图。
物理清理
Tombstone 标记的文档仍然占磁盘空间和查询时的遍历开销。段合并(第 10 篇的 k-way merge)时顺便清理——已删除的 docID 不写入合并后的新段。这就是为什么段合并不只是性能优化,也是空间回收的手段。
提交清单
问题
索引由多个段文件和删除位图组成。哪些文件属于当前有效的索引?需要一个元数据文件来记录——这就是提交清单(commit point)。
1 | |
每个段记录名称、文档总数和已删除文档数。generation 是递增的版本号——每次提交,generation +1。
Lucene 用 segments_N 文件(N 是 generation 编号),记录当前索引包含哪些段、每个段的状态、删除计数等。
为什么需要 Generation
如果只有一个 commit.json,修改它的过程中崩溃会留下损坏的文件。用 generation 编号:
- 当前有效提交是
commit-003.json - 新提交写到
commit-004.json - 如果写入过程中崩溃,
commit-003.json仍然完好 - 启动时找到最大 generation 的有效提交文件即可
原子切换
写入新提交的流程
1 | |
关键在 step 5:POSIX rename() 在同一文件系统内是原子操作。要么完成(commit-004.json 存在且完整),要么没发生(只有 commit-004.tmp)。
1 | |
fsync 的必要性
没有 fsync,数据可能还在 OS 的页缓存中。断电时页缓存丢失——文件看起来写完了,但磁盘上可能是空的或半截的。
fsync 的代价:一次 fsync 在 SSD 上约 0.1-1 ms,在 HDD 上约 5-15 ms。频繁 fsync 会拖慢写入。教学场景不用在意这个延迟。
启动恢复
恢复流程
程序启动(或崩溃重启)时:
1 | |
崩溃场景覆盖
| 崩溃时刻 | 磁盘状态 | 恢复结果 |
|---|---|---|
| 写新段文件过程中 | 新段不完整,旧 commit 有效 | 回到旧 commit,新段被清理 |
| 新段 fsync 完成,commit 未写 | 新段完整但不可见 | 回到旧 commit,新段被当孤儿清理 |
| commit tmp 写完,rename 未执行 | tmp 文件存在 | 回到旧 commit,tmp 被清理 |
| rename 完成 | 新 commit 生效 | 使用新 commit |
| 删除旧文件过程中 | 旧文件部分残留 | 新 commit 有效,下次启动继续清理 |
每种情况都回到最后一个成功的 commit——不会丢失已提交的数据,不会看到半提交的状态。
删除不复活
被标记删除的文档不会因崩溃恢复而重新出现。删除操作写入 tombstone 位图并持久化,位图文件被提交清单引用。只要 commit 包含了最新的 tombstone,恢复后删除仍然有效。
反例验证:删除文档 42,提交,崩溃,重启——文档 42 仍然被标记删除,搜索不会返回它。
验证
验证方法是在每个关键步骤注入崩溃(模拟进程退出),然后检查恢复结果:
1 | |
当前局限
- 没有实现预写日志(WAL)——本篇的恢复只保证回到最后一次提交,提交之间的操作会丢失
- 没有实现并发控制——单线程写入,多线程读取需要用快照隔离(第 14 篇处理)
- tombstone 位图在内存中是完整的——对百万级文档仍然只需要约 125 KB,不是瓶颈
- 提交频率没有策略——何时触发提交(按时间、按文档数、手动)留给应用层决定
- 段合并时的物理清理没有实现并发安全——正在搜索的线程可能引用即将被删除的段
练习
- 实现 tombstone 位图的持久化:写入一个
.del文件,格式为位图的字节数组 - 在写新段和 commit rename 之间插入
System.exit(1),验证重启后索引回到上一次提交 - 删除 3 篇文档后提交,崩溃重启,验证这 3 篇文档搜索不到
- 构造一个场景:两个 generation 的 commit 文件同时存在,验证恢复选择最新的有效 generation
- 测量 fsync 的延迟:对一个 1 MB 的段文件,比较有 fsync 和无 fsync 的写入时间
延伸阅读
- Introduction to Information Retrieval, Chapter 4.5: Dynamic indexing
- Lucene 源码:
org.apache.lucene.index.IndexWriter(commit 流程) - Lucene 源码:
org.apache.lucene.index.SegmentInfos(segments_N 格式) - POSIX rename(2) man page(原子性保证)






