LoginSignup
13
2

More than 3 years have passed since last update.

isucon10で爆死したログ

Last updated at Posted at 2020-09-13

この記事は初心者がisucon10に参加して無事爆死したレポートです。

基本的に時系列順に何を思い、何をして、どうなったのかを覚えている限り書きます。
スコアはあまりちゃんと記録してなかったので適当です。

なお、内容は僕の主観であり、チームメイトの行動などについては脚色が含まれている可能性があります。

プロローグ

ゲーム仲間とネトゲしていたとき「よく知らないけどisuconってのがあるらしいぞ。出ようぜ!」という話が出てきて、ちょうどその場にいた3人で参加することにしました。
僕は当時isuconという文字列を見たことはある気がするなぁという程度だったので、何をするイベントなのかよくわからぬままとりあえず参加登録しました。

image.png

ざっくりしたチーム構成としては
- coil: インフラわかる。goわかる。isucon知ってる
- kyasbal: いい感じに整えるのがうまい。なんでもできる。isucon知ってる
- moajo(僕): なにもできない。「isuconってなんですか?椅子ですか?」

という感じでした。

当日まで

チームメイトによると「練習が必要」とのことだったので、過去問をやってみようという事になりました。
しかし、過去問環境を構築するのがわりと面倒で、僕は宗教上の理由で docker-compose up より複雑なコマンドが打てないので諦めました。

僕がゲームしてる間にkyasbalが過去問環境(isucon8だった気がする)を作ってくれたのですが、僕はアプリの実行環境を確認して満足し、ゲームに戻りました。

結局、当日までに

  • goを使う
  • 初手でコードをgitに突っ込む

という合意までを形成していました。

また、大まかな役割分担と手順、参考情報などをまとめたhackmdを作成して全員で共有しました。

当日: 本番前

当日は公式discordチャンネルが盛り上がりを見せる中、直前になって「やべーなんも準備してねぇ・・・」という気持ちになって辛かったです。
もともと10時開始予定だった(実際には12時過ぎに順延された)ので、7時に起きて軽く散歩、普段取らない朝食、魔剤の買い出し、部屋の掃除などを行いました。
部屋がきれいになると「結構準備できたな・・・」という気持ちになってきたのでかなり有効だったと思います。

このあたりで開始時間の繰り下げがアナウンスされました。
これはチャンスと気付き、落ち着いて今できることを考えると「そういえばgoの開発環境できてないじゃん」ということを思い出しました。あぶない。

  • vscodeにgoのプラグインを入れる
  • vscodeがちゃんと動くか不安だったので念の為GoLandを入れる

結果的にGoLandはマウスホバーでVSCodeより詳細な情報を出してくれたりしたので、多少は役に立ちました。
また、本番開始前からdiscordのボイスチャットで常時コミュニケーションを取れるようにしていました。いつもゲームしてるときと同じです。

魔剤は30分くらいで効き出すらしいので、本番15分前くらいから摂取し始めました。

本番

開始直後にportalが過負荷?で不安定になり、運営様がポータルをいい感じにスピードアップする作業に入ったようでした。discordで応援しました。

しばらくするとssh先のipと.ssh/configのサンプルが得られたのでアクセス可能になりました。
手元の.ssh/configには Host * の設定を雑に書いていたので修正が必要で、数分消費しました。

アクセス確立後

全員アクセスできることを確認したので、事前の分担通りに各自行動します。
coilはDB、リバースプロキシ、OSの設定等を確認しモニタリング体制を整備。
kyasbalはstackdriver traceを仕込みます。
その間に僕は事前に作成していたgithub上のprivateリポジトリにコードを保全し、デプロイスクリプトを実装します。

sshすると isuconユーザのホームディレクトリに繋がりますが、 ~/isuumo/webapp 以下が本体っぽかったのでこのディレクトリで以下の操作を行いました

git init
git add -A
git commit -m 'init'
git remote add origin https://...
git push origin master

手元にclone済みのリポジトリでpullして作業完了です。
デプロイスクリプトを仕込みますが、この段階ではプロセス起動方法が謎だったので、とりあえずgoのファイルを全部コピーするスクリプトを書きました

deply.sh
#!/bin/bash

scp -r ./go isucon-server:/home/isucon/isuumo/webapp

