math314のブログ

主に競技プログラミング,CTFの結果を載せます

ISUCON12予選にチーム Takedashi として参加し、予選通過しました (52957点)

2022-07-23のISUCON12予選にチーム Takedashiとして参加して、52957点で予選通過しました。 予選通過はISUCON8以来だと思います。その間に子供が二人産まれており、時間の流れを感じますね。

リポジトリはこちら github.com

メンバーの @chibieggも記事を書いているのでこちらもどうぞ http://blog.chibiegg.net/2022/07/24_15_864.htm

メンバーはいつもの

アイコン メンバー 役割
chibiegg インフラ/デプロイ/プルリク管理/データ整備/コーディング
__math 司令塔/コードリーディング/コーディング
misodengaku コードリーディング/ミドルウェアチューニング/コーディング

となっています。今回は絵に描いたように上手に物事を進められたのがよかったです、100点満点!

スコア推移:

司令塔の動き方

他の2人と話し合ったわけではないのですが、今年は勝手に自分の動き方を変えていました。スクラム開発?

リスクマネジメントを最も重要視しました 具体的には以下の3つの項目を頭の中で比較して、改善内容をチームの人にお願いしていました。

  • リスク : 実装の難易度、手戻りが発生する確率、実装が失敗した時のrevertは容易か
  • 費用 : 実装からベンチマーク実行終了の合計時間。 実装が20分、デプロイ1分、ベンチマークが2分なら23分
  • 効果 : 実装後のベンチスコアの上昇割合。 1000 -> 1500 なら 1.5倍

これらをベースに優先度を付けます。 リスク、費用は個人の特性や実装内容で変わるので難しいですね。

我々のチームは何回か本選に出場したことがあるので、スコアの安定感を高めることが出来れば予選突破の安定するだろうと思いリスクを取りすぎないようにしました。 長年固定メンバーで色々な大会に出ていると、各々の得意分野、実装時間の見積もりの精度が上がって良いですね。毎年違うメンバーで出る場合はこのあたりの戦略が変わるだろうなと思います。

改善点の見つけ方

ボトルネック以外は目をつむる、これに尽きます。 優先順位は

  1. 各種メトリクスを見る
    • ベンチマークを走らせて、nginxのアクセスログやアプリケーションのプロファイル(各種言語で書かれたisuports, mysql等), サーバの負荷を見る。
    • 逆に言えば、各メトリクスを見ずにN+1を潰していくのはギャンブル要素が強いので、予選ではおすすめしません。本選で優勝以外狙ってないとかなら別かも。
  2. アプリケーションで遊んで仕様を一通り確認する
  3. 規約とかを読む
  4. コードを読む

です。

最序盤はメトリクスが出揃ってないので、その間に規約やコードを読んだりしておくといいかと思います。コードを読むのが一番優先度が低い理由ですが、自分はほっといたらコード読んでるのでわざと優先度を下げてます。

実装方針の伝え方

序盤は改善点が大量にあるので、見つけた改善点、気づきをGoogle Docにまとめてチームの人に見せていました。 中盤後半は手が打てる数点しかないのと、slack + 脳内だけで整理しきれるのでdocには残してません オフラインならホワイトボードでいいんですけどね…

本番ではこんなのを書いてました

本番時系列

9:00 本番前

vscodegolangubuntuに入れたり、公開鍵がちゃんとgithubに登録されてるか確認したり…

10:00 競技開始

  • @chibiegg : CloudFormationでプロビジョニング開始
  • @misodengaku, __math: 規約とかを読んでる

10:10 初回ベンチマーク

  • インスタンスが立ったのでとりあえずベンチを走らせる
  • @chibiegg: コードをprivate repoに上げる
  • @chibieggと@misodengakuにpprofとかnginxのログ、mysqlのスローログとかを取れるようにお願いする

10:?? アプリを試す、コードを眺める

  • MySQLSQLiteが併用されていることに気づく。各tenant毎にsqliteのdbファイルが切られているが、各クエリにtenantId = ?という文字があるので、マージできそうだなと漠然と考えていた。
  • とりあえず MySQLをisuportsと別サーバに移動

  • dispenseIDという、"admin" MySQLに依存したID発行関数があり、uuidに変えようか迷ったものの、まだ読んでない箇所もあるのでコードリーディングを続行。

11:00 nginxのログからボトルネックの確認

  • @__math: CLIを使いたくないのでmysql-workbenchを入れ始める
^/api/player/competition/[id]/ranking
^/api/player/player/[^/]+$

の2つのエンドポイントが遅いことがわかる。visit_history以外のMySQLの負荷は低いので、go+SQLiteのisuportsのIO waitかCPUだろうとあたりを付けて、この辺のコードを読み込む。 * sqliteのテーブル構造と、特にplayer_score tableの使われ方を把握する。

11:00 visit_historyの改善

  • @chibiegg にとりあえず visit_history のindexを貼ってもらう。MySQLの負荷が大幅に下がり、go + sqliteの負荷がボトルネックになることを確認。 現在2台構成 (go+sqlite, mysql)で、go+sqliteのCPUが張り付いていたので、dispenseIDはとりあえず放置決定に。

11:xx player_scoreの改善

  • player_scoreのレコード量が減らせることに気づいたものの、ここで間違ってたら手戻りがひどいため他の人と理解が正しいかダブルチェックしてもらう。
  • 各(tenantId, competitionId)毎に、同じプレイヤーのレコードは1つまでしか必要ないことがわかったので、2つの事をしたくなった
    • SQLiteのデータ量を減らす
      • 初期データをいじるので少しリスクが大きめ
    • competitionScoreHandler でinsertするデータ量を減らす
      • 間違っていてもinitializeすれば容易にデータが戻せる

