协程事务的隔离级别与回滚策略
解读
国内高并发业务(电商秒杀、金融支付、SaaS 多租户)普遍把 Swoole、Swow、OpenSwoole 这类协程运行时引入 PHP-FPM 之外的新架构。
面试官问“协程事务的隔离级别与回滚策略”,并不是想听 MySQL 四大隔离级别的背诵,而是考察候选人能否把“协程调度”与“数据库事务”这两个不同层面的概念对齐:
- 协程切换会不会破坏事务的上下文?
- 连接池复用会不会导致脏读/幻读/不可重复读?
- 当某个协程发生异常时,如何只回滚自己的事务,而不影响其它并发协程?
- 在 PHP 语境下,有哪些工程化手段(PDO、Swoole\Coroutine\MySQL、ORM、链路追踪)可以保证隔离与回滚的确定性?
答不出“连接与事务绑定”、“连接池隔离”、“回滚锚点”这三点,基本会被判定为“只用过 Laravel 的 DB::transaction,没写过生产级协程代码”。
知识点
- 协程 vs 线程:PHP 协程是用户态调度,切换只发生在 IO 挂起点,不会抢占,但仍有“连接交叉”风险。
- 连接池模型:Swoole 默认做“连接-协程”绑定(同一连接同时只能被一个协程占用),解除绑定后连接放回池子;若手动 disable 该机制,就会出现事务串扰。
- 事务隔离级别:读未提交、读已提交、可重复读、串行化;InnoDB 默认可重复读,快照读与当前读行为差异。
- 回滚策略:
a. 异常回滚:捕获 \Throwable 后 rollback。
b. 超时回滚:Swoole\Coroutine\Channel + select/timeout 机制。
c. 嵌套回滚:savepoint 命名锚点,PHP 层用 “sp_协程ID_序号” 保证唯一。 - 分布式事务:Seata、TCC、Saga 在 PHP 侧无官方 SDK,需通过 HTTP/gRPC 调用 Java 侧事务协调器;面试提到即可,无需展开。
- 观测与灰度:开启 general_log 或 performance_schema 看 connection_id 与 coroutine_id 的映射;使用链路追踪(SkyWalking、Zipkin)标注 coroutine_id,方便复盘“事务交叉”事故。
答案
在 Swoole/ Swow 协程环境下,我把“隔离级别”与“回滚策略”拆成三步落地:
-
连接隔离:
开启swoole.use_shortname=on后,连接池默认把连接与当前协程 CID 绑定;同一 CID 内复用同一连接,不同 CID 互不干扰,从根源上杜绝“事务 A 未提交却被事务 B 读到”的场景。
如果关闭绑定或自己实现池子,必须在取出连接时用spl_object_id($conn) . '_' . Coroutine::getCid()做 key 级缓存,确保“一个协程一个连接”。 -
隔离级别设置:
在获得连接后第一条语句执行SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;业务对账型接口可降到 READ COMMITTED 减少间隙锁;
若业务要求“绝对无幻读”,可临时改为 SERIALIZABLE,但评估好性能损耗。 -
回滚策略:
a. 单层事务:用 try/catch 包裹,捕获任意异常立即 rollback,最后 commit。
b. 嵌套事务:用 savepoint,命名规则sp_CID_{$cid}_seq,回滚时ROLLBACK TO SAVEPOINT sp_CID_1234_2,保证只回滚当前协程的局部状态。
c. 超时守护:启动一个go(function () use ($ch, $conn, $timeout) { if ($ch->pop($timeout) === false) $conn->rollback(); }),业务正常结束时向 channel push 信号,防止“协程泄漏”导致长事务。
生产验证:
- 压测 2 k 并发协程,连接池 50,最大事务时长 200 ms,无交叉回滚。
- 通过 SkyWalking 发现异常回滚率 0.3%,均属于业务校验失败,与隔离级别无关。
拓展思考
- 当协程数 >> 连接池大小时,连接等待会成为新瓶颈;可以引入“事务优先级队列”+“协程让渡”策略,把非关键读事务延迟到写低谷,再评估隔离级别能否降到 READ UNCOMMITTED。
- 在读写分离架构下,协程 A 开启事务后,读请求仍可能被负载均衡到只读节点,导致快照读与当前读版本不一致;需要在连接池层面打标“in_transaction”,强制走主库。
- PHP 8.2 的 Fiber 与 Swoole 协程共存时,Fiber 切换不会触发 Swoole 调度器,若混用可能打破“连接-协程”绑定;可以通过自定义
Fiber::suspend()钩子,把 CID 与 Fiber ID 也做映射,确保事务上下文正确。