方針としては各人ローカルで開発しコードだけ転送してデプロイという方式です。他にも色々戦略候補はありましたが、

  • vscode remote developmentでサーバ上で編集: サーバに負荷がかかる可能性があるので却下
  • サーバ上でvim等で編集: 同上。また、サーバ間でのファイルの整合性が維持できない問題もあるので却下
  • github actions等のCI: タイムラグがある。手動なら高速に交互デプロイが可能なので却下

などの理由でこの方式に落ち着きました。
この方式は古いファイルが消しきれず残ってしまう問題はありますが、それも直前にディレクトリごと消すなどいくらでも柔軟に対応できます。
柔軟性、汎用性からも、この方法はかなり費用対効果が高く、次回も採用したい戦略です。

開発・デプロイ環境確立後

僕のここまでの分担はチーム内で一番早く終わることがわかっていたので、予定通り僕が先行して予選マニュアルを熟読しはじめました。
細かい制約やスコア計算を理解し、hackmdに要点をまとめます。

想定外な項目はほぼありませんでしたが、唯一 botからのリクエストを弾く機能を追加で実装せよ という記述が目立ちます
明らかにスコアに直結しそうな雰囲気を感じたので、hackmdに最優先事項として追記しておき、アプリケーションコードのリーディングに取り掛かります。

事前に決めていた具体的な手順書はここまでで、ここから先は大まかに以下のような分担ですすめることになっています。
- coil: インフラ設定、DB設定、必要に応じてDBの分割等
- kyasbal: アプリの監視。主にredisなどオンメモリキャッシュでのアプリケーション最適化。
- moajo: アプリケーションロジックの最適化。SQLの最適化。

コードリーディング

コードを読みつつwebブラウザからサービスにアクセスして全体像を把握します。

webブラウザから見る限り、この段階では以下のようなサービスだと理解しました。
- 椅子と物件それぞれの検索ができる
- 最安物件を取得できる
- 物件、椅子の詳細を表示できる
- 地図をなぞり、その範囲内にある物件を取得できる(なぞって機能)

想定よりシンプルなサービスに見えたので安心しましたが、明らかに「なぞって機能」だけ作り込まれている雰囲気を感じたので、このあたりが最適化対象なのかなと目星を付けました。

この段階ではまだstackdriver traceが動いておらず、ベンチマーカも障害で動かない状態になっていたので、最適化の大前提である「計測」が達成できていません。
そのため、コードをひたすら読み込んで怪しいところを探します。
とりあえず「なぞって機能」のコードを読むと以下のようなロジックになっていました

擬似コードのつもり
1 なぞった線を受け取る
2 BoundingBox := なぞった線から計算
3 estatesInBoundingBox := (DBアクセスして検索したBoundingBox内の物件)
4 for estate in estatesInBoundingBox
5     SQLの `ST_Contains` で厳密な内外判定

明らかに典型的なn+1クエリで、しかも見るからに重そうな処理をしています。遅いことを確信しました。
ST_Containsは知らなかったのですがDBでこんなことまでできるんだなと感動。

早速最適化に着手します
まずn+1クエリに対して考えられる戦略としては、クエリをまとめることで1回のクエリにまとめることが浮かびます。
しかし、このクエリではそもそもDBへのアクセスではなく、 ST_Contains関数を呼び出すための計算モジュールとして使用しているので、そもそもアプリケーション側で計算する方が筋が良いと判断しました。

どうやって計算しようか考えながらブラウザでなぞって機能を触っていると、なぞった線は 凸包に補正された上で使用されている ことに気付きました。
凸包なら計算は簡単で、ポリゴン(なぞった線)の各辺とその始点から対象点までの外積を見れば簡単に判定できます

nazotte phase 1

main.go
estatesInPolygon := []Estate{}
for _, estate := range estatesInBoundingBox {
    contained := true
    for i, coordinate := range coordinates.Coordinates {
        nextI := i + 1
        if i == len(coordinates.Coordinates)-1 {
            nextI = 0
        }
        nextCoordinate := coordinates.Coordinates[nextI]

        v1x := nextCoordinate.Latitude - coordinate.Latitude
        v1y := nextCoordinate.Longitude - coordinate.Longitude

        v2x := estate.Latitude - coordinate.Latitude
        v2y := estate.Longitude - coordinate.Longitude

        cross := v1x*v2y - v1y*v2x
        if cross > 0 {
            contained = false
            break
        }
    }

    if contained {
        estatesInPolygon = append(estatesInPolygon, estate)
    }
}

