API の信頼性は、最終的にデータベース層の設計で決まります。コネクションプールを誤れば高負荷で枯渇し、トランザクション境界を誤れば不整合が残り、context を渡し忘れればクライアントが切断したのに DB は処理を続ける——こうした事故は「動くデモ」では見えず、本番のピーク時に初めて牙を剥きます。
この記事は、Go Echo 本番運用ガイドのデータベース編です。Echo の役割は薄い HTTP 層なので、DB 設計の大半は Go 標準と各ドライバの作法です。本記事は「Echo ハンドラから DB まで、何をどう繋ぐか」を、型安全・回復性・セキュリティの観点で実装します。
この記事のルール:Echo の API は 公式ドキュメント(v5・2026年6月時点) に基づきます。DB ライブラリ(
pgxv5 /sqlc/ GORM v2 /golang-migrate)は活発に更新されるため、本番投入前に各公式で最新 API を確認してください。接続情報(DSN・認証情報)は環境変数/シークレットマネージャ前提で、コードやイメージに焼き込みません。
1. 最重要:context をハンドラから DB まで貫通させる
Echo ハンドラから DB を呼ぶとき、必ず c.Request().Context() を渡します。これが本記事で唯一「Echo 特有」かつ最も効くポイントです。
func (h *UserHandler) Get(c *echo.Context) error {
ctx := c.Request().Context() // ← リクエストの context を取り出す
user, err := h.repo.FindByID(ctx, c.Param("id")) // ← DB まで渡す
if err != nil {
return err
}
return c.JSON(http.StatusOK, user)
}
なぜ重要か。この ctx にはリクエストのライフサイクルが乗っています。
- クライアント切断:ブラウザが離脱すると
ctxがキャンセルされ、進行中のクエリも中断される。切断された処理が DB コネクションを占有し続ける事故を防ぐ。 - タイムアウト:
context.WithTimeoutを噛ませれば、遅いクエリを期限で打ち切れる(v5 でmiddleware.Timeoutが削除されたいま、タイムアウトは context で表現する)。 - トレース伝播:OpenTelemetry のトレース ID が
ctxを通って DB スパンまで繋がり、可観測性が一本の糸になる。
// 重いクエリには期限を付ける
ctx, cancel := context.WithTimeout(c.Request().Context(), 3*time.Second)
defer cancel()
rows, err := pool.Query(ctx, "SELECT ...")
context.Background() を DB 呼び出しに使うのはアンチパターンです。リクエスト由来の ctx を捨てた瞬間、上記の恩恵をすべて失います。
2. ライブラリ選定:pgx / sqlc / GORM を損益で選ぶ
「とりあえず GORM」で始めて後悔する、はよくある話です。3つの選択肢を軸で選びます。
| 観点 | database/sql + pgx | sqlc | GORM |
|---|---|---|---|
| 抽象度 | 低(生 SQL) | 低(生 SQL + 型生成) | 高(ORM) |
| 型安全 | 手動マッピング | SQL から Go を自動生成(最強) | 構造体タグ依存 |
| 学習コスト | 中 | 中 | 低〜中(独自 DSL) |
| パフォーマンス | ◎ | ◎ | ○(リフレクション) |
| 複雑クエリ | ◎(SQL そのまま) | ◎(SQL そのまま) | △(生 SQL へ退避しがち) |
| 向く場面 | 細かい制御・高性能 | 型安全と SQL の両立 | CRUD 中心・開発速度優先 |
判断の目安:
- 型安全を最重視するなら sqlc。
.sqlファイルに書いたクエリから、引数・戻り値が型付けされた Go コードが生成されます。「SQL を書く自由」と「コンパイル時の型保証」を両立でき、本サイトが掲げる型安全の規律と最も相性が良い選択です。 - 低レベルな制御・最高性能が要るなら pgx(PostgreSQL 専用ドライバ)。
- CRUD 中心で開発速度優先なら GORM。ただし複雑クエリで結局生 SQL に逃げるなら、最初から sqlc/pgx の方が一貫します。
3. pgx:コネクションプールを正しく設定する
pgxpool は本番で必須のコネクションプールです。プールの上限設計を誤ると、高負荷時に too many connections でサービスが落ちます。
import "github.com/jackc/pgx/v5/pgxpool"
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn) // DSN は os.Getenv("DATABASE_URL")
if err != nil {
return nil, err
}
cfg.MaxConns = 10 // このインスタンスが握る最大接続数
cfg.MinConns = 2 // 常時温めておく最小接続数
cfg.MaxConnLifetime = time.Hour // 接続を作り直す周期(DB側の制限対策)
cfg.MaxConnIdleTime = 30 * time.Minute // アイドル接続の回収
cfg.HealthCheckPeriod = time.Minute // 死んだ接続の検出
return pgxpool.NewWithConfig(ctx, cfg)
}
MaxConnsの決め方:DB の max_connectionsを全アプリインスタンス数で割り、管理接続分の余裕を引いた値が上限です。例:max_connections=100、アプリ 4 インスタンス、予約 20 なら(100 - 20) / 4 = 20が天井。ここを「とりあえず大きく」すると、オートスケールで接続が一気に増えて DB が先に死にます。
プールはアプリ起動時に一度だけ生成し、Echo ハンドラ間で共有します。lifespan 相当の初期化は main で行い、defer pool.Close() でグレースフルに閉じます。
3.1 型安全に近い行マッピング(pgx v5)
pgx v5 は、行を構造体に詰めるヘルパを備えます。手書きの rows.Scan 地獄を避けられます。
type User struct {
ID string `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
}
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
rows, err := r.pool.Query(ctx, `SELECT id, name, email FROM users WHERE id = $1`, id)
if err != nil {
return nil, err
}
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound // ドメインエラーへ変換(後述)
}
return &user, err
}
4. トランザクション境界:WithTx ヘルパに集約する
トランザクションは「複数の書き込みを全か無かにする」ための機構です。Begin / Commit / Rollback を各所に手書きすると、rollback の書き忘れで接続がリークします。1つのヘルパに集約します。
// fn の中の処理を1トランザクションで実行する。エラー/panic なら rollback
func (r *Store) WithTx(ctx context.Context, fn func(tx pgx.Tx) error) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) // commit 済みなら no-op。panic 時も確実に巻き戻る
if err := fn(tx); err != nil {
return err // defer の Rollback が走る
}
return tx.Commit(ctx)
}
使う側は、トランザクションの意図だけを書けます。
func (uc *OrderUseCase) Place(ctx context.Context, order Order) error {
return uc.store.WithTx(ctx, func(tx pgx.Tx) error {
if err := insertOrder(ctx, tx, order); err != nil {
return err
}
return decrementStock(ctx, tx, order.Items) // どちらか失敗すれば両方巻き戻る
})
}
冪等性は DB に担保させる:リトライや二重送信に対しては、アプリのフラグ管理より**一意制約(unique index)**が堅牢です。
INSERT ... ON CONFLICT DO NOTHINGや冪等キー列で「同じ操作は1回だけ」を DB の制約として表現します。決済のように厳密さが要る領域での冪等設計は、二重課金ゼロの決済信頼性で深掘りしています。
5. Repository パターン:DB をインターフェースの裏に隠す
UseCase 層が pgx や GORM を直接知ると、DB を差し替えられず、テストに本物の DB が要ります。Repository をインターフェースで定義し、実装を裏に隠します。これはクリーンアーキテクチャ + DIの中核でもあります。
// ドメイン層:インターフェース(DB 技術を知らない)
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, u *User) error
}
// インフラ層:pgx 実装(差し替え可能)
type pgUserRepository struct {
pool *pgxpool.Pool
}
func NewUserRepository(pool *pgxpool.Pool) UserRepository {
return &pgUserRepository{pool: pool}
}
これで UseCase は UserRepository にのみ依存し、テストではモックに差し替え、本番では pgx 実装を注入する——という疎結合が成立します。
6. セキュリティ:SQLインジェクションを構造的に潰す
外部入力を SQL に文字列連結してはいけません。 必ずプレースホルダ($1, $2…)を使います。これは「気をつける」ではなく「そうとしか書けない構造にする」のが正解です。
// ❌ 絶対にやらない:文字列連結(SQLインジェクション)
q := "SELECT * FROM users WHERE email = '" + email + "'" // 攻撃者が ' OR '1'='1 を注入できる
// ✅ プレースホルダ(ドライバが値を安全にエスケープ/バインド)
rows, err := pool.Query(ctx, "SELECT * FROM users WHERE email = $1", email)
ORDER BY のカラム名のようにプレースホルダが使えない箇所は、ユーザー入力を直接埋めず、**許可リスト(allowlist)**で受けます。
allowed := map[string]string{"created_at": "created_at", "name": "name"}
col, ok := allowed[c.QueryParam("sort")]
if !ok {
col = "created_at" // 既定値。未知の入力は SQL に渡さない
}
query := fmt.Sprintf("SELECT ... ORDER BY %s", col) // col は allowlist 由来なので安全
入力の検証はバインディング&バリデーションで境界に効かせ、SQL 層では「もう信用できる値しか来ない」状態を二重に守ります。
7. GORM を選んだ場合の本番注意点
GORM を使うなら、最低限この2つは押さえます。
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
// ① プールは内部の *sql.DB で設定する(GORM 自体には無い)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(10)
sqlDB.SetMaxIdleConns(2)
sqlDB.SetConnMaxLifetime(time.Hour)
// ② context を必ず渡す(WithContext)。N+1 は Preload で潰す
var users []User
err = db.WithContext(ctx).Preload("Orders").Find(&users).Error // 関連を1回でまとめて取得
GORM の最大の罠は N+1 問題(一覧取得後に関連を1件ずつ引いて N 回追加クエリが飛ぶ)です。Preload/Joins でまとめて取得します。WithContext(ctx) を忘れると第1章の恩恵を失う点も同じです。
8. マイグレーション:スキーマもコードで管理する
スキーマ変更は手動 SQL ではなく、バージョン管理されたマイグレーションで行います(golang-migrate / goose が定番)。
# golang-migrate:up/down のペアでバージョンを進める
migrate -database "$DATABASE_URL" -path ./migrations up
CI でマイグレーションの適用と整合性を検証し、本番デプロイ前に流す運用にします。無停止でのスキーマ進化(後方互換を保った段階的変更)は、ゼロダウンタイム・マイグレーションの設計(Python 例だが原則は共通)が体系的です。
9. サーバーレス/オートスケールでの接続枯渇
Lambda や Cloud Run のようにインスタンスが多数・短命な環境では、各インスタンスがプールを持つと接続数が爆発します。max_connections を即座に食い潰し、DB が落ちます。
- 常駐サーバー(ECS/Cloud Run min>0):
pgxpoolでプールを共有すれば素直に機能する。 - Lambda 等の関数:接続プロキシ(RDS Proxy 等)を挟み、関数側はプロキシに繋ぐ。コネクションの多重化はプロキシに任せる。
この罠と対策は、Lambda × RDS/Aurora の接続管理(RDS Proxy)で詳説しています。Echo を ECS/Fargate で常駐運用するなら通常は pgxpool で十分で、これが Echo を「関数」ではなく「常駐サーバー」で動かす利点でもあります。
まとめ:DB 層を本番品質にする7原則
c.Request().Context()を全クエリに貫通させる。context.Background()を DB に使わない。- 型安全重視なら sqlc、制御なら pgx、速度なら GORM——損益で選ぶ。
pgxpoolのMaxConnsは DB 上限÷インスタンス数から逆算。大きすぎる設定が DB を殺す。- トランザクションは
WithTxに集約。defer tx.Rollbackで巻き戻しを構造化する。 - Repository をインターフェースで隠す。DB 差し替えとテスト容易性を両立。
- 必ずプレースホルダ。連結禁止。プレースホルダ不可の箇所は allowlist。
- 冪等性は一意制約で DB に担保させ、サーバーレスでは接続プロキシで枯渇を防ぐ。
DB 層は「動く」と「本番で耐える」の差が最も大きい領域です。Echo の薄さは、この層を Go の標準的な作法で堅く作れることの裏返しでもあります。次は、この上に載る認証・認可と、全体を束ねるクリーンアーキテクチャへ。