前回は、高頻度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を中心に、異なる周期の処理がそれぞれ巡回する構造になっている。

- Userの操作:
POST /api/app/ridesがライドを作成し、MATCHING状態でDBへ保存する。ここではMatcherを直接呼ばず、ユーザーには202 Acceptedを返す。 - Matcherの巡回: 専用コンテナが
GET /api/internal/matchingを呼び、レスポンス完了後に500ms待って再び呼ぶ。待機ライドと空きISUを結び、rides.chair_idを更新する。正常時のレスポンスは本文なしの204 No Contentである。 - 通知の確認: UserとISUはそれぞれ通知APIをポーリングし、DBへ反映された割り当てや状態遷移を知る。レスポンスには
retry_after_ms: 30が含まれる。
ゲームに例えると、Userはクエストを掲示板へ置き、MatcherというNPCが定期的に掲示板を巡回して空いているISUへ割り当てる。
なぜ1件ずつの処理が問題になるのか
Matcherの初期ハンドラー(webapp/nodejs/src/internal_handlers.ts)は、待機時間が最も長いライドを 1件だけ 取得していた。
SELECT *
FROM rides
WHERE chair_id IS NULL
ORDER BY created_at
LIMIT 1;
その後、アクティブな椅子を ORDER BY RAND() で1台選び、その椅子に未完了ライドがないか確認する。使用中だった場合は最大10回まで抽選をやり直す。空き椅子が見つかれば rides.chair_id を更新し、1回のMatcher処理を終了する。
Matcherは500ms間隔で巡回しているため、1回に1件しか処理できないと 理論上の処理能力は最大でも約2件/秒 に制限される。ベンチマーク中に短時間で大量の配車依頼が来ると、空き椅子があっても次の周期まで待たされるライドが溜まってしまう。
3. 改善方針と実装
なぜクエリ増大リスクを承知でこの変更を行ったのか?
今回の変更コードは、10件のライドに対して最大10回の空き判定SQLを直列に投げるため、最悪100回以上のクエリが発行される(N+1問題)というレイテンシ悪化のリスクを孕んでいる。
それでもあえてこの最小限の実装を行った理由は、「変更要因を単一化し、仮説を正確に検証するため」 である。
ISUCONのようなチューニングでは、以下のような変更を一気にまとめてやりがちである:
- 待機ライドを複数取得する
- 空き椅子を一括取得(JOINやサブクエリ)してクエリ数を減らす
- 距離計算を入れて近傍マッチングにする
しかし、これらを同時に変更してしまうと、
- ベンチマークで失敗した際に、どこにバグがあるのか原因特定が難しくなる
- スコアが変動した際に、どの変更がスコア向上(または悪化)に寄与したのか切り分けられない
そこで今回は、「そもそもMatcherの1件処理が待機列のボトルネックになっているのか?」という仮説だけを確かめるため、椅子の抽選ロジックやSQLには一切手を加えず、「1件処理から10件ループ処理への変更」のみ を適用した。
実装コードの比較(Before / After)
変更箇所は webapp/nodejs/src/internal_handlers.ts の internalGetMatching ハンドラーである。
改善前(Before: 1件のみ処理)
export const internalGetMatching = async (ctx: Context<Environment>) => {
// 待機ライドを1件だけ取得
const [[ride]] = await ctx.var.dbConn.query<Array<Ride & RowDataPacket>>(
"SELECT * FROM rides WHERE chair_id IS NULL ORDER 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>>(
"SELECT * FROM chairs INNER JOIN (SELECT id FROM chairs WHERE is_active = TRUE ORDER 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>
>(
"SELECT COUNT(*) = 0 FROM (SELECT COUNT(chair_sent_at) = 6 AS completed FROM ride_statuses WHERE ride_id IN (SELECT id FROM rides WHERE chair_id = ?) GROUP BY ride_id) is_completed WHERE completed = FALSE",
[matched.id],
);
empty = !!result["COUNT(*) = 0"];
if (empty) {
break;
}
}
if (!empty) {
return ctx.body(null, 204);
}
// 1件だけ割り当てて終了
await ctx.var.dbConn.query("UPDATE rides SET chair_id = ? WHERE 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>>(
"SELECT * FROM rides WHERE chair_id IS NULL ORDER 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>>(
"SELECT * FROM chairs INNER JOIN (SELECT id FROM chairs WHERE is_active = TRUE ORDER 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>
>(
"SELECT COUNT(*) = 0 FROM (SELECT COUNT(chair_sent_at) = 6 AS completed FROM ride_statuses WHERE ride_id IN (SELECT id FROM rides WHERE chair_id = ?) GROUP BY ride_id) is_completed WHERE completed = FALSE",
[matched.id],
);
empty = !!result["COUNT(*) = 0"];
if (empty) {
break;
}
}
if (!empty) {
return ctx.body(null, 204);
}
await ctx.var.dbConn.query("UPDATE rides SET chair_id = ? WHERE id = ?", [
matched.id,
ride.id,
]);
}
return ctx.body(null, 204);
};
diff で見る変更点
- const [[ride]] = await ctx.var.dbConn.query<Array<Ride & RowDataPacket>>(
- "SELECT * FROM rides WHERE chair_id IS NULL ORDER BY created_at LIMIT 1",
+ const [rides] = await ctx.var.dbConn.query<Array<Ride & RowDataPacket>>(
+ "SELECT * FROM rides WHERE chair_id IS NULL ORDER 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("UPDATE rides SET chair_id = ? WHERE id = ?", [
matched.id,
ride.id,
]);
+ }
実装のポイント
- 同一呼び出し内での重複割り当て防止:
ライド作成時には
ride_statusesへMATCHINGが記録されている。1件目を割り当ててUPDATE rides SET chair_id = ?を実行すると、次のライドの空き判定SQLではその椅子が「未完了ライドあり」と判定される。そのため、既存の判定SQLをそのまま使い回すだけで二重割り当てを防ぐことができる。 - 直列クエリとレイテンシのトレードオフ: 各ライドで空き椅子が見つかるまで個別にSQLを発行するため、待機ライド数が増えるほどMatcher 1回の処理時間は長くなる。この副作用がベンチマーク全体にどう影響するかを次の章で計測・評価する。
4. ベンチマーク結果と分析
計測結果
結果の分析
① マッチング待ち時間は狙い通り激減
待機ライドをまとめて処理できるようになったことで、マッチング待ちへの不満は 33.3% → 5.6〜10.0% へ激減した。Matcherの1件処理が待機列のボトルネックになっていたという仮説は正しかった。
② スコアが伸びない理由は「お迎え時間」
マッチングが速くなっても、スコアは1,611〜1,873点と改善前(1,758点)から大きく伸びなかった。
原因は、椅子の選択が依然として ORDER 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 が入っていた。

