今年も id:nonylene id:wass80 と出場しました。最終スコアは50,696点で予選突破できました。分析基盤 (Kibanaやプロファイラ) に流れてきたデータを見つつ作戦を立てて実装する、という流れがうまくはまって順調にチューニングを進められたのがよかったと思います。
最終的なサーバー構成
- isuports-1
- App (Go)
- nginx
- isuports-2
- DB (MySQL, player_scoreのみ)
- isuports-3
- DB (MySQL)
やったこと
リポジトリはこちらです。
分析基盤まわりの話を id:nonylene が書いてくれたので、こちらもあわせてどうぞ。
id:wass80 視点の記事もあるので、こちらもあわせてどうぞ。
テナントごとの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の回答を参考にしてマージしました。
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/ranking
で player
テーブルに対するN+1クエリが発行されていたので、IN句を使うように書き換えました。
billing
(課金レポート) のデータ移行とN+1を解決する
課金レポートを都度生成するのではなく billing
というテーブルに保存しておく実装を id:wass80 にやってもらい、初期データから課金レポートを書き出しておくところを実装しました。その過程で狂ったインタフェースの関数が生えておもしろかったです。
そのあと課金レポートを参照する箇所に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点ぐらい上がってウケました。
分析用グッズを剥がす
再起動試験の前に分析用の仕組みを剥がしてまわりました。これでさらにスコアが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万円をゲットしたいですね。