0
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?

SQLiteを高速化したら、思わぬ落とし穴が見えてきた

0
Posted at

削除したはずのデータが戻る。キャッシュに入ったときだけ読み取り結果が変わる。古い書き込みを再試行すると、新しい値が上書きされる。

こんな現象が、自作のSQLiteライブラリの検証で見つかりました。

DictSQLiteやNanaSQLiteでは、SQLiteをPythonの辞書のように扱えるAPIを用意しています。使う側は保存や読み取りを簡単に書けても、その裏ではキャッシュ・非同期書き込み・再試行・削除などが動いています。

速度、安定性、使いやすさ、安全性の両立は、開発を通して悩んできたテーマです。速くした結果、不具合や脆弱性が生まれ、修正すると性能が大きく落ちた経験もあります。そこで大切にしてきたのが、安全性を利用者任せにせず、できるだけライブラリ側で担うことでした。

今回は公開した変更履歴や実装、テスト記録をたどり、便利さの裏側にある「消しても戻らない」「新しい値を守る」「同じ操作なら同じ結果を返す」という約束をどう維持するかを紹介します。

1. 失敗した書き込みを戻すと、新しい値が消える?

DictSQLiteでは、書き込みをいったんバッファへためて後からディスクへ反映する仕組みを使っています。毎回の書き込み完了を待たずに済む一方、flushが失敗したときの復旧も必要になります。

例えば、こんな順序です。

順番 起きること
1 キーAへの書き込みがディスク反映に失敗する
2 キーAへ新しい更新・削除が受け付けられる
3 失敗した古い書き込みをバッファへ戻す

3番で古い値を無条件に戻せば、2番の変更を巻き戻してしまうかもしれません。

DictSQLite 2.1.3の変更履歴には、flush失敗時の復旧に加え、削除やclearのあとに未flushの書き込みから値が復活し得る問題への修正が記録されています。

実際の非同期書き込みのソースにある復旧処理の一部がこちらです。

Err(err) => {
    let mut buffer = write_buffer.lock();
    for (key, value) in items {
        buffer.entry(key).or_insert(value);
    }
    Err(err)
}

ポイントは or_insert(value) です。失敗した項目をバッファへ戻す際、すでに同じキーの新しい値があれば、それを残します。

失敗した操作を再試行できることだけでなく、失敗している間に受け付けた新しい操作を守る。 バッファを使う設計では、この順序が大事になります。

もちろん、このコード片だけですべての削除競合や停止時の耐久性が証明されるわけではありません。ここで紹介しているのは、復旧時の古い値による上書きを避けるための具体的な一手です。

2. キャッシュが全部ヒットすると、フックが動かない

次はNanaSQLiteです。

NanaSQLiteには、読み取り結果に処理を加えるフックと、読み取りを速くするキャッシュがあります。それぞれ便利な機能ですが、2026年9月5日の検証記録で、両者の組み合わせから問題が見つかりました。

batch_get() で全件キャッシュヒットしたときだけ、読み取りフックを通らなかったのです。

読み取りの経路 求めたい振る舞い
キャッシュにない値を読む 読み取りフックを適用
全件キャッシュ済みの値を読む 同じ読み取りフックを適用

利用者から見れば、同じAPIで同じデータを読む操作です。キャッシュの状態によって変換や検査の結果が変わるのは困ります。

修正後の記録では、非同期・unbounded・LRU・TTLなどのケースで一貫性を確認したと報告されています。

キャッシュを追加するとき、ヒット率や実行時間は測りやすい指標です。それに加えて、「キャッシュヒット時にも、必要な共通処理が省略されていないか」をテストする視点が役立ちます。

3. 古いDLQの再試行が、新しい値を巻き戻す

もう一つNanaSQLiteで見つかったのは、失敗した書き込みを後から再試行するDLQの問題です。

① キーAに古い値を書き込もうとして失敗 → DLQへ
② キーAに新しい値を書き込む
③ 古いDLQ項目を再試行する

③をそのまま実行したら、②の値より古い①の値が残ってしまう可能性があります。

検証記録では、同じテーブル・キーに対する新しいKVS操作を優先するよう修正したことが説明されています。失敗の公開・再投入・新規受付を同じロック順で同期し、古い再試行が後続の変更を上書きする問題に対応しています。

この設計で学べるのは、「再試行できる」と「再試行しても正しい」は違うということ。SQLiteに限らず、ジョブキューや非同期処理の設計でも役立つ考え方です。

この保護は同じテーブル・キーに対するKVS操作のもので、任意SQLへそのまま適用できる規則ではありません。

DBのロックを意図的に作るテストも役立った

