审批、幂等与不确定结果
前置为事件状态篇。模型提出退款参数后,应用先验证订单归属、金额和权限;需要人工批准时,批准对象是明确的动作摘要,不是“这个 Agent 以后做什么都同意”。批准后参数变化,就要重新审批。
幂等键标识一次意图
网络重试两次不应退款两次,但同一订单上的两笔合法操作也不能被误当重复。把租户、请求键与规范化参数关联;同键同参复用结果,同键异参拒绝。下面使用 SQLite 事务,将账本变更和幂等结果放在同一事务里。
import sqlite3
import json
conn = sqlite3.connect(':memory:')
conn.execute('CREATE TABLE receipts (tenant TEXT, key TEXT, args TEXT, result TEXT, PRIMARY KEY(tenant,key))')
conn.execute('CREATE TABLE refunds (tenant TEXT, order_id TEXT, cents INTEGER)')
def refund(tenant, key, order_id, cents, approved):
args = json.dumps([order_id, cents], separators=(',', ':'))
if approved != (tenant, order_id, cents):
raise PermissionError('approval mismatch')
if type(cents) is not int or cents <= 0:
raise ValueError('invalid cents')
conn.execute('BEGIN IMMEDIATE')
try:
row = conn.execute('SELECT args,result FROM receipts WHERE tenant=? AND key=?', (tenant,key)).fetchone()
if row:
if row[0] != args:
raise ValueError('key reused with different args')
conn.commit()
return row[1]
conn.execute('INSERT INTO refunds VALUES (?,?,?)', (tenant,order_id,cents))
result = 'committed:' + key
conn.execute('INSERT INTO receipts VALUES (?,?,?,?)', (tenant,key,args,result))
conn.commit()
return result
except Exception:
conn.rollback()
raise
approval = ('A', 'order1', 500)
assert refund('A','r1','order1',500,approval) == refund('A','r1','order1',500,approval)
assert conn.execute('SELECT COUNT(*) FROM refunds').fetchone()[0] == 1
try:
refund('A','r1','order1',600,('A','order1',600))
except ValueError as error:
assert str(error) == 'key reused with different args'
else:
raise AssertionError('conflict accepted')
print('one refund')
conn.close()保存为 commit.py,输出 one refund。示例的批准元组由本地测试提供,仅模拟服务端已经验证的批准记录;实际请求中不能相信客户端提交的 approved。本例不实现订单权限与剩余额度,其输入必须来自已完成业务校验的应用层。SQLite 使用内存数据库是为了隔离练习;落盘恢复时换成专属数据库文件并在启动时处理表已存在的情况。
外部支付服务打破单库原子性
若 HTTP 支付调用成功,但保存 receipts 前进程崩溃,本地没有结果并不代表没有退款。不能在一个数据库事务外包着 HTTP 就宣称 exactly-once。应把同一个幂等键传给支持该合同的下游;超时时记录 unknown,查询下游的交易结果后再决定补偿或重试。下游不支持幂等或查询时,不能自动保证恰好一次,需要人工对账或业务补偿。
事务 outbox 解决“业务提交与待发消息”之间的原子记录:同一事务写业务状态和 outbox,发送者重试投递,消费者仍需去重。它不让外部服务自动参加本地事务。取消到达时若已经 committed,返回提交凭据,再以新的授权任务申请撤销;不要篡改旧记录为从未执行。
练习
在现有实验 conn.close() 前加入两个独立检查:未批准调用必须不增加账本行;相同 key 在另一租户 B 可产生另一笔操作。每次失败后重新执行合法调用,证明事务没有遗留在失败状态。给出“外部服务已成功但客户端超时”的处理状态,不把 unknown 当 failed。