SQLite3 のリトライ戦略 busy_handler


SQLite3 in production シリーズ第三弾です。

第二回では、SQLite3 の Write-Ahead Logging (WAL) の概要と、WAL によって「読み書きの同時実行」が実現された、というお話をしました。

そして「書き込み同士の衝突」は依然としてエラーを引き起こす。この問題を解決する重要なピースが、この記事で解説する busy_handler です。

概要と歴史

SQLite3 はロック競合が発生すると、SQLITE_BUSY エラーを送出します。このエラーを受けて、自動でリトライするための制御装置が busy_handler です。

busy_handler に関数ポインタを渡すと、SQLITE_BUSY エラーが発生した時にその関数が呼ばれます。 関数の戻り値が0なら SQLITE_BUSY を再送出、0以外なら再度ロック取得を試みる、という実装になっています。

busy_handler の登場は、2010 年に実装された WAL より以前に遡ります。仕組み自体は SQLite3 の「3」がつく前からありました。 SQLite 1.0.0(2000年公開)のヘッダファイル ↗ sqlite_busy_handler の姿を見ることができます。

「単一ファイル」という SQLite の特性上、ロックの競合は想定内の課題であり、その解決策としての「リトライ」は基本的な機能だったのです。

Ruby on Rails との摩擦

ナイーブに考えましょう。

「busy_handler を使って、ロックが取れるまで sleep しながら一定時間待つ」

こうすれば、書き込み競合によるエラーの大部分は解消できそうですよね。実際、SQLite3 もそのような想定をしており、busy_timeout というショートハンドが用意されています。

しかし、Rails には、2つのハードルが立ちふさがりました。

そもそも busy_handler が呼ばれない

1つ目は、SQLite3 のデフォルトの挙動です。

SQLite3 は、トランザクション開始時(BEGIN)は共有ロックのみを取ります。その後、実際にデータを書き込もうとした瞬間に、初めて排他ロックを取得するのです。

そのため、複数の書き込みトランザクションが並行すると、それだけでデッドロックが発生します。 デッドロックした場合は、busy_handler を呼び出して待機しても状況は改善しませんから、SQLite3 は即座に SQLITE_BUSY を送出します。

SQLite3 のデフォルトのロック戦略と busy_handler は根本的に相性が悪かったのです。

GVL (Global VM Lock)

もう一つの、そして最大のハードルが Ruby 特有の GVL です。

Ruby の世界から C の関数を呼び出す時、基本的にはその処理が終了するまで GVL(Global VM Lock) というロックを保持します。 これにより、あるスレッドが C の世界で処理をしている間、同じプロセス内の他の Ruby スレッドは一切実行できなくなります。

SQLite3 の用意するショートハンド busy_timeout を利用すると、ロックが取れるまで sleep して粘ってくれます。 しかし、sleep している間も GVL を握り続けてしまうので、その間はマルチスレッドなリクエスト処理ができません。

書き込み衝突問題を解決しても、Web サーバー自体が詰まってしまえば本末転倒ですよね。

まとめ

SQLite3 のロック競合問題を緩和するための「busy_handler」について説明しました。

そして Rails がこの busy_handler を活用して、SQLite3 の Concurrency を最大限引き出すためには、2つのハードルを乗り越えなくてはいけません。

  • デッドロックにより busy_handler が呼ばれない
  • busy_handler による待機中に GVL を握り続けてしまう

次回は SQLite in production シリーズ最終回です。 Rails の開発チームは、この課題をいかにして解決したのでしょうか、お楽しみに。

フィードバックを送る

  • • お送りいただいた内容は、筆者が全て目を通し、今後の励みとさせていただきます
  • プライバシー: お名前やメールアドレスの収集は行っておりません
  • 返信: 全てへの返信はお約束できかねますが、必要な場合は本文内に連絡先を添えてください
  • 公開の可能性: 個人を特定できない形で記事内で紹介させていただく場合があります