Redis 如何实现延时队列

ps:如果这篇帖子对于还在找工作和找实习的你有所帮助,可以关注我,给本贴点赞、评论、收藏并订阅专栏;同时不要吝啬您的花花

延时队列是一种特殊的消息队列,核心功能是让任务在指定延迟时间后被消费,广泛应用于订单超时取消、优惠券到期提醒、邮件定时发送、消息重试等场景。Redis凭借高性能、丰富的数据结构、原子性操作及持久化支持,成为实现延时队列的首选方案之一。以下详细介绍四种主流实现方法,结合原理、实现步骤、优缺点及适用场景,帮助开发者根据业务需求选择合适方案。

一、基于Sorted Set(有序集合)的延时队列(最常用)

核心原理

Sorted Set(有序集合)的核心特性是每个元素(member)关联一个分数(score),Redis会根据score自动对元素排序。利用这一特性,将任务执行时间戳作为score,任务内容作为member存入Sorted Set,消费者通过定时轮询,获取score≤当前时间戳的元素,即为到期可执行任务,执行完成后从集合中删除该任务,避免重复消费。

实现步骤

  1. 生产者添加任务:计算任务的执行时间戳(当前时间戳+延迟时间),将任务内容序列化(如JSON格式),通过ZADD命令将任务存入Sorted Set,score设为执行时间戳。
  2. 消费者消费任务:通过定时任务(如Java的ScheduledExecutorService)轮询,调用ZRANGEBYSCORE命令,获取score在0到当前时间戳之间的所有任务。
  3. 原子性保障:为避免多个消费者重复获取同一任务,可通过Lua脚本一次性完成“查询到期任务+删除任务”的操作,利用Redis的原子性特性杜绝并发问题。
  4. 任务处理:将获取到的任务反序列化,执行具体业务逻辑,完成后确认任务已删除(若未删除则补充删除)。

核心代码示例(Java+StringRedisTemplate)

// 1. 添加延时任务
public void addZSetDelayTask(String taskId, Object taskData, long delaySeconds) {
    long executeTime = System.currentTimeMillis() + delaySeconds * 1000;
    String taskStr = JSON.toJSONString(taskData);
    // ZADD key score member
    stringRedisTemplate.opsForZSet().add("delay_queue:zset", taskStr, executeTime);
}

// 2. 消费任务(Lua脚本保证原子性)
private static final String ZSET_CONSUME_LUA = "local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2]) " +
        "if #tasks > 0 then redis.call('ZREM', KEYS[1], unpack(tasks)) end return tasks";

public List<String> consumeZSetTasks(int batchSize) {
    long currentTime = System.currentTimeMillis();
    // 执行Lua脚本,批量获取并删除到期任务
    List<String> tasks = stringRedisTemplate.execute(
            new DefaultRedisScript<>(ZSET_CONSUME_LUA, List.class),
            Collections.singletonList("delay_queue:zset"),
            String.valueOf(currentTime), String.valueOf(batchSize)
    );
    // 处理任务逻辑(反序列化、业务执行)
    if (tasks != null && !tasks.isEmpty()) {
        tasks.forEach(task -> processTask(JSON.parseObject(task, Task.class)));
    }
    return tasks;
}

// 3. 定时轮询(每1秒执行一次)
public void startZSetConsumer() {
    ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
    scheduler.scheduleAtFixedRate(this::consumeZSetTasks, 0, 1, TimeUnit.SECONDS);
}
  • ZADD key score member:核心添加命令,将任务内容(member)与执行时间戳(score)存入有序集合,实现任务排序。示例:
  • ZRANGEBYSCORE key min max [LIMIT offset count]:获取score在[min, max]范围内的任务,即到期任务。示例:
  • ZREM key member1 [member2 ...]:删除已执行完成的任务,避免重复消费。示例:
  • Lua脚本原子执行:整合上述命令,避免并发问题,核心是通过Redis原子性特性,一次性完成“查询+删除”。

优缺点

  • 优点:实现简单、无需额外依赖,Redis原生命令支持,稳定性高;添加和查询操作时间复杂度为O(log N),性能较好;支持批量处理到期任务,适配中高并发场景;可通过RDB/AOF持久化,避免任务丢失。
  • 缺点:依赖定时轮询,实时性受轮询频率影响(如轮询间隔1秒,最大延迟可能为1秒);大量任务时,轮询会增加Redis和消费者的开销;无原生消费确认机制,需额外实现任务重试逻辑。

实时性保障说明(轮询优化方案)

