29分

ISUCON14を95,656点から567,276点まで改善した記録 — isutoolsで改善点を整理しながら

isucongoperformancemysqlisutools

ISUCON14の参考環境で、Go実装のスコアを 95,656点から567,276点 まで改善しました。最終runはpass=trueで、ベンチマーカーのerror mapはmap[17:6 24:1]でした。542,441点を記録した版からさらに4.6%増えています。

この記事では、自作の計測ツールisutoolsで何を観測し、どの修正を選び、スコアとcorrectnessがどう変わったかを時系列で整理します。50万点を超えていてもcorrectnessでfailしたrunは採用していません。スコアだけでなく、採否の理由も残します。

ISUCON14のスコア推移。合格した採用runを緑の線、correctnessでfailした実験を赤い印で示しています
ISUCON14のスコア推移。合格した採用runを緑の線、correctnessでfailした実験を赤い印で示しています

ベンチマークには揺れがあるため、1回の差だけを厳密な因果効果とは扱いません。一方、各変更を1コミットに分け、1修正ごとにベンチを走らせ、pass=trueを確認してからartifactを保存しています。この運用により、少なくとも「どのコードで何が観測されたか」は追跡できます。

最終結果

項目結果
復旧後の比較可能なbaseline95,656
最終スコア567,276
baseline比+493.0%
直前の有効最高556,898
直前比+1.86%
correctnesspass=true
app commitc8a5d4a
artifact ID20260806-225651.942413996-000001

最終版では、POST /api/chair/coordinateを191,528件処理しながら平均2.2msに収めています。DB pool waitは65,991件、1回あたり平均14.0msでした。CPUはbusy 65.2%、idle 34.7%で、CPUを使い切った状態ではありませんでした。

計測環境

今回の構成はprivate-isuで使ったDocker構成ではなく、ISUCON14の参考環境をWSL2上でsystemd管理したものです。

項目構成
applicationGo、/home/isucon/webapp/go
reverse proxynginx、TLS終端、upstreamはUNIX domain socket
databaseMySQL
serviceisuride-go.serviceisuride-matcher.service
benchmark/home/isucon/bench run
isutools127.0.0.1:19191、SSH port forwardで閲覧

ベンチマーカーのpayment mockはrunごとに未使用ポートを割り当てています。開始直後にss -ltnpでlistenを確認し、前回プロセスやポート競合の影響を除いています。

isutoolsで残したもの

isutoolsは、SQL、HTTP、nginx access log、DB pool、プロセスCPU、host CPU、DB schema、query planを1つのHTMLに保存します。ベンチ終了後にスコアを付けて保存すると、実行時刻、revision、score、diffへのリンクが一覧に残ります。

isutoolsの実行履歴一覧。542,441点までのスコアとrevisionが新しい順に並んでいます
isutoolsの実行履歴一覧。542,441点までのスコアとrevisionが新しい順に並んでいます

今回の実行履歴には、採用runだけでなく退行やfailも残しています。失敗した実験を消さないことで、「同じ案を別の名前でもう一度試す」ことを避けられました。

最終レポートの先頭には、HTTP demand、SQL demand、5xx/499、CPU、DB pool、SQL row efficiency、Host I/Oを同じ表で表示しています。

542,441点runのisutools Bottleneck Overview。HTTP、SQL、CPU、DB pool、I/Oを同じ表で確認できます
542,441点runのisutools Bottleneck Overview。HTTP、SQL、CPU、DB pool、I/Oを同じ表で確認できます

この表は根本原因を自動で断定するものではありません。たとえば542,441点runの最大HTTP累計時間はnotificationですが、現在のnotificationは変更待ちのlong pollingです。並列に待機した時間の合計が大きいため、累計時間だけをCPU需要と解釈すると判断を誤ります。CPU、DB wait、request count、状態通知遅延を併記して読みます。

スコアの時系列

主要な合格runを抜き出すと、次のようになります。

