12月25日 9:30更新 「ライドと椅子のマッチング」の実装例のコードを修正しました。
----

こんにちは、ISUCON14の作問チーム・NaruseJunのとーふとふです。
この記事では、ISUCON14の問題として出題した「ISURIDE」の解説と講評をお届けします。
今回の問題は、NaruseJunのメンバーが所属するポケットサインが作問を担当し、LINEヤフーの皆さんにフロントエンド実装でご協力いただきました。さらに、アドバイザーとしてfujiwaraさんにも参加していただき、非常に充実した作問体制を整えることができました。

それでは、ここから「ISURIDE」全体の構成や狙い、ボトルネックの仕掛けなどを順を追って解説していきます。

「ISURIDE」とは
競技当日流したサービスの概要紹介動画は以下からご覧いただけます。



今回の問題では、いわゆるタクシー配車・ライドシェアアプリをベースに、自動運転椅子のライドチェアサービス「ISURIDE」を題材としました。
日本国内でもタクシー配車アプリの利用が徐々に増え、ライドシェアに関する法整備も進んできている中、「単にレスポンスを速くする」だけでなく、マッチング機能を含むアプリケーション全体をより最適化するという課題に挑んでもらうために、このライドシェア(ライドチェア)サービスのテーマを選択しました。

フロントエンドはLINEヤフーのみなさんに協力してもらい、クオリティの高いものになっています。競技中にフロントをじっくり見られなかった方は、ぜひこの機会に実装をご覧いただければと思います。


問題全体の構成
今回のISURIDE問題は、「チェアタウンで運営されていたISURIDEが新たにコシカケシティへ展開される」というシナリオになっています。キャンペーン施策の影響で利用者が急増し、システム全体に高負荷がかかるようになった――この状況をどう改善するかが本問題のテーマでした。

登場人物
ユーザー
ISURIDEを利用するエンドユーザーです。椅子に乗ってチェアタウンやコシカケシティ内を移動します。満足度が高ければ口コミや招待などを通じてさらにユーザーを増やしていく存在でもあります。

椅子
自動運転でユーザーを乗せるために走り回る乗り物となる椅子です。

オーナー
複数の椅子を保有・運営する立場の人々です。椅子の売上を随時確認し、十分な利益が見込めるようであれば追加の椅子を投入します。

ベンチマーカー内に構成された世界では30msを1tickとして次の行動を制御されるようになっていました。そのため、全エンドポイントが30ms以内にレスポンスを返せていることが理想的な状態です。

ライドの満足度
ユーザーがISURIDEアプリを通じて椅子を呼び出し、椅子に乗って移動した後、評価を行うまでの一連の流れのことをライドと呼び、ベンチマーカーはそれを模したリクエストを送るようになっていました。
また、ライドの最後にはそのライドの満足度を評価するようになっており、個人の評価が高いとユーザーによる招待が、地域ごとの評価の平均値が高いと口コミによるユーザー数の増加が起きるようになっていました。

ユーザーの行動は以下のような遷移を辿ります。
14problem-1

ベンチマーカーのログに出力されていたライドの評価内容と実際の改善ポイントは以下のようになっていました。

椅子がマッチングされるまでの時間
ユーザーがライドを作成してから椅子に割り当てられた通知を受け取るまでの時間を見ています。
初期実装では500msごとに1つのライドのマッチングしか行われないので、他のエンドポイントの改善などによりマッチング中のライドが増えると不満が溜まっていくようになっていたと思います。
また、ユーザーが通知を受け取るまでの時間を対象としているので通知エンドポイントをポーリングする頻度や通知エンドポイントのレスポンスタイム自体も影響します。

マッチされた椅子が乗車地点までに掛かる時間
ライドに割り当てられた椅子の割当時点での位置とライドの乗車位置の間の理想的な移動時間(2点間の距離と椅子のモデルの速度について 2点間の距離/速度 <= 10 かどうか)について見ています。一般的な配車アプリでも車両がマッチングされた時に到着予定時刻が出ると思いますが、それが長過ぎるような場合に満足度が下がるようになっています。

