ブログ

【Ruby on Rails】ActiveRecordがどんなSQL文を吐くか意識せずに実装することの怖さを「N+1問題」で解説する

サイバーウェーブでエンジニアをしている太田と申します。主にアプリケーションの構築、サーバ設定、お客様からのお問い合わせ対応を担当しています。

今回のテーマ

RailsでWebアプリケーションを作っているとよく遭遇する「N+1問題」とその対策について解説します。この問題についてはすでにたくさんの方が解説されていて、ネットで検索すると多くの記事がヒットするかと思います。ただ、Railsのようなフレームワークでアプリを作っていると、「N+1問題」の何が具体的に問題なのかわからないといった方も多いのではないでしょうか。

今回はサイバーウェーブで使われているコードを交え、「N+1問題」が稼働中のシステムでどういった障害となるかという観点で解説いたします。

「N+1問題」とは

「N+1問題」とは何か、かんたんに復習しておきます。

「N+1問題」とは、例えば会員制のサイトがあって、「会員」と所属する「組織」が別レコードであるとします。そこで組織の詳細で、所属する会員の名前も一覧表示させる画面を作ろうとしています。

組織A
会員1、 会員2、 会員3….

といった具合です。

Rails(ActiveRecord)のようなO/Rマッパーを使っていると、組織に属する会員の情報を取得する際、「会員ごと」にクエリを発行してしまいがちです。これが会員数(N)+対象の組織(1)となり、N+1分のクエリが発行されるため、「N+1問題」と呼ばれるゆえんとなっています。

具体的なコードは後述するのですが、できる限りクエリ数を抑えて、データベースとの通信を少なくすると、よりパフォーマンスのあるアプリケーションになります。

前提となるモデル

前提として、下記のようなモデルがあるとします。

  • アプリケーションの会員を表すUserモデル
  • 会員が所属する組織を表すOrganizationモデル
  • 会員と組織の多対多の所属を表現するOrganizationUserモデル
  • Organizationにはkindというカラムがあり、一般公開はpublic、限定公開はprivateという値が入っています
  • この時、Userモデルにはhas_many リレーションが書かれています
has_many :organizations, through: :organization_users

よくあるコードの例

上記のようなモデルがある時に「限定公開の組織に入っている会員の一覧を作りたい」という実装をするには、どのようなRailsのコードを書けば良いでしょうか。

下記は良くないコードの例です。

user.rb:

User.select do |user|
  user.organizations.exists?(kind: :private)
end

このコードはselectメソッドを使って全ての会員から関連する組織を参照し、組織の中に限定公開のものがあればその会員をselectの戻り値に含めています。これは実行したとき意図した通りに動作するため、一見すると正しいコードのように思えます。

しかし、会員の数が少ないうちは良いですが、会員の分だけSELECT文が発行されてしまうため、会員の数が増えるにつれ処理の回数が膨大になっていきます。典型的なN+1問題といえます。

JOINを使ったコードの例

上記のコードをjoinsメソッドを使って書き直してみます。joinsメソッドとは 複数のテーブルを結合するメソッドです。

user.rb:

User.joins(organization_users: :organization).where(organizations: {kind: :private}).distinct

joinsによって、結合と検索が同時にできるようになりました。多対多の結合は必然的に3モデルの結合になるのでやや難しく見えますが、joinの対象は良くないコードの時と変わっていません。

ポイントは、selectmapeachといった、rubyのループ系メソッドを使わずにコーディングすることです。ループ系メソッドを使うと内部的に会員の分だけSELECT文が発行され、結局、「N+1」問題が発生してしまうからです。

また、このコードの場合は同じ会員が複数の組織に所属していた際に重複する場合があるため、distinctを入れています。

クエリがどのように変化するか?

RailsのログからActiveRecord経由でどのようなSQLクエリが生成されるかを見ることができます。下記は良くない例のクエリログです。

User Load (0.5ms) SELECT `users`.* FROM `users`

Organization Exists (0.4ms) SELECT 1 AS one FROM `organizations` INNER JOIN `organization_users` ON `organizations`.`id` = `organization_users`.`organization_id` WHERE `organization_users`.`user_id` = 1 AND `organizations`.`kind` = 'private' LIMIT 1

Organization Exists (0.3ms) SELECT 1 AS one FROM `organizations` INNER JOIN `organization_users` ON `organizations`.`id` = `organization_users`.`organization_id` WHERE `organization_users`.`user_id` = 2 AND `organizations`.`kind` = 'private' LIMIT 1

...

まずusersがSELECTによって取得され、会員レコードの数だけクエリが実行されます

これがN(会員レコードに対するクエリ)+1(会員数取得のクエリ)となり、Nが増えるとその分実行量が増えることがわかると思います。

下記はjoinsを使った例のクエリログです。

User Load (0.9ms) SELECT DISTINCT `users`.* FROM `users` INNER JOIN `organization_users` ON `organization_users`.`user_id` = `users`.`id` INNER JOIN `organizations` ON `organizations`.`id` = `organization_users`.`organization_id` WHERE `organizations`.`kind` = 'private'

1度のクエリで目的の会員が取得できていることがわかります。

まとめ

2つのコードを見比べてみると、ループを使う例に比べるとjoin対象、検索対象のカラムを強く意識しなくてはならず大変だったり、そもそもそんなたくさんのレコードを扱わないアプリケーションの場合、別にループでも良いのではないかと思ってしまうかもしれません。

ですが、実際にシステムを運用し始めると、「挙動が遅い」という話になった時「あそこがN+1でボトルネックになっているのでは、、」と問題になることもあります。

ActiveRecordを使っていると、どんなSQL文が発行されているかを意識しないでも実装ができてしまいます。しかし、単にパフォーマンス的にOKというだけではなく、コードの品質を保つという意味でもSQLの機能を正確に使えるようになると良いかと思います。

サイバーウェーブでは一緒に働く仲間を募集しています

サイバーウェーブでは一緒に働く仲間を募集しています。当社は創業20年を機に「第2の創業期」として、事業を拡大方針へと舵を切りました。会社が急拡大しており、若いメンバーやインターン生がどんどん入社しています。個人の成長は、勢いのある環境のなかでこそ加速されるものです。成長事業に参画できるチャンスです!

サイバーウェーブはコード1行1行に対してこだわりを持って、プロ意識をもったエンジニアを育てている、技術力に自信のあるシステム開発会社です。社内には、創業23年のノウハウの詰まった研修コンテンツや、安定したシステム開発をするための手順が整っています。実力のあるシステム開発会社だからこそ、経験を積みながら、実践的なシステム開発の技術も学べます。自信をもって主義主張ができる『飯が食える』エンジニアを目指していただきます。

エンジニアとしてしっかりと飯を食べていけるまでには、道のりは決して短くありません。長期で頑張り、エンジニアになるという強い思いがあれば、実戦的な開発経験と、周りの仲間とコミュニケーションしながら、しっかりと成長できます。当社のノウハウを余すことなく活かし、技術力を大きく伸ばしていただきます。

ぜひ、エントリーをお待ちしております!

インターン採用

インターン採用

新卒・既卒・第二新卒採用

新卒・既卒・第二新卒

おすすめ記事