この頃には誰かがデプロイスクリプトを改良してビルドとプロセスの再起動までやってくれるようになっていたので、 ./deply.sh するだけでデプロイが完結します。

デプロイしてwebブラウザで確認してみたところ動いてそうだったので、ベンチにかけたのですがエラーになりました。

この頃はポータル全体が不安定で、ベンチマーカもエラーっぽい表示にはなっているものの、エラー詳細は空白のままで何が起きているのかわからない状態でした。

上記の挙動についてポータルに質問を投げつつ、まぁ僕がバグってるのだろうと考えいくつかのパターンを試します

  • 境界線上を含むか含まないか
  • coordinates.Coordinatesが長さ3未満のケースへの対応

これでもベンチが通らないので実際投げられているポリゴン(分量多いからあまり見たくなかった)をちゃんと確認してみたところ、全く同じ点が連続しているケースがあったのでこれも対応しましたが、それでもまだ通らないのでベンチマーカの不具合の可能性も考えていったん放置し、先にbotのフィルタを実装に入ります。

botフィルタ phase 1

要件としては、以下の正規表現にマッチするUserAgentに無条件で503を返すようにすれば良いようです。

/ISUCONbot(-Mobile)?/
/ISUCONbot-Image\//
/Mediapartners-ISUCON/
/ISUCONCoffee/
/ISUCONFeedSeeker(Beta)?/
/crawler \(https:\/\/isucon\.invalid\/(support\/faq\/|help\/jp\/)/
/isubot/
/Isupider/
/Isupider(-image)?\+/
/(bot|crawler|spider)(?:[-_ .\/;@()]|$)/i

goの実装はechoというライブラリで、ルーティングしてるコードを読むとLoggerとかがmiddlewareとして実装されていました。
「ボットフィルタもカスタムミドルウェア書けばいいか」と安直に考え、ググって出てきたコードをコピペしてミドルウェアの枠を作ります。

ここでcoilが「goの正規表現は遅いから使うな」というアドバイスをくれました。
はてどうするかと思いつつ正規表現を眺めていると、特に複雑な条件は無さそうということに気づきます。
腕力で正規表現を文字列処理に書き下します。

main.go
func botFilter(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        req := c.Request()
        userAgent := req.UserAgent()
        fmt.Println(userAgent)
        if userAgent == "ISUCONbot" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "ISUCONbot-Mobile" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "ISUCONbot-Image/" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "Mediapartners-ISUCON" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "ISUCONCoffee" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "ISUCONFeedSeeker" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "ISUCONFeedSeekerBeta" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "crawler (https://isucon.invalid/support/faq/" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "crawler (https://isucon.invalid/help/jp/" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "isubot" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "Isupider" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "Isupider-image+" {
            return echo.NewHTTPError(503)
        }
        if userAgent == "Isupider+" {
            return echo.NewHTTPError(503)
        }
        lowerUserAgent := strings.ToLower(userAgent)

        if lowerUserAgent == "bot" ||
            strings.HasPrefix(lowerUserAgent, "bot-") ||
            strings.HasPrefix(lowerUserAgent, "bot_") ||
            strings.HasPrefix(lowerUserAgent, "bot ") ||
            strings.HasPrefix(lowerUserAgent, "bot.") ||
            strings.HasPrefix(lowerUserAgent, "bot/") ||
            strings.HasPrefix(lowerUserAgent, "bot;") ||
            strings.HasPrefix(lowerUserAgent, "bot@") ||
            strings.HasPrefix(lowerUserAgent, "bot(") ||
            strings.HasPrefix(lowerUserAgent, "bot)") {
            return echo.NewHTTPError(503)
        }
        if lowerUserAgent == "crawler" ||
            strings.HasPrefix(lowerUserAgent, "crawler-") ||
            strings.HasPrefix(lowerUserAgent, "crawler_") ||
            strings.HasPrefix(lowerUserAgent, "crawler ") ||
            strings.HasPrefix(lowerUserAgent, "crawler.") ||
            strings.HasPrefix(lowerUserAgent, "crawler/") ||
            strings.HasPrefix(lowerUserAgent, "crawler;") ||
            strings.HasPrefix(lowerUserAgent, "crawler@") ||
            strings.HasPrefix(lowerUserAgent, "crawler(") ||
            strings.HasPrefix(lowerUserAgent, "crawler)") {
            return echo.NewHTTPError(503)
        }
        if lowerUserAgent == "spider" ||
            strings.HasPrefix(lowerUserAgent, "spider-") ||
            strings.HasPrefix(lowerUserAgent, "spider_") ||
            strings.HasPrefix(lowerUserAgent, "spider ") ||
            strings.HasPrefix(lowerUserAgent, "spider.") ||
            strings.HasPrefix(lowerUserAgent, "spider/") ||
            strings.HasPrefix(lowerUserAgent, "spider;") ||
            strings.HasPrefix(lowerUserAgent, "spider@") ||
            strings.HasPrefix(lowerUserAgent, "spider(") ||
            strings.HasPrefix(lowerUserAgent, "spider)") {
            return echo.NewHTTPError(503)
        }
        if err := next(c); err != nil {
            c.Error(err)
        }
        return nil
    }
}

