PHP 数据库事务怎么使用?涉及多张表的数据操作需要这样处理
图片说明:示意图 图片来源:Public Domain(公有领域)
在实际网站开发中,一项业务往往不只涉及一张数据库表。例如,用户注册可能需要同时写入用户表和积分表;创建订单时,系统可能需要更新订单表、扣减库存,并写入支付或操作日志。如果其中一个步骤成功,另一个步骤却失败,就可能出现用户已经创建、积分却没有到账,或者订单已经生成、库存却没有减少的情况。
数据库事务的作用,就是把这些相互关联的操作纳入同一个可控的执行范围。在符合事务条件的情况下,程序可以要求这些操作要么全部提交,要么在发生错误时全部回滚,从而避免部分业务数据被保存。事务并不是PHP独有的功能,而是由数据库管理系统提供、再通过PDO或MySQLi等接口进行控制的机制。
事务的核心原理
事务通常用ACID四个特性来描述。原子性(Atomicity)意味着一组相关操作作为一个整体执行,不能只完成其中一部分;一致性(Consistency)意味着事务执行前后,数据库应当继续满足既定的约束和业务规则;隔离性(Isolation)用于控制并发事务之间的相互影响;持久性(Durability)则表示事务成功提交后,已经保存的数据不会因为普通程序结束而自动消失。
需要注意,事务并不能自动修复所有业务错误。例如,程序虽然成功扣减了库存,但如果库存扣减逻辑本身写错,事务仍然可能把错误结果完整提交。因此,事务负责控制一组操作的提交与回滚,业务规则、数据约束和正确的SQL逻辑仍然需要由开发者设计。
PHP中如何开启和结束事务
使用PDO时,最常见的事务控制方法包括beginTransaction()、commit()和rollBack()。调用beginTransaction()后,当前PDO连接进入事务状态;执行期间的相关数据变更不会在每条语句执行后立即提交。所有步骤成功后调用commit(),发生异常或业务校验失败时,则调用rollBack()。
下面以用户注册为例。假设系统需要同时向users表和points表写入数据,示例使用MySQL的InnoDB存储引擎,并假设两张表均已建立适当的字段、主键和外键约束。
setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$username = 'john';
$email = 'john@example.com';
try {
$pdo->beginTransaction();
// 第一步:创建用户
$stmt = $pdo->prepare("
INSERT INTO users (username, email)
VALUES (?, ?)
");
$stmt->execute([
$username,
$email
]);
// 获取当前连接刚刚生成的用户ID
$userId = $pdo->lastInsertId();
// 第二步:为用户建立积分记录
$stmt = $pdo->prepare("
INSERT INTO points (user_id, points)
VALUES (?, ?)
");
$stmt->execute([
$userId,
100
]);
// 所有操作成功后提交
$pdo->commit();
echo '用户注册成功';
} catch (Throwable $e) {
// 只有在事务仍然处于活动状态时才执行回滚
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// 生产环境中不要直接向用户输出数据库内部错误
error_log(
'用户注册事务失败:' . $e->getMessage()
);
echo '操作失败,请稍后重试';
}
这段代码有几个关键点。第一,必须使用同一个PDO连接完成所有数据库操作。第二,用户ID应当在插入用户记录之后立即获取,并通过参数传递给下一条SQL。第三,使用预处理语句可以避免把用户输入直接拼接进SQL。第四,catch中不能无条件调用rollBack(),因为异常可能发生在事务开启之前,也可能发生在事务已经提交之后;使用inTransaction()进行判断更加稳妥。
为什么不能只依靠程序中的判断
有些开发者会采用这样的逻辑:先执行一条SQL,判断返回值是否成功,再执行下一条SQL。如果第二条SQL失败,就在程序中尝试删除第一条SQL写入的数据。这种做法在简单场景下看似可行,但并不等同于真正的事务。
例如,第一条操作成功后,程序在执行第二条操作之前突然中断,后续的删除代码就没有机会执行。又或者,第一条操作写入的数据已经被其他程序读取,手动补偿删除可能引发新的业务问题。事务由数据库统一管理,能够在事务范围内按照提交或回滚规则处理数据变化,比依靠多段人工补偿代码更加可靠。
不过,事务也有边界。它通常只对支持事务的存储引擎有效。在MySQL中,如果相关表使用不支持事务的存储引擎,即使PHP代码成功调用了beginTransaction(),也不能因此认为所有数据变更都能够回滚。涉及多表操作时,应确认相关表使用InnoDB等支持事务的存储引擎。
多表事务不等于锁住整张表
事务执行期间,数据库可能根据SQL语句、索引、存储引擎和隔离级别,对相关记录或索引范围加锁,但这并不意味着整个数据库表都会被锁住。锁的范围取决于具体操作。
例如,更新某个用户的积分记录时,如果user_id上存在合适的索引,数据库通常可以更精确地定位需要处理的记录。反过来,如果查询条件缺少索引,数据库可能需要扫描更多数据,进而增加锁竞争和执行成本。因此,事务设计不能只关注PHP代码,也必须考虑数据库表结构、索引和SQL执行计划。
事务中的操作应当尽量短小。不要在事务开启后执行长时间的网络请求、等待用户输入、调用外部API或进行复杂文件处理。更合理的做法是先完成不需要数据库锁的准备工作,随后快速开启事务,完成必要的数据读写,最后立即提交。
订单、库存和支付记录应该如何处理
订单系统是多表事务的典型应用场景。创建订单时,系统可能需要执行以下操作:建立订单主记录、写入订单明细、检查并扣减库存、写入支付待处理记录。如果这些步骤必须同时成功才能构成完整业务,就可以将适当的数据操作放入同一个事务。
但库存扣减不能只写成简单的“读取库存,再减去购买数量”。在并发环境下,两个用户可能同时购买最后一件商品。如果程序先读取库存,再分别执行更新,就可能出现超卖。因此,库存操作应当结合条件更新、行级锁或其他并发控制机制。例如,可以使用带有库存条件的更新语句:
$stmt = $pdo->prepare("
UPDATE products
SET stock = stock - ?
WHERE id = ?
AND stock >= ?
");
$stmt->execute([
$quantity,
$productId,
$quantity
]);
if ($stmt->rowCount() !== 1) {
throw new RuntimeException('库存不足');
}
这条SQL通过stock >= ?限制了扣减条件,程序还需要检查实际受影响的行数。如果没有成功扣减库存,就抛出异常,让外层事务回滚订单及其他相关数据。
支付系统则需要更加谨慎。数据库事务可以保证订单记录和支付状态在数据库中的一致性,但它无法把外部支付平台的扣款动作自动纳入同一个本地数据库事务。支付平台调用失败、网络超时或重复通知,都需要通过幂等键、支付状态机、回调验证和必要的补偿机制进行处理。不能简单地认为“数据库事务提交成功”就等于“支付已经完成”。
隔离级别和并发问题
数据库隔离级别决定了并发事务在读取和修改数据时能够看到什么,以及可能出现哪些并发现象。常见级别包括READ COMMITTED、REPEATABLE READ和SERIALIZABLE。隔离级别越严格,通常意味着更强的并发约束,但也可能带来更多锁竞争或性能成本。
MySQL的InnoDB默认使用REPEATABLE READ。开发者不应为了追求“绝对安全”就盲目把所有业务设置为最高隔离级别,而应根据业务场景、查询方式、锁策略和并发压力进行测试。对于库存、余额、账户转账等业务,仅仅调整隔离级别通常还不够,还需要使用正确的条件更新、行锁、唯一约束和业务校验。
死锁为什么会发生
当两个事务分别持有对方需要的锁,并且都在等待对方释放资源时,就可能发生死锁。数据库通常会检测到死锁,并主动回滚其中一个事务,让另一个事务继续执行。
因此,程序不能只记录死锁错误然后直接向用户返回失败。在适合重试的业务中,可以针对明确的死锁或瞬时锁等待错误进行有限次数的重试。不过,重试前必须确认事务已经回滚或当前连接已经恢复到可用状态,而且业务操作必须具备幂等性。涉及扣款、发货和外部接口调用的操作,不能不加区分地自动重复执行。
减少死锁的常见方法包括:让不同业务按照一致的顺序访问多张表或多组记录;为查询条件建立合适索引;缩短事务持续时间;避免在事务中执行不必要的查询和更新;分析数据库错误日志和死锁报告,而不是单纯依靠增加等待时间解决问题。
事务中不能随意执行建表和改表操作
事务主要用于处理业务数据,而不是随意混合数据库结构变更。在MySQL等数据库中,部分DDL语句,例如CREATE TABLE、ALTER TABLE和DROP TABLE,可能触发隐式提交。这样一来,开发者原本以为可以通过rollBack()撤销的操作,实际上可能已经被提交,无法恢复。
因此,建表、改字段、创建索引等数据库结构变更,应当通过独立的数据库迁移流程执行,不要与用户注册、订单创建等业务事务混在一起。
实际开发中的检查清单
使用PHP事务时,可以重点检查以下事项:
所有相关表是否使用支持事务的存储引擎。
多张表的操作是否使用同一个PDO连接。
是否开启了PDO::ERRMODE_EXCEPTION。
是否使用预处理语句处理外部输入。
事务是否只包含必要的数据库操作。
是否在提交前检查业务条件和SQL执行结果。
回滚前是否确认当前确实存在活动事务。
是否建立主键、外键、唯一索引和必要的业务约束。
是否考虑并发更新、锁等待和死锁重试。
是否避免在事务中执行可能触发隐式提交的DDL操作。
是否针对异常中断、重复请求和并发操作进行测试。
PHP数据库事务的价值,不只是让多条SQL语句“同时执行”,而是让一组具有业务关联的数据变化拥有明确的提交边界。它可以防止一个业务流程只完成一半,却把残缺结果永久写入数据库。但事务不是万能的保险箱:存储引擎、索引、锁策略、异常处理、幂等设计和业务规则,都会影响最终的数据可靠性。真正成熟的系统,不仅要知道什么时候调用beginTransaction(),更要知道哪些操作必须放在同一个事务里,哪些操作必须留在事务之外,以及发生并发冲突时应该如何安全恢复。
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP