1. 预编译这件事,先把"编译"的是什么说清楚
1.1 一条SQL在MySQL内部要走完的六道手续
很多人第一次听到"MySQL 预编译",脑子里浮现的是一台编译器把SQL翻译成机器码缓存起来,下次直接跑。这个画面错得比较离谱。MySQL 预编译(Prepared Statement)缓存的东西,跟机器码没有半点关系。
我把客户端发一条SQL到拿回结果的完整链路拆一遍:客户端把SQL文本按协议打包发出去,服务端协议层收到后交给解析器做词法分析(把一串字符切成 token)和语法分析(用 Bison 生成的语法规则拼出一棵语法树);接着进入预处理环节,检查表在不在、列在不在、当前账号有没有权限、视图要不要展开;然后才是优化器登场,决定走哪个索引、Join 用谁当驱动表、排序要不要用临时表;最后执行器拿着执行计划去调存储引擎的 handler 接口,一行一行取数据回来。这一整套走下来,最贵的是优化器那一段,最容易被忽略的反而是在它前面的解析和预处理。
打个比方:你去政务大厅办同一件事,每次都得重新领表、描一遍个人信息、窗口核验材料、主管签字,最后才轮到真正办事的窗口。预编译的意思就是先把"填表、核验、签字"这一套前戏做完并保留下来,后面再来办同一件事,你只需要补一句"这次是张三、金额三万块",直接跳到办事窗口。
这里要说清楚一个关键点:预编译省下的从来不是"执行",而是"准备执行"。真正扫描数据、回表、排序、聚合的时间一分都不会少。理解了这一点,后面很多关于"为什么我开了预编译性能没提升"的疑惑,答案就自己浮出来了。
1.2 硬解析与软解析,以及被多数教程忽略的优化时机
数据库圈子里有个老概念叫硬解析和软解析。硬解析指的是SQL文本从没见过的形态,必须完整走一遍解析、检查、优化的流程;软解析指的是SQL文本长得一模一样,在缓存里能直接命中已经算好的解析结果。Oracle 这类数据库有一个全局的共享池(library cache),不同会话执行相同文本的SQL可以共享同一份解析结果和执行计划,所以那边对"复用解析结果"这件事格外上心。
MySQL 走的是另一条路。**MySQL 的预编译语句是会话级的,不是全局共享的。**同一个 SQL 文本在 A 连接里 prepare 出来的语句,B 连接完全看不到,各准备各的。这就意味着 MySQL 的预编译在某些场景下比 Oracle 更"吃亏",因为它省不下跨会话的那一份开销;但反过来,它也不存在共享池被撑爆、需要靠绑定变量缓解 latch 竞争这类麻烦事。
更值得说一句的是优化发生的时机。在 MySQL 的实现里,PREPARE 阶段主要完成的是语法解析、对象存在性检查和权限校验这些"确定性的工作",而执行计划的选择放到 EXECUTE 阶段去做。这个设计的好处是优化器在真正执行时能看到具体的参数值,可以针对参数做更聪明的判断;代价就是我们期待的"计划只算一次"这件事,在 MySQL 里并没有想象中那么绝对。很多讲预编译的文章张口就说"计划只生成一次,后续直接复用",那是把别的数据库的行为套到 MySQL 头上了,你要是照着这个前提去调优,方向会歪掉。
1.3 预编译真正不可替代的价值:把数据挡在语法之外
如果只聊性能,预编译在 MySQL 里的性价比其实一般,甚至在某些场景是负收益。它真正无可替代的价值在安全上。
设想一个登录查询,代码是这样拼字符串的:
SELECT id, nickname FROM users WHERE username = '输入值' AND password = '输入值';用户在账号框里输入' OR '1'='1,拼出来的语句就变成了WHERE username = '' OR '1'='1' AND password = '',条件恒真,整张表被拖走。这个例子老掉牙,但每年都还有系统栽在上面。
预编译的应对方式是从根上拆开:SQL 的骨架先送给数据库编译成语句模板,用户输入的值作为独立的参数,走另一条通道传过去。数据库在执行时只是把参数往占位符上"贴",参数值从头到尾不参与语法解析,它就是一段数据,哪怕里面写满了引号和分号,也只会被当成字符串本身。
这个差别用一句话概括:**拼接是把用户输入当成了 SQL 代码的一部分,预编译是把用户输入当成了纯粹的值。**代码和数据的边界一旦划清,注入这件事在参数位置上就无从下手了。注意我说的是"参数位置上",后面第 5 章我会专门讲哪些位置看着像参数、其实参数化不了,那些地方才是现在实际项目里剩下的口子。
2. 两副面孔:服务端预编译与客户端预编译
2.1 服务端预编译:COM_STMT_PREPARE 这条协议路径
服务端预编译是"真"预编译。客户端的驱动通过 MySQL 协议的二进制命令走这条路:
| 命令 | 编号 | 作用 |
|---|---|---|
| COM_STMT_PREPARE | 0x16 | 把带?的SQL模板发给服务端,返回一个语句ID |
| COM_STMT_EXECUTE | 0x17 | 带着参数值执行指定语句ID |
| COM_STMT_RESET | 0x1A | 清空语句上挂的长数据、重置状态 |
| COM_STMT_CLOSE | 0x19 | 释放这个语句ID占用的资源 |
这套流程有几个容易被忽略的后果。第一,服务端会为每个语句ID维护一份对象,占用内存,而且受max_prepared_stmt_count这个全局参数的总额度限制。第二,参数走的是二进制编码,字符串、数字、时间、NULL 都有各自的编解码规则,比文本协议里的转义要紧凑,但也意味着字符集处理多了一层,配错连接字符集时乱码会更隐蔽。第三,既然是"语句ID",那它就有生命周期,连接断开时自动回收,连接不断开就得靠驱动显式 close,否则就一直挂着。这一点是后面第五章那个经典事故的根源。
2.2 客户端预编译:Connector/J 默认走的就是这条
这里有个让很多人当场愣住的事实:MySQL Connector/J 的useServerPrepStmts默认值是 false。
也就是说,一个 Java 项目里写着conn.prepareStatement("... where id = ?"),看起来标准得不能再标准,实际上驱动根本没走服务端预编译。驱动自己拿着这条带?的模板,在客户端把每个?用转义后的字面量替换掉,拼出一条完整的 SQL,然后用普通的COM_QUERY命令发出去。服务端收到的是一条普通SQL,该解析解析、该优化优化,什么便宜都没占到。
那这么做是不是就等于没用了?不是。**在客户端做正确的转义,同样能挡住注入。**驱动知道当前连接的字符集和NO_BACKSLASH_ESCAPES这类 SQL 模式,转义规则是适配过的,比自己手写字符串拼接靠谱一万倍。所以从安全角度看,客户端预编译够用;从性能角度看,它省不下服务端的解析开销。这两件事要分开评价,不要混为一谈。
顺带说下其他语言的默认行为,做多语言项目时很容易踩混:Go 的database/sql调db.Prepare是走服务端预编译的;Python 的pymysql只做客户端转义,根本不支持服务端预编译,而mysql-connector-python需要显式写cursor(prepared=True)才会走;Node.js 的mysql2里connection.execute()走服务端预编译,connection.query()走客户端拼接。同一个需求的三种写法,行为完全不一样。
2.3 两种模式的正面对比
把上面的差别做成一张表,配连接参数的时候对着看:
| 对比维度 | 客户端预编译(默认) | 服务端预编译 |
|---|---|---|
| 开启方式 | useServerPrepStmts=false | useServerPrepStmts=true |
| 服务端是否做解析 | 每次都做 | prepare 时做一次 |
| 防注入 | 靠驱动转义,效果可靠 | 参数与语法隔离,更彻底 |
| 网络往返 | 1 次 | 不缓存时 3 次(prepare/execute/close) |
| 服务端资源占用 | 无 | 每语句一份对象,受max_prepared_stmt_count约束 |
| 批量插入 | 可被改写成多值 INSERT,提速明显 | 改写行为受限,常常反而更慢 |
| 大文本参数 | 转义后体积膨胀 | 二进制传输,更紧凑 |
| 适用场景 | 短连接、一次性SQL、批量写入 | 长连接、高频重复执行同一条SQL |
这张表里最反直觉的是"批量插入"那一行。很多团队听说服务端预编译好,就把useServerPrepStmts改成 true,结果压测发现批量插入慢了一大截,回头找原因找半天。原理不复杂:批量插入的性能红利主要来自把 N 条 INSERT 合并成一条多值 INSERT,而合并这个动作要求驱动能看到所有参数的字面量;一旦改走 COM_STMT_EXECUTE,参数在服务端对象里,合并就没那么容易做了。这个坑我在第 5 章会展开。
3. 动手实操:环境、命令与连接串
3.1 用Docker起一个干净的MySQL 8.0
复现这类底层行为,最好有一台干净的实例,别在业务库上折腾。用 Docker 起一个是最省事的:
docker run -d \ --name mysql-prep-demo \ -p 3307:3306 \ -e MYSQL_ROOT_PASSWORD=Demo#2024 \ -e MYSQL_DATABASE=demo \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_0900_ai_ci端口故意映射成 3307,避免和你本机已有的 3306 冲突。启动参数里显式指定了utf8mb4,因为后面要验证字符集相关的行为,服务端和连接端的字符集必须心里有数,不然排查起来会多绕一圈。等十几秒容器起来后,用客户端连上去确认版本:
mysql -h127.0.0.1 -P3307 -uroot -p -e "SELECT VERSION(), @@max_prepared_stmt_count;"这条命令会同时把版本号和当前的预编译语句上限打出来。默认值通常是 16382,记住这个数字,第 5 章要拿它说事。
3.2 命令行里的 PREPARE / EXECUTE / DEALLOCATE 全流程
MySQL 在 SQL 层直接暴露了预编译的语法,这是观察它行为最直观的方式。先建一张小表灌点数据:
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, status VARCHAR(16) NOT NULL, amount DECIMAL(10,2) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_status (user_id, status) ) ENGINE=InnoDB; INSERT INTO orders (user_id, status, amount) SELECT n % 5000, IF(n % 3 = 0, 'paid', 'pending'), ROUND(RAND() * 1000, 2) FROM ( SELECT @n := @n + 1 AS n FROM information_schema.columns a, information_schema.columns b, (SELECT @n := 0) t LIMIT 100000 ) x;接着走一遍完整的三步:
PREPARE stmt_order FROM 'SELECT id, amount FROM orders WHERE user_id = ? AND status = ?'; SET @uid = 1001; SET @st = 'paid'; EXECUTE stmt_order USING @uid, @st; EXECUTE stmt_order USING @uid, 'pending'; DEALLOCATE PREPARE stmt_order;几个细节值得停下来看。第一,PREPARE接受的是一条字符串形式的SQL,?是占位符,不能用:name这种命名参数,那是驱动层的语法糖。第二,EXECUTE ... USING只能接用户变量(@x)或者字面量,不能直接写表达式比如USING 1000 + 1。第三,DEALLOCATE PREPARE要显式写,虽然会话结束时会自动清理,但在长连接里不写就是在持续占额度。
还有一个很实用的技巧:用SHOW WARNINGS观察服务端的反馈,用EXPLAIN EXECUTE看执行计划。在旧一些的版本上,语句的优化是发生在 PREPARE 阶段的,行为和新版本不一致,所以当你的项目跨多个 MySQL 版本时,同一段预编译代码在不同版本上的执行计划可能不一样,这一点在做版本升级评估时要专门测。
3.3 JDBC连接串与连接池配置
Java 项目里让服务端预编译真正生效,光写prepareStatement是不够的,连接串得配对:
jdbc:mysql://127.0.0.1:3307/demo ?useServerPrepStmts=true &cachePrepStmts=true &prepStmtCacheSize=250 &prepStmtCacheSqlLimit=2048 &useLocalSessionState=true &rewriteBatchedStatements=true &characterEncoding=utf8逐个说为什么。useServerPrepStmts=true是总开关,不开后面全白搭。cachePrepStmts=true打开语句缓存的开关,注意它和上面那个是两个独立的开关——只开useServerPrepStmts不开cachePrepStmts,每次执行完驱动就会把语句关掉,下次再 prepare,平白多出两次网络往返,比不预编译还慢。prepStmtCacheSize=250是每个连接缓存的语句条数,默认值 25 对稍大的项目就偏小,prepStmtCacheSqlLimit=2048是单条SQL的长度上限,超过这个长度的SQL不会被缓存,如果你的业务里动态SQL比较长,要把这个值调大。useLocalSessionState=true是让驱动在本地判断事务状态和自动提交状态,减少一次网络往返。rewriteBatchedStatements=true是批量写入的红利开关,后面第 5 章会讲它和服务端预编译之间的拉扯。
写代码的时候有一个动作必须坚持:
String sql = "UPDATE orders SET status = ? WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "shipped"); ps.setLong(2, 10086L); ps.executeUpdate(); }try-with-resources 保证ps一定被关掉。很多人觉得有连接池兜底就无所谓,其实连接池管的是 Connection,不是 Statement,Statement 不关,服务端那个语句对象就一直挂着,连接池里的连接是长连接,挂的越攒越多,最后撞上限。这个事故形态我在下面会详细复盘。
如果你用的是 MyBatis,默认的PREPARED语句类型对应的是客户端预编译,想走服务端预编译还得额外在数据源上加上述参数;SQL 里的${}是字符串拼接、#{}是参数占位,前者是注入高风险写法,代码评审时看到${}出现在参数位置必须拦下来。
3.4 用performance_schema验证预编译是否真的生效
配置改完了,怎么确认它确实走到了服务端预编译?两个办法。
第一个是看状态计数器:
SHOW GLOBAL STATUS LIKE 'Com_stmt%'; SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count';如果服务端预编译在工作,Com_stmt_prepare会随着程序启动增长,然后趋于平缓;如果它一直在线性增长,说明你的语句缓存没起作用,每次都在重新 prepare。Prepared_stmt_count是当前打开的语句总数,这个值应该在一个稳定区间里小幅波动,如果它一路往上爬,那就是有语句没被释放——这就是事故的前兆。
第二个办法更精确,直接查performance_schema:
SELECT STATEMENT_ID, SQL_TEXT, COUNT_EXECUTE, COUNT_REPREPARE, TIMER_PREPARE FROM performance_schema.prepared_statements_instances ORDER BY COUNT_EXECUTE DESC LIMIT 20;这张表里有两个字段特别值钱。COUNT_EXECUTE是这条语句被执行了多少次,COUNT_REPREPARE是它在执行时被重新优化了多少次。如果你看到一个语句执行了几万次、COUNT_REPREPARE也跟着几万,那说明每次执行优化器都在重新算计划,所谓的"解析一次、复用计划"在它身上基本没发生;如果COUNT_REPREPARE一直很小,说明计划确实稳住了。
这里也顺便回答一个常见疑问:Com_stmt_prepare的计数并不完全等于你代码里调用 prepare 的次数,因为存储过程内部、某些复制线程内部的行为也会计入。所以拿这个数字做监控指标时,要结合performance_schema一起看,别单看一个数就下结论。
4. 一次实测:服务端预编译到底省了多少
4.1 测试环境和压测方法
光讲原理不够,我把第 3 章那套环境拿来跑了一组对照测试,参数如下:
| 项目 | 配置 |
|---|---|
| 服务端 | MySQL 8.0 容器,2 核 4G,数据目录挂本地盘 |
| 客户端 | 同宿主机 JDK 17 进程,连接池 HikariCP,池大小固定 10 |
| 数据量 | orders 表 100 万行,idx_user_status复合索引 |
| 压测方式 | 固定线程数循环执行,跑 60 秒取平均值,丢弃前 10 秒预热 |
| 对照变量 | 只改useServerPrepStmts和cachePrepStmts,其他参数完全一致 |
三组场景分别是:单条点查(WHERE user_id = ? AND status = ?,命中索引返回几十行);批量插入(每批 500 条 INSERT);一次大文本更新(参数是一条 8KB 左右的字符串)。第 4.2 节的表格是我在这次测试里的记录,你换机器数字肯定会变,但相对关系参考价值比较大。
4.2 三组数据下的真实差距
| 场景 | 客户端预编译 | 服务端预编译(未开缓存) | 服务端预编译(开缓存) |
|---|---|---|---|
| 单条点查 QPS | 约 11800 | 约 7400 | 约 13200 |
| 批量插入 500 条/批 耗时 | 62 ms | 158 ms | 151 ms |
| 大文本更新 平均耗时 | 4.1 ms | 3.2 ms | 3.0 ms |
这组数字里有三个值得琢磨的地方。
单条点查那一行,开了缓存的服务端预编译确实最快,比客户端模式高出约 12%,但没开缓存的时候反而慢了一大截。原因就是每次执行多出的 prepare 和 close 两次往返,在高频短查询场景下,网络往返的代价远大于解析的代价。
批量插入那一行是差距最大、也最容易被忽略的:客户端模式下 62 毫秒,服务端模式下 151 毫秒,慢了一倍还多。这就是前面提过的批处理改写问题——客户端模式下驱动把 500 条 INSERT 合并成一条多值 INSERT,一次网络往返搞定;服务端模式下合并受限制,实际发送和执行的次数多得多。很多团队做"优化"的时候把useServerPrepStmts一开,接口响应时间翻倍,问题就出在这里。
大文本更新那一行,服务端模式反而快一些,原因是二进制协议传字符串不用做转义,8KB 的参数在文本协议里转义后体积会变大,而且拼接字符串本身也要消耗 CPU。
4.3 为什么收益和宣传的不一样
回到第 1 章埋的那个伏笔:MySQL 的预编译省的是"解析 + 权限校验 + 部分预处理",而优化和执行计划的选择在 MySQL 的实现里并没有像很多人以为的那样被彻底固化下来。一次点查的耗时里,解析可能只占几个百分点,网络往返、索引查找、InnoDB 的页访问才是大头。你把几个百分点省掉,还要额外付出网络往返的代价,净收益自然就不明显了。
还有一个常被忽略的变量:并发连接数。我们测的是 10 个连接,每个连接各自持有自己的预编译语句。连接数越少,服务端预编译的资源开销越可控;连接数一上去,每个连接都缓存一批语句,服务端的语句对象数量就是"连接数 × 每连接缓存条数",内存压力会线性上升。所以真正决定要不要开服务端预编译的,从来不是"它是不是更先进",而是你的业务形态:SQL 重复度高不高、连接是不是长连接、有没有大批量写入。
5. 踩坑记录:预编译最常见的六个问题
5.1 max_prepared_stmt_count 爆掉是怎么发生的
这是我在生产环境真真切切遇到过的故障,报错信息长这样:
Can't create more than max_prepared_stmt_count statements (current value: 16382)现象是接口开始大面积报错,重启服务能缓解,过一段时间又复发。排查路径是这样的:先确认SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count',发现这个值已经贴在 16000 以上下不来;再查performance_schema.prepared_statements_instances,按线程分组统计,发现语句集中在少数几个连接上,每个连接挂着上千条语句,SQL 文本呈现出高度重复的特征——同一张表的同一条查询,重复出现了几十遍。
根因是两个问题叠加。一是代码里有一批PreparedStatement没有关闭,走的不是 try-with-resources 而是手工管理,异常分支上漏了 finally;二是连接池的连接是长连接,连接不销毁,语句对象就跟着一直挂着。修复动作有三步:把漏关的地方补上;把max_prepared_stmt_count适当调高作为缓冲(SET GLOBAL max_prepared_stmt_count = 32768;,注意这是全局变量,重启后失效,要写进配置文件);加上针对Prepared_stmt_count的监控告警,阈值设在额度的 60%。
注意:调高 max_prepared_stmt_count 只是止血,不是治疗。这个值本质上是给程序 bug 兜底用的,靠调大它来"解决"问题,等于把定时炸弹的引线接长一点。
5.2 哪些位置看着能参数化、其实参数化不了
这一节是整篇文章最实用的部分,因为剩下的注入风险基本都藏在这里。
**第一个是 ORDER BY 的字段名和排序方向。**你写ORDER BY ?,MySQL 不会报错,但也不会按你传的值排序,它会把整个表达式当成一个常量处理,结果就是排序静默失效。这类 bug 最恶心的地方在于它不报错,测试环境数据量小的时候看不出来,上线之后用户说"排序按钮点了没用"才被发现。正确做法是把允许的字段名做成白名单映射,用户传create_time,代码里查表拿到真正的列名,再拼进SQL。
**第二个是表名、库名、列名。**占位符只能出现在"值"的位置,元数据的位置一概不行。凡是需要动态切换表名的场景(比如按月分表),一定要走白名单校验,因为表名一旦能被外部输入左右,注入的门就敞开了。
**第三个是 LIMIT 后面的数字。**这一点各家驱动行为有差异,有些版本能接受LIMIT ?,有些场景下会退化成字符串比较。稳妥的做法是把分页参数强制转成整型再校验范围,不要直接透传。
第四个是 IN 列表。WHERE id IN (?)只代表一个值,不能代表一个列表。要实现动态长度的 IN,只能用循环拼出对应数量的占位符:IN (?, ?, ?),然后逐个绑定参数。拼的是占位符的个数,不是值本身,这一点别搞混。
还有一个容易忽略的:UPDATE语句里带子查询的情形,比如UPDATE t SET flag = ? WHERE id IN (SELECT ...),子查询内部的这条 SQL 是整体参与解析的,它的结构不能被外部输入决定,能参数化的只有它自己内部的值位置。
5.3 批处理改写、字符集与连接池的三方拉扯
批处理的问题前面说过,这里补上完整结论:**批量写入密集的链路,服务端预编译往往不是好选择。**如果你的系统里既有高频点查又有大批量写入,比较务实的做法是让写入链路单独用一个数据源,关掉服务端预编译、打开rewriteBatchedStatements,读取链路用另一个数据源开服务端预编译。多一个数据源的维护成本,比强行统一配置带来的性能损失划算得多。
字符集这块的坑比较隐蔽。服务端预编译走二进制协议传参数,字符串参数需要带上字符集标识,如果连接串上的characterEncoding和服务端表字段的字符集不一致,可能出现"客户端看着正常、数据库里存的是问号"的情况。排查方法很直接:建一张测试表,从 Java 端写入一段包含中文和四字节字符的字符串,再用命令行SELECT HEX(col)看十六进制内容,对不对一眼就知道。别用"看起来没乱码"来判断,乱码有时候要到特定字符上才会暴露。
连接池这边要留意的是缓存的作用域。prepStmtCacheSize是每个连接一份的,池里有 20 个连接,理论上限就是 20 乘上这个值,算服务端资源占用的时候要把连接数乘进去。另外连接池回收连接时(比如空闲超时被逐出),该连接上的预编译语句会随之释放,这部分是自动的,不用担心。
5.4 问题速查表
把上面这些整理成一张对照表,出问题的时候照着找:
| 现象 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 报 max_prepared_stmt_count 超限 | 语句未关闭,长连接累积 | 查Prepared_stmt_count、按线程分组查语句数 | 补 close、加监控、临时调高上限 |
| 开了服务端预编译反而变慢 | 未开语句缓存,或批量写入被拖累 | 对比Com_stmt_prepare增速、分组压测 | 打开cachePrepStmts,写入链路单独数据源 |
| 排序参数传了但结果没变 | ORDER BY 后用了占位符 | 直接EXPLAIN看是否出现 Using filesort | 字段名白名单映射后拼接 |
| 批量插入变慢一倍 | 批处理改写失效 | 开 general log 看实际发送的SQL条数 | 批量链路关闭服务端预编译 |
| 中文写入后变问号 | 连接字符集与表字符集不一致 | 写入后用HEX()看实际存储字节 | 统一 utf8mb4 并显式配置 |
| 执行计划每次都在重算 | COUNT_REPREPARE持续增长 | 查prepared_statements_instances | 检查参数倾斜,必要时改回普通语句 |
6. 什么时候该开服务端预编译,什么时候该放手
6.1 适合打开的场景
判断标准其实就三条,满足得越多越值得开。
第一条是同一条SQL被执行很多次。报表类接口、门户首页那种每次请求都跑同一条聚合查询、定时任务里循环处理数据的场景,预编译省下的解析开销会被执行次数放大,收益就出来了。反过来,那种每次拼出来的SQL都不一样的动态查询,预编译什么都省不下。
第二条是长连接 + 连接池。短连接场景下,连接一断语句就释放,每次请求都要重新 prepare,纯亏。用连接池把连接复用起来,语句缓存才有意义。
第三条是参数以中长文本为主,或者参数数量很多。二进制协议在传输大量字符串参数时比文本转义更省,参数越多、字符串越长,优势越明显。
6.2 适合关掉的场景
同样三条,命中就得掂量。
大批量写入的链路,这个前面反复说过了,批量插入的性能红利几乎全在批处理改写上,跟服务端预编译冲突,写入链路默认关掉通常更划算。
一次性的、低频的 SQL。比如后台运维工具跑的导出语句、启动时执行的初始化脚本,每条只跑一次,开预编译纯属增加两次网络往返。
SQL 文本极长、变化极多的场景。这类SQL超过prepStmtCacheSqlLimit就不会被缓存,还会把缓存空间挤掉,不如直接走普通路径。
6.3 一套可以直接抄的配置
最后给两套配置,按链路分流使用。
读取链路(点查、列表、聚合为主,服务端预编译打开):
jdbc:mysql://host:3306/db ?useServerPrepStmts=true &cachePrepStmts=true &prepStmtCacheSize=250 &prepStmtCacheSqlLimit=2048 &useLocalSessionState=true &characterEncoding=utf8 &serverTimezone=Asia/Shanghai写入链路(批量插入为主,服务端预编译关闭,批处理改写打开):
jdbc:mysql://host:3306/db ?useServerPrepStmts=false &rewriteBatchedStatements=true &useLocalSessionState=true &characterEncoding=utf8配套的两条监控要跟上:Prepared_stmt_count超过max_prepared_stmt_count的 60% 就告警;Com_stmt_prepare的增长速率如果和请求量同比例线性上涨,说明缓存没生效,需要回头检查参数。
我个人在实际操作中的体会是,MySQL 预编译这东西被讲得神乎其神,实际上它的核心价值按重要性排序是:安全第一,资源复用第二,性能第三。真正把它用对的关键不在"要不要开",而在"分场景开"——读取链路和写入链路本来就不该用同一套参数,很多性能问题都是把一条配置糊到所有链路上造成的。至于那个max_prepared_stmt_count的坑,我建议你现在就去线上查一下Prepared_stmt_count这个值,如果它已经悄悄爬到几千,那离出事可能就只差一次流量高峰了。