Ruby on Railsで分間100万リクエストを捌くコードの書き方

Rails効率化に関する記事を読んだので、ポイントだけまとめておこうと思います。

engineering.shopify.com

ActiveRecordのパフォーマンス改善

SQLが実行されるタイミングを知る

たとえば下のようなコードは、すぐにクエリが発行されてしまう。

post.comments << user.comments.build

#    (0.1ms)  begin transaction
#  Comment Create (0.4ms)
#  INSERT INTO "comments" ("created_at", "updated_at", "post_id", "user_id")
#  VALUES (?, ?, ?, ?)  [["created_at", "2019-07-11 22:16:43.818858"], ["updated_at", "2019-07-11 22:16:43.818858"], ["post_id", 1], ["user_id", 2]]

予期せぬクエリ発行はパフォーマンス低下につながるので、クエリ発行のタイミングにはいつも注意しよう。

いつ発行されるか知るためには、経験を積んで、あとドキュメントとかいっぱい読もう、とのこと。

出来るだけ少なく選択する

Blog.select(:id)
#   Blog Load (0.2ms)  SELECT "blogs"."id" FROM "blogs" LIMIT
# => #<ActiveRecord::Relation [#<Blog id: 1>, #<Blog id: 2>, #<Blog id: 3>, #<Blog id: 4>, ...]>

Blog.pluck(:id)
#   (1.2ms)  SELECT "blogs"."id" FROM "blogs"
# => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

このようにidだけほしいならselectよりpluck。ActiveRecordオブジェクトを作るコストが減る。

クエリキャッシュを忘れる

Railsは一つのリクエストで同じクエリを発行するとき、その内容を自動的にキャッシュする機能がある。

Blog Load (0.2ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
CACHE Blog Load (0.0ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
CACHE Blog Load (0.0ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]

でも、これは一つのリクエストの話。リクエストをまたがってクエリを出したとしたら、この機能はべつに効率的に働かないので頼りすぎないこと。

インデックスのないカラムにSQLを打つことを避ける

インデックスなしのカラムを検索しようとすると、「フルテーブルスキャン(前表スキャン)」になって遅い。

だからよく検索で使うカラムにはインデックスをつけるのが大事。ただ、「ALTER TABLE」で後からインデックスを付けようとすると、何百万レコードとあるテーブルだった場合、半端ではない遅さになるそう。あとDBがロックされてその間書き込みできないらしい(このへんは自分のDBへの知識不足で、詳細不明)

なので俺達のチームでは自作Gemを使ってるぜ! とのことです。

https://github.com/shopify/lhm

Railsのパフォーマンス改善

全てをキャッシュする

Rals.cache.fetchメソッドを使うことで、「キーとバリューの組み合わせ」を、キャッシュとして保存できる(たぶんRedisとかに)。

もう一度fetchすれば、クエリを発行せずにキャッシュからデータを引っ張ってこれる。

expires_inで、有効期限の設定も可能。

Rails.cache.fetch("plan_names") do
  Subscription::Plan.all.map(&:name)
end

Rails.cache.fetch("blog_#{blog.id}_posts_#{posts.max(:updated_at)}") do
  blog.posts.as_json
end

Rails.cache.fetch("comment_count", expires_in: 5.minutes) do
  Comment.approved.count
end

これ、うまく使えばめちゃくちゃ効率的だなあ、と思った。クエリ発行しないから、「データの厳密性が必要なとき」は、使うべきじゃないけど、それを差し引いても有用に見えるからどんどん使ってみたい。

ボトルネックを調整する

キャッシュできないものに対する対策。

rack-attackというGemを使うことで、Railsに辿り着く前に、特定のIPによる連続リクエストを拒否する。

Rack::Attack.throttle("limit login by IP", limit: 10, period: 15.minutes) do |request|
  if request.path == "/admin/sign_in" && request.post?
    request.ip
  end
end

Railsに辿り着く前に拒否できるから、パフォーマンスよし、とのこと。

(Jobを使って)後で実行する

重い処理はJobで切り出して、他のプロセスに処理を委譲しようぜ! とのこと。

依存関係をダイエットする

Gemを入れすぎると、依存関係がやばくなるから注意。Dependabot入れると、依存パッケージがバージョンアップすると、プルリクでお知らせしてくれるから便利だよ、とのこと。

Rubyのパフォーマンス改善

メタプログラミングは少しだけ使う

メタプロ(動的なメソッド定義など)は遅いから自分でやるときは、本当に必要なときだけにしようぜとのこと。

O(1)とO(n)の差を知る

serializers = [UserSerializer]
serializers.find do |serializer|
  serializer.accepts?(object.class)
end

serializers = { User => UserSerializer }
serializers[object.class]

上の例のように、O(1)にしたほうが効率いいから、そう設計していこうぜとのこと。

割りあて(Allocation)は少なく

Rubyではよく「!」がついた類の破壊的メソッドは危険だから、あんまり使うなって言われるけど、使い方しだいでは、効率化につながる。

arr = %i(a a b c)

#これより
new_arr = arr.uniq

#こっちのが効率いい
arr.uniq!

既存のオブジェクトを破壊して、新しいオブジェクトを作るほうが、割当てが減って効率がいい、とのこと。もちろん、破壊的変更は使い方を間違えると危ないので注意とのこと。

なるほどねえ。。使っていきたい。

間接化は最小限に

No code is faster than no code.

(ノーコードに優るコードはない)

間接的に複雑な処理を行えば行うほど、パフォーマンスは落ちてしまう。

即座に最適化には結びつかないけど、リファクタリングに大事な考えだから、覚えておくほうがいいとのこと。

最後に

ソフトウェア開発にはトレードオフがいっぱい。速さがいつも「開発で一番大事にしないといけないこと」ではない。

Shopifyでは「速さは機能の一つ」としか考えてなくて、それは「ユーザー体験の向上」と、「サーバー請求額の減額」にはつながるけど、開発者の幸福より優先されて取り組むことではないと思っている。

楽しんで早くしていこうぜ!

感想

Railsのキャッシュとかマジで全く知らなかったから、チャンスがあれば使ってみたいなあ、と思った。(コナミ

あとは、Rubyでの破壊的変更の利用とかも、意識すればすぐに実務で使っていけそうだから、これも覚えておきたい。