Y

ISUCON12予選の復習をしました 3

この記事は何か?

ISUCON12予選の復習記録です。

第3回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「3. Score APIの追加のループクエリをなくす」を試します。

目次

  1. 「3. Score APIの追加のループクエリをなくす」を試す (N+1問題)
  2. 「3. Score APIの追加のループクエリをなくす」を試す (バルクインサート)

1. 「3. Score APIの追加のループクエリをなくす」を試す (N+1問題)

「3. Score APIの追加のループクエリをなくす」には2つの対策が解説されています。

N+1問題とバルクインサートです。どちらもISUCONでは馴染みのある対応ですね。

まずは、N+1問題を解決します。

ということで、「CSVを全行チェックしてPlayerIdの重複ないリストを作る」→「それをSELECT COUNT(*) as count FROM player WHERE id IN (...) に渡してCOUNTに差分があるか確認する」という処理を追加すると参加者数チェックの部分は1クエリでできるようになります。

解説では上記の対応方法が記載されていますが、私は件数だけの確認では整合性が取れない場合はあるのではないかと思い、先にテナントに紐づくプレイヤーをスライスに格納することにしました。今後他の人の解法も見てみるつもりなので、そこで答え合わせしたいと思います。

+
+       "golang.org/x/exp/slices"

+       // プレイヤー一覧をスライスにいれておく
+       playerIDs := []string{}
+       if err := tenantDB.SelectContext(
+               ctx,
+               &playerIDs,
+               "SELECT DISTINCT(id) FROM player WHERE tenant_id = ?",
+               v.tenantID,
+       ); err != nil && err != sql.ErrNoRows {
+               return fmt.Errorf("error Select player ID: tenantID=%d, %w", v.tenantID, err)
+       }

+
+               // 存在しない参加者が含まれている (スライス利用)
+               if !slices.Contains(playerIDs, playerID) {
+                       fmt.Errorf("error retrievePlayer: %w", err)
+
+                       return echo.NewHTTPError(
+                               http.StatusBadRequest,
+                               fmt.Sprintf("player not found: %s", playerID),
+                       )
+               }
+
+               /*
                if _, err := retrievePlayer(ctx, tenantDB, playerID); err != nil {
                        // 存在しない参加者が含まれている
                        if errors.Is(err, sql.ErrNoRows) {
                        ...
                        }
                        return fmt.Errorf("error retrievePlayer: %w", err)
                }
+               */

「go get golang.org/x/exp/slices」 を実行します。

  • 変更前のスコア

21:50:11.084591 SCORE: 2811 (+2811 0(0%))

  • 変更後のスコア

21:46:04.478709 SCORE: 3188 (+3188 0(0%))

2. 「3. Score APIの追加のループクエリをなくす」を試す (バルクインサート)

これは分かりやすいバルクインサート適用部分です。

+
+       // バルクインサート
+       if _, err := tenantDB.NamedExecContext(
+               ctx,
+               "INSERT INTO player_score (id, tenant_id, player_id, competition_id, score, row_num, created_at, updated_at) VALUES (:id, :tenant_id, :player_id, :competition_id, :score, :row_num, :created_at, :updated_at)",
+               playerScoreRows,
+       ); err != nil {
+               return fmt.Errorf("error Insert player_score: %w", err)
+       }
+
+        /*
        for _, ps := range playerScoreRows {
                if _, err := tenantDB.NamedExecContext(
                        ctx,
            .....
                }
        }
+        */
  • 変更前のスコア (N+1対応後)

21:46:04.478709 SCORE: 3188 (+3188 0(0%))

  • 変更後のスコア

22:05:24.438783 SCORE: 3862 (+3862 0(0%))

感想

今回の対応でスコアが1,000点改善しました。

上記のとおりN+1問題とバルクインサートはISUCON頻出問題ですが、僕は当日解決できませんでした 😭

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

ISUCON12予選の復習をしました 2

この記事は何か?

ISUCON12の復習記録です。

第2回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「2 Ranking APIが重いのでひとまずループクエリをなくす」を試します。

目次

  1. 環境構築 (alp, pporf)
  2. 「2 Ranking APIが重いのでひとまずループクエリをなくす」を試す

1. 環境構築 (alp, pporf)

alpとpporfをインストールします。alpのインストール手順は過去に書いたので省略します。

  • pporfインストール
go install runtime/pprof
go install net/http/pprof

vi isuports.go

-----------------
+
+       "runtime"
+       _ "net/http/pprof"


@@ -137,6 +140,12 @@ func Run() {
        e.Debug = true
        e.Logger.SetLevel(log.DEBUG)

+       runtime.SetBlockProfileRate(1)
+       runtime.SetMutexProfileFraction(1)
+       go func() {
+               http.ListenAndServe("0.0.0.0:6060", nil)
+       }()
-----------------

make
sudo systemctl restart isuports

sudo apt install graphviz

セキュリティグループの設定で1080を開放

go tool pprof -http=0.0.0.0:1080 /home/isucon/webapp/go  http://localhost:6060/debug/pprof/profile?seconds=90

http://xxx.xxx.xxx.xxx:1080/にアクセス
  • alp結果
alp --sum -r -f /var/log/nginx/access.log | head -100
+-------+--------+--------+---------+--------+--------+--------+--------+--------+-----------+------------+-------------+-----------+--------+-----------------------------------------------+
| COUNT |  MIN   |  MAX   |   SUM   |  AVG   |   P1   |  P50   |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)  | AVG(BODY) | METHOD |                      URI                      |
+-------+--------+--------+---------+--------+--------+--------+--------+--------+-----------+------------+-------------+-----------+--------+-----------------------------------------------+
|    18 |  2.764 | 16.020 | 189.032 | 10.502 |  2.764 | 12.688 | 15.708 |  5.091 |     0.000 |  14439.000 |  245022.000 | 13612.333 | GET    | /api/player/competition/9fa538f9/ranking      |
|    16 |  0.140 | 18.724 | 183.480 | 11.467 |  0.140 | 17.804 | 18.588 |  8.684 | 13563.000 |  13636.000 |  217081.000 | 13567.562 | GET    | /api/player/competition/9fa53e35/ranking      |
|    31 |  0.016 | 17.592 | 135.380 |  4.367 |  0.016 |  0.156 | 17.464 |  5.416 | 13650.000 |  13741.000 |  423394.000 | 13657.871 | GET    | /api/player/competition/9fa52cc0/ranking      |
|    27 |  0.028 | 17.632 | 132.436 |  4.905 |  0.028 |  0.192 | 10.464 |  5.561 | 13654.000 |  13708.000 |  368743.000 | 13657.148 | GET    | /api/player/competition/9fa53286/ranking      |
|    20 |  0.012 | 10.188 | 119.448 |  5.972 |  0.012 |  9.972 | 10.156 |  4.851 |     0.000 |  13730.000 |  260867.000 | 13043.350 | GET    | /api/player/competition/9fa53ed0/ranking      |
|    38 |  0.008 | 18.508 | 116.096 |  3.055 |  0.008 |  0.044 | 17.724 |  4.939 |   912.000 |   3439.000 |  124093.000 |  3265.605 | GET    | /api/player/competition/9fa5278c/ranking      |
|    19 |  0.000 |  9.648 | 105.476 |  5.551 |  0.000 |  9.428 |  9.620 |  4.686 |     0.000 |  13633.000 |  244237.000 | 12854.579 | GET    | /api/player/competition/9fa53fc1/ranking      |
|    23 |  0.020 |  7.888 |  99.780 |  4.338 |  0.020 |  6.480 |  7.888 |  3.786 | 13571.000 |  13721.000 |  312385.000 | 13581.957 | GET    | /api/player/competition/9fa5325a/ranking      |
|    26 |  0.020 |  7.624 |  98.900 |  3.804 |  0.020 |  0.252 |  7.620 |  3.710 |     0.000 |  13727.000 |  325444.000 | 12517.077 | GET    | /api/player/competition/9fa537bf/ranking      |
|    34 |  0.016 |  7.572 |  95.484 |  2.808 |  0.016 |  0.076 |  6.908 |  3.116 | 13457.000 |  13669.000 |  458558.000 | 13487.000 | GET    | /api/player/competition/9fa52a27/ranking      |
|    28 |  0.020 |  8.356 |  87.816 |  3.136 |  0.020 |  5.228 |  7.048 |  2.963 | 13555.000 |  13682.000 |  379750.000 | 13562.500 | GET    | /api/player/competition/9fa52efc/ranking      |
|    33 |  0.924 | 15.380 |  84.456 |  2.559 |  0.000 |  1.804 |  6.764 |  2.576 |     0.000 |   1565.000 |   46830.000 |  1419.091 | GET    | /api/admin/tenants/billing                    |
|    17 |  0.076 |  8.368 |  80.968 |  4.763 |  0.076 |  7.756 |  8.232 |  3.897 | 13639.000 |  13653.000 |  231922.000 | 13642.471 | GET    | /api/player/competition/9fa546cf/ranking      |
|    16 |  0.072 |  7.352 |  79.784 |  4.986 |  0.072 |  7.128 |  7.320 |  3.299 | 13686.000 |  13686.000 |  218976.000 | 13686.000 | GET    | /api/player/competition/9fa54745/ranking      |
|    29 |  0.012 |  4.060 |  44.864 |  1.547 |  0.012 |  0.048 |  4.052 |  1.923 | 11049.000 |  14504.000 |  416088.000 | 14347.862 | GET    | /api/player/competition/9fa533be/ranking      |
|    28 |  0.012 |  3.320 |  36.696 |  1.311 |  0.012 |  0.036 |  3.316 |  1.593 |  2908.000 |  12357.000 |  330278.000 | 11795.643 | GET    | /api/player/competition/9fa53adb/ranking      |

