MyBatis 进阶:插件开发与缓存机制

在 Java 持久层框架中,MyBatis 以其轻量级、灵活性和强大的 SQL 控制能力,成为众多项目的首选。本文将深入探讨 MyBatis 的两大进阶特性——插件(Interceptor)机制缓存(Cache)机制,帮助你从"会用 MyBatis"迈向"精通 MyBatis"。

一、MyBatis 插件机制:拦截器的奥秘

1.1 什么是 MyBatis 插件

MyBatis 插件本质上是一个拦截器(Interceptor),它利用了 JDK 动态代理和责任链模式,允许我们在 SQL 执行的特定节点插入自定义逻辑,实现功能的横向扩展。

可拦截的四大对象:

拦截对象说明常见用途
Executor执行器,负责 SQL 执行性能监控、分页插件
ParameterHandler参数处理器参数加密、敏感字段脱敏
ResultSetHandler结果集处理器结果脱敏、数据解密
StatementHandlerSQL 语句处理器SQL 改写、打印慢 SQL

1.2 插件开发三步走

第一步:实现 Interceptor 接口

@Intercepts({
    @Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
    )
})
public class PerformanceMonitorPlugin implements Interceptor {
    
    private long slowQueryThreshold; // 慢查询阈值(毫秒)
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        long startTime = System.currentTimeMillis();
        
        try {
            // 执行原方法
            return invocation.proceed();
        } finally {
            long elapsed = System.currentTimeMillis() - startTime;
            
            if (elapsed > slowQueryThreshold) {
                MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
                Object parameter = invocation.getArgs()[1];
                
                System.out.printf(
                    "[SLOW SQL] %s | 耗时: %dms | SQL: %s%n",
                    ms.getId(),
                    elapsed,
                    getSql(ms, parameter)
                );
            }
        }
    }
    
    @Override
    public Object plugin(Object target) {
        // 使用 Plugin.wrap 创建代理对象
        return Plugin.wrap(target, this);
    }
    
    @Override
    public void setProperties(Properties properties) {
        this.slowQueryThreshold = Long.parseLong(
            properties.getProperty("slowQueryThreshold", "1000")
        );
    }
    
    // 辅助方法:解析实际 SQL
    private String getSql(MappedStatement ms, Object parameter) {
        BoundSql boundSql = ms.getBoundSql(parameter);
        return boundSql.getSql();
    }
}

第二步:注册插件

<!-- mybatis-config.xml -->
<plugins>
    <plugin interceptor="com.example.plugin.PerformanceMonitorPlugin">
        <property name="slowQueryThreshold" value="500"/>
    </plugin>
</plugins>

或使用 Spring Boot 配置:

@Configuration
public class MyBatisConfig {
    
    @Bean
    public PerformanceMonitorPlugin performanceMonitorPlugin() {
        PerformanceMonitorPlugin plugin = new PerformanceMonitorPlugin();
        Properties props = new Properties();
        props.setProperty("slowQueryThreshold", "500");
        plugin.setProperties(props);
        return plugin;
    }
}

第三步:配置拦截器链(可选)

多个插件按配置顺序形成拦截器链,层层嵌套。

1.3 实战案例:分页插件原理

PageHelper 是 MyBatis 最著名的插件之一,其核心原理就是拦截 Executor.query() 方法,在执行查询前:

  1. 拦截 SQL:获取原始 SQL
  2. 计算 count:执行 SELECT COUNT(*) 获取总数
  3. 改写 SQL:添加 LIMIT offset, pageSize
  4. 执行分页查询:返回分页结果
// 简化版分页插件核心逻辑
@Override
public Object intercept(Invocation invocation) throws Throwable {
    // 1. 获取分页参数
    PageInfo pageInfo = PageContextHolder.getPageInfo();
    
    if (pageInfo != null) {
        // 2. 改写 SQL 为 COUNT 查询
        String countSql = "SELECT COUNT(*) FROM (" + originalSql + ") tmp";
        long total = executeCount(countSql);
        
        // 3. 添加 LIMIT 子句
        String pageSql = originalSql + " LIMIT " + 
                        pageInfo.getOffset() + ", " + pageInfo.getPageSize();
        
        // 4. 执行分页查询
        List result = executeQuery(pageSql);
        
        // 5. 封装分页结果
        return new PageResult<>(result, total, pageInfo);
    }
    
    return invocation.proceed();
}

1.4 插件开发最佳实践

  1. 精确拦截:尽量缩小 @Signature 的拦截范围,避免性能损耗
  2. 异常处理:确保插件逻辑异常不会中断正常 SQL 执行
  3. 线程安全:避免在插件中使用非线程安全的共享变量
  4. 配置外部化:将阈值、开关等配置参数化,便于动态调整

二、MyBatis 缓存机制:一级缓存与二级缓存

MyBatis 提供了两级缓存体系,用于减少数据库访问次数,提升查询性能。

2.1 一级缓存(Local Cache)

特点:

  • 默认开启,无法关闭(但可以清空)
  • Session 级别:每个 SqlSession 拥有自己的缓存
  • 生命周期:与 SqlSession 相同,session 关闭即清空

工作原理:

// 一级缓存命中场景
SqlSession session = sqlSessionFactory.openSession();

// 第一次查询:访问数据库
User user1 = session.selectOne("getUserById", 1);

// 第二次查询:命中缓存,不访问数据库
User user2 = session.selectOne("getUserById", 1);

