3-1. database/sql - Go標準のDBアクセス

所要時間: 40-50分 コミット内容: ~/learn/go/level3/day01/ に DB接続コードと接続プール設定例


このレッスンのゴール

  • sql.Open が物理接続を開かないことを説明できる
  • DSN を構造体から組み立て、パスワードを URL エスケープできる
  • SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime をチューニングできる
  • アプリ起動時に PingContext でタイムアウト付き接続確認
  • db.Stats() で接続プールを観測できる

なぜ学ぶか - Level 3 の出発点として

ORM が便利だから sqlx も database/sql も飛ばしていい?」 - NO。本番障害の8割は接続プール周りSetMaxOpenConns を知らずに本番でピーク時に max_connections 食い潰す事故は今でも頻発。ORM はこれを隠蔽するので、隠蔽されているものを知っておく必要がある。さらに OSS の DB アクセスは大半が database/sql の上に乗っているので、ここを理解しないと gorm/ent のソースが読めない

前章とのつながり

2-6_パッケージ分割Repository インターフェース、2-7_REST_API完成memoryRepo次のステップ。「永続化が消える」インメモリから、プロセス再起動しても残る 本物の DB アクセスへ移行。

これができると何が嬉しいか

  • 本番で invalid connection が出た時、原因候補を3つ即答できる
  • デプロイ前の接続プール設定 が脳内チェックリスト化
  • gorm の遅さの原因を ソースを追って特定 できる
  • MySQL / PostgreSQL / SQLite を1つのコードで使い分け られる

ストーリー導入: sql.Open は「電話帳を作るだけ」

「電話番号は登録した、でもまだ電話してない」状態が sql.Open の戻り値。実際に電話するのは Ping() か最初のクエリ実行時。これを知らずに「sql.Open 成功 → 起動 OK」と判定すると、最初のリクエストで初めて DB 接続失敗が判明 という分かりにくい障害を起こす。本章で身につける鉄則: 起動時に PingContext(ctx) を必ず呼ぶ


大前提: なぜ ORM ではなく database/sql から学ぶか

Go 界隈には ORM(gorm、ent)も SQL ビルダー(sqlx、squirrel)も SQLコンパイラ(sqlc)もあります。便利なものを最初に学ぶのは合理的に見える。でも本気のバックエンドエンジニアになりたいなら、最初に database/sql を触る べきです。理由は3つ。

  1. すべての上位ライブラリは database/sql の上に乗っている。gorm の Find() が遅い時、何が起きているか追えるのは「sql.DB の挙動を知ってる人」だけ。
  2. 本番障害の8割は接続プール周りSetMaxOpenConns を知らずに本番に出して、ピーク時に MySQL の max_connections を食い潰す事故は今でも頻発している。ORM はこれを隠蔽するので、隠蔽されているものを先に知っておく必要がある。
  3. デバッグ容易性。SQLを直接書けば、EXPLAIN で実行計画を見るのも、スロークエリログと突き合わせるのも一直線。ORMが生成する SQL を解読する手間が要らない。

TypeScript で言えば、Prisma を使う前に mysql2/promise で素のクエリを書いた経験があるかどうか、の差です。隠蔽されている層を一度自分の手で触っておく。それが「設計を語れる」エンジニアになる入口。


セッション①: database/sql の世界観(25分)

0. 録画スタートと作業ディレクトリ

mkdir -p ~/log ~/learn/go/level3/day01
cd ~/learn/go/level3/day01
script ~/log/go_level3_day01.log
 
go mod init example.com/dbplay

1. database/sql は「抽象化レイヤー」である

package main
 
import (
    "database/sql"
    "log"
 
    _ "github.com/go-sql-driver/mysql" // ドライバ登録のための副作用 import
)
 
func main() {
    db, err := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/appdb?parseTime=true")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
 
    if err := db.Ping(); err != nil {
        log.Fatal("DBに接続できない:", err)
    }
    log.Println("接続OK")
}

