在分布式环境下正确使用MyBatis二级缓存

在分布式环境下使用 MyBatis 二级缓存,核心挑战是解决多节点缓存一致性问题。单机环境中,二级缓存是内存级别的本地缓存,而分布式环境下多节点独立部署,本地缓存无法跨节点共享,易导致 "缓存孤岛" 和数据不一致。本文从底层原理出发,提供一套完整的分布式二级缓存解决方案,包含实战配置与最佳实践。

一、分布式环境下二级缓存的核心问题

在分布式架构(如微服务集群)中,默认的 MyBatis 二级缓存(本地内存缓存)会暴露三个致命问题:

  1. 缓存孤岛:每个节点维护独立缓存,同一查询在不同节点可能命中不同缓存数据(如节点 A 更新数据后,节点 B 的缓存仍是旧值)。
  2. 数据不一致:跨节点更新数据时,无法通知其他节点同步清空缓存,导致部分节点返回脏数据。
  3. 序列化风险:本地缓存可直接存储 Java 对象引用,而分布式缓存需网络传输,若对象未序列化会导致缓存失败。

二、解决方案:基于集中式缓存的二级缓存改造

分布式环境下的核心解决方案是:用集中式缓存(如 Redis、Memcached)替代本地内存缓存,让所有节点共享同一缓存源,实现缓存数据全局一致。

2.1 技术选型:MyBatis + Redis(最常用组合)

Redis 作为高性能的分布式缓存中间件,支持数据持久化、过期策略和集群模式,是 MyBatis 二级缓存的理想选择。实现思路是:

  • 让 MyBatis 的二级缓存数据存储到 Redis,而非本地内存。
  • 所有节点通过 Redis 访问缓存,确保缓存数据全局唯一。

三、实战:MyBatis 集成 Redis 实现分布式二级缓存

3.1 环境准备
  • JDK 17
  • MyBatis 3.5.10+
  • Redis 6.2+
  • Spring Boot 2.7.x(简化配置)
3.2 依赖配置(Maven)
复制代码
<!-- MyBatis核心依赖 -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.3.0</version>
</dependency>

<!-- Redis缓存依赖(MyBatis官方适配) -->
<dependency>
    <groupId>org.mybatis.caches</groupId>
    <artifactId>mybatis-redis</artifactId>
    <version>1.0.0-beta2</version>
</dependency>

<!-- Redis客户端 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
3.3 配置 Redis 连接

src/main/resources下创建redis.properties,配置 Redis 连接信息:

复制代码
# Redis服务器地址
redis.host=192.168.1.100
# Redis端口
redis.port=6379
# 连接超时时间(毫秒)
redis.timeout=2000
# Redis密码(无密码则留空)
redis.password=your_redis_password
# 数据库索引(默认0)
redis.database=1
# 缓存默认过期时间(毫秒,30分钟)
redis.default.expiration=1800000
3.4 改造实体类:实现序列化

分布式缓存中,对象需在网络中传输,必须实现Serializable接口,否则会导致缓存失败。
User.java

复制代码
package com.example.entity;

import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 用户实体类(必须实现Serializable)
 */
@Data
public class User implements Serializable {
    // 序列化版本号(避免反序列化冲突)
    private static final long serialVersionUID = 1L;
    
    private Long id;
    private String username;
    private String email;
    private LocalDateTime createTime;
}
3.5 配置 Mapper 使用 Redis 缓存

在 Mapper.xml 中指定缓存类型为 Redis,替代默认的本地缓存。
UserMapper.xml

复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.mapper.UserMapper">
    <!-- 配置Redis作为二级缓存 -->
    <cache 
        type="org.mybatis.caches.redis.RedisCache"  <!-- 指定Redis缓存实现类 -->
        eviction="LRU"  <!-- 缓存淘汰策略:最近最少使用 -->
        flushInterval="300000"  <!-- 自动刷新间隔(5分钟) -->
        size="1000"  <!-- 最大缓存对象数量 -->
        readOnly="false"/>  <!-- 非只读(需序列化) -->

    <!-- 查询语句:默认使用二级缓存 -->
    <select id="selectById" resultType="com.example.entity.User">
        SELECT id, username, email, create_time AS createTime
        FROM t_user
        WHERE id = #{id}
    </select>

    <!-- 更新语句:默认触发缓存清空(flushCache=true) -->
    <update id="update">
        UPDATE t_user
        SET username = #{username}, email = #{email}
        WHERE id = #{id}
    </update>
</mapper>

关键配置说明

  • type="org.mybatis.caches.redis.RedisCache":指定 MyBatis 使用 Redis 存储缓存数据。
  • eviction="LRU":当缓存满时,移除最久未使用的对象,避免内存溢出。
  • flushInterval="300000":5 分钟自动刷新一次缓存,作为数据一致性的兜底策略。
3.6 全局启用二级缓存

在 MyBatis 配置文件(或 Spring Boot 配置)中确保二级缓存全局开启(默认开启,建议显式配置)。
application.yml

复制代码
mybatis:
  configuration:
    cache-enabled: true  # 全局启用二级缓存(默认true)
  mapper-locations: classpath:mapper/*.xml  # 指定Mapper.xml路径
3.7 验证分布式缓存效果

部署两个服务节点(Node1 和 Node2),通过测试验证缓存一致性:

测试代码(Service 层)

复制代码
@Slf4j
@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;

    /**
     * 查询用户:优先从Redis缓存获取
     */
    public User getUserById(Long id) {
        if (Objects.isNull(id)) {
            log.warn("用户ID为空");
            return null;
        }
        User user = userMapper.selectById(id);
        log.info("查询用户结果:{}", user);
        return user;
    }

    /**
     * 更新用户:触发Redis缓存清空
     */
    @Transactional
    public void updateUser(User user) {
        if (Objects.isNull(user) || Objects.isNull(user.getId())) {
            log.warn("用户信息不完整");
            return;
        }
        int rows = userMapper.update(user);
        log.info("更新用户影响行数:{}", rows);
        // 事务提交后,MyBatis会自动清空Redis中该Mapper的缓存
    }
}

测试步骤与预期结果

  1. Node1 首次查询用户 ID=1:未命中缓存,查询数据库,结果存入 Redis。
  2. Node2 查询用户 ID=1:命中 Redis 缓存,直接返回结果(无需查库)。
  3. Node1 更新用户 ID=1:事务提交后,Redis 中该用户的缓存被清空。
  4. Node2 再次查询用户 ID=1:未命中缓存,查询数据库获取最新数据,并存入 Redis。

通过 Redis 客户端(如redis-cli)可观察到缓存键值的创建与删除,证明所有节点共享同一缓存。

四、分布式缓存的高级优化策略

4.1 缓存键设计:避免命名冲突

MyBatis 默认的缓存键由namespace + SQL语句 + 参数组成,在分布式环境下需确保唯一性。可通过自定义RedisCache实现类优化键名:

CustomRedisCache.java

复制代码
package com.example.cache;

import org.mybatis.caches.redis.RedisCache;
import java.util.UUID;

/**
 * 自定义Redis缓存,添加应用前缀避免键冲突
 */
public class CustomRedisCache extends RedisCache {
    // 应用唯一标识(避免多应用共用Redis时键冲突)
    private static final String APP_PREFIX = "myapp:";

    public CustomRedisCache(String id) {
        super(id);
    }

    /**
     * 重写缓存键,添加应用前缀
     */
    @Override
    public Object getObject(Object key) {
        String cacheKey = APP_PREFIX + key.toString();
        return super.getObject(cacheKey);
    }

    @Override
    public void putObject(Object key, Object value) {
        String cacheKey = APP_PREFIX + key.toString();
        super.putObject(cacheKey, value);
    }

    @Override
    public Object removeObject(Object key) {
        String cacheKey = APP_PREFIX + key.toString();
        return super.removeObject(cacheKey);
    }
}

在 Mapper.xml 中使用自定义缓存:

复制代码
<cache type="com.example.cache.CustomRedisCache"/>
4.2 缓存失效策略:主动 + 被动结合

分布式环境下,单一的自动失效可能存在延迟,需结合主动失效策略:

  1. 被动失效 :依赖flushInterval自动刷新(如 30 分钟),适合非核心数据。

  2. 主动失效:更新数据后,通过代码手动删除缓存(极端场景):

    /**

    • 手动删除指定用户的缓存
      */
      public void deleteUserCache(Long userId) {
      // 获取UserMapper的缓存对象
      Cache cache = sqlSessionFactory.getConfiguration().getCache("com.example.mapper.UserMapper");
      if (Objects.nonNull(cache)) {
      // 构造缓存键(需与MyBatis生成规则一致)
      // 键格式:namespace + "::" + SQLID + "::" + 参数
      String cacheKey = "com.example.mapper.UserMapper::selectById::" + userId;
      cache.removeObject(cacheKey);
      log.info("手动删除用户缓存,key: {}", cacheKey);
      }
      }
4.3 处理缓存与数据库一致性:延迟双删

在高并发场景,更新数据库后立即删除缓存可能仍有风险(删除缓存前已有请求读取旧缓存)。可采用 "延迟双删" 策略:

复制代码
@Transactional
public void updateUserWithDelayDelete(User user) {
    // 1. 更新数据库
    userMapper.update(user);
    // 2. 第一次删除缓存(事务提交后执行)
    transactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // 事务提交后删除缓存
            deleteUserCache(user.getId());
            
            // 3. 延迟1秒后第二次删除(避免更新前的请求仍读取旧缓存)
            CompletableFuture.runAsync(() -> {
                try {
                    Thread.sleep(1000);
                    deleteUserCache(user.getId());
                } catch (InterruptedException e) {
                    log.error("延迟删除缓存失败", e);
                }
            });
        }
    });
}
4.4 缓存序列化优化:使用 JSON 替代 Java 序列化

