ISUCON12の予選通過しました

このブログではとってもお久しぶりです。
最近は YouTubeチャンネルFantiaファンクラブ を始めたりしてみてます。 昨日 (2022-07-23) はISUCON12のオンライン予選でした。
今回もいつも通りのメンバー (@__math, @misodengaku, @chibiegg) で チームTakedashi として参加しました。
分担は明確には決めてないもののいつもの感じで以下の通り。

chibiegg (私) インフラ/デプロイ/プルリク管理/データ整備/コーディング
__math (まーす先生) コードリーディング/修正方針の検討/コーディング
misodengaku (みそでん) コードリーディング/ミドルウェアチューニング/コーディング

今回の予選問題は、 マルチテナントSaaS型 ISUCON 「ISUPORTS」 (eSportsではない) のリーダーボード (スコア表示画面) でした。
残り1時間でスコア表示が固定されたタイミングで5万点弱、1位と2位を独占してました (単なる表示上のバグです笑)。
この時点までの得点推移はこんな感じでした。
ISUCON12予選 スコアボードロック時点での遷移
この時点でこれ以上スコアを上げるのはやめて守りにはいり、再起動試験を行いベンチマークを実行。
再起動後の 17時22分 に 52957点(ベストスコア) を出したので作業完了としました。

先にまとめを書くと、今回は地道な下準備を5時間ぐらいやったおかげで最後の方にわかりやすく効果的な対策を入れることができたことが勝因だと思います。

5時間もデータクレンジングやプロファイリング、DBの載せ替えなんかやってスコアが全く上がらないと不安になりがちですが、その先の修正の見通しを信じて落ち着いて作業したのがよかったですね。
あと、いつものことですが3人の作業分担が完璧なのも重要です。

ここからは主にどんなことをしたか時系列で紹介しようと思います。

10:00 コンテスト開始

私がすぐに CloudFormation サーバでサーバを作成。

作成が完了したらSSHログインし、ホームディレクトリとnginxの設定をgit管理下に置く。
.gitignore を作成し、初期データなど重いデータや一時ディレクトリなどを除外するのが重要。
3台のサーバで設定を変えることを想定して、docker-compose-go.ymlやnginxの設定はホスト毎に別になるように調整。

アプリケーションサーバとデータベースサーバはいつも分けることにしているので、01をアプリケーションサーバ、03をDBサーバとしてつかうようにする。

この間他の2人にはドキュメントをよく読んでもらう。コードをgitにコミットした時点でみそでんがpprofを入れていた。

この時点で 10:33 ぐらい (コミット b077bcd 時点)

10:53 visit_history に INDEX を入れる

admin DB の visit_history テーブルにいい感じのINDEXがなかったので入れる。(コミット 74c0d70)

INDEX `tenant_id_competition_id_idx` (`tenant_id`, `competition_id`)

みそでんがNginxのログをltsvにしたり、alpで集計するための準備をしてた。 (コミット d5ce51b 時点)

11:53 competitionScoreHandler (CSVアップロード) において、必要なデータのみをINSERTする

これまでの間にまーす先生がコードを読んでどこが重いかどうやって直すかを検討してくれているので着手する。

