ISUCON参加歴
自分はこれまで4回、ISUCONに参加しています。これまでの結果は以下の通りです。
- ISUCON8予選: Goを選択。いきなり大掛かりな改修をしようとしてベンチが通らなくなり、爆死。
- ISUCON9予選: Rubyを選択。
/initialize
のレスポンス次第でベンチの負荷を変えられる仕様に気づかず、撃沈。 - ISUCON10予選: Rubyを選択。ASCとDESCが混じったorderにindexを効かせることができず、敗退。
- ISUCON11予選: Rubyを選択。いろいろ頑張ったんですがスコアが上がらず、爆散。
毎年「言語は関係ないんだ!」と、Rubyで挑んでいたのですが、ISUCON11では言語選択の差を感じる結果となりました。(詳細は こちら)
そこで今回はGoで出場することにしました。ちなみに自分のGoの利用経験は、弊社サービスである mockmock の開発で少し触ったことがある程度です。
チームメンバー
これまでは利用言語があまり変わらなかったので例年同じチーム構成で参加していたのですが、今年は他チームも含め選択言語を変えたので、チームも大幅に組み替えました。
パフォーマンスチューニングをテーマに数多くの登壇経験がある 清家
と、今回ISUCONに初参戦の 藤澤
でした。Goで戦うFusicのチームということで、チーム名は Gosic
となりました。
なお、本記事は岡嵜の主観で書いているので、他の2人とは認識が異なる点があるかもしれません。
午前の立ち回り
環境準備
まずはチューニングを始められるよう環境の準備から始めます。具体的には以下のようなことをしました。
- GitHubへアクセスできるようSSH鍵をサーバーに設置
- ソースコード、Nginx/MySQLの設定ファイル一式をGit管理
- 計測ツールのインストール
- オペレーションを楽にするMakefileの設置
予め作っていた手順通りに進めるだけなのですが、Git管理を始めるところで苦戦して、この時点で1時間ほど経過していました。
ちなみに、他のメンバーはアプリを触ってみたり、コードを読んだり、初回ベンチを実行したり、といった作業をしていました。
DBを覗いてみる
最初はDBからだろうという認識を持っていたので、MySQLへDBクライアントを接続しようとしたのですが、清家以外の2人はいつもの手順で何故か繋がらず。踏み台経由での接続をしていたので設定周りを見直したものの原因がわからず、結局DB周りの調査は清家に任せることになりました。
振り返ると今回はDB構成が肝な問題だったので、これは結構な痛手でした。それにしてもなんで繋がらなかったんだろう。。。
pprofで苦戦
GoでISUCONに挑む際の必須ツールとも言えるpprofをセットしました。Flame GraphやCall Graphはすぐに確認ができたのですが、Sourceだけ表示されず。
今回はアプリケーションがDockerコンテナ上で動く構成になっていたので、実行バイナリの指定が効いていなかったようです。清家がすぐにDockerを脱却して、CLIベースではありますがソースコードベースでボトルネックを可視化できるようになりました。
ちなみにDockerを脱却してもスコアはあまり変わりませんでした。
DBをサーバー3に逃がす
「早々にDBは別サーバーに逃がそう」という共通認識を持っていたので早々に逃しました。若干ですがスコアは上がりました。
今振り返ると、サーバーが分かれたことでMySQLとSQLiteのどちらにボトルネックがあるか分かりづらくなった気がしていますし、1人1サーバーでアプリケーションをチューニングという体制を作りづらくなったので、この時点でDBを逃がすのは時期尚早だったかもと反省しています。
リクエスト毎の処理時間を計測
alpを使って計測しました。結果はこんな感じ。
+-------+-----+-----+-----+-----+-----+--------+----------------------------------------------+-------+--------+----------+-------+--------+--------+--------+--------+-----------+-----------+-------------+-----------+
| COUNT | 1XX | 2XX | 3XX | 4XX | 5XX | METHOD | URI | MIN | MAX | SUM | AVG | P90 | P95 | P99 | STDDEV | MIN(BODY) | MAX(BODY) | SUM(BODY) | AVG(BODY) |
+-------+-----+-----+-----+-----+-----+--------+----------------------------------------------+-------+--------+----------+-------+--------+--------+--------+--------+-----------+-----------+-------------+-----------+
| 1 | 0 | 1 | 0 | 0 | 0 | POST | /initialize | 4.064 | 4.064 | 4.064 | 4.064 | 4.064 | 4.064 | 4.064 | 0.000 | 55.000 | 55.000 | 55.000 | 55.000 |
| 1 | 0 | 0 | 0 | 1 | 0 | GET | /owa/auth/logon.aspx | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 134.000 | 134.000 | 134.000 | 134.000 |
| 1 | 0 | 1 | 0 | 0 | 0 | GET | /index.html | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 479.000 | 479.000 | 479.000 | 479.000 |
| 1 | 0 | 1 | 0 | 0 | 0 | GET | /js/app.3a4ec98c.js | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 33294.000 | 33294.000 | 33294.000 | 33294.000 |
| 1 | 0 | 1 | 0 | 0 | 0 | GET | /css/app.83b4c321.css | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 4868.000 | 4868.000 | 4868.000 | 4868.000 |
| 1 | 0 | 1 | 0 | 0 | 0 | GET | /api/organizer/competitions | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 177.000 | 177.000 | 177.000 | 177.000 |
| 1 | 0 | 0 | 0 | 1 | 0 | GET | /bapi/fiat/v1/public/fiatpayment/menu | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 162.000 | 162.000 | 162.000 | 162.000 |
| 8 | 0 | 8 | 0 | 0 | 0 | POST | /api/organizer/players/add | 1.812 | 5.592 | 28.184 | 3.523 | 5.592 | 5.592 | 5.592 | 1.341 | 12150.000 | 26716.000 | 149219.000 | 18652.375 |
| 11 | 0 | 6 | 0 | 5 | 0 | POST | /api/admin/tenants/add | 0.004 | 0.144 | 0.460 | 0.042 | 0.068 | 0.144 | 0.144 | 0.042 | 39.000 | 204.000 | 1301.000 | 118.273 |
| 13 | 0 | 11 | 0 | 2 | 0 | GET | /api/admin/tenants/billing | 1.128 | 21.268 | 111.976 | 8.614 | 14.128 | 21.268 | 21.268 | 5.092 | 0.000 | 1556.000 | 16584.000 | 1275.692 |
| 15 | 0 | 14 | 0 | 1 | 0 | POST | /api/organizer/player/[a-z0-9]+/disqualified | 0.004 | 0.020 | 0.156 | 0.010 | 0.016 | 0.020 | 0.020 | 0.005 | 39.000 | 163.000 | 2237.000 | 149.133 |
| 23 | 0 | 23 | 0 | 0 | 0 | GET | /api/organizer/billing | 0.004 | 1.172 | 4.540 | 0.197 | 1.036 | 1.092 | 1.172 | 0.395 | 323.000 | 4628.000 | 41913.000 | 1822.304 |
| 26 | 0 | 26 | 0 | 0 | 0 | GET | /api/organizer/players | 0.004 | 0.016 | 0.184 | 0.007 | 0.016 | 0.016 | 0.016 | 0.004 | 2942.000 | 75980.000 | 560438.000 | 21555.308 |
| 38 | 0 | 37 | 0 | 1 | 0 | POST | /api/organizer/competition/[a-z0-9]+/finish | 0.004 | 0.020 | 0.428 | 0.011 | 0.016 | 0.016 | 0.020 | 0.004 | 21.000 | 39.000 | 816.000 | 21.474 |
| 42 | 0 | 40 | 0 | 2 | 0 | POST | /api/organizer/competitions/add | 0.004 | 0.056 | 0.984 | 0.023 | 0.032 | 0.040 | 0.056 | 0.009 | 0.000 | 180.000 | 6533.000 | 155.548 |
| 59 | 0 | 53 | 0 | 6 | 0 | POST | /api/organizer/competition/[a-z0-9]+/score | 0.136 | 15.804 | 139.260 | 2.360 | 6.788 | 7.336 | 15.804 | 2.791 | 0.000 | 62.000 | 3054.000 | 51.763 |
| 117 | 0 | 105 | 0 | 12 | 0 | GET | /api/player/competitions | 0.004 | 0.096 | 1.504 | 0.013 | 0.028 | 0.032 | 0.060 | 0.013 | 39.000 | 2817.000 | 132244.000 | 1130.291 |
| 506 | 0 | 455 | 0 | 51 | 0 | GET | /api/player/player/[a-z0-9]+ | 0.004 | 16.412 | 936.576 | 1.851 | 6.296 | 7.340 | 16.324 | 3.502 | 0.000 | 2048.000 | 365144.000 | 721.628 |
| 657 | 0 | 620 | 0 | 37 | 0 | GET | /api/player/competition/[a-z0-9]+/ranking | 0.004 | 16.408 | 1145.852 | 1.744 | 6.880 | 7.456 | 16.104 | 3.349 | 0.000 | 14603.000 | 5262000.000 | 8009.132 |
+-------+-----+-----+-----+-----+-----+--------+----------------------------------------------+-------+--------+----------+-------+--------+--------+--------+--------+-----------+-----------+-------------+-----------+
この時点でSUMの大きいrankingとplayerにフォーカスしてチューニングをしていくことにしました。
午後の立ち回り
DBにインデックスを貼る
清家がMySQL、藤澤がSQLiteという分担でDBにインデックスを貼りました。しかしスコアはあまり変わらず。これには首を傾げざるを得ませんでした。
解説記事 を読む限り、 visit_history
の created_at
にはインデックスを貼れていなかったのでこれが原因かもしれません。 slow-query
や EXPLAIN ANALYZE
でインデックスが効いているかどうか、慎重に確認すべきでした。
playerの高速化
自分はplayerの高速化を担当していました。SQLite内にある player_score
テーブルにCSVインポートしたスコア情報が格納されていて、このテーブルが巨大&N+1になっていることがわかりました。
テーブルは巨大ですが、欲しいのは直近のスコアのみなので、直近のスコアのみを覚えるテーブルを別途作成して、CSVインポート時に最新スコアだけをそのテーブルに書き込むことで高速化が見込めると判断し作業を着手しました。が、実際にやってみると以下のような点で躓いて、なかなかの改修量でした。
- 新しいテーブルにはいわゆるUpsertをしたい。
- しかし、このDBはSQLiteなので
ON DUPLICATE KEY
は使えずON CONFLICT
を使う必要がある - これに気づいて、かつ実装方法を確立するまでに時間がかかった
- しかし、このDBはSQLiteなので
- テナント追加時に追加したテーブルも
CREATE TABLE
する必要がある- テナント追加時に実行するスクリプトを追加することで対応
player_score
には大量の初期データがある- 大量の初期データから最新スコアのみを抜き出して、新しいテーブルに書き込む必要がある
初期データをどうするか問題は本当に悩ましく、最終的に以下のようなSQLクエリを流すことで生成しました。ベンチのチェック上は問題なかったですが、正解かどうかは不明です。
CREATE TABLE current_player_score AS
SELECT
player_score.id,
player_score.tenant_id,
player_score.player_id,
player_score.competition_id,
player_score.score,
player_score.created_at,
player_score.updated_at
FROM
player_score
INNER JOIN
(
SELECT
tenant_id,
player_id,
competition_id,
MAX(row_num) AS row_num
FROM
player_score
GROUP BY
tenant_id,
player_id,
competition_id
) AS tmp
ON player_score.tenant_id = tmp.tenant_id
AND player_score.player_id = tmp.player_id
AND player_score.competition_id = tmp.competition_id
AND player_score.row_num = tmp.row_num
;
これをすべて終えたのは17時。祈りながらベンチを走らせたのですが、スコアはほとんど変わりませんでした。
SQLiteをMySQLに統合(未完)
DBスキーマを見る限り、SQLiteの各テーブルには tenant_id
のカラムがあり、「MySQLへの移管を想定してこのような設計にしているのでは?」という議論が持ち上がりました。手が空いているメンバーにて、移管できそうか検討してもらったのですが「残り時間的にさすがに厳しい」と判断して、移管は断念しました。
懸命な選択だったと思っています。
rankingの高速化
こちらもplayerと同様にN+1問題の解消に挑戦したのですが、タイムオーバーでした。
最終調整
残り時間で以下のようなチューニングをしました。
- Echo, Nginx, MySQLのログを切る
- pprofを切る
- MySQL, Nginxの設定ファイルに通称秘伝のタレを適用する
スコアは少しだけ増えましたが、いわゆる「スコアが跳ねる」という状況はお目にかかれませんでした。
テナント毎にリクエストを振り分ける、というところまでたどり着けていなかったのでアプリケーションサーバーを分散できなかったことも痛かったです。
最終スコア
4035でした。予選敗退です。
反省点
alpを使ってチューニングすべきリクエストを特定したところまでは良かったものの、結果としてそのチューニングが大改修となってしまい、もっと簡単にチューニングできる箇所に手を付けられなかったことが反省点です。flockだったりテナント毎のリクエストの振り分けだったり、気づいていたけど本腰を入れて取り組めなかった箇所が多くありました。
また、今回は序盤にDBクライアントの接続やpprofで手間取り、チーム全体に焦りの空気が生まれたのも良くなかったです。それぞれが落ち着きを取り戻そうと頑張ったのですが、スコアが伸びずにより焦ってしまうという悪循環が生まれてしまいました。今年も結構練習を重ねていたのですが、もっとオペレーションに慣れる必要があるなと感じました。
良かったこと
最後に少しだけでも「良かったこと」を書いて、記事を締めます。
- まだまだ学ぶべきことがたくさんあることに気づけた
- 毎年同じことを言っている気がしますが、本当に世の中自分の知らないことばかりだなとISUCON参加を通じて思い知らされています。
- また来年に向けて頑張ろう、というモチベーションが湧いてきました
- Makefileをチームとして導入してオペレーションを効率化できた
- 昨年までは自分だけがサーバーをオペレーションしていたのでMakefileも自分専用だったのですが、今年はチームとして「オペレーションはMakefileで」ということにしました。
- おかげでどのサーバーに入っても短いコマンドで簡単にサーバーをオペレーションできる状態だったので非常に楽でした。
- 初参戦の藤澤が「楽しかった。学びになった」と言っていた
- 競技には負けても、学びがあって次に繋げることが一番大事ですよね。
というわけで、チームや言語がどうなるかはさておき、来年も頑張りたいと思います。