これはマッチングアルゴリズムの改善により満足度を上げることができます。単独のライドに関して言えば割り当て可能な椅子の中で最も近いものを割り当てれば良いですが、全体最適を目指そうとすると多少凝ったアルゴリズムを実装する必要がありました。

椅子の実移動時間
マッチされた椅子が実際に乗車位置まで移動し、乗車位置に到着したことをユーザーが通知で受け取るまでの理想的な移動時間(位置情報更新で詰まっていない場合の移動時間)と実際にかかった時間の差を見ています。
椅子は自分の位置情報を更新する
POST /api/chair/coordinate
のリクエストが完了しないと次の移動を開始しないので、このエンドポイントのレスポンスが遅いことも不満の原因となります。

また、通知を受けるまでの時間となっているので通知エンドポイントのポーリング頻度やレスポンスタイムも影響するようになっていました。

スコアの計算
以下の合計がスコアになっていました。
・椅子がライドとマッチした位置から乗車位置までの移動距離の合計 * 0.1
・椅子の乗車位置から目的地までの移動距離の合計
・ライド完了数 * 5

基本的にはライドの回数が重要になります。関連するエンドポイント全体を高速化していくことで、高速にライドを捌いていくことができるようになり、それに合わせてユーザーの満足度・椅子の売上も高くなりライドの数も増えていくようになっていました。
また、椅子の移動についてもスコアがついていますが空車のときと人を乗せているときではスコアに10倍の差があるので、椅子が移動する際にはできるだけ人を乗せている状態、つまり椅子と乗車位置が近いライドに割り当てることも必要になっていました。

解説
ここからは各要素について解説していきます。

序盤
インデックス
今年のデータベースも全くと言っていいほどインデックスが貼られておらず、初期状態ではNginx・アプリ・データベースの負荷のバランスを見てもデータベースが寡占するような状況になっていたと思われます。
データベースのプロファイリングを行ったりテーブル構造やアプリケーションからの使われ方から検討したりして、追加するべきインデックスを決めるのが各チーム初手になったのではないでしょうか。
POST /api/initialize
のハンドラ内でのデータベースのリセット方法は年によって異なり、これまで特定のID以降の削除、テーブルのTRUNCATE、テーブルやデータベース自体の作り直しなどの方法がありましたが、今回はテーブルの作り直しが行われるようになっていました。

例えば、 webapp/sql/1-schema.sqlの末尾に以下のように追記をしてインデックスを貼ることで、Go言語では4000点程度までスコアを伸ばせることを確認しています。(初期実装では使われないインデックスも含まれています)


 CREATE INDEX idx_chairs_access_token ON chairs (access_token);
 
 CREATE INDEX idx_users_access_token ON users (access_token);
 CREATE INDEX idx_users_invitation_code ON users (invitation_code);
 
 CREATE INDEX idx_rides_chair_id_updated_at_desc ON rides (chair_id, updated_at DESC);
 CREATE INDEX idx_rides_user_id_created_at_desc ON rides (user_id, created_at DESC);
 CREATE INDEX idx_rides_chair_id_created_at_desc ON rides (chair_id, created_at DESC);
 CREATE INDEX idx_rides_chair_id_created_at_asc ON rides (chair_id, created_at);
 
 CREATE INDEX idx_ride_statuses_ride_id_app_sent_at_created_at ON ride_statuses (ride_id, app_sent_at, created_at);
 CREATE INDEX idx_ride_statuses_ride_id_created_at_desc ON ride_statuses (ride_id, created_at DESC);
 CREATE INDEX idx_ride_statuses_ride_id_created_at ON ride_statuses (ride_id, created_at);
 
 CREATE INDEX idx_coupons_used_by ON coupons (used_by);
 CREATE INDEX idx_coupons_code ON coupons (code);
 
 CREATE INDEX idx_chair_locations_chair_id_created_at_desc ON chair_locations (chair_id, created_at DESC);
GET /api/app/nearby-chairsのN+1
初期実装では
GET /api/app/nearby-chairs
で、各リクエストごとにすべての椅子を取得して最新のライド情報と位置情報からレスポンスに含めるかどうかをチェックするような、 2N+1回DBにクエリされる処理になっていました。
これらの処理はうまくクエリを構成することで1回のDB問い合わせで結果を得ることができます。

