Java 基础体系 · 第 22/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

MyBatis 完整基础:Mapper、动态 SQL、缓存、事务和性能边界

MyBatis 是一个以 SQL 为中心的数据访问框架。它负责把 Java 方法调用转换为 JDBC 执行,把查询结果映射为 Java 对象;SQL 的连接、事务、索引、锁、隔离级别和执行计划仍然由数据库与 JDBC 共同决定。

因此,理解 MyBatis 不能只记住几个 XML 标签。一次查询至少经过以下路径:

sequenceDiagram
    participant App as 应用代码
    participant Mapper as Mapper 接口
    participant Session as SqlSession
    participant Executor as Executor
    participant JDBC as JDBC Driver
    participant DB as 数据库

    App->>Mapper: mapper.findById(1)
    Mapper->>Session: 调用映射语句
    Session->>Executor: 创建 BoundSql 与 CacheKey
    Executor->>Executor: 检查一级缓存
    Executor->>JDBC: PreparedStatement
    JDBC->>DB: 执行 SQL
    DB-->>JDBC: ResultSet
    JDBC-->>Executor: 行数据
    Executor-->>Session: 结果对象
    Session-->>Mapper: User
    Mapper-->>App: User

后文会分别说明这些组件的职责、状态变化和边界。


一、先建立正确的模型:MyBatis 到底管理什么

1. Mapper 是接口映射,不是数据库访问对象本身

Mapper 通常是一个没有实现类的 Java 接口:

public interface UserMapper {
    User findById(long id);

    List<User> search(String name, Integer status);

    int updateStatus(long id, int status);
}

MyBatis 启动时读取 XML 或注解,把每个方法注册成一个映射语句。运行时,MyBatis 为接口创建代理对象:

UserMapper mapper = session.getMapper(UserMapper.class);
User user = mapper.findById(1L);

调用 mapper.findById(1L) 时,代理不会执行接口中的 Java 方法体,而是根据以下信息找到映射语句:

  • Mapper 接口的全限定名;
  • 方法名;
  • 参数对象;
  • 返回类型;
  • XML 或注解中的 SQL;
  • 参数映射和结果映射。

因此,下面的 XML namespace 必须与接口全限定名一致:

<mapper namespace="com.example.user.UserMapper">
    <select id="findById"
            parameterType="long"
            resultType="com.example.user.User">
        SELECT id, name, status
        FROM users
        WHERE id = #{id}
    </select>
</mapper>

这里的完整语句标识通常是:

com.example.user.UserMapper.findById

如果 namespaceid 或 XML 资源路径错误,常见失败表现是启动时出现“找不到绑定语句”,或者调用时抛出 BindingException

2. #{} 是参数绑定,${} 是文本替换

这两个语法的安全含义完全不同。

WHERE id = #{id}

通常会生成:

WHERE id = ?

然后通过 PreparedStatement#setObject 绑定参数。假设传入字符串:

' OR 1 = 1 --

它会作为一个普通字符串值传给数据库,而不是改变 SQL 结构。

相反:

ORDER BY ${column}

会直接把文本拼到 SQL 中:

ORDER BY name

${} 不是参数绑定,不能防止 SQL 注入。它只有在“SQL 结构必须动态变化”时才有用途,例如列名、表名、排序方向;这时必须使用白名单:

private static final Map<String, String> SORT_COLUMNS = Map.of(
        "name", "name",
        "createdAt", "created_at"
);

String safeColumn = SORT_COLUMNS.get(sortKey);
if (safeColumn == null) {
    throw new IllegalArgumentException("unsupported sort key");
}

然后只把 safeColumn 交给 ${column}。不能把用户输入未经验证地直接传入。

一个常见错误是试图这样写:

ORDER BY #{column}

这会被当作:

ORDER BY ?

多数数据库不会把绑定参数解释为列名,因此不能实现动态排序。

3. 参数对象决定参数名

单个简单参数可以使用:

WHERE id = #{id}

但对于方法:

User find(long id, int status);

参数名是否保留取决于编译参数名配置和 MyBatis 的参数解析方式。为了避免依赖隐含名称,应显式使用 @Param

User find(@Param("id") long id,
          @Param("status") int status);

对应 XML:

WHERE id = #{id}
  AND status = #{status}

多个参数没有 @Param 时,MyBatis 常见可用名称是 param1param2,但这会降低可读性,也容易因重构产生错误。


二、一个可运行的最小示例

下面的示例使用 Java 25 编写普通 Java 应用;MyBatis 本身不要求使用 Java 25 特有语法。运行时需要:

  • MyBatis;
  • 一个 JDBC 驱动,例如 H2;
  • mybatis-config.xml
  • Mapper 接口和 XML;
  • 数据库初始化 SQL。

1. 数据表

CREATE TABLE users (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    status INT NOT NULL,
    created_at TIMESTAMP NOT NULL
);

