Java【缓存设计】定时任务+分布式锁实战:Redis vs Redisson实现状态自动扭转以及全量刷新预热机制


目录

[1. 前言](#1. 前言)

插播一条消息~

[2. 正文](#2. 正文)

[2.1 缓存存在的价值与策略](#2.1 缓存存在的价值与策略)

[2.2 定时任务实现状态扭转](#2.2 定时任务实现状态扭转)

[2.2.1 Redis 原生命令 + Lua脚本实现分布式锁](#2.2.1 Redis 原生命令 + Lua脚本实现分布式锁)

[2.2.2 Redisson框架实现高级分布式锁](#2.2.2 Redisson框架实现高级分布式锁)

[2.3 RabbitMQ异步解耦:全局缓存更新策略](#2.3 RabbitMQ异步解耦:全局缓存更新策略)

[2.4 全量刷新缓存预热机制](#2.4 全量刷新缓存预热机制)

[3. 小结](#3. 小结)


1. 前言

现代租房系统动辄百万房源、千万用户,每天面临着海量的查询请求(如条件筛选、价格区间统计)和频繁的状态变更(如出租到期、用户信息更新)。如果每次请求都直接打到数据库,不仅响应慢,还极易引发DB瓶颈甚至宕机。

为此,我们必须引入缓存层------Redis作为工业级分布式缓存中间件,凭借其高性能、丰富数据结构、原子操作与持久化能力,成为我们的首选。但"用Redis并不等于正确使用Redis"。如何规避典型陷阱?如何保证缓存与数据库的一致性?如何在多节点部署下安全执行定时任务?这些问题才是真正的挑战。

本文将基于真实的租房系统场景,带你彻底解决这类问题。我们会重点剖析:

  • 如何用定时任务批量处理状态扭转
  • 在分布式环境下如何用分布式锁避免重复执行
  • Redis原生方案 vs Redisson框架的优劣对比

Feast不止于理论,我会手把手带你写出生产可用的代码,并分享实际开发中的坑点应对方案。


插播一条消息~

🔍十年经验淬炼 · 系统化AI学习平台推荐

系统化学习AI平台https://www.captainbed.cn/scy/

  • 📚 **完整知识体系:**从数学基础 → 工业级项目(人脸识别/自动驾驶/GANs),内容由浅入深
  • 💻 **实战为王:**每小节配套可运行代码案例(提供完整源码)
  • 🎯**零基础友好:**用生活案例讲解算法,无需担心数学/编程基础

🚀 特别适合

  • 想系统补强AI知识的开发者
  • 转型人工智能领域的从业者
  • 需要项目经验的学生

2. 正文

2.1 缓存存在的价值与策略

在深入定时任务之前,我们先快速回顾下缓存的核心知识,这对理解整体架构很重要。

缓存策略三大核心

  1. 读取缓存:先查缓存,命中则返回,未命中查DB并回写缓存
  2. 写入缓存:更新DB后,删除或更新对应缓存(怕大家凌乱,具体可以看下文)
  3. 失效策略:给缓存设置合理的过期时间,避免永久存储脏数据

缓存数据类型

  • 热点数据:用户信息、配置信息等高频访问数据
  • 复杂查询结果:如价格区间统计、多表关联查询结果
  • 会话信息:用户登录Token、个性化设置等

缓存架构选择

对于分布式系统,我们选择Redis分布式缓存,主要原因:

  • 跨服务共享数据
  • 高性能读写能力
  • 丰富的数据结构支持
  • 持久化与高可用保障

数据一致性保障

Redis作为缓存,MySQL作为持久化存储,我们需要保障两者数据的一致性,常用策略:

|-------------|---------|--------------|-----------|
| 策略 | 优点 | 缺点 | 适用场景 |
| 先更新DB,后删除缓存 | 简单、高效 | 可能短暂不一致 | 读多写少场景 |
| 先删除缓存,后更新DB | 减少不一致时间 | 删除后更新前可能有脏数据 | 写多读少场景 |
| 异步同步 | 性能最好 | 实现复杂,可能丢失数据 | 最终一致性要求场景 |

缓存加载策略

  • 全量加载:启动时加载所有数据,适合数据量小场景(就是这个例子😁)
  • 增量加载:按需加载,节省内存但首次访问慢
  • 定时加载:定期刷新,适合数据变更不频繁场景

缓存高并发三大经典问题

这是面试官最爱问的问题,也是线上事故的高发地带。我们逐个击破:

|----------|------------------------------------|-----------------------------------------------------|
| 问题 | 定义 | 缓解措施 |
| 缓存穿透 | 查询不存在的数据(既无缓存也无DB),导致大量请求直达DB | 1. 对空结果也缓存(带短TTL) 2. 使用布隆过滤器(Bloom Filter)提前拦截非法key |
| 缓存击穿 | 热点Key到期瞬间大量并发读,均未命中缓存 | 互斥锁(mutex lock):仅一个线程查DB并回填,其余阻塞或降级 |
| 缓存雪崩 | 大片缓存Key同时过期(或Redis宕机),瞬间涌入巨大流量冲垮DB | 1. 设置随机TTL避免集体失效 2. 多级缓存(本地Caffeine + 远程Redis)缓冲冲击 |

现在我们对缓存有了基本认知,接下来进入正题:如何用定时任务+分布式锁解决房源状态同步问题。


2.2 定时任务实现状态扭转

背景说明

租房系统中,房源有三种核心状态:

  • 可出租(UP)
  • 已出租(RENTING)
  • 已下线(DOWN)

房东签署租赁合约后,房源状态变为【出租中】(RENTING)。合约到期时间明确(例:2025-06-01 00:00:00),系统需在该时刻后将其自动改为【待上线】(UP),释放给新租客。

但不能让前端或管理员手动改!原因如下:

  1. 租赁合同可能几千条并发结束;
  2. 时间精确性要求高(零点整切换);
  3. 若忘记操作会导致房源长期挂失无法上架;

所以------引入每日凌晨批量校验 + 更正状态是刚需动作。

业务代码实现

先看基础版的业务逻辑代码,后面我们再给这个代码加"分布式锁"的保护壳:

java 复制代码
// 查询全部已出租房源
List<HouseStatus> rentHouseStatusList = houseStatusMapper.selectList(
        new LambdaQueryWrapper<HouseStatus>()
                .eq(HouseStatus::getStatus, HouseStatusEnum.RENTING.name())
);

// 过滤需要扭转状态的房源列表(出租到期时间)
List<HouseStatus> needChangeHouseStatusList = rentHouseStatusList.stream()
        .filter(houseStatus -> null != houseStatus.getRentEndTime()
            && 0 > TimestampUtil.calculateDifferenceMillis(
                TimestampUtil.getCurrentSeconds(), houseStatus.getRentEndTime()))
        .collect(Collectors.toList());

// 批量更新房源状态
for (HouseStatus housestatus:needChangeHouseStatusList) {
    HouseStatusEditReqDTO houseStatusEditReqDTO = new HouseStatusEditReqDTO();
    housestatus.setHouseId(housestatus.getHouseId());
    houseStatusEditReqDTO.setStatus(HouseStatusEnum.UP.name());
    editStatus(houseStatusEditReqDTO);
}

这段代码在单机环境下运行没问题,但在分布式部署时就会出现重复执行的问题。

假设我们部署了3个实例,那么零点时分:

  1. 实例1执行任务,更新了100条房源状态
  2. 实例2执行同样任务,又更新了相同的100条房源
  3. 实例3也执行了相同任务...

这不仅浪费资源,更可能导致业务逻辑错误!

解决方案:分布式锁

我们需要一种机制确保同一时间只有一个实例能执行任务,这就是分布式锁的核心价值。

下面我们分别用两种方式实现分布式锁。


2.2.1 Redis 原生命令 + Lua脚本实现分布式锁

这是最经典的实现方式。优势在于无外部依赖升级成本低,劣势为需自行处理超时续期和死锁检测逻辑。

完整实现代码

java 复制代码
// 定时任务实现扭转房源状态
// 每日零点开始执行定时任务
@Scheduled(cron = "0 0 0 * * ?")
public void scheduledHouseStatus(){
    log.info("开始执行房源状态扭转任务");
  
    // 加redis分布式锁,锁的value来判断所是否为当前线程所有
    String value = UUID.randomUUID().toString();
    try {
        Boolean lock = redisService.setCacheObjectIfAbsent(LOCK_KEY, value, 180L, TimeUnit.SECONDS);
        if(Boolean.TRUE.equals( lock)){
            // 业务代码(上面2.2节的代码)
            doHouseStatusChangeBusiness();
        }else{
            // 获取锁失败,跳过执行
            log.info("定时任务被其他实例执行");
        }
    } finally {
        // 解锁,只能解锁自己
        redisService.cad(LOCK_KEY, value);
    }
}

关键方法解析

加锁方法 :使用Redis的SETNX命令(SET if Not exists)

java 复制代码
/**
 * 缓存String数据,如果该键不存在则存储,并设置数据有效时间,若已存在则不存储。
 *
 * @param key      缓存的键值
 * @param value    缓存的值
 * @param timeout  时间
 * @param timeUnit 时间单位
 * @param <T> 对象类型
 * @return 是否缓存了对象   如果key已经存在,则返回false,否则返回true
 */
public <T> Boolean setCacheObjectIfAbsent(final String key, final T value, final long timeout, final TimeUnit timeUnit) {
    return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, timeUnit);
}

解锁方法:使用Lua脚本保证原子性

java 复制代码
/**
 * 删除指定值对应的 Redis 中的键值(compare and delete)
 *
 * @param key   缓存key
 * @param value value
 * @return 是否完成了比较并删除
 */
public boolean cad(String key, String value) {
    if (key.contains(StringUtils.SPACE) || value.contains(StringUtils.SPACE)) {
        return false;
    }

    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

    // 通过lua脚本原子验证令牌和删除令牌
    Long result = (Long) redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(key),
            value);
    return !Objects.equals(result, 0L);
}

为什么需要Lua脚本?

简单用get+del两步操作不是原子性的,期间可能发生:

  1. 实例A检查value匹配,准备执行del
  2. 锁过期,实例B获得了锁
  3. 实例A执行del,误删了实例B的锁

Lua脚本保证判断和删除在同一次原子操作中完成


2.2.2 Redisson框架实现高级分布式锁

为什么选择Redisson?

虽然Redis原生方案能 work,但需要考虑太多细节:

  • 锁过期时间设置多少合适?
  • 如何处理锁续期问题?
  • 怎样避免误删其他实例的锁?

相比手动封装Lua,使用成熟的第三方库无疑开发效率更高,并天然提供watch-dog自动续期、公平锁/红锁选举算法等能力。

Redisson帮我们封装了这些复杂性,提供了更健壮的分布式锁实现。

完整实现代码

java 复制代码
@Scheduled(cron = "0 0 0 * * ?")
public void scheduledHouseStatus2(){
    log.info("开始执行房源状态扭转任务");

    // 加redisson分布式锁
    RLock lock = redissonLockService.acquire(LOCK_KEY, -1);
    if(null == lock){
        log.info("定时任务被其他实例执行");
        return;
    }

    try {
        // 业务代码
        doHouseStatusChangeBusiness();
    } finally {
        if(lock.isLocked() && lock.isHeldByCurrentThread()){
            redissonLockService.releaseLock(lock);
        }
    }
}

Redisson锁服务封装

java 复制代码
@Slf4j
@RequiredArgsConstructor
public class RedissonLockService {
    /**
     * redis操作客户端
     */
    private final RedissonClient redissonClient;

    /**
     * 获取锁
     *
     * @param lockKey        锁的key,唯一标识,建议模块名+唯一键
     * @param expire         超时时间,单位毫秒,传入-1自动续期
     * @return 获取到的RLock实例,为null则获取失败
     */
    public RLock acquire(String lockKey, long expire) {
        try {
            final RLock lockInstance = redissonClient.getLock(lockKey);

            // 注意:如果tryLock指定了leaseTime>0就不会续期。参考 RedissonLock类的tryAcquireAsync方法的实现
            lockInstance.lock(expire, TimeUnit.MILLISECONDS);
            return lockInstance;
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * 释放锁。注意:必须和获取锁在一个线程中
     *
     * @param lockInstance 锁的实例,acquire返回的
     * @return 释放成功返回true,否则返回false
     */
    public boolean releaseLock(RLock lockInstance) {
        if (lockInstance.isHeldByCurrentThread()) {
            lockInstance.unlock();
            return true;
        }
        return false;
    }
}

两种方案对比

|-----------|---------------|-------------|
| 特性 | Redis原生实现 | Redisson实现 |
| 实现复杂度 | 高,需自己处理各种边界情况 | 低,API简单易用 |
| 锁续期 | 需手动实现 | 内置看门狗自动续期 |
| 可重入性 | 需手动实现 | 内置支持 |
| 公平锁 | 不支持 | 支持 |
| 性能 | 略高(无额外开销) | 略低(有额外功能开销) |
| 可靠性 | 依赖自己实现细节 | 久经生产环境考验 |

生产环境建议:除非有极致性能要求,否则推荐使用Redisson


2.3 RabbitMQ异步解耦:全局缓存更新策略

前面提到的是被动定期轮询修正状态的做法,然而现实中很多变化其实是由其他事件主动驱动发生的 ------ 如房东上传新版身份证扫描件(认证级别提高) ⇒ 展示徽章样式改变;亦或者用户停用了某个优惠券模板 ⇒ 旗下已发布的房源应同步关闭特惠标......

这些变更并非周期规律发生,也不能等到第二天凌晨统一处理。于是我们借助事件驱动思维,通过发布订阅模式将跨模块间的耦合解开,实现高效灵活的消息通知机制。

这里使用RabbitMQ实现异步解耦:

java 复制代码
@Component
@Slf4j
@RabbitListener(bindings = {
        @QueueBinding(
                value = @Queue,
                exchange = @Exchange(value = RabbitConfig.EXCHANGE_NAME, type = ExchangeTypes.FANOUT))
})
public class EditAppUserMessageReceiver {
    @Autowired
    private IHouseService houseService;

    @RabbitHandler
    public void process(AppUserDTO appUserDTO){
        if(null == appUserDTO || null == appUserDTO.getUserId()){
            log.error("用户信息为空");
            return;
        }
        log.info("MQ成功收到消息,message:{}", JsonUtil.obj2String(appUserDTO));
        try {
            List<Long> houseIds = houseService.listByUserId(appUserDTO.getUserId());

            for (Long houseId : houseIds){
                houseService.cacheHouse(houseId);
            }
        } catch (Exception e) {
            log.error("处理用户更新时,更新房源缓存异常,appUserDTO:{}",
                    JsonUtil.obj2String(appUserDTO), e);
        }
    }
}

关键设计要点

  1. 使用发布/订阅模式(Fanout Exchange):一个用户消息需要被多个服务消费
  2. 异步处理:避免同步调用阻塞主流程
  3. 异常处理:保证消息消费的可靠性

2.4 全量刷新缓存预热机制

有时候我们会遇见这种情况:

  • 新机房裁撤上线后首次查询极其缓慢?
  • 系统重启导致缓存全部清空前端访问出现短暂瘫痪?

上述就是典型的"缓存完全miss冷启动"效应,尤其对搜索筛选类接口打击巨大。为了避免首波冲击压垮DB集群,我们必须建立全量Warmup Preload Script在服务上线前提前准备好关键路径上的热点数据。

最后我们看下如何做全量缓存刷新,这是在系统启动或数据大规模变更时使用的策略:

java 复制代码
public void refreshHouseIds() {
    // 查询全量城市列表(2级城市)
    List<SysRegion> sysRegions = regionMapper.selectList(
            new LambdaQueryWrapper<SysRegion>()
                    .eq(SysRegion::getLevel, 2)
    );
    for(SysRegion sysRegion: sysRegions){
        // 删除当前城市下所有的房源列表redis
        Long cityId = sysRegion.getId();
        redisService.removeForAllList(CITY_HOUSE_PREFIX + cityId);

        // 查询当前城市下所有的房源列表mysql
        List<CityHouse> cityHouses = cityHouseMapper.selectList(
                new LambdaQueryWrapper<CityHouse>()
                        .eq(CityHouse::getCityId, cityId)
        );

        // 新增当前城市下所有的房源列表映射redis
        if(!CollectionUtils.isEmpty(cityHouses)){
            redisService.setCacheList(CITY_HOUSE_PREFIX + cityId,
                    cityHouses.stream()
                            .map(CityHouse::getHouseId).distinct()
                            .collect(Collectors.toList()));
        }
        // 更新房源列表详细信息(redis)
        for(CityHouse cityHouse : cityHouses){
            cacheHouse(cityHouse.getHouseId());
        }
    }
}

优化建议

  1. 分页处理:如果数据量很大,需要分页处理避免内存溢出
  2. 异步执行:全量刷新耗时较长,应该异步执行并提供进度查询
  3. 灰度发布:先刷新部分数据,验证无误后再全量刷新

3. 小结

通过本文的实战讲解,我们完整掌握了:

✅ 核心解决方案

  1. 定时任务调度:使用Spring Schedule实现定时状态检查
  2. 分布式锁保障:Redis原生 vs Redisson两种实现方案
  3. 消息队列解耦:RabbitMQ处理用户信息变更事件
  4. 缓存全量刷新:系统启动或数据迁移时的缓存初始化

✅ 技术选型推荐

  • 中小项目:直接使用Redisson,避免重复造轮子
  • 极致性能场景:可考虑Redis原生方案,但要做好充分测试
  • 高可用要求:Redis集群模式+Sentinel保障可用性

希望本文能帮你彻底解决分布式环境下的定时任务同步问题。如果有任何疑问或建议,欢迎在评论区交流讨论!

相关推荐
心灵宝贝3 分钟前
申威架构SW64 KY10系统安装tomcat-9.0.10-25.ky10.sw_64.rpm详细步骤
java·架构·tomcat
CoderYanger4 分钟前
递归、搜索与回溯-综合练习:19.目标和
java·算法·leetcode·1024程序员节
LSL666_5 分钟前
SpringBoot项目连接deepseek
java·spring boot·后端·deepseek
蒲公英源码6 分钟前
AI智慧教育平台架构设计与实现方案,基于Jdk17+SpringBoot3AI智慧教育平台
java·人工智能·mysql·jdk
i***39586 分钟前
JAVA系统中Spring Boot 应用程序的配置文件:application.yml
java·开发语言·spring boot
f***45326 分钟前
Spring Boot 经典九设计模式全览
java·spring boot·设计模式
tkevinjd8 分钟前
JavaHashMap源码分析
java·后端
l***37098 分钟前
在2023idea中如何创建SpringBoot
java·spring boot·后端
s***117014 分钟前
Spring+Quartz实现定时任务的配置方法
java
j***630819 分钟前
Spring Boot 多数据源解决方案:dynamic-datasource-spring-boot-starter 的奥秘(上)
java·spring boot·后端