Redisを用いたキューのパフォーマンス改善
Redisのデータ構造、エンキュー・デキュー処理はライブラリを使わず自作していた
販売終了したアイテムをまとめて非公開にするバッチを実行したところ、パフォーマンスが悪化してサービス障害につながったため、パフォーマンス改善のための調査を行った
計測する
障害が起きたのがだいぶ前のため、当時の記憶がほとんどなくなっている
DB、Redisなどミドルウェアのスペックも変化していたため(今のスペックでは問題なくさばける可能性も考えられた)、障害当時のログをたどるよりは現在の状況で計測したほうがよいだろうと判断
DatadogでSETやSADDといったRedisのオペレーションの単位でレイテンシを調べた
Redisのデータ型
このキューはSet型を用いていた
Set型には順序がないので実はキューではなかったのである(!)
Set型の要素の追加・削除はO(1)で高速なものの、大量の要素があるときにSMEMBERSを使うとパフォーマンスの問題が起こる可能性がある
実際、デキューするときにSMEMBERSを使っていたのでまず怪しいポイントがひとつめ
Datadogによると平常時でも500〜600msかかるときがあった
Performance
Most set operations, including adding, removing, and checking whether an item is a set member, are O(1). This means that they're highly efficient. However, for large sets with hundreds of thousands of members or more, you should exercise caution when running the SMEMBERS command. This command is O(n) and returns the entire set in a single response. As an alternative, consider the SSCAN, which lets you retrieve all members of a set iteratively. 代わりにSSCANを使うべきと書かれている
SSCANを使ってもいいのだが、そもそもこのデータ構造はSet型である必要がない
パフォーマンスに影響するほど重複が起こるとは考えにくいし、Set型は順序を持たないので、きちんとファーストイン・ファーストアウトにしたほうがよりユーザーの期待に添える
Redisでキューを実現するには一般にList型を使うので、Set型のままパフォーマンス改善に取り組むよりも、まずはList型にしてしまう方が思わぬパフォーマンスの罠を回避できそうと考えた
ほかのソフトウェアのキュー管理
Sidekiqを止めた状態で適当なジョブを積んでRedisを眺めると、ジョブはsidekiq:queue:キュー名に入ることがわかった
default queueのsidekiq:queue:defaultはList型で、ジョブの実行に必要なメタデータをJSONにエンコードした文字列が入っているようだ
コードが難しくてエンキュー・デキュー処理は追えなかったが、List型であればLPUSH/RPOPであろう
List型の単純な実装でキューを実現している
LPUSHでリストの先頭に要素を追加し、RPOPで末尾から取り出す
Redis 6.2以降ならRPOPのcount引数を使って、1件ずつではなく1000件まとめて取得している
リストの末尾ではなく、LPUSHしてRPOPする実装になっている
RedisのList型は双方向リストなので、先頭に追加して末尾からポップするのと、末尾に追加して先頭からポップするのはパフォーマンス的に変わらない(はず)
RedisのドキュメントにもList型を用いてキューを実現する例が紹介されている
Treat a list like a queue (first in, first out):
ということで、やはりList型がベストと判断して変更した
エンキュー
エンキューはSet型(元の実装)ではSADD、List型ではLPUSHを用いている
いずれもO(1)なので高速だが、複数の要素を指定して追加するとO(N)になる
ユーザーが変更されたときはユーザーが持つ商品すべてをキューに追加する実装になっていて、商品の数によっては遅くなる可能性があった
SADDのレイテンシを見たところ、4秒ほどかかることがあった
全商品を一度に追加するのではなく、1000件ずつエンキューする実装にした
デキュー
先述の通り、Set型(元の実装)ではSMEMBERSを使っており問題が出る可能性があった
Searchkickと同様にRPOPのcount引数を用いて、1000件ずつ取り出すようにした
監視
管理画面にキューのサイズを表示し、バッチ実行後のキューサイズの変化を確認できるようにした
LLENを用いており、これはO(1)である
バッチ処理
引数によって1レコードずつsleepを入れたり、処理する件数を制御できるようにした
いざ実行
ここまでで、データ構造をList型へ変更し、エンキュー・デキュー処理の改善、およびキューサイズを確認できるようにした
件数を絞ってバッチ処理を実行したところ、数千件しか処理していないのにキューサイズが数百万件に膨れ上がってしまった
インデックス処理に遅れが発生したものの、(データ構造の改善のおかげか)サービス障害には至らなかった
これが根本原因だろうとのことでまた調査
原因はデータ構造とは別にあった
調査したところ、商品を1件更新しただけなのにユーザーが持つ商品すべてがキューに入ってしまうことがわかった
原因はアソシエーションにtouch: trueオプションが指定されていたため
コードは以下のようになっている
code:user.rb
class User < ApplicationRecord
after_commit -> { Indexer.enqueue self }
has_many :products
end
code:product.rb
class Product < ApplicationRecord
after_commit -> { Indexer.enqueue self }
belongs_to :user, touch: true
end
code:indexer.rb
class Indexer
def self.enqueue(record)
recordのクラスに対応するキューに追加
if record.is_a?(User)
ユーザーが持つ商品すべてをエンキュー
end
end
end
Productを更新すると、
touch: trueオプションによりUserのupdated_atが更新される
Userモデルのafter_commitによりUserがエンキューされる
UserがエンキューされるとUserが持つ商品すべてをエンキューする
これにより、バッチ処理の対象件数よりもはるかに多い商品がキューに追加されたことで、パフォーマンスの悪化(おそらくSMEMBERSで取り出す部分で)が起こったと考えられる
touch: trueを使わない
touch: trueが入った経緯は、キャッシュキーの関係で「ユーザーの更新日時と商品の更新日時のうち、最新の日時」を知りたかったため
商品を更新したときにユーザーのupdated_atが更新されれば、ユーザーのupdated_atを考慮するだけでよくなる
ユーザーが最後に商品を更新した時刻を別テーブルに保存し、touch: trueオプションを外すことで解決した
after_commitコールバックをやめる方法も考えたものの、別テーブル作るのにそこまで手間がかからないことと、touch: trueを積極的に使いたい理由もなかった
解決
バッチ処理を実行したところパフォーマンスの問題なく完了した🎉
結果的に解決できたものの、今回はデータ構造に着目するのではなく、バッチの内容を調べたほうがより早い解決につながったため、そこは反省ポイント
開発環境ではユーザーはせいぜい数件しか商品を持っていないので、1商品を更新しただけなのに何件もエンキューされる違和感に気づくのが難しかった
キューサイズを簡単に確認できるようにしたのはよかった。やはり可観測性だいじ