以下はGo言語での実装例です。レスポンスには椅子の位置情報も含める必要があるため、椅子のモデルに位置情報をあわせて持てるようにした
ChairWithLatLon
を導入しています。
 type ChairWithLatLon struct {
 	ID          string    `db:"id"`
 	OwnerID     string    `db:"owner_id"`
 	Name        string    `db:"name"`
 	Model       string    `db:"model"`
 	IsActive    bool      `db:"is_active"`
 	AccessToken string    `db:"access_token"`
 	CreatedAt   time.Time `db:"created_at"`
 	UpdatedAt   time.Time `db:"updated_at"`
 
 	Latitude  int `db:"latitude"`
 	Longitude int `db:"longitude"`
 }
 
 	chairs := []ChairWithLatLon{}
 	err = tx.Select(
 		&chairs,
 		`
 -- 近くに存在している椅子一覧
 WITH near_chairs AS (
 	SELECT cl.*
 	FROM (
 	    SELECT cl.*, row_number() over (partition BY chair_id ORDER BY created_at DESC) AS rn
 	    FROM chair_locations cl
 	) cl
 	WHERE cl.rn = 1 AND abs(cl.latitude - ?) + abs(cl.longitude - ?) < ?
 ),
 -- すべての椅子の最新のステータス
 chair_latest_status AS (
 	SELECT *
 	FROM (
 		SELECT rides.*, ride_statuses.status AS ride_status, row_number() over (partition BY chair_id ORDER BY ride_statuses.created_at DESC) AS rn
 		FROM rides LEFT JOIN ride_statuses ON rides.id = ride_statuses.ride_id		
 	) r 
 	WHERE r.rn = 1 AND r.ride_status = 'COMPLETED'
 )
 
 SELECT
 	chairs.*, near_chairs.latitude, near_chairs.longitude
 FROM 
 	chairs
 -- ここのINNER JOINで近くに存在している椅子に絞り込まれる
 INNER JOIN near_chairs ON chairs.id = near_chairs.chair_id
 LEFT JOIN chair_latest_status ON chairs.id = chair_latest_status.chair_id
 WHERE
 -- 最新のライドが完了しているか1度もライドに割り当てられていなくて現在アクティブな椅子を取得する
 	(chair_latest_status.ride_status = 'COMPLETED' OR chair_latest_status.ride_status IS NULL) AND chairs.is_active`,
 		lat, lon, distance,
 	)
 	if err != nil {
 		writeError(w, http.StatusInternalServerError, err)
 		return
 	}
 
 	nearbyChairs := make([]appGetNearbyChairsResponseChair, 0, len(chairs))
 	for _, chair := range chairs {
 		nearbyChairs = append(nearbyChairs, appGetNearbyChairsResponseChair{
 			ID:    chair.ID,
 			Name:  chair.Name,
 			Model: chair.Model,
 			CurrentCoordinate: Coordinate{
 				Latitude:  chair.Latitude,
 				Longitude: chair.Longitude,
 			},
 		})
 	}
また、ここで椅子がライドの割当を受け付けられるかをウィンドウ関数を利用したサブクエリによって判定しています(
chair_latest_status
の共通テーブル式)が、実際にライドのステータスが
COMPLETED
になるタイミングに注目すると、同じトランザクション内で
evaluation
に値が必ず設定されるようになるのでそれを用いて
ride_statuses
とJOINすることなく判定も可能です。
以下はその性質を利用したクエリ例です。

 WITH near_chairs AS (
 	SELECT cl.*
 	FROM (
 	    SELECT cl.*, row_number() over (partition BY chair_id ORDER BY created_at DESC) AS rn
 	    FROM chair_locations cl
 	) cl
 	WHERE cl.rn = 1 AND abs(cl.latitude - ?) + abs(cl.longitude - ?) < ?
 )
 SELECT
 	chairs.*, near_chairs.latitude, near_chairs.longitude
 FROM 
 	chairs
 INNER JOIN near_chairs ON chairs.id = near_chairs.chair_id
 LEFT JOIN rides ON chairs.id = rides.chair_id AND rides.evaluation IS NULL
 WHERE
 	rides.id IS NULL AND chairs.is_active
