先交代一下背景,这事发生在我们一个内部系统的联调环境里。那天下午运营那边反馈某个列表页打开特别慢,紧接着运维就发来告警,数据库连接池被打满。等我登录服务器一看,MyBatis 日志里全是 Connection is not available 的超时异常,而罪魁祸首就是同事新写的一条查询 SQL。一眼扫过去,那条 SQL 本身语法没问题,放 Navicat 里也能跑出结果,但它在 MyBatis 里执行时,直接把连接池拖垮了。
这个事故其实非常有代表性,它不是单纯的“SQL 写错了”,而是“SQL 写法 + MyBatis 执行机制 + 连接池行为”三重因素叠加的结果。很多人平时只会写 CRUD,从来没想过一条看似正常的 SQL 会在 ORM 框架里引发连锁反应。这篇文章我就完整复盘一下这次事故的来龙去脉,从 SQL 本身的问题、MyBatis 执行链路里埋的坑,再到连接池参数和慢 SQL 排查方法,一次性讲透,希望能帮你少踩几个类似的雷。
1. 事故复盘:那条 SQL 到底做了什么
1.1 现场还原:一条看似普通的动态查询
先还原一下当时的场景。业务需求是“按一批订单 ID 批量查询订单详情,同时带出用户昵称和商品信息”,同事在 MyBatis 的 XML 里写了类似这样的 SQL:
xml复制<select id="batchQueryOrders" resultType="map">
SELECT
o.order_id,
o.order_amount,
u.nick_name,
p.product_name
FROM
t_order o
LEFT JOIN t_user u ON o.user_id = u.user_id
LEFT JOIN t_product p ON o.product_id = p.product_id
WHERE
o.order_id IN
<foreach collection="orderIds" item="orderId" open="(" separator="," close=")">
#{orderId}
</foreach>
AND o.order_status = 1
AND u.nick_name LIKE CONCAT('%', #{keyword}, '%')
ORDER BY o.create_time DESC
</select>
单看这条 SQL,很多人的第一反应是“这有什么问题”?IN 查询很常见,LEFT JOIN 也合理,LIKE 模糊匹配虽然有一点性能隐患,但也不是什么致命的错。我当时也是这么想的,直到我打开慢查询日志,才发现这条 SQL 的执行时间已经飙到了 30 多秒。
真正让它“干翻 MyBatis”的并不是某一行代码,而是一个组合效应:orderIds 传了 8000 多个 ID,加上 LIKE '%keyword%' 导致索引失效,再加上 LEFT JOIN 的驱动顺序被优化器选错,整条 SQL 走成了三次全表扫描。订单表当时有 600 多万行,用户表 200 多万行,商品表 100 多万行,三个大表全扫一遍,再嵌套循环关联,性能直接爆炸。
1.2 表面现象:MyBatis 报错信息分析
当这条慢 SQL 出现在生产环境时,最先暴露的并不是 SQL 执行慢,因为 MyBatis 执行 SQL 是同步阻塞的,一个请求进来,线程会卡在数据库操作上。如果这条 SQL 要跑 30 秒,那么对应的数据库连接在这 30 秒内是“只借不还”的。
我截取当时日志里出现最多的报错:
code复制Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
at org.apache.ibatis.datasource.pooled.PooledDataSource.popConnection(PooledDataSource.java:427)
这里有个关键点:项目用的是 HikariCP 连接池,MyBatis 只是负责执行 SQL,连接是向连接池要的。当连接池里 30 个连接全部被慢 SQL 占着(每个连接都在执行那条要跑 30 秒的查询),新的请求来拿连接时,只能等待。HikariCP 默认的 connectionTimeout 是 30 秒,如果等待超过 30 秒还没拿到连接,就会抛出上面的异常。
所以“把 MyBatis 干翻了”这个说法,准确来说是“把 MyBatis 底层的数据库连接池干翻了”。一旦连接池耗尽,所有依赖数据库的接口全部不可用,整个服务看起来就像挂了,但服务进程本身还活着,表现就是接口大面积超时。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么 MyBatis 会被一条 SQL 干翻:连接池与执行链路分析
2.1 MyBatis 执行 SQL 的完整链路拆解
要理解事故的成因,我们先把 MyBatis 一次查询的完整流程过一遍。很多同学写了几年 MyBatis,其实对它的内部机制仍然停留在“调用接口 → 返回结果”的黑盒认知上,这里我拆开讲一下。
MyBatis 一次查询大致走这么几步:SqlSession 从 Configuration 里拿到 MappedStatement,创建 Executor 执行器,执行器负责管理一级缓存和二级缓存;接着通过 StatementHandler 创建 PreparedStatement,并通过 ParameterHandler 设置参数;然后交给 TypeHandler 做 Java 类型和 JDBC 类型之间的转换;最后通过 ResultSetHandler 把结果集映射成 Java 对象。
这个链路里最容易出问题的环节有两处。一是参数设置,如果 SQL 里写了 ${} 而不是 #{},MyBatis 会在 SQL 解析阶段直接把参数值拼接进 SQL 字符串,破坏了预编译机制,既没有类型安全,也给了 SQL 注入可乘之机;二是连接获取,真正调用 PreparedStatement.executeQuery() 之前,MyBatis 必须先从数据源拿到一个连接,也就是 PooledDataSource 或 HikariCP 的 getConnection() 调用。
事故发生时,问题就出在连接获取这一步。慢 SQL 把连接占满,后续请求在 getConnection() 上排队等待,线程池里所有 Tomcat 工作线程都阻塞在这,连健康检查接口都出现了延迟。
2.2 连接池耗尽机制:为什么单个慢 SQL 能搞挂整站
这里必须把连接池的工作机制说清楚。HikariCP 本质是一个管理数据库连接的池子,默认 maximumPoolSize 是 10,很多生产环境会调到 20 或者 30。每个活跃请求拿到一个连接后,这个连接只有在该请求的 SQL 执行完毕、事务提交或回滚后才会释放回连接池。
设想一个最简单的场景:连接池大小是 30,假设有 30 个请求同时进来,每个请求都在执行一条需要 30 秒的慢 SQL,那么这 30 个连接全部被占用。此时第 31 个请求进来,不管它的 SQL 多快多简单,都只能在连接池门口等着。如果后面所有请求的 SQL 都是秒级以内的快查询,它们也会跟着一起等,等到 HikariCP 的 connectionTimeout 超时,就会统一抛异常。
这就是典型的“池子被打满 — 请求排队 — 超时 — 异常扩散”的雪崩效应。而 MyBatis 本身是无辜的,它只是忠实地执行了 SQL,真正的问题在于慢 SQL 长时间占用连接,导致连接周转率骤降。正常情况下,一个连接每秒可以执行几十个快查询,连接复用率极高;但一个慢 SQL 就能让连接进入“只出不进”的状态,整个连接池的吞吐量直接归零。
我后来在复盘时算了一笔账:假设连接池 30 个连接,平均每个连接执行一条 SQL 需要 5 毫秒,理论上每秒钟可以处理 6000 个查询。但如果 30 个连接全被 30 秒的慢 SQL 占住,一秒内能处理的查询是 0 个,第 31 个查询开始排队,30 秒后才能处理 1 个。两者的吞吐量差了将近 18 万倍,这就是一条 SQL 干翻一个服务的原因。
3. 真实根因拆解:SQL 写法、动态 SQL 和索引的连环坑
3.1 大 IN 集合对 MyBatis 预编译和数据库优化的影响
回到那条 SQL 本身,另一个隐形问题出在 IN 集合过大。同事传了 8000 多个订单 ID,这在 MyBatis 里会做什么?如果用 #{},MyBatis 会为每个 ID 生成一个占位符,最终 SQL 变成 IN (?, ?, ?, ...),够 8000 多个占位符。虽然预编译没问题,但数据库在解析这条 SQL 时,要处理 8000 多个参数的绑定和索引查找,成本极高。
更麻烦的是,MySQL 的优化器对 IN 列表过大时的处理逻辑是:如果 IN 列表里的值过多,优化器可能会放弃索引,转而认为直接扫描全表比逐个回表更快。特别是当 IN 列表的值的区分度不高时,优化器基于统计信息估算出来的行数可能接近全表行数,于是直接选全表扫描,导致整个查询变成灾难。
那是不是说 8000 多个 ID 的批量查询就不能做?不是,只是要注意方式。常规的做法是控制单批数量,比如每批最多 500 个 ID,然后分页循环处理;或者把 ID 列表先导入临时表,业务 SQL 通过 JOIN 临时表而不是 IN 列表来查询。对于 MyBatis 来说,foreach 拼接大集合会让 MappedStatement 的 SQL 缓存失效,每次执行都可能重新解析、重新编译,也加剧了 CPU 开销。
3.2 LIKE 前置通配符和隐式类型转换:索引为什么用不上
除了大 IN,这条 SQL 里还有两个索引失效的陷阱。第一个是 u.nick_name LIKE CONCAT('%', #{keyword}, '%'),这种前置通配符的模糊查询根本无法走 B+ 树索引。B+ 树索引是按字段值的有序排列构建的,'%keyword%' 意味着查询条件是一个“中间包含”的匹配,数据库不知道从哪个索引节点开始扫描,只能全表扫描。MySQL 即使对 like 前缀匹配做了优化,也只能处理 'keyword%' 这种后缀通配的查询。
第二个陷阱是字段类型不匹配导致的隐式类型转换。当时 t_order 表的 user_id 字段是 bigint,但同事在某次迁移后把 t_user 表的 user_id 也定义成了 bigint,这本没有类型转换问题。但 o.order_id IN 里传入的 ID 是字符串类型(来自前端参数),MySQL 会自动把字符串转成数字再比较。如果字段本身是字符串类型、传入的是数字,或者反过来,索引也可能失效。这里的教训是:在 JOIN 条件和 WHERE 条件里,关联字段和过滤字段的类型必须严格一致,任何隐式转换都可能导致索引失效。
关于隐式类型转换,我多说一句。MySQL 在比较字符串和数字时,会把字符串转换成数字再比较,这本身不会让索引失效,但是如果你对索引字段做函数操作,比如 WHERE DATE(create_time) = '2024-01-01',索引就真的废了。这条 SQL 虽然没踩函数操作的坑,但 LIKE 前置通配符已经够致命了,三个大表全表扫描再加嵌套循环,不慢才怪。
3.3 动态 SQL 里的 ${} 与 #{}:预编译和安全边界
接下来是 MyBatis 动态 SQL 里一个必须强调的点:#{} 和 ${} 的区别。同事这次没用 ${},但我在代码评审里见过很多同学在不可避免需要动态排序字段或动态表名时,会用 ${}。它的代价是放弃预编译,直接把参数值拼接到 SQL 字符串里,这不仅是性能问题,更是安全问题。
用 #{} 时,MyBatis 通过 PreparedStatement 的占位符机制,把参数交给 JDBC 驱动处理,驱动会预先编译 SQL,再设置参数值。参数值不会改变 SQL 结构,所以能彻底避免 SQL 注入。而 ${} 是直接字符串替换,如果参数里混入了恶意内容,比如 ' OR '1'='1,拼接后的 SQL 就会出现所谓的万能密码绕过的条件,数据库全表数据都可能被你查询出来。
每次提到这个话题,我都会想起很久以前的一个事故:某后台管理系统的登录接口用了 ${} 接收用户名,结果有人在用户名里输入了 ' OR '1'='1' --,直接把登录查询语句变成了恒真条件,绕过了密码校验。所以不管你的项目是自研框架还是普及度极高的 MyBatis,凡是可以走预编译的地方,一律用 #{},禁止把 ${} 用在不可信的入参上。
4. 逐步排查与优化:从定位慢 SQL 到彻底修复
4.1 用 MyBatis 日志插件和数据库慢查询日志定位真凶
事故发生时,第一步不是去看代码,而是先定位“哪些 SQL 在占用数据库资源”。这里我强烈建议每个 MyBatis 项目都要配置 SQL 执行日志,至少要在开发/预发环境打印完整的 SQL 语句和参数。最简单的方式是在 mybatis-config.xml 里加配置:
xml复制<configuration>
<settings>
<!-- 让 MyBatis 打印 SQL 到控制台,生产环境慎开,开启前先评估日志量和敏感数据风险 -->
<setting name="logImpl" value="STDOUT_LOGGING"/>
</settings>
</configuration>
如果你用的是 Spring Boot,也可以在 application.yml 里指定 Mapper 接口所在包路径的日志级别为 DEBUG:
yaml复制logging:
level:
com.example.project.mapper: debug
这样运行时会在日志里输出类似 ==> Preparing: SELECT ... WHERE order_id IN (?, ?, ...) 和 ==> Parameters: 1001(String), 1002(String) 这样的内容,方便定位到具体 Mapper 和参数。
但生产环境光打日志还不够,更高效的手段是开启数据库的慢查询日志。MySQL 可以这样设置:
sql复制-- 查看当前慢查询配置
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志,并设置超过 2 秒的 SQL 都记录
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 2;
开启后,所有执行超过 2 秒的 SQL 都会被记录到日志文件里,再配合 mysqldumpslow 或直接查询 performance_schema 里的 events_statements_summary_by_digest 表,就能按执行次数、总耗时、平均耗时排序,快速定位哪些 SQL 语句是真正的性能瓶颈。
4.2 用 EXPLAIN 分析执行计划:识别全表扫描和 JOIN 顺序问题
定位到具体 SQL 之后,下一步是用 EXPLAIN 分析执行计划。我把当时那条 SQL 放到 EXPLAIN 里跑了一下,结果非常典型:
| id | select_type | table | type | key | rows | Extra |
|---|---|---|---|---|---|---|
| 1 | SIMPLE | u | ALL | NULL | 2178034 | Using where |
| 1 | SIMPLE | o | ref | idx_user_id | 38 | NULL |
| 1 | SIMPLE | p | eq_ref | PRIMARY | 1 | NULL |
看到没有,驱动表竟然是 t_user,type 是 ALL,也就是全表扫描。优化器把用户表作为第一个被读取的表,先扫 200 多万行数据,然后对每一行去订单表和商品表匹配。这就是典型的驱动表选错问题。业务上明明应该以订单表为主表(因为 order_id IN 能缩窄范围),但优化器在选择嵌套循环连接顺序时,可能因为统计信息不准、或 LIKE 条件导致估算成本偏差,选了最差劲那一套。
拿到这个执行计划,优化方案就很清晰了:
order_id IN的集合如果很大,分批查或改用临时表直联,绝不放几千个参数的IN。- 给订单表的
user_id、product_id加上索引(如果已有索引,定期做ANALYZE TABLE更新统计信息,避免优化器误判)。 - 把业务改成先按订单 ID 查出订单,再批量查用户和商品,分成三条 SQL,而不是一个大 JOIN 一把梭。
- 如果一定要模糊搜索用户昵称,可以考虑改成冗余字段或引入搜索引擎,而不是在核心查询里用前置通配符。
这是当时我能落地且收益最明显的几个手段。想理解 MySQL 为什么这么选连接顺序,建议系统地学习一下 MySQL 查询优化器基于成本的执行计划选择机制,以及 B+ 树索引的底层原理。理解了这些,你写出的 SQL 会少很多莫名其妙的坑。
4.3 连接池参数调优:缓解措施与长期方案
事故发生的当口,最紧迫的是把服务先救回来。我们当时的操作是:重启应用,让连接池里所有卡死的连接重新初始化;同时把 HikariCP 的 connectionTimeout 从 30 秒临时调低到 10 秒,避免大量请求堆积在连接池等待上。这一步是为了“快速失败”,让请求尽早返回错误,而不是都在那排队占着线程。
但这只是应急,根本不是根治。真正要做的是两方面:第一,从源头消灭慢 SQL;第二,把连接池参数和数据库参数调成合理水平。HikariCP 的几个关键参数我整理一下:
| 参数 | 默认值 | 说明 | 建议 |
|---|---|---|---|
maximumPoolSize |
10 | 连接池最大连接数 | 按 (CPU核数 × 2) + 1 估算,压测后再微调 |
minimumIdle |
10 | 最小空闲连接数 | 与最大连接数一致,避免频繁创建连接 |
connectionTimeout |
30000 ms | 等待连接的超时时间 | 一般 3000~10000 ms 即可,太长会造成请求堆积 |
maxLifetime |
1800000 ms | 连接最大存活时间 | 通常设为 30 分钟,比数据库 wait_timeout 短 |
validationTimeout |
5000 ms | 连接有效性检测超时 | 默认即可 |
尤其是 maximumPoolSize,并不是越大越好。连接数越多,数据库端需要维持的线程和内存资源越多;连接池里的连接大量处于空闲状态时,数据库也会做线程切换,浪费 CPU。常规的经验公式是 CPU 核心数 × 2 + 1 或 CPU 核心数 × 2 + 磁盘转速系数,但最靠谱的还是按压测结果来调,没有一劳永逸的数值。
4.4 MyBatis 层面的优化:批量操作、分页与缓存
最后再说说 MyBatis 层面的优化。那次事故发生在查询上,但类似的“把 MyBatis 干翻”还会出现在批量插入、循环单查、缓存滥用等场景。
先说批量插入。最忌讳的循环单条插入,比如在 Java 里 for (User user : userList) { userMapper.insert(user); }。这样会发起 N 次数据库请求,每次请求都走一次网络往返和事务提交,性能极差且容易把连接池打爆。正确的做法是 MyBatis 的批量插入 SQL,用 <foreach> 拼一条多条 INSERT 语句,比如:
xml复制<insert id="batchInsert">
INSERT INTO t_user (user_name, age, create_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.userName}, #{item.age}, now())
</foreach>
</insert>
不过要注意,MySQL 对单条 INSERT 语句的长度有限制(默认 max_allowed_packet 是 4MB),一次性插入的条数也不是越多越好,建议 500~1000 条一批。
再说分页。很多项目如果用的是 PageHelper,一定要确认它有没有正确拦截你的 SQL。PageHelper 的工作机制是在 MyBatis 执行器执行查询前,自动改写 SQL,拼接 LIMIT 分页条件,并额外执行一条 COUNT 查询。如果你在已经做了 UNION、GROUP BY 或者子查询的复杂 SQL 上强制分页,PageHelper 生成的 count SQL 可能会非常低效,甚至报错。我在项目里就遇到过 ORDER BY 字段不在 SELECT 中导致分页查询异常的问题。
最后是缓存。MyBatis 的一级缓存是 SqlSession 级别的,默认开启;二级缓存是 Mapper 级别的,默认关闭。如果你在 A 处查询了一个对象,对该对象做了修改后,同一 SqlSession 内再查同一主键,会直接返回一级缓存里的旧对象,这可能造成脏读。所以,涉及实时性要求高的数据,别用二级缓存;用一级缓存也要注意 SqlSession 的生命周期,避免长事务缓存的“幽灵读”。二级缓存开启后,还要处理好缓存刷新策略,比如增删改时要清理对应 Mapper 的缓存,不然数据不一致的问题甩都甩不掉。
5. 常见问题速查:MyBatis 慢 SQL 与连接池问题定位清单
5.1 典型异常与排查入口对照表
这里我整理了一份排查对照表,基本覆盖了“把 MyBatis 干翻”的常见表现,遇到类似问题可以直接按表索骥。
| 现象 | 根因 | 排查手段 |
|---|---|---|
Connection is not available |
连接池被打满,慢 SQL 或连接泄漏 | 查慢查询日志、HikariCP 指标监控 |
| 接口超时,CPU 飙升 | 大量慢 SQL 并发执行 | SHOW PROCESSLIST 看运行中的 SQL;EXPLAIN 分析执行计划 |
| SQL 执行很慢,索引没生效 | 索引失效、隐式转换、LIKE 前置通配符、函数处理字段 | EXPLAIN 看 type 是否 ALL,key 是否为 NULL |
| 查询结果数据和预期不一致 | MyBatis 一级/二级缓存、多表 JOIN 字段冲突 | 开启 SQL 日志,配合 DEBUG 查看缓存命中情况 |
| 批量操作效率低下,连接池被打满 | 循环单条增删改、大批量 IN 拼接 |
检查业务代码循环调用 Mapper 的写法 |
SQLSyntaxErrorException |
动态 SQL 拼接了错误字符、${} 注入 |
开启 MyBatis SQL 日志,查看最终执行的 SQL |
| 更新操作在只读事务里报错 | Service 方法事务只读 readOnly=true |
检查事务传播与 readOnly 配置是否正确设置 |
5.2 我踩过的几个 MyBatis 独特深坑
除了这次事故,再分享几个我平时踩过、后来在代码评审里反复强调的坑。
第一个是 foreach 拼 IN 时集合为空的问题。如果传入的 orderIds 是空集合,MyBatis 生成的 SQL 会变成 IN (),这是非法 SQL,直接报语法错误。我通常会在 XML 里加 <if test="orderIds != null and orderIds.size() > 0"> 判断,或者直接在 Java 层对空集合做短路返回。
第二个是动态 SQL 里的 <if test="keyword != null and keyword != ''">。有时候你传了空字符串,MyBatis 的判断不会触发,导致 SQL 里多了一个恒真的 LIKE '%%',看似没毛病,但对优化器来说可能会影响执行计划的选择。我一般会把这类判空逻辑统一收敛到一个全局工具方法里,比如 @SelectProvider 或直接在 XML 里写 OGNL 方法调用。
第三个是关于 MyBatis 打印可执行 SQL 插件。默认 STDOUT_LOGGING 打印的是带占位符的 SQL 和参数列表,不能直接复制到 Navicat 跑。社区里有很多 MyBatis SQL 打印插件,可以把参数值填充进去,生成可直接执行的完整 SQL。这在排查问题时特别省事,我建议把它加到测试环境里统一使用。
5.3 排查问题的日常意识:SQL 问题要尽早暴露
慢 SQL 的问题往往不是上线当天就爆发的,它有一个“数据量增长 — 执行计划劣化 — 连接池占满”的渐进过程。所以,排查不能只在出事故的时候做,日常就应该有意识地把问题暴露在早期。
我现在的习惯是:每次新写 SQL 或改动 SQL,都会先 EXPLAIN 一遍,确认 type 不是 ALL(全表扫描)或 index(全索引扫描);压测报告中必须包含“单条 SQL 的平均耗时”和“P99 耗时”两个指标;数据库的慢查询阈值调低到 1 秒或 2 秒,在生产环境只记录不影响业务的日志;定时巡检连接池活跃连接数和 SQL 执行耗时分布,一旦发现活跃连接数持续走高,就立即排查。
这些习惯看着简单,但能避免绝大多数“一条 SQL 干翻一个服务”的事故。很多团队都是等到线上报警才去救火,其实完全可以把问题拦截在代码评审阶段。代码评审的时候,如果有人给你提了一条“这个查询会不会全表扫描”的意见,别嫌烦,好好想一想——很可能就是这条 SQL 在某个月黑风高的晚上把你叫起来处理事故。
6. 写在最后:一些真实的经验和建议
搞完这次复盘,我最大的体会是:千万别把 MyBatis 当成一个黑盒,也别把数据库当成一个任劳任怨的傻子。MyBatis 的连接管理、缓存机制和动态 SQL 拼接方式,都在悄悄影响应用的稳定性;而数据库的索引优化器选择了什么执行计划,直接决定了你的 SQL 是快 10 毫秒还是慢 30 秒。
如果你让我给一条最实在的建议,我会说:在任何项目里都别写那种“看起来能跑、但没想到数据量增长后会崩”的 SQL。查询条件里能走索引的一定要让索引生效,能不前置通配符的绝对不要用,能分页的一次性别查全量,能批量的一定不要循环单条操作。先让 SQL 在数据库层面“体面”,MyBatis 在应用层面自然就不会“翻车”。
最后再分享一个小技巧。如果你发现自己某个接口响应变慢,第一件事不是去猜代码逻辑,而是先打开 MySQL 的 SHOW FULL PROCESSLIST,看看数据库当前正在执行哪些 SQL,每条 SQL 跑了多久,有没有长时间卡住的语句。这一步能让你在 5 分钟内定位到是不是慢 SQL 在作祟,比翻半天应用日志效率高得多。我那次事故,就是靠这一招在 10 分钟内锁定了那条“干翻 MyBatis”的 SQL。