database/sql の本質

database/sql 自体は SQL を投げる相手を持っていない。これは「ドライバが守るべき契約(インターフェース)」と「接続プール・トランザクション管理などの共通機能」だけを提供するパッケージ。

実際に MySQL や PostgreSQL と通信するのは別途 import する ドライバgo-sql-driver/mysqllib/pqjackc/pgx/v5/stdlib など)。

この設計は JDBC(Java)や Python の DB-API 2.0 にも見られる「Database Abstraction Layer」パターン。アプリ側のコードを DB 製品から切り離す ことで、MySQL → PostgreSQL の乗り換えが「ドライバ差し替え + SQL方言修正」だけで済むようにしている。

_ "github.com/go-sql-driver/mysql"_ は何?

Go の ブランク import(副作用 import)。

パッケージそのものは使わないが、そのパッケージの init() 関数を実行させるためだけに import する書き方。go-sql-driver/mysqlinit() は内部で sql.Register("mysql", &MySQLDriver{}) を呼んでいて、これによって sql.Open("mysql", ...) が「mysql」という名前でドライバを見つけられるようになる。

// mysql/driver.go 抜粋(go-sql-driver/mysql)
func init() {
    sql.Register("mysql", &MySQLDriver{})
}

もしブランク import を忘れると sql: unknown driver "mysql" (forgotten import?) というエラーになる。これは Go 初心者が必ず一度は踏むトラップ。

2. ドライバの選定

Goでよく使われる DBドライバ

DBドライバimport path備考
MySQL / MariaDBgo-sql-driver/mysqlgithub.com/go-sql-driver/mysql事実上の標準
PostgreSQL (database/sql 経由)lib/pqgithub.com/lib/pqメンテモード、新規は pgx 推奨
PostgreSQL (database/sql 経由・推奨)pgx の stdlibgithub.com/jackc/pgx/v5/stdlibpgx の本体機能はネイティブAPI
PostgreSQL (ネイティブ)pgxgithub.com/jackc/pgx/v5より高速・型情報豊富、database/sql を使わない
SQLitemattn/go-sqlite3github.com/mattn/go-sqlite3CGO が必要
SQLite (CGO不要)modernc.org/sqlitemodernc.org/sqlitePure Go 実装

pgx の特殊なポジション

PostgreSQL ユーザーは pgx を「ネイティブAPIで使う」か「stdlib 経由で database/sql として使う」か選べる。

  • ネイティブAPI: pgxpool.New()pool.Query(ctx, ...)。Postgres 固有型(jsonb、UUID、配列)の対応が綺麗、COPY プロトコル使える、高速。
  • stdlib 経由: 既存の database/sql コードに最小変更で導入できる。

「ORM や sqlx と組み合わせるなら stdlib、Postgres にロックインしてもいいから速度と機能が欲しいならネイティブ」と覚えておく。

3. sql.Open は「接続を開かない」

db, err := sql.Open("mysql", "user:wrong_pass@tcp(127.0.0.1:3306)/appdb")
if err != nil {
    log.Fatal(err) // ここではエラーにならない(!)
}
 
err = db.Ping() // ここで初めて接続を試みて、認証失敗が判明する
if err != nil {
    log.Fatal(err)
}

sql.Open は接続しない

名前から「接続を開く」と思いがちだが、実際にやっているのは *sql.DB という「接続プールのハンドル」を生成するだけ。DSN の構文が壊れている場合にだけ即座にエラーを返す。

実際にネットワーク接続するのは 最初のクエリ実行時明示的に Ping() を呼んだ時。これを「遅延接続(lazy connection)」と呼ぶ。

なぜこんな設計なのか:

  • 接続プール前提: *sql.DB は単一の接続ではなく 接続プール。プール内のコネクションは必要に応じて開閉される
  • テスタブル: テストで「DBが起動していなくても sql.Open は通る」と挙動が安定する

