Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 7.1: SQLite3アダプタのデフォルトコネクション設定が最適化された(翻訳)

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同期は極めて安全な代わりに低速です。synchronousNORMALに設定すると、データベースエンジンは最も重要な瞬間に同期しますが、同期の頻度は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_size2000に変更されたことで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 7.1.0 Active Record CHANGELOG(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。