# ISUCON13 チートシート ## 当日やること ``` ボトルネックを特定する。 ボトルネックに対して、変更は最小限で、どストレートにアプローチする。 ``` 1. ボトルネック特定のための素材を集める(最初の1h) 2. インデックスを貼るなどのお決まり(30min) 3. サービスをドッグフーディングしていじり倒す(30min) 4. アプリケーションマニュアルを読む(30min) 5. レギュレーション(特に採点マニュアル)を読む(30min) 6. main.goを読む(60min) --- (ここまでで2〜3時間。残りの5〜6時間で改善を行う) --- 7. ボトルネックの検討、アイディアの検討・計画(30 min) 8. 実装(15 min) 9. 7と8を繰り返す(できる限り7に時間をかける) ### ボトルネック特定のためのツール利用方法 - alpでnginxのアクセスログを解析する - pt-query-digestでmysqlのslow logを解析する - pprofでgoのcpu/heapのprofileを解析する - trdsqlでnginxのアクセスログを自由に解析する - dstatでcpu/memory/io/networkを解析する - iostatでioを解析する - topでcpu/memoryを解析する ## 最初の環境構築 ### sshの設定 自分のローカルにて以下を記載(make setupする前の最初のログインのみ、Userがubuntu出ないとログインできないことに注意) `~/.ssh/config` ``` Host isu* IdentityFile ~/.ssh/xxx.pem User isucon Host isu1 HostName xxx.xxx.xxx.xxx # これはpprof用 LocalForward 8090 localhost:8090 # ISUCON11qのようにローカルフォワーディングが必要なときはこんな感じで書くとよい # LocalForward 5000 localhost:5000 Host isu2 HostName xxx.xxx.xxx.xxx Host isu3 HostName xxx.xxx.xxx.xxx ``` ### s1/s2/s3 共通のsetup ``` $ sudo -iu isucon $ wget https://raw.githubusercontent.com/usatie/isucon-setup/main/Makefile ``` s1/s2/s3によってこれだけ変更する ``` $ make set-as-s1 ``` ``` $ sudo systemctl list-units --type=service | grep isu $ vim Makefile ``` これらを変更する ``` USER:=isucon BIN_NAME:=isucondition BUILD_DIR:=$(HOME)/webapp/go SERVICE_NAME:=$(BIN_NAME).go.service ``` ``` $ make setup $ make oh-my-zsh $ make zsh-setup $ exec $SHELL $ vim :Copilot setup :LspInstallServer ``` 一旦exitする Githubにdeploy keyを登録する ``` $ cat ~/.ssh/id_rsa.pub ``` https://github.com/usatie/isucon13f/settings/keys/new もしgolangがデフォルトじゃなかったらgolangに切り替える ``` $ sudo systemctl stop isu-ruby $ sudo systemctl disable isu-ruby $ sudo systemctl start isu-go $ sudo systemctl enable isu-go ``` ### s1だけでやる ``` $ make set-as-s1 ``` Makefileの設定値を編集してcommit/pushする ``` $ vim Makefile ``` 初期設定をcommitする ``` $ make get-conf $ git init && git branch -M main && git add . && git commit -m "Initial commit s1" $ git remote add origin git@github.com:usatie/isucon13f.git $ ggp ``` ### s2/s3 ``` $ make set-as-s2 又は $ make set-as-s3 ``` ``` $ git init && git branch -M main && git remote add origin git@github.com:usatie/isucon13f.git ``` pullするとoverwriteのエラーが出るので、無理やりこれで解決 ``` $ git fetch origin $ git reset --hard FETCH_HEAD ``` その後、初期設定をcommitする ``` $ make get-conf $ git add . && git commit -m "Initial commit $SERVER_ID" $ git push origin main ``` その後、他のサーバーの設定をコピーする ``` $ cp -r s2/etc s3/ $ gss # 確認 $ make deploy-conf $ git add . && git commit -m "Setup $SERVER_ID" $ ggp ``` ## Golang ### Golangのアップデート `make go-setup`に追加したので多分やる必要は出てこないはず ``` $ cd ~/local $ rm -rf go # もし最初からgoが入っていたら削除 $ curl -L https://go.dev/dl/go1.18.3.linux-amd64.tar.gz | tar -zxf - ``` ### main.go(最初に書くこと) ``` import "net/http/pprof" func main () { // 最後に簡単にログを切れるようにコメントを追加しておく // log.SetFlags(0) // log.SetOutput(ioutil.Discard) // おなじみの interpolateParams=true dsn := fmt.Sprintf( "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&loc=Local&interpolateParams=true", user, password, host, port, dbname, ) db, err = sqlx.Open("mysql", dsn) // dbのコネクション数の設定 db.SetMaxIdleConns(1000) db.SetMaxOpenConns(1000) // pprofの設定(echo) e := echo.New() pprofGroup := e.Group("/debug/pprof") pprofGroup.Any("/cmdline", echo.WrapHandler(http.HandlerFunc(pprof.Cmdline))) pprofGroup.Any("/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile))) pprofGroup.Any("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) pprofGroup.Any("/trace", echo.WrapHandler(http.HandlerFunc(pprof.Trace))) pprofGroup.Any("/*", echo.WrapHandler(http.HandlerFunc(pprof.Index))) // pprofの設定(goji) mux := goji.NewMux() mux.HandleFunc(pat.Get("/debug/pprof/cmdline"), pprof.Cmdline) mux.HandleFunc(pat.Get("/debug/pprof/profile"), pprof.Profile) mux.HandleFunc(pat.Get("/debug/pprof/symbol"), pprof.Symbol) mux.HandleFunc(pat.Get("/debug/pprof/trace"), pprof.Trace) mux.HandleFunc(pat.Get("/debug/pprof/*"), pprof.Index) } ``` もしかしてpprof、どのパターンでもこれだけでいいかも ``` import ( _ "net/http/pprof" ... ) func main() { runtime.SetBlockProfileRate(1) go func() { log.Fatal(http.ListenAndServe(":6060", nil)) }() } ``` ### /etc/systemd/system/*.service(最初に書くこと) カーネルパラメーターをチューニングするときはこのコマンド ``` # edit時のエディタを変える $ sudo update-alternatives --config editor # editする $ sudo systemctl edit isu-go.service $ sudo systemctl edit isucondition.go.service ``` ``` [Service] LimitNOFILE=65536 ``` ### お役立ち #### echoのリクエストをcurlで実行できる形で出力 ``` func printAsCurl(c echo.Context) error { // リクエストメソッドとURLを取得 method := c.Request().Method url := c.Request().URL.String() // curlコマンドを組み立てる curl := fmt.Sprintf("curl -X %s 'http://localhost%s'", method, url) // リクエストヘッダーを取得してcurlコマンドに追加 for name, headers := range c.Request().Header { for _, h := range headers { curl += fmt.Sprintf(" -H '%s: %s'", name, h) } } // リクエストボディがある場合は、それを取得してcurlコマンドに追加 if c.Request().Body != nil { body, err := ioutil.ReadAll(c.Request().Body) if err != nil { // エラー処理をここに記述します return err } // リクエストボディをcurlコマンドに追加 curl += fmt.Sprintf(" -d '%s'", string(body)) // ボディを読み取ったので、リクエストのBodyを再度読み取れるようにリセットします c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(body)) } // 組み立てたcurlコマンドを出力 fmt.Println(curl) return nil } ``` ## NGINX ### 最初にやる設定(etc/nginx/nginx.conf) confファイルを編集して、変更内容をコミットする ``` $ sudo vim /etc/nginx/nginx.conf ``` ``` worker_rlimit_nofile 65536; events { worker_connections 2048; multi_accept on; } http { sendfile on; # NFSでネットワークマウントされてるのじゃなければONがいい tcp_nopush on; # open file cache open_file_cache max=100000 inactive=20s; open_file_cache_valid 30s; open_file_cache_min_uses 2; open_file_cache_errors on; # gzip gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_min_length 1k; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_buffers 16 8k; gzip_http_version 1.1; # ssl ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE ssl_prefer_server_ciphers on; log_format ltsv "time:$time_local" "\thost:$remote_addr" "\tforwardedfor:$http_x_forwarded_for" "\treq:$request" "\tstatus:$status" "\tmethod:$request_method" "\turi:$request_uri" "\tsize:$body_bytes_sent" "\treferer:$http_referer" "\tua:$http_user_agent" "\treqtime:$request_time" "\tcache:$upstream_http_x_cache" "\truntime:$upstream_http_x_runtime" "\tapptime:$upstream_response_time" "\tvhost:$host"; access_log /var/log/nginx/access.log ltsv; # 終了直前にこちらをコメント外してログを切る # access_log off; } ``` ### 最初にやる設定(/etc/nginx/sites-available/isucondition.cnf) ちなみに`/etc/nginx/sites-available`は非推奨で、`/etc/nginx/conf.d/`が推奨とのことだけど最初からそっちに入ってたら仕方ない。 ``` $ sudo vim /etc/nginx/sites-available/isucondition.cnf ``` DBサーバーを切り分けた場合、初期化エンドポイントはDBサーバーに仕向けた方が楽だったりもした。 ``` upstream app { server localhost:8080; # 2台目のapp serverを追加する時はここに追加(どのサーバーにどれくらい負荷をかけるかのweightも設定可能。nginxと共存してるサーバーと1:2とか2:3くらいにする?) # server xxx.xxx.xxx.xxx:port weight=2; keepalive 64; keepalive_requests 10000; } server { client_max_body_size 10m; location / { proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass http://app; } } ``` ### カーネルパラメーターをチューニングする ``` $ sudo systemctl edit nginx.service ``` ``` [Service] LimitNOFILE=65536 ``` ### client cache 実質expiresだけでclient側でキャッシュしてくれるようになり、不要なrequestが来なくなる。try_filesは$uriに静的ファイルがあれば配信して、なければappに取りにいくという設定。最初に画像がDBに保存されていた場合など。 ``` server { location ~ /(image|js|css|img)/ { try_files $uri @app; expires 60s; } } ``` ## alp confファイルを編集して、変更内容をコミットする。 `main.go`の中のルーター部分を見るとmatchの部分は書きやすいと思う。 ``` $ vim tool-config/alp/config.yml $ make alp ``` ### 参考パターン ![](https://i.imgur.com/7KqEzvD.png) ``` matching_groups: - /api/isu/[-a-z0-9]+ - /api/isu/.+/icon - /api/isu/.+/graph - /api/condition/[-a-z0-9]+ - /isu/[-a-z0-9]+ - /isu/.+/graph - /isu/.+/condition ``` ![](https://i.imgur.com/INxwwfY.png) ``` matching_groups: - /posts/.+ - /image/.+ - /@[a-zA-Z]+ ``` ## MySQL ### envファイルの確認 ``` MYSQL_HOST:=$(ISUCON_DB_HOST) MYSQL_PORT:=$(ISUCON_DB_PORT) MYSQL_USER:=$(ISUCON_DB_USER) MYSQL_PASS:=$(ISUCON_DB_PASSWORD) MYSQL_DBNAME:=$(ISUCON_DB_NAME) ``` ### 最初にやる設定(slow_query/max_conneciton) confファイルを編集する ``` $ sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf $ make get-db-conf ``` slow-queryが有効化できなかった時(/var/log/mysql/mysql-slow.logの権限問題) ``` $ sudo ls /var/log/mysql/ -la total 8 drwxr-x--- 2 mysql adm 4096 Nov 23 07:42 . drwxr-xr-x 12 root syslog 4096 Nov 23 03:28 .. -rw-r--r-- 1 root root 0 Nov 23 07:44 error.log -rw-r--r-- 1 root root 0 Nov 23 07:44 mysql-slow.log $ sudo chmod 666 /var/log/mysql/mysql-slow.log $ sudo chmod 666 /var/log/mysql/error.log ``` ``` slow_query_log slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 0 log-queries-not-using-indexes open_files_limit = 65536 max_connections = 10000 # DBとappを分離する際に必要 bind-address = 0.0.0.0 # 読み込んだデータ及びインデックスをメモリ上に確保する機能(DB専用サーバーならば物理メモリの80%程度にしておく) # この例は3GB(4GBメモリを想定) innodb_buffer_pool_size = 3G # innodb_buffer_poolを活用する場合は、ディスクキャッシュと二重でメモリを確保しないようにO_DIRECTを設定する innodb_flush_method=O_DIRECT # 更新ログをdisk cacheをストレージに同期させるのを非同期に変更する innodb_flush_log_at_trx_commit = 2 # 更新ログをそもそもオフにする(レプリケーションしないならこれでいい) disable-log-bin = 1 ``` カーネルパラメーターをチューニングするときはこのコマンド ``` $ sudo systemctl edit mysql.service ``` ``` [Service] LimitNOFILE=65536 ``` 参考 https://knowledge.sakura.ad.jp/11981/ ``` [Service] LimitNOFILE=65536 ``` ### DBの水平分割 #### IDの採番(snowflake) snowflakeモジュールのインストール ``` $ cd webapp/go $ go get github.com/bwmarrin/snowflake ``` ソースコードの変更 ``` import ( "github.com/bwmarrin/snowflake" ) // generateID ユニークなIDを生成する var ( snowflakeNode *snowflake.Node ) func initSnowflake() { serverIdStr := os.Getenv("SERVER_ID") // "s1" if serverIdStr == "" { serverIdStr = "s1" } id, err := strconv.ParseInt(serverIdStr[1:], 10, 64) snowflakeNode, err = snowflake.NewNode(id) if err != nil { panic(err) } } func (h *Handler) generateID() (int64, error) { return snowflakeNode.Generate().Int64(), nil } ``` #### sharding用のhash xxhashモジュールのインストール ``` $ cd webapp/go $ go get github.com/cespare/xxhash/v2 ``` ``` import ( "github.com/cespare/xxhash/v2" ) type Handler struct { DB *sqlx.DB DB2 *sqlx.DB DB3 *sqlx.DB DB4 *sqlx.DB } const dbNum = 4 func (h *Handler) getAllDB() []*sqlx.DB { return []*sqlx.DB{h.DB, h.DB2, h.DB3, h.DB4} } func hash(userID int64) uint64 { return xxhash.Sum64String(strconv.FormatInt(userID, 10)) } func (h *Handler) getDB(userID int64) *sqlx.DB { return h.getAllDB()[hash(userID)%dbNum] } func main() { // ... dbx, err := connectDB(false, 1) if err != nil { e.Logger.Fatalf("failed to connect to db: %v", err) } defer dbx.Close() dbx2, err := connectDB(false, 2) if err != nil { e.Logger.Fatalf("failed to connect to db2: %v", err) } defer dbx2.Close() dbx3, err := connectDB(false, 3) if err != nil { e.Logger.Fatalf("failed to connect to db3: %v", err) } defer dbx3.Close() dbx4, err := connectDB(false, 4) if err != nil { e.Logger.Fatalf("failed to connect to db4: %v", err) } defer dbx4.Close() // ... } ``` ### 役立つMySQLコマンド集 #### DB分離直後のテスト Security GroupでPORT3306を開けてあげる必要があることに注意! ``` # DBサーバー以外から $ make access-db ``` #### 許可するhostの変更(DBサーバー分離の時) 許可あるユーザーで入る必要があるのでsudo mysqlが必要 ``` $ sudo mysql mysql> select user, host from mysql.user; mysql> update mysql.user set host='%' where user = 'isucon'; ``` #### connection数の確認 ``` mysql> show variables like "%connection%"; mysql> show status like 'Thread_%'; +-------------------+-------+ | Variable_name | Value | +-------------------+-------+ | Threads_cached | 0 | | Threads_connected | 2 | | Threads_created | 2 | | Threads_running | 2 | +-------------------+-------+ mysql> show processlist; +----+-----------------+-----------+---------+---------+-------+------------------------+------------------+ | Id | User | Host | db | Command | Time | State | Info | +----+-----------------+-----------+---------+---------+-------+------------------------+------------------+ | 5 | event_scheduler | localhost | NULL | Daemon | 32655 | Waiting on empty queue | NULL | | 8 | isuconp | localhost | isuconp | Sleep | 4698 | | NULL | | 10 | isuconp | localhost | isuconp | Query | 0 | init | show processlist | +----+-----------------+-----------+---------+---------+-------+------------------------+------------------+ ``` #### テーブル一覧、テーブル構造一覧の出力など ``` mysql> show DATABASES; +--------------------+ | Database | +--------------------+ | information_schema | | isucon | | mysql | | performance_schema | | sys | +--------------------+ 5 rows in set (0.00 sec) mysql> show TABLES; +-----------------------------------+ | Tables_in_isucon | +-----------------------------------+ | admin_sessions | | admin_users | | gacha_item_masters | | gacha_masters | | id_generator | | item_masters | | login_bonus_masters | | login_bonus_reward_masters | | present_all_masters | | user_bans | | user_cards | | user_decks | | user_devices | | user_items | | user_login_bonuses | | user_one_time_tokens | | user_present_all_received_history | | user_presents | | user_sessions | | users | | version_masters | +-----------------------------------+ 21 rows in set (0.00 sec) mysql> desc comments; +------------+-----------+------+-----+-------------------+-------------------+ | Field | Type | Null | Key | Default | Extra | +------------+-----------+------+-----+-------------------+-------------------+ | id | int | NO | PRI | NULL | auto_increment | | post_id | int | NO | | NULL | | | user_id | int | NO | | NULL | | | comment | text | NO | | NULL | | | created_at | timestamp | NO | | CURRENT_TIMESTAMP | DEFAULT_GENERATED | +------------+-----------+------+-----+-------------------+-------------------+ ``` #### テーブル一覧(カラム数の表示) ``` mysql> SELECT TABLE_NAME,TABLE_ROWS,AVG_ROW_LENGTH,DATA_LENGTH,MAX_DATA_LENGTH,INDEX_LENGTH from information_schema.TABLES WHERE table_schem a = 'isucon'; +-----------------------------------+------------+----------------+-------------+-----------------+--------------+ | TABLE_NAME | TABLE_ROWS | AVG_ROW_LENGTH | DATA_LENGTH | MAX_DATA_LENGTH | INDEX_LENGTH | +-----------------------------------+------------+----------------+-------------+-----------------+--------------+ | admin_sessions | 2 | 8192 | 16384 | 0 | 16384 | | admin_users | 0 | 0 | 16384 | 0 | 0 | | gacha_item_masters | 1012 | 97 | 98304 | 0 | 81920 | | gacha_masters | 40 | 409 | 16384 | 0 | 0 | | id_generator | 1 | 16384 | 16384 | 0 | 0 | | item_masters | 30 | 546 | 16384 | 0 | 0 | | login_bonus_masters | 4 | 4096 | 16384 | 0 | 0 | | login_bonus_reward_masters | 42 | 390 | 16384 | 0 | 0 | | present_all_masters | 29 | 564 | 16384 | 0 | 0 | | user_bans | 3334 | 63 | 212992 | 0 | 147456 | | user_cards | 722962 | 79 | 57245696 | 0 | 36257792 | | user_decks | 10109 | 157 | 1589248 | 0 | 262144 | | user_devices | 20484 | 128 | 2637824 | 0 | 3178496 | | user_items | 133550 | 74 | 9977856 | 0 | 5783552 | | user_login_bonuses | 31220 | 84 | 2637824 | 0 | 1589248 | | user_one_time_tokens | 20 | 819 | 16384 | 0 | 16384 | | user_present_all_received_history | 241360 | 76 | 18399232 | 0 | 0 | | user_presents | 3332730 | 126 | 421511168 | 0 | 141312000 | | user_sessions | 40 | 409 | 16384 | 0 | 16384 | | users | 13813 | 115 | 1589248 | 0 | 0 | | version_masters | 1 | 16384 | 16384 | 0 | 0 | +-----------------------------------+------------+----------------+-------------+-----------------+--------------+ 21 rows in set (0.00 sec) ``` #### SHOW INDEX(すべてのテーブル) ``` SELECT DISTINCT TABLE_NAME, INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = 'isucon' AND INDEX_NAME != 'PRIMARY'; ``` #### SHOW INDEX ``` mysql> SHOW INDEX FROM user_present_all_received_history; +-----------------------------------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ | Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression | +-----------------------------------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ | user_present_all_received_history | 0 | PRIMARY | 1 | id | A | 240862 | NULL | NULL | | BTREE | | | YES | NULL | +-----------------------------------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ 1 row in set (0.01 sec) ``` #### generated columnの追加 ``` mysql> ALTER TABLE t ADD neg_popularity INT AS (-popularity); mysql> ALTER TABLE t ADD full_name varchar(100) AS (CONCAT(first_name, ' ', last_name)); ``` #### indexの追加 ``` mysql> ALTER TABLE t ADD INDEX idx(col1, col2); mysql> ALTER TABLE t ADD INDEX idx(col1 + col2); mysql> ALTER TABLE t ADD INDEX idx(col(10)); ``` #### Full text indexの追加(フルテキストインデックス) ``` mysql> ALTER TABLE t ADD FULLTEXT INDEX idx(col) WITH PARSER ngram; ``` #### Spatial indexの追加 ``` mysql> ALTER TABLE t ADD point POINT AS (POINT(latitude, longitude)) STORED NOT NULL; mysql> ALTER TABLE t ADD SPATIAL INDEX idx(point); ``` ### おまけ:MySQLのアップデート(5.7 -> 8.0など) ``` $ wget https://dev.mysql.com/get/mysql-apt-config_0.8.22-1_all.deb $ sudo dpkg -i mysql-apt-config_0.8.22-1_all.deb $ sudo apt-get update $ sudo apt-get install mysql-server ``` mysqlがrestartにこける場合は、error.logを見て怒られているdirectiveを消せば良い。 ``` $ sudo less /var/log/mysql/error.log ``` ## カーネルパラメーター ``` $ sudo vim /etc/sysctl.cnf ``` 参考 https://knowledge.sakura.ad.jp/11981/#tcp_tw_reuse ``` # default & my config value #net.ipv4.ip_local_port_range=32768 60999 net.ipv4.ip_local_port_range=10511 65535 #net.ipv4.tcp_max_tw_buckets=4096 net.ipv4.tcp_max_tw_buckets=65536 #net.ipv4.tcp_tw_reuse=0 net.ipv4.tcp_tw_reuse=1 ``` ## 典型的な改善アイディアリスト ### indexを貼る(本当に機能しているか?) - ORが入っているとインデックスは効かない - ORをやめてUNIONで繋ぐとか ``` SELECT * FROM users WHERE (name = '田中' OR name = '佐藤') AND age = 30; ``` ``` (SELECT * FROM users WHERE name = '田中' AND age = 30) UNION ALL (SELECT * FROM users WHERE name = '佐藤' AND age = 30); ``` - whereやorder byは順番通りにインデックスを貼ってないと効かない - `score > 80`のような条件が入っているとインデックスは効かない(`=`で条件を指定する必要がある) ### Bulk Insert(N+1を解消する) ``` type User struct { ID int64 `db:"id"` Name string `db:"name"` Age int64. `db: "age"` } func bulkInsert(users []User) { query := "INSERT INTO user (name, age) VALUES (:name, :age)" _, err := db.NamedExec(query, users) if err != nil { fmt.Println("Error %v", err) } } ``` ### 遅延 Insert(buf | timer) 遅延させてinsertを実行するため、エラーをclientには通知できないことに注意。 ``` func postIsuCondition(c echo.Context) error { conditions := []IsuCondition{} err := c.Bind(&conditions) if err != nil || len(req) == 0 { return c.String(http.StatusBadRequest, "bad request body") } // goroutineで遅延insert go func() { bulkInsert(conditions) }() return c.NoContent(http.StatusAccepted) } func bulkInsert(conditions []IsuCondition) { _, err := db.NamedExec( "INSERT INTO `isu_condition`"+ " (`jia_isu_uuid`, `timestamp`, `is_sitting`, `condition`, `message`, `is_dirty`, `is_overweight`, `is_broken`)"+ " VALUES (:jia_isu_uuid, :timestamp, :is_sitting, :condition, :message, :is_dirty, :is_overweight, :is_broken)", conditions, ) if err != nil { c.Logger().Errorf("db error: %v", err) return } } ``` ``` type ConditionBuffer struct { conditions []IsuCondition mu sync.RWMutex } var buffer := ConditionBuffer{} func postIsuCondition(c echo.Context) error { ... buffer.mu.Lock() buffer.conditions = append(buffer.conditions, conditions) buffer.mu.Unlock() // goroutineで遅延insert(100件以上溜まっていれば) go func() { buffer.mu.Lock() if len(buffer.conditions) > 100 { bulkInsert(buffer.conditions) buffer.conditions = []IsuCondition } buffer.mu.Unlock() }() return c.NoContent(http.StatusAccepted) } func main() { // 500ms毎にbulkInsertを実行(1件でもあれば) go func() { for range time.Tick(time.Millisecond * 500) { buffer.mu.Lock() if len(buffer.conditions) > 0 { bulkInsert(buffer.conditions) buffer.conditions = []IsuCondition } buffer.mu.Unlock() } }() } ``` ### DBにカラムの追加(generated column) - 既存のテーブルを壊さずにテーブル構造を変えてインデックスを効くように変更 ``` ALTER TABLE isu_condition ADD COLUMN `is_dirty` bool NOT NULL DEFAULT false, ADD COLUMN `is_overweight` bool NOT NULL DEFAULT false, ADD COLUMN `is_broken` bool NOT NULL DEFAULT false; UPDATE isu_condition set is_dirty = true where `condition` like "%is_dirty=true%"; UPDATE isu_condition set is_overweight = true where `condition` like "%is_overweight=true%"; UPDATE isu_condition set is_broken = true where `condition` like "%is_broken=true%"; ALTER TABLE isu_condition ADD COLUMN `condition_level` tinyint AS (is_dirty + is_overweight + is_broken); ``` 多分これでも動くはず(試していない) ``` ALTER TABLE isu_condition ADD COLUMN `is_dirty` bool AS (condition like '%is_dirty=true%') STORED NOT NULL, ADD COLUMN `is_overweight` bool AS (`condition` like '%is_overweight=true%') STORED NOT NULL, ADD COLUMN `is_broken` bool AS () STORED NOT NULL, ADD COLUMN `condition_level` tinyint AS (is_dirty + is_overweight + is_broken); ``` ### DBにテーブルを追加(サマリーなど) ### cache (リクエスト時の更新) get onlyな値だったらこれでいいが、updateやdeleteもある場合は、その都度SetやRemoveを呼ばないといけないことに注意。 ``` // Cache type Cache struct { m sync.Map ttl time.Duration } func (c *Cache) Get(key any) (any, error) { v, ok := c.m.Load(key) if ok && !v.(*item).expired() { return v.(*item).data, nil } return nil, errors.New(fmt.Sprintf("Cache not found: %v", key)) } func (c *Cache) Set(key any, value any) { c.m.Store(key, &item{ data: value, expireAt: time.Now().Add(c.ttl), }) } func (c *Cache) Remove(key any) { c.m.Delete(key) } // item type item struct { data any expireAt time.Time } func (item *item) expired() bool { return item.expireAt.Before(time.Now()) } ``` ``` var group singleflight.Group var commentCountCache Cache func initCache() { commentCountCache.m = map[string]*item{} commentCountCache.ttl = 1000 * time.Millisecond } func main() { initCache() } func getCommentCount(postID int) (int, error) { // Get cache key := strconv.Itoa(postID) if v, err := commentCountCache.Get(key); err == nil { return v.(int), nil } // update cache (single flight) groupKey := fmt.Sprintf("getCommentCount.%s", key) v, err, _ := group.Do(groupKey, func() (interface{}, error) { var cnt int err := db.Get(&cnt, "SELECT COUNT(*) AS `count` FROM `comments` WHERE `post_id` = ?", postID) if err == nil { commentCountCache.Set(key) } return cnt, err }) if err != nil { return 0, err } return v.(int), nil } ``` ### cache (定期更新) ``` var trendCache sync.Map func initCache() { trendCache = sync.Map{} } func main() { initCache() // 500ms毎にupdateTrendCacheを実行 go func() { for range time.Tick(time.Millisecond * 500) { updateTrendCache() } }() } func postInitialize() { initCache() } func updateTrendCache() { key := "getTrend" // getTrend()が重いとする value := getTrend() trendCache.Store(key, value) } ``` ### 単一の値をSingleflightでキャッシュ これでサッとcacheできるのいいな ``` var gpaCachedAt time.Time var cachedGPAs []float64 var gpaCalcGroup singleflight.Group fung GetGrades() { // ... now := time.Now() var gpas []float64 if now.Sub(gpaCachedAt) > 900*time.Millisecond || cachedGPAs == nil { gpasIf, err, _ := gpaCalcGroup.Do("gpaCalc", func() (interface{}, error) { var newGPAs []float64 q := "SELECT IFNULL(SUM(`submissions`.`score` * `courses`.`credit`), 0) / 100 / `credits`.`credits` AS `gpa`" + " FROM `users`" + " JOIN (" + " SELECT `users`.`id` AS `user_id`, SUM(`courses`.`credit`) AS `credits`" + " FROM `users`" + " JOIN `registrations` ON `users`.`id` = `registrations`.`user_id`" + " JOIN `courses` ON `registrations`.`course_id` = `courses`.`id` AND `courses`.`status` = ?" + " GROUP BY `users`.`id`" + " ) AS `credits` ON `credits`.`user_id` = `users`.`id`" + " JOIN `registrations` ON `users`.`id` = `registrations`.`user_id`" + " JOIN `courses` ON `registrations`.`course_id` = `courses`.`id` AND `courses`.`status` = ?" + " LEFT JOIN `classes` ON `courses`.`id` = `classes`.`course_id`" + " LEFT JOIN `submissions` ON `users`.`id` = `submissions`.`user_id` AND `submissions`.`class_id` = `classes`.`id`" + " WHERE `users`.`type` = ?" + " GROUP BY `users`.`id`" if err := h.DB.Select(&newGPAs, q, StatusClosed, StatusClosed, Student); err != nil { return nil, err } return newGPAs, nil }) if err != nil { c.Logger().Error(err) return c.NoContent(http.StatusInternalServerError) } gpas = gpasIf.([]float64) cachedGPAs = gpas gpaCachedAt = now // time.Now() にするとリスク高そうなので保守的に } else { gpas = cachededGPAs } // ... } ``` ### 複数の値をsync.MapでCache(w/ Singleflight) flightGrouptは一個で良くないかと思うけど。 ``` // map by course ID var totalScoreCachedAt = sync.Map{} // map[string]time.Time var cachedTotalScore = sync.Map{} // map[string][]int var totalScoreCalcGroup = sync.Map{} // map[string]*singleflight.Group fung GetGrades() { // ... cachedAt, found := totalScoreCachedAt.Load(course.ID) score, found2 := cachedTotalScore.Load(course.ID) if !found || !found2 || now.Sub(cachedAt.(time.Time)) > 100*time.Millisecond { flightIf, found3 := totalScoreCalcGroup.Load(course.ID) var flight *singleflight.Group if !found3 { flight = &singleflight.Group{} } else { flight = flightIf.(*singleflight.Group) } totalScoreCalcGroup.Store(course.ID, flight) totalsIf, err, _ := flight.Do(fmt.Sprintf("totalScore-%s", course.ID), func() (interface{}, error) { var newTotals []int q := "SELECT IFNULL(SUM(`submissions`.`score`), 0) AS `total_score`" + " FROM `users`" + " JOIN `registrations` ON `users`.`id` = `registrations`.`user_id`" + " JOIN `courses` ON `registrations`.`course_id` = `courses`.`id`" + " LEFT JOIN `classes` ON `courses`.`id` = `classes`.`course_id`" + " LEFT JOIN `submissions` ON `users`.`id` = `submissions`.`user_id` AND `submissions`.`class_id` = `classes`.`id`" + " WHERE `courses`.`id` = ?" + " GROUP BY `users`.`id`" if err := h.DB.Select(&newTotals, q, course.ID); err != nil { return nil, err } return newTotals, nil }) if err != nil { c.Logger().Error(err) return c.NoContent(http.StatusInternalServerError) } totals = totalsIf.([]int) cachedTotalScore.Store(course.ID, totals) totalScoreCachedAt.Store(course.ID, now) } else { totals = score.([]int) } // ... } ``` ### nginx cache ``` # メモリは300MBまで使う、60秒でキャッシュの期限は切れる proxy_cache_path /var/cache/nginx keys_zone=CACHE:300m inactive=60s; server { # 初回はproxy_passまで取りに行く # 2回目以降はnginxが直接返す location ~ /api/isu/.*/icon { proxy_pass http://127.0.0.1:3000; proxy_cache CACHE; } } ``` ### client cache クライアント側でcacheしてくれて、そもそもリクエストしてこなくなる。リクエストされても、変更ないですよ〜って返せるようになる。 異なるクライアントは当然リクエストしてくるし、返してあげる必要ある。 ``` server { location ~ /api/isu/.*/icon { # 同じクライアントからの2回目のリクエストが60秒は来ない proxy_pass http://127.0.0.1:3000; expires 60s; } } ``` 静的ファイルがあるかをまず確かめる。あったらそれをnginxガ直接返すし、なければappにリクエストするパターン。 ``` server { location ~ /(image|js|css|img)/ { try_files $uri @app; expires 60s; } location @app { proxy_set_header Host $host; proxy_pass http://app; } } ``` ### N+1を解消する JOINとかで書き換えても時間かかる割にインデックスが効かなくなってしまってあんまりだったりする。 forの中身をcacheするとかが良かったり。 ### LIMITをつける(雑でもいい) 20必要で、でも中には削除済みの投稿もあるからLIMITがついていないとかもある。 LIMITなしだと10000件取ってきちゃう場合、IMIT20にできなくてもLIMIT100でも意味はある。オーダーが変わるならとりあえず雑にでもLIMITつけよう。 ### `SELECT *`をやめる インデックスは効いてるのに、データ量が多くて(バイナリデータが含まれているなど)転送量が多い時(I/Oのせいで遅い時) ### app専用サーバー ### db専用サーバー ### 2台目のdb専用サーバー ### 外部コマンドの書き換え ```diff= import ( + "crypto/sha512" + "encoding/hex" ) func digest(src string) string { - out, err := exec.Command("/bin/bash", "-c", `printf -"%s" `+escapeshellarg(src)+` | openssl dgst -sha512 | -sed 's/^.*= //'`).Output() - if err != nil { - log.Print(err) - return "" - } - - return strings.TrimSuffix(string(out), "\n") + out := sha512.Sum512([]byte(src)) + return hex.EncodeToString(out[:]) } ``` ### 外部コマンドの書き換え(cp, zip) 日本語がファイル名に含まれる場合、自分でheaderを作らないといけなくて少し面倒。 ```diff func createSubmissionsZip(zipFilePath string, classID string, submissions []Submission) error { - tmpDir := AssignmentsDirectory + classID + "/" - if err := exec.Command("rm", "-rf", tmpDir).Run(); err != nil { - return err - } - if err := exec.Command("mkdir", tmpDir).Run(); err != nil { - return err - } - // ファイル名を指定の形式に変更 - for _, submission := range submissions { - if err := exec.Command( - "cp", - AssignmentsDirectory+classID+"-"+submission.UserID+".pdf", - tmpDir+submission.UserCode+"-"+submission.FileName, - ).Run(); err != nil { - return err - } - } - // -i 'tmpDir/*': 空zipを許す - return exec.Command("zip", "-j", "-r", zipFilePath, tmpDir, "-i", tmpDir+"*").Run() + archive, err := os.Create(zipFilePath) + if err != nil { + panic(err) + } + defer archive.Close() + + zw := zip.NewWriter(archive) + defer zw.Close() + + for _, submission := range submissions { + info, err := os.Stat(AssignmentsDirectory + classID + "-" + submission.UserID + ".pdf") + if err != nil { + return err + } + + hdr, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + hdr.Name = submission.UserCode + "-" + submission.FileName + + zh, err := zw.CreateHeader(hdr) + if err != nil { + return err + } + + f, err := os.Open(AssignmentsDirectory + classID + "-" + submission.UserID + ".pdf") + if err != nil { + return err + } + + _, err = io.Copy(zh, f) + if err != nil { + return err + } + f.Close() + } + return nil } ``` ### 外部APIの書き換え ### 外部APIからのデータ取得を、キャッシュしてDBからのDB取得に置き換え ### 複数の外部API呼び出しを非同期で並列処理化 sync.WaitGroupやchanを使って複数のAPIを同時に実行し、待ち合わせをする。 ``` func postBuy() { ... // 1. APIShipmentCreate var scr *APIShipmentCreateRes var scrErr error var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() scr, scrErr = APIShipmentCreate(getShipmentServiceURL(), &APIShipmentCreateReq{ ToAddress: buyer.Address, ToName: buyer.AccountName, FromAddress: seller.Address, FromName: seller.AccountName, }) }() // 2. APIPaymentToken pstr, err := APIPaymentToken(getPaymentServiceURL(), &APIPaymentServiceTokenReq{ ShopID: PaymentServiceIsucariShopID, Token: rb.Token, APIKey: PaymentServiceIsucariAPIKey, Price: targetItem.Price, }) if err != nil { log.Print(err) outputErrorMsg(w, http.StatusInternalServerError, "payment service is failed") tx.Rollback() return } wg.Wait() } ``` ### 静的ファイル化 ``` func writeImage(id int, mime string, data []byte) { var ext string switch mime { case "image/jpeg": ext = ".jpg" case "image/png": ext = ".png" case "image/gif": ext = ".gif" default: fmt.Println("Failed to write file: ", id, mime) return } file_name := fmt.Sprintf("../public/image/%d%s", id, ext) f, err := os.OpenFile(file_name, os.O_WRONLY|os.O_CREATE, 0666) defer f.Close() if err != nil { panic(err) } f.Write(data) } func postIndex(w http.ResponseWriter, r *http.Request) { file, header, err := r.FormFile("file") ... filedata, err := io.ReadAll(file) ... writeImage(int(pid), mime, filedata) } func getImage(w http.ResponseWriter, r *http.Request) { ... err = db.Get(&post, "SELECT * FROM `posts` WHERE `id` = ?", pid) ... writeImage(pid, post.Mime, post.Imgdata) } ``` ### 絞り込みをappからmysqlで行うように変更する - LIMITをつける - whereで条件を絞り込む - カラムを追加する ### templateのコンパイルを一度だけ実行に変更 ``` ``` ### initializeを並列化 sync.WaitGroupやchanを使って複数の処理を同時に実行し、待ち合わせをする。 ``` func initialize(c echo.Context) error { initChan := make(chan int, 2) initf := func (paths []string, data *MySQLConnectionEnv) { for _, p := range paths { sqlFile, _ := filepath.Abs(p) cmdStr := fmt.Sprintf("mysql -h %v -u %v -p%v -P %v %v < %v", data.Host, data.User, data.Password, data.Port, data.DBName, sqlFile, ) if err := exec.Command("bash", "-c", cmdStr).Run(); err != nil { c.Logger().Errorf("Initialize script error : %v", err) initChan <- 1 } } initChan <- 0 } sqlDir := filepath.Join("..", "mysql", "db") go initf( []string{ filepath.Join(sqlDir, "0_Schema.sql"), filepath.Join(sqlDir, "2_DummyChairData.sql"), filepath.Join(sqlDir, "3_AlterTable.sql"), }, mySQLConnectionData) go initf( []string{ filepath.Join(sqlDir, "0_Schema.sql"), filepath.Join(sqlDir, "1_DummyEstateData.sql"), filepath.Join(sqlDir, "3_AlterTable.sql"), }, mySQLConnectionData2) cnt := 0 for { select { case i := <-initChan: if i == 1 { return c.NoContent(http.StatusInternalServerError) } } cnt += 1 if cnt == 2 { break } } return c.JSON(http.StatusOK, InitializeResponse{ Language: "go", }) } ``` ### appログの無効化 ``` func main() { // echoの時 // Loggerは使わない // e.Use(middleware.Logger()) e.Logger.SetLevel(log.FATAL) // いつでも log.SetFlags(0) log.SetOutput(ioutil.Discard) } ``` ### mysqlログの無効化 ### nginxログの無効化 ## おまけ:簡易ベンチの実装方法 ### GET / ``` # 5秒間リクエストを送り続ける $ ab -t 5 http://localhost/ # 1回だけリクエストを送る $ ab -n 1 http://localhost/ $ curl http://localhost/ ``` ### POST /comment `comment.txt` ``` comment=fuga2&post_id=10000&csrf_token=ab201eb2537426de50b1293e6fd6213c&submit=submit ``` dataやcookieはブラウザのインスペクタのNetworkからCopy as cURLでとってくるのがラク ![](https://i.imgur.com/KuHUzXE.png) ``` $ ab -t 5 \ -T "application/x-www-form-urlencoded" \ -C rack.session=8c12f8d877c738e6319cad06b33ca5053a1e045a9767b27b020a24b91d09bfb2 \ -p comment.txt \ http://localhost/comment $ curl 'http://35.77.229.7/comment' \ -H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Cookie: rack.session=8c12f8d877c738e6319cad06b33ca5053a1e045a9767b27b020a24b91d09bfb2' \ --data-raw 'comment=fuga2&post_id=10000&csrf_token=ab201eb2537426de50b1293e6fd6213c&submit=submit' ``` ## そのほか使うコマンド ### カーネルパラメーターを確認する ``` $ cat /proc/{pid}/limits ``` ``` $ ps aux | grep mysql mysql 735 0.8 6.0 2570164 471540 ? Ssl Jul18 5:30 /usr/sbin/mysqld $ cat /proc/735/limits ``` ## 初期設定用 ### vim最新版とCopilot vim 最新版のvimをインストールして、Copilot vimを設定する https://itsfoss.com/install-latest-vim-ubuntu/ ``` # nodeが使えるようにPATHを設定 $ vim ~/.zshrc export PATH=$HOME/local/node/bin:$HOME/local/go/bin:$HOME/go/bin:$PATH # Copilot vimをインストール $ git clone https://github.com/github/copilot.vim.git ~/.vim/pack/github/start/copilot.vim # Copilot vimを初期設定 $ vim :Copilot setup ```