Golang使用sqlx报错max_prepared_stmt_count超过16382

文章目录

背景

线上的跑的go服务操作mysql突然报错导致服务不可用,错误信息如下:

sql 复制代码
MySQL error: 1461 "Can't create more than max_prepared_stmt_count statements (current value: 16382)"

max_prepared_stmt_count是MySQL的一个基本参数,其是用来限制一个session内最多可以有多少条预处理语句,默认大小限制是16382。

sql 复制代码
mysql> show variables like 'max_prepared_stmt_count';
+-------------------------+-------+
| Variable_name           | Value |
+-------------------------+-------+
| max_prepared_stmt_count | 16382 |
+-------------------------+-------+

从报错信息上来看,是当前实例的预处理语句达到了16382的上限导致。

go服务使用的sql库是sqlx,数据库版本是mysql5.7.4

mysql的预处理

预处理把sql语句和参数区分开,编译是针对sql语句的编译,配合参数进行实际的sql操作。

  1. 一次编译、多次运行,提升性能
  2. 防止sql注入,参数不参与sql语句的编译
  3. 防止ddos攻击,上限16382

参考:如何定位和处理预编译语句(prepared statements)数量超限的问题 - 掘金

查看实例预处理详情

com_stmt_prepare

com_xx是mysql中的语句计数器变量,指每个语句已执行的次数。只要执行准备语句 API 调用(例如 mysql_stmt_prepare()、 **mysql_stmt_execute()**等),它们的值就会增加。

cpp 复制代码
Com_stmt_close prepare语句关闭的次数
Com_stmt_execute prepare语句执行的次数
Com_stmt_prepare prepare语句创建的次数

Com_stmt_prepare 减去 Com_stmt_close 大于 max_prepared_stmt_count 就会出现这种错误。最简单的解决的方案就是调大max_prepared_stmt_count的值,但治标不治本。

参考:max_prepared_stmt_count 问题与Sysbench 工具简介

而且计数器只能看到数量,看不到细节。max_prepared_stmt_count是数据库实例级别的变量,会影响到所有用到这个数据库实例的服务。如果要看具体哪个sql语句哪个客户端导致的预处理语句飙升,那么就要看下面的prepared_statements_instances表了。

线上数据库的com_stmt_prepare

可以看到prepare 和close的值是一致的,代表执行的prepare语句都被close掉了,虽然com_stmt_prepare的值超过了16382,但实际上并不会抛出错误。

注:

  1. 每次在RDS上执行sql也会导致com_stmt_prepare和com_stmt_close的值提升。
  2. GLOBAL STATUS除非重新启动,否则无法重置计数器。重启服务也不会重置计数器。

开启performance_schema

performance_schema是mysql的一个系统库,主要记录资源的消耗,资源等待等记录。performance_schema的prepared_statements_instances表中也记录了预处理语句。

cpp 复制代码
1、show variables like 'performance_schema';
开启需要设置成ON,参考:https://blog.csdn.net/weixin_41275260/article/details/125461160
2、设置 performance_schema_max_prepared_statements_instances
通过这个参数控制表的大小,可以设置成<=16283

注: 目前线上服务没有开启performance_schema,看不到故障现场的预处理语句情况。

本地查看预处理语句

手动执行prepare,发现prepare表中存在了该记录

此时查看prepare和close的值,发现这个prepare语句没有被close掉,因为正在被使用。

预处理语句飙升的原因

生成预处理语句但是不close

模拟只生成prepare语句,但是不close的情况。模拟结果也是mysql抛出错误,无法再创建预处理语句。

cpp 复制代码
mysql> show global status like 'Com_stmt_%';
+-------------------------+---------+
| Variable_name           | Value   |
+-------------------------+---------+
| Com_stmt_execute        | 2437392 |
| Com_stmt_close          | 2437392 |
| Com_stmt_fetch          | 0       |
| Com_stmt_prepare        | 2453774 |
| Com_stmt_reset          | 0       |
| Com_stmt_send_long_data | 0       |
| Com_stmt_reprepare      | 0       |
+-------------------------+---------+

Com_stmt_prepare - Com_stmt_close = 16382

执行sql过程中发生错误

模拟15次sql语句语法错误,此时查看Com_stmt_prepare如下:

cpp 复制代码
mysql> SHOW global STATUS LIKE 'Com_stmt_%';
+-------------------------+---------+
| Variable_name           | Value   |
+-------------------------+---------+
| Com_stmt_execute        | 23 |
| Com_stmt_close          | 23 |
| Com_stmt_fetch          | 0       |
| Com_stmt_prepare        | 38 |
| Com_stmt_reset          | 0       |
| Com_stmt_send_long_data | 0       |
| Com_stmt_reprepare      | 0       |
+-------------------------+---------+

prepare - close = 15

mysql官网解释如下:

即使预准备语句参数未知或执行期间发生错误, 所有变量也会增加。换句话说,它们的值对应于发出的请求数,而不是成功完成的请求数。例如,由于状态变量是在每次服务器启动时初始化的,并且不会在重新启动后持续存在,因此 跟踪 和 语句的 和 变量 的 值 通常为零,但如果 或 语句已执行但失败,则可以为非零值

mysql官方文档地址:https://dev.mysql.com/doc/refman/8.0/en/server-status-variables.html#statvar_Com_xxx

正常的prepare语句可以随着连接释放而断开,因mysql错误导致的prepare语句没有被close掉,这种是释放不了的。

go服务分析

抓包分析发送给mysql的包

