导读:本期聚焦于落伍者创作的《Redis实现分布式定时任务调度怎么做?几种方案对比与实战代码详解》,敬请观看详情。定时任务在单体应用里用Spring的@Scheduled或者Linux的crontab就能搞定,可一旦服务扩容到多台机器,问题就来了:同一个任务被多个实例重复执行、任务无法均匀分摊、某一台宕机任务就中断。把Redis引入到调度体系中,借助它的原子操作、过期键通知以及Lua脚本能力,可以低成本地搭出一套分布式定时任务方案。本文将围绕四种常见思路展开:基于分布式锁抢占执行、基于ZSet按时间戳排序的延迟队列、基于Redis过期事件的监听触发,以及红锁与看门狗机制的处理细节,每种方案都配有可直接运行的代码示例和适用场景分析,帮助读者根据业务量级和可靠性要求选择合适的实现方式。

定时任务是业务系统里绕不开的需求,订单超时关闭、报表定时生成、缓存预热、消息推送,几乎所有项目都会用到。单个实例的时候,Spring自带的@Scheduled或者Quartz就能满足需求,但部署两个以上实例后,任务会被每个实例各跑一遍,数据重复写入、通知重复发送的问题立刻暴露出来。Redis作为大多数项目里已有的中间件,天然适合拿来解决这个问题,它单线程的命令执行模型保证了操作的原子性,配合分布式锁、有序集合等结构,可以搭建出多种风格的分布式调度方案,不需要额外引入新的调度平台。

Redis实现分布式定时任务调度怎么做?几种方案对比与实战代码详解

方案一:基于分布式锁的任务抢占

这是最容易理解的一种思路:所有实例都按相同的cron表达式触发任务,但执行前先去Redis抢一把锁,谁抢到谁执行,没抢到的实例直接放弃本次执行。由于Redis的SET key value NX EX seconds命令是原子的,同一个key只会被一个客户端设置成功,天然解决了并发抢占问题。

实现时需要注意锁的value要写入唯一标识,一般用UUID加线程ID,目的是释放锁的时候校验是不是自己加的锁,避免误删别的实例的锁。释放操作必须用Lua脚本来保证"判断加删除"的原子性,如果先GET再DEL,两步之间锁可能恰好过期被别人抢走,删除就会出事故。下面是一段完整的示例代码:

public class RedisDistributedLock {

    private final StringRedisTemplate redisTemplate;
    private static final String UNLOCK_SCRIPT =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "    return redis.call('del', KEYS[1]) " +
            "else " +
            "    return 0 " +
            "end";

