每天总有那么几个订单,消息发出去了、却像人间蒸发一样没被处理:我在消息队列里踩的"消息丢失"的坑
这是一个"零星发作、却又确凿存在"的诡异问题。我用消息队列(MQ)做了一个异步处理的架构:订单服务在下单后,往 MQ 发一条"订单创建"的消息;另一个消费者服务,从 MQ 里取出这条消息,去做后续的处理(发短信、加积分、通知发货等)。这套异步解耦的架构,跑得很流畅。可财务对账时,发现一个让人不安的现象:每天,总有那么极少数的几个订单,它们的消息明明发出去了(订单服务的日志里有发送记录),可消费者那边,却像人间蒸发了一样,没有任何处理的记录——这几个订单,没发短信、没加积分、没通知发货,被无声无息地"漏掉"了。
这种"零星丢消息"的问题,排查起来格外揪心——它发生的概率很低、毫无规律,可它确确实实地、每天都在造成着真实的业务损失。我反复排查,终于揪出了根因——我犯了一个使用消息队列时极其经典、又极其隐蔽的错误:我的消费者,用的是"自动确认(auto-ack)"模式。也就是说,消费者一收到消息,MQ 就立刻认为"这条消息已经被成功处理了",把它从队列里删掉了。可问题是——消费者"收到"消息,不等于它"处理完"了消息!如果消费者在"收到消息之后、但还没处理完之前",恰好崩溃了(进程挂了、机器重启、抛了未捕获的异常),那么,这条消息,因为已经被 MQ 当成"处理成功"删掉了,就再也回不来了——它就这样,在消费者崩溃的那一刻,被永久地"丢失"了。我那些零星丢失的订单消息,正是那些"恰好在消费者收到、却还没处理完时,消费者崩溃了"的、不幸的消息。
故障现场:消息"收到"就被确认,可还没"处理完"
我把出问题的消费者逻辑,简化一下。问题就藏在那个"自动确认"上:
# 消费者(有"消息丢失"风险的版本): 自动确认
def consume():
channel.basic_consume(
queue="order_queue",
on_message_callback=process_message,
auto_ack=True, # ← 致命: 自动确认! 消息一"收到"就被 MQ 认为"处理成功"了
)
def process_message(ch, method, properties, body):
# 此时, 这条消息已经被 MQ 当成"处理成功", 从队列里删掉了!
order = parse(body)
send_sms(order) # 处理步骤1
add_points(order) # 处理步骤2
notify_shipping(order) # 处理步骤3
# ↑ 如果在这中间(比如 add_points 时), 消费者崩溃了 ——
# 消息已经被删了, 这个订单的处理就"半途而废"、且消息永久丢失!
# 消息丢失的时序:
# 1. 消费者从 MQ "收到"订单消息 X
# 2. MQ 因为 auto_ack=True, 立刻把消息 X 从队列删除(认为"处理成功")
# 3. 消费者开始处理 X: send_sms 成功... 正在 add_points...
# 4. 此时, 消费者进程崩溃了!(机器重启/OOM/未捕获异常)
# 5. 消费者重启后, 它不会再收到消息 X 了 —— 因为 X 早被删了!
# → 订单 X 的消息, 就这样永久丢失, 后续处理再也不会发生 → 业务损失!
看清这个时序,我才明白那些订单消息是怎么"人间蒸发"的。问题的核心,是"自动确认(auto-ack)"模式,把"收到消息"和"处理完消息"这两件本质不同的事,错误地等同了起来。在自动确认模式下,消费者一"收到"消息,MQ 就立刻认为这条消息已经被"成功处理"了,于是把它从队列里删除。可"收到",和"处理完",是天差地别的两件事!"收到",只是消息到了消费者手里;"处理完",才是消费者真正地、成功地完成了所有的业务处理(发短信、加积分、通知发货)。在这两者之间,有一段"正在处理"的时间——而如果消费者,恰好在这段"正在处理"的时间里崩溃了(进程被杀、机器重启、抛了个没接住的异常),那么:这条消息,因为已经被 MQ 当成"处理成功"删掉了,就再也无法被重新投递;而它对应的业务处理,却只做了一半、甚至一点没做就中断了。结果就是:这条消息,连同它没做完的业务,一起,在消费者崩溃的那一刻,被永久地、无声地"丢失"了。我那些零星丢失的订单,正是那些"命不好"的消息——它们恰好在被消费者收到、却还没处理完的那个"危险窗口"里,撞上了消费者的崩溃,于是就这样消失了。这个问题之所以"零星、无规律",正是因为"消费者崩溃"本身是个小概率、随机的事件;可只要消费者会崩溃(而它一定会偶尔崩溃),用 auto-ack 就一定会零星地丢消息。我那个看似流畅的异步架构,其实一直藏着这个会悄悄吞噬消息的、致命的隐患。
第一件事:搞懂消息队列的"确认机制(ack)"
定位到根源,我必须把消息队列的"确认机制(acknowledgement, ack)",彻底搞清楚:消息队列的"确认机制",是消费者用来告诉 MQ"这条消息,我处理好了,你可以放心地把它删掉了"的一种机制。它有两种模式:"自动确认(auto-ack)"和"手动确认(manual-ack)"——它们的区别,正在于"什么时候,算这条消息处理成功了"。
消息队列的"确认机制(ack)": 自动确认 vs 手动确认
# 自动确认(auto-ack): "收到"就算"处理成功"
# - 消费者一收到消息, MQ 立刻认为"成功", 删除消息。
# - 风险: 如果消费者"收到后、处理完前"崩溃 → 消息已删, 永久丢失!(本文)
# - 适合: 能容忍少量丢失、且追求高吞吐的场景(如非关键的日志)。
# 手动确认(manual-ack): "处理完"才算"处理成功"
# - 消费者收到消息, 先处理; 处理【成功后】, 才手动地发一个 ack 给 MQ。
# - MQ 收到 ack, 才删除消息。
# - 如果消费者"处理完前"崩溃了(没发 ack) → MQ 发现这条消息没被 ack,
# 会把它【重新投递】给(另一个)消费者 → 消息不丢失!
# - 适合: 不能丢消息的关键业务(如订单、支付)。
# 核心区别: "确认"的时机不同 ——
# auto-ack: 在"处理之前"就确认了 (乐观, 但会丢)
# manual-ack: 在"处理之后"才确认 (可靠, 不丢)
# 配套机制(保证消息端到端不丢, 三段都要):
# 1. 生产者: 用"发送确认"(producer confirm), 确保消息真的发到了 MQ
# 2. MQ: 用"持久化"(persistence), 确保 MQ 重启消息也不丢
# 3. 消费者: 用"手动确认"(manual-ack), 确保处理完才删消息
# → 三段任何一段没做好, 消息都可能在那一段丢失。
原理终于清晰了。消息队列的"确认机制(ack)",是消费者告诉 MQ"这条消息我处理好了、你可以删了"的机制;它有两种模式,区别就在于"什么时候,算这条消息处理成功了":"自动确认(auto-ack)":消费者一"收到"消息,MQ 就立刻认为成功、删除消息——它的风险,正是我踩的坑:消费者在"收到后、处理完前"崩溃,消息就已经被删、永久丢失了;它只适合"能容忍少量丢失、追求高吞吐"的非关键场景(如日志)。"手动确认(manual-ack)":消费者收到消息后,先处理;只有在处理成功之后,才手动地发一个 ack 给 MQ,MQ 收到 ack 才删除消息——这样,如果消费者在"处理完前"崩溃了(还没发 ack),MQ 就会发现"这条消息没被 ack",于是把它重新投递给(另一个)消费者去处理,消息就不会丢失了!它适合"不能丢消息的关键业务"(如订单、支付)。两者的核心区别,就是"确认的时机"——auto-ack 在"处理之前"就乐观地确认了(会丢),manual-ack 在"处理之后"才可靠地确认(不丢)。而更重要的是,我还认识到:要保证消息"端到端"地不丢失,光靠消费者的手动确认还不够,需要"生产者、MQ、消费者"三段都做好——生产者用"发送确认(producer confirm)"确保消息真的发到了 MQ;MQ 用"持久化(persistence)"确保它自己重启时消息也不丢;消费者用"手动确认(manual-ack)"确保处理完才删消息。这三段,任何一段没做好,消息都可能在那一段悄悄丢失。我这次,正是栽在了'消费者确认'这第三段上。
第二件事:正解——用手动确认,处理成功后才 ack
搞懂了根因——"auto-ack 在处理完前就删了消息"——正解就清晰了:把消费者改成"手动确认(manual-ack)"模式——收到消息后,先完整地处理它;只有在处理成功之后,才手动地发送 ack;如果处理过程中出错或崩溃,就不发 ack(或发 nack),让 MQ 把这条消息重新投递。这样,消息在"被成功处理"之前,绝不会被删除,也就绝不会丢失。
# 正解: 手动确认(manual-ack) —— 处理成功后才 ack
def consume():
channel.basic_consume(
queue="order_queue",
on_message_callback=process_message,
auto_ack=False, # ← 关键: 关闭自动确认, 改用手动确认!
)
def process_message(ch, method, properties, body):
try:
order = parse(body)
# 先完整地处理消息
send_sms(order)
add_points(order)
notify_shipping(order)
# 只有全部处理成功, 才手动 ack —— 告诉 MQ "这条我处理好了, 可以删了"
ch.basic_ack(delivery_tag=method.delivery_tag) # ← 手动确认!
except Exception as e:
# 处理失败! 不要 ack, 而是 nack(并 requeue), 让 MQ 重新投递
log.error(f"处理失败: {e}")
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True) # 重新入队
# → 这条消息会被 MQ 重新投递, 不会丢失!
# 现在的时序(消费者处理到一半崩溃):
# 1. 消费者收到消息 X (auto_ack=False, MQ 暂不删除, 只是标记"投递中")
# 2. 消费者处理 X: send_sms... 正在 add_points... 崩溃了!(还没 ack)
# 3. MQ 发现: 这条消息投递出去了, 但一直没收到 ack(连接断了)
# 4. MQ 把消息 X 【重新投递】给另一个消费者
# 5. 新消费者重新处理 X → 处理成功 → ack → 删除。 消息没丢! ✓
# 注意: 重新投递 = 消息可能被处理"两次"(崩溃前处理了一半, 重投后又从头处理)!
# → 所以消费者必须【幂等】! (处理两次和一次, 效果要相同) —— 这是配套要求!
这个正解的核心,是把"确认"的时机,从"收到时"推迟到"处理成功后"——让消息的删除,严格地以"它真的被处理成功"为前提。改成手动确认后:消费者收到消息,MQ 只是暂时把它标记为"投递中"、并不删除;消费者完整地处理完、成功了,才手动发 ack,MQ 这才删除它;而如果消费者在处理过程中崩溃了(还没来得及 ack),MQ 会发现"这条消息投递出去了、却一直没收到 ack(连接也断了)",于是把它重新投递给另一个消费者去处理——这样,消息在"真正被处理成功"之前,绝不会被删,也就绝不会丢失。不过,这里有一个极其重要的、配套的要求,必须注意:"重新投递"意味着,一条消息可能被处理两次!——比如,消费者第一次处理到一半(发了短信、正在加积分)时崩溃了,消息被重投,新的消费者会从头再处理一遍(又发一次短信、再加一次积分)。所以,用了手动确认(它保证了"至少处理一次、不丢消息"),就必须同时保证消费者的处理逻辑是"幂等"的(即"处理两次和处理一次,效果完全相同")——否则,你虽然解决了"丢消息",却引入了"重复处理"的新问题(重复发短信、重复加积分)。"手动确认(防丢失)+ 消费者幂等(防重复)",这两者必须配套使用,才能真正地、可靠地处理好消息。一句话:用手动确认让消息处理完才 ack,确保不丢;同时让消费者幂等,确保重投时不重复。
下面这张图,对比了"自动确认丢消息"和"手动确认不丢"两条路径:
这张图的对比很清楚:左边红色那条,自动确认、收到就 ack,MQ 立刻删消息,消费者处理到一半崩溃,消息已删无法重投、永久丢失;右边绿色那条,手动确认、处理成功后才 ack,MQ 暂不删消息,成功才 ack 删除、崩溃没 ack 就重新投递,消息不丢。两条路的根本分野,在于"确认"是发生在"处理之前"还是"处理之后"。
第三件事:消息队列的可靠性,是"三段"的事
填平了"消费者确认"这个坑,我意识到:消息的可靠传递,是一个"端到端"的问题,涉及"生产者→MQ→消费者"三段,每一段都可能丢消息。我把这"三段"的可靠性保障,系统地梳理了一遍:
# 消息可靠传递的"三段"保障(任何一段没做好, 都会丢消息):
# === 第1段: 生产者 → MQ (防"消息没发到 MQ 就丢了") ===
# 用"发送确认(publisher confirm)": MQ 收到消息后, 给生产者一个确认
channel.confirm_delivery() # 开启确认模式
try:
channel.basic_publish(..., mandatory=True)
# 如果没收到 MQ 的确认 → 说明可能没发到 → 重发/记录/告警
except Exception:
retry_or_log() # 没确认成功, 要处理(重发等), 别当它发成功了
# === 第2段: MQ 自身 (防"MQ 重启/宕机时消息丢了") ===
# 用"持久化(persistence)": 队列持久化 + 消息持久化, 让消息写到磁盘
channel.queue_declare(queue="order_queue", durable=True) # 队列持久化
channel.basic_publish(
..., properties=pika.BasicProperties(delivery_mode=2) # 消息持久化(写磁盘)
)
# → 这样 MQ 重启, 消息还在(从磁盘恢复), 不丢。(还可配集群/镜像队列更可靠)
# === 第3段: MQ → 消费者 (防"消费者处理完前崩溃, 消息丢了") ===
# 用"手动确认(manual-ack)": 处理成功才 ack (本文的正解)
# + 消费者幂等 (防重投导致的重复处理)
# 完整的可靠链路:
# 生产者(发送确认) → MQ(持久化) → 消费者(手动确认 + 幂等)
# ↑ 三段都做好, 消息才能"端到端"地, 既不丢失、又不重复(地影响业务)。
这一梳理,让我对"消息队列的可靠性"有了完整、系统的认识。消息的可靠传递,绝不是消费者一端的事,而是一个贯穿"生产者→MQ→消费者"三段的"端到端"问题——每一段,都有可能丢消息,所以每一段,都要做好相应的保障。第1段(生产者→MQ):要防"消息没发到 MQ 就丢了",用"发送确认(publisher confirm)"——MQ 收到消息后给生产者一个确认,如果生产者没收到确认,就说明消息可能没发到,要重发或告警。第2段(MQ 自身):要防"MQ 重启/宕机时消息丢了",用"持久化(persistence)"——把队列和消息都持久化到磁盘,这样 MQ 重启时消息还能从磁盘恢复(更高可靠还可以配集群)。第3段(MQ→消费者):要防"消费者处理完前崩溃、消息丢了",用"手动确认(manual-ack)+ 消费者幂等"(本文的正解)。这三段,构成了一条完整的"可靠链路":生产者(发送确认)→ MQ(持久化)→ 消费者(手动确认 + 幂等);只有这三段都做好了,消息才能真正地"端到端"地,既不丢失、又不重复地影响业务。我这次,正是只关注了'用 MQ 解耦'这个功能,却完全没有去思考'消息会不会在这三段的某一段丢失'这个可靠性问题——而消息队列,作为很多关键业务流程的'传送带',它的可靠性,恰恰是至关重要、却又极易被忽视的。理解并落实好这'三段'的可靠性保障,是用好消息队列、避免'消息悄悄丢失'的根本。
第四件事:消息队列的"三大可靠性难题"——不丢、不重、不乱
这次踩坑,让我系统地认识了消息队列在"可靠性"上,要面对的三大经典难题。它们环环相扣,理解了它们,才能真正用好 MQ:
消息队列的"三大可靠性难题": 不丢、不重、不乱
# 难题1: 消息不丢失 (本文)
# - 风险: 生产者发了没到 / MQ 重启丢了 / 消费者处理完前崩溃了
# - 解法: 生产者确认 + MQ持久化 + 消费者手动确认 (三段保障)
# 难题2: 消息不重复 (与"不丢"是一对孪生矛盾!)
# - 为什么会重复? 正是因为"不丢"的机制(重新投递)!
# 消费者处理完、ack 之前崩溃 → MQ 重投 → 消息被处理两次。
# - 大多数 MQ 是"至少一次(at-least-once)"投递 → 保证不丢, 但可能重复。
# - 解法: 消费者【幂等】(处理两次和一次效果相同)。
# → "不丢"和"不重", 是一对需要同时解决的孪生问题!
# 难题3: 消息不乱序 (顺序性)
# - 风险: 同一业务的多条消息(如"下单"→"付款"→"发货"), 处理顺序乱了。
# - 为什么会乱? 多个消费者并发消费, 或重投打乱了顺序。
# - 解法: 把"需要保序"的消息, 路由到"同一个队列/分区", 由"单消费者"顺序处理。
# (如 Kafka 按 key 分区, 同 key 进同分区, 分区内有序)
# → 但"严格保序"会牺牲并发性能, 要权衡: 哪些真的需要保序?
# 三者的关系:
# - "不丢"(至少一次) → 引入了"可能重复" → 要靠"幂等"解决"不重"
# - "并发消费"(高吞吐) → 引入了"可能乱序" → 要靠"分区保序"解决"不乱"
# → 可靠性, 是和"性能"在权衡: 越可靠/有序, 往往吞吐越受影响。
核心: 用 MQ, 不只是"把消息发过去"那么简单;
要想清楚: 这个业务, 能不能丢消息? 能不能重复? 需不需要保序?
然后, 用对应的机制, 去满足它的可靠性要求。
这一梳理,让我对消息队列的可靠性,有了完整的全景认识。用 MQ,要面对"三大可靠性难题"——不丢、不重、不乱,而且它们环环相扣:难题1(不丢失)是本文的主题,靠"生产者确认 + MQ 持久化 + 消费者手动确认"三段保障。难题2(不重复)则是和"不丢"孪生的一对矛盾——恰恰是为了"不丢"而引入的"重新投递"机制,导致了"可能重复"(消费者处理完、ack 之前崩溃,消息就被重投、处理两次);大多数 MQ 是"至少一次(at-least-once)"投递,保证不丢、但可能重复,所以要靠"消费者幂等"来解决不重。难题3(不乱序):同一业务的多条消息(下单→付款→发货)如果处理顺序乱了,会出问题;乱序源于"多消费者并发"或"重投",解法是把需要保序的消息路由到同一个队列/分区、由单消费者顺序处理(如 Kafka 按 key 分区),但严格保序会牺牲并发性能。这三者的关系很深刻:"不丢(至少一次)"会引入"可能重复",要靠"幂等"解决;"并发消费(高吞吐)"会引入"可能乱序",要靠"分区保序"解决——而可靠性,本质上是在和"性能"权衡:越可靠、越有序,往往吞吐就越受影响。所以,用 MQ 绝不是"把消息发过去"那么简单,你必须先想清楚:这个业务,能不能丢消息?能不能重复?需不需要保序?然后,用对应的机制,去精准地满足它的可靠性要求。把这三大难题整理成一张表:
| 难题 | 风险根源 | 解法 |
|---|---|---|
| 不丢失 | 发送/存储/消费各段可能丢 | 生产确认+持久化+手动 ack |
| 不重复 | 重投(为不丢而做)导致重复 | 消费者幂等 |
| 不乱序 | 并发消费/重投打乱顺序 | 同 key 进同分区, 单消费者保序 |
第五件事:"功能正确"和"在异常下也正确",是两回事
这次踩坑,在思维层面给了我最大的触动——它让我深刻地认识到,"功能正确"和"在异常下也正确",是两个完全不同的层次。我把这个反思沉淀了下来:
深刻反思: "功能正确" vs "在异常下也正确"
# 我的代码, 在"正常情况"下, 是完全正确的:
# 消费者收到消息 → 处理 → (没崩溃) → 完成。 一切正常!
# 我自测时, 消费者没崩溃, 所以一切都对, 我就以为"做好了"。
# 可它在"异常情况"下, 就错了:
# 消费者收到消息 → 处理到一半 → 【崩溃】→ 消息丢了。
# 而"消费者崩溃", 在生产环境, 是【一定会偶尔发生】的!
# 两个不同的层次:
# - "功能正确": 在"一切顺利、没有异常"时, 能做对。(入门要求)
# - "在异常下也正确(健壮性)": 在各种异常发生时(崩溃、断网、超时、重启...),
# 依然能保证正确(不丢数据、不出错、能恢复)。(高级要求)
# 为什么"异常下的正确"更难、也更重要?
# - 异常, 在生产环境是"常态"(墨菲定律: 能出错的, 一定会出错)
# - 异常, 往往是"小概率、随机"的 → 自测时碰不到 → 容易被忽略
# - 可一旦发生, 后果可能很严重(丢数据、资损)
# → 真正可靠的系统, 比的不是"正常时多好", 而是"异常时多稳"。
# 怎么做到"异常下也正确"?
# 1. 主动地、刻意地去想: "如果这一步, 突然崩溃/失败/超时, 会怎样?"
# 2. 对每一个"关键操作 + 可能的异常", 都设计好"它出问题时, 如何保证正确"。
# 3. 故障演练: 主动地注入崩溃/断网等异常, 验证系统是否依然正确。
核心: 写出"正常时能跑"的代码是入门;
写出"异常时也对"的代码, 才是真正的可靠 —— 而后者, 才是工程的精髓。
这层反思,是这次踩坑给我最深远的收获。复盘我的错误,我发现:我的代码,在"正常情况"下,是完全正确的——消费者收到消息、处理、完成,一切顺利;而我自测时,消费者没崩溃,所以一切都对,我就以为"做好了"。可它在"异常情况"下,就错了——消费者处理到一半崩溃,消息就丢了;而"消费者崩溃",在生产环境里,是一定会偶尔发生的。这让我清醒地认识到,有两个完全不同的层次:"功能正确"——在"一切顺利、没有异常"时能做对,这只是入门要求;而"在异常下也正确(健壮性)"——在各种异常发生时(崩溃、断网、超时、重启……),依然能保证正确(不丢数据、能恢复),这才是高级要求。而"异常下的正确",为什么更难、也更重要?因为:异常,在生产环境是"常态"(墨菲定律:能出错的,一定会出错);异常,往往是"小概率、随机"的,所以你自测时碰不到、容易被忽略;可它一旦发生,后果可能很严重(丢数据、资损)。所以,真正可靠的系统,比的不是"正常时表现多好",而是"异常时表现多稳"。我那个 bug,正是栽在了我只关注"功能正确"(正常时能处理消息)、却完全忽略了"异常下的正确"(消费者崩溃时消息会丢)上。而要做到"异常下也正确",关键是:主动地、刻意地去想"如果这一步,突然崩溃/失败/超时,会怎样?";对每一个"关键操作 + 可能的异常",都设计好"它出问题时,如何保证正确";并用"故障演练"(主动注入崩溃、断网等异常)去验证。一句话:写出'正常时能跑'的代码是入门,写出'异常时也对'的代码,才是真正的可靠——而后者,恰恰是工程的精髓所在。把"功能正确"和"异常下正确"两个层次对比成一张表:
| 维度 | 功能正确(入门) | 异常下正确(高级) |
|---|---|---|
| 考虑的场景 | 一切顺利 | 崩溃/断网/超时/重启 |
| 自测能发现吗 | 能 | 难(异常随机、小概率) |
| 难度 | 低 | 高 |
| 对可靠性 | 不够 | 关键 |
| 体现的功力 | 会实现功能 | 能驾驭复杂与异常 |
一张"用消息队列该怎么保证可靠"的决策图
把这次踩坑沉淀成一张图。每当你要用消息队列处理业务时,照着它把可靠性补全:
这张图的核心:不能丢消息的业务,要做"生产者确认 + MQ 持久化 + 消费者手动确认"三段保障;重投可能重复就做幂等;需要顺序就同 key 进同分区单消费者处理。把"用 MQ 前先想清楚能不能丢、能不能重、要不要顺序"变成本能,那个"消息悄悄丢失"的坑就再也碰不到你。
我立下的几条消息队列规矩
这次"消息零星丢失"的事故后,我给自己立了几条规矩:
- 关键业务用手动确认:不能丢消息的业务(订单/支付),消费者一律用手动确认,处理成功才 ack,绝不用 auto-ack。
- 消费者要幂等:手动确认会带来重投、重复,消费者必须幂等,处理两次和一次效果相同。
- 三段都做可靠保障:生产者发送确认、MQ 持久化、消费者手动确认,三段都做好,端到端不丢。
- 需要顺序就分区保序:有顺序要求的消息,同 key 路由到同分区、单消费者顺序处理。
- 先想清楚可靠性要求:用 MQ 前先想清楚"能不能丢、能不能重、要不要顺序",再选对应机制。
- 关注异常下的正确:不只测正常流程,主动想"这一步崩溃/失败会怎样",为异常设计好正确性。
- 故障演练:主动注入消费者崩溃、MQ 重启等故障,验证消息是否真的不丢不乱。
这几条里,第一条和第二条是直接根治这次 bug 的核心。而贯穿所有规矩的那条主线,是对"异常情况"的主动考量。我这次栽跟头,根子上是我在设计时,脑子里只有"正常的、顺利的"流程——消息发出去、被收到、被处理、完成,一切美好;我完全没有去主动地想"如果这中间某一步出了异常(比如消费者崩了),会发生什么"。而恰恰是这个被我忽略的"异常情况"(消费者崩溃),成了消息丢失的根源。这背后,是一个深刻的工程素养的差距:一个新手,设计时往往只考虑"happy path(顺利路径)";而一个有经验的工程师,会习惯性地、主动地,去考量那些"unhappy path(不顺利路径)"——崩溃、失败、超时、断网、并发、重复……并为这些异常,提前设计好应对。'主动地、刻意地去考量异常情况,并为之设计',是写出健壮、可靠系统的核心素养——因为异常,在真实世界里是必然会发生的,而一个只考虑了正常情况的系统,在异常面前,是脆弱不堪的。
写在最后:真正的可靠,是为"不顺利"准备好的
这次被"消息丢失"教育的经历,给我一个关于"可靠"的深刻领悟:真正的可靠,不是体现在"一切顺利"的时候——因为在一切顺利时,无论代码写得好坏,往往都能正常运转,看不出差别;真正的可靠,恰恰体现在"事情不顺利"的时候——当崩溃、失败、超时、断网这些异常发生时,一个可靠的系统,依然能稳住、能保证正确、能恢复,而一个不可靠的系统,则会在这些异常面前,丢数据、出错、甚至崩溃。所以,'可靠',本质上,是一种'为不顺利做好了准备'的能力。我那个消息处理系统,在"顺利"时(消费者不崩溃),工作得完美无缺;可它的不可靠,正暴露在"不顺利"时(消费者崩溃)——那一刻,它丢了消息。而它和一个可靠系统的差距,恰恰就在于:可靠的系统,提前为"消费者会崩溃"这个不顺利,做好了准备(用手动确认确保消息不丢);而我的系统,没有。
想通这一点,我对"为不顺利做准备"这件事的价值,有了更深的敬畏。我们做事,常常乐观地、本能地,去为"顺利的情况"做打算、做设计,而下意识地回避、或忽略那些"不顺利的情况"——觉得"应该不会出问题吧""崩溃是小概率事件吧"。可现实是,"不顺利",才是常态的一部分;墨菲定律早就告诉我们:凡是可能出错的,终将出错。而真正的智慧、真正的可靠,恰恰在于:不抱'侥幸',不只为'顺利'打算,而是清醒地、主动地,去为那些'可能的不顺利',提前做好准备和应对。这,体现在一个工程师对"异常路径"的重视、对"故障"的预演、对"墨菲定律"的敬畏上;也体现在一个人,在做任何重要的事时,不只憧憬"成功",更会预想"万一失败/出问题,我该如何应对"的审慎上。'为不顺利做好准备',看似是一种'悲观',实则是一种最深刻的'负责'与'可靠'——因为只有为最坏的情况做了准备,你才能在最坏的情况真的发生时,从容地应对、稳稳地兜住,而不至于手忙脚乱、损失惨重。
所以,如果你也想构建出真正可靠的系统、做成真正靠谱的事,我想把这次踩坑最想说的话送给你:别只为"顺利的情况"做设计,要主动地、清醒地,去为那些"可能的不顺利",提前准备好应对。写代码时,不只问"它正常时怎么工作",更要问"它崩溃/失败/超时时会怎样,我怎么保证正确";做架构时,不只设计"happy path",更要为各种异常、故障,设计好兜底、恢复、容错;做任何重要的决策时,不只憧憬最好的结果,更要预想最坏的情况、并准备好应对的预案。因为'顺利'时,人人都能正常运转,看不出可靠与否;唯有'不顺利'时,才见真章——而一个系统、一个人,可靠不可靠,恰恰就藏在,它有没有为那些'不顺利的时刻',提前准备好了从容应对的能力。真正的可靠,从来不是'祈祷一切顺利',而是'即使不顺利,我也早已准备好,稳稳地接住它'。那几个悄悄丢失的订单消息,最终教给我的,正是这份'为不顺利做好准备'的可靠之道——它让我懂得,构建可靠的系统,不能只盯着阳光灿烂的顺境,更要为那些必将到来的、风雨交加的逆境,提前撑好伞、备好船;因为一个系统真正的韧性与可靠,从来不是在顺境中炼成的,而是在它为逆境所做的、那一份份周全的准备之中。
—— 别看了 · 2026