ISUCON11 予選参加記(最終スコア 117585)

チーム「四年ぶり二度目」で @brook_bach と @mayoko_ で参加しました。4年前に ISUCON7 に参加したときと同じチームです。

kujira16.hateblo.jp

「興味ある言語でやってみたいよね~」ということで Rust で参加しました。私は本を積んでいる状態だったので、2週間くらいで急いで「実践 Rust プログラミング入門」の5章までを読んで間に合わせました。

事前準備は過去の ISUCON で使った便利スクリプトをかき集めてきたことと、MySQLAWS 向けにチューニングしたことくらいです。

以下はやったことの概要です。典型的なものしか直せていないので、作問者の意図した「この課題特有のおもしろいところ」まではたどり着けていない感があります。

MariaDB から MySQL に差し替えた(担当 @shora_kujira16)

最近の MariaDB の進化に付いていけていないので MySQL 8.0.26 に差し替えて、事前に用意しておいた設定を適用しました。

これだけだと failed to connect db: Tls(InvalidDNSNameError) というエラーが出て MySQL に接続できない状態になってしまったので少し焦りました。以下の記事を参考にして Rust から MySQL に接続するときの TLS の実装を runtime-actix-rustls から runtime-actix-native-tls に変更しました。

blog.foresta.me

あとから気づいたのですが、TLS の CPU 負荷も少し気になるので、MySQL への接続に TLS を利用しないことを検討してもよかったのかもしれません。

あとは GROUP BY による暗黙のソートの仕様が MySQL 8.0 で変更されたことも今思えば少し危なかったかもしれません。SELECT `character` FROM `isu` GROUP BY `character` というステートメントが使われているのですが、MySQL 5.7 以前では GROUP BY `character` ORDER BY `character` と書かれたのと同じになる仕様でした。MySQL 8.0 ではその仕様がなくなったため、同じ挙動にするためには ORDER BY `character` を付けてやる必要があります。

yoku0825.blogspot.com

Ruby に関するファイルを削除

rust と rubyプレフィックスが ru まで一緒のため、ディレクトリの移動や isucondition.rust.service の再起動などあらゆるところで補完が止まります。コマンド入力の生産性の低下につながってくるので消しました。

ログイン状態の確認を簡略化(担当 @brook_bach)

アプリケーションのあらゆる個所で require_signed_in によって user テーブルにセッションの値を問い合わせて正式なユーザーであることの確認をしていますが、ユーザーを削除するような処理は存在しないのでセッションの値を信用するということで user テーブルへの問い合わせをやめました。

isucon11-qualify/main.rs at a443b242596003515c3037540dd3e48abfa87382 · isucon/isucon11-qualify · GitHub

GET /api/isu の N+1 を少し改善(担当 @shora_kujira16)

isu テーブルのレコードそれぞれについて isu_condition の最新の値をとってくるというクエリがあったので、この部分の改善を試みました。こういうパターンでは MySQL 8.0 で使えるようになったウィンドウ関数が使えたはずだという記憶があったので使い方をググったのですが、微妙にシンタックスエラーになるため以下のようなサブクエリで妥協しました。

SELECT
    isu.id AS id,
    isu.character AS `character`,
    isu.jia_isu_uuid AS jia_isu_uuid,
    isu.name AS name,
    (SELECT isu_condition.timestamp FROM isu_condition WHERE isu_condition.jia_isu_uuid = isu.jia_isu_uuid ORDER BY isu_condition.timestamp DESC LIMIT 1) AS timestamp,
    (SELECT isu_condition.is_sitting FROM isu_condition WHERE isu_condition.jia_isu_uuid = isu.jia_isu_uuid ORDER BY isu_condition.timestamp DESC LIMIT 1) AS is_sitting,
    (SELECT isu_condition.condition FROM isu_condition WHERE isu_condition.jia_isu_uuid = isu.jia_isu_uuid ORDER BY isu_condition.timestamp DESC LIMIT 1) AS `condition`,
    (SELECT isu_condition.message FROM isu_condition WHERE isu_condition.jia_isu_uuid = isu.jia_isu_uuid ORDER BY isu_condition.timestamp DESC LIMIT 1) AS message
FROM isu
WHERE isu.jia_user_id = ? ORDER BY isu.id DESC

