dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

既存のRails 7アプリケーションにread/writeを分ける仕組みを導入でdatabaseの負荷分散

こんにちは、クラシルリワードのサーバーサイドエンジニアのhaindです。 この記事では、クラシルリワードのdatabase負荷を分散するために、既存のRails 7アプリケーションにdatabaseのread/writeを分ける仕組みを導入した事例についてお話ししたいと思います。

現状と課題

クラシルリワードのサーバーサイドではRails 7を使っており、MySQLをdatabaseとして採用しています。初期段階から、replica(reader)とprimary(writer)のインスタンスが存在していましたが、アプリケーションはprimaryにのみ向けられていました。replicaインスタンスは障害発生時のフェールオーバー用に設けられています。

クラシルリワードアプリの速い成長に伴い、databaseへのトラフィックも早く増えています。ただ、databaseのprimaryインスタンスだけがクエリを処理するため、負荷が高いです。 primaryインスタンスのCPU使用率が特定の閾値を超えると、インスタンスをスケールアップする必要がありますが、この作業にはダウンタイムが伴うため、深夜にメンテナンスを行うことにしています。

それに加えて、databaseのreplicaが存在するにもかかわらず、それをクエリに活用できないのはもったいないです。クエリの増加に対して、replicaに負荷の半分を分散することで、本番のスケールアップをスキップし、コストを削減できます。負荷分散によって処理速度も向上できます。

技術選定

上記課題を解決するためにRails 7アプリケーションにread/writeを分ける仕組みの導入が検討されました。 調査した方法は以下の2つです。

  1. Gemを利用する
  2. Rails 7の複数database機能を利用する

有名なgemとしてoctopusmakaraがあります。この2つのgemの詳細な比較についてこの記事が参考できます。 特にgemのreader/writerの自動切り替え機能に注目しています。

要するに、これらのgemは発行されたSQLクエリをもとに、適切なインスタンスにクエリを送信してくれます。

User.last

# 裏側で以下のquery文が発行されます
# SELECT `users`.* FROM `users` ORDER BY `users`.`id` DESC LIMIT 1

select クエリの場合はreplicaに送信され、それ以外(createupdateなど)はprimaryに送信されます。

makara

What goes where?

In general: Any SELECT statements will execute against your replica(s), anything else will go to the primary.

octopus

Replication

When using replication, all writes queries will be sent to master, and read queries to slaves.

(replica遅延問題に対して、テーブルに書き込んだ直後に、SELECTクエリを実行する場合は、それをprimaryで行うように指定できます)

この機能は便利ですが、残念ながらこれらのgemは直近数年間メンテナンスされていないため、Rails 7での動作がうまくいきませんでした。発行されたqueryを解析できるように、gemはRailsの内部処理に介入しているようです。つまりRailsのソースコードに依存しています。 Railsの新しいバージョンでは、ソースコードの変更によって正しく機能しません。

次に、Rails 7の複数database機能の特徴を調べてみましょう。(詳細は こちら) この機能ではreader/writerの自動切り替えと手動切り替えが可能です。

自動切り替えの仕組みは、到着したリクエストのHTTPメソッド(GET、POST、PATCH、DELETEなど)を基に、接続を切り替えます。

アプリケーションがPOST、PUT、DELETE、PATCHのいずれかのリクエストを受け取ると、自動的にwriterデータベースに書き込みます。リクエストがそれ以外のメソッドであっても、直近の書き込みがあった場合にはやはりwriterデータベースが利用されます。それ以外のリクエストではreplicaデータベースを使います。

手動で切り替えたい場合は特定の処理のブロックを以下で囲みます。

ActiveRecord::Base.connected_to(role: :reading) do
  # このブロック内のコードはすべてreadingロールで接続される
end

選定の要件

  • メンバーの手が空いている時間に実施できる(数週間にわたる集中的な対応は難しいため)
  • 変更箇所を素早くテストし、品質を確保できる

それを踏まえて、Railsの複数database機能の手動切り替えを選択しました(readerに送信したいGET APIを手動でreaderを指定します、それ以外はデフォルトでwriterに向けます)。自動切り替えが望ましいですが、クラシルリワードのGET APIの一部はdatabaseを更新しています。APIが多いため、修正が必要なAPIを見つけ出すには時間がかかります。また、動作を確認する段階も時間がかかる見込みです。そのため、自動切り替えは適していないと判断しました。

実際に導入

以下の手順で導入を進めました

Railsアプリケーションでreader/writerの設定

Rails guideのようにdatabase.ymlを変更しました。クラシルリワードの場合、primary、replicaのendpointが違うため、それぞれのhostも指定しました。 次にmigrationコマンドを実行すると自動的にdb/primary_replica_schema.rbが生成されます(内容はschema.rbと同じです)。 この変更をlocalと開発用サーバーで動作確認し、問題がないことを確認した後、本番環境にリリースしました。

リクエスト数が多いGET APIを洗い出す

Railsの複数database機能の手動切り替えの場合、クエリはデフォルトでprimaryに送信されます。replicaに送信したいGET APIを洗い出して、それらを優先して対応します。

対象APIのクエリをreplicaに送信する対応

対象APIを1つずつ実装、動作確認、リリースします。 実装は単にこれを追加するだけでした。

ActiveRecord::Base.connected_to(role: :reading, prevent_writes: true) do
  # このブロック内のコードはすべてreadingロールで接続される
end

prevent_writes: trueはreplicaへの書き込みを防ぐことを意味します。ブロック内に書き込みを行った場合、実行時にエラーが発生します。GET APIをreplicaに向けるようにしていますが、後で他のメンバーがAPIを修正する際、更新処理を追加したらエラーが発生して、replicaに書き込まないようにしています。replicaへの書き込みはprimaryと同期されないため、ブロック内で書き込みを行う場合は明示的にprimaryを指定する必要があります。

注意:ActiveRecord::Base.connected_toブロックを抜けると、クエリが実行される点に留意してください。同じ処理は同一のブロック内にまとめると良いと思います。(詳細はこちら

導入の効果

修正対象のAPIの一部を修正したところ、primaryのCPU使用率が最大20%減少しました。今後はさらにreplicaを効果的に活用して、パフォーマンスとコストを改善していきたいと考えています。

まとめ

クラシルリワードでRails 7の既存のアプリケーションにread/writeを分ける仕組みを導入する方法を紹介しました。皆さんの参考になれば幸いです!