ということでまず後者を実装してもらう : github.com

ベンチが通ったので、前者の処理をしても大丈夫そうだと確証が取れる。

11:xx SQLite -> MySQL

  • 並行して、SQLiteのデータをMySQLに移動できないか試してみる。
    • 十中八九SQLite周りがボトルネック
    • isuportsとMySQLに分けることで、暇をしているマシンに自然と負荷分散が出来る
      • その分isuports <-> SQLite では発生していなかったnetwork IOが増えるのでパフォーマンスが改善するとは限らない
    • MySQLのパフォーマンス解析に慣れている
    • データのdump, sqlite -> mysql の移行には人の手が掛からない上にrevertも必要ない。リスクも人的リソースもほとんどかからない。

作業は2段階に分けた。

  1. merge: 1 ~ 99.db を 1つのsqliteファイルにする
  2. migration: 1つになったsqlite -> mysql に流す

分けた理由: * "merge"が完了するだけでコードの見通しが良くなり、複数テナントにまたがるSQLの実行回数が1回になる。IOもCPU使用率も減りそう * "merge"をするだけでいちいちconnectionをopen, closeしているコードが全部消える。現状のままでconnectionを貼り続けるコードにするのはバグりやすそう * "migration"に失敗しても手戻りがほとんど発生しないのでリスクが低い

ふたりとも忙しそうだし、__mathが一部担当。 chibieggの手が空いたら交代してもらうことに。

12:10 細々とした改善

  • competitionScoreHandlerはBulk Insertに出来る。実装は軽いしバグりにくそう、将来ボトルネックになりそう、ということで @chibiegg にやってもらう。 github.com
  • @misodengakuに引き続きpprof周りをいい感じにやってもらう。
  • sqliteのmergeを行っている。dumpは一瞬だったがdump後1つのDBに流しこむのに時間がかかる。
    • 1.dbをコピーして、それに2~99.dbの中身を詰め込めばいいのでは?と思ったものの、それよりはデータ量を減らす方がいいだろうなと思い放置。

12:30 SQLiteにindex

  • go+sqliteのアプリのIOWaitではなくCPUの負荷が高いので、SQLiteにいい感じのindexを貼ってもらう。

12:40 1つのSQLiteファイルにマージ後を見越した改善

  • pprofを見ると基本的にsqlx.GetConnectが時間を食っている事がわかる。メインの方針は変わらず。
    • GetConnectscanAny等の別関数を呼び出していて、そこに時間がかかっている。名前通りconnectionを貼るのに時間がかかっているわけではないっぽい
  • @__math: tenantDBを1つにmergeした前提のコードを書き始める github.com

13:38 visit_historyの削減

  • visit_historyの不要なレコードを生成しないように。また、不要なレコードの削除も
  • 未だにsqliteのmergeは終わらず。 player_scoreのデータを大幅に減らせば移行が一瞬で終わりそうという話を伝えて @chibieggに丸投げする。 github.com

13:49 sqlite内のデータの削減

  • @chibieggがsqliteの不要レコードの削除に成功、ついでに1つのsqliteのファイルにまとめる。

14:03 MySQLへ移行

  • レコード数が減ったので、 sqlite -> MySQL の移行も一瞬で終わる。 github.com 現在の構成は
    • 01 : isuports
    • 02 : tenant DB (MySQL), sqliteの中身全部移行済み
    • 03 : Admin DB (MySQL)

ベンチを回すとそれぞれのCPU使用率が 100, 200, 150% 程度。tenantDBのCPUがボトルネックである。

14:49 N+1の解消

  • competitionRankingHandlerのN+1を解消する。 github.com

14:58 flockDBMSトランザクションに (40000+)

既に予選突破が見えてきているので、あまりチャレンジャーな事をしないように意識し始める。

15:30 rankingのクエリ最適化

  • @__math : INSERT時にrankingを作れば limit 100 offset ? が消せるので、これと適切なindexを合わせて負荷軽減。 github.com
  • @ misodengaku : playerHandlerのN+1を消してもらう github.com github.com

16:10 retry-after による負荷コントロール

  • たまに500が返ってきて スコア-10%を喰らい始めたので competitionsAddHandler で 429 Retry-After を返すようにお願いする。
    • 負荷を見ながら429を返す実装は難しいので、10%の確率で429を返すようにした。 github.com
  • @misodengaku にはmysqlの設定の調整をしてもらう
  • 予選通過と予選一位のどっちがいいかという話で、守りに入りましょうという結論になる。

16:58 一部をインメモリに

  • @chibiegg,@__mathで playerplayer_scoreをキャッシュするように改造。終わってから思ったんですけど、これは守りに入ったチームの戦略なのだろうか。 50000くらい出始める。 github.com github.com github.com

17:01 INSERTの完了を待たずにOKを返す (50000+)

  • 最後に @__mathが visit_historyのINSERTを非同期に github.com

17:05 再起動テスト

  • 再起動試験を始める
    • @misodengakuと@chibieggがログの出力の抑制、systemdの見直し
    • その後全員で再起動後のテスト、DBが立ち上がるのが遅い場合にちゃんと動くかの確認などをする。 github.com
  • 再起動テスト中に52957点が出る。あんまり高い点数が出て85%ルールに引っかかるとつらいので、ここでベンチマークを終了する。

  • 再起動テストも終了してお開きに。

おまけ

準備表のバグで度々1,2位を独占して何だか嬉しかった