他の人の参加記を読んで分かったのですが、こういった処理では MySQL 8.0.14 で追加された LATERAL 句も便利なようです。LATERAL 句では LIMIT 1 に限らず LIMIT N が必要になる場合でも対応できるようです。

インデックスを付ける(担当 @shora_kujira16)

N+1 の改善で全く速くならなかったのでなんだこれと思ってよく見直すとインデックスがまともについていなかったので付けました。MySQL 8.0 で使えるようになった降順インデックスがどれくらい寄与しているのかは分からないです。

-- WHERE isu.jia_user_id = ? ORDER BY isu.id DESC の高速化狙い
ALTER TABLE isu ADD INDEX ia_user_id_and_id (jia_user_id, id DESC);

-- WHERE isu_condition.jia_isu_uuid = ? ORDER BY isu_condition.timestamp DESC の高速化狙い
ALTER TABLE isu_condition ADD INDEX jia_isu_uuid_and_timestamp (jia_isu_uuid, timestamp DESC);

この改善でスコアが 13000 程度になりました。

画像を nginx から配信する(担当 @brook_bach, @mayoko_)

/api/isu/*/icon に10秒くらいかかっているものがあったので nginx から配信することにしました。今よく考えると、もしかすると Conditional GET のためのヘッダを適切に付与していれば省略できたのかもしれません。

画像ファイルが MySQL に保存されているのを静的ファイルとして保存するように変更するという課題は私たちのチームが前回参加した ISUCON 7 でまさに出題されたものです。今回の課題で違ったのは認証が含まれているということです。アプリケーション側でなにか処理をした結果に応じて静的ファイルを返すかどうか決めるというのはよく必要になる処理であり、多くの Web サーバーがそれに適した機能を提供しています。nginx ではアプリケーションから返すレスポンスヘッダに X-Accel-Redirect を付けると指定個所にリクエストを rewrite してくれるため、これを利用しました。

最新の isu_condition を取ってくる処理の高速化のため、最新の isu_condition を別テーブルに保存する(担当 @shora_kujira16)

GET /api/isu がまだ遅かった(1秒以上かかっていた)ため、N+1 を解消するためにサブクエリではなく JOIN が使えるように ORDER BY isu_condition.timestamp DESC LIMIT 1 の部分を別のテーブルに保存することにしました。

以下のようなテーブルを作り、

CREATE TABLE `last_condition` (
  `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,
  PRIMARY KEY (`jia_isu_uuid`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

/initialize では以下のようなクエリで初期化し、

INSERT INTO last_condition SELECT
    a.jia_isu_uuid AS jia_isu_uuid,
    (SELECT isu_condition.timestamp FROM isu_condition WHERE isu_condition.jia_isu_uuid = a.jia_isu_uuid ORDER BY isu_condition.timestamp DESC LIMIT 1) AS timestamp,
    (SELECT isu_condition.is_sitting FROM isu_condition WHERE isu_condition.jia_isu_uuid = a.jia_isu_uuid ORDER BY isu_condition.timestamp DESC LIMIT 1) AS is_sitting,
    (SELECT isu_condition.condition FROM isu_condition WHERE isu_condition.jia_isu_uuid = a.jia_isu_uuid ORDER BY isu_condition.timestamp DESC LIMIT 1) AS `condition`,
    (SELECT isu_condition.message FROM isu_condition WHERE isu_condition.jia_isu_uuid = a.jia_isu_uuid ORDER BY isu_condition.timestamp DESC LIMIT 1) AS message
FROM isu_condition a GROUP BY a.jia_isu_uuid;

POST /api/condition/* では以下のようなクエリで最新の isu_condition を保存することで

INSERT INTO last_condition (jia_isu_uuid, timestamp, is_sitting, `condition`, message) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE
    is_sitting=IF(timestamp<VALUES(timestamp), VALUES(is_sitting), is_sitting),
    `condition`=IF(timestamp<VALUES(timestamp), VALUES(`condition`), `condition`),
    message=IF(timestamp<VALUES(timestamp), VALUES(message), message),
    timestamp=IF(timestamp<VALUES(timestamp), VALUES(timestamp), timestamp)

GET /api/isu では以下のようなクエリを使えるようになりました。

SELECT
    isu.id AS id,
    isu.character AS `character`,
    isu.jia_isu_uuid AS jia_isu_uuid,
    isu.name AS name,
    last_condition.timestamp AS timestamp,
    last_condition.is_sitting  AS is_sitting,
    last_condition.condition AS `condition`,
    last_condition.message AS message
FROM isu LEFT OUTER JOIN last_condition ON isu.jia_isu_uuid = last_condition.jia_isu_uuid
WHERE isu.jia_user_id = ? ORDER BY isu.id DESC;

この改善によりスコアが 18000 程度になりました。

私が知らなかった SQL の仕様として、ON DUPLICATE KEY UPDATE に書いた代入リストは前から順に適用されるということがあります。以下のような順番で書くと3行目の is_sitting=... 以降を実行する際にはすでに timestamp=... による代入が行われているため、意図しない結果となります。

ON DUPLICATE KEY UPDATE
    timestamp=IF(timestamp<VALUES(timestamp), VALUES(timestamp), timestamp),
    is_sitting=IF(timestamp<VALUES(timestamp), VALUES(is_sitting), is_sitting),
    `condition`=IF(timestamp<VALUES(timestamp), VALUES(`condition`), `condition`),
    message=IF(timestamp<VALUES(timestamp), VALUES(message), message)

GET /api/trend の N+1 を改善(担当 mayoko_)

GET /api/isu の改善と並行して /api/trend の改善も進めました。last_condition が用意されていれば以下のようなクエリで N+1 を解消することができ、レスポンスの組み立てをアプリケーション側で処理することができます。

SELECT
    isu.id AS id,
    isu.character AS `character`,
    last_condition.condition AS `condition`,
    last_condition.timestamp AS timestamp
FROM isu
INNER JOIN last_condition
ON isu.jia_isu_uuid = last_condition.jia_isu_uuid
WHERE isu.character IS NOT NULL

この改善によりスコアが 35000 程度になりました。

この改善ではベンチマーカーの採点基準に起因すると思われる不思議な現象に悩まされました。当初の実装ではレスポンスに含まれるデータの順序を考慮しておらず減点が発生していました。順序を考慮するような実装に修正して再度負荷走行を行ったところ「サービスの評判が上がりユーザーが増加しました」という旨のメッセージともに負荷レベルが上昇しました。これによりこれまで問題となっていなかったエンドポイントで1秒以上の時間がかかるようになってしまいタイムアウトが頻発し、ほとんどスコアが出なくなってしまいました。おそらく正攻法ではないのではないかと考えています。

分散構成に移行(担当 @brook_bach)

当初は以下のような構成を想定していました。

  • 192.168.0.11 は nginx, MySQL の処理と画像ファイルに関連するエンドポイントを処理
  • 192.168.0.12, 192.168.0.13 はその他のエンドポイントを処理

しかし謎のエラーが出てしまい、時間も迫っていたので以下のような構成で妥協しました。

  • 192.168.0.11 は MySQL
  • 192.168.0.12 は target_base_url の処理
  • 192.168.0.13 でその他すべてのリクエストを受け付け

結果発表後に調査したところ、エラーの原因は非常に単純で location /api/isu { } としなければならないところを location /api/isu/ { } と余計なスラッシュを付けてしまっていたことでした。

負荷走行中に htop を眺めていると 192.168.0.13 の CPU 使用率が100%だったためこの間違いがなければスコアの改善が見込めたと考えられますが、192.168.0.11 と 192.168.0.12 の CPU 使用率もそれなりに高かったため、たかだか 1.x 倍程度の改善だったと考えられます。

そのほかカーネルのチューニングやログの無効化などにより最終スコアが得られました。

感想

  • 毎度のことですが、作問者の意図した「この課題特有のおもしろいところ」まではたどり着けていないのは悔しいです。一方、以前と比べて理詰めで改善が行えている点は進歩と言えそうです
  • Rust を使ったことに関しては今回の課題はアプリケーション側の負荷が高めだったため、速度面での恩恵が受けられたと言えそうです。私にとってはあまりスムーズに開発できたとはいえない状況でしたが、それは本を買って積んでいた私がわるいのです

運営さんありがとう

今回の運営はこれまでと比べて格段にルールやレギュレーションの示し方が明確になっており、clar の受付や選手向けページの障害発生時の対応などについても違和感を感じることがなく、高いレベルで運営されていると感じました。とても有意義な時間になりました。ありがとうございました。