想象一下,你托邮局给远方的朋友寄一封信。邮局提供三种服务:平信,寄出去就完事,丢了算你倒霉;挂号信,丢了会免费补发,但朋友可能接连收到两封一模一样的信;带唯一编号的挂号信,在补发的同时,收件人手里有一本登记簿,同一编号的信重复送达就直接扔掉,保证恰好收到一封。这三种服务,正是消息队列三大投递语义的日常模样:At-most-once(至多一次)、At-least-once(至少一次)、Exactly-once(恰好一次)。 消息队列是分布式系统里“送信”的邮局。生产者把消息交给 MQ,MQ 再投递给消费者,中途网络会抖动、机器会宕机,“信”随时可能丢。三种语义本质上是对“丢了怎么办”和“重复了怎么办”的不同态度。读完本文,你将像挑选邮寄服务一样,为不同业务精准选择合适的投递语义,并理解:所谓“恰好一次”,其实是“不丢”与“不重”两项能力的组合。

岗位关联

职业价值:投递语义是消息中间件选型与分布式一致性设计的核心决策点。掌握它,你才能在设计订单、支付、库存等关键链路时,自信地回答“消息丢了怎么办”“重复消费怎么防”。

目录
  1. 可视化演示
  2. 投递语义总览
  3. At-most-once:平信的洒脱
  4. At-least-once:挂号信的执着
  5. Exactly-once:唯一编号挂号信的魔法
  6. 幂等消费设计:那本登记簿怎么做
  7. 事务消息机制:发信前的“押金”
  8. 投递语义选型:按信的价值选服务
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示At-most-once、At-least-once、Exactly-once三种投递语义对比、ACK确认重传机制与幂等性去重设计,支持模式切换。

本节配有交互动效(进入视口后自动加载,约 3KB)。

三种投递语义对比 At-most-once可能丢失不重复 Exactly-once既不丢失也不重复 QoS=lost∪dup tolerance 选型匹配业务容忍度。

投递语义总览

邮局送信要过两个关卡:发件人把信交给邮局,邮局再把信送到收件人手里。消息队列也一样,生产者将消息发给 MQ,MQ 再投递给消费者。消息在两端之间奔波时,网络可能抖动、机器可能宕机,这封信就可能“丢”在半路。我们用字母 p 表示一次投递的丢失率,那么一次投递成功的概率就是 1 减去 p。

MQ 的可靠性承诺,本质上就是在回答两个问题:信丢了怎么办?信重复了怎么办? 平信(At-most-once)对这两个问题都不管不顾;挂号信(At-least-once)选择“丢了就补发”,代价是收件人可能收到多封;带编号的挂号信(Exactly-once)在补发的基础上增加一本登记簿,重复的信直接扔掉。三种语义各有代价:保护得越周全,消耗的资源就越多。

回到邮局场景:如果平信在途丢失的概率是 30%,那么 100 封信里大约有 30 封会石沉大海。后面你会看到,三种语义本质上都是在 p 不变的条件下,通过“是否重试”和“是否去重”来改变最终的可靠结果。

Psuccess=1−pP_{\text{success}} = 1 - p
一次消息投递的成功率,等于 1 减去当前网络条件下的丢件率。这个朴素公式是整个投递语义世界的基石——所有可靠性设计,都是围绕“如何对抗 p”展开的。
  1. p 代表单次丢失率p 是在特定网络环境下,一条消息从发送方到接收方途中丢失的概率。它可能来自网络抖动、内存溢出、节点宕机。
    p=P(消息途丢失)p = P(\text{消息途丢失})
  2. 1 - p 是单次成功概率这是一个互补事件——消息要么成功,要么失败,两者概率相加等于 1。
    Psuccess=1−pP_{\text{success}} = 1 - p

At-most-once:平信的洒脱

你寄出一封平信,邮局盖上邮戳就完事。信丢了?不知道,不补发,不追究。这就是 At-most-once 的世界观:消息最多被投递一次,发出去了就不再管结果。生产者把消息发给 MQ 后,既不等待确认,也不保存副本;MQ 把消息推给消费者,消费者处理完也不回执——整条链路“尽力而为”,任何一环失败,消息就悄无声息地消失了。

