MyBatis 与 ORM
本文范围
MyBatis 不是“少写 SQL”的工具,它更像一层可控的 SQL 映射器:SQL 仍然由你负责,参数绑定、结果映射、事务参与、插件拦截和 Spring 集成由框架帮你收口。项目里一旦出现列表查询膨胀、分页慢、批量导入锁表、事务不回滚、连接池被打满,问题往往不在“会不会写 Mapper”,而在数据访问层有没有边界。
本文只讲开发侧落地:Spring Boot 项目怎么接入 MyBatis,Mapper 和 XML 怎么组织,动态 SQL 怎么防止变成条件黑洞,ParamNameResolver 怎么决定 XML 参数名,TypeHandler 怎么影响 JDBC 绑定,一级缓存、二级缓存和 TransactionalCache 怎么参与事务,分页和批量怎么写,插件能做什么不能做什么,事务和慢 SQL 怎么验证。数据库安装、备份恢复、主从集群和服务器巡检不在本文范围内。
前置条件
建议你先准备一个能连接测试库的 Spring Boot 服务。新项目可以按 Spring Boot 4.x、MyBatis-Spring-Boot-Starter 4.0.x、MyBatis 3.5.x 做基线;如果显式锁版本,可以优先锁到同一条兼容线里的最新补丁版本。老项目不要跨大版本硬升,先按自己的 Spring Boot 主版本匹配对应 starter。企业内网或离线环境下,先把 org.mybatis、数据库驱动、分页插件和测试依赖缓存到 Maven 私服,再开始改代码。
示例默认使用 MySQL 测试库、HikariCP、XML Mapper 和 Spring 事务。生产库排查前要确认只读账号、慢 SQL 日志、执行计划权限、连接池指标和脱敏策略,不能用开发机脚本直接扫生产数据。
MyBatis 和 ORM 的边界
场景说明:很多团队把 MyBatis、MyBatis-Plus、JPA、Hibernate 都叫 ORM,但它们的工作方式不一样。边界没分清,就会把完整 ORM 的使用习惯带到 MyBatis 里,比如指望对象状态自动同步、指望延迟加载自动解决关联查询、指望缓存自动保证一致性。
MyBatis 更准确地说是 SQL Mapper。它帮你把 SQL、参数和结果对象连接起来,但 SQL 设计、事务边界、对象裁剪、关联加载和一致性策略仍然由开发团队负责。JPA/Hibernate 这类完整 ORM 更强调实体状态、持久化上下文、脏检查、级联和对象关系映射;它能减少一部分 SQL 编写,但也会带来查询透明度、N+1、持久化上下文大小和隐式 flush 的治理成本。
直接判断可以用这张小表:
| 选择 | 更适合 | 主要代价 |
|---|---|---|
| MyBatis | 复杂 SQL、报表查询、性能可控、团队愿意审查 SQL | SQL 和映射都要自己治理 |
| MyBatis-Plus | 规范 CRUD、简单条件构造、后台管理类页面 | 核心业务动作容易被通用方法掩盖 |
| JPA/Hibernate | 领域模型稳定、对象关系强、希望用实体状态驱动持久化 | SQL 透明度和性能边界要额外治理 |
本文后面默认站在 MyBatis 视角:SQL 是一等公民,Mapper 方法要表达业务语义,复杂查询必须能拿到执行计划,缓存和关联加载不能替代业务一致性设计。
先把最小链路跑起来
场景说明:很多 MyBatis 问题都是基础链路没跑通就开始写复杂 SQL。先做一个最小查询,让你确认依赖、数据源、Mapper 扫描、XML 路径、驼峰映射、SQL 日志都正常。
直接做法,先加依赖。版本交给项目 BOM 管理也可以;如果没有 BOM,就显式锁定版本。
<dependencies>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>4.0.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>再配置数据源和 MyBatis。这里先用最常见的 XML 方式,复杂 SQL 后续更好审查。
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/app_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: app_user
password: ${APP_DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
max-lifetime: 1800000
mybatis:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.example.demo.domain
configuration:
map-underscore-to-camel-case: true
default-statement-timeout: 5
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
logging:
level:
com.example.demo.mapper: debug然后写一个查询。Mapper 接口不要叫 BaseMapper、CommonMapper 这种万能名字,先把业务语义写出来。
package com.example.demo.mapper;
import java.util.Optional;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.example.demo.domain.UserAccount;
@Mapper
public interface UserAccountMapper {
Optional<UserAccount> findActiveById(@Param("id") Long id);
}<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.UserAccountMapper">
<resultMap id="UserAccountMap" type="UserAccount">
<id property="id" column="id"/>
<result property="username" column="username"/>
<result property="status" column="status"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findActiveById" resultMap="UserAccountMap">
select id, username, status, created_at
from user_account
where id = #{id}
and status = 'ACTIVE'
</select>
</mapper>验证结果,不要只看接口返回。先看 Spring 容器里有没有 Mapper,再看 SQL 有没有打出来。
./mvnw test
./mvnw spring-boot:run
curl "http://localhost:8080/users/1"如果开启了 Actuator,也可以看连接池指标:
curl "http://localhost:8080/actuator/health"
curl "http://localhost:8080/actuator/metrics/hikaricp.connections.active"
curl "http://localhost:8080/actuator/metrics/hikaricp.connections.pending"常见坑:
- 启动报
Invalid bound statement:Mapper 方法名和 XML 的id不一致,或者 XML 没被mapper-locations扫到。 - 启动报找不到 Mapper Bean:忘了
@Mapper,或者没有配置@MapperScan。 - 字段都是
null:列名和属性名没映射上,确认map-underscore-to-camel-case或显式resultMap。 - SQL 没打印:日志级别没开到 Mapper 包,或者
log-impl没接到 SLF4J。
生产建议:核心业务不要只依赖自动映射。简单表可以开驼峰映射,复杂查询、联表、聚合、字段裁剪一律写 resultMap,把“查了哪些字段、映射到哪里”留在代码审查里。
一次 Mapper 调用到底经过哪些对象
场景说明:会写 Mapper 只能解决“怎么查”。线上排障时更重要的是知道一次调用卡在了哪里:是 Mapper 没绑定、动态 SQL 拼错、参数没绑定、缓存返回旧数据、分页插件改了 SQL,还是 JDBC 和数据库执行慢。
先把主链路背下来,后面所有排障都往这条链上挂:
Mapper 接口
-> MapperProxy.invoke
-> MapperMethod.execute
-> SqlSession.selectList/update
-> Configuration.getMappedStatement
-> MappedStatement.getBoundSql
-> Executor.query/update
-> StatementHandler.prepare/parameterize/query/update
-> ParameterHandler.setParameters
-> JDBC PreparedStatement
-> ResultSetHandler.handleResultSets
-> 一级缓存 / 二级缓存 / 插件链
-> Spring 事务提交或回滚如果画成运行时关系,大概是这样:
直接做法,写一个很小的测试,把 MappedStatement 和 BoundSql 打出来。这样你能确认 XML 是否真的进入了 MyBatis,以及动态 SQL 最终生成了什么。
@SpringBootTest
class MyBatisStatementSmokeTest {
@Autowired
SqlSessionFactory sqlSessionFactory;
@Test
void printMappedStatementAndBoundSql() {
Configuration configuration = sqlSessionFactory.getConfiguration();
String statementId = "com.example.demo.mapper.OrderQueryMapper.listForBackoffice";
MappedStatement ms = configuration.getMappedStatement(statementId);
OrderListQuery query = new OrderListQuery();
query.setTenantId(1001L);
query.setStatus("PAID");
query.setLimit(20);
query.setOffset(0);
BoundSql boundSql = ms.getBoundSql(query);
System.out.println("id=" + ms.getId());
System.out.println("commandType=" + ms.getSqlCommandType());
System.out.println("sql=" + boundSql.getSql().replaceAll("\\s+", " ").trim());
System.out.println("parameterMappings=" + boundSql.getParameterMappings());
}
}验证结果应该能看到完整的 statementId、SQL 类型、最终 SQL 和参数映射。这里不需要连数据库执行,只要能拿到 BoundSql,就说明 Mapper XML 已经被解析进 Configuration。
常见坑:
statementId写错时,getMappedStatement直接抛异常,说明不是数据库问题,而是 Mapper 注册问题。BoundSql里的 SQL 没有你预期的条件,优先检查<if test>、参数名、@Param和入参对象属性名。- 参数映射为空但 SQL 里有用户输入,通常说明用了
${}字符串替换,要重新审查注入风险。
生产建议:核心 SQL 的 MappedStatement id 要进入慢 SQL 日志或 trace 标签。只有 SQL 文本没有 Mapper id,排障时很难从数据库慢日志回到代码。
XML 如何变成 MappedStatement
场景说明:Invalid bound statement、XML 改了不生效、启动时才报 SQL 解析错误,本质都和“XML 有没有被解析成 MappedStatement”有关。不要只检查 SQL 字符串,要知道 MyBatis 启动时做了什么。
Spring Boot Starter 会根据 DataSource 创建 SqlSessionFactoryBean,再由它构建 SqlSessionFactory。构建过程中会创建 Configuration,扫描 mapper-locations,每个 XML 由 XMLMapperBuilder 解析,里面的 <select>、<insert>、<update>、<delete> 再交给 XMLStatementBuilder 封装成 MappedStatement。namespace + id 就是最终的 statement id。
这条注册链路可以这样看:
排查 Invalid bound statement 时,先按这张图切问题:资源有没有被 mapper-locations 扫到,namespace 是否对上接口,XML 里的 id 是否对上方法名,最后再看运行时调用。设计评审时也要把 statementId 当成数据访问契约来审,避免一个 XML 复制粘贴后命名空间、缓存和结果映射混在一起。
一个 XML 节点被解析后,大致会留下这些运行时对象:
| XML 内容 | 运行时对象 | 排障时看什么 |
|---|---|---|
<mapper namespace=""> | Mapper 命名空间 | 是否等于 Mapper 接口全限定名 |
<select id=""> | MappedStatement.id | 是否等于 namespace + "." + 方法名 |
| SQL 文本和动态标签 | SqlSource | 静态 SQL 还是动态 SQL |
#{} 参数 | ParameterMapping | JDBC 参数顺序和类型处理器 |
resultMap / resultType | ResultMap | 字段为 null、类型转换失败 |
<cache> | Cache | 二级缓存作用域和刷新行为 |
直接做法,启动时打开 fail fast 思路:让 Mapper XML 解析错误尽早暴露。Spring Boot 项目里至少保证 mapper-locations 指向资源目录里的 XML,并写一个能加载应用上下文的测试。
mybatis:
mapper-locations: classpath*:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true@SpringBootTest
class MyBatisMapperLoadTest {
@Autowired
SqlSessionFactory sqlSessionFactory;
@Test
void shouldLoadMappedStatements() {
assertThat(sqlSessionFactory.getConfiguration().getMappedStatementNames())
.contains("com.example.demo.mapper.OrderQueryMapper.listForBackoffice");
}
}验证结果:应用上下文能启动,并且 getMappedStatementNames() 能找到目标 statement id。这个测试很朴素,但它能提前挡住 XML 路径、命名空间、方法名和资源打包问题。
常见坑:
- XML 放在
src/main/java但构建没有打包进去,开发环境能跑,打 jar 后找不到。 namespace和 Mapper 接口包名不一致,接口方法永远绑不上 XML。type-aliases-package扫不到可执行 jar 里的类型,老式手工配置没有使用适合 Spring Boot 的 VFS。- 一个 XML 里复制了同名
id,启动阶段就应该失败,不要等接口调用时才发现。
生产建议:Mapper XML 是代码的一部分,必须进单元测试和构建验证。不要把 XML 当成“运行时配置”在线上手改;线上手改和应用包里的 MappedStatement 不一致,会让回滚和复现非常痛苦。
BoundSql:动态 SQL 的最终落点
场景说明:XML 里的 <if>、<where>、<foreach> 只是模板;数据库真正看到的是 BoundSql 里的最终 SQL 和参数列表。慢 SQL 排查、分页插件、SQL 注入审查,都应该落到 BoundSql。
MyBatis 默认的 XML 动态 SQL 由 XmlLanguageDriver 处理。静态 SQL 通常会变成较简单的 SqlSource;只要出现动态标签或 ${},就会在执行时根据参数重新计算 SQL。#{} 会变成 JDBC 占位符和 ParameterMapping,${} 会直接替换成文本。
BoundSql 和参数绑定的关系可以按下面这条链路排查:
这张图排障时很好用:Parameter 'xxx' not found 先看 ParamNameResolver,null 绑定报错先看 JdbcType 和 TypeHandler,SQL 注入审查直接看用户输入有没有绕过 ParameterMapping 进入 ${}。设计评审时要求关键列表查询把 BoundSql.sql、参数列表和排序白名单都能讲清楚,否则后面分页插件、慢 SQL 日志和数据库执行计划很容易对不上。
直接做法,用这三条规则审查动态 SQL:
SQL 结构变化:交给 <if>、<choose>、<where>、<set>、<foreach>。
参数值绑定:默认用 #{},让 JDBC PreparedStatement 绑定。
标识符拼接:只有表名、列名、排序方向这类无法用 #{} 的位置,才允许白名单后的 ${}。拿排序举例,前端传的是枚举,不是 SQL 片段:
public String resolveSortClause(OrderSort sort) {
if (sort == null) {
return "created_at desc, id desc";
}
return switch (sort) {
case CREATED_ASC -> "created_at asc, id asc";
case CREATED_DESC -> "created_at desc, id desc";
case AMOUNT_DESC -> "amount desc, id desc";
};
}XML 里再使用白名单后的值:
order by ${sortClause}验证结果,拿 BoundSql 打印最终 SQL,并确认:
普通用户输入是否都变成了 ?。
排序字段是否只来自枚举白名单。
空集合、空字符串、空时间范围是否不会变成全表查询。常见坑:
@Param名称和 XML 里使用的名称不一致,test表达式一直为 false。<where>可以去掉开头的and,但不能替你决定“没有任何条件时是否允许查询”。foreach nullable="true"只能处理标签输出问题,不能替代业务上的集合大小限制。like #{keyword}不会自动加%,要在 Java 层或<bind>明确生成。
生产建议:动态 SQL 越灵活,越要限制入参形态。核心列表查询要把高频条件、排序、分页方式写成清楚的参数对象,不要让一个 XML 同时服务后台列表、导出、定时任务和外部接口。
ParamNameResolver:XML 里的名字从哪里来
场景说明:很多 BindingException: Parameter 'xxx' not found 看起来像 XML 写错了,其实是 Mapper 方法参数名和运行时参数名不一致。MyBatis 会通过 ParamNameResolver 把 Java 方法参数解析成可供 OGNL 使用的名字:单参数对象可以直接取属性,多参数默认会有 param1、param2 这类兜底名;显式写了 @Param 后,XML 应优先使用注解名。
直接做法是:核心 Mapper 多参数一律显式 @Param,列表查询优先封装参数对象。这样 XML 的参数名就不会依赖编译器是否保留 -parameters,也不会因为方法签名调整而悄悄漂移。
Optional<OrderDetailRow> findDetailForCustomer(@Param("orderNo") String orderNo,
@Param("customerId") Long customerId);对应 XML:
<select id="findDetailForCustomer" resultMap="OrderDetailMap">
select id, order_no, customer_id, status, amount
from biz_order
where order_no = #{orderNo}
and customer_id = #{customerId}
</select>验证方式很简单:写一个最小集成测试,让 Mapper 方法真的执行一次,不要只靠 IDE 跳转。异常里如果出现 Available parameters are [arg1, arg0, param1, param2],基本就能定位到参数名解析问题。
常见坑:
- 多参数不写
@Param,XML 里却使用业务名。 - 单个
List参数在 XML 里误用成业务名,实际要看是否被解析成list、collection或注解名。 - 参数对象里嵌套字段过深,
test表达式可读性很差,后续维护者不敢改。
生产建议:Mapper 方法签名属于数据访问契约。高频 SQL 的参数对象要独立建类,并用单元测试覆盖空值、空集合、极限分页和非法排序,别让参数名问题等到线上才从日志里冒出来。
TypeHandler、JdbcType 和枚举映射
场景说明:字段明明有值,查出来却不对;枚举上线后老数据解析失败;传 null 时某些驱动报 JDBC 类型不明确。这类问题通常不在 SQL 文本,而在 TypeHandler、JdbcType 和数据库字段类型的边界上。
MyBatis 参数绑定和结果读取都会经过 TypeHandlerRegistry。常规类型有内置处理器;枚举默认可用 EnumTypeHandler,按枚举名读写;如果数据库存的是数字码或业务码,就要显式写自定义 TypeHandler,不要靠 ordinal()。
@MappedTypes(OrderStatus.class)
@MappedJdbcTypes(JdbcType.INTEGER)
public class OrderStatusTypeHandler extends BaseTypeHandler<OrderStatus> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
OrderStatus parameter, JdbcType jdbcType) throws SQLException {
ps.setInt(i, parameter.code());
}
@Override
public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
int code = rs.getInt(columnName);
return rs.wasNull() ? null : OrderStatus.fromCode(code);
}
@Override
public OrderStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
int code = rs.getInt(columnIndex);
return rs.wasNull() ? null : OrderStatus.fromCode(code);
}
@Override
public OrderStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
int code = cs.getInt(columnIndex);
return cs.wasNull() ? null : OrderStatus.fromCode(code);
}
}配置示例:
mybatis:
type-handlers-package: com.example.infra.mybatis.type
configuration:
jdbc-type-for-null: 'NULL'验证方式:至少测三类值,正常枚举、未知码、null。未知码不要静默映射成默认状态,应该明确抛异常或进入兼容分支。
TypeHandler 的选择规则也要讲清。参数绑定时 Java 类型和 JDBC 类型通常都比较明确;结果映射时 Java 属性类型明确,但 JDBC 类型经常是 null。如果你只写了 @MappedJdbcTypes(JdbcType.INTEGER),又没有让它包含 null 场景,某些 ResultMap 读取可能不会按你想的方式选择这个处理器。更稳的做法是:
<resultMap id="OrderResultMap" type="com.example.order.Order">
<result column="status"
property="status"
jdbcType="INTEGER"
typeHandler="com.example.infra.mybatis.type.OrderStatusTypeHandler"/>
</resultMap>排查顺序:
- 看
mybatis.type-handlers-package是否真的扫描到类。 - 看处理器上的
@MappedTypes、@MappedJdbcTypes是否覆盖读写两边。 - 看 XML 的
resultMap、#{status,jdbcType=INTEGER,typeHandler=...}是否显式指定。 - 写一条坏值数据,确认读取时走的是自定义处理器,而不是默认枚举名处理器。
生产建议:全局扫描适合统一约定,关键状态字段建议在 resultMap 或参数占位符里显式指定。这样升级 MyBatis、换驱动或字段类型调整时,映射边界不会藏在自动选择规则里。
常见坑:
- 枚举使用
ordinal(),后续新增枚举导致历史数据语义错位。 - 数据库字段是
tinyint,Java 侧按字符串枚举名处理,查询和写入都不稳定。 jdbcTypeForNull在不同驱动上的行为不一致,核心写入最好在 XML 里给关键字段显式jdbcType。
生产建议:枚举入库要先定“存名字、存业务码、还是存数字码”。状态、类型、渠道这类字段必须有兼容策略和坏值告警,否则一次枚举重构就可能变成数据事故。
Executor、缓存和结果映射的边界
场景说明:有些查询在一个事务里重复执行但结果没变,有些更新后同一个会话里读到了旧值,有些团队打开二级缓存后出现权限或状态错乱。这些问题不是 SQL 写法能解释的,要看 Executor 和缓存作用域。
SqlSession 调用 selectList 后,会从 Configuration 拿到 MappedStatement,再交给 Executor。常见执行器有三类:
| 执行器 | 适合场景 | 风险 |
|---|---|---|
SIMPLE | 默认选择,每次创建新的 PreparedStatement | 普通接口最稳 |
REUSE | 复用 PreparedStatement | 收益依赖驱动和连接生命周期 |
BATCH | 批量写入 | 需要显式处理 flush、异常和事务边界 |
一级缓存默认绑定在 SqlSession 上。在 Spring 事务中,同一个事务通常会复用同一个会话,所以同一个 statement、同一组参数可能命中本地缓存。可以通过配置把本地缓存收窄到语句级别:
mybatis:
configuration:
local-cache-scope: statement二级缓存绑定在 Mapper namespace 上,需要 XML 里显式配置 <cache> 或共享 <cache-ref>,并受 useCache、flushCache 影响。它看起来省数据库,但在权限敏感、状态频繁变化、跨服务写入、缓存失效链路不完整的系统里,很容易把旧数据包装成“框架返回”。
缓存命中不是只看 SQL 字符串。MyBatis 会把 MappedStatement、分页边界、最终 SQL、参数值、环境等信息组合成 CacheKey。所以同一段 XML,只要动态 SQL、分页、参数或环境变化,缓存 key 就会不同。排查时不要只问“SQL 一样吗”,要问“BoundSql 和参数是否完全一样”。
二级缓存还会经过 CachingExecutor 和 TransactionalCacheManager。事务内查询结果先进入 TransactionalCache 的暂存区,只有事务提交后才真正写入 namespace cache;事务回滚时暂存结果会被丢弃。这个语义很重要:如果你看到事务未提交前别的会话读不到二级缓存结果,这是设计;如果绕过 Spring 事务或手工提交混用,就可能把刷新边界搞乱。
可以用一张图把缓存路径记住:
验证结果,写一个事务内重复查询测试,观察同一条 SQL 是否被重复打印:
@Transactional
public void printTwice(Long id) {
orderQueryMapper.findDetail(id);
orderQueryMapper.findDetail(id);
}如果同一个事务内第二次没有再打印 SQL,先判断是不是一级缓存命中;如果你需要每次都查库,再评估 local-cache-scope: statement 或拆开事务边界。
常见坑:
- 在一个长事务里先查后改再查,以为第二次一定来自数据库。
- 为了性能打开二级缓存,却没有定义清楚哪些写操作会刷新缓存。
- Mapper 返回可变对象,二级缓存 read-only 配置不当时,调用方修改对象会污染后续读取。
BATCH执行器和普通查询混用,flush 时机不清楚,异常定位困难。
生产建议:大多数业务系统默认不开 MyBatis 二级缓存,把缓存设计放到业务缓存层统一治理。一级缓存要知道它存在,长事务和强一致读取场景要通过测试验证行为。
Mapper 和 XML 怎么分工
场景说明:一个系统早期只有几个 CRUD,后面加了导入、审核、权限过滤、外部同步、历史查询,Mapper 很快就会变成一堆 list、query、update。等慢 SQL 出来时,没人知道某条 SQL 服务哪个页面。
直接做法,把 Mapper 方法按业务动作命名,把参数对象和返回对象分开。
public interface OrderQueryMapper {
List<OrderListRow> listForBackoffice(OrderListQuery query);
List<OrderExportRow> listForExport(OrderExportQuery query);
Optional<OrderDetailRow> findDetailForCustomer(@Param("orderNo") String orderNo,
@Param("customerId") Long customerId);
}参数对象只放查询条件,不复用 Controller 的请求体,也不直接复用实体对象。
public class OrderListQuery {
private Long tenantId;
private Long ownerId;
private String status;
private LocalDateTime createdFrom;
private LocalDateTime createdTo;
private String keyword;
private Integer limit;
private Integer offset;
}返回对象按页面或业务动作裁剪字段。
public class OrderListRow {
private Long id;
private String orderNo;
private String status;
private BigDecimal amount;
private LocalDateTime createdAt;
}验证结果:评审时看三个问题就够直接。
这个 Mapper 方法服务哪个业务场景?
这个 SQL 的高频过滤条件和排序字段是什么?
这个返回对象有没有把敏感字段或无关大字段带上来?常见坑:
- Mapper 返回实体对象,导致数据库字段变成接口契约。
- 一个
list(Query query)支撑十几个页面,任何条件改动都影响一大片。 select *看着省事,后续字段增加、网络传输、脱敏和索引覆盖都会被拖累。
生产建议:核心 Mapper 方法旁边可以保留短注释,只写用途和索引假设,不写废话。
/**
* 管理后台订单列表:按租户、状态、创建时间筛选,默认按 created_at desc, id desc 翻页。
* 高频查询,依赖 idx_order_tenant_status_created_id。
*/
List<OrderListRow> listForBackoffice(OrderListQuery query);动态 SQL:能用,但别写成条件黑洞
场景说明:动态 SQL 最容易从“可选条件”滑成“万能查询”。一开始只是状态可选,后来权限、关键字、时间、标签、排序、分页全塞进一个 XML。它能跑,但调优时很难知道哪条组合是真正高频。
直接做法,普通可选条件用 <where>,更新用 <set>,集合用 <foreach>,模糊查询用 <bind>,排序字段用白名单在 Java 层转换。
<select id="listForBackoffice" resultMap="OrderListRowMap">
select id, order_no, status, amount, created_at
from biz_order
<where>
tenant_id = #{tenantId}
<if test="ownerId != null">
and owner_id = #{ownerId}
</if>
<if test="status != null and status != ''">
and status = #{status}
</if>
<if test="createdFrom != null">
and created_at >= #{createdFrom}
</if>
<if test="createdTo != null">
and created_at < #{createdTo}
</if>
<if test="keyword != null and keyword != ''">
<bind name="kw" value="'%' + keyword + '%'"/>
and order_no like #{kw}
</if>
</where>
order by created_at desc, id desc
limit #{limit} offset #{offset}
</select>如果要做 IN 查询,先在 Service 层限制集合大小和空集合行为。
if (ids == null || ids.isEmpty()) {
return List.of();
}
if (ids.size() > 1000) {
throw new IllegalArgumentException("ids too many");
}
return orderMapper.listByIds(ids);<select id="listByIds" resultMap="OrderListRowMap">
select id, order_no, status, amount, created_at
from biz_order
where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>排序不要直接把前端传入拼进 SQL。${} 是字符串替换,不是参数绑定;它只适合已经白名单化的字段、表名、排序方向。
public enum OrderSort {
CREATED_DESC("created_at desc, id desc"),
AMOUNT_DESC("amount desc, id desc");
private final String clause;
OrderSort(String clause) {
this.clause = clause;
}
public String clause() {
return clause;
}
}order by ${sortClause}验证结果,把日志里的最终 SQL 拿到数据库里跑执行计划。
EXPLAIN
select id, order_no, status, amount, created_at
from biz_order
where tenant_id = 1001
and status = 'PAID'
and created_at >= '2026-07-01 00:00:00'
order by created_at desc, id desc
limit 20 offset 0;常见坑:
if test="name != null"没判断空字符串,导致like '%%'。foreach空集合拼出非法 SQL,或者直接跳过条件查全表。${}拼接用户输入,形成注入风险。- XML 里混入太多业务判断,Service 失去边界。
生产建议:动态 SQL 只处理 SQL 形态,不处理业务决策。是否允许某个状态、是否能查看某个租户、排序字段是否合法,先在 Service 层判断,再把干净参数交给 Mapper。
分页:先稳定,再谈快
场景说明:分页慢有两类。第一类是没排序或排序不稳定,用户翻页看到重复或漏数据;第二类是深分页慢,offset 越大数据库跳过的行越多。
直接做法,普通后台列表先做到稳定排序和最大页码限制。
<select id="pageOrders" resultMap="OrderListRowMap">
select id, order_no, status, amount, created_at
from biz_order
where tenant_id = #{tenantId}
order by created_at desc, id desc
limit #{pageSize} offset #{offset}
</select>Service 层计算分页参数,限制 pageSize 和最大 offset。
int pageSize = Math.min(Math.max(request.pageSize(), 1), 100);
int pageNo = Math.max(request.pageNo(), 1);
int offset = (pageNo - 1) * pageSize;
if (offset > 10000) {
throw new IllegalArgumentException("deep pagination is not allowed");
}如果用了 PageHelper,要记住 startPage 只影响紧跟着的下一次 MyBatis 查询,中间不要夹别的查询。
PageHelper.startPage(pageNo, pageSize);
List<OrderListRow> rows = orderQueryMapper.listForBackoffice(query);
PageInfo<OrderListRow> page = PageInfo.of(rows);PageHelper 之所以能做到“不改 Mapper 方法也分页”,不是魔法,而是 MyBatis 插件链给了它切入点。它在查询进入 Executor 时拿到 MappedStatement、参数和 BoundSql,根据数据库方言生成 count SQL 和分页 SQL,再继续执行原查询链路。Spring Boot 4 项目如果使用 PageHelper,也要选对应的 4.x starter,不要把老的 1.x 配置参数原样搬过来。
深分页改成游标翻页,适合时间线、消息、订单流水这类“向后翻”的场景。
<select id="scrollOrders" resultMap="OrderListRowMap">
select id, order_no, status, amount, created_at
from biz_order
where tenant_id = #{tenantId}
<if test="lastCreatedAt != null and lastId != null">
and (
created_at < #{lastCreatedAt}
or (created_at = #{lastCreatedAt} and id < #{lastId})
)
</if>
order by created_at desc, id desc
limit #{pageSize}
</select>验证结果:同一查询条件下连续翻三页,检查是否重复、漏数据、排序漂移。再用执行计划确认排序字段是否走索引。
EXPLAIN
select id, order_no, status, amount, created_at
from biz_order
where tenant_id = 1001
order by created_at desc, id desc
limit 20 offset 10000;常见坑:
- 只按
created_at排序,时间相同的记录顺序不稳定,要补id。 - PageHelper 自动生成的
countSQL 在复杂联表上很重,列表快但总数慢。 - 后台导出复用分页查询,一页一页深翻,越跑越慢。
生产建议:用户界面分页可以保留 limit offset,但要设上限;导出、对账、任务扫描优先用游标或按主键范围切片,不要用深分页扫全量。
批量写入:分批、短事务、可恢复
场景说明:批量导入最容易把数据库和连接池拖垮。常见错误是文件解析、校验、远程查询、逐条插入全放进一个事务,失败后既慢又难恢复。
直接做法,先在事务外完成解析和基础校验,再分批进入短事务。
public ImportResult importOrders(List<OrderImportCommand> commands) {
List<OrderImportCommand> validCommands = commands.stream()
.filter(OrderImportCommand::basicValid)
.toList();
int batchSize = 500;
for (int from = 0; from < validCommands.size(); from += batchSize) {
int to = Math.min(from + batchSize, validCommands.size());
orderImportTxService.importBatch(validCommands.subList(from, to));
}
return ImportResult.success(validCommands.size());
}@Service
public class OrderImportTxService {
private final OrderWriteMapper orderWriteMapper;
public OrderImportTxService(OrderWriteMapper orderWriteMapper) {
this.orderWriteMapper = orderWriteMapper;
}
@Transactional(rollbackFor = Exception.class)
public void importBatch(List<OrderImportCommand> batch) {
orderWriteMapper.batchInsert(batch);
}
}XML 用 foreach 生成多值插入。批次不要拍脑袋,先从 200、500、1000 做压测,看 SQL 长度、锁等待和复制延迟。
<insert id="batchInsert">
insert into biz_order
(order_no, tenant_id, customer_id, status, amount, created_at)
values
<foreach collection="list" item="item" separator=",">
(#{item.orderNo}, #{item.tenantId}, #{item.customerId},
#{item.status}, #{item.amount}, #{item.createdAt})
</foreach>
</insert>如果你使用 ExecutorType.BATCH,要把 flushStatements() 的失败语义写进代码评审。BatchExecutor 会把多次 update 收集成 JDBC batch,真正发送和拿到影响行数是在 flush 阶段;中间某一批失败时,MyBatis 会抛 BatchExecutorException,异常里能拿到已经成功的 BatchResult 和失败的批次,但这不等于业务已经安全提交。只要外层事务回滚,数据库最终仍应回滚已执行的批次。
Spring 项目里不要在业务代码中随手 sqlSessionFactory.openSession()。如果确实需要 MyBatis batch executor,优先单独配置一个 batch 用的 SqlSessionTemplate,让它仍然走 Spring 事务管理:
@Bean
public SqlSessionTemplate batchSqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory, ExecutorType.BATCH);
}@Service
public class OrderBatchImportService {
private final SqlSessionTemplate batchSqlSessionTemplate;
public OrderBatchImportService(@Qualifier("batchSqlSessionTemplate") SqlSessionTemplate batchSqlSessionTemplate) {
this.batchSqlSessionTemplate = batchSqlSessionTemplate;
}
@Transactional(rollbackFor = Exception.class)
public void importWithBatchExecutor(List<OrderImportCommand> commands) {
OrderWriteMapper mapper = batchSqlSessionTemplate.getMapper(OrderWriteMapper.class);
for (OrderImportCommand command : commands) {
mapper.insertOne(command);
}
List<BatchResult> results = batchSqlSessionTemplate.flushStatements();
recordBatchResult(results);
}
}生产里更推荐让批量导入通过明确的短事务服务分批执行,而不是在普通业务链路里临时切换 ExecutorType.BATCH。如果必须使用它,至少要记录批次号、批次范围、BatchResult、失败 SQL 所属 Mapper、业务幂等键和回滚验证 SQL。
验证结果:每批记录影响行数、失败批次、失败原因要能查到。导入后抽样核对数量和唯一键。
select count(*) from biz_order where import_batch_no = 'B20260709001';
select order_no, count(*)
from biz_order
where import_batch_no = 'B20260709001'
group by order_no
having count(*) > 1;常见坑:
- 批量 SQL 太长,触发数据库包大小限制或网络传输变慢。
- 全量导入放一个事务,锁持有时间过长,线上接口跟着卡。
- 失败后没有幂等键,只能人工清表重跑。
- 全局配置
executor-type=BATCH,影响普通接口的执行语义。 flushStatements()抛异常后只看 Java 异常,不核对数据库是否因事务回滚恢复到导入前状态。
生产建议:大批量任务要有导入批次号、幂等键、进度表、失败明细表和暂停开关。核心在线库不要在业务高峰跑大导入,必要时拆到任务库、临时表或消息分片。
插件机制:适合观测,不适合乱改 SQL
场景说明:MyBatis 插件可以拦截 Executor、StatementHandler、ParameterHandler、ResultSetHandler。它很强,但位置太底层,乱改参数、结果或 SQL 会让问题非常隐蔽。
插件的装配发生在对象创建阶段。Configuration 创建 Executor、StatementHandler、ParameterHandler、ResultSetHandler 时,会把对象交给 InterceptorChain.pluginAll,每个拦截器再用 JDK 动态代理包一层。多个插件同时存在时,外层先执行,顺序问题要在团队规范里写清楚。
插件链的执行和风险边界可以这样画:
排查分页错乱、租户条件丢失、慢 SQL 日志和数据库慢日志不一致时,先把这张链路按实际插件顺序写出来:哪个插件在外层,哪个插件改了 SQL,哪个插件只观测。设计评审时要把“可观测插件”和“改变执行语义的插件”分开审批,后者必须有关闭开关、回归用例和异常时的降级策略。
直接做法,生产里最常见的自定义插件是慢 SQL 观测。先只记录 mappedStatementId、耗时、SQL 摘要、traceId,不改变执行结果。下面这个示例拦截 Executor,优点是能直接拿到 MappedStatement id;如果你只想统计纯 JDBC 执行耗时,再考虑拦截 StatementHandler。
拦截点要先选清楚:
| 拦截点 | 更容易拿到 | 更适合 | 主要风险 |
|---|---|---|---|
Executor | MappedStatement、参数对象、MyBatis 总耗时 | Mapper 级观测、N+1 识别、审计兜底 | 包含缓存、结果映射等耗时,不等于数据库执行耗时 |
StatementHandler | JDBC Statement、最终 SQL、数据库执行阶段 | 统计接近数据库执行的耗时、分页 SQL 改写 | 拿 Mapper id 不如 Executor 直接,改 SQL 风险更高 |
ParameterHandler | 参数绑定过程 | 参数脱敏、绑定异常定位 | 误改参数会造成数据错误 |
ResultSetHandler | 结果映射过程 | 行数统计、映射异常定位 | 误改返回结果会很隐蔽 |
团队规范里要写清插件顺序。分页、租户、数据权限、慢 SQL 观测如果都在同一个链路上,顺序不同会导致观测到的 SQL、实际执行的 SQL 和数据库慢日志对不上。
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
),
@Signature(
type = Executor.class,
method = "update",
args = {MappedStatement.class, Object.class}
)
})
public class SlowSqlInterceptor implements Interceptor {
private static final long SLOW_SQL_MILLIS = 300;
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameterObject = args.length > 1 ? args[1] : null;
BoundSql boundSql = ms.getBoundSql(parameterObject);
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
if (cost >= SLOW_SQL_MILLIS) {
logSlowSql(ms.getId(), cost, boundSql.getSql());
}
}
}
private void logSlowSql(String statementId, long cost, String sql) {
String normalized = sql.replaceAll("\\s+", " ").trim();
// 生产里还要补 traceId、脱敏参数、返回行数和连接池状态。
System.out.println("slowSql mapper=" + statementId + " cost=" + cost + "ms sql=" + normalized);
}
}Spring Boot 项目里把拦截器注册成 Bean,starter 会自动检测 MyBatis 的 Interceptor。
@Configuration
public class MyBatisPluginConfig {
@Bean
public SlowSqlInterceptor slowSqlInterceptor() {
return new SlowSqlInterceptor();
}
}验证结果:写一个故意慢的 SQL 或把阈值临时调低,确认日志能看到耗时和 SQL 摘要。
grep "slowSql" application.log常见坑:
- 插件里打印完整参数,泄漏手机号、身份证、Token 等敏感信息。
- 插件里改写 SQL,导致执行计划和本地调试看到的不一致。
- 多个分页、审计、租户插件顺序不明确,互相包裹后 SQL 不可预期。
startPage后面夹了一次别的查询,分页被应用到错误 SQL 上。- 插件拦截
Executor统计到的是 MyBatis 查询总耗时,拦截StatementHandler才更接近 JDBC 执行耗时。
生产建议:插件优先用于观测、租户兜底和审计,不要承载核心业务规则。插件必须登记拦截点、影响范围、执行顺序、失败表现和关闭开关。
事务边界:让 MyBatis 参加 Spring 事务
场景说明:MyBatis 在 Spring 项目里不应该自己管理事务。业务事务要放在 Service 层,由 Spring 的事务管理器控制;Mapper 只做数据访问。
Spring 集成后的关键对象是 SqlSessionTemplate。它是线程安全的 SqlSession 包装层,真正执行时会从 Spring 事务上下文里拿到当前会话;如果当前没有事务,就按一次调用的边界打开和关闭会话。底层事务对象通常是 SpringManagedTransaction,它把连接提交、回滚和关闭交给 Spring 的事务管理器处理,而不是让 Mapper 自己调用 commit。
调用链可以这样理解:
所以不要在 Spring 托管的 Mapper 里混用手工 openSession()、commit() 和 rollback()。一旦同一条业务链路里既有 SqlSessionTemplate,又有人手工打开会话,连接、一级缓存和提交边界就可能不一致。
直接做法,写操作入口放 @Transactional,并明确回滚规则。
@Service
public class OrderPaymentService {
private final OrderWriteMapper orderWriteMapper;
private final PaymentMapper paymentMapper;
public OrderPaymentService(OrderWriteMapper orderWriteMapper, PaymentMapper paymentMapper) {
this.orderWriteMapper = orderWriteMapper;
this.paymentMapper = paymentMapper;
}
@Transactional(rollbackFor = Exception.class)
public void markPaid(String orderNo, String paymentNo) {
int updated = orderWriteMapper.markPaid(orderNo);
if (updated != 1) {
throw new IllegalStateException("order status changed");
}
paymentMapper.insertPayment(orderNo, paymentNo);
}
}验证结果,写一个集成测试:第二条写入故意失败,第一条更新必须回滚。
@SpringBootTest
class OrderPaymentServiceTest {
@Autowired
OrderPaymentService orderPaymentService;
@Autowired
OrderQueryMapper orderQueryMapper;
@Test
void shouldRollbackWhenPaymentInsertFailed() {
assertThrows(Exception.class, () -> {
orderPaymentService.markPaid("O1001", "DUPLICATE_PAYMENT_NO");
});
assertThat(orderQueryMapper.findStatus("O1001")).isEqualTo("WAIT_PAY");
}
}常见坑:
- 同类内部方法调用,绕过 Spring 代理,事务不生效。
- 捕获异常后不抛出,事务认为方法成功。
- 默认只对运行时异常和
Error回滚,受检异常需要显式rollbackFor。 - 多数据源项目里事务管理器和
SqlSessionFactory使用的不是同一个DataSource。 - 在事务里做远程调用、文件解析、长循环,锁持有时间被拉长。
- Mapper 方法上直接标事务,看似方便,但事务语义会被拆散到数据访问层,Service 编排无法看清一致性边界。
REQUIRES_NEW用在失败日志或审计日志时要特别小心,它会独立提交,主事务回滚后日志仍然存在是设计结果,不是框架异常。
生产建议:事务只包数据库一致性所需的最短代码。外部调用、文件解析、复杂计算放到事务前;消息发送、缓存删除、搜索索引同步放到事务后或可靠事件表里处理。
慢 SQL 和 N+1:从现象追到 Mapper
场景说明:线上慢 SQL 不会告诉你“我是哪个 Mapper 写的”。如果只有数据库慢日志,没有应用 trace、Mapper id 和参数摘要,排障会变成猜谜。
先看一条典型链路:
直接做法,把慢 SQL 的四类信息打通:
- 应用侧:接口路径、traceId、用户或租户、Mapper 方法、耗时、返回行数。
- MyBatis 侧:MappedStatement id、SQL 摘要、参数脱敏摘要。
- 数据库侧:慢日志、执行计划、锁等待、扫描行数。
- 连接池侧:active、idle、pending、timeout、leak 线索。
排查 N+1 时,先看日志里是否同一条 SQL 短时间重复出现很多次。
grep "Preparing:" application.log | sort | uniq -c | sort -nr | head如果一条查询订单的 SQL 后面跟着几十条查询订单明细的 SQL,大概率是循环里逐条查。
错误写法:
List<OrderListRow> orders = orderMapper.listRecentOrders(query);
for (OrderListRow order : orders) {
order.setItems(orderItemMapper.listByOrderId(order.getId()));
}改成批量查询再内存分组:
List<OrderListRow> orders = orderMapper.listRecentOrders(query);
List<Long> orderIds = orders.stream().map(OrderListRow::getId).toList();
Map<Long, List<OrderItemRow>> itemMap = orderItemMapper.listByOrderIds(orderIds)
.stream()
.collect(Collectors.groupingBy(OrderItemRow::getOrderId));
orders.forEach(order -> order.setItems(itemMap.getOrDefault(order.getId(), List.of())));验证结果:
curl "http://localhost:8080/orders/recent"
grep "OrderItemMapper.listByOrderId" application.log
grep "OrderItemMapper.listByOrderIds" application.log改造后应该看不到 N 次 listByOrderId,只看到一次 listByOrderIds。
常见坑:
resultMap里用嵌套select,数据量一大就触发 N+1。- 日志只打印 SQL,不打印 Mapper id,无法回到代码。
- 慢 SQL 只看耗时,不看返回行数、扫描行数和锁等待。
生产建议:核心查询上线前必须留执行计划。出现慢 SQL 时,先定位 Mapper,再验证索引、条件选择性、排序、分页方式和返回行数,不要直接加缓存掩盖问题。
落地工程深水区
SQL 可观测性
生产里最怕“数据库慢了,但应用不知道是哪段业务慢”。每个核心 Mapper 至少要能追踪到接口、traceId、Mapper id、耗时和 SQL 摘要。参数要脱敏,不能把身份证、手机号、Token、密码打进日志。
上线前检查:
核心 SQL 是否有 Mapper id?
慢 SQL 阈值是多少?
SQL 日志是否能按 traceId 关联接口日志?
参数是否脱敏?
连接池 active / pending 是否有指标?建议把慢 SQL 观测拆成三层指标:
应用层:接口、traceId、Mapper id、耗时、返回行数、异常类型。
MyBatis 层:BoundSql 摘要、参数数量、Executor 类型、是否批处理、是否命中分页插件。
数据库层:慢日志 SQL、执行计划、锁等待、扫描行数、连接池等待。如果三层信息缺一层,排障就会变成猜。尤其是批量任务和分页插件,必须把原始 Mapper id 和改写后的 SQL 都能关联起来。
N+1
N+1 不是 MyBatis 专属问题,但 MyBatis 的嵌套查询和循环调用很容易把它藏起来。判断标准很简单:一次接口请求内,相同 Mapper 查询按列表行数重复执行,就是风险。
处理顺序:
- 先批量查子表。
- 再按业务键分组。
- 最后回填到返回对象。
只有列表很小、低频、且有明确上限时,才接受嵌套查询。
批量写入
批量写入要同时看数据库、连接池和事务。批次太小,吞吐低;批次太大,SQL 长、锁久、失败回滚成本高。一般先用 200 到 1000 做压测,不要直接上万。
生产约束:
- 每批必须有批次号。
- 每条业务数据必须有幂等键。
- 失败要能定位到行和原因。
- 任务要能暂停、恢复、重跑。
- 大任务和在线接口要做连接池或时间窗口隔离。
连接池
连接池不是越大越好。后台批量任务、慢查询、长事务会占住连接,导致在线接口排队超时。排查连接池问题时,先看 pending 和 timeout,再看慢 SQL 和事务耗时。
curl "http://localhost:8080/actuator/metrics/hikaricp.connections.active"
curl "http://localhost:8080/actuator/metrics/hikaricp.connections.pending"
curl "http://localhost:8080/actuator/metrics/hikaricp.connections.timeout"生产建议:核心在线接口和低优先级批量任务尽量隔离限流。批量任务至少要有并发数、批次大小和暂停开关。
事务传播
事务传播最容易被误判。REQUIRED 会加入当前事务,REQUIRES_NEW 会挂起当前事务新开一个事务,NESTED 依赖保存点能力。审计日志、失败明细、业务主表更新放错传播级别,会出现“主事务回滚了,日志还在”或“日志也回滚了,失败原因没了”。
建议做法:
- 核心业务写入用一个清晰的
REQUIRED入口。 - 失败日志、导入明细是否独立提交,要在设计里写清。
- 不要在同一个类里通过内部方法调用指望传播行为生效。
- 多数据源要显式指定事务管理器,并验证使用同一个数据源。
数据一致性
MyBatis 不负责业务一致性。重复提交、状态覆盖、批量冲突、缓存失效,都要靠业务设计兜住。
落地模板:
唯一性:数据库唯一索引兜底。
幂等性:请求号、业务单号或导入批次号。
并发控制:状态条件更新或版本号。
失败恢复:失败明细表、可重试状态、补偿任务。
验证方式:并发测试 + 回滚测试 + 数据核对 SQL。排障手册
| 现象 | 优先判断 | 直接排查 | 修复建议 |
|---|---|---|---|
Invalid bound statement | XML 没加载或 namespace/id 不一致 | 检查 mapper-locations、XML 路径、Mapper 全限定名 | 统一 Mapper 包路径和 XML 命名 |
| Mapper Bean 找不到 | 未扫描 Mapper | 检查 @Mapper、@MapperScan、启动日志 | 在启动类配置 @MapperScan |
字段映射为 null | 列名和属性名不匹配 | 检查 resultMap、驼峰配置、SQL 别名 | 复杂查询显式 resultMap |
| 分页重复或漏数据 | 排序不稳定 | 检查 order by 是否有唯一兜底 | 用 created_at desc, id desc |
| 深分页很慢 | 大 offset 扫描多 | 看执行计划和扫描行数 | 限制 offset 或改游标翻页 |
| 批量导入拖慢接口 | 长事务或连接占满 | 看连接池 pending、慢 SQL、锁等待 | 分批、限流、任务隔离 |
| 事务没回滚 | 代理、异常或事务管理器问题 | 写集成测试验证回滚 | Service 层事务、抛出异常、指定事务管理器 |
| SQL 注入风险 | 使用 ${} 拼用户输入 | 搜索 ${ 和排序参数 | 白名单转换后再拼接 |
| N+1 | 循环查子表或嵌套 select | 统计同一 SQL 重复次数 | 批量查、分组回填 |
| 连接池耗尽 | 慢 SQL、长事务、并发过高 | 看 active / pending / timeout | 优化 SQL、缩短事务、限流批量任务 |
命令速查
# 依赖和版本
./mvnw dependency:tree | grep -E "mybatis|mybatis-spring|pagehelper|mysql|hikari"
# 启动并打开条件日志
./mvnw spring-boot:run
# 健康检查和连接池指标
curl "http://localhost:8080/actuator/health"
curl "http://localhost:8080/actuator/metrics/hikaricp.connections.active"
curl "http://localhost:8080/actuator/metrics/hikaricp.connections.pending"
# 查看 Mapper SQL 日志
grep "Preparing:" application.log
grep "Parameters:" application.log
grep "slowSql" application.log
# 检查某个 Mapper XML 是否进入构建产物
jar tf target/*.jar | grep "mapper/.*\\.xml"-- 执行计划
EXPLAIN
select id, order_no, status, created_at
from biz_order
where tenant_id = 1001
order by created_at desc, id desc
limit 20;
-- 批量导入数量核对
select count(*) from biz_order where import_batch_no = 'B20260709001';
-- 幂等键重复检查
select order_no, count(*)
from biz_order
group by order_no
having count(*) > 1;Mapper 落地模板
@Mapper
public interface ExampleQueryMapper {
/**
* 后台列表查询:按租户、状态、时间筛选,按 created_at desc, id desc 稳定分页。
* 上线前需确认 idx_example_tenant_status_created_id 执行计划。
*/
List<ExampleListRow> listForBackoffice(ExampleListQuery query);
Optional<ExampleDetailRow> findDetailForOwner(@Param("id") Long id,
@Param("ownerId") Long ownerId);
List<ExampleListRow> listByIds(@Param("ids") List<Long> ids);
}<mapper namespace="com.example.demo.mapper.ExampleQueryMapper">
<resultMap id="ExampleListRowMap" type="ExampleListRow">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="status" column="status"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<sql id="BaseListColumns">
id, name, status, created_at
</sql>
<select id="listForBackoffice" resultMap="ExampleListRowMap">
select <include refid="BaseListColumns"/>
from example_table
<where>
tenant_id = #{tenantId}
<if test="status != null and status != ''">
and status = #{status}
</if>
<if test="createdFrom != null">
and created_at >= #{createdFrom}
</if>
<if test="createdTo != null">
and created_at < #{createdTo}
</if>
</where>
order by created_at desc, id desc
limit #{limit} offset #{offset}
</select>
</mapper>上线检查清单
- Mapper 方法名是否表达业务语义。
- Mapper XML 是否被打进构建产物,
namespace/id是否和接口方法一致。 - 多参数 Mapper 是否显式使用
@Param,XML 参数名是否有测试覆盖。 - 自定义
TypeHandler、枚举映射和jdbcTypeForNull是否有空值、未知值和兼容性测试。 - 核心 SQL 是否能通过
Configuration#getMappedStatement和BoundSql测试验证。 - 复杂查询是否使用
resultMap并裁剪字段。 - 动态 SQL 是否避免空条件查全表。
${}是否只来自白名单。- 分页是否有稳定排序和最大页数限制。
- 核心 SQL 是否保留执行计划。
- 批量写入是否分批、短事务、可恢复。
BatchExecutor.flushStatements()失败后是否验证事务回滚和失败明细记录。- MyBatis 二级缓存是否默认关闭;如启用,是否写清 namespace、刷新和一致性边界。
- 插件拦截点、执行顺序、关闭开关和脱敏策略是否登记。
- 事务是否在 Service 层,并有回滚测试。
- 多数据源项目中
SqlSessionFactory和事务管理器是否绑定同一个DataSource。 - 连接池 active、pending、timeout 是否可观测。
- 慢 SQL 是否能关联接口、traceId 和 Mapper id。
- N+1 是否通过日志或测试验证过。
- 敏感参数是否脱敏,日志是否不会泄漏凭证和个人信息。
