EN

Redisのsorted setとLuaで作る、2〜3人マッチングの仕組み

この記事の内容は次のとおりです。

はじめに

匿名でトピックについて話せるチャットサービスを個人開発しています。前回の記事では、ルームに入ったあとの会話(GET /ws/rooms/{roomID}で始まるWebSocket)の話を書きました。

この記事はその手前、「同じトピックを選んだ人同士を2〜3人のルームにまとめる」マッチング部分の仕組みを紹介します。使っている技術はGo / Redis / PostgreSQLです。

やりたいこと

  • 利用者はトピックとルームの人数(2人か3人)を選んで待機する
  • 同じ条件で待っている人が揃ったら、ルーム(会話)を作る
  • APIサーバーは2台あるので、どちらのサーバーに繋いでも同じ待機列に入る
  • 同時にリクエストが来ても、1人が2つのルームに入ることは絶対に起きない
  • 3人ルームで3人目がなかなか来ないときは、60秒待ったら2人で始める

全体の流れ

クライアントから見ると、APIは3つだけです。

メソッドパス役割
POST/api/matching待機列に入る
GET/api/matching今の状態を聞く(待機中 / 成立)
DELETE/api/matching待機をやめる

成立の通知はプッシュではなく、クライアントがGETで状態を聞きに来るポーリング方式です。

マッチングの全体の流れ。POSTで待機列に入り、その後はGETのポーリングのたびにRedisでルームを作れるか試し、人数が揃ったらPostgreSQLに会話を作る図 クライアント APIサーバー Redis PostgreSQL POST /api/matching 待機列に追加 ルームを作れるか試す 202 waiting 成立するまでくり返す GET /api/matching ルームを作れるか試す 人数が揃ったとき 参加者リスト 会話を作成 会話IDを書き込む waiting / matched

ポイントは、POSTのときもGETのときも「ルームを作れるか試す」ことです。マッチング専用のワーカーを別に動かすのではなく、利用者のリクエストそのものがマッチングの引き金になっています。

待機列はRedisのsorted set

待機列はRedisのsorted setで持っています。sorted setは、重複しない要素それぞれにscoreという数値を持たせ、scoreの順に並べて保持するデータ型です(公式ドキュメント)。

  • キー: matching:queue:<トピックID>:<人数>
  • member: 利用者のセッショントークン
  • score: 待機を始めた時刻(ミリ秒)
matching:queue:42:3   (トピック42の3人ルーム待ち)

  score (待ち始め)     member
  ─────────────────   ──────────
  1727300000000       token-A   ← 一番古い
  1727300015000       token-B
  1727300040000       token-C

scoreが時刻なので、古い順に取り出す(ZPOPMIN)だけで到着順のマッチングになります。さらに「いつから待っているか」が列の中に入っているので、次のような判定もこのsorted setだけで完結します。

  • 5分以上待った人を列から消す(タイムアウト)
  • 先頭の人が60秒以上待っているか調べる(フォールバック)

Listで持つ案も考えましたが、要素に時刻を持てないので、タイムアウト判定のために別のキーを見に行く必要が出てきます。そこでsorted setを選びました。

利用者の状態は3つだけ

1人の利用者は、常に次の3つの状態のどれか1つにいます。

利用者の状態遷移。IdleからPOSTでWaitingへ、人数が揃うとMatchedへ進み、DELETEやタイムアウトでIdleに戻る図 Idle Waiting Matched POST(待機列に入る) DELETE / 5分でタイムアウト 人数が揃う DELETE(ルームを離れる)

それぞれRedisのキーで表しています。

状態Redisのキー
Idle(何もしていない)キーなし
Waiting(待機中)matching:waiting:<token>+待機列のmember
Matched(ルーム成立)matching:room:<token>

「1人が2つのルームに入らない」というのは、言い換えるとこの状態の移動が必ず1ステップで起きることです。

二重割り当てを防ぐ:Luaスクリプトで一気にやる

サーバーが2台あると、こんな競合が起こりえます。

2台のサーバーが同じ待機列をほぼ同時に読み、それぞれがA、B、Cを取り出して別のルームに入れてしまう競合の図 サーバー1 Redis サーバー2 待機列を読む(A, B, C) 待機列を読む(A, B, C) A, B, Cをルーム1へ A, B, Cをルーム2へ A, B, Cが2つのルームに入ってしまう

問題は、「読む」と「取り出す」の間に別のサーバーが割り込めることです。

そこで、ルームを作る処理をまるごと1本のLuaスクリプトにしてRedis上で実行しています。RedisはLuaスクリプトを実行している間、他のコマンドを挟まないので、スクリプト全体が1つの操作として扱われます。RedisでのLuaスクリプトの書き方や実行のされ方は、公式ドキュメントにまとまっています。

スクリプトがやっていることは、ざっくりこれだけです。

-- 1. 待ち時間が切れた人を列から消す
redis.call('ZREMRANGEBYSCORE', queueKey, '-inf', timeoutBefore)

-- 2. 何人取り出すか決める
local waiting = redis.call('ZCARD', queueKey)
local take = 0
if waiting >= size then
  take = size                      -- 人数が揃っている