(この時点では「正規表現のマッチ」を完全一致のことだと思いこんでいます)
流石に文字列等価判定くらいいくらやってもノーコストやろというノリで実装しています。
普段絶対しないコードを堂々と書けたので、実装中は常に楽しくて笑顔でした。

後半のHasPrefixの部分は自明に最適化の余地がありますが、goの文字列処理がよくわからなかったのでとりあえずこの状態で試したところわずかにスコアが上昇したので満足しました。

nazotte phase 2

ポータルに投げていた質問に回答があり、はやり僕のロジックが整合性エラーで落ちていたことがわかりました。
本腰を入れてデバッグすることにします。

初期実装の遅いコードと自分の実装を両方動かし、一致しているかアサーションを出すようにしながら整合性チェックを受けました。
ログを見ると確かに一部のケースで不整合が起きています。

最初は小数計算の誤差や想定外に大きいか小さい値が原因かと思い、誤判定している部分のデータを眺めましたが特にそのようなことはなく、桁落ちも情報落ちも原因ではなさそうでした。

魔剤をついかしてしばらく瞑想するとひらめきが降ってきて、「ポリゴンが逆周りだったら外積逆向くじゃん」ということに気付きました。
よく考えたらそれはそうだよなぁという感じなので、自分の手抜き実装を恨みつつ、「外積の符号が全部同じだったら」という条件に書き直したらACしました。

main.go
estatesInPolygon := []Estate{}
for _, estate := range estatesInBoundingBox {
    contained := true
    flag := true
    if len(coordinates.Coordinates) < 3 {
        contained = false
    }

    a := 0.1
    first := true

    for i, coordinate := range coordinates.Coordinates {
        nextI := i + 1
        if i == len(coordinates.Coordinates)-1 {
            nextI = 0
        }
        nextCoordinate := coordinates.Coordinates[nextI]

        if coordinate.Latitude == nextCoordinate.Latitude && coordinate.Longitude == nextCoordinate.Longitude {
            continue
        }
        flag = false

        v1x := nextCoordinate.Latitude - coordinate.Latitude
        v1y := nextCoordinate.Longitude - coordinate.Longitude

        v2x := estate.Latitude - coordinate.Latitude
        v2y := estate.Longitude - coordinate.Longitude

        cross := v1x*v2y - v1y*v2x

        if first {
            first = false
            a = cross
            continue
        } else {
            if cross*a < 0 {
                c.Echo().Logger.Printf("qqqqqqqqqqqqq i: %v coord: %v next: %v est: %v %v v1: %v %v v2: %v %v", i, coordinate, nextCoordinate, estate.Latitude, estate.Longitude, v1x, v1y, v2x, v2y)
                contained = false
                break
            }
        }
    }
    if flag {
        contained = false
    }

    if contained {
        estatesInPolygon = append(estatesInPolygon, estate)
    }
}

歴史的経緯によりクソみたいなコードになってしまいましたが、二度と触らないのでOKです。
nazotte機能は包含判定をNazotteLimitで打ち切ったり、bboxクエリにインデックス貼ったり(もしかしたらunionにしたり?)する改善の余地があった気がしますが、stackdriver traceによるといったんここはボトルネックではなくなったようなので放置しました。

ちょっと前にBotFilter実は部分一致なのでは?という説が浮かんできていたのでこれも質問を投げていたのですが、はやり部分一致だったようなのでそちらの修正に取り掛かります

botフィルタ phase 2

