核心
- 不依赖进程内计时器:Pod 崩溃 / OOM / 重启不影响超时处理进度
- 至少一次执行 & 幂等消费:重复触发不产生副作用
- 时间同源:
expires_at与下单时间同源,避免时钟飘移/误操作 - 有兜底:定时全量扫描补偿,防止长尾漏单
关键概念
- DLQ(Dead-Letter Queue) :隔离“坏消息”,防止阻塞主流程,支持排查与补偿
- TTL(Time-To-Live) :消息/键的存活时间或延时触发时间
- Redis ZSET:用
score=执行时间戳(ms)做延时队列(内部可能使用 listpack 编码;只是实现细节,不依赖)
方案一:基于消息队列(延时/定时消息)
流程
-
下单写库:保存
expires_at。 -
同步投递一条“取消订单”延时消息(或定时消息)。
-
消费者到期拉取消息:
- 读订单状态:
Paid→ 忽略;Created/Pending且已过期 → 原子更新为Canceled。
- 读订单状态:
-
失败重试:按策略重投;超过阈值进入 DLQ,人工/任务补偿。
并发/一致性
- 与支付并发:以订单状态机 + 幂等更新为准;取消时校验版本/状态。
- 消费端挂了:位点在 Broker,恢复后继续;业务端仍需幂等。
优缺点
- ✅ 解耦、可靠性好、易水平扩展
- ⚠️ 需要 MQ 支持延时/定时消息或补充轮询通道;消息堆积需限流与监控
方案二:基于 Redis ZSET 的延时队列
数据模型
ZSET delayed:orders:score = 执行时间(ms),member = 任务ID/JSONLIST/STREAM processing:orders:临时“处理中”队列/组(防 OOM/重启丢失)
处理器(建议原子领取)
-
周期性扫描到期任务(
<= now,批量 N): -
业务处理成功:从
processing删掉;失败:计数 + 回退/重试;超阈值→ DLQ。 -
进程 OOM/重启:
processing中超时未确认的任务由守护进程回迁到delayed:orders或直接重试。
兜底服务
- 单独部署的扫描器:定期扫
processing超时与delayed漏网任务,避免单点。
优缺点
- ✅ 简单、低延迟、易按需定制
- ⚠️ 内存受限、容错需自建(原子领取、回迁、主从切换与持久化要验证)
公共设计要点
- 时间同源:
expires_at = created_at + timeout_ms,由服务端生成;不要用客户端时钟。 - 幂等:以
order_id唯一键 + 状态机(Created → Paid → Canceled)保证重复安全;可加去重表/版本号。 - 监控告警:扫描滞后、队列积压、重试次数、DLQ 大小、取消成功率。
- 限流与批量:扫描/处理批次与 QPS 可配,避免抖动;加入随机抖动(jitter)防止羊群效应。
- 优雅退出:进程退出前 flush “处理中”,确保可恢复。
兜底补偿(必须有)
-
定时任务:周期性查询数据库
- 例:
WHERE expires_at <= now AND status IN ('Created','Pending')→ 批量尝试取消(幂等)。
- 例:
-
DLQ 回放:离线排查后择机重放;必要时灰度/限速。
常见坑位
- 直接
ZPOPMIN后处理:一旦 OOM/重启,任务可能丢失 → 必须“领取后迁移到 processing 再处理”。 - 扫描时间边界:使用
now + skew容差,避免边界抖动漏扫。 - 重复触发:实时消费 + 兜底任务双通道并存 → 全程依赖幂等与状态校验。
选择建议(粗略)
- 业务量中小、快速落地 → Redis ZSET 方案
- 多团队统一、强可靠与可观测 → MQ 延时消息 + DLQ