player_scoreテーブルには全てのスコアが記録される構造になっているが、実際に利用されるのは各プレイヤーの最新情報だけであることがわかっていたので、CSVの全件をINSERTせずに最新の情報のみをINSERTするようにした。(PR #1 必要なものだけINSERT)

12:10 competitionScoreHandler においてバルクINSERT

上記修正の直後に、1件INSERTからバルクインサートにした。 (PR #2 player_scoreをBulkInsert )
最後の方で修正するが、0件の時には実行しないようにしないとsqlxがエラーを吐くので注意。

13:38 visit_history の重複排除

visit_historyテーブルにはPlayerのアクセスログが残っているが、こちらも全てのレコードは利用されない。

UNIQUE INDEX を作成し、いらない場合はレコードが作成されないようにした。
PR #4 distinct visit history

13:49 テナントDBを一つにする

この作業が最も重かった。オリジナルの実装ではテナント毎にSQLite3のDBファイルが作成されるようになっている。我々はテナントのDBもMySQLにしたかったし、複数のテナントの情報を一つのテーブルに入れたかった。

みそでんにpprofを見てもらってもSQLiteの処理が大半でSQLiteを剥がしたい気持ちでいっぱいに。
そこで、MySQLに変更する前段階として SQLIte3 のDBを一つにする作業に着手する。
私が初期データのファイルを一つにマージする作業、まーす先生がそれに合わせて実装を変更する作業をすることにした。

最初は愚直にSQLIte3の初期データを全てSQL文に変換し、一つにしたSQLIte3データを作成しようとしたが件数が多すぎて全然作業が進まない。そこで、先に各データベースの中身をスリム化することにして、以下のようなSQLを発行した。これによって初期DBのサイズを300MBぐらいから10MBぐらいまで削減でき、簡単に扱えるようになった。

これは先にやった対策の通り、player_score は competition, player 毎に最新の情報しか使っていないため古いレコードを削除する処理である。

CREATE TABLE player_score_new (
  id VARCHAR(255) NOT NULL PRIMARY KEY,
  tenant_id BIGINT NOT NULL,
  player_id VARCHAR(255) NOT NULL,
  competition_id VARCHAR(255) NOT NULL,
  score BIGINT NOT NULL,
  row_num BIGINT NOT NULL,
  created_at BIGINT NOT NULL,
  updated_at BIGINT NOT NULL
);


INSERT INTO player_score_new 
SELECT
    a.id, a.tenant_id, a.player_id, a.competition_id, a.score, a.row_num, a.created_at, a.updated_at
FROM
    player_score as a
    INNER JOIN (SELECT
                    b.tenant_id, b.player_id, b.competition_id,
                    MAX(b.row_num) AS row_num
                FROM
                    player_score as b
                GROUP BY
                    b.tenant_id, b.player_id, b.competition_id) AS t
    ON a.tenant_id = t.tenant_id AND a.player_id = t.player_id AND a.competition_id = t.competition_id
       AND a.row_num = t.row_num;

DROP TABLE player_score;
ALTER TABLE player_score_new RENAME TO player_score;

VACUUME;

この後、1.dbから99.dbまでのデータを統合した merged_tenant.db を作成し、これだけを使うようにした。
PR #3 merged tenants

14:03 テナントDBをMySQLに置き換える

上の作業によってテナントのDBが一つに統合されたので、MySQLへの移行は簡単だった。
MySQLにINDEX付きでいい感じにテーブルを作成し、マージしたDBを作成する時に使ったSQLをそのまま流し込んだ。

ここで重要だと思っているのは、Adminデータベースとテナントデータベースを別のサーバに設置したことだと思っている。
03をAdminデータベース、02をテナントデータベースにすることで負荷を分散するようにした。
この辺りでCPU利用率が アプリケーションサーバ(01) 100%ぐらい、テナントDB (02) 200%ぐらい、Admin DB (03) 150%ぐらい、だったので分けたのは多分正解だと思う。

この時点では実は全然スコアは上がっていない。ここまでの下準備があればこの先スコアが上がることがわかっているので地道に作業をしている感じ。
PR #5 tenant mysql


14:42 competitionRankingHandler の改善

まーす先生が competitionRankingHandler の改善をしていた。
player_score に最新のデータのみが入ることなったので、順位を出すのが簡単になった。
PR #6 optimize competition-ranking-handler, #8 removed distinct check, #10 optimize ranking

14:58 トランザクションの採用

予選後のLiveの講評でfujiwaraさんがおっしゃっていた通り、なぜかこの開発者はトランザクションを知らないことになっており、ファイルロックが利用されている。

これは遅いので適当にトランザクションを使うように実装を修正した。

この時の修正は雑すぎて、トランザクションが不要なSELECTでもトランザクションを発行してるのはご愛嬌。

この時点で4万点を突破。

実はSQLiteの時点で、ロックを削除しただけのコード (データ整合性がないのでエラーが出るが) で2万点ぐらい出ることもあることがわかっていたが、MySQLにすでに載せ替えていたおかげでスコアが高かったと思う。
PR #9 トランザクション

15:25 playerHandle の N+1問題解決

この前後でみそでんにN+1問題の解決をしてもらっていた。
PR #11 playerHandler N+1

16:10 competitionsAddHandler で 429 Retry-After を返す

この辺りでまーす先生がこの仕様を思い出す。
ISUCONではよくあるが、負荷が上がりすぎてスコアが下がる問題を対策するために一部のエンドポイントで429を返すことが許可されていたりする。
今回はコンテストを開催するAPIで429とRetry-Afterを返すことでPlayerの参加を抑制することができた。

テナントDBに負荷がかかりすぎていたのでこれを実施、雑に10%ぐらい捨てる実装でスコアが高かったのでそうしておいた。
PR #13 competitionsAddHandler で 429

16:58 player と player_score でオンメモリキャッシュを利用する

重すぎる金額計算など本来やるべきことは見えていたが、この時点で十分スコアが高く大掛かりなことをやる気持ちは無くなっていた。
Zoomで通話をしていたが「スコア上げたい気持ちはあるけど、守りに入るのが正解よね」的なまーす先生の発言にみんなで同意 (笑)。
安全で簡単な作業として player と player_score をメモリにキャッシュすることにした。
PR #15 キャッシュ, #16 Playerのキャッシュ, #17 use cache on ranking

17:01 visit_history への INSERT を非同期実行に

ログのINSERTは非同期でいいじゃんと言うことでまーす先生がGoルーチンに。
PR #18 async visit_history

17:20頃 再起動試験

ここまでの間にみそでんがMySQLの設定をいじったりしてくれているので動作確認。

色々なログの出力を抑制をするような設定変更を施し、systemdの設定を見直し。
問題がなさそうなのでサーバを3台とも再起動。
再起動後にベンチマークをとった結果が最もベストで 52957点 を記録。
ミスをすると怖いのでこの時点で作業を終了、サーバからログアウトしコンテスト終了を待つ。

コメントを残す

メールアドレスが公開されることはありません。

question razz sad evil exclaim smile redface biggrin surprised eek confused cool lol mad twisted rolleyes wink idea arrow neutral cry mrgreen

*