    public String tryLock(String lockKey, long expireSeconds) {
        String requestId = UUID.randomUUID().toString();
        Boolean ok = redisTemplate.opsForValue().setIfAbsent(
                lockKey, requestId, expireSeconds, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(ok) ? requestId : null;
    }

    public boolean unlock(String lockKey, String requestId) {
        Long result = redisTemplate.execute(
                new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
                Collections.singletonList(lockKey), requestId);
        return result != null && result == 1;
    }
}

// 定时任务入口,多实例部署时只有一个能真正执行
@Scheduled(cron = "0 0 2 * * ?")
public void generateDailyReport() {
    String requestId = lock.tryLock("lock:report:job", 300);
    if (requestId == null) {
        return; // 其他实例已抢到锁,本次直接跳过
    }
    try {
        doGenerateReport();
    } finally {
        lock.unlock("lock:report:job", requestId);
    }
}

这种方案的优点是改造成本低,原任务逻辑几乎不动,加一层锁判断即可。缺点是任务执行时间必须可控,锁的过期时间要是设置得比任务执行时间短,锁提前释放会导致另一个实例也进入执行;设置得太长,实例崩溃后锁要等很久才释放。比较稳妥的做法是起一个后台线程定期给锁续期,也就是常说的看门狗机制,Redisson框架已经内置了实现,直接使用RLock即可,业务量不大时这是性价比最高的选择。

方案二:基于ZSet的延迟任务队列

抢占式锁解决的是"多实例只跑一次"的问题,但如果需求是"在未来某个时间点执行某件事",比如订单30分钟未支付自动关闭,用cron轮询数据库效率很低,更适合用Redis的有序集合来做一个延迟队列。核心思路是把任务ID作为member,到期时间戳作为score存入ZSet,然后由一个后台线程不断取出score小于当前时间的任务执行。

取任务这一步同样要考虑原子性。如果先ZRANGEBYSCORE查询再ZREM删除,两个消费者可能同时查到同一条任务。解决办法是用Lua脚本把"查询加删除"合并成原子操作,脚本如下:

// 生产者:添加延迟任务
public void addDelayTask(String taskId, long executeAtMillis) {
    redisTemplate.opsForZSet().add(
            "delay:queue:order", taskId, executeAtMillis);
}

// 消费脚本:原子地取出到期的任务
private static final String POLL_SCRIPT =
        "local tasks = redis.call('zrangebyscore', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 10) " +
        "if #tasks > 0 then " +
        "    redis.call('zrem', KEYS[1], unpack(tasks)) " +
        "end " +
        "return tasks";

// 消费者:每秒轮询一次
@Scheduled(fixedDelay = 1000)
public void pollDelayTasks() {
    List<Object> tasks = redisTemplate.execute(
            new DefaultRedisScript<>(POLL_SCRIPT, List.class),
            Collections.singletonList("delay:queue:order"),
            String.valueOf(System.currentTimeMillis()));
    if (tasks != null) {
        for (Object taskId : tasks) {
            executeTask((String) taskId);
        }
    }
}

这套方案的关键细节有两个。一是取出任务后如果进程崩溃,任务就丢了,所以稳妥的做法是先把任务搬到另一个processing的ZSet或者List里,执行成功再真正删除,失败或者超时则由恢复逻辑重新入队。二是轮询间隔决定了任务触发的精度,每秒轮询任务误差就在一秒左右,对于订单关闭这类场景完全够用。ZSet方案的优势在于时间精度高、支持任意的延迟时长,任务量大时还可以按业务拆分多个queue,由不同实例消费不同的key,实现负载分摊。

方案三:过期键事件监听与选型建议

Redis从2.8开始支持键空间通知,给key设置过期时间后,可以在过期事件中被订阅到。有些团队会用这个特性做定时任务:把任务数据写入String并设置TTL,客户端订阅__keyevent@0__:expired频道,收到事件就执行任务。写法上非常简洁:

// 添加任务,60秒后过期触发
redisTemplate.opsForValue().set("task:notify:1001", "payload", 60, TimeUnit.SECONDS);

// 监听过期事件
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory factory) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener((message, pattern) -> {
        String key = new String(message.getBody(), StandardCharsets.UTF_8);
        handleExpiredTask(key);
    }, new ChannelTopic("__keyevent@0__:expired"));
    return container;
}

但必须提醒的是,这种方案在生产环境要谨慎使用。过期事件的推送是尽力而为的,Redis惰性删除加定期删除的机制决定了事件可能延迟才发出;事件里只携带key不携带value,任务内容需要二次查询;如果订阅端断线,错过的通知不会补发。所以它只适合对时效要求宽松、允许少量丢失的场景,比如缓存失效后的回源提醒,核心业务不要依赖它。

综合来看,三种方案各有定位:任务触发时间固定、只需防止重复执行,用分布式锁加看门狗,直接上Redisson最省心;任务延迟时长不固定、量大且要求可靠,用ZSet延迟队列,配合processing队列保证不丢;轻量级的延迟提醒可以偶尔用键空间通知。如果业务规模继续增长,需要任务分片、失败重试、控制台管理这些完整能力,再考虑引入XXL-JOB或者Elastic-Job这类专业调度平台。对于多数中小项目来说,Redis加上前两种方案的组合,已经能覆盖百分之九十的定时任务需求,实现简单、依赖少、排查问题也直观。

Redis分布式定时任务任务调度修改时间:2026-09-04 06:42:42

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260904/50077.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。