Java缓存同步流程如何统一

wen java案例 23

本文目录导读:

Java缓存同步流程如何统一

  1. 核心原则:选对策略
  2. 统一的标准流程(以“旁路缓存 + 延迟双删”为例)
  3. Java 代码实现(统一封装)
  4. 统一流程中的关键注意事项
  5. 优化与进阶(走向“绝对统一”)

这是一个非常好的问题,在实际的分布式系统或高并发应用中,缓存(如Redis)与数据库(如MySQL)之间的数据一致性是核心难点,所谓的“统一缓存同步流程”,是指设计一套标准化的、可复用的模式,来确保缓存中的数据与数据库中的数据最终一致(或强一致),同时避免常见的坑(如缓存穿透、击穿、雪崩)。

下面从策略选择标准化流程代码实现注意事项四个维度来拆解。

核心原则:选对策略

没有“万能”的统一流程,因为业务对一致性的要求不同,通常推荐以下两种主流、统一且工程化程度高的方案:

策略 核心思想 一致性等级 适用场景 缺点
Cache Aside Pattern (旁路缓存) 读的时候先读缓存,miss了查DB再写缓存;写的时候先更新DB,再删除缓存 最终一致性 绝大多数业务场景(读多写少、允许短时间不一致) 有并发写导致脏数据的极端情况
延迟双删 (优化版) 在Cache Aside的基础上,更新DB后先删一次缓存,延迟一小段时间(如几百毫秒) 再删一次。 高最终一致性 对一致性要求较高,但不能接受分布式事务的场景 需要引入MQ或定时任务,实现稍复杂

关键结论“先更新DB,后删除缓存” 是目前公认最简洁且有效的统一同步基石。不推荐“先删缓存,后更新DB”,因为极易导致并发场景下的脏数据。

统一的标准流程(以“旁路缓存 + 延迟双删”为例)

以下是一个高度抽象、可复用的公共流程,封装在服务层(Service Layer)中。

读请求(Query Flow)

graph TD
    A[客户端请求] --> B{缓存中是否存在?};
    B -- 存在 --> C[直接返回缓存数据];
    B -- 不存在 --> D[加分布式锁,防止缓存击穿];
    D --> E[再次检查缓存(Double Check)];
    E -- 存在 --> C;
    E -- 不存在 --> F[从数据库查询];
    F --> G{数据库是否存在?};
    G -- 是 --> H[将数据写入缓存,设置过期时间];
    H --> I[释放锁,返回数据];
    G -- 否 --> J[在缓存写入空值/布隆过滤器处理,防止穿透];
    J --> I;

写请求(Write Flow)

graph TD
    A[客户端写请求] --> B[操作数据库(新增/更新/删除)];
    B --> C[事务提交成功];
    C --> D[执行异步任务:第1次删除缓存(Key)];
    D --> E[延迟N毫秒(如500ms)];
    E --> F[执行异步任务:第2次删除缓存(Key)];
    F --> G[结束];

Java 代码实现(统一封装)

为了做到“统一”,我们需要把流程抽象出来,通过一个统一的类(如 CacheSyncService)来管理。

核心接口定义

