【ISUCON14】1件ずつ処理するMatcherを複数件対応にするとスコアは伸びるのか

前回は、高頻度APIの裏で実行されていたSQLへ複合インデックスを追加した。MySQLの総検査行数は1.5685億行から989万行へ減り、総実行時間も250秒から111秒へ短縮した。

しかし、単発のベンチマークスコアは2,225点から1,685点へ低下した。DBが軽くなっても、椅子とユーザーがマッチし、ライドが完了しなければスコアにはつながらない。

今回は次のボトルネック候補であるMatcherに注目し、「1回の実行で1件だけ処理する構造」を複数件処理へ変更する。近傍マッチングにはまだ手を入れず、処理件数だけを変えて効果を測定する。


TL;DR

待機ライドを1回1件ではなく最大10件処理するよう変更したところ、マッチング待ちへの不満は 33.3%から5.6〜10.0% へ減少した。一方、改善後2回のスコアは 1,611点と1,873点 で、改善前の1,758点を安定して上回らなかった。

複数件化は待機列の解消には効いたが、ランダムに椅子を選ぶ限り、お迎え時間への不満は 85.0〜94.4% 残った。処理量を増やすだけではなく、次は「近い椅子を選ぶ」という配車品質の改善が必要である。


1. 前回の振り返りと今回のターゲット

前回までの改善により、DBの負荷は劇的に低下した。

  • MySQL総実行時間: 250秒 → 111秒(-55.6%)
  • 総検査行数: 156.85 M行 → 9.89 M行(-93.7%)
  • ベンチマークスコア: 2,225点 → 1,685点

DB指標は大幅に良くなったものの、競技スコアは伸びなかった。ISUCON14(ISURIDE)の加点対象は「完了したライドの移動距離」であり、いくらDBが高速化しても、ユーザーと椅子がマッチングして実際に移動を完了しなければ点数にならない。

そこで、DB改善後のalpログや不満要因を再確認したところ、「マッチング待ち時間」に対するユーザーの不満が33.3% に達していた。DBの詰まりが解消された結果、次にMatcherの処理能力がボトルネックとして浮上してきた。


2. ISURIDEの構造とMatcherの現状

ISURIDEを動かす3つのループ

ISURIDEの初期実装は、ユーザーの配車依頼がMatcherを直接起動するイベント駆動型ではない。DBを中心に、異なる周期の処理がそれぞれ巡回する構造になっている。

ISURIDEを動かす3つのループ
  1. Userの操作: POST /api/app/rides がライドを作成し、MATCHING 状態でDBへ保存する。ここではMatcherを直接呼ばず、ユーザーには 202 Accepted を返す。
  2. Matcherの巡回: 専用コンテナが GET /api/internal/matching を呼び、レスポンス完了後に500ms待って再び呼ぶ。待機ライドと空きISUを結び、rides.chair_id を更新する。正常時のレスポンスは本文なしの 204 No Content である。
  3. 通知の確認: UserとISUはそれぞれ通知APIをポーリングし、DBへ反映された割り当てや状態遷移を知る。レスポンスには retry_after_ms: 30 が含まれる。

ゲームに例えると、Userはクエストを掲示板へ置き、MatcherというNPCが定期的に掲示板を巡回して空いているISUへ割り当てる。

なぜ1件ずつの処理が問題になるのか

Matcherの初期ハンドラー(webapp/nodejs/src/internal_handlers.ts)は、待機時間が最も長いライドを 1件だけ 取得していた。

S​E​L​E​C​T *
F​R​O​M rides
W​H​E​R​E chair_id IS NULL
O​R​D​E​R BY created_at
LIMIT 1;

その後、アクティブな椅子を O​R​D​E​R BY RAND() で1台選び、その椅子に未完了ライドがないか確認する。使用中だった場合は最大10回まで抽選をやり直す。空き椅子が見つかれば rides.chair_id を更新し、1回のMatcher処理を終了する。

Matcherは500ms間隔で巡回しているため、1回に1件しか処理できないと 理論上の処理能力は最大でも約2件/秒 に制限される。ベンチマーク中に短時間で大量の配車依頼が来ると、空き椅子があっても次の周期まで待たされるライドが溜まってしまう。


3. 改善方針と実装

なぜクエリ増大リスクを承知でこの変更を行ったのか?

今回の変更コードは、10件のライドに対して最大10回の空き判定SQLを直列に投げるため、最悪100回以上のクエリが発行される(N+1問題)というレイテンシ悪化のリスクを孕んでいる。

それでもあえてこの最小限の実装を行った理由は、「変更要因を単一化し、仮説を正確に検証するため」 である。

