今日開催された ISUCON 2026 に参加して、負けました。
競技終了26分前の 17:34、それまで2時間かけて作っていた約2,100行のオンメモリ状態管理(StateStore)が再起動試験の整合性チェックで落ち、本番ブランチから Revert しました。Revert 後にログとコードから原因候補を調べて修正を書きましたが、再びマージして再起動試験を通し切る時間は残っていませんでした。
記憶が新しいうちに、今日やったことと、なぜ最後のオンメモリ化で整合性を落としたのかを書いておきます。
題材とマシンの癖
今回の題材「ISUREAL」は、BeReal のような写真共有アプリです。地域ごとに開く投稿ウィンドウ(posting_windows)に合わせて写真を投稿し、友達の投稿に写真リアクション(RealMoji)やコメントを付け、フィードや過去の振り返り(Memories)を見ます。
与えられたサーバはルータを除くと4台です。アプリサーバが3リージョン(asia1・europe1・america1)に1台ずつあり、データベース(PostgreSQL 16)は asia2 の1台だけで動いています。
設計を決めるうえで効いてくる制約は3つありました。
- 大陸間の往復 100ms の距離: DB のある
asia2へは、asia1からは約1msで引けますが、europe1とamerica1からは1クエリごとに往復約100msかかります。初期実装のように1リクエストの中で5〜6回 SQL を直列に投げると、通信待ちだけで500〜600ms消えます。トランザクション内でロックを取ったあとの通信待ちは、そのまま他のリクエストがロックを待つ時間にもなります。 - CPU の
sha_niの有無と 1,000 回連鎖の HMAC-SHA256 署名: このアプリは、フィードや Memories で返すほぼすべての画像 URL(/api/images/:id?exp=...&sig=...)にオーナーごとの鍵で署名を付けます。しかも HMAC-SHA256 を1回計算するのではなく、前の出力を次の入力にして 1,000 回連鎖させる仕様です。/proc/cpuinfoを見ると、europe1とamerica1(Sapphire Rapids /c7i.large)には SHA-256 のハードウェア命令(sha_ni)がありますが、asia1とasia2(Cascade Lake /c5.large)にはありません。asia1ではこの署名計算だけで CPU の約4割が消えていました。 - RAM 3.7 GiB / Swap 0: 4台ともメモリは 3.7 GiB でスワップがありません。とくに PostgreSQL が動く
asia2は、初期データだけで約9GBの画像テーブル(image_objects)を抱えており、コネクション数やバッファを増やしすぎるとすぐに OOM killer が動きます。
スコアの推移と2つの作業ライン
序盤から myuon さんがベンチマークごとの時刻とスコアを記録してくれていました。10:08 の初回は 2,650 点、16:05 の PgBouncer 導入時点で 125,720 点です。ずっと右肩上がりだったわけではなく、1回ごとのスコアのブレに悩まされながら、退行したら戻して進めています。
| 計測時刻 | スコア | その時点で入れていた主な変更 | fail |
|---|---|---|---|
| 10:08 | 2,650 | 初期状態 | 4 |
| 10:30 | 19,405 | インデックス追加、署名キャッシュ、DB接続再利用 | 0 |
| 11:23 | 26,837 | 書き込みトランザクションのPL/pgSQL関数化、通知インデックス | 0 |
| 11:47 | 35,693 | JPEGヘッダ検査の軽量化、画像DB書き込みの非同期化 | 0 |
| 12:14 | 47,277 | ユーザー検索のオンメモリ化とロック範囲の縮小 | 20 |
| 13:10 | 66,466 | ピア通信(:8081)で海外へ署名委譲、画像配布、フィードSQL絞り込み | 0 |
| 13:42 | 73,414 | 通知取得の1クエリ化、フィードの閲覧判定改善 | 0 |
| 14:35 | 85,133 | SSE通知のメモリ配信、新規画像のnginx X-Accel-Redirect | 0 |
| 14:54 | 87,199 | Go 1.27のAVX-512(16レーン並列)署名を有効化 | 0 |
| 15:26 | 96,424 | DB接続プール縮小(OOM回避)、ログインセッションのオンメモリ化 | 0 |
| 15:55 | 110,614 | shared_buffers を768MBに縮小、Memoriesの2クエリ並列化 | 0 |
| 16:05 | 125,720 | PgBouncer導入(アプリ側プール上限700、DB実接続90) | 0 |
15時過ぎからは、作業が2つのラインに分かれました。
- myuon さんのライン(
mainブランチ): PostgreSQL を正(Source of Truth)としたまま、16時以降もクエリの1往復化、再起動時の DB プリウォーム、asia2への署名専任プロセスの分離などを1つずつ積み上げて検証するライン。 - 私のライン(別ブランチの
StateStore): 残る全テーブルを各アプリサーバのメモリに載せ、書き込み差分だけをピア間で配信して、海外リージョンからの DB 読み取り自体をゼロにしようとしたライン。
まず、main ブランチ側でスコアを伸ばした4つの仕組みをまとめます。
main ブランチで効いた4つの仕組み
1. 100ms の DB 往復を削る(PL/pgSQL 関数化とクエリ統合)
初期実装の書き込み処理(投稿作成や友達承認など)は、Go 側でトランザクションを開いてから「存在確認 → 権限チェック → INSERT → 通知の登録 → COMMIT」と5〜6回 SQL を直列に投げていました。europe1 や america1 からこれを行うと、DB の行ロックを握ったまま 100ms × 5〜6回 = 500ms 以上も大陸間の通信を待つことになります。
そこで主要な書き込み処理をすべて PostgreSQL の PL/pgSQL 関数にまとめ、Go からは SELECT func(...) を1回呼ぶだけ(1往復・100ms)で完結するように書き換えました。これでロックを握ったまま大陸間通信を待つ時間がなくなりました。
読み取り系でも、複数回に分かれていたクエリを1往復にまとめています。
- 通知一覧・投稿詳細・プロフィール取得: 閲覧権限のチェックとデータ本体の取得を並列化または1クエリに統合し、2〜3往復かかっていた処理を1往復に短縮。
- 友達の友達(
fof)フィード: 候補ユーザーの絞り込みからスコア計算まで最大6往復かかっていた処理を1本の SQL に集約(本番 DB の400人全員で旧実装と結果・順序が一致することを確認して投入)。 - 部分的なオンメモリ化: 全テーブルではなく、他テーブルと複雑に絡まない「ユーザー検索一覧」「ログインセッション」「SSE の通知配信」だけを先にオンメモリ化。
2. 内部ピア通信(:8081)による画像の直接配布(Push + オンデマンド Pull)
pg_stat_statements を見ると、DB 時間の1位(1回のベンチマーク走行あたり合計132秒)は image_objects テーブルへの画像データ(bytea)の読み書きでした。
初期データとして PostgreSQL に入っていた約9GBの画像は、あらかじめ各アプリサーバのローカルディスク(/home/isucon/image-cache/<id>)へ書き出しておき、DB を引かずに nginx の X-Accel-Redirect で直接配信するようにしました。
問題はベンチマーク中にアップロードされる新規画像です。各アプリサーバにプライベート IP 限定の内部 HTTP ポート(:8081)を開き、DB の image_objects テーブルには署名検証用のメタデータ(所有者 ID と作成時刻)だけを空の data = '' で入れ、画像本体(JPEG バイト列)はアップロードを受けたサーバから他リージョンのピアへ直接配る仕組みにしました。
この仕組みが最後まで安定して動いた理由は、非同期 Push(PUT /image/{id})だけでなく、ローカルに無かったときのオンデマンド Pull(GET /image/{id})を持っていたからです。ヨーロッパでアップロードされた直後の画像が、100ms の非同期 Push が届く前にアジアでリクエストされても、アジアのサーバがその場で他ピアへ取りに行けるため、整合性を落とさずに DB への画像書き込みをゼロにできました。
3. 1,000回連鎖の HMAC-SHA256 署名を速くする(AVX-512 16レーン並列と署名専任プロセス)
画像 URL 1つあたり HMAC-SHA256 を 1,000 回連鎖させる処理には、4段階で手当てを入れました。
- 有効期限(
exp)の60秒丸めとオンメモリキャッシュ: 毎回異なる秒数で署名するのをやめ、expを60秒単位のバケットに丸めて計算結果をメモリにキャッシュしました(一度1時間単位に広げましたが、ベンチマーカーの有効期限チェックで弾かれたため60秒に戻しています)。また、現在のバケットの残り時間が20秒を切ったら次の+60秒バケットの署名を裏で先回り計算するようにしました。 - 海外リージョン(
sha_niあり)への署名委譲: 昼の時点では、sha_niのないasia1から、内部ポート:8081に作ったPOST /signを使ってeurope1・america1(sha_niあり)へ未キャッシュの署名バッチを投げて計算させていました。 - Go 1.27 の
GOEXPERIMENT=simdによる AVX-512 16レーン並列計算:asia1とasia2にはsha_niはありませんが、512ビット幅の AVX-512 は使えます。1つの画像 URL の署名は「前回の出力を次の入力にする」直列1,000回の計算なので1本だけでは SIMD になりませんが、フィードや Memories では1リクエストで数十枚の画像 URL にまとめて署名を付けます。そこで独立した16本の署名を束ね、512ビットレジスタ(archsimd.Uint32x16)の16レーンに1本ずつ割り当てて、連鎖計算を16本同時に回すようにしました。 asia2に署名専任プロセス(:8081)を置く: 終盤(17:24)、asia1から頼まれた署名計算でeurope1とamerica1の CPU が約25%使われ、ピーク時に海外2台の CPU が詰まっていることが分かりました。すでに AVX-512 の16レーン並列署名は動いていたため、CPU に余裕のあった DB サーバasia2に署名専用のアプリプロセス(:8081、nginx の振り分け対象外)を立て、asia1からは往復1msのasia2:8081へ署名を委譲するように切り替えました。
4. RAM 3.7 GiB / Swap 0 での OOM 回避と PgBouncer
europe1 と america1 は DB クエリ1回ごとに往復100msの間コネクションを掴み続けるため、アプリの接続プールが小さいとすぐにコネクション待ちになります。しかし、PostgreSQL の max_connections を 400 に上げて3台のアプリから合計310本つないだところ、asia2(RAM 3.7 GiB / Swap 0)のメモリが尽きて PostgreSQL が OOM killer に落とされました。
そこで PostgreSQL の shared_buffers を 1.5GB から 768MB に下げたうえで、asia2 に PgBouncer(transaction モード) を入れました。
- アプリ側の接続プール上限:
europe1300 +america1300 +asia1100 = 合計 700 本 - PgBouncer から PostgreSQL への実接続:90 本(上限 100 本)
これで「大陸間の100ms待ちでコネクションが詰まる問題」と「PostgreSQL のバックエンドプロセス増大による OOM」を同時に解消できました。さらに 17:05 には、再起動試験の直後に DB キャッシュが空で initialize が 504 になったのを受け、asia2 の起動時に全テーブルを先読みする systemd サービスも追加しています。
ここまでの main ブランチの最終形を図にすると次のようになります。
なぜ最後の全テーブルオンメモリ化(StateStore)で落ちたのか
上記の main ブランチの延長線上だけでもスコアは伸びていましたが、私は15時過ぎから別ブランチで、残る全テーブル(ユーザー・投稿・コメント・RealMoji・フォロー・ブロック・友達申請・通知・閲覧履歴)をすべてメモリに載せる StateStore(約2,100行)を自分のエージェントに実装させていました。europe1 や america1 から asia2 へ行く1往復(100ms)すらゼロにして、すべての読み取りを各リージョンのローカルメモリだけで返そうという狙いです。
仕組みとしては、内部ポート :8081 に POST /state/op と POST /state/reload を追加し、書き込みを受けたサーバが PostgreSQL を更新したあと、他の2リージョンのピアへ変更差分(StateOp JSON)をブロードキャストしてメモリを更新する設計でした。
16:55 に一度 main にマージして17:11 に戻し、修正を入れて 17:30 に再び main へマージしました。単発のベンチマークでは動いていたのですが、終了26分前の 17:34 の再起動試験で整合性チェックに落ちました。投稿したはずのコメントが無い、GET /api/notifications が 401 になる、友達の友達フィードのスコアが 1〜2 ズレる、差し替えたはずの RealMoji が古いまま返る、画像が 403 image_forbidden になる、といったエラーが一斉に出たため、その場で Revert しました。
Revert 後の残り時間と競技後にログとコードを調べて見つけた原因候補は以下の3つです。再びマージして本番の再起動試験を通す時間はなかったため本番環境での解消までは未検証ですが、コードに入っていた欠陥自体ははっきりしています。
1. リージョンをまたぐユーザー(user moved)と Push のみの非同期キュー
ログを追って分かったのが、ベンチマーカーのユーザーが途中で接続先リージョンを移動する(user moved)ことでした。
画像のピア配布では「非同期 Push が間に合わなくても、読まれたサーバが他ピアへ GET /image/{id} で取りに行く(オンデマンド Pull)」という逃げ道がありました。しかし StateStore は、投稿もコメントもセッションもローカルメモリにあるものだけを即座に返す Push のみの設計でした。
しかも当初の実装では、他リージョンへの POST /state/op(往復100ms)で書き込みレスポンスが遅くなるのを嫌い、ピアへの送信が 800ms でタイムアウトしたらピアごとの非同期リトライキュー(peerRetryQueue)に積んで、クライアントにはすぐ HTTP 200/201 を返していました。
4台同時再起動の直後は DB の接続もキャッシュも冷えており、ピア間の HTTP 接続も張り直しになるため同期が遅れます。その状態で、asia1 でコメントや友達承認やログインをしたユーザーが直後に europe1 に移動して読み取りを行うと、まだ europe1 のメモリに StateOp が届く前に自分の書き込み結果を読みに来てしまいます。Revert 後の 17:46 に入れた修正では、書き込み後に最大3秒までピアへの同期完了を待つように変えました。
2. アプリの時計と DB の時計の混在
各サーバに届く StateOp の順序逆転を防ぐため、データの更新時刻を比べて新しい方だけを採用する Last-Write-Wins(LWW)を入れていましたが、ここに時計の混在がありました。
大半のテーブルでは DB 側でロックを取った時点の clock_timestamp() を使っていましたが、RealMoji の差し替え関数だけ、引数で渡したアプリサーバの時刻(p_created_at)をそのまま新しい行の created_at に入れていました。
-- 修正前: 旧行を replaced_at で無効化したあと、アプリ時計由来の値で新しい行を作る
INSERT INTO realmojis (id, post_id, reactor_id, image_id, reaction_type, created_at)
VALUES (p_realmoji_id, p_post_id, p_actor_id, p_image_id, p_reaction_type, p_created_at);リージョンを移動したユーザーが、時計のわずかに遅れた別サーバで RealMoji を差し替えると、新しい RealMoji の created_at が古い方より小さくなり、StateStore 側で「古いイベントが遅れて届いた」と判定されて捨てられる経路がありました。
3. initialize で戻らない初期画像の削除とメタデータの欠落
終了間際に見つけたのが、画像キャッシュ周りの2つの不整合です。
1つ目は、初期データのプロフィール画像削除バグです。POST /api/initialize で走る reset.sql は、初期ユーザーの profile_image_id を initial_users から復元しますが、image_objects テーブルの初期データ行やディスク上の /home/isucon/image-cache/<id> は復元しません。初期データの画像は消えない前提になっているためです。
ところが、初期データに含まれるユーザーがベンチマーク中にプロフィール画像を更新・削除すると、私たちのコードは古い画像 ID に対して DELETE FROM image_objects WHERE id = $1 と os.Remove(imageCachePath(id)) を呼んでいました。1回目のベンチマークで初期ユーザーがプロフィール画像を変えると、次のベンチマークの initialize で profile_image_id だけが初期値に戻り、実体の画像が DB にもディスクにも無くなって 403 になり得ます。
// 修正後: 初期データ境界より後の画像だけを DB から消し、ディスクの初期画像ファイルは残す
_, _ = item.db.ExecContext(ctx, `
DELETE FROM image_objects
WHERE id = $1
AND created_at > COALESCE((SELECT created_at FROM initial_data_boundary LIMIT 1), '-infinity'::timestamptz)
`, item.imageID)2つ目は、ピアから受け取った新規画像のメタデータをディスクに保存する際、所有者 ID だけを書いて作成時刻(created_at)を保存し忘れていたことです。再起動後にディスクから読み戻すと作成時刻が time.Now() に置き換わってしまい、作成時刻を条件に含む DB フォールバック検索が外れて 403 image_forbidden になり得る実装になっていました。
反省
チームの動き方として、午前中は1人で Claude Code をぶん回す形で進めていました。ただ、2万点を超えたあたりで Opus 5.5 が詰まり始め、途中から並列で Argon を回し始めました。振り返ると、最初から3人それぞれが自分のエージェントを回して、並行して手を動かす形にすべきだったかもしれません。
もう1つ大きかったのが、ベンチマークの待ち時間です。エージェントがコードを書く速度に対して、本番ベンチマーカーが回り切るまでの時間がずっとボトルネックになっていました。1回の計測ごとに点数のブレもあり、変更の切り分けに毎回本番ベンチの時間を消費していました。特定のエンドポイントやシナリオだけでも手元で負荷試験できるミニベンチマーカーを序盤に作っておけば、本番ベンチを待たずに当たり外れや退行をもっと速く潰せたはずです。
そして直接の敗因は、残り3時間を切ったところから、残る全テーブルを対象にした2,100行のオンメモリキャッシュを一気に書いて入れようとしたことでした。
画像のピア配布やユーザー検索・SSE・セッションのように、1つずつ仕組みを入れて本番ベンチマークで確かめていた間は、問題が起きてもすぐに切り分けて直せていました。AI エージェントを使えば、2,100行の StateStore 自体は短時間で書けます。ただ、リージョンをまたぐユーザーの Read-Your-Writes や、アプリ時計と DB 時計の混在、reset.sql が復元するテーブルと復元しないテーブルの境界といった細かい前提は、一気に書き上げたコードの中には自然とは入りません。
もしやり直すなら、全テーブルを一度にオンメモリにするのではなく、まず posts と comments だけを同期ブロードキャスト付きでメモリに載せて本番の再起動試験を通し、1テーブルずつ広げていたと思います。コードを書く速度が上がったぶん、変更の単位まで大きくしてしまったのが今回の敗因でした。