消息队列:从同步到异步的鸿沟

36 阅读 2843 字 · 约 10 分钟

消息队列:从同步到异步的鸿沟

同步调用:你调我,我调他,他调数据库。一条链路串到底,任何一个环节卡住,整条链路卡住。

消息队列把这条链路切断。发送方把消息丢进队列就走,接收方按自己的节奏取。解耦了,但也丢了三样东西:消息可能丢、可能重复、顺序可能乱。

这三样东西,是分布式系统从同步走向异步必须还的代价。

为什么要从同步到异步

同步调用的链路有多脆弱,看一个场景。

用户下单:订单服务调库存服务扣库存,库存服务调支付服务冻结余额,支付服务调银行接口扣款。四个服务串在一起,每个 200 毫秒,总耗时 800 毫秒。银行接口偶尔慢,从 200 毫秒变成 2 秒,整条链路从 800 毫秒变成 2.6 秒。用户等不了,超时了。

更狠的是级联效应。银行接口慢,支付服务的线程池被占满,支付服务不可用。库存服务调支付服务超时,库存服务的线程池也被占满,库存服务不可用。订单服务调库存服务超时,订单服务也不可用了。一个下游慢,整条链路雪崩。

级联雪崩:一个下游慢,整条链路倒

消息队列解决的是这个问题。订单服务不直接调库存服务,而是发一条"订单已创建"的消息到队列,立刻返回给用户。库存服务订阅这个消息,按自己的节奏消费。银行接口慢?库存服务慢慢处理,消息在队列里排队等着,不影响订单服务,不影响用户。

发送方和接收方解耦了。发送方只管发,接收方只管收。谁慢了不影响谁。

但代价来了。

消息丢失:发出去不等于收到了

同步调用,调用方直接拿到响应——成功还是失败,当场知道。消息队列不一样,发送方把消息丢进队列,队列收到没有?接收方处理了没有?发送方不知道。

消息在三个环节都可能丢。

发送方到队列:网络断了。 发送方调队列的 API 发消息,网络超时。消息发出去了吗?可能发出去了但确认包丢了,也可能根本没发出去。发送方不知道,重发还是放弃?

队列本身:机器挂了。 消息存在队列服务器的内存里,服务器宕机,消息没了。如果开了持久化,消息写进了磁盘,但磁盘也可能坏。如果做了主从复制,主挂了从还没同步过来,这部分消息也没了。

队列到接收方:接收方拿了消息但处理前挂了。 接收方从队列取走消息,处理到一半机器重启了。消息从队列里删了(已消费),但业务没处理完。消息丢了。

怎么解决?三个环节分别应对。

发送方到队列:用确认机制。发送方发消息后等队列的 ACK,没收到就重发。但重发引入了重复问题——后面说。

队列本身:开持久化 + 主从复制。消息先写磁盘再返回 ACK,主从同步后再告诉发送方"收到了"。代价是延迟——每条消息多几毫秒。

队列到接收方:用手动确认。接收方取走消息后不立即从队列删除,处理完了再发 ACK 给队列。处理失败或超时不发 ACK,队列把消息重新投递给另一个消费者。这叫 at-least-once 语义——至少处理一次,可能多处理几次。

消息重复:至少一次不等于恰好一次

手动确认解决了消息丢失,但引入了新问题:消息重复。

接收方处理完了消息,发 ACK 之前网络断了。队列没收到 ACK,认为消息没被处理,重新投递。接收方重启后又收到同一条消息,又处理了一遍。

重复消费的后果可大可小。如果消息是"更新用户昵称",重复处理十次结果一样,无所谓。如果消息是"扣款 100 元",重复处理一次用户就多扣了 100 元,出事了。

核心区别在于操作是否幂等。幂等操作重复执行结果不变,非幂等操作重复执行结果会叠加。

解决重复,两条路。

让消费端幂等。 扣款操作天然不幂等,但可以加一层:每条消息带全局唯一 ID,消费端处理前先查这个 ID 处理过没有。处理过就跳过,没处理过就处理并记录 ID。这样即使消息重复投递,也只会处理一次。