Sorted Set依托ZRANGEBYSCORE定时轮询实现任务消费,虽无法达到Redisson毫秒级极致实时,但可通过精细化轮询优化,保障业务可接受的亚秒级/秒级实时性,彻底适配常规延时业务需求,具体优化手段如下:

  • 缩短轮询间隔:摒弃默认1秒长轮询,将轮询周期调至50ms-100ms,大幅缩小任务触发延迟,延迟上限可控制在百毫秒内,平衡实时性与性能开销。
  • 动态轮询频率:无到期任务时,自动拉长轮询间隔(如1秒),减少Redis无效查询;检测到到期任务后,立即缩至短间隔(如50ms),快速消化积压任务。
  • Lua原子批量操作:通过Lua脚本一次性完成ZRANGEBYSCORE查询+ZREM删除,省去多次网络往返和Redis命令执行耗时,单轮轮询效率提升30%以上。
  • 批量拉取任务:单次轮询批量拉取10-50条到期任务,避免单条轮询的频繁交互,进一步降低整体延迟。

核心结论:该方案的实时性完全满足订单取消、优惠券到期等常规业务,仅极端追求毫秒级实时的场景,才需要选用Redisson封装方案。

适用场景

订单超时取消、优惠券到期提醒、消息重试等对实时性要求不高(允许秒级延迟)、任务量中等的场景,是最通用的Redis延时队列实现方案。

二、基于List + 定时轮询的延时队列

核心原理

List是Redis的线性结构,支持LPUSH(左插)、RPOP(右取)等命令,适合实现普通队列,但本身不支持按时间排序。基于List的延时队列,需将任务与执行时间戳封装后存入List,消费者通过定时轮询List,遍历所有任务,筛选出执行时间戳≤当前时间的任务进行消费,未到期任务放回List尾部,循环等待。

优化方案:为减少遍历开销,可按延迟时间分级(如5秒、1分钟、5分钟),创建多个List(如delay_queue:5s、delay_queue:1m),将不同延迟时间的任务存入对应List,消费者按分级轮询,降低遍历压力。

实现步骤

  1. 定义任务实体:封装任务ID、任务内容、执行时间戳三个核心字段,序列化后存入List。
  2. 生产者添加任务:根据任务延迟时间,将序列化后的任务LPUSH到对应分级List(无分级则存入同一个List)。
  3. 消费者轮询:定时(如每1秒)从List头部取出任务,解析执行时间戳,若已到期则执行任务并删除;若未到期则RPUSH回List尾部,等待下一轮轮询。
  4. 异常处理:任务执行失败时,可将任务重新放回List,或存入重试队列,避免任务丢失。

优缺点

  • 优点:实现极简,无需复杂命令;分级后可降低遍历开销,适配少量、延迟分级明确的任务;List的LPUSH/RPOP操作时间复杂度为O(1),单队列读写性能高。
  • 缺点:未分级时,任务量较大时遍历效率极低(时间复杂度O(N));实时性依赖轮询频率,延迟明显;多个消费者并发轮询易导致任务重复消费(需额外加锁);无原生持久化保障(需依赖Redis全局持久化)。

适用场景

任务量少、延迟时间固定且分级明确的轻量场景,如简单的定时提醒、小型系统的任务延迟执行,不适合高并发、大量任务的场景。

三、基于Key过期通知(Pub/Sub)的延时队列

核心原理

Redis支持键空间通知(Keyspace Notifications),当某个Key过期时,Redis会发布一个过期事件到指定频道(如__keyevent@0__:expired)。利用这一特性,将任务内容作为Key的值,Key的过期时间设为任务的延迟时间,消费者订阅该过期事件频道,当Key过期时,接收事件并执行对应的任务逻辑。

实现步骤

  1. 配置Redis:修改redis.conf文件,设置notify-keyspace-events "Ex",开启Key过期事件通知(E表示键事件,x表示过期事件),重启Redis生效。
  2. 生产者添加任务:生成唯一Key(如task:123),将任务内容序列化后作为Key的值,通过SET命令设置Key的过期时间(即延迟时间)。
  3. 消费者订阅事件:通过Redis的Pub/Sub功能,订阅__keyevent@0__:expired频道(0表示Redis数据库编号),监听Key过期事件。
  4. 任务处理:消费者接收到过期事件后,获取过期Key的名称,根据Key解析任务信息(或从其他存储中获取任务详情),执行业务逻辑。

核心代码示例(Java)

// 1. 配置Redis监听器容器
@Configuration
public class RedisListenerConfig {
    @Bean
    public RedisMessageListenerContainer container(RedisConnectionFactory factory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);
        return container;
    }
}

// 2. 监听Key过期事件
@Service
public class KeyExpireListener extends KeyExpirationEventMessageListener {
    public KeyExpireListener(RedisMessageListenerContainer container) {
        super(container);
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        // 获取过期的Key
        String expiredKey = message.toString();
        // 解析任务信息(假设Key格式为task:taskId)
        if (expiredKey.startsWith("task:")) {
            String taskId = expiredKey.split(":")[1];
            // 执行任务逻辑
            processTask(taskId);
        }
    }
}

