想象你经营一家开在写字楼里的热门餐厅:一楼收银台负责接单收钱,三楼厨房负责出菜。如果收银员冲着天花板喊一句“一份宫保鸡丁”,三天后八成会变成“宫保鸡丁?没听见”。于是老板立了一条规矩:收银员接单时,必须用同一块夹板上的复写纸,一次写下两份单——一份给顾客,一份塞进厨房票架。只要夹板上的笔没坏,菜和单永远成对出现。这套“票根系统”,就是今天要讲的本地消息表:用同一个数据库事务,把“业务操作”和“待发消息”绑在一起;再由传菜员定时翻票架、把单子送往厨房;厨房看单号防止重复做菜;打烊后三方对账,保证一道菜都不少。这套机制不追求“瞬间一致”,而是接受“晚一点一定一致”,这就是分布式系统里大名鼎鼎的最终一致性。

岗位关联

本地消息表是分布式系统里性价比最高的最终一致性方案之一。掌握它,你就能独立设计“业务+消息同库、异步投递、幂等消费”的可靠链路,在微服务架构实战与面试中拿出有说服力的方案。

目录
  1. 可视化演示
  2. 问题背景:一张被喊丢的菜单
  3. 核心原理:同一块夹板上的复写纸
  4. 消息表设计:一张票根上要印哪些栏
  5. 投递机制:传菜员定时翻票架
  6. 幂等消费:厨房认单不认人
  7. 性能优化:一张票架变成一排票架
  8. 生产实践:打烊后的三方对账
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

演示业务与消息同事务写入、定时扫描投递MQ与幂等消费闭环,理解本地消息表保障分布式最终一致性的机制。

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

同事务写消息 业务+消息 原子落库 txn(biz,msg) 一个事务保证原子

问题背景:一张被喊丢的菜单

餐厅刚开业时,老板觉得装传菜系统太贵,让收银员直接对厨房喊单。周末爆满,喊声淹没在炒勺声里,顾客等了四十分钟,厨房却查无此菜,投诉率飙升。老板被迫思考:到底怎样才能让“楼下收钱”和“楼上做菜”这两件跨楼层的事,准确对上号?

在分布式系统里,“楼下”和“楼上”是两个独立服务,订单库和支付库分属不同数据库,单个数据库的事务管不到别人家。最经典的办法是两阶段提交(2PC):先让所有参与者“预提交”,都同意后再“正式提交”。这就像两个楼层各派一名主管,举着对讲机反复确认“你可以吗?我可以”。一旦有人手机没电,整条链路僵住,所有桌都别想上菜——同步阻塞、协调者单点、可用性差,是 2PC 的致命伤。

本地消息表的思路完全换了一个方向:不追求“同一瞬间达成一致”,而是先保证“我方这一亩三分地绝对可靠”,再靠异步任务把差异慢慢抹平。它放弃强一致,接受一个可量化的“最终一致窗口”,换取系统的可用性和吞吐。业务上只要窗口足够短、失败可监控可补偿,用户几乎无感。

节点越多,两阶段提交越容易整体失败,这个直觉可以用一个公式表达:

所以老板明白了一个道理:与其让两个主管举着对讲机互等,不如先把单子用复写纸锁死在夹板上,后面慢慢送。跨楼层的问题,从来不是“喊得响不响”,而是“有没有一张跑不掉的票根”。

P全部成功=∏i=1n(1−pi)\displaystyle P_{\text{全部成功}} = \prod_{i=1}^{n}(1 - p_i)
每个参与方都有一点失败概率,乘起来之后,整体成功的概率随节点数急剧下降。
  1. n 表示参与方数量分布式事务涉及的节点数,例如订单服务、支付服务、库存服务,每个参与者都要点头,事务才算成功。
    n\displaystyle n
  2. p_i 表示第 i 个节点的失败概率每个服务可能宕机、超时或网络抖动,失败概率各不相同。
    pi\displaystyle p_i
  3. 1 - p_i 是第 i 个节点成功的概率成功概率等于 1 减去失败概率,这是概率论的基本关系。
    1−pi\displaystyle 1 - p_i
  4. 连乘符号 \prod把所有节点的成功概率乘起来,得到“所有人都成功”的整体概率。
    ∏i=1n\displaystyle \prod_{i=1}^{n}

核心原理:同一块夹板上的复写纸

