私が歌川です

@utgwkk が書いている

ISUCON12 チーム :old_noto_innocent: で予選突破した #isucon

今年も id:nonylene id:wass80 と出場しました。最終スコアは50,696点で予選突破できました。分析基盤 (Kibanaやプロファイラ) に流れてきたデータを見つつ作戦を立てて実装する、という流れがうまくはまって順調にチューニングを進められたのがよかったと思います。

最終的なサーバー構成

  • isuports-1
    • App (Go)
    • nginx
  • isuports-2
    • DB (MySQL, player_scoreのみ)
  • isuports-3
    • DB (MySQL)

やったこと

リポジトリはこちらです。

github.com

分析基盤まわりの話を id:nonylene が書いてくれたので、こちらもあわせてどうぞ。

nonylene.hatenablog.jp

id:wass80 視点の記事もあるので、こちらもあわせてどうぞ。

memo.wass80.xyz

テナントごとのDBをMySQLに統合する

MySQLの様子を見ていて、あれ、競技のデータはどこ? と初期化スクリプトを見にいったらなにやらファイルをコピーしている、SQLiteだ!! となったのが一番の衝撃でした。 SQLiteもインデックスを貼るとかはできるけど、これは慣れた土俵に持っていくのがよい、ということでテナントごとのDBをMySQLに統合することにしました。

マルチテナントなのに各テーブルに tenant_id カラムがあり、さらにsqlite-to-mysqlというスクリプトが用意されていたので、これは移行しろというメッセージなのか? と思いながら試行錯誤しつつデータ移行ロジックを完成させました。 初期化がギリギリで、素朴にsqlite-to-mysqlスクリプトでテナントDBの初期データから持ってくるのでは全く間に合わないため、移行済の初期データのmysqldumpを取って流し込むことでなんとか30秒以内に収めていました。

完全にMySQLに統合するのに2時間ぐらいかかりましたが、なんとか統合できました。ここで統合を完遂できたのが一番のバリューだったと思います。

テナントごとのDBをMySQLに統合するにあたっては、各DBをマージした1つのDBファイルを用意する→sqlite-to-mysqlでMySQLに流し込む という流れを取りました。以下のStackoverflowの回答を参考にしてマージしました。

stackoverflow.com

github.com

player_score をUPSERTできるようにする

player_score は最新のデータしか参照していないので、履歴を持たないようにできます。(tenant_id, competition_id, player_id) の複合主キーにすることでUPSERTできるようになりました。

visit_history をUPSERTできるようにする

visit_history は履歴データになっていますが、アプリケーションではユーザーが最初に訪れた履歴しか参照していないため、そのようなデータ構造になるように書き換えました。

このあたりで初期データの行数がぐっと減ったことで、初期化でmysqldumpを流し込むのが3秒ぐらいで終わるようになりました。

(2022/7/24 23:33 追記) UPSERTって書いてるけど最初の履歴だけ残したらいいのでINSERT IGNOREでいいですね。

player のN+1を解決する

GET /api/player/competition/:competition_id/rankingplayer テーブルに対するN+1クエリが発行されていたので、IN句を使うように書き換えました。

billing (課金レポート) のデータ移行とN+1を解決する

課金レポートを都度生成するのではなく billing というテーブルに保存しておく実装を id:wass80 にやってもらい、初期データから課金レポートを書き出しておくところを実装しました。その過程で狂ったインタフェースの関数が生えておもしろかったです。

github.com

そのあと課金レポートを参照する箇所にN+1クエリがあったので解消しました。 ISUCONでは基本的にINクエリで解決する方針を取ることが多いのですが、ここはJOINで書いたほうが楽だろう、ということで久しぶりにLEFT JOINを書きました。

SELECT c.id, c.title, IFNULL(b.player_count, 0) AS player_count, IFNULL(b.visitor_count, 0) AS visitor_count
FROM competition c
LEFT JOIN billing b ON c.id = b.competition_id
WHERE c.id IN (?) 

インデックスを貼る

スローログを見つつ、明らかに足りないインデックスを足してまわりました。

CREATE INDEX tenant_id ON competition (tenant_id);
CREATE INDEX tenant_id_created_at_desc ON player (tenant_id, created_at DESC);

player_score 用のDBを参照する

MySQLの負荷がまだ高いということでDBを分割することになりました。このあたりはサーバーを手際よく準備してもらっていたので *sqlx.DB のインスタンスをもう1つ用意して差し替えるぐらいでサクッと参照を分けられました。

DSNに interpolateParams=true を足す

SQLのプレースホルダ置換を有効にするアレです。スコアが10000点ぐらい上がってウケました。

dsas.blog.klab.org

分析用グッズを剥がす

再起動試験の前に分析用の仕組みを剥がしてまわりました。これでさらにスコアが13000点ぐらい上がってまたウケました。

やってないこと

flockを剥がす

テナント単位でflockを取っているのをなんとか剥がせないか、とアプリケーションロックを使う実装を用意していましたが、参加者やコンテストが増えすぎるのか減点が大きくなって導入を断念しました。429を返してコンテストが増えすぎないようにするのも試したけどあまりうまくいきませんでした。キャッシュの導入ができているともうちょっとマシになったのかも?

ID採番ロジックを変える

採番用のテーブルにREPLACE文を発行しまくるおもしろ採番ロジックを剥がそう、と単なる乱数にしてみましたが整合性チェックに落ちるようになったので差し戻しました。uuidとか使えばよかったのかも? あるいは何もしてないRedisが立っていたのでkatsubushiを導入してほしいというメッセージだったのかもしれません。

キャッシュを入れる

今回はとくにどこにもキャッシュを入れることなく終わりました。先述したようにflockを剥がすにあたってはキャッシュでいくらか負荷を低減できているとよかったのかもしれません。

感想

インフラ周りの調整や分析基盤の用意は id:nonylene に、分析や作戦立ては id:wass80 にだいたいやってもらっていたので、自分はひたすら手を動かすことに専念できました。手が速いことが自分のバリューだと思っているので、今回はかなり本領を発揮できたのではないでしょうか。

今回は初手でSQLiteのデータをMySQLに統合しましたが、SQLiteのままスケールさせるにはどうするのが正解だったのでしょうか。SQLiteのまま予選を突破したチームがいれば話を聞いてみたいです。

(2022/7/24 21:51 追記) Discordを見ている限りだとSQLiteを残したままスコアを上げたチームのほうが多そうでした。みんなMySQLに移行しているものだと思っていたけど意外と少数派だったのか……。

去年の反省をもとに、初期化スクリプトをちゃんと読む・いきなり必殺技に手を出さない、を心がけて進められたのもよかったです。

今回の予選問題もかなりクオリティが高く、ベンチマークもスムーズだったように思います。運営の皆様ありがとうございました。今年こそは100万円をゲットしたいですね。

ベンチマーク履歴

gyazo.com