ランキングをDBで出すとなぜ崩れるのか
一言でいうと
ORDER BY score DESC LIMIT 10は、1人の順位を尋ねた瞬間に崩れます。ZSetはソート状態を常に保ち、順位をO(log N)で返します。
なぜ必要なのか
リーダーボードの要件は、たいていこのように来ます。上位10人を見せ、自分の順位も見せ、自分の前後5人も見せ、スコアはリアルタイムで変わります。
RDBなら、最初の要件は簡単です。ORDER BY score DESC LIMIT 10にインデックスを張ればよいのです。2つ目から崩れます。「自分の順位」は、自分よりスコアが高い人の数を数えることで、SELECT COUNT(*) WHERE score > my_scoreは、インデックスがあっても、その範囲をすべて数える必要があります。ユーザー100万人のうち下位のユーザーの順位を尋ねると、99万行を数えます。しかもこのクエリが、ユーザーごとに、リクエストのたびに入ってきます。
3つ目の要件である前後5人は、さらに悪いです。オフセットを知る必要があるので、順位の計算が先に必要です。4つ目のリアルタイム更新は、そのインデックスに書き込みが入り続けるという意味なので、ロック競合まで加わります。
どう動くのか
Redisのソート済み集合は、この問題のために作られたかのようにぴったりです。内部でスキップリストとハッシュテーブルを一緒に維持しているので、メンバーからスコアを探すことと、スコア順に範囲を走査することの両方を高速にします。
核心のコマンドは5つです。ZADDでスコアと一緒に入れ、ZINCRBYでアトミックに加算し、ZREVRANGEで上位Nを取り出し、ZREVRANKで特定のメンバーの順位を得て、ZSCOREでスコアを見ます。順位と範囲の取得は、すべてO(log N)かO(log N + M)です。100万人でも1000万人でも、順位の取得がミリ秒を超えません。
同点の処理が、実務での最初の関門です。Redisはスコアが同じなら、メンバー文字列の辞書順に並べます。ところがプロダクトの要件は、たいてい「同じスコアなら先に達成した人が上」です。解決策は複合スコアです。スコアと時刻を1つの実数に畳み込みます。
composite = points + (1 - ts / 1e10)
# points 5000, ts 1795000000 -> 5000.8205
# 같은 points 라면 ts 가 작을수록(먼저 달성) composite 가 크다
小数部が常に0と1の間なので、整数の境界を越えず、スコアの順序はそのまま保たれながら、同点だけが時刻で分かれます。
2つ目の関門は、期間別のランキングです。全体のランキングと週間のランキングを別々に置き、週間のキーにTTLをかけます。lb:weekly:2026-W34のようなキー名に期間を入れると、期限切れが自動で片付けてくれます。複数の期間を合算する必要があれば、ZUNIONSTOREが重みまで受け取って、一度に処理します。
現場での姿
ランキングは、Redisがキャッシュではなく一次ストレージになる、珍しいケースです。そのため、永続化を有効にしておくか、元のイベント(どのユーザーがいつ何点を得たか)を別の場所に残して、再構築できるようにする必要があります。筆者の推奨は後者です。ランキングは派生データなので、いつでも作り直せなければなりません。
そして、ページネーションにオフセットを使うことは、ZSetでは問題になりません。ZREVRANGE key 99 108は、スキップリストをたどってすぐに行きます。RDBのOFFSET 99とは、性格がまったく違います。
大きなランキングを扱うとき
ソート済み集合は高速ですが、すべてがメモリに載ります。 ユーザーが増えると、この事実が設計を左右し始めます。
メンバー1つが占めるサイズはメンバー文字列の長さに比例するので、キーに入れる識別子を短く保つことだけでも、かなり差が出ます。ユーザー名の代わりに数値のIDを使い、そのIDを文字列ではなく整数で表現できる形にしておく、といった具合です。そして、ランキングに全員を入れる必要がないことも多くあります。下位のユーザーの正確な順位を誰も見ないなら、上位数万人だけを維持し、残りは「上位N位の圏外」として扱うほうがはるかに安上がりです。ZREMRANGEBYRANKで定期的に切り詰めればよいのです。
コマンド1つに時間がかかることにも注意が必要です。Redisは一度に1つのコマンドを処理するので、範囲の広い取得1つが、ほかのすべてのリクエストを止めます。 ZRANGE key 0 -1で全体を取得するコードが、100万メンバーのキーに対して実行されると、その瞬間にサービス全体が固まります。ページサイズを上限として強制し、全体を走査する必要がある作業は、ZSCANで分割して回すのが原則です。
複数の期間を合算するZUNIONSTOREも、同じ理由で注意が必要です。大きな集合を複数合算すると、その間ほかのリクエストが滞ります。リアルタイムのリクエスト経路では呼ばず、事前に計算しておいて結果だけを読むようにするほうが安全です。
最後に、ランキングは不正行為が真っ先に試みられる場所でもあります。スコアを上げるリクエストをクライアントがそのまま送れるようにしておくと、誰かがそのリクエストを自分で作って送ります。スコアはサーバーが計算する必要があり、同じ出来事で2回上がらないように、前に見た冪等キーがここでもそのまま使われます。ランキングの価値は正確性から生まれるので、一度信頼を失うと、その機能自体が無意味になります。
次のラボですること
2,000件のプレイ記録で、200人のリーダーボードを作ります。上位10人、特定のユーザーの順位、100位台のページ、同点の規則、週間ランキングと合算まで、すべて実装し、最後に順位取得APIを立ち上げて、1,000回の取得時間を測ります。