// 3. 添加延时任务
public void addKeyExpireTask(String taskId, Object taskData, long delaySeconds) {
    String key = "task:" + taskId;
    String taskStr = JSON.toJSONString(taskData);
    // SET key value EX 过期时间(秒)
    stringRedisTemplate.opsForValue().set(key, taskStr, delaySeconds, TimeUnit.SECONDS);
}
  • SET key value EX seconds:设置带过期时间的键值对,key为任务标识,value为任务内容,EX指定延迟时间(秒)。示例:
  • SUBSCRIBE __keyevent@0__:expired:订阅Redis键过期事件频道,0为Redis数据库编号,消费者监听过期任务的核心命令。
  • CONFIG SET notify-keyspace-events "Ex":临时开启键过期事件通知(永久开启需修改redis.conf),核心配置命令。

优缺点

  • 优点:实现简单,无需定时轮询,节省CPU资源;实时性较好(Key过期后触发事件,延迟取决于Redis的删除策略);无需额外维护任务队列,依赖Redis原生特性。
  • 缺点:Redis的过期事件是“事后通知”,Key过期后并非立即删除(受惰性删除、定期删除策略影响),可能存在延迟;事件可能丢失(如消费者断开连接期间,过期事件不会缓存);无法批量处理任务,且任务内容受Key值大小限制;所有Key过期都会触发事件,需通过Key前缀过滤,易造成干扰。

适用场景

对实时性要求一般、任务量少、无需批量处理的轻量场景,如简单的定时清理、临时任务延迟执行,不适合高可靠、高并发的核心业务。

四、基于Redisson封装的延时队列(最推荐分布式场景)

核心原理

Redisson是Redis的Java客户端,提供了丰富的分布式工具类,其中RDelayedQueue是专门用于实现延时队列的封装类,底层基于Sorted Set和Pub/Sub实现,自动处理了任务的添加、过期监听、消费、重试、持久化等细节,无需开发者手动实现轮询和原子性保障,简化开发流程。

核心机制:RDelayedQueue将任务添加到Sorted Set中,同时通过Pub/Sub监听任务过期,任务到期后自动移动到目标队列(RQueue),消费者从目标队列中获取任务执行,支持分布式部署、任务重试、持久化等高级特性。

实现步骤

  1. 引入依赖:在项目中引入Redisson依赖(如Maven),配置Redis连接信息(地址、端口、密码等)。
  2. 初始化Redisson客户端:创建RedissonClient实例,配置Redis连接参数。
  3. 创建延时队列:通过RedissonClient获取RQueue(目标队列)和RDelayedQueue(延时队列),将RDelayedQueue与RQueue绑定。
  4. 生产者添加任务:调用RDelayedQueue的offer方法,指定任务内容和延迟时间。
  5. 消费者消费任务:从RQueue中通过take(阻塞获取)或poll(非阻塞获取)方法获取到期任务,执行业务逻辑。

核心代码示例(Java)

// 1. 配置Redisson客户端
@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379")
                .setPassword("123456")
                .setDatabase(0);
        return Redisson.create(config);
    }
}

// 2. 实现延时队列
@Service
public class RedissonDelayQueueService {
    @Autowired
    private RedissonClient redissonClient;

    // 初始化目标队列和延时队列
    private RQueue<Task> targetQueue = redissonClient.getQueue("delay_queue:target");
    private RDelayedQueue<Task> delayedQueue = redissonClient.getDelayedQueue(targetQueue);

    //  添加延时任务
    public void addRedissonDelayTask(Task task, long delaySeconds) {
        // offer(任务, 延迟时间, 时间单位)
        delayedQueue.offer(task, delaySeconds, TimeUnit.SECONDS);
    }