検証記録には、別のSQLite接続が書き込みロックを保持している場面の問題も残っています。

失敗行を復旧するときに、開始していないトランザクションへ ROLLBACK を実行してしまい、後続の失敗行がDLQへ入らなくなっていました。

修正ではトランザクションが開始済みの場合だけロールバックし、失敗行を再投入。その後、独立した接続で永続化された結果を確認しています。

正常に保存できた場合だけではなく、意図的にロックを競合させて、失敗後の復旧まで確認する。実ファイルDBを使った検証だからこそ扱える内容でした。

検証記録には、当時のテスト実行で §1119 passed, 9 skipped§、追加のv2対象テストで §24 passed§ という結果も残っています。後者は重複するケースを含むので合計件数へ足していません。実際の失敗ケースをテストにして、修正後の結果まで残せているのが、この取り組みの大きな価値だと思います。

4. close()で書き込みを守ると、メモリにも変化が出た

非同期書き込みでは、アプリが終了するときも考えたいところです。

NanaSQLiteでは受付上限なしの非同期書き込みで、呼び出し元のキャンセル後に close() を実行すると、保存が失われる可能性がありました。修正では、上限の有無によらずworkerの完了を追跡し、終了時に待つようにしています。

この修正を含む比較では、追加Python割り当てのピークも測りました。

条件 ピーク中央値
変更前・受付上限なし 1,288,133 bytes
変更後・受付上限なし 1,732,652 bytes
変更後・受付上限16件 651,475 bytes

これは公開JSONにある各7回の測定値の中央値です。プロセス全体のRSSではなく、測定開始後の追加Python割り当てを表します。

上限なしの変更版では追跡のための追加コストが現れた一方、受付上限16件の構成では小さくなりました。呼び出し側が作成するコルーチン自体の数まで制限するものではありません。

終了時のデータ整合性とメモリ使用量を、両方の指標で確かめる。 こうした測定が、次の設計判断の手がかりになります。

5. 性能も測ると「速くなった操作」がはっきりする

NanaSQLiteの測定記録では、Windows / Python 3.11.9 / APSW 3.53.1.0を使って、旧版と変更版を交互に各7回比較しています。

操作 変更後/変更前の時間比率中央値 結果
全件キャッシュ済みの一括読み取り 0.736 約26%短縮
キャッシュ済み db[key] 1.123 約12%増加
キャッシュなしの読み取り 1.018 小差

この数字は生データの7組の比率から再計算しても一致します。

特に一括読み取りでは性能改善が見られました。一方、キャッシュ済みの単一キー読み取りには追加コストが出ています。だからこそ、ライブラリ全体の「速さ」より、自分が重視する操作の性能と整合性を見たいと思います。

測定では、毎回新しいプロセスと実ファイルDBを使い、終了後に保存結果を別モデルと照合し、SQLiteの integrity_check も実施しています。測定した変更版は当時の未コミット作業ツリーであり、今回新たな測定を実施したわけではありません。

性能を上げる前に、測定自体を正しくする

同じ検証記録には、以前のメモリDB・単発の書き出し・人工的な待機を含む測定を性能判断から外し、実ファイルDBやAB/BA交互実行で取り直したことも書かれています。

この改善は、ライブラリ本体だけでなく「何を速くしたといえるのか」を確かめる道具そのものを見直した例です。

技術的負債として何が残るのか

ここまでに登場した問題は別々ですが、共通点があります。

  • バッファを使っても、削除後の値を復活させない
  • キャッシュに入っていても、読み取りフックの振る舞いを揃える
  • 失敗後の再試行でも、新しい書き込みを守る
  • 終了時には、受け付けた処理の完了を追跡する

呼び出す側にとっては簡単な辞書操作でも、内部ではこの約束を複数の経路で守り続ける必要があります。

便利なAPIを実現するほど、内部の分岐や検証が増えていく。それが、今回の開発記録から見えてきた技術的負債です。

自分としては、使う側が安全性を毎回気にしなくて済むライブラリを作っていきたい。そのためには速度だけでなく、失敗・キャンセル・競合・再試行までテストし、測定結果を残すことが欠かせないと考えています。

まとめ

自作SQLiteライブラリを振り返ると、改善の過程で見つかった問題には、普通に値を読み書きするだけでは出会いにくいものが数多くありました。

消した値が戻らないこと。古い書き込みが最新の値を上書きしないこと。キャッシュで速くなっても結果が変わらないこと。

便利な機能を増やしたら、その機能同士の組み合わせまで守る必要がある。 この発見は、これからのライブラリ開発にも活かせそうです。

実装と実測の詳細は、DictSQLiteのコードとNanaSQLiteの検証記録から確認できます。

0
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
0
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?