本地消息表是hyperf中实现最终一致性的最稳路径,因seata无php客户端且协程客户端不支持xa/at;需同库事务写入、定时扫描+行锁、下游严格幂等与乐观锁。

Hyperf 里没法用 Seata 做 XA 或 AT,但用本地消息表 + 携程高可用 Redis(或任意可靠 MQ)+ MySQL,能快速落地最终一致性。这不是妥协,而是当前 PHP 生态下最稳、最可控的路径。
为什么不能硬上 Seata
Seata 官方无 PHP 客户端,seata-php 仓库全是已归档/未维护项目,不支持 XA 协议,也不兼容 Seata 1.7+ 的通信模型。Hyperf 的协程 MySQL 客户端(hyperf/db)无法拦截 SQL、生成 undo_log、响应两阶段指令——这不是配置问题,是底层能力缺失。
实测现象包括:GlobalSession 卡在 Begin、BranchRegisterRequest 重复发送、Cancel 回调收不到。别浪费时间调参,直接换路。
本地消息表怎么建才不翻车
核心原则:消息写入必须和业务操作在同一个 MySQL 事务里,靠数据库原子性兜底。
-
outbox_message表必须和业务表同库(比如都在order_db),字段至少含:id、event_type(如"ORDER_CREATED")、payload(JSON 字符串)、status(0=pending,1=sent,2=confirmed)、created_at - 不要用
UUID或雪花 ID 当主键——并发插入时容易锁表;用自增id+status = 0索引即可 - 写入时用
DB::transaction()包裹,示例:DB::transaction(function () use ($order) { Order::create($order); OutboxMessage::create([ 'event_type' => 'ORDER_CREATED', 'payload' => json_encode(['order_id' => $order['id']]), 'status' => 0, ]); });
定时扫描器怎么写才可靠
用 hyperf/crontab 启一个每秒执行的协程任务,但必须加防重和限流,否则扫崩库。
- 每次只取最多 50 条:
OutboxMessage::where('status', 0)->limit(50)->get() - 用
forUpdate()加行锁,避免多进程重复捞同一批:->sharedLock()不够,得用->lockForUpdate() - 发 MQ 失败后,更新
status = 0并递增retry_count,超过 5 次写入告警表或丢进死信队列 - 成功发送后,不是直接
UPDATE status = 1,而是用WHERE id IN (...) AND status = 0更新——防止并发下被其他协程抢先处理
下游消费端必须守的三条铁律
最终一致性的“最终”,取决于下游是否真正幂等、是否扛住重试、是否能识别悬挂。
- 消费前先查 Redis 缓存:
GET tcc:order_created:{$orderId},存在就直接 return(防重复) - 业务逻辑执行完,再
SET tcc:order_created:{$orderId} 1 EX 3600(过期时间设为业务最长生命周期) - MySQL 写操作必须带乐观锁条件,比如:
UPDATE user_points SET points = points + ? WHERE user_id = ? AND version = ?,失败就重试或抛异常触发补偿 - 如果上游消息长期没收到
confirmed,靠独立对账服务比依赖 MQ ACK 更可控
最关键的细节常被忽略:跨服务调用时,X-TCC-XID 这类透传头要手动塞进 HTTP 请求头,Hyperf 的 Context 不会自动继承;Redis 键名里的业务标识(如 order_created)必须和事件类型严格一致,拼错一个字符就会导致幂等失效。











