🐕

増え続けるユーザーを支えるためのRails開発について

2024/12/17に公開

MICIN のオンライン医療事業部のエンジニア Shuda です。
この記事は MICIN Advent Calendar 2024 の 17 日目の記事です。
前回は tatsukoizumi さんの、Webフロントエンドを長期運用するためにでした。

はじめに

こんにちは、MICIN のオンライン医療事業部のエンジニア Shuda です。
弊社が提供するオンライン診療アプリ「クロン」のサーバーサイドは、9年目を迎え、当初から Ruby on Rails を使用して運用しています。
一方でサービスの成長とともに、ユーザー数やデータ量の増加が進み、パフォーマンスに関する課題が次第に顕在化してきました。
今年は特にその観点と向き合う機会が多かったため、この点について記事にまとめることにしました。

この記事では、実際に気をつけているポイントを順を追って解説していきます。

チームでの取り組み

まずはチームの取り組みの紹介です。

GraphQLの導入

APIレスポンスを効率化するためには、必要なデータのみを返すことが重要です。
従来のREST APIでは、クライアントに不要なデータが含まれることがあり、これがパフォーマンス低下の原因となることがあります。

弊社では、新規開発においてGraphQLを採用しています。GraphQLのメリットは、クライアント側が必要なデータだけを選択して取得できることです。
これにより不要なデータの転送を避け、ネットワークの負担を軽減できます。
それに加えて生産性向上も期待し、昨年から取り組んでいる「クロンお薬サポート」のフルリニューアルでは、サーバーサイドの多くの部分がGraphQLに移行しております。

ただし、GraphQLでは基本的にPOSTメソッドを使用するため、キャッシュの利用やリクエストの効率化が難しくなることがあります。
これにより、今後システムが大規模に成長した際には、パフォーマンスに悪影響を与える可能性があるため、今後の課題として認識しておく必要があります。

フロントエンドでのキャッシュ活用

本題であるRailsとは逸れますが、サーバーへのリクエストを最小限に抑えるため、弊社ではフロントエンドでのキャッシュ戦略も積極的に採用しています。
具体的には、Apollo Client や urql を活用し、クライアントサイドで複数回同じデータを再取得することを防いでいます。
このキャッシュ機能により、リクエストの頻度が大幅に削減され、サーバーへの負荷が軽減されるため、スケーラビリティの面でも効果的です。
サービスが大規模になってもこのアプローチを維持できるよう、慎重に設計しています。

RDB の VIEW および MATERIALIZED VIEW の導入

来期は、VIEW および MATERIALIZED VIEW を活用したアプローチを検討しています。

VIEW は、効率的なクエリを事前にデータベース内で定義することで、ActiveRecord による意図しない非効率なクエリを排除し、さらに実装コードをシンプルに保つことにも繋がります。

MATERIALIZED VIEW は、通常のビューと異なり、計算結果をディスクに保存するため、次回以降のクエリ実行時には事前に計算されたデータを再利用できます。
この仕組みにより、クエリのパフォーマンスが大幅に向上します。特に、大量データを扱うシステムでは、MATERIALIZED VIEW の活用が非常に有効です。

Railsでは、scenicというgemを利用して、VIEW や MATERIALIZED VIEW の導入を進めていく予定です。
これにより、ビューの作成、変更、データベースのマイグレーションをシンプルに管理し、スケーラビリティの向上を実現します。

使い所は丁寧に検討する必要はありそうですが、来期のチャレンジテーマの一つとして考えています。

個人で気を付けるべきこと

基本的には ActiveRecord の最適化です。
どの規模のレコードに対し、どのくらいの頻度で、どのようなことをするのかを常に意識しています。

N+1 クエリの解消

Railsアプリケーションを初期段階で構築する際、N+1クエリ問題をそのままにしているコードがいくつか残っていました。
基本的なことですがユーザー数やデータ量が増加する中で、N+1クエリの影響は避けられません。
この問題を防ぐため、関連するモデルを取得する際に、不要なクエリを発行しないよう意識しています。

preloadeager_load を使い分け、効率的にデータをロードするようにしています。
個人的にはリスクが少ないpreloadを優先して使っており、どうしても結合する必要がある時にeager_loadをと言うシンプルな使い分けをしています。

preload: 関連するデータを最初に別のクエリで取得
eager_load: SQLのJOINを使用して、関連データを一つのクエリで取得

# N+1問題が発生する例
posts = Post.all
posts.each do |post|
  render json: post.author.name
end

# N+1問題を回避するために
posts = Post.preload(:author).all
render json: posts.as_json(include: :author)

実際は意識をしても見逃してしまうため、Bulletを導入し開発中に検知しています。
また、GraphQLに移行したAPIはGraphQL::Batchを利用してN+1を回避しています。

必要なデータだけを取得

APIのレスポンス速度を改善するため、不要なデータを取得しないことが重要です。
select メソッドを使って必要なカラムだけを選択することで取得するデータ量を削減し、パフォーマンス向上に繋がります。

# 不要なカラムを含む全レコードを取得
posts = Post.all

# 必要なカラムだけを選択して取得
posts = Post.select(:id, :title, :created_at).all

一方でこの方法はコードの複雑さが増す可能性があるため、実際に使う場面は慎重に決めています。
しかしデータ量が多くなる場面や、レスポンス速度が重要な場面では使うべきですが、現状そこまでのケースには遭遇していません。

バッチ処理

大量のデータを扱う場合、1件ずつ処理するのではなく、バッチ処理を使用することが大切です。
Rails 6以降では、insert_all!upsert_all! メソッドを使って、一度に複数レコードを挿入・更新できます。
この手法をデータ移行や一括更新時に使用することで、パフォーマンスを大幅に向上させます。

例えば、新しいユーザー情報を一括登録する場合:

# 一括挿入
users = [
  { name: 'Alice', ... },
  { name: 'Bob', ... }
]

User.insert_all!(users)

実際は例のようにユーザーを一括で入れることはありませんが、定時に実行されるデータの一括反映や大規模なデータマイグレーションでは重宝しています。
より柔軟かつ安全に扱いたい場合は、Activerecord-Importといったgemを利用がおすすめです。

非同期処理とバックグラウンドジョブ

時間のかかる処理を非同期で実行することは、APIパフォーマンス向上に欠かせません。
特に、即時レスポンスが求められる場面では、時間のかかる処理(例えば、メール送信や重い計算など)をバックグラウンドで実行するようにしています。

弊社ではDelayed::Jobを利用して、非同期処理を効率的に管理しています。例えば、社内からユーザーへの一斉通知などの処理をバックグラウンドジョブで行っています。

最後に

以上のことは一度きりの改善ではなく、継続的な取り組みが必要です。
個人的に意識しているのは、データアクセスの最適化、APIレスポンスの効率化、そして時間のかかる処理を非同期で実行することです。
これらを意識しながら実装を進めることでサービスのパフォーマンスを保ちながら、より良いユーザー体験を提供できると考えています。

今後もサービスの成長に合わせて自身の視野を広げ、さらなる最適化に取り組んでいきたいと思います。


MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!
https://recruit.micin.jp/

株式会社MICIN

Discussion