実務的な含意: アプリ起動時に必ず db.Ping() を呼ぶ。これをしないと「起動成功 → 最初のリクエストで初めて DB接続失敗が判明 → 不可解な500エラー」という分かりにくい障害になる。

アプリ起動時の正しい接続確認パターン

db, err := sql.Open("mysql", dsn)
if err != nil {
    return fmt.Errorf("sql.Open: %w", err)
}
 
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
    return fmt.Errorf("db ping: %w", err)
}
log.Println("DB接続OK")

PingContext でタイムアウトを切ること。DNS が引けない・ネットワークが遅い時に永久ブロックされないため。

4. DSN(Data Source Name)の構造

// MySQL の DSN(go-sql-driver/mysql 形式)
"user:password@tcp(host:port)/dbname?param1=value1&param2=value2"
 
// 例
"appuser:s3cr3t@tcp(db.example.com:3306)/myapp?parseTime=true&loc=Asia%2FTokyo&charset=utf8mb4"
 
// PostgreSQL の DSN(lib/pq 形式)
"postgres://user:password@host:5432/dbname?sslmode=require"
 
// PostgreSQL の DSN(key=value 形式・どっちも使える)
"host=localhost port=5432 user=appuser password=s3cr3t dbname=myapp sslmode=require"

DSN は「DB接続情報の文字列表現」

Driver ごとに微妙にフォーマットが違うため、毎回ドキュメントを確認するクセを付ける。

MySQL ドライバで最低限指定したいパラメータ

パラメータ推奨値理由
parseTimetrueDATETIME / TIMESTAMPtime.Time にスキャンする
locAsia%2FTokyo (URLエンコード)タイムゾーン
charsetutf8mb4絵文字対応の本物の UTF-8
collationutf8mb4_unicode_ci照合順序
interpolateParamstrue(要検討)ステートメントキャッシュをバイパス。プレースホルダの仕組みが変わる

parseTime=true を入れ忘れると、time.Time 型のカラムを Scan しようとした時に unsupported Scan, storing driver.Value type []uint8 into type *time.Time という分かりにくいエラーで詰まる。これは Go DB入門者の通過儀礼

DSN のパスワードに含まれる特殊文字

パスワードに @:/ が入っていると DSN がパースできなくなる。これらは URL エンコード が必要。

// 悪い例: パスワードに @ を含む
dsn := "user:p@ss@tcp(host:3306)/db" // パース不能
 
// 良い例: net/url でエスケープ
import "net/url"
 
pw := url.QueryEscape("p@ss")
dsn := fmt.Sprintf("user:%s@tcp(host:3306)/db", pw)

セキュリティ事故事例: 平文パスワードを git push する事故。DSN をソースに書かず、必ず環境変数から読む。後の 3-7_本番準備.md で詳しくやる。

5. 構造化された Config

import (
    "fmt"
    "net/url"
)
 
type DBConfig struct {
    User     string
    Password string
    Host     string
    Port     int
    Name     string
}
 
func (c DBConfig) MySQLDSN() string {
    return fmt.Sprintf(
        "%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=Asia%%2FTokyo&charset=utf8mb4&collation=utf8mb4_unicode_ci",
        c.User,
        url.QueryEscape(c.Password),
        c.Host,
        c.Port,
        c.Name,
    )
}

DSN は構造体から生成する

文字列直書きすると環境ごとに差し替えにくく、パスワードエスケープも忘れがち。Config 構造体 → メソッドで DSN を組み立てる ようにしておくと、*_test.go で差し替えやすい・ログに出さない方針を守りやすい・パスワードエスケープを1箇所に閉じ込められる。


セッション②: 接続プールと運用設計(25分)

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

db, err := sql.Open("mysql", dsn)
if err != nil {
    return err
}
 
db.SetMaxOpenConns(25)                  // 同時に開ける接続の上限
db.SetMaxIdleConns(25)                  // アイドル状態で保持する接続数
db.SetConnMaxLifetime(5 * time.Minute)  // 接続を破棄するまでの最大寿命
db.SetConnMaxIdleTime(1 * time.Minute)  // アイドル接続を破棄するまでの時間

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

