10-MyBatis执行链与更新一致性
Mapper 背后发生了什么?MyBatis 执行链与更新一致性
Mapper 只有接口,没有手写实现,SQL 却可以执行;同一查询调用两次,又不一定发送两次 SQL。理解代理、会话和执行器之后,分页、乐观锁及逻辑删除才能被放进正确的业务边界。
涉及 Spring 的示例沿用 Java 17、Spring Boot 3.5.3;不同示例项目独立阅读,文件名与依赖在相应代码段说明。
1. 从接口到 SQL 执行链
方法映射、参数绑定与执行器不是一层组件。
Mapper 接口背后是代理对象
扫描 Mapper 时,MyBatis-Spring 为接口注册能够生产代理对象的 Bean。注入到业务类中的 Mapper,并不是接口自己在执行,而是一个 JDK 动态代理。
代理拦截方法调用后,根据接口和方法定位映射语句,再把参数交给 MyBatis。通常,映射语句的标识是“接口全限定名 + 方法名”。XML Mapper 的 namespace 因此需要对应接口名,statement 的 id 对应方法名。不要用 Java 方法重载来表达同名的不同 SQL;它们会争用同一个 statement 标识。
@Select 与 XML 是定义映射语句的两种方式,并不代表两套完全不同的数据库执行引擎。项目初始化时,它们都会形成配置中的 MappedStatement。
一次查询经过哪些对象?
先看主干流程:
业务代码调用 Mapper
→ MapperProxy / MapperMethod
→ Spring 管理的 SqlSessionTemplate
→ 当前 SqlSession 与 Executor
→ MappedStatement / BoundSql
→ StatementHandler 创建 JDBC Statement
→ ParameterHandler 绑定参数
→ JDBC 执行
→ ResultSetHandler 与 TypeHandler 处理结果
→ 返回 Java 对象
MappedStatement 保存一条映射语句的配置,例如 SQL 来源、语句类型和结果映射。BoundSql 表示这次调用生成的 SQL 和参数映射;动态 SQL 会根据本次参数决定最终形态。
Executor 负责执行调度和缓存等逻辑。默认 SIMPLE 执行器的行为,与 REUSE、BATCH 的行为不同。下面的程序使用默认配置,不把其准备语句次数推广到所有执行器。
这些对象的职责可以帮助定位故障:找不到 statement 应检查扫描、namespace 和 id;参数异常应检查名称与类型;结果字段为空应检查列名和映射;慢查询最终仍需检查数据库执行计划。
#{} 如何绑定参数?
假设映射语句是:
select id, title from blog_post where id = #{id}
对于本例,生成的 JDBC SQL 是 select id, title from blog_post where id = ?。参数值随后绑定到占位符,并不是直接拼接进 SQL 文本。
接口写成 find(@Param("id") long id),可以明确 SQL 中 id 对应哪个参数,减少对编译参数名保留配置的依赖。多个参数尤其应该明确命名。
${} 是文本替换。例如动态排序字段不能作为普通值绑定,但也不能直接使用用户提交的字符串拼接。应把允许的排序选项映射到固定 SQL 标识符;排序方向也需要限制。#{} 能保护参数值的绑定,不能自动保证业务授权,也不能替所有 SQL 拼接消除风险。
2. 会话、缓存与事务怎样关联
缓存命中和实际数据库读取需要分别观察。
调用两次 Mapper,不一定执行两次 SQL
一级缓存与 SqlSession 有关,默认作用域是 SESSION。查询条件和相关配置形成缓存键;同一个会话中命中缓存的查询,可以直接返回已有结果。
在本文的默认配置下,同一个 Spring 事务内连续执行两次相同查询,中间没有更新或清理缓存,通常复用事务绑定的会话,因此第二次查询可以命中一级缓存。
缓存命中还意味着两个结果可能指向同一个对象。修改第一次查出的对象,有可能影响随后读取的缓存结果,即使没有执行更新 SQL。业务代码不要把查询对象的内存修改当成持久化结果。
修改语句会影响缓存。本例在“查询 → 更新 → 查询”的过程中,后一次查询重新准备 SQL,从数据库读取新标题。把 localCacheScope 改为 STATEMENT、改变查询参数,或者切换执行路径,都可能改变观察结果。
Spring 中应该如何使用会话和事务?
Spring 集成使用 SqlSessionTemplate 协调会话生命周期。这个模板可以共享;不要因此认为原生 DefaultSqlSession 也适合多个线程共同使用。
事务管理器与 MyBatis 必须使用对应的同一数据源,才能正确协调数据库连接和提交、回滚。业务代码通过 @Transactional 或 TransactionTemplate 声明事务,不要在 Spring 管理的会话上自行调用 commit、rollback 或 close。MyBatis-Spring 事务文档
事务外的两次 Mapper 调用,在本例中各自使用并关闭会话,因此没有跨调用的一级缓存命中。一级缓存不能作为跨请求缓存,更不能替代 Redis。二级缓存还需要 namespace 等配置;本文没有开启 Mapper namespace 的二级缓存。
3. 完整程序观察代理和回滚
保留 Maven、源码和运行说明,避免只展示一行 Mapper。
完整示例:观察代理、准备次数和回滚
新建 Maven 项目,将下面内容保存为 pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version><relativePath/>
</parent>
<groupId>demo</groupId><artifactId>mybatis-flow</artifactId><version>1.0.0</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>3.0.5</version></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
将完整代码保存为 src/main/java/demo/Main.java:
package demo;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.ibatis.annotations.*;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
@SpringBootApplication
@MapperScan(basePackageClasses = Main.class, annotationClass = Mapper.class)
public class Main {
public static void main(String[] args) {
try (var context = SpringApplication.run(Main.class, args)) { }
}
public static class Post {
private Long id;
private String title;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
}
@Mapper
public interface PostMapper {
@Select("select id, title from blog_post where id = #{id}")
Post find(@Param("id") long id);
@Update("update blog_post set title = #{title} where id = #{id}")
int rename(@Param("id") long id, @Param("title") String title);
@Insert("insert into blog_post(id, title) values(#{id}, #{title})")
int insert(@Param("id") long id, @Param("title") String title);
}
@Intercepts(@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class}))
public static class SqlTrace implements Interceptor {
final AtomicInteger prepares = new AtomicInteger();
@Override public Object intercept(Invocation invocation) throws Throwable {
prepares.incrementAndGet();
return invocation.proceed();
}
void reset() { prepares.set(0); }
}
@Bean SqlTrace sqlTrace() { return new SqlTrace(); }
@Bean CommandLineRunner run(JdbcTemplate jdbc, PostMapper mapper,
SqlSessionFactory factory, SqlTrace trace,
PlatformTransactionManager manager) {
return args -> {
jdbc.execute("create table blog_post(id bigint primary key, title varchar(80))");
jdbc.update("insert into blog_post values(1, 'original')");
System.out.println("mapper.proxy=" + Proxy.isProxyClass(mapper.getClass()));
String statement = PostMapper.class.getName() + ".find";
BoundSql sql = factory.getConfiguration().getMappedStatement(statement)
.getBoundSql(Map.of("id", 1L));
System.out.println("statement=" + statement);
System.out.println("binding.sql=" + sql.getSql());
System.out.println("parameter.name=" + sql.getParameterMappings().get(0).getProperty());
TransactionTemplate tx = new TransactionTemplate(manager);
trace.reset();
tx.executeWithoutResult(status -> {
Post first = mapper.find(1);
Post second = mapper.find(1);
System.out.println("same.instance=" + (first == second));
});
System.out.println("within.tx.prepares=" + trace.prepares.get());
trace.reset();
mapper.find(1);
mapper.find(1);
System.out.println("outside.tx.prepares=" + trace.prepares.get());
trace.reset();
tx.executeWithoutResult(status -> {
mapper.find(1);
mapper.rename(1, "changed");
System.out.println("cache.updated.title=" + mapper.find(1).getTitle());
});
System.out.println("cache.invalidated.prepares=" + trace.prepares.get());
try {
tx.executeWithoutResult(status -> {
mapper.insert(3, "will rollback");
throw new IllegalStateException("stop transaction");
});
} catch (IllegalStateException expected) {
if (!"stop transaction".equals(expected.getMessage())) throw expected;
}
System.out.println("rollback.rows=" + jdbc.queryForObject(
"select count(*) from blog_post where id=3", Integer.class));
};
}
}
这里的插件统计 StatementHandler.prepare 调用次数。它不统计 JdbcTemplate 初始化表的 SQL,也不等于所有场景下的数据库网络往返次数。计数器只用于这个顺序执行的观察程序。
安装 JDK 17 和 Maven 后,在项目目录运行:
mvn package
java -jar target/mybatis-flow-1.0.0.jar
去掉 Spring 启动日志,本程序的输出为:
mapper.proxy=true
statement=demo.Main$PostMapper.find
binding.sql=select id, title from blog_post where id = ?
parameter.name=id
same.instance=true
within.tx.prepares=1
outside.tx.prepares=2
cache.updated.title=changed
cache.invalidated.prepares=3
rollback.rows=0
程序自动使用内存 H2 数据库,结束后关闭应用。它验证会话、缓存和事务集成,不能用于证明 MySQL 的执行计划、隔离级别或锁行为。
慢查询与数据异常应该从哪里排查?
先确认是否真的执行 SQL,再确认 SQL 与参数是否符合预期。重复 Mapper 调用可能被缓存拦截;动态条件缺失也可能把精确查询变成全表查询。
随后检查返回列与 Java 属性是否正确映射。复杂连接查询应明确别名,必要时使用 resultMap;不要依赖同名字段碰巧覆盖。分页列表还要留意逐条查询关联数据带来的 N+1 问题。
最后使用数据库工具分析执行计划、索引和等待。MyBatis 帮助组织 SQL 与映射,它不会自动把低效 SQL 变成高效查询。生产日志中的参数也应按敏感程度处理,避免为了排查而完整记录凭据或个人信息。
4. 分页与更新插件仍需要业务规则
排序、版本冲突和逻辑删除条件不能由插件名称代替。
三种功能解决三个不同问题
| 功能 | 主要作用 | 业务仍需处理的事情 |
|---|---|---|
| 分页 | 为分页查询生成对应数据库的分页 SQL,按配置处理总数 | 参数限制、稳定排序、查询成本 |
| 乐观锁 | 更新时检查旧版本,避免符合条件的更新覆盖已变更数据 | 冲突提示、重读与业务校验、受影响行数 |
| 逻辑删除 | 将支持的删除转成标记更新,让生成查询过滤删除标记 | 自定义 SQL、唯一约束、数据保留与清理 |
插件围绕特定 SQL 或方法工作。不要把它们当成对整个数据库操作的无条件保证。
分页为什么必须明确排序?
分页没有确定排序时,数据库没有义务按插入顺序返回记录。如果按非唯一字段排序,相同字段值之间的次序仍可能不确定。例如按发布时间排序,可以再加主键作为排序条件。
示例按唯一主键升序查询第二页,每页两条,得到 id 为 3 和 4 的记录。插件配置 maxLimit=2,请求每页 100 条时,实际分页大小被限制为 2。
应用还应校验页码与大小,约定超出页数时如何处理。overflow=false 不会自动把超出页数的请求跳回第一页。分页插件也不会让所有普通列表查询都自动受到这个大小上限约束。
深分页使用很大的 OFFSET 时,仍可能需要处理大量前面的记录;是否采用按游标翻页,要结合索引、排序和产品交互决定。带连接的复杂 COUNT 查询还应核对优化后的 SQL 与实际统计语义。分页插件文档
乐观锁究竟检查什么?
假设数据库中 id=1 的版本为 0,两个操作分别读到这份旧数据。第一个更新成功后,将数据库版本推进到 1;第二个仍携带旧版本 0,应该更新失败。
其核心条件可以理解为:
update mp_post
set title = ?, version = 1
where id = 1 and version = 0 and deleted = 0
这是示意 SQL,字段顺序和具体生成形态应以实际映射为准。关键是把版本比较放进数据库更新条件,不能先在 Java 中比较,再执行一个不带版本条件的 UPDATE。
使用 @Version 并注册插件后,还要给符合插件处理条件的方法传入有效的旧版本。本例使用 updateById(entity)。更新返回 0 时,应作为未更新处理;原因可能是版本冲突,也可能是记录不存在或已逻辑删除,应结合业务再判断。
插件可能回写传入实体的版本,因此实体中的新版本不是数据库成功提交的证明。以更新结果和事务最终结果为准。对于库存等操作,还要表达数量下限等业务条件;版本字段不能自动补齐这些规则。
逻辑删除后,数据库行还在
@TableLogic(value="0", delval="1") 声明未删除值和已删除值。常见生成 CRUD 会按该配置处理查询和删除;物理记录仍然存在。
这会影响唯一约束。如果 slug 列有唯一约束,即使原文章已逻辑删除,插入相同 slug 仍可能失败。恢复旧记录、释放业务标识还是保留唯一性,是数据模型和业务规则的选择,不能靠删除标记自动解决。
简单把 (slug, deleted) 设为唯一约束,也可能无法保存同一 slug 的多条历史删除记录,因为已删除值都是 1。需要根据数据库能力和保留策略设计唯一性。
手写 SQL 必须明确是否过滤删除标记。本文故意写了一个原始查询和更新,展示它们可以读到、改到已删除行。管理端查询历史记录也可能需要这种能力,但必须有明确入口与权限边界。逻辑删除文档
业务接口应该怎样处理冲突?
编辑接口可以要求提交 id、修改内容和读取时的 version。更新结果为 0 时,向客户端返回能够识别的冲突结果,提示重新获取数据后编辑。不能忽略返回值并统一报告成功。
自动重试需要重新读取数据、重新验证条件,并明确哪些修改可以合并。对于支付、扣减等操作,还需要幂等和事务设计;重复执行整个业务方法可能造成额外副作用。
逻辑删除同样需要结合关联数据决定行为。删除标记不会自动清理文件、撤销权限或解除关联。查询历史、恢复记录和物理清理应作为明确的业务流程设计。
5. 设计判断与适用边界
数据访问层首先保证参数安全和范围正确。值使用绑定参数,动态排序等结构由白名单选择;即使来自后台界面,也不把任意 SQL 片段当作可信输入。
乐观锁失败是竞争结果,业务层需要说明刷新、重试或拒绝的策略。逻辑删除保留数据,因此唯一约束、查询范围和恢复功能都要结合实际结构设计。插件简化代码,但没有消除这些选择。
6. 问答
问:Mapper 是接口,为什么能执行?
注入的是代理。代理解析方法与参数,找到 MappedStatement,再通过会话和执行器完成 JDBC 调用与结果映射。
问:#{} 和 ${} 有什么区别?
前者通常形成参数占位符,由 JDBC 绑定值;后者替换 SQL 文本。动态标识符应采用固定选项映射,不能直接接受用户输入。
问:引入 MyBatis-Plus 就能分页了吗?
还需要注册分页插件,使用符合条件的分页调用,并配置数据库类型。本文显式引入分页解析模块,且将分页拦截器放在链末尾。
问:加上 @Version,所有 UPDATE 都会防止覆盖吗?
不会。需要插件、适用的参数与方法形态,以及有效旧版本。本文手写更新和空版本更新都展示了保护边界。