登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  php教程

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() 仍可能继续运行。

PHP PDO 订单事务边界示意:订单写入和库存更新失败后都回到回滚分支

先把 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() 判断“库存条件是否满足”,因为库存为零不是数据库故障,而是业务拒绝。

这两个失败信号要分开处理:

现象含义处理方式
PDOExceptionSQL 或连接失败抛出,统一回滚
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 状态码。

PHP PDO commit 与 rollBack 核对示意:成功订单提交,库存不足订单和库存都保持原状态

这几种写法看似安全,实际会留下边界漏洞

在 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 事务最小可靠模型可以压缩成一句话:连接开启异常模式,服务方法包住完整业务,失败抛出并回滚,成功最后一次提交,随后用数据库状态复查。这样排查“回滚没生效”问题时,先看异常有没有穿过边界,再看提交是否提前发生,定位速度会快很多。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>