1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonでSQLiteの共有インメモリDBが消える仕様と、`deserialize()` の落とし穴を `backup()` で回避した話

1
Posted at

先日、外部依存ゼロのピュアPythonで動くインメモリ仮想ファイルシステム「D-MemFS」をリリースしました。

これを開発中のアプリのバックエンドに組み込んでいる最中、SQLiteのインメモリDB(:memory:)のちょっとした弱点を克服できるのではないかと閃きました。

前座::memory:cache=shared の限界

SQLite の :memory: はファイルを作らずプロセス内で高速にデータベースを使えるため、テストやプロトタイピングで重宝します。しかし、セッション(コネクション)を跨いで共有できないという制約があります。

これを解決するのが cache=shared オプションです。
特に file:my_db?mode=memory&cache=shared のように名前付き共有を使えば、目的別に複数の共有インメモリDBを運用できて非常に便利です。

しかし、cache=shared を使って共有の利便性を手に入れても、通常の :memory: と同様に避けられない揮発の仕様が存在します。

インメモリDB揮発の仕様
複数スレッドやセッションからDBを共有できて便利ですが、通常の :memory: と同じく、最後のコネクションが閉じられた瞬間にデータが跡形もなく消滅してしまいます。

Web系のシステムなどでは、このデータロストを防ぐためにわざわざダミーのコネクションを繋ぎっぱなしにするようなハックを見かけますが、少し不格好です。

「それなら、コネクションが切れる直前に状態を退避させておき、次に必要なときにパッと復元すればスマートに解決するぞ」と考えました。ここで登場するのが、Python 3.11から追加された serialize() / deserialize() と、インメモリFSである D-MemFS です。

しかし、実際にやってみると、そこには思わぬ落とし穴が待っていました。

「deserialize() で余裕でしょ?」と思った結果

Python 3.11以降の sqlite3.Connection には serialize()deserialize() があり、これを使えばディスクI/Oなしで簡単に状態を保存・復元できるはずでした。

何の疑いもなく、以下のようなコードを書きました。

  1. 退避: conn.serialize() で状態をバイト列にし、D-MemFSへ保存する。
  2. 復元: D-MemFSから読み出し、共有キャッシュのURIで繋ぎ直して deserialize() を実行する。
import sqlite3

# 共有キャッシュのURIで繋ぎ直し、データを流し込む
new_conn = sqlite3.connect("file:my_db?mode=memory&cache=shared", uri=True)
new_conn.deserialize(snapshot_bytes)

# ヨシ!復元できた!
print(new_conn.execute("SELECT * FROM users").fetchall())
# → [(1, 'Alice'), (2, 'Bob')] 

ここまでは完璧でした。「なんだ、簡単じゃないか」と。
しかし、別のワーカースレッド(あるいは別のリクエスト)から、この共有DBに繋ぎに行った瞬間、見事にエラーを吐かれます。

# 別の場所から同じ共有DBに繋いでみる
worker_conn = sqlite3.connect("file:my_db?mode=memory&cache=shared", uri=True)
worker_conn.execute("SELECT * FROM users").fetchall()

# 💥 sqlite3.OperationalError: no such table: users

「え? なぜ? テーブルがない?」
さっき new_conn では確実に読めていたのに、同じURIで繋いだ worker_conn からは見えないのです。思わず二度見しました。

SQLite deserialize() の厄介な仕様

原因を調べてみると、deserialize() のちょっと厄介な仕様に行き着きました。

  • deserialize() を実行すると、そのコネクションの背後にあるページャー(メモリ管理の仕組み)が、データをロードした**「完全に独立したプライベートなインメモリDB」にすり替わってしまう**。
  • つまり、deserialize() を呼んだ瞬間、そのコネクションは共有キャッシュ(cache=shared)のリングから静かに脱退してしまう。

復元を実行したコネクション自身はデータを持っていますが、後から来たコネクションは「空っぽのまま放置された元の共有キャッシュ」に繋がってしまうため、no such table になるというオチでした。

backup() API を使ったハックで回避する

共有キャッシュのリングに deserialize() は直接使えない。でもバイト列から復元はしたい。
これを解決するためには、直接復元するのではなく**「運び屋」**を用意する必要があります。