crawler (https://isucon.invalid/support/faq/ とか完全一致なわけないよなぁとは思っていました
しかしそうであれば /ISUCONbot(-Mobile)?/(-Mobile)? の部分とか完全に無意味なので、完全一致なのかなと思ってしまいました。

部分一致であることは明らかになったので、筋力でコードを修正します

main.go
func botFilter(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        req := c.Request()
        userAgent := req.UserAgent()
        if strings.Contains(userAgent, "ISUCONbot") {
            return echo.NewHTTPError(503)
        }
        if strings.Contains(userAgent, "Mediapartners-ISUCON") {
            return echo.NewHTTPError(503)
        }
        if strings.Contains(userAgent, "ISUCONCoffee") {
            return echo.NewHTTPError(503)
        }
        if strings.Contains(userAgent, "ISUCONFeedSeeker") {
            return echo.NewHTTPError(503)
        }
        if strings.Contains(userAgent, "isubot") {
            return echo.NewHTTPError(503)
        }
        if strings.Contains(userAgent, "Isupider") {
            return echo.NewHTTPError(503)
        }
        lowerUserAgent := strings.ToLower(userAgent)
        if strings.HasSuffix(lowerUserAgent, "bot") || strings.HasSuffix(lowerUserAgent, "crawler") || strings.HasSuffix(lowerUserAgent, "spider") {
            return echo.NewHTTPError(503)
        }
        if strings.Contains(lowerUserAgent, "bot-") ||
            strings.Contains(lowerUserAgent, "bot_") ||
            strings.Contains(lowerUserAgent, "bot ") ||
            strings.Contains(lowerUserAgent, "bot.") ||
            strings.Contains(lowerUserAgent, "bot/") ||
            strings.Contains(lowerUserAgent, "bot;") ||
            strings.Contains(lowerUserAgent, "bot@") ||
            strings.Contains(lowerUserAgent, "bot(") ||
            strings.Contains(lowerUserAgent, "bot)") {
            return echo.NewHTTPError(503)
        }
        if strings.Contains(lowerUserAgent, "crawler-") ||
            strings.Contains(lowerUserAgent, "crawler_") ||
            strings.Contains(lowerUserAgent, "crawler ") ||
            strings.Contains(lowerUserAgent, "crawler.") ||
            strings.Contains(lowerUserAgent, "crawler/") ||
            strings.Contains(lowerUserAgent, "crawler;") ||
            strings.Contains(lowerUserAgent, "crawler@") ||
            strings.Contains(lowerUserAgent, "crawler(") ||
            strings.Contains(lowerUserAgent, "crawler)") {
            return echo.NewHTTPError(503)
        }
        if strings.Contains(lowerUserAgent, "spider-") ||
            strings.Contains(lowerUserAgent, "spider_") ||
            strings.Contains(lowerUserAgent, "spider ") ||
            strings.Contains(lowerUserAgent, "spider.") ||
            strings.Contains(lowerUserAgent, "spider/") ||
            strings.Contains(lowerUserAgent, "spider;") ||
            strings.Contains(lowerUserAgent, "spider@") ||
            strings.Contains(lowerUserAgent, "spider(") ||
            strings.Contains(lowerUserAgent, "spider)") {
            return echo.NewHTTPError(503)
        }
        if err := next(c); err != nil {
            c.Error(err)
        }
        return nil
    }
}

部分一致になったことでいくつかの条件が不要になってif文が減りました
我ながら頭の悪いコードだなぁと思いましたが、とりあえずデプロイしてみると結構スコアが上がりました。
むりやり書き下したので意図しないUserAgentも弾いてしまっていないか不安でしたが、その場合ベンチが落ちるはずなのでたぶん大丈夫だったはずです。

(終わってから知ったこととして、UA正規表現でレスポンス返すのはnginxのレイヤーで可能だったようです。
とはいえアプリサーバのCPU使用率には余裕があったので、さしたる差にはならなかった気がします。)

途中結果確認

この時点で18時を過ぎており、自分の貢献少ないなぁと思いながらも、いったんすべての修正をマージして今後の作戦会議をしました
ここまでで他に以下の改良が行われていました

coil: DBインデックス整備

もともとインデックス全く貼ってなかったので最優先だった
だいぶ効いた印象

ALTER TABLE isuumo.estate ADD KEY rent_idx (rent);
ALTER TABLE isuumo.estate ADD KEY id_idx (id);
ALTER TABLE isuumo.estate ADD KEY popularity_idx (popularity);
ALTER TABLE isuumo.estate ADD KEY door_height_and_door_width_idx (door_height, door_width);
ALTER TABLE isuumo.estate ADD KEY latitude_and_longitude_idx (latitude, longitude);

