我给一个电商团队做过一个订单 Agent。它的工作流程特别像人:先查订单状态,再扣库存,然后调物流接口,最后把订单标记为已发货。就这么一套 "先查再改再验证" 的流程,上线第三天出了事故——库存扣了,订单没更新,客户却收到了物流通知。

你可能觉得,这本质上不就是 "多步操作没有事务一致性" 吗?包在一个事务里不就完了?
但如果你真想这么做,下一秒就会撞上 Hard Problem:Agent 的多步操作,跟教科书里的事务不是一回事。
// 伪代码:Agent 执行任务
1. SELECT stock FROM products WHERE id = 100; // 先查
2. UPDATE products SET stock = stock - 1 WHERE id = 100; // 再改
3. POST /ship 调物流外部接口; // 外部副作用
4. UPDATE orders SET status = 'shipped' WHERE id = 200; // 最后改
如果第 4 步失败,结果是什么?库存已经扣了,物流也发出了,订单还是 "已支付"。你如果天真地给第 1、2、4 步包一个数据库事务,那第 3 步的外部调用就永远无法回滚——物流公司不会知道你回滚了。
数据库事务的边界,比你想的窄得多
数据库事务保证的是对库的操作具备原子性、一致性、隔离性和持久性(ACID)。但这些性质只存在于数据库这个黑盒内部。
一旦 Agent 的中间步骤调用了外部 API、写文件、发消息,这些副作用就不受事务管理。事务 COMMIT 之后,外部副作用已经存在;事务 ROLLBACK 也不会撤销它们。
先查再改,比你想象的更容易出竞态
Agent 习惯先 SELECT 得到当前值,再在代码里算出新值,然后 UPDATE。这个窗口期就是竞态温床。
举个例子:商品库存当前是 10。两个 Agent 同时接到订单。它们都先查到了 10,然后在内存里算成 9,最后都执行 UPDATE products SET stock = 9 WHERE id = 100。结果库存被覆盖为 9,而不是 8。
这不能怪 AI,这是典型的 read-modify-write 竞态。要解决,要么用原子操作,要么用锁。
对于库存扣减,最简单的是别用 "查再改",直接让数据库自己算:
UPDATE products SET stock = stock - 1 WHERE id = 100;
但如果 Agent 必须根据查询结果做复杂决策,原子 SQL 无法表达。这时候需要行锁。
悲观锁:锁住你再算
在事务里先执行 SELECT * FROM products WHERE id = 100 FOR UPDATE(详见 PostgreSQL 文档),其他事务修改同一行就会被阻塞。你拿到锁后再 UPDATE,最后 COMMIT。这能防超卖,但持有锁的时间不能太长。
所以 Agent 最忌讳的一件事,就是在 FOR UPDATE 之后去调用外部 API。锁会被拖住几分钟,线上事务超时,大家一起死锁。
乐观锁:不锁,但改之前确认版本
如果并发冲突少,但处理时间长,用乐观锁。给表加 version 字段,更新时带上之前查到的版本号:
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 1;
如果影响行数为 0,说明版本在查询期间已经变了,这次修改作废,Agent 需要从头重新查询和决策。注意,重试必须做幂等控制:Agent 如果收到超时却重试,可能把库存扣两次,那是另一场灾难。
为了兜底,还要在表上加 CHECK (stock >= 0) 约束。约束是最后一道防线,比 Agent 的自我验证可靠得多。
跨出数据库之后,事务就不存在了
回到开头那个 Agent:库存和订单都在数据库,但物流接口在外面。数据库事务无法覆盖外部调用。这时有两种路线。
- 重新设计操作顺序,让每个数据库操作单独提交,同时记录操作日志,失败时做补偿。
- 协调所有参与者,引入分布式事务,如 2PC 或 Saga。但 2PC 太脆,一般不适合 Agent 这种灵活的执行体。
我强烈建议用 Saga 模式:每个步骤配备一个反向操作。扣库存成功 → 调物流失败 → 调用 "回补库存" 操作,把库存加回去。
就像记账,每一步都留下一个凭证,出了错就拿着凭证反向走。Saga 不追求瞬间一致,而是最终一致。
Saga 是一种在微服务场景下管理多步骤事务的分布式事务模式,它通过把长事务拆成一系列本地事务,并在失败时触发补偿操作来达到最终一致性。Pattern: Saga
用一张表看清:不同方案到底解决了哪一环
| 方案 | 原子性 | 并发控制 | 跨流程 | 推荐场景 |
|---|---|---|---|---|
| 单条原子 SQL | 高 | 数据库行锁 | 不支持 | 简单加减库存/金额 |
| 本地事务 | 高 | 锁/隔离级别 | 不支持 | 同一库里多表操作 |
| 悲观锁 + 事务 | 高 | 强锁,阻塞 | 不建议跨外部 | 低并发、强一致场景 |
| 乐观锁 + 重试 | 高 | 版本号,不阻塞 | 可配合 | 高并发、冲突少 |
| Saga 补偿 | 最终一致 | 每步各自控制 | 支持 | 跨服务、长流程的 Agent |
最后一步的验证,到底在验证什么?
Agent 在执行完修改后,往往还会再查一遍确认。这个习惯没问题,但验证经常被误用:读取的是当前连接的默认快照,看到的是旧值,于是误判成功或失败。
正确做法是验证也要和写入处于同一事务中(提交后读),或者在事务提交后立刻读,并确保隔离级别是 READ COMMITTED 或更高。
更可靠的方式是,在数据库里设置约束和触发器来保证不变量,而不是让 Agent 自己做语义验证。比如库存不能小于零、状态转移必须合法(paid → shipped 不能跳到 refunded)——这些规则应该下沉到表结构或存储过程里。
这也是为什么很多负责的工程团队会让 Agent 只调用预定义的 "函数",而不是让它直接生成 SQL。Agent 生成的 SQL 可以字面上合理,但很容易忽略这些隐藏约束。
真正的坑可能不是并发,而是 Agent 的 SQL 本身
如果你让 Agent 直接生成 SQL,它会生成像 SELECT * FROM users 这种全表扫描,也可能在多步操作中把条件写漏。最安全的方式是给 Agent 一个受限的操作集合,所有数据库访问都通过代码里写死的函数或存储过程。
function deductStock(productId, delta) {
return db.transaction(tx => {
const rows = tx.selectForUpdate(productId);
if (rows[0].stock + delta < 0) throw new Error('negative stock');
tx.update('products', { stock: rows[0].stock + delta }, { id: productId });
return rows[0].stock + delta;
});
}
我原来怎么以为的,后来发现完全错了
最早做 Agent 数据写入时,我以为每个步骤都开一个连接,执行成功就 COMMIT,失败就联系人工。结果有一次 Agent 在扣款后发短信失败,流程中止,用户钱扣了但优惠券没发。我第一反应是把整个 Agent 操作用 synchronized 锁住。后来发现,分布式部署下 synchronized 根本管不住另一台机器上的实例。
后来我学到两件事:第一,所有关键数据表必须有版本号和 CHECK 约束;第二,所有 Agent 步骤必须写进一个事件表,失败时可以反向补偿或重放。现在,即使 Agent 中途崩了,我们也有日志可以恢复到一致状态。
我的判断
Agent 多步数据库操作的一致性问题,目前没有银弹。在单库场景,事务 + 锁 + 约束就是最优解;在跨服务场景,Saga + 幂等重试是主流。但无论选哪种,真正的底线是:不要让 Agent 直接、自由地写 SQL。
数据库一致性从来不是 "一个事务" 就能解决的问题。把操作边界缩小,把约束放入数据库,把补偿机制变成标准动作——这样才能让 Agent 在数据世界里 "犯错但不作恶"。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/623.html