PHP 数据库事务处理：ACID 特性、隔离级别与并发控制

admin
Fri, 18 Jul 2025 11:14:12 +0000
PHP, acid, php, 事务处理

咳咳，大家好！欢迎来到“PHP 数据库事务处理：ACID 特性、隔离级别与并发控制”专题讲座。我是今天的主讲人，咱们今天就来聊聊数据库里那些个“事务”的前世今生，以及它们背后的各种门道。 开场白：为啥我们需要事务？ 各位程序员，你们有没有遇到过这样的场景：你正在写一个转账程序，A的账户扣了100块，结果服务器突然抽风，B的账户没加上这100块，钱凭空消失了？或者，你正在更新一个订单状态，从“已支付”改成“已发货”，结果一半更新成功了，一半没更新，订单状态乱成一锅粥？ 这些问题，都是因为数据库操作没有做到“原子性”，也就是要么全成功，要么全失败。为了解决这些问题，我们就需要用到数据库事务。简单来说，事务就是把一系列数据库操作打包成一个“原子”操作，要么一起成功，要么一起失败，保证数据的完整性。 第一部分：ACID – 事务的四大金刚 事务之所以能保证数据的可靠性，是因为它遵循ACID原则。ACID 不是一种洗涤剂，而是指事务的四个关键特性： Atomicity（原子性）： 事务是最小的执行单位，不允许分割。事务的所有操作要么全部完成，要么全部不完成，不会存在中间状态。就像我们往冰箱里塞西 …

继续阅读“PHP 数据库事务处理：ACID 特性、隔离级别与并发控制”

The post PHP 数据库事务处理：ACID 特性、隔离级别与并发控制 first appeared on 智猿学院-前后端，数据库，人工智能，云计算等领域前沿技术讲座.

咳咳，大家好！欢迎来到“PHP 数据库事务处理：ACID 特性、隔离级别与并发控制”专题讲座。我是今天的主讲人，咱们今天就来聊聊数据库里那些个“事务”的前世今生，以及它们背后的各种门道。

开场白：为啥我们需要事务？

各位程序员，你们有没有遇到过这样的场景：你正在写一个转账程序，A的账户扣了100块，结果服务器突然抽风，B的账户没加上这100块，钱凭空消失了？或者，你正在更新一个订单状态，从“已支付”改成“已发货”，结果一半更新成功了，一半没更新，订单状态乱成一锅粥？

这些问题，都是因为数据库操作没有做到“原子性”，也就是要么全成功，要么全失败。为了解决这些问题，我们就需要用到数据库事务。简单来说，事务就是把一系列数据库操作打包成一个“原子”操作，要么一起成功，要么一起失败，保证数据的完整性。

第一部分：ACID – 事务的四大金刚

事务之所以能保证数据的可靠性，是因为它遵循ACID原则。ACID 不是一种洗涤剂，而是指事务的四个关键特性：

Atomicity（原子性）： 事务是最小的执行单位，不允许分割。事务的所有操作要么全部完成，要么全部不完成，不会存在中间状态。就像我们往冰箱里塞西瓜，要么整个西瓜都塞进去，要么就别塞，不能只塞一半。

Consistency（一致性）： 事务执行前后，数据库都必须处于一致性状态。换句话说，事务必须满足数据库的约束条件，不能破坏数据的完整性。就像你不能往一个只能装100斤水的桶里倒1000斤水一样，会溢出的！

Isolation（隔离性）： 多个并发事务之间应该相互隔离，一个事务的执行不应该受到其他事务的干扰。就像你和你的朋友同时修改同一篇文章，你们应该各自修改自己的部分，互不影响，最后再合并。

Durability（持久性）： 事务一旦提交，对数据库的修改就是永久性的，即使系统崩溃也不会丢失。就像你把钱存到银行，银行保证你的钱不会因为服务器宕机而消失一样。

咱们用一个经典的银行转账案例来更形象地说明ACID：

<?php

$db = new PDO('mysql:host=localhost;dbname=bank', 'user', 'password');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // 开启异常处理

try {
    $db->beginTransaction(); // 开启事务

    // 1. A账户扣款
    $stmt = $db->prepare("UPDATE accounts SET balance = balance - :amount WHERE account_id = :account_a");
    $stmt->execute([':amount' => 100, ':account_a' => 1]);

    // 2. B账户加款
    $stmt = $db->prepare("UPDATE accounts SET balance = balance + :amount WHERE account_id = :account_b");
    $stmt->execute([':amount' => 100, ':account_b' => 2]);

    $db->commit(); // 提交事务，所有操作生效
    echo "转账成功！";

} catch (Exception $e) {
    $db->rollBack(); // 回滚事务，撤销所有操作
    echo "转账失败: " . $e->getMessage();
}

?>

在这个例子中：

Atomicity: 如果A账户扣款成功，但B账户加款失败，catch块会捕获异常，并执行$db->rollBack()，回滚事务，撤销A账户的扣款操作，保证转账操作要么全部成功，要么全部失败。

Consistency: 假设账户余额不能为负数，数据库会设置一个约束。如果A账户余额不足100，扣款操作会违反约束，导致事务失败，保证数据库的一致性。

Isolation: 如果在A转账给B的同时，C也在转账给A，这两个事务应该互不干扰，各自完成自己的转账操作。后面我们会详细讨论隔离级别。

Durability: 一旦$db->commit()执行成功，转账操作的结果就会永久保存在数据库中，即使服务器宕机，数据也不会丢失。

第二部分：隔离级别 – 如何让事务“互不打扰”？

在并发环境下，多个事务同时访问和修改数据库，可能会导致各种问题，比如：

脏读（Dirty Read）： 事务A读取了事务B尚未提交的数据。如果事务B最终回滚，那么事务A读取到的就是无效的数据。就像你看到了朋友在草稿纸上写的答案，结果他发现算错了又擦掉了，你抄到的答案是错的。

不可重复读（Non-repeatable Read）： 在同一个事务中，多次读取同一条数据，结果读取到的数据不一样。这是因为在两次读取之间，有其他事务修改了这条数据并提交了。就像你第一次看了一部电影的结局，第二次看的时候结局被导演改了，让你一脸懵逼。

幻读（Phantom Read）： 在同一个事务中，使用相同的查询条件，多次查询，结果查询到的记录条数不一样。这是因为在两次查询之间，有其他事务插入或删除了满足查询条件的数据。就像你第一次数了羊圈里的羊有100只，第二次数的时候发现变成了101只，不知道从哪里冒出来一只。

为了解决这些问题，数据库定义了不同的隔离级别，用于控制并发事务之间的隔离程度。SQL 标准定义了四种隔离级别：

隔离级别 | 脏读 | 不可重复读 | 幻读
Read Uncommitted (RU) | √ | √ | √
Read Committed (RC) | × | √ | √
Repeatable Read (RR) | × | × | √
Serializable (最严格) | × | × | ×

Read Uncommitted（读未提交）： 这是最低的隔离级别，允许事务读取其他事务尚未提交的数据。这种隔离级别性能最高，但安全性最差，容易出现脏读、不可重复读和幻读。 一般很少使用。

Read Committed（读已提交）： 允许事务读取其他事务已经提交的数据。可以防止脏读，但无法防止不可重复读和幻读。 大部分数据库默认使用此级别，例如 Oracle, SQL Server。

Repeatable Read（可重复读）： 保证在同一个事务中，多次读取同一条数据的结果是一样的。可以防止脏读和不可重复读，但无法防止幻读。 MySQL InnoDB 默认使用此级别。

Serializable（串行化）： 这是最高的隔离级别，强制事务串行执行，完全避免并发问题。可以防止脏读、不可重复读和幻读，但性能最差。

如何设置隔离级别？

在 PHP 中，可以使用 PDO 的 setAttribute() 方法来设置隔离级别。

<?php

$db = new PDO('mysql:host=localhost;dbname=bank', 'user', 'password');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// 设置隔离级别为 Read Committed
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // 设定默认的模式
$db->exec("SET TRANSACTION ISOLATION LEVEL READ COMMITTED");

try {
    $db->beginTransaction();

    // ... 执行数据库操作 ...

    $db->commit();
} catch (Exception $e) {
    $db->rollBack();
    echo "Error: " . $e->getMessage();
}

?>

不同的数据库系统，设置隔离级别的语法可能略有不同，需要查阅相应的文档。

重要提示： 隔离级别越高，并发性能越低。在实际开发中，需要根据业务需求选择合适的隔离级别，在数据一致性和并发性能之间取得平衡。

第三部分：并发控制 – 锁的艺术

除了隔离级别，数据库还提供了各种锁机制，用于控制并发访问，保证数据的一致性。常见的锁类型包括：

共享锁（Shared Lock）： 也叫读锁，允许事务读取数据，但不允许修改数据。多个事务可以同时持有同一数据的共享锁。就像一本书可以被很多人同时阅读，但不能被同时修改。