System.out.println(user1 == user2); // true,同一对象

session.close(); // 缓存清空

缓存失效条件:

操作效果
执行 INSERT/UPDATE/DELETE自动清空当前 session 缓存
调用 session.clearCache()手动清空缓存
session.close()缓存销毁
flushCache="true"强制刷新缓存

2.2 二级缓存(Second Level Cache)

特点:

  • 默认关闭,需要显式配置
  • Mapper 级别:同一个 namespace 下的 statement 共享缓存
  • 跨 Session:多个 SqlSession 可共享缓存数据
  • 序列化要求:缓存对象必须实现 Serializable

配置步骤:

Step 1: 开启二级缓存全局配置

<!-- mybatis-config.xml -->
<settings>
    <setting name="cacheEnabled" value="true"/>
</settings>

Step 2: Mapper XML 中声明缓存

<!-- UserMapper.xml -->
<cache 
    eviction="LRU"
    flushInterval="60000"
    size="512"
    readOnly="false"
    blocking="true"
/>

<!-- 或使用自定义缓存实现 -->
<cache type="com.example.cache.RedisCache"/>

缓存策略对比:

策略说明适用场景
LRU最近最少使用默认策略,适合大多数场景
FIFO先进先出数据访问具有时间局部性
SOFT软引用内存紧张时自动回收
WEAK弱引用更激进的内存回收策略

Step 3: POJO 实现 Serializable

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    private String username;
    // ... getters/setters
}

Step 4: 指定 statement 使用缓存

<select id="getUserById" resultType="User" useCache="true">
    SELECT * FROM user WHERE id = #{id}
</select>

<!-- 禁用某个查询的缓存 -->
<select id="getLatestData" resultType="Data" useCache="false">
    SELECT * FROM data ORDER BY create_time DESC LIMIT 10
</select>

2.3 缓存执行流程图解

┌─────────────────┐
│   SqlSession 1  │
│  ┌───────────┐  │
│  │ 一级缓存  │  │
│  └─────┬─────┘  │
└────────┼────────┘
         │ ① 查询
         ▼
    ┌─────────┐ ② 未命中
    │ 二级缓存 │
    └────┬────┘
         │ ③ 未命中
         ▼
    ┌─────────┐
    │ 数据库  │
    └─────────┘
         │ ④ 返回结果
         ▼ ⑤ 回填缓存
    [一级 ← 二级 ← DB]

2.4 自定义缓存:集成 Redis

生产环境通常使用 Redis 作为二级缓存,实现集群共享:

public class RedisCache implements Cache {
    
    private final String id;
    private static JedisPool jedisPool;
    
    public RedisCache(String id) {
        this.id = id;
    }
    
    @Override
    public String getId() {
        return id;
    }
    
    @Override
    public void putObject(Object key, Object value) {
        try (Jedis jedis = jedisPool.getResource()) {
            String cacheKey = id + ":" + key.hashCode();
            jedis.setex(cacheKey.getBytes(), 3600, serialize(value));
        }
    }
    
    @Override
    public Object getObject(Object key) {
        try (Jedis jedis = jedisPool.getResource()) {
            String cacheKey = id + ":" + key.hashCode();
            byte[] data = jedis.get(cacheKey.getBytes());
            return data != null ? deserialize(data) : null;
        }
    }
    
    @Override
    public Object removeObject(Object key) {
        Object old = getObject(key);
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.del((id + ":" + key.hashCode()).getBytes());
        }
        return old;
    }
    
    @Override
    public void clear() {
        try (Jedis jedis = jedisPool.getResource()) {
            Set<String> keys = jedis.keys(id + ":*");
            for (String key : keys) {
                jedis.del(key);
            }
        }
    }
    
    // 序列化/反序列化方法省略...
}

2.5 缓存使用注意事项

  1. 数据一致性:MyBatis 缓存默认不会主动同步,高并发写入场景慎用
  2. 序列化开销:二级缓存涉及序列化/反序列化,大数据量对象需谨慎
  3. 缓存穿透:热点数据失效时可能导致并发查询,可结合互斥锁优化
  4. 脏读风险:二级缓存可能存在短时间内的数据不一致

三、插件与缓存的组合应用

3.1 自动缓存预热插件

@Intercepts({
    @Signature(type = Executor.class, method = "update", 
               args = {MappedStatement.class, Object.class})
})
public class CacheWarmupPlugin implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object result = invocation.proceed();
        
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        
        // 数据更新后触发缓存预热
        if (ms.getSqlCommandType() == SqlCommandType.INSERT) {
            warmUpCache(ms);
        }
        
        return result;
    }
    
    private void warmUpCache(MappedStatement ms) {
        // 异步加载热点数据到缓存
        // ...
    }
}

四、总结

特性插件机制一级缓存二级缓存
作用域全局拦截SqlSessionMapper namespace
默认状态需开发配置自动开启需手动开启
主要用途功能扩展、监控减少重复查询跨 session 共享
线程安全需注意Session 隔离需保证

掌握 MyBatis 的插件与缓存机制,能让你在实际项目中:

  1. 不修改业务代码即可实现通用功能(如审计、加密、监控)
  2. 显著提升查询性能,减少数据库压力
  3. 灵活应对复杂场景,打造定制化的数据访问层

希望本文能帮助你在 MyBatis 的使用上更进一步!


参考链接: