Rust in Everything
基于 tantivy 0.27.0

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。

📝NOTE
冷知识:tantivy 曾经用 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 字节才更新一次共享计数。

📝NOTE
冷知识:0.27.0 以前,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 篇得到的段,每个文件的字节数完全一样。

📝NOTE
没有蠢问题

问: 旧 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 的文档注释也这么提醒。

❗IMPORTANT
动脑筋:同样是建 writer、再 drop,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。

.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 分区表。

💬 关联讨论

发起讨论
加载中...