抓包发现每次sql会发送三个指令,一个是prepare包含sql语句,一个是exec执行命令,一个是close statement指令。如下所示:

debug查看预处理细节

go服务中使用的查询语句主要是Get()和Select()方法。

sqlx发送statement command指令

cpp 复制代码
func (stmt *mysqlStmt) query(args []driver.Value) (*binaryRows, error) {
	if stmt.mc.closed.Load() {
		errLog.Print(ErrInvalidConn)
		return nil, driver.ErrBadConn
	}	
// Send command
	err := stmt.writeExecutePacket(args)
	if err != nil {
		return nil, stmt.mc.markBadConn(err)
	}

	mc := stmt.mc

	// Read Result
	resLen, err := mc.readResultSetHeaderPacket()
	if err != nil {
		return nil, err
	}
}

sqlx关闭stmt的close

cpp 复制代码
func (rs *Rows) close(err error) error {
// 忽略
	if rs.closeStmt != nil {
		rs.closeStmt.Close()
	}
// 忽略
	return err
}

closeStmt哪来的?
func (db *DB) queryDC(){
	var si driver.Stmt
	var err error
	withLock(dc, func() {
		si, err = ctxDriverPrepare(ctx, dc.ci, query)
	})
	if err != nil {
		releaseConn(err)
		return nil, err
	}
	// 
	ds := &driverStmt{Locker: dc, si: si}
	rowsi, err := rowsiFromStatement(ctx, dc.ci, ds, args...)
	if err != nil {
		ds.Close()
		releaseConn(err)
		return nil, err
	}
}
cpp 复制代码
func (stmt *mysqlStmt) Close() error {
	if stmt.mc == nil || stmt.mc.closed.Load() {
		// driver.Stmt.Close can be called more than once, thus this function
		// has to be idempotent.
		// See also Issue #450 and golang/go#16019.
		//errLog.Print(ErrInvalidConn)
		return driver.ErrBadConn
	}

	err := stmt.mc.writeCommandPacketUint32(comStmtClose, stmt.id)
	stmt.mc = nil
	return err
}

理论上来说,我们想要对重复的sql省去编译的时间,那么是不是不应该每次都close()? 如果不主动close那么就只能等待连接释放来关闭prepare语句了?

go官方在14年回答过这个问题,需要执行stmt.close()来释放资源。https://groups.google.com/g/golang-nuts/c/ISh22XXze-s

go官方文档也说:确保stmt.Close在代码完成语句时调用它。这将释放可能与其关联的任何数据库资源(例如底层连接)。对于函数中仅是局部变量的语句,使用defer stmt.Close()就足够了。
https://go.dev/doc/database/prepared-statements

database/sql中也有一段话:由于语句在原始连接繁忙时会根据需要重新准备,因此数据库的高并发使用可能会导致大量连接繁忙,从而创建大量准备好的语句。这可能会导致明显的语句泄漏,准备和重新准备语句的频率比您想象的要高,甚至会遇到服务器端语句数量的限制。
Using Prepared Statements

怎么才能不使用预处理语句

func (mc *mysqlConn) query(query string, args []driver.Value) (*textRows, error) {
	// 这里判断参数是否为0,是否有占位符,没有占位符则发送给mysql的包没有stmt模版
	if len(args) != 0 {
		if !mc.cfg.InterpolateParams {
			return nil, driver.ErrSkip
		}
		// try client-side prepare to reduce roundtrip
		prepared, err := mc.interpolateParams(query, args)
		if err != nil {
			return nil, err
		}
		query = prepared
	}
}

这么看起来,go官方的实现就是只要sql中有占位符就默认使用预处理的方式,然后close掉。 如果sql语句不包含占位符,则直接发送sql语句给mysql服务器。

结论

  1. sql语句执行失败、没有主动close stmt都会导致mysql实例中预处理语句数量的飙升
  2. go服务中带占位符的sql会自动生成和关闭预处理语句,没有占位符的sql则不使用预处理语句
  3. 可通过Com_stmt_prepare - Com_stmt_close观测实例中的预处理语句数量

解决方案

  1. 通过报警提前发现
  2. 运维开启性能追踪库,可排查具体导致预处理语句飙升的sql以及数据库
  3. 服务的连接导致的预处理语句没close,可以重启服务解决。mysql server内部执行错误导致的预处理没close则需要重启数据库,并排查服务减少错误sql的产生
  4. 尽量不要太多服务使用一个数据库实例,如果都使用预处理语句的话,16382的上限也不算多
相关推荐
慕城南风1 小时前
Go语言中的defer,panic,recover 与错误处理
golang·go
Ren_xixi2 小时前
redis和mysql的区别
数据库·redis·mysql
m0_748233883 小时前
SQL语句整理五-StarRocks
数据库·sql
追逐时光者4 小时前
免费、简单、直观的数据库设计工具和 SQL 生成器
后端·mysql
drebander4 小时前
MySQL 查询优化案例分享
数据库·mysql
小林coding6 小时前
阿里云 Java 后端一面,什么难度?
java·后端·mysql·spring·阿里云
drebander7 小时前
SQL 实战-巧用 CASE WHEN 实现条件分组与统计
大数据·数据库·sql
18号房客7 小时前
高级sql技巧进阶教程
大数据·数据库·数据仓库·sql·mysql·时序数据库·数据库架构
Dawnㅤ7 小时前
使用sql实现将一张表的某些字段数据存到另一种表里
数据库·sql
黄金右肾7 小时前
Qt之数据库使用(十四)
sql·qt·sqlite·database