tantivy IndexWriter 的有界 channel
主线位置:一条消息在本站里的实例。tantivy 一侧的写入管道在 tantivy 卷 IndexWriter 里讲过;本页只看 channel 两端各由谁持有、谁先走,用来判断 常见坑:接收端都走了,有界 channel 里的消息还没析构 在本站会不会发生。下面的结论都是读 tantivy 0.27.0 源码得出的,没有在本站上复现。
两端各由谁持有
IndexWriter::new 建一个 crossbeam_channel::bounded(PIPELINE_MAX_SIZE_IN_DOCS),容量 10 000,元素是 AddBatch(SmallVec<[AddOperation; 4]>,add_document 一次一篇)。
- 发送端只有一个:
IndexWriter的字段operation_sender。add_document经send_add_documents_batch发送,发之前先看index_writer_status.is_alive()。 - 接收端有好几个:
IndexWriter的字段index_writer_status里存着一个(Arc<Inner>里的RwLock<Option<Receiver>>,index_writer_status.rs);- 每个索引线程从它克隆一个,再
.clone().into_iter()得到一个迭代器(add_indexing_worker),线程结束时这两个一起 drop。
- 每个索引线程还带一个「炸弹」
IndexWriterBomb:正常结束时defuse();出错(?返回Err)或 panic 时炸弹被 drop,调kill():把is_alive置假,并把index_writer_status里那个接收端take()掉。
本站的 module-search 用 index.writer(50_000_000):每线程至少要 15 MB,所以在 4 核以上的机器上是 3 个索引线程(50_000_000 / 15_000_000),接收端一共 1 + 3 × 2 = 7 个。
flowchart TB w["IndexWriter"] -->|operation_sender| ch["bounded(10_000):AddBatch"] st["index_writer_status:Option<Receiver>"] --> ch w --> st t1["索引线程 1:Receiver + 迭代器 + 炸弹"] --> ch t2["索引线程 2"] --> ch t3["索引线程 3"] --> ch t1 -.->|"出错或 panic:kill()"| st
正常路径:发送端总是先走
四个会关闭这条 channel 的地方,都是先换掉(drop)发送端:
| 路径 | 先做什么 | 之后 |
|---|---|---|
Drop for IndexWriter | drop_sender():把 operation_sender 换成一个新 bounded(1) 的发送端,旧的随之 drop | join 所有索引线程 |
prepare_commit(commit 的第一步) | recreate_document_channel:先换 operation_sender,再换 index_writer_status | join 旧线程、起新线程 |
rollback | *self = new_index_writer:旧 writer 就地 drop,走上面的 Drop | 自己把旧接收端排空 |
wait_merging_threads | drop_sender() | join |
发送端一走,channel 断开;接收端还在,索引线程照样把缓冲里的批取完(断开后剩余消息仍可收完,见 array),取完 recv 返回 Err,迭代器结束,线程 defuse 后退出。所以等到最后一个接收端 drop 时,缓冲已经是空的。0.5.15 的问题是「接收端先走光、发送端还在」,这四条路径都不是这个顺序。
drop_sender 换上的那个 bounded(1),接收端 _receiver 当场就 drop 了,之后没有任何东西往里发,也不受影响。
异常路径:所有索引线程都出错退出
接收端先于发送端走光,只有一种情况:所有索引线程都因为错误或 panic 退出。
- 一个线程出错退出:它的两个接收端 drop;炸弹
kill(),把index_writer_status里的接收端也拿走。其余线程还活着,继续从 channel 里取,缓冲会被它们消化。 - 所有线程都出错退出(比如磁盘写满,每个线程写段文件都失败):接收端走光,
operation_sender还在IndexWriter里。这时缓冲里还没被取走的批(最多 10 000 篇文档)在 0.5.15 上不析构。
它们留到 operation_sender 被换掉或 drop 的那一刻,也就是上表四条路径里任何一条先发生的时候:那时最后一个发送端走了,Counter 被释放,Channel 的 Drop 析构剩下的消息。此期间:
add_document先看is_alive(),已经是假,直接返回Err("An index writer was killed."),不会再往缓冲里加;- 如果最后一个线程退出时,调用方正卡在
send上(缓冲满了),断开会叫醒它,send返回Err,不会死锁。
在本站留多久
本站 module-search 的写入函数每次新开一个 writer、用完就扔:replace_all / upsert_documents / delete_documents 里 add_one(..)? 出错就提前返回,commit_and_reload 里 commit()? 出错也提前返回,两种情况下 writer 都在函数返回时 drop(tantivy 卷 本站的搜索模块)。所以在「所有索引线程都出错」这种本来就已经失败的写入里,积压的文档最多多活到这次写入函数返回。消息里是 TantivyDocument(标题、正文等字符串),不持有文件句柄或连接,影响只是这一段时间里多占一些内存。
结论:0.5.15 的这个行为在本站没有实际影响,不需要为它改依赖或改代码。
升级之后
- 升到 0.5.17:这一处没有变化(版本差异)。
- 升到包含
main上 #1121 的版本:异常路径里积压的文档会在最后一个索引线程退出、drop 它的接收端时析构,发生在那个索引线程上,而不是在调用方线程 drop writer 时。正常路径不变(那时缓冲已空)。对本站来说只是析构提前了一点、换了个线程。
小结
- 本站生产构建里唯一在运行的 crossbeam channel 是 tantivy
IndexWriter的bounded(10_000);发送端一个(writer 自己),接收端是 writer 的状态对象加每个索引线程各两个。 - drop、commit、rollback、
wait_merging_threads都先放掉发送端,索引线程把缓冲取完才退出,0.5.15 的问题碰不到。 - 只有所有索引线程都出错退出时,积压的文档会留到 writer 的发送端被换掉;在本站就是那次写入函数返回时,影响仅是内存。