AI SaaS 工程诊断实战(九)
请求超时了,客户端不知道服务端有没有创建订单,于是带同一个键再试。查到旧结果就返回,看起来已经实现幂等。
但第二次请求把数量从10改成20了呢?如果仍返回第一次的订单,调用方可能把它理解成"20已经保存";如果重新执行,同一个键又产生不同结果。
这篇用 Python 与 SQLite 的构造演示,把键的作用域、请求内容和本地事务放在一起检查。不访问真实业务,不发送外部请求,AI 辅助整理。它只验证单连接顺序调用,不是完整并发或分布式幂等方案。
键需要同时绑定范围和请求含义
本例使用 (tenant, req_key) 作为记录主键。不同租户即使碰巧用了 K1,也不应互相拿到结果。
每条记录保存本次 payload 的指纹和原始响应。同范围、同键、同指纹,返回已有响应;同键但指纹不同,明确冲突,不把新参数静默当成旧请求。
指纹计算前要先验证并规范业务输入。本例 payload 只有正整数 quantity,使用排序键的 JSON 编码计算 SHA-256。真实业务若有金额精度、默认值、字段别名或数组顺序,需要先定义什么算同一个请求,不能以为 sort_keys 会自动理解业务等价。
本地写入与幂等记录放在同一事务
只有业务记录、没有请求记录,重试仍可能重复执行。只有请求记录、业务却失败,也可能让后续一直拿到不存在的结果。
因此这个演示在一个 SQLite 事务里插入订单与请求记录,退出成功后才返回。通过教学故障开关,在订单插入后、请求记录插入前抛出异常,再直接查询行数,检查订单有没有被回滚。
Python sqlite3 的事务上下文说明可查 官方文档。本例没有把邮件、模型调用等外部副作用放进事务,也不声称数据库回滚能撤回外部动作。
完整演示
保存为 idempotency.py,Python 3.12 执行 python idempotency.py,仅使用标准库。tenant 在示例里由测试传入;生产服务必须从可信认证上下文取得,不能相信请求体自报身份。
python
import json
import sqlite3
import hashlib
def fingerprint(payload):
text = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def submit(db, tenant, key, payload, fail=False):
if set(payload) != {'quantity'} or type(payload['quantity']) is not int or payload['quantity'] <= 0:
raise ValueError('invalid payload')
digest = fingerprint(payload)
with db:
old = db.execute('SELECT digest,response FROM requests WHERE tenant=? AND req_key=?',
(tenant, key)).fetchone()
if old:
if old[0] != digest:
raise ValueError('idempotency key conflict')
return json.loads(old[1])
cursor = db.execute('INSERT INTO orders(tenant,quantity) VALUES (?,?)',
(tenant, payload['quantity']))
response = dict(order_id=cursor.lastrowid, quantity=payload['quantity'])
if fail:
raise RuntimeError('injected before request record')
db.execute('INSERT INTO requests VALUES (?,?,?,?)',
(tenant, key, digest, json.dumps(response)))
return response
if __name__ == '__main__':
db = sqlite3.connect(':memory:')
db.executescript('''
CREATE TABLE orders(id INTEGER PRIMARY KEY, tenant TEXT, quantity INTEGER);
CREATE TABLE requests(tenant TEXT,req_key TEXT,digest TEXT,response TEXT,
PRIMARY KEY(tenant,req_key));
''')
first = submit(db, 'T1', 'K1', {'quantity': 10})
retry = submit(db, 'T1', 'K1', {'quantity': 10})
try:
submit(db, 'T1', 'K1', {'quantity': 20})
conflict = False
except ValueError:
conflict = True
other_tenant = submit(db, 'T2', 'K1', {'quantity': 10})
before = db.execute('SELECT COUNT(*) FROM orders').fetchone()[0]
try:
submit(db, 'T1', 'K2', {'quantity': 30}, fail=True)
except RuntimeError:
pass
after = db.execute('SELECT COUNT(*) FROM orders').fetchone()[0]
recovered = submit(db, 'T1', 'K2', {'quantity': 30})
count = db.execute('SELECT COUNT(*) FROM orders').fetchone()[0]
assert first == retry and conflict
assert first['order_id'] != other_tenant['order_id']
assert before == after == 2 and count == 3
print(json.dumps(dict(first=first, retry=retry, conflict=conflict,
other_tenant=other_tenant, rollback_count=after,
recovered=recovered, total_orders=count), indent=2))
db.close()
实际运行结果:第一次和同参数重试返回同一个订单;改数量被拒绝;另一个租户同用 K1 创建自己的订单;故障注入前后订单数仍为2;之后重试 K2 成功,最终共3条。
这里还没回答的几个问题
第一是并发。两个工作进程同时查不到记录时怎样处理?唯一约束、事务隔离、锁等待、冲突后的重新读取都需要按实际数据库设计并执行检查。当前顺序样例通过,不能推出并发安全。
第二是保留期。键过期以后可否重新执行,旧客户端重试会怎样,需要业务决定。本例没有实现过期清理。
第三是外部副作用。如果订单创建后还要向外部系统发送动作,需要可靠投递和对方去重等额外设计,不能把本例称作端到端 exactly-once。
第四是响应版本。已有响应是否仍能被新客户端理解,也需要协议管理,不能静默把旧响应改成另一次结果。
一组适合写进验收的输入
同租户、同键、同参数;同租户、同键、不同参数;不同租户、同键;业务写入与请求登记之间失败;记录有效期边界;多连接同时首次提交。
本篇实际执行的是前四类中的本地顺序路径,后两类是需要继续补测的方向。把已跑和待跑分开,才能知道当前补丁支持哪种结论。
------ Linksass|AI SaaS 独立开发者