ISUCON12予選に参加した

ISUCON12予選に参加しました。 最終ベンチ結果は6815点で、最高ベンチ結果は10625点、failの可能性は高いです。 もし結果発表でfailしていたらブログを書く気に多分ならないと思う(あと記憶力が弱い)ので、今のうちに当日やったことを中心に振り返っていきます。

追記 (2022/7/25): failしていました :cry:

自分がやったこと

ファイルロックがボトルネックになっているっぽいことを見つけた

alpのログを見ると、ランキングやプレイヤー詳細のAPIボトルネックになっていることが分かりました。

alpだとエンドポイント単位でしか見られないんですが、pprofはポートを立ててWeb UIから見なければ詳しくは見られない上にちょっと見づらい・・・ということで、このときから作っていたツール1 を使って、関数ごとにかかった時間を計測しました。

alpで一番重かったランキングAPI (competitionRankingHandler) を分割して見たところ、一番時間がかかっているのはSQL文ではなく、ファイルロックをしている部分(competitionRankingHandler 4)でした。

ファイルロックをする関数(flockByTenantID)も、時間がかかっていることが分かります。

ファイルロックを消した (failの戦犯)

お題のアプリケーションはマルチテナントSaaSで、管理側はMySQLを使っており、テナントに入っている側はそれぞれ別のSQLiteを使っていました。

ロックをかけている部分のコードには、

// player_scoreを読んでいるときに更新が走ると不整合が起こるのでロックを取得する

とコメントがあるのですが、DBの読み込み時にかかっている排他ロックが、読み込みが非常に遅い原因になっていました。

読み込み時には共有ロックをかけると読み込みが同時に行えて性能が上がるだろうということで、ファイルロックをやめてSQLiteのトランザクションを使おうとしました。

トランザクションに書き換えると、error retrievePlayer: error Select player: id=395f19764, database is locked というエラーが出てしまうものの、スコアは5000点から10000点近くまで上昇しました。

追記 (2022/7/25): 正しくトランザクションが貼れていてもこのエラーは出るので無視してよかったらしい。そんな・・・

いろいろ試行錯誤した結果、このエラーが出たり出なかったりしてスコアが0になったり10000になったりしました。これが失格の原因になるのは困るということで、⚠️ロックを一切かけない⚠️ことにしました。このあたりで既に完全に沼に入っているので、冷静な判断ができていればrevertして5000点から再開していたと思います。失格になったらこの変更のせいなのでごめんなさい(泣)

リーダーボードで上位だった方のコミット履歴を見ると、SQLiteからMySQLにテーブル単位で移していました。 SQLiteのロックを外すことを考えるよりも先にその戦略を取れば、普段使っているMySQLトランザクションが使えるのでやりやすかったと思います。 SQLiteからMySQLへのリプレースは弊チームではリスクが高いと判断して取り組まなかったことを考えると、部分的に置き換えるというのはやはりISUCONにおいて重要戦略であると思いました。

追記 (2022/7/24): ファイルロックをsync.RWLockとsync.RLockにする案は思いついたんですが、tenant IDごとにロックを分けるためにmapを作る必要があって、そのmapを管理するためにロックが必要で・・・となって面倒くさそうでやめてしまいました。tenant IDは固定でmapはreadだけなので、本当はそんなに難しくなかっただろうなと思います・・・こっちでやればよかった:cry:

ファイルロックをRLockにもしてみたんですが、failしたのでロックが取れるまで待つ実装は自前でやらなければいけないのかなと勘違いしました。It will wait until it is able to obtain the shared file lock. とあるので、その方針でも自信をもってやればできただろうと思います。

https://pkg.go.dev/github.com/gofrs/flock#Flock.RLock

チームメンバーがやったこと

IDジェネレータをUUIDにした (AokabiC)

スロークエリログを見ると、id_generatorという謎テーブルへのINSERTが一番遅いことが分かりました。被らない連番IDを取るために、MySQLにわざわざレコードを追加して採番していたので、これをUUIDに変更してくれました。

  REPLACE INTO id_generator (stub) VALUES ('S')

AokabiC氏の目の付け所はいつも洗練されているのですごいなあと思います。

App, DBの2台構成にした (zatton)

ISUCON12予選では3台のサーバーが与えられますが、AppとDBを分離するのは、 - htopをするとAppとDBのどちらがボトルネックになっているかわかる - 負荷が軽くなる の2点のメリットがあるため、早い段階で分離しました。

弊チームは、/etcなどの設定ファイルは権限周りが面倒なのでGit管理せずにzatton氏がVimでゴリゴリ直接書いていく方針で、zatton氏がサクッとやってくれました。

visit_historyへの書き込みを減らす (AokabiC)

visit_historyはランキング取得のタイミングで履歴を書き込むのですが、このテーブルは、最新のものしか取得されていません。そこで、visit_history_summerizeというテーブルを作って、最新のものだけを書き込むようにしました。インデックスも貼りました。

自分がやろうとしたこと

player_score を剥がす

ファイルロックがボトルネックから外れた後は、player_scoreテーブルを使う関数がボトルネックになってきたのでこれをRedisに移そうとしました。しかし、整合性チェックでfailしてデバッグする時間もなく断念。RedisではなくMySQLにInsertしている方が整合性に関して安心できるという点で良い方針だったのかもしれません。

チームメンバーがやろうとしたこと

3台構成にする (zatton)

3台構成にする際は、Appを2台(nginx+AppとApp)にするかDBを2台(リードレプリカや垂直分割)にするか、その他の構成にするかが悩みどころです。 今回は、2台構成にしたところAppのCPU負荷が100%に張り付いていたので、Appを2台にすることを考えました。しかし、Appサーバーのファイルシステム上にはSQLiteがあり、Appを2台にするならNFSなどで同期しなければいけないことが分かります。ということで、これは一旦放置することにしました。

player_scoreへの書き込みを減らす (AokabiC)

player_scoreもvisit_historyと同様に書き込みが減らせそうということで、取り組んでいましたが時間が足りず断念。

感想

既存のISUCON攻略法を封じてくるような問題だったので、面白かったです。 具体的には、DBがSQLiteMySQLに分離されていたので、MySQLのスロークエリログには出ないボトルネックがありました。

余談

当日は、SQLiteMySQLがトレンド入りして盛り上がりました。

ISUCON界隈の外からはMySQLトレンド入りの原因が謎だったっぽい。

ここは違うよ、というのがあれば、TwitterのDMで教えてもらえると助かります。


  1. 本当はこういう感じのラインプロファイラがGoにも欲しいんですけどね・・・ 自作ツールは需要があったら公開したいですが、品質が低すぎるので準備中です。