排他锁（Exclusive Lock）： 也叫写锁，允许事务读取和修改数据，但不允许其他事务读取或修改数据。同一时刻，只能有一个事务持有同一数据的排他锁。就像一支笔只能被一个人用来写字，不能同时被很多人用。

乐观锁（Optimistic Lock）： 假设并发冲突的可能性较低，不使用真正的锁机制。在更新数据时，检查数据是否被其他事务修改过。通常通过版本号或时间戳来实现。就像你去抢购限量版球鞋，你乐观地认为没人跟你抢，直到你付款的时候发现库存不足才傻眼。

悲观锁（Pessimistic Lock）： 假设并发冲突的可能性较高，使用真正的锁机制来防止并发访问。在读取数据时，就获取锁，防止其他事务修改数据。就像你去银行取钱，你悲观地认为有人会抢你的钱，所以你一直紧紧地抓住你的钱包。

如何使用锁？

显式锁：

在 MySQL 中，可以使用 SELECT ... FOR SHARE 获取共享锁，使用 SELECT ... FOR UPDATE 获取排他锁。

<?php

$db = new PDO('mysql:host=localhost;dbname=bank', 'user', 'password');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

try {
    $db->beginTransaction();

    // 获取A账户的排他锁
    $stmt = $db->prepare("SELECT balance FROM accounts WHERE account_id = :account_id FOR UPDATE");
    $stmt->execute([':account_id' => 1]);
    $account = $stmt->fetch(PDO::FETCH_ASSOC);

    $balance = $account['balance'];

    // ... 执行转账操作 ...

    $db->commit();
} catch (Exception $e) {
    $db->rollBack();
    echo "Error: " . $e->getMessage();
}

?>

隐式锁：

在执行 UPDATE、DELETE 等修改数据的 SQL 语句时，数据库会自动获取相应的锁。

乐观锁：

在数据表中添加一个版本号（version）字段。每次更新数据时，都检查版本号是否与之前读取的版本号一致。如果一致，则更新数据并将版本号加1；如果不一致，则说明数据已被其他事务修改，放弃更新。

<?php

$db = new PDO('mysql:host=localhost;dbname=bank', 'user', 'password');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$account_id = 1;
$amount = 100;

// 1. 读取账户信息和版本号
$stmt = $db->prepare("SELECT balance, version FROM accounts WHERE account_id = :account_id");
$stmt->execute([':account_id' => $account_id]);
$account = $stmt->fetch(PDO::FETCH_ASSOC);

$balance = $account['balance'];
$version = $account['version'];

// 2. 更新账户余额和版本号
$new_balance = $balance - $amount;
$new_version = $version + 1;

$stmt = $db->prepare("UPDATE accounts SET balance = :balance, version = :new_version WHERE account_id = :account_id AND version = :version");
$stmt->execute([':balance' => $new_balance, ':new_version' => $new_version, ':account_id' => $account_id, ':version' => $version]);

// 3. 检查更新是否成功
if ($stmt->rowCount() > 0) {
    echo "转账成功！";
} else {
    echo "转账失败，账户已被修改！";
}

?>

第四部分：死锁 – 数据库的“交通堵塞”

死锁是指两个或多个事务互相等待对方释放锁，导致所有事务都无法继续执行的状态。就像两条路上的车互相堵住，谁也过不去。

如何避免死锁？

避免锁的竞争： 尽量减少事务的执行时间，避免长时间持有锁。

按相同的顺序访问资源： 如果多个事务需要访问相同的资源，尽量按照相同的顺序访问，避免形成循环等待。

使用短事务： 将大事务拆分成多个小事务，减少锁的持有时间。

设置锁超时时间： 如果事务在一段时间内无法获取锁，则自动放弃，避免长时间等待。

死锁检测和恢复： 数据库系统通常会自动检测死锁，并选择一个事务进行回滚，释放其持有的锁，使其他事务可以继续执行。

总结：事务的正确打开方式

理解 ACID 原则： 这是事务的基础，必须牢记于心。

选择合适的隔离级别： 根据业务需求，在数据一致性和并发性能之间取得平衡。

合理使用锁机制： 控制并发访问，避免数据冲突。

预防死锁： 编写健壮的代码，避免出现死锁。

希望今天的讲座能帮助大家更好地理解 PHP 数据库事务处理。记住，事务是保护数据安全的利器，但也要谨慎使用，避免滥用导致性能问题。感谢大家的参与！下次再见！