ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP + mysqli 数据库事务实战:从ACID原理到银行转账代码完整解析

PHP + mysqli 数据库事务实战:从ACID原理到银行转账代码完整解析 事情是这样的前阵子公司给一个老项目做账务模块迁移业务方提了个很朴素的验收标准——“转账不能出现钱对不上的情况否则要出大问题”。我当时一看老代码压根没开事务用户给商户付款就是两条裸奔的 UPDATE一条扣余额一条加余额中间但凡 MySQL 报个错或者 PHP 进程被 kill这笔钱就直接“消失”了。为了把坑填上我重新把 PHP 的 mysqli 扩展和数据库事务完整过了一遍顺手做了一套银行转账的案例代码。这套东西本身不复杂但里面关于 ACID、autocommit、异常回滚、并发隔离的细节值得拿出来写一篇完整实战给正在用原生 mysqli 做支付、钱包、订单类功能的人参考。这篇文章会从零梳理事务的核心概念到表结构怎么设计再到 mysqli 下开启事务的标准姿势、完整转账代码、失败场景模拟最后是常见的坑和排查思路。适合刚接触数据库事务的 PHP 开发者也适合那种“用过事务但没仔细想过为什么”的同行。1. 为什么转账必须要用事务不这样做的代价很大1.1 没有事务的时候一笔转账到底经历了什么先还原一下老代码的真相。用户 A 要给用户 B 转账 100 元在没有事务的代码里通常长这样// 伪代码没有任何事务保护 mysqli_query($conn, UPDATE account SET balance balance - 100 WHERE user_id 1); mysqli_query($conn, UPDATE account SET balance balance 100 WHERE user_id 2);这两条 SQL 单独执行谁也没毛病。但它俩之间没有任何“绑定关系”。如果第一条执行成功、第二条执行失败A 的 100 块就凭空没了。更常见的情况是PHP 在两条 SQL 之间抛了异常代码中断B 没收到钱。MySQL 在第二条 UPDATE 时超时报错了A 的钱已经扣掉。服务器突然断电、PHP-FPM 进程被 kill第一条 UPDATE 已经提交进磁盘。同时有多个请求在改同一个账户读到了旧余额覆盖了别人的更新。这些都是真实生产环境里出现过的问题不是纸上谈兵。没有事务就没有一个“要么全部成功、要么全部失败”的边界钱这种数据根本不敢碰。1.2 ACID 四个字母到底在说啥ACID 是事务的四个核心特质分别是原子性、一致性、隔离性、持久性。很多人背过这四个词但在实操里没把它跟代码对上。我用转账场景挨个解释原子性事务里的多个操作被视为一个不可分割的整体。要么全部提交成功要么全部回滚成没发生的样子。转账的扣款和入账就是“一个整体”不能拆开。一致性事务执行前后数据要满足所有预设约束。最直观的就是转账完成后A 和 B 的余额总和不变。另一个例子是账户余额不能小于零如果 SQL 没做扣款前检查事务内部要保证这种业务约束被满足。隔离性多个事务同时操作同一批数据时不能互相干扰。比如 A 在给 B 转账的同时C 也在查 A 的余额C 不应该看到一个“扣了款但还没入账”的中间状态除非你主动降低隔离级别。持久性事务提交成功后数据修改必须永久保存下来即使系统崩溃也不丢。这块主要靠 InnoDB 的 redo log 和 doublewrite 机制实现。用一句人话总结ACID 就是让数据库在异常情况下依然能保证数据可信。1.3 为什么 MySQL 自带的事务演示看起来简单实操却不简单很多教学案例会教你这样写START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2; COMMIT;直接在命令行里敲看起来也行了。但这跟 PHP 里边实际跑完全不是一回事。在 PHP 里你要面对的是连接是否用的是同一个 mysqli 实例。是否正确处理了异常而不是让 PDOException 或 Error 直接干掉脚本。是否检查了每条 SQL 的返回值尤其是受影响行数为 0 的情况比如余额扣减条件没命中。是否在 finally 里处理了回滚保证不把连接状态搞脏。所以在 PHP mysqli 这套技术栈下开启事务的正确姿势绝不是简单写一条 START TRANSACTION而是要把代码结构、异常流、自动提交开关都考虑进去。2. 动手前的准备环境、表结构与关键配置2.1 基础环境说明我这次实战用的是 PHP 7.4 MySQL 5.7扩展为 mysqliMySQL 引擎为 InnoDB。PHP 8.x 下写法完全兼容MySQL 8.0 也兼容只是 8.0 的默认隔离级别同样是 REPEATABLE READ不用额外调整。装环境没什么好说的宝塔也好docker 也好本地编译也好只要 phpinfo 里能看到 mysqli 扩展就行。我用命令行验证方式php -m | grep mysqli能看到 mysqli 就说明扩展可用。2.2 账户表与交易流水表设计转账相关最核心的两张表账户表和交易流水表。这里我给出可以直接跑的表结构并说明字段设计的理由。CREATE DATABASE IF NOT EXISTS bank_demo DEFAULT CHARACTER SET utf8mb4; USE bank_demo; -- 账户表 CREATE TABLE IF NOT EXISTS account ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL COMMENT 用户名, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 账户余额, version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 乐观锁版本号可选, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_name (user_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账户表; -- 交易流水表 CREATE TABLE IF NOT EXISTS transaction_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, from_account_id INT UNSIGNED NOT NULL COMMENT 转出账户ID, to_account_id INT UNSIGNED NOT NULL COMMENT 转入账户ID, amount DECIMAL(10,2) NOT NULL COMMENT 转账金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0处理中1成功2失败, error_msg VARCHAR(255) DEFAULT NULL COMMENT 失败原因, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_from_account (from_account_id), KEY idx_to_account (to_account_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT交易流水表;插入两条测试数据INSERT INTO account (id, user_name, balance) VALUES (1, Alice, 1000.00), (2, Bob, 500.00);账户表的 balance 用 DECIMAL(10,2)这是钱的标准类型。千万不能用 FLOAT 或者 DOUBLE二进制浮点算钱在精度上会出问题比如 0.1 0.2 这种事应该交给 DECIMAL。为什么一定要有 transaction_log 流水表因为事务只能保证原子操作的成败但业务上“这笔转账到底成没成”需要一条可追溯的记录。流水表加上状态字段可以用于对账、异常排查和后续补偿操作。关于 version 字段我这次案例暂时没用它做乐观锁但保留这个字段后面讨论并发时会有用处。3. mysqli 下开启事务的标准流程3.1 三步法关自动提交、执行、提交或回滚原生 mysqli 的事务操作核心方法有三个autocommit、commit、rollback。完整的标准流程是这样关闭自动提交也就是mysqli_autocommit($conn, false)。执行事务内的一组 SQL。全部成功则 commit有任何异常则 rollback。关闭自动提交的意义在于MySQL 默认每条语句执行完就自动提交如果不关掉你第一条 UPDATE 执行完数据就固化了后面再想统一回滚根本没机会。代码骨架长这样mysqli_begin_transaction($conn, MYSQLI_TRANS_START_READ_WRITE); try { // 执行多条 SQL // 业务检查 mysqli_commit($conn); } catch (Exception $e) { mysqli_rollback($conn); throw $e; }这里我用了mysqli_begin_transaction()而不是写START TRANSACTION因为前者是 mysqli 封装好的方法第二个参数还能显式指定事务模式为只读还是读写。这个细节在需要控制事务类型时很实用。3.2 核心转账逻辑完整代码实现下面给出整个转账功能的核心实现。这个函数接收转出账户 ID、转入账户 ID、转账金额三个参数返回布尔值或抛出异常。?php /** * 基于 mysqli 的银行转账 * param mysqli $conn 已连接的 mysqli 实例 * param int $fromAccountId 转出账户ID * param int $toAccountId 转入账户ID * param float $amount 转账金额 * return bool * throws Exception */ function transfer(mysqli $conn, int $fromAccountId, int $toAccountId, float $amount): bool { // 1. 基础校验 if ($fromAccountId $toAccountId) { throw new InvalidArgumentException(不能转账给自己); } if ($amount 0) { throw new InvalidArgumentException(转账金额必须大于0); } // 2. 开启事务 mysqli_begin_transaction($conn, MYSQLI_TRANS_START_READ_WRITE); try { // 3. 查询转出账户余额加行锁防止并发修改 $stmt1 mysqli_prepare($conn, SELECT balance FROM account WHERE id ? FOR UPDATE); mysqli_stmt_bind_param($stmt1, i, $fromAccountId); mysqli_stmt_execute($stmt1); $result mysqli_stmt_get_result($stmt1); $fromAccount mysqli_fetch_assoc($result); mysqli_stmt_close($stmt1); if (!$fromAccount) { throw new RuntimeException(转出账户不存在); } if (bccomp((string)$fromAccount[balance], (string)$amount, 2) 0) { throw new RuntimeException(余额不足); } // 4. 查询转入账户是否存在同样加行锁 $stmt2 mysqli_prepare($conn, SELECT id FROM account WHERE id ? FOR UPDATE); mysqli_stmt_bind_param($stmt2, i, $toAccountId); mysqli_stmt_execute($stmt2); $result2 mysqli_stmt_get_result($stmt2); $toAccount mysqli_fetch_assoc($result2); mysqli_stmt_close($stmt2); if (!$toAccount) { throw new RuntimeException(转入账户不存在); } // 5. 扣减转出账户余额 $stmt3 mysqli_prepare($conn, UPDATE account SET balance balance - ? WHERE id ?); $amountStr number_format($amount, 2, ., ); mysqli_stmt_bind_param($stmt3, si, $amountStr, $fromAccountId); mysqli_stmt_execute($stmt3); if (mysqli_affected_rows($conn) ! 1) { throw new RuntimeException(扣款失败); } mysqli_stmt_close($stmt3); // 6. 增加转入账户余额 $stmt4 mysqli_prepare($conn, UPDATE account SET balance balance ? WHERE id ?); mysqli_stmt_bind_param($stmt4, si, $amountStr, $toAccountId); mysqli_stmt_execute($stmt4); if (mysqli_affected_rows($conn) ! 1) { throw new RuntimeException(入账失败); } mysqli_stmt_close($stmt4); // 7. 写入交易流水 $stmt5 mysqli_prepare($conn, INSERT INTO transaction_log (from_account_id, to_account_id, amount, status) VALUES (?, ?, ?, 1)); mysqli_stmt_bind_param($stmt5, iis, $fromAccountId, $toAccountId, $amountStr); mysqli_stmt_execute($stmt5); mysqli_stmt_close($stmt5); // 8. 提交事务 mysqli_commit($conn); return true; } catch (Throwable $e) { // 9. 出现任何异常回滚事务 if ($conn-errno) { mysqli_rollback($conn); } // 记录流水失败状态 try { $stmt6 mysqli_prepare($conn, INSERT INTO transaction_log (from_account_id, to_account_id, amount, status, error_msg) VALUES (?, ?, ?, 2, ?)); mysqli_stmt_bind_param($stmt6, iiss, $fromAccountId, $toAccountId, $amountStr, $e-getMessage()); // 注意如果回滚后还写流水这条流水会在事务外自动提交 mysqli_stmt_execute($stmt6); mysqli_stmt_close($stmt6); } catch (Throwable $ignored) { // 日志记录失败忽略不影响主流程 } throw $e; } }这段代码有几个关键点值得展开说。第一次SELECT ... FOR UPDATE是行级锁把转出账户这一行锁住了阻止其他事务同时修改这一行。为什么这里用悲观锁因为转账业务里两个并发请求同时扣同一个账户很可能出现“都读到余额 500都判断能扣最后都执行成功余额变成负数”这种数据错乱。用FOR UPDATE让第二个事务在第一个事务提交之前一直等锁等拿到锁时重新读到的是最新余额再判断余额自然就准确了。bccomp()做余额比较是因为 PHP 浮点数直接比较有精度问题。虽然 DECIMAL 查询出来会转成字符串但保险起见钱相关的比较统一用 BC Math 扩展来处理。如果你环境没装 bcmath也可以把余额转成整数单位分来比较。根因是 PHP 的浮点数在表示 0.1 这类小数时存在二进制精度损失。把金额强转成 string 再比较是实操里最常见的做法。3.3 关于事务方法选择的补充说明mysqli 扩展里开启事务的方法除了mysqli_begin_transaction之外还可以用mysqli_autocommit($conn, false)然后执行 SQL最后 commit 或 rollback。两者效果接近但有三点差异mysqli_begin_transaction可以指定第二个参数比如MYSQLI_TRANS_START_READ_ONLY或MYSQLI_TRANS_START_READ_WRITE对事务的读写模式做显式声明。mysqli_autocommit(false)影响的是连接级别的自动提交状态用完之后如果忘记恢复成 true后续普通 SQL 都会处于“手动提交但没人 commit”的状态非常容易坑到人。实际应用中我倾向于用mysqli_begin_transaction它更接近 SQL 标准写法且不需要额外维护自动提交开关。经验之谈如果你确实用了mysqli_autocommit(false)一定要在finally里恢复自动提交。否则连接归还到连接池后下一个请求拿到一个“非自动提交”的连接执行普通的 UPDATE 半天不生效排查起来异常痛苦。4. 如何验证代码真的满足 ACID4.1 最简单直接手动制造一次失败代码写完先别高兴得验证是否真的具备原子性。我的做法是故意写一个不存在的转入账户 ID然后看整个操作是否完全回滚。调用方式try { transfer($conn, 1, 99999, 100); } catch (Exception $e) { echo 转账失败 . $e-getMessage() . PHP_EOL; }预期结果是转入账户不存在异常抛出转出账户余额依然是 1000流水表里多了一条 status2 的记录。我再验证一下“余额不足”的场景try { transfer($conn, 2, 1, 999999); } catch (Exception $e) { echo 转账失败 . $e-getMessage() . PHP_EOL; }Bob 的余额 500转 999999 必然失败。此时检查 account 表Bob 余额不变Alice 余额不变。同时 transaction_log 里多了一条失败流水。这样手动验证两次基本能确定回滚逻辑是通的。4.2 模拟并发扣款两个请求同时对同一账户操作这一步值得专门做。我用两个 PHP CLI 脚本模拟并发脚本 a.php 模拟用户 1 给用户 2 转 300脚本 b.php 模拟用户 1 给用户 3 转 400同时启动。// concurrent_test.php // 演示用完整连接代码略 $conn getConnection(); try { transfer($conn, 1, 2, 300); echo 转账A成功\n; } catch (Exception $e) { echo 转账A失败 . $e-getMessage() . \n; }另一个脚本参数改成 1 给 3 转 400。同时跑php concurrent_a.php php concurrent_b.php 如果事务和行锁生效最终 Alice 的余额应该是 1000 - 300 - 400 300不会出现 700 或者 600 这种错乱结果。两边同时执行时因为FOR UPDATE的存在第二个事务会等待第一个事务提交或回滚后才继续所以最终结果一定是正确的。这个测试我实际跑了很多次没有一次出现余额计算错误。但如果你把SELECT ... FOR UPDATE去掉就会发现在并发情况下几乎每跑几次就有一回余额不对。4.3 事务的隔离级别需不需要调MySQL InnoDB 默认隔离级别是 REPEATABLE READ可重复读。在转账场景下这个级别配合行锁和 MVCC已经能保证上面那些并发问题不出现。我不建议初学者一上来就改成 READ COMMITTED虽然在很多金融系统里 READ COMMITTED 更常见但那是因为这类系统有大量专门的对账和补偿机制已经不是单纯靠数据库事务来兜底了。如果你在做的是中小型项目保持默认的 REPEATABLE READ 就行。真正要注意的是锁等待时间这个下一节讲。有个细节值得知道在 REPEATABLE READ 下如果你在一个事务里先 SELECT 再 UPDATEMySQL 会把 SELECT 加锁读升级为当前读读到的是最新已提交版本。所以才需要用FOR UPDATE明确锁定否则两条并发请求都可能读到旧快照。5. 避坑实录mysqli 事务里的常见毛病与排查方法5.1 事务不生效十有八九是表引擎用了 MyISAM这个坑几乎是初学者的头号问题。InnoDB 支持事务MyISAM 不支持。如果你的建表语句是ENGINEMyISAM哪怕代码里写得再规范mysqli_begin_transaction和rollback都只会静默无效SQL 照样一条条自动提交。排查方法SHOW TABLE STATUS FROM bank_demo WHERE Name account;看 Engine 列如果是 MyISAM直接改表ALTER TABLE account ENGINEInnoDB;这个坑之所以可怕是因为它不报错。PHP 端 commit 和 rollback 方法都正常调用、返回 true但数据就是不回滚特别误导人。5.2 事务代码里混用了“不是同一个连接”的查询如果你的项目里有连接池或者在某些框架里习惯用Db::connect()极容易在事务里执行 SQL 时用了另一个连接。这样事务就完全失效了第一部分 SQL 在连接 A 的事务里第二部分 SQL 跑到连接 B 去执行连接 B 默认自动提交直接写成永久数据。检查思路很直接事务开始到结束的所有 mysqli 操作必须使用同一个连接变量。如果你封装了数据库类要确保事务方法内部从同一个连接获取操作句柄。5.3 对受影响行数判断过严或过松我在代码里对 UPDATE 语句判断mysqli_affected_rows($conn) 1这在大多数场景是合理的但有个特殊情况如果更新前后的值完全一样MySQL 的受影响行数可能返回 0。举例原本余额是 100你执行balance balance - 0受影响行数是 0。还有极端情况balance balance - 100但余额恰好也是 100结果值不变受影响行数也是 0。对于转账来说金额为 0 已经被拦截所以这个问题在我们的案例里不会出现。但在其他业务里要谨慎依赖受影响行数做业务判断。另外“受影响行数”和“匹配行数”的区别MySQL 文档里专门提过。默认情况下受影响行数是实际发生变更的行数不是匹配的行数。如果你用mysqli_info()去看会看到Rows matched: 1 Changed: 0 Warnings: 0这样的信息。这个细节很容易让人误判。5.4 死锁和锁等待超时并发转账场景下最容易遇到的两个报错Lock wait timeout exceeded错误码 1205。Deadlock found when trying to get lock错误码 1213。1205 是锁等待超时。默认innodb_lock_wait_timeout是 50 秒也就是说一个事务锁住某行后50 秒内没释放另一个等待的事务就会超时。我们平时操作 MySQL 很少会等 50 秒所以基本可以确定是哪里忘了提交或回滚导致锁一直没释放。1213 是死锁。比如事务 A 先锁账户 1 再锁账户 2事务 B 先锁账户 2 再锁账户 1两边互相等对方释放锁就死锁了。InnoDB 会自动检测并回滚其中一个事务让它抛出 1213。预防死锁最简单的方式多个事务涉及多行数据时统一按照相同的顺序访问比如都先处理账户 ID 小的那一行再处理账户 ID 大的那一行。在我们的转账代码里可以加一段逻辑$minId min($fromAccountId, $toAccountId); $maxId max($fromAccountId, $toAccountId);按 $minId 到 $maxId 的顺序加锁能极大减少死锁概率。不过在我们当前这个两个账户互相转账、没有第三个账户参与的例子里出现的概率比较低。5.5 为什么我还不建议在事务里做远程调用写代码时要克制一个冲动在事务中间去调外部接口比如发短信、调支付网关、请求第三方风控。原因很简单事务期间持有的数据库连接和锁可能因为外部服务响应慢而长时间不释放拖垮整个数据库连接池。以转账为例如果你在 UPDATE 之后、COMMIT 之前调了短信接口而短信接口 10 秒没响应那这笔转账的数据库锁就白白握了 10 秒。这个期间其他用户给 Alice 转账全部排队等待。正确的做法是事务只负责数据库状态变更事务提交之后再发通知。也就是说外呼操作放到事务外面。如果外部调用失败再走补偿逻辑。这也是为什么很多系统里要有个“转账结果表”或者“消息队列表”就是为了解耦事务和外呼。5.6 事务代码中异常类型别只盯着 ExceptionPHP 7 之后很多错误是Error而不是Exception。比如调用了不存在的函数、类型错误都会抛Error。如果 catch 里只写catch (Exception $e)那Error会直接穿透事务不会回滚数据就坏了。所以我在转账代码里用的是catch (Throwable $e)Throwable 能同时捕获 Exception 和 Error。这是很多老代码里没注意到的点值得单独拎出来。另外一个隐蔽坑如果事务中间有个 SQL 报了错但你用or die(mysqli_error($conn))这种老写法直接终止脚本脚本结束时会自动回滚吗不一定。如果你的连接关闭了事务会被回滚但如果框架在 shutdown 阶段自动 commit 了那结果就不确定。所以别依赖脚本结束来自动处理必须在代码里显式回滚。6. 进阶扩展这套代码还能怎么改代码跑通之后有几个方向可以继续完善。第一把金额单位换成整数分存储彻底告别浮点数问题。表结构改成balance BIGINT单位是分PHP 端展示的时候再除以 100。这种做法在一些高并发交易系统里很常见省去 DECIMAL 的精度转换开销。第二把流水表写入抽取为独立函数方便后续对账。我们的 transaction_log 只有成功和失败两种状态真实业务里可能还有“待对账”“已对账”“已冲正”等状态。把流水逻辑抽出来以后加补偿任务就顺手很多。第三加接口层。提供一个 HTTP 接口前端调用时通过$_POST接收参数事务代码只负责内部逻辑。这样既方便测试也方便以后换成 PDO 或者 ORM。第四切换到 PDO 扩展。PDO 对事务的支持比 mysqli 更统一方法名更简洁预处理也更顺手。如果你在新项目里能从零选型建议直接用 PDO。但如果是老项目已经大量使用 mysqli也没必要为了用 PDO 而重写所有数据库层毕竟 mysqli 该有的能力一个不少。我个人的习惯是老项目接受 mysqli新项目用 PDO。但事务思想的本质完全一样不是换了个扩展就自动安全该有的锁、隔离级别、异常处理一个都不能少。7. 写在最后的小提示最后分享一个我踩坑多次换来的经验在生产环境给账户表加索引和行锁很容易但排查“事务到底有没有生效”特别容易忽略一个前提就是连接本身是否开启了自动提交。所以我在自己的代码里会在mysqli_begin_transaction之后加一行调试日志在mysqli_commit之前也加一条确保执行流真的按照事务的边界在跑。代码写完一定要跑失败场景不要只测正常转账。正常流程转账成功没什么稀罕真正值钱的是断网、余额不够、账户不存在、并发同扣这些异常场景下数据依然可靠。每次事故复盘十次有九次都是“正常路径没问题异常路径没人管”导致的。这套案例代码我已经整理到自己的项目模板里了以后凡是涉及钱包、积分、预存款这类余额变动功能直接拿这套结构去套再根据业务调整校验逻辑效率高很多。希望这篇也能帮你少踩几个坑。
返回列表