服务间解耦这块之前一直是内部自己搭的 RabbitMQ,迁到 GCP 之后顺势换成了 Pub/Sub,图的是不用自己运维,扩缩容、高可用这些都交给平台。用下来整体省心,但也有几个跟传统消息队列思路不太一样的地方,第一次接触容易想当然踩坑。
最先踩的是消息顺序问题。之前用 RabbitMQ 习惯了单队列内消息基本是顺序处理的,迁过来之后有个业务逻辑依赖”先创建订单事件、再更新订单事件”这个顺序,结果线上偶发看到更新事件先于创建事件被处理,业务状态直接错乱。查文档才知道 Pub/Sub 默认不保证消息顺序,多个订阅者并发拉取的场景下顺序完全没保证。真需要顺序的话得用 ordering key,同一个 key 的消息才会按发布顺序投递:
1 | from google.cloud import pubsub_v1 |
订阅端也得开启 enable_message_ordering,而且用了 ordering key 之后单个 key 的吞吐是有代价的(同一个 key 的消息会按顺序一条条处理,没法并发消费),所以 key 的粒度要选好,粒度太粗(比如全局一个 key)等于把整个系统的并发度打回单线程。
第二个坑是 ack 超时和重复消费。Pub/Sub 是 at-least-once 投递,这个文档写得很清楚,但真正踩到重复消费的时候还是有点意外。处理逻辑跑得比较慢的一个订阅者,默认 ack deadline 是 10 秒,一旦处理时间超过这个窗口,Pub/Sub 会认为这条消息没被成功处理,重新投递给别的订阅者,于是同一条消息被处理了两次,数据库里插入了两条重复记录:
1 | def callback(message): |
解法一方面是把 ack deadline 调大或者用 modify_ack_deadline 主动续期,另一方面更根本的是处理逻辑本身要做成幂等的,不能假设消息只会被处理一次。我们后来是在处理逻辑里加了个基于消息唯一 id 的去重表,处理前先检查这个 id 是不是已经处理过:
1 | def callback(message): |
这个幂等设计不是 Pub/Sub 特有的问题,任何 at-least-once 的消息系统理论上都得这么处理,只是之前用 RabbitMQ 的时候流量小、重复投递的概率低,一直心存侥幸没认真做,换了系统之后这个隐患才真正暴露出来。
死信队列(dead letter topic)这个配置建议一开始就设好,没配的时候有条消息处理逻辑一直报错,Pub/Sub 会不断重新投递重试,日志被刷屏刷得没法看,也搞不清楚到底是消息本身有问题还是消费者代码有 bug。配上死信队列之后,超过最大重试次数的消息会被转到专门的 topic,主流程的日志清净了,出问题的消息也不会丢,单独找时间处理:
1 | gcloud pubsub subscriptions create my-subscription \ |
费用这块跟 BigQuery 类似,也是用起来无感、账单容易让人意外的类型。按消息量和数据量计费,量小的时候完全感觉不到,但是有个内部服务不小心把一个高频心跳信号也发布到了 Pub/Sub 上,一秒好几条,量起来之后才发现这个 topic 贡献了不小一部分账单,其实这种数据完全不需要走消息队列,内存里做个计数器就够了。用 Pub/Sub 之前多想一句这条消息是不是真的需要”发布订阅+持久化+多消费者”这套重量级的语义,不是所有内部信号都值得上消息队列。
整体感觉 Pub/Sub 作为托管服务确实省了不少运维精力,不用自己操心节点扩容、集群健康这些事,但它的”默认行为”(无序、at-least-once)跟很多人从传统消息队列带过来的直觉不太一样,接手一个新的消息系统之前,顺序保证和投递语义这两点值得先花十分钟确认清楚,比出了问题再回头查文档划算。