尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PHP事务实战:用mysqli+银行转账吃透ACID与并发

PHP事务实战:用mysqli+银行转账吃透ACID与并发 写 PHP 这么多年我第一次真正意识到“事务”是干嘛的是在一个支付项目线上出了 bug 之后。表面现象是用户支付成功、订单状态没更新更深层看是代码里扣款、写订单、记流水三个 SQL 操作分散在不同地方中间某个环节抛了异常数据就对不上了。那种状态下你查日志、对数据、找差异可能半天都说不清金额为什么凭空少了一笔。从那以后我再看到 PHP 里的数据库操作第一反应永远是这段逻辑是不是该放进一个事务里事务在 PHP 开发里并不神秘它本质上就是数据库提供的一套机制让你可以把多个 SQL 操作打包成一个“要么全部成功、要么全部失败”的整体。这篇文章用最常见的 mysqli 扩展结合经典的银行转账案例把 ACID 四个特性、事务语句、隔离级别、行锁以及实战中容易踩的坑完整过一遍。读完之后你不仅能写出带事务的转账代码遇到并发扣款、超时回滚这类问题时也知道从哪里下手查。1. 事务是什么先用银行转账把 ACID 拆明白很多新手看到 ACID 四个字母第一反应是背概念原子性、一致性、隔离性、持久性。背完就忘因为概念没和具体场景挂钩。其实这四个特性完全可以用一笔转账讲清楚。1.1 从转账逻辑反推 ACID 的真实含义假设你要实现一个最简单的转账功能从 A 账户扣 100 元往 B 账户加 100 元。在数据库层面这至少是两条 UPDATEUPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2;原子性Atomicity要求的就是这两条 UPDATE 要么都成功要么都失败。如果第一条执行完、第二条执行到一半数据库突然宕机那 A 的钱没了B 的钱没到这就是典型的“钱凭空消失”。有了事务系统会在第二条失败时把第一条也撤销回到转账前的状态。一致性Consistency关注的是业务规则不被破坏。银行系统的老会计会告诉你转账前后所有账户余额总和必须不变。事务在这里起的作用是保证你在事务内做的所有操作都符合预设约束比如余额不能为负数、流水表必须插入记录。如果某一步违反了规则整个事务照样回滚。隔离性Isolation解决的是并发问题。两个用户同时操作同一条数据比如同一个账户被两笔请求同时扣款如果没有隔离机制就可能出现“两个事务都读到余额是 100各自扣了 50最后余额变成 50 而不是 0”的情况。隔离性就是让事务之间互不干扰或者说在可控范围内互不干扰。持久性Durability最容易理解只要事务提交成功数据就必须永久保存即使系统马上断电重启后数据依然在。数据库通过 redo log 这类机制来保证这一点。这四个特性不是四个独立选项而是一个组合拳。原子性保证单个事务内部的完整性一致性保证业务规则不被破坏隔离性保证并发环境下的正确性持久性保证提交结果不会丢。任何一个环节出问题转账逻辑都不可信。1.2 为什么 PHP 项目里事务总被写歪PHP 的特点是请求生命周期短、无状态一个典型的 PHP 请求从接收到返回可能只有几十毫秒。这种模型让很多开发者习惯于“顺序写 SQL、出了问题靠日志猜”对事务的重视程度远不如 Java、Go 这类后端技术栈。再加上不少项目用框架封装了数据库操作新手可能只见过DB::beginTransaction()这样的方法却不清楚底层到底发生了什么。常见的写歪方式有三种把事务放到了循环外面导致某一条数据失败时前面已提交的数据无法回滚。在事务中间执行了CREATE TABLE、ALTER TABLE或者mysqli::commit()之外的隐式提交操作导致事务被提前打断。异常捕获写得不完全有些错误路径没走到rollback()事务就一直挂着连接长时间持有锁。在写代码之前先理解这些反面案例比直接抄一段事务代码更有价值。事务不是“包一层 begin 就完事”而是需要你清楚每条 SQL 的执行顺序、每个分支的退出路径。2. 动手前的准备mysqli 事务基础与连接配置既然要用 mysqli 实现事务环境检查这步别跳过。很多事务问题源自扩展没开、表引擎不对、或者 PHP 版本太老代码写得再对也没用。2.1 环境要求与连接方式建议至少满足这些条件PHP 7.4 及以上版本PHP 8.0 之后对 mysqli 的支持依然很好。数据库使用 MySQL 5.7 或 8.0事务依赖 InnoDB 引擎请务必确认表引擎是 InnoDB。mysqli 扩展已开启php -m | grep mysqli能看到 mysqli 字样。连接数据库时我习惯显式设置字符集避免中文数据出现乱码影响后续判断。基础连接代码?php $host 127.0.0.1; $port 3306; $user root; $pass your_password; $db bank_demo; $mysqli new mysqli($host, $user, $pass, $db, $port); if ($mysqli-connect_errno) { // 连接失败时 mysqli-connect_error 里是具体错误信息 throw new RuntimeException(数据库连接失败: . $mysqli-connect_error); } $mysqli-set_charset(utf8mb4);注意new mysqli()失败时并不会自动抛异常只是设置connect_errno所以必须手动判断。后续如果希望所有 mysqli 操作出错时直接抛异常可以这样设置mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);开启之后SQL 执行出错会直接抛mysqli_sql_exception配合事务回滚写起来会舒服很多不用在每个query()调用的返回值里反复判断。2.2 autocommit 与事务函数梳理MySQL 默认是自动提交模式也就是每执行一条 SQL只要没语法错误立刻写入磁盘。这个模式在不需要事务的普通查询里很方便但在多个操作需要捆绑时就非常危险。mysqli 扩展里和事务直接相关的方法有三个begin_transaction()关闭自动提交开启一个新事务。commit()提交事务把事务内的所有变更确认落库。rollback()回滚事务把事务内的所有变更撤销。有些人会看到老代码里用mysqli-autocommit(false)加上mysqli-commit()的组合效果和begin_transaction()类似。区别在于begin_transaction()更语义化从 PHP 5.5 开始推荐使用而且它支持传入标志位比如只读事务、快照事务等。日常开发我优先用begin_transaction()。一个容易忽略的细节begin_transaction()之前如果已经有未提交的事务直接调用可能会报错或者隐式提交。稳妥的做法是在事务代码入口检查$mysqli-errno或者统一规划好事务边界避免多层嵌套。mysqli 本身不支持真正的事务嵌套第二个begin_transaction()默认会返回错误遇到这种情况需要自己设计“事务嵌套层级”或者干脆禁止嵌套。3. 银行转账案例完整实现这一节是全文核心。我直接给出一个可运行的转账函数然后逐段解释为什么这么写。3.1 表结构与余额设计先建两张表账户表和流水表。企业级的账户体系远比这复杂但演示事务足够。CREATE TABLE account ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL DEFAULT , balance DECIMAL(12,2) NOT NULL DEFAULT 0.00, version INT UNSIGNED NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE account_flow ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, account_id INT UNSIGNED NOT NULL, change_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, balance_after DECIMAL(12,2) NOT NULL DEFAULT 0.00, remark VARCHAR(255) NOT NULL DEFAULT , created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_account_id (account_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个细节要强调第一余额字段必须用DECIMAL不能用FLOAT或DOUBLE。浮点数在计算机里是近似值做金额加减会产生不可预期的误差。DECIMAL(12,2)可以精确到分大多数业务场景够用了。第二流水表里记录balance_after意思是这笔操作之后账户的余额。这个字段在对账、排查数据问题时非常有用能在不锁表的情况下快速确认金额链路是否完整。3.2 完整转账代码转账函数需要做的事包括校验参数、开启事务、扣减转出账户余额、增加转入账户余额、写入流水、提交事务任何一步出错都回滚。?php /** * 转账 * * param mysqli $mysqli * param int $fromAccountId 转出账户ID * param int $toAccountId 转入账户ID * param float $amount 转账金额 * return bool * throws RuntimeException */ function transferMoney(mysqli $mysqli, int $fromAccountId, int $toAccountId, float $amount): bool { if ($fromAccountId $toAccountId) { throw new InvalidArgumentException(不能给自己转账); } if ($amount 0) { throw new InvalidArgumentException(转账金额必须大于0); } // 统一使用 DECIMAL避免浮点误差金额转成字符串传入 SQL $amount number_format($amount, 2, ., ); $mysqli-begin_transaction(); try { // 1. 查询转出账户并加上行锁 $stmt $mysqli-prepare(SELECT id, balance FROM account WHERE id ? FOR UPDATE); $stmt-bind_param(i, $fromAccountId); $stmt-execute(); $fromResult $stmt-get_result()-fetch_assoc(); if (!$fromResult) { throw new RuntimeException(转出账户不存在); } // 2. 检查余额是否充足这一步必须在事务内完成 if (bccomp($fromResult[balance], $amount, 2) 0) { throw new RuntimeException(余额不足); } // 3. 扣减转出账户余额 $stmt $mysqli-prepare(UPDATE account SET balance balance - ? WHERE id ?); $stmt-bind_param(si, $amount, $fromAccountId); $stmt-execute(); // 4. 查询转入账户并加上行锁 $stmt $mysqli-prepare(SELECT id, balance FROM account WHERE id ? FOR UPDATE); $stmt-bind_param(i, $toAccountId); $stmt-execute(); $toResult $stmt-get_result()-fetch_assoc(); if (!$toResult) { throw new RuntimeException(转入账户不存在); } // 5. 增加转入账户余额 $stmt $mysqli-prepare(UPDATE account SET balance balance ? WHERE id ?); $stmt-bind_param(si, $amount, $toAccountId); $stmt-execute(); // 6. 写入转出流水 $afterFromBalance bcsub($fromResult[balance], $amount, 2); $stmt $mysqli-prepare( INSERT INTO account_flow (account_id, change_amount, balance_after, remark) VALUES (?, ?, ?, ?) ); $remark 转出至账户 . $toAccountId; $stmt-bind_param(isds, $fromAccountId, $amount, $afterFromBalance, $remark); $stmt-execute(); // 7. 写入转入流水 $afterToBalance bcadd($toResult[balance], $amount, 2); $stmt $mysqli-prepare( INSERT INTO account_flow (account_id, change_amount, balance_after, remark) VALUES (?, ?, ?, ?) ); $remark 从账户 . $fromAccountId . 转入; $stmt-bind_param(isds, $toAccountId, $amount, $afterToBalance, $remark); $stmt-execute(); // 8. 全部成功提交事务 $mysqli-commit(); return true; } catch (Throwable $e) { // 任何异常都回滚避免扣款成功但流水没写 $mysqli-rollback(); throw $e; } }代码用到了bcsub、bcadd这两个 BC Math 函数它们是 PHP 处理高精度数值的利器和DECIMAL字段配合起来金额计算基本不会因为浮点问题翻车。如果你的环境没装bcmath扩展也可以把金额当作字符串自己写加减或者直接让 MySQL 用balance balance - ?这种表达式计算获取最终余额时再查一次。3.3 代码走读与关键决策这段代码有几个决策点值得单独说清楚。为什么要用SELECT ... FOR UPDATE锁转出账户因为并发场景下不锁行的话两个请求同时读到余额是 100各自判断余额足够然后各扣 50最终余额变 50 而不是 0。加了FOR UPDATE后第二个事务的SELECT ... FOR UPDATE会等待第一个事务提交或回滚从而避免并发扣款超扣。为什么不直接用UPDATE account SET balance balance - ? WHERE id ? AND balance ?做条件更新然后再检查影响行数这也是常见做法而且性能更高因为它减少了一次 SELECT。但问题在于条件更新确认的是“账户存在且余额足够”却无法在线程中继续写“扣款后的余额是多少”的流水你还得再查一次。对于演示来说先 SELECT 后 UPDATE 更直观也方便后面扩展到更复杂的业务校验。实际项目中我会根据性能需求选择。为什么要写流水表而且放进同一个事务因为资金类操作只改余额是不够的。如果没有流水后续排查一笔异常转账时你根本无法还原当时的操作过程。流水和余额变更必须在同一个事务里要么一起成功要么一起失败否则就会出现“余额变了但查不到流水”的尴尬境地。事务代码写完后建议在测试环境直接模拟一次断线或异常场景验证rollback()是否真的把余额变回去了。这一步很多人忽略结果代码逻辑看着对真出问题时才发现异常被吞了或者事务根本没开启。4. 并发、锁与隔离级别实战转账案例能跑通只是第一步线上的真实挑战永远是并发。这一节把隔离级别和锁这两个重头戏说透。4.1 三个并发读问题的直观理解谈到隔离级别绕不开三个经典问题脏读、不可重复读、幻读。脏读是指一个事务读到了另一个事务还没提交的数据。事务 A 改了余额但没提交事务 B 读到了这个改过的余额万一 A 回滚B 就基于一个不存在的值做了判断。隔离级别为READ UNCOMMITTED时会出现这种现象。不可重复读是指同一事务内两次相同的 SELECT 查出的结果不一样。比如事务 A 先查询余额是 100事务 B 提交了余额改为 90事务 A 再查就变成 90。隔离级别为READ COMMITTED及以下时可能发生。幻读是指同一事务内两次查询返回的行数不一样。比如事务 A 查询所有余额大于 0 的账户事务 B 插入了一个新账户并提交事务 A 再查多了一行。这是因为范围查询的间隙可以被其他事务插入。REPEATABLE READ和SERIALIZABLE之间的取舍往往就是在讨论怎么解决幻读。用表格概括就是隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不会可能可能REPEATABLE READ不会不会可能InnoDB 下通过间隙锁多数情况可避免SERIALIZABLE不会不会不会MySQL 默认隔离级别是REPEATABLE READ。很多从其他数据库转过来的开发者会惊讶因为 Oracle 默认是READ COMMITTED。MySQL 之所以敢在REPEATABLE READ下应对大多数业务场景是因为 InnoDB 实现了间隙锁Next-Key Lock在特定条件下能规避幻读。4.2 隔离级别的设置方式会话级设置非常简单SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;也可以在 PHP 代码里执行$mysqli-query(SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED);但要注意这条语句必须在begin_transaction()之前执行否则对本事务不生效。全局设置要谨慎会影响所有连接一般在配置文件my.cnf里改[mysqld] transaction-isolation READ-COMMITTED实际项目中我建议优先保持 MySQL 默认的REPEATABLE READ。因为READ COMMITTED虽然在部分场景下能减少锁冲突但会让“同一事务内两次查询结果不一致”的问题暴露出来代码里如果没做好兜底反而更难排查。只有在你明确知道某个业务不需要可重复读、且希望降低锁等待时才去调整隔离级别。4.3 行锁 SELECT ... FOR UPDATE 的正确用法回到转账案例我加了FOR UPDATE这是一种悲观锁。它假设并发冲突很常见所以直接把人家的行锁住让别人等我。好处是逻辑简单、不会超扣坏处是并发量上来之后锁等待会成为瓶颈。使用FOR UPDATE有几个注意事项必须在事务内执行才有意义。如果没开启事务SELECT ... FOR UPDATE锁住的行会立刻释放相当于白锁。锁的是索引记录。如果 WHERE 条件没走索引InnoDB 可能升级为锁表影响范围瞬间变大。所以FOR UPDATE的查询条件一定要走主键或唯一索引。查询不存在的行时FOR UPDATE同样会加间隙锁可能阻塞其他事务插入数据。转账场景里转入账户不存在时反而会因为这个间隙锁影响同范围内的插入操作所以代码里要在锁内检查结果集。在转账逻辑里正确的加锁顺序很重要。如果事务 A 先锁账户 1 再锁账户 2事务 B 先锁账户 2 再锁账户 1就可能出现死锁。解决方式就是统一加锁顺序比如按账户 ID 排序后再锁。老练的开发者写转账相关逻辑时会先把两个账户 ID 做排序按固定顺序加锁从根上规避死锁。5. 常见问题与排查技巧实录这一节写的都是我实际遇到过、或者在帮别人排查代码时见过的问题不是网上抄来的理论每一条都有具体的现场特征。5.1 事务不生效的典型原因事务代码写得没问题但实际跑起来数据还是乱的先按下面这个清单查表引擎不是 InnoDB。MyISAM 不支持事务begin_transaction()不会报错但操作根本没有回滚能力。执行SHOW TABLE STATUS WHERE Name account确认 Engine 字段是 InnoDB。事务中间执行了隐式提交操作。MySQL 中ALTER TABLE、CREATE TABLE、DROP TABLE、TRUNCATE TABLE、GRANT、LOCK TABLES等语句都会导致当前事务隐式提交前面的未提交操作立刻落库。如果事务代码里混入了这类语句回滚就失灵了。异常被吞掉了。PHP 代码里如果把query()包在try { } catch {}里不往外抛或者用了抑制错误那rollback()可能根本没走到事务会一直挂着。开启mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT)能少踩这个坑。事务连接对象不一致。常见于框架里用了连接池或者手动 new 了多个 mysqli 对象一个对象开启事务另一个对象执行 SQL事务自然管不到后面的操作。嵌套事务处理不当。mysqli 不支持原生嵌套事务第二个begin_transaction()要么报错要么被忽略内层想回滚根本做不到。要么设计成单层事务要么自己维护事务计数器模拟保存点。5.2 死锁、超时与重试死锁在转账场景里很常见尤其是两个账户互相转账的并发请求。MySQL 检测到死锁后会回滚其中一个事务并返回错误码 1213ER_LOCK_DEADLOCK。锁等待超时则返回错误码 1205ER_LOCK_WAIT_TIMEOUT。遇到死锁正确做法不是改 SQL而是让业务层重试。伪代码如下$maxRetry 3; $retry 0; while ($retry $maxRetry) { try { transferMoney($mysqli, $fromId, $toId, $amount); break; } catch (mysqli_sql_exception $e) { if ($e-getCode() 1213 || $e-getCode() 1205) { $retry; usleep(100000 * $retry); // 简单退避 continue; } throw $e; } }重试前要注意连接的事务状态已经不可控。如果transferMoney()内部已经调用过rollback()这个连接可以继续使用如果没回滚就必须先手动rollback()或重新连接否则后续 SQL 会一直报“Commands out of sync”之类的错误。5.3 排查实录从日志到锁监控我在定位线上事务问题时习惯按三步走。第一步看应用日志里有没有 SQL 异常尤其是死锁和锁等待超时。很多项目只在异常时记录 message不记录错误码这会导致你无法区分死锁和普通语法错误。建议日志中至少记录异常类、错误码、SQL 语句、请求参数。第二步连上 MySQL 执行SELECT * FROM information_schema.innodb_trx\G这张表会列出当前所有正在执行的事务重点关注trx_started、trx_state、trx_rows_locked字段。如果有事务长时间处于RUNNING状态且trx_rows_locked很大十有八九是锁等待或事务没提交。第三步查锁等待关系SELECT * FROM information_schema.innodb_lock_waits\G配合performance_schema.data_lock_waits能定位到具体是哪个事务阻塞了哪个事务。加上sys.innodb_lock_waits视图MySQL 5.7 自带 sys 库可以直接看到阻塞者和被阻塞者的连接 ID、SQL 片段排查效率会高很多。遇到怀疑是大事务的情况还可以执行SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration FROM information_schema.innodb_trx;事务持续时间超过几十秒基本可以断定代码里有外部调用、慢 SQL 或者忘记提交的问题。6. 写在最后的实操心得6.1 我踩过的几个坑第一个坑是在事务里做了远程 HTTP 调用。当时为了通知下游系统我把请求接口放在了事务提交之前结果下游响应超时数据库连接一直持有锁整个表的更新操作被堵了几分钟。后来改成先提交事务再发异步通知才彻底解决。记住一个原则事务里只做数据库操作外部 IO 一律挪到事务外。第二个坑是在大事务里批量更新。有一次做定时任务循环了几千条数据每条数据都放在同一个事务里执行更新。事务越积越大最后主从延迟飙高还出现了锁等待超时。优化方案很简单分批提交每 200 条一个事务既保证局部一致性又不至于把事务撑爆。第三个坑是测试时候偷懒没用真实并发压测。单线程跑事务代码一直正常上线后第一波双十一流量就把余额扣成了负数。后来用两个终端开两个 mysql 会话模拟同时转账才真正验证了锁的作用。写事务代码的验证标准必须是并发场景下依然正确而不是单线程能跑通。6.2 除了事务你还需要想清楚这些事务解决的是数据库层面的原子性和隔离性但一个完整的资金类系统光靠事务远远不够。比如幂等性。如果客户端超时重试同一笔转账请求可能被发送两次事务本身就是“都成功或都失败”但无法判断这是不是同一笔业务。这时候需要业务幂等表用唯一键约束保证同一笔单子只处理一次。比如对账。即使有了事务线上还是可能出现数据库本身无法发现的逻辑错误比如程序 bug 把金额算错了。定期跑对账任务把账户余额、流水、第三方支付账单三方核对一遍才能兜住最后一层。再比如性能。事务保证了正确性却也拉长了锁持有时间。对于高并发扣款类业务可以在事务外先做预扣、再用消息队列异步更新账单不过这已经超出本文范围了属于架构层面的取舍。写事务代码这几年我的体会是数据库事务不是银弹但它是一张安全网。你得先会正确使用这张网再讨论怎么网眼更细、性能更好。希望这篇文章能帮你在 PHP 的 mysqli 事务路上少走几步弯路遇到问题时能自信地说一句“这是并发问题我见过”。
返回列表