ISUCON10で予選落ちしたが、俺はまだ負けちゃいない

今回は初の一人参加で、最高スコアは20時前の813。

だいたいやったこと

  1. nginxにBot除外の正規表現いれた
  2. DB専用ホストを作った
  3. なぞって検索のN+1の解消
  4. 最安価格検索のキャッシュ
  5. pagingの実装によりカウントクエリをなくした
  6. 賃料やドアのサイズをID検索に対応
  7. 検索比率の高い検索条件をキャッシュ

いくつかの実装を紹介する。まず序盤でDBが今回のキーポイントになるのはMackerelですぐわかったから、そちらの対策に舵をきった。インフラ面の整備は秘伝のitamaeを実行するとカーネルチューニングやalpやmyprofiler、デプロイスクリプトなどピッと入るようになっている。

NginxのBot削除はこんなのりでかいた。

if ( $http_user_agent ~* ((bot|crawler|spider)(?:[-_\\ .\\/\\;@\\(\\)]|$))) {
   return 503;
}

これをひたすら並べた感じ。最初一個のifで書こうと思ったのだがエスケープの諸々が自信がなく動けばいいやって感じでエイっとやった。

なぞって検索は座標データをこれまで扱ったことがなく、面食らったが世界中にドキュメントはあるのでこんな感じのクエリに書き換えた。

	query := fmt.Sprintf(`
SELECT
    *
FROM
    estate
WHERE
    latitude <= ?
AND latitude >= ?
AND longitude <= ?
AND longitude >= ?
AND ST_Contains(ST_PolygonFromText(%s), %s)
ORDER BY
    popularity DESC,
    id ASC
`, coordinates.coordinatesToText(), "POINT(latitude, longitude)")

最初は、地理データわかんねぇよとか思って、1台なぞって検索用のノードにトラフィックを分けて忘れる作戦にしたのだが、

「いや、俺なら出来る!!!」

と思って取り組んだら30分位で出来た。

最安価格のキャッシュは普通にgo-cacheつかった。

func getLowPricedChair(c echo.Context) error {
	var chairs []Chair
	if x, found := gocache.Get("low_chair"); found {
		chairs = x.([]Chair)
	} else {
		query := `SELECT * FROM chair WHERE stock > 0 ORDER BY price ASC, id ASC LIMIT ?`
		err := db.Select(&chairs, query, Limit)
		if err != nil {
			if err == sql.ErrNoRows {
				c.Logger().Error("getLowPricedChair not found")
				return c.JSON(http.StatusOK, ChairListResponse{[]Chair{}})
			}
			c.Logger().Errorf("getLowPricedChair DB execution error : %v", err)
			return c.NoContent(http.StatusInternalServerError)
		}
		gocache.Set("low_chair", chairs, cache.DefaultExpiration)
	}
	return c.JSON(http.StatusOK, ChairListResponse{Chairs: chairs})
}

椅子が売れたり、新規登録されたらこんな感じでキャッシュ消した。

gocache.Delete("low_chair")

椅子、物件共にページング表示用のクエリ、カウント用のクエリが二回実行されてたので、ぐわっと持ってきてページングした。今思えばこれは悪手だった気がする。条件なし検索とかも飛んできてたので、MySQLの負荷をあげてしまったっぽい。

func paginateChair(x []Chair, skip int, size int) []Chair {
	if skip > len(x) {
		skip = len(x)
	}

	end := skip + size
	if end > len(x) {
		end = len(x)
	}

	return x[skip:end]
}

これのおかげでしばらく、200代に陥った。

ID検索は、jsonファイルに条件があったので、何かしら動的生成すればよかったなぁと今更思ったものの、焦りもあったので検索回数が多かったカラムを対象にした。

CREATE TABLE isuumo.estate
(
    id          INTEGER             NOT NULL PRIMARY KEY,
    name        VARCHAR(64)         NOT NULL,
    description VARCHAR(4096)       NOT NULL,
    thumbnail   VARCHAR(128)        NOT NULL,
    address     VARCHAR(128)        NOT NULL,
    latitude    DOUBLE PRECISION    NOT NULL,
    longitude   DOUBLE PRECISION    NOT NULL,
    rent        INTEGER             NOT NULL,
    door_height INTEGER             NOT NULL,
    door_width  INTEGER             NOT NULL,
    features    VARCHAR(64)         NOT NULL,
    popularity  INTEGER             NOT NULL,
    rent_class  INTEGER              default 0,
    door_width_class INTEGER              default 0,
    door_height_class INTEGER              default 0
);

こんなのりでテーブル作って、initializeでアップデートとinsert時に工夫した。

	_, err := db.Exec(`UPDATE estate SET rent_class = CASE
      WHEN rent < 50000 THEN 0
      WHEN rent >= 50000 and rent < 100000 THEN 1
      WHEN rent >= 100000 and rent < 150000 THEN 2
      WHEN rent >= 150000 THEN 3
END
	`)

	_, err = db.Exec(`UPDATE estate SET door_width_class = CASE
      WHEN door_width < 80 THEN 0
      WHEN door_width >= 80 and door_width < 110 THEN 1
      WHEN door_width >= 110 and door_width < 150 THEN 2
      WHEN door_width >= 150 THEN 3
END
	`)
	_, err = db.Exec(`UPDATE estate SET door_height_class = CASE
      WHEN door_height < 80 THEN 0
      WHEN door_height >= 80 and door_height < 110 THEN 1
      WHEN door_height >= 110 and door_height < 150 THEN 2
      WHEN door_height >= 150 THEN 3
END
	`)

最後の1時間はキャッシュをローカルキャシュ採用したのを頭からパージされて、アプリケーションを複数台にしてしまい、フェイルするのをなんでやーーーとドツボにハマっていた。

DB分割まで手を付けられるともう少し点数伸ばせたのかなーという気がしていて、力不足を感じた一方、フロントからバックエンドまで一通りこなせたのは良かった。

運営の皆様、序盤色々ありご苦労されたと思いますが大変有意義な時間でした、ありがとうございました。

来年は優勝したい。