延迟与定时消息:三种实现与适用边界

用超时关单解释到期可见与业务完成的区别,对比 RocketMQ 原生定时消息、RabbitMQ TTL 加死信和业务调度表,分析竞争、取消、重试、积压与恢复。

订单创建后十五分钟还没有支付,就关闭订单并释放库存。这看起来只是“等十五分钟再执行一次”,但真正的困难在等待之外:进程重启怎么办,支付刚好发生怎么办,任务重复怎么办,十五分钟后系统正在积压怎么办?

延迟消息解决的是未来某个时间开始交付任务。它不替业务决定订单是否应该关闭,也不承诺在那个时间点所有副作用已经完成。把时间触发和业务裁决拆开,才能讨论一个可恢复的超时方案。

本文围绕超时关单这个设计示例,对比三条路径:Broker 原生定时消息,TTL 加死信转发,业务维护调度记录。RocketMQ 区分 4.x 延迟等级与 5.0 文档中的时间戳模型;RabbitMQ 按当前官方 TTL 和 DLX 文档说明队列边界。流程中的订单状态、任务表与竞争协议是业务设计示例,不代表某家公司的内部实现。

一、先定义时间的含义

延迟发送通常表示“经过一段时间后交付”,定时发送表示“某个绝对时刻后交付”。订单创建于 10:00,到期时间是 10:15;重试发送发生在 10:05,如果再次设置延迟十五分钟,触发就被推到 10:20。保存原始 expireAt,并在重发时使用同一个到期时间,可以避免发送重试改变业务期限。

至少要区分到期时间、Broker 可见时间、消费者开始处理时间和业务提交时间。消费者收到消息时,任务可能已经迟了三分钟;这个延迟不一定来自定时器,也可能来自网络、消费者积压或数据库连接池。只监控“消息发送成功”,看不到真正的超时体验。

对于关单,常见契约是到期之前不允许关闭,到期之后尽快完成,同时以数据库的权威状态判断是否还未支付。这个契约允许合理的晚执行,却不允许早执行。如果业务要求某个法律或活动时间点立即禁止支付,支付入口自身也必须检查期限,不能等异步关单消息到达才生效。

消息上的毫秒时间戳只是表达精度,不代表调度和执行都精确到毫秒。机器时钟、调度粒度、重启恢复、队列拥塞都会影响实际触发。需要高精度控制的实时系统,不应把普通消息队列当成硬实时调度器。

二、一条超时任务需要怎样走完

创建订单时,数据库记录 status=UNPAID 和 expireAt,同时保存超时任务的发送意图。后续投递即使失败,也能从记录恢复。消费者到期收到的应是“检查订单是否超时”的命令,而不是一份永远正确的“订单未支付”事实。

超时任务从订单创建到条件关单的流程

消费端查询当前状态。订单已支付或已关闭,任务不再产生关单效果;仍未支付但尚未到期,说明时间参数、时钟或投递路径出现了偏差,需要重新安排而不是提前关闭;仍未支付且已经到期,才尝试受条件保护的关单。

关单和释放库存不一定在同一数据库。可以在本地事务里把订单改为关闭,并写入释放库存的 Outbox 事件。随后可靠发送释放事件,由库存端幂等处理。只在内存里连续调用“关单、释放库存”,中途宕机就可能留下已关闭但仍占库存的订单。

超时消息成功消费的条件也要明确。若它的责任是完成本地关单并持久建立释放任务,那么这个本地事务提交后可以确认;若业务还要求等待外部释放完成,就需要持久工作流记录后续状态。不能在没有恢复记录时返回成功,然后把库存请求交给一个临时线程。

这与 消息怎样不丢失 是同一条责任链。延迟机制只多了一个等待到期的阶段,其余发送、消费和跨系统效果的确认窗口仍然存在。

三、原生定时消息把等待交给 Broker

RocketMQ 4.x 常见接口是 setDelayTimeLevel,参数是等级,不是任意毫秒数。官方示例中的默认等级表包含十五分钟、三十分钟等档位。选择等级前应检查部署配置及客户端兼容性,不能把数字 15 理解成十五分钟。4.x 延迟发送

RocketMQ 5.0 文档采用交付时间戳,并要求相应的 DELAY Topic。到期前消息处于定时等待阶段,到期后才进入消费者可见的交付路径。具体允许范围、时间粒度和异常参数处理,应按实际版本与部署核对。5.0 定时消息

// 5.x Java 客户端形状示例:builder、producer 已按部署初始化。
Message message = builder
    .setTopic("order-expiration")
    .setKeys(eventId)
    .setDeliveryTimestamp(expireAtMillis)
    .setBody(payloadBytes)
    .build();
producer.send(message); // 仍需处理结果未知、重试及持久发送意图