INSERT INTO users(name, status, created_at)
VALUES
    ('Alice', 1, CURRENT_TIMESTAMP),
    ('Bob', 0, CURRENT_TIMESTAMP);

2. Java 实体

package com.example.user;

import java.time.LocalDateTime;

public class User {
    private Long id;
    private String name;
    private Integer status;
    private LocalDateTime createdAt;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }

    @Override
    public String toString() {
        return "User{id=" + id +
                ", name='" + name + '\'' +
                ", status=" + status +
                ", createdAt=" + createdAt + '}';
    }
}

3. Mapper 接口

package com.example.user;

import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface UserMapper {
    User findById(@Param("id") long id);

    List<User> search(@Param("name") String name,
                      @Param("status") Integer status);

    int updateStatus(@Param("id") long id,
                     @Param("status") int status);
}

4. Mapper XML

<?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.user.UserMapper">

    <resultMap id="userMap"
               type="com.example.user.User">
        <id property="id" column="id"/>
        <result property="name" column="name"/>
        <result property="status" column="status"/>
        <result property="createdAt" column="created_at"/>
    </resultMap>

    <select id="findById"
            resultMap="userMap">
        SELECT id, name, status, created_at
        FROM users
        WHERE id = #{id}
    </select>

    <select id="search"
            resultMap="userMap">
        SELECT id, name, status, created_at
        FROM users
        <where>
            <if test="name != null and name != ''">
                AND name LIKE CONCAT('%', #{name}, '%')
            </if>
            <if test="status != null">
                AND status = #{status}
            </if>
        </where>
        ORDER BY id
    </select>

    <update id="updateStatus">
        UPDATE users
        SET status = #{status}
        WHERE id = #{id}
    </update>
</mapper>

resultMap 显式描述数据库列到 Java 属性的映射。若启用了下划线转驼峰,也可以让 created_at 自动映射到 createdAt,但显式 resultMap 更容易发现列名、类型和别名错误。

5. MyBatis 配置

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-config.dtd">

<configuration>
    <settings>
        <setting name="mapUnderscoreToCamelCase" value="true"/>
        <setting name="cacheEnabled" value="true"/>
        <setting name="localCacheScope" value="SESSION"/>
    </settings>

    <typeAliases>
        <typeAlias alias="User" type="com.example.user.User"/>
    </typeAliases>

    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="org.h2.Driver"/>
                <property name="url"
                          value="jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"/>
                <property name="username" value="sa"/>
                <property name="password" value=""/>
            </dataSource>
        </environment>
    </environments>

    <mappers>
        <mapper resource="mapper/UserMapper.xml"/>
    </mappers>
</configuration>

6. Java 启动代码

try (InputStream input =
         Resources.getResourceAsStream("mybatis-config.xml");
     SqlSessionFactory factory =
         new SqlSessionFactoryBuilder().build(input);
     SqlSession session = factory.openSession(false)) {

    UserMapper mapper = session.getMapper(UserMapper.class);

    User user = mapper.findById(1L);
    System.out.println(user);

    List<User> activeUsers = mapper.search(null, 1);
    System.out.println(activeUsers);

    int affected = mapper.updateStatus(1L, 0);
    if (affected != 1) {
        session.rollback();
        throw new IllegalStateException("unexpected affected rows: " + affected);
    }

    session.commit();
} catch (Exception e) {
    // try-with-resources 会关闭 session;
    // 事务型代码仍应在可控范围内显式 rollback。
    throw new RuntimeException(e);
}

openSession(false) 表示关闭自动提交。事务流程是:

  1. openSession(false) 获取一个非自动提交的数据库连接;
  2. 查询和更新在同一个 SqlSession 的连接上执行;
  3. 所有更新成功后调用 commit()
  4. 任意异常时调用 rollback()
  5. 最后关闭 SqlSession,释放 JDBC 资源。

更严格的写法是:

try (SqlSession session = factory.openSession(false)) {
    try {
        UserMapper mapper = session.getMapper(UserMapper.class);
        mapper.updateStatus(1L, 1);
        session.commit();
    } catch (RuntimeException | Error e) {
        session.rollback();
        throw e;
    }
}

三、结果映射:从 ResultSet 到 Java 对象

MyBatis 的结果映射不是简单的“按列顺序赋值”。它至少涉及:

  • 列名或列别名;
  • Java 属性名;
  • Java 类型;
  • 类型处理器;
  • 空值处理;
  • 嵌套对象或集合;
  • 一对一、一对多查询策略。

1. 别名映射

SELECT
    id,
    created_at AS createdAt
FROM users

如果 Java 属性是 createdAt,SQL 别名可以直接消除命名差异。

2. 类型处理器

JDBC 返回的是 JDBC 类型,例如 VARCHARINTEGERTIMESTAMP。MyBatis 通过 TypeHandler 在 JDBC 值和 Java 值之间转换。常见的:

  • StringTypeHandler
  • IntegerTypeHandler
  • LongTypeHandler
  • LocalDateTime 对应的时间类型处理器;
  • 枚举类型处理器。

数据库列为 NULL 时,Java 包装类型如 Integer 可以表达 null;基本类型 int 不能表达数据库空值,映射时容易产生语义错误。因此表示可空数据库字段时通常使用包装类型。

3. 一对多映射的乘法效应

假设查询用户和订单:

SELECT u.id AS user_id,
       u.name,
       o.id AS order_id,
       o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = ?

一个用户有 3 个订单,数据库返回 3 行。MyBatis 必须通过 <id> 标识去重父对象,否则可能创建 3 个看似相同的用户对象。

<resultMap id="userWithOrdersMap"
           type="com.example.user.User">
    <id property="id" column="user_id"/>
    <result property="name" column="name"/>

    <collection property="orders"
                ofType="com.example.order.Order">
        <id property="id" column="order_id"/>
        <result property="amount" column="amount"/>
    </collection>
</resultMap>

这里的真实代价不只是对象数量。连接结果的行数大致为:

R=uUmax(1,orders(u))R = \sum_{u \in U} \max(1, |orders(u)|)

其中 U 是用户集合,R 是连接后行数。若同时连接多个一对多集合,行数可能发生笛卡尔式膨胀。例如用户有 10 个订单和 5 个标签,同时连接后可能得到约 50 行,虽然逻辑上只有 15 个子对象。

这就是“单条 SQL”不一定比多条 SQL 更快的边界。必须查看结果行数、网络传输量和内存消耗。


四、动态 SQL:让 SQL 结构根据参数变化

动态 SQL 的目标不是“把 SQL 拼得更灵活”,而是在保持参数绑定的前提下,有条件地生成 SQL 片段。

1. <if><where>

直接这样写容易产生错误:

SELECT *
FROM users
WHERE
<if test="name != null">
    name = #{name}
</if>
<if test="status != null">
    AND status = #{status}
</if>

name == nullstatus != null 时,结果可能是:

WHERE AND status = ?

<where> 会在至少有内容时添加 WHERE,并删除开头多余的 ANDOR

<where>
    <if test="name != null and name != ''">
        AND name LIKE CONCAT('%', #{name}, '%')
    </if>
    <if test="status != null">
        AND status = #{status}
    </if>
</where>

四种输入的结构分别是:

name status 生成的条件
null null WHERE
"A" null WHERE name LIKE ?
null 1 WHERE status = ?
"A" 1 WHERE name LIKE ? AND status = ?

“无过滤条件”是否允许全表查询,必须由业务层另行决定。<where> 只负责生成合法 SQL,不负责防止昂贵查询。

2. <set> 用于动态更新

<update id="updateUser">
    UPDATE users
    <set>
        <if test="name != null">
            name = #{name},
        </if>
        <if test="status != null">
            status = #{status},
        </if>
    </set>
    WHERE id = #{id}
</update>

<set> 会添加 SET 并删除最后一个多余逗号。

但它有一个业务边界:如果 namestatus 都为 null,最终可能得到空的 SET。所以应在 Java 层拒绝空更新,或者使用明确的 SQL 语义:

if (command.name() == null && command.status() == null) {
    throw new IllegalArgumentException("nothing to update");
}

3. <choose> 表示互斥分支

<choose>
    <when test="status != null">
        AND status = #{status}
    </when>
    <when test="name != null and name != ''">
        AND name LIKE CONCAT('%', #{name}, '%')
    </when>
    <otherwise>
        AND status = 1
    </otherwise>
</choose>

与多个 <if> 不同,<choose> 只选择第一个成立的分支。它适用于“优先按某条件过滤,否则使用默认条件”的场景。

4. <foreach> 生成 IN

<select id="findByIds" resultMap="userMap">
    SELECT id, name, status, created_at
    FROM users
    <where>
        <if test="ids != null and ids.size() > 0">
            id IN
            <foreach collection="ids"
                     item="id"
                     open="("
                     separator=","
                     close=")">
                #{id}
            </foreach>
        </if>
    </where>
</select>

传入 [2, 5, 8] 后,参数化 SQL 类似:

WHERE id IN (?, ?, ?)

空集合不能简单生成 IN ()。不同数据库对此处理不同,有的直接语法错误。因此应在调用前决定空集合语义:

  • 空集合代表不查询,直接返回空列表;
  • 空集合代表忽略此过滤条件;
  • 空集合是非法输入。

不能让 <foreach> 替业务层作这个决定。

另外,IN 参数过多会遇到:

  • 数据库参数数量限制;
  • SQL 文本过长;
  • 优化器难以估算选择性;
  • 执行计划不稳定。

大量 ID 通常应使用临时表、批量导入表、数组参数或分批查询,具体取决于数据库能力。

5. <trim> 和可复用 SQL

<sql id="userColumns">
    id, name, status, created_at
</sql>

<select id="findById" resultMap="userMap">
    SELECT
    <include refid="userColumns"/>
    FROM users
    WHERE id = #{id}
</select>

<sql> 只是文本片段复用,不是数据库视图,也不会自动保证不同查询需要的列集合一致。过度抽取一个“万能列片段”可能导致列表查询读取了不需要的大字段,增加 I/O 和网络开销。


五、插入、主键和批处理

1. 自增主键

<insert id="insert"
        parameterType="com.example.user.User"
        useGeneratedKeys="true"
        keyProperty="id"
        keyColumn="id">
    INSERT INTO users(name, status, created_at)
    VALUES (#{name}, #{status}, #{createdAt})
</insert>

成功后,驱动和数据库支持的前提下,user.id 会被回填。

这里存在多个边界:

  • 数据库必须支持返回生成键;
  • JDBC 驱动必须正确实现 getGeneratedKeys
  • keyProperty 必须对应 Java 属性;
  • 批量插入时生成键回填行为依赖驱动和数据库实现,不能只根据 MyBatis XML 推断。

如果数据库使用序列,可以显式获取序列值:

<selectKey keyProperty="id"
           resultType="long"
           order="BEFORE">
    SELECT nextval('users_id_seq')
</selectKey>

order="BEFORE" 表示先执行取号,再执行插入。序列取号和插入仍应处于同一个事务中,否则可能出现取到号码但插入失败的空洞;空洞通常是序列的正常特性,不代表数据错误。

2. JDBC 批处理与 MyBatis ExecutorType.BATCH

try (SqlSession session =
         factory.openSession(ExecutorType.BATCH, false)) {
    try {
        UserMapper mapper = session.getMapper(UserMapper.class);

        for (User user : users) {
            mapper.insert(user);
        }

        List<BatchResult> results = session.flushStatements();
        session.commit();
    } catch (RuntimeException | Error e) {
        session.rollback();
        throw e;
    }
}

调用 Mapper 方法时,BATCH 执行器通常不会立即把每条语句都发送到数据库,而是先缓存相同或兼容的批处理语句,flushStatements() 时交给 JDBC 批量执行。

失败路径必须注意:

  1. mapper.insert 调用成功,不代表数据库已经成功写入;
  2. flushStatements 可能才暴露约束错误;
  3. commit 仍可能因连接、锁或数据库故障失败;
  4. 事务失败后必须回滚,不能只捕获循环中的异常。

批处理并不总是更快。批量过大时会增加:

  • 客户端内存;
  • 数据库锁持有时间;
  • 单次失败的回滚范围;
  • 事务日志峰值;
  • 主从复制延迟。

合理批大小必须通过实际数据库、驱动和数据量测试确定,不能套用一个固定数字。


六、事务:MyBatis 的提交边界不是数据库业务边界

1. JDBC 事务的基本状态

JDBC 连接通常有 autoCommit 属性:

connection.setAutoCommit(false);

当自动提交关闭时,一组 SQL 处于同一个事务中:

BEGIN
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT

如果第二条失败:

BEGIN
  第一条 UPDATE 成功
  第二条 UPDATE 失败
ROLLBACK

回滚的关键不是 Java 方法是否正常返回,而是数据库连接是否仍在同一个事务中。

2. MyBatis SqlSession 与事务

在纯 MyBatis 中,SqlSession 通常持有一个执行上下文和连接。下面两个 session 默认不是同一个事务:

try (SqlSession s1 = factory.openSession(false);
     SqlSession s2 = factory.openSession(false)) {
    // s1 与 s2 通常使用不同连接,也不共享事务
}

如果一个业务操作先用 s1 更新,再用 s2 查询,s2 可能看不到 s1 尚未提交的数据。这不是 MyBatis 缓存问题,而是事务连接不同。

事务边界应覆盖完整业务动作:

try (SqlSession session = factory.openSession(false)) {
    try {
        AccountMapper mapper = session.getMapper(AccountMapper.class);
        int from = mapper.debit(1L, 100);
        if (from != 1) {
            throw new IllegalStateException("source account unavailable");
        }

        int to = mapper.credit(2L, 100);
        if (to != 1) {
            throw new IllegalStateException("target account unavailable");
        }

        session.commit();
    } catch (RuntimeException | Error e) {
        session.rollback();
        throw e;
    }
}

3. Spring 环境中的事务代理

在 Spring 中,常见模型是:

@Transactional
public void transfer(long from, long to, BigDecimal amount) {
    accountMapper.debit(from, amount);
    accountMapper.credit(to, amount);
}

事务通常由 Spring 代理在方法进入和退出时控制:

  1. 代理开启或加入事务;
  2. SqlSessionTemplate 获取与当前事务绑定的 SqlSession;
  3. 多个 Mapper 调用复用同一事务资源;
  4. 方法正常返回时提交;
  5. 抛出符合回滚规则的异常时回滚。

因此,Mapper 方法一般不应自行调用 commit()rollback()。如果绕过 Spring,手动创建另一个 SqlSession,就可能把操作放到事务之外。

还要注意代理边界。例如同一个类中的方法自调用:

public void outer() {
    inner(); // 可能绕过代理
}

@Transactional
public void inner() {
}

如果 outer() 通过 this.inner() 调用 inner(),常见 Spring 代理模式下事务注解可能不会生效。是否生效取决于代理类型和调用路径,不能仅看注解文本。

4. 隔离级别决定并发可见性

数据库隔离级别讨论多个事务并发时的可见性。常见异常可以形式化为:

  • 脏读:事务 B 读到了事务 A 尚未提交的数据;
  • 不可重复读:事务 B 两次读取同一行,期间事务 A 提交了修改,导致结果不同;
  • 幻读:事务 B 两次按条件查询,期间事务 A 插入或删除了满足条件的行,导致结果集边界变化。

READ_COMMITTED 通常要求只能读取已提交数据,但具体快照与锁行为由数据库实现决定。REPEATABLE_READSERIALIZABLE 也不能被 MyBatis 改写成统一语义。

事务隔离不是“越高越好”。提高隔离通常会增加锁竞争、等待或冲突重试。需要先明确业务不变量:

库存不能小于 0

仅执行:

SELECT stock FROM products WHERE id = ?;
-- Java 中判断 stock > 0
UPDATE products SET stock = stock - 1 WHERE id = ?;

在并发下可能发生:

  1. 事务 A 读到 1;
  2. 事务 B 也读到 1;
  3. A 更新为 0;
  4. B 更新为 0;
  5. 两次销售都返回成功,但只扣减了一次。

更直接的原子写法是:

UPDATE products
SET stock = stock - 1
WHERE id = ?
  AND stock > 0

然后检查受影响行数:

int affected = mapper.decreaseStock(productId);
if (affected != 1) {
    throw new OutOfStockException();
}

这里数据库的条件更新和受影响行数共同构成并发控制。MyBatis 只是执行这条 SQL。


七、缓存:减少重复查询,不等于自动保持业务一致性

MyBatis 有两层常见缓存。

1. 一级缓存:SqlSession 级别

默认情况下,一级缓存作用域通常是 SESSION。在同一个 SqlSession 中:

User a = mapper.findById(1L);
User b = mapper.findById(1L);

第二次查询可能直接返回缓存结果,不再访问数据库。

但以下情况会使结果失效或不再命中:

  • 使用了不同的 SqlSession
  • 查询参数不同;
  • SQL 语句标识不同;
  • 中间执行了更新、插入或删除;
  • localCacheScope=STATEMENT
  • 查询结果无法安全复用。

默认一级缓存的关键风险是:同一 session 内可能继续看到旧对象。更严重的是,查询对象通常是可变 Java 对象:

User user = mapper.findById(1L);
user.setName("local change");

User again = mapper.findById(1L);

again 可能就是同一个缓存对象,包含尚未写入数据库的本地修改。一级缓存不是实体状态管理器,也不会自动检测对象变化并生成 UPDATE。

localCacheScope=STATEMENT 会把本地缓存范围缩小到单条语句执行期间,适合希望减少 session 内陈旧对象影响的场景,但会牺牲同一 session 内重复查询的命中机会。

2. 二级缓存:Mapper namespace 级别

在 Mapper XML 中声明:

<cache/>

表示该 namespace 可以使用二级缓存。它通常跨越多个 SqlSession,但不是全局统一缓存;不同 namespace 的缓存默认彼此独立。

二级缓存的典型流程是:

SqlSession A 查询
    -> 一级缓存未命中
    -> 二级缓存未命中
    -> 数据库查询
    -> 事务提交时,结果进入 namespace 缓存

SqlSession B 查询同一语句
    -> 一级缓存未命中
    -> 二级缓存命中

二级缓存并不是查询后立即无条件写入。MyBatis 的事务缓存实现通常会把变更延迟到事务提交阶段,以避免回滚事务把未提交结果暴露给其他 session。

更新语句默认会刷新相关缓存:

<update id="updateStatus" flushCache="true">
    ...
</update>

查询语句可以通过 useCache 控制是否使用二级缓存:

<select id="findById" useCache="false">
    ...
</select>

3. 缓存失效的真实边界

假设有两个 namespace:

UserMapper.findById
OrderMapper.findByUserId

事务通过 OrderMapper 修改订单,但用户查询的缓存可能不会自动失效,除非这些语句共享缓存、显式刷新,或者应用采用了更高层的缓存失效策略。

因此,下列说法都不可靠:

  • “执行了一个 UPDATE,所有相关查询缓存都会自动清空”;
  • “缓存配置打开后就能保证跨表一致性”;
  • “二级缓存等同于业务缓存”;
  • “不同进程中的 MyBatis 本地缓存天然一致”。

在多实例部署中,进程内缓存无法自动同步。即使使用了可共享的缓存实现,也必须定义更新通知、失效顺序、事务提交时机和故障恢复。

4. 缓存什么时候不适合

以下数据通常不适合直接启用长生命周期缓存:

  • 频繁变化的库存、余额、权限;
  • 强一致性要求的状态;
  • 查询条件组合很多、命中率低的数据;
  • 结果中包含当前时间、随机值或数据库会话相关值;
  • 同一数据会被多个系统修改的场景。

缓存价值可以粗略理解为:

收益命中率×单次数据库成本缓存维护成本失效错误成本收益 \approx 命中率 \times 单次数据库成本 - 缓存维护成本 - 失效错误成本

其中“失效错误成本”不是普通性能损失,而可能是错误授权、重复操作或资金状态错误。因此缓存的正确性优先级高于命中率。


八、动态 SQL、缓存和事务如何相互作用

MyBatis 的缓存键并不只由“业务参数”组成。查询的语句标识、最终 SQL、参数值、分页边界等都会影响缓存键。动态 SQL 使最终 SQL 可能不同:

status = 1

和:

name LIKE ?

属于不同的 SQL 形状,通常不会互相命中。

事务又会影响缓存可见性:

事务 A:
  UPDATE users SET status = 0 WHERE id = 1
  未提交

事务 B:
  查询 id = 1

事务 B 能否看到修改,首先由数据库隔离级别和连接决定;如果 B 命中 MyBatis 缓存,甚至可能根本不访问数据库。此时即使数据库已经允许读取最新提交值,缓存也可能返回旧值。

所以排查“读到旧数据”时,应按以下顺序确认:

  1. 是否复用了同一个 SqlSession
  2. 是否命中一级缓存;
  3. 是否启用了二级缓存;
  4. 查询与更新是否属于同一 namespace;
  5. 更新是否真正提交;
  6. 是否存在其他进程或其他服务修改数据;
  7. 数据库隔离级别和读写路由是否改变了可见性。

不要一开始就把所有问题归因于数据库主从延迟或事务隔离。


九、分页:MyBatis 只执行分页 SQL,不负责分页语义

最可靠的分页方式是显式写出数据库分页语法。例如某些数据库支持:

SELECT id, name, status, created_at
FROM users
WHERE status = ?
ORDER BY id
LIMIT ? OFFSET ?

分页必须有稳定排序。没有 ORDER BY 时,数据库不保证返回顺序;即使当前执行计划看起来顺序稳定,索引变化或数据量变化也可能使页面之间重复或漏行。

OFFSET 分页的成本通常随偏移量增加。数据库可能需要扫描或跳过前面的 OFFSET 行,再返回后面的数据。游标分页可以利用索引:

SELECT id, name, status, created_at
FROM users
WHERE status = ?
  AND id > ?
ORDER BY id
LIMIT ?

第一次请求:

status = 1, lastId = 0, limit = 20

返回最后一条记录的 id=120 后,下一页使用:

status = 1, lastId = 120, limit = 20

这个方案的前提是排序键具有稳定的严格顺序。若按 created_at 排序且可能相同,应使用复合游标:

WHERE (created_at, id) > (?, ?)
ORDER BY created_at, id
LIMIT ?

同时应建立匹配访问模式的索引,例如:

CREATE INDEX idx_users_status_id
ON users(status, id);

索引是否有效必须使用数据库的执行计划工具验证,而不是根据 SQL 外观猜测。


十、性能边界:MyBatis 不会替你修复慢 SQL

MyBatis 的主要工作是映射和执行。以下问题通常不由 MyBatis 自动解决:

  • 缺失索引;
  • 低选择性条件;
  • 全表扫描;
  • 错误连接顺序;
  • 过多返回列;
  • 锁竞争;
  • 数据库统计信息过期;
  • 连接池耗尽;
  • 主从延迟;
  • 网络传输过大;
  • N+1 查询。

1. N+1 查询

以下代码可能产生 N+1 次数据库访问:

List<User> users = userMapper.findAll();

for (User user : users) {
    List<Order> orders = orderMapper.findByUserId(user.getId());
}

当用户数为 N 时:

1 次查询用户 + N 次查询订单 = N + 1 次查询

即使每次查询只需 1 毫秒,网络往返和连接调度也会累积;实际耗时还可能受到锁和数据库排队影响。

可选方案包括:

  • 一次 JOIN 查询并使用嵌套结果映射;
  • 先收集用户 ID,再使用一个 IN 查询订单;
  • 使用分批 IN
  • 明确只查询需要的关联数据。

选择哪一种取决于结果集膨胀、对象结构和数据库执行计划,不能机械地认为 JOIN 永远优于多次查询。

2. 返回列必须匹配用例

不应为了方便长期使用:

SELECT *
FROM users

原因包括:

  • 表新增大字段后查询成本隐式增加;
  • 列顺序和列集合变化影响调试;
  • 网络传输和对象构造增加;
  • 索引覆盖查询更难成立。

列表查询应只取所需列:

SELECT id, name, status
FROM users
WHERE status = #{status}
ORDER BY id

3. PreparedStatement 的收益与限制

#{} 使用参数绑定,通常可以:

  • 防止值进入 SQL 结构;
  • 让数据库或驱动复用部分解析结果;
  • 统一类型转换;
  • 避免手工转义错误。

但参数化不会自动使查询变快。若条件列没有合适索引,参数化查询仍然可能全表扫描;如果 SQL 形状变化过多,执行计划复用效果也可能有限。

4. 流式读取与游标

处理大量结果时,直接返回 List 会把结果全部加载到内存。MyBatis 可以通过 Cursor<T>ResultHandler 逐步处理,但资源生命周期必须覆盖整个消费过程:

try (SqlSession session = factory.openSession();
     Cursor<User> cursor = session.getMapper(UserMapper.class)
                                  .scanAll()) {
    for (User user : cursor) {
        process(user);
    }
}

不能在 Mapper 方法返回后立刻关闭 session,再异步消费 cursor。cursor 通常依赖底层 ResultSet、Statement 和 Connection;提前关闭会出现“结果集已关闭”或驱动相关异常。

流式读取也不等于数据库完全不占资源。数据库仍可能持续持有游标和连接,因此应控制处理时间、事务范围和异常关闭路径。


十一、批量、分页、连接池和资源泄漏的边界

1. 连接池不是事务管理器

连接池负责复用连接,不负责决定业务事务何时提交。一个连接从池中取出后,归还前必须处于干净状态:

  • 自动提交模式被正确恢复;
  • 隔离级别没有被意外改变;
  • 未提交事务已提交或回滚;
  • Statement、ResultSet 已关闭;
  • 会话级变量没有污染后续请求。

成熟连接池通常会重置部分连接状态,但不能假设所有数据库会话状态都自动恢复。

2. 连接泄漏的故障路径

一种典型泄漏路径是:

请求进入
  -> 获取连接
  -> 执行慢查询
  -> 代码抛异常
  -> 未关闭 SqlSession/ResultSet
  -> 连接未归还池
  -> 并发请求继续获取连接
  -> 连接池耗尽
  -> 后续请求超时

诊断时应区分:

  • 获取连接超时;
  • SQL 执行超时;
  • 事务提交超时;
  • 数据库锁等待;
  • 连接泄漏;
  • 数据库本身连接数达到上限。

常见验证手段包括:

  • 连接池活动连接数、空闲连接数;
  • 获取连接等待时间;
  • 泄漏检测日志;
  • 数据库活动会话;
  • MyBatis SQL 日志;
  • 数据库锁等待和执行计划;
  • JVM 线程栈中等待连接池的线程。

日志参数值必须避免泄露密码、令牌和敏感个人数据。开发环境打开完整 SQL 日志有助于调试,生产环境应使用结构化、脱敏和采样日志。


十二、错误处理:受影响行数不是装饰信息

更新和删除方法通常返回 int,表示数据库报告的受影响行数:

int affected = userMapper.updateStatus(id, expectedStatus, newStatus);

可以利用它实现条件更新:

UPDATE users
SET status = #{newStatus}
WHERE id = #{id}
  AND status = #{expectedStatus}

如果 affected == 0,可能意味着:

  • 记录不存在;
  • 当前状态不符合预期;
  • 被其他事务抢先修改;
  • 数据库驱动对受影响行数的报告方式不同。

因此要结合数据库语义判断,不能无条件把 0 行更新当作系统异常,也不能无条件忽略。

插入、更新、删除失败时,常见异常包括:

  • 唯一键冲突;
  • 外键约束失败;
  • 非空约束失败;
  • 数据类型转换失败;
  • SQL 语法错误;
  • 连接断开;
  • 锁等待超时;
  • 事务提交失败。

底层异常应保留原始原因,业务层可以转换为更明确的异常,但不能只抛出“操作失败”而丢弃 SQL 状态、约束名称和原始堆栈。


十三、常见误解与反例

误解一:Mapper 方法返回了,就代表事务提交了

错误。方法返回只代表 SQL 执行调用没有抛出异常。非自动提交模式下,仍必须 commit();事务提交失败也可能发生在最后一步。

误解二:MyBatis 是 ORM,会自动跟踪对象变化

MyBatis 通常不会像 JPA 的持久化上下文那样,自动检测实体属性变化并生成 UPDATE。查询出的对象被修改后,必须显式调用更新 Mapper:

User user = mapper.findById(id);
user.setStatus(1);

// 不会因为 setStatus 自动写库
mapper.updateStatus(id, 1);

这也是 MyBatis 与 Jakarta Persistence 这类实体持久化规范的重要差异:MyBatis 更接近“SQL 映射工具”,对象生命周期和写入时机需要应用明确控制。

误解三:一级缓存保证同一数据库数据最新

错误。一级缓存保证的是同一 session 内的重复查询可能复用结果,不保证其他事务提交后的变化立即可见。事务、缓存和数据库隔离级别必须一起分析。

误解四:动态 SQL 越灵活越好

动态 SQL 增加了 SQL 形状数量,也增加测试组合。两个可选条件有最多:

22=42^2 = 4

种条件组合;十个独立可选条件理论上有:

210=10242^{10} = 1024

种组合。不是每种都需要手写测试,但必须覆盖空条件、单条件、组合条件、边界值和非法输入。复杂到难以阅读时,应考虑拆分查询方法或使用明确的查询对象。

误解五:开启批处理后每条 insert 都已经成功

错误。批处理通常延迟发送或执行。错误可能在 flushStatements()commit() 阶段才出现,调用方必须围绕完整批次处理回滚和重试。

误解六:只要使用 #{},SQL 就一定安全

#{} 只能保护参数值。若使用 ${}、字符串拼接、动态表名或动态排序,就必须单独验证 SQL 结构。安全边界始终是“哪些部分来自用户输入”。


十四、如何定位一次慢查询

应把一次 MyBatis 查询拆成多个时间段:

获取连接
  -> 动态 SQL 生成
  -> PreparedStatement 创建
  -> 数据库执行
  -> ResultSet 网络传输
  -> MyBatis 对象映射
  -> 业务处理

如果总耗时为:

T=Tpool+Tprepare+Tdb+Tnetwork+Tmap+TappT = T_{pool} + T_{prepare} + T_{db} + T_{network} + T_{map} + T_{app}

其中:

  • T_pool:从连接池取得连接的等待时间;
  • T_prepare:创建或准备语句的时间;
  • T_db:数据库执行和锁等待时间;
  • T_network:结果集传输时间;
  • T_map:MyBatis 映射对象的时间;
  • T_app:业务代码处理时间。

不同问题要用不同证据:

  • T_pool 高:查看连接池耗尽、连接泄漏和并发峰值;
  • T_db 高:查看执行计划、索引、锁等待、扫描行数;
  • T_network 高:减少返回列和结果行,检查压缩与网络;
  • T_map 高:检查嵌套映射、重复对象和过大的结果集;
  • T_app 高:检查循环中的 N+1 查询和同步外部调用。

只看 MyBatis 打印的“SQL 总耗时”通常无法区分这些原因。


十五、何时使用 MyBatis,何时需要更高层抽象

MyBatis 适合以下场景:

  • SQL 结构复杂且需要精确控制;
  • 查询与数据库特性紧密相关;
  • 需要明确控制列、连接、批处理和分页;
  • 团队具备 SQL、索引和事务分析能力;
  • 读模型与写模型需要不同 SQL。

它不会自动提供以下能力:

  • 实体变更跟踪;
  • 统一对象级缓存;
  • 自动关联加载;
  • 自动生成所有 CRUD;
  • 跨服务事务;
  • 自动修复索引和执行计划;
  • 自动保证多实例缓存一致性。

如果使用 Spring Data 或 Jakarta Persistence,需要额外理解 Repository、持久化上下文、延迟加载、脏检查、分页和审计等语义。不能把 JPA 的“实体状态”经验直接套到 MyBatis,也不能把 MyBatis 的“每次显式执行 SQL”经验直接套到 JPA。

一个清晰的分层通常是:

Controller/API
    -> Application Service:事务边界、业务不变量
        -> Mapper:SQL 与结果映射
            -> JDBC:连接、PreparedStatement、ResultSet
                -> Database:锁、隔离、索引、执行计划、日志

每一层都只能对自己的职责负责。Mapper 可以保证参数绑定和映射正确,但不能保证 SQL 有索引;Service 可以定义事务边界,但不能替数据库实现隔离;数据库可以执行原子条件更新,但不能理解 Java 对象是否过期。


十六、实践检查顺序

遇到一个新的 MyBatis 查询,可以按以下顺序验证:

  1. SQL 结构:是否明确列名、条件、排序和分页?
  2. 参数安全:值是否全部使用 #{}${} 是否经过白名单?
  3. 参数命名:多参数是否使用 @Param 或明确参数对象?
  4. 结果映射:列、Java 属性、可空类型和嵌套集合是否匹配?
  5. 事务边界:完整业务动作是否使用同一事务资源?
  6. 并发不变量:是否需要条件更新、锁或更高隔离级别?
  7. 缓存语义:数据是否允许陈旧?更新是否能覆盖所有相关缓存?
  8. 查询规模:是否存在 N+1、深分页、过大 IN 或结果集膨胀?
  9. 数据库证据:索引、执行计划、扫描行数、锁等待是否符合预期?
  10. 资源生命周期:session、connection、statement、result set 和 cursor 是否在所有异常路径关闭?

MyBatis 的性能上限,最终受数据库访问模式约束;MyBatis 的一致性上限,最终受事务、缓存失效和并发控制约束。掌握 Mapper、动态 SQL、缓存和事务的真正边界,才能知道哪些问题应在 XML 中解决,哪些应在 Java 业务层解决,哪些必须回到 JDBC 或数据库本身。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。