GET /api/owner/chairsのSQLクエリ
オーナーが管理している各椅子の総走行距離を計算して返すエンドポイントです。ここまでの修正をすると、このエンドポイントのクエリが重いものとしてメトリクスに上がって来たのではないかと思います。

最初のクエリでは、全椅子の総走行距離を計算した後にOwnerIDで絞り込むようなクエリになっていますが、総走行距離を計算する処理が重いので、EXPLAINなどを利用しながらクエリを組み替えていくことでスキーマやアプリケーションコードを変更することなくクエリの負荷を軽減することができます。

以下が改善例です。

 WITH filtered_chairs AS (
     SELECT id
     FROM chairs
     WHERE owner_id = ?
 )
 SELECT c.id,
        c.owner_id,
        c.name,
        c.access_token,
        c.model,
        c.is_active,
        c.created_at,
        c.updated_at,
        ifnull(dt.total_distance, 0) AS total_distance,
        dt.total_distance_updated_at
 FROM filtered_chairs fc
 JOIN chairs c ON fc.id = c.id
 LEFT JOIN (
     SELECT chair_id,
            SUM(distance) AS total_distance,
            MAX(created_at) AS total_distance_updated_at
     FROM (
         SELECT cl.chair_id,
                cl.created_at,
                abs(cl.latitude - lag(cl.latitude) OVER (PARTITION BY cl.chair_id ORDER BY cl.created_at)) +
                abs(cl.longitude - lag(cl.longitude) OVER (PARTITION BY cl.chair_id ORDER BY cl.created_at)) AS distance
         FROM chair_locations cl
         JOIN filtered_chairs fc ON cl.chair_id = fc.id
     ) sub
     WHERE distance IS NOT NULL
     GROUP BY chair_id
 ) dt ON dt.chair_id = c.id
 
また、最新以外の
chair_locations
はここでの
total_distance
を計算することにしか使われないことと、新しく椅子の位置情報が記録されるとき次の
total_distance
は1つ前の位置情報とそれまでの
total_distance
から計算できることに気がつけると、各椅子ごとに最新の位置情報と総走行距離を持つテーブルを作りUPSERTだけで管理できるようになり、ここでのクエリをはじめ様々なクエリをシンプルにすることができます。

中盤以降
適切にインデックスを貼り、めぼしいN+1といわゆる観光名所と言われる
GET /api/owner/chairs
のSQLを整理したあとは主に以下の3要素と格闘することになったと思います。

ライドと椅子のマッチング
インスタンス内部のサービスからポーリングされているエンドポイントで処理されている部分でした。
1つのリクエストを高速化するというよりサービス全体のスループットを高速化することを意識して変更することが必要でした。
初期状態ではリクエストごとに割り当て可能な椅子をランダムに1つ選択してライドに割り当てるという実装になっていました。これではライドや割り当て可能な椅子が複数溜まっていても500msごとに1つのライドしか成立せず、非常に遠い椅子が割り当てられる可能性もあります。

改善案としては椅子割当待ちのride全てと空いている椅子すべてをマッチングさせるようにする・乗車位置に近い椅子を割り当てるなどがありました。更にISURIDEは2地域で展開されており各ユーザーは地域内でしか移動しないという記載がマニュアルにあることより、一定距離より遠い椅子とライドはマッチングするのを見送った方が効率が良くなります。ランダム性を除いたマッチングアルゴリズムに変更すると、それまでかなりぶれていたベンチマークスコアもかなり安定したのではないでしょうか。

