MySQL 报 ERROR 1461(max_prepared_stmt_count 16382):先看 Com_stmt_close 是不是 0
报错原文:
FATAL: MySQL error: 1461 "Can't create more than max_prepared_stmt_count statements (current value: 16382)"
max_prepared_stmt_count 默认 16382,取值范围 0 到 1048576,动态修改立即生效,不需要重启。它限制的是整个实例上同时存在的服务端预处理语句总数,所有连接共享这一个配额。每条预处理语句至少占 8 KB 内存,这个上限的作用是防止应用或攻击者用无限多的语句把实例内存撑满。
结论先行
遇到 1461,先不要急着调 max_prepared_stmt_count。看两个累计计数器就能分出两类情况:
Com_stmt_prepare一直在涨,Com_stmt_close长期为 0,说明客户端根本没发过COM_STMT_CLOSE,语句一直挂在连接的语句缓存里,直到连接断开。这是语句泄漏,改代码或调驱动缓存上限,不是把服务端配额调大。Com_stmt_prepare与Com_stmt_close大致同步增长,Prepared_stmt_count稳定在某个值附近,那才可能是配额确实不够,可以调参。
计数器怎么读
SHOW VARIABLES LIKE 'max_prepared_stmt_count'; -- 配额
SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count'; -- 当前还开着多少条,瞬时值
SHOW GLOBAL STATUS LIKE 'Com_stmt%'; -- 累计命令次数
Com_stmt% 包含 Com_stmt_prepare、Com_stmt_execute、Com_stmt_close、Com_stmt_fetch、Com_stmt_reset、Com_stmt_send_long_data、Com_stmt_reprepare。
一个真实的 GitHub issue 里,报错现场的输出是:
Com_stmt_execute 6512872
Com_stmt_close 0
Com_stmt_prepare 18313
max_prepared_stmt_count 16382
execute 涨了六百多万次,close 一次都没有。语句 PREPARE 出来之后从来没被关闭。prepare 累计 18313 已略超 16382,差值就是被拒绝的那部分。
要定位是哪个连接持有最多语句,5.7.4 之后有 performance_schema.prepared_statements_instances:
SELECT owner_thread_id, COUNT(*) AS cnt
FROM performance_schema.prepared_statements_instances
GROUP BY owner_thread_id
ORDER BY cnt DESC LIMIT 10;
再把 thread_id 映射到连接:
SELECT thread_id, processlist_id, processlist_user, processlist_host
FROM performance_schema.threads
WHERE thread_id = ;
这张表不存在说明版本早于 5.7.4,只能看全局计数和连接数估。
全局配额 vs 每连接缓存
服务端的 16382 是全局的,而驱动侧的语句缓存是每个连接一份。两者相乘才是真实的语句总数。
node-mysql2 的 maxPreparedStatements 默认 16000,是 per connection。Sequelize 默认连接池 max 是 5。一个连接就能缓存 16000 条,两个连接就是 32000,已经超过服务端 16382。现场就是这么炸的:把 maxPreparedStatements 调低(比如 100),让 LRU 主动淘汰,问题消失。
Connector/J 同理:cachePrepStmts 管是否缓存,prepStmtCacheSize 管每个连接缓存多少条,prepStmtCacheSqlLimit 管多长的 SQL 才进缓存。这三者同样是每连接维度。
所以看到 1461,先把「连接池大小 × 驱动缓存上限」算一遍,和服务端配额比一比。
先确认自己是哪种「预处理」
两种模式必须分清,否则上面所有计数器读数都会被误读。
client-side 模拟:驱动在客户端把参数转义后拼进 SQL 文本,发一条普通 COM_QUERY。服务端看到的永远是完整 SQL,不产生服务端语句,Com_stmt_prepare 不涨。PHP PDO 的 PDO::ATTR_EMULATE_PREPARES 默认就是 true,用 $pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES) 可以打印确认。这种模式下防注入靠驱动转义,不是协议层隔离。
server-side 真预编译:驱动发 COM_STMT_PREPARE 拿到 statement id,之后每次 COM_STMT_EXECUTE 只带参数。mysqli、Go 的 go-sql-driver/mysql(非 interpolateParams 模式)、开启 useServerPrepStmts=true 的 Connector/J 走这条路。只有这条路才会消耗 max_prepared_stmt_count。
Connector/J 的 useServerPrepStmts 默认是 false,也就是默认不走服务端预编译;开启服务端预编译而不开 cachePrepStmts,则是每次调用都重新发一次 COM_STMT_PREPARE,比默认模式更慢,Com_stmt_prepare 也会涨得很快。
根因通常不是配额
绝大多数 1461 来自「把变化的值拼进 SQL 文本再交给 execute()」,导致每条 SQL 文本都不同,每次都新建一条语句。
反例(node-mysql2):
conn.execute('select * from t where id = ' + id)
正例:
conn.execute('select * from t where id = ?', [id])
反例(JDBC 字符串拼接):
stmt.executeQuery("select * from t where id = " + id)
正例:
PreparedStatement ps = conn.prepareStatement("select * from t where id = ?");
ps.setLong(1, id);
ps.executeQuery();
ORM 里也有类似情况:用 Op.eq 之类的包装写条件时,某些版本会把值内联进 SQL(日志里能看到 WHERE id = 5 而不是 WHERE id = ?),每次值不同就是一条新语句,慢慢把配额耗光。
边界与容易踩的点
- 语句是会话级的。连接正常或异常断开,服务端都会自动释放该会话的语句。所以「重启 MySQL 就好了」不是修复,只是把所有连接重置了一遍。
- 调小
max_prepared_stmt_count不会杀掉已存在的语句。当前数量已超新上限时,只有新的 PREPARE 被拒,直到旧语句释放。 - 设为 0 等于禁用预处理语句。
- 修改是动态的,
SET GLOBAL立即生效;写进my.cnf才能在重启后保留。 - 中间件场景要单独看:有些分库分表中间件(如 Gaea、DBLE)在 client→proxy 用 server-side 预编译,proxy→MySQL 转成文本 SQL,这时后端的
max_prepared_stmt_count不会涨,语句堆在中间件的会话里,得看中间件自己的指标。 Com_stmt_reprepare只增不减。语句因元数据变更或参数类型变化被重新 prepare 时它也会涨,拿它判断泄漏会误判。判断泄漏看Com_stmt_close和Prepared_stmt_count。
排查顺序
SHOW GLOBAL STATUS LIKE 'Com_stmt%',算 prepare 与 close 的差值;close 为 0 基本就是泄漏。SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count',隔几分钟看两次,是否只涨不跌。performance_schema.prepared_statements_instances按owner_thread_id聚合,找出持有最多的连接。- 抓该连接的 SQL 文本,确认有没有把变量拼进 SQL。
- 修代码(参数化),或调驱动缓存上限(
maxPreparedStatements/prepStmtCacheSize)。 - 最后才考虑
SET GLOBAL max_prepared_stmt_count=65536作为临时缓解,并写进配置文件。
还有一种情况没在上面的顺序里覆盖:中间件把 server-side 转成 client-side 之后,MySQL 侧的计数器不再反映真实语句数量,此时只能看中间件自己的会话指标。计数器读数在哪一侧可见,取决于请求链路在哪一跳做了协议转换,这一点需要按实际部署逐跳确认。