DEV

rows.Close() は接続を切ってない — Go の *sql.DB はプールだった

Go で db.Query() の結果に rows.Close() を書く意味を、プール概念を Go と Rust (Rocket + sqlx) で並べて整理したメモ。

Go で XSS を実演する記事を書いてて、db.Query(...) の結果に defer rows.Close() を書いてた。「なんで rows(SELECT の結果)を Close するんだろう?db.Close() なら分かるけど」と気になった。

答え:rows は「検索結果のデータ + プールから借りてる接続の借用トークン」を束ねたハンドル。だから返却の合図として rows.Close() が要る。整理する。

結論

  • *sql.DB は接続プール
  • sql.Open() はプール構造体を作るだけ(TCP は繋がない)
  • 初回 db.Query() or db.Ping() で TCP 接続が張られる
  • db.Query() は「プールから接続を借りる」
  • rows.Close() は「プールに接続を返す」(TCP は切らない)
  • 物理切断は idle 期限 or Lifetime 期限 or db.Close() で発動

ライフサイクル (Go)

sql.Open(url) ← プール構造体を作る (TCP なし)
db.Ping() or db.Query(...) ← ここで初めて TCP 接続を張る
db.Query(...) ← プールから idle 接続を1つ借用
↓ rows を返す (rows は「借用トークン + 受信バッファ + パース状態」)
rows.Next() ... rows.Scan(...) ← 借用中の接続の受信バッファから読む
rows.Close() ← プールに返却 (物理接続は生きたまま idle へ)
ConnMaxIdleTime 超過 or db.Close() ← ここで初めて TCP FIN

sql.Open は TCP を繋がない

db, err := sql.Open("postgres", url)
// URL の形式チェックのみ。TCP は張らない。
// err == nil でも DB に到達可能とは限らない。

物理接続が張られるのは:

  • 初回の db.Query() / db.Exec()
  • 明示的に db.Ping() を呼んだ時

起動時に到達性を確認したければ Ping する:

db, _ := sql.Open("postgres", url)
if err := db.Ping(); err != nil {
log.Fatal("DB unreachable:", err)
}

rows.Next() は接続を新しく借りない

rows, _ := db.Query(sqlStr) // 1回だけ借用
defer rows.Close()
for rows.Next() { // ループしても借用は増えない
rows.Scan(...)
}

rows.Next() は「借用中の接続の受信バッファから次の DataRow を読む」だけ。DB に「次の行くれ」というコマンドを送ってるわけじゃない。Postgres なら Execute 1発でサーバが全行をストリームしてくる。

物理切断が起きるタイミング

  • ConnMaxIdleTime 超過 → idle 放置された接続を切る
  • ConnMaxLifetime 超過 → 次の返却時に切って作り直す
  • DB 側から切られる(再起動、pg_terminate_backend 等)
  • db.Close() を呼ぶ or プロセス終了

普段の CRUD ではまず起きない。rows.Close() は物理切断とは無関係。

Rust (sqlx::PgPool) の場合

Rust は connect() 時点で物理接続を張る:

let pool = PgPool::connect(url).await?; // ここで実際に1接続作る

min_connections(N) で N 個を preheat できる:

PgPoolOptions::new()
.min_connections(5) // 起動時に 5 接続を先に確保
.max_connections(25)
.connect(url).await?

「アプリ起動 = DB 到達性確認済み」が保証される。

対応表