这种语义最典型的实现是“即发即弃”:生产者调用发送接口后立刻返回,MQ 尝试一次投递,无论消费者是否收到,流程都宣告结束。它不需要维护投递状态,不需要重试队列,内存占用极低,吞吐量极高。也正因如此,它的期望投递次数总是小于 1——每次发送都有一批消息注定奔赴“火葬场”。

哪些业务能接受这种“洒脱”?日志埋点、用户行为上报、监控指标采集。这些数据量巨大,偶尔丢几条无关痛痒——就像你不会为了一张广告传单的丢失而投诉邮局。用 At-most-once 换取极致的吞吐和极低的资源消耗,是性价比最优的选择。

Edelivery=1−pE_{\text{delivery}} = 1 - p
At-most-once 下,一条消息被成功投递的期望次数等于 1 减去丢件率。它不承诺“不丢”,只承诺“不重”——毕竟只发一次,自然没有重复的机会。
  1. 期望投递次数E 表示长期运行下,每条消息平均被消费者接收的次数。At-most-once 因为不重试,这个值天然小于 1。
    Edelivery=1×(1−p)+0×pE_{\text{delivery}} = 1 \times (1-p) + 0 \times p
  2. 代入 p 化简成功时投递 1 次,失败时投递 0 次,期望值恰好等于 1 - p。
    Edelivery=1−pE_{\text{delivery}} = 1 - p

At-least-once:挂号信的执着

挂号信服务在投递失败后会自动补发,直到收件人签收。但问题来了:如果第一封信其实送到了,只是签收单在回传途中弄丢了,邮局就会补发第二封——收件人不得不拆开两封一模一样的信。At-least-once 正是如此:消费者成功处理消息后,必须向 MQ 返回确认 ACK;若 MQ 在超时时间内没收到 ACK,就判定投递失败并重新发送。

这里的关键在于,MQ 无法区分“消息没送到”和“处理结果没传回来”。它只能基于确认超时做重试,因此重复投递成为必然的副作用。重试 n 次内至少成功一次的概率随着重试次数迅速趋近 1——这就是“至少一次”的数学底气。

At-least-once 支撑了绝大多数核心业务:订单状态通知、任务调度、短信/邮件推送。这些场景中,消息丢失的代价远高于消息重复——漏发一条订单超时提醒可能导致客户投诉,而重复提醒顶多让用户觉得“这系统有点啰嗦”。选择挂号信,就是选择“宁可多送,不可漏送”。

Psuccess=1−p nP_{\text{success}} = 1 - p^{\,n}
在重试 n 次的条件下,消息最终被成功投递的概率是 1 减去 p 的 n 次方。只要有足够的耐心,几乎什么样的消息都能送达——但请注意,送达次数可能不止一次!
  1. n 次连续失败概率每次投递失败概率为 p,n 次全部失败的概率就是 p 的 n 次方。这是独立性假设下的乘法法则。
    P连续失败 n 次=p nP_{\text{连续失败 n 次}} = p^{\,n}
  2. 至少一次成功的补事件只要不出现“连续全部失败”,结果就是成功。用 1 减去失败概率,得到最终成功率。
    Psuccess=1−p nP_{\text{success}} = 1 - p^{\,n}

Exactly-once:唯一编号挂号信的魔法

现在邮局推出最高档服务:挂号信上印着唯一编号,收件人手里有一本登记簿。信到了先查编号——咦,这封信之前收到过?直接扔进垃圾箱;新编号?签收、登记、收下。于是无论邮局补发多少次,收件人实际打开的信恰好一次。这就是 Exactly-once 的全部秘密:它不是一种独立的传输机制,而是“At-least-once 投递 + 消费端幂等去重”的组合。

在 MQ 层面,Exactly-once 通常依然采用“发送 + ACK + 重试”的 At-least-once 模式,保证不丢;在消费者层面,业务系统通过幂等键去重,保证重复消息不会重复执行。真正让“恰好一次”成立的,不是 MQ 的某种神奇开关,而是那条贯穿投递路径的业务唯一标识——它像登记簿上的编号,把重复的洪水拦在业务门外。

