「ファイルを直接開くDB」を複数プロセスで触って、二度壊した — 書き手を一つに絞って根治するまで
はじめに
自分用に、日々の記録や過去の会話を貯めて意味で引ける知識ベース(RAG)を運用している。その中身はベクトルDB——文章を数値の並び(ベクトル)にして貯め、意味の近さで検索するためのデータベースだ。ここには過去の判断や教訓が溜まっていて、自分にとっては記憶そのものに近い。
そのベクトルDBが、二度、索引ごと壊れた。検索も件数取得もできなくなり、記憶が読めない状態になった。原因は派手なバグではなく、構造的な使い方の誤りだった。この記事は、なぜ壊れたのかを突き止め、二度と壊れない形に作り替えるまでの記録だ。
壊した後に「バックアップを取ると決めただけで実行しなかった」という別の失敗もしていて、その予防側の話は 「バックアップを取る」と決めただけで実行せず、記憶用のベクトルDBを二度壊した話 に書いた。この記事はその根っこ側——そもそもなぜ壊れたのか、だ。
何が壊れたのか
症状は明確だった。ベクトルDBに対して件数を数える・検索する、という基本操作が、突然 Access Violation(不正メモリアクセス) で落ちるようになった。データを貯めていたファイルは残っているのに、それを引くための HNSW索引——ベクトルの近傍探索を速くするための索引で、ここが壊れると検索が成立しない——が破損していた。
一度目は復旧させた。だが数日後、同じ壊れ方をした。二度同じ場所で壊れたなら、それは偶発ではなく構造だ。 対症療法で戻すのをやめ、原因の特定に切り替えた。
なぜ壊れたのか:ファイルを、みんなで同時に開いていた
使っていたのは、DBの標準的な組み込みモード(組み込みDB=アプリのプロセスの中で、DBのファイルを直接開いて読み書きする方式。別建てのサーバーを立てない、手軽なやり方)だった。一人で使うぶんには、これでいい。
問題は、実際の運用が「一人」ではなかったことだ。この知識ベースには、同じDBファイルを触るプロセスが複数いた。
同じ DB ファイル(chroma_db)
▲ ▲ ▲ ▲
┌─────────────┘ │ │ └─────────────┐
│ ┌───────┘ └───────┐ │
MCPサーバー 別クライアント 入力フック 使い捨てスクリプト
(常駐) (会話ごと) (毎プロンプト) (バッチ・修復など)
│ │ │ │
└──── 全員が同じファイルを同時に開いて書く ────┘
│
HNSW索引が壊れる(単一プロセス前提を破っている)
組み込みDBは、一つのプロセスが開いていることを前提に索引ファイルを更新する。そこへ複数のプロセスが同時に書き込むと、索引の更新が競合して壊れる。一人用の道具を、知らずに多人数で使っていた——それが二度の破損の正体だった。
根治その一:書き手を、一つに絞る
対策の芯は単純だ。DBファイルを開くプロセスを、一つだけにする。
やり方は、DBをサーバーモード(DBを独立した一つのサーバープロセスとして起動し、他は全員そのサーバーに接続して読み書きする方式)に切り替えること。これで、ファイルを直接開くのはサーバーの1プロセスだけになり、他のプロセスはHTTP越しに頼むだけになる。書き手が一つに絞られ、競合そのものが消える。
【変更前】各プロセスが直接ファイルを開く → 書き手が複数 → 壊れる
【変更後】各プロセス ──HTTP──▶ Chromaサーバー ──▶ ファイル
(ファイルを開くのはここ1つだけ)
コードでは、DBへのアクセスを必ず一つの入口(get_client())に通し、直接ファイルを開く旧方式(PersistentClient)の使用を禁止した。禁止を「気をつける」で終わらせず、入口の関数にコメントで固定し、再発経路を塞いだ。
def get_client():
"""Chroma クライアント取得(サーバー未起動なら自動起動して接続)。
PersistentClient を直接使うことは禁止(マルチプロセス破損の再発経路)。
chroma_db へのアクセスは必ずこの関数を経由すること。"""
# HttpClient = サーバー経由。ファイルを直接は開かない
client = chromadb.HttpClient(host="127.0.0.1", port=8800)
client.heartbeat()
return client
細かいが効いている点が一つある。サーバーの二重起動も、ポート番号が勝手に防いでくれる。あるプロセスがサーバーを立ち上げようとしても、既に誰かが同じポートを使っていれば、後から来た方はポートを確保できずに即終了する。「ポートは一つしか取れない」という当たり前の性質が、そのまま「サーバーは一つだけ」の排他ロックとして働く。仕組みを足すのではなく、既にある制約を利用する。
根治その二:ファイルは無事でも、中身が重複する
サーバー化で「索引ファイルが壊れる」物理的な破損は消えた。だが、もう一段別の同時実行の問題が残っていた。論理的な重複だ。
この知識ベースは、書き込む前に「同じ内容が既にあるか」を調べて重複を弾く(重複排除)。この判定は「読んで(あるか調べて)→無ければ書く」という二段構えになっている。ここに、複数のプロセスがほぼ同時に、ほぼ同じ内容を書きに来ると、両方が「まだ無い」と読んでしまい、両方が書き込む。重複判定を、二人まとめてすり抜けるのだ。
プロセスA:あるか調べる → 無い ┐
プロセスB:あるか調べる → 無い ┤ ← どちらも「書く前」に調べたので両方「無い」
プロセスA:書く ┘
プロセスB:書く → 同じ内容が二重に入る
サーバーモードは「ファイルへの書き手」は一つにしたが、「調べてから書くまでの一続きの操作」までは束ねてくれない。そこで、書き込み操作の全体を一つの門(ミューテックス)に通した。ミューテックスとは、同時に一人しか通れない関所のことだ。「調べる→書く」を丸ごとこの門の中でやれば、Aが通り抜けるまでBは待つので、Bは「もうある」を正しく読める。
# プロセス間の書き込み全体ミューテックス。
# 「重複判定(読む)→書く」の一続きを直列化し、同時書き込みの二重登録を防ぐ。
# 再入可能(入れ子で取っても自分自身とはデッドロックしない)。
# timeout 超過は握り潰さず例外で顕在化させる(黙って失敗させない)。
_WRITE_MUTEX = FileLock(str(DATA_DIR / ".write_mutex.lock"), timeout=180)
一点だけ、意図して選んだことがある。門が時間切れになったとき、それを黙って無視せず、例外として表に出すようにした。待ちが異常に長いのは何かが詰まっている合図で、握り潰すと次の障害の種になる。防御は、失敗したときに気づける形でなければ意味がない。
「壊れない」を、気をつけるのではなく仕組みで担保する
振り返ると、二度の破損も重複も、根っこは同じだった。単一プロセスを前提にした道具を、複数プロセスの現実で使っていた。 そして直し方も一貫していた——同時に触る経路を、一本に束ねる。
- 物理的な破損は、ファイルを開くプロセスを一つに絞る(サーバー経由)ことで消えた。
- 論理的な重複は、調べてから書くまでを一つの門に通す(ミューテックス)ことで消えた。
大事だったのは、どちらも「以後は気をつける」で終わらせなかったことだ。直接ファイルを開く旧方式は入口の関数で使えないようにし、二重起動はポートという既にある制約に防がせ、時間切れは例外で顕在化させた。気をつけるは、疲れれば破れる。破れない形にして初めて、「二度と壊れない」は担保になる。
大切なデータほど、どう守るかを本人の注意力に預けない。同時に触られても壊れない構造に作り替える——二度壊してようやく、それが腹に落ちた。
関連する記事
- 壊した後の「バックアップを取ると決めただけで実行しなかった」失敗と、その予防をどう仕組み化したかは 「バックアップを取る」と決めただけで実行せず、記憶用のベクトルDBを二度壊した話 に書いた。この記事はその根っこ(なぜ壊れたか)側にあたる。
- 事故の教訓を「助言」で終わらせず「強制」に引き上げた仕組みの話は AIアシスタントに「やらせない」仕組みを作る にまとめた。