Goのdatabase/sql.Stmtのスケーラビリティを改善しました
先日、 Goに初めて私のパッチが取り込まれ 、コントリビュータに仲間入りしました。
このパッチは、 database/sql.Stmt
をヘビーに使った時に性能がだいたい16コア以上のコア数にスケールしないという問題を解決するものです。
こういった問題をどうやって調査するのかと、Goにパッチが取り込まれるまでの手順を紹介します。
背景
私は TechEmpower の FrameworkBenchmarks という、いろんな言語/フレームワークで同一のアプリを作ってベンチマークするというプロジェクトで、主にPython関連のメンテナをしています。 Goにも興味があるので、Ginというフレームワークを追加したりコードレビューに参加したりしています。
2014-05-01 に行われた前回のベンチマーク Round 9 では、 PEAK Hosting が実行環境に加わりました。この環境は、デュアル Xeon E5-2660 v2 のマシンを 10Gbit Ethernet で接続するというハイスペックなものです。
この環境で、少し不思議な結果が出ていました。他の環境ではJavaやnginxベースの環境に続いて上位につけていた大量のGoのフレームワークが軒並み順位を落とし、Scala, node.js, php の軽量フレームワークにまで負けてしまっていたのです。
2014-11-02 に この問題を改善するプルリクエスト が登場しました。このプルリクエストは、 *sql.DB
を複数作りラウンドロビンで利用することでロック競合を改善するというものでした。
私は当初この修正方法に反対でした。このプロジェクトは単にハイスコアを狙うためだけのものではなく、各フレームワークの 現実的な コードのサンプルと、その現実的なコードがどれくらいの性能を出すのかの参考値を多くの人に提供するためのものです。 *sql.DB
を複数作るのは設計意図と異なり、あまり現実的なものには感じられませんでした。
database/sql
の設計について
少し寄り道して、 database/sql
の基本設計について説明します。 Go でデータベースを使うプログラムを書いた経験のある方はこのセクションは読み飛ばしてください。
database/sql
は、 PHP でいう PDO のように、各種データベースへ接続するドライバの上に被さり、統一したインタフェースを提供します。例えば MySQL であれば go-sql-drivers/mysql が一番有名なドライバになります。ユーザーは基本的にはドライバを直接利用することはしません。
database/sql
の中心になるのが sql.DB
です。これはコネクションプールになっていて、以下のような使い方をします。
DB.Exec()
やDB.Query()
で直接クエリを投げるDB.Prepare()
でプリペアドステートメントを表すStmt
オブジェクトを作り、Stmt.Exec()
やStmt.Query()
を使うDB.Begin()
でトランザクションを表すTx
オブジェクトを作り、Tx.Exec()
やTx.Query()
を使う
トランザクションを使う場合は Tx
オブジェクトが DB
からチェックアウトしたコネクションを保持しますが、それ以外の場合は DB
がプールしているコネクションのいずれかでクエリが実行され、ユーザーはコネクションを選ぶことができません。 (一部のカバーできていないユースケースに対応するため、 Go 1.5 ではトランザクションなしでコネクションをチェックアウトして使う API が追加される見込みです。)
1番の方法を使う場合、 DB.Query()
はドライバがプレースホルダの置換に対応していない場合は内部で DB.Prepare()
, Stmt.Exec()
, Stmt.Close()
を行います。1回のクエリで3回のラウンドトリップが発生するので性能的には不利になります。 go-sql-drivers/mysql もプレースホルダ置換に対応していない (対応するためのプルリクエスト を投げていて議論中です) ので、 2番の方法が一番性能が出ることになります。
2番の方法を利用する場合、 Stmt
が各ドライバで prepare した結果と DB 内のコネクションとの対応を管理してくれるので、ユーザーはどの接続で Prepare をしたのかを気にする必要はありません。今回の問題はこの部分がネックになってきます。
このような設計になっているために、 DB
自体を複数持つのはイレギュラーな使い方で、1つのデータベースあたり1つの DB
だけで性能がスケールするようにするのが理想です。
調査
まず、現象が発生している PEAK Hosting に近い環境を用意する必要があります。 AWS で c3.8xlarge のマシンを2台、拡張ネットワーキングを有効にして、同一の placement group に配置することで、32コアのマシンを10Gbitの安定したネットワークで接続した環境を用意することができます。 Amazon Linux AMI を使えば、デフォルトで拡張ネットワーキングが有効になっているので楽です。 スポットインスタンスを利用すれば、2台の c3.8xlarge はだいたい $0.7/h で借りることができます。
次に、ベンチマークをかけて問題が再現することを確認しつつ、 top コマンドでCPUの利用状況を確認します。 Mutex を使ったロックは、ロックをすぐに取得できた場合は低コストですが、競合した場合はそこそこCPUサイクルを消費します。 問題になっている Mutex が長時間ロックされている場合は、動作できるスレッドが減るのでCPU使用率が低くなり、短時間だけど非常に頻繁にロックされる場合はロックの競合の発生頻度が激しくなり、性能がコア数にスケールしてない割にはCPU使用率は高くなる傾向にあります。今回はどちらかというと後者だということが確認できました。
最後に、 net/http/pprof
を利用して、原因を特定します。 net/http/pprof
はCPUプロファイルをはじめ幾つかの診断機能がありますが、ロック競合で一番役に立つのは full stack dump です。実際のスタックダンプが こちら になります。
まず、多くの goroutine が止まっている Mutex.Lock()
を見つけます。今回の場合は次のようなスタックトレースで止まっている場合が多いのがわかります。 ソースコードでは この部分 になります。
goroutine 81 [semacquire]: sync.(*Mutex).Lock(0xc2080c42d0) /home/ec2-user/local/go/src/sync/mutex.go:66 +0xd3 database/sql.(*Stmt).connStmt(0xc2080c4280, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0) /home/ec2-user/local/go/src/database/sql/sql.go:1357 +0xa6 database/sql.(*Stmt).Query(0xc2080c4280, 0xc2089a1be8, 0x1, 0x1, 0x0, 0x0, 0x0) /home/ec2-user/local/go/src/database/sql/sql.go:1438 +0x120
だいぶ原因に近づいてきましたが、まだこの部分が犯人だと断言することはできません。ロック競合は、ロックを取得する頻度とロックを持っている時間の掛け算が問題になるのに対して、スタックダンプでは頻度が多い部分が多く表示されるからです。
そこで、これと同じスタックダンプを除外した中から、この Stmt.mu
のロックを持っているものを探します。すると次のスタックダンプが見つかります。
goroutine 158 [semacquire]: sync.(*Mutex).Lock(0xc208096de0) /home/ec2-user/local/go/src/sync/mutex.go:66 +0xd3 database/sql.(*DB).connIfFree(0xc208096dc0, 0xc2083080c0, 0x0, 0x0, 0x0) /home/ec2-user/local/go/src/database/sql/sql.go:695 +0x67 database/sql.(*Stmt).connStmt(0xc2080c4280, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0) /home/ec2-user/local/go/src/database/sql/sql.go:1378 +0x316
Stmt.mu
をロックしているのは先ほどと同じ位置なのですが、その中で今度は DB.mu
のロックを待っていることがわかります。 Stmt.connStmt()
と DB.connIfFree()
のコードを読むと、 connStmt()
が Stmt.css
(prepare 済みのコネクションと stmt のペアを管理しているリスト) に対してループしつつ、 connIfFree()
を呼び出し、 connIfFree()
が毎回 DB.mu
をロックしているために、 DB.mu
が高い頻度でロックされているのがわかります。しかし、この場所は Stmt.mu
をロックしている1つの goroutine しか同時にこないので、他の場所の DB.mu
のロックと競合しているようです。今度はスタックダンプのなかから、 DB.mu
のロックを持ってるものを探します。すると、 DB.addDep()
のロック競合に行き当たります。
# DB.mu を持っている DB.addDep goroutine 18 [runnable]: database/sql.(*DB).addDep(0xc208096dc0, 0x7f0932f0e4d8, 0xc2080c4280, 0x73d340, 0xc2082ec540) /home/ec2-user/local/go/src/database/sql/sql.go:362 database/sql.(*Stmt).Query(0xc2080c4280, 0xc2087cbbe8, 0x1, 0x1, 0x0, 0x0, 0x0) /home/ec2-user/local/go/src/database/sql/sql.go:1455 +0x49a # DB.mu を待っている DB.addDep. これと同じスタックダンプが多数. goroutine 64 [semacquire]: sync.(*Mutex).Lock(0xc208096de0) /home/ec2-user/local/go/src/sync/mutex.go:66 +0xd3 database/sql.(*DB).addDep(0xc208096dc0, 0x7f0932f0e4d8, 0xc2080c4280, 0x73d340, 0xc2085f0540) /home/ec2-user/local/go/src/database/sql/sql.go:364 +0x38 database/sql.(*Stmt).Query(0xc2080c4280, 0xc20899fbe8, 0x1, 0x1, 0x0, 0x0, 0x0) /home/ec2-user/local/go/src/database/sql/sql.go:1455 +0x49a
これで問題の全体像が把握できました。
Stmt.connStmt()
が(比較的)長時間Stmt.mu
をロックしているので、Stmt
があまりたくさん並列に動けない。Stmt.connStmt()
は繰り返しDB.connIfFree()
を呼び出し、そこでDB.mu
のロックが競合しているため遅くなる。DB.mu
のロック競合相手はDB.addDep()
解決策1: DB.mu を分離する
DB.addDep()
を読んでみると、参照カウント方式でリソースを管理していて、最後にリソースの解放を行っているようです。
参照カウントと解放処理を管理するための map を守るために DB.mu
をロックして排他していますが、これは独立した Mutex に分離できます。
DB.connIfFree()
は DB.mu
がロック競合を起こさなければ十分に速くなる可能性があり、それなら振る舞いを一切変更せずに問題を解決することができます。
しかし、実際にロックを分離してみたところ、2割程度しか性能が改善せず、 DB
自体を複数持ってラウンドロビンするのに比べるとまだ性能がかなり悪いです。
DB.mu
のロック競合を解決しても、 Stmt.connStmt()
が Stmt.mu
をロックしている内側がまだ遅いので Stmt.mu
のロック競合が解決されてないようです。
Mutex の分離はパッチを投げつつ、 Stmt.connStmt()
の高速化に乗り出します。
解決策2: Stmt.connStmt() / DB.connIfFree() の高速化
Stmt.connStmt()
が Stmt.css
に対してループで DB.connIfFree()
を呼び出しているのですが、このループは prepare 済みの接続が空いていたらそれを利用するという目的と、 prepare 済みの接続が close されていたら Stmt.css
から除去するという2つの目的があります。
しかし、接続が close されているかどうか、利用されているかどうかは、 DB.mu
をロックしてさえいれば、 DB
のメンバを参照しなくても高速に判定できます。
そこで、 Stmt.connStmt()
の中の、 Stmt.css
に対するループの外側で直接 DB.mu
をロックしてしまい、なおかつ接続のチェックもループ内で直接行い DB.connIfFree()
の呼び出しを減らす修正を行ったところ、今度は複数 DB
のラウンドロビンと同じ程度の速度がでるようになりました。
こちらを本命としてまたパッチを投げます。 参考
Go にパッチを送り取り込まれるまで
とりあえず Go の Contribution Guideline を読みましょう。 以下かいつまんで説明します。
最初に Github Issue で修正の大まかな方針について合意をとっておきます。
修正を始める前に、 Google の CLA に署名しておきましょう。 参考
Goのリポジトリは最近 Mercurial から Git に移行しましたが、Githubに移行したわけではありません。 Github は Issue Tracker と、リポジトリのクローンに利用されています。オリジナルのリポジトリの場所は https://go.googlesource.com/go なので、 github から clone しているなら git upstream set-url
などで origin の向き先を変える必要があります。
Go のコードレビューは Gerrit で行われています。ここにアクセスしてログインし、コマンドラインツールからパッチを投げるのに必要なパスワードを発行しておきます。
次に、 git-codereview というツールをインストールします。
go get -u golang.org/x/review/git-codereview
このツールは、パッチを1コミットのブランチという形で管理し、そのパッチを gerrit に送信するものです。 git に慣れた人向けに説明すると次のようになります。
git codereview change fix-abc
はgit checkout -b fix-abc
相当git codereview change
は、最初のコミットであればgit commit
相当、2回目以降はgit commit --amend
相当. (commit
と同じ-a
が利用可能).git codereview sync
はgit fetch origin && git rebase origin/master
相当git codereview mail -diff
はgit diff HEAD..master
相当git codereview mail
で Gerrit にパッチを送る
パッチを投げるとレビューが始まります。 (Go の開発はとても活発で、元旦にパッチを投げたらその日のうちにコメントがついてびっくりしました)
- 大まかな設計
- コードの書き方、コミットログの書き方
- テストの書き方
- コメントの書き方
という感じで、レビューの注意点が移っていき、複数の Googler から繰り返しレビューを受けます。
レビューを通った感想
レビューの初期で、 prepare 済みの接続を優先して使うという処理自体を消すなど大幅にシンプルな設計になりました。 これは Github Issue の時点で提案されていた方針で、ちゃんとコメントを読んでおけばお互いに無駄な時間を省けたはずです。
テストについては、既存のベンチマークを簡単に修正していたのですが、平行性のベンチマークは RunParallel
を使うように指摘されて、そのままたたき台として使えるようなサンプルをコメントしてもらいました。既存のファイルの上下がレガシーなら自分の修正部分もレガシーな書き方が許されるわけではないようです。
最後の方は、コメントを対象行の右に書くときは主語を省略していいけど、長くなって対象行の上に移動するならちゃんと主語から書き始めるとか、英語の構文、言い回しなど全然コードと関係ない指摘と修正の繰り返しでした。最終的に取り込まれるまでにパッチを送った回数は13回になりました。
ぶっちゃけて言えば、調査結果を詳細に Issue で報告するだけで止めておいて、実際の修正は Googler に任せてしまった方が、私だけでなくレビューする側も楽だったでしょう。それでも、複数の Googler から丁寧なコードレビューを受けるというのは貴重な経験でした。「あとはやっとくから」と言わず、最後までレビューに付き合ってもらって感謝しています。