# ISUCON 13 で 18,673 点でした
ISUCON 13 に「[a, b, u] <- 3σ」というチームで fplwd と [BTBTBT](https://mstdn.anqou.net/@BBTT) と一緒に出ていました。最終スコアが 18,673 点で、公式発表のページで上から数えると 113 位くらいでした。
## 事前準備
久しぶりの ISUCON だったので、過去の秘伝のタレを漁って事前準備をしていました。メンバー全員で集まれる機会が 2 回あったので、ISUCON 11 と 12 の予選を使って実践形式の練習をしました。fplwd と自分は以前に ISUCON に出たことがあったのですが、BTBTBT は初だったので、諸々のツールの使い方とか、N+1 の潰し方とかを伝授していました。
## 当日
無事全員起床できたので、10 時から開始しました。
### 諸々の準備
とりあえず AWS のスタックを立てて、SSH 接続できるようにして、ベンチマークを回して初期スコアを出しました(3,509)。fplwd が Ansible を使ってツールを色々入れてくれていたので、その間に BTBTBT と自分でアプリケーションマニュアルを読んでいました。この時点で、アイコン生成に改善ポイントがあることと、ひっきりになしに DNS 水責め攻撃が行われるというやばすぎる環境であることを把握しました。
fplwd が入れてくれた ALP を使って遅いエンドポイントを探しました。案の定アイコンの GET と POST が遅かったので、アプリコードに手を入れて、アイコンのハッシュ値の生成と保存を非同期にやるようにして、さらに `If-None-Match` ヘッダーを正しくハンドルするようにしました。この時点ではあまり点数は伸びなかったのですが、後々 `POST` 側の処理に誤りがあることに気づいて、直したところ 3000 点くらい伸びた気がします。
### DNS 水責め攻撃をどうにかする(1)
このあたりで、MySQL にかなり負荷がかかっていることに気づきました。DNS クエリに返答している PowerDNS は MySQL を使ってレコードが存在するかを判定するので、DNS 水責め攻撃のせいで MySQL に凄まじい負荷がかかっていそうでした。`journalctl` を見ると PowerDNS がログを吐きまくっていたので、設定をいじってログを吐かないようにしてみましたが、対してスコアは上がりませんでした。
そこで fplwd が MySQL を別サーバに移動させてくれました。その後、DNS 用の DB とアプリ用の DB を別サーバに移せることに気づいたので、これをやりました。この双方で 1000 点くらい上がった気がします。いずれにしてもアプリ側の負荷は大したことはなくて、MySQL を担っている2つのサーバの負荷がえらいことになっていました。
### DNS 水責め攻撃をどうにかする(2)
さくらインターネットの記事などを見ながら dnsdist を入れてリクエストを絞ったりしてみましたが、パフォーマンスは特に改善しませんでした。その後 fplwd が PowerDNS の設定をいじって MySQL を使わずに DNS クエリを打ち返せるようにしてくれましたが、点数はあまり伸びませんでした。
### アプリコードの改善
BTBTBT が定期的に ALP と fgprof をかけてアプリコードの遅い部分を洗い出してくれていたので、BTBTBT と自分で手分けしてコードを改善していました。Git log を見ると以下のような部分を改善していたようです。
- `fillLivestreamResponse` の N+1 を潰す
- `moderateHandler` の N+1 を潰す
- `reserveLivestreamHandler` の無駄なクエリを削除
その他の細かいインフラの整備(覚えてない)も含めて、17 時直前に 9,368 点でした。
### `reserveLivestreamHandler` のクエリの改善
スロークエリを睨んでいた fplwd が、このハンドラのクエリで、`startAt` と `endAt` の2つで大小比較をしているせいでインデックスがうまく使われていないことに気づきました。予約スロットは規則的な仕組みで並んでいたので、予め以下のようにして ID の範囲に変換してからクエリを投げることで、インデックスがうまく使われるようにしました。
```diff=
+ startIndex := math.Ceil(float64(req.StartAt-1700874000) / 3600)
+ endIndex := math.Floor(float64(req.EndAt-1700874000) / 3600)
+
// 予約枠をみて、予約が可能か調べる
// NOTE: 並列な予約のoverbooking防止にFOR UPDATEが必要
var slots []*ReservationSlotModel
- if err := tx.SelectContext(ctx, &slots, "SELECT * FROM reservation_slots WHERE start_at >= ? AND end_at <= ? FOR UPDATE", req.StartAt, req.EndAt); err != nil {
+ if err := tx.SelectContext(ctx, &slots, "SELECT * FROM reservation_slots WHERE id >= ? AND id <= ? FOR UPDATE", startIndex, endIndex); err != nil {
```
この修正で 1 万点を超えました(10,861)。
## `getUserStatisticsHandler` の N+1 を潰す
このあたりで BTBTBT が ALP をかけると、statistics のエンドポイントが遅そうでした。見ると N+1 でクエリを発行していたので、`GROUP BY` を使って潰しました。潰し始めたのが 17 時過ぎとかだったのですが、わりと早くクエリは書くことができました。これがめちゃめちゃ効いて、いっきに 17,649 点まで上がりました。
```diff=
- var users []*UserModel
- if err := tx.SelectContext(ctx, &users, "SELECT * FROM users"); err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "failed to get users: "+err.Error
())
+ query := `
+SELECT u.id AS id, u.name AS username, COUNT(*) AS count
+FROM users u
+INNER JOIN livestreams l ON l.user_id = u.id
+INNER JOIN reactions r ON r.livestream_id = l.id
+GROUP BY u.id, u.name
```
潰せたのは user の統計だけで、livestream の方は間に合いませんでした。
## 後始末
このあたりで時間切れだったので、ログ出力コードなどを消して、最終的にスコアが 18,673 点でした。livestream の統計の改善を時間いっぱいまで頑張っていたので再起動試験は時間内にはテストできませんでした。
## おわりに
正直、かなり悔いが残るというか、反省ポイントが多めの結果でした。最後の livestream の統計処理の改善が入っていれば 2 万は超えたんじゃないかという気もしますし、そもそも ALP, fgprof をみてアプリコードを改善するというサイクルがもっと早く回り始めていればもっと点数が上がっただろうなという反省もあります。
過去に参加した [ISUCON 9](https://anqou.net/poc/2019/09/08/post-3057/) と [10](https://anqou.net/poc/2020/09/13/post-3323/) と比べて、(i) ALP と fgprof の結果を見て (ii) コードを修正して (iii) ベンチを回して動作を確認する(必要なら (i) や (ii) に戻る)、というサイクルを素早く回せなかったのは大きめの反省ポイントでした。原因ははっきりしていて、DNS 水責めの対策のために PowerDNS や dnsdist をいじっていて、ベンチマークを回せない状態が序盤から続いてしまいました。この状態が続いていることはコンテスト中から気づいていたのですが、何も対策が打てませんでした。
まぁでも、ISUCON 10 の決勝みたいに、最後に fail しなくてよかったなーという感じです。お疲れ様でした。