PHP事务内禁止调用外部API:长事务、锁与最终一致性重构指南

发布时间:2026/10/15 21:50:01
PHP事务内禁止调用外部API:长事务、锁与最终一致性重构指南
PHP事务内禁止调用外部API这句话我在代码评审里强调过很多次自己早年间也因为没守住这条底线吃过亏。当时做订单系统为了省事把支付下单、库存扣减、短信通知全塞进同一个数据库事务里上线后第一次大促就对账对得头皮发麻——用户那边钱扣了本地订单表里却查不到记录。今天就用庖丁解牛的方式把这个原则从原理到实践完整拆一遍为什么事务里不能调外部接口外部副作用会在哪个环节咬人已经踩进坑里的代码又该怎么一步步重构出来。这篇东西适合写PHP事务的开发者读也适合那些review别人代码时总觉得“有问题但一时说不清哪里有问题”的朋友。1. 先想清楚事务的本质它到底能保护什么1.1 一条数据库事务的完整生命周期一条事务从begin开始到commit或rollback结束。在这期间数据库为它分配事务ID记录undo日志锁住它操作过的行在特定隔离级别下还要维护一个一致性快照。事务的初衷很纯粹保证同一数据库连接内的一组本地写操作要么全部生效要么全部不生效。注意我这里特别强调了“本地数据库操作”。很多同事写代码时会把事务当成业务逻辑的“保险箱”觉得把支付、库存、通知全部包进去它们就能和订单数据一起成功、一起失败。这是个非常普遍的误解。事务不是分布式协调器它做不了跨系统的原子性。数据库事务能控制的只有当前连接、当前实例里那个数据库文件的数据变化它控制不了网络另一端任何一个服务的状态。把外部API调用塞进事务相当于厨师炒菜炒到一半关火跑出去买菜锅里的菜确实是你的但外面那家菜市场可不听你的指挥你去了不一定买得着买回来你的菜也可能已经糊了。1.2 外部API调用的本质不可控的等待那么什么是“外部API”这里的“外部”不单单指第三方更准确地说是指任何经过网络传输、不由当前数据库事务掌控的调用第三方支付平台的下单接口、库存服务的扣减接口、用户中心的RPC、短信邮件推送网关都算。它们的共同特点是延迟不可控、结果不可预知。一次普通的HTTP请求从DNS解析开始TCP握手、TLS协商、服务端业务处理、响应返回每一个环节都可能消耗几十毫秒甚至几秒。更麻烦的是超时的时候你根本不知道对方到底执行了没有——有时候是服务端已经扣款成功只是响应报文在网络上丢了。这种不确定性一旦放进数据库事务里事务原本“短小快”的特点就被彻底破坏数据库连接被长时间占用行锁被长时间把持整个系统的吞吐量会被一根外部请求拖垮。2. 三宗罪长锁、连接耗尽和无法回滚的副作用2.1 行锁和表锁被无限期持有先看最常见的场景。事务A更新了订单表里某一行的状态紧接着调用外部支付API支付网关平均耗时1.5秒。就在这1.5秒里事务B想给同一行订单更新一个取消标记它就只能阻塞在锁等待状态。如果并发量上来排队等锁的会话越来越多InnoDB的锁等待超时参数一旦到了临界点业务就会报出典型的Lock wait timeout exceeded; try restarting transaction。最迷惑的地方在于你去查那条update语句它本身执行只要1毫秒但整个事务从开启到提交横跨了几秒钟这1毫秒的活被无限拉长数据库表面负载不高业务却大量报错。我帮人排查过很多线上的长事务问题十有八九都能在事务范围内找到HTTPClient的影子。长事务最阴险的一点是它不一定会立刻报错而是让锁和连接像慢性病一样越积越多直到某个时间点集中爆发。2.2 连接池被“慢事务”占满PHP-FPM模式下每个PHP进程通常持有自己的数据库连接在连接池模式里连接数量更是有限资源。一个请求在事务里调外部API阻塞3秒这条数据库连接就被占用3秒期间它做不了任何别的事情。做个粗算假设连接池有20个连接20个请求同时命中这种事务内调API的代码连接池瞬间打满后续所有请求排队等待拿连接。表现就是接口RT从几十毫秒暴涨到几十秒数据库服务器的CPU可能还很闲但客户端就是等不到响应。这是非常典型的应用层错误最后往往被别人误判成数据库性能问题浪费一堆时间去看慢查询、加索引其实换个角度看代码两分钟就能定位到根因。用生活化的比喻就是修车铺工位全占着每台车都要等师傅外出采购零件出去的师傅没回来后面的车全部堵在门口铺子里却没有一个工位在真正干活。2.3 回滚困境数据库能回滚外部副作用不会这是事务内调用外部API最致命的问题。看一段典型伪代码$pdo-beginTransaction(); try { insertOrder($order); $payResult callPayApi($order); // 外部API调用 decreaseStock($order-sku); $pdo-commit(); } catch (\Throwable $e) { $pdo-rollBack(); throw $e; }问题在于如果decreaseStock失败触发了rollBack数据库里刚插入的订单和库存会一起回滚看起来一切干净了。但支付平台那边的钱已经真实扣掉了。数据库的rollback能撤销本地行却没有能力撤销外部已经扣款的动作、已经发出的短信、已经扣减的积分。这就是分布式系统里典型的“部分成功”状态外部成功、本地失败。更隐蔽的是哪怕把支付调用挪到事务的最后一条commit之前数据库任何一个环节报错依然存在这个窗口。所以结论只有一个只要外部调用发生在事务提交之前不一致就不可避免。3. 高频反模式现场订单、积分、短信这三个坑最典型3.1 下单、支付、库存一锅端上面那段伪代码就是下单场景最常见的反模式。它一次踩了前面说的三个坑支付API耗时长事务把订单行锁得死死的支付一旦成功但本地后续SQL失败用户付了钱订单却没生成库存扣减放在支付之后高并发场景下还容易超卖。这个场景里多数产品经理要求的是“用户下单必须保证库存、支付、订单三件事一致”。但正确做法不是把它们放进同一个数据库事务而是先落本地订单订单状态标记为“待支付”立刻提交事务释放锁事务提交成功后再去调支付API最终以支付回调的结果为准更新订单状态为“已支付”或者“支付失败”并配合库存预留和超时释放策略来做。3.2 积分和余额的跨系统同步积分系统的账户往往在另一个数据库甚至完全另一个服务。但很多PHP项目为了省事会在用户充值、下单扣积分的过程中于同一个本地事务里同步调用积分服务。这样的代码积分服务一次抖动主链路整体失败积分服务超时主库事务被拖成慢事务。我见过一个案例某个会员日活动中积分服务因为缓存穿透变慢结果主订单服务的数据库连接池被全部占满整个下单入口瘫痪了将近半小时。后期拆成事件异步同步之后积分服务再怎么抖动主流程都纹丝不动。正确的姿势是本地事务只写用户在本库的流水和余额变化同时在同一事务里插入一条待发送的事件记录提交之后再通过队列或事件分发器把“用户积分解冻/扣减”的消息发出去由积分服务自行消费并通过幂等保证只处理一次。3.3 短信和推送“顺手”放进事务里短信、邮件、App推送这类通知是事务内调用外部API里最没有必要、危害却很大的一个。短信网关可能因为手机号格式错误、通道限流、供应商故障等各种原因报错而且第三方通道超时情况非常常见。如果你在事务里调用短信服务一条短信超时整个订单回滚。我做过的项目中真实发生过后台管理员批量操作时事务里带了短信群发短信通道抖动导致事务一直不提交结果旁边一张高频访问的数据表被锁影响前台用户看到的是整个服务不可用而不是“短信没发出去”。这种场景几乎没有任何业务需要把通知和核心数据放进同一个原子操作里。通知本来就应该走异步丢失最多补发一条完全不值得为它牺牲系统稳定性。4. 重构落地方案把事务边界画在副作用之外4.1 核心原则本地事务只做本地的事重构后最核心的一条原则是“凡是会经过网络的调用一律放到commit之后”。把上面的下单例子改成这样$pdo-beginTransaction(); try { insertOrder($order); $pdo-commit(); } catch (\Throwable $e) { $pdo-rollBack(); throw $e; } try { callPayApi($order); } catch (\Throwable $e) { // 记录日志发起补偿或进入重试队列 markOrderPayFailed($order-id, $e-getMessage()); }事务提交完之后锁释放了连接归还了这时候再做外部调用外部服务慢一点、挂掉一点影响范围都被限制在一个请求内不会传导到数据库层。但代价是你失去了“数据库回滚兜底”的幻觉必须主动去处理“订单已经落库、支付调用失败”的后续逻辑。4.2 本地消息表加异步Worker最稳妥的落地姿势如果希望更鲁棒可以引入本地消息表也有人管它叫 outbox 模式。思路很简单业务数据和待发送消息在同一个库、同一个事务里写入提交后由独立的Worker扫表把消息逐个投递给外部API成功后更新消息状态为已发送。$pdo-beginTransaction(); try { insertOrder($order); insertOutboxMessage([ topic order.paid, payload json_encode($order), status pending, ]); $pdo-commit(); } catch (\Throwable $e) { $pdo-rollBack(); throw $e; }这里有个很多人没注意到的关键点业务表和消息表必须在同一个库、同一个连接、同一个事务里写。如果把消息插入放到事务外那消息表本身就面临“订单写成功了消息没写进去”的问题跟直接调API又有什么区别呢出队Worker消费时需要做幂等防止重复投递。消息表至少要有这些字段id、topic、payload、status、retry_count、next_retry_time、create_time。Worker扫描statuspending且next_retry_time now()的记录调用外部API成功置sent失败则累加重试次数并设置指数退避的next_retry_time。这套方案同样不完美它解决的是“外部调用在事务外可靠执行”的问题但引入了一个新的分布式场景外部API成功了可Worker更新status之前crash了消息会被重投。所以幂等是最后一道防线外部API必须接收一个唯一的request_id或业务单号来做去重。4.3 最终一致性与补偿状态机、对账、幂等本地消息表是手段而不是目的业务上真正需要的是最终一致性。所以我习惯在重构时把业务状态设计成一个小型状态机。以支付为例订单初始状态为“待支付”事务里只写这个状态。支付回调到达后事务里把状态改成“已支付”同时扣减库存如果外部调用失败达到上限把状态改成“支付失败”或者保持“待支付”并进入定时对账任务。对账任务的作用是每天定时拉取外部服务账单和本地订单状态比对凡是外部已扣款但本地还是“待支付”的进行补偿处理要么补全本地流程要么调用退款接口。幂等设计上我建议对每一个外部动作生成一个biz_id规则可以是业务表主键加动作类型例如order:12345:pay。外部服务拿这个ID去重重试多少次都不会产生重复扣款。4.4 重构前后对比用数据感受一下差异拿我经手过的一个订单模块来举例。重构前用户点“提交订单”代码依次执行插入订单、调支付API、扣库存、发短信全部包在一个事务里。线上真实数据支付API平均耗时800ms短信接口时不时飘到2秒整个事务最慢能拖到3秒多。重构后事务里只有一次insert提交耗时稳定在30ms左右事务提交后异步投递“已下单”消息由Worker负责调用支付API和短信网关。两者的对比可以看下面这张表维度事务内同步调API事务外异步投递事务持锁时间支付网关耗时短信耗时秒级本地insert耗时毫秒级连接占用长事务长期占用连接事务提交即释放连接外部服务抖动直接拖垮主流程仅消息积压可重试一致性保证看似一致实际回滚不了外部副作用状态机补偿最终一致系统复杂度低增加消息表和Worker但可控大多数业务系统并不需要跨服务强一致能真正做到强一致的手段也就是XA协议而且XA在微服务和跨网络环境下成本极高。对API调用这种天然不可控的操作接受最终一致性反而更容易保证整体可用性。5. 绕不开时的过渡方案超时、熔断与妥协姿势5.1 如果只能同步调用至少要做超时和熔断总有些老系统一时半会重构不了外部调用暂时必须同步出现在业务链路里。这种情况下至少两条底线必须守住超时和熔断。PHP这边无论是用原生curl还是Guzzle都必须显式设置连接超时和总超时。比如curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); curl_setopt($ch, CURLOPT_TIMEOUT, 3);。没有超时的HTTP调用一旦对方网络异常请求可能几分钟不返回一个事务就卡上几分钟。同时调用方需要做熔断降级。连续失败阈值到了就快速失败不再把请求打向外部的脆弱服务给数据库连接一条生路。这是短期止血手段不代表你可以一直这样写后续该重构还是要重构。5.2 重试绝不能塞进事务里有些人为了增强“可靠性”会在事务内写循环重试这等于给事故按了加速键。一次失败重试3次每次超时5秒事务持有时间直接变成15秒锁和连接都炸了。即便要同步重试也应该把重试逻辑放到事务提交之后通过标记来记录处理进度。有一个过渡阶段的妥协做法事务内只把订单标记成“待发送通知”commit完成后再在代码层面读取这个标记执行外部调用。这样事务不受外部延迟影响只是代码整体上还是同步链路。它能保证数据库连接不被外部调用占用但依然无法处理“通知服务失败”的情况所以必须配套重试队列和日志记录。5.3 别把内部RPC当成“自己人”就掉以轻心很多团队觉得内部服务之间的RPC不算外部API延迟低、可控性高放事务里问题不大。这是错觉。判断一个调用该不该放进事务标准不是“是不是我的服务”而是“是否经过网络传输、是否可能长时间等待”。内部RPC同样有网络开销和超时同样会让当前事务变成慢事务。更麻烦的是内部服务之间容易出现交叉调用A事务调B服务B服务又回查A服务的数据库一旦双方都在事务里等待对方就可能形成跨服务死锁排查难度比单库死锁高不少。所以我的建议是即使是内部RPC也尽量放在事务提交之后除非你确认它对延迟极度敏感且经过严格压测。6. 排查实录怎么从代码和数据库里揪出事务内外部调用6.1 用慢查询和processlist定位长事务代码里藏着外部API调用的事务最典型的表现就是“SQL执行很快事务整体很慢”。开启慢查询日志long_query_time设成1秒这时候你看到的可能不是某条SQL特别慢而是请求日志里一条接口从进入到响应隔了好几秒。我常用的定位手段是开另一个终端执行SHOW FULL PROCESSLIST;看连接的状态。如果一个连接显示Sleep但通过information_schema.innodb_trx查到它有一条未结束的事务且trx_started已经是好几秒甚至几十秒之前那基本可以断定事务在等某个事务外的东西。配合代码搜索在这个时间窗口里有没有HTTP调用、RPC调用几乎一抓一个准。6.2 死锁和锁等待的具体排查命令当业务已经报出锁等待超时或者死锁错误可以先看SHOW ENGINE INNODB STATUS\G输出里的LATEST DETECTED DEADLOCK段落它会告诉我们最近一次死锁涉及哪两张表、哪两条语句、哪个事务最后持有的锁。更系统的做法是查这三张系统表information_schema.innodb_trx查看所有正在运行的事务innodb_lock_waits查看谁在等谁的锁innodb_locks查看锁明细。我一般三步走先把trx_time超过10秒的事务挑出来再拿事务对应的SQL去代码里搜最后看事务代码段里有没有外部调用。三步走完问题基本水落石出。6.3 常见问题与排查思路速查现象排查思路常见根因接口偶发超时数据库CPU不高看processlist慢连接和innodb_trx长事务内调用外部API锁被长时间持有数据库连接池被打满监控连接占用曲线对应时间点的请求日志慢事务持有连接时间过长用户被扣款但订单未生成本地事务日志和外部API日志比对外部副作用不受数据库事务回滚控制定时任务重复发放积分检查外部API幂等键是否一致重试机制没有幂等保障一个短信网关故障拖垮整站追调用链看事务范围事务内包含通知类外部调用排查这些问题的核心思路是不要只看SQL本身。数据库往往只是受害者根因在应用层的代码设计里。下次再看到接口监控图上那些毛刺先别急着让DBA加索引去事务边界里看看有没有网络调用经常能省下一整晚的排查时间。最后分享一点我自己的体会把外部调用移出事务技术上其实不难难的是产品经理和业务方总想让你“一步到位”恨不得在一次请求里把支付、库存、积分、短信全同步搞定。我现在的习惯是每次需求评审先把事务边界画出来凡是会经过网络的调用一律放到事务外用状态机、重试队列和定时对账来兜底。刚开始大家都嫌麻烦直到某天外部通道一次抖动别人家的服务挂半天我们的系统稳如磐石团队里的质疑声立刻就消失了。另外还有一个小技巧事务commit之后再发通知catch里记录日志并且把一条失败消息写到本地队列表配合定时对账任务能解决90%因外部调用导致的不一致问题。希望这篇拆解能帮到正在被长事务和分布式一致性折磨的PHP开发者。