5 分钟学会怎么实现订单超时

65 阅读3分钟

核心

  • 不依赖进程内计时器:Pod 崩溃 / OOM / 重启不影响超时处理进度
  • 至少一次执行 & 幂等消费:重复触发不产生副作用
  • 时间同源expires_at 与下单时间同源,避免时钟飘移/误操作
  • 有兜底:定时全量扫描补偿,防止长尾漏单

关键概念

  • DLQ(Dead-Letter Queue) :隔离“坏消息”,防止阻塞主流程,支持排查与补偿
  • TTL(Time-To-Live) :消息/键的存活时间或延时触发时间
  • Redis ZSET:用 score=执行时间戳(ms) 做延时队列(内部可能使用 listpack 编码;只是实现细节,不依赖)

方案一:基于消息队列(延时/定时消息)

流程

  1. 下单写库:保存 expires_at

  2. 同步投递一条“取消订单”延时消息(或定时消息)。

  3. 消费者到期拉取消息:

    • 读订单状态:Paid → 忽略;Created/Pending 且已过期 → 原子更新为 Canceled
  4. 失败重试:按策略重投;超过阈值进入 DLQ,人工/任务补偿。

并发/一致性

  • 与支付并发:以订单状态机 + 幂等更新为准;取消时校验版本/状态。
  • 消费端挂了:位点在 Broker,恢复后继续;业务端仍需幂等。

优缺点

  • ✅ 解耦、可靠性好、易水平扩展
  • ⚠️ 需要 MQ 支持延时/定时消息或补充轮询通道;消息堆积需限流与监控

方案二:基于 Redis ZSET 的延时队列

数据模型

  • ZSET delayed:ordersscore = 执行时间(ms)member = 任务ID/JSON
  • LIST/STREAM processing:orders:临时“处理中”队列/组(防 OOM/重启丢失)

处理器(建议原子领取)

  1. 周期性扫描到期任务(<= now,批量 N):

  2. 业务处理成功:从 processing 删掉;失败:计数 + 回退/重试;超阈值→ DLQ

  3. 进程 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