从队列到消息总线:PHP 项目异步化改造的取舍复盘
异步化不是银弹,但它是 PHP 项目走向高并发、高可用的必经之路。最近我们团队刚完成了一次从简单队列到消息总线的架构升级,整个过程踩了不少坑,也做了很多取舍。今天把这些复盘写下来,希望能给正在做类似改造的兄弟们一些参考。
痛点:为什么我们最初选择了 Redis 队列
项目早期的异步需求很简单:注册后发邮件、下单后推送通知、生成报表。这些任务的实时性要求不高,量也不大。最初我们用 Redis 的 LPUSH/BRPOP 做了一个简单的 FIFO 队列,配几个常驻的 PHP worker 消费,逻辑清晰,代码量不到一百行,上线后跑了一年多都很稳定。
在业务量小的时候,这个方案是性价比最高的。它没有引入新的依赖,也没有复杂的 ACK 机制,出问题直接看 Redis 日志就行。我当时甚至觉得“队列也就这样了”。
转折:当业务开始拖垮架构
转折发生在一次大促活动之后。活动当天订单量是平时的 30 倍,我们的 worker 处理不过来,队列越长越深,最终导致部分订单的通知延迟了将近两个小时。但这只是表象,真正致命的是业务方提出了一个需求:所有订单操作都要记录审计日志,并且要保证和订单状态一致。
用 Redis 队列做审计日志就傻眼了——worker 消费时如果 Redis 崩溃,消息就丢了,日志就缺了一块;如果 worker 处理一半挂了,消息已经 POP 出去,ACK 机制根本不存在,消息要么丢要么重复。对于审计这种强一致性场景,这没法接受。
更麻烦的是,业务方开始要求“延迟队列”(下单后 30 分钟未支付自动关闭)和“优先级队列”(VIP 用户的订单优先处理)。这些特性用原生 Redis 队列实现,要么靠 ZSET 模拟,要么搞多个队列轮询,代码复杂度剧增,逻辑也开始散落到各个业务模块里。
决策:消息总线不是升级,是换思维
在调研了 RabbitMQ、Kafka、RocketMQ 之后,我们最终选定了 RabbitMQ 作为消息总线。选它的核心原因就一个:在保证消息不丢失的前提下,提供了足够丰富的消息模型——Direct、Topic、Fanout、Headers 四种交换机满足不同的路由需求;延迟队列可以通过死信交换机实现;优先级队列原生支持。
但真正让我们觉得“之前白折腾”的,是对架构思维的转变。消息总线不只是“把任务放到队列里消费”,而是一种事件驱动架构。订单创建后往交换机发一个 `order.created` 事件,邮件服务、审计服务、积分服务各自订阅自己感兴趣的路由键。各服务之间彻底解耦,新增一个下游消费者不需要改动上游代码,只需要在 RabbitMQ 里加一个绑定。
取舍:没有完美方案,只有平衡点
这个改造过程并非一路高歌,很多取舍是反复纠结后才定下来的。
取舍一:要不要用事务消息? 订单入库和发消息必须保证原子性,否则存在“订单创建了但消息没发出去”的情况。最理想是 RocketMQ 的事务消息,但我们不希望为此引入新的中间件。最终采用了“本地消息表 + 定时补偿”的方案:订单创建后先写本地消息表,然后发消息,消费端 ACK 之后删除本地消息。如果消息发送失败,定时任务扫描表中未确认的记录重新发送。这个方案的代价是增加了约 20% 的代码量,但换来的是无中间件锁定的可靠投递。
取舍二:要不要全部异步化? 我们曾经尝试把读操作也走事件驱动,结果发现查单详情时数据不一致的概率大大增加。最终只对写操作、非核心链路做了异步化,读路径保持同步。异步化改造不是越彻底越好,而是要让用户感知不到延迟才行。
取舍三:牺牲运维复杂度换扩展性。 Redis 队列零运维,RabbitMQ 需要节点监控、连接池管理、消费者心跳处理。在活动大促时需要手动扩容消费者,还要配置合理的 prefetch 数量避免消息分发不均。这些都是成本,但对于一个正在快速增长的业务来说,这笔投入是值得的。
最后的反思
如果把这次改造看成一次复盘,我觉得最重要的收获是:不要为了异步而异步,也不要因为怕复杂就永远停留在最简陋的方案。 当业务出现真正的痛点时,该升级就升级,但要带着清晰的取舍去升级,而不是盲目堆砌新技术。
消息总线让我们走出了 Redis 队列的瓶颈,但它也带来了新的问题需要持续解决:消息幂等性、消费重试策略、死信队列的监控告警。架构没有终点,只有不断的权衡和演进。希望这篇复盘能让大家少踩一些坑。
年卡会员