概念GoRust (sqlx)
プール作成sql.Open() を main で1回PgPool::connect() を1回
起動時に物理接続作らない(要 Ping作る
Preheat手動 Ping を N 回min_connections(N)
借用db.Query() の内部で暗黙pool.acquire() or Request Guard
返却defer rows.Close() を書くRAII で自動(Drop
上限SetMaxOpenConnsmax_connections
Idle 保持SetMaxIdleConnsmin_connections
Idle 期限SetConnMaxIdleTimeidle_timeout
総寿命SetConnMaxLifetimemax_lifetime

Rocket + sqlx の実装例

自分で作った rust-rocket-sqlx-sample から抜粋。

プール定義 (src/db.rs)

use rocket_db_pools::{sqlx, Connection, Database};
use sqlx::{pool::PoolConnection, Postgres};
pub type ConnectionDb = Connection<Db>;
#[derive(Database)]
#[database("hoge")]
pub struct Db(sqlx::PgPool);

設定 (Rocket.toml)

[default.databases.hoge]
url = "postgres://user:password@localhost/hoge"
min_connections = 1
max_connections = 5
connect_timeout = 5
idle_timeout = 120

main で1回 attach

#[launch]
async fn rocket() -> _ {
rocket::build()
.attach(Db::init()) // pool 初期化 fairing (launch 時に1回)
.mount("/users", user_controller::routes())
}

ハンドラで自動注入

#[get("/")]
async fn index(mut db: ConnectionDb) -> Result<Json<Vec<User>>, AppError> {
let users = query_as!(User, "SELECT * FROM users")
.fetch_all(&mut *db).await?;
Ok(Json(users))
}

mut db: ConnectionDb を引数に取るだけで、Rocket が Request Guard として pool.acquire() してから注入してくる。ハンドラが返る時に PoolConnectionDrop されて、プールに自動返却される。

Go の defer rows.Close() を、Rust は所有権 + Drop で言語仕様レベルで保証してる。

プール設定 (Go)

設定何を制御するキャッシュで言うと
SetMaxOpenConns同時に開ける上限メモリ上限
SetMaxIdleConnsidle で保持する数キャッシュサイズ
SetConnMaxIdleTimeidle が何分放置されたら切るかエントリ TTL
SetConnMaxLifetime接続の総寿命全体の TTL

物理接続の open は数十ms〜数百ms 掛かるので、idle で保持しておけば次のリクエストで即再利用できる。

デフォルトは MaxIdleConns = 2 のみでピーク後にすぐ縮む。Web サーバの定跡:

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxIdleTime(5 * time.Minute)
db.SetConnMaxLifetime(30 * time.Minute)

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

func handler(w http.ResponseWriter, r *http.Request) {
db, _ := sql.Open("postgres", url) // 毎回新プール
defer db.Close()
db.Query("...")
}
  • プール作る度に TCP + 認証コスト
  • idle 再利用のキャッシュ効果ゼロ
  • 同時リクエスト × MaxOpenConns で青天井
  • Postgres の max_connections を突き抜けて too many connections

正しくはプロセス起動時に sql.Open を1回、全ハンドラで共有:

var db *sql.DB
func main() {
db, _ = sql.Open("postgres", url) // 1回だけ
db.Ping()
db.SetMaxOpenConns(25)
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
func handler(w, r) { db.Query(...) } // 共有プールから借用

プロセスを跨いでプールを共有したい時

複数プロセス(K8s レプリカ、Lambda)が同じ DB に繋がるケースでは、DB 側の max_connections をすぐ突き抜ける。この時は外部プールプロキシを挟む:

[App1] ──┐
[App2] ──┼─→ [PgBouncer] ─→ [Postgres]
[App3] ──┘ (物理接続を max_connections=100
統合管理) 物理接続=20だけ

AWS Aurora なら RDS Proxy も同じ発想の SaaS。

まとめ

  • *sql.DB = 接続プール
  • sql.Open = プール構造体を作るだけ、TCP は繋がない
  • 初回 db.Query() or db.Ping() で TCP 接続開始
  • db.Query() = プールから借用
  • rows.Close() = プールに返却(TCP は切らない)
  • 物理切断は idle 期限 / Lifetime 期限 / db.Close() の時だけ
  • Rust PgPool::connect は connect 時に TCP 繋ぐ、min_connections で preheat
  • Rocket + sqlx は RAII で返却漏れが物理的に起こらない