跳到正文

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.StdOutImpl
bash
# 调用一次列表接口后,统计 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 条数、确认事务边界、看结果映射,比凭经验加缓存或加索引更可靠。

把一门技术讲透到既懂也会用