重试不是再思考一次
第三章保证调用次数有限,但失败后的再次调用仍可能造成重复效果。先定义业务意图为一次任务希望预留某器材某数量;尝试是传输或执行该意图的一次机会;效果是库存真正扣减。一个意图可能经历多次尝试,但正确的幂等协议让它最多产生一次同样效果。读取文档通常没有库存写入,预留则有,所以不能给所有工具一套无差别的“超时就重试”。本章只讨论当前本地单数据库事务,不承诺远程服务自动恰好一次。
客户端没收到成功可能有多种原因:请求尚未执行、执行中失败并回滚、事务已提交但回复丢失、回复已到但客户端来不及保存。后两种与前两种在客户端都可能表现为没有结果。因此“捕获异常就再扣一次”不是恢复策略。项目的 lost_reply 故障在库存与回执已经提交后抛出可重试异常,crash_after_commit 则直接模拟中断,让 pending 留在持久化状态;两者都通过实际数据库变化产生,而不是手写一条失败消息。
幂等键绑定业务意图
项目用任务 id、器材 id 和数量的规范化摘要生成幂等键。同一个 pending 重试必须保留原键和完整参数,不在每次尝试时用随机数生成新键。工具端账本以键为主键,同时保存参数指纹和结果。若键已存在且指纹相同,直接返回原结果;若同键对应不同参数,拒绝冲突。这样避免调用者误复用键而把一次数量二的请求伪装成此前数量一的成功。键不是秘密,也不是授权票据,执行器授权仍先执行。
参数指纹包含预期库存版本。版本用于乐观并发检查:读取可用三、版本零后,预留只在当前版本仍零时执行。另一个事务已改为版本一,则旧版本请求失败,不能悄悄在新状态下继续。相同业务键的合法重试先检查已存回执,故即使当前版本已经一,也能返回原成功;若把版本检查放在账本检查之前,会错误地把自己的已成功重试判成过期。这个顺序是协议的一部分。
为什么效果和回执必须同事务
若先扣库存再保存回执,中间崩溃会留下效果但无键,重试又扣一次。若先保存成功回执再扣库存,中间崩溃会留下“成功”但效果未发生。项目把库存更新和 ledger 插入放在同一 SQLite 事务里:提交时两者同时可见,异常时两者回滚。这个局部原子性来自数据库的事务机制及其文件系统前提,课程将其作为官方文档说明的导入能力,不重新证明磁盘原子提交算法。应用仍要保证两条写入确实在同一事务中。
客户端保存 observed 与工具端事务不是同一次事务:工具先提交,再返回,Runner 随后保存观察。因此中间仍有空窗,但幂等账本使重试能够读取已提交结果并补齐客户端。若远程邮件或支付接口不支持幂等键,这个本地技巧不能自动延伸到远端效果。那时需要查询业务状态、人工核对、补偿或服务端明确的幂等协议。不能仅因为本地测试通过,就给任意外部工具贴上恰好一次标签。
完整算例一:回复丢失但库存只扣一次
相机起始可用三、版本零。第一次 reserve 以键 k、数量一执行,将库存改二、版本一,账本写入结果 r,然后故障让回复丢失。Runner 已记录第四次尝试,但 observations 仍无 reserve,pending 保持原参数。下一次尝试先找到 k,参数指纹一致,返回同一 r,不再更新库存。最终 calls 为五,其中两次尝试同一 reserve;ledger 只有一条,库存仍二。若只看 calls 会误以为做了五个业务动作,若只看成功文字又会漏掉恢复成本。
提交后中断的恢复也相同。关闭并重新打开数据库后,事件重放确认 pending 仍在,下一次重试以同键命中账本,完成观察与结束。测试实际断开连接并以新 Runner 恢复,检查 calls、库存、ledger 和最终状态。这里“最多一次效果”在同一键及局部数据库内成立;不是所有尝试都会成功,预算或永久错误仍可能令业务没有完成。恢复可靠性需要同时说明安全和可完成条件。
import sqlite3
con=sqlite3.connect(':memory:')
con.executescript('CREATE TABLE stock(n INTEGER);INSERT INTO stock VALUES(3);CREATE TABLE receipts(k TEXT PRIMARY KEY,n INTEGER);')
def reserve(key,qty):
with con:
old=con.execute('SELECT n FROM receipts WHERE k=?',(key,)).fetchone()
if old:
if old[0]!=qty:raise ValueError('payload conflict')
return
con.execute('UPDATE stock SET n=n-?',(qty,))
con.execute('INSERT INTO receipts VALUES(?,?)',(key,qty))
reserve('same',1);reserve('same',1)
assert con.execute('SELECT n FROM stock').fetchone()[0]==2
try:reserve('same',2)
except ValueError:pass
else:raise AssertionError('conflict accepted')
assert con.execute('SELECT n FROM stock').fetchone()[0]==2
con.close();print('one effect, repeated delivery PASS')完整算例二:重试耗尽仍可能已有副作用
将同一 lost_reply 情形的允许重试数改为零。第一次 reserve 已提交库存二和回执,但客户端收到异常后立即终止 retry_exhausted,observations 仍没有 reserve。若报告把这个终止状态解释为“没预留,所以可以直接发新请求”,新键会再扣一次。项目固定任务 lost-reply-no-retry 预期 retry_exhausted 且效果账本一条;它是刻意保留的未知确认边界。客户端失败与业务效果为零不等价,必须查询同键回执或继续同意图恢复。
再看同键载荷冲突:先以键 k 预留一个相机,再以同键改数量二。工具应拒绝,不返回旧结果冒充本次成功,也不执行新数量。独立测试检查库存仍二。若改为新键但还持旧版本零,也应因版本过期拒绝。两种拒绝来源不同:前者保护身份一致性,后者保护状态前提。错误日志应保留冲突类型,让调用者知道该恢复同意图还是重新读取并建立新业务决定。
哪些错误允许重试
当前 TransientError 只用于明确注入的暂时读失败和回复丢失;ContractError 表示未知对象、参数冲突、过期版本或库存不足,不自动重试。重试必须维持 pending,增加 attempts 和 calls,并受两个上限同时约束。项目没有实际延迟等待或指数退避,因为离线故障一步后消失;真实网络系统常需要退避与抖动避免同一时刻拥塞,但这属于负载控制,还需实际时间与请求流证据,不能在此伪造恢复吞吐。
错误分类本身也要谨慎。超时并不证明服务未执行,所以非幂等写入不能仅按“暂时错误”标签安全重做。反过来,明确的参数验证失败不会随时间自动变合法,反复尝试只耗预算。若存在取消,客户端取消也不等于远程事务已取消;应持久化未确认状态并查询。课程把这些看似同义的“失败”拆开,是为了让恢复策略可以由状态和证据决定,而非由情绪化的再试一次驱动。
第一类检查是重复交付不变量:同键同参数任意重复,库存只变化一次、回执相同。第二类是故障时间点:执行前暂时失败、提交后丢回复、提交后中断、无重试额度分别产生预期状态与效果。只在请求正常成功时测试幂等,没有覆盖真正危险的空窗。当前十七固定任务与十六项单元检查包含这些代表性边界,未实现多工作进程抢占、分布式两阶段事务或无限历史幂等键清理。
账本保留期限也是协议的一部分。当前教学数据库永久保留每个键,没有清理;若生产系统在一天后删除回执,同一旧请求再次到达就可能重新生效。因此“同键最多一次”只在键仍被保留的有效期内有相应保证。扩展时应规定请求有效期、回执保留、迟到请求处理和任务身份唯一范围,不用一个摘要字段替代这些生命周期条件。
练习与项目增量
TRY IT YOURSELF
基础:三种计数
一个业务意图第一次提交后失联,第二次同键成功读回执。业务意图数、尝试数和效果数各是多少?
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
进阶:错误事务顺序
先写回执再扣库存,为什么仍不安全?给出中断位置。
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
挑战:未知结果处理
retry_exhausted能否证明库存未改?用本项目故障给出反例和合适处置。
提示 1
提示 2
对照参考解答
本章增量是与效果同事务的 ledger、参数指纹、过期版本检查和提交后失联故障。下一章检查工具与资料的危险输入边界;即使幂等实现正确,若攻击者能把另一个权限动作塞进执行器,一次错误效果仍然是错误。可靠重试与正确授权必须共同成立。
BUILD THE MECHANISM
补全执行、重试与崩溃恢复
在同一个LearnerRunner.step实现prepared→attempt→工具→observed,使用现有Store原子事务。 下载本课完整项目,在项目根目录运行;只编辑 learner/starter.py,已有构造框架由项目提供。 本阶段检查恢复到 reserve 工具观察即止,状态仍为 running;最终引用验证留给后续 evidence 阶段,不要求提前补完 validate_claims。
输入与输出 prepared先保存,失败工具调用也计费;pending只在观察到结果后清空。
提交后崩溃以同key恢复;最多一次扣库存;重试耗尽可能已提交。
# LearnerRunner.step(替换已提供类的此方法)
def step(self,state,fault=None):
raise NotImplementedError('execute')提示 1
先区分服务端commit和客户端observed,画出中间状态。
提示 2
在execute前保存pending与attempt_started,恢复时沿用动作与key。
提示 3
真实临时SQLite制造crash_after_commit;库存应只扣1,ledger只有1行。
独立检查 python3 -m learner.check --stage execute --output runs/learner-execute.json
迁移检验 用新器材与quantity=2重复提交后中断;提交次数和客户端调用次数分开报告。本练习复用已提供Store,不声称学生实现了SQLite事务。
参考实现与解释
# LearnerRunner.step(替换已提供类的此方法)
def step(self,state,fault=None):
if state['status']!='running':return state
if state['pending'] is None:
try:
action=validate_action(self.policy.next_action(copy.deepcopy(state)))
self.authorize(state,action)
if action['kind']=='finish':return self.finalize(state,action['status'])
if state['calls']>=state['task']['max_calls']:
state['status']='budget_exhausted';self.store.save(state,'budget_exhausted');return state
except (ContractError,ValueError,KeyError,TypeError,OSError) as error:
state['status']='policy_error';state['result']={'error':str(error)};self.store.save(state,'policy_error');return state
state['pending']=action;state['attempts']=0;state['decisions']+=1
self.store.save(state,'prepared')
if state['calls']>=state['task']['max_calls']:
state['status']='budget_exhausted';self.store.save(state,'budget_exhausted');return state
state['calls']+=1;state['attempts']+=1;self.store.save(state,'attempt_started')
try:
self.authorize(state,state['pending']);result=self.store.execute(state['pending'],fault)
except TransientError as error:
if state['attempts']>state['task']['max_retries']:state['status']='retry_exhausted'
self.store.save(state,'transient:'+str(error));return state
except ContractError as error:
state['status']='tool_error';state['result']={'error':str(error)};self.store.save(state,'tool_error');return state
state['observations'][state['pending']['name']]=result;state['pending']=None
self.store.save(state,'observed');return state