4. PDO事务处理:beginTransaction、commit、rollback、事务隔离级别

事务处理,说白了就是让一堆数据库操作要么全部成功,要么全部失败。我刚开始用MySQL的时候,觉得事务这玩意儿可有可无。直到有一次,我在做一个订单系统,用户下单后扣库存、减余额、生成订单记录,三个步骤只成功了两个……嗯,从那以后,我再也不敢跳过事务了。

4.1 为什么需要事务?

你想想看,一个典型的转账场景:A给B转100块钱。需要两步操作——A的账户扣100,B的账户加100。如果第一步成功了,第二步突然断电了,那这100块钱就凭空消失了。这就是数据不一致。

事务就是来解决这个问题的。它把多个操作打包成一个原子操作。要么全做,要么全不做。

事务的四大特性(ACID):

  • 原子性(Atomicity):事务里的操作,要么全部完成,要么全部回滚
  • 一致性(Consistency):事务前后,数据必须符合所有预设规则
  • 隔离性(Isolation):多个事务并发执行时,互不干扰
  • 持久性(Durability):事务一旦提交,数据永久保存

4.2 PDO中的事务操作

PDO处理事务非常简单,就三个核心方法:beginTransaction()commit()rollback()。我习惯把它们叫做「事务三兄弟」。

4.2.1 基本用法

<?php
try {
    // 1. 开启事务
    $pdo->beginTransaction();
    
    // 2. 执行多个SQL操作
    $pdo->exec("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
    $pdo->exec("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
    
    // 3. 提交事务
    $pdo->commit();
    
    echo "转账成功!";
} catch (Exception $e) {
    // 4. 出错就回滚
    $pdo->rollback();
    echo "转账失败:" . $e->getMessage();
}
?>

这里有个细节要注意:beginTransaction()之后,PDO会自动把setAttribute(PDO::ATTR_AUTOCOMMIT, 0)关掉。提交或回滚后,又会自动恢复自动提交模式。这个机制挺贴心的,省得我们手动切换。

我的经验:在实际项目中,我习惯把事务操作封装成一个回调函数。这样代码更干净,也避免忘记提交或回滚。

<?php
function transaction(PDO $pdo, callable $callback) {
    $pdo->beginTransaction();
    try {
        $result = $callback($pdo);
        $pdo->commit();
        return $result;
    } catch (Exception $e) {
        $pdo->rollback();
        throw $e;
    }
}

// 使用
transaction($pdo, function($pdo) {
    $pdo->exec("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
    $pdo->exec("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
});
?>

4.2.2 嵌套事务

PDO本身不支持嵌套事务。你可能会想:我在一个事务里再开一个事务行不行?答案是——不行。PDO会抛异常。

我曾经踩过这个坑。在一个大事务里调用了另一个也带事务的函数,结果程序直接崩溃。后来我用了「保存点(savepoint)」来模拟嵌套事务。

<?php
// 模拟嵌套事务
$pdo->beginTransaction();
$pdo->exec("SAVEPOINT sp1");

try {
    // 子事务操作
    $pdo->exec("UPDATE ...");
    $pdo->exec("RELEASE SAVEPOINT sp1"); // 释放保存点
} catch (Exception $e) {
    $pdo->exec("ROLLBACK TO SAVEPOINT sp1"); // 回滚到保存点
}

$pdo->commit();
?>

注意:保存点不是标准SQL,不同数据库实现可能不同。MySQL支持,但PostgreSQL和SQLite也支持。如果你要切换数据库,得先确认一下。

4.3 事务隔离级别

隔离级别是个容易让人头疼的概念。说白了,它决定了「一个事务能看到其他事务的哪些中间状态」。我刚开始接触时也绕晕过,后来用一张表就理清了。

4.3.1 四种隔离级别

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不可能 可能 可能
REPEATABLE READ 不可能 不可能 可能
SERIALIZABLE 不可能 不可能 不可能

我来解释一下这三个「读」的问题:

  • 脏读:读到另一个事务未提交的数据。万一人家回滚了,你读到的就是假数据
  • 不可重复读:同一个事务里,两次读同一行数据,结果不一样。因为中间被别的事务修改并提交了
  • 幻读:同一个事务里,两次查询同一范围的数据,行数不一样。因为中间被别的事务插入或删除了

4.3.2 MySQL的默认隔离级别

MySQL InnoDB引擎的默认隔离级别是REPEATABLE READ。为什么选这个?因为InnoDB通过MVCC(多版本并发控制)解决了幻读问题,所以实际上REPEATABLE READ级别下也不会出现幻读。这一点和标准SQL定义不太一样。

我个人建议:大部分业务场景用默认的REPEATABLE READ就够了。除非你明确知道自己在做什么,否则别轻易改。

4.3.3 在PDO中设置隔离级别

<?php
// 设置隔离级别
$pdo->exec("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED");

// 或者直接在连接时设置
$dsn = 'mysql:host=localhost;dbname=test';
$pdo = new PDO($dsn, $user, $pass, [
    PDO::MYSQL_ATTR_INIT_COMMAND => "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED"
]);
?>

避坑指南:我曾经在一个高并发订单系统里,把隔离级别设成了SERIALIZABLE。结果并发一上来,数据库直接死锁了。SERIALIZABLE虽然最安全,但性能代价极大。除非是金融级别的核心交易,否则别用。

4.4 实际项目中的事务最佳实践

说了这么多理论,来点实战经验。我在多个项目中总结了几条铁律:

  1. 事务要短:事务里不要做网络请求、文件读写等耗时操作。锁住资源的时间越长,并发冲突的概率越大
  2. 先检查后操作:在事务开始前,先把参数校验、权限检查做完。别让无效操作浪费事务资源
  3. 捕获异常要精准:不要用catch (Exception $e)一把抓。有些异常(比如连接断开)回滚也没用,需要特殊处理
  4. 考虑死锁:多个事务同时操作同一批数据时,可能发生死锁。MySQL会自动检测并回滚其中一个。你的代码要做好重试机制
<?php
// 带重试机制的事务
$maxRetries = 3;
$attempt = 0;

while ($attempt < $maxRetries) {
    try {
        $pdo->beginTransaction();
        // 业务操作...
        $pdo->commit();
        break; // 成功就跳出循环
    } catch (PDOException $e) {
        $pdo->rollback();
        $attempt++;
        
        if ($e->errorInfo[1] == 1213) { // 死锁错误码
            usleep(100000); // 等100ms再重试
            continue;
        }
        
        throw $e; // 非死锁异常直接抛出
    }
}
?>

4.5 事务与锁的关系

事务和锁是分不开的。开启事务后,InnoDB会根据隔离级别自动加锁。但有时候你需要手动控制锁的行为。

<?php
// 行锁示例:SELECT ... FOR UPDATE
$pdo->beginTransaction();
$stmt = $pdo->prepare("SELECT balance FROM accounts WHERE id = ? FOR UPDATE");
$stmt->execute([1]);
$row = $stmt->fetch();

// 此时id=1的行被锁住,其他事务无法修改
// 执行更新...
$pdo->commit(); // 提交后自动释放锁
?>

警告:FOR UPDATE一定要配合事务使用。如果你在非事务环境下用FOR UPDATE,锁会立即释放,等于没用。我见过有人踩这个坑,查了半天才发现没开事务。

好了,关于PDO事务处理,核心内容就这些。记住一句话:事务是保证数据一致性的最后一道防线。该用的时候别省,不该用的时候别滥用。多在实践中体会,慢慢就能找到感觉。