段階主な変更scoregate
0計測・サービス復旧後95,656pass
1rolling Hungarian102,260pass
2matcher入力cache149,780pass
3nearby位置・availability整理210,955pass
4距離correctness修正218,468pass
5notification response cache236,006pass
6matcher周期を100msへ285,746pass
7coordinate transaction短縮309,372pass
8owner距離の増分管理325,004pass
9ride statusをmemoryから通知359,732pass
10chair ACK後にnearby公開392,520pass
11matcher周期を10msへ437,852pass
12payment transaction短縮 + 100ms visibility grace542,441pass
13matching再公開とnearby再公開を分離567,276pass

ここからは、判断が変わった箇所を中心に説明します。

1. SQLとN+1を整理しました

初期段階では、chairschair_locationsridesride_statusescouponsに必要な複合indexを追加しました。GET /api/app/nearby-chairsとowner系endpointのN+1も、まとめて取得するSQLへ変更しました。

この段階では、1本の重いSQLだけでなく、notification pollingから呼ばれる短いSQLの回数も問題でした。1msのSQLでも数十万回実行されれば、DB poolを長時間占有します。以後は「1回の遅さ」と「回数による累計需要」を分けて見ています。

2. 1件ずつのmatcherをrolling Hungarianへ変更しました

初期matcherは500msごとに1 rideだけを割り当てていました。未割当rideとfree chairが複数あっても、割当ての上限は約2件/秒です。

そこで、古い未割当rideとfree chairをwindowで取得し、距離とchair modelの速度をcostにしたHungarian法でまとめて割り当てました。ISURIDEには2地域があるため、遠すぎるchairを無理に割り当てず、一定時間待ったrideだけをstarvation fallbackで救う条件も入れています。

ただし、これはlifelong schedulerではありません。現在は10msごとに現在の候補集合を解くrolling Hungarianです。次の処理は行っていません。

  • 一度確定したassignmentの解除と再割当て
  • busy chairの完了時刻と完了位置の予測
  • 将来到着するrideを含めた時間展開最適化
  • assignment churnを含むcommit horizon

障害物のないManhattan格子なので、A*よりdispatch schedulingの方が先の課題でした。

3. マッチングの軌跡を可視化しました

スコアだけでは、2地域の間でどのような割当てが起きたかを確認しにくいため、agent、pickup、destination、assignment eventを再生できるtrajectory viewerをisutoolsへ追加しました。

ISUCON14のtrajectory viewer。2地域のchair軌跡、pickup、destination、assignmentを同じ画面で再生できます
ISUCON14のtrajectory viewer。2地域のchair軌跡、pickup、destination、assignmentを同じ画面で再生できます

このsnapshotでは、540 chairs、115,875 points、3,678 rides、3,307 assignmentsを表示しています。同じrideの再割当ては0件でした。この結果も、現在の実装がlifelong再最適化ではないことと一致します。

可視化からは、旧costで2地域間の長距離dispatchが発生していることも分かりました。dummy wait列を持つHungarianに変えるとdispatch Manhattan p95は561から25まで改善しましたが、128 rideをDB transaction内で解いたためpool待ちが増え、scoreは187,425まで下がりました。距離costは参考になりましたが、長いtransaction境界は採用していません。

4. notificationをDB pollingから状態通知へ寄せました

ISUCON14の世界では30msが1 tickです。appとchairのnotificationが高頻度で呼ばれるため、同じride statusを毎回DBから組み立てる処理がDB poolを占有していました。

まずride単位のgenerationを持つresponse cacheを入れました。status挿入、chair assignment、ride更新時にgenerationを進め、生成中に状態が変わったresponseを保存しないようにしています。app_sent_atまたはchair_sent_atが未送信の場合はcacheせず、queueを排出してから再利用します。

次に、状態が変わらないnotificationをevent-driven long pollingへ変更しました。変更前の計測ではapp notificationが約155万回、chair notificationが約37.9万回でした。変更後の比較runでは、それぞれ約14.5万回、約5.6万回まで減りました。

SSEへ移行する方法もありますが、今回はJSON APIの契約を保ったままDB pollingを減らしています。ISUCON14公式解説でも、JSON APIの状態でcacheやlong pollingを使う余地が説明されています。

5. coordinateとstatusのDB往復を減らしました

chairはPOST /api/chair/coordinateの応答が返るまで次の移動を始めません。そのため、このendpointは単なる書込負荷ではなく、ride完了数に直結するcritical pathです。

