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 实际项目中的事务最佳实践
说了这么多理论,来点实战经验。我在多个项目中总结了几条铁律:
- 事务要短:事务里不要做网络请求、文件读写等耗时操作。锁住资源的时间越长,并发冲突的概率越大
- 先检查后操作:在事务开始前,先把参数校验、权限检查做完。别让无效操作浪费事务资源
- 捕获异常要精准:不要用
catch (Exception $e)一把抓。有些异常(比如连接断开)回滚也没用,需要特殊处理 - 考虑死锁:多个事务同时操作同一批数据时,可能发生死锁。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事务处理,核心内容就这些。记住一句话:事务是保证数据一致性的最后一道防线。该用的时候别省,不该用的时候别滥用。多在实践中体会,慢慢就能找到感觉。