一、为什么不用 Redis ZSet 的 Simple 方案?
很多教程会教你用 ZADD 添加分数(时间戳),然后用 ZRANGEBYSCORE 轮询。这在生产环境有一个致命问题:CPU空转与惊群效应。
- Simple方案:每隔100ms全量扫描Key,即使没有数据也会执行。
- 生产级方案:使用 Redis Sorted Set + List + Pub/Sub,结合 Time Wheel (时间轮) 思想,大幅降低Redis IO压力。
二、核心设计:分级时间轮 (Hierarchical Time Wheels)
我们将延迟时间分为两级:
- 近实时轮 (Near-time Wheel):处理接下来1小时内的任务,精度秒级。
- 远时轮 (Far-time Wheel):处理超过1小时的任务,精度分钟级。
数据结构设计:
delay:near:{slot}(Sorted Set): score为时间戳,member为jobId。delay:far:{slot}(List): 存储序列化后的Job数据。delay:bucket(List): 暂存区,用于原子化迁移数据。delay:processing(Hash): 正在消费的消息,用于ACK确认。
三、生产级代码实现
1. Maven依赖
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.4.3</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency></dependencies>
2. 核心配置类 (RedisConfig)
为了生产级性能,必须配置连接池和序列化方式。
@Configurationpublic class RedisDelayQueueConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // 使用Jackson2JsonRedisSerializer替换默认的JDK序列化 Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper mapper = new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL); serializer.setObjectMapper(mapper); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } @Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setPassword("your_password") .setDatabase(0) .setConnectionPoolSize(64) .setConnectionMinimumIdleSize(10); return Redisson.create(config); } }
3. 延迟任务实体 (DelayJob)
@Data@AllArgsConstructor@NoArgsConstructorpublic class DelayJob implements Serializable { private static final long serialVersionUID = 1L; /** * 任务ID (全局唯一) */ private String jobId; /** * 主题 (业务类型,如 ORDER_CLOSE, SMS_SEND) */ private String topic; /** * 延迟时间 (时间戳,毫秒) */ private long delayTime; /** * 消息体 */ private String body; /** * 重试次数 */ private int retryCount; /** * 最大重试次数 */ private int maxRetry; }
4. 生产者:投递延迟消息
这里使用了 Lua脚本 保证原子性,这是生产级代码的标配。
@Component@Slf4jpublic class DelayProducer { @Autowired private RedisTemplate<String, Object> redisTemplate; private static final String DELAY_NEAR_PREFIX = "delay:near:"; private static final String DELAY_FAR_PREFIX = "delay:far:"; // Lua脚本:防止重复投递 private static final String PUSH_LUA = "if redis.call('EXISTS', KEYS[1]) == 0 then " + " redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2]) " + " return 1 " + "else " + " return 0 " + "end"; private DefaultRedisScript<Long> redisScript; @PostConstruct public void init() { redisScript = new DefaultRedisScript<>(); redisScript.setScriptText(PUSH_LUA); redisScript.setResultType(Long.class); } /** * 投递延迟任务 */ public boolean send(DelayJob job) { long now = System.currentTimeMillis(); long delaySeconds = job.getDelayTime() / 1000; String key; if (delaySeconds <= now + 3600) { // 1小时内进近时轮 key = DELAY_NEAR_PREFIX + (delaySeconds % 60); // 按分钟分槽 Long result = redisTemplate.execute(redisScript, Collections.singletonList(key), String.valueOf(job.getDelayTime()), JSON.toJSONString(job)); return result != null && result == 1; } else { // 超过1小时进远时轮 key = DELAY_FAR_PREFIX + (delaySeconds / 60 % 60); // 按小时分槽 redisTemplate.opsForList().rightPush(key, job); return true; } } }
5. 消费者:时间轮驱动与消息处理
这是最核心的部分。我们需要一个后台线程不断扫描时间轮,并将到期的任务转移到就绪队列。
@Component@Slf4jpublic class DelayConsumer implements ApplicationRunner { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private DelayProducer producer; private static final String READY_QUEUE = "delay:ready"; private static final String PROCESSING_HASH = "delay:processing"; private static final String NEAR_PREFIX = "delay:near:"; // 定时任务线程池 private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4); @Override public void run(ApplicationArguments args) { // 启动近时轮扫描器 scheduler.scheduleAtFixedRate(this::scanNearTimeWheel, 0, 1, TimeUnit.SECONDS); // 启动远时轮迁移器 scheduler.scheduleAtFixedRate(this::migrateFarToNear, 0, 30, TimeUnit.SECONDS); // 启动消息处理器 scheduler.scheduleAtFixedRate(this::handleReadyMessages, 0, 500, TimeUnit.MILLISECONDS); log.info("Delay queue consumers started..."); } /** * 扫描近时轮 */ private void scanNearTimeWheel() { long now = System.currentTimeMillis(); int slot = (int) (now / 1000 % 60); String key = NEAR_PREFIX + slot; try { // 获取所有到期的任务 Set<String> jobs = redisTemplate.opsForZSet() .rangeByScore(key, 0, now); if (jobs != null && !jobs.isEmpty()) { for (String jobStr : jobs) { DelayJob job = JSON.parseObject(jobStr, DelayJob.class); // 原子化移动到就绪队列 moveToReadyQueue(job, key, jobStr); } } } catch (Exception e) { log.error("Scan near time wheel error", e); } } /** * 原子化移动任务到就绪队列 (Lua脚本) */ private static final String MOVE_TO_READY_LUA = "if redis.call('ZREM', KEYS[1], ARGV[1]) == 1 then " + " redis.call('LPUSH', KEYS[2], ARGV[2]) " + " return 1 " + "else " + " return 0 " + "end"; private void moveToReadyQueue(DelayJob job, String zsetKey, String jobStr) { DefaultRedisScript<Long> script = new DefaultRedisScript<>(MOVE_TO_READY_LUA, Long.class); Long result = redisTemplate.execute(script, Arrays.asList(zsetKey, READY_QUEUE), jobStr, jobStr); if (result != null && result == 1) { log.debug("Job moved to ready queue: {}", job.getJobId()); } } /** * 处理就绪队列中的消息 */ private void handleReadyMessages() { try { // 阻塞式弹出,防止CPU空转 List<Object> objs = redisTemplate.executePipelined((RedisCallback<Object>) connection -> { connection.listCommands().bLPop(2, READY_QUEUE.getBytes()); return null; }); if (objs != null && !objs.isEmpty()) { Object raw = objs.get(0); if (raw instanceof byte[]) { DelayJob job = JSON.parseObject((byte[]) raw, DelayJob.class); processJob(job); } } } catch (Exception e) { log.error("Handle ready messages error", e); } } /** * 具体的业务逻辑处理 */ private void processJob(DelayJob job) { String lockKey = "lock:delay:" + job.getJobId(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { log.info("Processing job: {}, Topic: {}", job.getJobId(), job.getTopic()); // TODO: 这里是你的具体业务代码 // 例如:检查订单状态,如果未支付则关闭 boolean success = doBusinessLogic(job); if (!success && job.getRetryCount() < job.getMaxRetry()) { // 重试逻辑:指数退避 job.setRetryCount(job.getRetryCount() + 1); long nextDelay = (long) (Math.pow(2, job.getRetryCount()) * 1000); job.setDelayTime(System.currentTimeMillis() + nextDelay); producer.send(job); } } finally { redisTemplate.delete(lockKey); } } } private boolean doBusinessLogic(DelayJob job) { // 模拟业务处理 return true; } /** * 将远时轮任务迁移到近时轮 */ private void migrateFarToNear() { // ... 省略具体实现,逻辑类似scanNearTimeWheel,但针对delay:far:*进行操作 log.debug("Migrating far time wheel tasks..."); } }
6. 优雅停机与健康检查
生产环境必须考虑JVM退出时的任务回收,否则会导致消息丢失。
@Component@Slf4jpublic class DelayShutdownHook { @PreDestroy public void destroy() { log.info("Shutting down delay queue consumer..."); // 1. 停止接收新任务 // 2. 等待当前正在处理的任务完成 (通常需要一个计数器) // 3. 将processing中的任务重新放回delay队列 recoverProcessingJobs(); log.info("Delay queue shutdown completed."); } private void recoverProcessingJobs() { // 读取 processing hash // 遍历并重新投递 } }
四、生产环境优化建议 (大牛视角)
- 幂等性设计:
- 所有的消费者逻辑必须支持幂等。因为网络抖动或重试机制,同一条消息可能会被处理两次。建议使用
jobId作为唯一键,配合数据库的唯一索引或Redis的SETNX。
- 内存与持久化:
- AOF: 务必开启
appendfsync everysec,防止Redis宕机导致大量延迟任务丢失。 - Key过期: 对于
processingHash中的字段,设置合理的TTL,防止死信堆积。
- 监控告警:
- 监控
delay:ready的长度。如果长度持续增长,说明消费能力不足,需要扩容消费者。 - 监控
delay:processing的长度。如果长度过大,说明有大量任务处理超时或被卡住。
- 集群模式下的分布式锁:
- 本文使用的是单Redis实例的简单锁。在生产集群环境中,强烈建议使用 Redisson 的
RLock(红锁)或 Zookeeper 来实现分布式选主,确保同一时间只有一个消费者线程在执行scanNearTimeWheel,避免资源浪费和惊群效应。
五、总结
这套方案相比简单的 ZRANGEBYSCORE,引入了时间轮分槽和双缓冲队列,极大地降低了Redis的CPU负载。同时,通过 Lua脚本 保证了操作的原子性,通过 分布式锁 和 重试机制 保证了消息的可靠性。
在高并发场景下(如QPS 10k+),此架构已在多个生产项目中验证稳定。如果你面临更高的吞吐需求,可以考虑将 Ready Queue 替换为 Kafka,由 Redis 负责调度,Kafka 负责削峰填谷。
本文由 摸鱼不慌 发布,转载请注明出处。