ISUCON12本選に@eiya、@oribe、@tokiで「織時屋」として出場し、全体2位、学生チーム内では1位になりました。
本選リポジトリ: https://github.com/oribe1115/isucon12-final
@mazreanによる実況記事: ISUCON 12本戦 traQ内実況まとめ | 東京工業大学デジタル創作同好会traP
予選記事: ISUCON12予選を学生チーム内1位のスコアで突破しました | 東京工業大学デジタル創作同好会traP
予選〜本選
kayac/kayac-isucon-2022を使って初見の問題を解く練習を行った。
その過程でMakefileや周辺ツールの改良(詳細)をした。
また、予選での反省を踏まえてpprofの代わりにfgprofを使うようにした。
だだ、ソースコード行ごとの実行時間が見れないのが辛かったので、準備だけしてpprofを使うで良かったかもしれない。
本選当日
10:00~ 初動 600点
サーバーの台数が5台あることに驚く。最終的にシャーディングをしたりで全サーバーを使い切ることになりそう、と推測しつつもしばらくは堅実に1台構成で進める方針で決定。
当初の予定通り分担して初動を進めた。
@oribe: 環境セットアップとリポジトリの載せ
@eiya: 計測ツールの設定(slow query log、alp、fgprof)
@toki: マニュアル読み
10:36 13,291点
user_present_all_received_historyテーブルからのSELECTがそこそこ重かったので、検索条件のuser_id
と present_all_id
に複合インデックスを貼る。
11:02 16,798点
最上位のslow query: generateIDで発行されているUPDATEクエリ
id_generatorテーブルを使っての発行をやめ、アプリケーション内の変数でインクリメンタルにIDを発行するようにした。
排他制御のため、sync/atomicパッケージのatomic.AddInt64を使用して実装。
この時点では、再起動時後に重複したIDを発行してしまう適当実装だったが、急いでいたため気づかず。
11:09 18,073点
最上位のslow query: user_presentsテーブルへのSELECTクエリ
検索条件に使われていたuser_id
とdeleted_at
、ソートに使われていたcreated_at
のDESCに対しての複合インデックスを貼る。
11:18 27,927点
最上位のslow query: Prepare
interpolateParams=true
を設定。
11:30 29,008点
最上位のslow query: receivePresent内のUPDATEクエリ
N+1になっていたので、bulk updateに変更。
12:06 34,067点
最上位のslow query: drawGacha内のuser_presentsテーブルへのINSERTクエリ
drawGacha内のINSERTクエリがN+1になっていたので、bulk inertに変更。
また、上位に上がっていたobtainPresent内のSELECTクエリがN+1だったので改善。
Goを1.18から1.19に更新。
アプリ負荷に比べてまだDB負荷が大きかったが、DBを二台以上使えば誤魔化せると判断。
2台目以降を準備開始する。
@oribeが各サーバーのセットアップを行い、@eiyaがDBを分散させるための追加実装などを担当。
昼食休憩。
13:24 37,365点
app 1台、DB 2台の3台構成を試す。
クエリを投げるDBをユーザーIDによって選択することで分散を試みる。
初期実装では高速かつ簡単なxorを使ったhashを試したが、IDの生成方法と相まってDBが台のとき上手く分散されないことに気づく。
ユーザーごとに均等に分散されるように、xxhashを使用。
改善前
func xor64(x int64) int64 {
x = x ^ (x << 13)
x = x ^ (x >> 7)
return x ^ (x << 17)
}
db := dbs[xor64(userID) % len(dbs)]
改善後
func userXXHash(userID int64) uint64 {
x := xxhash.New()
var b [8]byte
binary.LittleEndian.PutUint64(b[:], uint64(userID))
_, _ = x.Write(b[:])
return x.Sum64()
}
db := dbs[userXXHash(userID) % len(dbs)]
one time tokenの削除をsoft deleteからhard deleteに変更。
user_one_time_tokensテーブルにtoken
とtoken_type
の複合インデックスを貼る。
14:16 71,078点
4台への分割成功。
1台目: 使用せず
2台目: appとnginx
3,4台目: user_presents、user_presentsをuserIDで振り分け
5台目: その他DB
initializeを全DBサーバーでは走らせるための準備に手間取った。
もともと各サーバーごとに個別の環境変数を簡単に撒けるようにしていたので、それを使って挙動を制御した。
user_sessionsの削除をsoft deleteからhard deleteに変更。
14:50 80,408点
user_itemsのシャーディング。
item_mastersの更新をすべてのDBに反映するようにした。
15:19 108,381点
user_one_time_tokensをシャーディング。
マスターデータの更新をすべてのDBに反映するようにする。高速に反映できるよう、golang.org/x/sync/errgroup.Group
を使って並列にシャーディング先DBのマスタデータを更新する。
adminUserでもシャーディング済みのユーザーデータを参照するようにした。
15:47 147,250点
DBのmax_connectionを上げた。
5台構成に変更
2台目: appとnginx
1,3,4,5台目: DB
殆どのテーブルについて、userIDでシャーディングされている状態。mastersは全台のDBが同一のテーブルを持っており、更新時にも全台に反映する。
16:03 160,260点
ID生成方法を改善。ID生成時に外部に情報を保存しなくとも再起動時に重複しない要件を満たすためと、アプリケーションプロセスを複数台サーバーに置くことを見込んで、snowflakeを使う実装に変更。実際はアプリケーションのプロセスは最終的に1つに落ち着いた。
user_sessionテーブルのPrimary keyをuser_id
に変更する。
17:40 190,894点
全体のリソースが浮いてる状態で策が尽きたので、obtainItemをbulkで処理しようと3人で頑張った。
17:30コードフリーズを目指していたので、がっつり実装するには厳し目な残り時間で実装スタート。
ItemTypeごとの処理を関数に切り出して、それぞれが各関数を担当する形で並列に実装を進める。
一通りの実装は17時ごろには終わっていたが、バグがあってfailしまくっていたため全員で総力を上げてデバッグすることに。
17:44 231,371点
ログを切る。
結果発表
最終スコアは242,653点。
全体2位&学生1位を獲得🎉
企業賞としてはNew Relic賞、freee賞を受賞。
ISUCON12 本選の結果発表と全チームのスコア (追記あり) : ISUCON公式Blog
ツール準備
ツールの整備は主に@oribeが担当。予選時に使っていた環境を改良して使用した。
Makefile
isucon12-final/Makefile at main · oribe1115/isucon12-final
競技中に使用するコマンドは基本的にMakefileで管理。
ちゃんとしたスクリプトを書くことも考えたが、メンバー全員が簡単に実行内容を確認できること、回ごとに微妙に違う環境への対応のしやすさからMakefileを使用し続けることにした。
Discordへのpost
チームではボイスチャット・テキストチャットのツールとしてDiscordを使用。
ベンチマーク実行時の情報とalpやpt-query-digestによる計測情報を、discocatによりDiscordに送信するようにしていた。
ブランチの切り替え忘れや最新コミットをpullしていないといった作業ミスが予選や練習で数回発生していた。
ベンチマーク実行時の環境情報としてgitのブランチ名やコミットメッセージを送信するようにしたことで、これらのミスを他のメンバーでも気づけるようになった。
また、この詳細なベンチマーク実行ログは本選が終わって2ヶ月以上経ってからブログを書くのにも大変役に立った。
同一コミットでのベンチ実行の警告(未使用)
最新コミットへの変更忘れの対策として、前回ベンチマークを実行したのと同じコミットでベンチマークを実行しようとしたときに警告を出す、というmakeコマンドを追加した。
***************************************************************
* !!!!!!!! WARNING !!!!!!!! *
* The current HEAD is the same commit with the last deploy! *
***************************************************************
これにより作業者が即座に作業ミスを気づけるようにしたかったのだが、このmakeコマンドを常用するコマンドに組み込むのを忘れていたため未使用で終わった。
netdataのカスタムダッシュボード
サーバー上のリソースの監視にはnetdataを使用。
デフォルトのダッシュボードは情報が多すぎてベンチマーク実行中に全体を監視するのは難しかった。
そこで、カスタムダッシュボード機能を使って特に重要な情報をまとめたダッシュボードページを作成した。
Custom dashboards | Learn Netdata
isucon12-final/isucon.html at main · oribe1115/isucon12-final
サーバー間で共通のコード、個別の環境変数と設定
リポジトリでは、全サーバー間で共通して使用するソースコードと、各サーバーごとに設定可能な環境変数と設定ファイルを管理した。
サーバーごとの個別のファイルはs1などのディレクトリ以下で管理する。
.
├── Makefile
├── README.md
├── s1
│ ├── etc
│ │ ├── mysql/
│ │ ├── nginx/
│ │ └── systemd/
│ └── home
│ └── isucon
│ └── env
├── s2/
├── s3/
├── s4/
├── s5/
├── tool-config/
└── webapp/
環境変数は各envファイルに記述し、それをMakefile内でも読み込んだ。
s1/home/isucon/envISUCON_DB_USER=isucon
ISUCON_DB_PASSWORD=isucon
ISUCON_DB_HOST=127.0.0.1
ISUCON_DB_PORT=3306
ISUCON_DB_NAME=isucon
SERVER_APP_PORT=8080
PERL5LIB=/home/isucon/webapp/perl/local/lib/perl5
SERVER_ID=s1
ISUCON_DB_HOST2=133.152.6.66
ISUCON_DB_HOST3=133.152.6.67
Makefileinclude env
ENV_FILE:=env
# 変数定義 ------------------------
# SERVER_ID: ENV_FILE内で定義
...
.PHONY: deploy-db-conf
deploy-db-conf:
sudo cp -R ~/$(SERVER_ID)/etc/mysql/* $(DB_PATH)
...
環境変数のSERVER_ID
は、サーバーに展開する設定ファイルの制御やDiscordの送信情報に使用した。
サーバーごとに簡単に環境変数を設定できるこの構成は、サーバーごとにアプリケーションの振る舞いを変える実装に活用された。