Hack Your Design!

railsdm2018で「ActiveRecordデータ処理アンチパターン」を発表しました

Rails Developers Meetup 2018で「ActiveRecordデータ処理アンチパターン」というタイトルで発表してきました。

発表資料

発表概要

ActiveRecordはWebエンジニア達が嫌う(?)SQLを書かずとも、Rubyオブジェクトで気軽にデータベースへアクセスできる魔法のようなツールです。しかし便利な反面、何も考えずにゴリゴリActiveRecordを使ってDBアクセスしていると、劇的に重たいクエリが発行されたり非効率的なクエリが量産されたりします。

本発表ではそれらActiveRecordで陥りがちな罠をパターン化し、ActiveRecordデータ処理アンチパターンとして発表します。

※発表では実際のサンプルコードとともにパフォーマンスの計測結果も紹介します。

事前に公開したエントリ

発表資料に出てくる最初の事例はこちらがベースの事例となっています。

ソースコード

実際使ったコード、ベンチマーク結果はこちらに上がってます。コードはlib/tasks/batch.rake、スキーマはschema.rb、シードデータはdb/*_seed.csv、ベンチマーク結果はCircleCIをそれぞれ参照ください。

https://github.com/toshimaru/rdm-rails5.1

発表モチベーション

今回の発表に至るモチベーションとしては、僕が実際に踏んだActiveRecordの重い処理とか他のエンジニアが書いたActiveRecordコードのパフォーマンス改善のための修正などをやっている中で、その良くない処理及びそれに対する解決アプローチがパターン化できると思ったからです。

僕のアタマの中に「こういうアンチパターンがありそう」というアンチパターン候補がある程度リストアップされていたので、今回の発表を機にそれらにそれっぽい名前を付けて、同時に机上の空論にならないようにそれらをコードに落として、聞き手がイメージしやすいように具体的な事例とともに紹介しました。

結果として、自分の中にあったActiveRecordアンチパターンを命名とともに整理できたことは大変良かったと思っています。またこの資料さえチームに共有しとけば、今後レビューのときとかでもアンチパターンに関するコミュニケーションがしやすくなって個人的に助かりそうです。

紹介したアンチパターン

発表内で紹介したアンチパターンがこちらです。

  1. All Each Pattern
  2. N+1 Update Queries Pattern
  3. Ruby Aggregation Pattern
  4. N+1 Queries Pattern
  5. Unnecessary Query Pattern
  6. Unnecessary Mode Initialization Pattern

紹介できなかったアンチパターン

何かしらアンチパターン化できそうだけど、時間の都合上しなかったアンチパターンがこちらです。発表しなかったので命名は適当です。

なんでもincludesパターン

joinsで良いのになんでもincludesで解決しようとしちゃうパターン。このへんは下記の解説に詳しいです。

ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い - Qiita

Too many find_or_create_by パターン

find_or_create_byは、オブジェクトが存在する場合は取得、なければ作成って挙動をするやつです。これをループ内で使いまくっちゃうパターン。

そんなときはSQLのUPSERTの機能を使うのが得策。具体的にはMySQLであればINSERT...ON DUPLICATE KEY UPDATEです。

残念なことにUPSERTはActiveRecordの標準機能では提供されていないので、activerecord-importなどのgemを使って解決する必要があります。

has_many関連のcount方法いろいろあるよ問題

ちゃんとパターン化できていませんが、この問題もなかなか難しい問題です。どのメソッド使ったらよいかはケースバイケースで変わってくるので詳しくは下記を参照されたし。

ActiveRecord の has_many関連、件数を調べるメソッドはどれを使えばいい? - Qiita

Q & A

アンチパターンの出典は?

全部オレです(笑

一応元ネタというかインスパイアを受けた本としては発表内でも紹介している『SQLアンチパターン』です。

こちらの本が原著は英語で書かれており、それに倣うかたちで英語でアンチパターンを命名しました。まぁ平たく言うとカッコつけて英語にしました以上の理由はありません :smile:

(事例1)User.created_atにINDEX貼らないの?

下記二点の理由により貼りませんでした。

  1. 前提事項としてDBの最適化はしないと述べた
  2. User.created_at にINDEXを貼ってもINDEX効かない

User.created_atにINDEX(index_users_on_created_at)を貼ったあとの実行計画がこちらになります。

mysql> EXPLAIN UPDATE `users` SET point = point + 100 WHERE (created_at >= '2017-01-01') \G
*************************** 1. row ***************************
           id: 1
  select_type: UPDATE
        table: users
   partitions: NULL
         type: index
possible_keys: index_users_on_created_at
          key: PRIMARY
      key_len: 8
          ref: NULL
         rows: 99574
     filtered: 100.00
        Extra: Using where
1 row in set (0.00 sec)

INDEX貼ってても対象範囲が大きいとINDEX効かなくなるんです。そして今回のケースはそれに当たります。(この挙動、実は僕も全然知りませんでした…)

テストとして条件の範囲を小さくした実行計画がこちらになります。

mysql> EXPLAIN UPDATE `users` SET point = point + 100 WHERE (created_at >= '2018-01-01') \G
*************************** 1. row ***************************
           id: 1
  select_type: UPDATE
        table: users
   partitions: NULL
         type: range
possible_keys: index_users_on_created_at
          key: index_users_on_created_at
      key_len: 5
          ref: const
         rows: 5903
     filtered: 100.00
        Extra: Using where
1 row in set (0.00 sec)

きちんとこちらではINDEXが効いてます。

(事例2)改善1のコードのモデルってロードされている?

会場であった質問です。こちらはRails consoleで実際のコードを動かしてあげれば一目瞭然です。

> Post.group(:user_id).select("user_id, SUM(like_count) AS like_count").order("like_count DESC") .limit(100)
  Post Load (976.6ms)  SELECT  user_id, SUM(like_count) AS like_count FROM `posts` GROUP BY `posts`.`user_id` ORDER BY like_count DESC LIMIT 11
=> #<ActiveRecord::Relation [#<Post id: nil, user_id: 2632, like_count: 832>, #<Post id: nil, user_id: 51965, like_count: 800>, #<Post id: nil, user_id: 25068, like_count: 783>, ...]>

> Post.group(:user_id).order("SUM(like_count) DESC") .limit(3000).pluck(:user_id)
   (668.3ms)  SELECT  `posts`.`user_id` FROM `posts` GROUP BY `posts`.`user_id` ORDER BY SUM(like_count) DESC LIMIT 3000
=> [2632, 51965, 25068, 8515, 84933, 67763, 89631, 69494, 78805, 17541, 53344, 7618, 92652, 13704, 94308, 96778, ...

一つ目の.selectを使ったコードはログにPost Loadと出現している通り、Postモデルがロードされている一方、.pluckのほうではPost Loadとはなにも出ず単純に走ったクエリのみがログに出力されています。

紹介したアンチパターン、どれくらいの件数で障害に繋がりそう?

今回紹介した事例は数千件-数十万くらいの程度のデータ量なのでそこまで酷いパフォーマンス結果は出ませんでしたが、例えば事例1でこれがUserレコード数百万件とか、事例3でレコードが数十万件くらいのオーダーになってくるとボトルネックが表出しそうかな、という印象です。

いずれにせよそこそこの規模のアプリケーションになってくると、数百万レコードを扱うのは当たり前の世界になってくると思うので、そのレコード数をどうActiveRecordの世界で上手に扱うは逃げられないテーマになってくるかなと思います。

発表を終えて

30minsと長めの発表はAWS Summitぶりだったので時間配分にやや不安があったけど、当日は発表を巻くこともなく余裕をもって25分くらいで発表を終えられたのでよかったです。

その他の資料

  • このエントリーをはてなブックマークに追加