GET /api/player/competition/:competition_id/rankingが遅いことが分かります。

  • pprof結果

tenantDB.SelectContextが4.89秒、retrievePlayerが3.14秒かかっていて、retrievePlayerはいわゆる「N+1問題」になっていることが分かります

2. 「2 Ranking APIが重いのでひとまずループクエリをなくす」を試す

mysqlがちょっと落ち着いたら、alpでリクエストごとの総レスポンスタイム(SUM)を見てみると、rankingが重いことが分かります。flockしているのも気になりますが、ロック区間内でループクエリが発生しているのがマズいので、まずはplayer_scoreを取るところにplayerをJOINして、retrievePlayerせずともdisplay_nameを取れるようにします。

解説ではSQLiteのロックは一旦そのまま放置して、N+1問題の解決をしています。

+type PlayerScoreJoinPlayerRow struct {
+       TenantID      int64  `db:"tenant_id"`
+       ID            string `db:"id"`
+       PlayerID      string `db:"player_id"`
+       CompetitionID string `db:"competition_id"`
+       Score         int64  `db:"score"`
+       RowNum        int64  `db:"row_num"`
+       CreatedAt     int64  `db:"created_at"`
+       UpdatedAt     int64  `db:"updated_at"`
+       DisplayName   string `db:"display_name"`
+}

- "SELECT * FROM player_score WHERE tenant_id = ? AND competition_id = ? ORDER BY row_num DESC",
+ "SELECT player_score.*, player.display_name FROM player_score JOIN player ON player.id = player_score.player_id WHERE player_score.tenant_id = ? AND competition_id = ? ORDER BY row_num DESC",

/*
  p, err := retrievePlayer(ctx, tenantDB, ps.PlayerID)
  if err != nil {
    return fmt.Errorf("error retrievePlayer: %w", err)
  }
*/
ranks = append(ranks, CompetitionRank{
  Score:             ps.Score,
  PlayerID:          ps.PlayerID,
  PlayerDisplayName: ps.DisplayName,
  RowNum:            ps.RowNum,
})
  • 変更後のpprof結果

N+1問題は解決しました。しかしスコアは2972と改善していません。解説にあるとおりflockを解決しないとスコア改善しないようです。

感想

今見ると分かりやすいN+1問題ですが、予選当日は対応できませんでした。アプリやコードの仕様理解をさくっとできるようになる必要があります。

また、対応できたとしてもスコアが改善しないので、「それはそれとして、改善を重ねていこう」というゆるい気持ちが必要そうです。

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

ISUCON12予選の復習をしました 1

この記事は何か?

ISUCON12の復習をやっていきます。

第1回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「adminDB visit_history にINDEXを張る」までをやります。

目次

  1. 環境構築
  2. 「adminDB visit_history にINDEXを張る」を試す

1. 環境構築

用意されたCloudFormationテンプレートを利用する場合に記載された方法でAWS上に環境を用意します。

  1. cloudformation.yamlをダウンロードして、CloudFormationの画面から実行
  2. 3台起動されるが、節約のため1台停止 (1台をベンチ実行用、2台目をチューニング用に利用します)
  3. Systems Manager > パラメータストア を開き、秘密鍵をコピペ、SSHログイン
vi ~/.ssh/isucon12q.pem
chmod 400 ~/.ssh/isucon12q.pem
ssh -i ~/.ssh/isucon12q.pem ubuntu@xx.xx.xx.xx ← 1台目にログイン

04.初回ベンチ実行

sudo su - isucon
cd /home/isucon/bench
./bench -target-addr xx.xx.xx.xx:443  ← 2台目に向けてベンチ実行

初回スコアは2,781でした。運営の方がCloudFormationテンプレートを用意してくれたおかげで準備が超簡単です。ありがとうございます。

「adminDB visit_history にINDEXを張る」を試す

初手は「adminDB visit_history にINDEXを張る」でした。私と同じで嬉しかったのですがINDEXの張り方が違っていました。

最後にcreated_atまで入れておくと、Covering Indexになるのでセカンダリインデックス単体でクエリを返すことができて高速ですが、created_atを張るのを忘れるとcreated_atを読むためにクラスタインデックスを読みに行くのでそこまで伸びない(もちろん張らないよりはマシ)といった感じです。私は最初created_atまで入れてなくて「アルェー」と言ってました。

私は2カラムしか設定しませんでしたが、解説では4カラムでした。

私: tenant_id, competition_id
解説: tenant_id, competition_id, player_id, created_at

気になったので、インデックス毎に「Extra」の値を確認しました。

  • スロークエリログ
Reading mysql slow query log from /var/lib/mysql/slow.log
Count: 1152  Time=0.11s (129s)  Lock=0.00s (0s)  Rows=92.7 (106831), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id
  • EXPLAIN結果 (インデックス無し)
mysql> explain SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = 1 AND competition_id = 'S' GROUP BY player_id\G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: visit_history
   partitions: NULL
         type: ref
possible_keys: tenant_id_idx
          key: tenant_id_idx
      key_len: 8
          ref: const
         rows: 1293162
     filtered: 10.00
        Extra: Using where; Using temporary
1 row in set, 1 warning (0.00 sec)
  • EXPLAIN結果 (インデックス tenant_id, competition_id)
mysql> explain SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = 1 AND competition_id = 'S' GROUP BY player_id\G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: visit_history
   partitions: NULL
         type: ref
possible_keys: tenant_id_idx,idx_tenant_id_competition_id
          key: idx_tenant_id_competition_id
      key_len: 1030
          ref: const,const
         rows: 1
     filtered: 100.00
        Extra: Using temporary
1 row in set, 1 warning (0.00 sec)
  • EXPLAIN結果 (インデックス tenant_id, competition_id, player_id)
mysql> explain SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = 1 AND competition_id = 'S' GROUP BY player_id\G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: visit_history
   partitions: NULL
         type: ref
possible_keys: tenant_id_idx,idx_tenant_id_competition_id_player_id
          key: idx_tenant_id_competition_id_player_id
      key_len: 1030
          ref: const,const
         rows: 1
     filtered: 100.00
        Extra: NULL
