PHP PDO 事务回滚为什么没生效:异常捕获、返回值与提交边界
订单扣库存的接口明明走进了 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 事务最小可靠模型可以压缩成一句话:连接开启异常模式,服务方法包住完整业务,失败抛出并回滚,成功最后一次提交,随后用数据库状态复查。这样排查“回滚没生效”问题时,先看异常有没有穿过边界,再看提交是否提前发生,定位速度会快很多。
Go REST API 如何统一错误响应:错误码、字段语义与兼容边界
- 上一篇
- Go REST API 如何统一错误响应:错误码、字段语义与兼容边界
- 下一篇
- Go json.Decoder 与 json.Unmarshal 怎么选:连续 JSON 请求的边界问题
-
- 文章 · php教程 | 20小时前 | PHP · laravel · 重试 · php queue:work Laravel Queue 队列重试 queue:retry failed_jobs
- PHP Laravel Queue 重试 怎样处理
- 326浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- PHP json_encode 返回 false 怎样处理
- 124浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- PHP stream 分块大文件 怎样处理
- 412浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- PHP DateTime 跨时区日期差 怎样处理
- 226浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- PHP 继承方法类型兼容 怎样处理
- 353浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- PHP OPcache 缓存命中 怎样处理
- 104浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- PHP PHP-FPM worker 并发 怎样处理
- 172浏览 收藏
-
- 文章 · php教程 | 20小时前 | 消息队列 · PHP · 进程管理 · Symfony Messenger messenger:consume --limit --time-limit --memory-limit Worker生命周期
- Symfony Messenger Worker 怎样限制单次处理数量
- 341浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- PHP Eloquent 关系字段 怎样处理
- 394浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- Composer why 怎样查出依赖是被谁引入
- 213浏览 收藏
-
- 文章 · php教程 | 20小时前 |
- PHP 8.5 管道操作符返回值 怎样处理
- 441浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 600次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 641次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 673次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 639次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 458次使用
-
- MySQL 明明加了索引,为什么查询还是很慢?先查这 6 个点
- 2026-06-27 374浏览
-
- 物流异常件转派时如何保留原单号与处理时限
- 2026-09-20 276浏览
-
- golang MySQL实现对数据库表存储获取操作示例
- 2022-12-22 499浏览
-
- golang 基于 mysql 简单实现分布式读写锁
- 2023-01-07 384浏览
-
- 详解如何利用GORM实现MySQL事务
- 2023-01-07 184浏览