这里使用订单保存的到期时间,而不是每次发送都重新计算“现在加十五分钟”。发送结果未知时,恢复的是同一任务,业务事件身份保持不变。setKeys 便于检索,不应被解释为 Broker 自动阻止所有业务重复。

原生能力的优势是等待任务不需要占据业务工作线程,调度和持久恢复由消息系统承担。应用仍要保留业务期限和状态,才能处理过期参数、消息重复、积压以及到期前订单已支付等情况。

还有容量问题。大促在整点创建大量订单,十五分钟后就会集中到期。Broker 恢复和消费者扩容都不能凭空消除数据库的写入上限。可以为超时检查设置独立消费资源,对允许弹性的任务分散触发;若关单期限不能改变,就保留期限,只对执行调度做限流,并接受、监控晚执行。

四、TTL 加死信为什么不等于任意时间调度

RabbitMQ 消息 TTL 表示消息可以在队列里存活多久。到期后,它不会再作为正常消息交给该队列消费者;配置 DLX 后,过期消息可以被死信转发到另一个队列。一个等待队列没有业务消费者,到期后通过 DLX 进入执行队列,就组成了常见的延迟路径。TTL 文档

固定 TTL 等待队列与不同到期时间混排的区别

适合的情况是几个固定延迟档位,例如短暂重试、较长重试和超时检查。每个档位使用明确的队列策略,让同一队列里的任务有相近的等待规则。配置应限定目标队列、DLX 和路由,不要把一条全局策略意外应用到所有业务队列。

要注意逐消息 TTL 的队头问题。先入队的消息等待一小时,后入队的消息只等待一分钟,后者到期不意味着它一定立即被转发。在官方说明的 quorum queue 行为中,过期消息到达队头才进入死信处理;classic queue 也存在相关队头处理条件。把任意到期时间混在同一个等待队列,不会自动得到按截止时间排序的优先队列。

队列 TTL 则是未使用队列的过期机制,和消息 TTL 不是同一参数。整个队列过期不能当作“所有消息按时进入死信”的实现。这个区别很容易在配置名称相似时被忽略。

DLX 转发还涉及可靠性。默认死信重发布路径不是无条件的可靠事务,目标不可用时存在丢失风险;quorum queue 的 at-least-once dead-lettering 需要符合相应条件并正确配置。死信安全边界 对关键关单任务,应把转发链和业务扫描兜底一起审查,而不是认为“死信”天然比普通消息更可靠。

五、业务调度表与 ZSet 需要自己补齐领取协议

另一条路径是在数据库保存任务:taskId、业务键、到期时间、任务版本、状态、领取租约、重试次数和最近错误。调度器查询已到期且未完成的任务,领取后执行,失败则安排下次尝试。适合需要长周期、取消、改期、查询和人工恢复的任务。

-- 形状示例:先用索引挑选候选,再逐条条件领取。
UPDATE scheduled_task
SET state = 'RUNNING', lease_owner = :worker,
    lease_until = :leaseEnd, lease_epoch = lease_epoch + 1
WHERE task_id = :taskId
  AND state = 'PENDING'
  AND due_at <= :now;

更新影响一行才表示本次领取成功。租约过期的 RUNNING 任务要通过另一条受条件保护的恢复路径重新安排;完成提交必须带领取世代,避免过期工作者把新执行者的任务覆盖。SQL 只是领取结构示意,生产设计还需要相应索引、批次上限、事务隔离和恢复规则。

调度器先读取再执行,没有原子领取,会让多个实例执行同一任务。领取后直接删除,执行期间宕机会丢掉恢复责任。领取租约又不能证明旧工作者已经停止,因此订单条件更新与库存幂等仍不可少。

ZSet 可以把到期时间放在 score,根据分值查找已到期成员。ZRANGE 的分值范围查询 提供了排序检索能力,但检索本身没有消费确认、执行租约和失败恢复语义。读出后直接 ZREM,与先删除调度记录一样,会留下宕机窗口。

可以通过 Lua 原子把到期任务从待调度集合移到处理中集合,再为处理中任务建立超时回收。跨 Redis、数据库和消息队列的操作仍不是一个自动原子事务。如果数据库任务表是权威记录、Redis 只作时间索引,Redis 数据丢失后可以重建;如果 Redis 是唯一任务来源,就必须按该业务要求审查持久化、复制和故障切换。

我更愿意把数据库索引扫描看成可行基线,而不是默认低效方案。合理的到期索引、分片、批量领取和负载控制,足以覆盖很多规模。只有明确知道瓶颈在时间索引时,才值得增加 ZSet 或其他调度结构;增加组件同时增加了恢复协议。

六、支付与关单同时发生,谁来裁决

假设关单线程查询到订单未支付,随后支付线程把订单改成已支付,关单线程又无条件更新成关闭。即使超时消息绝对准时,也会产生错误。读取状态后再修改的间隙,必须由数据库中的状态条件或事务控制保护。

