消息队列:异步通信的核心设计
在分布式系统中,服务之间最直接的通信方式是同步调用:服务 A 向服务 B 发送请求,等待 B 处理完成并返回结果。这种方式简单直观,但也带来了紧密的耦合:B 处理慢,A 就要一直等;B 宕机,A 的请求就会失败;流量突增时,B 的压力会直接传导到整条调用链上。
消息队列(Message Queue)提供了另一种通信方式:发送方将消息写入队列后即可返回,接收方在合适的时候从队列中取出消息处理。发送方与接收方在时间上和空间上都被解耦,彼此不需要同时在线,也不需要知道对方的存在。
本文介绍消息队列的作用、两种主要的消息模型、消息投递语义、顺序性保证、基于日志的消息系统设计,以及在实践中常见的问题和应对方法。
一、消息队列的作用
异步处理
许多业务操作包含一个必须立即完成的核心步骤,以及一系列可以稍后完成的附属步骤。以用户注册为例,核心步骤是在数据库中创建账户,而发送欢迎邮件、初始化推荐数据、记录统计信息等操作,并不需要让用户等待。
将这些附属操作通过消息队列异步处理后,注册接口只需完成核心步骤并发送一条消息,就可以立即返回,响应时间大幅缩短。
系统解耦
在同步调用模式下,上游服务需要知道所有下游服务的地址和接口。每增加一个需要响应某事件的下游服务,就要修改上游服务的代码。
引入消息队列后,上游服务只需将事件发布到队列中,不关心有哪些服务在消费。新的下游服务只需订阅相应的消息即可,上游完全不需要改动。各服务可以独立开发、部署和扩展。
削峰填谷
许多业务的流量存在明显的波峰波谷,例如电商促销、票务开售、整点抢购。如果后端按峰值流量配置资源,在大部分时间里会严重浪费;如果按平均流量配置,峰值时又会被压垮。
消息队列可以作为缓冲区:流量高峰时,请求被快速写入队列;后端以自身能够承受的稳定速率从队列中取出处理。高峰过后,积压的消息逐渐被消化。这样,后端只需按略高于平均水平的能力配置资源即可。
故障隔离
在同步调用链中,下游服务的故障会直接导致上游请求失败。借助消息队列,下游服务短暂不可用时,消息会在队列中暂存,待服务恢复后继续处理,上游服务完全不受影响。
数据分发与集成
同一份数据往往需要被多个系统使用,例如一条订单数据可能需要同步到搜索引擎、数据仓库、缓存和风控系统。消息队列可以作为数据分发的中枢,让各系统按照各自的节奏订阅和消费,而不必由数据源逐一推送。
二、两种消息模型
点对点模型
在点对点模型(Point-to-Point)中,每条消息只会被一个消费者处理。
- 生产者将消息发送到队列;
- 多个消费者可以同时从同一个队列中获取消息,但每条消息只会被分配给其中一个;
- 消息被成功处理后,从队列中删除。
这种模型适合任务分发的场景:将大量独立的任务分摊给多个工作进程并行处理。增加消费者的数量,就能提高整体的处理能力。
发布订阅模型
在发布订阅模型(Publish-Subscribe)中,每条消息会被所有订阅者接收。
- 生产者将消息发布到一个主题(Topic);
- 多个订阅者可以订阅同一个主题,每个订阅者都会收到该主题下的全部消息;
- 订阅者之间互不影响。
这种模型适合事件广播的场景:一个事件发生后,需要通知多个对它感兴趣的系统。
消费者组
许多现代消息系统将两种模型结合起来,引入了消费者组(Consumer Group)的概念:
- 不同的消费者组之间是发布订阅关系,每个组都会收到主题的全部消息;
- 同一个消费者组内部的多个消费者之间是点对点关系,每条消息只会被组内的一个消费者处理。
这样,一个主题可以同时服务于多个不同的业务系统,每个系统作为一个消费者组,独立地消费全部消息;而每个系统内部又可以通过增加消费者实例来横向扩展处理能力。
三、两类消息系统的架构
消息系统在内部实现上大致可以分为两类,它们在设计理念上有明显的差异。
传统消息代理
传统消息代理以队列为核心抽象。
- 消息被投递给消费者,消费者处理完成后发送确认;
- 收到确认后,代理将消息从队列中删除;
- 代理负责跟踪每条消息的状态:未投递、已投递待确认、已确认;
- 通常支持丰富的路由规则,可以根据消息的属性将其分发到不同的队列;
- 支持消息优先级、延迟投递、单条消息的过期时间等特性。
这类系统的特点是以消息为单位进行管理,适合任务队列、工作流调度等需要灵活路由和精细控制单条消息的场景。但由于需要逐条跟踪消息状态,在极高吞吐量下会面临性能瓶颈;消息被消费后即删除,无法重新读取历史消息。
基于日志的消息系统
基于日志的消息系统以仅追加日志(Append-Only Log)为核心抽象。
- 主题被划分为多个分区,每个分区是一个有序的、只能在末尾追加的日志;
- 每条消息在分区中有一个单调递增的序号,称为偏移量(Offset);
- 消息被消费后不会删除,而是按照预设的保留策略,例如保留七天或保留一定的数据量,统一过期清理;
- 消费者自己维护消费进度,即已经处理到了哪个偏移量;
- 消费者顺序读取分区中的消息。
这种设计带来了一系列重要的特性:
- 高吞吐:写入是对日志文件的顺序追加,读取也是顺序读,能够充分利用磁盘的顺序 I/O 性能和操作系统的页缓存,单个集群每秒可以处理数百万条消息;
- 消息可重放:由于消息不会因消费而删除,消费者可以将偏移量回退到过去的某个位置,重新处理历史消息。这在修复程序缺陷后重新计算数据、新系统上线需要导入历史数据等场景中非常有用;
- 代理端状态简单:代理不需要跟踪每条消息是否被确认,只需为每个消费者组记录一个偏移量;
- 多个消费者组独立消费:每个组维护自己的偏移量,互不干扰,增加一个新的消费者组几乎不会给代理带来额外负担。
基于日志的设计也有其局限:
- 并行度受分区数限制:同一个消费者组内,一个分区在同一时刻只能被一个消费者处理。如果消费者数量超过分区数量,多余的消费者将处于空闲状态;
- 队头阻塞:分区内的消息必须按顺序处理,如果某一条消息处理得很慢或反复失败,会阻塞其后所有消息的处理;
- 单条消息的精细控制较弱:很难对单条消息单独确认、单独延迟重试或设置优先级。
基于日志的消息系统特别适合事件流、日志收集、数据管道、流式计算等需要高吞吐、需要重放、需要被多个系统消费的场景。
四、分区与扩展
分区的作用
在基于日志的消息系统中,分区是实现水平扩展的关键:
- 一个主题的多个分区可以分布在集群中不同的代理节点上,写入和读取的负载被分散到多台机器;
- 同一消费者组中的多个消费者,可以分别负责不同的分区,并行处理。
消息如何分配到分区
生产者发送消息时,需要决定将消息写入哪个分区。常见的策略有:
- 指定分区:生产者直接指定目标分区;
- 按键哈希:消息附带一个键,例如用户编号或订单编号,根据键的哈希值选择分区。相同键的消息总是进入同一个分区;
- 轮询或随机:没有键的消息,在各分区之间轮流或随机分配,以实现负载均衡。
分区数量的选择
分区数量决定了一个主题的最大并行度,但并不是越多越好:
- 分区太少,消费能力无法通过增加消费者来扩展;
- 分区太多,每个分区都对应着文件句柄、内存缓冲区和副本同步的开销,代理的元数据管理负担加重,故障恢复时间变长;
- 增加分区数量会改变按键哈希的映射结果,原本进入同一分区的键可能被分到不同分区,破坏基于键的顺序保证。
因此,分区数量通常需要根据预期的吞吐量和消费者处理能力提前规划,并在一开始就留有一定余量。
消费者再平衡
当消费者组中有消费者加入、离开或崩溃时,分区需要在剩余的消费者之间重新分配,这个过程称为再平衡(Rebalance)。
再平衡期间,相关分区的消费可能暂停。在早期的实现中,再平衡需要所有消费者停止消费、重新分配全部分区,可能导致较长的停顿。较新的实现采用了增量式再平衡,只迁移需要变动的分区,未受影响的分区可以继续消费,大大减少了停顿时间。
为了避免不必要的再平衡,需要合理设置消费者的心跳间隔和会话超时时间:超时时间设置过短,消费者在进行一次较长的垃圾回收或处理一批较慢的消息时,就可能被误判为宕机。
五、消息投递语义
消息系统是分布式系统,网络可能丢包、节点可能宕机、确认可能丢失。在这样的环境下,一条消息究竟会被处理几次,是使用消息队列时必须明确的问题。通常有三种投递语义。
最多一次
最多一次(At-Most-Once):消息可能丢失,但不会被重复处理。
在消费端,实现方式是:先提交消费进度,再处理消息。如果在处理过程中消费者崩溃,由于进度已经提交,重启后会从下一条消息开始,当前这条消息就丢失了。
在生产端,生产者发送消息后不等待确认,或者不进行重试,也属于最多一次。
这种语义适合可以容忍少量丢失的场景,例如采集用于统计的监控指标、非关键的日志。
至少一次
至少一次(At-Least-Once):消息不会丢失,但可能被重复处理。
在消费端,实现方式是:先处理消息,再提交消费进度。如果消息处理完成后、进度提交前消费者崩溃,重启后会再次读取这条消息并重复处理。
在生产端,生产者在没有收到代理确认时会重试发送。但如果实际上代理已经写入了消息,只是确认在网络中丢失了,重试就会导致消息被写入两次。
至少一次是大多数场景下的默认选择:宁可重复,不可丢失。重复处理的问题,通常交由消费端的幂等设计来解决。
恰好一次
恰好一次(Exactly-Once):每条消息被处理且仅被处理一次。
这是最理想的语义,但在分布式系统中实现起来非常困难。需要注意的是,"恰好一次"通常指的是处理效果上的恰好一次,而非消息在物理上只被投递一次。在存在网络故障的情况下,消息的重复投递几乎无法避免,系统能做到的是让重复投递不产生重复的效果。
一些消息系统通过以下机制,在系统内部提供恰好一次的保证:
- 幂等生产者:代理为每个生产者分配一个标识,生产者为发送的每条消息附带一个递增的序号。代理据此识别并丢弃因重试而产生的重复消息;
- 事务:生产者可以将写入多个分区的消息、以及消费进度的提交,放在一个事务中原子地完成。在"从一个主题读取、处理后写入另一个主题"的流式处理场景中,读取进度的提交和结果的写入要么同时生效,要么同时不生效;
- 事务隔离:消费者可以设置为只读取已提交事务中的消息,跳过被中止的事务所写入的消息。
但这种保证的范围是有限的:它只在消息系统内部的读写之间成立。一旦处理过程涉及外部系统,例如写入数据库、调用第三方接口、发送邮件,消息系统的事务就无法覆盖这些操作。此时,端到端的恰好一次效果,仍然需要依赖外部操作的幂等性或其他协调机制来实现。
六、幂等消费
由于至少一次投递是最常见的选择,消费端的幂等性成为使用消息队列时最重要的设计原则之一:同一条消息被处理多次,产生的效果应当与处理一次相同。
天然幂等的操作
有些操作本身就是幂等的:
- 将某个字段设置为一个固定值,例如将订单状态设为"已支付";
- 按主键删除一条记录;
- 以主键为条件插入,主键冲突时忽略。
而另一些操作则不是幂等的:
- 余额加上一个数值;
- 插入一条没有唯一约束的记录;
- 发送一封邮件或一条短信。
实现幂等的常见方法
唯一标识去重。 为每条消息分配一个全局唯一的标识。消费者处理消息前,先检查该标识是否已经处理过;处理完成后,记录该标识。检查和记录需要与业务操作在同一个事务中完成,否则在两者之间发生故障,仍然可能导致重复或遗漏。
利用数据库约束。 将消息标识或业务上的唯一键作为数据库表的唯一约束。重复的消息在插入时会因违反约束而失败,消费者捕获该错误并视为成功即可。
状态机校验。 对于有明确状态流转的业务,在更新时附加状态条件。例如,只有当订单当前状态为"待支付"时,才将其更新为"已支付"。重复的消息到达时,订单已不再是"待支付"状态,更新不会生效。
版本号控制。 在记录上维护一个版本号,消息中携带期望的版本号,只有版本号匹配时才执行更新,并同时递增版本号。
将非幂等操作转为幂等。 例如,将"余额加 100"改为"根据流水号为账户添加一笔 100 元的入账记录",流水号唯一,重复插入会被拒绝,余额由入账记录汇总得出。
七、消息顺序
顺序的含义
许多业务对消息的处理顺序有要求。例如,同一个订单的"创建"“支付”"发货"消息,必须按顺序处理;如果"发货"先于"支付"被处理,就会出现逻辑错误。
但"顺序"的范围需要明确:
- 全局顺序:所有消息都严格按照发送顺序处理;
- 局部顺序:只要求具有相同业务键的消息按顺序处理,不同业务键之间的消息可以任意交错。
全局顺序要求整个系统只能有一个分区、一个消费者,完全放弃了并行能力,在实际系统中很少使用。绝大多数业务只需要局部顺序:同一个订单的消息有序,不同订单之间无需有序。
实现局部顺序
在基于日志的消息系统中,实现局部顺序的标准做法是:
- 生产端:将需要保持顺序的消息使用同一个键发送,确保它们进入同一个分区;
- 代理端:分区本身是有序的日志,消息按写入顺序存储;
- 消费端:每个分区由一个消费者顺序处理。
破坏顺序的常见原因
即使采用了上述方法,以下情况仍可能导致顺序被破坏:
生产端重试导致乱序。 生产者为了提高吞吐量,可能同时有多个批次的消息正在发送中。如果前一个批次发送失败、后一个批次发送成功,前一个批次重试后就会排在后一个批次之后。启用幂等生产者,或限制同时在途的请求数量为一个,可以避免这一问题。
消费端并行处理。 为了提高处理速度,消费者可能从一个分区拉取一批消息后,交给线程池并行处理。这会使同一分区中的消息乱序执行。如果需要保持顺序,可以在消费者内部按业务键再次分组,保证同一个键的消息由同一个线程依次处理。
分区数量变化。 增加分区后,按键哈希的结果发生变化,同一个键的新消息可能进入另一个分区,与旧分区中尚未处理完的消息之间失去顺序保证。
失败消息的重试。 如果某条消息处理失败后被放到其他地方稍后重试,其后的消息会先于它被处理。
在业务层面容忍乱序
由于严格的顺序保证在分布式系统中代价高昂且容易被破坏,许多系统选择在业务逻辑层面容忍一定程度的乱序:
- 在消息中携带版本号或时间戳,消费者只应用比当前状态更新的消息,丢弃过时的消息;
- 使用状态机校验,非法的状态转换直接忽略或暂存等待;
- 消息中携带完整的最新状态,而不仅仅是增量变化,这样后到的旧消息可以被新状态覆盖。
八、可靠性保障
消息不丢失需要生产者、代理、消费者三个环节共同保证,任何一个环节出现疏漏,都可能导致消息丢失。
生产端
- 等待确认:生产者应当等待代理返回写入成功的确认,而不是发送后即认为成功;
- 合理的确认级别:可以要求消息被写入多少个副本后才返回确认。只要求主副本写入成功,延迟最低,但主副本在同步给其他副本前宕机,消息就会丢失;要求所有同步副本都写入成功,可靠性最高,但延迟较大;
- 失败重试:发送失败时进行重试,并配合幂等生产者避免重复;
- 处理最终失败:重试多次仍然失败时,需要有兜底措施,例如将消息写入本地文件或数据库,稍后补发,并触发告警。
业务操作与消息发送的一致性
一个常见的难题是:业务操作写入数据库,并发送一条消息通知下游。这两步操作分属两个系统,无法放在同一个本地事务中:
- 先写数据库再发消息:数据库提交后、消息发送前进程崩溃,消息丢失;
- 先发消息再写数据库:消息发出后数据库写入失败,下游收到了一条不应存在的消息。
事务发件箱模式(Transactional Outbox)是解决这一问题的常用方法:
- 在业务数据库中创建一张发件箱表;
- 在同一个本地事务中,既完成业务数据的修改,又将要发送的消息写入发件箱表;
- 由一个独立的进程读取发件箱表中的消息,发送到消息队列,发送成功后标记或删除该记录。
由于业务修改和消息记录在同一个数据库事务中,两者要么同时成功,要么同时失败。发件箱中的消息至少会被发送一次,重复发送的问题由下游的幂等消费来处理。
另一种类似的方法是变更数据捕获(Change Data Capture):直接读取数据库的事务日志,将数据变更转化为消息发送出去。这种方式对业务代码没有侵入,也能保证消息与数据变更的一致性。
一些消息系统还提供了事务消息功能:先发送一条"半消息",此时消费者不可见;执行本地事务后,再根据结果提交或回滚这条消息;如果代理长时间没有收到结果,会主动回查生产者确认事务状态。
代理端
- 多副本:每个分区在多个节点上保存副本,其中一个为主副本负责读写,其他副本从主副本同步数据。主副本所在节点宕机时,从同步状态良好的副本中选出新的主副本;
- 同步副本集合:代理维护一个与主副本保持同步的副本集合,只有该集合中的副本才有资格被选为新的主副本,以避免选出一个数据落后的副本导致消息丢失;
- 最少同步副本数:要求至少有一定数量的副本处于同步状态时才接受写入。如果同步副本不足,宁可拒绝写入,也不冒丢失数据的风险;
- 持久化:消息写入后何时刷入磁盘,影响宕机时的数据安全。许多系统依赖多副本而非每条消息都同步刷盘来保证可靠性,因为多个节点同时宕机的概率远低于单个节点。
消费端
- 处理完成后再提交进度:确保至少一次的语义;
- 谨慎使用自动提交:一些客户端默认定期自动提交消费进度。如果在拉取消息后、处理完成前自动提交发生了,随后消费者崩溃,这些消息就会丢失。对于重要的消息,应当在处理完成后手动提交。
九、常见问题与应对
消息积压
当消费速度持续低于生产速度时,消息会在队列中不断积压。积压本身并不一定是问题,消息队列本就承担着缓冲的作用;但如果积压持续增长,消息的处理延迟会越来越大,最终可能超过消息的保留期限而被清除。
应对积压的常见方法:
- 排查消费瓶颈:消费速度下降往往是因为下游依赖变慢、消费逻辑存在性能问题,或者某些消息反复失败重试;
- 增加消费者:在分区数量允许的范围内增加消费者实例;
- 批量处理:将多条消息合并处理,例如批量写入数据库,减少每条消息的平均开销;
- 临时扩容:如果分区数量已成为瓶颈,可以先由一个简单的消费者将积压的消息快速转发到一个拥有更多分区的临时主题,再由大量消费者并行处理;
- 降级处理:对于非关键消息,在积压严重时可以选择跳过或简化处理。
毒丸消息
毒丸消息(Poison Message)是指由于格式错误、数据异常或程序缺陷,无论重试多少次都无法成功处理的消息。
在基于日志的消息系统中,如果消费者对一条毒丸消息无限重试,它会阻塞整个分区,后续所有消息都无法被处理。
应对方法是设置重试上限和死信队列(Dead Letter Queue):
- 消息处理失败时,进行有限次数的重试,重试之间可以采用逐渐增长的间隔;
- 超过重试上限后,将消息转移到一个专门的死信队列中,然后继续处理后续消息;
- 死信队列中的消息需要有监控和告警,由人工分析原因、修复问题后,再决定重新投递还是丢弃。
需要注意的是,将失败消息转移到死信队列会破坏顺序。对于有严格顺序要求的场景,可能需要暂停对应业务键的后续消息处理,直到问题解决。
重复消费
除了前面提到的生产重试和进度提交时机外,消费者再平衡也是重复消费的常见原因:消费者处理完一批消息、尚未提交进度时,分区被分配给了另一个消费者,新消费者会从上一次提交的进度开始,重新处理这批消息。
由于重复消费在各种情况下都可能发生,消费端必须具备幂等性,这是使用消息队列时最基本的要求。
消息过大
消息队列通常针对较小的消息进行了优化,过大的消息会占用大量内存和网络带宽,降低整体吞吐量,甚至超出代理或客户端的大小限制。
对于大体积的数据,常见做法是只在消息中传递引用:将数据本身存储在对象存储或文件系统中,消息只携带数据的位置和必要的元数据,消费者根据引用获取实际数据。
十、背压
背压(Backpressure)是指当下游处理能力不足时,将压力反向传递给上游,使上游放慢生产速度的机制。
消息队列在一定程度上起到了缓冲压力的作用,但缓冲区并非无限:
- 代理的存储空间有限,长时间积压可能耗尽磁盘;
- 在基于拉取的消费模型中,消费者根据自身能力决定拉取的速度和数量,天然具备背压能力,不会被消息淹没;
- 在基于推送的消费模型中,代理主动将消息推送给消费者,需要通过限制未确认消息的数量等方式,防止消费者被压垮。
如果上游的生产速度长期超过下游的处理能力,仅靠队列缓冲是不够的,需要通过扩容、限流或降级来从根本上解决问题。
十一、何时不应使用消息队列
消息队列带来了解耦和弹性,但也引入了额外的复杂性:
- 增加了一个需要运维的组件,其本身的可用性、容量和性能都需要关注;
- 调用链变得不透明,一个业务流程被拆分成多个异步步骤,排查问题时需要跨越多个系统追踪消息的流转;
- 一致性变成最终一致,数据在不同系统之间的同步存在延迟;
- 需要处理重复、乱序、积压、死信等一系列新问题。
因此,并不是所有的服务间通信都适合使用消息队列:
- 如果调用方需要立即得到处理结果才能继续,例如查询账户余额、校验库存,同步调用通常更合适;
- 如果业务流程简单、调用量不大、各服务稳定可靠,引入消息队列可能是过度设计;
- 如果需要强一致性,异步消息带来的最终一致性可能无法满足要求。
十二、实践建议
明确投递语义。 在设计之初就明确每类消息需要哪种投递语义,并据此配置生产者的确认级别和消费者的进度提交方式。大多数业务应默认采用至少一次投递,并在消费端实现幂等。
为消息设计唯一标识。 每条消息都应携带一个全局唯一的标识,便于去重、追踪和排查问题。
设计好消息格式。 使用结构化且支持版本演进的格式,在增加字段时保持向前和向后兼容,使生产者和消费者可以独立升级。对于跨团队共享的主题,可以借助模式注册中心统一管理消息格式。
合理规划分区和键。 根据吞吐量需求和顺序要求选择分区数量和分区键,避免因键分布不均导致的热点分区。
监控关键指标。 消息的生产速率、消费速率、积压数量、消费延迟、死信数量、再平衡次数,都应纳入监控并设置告警。消费延迟的持续增长,往往是系统出现问题的最早信号。
实现端到端追踪。 在消息中传递追踪上下文,使一个业务请求在多个服务之间、经过多个异步步骤的完整流转路径可以被追踪和还原。
为故障做好准备。 制定消息积压、死信堆积、代理故障时的应对预案,并定期演练消息重放、偏移量重置等操作。
结语
消息队列通过引入一个中间层,将服务之间的直接调用转变为异步的消息传递,实现了时间上和空间上的解耦。它让系统能够吸收流量峰值、隔离局部故障、灵活地增加新的消费者,已经成为现代分布式架构中不可或缺的组件。
但异步通信并不是没有代价的。消息可能丢失、可能重复、可能乱序、可能积压,这些问题在同步调用中或许不存在,或许表现为更直接的错误;而在异步系统中,它们往往隐藏得更深,影响更持久。至少一次投递配合幂等消费、按键分区保证局部顺序、事务发件箱保证业务与消息的一致性、死信队列隔离无法处理的消息,这些模式构成了可靠使用消息队列的基础。
理解消息队列的设计原理和各种语义的含义,才能在享受异步通信带来的好处的同时,正确地处理随之而来的复杂性。
- 点赞
- 收藏
- 关注作者
评论(0)