SyntaxHighlighter

2023年11月27日月曜日

SQLite3 のトランザクションと Rails

最近は開発よりもマネージメントの仕事が多いのですが、久しぶりに技術的な話で書くネタができたので書いておきます。

SQLite3 のトランザクションは他のデータベースと少し異なり特殊なので、色々とはまりがちですが、その内容についてまとめておきます。
Rails での話も少し書いておきます。

SQLite3 の分離レベル

SQL の分離レベルは、 Wikipedia の記事にあるように、 Read Uncommitted, Read Committed, Repeatable Read, Serializable の4種類があります。

SQLite3 では、 Read Uncommitted になる例外もありますが、基本的には常に Serializable です。
後述するトランザクションのモードとして、 Deferred, Immediate, Exclusive が指定できますが、それとは関係なく、常に Serializable になるようです。
つまり、どのトランザクションのモードを選んでも、 Dirty Read や Phantom Read などは起こらず、トランザクション中は、常に整合性の取れた読み取りができるかエラーになるかのいずれかになります。

SQLite3 のトランザクションのモードと、エラーになるケース

SQLite3 では、トランザクションのモードを BEGIN TRANSACTION の際に指定することができます。

公式のページに書かれていますが、 Deferred, Immediate, Exclusive の3つを指定でき、デフォルトでは Deferred が使われます。
細かな定義については公式のページに記載があるので割愛しますが、それぞれで、「ロックを取得するタイミング」、「取得するロックの種類」が異なります。
これらは、既に実行中のトランザクションが別で存在する場合に、「相手のトランザクションの終了をどのタイミングで待つか」や「何をしたときにエラーにするか」が異なります。

例えば、標準の設定である Deferred を選んだ場合、 BEGIN DEFERRED TRANSACTION を実行したタイミングでは何のロックも取得されず、その後に続く SELECTINSERT などが実行された際に、初めて読み取り用の Shared ロックや書き込み用の Reserved ロックが取得されます。
なお、 Shared ロックと異なり、同時に複数のセッションが Reserved ロックを取得することはできません。
そのため、例えば下記のようなコードをほぼ同時に二つ実行すると、後から実行した方は INSERT 実行のタイミングで Reserved ロックの取得に失敗することになります。

これは、 SELECT 実行の時点で Shared ロックが取得され、 INSERT 実行の時点で Reserved ロックの取得が試みられることによります。
Reserved ロックは同時に一つのセッションしか取得できないので、後から実行した方のコードでは、 Reserved ロックの取得ができません。
このタイミングで、後から実行した方のコードは、トランザクションの実行に失敗します。
勘違いしやすいですが、自動のリトライは走らず Busy Timeout の設定も意味を持ちません。
これは、後から実行した方のコードでも、既に SELECT を発行しているために、このトランザクションの Serializable の分離レベルを保証できなくなるからです。
よって、 INSERT 実行のタイミングで、デッドロックにより失敗します。

一方で、後から実行した方のコードで、 SELECT などの読み取りのみを行うのであれば、トランザクションは最後まで問題なく実行されます。
おそらくですが、 Deferred のトランザクションが最も効果的に動作するのはこのような読み取りの場合で、この場合は Phantom Read などの影響を受けずに読み取れることが保証できます。

なお、 Immediate を指定したトランザクションの場合、 BEGIN IMMEDIATE TRANSACTION を実行したタイミングで即座に Reserved ロックが取得されるので、同時に二つのコードを実行してもエラーは発生しません。
ただし、 SELECT も待たされてしまうので、並列性は下がることになります。
同時に読み込みや書き込みがあってもエラーとなる可能性は下がるので、こちらが適しているケースも多々あるように思います。

Ruby on Rails での挙動

Ruby on Rails では、標準では SQLite3 がデータベースとして使われるように設定されています。

一方で、 SQLite3 独自のトランザクションのモードである Deferred や Immediate は、 Rails の世界からは容易には指定することはできません。
原則として常に Deferred になります。
なお、分離レベルとしては、 Read Uncommitted は指定することができるようになったようです。

いずれにしても、 SQLite3 は基本的には分離レベルは Serializable であり、その上で Deferred, Immediate などのトランザクションの種類を指定することになるので、他のデータベースも考慮して、汎用的な Rails のコードを書こうと思うと、トランザクションのモードを指定するのは難しくなります。

sqlite3 ライブラリでの吸収

こちらについて少し考えた結果、 SQLite3 へアクセスするためのライブラリ sqlite3 で吸収するのがよいのではないかと考えました。

そこで、こちらの Pull Request で、「標準で使うトランザクションのモード」をコンストラクタで指定できるようにしてもらいました。

これにより、トランザクション実行時に、どのようなモードにするかの既定値を指定することができるようになります。
Rails では、何も指定せずにトランザクションが開始されるので、そういった状況で使われるモードを指定できることになります。

Ruby on Rails での設定

以上を踏まえ、 sqlite3 のバージョン 1.6.9以降を使った上で、以下の設定値を config/database.yml に書くと、トランザクションが Immediate モードで実行されるようになります。