
スマートバンク 新春エンジニア駅伝 2026第九区走者のサーバーサイド部の occhi です。
最近、私たちのシステムの心臓部である本体アプリケーション「core-api」のデータベース負荷を分散するため、リードレプリカを導入しました。
プライマリ(書き込み用)とリードレプリカ(読み取り用)を分ける構成は、読み取り処理をスケールさせるための定石ですが、運用を開始するにあたって避けて通れないのが「レプリケーション遅延」という課題です。
本記事では、ユーザー体験を損なわずにリードレプリカを活用するための「書き込み後の読み取り一貫性」の担保手法について、具体的な実装とともにお話しします。
課題:レプリケーション遅延がもたらすユーザー体験の低下
プライマリからリードレプリカへの書き込みは非同期で行われます。そのため、コンマ数秒というわずかな時間ですが、プライマリとレプリカの間にデータの差異(ラグ)が生じます。
ワンバンクにおいて、これがどのような問題を引き起こすか考えてみると、以下のようなケースが考えられます。
- ユーザーがプリペイドカードに入金(書き込み)した直後、画面が更新された際に入金前の残高が表示される。
ユーザーからすれば「入金したはずなのにお金が反映されていない!」という大きな不安に繋がります。これを防ぐには、 「自分が書き込んだデータに関しては、反映が完了するまで最新のソース(プライマリ)から読み取る」 という制御が必要になります。
解決策の比較検討
この課題を解決するには、主に3つのアプローチがあります。
1. 同期レプリケーション
プライマリへの書き込み時、全レプリカへの反映完了を待ってからレスポンスを返す方法です。
- メリット:
- 遅延がゼロ。
- デメリット:
- 書き込みのスループットが劇的に低下し、レプリカに不調があるだけでシステム全体が止まるリスクがあります。

2. ユーザーを一時的にプライマリDBに固定する
特定のユーザーが書き込み操作を行った後、一定時間(delay秒数)だけ、そのユーザーの読み取りリクエストを強制的にプライマリに向ける方法です。
Railsでは標準でDatabaseSelectorというものがあり、それによって実現することができます。
- メリット:
- 非同期レプリケーションのままで良いため、書き込みスループットへの影響がない。
- Rails標準で機能が用意されているので実装が楽。
- デメリット:
- ユーザーあたりの書き込み頻度が多い場合だと、ほぼプライマリDBへリクエストが流れてしまう。
- あくまで「自分の書き込み」に対して一貫性を担保するものなので、他のユーザーの最新のデータは見えない。例えば「Aさんが更新し、直後にBさんがそのデータを見る」というケースでは、Bさんはレプリカを見ているため、遅延の影響を受ける可能性がある。

3. レプリカにデータがない場合にプライマリへフォールバック
レプリカへ読み取りに行き、データがなければプライマリへ再試行する方法です。
- メリット:
- 最大限レプリカを利用できるので、プライマリへの読み取り負荷をかなり減らせる。
- 非同期レプリケーションのままで良いため、書き込みスループットへの影響がない。
- デメリット:
- 一覧取得するようなAPIではデータが古いかどうかの判定が難しく、また書き込みが多いサービスでは、ほとんどプライマリへリクエストすることになるので、アプリケーションのレイテンシーが悪化する。

