编程 Go 的 sql.Stmt 与连接池:为什么句柄数约等于连接数乘 SQL 文本数

2026-11-02 00:04:17

Go 的 sql.Stmt 与连接池:为什么句柄数约等于连接数乘 SQL 文本数

先说能观察到的现象

线上 MySQL 的 Com_stmt_prepare 一直上涨,Com_stmt_close 跟不上,两者差值就是当前存活的 prepared statement 数。同时 processlist 里句柄数大致满足:

句柄数 ≈ 连接数 × SQL 文本种类数

另一个更明显的信号是:Com_stmt_prepare 的累计值远大于业务代码里出现的 SQL 文本种类数。SQL 只有几十条,prepare 却有几十万次——这基本可以确定并不是代码在反复调用 Prepare,而是同一条 SQL 在不同连接上被反复准备。

SHOW GLOBAL STATUS LIKE 'Com_stmt_%';

想看到具体是哪些连接、哪些语句,查 performance_schema.prepared_statements_instances,它按连接列出每条已准备的语句。db.Stats() 只有连接数(Open/InUse/Idle),没有语句数,所以从 Go 侧看不到这个放大。

机制:Stmt 记住它是在哪条连接上准备好的

database/sql 文档对 Stmt 的语义写得很直接:

If a Stmt is prepared on a Tx or Conn, it will be bound to a single underlying connection forever... If a Stmt is prepared on a DB, it will remain usable for the lifetime of the DB. When the Stmt needs to execute on a new underlying connection, it will prepare itself on the new connection automatically.

(pkg.go.dev/database/sql)

数据库层面的 prepared statement 本身绑定一条连接:客户端发送带占位符的 SQL,服务端返回 statement id,之后发送 id 加参数执行。Go 不把连接暴露给使用者,所以在 DB 或 Tx 上 prepare。实际流程是:prepare 在池中某条连接上完成,Stmt 记住用的是哪条连接;执行时如果那条连接关闭或忙,就从池里取另一条连接,并在新连接上重新 prepare。这正是官方 Wiki 里那段话的来源:

it's possible for high-concurrency usage of the database, which may keep a lot of connections busy, to create a large number of prepared statements. This can result in apparent leaks of statements...

(go-database-sql.org/prepared.html)

所以一个 DB 级 *sql.Stmt 并不等于“一条服务端语句”,它对应的是“每条连接各一份”的组合。

还有一层会加重这个现象:DB.PrepareContext 内部调用 db.retry,走的是 maxBadConnRetries = 2,先用两次 cachedOrNewConn 策略,最后用 alwaysNewConn。putConn 时还会调用 Validator.IsValid 和 SessionResetter.ResetSession。也就是说,prepare 本身自带连接重试,一条 Stmt 的准备次数会比预期多。

(sql.go 源码)

句柄数为什么按连接数倍增

连接池默认只保留 2 条 idle 连接,SetConnMaxLifetime 到期连接会被关闭重建。连接一旦重建,之前在该连接上 prepare 过的语句就全部失效;下次执行时,Stmt 要在新连接上重新 prepare。连接数越大、寿命越短,重新 prepare 的次数以及当前存活的语句数就越多。

相关参数见 go.dev/doc/database/manage-connections:SetMaxOpenConns、SetMaxIdleConns、SetConnMaxIdleTime、SetConnMaxLifetime。

隐式路径:带参数的 db.Query 和 db.Exec

db.Query、db.Exec 带参数时,go-sql-driver/mysql 会做 prepare + execute + close 三次往返,语句单次使用,驱动不缓存。设置 interpolateParams=true 可以让参数在客户端插值后走文本协议,避掉这三次往返。

db, err := sql.Open("mysql",
    "user:pass@tcp(127.0.0.1:3306)/app?interpolateParams=true&parseTime=true")

但维护者在 issue 里明确说了作用域:

interpolateParams=true doesn't affect to explicit DB.Prepare and Stmt.Exec

(go-sql-driver/mysql#413)

显式 DB.Prepare 出来的 Stmt 仍然走二进制协议,走服务端 prepared statement。interpolateParams 解决的是隐式路径的往返次数,与 Stmt 在连接上的绑定放大无关。代价是参数在客户端做转义、以文本发送,不再有服务端类型校验,安全边界落在驱动的转义实现上。

边界:Tx、Conn 与不支持的驱动

  • Tx.Prepare / Conn.PrepareContext 得到的 Stmt 永久绑定单条连接,Tx 或 Conn 关闭后 Stmt 不可用,所有操作返回错误。必须 stmt.Close 释放资源。见 Using prepared statements。
  • Tx.Stmt() 把事务外的语句转成事务内语句,做法是每次执行都重新 prepare,源码里就留着 TODO,官方建议不要用。
  • 某些走 MySQL 协议但不支持 binary protocol 的实现(MemSQL、Sphinx),prepare 会以难懂的方式失败,而不是报“不支持”。

driver 侧还有两条会影响行为的语义:Stmt.NumInput 返回 >=0 时,database/sql 在调用 Exec/Query 之前就在客户端校验参数个数,不会发到服务端;返回 -1 则不校验。driver.ErrBadConn 只应在连接已失效时返回,官方原文是 “should NOT be returned if there's a possibility that the database server might have performed the operation”,否则会引发重复执行。Stmt.Close 自 Go 1.1 起对仍被查询使用的 Stmt 不会立即关闭。见 database/sql/driver。

判据

同时满足以下两条时,就应优先怀疑 Stmt 与连接池的绑定放大:

  • Com_stmt_prepare - Com_stmt_close 持续为正且随连接数上升;
  • performance_schema.prepared_statements_instances 里同一个 SQL 文本出现在多条连接上。

如果 prepare 数远大于 SQL 文本种类数,但连接数不大,检查是否有 Tx.Stmt() 或循环里反复 Prepare 未 Close。

处置顺序

  1. 先收敛 SQL 文本。动态拼 WHERE、拼 IN 列表这类代码先把文本种类数降下来;文本数不减,后面调连接池只是换一个放大系数。
  2. 再看连接数与寿命。SetMaxOpenConns 定住上限,SetConnMaxLifetime / SetConnMaxIdleTime 不要设得过短,连接频繁重建会带来成批的重新 prepare。
  3. 最后决定用显式 Prepare 还是 interpolateParams。高频固定 SQL、连接数可控时,用 DB.PrepareContext 复用 Stmt,并确保 Close:
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"time"

	_ "github.com/go-sql-driver/mysql"
)

func main() {
	ctx := context.Background()

	db, err := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/app?parseTime=true")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	db.SetMaxOpenConns(20)
	db.SetMaxIdleConns(20)
	db.SetConnMaxLifetime(30 * time.Minute)

	stmt, err := db.PrepareContext(ctx, "SELECT id, name FROM users WHERE id = ?")
	if err != nil {
		log.Fatal(err)
	}
	defer stmt.Close()

	queryID := 1
	var gotID int
	var name string
	err = stmt.QueryRowContext(ctx, queryID).Scan(&gotID, &name)
	if err != nil {
		if err == sql.ErrNoRows {
			fmt.Println("no rows")
			return
		}
		log.Fatal(err)
	}

	fmt.Println(gotID, name)
}
复制全文 生成海报 prepared statement Go database sql MySQL

推荐文章

程序员茄子在线接单