支付扣款、红包发放、库存扣减、积分入账——这些场景中,重复执行的后果是资金损失和用户投诉,丢失同样不可接受。只有“恰好一次”能同时满足“不能少”和“不能多”两个苛刻要求。不过要记住:这份魔法不免费,它的成本就在下一节——你要亲手维护那本登记簿。

Nbusiness=1N_{\text{business}} = 1
在幂等约束下,无论 MQ 投递多少次,业务真正执行处理的次数恒等于 1。这是“不丢(At-least-once)+ 不重(幂等)”两项能力叠加的必然结果。
  1. 业务执行次数以幂等为界N 表示业务逻辑实际执行次数。当消费者检测到重复消息时会直接跳过,因此 N 被约束为 1。
    Nbusiness=min⁡(到达次数,1)N_{\text{business}} = \min(\text{到达次数}, 1)
  2. 到达次数趋于无穷也不怕即便 MQ 因网络故障无限重发,由于每次重复都被登记簿拦截,业务执行次数依然恒定为 1。
    lim⁡到达次数→∞Nbusiness=1\lim_{\text{到达次数} \to \infty} N_{\text{business}} = 1

幂等消费设计:那本登记簿怎么做

收件人打开信箱,发现又来了一封编号为 “2024001“ 的信。他翻翻登记簿,上面已经写过这个编号,于是直接扔掉。幂等消费设计的核心,就是维护这样一本”编号登记簿”,用唯一的业务键识别每条重复消息。这个业务键可以是订单号、支付流水号、用户操作 ID,或者 MQ 消息自带的 MessageID——总之,它是那条消息指纹性的东西。

最常见的实现方法有三种。第一,数据库唯一索引:在业务表中为幂等键建立唯一约束,重复插入直接报错或返回 0 行影响,天然具备原子性。第二,Redis SETNX 原子操作:以幂等键为 Key,首次设置成功才执行业务,命令本身是原子的,高并发下不会出现两个线程同时通过判定。第三,状态机前置查询:处理前先查业务状态,若已是“已处理”则直接返回成功。无论哪种方案,都必须保证“检查”与“执行”之间的原子性——否则两个线程可能同时通过检查,同时执行业务。

需要注意的是,Redis 去重表的过期时间要大于消息最大重试周期,否则登记簿上的编号被擦除后,重复消息又会漏进来;数据库唯一索引则不存在这个问题,所以资金类操作建议以数据库唯一索引为主、Redis 为辅。登记簿做得越扎实,“恰好一次”的承诺就越值得信赖。

