Skip to content

Instantly share code, notes, and snippets.

@catatsuy
Last active November 8, 2024 07:50
Show Gist options
  • Save catatsuy/e627aaf118fbe001f2e7c665fda48146 to your computer and use it in GitHub Desktop.
Save catatsuy/e627aaf118fbe001f2e7c665fda48146 to your computer and use it in GitHub Desktop.

GoでISUCONを戦う話

mercari.go #4 https://mercari.connpass.com/event/105640/

自己紹介

  • catatsuyというIDで各種SNS活動しています
    • かたついって呼ばれています
  • メルカリのSREチームで主にGoを書いています
    • 前職はピクシブでpixivのHTTPS化・PHP7.1化・HTTP/2化や、広告サーバーの新機能追加など色々やっていました
  • ISUCON大好き

私とISUCON

  • ISUCON4:2位(初出場)
  • ISUCON5:8位
  • ISUCON6:運営
  • ISUCON7:予選敗退
  • ISUCON8:3位

他にもpixiv社内ISUCONを作りました

ISUCONとは

  • 『お題となるWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトル』
  • ISUCONの初期実装ではアプリケーションの実装自体に問題があることがほとんど
  • 複数の言語実装が用意されている
    • ISUCON8ではGo/Node.js/Perl/PHP/Python/Rubyが用意(Node.jsは予選のみ)された
  • 基本的に3人チームで戦う
  • 予選上位者が本選に進め、本選で順位が決定する
  • 10時開始、18時終了

Goの方が有利か

  • ライブラリの内部実装は言語により異なるので、ISUCONにおいて全言語を同じ実装をすることは不可能
  • encoding/jsonhtml/templateなどはPHPなどに性能で負けることもあるので、必ずしもGoだから早いということはない
  • ここ数年のISUCONの実装は時間内に問題のある実装を直しきるのが難しくなっている
    • 単純にアプリケーションのCPU使用率を下げて勝てる大会ではない
  • シングルスレッド・マルチプロセスが一般的な他言語実装とは違い、シングルプロセス・マルチスレッドのアーキテクチャなので、他の言語が容易に取れないアプローチも取れるため、その点は有利なことがある
    • インメモリキャッシュやgoroutineによる処理のバックグラウンド実行など
  • 言語毎の有利不利の議論は割と荒れがちなので、今回はこれ以上議論しません
    • 今回はGo言語でISUCONを参加する前提

チーム戦でGoを使うために

  • チームメンバーが使うGoのバージョンは事前に合わせておく
    • パフォーマンス面での改善も入っているので基本的には最新を使う
  • goimportsを使うとライブラリを自動でimportしてくれるので便利だが、importの順番が変わる
    • goimportsを使うメンバーと使わないメンバーがいると毎回コードの差分が発生するので事前に統一する
  • 以前ブログを書いた

デプロイ

  • 手元でLinux用のバイナリをビルドしてscpするのが楽なので推奨
    • ISUCON8本選はDockerを使っていたのでこの手法は簡単にはできなかった
  • 自作ツールのnotify_slackを使うと簡単にSlackに通知できるのでおすすめ
  • チームのサーバーの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への書き込みや読み込みを行ってはいけない
  • 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()

ISUCON8本選で実際に使ったインメモリキャッシュ実装

ログ分析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の中で無限ループを書くことで簡易的なデーモンをアプリケーション内部で作ることができる
    • この実装がこのレベルのコードで実現できるのは他の言語から見ると驚異的だと思う

スライスのlenとcap

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していく実装が一般的

capが0のスライスの内部配列はどう大きくなるか

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

sliceのlenとcapはどうするべきか

  • 必要なデータをドンドンappendするコードになっているならlenは0以外あり得ない
    • len分がゼロ値で埋まり、その後ろにappendすることになる
  • 最終的なサイズが分かっているなら、capはそのサイズを指定するべき
    • 最終的なサイズはISUCONだと分からないことが多いが、0だとさすがにつらいのでそれっぽい数字を指定するとマシになる
    • ここ数年のISUCONはGoのインメモリキャッシュ戦略を取らせないためにサーバーのメモリは小さくされている
    • 大きなcapを指定するとメモリが不足する可能性がある
    • ISUCONではある程度の内部配列の生成・破棄は許容する方針にすべき
    • マイクロな最適化なのでここに時間を使いすぎないこと

stringの問題

  • 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("<", "&lt;", ">", "&gt;")
fmt.Println(r.Replace("This is <b>HTML</b>!"))

小さいメモリのアロケートと破棄を見つける方法

  • pprofでプロファイリングを取ることができる
  • cpuのprofiling結果にruntime.mallocgcが多い場合は小さいメモリのアロケートが多い可能性がある

参考URL

