因为ZSET天然支持按时间戳排序和范围查询,而LIST不排序、PUB/SUB不支持延迟;必须用毫秒级int64时间戳作score,配合ZRANGEBYSCORE扫描到期消息,再通过Lua脚本原子性完成查删推三步,才能避免重复消费与消息丢失。

为什么用 Redis 的 ZSET 而不是 LIST 或 PUB/SUB 实现延迟队列
Go-Redis 本身不提供原生延迟队列,得靠数据结构选型来模拟。LIST 不支持按时间排序,PUB/SUB 是即时广播、无法延迟;ZSET 才是唯一能天然支持「按时间戳排序 + 按分值范围查询」的结构。实际就是把消息体序列化后存进 ZADD,时间戳作为 score,消费端用 ZRANGEBYSCORE 扫描到期消息。
注意:score 必须是 int64(毫秒级时间戳),不能用 float 表示秒级时间,否则 Go-Redis 解析时可能精度丢失或 panic。
- 用
time.Now().UnixMilli()生成 score,别用time.Now().Unix() - 消息体推荐用 JSON 序列化,避免二进制内容含 \x00 导致 Redis 协议解析失败
- ZSET key 建议带业务前缀,比如
delay_queue:order_timeout,方便监控和清理
如何用 go-redis/v9 正确实现延迟入队与轮询消费
go-redis/v9 的 API 和 v8 差异较大,ZADD 和 ZRANGEBYSCORE 都要通过 client.ZAdd 和 client.ZRangeByScore 调用,且参数顺序、返回类型都变了。常见错误是把 score 写成字符串或 float64,导致 ZSET 乱序。
入队示例:
err := client.ZAdd(ctx, "delay_queue:notify", &redis.Z{
Member: "{'uid':123,'event':'pay_success'}",
Score: time.Now().Add(5 * time.Minute).UnixMilli(),
}).Err()
消费端轮询(非阻塞):
now := time.Now().UnixMilli()
vals, err := client.ZRangeByScore(ctx, "delay_queue:notify", &redis.ZRangeBy{
Min: "-inf",
Max: strconv.FormatInt(now, 10),
Offset: 0,
Count: 100, // 避免一次扫太多
}).Result()
- 必须在消费后立即用
ZREM删除已处理项,否则重复消费 - 轮询间隔建议 ≥ 1s,太密会压垮 Redis;可用
time.Sleep控制节奏 - 不要用
ZPOPMIN(Redis 5.0+),go-redis/v9 对它的支持不稳定,且无法保证原子性删除+获取
如何避免延迟消息漏投或重复投递
纯 ZSET 方案没有事务保障,消费端取到消息、处理成功、再删 ZSET 这三步是分开的,网络中断或 panic 会导致消息卡住或重发。这不是 Go-Redis 的 bug,而是架构设计问题。
- 消费逻辑必须幂等:消息体里带唯一
msg_id,处理前先查 DB 或 Redis 缓存是否已存在该 ID - 加一层「待确认队列」:取出消息后先
ZADD delay_queue:pending ...,处理完再ZREM delay_queue:pending,定时任务扫描 pending 中超时未确认的消息做补偿 - 避免用
WATCH/MULTI/EXEC包裹 ZSET 操作——Redis 的 WATCH 对 ZSET 的 score 修改不敏感,容易失效
生产环境必须关掉的两个默认配置
本地跑通不等于线上可用。go-redis 默认连接池和超时设置对延迟队列场景很危险。
-
client.Options.PoolSize默认是 10,高并发轮询下容易打满,建议设为 CPU 核数 × 4(如 16) -
client.Options.Dialer必须显式设置timeout和keepalive,否则网络抖动时连接卡死,轮询线程全阻塞。示例:net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}
另外,ZSET 的 score 精度只到毫秒,如果业务要求亚秒级延迟(比如 300ms),就得自己在 consumer 里 sleep 补偿,Redis 层做不到。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











