踩坑指南 —— MySQL 预编译

背景

某日,我正在享受为数不多的下班游戏时间。突然收到频繁告警,一看是丫的数据库报错,直接拒绝服务了。 我的第一反应是不好,数据库可能被打挂了。赶紧对游戏好友说声抱歉,打开日志和监控。

好家伙,数据库 CPU 100% 然后我顺着思路去看慢日志,但是也不太对。中间过程很曲折,团队小伙伴把服务重启之后,我们在被海量报错的日志里看到了真实的报错原因:

ERROR 1461 (42000): Can't create more than max_prepared_stmt_count statements (current value: 16382)

然后赶紧搜索一下,还好不少人遇到过,面向 Google & Stackoverflow debug 开始。

这个时候止损有一个方式就是:

  • 临时调大 max_prepared_stmt_count 的值

我们预感到调大之后,有多大就会撑到多大,况且 MySQL CPU 100% 了,这个特殊时期确实流量巨大,这么改不能解决根本问题。然后就开始分析,这玩意到底是什么。

简单易懂的预编译

MySQL 作为高效的数据库,自然有一套方法来提升运行效率。除了常见的自动优化 SQL 之外,还有我们的老方法,上 Cache 。

实际场景中,我们也可以发现,有很多语句,只有 where 子语句的条件的值产生了改变,而其余部分估计到项目下线都不会改变。比如

SELECT id, name FROM users WHERE sex = "male"; 
SELECT id, name FROM users WHERE sex = "female";
复制代码

这个时候大量重复的字符串让数据库重新编译解析那不就做了很多无用功,所以是不是可以把这个变量扣掉,保留一份模板,每次仅接收变量,不再重新解析整个语句了。

比如这样:

SELECT id, name FROm users WHERE sex = ?;
复制代码

那么数据库就不需要做很多工作,直接执行得结果,岂不美哉。

容易使用的预编译

在 golang 官方的 sql 库中,可以看到,Query 方法会根据是否传参来决定是否使用 prepare 方法向服务器发送请求,那么我们使用 prepare 就非常简单了,copy paste 官方例子即可;当拿到结果的时候,会自动关闭这条预编译,当然也可以直接调用 rows.Close() 来手动关闭。

// use prepare
rows, err := db.Query("SELECT id, name FROm users WHERE sex = ?;", "female")
// without prepare statement
rows, err := db.Query("SELECT id, name FROm users WHERE sex = female;")
复制代码

SQL - 官方文档

然后线上就崩了

回到那尽心动魄的一晚,我们通过搜索发现,16382 是 MySQL 的默认值,一般不需要更改。这个值打满了,那就是我们代码使用了错误的预编译模版。

基本上可以分为两个 debug 思路:

  1. 预编译使用完没有关闭;
  2. 预编译模版里面有动态值;

首先排除「没有关闭」,Golang SQL 包有自动关闭功能,我们直接使用的就是这个包。那么是不是我们预编译模版有动态值呢,比如将如下语句当预编译发给了 MySQL :

SELECT id, name FROm users WHERE id = 13 and sex = ?;
SELECT id, name FROm users WHERE id = 73 and sex = ?;
SELECT id, name FROm users WHERE id = 86 and sex = ?;
复制代码

经过筛查,发现我们虽然直接使用 Golang SQL 包,但是上层还有一层封装,它将所有的 SQL 都作为预编译发给 MySQL ,我们有一条复杂 SQL 的写法里面包含了一个动态值,导致预编译的参数被打满,MySQL 直接拒绝服务。

诶,为什么之前好好的

每次出问题的时候脑海总有这个念头,这次也比较好想明白:

  1. 平时流动不大,那个复杂 SQL 执行完很快就关掉了,不会打满默认值;
  2. 中间做过几次优化,其余的有动态值的 SQL 其实都被优化掉了,所以之前这个问题也没暴露出来;
  3. 没做压测!!!

猜你喜欢

转载自juejin.im/post/7014807482434322468