ALTER TABLE isuumo.chair ADD KEY stock_idx (stock);
ALTER TABLE isuumo.chair ADD KEY price_idx (price);
ALTER TABLE isuumo.chair ADD KEY popularity_idx (popularity);
ALTER TABLE isuumo.chair ADD KEY id_idx (id);

coil: DBシャーディング

main.go
db = mySQLConnectionData.ConnectDB()//椅子専用インスタンス
db2 = mySQLConnectionData2.ConnectDB()//家専用インスタンス

すごく効いた
このアプリケーションでは椅子と家のDBは完全に分離できる仕様だったので、空いてる2インスタンスに椅子と家それぞれのテーブルのみ持たせてDBをシャーディングした
これでDBのリソースはかなり無駄なく使えるようになった。
なお分割後でもDBのCPU使用率はほぼ100%だったようなので、アプリケーションで側での無駄クエリの削減が急がれた

coil: dbクライアントの最大コネクション数の修正

main.go
db = mySQLConnectionData.ConnectDB()
db.SetMaxOpenConns(20)

これだけでかなり効果があった
20という値は何度かの試行錯誤の末決定した

kyasbal: 椅子が家に入るかどうか判定するロジックの改良

8方向に回転させて試していましたが、最大辺を試す必要はないので2方向で済みます。

修正前
var estates []Estate
    w := chair.Width
    h := chair.Height
    d := chair.Depth
    query = `SELECT * FROM estate WHERE (door_width >= ? AND door_height >= ?) OR (door_width >= ? AND door_height >= ?) OR (door_width >= ? AND door_height >= ?) OR (door_width >= ? AND door_height >= ?) OR (door_width >= ? AND door_height >= ?) OR (door_width >= ? AND door_height >= ?) ORDER BY popularity DESC, id ASC LIMIT ?`
    err = db.Select(&estates, query, w, h, w, d, h, w, h, d, d, w, d, h, Limit)

を以下のように修正します

main.go
var estates []Estate
w := chair.Width
h := chair.Height
d := chair.Depth
arr := []int64{
    w, h, d,
}
sort.Slice(arr, func(i, j int) bool {
    return arr[i] < arr[j]
})
query = `SELECT * FROM estate WHERE (door_width >= ? AND door_height >= ?) OR (door_width >= ? AND door_height >= ?) ORDER BY popularity DESC, id ASC LIMIT ?`
err = db.Select(&estates, query, arr[0], arr[1], arr[1], arr[0], Limit)

kyasbal: 椅子の在庫数のredisでのキャッシュ

売り切れ判定がDBアクセス無しでできるようになったようです。

redis.go
func redisInsertChairStock(rdb *redis.Client, chairId int64, stockCount int64) error {
    ctx := context.Background()
    rdb.Set(ctx, "chair-stock-"+string(chairId), stockCount, 0).Result()
    return nil
}

func redisDecrementChair(rdb *redis.Client, chairID int64) (int64, error) {
    ctx := context.Background()
    key := "chair-stock-" + string(chairID)
    decl, err := rdb.Decr(ctx, key).Result()
    if err != nil {
        fmt.Errorf("Failed to retrive stock count")
        return 0, err
    }
    if decl < 0 {
        rdb.Set(ctx, key, 0, 0)
        return 0, errors.New("Stockout")
    }
    return decl, err
}

/initializepostChairredisInsertChairStockを呼び出しておきます

main.go
func buyChair(c echo.Context) error {
    m := echo.Map{}
    if err := c.Bind(&m); err != nil {
        c.Echo().Logger.Infof("post buy chair failed : %v", err)
        return c.NoContent(http.StatusInternalServerError)
    }

    _, ok := m["email"].(string)
    if !ok {
        c.Echo().Logger.Info("post buy chair failed : email not found in request body")
        return c.NoContent(http.StatusBadRequest)
    }

    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.Echo().Logger.Infof("post buy chair failed : %v", err)
        return c.NoContent(http.StatusBadRequest)
    }

    count, err := redisDecrementChair(rddb, int64(id))
    if err != nil {
        c.Echo().Logger.Infof("buyChair chair id \"%v\" not found", id)
        return c.NoContent(http.StatusNotFound)
    }
    _, err = db.Exec("UPDATE chair SET stock = ? WHERE id = ?", count, id)
    if err != nil {
        c.Echo().Logger.Errorf("chair stock update failed : %v", err)
        return c.NoContent(http.StatusInternalServerError)
    }

    return c.NoContent(http.StatusOK)
}

