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()ordb.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 FINsql.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 到達性確認済み」が保証される。
対応表
| 概念 | Go | Rust (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) |
| 上限 | SetMaxOpenConns | max_connections |
| Idle 保持 | SetMaxIdleConns | min_connections |
| Idle 期限 | SetConnMaxIdleTime | idle_timeout |
| 総寿命 | SetConnMaxLifetime | max_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 = 1max_connections = 5connect_timeout = 5idle_timeout = 120main で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() してから注入してくる。ハンドラが返る時に PoolConnection が Drop されて、プールに自動返却される。
Go の defer rows.Close() を、Rust は所有権 + Drop で言語仕様レベルで保証してる。
プール設定 (Go)
| 設定 | 何を制御する | キャッシュで言うと |
|---|---|---|
SetMaxOpenConns | 同時に開ける上限 | メモリ上限 |
SetMaxIdleConns | idle で保持する数 | キャッシュサイズ |
SetConnMaxIdleTime | idle が何分放置されたら切るか | エントリ 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()ordb.Ping()で TCP 接続開始 db.Query()= プールから借用rows.Close()= プールに返却(TCP は切らない)- 物理切断は idle 期限 / Lifetime 期限 /
db.Close()の時だけ - Rust
PgPool::connectは connect 時に TCP 繋ぐ、min_connectionsで preheat - Rocket + sqlx は RAII で返却漏れが物理的に起こらない