老板定下铁律:接单时,收银员必须把“顾客点了什么”和“这张单子已记录”同时写在复写纸上,一次落笔,两份都清晰。笔没水了?两份一起作废重写。这就是原子性:不许出现“单子记了菜没记”,或者“菜记了单子没记”的尴尬。

映射到技术:业务服务在操作自己的数据库时,把“业务变更”(如插入订单)和“往本地消息表插入一条待发消息”放进同一个数据库本地事务。消息表和业务表同库,共享同一个事务管理器,由数据库保证原子提交。

流程很朴素:开启事务 → 写业务记录 → 写消息记录 → 提交。提交成功,业务和消息同时对外可见;提交失败,一起回滚,连“消息曾经存在过”的痕迹都没有。这正是“本地消息表”名字的由来:消息不是先发给 MQ,而是先稳稳落在业务库的表里,事后由任务捞出来投递。它天然保证了“业务成功则消息必不丢”这一条最重要的可靠性。

复写纸的妙处在于:顾客和厨房各拿一份,互不打扰。同理,业务服务写完即返回“下单成功”,不用等支付、库存等服务同步响应——解耦换来的是响应更快、可用性更高,代价只是“别人晚一点知道”。

T={业务写入}∪{消息写入},要么全部提交,要么全部回滚\displaystyle T = \{\text{业务写入}\} \cup \{\text{消息写入}\},\quad \text{要么全部提交,要么全部回滚}
业务写入和消息写入被装进同一个事务包里,成败与共,不可能只发生一半。
  1. T 表示一个数据库本地事务事务是数据库的“打包批量操作”,要么全做,要么全不做。
    T\displaystyle T
  2. 业务写入例如插入一条订单记录,这是对外可见的业务结果。
    业务写入\displaystyle \text{业务写入}
  3. 消息写入向本地消息表插入一条“待发”消息,记录着后续要通知谁、干什么。
    消息写入\displaystyle \text{消息写入}
  4. 并集符号 \cup表示这两类写入被装进同一个事务包里,共享命运。
    ∪\displaystyle \cup
  5. 要么全部提交,要么全部回滚提交成功则两条都生效;任何一步失败则两条一起消失,绝不留下半截状态。
    提交或回滚\displaystyle \text{提交或回滚}

消息表设计:一张票根上要印哪些栏

复写纸不能是白纸,老板设计了栏目:单号、桌号、菜品、金额、状态(待做/已出餐/已上桌)、重试次数、备注。栏太少,传菜员没法判断;栏太多,写字都费劲。

消息表字段也遵循同样的逻辑:主键 id;业务唯一键(如订单号),它是幂等的锚点;消息内容(JSON 字符串);状态(待发/已发/完成/死信);重试次数;下次投递时间;创建与更新时间。其中“状态 + 下次投递时间”要建联合索引,定时扫描就靠它命中。

状态机要严格单向:待发 → 已发 → 完成。投递失败不倒退,而是留在“待发”并累加重试次数,超过上限进入“死信”,由人工或补偿任务兜底。业务键要建唯一索引:同一订单无论产生多少条消息,业务语义上只能有一个“锚”,否则消费端根本无法去重。

老板还发现,若在票根上印“本单唯一编号”,就算单子送重复了,厨房也能认出来。消息表里的“业务唯一键”就是这个编号,它是后面幂等消费的第一道保险。

S:待发→已发→完成,R≤Rmax⁡\displaystyle S: \text{待发} \rightarrow \text{已发} \rightarrow \text{完成},\quad R \le R_{\max}
消息状态只能向前走;重试次数有上限,防止一条消息在系统里无限循环。
  1. S 表示消息状态消息表里每条记录都有一个状态字段,标记它走到了哪一个环节。
    S\displaystyle S
  2. 待发 \rightarrow 已发 \rightarrow 完成状态只能按这个方向流转:先待发,扫描投递后变已发,消费方处理完变完成。
    →\displaystyle \rightarrow
  3. R 表示重试次数每次投递失败,重试次数字段加一。
    R\displaystyle R
  4. R_{\max} 表示最大重试上限超过上限的消息进入“死信”状态,等待人工介入,防止无限循环。
    Rmax⁡\displaystyle R_{\max}

投递机制:传菜员定时翻票架

票根写好了,不能等顾客自己上厨房取。老板雇了一个传菜员,每 5 分钟巡视一次票架,每次最多抽 20 张送到厨房窗口,并在票根上盖“已送”章。如果厨房说没收到,传菜员就在重试次数上加一,按“1 分钟、2 分钟、4 分钟……”的节奏延长后再来。