让队列保证恰好一次。 有些队列支持事务消息或去重表,在队列层面保证不重复。但代价是性能——每条消息多一次查重开销,吞吐量下降。

业界主流选择是 at-least-once + 消费端幂等。不追求队列层面的恰好一次,而是在消费端自己保证重复无害。原因很简单:网络不可靠,队列层面的恰好一次要么做不到,要么代价太大。与其在队列层面较劲,不如在消费端老老实实做幂等。

消息顺序:先发的未必先到

同步调用天然有序:你先调 A 再调 B,A 一定先执行完再执行 B。消息队列不一定。

发送方先发消息 A 再发消息 B,但接收方可能先收到 B 再收到 A。为什么?

多消费者并行。 队列有多个消费者实例。消息 A 被消费者 1 拿走,消息 B 被消费者 2 拿走。消费者 1 处理慢,消费者 2 先处理完了。B 在 A 前面执行了。

重投递插队。 消息 A 处理失败,被重新放回队列。这时候消息 B 已经被另一个消费者取走处理了。A 排到了 B 后面。

多分区。 消息按 key 分到不同分区,不同分区并行消费,跨分区没有顺序保证。

顺序乱了会怎样?看场景。

"创建订单"消息和"取消订单"消息,如果取消先于创建被处理,逻辑就错了。"更新余额 100"和"更新余额 200",如果 200 先处理再处理 100,最终余额是 100 而不是 200。

怎么解决?

单分区单消费者。 需要保序的消息发到同一个分区,只有一个消费者消费。顺序保证了,但吞吐量下来了——这个分区成了瓶颈。

业务层排序。 消息带时间戳或序列号,消费端收到后按序列号排序处理。乱序到达但有序处理。代价是消费端要缓存后续消息等前面的,内存和延迟都增加。

接受乱序,设计上容忍。 很多场景不需要全局有序,只需要同一实体的消息有序。比如同一个订单的消息保序,不同订单之间无所谓。按订单 ID 分区,同一订单的消息进同一分区,不同订单并行消费。这是 Kafka 的 partition key 思路——不是保证全局有序,是保证分区内有序。

一个现实场景

电商大促,订单峰值每秒 10 万单。

同步链路:订单服务调库存、调支付、调物流,三个服务串行,每个 100 毫秒,总耗时 300 毫秒。10 万单并发,每个服务要扛住 10 万 QPS。任何一个服务扛不住,全链路雪崩。

异步链路:订单服务发消息到队列,立刻返回。库存、支付、物流各自消费消息,按自己的速度处理。订单服务只需要扛住"发消息"的 QPS,远低于"调三个服务"的 QPS。下游服务按自己的容量消费,积压了消息在队列里排队,不会压垮上游。

代价是什么?用户下单后不能立刻知道"库存够不够""支付成没成功"。得轮询或者等通知。体验上从"同步等结果"变成了"异步等通知"。

这个体验落差,就是从同步到异步要权衡的东西。不是所有场景都适合异步,但高并发场景下,同步链路的脆弱性远大于异步的体验损失。

三大问题的关系

丢失、重复、顺序,不是三个独立的问题,是一个三角。

不可能三角:不丢、不重、有序、高吞吐,无法同时满足

防丢失要重试,重试导致重复。防重复要去重,去重需要序列号,序列号依赖顺序。保序要单消费者,单消费者降低吞吐,吞吐不够又要加消费者,加了消费者又破坏顺序。

你不可能同时要"不丢、不重、有序、高吞吐"。这是分布式系统的 CAP 一样的取舍——不是选不选的问题,是放弃哪个的问题。

主流选择:at-least-once(接受重复)+ 消费端幂等(解决重复)+ 分区内有序(局部保序)+ 多分区并行(保证吞吐)。放弃全局有序和恰好一次,换取高吞吐和高可用。

一句话

消息队列用异步解耦换来了高吞吐和高可用,代价是丢失、重复、顺序三个问题。不存在三全其美的方案,只有想清楚放弃哪个。