ISUCON9予選参加記 (5位通過)

mapk0y (インフラ), makki_d (アプリ) とともに「ようするにメガネが大好きです」というチーム名で参加し、2日間を通して5位のスコアで通過しました。

選択言語は Go です。ソースコードはこちらで公開しています。 GitHub - methane/isu9q

POST /buy 初期対策

CPUプロファイルを見ると login の bcrypt が重いのはすぐに目に付きます。過去にもあったのでsha1など軽いハッシュへの置き換えも考えましたが、面白くないのでこれはしなくても攻略できるようになっているはずだと出題者を信じて無視します。 最終的に login は残りの2台に分散するだけで一切手を加えずに乗り切りました。

アクセスログを見ると明らかに POST /buy が重く、 campaign を増やしたときのマッコネ (MySQL の Too many connections) やベンチマークタイムアウトエラーの主因にもなっていました。とりあえず最初はシンプルにGoの段階で排他してやります。

+var itemMutex sync.Mutex
+
+func itemLock() func() {
+   itemMutex.Lock()
+   return itemMutex.Unlock
+}
+
 func postBuy(w http.ResponseWriter, r *http.Request) {

...


+   defer itemLock()()
+
    tx := dbx.MustBegin()
 
    targetItem := Item{}
    err = tx.Get(&targetItem, "SELECT * FROM `items` WHERE `id` = ? FOR UPDATE", rb.ItemID)

外部APIエラーとの戦い

しかし外部APIサーバーが 502 Bad Gateway を大量に返すようになってしまって fail が続きます。

f:id:methane:20190910094451p:plain
502-bad-gateway

答えられないということは、障害ではなくて仕様なのでしょう。(追記: 障害だったようです。なので私達のチームはこのエラーのために多くの時間を浪費することに。) ここからAPIサーバーとの戦いが始まります。

マニュアルを読んでもレートリミットやバッチ化呼び出しなどの仕様はなく、 tcpdump を使って確認してもヘッダは削られまくっててやはりレートリミットに関するヘッダなどのヒントはありません。

この API は購入と配送に関するもので、1取引あたりの最低呼び出し回数は決まってます。とりあえず GET /transaction のなかからの呼び出しを削除してその時点の暫定1位はゲットしたものの、その後はずっとAPIの呼び出し方を工夫していました。

mapk0y が chocon という API 呼び出し用の proxy サーバーを入れてくれ、 makki_d がその対応や分散などをしてくれました。

呼び出しの並列度を下げても、すこし間隔を入れてもエラーが出ます。 Bad Gateway 以外に bad IP もあるみたいなので、API呼び出しを3台から分散して行うようにしてみたのですが効果は微妙です。

マニュアルにベンチマーク中以外に使える開発用APIサーバーが載っていたのでそこに curl でアクセスしてみて、使い方がわかったところでそれを雑に while : で回したらエラーが出ないことに気づきました。それで chocon の keep alive を切ってもらったのですが、残念ながらエラーはまだ出たので最終的に makki_d にリトライを入れてもらってゴリ押ししました。

結局502エラーの数が安定しないまま、どの対策がどれくらい効果があるのかを確認できないままでした。ベンチを回すばかりでなく、もうちょっと直接APIサーバーを色んな方法でアクセスして bad gateway や bad ip エラーの発生条件を調べられなかったのが反省点です。(ブラックボックスリバースエンジニアリング、好きな人は大好きだけど、そうじゃない人は逃げがち)

その他のチューニング

上で紹介した雑なロックは次のように書き換えて別々の商品は並行して買えるようにしました。

var itemMutex []sync.Mutex = make([]sync.Mutex, 100)

func itemLock(i int) func() {
    n := i % 100
    itemMutex[n].Lock()
    return itemMutex[n].Unlock
}
...
    defer itemLock(int(rb.ItemID))()

これでも /buy のタイムアウトが発生します。商品が売れたあとに、API呼び出しを含む長いロックを待ってた人が1人ずつ売れてしまったことを確認するようになっていたので、売れた商品をキャッシュするようにするとタイムアウトが解決しました。

そもそも売上がスコアに直結するのに同じ商品に客が集まり過ぎなので、これをバラせないかなと思って新着商品の順番をページ内でシャッフルしてみたところベンチマーカーに怒られたので諦めてしまいました。あとから考えるとここがスコアに直結するので諦めたのは早計で、 /buy でロック中の商品(APIエラーで売れない可能性がある) を sold 扱いで表示するとか、隠すとか、もうちょっと粘るべきだったと思います。

この辺は実際のアプリ開発なら自分から提案できるんですが、あくまでもブラックボックスのベンチマーカーがルールのISUCONでは提案ができないので、ベンチマーカーが何を許すかの試行錯誤を面倒がってしまいます。。。チューニングがしたいんであってブラックボックスリバースエンジニアリングがしたいんじゃないんですよね。。。

他には、 items テーブルの seller_id, buyer_id, created_at カラムにインデックスを張ったり、設定をいじったりしていました。アプリ側で一番大きい変更 (user の N+1 解決) は makki_d がやってくれました。

感想

去年の予選は上位スコアを出しつつも重いAPIの対策をおろそかにしていたために終盤スコアが乱高下し結果敗退しました。

その反省から、今年は12時台に 502 エラーが大量発生したあとは、頑張ってチューニングしてエラーで fail するという結末を避けるために穏当なチューニングだけで手堅く勧め、エラー率の低い(スコアが安定する)状態で勝ち進むことができました。

一方でAPIエラーの発生条件の解析や、商品リストをどう改造するとベンチマーカーがエラーを出すかの解析など、役割分担的に僕がするべきだったところの解析がちゃんとできていなかったのが悔やまれます。これを突破しないと実力での優勝はできないので、決勝では面倒臭がらずもっと覚悟と気合を入れていきます。

設問とインフラは良かったと思います。特に Go の初期実装が (Sinatra風の無名関数を直接ハンドラに登録するやりかたでなく) ちゃんとAPIごとに名前のある関数を用意しているのでスタックトレースやプロファイルも読みやすかったですし、ベンチマーク待ちも(17:20以降は完全に手を触れなかったのもあり) ほぼ感じませんでした。

一日目の Twitter を見てトラブルを覚悟していたのですが、初回起動もすぐ終わり、インスタンス作り直しが必要なミスもしなかったのでとても快適に競技に集中することができました。ありがとうございます。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。