技术实现:一个定时任务(Quartz、XXL-Job 或自研调度组件)周期性扫描消息表,选出 status=待发 且 下次投递时间 <= now 的记录,按 limit 分批取出,调用 MQ 发送接口。发送成功,状态置为“已发”;失败,则记录重试次数,并按指数退避更新下次投递时间。

工程要点:线上常常部署多个服务实例,如果每个实例都跑定时任务,同一消息可能被并发捞起。必须用行锁(SELECT …… FOR UPDATE)或乐观锁(update …… where status='待发')保证只有一台机器投递成功。此外,扫描要加 limit,避免一次捞几十万条把数据库拖垮;批大小要可配置,配合分片一起使用。

传菜员的节奏也很讲究:翻太勤,空转浪费;翻太慢,顾客饿肚子。退避重试就是让“越失败,越要冷静”,给下游系统喘息的时间,而不是用重试把对方打垮。

t下次=t当前+b×2R\displaystyle t_{\text{下次}} = t_{\text{当前}} + b \times 2^{R}
越失败,等得越久:重试等待时间按指数增长,给下游系统留出恢复时间。
  1. t_{\text{当前}} 表示本次重试的时刻记录当下时间,作为计算的起点。
    t当前\displaystyle t_{\text{当前}}
  2. b 表示基础间隔初始等待时长,例如 1 分钟。
    b\displaystyle b
  3. 2 的 R 次方重试次数越多,指数越大,等待时间成倍拉长,避免把系统打爆。
    2R\displaystyle 2^{R}
  4. t_{\text{下次}} 表示下次重试的时刻当前时间加上退避时长,得到下次扫描该再试的时间点。
    t下次\displaystyle t_{\text{下次}}

幂等消费:厨房认单不认人

传菜员手一滑,把同一张单子送进厨房两次。第一次厨师做了菜,第二次怎么办?如果无脑再做一份,顾客会收到两份宫保鸡丁。老板的规矩是:先看单号,已出过餐的单子直接塞进废纸篓。认单不认人,就是幂等。

MQ 的消息投递语义是“至少一次”:网络超时、客户端重启、服务端重发,都会导致同一条消息被消费方收到多次。所以消费端绝不能假设“我收到的每条消息都是第一次”。唯一的防线,是业务逻辑自身的幂等。

常见做法:消费端建一张去重表,把消息里的业务唯一键(订单号/流水号)作为唯一索引,处理前先插入,冲突则说明已处理,直接 ACK 跳过;或者利用业务表本身的唯一约束(如支付流水号)。流程总结为:查重 → 业务处理 → 记录已处理 → 确认消费。四个步骤要和业务操作放在同一个本地事务里,才能保证“处理了就一定记下来”。

厨房认单号,顾客才能安心。幂等看似是消费端的“额外负担”,实则是整个方案的最后一道闸门:投递可以重发、扫描可以重复,只要闸门在,系统永远只会产生一次业务效果,最终一致就不会变成“最终混乱”。

效果(第N次处理)=效果(第1次处理),∀N≥1\displaystyle \text{效果}(\text{第}N\text{次处理}) = \text{效果}(\text{第}1\text{次处理}),\quad \forall N \ge 1
不管同一消息被投递多少次,业务效果必须和第一次处理时完全一致。
  1. 效果(第 1 次处理)消息第一次被消费时产生的业务结果,比如给用户加了 100 积分。
    效果(第1次处理)\displaystyle \text{效果}(\text{第}1\text{次处理})
  2. 效果(第 N 次处理)第 N 次重复消费同样的消息,业务结果必须和第一次完全一样。
    效果(第N次处理)\displaystyle \text{效果}(\text{第}N\text{次处理})
  3. 等号表示两边结果等价,这正是幂等定义的核心。
    =\displaystyle =
  4. \forall N \ge 1对任意正整数 N 都成立,无论重投多少次,效果都不变。
    ∀N≥1\displaystyle \forall N \ge 1

性能优化:一张票架变成一排票架

餐厅火了,一天一万桌,一个票架塞满单子,传菜员翻找都要排队。老板把票架按楼层和区域拆成十几个,传菜员分组负责,翻单速度立刻提上去。这就是分片。

