くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログです

ISUCONの過去問にチャレンジしてみた: Part-1

こんにちは!バックエンドエンジニアのカーキです。

今年の9月に初めてISUCONに挑戦してみたのですが、予選で手も足も出なくて悔しい思いをしました。問題は面白かったので、同じ問題をもう一度本気で解いてみました。

ISUCONとは

ISUCONは8時間で渡されたWebアプリケーションのボトルネックを解消し、パフォーマンスを上げるコンテストです。評価はISUCON側で用意してあるベンチマーカーを実行してから出る最終スコアでされます。

環境準備

ISUCONはGo, Rust, Pythonなどいろんな言語で挑戦できますが、仕事で長くNode.jsを使ってるので言語はJavaScript (Node.js)にしました。

もらえるソースコードには監視ツールがついてないので、ボトルネックを可視化するために、以下のツールをアプリケーションに組みました:

ログ分析ツール: Goaccess

Webアプリケーションを何もいじってない状態でGoaccessでベンチマーク時のリクエストを分析してみたら、こんなレポートが出ました。 f:id:curama-tech:20210922155044p:plain

APMツール: Elastic APM

Elastic APMを使うことでエンドポイント毎にかかってる時間だけではなく、繋がってるシステム全ての細かいtraceまで見れてとても便利でした。 f:id:curama-tech:20210922165441p:plain

試してみたこと

GoaccessElastic 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_conditioncondition カラムのデータからアラートのステータスを毎回作成してるところも効率が悪いです。

改善1: isu_condition テーブルのリファクタリング

/api/trend では condition の値を毎回サーバー側で is_dirty, is_overweight, is_brokencondition_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_conditionINSERT が走った時に毎回MySQLのトリガーでこの新しいテーブルにUPSERTするようにして、latest_isu_condition には常に最新の状態が反映されるようにしました。 また、/api/trend で毎回 isu テーブルとジョインしてnamecharacter を取得していたので、latest_isu_condition に最初から椅子のnamecharacterも入るようにして効率を上げました。

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 f:id:curama-tech:20210922165112p:plain

AFTER

f:id:curama-tech:20210922165230p:plain /post/initialize はベンチマーク時の初期化のスクリプトで、スコアに影響しないエンドポイントです

エンドポイント毎: /api/isu/<椅子のID>

BEFORE

f:id:curama-tech:20210922165351p:plain

AFTER

f:id:curama-tech:20210922165404p:plain

エンドポイント毎: /api/trend

BEFORE

f:id:curama-tech:20210922165441p:plain

AFTER

f:id:curama-tech:20210922181709p:plain

次に試したいこと

まだアイディアベースですが、次に以下の改善策を試してみようと思っています

  • Endpoint毎の細かい改善
  • Nginxでロードバランス
  • Session storeをRedisにする
  • Queueの導入し、非同期で実行していいものはQueueに入れて、コンシューマーで実行するようにする
  • Kafka Streamsを使ってisu_condition のストリームを作ってTrendやグラフデータの作成を楽にする

まとめ

ISUCONに参加して何もできなかったことで、色々勉強し直す気になり、いい刺激を受けました。くらしのマーケットのシステムにも適用できる部分が沢山あったので、おかげで日々の仕事でもボトルネックを意識するようになり、参加してかなり良かったと思いました。今回のアプリケーションにはまだ見えてない改善点がいっぱいあると思うので継続して改善してみます!