elseif size == 3 and waiting >= 2 then
  -- 3人ルームで2人しかいない → 先頭が60秒以上待っていれば2人で成立
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
  if tonumber(oldest[2]) <= fallbackBefore then
    take = 2
  end
end
if take == 0 then return {} end

-- 3. 古い順に取り出して、各参加者を「待機中」から「ルームあり」へ移す
local participants = {}
local popped = redis.call('ZPOPMIN', queueKey, take)
for i = 1, #popped, 2 do
  local token = popped[i]
  redis.call('DEL', waitingPrefix .. token)
  -- roomPrefix .. token にルーム情報を書き込む(フィールドは省略)
  table.insert(participants, token)
end
return participants

「期限切れの掃除 → 人数の判定 → 取り出し → 状態の書き換え」が、途中で割り込まれずにひとまとまりで実行されます。そのため2台のサーバーが同時に実行しても、あとから実行されたほうが見る列からは、先に取り出された人がもう消えています。

WATCH / MULTIの楽観ロックも候補でしたが、競合したらアプリ側でリトライが必要です。混んでいるときほど競合が増えてリトライが失敗しやすい、というのが嫌でLuaにしました。

待機列に入る処理(Enqueue)も同じくLuaスクリプトにしていて、「すでにルームがある人」「別の列で待っている人」はここで弾いています。

3人ルームのフォールバック

3人ルームは、3人目が来ないといつまでも始まりません。深夜やサービスの初期は人が少ないので、ずっと待たされて離脱…となりがちです。

そこで、3人ルームの列の先頭が60秒以上待っていて、2人以上いるなら、2人で始めることにしました。

ルームを作れるかの判定フロー。選んだ人数が揃えばその人数で成立し、3人ルームで2人以上いて先頭が60秒以上待っていれば先頭の2人で成立し、それ以外は待機を続ける図 ルームを作れるか試す 選んだ人数ぶん 揃っている? はい その人数で成立 いいえ 3人ルームで 2人以上いる? いいえ はい 先頭が60秒以上 待っている? いいえ 待機を続ける はい 先頭の2人で成立

成立したときのAPIレスポンスには実際の人数(room_type)が入るので、クライアントはその値を見て画面を描き分けます。

ちなみにこの判定も、待っている人自身のGETポーリングが引き金です。「60秒経った」ことを知らせるタイマーは無く、次に誰かが状態を聞きに来たときに成立します。

RedisとPostgreSQLの間で落ちたら?

ルームを取り出したあと、会話はPostgreSQLのconversationsテーブルに書き込みます。ここで「Redisからは取り出したのに、DBに書く前にサーバーが落ちた」ケースが問題になります。

これに対しては2段構えにしています。

Luaで取り出したあとPostgreSQLに会話を作る流れ。成功すればroomキーに会話IDを書いてTTLを延ばし、失敗すればroomキーを消し、サーバーが落ちたら30秒のTTLでroomキーが自然に消える図 Luaで取り出す roomキーに30秒のTTL PostgreSQLに 会話を作成 成功 会話IDを書き込む TTLを10分に延長 失敗 roomキーを削除 待機前の状態に戻る サーバーが落ちた roomキーが消える 30秒のTTLで自然に
  • DBへの書き込みがエラーになった → roomキーを消して、参加者を待機前の状態に戻す
  • サーバーごと落ちた → roomキーには最初から30秒のTTLを付けてあるので、自然に消えて元に戻る

どちらの場合も、利用者から見ると「待機が1回無駄になった」だけで、もう一度並び直せます。会話が二重にできるより、待機が1回無駄になるほうがマシ、という判断です。

また、roomキーはあるけれど会話IDがまだ書かれていない間は、クライアントにはwaitingと返しています。まだ入れる会話が無いからです。

待機列に入る前のチェック

最後に、待機列に入る前に行っているチェックを並べておきます。

  1. 人数が2か3か
  2. レート制限(同じIPから短時間に何度も並ばせない)→ 429とRetry-After
  3. BANリストに載っていないか → 403
  4. トピックが存在して有効か → 400
  5. すでに別の列で待っていないか / すでにルームがないか → 409

BANリストやトピックの情報は別モジュールの持ち物です。matchingモジュールはインターフェース越しに問い合わせるだけで、相手のDBテーブルは直接触らないようにしています。

まとめ

  • 待機列はRedisのsorted set(score=待ち始めた時刻)
  • ルームの取り出しは1本のLuaスクリプトで原子的に行い、二重割り当てを防ぐ
  • 3人ルームは60秒でフォールバックして2人で開始
  • DBに書く前に落ちても、30秒のTTLで自然に元に戻る
  • 専用のワーカーは持たず、利用者のポーリングがマッチングの引き金

「複数のサーバーで同じ待機列を共有しつつ、二重割り当てを起こさない」という要件に対して、Redisのsorted set+Luaはかなりシンプルに書けて気に入っています。

今は成立をポーリングで知らせています。前回の記事に書いたとおり、待機中の全員分の接続を抱えないための選択です。成立をもっと早く知らせたくなったら、WebSocketでpushするのが次の改善候補です。