ISUCONのようなチューニングでは、以下のような変更を一気にまとめてやりがちである:

  1. 待機ライドを複数取得する
  2. 空き椅子を一括取得(J​O​I​Nやサブクエリ)してクエリ数を減らす
  3. 距離計算を入れて近傍マッチングにする

しかし、これらを同時に変更してしまうと、

  • ベンチマークで失敗した際に、どこにバグがあるのか原因特定が難しくなる
  • スコアが変動した際に、どの変更がスコア向上(または悪化)に寄与したのか切り分けられない

そこで今回は、「そもそもMatcherの1件処理が待機列のボトルネックになっているのか?」という仮説だけを確かめるため、椅子の抽選ロジックやSQLには一切手を加えず、「1件処理から10件ループ処理への変更」のみ を適用した。

実装コードの比較(Before / After)

変更箇所は webapp/nodejs/src/internal_handlers.tsinternalGetMatching ハンドラーである。

改善前(Before: 1件のみ処理)

export const internalGetMatching = async (ctx: Context<Environment>) => {
  // 待機ライドを1件だけ取得
  const [[ride]] = await ctx.var.dbConn.query<Array<Ride & RowDataPacket>>(
    "S​E​L​E​C​T * F​R​O​M rides W​H​E​R​E chair_id IS NULL O​R​D​E​R BY created_at LIMIT 1",
  );
  if (!ride) {
    return ctx.body(null, 204);
  }

  let matched!: Chair & RowDataPacket;
  let empty = false;
  // 空き椅子が見つかるまで最大10回ランダム抽選
  for (let i = 0; i < 10; i++) {
    [[matched]] = await ctx.var.dbConn.query<Array<Chair & RowDataPacket>>(
      "S​E​L​E​C​T * F​R​O​M chairs INNER J​O​I​N (S​E​L​E​C​T id F​R​O​M chairs W​H​E​R​E is_active = TRUE O​R​D​E​R BY RAND() LIMIT 1) AS tmp ON chairs.id = tmp.id LIMIT 1",
    );
    if (!matched) {
      return ctx.body(null, 204);
    }
    const [[result]] = await ctx.var.dbConn.query<
      Array<{ "COUNT(*) = 0": number } & RowDataPacket>
    >(
      "S​E​L​E​C​T COUNT(*) = 0 F​R​O​M (S​E​L​E​C​T COUNT(chair_sent_at) = 6 AS completed F​R​O​M ride_statuses W​H​E​R​E ride_id IN (S​E​L​E​C​T id F​R​O​M rides W​H​E​R​E chair_id = ?) G​R​O​U​P BY ride_id) is_completed W​H​E​R​E completed = FALSE",
      [matched.id],
    );
    empty = !!result["COUNT(*) = 0"];
    if (empty) {
      break;
    }
  }
  if (!empty) {
    return ctx.body(null, 204);
  }

  // 1件だけ割り当てて終了
  await ctx.var.dbConn.query("U​P​D​A​T​E rides SET chair_id = ? W​H​E​R​E id = ?", [
    matched.id,
    ride.id,
  ]);

  return ctx.body(null, 204);
};

改善後(After: 最大10件をループ処理)

export const internalGetMatching = async (ctx: Context<Environment>) => {
  // 待機中のライドを最大10件まとめて取得
  const [rides] = await ctx.var.dbConn.query<Array<Ride & RowDataPacket>>(
    "S​E​L​E​C​T * F​R​O​M rides W​H​E​R​E chair_id IS NULL O​R​D​E​R BY created_at LIMIT 10",
  );
  if (rides.length === 0) {
    return ctx.body(null, 204);
  }

  // ★外側にループを追加し、取得した最大10件を順番に処理
  for (const ride of rides) {
    let matched!: Chair & RowDataPacket;
    let empty = false;
    for (let i = 0; i < 10; i++) {
      [[matched]] = await ctx.var.dbConn.query<Array<Chair & RowDataPacket>>(
        "S​E​L​E​C​T * F​R​O​M chairs INNER J​O​I​N (S​E​L​E​C​T id F​R​O​M chairs W​H​E​R​E is_active = TRUE O​R​D​E​R BY RAND() LIMIT 1) AS tmp ON chairs.id = tmp.id LIMIT 1",
      );
      if (!matched) {
        return ctx.body(null, 204);
      }
      const [[result]] = await ctx.var.dbConn.query<
        Array<{ "COUNT(*) = 0": number } & RowDataPacket>
      >(
        "S​E​L​E​C​T COUNT(*) = 0 F​R​O​M (S​E​L​E​C​T COUNT(chair_sent_at) = 6 AS completed F​R​O​M ride_statuses W​H​E​R​E ride_id IN (S​E​L​E​C​T id F​R​O​M rides W​H​E​R​E chair_id = ?) G​R​O​U​P BY ride_id) is_completed W​H​E​R​E completed = FALSE",
        [matched.id],
      );
      empty = !!result["COUNT(*) = 0"];
      if (empty) {
        break;
      }
    }
    if (!empty) {
      return ctx.body(null, 204);
    }
    await ctx.var.dbConn.query("U​P​D​A​T​E rides SET chair_id = ? W​H​E​R​E id = ?", [
      matched.id,
      ride.id,
    ]);
  }

  return ctx.body(null, 204);
};