core-apiでは、書き込み頻度がそれなりに多いかつ、Rails標準の機能で実現したかったので、「2. ユーザーをプライマリDBに固定する」方式を採用しました。
アプリケーションコードの実装詳細
1. Rails標準機能の拡張: カスタム Resolver の実装
Rails 6から導入された ActiveRecord::Middleware::DatabaseSelector::Resolver の仕組みを活用しました。通常、Railsは「最終書き込み時刻」をブラウザのCookieに保存して判定を行いますが、ワンバンクはモバイルアプリであるため、ステートレスなAPIサーバーとして振る舞う必要があり、Cookieに頼ることができません。
そこで、以下のような 書き込み時刻をキャッシュストアで管理するカスタムResolver を実装しました。
class UserWriteTimestampResolver CACHE_KEY_PREFIX = 'user_last_write_timestamp:' # 書き込みタイムスタンプの有効期限(1日) TIMESTAMP_TTL_SECONDS = 86_400 def self.call(request) new(request) end def initialize(request) @request = request end def last_write_timestamp key = cache_key # user_idが取得できない場合は、安全のため primary DB を使用するため現在時刻を返す # 以下のリンク先の read_from_primary? メソッドの返り値をtrueにするため # reference: https://github.com/rails/rails/blob/a9ee16f0b94fe53bd3b715aa4e6e3f63e69a215a/activerecord/lib/active_record/middleware/database_selector/resolver.rb#L35-L41 return Time.current unless key timestamp = Rails.cache.read(key) timestamp ? Time.zone.parse(timestamp) : Time.zone.at(0) rescue Redis::BaseConnectionError, Redis::TimeoutError => e # Redisに関するエラー時はfallbackしてprimary DBに接続する Rails.logger.warn("Cache connection error in UserWriteTimestampResolver: #{e.message}") Time.current end def update_last_write_timestamp key = cache_key # user_idが取得できない場合は記録しない return unless key Rails.cache.write(key, Time.current.iso8601(6), expires_in: TIMESTAMP_TTL_SECONDS) rescue Redis::BaseConnectionError, Redis::TimeoutError => e # Redisに関するエラー時はfallbackする。 Rails.logger.warn("Cache connection error in UserWriteTimestampResolver: #{e.message}") end def save(response) end private def cache_key return nil unless user_id "#{CACHE_KEY_PREFIX}#{user_id}" end def user_id @user_id ||= extract_user_id end # user_idを抽出する処理 def extract_user_id # 省略 end end
2. リードレプリカを段階的に移行する
いきなり全てのエンドポイントをレプリカに向けると、内部で書き込みが発生する GET リクエストで ActiveRecord::ReadOnlyError が発生する恐れがあります。
そこで、いきなり全てをレプリカに向けるのではなく、「安全性が確認できたパスから段階的に適用する」というアプローチを取りました。
具体的には、以下のような DatabasePrimaryRoleSelector モジュールを実装し、特定のControllerのみがリードレプリカを利用するように制御しました。
module DatabasePrimaryRoleSelector extend ActiveSupport::Concern # リードレプリカの利用を許可するパスを定義 READING_ROLE_CONTROLLER_PATHS = [ '/api/hoge', '/api/huga' ].freeze included do around_action :set_primary_database_role end private def set_primary_database_role(&block) # ホワイトリストに含まれないパス、または書き込みリクエストの場合はPrimaryに向ける if !reading_request? || reading_role_controller? return yield end # 明示的にwritingロールを指定してブロックを実行する ActiveRecord::Base.connected_to(role: :writing, prevent_writes: false, &block) end def reading_role_controller? # controller_path がホワイトリストに含まれているか判定 READING_ROLE_CONTROLLER_PATHS.include?(self.class.controller_path) end def reading_request? request.get? || request.head? end end
Delay秒数の決定と監視
Delay秒数の算出
core-apiでは Amazon Aurora MySQL を使用しています。DB クラスター内のすべての Aurora DB インスタンスは共通のデータボリュームを共有しているため、レプリケーション遅延は極めて低いですが、それでも多少のレプリケーション遅延は存在します。
今回、レプリケーション遅延が無視できる一部のAPI (e.g. 管理画面用のAPI)において、先にリードレプリカへ読み取り時にコネクションを向ける実装がされていました。
この先行導入していたAPIのメトリクスを使い、CloudWatch上で過去数ヶ月にわたる AuroraReplicaLag の推移を確認しました。
確認したところ、 AuroraReplicaLag の値が最大で50ms ~ 100ms程度だったので、delay秒数を 0.5s にしました。
SREチームと連携した監視
設定したdelay秒数が適切かどうかは、常に監視する必要があります。
もし大幅なレプリケーション遅延が発生し、設定したdelay秒数を超えてしまうと、ユーザーに古いデータを見せてしまうことになります。
そこでSREチームと協力し、以下の監視を導入しました。
具体的には、 AuroraReplicaLag が delay秒数(= 0.5s) × 0.8 = 0.4s の値を超えた場合にSlack通知するようにしました。
まとめ
リードレプリカの導入は単なるインフラ構成の変更ではなく、アプリケーション側での「一貫性の設計」とセットで考えるべき課題です。
今回、Railsの標準機能をモバイルアプリ向けにカスタマイズし、キャッシュストアによる書き込み追跡と監視を組み合わせることで、スケーラビリティと信頼性を両立させることができました。
リードレプリカ導入を検討されている方へ少しでも参考になれば幸いです。
明日のエンジニア駅伝の第十区走者は、 toshimaru さんです!
参考資料
*1:https://arpitbhayani.me/blogs/read-your-write-consistency/ #Synchronous Replicationから引用
*2:https://arpitbhayani.me/blogs/read-your-write-consistency/ #Pinning User to Masterから引用
*3:https://arpitbhayani.me/blogs/read-your-write-consistency/ #Master Fallbackから引用