SQLite on Railsシリーズ(02)SQLiteをチューニングで強化する(翻訳)
本記事は、Ruby on RailsのデータベースエンジンにSQLiteを使うことでアプリケーションを強化する方法を紹介するシリーズ記事の第2弾です。
今回は、production環境のWebアプリケーションをより適切にサポートするためのSQLite設定の調整方法を詳しく解説します。
チューニング済みの設定に飛びつく前に、SQLiteがどのように設定されるかについておさらいしておきましょう。
SQLiteの設定は、カスタムSQLステートメントを使って行います。
たとえばPRAGMA
ステートメントの説明は以下のようになっています。
PRAGMA
ステートメントは、SQLite固有のSQL拡張機能であり、SQLiteライブラリの操作を変更したり、SQLiteライブラリ内部(つまりテーブル以外)のデータを照会するのに使われます。
この定義からわかるように、プラグマステートメントは基本的に以下の2種類です。
- SQLiteライブラリの動作を変更する
- SQLiteライブラリの内部データを照会する
SQLiteを設定するうえで関心があるのは、後者ではなく前者です。
SQLiteのドキュメント↓には、プラグマのあらゆるステートメントに関する概要が掲載されています。
参考: PRAGMA
statements supported by SQLite
その中から非推奨化されたものや特殊なもの、内部データ用のものを除外していくと、以下のように40件の「設定用」PRAGMA
が残ります。
application_id
auto_vacuum
automatic_index
busy_timeout
cache_size
cache_spill
case_sensitive_like
cell_size_check
checkpoint_fullfsync
data_version
defer_foreign_keys
encoding
foreign_keys
freelist_count
fullfsync
hard_heap_limit
ignore_check_constraints
integrity_check
journal_mode
journal_size_limit
legacy_alter_table
locking_mode
max_page_count
mmap_size
page_count
page_size
query_only
quick_check
read_uncommitted
recursive_triggers
reverse_unordered_selects
secure_delete
soft_heap_limit
synchronous
temp_store
threads
trusted_schema
user_version
wal_autocheckpoint
私はRails 7.0.7.2
アプリケーションを作成して、これらのPRAGMA
の値が新規Railsアプリケーションでどのようにセットアップされているかをチェックしてみました。
{"analysis_limit"=>0,
"application_id"=>0,
"auto_vacuum"=>0,
"automatic_index"=>1,
"timeout"=>5000,
"cache_size"=>-2000,
"cache_spill"=>483,
"cell_size_check"=>0,
"checkpoint_fullfsync"=>0,
"data_version"=>1,
"defer_foreign_keys"=>0,
"encoding"=>"UTF-8",
"foreign_keys"=>1,
"freelist_count"=>0,
"fullfsync"=>0,
"hard_heap_limit"=>0,
"ignore_check_constraints"=>0,
"integrity_check"=>"ok",
"journal_mode"=>"delete",
"journal_size_limit"=>-1,
"legacy_alter_table"=>0,
"locking_mode"=>"normal",
"max_page_count"=>1073741823,
"mmap_size"=>0,
"page_count"=>5,
"page_size"=>4096,
"query_only"=>0,
"quick_check"=>"ok",
"read_uncommitted"=>0,
"recursive_triggers"=>0,
"reverse_unordered_selects"=>0,
"secure_delete"=>0,
"soft_heap_limit"=>0,
"synchronous"=>2,
"temp_store"=>0,
"threads"=>0,
"trusted_schema"=>1,
"user_version"=>0,
"wal_autocheckpoint"=>1000}
原注
上の出力は以下のコマンドで得ました。
pragmas.reduce({}) do |memo, pragma|
memo.merge!(ActiveRecord::Base.connection.execute("PRAGMA #{pragma}").first)
end
これは興味深い結果ですが、もちろん全てのプラグマがRailsやActive Recordのチューニングで同じように重要というわけではありません。そこで、最も影響力のあるプラグマに絞って見ていきましょう。特にWebアプリケーションのコンテキストでは、パフォーマンスに大きな役割を果たすプラグマが6つほどあります。
journal_mode
synchronous
journal_size_limit
mmap_size
cache_size
busy_timeout
/busy_handler
これらのプラグマの機能や用途を理解し、RailsとActive Recordでの利用方法に最も適した調整方法を理解しておくことが重要です1。
🔗 1: journal_mode
プラグマ
最初のjournal_mode
は、十分理解してからチューニングすべき最も重要なプラグマです。SQLite 3.7.0(2010-07-21)以降は、アトミックトランザクションをサポートするために以下の2種類の実装を提供しています。
ロールバックジャーナルはオリジナルのデフォルト実装ですが、ログ先行書き込みは新しい実装です。
journal_mode
プラグマには以下のオプションがあります。
- ロールバックジャーナル実装の振る舞いを変更する5つのオプション(
DELETE
、TRUNCATE
、PERSIST
、MEMORY
、OFF
) - ログ先行書き込み実装の利用を指定する1つのオプション(
WAL
)
新規作成直後のRailsアプリは、デフォルトではロールバックジャーナルをDELETE
ジャーナルモードで利用します。これは、トランザクションがコミットされるたびにロールバックジャーナルのファイルがディスク上から削除されるという意味です。
しかしWebアプリケーションの場合は、ログ先行書き込みがオプションとして優れています。SQLiteドキュメントにも書かれているように、ログ先行書き込みは、特にWebアプリケーションのコンテキストで、ロールバックジャーナルよりも優れた点がいくつかあります。
- WALはほとんどのシナリオで著しく高速化される
- WALのリーダー(reader)はライター(writer)をブロックせず、ライターもリーダーをブロックしないため、コンカレンシーが高まる。読み取りも書き込みもコンカレントに行われる。
私たちのアプリケーションで欲しいのはこれです。
Webではクエリの高速化とコンカレンシーの向上が必要なので、最初に行う必要がある設定変更は、journal_mode
プラグマをWAL
に設定することです(Rails でこれを行う方法の技術的な詳細については、本記事の後半で説明します)。
🔗 2: synchronous
プラグマ
SQLiteでサポートされているsynchronous
プラグマは、SQLiteがコンテンツをディスクに反映するタイミングや方法を制御する4つの異なるモードをサポートしています。
FULL
とNORMAL
は一般的なオプションです。
FULL
は「すべての書き込み時に同期」、NORMAL
は「書き込み1000回ごとに同期」を意味します。
4つのモードはそれぞれ整数値にも対応しているので、新規作成直後のRailsアプリでは、デフォルトの"synchronous"=>2
はFULL
モードに対応します。つまりSQLiteはすべての書き込みのたびにファイルに書き込みます。ドキュメントにも以下のように書かれています。
このモードはOSクラッシュや電源障害でデータベースが破損しないようにします。
FULL
同期は非常に安全な代わりに低速です。
遅いモードは欲しくありませんし不要です。SQLiteドキュメントにも以下のように書かれています。
WAL
モードで実行されているほとんどのアプリでは、synchronous=NORMAL
設定がよい選択となります。
要するに、journal_mode
をWAL
にしたら、synchronous
をNORMAL
に設定すればよいのです。この2つの設定はピーナッツバターとジャムのように相性バツグンです。
しかしNORMAL
同期モードとはそもそも何でしょうか?
NORMAL
は、SQLiteのディスク反映を書き込みのたびに行うのではなく、もっと低い頻度で行うという意味です。
SQLiteには、ディスクに書き込む「最も重要な瞬間」を決定する独自のアルゴリズムがあります。SQLiteはそのタイミングで、すべてのwal_autocheckpoint
ページ(デフォルトでは1000)を同期します。すなわち、wal_autocheckpoint
プラグマを変更すると、そのページ数の書き込みが行われた後でNORMAL
モードの同期が行われます。つまり、耐久性と引き換えに速度を得る強気の方法というわけです。
しかしSQLiteは耐久性をなるべく落とさないようさまざまなことを行っていますが、正直に言うと、これはよほどのエッジケースです。実際SQLiteは、OSやファイルシステムの障害でない限り潜在的なデータ損失はまず起きないようになっています。プロセスがクラッシュしてもデータの耐久性には影響しません。つまりこの最適化は1%のケースではなく99%のケースに対応するためのものであり、これはRailsアプリケーションに適していると思います。
🔗 3: journal_size_limit
プラグマ
次はjournal_size_limit
プラグマです。
これは、ログ先行書き込みデータをどの程度ディスク上に保持するかをSQLiteに指示します。
デフォルトの-1
はファイルサイズに上限がないことを意味するため、このディスクファイルのサイズは無制限に大きくなります。これは望ましくありません。
ディスクがログファイルで溢れてアプリがダウンしてしまった経験があれば、ファイルサイズ上限なしという設定は、いつダウンするかわかったものではない頭痛の種となることがわかるでしょう。
そこでファイルサイズを適切なサイズに制限する必要があります。しかし、適切なサイズとは正確にはいくつなのでしょうか。
小さすぎるのは望ましくないでしょう。ジャーナルファイル内のデータ量が多いほど、(一般的には)その分SQLiteも高速になります。かといって大きすぎると、読み込みパフォーマンスが悪化するため望ましくありません。production環境での利用状況や実験に基づいて、確実なデフォルト値として64MBという値に落ち着きました。
🔗 4: mmap_size
プラグマ
次はmmap_size
プラグマです。この設定は、「メモリマップドI/O(mmap)でアクセスされるデータベースファイルの最大バイト数」を制御します。長い説明ですが、要するにSQLiteのメモリマップドI/Oを有効にすると複数のプロセス間でデータを共有できるようになります。
メモリマップはPostgreSQLのバッファプールと同様の役割を果たすため、無効にせずに、デフォルトのPostgreSQLバッファプールと同じ安全な値(128MB)に設定する必要があります。
🔗 5: cache_size
プラグマ
cache_size
プラグマは、開いているデータベースファイルごとにSQLiteがメモリ内に一度に保持するデータベースディスクページの最大数」を設定します。
デフォルト値-2000
は負の数で、SQLite はこれをバイト数の上限として解釈します。正の数を指定すると、SQLiteはこれをページ数の上限として解釈します。なお、デフォルトの上限は最大2MB(2,048,000バイト)ですが、これはページ数とは無関係です。
大きなキャッシュを確保して、ページ間で分割されないようにしたいので、cache_size
には正の数を指定してキャッシュ制限をページ数に設定しましょう。
cache_size
の値は2000
(最大2,000ページ)がおすすめです。これは、デフォルトのページサイズが4,096バイトであるため、キャッシュ制限が最大8MB(4,096x2,000 = 8,192,000バイト)となることを意味します。
ページキャッシュについて完全に理解するために、次の点を知っておくとよいでしょう。
ページキャッシュは個別のコネクション(同じプロセス内のコネクションも含む)ごとにプライベートであり、別のコネクションからデータベースファイルに書き込むとページキャッシュが効きません。それでもページキャッシュは、ステートメントまたはトランザクションの範囲内でコンカレンシーの実行速度を最大化するために非常に有用です。
🔗 6: busy_timeout
/busy_handler
プラグマ
訳注
参考までにRails 8.0.1では、busy_timeout
がbusy_handler_timeout
に置き換えられました(#51958)。
理解してから調整することが重要な最後のプラグマはbusy_timeout
です。
これは、新しいコネクションの確立を試みるときに、データベースへの接続に成功するまでにSQLiteにどのくらいの時間待機するかを指示します。
SQLiteを使うRailsを新規作成すると、/config/database.yml
ファイル内のtimeout
オプションは5000
ミリ秒に設定されます。
SQLiteはタイムアウトを指定している間、指数バックオフ(exponential backoff)アルゴリズムを用いてコネクション試行をリトライします(バックオフは各試行の間に1、2、5、10、15、20、25、25、25、50、50、100
ミリ秒ずつ待機し、12回リトライすると100ミリ秒ごとに再試行します2。これは妥当なデフォルトですが、より積極的なパフォーマンスチューニングを行うには、代わりに busy_handler
を手動で設定することもできます。
busy_timeout
は、SQLiteで使うbusy_handler
を設定するための高レベルインターフェイスを提供しますが、カスタムbusy_handler
関数を自分で設定することも可能です。一般的なアプローチは、指数バックオフを用いてできるだけ早期にコネクションをリトライし、できるだけ早期にコネクションを確立します。無限の再試行を防ぐためにリトライの最大回数を制限できます。Ruby用のSQLite3-ruby
アダプタを使っている場合は、以下のようにprocを渡すことでbusy_handler
を設定できます。
@raw_connection.busy_handler do |count|
count <= @config[:retries]
end
この後の実装セクションでは、Railsアプリケーションで「最大タイムアウト」ではなく最大リトライ回数を設定する方法について説明します。
さしあたって、busy_timeout
を使うかbusy_handler
を使うかは、BUSY
例外が発生する可能性とタイミングがどのぐらい楽観的であるかによって決まることを知っておけば十分でしょう3。ほとんどのRailsアプリケーションでは、できるだけ早期にコネクションを確立できるようにbusy_handler
を設定することが推奨されます。
🔗 プラグマのまとめ
SQLiteの6つのプラグマは、以下のSQLで設定できます。
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA journal_size_limit = 67108864; -- 64MB
PRAGMA mmap_size = 134217728; -- 128MB
PRAGMA cache_size = 2000;
PRAGMA busy_timeout = 5000;
これにより、Webアプリケーションに適した微調整済みのSQLiteデータベースが提供されます。実際、このデフォルト設定は、Litestack
のLitedb
モジュールで使われているデフォルト設定とまったく同じです。RailsにおけるSQLiteの利用に最適なデフォルト設定を見つけるための道筋を切り開くという大変な作業をしてくれたLitestack
と@oldmoe に心から感謝いたします。
これで、設定したい6つのプラグマと、それぞれに設定したい値が決まりました。残るは、Railsアプリケーションを実際にこの通りに設定する作業だけです。本シリーズのテーマとして、Railsをオーバーライドするのではなく、Railsを強化したいのです。そのためには、Rails上に構築され、Railsと同様の柔軟性を提供するメカニズムが必要です。
🔗 Railsアプリケーションをチューニングする
Railsのデータベースアダプタを自然に設定できるフックポイントがあるようですが、残念ながら、まだ拡張用に公開されていません。これはconfigure_connection
メソッドです。このメソッドのコメントは以下の通りです。
新しく確立された
@raw_connection
で必要な初期化を実行します。ここでは、アダプタのコネクション設定を変更したり、アプリケーショングローバルの「セッション」変数を構成するためのクエリを実行したりします。
私たちが必要としているのはまさにこれのようです。まだ自然にフックをかけられないので、代わりに自己責任でSQLiteアダプタにモンキーパッチを適用しましょう。現時点のSQLiteアダプタは、timeout
オプションが設定されている場合はbusy_timeout
プラグマを設定し、configure_connection
メソッドでforeign_keys
プラグマをオンにします。このメソッドを拡張するために、SQLiteアダプタを拡張する初期化ファイルを作成しましょう。
ActiveSupport.on_load(:active_record_sqlite3adapter)
フックを使うと、アダプタが読み込まれた場合にのみ拡張できるようになります。このブロックにはSQLite3Adapter
が渡されるので、ブロック内でprepend
を呼び出すだけです。つまり、configure_connection
メソッドを拡張するモジュールを定義して、そのモジュールをアダプタクラスにprepend
するだけです。Rails 拡張機能をRailsExt
モジュール名前空間の下に配置したので、RailsExt::SQLite3Adapter
モジュールを作成しましょう。
# /config/initializers/active_record_sqlite3adapter.rb
module RailsExt
module SQLite3Adapter
# Perform any necessary initialization upon the newly-established
# @raw_connection -- this is the place to modify the adapter's
# connection settings, run queries to configure any application-global
# "session" variables, etc.
#
# Implementations may assume this method will only be called while
# holding @lock (or from #initialize).
#
# extends https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L691
def configure_connection
super
# ...
end
end
end
ここで行いたいのは、Railsデータベース設定のセットアップに機能強化を提供することで。私見では、データベース設定のデフォルト部分にpragmas
セクションを設定すれば、明確かつ柔軟な開発者エクスペリエンスを提供できると思います。後は、プラグマハッシュを反復処理し、SQLite呼び出しを実行して、拡張モジュールでプラグマを設定すればよいのです。これは簡単に実装できます。
def configure_connection
super
@config[:pragmas].each do |key, value|
raw_execute("PRAGMA #{key} = #{value}", "SCHEMA")
end
end
これで、データベース設定を以下のように強化できます。
default: &default
adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
# 例外発生前に書き込みロックを取得するために待機する時間(ミリ秒単位)
# https://www.sqlite.org/pragma.html#pragma_busy_timeout
timeout: 5000
pragmas:
# データベースの耐久性(durability)のレベル。
# 2 = "FULL"(書き込みごとに同期)、その他の値には1 = "NORMAL"(1000ページの書き込みごとに同期)、0 = "NONE"がある。
# https://www.sqlite.org/pragma.html#pragma_synchronous
synchronous: "NORMAL"
# ジャーナルモードのWALでは高コンカレンシー(多数のリーダーと1つのライター)が実現する。
# https://www.sqlite.org/pragma.html#pragma_journal_mode
journal_mode: "WAL"
# WALファイル数に上限を設けて無制限な増加を防ぐ(読み取りパフォーマンスにも悪影響を及ぼす)
# https://www.sqlite.org/pragma.html#pragma_journal_size_limit
journal_size_limit: <%= 64.megabytes %>
# すべてのプロセスがデータを共有できるようにグローバルメモリマップを設定する
# https://www.sqlite.org/pragma.html#pragma_mmap_size
# https://www.sqlite.org/mmap.html
mmap_size: <%= 128.megabytes %>
# ローカルのコネクションキャッシュを2000ページに増やす
# https://www.sqlite.org/pragma.html#pragma_cache_size
cache_size: 2000
このような感じで、アプリケーションのSQLiteデータベースを Webアプリケーション用途に適した設定に調整したことで、必要に応じて追加のSQLiteプラグマを設定するための明確でシンプルなメカニズムも提供されました。
最後に、timeout
オプションではなくretries
オプションをサポートする方法について説明しましょう。ここで重要な詳細は2つあります。
- 第1に、
retries
オプションとtimeout
オプションは両方同時に設定できない点が重要です(busy_handler
とbusy_timeout
は相互排他的であるため)。 -
第2に、このプラグマは最初に設定される点が重要です(ビジー処理ロジックが他のプラグマクエリで尊重されるようにするため)。
これらの機能をサポートするには、configure_connection
メソッドを以下のように更新します。
def configure_connection
if @config[:timeout] && @config[:retries]
raise ArgumentError, "Cannot specify both timeout and retries arguments"
elsif @config[:retries]
# 参考: https://www.sqlite.org/c3ref/busy_handler.html
@raw_connection.busy_handler do |count|
count <= @config[:retries]
end
end
super
@config[:pragmas].each do |key, value|
raw_execute("PRAGMA #{key} = #{value}", "SCHEMA")
end
end
これで、timeout: 5000
設定をretries: 1000
設定に置き換えて適切なbusy_handler
が設定されるようになります。
原注
本記事で行った最終的な実装についてはGistで参照できます。
さらに、Nate Hopkinsが共有してくれたGistには、いくつかのパフォーマンス最適化を含んだSQLiteを手動でダウンロードしてコンパイルするDockerfileが含まれています。
SQLiteのドキュメントには、SQLiteを自分でコンパイルする場合に行うべき推奨されるコンパイル最適化が概説されており、NateはこれをDockerfileにうまく統合しています。そのいくつかは、私たちが採用しているPRAGMA
ステートメントと重複していますが、それ以外はすべてコンパイル最適化です。つまり、NateはSQLiteをコンパイルするときに次のフラグを渡しています。
SQLITE_DEFAULT_MEMSTATUS=0 \
SQLITE_DEFAULT_PAGE_SIZE=16384 \
SQLITE_DEFAULT_WAL_SYNCHRONOUS=1 \
SQLITE_DQS=0 \
SQLITE_ENABLE_FTS5 \
SQLITE_LIKE_DOESNT_MATCH_BLOBS \
SQLITE_MAX_EXPR_DEPTH=0 \
SQLITE_OMIT_PROGRESS_CALLBACK \
SQLITE_OMIT_SHARED_CACHE \
SQLITE_USE_ALLOCA"
SQLITE_DEFAULT_WAL_SYNCHRONOUS
設定は、私たちのPRAGMA journal_mode = WAL;
やPRAGMA synchronized = NORMAL;
設定と重複していますが、メモリトラッキングやBLOB
フィールドでのLIKE
検索を無効にする最適化はコンパイル時にのみ有効です。
今回もいつものように、可能であればデータベースをチューニングしてニーズに合わせる方法を必ず学んでおきましょう。SQLiteドキュメントのコンパイル時の推奨オプションとNateのDockerfileのページを読み、SQLiteのインストールから余分なサイクルを絞り出してRailsアプリをスムーズに動かす方法を学んでください。
関連記事
- 原注: SQLiteをproduction環境で利用するためのプラグマ調整法について詳しい記事として、SQLite performance tuning - Scaling SQLite databases to many concurrent readers and multiple gigabytes while maintaining 100k SELECTs per second - phiresky's blogやIncreasing SQLite Performance - Devart Blogなどがあります。 ↩
-
原注: これはSQLiteのメンテナーがフォーラムで返信したものであり、
main.c
ファイル内のsqliteDefaultBusyCallback
メソッドで見られます。 ↩ - 原注: SQLiteが例外をスローする方法については、『Understanding SQLITE_BUSY • ActiveSphere - Software consulting』という良記事に詳しくまとまっています。 ↩
概要
原著者の許諾を得て翻訳・公開いたします。
参考: Rails 8はSQLiteで大幅に強化された「個人が扱えるフレームワーク」(翻訳)|YassLab 株式会社
日本語タイトルは内容に即したものにしました。
本記事のチューニングは、Rails 7.1以降に取り込まれています。参考までに、以下はRails 8.0.1のSQLiteデフォルト設定です。