次の順で同期処理を減らしました。

  • INSERT直後に同じ座標rowをSELECTする処理を削除しました。
  • 通常の位置更新から長いtransactionを外しました。
  • 最新座標をprocess memoryに保持しました。
  • latest ride statusを単調増加するmemory stateとして保持しました。
  • initialize時にcache generationを作り直しました。

325,004点runではcoordinateが107,643 calls、平均73.6msでした。最終runでは191,528 calls、平均2.2msです。処理数は77.9%増え、平均latencyは97.0%減りました。

ownerのchair総走行距離も、リクエストごとに全chair_locationsLAGを実行する方式から変更しました。initialize時に一度だけ正確な総距離を作り、以後はcoordinate更新時にManhattan距離を増分加算しています。GET /api/owner/chairsは最終runで平均2.2msでした。

6. matcher周期を100msから10msへ短縮しました

matcher処理自体を軽くした後、poll周期を100msから10msへ短縮しました。ベンチマーカーの30ms tickより細かくrolling windowを更新することで、free chairを次の割当てへ取り込むまでの待ちを減らしています。

この変更で389,614点から437,852点へ上がり、correctnessもpassしました。最終runのGET /api/internal/matchingは1,526 calls、平均19.4ms、p95 134.2msです。

7. paymentをDB transactionの外へ出しました

POST /api/app/rides/:id/evaluationでは、外部payment gatewayへのHTTP requestをDB transaction内で実行していました。payment待ちの間もconnectionとrow lockを保持するため、DB pool waitの大きな要因になります。

修正後は、Idempotency-Keyにride IDを設定してpaymentを先に実行し、成功後だけ短いtransactionを開きます。transaction内では対象rideをFOR UPDATEし、evaluation更新とCOMPLETED確定だけを行います。

この変更単独のraw scoreは591,636でしたが、correctness error code 30が205件出たため採用しませんでした。処理が速くなったことで、serverがevaluationをcommitしてchairを再公開する時点が、benchmark clientがevaluation応答を受け取る時点を追い越したためです。

response全体をlockする方法と、active rideを再確認する方法も試しました。結果は561,815点と519,270点でしたが、どちらもcode 30または31が残りました。

いったん、chairがCOMPLETEDをACKした後も100msだけ再利用を遅らせました。ベンチマーカーは完了したchairがnearbyから3秒以内欠けることを許容するため、その範囲内でserver/client間の観測差を吸収しています。この変更で542,441点になり、correctness gateを通過しました。

8. matching再公開とnearby再公開を分けました

542,441点版では、同じunavailableChairsをmatcherとGET /api/app/nearby-chairsの両方で参照していました。このため、観測順序を守る100ms graceはnearbyの整合性には必要でも、次の割り当てまで一律に止める結果になっていました。

そこで、COMPLETED応答後はmatcherに対してすぐchairを再公開し、nearbyに対してだけ次のchair notification requestまで非表示にしました。次のrequestが来たことを、前のCOMPLETED応答をclientが処理した確認として使います。matcherのスループットとnearbyの観測整合性を、別の境界で管理する形です。

途中の即時公開版は637,737点でしたが、nearbyのcode 30が13件出たため採用しませんでした。分離版は567,276点、pass=trueでcode 30は0件です。同じ割り当て計測を入れた556,898点runと比べると、assignment eventは10,381件から10,760件へ3.7%増え、matching待ちの中央値は7.34秒から5.95秒へ19.0%短くなりました。距離400以上の地域間割り当ては前後とも0件です。

95,656点時のコミットを再生した初期版と567,276点の最終版について、割り当てを同一座標・同一時間軸で比較したGIF
95,656点時のコミットを再生した初期版と567,276点の最終版について、割り当てを同一座標・同一時間軸で比較したGIF

比較を分かりやすくするため、GIFの左側は復旧後baselineの95,656点を記録したコミット72d840f、右側は567,276点の最終runにしました。ただし、95,656点のartifactにはassignment eventが保存されていませんでした。そのため左側の軌跡は、同じアプリケーションコミットへ挙動を変えないmemory-only traceを加え、現在のOS・MySQL・nginx設定上で再実行したものです。この再生run自体は122,320点だったため、GIF内には元artifactの95,656点と再生値の両方を表示しています。右側は最終runで実際に保存したtraceです。

