この記事は SUPER STUDIO Advent Calendar 2024 の 15 日目の記事になります。
概要
以前、一括処理系のジョブを実装した際に、ロジックミスで処理が激重になってしまったお話です。
詳細なお話はできないので、ところどころ端折ってお伝えすることになりますがご了承ください。
教訓
先にこの話で得た教訓を書いてしまうと
- 利用想定はしっかりヒアリングしましょう
- 一括処理する際はリクエストされたデータをそのまま処理するのではなく、ある程度の単位でチャンクしましょう
という、当たり前のことではありますがこちらになります。
伝えたかったのは上記だけなので、以下は、実際にどういうやらかしをしてしまったのか、自分への戒めを込めて書いた内容になりますので、余談程度に読んでくださると助かります。
ジョブの要件
Web サーバにて一括登録のリクエストを受け付けて、Batch サーバにて非同期でリクエストされたデータを一括登録するジョブを実装する必要がありました。
前提
- Web サーバでリクエストを受けて、Batch サーバでリクエストされたデータを一括登録するジョブを実装
- Hoge, Fuga の情報が含まれる
- 一括登録には Ruby で Activerecord-import という gem を利用
- ※Rails6 以上ではバルクインサートが利用可能だが当時は使えなかった
- DB は MySQL5.7
- Hoge, Fuga はそれぞれ別テーブルの情報で、Fuga は Hoge への外部キーが必要
- 外部キーは Hoge テーブルの id
要件に対する課題と解決策
前提に書いてある内容でお気づきの方もいると思いますが、「Activerecord-import x MySQL の場合、関連のあるテーブルをまとめて import できない」という課題がありました。
その課題に対して以下のようなロジックを組んで解決しようとしました(実装時のコードとは若干異なります)
# 一括登録用のリストを用意
hoge_lists = []
hoge_keys = []
fuga_lists = []
fuga_hashes = []
# ループ開始
requests.each do |request|
# request の中身は以下の様な hash イメージ
# { hoge: { column_a: 'a', column_b: 'b', key: 'xxx' }, fuga: { fuga: 'fuga' } }
# hoge を生成してリストの格納
hoge = Hoge.new(request[:hoge])
hoge_lists << hoge
# hoge の key を格納
hoge_keys << hoge.key
# fuga の hash を生成してリストに格納(hoge の key を一旦 merge しておく)
fuga_hashes << request[:fuga].merge!({ hoge_key: hoge.key })
end
# Activerecord-import で Hoge の情報を一括登録
Hoge.import(hoge_lists)
# 登録成功した Hoge の情報を key を基に取得
imported_hoge_lists = Hoge.where(key: hoge_keys)
# 登録成功した Hoge の id を Fuga に紐づける
imported_hoge_lists.each do |imported_hoge|
id = imported_hoge.id
key = imported_hoge.key
# fuga_hashes から一旦紐づけた key を基に紐づけ対象の fuga を特定して id を紐づける
# また、紐づけに 利用した key は削除する
fuga_hash = fuga_hashes.find{ |fuga| fuga[:hoge_key] == key }
fuga_hash.merge!({ hoge_id: id }).delete(:hoge_key)
fuga_lists << Fuga.new(fuga_hash)
end
# Fuga を一括登録する
Fuga.import(fuga_hashes)
上記ロジックの問題点
上記ロジックの問題点は「リクエストされたデータをチャンクしていないため、fuga_hashes.find
している箇所が処理件数によって膨大に時間がかかってしまう」ことです。
紐づけ対象を特定するためにfind
している処理が100件程度までなら問題ないのですが、1,000件、10,000件と増えるごとに指数関数的に処理に時間がかかることが判明しました。
動作確認時は「せいぜい 100 件程度のリクエストしか来ないだろう」という想定で動作確認していたため、問題が浮き彫りになることはなかったのですが、実運用前のリハーサルで想定以上(たしか、10 万件とか)のリクエストが来ており、ジョブが重すぎるという問題がうきぼりになってしまいました。
改修案
そのため、以下のようにリクエストをチャンクするように改善するだけで、ジョブの実行速度が劇的に改善しました。
# リクエストを each_sliece で分割
requests.each_slice(100).each do |chuncked_requests|
chuncked_requests.each do |request|
# あとのロジックは前述の通り
...
end
end
上記のように実装しておくだけで、リクエストが増えてもfind
による検索対象は一定になるため、リクエスト件数に応じてジョブの処理時間が指数関数的に増加することはなくなりました。
最後に
最後までお読みいただきありがとうございます。
過去の自分のやらかしを戒めを込めて書き起こしました。
この記事が誰かの参考になったり、笑い話になったりしてくれたら幸いです。