在分布式系统开发中,我们经常遇到“延迟处理”的需求。最经典的场景莫过于 电商订单支付超时自动取消:用户下单后,如果 30 分钟内未完成支付,系统需要自动取消订单并释放库存。
虽然 RabbitMQ、RocketMQ 等消息队列提供了延迟消息的功能,但在很多轻量级场景或基础设施受限的情况下,利用我们手头已有的 Redis 来实现一个延迟队列,既简单又高效。
今天,我们就通过 Go 语言,结合 Redis 的 Sorted Set(有序集合)数据结构,手写一个生产级的延迟队列。
核心原理:Redis ZSet
Redis 的 ZSet 是一个有序集合,每个元素都有一个 Score(分值)。
设计思路:
- 我们将 任务执行的时间戳 作为
Score。 - 将 任务的具体内容(如订单ID或JSON结构体)作为
Member。 - 消费者启动一个无限循环,不断通过
ZRangeByScore查询Score <= 当前时间戳的元素。 - 查询到的元素即为“到期可执行”的任务。
场景设定
假设我们正在开发一个 “抢票系统”。
- 用户锁票后,系统生成一个检查任务。
- 任务需要在 15 分钟后触发,检查用户是否支付。
- 如果未支付,则取消订单;如果已支付,则标记完成。
- 进阶逻辑:如果支付状态是“处理中”(例如银行回调延迟),我们需要让任务“再次延迟”一小段时间(重试),而不是立即取消。
代码实现
1. 定义任务结构与常量
首先,我们定义存入 Redis 的数据结构。
package main
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-redis/redis/v8" // 假设使用 go-redis
)
const (
OrderDelayQueueKey = "ticket_order_delay_zset" // Redis Key
MaxRetryDuration = 30 * 60 // 最大重试/等待时间:30分钟
)
// OrderTask 存储在 Redis ZSet Member 中的结构
type OrderTask struct {
OrderID string `json:"order_id"`
UserID int64 `json:"user_id"`
CreateTime int64 `json:"create_time"` // 任务首次创建时间
RetryCount int `json:"retry_count"` // 重试次数
}
2. 生产者:投递延迟任务
当用户下单成功后,我们将任务加入 Redis。这里我们将 Score 设置为 当前时间 + 延迟时间。
// AddOrderCheckTask 添加订单检查任务
// delaySeconds: 首次检查的延迟时间(例如 900秒/15分钟)
func AddOrderCheckTask(rdb *redis.Client, orderID string, userID int64, delaySeconds int) error {
now := time.Now().Unix()
task := &OrderTask{
OrderID: orderID,
UserID: userID,
CreateTime: now,
RetryCount: 0,
}
taskJson, _ := json.Marshal(task)
// ZAdd: Score = 执行时间点
err := rdb.ZAdd(context.Background(), OrderDelayQueueKey, &redis.Z{
Score: float64(now + int64(delaySeconds)),
Member: taskJson,
}).Err()
if err != nil {
fmt.Printf("Failed to add delay task: %v\n", err)
return err
}
fmt.Printf("Order %s added to delay queue, exec at: %v\n", orderID, time.Unix(now+int64(delaySeconds), 0))
return nil
}
3. 消费者:轮询与任务处理
这是延迟队列的核心引擎。我们需要一个后台协程不断轮询。
关键点优化:
为了防止多个消费者重复处理同一个任务,我们在获取到任务列表后,使用 ZRem 进行原子删除。只有 ZRem 成功(返回移除数量 > 0)的消费者,才有资格处理该任务。
// StartDelayQueueWorker 启动消费者进程
func StartDelayQueueWorker(rdb *redis.Client) {
go func() {
ctx := context.Background()
for {
now := time.Now().Unix()
// 1. 拉取所有 score <= 当前时间 的任务
// Limit 限制每次拉取的数量,防止一次处理太多导致阻塞
vals, err := rdb.ZRangeByScoreWithScores(ctx, OrderDelayQueueKey, &redis.ZRangeBy{
Min: "-inf",
Max: fmt.Sprintf("%d", now),
Count: 10, // 每次取10个
}).Result()
if err != nil {
fmt.Printf("Redis error: %v\n", err)
time.Sleep(time.Second) // 发生错误休眠一会
continue
}
// 如果没有到期任务,休眠一小会避免空轮询消耗 CPU
if len(vals) == 0 {
time.Sleep(time.Second)
continue
}
// 2. 并发处理这一批任务
for _, z := range vals {
go processTask(ctx, rdb, z)
}
}
}()
}
4. 核心逻辑:处理、重试与完成
这里模拟了原代码中精髓的 “条件检查与动态重入队” 逻辑。
如果订单还未支付,但还没到最终的强制取消时间(例如银行接口超时),我们可以计算一个新的延迟时间,把任务重新放回 Redis,实现 阶梯式重试。
func processTask(ctx context.Context, rdb *redis.Client, z redis.Z) {
// A. 抢占任务:利用 ZREM 的原子性防止多消费者并发冲突
// 只有移除成功的那个协程才能继续向下执行
deleted, err := rdb.ZRem(ctx, OrderDelayQueueKey, z.Member).Result()
if err != nil || deleted == 0 {
return // 被其他消费者抢了,或者 Redis 异常
}
// B. 解析任务数据
var task OrderTask
taskStr, _ := z.Member.(string)
if err := json.Unmarshal([]byte(taskStr), &task); err != nil {
fmt.Printf("Invalid task format: %v\n", taskStr)
return
}
// C. 模拟调用业务服务检查订单状态
orderStatus := mockCheckOrderStatus(task.OrderID)
now := time.Now().Unix()
// 情况1:订单已支付 -> 任务结束
if orderStatus == "PAID" {
fmt.Printf("Order %s is PAID. Task done.\n", task.OrderID)
return
}
// 情况2:订单未支付,且超过了最大等待时间 -> 执行取消逻辑
if now - task.CreateTime >= MaxRetryDuration {
fmt.Printf("Order %s timeout. Cancelling order...\n", task.OrderID)
// doCancelOrder(task.OrderID)
return
}
// 情况3:订单状态未知或想给用户更多缓冲(例如处于支付中),但还没超时
// -> 重新计算延迟时间,放回队列 (Re-enqueue)
// 简单的退避策略:每次多等 30 秒
nextDelay := 30
task.RetryCount++
nextScore := float64(now + int64(nextDelay))
fmt.Printf("Order %s pending. Re-queueing for %d seconds later.\n", task.OrderID, nextDelay)
rdb.ZAdd(ctx, OrderDelayQueueKey, &redis.Z{
Score: nextScore,
Member: z.Member, // 保持 Member 内容不变(或者更新 RetryCount 后重新 Marshal)
})
}
// 模拟业务检查
func mockCheckOrderStatus(orderID string) string {
// 这里可以是 RPC 调用或数据库查询
return "PENDING"
}
方案总结
优点
- 轻量级:不需要引入 Kafka 或 RocketMQ 这样厚重的组件,只要有 Redis 就能跑。
- 灵活性:可以随意控制
Score,实现任意时间的延迟,甚至支持动态修改延迟时间(如上述代码中的重入队逻辑)。 - 可视化:通过 Redis Desktop Manager 可以直接看到当前排队的任务,便于调试。
注意事项(坑点)
- 原子性问题:在
ZRange和ZRem之间存在时间窗口。如果是多实例部署,必须像代码中那样,通过ZRem的返回值判断是否抢到了任务,或者使用 Lua 脚本将“获取并删除”做成原子操作。 - 空轮询压力:当队列为空时,频繁的
ZRange会浪费 CPU 和 Redis QPS。建议在没有获取到数据时加上Sleep(如代码中的time.Sleep(time.Second))。 - 大Key风险:如果积压的任务极多(百万级),
ZSET会变成大 Key,操作性能下降。此时建议按照时间分桶(如queue_hour_1,queue_hour_2)或使用多组 Key 分片。