UPDATE orders
SET status = 'CLOSED', version = version + 1
WHERE order_id = :orderId
  AND status = 'UNPAID'
  AND expire_at <= :authoritativeNow;

支付路径也需要自己的合法状态条件。受同一权威记录约束时,一个状态转移成功后,另一个条件不再满足。影响零行不能直接报故障,应查询是已经支付、已经关闭、期限变化,还是并发竞争。关单的 Outbox 事件只能在本次真实转移成功的事务中创建。

支付网关成功与订单状态提交不是同一件事。关单赢得本地状态竞争之后,可能收到外部已扣款的回调。这时需要定义迟到支付如何查询、退款或人工处理。仅靠数据库状态不接收回调,会把“订单没有接受支付”误当成“外部没有扣款”。

这也是为什么消费者不能直接相信消息体里的旧状态。超时任务建立时未支付,不代表到期仍未支付;携带创建时版本,适合识别任务来源,但不能替代当前权威查询。如果期限可以延长,关单条件必须检查当前期限,或使用专门的超时策略版本。

七、取消、改期和重复触发

多数业务并不需要从 Broker 物理撤回已经发送的超时消息。订单支付后,保留旧任务也可以,到期消费时发现已支付便正常结束。这种逻辑失效避免了一个“数据库状态已变,但取消消息失败”的额外同步问题。

需要改期时,可以让订单保存 deadlineVersion。新任务携带新的期限版本,旧任务到达后发现版本不匹配,结束它的检查责任;新的期限版本由新的任务或调度记录继续负责。若旧任务跳过时新任务还没有持久建立,就可能没有任何人负责下一次检查,所以改期与新发送意图应一起提交。

不要直接用整个订单版本作为期限版本。订单修改备注也会增加业务版本,如果旧超时任务因为备注变化被丢弃,而没有新任务,就永久漏掉关单。任务失效规则应只绑定真正改变截止策略的状态。

重复触发要按业务转移判定。两个关单任务同时收到,只允许一个完成 UNPAID 到 CLOSED 的转移;库存释放使用原始预占或释放动作的稳定身份。以消费消息 ID 为唯一防线,可能挡不住两条不同消息代表同一个关单义务。

消费过程已经提交,确认却丢失,再次收到任务时应读到已关闭并正常确认。它不应再创建第二笔释放任务。如果之前只是保存了未完成工作流,则返回既有工作流的状态并继续恢复,不能把“记录存在”当成“全部成功”。

八、积压与恢复要围绕截止时间观察

延迟任务的健康度可以用 业务完成时间 - expireAt 表示,而不是普通消费速率。进一步拆分 Broker 可见延迟、可见后等待和业务执行时间,就能定位瓶颈。还应观察最老未完成到期任务、当前到期任务数量、无效任务比例及释放库存的滞后。

定时系统重启后会集中释放已经到期的任务。消费者直接满并发请求下游,容易造成恢复流量把数据库再次压垮。恢复速度应服从下游容量,对不同业务优先级和期限分开限流;关键任务的恢复不能被大量无效旧任务占满。

超时检查失败后的重试时间也和业务期限不同。订单 10:15 到期,第一次检查失败,下一次 10:16 再试,只改变尝试计划,不应把订单期限改成 10:16。原始到期时间、下次尝试时间和业务最终完成时间应该分别保存。

最后需要业务兜底。定期扫描已经到期但仍未支付的订单,生成或恢复遗漏任务,并检查关闭后库存释放的未完成记录。扫描不应与实时消息各自无条件执行,应进入同一个状态推进器和同一套幂等约束。它补的是持久责任链中的缺口,而不是用更多消息掩盖缺口。

九、怎样选择三种实现

方案 等待时间由谁管理 适合什么 需要额外负责什么
原生定时消息 Broker 调度机制 支持范围内的一次性触发 业务期限、重复、取消失效和晚执行
TTL 加 DLX 等待队列过期与转发 少数固定延迟档位 队头影响、转发可靠性和配置边界
业务调度表 应用的持久调度记录 长周期、可取消改期、可追踪任务 领取租约、恢复、索引和执行能力

对于十五分钟关单,若已有支持相应定时能力的 RocketMQ,我会先用原生消息加订单条件更新与扫描兜底。若团队已有 RabbitMQ 且只有几个固定档位,可以审查 TTL 加 DLX 是否满足要求。若产品频繁延长期限、取消任务、查询调度状态,业务任务表通常更容易把这些状态表达清楚。

无论选哪一种,判断标准都应包括:任务到期前是否会误执行,丢失触发后能否恢复,支付竞争怎样处理,重复执行会不会产生副作用,长时间积压后怎样有界恢复。等待结构决定如何找到到期任务,订单状态机决定它现在还能不能执行。

参考资料