このGIFはchairの全移動軌跡ではありません。各assignmentについて、割り当て時のchair位置からpickupまでの線を、60秒へ正規化して再生しています。初期コミット再生ではassignmentが1,810件、位置を取得できた1,784件のうち距離400以上の地域間割り当てが491件、pickupまでの平均距離が211.3でした。最終版は10,760件、地域間割り当て0件、平均距離13.25です。初期と最終ではアプリ以外の設定も異なるため、95,656点から567,276点までを単一修正の因果効果とは扱っていません。直前版に対する再公開境界分離の効果は、同一計測条件の556,898点との比較で判断しています。

50万点を超えても採用しなかったrun

commitraw scoreerror判断
e63bb26591,636code 30 ×205不採用
54f5f85561,815code 30 ×196、code 31 ×313不採用
c4d8de8519,270code 30 ×244、code 31 ×19不採用
4790b53637,737code 30 ×13、pass=truesoft errorを増やしたため不採用
3bf5e49543,359code 30なし、pass=true安全だがスループットが戻ったため不採用
c8a5d4a567,276code 30なし、pass=true採用
4a07343546,855DB pool 100→200、pass=true退行したためrevert

ISUCONでは、スコアが高くても仕様不整合があれば採用できません。今回の採用値は、raw scoreが最大だったrunではなく、割り当てを増やしながらnearbyのcode 30を出さなかった567,276点です。

最終レポートで見えている次の課題

signal最終run
app notification179,908 calls、平均695.0ms
chair notification109,885 calls、平均270.7ms
evaluation9,336 calls、平均493.4ms
nearby chairs74,878 calls、平均16.6ms
coordinate191,528 calls、平均2.2ms
matcher1,526 calls、平均19.4ms
DB pool65,991 waits、平均14.0ms
host CPUbusy 65.2%、idle 34.7%

notificationの平均が大きいのはlong pollingの待機を含むためです。現時点でCPUやdisk I/Oが飽和しているわけではありません。

ベンチマーカーログでは、最終的に87.9%のrideがmatching待ち時間へ不満を持っています。次に改善するなら、現在のpass commitをrollback点として保存し、混雑フェーズのwaiting ageとfree chair数を時系列で計測します。その後、read-only snapshotでcostをtransaction外計算し、短いcommit phaseだけでconditional updateするmatcherを独立実験するのが妥当です。

DB poolは100/100に達していましたが、200へ広げる単独実験は546,855点へ退行しました。pool waitという観測だけから、接続数を増やせば速くなるとは限りません。MySQL側の同時実行競合を増やすため、この変更はrevertしています。

ただし、現在のrolling Hungarianをそのまま「lifelong最適化」とは呼びません。busy chairの将来解放や再割当てまで扱う場合は、append-only assignment history、leaseまたはgeneration、commit horizonを含む別の設計が必要です。

isutoolsで分かったことと、分からなかったこと

isutoolsから直接分かったのは、次のような汎用的な事実です。

  • どのendpointとSQLが累計時間を使っているか
  • DB pool waitがどの程度あるか
  • CPUとI/Oに余力があるか
  • 変更前後でrequest count、latency、SQL需要がどう移ったか
  • 高いスコアとrevisionが対応しているか

一方、次の内容はISURIDE固有の仕様とベンチマーカーerrorから判断しました。

  • chairをnearbyへ再公開してよい正確な時点
  • owner salesとCOMPLETED markerの関係
  • 2地域間のdispatchをどの距離で見送るか
  • evaluation応答受信とserver commitの観測順序

計測ツールがアプリケーション固有の不変条件まで自動で断定することはできません。今回は、isutoolsのresource evidence、ベンチマーカーのcorrectness error、アプリケーションコードの3つを突き合わせました。

この検証で見つかったisutools側の汎用課題は、個別のISURIDE最適化と分けてGitHub Issuesへ記録しています。たとえば、phase shiftや低頻度高影響処理を全期間集計だけでは見落とす問題、SSH port forwardの維持、保存TTL後のfallbackなどです。