高并发下,消息表会迅速膨胀,单表扫描慢、索引大、写入变热。优化三板斧:第一,联合索引 + 分批 limit,让扫描走索引而不是全表;第二,分片,按订单号哈希或用户 ID 拆成多张表,每张表独立扫描投递;第三,归档,把“完成”状态的老消息定期迁移到历史表,缩小热表。

分片不是越多越好:分片键必须和业务查询模式对齐,否则某个分片成为热点,总吞吐被最短的木板卡住。多线程扫描时,不同分片天然隔离,不会互相竞争行锁;同一个分片内部仍要加锁防重投。容量监控要盯着每个分片的积压量、扫描延迟,而不是只看总量。

老板的经验是:拆票架之前,先数清楚哪个区域单量最大。技术同理,分片之前必须摸清业务键的分布,把热键打散,否则分片再多,也挡不住某个爆款桌把对应票架挤爆。

QPS总=QPS单片×N片,受热点分片约束\displaystyle \text{QPS}_{\text{总}} = \text{QPS}_{\text{单片}} \times N_{\text{片}},\quad \text{受热点分片约束}
分片越多并行度越高,但某个热点分片会成为瓶颈,拉低整体吞吐。
  1. QPS_{\text{单片}}单个分片每秒能处理的扫描与投递消息数,衡量单个票架的处理速度。
    QPS单片\displaystyle \text{QPS}_{\text{单片}}
  2. N_{\text{片}}分片总数,消息表按业务键拆成了多少张表。
    N片\displaystyle N_{\text{片}}
  3. 乘号 \times总吞吐约等于单分片吞吐乘以分片数,分片越多,并行度越高。
    ×\displaystyle \times
  4. 受热点分片约束如果某个分片写入特别集中,它会成为瓶颈,总吞吐就被它卡住。
    热点约束\displaystyle \text{热点约束}

生产实践:打烊后的三方对账

每天打烊,老板做三件事:看票架还有没有积压的单,翻重试次数异常高的票根,把点菜单、出餐记录、收银记录三方比对。发现“有单没菜”立刻补做,“有菜没单”追查谁漏了单。这套日常,就是生产的监控与对账。

上生产前必须配好监控:消息表积压量、扫描任务延迟、发送失败率、重试分布、死信数量。任何指标异常都应告警。更关键的是定期对账:业务库里的真实业务记录、消息表记录、MQ 消费方的处理结果,三方比对,发现缺口用补偿任务重新投递。因为本地消息表只能降低丢失概率,不能做到绝对零丢失,对账是最后的安全网。

经典故障复盘:某平台订单服务改造本地消息表后,定时任务误配成每分钟全表扫描,扫描 SQL 又没走索引,数据库 CPU 被打满,业务写入连带变慢。对策是强制 limit、加联合索引、按分片并发扫。另一个案例是双实例定时任务未加锁,同一条支付成功消息被投递两次,最终靠消费端幂等键拦截,用户感知无异常——这正好说明幂等设计不是锦上添花,而是防线。

老板的结论写在墙上:“可以慢,不能错;可以晚,不能丢。”本地消息表买到的是可靠性,付出的代价是一段可衡量的时间差。架构师要做的,是度量这个窗口、监控这个窗口,然后把窗口压缩到业务能接受的范围之内。

E[Δt]≤SLA,Δt=t完成−t提交\displaystyle \mathbb{E}[\Delta t] \le \text{SLA},\quad \Delta t = t_{\text{完成}} - t_{\text{提交}}
从消息落库到消费完成的平均时间差,必须小于业务约定的服务水平目标。
  1. t_{\text{提交}}业务和消息同库提交的时刻,也就是点菜单写下的瞬间。
    t提交\displaystyle t_{\text{提交}}
  2. t_{\text{完成}}消费方处理完消息、状态变为“完成”的时刻,相当于菜最终端上桌。
    t完成\displaystyle t_{\text{完成}}
  3. \Delta t两个时刻之差,即“最终一致窗口”的长度,这段时间里两边数据是不一致的。
    Δt\displaystyle \Delta t
  4. \mathbb{E}[\Delta t] \le \text{SLA}平均迟到时间要小于业务可接受的服务水平协议,例如“90% 的订单 1 秒内完成对账”。
    E[Δt]≤SLA\displaystyle \mathbb{E}[\Delta t] \le \text{SLA}

AI 应用场景

场景 1:电商下单与库存扣减

阶段:数据一致性与可靠性工程(分布式事务)

