ここで書いたけれど、一撃で超改善というのはなかなか難しく、一旦現状を打破するために小手先のやりくりでなんとかしたいときもある。実際に小手先のやりくりをいろいろしたのでその記録。
基本は愚直にやるしかない
- ボトルネックを探して、そこを改善していく
- なので、まずは計測が必要。改善したあともどういう変化が見られたかを確認していく
- 1日の中で処理する量は変化するので、1日様子を見てからの判断でも遅くはない
- あるWebAPIなエンドポイントのレイテンシが高くてなんとかしたい、みたいな場合、その処理が単純なDBクエリして結果を整形して返す、であればまあまあわかりやすいが、現実の問題でそういうケースはあまりない
ちりつもである
- というわけで、どうみてもわかるような大きなボトルネックの改善だけでなく、小さなボトルネックの改善も積み重ねていくしかない
- たとえば1つのクエリ、1つの処理が1msだとしても、でループして何度もするなら積み重なる
- いろいろ待ちもあるので10回読んだら10msということはなく11msとか12msとかになる。ワーストケース、p99とかで見るともっといく可能性もある
- 単純なN+1の場合もあれば、1ページずつ読む、ある一定期間を順番に計算する必要がある、などのケースでもこうなる
インデックスをつけたりクエリを見直したり
ActiveRecord周りに多くのちりつも改善チャンスが眠っている
exists? / present? を使いわける
- exists? はActiveRecordに対してやると SELECT 1 が発行される、これはすでにクエリ済みの結果でも変わらない
- present? は結果を取得して、その結果に対して配列として存在確認がされる
- ActiveRecordの結果を使うことがあるなら present? をして、配列として扱ったほうがよい
- 逆にクエリ結果がいらないなら present? ではなく exists? にしたほうがいい
- empty? もそう。
Arrayのfilterやfindを使う
books.map {|book| book.categories.where(type: TYPE_MANGE) }
だとN+1になる。これを回避するためにmapの中でfilter
かfind
などを使うbooks.map {|book| book.categories.filter {|category| category.type == TYPE_MANGE } }
- さらにこのままだと
book.categories
が毎回読み込まれるので、プリロードも必要books = books.preload(:categories)
- 各bookのcategoriesがメモリ上に読み込まれた状態になり、そこから探すようになり、MySQLへのクエリを行わなくなる
- ちなみにfilterの別名である
.select()
とするとActiveRecordでSELECTが呼ばれることに注意。もしselectを使いたいならブロックにする.select { ... }
ちゃんとプリロードする
- scopeを使っているとプリロードできないので、エイリアスしたアソシエーションか、Preloaderを使うか、自分で実装する必要がある
if association(:author).loaded?
で読み込み済みかを判断できるので、これを使って処理の仕方を変えられる
必要なカラムだけ取る
- 極端だけど
Book.all.map(:id)
じゃなくてBooks.pluck(:id)
とか- TEXTが無制限に入るフィールドとかも一緒に取るとまあまま時間がかかる
- 絞ればカバリングインデックスで取れる可能性も出てくる
Books.select(:name).where(type: "foo")
みたいなもの
instantiateのコストは低くない
Book.find_by(id: 1)
をするとSELECT * FROM books where id = 1
というクエリが出て、Bookインスタンスを作る- このインスタンス化の部分のコストが実は低くはなく、大量のレコードをインスタンス化するとまあまあ時間がかかる
- 生クエリを書けば当然インスタンス化はないが大変。けれども to_sql で実は生クエリに落とせる
- 複数のテーブルからまとめてデータを取るのもこれで行ける
- 結構アクロバティックな対応になるので、本当に遅くて困っているような部分で検討するとよさそう
Jbuilderは遅いのか?
- いろいろ調べたけれど、JbuilderのDSLをごりごり使うと遅い
- 結論、Jbuilderにハッシュを渡せばDSL使わずともto_jsonしてくれて、これが最速。Jbuilderの恩恵は少ないし、コントローラーから直でto_jsonを返せばいいのでは?という気持ちになるが…
- パーシャルキャッシュなんかもできるが内容によって効果は全然ないので要確認
- これを使うのも手: amatsuda/jb: A simple and fast JSON API template engine for Ruby on Rails
- いい感じにJSONにするのであれば
rails-api/active_model_serializers: ActiveModel::Serializer implementation and Rails hooks
とか
jsonapi-serializer/jsonapi-serializer: A fast JSON:API serializer for Ruby (fork of Netflix/fast_jsonapi)
も検討するといいかもしれない
- ちゃんと試してないけれどどっちにしてもHashでto_jsonしたほうが速いはず…
- 開発利便性 vs 速度
リクエストスコープベースのインメモリキャッシュ
- お手軽には @latest_notification ||= Notification.for_me.last みたいにしてインスタンス変数にいれる
- クラスをまたぐと共有できないのでそれをしたいならどうにかする必要がある
-
ElMassimo/request_store_rails: 📦 Per-request global storage for Rails prepared for multi-threaded apps
とか
steveklabnik/request_store: Per-request global storage for Rack.
を使うといい
- 同じレコードを何度も参照したくなるような複雑な計算だったり
- Rails.cache に入れるよりも速くなるし、リクエスト終わりにクリーンナップする、みたいな処理もいらない
並列処理
- 外部の遅いAPIを利用する場合だとかで並列に処理しても問題ないときは並列にすればその分速くなる
- grosser/parallel: Ruby: parallel processing made simple and fast
- 割り当てられているCPUやメモリ、ネットワークのリソースにもよるので、思考停止で並列にしても最大の効果はでない
- 追求するなら色々計測して細かく調整が必要
- 集計のために多数の計算が必要だったりする箇所も、並列にできるなら速くなる可能性が高い
- CPUとメモリをより多く使うことになるのでその部分の注意が必要
Rails.cacheを見直す
- 基本は1レコード1キャッシュのような形にすると、キャッシュヒット率が高くてよい
- キャッシュ参照がN+1になる場合もあるので、全件キャッシュしたり、プリロードしたり適宜判断したい
- キャッシュN+1はDBのN+1より圧倒的に早いけれど通信もあるしインメモリで済ませるよりは遅い
- ちなみにActiveRecordをそのままキャッシュすると、クラスがリロードされたりした際にキャッシュから復元できなくなるので、あまり良くない
- Productionリリース時にキャッシュを一度クリアしていると問題はない
- それが難しいほどの大きなサービスだと気をつける必要がある
- ActiveRecordのafter_save/after_deleteを使って、データ更新と同時にキャッシュを更新することができる
- データ操作があまりにも多いようだとキャッシュの恩恵も大きくなさそうなので、増える複雑さとのバトルになる
- 一切変わらないマスター的なデータはキャッシュを使う必要もなくて、Rails起動時にインメモリに置く、でもいい