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
如果 namespace、id 或 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 常见可用名称是 param1、param2,但这会降低可读性,也容易因重构产生错误。
二、一个可运行的最小示例
下面的示例使用 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) 表示关闭自动提交。事务流程是:
openSession(false)获取一个非自动提交的数据库连接;- 查询和更新在同一个
SqlSession的连接上执行; - 所有更新成功后调用
commit(); - 任意异常时调用
rollback(); - 最后关闭
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 类型,例如 VARCHAR、INTEGER、TIMESTAMP。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>
这里的真实代价不只是对象数量。连接结果的行数大致为:
其中 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 == null 且 status != null 时,结果可能是:
WHERE AND status = ?
<where> 会在至少有内容时添加 WHERE,并删除开头多余的 AND 或 OR:
<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 并删除最后一个多余逗号。
但它有一个业务边界:如果 name 和 status 都为 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 批量执行。
失败路径必须注意:
mapper.insert调用成功,不代表数据库已经成功写入;flushStatements可能才暴露约束错误;commit仍可能因连接、锁或数据库故障失败;- 事务失败后必须回滚,不能只捕获循环中的异常。
批处理并不总是更快。批量过大时会增加:
- 客户端内存;
- 数据库锁持有时间;
- 单次失败的回滚范围;
- 事务日志峰值;
- 主从复制延迟。
合理批大小必须通过实际数据库、驱动和数据量测试确定,不能套用一个固定数字。
六、事务: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 代理在方法进入和退出时控制:
- 代理开启或加入事务;
SqlSessionTemplate获取与当前事务绑定的 SqlSession;- 多个 Mapper 调用复用同一事务资源;
- 方法正常返回时提交;
- 抛出符合回滚规则的异常时回滚。
因此,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_READ、SERIALIZABLE 也不能被 MyBatis 改写成统一语义。
事务隔离不是“越高越好”。提高隔离通常会增加锁竞争、等待或冲突重试。需要先明确业务不变量:
库存不能小于 0
仅执行:
SELECT stock FROM products WHERE id = ?;
-- Java 中判断 stock > 0
UPDATE products SET stock = stock - 1 WHERE id = ?;
在并发下可能发生:
- 事务 A 读到 1;
- 事务 B 也读到 1;
- A 更新为 0;
- B 更新为 0;
- 两次销售都返回成功,但只扣减了一次。
更直接的原子写法是:
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. 缓存什么时候不适合
以下数据通常不适合直接启用长生命周期缓存:
- 频繁变化的库存、余额、权限;
- 强一致性要求的状态;
- 查询条件组合很多、命中率低的数据;
- 结果中包含当前时间、随机值或数据库会话相关值;
- 同一数据会被多个系统修改的场景。
缓存价值可以粗略理解为:
其中“失效错误成本”不是普通性能损失,而可能是错误授权、重复操作或资金状态错误。因此缓存的正确性优先级高于命中率。
八、动态 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 缓存,甚至可能根本不访问数据库。此时即使数据库已经允许读取最新提交值,缓存也可能返回旧值。
所以排查“读到旧数据”时,应按以下顺序确认:
- 是否复用了同一个
SqlSession; - 是否命中一级缓存;
- 是否启用了二级缓存;
- 查询与更新是否属于同一 namespace;
- 更新是否真正提交;
- 是否存在其他进程或其他服务修改数据;
- 数据库隔离级别和读写路由是否改变了可见性。
不要一开始就把所有问题归因于数据库主从延迟或事务隔离。
九、分页: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 形状数量,也增加测试组合。两个可选条件有最多:
种条件组合;十个独立可选条件理论上有:
种组合。不是每种都需要手写测试,但必须覆盖空条件、单条件、组合条件、边界值和非法输入。复杂到难以阅读时,应考虑拆分查询方法或使用明确的查询对象。
误解五:开启批处理后每条 insert 都已经成功
错误。批处理通常延迟发送或执行。错误可能在 flushStatements() 或 commit() 阶段才出现,调用方必须围绕完整批次处理回滚和重试。
误解六:只要使用 #{},SQL 就一定安全
#{} 只能保护参数值。若使用 ${}、字符串拼接、动态表名或动态排序,就必须单独验证 SQL 结构。安全边界始终是“哪些部分来自用户输入”。
十四、如何定位一次慢查询
应把一次 MyBatis 查询拆成多个时间段:
获取连接
-> 动态 SQL 生成
-> PreparedStatement 创建
-> 数据库执行
-> ResultSet 网络传输
-> MyBatis 对象映射
-> 业务处理
如果总耗时为:
其中:
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 查询,可以按以下顺序验证:
- SQL 结构:是否明确列名、条件、排序和分页?
- 参数安全:值是否全部使用
#{}?${}是否经过白名单? - 参数命名:多参数是否使用
@Param或明确参数对象? - 结果映射:列、Java 属性、可空类型和嵌套集合是否匹配?
- 事务边界:完整业务动作是否使用同一事务资源?
- 并发不变量:是否需要条件更新、锁或更高隔离级别?
- 缓存语义:数据是否允许陈旧?更新是否能覆盖所有相关缓存?
- 查询规模:是否存在 N+1、深分页、过大
IN或结果集膨胀? - 数据库证据:索引、执行计划、扫描行数、锁等待是否符合预期?
- 资源生命周期:session、connection、statement、result set 和 cursor 是否在所有异常路径关闭?
MyBatis 的性能上限,最终受数据库访问模式约束;MyBatis 的一致性上限,最终受事务、缓存失效和并发控制约束。掌握 Mapper、动态 SQL、缓存和事务的真正边界,才能知道哪些问题应在 XML 中解决,哪些应在 Java 业务层解决,哪些必须回到 JDBC 或数据库本身。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:JDBC 与事务:连接池、PreparedStatement、隔离、批处理和泄漏
- 下一篇:JPA 与 Hibernate:实体状态、关联、查询、事务和 N+1 诊断
- 延伸:Spring Data 数据访问:Repository、事务、分页、审计和缓存边界
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论