ISUCON5予選を通過した話 #isucon
今年も去年と同様に同じ会社の @walf443 さんと @edvakf さんの3人で『チームフリー素材+α』というチーム名でISUCON5に参加しました。
去年とチーム名が微妙に違いますが、チームメンバーは同じです。
+αには去年準優勝で得た30万円の賞金を表しているらしいです。
『チームフリー素材+α』というチーム名でISUCONに参戦します
よろしくお願いします!!!
#isucon
— 麺類 (@catatsuy) September 26, 2015
今年も無事、本選に出場できたのでとりあえず安心しています。
今年も去年と同様にGolang実装に切り替えてから作戦を練るつもりでしたが、今年は予選マニュアルよりGolang実装には『微細なバグがあり初期状態でベンチマークのチェックを通過しません』とありました。そのためGolang実装でベンチマークをずっと通せず、戦えずに予選敗退という最悪の展開が予想できました。そこで最初からベンチマークを通せる状況だったRuby実装で各ルーティングの速度を確認してからGolang実装に切り替える戦略にしました。
たしか14:00くらいまでベンチマークを通せなければRuby実装で戦おうという話もしていました。
他にもGCPで最初に立てたインスタンスがなぜかsshできなくなったので、即座にもう1つインスタンスを立ててあっさり前のインスタンスを捨てたりとかして最初少し手間取りました。
また開発環境構築手法を最初に調査する予定だったので、その一環で初期データのmysqldump
を取りました。ただ今回のインスタンスはディスクのIOが極端に遅いのに加えてデータ量が多いという特徴があり、dumpがしばらく終わらないという問題が起こったりしました。うちのチームはdumpを取ったおかげで手元でほぼ完璧な開発環境を構築することができましたが、手元の環境構築はあきらめたチームも多かったようです。今から考えると折角のクラウド環境なのでデータを抜く用の性能の出るインスタンスを立てるなどをすればもっとすぐに終わらせることもできたと思います。ただとっさにそこまで思いつくのが難しいのもISUCONの醍醐味かなと思います。
今回は初期実装でもベンチマークが通らなかったのですが、事前にやる予定になっていたものを黙々とやりました。
この辺りをやってもベンチマークは安定しませんでした。ベンチマークを回すと、どうしてもベンチマーク後半でMySQL has gone away
(うろ覚え)というエラーが発生して500番台を返すようになりました。そこで直るかは分かりませんでしたが、ここでMySQLとの通信をUNIXドメインソケット経由にすることになりました。
このときにはまったのは/etc/my.cnf
に設定されているソケットファイルが実際には使われていないということでした。
ここはすぐにDebianの設定ファイルが使われていることに気付いたので、実際に使われているソケットファイルに変更しました。
MySQLの通信をUNIXドメインソケット経由にしたことでようやくベンチマークが安定しました。また当初の予定では最初に作る予定だったデプロイスクリプトもほぼ同時に動かせるようになりました。デプロイスクリプトが予定より遅れていたのはGolang実装を使わない可能性がこのときはまだあったためです。
ギリギリの判断が迫られるところもISUCONです。ベンチマークを通せたことで、このままGolang実装で行くことになりました。
デプロイスクリプトを作る時に気を付けなければいけないのは、authorized_keysにadded by Google
という文字列を含んではいけないところです。インスタンス上の各ユーザーのauthorized_keysには含まれているので、単純にcat
するとこれを含んでしまいます。なんとこうなると謎のpythonのデーモンが勝手に書き換えて全部削除してしまう!のでデプロイ出来なくなります。
このことは事前の練習で気づいていたので、本番でははまらずに済みました。
ベンチマークを通せてからやっていったことを1つ1つ書いていくと、かなり長くなってしまうのでやった変更を箇条書きで書いていきます。極力時系列順に書きます。
- インデックスの追加(13:39 1437)
relations
にすでにあるインデックスとは逆方向の(another
,one
)の複合インデックスを貼るfootprints
にuser_idのインデックスを貼る- ディスクの速度が遅いのとデータ量が多いのもあってちょっとしたインデックスの追加でも少し時間がかかる
isFriend
関数の返り値はfriendsMap
からも取得可能なので再利用する(14:01 5488)- MySQLの
innodb_buffer_pool_size
を1GBに増やしたり、innodb周りの設定を追加(14:03 5975)- メモリにまだ余裕がある状況だった(ただし潤沢ではない)
- 本文の取得を
SUBSTRING_INDEX
を使って最小限に(14:10 5600 / 14:36 6215)- 本文をすべて取得する必要はなかったので、
SUBSTRING_INDEX(body, '\n', 1)
という風にしてSQLからの取得を最低限に - 今から考えると
SUBSTRING_INDEX
を使ったとしてもディスクの読み込みはある程度走っているはずなのでスコアはそんなに上がらない変更かも - 他のチームでは
ALTER TABLE
でSUBSTRING_INDEX
の結果だけを別カラムに用意したチームもあったらしいので、そこまでできれば良かったかもしれないが、今回はディスクのIOがかなり遅く、かつデータ量が多いのでALTER TABLE
をどこまでやるかの判断は難しかった - bodyやcommentなども
SUBSTRING_INDEX
を使って通信量を減らした
- 本文をすべて取得する必要はなかったので、
- POSTされたリクエストの反映は3s以内にするという条件があったので、逆にPOSTされるのを遅らせてMySQLのクエリキャッシュに頼ることはできないか試行錯誤
- 自分がやっていたが、うまくスコアにつながらず放棄
- IN句を使ってインデックスページのクエリを最適化(15:12 6682)
- インデックスページにはエントリを1000件引いてからフレンドかどうか確認し、条件を満たすエントリが10件集まればfor文を打ち切るというやばい感じの処理があった
- friendのIDをスライスで用意してから、それをIN句に付けて、friendでないuser_idのレコードを引かないように
- コメントもIN句に(15:48 7185)
- commentsテーブルに(user_id, created_at)の複合インデックスを追加
- コメントの取得をした後にfor文でエントリを引くという典型的なN+1クエリをINNER JOINを使って解消する変更
- あなたへのコメントをキャッシュ(16:20 8650)
- あしあとをRedisに入れる(17:44 9828)
- MySQLへの書き込みは止める
- ソート済みセット型を使ってscoreにタイムスタンプを入れる
- 新しいもの順でしか取得しないのでソート済みセット型を使える
- ユーザー情報をキャッシュ(18:04 14882)
- スロークエリでユーザー情報を引くだけの単純なSELECT文が目立つようになったのでGoのグローバル変数に入れる
- ユーザーは増えないのでサーバー起動時に全部持ってきて突っ込む
- IN句を使ったSQLに書き換えたクエリにFORCE INDEXを指定する(18:11 15315)
- フレンドが少なければuser_idのインデックスを使った方が速いが、フレンドが多いならcreated_atのインデックスを使って順番に見ていった方が速いはず
ORDER BY created_at
があるのでcreated_atのインデックスを使う- 閾値20でFORCE INDEXを切り替えるように
- 実はpixivでも使われている手法
- テンプレート側で
getEntry
関数を呼ばないように(18:44 16346) getCurrentUser
関数もキャッシュから引くように(18:50 17075)- SQLを減らす
- Redisの接続をUNIXドメインソケット経由に(18:58 16967)
- 終了寸前だったため決め打ちに
- これで手元での開発が不可能になるが、もう残り数分だったため妥協
今年は初期実装が去年より多く、やることが色々ありました。今から改めて本番でやったコードを見ると無駄な処理がちらほら見つかったのでもっと時間が欲しくなるような戦いだったと思います。またそもそも各言語での参考実装を作るのも如何にも大変そうで、運営の大変さが分かる大会でした。
また表示するものが多いインデックスページの具体的にどこがどの程度遅いのかなど、具体的なプロファイリングは取らなかったのですが、今回は取った方が効率の良い戦いができたのかなとも思います。
自分だとどうしても全体を見通す力とか、どこをどういう変更すれば速くなるのかなどを即座に的確に判断することができなくてほとんどチームに貢献できませんでした。なので本選ではもっと活躍できるように頑張りたいと思います。とりあえず今回の予選の復習を頑張るつもりです。
本選で100万円取るぞ!!