Rails 7.1: SQLite3アダプタのデフォルトコネクション設定が最適化された(翻訳)
Rails 7.1では、Active RecordのSQLite3アダプタのコネクション設定が現代のRailsアプリによりふさわしい形で更新されました。
設定の変更について説明する前に、PRAGMA(プラグマ)について理解しておきましょう。PRAGMAは特殊なSQLステートメントで、データベースのさまざまな振る舞いや設定をクエリまたは操作するのに用いられます(SQLiteでのみ利用可能)。
🔗 1. journal_mode
でロールバックジャーナルではなくWALを使うようになった
journal_mode
は、SQLiteのデータベースがトランザクションを扱う方法と、システムクラッシュや予期せぬシャットダウンが発生した場合にデータ整合性を維持する方法を決定する設定です。従来の新規Railsアプリケーションでは、DELETE
ジャーナルモードでロールバックジャーナルを利用していました。変更後は、より効率の高いWAL(Write-Ahead Logging: ログ先行書き込み)というジャーナリングが使われるようになりました。2つのモードの違いを理解してみましょう。
🔗 ロールバックジャーナル
この実装では、データベースエンジンは最初に元の未変更のデータベースコンテンツをロールバックジャーナルに保存し、書き込みはデータベースファイルに対して直接行われます。システムがクラッシュした場合、データベースをトランザクション開始前の状態まで復元するときにこのロールバックジャーナルを利用できます。この方法の問題点は、ライター(writer)はデータベースを変更可能で、リーダー(reader)はデータベースから読み取り可能ですが、両者を同時に行えないことです。
🔗 WAL
WALモードでは、SQLiteが書き込み先行ログを別途維持します。変更はデータベースファイルに直接書き込まれず、このログに最初に書き込まれます。リーダーがコンテンツのページを必要とするときは、そのページがWALにあるかどうかを最初にチェックし、WALにそのページが存在する場合は、WALにある最新ページのコピーを取得します。WALにそのページがない場合は、元のデータベースファイルから読み取ります。つまり、リーダーとライターが協調動作し、競合が発生しないということです。
🔗 データベースファイルをWALファイルで最新に保つしくみ
SQLiteは、WALファイルを定期的にデータベースに移動します。このプロセスはチェックポインティング(checkpointing)と呼ばれます。デフォルトのSQLiteは、WALファイルがしきい値の1000ページに達すると自動的にチェックポインティングを実行します。
システムクラッシュ時には、最後のコミットはWALに書き込まれません。このコミットレコードが存在しない場合、新しいデータは有効とみなされなくなり、データベースで単に無視されます。
WALはコンカレンシーを高めるため、Webアプリケーションに適した選択肢です。
WALの内部動作について詳しくは、以下の素晴らしい記事をどうぞ。
参考: How SQLite Scales Read Concurrency · The Fly Blog
🔗 2. synchronous
設定がFULLからNORMALに変更された
synchronous
プラグマは、SQLiteがコンテンツをディスクに反映する方法とタイミングを制御します。よく使われるオプションはFULL
(すべての書き込みを同期)とNORMAL
(1000ページ書き込みごとに同期)の2つです。FULL
同期は極めて安全な代わりに低速です。synchronous
をNORMAL
に設定すると、データベースエンジンは最も重要な瞬間に同期しますが、同期の頻度はFULL
モードよりも低くなります。つまり、安全性と引き換えにスピードを得る積極的なアプローチです。
SQLiteドキュメントでは、WALモードで実行するアプリケーションではNORMAL
モードが推奨されています。
3. 🔗 journal_size_limit
に上限64MBが設定された
journal_size_limit
プラグマは、ディスク上のファイルに保持するWALデータの量をSQLiteに指示します。従来はジャーナルサイズの「上限なし」を意味する-1
が設定されていたため、ジャーナルが無制限に増加して読み取りパフォーマンスが悪化する可能性がありました。変更後は64MBという適切な上限が設定されました。
4. cache_size
が上限8MBに設定された
cache_size
プラグマは、SQLiteが一度にメモリ上に保持するデータベースディスクページの最大数を設定します。デフォルト値は-2000
ですが、SQLiteでは値が負の数値をバイト数と解釈します(この場合は2000バイト)。このcache_size
が2000
に変更されたことで2000ページに設定され、1ページあたりのデフォルトページサイズ4096バイトなので8MBが上限となりました。
🔗 5. mmap_size
が128MBに設定された
mmap_size
プラグマは、データベース1つあたりのメモリマップI/O用に確保する最大メモリ(単位はバイト数)を指定します。まずメモリマップI/Oとは何かを理解しましょう。
メモリマップ(mmap)I/OはOSが提供する機能で、二次ストレージ上のファイルの内容をプログラムのアドレス空間にマッピングします。次にプログラムがポインタ経由でページにアクセスするときには、あたかもファイル全体がメモリ上に存在するかのように扱われます。OSは、プログラムがページを参照する場合にのみページを透過的に読み込み、メモリがいっぱいになるとページを自動的に削除します。メモリマップのメリットは、二次ストレージから一次ストレージにページをコピーする必要がある手順をバイパスすることで処理が高速化されることです。
🔗 SQLiteのメモリマップI/Oはどう実装されているか?
SQLiteがデータベースファイルにアクセスおよび更新するときは、デフォルトでxRead()
メソッドとxWrite()
メソッドを使います。通常、これらのメソッドはシステムコールのread()
とwrite()
で実装され、OSがカーネルバッファキャッシュとユーザー空間の間でディスクの内容をコピーします。
またSQLiteには、メモリマップI/Oで直接ディスクの内容にアクセスするxFetch()
メソッドとxUnfetch()
メソッドもあります。
SQLiteのレガシーxRead()
を使うと、ページサイズのヒープメモリブロックが割り当てられ、データベースページのコンテンツ全体がxRead()
呼び出しによって割り当て済みメモリにコピーされます。
一方、メモリマップI/Oが有効になっている場合はxFetch()
メソッドが呼び出されます。このxFetch()
メソッドは、要求されたページへのポインタを返すようOSに要求します。リクエストされたページがアプリケーションのアドレス空間にマッピング済みまたはマッピング可能な場合は、xFetch()
がそのページへのポインタを返し、SQLiteがコピーを行わずに済むようにします。コピー手順がスキップされることで、メモリマップI/Oが高速になります。
mmap_size
は、SQLiteがプロセスのアドレス空間に一度にマッピングするデータベースファイルの最大バイト数を指定します。変更後はこの値が128MBに設定されました。
以上の変更によってパフォーマンスが著しく向上しました。なお、SQLiteが強みを発揮するのは単一ノードのproductionアプリケーションです(NVMeディスクと組み合わせると特にパフォーマンスが向上します)。
詳しくは#49349を参照してください。
概要
元サイトの許諾を得て翻訳・公開いたします。
参考: 週刊Railsウォッチ20231004: SQLite3アダプタのコネクション設定のパフォーマンスチューニング