解决的问题:传统下单流程先写订单再调库存服务,要么订单成功但库存没扣(超卖),要么扣了库存订单却失败;用强一致 2PC 又太慢。本地消息表让订单与“扣库存消息”同库提交,后台异步投递,库存服务幂等扣减,既防超卖又不阻塞下单。

具体案例:某生鲜电商秒杀场景,订单服务把“写订单表”和“写本地消息表”放进同一事务,定时任务把消息推送至库存服务;库存服务按订单号幂等扣减,重复消息直接 ACK。大促峰值下订单成功率和库存准确率双双达标。

场景 2:支付回调与积分发放

阶段:消息驱动的最终一致性

解决的问题:支付成功后要给用户加积分,涉及支付系统和积分系统两个服务,直接 RPC 同步调用会因积分服务抖动导致“钱扣了分没加”。用本地消息表,支付成功回调里把流水写入和“加积分消息”写入放进同一事务,异步投递,积分服务幂等发放。

具体案例:某银行 App 信用卡还款返积分,每笔还款生成一条“返积分”本地消息,定时投递到积分服务;积分服务用“还款流水号”做唯一键,重复投递不重复发放,对账时零差错。

场景 3:搜索索引与数据同步

阶段:数据管道与异步构建

解决的问题:商品信息变更后需要同步到搜索引擎或数仓,传统全量重建慢且成本高;本地消息表把“商品变更事件”和业务更新绑定,增量同步,保证搜索数据和业务数据最终一致。

具体案例:某零售平台商品服务每次改价,同库写入一条“商品变更消息”,同步任务扫表后推送至 ES 索引构建服务,消费者按商品 ID 幂等更新索引,白天改价五分钟内可被搜索到。

Python 演示

