消息语义描述的是系统发生重试、断连、进程崩溃后,仍能承诺什么。讨论“恰好一次”之前,必须明确计数对象:网络发送次数、消费者执行次数,还是数据库里最终生效的业务操作次数。这三者可以不同。
本文先用一次确认丢失解释问题,再讨论三种语义与实现机制。Kafka 的日志结构见 Kafka 架构 ,分区接管与 offset 管理见 消费组与协作 。
问题从哪里来:没有收到确认不等于没有成功
假设消费者处理事件 E42,把库存扣减一件,然后向消息系统确认:
确认丢失:业务已成功,消息系统却无法确认
消息系统
消费者
数据库
投递 E42
扣减库存
事务提交成功
ACK 在途中丢失
×
超时只能说明确认未到达;重新投递可能使库存被扣减第二次。
消息系统看到的只有“没有收到 ACK”。消费者可能根本没处理,也可能已经完成、只是确认丢了。前者需要重试,后者重试又可能扣减第二次。仅仅调长超时无法消除这个不确定窗口。
同样的问题也存在于生产者到 broker 的链路。生产者确认表示消息在 broker 侧达到某种接受条件;消费者确认表示处理进度可以推进。两种确认不能互相替代,RabbitMQ 将它们明确分为 Publisher Confirms 与 Consumer Acknowledgements 。
三种语义的准确含义
语义 对某条逻辑消息的承诺 典型实现思路 主要代价
At-most-once,至多一次 可以零次,但不重复交付或生效 不重试不确定结果,或先提交进度再处理 崩溃窗口可能丢处理
At-least-once,至少一次 在重试与恢复条件满足时最终处理,可能多次 处理完成后确认,失败重投 重复执行与去重成本
Exactly-once,恰好一次 在声明的边界内,效果等同于一次成功处理 原子提交进度和结果,或持久去重与副作用共事务 协调、存储与接口约束
“至少一次”依赖消息保留、持久化、恢复能力和重试策略。队列到期删除、重试次数耗尽或所有副本丢失,都不在无条件保证之内。“至多一次”也不是简单地关闭某一处重试就完成了:应用层重新提交同一个业务请求,仍可能产生两条不同消息。
恰好一次不要求处理函数物理上只运行一次。第一次执行可能回滚,第二次执行才提交;外界只能看到一次结果。不能直接由 FLP 推导出“恰好一次效果不可能”:FLP 讨论特定异步故障模型下确定性共识的终止性,不能替代这里对事务边界和可见结果的分析。
确认顺序如何决定故障窗口
把业务效果记为 E,确认或提交进度记为 C,两者独立时只有两种顺序:
两个独立提交点:交换顺序不能消除故障窗口
先确认 C
后处理 E
崩溃窗口
中间崩溃:漏处理
先处理 E
后确认 C
崩溃窗口
中间崩溃:重复处理
解决方向:共同事务,或持久去重与业务效果原子提交。
所以,单纯交换顺序无法同时消除遗漏和重复。系统必须进一步做到:让 E 和 C 原子发生,或者允许重复 E,但把重复转换为无额外副作用的操作。这就是事务与幂等性的作用。
幂等:重复请求如何得到同一个业务结果
数学上,操作 f 幂等意味着 f(f(x)) = f(x)。把状态设为 CONFIRMED,在只考虑该字段赋值时是幂等的;把数量加一则不是。但赋值旁边若还发送邮件、扣款或写审计事件,整个处理函数未必幂等。
业务去重需要稳定的事件 ID。同一事件的重投必须复用 ID;每次重试生成新 UUID 会让去重失效。常用键为 (consumer_name, event_id):同一事件可被不同业务消费者各处理一次。
下面是 PostgreSQL 的一个原子消费示例。前提是库存表中 product_id 唯一,处理成功定义为库存增加;消息格式校验在事务前完成。
CREATE TABLE inventory (
product_id bigint PRIMARY KEY ,
count bigint NOT NULL
);
CREATE TABLE processed_events (
consumer_name text NOT NULL ,
event_id text NOT NULL ,
PRIMARY KEY (consumer_name, event_id)
);
BEGIN ;
WITH first_delivery AS (
INSERT INTO processed_events (consumer_name, event_id)
VALUES (' inventory' , ' E42' )
ON CONFLICT DO NOTHING
RETURNING event_id
)
INSERT INTO inventory (product_id, count)
SELECT 42 , 1 FROM first_delivery
ON CONFLICT (product_id)
DO UPDATE SET count = inventory .count + EXCLUDED .count ;
COMMIT ;
第一次插入去重键成功,库存增加;第二次 first_delivery 为空,不再修改库存。若库存更新失败,整个事务回滚,去重键也不会留下。这里依赖 PostgreSQL 的 ON CONFLICT 与 RETURNING 语义 。
这个例子还揭示两个边界:去重记录与库存若位于两个独立数据库,以上原子性就不存在;如果提前清除 E42 的去重记录,旧消息再次回放就可能重新生效。去重保留期必须覆盖业务允许的重放范围。
版本条件更新也能防止覆盖,但 version mismatch 只说明状态已变化,不足以证明“这条事件已处理”。事件身份与并发版本解决的是两个不同问题。
动手观察:事务提交前后的失败,留下什么状态
用一个本地 SQLite 文件模拟库存消费者,初始库存为 100,E42 要扣减 3。这里把事件内容简化为一个整数 delta;去重记录同时保存 delta,避免同一个 ID 意外对应不同操作时被默默吞掉。真实事件还应比较商品、租户和业务参数,或比较规范化后的内容摘要。
import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory
class Interrupted ( Exception ) :
pass
def consume ( path , event_id , delta , fail_at = None ) :
db = sqlite3 . connect ( path , isolation_level = None )
try :
db . execute ( " BEGIN IMMEDIATE" )
inserted = db . execute (
" INSERT INTO done VALUES (?, ?) ON CONFLICT(id) DO NOTHING" ,
( event_id , delta ) ,
) . rowcount
if not inserted :
old_delta = db . execute (
" SELECT delta FROM done WHERE id = ?" , ( event_id , )
) . fetchone ( ) [ 0 ]
if old_delta != delta :
raise ValueError ( " 同一个事件 ID 对应不同内容" )
else :
if fail_at == " after_claim" :
raise Interrupted ( )
changed = db . execute (
" UPDATE stock SET n = n + ? WHERE n + ? >= 0 " ,
( delta , delta ) ,
) . rowcount
if changed != 1 :
raise ValueError ( " 库存不足" )
if fail_at == " after_effect" :
raise Interrupted ( )
db . execute ( " COMMIT" )
if fail_at == " after_commit" :
raise Interrupted ( ) except Exception :
if db . in_transaction :
db . execute ( " ROLLBACK" )
raise
finally :
db . close ( )
def state ( path ) :
with sqlite3 . connect ( path ) as db :
return ( db . execute ( " SELECT n FROM stock" ) . fetchone ( ) [ 0 ] ,
db . execute ( " SELECT count (* ) FROM done" ) . fetchone ( ) [ 0 ] )
with TemporaryDirectory ( ) as directory :
for cut in ( " after_claim" , " after_effect" , " after_commit" ) :
path = Path ( directory ) / f " { cut } .sqlite"
with sqlite3 . connect ( path ) as db :
db . executescript ( """
CREATE TABLE stock (n INTEGER NOT NULL CHECK (n >= 0 ));
INSERT INTO stock VALUES (100 );
CREATE TABLE done (id TEXT PRIMARY KEY , delta INTEGER NOT NULL );
""" )
try :
consume ( path , " E42" , - 3 , cut )
except Interrupted :
pass
assert state ( path ) == ( ( 97 , 1 ) if cut == " after_commit" else ( 100 , 0 ) )
consume ( path , " E42" , - 3 ) consume ( path , " E42" , - 3 ) assert state ( path ) == ( 97 , 1 )
for event_id , delta in ( ( " E42" , - 4 ) , ( " E43" , - 200 ) ) :
try :
consume ( path , event_id , delta )
except ValueError :
pass
else :
raise AssertionError ( " 应该拒绝内容冲突或库存不足" )
assert state ( path ) == ( 97 , 1 )
每次调用都重新打开数据库,观察的是提交后可读的状态。实验注入的是 Python 异常并显式回滚,展示事务边界;它没有模拟断电、磁盘故障或消息 broker。SQLite 只允许一个写事务,BEGIN IMMEDIATE 可能因其他写者返回忙;生产消费者还需要处理这类可重试失败。具体事务行为见 SQLite 事务文档 。
失败位置 库存 / 去重记录数 重试为何安全
已插入去重键,尚未扣库存 100 / 0 回滚连同去重键一起撤销,重试仍能执行
已扣库存,尚未提交 100 / 0 扣减与去重一起回滚,重试不会叠加旧效果
已提交,成功响应未到达 97 / 1 重试看到相同事件及内容,跳过第二次扣减
第三行才是幂等记录真正消解的不确定窗口。若去重键先在独立事务提交,第一行会变成“已处理但库存没改”;若业务先独立提交,第二行会变成“库存已改但没有去重证据”。两张表放在同一个数据库里还不够,必须由同一个事务提交。
库存不足在这里被定义为拒绝执行,因此没有留下成功去重记录;业务系统可以把它记录为一个可查询的拒绝结果,但需要明确这个结果是否允许以后重试。若需求是让重试返回首次生成的订单号、收据或拒绝原因,还要在事务里保存该结果,仅记录一个“出现过”的 ID 不足以重建响应。
响应丢失后,重投会不会再扣一次?
把同一次 E42 的“处理尝试数”与“持久扣减次数”分别计数,才能准确讨论恰好一次效果。可以在提交前崩溃,也可以在提交后、ACK 前崩溃:前者丢弃暂存,后者保留数据库结果却让 broker 仍可重投。切换策略会重置整段历史,比较去重记录是否与效果共享原子边界。
正在呈现知识画面 重新加载
响应丢失后,重投会不会再扣一次? ACK 未到达时,broker 无法知道业务是否已提交。效果与去重记录共同提交,才能让同一事件的重投不重复扣减。
{"id":"message-effect-and-ack","title":"响应丢失后,重投会不会再扣一次?","summary":"ACK 未到达时,broker 无法知道业务是否已提交。效果与去重记录共同提交,才能让同一事件的重投不重复扣减。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"dedup\" data-i18n=\"dedup\"></button><button data-mode=\"plain\" data-i18n=\"plain\"></button></div><div class=\"metrics\"><div><span data-i18n=\"stock\"></span><strong id=\"stock\"></strong></div><div><span data-i18n=\"attempts\"></span><strong id=\"attempts\"></strong></div><div><span data-i18n=\"effects\"></span><strong id=\"effects\"></strong></div><div><span data-i18n=\"record\"></span><strong id=\"record\"></strong></div></div><div id=\"broker\" class=\"cell\"></div><div class=\"actions\"><button id=\"deliver\" data-i18n=\"deliver\"></button><button id=\"commit\" data-i18n=\"commit\"></button><button id=\"ack\" data-i18n=\"ack\"></button><button id=\"crash\" data-i18n=\"crash\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"staged\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"saved\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"idle\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"done\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.metrics strong{min-height:1.4em}#broker{min-height:65px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let mode='dedup',stock,attempts,effects,record,phase,acked;function reset(){stock=100;attempts=effects=0;record=false;phase='idle';acked=false;draw();}function draw(){pressed(mode);set('stock',stock);set('attempts',attempts);set('effects',effects);set('record',record?'E42':'—');set('broker',t(acked?'acked':'pending'));$('#deliver').disabled=acked||phase!=='idle';$('#commit').disabled=phase!=='staged';$('#ack').disabled=phase!=='saved';set('explanation',t(acked?'done':phase));$('#broker').dataset.acked=String(acked);}$('#deliver').onclick=()=>{attempts++;phase='staged';draw();};$('#commit').onclick=()=>{if(!(mode==='dedup'&&record)){stock-=3;effects++;if(mode==='dedup')record=true;}phase='saved';draw();};$('#ack').onclick=()=>{acked=true;phase='idle';draw();};$('#crash').onclick=()=>{phase='idle';draw();};$('#reset').onclick=reset;document.querySelectorAll('[data-mode]').forEach(e=>e.onclick=()=>{mode=e.dataset.mode;reset();});reset();","audio":false,"strings":{"ack":"递送 ACK","acked":"broker 已确认完成","attempts":"处理尝试数","commit":"提交业务事务","crash":"崩溃并恢复","dedup":"效果与去重共事务","deliver":"投递 E42","done":"broker 已收到 ACK;本模型不再重投。","effects":"持久扣减次数","heading":"响应丢失后,重投会不会再扣一次?","idle":"可从投递开始;策略切换重新比较相同事件。","intro":"E42 扣减 3 件。分别操作业务事务、ACK 和崩溃恢复,对照执行尝试与持久效果。","mode":"处理策略(切换重置历史)","pending":"未确认,可重投","plain":"只有业务效果","record":"去重记录","reset":"重置","saved":"数据库已提交,但 ACK 尚未送达。此时崩溃将触发重投。","scope":"初始库存 100,固定事件 E42。提交是本地原子事务;崩溃丢失未提交暂存和待发 ACK,保留数据库与 broker 已收 ACK。不模拟真实磁盘故障。","scopeLabel":"模型范围与约定","staged":"事务尚未提交;崩溃会撤销本次暂存。","stock":"持久库存"}}
Kafka 的幂等生产与事务
Kafka 的幂等生产把 producer 身份、epoch 与分区内序号带入协议,识别客户端发送重试产生的重复批次。这不等于按业务内容去重:应用把同一个订单重新作为一条新记录发送,仍可能被正常追加。相关约束见 Producer Configs 。
事务进一步把多个分区的输出与消费进度纳入一次提交。一个 Kafka 到 Kafka 的变换可以写成:
一个事务:结果与恢复进度一起提交
读取 A
offset N
Kafka 事务边界
结果写入 B
提交 A 的下一位置 N+1
提交或中止
共同决定可见性
外部数据库与 HTTP 副作用,不会自动进入这个事务。
崩溃时,事务可能尚未提交,也可能已经提交但响应丢失;恢复由事务协议处理。下游用 read_committed 排除未提交或中止事务的记录。应把“结果可见”和“恢复时从哪里继续”看作同一个原子决定。机制与限制见 Kafka Message Delivery Semantics / Transactions 。
Kafka 事务不会自动包含外部 SQL 更新、HTTP 请求或邮件发送。数据库侧要使用自己的事务与去重;外部接口需要稳定幂等键,或者可查询的业务状态来消解不确定结果。
Outbox:数据库提交后,事件怎样可靠发出
“先写数据库,再发消息”也有两个独立提交点:数据库成功后进程崩溃,消息可能永远未发。反过来先发消息,又可能发布一个最后没有提交的业务事实。
Outbox 把业务变更与待发布事件写入同一数据库事务,由独立转发器读取事件并投递:
Outbox:本地原子提交,跨系统允许重投
同一个数据库事务
修改订单
插入事件 E42
转发器
发送后标记已发送
消费者
E42 去重后执行业务
已发送但未标记 → 可重发
Outbox 解决漏发窗口;消费者去重处理重复到达。
转发器在“已发送、未标记”之间崩溃仍会重发,所以 Outbox 主要解决漏发,消费去重负责重复。两者配合才形成完整链路。更一般的事务取舍见 分布式事务 。
快速判断保证边界
检查一条消息链路时,依次确定:谁生成稳定事件 ID;哪一步可能重试;ACK 到底确认了什么;业务效果与进度能否共事务;外部副作用如何去重;去重记录和原消息保留多久。
如果这些问题没有明确答案,仅写“开启 exactly-once”并不能说明系统实际提供什么保证。
参考
关键词:消息语义、确认、重试、幂等、去重、exactly-once、事务边界、Outbox