日記マン

動画広告プロダクトしてます。Go, Kubernetesが好きです。

GoにおけるDBクライアントの設計とObservabilityの確保

Goで sql.DB を扱ってMySQLを扱うとき、
インターフェイスの実装、チューニングできる設定値への理解、Observabilityをどのように確保すればよいか。
ある程度知識がまとまってきたのでアウトプットする。
(実際は sql.DB にない構造体バインディングが欲しかったので sqlx.DB を使っている。)

DBクライアント

func main() {
    stop := utils.SignalHandler(logger)
    cleanup, err := adapter.SetupMySQL(cfg, logger)
    defer func() {
        <-stop
        cleanup()
    }()
     
    db := adapter.GetMySQL()
    // ...
}

DBクライアントのセットアップ関数を実行しクリーンアップ用のコールバックを受け取るインターフェイス

package adapter
 
var dbClient DB
 
func SetupMySQL(cfg config.MySQL, logger log.Logger) (func(), error) {
    conf := mysql.Config{}
    // set config
    conf.ParseTime = true
    loc, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        return nil, err
    }
    conf.Loc = loc
 
    // initialize client
    db, err := sqlx.Open("mysql", conf.FormatDSN())
    if err != nil {
        return func() {}, fmt.Errorf("could not connect to %s: %w", conf.FormatDSN(), err)
    }
 
    // db.SetMaxOpenConns
    // db.SetMaxIdleConns
    // db.SetConnMaxLifeTime
 
    dbClient = newWrappedDB(db)
 
    return func() {
        if err := dbClient.Close(); != nil {
            level.Error(logger).Log("msg", "failed to close dbClient", "error", err)
        }
    }, nil
}

Setup関数は大体こんな感じの実装になる。
さてDBクライアント側でどのようなチューニングができるか。
*sql.DB (もとい *sqlx.DB)が設定できる項目は以下の3つ。

  • SetMaxOpenConns
  • SetMaxIdleConns
  • SetConnMaxLifetime

MaxOpen, MaxIdle は文字通りこのクライアントのTCPコネクションの本数を制御できる。

MaxOpenConns は同時コネクション最大数になる。
MaxOpenConns がサチってるとクエリ系のメソッドの処理がブロックされる。

基本的に SetMaxOpenConns と SetMaxIdleConns は同値を設定する。
新規接続はコストが発生する。
TCPセッションの3-handshakeのコスト、
サーバの実装によるがMySQLではフォアグランドスレッドの生成コストなどが発生する。
確立したコネクションをプールして使い回すのはそれなりに理由になる。

プールしているコネクションを再利用すればそれは効率的だが、
コネクションが何らかの理由で切断した場合、(MySQLサーバ側が落ちたり、リスタートしたり、)
クライアントはFIN通知を受け取るわけではないのでそのコネクションは生きていると勘違いし続ける。
特にKubernetesなどの利用シーンではGoとDBサーバの間にプロキシサーバを設置する場合があるが、
プロキシサーバが何らかの理由でプロキシ<->DB間のセッションを切ってもGo側からは気づけずにコネクションを保持し続ける。
そのようなコネクションはすでに接続は途中で切れていて安全ではなく、クライアントからクエリパケットを送る際に初めてパケット喪失をして気づく。

error="invalid connection"
[mysql] 2019/09/03 12:50:55 packets.go:36: unexpected EOF

このようなログが吐かれたらその現象を疑える。

このプールしているコネクションの安全性を制御するには3つ目のSetConnMaxLifetimeが重要になる。
新規接続してからSetConnMaxLifetimeで指定した時間を経過したコネクションは「利用できないコネクション」と判断しその該当コネクションを解放する。
クエリ実行時のコネクション選択ロジックにその処理は盛り込まれており、

  • クエリを実行するためのコネクションを用意する
  • プールしているコネクション(freeConnection)があれば1つ選ぶ
  • そのコネクションの経過時間とMaxLifetimeを比較する
  • 利用できないコネクションとわかればそのコネクションは破棄し再度コネクション選択に戻る

といったようにクライアントは適切なコネクションを選ぶ。
この値は最低でもMySQLサーバの wait_timeout より低い値にする。
もしHAProxyなど間にプロキシを挟む場合はそのプロキシの可用性に釣られることになる。
MaxOpenConnsの値×1秒程度にしておく。

可観測性

ここではPrometheusによるメトリクスの収集を前提にする。
どのようにObservabilityを確保できるか。指標の設計としてUSEメソッドが使えないか考える。

www.brendangregg.com

各設定値の妥当性の検証やサービスのパフォーマンスのドリルダウンなどにこれら指標は重要になる。

type wrappedDB struct {
    db *sqlx.DB
    exit chan struct{}
    done chan struct{}
}
 
func newWrappedDB(db *sqlx.DB) *wrappedDB {
    w := &wrappedDB{
        db: db,
        exit: make(chan struct{}, 0),
        done: make(chan struct{}, 0),
    }
    go w.collectMetric()
    return w
}

func (w wrappedDB) collectMetric() {
    t := time.NewTicker(5*time.Seconds)
    defer func() {
        t.Stop()
        close(w.done)
    }()
    for {
        select {
        case <-t.C:
            stats := w.db.Stats()
            MySQLWaitDurationSecondsTotal.Set(stats.WaitDuration.Seconds())
              MySQLConnections.WithLabelValues("use").Set(float64(stats.InUse))
              MySQLConnections.WithLabelValues("idle").Set(float64(stats.Idle))
        case <-w.exit:
            return
        }
    }
}

func (w wrappedDB) Close() error {
    close(w.exit)
    <-w.done
    return w.db.Close()
}

var MySQLConnections = prometheus.NewGaugeVec(prometheus.GaugeOpts{
    Namespace: "app",
    Name:      "mysql_connections",
}, []string{"mode"})

var MySQLWaitDurationSecondsTotal = prometheus.NewGauge(prometheus.GaugeOpts{
    Namespace: "app",
    Name:      "mysql_wait_duration_seconds_total",
})

MaxOpenConnsで設定した最大数以上に新規コネクションは生成されずその時点で処理にロックがかかり待機する。
この時の待機中は sql.DBStats 構造体の WaitDuration に待機状態の時刻が加算される。
これによりUSEメソッドにおけるSaturationはWaitDurationで観測できる。
利用中・プール中(idle)のコネクション数やWaitDurationのメトリクスは計測しておきたい。

まとめ

GoによるDBクライアントの設計に焦点を当てた。
sql.DB はシンプルなインターフェイスで MaxOpenConns, MaxIdleConns, ConnMaxLifetime の3つの設定値が用意されている。
可観測性のためのメトリクスは、
Utilization は DBStats.InUse, Saturation は WaitDuration, Errors はクエリメソッドのエラー数、
が対応できる。