10 · RPC 与持久工作流:重试需要什么信息
目标:区分调用失败和结果未知,并设计能安全恢复的重试步骤。
超时不是一个业务结果
报名后要预留名额、生成凭证、发送通知。进程内函数调用通常能直接得到返回值或异常;远程调用多了网络,服务可能已经成功,只是响应丢失。RPC 让语法像函数,并不能消除这个差异。REST 也一样要定义超时、重试与兼容策略。
客户端若超时后用新 ID 再报一次,服务可能创建两条报名。幂等键把同一个业务意图的重试关联起来。服务必须把键与结果可靠保存,并检测同一键携带不同请求内容的情况。幂等不是“网络只传一次”,而是重复执行不会产生额外业务效果。
持久工作流把步骤及结果记入历史,在进程重启后继续推进。它仍然需要活动步骤处理重复调用,尤其当步骤已完成但“完成记录”未保存时。业务补偿也应明确:取消预留是新的业务动作,不是让已经发送的短信从用户手机消失。
响应丢失之后再试一次
保存 workflows.py。这是一份内存故障模型,演示幂等接口;不声称跨进程持久。先预测两次相同调用消耗几个名额。
seats = 2
results = {}
def reserve(key, user):
global seats
if key in results:
prior_user, result = results[key]
if prior_user != user:
raise ValueError("idempotency key reused with different payload")
return result
if seats == 0:
result = "full"
else:
seats -= 1
result = "confirmed"
results[key] = (user, result)
return result
reserve("request-1", "u1") # 服务已完成,模拟客户端未收到响应
reply = reserve("request-1", "u1")
assert seats == 1 and reply == "confirmed"
try:
reserve("request-1", "u2")
except ValueError:
print("payload_conflict: rejected")
print("remaining:", seats)
uv run --python 3.12 --no-project workflows.py
预期输出:
payload_conflict: rejected
remaining: 1
results 的查询发生在扣减之前,返回的是原来的结果。这里把一个函数视为不可中断的顺序执行;现实里的查键、扣减和保存结果必须放在同一事务边界,否则并发请求或崩溃仍会破坏它。第 14、21 课会把关键状态放进真实事务。
引导修改:① 用 request-2 为 u1 再调用一次;② 名额变成 0,因为新键表示新意图;③ 如果产品禁止同用户同活动重复报名,必须另设业务唯一约束,不能只依赖请求键。
工作流该保存什么
对“预留 → 生成凭证 → 发通知”,保存工作流 ID、每步状态、输入指纹、可重用的结果和失败原因。恢复时先确定上一动作的结果,再决定重试。退避和抖动减少同时重试的压力,但不会使不安全操作突然变安全。消息代理把调用者与执行者解耦,增加缓冲和重试机会,也增加积压与重复处理的管理责任。
独立练习:凭证已经生成,服务在保存步骤成功状态前崩溃。给出恢复策略和外部凭证服务必须提供的接口条件。
参考恢复流程
用稳定键 workflow_id + ':ticket' 查询或重试生成凭证;凭证服务返回同一个凭证 ID。把此结果记入工作流历史,再推进通知。若服务既不能按键查询也不支持幂等创建,恢复端无法凭超时判断是否创建过,需进入结果待核实状态而不是盲目再发。通知服务也要单独定义重复发送是否允许。
重复效果出现时,先看重试是否沿用了同一个键;重启后才重复时,检查去重信息是否只在内存。把重试键、结果未知状态和工作流的三步边界写进 design.md。