MyBatis Executor、缓存与插件:执行策略怎样改变数据语义
同一 Mapper 方法在一个会话里不再查库、插件顺序改变后分页 SQL 重复包裹、BATCH 模式直到 flush 才暴露约束错误——这些不是外围细节,而是 Executor 和代理链改变了执行时机与可见性。
Executor、缓存和结果映射的边界
场景说明:有些查询在一个事务里重复执行但结果没变,有些更新后同一个会话里读到了旧值,有些团队打开二级缓存后出现权限或状态错乱。这些问题不是 SQL 写法能解释的,要看 Executor 和缓存作用域。
SqlSession 调用 selectList 后,会从 Configuration 拿到 MappedStatement,再交给 Executor。常见执行器有三类:
| 执行器 | 适合场景 | 风险 |
|---|---|---|
SIMPLE | 默认选择,每次创建新的 PreparedStatement | 普通接口最稳 |
REUSE | 复用 PreparedStatement | 收益依赖驱动和连接生命周期 |
BATCH | 批量写入 | 需要显式处理 flush、异常和事务边界 |
一级缓存默认绑定在 SqlSession 上。在 Spring 事务中,同一个事务通常会复用同一个会话,所以同一个 statement、同一组参数可能命中本地缓存。可以通过配置把本地缓存收窄到语句级别:
mybatis:
configuration:
local-cache-scope: statement二级缓存绑定在 Mapper namespace 上,需要 XML 里显式配置 <cache> 或共享 <cache-ref>,并受 useCache、flushCache 影响。它看起来省数据库,但在权限敏感、状态频繁变化、跨服务写入、缓存失效链路不完整的系统里,很容易把旧数据包装成“框架返回”。
缓存命中不是只看 SQL 字符串。MyBatis 会把 MappedStatement、分页边界、最终 SQL、参数值、环境等信息组合成 CacheKey。所以同一段 XML,只要动态 SQL、分页、参数或环境变化,缓存 key 就会不同。排查时不要只问“SQL 一样吗”,要问“BoundSql 和参数是否完全一样”。
二级缓存还会经过 CachingExecutor 和 TransactionalCacheManager。事务内查询结果先进入 TransactionalCache 的暂存区,只有事务提交后才真正写入 namespace cache;事务回滚时暂存结果会被丢弃。这个语义很重要:如果你看到事务未提交前别的会话读不到二级缓存结果,这是设计;如果绕过 Spring 事务或手工提交混用,就可能把刷新边界搞乱。
可以用一张图把缓存路径记住:
验证结果,写一个事务内重复查询测试,观察同一条 SQL 是否被重复打印:
@Transactional
public void printTwice(Long id) {
orderQueryMapper.findDetail(id);
orderQueryMapper.findDetail(id);
}如果同一个事务内第二次没有再打印 SQL,先判断是不是一级缓存命中;如果你需要每次都查库,再评估 local-cache-scope: statement 或拆开事务边界。
常见坑:
在一个长事务里先查后改再查,以为第二次一定来自数据库。为了性能打开二级缓存,却没有定义清楚哪些写操作会刷新缓存。Mapper 返回可变对象,二级缓存 read-only 配置不当时,调用方修改对象会污染后续读取。
BATCH 执行器和普通查询混用,flush 时机不清楚,异常定位困难。
生产建议:大多数业务系统默认不开 MyBatis 二级缓存,把缓存设计放到业务缓存层统一治理。一级缓存要知道它存在,长事务和强一致读取场景要通过测试验证行为。
插件机制:适合观测,不适合乱改 SQL
场景说明:MyBatis 插件可以拦截 Executor、StatementHandler、ParameterHandler、ResultSetHandler。它很强,但位置太底层,乱改参数、结果或 SQL 会让问题非常隐蔽。
插件的装配发生在对象创建阶段。Configuration 创建 Executor、StatementHandler、ParameterHandler、ResultSetHandler 时,会把对象交给 InterceptorChain.pluginAll,每个拦截器再用 JDK 动态代理包一层。多个插件同时存在时,外层先执行,顺序问题要在团队规范里写清楚。
插件链的执行和风险边界可以这样画:
排查分页错乱、租户条件丢失、慢 SQL 日志和数据库慢日志不一致时,先把这张链路按实际插件顺序写出来:哪个插件在外层,哪个插件改了 SQL,哪个插件只观测。设计评审时要把“可观测插件”和“改变执行语义的插件”分开审批,后者必须有关闭开关、回归用例和异常时的降级策略。
直接做法,生产里最常见的自定义插件是慢 SQL 观测。先只记录 mappedStatementId、耗时、SQL 摘要、traceId,不改变执行结果。下面这个示例拦截 Executor,优点是能直接拿到 MappedStatement id;如果你只想统计纯 JDBC 执行耗时,再考虑拦截 StatementHandler。
拦截点要先选清楚:
| 拦截点 | 更容易拿到 | 更适合 | 主要风险 |
|---|---|---|---|
Executor | MappedStatement、参数对象、MyBatis 总耗时 | Mapper 级观测、N+1 识别、审计兜底 | 包含缓存、结果映射等耗时,不等于数据库执行耗时 |
StatementHandler | JDBC Statement、最终 SQL、数据库执行阶段 | 统计接近数据库执行的耗时、分页 SQL 改写 | 拿 Mapper id 不如 Executor 直接,改 SQL 风险更高 |
ParameterHandler | 参数绑定过程 | 参数脱敏、绑定异常定位 | 误改参数会造成数据错误 |
ResultSetHandler | 结果映射过程 | 行数统计、映射异常定位 | 误改返回结果会很隐蔽 |
团队规范里要写清插件顺序。分页、租户、数据权限、慢 SQL 观测如果都在同一个链路上,顺序不同会导致观测到的 SQL、实际执行的 SQL 和数据库慢日志对不上。
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
),
@Signature(
type = Executor.class,
method = "update",
args = {MappedStatement.class, Object.class}
)
})
public class SlowSqlInterceptor implements Interceptor {
private static final long SLOW_SQL_MILLIS = 300;
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameterObject = args.length > 1 ? args[1] : null;
BoundSql boundSql = ms.getBoundSql(parameterObject);
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
if (cost >= SLOW_SQL_MILLIS) {
logSlowSql(ms.getId(), cost, boundSql.getSql());
}
}
}
private void logSlowSql(String statementId, long cost, String sql) {
String normalized = sql.replaceAll("\\s+", " ").trim();
// 生产里还要补 traceId、脱敏参数、返回行数和连接池状态。
System.out.println("slowSql mapper=" + statementId + " cost=" + cost + "ms sql=" + normalized);
}
}Spring Boot 项目里把拦截器注册成 Bean,starter 会自动检测 MyBatis 的 Interceptor。
@Configuration
public class MyBatisPluginConfig {
@Bean
public SlowSqlInterceptor slowSqlInterceptor() {
return new SlowSqlInterceptor();
}
}验证结果:写一个故意慢的 SQL 或把阈值临时调低,确认日志能看到耗时和 SQL 摘要。
grep "slowSql" application.log常见坑:
插件里打印完整参数,泄漏手机号、身份证、Token 等敏感信息。插件里改写 SQL,导致执行计划和本地调试看到的不一致。多个分页、审计、租户插件顺序不明确,互相包裹后 SQL 不可预期。
startPage 后面夹了一次别的查询,分页被应用到错误 SQL 上。插件拦截 Executor 统计到的是 MyBatis 查询总耗时,拦截 StatementHandler 才更接近 JDBC 执行耗时。
生产建议:插件优先用于观测、租户兜底和审计,不要承载核心业务规则。插件必须登记拦截点、影响范围、执行顺序、失败表现和关闭开关。
慢 SQL 和 N+1:从现象追到 Mapper
场景说明:线上慢 SQL 不会告诉你“我是哪个 Mapper 写的”。如果只有数据库慢日志,没有应用 trace、Mapper id 和参数摘要,排障会变成猜谜。
先看一条典型链路:
直接做法,把慢 SQL 的四类信息打通:
应用侧:接口路径、traceId、用户或租户、Mapper 方法、耗时、返回行数。MyBatis 侧:MappedStatement id、SQL 摘要、参数脱敏摘要。数据库侧:慢日志、执行计划、锁等待、扫描行数。
连接池侧:active、idle、pending、timeout、leak 线索。
排查 N+1 时,先看日志里是否同一条 SQL 短时间重复出现很多次。
grep "Preparing:" application.log | sort | uniq -c | sort -nr | head如果一条查询订单的 SQL 后面跟着几十条查询订单明细的 SQL,大概率是循环里逐条查。
错误写法:
List<OrderListRow> orders = orderMapper.listRecentOrders(query);
for (OrderListRow order : orders) {
order.setItems(orderItemMapper.listByOrderId(order.getId()));
}改成批量查询再内存分组:
List<OrderListRow> orders = orderMapper.listRecentOrders(query);
List<Long> orderIds = orders.stream().map(OrderListRow::getId).toList();
Map<Long, List<OrderItemRow>> itemMap = orderItemMapper.listByOrderIds(orderIds)
.stream()
.collect(Collectors.groupingBy(OrderItemRow::getOrderId));
orders.forEach(order -> order.setItems(itemMap.getOrDefault(order.getId(), List.of())));验证结果:
curl "https://api.example.test/orders/recent"
grep "OrderItemMapper.listByOrderId" application.log
grep "OrderItemMapper.listByOrderIds" application.log改造后应该看不到 N 次 listByOrderId,只看到一次 listByOrderIds。
常见坑:
resultMap 里用嵌套 select,数据量一大就触发 N+1。日志只打印 SQL,不打印 Mapper id,无法回到代码。慢 SQL 只看耗时,不看返回行数、扫描行数和锁等待。
生产建议:核心查询上线前必须留执行计划。出现慢 SQL 时,先定位 Mapper,再验证索引、条件选择性、排序、分页方式和返回行数,不要直接加缓存掩盖问题。
用两个状态模型拆掉框架错觉
下面两个 Java 17 程序只保留本篇最关键的状态与分支。它们不连接真实数据库,因此不能证明驱动或数据库的厂商行为;它们用来证明调用方必须维持的不变量,真实集成测试再负责验证 SQL、锁和网络。
javac --release 17 -Xlint:all -Werror examples/backend-development/data-access/mybatis-executor-cache-plugin/ExecutorCacheDemo.java examples/backend-development/data-access/mybatis-executor-cache-plugin/PluginOrderDemo.java
java -cp examples/backend-development/data-access/mybatis-executor-cache-plugin ExecutorCacheDemo
java -cp examples/backend-development/data-access/mybatis-executor-cache-plugin PluginOrderDemofirstLevelHit=true updateFlushesLocal=true cacheSize=0
configured=[tenant, pagination, metrics] invocation=[metrics.before, pagination.before, tenant.before, target, tenant.after, pagination.after, metrics.after] sqlRewrittenOnce=true输出的价值在于固定中间状态,而不是展示 API 能运行。修改实现后,如果资源没有复位、冲突被误报为成功、缓存跨越了更新边界或调用身份发生变化,模型应先失败,随后真实数据库测试再给出厂商级证据。