    // 消费任务(阻塞式获取,无任务时阻塞)
    public void startRedissonConsumer() {
        new Thread(() -> {
            while (true) {
                try {
                    // take() 阻塞获取到期任务
                    Task task = targetQueue.take();
                    // 执行任务逻辑
                    processTask(task);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }).start();
    }
}

核心Redis命令

// Redisson底层依赖以下Redis命令,自动封装执行,无需手动调用
// 1. ZADD:将任务存入有序集合,用于任务排序和过期判断(同Sorted Set方案)
// 2. ZRANGEBYSCORE:查询到期任务,触发任务从延时队列向目标队列移动
// 3. ZREM:删除已处理的过期任务,避免重复消费
// 4. SUBSCRIBE:订阅键过期事件,实现任务到期实时触发
// 5. LPUSH/RPOP:目标队列底层基于List,用于存储和获取已到期任务

  • ZADD:Redisson底层将任务存入有序集合,命令同Sorted Set方案,用于任务排序和过期判断。
  • ZRANGEBYSCORE:底层用于查询到期任务,触发任务从延时队列向目标队列移动。
  • ZREM:删除已处理的过期任务,避免重复消费。
  • SUBSCRIBE:底层通过订阅键过期事件,实现任务到期的实时触发(无需手动轮询)。
  • LPUSH/RPOP:目标队列(RQueue)底层基于List实现,用于存储和获取已到期的任务。

核心答疑(实时性+事件监听)

  • 该方案能否保证实时性?可以保证高实时性,远优于原生Sorted Set纯轮询方案。Redisson采用“定时轮询+Pub/Sub订阅”双触发机制:既通过定时任务兜底扫描到期任务,又通过订阅机制实时感知任务到期信号,大幅缩短任务触发延迟,基本做到毫秒级实时性,彻底解决纯轮询的间隔延迟问题。
  • Redisson延迟为什么低?(核心原因)推送替代无效轮询:摒弃原生Sorted Set固定间隔轮询的弊端,不做无意义的Redis查询,任务到期后通过内部Pub/Sub频道主动推送信号,消费者无需反复轮询,触发零等待。双机制兜底无死角:订阅推送负责毫秒级实时触发,轻量定时轮询做兜底保障,杜绝极端情况下推送丢失导致的延迟,兼顾实时性与可靠性。Lua原子化操作:底层用Lua脚本一次性完成ZRANGEBYSCORE查询+ZREM删除+队列转移,无多次网络往返和命令执行耗时,效率拉满。阻塞式消费:消费者通过take()方法阻塞等待任务,任务一到目标队列立即获取,无空闲等待损耗。精准事件监听:订阅自身封装的任务到期频道,而非Redis原生Key过期事件,不受Redis惰性删除、定期删除的延迟影响,判断精准无滞后。
  • ZREM删除过期任务的作用?Redisson底层通过Lua脚本原子执行ZRANGEBYSCORE+ZREM,查询到到期任务后立即用ZREM从延时有序集合中删除,彻底杜绝多消费者重复消费、任务重复执行的问题;删除操作是任务流转的核心环节,删除后任务才会被转移至目标消费队列。
  • SUBSCRIBE订阅能监听到任务过期吗?可以精准监听,但并非监听Redis原生Key过期事件。Redisson的SUBSCRIBE订阅的是自身封装的任务到期内部频道,而非Redis自带的__keyevent@0__:expired频道;底层基于任务时间戳判断到期,而非依赖Redis Key删除机制,因此监听稳定、不会出现事件丢失、延迟触发的问题,可靠性远高于原生Key过期通知。

优缺点

  • 优点:封装完善,无需手动处理轮询、原子性、事件监听等细节,开发效率高;支持分布式部署,多消费者可并发消费,自动实现负载均衡;支持任务重试、持久化、过期时间精确控制;实时性好,任务到期后立即触发消费。
  • 缺点:依赖Redisson框架,增加项目依赖;底层基于Sorted Set,大量任务时仍存在一定的性能开销;配置相对复杂(需配置Redisson客户端)。

适用场景

分布式系统、高并发、高可靠的核心业务场景,如分布式任务调度、订单超时取消、分布式消息重试等,是企业级开发中最推荐的Redis延时队列实现方案。

五、四种方法对比总结

Sorted Set

实现简单、性能好、支持批量、原生支持

依赖轮询、实时性受轮询间隔影响

中高并发、任务量中等、对实时性要求不高

List + 定时轮询

极简、单队列读写性能高

遍历效率低、易重复消费、不适合大量任务

任务量少、延迟分级明确的轻量场景

Key过期通知

无需轮询、节省CPU、实现简单

事件可能丢失、延迟不确定、受Key大小限制

轻量场景、简单定时任务、对可靠性要求低

Redisson封装

开发高效、支持分布式、高可靠、实时性好

依赖Redisson、配置复杂

分布式系统、高并发、核心业务场景

总结:实际开发中,优先选择Sorted Set(原生、通用)或Redisson封装(分布式、高可靠)方案;轻量场景可考虑Key过期通知;List+定时轮询仅适用于极简单的少量任务场景。同时需根据业务的实时性、可靠性、并发量需求,结合Redis的持久化配置,确保任务不丢失、不重复消费。

ps:如果这篇帖子对于还在找工作和找实习的你有所帮助,可以关注我,给本贴点赞、评论、收藏并订阅专栏;同时不要吝啬您的花花

Redis常用的数据结构 文章被收录于专栏

Redis 作为高性能键值数据库,核心在于丰富的数据结构。本专栏聚焦String、Hash、List、Set、ZSet、Bitmap、HyperLogLog 等常用类型,从底层原理、使用场景到实战示例,清晰讲解每种结构的优缺点与最佳实践。帮你快速掌握如何用对数据结构,提升缓存、限流、排行榜、消息队列等业务场景的开发效率,写出更稳定、高效的 Redis 应用。

全部评论

相关推荐

评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务