IndexWriter
主线位置:写入主线的第一、二步。
Index::writer(budget)拿目录锁、算出线程数、启动索引线程;三次add_document各领一个 opstamp,经有界 channel 交给某个索引线程。线程拿到文档以后怎么建段,在 SegmentWriter 与内存池;commit在提交。
flowchart TB wr["Index writer"] --> lock["拿写锁"] wr --> nt["算线程数和预算"] lock --> nw["IndexWriter new"] nt --> nw nw --> su["SegmentUpdater 1 线程"] nw --> mt["合并线程池 4"] nw --> wk["索引线程 x N"] add["add_document"] --> st["Stamper 领 opstamp"] st --> ch["bounded 10000"] ch --> wk wk --> seg["每线程一个 SegmentWriter"]
IndexWriter 在 src/indexer/index_writer.rs,文件 2875 行,其中实现约 830 行,其余是单元测试。它自己不建索引:它持有一个发送端、一组索引线程的 JoinHandle、一个 SegmentUpdater、一个删除队列和一个 Stamper,把文档分给线程、把提交和合并交给 SegmentUpdater。
实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建。available_parallelism() 是 12(8 个性能核 + 4 个能效核),本页凡是线程数都依赖这个数。实验程序在 idx-lab/src/bin/,语料是仓库自带的 benches/wiki.json(1000 篇英文维基摘要)。
线程数和内存预算
Index::writer 只有几行:
pub fn writer<D: Document>(
&self,
memory_budget_in_bytes: usize,
) -> crate::Result<IndexWriter<D>> {
let mut num_threads = std::cmp::min(available_parallelism()?.get(), MAX_NUM_THREAD);
let memory_budget_num_bytes_per_thread = memory_budget_in_bytes / num_threads;
if memory_budget_num_bytes_per_thread < MEMORY_BUDGET_NUM_BYTES_MIN {
num_threads = (memory_budget_in_bytes / MEMORY_BUDGET_NUM_BYTES_MIN).max(1);
}
self.writer_with_num_threads(num_threads, memory_budget_in_bytes)
}
参数是总预算。先按核数取线程数,上限 MAX_NUM_THREAD = 8;平分下来每线程不足 MEMORY_BUDGET_NUM_BYTES_MIN(1_000_000 * 15,即 15,000,000 字节)时,线程数改成 总预算 / 15 MB,向下取整,至少 1。然后 writer_with_num_threads 再除一次,得到每线程预算,交给 IndexWriterOptions。
总预算 / 15 MB 向下取整,保证除回去的每线程预算不小于 15 MB。例外是 .max(1):总预算不足 15 MB 时线程数也被抬到 1,每线程预算就是总预算本身,到 IndexWriter::new 里才被拒:
if options.memory_budget_per_thread < MEMORY_BUDGET_NUM_BYTES_MIN {
let err_msg = format!(
"The memory arena in bytes per thread needs to be at least \
{MEMORY_BUDGET_NUM_BYTES_MIN}."
);
return Err(TantivyError::InvalidArgument(err_msg));
}
writer(50_000_000) 开几个索引线程?本机 available_parallelism() 是 12。用 index.writer(50_000_000) 建一个 writer,合并策略换成 NoMergePolicy,连续 add_document 两万篇很短的文档("a b c d e f g"),提交一次。每个拿到过文档的索引线程都会交出自己的段,所以段数就是线程数。会有几个段?writer(29_999_999)、writer(30_000_000)、writer(120_000_000) 呢?
IndexWriterOptions 是 0.24 加的 builder(PR #2562),三个字段:每线程预算(默认 15 MB)、索引线程数(默认 1)、合并线程数(默认 4)。合并线程数只能从这里改,writer 和 writer_with_num_threads 都用默认的 4。直接用 writer_with_options 时,三个检查都在 IndexWriter::new 里,实测:
0 worker threads: Err An invalid argument was passed: 'At least one worker thread is required, got 0'
u32::MAX per thread: Err An invalid argument was passed: 'The memory arena in bytes per thread cannot exceed 4293967295'
上限是 u32::MAX - 1_000_000,因为内存池的地址是 32 位(见 SegmentWriter 与内存池)。线程数 0 的检查在 writer_with_num_threads 这条路上走不到:它先做 overall_memory_budget_in_bytes / num_threads,writer_with_num_threads(0, 50_000_000) 直接 panic:
thread 'main' (6127582) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tantivy-0.27.0/src/index/index.rs:725:48:
attempt to divide by zero
max0.15 之前,Index::writer 的线程数就是 num_cpus::get(),核越多线程越多,总预算被摊得越薄。PR #1033(「Capping IndexWriter Num thread」,首次发布在 0.15)加了 MAX_NUM_THREAD = 8,把那一行改成 std::cmp::min(num_cpus::get(), MAX_NUM_THREAD)。合并前的提交记录里有一条「fix bug from max to min」:取上限要用 min,第一版写反了。现在的代码只是把 num_cpus::get() 换成了标准库的 available_parallelism()。
一个 writer 起几个线程
IndexWriter::new 依次建:crossbeam_channel::bounded(PIPELINE_MAX_SIZE_IN_DOCS)、空的删除队列、从 meta.json 读出 opstamp 起点的 Stamper、SegmentUpdater::create,最后 start_workers 起 N 个索引线程。SegmentUpdater 里有两个 rayon 线程池:一个只有 1 个线程(名字 segment_updater),所有元数据变更都排在它上面;一个是合并线程池(merge_thread_0 到 merge_thread_3)。
另外每个段写手默认还有一个压缩 doc store 的线程(docstore-compressor-thread,由 IndexSettings::docstore_compress_dedicated_thread 控制,默认开),在索引线程拿到第一篇文档、建 SegmentWriter 时才起。用 ps -M 数 writer(50_000_000) 前后的线程数:
t1 pid=22645 threads= 1
t2 pid=22645 threads= 9
t3 pid=22645 threads= 12
t4 pid=22645 threads= 1
t1 还没有 writer;t2 刚建好 writer:主线程、3 个索引线程、1 个 segment_updater、4 个合并线程,共 9;t3 加了 10 篇文档,3 个索引线程各起了一个压缩线程,共 12;t4 drop 之后全部退出。一个 50 MB 的 writer 在本机一共 11 个线程,这就是为什么本站那种「每次 upsert 新开一个 writer」的用法,每次都要付一遍线程创建的钱(这部分很便宜,见提交里的计时)。
目录锁
同一个索引目录同时只能有一个 writer。Index::writer_with_options 第一件事就是拿 INDEX_WRITER_LOCK(文件 .tantivy-writer.lock,非阻塞),拿不到就包成 TantivyError::LockFailure。锁的生命周期绑在 IndexWriter::_directory_lock 字段上,writer drop 时一起释放。
拿锁在检查预算之前,所以预算不合法时也会先拿到锁、再随着出错的 IndexWriter::new 返回而释放。同一个 Index、或者同一目录另开的 Index,第二个 writer 都拿到同样的错误,Display 和 Debug 实测:
disk second (other Index handle): Err Display = Failed to acquire Lockfile: LockBusy. Some("Failed to acquire index lock. If you are using a regular directory, this means there is already an `IndexWriter` working on this `Directory`, in this process or in a different process.")
disk second (other Index handle): Err Debug = LockFailure(LockBusy, Some("Failed to acquire index lock. If you are using a regular directory, this means there is already an `IndexWriter` working on this `Directory`, in this process or in a different process."))
disk second (other Index handle): LockError = Could not acquire lock as it is already held, possibly by a different process.
RamDirectory 上的第二个 writer 得到一模一样的文字。注意 Display 里把 Option 原样打成了 Some("...")。
锁怎么实现,两种目录不一样。Directory trait 的默认实现 acquire_lock 用「独占创建文件」当锁,守卫 drop 时删掉这个文件,RamDirectory 用的就是它。MmapDirectory::acquire_lock 覆盖了它:打开(必要时创建)锁文件,用 fs4 的 try_lock_exclusive 加锁,Unix 上就是 flock;锁随文件句柄关闭释放,文件本身不删。实测 drop writer 之后目录里还是:
files after drop: [(".managed.json", 14), (".tantivy-writer.lock", 0), ("meta.json", 917)]
disk after drop: Ok
问: 进程崩溃以后 .tantivy-writer.lock 留在目录里,下次启动还能开 writer 吗?IndexWriter::new 的文档说「a stale lockfile will be left in the index directory」,要手动删吗?
答: 用 MmapDirectory 时不用。锁是文件上的 flock,进程一退出内核就释放了,文件在不在都不影响;上面的实验里文件一直在,第二次拿锁照样成功。那段文档描述的是 trait 默认实现「文件存在即上锁」的语义,对 MmapDirectory 已经不成立。同一处文档还说出错时返回 Error::FileAlreadyExists(Index::writer)或 Error::DirectoryLockBusy(writer_with_num_threads),这两个变体在 0.27.0 的 TantivyError 里都不存在,实际是 LockFailure(LockBusy, ..)。
add_document:领 opstamp,进 channel
add_document 只做两件事:
pub fn add_document(&self, document: D) -> crate::Result<Opstamp> {
let opstamp = self.stamper.stamp();
self.send_add_documents_batch(smallvec![AddOperation { opstamp, document }])?;
Ok(opstamp)
}
Stamper 是一个 Arc<AtomicU64>,stamp() 就是 fetch_add(1);clone 出来的副本共用同一个计数器,SegmentUpdater 手里也有一份。opstamp 是这个 writer 上所有操作的全序:加文档、删除、提交都领号,删除只作用于 opstamp 比它小的文档。它的完整含义在提交讲。
send_add_documents_batch 把一个 AddBatch(SmallVec<[AddOperation; 4]>)塞进 channel。channel 是 crossbeam_channel::bounded(10_000),单位是批,add_document 一批一篇,所以最多积压一万篇;满了 send 就阻塞,这是写入路径上唯一的背压。所有索引线程共用一个接收端,谁空闲谁取,所以多线程时文档进哪个段、段内 doc id 的顺序都不确定(writer_for_tests 只开 1 个线程,注释说的就是「a deterministic allocation of DocId」)。
run 是批量版:一次领 n + 1 个连续的 opstamp,删除直接进删除队列,加文档的操作攒成一个 AddBatch 发出去。一个批是一个整体,同一批的文档一定进同一个段。
索引线程的循环在 add_indexing_worker:
loop {
let mut document_iterator = document_receiver_clone
.clone()
.into_iter()
.filter(|batch| !batch.is_empty())
.peekable();
// The peeking here is to avoid creating a new segment's files
// if no document are available.
// ...
if let Some(batch) = document_iterator.peek() {
assert!(!batch.is_empty());
delete_cursor.skip_to(batch[0].opstamp);
} else {
// No more documents.
// It happens when there is a commit, or if the `IndexWriter`
// was dropped.
index_writer_bomb.defuse();
return Ok(());
}
index_documents(
mem_budget,
index.new_segment(),
&mut document_iterator,
&segment_updater,
delete_cursor.clone(),
)?;
}
外层每转一圈是一个新段:peek 先等到第一批文档,再 index.new_segment(),没有文档就不建段、不建文件。index_documents 在段满或者 channel 关闭时返回,channel 关闭时 peek 得到 None,线程退出。index_writer_bomb 是一个 drop 守卫:线程因为出错或 panic 没走到 defuse() 就退出时,它把整个 writer 标成死的,之后的 add_document 都返回 TantivyError::ErrorInThread(send_add_documents_batch 先查 is_alive())。
rollback 与 Drop
impl<D: Document> Drop for IndexWriter<D> {
fn drop(&mut self) {
self.segment_updater.kill();
self.drop_sender();
for work in self.workers_join_handle.drain(..) {
let _ = work.join();
}
}
}
三步:先把 SegmentUpdater 标成 killed,之后任何排给它的任务都直接返回 Segment updater killed;再把发送端换成一个新的(旧的随之 drop,channel 关闭);最后 join 所有索引线程。
关键在 join。channel 关闭以后,接收端仍然能把缓冲里的批取完,索引线程会继续把积压的文档一篇篇分词、写进内存池;段满了或者取完了,index_documents 看到 !segment_updater.is_alive() 就直接返回,不 finalize、不交段。也就是说,drop 会等最多一万篇文档被索引完,然后全部扔掉。
实验:一个 writer(50_000_000) 加两万篇维基摘要(20 遍 wiki.json),不提交,直接 drop:
add_document x20000 returned after 261.706125ms
before drop: 36 files
drop took 138.13325ms
after drop: 63 files
.managed.json:2454
.tantivy-writer.lock:0
41da853245d74641b088a5d04db8cafa.fast:0
41da853245d74641b088a5d04db8cafa.fieldnorm:0
41da853245d74641b088a5d04db8cafa.idx:0
41da853245d74641b088a5d04db8cafa.pos:0
41da853245d74641b088a5d04db8cafa.store:123
41da853245d74641b088a5d04db8cafa.term:0
5ccf39a6b2fb4b3fb17a0b9e4a7d5a6e.fast:146
5ccf39a6b2fb4b3fb17a0b9e4a7d5a6e.fieldnorm:8027
5ccf39a6b2fb4b3fb17a0b9e4a7d5a6e.idx:515400
5ccf39a6b2fb4b3fb17a0b9e4a7d5a6e.pos:612542
5ccf39a6b2fb4b3fb17a0b9e4a7d5a6e.store:75666
5ccf39a6b2fb4b3fb17a0b9e4a7d5a6e.term:247445
...
meta.json:917
docs visible after reopen: 0
add_document两万次只用了 262 ms,因为它只是领号、入队;channel 满了才会等。drop用了 138 ms:这是三个索引线程把积压的文档索引完的时间。- 磁盘上留下 10 组段文件。3 组是完整的(
.idx有 500 KB):drop 之前就因为内存满而定稿的段,已经交给了SegmentUpdater,但还没提交。另外 7 组除了.store全是 0 字节:drop 那一刻正在写的段,以及之后线程继续消化积压时新建的段,都没有走到finalize。段写手一建好就打开了全部文件,.store是边加文档边写的,其他文件要等finalize才写,见 SegmentWriter 与内存池。 meta.json还是 917 字节,重新打开看到 0 篇文档。
这些孤儿文件登记在 .managed.json 里,下一次提交时的 GC 会删掉。实验的最后一步在同一个目录上开 writer、做一次空提交,目录回到:
after an empty commit: [".managed.json:14", ".tantivy-meta.lock:0", ".tantivy-writer.lock:0", "meta.json:917"]
Drop 不等合并。合并线程在 SegmentUpdater 的线程池里,kill() 之后它们照样跑完,但交结果的 end_merge 要排进已经 killed 的 SegmentUpdater,结果被丢掉。要等合并就得调 wait_merging_threads,见合并。
rollback 回到上一次提交:先 kill(),把目录锁从自己身上拿走,用它 IndexWriter::new 出一个新 writer,再 *self = new_index_writer。这一句让旧 writer 就地 drop,走的正是上面的 Drop,所以 rollback 的耗时和 drop 差不多:同样两万篇,实测 rollback took 157.630667ms, returned 0。
动脑筋答案
available_parallelism = 12
writer(10000000) -> Err: An invalid argument was passed: 'The memory arena in bytes per thread needs to be at least 15000000.'
debug: InvalidArgument("The memory arena in bytes per thread needs to be at least 15000000.")
writer(14999999) -> Err: An invalid argument was passed: 'The memory arena in bytes per thread needs to be at least 15000000.'
debug: InvalidArgument("The memory arena in bytes per thread needs to be at least 15000000.")
writer(15000000) -> 1 segments [20000]
writer(29999999) -> 1 segments [20000]
writer(30000000) -> 2 segments [10282, 9718]
writer(50000000) -> 3 segments [7172, 7122, 5706]
writer(100000000) -> 6 segments [5374, 4252, 3827, 2887, 2466, 1194]
writer(119999999) -> 7 segments [4839, 3993, 3941, 2839, 2500, 1222, 666]
writer(120000000) -> 8 segments [4322, 4231, 4131, 2290, 1838, 1551, 860, 777]
writer(400000000) -> 8 segments [4449, 4295, 3578, 2154, 1582, 1418, 1384, 1140]
writer(50_000_000):min(12, 8) = 8,50_000_000 / 8 = 6_250_000不到 15 MB,线程数改成50_000_000 / 15_000_000 = 3,每线程16_666_666字节。三个段,大小不均:哪个线程先空下来就先取,doc 数取决于调度。29_999_999除 15 MB 取整是 1,30_000_000是 2;要用满 8 个线程,总预算至少120_000_000,少一个字节就只剩 7 个。- 不足 15 MB 时线程数被
.max(1)抬到 1,每线程预算等于总预算,被IndexWriter::new拒掉。
小结
Index::writer(总预算):线程数min(核数, 8),每线程不足 15 MB 时改成总预算 / 15 MB(至少 1);本机 12 核,50 MB 是 3 个线程、每个 16,666,666 字节,用满 8 线程要 120 MB。- 一个 writer 的线程:N 个索引线程、1 个
segment_updater、4 个合并线程,加上每个段写手一个 doc store 压缩线程;50 MB 的 writer 本机共 11 个。 - 目录锁:
MmapDirectory用flock,锁文件不删也不碍事;第二个 writer 得到LockFailure(LockBusy, ..),文档里写的两个错误名都不存在。 add_document领 opstamp、进容量一万批的 channel,满了才阻塞;多线程下 doc 进哪个段不确定,run保证一批进同一个段。- drop 先 kill
SegmentUpdater,再关 channel、join 索引线程:积压的文档会被索引完再丢掉,已定稿未提交的段也丢掉,孤儿文件等下一次提交的 GC 清理;drop 不等合并,rollback内部就是一次 drop。