Rust in Everything
基于 tantivy 0.27.0

Failed to acquire Lockfile: LockBusy

主线位置:写入主线第二步,Index::writer 先拿目录锁 .tantivy-writer.lock,再起线程。锁在两种目录上怎么实现、锁文件为什么一直留着、文档里写的错误名为什么对不上,见 IndexWriter。

flowchart TB
  r1["请求 1 writer"] --> lk["flock 成功"]
  r2["请求 2 writer"] --> try["try_lock 失败"]
  try --> err["LockBusy 立即返回"]
  lk --> work["写入 提交"]
  work --> rel["drop 释放"]
  fix["共享一个 writer"] --> mu["Mutex 排队"]
  mu --> work

实验 crate:exp-tantivy/pita-lab,src/bin/p09_lock.rs(同进程、跨进程)和 src/bin/p09_threads.rs(并发写)。M2 Max、macOS 27、rustc 1.99.0,release。

现象

同一个进程里,已经有一个 writer,再开一个:

same process, same Index, second writer: Some(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.")))

另一个进程拿着 writer 不放(hold 模式睡 3 秒),这边去拿:

pid 97100 holds the writer for 3s
pid 97129 writer: Err(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.")))
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.")
is LockBusy: true

日志里常见的是 Display 那一行,Some("...") 是把 Option 原样打了出来。3 秒后那个进程退出,再拿就是 pid 97140 writer: Ok。

更常见的触发方式是 Web 服务里「每个请求开一个 writer、写完提交」。8 个线程,每个做 5 次「开 writer → 加一篇 → commit → drop」,连跑三次:

writer per request: 40 upserts, 35 errors Some("LockFailure(LockBusy, Some(\"Fa"), num_docs 5
one shared writer : 40 upserts, 0 errors None, num_docs 40

三次结果相同:40 次写入只成功了 5 次。失败的请求如果只打日志、不重试,文档就丢了。

原因

Index::writer_with_options 第一件事是 acquire_lock(&INDEX_WRITER_LOCK),而 INDEX_WRITER_LOCK 是非阻塞的:

pub static INDEX_WRITER_LOCK: Lazy<Lock> = Lazy::new(|| Lock {
    filepath: PathBuf::from(".tantivy-writer.lock"),
    is_blocking: false,
});

MmapDirectory::acquire_lock 对非阻塞锁调 try_lock_exclusive(Unix 上是 flock),拿不到立刻返回 LockError::LockBusy,writer_with_options 再包一层 TantivyError::LockFailure 和那段提示。没有等待、没有重试,也没有选项能让它等:is_blocking 写死在这个静态量里,IndexWriterOptions 里没有相关字段。

锁按 flock 的语义作用于打开的文件,同一个进程里的两个 Index 句柄、甚至同一个句柄开两次,都互相排斥。writer 一直持有它,直到 drop(或 wait_merging_threads 消费掉 writer)。所以「每个请求开一个 writer」等于让请求们抢一把不排队的锁,只有手快的那个成功。

这是设计:一个索引同一时刻只能有一个写者,IndexWriter 自己内部是多线程的,add_document 和 delete_term 只要 &self。

修法

整个进程只开一个 writer,大家共享。 add_document、delete_term 是 &self,commit 要 &mut self,最简单的是放进 Arc<Mutex<IndexWriter>>:

let shared = Arc::new(Mutex::new(index.writer(50_000_000)?));
// 每个请求里
let mut writer = shared.lock().unwrap();
writer.add_document(doc)?;
writer.commit()?;

上面那组输出的第二行就是这样改的:40 次全部成功,num_docs 40。锁住的范围里包括 commit,提交是串行的;想要更高的吞吐,就别每个请求提交一次,攒一批或定时提交(提交)。长命的 writer 还顺带解决了合并结果被丢掉的问题,见 合并。

确实需要多个进程写同一个索引时,把写入收到一个进程里(其他进程通过队列或 RPC 交给它)。tantivy 不提供跨进程的写入协调。

偶发的 LockBusy(比如上一个进程还没退出) 可以由调用方重试:匹配 TantivyError::LockFailure(LockError::LockBusy, _),等一会儿再 index.writer(..)。

怎么检查

谁拿着锁。 MmapDirectory 的锁文件不会删,看文件在不在没有意义;看谁打开了它:

$ lsof pita-lab/ix/lock/.tantivy-writer.lock
COMMAND    PID USER   FD   TYPE DEVICE SIZE/OFF      NODE NAME
p09_lock 97100  user   3w   REG   1,16        0 472687049 pita-lab/ix/lock/.tantivy-writer.lock

没有输出就是没人持有,可以直接开 writer,不需要删文件。

  • 代码里: 搜 .writer( 和 writer_with_num_threads(,它们应该只出现在启动路径上,而不是请求处理函数里。
  • 日志里: 出现 Failed to acquire Lockfile: LockBusy 时,同时记下 lsof 的结果,能直接定位到另一个进程。

小结

  • 一个索引目录同一时刻只能有一个 IndexWriter;第二个 writer() 立刻返回 LockFailure(LockBusy, ..),同进程、跨进程都一样,不会等待。
  • 「每个请求开一个 writer」在并发下大量失败:8 线程各 5 次,40 次里 35 次 LockBusy。
  • 进程里共享一个 writer(Arc<Mutex<IndexWriter>>),多进程就把写入集中到一个进程;用 lsof .tantivy-writer.lock 看谁持有锁。

💬 关联讨论

发起讨论
加载中...