そこで、sqlite3 に昔からある backup() API を使って以下の手順を踏むことにしました。

  1. 読み出し: 退避先(D-MemFS)からバイト列を取得する。
  2. 運び屋の準備: 使い捨てのインメモリDB(:memory:)を作成し、そこに deserialize() でデータをロードする。
  3. 本命へのコピー: backup() APIを使い、運び屋から本命の共有キャッシュへデータを丸ごと流し込む。
  4. 破棄: 運び屋をクローズする。
# 本命の共有キャッシュDB
shared_conn = sqlite3.connect("file:my_db?mode=memory&cache=shared", uri=True)

if snapshot_bytes:
    # 運び屋(一時DB)を用意し、そこにデシリアライズ
    temp_conn = sqlite3.connect(":memory:")
    temp_conn.deserialize(snapshot_bytes)
    
    # 運び屋から本命へ、データを丸ごとバックアップ(コピー)
    temp_conn.backup(shared_conn)
    temp_conn.close() # 運び屋は用済み

このちょっと泥臭いワークアラウンドにより、Python 3.11〜3.14の全バージョンで、共有キャッシュを壊さずに状態を復元することに成功しました。

結論:ぶっちゃけ辞書(Dict)でよくない?

ここまで解説しておいてなんですが、勘の良い方ならこう思うはずです。

「シリアライズした bytes を退避するだけなら、わざわざ仮想ファイルシステムなんか使わずに、グローバルな辞書(Dict[str, bytes])で保持すればいいのでは? その方が速いし簡単だ」と。

おっしゃる通りです。
辞書(Dict)などを使えば、外部ライブラリも不要ですし、コードもシンプルになります。まあグローバルよりは、管理用のクラスを作って、その中で実装する方がいいでしょうが、いずれにしても、メモリファイルシステムを使うメリットは薄いでしょう。

「じゃあ、なんでわざわざD-MemFSを使ったの?」と聞かれたら……完全に私の「好み」です。

比較項目 グローバル辞書 (Dict) D-MemFS
速度・メモリ効率 ⭕ 圧倒的に速い・軽い 🔺 FSのオーバーヘッドあり
状態管理のモデル 🔺 キー名での自前管理 /data/shared.db のような直感的なパス
スナップショットの容量制限 🔺 自前で実装が必要 ⭕ ハードクォータでメモリ肥大化を確実防止
世代管理・永続化 🔺 マッピングや書き出し処理が地味に面倒 ⭕ ディレクトリ構造ごと一発でエクスポート可能

実際のアプリ開発において「わざわざSQLiteを使う」ということは、アプリの終了時やキリの良いタイミングで「物理ディスク上のファイルとして永続化する」要件が絡むことが多いはずです。

いざ物理ディスクへ書き出す段になった時、生のバイト列を辞書のキーで管理するより、仮想的とはいえ /snapshots/v1.db のような「直感的なファイルパス」として扱える方が、SQLiteのメンタルモデルと一致して圧倒的にしっくりきました。
さらに、D-MemFSなら面倒なパスのマッピング処理なしに、ディレクトリ構造ごと一発でエクスポートできたり、ハードクォータ機能でスナップショットの無制限な肥大化によるOOMキルを確実に防げるという強みもあります。

公式ユースケースに追加しました
今回の「deserialize() の仕様」と「backup() による回避策」は、個人的にとても面白い知見だったので、この処理を安全にラップした MFSSQLiteStore クラスを、D-MemFSの公式ユースケースとして READMEexamples に追加しておきました。

「いやいや、俺は普通に辞書で管理するぜ」という方にとっても、このSQLiteのハック自体はいつか役立つ時が来ると思うので、ぜひ頭の片隅に置いておいてください。
そして、「仮想FS上でDBを管理するのもちょっと面白いな」と思っていただけた奇特な方がいれば、ついでに D-MemFS も触ってみていただけると嬉しいです。


🌍 海外での反響
D-MemFSは r/Python で好意的な反響をいただき、Python Weekly Issue 737 にも掲載されました。


🛡️ D-MemFS 活用ガイド(連載)

📖 設計の裏側をもっと深く
なぜこのライブラリを作ったのか、その原体験や「バイブコーディング消耗からの脱却」としてのSDD(仕様駆動開発)の実践記録については、Zenn で連載しています。あわせてどうぞ。

GitHub で Star ⭐ をいただけると、開発の大きな励みになります!
(↓以下のリンク先がプロジェクトの本体です)

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?