多くの初心者は *sql.DB を「1つの接続」だと思っている。違う。これは 複数の物理接続を管理するプール

並行リクエストが来た時の挙動:

  1. プール内に空いている接続があればそれを使う
  2. 空きが無く、MaxOpenConns の上限未満なら新規接続を開く
  3. 上限に達していたら、誰かが接続を返すまで ブロック待ち

この「ブロック待ち」が 本番のレイテンシ悪化の主要因SetMaxOpenConnsMySQL の max_connections を上回ると、今度は MySQL 側で接続拒否が起きる。だから両者を整合させる設計が必要。

各パラメータの設計指針

メソッド何を制御するか推奨値の考え方
SetMaxOpenConns(n)同時オープン接続数の上限アプリインスタンス数 × n ≤ DB の max_connections の 70-80%
SetMaxIdleConns(n)アイドル接続を何個キープするか多くの場合 MaxOpenConns と同じで OK(少ないと頻繁な接続再生成でレイテンシ悪化)
SetConnMaxLifetime(d)接続を強制破棄する時間MySQL の wait_timeout より短く。一般に 5分 が良い出発点
SetConnMaxIdleTime(d)アイドル接続を破棄するまでの時間1-5分

なぜ ConnMaxLifetime が必要か

MySQL や PostgreSQL は サーバー側で「使われていない接続を一定時間後にクローズする」設定(MySQL の wait_timeout、デフォルト 8時間)を持っている。

サーバー側が接続を切ったのに Go 側が「まだ生きている接続」だと思ってクエリを投げると invalid connection エラーになる。これを防ぐため、サーバー側のタイムアウトより短い時間で Go 側が自発的に接続を破棄する のが定石。

加えて、ロードバランサ越し(AWS RDS Proxy、HAProxy、ProxySQL)の場合は LB側にも独自タイムアウトがある。本番で invalid connection がたまに出る場合、これが原因のことが多い。

アンチパターン: 接続プール無設定で本番投入

db, _ := sql.Open("mysql", dsn)
// 何もチューニングせず本番に出す

Go の database/sql のデフォルトは MaxOpenConns = 0 (無制限)。これでトラフィックが増えると、Goアプリが好き勝手に接続を開き、ある日突然 MySQL の max_connections を食い潰してサービス全体がダウンする。

「DB に繋がらない」のではなく、「Go アプリが DB のリソースを使い切ってしまう」 事故が起きる。実際にこれで深夜に呼び出された経験談は山ほどある。SetMaxOpenConns は必ず設定する

アンチパターン: リクエストごとに sql.Open

// これは絶対 NG
func handleUser(w http.ResponseWriter, r *http.Request) {
    db, _ := sql.Open("mysql", dsn)
    defer db.Close()
    // ... クエリ実行
}

sql.Open は接続プールを作るコストの高い操作。リクエストごとに呼ぶと、プールの意味が無くなって接続オープン/クローズが大量発生し、ベンチマークで100倍以上遅くなる。

正しくは アプリ全体で *sql.DB を1つだけ作って共有する。*sql.DB は内部的にスレッドセーフ。

7. プールサイズの実務的な決め方

「DB の max_connections をアプリで分配する」発想

例: AWS RDS MySQL の max_connections = 300 を、3台のアプリインスタンスで使うとする。

  • 各アプリの SetMaxOpenConns = 300 × 0.7 / 3 ≒ 70
  • 残りの 30% は管理用接続(dump、メンテ、監視)に取っておく

ただし「多ければいい」は誤り。アプリ並列度を増やすと DB の競合が増えるため、ある点を超えると逆にスループットが落ちる。ベンチマークを取って最適値を探すのが正解。

目安: PostgreSQL は max_connections=100、MySQL は 150-300 が多い。クラウド DB は分単位の課金スペックでも数百は確保される。

