Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭------ZSet 延迟队列 Go 实现](#Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现)

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。三个问题:

  1. 大表上这个查询越来越慢,索引压力大;
  2. 扫描周期 = 关单精度(分钟级,想秒级就得每秒扫);
  3. 无效扫描: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 持续上涨 = 消费者挂了)、死信数量、任务平均延迟(实际执行时间 - 计划时间)。

六、排查步骤:延迟任务没执行

  1. 任务在不在队列:ZSCORE dq:order <id>,没有 = 生产没成功或已被消费/取消;
  2. score 对不对:确认存的是毫秒时间戳,存成秒的话 30 分钟任务要等 50 小时才到期;
  3. 积压监控:ZCARD 是否上涨、worker 是否存活(goroutine 泄漏、context 被 cancel);
  4. 死信队列里找:LRANGE dq:order:dead 0 -1,超过重试上限的任务都在这。

七、总结

  1. ZSet(score=时间戳)是 Redis 里做延迟队列的最优结构:到期感知、精确取消都是 O(logN);
  2. 不用 Lua 也能原子消费:WATCH + 读 + ZREM,冲突即跳过下一轮;
  3. 任务只存 id,幂等靠业务状态机(UPDATE ... WHERE status=...);
  4. 重试 + 死信 + 队列积压监控,三件套配齐才敢上线。

下一篇预告:延迟队列是"定时发",下一篇讲"实时发"------下一篇《List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操》,我们对比三种消息方案,并用 Stream 消费组实现至少一次投递。

相关推荐
利威尔xu3 小时前
Go IM 源码实战(13):30 个会话只有 2 个红点,未读数错在三条路径
redis·后端·微服务·面试·golang
FungLeo4 小时前
成为全栈·Go 后端篇·从 Node SQLite 到 PostgreSQL:把迁移做成独立交付物
postgresql·golang·sqlite·go·数据库迁移·成为全栈
will_net5 小时前
Redis集群部署+Redis 链接地址放入Aollo +c#项目接入Redis
redis·c#·bootstrap
用户6919026813397 小时前
Redis 1:简述Redis概念与Agent的联系
redis·后端·设计
小南家的青蛙8 小时前
华为云DCS全形态原生Redis命令兼容性测试方案
redis·华为云·兼容性·兼容性测试·dcs
xiaoqiMikko8 小时前
Redis 实战(三):持久化出错 —— fork 失败、磁盘写满、AOF 膨胀与损坏,逐个复现(6.2.4)
redis·后端
IT规划师9 小时前
面试中被嘲笑Token放在redis里,该如何应对?
redis·面试