python79 行
# -*- coding: utf-8 -*-# 模拟数据库: 本地消息表message_table = []msg_id_counter = 0 # 消费端幂等去重集合(模拟去重表)processed_key_set = set() # 模拟消息队列(投递目标)outbox = []  def create_order_with_message(order_id, amount):    """核心: 业务操作与消息写入在同一个本地事务中原子完成"""    global msg_id_counter     # 模拟业务执行结果, 失败则两者都不落库    business_success = True     if business_success:        msg_id_counter += 1        message_table.append({            'msg_id': msg_id_counter,            'order_id': order_id,            'amount': amount,            'status': 'PENDING',   # 待发            'retry': 0        })        return True    return False  def scan_and_deliver(batch_size=2):    """定时任务: 扫描待发消息, 模拟发送到 MQ"""    pending = [m for m in message_table if m['status'] == 'PENDING']    for msg in pending[:batch_size]:        msg['status'] = 'SENT'    # 已发        outbox.append(msg)  def consume(msg):    """消费端: 先幂等校验, 再执行业务"""    key = msg['order_id']     # 重复投递: 业务键已存在则直接跳过    if key in processed_key_set:        print('  幂等拦截: 订单 %s 已处理, 跳过重复消费' % key)        return     processed_key_set.add(key)    print('  处理订单 %s, 扣款金额 %.1f 元' % (key, msg['amount']))    msg['status'] = 'DONE'        # 完成  def run():    print('== 第一步: 两笔订单的业务与消息同库提交 ==')    create_order_with_message('A001', 99.9)    create_order_with_message('A002', 199.0)     print('== 第二步: 定时扫描并投递到 MQ ==')    scan_and_deliver(batch_size=5)     print('== 第三步: 消费端处理(含重复投递) ==')    for msg in list(outbox):        consume(msg)        consume(msg)   # 模拟 MQ 重复投递同一条消息     print('== 第四步: 新订单产生, 下一轮扫描重试 ==')    create_order_with_message('A003', 59.0)    scan_and_deliver(batch_size=5)    consume(outbox[-1])     print('== 本地消息表最终状态 ==')    for msg in message_table:        print('  ', msg)  if __name__ == '__main__':    run()
目的:用内存数据结构模拟完整的本地消息表闭环——业务与消息同库提交、定时扫描投递、消费端幂等拦截重复消息,最终状态机流转到“完成”。
  1. message_table.append({把一条消息记录追加进本地消息表,只有业务成功才会执行这步
  2. if business_success:模拟业务执行成功才写消息,失败则两条都不落库
  3. for msg in pending[:batch_size]:分批取出待发消息,避免一次捞太多压垮数据库
  4. msg['status'] = 'SENT'投递成功后,状态从“待发”流转为“已发”
  5. if key in processed_key_set:幂等校验:业务键已处理过则直接跳过
  6. msg['status'] = 'DONE'消费完成后状态流转为“完成”,闭环收尾

常见误区

误区我以为本地消息表就是 MQ 的一种,业务直接把消息写到表里就等于发到了消息队列,不用再引入 RocketMQ 或 Kafka 了。

正解本地消息表只是业务数据库里的一张普通表,它的职责是“可靠地暂存待发事件”,本身不具备消息队列的任何能力——不削峰、不广播、不管理消费位点。真正投递还是要发到 MQ,消息表是“生产者和 MQ 之间的保险柜”。就像票根夹板不是厨房,只是把单子可靠送到厨房的临时载体。

为何常见:名字里带“消息”两个字,又确实承担了发送前的暂存功能,很容易被误当成队列产品。

误区只要业务和消息同库提交,消息就永远不会丢,可以完全不用考虑对账和补偿了。

正解同库提交只保证了“业务成功则消息必落库”,但从表里捞出来投递到 MQ、再到消费方处理的整条链路,仍可能丢:投递失败、MQ 数据丢失、消费方处理崩溃。所以必须配合重试、死信、定期对账和补偿任务,才能把丢失率压到可接受范围。票根是写下了,但传菜员摔一跤把纸掉进水沟,菜照样上不了桌。

为何常见:只看到“同库原子写”这一环,把局部可靠性当成了全局可靠性。

误区用了本地消息表,订单和支付就能像单库事务一样,任何时刻两边都一模一样。

正解本地消息表买入的是最终一致性:业务提交后,消息可能几秒甚至几分钟后才被消费,中间存在一个“不一致窗口”。它只承诺“最终会一致”,不承诺“此刻就一致”。点菜单已经写好但菜还没出锅,顾客此刻确实没吃到——这是设计上主动接受的取舍。

为何常见:把“可靠”和“强一致”画等号,没理解原子性只发生在本地库,跨库的同步依然靠异步。

误区我在去重表上建了唯一索引,重复消息插入会失败,所以业务就一定不会重复处理。

正解唯一索引只能拦截“重复插入”这一种路径。如果业务副作用发生在插入之前(如先加库存再写去重记录),或插入失败时业务已经执行,索引就拦不住。正确做法是把“查重 + 业务处理 + 记录已处理”放进同一个本地事务,让去重和业务同生共死。厨房光有单号清单不够,还必须保证“看一眼单号再下锅”和“在清单上打勾”是连着完成的。

为何常见:把数据库约束当成业务逻辑,忽略了执行时序问题。

误区定时扫描间隔越短,消息投递越快,一致性窗口越小,所以干脆 1 秒扫一次全表。

正解扫描是要花钱的:全表扫描不命中索引会打满数据库 CPU,反而拖慢业务写入,造成雪崩。正确做法是联合索引 + 分批 limit + 分片并行,扫描频率配合批大小做容量规划,而不是一味求快。传菜员每分钟跑一趟空票架纯属浪费体力;但高峰期每 5 分钟才跑一趟,票架堆到地上同样糟糕。

为何常见:直觉认为“越快越好”,忽略了扫描本身对数据库的巨大消耗。

面试题

Q1. 本地消息表方案中,业务操作与消息记录之间依靠什么保证原子性?

单选题 · easy

  • A.消息队列的事务消息
  • B.同一个数据库本地事务← 正确答案
  • C.跨库的分布式事务锁
  • D.Redis 的原子操作命令

答案:B。本地消息表的核心是“同库”:业务表和消息表在同一个数据库里,共享同一个本地事务,提交则同时生效,回滚则同时消失。MQ 事务消息是另一种方案,跨库锁和 Redis 都无法提供数据库级别的原子提交。就像复写纸的两联必须同一次落笔,而不是两个本子各写各的。

Q2. 本地消息表方案最终提供的一致性类型是?

单选题 · easy

  • A.强一致性
  • B.线性一致性
  • C.最终一致性← 正确答案
  • D.因果一致性

答案:C。本地消息表允许业务提交后存在一个“消息还没被消费”的窗口,之后通过扫描投递、幂等消费达到一致,因此是最终一致性。强一致要求任意时刻读取都相同,线性一致和因果一致是更强的模型,都不是本方案的目标。

Q3. 定时扫描“待发”消息时,最合理的取数方式是?

单选题 · easy

  • A.一次全表扫描全部取出
  • B.按状态索引分批取出← 正确答案
  • C.将消息删除后重新插入
  • D.随机抽查若干条即可

答案:B。待发消息量大时,一次捞全部会拖垮数据库。正确姿势是利用“状态 + 下次投递时间”联合索引,按 limit 分批取出并逐批处理。全表扫描无索引、先删后插破坏一致性、随机抽查会漏消息。

Q4. 多个服务实例同时部署扫描任务时,防止同一条消息被重复投递的常用手段是?

单选题 · mid

  • A.扫描时对消息行加行锁← 正确答案
  • B.增加 MQ 消费端并发
  • C.缩短定时扫描的间隔
  • D.增大每次批量的条数

答案:A。多实例并发扫描时,SELECT …… FOR UPDATE 能锁定待处理行,让其他实例拿到同样的行时被阻塞或跳过,保证一条消息只有一个投递者。提高并发、缩短间隔、增大批量反而会加剧竞争。传菜员甲翻票架时,传菜员乙不能同时抽走同一张单。

Q5. 消息投递失败后,推荐采用哪种重试策略?

单选题 · mid

  • A.固定一秒重试一千次
  • B.指数退避并设最大值← 正确答案
  • C.失败后立即无限重试
  • D.直接丢弃不再投递

答案:B。指数退避(1 分钟、2 分钟、4 分钟……)让下游有恢复时间,并设置最大重试次数,超出后进入死信待人工处理。固定高频重试会打爆下游,立即无限重试可能放大故障,直接丢弃则造成业务丢失。

Q6. 消费端做幂等处理时,最可靠的判断依据是?

单选题 · mid

  • A.消息本身的全部内容
  • B.业务唯一键如订单号← 正确答案
  • C.MQ 的投递时间戳
  • D.消费实例的机器 IP

答案:B。幂等必须以业务唯一键(订单号、流水号)为锚:同一订单的重复消息业务键相同,才能被识别并拦截。消息内容可能因重试而略有不同,时间戳和 IP 每次都可能变,都不具备“业务上唯一”的语义。

Q7. 相比两阶段提交,本地消息表方案的优点包括?

多选题 · mid

  • A.不存在同步阻塞← 正确答案
  • B.系统可用性更高← 正确答案
  • C.能保证业务强一致
  • D.工程实现更简单← 正确答案

答案:ABD。本地消息表提交完本地事务就返回,消费者异步处理,不存在 2PC 的同步阻塞;参与者故障不会拖死全局,可用性更高;实现也简单。但它只保证最终一致,C 错误。就像餐厅不怕传菜员迟到,只要单子不会丢。

Q8. 高并发下本地消息表性能优化的有效手段包括?

多选题 · hard

  • A.按业务维度分片存储← 正确答案
  • B.完成消息定期归档← 正确答案
  • C.提高全表扫描频率
  • D.设计联合索引查询← 正确答案

答案:ABD。分片把单表拆成多张独立表并行扫描,归档把已完成数据迁走缩小热表,联合索引让扫描走索引而非全表。提高全表扫描频率而不建索引只会把数据库打满,C 错误。拆票架、清旧单、编索引,才能跑得动高峰。

Q9. 消费端必须处理幂等,是因为?

多选题 · mid

  • A.MQ 至少一次可能重复← 正确答案
  • B.扫描重试可能重发← 正确答案
  • C.业务要求强一致
  • D.网络超时触发重发← 正确答案

答案:ABD。MQ 默认至少一次投递,扫描端重试和网络超时重发都会让消费方收到重复消息,所以幂等是刚需。C 说反了:本地消息表恰恰接受最终一致,而不是要求消费端强一致。重复投递是常态,幂等是消费端的“安全带”。

Q10. 某订单服务双实例均运行扫描任务,且扫描逻辑未加任何锁。最可能出现的线上后果是?

单选题 · extreme

  • A.消息表数据意外丢失
  • B.同条消息被重复投递← 正确答案
  • C.数据库连接池自动扩容
  • D.本地事务被强制回滚

答案:B。两个实例同时捞起同一条待发消息并都投递,消费端会收到两条,只能靠幂等键拦截避免业务重复。消息表数据不会因并发扫描而丢失,连接池扩容和事务回滚与锁缺失没有直接关系。这提醒我们:扫描端加锁 + 消费端幂等,两道防线缺一不可。