8. 観測: 接続プール統計を出す

import "expvar"
 
stats := db.Stats()
log.Printf("OpenConnections: %d, InUse: %d, Idle: %d, WaitCount: %d, WaitDuration: %s",
    stats.OpenConnections,
    stats.InUse,
    stats.Idle,
    stats.WaitCount,
    stats.WaitDuration,
)

db.Stats() で接続プールを観測する

*sql.DB.Stats() は接続プールの稼働状況を返す。これを Prometheus などのメトリクスに流すと、プールが詰まっているか一目で分かる

観るべき指標:

  • WaitCount: 接続が空くのを待った回数。これが増えている = プールが小さすぎる
  • WaitDuration: 累積待ち時間。レイテンシ悪化の主因になる
  • InUse / OpenConnections: 使用率。常時 90%超なら危険信号
  • MaxIdleClosed / MaxLifetimeClosed: 設定で破棄された接続数。極端に多ければ設定見直し

本番投入後、これらを Grafana に出すだけで「接続プール起因の障害」の8割は事前検知できる。

9. ORM / SQL ビルダー / sqlc の位置付け

// database/sql 直書き
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users WHERE id = ?", id)
 
// sqlx (database/sql のラッパー)
err = db.GetContext(ctx, &user, "SELECT id, name FROM users WHERE id = ?", id)
 
// sqlc (SQL → 型付き関数を生成)
user, err := queries.GetUser(ctx, id) // SQL から自動生成された関数
 
// gorm (ORM)
db.WithContext(ctx).First(&user, id)
 
// ent (スキーマ駆動 ORM)
user, err := client.User.Get(ctx, id)

それぞれのトレードオフ

ライブラリ学習コスト型安全パフォーマンス推奨用途
database/sql 直書き中(Scan が手書き)すべての基礎・小規模アプリ
jmoiron/sqlx中(タグで struct マッピング)database/sql + 構造体マッピング欲しい時
sqlc中(SQL は書く)(コード生成で型付き)大規模、SQL を生で書きたいチーム
gorm低-中(リフレクションベース)プロトタイピング、CRUD だけのアプリ
ent高(スキーマ駆動)グラフ的なデータモデル、Facebook 由来

「最初に gorm」を勧めない理由

gorm はリフレクションでクエリを組み立てる。何が裏で走っているかブラックボックス化されやすく、「思っていないクエリが N回投げられている(N+1)」 という事故が起きやすい。

Go コミュニティでは「ORMより SQL を書け」という文化が比較的強い。sqlc の人気が高いのもその表れ。database/sql で素手で書ける人が、必要に応じて上位ライブラリを選ぶ 順序が安全。

10. 練習課題: 接続プール込みの初期化関数

// internal/db/db.go
package db
 
import (
    "context"
    "database/sql"
    "fmt"
    "time"
 
    _ "github.com/go-sql-driver/mysql"
)
 
type Config struct {
    DSN             string
    MaxOpenConns    int
    MaxIdleConns    int
    ConnMaxLifetime time.Duration
}
 
func Open(ctx context.Context, cfg Config) (*sql.DB, error) {
    db, err := sql.Open("mysql", cfg.DSN)
    if err != nil {
        return nil, fmt.Errorf("sql.Open: %w", err)
    }
 
    db.SetMaxOpenConns(cfg.MaxOpenConns)
    db.SetMaxIdleConns(cfg.MaxIdleConns)
    db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
 
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    if err := db.PingContext(ctx); err != nil {
        db.Close()
        return nil, fmt.Errorf("ping: %w", err)
    }
    return db, nil
}

このパターンが Level 3 全体で再利用される基盤。Open 失敗時に db.Close() する こと、PingContext でタイムアウトを切る こと、この2つを必ず守る。


締め: git で証跡を残す

cd ~/learn/go/level3/day01
exit  # script 終了
git add .
git commit -m "feat(go): database/sql の接続プール設計を実装"

アンチパターン集 - やらかし事例

