こんにちは。高知県在住の @kawaida です。 最近子どもが捕まえて来たダンゴムシを飼い始めました。 ダンゴムシって湿度がないと呼吸ができないんですね。知らなかった。
この記事では、「基本機能」と呼ばれる SmartHR 最大の Rails アプリケーションにリードレプリカを導入して1年が経ったので導入する際に考えたことをご紹介します。
背景
「基本機能」はフィーチャーチームを中心にプロダクト開発に必要なことは何でもやるスタイルで開発を進めていました。
しかし、フィーチャーチームはそれぞれの担当機能を自律的に開発しているので、アーキテクチャ、パフォーマンスなどアプリケーションを横断した観点が抜けがちになる面がありました。
その結果、重要度は高いけど優先度の上がらない中長期的な課題が少しずつ積み上がっていき、特に高負荷時に不安定になるスケーラビリティの問題が表面化し始めていました。
そこで、スケーラビリティに関心を持つスケーラビリティエンジニアリングユニットが組成され、1人目のエンジニアとして私がアサインされました。 参照系の一部のエンドポイントへのリクエストの集中で負荷が高まっていたので、まず最初に取り組んだのがリードレプリカの導入です。
構成
リードレプリカを導入する前は Cloud SQL のシングルインスタンスで運用をしていました。

データ量の増加により、1クエリで扱うデータ量が増え高負荷時にレイテンシの悪化がみられました。
まだスペックアップできる余地はありましたが、ワークロードの傾向を考えると読み取り操作の方が多いことから、リードレプリカを立てて負荷分散をする方がコスト効率が良さそうだったので導入を決めました。

