MySQL と Go (Golang): database/sql・SQLX の接続とマイクロサービス向けコネクションプールの最適化

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

実際の問題:GoマイクロサービスのMySQL接続が遅くなりタイムアウトする

私のマイクロサービスシステムは、トラフィックが急増するまで安定して稼働していた。リクエストのたびにMySQLへの新しいコネクションを作成していたため、レイテンシが急上昇し、too many connectionsエラーが発生し始めた。本番データベースはMySQL 8.0で約50GBのデータを扱っていたが、コネクションプールを最適化して初めて、ピーク時のP99レイテンシが800msから約120msまで下がった。

原因を突き止めるまでに半日近くログを追い続けた。GoもMySQLも問題はなかった — 原因は純粋に、コードがリクエストごとに新しいコネクションを作成していたことだ。

原因の分析

1. リクエストごとに新しいコネクションを開く

MySQLに接続する最もシンプルなGoのコードはこのようになる:

db, err := sql.Open("mysql", dsn)
defer db.Close()
// クエリを実行して閉じる

各ハンドラー関数内でsql.Open()を呼び出すと、リクエストごとにMySQLへの新しいTCPコネクションが生成される。100の並行リクエスト = 同時に100の新しいコネクション — MySQLのデフォルトの最大接続数はわずか151だ。

2. プールサイズが未設定

Singletonパターン自体は正しいが、上限を設定し忘れると — Goは際限なくコネクションを開いてしまう。その結果:

  • トラフィックが急増するとMySQLが過負荷になる
  • コネクションオーバーヘッドが大きい — TCPハンドシェイク + MySQL認証で約5〜10msかかる
  • MySQL側のメモリが制御不能に増大する

3. 素のdatabase/sqlは冗長すぎる

素のdatabase/sqlでは、クエリ結果をstructにスキャンするコードが非常に冗長になる:

rows, err := db.Query("SELECT id, name, email FROM users WHERE id = ?", id)
var user User
if rows.Next() {
    err = rows.Scan(&user.ID, &user.Name, &user.Email)
}

structに10〜15列あると、Scan()内で全フィールドを列挙しなければならない。フィールドを見落としやすく、スキーマ変更時のメンテナンスも困難になる。

解決方法

方法1:database/sql を正しく使う(Singletonパターン)

GoのMySQLドライバーをインストールする:

go get github.com/go-sql-driver/mysql

*sql.DBを一度だけ作成し、アプリケーションのライフサイクル全体で再利用する:

package database

