Redis ZSET 是中小型项目最稳的延迟队列起点,用 ZAdd 存秒级时间戳+消息体,ZRangeByScore 扫描到期项;需统一时间单位、避免短间隔扫描、Lua 原子移除、关注内存水位。

Redis ZSET 方案:中小型项目最稳的起点
直接用 redis.ZAdd 存时间戳 + 消息体,再用 ZRangeByScore 扫描到期项,是 Go 里最快落地延迟队列的方式。它不依赖额外中间件,也不用自己维护调度逻辑。
常见错误现象:ZRangeByScore 返回空但消息明明该执行了——多半是 score 类型没对齐(比如用了毫秒存、却用秒查),或没处理时区导致 time.Now().Unix() 和 Redis 服务器时间偏差。
- 务必统一用秒级时间戳(
time.Now().Unix()),避免浮点精度问题 - 扫描间隔别设太短(如
100ms),否则 Redis 压力陡增;1 秒足够应对多数业务场景 - 移除已消费消息必须原子化:用 Lua 脚本封装
ZRangeByScore + ZRem,否则可能重复投递 - 注意内存水位:长期堆积未消费消息会让 ZSET 膨胀,建议加 TTL 或定期清理过期 key
time.AfterFunc + heap:纯内存方案适合低并发定时任务
Go 标准库没有现成 DelayQueue,但组合 time.AfterFunc 和最小堆(container/heap)能做出轻量、无外部依赖的实现,适合单机内调度(比如每分钟检查一次本地缓存健康度)。
使用场景:不是“消息队列”,而是“任务调度器”——任务生命周期短、不跨进程、失败不重试。
- 不要把大对象塞进
heap元素里,只存指针或 ID,避免 GC 压力 -
heap.Fix比heap.Push/heap.Pop更省,适合频繁更新延迟时间的场景(如订单倒计时刷新) - 一旦 goroutine panic,整个调度循环就停了,必须用
recover包裹执行体 - 无法持久化:进程重启后所有未触发任务丢失,别用它管支付超时这类强一致性场景
RabbitMQ delayed exchange:已有 AMQP 架构时的无缝选择
如果你已经在用 RabbitMQ,装上 rabbitmq_delayed_message_exchange 插件,几行代码就能让延迟消息走原生路径,不用改消费端逻辑。
参数差异关键点:x-delay 是 header 字段,不是 message body 一部分;Exchange type 必须声明为 "x-delayed-message",且要提前在管理界面或命令行启用插件。
- 插件默认不开启,Docker 部署需在
docker run加-e RABBITMQ_ENABLED_PLUGINS_FILE=/etc/rabbitmq/enabled_plugins并挂载配置文件 - 延迟精度最低 1 毫秒,但实际受 broker 负载影响,高并发下可能漂移 50–200ms
- 消息体积限制仍受 RabbitMQ 单条限制(默认 128MB),大 payload 要先存对象存储再发 ID
- 插件版本和 RabbitMQ 主版本强绑定,升级前务必查官方兼容表,别直接
apt upgrade
为什么别急着写时间轮(TimeWheel)?
时间轮听着高大上,但 Go 里真用起来容易掉坑里:槽位数、tick 间隔、层级设计一错,内存就暴涨,精度反而不如 time.AfterFunc。
性能影响很实在:10 万任务塞进单层时间轮,每个槽位是个 *list.List,光指针就占几 MB;分层轮又得手动处理“刻度溢出”,调试成本远超收益。
- 只有当你明确需要百万级并发、亚秒级精度、且 CPU/内存资源富余时,才值得投入自研
- 社区有成熟封装如
github.com/RichardKnop/machinery/v2,但它把时间轮藏在后台,你调的是SendDelayed,不是裸写tw.AddTask - 别信“O(1) 插入”宣传——真实世界里,GC 扫描链表、锁竞争、cache miss 都会吃掉理论优势
真正难的从来不是“怎么实现延迟”,而是“怎么保证消息不丢、不重、不错序、可追溯”。选方案前,先问清楚:这个延迟,是用户能感知的(比如倒计时),还是系统级保障(比如金融对账超时熔断)?前者够用就行,后者就得上 Kafka + 事务日志 + 死信追踪那一套。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