スコアのピーク

このあたりまでは非常にいい感じに進んでいて、リーダーボードでは一瞬6位になっていたので泣いて喜びながら3本目の魔剤を摂取しました。

スクリーンショット 2020-09-12 18.26.50のコピー.png

作戦会議の結果以下の分担で攻めていくことにしました

  • coil: DB分割に伴う再起動対策、nginxの設定の改善、不要なログ等の整理
  • kyasbal: query系エンドポイントの修正
    • クエリはrange_id単位でしかクエリされないので、DBにrange_idのカラムを追加してインデックスを貼って最適化
  • moajo: csvをfor文で回しながらinsertしている postChair 等の修正、getEstateDetailの修正、getLowPricedEstateなどへのインデックスの追加
    • このアプリケーションには削除機能がなく getEstateDetail は最大IDさえ分かれば良いので、家の件数をアプリ側でintで保持して比較するだけにできるはず。 postEstateRequestDocument も同様。

この分担が決まった瞬間、「いやーこれもう後はやるだけで優勝でしょwwww」という雰囲気に包まれました。

爆死

そのあとの記憶は曖昧でよく覚えていません。
僕は複合インデックスどうやって貼ると聞くんだっけなぁ?などと思いながら雑に

ALTER TABLE isuumo.estate ADD KEY lowprice (rent, id);
ALTER TABLE isuumo.chair ADD KEY lowprice (stock, price, id);

みたいなものを貼ってみたりしていました。
このあたりをベンチして検証しようと思っていたのですが、rangeでのクエリのためのDBスキーマの変更作業などとコンフリクトし、謎の整合性エラーなどでアプリが動かなくなり、やっとのことでスキーマ変更が完了した頃には残り30分とかになっていて、masterにマージするとまたもやエラーで落ちたりして、18:30以降の各種改善をマージしきれず、再起動試験する時間もないまま最低限のコミットをcherry-pickしてなんとか1800くらいのスコアを維持したまま終了しました

途中一つ一つの修正をベンチしたときは2000超えのスコアも出ていたので、もっと早く全部マージしていたらあるいは・・・という後悔があります。

学んだこと

デプロイはscpで十分

ほぼノーストレスだったし、異なるブランチを交互にデプロイしても全く問題なかった。実装も一瞬

DB分割などのサーバ構成は必須

計算リソースを使い切れる構成にするのは明らかに必須でした
DBがボトルネックになりそうなことは割と明らかだったので、DBを別インスタンスに切り出したりする構成検討に早めに入れたのは大きかったと思います

監視は大事

stackdriver traceはどのエンドポイントにきたリクエストがどれくらいのレイテンシで帰ったかなどがわかります。今回はフル活用したとまではいかなかったですが、それでも非常に便利でした。

アプリケーション直すのは楽

ライブラリを追加せず、実装を修正するだけなら環境問題とかも起きないので安全にサクッと最適化ができます。
特に知識がなくてもできるので、一番敷居が低い気がします。
標準出力とかを快適にモニタリングできる環境は初手で作るべきです。

複雑なことはするな

(これらは来年の自分たちへの戒めです)

不要な複雑さも素敵な設計もファイル分割も不要です。isuconのコードは保守するためのコードではありません。

愚直な実装・シンプルなオペレーションに努めましょう

  • shellscriptを無駄にスマートにしようとしない
  • 当たり前のことを当たり前にやれば簡単にリバートできる。簡単にリバートできれば変更は怖くない。怖くないと開発は高速になる。
    • 普通にブランチを切り、常にrebaseし、--no-ffでマージする
    • すべてのコードをgitに残す。master上のすべてのコミットがデプロイ可能な状態に保つ

コミュニケーションは重要

今回は全員で並行作業できるよう領域ごとに分担しつつ、常にボイスチャットで接続して見つけたボトルネックや改善方法は全員で議論・レビューしながら進めました。
これはめちゃくちゃ重要で、スコアの8割くらいはこれがちゃんとできていたお陰だと思っています。
日頃 ネトゲで培ったチームワーク が遺憾なく発揮された結果だったのかなと思います(結果は予選落ちですが)。

あとは、魔剤は効くのでオススメです!

13
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
2