Contents

延迟队列的四种实现方案:从数据库轮询到 Redis 时间轮

延迟任务无处不在

下完订单 30 分钟未支付,自动关闭。优惠券到期前 1 小时,推送提醒。注册后 3 天未登录,发送召回邮件。这些场景的本质是同一个问题:在某个未来时间点执行一个任务

延迟队列的难点不在于"能不能实现",而在于在高并发下既不丢任务、又不重复执行,同时保持可接受的资源开销。下面从最简单的方案讲起,逐步演进。

方案一:数据库轮询

最直觉的做法——把延迟任务存到数据库表里,起一个定时任务每秒扫一遍。

1
2
3
4
5
6
7
8
9
CREATE TABLE delay_tasks (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_type   VARCHAR(64) NOT NULL,
    payload     TEXT NOT NULL,
    execute_at  DATETIME NOT NULL,
    status      TINYINT DEFAULT 0,  -- 0=待执行 1=执行中 2=已完成
    created_at  DATETIME DEFAULT NOW(),
    INDEX idx_execute_at (execute_at, status)
);

消费逻辑用 Go 实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func PollTasks(db *sql.DB) {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    for range ticker.C {
        rows, err := db.Query(`
            SELECT id, task_type, payload 
            FROM delay_tasks 
            WHERE execute_at <= NOW() AND status = 0 
            LIMIT 50`)
        if err != nil {
            log.Printf("query error: %v", err)
            continue
        }
        var tasks []Task
        for rows.Next() {
            var t Task
            rows.Scan(&t.ID, &t.Type, &t.Payload)
            tasks = append(tasks, t)
        }
        rows.Close()

        for _, t := range tasks {
            // 用 UPDATE 抢占任务,防止多实例重复执行
            res, _ := db.Exec(
                `UPDATE delay_tasks SET status = 1 WHERE id = ? AND status = 0`, t.ID)
            if n, _ := res.RowsAffected(); n > 0 {
                go executeTask(t)
            }
        }
    }
}

优点:实现简单,事务保证强,不丢数据。

缺点:轮询空转浪费资源,数据库压力大;精度受轮询间隔限制;扩展性差。

适用场景:任务量小(<1000/秒),精度要求秒级,团队不想引入额外中间件。

方案二:Redis ZSet 有序集合

用 Redis 的 ZSet,把任务执行时间作为 score,消费端不断拉取 score <= 当前时间的任务。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type RedisDelayQueue struct {
    client *redis.Client
    key    string
}

func (q *RedisDelayQueue) Push(taskID string, executeAt time.Time) error {
    return q.client.ZAdd(q.key, redis.Z{
        Score:  float64(executeAt.Unix()),
        Member: taskID,
    }).Err()
}

func (q *RedisDelayQueue) Consume() {
    for {
        now := time.Now().Unix()
        // 取出所有到期的任务
        members, err := q.client.ZRangeByScore(q.key, &redis.ZRangeBy{
            Min:   "0",
            Max:   strconv.FormatInt(now, 10),
            Count: 50,
        }).Result()
        if err != nil || len(members) == 0 {
            time.Sleep(500 * time.Millisecond)
            continue
        }
        for _, member := range members {
            // 原子删除:只有第一个删成功的才会执行
            n, err := q.client.ZRem(q.key, member).Result()
            if err == nil && n > 0 {
                go executeTask(member)
            }
        }
    }
}

关键在于 ZRem 的原子性——多个消费者同时抢同一个任务,只有 ZRem 返回 1 的那个才会真正执行,天然防重复。

优点:内存操作极快,单机支撑 10 万+ QPS;ZRem 原子保证不重复执行。

缺点:断电丢数据(可通过 AOF 持久化缓解但无法完全避免);任务执行失败需要手动重试机制。

适用场景:任务量大、允许极小概率丢失、已有 Redis 基础设施。

方案三:Redis + Lua 脚本(解决并发竞争)

方案二在超高并发下,ZRangeByScoreZRem 之间存在一个窗口——多个消费者可能同时拿到同一个任务,然后都尝试 ZRem。虽然最终只有一个成功,但无效的竞争白白浪费资源。

用 Lua 脚本将"查询 + 删除"合为一个原子操作:

1
2
3
4
5
6
7
8
-- KEYS[1] = 延迟队列的 ZSet key
-- ARGV[1] = 当前时间戳
-- ARGV[2] = 一次取多少个
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '0', ARGV[1], 'LIMIT', 0, ARGV[2])
if #tasks > 0 then
    redis.call('ZREM', KEYS[1], unpack(tasks))
end
return tasks

Go 调用代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var consumeScript = redis.NewScript(`
    local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '0', ARGV[1], 'LIMIT', 0, ARGV[2])
    if #tasks > 0 then
        redis.call('ZREM', KEYS[1], unpack(tasks))
    end
    return tasks
`)

