sql 库的 prepare 行为设计得真脑残啊

2021-05-15

我们有个内部集群跑着一个模拟客户流量的测试,前一阵子值班同事反馈说集群状态不太对,于是我登上去查了一下。结果发现客户端对 prepare 的使用姿势很有问题。

正常情况下,数据库执行一条 SQL 语句,要将文本转成 AST,然后再将 AST 做成 Plan(查询计划),再执行 Plan。在高并发的时候,这些从文本到查询计划的过程还是蛮耗资源的。于是,比如在 MySQL 里面,就有 prepare 协议。第一步先发送一个 prepare 操作,生成一个 prepared 的 statement:

prepare stmt from 'select * from t where id = ?'

这里面有一个 ? 是参数,先空着。再接下来,就可以直接指定参数并使用 stmt 了:

set @x = 42;
execute stmt using @x;

只要 stmt 不释放,就可以多次的被重重使用。这样可以绕开每次发送 SQL 文本 parse 和生成查询计划的开销。

我发现我们内部测试集群里面,客户端执行的骚操作是,prepare 一次,execute 一次,立即释放掉,每条 SQL 都这么干。这太蠢了!相比于直接走 query 协议,不仅没有省掉生成 ast 和做 plan 开销,还额外引入了好几次网络的 roundtrip。然后我就去研究一下,测试程序是怎么写成这样的。

测试代码看起来没注意到异常,它是用的 sql.DB 或者 sql.Tx 这样调用:

db.QueryRow("select xxx from t where user_id = ?", arg)

所以那就是库的锅咯?标准库的实现里面接口是这样的:

func (db *DB) QueryRow(query string, args ...interface{}) *Row

具体的代码实现会先尝试调用 ctxDriverQuery,如果返回了 driver.ErrSkip,就会用 prepare, execute 再 close 的方式了。 然后看一下 go-sql-driver/mysql 包:

        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
        }
		...

对于 query 函数传了参数的情况,如果没有配置 InterpolateParams,就会返回 driver.ErrSkip。(如果配置了 InterpolateParams,会把参数转成文本后使用 query 协议)

这个设计太蠢了,用户本以为用 Query("select ...?", args ...) 这种是 prepare 协议,可以提升性能,但实际是性能反而不如不带参数的写法。

结论:QueryRow(query string, args ...interface{}) 带可变参数调用的时候对 prepare 的使用姿势有问题,它是 prepare 一次 execute 一次的。

标准库其实有另一个 Prepare 函数,正确的使用 prepare 协议应该使用那个方法。

preparesql