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つ。
- すべての上位ライブラリは
database/sqlの上に乗っている。gorm のFind()が遅い時、何が起きているか追えるのは「sql.DB の挙動を知ってる人」だけ。 - 本番障害の8割は接続プール周り。
SetMaxOpenConnsを知らずに本番に出して、ピーク時に MySQL のmax_connectionsを食い潰す事故は今でも頻発している。ORM はこれを隠蔽するので、隠蔽されているものを先に知っておく必要がある。 - デバッグ容易性。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/dbplay1. 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/mysql、lib/pq、jackc/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/mysqlのinit()は内部で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 / MariaDB go-sql-driver/mysql github.com/go-sql-driver/mysql事実上の標準 PostgreSQL (database/sql 経由) lib/pq github.com/lib/pqメンテモード、新規は pgx 推奨 PostgreSQL (database/sql 経由・推奨) pgx の stdlib github.com/jackc/pgx/v5/stdlibpgx の本体機能はネイティブAPI PostgreSQL (ネイティブ) pgx github.com/jackc/pgx/v5より高速・型情報豊富、 database/sqlを使わないSQLite mattn/go-sqlite3 github.com/mattn/go-sqlite3CGO が必要 SQLite (CGO不要) modernc.org/sqlite modernc.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¶m2=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/TIMESTAMPをtime.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つの接続」だと思っている。違う。これは 複数の物理接続を管理するプール。並行リクエストが来た時の挙動:
- プール内に空いている接続があればそれを使う
- 空きが無く、
MaxOpenConnsの上限未満なら新規接続を開く- 上限に達していたら、誰かが接続を返すまで ブロック待ち
この「ブロック待ち」が 本番のレイテンシ悪化の主要因。
SetMaxOpenConnsがMySQL の 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.Openfunc 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 未設定
さらに深掘りするなら
- 公式: pkg.go.dev/database/sql (標準パッケージのドキュメント)
- 公式チュートリアル: Accessing a relational database (go.dev)
- 良記事: Configuring sql.DB for Better Performance — Alex Edwards
- OSS実装:
kelseyhightower/contributorsやgolang/pkgsiteのソースに DB 初期化のリアルな例がある - 書籍: 『Let’s Go Further』(Alex Edwards) - net/http + database/sql の本気の本
次のレッスン
3-2 CRUDとトランザクション で実際の CRUD 操作・トランザクション・SQL injection 防止・N+1 問題を扱う。
つながりの予告
- 本章で作った
Open(ctx, cfg) (*sql.DB, error)を次章でUserRepository.Create/Get/Updateに発展 *sql.DBを Repository フィールドに保持するパターン- 接続プール統計の観測は 3-7_本番準備 で Prometheus メトリクスに流す