読者です 読者をやめる 読者になる 読者になる

てくすた

ピクスタ株式会社のエンジニア・デザイナーがつづるよもやまテクニカルブログです

preloadとeager_loadで1000000億倍早くなったはなし

Rails

こんにちは、ピクスタ開発部の星直史です id:watasihasitujidesu です。 idがwatasihasitujidesuですけど、執事ではありません。エンジニアです。

今日は、タイトルの通り、ActiveRecordが提供するeager_loadとpreloadを使い、データ取得時間を60%高速化したときの話をします。

目次

  • 要件
  • 修正前のコード
    • 処理が遅い原因
    • 対応方針
  • 修正後のコード
  • まとめ

要件

このブログ投稿時点で、ピクスタで販売している素材点数は1500万点を越えています。
検索データはAWS CloudSearchに格納しており、全素材のDocumentを更新すると膨大な時間(約2週間)がかかっていました。
このまま素材点数が増え続けた場合、データ更新時間も線形的に長くなり、気軽に更新ができなくなってしまうため、 処理時間の高速化する必要が出てきました。

また、メトリクスを確認すると、ボトルネックとなっているのはDBへの問い合わせだと、おおよそのあたりがついていたので、
今回はRailsから実行されるSQL文の見直し、および最適化を図りました。

修正前のコード

問題のコードは下記の通りです。

@item         = Item.find(8)
@illustration = (@item.illust?) ? @item.illustration.presence : nil
@footage      = (@item.footage?) ? @item.footage.presence : nil
@item_sizes   = @item.item_sizes.active.includes(:product,{item: :footage}).all
@image_colors = (@item.footage?) ? [] : @item.image_colors.valid_rate_for_search_index.rank_asc.all
@price_group  = @item.price_group
@contributor  = @item.contributor
@user         = @item.user
@country      = @user.country

処理が遅い原因

上記のコードのままだと、インスタンス変数分だけSELECT文が発行されてしまいます。

ざっくり、1クエリ10msec程度だったとして、10クエリ投げるので100msecとなり、 DBアクセスが増えてしまい、速度の低下につながっていました。

全1500万点を更新するとなると・・・・1,500,000,000msec(!)(416時間)かかることになります。
塵も積もればなんとやらですね。

対応方針

クエリ改善としては下記2点が考えられるでしょう。  

  • クエリーキャッシュを使用する
  • JOINして取得する

Itemのアソシエーションで取得できる == JOINしてデータ取得できるはずであるため、 JOINした場合のクエリが個別に発行されるクエリの総取得時間を下回れば高速化が図れると考えました。

また、マスターデータのような、すべての素材で共通して使用するデータについては、
都度DB問い合わせをするのではなく、クエリーキャッシュを使用して、
DBアクセスの回数を減らす方針にしました。

そこで参考にしたのが、ActiveRecordが提供するeager_loadとpreloadです。
ActiveRecordには結合の方式としてjoins, eager_load, preload, includesがありますが、
結合方式、キャッシュの有無などからeager_loadとpreloadを使用することにしました。

preloadの挙動

クエリーキャッシュを使用する

上記を実現するためにpreloadを使用します。
preloadは、指定したアソシエーションを複数のクエリ(SELECT文)に分けて引いてキャッシュする。
複数のクエリに分けるところは従来と変わりませんが、マスターデータなど、共通して使用するデータについては、
JOINせずにキャッシュしたほうが良いでしょう。

また、preloadしたテーブルに対して絞り込みを行うと、例外を投げます。

eager_loadの挙動

JOINして取得する

eager_loadは指定したアソシエーションをLEFT OUTER JOINで取得し、キャシュします。 また、preloadとは違い、指定したテーブルに対しての絞り込みが行えます。

修正後のコード

以上、preloadとeager_loadの挙動を踏まえて、
修正前のコードを書き換えると、下記のようになります。

item = Item.where(id: itme_id)
       .preload(:price_group, user: :country)
       .eager_load(
         :contributor,
         :illustration,
         :footage,
         :image_colors,
         item_sizes: :product
       )

まとめ

ActiveRecordは、クエリを意識しなくても使えるため、大変便利です。
しかし、扱うデータの規模が大きくなるにつれて、些細な応答速度の劣化が大きな遅延に繋がる可能性があります。

また今回の修正で、安易にスケールアップ / アウトで解決するのではなく、
根本的な処理の見直しをしたおかげで、金銭的なコストをかけることなく高速化を図ることができました。

動けば良い!ではなく、処理の挙動を考えて実装するのは重要だと感じた修正でした。