《数据密集型应用系统设计》消息代理

2022-12-06 08:52:21 浏览数 (2)

引言

消息代理其实指的就是消息队列,但是我认为作者这里的代理,是给予系统架构位置考量的,因为消息中间件的本质就是作为不同服务之间交流的一种媒介。

介绍

消息代理可以看作是 处理数据流进行优化的数据库

消息代理通常部署在独立的服务器当中,无论是生产者还是消费者,都有可能来自于不同的服务。整个流程通常为生产者生产数据通过消息代理当中,消费者连接消息代理接受生产者数据进行消费。

消息存在在中间代理有一个明显的好处是可以屏蔽频繁变动的生产者端和消费者端,将有关消息代理内部的特性转移到代理中。

比如是否持久化问题,有的消息代理处理消息方式是无论是否消费都会存盘(Rocket MQ),保证消息不会随服务器的宕机而丢失消息数据。

当然也有比较粗暴的消息代理处理方式,把消息放在内存中,一旦关闭立马释放消息,但是这样也会导致消息丢失。

消息代理的优势和劣势都在异步处理,生产者只需要确保生产数据正确发送并且正确存储到消息代理中,这些步骤处理完成之后,生产者可以接着处理其他业务。

而消费者则不同,消费者可以配置定期获取消息代理消息并且检查消息内容是否属于自己消费范畴,这保证了消费者可以及时处理,但是处理消费时效性是不确定的(几分钟、几小时、甚至几天),如果消费者的消费能力或者处理效率过低,就会出现消息挤积压的问题。

大部分情况下可以使用无限队列积压消息和切换消费的者的方式对待消费慢的消费者。

消息代理对比数据库

现在一部分消息代理设计可以使用两阶段提交,看起来似乎越来越和数据库进行靠拢。

消息代理虽然可以看作是优化数据流的数据库,但是消息队列和数据库是存在本质差距的,主要的差距如下:

  • 数据库需要保证数据的持久化,删除需要指定的命令完成,否则不能擅自丢失数据。传统的消息代理更多设计为消息成功传递立马删除消息,这样被消费过的消息就不会堆积,也不会有重复消费问题。(但是有部分消息代理存在特例)
  • 消息代理删除消息,多数消息的工作区间非常消息,队列也比较短,通常这些内容都能很快的在内存中"转接",但是一旦消息堆积,消息无法在内存中堆放,就需要临时序列化持久存储到磁盘中,等待内存有了足够空间之后再加载内容。这一步操作需要消耗系统的CPU和IO资源。
  • 数据库通常会使用多级索引加快数据的搜索,而消息代理通常支持某种消息模型来支持特定主题主题发送模式,以及利用日志顺序读写加快数据搜索。
  • 数据库查询数据通常基于数据的时间点快照,为了保证数据的ACID特性,数据库通常需要保证前后两个线程之间的数据可见性正常,前者在不重复查询的前提下,不应该看到后者改动的数据内容(当然也可以做到完全看不到,比如单线程化)。消息代理则侧重于在数据改动之后通知客户端,对于消息查询的能力支持较弱(或干脆没有)。

多个消费者读取

生产者端的数据处理 通常比较简单,消息代理的关注重点再对待消费者的“消费行为”上, 目前消息代理有两种主要的消费模式:负载均衡和扇出式

负载均衡

负载均衡代表了每一个消息只能传给一个消费者,消费者可以共享处理消息的动作,代理也可以分配给任意的消费者,消息处理的代价非常高的时候,这种负载均衡的模式比较受欢迎,通常我们会希望添加消费者并行处理消息。

扇出式

扇出式指的是消息会发给所有的消费者,使用的实现方式是让独立的消费者共同“监听”相通的消费信息同时不互相进行干涉,也可以看作是一个流被复制到不同的批次里面进行工作(实现方式通过JMS和AMQP进行交换绑定)。

负载均衡和扇出式

扇出式和负载均衡方式可以组合完成,比如可以通过负载均衡的多个分组而消费组内每一个消费者都可以接受消息。

消息传递确认

消息传递过程具有不确定性,消费者接受到消息有可能出现不会处理,或者无法处理的情况。为了确保消息不会丢失,客户端再进行消息处理之后必须告诉消息代理,然后消息代理才能确认是否真的被消费过。

如果连接超时或者客户端处理超时,消息代理没有收到消息代理处理完成的请求,则需要把消息重新传递给另一个消费者,通常会把消息重传多次,直到成功为止,否则就认为是存在消费失败的情况。

还有一种情况是有的时候可能接收到消息的客户端已经把消息处理过了,但是在通知消息代理之前崩溃了,这时候就涉及分布式事务问题。

重复消费

消息传递的最后一个问题是重复消费问题:

以上面的图为例,如消费者2在消费m3的时候突然发生崩溃,此时消费者1,刚好消费m4消费完毕,然而下一个消费确实意料之外的消息m3,最后才是消费m5。出现这样的情况由于消费者2消费m3的时候没有对消息代理进行回应。

