READ · UNDERSTAND · TRANSFER
阅读理解,按需巩固
先沿着原理、问答和迁移案例阅读。需要检查理解时,再切换巩固练习或展开个人记录。
核心知识 · 结果未知下的幂等与补偿
先理解核心原理
先备概念:网络超时、唯一约束、业务操作标识
调用方没拿到响应无法区分未执行与已执行。安全重试依赖同一业务意图在执行端被识别并去重;本地检查点不能跨越远端副作用与本地记录之间的原子性缺口。
先画三种结果
请求可能没到服务端、被拒绝、已执行但响应丢失,调用方都可能只看到异常。前两种有时适合重新提交,第三种可能双发或双付。所以写请求需要“结果未知”状态,不能用统一 failed 直接驱动重试。
幂等键的关键是稳定
UUID 可以作为键,问题是一次业务动作的全部尝试是否复用它。服务端比较相同键的参数与执行结果,同键不同意图拒绝。并发去重要靠原子约束,先查询不存在再执行存在竞态。
保留期限与跨系统限制
Stripe 的具体接口展示参数比较和键保留策略,但其他远端未必一样。任务暂停超过远端保留期,复用同键也可能被当成新请求,必须核对业务状态。outbox 可以防本地任务丢失,不能凭空使不支持幂等的邮件服务恰好发送一次。补偿是新业务动作,也可能失败。
回到问题:怎样回答?
超时只说明调用方没拿到结果,不说明服务端没有执行。写工具要用稳定业务操作 ID 作为幂等键,服务端保存参数摘要与执行结果;同键同参数返回旧结果,同键不同参数拒绝。若远端支持幂等就透传同一业务键;不支持时先查询状态,再人工判断或执行补偿。工作流检查点与工具去重解决不同问题,不能用一次状态落库来宣称外部动作 exactly-once。
实现与取舍
为什么普通重试会出错
客户端发出发布请求,服务器已完成发布,但响应在网络中丢失。此时客户端看到超时,重新执行就可能产生第二篇文章。把超时统一映射成 failed 会掩盖事实。运行记录应允许 outcome_unknown,保存操作 ID 和远端请求标识,并走查询确认流程。只读查询通常更容易重试;写请求是否可重试取决于其幂等设计,不取决于模型是否认为值得重试。
幂等键要表达业务意图
键可以由任务 ID、动作阶段和目标版本构成,关键是同一逻辑动作在恢复和重试时保持不变。每次尝试都新建随机键会失去去重效果。服务端以租户和操作键建唯一约束,并存储规范化参数摘要、状态和结果。同键同参数重放旧结果,同键不同参数报冲突。并发执行要在数据库事务或远端原子操作中争夺同一键,不能先查是否存在、退出事务后再写。日志记录 attempt,业务记录 operation,两者不要混成一个 ID。
跨系统边界怎么处理
下面的 SQLite 代码将本地业务写入与结果存储放进同一事务,展示本地去重。对于发信或支付,数据库提交和远端执行无法靠这个事务合并。优先使用远端幂等 API,并将相同业务键传过去;否则采用持久化待发送记录和可重试投递,仍需承认远端重复风险。若远端可按业务编号查询,先核验后重试;既不能去重也不能查询的高影响动作进入人工核验,补偿动作必须单独记录,不能冒充回滚成功。
如何用故障注入验证
分别在执行前、业务写入后、提交后响应前制造失败,再发两次相同请求。断言本地只出现一条业务记录、重放结果一致、修改参数得到冲突。并发场景另行验证唯一约束,跨进程场景用持久化数据库。LangGraph 中断恢复会重跑所在节点,更说明工具网关必须独立保护副作用。验收关注业务结果而非“函数被调用一次”;生产代码还需过期策略、审计、权限重验和远端对账。
代码示例
SQLite 事务内的业务操作去重
Python 3 标准库演示,使用内存 SQLite;真实恢复需要文件或服务端数据库,外部动作需要远端幂等能力。
import hashlib
import json
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE publications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant TEXT NOT NULL,
title TEXT NOT NULL
);
CREATE TABLE operations (
tenant TEXT NOT NULL,
op_key TEXT NOT NULL,
payload_hash TEXT NOT NULL,
result_id INTEGER NOT NULL,
PRIMARY KEY (tenant, op_key)
);
""")
def publish(tenant, op_key, title):
payload = json.dumps({"title": title}, sort_keys=True).encode()
digest = hashlib.sha256(payload).hexdigest()
db.execute("BEGIN IMMEDIATE")
try:
old = db.execute(
"SELECT payload_hash, result_id FROM operations WHERE tenant=? AND op_key=?",
(tenant, op_key),
).fetchone()
if old:
if old[0] != digest:
raise ValueError("IDEMPOTENCY_CONFLICT")
db.commit()
return old[1]
cur = db.execute(
"INSERT INTO publications(tenant,title) VALUES (?,?)", (tenant, title)
)
result_id = cur.lastrowid
db.execute("INSERT INTO operations VALUES (?,?,?,?)",
(tenant, op_key, digest, result_id))
db.commit()
return result_id
except Exception:
db.rollback()
raise
print(publish("t1", "task-7:publish:v3", "Agent recovery"))
print(publish("t1", "task-7:publish:v3", "Agent recovery"))
print("rows", db.execute("SELECT COUNT(*) FROM publications").fetchone()[0])
try:
publish("t1", "task-7:publish:v3", "Changed title")
except ValueError as exc:
print(str(exc))
db.close()
预期输出
1
1
rows 1
IDEMPOTENCY_CONFLICT工程推演
- 场景
- 假设工程场景:文章发布 API 超时,Agent 恢复后重新请求发布。
- 设计决策
- 操作键绑定任务与草稿版本;同键重试返回原发布结果;参数变化要求新审批与新操作。
- 验证目标
- 演示结果:两次相同调用返回同一 ID,数据库只有一条发布记录,不同参数被拒绝。
- 适用边界
- 代码只证明单数据库事务内去重;不证明外部网络动作 exactly-once,也未实现分布式投递。
连续追问与解答
沿着问题的前提和约束继续向下读。先理解参考解答,再尝试收起答案,用自己的话解释因果和取舍。
第 1 层每次重试生成一个新的 UUID,为什么无效?
标识要对应业务生命周期,不能只对应网络请求。
参考解答
新的 UUID 会让远端认为这是新的业务意图,因此不会命中原请求的去重记录。应在首次创建操作时生成键,持久保存,重试、恢复和重复投递都使用它;每次网络尝试另记 attempt_id。不同业务动作仍用不同键,稳定不等于全系统复用同一个值。
沿着这个回答继续深入
第 2 层任务暂停两天,远端幂等键已过期,还能照原键重试吗?
稳定键仍依赖执行端去重记录存活,需要处理长任务恢复。
参考解答
不能假设仍安全。检查远端保留期限,先按业务编号或回执查询原动作;已完成则补齐本地记录,未完成且可靠确认后才提交。保留本地长期账本与远端状态关联,未知时停下核验。Stripe 的期限是其产品策略,不能推广为全部服务。
沿着这个回答继续深入
第 3 层本地账本说执行中,原消费者也不知是否存活,抢占锁后就能重发吗?
本地互斥与远端去重是两个不同的一致性问题。
参考解答
锁或租约只解决谁能继续处理,不能证明远端没有执行。新消费者先核对同一业务操作,再按远端幂等语义继续;防止旧消费者恢复后再次提交可用递增抢占版本,由执行端拒绝旧版本请求,但下游不识别该约束时仍需面对窗口。
第 1 层同一幂等键却换了收件人,应如何处理?
同键必须同意图,否则去重会把不同业务错误折叠。
参考解答
拒绝为幂等冲突,保存原参数摘要并明确指出意图改变。若用户真的要求改收件人,先核验旧信是否已经发出,再创建新动作和新键,重新检查授权与内容;不能悄悄覆盖原键的结果,否则既无法审计,也可能把旧批准用在新目标上。
第 1 层远端不支持幂等也没有状态查询,你会自动重试吗?
机制不具备时,要调整交付语义而不是承诺不存在的保证。
参考解答
高影响写操作不自动重试,标为结果未知并进入人工核验或明确业务对账。低影响、允许重复的场景可由预定策略接受至少一次投递,但必须说明重复可能性,不能宣称防重成功。决定基于动作代价和可验证性,不能由模型临时乐观判断。
举一反三:条件变了,怎样推导?
先找出改变的条件,再判断原方案中哪些前提仍成立。下面的案例是教学推演,便于将原理迁移到新问题。
只读查询超时
改变的条件:有副作用写入变为无副作用查询
延伸问题:是否还要业务幂等键?
推导与参考解答
通常不必为查询结果防重,但仍用截止时间、退避和调用预算,避免放大负载。查询可能读到不同版本,涉及核对付款时必须按同一业务编号判断,而不是把新查询结果当成原请求失败证明。只读易重试,不代表一致性不重要。
保持不变的原理:重试是否安全取决于操作语义,超时本身不说明业务结果。
撤销已发布内容
改变的条件:创建动作变为补偿动作
延伸问题:删除后是否就等同从未发布?
推导与参考解答
不等同,用户可能已读、外部缓存仍在。把撤销作为新操作,核对权限与目标版本,保存发布和撤销两份回执;对外说明能恢复的范围。撤销超时也进入未知处理,不能用补偿请求发出替代补偿成功。
保持不变的原理:副作用不能从账本抹除,补偿的结果也需独立验证。
易错点
- 把本地 timeout 当作远端未执行
- 先查后写但没有事务和唯一约束
- 认为保存检查点即可保证外部副作用只发生一次
参考资料
依据公开技术资料设计;参考资料支持技术机制,场景与评分标准为本站设计,不代表某公司面试原题。 新增问答与迁移案例用于原理讲解,来源核查与案例运行验证分别记录。
检查自己理解到哪一步
读完后可以对照这些标准解释原理、边界和取舍。掌握程度由你自评;需要进一步验证时,再完成下方小任务。
- 基础达标
- 知道超时可能发生在外部提交之后。
- 中高级信号
- 使用稳定业务幂等键、意图账本和回执核对。
- 资深信号
- 明确不支持幂等时的未知终态、人工处理与故障注入。
巩固练习 按需完成 · 建议 15 分钟
演示发布成功但响应丢失时的一次恢复流程。
展开验收要求与检查点
- 不盲目重复发布
- 业务键跨重试稳定
- 无法核对时不宣称成功或未执行
重点检查
- 区分未执行、已执行和结果未知
- 同键重复请求与同键不同参数有明确处理
- 能说明本地事务无法保证远端副作用恰好一次