database/sql の本番事故 5選

1. 接続プール無設定で本番投入 Go デフォルトは MaxOpenConns = 0(無制限)。ピーク時に MySQL の max_connections 食い潰しでサービス全体ダウン。

2. リクエストごとに sql.Open

func handler(w, r) {
    db, _ := sql.Open("mysql", dsn)  // 毎回プール作成
    defer db.Close()
}

100倍以上遅い。アプリ全体で1つの *sql.DB を共有。

3. parseTime=true を忘れる DATETIME を Scan しようとして unsupported Scan, storing driver.Value type []uint8 で詰む。

4. DSN にパスワードを平文 git push 平文クレデンシャル流出の典型。環境変数か Secret Manager で。

5. Ping 無しで起動成功と判定 最初のリクエストで初めて接続失敗が発覚。起動時に PingContext(ctx) 必須

対比表で違いを明確化

DB ライブラリ選定マトリクス

ライブラリ学習コスト型安全性能用途
database/sql 直書き中(Scan手書き)全ての基礎
sqlx中(struct タグ)構造体マッピングが欲しい
sqlc(コード生成)大規模、SQL生で書きたい
gorm低-中(リフレクション)プロトタイピング
ent高(スキーマ駆動)グラフ的データ

接続プールパラメータ早見表

パラメータ何を制御推奨値
SetMaxOpenConns同時接続上限DB の max_connections × 0.7 / インスタンス数
SetMaxIdleConnsアイドル保持数MaxOpenConns と同じ
SetConnMaxLifetime接続強制破棄時間5分(DB wait_timeout より短く)
SetConnMaxIdleTimeアイドル破棄時間1-5分

自己評価チェックリスト

手を動かせた

  • MySQL/Postgres/SQLite いずれかに sql.Open で接続
  • PingContext(ctx) でタイムアウト付き接続確認
  • SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime を設定
  • DSN を構造体から組み立て、url.QueryEscape でパスワード安全化
  • db.Stats() で接続プール統計を観測

説明できる

  • sql.Open が物理接続しない設計理由
  • ブランク import _ "github.com/..." の意味
  • *sql.DB がプールである理由
  • invalid connection が出る」原因候補3つ(wait_timeout / LB / ConnMaxLifetime)

やらかし回避

  • リクエストごと sql.Open を避ける
  • DSN を平文で git push しない
  • parseTime=true を忘れない
  • 接続プール無設定で本番に出さない

詰まった時のチートシート

やりたいこと書き方
MySQL に接続sql.Open("mysql", dsn) + _ "github.com/go-sql-driver/mysql"
Postgres に接続 (pgx 経由)sql.Open("pgx", dsn) + _ "github.com/jackc/pgx/v5/stdlib"
SQLite (Pure Go)sql.Open("sqlite", "file:app.db") + _ "modernc.org/sqlite"
接続確認db.PingContext(ctx)
プール統計db.Stats()
DSN組み立てnet/url.QueryEscape でエスケープ必須

「実務OK」基準

  • 接続プールチューニングを説明できる: MaxOpenConns が DB の max_connections とどう連動するか言語化
  • 遅延接続の意味が分かる: sql.Open ≠ 物理接続、Ping で初確認
  • ブランク import で説明が出る: 「ドライバの init()sql.Register を呼ぶため」
  • 本番で invalid connection が出たら原因候補が3つ言える: サーバー側 wait_timeout、LB タイムアウト、ConnMaxLifetime 未設定

さらに深掘りするなら


次のレッスン

3-2 CRUDとトランザクション で実際の CRUD 操作・トランザクション・SQL injection 防止・N+1 問題を扱う。

つながりの予告

  • 本章で作った Open(ctx, cfg) (*sql.DB, error) を次章で UserRepository.Create/Get/Update に発展
  • *sql.DB を Repository フィールドに保持するパターン
  • 接続プール統計の観測は 3-7_本番準備 で Prometheus メトリクスに流す