默认情况下,MyBatis-Redis 使用 Java 序列化存储对象,存在性能差、可读性低的问题。可自定义序列化方式(如 JSON):

JsonRedisCache.java(简化版)

复制代码
package com.example.cache;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.cache.Cache;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class JsonRedisCache implements Cache {
    private final String id;
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public JsonRedisCache(String id) {
        this.id = id;
        // 注入RedisTemplate(实际需通过Spring上下文获取)
        this.redisTemplate = SpringContextHolder.getBean(RedisTemplate.class);
    }

    @Override
    public String getId() { return id; }

    @Override
    public void putObject(Object key, Object value) {
        try {
            String jsonValue = objectMapper.writeValueAsString(value);
            redisTemplate.opsForValue().set(key.toString(), jsonValue, 30, TimeUnit.MINUTES);
        } catch (Exception e) {
            log.error("缓存序列化失败", e);
        }
    }

    @Override
    public Object getObject(Object key) {
        try {
            String jsonValue = (String) redisTemplate.opsForValue().get(key.toString());
            if (StringUtils.hasText(jsonValue)) {
                // 根据实际类型反序列化(简化示例)
                return objectMapper.readValue(jsonValue, User.class);
            }
        } catch (Exception e) {
            log.error("缓存反序列化失败", e);
        }
        return null;
    }

    @Override
    public Object removeObject(Object key) {
        redisTemplate.delete(key.toString());
        return null;
    }

    @Override
    public void clear() {
        // 清空当前namespace的所有缓存(需批量删除匹配键)
    }

    @Override
    public int getSize() { return 0; }

    @Override
    public ReadWriteLock getReadWriteLock() { return readWriteLock; }
}

使用 JSON 序列化后,Redis 中缓存的数据可读性更强,且序列化效率更高。

五、分布式二级缓存的适用场景与禁忌

5.1 适用场景
  • 查询频繁、更新极少的数据:如字典表、地区表、系统配置表(更新频率低,缓存命中率高)。
  • 非核心业务数据:如商品详情、历史订单(允许短时间不一致,优先保证性能)。
  • 数据一致性要求不高的场景:如用户浏览记录、热门商品排行(可接受分钟级延迟)。
5.2 禁忌场景
  • 实时性要求极高的数据:如库存数量、账户余额(缓存延迟可能导致超卖、余额显示错误)。
  • 高频更新数据:如秒杀商品状态、实时在线人数(缓存命中率低,反而增加 Redis 负担)。
  • 超大对象:如包含大量字段的报表数据(序列化 / 传输成本高,不如直接查库)。

六、监控与调优

  1. 缓存命中率监控 :通过 Redis 的INFO stats命令查看keyspace_hits(命中数)和keyspace_misses(未命中数),命中率低于 70% 需优化缓存策略。
  2. 过期键清理:避免缓存键永久有效,结合业务设置合理过期时间(如 30 分钟~24 小时)。
  3. Redis 集群:高并发场景下,使用 Redis Cluster 保证缓存服务的高可用。

七、总结

分布式环境下正确使用 MyBatis 二级缓存的核心是用集中式缓存(如 Redis)替代本地缓存,关键步骤包括:

  1. 集成 MyBatis-Redis 适配器,让缓存数据存储到 Redis。
  2. 实体类实现序列化,确保跨节点传输正常。
  3. 配置合理的缓存淘汰策略与过期时间,平衡性能与一致性。
  4. 结合业务场景选择缓存对象,实时性数据禁用缓存。

通过这套方案,既能保留二级缓存的性能优势,又能解决分布式环境的数据一致性问题,实现 "高性能 + 高可靠" 的平衡。

相关推荐
考虑考虑5 小时前
Jpa使用union all
java·spring boot·后端
用户3721574261355 小时前
Java 实现 Excel 与 TXT 文本高效互转
java
浮游本尊6 小时前
Java学习第22天 - 云原生与容器化
java
渣哥8 小时前
原来 Java 里线程安全集合有这么多种
java
间彧8 小时前
Spring Boot集成Spring Security完整指南
java
间彧9 小时前
Spring Secutiy基本原理及工作流程
java
Java水解10 小时前
JAVA经典面试题附答案(持续更新版)
java·后端·面试
洛小豆12 小时前
在Java中,Integer.parseInt和Integer.valueOf有什么区别
java·后端·面试
前端小张同学12 小时前
服务器上如何搭建jenkins 服务CI/CD😎😎
java·后端
ytadpole12 小时前
Spring Cloud Gateway:一次不规范 URL 引发的路由转发404问题排查
java·后端