最近用ChatGPT、Codex排查真实项目时,有一类问题特别容易让人产生错觉:

数据库事务明明已经Rollback了,为什么有些事情却还是发生了?

比如一个订单流程:

BEGIN

创建订单
扣减余额
调用短信服务
调用第三方库存接口

后续步骤报错

ROLLBACK

最后去数据库里看:

订单没了。

余额也恢复了。

从数据库角度看,事务确实已经完整回滚。

但用户手机上:

短信已经收到了。

第三方系统里:

库存也已经被扣了。

甚至某些场景里:

支付请求都已经发出去。

这时候很多人第一反应是:

“不是已经Rollback了吗?”

问题就在这里。

数据库事务能够回滚的,只是:

数据库自己控制范围里的状态。

它没办法自动撤回已经发送出去的HTTP请求、短信、Webhook、消息或者第三方操作。

这就是今天真正要讲的问题:

Transaction Boundary——事务边界


一、为什么代码看起来像在一个事务里,实际上并不是?

很多代码会写成类似这样:

@Transactional
createOrder() {
    saveOrder();

    callInventoryApi();

    sendNotification();

    updateBalance();
}

从代码结构看:

这些操作都在同一个方法里。

方法上甚至还有:

@Transactional

很容易产生一种感觉:

“这里面的操作要么全部成功,要么全部失败。”

但实际上,数据库事务真正能够控制的只有:

数据库连接里的:

INSERT。

UPDATE。

DELETE。

如果中间执行:

callInventoryApi()

这个请求一旦已经被第三方系统接收并执行:

数据库事务根本不知道:

对方做了什么。

也没有能力在本地ROLLBACK时告诉对方:

“刚才那次操作也请撤销。”

所以:

代码块在一个事务里,不代表所有副作用都属于同一个事务。


二、一个最典型的真实场景

假设创建订单流程是:

1. INSERT order
2. 调用库存服务扣库存
3. INSERT payment_record
4. 后续逻辑异常
5. ROLLBACK

最终本地数据库:

order:不存在
payment_record:不存在

看起来恢复得非常干净。

但库存服务那里:

stock - 1

已经执行完成。

于是整个系统变成:

本地认为:

订单从来没有创建过。

库存系统却认为:

这笔库存已经扣掉。

这种问题真正危险的地方是:

两个系统分别看自己,都可能是正常的。

只有从业务整体来看,状态才是不一致的。


三、为什么ChatGPT、Codex也很容易误判这里?

因为Agent读代码时,很容易看到:

@Transactional

然后默认:

失败以后会自动恢复。

尤其如果后面的异常确实触发了数据库Rollback,它可能进一步判断:

事务机制已经生效。

但真正需要问的是:

事务里到底有哪些操作是数据库本地操作,哪些已经跨出了数据库边界?

比如:

写MySQL。

可以Rollback。

但:

发HTTP请求。

发短信。

发邮件。

调用支付。

写对象存储。

调用第三方SaaS。

这些都属于:

External Side Effect——外部副作用

一旦副作用已经真正发生:

本地数据库事务不会自动帮你撤回。


四、所以事务真正的边界在哪里?

一个数据库事务可以保证:

BEGIN
INSERT A
UPDATE B
DELETE C
COMMIT

这些动作:

要么都成功。

要么都失败。

因为它们都属于同一个数据库事务管理器。

但如果流程变成:

数据库
↓
Redis
↓
第三方API
↓
消息队列
↓
邮件服务

你面对的已经不是:

单个数据库事务。

而是:

分布式状态变化

每个系统都有:

自己的状态。

自己的失败方式。

自己的重试机制。

自己的时间顺序。

这时候简单依赖:

ROLLBACK

已经不够了。


五、为什么把外部调用放在事务里面,反而可能更危险?

很多人会觉得:

既然外部调用很重要,那就放到数据库事务里。

例如:

BEGIN

UPDATE order

callPaymentApi()

UPDATE order_status

COMMIT

表面看起来:

整个流程更完整。

但这里又会产生一个新的问题:

长事务

假设第三方API响应5秒。

那么数据库事务可能一直保持5秒甚至更久。

期间:

锁可能一直不释放。

连接一直占用。

其他请求继续等待。

如果外部服务更慢:

事务会越来越长。

所以把外部调用简单塞进数据库事务,并不能真正获得跨系统原子性。

反而可能同时得到两个问题:

外部副作用无法Rollback + 数据库长事务。


六、那这种问题到底应该怎么设计?

核心思路不是:

“想办法让数据库替你回滚外部世界。”

而是:

承认跨系统操作可能部分成功,然后设计恢复路径。

这其实是很多分布式系统设计的起点。


七、第一种办法:先记录状态,再异步执行外部操作

比如订单创建。

不要直接:

创建订单
↓
扣库存
↓
发通知

可以改成:

创建订单
status = PENDING

COMMIT

数据库先保存一个明确状态。

然后再由后台任务:

扣库存。

调用第三方。

发送通知。

成功以后:

status = COMPLETED

失败以后:

status = FAILED

这样外部操作不再强行绑在一个数据库事务里。

而是变成:

一个可以观察、重试和恢复的状态机。


八、第二种办法:为副作用设计补偿操作

比如:

已经扣库存。

后面的步骤失败。

那系统应该知道:

需要执行:

restoreInventory()

已经创建第三方资源:

需要:

deleteRemoteResource()

已经预扣余额:

需要:

refund()

这种思路就是:

Compensation——补偿

注意:

补偿和Rollback不是一回事。

Rollback是:

事情从来没有发生过。

补偿是:

事情已经发生了,现在再执行一个反向动作尽量恢复业务状态。

