搞定订单超时自动取消:基于 Redis ZSet 实现的高性能延迟队列(Go 语言实战)

30 阅读5分钟

在分布式系统开发中,我们经常遇到“延迟处理”的需求。最经典的场景莫过于 电商订单支付超时自动取消:用户下单后,如果 30 分钟内未完成支付,系统需要自动取消订单并释放库存。

虽然 RabbitMQ、RocketMQ 等消息队列提供了延迟消息的功能,但在很多轻量级场景或基础设施受限的情况下,利用我们手头已有的 Redis 来实现一个延迟队列,既简单又高效。

今天,我们就通过 Go 语言,结合 Redis 的 Sorted Set(有序集合)数据结构,手写一个生产级的延迟队列。

核心原理:Redis ZSet

Redis 的 ZSet 是一个有序集合,每个元素都有一个 Score(分值)。

设计思路:

  1. 我们将 任务执行的时间戳 作为 Score
  2. 任务的具体内容(如订单ID或JSON结构体)作为 Member
  3. 消费者启动一个无限循环,不断通过 ZRangeByScore 查询 Score <= 当前时间戳 的元素。
  4. 查询到的元素即为“到期可执行”的任务。

场景设定

假设我们正在开发一个 “抢票系统”

  • 用户锁票后,系统生成一个检查任务。
  • 任务需要在 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" 
}

方案总结

优点

  1. 轻量级:不需要引入 Kafka 或 RocketMQ 这样厚重的组件,只要有 Redis 就能跑。
  2. 灵活性:可以随意控制 Score,实现任意时间的延迟,甚至支持动态修改延迟时间(如上述代码中的重入队逻辑)。
  3. 可视化:通过 Redis Desktop Manager 可以直接看到当前排队的任务,便于调试。

注意事项(坑点)

  1. 原子性问题:在 ZRangeZRem 之间存在时间窗口。如果是多实例部署,必须像代码中那样,通过 ZRem 的返回值判断是否抢到了任务,或者使用 Lua 脚本将“获取并删除”做成原子操作。
  2. 空轮询压力:当队列为空时,频繁的 ZRange 会浪费 CPU 和 Redis QPS。建议在没有获取到数据时加上 Sleep(如代码中的 time.Sleep(time.Second))。
  3. 大Key风险:如果积压的任务极多(百万级),ZSET 会变成大 Key,操作性能下降。此时建议按照时间分桶(如 queue_hour_1, queue_hour_2)或使用多组 Key 分片。