図 : ベンチマーク走行後のridesテーブルをChatGPTでプロット
14problem-2
それらを取り込んだ実装例が以下になります。
func internalGetMatching(w http.ResponseWriter, r *http.Request) {
	// 決まっていないライドと空いている椅子を全て取得
	rides := []Ride{}
	if err := db.Select(&rides, `SELECT * FROM rides WHERE chair_id IS NULL ORDER BY created_at ASC`); err != nil {
		w.WriteHeader(http.StatusNoContent)
		return
	}
	if len(rides) <= 0 {
		return
	}

	chairs := []ChairWithLatLon{}
	if err := db.Select(&chairs, `
 WITH chair_latest_location AS (
 	SELECT *
 	FROM (
 		SELECT chair_locations.*, ROW_NUMBER() OVER (PARTITION BY chair_id ORDER BY created_at DESC) AS rn
 		FROM chair_locations
 	) c
 	WHERE c.rn = 1
 ),
 chair_latest_status AS (
 	SELECT *
 	FROM (
 		SELECT rides.*, ride_statuses.status AS ride_status, ROW_NUMBER() OVER (PARTITION BY chair_id ORDER BY ride_statuses.created_at DESC) AS rn
 		FROM rides INNER JOIN ride_statuses ON rides.id = ride_statuses.ride_id AND ride_statuses.chair_sent_at IS NOT NULL -- この条件は椅子の通知エンドポイントの実装で、未送信の状態がある2つ以上の異なるライドが割り当てられていても正しく順番に送るように修正していれば不要
 	) r
 	WHERE r.rn = 1
 )
 SELECT
 	chairs.*, chair_latest_location.latitude, chair_latest_location.longitude
 FROM chairs
 LEFT JOIN chair_latest_status ON chairs.id = chair_latest_status.chair_id
 LEFT JOIN chair_latest_location ON chairs.id = chair_latest_location.chair_id
 WHERE
 	(chair_latest_status.ride_status = 'COMPLETED' OR chair_latest_status.ride_status IS NULL) AND chairs.is_active`); err != nil {
		writeError(w, http.StatusInternalServerError, err)
		return
	}

	for _, ride := range rides {
		minDistance := 400
		var minChair *ChairWithLatLon
		var minChairIdx int
		for idx, chair := range chairs {
			distance := calculateDistance(chair.Latitude, chair.Longitude, ride.PickupLatitude, ride.PickupLongitude)
			if distance < minDistance {
				minDistance = distance
				minChair = &chair
				minChairIdx = idx
			}
		}
		if minChair != nil {
			// 複数のmatcherが動く場合には、複数の椅子が同じライドに割り当てられないようトランザクションなどで排他制御を行う必要がある
			if _, err := db.Exec("UPDATE rides SET chair_id = ? WHERE id = ?", minChair.ID, ride.ID); err != nil {
				writeError(w, http.StatusInternalServerError, err)
				return
			}

			chairs = append(chairs[:minChairIdx], chairs[minChairIdx+1:]...)
		}
	}

	w.WriteHeader(http.StatusNoContent)
}
作問時点では上位入賞には、マッチングアルゴリズム自体の改善とバルク化が必須だと考えていましたが、アルゴリズムはそのままでマッチング頻度を上げるだけで上位入賞を果たしているチームもありました。

ユーザー・椅子への通知
ユーザーや椅子がライドの割当状況やステータスの変化を取得するためのエンドポイント(
GET /api/app/notification
,
GET /api/chair/notification
)の改善についてです。

初期状態では30ms間隔という超頻度でポーリングされるようになっていました。これはベンチマーカー内の世界が30msを1tickとして動いていることに由来します。
retry_after_ms
プロパティで頻度を制御することが可能ですが、頻度を下げるとユーザーや椅子が状態変化に気づくまでの時間が長くなり、満足度が下がるような仕組みになっていました。とはいえ、序盤はこれによる負荷が高すぎるせいで他の処理がスムーズに進まないため、頻度を適度に下げることも良い選択肢だったと思います。

これらのエンドポイントはServer-Sent Eventsでのレスポンスに変更することも可能でしたが、(今回の優勝得点程度まで)得点を上げるためには必須ではありませんでした。また、SSEに変更したとしてもスコアを伸ばすためには同一のユーザーに対する接続が継続していることを利用したキャッシュやGoのchannelやRedisのPubSub機能などを利用してのステータス変更時の即時通知などまで実装する必要があったはずです。

JSON APIの状態でもライドを丁寧にキャッシュすることによってスコアを伸ばすことが可能です。試し解きではJSON APIの状態で少なくとも10万点は取れることを確認しています。
(JSON APIでもタイムアウトが60秒なのを利用して、ロングポーリングすることも一応想定していましたがこれを実装したチームはあったでしょうか?)

