1 ポイント 投稿者 GN⁺ 2024-09-13 | 1件のコメント | WhatsAppで共有
  • RailsでSQLiteをデフォルト設定のまま本番環境で使うにはまだ不十分だが、いくつか設定を追加すれば、高性能で回復力のあるアプリケーションを構築できる
  • 同時書き込み負荷がかかると、SQLiteのグローバルな書き込みロックにより SQLITE_BUSY が発生し、Railsの一般的な書き込みトランザクションの流れはデフォルトのdeferredモードと相性がよくない
  • sqlite3-ruby 1.6.9以降では database.yml でデフォルトのトランザクションモードを IMMEDIATE に変更でき、書き込みロック取得失敗時に、より安全に待機・再試行できるようになる
  • busy_timeout は待機中もRubyのGVLを保持してPumaの並列性を損なうため、busy_handlersleep を使ってGVLを解放し、1ms間隔で再試行するとロングテールのレイテンシを減らせる
  • WALモード、IMMEDIATEトランザクション、GVLを解放するbusy handler、Rails 7.1のSQLiteデフォルト設定、そして任意の読み取り/書き込みコネクションプール分離が、SQLite on Railsの性能改善の中核となる

基本的な Rails + SQLite 設定で見えてくる限界

  • RailsアプリケーションをSQLiteベースで高性能かつ回復力のある形で運用するには、現在のデフォルト設定だけでは不十分
  • 目標は、Rails 8でSQLiteのデフォルト体験を本番環境対応完了の状態にすること
  • デモアプリケーションは Lorem News
    • Hacker News風の基本クローンで、ユーザー、投稿、コメントを含む
    • コンテンツは Lorem Ipsum で構成されている
  • 負荷テストには oha load testing CLI と、アプリに組み込まれた benchmarking routes を使用
  • post#create エンドポイントに5秒間シーケンシャルにリクエストを送ると、RPSは安定し、すべてのリクエストが成功する
  • 同じエンドポイントに4つの同時リクエストを5秒間送ると、一部のリクエストが500レスポンスを返す

SQLITE_BUSY と IMMEDIATE トランザクション

  • ログで最初に確認できる問題は SQLITE_BUSY 例外
  • SQLiteは同時に1つの書き込み処理だけを許可するため、データベースに書き込みロックを使う
    • 一度に1つのコネクションだけが書き込みロックを保持できる
    • 別のコネクションがロックを保持している状態で、新しいコネクションが書き込みロックを取得しようとすると SQLITE_BUSY が発生する
  • Railsアプリがより大きな同時負荷を受けると、SQLITE_BUSY によって失敗するリクエストの割合も増える
  • SQLiteのデフォルトのトランザクションモードは deferred で、実際に書き込みが発生するまでロックを取得しない
    • 単一コネクション環境や読み取り専用トランザクションが多い環境では、性能面で有利
    • 本番のRailsアプリは複数スレッドで複数コネクションを使い、Railsは書き込みクエリをトランザクションで包むため、このデフォルトとは衝突する
  • トランザクションの途中で書き込みロック取得に失敗すると、SQLiteはシリアライズ可能分離を壊さないために、そのクエリを安全に再試行できず、即座に例外を投げる
  • IMMEDIATE トランザクション は、トランザクション開始時点から書き込みロックの取得を試みる
    • SQLiteが書き込みクエリをキューに入れ、後から再びロック取得を試せるようになる
    • Railsの書き込みトランザクションパターンには、deferredよりこちらの方が適している
  • sqlite3-ruby gem は 1.6.9 からデフォルトトランザクションモードの設定をサポートしている
    • Railsは database.yml のトップレベルキーを sqlite3-ruby のデータベース初期化に渡す
    • database.yml でRailsのSQLiteトランザクションを IMMEDIATEモード で実行するよう設定できる
  • この変更後、単純な負荷テストでは同時負荷下でもほとんど500エラーなしで処理できるが、16同時リクエストでは再び一部エラーが現れる

busy_timeout、GVL、custom busy_handler

  • 次のボトルネックは、同時リクエスト数がPumaワーカー数に近づく、またはそれを超えたときに、p99レイテンシが急激に増加する現象
  • 実際のリクエスト処理時間はPumaワーカー数の3倍の同時負荷でも安定しているが、約5秒かかるリクエストが出始めると SQLITE_BUSY の500レスポンスも一緒に発生する
  • この5秒は database.yml の timeout 設定と一致し、SQLiteの busy_timeout 設定に対応する
  • busy_timeout は即座に BUSY 例外を投げる代わりに、指定ミリ秒のあいだ待機しながら書き込みロックの再取得を試みる
    • SQLiteは一種の指数バックオフ方式でロック再取得を試みる
    • timeout 内にロックを取得できなかった場合にのみ BUSY 例外を投げる
    • Webアプリケーションは複数コネクションを開いても、書き込み順序を自前で調整せずSQLiteに任せられる
  • ボトルネックは、Rubyプロセス内にSQLiteが組み込まれており、sqlite3-ruby がSQLiteのCコードを呼び出す際にRubyのGVLを解放しないことから生じる
    • 1つのPumaワーカーは、データベースクエリの返却を待つあいだもGVLを保持し続ける
    • 他のPumaワーカーが同時に書き込みクエリをSQLiteへ送ることすら難しくなる
    • SQLiteの逐次書き込み特性がRailsのリクエスト処理までより直列的にしてしまい、スループットを大きく下げる
  • SQLiteは busy_timeout より低レベルな busy_handler フックを提供している
    • busy_timeout はSQLiteが提供する特定の busy_handler 実装
    • sqlite3-rubysqlite3_busy_handler C関数へのバインディングを提供しているため、クエリがキューに入ったときに呼ばれるRubyコールバックを作れる
  • Rubyコールバックで Kernel.sleep を使えば、クエリが書き込みロックの再試行を待つあいだGVLを解放できる
  • この方法は同時負荷時のp99レイテンシを大きく改善するが、p99.99レイテンシでは、同時負荷が増えるほど最も遅いリクエストがさらに遅くなる現象が残る

指数バックオフのロングテールと1ms再試行

  • Rubyで再実装したSQLiteの busy_timeout ロジックは、長く待たされているクエリほど不利になる構造
  • 初期の再試行では待機時間は短いが、コールバック呼び出し回数が増えるほど待機時間が長くなる
    • 最初の待機では1ms待つ
    • 10回目の呼び出しでは50ms待つ
    • 12回目以降は100msずつ待つ
    • 累積待機時間が timeout の5000msを超えると例外が発生する
  • 継続的に新しい書き込みクエリが入ってくると、新しいクエリは短い待機時間でより頻繁にロック取得を再試行できる
    • すでに3回待機した古いクエリが10ms待っているあいだに、新しいクエリは1ms、2ms、5msの待機を経て3回再試行できる
    • この増加型バックオフは、古いクエリが書き込みロックを取得できずにtimeoutする可能性を高める
  • 解決策は、クエリの古さに関係なく、すべてのクエリが同じ頻度で再試行するようにすること
  • sqlite3-ruby の main ブランチ には、この変更が反映されている
    • この機能は執筆時点ではまだタグ付きリリースには含まれていない
    • Rubyコールバックは sleep によって待機中にGVLを解放する
    • 常に 1ms だけ sleep する
  • この変更後、ベンチマークでは p99.99 レイテンシ曲線が平坦化する
    • 同時性がPumaワーカー数の半分を超えたときのジャンプは残る
    • その後のロングテールレイテンシは約0.5秒程度で平坦になる

