3.3 TTL、死信与延迟队列 本节摘要:TTL 给消息设定寿命,到期消息被移除;死信机制给"被移除、被拒绝、被挤掉"的消息一个正式归宿;两者组合即是无插件的延迟队列,配合官方插件则是更精确的延迟方案。本节讲透三种机制的流转规则与各自的坑,并以订单超时关闭为案例完成端到端实现。 追查第三现场始于一条日志:某用户的订单在支付超时后没有被自动取消,一直挂到人工清理。排查发现取消逻辑依赖"消息过期触发",而过期消息被静默移除,没有任何下游收到通知。要让过期消息"死得其所",需要把 TTL 与死信机制接在一起。 TTL 的两种设法与一个陷阱 TTL 可以按队列统一设,也可以按消息单独设。按队列设:声明队列时挂 参数,该队列所有消息共享同一寿命,过期消息在队尾被批量清理,效率高。
本节摘要:TTL 给消息设定寿命,到期消息被移除;死信机制给"被移除、被拒绝、被挤掉"的消息一个正式归宿;两者组合即是无插件的延迟队列,配合官方插件则是更精确的延迟方案。本节讲透三种机制的流转规则与各自的坑,并以订单超时关闭为案例完成端到端实现。
追查第三现场始于一条日志:某用户的订单在支付超时后没有被自动取消,一直挂到人工清理。排查发现取消逻辑依赖"消息过期触发",而过期消息被静默移除,没有任何下游收到通知。要让过期消息"死得其所",需要把 TTL 与死信机制接在一起。
TTL 可以按队列统一设,也可以按消息单独设。按队列设:声明队列时挂 x-message-ttl 参数,该队列所有消息共享同一寿命,过期消息在队尾被批量清理,效率高。按消息设:发布时带 expiration 属性,每条消息各活各的。
陷阱在后者。RabbitMQ 只检查队头消息是否过期——因为队列语义是先进先出,队头不出队,后面的消息理论上轮不到"出场"。于是出现一种反直觉现象:队列里躺着一条 TTL 十秒的 A 消息和一条 TTL 一秒的 B 消息,A 先进队列;一秒后 B 早已"到点",但它排在 A 后面,继续安静等待;十秒后 A 过期出队,B 才被处理。按消息设 TTL 的延迟精度会被队头阻塞破坏,延迟时间跨度大的场景(既有三秒又有三小时的消息混进同一队列)绝不能用单队列加消息级 TTL 的组合。
消息在三种情况下会变成"死信":被 nack 或 reject 且 requeue=false;TTL 到期;队列超过长度上限被挤出(drop-head 或溢出淘汰)。普通处理下它们就此消失;若队列声明时挂了 x-dead-letter-exchange 参数,死信会被重新路由到指定的死信交换机,走一遍完整的路由判决,进入死信队列。
完整流转一张图看懂:

背景:电商标准需求——下单后三十分钟未支付则自动关单释放库存。用 TTL 加死信组合实现,不引入任何插件。
操作:三步拓扑。第一步,建"等待区"队列:不设消费者,只挂 TTL 与死信交换机;第二步,建死信交换机与"关单执行"队列;第三步,消费者只监听执行队列:
import pika connection = pika.BlockingConnection(pika.ConnectionParameters(host="localhost")) channel = connection.channel() # 第一步:等待区。消息进来活 30 分钟,过期转投死信交换机 channel.exchange_declare(exchange="order.delay", exchange_type="direct", durable=True) channel.queue_declare( queue="order.wait.close", durable=True, arguments={ "x-message-ttl": 1800000, # 30 分钟 "x-dead-letter-exchange": "order.dead", # 过期后去哪 "x-dead-letter-routing-key": "order.close"}) # 第二步:死信交换机与执行队列 channel.exchange_declare(exchange="order.dead", exchange_type="direct", durable=True) channel.queue_declare(queue="order.close.exec", durable=True) channel.queue_bind(exchange="order.dead", queue="order.close.exec", routing_key="order.close") # 发布:新订单进入等待区 channel.basic_publish(exchange="order.delay", routing_key="order.created", body=b'{"order_id": "A2001"}') print("订单已进入 30 分钟等待区")
消费端像普通消费者一样监听执行队列即可,收到即代表"订单已满三十分钟未支付":
def on_close(ch, method, properties, body): order = json.loads(body) if not payment_service.paid(order["order_id"]): inventory_service.release(order["order_id"]) print("订单超时关闭:", order["order_id"]) ch.basic_ack(delivery_tag=method.delivery_tag) channel.basic_consume(queue="order.close.exec", on_message_callback=on_close, auto_ack=False)
结果:消息在等待区躺满三十分钟,过期转投死信交换机,落在执行队列被消费,关单动作触发。解读:这套方案的精妙在于"用死亡当信号"——过期本身成了业务事件。两个工程要点:其一,等待区不设消费者,TTL 是唯一出口;其二,等待区必须每个延迟档位一个队列(三十分钟档、十分钟档各建一个),因为队列级 TTL 才不受队头阻塞之害——这正是"延迟档位少且统一用原生"选型口径的原因。
变式一:延迟值任意多变(比如用户自选提醒时间),改用官方延迟消息插件:声明 x-delayed-message 类型交换机,消息头带 x-delay 毫秒数,插件按到期时间精确调度,代价是需安装插件、极端延迟量下性能不佳。变式二:验证死因一路径——对执行队列的消费者回 nack 且不退回,消息转投死信,证明"补偿队列"与"延迟队列"共用同一套死信管道,监控上可按死因路由键区分流量来源。
死信队列一旦建起,必须配监控:死信队列出现持续增长就是事故信号——它可能意味着消费端成批失败,也可能意味着延迟队列配置错误导致消息提前死亡。只建死信不建告警,等于给事故装了个静音器。死信队列的消费优先级建议低于业务队列,避免补偿逻辑反把正常业务拖慢。
⚠️ 常见坑:死信队列里塞进"被挤掉"的合法业务消息而不自知。max-length 的 drop-head 淘汰也会进死信,如果不按死因区分,补偿任务会把本该丢弃的旧数据重新处理一遍。按死因路由键分流是干净的解法。
异常归宿安排妥当。下一现场处理顺序问题:什么时候消息会乱序,业务真的需要全局有序吗。