diff で見る変更点

-  const [[ride]] = await ctx.var.dbConn.query<Array<Ride & RowDataPacket>>(
-    "S​E​L​E​C​T * F​R​O​M rides W​H​E​R​E chair_id IS NULL O​R​D​E​R BY created_at LIMIT 1",
+  const [rides] = await ctx.var.dbConn.query<Array<Ride & RowDataPacket>>(
+    "S​E​L​E​C​T * F​R​O​M rides W​H​E​R​E chair_id IS NULL O​R​D​E​R BY created_at LIMIT 10",
   );
-  if (!ride) {
+  if (rides.length === 0) {
     return ctx.body(null, 204);
   }

+  for (const ride of rides) {
     let matched!: Chair & RowDataPacket;
     let empty = false;
     for (let i = 0; i < 10; i++) {
       // ... 既存のランダム抽選と空き判定(変更なし) ...
     }
     if (!empty) {
       return ctx.body(null, 204);
     }
     await ctx.var.dbConn.query("U​P​D​A​T​E rides SET chair_id = ? W​H​E​R​E id = ?", [
       matched.id,
       ride.id,
     ]);
+  }

実装のポイント

  1. 同一呼び出し内での重複割り当て防止: ライド作成時には ride_statusesMATCHING が記録されている。1件目を割り当てて U​P​D​A​T​E rides SET chair_id = ? を実行すると、次のライドの空き判定SQLではその椅子が「未完了ライドあり」と判定される。そのため、既存の判定SQLをそのまま使い回すだけで二重割り当てを防ぐことができる。
  2. 直列クエリとレイテンシのトレードオフ: 各ライドで空き椅子が見つかるまで個別にSQLを発行するため、待機ライド数が増えるほどMatcher 1回の処理時間は長くなる。この副作用がベンチマーク全体にどう影響するかを次の章で計測・評価する。

4. ベンチマーク結果と分析

計測結果

指標 改善前(1件処理) 改善後(最大10件処理) 変化 ベンチマークスコア 1,758 1,611 / 1,873 安定した向上は見られず マッチ待ち不満 33.3% 5.6% / 10.0% 約25ポイント大幅改善! お迎え時間への不満 95.8% 94.4% / 85.0% 依然として高水準 Matcher呼び出し回数 99 96 / 95 ほぼ同じ Matcher合計時間 26.199秒 37.264秒 / 25.664秒 1回目は増加 Matcher平均時間 0.265秒 0.388秒 / 0.270秒 1回目は悪化 Matcher最大時間 3.376秒 13.147秒 / 2.506秒 大きなスパイクが発生 MySQL総実行時間 97秒 112秒 / 105秒 微増 MySQL総検査行数 8.93 M行 9.05 M行 / 8.98 M行 ほぼ横ばい

結果の分析

① マッチング待ち時間は狙い通り激減

待機ライドをまとめて処理できるようになったことで、マッチング待ちへの不満は 33.3% → 5.6〜10.0% へ激減した。Matcherの1件処理が待機列のボトルネックになっていたという仮説は正しかった。

② スコアが伸びない理由は「お迎え時間」

マッチングが速くなっても、スコアは1,611〜1,873点と改善前(1,758点)から大きく伸びなかった。 原因は、椅子の選択が依然として O​R​D​E​R BY RAND()(ランダム)な点にある。マッチング自体は即座に行われても、遠く離れた椅子が割り当てられるため、乗車地点に到着するまでに長い時間がかかってしまう。実際、お迎え時間への不満は85.0〜94.4% も残っていた。

③ 直列ループによるMatcher自身の負荷増大

改善後1回目の実行では、Matcherの最大処理時間が 13.147秒 に跳ね上がった。10件のライドに対して最大10回の空き判定SQLを直列実行するため、Matcherハンドラー自体のレイテンシが悪化する場合があった。


5. 【深掘りハンズオン】実機で見るMatcherの巡回とDB内部挙動

複数件処理が実際のDB上でどのように動き、どのような限界を抱えているのかを実機で手動検証した。

1. Matcher APIの仕様と巡回の停止

まず、Matcher APIを手動で呼び出してみた。

curl -i http://localhost:8080/api/internal/matching

レスポンスは HTTP/1.1 204 No Content だった。正常にマッチした場合も、待機ライドがない場合も、Matcherは本文を返さず 204 を返す。呼び出し側へ割り当て結果を返すのではなく、DBの rides.chair_id を更新するAPIだからである。

次に、自動巡回を止めてDB状態を固定するため、Matcherコンテナを停止した。

docker compose -f development/compose-node.yml stop matcher

この状態で rides テーブルの直近10件を確認すると、すべての行に chair_id が入っていた。

ridesテーブルでUserと割り当て済みISUの対応を確認

2. 待機ライドの発生とDB接続の確認

未割り当てのライド数を確認する。

S​E​L​E​C​T COUNT(*) AS waiting_rides F​R​O​M rides W​H​E​R​E chair_id IS NULL;
Matcher停止直後の待機ライド数は0件

waiting_rides は0件だった。そこでMatcherを止めたままテストユーザーから配車依頼を作成した。

Matcherを停止した状態で配車依頼がマッチング中になる

画面は「マッチング中」になった。しかし、GUIツール(Beekeeper Studio)で再確認しても waiting_rides は0件のままだった。

原因は、Beekeeper StudioのSSH Tunnelが無効になっており、手元のMacの別DBを見ていたためだった。SSH Tunnelを有効化して本番DBへ再接続した。

SSH Tunnelを有効にして公開Webアプリと同じDBのテストユーザーを確認

正しいDBで確認すると、waiting_rides は5件(今回のテスト1件+過去検証の残り4件)存在していた。

Matcher停止中の正しいDBで待機ライド5件を確認 待機ライド5件のユーザー名と作成時刻を確認

3. 空き椅子ゼロ問題と手動アクティブ化

この待機5件の状態でMatcher APIを手動で1回呼んだが、204 No Content が返ったにもかかわらず waiting_rides は5件のまま減らなかった。

Matcher APIの204応答後も待機ライドが5件残る

椅子の状態を確認すると、アクティブな椅子25台すべてが使用中(進行中ライドあり)であり、空き椅子が0台だった。

全525台のうちアクティブな椅子が25台あることを確認 アクティブな25台すべてが使用中で空き椅子が0台

ベンチマーカーが動いていない状態では既存ライドが完了しないため、空き椅子が生まれない。そこで、待機中の非アクティブな椅子を5台選び、一時的にアクティブ化した。

進行中ライドを持たない非アクティブな椅子を5台選択 検証用の空き椅子5台を一時的にアクティブ化

4. 複数件マッチングの動作と抽選の限界

この状態でMatcher APIを1回呼ぶと、waiting_rides は5件から4件へ減った(1件割り当て成功)。

1回のMatcher実行で待機ライドが5件から4件へ減少

さらにもう一度呼ぶと、今度は waiting_rides が4件から1件へ減り、1回の呼び出しで3件がまとめて割り当てられた。

2回目のMatcher実行では待機ライドが4件から1件へ減少

ここで注目すべきは、「1回目は1件しか処理されず、2回目は3件処理された」 点である。 現在の実装では、途中の1件でランダム抽選(10回)に失敗すると、return ctx.body(null, 204) でループ全体を抜けてしまう。そのため、後ろに空き椅子を待つライドがあっても処理が中断される。

検証後は、一時アクティブ化した椅子を元に戻し、Matcherコンテナを再開した。

検証用に変更した5台を非アクティブへ戻して復元

6. まとめと次の一手

Matcherを1件処理から最大10件処理へ変更したことで、マッチング待ちへの不満は33.3%から5.6〜10.0%へと劇的に改善 した。待機列を素早く捌くという目的は達成できた。

しかし、競技スコアは伸びず、お迎え時間への不満(85〜94%)が残った。「早くマッチングすること」と「近い椅子を割り当てること」は別問題 であり、ランダム配車を続ける限りスコアの頭打ちは解消できない。

次の改善ステップ

次は、この複数件処理をベースに 「近傍マッチング」 へ移行する。 ユーザーの乗車位置と椅子の現在位置から距離を計算し、最も近い空き椅子を優先的に割り当てることで、お迎え時間の短縮とスコアアップを目指す。