AI SaaS 工程诊断实战(三)
确认按钮点两次,订单仍然显示“已确认”。只看页面,这像是没有问题;如果后台每次都登记一次确认事件,下游通知或统计仍可能重复。
这篇用一个教学构造程序说明:最终状态正确,不代表操作的副作用也正确。案例只在单连接、顺序调用条件下运行,不把它包装成分布式幂等方案。
先把一次确认的规则写清楚
这里约定四件事:草稿订单可以确认;已经确认的订单再确认,返回 already_confirmed;取消订单不允许确认;未知 ID 报错。确认状态的改变与本地确认事件,应在同一事务中完成。
规则之所以要先说,是因为“重复调用应该怎样处理”没有唯一答案。有的业务可以返回同一结果,有的需要记录每次尝试,但尝试日志与业务确认事件应分别建模。本例关心的是后者只产生一次。
一个容易漏掉的错误
with db:
db.execute("UPDATE orders SET status='confirmed' WHERE id=?", (order_id,))
db.execute("INSERT INTO events VALUES (?, 'confirm')", (order_id,))
每次调用都写 confirmed,每次调用也都插入事件。跑两次以后,状态是 confirmed,事件数量是 2。只验证状态的测试会通过;检查事件的测试会失败。
定位到这里后,先别急着加一个“已处理 ID 集合”。要问这个集合放在哪里,重启后是否存在,多实例能否共享,哪些动作算处理完成。对当前最小问题,更直接的办法是把允许的状态转换写进操作。
在事务里完成状态转换与本地事件
修复版先读取状态,区分未知、已确认和不允许的状态;真正转换时,再让 UPDATE 带上 status='draft' 条件,并核对受影响行数。只有转换成功,才插入本地事件。
为什么读取后还保留条件?因为代码应表达它允许的转换,而不是“无论旧状态是什么,都覆盖成新状态”。不过这里的检查不等于已经验证了多连接并发:并发锁、重试与异常仍需要单独设计和测试。
状态与事件在同一个事务块内。演示还提供一个故障注入点:状态更新后、事件插入前抛出异常。我们检查异常后的真实存储,预期状态回到 draft,事件数量仍为 0。
这一项检查支持“本例事务内异常时两项本地写入一起回滚”。它不证明进程被强制终止、磁盘损坏或外部消息发送的行为。
本次得到的结果
| 场景 | 状态 | 确认事件数 | 调用结果 |
|---|---|---|---|
| 故障版顺序调用两次 | confirmed | 2 | 缺少重复保护 |
| 修复版在更新后注入异常 | draft | 0 | 抛出异常并回滚 |
| 修复版首次确认 | confirmed | 1 | confirmed |
| 修复版再次确认 | confirmed | 1 | already_confirmed |
取消订单与未知订单的调用也被拒绝。这里每个数字都来自下方程序的数据库查询,而不是函数声称的成功。
完整可运行示例
保存为 demo.py,使用 Python 3.12 运行 python demo.py,无需额外依赖。程序包含故障版、修复版和断言;异常注入是为验证事务而特意加入的教学开关。
import json
import sqlite3
def setup():
db = sqlite3.connect(':memory:')
db.executescript('''
CREATE TABLE orders(id TEXT PRIMARY KEY, status TEXT NOT NULL);
CREATE TABLE events(order_id TEXT, action TEXT);
INSERT INTO orders VALUES ('A', 'draft');
INSERT INTO orders VALUES ('C', 'cancelled');
''')
return db
def bad_confirm(db, order_id):
with db:
db.execute("UPDATE orders SET status='confirmed' WHERE id=?", (order_id,))
db.execute("INSERT INTO events VALUES (?, 'confirm')", (order_id,))
def confirm(db, order_id, fail_after_update=False):
with db:
row = db.execute('SELECT status FROM orders WHERE id=?', (order_id,)).fetchone()
if row is None:
raise LookupError(order_id)
if row[0] == 'confirmed':
return 'already_confirmed'
if row[0] != 'draft':
raise ValueError('cannot confirm from ' + row[0])
changed = db.execute(
"UPDATE orders SET status='confirmed' WHERE id=? AND status='draft'",
(order_id,)).rowcount
if changed != 1:
raise RuntimeError('state changed')
if fail_after_update:
raise RuntimeError('injected failure')
db.execute("INSERT INTO events VALUES (?, 'confirm')", (order_id,))
return 'confirmed'
def inspect(db):
return {
'status': db.execute("SELECT status FROM orders WHERE id='A'").fetchone()[0],
'events': db.execute("SELECT COUNT(*) FROM events WHERE order_id='A'").fetchone()[0],
}
if __name__ == '__main__':
db = setup()
bad_confirm(db, 'A')
bad_confirm(db, 'A')
bad = inspect(db)
db.close()
db = setup()
try:
confirm(db, 'A', fail_after_update=True)
except RuntimeError:
pass
rollback = inspect(db)
first, repeat = confirm(db, 'A'), confirm(db, 'A')
good = inspect(db)
rejected = {}
for order_id in ('C', 'missing'):
try:
confirm(db, order_id)
rejected[order_id] = False
except (ValueError, LookupError):
rejected[order_id] = True
assert bad == {'status': 'confirmed', 'events': 2}
assert rollback == {'status': 'draft', 'events': 0}
assert good == {'status': 'confirmed', 'events': 1}
assert repeat == 'already_confirmed' and all(rejected.values())
print(json.dumps(dict(bad=bad, rollback=rollback, first=first, repeat=repeat,
good=good, rejected=rejected), indent=2))
db.close()
接到真实 SaaS 时,要再补哪几层
如果只是数据库内的确认事件,事务能把本地写入组合起来。如果事务里直接发送邮件或调用支付、物流等外部服务,数据库回滚并不能撤回已经发生的外部动作。此时需要另行设计可靠投递、去重与恢复,而不是把外部调用放进 with 块就结束。
如果同一业务允许多次确认、撤销再确认,幂等标识和事件唯一性也需要按业务周期定义,不能直接用“每个订单最多一条事件”覆盖所有阶段。
本篇没有进行多进程、并发请求、跨库事务和外部服务测试,因此不宣称端到端 exactly-once。想继续推进,应先列出真实副作用,再为每个副作用决定唯一性、失败恢复与验收方式。
关于本例使用的事务上下文行为,参考 Python sqlite3 官方说明。
—— Linksass|AI SaaS 独立开发者。AI 辅助整理;教学代码已在本地执行,未使用客户数据。