こんにちはdas08です。 ISUCON14参戦に向け感覚を取り戻すべく過去の予選問題を解きました。
ISUCONは8時間で改善の余地のあるWebアプリをチューニングして高速化しスコアを競う大会です。
今回はISUCON11予選の過去問をメンバー3人で解き、歴代最高スコアの228万点(2024/10/26; 自分調べ)を達成したのでその改善手法をまとめておこうと思います。
過去の11予選参戦記はこちら→ ISUCON11予選を学生枠で通過してしまった - das08’s blog
チームメンバー
- das08
- tinaxd
- arakistic
チューニングの流れ
以下時系列に沿って開始時間から最高点達成までのチューニング箇所について簡単に紹介します.
0:00 ISUCON開始 (Score: 1724)
今回もGolang実装をベースに改善していきます.初期ベンチのスコアは1724点でした
0:30 マニュアル読み・サーバ分離 (Score: 7826)
アプリケーションの構成が,APP+DB (MySQL)のいつもの構成であるため初手分離しました. 基本的に1台構成時にCPUが張り付いている場合は,ボトルネック特定を明瞭化するためにサクッと分離することが多い気がします.
- Server1: Nginx + APP
- Server2: DB
- Server3:
0:45 Index追加 (Score: 33308)
Indexが一部足りていなかったので追加しました.
CREATE INDEX idx_isu_uuid ON `isu_condition` (jia_isu_uuid); CREATE INDEX idx_jia_isu_uuid_timestamp ON isu_condition (jia_isu_uuid, timestamp);
1:30 Icon剥がし (Score: 37465)
アイコンの画像データがDBに格納されており、APPとDBのIOに比較的影響を与えていたのでファイルとして書き出しAPPから配信することにしました. Nginxからstaticファイルとして配信するのがベストですが、認証に応じてアイコンを返すどうかをAPPで判断していたためNginxに移譲しないことにしました.
1:45 postIsuCondition()のバルクインサート化 (Score: 43608)
postIsuCondition()ではISU(椅子)の状態(condition
)が複数まとめてPOSTされることがあるのですが,それを1件ずつ直列InsertするのではなくBulk Insertに変えることでAPP-DB間の通信を減らしました.
3:00 getUserIDFromSession()をインメモリキャッシュ (Score: 54841)
ユーザ認証関連で毎回DBにアクセスが走っていたのですが,今回のアプリケーションの特性上一度登録されたユーザの認証情報は変更されないためAPP上でインメモリに保持するよう変更しました.
4:00 condition を 3ビット整数で表現 (Score: 58599)
DB内のデータでISUの状態レベル(conditional_level
)が正規化されておらずカンマ区切りのような形式で格納されていたためクエリを絞り込めない状況でした.
そこで,この段階で正規化を行い整数で表現することで効率化を図りました.
4:25 /api/trendのキャッシュ (Score: 61355)
この段階でalp
を確認すると、以前として/api/trend
のResp. avg.が非常に遅かったためボトルネックのままでした.
Singleflightで返すことも検討しましたが、アクセス回数は1000回前後で少なかったためあまり効果はなさそうとの判断になりました.
そこでマニュアルを見ると最新情報の反映がある程度反映が遅れても良いとの記載があったため、/api/trend
に表示されるISU conditionをキャッシュすることにしました.
APPへ貫通させずに前段のNginxで1sキャッシュすることで対応しました.
4:40 postIsuCondition()の高速化 (Score: 67655)
postIsuCondition()も律速の1つであったため高速化を行いました. POSTされたISUが正しいものかをバリデーションするために,
SELECT COUNT(*) FROM `isu` WHERE `jia_isu_uuid` = ?
のコマンドがN+1で実行されていたためこの辺りをキャッシュし高速に判定できるように変更しました.
4:50 getIsuGraph()の高速化 (Score: 111645)
ISUの過去の状態を時系列で表示する箇所(getIsuGraph())が律速になっており、スコアにも影響が出そうとのことで改善することにしました.
ここでは前日までのデータはキャッシュする方策を取りました.
6:00 APPの分離 (Score: 200044)
そろそろ煮詰まってきたのでhtop
を確認しているとServer1のCPU使用率が100%で頭打ちになっていることからAPPの分離を検討することにしました.
登録ユーザが増えるとPOST /api/condition
が急増することがなんとなく掴めていたので、これと/api/trend
をServer3に移すことにしました.
- Server1: Nginx + APP
- Server2: DB
- Server3: APP
6:50 dropProbabilityの変更 (Score: 0~220000くらい)
POST /api/condition
では初期状態でdropProbability=0.9
の確率でリクエストを落とす設計になっておりました.
POSTで登録されたcondition
の数に応じて得点源となるグラフが生成されるので本来であればdropProbability=0.0
がベストでした.
しかし,dropProbability=0.0
にしてしまうとその分/api/trend
の変化が激しくなり新規ユーザの増加→更なるPOST /api/condition
の増加といった正(負?)循環が発生してしまっておりました.
7:20 /api/trendの永久キャッシュ (Score: 1543638)
その分
/api/trend
の変化が激しくなり新規ユーザの増加→更なるPOST/api/condition
の増加といった正(負?)循環が発生してしまっておりました.
このベンチマーカのシナリオから推察するに、ユーザの増加に伴い爆発的にPOST /api/condition
が送信されるようになるため新規ユーザの登録を抑え、既存ユーザのPOST /api/condition
を最大化することでISUグラフ表示による加点を目指しました.
したがって、/api/trend
を一切更新しないことで新規ユーザは興味を失い、新規登録を行わないのではないかという方策を取りました.
// trendは初回の結果を格納し、それ以降更新しない if isTrendCached { return c.JSON(http.StatusOK, trendCache) }
これが功を奏し、スコアは150万と爆増しました.これは当日1位のスコア146万を超えるハイスコアです.
7:50 /api/trendの一部更新 (Score: 2285987)
各サーバのhtop
を見ていると、CPU使用率が最大時でも7割程度と余裕がある状況でした.
そこで多少の新規ユーザを取り込むことでPOST /api/condition
を稼ぐ戦略を取りました.
具体的には確率的に/api/trend
を更新することで数人程度の新規ユーザを獲得できるように工夫しています.
updateProbability := 0.05 if rand.Float64() <= updateProbability { isTrendCached = false }
これで約3人の新規ユーザを獲得し,最終スコアは228万になりました!
この辺りでのalp
は以下のとおりです
まとめと反省
今回のチューニングではベンチマーカのシナリオを推測することでスコアを最大化する方策で最終スコア228万を達成しました.
しかしながら/api/trend
はほとんど更新されないため運営チェックで引っ掛かる可能性があります.実際のコンテストでは確率的にtrendを更新するのではなく、長スパンでゆっくり更新する方が良い気がしています.
その一方で、計測と改善を積み重ねた結果その他Endpointの律速がなくなり、新規ユーザをゼロにする戦略を取ることができたため単純なシナリオハックではないことに留意する必要があるかと思います.
引き続きISUCONを練習し今年こそは優勝を目指します!