资讯详情

PHP的PDO事务与自动提交

📅 2026/10/9 13:25:51 | 华诺云谱 👁 阅读
PHP的PDO事务与自动提交
前言自动提交auto-commit这四个字看起来像是个技术细节实际上它是理解 PDO 事务行为的起点。首要的误解是以为不开事务就等于没有事务。真实情况恰好相反在自动提交模式下每一条单独的语句都被数据库当成一个隐式事务来执行——要么整条成功要么整条失败绝不会出现这条 UPDATE 改了一半的状态。beginTransaction()做的事情不是凭空造出事务而是把多个语句圈进同一个事务边界。理解了这个区别才能明白事务解决的到底是什么问题。第二个误解是以为隔离级别是 PHP 层面的事。PDO 提供了开启、提交、回滚三个动作但隔离级别isolation level属于数据库会话设置PDO 没有对应的属性要改它得在某条会话级 SQL 上动手。第三个误解是以为事务总是让程序更快。手册确实说过事务能显著提升批量更新的效率但它同时强调你仍然需要正确使用它才能获得收益。事务持有锁锁会阻塞别人用错地方性能反而不如一句一句自动提交。本文按 PHP 8.1 讲清自动提交模式的真实语义、PDO 里切换事务的两种方式、隔离级别在哪一层设置以及批量写入时该注意什么。一、自动提交模式的真实语义手册在事务章节描述得很到位Auto-commit mode means that every query that you run has its own implicit transaction, if the database supports it, or no transaction if the database doesnt support transactions.拆开看有两层意思。第一层支持事务的数据库上单条语句自带隐式事务。一条UPDATE account SET balance balance - 100 WHERE id 1在自动提交下是原子的。它不会因为执行到一半断电而只扣一半。它的原子性由数据库保证不需要你在 PHP 里写任何东西。第二层不支持事务的数据库上连这条隐式事务都没有。这类引擎上的写入没有任何保护出问题只能靠重试和业务补偿。那为什么还要显式事务因为业务上一条语句原子远远不够。经典的转账需要两条语句A 扣钱、B 加钱。自动提交模式下这两条各自原子但合起来不原子——第一条成功、第二条失败钱就凭空少了一份。beginTransaction()的价值就是把这两条语句装进同一个事务边界让它们要么一起生效、要么一起消失。用一段可运行的代码把这个场景写出来?php // 适用于 PHP 8.0declare(strict_types1);$pdo new PDO(mysql:host127.0.0.1;dbnamebank;charsetutf8mb4,app,secret,[PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC,]);/** 自动提交模式两条语句各自原子合起来不原子 */function transferNaive(PDO $pdo, int $from, int $to, int $cents): void{$pdo-prepare(UPDATE account SET balance balance - ? WHERE id ?)-execute([$cents, $from]); // 第 1 条独立的隐式事务if (random_int(0, 1) 1) {throw new RuntimeException(模拟中间故障);}$pdo-prepare(UPDATE account SET balance balance ? WHERE id ?)-execute([$cents, $to]); // 第 2 条另一个独立事务}/** 显式事务两条语句共享同一个事务边界 */function transferSafe(PDO $pdo, int $from, int $to, int $cents): void{$pdo-beginTransaction();try {$stmt $pdo-prepare(UPDATE account SET balance balance - ? WHERE id ? AND balance ?);$stmt-execute([$cents, $from, $cents]);if ($stmt-rowCount() ! 1) {throw new RuntimeException(余额不足或账户不存在);}$pdo-prepare(UPDATE account SET balance balance ? WHERE id ?)-execute([$cents, $to]);$pdo-commit();} catch (Throwable $e) {if ($pdo-inTransaction()) {$pdo-rollBack();}throw $e;}}transferSafe()里有个细节值得单独说扣款语句用了AND balance ?加上rowCount()判断而不是先查余额再判断。这样把检查和扣减合成一条语句靠数据库的行锁保证中间没有别人插队。如果先SELECT balance再UPDATE两条语句之间就留出了一个窗口并发事务可能在这中间把余额改掉而你的判断用的是那个已经作废的旧值。二、切换事务的两种方式PDO 提供两条路径进入手动事务控制方式写法特点方法调用PDO::beginTransaction()/commit()/rollBack()推荐PDO 内部有事务状态标志属性设置PDO::ATTR_AUTOCOMMIT false手册措辞是PDO尝试关闭自动提交手册对PDO::ATTR_AUTOCOMMIT的描述里用的是attempts to disable autocommit也就是尝试并没有承诺一定成功。而且这条路径不会在 PDO 内部把事务状态标记为活动中因此PDO::inTransaction()检测不到它脚本异常终止时的自动回滚保护也不覆盖它。所以结论很明确要手动控制事务就用beginTransaction()不要用ATTR_AUTOCOMMIT。后者存在的意义主要是给某些特定驱动或特殊场景留后门不是日常写法。顺带说清inTransaction()的边界。手册在方法页上说明它检查驱动层面当前是否有活动事务只对支持事务的驱动有效用户注释也补充了同一件事——它只认通过beginTransaction()开启的事务你手工执行START TRANSACTION再调它得到的是false。三、隔离级别在数据库会话上设置PDO 没有隔离级别的属性因为是数据库会话在管这件事。以 MySQL 为例会话级隔离级别用一条 SQL 设置?php // 适用于 PHP 8.0数据库为 MySQLInnoDB// 会话级设置对本连接后续的所有事务生效$pdo-exec(SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED);// 之后正常开启事务隔离级别已经变了$pdo-beginTransaction();// ... 读写 ...$pdo-commit();几个要点会话级设置在连接级别生效所以要注意连接复用持久连接、连接池时它会被带到后续请求上别在请求入口随手改。MySQL 还有SET TRANSACTION ISOLATION LEVEL ...这种只对下一个事务生效的写法作用范围是单次事务。隔离级别的实际取值和默认值由数据库决定。MySQL InnoDB 的默认是REPEATABLE READ和 SQL 标准的默认不同这是跨数据库迁移时经常被忽略的差异。隔离级别越高一致性越强并发冲突锁等待、死锁的概率也越高。这是取舍不是越高越好。配合隔离级别还要设一个东西锁等待超时。MySQL InnoDB 侧由innodb_lock_wait_timeout控制超时后语句失败并回滚当前语句。PDO 这侧则可以通过PDO::ATTR_TIMEOUT设置与数据库通信的超时秒数二者作用层次不同不要混为一谈。四、事务里的实务锁、超时和不该放的东西批量写入是事务最划算的场景。手册提到事务会把一批改动攒起来一次性应用这对效率有好处。以插入一千行为例自动提交意味着一次网络往返加一次磁盘刷写乘以一千包进一个事务则只剩一次提交开销。?php // 适用于 PHP 8.0批量插入$pdo-beginTransaction();try {$stmt $pdo-prepare(INSERT INTO log (uid, action, created_at) VALUES (?, ?, ?));foreach ($batch as $row) {$stmt-execute([$row[uid], $row[action], $row[ts]]);}$pdo-commit();} catch (Throwable $e) {if ($pdo-inTransaction()) {$pdo-rollBack();}throw $e;}注意这里没有把$batch一次性构造成巨型 SQL而是复用同一条预处理语句循环执行——解析只做一次参数每次重绑。这是预处理语句和事务配合起来最典型的高效写法。不该放进事务的东西外部调用。事务里请求第三方 HTTP 接口、发短信、写远程日志都会把数据库锁按住不放。对方慢三秒你的锁就多占三秒。正确做法是把外部调用挪到事务前后。DDL。手册明确警告MySQL 和 Oracle 在事务中遇到 DDL 会隐式提交导致rollBack()悄悄失效。长事务。事务持续越久占用的 undo 日志和持有的锁越多。批量任务要分片提交比如每 500 行提交一次。用户交互等待。事务里读用户输入等于把锁交给一个不可控的计时器。死锁是正常现象不是事故。两个事务以相反顺序更新同一批行就可能死锁数据库会挑一个受害者回滚通常报SQLSTATE[40001]一类的错误码。业务代码应当识别这类错误并重试整个事务而不是当成 bug 处理?php // 适用于 PHP 8.0带有限重试的事务执行器function withRetry(PDO $pdo, callable $work, int $maxAttempts 3): mixed{$attempt 0;while (true) {$attempt;$pdo-beginTransaction();try {$result $work($pdo);$pdo-commit();return $result;} catch (PDOException $e) {if ($pdo-inTransaction()) {$pdo-rollBack();}$sqlState ($e-errorInfo ?? [])[0] ?? (string) $e-getCode();// 40001 为序列化失败/死锁属于可重试错误if ($sqlState 40001 $attempt $maxAttempts) {usleep(50_000 * $attempt); // 退避后重试continue;}throw $e;}}}重试时要注意幂等性整个事务重来一遍里面的副作用比如计数器、日志也会重来一遍。所以重试范围里只应包含数据库操作不应包含已经产生外部副作用的代码。常见坑点以为不开事务就没有原子性❌ 认为单条UPDATE也需要包事务才安全 ✅ 自动提交下单条语句自带隐式事务事务是为了把多条语句合成一个原子单元用PDO::ATTR_AUTOCOMMIT false代替beginTransaction()❌ 设了属性就当开了事务 ✅ 用beginTransaction()属性方式inTransaction()检测不到也没有自动回滚保护inTransaction()对手写START TRANSACTION失灵❌$pdo-exec(START TRANSACTION); $pdo-inTransaction();✅ 该场景返回false一律改用beginTransaction()事务里做外部调用❌beginTransaction()后调用支付网关接口 ✅ 外部调用放在事务外事务只包数据库写入长事务不分片❌ 一个事务里插十万行 ✅ 每 500 到 1000 行提交一次缩小锁持有时间和 undo 占用把死锁当成致命错误❌ 捕获PDOException后直接返回 500 ✅ 识别40001等可重试 SQLSTATE退避后重试整个事务在请求入口无条件改隔离级别❌ 每个请求都SET SESSION TRANSACTION ISOLATION LEVEL ...✅ 隔离级别改动要评估持久连接复用的影响最好在连接初始化时统一设定事务里执行 DDL 还指望回滚❌beginTransaction()→INSERT→ALTER TABLE→rollBack()✅ MySQL / Oracle 会隐式提交DDL 必须移出事务总结项目说明自动提交语义每条语句各自构成一个隐式事务数据库支持事务时显式事务的作用把多条语句合成一个原子单元而不只是更快开启方式PDO::beginTransaction(): bool关闭自动提交结束方式PDO::commit()提交PDO::rollBack()撤销PDO::ATTR_AUTOCOMMIT手册措辞为尝试关闭不建议用于手动事务状态检测PDO::inTransaction(): bool仅识别beginTransaction()开的事务隔离级别数据库会话设置如 MySQL 的SET SESSION TRANSACTION ISOLATION LEVELPDO 无对应属性适合场景批量写入、多语句业务原子操作不适合场景外部调用、DDL、长事务、等待用户输入自动提交不是关闭事务而是每条语句一个事务。真正需要显式事务的场合是多个语句必须同生共死的时候。把这条判断标准记住再配上分片提交、外部调用外移、死锁重试这三条实务规则事务就会成为一个可控的工具而不是性能与一致性的隐患来源。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