GET /api/app/nearby-chairs
ユーザーがライドを作成する前にリクエストしてくるエンドポイントでした。

椅子の位置情報や割当による除去を的確に反映した上でレスポンスを返す必要がある点が難しかったと思います。
序盤の節でも説明したように最新の位置情報だけを持つようなテーブルを導入したり、
chairs
テーブルに椅子が割り当て可能かを持つカラムを追加すると処理がシンプルになり高速化が可能でした。

ドキュメントでこのエンドポイントで返される椅子の座標は過去3秒以内に送られた
POST /api/chair/coordinate
であれば良いという記述がありますが、猶予されるのは位置情報のみで椅子が割り当て可能かは即時に反映される必要があったためサクッとレスポンス全体を3秒キャッシュみたいなことはできない様になっていたと思います。(これは作問チーム内でも分かりづらかったという反省点になってます…すみません!) 

POST /api/chair/coordinate
については他に利用されるエンドポイントも3秒の反映猶予があるため、非同期化・バルクインサート化が可能でした。これによって
POST /api/chair/coordinate
のレスポンス高速化ができるので、椅子のマッチから到着までや実移動時間の改善をすることが可能でした。

その他・感想戦
以降は作問時点で競技時間内には改善されることは少ないだろうと思っていた要素です。

Idempotency-Keyヘッダ
ライドの終了時に行われる決済の頻度が高まると、ベンチマーカー側にある決済マイクロサービスが実際には決済が成功しているのにレスポンスとしては200番台以外のエラーとして返ってくることが多くなります。初期実装ではリトライのたびに、時間のかかる決済確認APIを叩いて本当に決済が失敗しているのかを確認するようになっていました。
決済マイクロサービスはIdempotency-Keyヘッダに対応しており、想定修正としてはIdempotency-KeyヘッダにRideIDなど指定することでエラーになったとしても確認APIを叩かずにそのまま再度リクエストして高速化するというものでした。

これは私が1月のYAPC::Hiroshima 2024でIdempotency-Keyヘッダの存在を知り、どうしても入れたいと要望して取り込んだ要素なのですが、うまくボトルネックとして絡めることができませんでした…
要素としては存在するのでドキュメントにも記載したので、かなりのブラフになってしまったのですがこれに囚われていた人はあまりいなかったようで安心しています。

マッチングアルゴリズムのさらなる改善
作問側ではより最適なマッチング方法があることは認識しつつ、ライドごとに近い椅子を割り当てる貪欲法が使われることを想定していました。ある時点での割り当て可能なライドと椅子についての、ライドの乗車位置への椅子の存在地点からの移動距離の最小化は二部マッチング問題として解くことができるようです。普段競技プログラミングをしている参加者にとっては比較的初歩的なアルゴリズムらしく、懇親会でもそのようなアルゴリズムを実装した参加者と話すことができました。

さらなる最適を追い求めるなら、人を乗せている椅子の状況も把握した上で「近くのフリーな椅子が乗車位置に到着するまでの時間」より「人を乗せている椅子がそのライドを完了させて乗車位置に移動して到着するまでの時間」のほうが短ければそれを優先するなどもできると思います。ここらへんを突き詰めていくと競技プログラミングのヒューリスティックコンテストっぽい感じになっていきそうですね。

講評
問題としては中盤以降いろんな解き方のルート(3要素のうちどれから取り掛かるか、どのように解決するか)がある問題になっていたのではないかと思います。その分、明らかなボトルネックというのが計測結果から得られず、どこから改善を進めていけばいいかわかりにくい問題になっていたかもしれません。

さらに作問過程で要素を追加していった結果、一部のエンドポイントを変更するとそのエンドポイントの仕様は変わらないが他エンドポイントでベンチマーカーに不整合として検知されたり、isuride-matcherの存在によって複数台構成に移行した際にベンチマークがfailしてしまったりとかなりピーキーな実装の状態で皆さんに解いていただく事になってしまいました。これに関しては十分な準備を行って皆さんに気持ちよく解いてもらう事ができず反省の限りです。

