服务间解耦这块之前一直是内部自己搭的 RabbitMQ,迁到 GCP 之后顺势换成了 Pub/Sub,图的是不用自己运维,扩缩容、高可用这些都交给平台。用下来整体省心,但也有几个跟传统消息队列思路不太一样的地方,第一次接触容易想当然踩坑。

最先踩的是消息顺序问题。之前用 RabbitMQ 习惯了单队列内消息基本是顺序处理的,迁过来之后有个业务逻辑依赖”先创建订单事件、再更新订单事件”这个顺序,结果线上偶发看到更新事件先于创建事件被处理,业务状态直接错乱。查文档才知道 Pub/Sub 默认不保证消息顺序,多个订阅者并发拉取的场景下顺序完全没保证。真需要顺序的话得用 ordering key,同一个 key 的消息才会按发布顺序投递:

1
2
3
4
5
6
7
8
9
10
from google.cloud import pubsub_v1

publisher = pubsub_v1.PublisherClient()
topic_path = publisher.topic_path(project_id, "order-events")

publisher.publish(
topic_path,
data=event_data,
ordering_key=f"order-{order_id}", # 同一订单的事件保证顺序
)

订阅端也得开启 enable_message_ordering,而且用了 ordering key 之后单个 key 的吞吐是有代价的(同一个 key 的消息会按顺序一条条处理,没法并发消费),所以 key 的粒度要选好,粒度太粗(比如全局一个 key)等于把整个系统的并发度打回单线程。

第二个坑是 ack 超时和重复消费。Pub/Sub 是 at-least-once 投递,这个文档写得很清楚,但真正踩到重复消费的时候还是有点意外。处理逻辑跑得比较慢的一个订阅者,默认 ack deadline 是 10 秒,一旦处理时间超过这个窗口,Pub/Sub 会认为这条消息没被成功处理,重新投递给别的订阅者,于是同一条消息被处理了两次,数据库里插入了两条重复记录:

1
2
3
def callback(message):
process_order(message.data) # 处理耗时 15 秒,超过默认 ack deadline
message.ack()

解法一方面是把 ack deadline 调大或者用 modify_ack_deadline 主动续期,另一方面更根本的是处理逻辑本身要做成幂等的,不能假设消息只会被处理一次。我们后来是在处理逻辑里加了个基于消息唯一 id 的去重表,处理前先检查这个 id 是不是已经处理过:

1
2
3
4
5
6
7
8
def callback(message):
msg_id = message.message_id
if is_already_processed(msg_id):
message.ack()
return
process_order(message.data)
mark_as_processed(msg_id)
message.ack()

这个幂等设计不是 Pub/Sub 特有的问题,任何 at-least-once 的消息系统理论上都得这么处理,只是之前用 RabbitMQ 的时候流量小、重复投递的概率低,一直心存侥幸没认真做,换了系统之后这个隐患才真正暴露出来。

死信队列(dead letter topic)这个配置建议一开始就设好,没配的时候有条消息处理逻辑一直报错,Pub/Sub 会不断重新投递重试,日志被刷屏刷得没法看,也搞不清楚到底是消息本身有问题还是消费者代码有 bug。配上死信队列之后,超过最大重试次数的消息会被转到专门的 topic,主流程的日志清净了,出问题的消息也不会丢,单独找时间处理:

1
2
3
4
gcloud pubsub subscriptions create my-subscription \
--topic=my-topic \
--dead-letter-topic=my-topic-dlq \
--max-delivery-attempts=5

费用这块跟 BigQuery 类似,也是用起来无感、账单容易让人意外的类型。按消息量和数据量计费,量小的时候完全感觉不到,但是有个内部服务不小心把一个高频心跳信号也发布到了 Pub/Sub 上,一秒好几条,量起来之后才发现这个 topic 贡献了不小一部分账单,其实这种数据完全不需要走消息队列,内存里做个计数器就够了。用 Pub/Sub 之前多想一句这条消息是不是真的需要”发布订阅+持久化+多消费者”这套重量级的语义,不是所有内部信号都值得上消息队列。

整体感觉 Pub/Sub 作为托管服务确实省了不少运维精力,不用自己操心节点扩容、集群健康这些事,但它的”默认行为”(无序、at-least-once)跟很多人从传统消息队列带过来的直觉不太一样,接手一个新的消息系统之前,顺序保证和投递语义这两点值得先花十分钟确认清楚,比出了问题再回头查文档划算。