Golangでdatabase/sqlパッケージを使ったデータベース接続の正しい実装パターン

はじめに:よくある間違いとその原因

Go言語でデータベースにアクセスする際、多くの初心者が以下のようなコードを書きます。

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

func main() {
    db, err := sql.Open("mysql", "root:111111@tcp(127.0.0.1:3306)/testdb")
    if err != nil {
        panic(err)
    }
    err = db.Ping()
    if err != nil {
        panic(err)
    }
    fmt.Println("Successfully connected!")
}

このコードは一見正しく動作しますが、実際には深刻な問題をはらんでいます。特に、このコードをそのまま共通関数としてラップすると、連続したデータベース操作でプログラムがハングアップする現象が発生することがあります。

sql.DBは接続プールである

sql.Open が返す sql.DB は、単一のデータベース接続ではなく、接続プールを管理するオブジェクトです。これはデータベースの種類に依存しない抽象的なインターフェースであり、実際の接続処理は別途インポートするドライバ(例:go-sql-driver/mysql)が担当します。

この認識が不足していると、以下のような誤ったコードを書いてしまいます。

func GetDbContext() *sql.DB {
    db, err := sql.Open("mysql", "root:111111@tcp(127.0.0.1:3306)/testdb")
    if err != nil {
        panic(err)
    }
    err = db.Ping()
    if err != nil {
        panic(err)
    }
    return db
}

func DoSomething() {
    db := GetDbContext()
    rows, _ := db.Query("select * from table1")
}

このコードの問題点は、GetDbContext が呼ばれるたびに新しい接続プールを作成しようとすることです。その結果、プール内の接続が解放されず、TCP接続が枯渇してプログラムが応答しなくなります。

正しいアプローチ:シングルトンな接続プール

公式ドキュメントには以下のように記述されています。

sql.DB オブジェクトは長期生存が前提です。Open()Close() を頻繁に呼び出してはいけません。代わりに、アクセスするデータストアごとに1つの sql.DB オブジェクトを作成し、プログラムがそのデータストアへのアクセスを終了するまで保持し続けてください。」

この原則に従うために、グローバル変数を使用して接続プールを保持します。

package demo

import (
    "database/sql"
)

var mydb, _ = sql.Open("mysql", "connection_string")

ただし、複数のデータベースを扱う必要がある場合は、マップを使って動的に管理します。

var envdbMap map[string]*sql.DB

func GetEnvDbContext(connector config.DbConnector) *sql.DB {
    if envdbMap == nil {
        envdbMap = make(map[string]*sql.DB)
    }

    db, ok := envdbMap[connector.ID]
    if ok {
        return db
    }

    connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
        connector.Host, connector.Port, connector.UserName, connector.Password, connector.DatabaseName)
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        panic(err)
    }

    envdbMap[connector.ID] = db
    return db
}

この実装では、各データベースに対して1回だけ sql.Open が呼ばれ、以降は既存のプールが再利用されます。

接続の返却を確実に

接続プールから取得した接続は、必ず呼び出し側で返却する必要があります。以下の例のように、defer を使って確実にクローズします。

rows, err := db.Query("select * from table1")
if err != nil {
    // エラーハンドリング
}
defer rows.Close()

for rows.Next() {
    // 行処理
}

defer rows.Close() は関数が終了するとき(正常でも異常でも)必ず実行されるため、接続リークを防げます。公式ドキュメントでは、rows.Close() は何度呼んでも安全(harmless)な操作とされています。

特に注意すべき点として、更新や挿入など結果セットを返さないSQLを実行する場合は、Query ではなく Exec メソッドを使用します。Query を使うと、内部で結果セットが期待され、接続が解放されない原因になります。

接続プールのチューニング

デフォルトでは接続プールに上限はありませんが、OSのTCP接続制限を超えると問題が発生します。以下のパラメータでプールを制御できます。

db.SetMaxIdleConns(10)      // アイドル状態の最大接続数
db.SetMaxOpenConns(100)     // 同時に開ける最大接続数
db.SetConnMaxLifetime(5 * time.Minute)  // 接続の最大生存時間

これらの設定により、リソース消費を適切に管理できます。

まとめ

Goでデータベースにアクセスする際の最大の落とし穴は、sql.DB が接続プールであることを理解せずに頻繁に生成・破棄してしまうことです。適切なアプローチは以下の通りです。

  • sql.DB はプログラム全体で1つ(またはデータベースごとに1つ)のシングルトンとして管理する
  • クエリ結果は defer rows.Close() で確実に解放する
  • 更新系のSQLには Exec を使用する
  • 接続プールの上限を適切に設定する

これらのポイントを押さえることで、安定的なデータベースアクセスを実現できます。

参考資料

タグ: golang database/sql 接続プール sql.DB データベースアクセス

6月5日 22:59 投稿