実装検討
導入を決めたのは良いものの、リードレプリカを導入した経験はなかったのでどういう機能が必要になるのかわかりませんでした。 そのため Rails 標準の database_selector や gem の実装を確認するところから始めました。
Rails 標準の database_selector
ActiveRecord::Base.connected_to(role: :reading)ブロックで向き先を指定- GET, HEAD 以外のリクエスト時に最終更新日時をセッションに保存
- GET, HEAD のリクエストで最終更新日時から指定秒数経過していたらレプリカに向ける
- 最終更新日時はセッションに保存されるので自分が書き込んだものを読み取ることを保証してくれる
switch_point
with_readonly,with_writableのブロックで囲って向き先を変えるauto_writableを使うと 書き込みクエリは自動でwritableな接続を利用する- Rails 6.1 以降は未サポート
makara
- 多機能
- プライマリに向けた時刻を Cookie に残し、指定秒数以内のアクセスの場合はプライマリに向ける
- クエリをパースして参照クエリはレプリカ、それ以外はプライマリに向ける
- 複数台のレプリカで負荷分散できる
- フェイルオーバーをサポート
- メンテナー募集中
active_record_proxy_adapters
- makara の影響を受けた gem
- 複数台のレプリカはサポート外(アプリケーションサーバーの外側で行うべき)
- Rails 7 以降もサポート
distribute_reads
distribute_readsブロックで囲ってレプリカに向けられるlog_failoverオプションで DB にレプリケーションラグを問い合わせて一定以上ならプライマリを向ける- makara と組み合わせて利用が可能
見えてきた要件
各実装が提供している機能を調べて、必要な機能がわかりました。
ブロックで囲って向き先を指定する
SmartHR 基本機能は SmartHR 最大の Rails アプリケーションでエンドポイントが多く、いきなりすべてのエンドポイントで自動制御を入れるのは影響範囲が広く導入に時間がかかります。
switch_point や distribute_reads のようなブロックでレプリカに向ける対象を指定する方が影響範囲が絞れて良さそうでした。
書き込み後一定期間はプライマリに向ける
自分の操作で更新を行った後に、詳細ページに遷移して更新されていなかったり、RecordNotFound が発生すると体験が悪いので書き込み後一定期間はプライマリを向ける機能は必要です。
レプリケーションラグを DB に問い合わせて向き先を決める
検証環境で Cloud SQL のレプリカインスタンスを立てたところ、レプリケーションラグがそれなりに大きいことがわかりました。(後述するオプションの変更により多少は減りました)
できるだけ負荷分散するためには「書き込み後一定期間はプライマリを向ける」しきい値は小さくしておきたいですが、しきい値よりラグが大きくなると古いデータを参照し続ける可能性があります。
distribute_reads の提供するレプリケーションラグに応じて向き先を変える機能はとても魅力的でした。
自作の道
各種実装を確認して、欲しい機能が整理できました。 それらをすべて提供している実装はなかったことと、シンプルな実装にできそうだったので自前で作ることにしました。 具体的には以下のようなモジュールを作成しました
レコード更新時に最終書き込み日時を Cookie に書き込む rack ミドルウェア
書き込み後一定期間はプライマリに向けるためにレコード更新時に最終書き込み日時を Cookie に書き込む rack ミドルウェアを作成しました。
Rails のcheck_if_write_query メソッドにパッチを当て、トランザクション中に1度でも更新クエリが流れたら Cookie の最終書き込み日時を更新するように実装しました。
def check_if_write_query(sql) # :nodoc: if preventing_writes? && write_query?(sql) raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" end end
Rails の database_selector のように GET / HEAD 以外のリクエストのみ更新するでも良かったのですが、より精度高く判定できそうな方を選択しました。
Cookie で制御する方式だと
- Cookie を使うことが保証されていない非同期処理などでは使えない
- 他のユーザーが更新した場合はラグの影響を受ける
といった懸念があったので、Redis のようなグローバルに状態を共有できるストアにテナント毎の最終更新日時を保存し、制御する方式も考えましたが、実際にどれくらいユーザー影響があるのかわからないですし、グローバルなストアにアクセスするオーバーヘッドは増えるので導入後問題が顕在化したら改めて考えることにして進めました。
ブロックで囲うと最終書き込み時間からの経過時間と DB に問い合わせたレプリケーションラグから向き先を変えてくれる database_selector
ブロックで囲うと「最終書き込み時間からの経過時間」と「DB に問い合わせたレプリケーションラグ」を元に向き先を変えてくれる database_selector を実装しました。
ほぼほぼ、Rails の DatabaseSelector::Resolver と同じ実装です
class DatabaseSelector LAST_WRITE_KEY = xxx SEND_TO_REPLICA_DELAY = yyy.seconds MAX_LAG_SECOND = zzz def initialize(last_write) @last_write = last_write end def self.read(session, &blk) new(session[LAST_WRITE_KEY]).read(&blk) end def read(&blk) if read_from_replica? read_from_replica(&blk) else read_from_primary(&blk) end end private attr_reader :last_write def read_from_primary(&blk) connected_to(role: ::ActiveRecord.writing_role, prevent_writes: true, &blk) end def read_from_replica(&blk) connected_to(role: ::ActiveRecord.reading_role, prevent_writes: true, &blk) end def connected_to(role:, prevent_writes:) ::ActiveRecord::Base.connected_to(role:, prevent_writes:) do yield end end def read_from_replica? unless time_since_last_write_ok? return false end unless replication_lag_ok? return false end true end def time_since_last_write_ok? Time.now - Timestamp.convert_timestamp_to_time(last_write) >= SEND_TO_REPLICA_DELAY end def replication_lag_ok? ReplicationLag.current < MAX_LAG_SECOND end end
※ イメージ
レプリケーションラグを取得する実装はほぼほぼ distribute_reads のものと同じなので割愛します
導入後
実装が整い検証環境でも大きな不具合もなかったので本番環境にも導入しました。 New Relic のトランザクショントレースを確認し、リクエスト時間の 20%程度を占めるエンドポイントでリードレプリカを使い始めました。
導入後以下のような課題と効果が確認できました。
HUGE ラグ
本格的にレプリカを利用開始するとラグが大きくなり、canceling statement due to conflict with recovery でレプリカへの参照クエリが切断されることがありました。
調べてみると レプリカへの参照クエリとレプリケーション処理が同一の行にアクセスし競合が発生、レプリケーションが遅延しすぎることがないように待機時間の制限が設定されており、待機時間を超えると競合を起こしている参照クエリが切断されてしまうことがわかりました。
競合する理由はいくつかありますが、ログを確認すると auto vacuum が走ったタイミングでよく発生しているようだったので hot_standby_feedback オプションを有効にすることにしました。
このオプションは参照中の行をプライマリインスタンスに共有し、該当行の vacuum 処理を遅らせるオプションだそうです。
有効にすることでレプリケーションラグが5分の1になり、canceling statement due to conflict with recovery の発生は0件になりました。
効果
今回リードレプリカに向けた対象のエンドポイントで高負荷時に発生していた lwlock(軽量ロック)の発生が抑えられることができました。
CPU 使用率とメモリ使用量はメトリクス上でわかりやすい改善効果は確認できませんでしたが、リードレプリカを使う対象のエンドポイントのリクエストの内 82%がリードレプリカを利用していたので確実に負荷分散できているはずです。
これから
導入後、大きいラグが発生したり、一部リクエストが切断されてしまう問題は発生しましたが、その後の設定変更により問題は解消しました。 1年経った今の課題はリードレプリカを利用するエンドポイントがあまり増やせていないことにあります。 今後はさらにレプリケーションラグの影響を小さくできるように改修し、利用促進したいと思っています。
We Are Hiring!
この記事を読んで SmartHR のスケーラビリティに少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!