mercari.go #4 https://mercari.connpass.com/event/105640/
- catatsuyというIDで各種SNS活動しています
- かたついって呼ばれています
- メルカリのSREチームで主にGoを書いています
- 前職はピクシブでpixivのHTTPS化・PHP7.1化・HTTP/2化や、広告サーバーの新機能追加など色々やっていました
- ISUCON大好き
- ISUCON4:2位(初出場)
- ISUCON5:8位
- ISUCON6:運営
- ISUCON7:予選敗退
- ISUCON8:3位
他にもpixiv社内ISUCONを作りました
- 『お題となるWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトル』
- ISUCONの初期実装ではアプリケーションの実装自体に問題があることがほとんど
- 複数の言語実装が用意されている
- ISUCON8ではGo/Node.js/Perl/PHP/Python/Rubyが用意(Node.jsは予選のみ)された
- 基本的に3人チームで戦う
- 予選上位者が本選に進め、本選で順位が決定する
- 10時開始、18時終了
- ライブラリの内部実装は言語により異なるので、ISUCONにおいて全言語を同じ実装をすることは不可能
encoding/json
やhtml/template
などはPHPなどに性能で負けることもあるので、必ずしもGoだから早いということはない- ここ数年のISUCONの実装は時間内に問題のある実装を直しきるのが難しくなっている
- 単純にアプリケーションのCPU使用率を下げて勝てる大会ではない
- シングルスレッド・マルチプロセスが一般的な他言語実装とは違い、シングルプロセス・マルチスレッドのアーキテクチャなので、他の言語が容易に取れないアプローチも取れるため、その点は有利なことがある
- インメモリキャッシュやgoroutineによる処理のバックグラウンド実行など
- 言語毎の有利不利の議論は割と荒れがちなので、今回はこれ以上議論しません
- 今回はGo言語でISUCONを参加する前提
- チームメンバーが使うGoのバージョンは事前に合わせておく
- パフォーマンス面での改善も入っているので基本的には最新を使う
- goimportsを使うとライブラリを自動でimportしてくれるので便利だが、importの順番が変わる
goimports
を使うメンバーと使わないメンバーがいると毎回コードの差分が発生するので事前に統一する
- 以前ブログを書いた
- 手元でLinux用のバイナリをビルドしてscpするのが楽なので推奨
- ISUCON8本選はDockerを使っていたのでこの手法は簡単にはできなかった
- 自作ツールのnotify_slackを使うと簡単にSlackに通知できるのでおすすめ
- 標準出力をいい感じにSlackに通知できる
- ISUCONの情報共有にはこれ!notify_slack!/isucon_notify_slack - Speaker Deck
- チームのサーバーのIPアドレス管理は面倒なので、いい感じの
~/.ssh/config
を最初に作ってチーム内で共有しておく- 以下のスクリプトは
isu01
isu02
isu03
というHostの設定を各自書いている前提のスクリプト
- 以下のスクリプトは
- スピードが重要なのでとにかくシンプルにやるのがおすすめ
deploy.sh
#!/bin/bash -x
./deploy_body.sh | notify_slack
deploy_body.sh
#!/bin/bash -x
echo "start deploy ${USER}"
GOOS=linux go build -o app src/isucon/app.go
for server in isu01 isu02 isu03; do
ssh -t $server "sudo systemctl stop isucon.golang.service"
scp ./app $server:/home/isucon/webapp/go/isucon
# templateは別途rsyncする必要がある
rsync -av ./src/isucon/views/ $server:/home/isucon/webapp/go/src/isucon/views/
ssh -t $server "sudo systemctl start isucon.golang.service"
done
echo "finish deploy ${USER}"
- Goのsliceとmapはthread safeではない
- Goの
net/http
を使って起動するHTTPサーバーは1request毎にgoroutineを起動する - リクエスト間で共有するsliceやmapへの書き込みや読み込みを行ってはいけない
- Goの
- sliceかmapを持つ構造体を作って、
sync
パッケージを使って書き込み中に参照がされないことを保証する必要がある- 基本的には
sync.Mutex
、書き込みはたまにで、圧倒的に参照が多い場合はsync.RWMutex
を使うのがおすすめ
- 基本的には
- 数回実際に書いてみると普通に書けるようになるはず
- 事前に練習しておきましょう
注:deferを使うと少しオーバーヘッドがあるので、deferを使わない実装にしてある
type cacheSlice struct {
// Setが多いならsync.Mutex
sync.RWMutex
items map[int]int
}
func NewCacheSlice() *cacheSlice {
m := make(map[int]int)
c := &cacheSlice{
items: m,
}
return c
}
func (c *cacheSlice) Set(key int, value int) {
c.Lock()
c.items[key] = value
c.Unlock()
}
func (c *cacheSlice) Get(key int) (int, bool) {
c.RLock()
v, found := c.items[key]
c.RUnlock()
return v, found
}
func (c *cacheSlice) Incr(key int, n int) {
c.Lock()
v, found := c.items[key]
if found {
c.items[key] = v + n
} else {
c.items[key] = n
}
c.Unlock()
}
var mCache = NewCacheSlice()
ログ分析APIをsend_bulk化するコードの一部(簡略版)
https://github.com/catatsuy/isucon8-final/pull/14/files
type cacheLog struct {
sync.Mutex
items []isulogger.Log
}
func NewCacheLog() *cacheLog {
m := make([]isulogger.Log, 0, 100)
c := &cacheLog{
items: m,
}
return c
}
func (c *cacheLog) Append(value isulogger.Log) {
c.Lock()
c.items = append(c.items, value)
c.Unlock()
}
func (c *cacheLog) Rotate() []isulogger.Log {
c.Lock()
tmp := c.items
c.items = make([]isulogger.Log, 0, 100)
c.Unlock()
return tmp
}
var mCacheLog = NewCacheLog()
func SetDB(d QueryExecutor) {
c := time.Tick(1 * time.Second)
go func() {
for {
ls := mCacheLog.Rotate()
// 外部APIにlsをsend_bulkで送る
<-c
}
}()
}
- この実装をGo以外でやる場合は、Redisとかでジョブキューみたいな仕組みを作るしかなさそう
- Goならgoroutineの中で無限ループを書くことで簡易的なデーモンをアプリケーション内部で作ることができる
- この実装がこのレベルのコードで実現できるのは他の言語から見ると驚異的だと思う
Go Slices: usage and internals - The Go Blog
基本的なことは↑のリンクに書いてあるので、以下ざっくり説明と補足
- スライスは内部に配列とlenとcapを持つ
- capは内部配列のサイズで、lenはスライスの長さ
- lenがcapを超えるタイミングで新しい内部配列を作成し、元の内部配列の内容をコピーする
- この処理は一般的に重い処理で、避けるべき
- make関数は第2引数と第3引数があり、第2引数はlen、第3引数はcapを渡すが、第3引数を省略した場合はcapとlenは同一になる
make([]int, 0)
はlenとcapが0のintのスライスが作られる- ISUCONの初期実装ではlenとcapがともに0のスライスに必要なデータをappendしていく実装が一般的
package main
import (
"fmt"
)
func main() {
a := make([]int, 0)
prev := -1
for i := 0; i < 10000; i++ {
if prev != cap(a) {
prev = cap(a)
fmt.Println(cap(a))
}
a = append(a, i)
}
}
Go1.11.2の実行結果
0
1
2
4
8
16
32
64
128
256
512
1024
1280
1696
2304
3072
4096
5120
7168
9216
12288
https://github.com/golang/go/blob/master/src/runtime/slice.go#L95-L114
- 1024以下なら現在のcapの大体2倍に増やす
- capが0のスライスにappendするとcapが1のスライスになる
- capが0のスライスにappendを繰り返すコードを書くと、小さい内部配列を作っては破棄することになる
- 必要なデータをドンドンappendするコードになっているならlenは0以外あり得ない
- len分がゼロ値で埋まり、その後ろにappendすることになる
- 最終的なサイズが分かっているなら、capはそのサイズを指定するべき
- 最終的なサイズはISUCONだと分からないことが多いが、0だとさすがにつらいのでそれっぽい数字を指定するとマシになる
- ここ数年のISUCONはGoのインメモリキャッシュ戦略を取らせないためにサーバーのメモリは小さくされている
- 大きなcapを指定するとメモリが不足する可能性がある
- ISUCONではある程度の内部配列の生成・破棄は許容する方針にすべき
- マイクロな最適化なのでここに時間を使いすぎないこと
- Goのstringはimmutableなbyte型のスライスと考えるとわかりやすい
- 文字列結合を繰り返すとそれだけ内部配列の作成と破棄を繰り返す
- byte型のスライスを適切なcapで作り、最後にstringにキャストするコードにすると避けられる
- 同様の理由で
strings.Replace
を複数回実行するよりもstrings.NewReplacer
を使った方が早いstrings
パッケージは色々な便利関数があるので一度確認するのがおすすめ
b := make([]byte, 0, 40)
b = append(b, str...)
b = append(b, ' ')
b = time.Now().AppendFormat(b, "2006-01-02 15:04:05.999999999 -0700 MST")
r = string(b)
r := strings.NewReplacer("<", "<", ">", ">")
fmt.Println(r.Replace("This is <b>HTML</b>!"))
- pprofでプロファイリングを取ることができる
- cpuのprofiling結果に
runtime.mallocgc
が多い場合は小さいメモリのアロケートが多い可能性がある
参考URL
- Debugging performance issues in Go programs | Intel® Software
- High Performance Go
- Profiling Go Programs - The Go Blog
補足
- 一般的な開発では
testing
パッケージのtesting.B
と-benchmem
オプションを使えば、テスト対象の関数のメモリのアロケート回数が分かるのでそれを使うとよい- ISUCONでは時間内に最適化を行う必要があるので、関数毎のマイクロベンチを取る余裕はない
- ISUCONでは遅い関数を最適化するのではなく、ベンチマーカーが頻繁に叩いてくる関数を効率よく最適化すべきなので、pprofを有効にしてからベンチマーカーを流すのが楽で最適
- pprofではネットワークで待ちになっている時間などは顕在化しないので、ボトルネックがアプリケーションのCPUに移らない限り、取る意味はほぼない
- MySQLのスロークエリが残っている状況ならそのチューニングに時間を割くべき
github.com/pkg/profile
を使うと楽
import "github.com/pkg/profile"
// main()の中で
defer profile.Start().Stop()
// ファイル名を指定する
defer profile.Start(profile.ProfilePath("/home/isucon/profile")).Stop()
// memoryのプロファイリングをしたいとき
defer profile.Start(profile.MemProfile).Stop()
- デフォルトは
ioutil.TempDir("", "profile")
で指定されたディレクトリにファイルができる- 環境変数
TMPDIR
にもよるが、Linuxなら/tmp/profile/cpu.pprof
というファイルができるはず - systemdなら
PrivateTmp
がデフォルトで有効なので注意
- 環境変数
- 空ファイルができた場合は
.Stop()
が呼ばれていない(つまりdeferが呼ばれていない)- Goは
os.Exit
を呼び出すとdeferを呼ばずに終了する signal.Notify
でシグナルをハンドリングしていない場合、SIGTERMなどを送るとdeferが呼ばれずに終了するprofile.NoShutdownHook
という設定もあるが、これはSIGINTのみをハンドリングするsignal.Notify
自体は複数回呼び出すことは許容されているが、ややこしくなるので自分でシグナルをハンドリングするか、.Start()
の返り値を変数に入れておき、特定のルーティングで.Stop()
を呼び出すようにすることを個人的に推奨
- Goは
graphviz
をインストールしてからgo tool pprof --pdf /tmp/profile/cpu.pprof > profiling.pdf
するとプロファイリング結果がPDFとして出力される- Go1.8以下の場合バイナリを指定する必要があった
go tool pprof --pdf app /tmp/profile/cpu.pprof > profiling.pdf
- Linuxで作ったpprofのファイルをMacで解析することも自分の手元ではできた
- Go1.8以下の場合バイナリを指定する必要があった
- ある程度最適化をした自チームのpprof(実際に大会中に取ったもの)
- アプリケーションのCPUは圧倒的にパスワードのハッシュ化に使われている
- この時点ではMySQLのCPU使用率を減らし切れていなかったので着手しなかった
- MySQLのCPU使用率を減らせたら次に着手したと思う
- pprofではCPU時間をどれだけ使ったかしか出せず、ネットワークの待ちになっているコストなどは分からない
- CPUにボトルネックが移るまで真価を発揮できない
- Google StackdriverやAWS X-Rayなどでアプリケーションのプロファイリングが可能
- AWS X-Ray による ISUCON8 本選問題の解析 - 酒日記 はてな支店
- ISUCONのレギュレーションでは監視やモニタリングに外部サービスを利用することは禁止されていない
- 例年のISUCONのベンチマーカーは約1分間負荷をかけてくる
- それ以上だとスコアを出すのに時間がかかりすぎるし、それ以下だと安定したスコアを出すのが難しい
- 分解能が1分のサービスだとISUCONではボトルネックを見つけきれないと思う
- Datadogは分解能が1秒らしいので使えるかも?
- 競技の時間内に有用なデータを外部サービスで出すのは難しい可能性があるので、自分のチームでは今まで使ったことはない
- 下手にハマると時間を浪費しかねない
- リクエストの度にtemplateを毎回Parseする実装はダメで、グローバル変数を定義して起動時にParseを済ませておく
- ただし
template.FuncMap
を使っている場合はParseする前に呼び出す必要がある template.FuncMap
はテンプレート内で独自の関数を呼び出す際に必要で、移植元の参考実装が使用している場合、実装を合わせるために利用される- ISUCONの問題はGo以外の他言語で初期実装が作られてからGo実装が作られることが多いため、初期実装に使われている可能性は十分ある
- ただし
- ISUCON5予選ではリクエストの度に内容が変わる関数を
template.FuncMap{}
経由で渡していたため、起動時にParseすることが不可能だった- https://github.com/isucon/isucon5-qualify/blob/master/webapp/go/app.go#L234-L268
- 関数の実行結果を変数で渡すようにするなどして該当関数を排除しなければ、起動時にParseすることができない
- テンプレート内の内容を書き換えるのを時間内に行うのは難しいことが多いと思う
- ISUCON5予選はRubyで書かれた参考実装をGoに移植したため、Goとしては無理のある実装になっている
- 起動時にParseしたテンプレートを使うには
.ExecuteTemplate
を使う.ExecuteTemplate
に渡すのはtemplateの名前.ParseFiles
を使った場合、名前はファイル名になる(ディレクトリ名は含まない)- テンプレート内で
{{template}}
を使用することでテンプレートを呼び出すこともできる .ParseFiles
にはアプリケーション内で使うすべてのテンプレートのファイルを渡しておく
var templates *template.Template
func init() {
// FuncMapを使わない場合
templates = template.Must(template.ParseFiles("templates/edit.html", "templates/view.html"))
// FuncMapを使う場合
fmap := template.FuncMap{}
templates = template.Must(template.New("").Funcs(fmap).ParseFiles("templates/edit.html", "templates/view.html"))
}
func main() {
// ...
err := templates.ExecuteTemplate(w, "view.html", struct{}{})
}
参考URL
- 世間的にはテンプレートの組み立てはJavaScript側で行い、Webサーバー側ではJSONしか返さない構成が一般的になっている
- その流れをくみ、ISUCONでも最近はHTMLは単純なものしか返さず、問題になるのはJSON APIであることが多い
- テンプレートを複雑にすると他言語への移植の難易度がかなり上がるため、運営の都合上も基本的には避けたい
- ただしテンプレートが課題になりうるのはGo言語特有の問題と思われるので、対策方法を知らないと課題になった時に解決できない
- 運営側が意図的にGo言語を不利にしようとした場合に課題になる可能性はある
- 過去のISUCONではMySQL以外のDBを使っていた問題も出ている
- ISUCON5本選のPostgreSQLやISUCON4本選のRedisなど
- 必ずMySQLというわけではないが、MySQLが中心なので対策は必要
- Goの場合、MySQLへのコネクションはデフォルトは無限になっているので制限するべき
- github.com/shogo82148/go-sql-proxyを使うと発行したクエリを簡単に確認できるようになるので、N+1クエリも簡単に見つけられる
- 開発時だけ有効にするのがおすすめ
- Go言語でSQLのトレースをする
db, _ = sql.Open("mysql", dsn)
maxConns := os.Getenv("DB_MAXOPENCONNS")
if maxConns != "" {
i, err := strconv.Atoi(maxConns)
if err != nil {
panic(err)
}
db.SetMaxOpenConns(i)
db.SetMaxIdleConns(i)
}
var isDev bool
if os.Getenv("DEV") == "1" {
isDev = true
}
var err error
if isDev {
proxy.RegisterTracer()
db, err = sql.Open("mysql:trace", dsn)
} else {
db, err = sql.Open("mysql", dsn)
}
- prepared statementの利用には静的プレースホルダと動的プレースホルダの2つがある
- 静的プレースホルダはプレースホルダ付きのクエリをMySQLに投げてクエリの解析などの実行準備を行った後に、バインド値をMySQLに送って実際に実行する
- 1つのクエリを実行するのに2往復の通信が必要
- 同じクエリを何回も実行する場合、クエリの解析を省略できるのでパフォーマンス的にうれしいが、実際のWebアプリケーションで活かせるケースは少ない
- 動的プレースホルダはアプリケーションのライブラリ内でパラメータを適切にエスケープしてクエリを実行する
- ライブラリのバグがなければSQLインジェクションなどの脆弱性は発生しない
- 通信回数を減らせるので、MySQLとアプリケーションが別サーバーになっている場合は特にパフォーマンス上の利点がある
- PHPではこちらがデフォルト
- Goでは静的プレースホルダがデフォルトだが、dsnに
interpolateParams=true
を付与することで動的プレースホルダに変更できる
参考URL
- 昨今のマイクロサービスの流行に伴い、他サービスにリクエストを飛ばすサービスの最適化を問う問題も出ている
- ISUCON8本選、ISUCON5本選
- ISUCON6予選でも出ていたが、マイクロサービスを辞めることが前提の問題だったので今回は除外
http.Client
を都度作成するのではなく、グローバル変数に持って使い回す- 内部の
http.Transport
を使い回さないとTCPコネクションを都度貼ってしまう - 複数のgoroutineから利用しても安全
- 内部の
http.Get
などは内部的にグローバル変数のhttp.DefaultClient
を使い回す構成になっている- 大量のリクエストを外部サービスに送らないなら
http.Get
のままが無難
- 大量のリクエストを外部サービスに送らないなら
- デフォルトだと同一ホストへのコネクション数は
http.DefaultMaxIdleConnsPerHost
の2に制限されている- 他サービスに大量のリクエストを送る必要がある場合は大きくした方がよい
MaxIdleConns
(default: 100)とIdleConnTimeout
(default: 90s)もいじった方が良い可能性がある- 最適な値は問題や状況により異なる
- デフォルトだと
http.Client
のTimeout
は無限になっているので、制限した方が安全- いくつかタイムアウトの設定があるので適宜設定する
- レスポンスを受け取ったら必ずBodyをCloseする
- Closeを忘れるとTCPコネクションが再利用されない
- (ISUCONではあまりないと思うが)
res.Body
をReadせずにCloseするとコネクションが切断されるので、ioutil.ReadAll
などを使って読み切る - 本来はISUCONの初期実装で実装されているはずだが、初期実装がバグっている可能性もあるので確認すること
var (
IsuconClient http.Client
)
func init() {
IsuconClient = http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 500,
MaxIdleConnsPerHost: 200,
IdleConnTimeout: 120 * time.Second,
},
}
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
_, err = ioutil.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
参考URL
- Goでnet/httpを使う時のこまごまとした注意 - Qiita
- The complete guide to Go net/http timeouts
- Accelerating real applications in Go
- 実際にはISUCONで高スコアを取るにはアプリケーションのロジックを変更するしかない
- アプリケーションのCPU使用率を下げて勝てる大会ではない
- 今回の内容を把握していても勝てない
- ISUCONはWebアプリケーション開発の深く広い知識が問われる大会
- だからこそ簡単ではないし、おもしろい
- 今回の発表はISUCONに特化しましたが、実際の開発にも活かせる知見のはずです
- これからもISUCONが続いて欲しい