出題担当の @methane です。今年の予選問題について解説します。

問題の公開

予選問題のベンチマーカーと参照実装のコードと、Ubuntu 16.04 上に予選問題を動くようにするための手順を公開します。感想戦にご利用ください。
予選問題のリポジトリ

複数台構成について

今年のISUCON予選では、予選としては初めて複数台構成を利用してみました。

倍率が高くなった現代のISUCONにおいては、多くの参加者にとって予選こそがISUCONになるということを念頭に、ISUCONの醍醐味で予選でまだやってないのはなんだろうと考えたときに思いついたのが複数台構成でした。

また、1台あたりの性能を厳しく制限することで、1プロセスで簡単にマルチコアを活かせるGoが強くなりすぎないようにするという考えもありました。サーバー1台あたりのCPUは1コアしかないので、Goでも他の言語でも複数コア数を使いたければ複数サーバーを使うしかありません。メモリも1GBしかないので、1台で捌く状態でチューニングが進むと画像のアップロードなどで苦戦する可能性が高いです。(実際に1台オンメモリ戦略を実装してみて苦戦しました。)

最近の予選では本戦での実力以上にGoを使ったチームがトップ争いをすることが多い印象がありましたが、今年の予選では(アンケート結果を見ないとわからないものの)そこまでGoが強い印象もなく、本戦に近い雰囲気の問題にできたのではないかと思います。

残念ながら、各日200以上のチーム数×3台=約600台のサーバーに、ベンチマーカーを加えた大量のサーバー群の構築を甘く見ていたために、大きなトラブルの要因になってしまいました。特に土曜日の参加者に迷惑をかけてしまったのと、インフラ構築をしてくれたメンバーに連日の徹夜をしてもらう事になったのは、もとを辿ればこの思いつきが原因です。ごめんなさい。

来年以降の出題者へのアドバイスとして、予選は絶対に参加者自身にサーバーを起動してもらう去年までの形式が良いと思います。

アプリケーションについて

今年の予選はチャットアプリケーションでした。特別な意味があったわけではなく、単にN+1や画像アップロード、簡単なリアルタイム要素など、程よくISUCONの過去問のエッセンスを配合しやすかったのでこのお題になりました。

静的ファイルと画像ファイルのキャッシュについて

私の想定では静的ファイルや画像はCDN経由のアクセスだから
Cache-Control: public, max-age=3600
をつければ自力で配信する必要はない、という問題にするつもりでした。

ベンチマーカーは(エラーが起こらない限り)毎秒一定のペースでユーザーを増やし、各ユーザーがプロフィール画像を登録していくのですが、キャッシュが無い、あるいはユーザー単位のキャッシュではユーザー数に比例したダウンロード帯域が必要になってしまい、後半すぐに帯域がサチってしまいます。

ユーザー数が増えていってもダウンロード帯域をサチらせないためには、CDNによるキャッシュが効いているという前提にしてユーザー数が増えても画像一つあたりのダウンロード数が増えない設計が必須でした。

当初の予定では単に
Cache-Control: public, max-age=3600
をつければいいので難しくないはずだったのですが、予選直前に
max-age
をつけなかったときの 304 によるスコアが想定より大きいことに気づいて、「
max-age
をつけないほうがスコアが簡単に上がるのはおかしい」と
max-age
がついていたらダウンロードをスキップする部分を削ってしまいました。

このときに複数台構成で正しく304を返す難しさに思い至らなかったために、意図せずこの部分で多くのチームをハメてしまうことになってしまいました。

GET /fetch と GET /message

エラーやタイムアウトがない場合、毎秒新規ユーザーが登録してきます。メッセージの投稿数はユーザー数に比例するだけですが、メッセージの受信数はユーザーの2乗に比例します。ボトルネックを潰してメッセージの流量を上げていけば気持ちよくどんどんスコアが上がっていくという設計にするつもりでした。

特に、
GET /fetch
はスコアがなく、
GET /message
には受信メッセージ数にも加算されるというところがポイントでした。

クライアントは
GET /fetch
をポーリングしていて、閲覧中のチャンネルに新着メッセージがある場合には
GET /message
を呼んで新着メッセージを受信します。
GET /fetch
のレスポンスをどんなに改善しても、新着メッセージがなければスコアは上がりません。閲覧中のチャンネルに新着メッセージが1件ある状態ですぐに返しても、
GET /message
で1点とそこに含まれる1メッセージ分の1点で、2リクエストで2点しか稼ぐことができません。

しかし
GET /fetch
をタイムアウトにならない範囲で遅くしてやると、
GET /message
は1リクエストで数十件のメッセージを取得することができるので2リクエストで数十点を稼ぐ事ができます。

このようにリアルタイム性のあるアプリケーションでは、要求される時間内でイベントをバッチ化することでスループットが上がっても比例して負荷が上がらないように設計できることがあります。

GET /fetch
GET /message
は N+1 クエリや COUNT クエリなど非常に重い処理になっていたのでどのチームもこの処理を軽くすることに力を入れたと思いますが、単に処理を軽くするだけでなく一見無駄に見える参照実装の sleep の意味に気づくとブレイクスルーすることができたと思います。

最後に

今回の予選は甘い想定や調整・検証不足のため、参加者の皆様に気持ちよく楽しんでもらうことができず、申し訳ありませんでした。

本選までもうあまり時間がありませんが、参加者が余計な部分で苦しむことのないように最善を尽くします。本選もよろしくお願いします。