Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

Rails製の上場企業向けの会計プロダクトで、リーダー/ライター分離によるDB負荷分散を実装しました

こんにちは、マネーフォワード クラウド会計Plus(以下会計Plus)のSREチームのリーダーの段松侑太(@ydammatsu)です。

会計PlusにおいてDBのリーダー/ライター分離を導入した際の実装方針・手順・成果を紹介します。

本記事で扱う内容

  • 実装方針と技術選定(Rails複数DB機能の採用理由)
  • 実際に適用するまでの手順(GraphQL、書き込みSQLの検知と除外、レプリケーションラグ、Sidekiq)
  • 実際の成果(Before/AfterのCPU使用率)

想定読者

  • Rails製アプリケーションでDB負荷に課題を感じている方
  • リーダー/ライター分離を検討しているが、既存システムへの適用に不安がある方
  • Aurora MySQLまたはMySQLレプリケーションを使用している方

背景

会計Plusは、IPO準備・中堅〜上場企業向けの会計処理を支えるプロダクトとして、年間を通じて膨大な仕訳データを扱っています。

会計PlusはデータベースとしてAurora MySQLを利用しています。全体の仕訳数は数億件に達し、1事業者あたりの仕訳数も増加し続けています。

このような大規模なデータを安定して処理するには、信頼性と可用性の高いデータベース基盤の構築・運用が不可欠です

会計システムでは、迅速かつ正確に会計処理を行うため、

  • 正確な書き込み
  • 高速な読み込み

の両立が求められます。

DBのわずかな遅延や負荷増加が、画面操作のレスポンス、さらにはサービスの信頼性そのものに影響を及ぼします。

このため、SREチームでは以前から「DB負荷の最適化」と「安定性の向上」を重要なテーマとして扱ってきました。

なぜ今、リーダー/ライター分離に取り組んだのか

これまでは、数年分の技術的負債が蓄積された既存コードベースへのリーダーライター分離ロジックの実装が困難であったこと、レプリケーションラグへの対応方針が確立できていなかったことから、実施を見送ってきました。当時のレコード数では、まだ負荷に耐えられる状態でした。

しかし、プロダクトの成長に伴ってデータ量が急増し、ライターインスタンスの負荷が今後のボトルネックになり得るフェーズに突入したため、本格的に取り組むことを決めました。

以下では、実装におけるポイントや工夫した点、実際にリリースして得られた効果を紹介します。

実装方針と技術選定

Webリクエストが負荷の大半を占めるため、ここへの効率的な分散が必要でした。

ただし、SELECTクエリをリーダー、INSERT・UPDATE・DELETEをライターに単純に振り分けるだけでは、Aurora MySQLのレプリケーションラグが非常に小さいとはいえ 1 、リクエスト内でデータの不整合が発生してしまいます。そのため、読み込みのみを行うエンドポイントだけをリーダーに接続するロジックを実装しました。

しかし、全体で数百のエンドポイントが存在するため、個別に判定するロジックを実装するのは現実的ではありません。そこで、以下のルールでアクセスを振り分けることにしました。

  • REST APIのGETはリーダー、GET以外(POST、DELETEなど)はライター
  • GraphQL APIのQueryはリーダー、Mutationはライター

技術選定については、Rails 6.0から利用可能な複数DB機能を採用しました。この機能には自動ロール切り替えが含まれており、GETならリーダー、それ以外ならライターに接続する仕組みがすでに備わっています。Rails組み込みの機能でメンテナンスも継続されているため、これを使うことにしました。

しかし、Railsの複数DB機能を利用するだけでは以下の点が解消されないので、対応する必要がありました。これらについては、次の項目で見ていきます。

  • GraphQLへの対応
  • GETやGraphQLのQueryに書き込みSQLが混入していた際の対応
  • レプリケーションラグへの対応
  • Sidekiqへの対応

実際に適用するまでの手順

GraphQLへの対応

現在、会計PlusはバックエンドAPIをGraphQLに移行中です。GraphQLではすべてのリクエストがPOSTメソッドで送信されるため、HTTPメソッドだけではDB接続を切り替えられません。

しかし、複数DB機能の自動ロールスイッチ部分はカスタマイズ可能な設計になっています。

そこで、リクエストの中身を確認し、GraphQLかどうか、mutationかどうかを判定するロジックを実装しました。具体的には、ActiveRecord::Middleware::DatabaseSelector::Resolver を継承した CustomDatabaseResolver を実装することで、GraphQLにも対応しました。

class CustomDatabaseResolver < ActiveRecord::Middleware::DatabaseSelector::Resolver
  def reading_request?(request)
    graphql_query?(request) || super
  end

  private

  def graphql_query?(request)
    return false unless request.path.start_with?('/graphql') && request.post?

    graphql_body = request.body.read

    request.body.rewind

    graphql_body.downcase.exclude?('mutation')
  end
end

GETやGraphQLのQueryに書き込みSQLが混入していた際の対応

GETリクエストでは理想的にはDB書き込みが発生しませんが、会計Plusは長年運用されているプロダクトのため、そうなっていないコードも存在します。書き込みが発生すると ActiveRecord::ReadOnly エラーが発生するため、リリース前に検知して対応する必要がありました。

そこで、ActiveSupport::Notifications を使ってRailsが発行するSQLを監視し、書き込みSQLを検知するミドルウェアを実装しました。本番環境で1ヶ月ほど運用し、検出されたエンドポイントはログに出力されるようにしました。

