10-MyBatis执行链与更新一致性

O泡李华 6

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 都会防止覆盖吗?

不会。需要插件、适用的参数与方法形态,以及有效旧版本。本文手写更新和空版本更新都展示了保护边界。

参考资料