业务幂等:把重复请求归到同一意图
支付请求超时后,客户端换一个消息再次提交;消费者完成加款后,在提交位点前退出。两者都会出现“又来一次”,却不能只靠消息 ID 解决。本页先定义业务身份,再把数据库效果、外部支付和恢复状态分别放进明确的提交边界。
Kafka 的 producer epoch、sequence 与分区顺序在MQ 权威讲解;消费位点解释为什么业务已提交仍会重投。本页不把日志去重当成业务去重。SQL 教学基线为 MySQL 8.0.39,可运行实验使用标准库 SQLite 文件事务,两者实现与并发行为不能互相冒充。
三种身份分别回答什么问题
| 身份 | 代表什么 | 不能据此判断什么 |
|---|---|---|
event_id | 一次不可变事实或命令的传播身份 | 两个不同事件是否要求执行同一次付款 |
operation_id | 在业务作用域内的一次意图 | 某个 Kafka batch 是否已经追加 |
topic-partition-offset | 一条分区日志位置 | 换 offset 后是否仍是同一事件或操作 |
发送确认丢失会导致同一 batch 重试;Outbox 重发同一个 event_id 可能产生不同 offset;用户重试又可能产生不同 event_id,却仍要求同一个 operation_id 只付款一次。相反,同一订单失败后更换渠道进行新的支付尝试,可能是合法新操作。tenant_id + payment_attempt_id 比“所有支付都按订单号去重”更容易表达这种差别,前提是调用链保留同一尝试的身份,不在每次超时后生成新键。
幂等契约至少包含:谁生成身份、租户及操作种类作用域、相同身份允许的请求内容、结果保留期、查询与冲突响应。金额用整数最小币种单位;对参与意图判定的字段做确定性编码再摘要,不能直接依赖 JSON 字段的偶然顺序。相同键但金额、币种或收款方不同应返回冲突并保留审计,不能把第二个请求悄悄当成前次成功。
“重复执行,最终业务效果与执行一次一致”不要求每次重新查询当前账户并返回不同余额。接口可以返回第一次提交时保存的结果;否则用户两次重试之间账户被其他交易修改,会误以为本次支付结果发生变化。一次意图的结果与对象的当前状态需要分开。
Inbox与效果同事务:并发裁决在数据库
先看一个范围足够小的不变量:credit-v1 对同一 event_id 给账户加 100 分,只允许一笔已提交加款。以下为教学 SQL,展示本地原子边界;哈希在应用完成规范化与校验后传入,REPEAT('a',64) 只是演示占位值。
CREATE TABLE inbox (
consumer_name VARCHAR(64) NOT NULL,
event_id VARCHAR(64) NOT NULL,
payload_hash CHAR(64) NOT NULL,
result_code VARCHAR(32) NOT NULL,
PRIMARY KEY (consumer_name, event_id)
);
CREATE TABLE account (
id BIGINT PRIMARY KEY,
cents BIGINT NOT NULL,
version BIGINT NOT NULL
);
INSERT INTO account VALUES (1, 10000, 0);
START TRANSACTION;
INSERT INTO inbox VALUES ('credit-v1', 'e-001', REPEAT('a',64), 'OK');
UPDATE account SET cents=cents+100, version=version+1 WHERE id=1;
COMMIT;Inbox 的唯一插入和加款必须使用同一物理本地事务。若账户不存在或业务检查失败,应用必须回滚,不能保留一个假的 OK;真实接口还应保存需要重复返回的结果。第二个消费者插入相同唯一键时,由数据库裁决谁能提交,不是靠“先查不存在”防并发。InnoDB 隔离、唯一约束的等待与死锁细节回查事务与锁,这里仅规定协议使用它们的方式。
处理分支需要区分:
- 正常插入、效果成功:共同提交,再允许提交消费进度。
- 确认是 Inbox 主键冲突:结束本次失败事务,在可读到已提交记录的事务中读取原哈希及结果;同参返回原结果,异参报冲突。
- 死锁、锁等待超时、业务失败:按实际事务结果回滚并有限重试,不能
catch(Exception)后统称重复。 COMMIT响应丢失:数据库可能已经提交。保留原身份重新查询或重投,由同一唯一约束与结果记录裁决,不能换身份重新加款。
若效果对应的是 operation_id,仅 Inbox 的事件唯一键还不够。两个不同 event_id 可以携带同一付款意图,业务流水还需 (tenant_id, operation_type, operation_id) 唯一约束及载荷校验,并与本地效果同事务。分库时这些裁决行必须按同一业务键路由;不能让同一操作落在两个独立分片分别成功。
崩溃时间线:哪里可以重投
| 退出位置 | 持久结果 | 重新投递后的合法行为 |
|---|---|---|
| Inbox 插入后、效果提交前 | 整个事务回滚 | 再次插入并执行效果 |
| 效果与 Inbox 已提交、offset 未提交 | 业务已生效,日志进度可能重放 | 唯一冲突后核对载荷并返回原结果 |
| offset 已提交、业务尚未提交 | 消息可能不再重投 | 违反协议,可能永久漏处理 |
| 两个事件对应同一操作 | 两条 Inbox 都可能合法存在 | 仍由业务操作唯一约束阻止第二次效果 |
先 Redis SETNX 占位,再写数据库,会在占位成功、业务未提交时崩溃;重投被当作重复后永久漏处理。先写库再 SETNX 又存在业务提交、占位未写的重复窗口。给占位加 TTL 能恢复某些卡死,但到期后的旧执行者还可能继续,不能凭 TTL 获得跨资源原子性。缓存可以减少读取,最终裁决仍应落在业务权威资源。
批量消费还要写清原子单位:每条消息各一事务,还是整个批次一事务。不能将部分成功批次的所有 offset 一次推进而跳过失败项;稀疏位点与已完成前缀见Kafka 消费位点。
外部副作用:本地幂等还不够
把 HTTP 付款放进数据库事务,不会让付款随本地 ROLLBACK 撤销。典型失败历史是:本地记录操作→供应商完成付款→响应丢失→本地事务退出。此时“本地没有成功记录”不等于“对方没有扣款”。
先持久化操作身份、不可变参数与恢复状态,再调用供应商。可用的状态协议如下,状态命名不是自动恢复能力:
| 状态 | 已知事实 | 下一步 |
|---|---|---|
PROCESSING | 有持久意图,有当前处理责任者;尚无可确认终态 | 发起或恢复调用,受租约及尝试预算约束 |
SUCCEEDED | 已确认供应商成功,并保存可核查结果 | 重复请求返回原结果 |
FAILED_FINAL | 按供应商契约确认本操作不会再成功 | 返回确定失败;若要新尝试,由业务明确创建新身份 |
UNKNOWN | 超时、连接断开或决议响应丢失,不能裁决是否生效 | 使用原身份查证或受控重试;超期对账、人工接管 |
恢复必须依赖请求发出前已经知道的 client_key,且供应商承诺该键的幂等范围、参数冲突语义、保留期与查询能力。若查询只能使用成功响应中才有的 receipt_id,响应一丢就连查谁都不知道;“供应商有查单 API”并不足以证明可恢复。查不到也可能是处理尚未可见;只有契约明确保证终态缺失含义时,才能据此裁决安全重试。
外部幂等键的保留期若短于本地重试及补发窗口,晚重试仍可能重复扣款。没有满足契约的外部幂等或预知身份查询时,应记录 UNKNOWN 敞口、暂停自动重试并进入对账或人工止损,不能承诺自动恰好一次。
处理中租约只分配恢复责任。A 暂停后 B 接管,A 恢复时仍可能已经发出付款;本地 owner/attempt 的 CAS 只能拒绝 A 覆盖较新决议,不能撤回网络中的请求。若外部资源支持栅栏,应按租约与 fencing规定由资源验证;否则依赖同一业务幂等键及查证。已取消、已退款、确定失败不是可由迟到成功随意覆盖的状态,撤销竞争见跨资源事务与长流程。
顺序要求从业务不变量推导
同一操作去重解决“不要再做一次”,并不自动解决“先支付再发货”。需要顺序时,业务提交需携带聚合版本,消费者区分旧版本、缺失前驱与非法状态,更新零行不能一律解释成重复。日志顺序、worker 执行顺序、业务版本的权威推演及 10 → 收到12、11、11、13 算例保留在MQ 顺序讲解。
未来版本尚未应用,不能先把 Inbox 记作 SUCCEEDED 又只留一个内存缓冲;进程退出后再重投会被去重,效果永远缺失。应回滚并保留可重放的位点,或者将载荷与 DEFERRED 状态同事务持久化,指定补拉前驱与重新推进的责任者。去重成功与业务成功是不同状态。
可交换操作是另一种契约:若是各自有唯一流水身份的独立加分,去重后相加可能无需等待全序;若是支付后发货,就不能拿“最终都是两条事件”跳过前置条件。若下游只要求最新快照,可按版本覆盖并忽略中间状态,但余额流水、审计历史不能无条件改成快照。
可运行验证与证据范围
下载入口见中间件实验。在仓库根运行以下公开命令;脚本使用临时 SQLite 文件并重新打开连接观察持久结果,不需启动 Kafka、MySQL 或供应商服务:
python3 labs/middleware-senior/reliability_lab.py inbox --case all --json
python3 labs/middleware-senior/reliability_lab.py inbox --case before-commit --json
python3 labs/middleware-senior/reliability_lab.py inbox --case after-commit --json
python3 labs/middleware-senior/reliability_lab.py inbox --case conflict --json
python3 labs/middleware-senior/reliability_lab.py inbox --case setnx --json
python3 labs/middleware-senior/reliability_lab.py inbox --case external --json实验输入 op-001 加 100,初始余额 0,以业务操作身份同时简化表示 Inbox 与流水键;上面的 MySQL 教学 SQL 是另一组 10000→10100 的输入。before-commit 在提交前显式回滚,再投递两次,预期状态依次为 ROLLED_BACK/SUCCEEDED/DUPLICATE,最终余额 100、Inbox 一条、效果一条。after-commit 注入提交响应丢失,重投为 DUPLICATE,仍只有一次效果。相同身份改为加 200,预期 CONFLICT 且不改变原结果。setnx 是故意错误的协议,预期展示“已占位但效果为零”的反例,不能将该反例包装成安全处理。实验注入的是确定的回滚/丢响应分支,不是杀进程或真实网络故障。
external 在另一个 SQLite 文件中模拟供应商独立提交:预知 client_key 的协议在查询并使用同键重试后从 UNKNOWN 恢复为 SUCCEEDED,两次请求只有一笔扣款;只允许按响应内 receipt_id 查询的协议仍是 UNKNOWN。这证明模型中两个事务不共用提交,不是真实支付服务的承诺。本轮已运行上述公开命令及对应断言。本页不把这些有限输入外推为真实 Kafka 崩溃恢复或 InnoDB 并发覆盖。
真实 MySQL 的补充步骤是:专用库中,A 插入同键但不提交,B 插入同键;A 提交则 B 唯一冲突,A 回滚则 B 可继续。另在数据库提交后、Kafka 提交前终止业务进程,恢复读同一事件。应记录原余额、身份、事务结果、offset 和最终余额,消费者“成功日志次数”不是效果一次的证据。该完整联调需要 Kafka 环境,未运行范围以审计为准,不写成既成结果。
保留窗口、分片与容量条件
Kafka 保留 7 天、灾备回放 30 天、人工补发 90 天时,只留 7 天 Inbox 会让第 30 天回放再次生效。可将资金流水的业务唯一键长期保留,将技术 Inbox 按窗口归档;重放仍经过同一裁决入口,而不是管理员绕过校验直写。归档记录的查验路径、查询延迟与恢复责任都是协议的一部分。
每秒 10 万条、保留 90 天约有 7776 亿条记录;即使每条只算 100 字节也约 77.76 TB 原始数据,尚未含索引、副本、写放大与归档元数据。应比较长期业务流水键、在线短窗 Inbox 加冷查验、聚合共置等方案。按天分区方便删除,但唯一键包含日期或同事件路由跨天时可能绕过全局唯一性,必须验证路由和唯一约束作用域。
当业务从单库迁到多库,Inbox 与效果可能不再同事务。可以按聚合共置,或持久意图加长流程恢复;不能继续引用单库证明。资金场景还需定义 UNKNOWN 金额及数量上限、对账周期与人工接管阈值,“最终会对上”没有给出中间风险预算。
闭卷验收
本页 5 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。