他の記録との違い

traPの「ISUCON14感想戦で40万点超えました」では、SSEとevent bus、各種on-memory cache、Badger、owner API、Go 1.24などで417,923点まで改善しています。今回も、coordinate、ride status、active chairをDB hot pathから外す判断は同じ方向です。

実装の共通点と差分を分けると、次のようになります。スコアは環境やrunの需要生成にも左右されるため、この表は優劣ではなく実装範囲の比較です。

観点今回の実装traPの40万点実装
notificationJSON APIを維持し、generation付きresponse cacheとevent-driven long pollingSSEとin-process event bus
matching2地域の距離上限、starvation救済、rolling Hungarian経過時間を含むpriority調整
coordinateMySQLの履歴を残し、最新位置と総距離をmemoryで増分管理chair locationの主データをBadgerへ移動
availabilitymatching再公開とnearby再公開を別のACK境界で管理active chairとride statusのmemory cache
可視化assignment trace、2地域viewer、前後比較GIF、isutools artifactPrometheusとGrafanaでユーザー・椅子の状態分布を表示
実行環境systemd service cgroup、nginx TLS終端、isutoolsのSQL/HTTP/pool/CPU統合計測Go 1.24、PGO、sonic、strings.Builderによる低レイヤ最適化

今回にしかない実装は、rolling Hungarianそのものだけではありません。2地域間の割り当て抑制、assignment eventの保存、matchingとnearbyの再公開境界分離、correctness errorを含むartifactの時系列保存までを一つの検証経路にしています。特に637,737点版を採用せず、code 30を0件にした567,276点版へ戻した判断は、この経路から得られたものです。

一方、traPの記事にあり、今回まだ実装していないものは次のとおりです。

  • chair locationのBadger移行
  • app/chair notificationのSSEとevent bus
  • Go 1.24のSwiss Table
  • PGO、sonic、strings.Builderによるencode処理の削減
  • PrometheusとGrafanaによる状態分布の常設表示

これらをまとめて移植しなかったのは、1修正ごとの効果とcorrectnessを分離できなくなるためです。BadgerやSSEは有力ですが、永続性と通知契約を変えるため単独の小変更ではありません。今回の最終runでは、まず現在のJSON APIとMySQL構成のまま観測できた境界を修正しています。

共通しているのは、latest ride status、latest chair location、active chairをhot pathではmemoryから読むこと、owner APIのN+1を削減すること、nginxとGo間をUNIX domain socketにすることです。同じ方向の改善でも、今回のartifactで効果を確認してから取り込んでいます。

今回の環境では、さらに次の点を別に検証しました。

  • systemd service cgroupとnginx TLS終端を含むisutools計測
  • rolling Hungarianの軌跡可視化
  • 1修正1ベンチのartifact保存
  • 50万点を超えたfail runを不採用にするcorrectness運用
  • payment transaction短縮で表面化したserver/client visibility race

手法をそのまま移植したのではなく、各段階で現在のレポートに現れた需要を確認してから適用しています。

まとめ

今回の最終結果は567,276点、pass=trueです。95,656点の比較可能baselineから493.0%増えました。

効果が大きかったのは、Hungarian法という名前だけではなく、matcherを複数件処理へ変えたこと、notificationとstatusをDB pollingから外したこと、coordinateを低遅延にしたこと、matcher周期を10msへ短縮したこと、payment待ちをDB transactionから外したことです。

最後は100ms visibility graceを一律に短くするのではなく、matcherとnearbyの再公開を分けました。これによりassignmentを10,381件から10,760件へ増やし、code 30を出さずに567,276点まで伸ばしています。性能改善では、処理時間だけでなく、異なるclientが状態を観測する順序も確認する必要があります。

60万点を狙ってDB poolを100から200へ広げる実験も行いましたが、546,855点へ退行したためrevertしました。今回の時点で、correctnessを保った60万点到達は確認していません。

全コミット、artifact ID、失敗run、rollback理由は、isutoolsリポジトリのcase studyに残しています。

参考リンク