DocumentsWriterDeleteQueue是一个非阻塞的、链表结构的"待处理删除操作队列"。与其他队列实现不同,我们只维护队列的尾部(tail)。
删除队列总是与一组
DWPT(DocumentsWriterPerThread)和一个全局删除池(global delete pool)一起使用。每个 DWPT 和全局删除池都需要各自维护队列的"头部"位置 (通过一个
DeleteSlice实例来表示,每个 DWPT 一个)。DWPT 与全局删除池的区别在于:
- DWPT 从它添加第一个文档时才开始维护自己的 head ,因为对于它即将 flush 出的 segment 来说,只有在该文档之后发生的删除操作才是相关的;
- 而全局删除池从
DocumentsWriterDeleteQueue创建时就开始维护 head,初始值为哨兵节点(sentinel)。由于每个
DeleteSlice都维护自己的 head,且链表是单向的,垃圾回收器(GC)会自动帮我们清理不再需要的节点。所有仍然"相关"的节点,必定被以下两者之一直接或间接引用:
- 某个 DWPT 的私有
DeleteSlice- 全局
BufferedUpdates的 slice每个 DWPT 和全局删除池都持有自己私有的
DeleteSlice实例。对于 DWPT 来说,更新它的 slice 相当于"原子性地完成一个文档的处理"。
这种 slice 更新机制保证了:在同一个索引会话中,该操作"happens-before"所有其他更新操作(即建立内存可见性和顺序性)。
当 DWPT 处理一个文档(尤其是
updateDocument)时,它会:
- 消费并完成该文档的处理;
- 更新其私有的
DeleteSlice------ 要么调用updateSlice(),要么(如果文档带有delTerm)调用add(Node, DeleteSlice);- 将该 slice 中的所有删除操作应用到它自己的
BufferedUpdates上,并重置 slice;- 递增其内部的文档 ID(docID)。
此外,DWPT 不会在更新其 delete slice 之前应用当前文档的 delete term,这确保了更新的一致性。
如果在更新
DeleteSlice之前发生失败(如异常),那么这个deleteTerm既不会加入到 DWPT 的私有删除集合中,也不会加入到全局删除队列中。
🔍 深入解释(关键点剖析)
1. 为什么"只维护 tail"?
- 因为这是一个只追加(append-only)的日志式队列。
- 所有写入线程只需竞争
tail的更新(通过synchronized add),效率高。 - "读取者"(DWPT 或全局池)不修改队列结构,只记录自己看到的
head位置(通过DeleteSlice)。
2. DWPT 为何"从第一个文档才开始记录 head"?
- 假设 DWPT 刚创建,还没加任何文档。
- 此时即使有全局删除(比如
delete(termX)),也与它无关,因为它还没有要 flush 的 segment。 - 只有当它开始缓冲文档后,之后发生的删除才可能影响这些文档。
- 所以它的
DeleteSlice初始head = tail = 当前全局 tail(即"从现在开始关注")。
3. GC 如何自动清理?
- 链表节点一旦没有任何
DeleteSlice的head或tail指向它,就变成不可达对象。 - 例如:所有 DWPT 都已 flush 并推进了 slice,全局池也处理完了 → 旧节点可被回收。
- 这避免了手动管理内存或使用循环缓冲区。
4. "更新 slice = 原子完成文档" 是什么意思?
-
在
updateDocument(doc, delTerm)中:long seqNo = add(delNode); // 全局队列追加 delTerm slice.sliceTail = delNode; // ← 关键!标记"我已看到这个 delete" -
这一行
slice.sliceTail = delNode是无锁但线程局部的操作(每个 DWPT 独占 slice)。 -
它建立了 happens-before:后续 flush 时一定能看到这个 delete。
-
如果在这行之前 crash,
delNode虽在全局队列,但没有 DWPT 的 slice 指向它 → 不会被应用 → 相当于"没发生"。
5. 为什么 deleteTerm 要等 slice 更新后才生效?
- 这是为了保证 原子性语义 : "要么整个 update 成功(旧文档被删 + 新文档加入),要么完全失败。"
- 如果先应用 deleteTerm,再更新 slice,中间 crash → deleteTerm 被应用但新文档没加 → 数据丢失。
- Lucene 通过 延迟应用 deleteTerm 到 flush 阶段,并依赖 slice 的进度控制,实现安全。
🧩 举个例子:updateDocument
writer.updateDocument(new Term("id", "100"), newDoc);
内部流程:
- DWPT-A 处理
newDoc - 构造
delNode = new TermNode(Term("id","100")) - 调用
deleteQueue.add(delNode, dwptSlice)- 全局队列追加
delNode - dwptSlice.sliceTail = delNode ← 标记"我负责处理这个 delete"
- 全局队列追加
- 文档继续处理(分析、存储等)
- 后续 flush 时:
- 调用
freezeGlobalBuffer(dwptSlice)→ 推进 slice 到最新 tail dwptSlice.apply(...)→ 应用包括delNode在内的所有 delete- 如果 segment 中已有 doc(id=100),则被标记删除;新 doc 被写入
- 调用
✅ 即使多个线程同时 update id=100,只有一个能成功(因为 slice.tail 原子更新 + delete 应用顺序)
✅ 总结
这段注释揭示了 Lucene 如何在高并发下安全、高效、一致地处理删除和更新:
- 全局有序日志(DeleteQueue)保证操作顺序
- 局部进度跟踪(DeleteSlice)实现增量处理
- GC 自动回收避免内存泄漏
- slice 更新作为同步点确保原子性和 happens-before
这是 Lucene 能同时支持 高吞吐写入 + 实时更新 + 强一致性 的核心机制之一。
!"全局 BufferedUpdates 的 slice " 是 Lucene 索引过程中用于跟踪和应用那些影响已提交(已 flush)segments 的删除/更新操作的关键机制。
我们来深入解释它的作用、生命周期和与 DWPT 的区别。
✅ 一句话回答
全局
BufferedUpdates的 slice(即DocumentsWriterDeleteQueue.globalSlice)的作用是:记录"从索引启动以来,所有尚未应用到已提交 segments 的删除/更新操作",并在后续 merge 或 searcher 刷新时将这些操作打包成.del文件或FrozenBufferedUpdates应用到旧 segments 上。
🔍 一、背景:为什么需要"全局" delete?
Lucene 的索引由多个 segment 组成:
- 新文档先写入内存,flush 后变成新 segment
- 旧 segment 已经在磁盘上,对 searcher 可见
当你执行:
writer.deleteDocuments(new Term("id", "100"));
这个删除必须:
- 影响未来 flush 的 segment → 由 DWPT 的
DeleteSlice处理 - 也影响已经存在的 segment → 这就是 全局 delete 的职责
🌟 全局 delete = "对历史 segments 的删除"
🧱 二、globalSlice 是什么?
在 DocumentsWriterDeleteQueue 中:
private final DeleteSlice globalSlice;
private final BufferedUpdates globalBufferedUpdates;
globalSlice:一个DeleteSlice,初始head = tail = sentinelglobalBufferedUpdates:一个缓冲区,存放"待应用到历史 segments 的 delete/update"
它的生命周期:
- 创建时 :
globalSlice指向哨兵节点(表示"从头开始关注所有 delete") - 每当有 delete 发生 :通过
tryApplyGlobalSlice()尝试将新 delete 应用到globalBufferedUpdates - 当需要刷新 searcher 或执行 merge 时 :
- 调用
freezeGlobalBuffer(null)(callerSlice = null) - 将
globalSlice推进到当前 tail - 把
globalBufferedUpdates冻结为FrozenBufferedUpdates - 清空
globalBufferedUpdates
- 调用
- 冻结后的 packet 会被:
- 在 merge 时应用到输入 segments
- 或作为 独立的 .del 文件 附加到 segment infos
⚖️ 三、与 DWPT 的 DeleteSlice 对比
| 特性 | DWPT 的 DeleteSlice |
全局 globalSlice |
|---|---|---|
| 目的 | 为即将 flush 的 segment 应用 delete | 为已存在的 segments 应用 delete |
| 何时初始化 head | 添加第一个文档时 | DeleteQueue 创建时(sentinel) |
| 应用时机 | flush 时(构建 FlushedSegment) |
searcher 刷新 / merge / commit 时 |
| 输出 | 成为 FlushedSegment.segmentUpdates 的一部分 |
成为全局 FrozenBufferedUpdates,用于旧 segments |
| 是否包含自身 delTerm | 是(updateDocument 时) | 是(所有 delete 都包含) |
🔄 四、工作流程示例
假设索引已有 segment _0(包含 doc id=100)
// 1. 删除 id=100(影响 _0)
writer.deleteDocuments(new Term("id", "100"));
// 2. 添加新文档(会 flush 出 _1)
writer.addDocument(...);
内部发生:
delete(term)→ 全局队列追加delNodetryApplyGlobalSlice()→globalSlice推进,delNode加入globalBufferedUpdates- DWPT flush → 生成
_1,其FlushedSegment不包含这个 delete(因为 delete 发生在它 buffer 之前) - 当你调用
writer.getReader():- 触发
maybeFreezeGlobalBuffer() - 返回
FrozenBufferedUpdates包含del(id=100) - searcher 用它标记
_0中的 doc 为 deleted
- 触发
✅ 所以
_0被逻辑删除,_1不受影响(因为它没这个 doc)
💡 五、关键设计思想
-
分离关注点
- DWPT 只关心"我的 segment 需要哪些 delete"
- 全局池关心"历史 segments 需要哪些 delete"
-
避免重复应用
一个 delete 不会同时被 DWPT 和全局池应用到同一个 segment
-
延迟应用 + 批量处理
全局 delete 不立即写
.del文件,而是累积后批量冻结,减少 I/O -
GC 安全
一旦
globalSlice推进,旧节点若无 DWPT 引用,即可回收
📁 六、实际存储形式
冻结后的全局 delete 最终会变成:
.del文件:如果只包含 term/query deletesFieldInfos+DocValues更新文件:如果包含 DocValues updates- 这些文件与 segment 关联,记录在
segments_N文件中
✅ 总结
全局
BufferedUpdates的 slice(globalSlice)是 Lucene 用来追踪"应作用于已提交 segments 的删除/更新操作"的进度指针。它确保:
- 历史数据能被正确删除
- 删除操作按顺序应用
- 内存高效(GC 自动清理)
- 与 DWPT 的局部 delete 机制协同工作,覆盖全生命周期
没有它,deleteDocuments() 对已存在的 segment 就完全无效 ------ 你的索引会"删不掉旧数据"。
这是 Lucene 实现 完整 CRUD 语义(尤其是 Delete 和 Update)不可或缺的一环。
我们从头开始,用最简单的方式讲清楚 "全局 BufferedUpdates 的 slice" 到底是干什么的。
🧩 场景设定:你有一个 Lucene 索引
假设你已经往索引里加了 1000 个文档,并且这些文档已经被 写入磁盘 ,变成了一个 segment(比如叫 _0)。
现在,你想删除其中某一条:
writer.deleteDocuments(new Term("id", "123"));
问题来了:
❓ 这条删除命令,怎么让已经写到磁盘上的
_0segment "知道"要删掉 id=123 的文档?
因为:
_0是只读文件,不能直接修改- 删除不能只影响未来的新文档,必须影响已经存在的旧文档
✅ Lucene 的解决方案:用"删除清单"标记
Lucene 不会真的去改 _0 文件,而是:
单独记一张"删除清单" ,说:"在读
_0的时候,跳过 id=123 这条"。
这张"删除清单",就存在 全局的 BufferedUpdates 里。
而 "slice" 就是用来跟踪"这张清单更新到哪了"的指针。
🔁 回到 DocumentsWriterDeleteQueue:所有删除先排队
每次你调 deleteDocuments(),Lucene 会:
-
把这个删除操作(比如
del(id=123))放进一个全局的删除队列 (就是DocumentsWriterDeleteQueue) -
这个队列像一条链子:
[哨兵] → [del(id=123)] → [del(id=456)] → ...
👁️ 两个"观察者"看这个队列
有两类"人"需要从这个队列里拿删除操作:
| 观察者 | 负责谁? | 怎么看队列? |
|---|---|---|
| 每个 DWPT(写新文档的线程) | 只关心自己即将生成的新 segment | 从"我开始写文档那一刻"往后看删除 |
全局 delete pool (也就是 globalSlice) |
关心所有已经存在的旧 segment | 从队列最开头(哨兵)开始看所有删除 |
✅
globalSlice就是"全局 delete pool"用来记录"我看到哪了"的书签。
📌 举个具体例子
时间线:
- t=0 :索引已有 segment
_0(包含 doc id=123) - t=1 :你调
deleteDocuments(new Term("id", "123"))- 删除操作被加到队列尾部
- t=2 :你调
writer.getReader()想搜一下- Lucene 说:"等等,我得先看看有没有新的删除要应用到旧 segment"
- 它检查
globalSlice:- 上次看到哨兵
- 现在队列尾部是
del(id=123)
- 于是它把
del(id=123)复制一份 ,存到globalBufferedUpdates - 然后生成一个
FrozenBufferedUpdates包裹
- ** searcher 使用这个包裹**:
- 读
_0时,发现 id=123 在删除清单里 → 跳过它 - 用户搜不到 id=123,删除成功!
- 读
💡 这个
globalBufferedUpdates里的内容,最终可能变成一个.del文件,和_0放在一起。
❓ 那 "slice" 到底是什么?
你可以把它想象成 一个书签:
globalSlice.sliceHead:上次读到哪了(其实不用,因为总是从头开始)globalSlice.sliceTail:这次要读到哪(当前队列的 tail)
每次刷新 searcher 或 merge 时:
- 把
globalSlice.sliceTail更新为当前队列尾部 - 把中间所有的删除操作"抄"到
globalBufferedUpdates - 然后清空
globalBufferedUpdates,准备下一轮
🔄 所以
globalSlice的作用就是:避免重复处理同一个删除操作。
✅ 最终总结(超简版)
| 问题 | 答案 |
|---|---|
| 全局 BufferedUpdates 是什么? | 一个缓冲区,存"要应用到旧 segment 的删除/更新" |
| 它的 slice(globalSlice)是干嘛的? | 一个进度指针,记录"删除队列中哪些操作还没抄到全局缓冲区" |
| 为什么需要它? | 因为旧 segment 不能改,只能靠"删除清单"来逻辑删除;而这个清单必须按顺序、不重复地生成 |
| 和 DWPT 的 slice 有什么区别? | DWPT 的 slice 只管"新 segment",globalSlice 管"所有旧 segment" |
globalSlice 确实有 sliceHead 和 sliceTail 两个指针 ,但在实际使用中,globalSlice.sliceHead 始终等于哨兵(sentinel),几乎"不动"。那中间的节点怎么办?会不会漏掉?重复处理?
我们来彻底澄清这一点。
✅ 核心答案一句话:
globalSlice并不是靠head → tail的区间来读取删除操作的;它每次都是从sentinel.next开始遍历到当前全局队列尾部(tail),然后把整个链路上的所有删除都"抄"一遍。
sliceTail的作用不是"读区间",而是"标记上次抄到哪了,避免重复抄"。
换句话说:globalSlice 的"中间节点"根本不需要用 head 来定位------因为它总是从头开始读,但只处理"新来的"部分。
🔍 详细解释:它是怎么工作的?
步骤 1:全局删除队列结构
Queue: [sentinel] → [del1] → [del2] → [del3] → [del4]
↑ ↑
(旧 globalSlice.tail) (当前 queue.tail)
步骤 2:第一次 freeze(比如 searcher 刷新)
globalSlice.sliceTail = sentinel(初始状态)- Lucene 调用
freezeGlobalBuffer(null)(callerSlice = null 表示是全局) - 它会:
- 获取当前
queue.currentTail = del4 - 从
sentinel.next(即 del1)开始遍历,一直到 del4 - 把 del1 ~ del4 全部加到
globalBufferedUpdates - 更新
globalSlice.sliceTail = del4
- 获取当前
✅ 这次处理了所有删除。
步骤 3:又来了新删除
Queue: ... → [del4] → [del5] → [del6]
↑ ↑
(globalSlice.tail) (new tail)
步骤 4:第二次 freeze
globalSlice.sliceTail = del4- 当前
queue.tail = del6 - Lucene 依然 从
sentinel.next开始遍历(del1 → del2 → ... → del6) - 但它在遍历时会:
- 跳过所有
node == globalSlice.sliceTail 或在其之前的节点 - 只处理
node 在 sliceTail 之后的(即 del5, del6)
- 跳过所有
🤔 那为什么不直接从
sliceTail.next开始?因为链表是单向的 !你无法从
del4直接跳到del5------ 除非你保存了del4.next。但 Lucene 没这么做。
所以它采用了一个看似"低效"但安全且 GC 友好的方式:
- 总是从头遍历
- 但用
sliceTail作为"截止已处理"的标记 - 遇到
node == sliceTail就知道:"前面的都处理过了,从下一个开始收"
但实际上,在代码中(如 applyDeletes 或 tryApplyGlobalSlice),它并不会真的"跳过"前面的节点,而是:
💡
globalBufferedUpdates本身是幂等的(idempotent):重复添加同一个 delete term 不会有副作用(比如用 Set 存储)。
所以即使"重新遍历前面的",也不会出错,只是略微多花点时间。
但更重要的是:GC 依赖这种"从 sentinel 开始引用"的结构 。如果 globalSlice 的 head 移走了,前面的节点就可能被回收,导致无法遍历。
🧠 为什么 globalSlice.head 不动?
因为:
- 全局 delete 必须能访问从索引启动以来的所有删除(只要还有 segment 没被 merge 掉)
- 如果
globalSlice.head向前移动,那么sentinel → head之间的节点就只有 DWPT 引用 - 一旦所有 DWPT 都 flush 完毕,这些节点就会被 GC
- 但此时如果有个老 segment 还没被 merge,它仍然需要那些 delete!→ 数据错误
所以:
✅
globalSlice.head必须永远钉在sentinel,确保整条删除链从头开始可达,防止 GC 过早回收。
而 sliceTail 只是一个逻辑进度标记,用于:
- 避免重复应用(虽然 BufferedUpdates 幂等,但能省则省)
- 记录"哪些 delete 已经打包进 FrozenBufferedUpdates"
✅ 总结回答你的问题:
"那中间节点呢?"
- 中间节点始终可以通过
sentinel → next → next ...访问到 globalSlice.sliceTail不是用来"划定区间起点",而是"标记上次处理到哪"- 实际遍历时从头开始 ,但只收集 tail 之后的新节点(或依赖 BufferedUpdates 的幂等性)
sliceHead不动(= sentinel)是为了防止 GC 错误回收仍在使用的 delete 节点
这看似"冗余遍历",实则是 Lucene 在 并发安全 + GC 友好 + 正确性 之间做的精巧平衡。