MyBatis Mapper 主链:接口调用如何抵达 MappedStatement 与 JDBC
Mapper 接口没有实现类,方法却能执行 SQL。把它解释成“动态代理”只到达入口:方法标识怎样定位 MappedStatement,参数怎样交给执行器,SqlSession 由谁持有,连接为何能参加 Spring 事务,才决定一次调用是否正确。
MyBatis 没有消除 JDBC,它把“statement 标识、SQL 生成、参数设置、执行策略与结果物化”编排成可配置流水线。官方 Configuration 列出 TypeHandler、plugin、environment 与 mapper 的运行时位置,Mapper XML 则定义 MappedStatement、resultMap 和 namespace cache 的真实语义。
一次 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 不一致,会让回滚和复现非常痛苦。
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);事务边界:让 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 用在失败日志或审计日志时要特别小心,它会独立提交,主事务回滚后日志仍然存在是设计结果,不是框架异常。
生产建议:事务只包数据库一致性所需的最短代码。外部调用、文件解析、复杂计算放到事务前;消息发送、缓存删除、搜索索引同步放到事务后或可靠事件表里处理。
用两个状态模型拆掉框架错觉
下面两个 Java 17 程序只保留本篇最关键的状态与分支。它们不连接真实数据库,因此不能证明驱动或数据库的厂商行为;它们用来证明调用方必须维持的不变量,真实集成测试再负责验证 SQL、锁和网络。
javac --release 17 -Xlint:all -Werror examples/backend-development/data-access/mybatis-mapper-chain/MapperInvocationDemo.java examples/backend-development/data-access/mybatis-mapper-chain/SessionOwnershipDemo.java
java -cp examples/backend-development/data-access/mybatis-mapper-chain MapperInvocationDemo
java -cp examples/backend-development/data-access/mybatis-mapper-chain SessionOwnershipDemostatementId=OrderMapper.findById chain=[MapperProxy, MapperMethod, MappedStatement, SqlSession, Executor, StatementHandler, JDBC] rows=1
sameTransactionalSession=true commitOwnedBySpring=true mapperThreadSafe=true输出的价值在于固定中间状态,而不是展示 API 能运行。修改实现后,如果资源没有复位、冲突被误报为成功、缓存跨越了更新边界或调用身份发生变化,模型应先失败,随后真实数据库测试再给出厂商级证据。
从“SQL 慢”退回第一处状态偏差
把正确性做成跨层验收
单元模型验证状态机,数据库集成测试验证约束、隔离、锁和驱动行为,应用集成测试验证 Spring 事务、Mapper/Repository 代理与连接复用,压测验证池、线程与数据库容量的最小瓶颈。故障用例必须包含:连接池耗尽、statement 超时、事务回滚、批量中段失败、并发版本冲突、主从延迟、迁移校验失败和应用关闭时仍有在途事务。
