PHP PDO 事务回滚为什么没生效:异常捕获、返回值与提交边界
来源:17golang原创
时间:2026-07-21 13:03:55 329浏览 收藏
订单扣库存的接口明明走进了 beginTransaction(),库存却已经减掉,订单写入也没有撤回。PHP PDO 事务回滚失效,通常不是数据库突然不支持事务,而是异常被业务代码吞掉了,或者 commit() 的边界放错了。
把事务当成一个完整的业务边界:成功路径只提交一次,失败路径只回滚一次;不要在事务内部把异常改成普通返回值。
- PDO 默认异常模式不一定开启,先显式设置
PDO::ATTR_ERRMODE。 - 事务函数只负责边界,业务函数应通过异常把失败交给边界处理。
commit()只能出现在所有写操作成功之后,不能夹在中间步骤。- 回滚后要查询数据库状态,不能只看接口返回的“操作失败”。
为什么 catch 之后,订单和库存仍然各改了一半
先看一个很常见的写法。订单创建和扣库存分别执行,但库存不足时只是返回 false:
function createOrder(PDO $db, int $userId, int $skuId, int $count): bool
{
$db->beginTransaction();
try {
$add = $db->prepare('INSERT INTO orders (user_id, sku_id, quantity, status) VALUES (?, ?, ?, ?)');
$add->run([$userId, $skuId, $count, 'pending']);
$take = $db->prepare('UPDATE stock SET quantity = quantity - ? WHERE sku_id = ? AND quantity >= ?');
$take->run([$count, $skuId, $count]);
if ($take->rowCount() !== 1) {
return false;
}
$db->commit();
return true;
} catch (Throwable $e) {
$db->rollBack();
return false;
}
}
问题在于 return false 会直接离开 try,不会自动跳到 catch。此时事务既没有提交,也没有回滚,连接后续被复用时可能带着未结束的事务;如果外层又做了提交,半套数据就会落库。
另一个坑是 PDO 仍处于默认错误模式。SQL 失败只返回 false 时,代码没有抛异常,commit() 仍可能继续运行。

