ISUCON13に参加したので、やったこと感想などについて書いていきます。
僕はISUCONには今回初出場です。去年の秋ごろにISUCON本をいただいて、ずっと出たいなと思っていたのでようやく参加できて嬉しかったです。
参加までにやったこととしては、
- ISUCON本読む
- private-isuを色々参考にしながら30万点くらいまで持っていく
- ISUCON11予選を公式解説を参考に13万点くらい
ちなみに、ISUCONでほぼ初めてバックエンドを触り始めました。なのでそこまでレベルは高くないと思っていただけると助かります。
Twitterで一緒に参加してくれる人を募集していたところ、たまたま来年入社予定の先輩と、これもたまたま同じ大学の後輩と参加することになりました。
お題
今年のお題はISUPIPEというライブ配信プラットフォームをチューニングせよ、ということでした。
お題が発表される前にYouTubeでのオンラインライブ中継が激おもになって不安定になっていたので、「これは今年のテーマが動画配信プラットフォームである伏線か?」なんて冗談でいっていたら、無事伏線回収されたのでみなさん盛り上がってました。
やったこと
開始〜11:00 環境構築、もろもろセッティング
競技化始まってからは、環境構築系のタスクをを行いました。
- Cloud Formationテンプレートでの環境立ち上げ
- Gitでのチーム開発環境のセッティング
- 計測ツールもろもろのインストール、動作確認
- deployスクリプト、ベンチ中に回すスクリプトの作成
- 初期スコア測定(3200点くらい)
僕が環境構築を行っている間、チームメンバー二人には、当日マニュアルとアプリケーションマニュアルを読み込んでおいてもらいました。
11:00〜11:20 スキーマの共有、最初の方針決め
- DBのスキーマをチームで共有したり、
- applicatonコード、マニュアルを読んで内容のすり合わせ
などを軽く行いました。これから各々怪しそうな点があれば共有しつつ、取り組んでいくという感じで進めていきました。
僕はマニュアル系を読んでなかったので、ここら辺で軽く読んでおきました。
11:00〜 改善
やったことを(できるだけ)時系列順で書いていきます
一番遅いクエリにINDEXをはる。
とりあえず、queryーdigesterで調査した一番遅いクエリが、WHEREが一つのシンプルなSELECT文だったので、INDEXを貼りました。(どんなクエリかは忘れました)
CREATE INDEX idx_livestream_id ON livestream_tags(livestream_id);
名前解決成功数 1521
売上: 3716
PREPARED STATEMENTを無効にする
先ほどの改善後、ADMIN PREPAREが上位に来ていたのでPREPARED STATEMENTを無効にする改善を行いました。private-isuのように、文字列追加で OFFにする方法しか知らなかったのですが、そこはGPTに聞いていいかんじにできました。
conf := mysql.NewConfig()
・・・
conf.InterpolateParams = true
ここまでは、ISUCON本通り順調でした。
名前解決成功数 2248
売上: 5200
api/livestream/searchの改善 (不発)
次にalpで最上位だった、api/livestream/searchを見ました。live_stream_handler.goのsearchLivestreamsHandler関数では、N+1SELECT文とN(N+1)SELECT文があることが素人目にもわかって、改善のしがいがありそうでした。
livestreams := make([]Livestream, len(livestreamModels))
for i := range livestreamModels {
livestream, err := fillLivestreamResponse(ctx, tx, *livestreamModels[i])
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to fill livestream: "+err.Error())
}
livestreams[i] = livestream
}
func fillLivestreamResponse(ctx context.Context, tx *sqlx.Tx, livestreamModel LivestreamModel) (Livestream, error) {
ownerModel := UserModel{}
if err := tx.GetContext(ctx, &ownerModel, "SELECT * FROM users WHERE id = ?", livestreamModel.UserID); err != nil {
return Livestream{}, err
}
owner, err := fillUserResponse(ctx, tx, ownerModel)
if err != nil {
return Livestream{}, err
}
var livestreamTagModels []*LivestreamTagModel
if err := tx.SelectContext(ctx, &livestreamTagModels, "SELECT * FROM livestream_tags WHERE livestream_id = ?", livestreamModel.ID); err != nil {
return Livestream{}, err
}
tags := make([]Tag, len(livestreamTagModels))
for i := range livestreamTagModels {
tagModel := TagModel{}
if err := tx.GetContext(ctx, &tagModel, "SELECT * FROM tags WHERE id = ?", livestreamTagModels[i].TagID); err != nil {
return Livestream{}, err
}
tags[i] = Tag{
ID: tagModel.ID,
Name: tagModel.Name,
}
}
livestream := Livestream{
ID: livestreamModel.ID,
Owner: owner,
Title: livestreamModel.Title,
Tags: tags,
Description: livestreamModel.Description,
PlaylistUrl: livestreamModel.PlaylistUrl,
ThumbnailUrl: livestreamModel.ThumbnailUrl,
StartAt: livestreamModel.StartAt,
EndAt: livestreamModel.EndAt,
}
return livestream, nil
}
まず、fillLivestreamResponseのN+1をIN句を使って改善しました。
func fillLivestreamResponse(ctx context.Context, tx *sqlx.Tx, livestreamModel LivestreamModel) (Livestream, error) {
ownerModel := UserModel{}
if err := tx.GetContext(ctx, &ownerModel, "SELECT * FROM users WHERE id = ?", livestreamModel.UserID); err != nil {
return Livestream{}, err
}
owner, err := fillUserResponse(ctx, tx, ownerModel)
if err != nil {
return Livestream{}, err
}
var livestreamTagModels []*LivestreamTagModel
if err := tx.SelectContext(ctx, &livestreamTagModels, "SELECT * FROM livestream_tags WHERE livestream_id = ?", livestreamModel.ID); err != nil {
return Livestream{}, err
}
// タグIDを収集
tagIDs := make([]int64, 0, len(livestreamTagModels))
for _, tm := range livestreamTagModels {
tagIDs = append(tagIDs, tm.TagID)
}
// 一括でタグを取得
var tagModels []*TagModel
query, args, err := sqlx.In("SELECT * FROM tags WHERE id IN (?)", tagIDs)
if err != nil {
return Livestream{}, err
}
query = tx.Rebind(query)
if err := tx.SelectContext(ctx, &tagModels, query, args...); err != nil {
return Livestream{}, err
}
// タグIDをキーとするマップを作成
tagMap := make(map[int64]Tag)
for _, tm := range tagModels {
tagMap[tm.ID] = Tag{ID: tm.ID, Name: tm.Name}
}
// マップを使用してタグ配列を作成
tags := make([]Tag, len(livestreamTagModels))
for i, tm := range livestreamTagModels {
tags[i] = tagMap[tm.TagID]
}
livestream := Livestream{
ID: livestreamModel.ID,
Owner: owner,
Title: livestreamModel.Title,
Tags: tags,
Description: livestreamModel.Description,
PlaylistUrl: livestreamModel.PlaylistUrl,
ThumbnailUrl: livestreamModel.ThumbnailUrl,
StartAt: livestreamModel.StartAt,
EndAt: livestreamModel.EndAt,
}
return livestream, nil
しかし、この改善を行った後、ベンチを回すと整合性エラーが出て???となりました。
しかも、この関数を改善前のものと改善後の両方を定義して、一部の呼び出し先だけに改善後のものを適用させると、ベンチはうまく通る(でもスコアは逆に下がってる??)という事態に陥って、どうにも解決法がわからなかったので、一旦放置しました。
原因わかる方いたら教えていただきたいです、、、
アイコン画像をnginxから配信
これは僕の担当ではなかったのですが、
スロークエリログの一番上にSELECT iconが出てきていて、
-- プロフィール画像
CREATE TABLE `icons` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`user_id` BIGINT NOT NULL,
`image` LONGBLOB NOT NULL,
`icon_hash` VARCHAR(64) NOT NULL
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
このようにLONGBLOG型で画像を保存していたので、INSERT時に空データを入れ、画像書き出しをして、nginxから静的配信をさせることにしました。
しかし、こちらもベンチを動かすとエラーで止まってしまいました。
空データではなくちゃんとした画像データDBに保存してベンチを回した際には、画像の書き出しは行われていたので、おそらくnginxの設定が間違っていたのかと考えていますが、結局原因はわからずでした。
(割愛 その他DELETE文の改善やINDEXの追加などを行いました。)
名前解決成功数 2358
売上: 8350
MySQLを二台目サーバーに移す
残り1時間くらいで、色々不発に終わってしまって何をすればいいのかわからなくなってしまったので、とりあえずDBを二台目に分離することにしました。
新DBサーバーの方で、isucon user作成→GRANT与える→mysqld.confでbind_addressを0.0.0.0に変更して、AppサーバーからDBサーバーへmysqlコマンドを実行することは確認できたのですが、ベンチを回すと失敗しました。
終了2分前にmain.goを見て、conf.Addrを書き換えてもこれは所詮デフォルト値でしかないので、環境変数を直接書き換えないと意味ないじゃんっていうことに気がついたので、
急いで/etc/systemd/system/isupipe-go.serviceを見てみたら、/home/isucon/env.shにローカルにアドレスが向くように環境変数が指定してあったのでここを書き換えなきゃいけないじゃんということはわかったのですが、時すでに遅しで時間切れでした。
//conf.Addr = net.JoinHostPort("127.0.0.1", "3306")
//ここを変更
conf.Addr = net.JoinHostPort("DBサーバーへのIP", "3306")
//ここでLookEnvしてるじゃん
if addr, ok := os.LookupEnv(addrEnvKey); ok {
if port, ok2 := os.LookupEnv(portEnvKey); ok2 {
conf.Addr = net.JoinHostPort(addr, port)
} else {
conf.Addr = net.JoinHostPort(addr, "3306")
}
}
静的解析すれば127.0.0.1が書いてあるところわかるじゃんって話なのですが、/home/isucon/webapp配下しかVscodeとgitで管理してなかったので存在にすら気づきませんでした。もっともマニュアルをちゃんと読んでおけば防げたミスだと思います。
(というかこのミス素振りの時にもした気がする、、)
反省
こんな感じで初めてのISUCONは色々うまくいかずで終了しました。
最後に来年に向けての反省点を書こうと思います。
- そもそも圧倒的に練習量が足りないので、来年のISUCON14までに全ての問題を解ききる
- 今まで、ベンチが成功するか否かで改善がうまくいっているかを確認していたが、うまくいかない原因がわからない場合どうしようもなくなるので、詳細なデバッグをできるようにする。(ちなみにNaruseJunチームはベンチマークデバッグしてるらしい)
- マニュアル系はちゃんと読む。素振りの時からちゃんと読む癖をつける。
- DNSの改善なんて絶対わからんわぁと思って今回は何もしようとしなかったが、エンジニアとしてDNSは当たり前の知識なのでちゃんと勉強する。
- 次から/home/isucon配下でgit管理をするようにする。
- ベンチマークを待つ時間が混み具合によって結構バラバラで、ベンチ中に回すシェルを実行するタイミングが掴みづらいので改善する。
来年は上位30には入りたい