WRITE_SQL_PATTERN = /\A\s*(INSERT|UPDATE|DELETE|REPLACE|TRUNCATE)\b/i

ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, _start, _finish, _id, payload|
  next unless RequestStore.store[:current_request_is_reading]

  sql = payload[:sql]
  next if sql.start_with?("SAVEPOINT", "RELEASE SAVEPOINT", "ROLLBACK TO SAVEPOINT")

  if sql.match?(WRITE_SQL_PATTERN)
    Rails.logger.warn({
      title: "WRITE DETECTED",
      request: RequestStore.store[:current_request],
      sql: sql.split[0, 3].join(" "), # Log only the first 3 words of the SQL to avoid logging full sensitive query details
    })
  end
end

class MiddlewareForWriteQueryDetection
  def initialize(app)
    @app = app
    @resolver = CustomDatabaseResolver.new({})
  end

  def call(env)
    request = ActionDispatch::Request.new(env)
    RequestStore.store[:current_request] = "#{request.method} #{request.path}"
    RequestStore.store[:current_request_is_reading] = @resolver.reading_request?(request)
    @app.call(env)
  ensure
    RequestStore.store[:current_request_is_reading] = nil
  end
end

Rails.application.config.middleware.insert_before 0, MiddlewareForWriteQueryDetection

検知されたパスは、CustomDatabaseResolver をカスタマイズして除外リストに追加することで、ActiveRecord::ReadOnly エラーを回避しました(時間ができれば、GETリクエスト内の書き込み処理をリファクタリングして改善したいと考えています)。

class CustomDatabaseResolver < ActiveRecord::Middleware::DatabaseSelector::Resolver
  READING_REQUEST_EXCEPTION_PATHS = [
    '/example/path', # ← 除外パスはここに追加
  ].freeze

  def reading_request?(request)
    return false if reading_request_exception_path?(request.path)

    graphql_query?(request) || super
  end

  private

  def reading_request_exception_path?(path)
    READING_REQUEST_EXCEPTION_PATHS.any? { path.start_with?(_1) }
  end

  def graphql_query?(request)
    return false unless request.path.start_with?('/graphql') && request.post?

    graphql_body = request.body.read

    request.body.rewind

    graphql_body.downcase.exclude?('mutation')
  end
end

レプリケーションラグへの対応

エンドポイントごとにリーダーとライターを指定しても、更新APIから読み込みAPIへ短時間で遷移した場合、レプリケーションラグの影響を受ける可能性があります。これに対しては、Railsの複数DB機能が提供する「同一ユーザーのセッションであれば一定時間ライターに接続する」機能を利用しました。

config.active_record.database_selector = { delay: 2.seconds }

ただし、公開APIや社内プロダクト間のAPIにはセッションが存在しないため、この機能は適用せず、上記の CustomDatabaseResolver の除外パスに含めることで対応しました。今後、同一事業者IDなどを条件にすることで、これらのAPIでもリーダーライターへの負荷分散が可能になると考えています。

Sidekiqへの対応

会計Plusでは、非同期ジョブの実行にSidekiqを利用しています。

今回の対応ではWebリクエストの負荷分散が主な目的でしたが、特定のテーブルに対して全件の整合性チェックなどの読み取りのみを行うジョブも存在しています。これらの読み取り専用ジョブについても、リーダーインスタンスを利用できるように実装を行いました。around_perform を活用してこの処理を共通化したので、以下にその例を紹介します。

class ApplicationReadOnlyJob < ApplicationJob
  around_perform do |_job, block|
    ActiveRecord::Base.connected_to(role: :reading, prevent_writes: true) do
      block.call
    end
  end
end

成果

Before(リーダー/ライター分離前)

before.png

濃い紺色の線がライターインスタンスで、それ以外の薄い線2本が2台のリーダーインスタンスです。

リーダー/ライター分離前は、一部の帳票作成用サービスはすでにリーダーにアクセスを流していましたが、それ以外のすべてのアクセスは単一のライターインスタンスに集中していました。

特に日中のピーク時間帯(10:00〜16:00)になると、CPUの使用率が平均で45〜70%まで上昇し、瞬間的には90%近くに達することもありました。このような高負荷状態が続くと、画面の応答が遅くなったり、処理が不安定になったりする懸念がありました。実際、ピーク時間帯にはレスポンスにばらつきが見られ、システムの処理能力に余裕が少なくなっている状態でした。

After(リーダー/ライター分離後)

after.png

リーダー/ライター分離を導入したことで、Webリクエストの多くをリーダーインスタンスへ効率的に振り分けられるようになりました。その結果、従来ライターインスタンスに集中していた負荷が大幅に削減され、CPU使用率も平均20〜35%と大きく低下しました。特に、これまで頻発していた瞬間的な負荷のスパイクもほぼ解消され、日中のピークタイムであっても安定した稼働を維持できています。

リーダーインスタンスも2台で10〜30%程度の負荷に収まっており、余裕がある状態です。今後さらに負荷が増大した場合も、リーダーインスタンスのスケールアウトで対応可能です。

まとめ

リーダー/ライター分離によるデータベース負荷分散は一般的な手法ですが、理想的にはアプリケーション設計の初期段階で導入することが望ましいです。しかし、すでに稼働している大規模システムに後から適用する場合、想定以上の労力や調整が必要となるケースも少なくありません。本記事の内容が、同様の課題に取り組んでいる方々の参考になれば幸いです。

会計PlusのSREチームは、インフラやモニタリングの改善、SLI/SLOの策定・実装だけでなく、今回ご紹介したようなアプリケーション側での施策も積極的に推進し、プロダクト全体の信頼性向上に継続的に取り組んでいます。


  1. CloudWatchで計測したところ、20〜30ms程度のレプリケーションラグがありました。