解决负载均衡和重复消费问题,通常的解决方案是使用单独队列的方式处理,但是如果消息和消息之间没有交集,则完全可以放心重排序问题。

分区日志

传统的消息队列通常不具备和数据库相同的功能,消费者消费完消息之后,消息代理会把消息直接丢弃,早期消息代理是单纯为了瞬时数据处理而出现的。

数据库的设计思路则要求数据的持久化存储,只要用户存在操作,应该是永久贮存到消息代理当中。

传统消息队列如果无法找到消费者,则通常会直接把消息丢失并且无法恢复,但是现代消息随着量级膨胀会出现消息阻塞的问题。既然数据库擅长存储,而消息代理擅长数据的瞬时传递,那么肯定是可以混合使用的。

日志消息存储后续受到消息队列欢迎。

日志存储队列

日志存储结构的关键是 顺序读写追加,在[[《数据密集型型系统设计》LSM-Tree VS BTree]]中介绍了有关日志存储结构的特点。

日志存储和消息代理结合之后的工作方式是生产者推送到消息队列使用追加日志,而消费者则读取最新日志进行接收处理,如果读取到末尾则立刻进入阻塞等待的状态等待生产请求。

为了实现这样的等待消费机制,消息代理通常会设计序号或者消费进度(偏移量)来完成每个消费者的消费进度监控。

在Unix系统当中tail -f 以相同的思路进行工作,默认情况下会监听某个文件的末尾位置监听改变。

使用序号递增是由于日志是只追加不修改的,序号可以保证消息的发送和消费顺序,但是如果使用分区并发发送,依然没法保证顺序消费

下面的图就是消息队列消费的分区以及日志存储结合。

从上面的图可以看到,生产者按照顺序追加到分区后面,消费者维护偏移量记录消费位置,但是分区的实际消费顺序是无法保证的, 只能保证单个分区的消费顺序

目前主流的消息队列都会结合日志消息存储实现高可用,高可用是现代架构的基本要求,使用分区以及消息的顺序读写可以基本达到媲美内存的操作速度,在实现百万消息吞吐量的同时通过日志存储保持高可用以及保证消息的容错性。

日志和传统消息对比

  • 基于日志的消息代理使用日志的方式可以很好的支持扇出结构。
  • 日志消息代理支持负载均衡,可以把整个分区交给消费者进行消费,不需要将单个消费者给消费者客户端。
  • 负载均衡的情况下每个客户端分配分区中的所有消息,之后将会通过单线程顺序消费的方式对于分区进行消费处理。但是这样会带来一个问题,那就是一个主题最终只能有一个分区进行处理,并且只能有一个消费者进行消费,这样会带来两个方面的问题。
    • 主题数量将会等于分区的数量,负载均衡变为单节点,消息队列的所有优点被屏蔽了。
    • 如果消费者无法及时消费,将会出现消息堆积问题。

通过上面几点我们可以了解到,针对不同的应用场景,选择消息的处理方式也不一样:

  • 如果消息的处理代价非常高但消息的排序不是非常重要,可以并行处理保证消息的正常消费。这时候使用传统的 JMS/AMQP 类型的消息代理进行处理。(举例:日志)
  • 如果消费顺序非常重要,消息顺序也非常重要,使用日志方式处理也可以很好工作。(举例:扣款结算)

消费偏移量

顺序读取的方式可以容易实现偏移量处理的需求,通过加入消息偏移量,小于当前消费者偏移量的消息都可以认为已经被消费,而更大的偏移量此时消费者并没有发现,在这种工作模式下,消费者只需要定期进行偏移量检查,然后进行消费和移动偏移量即可。

这种方式有点类似流水线工作上下游定期查看上游推送的任务。

偏移量处理在主从复制的数据库处理方式中的日志序列号比较常见,比如Mysql 的Binlog日志文件复制,主从复制支持从节点重新连接主节点之后,从断开节点的日志序列号开始重新进行同步,在不跳过任何写入的情况下恢复节点复制。

如果消息消费失败,通常由另一个节点负责接管工作,同时记录偏移量最后的使用记录。但是消费偏移量也会出现重复消费的情况,那就是消费者已经把消息消费处理完成了,但是移动偏移量的时候没有记录就崩溃了,那么这条消息在消息队列恢复之后会认为被消费!这种情况下会便出现了重复消费的情况。

磁盘空间使用

日志消息存储的关键问题是磁盘空间会随着日志记录被耗尽,为了更好的管理消息日志, 日志设计通常会采用分区分段的方式,定期将旧段进行归档或者删除。

日志通常可以看作是非常大的缓冲区,缓存区一旦满了通常需要删除掉最早的数据,缓冲区通常会以环形缓冲或者被叫做循环缓冲的方式存在。如果消息的消费速度跟不上消息的生产速度,有可能出现消费者未进行消费,但是待删除标记指向未消费片段的情况。