WALモードと読み取り/書き込みコネクションプールの分離

  • SQLite on Rails の性能のために必要な4つの条件は、IMMEDIATEトランザクション、GVLを解放するbusy handler、適切なSQLite設定、そしてWALモード
  • write-ahead log は、SQLiteが複数の同時読み取りを処理できるようにする
    • デフォルトの rollback journal mode では、読み取りでも書き込みでも一度に1つのクエリしか許可されない
    • WALモードでは複数のreaderを同時に許可するが、writerは同時に1つだけ
  • Rails 7.1 以降、RailsはSQLiteデータベースにより良いデフォルト設定を適用している
    • これらの設定は、SQLiteがWebアプリケーション文脈でうまく動作するために重要
    • 詳細設定とその理由は、別の ブログ記事 で扱っている
  • 任意の5つ目の性能レバーは、読み取り専用プール書き込み専用プールを分離すること
    • SQLiteのWALモードは、複数の読み取りコネクションと1つの書き込みコネクションをサポートする
    • Active Recordのコネクションプールが書き込みコネクションで飽和すると、読み取り処理まで不要にブロックされる可能性がある
  • Railsの multiple databases support を活用すれば、readerとwriterの設定を同じSQLiteデータベースへ向けられる
    • 実際には別データベースではなく、同じ単一データベースを指している
    • その結果、分離されたコネクションプールとコネクション設定を作れる
  • readerコネクションプールは読み取り専用コネクションで構成し、writerコネクションプールは1つのコネクションだけを持つようにする
  • Active Recordモデルは役割に応じて適切なコネクションプールに接続するよう設定する
  • Railsの automatic role switching を使って、すべてのWebリクエストのデフォルトコネクションをreaderプールにし、データベース書き込みが必要なときだけwriterプールへ切り替える
    • 同じデータベースを書いているため、read your own writes 保証のための delay は不要
    • ActiveRecordアダプタの transaction メソッドにパッチを当てて、トランザクションがwriterデータベースへ接続されるようにする
  • この「deferred requests」と分離コネクションプールの組み合わせは、comment create エンドポイントのテストで、単純なRPS基準の性能改善を示す

gemとしてパッケージ化された改善点

  • すべての改善をRailsアプリ内で直接実装する必要はない
  • activerecord-enhancedsqlite3-adapter を導入すれば、関連する改善を適用できる
  • 分離コネクションプール機能はより新しい実験的機能のため、設定を追加してopt-inする必要がある
  • このアプローチは、SQLiteをRails本番環境で高速かつ柔軟に使うためのツール、手法、デフォルト設定をまとめて提供する
  • RailsはSQLiteと組み合わせて使うのに適したWebアプリケーションフレームワークであり、関連ツールとgemエコシステムも成長している