f(x)={1,x∉keys0,x∈keysf(x) = \begin{cases} 1, & x \notin \text{keys} \\ 0, & x \in \text{keys} \end{cases}
幂等判定函数:如果业务键 x 不在登记表中,执行处理并登记(返回 1);如果已经存在,说明是重复消息,直接跳过(返回 0)。这个看似简单的查表动作,就是 Exactly-once 落地的最关键一环。
  1. 集合成员判定keys 是所有已处理业务键的集合。判定 x 是否在集合中,决定这条消息是“新消息”还是“重复消息”。
    x∈keys  ⟺  已处理x \in \text{keys} \iff \text{已处理}
  2. 登记与执行不可分割若 x 不在集合中,必须立刻“执行业务 + 写入 keys”两步合一,防止并发下两个消费者同时处理同一条消息。
    execute(x)∧keys←keys∪{x}\text{execute}(x) \wedge \text{keys} \leftarrow \text{keys} \cup \{x\}

事务消息机制:发信前的“押金”

如果发件人想保证“钱扣了,信一定发出去”或者“钱没扣,信绝不发出”,该怎么办?普通平信和挂号信都不够用——它们只管投递,不管发件人的业务一致性。此时需要引入事务消息。它的玩法类似“押金模式”:发件人先向邮局交一笔押金(发送半消息),邮局将信暂存在仓库,暂不投递;紧接着发件人执行本地业务事务,比如扣款。本地事务成功后,通知邮局“可以送信了”,信才真正寄出;本地事务回滚,则通知邮局“取消寄送”,仓库里的信作废。

这中间有个精妙的设计:半消息。它在本地事务提交前不可见,消费者永远消费不到“未提交”的消息。如果发件人提交本地事务后忘记通知邮局了呢?邮局会主动回查发件人的事务状态——问一句“你那边到底成功没有?”以此兜底,避免消息永久滞留仓库。这样,本地事务与消息发送就绑成了原子操作。

事务消息解决的是生产端的原子性问题:比如电商下单时“扣库存 + 发消息通知仓库”必须同生共死。它常与 At-least-once 搭配——MQ 投递阶段仍靠重试保证不丢,消费端再靠幂等保证不重。这样从生产端到消费端,整条链路的可靠性才算闭环。

P(send)=P(local commit)∧P(notify)P(\text{send}) = P(\text{local commit}) \wedge P(\text{notify})
消息最终被投递的前提是:本地事务提交成功,且事务状态成功通知到 MQ。两者缺一不可,事务回查机制确保了即使通知丢失,MQ 也能通过主动询问来补上这一环。
  1. 两个条件缺一不可∧ 表示逻辑与,本地未提交或通知未送达,消息都不得对消费者可见。
    send  ⟺  local commit∧notify\text{send} \iff \text{local commit} \wedge \text{notify}
  2. 回查兜底当 MQ 无法确定发件人状态时,主动发起事务状态查询,从而保证“提交则必发,回滚则必不发”。
    unknown state⇒回查 local transaction status\text{unknown state} \Rightarrow \text{回查 local transaction status}

投递语义选型:按信的价值选服务

给朋友寄生日贺卡,用平信就够;寄房产过户文件,你恨不得用带卫星定位的保险箱。投递语义的选型没有银弹,一切都取决于消息背后的业务价值。当消息丢失带来的损失远大于重复处理的开销时,选 At-least-once;当重复执行的后果同样不可接受时,选 Exactly-once;当数据量大且对丢失不敏感时,At-most-once 是省钱的智慧。

多个因素需要综合权衡:业务容忍度(丢了是否致命?重复是否致命)、性能预算(重试和幂等会消耗 CPU、存储和网络)、下游能力(消费者是否方便实现幂等)、端到端延迟(At-most 延迟最低,Exactly 因重试和幂等开销最高)。我们可以用一个小模型来描述这个决策:设 λ 为业务权重,R 为可靠性收益,C_loss 为丢失成本,C_dup 为重复成本,则选择使总效用最大化的语义。

最终落地时,成熟系统通常采用混合策略:日志走 At-most-once,任务与通知走 At-least-once,资金与库存走 Exactly-once。这就像一家邮局同时提供平信、挂号信和带编号的挂号信——不同类型的邮件,选择不同的服务,用最合理的成本达成最可靠的投递。

U=λrR−λlCloss−λdCdupU = \lambda_r R - \lambda_l C_{\text{loss}} - \lambda_d C_{\text{dup}}
效用函数 = 可靠性收益 - 丢失代价 - 重复代价。当重复代价趋近于零时,At-least-once 胜出;当丢失和重复都不可接受时,只有 Exactly-once 能让效用最大化。
  1. 代价因子决定语义对每条业务线打三个分:可靠性收益、丢失成本、重复成本。权重 λ 表示该业务对这些指标的敏感程度。
    业务线↦(λr,λl,λd)\text{业务线} \mapsto (\lambda_r, \lambda_l, \lambda_d)
  2. 效用最大化选型计算每种语义下的 U 值,选最高者。日志场景 C_loss 低,At-most 胜出;支付场景 C_loss 和 C_dup 都极高,Exactly 胜出。
    \text{Semantics}^\* = \arg\max U

AI 应用场景

电商订单支付回调

阶段:消息可靠传输与幂等消费

解决的问题:支付宝等第三方支付平台的回调通知采用 At-least-once 语义,同一笔支付可能回调多次。如果忽略这一事实直接发货,用户付一笔钱却收到两件商品。AI 系统在接收支付回调时,必须用订单号做幂等处理,保证退款、发货、积分等业务只执行一次。

具体案例:某电商平台在订单服务中增加“支付流水号唯一索引”表,每次回调先尝试插入流水号,插入成功才执行发货逻辑。重复回调命中唯一索引,直接被判定为重复消息丢弃,系统在 618 大促期间拦截了 12% 的重复回调,避免了对账混乱。

日志与监控数据采集

阶段:高性能数据管道

解决的问题:全链路监控系统每天要采集 TB 级日志,消息丢失几条对统计聚合结果的影响微乎其微,但高吞吐低延迟是刚需。采用 At-most-once 投递语义,可以避免 ACK 等待和重试带来的性能损耗,让采集 Agent 全速发送。

具体案例:某大数据平台的日志管道将默认语义从 At-least-once 调整为 At-most-once 后,采集端吞吐提升约 40%,CPU 占用下降 25%。埋点丢失率约 0.3%,远低于业务可接受阈值,监控大盘的准确率不受影响。

分布式任务调度

阶段:任务可靠分发与去重执行

解决的问题:分布式定时任务平台需要保证“每天 2 点执行一次数据清洗”这个承诺不因消息丢失而落空。任务消息采用 At-least-once 投递,任务执行器以任务 ID 为幂等键,配合 Redis SETNX 实现“同一任务同时只允许一个节点执行”,既保证任务必达,又防止多个执行器重复处理数据。

具体案例:某金融科技公司的数据仓库每日任务调度改为“At-least-once + 任务幂等锁”方案后,任务漏执行率从 0.1% 降至 0,同时不会出现同一个任务被两台 Worker 同时执行导致的数据错乱。

Python 演示

python90 行
import random  class Message:    def __init__(self, mid, payload):        self.mid = mid        self.payload = payload  class FakeMQ:    """模拟一个会丢消息、会丢 ACK 的消息队列"""     def __init__(self, loss_rate=0.15, ack_loss_rate=0.1, max_retry=5):        self.loss_rate = loss_rate        self.ack_loss_rate = ack_loss_rate        self.max_retry = max_retry        self.attempts = 0     def _try_send(self):        self.attempts += 1        return random.random() >= self.loss_rate     def at_most_once(self, msg, service):        if self._try_send():            service.handle(msg)     def at_least_once(self, msg, service):        for _ in range(self.max_retry):            if self._try_send():                service.handle(msg)                if random.random() >= self.ack_loss_rate:                    return                # ack 丢失, broker 误以为投递失败, 继续重发  class PlainService:    """无幂等保护: 消息到一次, 业务执行一次"""     def __init__(self):        self.count = 0     def handle(self, msg):        self.count += 1  class DedupService:    """带去重表: 同一订单号只执行业务一次"""     def __init__(self):        self.done = set()        self.count = 0        self.repeat = 0     def handle(self, msg):        if msg.mid in self.done:            self.repeat += 1            return        self.done.add(msg.mid)        self.count += 1  def run(trials=100, loss=0.15, ack_loss=0.1):    random.seed(7)    msgs = [Message(i, "扣款") for i in range(trials)]     mq0 = FakeMQ(loss, ack_loss)    s0 = PlainService()    for m in msgs:        mq0.at_most_once(m, s0)     mq1 = FakeMQ(loss, ack_loss)    s1 = PlainService()    for m in msgs:        mq1.at_least_once(m, s1)     mq2 = FakeMQ(loss, ack_loss)    s2 = DedupService()    for m in msgs:        mq2.at_least_once(m, s2)     print("订单总数: %d" % trials)    print("At-most-once 业务执行: %d 次 (丢失 %d 笔订单)" % (s0.count, trials - s0.count))    print("At-least-once 业务执行: %d 次 (多执行 %d 次)" % (s1.count, s1.count - trials))    print("Exactly-once  业务执行: %d 次 (重复消息 %d 条被拦截)" % (s2.count, s2.repeat))    print("MQ 发送尝试次数: at-most=%d at-least=%d exactly=%d"          % (mq0.attempts, mq1.attempts, mq2.attempts))  if __name__ == "__main__":    run(trials=100, loss=0.15, ack_loss=0.1)
目的:用 120 行代码模拟三种投递语义在丢件与 ACK 丢失双重故障下的真实表现,直观对比“丢失”与“重复”两个指标的此消彼长。
  1. def _try_send(self):底层发送函数,以 loss_rate 概率随机丢件,真实 MQ 中对应网络故障或节点宕机。
  2. if random.random() >= self.ack_loss_rate:模拟消费者处理成功但 ACK 回传丢失的场景,这是 At-least-once 重复投递的根源。
  3. self.attempts += 1统计 MQ 的总发送尝试次数,用于对比三种语义对网络资源的消耗差异。
  4. if msg.mid in self.done:去重表的判重逻辑,同一业务键已处理过,直接拦截重复消息。
  5. self.done.add(msg.mid)处理前先登记业务键,确保“检查”与“登记”在单线程下保持原子性。

常见误区

误区感觉只要 MQ 切换到 Exactly-once 模式,消息就一定会恰好处理一次,跟业务代码没关系。就像买了一台高档洗衣机,丢进去衣服它就能洗得干干净净,你什么都不用管。

正解Exactly-once 必须由“At-least-once 投递”和“消费端幂等”共同实现。MQ 只负责重试不丢,重复消息的拦截靠业务侧维护的唯一键和去重表。邮局能保证挂号信补发,但收件人得自己拿登记簿对编号——那本登记簿,永远是你的业务代码。

为何常见:"Exactly"这个词太有迷惑性,让人误以为它是产品内置功能;实际上它是分布式共识中的端到端承诺,需要生产端、MQ、消费端三方协作。

误区选了 At-least-once,消费者会被同一条消息轰炸到崩溃,重复消息像雪崩一样停不下来。

正解工程实现中有 ACK 超时机制和最大重试次数。消费者通常会在几秒内返回 ACK,只有 ACK 丢失或消费者宕机的极端情况下才会重发,且重试次数有限(如 3-5 次)。重复是“少量且可控”的,配合幂等消费几乎无感。真正的雪崩风险反而来自错误关闭确认机制。

为何常见:把概念推演到极端情况,忽略了现实系统中的超时阈值和重试上限,也忽略了“重试要付出成本,没人会无限重试”的工程常识。

误区只要消息成功 send 给 MQ,就万事大吉了,MQ 那么可靠,消息怎么会丢?

正解丢失可能发生在任何环节:生产者发送时进程崩溃、MQ 收到消息但未落盘时宕机、消费者处理成功但 ACK 丢失、消息过期被删除……三种投递语义正是针对不同环节设计防护策略。At-least-once 通过 ACK 确认防止消费端丢失,持久化机制防止 MQ 重启丢失——缺一不可。

为何常见:大家往往只盯着网络这一段,忽略了“内存到磁盘”“进程到进程”这些更微妙的边界,而这些恰恰是分布式系统消息丢失的重灾区。

误区在消费逻辑前面加一行 Redis EXISTS 检查,如果 Key 不存在就执行业务,这就是完美的幂等方案。

正解EXISTS + 业务执行两步并非原子操作,并发场景下两个消费者可能同时通过检查、同时执行业务。必须使用 SETNX 这种原子指令,或者在数据库层用唯一索引兜底。此外 Redis Key 的过期时间必须大于消息最大重试周期,否则 Key 过期后重复消息会再次漏入。幂等的难点不在“去重”,而在“原子地去重”。

为何常见:在小流量或单线程环境中测试通过后,误以为高并发下也能安全运行;忽略了分布式系统中并发检查和执行的竞态条件。

面试题

Q1. At-most-once 投递语义的含义是?

单选题 · easy

  • A.消息最多被投递一次← 正确答案
  • B.消息至少被投递一次
  • C.消息恰好被投递一次
  • D.消息无限次被投递

答案:A。At-most-once 即“至多一次”,发送后不确认不重试,消息可能丢失但绝不会重复投递。就像平信,寄出去就完事。选项 B 是 At-least-once 的定义,C 是 Exactly-once 的定义,D 是重试失控的异常场景。

Q2. At-least-once 投递语义可能带来的主要问题是?

单选题 · easy

  • A.消息全部丢失
  • B.消息重复消费← 正确答案
  • C.消息无法发送
  • D.队列自动关闭

答案:B。At-least-once 通过 ACK 超时重试保证不丢,但因为无法区分“消息没送到”和“ACK 丢了”,必然可能重复投递。挂号信补发时收件人可能收到两封同样的信,重复消费是它的固有代价。

Q3. 以下哪种业务场景最适合采用 At-most-once 语义?

单选题 · easy

  • A.支付扣款处理
  • B.实时埋点日志← 正确答案
  • C.库存数量扣减
  • D.订单状态流转

答案:B。埋点日志数据量大且允许少量丢失,用 At-most-once 换取高吞吐。支付、库存、订单状态都关乎资金与核心数据,丢一条都可能造成严重事故,必须用 At-least-once 或 Exactly-once 保护。

Q4. 消费者处理消息成功但 ACK 在回传途中丢失,MQ 会如何反应?

单选题 · mid

  • A.消息立即删除
  • B.消息重新发送← 正确答案
  • C.队列立即停止
  • D.消费进度回退

答案:B。MQ 只依据 ACK 判断投递结果,超时未收到 ACK 就当作失败并重发消息。这正是 At-least-once 产生重复消费的根源——业务已处理,但 MQ 不知道。挂号信签收单丢了,邮局就会再送一封。

Q5. 实现 Exactly-once 语义通常需要哪些要素配合?

多选题 · mid

  • A.投递不丢失的重试机制← 正确答案
  • B.消费确认与超时重发← 正确答案
  • C.业务幂等键去重设计← 正确答案
  • D.TCP 长连接保证

答案:ABC。Exactly-once = At-least-once 投递(A、B 保证不丢消息)+ 消费端幂等(C 保证不重复处理),两者缺一不可。TCP 长连接只是传输层优化,与消息语义无直接关系。

Q6. 实现消息幂等去重时,以下哪种存储方案更稳妥?

单选题 · mid

  • A.进程内存缓存
  • B.Redis 原子写入← 正确答案
  • C.文件系统存储
  • D.日志采集代理

答案:B。Redis SETNX 是原子指令,能安全完成“检查 + 登记”两步操作,且支持设置过期时间,是分布式去重的常用方案。进程内存缓存无法跨节点共享,文件系统存储并发控制困难。

Q7. 事务消息中“半消息”的含义是?

单选题 · mid

  • A.已投递完成的消息
  • B.生产者待提交的消息← 正确答案
  • C.消费者已签收的消息
  • D.队列已备份的消息

答案:B。半消息是暂存于 MQ、对消费者不可见的消息。生产者本地事务提交后,半消息才转为可投递;事务回滚则半消息作废。它保证了“业务提交”与“消息发送”的原子性,就像邮局先把信扣在仓库,确认押金后才寄出。

Q8. 支付回调采用 At-least-once 时,服务端如何保证不重复扣款?

多选题 · hard

  • A.订单号唯一索引← 正确答案
  • B.处理前查询状态← 正确答案
  • C.分布式锁保护← 正确答案
  • D.无限次重发消息

答案:ABC。唯一索引让重复写入直接失败,状态查询让已处理订单直接返回,分布式锁保证并发安全。无限次重发反而会放大重复问题,正确做法是限制重试次数并配合幂等去重。

Q9. 设单次投递丢失率 p=0.1,At-least-once 重试 2 次后最终投递成功率是多少?

单选题 · hard

  • A.0.729
  • B.0.810
  • C.0.900
  • D.0.999← 正确答案

答案:D。重试 2 次意味着总共尝试 3 次,全部失败的概率是 p 的 3 次方,即 0.001。最终成功率 = 1 - 0.001 = 0.999。这个公式说明只要重试次数够多,消息几乎一定能送到——代价是可能重复。

Q10. 在不做任何幂等保护的情况下,At-least-once 无限重试最终会导致什么结果?

单选题 · extreme

  • A.消息必然丢失
  • B.最终恰好一次
  • C.重复放大失控← 正确答案
  • D.系统自动容错

答案:C。无限重试会持续产生重复消息,业务被执行无限次,直到资源耗尽或系统崩溃。如果每次重发都因 ACK 丢失而继续重试,“不丢”变成了“无限重”,而“恰好一次”永远不会自动出现——它必须依赖消费端的幂等去重。