每天总有那么几个订单,消息明明发出去了、却像人间蒸发了一样没被处理:我在消息队列里栽进的"自动确认导致消息丢失"的坑的深度复盘

用 MQ 做异步处理,可财务对账发现每天总有极少数订单的消息明明发出去了、消费者却没有任何处理记录——像人间蒸发了。根因是消费者用了自动确认(auto-ack):消息一收到 MQ 就当它"处理成功"删掉了,可"收到"不等于"处理完",消费者一旦在处理到一半时崩溃,消息就被永久丢失。这篇从 ack 确认机制讲到手动确认+消费者幂等的正解、消息可靠的生产者/MQ/消费者三段保障、不丢不重不乱三大难题,以及"功能正确不等于异常下也正确""真正的可靠是为不顺利准备好的"。

每天总有那么几个订单,消息发出去了、却像人间蒸发一样没被处理:我在消息队列里踩的"消息丢失"的坑

这是一个"零星发作、却又确凿存在"的诡异问题。我用消息队列(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 前先想清楚能不能丢、能不能重、要不要顺序"变成本能,那个"消息悄悄丢失"的坑就再也碰不到你。

我立下的几条消息队列规矩

这次"消息零星丢失"的事故后,我给自己立了几条规矩:

  1. 关键业务用手动确认:不能丢消息的业务(订单/支付),消费者一律用手动确认,处理成功才 ack,绝不用 auto-ack。
  2. 消费者要幂等:手动确认会带来重投、重复,消费者必须幂等,处理两次和一次效果相同。
  3. 三段都做可靠保障:生产者发送确认、MQ 持久化、消费者手动确认,三段都做好,端到端不丢。
  4. 需要顺序就分区保序:有顺序要求的消息,同 key 路由到同分区、单消费者顺序处理。
  5. 先想清楚可靠性要求:用 MQ 前先想清楚"能不能丢、能不能重、要不要顺序",再选对应机制。
  6. 关注异常下的正确:不只测正常流程,主动想"这一步崩溃/失败会怎样",为异常设计好正确性。
  7. 故障演练:主动注入消费者崩溃、MQ 重启等故障,验证消息是否真的不丢不乱。

这几条里,第一条和第二条是直接根治这次 bug 的核心。而贯穿所有规矩的那条主线,是对"异常情况"的主动考量。我这次栽跟头,根子上是我在设计时,脑子里只有"正常的、顺利的"流程——消息发出去、被收到、被处理、完成,一切美好;我完全没有去主动地想"如果这中间某一步出了异常(比如消费者崩了),会发生什么"。而恰恰是这个被我忽略的"异常情况"(消费者崩溃),成了消息丢失的根源。这背后,是一个深刻的工程素养的差距:一个新手,设计时往往只考虑"happy path(顺利路径)";而一个有经验的工程师,会习惯性地、主动地,去考量那些"unhappy path(不顺利路径)"——崩溃、失败、超时、断网、并发、重复……并为这些异常,提前设计好应对。'主动地、刻意地去考量异常情况,并为之设计',是写出健壮、可靠系统的核心素养——因为异常,在真实世界里是必然会发生的,而一个只考虑了正常情况的系统,在异常面前,是脆弱不堪的。

写在最后:真正的可靠,是为"不顺利"准备好的

这次被"消息丢失"教育的经历,给我一个关于"可靠"的深刻领悟:真正的可靠,不是体现在"一切顺利"的时候——因为在一切顺利时,无论代码写得好坏,往往都能正常运转,看不出差别;真正的可靠,恰恰体现在"事情不顺利"的时候——当崩溃、失败、超时、断网这些异常发生时,一个可靠的系统,依然能稳住、能保证正确、能恢复,而一个不可靠的系统,则会在这些异常面前,丢数据、出错、甚至崩溃。所以,'可靠',本质上,是一种'为不顺利做好了准备'的能力。我那个消息处理系统,在"顺利"时(消费者不崩溃),工作得完美无缺;可它的不可靠,正暴露在"不顺利"时(消费者崩溃)——那一刻,它丢了消息。而它和一个可靠系统的差距,恰恰就在于:可靠的系统,提前为"消费者会崩溃"这个不顺利,做好了准备(用手动确认确保消息不丢);而我的系统,没有。

想通这一点,我对"为不顺利做准备"这件事的价值,有了更深的敬畏。我们做事,常常乐观地、本能地,去为"顺利的情况"做打算、做设计,而下意识地回避、或忽略那些"不顺利的情况"——觉得"应该不会出问题吧""崩溃是小概率事件吧"。可现实是,"不顺利",才是常态的一部分;墨菲定律早就告诉我们:凡是可能出错的,终将出错。而真正的智慧、真正的可靠,恰恰在于:不抱'侥幸',不只为'顺利'打算,而是清醒地、主动地,去为那些'可能的不顺利',提前做好准备和应对。这,体现在一个工程师对"异常路径"的重视、对"故障"的预演、对"墨菲定律"的敬畏上;也体现在一个人,在做任何重要的事时,不只憧憬"成功",更会预想"万一失败/出问题,我该如何应对"的审慎上。'为不顺利做好准备',看似是一种'悲观',实则是一种最深刻的'负责'与'可靠'——因为只有为最坏的情况做了准备,你才能在最坏的情况真的发生时,从容地应对、稳稳地兜住,而不至于手忙脚乱、损失惨重。

所以,如果你也想构建出真正可靠的系统、做成真正靠谱的事,我想把这次踩坑最想说的话送给你:别只为"顺利的情况"做设计,要主动地、清醒地,去为那些"可能的不顺利",提前准备好应对。写代码时,不只问"它正常时怎么工作",更要问"它崩溃/失败/超时时会怎样,我怎么保证正确";做架构时,不只设计"happy path",更要为各种异常、故障,设计好兜底、恢复、容错;做任何重要的决策时,不只憧憬最好的结果,更要预想最坏的情况、并准备好应对的预案。因为'顺利'时,人人都能正常运转,看不出可靠与否;唯有'不顺利'时,才见真章——而一个系统、一个人,可靠不可靠,恰恰就藏在,它有没有为那些'不顺利的时刻',提前准备好了从容应对的能力。真正的可靠,从来不是'祈祷一切顺利',而是'即使不顺利,我也早已准备好,稳稳地接住它'。那几个悄悄丢失的订单消息,最终教给我的,正是这份'为不顺利做好准备'的可靠之道——它让我懂得,构建可靠的系统,不能只盯着阳光灿烂的顺境,更要为那些必将到来的、风雨交加的逆境,提前撑好伞、备好船;因为一个系统真正的韧性与可靠,从来不是在顺境中炼成的,而是在它为逆境所做的、那一份份周全的准备之中。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我的交易欺诈检测模型,准确率高达 99.5%,可上线后它连一笔欺诈都没能抓到:被"准确率"这个误导性指标骗惨的一课的深度复盘

2026-6-1 20:58:53

技术教程

页面明明都关了,程序内存却只升不降、最后被撑爆:我在 C# 里订阅了事件却忘了退订,埋下的那个悄无声息的内存泄漏的深度复盘

2026-6-1 21:13:00

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索