1件のコメント

 
GN⁺ 2024-09-13
Hacker News のコメント
  • SQLite + Railsを使おうとしている人なら、Oldmoe(X/GitHub)のLitestackプロジェクトはぜひ見る価値があります
    Litestackは、RubyとRuby on RailsアプリでSQLiteの組み込み性を活用し、SQLデータベース、高速キャッシュ、ジョブキュー、メッセージブローカー、全文検索エンジン、メトリクス基盤を1つのパッケージで提供するRuby gemです
    今のプロジェクトで使っていますが、とても満足しており、リンクはこちらです: https://github.com/oldmoe/litestack

  • これほど詳しい記事を書くのに何日かかったのか想像もつきません
    Railsに限らず、SQLiteのWebアプリケーションのスケーリングを考えている人に役立つ記事です

  • SQLiteを扱う人は、使用言語やフレームワークに関係なくこの記事を読むべきです
    数年前、こうした内容の大半を自力で見つけなければならなかった立場として、まとめてくれてありがたいです

  • FOSSの分析システムを作っているのですが、インストールを簡単にする必要があるため、イベントデータをメインアプリのデータとは分離した別のSQLiteデータベースに送りたいと考えています
    そこそこ忙しいWebサイトでも毎秒1000件以上のイベントが発生し得るので、スケーリングが心配です
    サーバーメモリにイベントをためて、1秒ごとに一度バッチ書き込みする方式は、SQLiteの複数書き込み制限を避ける合理的な方法なのか、あるいはもっと良いアイデアがあるのか気になります

    • かなり合理的で良いアイデアだと思います。複数のシステムで似た方式を実装したことがありますし、一般にバッチ処理は項目あたりのオーバーヘッドを減らしてくれます
      ベンチマークでも簡単に証明できますし、まとめた項目全体を単一トランザクションに入れることもできます
      バッチ処理であれば、実質的にバッチを取り出して実際の書き込みを行うスレッドが1つできるため、SQLiteの同時書き込みは1つまでという制限とも相性が良いです
      ただし複雑さは少し増します。単一のバッチ書き込みが1秒以内に終わらなかったらどうするのか、メモリ内に保存するキューサイズを無制限にするのか、無制限ならサーバー過負荷時にOOMで落ちないと確信できるのか、制限を設けるなら項目の欠落を許容するのか、捨てるならどの項目を捨てるのか、キューの上限はいくつにするのかを決める必要があります
      こうした問いは、キューを必要とするほぼすべてのシステムで必ず出てくるもので、今だけでなく今後の状況にも役立ちます
    • SQLiteが書き込み自体をできないわけではありません。ただし一度に1つの書き込みトランザクションしかサポートしません
      SQLiteのトランザクション並行性の性能を信頼しにくいなら、特定のスレッドやプロセスですべての書き込みを直列化すればよいです
    • ここではうまく動き、SQLiteに対する考えが変わりました。Hippとも一緒に作業しましたが、SQLiteは驚異的な性能を出せます: https://use.expensify.com/blog/scaling-sqlite-to-4m-qps-on-a...
    • 分析データは通常書き込み中心なので、ClickHouseをおすすめします
      ClickHouseのasync-insert[0]機能を使えば、アプリケーション側でイベントのバッチ処理を気にしなくて済みます
      組み込み型のソリューションを探しているなら、ClickHouseベースのchDBを使えます
      [0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
    • バッチ書き込みはおそらく良い考えですが、SQLiteでこうしたことをする際の最善の方法はWALを使い、指定された単一のライターを1つ置くことです
      読み取りワーカーはいくらでも置けますし、ライターはキューのようなものから入力を受け取ればよいです
      これだけで、性能が本当に驚くほど高いことが多いです
  • SQLite をプロダクションのバックエンドデータベースとして使おうという流れは、まだよく理解できない
    SQLite はスマートフォンのアドレス帳のような、小さく組み込み可能なクライアント側アプリケーションのデータベースとしては優れているが、開発者自身もその範囲を超えて拡張されることを一貫して拒んできた
    たとえば日付/時刻や UUID のような有用なネイティブ型を追加していない。コードと組み込みオブジェクトのサイズが大きくなるためで、その結果「すべてが文字列」という状態に閉じ込められている
    参照整合性は有効化できるが、制約条件のオプションも非常に限られている
    なぜ合っておらず、きちんとサポートもされていない役割に、無理やり押し込み続けようとするのか分からない

    • まず Richard Hipp が、SQLite が「本来意図された」役割について同じ考えかどうかは分からない
      理由は単純。読み取り中心のアクセスパターンでは SQLite は非常に速く、十分に速いのでデータベースアクセスコードを単純化でき、たとえば N+1 クエリも実際には問題にならないことが多い
      また SQLite は N 層アーキテクチャから 1 つの層を取り除き、その分だけ故障しうる要素も減らす。Postgres や MySQL を自分で運用したことがあるなら、実際に多くのことがうまくいかなくなり得ると分かるはず
      すべてのアプリ、まして多数のアプリにとって完璧な選択ではないが、今の流れは SQLite が「小さな組み込み型クライアントアプリのデータベース」にしか適さないという明らかに誤った考えに対するバランス調整に近い
    • 結局「API サーバー」は、実は最初から DBMS だったのだと気づくことになる
      そう見れば、同じデータを扱う DBMS の横に、さらに別の DBMS がある構造はかなり滑稽に見える
      そこで、クライアントを Postgres に直接接続させるか、Postgres を外して独自の DBMS により集中するかに分かれる
      2 番目を選ぶなら、SQLite はその上に構築しやすいエンジンだ。完璧ではないが、今ある道具である
      こうした気づきが大規模に広がったのは比較的最近なので、何ができて何ができないのかを探る実験が多く進んでいる
      コンピューティングにおける自然な循環であり、古いものがまた新しくなるということだ
      Postgres は MySQL、MSSQL、Oracle、または他の DBMS に読み替えてもよい
    • UUID は文字列や BLOB として、日付は文字列または整数/実数のタイムスタンプとして使えばよいのでは?
      こうした単純化は SQLite の開発者や低スペックハードウェアだけでなく、アプリ開発者にもさまざまな面で利益をもたらす
      ドキュメントが単純になり、学習曲線が短くなり、バグの表面積やバイナリサイズも小さくなる
      最近のソフトウェアには、あらゆるものに肥大化と複雑性を加えようとする傾向があるが、SQLite のようないくつかのプロジェクトがそれに抗っているのは非常に良い
  • 素晴らしい記事で、Django にも似た資料があるのか気になる
    ArchiveBox は Django 経由で SQLite を使っているが、記事で Rails について説明されているのとまったく同じ問題にかなり頻繁に遭遇している
    アプリの別経路ですべての書き込みを直列化しなくても済む SQLite レイヤーのソリューションがあるとよい

  • sqlite3-ruby gem は設計上、SQLite 呼び出し中に GVL を解放しないが、リンク先の issue コメント https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss... を見ると、ロックを再取得するコストが大きいという疑いはあったものの、検証はしていないように見える
    こうした回避策のすべてを考えると、少し疑わしい
    Python の拡張文化なら逆の設計になっていそうだが、実際にそちらではどうしているのか気になる
    さらにリンク先の issue には、「extralite gem はブロッキング中に GVL を解放する代替 SQLite クライアントであり、並行性に関する内容は https://github.com/digital-fabric/extralite?tab=readme-ov-fi... にある。一般的にこの gem よりずっと速く、並行性の問題もない」というコメントもある

    • より詳しい議論は https://github.com/sparklemotion/sqlite3-ruby/pull/528https://github.com/digital-fabric/extralite/pull/46 で見られる
      SQLite 仮想マシンのすべての step ごとに単純に GVL を解放すると、シングルスレッド性能が大きく悪化することが検証されている
      シングルスレッド性能とマルチスレッド性能の中間点を見つけるのは難しい
      Rails では接続プールがあるためマルチスレッドであることが分かっているが、低レベルの gem はシングルスレッド環境で使われる他のライブラリやツールでも多く使用されている
  • 個人の実験的な Web サービスで維持しているいくつかの調整値は次のとおり
    PRAGMA journal_mode = WAL;
    PRAGMA busy_timeout = 5000;
    PRAGMA synchronous = NORMAL;
    PRAGMA cache_size = 1000000000;
    PRAGMA foreign_keys = true;
    PRAGMA temp_store = memory;
    そして BEGIN IMMEDIATE トランザクションを使っている
    https://kerkour.com/sqlite-for-servers

    • cache_sizemmap_size についてはどう考えているのか気になる
  • SQLite も Rails も好きだが、これは本番環境で MS Access を使うのに似て見える

    • まったく違うわけではないが、読み取り中心のシナリオでは SQLite は Jet(MS Access)よりはるかに性能が良い
      それに、Access の利用がより多かった数十年前より、今はコンピュータもディスクもずっと速くなっている
      読み取り中心なら、SQLite はもちろん Jet でさえ、秒間数万リクエストにはかなり容易に到達できるはず
      ほとんどのアプリケーションは同時ユーザー数が数十万人に達しないので、SQLite がうまく合う場合がある
      SQLite は、ほぼあらゆるプラットフォームと言語にクライアントがある点も強み
      アーカイブ、バックアップ、移植性も SQLite に向いた用途。特定期間に限られたデータ入力があるプロジェクトで、ボックスごとに SQLite を使うことを強く推したことがあり、今でもそのほうが良かっただろうと感じている
      複雑なスキーマやユーザー定義のエクスポート/アーカイブ機能を作る代わりに、1つのファイルをコピーしてアーカイブやバックアップにでき、時間とともに生じるスキーマ変更もそこまで深く考えずに済んだはず
      状況によるが、多くの問題に対してかなり良い解決策。ほとんどのアプリケーションでは、よりスケーラブルな NoSQL の選択肢より PostgreSQL や他のリレーショナル DBMS のほうが良い選択であることが多いのと似ている
      過剰に設計しがちだったが、今ではコンピューティングと入出力性能が、そのような努力を正当化しにくい水準に近づきつつある
    • Web アプリ向けの MS Access のようなもの があるといい。いろいろな Web サイトビルダー製品を使ってみたが、Access ほど直感的なものはなかった
    • なぜそうなのか気になる。特に読み取り中心のアプリケーションなら、どんなスケーリング限界を心配しているのか?
    • Pieter Levels にはこの方式がうまく機能している。もちろん、ユーザー数がある閾値を超えれば問題は起きるだろう
  • とても有益でよく書かれた記事
    デフォルトの busy_timeout メソッドが、古いクエリにペナルティを与える 指数的な遅延 をなぜ持つのか気になる
    これがデフォルト値として合理的な理由は何だろう?