定时任务是业务系统里绕不开的需求,订单超时关闭、报表定时生成、缓存预热、消息推送,几乎所有项目都会用到。单个实例的时候,Spring自带的@Scheduled或者Quartz就能满足需求,但部署两个以上实例后,任务会被每个实例各跑一遍,数据重复写入、通知重复发送的问题立刻暴露出来。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加上前两种方案的组合,已经能覆盖百分之九十的定时任务需求,实现简单、依赖少、排查问题也直观。