这两个概念非常重要。


九、Saga为什么经常出现在这种场景里?

如果一个业务流程横跨多个系统:

订单。

库存。

支付。

通知。

物流。

想靠一个数据库事务控制所有系统,通常不现实。

Saga的核心思路就是:

把一个大事务拆成多个本地事务。

每一步都有:

正向操作。

以及必要时的补偿操作。

例如:

创建订单
↓
扣库存
↓
支付
↓
创建物流

如果支付失败:

补偿库存。

取消订单。

如果物流创建失败:

可能需要:

取消支付。

恢复库存。

取消订单。

也就是说:

系统不再追求:

所有系统瞬间原子一致。

而是追求:

失败以后能够走回一个可接受状态。


十、但补偿也不一定百分百成功

这是工程里最现实的一点。

比如库存扣减成功。

后续失败。

系统开始补偿:

恢复库存。

结果库存服务这时候也挂了。

怎么办?

所以补偿本身也需要:

重试。

状态记录。

告警。

幂等。

人工兜底。

这也是为什么真正成熟的分布式流程里,通常会保存:

当前业务状态
已经完成哪些步骤
哪些补偿已执行
哪些补偿仍失败

而不是只靠:

try-catch。


十一、幂等为什么在这里特别重要?

假设补偿接口是:

restoreInventory(order_id)

第一次调用其实成功了。

但网络超时,调用方没收到结果。

于是系统再次Retry。

如果接口不是幂等的:

库存可能加回来两次。

所以所有可能重复调用的:

执行。

重试。

补偿。

最好都设计成:

Idempotent——幂等

例如根据:

order_id。

event_id。

operation_id。

保证同一个业务动作重复执行,不会产生重复副作用。


十二、真正排查这类问题时,第一步不是看Rollback日志

遇到:

“事务回滚了,但外部状态没恢复”

不要继续盯着:

ROLLBACK

而应该把流程分成两类。

本地事务内动作

数据库:

INSERT。

UPDATE。

DELETE。

外部副作用

HTTP。

消息。

短信。

支付。

库存。

文件。

第三方API。

然后问:

哪些副作用发生在事务提交之前?

它们失败后有没有补偿?

补偿失败怎么办?

这三句话往往比继续看数据库日志更重要。


十三、怎么让Codex更容易发现这种问题?

不要只告诉它:

“事务回滚了,但库存还是少了。”

更有效的是让它检查:

1. 哪些操作属于数据库事务
2. 哪些操作属于外部系统
3. 每个外部操作发生在什么时间
4. 失败后是否存在补偿
5. 重试和补偿是否幂等

这样Agent就不会把:

“数据库事务成功Rollback”

误认为:

“整个业务流程已经回到原点”。


十四、给自己测一个指标:孤立副作用率

这篇我建议只看一个核心指标:

Orphan Side Effect Rate——孤立副作用率

统计那些最终本地事务失败的任务。

看其中有多少仍然留下了:

外部系统里的有效副作用。

比如:

100次最终Rollback的业务执行里。

有3次虽然数据库恢复了,但:

库存已经扣减。

通知已经发出。

第三方资源已经创建。

那么:

孤立副作用率 = 3%。

这个数字看起来不高。

但如果涉及:

支付。

库存。

订单。

账户余额。

哪怕1%都可能非常危险。


十五、孤立副作用率高,应该先改什么?

如果这个指标偏高:

优先检查:

外部API是不是直接放在数据库事务里。

任务有没有状态机。

有没有补偿动作。

补偿能不能重试。

执行和补偿是不是幂等。

失败后有没有可观察状态。

不要第一反应就是:

让ChatGPT、Codex继续修改事务注解。

因为真正的问题可能根本不在:

事务有没有Rollback。

而在:

事务边界之外已经发生了什么。


十六、Plus和Pro怎么判断?

如果你的孤立副作用率还比较高:

业务经常出现:

数据库回滚了。

外部状态却没有恢复。

补偿依赖人工处理。

Retry以后又出现重复操作。

那么当前真正限制效率的不是ChatGPT、Codex容量。

而是:

跨系统失败恢复机制还不完整。

这种阶段Plus通常已经够用。

更值得先完善:

状态机。

补偿。

幂等。

失败恢复。

审计。

否则增加更多AI容量:

只会让Agent更快地产生更多复杂跨系统修改。


如果你的孤立副作用率已经长期接近0:

外部操作都有明确状态。

失败以后可以自动补偿。

补偿本身能够安全重试。

高风险流程也能被追踪和恢复。

同时复杂工程任务长期排队,

AI执行容量才真正成为瓶颈。

这时候Pro才更容易放大真实效率。

因为Agent工作的基础已经从:

“失败以后靠人工救回来”

变成:

“失败本身就是系统设计的一部分”。


最后

数据库事务已经Rollback,

并不意味着:

整个世界都回到了执行之前。

数据库能够撤销的是:

它自己控制范围里的状态。

但短信已经发送。

HTTP请求已经执行。

库存已经被扣。

第三方资源已经创建。

这些事情不会因为:

ROLLBACK

自动消失。

所以以后让ChatGPT、Codex分析这类工程问题时,真正值得问的不是:

“事务为什么没有回滚成功?”

而是:

“哪些操作根本就不在这个事务里?”

当你把这个边界画清楚以后,很多看起来很诡异的分布式问题,其实就已经解释了一半。

持续分享 Codex、大模型开发与 AI 编程实战内容。
长期深度使用各类代码大模型,也整理了
稳定的Plus/Pro会员订阅渠道,有需要可自取。

Logo

AtomGit AI 社区提供模型库、数据集、Agent、Token等资源

更多推荐