Directory
主线位置:磁盘主线的第二步。
Index::open_in_dir把一个目录包成MmapDirectory,再套一层ManagedDirectory;之后读段文件、剥掉 footer、拿到FileSlice,都经过这里。写入主线的两头也在这里:Index::writer先拿.tantivy-writer.lock,commit最后原子写meta.json并做一次垃圾回收。查询主线里IndexReader靠这里的watch知道有新提交。
flowchart TB idx["Index"] --> md["ManagedDirectory"] md --> mm["MmapDirectory"] md --> ram["RamDirectory"] md -->|open_read| foot["剥 footer、查版本"] foot --> fs["FileSlice"] fs --> ob["OwnedBytes"] md -->|open_write| reg["登记 .managed.json"] reg --> proxy["FooterProxy 算 crc"] md -->|atomic_write| meta["meta.json"] meta --> watcher["FileWatcher 轮询"] md -->|garbage_collect| gc["删不再用的文件"]
src/directory/ 共 3203 行(含测试),FileSlice 在 common/src/file_slice.rs(440 行),OwnedBytes 在独立的 ownedbytes crate。实验 crate 是 exp-tantivy/stora-disk:M2 Max、macOS 27、rustc 1.99.0,release 构建;索引是 benches/wiki.json 的全部 1000 篇文档,schema 为 url(STRING | STORED)、title(TEXT | STORED)、body(TEXT),单线程 writer、提交一次,得到一个段。
一个索引目录里有什么
提交并等 writer 退出后,目录是这样的:
lock file while writer alive: true
lock file after writer dropped: true
== files
258 .managed.json
0 .tantivy-meta.lock
0 .tantivy-writer.lock
146 67591bbf248c4dbaa5a27fca65d32399.fast
3118 67591bbf248c4dbaa5a27fca65d32399.fieldnorm
200383 67591bbf248c4dbaa5a27fca65d32399.idx
245863 67591bbf248c4dbaa5a27fca65d32399.pos
30922 67591bbf248c4dbaa5a27fca65d32399.store
235399 67591bbf248c4dbaa5a27fca65d32399.term
1040 meta.json
== .managed.json
["meta.json","67591bbf248c4dbaa5a27fca65d32399.fast","67591bbf248c4dbaa5a27fca65d32399.pos","67591bbf248c4dbaa5a27fca65d32399.term","67591bbf248c4dbaa5a27fca65d32399.store","67591bbf248c4dbaa5a27fca65d32399.fieldnorm","67591bbf248c4dbaa5a27fca65d32399.idx"]
三类文件:段文件(<段 id>.<扩展名>,每个文件是什么见一个段),meta.json(段列表、schema、opstamp,见提交),以及三个以点开头的文件:两个锁和 .managed.json。没有删除过文档,所以没有 .del。
Directory:只写一次,多次读
Directory 的注释开头是「Write-once read many (WORM) abstraction」。它不提供 Read/Seek,也不允许改已有文件:
| 方法 | 语义 |
|---|---|
open_read | 返回 FileSlice。打开之后,文件再怎么变都不影响这个切片 |
open_write | 文件必须不存在,返回 WritePtr(BufWriter<Box<dyn TerminatingWrite>>);调用方必须 terminate(),不能靠 Drop |
atomic_read / atomic_write | 整个读、整个替换,只用于小文件,实际就是 meta.json 和 .managed.json |
delete、exists、sync_directory | 和 POSIX 的对应操作同义 |
acquire_lock | 有默认实现:用 open_write 建锁文件,Drop 时删掉 |
watch | 注册回调,meta.json 被 atomic_write 改了就调 |
段文件全部经 open_write 写出、写完不再改,只有 meta.json 原地替换。这个约定让读端可以放心地 mmap 整个文件,也让「一个文件还被读着、就被删除」这种情况可以交给操作系统处理(见下面的垃圾回收)。
FileSlice 与 OwnedBytes
FileSlice 只有两个字段:Arc<dyn FileHandle> 和一个字节范围。slice、split、split_from_end 只改范围,不读任何字节;read_bytes 才调 FileHandle::read_bytes,拿到一个 OwnedBytes:
pub struct OwnedBytes {
data: &'static [u8],
box_stable_deref: Arc<dyn Deref<Target = [u8]> + Sync + Send>,
}
一个指向数据的切片,加上一个把数据「拴住」的 Arc。对 mmap 来说,read_bytes 只是把 Arc<Mmap> 克隆一份、算出子切片,不拷贝。思路和 bytes::Bytes::from_owner 相同(见 bytes 卷的五种存储),只是 OwnedBytes 只读、没有 vtable。
读段文件的代码处处是这种写法:先 split_from_end 切出尾部的长度字段,read_bytes 读这几个字节,再用读到的长度切下一层。后面三页(倒排表、位置、词典)解析文件格式时都是这样一层层剥的。
FileSlice 不要求数据在内存里。FileHandle 还有一个隐藏的 read_bytes_async,quickwit feature 下的远程目录靠它按需取一段字节;本地的 MmapDirectory 用不上。
MmapDirectory
弱引用的 mmap 缓存
MmapDirectory::get_file_handle 不直接 mmap,先查 MmapCache。缓存是 HashMap<PathBuf, Weak<...>>,get_mmap 先试着 upgrade:
if let Some(mmap_weak) = self.cache.get(full_path) {
if let Some(mmap_arc) = mmap_weak.upgrade() {
self.counters.hit += 1;
return Ok(Some(mmap_arc));
}
}
self.cache.remove(full_path);
self.counters.miss += 1;
存的是 Weak,所以缓存本身不让映射活着:只要还有 OwnedBytes 在用,同一个文件再打开就命中;所有使用者都放手后,映射被 munmap,下次打开就是未命中。实验里同一个 MmapDirectory 先后建了几个 reader(每个用完就丢),中途和结束各打一次 get_cache_info:
mmap cache: CacheCounters { hit: 9, miss: 12 }
...
mmap cache: CacheCounters { hit: 9, miss: 18 }, 6 files mapped
一个段 6 个文件。第二次只多了 6 次未命中、没有命中,说明前一个 reader 丢掉后它的映射全部释放了,新 reader 重新映射了一遍。
空文件不能 mmap,open_mmap 遇到长度为 0 返回 None,上层换成空的 OwnedBytes。Unix 上可以用 open_with_madvice 给每个新映射加一个 madvise(比如 Advice::Random),错误被忽略。
写
open_write 用 create_new(true) 打开,文件已存在就是 OpenWriteError::FileAlreadyExists,这就是 WORM 的「只写一次」。写入对象是 SafeFileWriter:它的 flush 什么也不做,terminate 才 flush 加 sync_data。新文件的目录项要等 sync_directory(打开目录本身、sync_data)才算落盘,代码注释专门说明了这一点。
atomic_write 在同一目录下建临时文件、写完 sync_data、再 persist 改名覆盖目标。放在同一目录是为了保证改名不跨文件系统,改名才是原子的。
监听 meta.json
FileWatcher 不用任何文件系统事件接口。第一次 watch 时 spawn 一个名为 thread-tantivy-meta-file-watcher 的线程,每隔 POLLING_INTERVAL(500ms,测试构建是 1ms)把 meta.json 逐行读一遍算 crc32(compute_checksum),和上次不同就广播给所有回调,并等它们执行完。第一次读到时没有「上次」,也算变化,所以回调在注册后很快会先被调一次。FileWatcher 被 drop 时把状态改成 2,线程在下一轮醒来时退出。
IndexReader 的 ReloadPolicy::OnCommitWithDelay 就挂在这里,所以「提交后多久能搜到」的上限里有这 500ms;具体见 IndexReader 与 Searcher。
notify 监听目录0.14 之前,mmap feature 依赖 notify 4,靠文件系统事件发现 meta.json 的变化。早在 0.10.1,CHANGELOG 就记着「A few users experienced problems with the directory watching system」,当时的处理是推迟到真有 reader 用到时才开始监听。PR #924(提交 2875deb4b「Implement FileWatcher」,首次进入 0.14)删掉了这个依赖,Cargo.toml 里 mmap = ["fs2", "tempfile", "memmap", "notify"] 变成不带 notify。0.14 的 CHANGELOG 写的是:「Removed dependency on notify #924. Replaced with FileWatcher struct that polls meta file every 500ms in background thread.」今天的 file_watcher.rs 仍是这个结构:一个线程、500ms、crc32。代价是最多半秒的延迟和一个常驻线程,换来的是不再依赖各平台的文件事件接口,少了一个依赖。
RamDirectory
RamDirectory 是 Arc<RwLock<InnerDirectory>>,里面一个 HashMap<PathBuf, FileSlice>。写入对象 VecWriter 往自己的 Vec<u8> 里写,terminate 时 mem::take 出来、shrink_to_fit,整个放进 map。没有轮询线程:atomic_write 写的若是 meta.json,当场广播。锁用的是 trait 的默认实现(见下面的锁)。
0.27.0 里它还多了一个 total_mem_usage:已经落定的文件加上还在写的 VecWriter 占的容量,后者每攒够 10000 字节才更新一次共享计数。
RamDirectory 每次 flush 都复制整个文件0.26.1 的 VecWriter::flush 是 fs.write(self.path.clone(), self.data.get_ref()):把当前缓冲区整个复制一份放进 map,terminate 也只是调一次 flush。Directory 的约定当时要求调用方用 flush 让写入可见,写一个大文件时中途每 flush 一次就多复制一次。0.27.0 的 CHANGELOG 在「Breaking change」下记着:「Directory writers only need terminate(), not flush(). RamDirectory::deep_clone is removed #3095」,对应的提交 98d91f043 说明是「Intermediate writes are an implementation detail. From Tantivy side we only decide when a file is finished. This change is done to remove flush overhead for VecWriter. On finalization we just move the Vec to the directory.」现在的 VecWriter::flush 是空的,terminate_ref 把 Vec 移进去,不再复制。MmapDirectory 的 SafeFileWriter 在 0.26.1 已经是空 flush,这次只删掉了 open_write 里一次多余的 file.flush(),并把注释改成「calling sync_all on terminate」。
ManagedDirectory 与垃圾回收
Index 从不直接用 MmapDirectory,而是 ManagedDirectory::wrap 一层。ManagedDirectory 回答一个问题:哪些文件是 tantivy 自己建的、可以删。
register_file_as_managed在open_write和atomic_write之前把路径加进集合,并把集合用atomic_write存成.managed.json。先登记后建文件,崩在中间留下的是「登记了但不存在」的条目,下次 GC 删除时遇到FileDoesNotExist也当成删掉了;反过来的顺序会留下永远没人删的文件。- 以点开头的文件不登记(
is_managed),锁文件和.managed.json本身因此不会被 GC。meta.json是登记了的,GC 时由调用方放进「活文件」列表保住。 garbage_collect在拿着META_LOCK的情况下调用get_living_files,集合里不在活文件列表的就删。调用方是SegmentUpdater的garbage_collect_files,提交后和合并后各跑一次,活文件来自list_files:所有被跟踪的段(包括还在合并的)的文件加meta.json。
实验:关掉合并策略,分两次提交各 500 篇,拿着一个 Searcher,再手动把两个段合并:
== before merge: 2 segments
502 .managed.json
...
107382 5bbc4c83a2b547819f6e833826316efc.idx
...
93212 fdc6adc1b5f74f4cbe87331b7d8f9a67.idx
...
== after merge + gc
258 .managed.json
0 .tantivy-meta.lock
0 .tantivy-writer.lock
146 36ec6b6b1fba4d93b4b8fa8b79110c3c.fast
3118 36ec6b6b1fba4d93b4b8fa8b79110c3c.fieldnorm
200383 36ec6b6b1fba4d93b4b8fa8b79110c3c.idx
245863 36ec6b6b1fba4d93b4b8fa8b79110c3c.pos
30922 36ec6b6b1fba4d93b4b8fa8b79110c3c.store
235399 36ec6b6b1fba4d93b4b8fa8b79110c3c.term
1040 meta.json
.managed.json: ["36ec6b6b1fba4d93b4b8fa8b79110c3c.pos", ... "meta.json", ...]
old searcher (2 deleted segments) still counts: 849
new searcher: 1 segments, count 849
两个旧段的 12 个文件从目录和 .managed.json 里都消失了。合并出来的段和一次提交 1000 篇得到的段,每个文件的字节数完全一样。
问: 旧 Searcher 还映射着那 12 个文件,GC 把它们删了,这个 Searcher 为什么还能查出 849?
答: 在 macOS 和 Linux 上,remove_file 只删目录项;已经 mmap 的文件,内核要等最后一个映射解除才回收数据。旧 Searcher 手里的 OwnedBytes 拴着 Arc<Mmap>,所以照常可读,直到它被丢掉。Windows 不允许删除正被映射的文件,删除失败的路径进 failed_to_delete_files,留在 .managed.json 里,下一次 GC 再试(garbage_collect 在 Windows 上连错误日志都不打)。META_LOCK 防的是另一件事:别的进程的 reader 刚读了 meta.json、还没来得及打开段文件,GC 就把文件删了。open_segment_readers 读段列表、打开段文件的那一小段时间持有这把锁,GC 列活文件时也要拿它。
锁
directory_lock.rs 定义了 tantivy 自己用的两把锁,INDEX_WRITER_LOCK 和 META_LOCK:
| 锁 | 文件 | 阻塞 | 用途 |
|---|---|---|---|
INDEX_WRITER_LOCK | .tantivy-writer.lock | 否 | 一个目录同时只能有一个 IndexWriter |
META_LOCK | .tantivy-meta.lock | 是 | GC 和 reader 打开段文件互斥 |
trait 的默认 acquire_lock 用「建文件成功即拿到锁」实现:try_acquire_lock 调 open_write,FileAlreadyExists 就是被占用;阻塞锁按 retry_policy 重试 100 次、每次睡 100ms;拿到的 DirectoryLock 被 drop 时删文件。RamDirectory 用的就是它。
MmapDirectory::acquire_lock 覆盖了默认实现:文件不存在就建,然后用 fs4 加 flock 排他锁(阻塞锁 lock_exclusive,非阻塞锁 try_lock_exclusive)。返回的 ReleaseLockFile 在 drop 时只打一行 debug 日志,关闭文件描述符就释放了 flock,文件本身从不删除。
同一进程里开第二个 writer,以及另一个进程拿着 writer、被 kill -9 之后再开:
second writer: Err(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."))
writer after drop: ok
holding writer, pid 27192
acquire: Err(Failed to acquire Lockfile: LockBusy. Some("Failed to acquire index lock. ..."))
(kill -9 27192)
-rw-r--r-- 1 user staff 0 Oct 29 16:21 idx-wiki/.tantivy-writer.lock
acquire: ok
flock 属于打开的文件描述,进程死掉内核就释放,留在目录里的空文件不碍事。Index::writer_with_options 的文档说「The lockfile should be deleted on drop」「If the lockfile already exists, returns TantivyError::LockFailure」,这只对默认实现(比如 RamDirectory)成立;对 MmapDirectory,锁文件一直在,看它存不存在判断不了有没有 writer。flock 在 NFS 上的语义取决于实现,MmapDirectory 的文档注释也这么提醒。
MmapDirectory 和 RamDirectory 里 .tantivy-writer.lock 在 drop 之后还存在吗?平台 M2 Max、macOS 27,tantivy 0.27.0。RamDirectory 用 Index::create(ram.clone(), schema, Default::default()),ram.exists(".tantivy-writer.lock") 检查。答案在文末。
footer:每个文件末尾的 JSON
ManagedDirectory::open_write 把下层的写入对象包进 FooterProxy:每次 write 都把写出的字节喂给 crc32 hasher,terminate 时生成 Footer,由 append_footer 追加到文件末尾:
counting_write.write_all(serde_json::to_string(&self)?.as_ref())?;
let footer_payload_len = counting_write.written_bytes();
BinarySerializable::serialize(&(footer_payload_len as u32), write)?;
BinarySerializable::serialize(&FOOTER_MAGIC_NUMBER, write)?;
所以 footer = JSON + 4 字节长度 + 4 字节魔数 FOOTER_MAGIC_NUMBER(1337),数字都是小端。.fieldnorm 文件的最后 96 字节:
00000bce 00 00 00 7b 22 76 65 72 73 69 6f 6e 22 3a 7b 22 ...{"version":{"
00000bde 6d 61 6a 6f 72 22 3a 30 2c 22 6d 69 6e 6f 72 22 major":0,"minor"
00000bee 3a 32 37 2c 22 70 61 74 63 68 22 3a 30 2c 22 69 :27,"patch":0,"i
00000bfe 6e 64 65 78 5f 66 6f 72 6d 61 74 5f 76 65 72 73 ndex_format_vers
00000c0e 69 6f 6e 22 3a 37 7d 2c 22 63 72 63 22 3a 38 32 ion":7},"crc":82
00000c1e 34 34 31 31 39 35 31 7d 55 00 00 00 39 05 00 00 4411951}U...9...
55 00 00 00 是 85,JSON 的长度;39 05 00 00 是 1337。实验程序对每个段文件把 footer 前面的所有字节自己算一遍 CRC-32,和 JSON 里的 crc 逐一相等:
67591bbf248c4dbaa5a27fca65d32399.fieldnorm: size 3118, footer json 85 B + 8 B, magic 1337, crc(body)=824411951
{"version":{"major":0,"minor":27,"patch":0,"index_format_version":7},"crc":824411951}
67591bbf248c4dbaa5a27fca65d32399.idx: size 200383, footer json 86 B + 8 B, magic 1337, crc(body)=1623224100
{"version":{"major":0,"minor":27,"patch":0,"index_format_version":7},"crc":1623224100}
...
JSON 的长度随 crc 的十进制位数变,85 或 86 字节。index_format_version 是 INDEX_FORMAT_VERSION,0.27.0 和 0.26.1 都是 7。
读的时候,ManagedDirectory::open_read 调 extract_footer:读最后 8 字节,魔数不对就报「Footer magic byte mismatch」,长度超过 50000 就报 footer 不合法,然后解析 JSON、返回去掉 footer 的 FileSlice;再用 is_compatible 检查格式版本在 4 到 7 之间。crc 在这里不校验:校验要读完整个文件,会把 mmap 的「打开即用」变成「打开先读一遍」。需要时显式调 Index::validate_checksum,它对每个被管理的文件跑 ManagedDirectory::validate_checksum,返回 crc 不符的路径集合。
meta.json 和 .managed.json 走 atomic_write,没有 footer。
footer 之内:CompositeFile
.idx、.pos、.term、.fieldnorm 按字段分区,每个字段一段,由 CompositeWrite 写出。它的 close 在数据后面追加一张表:字段数(VInt),每个字段的起点增量(VInt)、字段号(u32)、序号(VInt),最后是表的长度(u32)。这张表在 footer 的 JSON 前面。.idx 的表:
idx composite footer 21 B: 83 80 00 00 00 00 80 58 8e 01 00 00 00 80 79 c0 02 00 00 00 80 | len 15 00 00 00
field 0 idx 0 start 0
field 1 idx 0 start 1880
field 2 idx 0 start 10193
tantivy 的 VInt 低位在前、每字节 7 位,最后一个字节最高位置 1(83 是 3,58 8e 是 88 + 14 × 128 = 1880)。字段 0、1、2 是 url、title、body。CompositeFile::open 读这张表,open_read(field) 返回该字段那一段的 FileSlice。于是读一个段的一个字段要剥两层:外层 footer,内层分区表。
动脑筋答案
lock file while writer alive: true
lock file after writer dropped: true
ram lock while alive: true
ram lock after drop: false
MmapDirectory 里还在,RamDirectory 里没了。MmapDirectory 用 flock,锁随文件描述符释放,ReleaseLockFile 不删文件;RamDirectory 走 trait 的默认实现,锁就是文件本身,DirectoryLockGuard 的 Drop 调 delete。所以「目录里有 .tantivy-writer.lock」对磁盘索引不说明任何事。
小结
Directory是只写一次、多次读的抽象:open_write要求文件不存在,只有meta.json和.managed.json用atomic_write原地替换;读出来是FileSlice(Arc<dyn FileHandle>加范围),切片不读数据。MmapDirectory的 mmap 缓存存Weak,不延长映射寿命;写用create_new加terminate时sync_data,原子写是同目录临时文件加改名。meta.json的变化靠一个线程每 500ms 读一遍算 crc32 发现,0.14(PR #924)起取代notify。RamDirectory在atomic_write时同步广播;0.27.0(#3095)起它的写入只在terminate时移交Vec。ManagedDirectory先登记再建文件,.managed.json记录 tantivy 建过的文件;GC 在提交和合并后跑,持META_LOCK列活文件;被删的文件旧Searcher仍可读(Unix)。MmapDirectory的锁是flock,锁文件永不删除,kill -9后照样能拿锁;默认实现(RamDirectory)的锁是文件本身。- 每个段文件末尾是 JSON footer(版本加 crc32)+ 长度 + 魔数 1337;打开时只查魔数和格式版本,crc 只在
Index::validate_checksum时校验。按字段分区的文件里,footer 前还有一张CompositeFile分区表。