在PHP开发中,当业务操作需要同时修改多条数据库记录且要求所有修改要么全部成功要么全部失败时,就需要使用数据库事务。最常见的场景就是银行转账,转出账户扣钱和转入账户加钱这两个操作必须绑定在一起,任何一个操作失败都要回滚所有修改。

事务的基本概念
数据库事务是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务具有ACID特性:
- 原子性:事务是最小的操作单元,要么全部完成,要么全部不完成
- 一致性:事务执行前后,数据库的完整性约束没有被破坏
- 隔离性:多个事务并发执行时,一个事务的执行不能被其他事务干扰
- 持久性:事务完成后,对数据的修改是永久性的
使用mysqli扩展实现转账事务
mysqli扩展是PHP中操作MySQL数据库的常用扩展,支持事务操作。下面是通过mysqli实现银行转账的完整示例:
<?php
// 数据库连接配置
$host = '127.0.0.1';
$user = 'root';
$password = 'root';
$database = 'test';
// 创建mysqli连接
$mysqli = new mysqli($host, $user, $password, $database);
// 检查连接是否成功
if ($mysqli->connect_error) {
die('数据库连接失败:' . $mysqli->connect_error);
}
// 设置字符集
$mysqli->set_charset('utf8mb4');
// 转账参数:转出账户ID、转入账户ID、转账金额
$fromUserId = 1;
$toUserId = 2;
$amount = 100.00;
// 开启事务
$mysqli->begin_transaction();
try {
// 1. 转出账户扣减金额
$sql1 = "UPDATE user_account SET balance = balance - ? WHERE id = ? AND balance >= ?";
$stmt1 = $mysqli->prepare($sql1);
$stmt1->bind_param('dii', $amount, $fromUserId);
$stmt1->execute();
// 检查转出操作是否影响行数,0表示账户不存在或者余额不足
if ($stmt1->affected_rows != 1) {
throw new Exception('转出账户扣减失败,可能余额不足或账户不存在');
}
// 2. 转入账户增加金额
$sql2 = "UPDATE user_account SET balance = balance + ? WHERE id = ?";
$stmt2 = $mysqli->prepare($sql2);
$stmt2->bind_param('dii', $amount, $toUserId);
$stmt2->execute();
// 检查转入操作是否成功
if ($stmt2->affected_rows != 1) {
throw new Exception('转入账户不存在,转入失败');
}
// 3. 记录转账流水
$sql3 = "INSERT INTO transfer_log (from_user_id, to_user_id, amount, create_time) VALUES (?, ?, ?, NOW())";
$stmt3 = $mysqli->prepare($sql3);
$stmt3->bind_param('iid', $fromUserId, $toUserId, $amount);
$stmt3->execute();
if ($stmt3->affected_rows != 1) {
throw new Exception('转账流水记录失败');
}
// 所有操作成功,提交事务
$mysqli->commit();
echo '转账成功,转出账户:' . $fromUserId . ',转入账户:' . $toUserId . ',金额:' . $amount;
} catch (Exception $e) {
// 操作失败,回滚事务
$mysqli->rollback();
echo '转账失败:' . $e->getMessage();
}
// 关闭连接
$mysqli->close();
?>使用PDO扩展实现转账事务
PDO是PHP提供的数据访问抽象层,支持多种数据库,事务操作更加统一。下面是PDO实现转账的示例:
<?php
// 数据库连接配置
$dsn = 'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4';
$user = 'root';
$password = 'root';
try {
// 创建PDO连接
$pdo = new PDO($dsn, $user, $password);
// 设置PDO错误模式为异常,方便捕获错误
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die('数据库连接失败:' . $e->getMessage());
}
// 转账参数
$fromUserId = 1;
$toUserId = 2;
$amount = 200.00;
try {
// 开启事务
$pdo->beginTransaction();
// 1. 转出账户扣减金额
$sql1 = "UPDATE user_account SET balance = balance - :amount WHERE id = :from_id AND balance >= :amount";
$stmt1 = $pdo->prepare($sql1);
$stmt1->bindParam(':amount', $amount, PDO::PARAM_STR);
$stmt1->bindParam(':from_id', $fromUserId, PDO::PARAM_INT);
$stmt1->execute();
if ($stmt1->rowCount() != 1) {
throw new Exception('转出账户扣减失败,可能余额不足或账户不存在');
}
// 2. 转入账户增加金额
$sql2 = "UPDATE user_account SET balance = balance + :amount WHERE id = :to_id";
$stmt2 = $pdo->prepare($sql2);
$stmt2->bindParam(':amount', $amount, PDO::PARAM_STR);
$stmt2->bindParam(':to_id', $toUserId, PDO::PARAM_INT);
$stmt2->execute();
if ($stmt2->rowCount() != 1) {
throw new Exception('转入账户不存在,转入失败');
}
// 3. 记录转账流水
$sql3 = "INSERT INTO transfer_log (from_user_id, to_user_id, amount, create_time) VALUES (:from_id, :to_id, :amount, NOW())";
$stmt3 = $pdo->prepare($sql3);
$stmt3->bindParam(':from_id', $fromUserId, PDO::PARAM_INT);
$stmt3->bindParam(':to_id', $toUserId, PDO::PARAM_INT);
$stmt3->bindParam(':amount', $amount, PDO::PARAM_STR);
$stmt3->execute();
if ($stmt3->rowCount() != 1) {
throw new Exception('转账流水记录失败');
}
// 提交事务
$pdo->commit();
echo '转账成功,转出账户:' . $fromUserId . ',转入账户:' . $toUserId . ',金额:' . $amount;
} catch (Exception $e) {
// 回滚事务
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
echo '转账失败:' . $e->getMessage();
}
?>事务操作的注意事项
在使用PHP操作数据库事务时,需要注意以下几点:
- 确保数据库引擎支持事务,比如MySQL的InnoDB引擎支持事务,MyISAM引擎不支持
- 事务开启后,只有在提交或者回滚之后,占用的锁才会释放,避免长时间持有事务导致锁冲突
- 事务中的操作尽量简洁,减少事务执行时间,提升并发性能
- 捕获异常时要正确判断事务状态,避免重复回滚或者提交
- 如果业务中需要部分操作成功部分失败,可以考虑使用保存点,但大部分场景全 rollback 或者全 commit 即可满足需求
相关表结构参考
上述示例用到的表结构参考如下:
-- 用户账户表 CREATE TABLE `user_account` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_name` varchar(50) NOT NULL COMMENT '用户名', `balance` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '账户余额', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户账户表'; -- 转账流水表 CREATE TABLE `transfer_log` ( `id` int(11) NOT NULL AUTO_INCREMENT, `from_user_id` int(11) NOT NULL COMMENT '转出用户ID', `to_user_id` int(11) NOT NULL COMMENT '转入用户ID', `amount` decimal(10,2) NOT NULL COMMENT '转账金额', `create_time` datetime NOT NULL COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='转账流水记录表';