1位の人の全てのコミットを追うまとめ - https://github.com/takonomura/isucon14
具体的な作業
- 10:08 各サーバの情報などをスクリプトで自動取得し保存
- 10:08 nginx / mysql / systemd 系のものも取得
- 10:13 プロセス名を修正
- 10:13 netdata install
- 10:13 nginx.conf と site-enabled をひとまとめにして nginx.confへ
10:17 nginx 秘伝のタレ
nginx.conf
worker_rlimit_nofile 100000 events { worker_connections 4096; } http { open_file_cache max=100 inactive=65s; gzip_static on; log_format json escape=json '{' '"time":"$time_local",' '"host":"$remote_addr",' '"forwardedfor":"$http_x_forwarded_for",' '"req":"$request",' '"status":"$status",' '"method":"$request_method",' '"uri":"$request_uri",' '"body_bytes":$body_bytes_sent,' '"referer":"$http_referer",' '"ua":"$http_user_agent",' '"request_time":$request_time,' '"cache":"$upstream_http_x_cache",' '"runtime":"$upstream_http_x_runtime",' '"response_time":"$upstream_response_time",' '"vhost":"$host"}'; access_log /var/log/nginx/access.log json; } server { listen 443 ssl default_server http2; # http2 追加 ... } server { listen 443 ssl http2; # http2 追加 ...
10:25 alp の設定ファイルを導入
設定ファイル
# https://github.com/tkuchiki/alp/blob/main/example/config.yml file: /var/log/nginx/access.log sort: sum reverse: true query_string: true query_string_ignore_values: true output: count,2xx,4xx,5xx,method,uri,min,p50,p99,max,sum matching_groups: - '^/api/chair/rides/[0-9A-Z]+/status$' - '^/api/app/rides/[0-9A-Z]+/evaluation$' percentiles: [50, 99] ltsv: {} json: {} regexp: {} pcap: {}
10:36 sql-perfomance の設定
shell
#!/bin/bash set -exuo pipefail . env.sh mysql -u"$ISUCON_DB_USER" \ -p"$ISUCON_DB_PASSWORD" \ --host "$ISUCON_DB_HOST" \ --port "$ISUCON_DB_PORT" \ "$ISUCON_DB_NAME" << EOF select SCHEMA_NAME, -- DIGEST, DIGEST_TEXT, COUNT_STAR, round(SUM_TIMER_WAIT / pow(1000,4), 3) as SUM_TIMER_WAIT_SEC, round(MIN_TIMER_WAIT / pow(1000,4), 3) as MIN_TIMER_WAIT_SEC, round(AVG_TIMER_WAIT / pow(1000,4), 3) as AVG_TIMER_WAIT_SEC, round(MAX_TIMER_WAIT / pow(1000,4), 3) as MAX_TIMER_WAIT_SEC, round(SUM_LOCK_TIME / pow(1000,4), 3) as SUM_LOCK_TIME_SEC, round(QUANTILE_95 / pow(1000,4), 3) as P95, round(QUANTILE_99 / pow(1000,4), 3) as P99, round(QUANTILE_999 / pow(1000,4), 3) as P999, SUM_ERRORS, SUM_WARNINGS, SUM_ROWS_AFFECTED, SUM_ROWS_SENT, SUM_ROWS_EXAMINED, SUM_CREATED_TMP_DISK_TABLES, SUM_CREATED_TMP_TABLES, SUM_SELECT_FULL_JOIN, SUM_SELECT_FULL_RANGE_JOIN, SUM_SELECT_RANGE, SUM_SELECT_RANGE_CHECK, SUM_SELECT_SCAN, SUM_SORT_MERGE_PASSES, SUM_SORT_RANGE, SUM_SORT_ROWS, SUM_SORT_SCAN, SUM_NO_INDEX_USED, SUM_NO_GOOD_INDEX_USED, -- round(SUM_CPU_TIME / pow(1000,4), 3) as SUM_CPU_TIME_SEC, round(MAX_CONTROLLED_MEMORY / pow(1024,2), 3) as MAX_CONTROLLED_MEMORY_MB, round(MAX_TOTAL_MEMORY / pow(1024,2), 3) as MAX_TOTAL_MEMORY_MB, -- COUNT_SECONDARY, -- FIRST_SEEN, -- LAST_SEEN, QUERY_SAMPLE_TEXT, -- QUERY_SAMPLE_SEEN, round(QUERY_SAMPLE_TIMER_WAIT / pow(1000,4), 3) as QUERY_SAMPLE_TIMER_WAIT_SEC from performance_schema.events_statements_summary_by_digest where SCHEMA_NAME != 'performance_schema' AND SCHEMA_NAME IS NOT NULL order by SUM_TIMER_WAIT desc limit 20\G EOF
10:38 log rotate sh の設定
script
#!/bin/bash set -e cd "$HOME" mkdir -p logs TIMESTAMP=$(date +%H%M%S) for f in /var/log/nginx/{access,error}.log /var/log/mysql/mysql-slow.log; do if sudo test -f "$f"; then output="logs/$(basename "$f")" output="${output/%.log/.$TIMESTAMP.log}" echo mv "$f" "$output" sudo mv "$f" "$output" sudo chown $USER:$USER "$output" gzip "$output" fi done if sudo systemctl is-active "mysql.service" >/dev/null; then sudo mysql -uroot -e "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;" fi
10:53 chair に index をはる
- CREATE INDEX idx_chairs_created_at ON chairs (created_at);
- CREATE INDEX idx_chairs_owner_id_created_at ON chairs (owner_id, created_at DESC);
- 11:04 performance_schema を initialize 時に削除するようにする
- 11:08 isu2 の情報を保存
- 11:12 db を分割
- 11:30 systemd の修正
- 11:36 rides に index
- CREATE INDEX idx_rides_chair_id_created_at ON rides (chair_id, created_at);
- 11:57 notification の polling を 500ms にする
- 12:10
appGetNearbyChairs
の N+1 を修正- この時、アプリロジックで持っている isActive / distance <50 をすべて解消するクエリをを書き一気に解決している。
- 一つ前の実装から13分しか経ってない。コードを読み、コードを書く速度が異常
- しかもこの時 ride の N+1 には手を入れていない
- retrieve はそのまま timeをDBから引っ張ってるだけだった。。
12:28 chairs テーブルにマッチ可能か、location に関する情報を追加する
スキーマ
CREATE TABLE chairs ( id VARCHAR(26) NOT NULL COMMENT '椅子ID', owner_id VARCHAR(26) NOT NULL COMMENT 'オーナーID', name VARCHAR(30) NOT NULL COMMENT '椅子の名前', model TEXT NOT NULL COMMENT '椅子のモデル', is_active TINYINT(1) NOT NULL COMMENT '配椅子受付中かどうか', + is_matchable TINYINT(1) NOT NULL COMMENT 'マッチ可能かどうか', access_token VARCHAR(255) NOT NULL COMMENT 'アクセストークン', + latitude INTEGER NULL COMMENT '経度', + longitude INTEGER NULL COMMENT '緯度', + total_distance INTEGER NOT NULL, + location_updated_at DATETIME(6) NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '登録日時', updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新日時', PRIMARY KEY (id) ) COMMENT = '椅子情報テーブル'; CREATE INDEX idx_chairs_created_at ON chairs (created_at); CREATE INDEX idx_chairs_owner_id_created_at ON chairs (owner_id, created_at DESC); +CREATE INDEX idx_chairs_is_matchable ON chairs (is_matchable);
12:53 初期データを改造し、はじめから chair table に locatioin 情報を持っているようにする
ddl
ALTER TABLE chairs ADD COLUMN latitude INTEGER COMMENT '経度' AFTER is_active, ADD COLUMN longitude INTEGER COMMENT '緯度' AFTER latitude, ADD COLUMN location_updated_at DATETIME(6) COMMENT '位置情報の更新日時' AFTER longitude; -- UPDATE chairs c -- JOIN ( -- SELECT -- cl.chair_id, -- cl.latitude, -- cl.longitude, -- cl.created_at AS location_updated_at -- FROM chair_locations cl -- INNER JOIN ( -- SELECT chair_id, MAX(created_at) AS max_created_at -- FROM chair_locations -- GROUP BY chair_id -- ) latest -- ON cl.chair_id = latest.chair_id AND cl.created_at = latest.max_created_at -- ) latest_location -- ON c.id = latest_location.chair_id -- SET -- c.latitude = latest_location.latitude, -- c.longitude = latest_location.longitude, -- c.location_updated_at = latest_location.location_updated_at;
13:00 この時点で ChairLocation を消している
- これにより chair location への insert は消えた
- insert が多いので消そう→そのためには既存のレコードに持たせれば良くね?の発想が早い
13:29 chair の is_matchable の実装
- isActive(これは owner が椅子を用意できたという意味だと思う)
- isFree (これは勝手に作った、ステートが COMPLETE になったことを示す)
- この二つが && で true なら手配可能とする STORED カラムを作って利用
- 以後はこのカラムのみをみて対応している
ddl
ADD COLUMN is_free TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'マッチ中かどうか' AFTER is_active; -- UPDATE chairs c -- LEFT JOIN ( -- SELECT -- r.chair_id, -- MAX(CASE WHEN rs.status = 'COMPLETED' THEN 1 ELSE 0 END) AS has_completed_ride -- FROM rides r -- LEFT JOIN ride_statuses rs ON r.id = rs.ride_id -- GROUP BY r.chair_id -- ) ride_data ON c.id = ride_data.chair_id -- SET c.is_free = CASE -- WHEN ride_data.chair_id IS NULL OR ride_data.has_completed_ride = 1 THEN 1 -- ELSE 0 -- END; ALTER TABLE chairs ADD COLUMN is_matchable TINYINT(1) AS (is_active AND is_free) STORED NOT NULL COMMENT 'マッチ可能かどうか';
13:59 マッチングアルゴリズムの修正
- とりあえず直近1台ではなく2台とってみる
- 14:10 binlog の停止 / 5354
- 14:28 prepared statement の停止
14:30 mysql innodb 設定
innodb_buffer_pool_size = 1GB innodb_flush_log_at_trx_commit = 2 innodb_flush_method = O_DIRECT
14:30 ride と chair の bulk matching / 6679
- 元々の処理は chair と1つとって、それに対してマッチさせる感じだった
- match 可能な chair と chair が null な ride を取得
- ride に対して一番最適な chair を取得しその結果を格納する
- 14:46 rides に index をはる
- CREATE INDEX idx_ride_statuses_ride_id_created_at ON rides (ride_id, created_at DESC);
- 14:47 chi logger 削除
14:53 db connection の設定
connection
func initDB(db *sql.DB) { waitDB(db) go pollDB(db) db.SetConnMaxLifetime(10 * time.Second) db.SetMaxIdleConns(512) db.SetMaxOpenConns(512) } func waitDB(db *sql.DB) { for { err := db.Ping() if err == nil { return } log.Printf("Failed to ping DB: %s", err) log.Println("Retrying...") time.Sleep(time.Second) } } func pollDB(db *sql.DB) { for { err := db.Ping() if err != nil { log.Printf("Failed to ping DB: %s", err) } time.Sleep(time.Second) } }
14:54 db の max connection を 65535 へ / 15152
- 14:53 chair に index / 25228
- CREATE INDEX idx_chairs_access_token ON chairs (access_token);
- 15:01 evaluation を利用した処理の効率化 / 25800
- 評価をすると COMPLETE に移動することを利用し、評価があるかどうか?のチェックで rideStatus のチェックをしなくていいようにしている
- 賢すぎる
- 15:06 rides に index / 24639
- CREATE INDEX idx_rides_chair_id_updated_at ON rides (chair_id, updated_at DESC);
- 15:12 notification の polling を 500ms にする / 26572
- 15:16 coupon に index
- CREATE INDEX idx_coupons_used_by ON coupons (used_by);
- CREATE INDEX idx_coupons_user_id_used_by_created_at ON coupons (user_id, used_by, created_at);
- CREATE INDEX idx_coupons_user_id_code ON coupons (user_id, code);
- 15:34 isu3 の情報を保存
- 15:46 まだクライアントに送信されていない通知について、通知を送ってないものに関しては retryAfeterMs を調整する / 32809
- 16:17 SSE の導入 / 32809
- http.Flusher
- http.Flusher は Go の標準ライブラリの net/http パッケージに含まれるインターフェースで、HTTP レスポンスのストリーミングをサポートするためのものです。これを使うことで、サーバーがクライアントに部分的なデータを送信した後も、接続を保持し続けることができます。
- このようにしており、対策が完璧
func chairGetNotificationSSE(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { chairGetNotification(w, r) return }
- しかも既存の実装も使いつつ、最小限の修正になっている
- http.Flusher
- 16:38
X-Accel-Buffering
を追加 / 32926- バッファリングを無効化すると、サーバーから送信されたデータが即座にクライアントに転送されます。これによりNginx では貯めずにすぐに転送します
- 16:52 signal ? / 33896
- 16:56 appGetNotification のリトライ頻度を 500ms へ / 51957
- 17:22 app notification の SSE / 56521
- 17:36 ssm / snapd など不要なものを止める
- 17:43 isuride-go.service に
StartLimitInterval=0
を設定し再起動何度でもできるように - 17:44 sqlx.Connect から sqlx.Open へ
sqlx.Connect
- アプリケーション起動時にデータベース接続が確実に確立されている必要がある場合
- 起動時に接続エラーを検知して明確に対処したい場合。
sqlx.Open
- 接続が必ずしも即時必要ではなく、遅延的な接続が許容される場合。
- データベースの使用頻度が低いアプリケーションや、接続を管理する特殊なロジックが必要な場合。
- 以後ベンチマークなんども
学び
- 自動で初期状態を取得している
- nginx で最初に入れるのは数個だけ
- alp の設定ファイルなんてあるのか
- schema の状態を取得する設定のシェル用意してる
- ログローテートしないのですぐ消えてたので良くない
appGetNearbyChairs
の N+1 を探すのも、修正もクソ早い。しかも正確。 ⭐️- 初期データをいじればもっと早いは確かに〜〜〜
- mysql や nginx の設定を一気に入れることをしていない
- bulk match は思いついたらできそうだが、書くスピードが追いついてない
- 結果がコミットごとに見れるのとてもいいな https://github.com/takonomura/isucon14/commit/88eae785a891255020747eb00c4e7079afe904b3
STORED
なるほど・・・ is_active と is_free があるのか- 評価をすると COMPLETE に移動することを利用し、評価があるかどうか?のチェックで rideStatus のチェックをしなくていいようにしている。ドキュメントからの推察力高杉 ⭐️
- まだクライアントに送信されていない通知の retryAfterMs の調整について、コードを理解するスピードが早すぎる
- SSE についての理解度。net/http Flusher というかスピード早すぎ
X-Accel-Buffering
はじめてしった- ssm / snapd などとめてなかった
StartLimitInterval=0
で何度も再起動できるようにした