1 row in set, 1 warning (0.00 sec)
  • EXPLAIN結果 (インデックス tenant_id, competition_id, player_id, created_at)
mysql> explain SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = 1 AND competition_id = 'S' GROUP BY player_id\G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: visit_history
   partitions: NULL
         type: ref
possible_keys: tenant_id_idx,idx_all_cover
          key: idx_all_cover
      key_len: 1030
          ref: const,const
         rows: 1
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning (0.00 sec)
  • スコア比較
// Before (インデックス無し)
07:03:05.717432 SCORE: 2781 (+2781 0(0%))

Count: 1152  Time=0.11s (129s)  Lock=0.00s (0s)  Rows=92.7 (106831), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

// After (インデックス idx_all_cover)
07:13:45.086791 SCORE: 3238 (+3238 0(0%))

Count: 4076  Time=0.01s (20s)  Lock=0.00s (0s)  Rows=153.0 (623605), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

idx_all_coverは「Using index」になっていて、スコアも上がっています。(他のスコアは省略して計測しませんでした)

感想

クラスタインデックス」と「セカンダリインデックス」の説明は(通称)ISUCON本[2]の説明が分かりやすいです。その本では、「クラスタインデックス」だけから情報を取得できることでの性能向上について書かれていましたが、「セカンダリインデックス」だけにアクセスすることも可能ということを知りました。またyoku0825さんの以下の説明[3]も分かりやすいです。

MySQLのインデックスはほぼB+Treeです。MySQLのB+Treeインデックスのリーフには「テーブル内での行の位置」が記録されています(MyISAMであれば.MYDファイルの先頭からオフセットバイト数、InnoDBであればクラスターインデックスの値が記録されている)。そのため、インデックスを利用した行フェッチを行う際は、以下の3ステップで行われます。 1 インデックス上から条件にマッチするリーフを探す 2 インデックスのリーフ上に書かれた情報から行の位置を探す 3 行をフェッチする

Using indexが示すのはインデックス上に書かれた情報だけで(インデックスは「ソート済みのデータの複製(サブセット)」でありインデックスを作成したカラムの値を含む)、要求された情報の取り出しが終了したため 2. と 3. のステップを省略した、というものです。

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

2 達人が教えるWebパフォーマンスチューニング 〜ISUCONから学ぶ高速化の実践

3 SQL実行計画の疑問解決には「とりあえずEXPLAIN」しよう

ISUCON12予選に参加しました

結果

7/23(土) ISUCON12予選に、チーム「y」(メンバーは私のみ)で参加しました。

昨年に続き2回目の参加となります。

結果は、5,369点 181位 (全1,657チーム) でした。

前回同様、ほぼ何もできませんでしたが、楽しかったです。運営の皆様ありがとうございます。

この記事では「参加振り返り」をします。

振り返り

Keep

  • 1年前の自分のブログを参考にできた
  • 前日は仕事を休みにした
  • 失格にならなかった

Problem

  • 準備不足 (一度も過去問や予習をしなかった)
  • アプリ側が弱かった (Go言語を速く読めない。SQLiteへの移行の工数見積もりができなかった)
  • 解析結果の出力などの自動化準備をしていない

Try

  • 解説を読んで実際に試してみる
  • ISUCON本に書いてあるベンチマークを実装してみる
  • Go言語に慣れる
  • 自動化

当日やったこと

目次

  1. ドキュメント確認 (10:00)
  2. 初期ベンチ確認 (10:25)
  3. 準備作業 (11:00)
  4. 対応1 INDEX追加 (visit_historyテーブル) (12:17)
  5. 対応2 一意な値の生成方法を変更 (id_generatorテーブル) (12:53)
  6. 昼食休憩 (13:30)
  7. 対応3 2台目利用 (14:34)
  8. 対応4 SQLiteにINDEX追加 (16:02)
  9. 対応5 MySQLに変更失敗 (16:24)

ドキュメント確認 (10:00)

ISUCON12 予選当日マニュアルを読みました。前年と比べてドキュメント量が少なくて理解しやすかったです。

初期ベンチ確認 (10:25)

初期ベンチを実行。2,926でした。

準備作業 (11:00)

対象サーバーにSSHログインできなくて、調べたらauthorized_keysに公開鍵が設定されていませんでした。環境構築不備かなと思って再度CloudFormationを再実行しましたが、解決しなかったのでauthorized_keysに直接公開鍵を設定しました。時間を無駄にしたので、これは反省しています。(最初からauthorized_keysを変更すればよかった)

その後以下作業を実行しました。過去のブログを参考にして問題なく設定できました。

  • gitコミット
cd /etc/
sudo git init
sudo git add .
sudo git commit -a -m "Initial checkin."

cd /home/isucon/webapp/
git init
git add .
git config --global user.email "xxx@gmail.com"
git config --global user.name "xxx"
git commit -a -m "Initial checkin."
  • mysqldumpslow設定
sudo vi /etc/mysql/conf.d/my.cnf

slow_query_log      = 1
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time     = 0

sudo rm /var/lib/mysql/slow.log && sudo /etc/init.d/mysql restart

# 上記だとエラーが出たのでstatusで確認

root@ip-192-168-0-11:/etc/mysql/conf.d# systemctl status mysql
● mysql.service - MySQL Community Server

# mysql -u isucon -p
mysql: [ERROR] unknown variable 'slow_query_log=1'

# 上記エラーを見て以下に変更

[mysqld]
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time = 0
  • alp設定
wget https://github.com/tkuchiki/alp/releases/download/v0.3.1/alp_linux_amd64.zip
unzip alp_linux_amd64.zip
sudo install ./alp /usr/local/bin

sudo vim /etc/nginx/nginx.conf

http {
  log_format ltsv "time:$time_local"
    "\thost:$remote_addr"
    "\tforwardedfor:$http_x_forwarded_for"
    "\treq:$request"
    "\tmethod:$request_method"
    "\turi:$request_uri"
    "\tstatus:$status"
    "\tsize:$body_bytes_sent"
    "\treferer:$http_referer"
    "\tua:$http_user_agent"
    "\treqtime:$request_time"
    "\truntime:$upstream_http_x_runtime"
    "\tapptime:$upstream_response_time"
    "\tcache:$upstream_http_x_cache"
    "\tvhost:$host";

  access_log  /var/log/nginx/access.log ltsv;
}

sudo rm /var/log/nginx/access.log && sudo /etc/init.d/nginx restart

ここまでで11:30頃。もう少し短縮できたと思う。

4. 対応1 INDEX追加 (visit_historyテーブル) (12:17)

  • データ件数確認
mysql> select count(*) from id_generator;
+----------+
| count(*) |
+----------+
|        1 |
+----------+
1 row in set (0.00 sec)

mysql> select count(*) from tenant;
+----------+
| count(*) |
+----------+
|      106 |
+----------+
1 row in set (0.00 sec)

mysql> select count(*) from visit_history;
+----------+
| count(*) |
+----------+
|  3225470 |
+----------+
1 row in set (1.33 sec)

前年同様1つのテーブルだけがレコード数が多い(300万件)。これをどうにかする必要がある。

  • 実行計画確認
mysql> SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = 1 AND competition_id = 'S' GROUP BY player_id;
Empty set (9.11 sec)

mysql> EXPLAIN SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = 1 AND competition_id = 'S' GROUP BY player_id\G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: visit_history
   partitions: NULL
         type: ref
possible_keys: tenant_id_idx
          key: tenant_id_idx
      key_len: 8
          ref: const
         rows: 1472383
     filtered: 10.00
        Extra: Using where; Using temporary
1 row in set, 1 warning (0.00 sec)

ERROR:
No query specified
  • テーブル構造確認
CREATE TABLE `tenant` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL,
  `display_name` VARCHAR(255) NOT NULL,
  `created_at` BIGINT NOT NULL,
  `updated_at` BIGINT NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

