Go実践 #4 DB連携 — database/sqlとトランザクション
#3 JSON入出力の次、データを内部で永続化する段階。DB連携。
Goの標準は — database/sqlパッケージ。他言語のORMのような抽象化は標準にありません。SQLを直接扱いつつ、安全に扱える道具を提供します。
スタート — ドライバの登録 #
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/mydb")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal(err)
}
}中核は2つ:
_ "github.com/.../stdlib"— 空のimportでドライバ登録(side effect)sql.Open自体は接続しない — 最初のクエリまたはPingで接続
代表的なドライバ:
- PostgreSQL —
jackc/pgx - MySQL —
go-sql-driver/mysql - SQLite —
mattn/go-sqlite3(cgo)またはmodernc.org/sqlite(純粋Go)
単一行の取得 #
var name string
err := db.QueryRow("SELECT name FROM users WHERE id = $1", 42).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
// 見つからない
} else if err != nil {
// 別のエラー
}中核:
$1(または?、ドライバによる) — placeholder。値は別の引数でScanで結果を変数に入れるsql.ErrNoRowsが — 「結果なし」の標準エラー
SQLインジェクション防御 — placeholderが標準。
fmt.SprintfでSQL文字列を作らないでください。
複数行 — Query + Rows
#
rows, err := db.Query("SELECT id, name FROM users WHERE active = $1", true)
if err != nil {
return err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return err
}3つを忘れないこと:
defer rows.Close()— 閉じないとコネクションリークrows.Next()で1行ずつrows.Err()で最後のチェック — ループ中に発生したエラー確認
Insert / Update — Exec
#
result, err := db.Exec(
"INSERT INTO users (name, email) VALUES ($1, $2)",
"イ・ドギョン", "x@y.z",
)
if err != nil {
return err
}
id, _ := result.LastInsertId() // MySQLなど
affected, _ := result.RowsAffected()LastInsertIdは — DBによって動作。PostgreSQLは — RETURNING句を使うのが標準。
var id int
err := db.QueryRow(
"INSERT INTO users (name) VALUES ($1) RETURNING id",
"イ・ドギョン",
).Scan(&id)Contextと一緒に #
ハンドラで受け取ったcontextを — DB呼び出しにも伝達。
func GetUser(ctx context.Context, db *sql.DB, id int) (User, error) {
var u User
err := db.QueryRowContext(ctx,
"SELECT id, name FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name)
return u, err
}QueryContext、ExecContext、QueryRowContext — すべてctxを受け取るバージョンがあります。リクエストがキャンセルされるとクエリもキャンセル(ドライバがサポートする場合 — メジャードライバの大半はOK)。
中級#5 contextで見たパターンが — DBまでそのままつながります。
Prepared statement #
同じクエリを複数回実行するとき — DBがSQLパース/プランをキャッシュできるように。
stmt, err := db.Prepare("SELECT name FROM users WHERE id = $1")
if err != nil {
return err
}
defer stmt.Close()
for _, id := range ids {
var name string
stmt.QueryRow(id).Scan(&name)
// ...
}ただし — Goのdatabase/sqlは単一クエリも自動的にpreparedとして処理します(ドライバによる)。明示的なPrepareが必要な場面は — 同じクエリを短時間に非常に頻繁に呼ぶ場合くらい。
トランザクション #
func transfer(ctx context.Context, db *sql.DB, from, to int, amount int) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // commitされなければ自動rollback
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount, from,
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, to,
); err != nil {
return err
}
return tx.Commit()
}中核パターン:
defer tx.Rollback()— 関数がcommitなしで終了すれば自動rollback。commit後の呼び出しは無害(ErrTxDoneは無視)。- すべてのクエリは —
tx.ExecContext/tx.QueryContextで
トランザクション分離レベル #
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: false,
})デフォルト値は — DBのdefault。分離レベルが重要な場面(お金の移動、在庫の差し引きなど)は明示的に設定。
NULL処理 #
var u struct {
ID int
Email sql.NullString // NULL可
}
err := db.QueryRow(...).Scan(&u.ID, &u.Email)
if u.Email.Valid {
fmt.Println(u.Email.String)
}DBのNULLを — Goのzero valueと区別すべきときsql.NullString、sql.NullInt64、sql.NullTimeを使用。JSONで見たポインタパターンと似た動機。
または — ポインタ (*string)も可能ですが、sql.Null*が明示的でよく使われます。
コネクションプール設定 #
*sql.DB自体がコネクションプールです。1つのインスタンスをアプリ全体で共有。
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute)典型的な推奨:
- MaxOpenConns — DBが耐えられる上限、通常10~50
- MaxIdleConns — MaxOpenと同じ値が普通
- ConnMaxLifetime — 5~30分(load balancer越しなら更新)
- ConnMaxIdleTime — 1~10分
よくある落とし穴 #
1) rows.Close()の漏れ — コネクションリーク
#
rows, _ := db.Query(...)
// forループ中に早めにreturn → rowsが閉じない → コネクション占有
for rows.Next() {
if some_condition {
return // ✗ rows.Closeされない
}
}解決 — 常にdefer rows.Close()を最初の行に。
2) Scanの型ミスマッチ #
var n int
db.QueryRow("SELECT amount FROM x").Scan(&n)
// amountがNUMERICならば — driftの可能性、エラーの可能性DBカラム型とGoの型を — 正確にマッピング。曖昧ならstringで受けて変換。
3) トランザクション内での通常のdb呼び出し #
tx, _ := db.BeginTx(ctx, nil)
db.Exec(...) // ✗ txではなく通常のコネクションを使用
tx.Commit()トランザクション内では — 必ずtx.*を通して。通常のdb.*は別のコネクションで動作してトランザクション外。
上位ツール #
database/sqlだけで十分な場面もありますが — しばしば次のツールが登場。
sqlx
#
type User struct {
ID int `db:"id"`
Name string `db:"name"`
}
var users []User
err := sqlx.SelectContext(ctx, db, &users, "SELECT id, name FROM users")標準database/sqlの上に — struct自動マッピング、Get / Selectヘルパーだけを追加。摩擦が最も少ない追加ツール。
sqlc
#
-- name: GetUser :one
SELECT id, name FROM users WHERE id = $1;sqlc generate→ 上のSQLに対応するGo関数が自動生成。SQLをそのまま書きつつ型安全性を確保。上級#7のコード生成事例。
最近のGoバックエンドで人気 — SQLを直接書きながらボイラープレートを減らす。
GORM #
type User struct {
gorm.Model
Name string
Email string
}
db.Where("email = ?", "x@y.z").First(&user)他言語のORMのように — メソッドチェーン、関連の自動処理。便利ですが — SQLが見えないと性能問題の追跡が難しい、魔法のような動作が多く摩擦。Goコミュニティでは好みが分かれるツール。
どのツールを選ぶか? #
| 状況 | おすすめ |
|---|---|
| 小さなサービス、SQL手書きOK | database/sql |
| ボイラープレートだけ減らしたい | sqlx |
| SQL直接 + 型安全性 | sqlc |
| 他のORMに慣れている、速い開発 | GORM |
主観: 標準 →
sqlx→sqlcの順序が自然です。GORMは別のコンテキストから移って来た方には親しみやすいですが — Goの明示的なデザインとはやや距離があってトレードオフ。
マイグレーション #
スキーマ変更管理は — Go標準にはありません。通常:
golang-migrate/migrate— 最も一般的pressly/goose— Goコードでマイグレーションが書ける
migrate -database "postgres://..." -path migrations upmigrations/0001_init.up.sql / 0001_init.down.sqlファイルを順に適用。
まとめ #
この記事で整理した内容:
database/sql— 標準、ドライバは別importでQuery/QueryRow/Exec— メソッドセット- placeholderを使う — SQLインジェクション防御
Contextバージョンを常に使う — リクエストキャンセルの伝播- トランザクション —
BeginTx+defer Rollback+ 最後のCommit - NULL —
sql.Null*またはポインタ - プール設定 — MaxOpenConns、ConnMaxLifetimeなど
- 上位ツール —
sqlx(軽量)、sqlc(コード生成)、GORM(ORM) - マイグレーション —
golang-migrate、goose
次の記事(#5 ミドルウェア)では — Goのミドルウェアパターン。http.Handlerアダプタチェーンがどう認証、ロギング、パニック復旧をきれいに解くか整理します。