@Component
public class CacheSyncService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private RabbitTemplate rabbitTemplate; // 或使用线程池
    /**
     * 统一的写操作同步方法(延迟双删)
     * @param cacheKey 需要失效的缓存Key
     * @param databaseOp 数据库操作函数(返回值影响是否执行后续操作)
     * @param <T>
     * @return
     */
    public <T> T executeWriteWithCacheSync(String cacheKey, Supplier<T> databaseOp) {
        // 1. 执行数据库操作
        T result = databaseOp.get();
        // 2. 异步删除缓存(第1次)
        asyncDeleteCache(cacheKey);
        // 3. 延迟删除缓存(第2次),为了处理并发读写的脏数据
        scheduleDelayedDelete(cacheKey);
        return result;
    }
    /**
     * 统一的读操作同步方法
     * @param cacheKey 缓存Key
     * @param type 返回类型
     * @param databaseLoader 数据库加载函数
     * @param <T>
     * @return
     */
    public <T> T executeReadWithCacheSync(String cacheKey, Class<T> type, Supplier<T> databaseLoader) {
        // 1. 从缓存查询
        T cacheData = (T) redisTemplate.opsForValue().get(cacheKey);
        if (cacheData != null) {
            return cacheData;
        }
        // 2. 缓存Miss,加分布式锁(防击穿)
        String lockKey = "lock:" + cacheKey;
        RLock lock = redissonClient.getLock(lockKey);
        try {
            if (lock.tryLock(2, 10, TimeUnit.SECONDS)) {
                try {
                    // 3. 双检:防止当前线程加锁期间,其他线程已经写入缓存
                    cacheData = (T) redisTemplate.opsForValue().get(cacheKey);
                    if (cacheData != null) {
                        return cacheData;
                    }
                    // 4. 查数据库
                    T dbData = databaseLoader.get();
                    // 5. 处理缓存穿透
                    if (dbData == null) {
                        // 写入空值(过期时间短,如30秒)
                        redisTemplate.opsForValue().set(cacheKey, new NullValue(), 30, TimeUnit.SECONDS);
                        return null;
                    }
                    // 6. 写入缓存(设置随机过期时间,防雪崩)
                    long expireTime = getRandomExpireTime();
                    redisTemplate.opsForValue().set(cacheKey, dbData, expireTime, TimeUnit.SECONDS);
                    return dbData;
                } finally {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        // 如果拿不到锁,走降级策略(例如再查一次DB,或者等待重试)
        return databaseLoader.get();
    }
    // =============== 辅助方法 ===============
    private void asyncDeleteCache(String key) {
        // 方案1:线程池
        // threadPoolTaskExecutor.execute(() -> redisTemplate.delete(key));
        // 方案2:MQ(推荐,保证最终一致性)
        rabbitTemplate.convertAndSend("cache.delete.exchange", "cache.delete.routing", key);
    }
    private void scheduleDelayedDelete(String key) {
        // 延迟500ms后执行
        CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS)
                .execute(() -> {
                    // 第2次删除
                    redisTemplate.delete(key);
                });
    }
    private long getRandomExpireTime() {
        // 基础过期时间 + 随机值,防止大量缓存同时过期
        return 60 * 60 + (long)(Math.random() * 600); // 1小时 + 随机0-10分钟
    }
}

在业务层调用

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private CacheSyncService cacheSyncService;
    private static final String USER_CACHE_PREFIX = "user:";
    @Override
    public User getUserById(Long id) {
        String cacheKey = USER_CACHE_PREFIX + id;
        return cacheSyncService.executeReadWithCacheSync(
            cacheKey,
            User.class,
            () -> userMapper.selectById(id)   // 数据库加载
        );
    }
    @Override
    @Transactional
    public User updateUser(User user) {
        // 使用统一封装,自动处理缓存
        return cacheSyncService.executeWriteWithCacheSync(
            USER_CACHE_PREFIX + user.getId(),
            () -> {
                userMapper.updateById(user);
                return user;
            }
        );
    }
}

统一流程中的关键注意事项

为了保证流程在各种极端情况下仍能工作,需要统一处理以下三个“顽疾”:

  1. 缓存穿透

    • 方案:在统一读流程中,若数据库返回空,写入一个短期的空值对象(Null Object),防止恶意Key压垮DB。
  2. 缓存击穿

    • 方案:在统一读流程中,使用分布式锁(如Redisson),注意要配合Double Check,避免加锁后重复查DB。
  3. 缓存雪崩

    • 方案:在统一写流程中,设置随机过期时间,不要在代码中写死固定的过期秒数。

优化与进阶(走向“绝对统一”)

如果想让整个流程更加自动化、无感,可以引入监听机制

  1. 基于Canal(阿里开源):监听MySQL的binlog变化。

    • 当数据库某张表发生变更时,Canal会推送该行数据。
    • 统一流程:写操作只写DB,无需写代码去操作Redis,Canal监听到binlog后,自动删除或更新对应缓存。
    • 优点:业务代码零侵入,彻底解耦,这是目前大型互联网公司最常用的方案。
  2. 基于注解的AOP

    • 定义一个 @CacheSync 注解,包含 keyexpire 等属性。
    • 通过AOP切面拦截所有带此注解的方法,自动执行“查缓存 -> 没查到 -> 锁 -> 查DB -> 写缓存”的流程,这进一步统一了代码风格。

Java缓存同步流程的统一,核心在于“固化最佳实践”

  • 最简方案(适合大部分项目):Cache Aside + 统一封装Service
  • 高一致方案延迟双删 + MQ异步
  • 无侵入方案Canal + Binlog监听

无论选择哪种方案,关键在于:将缓存操作的细节(加锁、防穿透、延迟删、key拼装)都收归到一个统一的服务或切面中,业务代码只关心数据库操作本身。 这样就实现了真正的“统一流程”。

抱歉,评论功能暂时关闭!