在复杂业务逻辑中,事务嵌套的回滚问题一直是ThinkPHP开发者容易踩的坑,尤其是TP6.0版本。先说核心结论:TP6.0并不支持真正意义上的嵌套事务。很多人以为调用两次Db::transaction()就能在内部自动管理回滚,实际上这只是一层“伪装”——内层事务的提交或回滚操作会被直接忽略,异常也不会向外传导。后果就是:外层事务照常提交,但部分操作失败了,导致数据不一致。这种问题在业务高峰期排查起来极其痛苦。

为什么TP6的嵌套事务会失效
根源在于底层依赖的是PDO的beginTransaction(),而PDO和MySQL本身都不原生支持嵌套事务。TP6在检测到当前已经存在一个活跃事务时,Db::transaction()并不会真的开启一个新事务,它只是顺序执行传入的闭包逻辑,而且完全没接管回滚控制权。
- 外层事务开启后,内层再次调用
Db::transaction()→ 不会开启新事务,只会顺序执行SQL - 内层抛异常 → 只中断当前闭包的执行,外层事务根本不知道发生了什么,继续走到
commit() - 结果是:用户表更新成功了,但订单没创建,库存却已经扣减了——数据一致性被彻底打破
正确做法:统一收口 + 显式判断
所有关联操作必须放在同一个事务闭包中,避免逻辑分散到不同的模块里。如果某个业务模块需要复用,最好的方式是把它们拆成不包含事务的纯数据操作函数,然后在顶层统一用一个事务包裹起来。
- ✅ 正确写法:
Db::transaction(function () { userCreate(); orderCreate(); stockDeduct(); }); - ❌ 错误写法:
Db::transaction(fn() => userCreate()); Db::transaction(fn() => orderCreate()); - 如果确实需要分层控制,改用
Db::startTrans()+ 手动状态标记(比如一个$shouldRollback = true),在catch中设置标志,外层根据标志决定是否调用Db::rollback()
需要局部回滚?用SAVEPOINT模拟
TP6本身没有封装savepoint的API,但可以直接连接PDO执行原生SQL语句,达到精细的局部回滚效果。这种方法特别适合“主流程必须成功,但日志或通知失败可以容忍”的场景。
- 开启事务后,执行
Db::execute('SAVEPOINT sp_log'); - 尝试记录日志,如果失败就执行
Db::execute('ROLLBACK TO SAVEPOINT sp_log'); - 主流程没有异常 →
Db::commit();有异常 → 直接Db::rollback() - 需要注意的是:MySQL 8.0及以上版本才全面支持savepoint,低版本请先确认兼容性
上线前必查的三个信号
如果你的代码出现以下迹象,说明事务设计已经在埋雷:
- 代码里出现多个
Db::transaction()调用,而且分布在不同Service方法中 - 事务闭包内有try/catch但没有re-throw异常,或者干脆把异常吞掉后继续执行
- 数据库监控中发现“部分写入”的记录——比如订单表有数据,但关联的支付流水是空的