主题
MyBatis 生产踩坑
一句话:MyBatis 的线上问题大多不是“框架坏了”,而是映射、缓存、session 生命周期三处边界没对齐;每类问题都有可观测证据,先取证再改代码。
适用版本:MyBatis 3.5.x + mybatis-spring 3.0.x / Spring Boot 3.x。数据库以 MySQL 8.x InnoDB 为例。
为什么需要
MyBatis 把 SQL 控制权交给开发者,也把三类责任一起交了出来:
- SQL 条数:嵌套查询会在 N 行结果上放大成 N+1 条 SQL。
- 对象生命周期:一级缓存跟
SqlSession走,延迟加载依赖 session,session 一关就失效。 - 类型映射:
ResultMap、TypeHandler、驱动推断三者任一出错,数据会静默变形。
这些问题在开发环境数据少、并发低时不明显,上线后才放大。下面按“现象 → 证据 → 根因 → 处置”写,检查清单见 99-生产检查清单。
核心机制
一、N+1:一行结果触发一条子查询
现象:列表接口返回 100 行,SQL 日志里有 101 条查询,数据库 QPS 突增。
证据:打开 SQL 日志,看到主查询之后跟着大量相同模板、只有参数不同的子查询。
根因:<collection select="..."> / <association select="..."> 是嵌套查询,每映射一行就发一次子查询。延迟加载只是把“立即发”改成“访问时发”,不消除条数。
处置:列表场景改 JOIN 嵌套结果,一次 SQL 取回;不确定是否需要关联数据时,用批量 IN 查询在 Service 层组装;延迟加载只适合单条详情,不适合列表。
二、一级缓存:长事务里的旧值
现象:另一个事务已经更新并提交,当前请求仍返回旧值。
证据:同一 @Transactional 方法内两次相同查询只打了一次 Preparing;把 @Transactional 去掉后第二次回表。
根因:一级缓存是 SqlSession 级,Spring 在同一个事务内复用 session。另一个事务的提交不会清空当前 session 的本地缓存。
处置:缩小事务范围,别把用户交互、远程调用放进事务;关键“先查后改”查询加 flushCache="true";一致性要求极高时用 localCacheScope=STATEMENT,代价是重复回表。
三、延迟加载:异常名和 session 生命周期
现象:Controller 序列化返回对象时抛异常,提示 session/executor 已关闭,或代理对象不可用。
证据:异常发生在事务提交之后;SQL 日志里没有对应子查询,或者查询发出时连接已归还。
根因:延迟加载代理在首次 getter 时才查库,而 SqlSession/Executor 已经关闭。这里要纠正一个常见混淆:LazyInitializationException 是 Hibernate/JPA 的异常名,不是 MyBatis 的;MyBatis 侧常见的是 ExecutorException: Executor was closed.,在序列化/代理替换路径上也可能出现 PersistentObjectException 或代理失效。
处置:在 Service 内用 DTO/VO 或 JOIN 一次取完;不要用 OpenSessionInViewFilter 掩盖。MyBatis-Spring 的 OpenSessionInViewFilter/OpenSessionInViewInterceptor 会把 session 开到请求结束,代价是数据库连接被占用的时间从“SQL 执行”拉长到“整个 HTTP 请求”;它不是 JPA 的 spring.jpa.open-in-view 配置,别把两者混为一谈。
四、大字段:一次性把 LONGTEXT 拉进堆
现象:堆内存上涨,dump 里 char[]/byte[] 占比高,GC 后不下降。
证据:出问题的查询包含 TEXT/LONGTEXT/BLOB 字段;返回行数多或单行字段很大。
根因:默认查询会把结果集映射成对象列表,多个大字段同时在堆里。resultOrdered 主要服务于嵌套结果映射的行复用,不是大字段的内存开关;把它当成通用优化会失望。
处置:列表查询不查大字段,详情再单独查;必须流式处理时用 Cursor,并保证 session 在遍历期间打开(Spring 下通常要 @Transactional);MySQL 流式读取还要驱动配合(如 useCursorFetch=true + 正数 fetchSize,或按驱动版本使用 fetchSize=Integer.MIN_VALUE,以官方文档为准)。
五、DECIMAL 精度:默认映射通常是 BigDecimal
现象:金额对账差几分钱,或 Java 侧拿到 Double。
证据:看 ResultMap 的 javaType、实体字段类型、自定义 TypeHandler;再看驱动返回的 ResultSetMetaData 列类型。
根因:MyBatis 3.5.x 默认的 BigDecimalTypeHandler 会把 DECIMAL/NUMERIC 映射为 java.math.BigDecimal,不是默认变成 double。真正的问题多来自:实体字段声明成 double/Double、resultMap 显式写了 javaType="java.lang.Double"、自定义 TypeHandler 转错、或驱动/jdbcType 推断与预期不一致。不能一概说“MyBatis 把 DECIMAL 映射成 double”。
处置:金额字段统一 BigDecimal;resultMap 显式写 jdbcType="DECIMAL" javaType="java.math.BigDecimal" 消除歧义;检查自定义 TypeHandler;对历史数据做一次回归,精度丢失不会抛异常。
最小上手
用 SQL 日志数一次列表查询的实际条数,先确认是不是 N+1:
properties
# application.properties(仅本地/预发)
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImplbash
# 调用一次列表接口后,统计 Preparing 行数
grep -c "==> Preparing:" app.log
# 再统计参数行,确认是否同一模板反复执行
grep "==> Parameters:" app.log | sort | uniq -c | sort -nr | head返回 100 行却出现 101 条 Preparing,且参数行高度重复,基本可以判定为嵌套查询导致的 N+1;改 JOIN 或批量 IN 后同样的命令应降到 1–2 条。
生产动作
- 先取证再改:SQL 日志、
Preparing/Parameters计数、连接池活跃数、慢查询日志,四者对齐后再判断是 N+1、缓存还是锁。 - 一级缓存相关:确认查询和更新是否在同一事务;需要强制回表时用
flushCache="true",不要为了“绕过缓存”把整个事务拆碎。 - 延迟加载相关:确认异常发生在事务提交前还是提交后;提交后出现就改 DTO/JOIN,不要延长 session。
- 大字段相关:确认默认查询是否包含大字段;用
Cursor时确认事务和fetchSize配置,遍历完及时关闭。 - 精度相关:确认实体、
resultMap、TypeHandler三处的类型一致;不要只改jdbcType而保留double字段。 - 观测指标:数据库 QPS 与平均 SQL 响应时间同时异常(大量简单 SQL 拉低均值)、连接池等待数、堆中
char[]/byte[]占比、二级缓存命中率。指标只能提示方向,最终要用 SQL 日志确认。
边界
- 不是所有慢接口都是 MyBatis:网络、锁等待、序列化、下游 RPC 都会拖慢;先看 SQL 耗时占比。
- 不是所有旧值都是缓存:主从延迟、事务隔离级别、读己之写语义也会造成旧值;先确认是否同一事务/同一连接。
- 不是所有精度问题都要改
jdbcType:字段类型、TypeHandler、驱动行为都可能有问题;按证据逐层排除。 OpenSessionInView不是延迟加载的解:它把连接持有时间拉长,线上高并发时更容易耗尽连接池;优先 DTO/JOIN。resultOrdered不是大字段优化:它解决嵌套结果的行复用,别把它当成流式查询或内存优化开关。
讲清楚
MyBatis 的线上问题集中在三处边界:SQL 条数、session 生命周期、类型映射。N+1 是嵌套查询在 N 行结果上放大;一级缓存是同一个
SqlSession在事务内复用了旧值;延迟加载是 session 关闭后代理才去查库。三者都能用 SQL 日志和事务边界快速区分。两个事实要记准:
LazyInitializationException是 Hibernate/JPA 的异常,不是 MyBatis 的;MyBatis 侧常见ExecutorException: Executor was closed.或代理失效。DECIMAL在 MyBatis 3.5.x 默认映射为BigDecimal,精度问题通常来自实体字段、显式javaType、自定义TypeHandler或驱动推断,不能一概而论。处置顺序是“先取证,再改代码”:数 SQL 条数、确认事务边界、看结果映射,比凭经验加缓存或加索引更可靠。