##TL;DR
負荷の変動が激しい環境でコネクションプールの設定のチューニングをさぼるためによくやるハックを紹介します。
##問題
Go から https や mysql など外部のリソースにアクセスする場合、一般的にコネクションプールを使うことになります。
コネクションプールは、利用が終わった (idle) コネクションをプールしておき、次に使いたい時に再利用するものです。
(idle コネクションのプールを以後 free pool と呼びます。)
ほとんどのコネクションプールの実装には、 idle なコネクションの最大数を制限するオプションがあります。
また、利用中の (active) コネクションと idle なコネクションを合計した全体を制限するオプションを持つものもあります。
例えば net/http
パッケージの Transport
は MaxIdleConnsPerHost
というフィールドで最大の idle コネクション数を制限します。 また database/sql
パッケージの DB
は、 SetMaxIdleConns()
で idle の総数を、 SetMaxOpenConns()
で全体の総数を制限します。 (Go ではありませんが nginx の upstream の keepalive ディレクティブも idle なコネクション数の最大値を制限するものです。)
最大の idle コネクション数を制限すると、それ以上のコネクションが idle になったとき、そのコネクションは閉じられます。
active なコネクション数の変動が激しい場合、 idle なコネクション数の最大値が十分で無いと、大量のコネクションが閉じられ、またすぐに大量のコネクションが開かれることになります。
実際に、 https で提供されている API に対して大量にリクエストを投げるツールを作ったときに、デフォルトのままだと idle コネクション数の最大値が十分で無いために頻繁にハンドシェイクが発生し、その負荷がツールの負荷の半分以上を占めることがありました。
##対策その1: max idle を十分大きくする
最初に調整するのが、保持する idle コネクション数の最大値 (max idle と呼ぶ) です。
維持している接続のコストが、クライアント側 (この場合Goプログラム) とサーバー側両方で十分に小さい場合は、何も考えず7万くらい (TCPの場合ポートが65535個なので、サーバーのアドレス/ポートが1つならこれを超えることがない) に設定して、一度作った接続は二度と開放しないという設定が可能かもしれません。
ただし、コネクションプール内の free pool の実装が大量の idle connection に最適化されてない場合、そこが遅くなる可能性があるのに注意してください。
不要になった接続はある程度開放したい場合、 max idle を適切な値に設定しないといけません。その場合は、例えば次のようなコマンドで、どれくらい接続数が増減しているのかを計測しましょう。
$ # 1.2.3.4:433 は実際の接続先に置き換える
$ while : ; do netstat -tn | grep -c '1.2.3.4:433 *ESTAB'; sleep 0.1; done
これで得た指定ポートへの接続数の最大値と最小値の差を、現在の max idle の値に加えてやることで、再接続の回数を大幅に抑えることができます。
##対策その2: max open を制限する
MySQL の max_connections のように、クライアント側ではなくサーバー側で接続数の上限が決まってるケースがあります。
そうでなくても、コネクション数をあまりたくさん維持するのがお行儀が悪い事があります。 (netstat 等のコマンドをよく打つインフラエンジニアに嫌がられるとか)
この場合、 max idle だけでなく同時接続数の上限 (max open と呼ぶ) を調整する必要があります。
max idle を調整するアプローチの1つ目は、最大同時接続数を外部要因で決めてしまうことです。例えば MySQL の max_connections から運用に必要な余裕を引いた値を、 Web サーバーの台数で割った値が Web サーバーの max open 数になるはずです。 サーバー台数を動的に変更する場合は、想定している最大のサーバー台数で割りましょう。
もう一つのアプローチは、「対策その1」と同じように負荷特性に応じて設定する方法です。 max idle を設定した時は最大値と最小値を比べていましたが、 max open を設定するときは移動平均の最大値を取ります。新規に接続する代わりに他の接続が空くのをどれくらいの時間待てるか考え、例えば1秒待てるなら、同時接続数の1秒間の移動平均の最大値を取ればいいことになります。
##channelを使ったセマフォ
http
パッケージの Transport
のように、 max idle は指定できても max open を指定できないコネクションプールを使う場合でも、呼び出し側で排他制御してやれば同時接続数を制御することが可能です。
有限個数のリソースに対する排他制御を行うためによくセマフォを使います。 Go の場合 buffered channel がそのままシンプルなセマフォとして利用できます。
semHttp := make(chan struct{}, 10)
// ...
semHttp <- struct{}{}
res, err := http.Get("http://example.com/")
if err == nil {
// res.Body を最後まで読んでから Close() することでコネクションがプールに返される
content = ioutil.ReadAll(res.Body)
res.Body.Close()
}
<- semHttp
Note: 複数個のリソースを同時に取得できるようにする場合、この実装のままではデッドロックの危険があります。例えばリソースが全体で2個なのに、2個のリソースを必要とするスレッド2つがそれぞれ1つずつリソースを持ってしまった場合、どちらもあと一つのリソースを取得できません。この場合はリソースの取得側を Mutex で保護する必要があります。 (サンプル)
##対策その3: コネクションプールのサイズを自動的に調整する
接続数の上限が明確には決まってないものの、負荷に応じて節度ある程度で抑えたい場合、手作業で適切な値を調べるのは面倒です。
そこで max idle や max open を大きめに設定したうえで、 channel を使った、動的に大きくなるセマフォを手前に被せます。
目標とする「適切な接続数」が「1秒以上の接続待ちが殆ど発生しない」程度とするなら、逆に「1秒接続待ちをしたら同時接続数が足りない」と言えそうです。なので「1秒待ってもセマフォが獲得できないならセマフォを1つ大きくする」実装をしてみます。
net/http
を使った例:
package main
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"sync/atomic"
"time"
)
const (
maxConnsInitial = 5
maxConnsLimit = 1000
)
var client = http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: maxConnsLimit}}
var sem *ElasticSemaphore = NewElasticSemaphore(maxConnsInitial, maxConnsLimit)
type ElasticSemaphore struct {
sem chan struct{}
max int32
}
func NewElasticSemaphore(initial, limit int) *ElasticSemaphore {
if initial > limit {
panic("initial should be <= limit")
}
// buffered channel の空きではなく、中身をセマフォのカウントにする
s := make(chan struct{}, limit)
for i := 0; i < initial; i++ {
s <- struct{}{}
}
return &ElasticSemaphore{
sem: s,
max: int32(initial),
}
}
func (t *ElasticSemaphore) Acquire() {
// 1秒まってもセマフォを獲得できない場合はそのまま実行する
select {
case <-t.sem:
case <-time.After(time.Second):
atomic.AddInt32(&t.max, +1)
}
}
func (t *ElasticSemaphore) Release() {
select {
case t.sem <- struct{}{}:
default:
atomic.AddInt32(&t.max, -1)
}
}
func (t *ElasticSemaphore) MaxConns() int32 {
return atomic.LoadInt32(&t.max)
}
func worker() {
buff := &bytes.Buffer{}
for {
sem.Acquire()
res, err := client.Get("http://127.0.0.1:8001/")
if err != nil {
sem.Release()
panic(err)
}
buff.ReadFrom(res.Body)
res.Body.Close()
sem.Release()
buff.Reset()
time.Sleep(time.Second)
}
}
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("hello, world\n"))
})
go func() { fmt.Println(http.ListenAndServe("127.0.0.1:8001", nil)) }()
for i := 0; i < 1000; i++ {
go worker()
time.Sleep(time.Millisecond)
}
for i := 0; i < 100; i++ {
fmt.Println(sem.MaxConns())
time.Sleep(time.Second / 5)
}
}
プログラムの実行開始直後、 initial の数では接続数が足りなければ足りないほど、大量の goroutine が同時に1秒のタイムアウトを起こすので、セマフォのサイズが一気に大きくなります。
このサンプルプログラムを実行してみると、最初の2秒で一気にプールが大きくなってすぐに安定する様子が見えるはずです。
まとめ
コネクションプールのサイズをチューニングする上での基本的な考え方、手順と、コネクションプールのサイズを自動的に必要十分に増やす簡単な Go 実装を紹介しました。