2. 待機ライドの発生とDB接続の確認
未割り当てのライド数を確認する。
SELECT COUNT(*) AS waiting_rides FROM rides WHERE chair_id IS NULL;

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

画面は「マッチング中」になった。しかし、GUIツール(Beekeeper Studio)で再確認しても waiting_rides は0件のままだった。
原因は、Beekeeper StudioのSSH Tunnelが無効になっており、手元のMacの別DBを見ていたためだった。SSH Tunnelを有効化して本番DBへ再接続した。

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

3. 空き椅子ゼロ問題と手動アクティブ化
この待機5件の状態でMatcher APIを手動で1回呼んだが、204 No Content が返ったにもかかわらず waiting_rides は5件のまま減らなかった。

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

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

4. 複数件マッチングの動作と抽選の限界
この状態でMatcher APIを1回呼ぶと、waiting_rides は5件から4件へ減った(1件割り当て成功)。

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

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

6. まとめと次の一手
Matcherを1件処理から最大10件処理へ変更したことで、マッチング待ちへの不満は33.3%から5.6〜10.0%へと劇的に改善 した。待機列を素早く捌くという目的は達成できた。
しかし、競技スコアは伸びず、お迎え時間への不満(85〜94%)が残った。「早くマッチングすること」と「近い椅子を割り当てること」は別問題 であり、ランダム配車を続ける限りスコアの頭打ちは解消できない。
次の改善ステップ
次は、この複数件処理をベースに 「近傍マッチング」 へ移行する。 ユーザーの乗車位置と椅子の現在位置から距離を計算し、最も近い空き椅子を優先的に割り当てることで、お迎え時間の短縮とスコアアップを目指す。