事情是这样的:前阵子公司给一个老项目做账务模块迁移,业务方提了个很朴素的验收标准——“转账不能出现钱对不上的情况,否则要出大问题”。我当时一看,老代码压根没开事务,用户给商户付款就是两条裸奔的 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,扩展为 mysqli,MySQL 引擎为 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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. 如何验证代码真的满足 ACID
4.1 最简单直接:手动制造一次失败
代码写完先别高兴,得验证是否真的具备原子性。我的做法是故意写一个不存在的转入账户 ID,然后看整个操作是否完全回滚。
调用方式:
try { transfer($conn, 1, 99999, 100); } catch (Exception $e) { echo "转账失败:" . $e->getMessage() . PHP_EOL; }预期结果是:转入账户不存在,异常抛出,转出账户余额依然是 1000,流水表里多了一条 status=2 的记录。
我再验证一下“余额不足”的场景:
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 再 UPDATE,MySQL 会把 SELECT 加锁读升级为当前读,读到的是最新已提交版本。所以才需要用FOR UPDATE明确锁定,否则两条并发请求都可能读到旧快照。
5. 避坑实录:mysqli 事务里的常见毛病与排查方法
5.1 事务不生效:十有八九是表引擎用了 MyISAM
这个坑几乎是初学者的头号问题。InnoDB 支持事务,MyISAM 不支持。如果你的建表语句是ENGINE=MyISAM,哪怕代码里写得再规范,mysqli_begin_transaction和rollback都只会静默无效,SQL 照样一条条自动提交。
排查方法:
SHOW TABLE STATUS FROM bank_demo WHERE Name = 'account';看 Engine 列,如果是 MyISAM,直接改表:
ALTER TABLE account ENGINE=InnoDB;这个坑之所以可怕,是因为它不报错。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 事务代码中异常类型别只盯着 Exception
PHP 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之前也加一条,确保执行流真的按照事务的边界在跑。
代码写完一定要跑失败场景,不要只测正常转账。正常流程转账成功没什么稀罕,真正值钱的是断网、余额不够、账户不存在、并发同扣这些异常场景下,数据依然可靠。每次事故复盘,十次有九次都是“正常路径没问题,异常路径没人管”导致的。
这套案例代码我已经整理到自己的项目模板里了,以后凡是涉及钱包、积分、预存款这类余额变动功能,直接拿这套结构去套,再根据业务调整校验逻辑,效率高很多。希望这篇也能帮你少踩几个坑。