先把 PDO 的失败信号统一成异常
建立连接后马上设置错误模式,避免每条 SQL 都要手动检查返回值:
$db = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
这样做以后,SQL 语法错误、约束冲突和连接层错误都会以 PDOException 进入异常路径。业务层仍然可以用 rowCount() 判断“库存条件是否满足”,因为库存为零不是数据库故障,而是业务拒绝。
这两个失败信号要分开处理:
| 现象 | 含义 | 处理方式 |
|---|---|---|
| PDOException | SQL 或连接失败 | 抛出,统一回滚 |
| rowCount() 为 0 | 库存条件不满足 | 抛出业务异常,统一回滚 |
| 所有写入成功 | 事务可提交 | 只调用一次 commit() |
把事务边界放在服务方法,而不是散落在 DAO 里
更稳妥的方式是:仓储方法只做单条 SQL 操作,服务方法负责事务的开启、提交和回滚。业务失败也用异常表达,这样调用方不会忘记处理未完成的事务。
final class StockNotEnough extends RuntimeException
{
}
function createOrder(PDO $db, int $userId, int $skuId, int $count): int
{
if ($count beginTransaction();
try {
$add = $db->prepare(
'INSERT INTO orders (user_id, sku_id, quantity, status) VALUES (?, ?, ?, ?)'
);
$add->run([$userId, $skuId, $count, 'pending']);
$orderId = (int) $db->lastInsertId();
$take = $db->prepare(
'UPDATE stock SET quantity = quantity - ? WHERE sku_id = ? AND quantity >= ?'
);
$take->run([$count, $skuId, $count]);
if ($take->rowCount() !== 1) {
throw new StockNotEnough('库存不足');
}
$db->prepare('UPDATE orders SET status = ? WHERE id = ?')
->run(['paid_waiting', $orderId]);
$db->commit();
return $orderId;
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
这里有三个值得留意的细节。第一,commit() 紧贴成功路径末尾;第二,回滚前用 inTransaction() 判断,避免异常发生在事务开启之前又触发二次错误;第三,服务层重新抛出异常,让控制器决定返回 409、422 还是 500,而不是把所有失败都压成 200 状态码。

这几种写法看似安全,实际会留下边界漏洞
在 DAO 内部提前提交
如果订单 DAO 自己提交,库存 DAO 再失败,外层已经没有办法撤回订单数据。DAO 可以接收 PDO 实例并执行写操作,但不要替业务逻辑决定整个事务何时结束。
只判断语句运行的返回值
在异常模式下,失败会直接抛出;在静默模式下,返回值检查又很容易出现遗漏。新项目直接使用异常模式,旧项目迁移时至少要检查每一条写入语句,并禁止提交前存在失败结果。
catch 里无条件 rollBack()
连接错误可能发生在 beginTransaction() 之前,或者事务已经被其他层结束。无条件回滚会覆盖原始错误,所以先判断 inTransaction() 更稳妥。
把死锁当成普通库存不足
MySQL 的死锁通常需要记录错误信息并做有限次数重试;库存不足则是确定性的业务结果。两者都应回滚,但返回码、日志级别和重试策略不能混在一起。
用数据库状态核对提交和回滚结果
不要只用接口响应判断事务是否正确。准备一个初始库存为 10 的 SKU,分别测试成功下单、购买数量为0、库存不足和重复写入四条路径。
SELECT id, sku_id, quantity, status FROM orders WHERE user_id = 1007 ORDER BY id DESC LIMIT 5; SELECT sku_id, quantity FROM stock WHERE sku_id = 9001;
成功用例应同时生成一条新订单且库存对应扣减;库存不足用例应看不到新订单生成,库存仍保持为 10。如果订单数量增加但库存不变,说明写入顺序或异常边界仍有问题;如果库存减少但没有对应订单,要优先检查是否在服务方法外又调用了 commit()。
测试时还应把同一个 PDO 连接放进连接池或长连接场景跑一次,验证失败后下一次请求不会继承上一次未结束的事务。
常见问题
PDO 的 beginTransaction() 调用后一定能回滚吗?
只有事务真正开启、底层表支持事务,并且后续写入仍在同一连接上时,回滚才有意义。MySQL 的非事务表不会按 InnoDB 事务规则撤回修改。
业务失败必须抛异常吗?
不一定,但事务边界必须收到一个明确的失败信号。用专门的业务异常最不容易漏掉回滚,也能让控制器区分库存不足和系统故障。
事务里能调用外部支付接口吗?
不建议长时间占用数据库事务等待外部网络响应。更合适的做法是先提交本地订单状态,再通过可靠消息或补偿任务推进支付状态。
怎么判断 rollBack() 真的执行了?
检查 publish 前后的订单和库存查询结果,并记录事务开始、提交、回滚和异常类型。只看日志里的“库存不足”提示不够,还要确认数据库没有残留异常订单。
PDO 事务最小可靠模型可以压缩成一句话:连接开启异常模式,服务方法包住完整业务,失败抛出并回滚,成功最后一次提交,随后用数据库状态复查。这样排查“回滚没生效”问题时,先看异常有没有穿过边界,再看提交是否提前发生,定位速度会快很多。
-
374 收藏
-
499 收藏
-
384 收藏
-
184 收藏
-
265 收藏
-
文章 · php教程 | 2天前 | 依赖注入 · 架构设计 · PHP · PHP 8 · 属性 · ReflectionClass · 依赖注入 可测试代码 ReflectionClass 构造器注入 PHP 8 属性262 收藏
-
335 收藏
-
文章 · php教程 | 3天前 | 性能 · PHP · php-fpm · OPcache · 部署排查 · php-fpm 缓存排查 PHP OPcache 代码更新不生效 opcache.validate_timestamps372 收藏
-
257 收藏
-
文章 · php教程 | 3天前 | nginx · PHP · 运维 · php-fpm · 部署优化 · 进程池 · php-fpm 进程池 PHP教程 pm.max_children 多站点部署 慢请求隔离256 收藏
-
130 收藏
-
文章 · php教程 | 5天前 | API · PHP · 后端开发 · DateTimeImmutable · 时区处理 · php JSON 时区 UTC DateTimeImmutable 接口时间496 收藏
-
424 收藏
-
391 收藏
-
文章 · php教程 | 6天前 | PHP · 函数式编程 · 升级 · PHP 8.5 · 管道操作符 · 代码可读性 · PHP 8.5 PHP 管道操作符 PHP |> 运算符 PHP 函数链 PHP 8.5 升级 PHP 输入清洗445 收藏
-
文章 · php教程 | 6天前 | PHP · api设计 · url编码 · 后端开发 · http_build_query · 查询参数 · PHP http_build_query PHP 数组参数 URL 查询参数 PHP_QUERY_RFC3986 重复键参数 接口参数设计245 收藏
-
495 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习