インフラの構成を含めて丁寧な全体把握とドキュメントやアプリケーションの仕様も含めて、かなりボリュームのある問題になったと思いましたが蓋を開けてみると1人チームのtakonomuraが2回目の優勝ということになりました。一人で着実に改善を積み重ねて優勝を勝ち取る実力の高さには脱帽でしかありません。おめでとうございます!

ここからはいくつかのトピックに分けて講評を行いたいと思います。

SSEへの移行
作問としては、Server-Sent Eventsであればベンチマーカーの対応によってWebアプリケーション側が複数の振る舞いをしても対応できるなという密かに温めていた夢を実現できて良かったです。

移行するか否かについては「ドキュメントに書いてあるからとりあえず移行してみよう」ではなくこちらからステータスの変更を迅速に送ることができるなどの移行によるメリットや移行のためのコストなどを勘案して移行するかの判断をすることが必要だったのではないかと思います。JSON APIとしての振る舞いでも、
retry_after_ms
プロパティなどによってリトライ時間を制御してスコアを伸ばしているチームも多くありました。

こぼれ話として懇親会では「ISUCON10本選の通知のWeb Push化を思い出して、移行したからと言って必ずスコアが上がるわけではないだろうと思って移行は最初から捨てた」という話も聞きました。懸命な判断だと思います。

一方でSSEという概念を初見のチームにとっては移行しなければ!となっても比較的難易度が高くなってしまっているかもという懸念からドキュメントとしてメッセージの送信例などは提供しましたが、もう少し補助として、例えば、参考実装からの最小変更でSSEに移行するような疑似コードとかは提供しても良かったかもと思っています。

生成AIの活用
昨年よりも格段に強力になった生成AIを利用しているチームも数多くありました。参加ブログには記載しないでもGitHub CopilotやCursorなどを利用している方も多くいたのではないかと思います。利用用途としてはSQLクエリやアプリケーションコードの改善に利用されているのを多く見ました。特にN+1を改善するような修正については一発で正解のクエリを出してくれることもあったようです。

一方で、アプリケーション仕様全体の把握やドキュメントのサマライズ・ドキュメントから改善のヒントを得るような目的での利用は思ったよりも少なかった印象です。ドキュメントのMarkdownをリポジトリに取り込んでコンテキストとして含ませたりするとより精度の良い改善や提案を受けられるかもしれません。(来年にはそれで勝手に改善から計測までやってくれるようなエージェント型のAIやエディタも普及しているかもしれないですね…)

謝辞
最後に改めて、フロントエンドチームとして協力してくださったLINEヤフーの皆さん、本当にありがとうございました。皆さんにフロントエンドをお任せできたおかげで最後まで問題・ベンチマーカーに集中でき、納得の行く問題を作ることができたと思います。

アドバイザーとして加わって頂いたfujiwaraさん。fujiwaraさんのアドバイスと試し解きで多くの問題の改善とバグの修正をすることができました、ありがとうございました。

また、各言語の実装を担当して頂いたpastakさんnana4gontaさんeagletmtさんzhanponさんkyoto7250さんhanhan1978さんytakeさんanatofuzさんkobakenさん。実装へのコメントや修正PR、ギリギリの仕様変更にも素早く対応いただいてありがとうございました。 

カケハシの941さん、LINEヤフーのsatoruさんをはじめとする皆様。イベントに協力いただいたスポンサー企業のみなさま、参加者の皆様、本当にありがとうございました。
これからもISUCONが長く盛り上がり続けていけるよう、少しでも協力できたらと考えていますのでよろしくお願いします。

----
ISUCON14は 2025年 1月17日まで感想戦モードになっています。楽しめるポイントが沢山ある問題になっていると思うので、ぜひ年末年始にもお楽しみください!
また、感想戦モードではISUCON14には参加していなかった皆さんも新規に登録して、本番と同じポータル・ベンチマーカーで今回の問題を解くことができるようになっています。この記事を読んで興味が湧いた方は https://portal.isucon.net/registration からチャレンジ可能です!また、お近くのISUCONを知らない方・知っているけど参加をためらっている方がいたらぜひ宣伝してください!