ISUCON 10 予選を nodejs 実装で突破しました(へしこず)
「へしこず」の rch850 です。ISUCON 10 の予選を nodejs 実装で通過しました。奇跡的な予選通過の ISUCON 5 以来、2度目の本選進出です。やったぜ!チームメイトは machosita と emittam です。いつもありがとうございます。
個人的には nodejs で予選を突破できたのがうれしいです。 nodejs への思いは最後に。
macoshita の予選レポートが実況風味なので、こちらは箇条書きにします。実況風味はまた時間があるときに。
なおリポジトリはこちらです。
スコア推移はこう。
スコア推移。例年と違って早すぎる最適化を避けた結果、こんな推移になった #ISUCON pic.twitter.com/TSHmMosU7E
— りちゃ🏠🌈 (@rch850) 2020年9月14日
やったこと
サーバ構成
シンプルにこうしました。DB 分割したチームが結構いたようですが、そこまで考えてませんでした。しなくても勝てたし、して勝てたかは分かりません。
効果が大きかったかもしれないもの
- bot 対策 (nginx側, nodejs 側)
- /api/estate/low_price と /api/chair/low_price をキャッシュ (commit)
- どのベンチマーカーも最初にアクセスしてくる。そして更新頻度がとても低い
- 物件は入稿時のみ、椅子は入稿時と在庫が切れた時にキャッシュを破棄
- あとから Redis に乗せた (commit)
- nazotte をワンクエリにして spatial index 追加 (commit)
効果がそこまで大きくなかったかもしれないもの
- /api/estate/req_doc/:id で id 29500 以下なら即 OK、それ以外は Redis に id をキャッシュしてその後のアクセスはキャッシュを見て OK (commit)
- /estats/:id のキャッシュ (commit)
- nazotte に limit 指定が無かったので追加 (commit)
- これに気づいたのが残り10分切ってから。今からこれやる!?って10秒ほど相談して投入した。これが最後のコミット
- rent, (stock, price) のインデックス追加 (commit)
- (door_width, door_height), door_height, popularity のインデックス追加 (commit)
- (door_width, door_height, rent), (kind, stock, price) のインデックス追加 (commit)
- search 系のカウントと検索を
Promise.all
で並列化 (commit) - 不必要な
SELECT *
の改善
やろうとしたけどできなかったこと
自分がやってたとこ。悔しいので晒します。
- recommended の OR を減らす
- 短い2辺 (len1, len2 とする) が通ればいいから
(door_width >= len1 AND door_height>= len2) OR (door_width >= len2 AND door_height>= len1)
だけでいいよね? const [len1, len2] = [chair.width, chair.height, chair.depth].sort()
すればいいんだな。はいコミット (commit)- あれ?検証エラー?
- ソート順間違えたかーー???えいやー
const [_, len1, len2] = [chair.width, chair.height, chair.depth].sort()
(パニック状態) (commit) - また検証エラー??????完全に正解してるでしょ????
- MDN 見たら sort は「デフォルトではUnicodeコードポイントの昇順にソート」って書いてある!
parseInt
したら通るやろ! (commit)[1, 10, 4].sort()
は[1, 4, 10]
になると思い込んでたけど、実際は[1, 10, 4]
[1, 10, 4].sort((a, b) => a - b)
なら[1, 4, 10]
になる
- 通らない……もう限界。revert します
ラスト数分でこうすればいいことに気づくが、もうコードを変える時間は残っておらず終了
// ここまでのコード const [_, len1, len2] = [parseInt(chair.width), parseInt(chair.height), parseInt(chair.depth)].sort((a, b) => a - b); // 多分これが正解 const [len1, len2] = [parseInt(chair.width), parseInt(chair.height), parseInt(chair.depth)].sort((a, b) => a - b);
- 短い2辺 (len1, len2 とする) が通ればいいから
どういう方向性で動いたか
今回はこれまでの ISUCON の動き方からちょっと変えてみました。
- 落ち着いて取り組むようにした(というのは方針で抽象的なので、以降具体的な話)
- 自信がある nodejs を選んだことで落ち着けたというのはあると思う
- アクセスログからベンチマーカーの動きを見て、どうやって得点を稼げるか考えた(詳細は次のセクションに)
- ウェブサービスを自分で触って挙動を理解するようにした
- これも上と関係した話ですね
- Redis を使うようにしたのが19時過ぎ(残り2時間弱)だったり、API サーバを増やしたのが20時過ぎ(残り1時間切ってる)だったりと、普段は早めにやっていた作業を、かなり後回しにした
- 「早すぎる最適化」を懸念したり、ぬか喜びしたくないなという考えから、意図的に後回しにしました。結果にどう影響したかは分からないけど、たぶん良かったんでしょう
ベンチマーカーの行動パータン
nginx のアクセスログから調べました。User-Agent に UUID らしきものがついていたので、それで絞り込んでパターンを分類しました。
3パターンの行動が確認できました。他にもあったかもしれませんが、ひとまずこの3つで。末尾の数字はその時点での req_time です。これを見て、あるパターンのユーザーが得点に至るまでの合計時間を意識するようにしました。low_price は全パターンに効いてくるとか、search は回数が多いから大事とか。
ユーザーパターン 1(条件検索して資料請求)
"GET /api/estate/low_priced HTTP/1.1" 0.003 "GET /api/chair/low_priced HTTP/1.1" 0.071 "GET /api/estate/search/condition HTTP/1.1" 0.001 "GET /api/estate/search?page=0&perPage=25&rentRangeId=2 HTTP/1.1" 0.079 "GET /api/estate/search?page=3&perPage=25&rentRangeId=2 HTTP/1.1" 0.114 "GET /api/estate/search?page=3&perPage=25&rentRangeId=2 HTTP/1.1" 0.118 "GET /api/estate/search?doorWidthRangeId=1&page=0&perPage=25 HTTP/1.1" 0.168 "GET /api/estate/search?doorWidthRangeId=1&page=4&perPage=25 HTTP/1.1" 0.185 "GET /api/estate/search?doorWidthRangeId=1&page=3&perPage=25 HTTP/1.1" 0.180 "GET /api/estate/27131 HTTP/1.1" 0.002 "GET /api/estate/24611 HTTP/1.1" 0.001 "POST /api/estate/req_doc/24611 HTTP/1.1" 0.002
ユーザーパターン 2(なぞって資料請求)
"GET /api/estate/low_priced HTTP/1.1" 0.010 "GET /api/chair/low_priced HTTP/1.1" 0.176 "POST /api/estate/nazotte HTTP/1.1" 2.000 "GET /api/estate/low_priced HTTP/1.1" 0.016 "GET /api/chair/low_priced HTTP/1.1" 0.095 "POST /api/estate/nazotte HTTP/1.1" 0.223 "GET /api/estate/16302 HTTP/1.1" 0.005 "POST /api/estate/req_doc/16302 HTTP/1.1" 0.002
ユーザーパターン 3 (条件検索して購入)
"GET /api/estate/low_priced HTTP/1.1" "GET /api/chair/low_priced HTTP/1.1" "GET /api/chair/search/condition HTTP/1.1" "GET /api/chair/search?depthRangeId=1&page=0&perPage=25 HTTP/1.1" "GET /api/chair/search?depthRangeId=1&page=2&perPage=25 HTTP/1.1" "GET /api/chair/search?depthRangeId=1&page=0&perPage=25 HTTP/1.1" "GET /api/chair/search?color=%E3%83%8D%E3%82%A4%E3%83%93%E3%83%BC&page=0&perPage=25 HTTP/1.1" "GET /api/chair/search?color=%E3%83%8D%E3%82%A4%E3%83%93%E3%83%BC&page=3&perPage=25 HTTP/1.1" "GET /api/chair/search?color=%E3%83%8D%E3%82%A4%E3%83%93%E3%83%BC&page=2&perPage=25 HTTP/1.1" "GET /api/chair/2032 HTTP/1.1" "GET /api/recommended_estate/2032 HTTP/1.1" "GET /api/chair/19330 HTTP/1.1" "GET /api/recommended_estate/19330 HTTP/1.1" "POST /api/chair/buy/19330 HTTP/1.1"
また、これらのパターンに加えて、資料請求とイス購入のどちらの得点が多いかも調べました。序盤は 5:1 で資料請求が多め。後半はさらに顕著になって 10:1 ぐらいになっていました。これをもとに、資料請求大事という方向性が見えました。
なぜ nodejs 実装にしたのか?
これは予選突破した今だから言えることだし、とてもとてもおこがましい話なのですが、「Go じゃないと ISUCON は戦えない」というハードルができてしまうのが嫌でした。これは自分たちが ISUCON 9 を迎える時に感じていたハードルでした。結果、Go を選んでも勝てなかった(そもそも書けないんだけど)。Go でもダメなら諦めないとダメかな。そう思った時に、慣れた nodejs 実装のことを考えました。
そこで、これまでの ISUCON を振り返りました。
初めて ISUCON に参加した ISUCON 5 から一昨年の ISUCON 8 まで、ずっと Ruby 実装で挑戦していました。3人とも Ruby ばっかり書いているというタイプではなかったのですが、それなりに書けてました。
ISUCON 5 では奇跡的に予選突破できたのですが、そのあと Ruby で書き続けても、予選突破できない年が続きました。ISUCON 9 まで来て、さすがに Golang にしないと勝てないかな、と思って Golang に切り替えたのですが、まぁ書けない書けない。それなりに予習したつもりでしたが、それでも競技時間中に「これってどうやって書くんだっけ」と調べることが多かったのが反省です。
そして ISUCON 10。正直、一番書けるのは nodejs 実装だというのは確信してました。でもそれで勝てるのか?というのが不安でした。実際、nodejs で予選突破したチームは非常に少ないです。
これで大丈夫なんだろうか……
そんな心配をしながら、昨年予選の nodejs 実装を開いてみると、なんと TypeScript 実装。これは捗る!しかし手元でベンチマークしてみると Go や Ruby の半分程度のスコア。これは厳しいか……と思ったのですが cluster で fork したらあっけなく他言語に並びました。これは行ける!!!!
そう考えて今年の ISUCON 10 に挑み、なんとか予選突破できました。
これで「nodejs でも予選突破できるんやで」って胸を張って言える……そう思っていました。
Nodejs 2組 6.5%
他にもいたんだね。どこのチームだったのかな?
ponyopoppoが普段から使っているNode.jsです。
( ゚д゚)……………
「nodejs で予選1位通過しました」に勝てるわけないやーん。
ま、それを証明するのは自分じゃなくてもいいよね。nodejs でも ISUCON 戦えるぞ!立ち上がれ!nodejs の民よ!来年の ISUCON で待ってるぞ!