こんにちは!バックエンドエンジニアのカーキです。
今年の9月に初めてISUCONに挑戦してみたのですが、予選で手も足も出なくて悔しい思いをしました。問題は面白かったので、同じ問題をもう一度本気で解いてみました。
- 問題のソースコード: https://github.com/isucon/isucon11-qualify
- マニュアル: https://github.com/isucon/isucon11-qualify/blob/main/docs/manual.md
- アプリケーションについて: https://github.com/isucon/isucon11-qualify/blob/main/docs/isucondition.md
ISUCONとは
ISUCONは8時間で渡されたWebアプリケーションのボトルネックを解消し、パフォーマンスを上げるコンテストです。評価はISUCON側で用意してあるベンチマーカーを実行してから出る最終スコアでされます。
環境準備
ISUCONはGo, Rust, Pythonなどいろんな言語で挑戦できますが、仕事で長くNode.jsを使ってるので言語はJavaScript (Node.js)にしました。
もらえるソースコードには監視ツールがついてないので、ボトルネックを可視化するために、以下のツールをアプリケーションに組みました:
ログ分析ツール: Goaccess
Webアプリケーションを何もいじってない状態でGoaccessでベンチマーク時のリクエストを分析してみたら、こんなレポートが出ました。
APMツール: Elastic APM
Elastic APMを使うことでエンドポイント毎にかかってる時間だけではなく、繋がってるシステム全ての細かいtraceまで見れてとても便利でした。
試してみたこと
Goaccess
と Elastic apm
の結果の基に以下の改善点を試してみました
1. Staticファイルの配信
現行のコードではExpressからstaticファイル (css, jsなど)が配信されています。これによってExpressに無駄に負荷がかかってたので、staticファイルをNginxから配信するようにしました。
location /assets/ { access_log /var/log/nginx/static-access.log; error_log /var/log/nginx/static-error.log; root /static/; }
2. テーブルにIndexの追加
SQLでSELECTしてるクエリをみてみたら絞り込みやソート条件になってるカラムにindexが貼られてなさそうだったので、DB初期化スクリプトにindex作成のクエリを追加しました。
3. INSERTの効率化
椅子の状態を大量にPOSTする /api/condition/:椅子のID
APIではリクエストのbodyに複数件のisu_condition
が入っていて、現行のコードではloopで一つずつデータベースにINSERTするようになっています。ここをバルクINSERTに変えたら効率よくINSERTができ、スコアが改善しました。
4. Database設計の見直し
今回の課題では登録済みの椅子が自分の状態 (壊れてるかどうか、汚れてるかどうか、座られてるかどうかなど) を定期的にWebアプリケーションにPOSTする流れになっていて、当然このPOSTのエンドポイントへのリクエストが多いのでボトルネックになりがちです。既存のコードだとPOSTされたデータが以下のテーブルに入ります
CREATE TABLE `isu_condition` ( `id` bigint AUTO_INCREMENT, `jia_isu_uuid` CHAR(36) NOT NULL, `timestamp` DATETIME NOT NULL, `is_sitting` TINYINT(1) NOT NULL, `condition` VARCHAR(255) NOT NULL, `message` VARCHAR(255) NOT NULL, `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY(`id`) ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;
このテーブルはINSERT onlyなので、椅子の新しい状態のデータがどんどんINSERTされ続ける設計になっていて膨大になりがちです。INSERT only自体は問題がないんですが、別の/api/trend
というAPIでは各椅子の最新状態が必要になり、リアルタイムで isu_condition
テーブルをクエリして椅子毎の最新の状態を取得にはすごくコストがかかります。また、trend
取得時に isu_condition
の condition
カラムのデータからアラートのステータスを毎回作成してるところも効率が悪いです。
改善1: isu_condition
テーブルのリファクタリング
/api/trend
では condition
の値を毎回サーバー側で is_dirty
, is_overweight
, is_broken
と condition_level
にparseしてるので、取得時のコストを下げるために、INSERTの時点でそれぞれの値をテーブルに入れるようにしました。
新しいテーブル設計は以下のようにしました:
CREATE TABLE `isu_condition` ( `id` bigint AUTO_INCREMENT, `jia_isu_uuid` CHAR(36) NOT NULL, `timestamp` DATETIME NOT NULL, `is_sitting` TINYINT(1) NOT NULL, `condition` VARCHAR(255) NOT NULL, `is_dirty` BOOLEAN, `is_overweight` BOOLEAN, `is_broken` BOOLEAN, `condition_level` VARCHAR(255), `message` VARCHAR(255) NOT NULL, `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY(`id`) ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;
改善2: 椅子毎の最新の状態を持つテーブルをべつで用意
isu_condition
から毎回最新の状態を取得するのは効率が悪いので最新の状態のみを持つ latest_isu_condition
という別のテーブルを用意し、isu_condition
に INSERT
が走った時に毎回MySQLのトリガーでこの新しいテーブルにUPSERTするようにして、latest_isu_condition
には常に最新の状態が反映されるようにしました。
また、/api/trend
で毎回 isu
テーブルとジョインしてname
やcharacter
を取得していたので、latest_isu_condition
に最初から椅子のname
とcharacter
も入るようにして効率を上げました。
CREATE TABLE `latest_isu_condition` ( `isu_id` bigint, `isu_character` VARCHAR(255), `jia_isu_uuid` CHAR(36) NOT NULL, `timestamp` DATETIME NOT NULL, `is_sitting` TINYINT(1) NOT NULL, `condition` VARCHAR(255) NOT NULL, `is_dirty` BOOLEAN, `is_overweight` BOOLEAN, `is_broken` BOOLEAN, `condition_level` VARCHAR(10), `message` VARCHAR(255) NOT NULL, `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY(`jia_isu_uuid`) ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;
5. n+1 問題
現行の GET /api/isu
エンドポイントで椅子一覧を取得してからloopで各椅子の最新の isu_condition
を取得するようになっていて、 n+1
問題があります。
例:
const [isuList] = await db.query<Isu[]>( "SELECT * FROM `isu` WHERE `jia_user_id` = ? ORDER BY `id` DESC", [jiaUserId] ); const responseList: Array<GetIsuListResponse> = []; for (const isu of isuList) { let foundLastCondition = true; const [[lastCondition]] = await db.query<IsuCondition[]>( "SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = ? ORDER BY `timestamp` DESC LIMIT 1", [isu.jia_isu_uuid] );
ここは JOIN + latest_isu_condition
テーブルを使うことで n+1
問題を解消し、パフォーマンスをかなり上げることができました。
6. Nodeアプリケーションのクラスター化
Node.jsのinstanceはシングルスレッドで動くのでサーバーがマルチコアだとしてもそのままでは最大限に力が発揮できません。そこで、Node.jsのClusterモジュールを使うことで、複数の子processでNode.jsを動かすことができ、マルチコアなサーバーの力を最大限に引き出すことができそうだったので cluster
モジュールを使ってみたらさらにスコアを上げることができました。
7. アイコン配信の効率化
Goaccess
の分析結果をみたら icon
の取得にも数百ms時間かかってそうだったので改善してみました。
現行のコードだとDBに画像をBlobとして保存してるので、DBに無駄に負荷がかかってる状態だったのでiconもNginxから配信するように設計し直しました。
アイコンのファイルを椅子とユーザーIDの組み合わせでファイルシステムに保存するようにして、Expressからではなく、Nginxから配信するようにしたらかなり速度が上がりました。icon取得の認証にはNginxのauth_request
機能を初めて触ってみたんですが、かなり役に立ちました。
結果
重たいエンドポイント一覧 (降順)
BEFORE
AFTER
/post/initialize
はベンチマーク時の初期化のスクリプトで、スコアに影響しないエンドポイントです
エンドポイント毎: /api/isu/<椅子のID>
BEFORE
AFTER
エンドポイント毎: /api/trend
BEFORE
AFTER
次に試したいこと
まだアイディアベースですが、次に以下の改善策を試してみようと思っています
- Endpoint毎の細かい改善
- Nginxでロードバランス
- Session storeをRedisにする
- Queueの導入し、非同期で実行していいものはQueueに入れて、コンシューマーで実行するようにする
- Kafka Streamsを使って
isu_condition
のストリームを作ってTrendやグラフデータの作成を楽にする
まとめ
ISUCONに参加して何もできなかったことで、色々勉強し直す気になり、いい刺激を受けました。くらしのマーケットのシステムにも適用できる部分が沢山あったので、おかげで日々の仕事でもボトルネックを意識するようになり、参加してかなり良かったと思いました。今回のアプリケーションにはまだ見えてない改善点がいっぱいあると思うので継続して改善してみます!