CREATE TABLE `id_generator` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `stub` CHAR(1) NOT NULL DEFAULT '',
  PRIMARY KEY  (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

CREATE TABLE `visit_history` (
  `player_id` VARCHAR(255) NOT NULL,
  `tenant_id` BIGINT UNSIGNED NOT NULL,
  `competition_id` VARCHAR(255) NOT NULL,
  `created_at` BIGINT NOT NULL,
  `updated_at` BIGINT NOT NULL,
  INDEX `tenant_id_idx` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

当日のメモには「プライマリーキーが存在しないのか」と書かれている。でもインデックス追加しているみたい。その方がよいのか? (それと今見るとインデックス名が変だな)

この時点のスコアは2,500でした。

  • インデックス追加
ALTER TABLE visit_history ADD INDEX tenant_id_idx_competition_id (tenant_id, competition_id);
  • インデックス追加後の実行計画
mysql> SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = 1 AND competition_id = 'S' GROUP BY player_id;
Empty set (0.00 sec)

mysql> EXPLAIN SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = 1 AND competition_id = 'S' GROUP BY player_id\G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: visit_history
   partitions: NULL
         type: ref
possible_keys: tenant_id_idx,tenant_id_idx_competition_id
          key: tenant_id_idx_competition_id
      key_len: 1030
          ref: const,const
         rows: 1
     filtered: 100.00
        Extra: Using temporary
1 row in set, 1 warning (0.00 sec)
  • インデックス追加後のスロークエリログ
// 変更前
Count: 1718  Time=0.06s (105s)  Lock=0.00s (0s)  Rows=82.7 (142040), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

// 変更後
Count: 2903  Time=0.02s (50s)  Lock=0.00s (0s)  Rows=119.7 (347424), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

スループットが16.36から58.06に改善しました。ただしスコアは2,647でほとんど変更がありません。

5. 対応2 一意な値の生成方法を変更 (id_generatorテーブル) (12:53)

あやしげなスロークエリログがあります。

Count: 8384  Time=0.01s (51s)  Lock=0.01s (47s)  Rows=0.0 (0), isucon[isucon]@localhost
  REPLACE INTO id_generator (stub) VALUES ('S')

mysql> select * from id_generator;
+------------+------+
| id         | stub |
+------------+------+
| 2678409755 | a    |
+------------+------+
1 row in set (0.00 sec)

"REPLACE INTO"なんて当然知らないので検索すると、該当レコードの置き換えをするSQLらしいです。Goのソースコードを読むと丁寧にコメントが書いてありました。

// システム全体で一意なIDを生成する
func dispenseID(ctx context.Context) (string, error) {

というわけで、DBアクセスなしに一意なIDを生成すればよいとのことなので、雑な関数を作成しました。(UUID生成ライブラリは数値以外を除去する方法が分からないためランダム関数を利用しました)

import (
  "fmt"
    "math/rand"
    "time"
)

func main() {
    rand.Seed(time.Now().UnixNano())
    min := 2678400000
    max := 922337203685477
    fmt.Println(rand.Intn(max - min) + min)
}

max値を大きくすることで重複をさける狙いがありましたが、KOBA789さんの解説動画を見ると、BTree構造上あまりよくないらしいです。[1]

  • 変更後のスコア
04:28:20.169932 SCORE: 4050 (+4132 -82(2%))

スロークエリログから対象のクエリーがなくなり上機嫌になったので昼食にすることにしました。

6. 昼食休憩 (13:30)

13:31〜14:12お昼休み。何食べたか覚えていない。

7. 対応3 2台目利用 (14:34)

いつものとおりbind-addressを変更

mysql> SHOW GLOBAL VARIABLES like 'bind_address';
+---------------+-----------+
| Variable_name | Value     |
+---------------+-----------+
| bind_address  | 127.0.0.1 |
+---------------+-----------+
1 row in set (0.01 sec)

sudo vi /etc/mysql/conf.d/my.cnf

[mysqld]
slow_query_log      = 1
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time     = 0
bind-address            = 0.0.0.0

アプリ側の環境変数は以前はSystemdの設定だったけど、今回は... 忘れましたが別のところを変更したと思います。

05:34:15.776365 SCORE: 4689 (+4833 -144(3%))

600点ぐらいUPしました。

8. 対応4 SQLiteにINDEX追加 (16:02)

ここまでSQLiteを利用していることをなんとなく知っていましたが、これがボトルネックになっていることを考えたのはこの時間からでした。(絶望的に遅い..)

ここで僕は考えます。SQLiteをこのまま使うか? または置き換えるか? 前回ISUCON11ではMariaDBが使われていて、MySQLに置き換えるか悩みましたが、結果的にMariaDBでも問題ありませんでした。そのことを思い出した僕は「SQLiteを利用し続ける」と決めました。

調べるとSQLiteもインデックスを利用できて、実行計画も見れるようでした。

sqlite> explain SELECT * FROM player_score WHERE tenant_id = 1 AND competition_id = 1 AND player_id = 1 ORDER BY row_num DESC LIMIT 1;
addr  opcode         p1    p2    p3    p4             p5  comment
----  -------------  ----  ----  ----  -------------  --  -------------
0     Init           0     39    0                    0   Start at 39
1     OpenEphemeral  1     10    0     k(1,-B)        0   nColumn=10
2     Integer        1     1     0                    0   r[1]=1; LIMIT counter
3     OpenRead       0     6     0     8              0   root=6 iDb=0; player_score
4     Rewind         0     27    0                    0
5       Column         0     1     2                    0   r[2]=player_score.tenant_id
6       Ne             3     26    2     BINARY-8       84  if r[2]!=r[3] goto 26
7       Column         0     3     2                    0   r[2]=player_score.competition_id
8       Ne             3     26    2     BINARY-8       82  if r[2]!=r[3] goto 26
9       Column         0     2     2                    0   r[2]=player_score.player_id
10      Ne             3     26    2     BINARY-8       82  if r[2]!=r[3] goto 26
11      Column         0     5     4                    0   r[4]=player_score.row_num
12      Sequence       1     5     0                    0   r[5]=cursor[1].ctr++
13      IfNotZero      1     17    0                    0   if r[1]!=0 then r[1]--, goto 17

sqlite> PRAGMA table_info(player_score);
0|id|VARCHAR(255)|1||1
1|tenant_id|BIGINT|1||0
2|player_id|VARCHAR(255)|1||0
3|competition_id|VARCHAR(255)|1||0
4|score|BIGINT|1||0
5|row_num|BIGINT|1||0
6|created_at|BIGINT|1||0
7|updated_at|BIGINT|1||0

でも上記の実行計画は見方が分かりません。試しにインデックスを追加したところ、体感的に速くなったので(正しいかは不明。ただのキャッシュだったのかも)、全部のDBファイルに対してインデックスを追加することにしました。

sqlite3 1.db < test.sql
sqlite3 14.db < test.sql
sqlite3 2.db < test.sql
sqlite3 25.db < test.sql
sqlite3 30.db < test.sql
sqlite3 36.db < test.sql

上記のような感じでインデックスを追加しました。

07:03:47.987977 SCORE: 5081 (+5405 -324(6%))

失敗も増えましたが、少しスコアが上がりました。なんで失敗が増えたのか分かりません。

9. 対応5 MySQLに変更失敗 (16:24)

ここでMySQLに置き換えることに決めます。理由は以下です。

  1. SQLiteへのインデックスが効果無かった
  2. ツール「sqlite3-to-sql」が用意されていて、運営側からのヒントだと受け取った
  3. テーブル構造が移行しやすい形になっていたので、運営側からのヒントだと受け取った
  4. スロークエリログが見れないことが不満だった
  5. 実運用だったら、(時間があれば)MySQLに移行するのが正しいと思い、正しいことをしたいと思った
  6. 残り時間が少なく、スコアも全然ダメだったので、もうどうでもよいと思った
./sqlite3-to-sql ../../initial_data/1.db >> insert.sql
./sqlite3-to-sql ../../initial_data/14.db >> insert.sql
./sqlite3-to-sql ../../initial_data/2.db >> insert.sql
./sqlite3-to-sql ../../initial_data/25.db >> insert.sql
./sqlite3-to-sql ../../initial_data/30.db >> insert.sql
./sqlite3-to-sql ../../initial_data/36.db >> insert.sql

上記のようなSQL文を作成して、MySQL DBにインポートしました。インポートに20分ぐらいかかりました。(この時間が一番ドキドキしました)

インポート後、SQLiteを利用しているGoのコードを変更しましたが、ベンチマークが失敗したので、元に戻しました。

データベースオブジェクトを作成している部分を1箇所変更すればワンチャンいけるかなと思いましたが、いけませんでした。

当日夜にリアタイ視聴したKOBA789さんの動画を観ると、KOBA789さんのチームは年蜜に移行準備を行っており、自分の浅はかさを思い知らされました。(それがISUCONさ)

最終作業

各種ログを停止、各サーバーを再起動してベンチを実行して、結果が問題ないことを確認しました。とりあえず失格とならなかったので良かったです。

予選終了後の夜にやったこと

  1. KOBA789さんの動画をリアルタイム視聴 (超参考になりました!)
  2. 「詳解Go言語Webアプリケーション開発」購入して読んだ

現在8/16、予選から1ヶ月近くたちましたが、ISUCON熱がまだまだ冷めません!

参考

[1] 【凸待ち】ISUCON12予選 感想戦やるぞ! #ch789 【なんでSQLite3やねん】

「ISUCON11 予選問題の解説と講評」を読んで試してみた

この記事は何か?

ISUCON12予選に参加しました。そのことは別の記事で書きますが、

ISUCON12予選参加時に参考にしたのは自分のブログでした。

なので、去年やってブログに書いていなかった「"ISUCON11 予選問題の解説と講評"を読んで試したこと」を書きます。

たぶん1年後の自分が参考にすると思います。

ISUCON11 予選問題の解説と講評

ISUCON11 予選問題の解説と講評
https://isucon.net/archives/56044867.html

ISUCON11 予選問題実践攻略法
https://isucon.net/archives/56082639.html

上記の2サイトに書いてあることをやりました。

実行環境

AMIがまだなかった(?)ため、matsuuさんのVagrantfileを利用させていただいた気がします。いつもありがとうございます。

vagrant-isucon/isucon11-qualifier-standalone/Vagrantfile
https://github.com/matsuu/vagrant-isucon/blob/master/isucon11-qualifier-standalone/Vagrantfile

コミットログとスコア

  1. 初期スコア (1,402)
  2. alp設定 (1,491)
  3. mysqldumpslow設定 (1,458)
  4. pprof設定 (1,386)
  5. プライマリキー変更 (17,340)
  6. バルクインサート (18,628)
  7. LIMIT 1 追加 (23,848)
  8. レベルカラム追加 (27,000)
  9. グラフデータ条件追加 (28,630)
  10. ログ停止 (32,770)

1 初期スコア (1,402)

https://github.com/zono/isucon11-qualify/issues/1#issue-1007352307

PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
190249 mysql     20   0 1713420 126116  11744 S  73.4   3.3   1:26.76 mysqld
212863 isucon    20   0 1372068 144984  11548 S  43.9   3.8   0:16.23 bench
212664 isucon    20   0 1526580  29908   7632 S  40.5   0.8   0:27.59 isucondition
   162 root      19  -1  303276 123968 122668 S  13.3   3.3   0:07.85 systemd-journal
188178 www-data  20   0   16616   9200   4116 S  12.6   0.2   0:09.35 nginx
188179 www-data  20   0   14728   7276   4116 S   7.0   0.2   0:03.13 nginx
   520 syslog    20   0  224500   4860   3416 S   5.6   0.1   0:03.58 rsyslogd

mysqld、対象アプリ(isucondition)、nginxがCPUを使っています。またbenchも同じサーバーで動かしていますね。

2 alp設定 (1,491)

https://github.com/zono/isucon11-qualify/issues/1#issuecomment-927283219

+-------+--------+------------------------------+-------+-------+---------+-------+-------+
| COUNT | METHOD |             URI              |  MIN  |  MAX  |   SUM   |  AVG  |  P99  |
+-------+--------+------------------------------+-------+-------+---------+-------+-------+
| 69454 | POST   | /api/condition/[-a-z0-9]+    | 0.020 | 0.232 | 706.684 | 0.010 | 0.104 |
|   403 | GET    | /api/isu                     | 0.004 | 3.000 | 342.580 | 0.850 | 1.012 |
|   530 | GET    | /api/isu/.+/icon             | 0.004 | 1.812 |  93.848 | 0.177 | 1.008 |
|   156 | GET    | /api/condition/[-a-z0-9]+    | 1.000 | 1.004 |  28.992 | 0.186 | 1.004 |
|    35 | GET    | /api/trend                   | 0.028 | 1.020 |  27.828 | 0.795 | 1.020 |
|   126 | POST   | /api/auth                    | 0.004 | 3.004 |  25.176 | 0.200 | 3.000 |
|    80 | GET    | /api/isu/.+/graph            | 1.000 | 1.004 |  14.092 | 0.176 | 1.004 |
|   130 | GET    | /api/isu/[-a-z0-9]+          | 3.004 | 3.004 |  13.328 | 0.103 | 1.000 |
|    55 | POST   | /api/isu                     | 0.004 | 0.116 |   4.208 | 0.077 | 0.116 |
|    39 | GET    | /api/user/me                 | 0.000 | 0.632 |   0.920 | 0.024 | 0.632 |
|    23 | POST   | /api/signout                 | 0.004 | 0.088 |   0.312 | 0.014 | 0.088 |
|    81 | GET    | /assets/favicon.d0f5f504.svg | 0.004 | 0.020 |   0.256 | 0.003 | 0.020 |
|    81 | GET    | /assets/vendor.ee7444dd.js   | 0.004 | 0.020 |   0.240 | 0.003 | 0.020 |
|    81 | GET    | /assets/index.23dac98b.js    | 0.004 | 0.012 |   0.196 | 0.002 | 0.012 |
|     1 | POST   | /initialize                  | 0.164 | 0.164 |   0.164 | 0.164 | 0.164 |
|    81 | GET    | /assets/index.144d8ca8.css   | 0.004 | 0.012 |   0.148 | 0.002 | 0.012 |
|    48 | GET    | /                            | 0.004 | 0.012 |   0.124 | 0.003 | 0.012 |
|    81 | GET    | /assets/logo_white.svg       | 0.000 | 0.008 |   0.104 | 0.001 | 0.008 |
|    37 | GET    | /assets/logo_orange.svg      | 0.000 | 0.008 |   0.096 | 0.003 | 0.008 |
|    21 | GET    | /?jwt=                       | 0.000 | 0.012 |   0.056 | 0.003 | 0.012 |
|    30 | GET    | /isu/........-....-.+        | 0.000 | 0.004 |   0.004 | 0.000 | 0.004 |
|     3 | GET    | /register                    | 0.000 | 0.004 |   0.004 | 0.001 | 0.004 |
+-------+--------+------------------------------+-------+-------+---------+

書き込みが多いことが分かります。解説にも以下の記載があります。

例年と違う要素として、予選では珍しい大量の書き込み処理と、書き込まれた分だけ重くなる表示処理をいかにパフォーマンスチューニングするかという要素も問題に取り入れました。

3 mysqldumpslow設定 (1,458)

https://github.com/zono/isucon11-qualify/issues/1#issuecomment-927284684

Count: 2504  Time=0.16s (405s)  Lock=0.00s (0s)  Rows_sent=1.0 (2483), Rows_examined=40767.5 (102081883), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY `timestamp` DESC LIMIT N

Count: 1721  Time=0.08s (129s)  Lock=0.00s (0s)  Rows_sent=246.2 (423744), Rows_examined=18328.1 (31542676), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC

Count: 7035  Time=0.01s (38s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=0.0 (0), isucon[isucon]@localhost
  COMMIT

Count: 4016  Time=0.00s (11s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=0.0 (0), isucon[isucon]@localhost
  #

Count: 2304  Time=0.00s (10s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=1.0 (2304), isucon[isucon]@localhost
  INSERT INTO `isu_condition`   (`jia_isu_uuid`, `timestamp`, `is_sitting`, `condition`, `message`) VALUES ('S', 'S', N, 'S', 'S')

Count: 102  Time=0.04s (4s)  Lock=0.00s (0s)  Rows_sent=91.0 (9282), Rows_examined=7201.6 (734561), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S'  AND `timestamp` < 'S'    ORDER BY `timestamp` DESC

Count: 29  Time=0.04s (1s)  Lock=0.00s (0s)  Rows_sent=142.6 (4135), Rows_examined=7799.1 (226174), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY `timestamp` ASC

合計405秒かかっているクエリーがあります。

4 pprof設定 (1,386)

https://github.com/zono/isucon11-qualify/issues/1#issuecomment-927287786

1109行目で1.98秒かかっています。pprof分かりやすいです。(ISUCON12予選では存在を忘れてて使いませんでした)

5 プライマリキー変更 (17,340)

https://github.com/zono/isucon11-qualify/issues/1#issuecomment-927289590

- PRIMARY KEY(`id`)
+ PRIMARY KEY(`jia_isu_uuid`, `timestamp`)
// 変更前
Count: 1877  Time=0.08s (144s)  Lock=0.00s (0s)  Rows_sent=251.8 (472671), Rows_examined=18193.5 (34149146), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC

// 変更後
Count: 4768  Time=0.00s (22s)  Lock=0.00s (0s)  Rows_sent=538.2 (2566154), Rows_examined=538.2 (2566142), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC

この発想はありませんでした。余分なインデックス作成が無くなるので良いです。

6 バルクインサート (18,628)

https://github.com/zono/isucon11-qualify/issues/1#issuecomment-927294136

var rows []IsuCondition

rows = append(rows, IsuCondition{
        JIAIsuUUID: jiaIsuUUID,
        Timestamp:  timestamp,
        IsSitting:  cond.IsSitting,
        Condition:  cond.Condition,
        Message:    cond.Message,
})

_, err = tx.NamedExec(
        "INSERT INTO `isu_condition`"+
                "       (`jia_isu_uuid`, `timestamp`, `is_sitting`, `condition`, `message`)"+
                "       VALUES (:jia_isu_uuid, :timestamp, :is_sitting, :condition, :message)",
        rows)
// 変更前
Count: 1064  Time=0.00s (3s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=1.0 (1064), isucon[isucon]@localhost
  INSERT INTO `isu_condition`   (`jia_isu_uuid`, `timestamp`, `is_sitting`, `condition`, `message`) VALUES ('S', 'S', N, 'S', 'S')

// 変更後
Count: 255  Time=0.00s (0s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=10.0 (2550), isucon[isucon]@localhost
  INSERT INTO `isu_condition`       (`jia_isu_uuid`, `timestamp`, `is_sitting`, `condition`, `message`)       VALUES ('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S'),('S', 'S', N, 'S', 'S')

7 LIMIT 1 追加 (23,848)

https://github.com/zono/isucon11-qualify/issues/1#issuecomment-927302335

- "SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = ? ORDER BY timestamp DESC",
+ "SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = ? ORDER BY timestamp DESC LIMIT 1",
// 変更前
Count: 4862  Time=0.00s (20s)  Lock=0.00s (0s)  Rows_sent=637.5 (3099623), Rows_examined=637.5 (3099537), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC

// 変更後
Count: 1378  Time=0.00s (4s)  Lock=0.00s (0s)  Rows_sent=1.0 (1357), Rows_examined=1.0 (1353), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC LIMIT N

8 レベルカラム追加 (27,000)

https://github.com/zono/isucon11-qualify/issues/1#issuecomment-927305929

ALTER TABLE `isu_condition` ADD `level` VARCHAR(8) NOT NULL DEFAULT "warning";
UPDATE `isu_condition` SET `level`="info" WHERE `condition`="is_dirty=false,is_overweight=false,is_broken=false";
UPDATE `isu_condition` SET `level`="critical" WHERE `condition`="is_dirty=true,is_overweight=true,is_broken=true";

高得点を狙うならデータ構造の変更は必ず発生する と思ってます。(またはISUCON12予選のSQLiteからMySQLヘの変更のようにDB自体を変更することも)

9 グラフデータ条件追加 (28,630)

https://github.com/zono/isucon11-qualify/issues/1#issuecomment-927307267

- rows, err := tx.Queryx("SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = ? ORDER BY `timestamp` ASC", jiaIsuUUID)
+ rows, err := tx.Queryx("SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = ?"+
+          " AND ? <= timestamp AND timestamp < ?"+
+          " ORDER BY `timestamp` ASC", jiaIsuUUID, graphDate, graphDate.Add(24*time.Hour))

これは仕様理解が必要で、ISUCON11予選は仕様理解が難しかったです。前日は仕事を休む方がいいです。(ISUCON12予選は休んだけど遊びに行きました)

10 ログ停止 (32,770)

-        c.Logger().Warnf("drop post isu condition request")

これも気づきませんでした。詰めが甘いです。

まとめ(1年前に思ったこと)

  1. pprof便利
  2. アプリ仕様理解するの難しい
  3. benchのソースコード理解したい

補足 Makefile

https://github.com/zono/isucon11-qualify/blob/f9c424a0beafc719c4b4b67600c135d26f5ee532/Makefile

例: alp結果表示
$ make alp

1年間の自分は便利なツールを用意してくれたのに、ISUCON12予選では気づきませんでした。(それでは勝てません)

2022/7/30に思うこと

成長が足りません。がんばるます。(2022年にはやっている言葉です to 来年の僕)

ISUCON11予選参加

結果

ISUCON11予選に、チーム「z」(メンバーは私のみ)で参加しました。

結果は、23,886点 195位 (全598チーム) でした。[1]

ほとんど何もできずに終わりましたが、楽しかったです。

来年に向けて1年間準備します!

振り返り

Keep

  • 準備していたことはできた (mysqldumpslow設定, ALP設定など)

Problem

  • 準備不足 (ISUCON慣れしていない)
  • アプリ側何もできなかった (N+1問題すら解決できなかった)

Try

  • ISUCON11予選再実行
  • ISUCON過去問全部やる (ISUCON10予選問題しかやってない)
  • Go言語アプリに慣れる

当日やったこと

目次

  1. 初期ベンチ確認 (10:15)
  2. 準備作業 (10:45)
  3. 対応1 INDEX追加(timestamp) (11:15)
  4. 対応2 INDEX追加(jia_isu_uuid) (12:15)
  5. 対応3 サーバー2台利用(WEB+DB) (13:15)
  6. 対応4 DBログ書き込みのタイミングを変更 (14:15)
  7. 対応5 N+1解決失敗1 (14:30)
  8. 仕様再確認 (15:00)
  9. 対応6 /assets/以下をキャッシュ対象に設定 (16:00)
  10. 対応7 N+1解決失敗2 (17:00)

初期ベンチ確認 (10:15)

初期ベンチを実行。(3,278)

画面を見るためにhostsファイルを変更 (マニュアルに画面を見る方法が記載されていた)

sudo vi /etc/hosts

xxx.xxx.xxx.xxx isucondition.t.isucon.dev

準備作業 (10:45)

マニュアルを30分ぐらい読んだ。ISUの登録がエラーになり、最後まで理由が分からなかった。

ISUCON10予選の「isuumo」よりも構成が複雑と思った。(JIA? ISUからコンディションが送信される? どういう意味?)

とりあえずtopコマンド実行。以下を確認。

  • CPUは2コアあり、全体で100%近く利用されている
  • mysqldのCPU使用率が高い

f:id:yzono:20210828131500p:plain

f:id:yzono:20210828131529p:plain

  • gitコミット
cd /etc/
git init
git add .
git commit -a -m "Initial checkin."

cd /home/isucon/webapp/
git init
git add .
git commit -a -m "Initial checkin."
  • mysqldumpslow設定
sudo vi /etc/mysql/conf.d/my.cnf 

slow_query_log      = 1
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time     = 0

sudo rm /var/lib/mysql/slow.log && sudo /etc/init.d/mysql restart
  • mysqldumpで使うコマンド
// 実行時間の合計が多い順でソート
mysqldumpslow -s t /var/lib/mysql/slow.log | head -30

// 平均実行時間が多い順でソート
mysqldumpslow -s at /var/lib/mysql/slow.log | head -30

// スロークエリログとして検出された件数で多い順でソート
mysqldumpslow -s c /var/lib/mysql/slow.log | head -30
  • DBバージョン確認
isucon@ip-192-168-0-11:~/webapp$ mysql --version
mysql  Ver 15.1 Distrib 10.3.31-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2

MariaDBMySQL のバージョン比較 [2]

f:id:yzono:20210828131818p:plain

  • ALP設定 [3]
wget https://github.com/tkuchiki/alp/releases/download/v0.3.1/alp_linux_amd64.zip
unzip alp_linux_amd64.zip
sudo install ./alp /usr/local/bin

sudo vim /etc/nginx/nginx.conf

http {
  log_format ltsv "time:$time_local"
    "\thost:$remote_addr"
    "\tforwardedfor:$http_x_forwarded_for"
    "\treq:$request"
    "\tmethod:$request_method"
    "\turi:$request_uri"
    "\tstatus:$status"
    "\tsize:$body_bytes_sent"
    "\treferer:$http_referer"
    "\tua:$http_user_agent"
    "\treqtime:$request_time"
    "\truntime:$upstream_http_x_runtime"
    "\tapptime:$upstream_response_time"
    "\tcache:$upstream_http_x_cache"
    "\tvhost:$host";

  access_log  /var/log/nginx/access.log ltsv;
}

sudo rm /var/log/nginx/access.log && sudo /etc/init.d/nginx restart
  • ALPで使うコマンド
alp -f /var/log/nginx/access.log | head
alp -r -f /var/log/nginx/access.log | head
alp --sum -r -f /var/log/nginx/access.log | head
alp --sum -r -f /var/log/nginx/access.log --aggregates='/api/condition/.*' | head
alp --sum -r -f /var/log/nginx/access.log --aggregates='/api/isu/.*' | head

対応1 INDEX追加(timestamp) (11:15)

  • 実行時間の合計が多いクエリ
sudo mysqldumpslow -s t /var/lib/mysql/slow.log | head -30

Count: 6482  Time=0.14s (889s)  Lock=0.00s (0s)  Rows_sent=1.0 (6343), Rows_examined=41212.3 (267137976), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY `timestamp` DESC LIMIT N
  • 実行時間の合計が多いクエリのEXPLAIN
MariaDB [isucondition]> EXPLAIN SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY `timestamp` DESC LIMIT 10;
+------+-------------+---------------+------+---------------+------+---------+------+-------+-----------------------------+
| id   | select_type | table         | type | possible_keys | key  | key_len | ref  | rows  | Extra                       |
+------+-------------+---------------+------+---------------+------+---------+------+-------+-----------------------------+
|    1 | SIMPLE      | isu_condition | ALL  | NULL          | NULL | NULL    | NULL | 72591 | Using where; Using filesort |
+------+-------------+---------------+------+---------------+------+---------+------+-------+-----------------------------+
  • インデックス追加

jia_isu_uuidとtimestampのどちらに設定すべきか迷ったけど、カーディナリティが高い(であろう)timestampに設定した。どのカラムに設定すべきかはよく分かっていない。

CREATE INDEX idx_timestamp ON  isucondition.isu_condition(timestamp);
  • 実行時間の合計が多いクエリ (インデックス追加後)
Count: 3780  Time=0.06s (208s)  Lock=0.00s (0s)  Rows_sent=207.6 (784729), Rows_examined=15865.0 (59969855), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC

0.14s → 0.06s の改善

  • 実行時間の合計が多いクエリのEXPLAIN (インデックス追加後)
MariaDB [isucondition]> EXPLAIN SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY `timestamp` DESC LIMIT 10;
+------+-------------+---------------+-------+---------------+---------------+---------+------+------+-------------+
| id   | select_type | table         | type  | possible_keys | key           | key_len | ref  | rows | Extra       |
+------+-------------+---------------+-------+---------------+---------------+---------+------+------+-------------+
|    1 | SIMPLE      | isu_condition | index | NULL          | idx_timestamp | 5       | NULL |   10 | Using where |
+------+-------------+---------------+-------+---------------+---------------+---------+------+------+-------------+

72591 → 10 の改善

  • ベンチ実行

3,278 → 5,166 の改善

対応2 INDEX(jia_isu_uuid)追加 (12:15)

  • 実行時間の合計が多いクエリ
Count: 750  Time=0.16s (118s)  Lock=0.00s (0s)  Rows_sent=396.1 (297053), Rows_examined=36562.2 (27421666), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S'  AND `timestamp` < 'S'    ORDER BY `timestamp` DESC
  • 実行時間の合計が多いクエリのEXPLAIN
MariaDB [isucondition]> EXPLAIN SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S'  AND `timestamp` < 'S'  ORDER BY `timestamp` DESC;
+------+-------------+---------------+------+---------------+------+---------+------+-------+-----------------------------+
| id   | select_type | table         | type | possible_keys | key  | key_len | ref  | rows  | Extra                       |
+------+-------------+---------------+------+---------------+------+---------+------+-------+-----------------------------+
|    1 | SIMPLE      | isu_condition | ALL  | idx_timestamp | NULL | NULL    | NULL | 73244 | Using where; Using filesort |
+------+-------------+---------------+------+---------------+------+---------+------+-------+-----------------------------+
  • インデックス追加

CREATE INDEX idx_jia_isu_uuid ON isucondition.isu_condition(jia_isu_uuid);

  • 実行時間の合計が多いクエリ (インデックス追加後)
Count: 2383  Time=0.02s (39s)  Lock=0.00s (0s)  Rows_sent=489.3 (1166055), Rows_examined=1174.4 (2798517), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S'  AND `timestamp` < 'S'    ORDER BY `timestamp` DESC

0.16s → 0.02s の改善

  • 実行時間の合計が多いクエリのEXPLAIN (インデックス追加後)
MariaDB [isucondition]> EXPLAIN SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S'  AND `timestamp` < 'S'  ORDER BY `timestamp` DESC;
+------+-------------+---------------+------+--------------------------------+------------------+---------+-------+------+----------------------------------------------------+
| id   | select_type | table         | type | possible_keys                  | key              | key_len | ref   | rows | Extra                                              |
+------+-------------+---------------+------+--------------------------------+------------------+---------+-------+------+----------------------------------------------------+
|    1 | SIMPLE      | isu_condition | ref  | idx_timestamp,idx_jia_isu_uuid | idx_jia_isu_uuid | 144     | const |    1 | Using index condition; Using where; Using filesort |
+------+-------------+---------------+------+--------------------------------+------------------+---------+-------+------+----------------------------------------------------+

73244 → 1 の改善

  • ベンチ実行

5,166 → 15,894 の改善

対応3 サーバー2台利用(WEB+DB) (13:15)

  • WEB→DBへのアクセス許可設定
MariaDB [(none)]> SHOW GLOBAL VARIABLES like 'bind_address';
+---------------+-----------+
| Variable_name | Value     |
+---------------+-----------+
| bind_address  | 127.0.0.1 |
+---------------+-----------+
sudo vi /etc/mysql/mariadb.conf.d/50-server.cnf

#bind-address            = 127.0.0.1

sudo rm /var/lib/mysql/slow.log && sudo /etc/init.d/mysql restart
  • WEB→DBへのアクセス許可設定 (変更後)
MariaDB [(none)]> SHOW GLOBAL VARIABLES like 'bind_address';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| bind_address  | *     |
+---------------+-------+
  • isuconditionのサービス確認
$ cat /etc/systemd/system/isucondition.go.service

EnvironmentFile=/home/isucon/env.sh
  • env.shファイル変更
$ vi env.sh
MYSQL_HOST="xxx.xxx.xxx.xxx"

$ sudo /etc/init.d/isucondition.go.service restart
  • ベンチ実行

15,894 → 23,436 の改善

f:id:yzono:20210828131947p:plain

この時点で50位でした。

対応4 DBログ書き込みのタイミングを変更 (14:15)

  • 実行時間の合計が多いクエリ
Count: 10025  Time=0.00s (37s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=0.0 (0), isucon[isucon]@isucondition-1.t.isucon.dev
  COMMIT

この「COMMIT」の総時間が多かったので、DBログ書き込みのタイミングを変更しました。[4]

  • DBログ書き込みのタイミングを変更
$ sudo vi /etc/mysql/conf.d/my.cnf 

innodb_flush_log_at_trx_commit=2
  • 実行時間の合計が多いクエリ
Count: 9990  Time=0.00s (2s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=0.0 (0), isucon[isucon]@isucondition-1.t.isucon.dev
  COMMIT

37s → 2s の改善

  • ベンチ実行

23,436 → 23,196。ほぼ変化なし

  • topコマンド確認

f:id:yzono:20210828132205p:plain

f:id:yzono:20210828132252p:plain

DBの使用率は70%程度だったので、アプリ側の対応に着手しました。

対応5 N+1解決失敗1 (14:30)

  • ALPコマンド確認

f:id:yzono:20210828132103p:plain

1079 func getTrend(c echo.Context) error {
1080         characterList := []Isu{}
1081         err := db.Select(&characterList, "SELECT `character` FROM `isu` GROUP BY `character`")

N+1が発生していることを確認

  • スロークエリログとして検出された件数で多いクエリ確認
sudo mysqldumpslow -s c /var/lib/mysql/slow.log | head -30

Count: 25255  Time=0.00s (88s)  Lock=0.00s (0s)  Rows_sent=318.9 (8053234), Rows_examined=631.8 (15955566), Rows_affected=0.0 (0), isucon[isucon]@isucondition-1.t.isucon.dev
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC

88sを解決できないか確認

all: isucondition

isucondition: *.go
    go build -o isucondition
  • 断念

クエリを1つにした場合にベンチエラーが発生したので一旦断念。

仕様再確認 (15:00)

アプリの仕様を理解するために、再度マニュアルや画面を確認しました。

  • ISU登録失敗
{"time":"2021-08-21T15:53:41.260291827+09:00","level":"ERROR","prefix":"echo","file":"main.go","line":"610","message":"failed to request to JIAService: Post \"http://13.113.232.243:5000/api/activate\": dial tcp 13.113.232.243:5000: connect: connection refused"}

{"time":"2021-08-21T15:53:41.260559356+09:00","id":"","remote_ip":"127.0.0.1","host":"isucondition.t.isucon.dev","method":"POST","uri":"/api/isu","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36","status":500,"error":"","latency":2877574,"latency_human":"2.877574ms","bytes_in":275,"bytes_out":0}

{"time":"2021-08-21T16:22:33.295717404+09:00","level":"ERROR","prefix":"echo","file":"main.go","line":"610","message":"failed to request to JIAService: Post \"/api/activate\": unsupported protocol scheme \"\""}

結局、最後までISU登録は成功しませんでした。

対応6 /assets/以下をキャッシュ対象に設定 (16:00)

isuconditionアプリログを見ると、/assets/へのアクセスが多かったのでキャッシュ設定を追加。[5]

  • WEBサーバー設定変更
server {
    location /assets/ {
        expires 24h;
    }
  • ベンチ実行

23,196 → 23,528。ほぼ変化なし

対応7 N+1解決失敗2 (17:00)

N+1問題に再チャレンジ。「時系列の最新を取得する」といえば「ウィンドウ関数」ではないか? MariaDBのバージョンも「ウィンドウ関数」をサポートしている。ということで試してみました。

  • ウィンドウ関数の結果
SELECT 
  i.id, 
  i.jia_isu_uuid, 
  c.condition, 
  c.timestamp 
FROM   
  isu i 
INNER JOIN
(
SELECT 
  c.id,
  c.condition, 
  c.timestamp,
  Row_number() OVER ( 
    partition BY 
      id 
    ORDER BY 
      timestamp DESC 
  ) AS seqnum 
FROM   
    `isu_condition` c
) c
WHERE
  i.id = c.id
AND
  c.seqnum = 1;

+----+--------------------------------------+----------------------------------------------------+---------------------+
| id | jia_isu_uuid                         | condition                                          | timestamp           |
+----+--------------------------------------+----------------------------------------------------+---------------------+
| 14 | 023c8e56-4410-484d-b7fa-702d48188d3c | is_dirty=false,is_overweight=false,is_broken=false | 2021-06-04 04:59:09 |
| 21 | 0634a999-5f5b-4e8b-bee6-f95d622954a4 | is_dirty=true,is_overweight=false,is_broken=false  | 2021-06-04 11:59:09 |
| 63 | 063a0168-f0da-4603-9267-4f05c8565c45 | is_dirty=false,is_overweight=false,is_broken=true  | 2021-06-06 05:59:09 |
| 23 | 08985a64-c425-48d2-ac33-25ac61d5a931 | is_dirty=false,is_overweight=false,is_broken=false | 2021-06-04 13:59:09 |
| 24 | 0b675a72-c8fd-4c21-b87b-eefc1d5df5e4 | is_dirty=false,is_overweight=true,is_broken=false  | 2021-06-04 14:59:09 |
| 34 | 0bb7ebac-2dd0-49c8-a3bf-830a5b3c02a1 | is_dirty=false,is_overweight=true,is_broken=false  | 2021-06-05 00:59:09 |
| 65 | 1135cbe0-c5f5-4e77-ac87-d79dce97b1a7 | is_dirty=false,is_overweight=false,is_broken=true  | 2021-06-06 07:59:09 |
|  4 | 15063e10-26fc-4dac-ae3a-1f195e0c6c4a | is_dirty=true,is_overweight=false,is_broken=true   | 2021-06-03 18:59:09 |

当時は「それっぽい結果が出た」と思いました。

  • ベンチ実行

失敗。時間が迫っていたのでソースコードを元に戻しました。

最終作業 (18:00)

各サーバーを再起動してベンチを実行して、結果が問題ないことを確認しました。

参考

[1] ISUCON11 オンライン予選 全てのチームのスコア(参考値) https://isucon.net/archives/56021246.html

[2] MariaDBMySQL のバージョン比較 https://qiita.com/katzueno/items/e735950c7440f232ef27

[3] alpのインストールからNginxのアクセスログ解析までの手順【ISUCON】 https://nishinatoshiharu.com/install-alp-to-nginx/

[4] [ISUCON用メモ] MySQL https://qiita.com/stomk/items/6265e9fdfdfb4f104a7e

[5] nginxで静的コンテンツをキャッシュ配信する方法 https://nishinatoshiharu.com/nginx-static-deliver/

ISUCON11予選準備

準備したこと

8/21 ISUCON11予選に向けて、ISUCON10予選を試してみました。 具体的に対応した結果とスコアは以下です。

  1. 初期スコア (444)
  2. ChairテーブルINDEX(500)
  3. EstateテーブルINDEX(531)
  4. MySQL8アップグレード (308)
  5. 降順INDEX(601)
  6. N+1問題 (628)
  7. 空間INDEX (645)
  8. bot削除 (1126)
  9. gzip圧縮の有効 (1060)
  10. WEB+DB構成 (1340)
  11. DB複数台構成 (x)
  12. New Relic (x)

資料

準備の詳細は下記資料にまとめました。