我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭------ZSet 延迟队列 Go 实现](#Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现)
-
- 一、现象:每分钟一次的全表扫描
- [二、为什么是 ZSet 而不是 List](#二、为什么是 ZSet 而不是 List)
- [三、Go 实操:完整延迟队列(收藏点 1)](#三、Go 实操:完整延迟队列(收藏点 1))
- [四、关键设计决策(收藏点 2)](#四、关键设计决策(收藏点 2))
- 五、命令清单
- 六、排查步骤:延迟任务没执行
- 七、总结
Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭------ZSet 延迟队列 Go 实现
上一篇《Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测》我们搞定了计数与去重,链接:https://blog.csdn.net/2501_92769340/article/details/167399787 。
本篇处理"到点执行"类需求:订单超时未支付要自动关闭、红包到期要退回、秒杀结束后要结算。
一、现象:每分钟一次的全表扫描
第一版超时关单方案:定时任务每分钟跑一次 SELECT * FROM orders WHERE status='unpaid' AND created_at < NOW() - INTERVAL 30 MINUTE。三个问题:
- 大表上这个查询越来越慢,索引压力大;
- 扫描周期 = 关单精度(分钟级,想秒级就得每秒扫);
- 无效扫描:90% 的时间扫出来是 0 条(大部分订单要么付了要么在 30 分钟内)。
正确姿势:事件驱动------订单创建时就往延迟队列里丢一条"30 分钟后关闭我"的任务,到点消费,没有轮询、没有扫描、精度秒级。
二、为什么是 ZSet 而不是 List
| 方案 | 到期感知 | 支持取消 | 复杂度 |
|---|---|---|---|
| List + 定时全量检查 | 靠轮询 | 要遍历删 | O(N) |
| ZSet(score=执行时间戳) | ZRANGEBYSCORE ≤ now 一次捞出全部到期任务 |
ZREM 精确取消 |
O(logN+M) |
| Redis key 空间事件(EXPIRE) | 有通知但拿不到内容 | ------ | 只适合缓存失效 |
ZSet 的方案在 Redis 4 之前没法原子"取走"(取和删是两步,可能重复消费),但配合第 13 篇的 WATCH/MULTI 就能做到"检查+删除"原子化------这正是本系列不用 Lua 路线的一次实战。
三、Go 实操:完整延迟队列(收藏点 1)
队列五件套:生产、消费(原子取出)、ACK、重试、死信。
go
package delayq
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var ErrNoTask = errors.New("没有到期任务")
type Task struct {
ID string // 任务唯一 id(业务主键)
RunAt time.Time // 执行时间
}
// Produce 生产:订单创建时调用,score = 到期时间戳
func Produce(ctx context.Context, rdb *redis.Client, queue string, id string, runAt time.Time) error {
return rdb.ZAdd(ctx, "dq:"+queue, redis.Z{
Score: float64(runAt.UnixMilli()),
Member: id,
}).Err()
}
// Cancel 取消:订单支付了,把待执行任务删掉
func Cancel(ctx context.Context, rdb *redis.Client, queue string, id string) error {
return rdb.ZRem(ctx, "dq:"+queue, id).Err()
}
// Consume 消费:原子取出所有到期任务(WATCH 保证"读到+删到"之间队列没被并发改动)
func Consume(ctx context.Context, rdb *redis.Client, queue string, batch int64) ([]string, error) {
key := "dq:" + queue
now := float64(time.Now().UnixMilli())
var ids []string
_, err := rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
// 1. 读出到期任务([0, now] 区间的 score)
rng := pipe.ZRangeByScore(ctx, key, &redis.ZRangeBy{
Min: "0", Max: fmt.Sprintf("%v", now), Offset: 0, Count: batch,
})
if _, err := pipe.Exec(ctx); err != nil {
return err
}
ids = rng.Val()
if len(ids) == 0 {
return nil
}
// 2. 事务内删除这批(EXEC 前若有并发改动则整体失败,外层重试)
members := make([]any, len(ids))
for i, id := range ids {
members[i] = id
}
pipe.ZRem(ctx, key, members...)
return nil
})
if err != nil {
// 并发冲突:返回空,让调用方下一循环再来(延迟队列不在乎这一次)
return nil, nil
}
return ids, nil
}
// Ack/Fail 重试与死信:消费结果登记在 Hash(TTL 兜底)
// 重试:执行业务失败后 ZADD 回去,score = now + 退避时间,重试超过 3 次进死信
// 死信:LPUSH dq:queue:dead + 告警,人工介入
消费者(worker)模板:
go
func Worker(ctx context.Context, rdb *redis.Client, queue string) {
ticker := time.NewTicker(500 * time.Millisecond) // 轮询粒度 = 精度
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
ids, err := delayq.Consume(ctx, rdb, queue, 100)
if err != nil || len(ids) == 0 {
continue
}
for _, id := range ids {
if err := closeOrder(ctx, id); err != nil {
delayq.Retry(ctx, rdb, queue, id) // 退避重试,超限进死信
continue
}
delayq.Ack(ctx, rdb, queue, id)
}
}
}
}
四、关键设计决策(收藏点 2)
| 决策点 | 本文选择 | 理由 |
|---|---|---|
| 任务存什么 | 只存 id,内容回业务表查 | ZSet member 有长度限制且重复存内容浪费内存 |
| 消费幂等 | 关单前先 UPDATE ... WHERE status='unpaid' 影响行数判断 |
重试/重复消费必然发生,业务层幂等 |
| 并发消费 | 多 worker 抢任务 | WATCH 冲突时自然错开,无需分布式锁 |
| 精度 | 轮询 500ms | 30 分钟关单场景秒级足够;要毫秒级就别用 Redis |
五、命令清单
| 命令 | 作用 |
|---|---|
ZADD dq:order <ts> <orderID> |
生产任务 |
ZRANGEBYSCORE dq:order 0 <now> LIMIT 0 100 |
捞到期任务 |
ZREM dq:order <orderID> |
消费后删除 / 业务取消 |
ZCARD dq:order |
队列积压量监控 |
EXPIRE dq:meta:xxx 86400 |
任务状态 Hash 的 TTL 兜底 |
监控指标:队列积压(ZCARD 持续上涨 = 消费者挂了)、死信数量、任务平均延迟(实际执行时间 - 计划时间)。
六、排查步骤:延迟任务没执行
- 任务在不在队列:
ZSCORE dq:order <id>,没有 = 生产没成功或已被消费/取消; - score 对不对:确认存的是毫秒时间戳,存成秒的话 30 分钟任务要等 50 小时才到期;
- 积压监控:ZCARD 是否上涨、worker 是否存活(goroutine 泄漏、context 被 cancel);
- 死信队列里找:
LRANGE dq:order:dead 0 -1,超过重试上限的任务都在这。
七、总结
- ZSet(score=时间戳)是 Redis 里做延迟队列的最优结构:到期感知、精确取消都是 O(logN);
- 不用 Lua 也能原子消费:WATCH + 读 + ZREM,冲突即跳过下一轮;
- 任务只存 id,幂等靠业务状态机(UPDATE ... WHERE status=...);
- 重试 + 死信 + 队列积压监控,三件套配齐才敢上线。
下一篇预告:延迟队列是"定时发",下一篇讲"实时发"------下一篇《List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操》,我们对比三种消息方案,并用 Stream 消费组实现至少一次投递。