Ruby on Railsで分間100万リクエストを捌くコードの書き方
Rails効率化に関する記事を読んだので、ポイントだけまとめておこうと思います。
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での破壊的変更の利用とかも、意識すればすぐに実務で使っていけそうだから、これも覚えておきたい。