DocumentsWriterDeleteQueue 的核心设计思想

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)时,它会:

  1. 消费并完成该文档的处理;
  2. 更新其私有的 DeleteSlice ------ 要么调用 updateSlice(),要么(如果文档带有 delTerm)调用 add(Node, DeleteSlice)
  3. 将该 slice 中的所有删除操作应用到它自己的 BufferedUpdates 上,并重置 slice;
  4. 递增其内部的文档 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 如何自动清理?
  • 链表节点一旦没有任何 DeleteSliceheadtail 指向它,就变成不可达对象。
  • 例如:所有 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);

内部流程:

  1. DWPT-A 处理 newDoc
  2. 构造 delNode = new TermNode(Term("id","100"))
  3. 调用 deleteQueue.add(delNode, dwptSlice)
    • 全局队列追加 delNode
    • dwptSlice.sliceTail = delNode ← 标记"我负责处理这个 delete"
  4. 文档继续处理(分析、存储等)
  5. 后续 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 = sentinel
  • globalBufferedUpdates:一个缓冲区,存放"待应用到历史 segments 的 delete/update"
它的生命周期:
  1. 创建时globalSlice 指向哨兵节点(表示"从头开始关注所有 delete")
  2. 每当有 delete 发生 :通过 tryApplyGlobalSlice() 尝试将新 delete 应用到 globalBufferedUpdates
  3. 当需要刷新 searcher 或执行 merge 时
    • 调用 freezeGlobalBuffer(null)(callerSlice = null)
    • globalSlice 推进到当前 tail
    • globalBufferedUpdates 冻结为 FrozenBufferedUpdates
    • 清空 globalBufferedUpdates
  4. 冻结后的 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(...);

内部发生:

  1. delete(term) → 全局队列追加 delNode
  2. tryApplyGlobalSlice()globalSlice 推进,delNode 加入 globalBufferedUpdates
  3. DWPT flush → 生成 _1,其 FlushedSegment 不包含这个 delete(因为 delete 发生在它 buffer 之前)
  4. 当你调用 writer.getReader()
    • 触发 maybeFreezeGlobalBuffer()
    • 返回 FrozenBufferedUpdates 包含 del(id=100)
    • searcher 用它标记 _0 中的 doc 为 deleted

✅ 所以 _0 被逻辑删除,_1 不受影响(因为它没这个 doc)


💡 五、关键设计思想

  1. 分离关注点

    • DWPT 只关心"我的 segment 需要哪些 delete"
    • 全局池关心"历史 segments 需要哪些 delete"
  2. 避免重复应用

    一个 delete 不会同时被 DWPT 和全局池应用到同一个 segment

  3. 延迟应用 + 批量处理

    全局 delete 不立即写 .del 文件,而是累积后批量冻结,减少 I/O

  4. GC 安全

    一旦 globalSlice 推进,旧节点若无 DWPT 引用,即可回收


📁 六、实际存储形式

冻结后的全局 delete 最终会变成:

  • .del 文件:如果只包含 term/query deletes
  • FieldInfos + 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"));

问题来了:

这条删除命令,怎么让已经写到磁盘上的 _0 segment "知道"要删掉 id=123 的文档?

因为:

  • _0 是只读文件,不能直接修改
  • 删除不能只影响未来的新文档,必须影响已经存在的旧文档

✅ Lucene 的解决方案:用"删除清单"标记

Lucene 不会真的去改 _0 文件,而是:

单独记一张"删除清单" ,说:"在读 _0 的时候,跳过 id=123 这条"。

这张"删除清单",就存在 全局的 BufferedUpdates

"slice" 就是用来跟踪"这张清单更新到哪了"的指针


🔁 回到 DocumentsWriterDeleteQueue:所有删除先排队

每次你调 deleteDocuments(),Lucene 会:

  1. 把这个删除操作(比如 del(id=123))放进一个全局的删除队列 (就是 DocumentsWriterDeleteQueue

  2. 这个队列像一条链子:

    复制代码
    [哨兵] → [del(id=123)] → [del(id=456)] → ...

👁️ 两个"观察者"看这个队列

有两类"人"需要从这个队列里拿删除操作:

观察者 负责谁? 怎么看队列?
每个 DWPT(写新文档的线程) 只关心自己即将生成的新 segment 从"我开始写文档那一刻"往后看删除
全局 delete pool (也就是 globalSlice 关心所有已经存在的旧 segment 队列最开头(哨兵)开始看所有删除

globalSlice 就是"全局 delete pool"用来记录"我看到哪了"的书签。


📌 举个具体例子

时间线:

  1. t=0 :索引已有 segment _0(包含 doc id=123)
  2. t=1 :你调 deleteDocuments(new Term("id", "123"))
    • 删除操作被加到队列尾部
  3. t=2 :你调 writer.getReader() 想搜一下
    • Lucene 说:"等等,我得先看看有没有新的删除要应用到旧 segment"
    • 它检查 globalSlice
      • 上次看到哨兵
      • 现在队列尾部是 del(id=123)
    • 于是它把 del(id=123) 复制一份 ,存到 globalBufferedUpdates
    • 然后生成一个 FrozenBufferedUpdates 包裹
  4. ** 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 确实有 sliceHeadsliceTail 两个指针 ,但在实际使用中,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 表示是全局)
  • 它会:
    1. 获取当前 queue.currentTail = del4
    2. sentinel.next(即 del1)开始遍历,一直到 del4
    3. 把 del1 ~ del4 全部加到 globalBufferedUpdates
    4. 更新 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 就知道:"前面的都处理过了,从下一个开始收"

但实际上,在代码中(如 applyDeletestryApplyGlobalSlice),它并不会真的"跳过"前面的节点,而是:

💡 globalBufferedUpdates 本身是幂等的(idempotent):重复添加同一个 delete term 不会有副作用(比如用 Set 存储)。

所以即使"重新遍历前面的",也不会出错,只是略微多花点时间。

但更重要的是:GC 依赖这种"从 sentinel 开始引用"的结构 。如果 globalSlicehead 移走了,前面的节点就可能被回收,导致无法遍历。


🧠 为什么 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 友好 + 正确性 之间做的精巧平衡。


相关推荐
风味蘑菇干8 小时前
Stream基础题目
java·算法
2501_932750268 小时前
Java反射机制基础入门
java·开发语言
500849 小时前
HCCL 集合通信编程:多卡协同的正确姿势
java·flutter·性能优化·electron·wpf
asdfg12589639 小时前
Java中的Comparator 和JS中的回调函数好相似
java·开发语言
会编程的土豆9 小时前
消息队列(MQ)入门笔记
java·笔记·spring
专注VB编程开发20年9 小时前
python运行提速方案全解
java·linux·服务器
涤生大数据9 小时前
大数据面试高频题:row_number() 数据倾斜到底怎么解决?
java·大数据·面试
weixin_446729169 小时前
注解和反射
java·开发语言
摇滚侠9 小时前
HashMap 源码解析 底层原理 面试如何回答
java·面试·职场和发展