现代磁盘的存储效率基本可以维持海量数据的日志保存几天甚至几周的时间,不管消息的留存时间多长,可以确定的是消息一定会被存储在磁盘形成的缓冲区当中,所以整个日志的吞吐量基本恒定不变。

磁盘空间利用的另一种方式是当队列过大的时候才把存在内存的消息写入导磁盘当中,和固定放在磁盘缓冲区的日志消息存储相比,使用这种方式在内存的写入是相当快的,但是磁盘读写的时候会明显降速,这会导致队列不能保证吞吐量的稳定(当然也取决于队列中的消息数量)。

消费者跟不上生产者

上面基本讨论了消费者跟不上生产者的的一系列问题,我们可以总结为三种处理方式:

  • 丢弃消息
  • 消息缓冲
  • 应用抗压

使用比较多的方式是日志作为消息缓冲,因为缓冲要求具备较大并且固定大小的缓冲。(受到磁盘制约)

但是消费者落后太多,所需的信息如果比磁盘上的信息还要旧,那么可能无法读者这些信息。所以代理丢弃的消息是缓冲区容量不能容纳的旧消息,实现的方式是监控消费者落后日志头部的距离,落后非常多的情况下需要进行报警,防止消息积压。

消费者所消费的队列“爆满”之前,报警机制以及一些监控工具能提前判断出消费者异常,缓冲区承载量通常足够抗到解决问题修复完成,出现这些问题更多的情况是由于长事务或者长业务处理导致的阻塞问题,这些问题需要在消息消失之前及时处理修复完成。

那如果真的没来得及处理并且开始丢失消息怎么办? 通常也是消费者出现问题,消费者通常为集群部署的(需要强一致性和顺序消费例外),如果单消费者出现故障,可以通过异常之后立即下线的方式把消息转移给其他消费者处理,同时现代消息队列通常有心跳检测和负载均衡,可以在某个节点故障的时候,自动切换到其他消费者进行消费,切换和异常处理有许多策略可以选择。

传统消息队列则没有这样的好处,因为缺乏日志管理,当消费者堆积消息,或者消费者节点宕机,消息依然会累计再消费者身上,待重启之后依然需要处理分配给自己的任务,这显然非常麻烦。

个人建议传统消息队列结合数据库做一套符合业务的重试机制更为妥当。

重新处理消息

传统消息队列的重复消费是采用“即用即删”的方式消费消息,如果消息消费失败,通常的情况是消息被丢失删除,所以传统消息处理不会出现重复消费问题。

使用日志的消息消费则安全很多,虽然会还是会出现重复消费问题,但是换来的是消息可以从日志获取到历史记录,同时因为是只读操作对于日志也不会有任何干涉。

偏移量是消费者的巨大特权,消费可以通过移动偏移量的方式将过往消费信息重复消费,同时输出到不同位置,手动进行数据的重复处理,可以类似批处理的方式,对于一个消息进行重复消费。

小结

在这本书中,作者将消息队列统称为消息代理,当然我们接触更多的说法是消息中间件

我们从消息代理和数据库的区别开始,介绍了有关消息代理的巨大的优势,瞬时处理和异步,它确保了两个完全不同业务系统之间的消息安全传递,消息代理可以通过负载均衡或者扇出方式,将消息负载到多个消费者节点进行消费。

消息代理起初只是简单的简单的消息传递,并且消息不具备任何持久化存储特点,在书中被称之为AMQP/JMS的消息代理结构,这种结构中消息一旦接受成功处理会立马删除过期消息,保证内存有足够的空间存放消息,不保证顺序消费和消息响应而是侧重于速度,这种方式更像是RPC的“升级”。

而现代的消息代理则多数使用日志存储结合,这种思路是从数据库的持久化进行吸收融合,后续发展出基于日志的消息存储和消息回溯以及消费者高可用。消费者仅仅通过偏移量就可以检查最新的消费内容,在宕机重启之后从磁盘日志中找到丢失消息进行重新接受和处理。

我们通常总是认为沾上日志的日志消息存储要比传统的AMQP消息队里的方式要慢,实际上这样的想法是存在偏颇的,因为日志采用的磁盘顺序读写 追加写入的方式,文件中的游标不需要回溯,而磁盘顺序读写 追加写入基本可以保证效率基本可以和内存持平(至少不会断崖式差距),所以日志结构的存储是现代消息代理的主流选择。

消息队列日志的方式选择和适合现代的互联网移动生态十分契合,现代的网络环境不再是单一化,而是随着环境切换会切换不同的网络,所以消息丢失和重复消费的事情几乎是不可避免的(非人为干预的情况下)。

写在最后

这一篇是抽取了整本书消息代理的部分,也就是设计了整个消息代理中比较核心的设计点和一些常见的设计产生的问题,只要入门任意一款现代主流的消息队列中间件,基本能知道作者在表达的深层含义,所以建议看这部分内容之前,先了解一款消息中间件成品会有更好的阅读体验。

0 人点赞