import (
    "database/sql"
    "fmt"
    "time"
    _ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

func Init(dsn string) error {
    var err error
    DB, err = sql.Open("mysql", dsn)
    if err != nil {
        return fmt.Errorf("open mysql: %w", err)
    }

    DB.SetMaxOpenConns(25)
    DB.SetMaxIdleConns(10)
    DB.SetConnMaxLifetime(5 * time.Minute)

    return DB.Ping()
}

main.go内で一度だけ呼び出し、database.DBを各ハンドラーに渡す:

dsn := "user:password@tcp(localhost:3306)/mydb?parseTime=true&charset=utf8mb4"
if err := database.Init(dsn); err != nil {
    log.Fatal(err)
}

コネクションリークはもはや問題ではなくなった — ただし、フィールドの多いstructではクエリコードがまだ冗長だ。

方法2:SQLX を使う — database/sql をより実用的に

SQLXはdatabase/sqlの薄いラッパーだ。インターフェース全体はそのままで、StructScanNamedQuerySelectが追加されている — 標準ライブラリをシンプルに保つためにdatabase/sqlが意図的に省いた機能だ。

go get github.com/jmoiron/sqlx

*sql.DB*sqlx.DBに置き換える:

package database

import (
    "fmt"
    "time"
    "github.com/jmoiron/sqlx"
    _ "github.com/go-sql-driver/mysql"
)

var DB *sqlx.DB

func Init(dsn string) error {
    var err error
    DB, err = sqlx.Connect("mysql", dsn)
    if err != nil {
        return fmt.Errorf("connect mysql: %w", err)
    }

    DB.SetMaxOpenConns(25)
    DB.SetMaxIdleConns(10)
    DB.SetConnMaxLifetime(5 * time.Minute)
    DB.SetConnMaxIdleTime(2 * time.Minute)

    return nil
}

SQLXでのクエリははるかにシンプルで — dbタグ経由でstructに直接スキャンできる:

type User struct {
    ID        int       `db:"id"`
    Name      string    `db:"name"`
    Email     string    `db:"email"`
    CreatedAt time.Time `db:"created_at"`
}

// 1件取得
var user User
err := database.DB.Get(&user, "SELECT * FROM users WHERE id = ?", userID)

// 複数件取得
var users []User
err = database.DB.Select(&users, "SELECT * FROM users WHERE active = 1 LIMIT 100")

NamedExecを使うと、位置指定プレースホルダーの代わりにフィールド名が使える。8〜10列のINSERTでも読みやすいコードが書ける:

user := User{Name: "Nguyen Van A", Email: "[email protected]"}
_, err := database.DB.NamedExec(`
    INSERT INTO users (name, email) VALUES (:name, :email)
`, user)

方法3:SQLX で正しいトランザクション処理

マイクロサービスでは複数テーブルを同時に操作する際にトランザクションが必要になることが多い。defer tx.Rollback()を使う標準的なパターン:

func TransferBalance(fromID, toID int, amount float64) error {
    tx, err := database.DB.Beginx()
    if err != nil {
        return err
    }
    defer tx.Rollback() // Commitが成功した場合はno-op

    if _, err = tx.Exec(
        "UPDATE accounts SET balance = balance - ? WHERE id = ?",
        amount, fromID,
    ); err != nil {
        return err
    }

    if _, err = tx.Exec(
        "UPDATE accounts SET balance = balance + ? WHERE id = ?",
        amount, toID,
    ); err != nil {
        return err
    }

    return tx.Commit()
}

defer tx.Rollback()はセーフティネットだ:Commit()前に関数がエラーを返した場合、トランザクションは自動的にロールバックされるCommit()が成功した後にRollback()を呼び出してもno-opになる — 何も影響しない。

本番環境向けコネクションプールの最適化

プールの4つのパラメータを理解する

Goのコネクションプールには正しく設定すべき4つのパラメータがある。デフォルトのままにするとGoが代わりに決定してしまう — そしてGoは「無制限」を選ぶ:

DB.SetMaxOpenConns(n)      // 最大接続数の合計(idle + active)
DB.SetMaxIdleConns(n)      // アイドル時にプールに保持する接続数
DB.SetConnMaxLifetime(d)   // 接続の最大生存時間
DB.SetConnMaxIdleTime(d)   // アイドル状態の接続の最大保持時間

マイクロサービス向けプールサイズの計算式

本番稼働を重ねてメトリクスを観察した結果、このシンプルな計算式を導き出した:

  • MaxOpenConns = 並行リクエストを処理するGoroutine数 × 1.5(バッファのため)
  • MaxIdleConns = MaxOpenConnsの40%(再利用に十分で、無駄がない)
  • ConnMaxLifetime = 5〜10分(MySQLの再起動/フェイルオーバー後の古いコネクションを防ぐ)
  • ConnMaxIdleTime = 2〜3分(未使用のコネクションを解放し、MySQLの負荷を軽減)

50の並行リクエストを処理するサービスで実際に使っている設定:

func InitProductionDB(dsn string) (*sqlx.DB, error) {
    fullDSN := dsn + "?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci"
    db, err := sqlx.Connect("mysql", fullDSN)
    if err != nil {
        return nil, err
    }

    db.SetMaxOpenConns(75)               // 50 × 1.5
    db.SetMaxIdleConns(30)               // 75の40%
    db.SetConnMaxLifetime(8 * time.Minute)
    db.SetConnMaxIdleTime(3 * time.Minute)

    return db, nil
}

db.Stats() でプールの健全性を監視する

Goはdb.Stats()を提供しており、プールの状態をリアルタイムで確認できる — APMも外部ツールも不要だ:

func LogDBStats(db *sqlx.DB) {
    s := db.Stats()
    log.Printf(
        "DB Pool — open: %d, idle: %d, in-use: %d, wait: %d, waitDuration: %s",
        s.OpenConnections, s.Idle, s.InUse, s.WaitCount, s.WaitDuration,
    )
}

WaitCountが増え続けている → MaxOpenConnsが低すぎる、リクエストがコネクション待ちでキューに並んでいる。Idleが常にMaxIdleConnsと同じ → アイドル状態のコネクションを保持しすぎている。MySQLのコネクションは1つあたり約4〜8MBのRAMを消費する — 数十個に増えればすぐにわかる。

すべてのクエリにコンテキストタイムアウトを設定する

タイムアウトのないクエリは30秒間もコネクションをプールに保持し続け、後続のリクエストがキューに並び — 呼び出し元のサービスもタイムアウトが連鎖する。常にGetContext/SelectContextを使おう:

func GetUserWithTimeout(ctx context.Context, db *sqlx.DB, id int) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    var user User
    err := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    return &user, nil
}

本番デプロイ前のチェックリスト

  • *sqlx.DBは一度だけ作成し、各ハンドラーに渡す(ハンドラー内で再作成しない)
  • プールの4つのパラメータをすべて設定する — デフォルトのままにしない
  • DSNにはparseTime=truecharset=utf8mb4を必ず含める
  • すべてのクエリにGetContext/SelectContextとタイムアウトを使う
  • トランザクション:Beginx()の直後にdefer tx.Rollback()を置く
  • db.Stats()を定期的に監視する(特にトラフィックが急増したとき)

Share: