クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

ActiveRecord のパフォーマンス改善に関するgemを作った話

こんにちは。クラウドワークスの八木です。

今回は、ActiveRecord のパフォーマンス改善に関する gem を作ったので、それについて紹介したいと思います。先にオチを書いちゃいますが、この Gem を入れるだけで必ずパフォーマンスよくなるとか、そこまでのものではありません(仮に、そんな銀の弾丸があれば、 Rails 本体にリクエストを出すべきですし....)。特定のケースにおいて改善が期待される、というタイプのものです。

どんなgem

ActiveRecordのpreloadを使う際、アプリケーションの仕様上不必要なSQLのクエリの発行を避けることができるようになる active_record_association_query_economizer という gemです。

github.com

例をあげて説明します。

ある RPG のゲームで、ユーザー (User) の職業が魔法使い (wizard) のときのみ魔法 (magic) が使える、という設定があるとします。user_id=1, 2 のユーザーは魔法使いなので魔法が使えるが、 user_id=3 のユーザーは戦士なので魔法が使えない、という具合です。 サイトには、ユーザーの一覧を表示するページが用意されています。その中で「各ユーザーの名前の横に、使える魔法を表示したい」としましょう。

すると、きっとこんなコードを書くことになります。

Controller:

@users = User.last(25)

View:

<% @users.each do |user| %>
  <%= user.name %>
  <ul>
    <% user.magics.each do |magic| %>
      <li><%= magic.name %></li>

users の各 user に対して magics を参照しているので、このままではクエリがたくさん発行をされてしまいます。いわゆるN+1問題というやつです。そこで、preloadを使ったりすると思います。

Controller:

@users = User.last(25).preload(:magics)

クエリはこんな感じになります。

SELECT users.* FROM users ORDER BY id DESC LIMIT 25
SELECT magics.* FROM magics WHERE user_id IN (1, 2, 3, 4, ...)

このときに、魔法使いじゃない、user_id=3 のユーザーに注目しましょう。preload のクエリには user_id IN (..., 3, ...) とあるので、magics の中から user_id=3 のレコードを探しています。しかし、この例における「魔法使いじゃないと魔法を使えない」という仕様からすると、これは必要のない検索です。今回作った active_record_association_query_economizer では、このような必要のない検索を減らすことができるようになります。

この magics の例では、IN句のコストがほんのちょっと減るだけなので、ほとんど効果はないでしょう。では、同じページに「職業が戦士のユーザーで絞り込む」という機能があった場合はどうでしょうか。その場合は preload のクエリがそのまま全部不要になります1。その他、 preload 対象のテーブルにレコードが少なければ少ないほど削減量が大きくなります。

使い方

active_record_association_query_economizer を追加して、アソシエーション定義のところに下記のように preload_if オプションを記述します。

class User
  has_many :magics, preload_if: :wizard?

以上です。これで、preload によるクエリ発行前に、 wizard? の戻り値によって id のフィルタリングが実行され、仕様上必要なもののみ preloadが行われるようになります。

クエリはこのようになります。

SELECT users.* FROM users ORDER BY id DESC LIMIT 25
SELECT magics.* FROM magics WHERE user_id IN (1, 2, 4, ...)

Symbol のサンプルを示しましたが、Proc も記述できます。Proc を書いた場合、引数に渡ってくるのはこのモデルのインスタンスです。

サービスへの導入

弊社のサービス crowdworks.jp に適用した例をご紹介したいと思います。一定期間の特定テーブルへの preload クエリを集計して、効果の参考としました。

サービスで使っているモデル Foo とモデル Bar の間には、 foo has_one bar で表現される関連が定義されており、一日に約137万件のpreloadクエリが発行されていました。

SELECT bars.* FROM bars WHERE foo_id IN (1, 2, 3, 4, ...)

このようなクエリが1日に 137 万件発行されてしました。そのうち、テーブル bars にレコードが一つもないようなクエリは、実に 119 万件もありました。このクエリは、gem の導入により一切発行されなくなりました。クエリの発行数を検索レコード数別に見ても、ほとんどのクエリが検索レコード数20以下に収まっています(下図)。

f:id:negito6:20171104210024p:plain:w480

また、preload により bars のレコードが取れていたケースでも、SQL の IN 句に含まれた id のうち、半分以上にレコードが存在したクエリは、わずか 9万件しかありませんでした(要は、ほとんどのクエリは空振りばかりしていたということです)。

ちなみに、レスポンスタイムへの影響ですが...少なくとも体感では全く違いは分かりませんでした。これは予め分かっていたことではありますが、preloadのクエリは、特にインデックスを適切に貼っていると非常に高速です。(ということもあり、今回はクエリ数についての検証のみ行いました。)

Q&Aのコーナー

ソースコード見たけど書き方おかしくない?

プルリクエストお待ちしております!過疎なので日本語でもいいですよ!

名前が長い。名付けも置きにいってる

既に社内で dis られ済みです。何か機能を実現するライブラリではないので、ユニークな名前は避けました。内容が分かりにくいと「これ要らないんじゃね?」って消されちゃいそうなので。

Railsにプルリク出さないの?

実際の使用実績がないと需要に疑問があるので、みなさんの導入事例報告をお待ちしております。 そもそもこれが必要になる状況というのは、テーブル構造に問題がありそうですが、現実はそううまくはいかないので、gemで公開という形にしてみました。

はじめての Gem

ライブラリをきちんと公開したのは初めてで、まあ色々大変でした。一ヶ月以上経ちますが、未だに毎日のように、バグ報告がないかとダウンロード数とをチェックしちゃっています。gemは我が子です。すくすくと立派に育ってほしいものです。

クラウドソーシングのクラウドワークス では、OSS に積極的に関わるエンジニアを募集しています。

www.wantedly.com


  1. 魔法使いが表示されないようなパターンでは preload をそもそもしなければいいという話であはりますが、絞り込み条件によって preload を変えるというのはコードが複雑になりがちかと思います。

© 2016 CrowdWorks, Inc., All rights reserved.