func (q *RedisDelayQueue) ConsumeWithLua() {
    for {
        now := strconv.FormatInt(time.Now().Unix(), 10)
        result, err := consumeScript.Do(q.client, q.key, now, 50).Result()
        if err != nil {
            log.Printf("lua error: %v", err)
            time.Sleep(500 * time.Millisecond)
            continue
        }
        tasks := result.([]interface{})
        if len(tasks) == 0 {
            time.Sleep(500 * time.Millisecond)
            continue
        }
        for _, t := range tasks {
            go executeTask(t.(string))
        }
    }
}

Lua 脚本在 Redis 单线程内执行,天然原子,彻底消除竞争窗口。

优点:无竞争开销,吞吐更高。

缺点:Lua 脚本会阻塞 Redis 线程,如果单次取太多任务会影响其他命令;Redis Cluster 下需要按 hash tag 路由到同一分片。

方案四:分层时间轮

对于海量延迟任务(百万级),无论数据库还是 Redis ZSet 都会遇到瓶颈。时间轮是一种 O(1) 复杂度的经典数据结构,Kafka 和 Netty 内部都在用。

核心思路:想象一个时钟,有 N 个格子。任务按延迟时间的取模放入对应格子,指针每 tick(如 100ms)前进一格,执行格子里的所有任务。

1
2
3
4
5
6
7
8
分层时间轮结构:
  [0] [1] [2] [3] [4] [5] [6] [7] ... [59]   ← 秒级轮(60格)
   |
   +-- 超过60秒的任务,升级到分钟轮
  [0] [1] [2] ... [59]                        ← 分钟轮(60格)
   |
   +-- 超过60分钟的任务,升级到小时轮
  [0] [1] ... [23]                            ← 小时轮(24格)

简化版 Go 实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
type TimerTask struct {
    executeAt int64
    fn       func()
}

type TimeWheel struct {
    slots    [][]*TimerTask
    curSlot  int
    tickDur  time.Duration
    slotNum  int
    mu       sync.Mutex
}

func NewTimeWheel(tick time.Duration, slots int) *TimeWheel {
    tw := &TimeWheel{
        slots:   make([][]*TimerTask, slots),
        tickDur: tick,
        slotNum: slots,
    }
    return tw
}

func (tw *TimeWheel) Add(delay time.Duration, fn func()) {
    tw.mu.Lock()
    defer tw.mu.Unlock()
    ticks := int(delay / tw.tickDur)
    slot := (tw.curSlot + ticks) % tw.slotNum
    tw.slots[slot] = append(tw.slots[slot], &TimerTask{
        executeAt: time.Now().Add(delay).Unix(),
        fn:        fn,
    })
}

func (tw *TimeWheel) Start() {
    ticker := time.NewTicker(tw.tickDur)
    defer ticker.Stop()
    for range ticker.C {
        tw.mu.Lock()
        tw.curSlot = (tw.curSlot + 1) % tw.slotNum
        tasks := tw.slots[tw.curSlot]
        tw.slots[tw.curSlot] = nil
        tw.mu.Unlock()

        for _, t := range tasks {
            if time.Now().Unix() >= t.executeAt {
                go t.fn()
            }
        }
    }
}

优点:O(1) 入队和出队,内存开销小,适合百万级任务。

缺点:单机内存方案,宕机丢数据;实现复杂度高(完整分层时间轮涉及任务降级逻辑);精度受 tick 粒度限制。

适用场景:超大规模延迟任务、内存可接受丢失、追求极致性能。

选型对比

方案 精度 吞吐量 可靠性 实现复杂度 适用场景
数据库轮询 秒级 任务少、强一致
Redis ZSet 毫秒级 通用首选
Redis + Lua 毫秒级 很高 中高 超高并发
时间轮 tick级 极高 海量任务、内存态

实践建议

  1. 大多数场景选 Redis ZSet 方案:在性能和可靠性之间取得了最佳平衡,社区成熟,踩坑少。

  2. 务必实现失败重试:任务从队列取出后如果执行失败,需要写回队列或转入死信队列。可以用独立的 retry ZSet,score 为下次重试时间。

  3. 监控队列积压:用 ZCARD 检查队列长度,设置告警阈值。积压说明消费者处理不过来或任务执行时间过长。

  4. 幂等设计:无论哪种方案,任务都可能被重复投递(比如服务重启后重新加载),消费者必须做幂等处理——用唯一 ID + 去重表或 Redis SETNX。

  5. 优雅关闭:消费协程要响应 context.Done(),关闭前处理完已拉取的任务,避免丢弃。

延迟队列没有银弹,选型时先看可靠性和吞吐量要求,再看团队技术栈,最后看运维成本。简单的数据库轮询在 80% 的场景下就够用了,别过度设计。