inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

リードレプリカにおける「書き込み後の読み取り一貫性」を担保する 〜 ワンバンクでの実践 〜

スマートバンク 新春エンジニア駅伝 2026第九区走者のサーバーサイド部の occhi です。

最近、私たちのシステムの心臓部である本体アプリケーション「core-api」のデータベース負荷を分散するため、リードレプリカを導入しました。

プライマリ(書き込み用)とリードレプリカ(読み取り用)を分ける構成は、読み取り処理をスケールさせるための定石ですが、運用を開始するにあたって避けて通れないのが「レプリケーション遅延」という課題です。

本記事では、ユーザー体験を損なわずにリードレプリカを活用するための「書き込み後の読み取り一貫性」の担保手法について、具体的な実装とともにお話しします。

課題:レプリケーション遅延がもたらすユーザー体験の低下

プライマリからリードレプリカへの書き込みは非同期で行われます。そのため、コンマ数秒というわずかな時間ですが、プライマリとレプリカの間にデータの差異(ラグ)が生じます。

ワンバンクにおいて、これがどのような問題を引き起こすか考えてみると、以下のようなケースが考えられます。

  • ユーザーがプリペイドカードに入金(書き込み)した直後、画面が更新された際に入金前の残高が表示される。

ユーザーからすれば「入金したはずなのにお金が反映されていない!」という大きな不安に繋がります。これを防ぐには、 「自分が書き込んだデータに関しては、反映が完了するまで最新のソース(プライマリ)から読み取る」 という制御が必要になります。

解決策の比較検討

この課題を解決するには、主に3つのアプローチがあります。

1. 同期レプリケーション

プライマリへの書き込み時、全レプリカへの反映完了を待ってからレスポンスを返す方法です。

  • メリット:
    • 遅延がゼロ。
  • デメリット:
    • 書き込みのスループットが劇的に低下し、レプリカに不調があるだけでシステム全体が止まるリスクがあります。

同期レプリケーションのフロー*1

2. ユーザーを一時的にプライマリDBに固定する

特定のユーザーが書き込み操作を行った後、一定時間(delay秒数)だけ、そのユーザーの読み取りリクエストを強制的にプライマリに向ける方法です。

Railsでは標準でDatabaseSelectorというものがあり、それによって実現することができます。

  • メリット:
    • 非同期レプリケーションのままで良いため、書き込みスループットへの影響がない。
    • Rails標準で機能が用意されているので実装が楽。
  • デメリット:
    • ユーザーあたりの書き込み頻度が多い場合だと、ほぼプライマリDBへリクエストが流れてしまう。
    • あくまで「自分の書き込み」に対して一貫性を担保するものなので、他のユーザーの最新のデータは見えない。例えば「Aさんが更新し、直後にBさんがそのデータを見る」というケースでは、Bさんはレプリカを見ているため、遅延の影響を受ける可能性がある。

ユーザーを一時的にプライマリDBに固定するフロー*2

3. レプリカにデータがない場合にプライマリへフォールバック

レプリカへ読み取りに行き、データがなければプライマリへ再試行する方法です。

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

レプリカにデータがない場合にプライマリへフォールバックするフロー*3

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チームと協力し、以下の監視を導入しました。

具体的には、 AuroraReplicaLagdelay秒数(= 0.5s) × 0.8 = 0.4s の値を超えた場合にSlack通知するようにしました。


まとめ

リードレプリカの導入は単なるインフラ構成の変更ではなく、アプリケーション側での「一貫性の設計」とセットで考えるべき課題です。

今回、Railsの標準機能をモバイルアプリ向けにカスタマイズし、キャッシュストアによる書き込み追跡と監視を組み合わせることで、スケーラビリティと信頼性を両立させることができました。

リードレプリカ導入を検討されている方へ少しでも参考になれば幸いです。

明日のエンジニア駅伝の第十区走者は、 toshimaru さんです!


参考資料

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.