Go実践 #4 DB連携 — database/sqlとトランザクション

読了 7分

#3 JSON入出力の次、データを内部で永続化する段階。DB連携

Goの標準は — database/sqlパッケージ。他言語のORMのような抽象化は標準にありません。SQLを直接扱いつつ、安全に扱える道具を提供します。

スタート — ドライバの登録 #

PostgreSQL接続
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で接続

代表的なドライバ:

  • PostgreSQLjackc/pgx
  • MySQLgo-sql-driver/mysql
  • SQLitemattn/go-sqlite3 (cgo)またはmodernc.org/sqlite(純粋Go)

単一行の取得 #

QueryRow
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 #

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句を使うのが標準。

PostgreSQL RETURNING
var id int
err := db.QueryRow(
	"INSERT INTO users (name) VALUES ($1) RETURNING id",
	"イ・ドギョン",
).Scan(&id)

Contextと一緒に #

ハンドラで受け取ったcontextを — DB呼び出しにも伝達。

Contextバージョン
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
}

QueryContextExecContextQueryRowContext — すべてctxを受け取るバージョンがあります。リクエストがキャンセルされるとクエリもキャンセル(ドライバがサポートする場合 — メジャードライバの大半はOK)。

中級#5 contextで見たパターンが — DBまでそのままつながります。

Prepared statement #

同じクエリを複数回実行するとき — DBがSQLパース/プランをキャッシュできるように。

Prepare
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

トランザクション分離レベル #

isolationの指定
tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelSerializable,
	ReadOnly:  false,
})

デフォルト値は — DBのdefault。分離レベルが重要な場面(お金の移動、在庫の差し引きなど)は明示的に設定。

NULL処理 #

sql.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.NullStringsql.NullInt64sql.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 #

sqlxのStructScan
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 #

query.sql
-- name: GetUser :one
SELECT id, name FROM users WHERE id = $1;
sqlc generate

→ 上のSQLに対応するGo関数が自動生成。SQLをそのまま書きつつ型安全性を確保。上級#7のコード生成事例。

最近のGoバックエンドで人気 — SQLを直接書きながらボイラープレートを減らす。

GORM #

GORM ORM
type User struct {
	gorm.Model
	Name  string
	Email string
}

db.Where("email = ?", "x@y.z").First(&user)

他言語のORMのように — メソッドチェーン、関連の自動処理。便利ですが — SQLが見えないと性能問題の追跡が難しい、魔法のような動作が多く摩擦。Goコミュニティでは好みが分かれるツール。

どのツールを選ぶか? #

状況おすすめ
小さなサービス、SQL手書きOKdatabase/sql
ボイラープレートだけ減らしたいsqlx
SQL直接 + 型安全性sqlc
他のORMに慣れている、速い開発GORM

主観: 標準 → sqlxsqlcの順序が自然です。GORMは別のコンテキストから移って来た方には親しみやすいですが — Goの明示的なデザインとはやや距離があってトレードオフ。

マイグレーション #

スキーマ変更管理は — Go標準にはありません。通常:

  • golang-migrate/migrate — 最も一般的
  • pressly/goose — Goコードでマイグレーションが書ける
golang-migrate
migrate -database "postgres://..." -path migrations up

migrations/0001_init.up.sql / 0001_init.down.sqlファイルを順に適用。

まとめ #

この記事で整理した内容:

  • database/sql — 標準、ドライバは別importで
  • Query / QueryRow / Exec — メソッドセット
  • placeholderを使う — SQLインジェクション防御
  • Contextバージョンを常に使う — リクエストキャンセルの伝播
  • トランザクションBeginTx + defer Rollback + 最後のCommit
  • NULLsql.Null*またはポインタ
  • プール設定 — MaxOpenConns、ConnMaxLifetimeなど
  • 上位ツール — sqlx(軽量)、sqlc(コード生成)、GORM(ORM)
  • マイグレーション — golang-migrategoose

次の記事(#5 ミドルウェア)では — Goのミドルウェアパターン。http.Handlerアダプタチェーンがどう認証、ロギング、パニック復旧をきれいに解くか整理します。

X