補足

  • 一般的な開発ではtestingパッケージのtesting.B-benchmemオプションを使えば、テスト対象の関数のメモリのアロケート回数が分かるのでそれを使うとよい
    • ISUCONでは時間内に最適化を行う必要があるので、関数毎のマイクロベンチを取る余裕はない
    • ISUCONでは遅い関数を最適化するのではなく、ベンチマーカーが頻繁に叩いてくる関数を効率よく最適化すべきなので、pprofを有効にしてからベンチマーカーを流すのが楽で最適
  • pprofではネットワークで待ちになっている時間などは顕在化しないので、ボトルネックがアプリケーションのCPUに移らない限り、取る意味はほぼない
    • MySQLのスロークエリが残っている状況ならそのチューニングに時間を割くべき

pprofの取り方

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()を呼び出すようにすることを個人的に推奨
  • 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で解析することも自分の手元ではできた

ISUCON8本選のpprof

  • ある程度最適化をした自チームのpprof(実際に大会中に取ったもの)
  • アプリケーションのCPUは圧倒的にパスワードのハッシュ化に使われている
    • この時点ではMySQLのCPU使用率を減らし切れていなかったので着手しなかった
    • MySQLのCPU使用率を減らせたら次に着手したと思う

【余談】外部サービスを利用する

  • pprofではCPU時間をどれだけ使ったかしか出せず、ネットワークの待ちになっているコストなどは分からない
    • CPUにボトルネックが移るまで真価を発揮できない
  • Google StackdriverやAWS X-Rayなどでアプリケーションのプロファイリングが可能
  • 例年のISUCONのベンチマーカーは約1分間負荷をかけてくる
    • それ以上だとスコアを出すのに時間がかかりすぎるし、それ以下だと安定したスコアを出すのが難しい
    • 分解能が1分のサービスだとISUCONではボトルネックを見つけきれないと思う
    • Datadogは分解能が1秒らしいので使えるかも?
  • 競技の時間内に有用なデータを外部サービスで出すのは難しい可能性があるので、自分のチームでは今まで使ったことはない
    • 下手にハマると時間を浪費しかねない

templateの使い方

  • リクエストの度に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

【余談】templateが今後課題になるのか

  • 世間的にはテンプレートの組み立てはJavaScript側で行い、Webサーバー側ではJSONしか返さない構成が一般的になっている
    • その流れをくみ、ISUCONでも最近はHTMLは単純なものしか返さず、問題になるのはJSON APIであることが多い
  • テンプレートを複雑にすると他言語への移植の難易度がかなり上がるため、運営の都合上も基本的には避けたい
  • ただしテンプレートが課題になりうるのはGo言語特有の問題と思われるので、対策方法を知らないと課題になった時に解決できない
    • 運営側が意図的にGo言語を不利にしようとした場合に課題になる可能性はある

GoとMySQL

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について

  • prepared statementの利用には静的プレースホルダと動的プレースホルダの2つがある
  • 静的プレースホルダはプレースホルダ付きのクエリをMySQLに投げてクエリの解析などの実行準備を行った後に、バインド値をMySQLに送って実際に実行する
    • 1つのクエリを実行するのに2往復の通信が必要
    • 同じクエリを何回も実行する場合、クエリの解析を省略できるのでパフォーマンス的にうれしいが、実際のWebアプリケーションで活かせるケースは少ない
  • 動的プレースホルダはアプリケーションのライブラリ内でパラメータを適切にエスケープしてクエリを実行する
    • ライブラリのバグがなければSQLインジェクションなどの脆弱性は発生しない
    • 通信回数を減らせるので、MySQLとアプリケーションが別サーバーになっている場合は特にパフォーマンス上の利点がある
    • PHPではこちらがデフォルト
  • Goでは静的プレースホルダがデフォルトだが、dsnにinterpolateParams=trueを付与することで動的プレースホルダに変更できる

参考URL

http.Clientについて

  • 昨今のマイクロサービスの流行に伴い、他サービスにリクエストを飛ばすサービスの最適化を問う問題も出ている
    • 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.ClientTimeoutは無限になっているので、制限した方が安全
    • いくつかタイムアウトの設定があるので適宜設定する
  • レスポンスを受け取ったら必ず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

最後に

  • 実際にはISUCONで高スコアを取るにはアプリケーションのロジックを変更するしかない
    • アプリケーションのCPU使用率を下げて勝てる大会ではない
    • 今回の内容を把握していても勝てない
  • ISUCONはWebアプリケーション開発の深く広い知識が問われる大会
    • だからこそ簡単ではないし、おもしろい
  • 今回の発表はISUCONに特化しましたが、実際の開発にも活かせる知見のはずです
  • これからもISUCONが続いて欲しい
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment