除了正常的消息发送和消费, 在使用Kafka的过程中难免会遇到一些其他高级应用类的需求, 比如消费回溯, 这个可以通过原生Kafka提供的KafkaConsumer.seek() 方法来实现, 然而类似延时队列、消息轨迹等应用需求在原生Kafka中就没有提供了。我们在使用其他消息中间件时, 比如Rabbit MQ,使用到了延时队列、消息轨迹的功能, 如果我们将应用直接切换到Kafka中, 那么只能选择舍弃它们。但这也不是绝对的, 我们可以通过一定的手段来扩展Kafka, 本章讲述的就是如何实现这类扩展的高级应用。
我们在3.2.9节讲述消费者拦截器用法的时候就使用了消息TTL(Time To Live,过期时间) ,
代码清单3-10中通过消息的timestamp字段和ConsumerInterceptor接口的onConsume() 方法来实现消息的TTL功能。消息超时之后不是只能如案例中的那样被直接丢弃, 因为从消息可靠性层面而言这些消息就丢失了,消息超时可以配合死信队列(在11.3节中会讲到)使用,这样原本被丢弃的消息可以被再次保存起来,方便应用在此之后通过消费死信队列中的消息来诊断系统的运行概况。
在案例(代码清单3-10)中有一个局限,就是每条消息的超时时间都是一样的,都是固定的EXPIRE_INTERVAL值的大小。如果要实现自定义每条消息TTL的功能, 那么应该如何处理呢?
这里还可以沿用消息的timestamp字段和拦截器ConsumerInterceptor接口的onConsume()方法, 不过我们还需要消息中的headers字段来做配合。我们可以将消息的TTL的设定值以键值对的形式保存在消息的headers字段中, 这样消费者消费到这条消息的时候可以在拦截器中根据headers字段设定的超时时间来判断此条消息是否超时, 而不是根据原先固定的EXPIRE_INTERVAL值来判断。
下面我们来通过一个具体的示例来演示自定义消息TTL的实现方式。这里使用了消息的headers字段, 而headers字段涉及Headers和Header两个接口, Headers是对多个Header的封装, Header接口表示的是一个键值对, 具体实现如下:
package org.apache.kafka.common.header;
public interface Header{
String key() ;
byte[] value() ;
}
我们可以自定义实现Headers和Header接口, 但这样未免过于烦琐, 这里可以直接使用Kafka提供的实现类org.apache.kafka.common.header.internals.RecordHeaders和org.apache.kafka.common.header.internals.RecordHeader。这里只需使用一个Header,key可以固定为“ttl”,而value用来表示超时的秒数, 超时时间一般用Long类型表示, 但是RecordHeader中的构造方法RecordHeader(String key, byte[] value) 和value() 方法的返回值对应的value都是byte[] 类型, 这里还需要一个小工具实现整型类型与byte[] 的互转, 具体实现如下:
public class BytesUtils {
public static byte[] longToBytes(long res) {
byte[] buffer = new byte[8];
for (int i = 0; i < 8; i++)(
int offset = 64 - (i + 1) * 8;
buffer[i] = (byte) ((res >> offset) & Oxff);
}
return buffer;
}
public static long bytesToLong(byte(] b) {
long values = 0;
for (int i = 0; i < 8; i++) {
values <<= 8;
values|= (b[i] & 0xff);
}
return values;
}
}
下面我们向Kafka中发送3条TTL分别为20秒、5秒和30秒的3条消息, 主要代码如代码清单11-1所示。
代码清单11-1 发送自定义TTL消息的主要代码
ProducerRecord 中包 Headers 字段的构造方法只有2个,具体如下:
代码清单11-1中指定了分区编号为0和消息key的值为null, 其实这个示例中我们并不需要指定这2个值, 但是碍于ProducerRecord中只有2种与Headers字段有关的构造方法。其实完全可以扩展ProducerRecord中的构造方法, 比如添加下面这个方法:
这样就可以修改代码清单 11-1 ProducerRecord 的构建方式 似下面这种写法
回归正题, 很显然代码清单11-1中的第2条消息record 2是故意被设定为超时的, 因为这条消息的创建时间为System.currentTimeMillis() - 5×1000,往前推进了5秒, 而这条消息的超时时间也为5秒。如果在发送这3条消息的时候也开启了消费者,那么经过拦截器处理后应该只会收到“msg_ttl_1”和msg_ttl_3”这两条消息。
我们再来看一下经过改造之后拦截器的具体实现,onCommit() 、close() 、configure() 这3个方法都和代码清单3-10中的一样, 所不同的主要是onConsume()方法, 此方法的具体实现如代码清单11-2所示。
代码清单11-2自定义TTL的拦截器关键代码实现
代码清单11-2中判断每条消息的headers字段中是否包含key为“ttl”的Header,如果包含则对其进行超时判定;如果不包含,则不需要超时判定,即无须拦截处理。
使用这种方式实现自定义消息TTL时同样需要注意的是:使用类似中这种带参数的位移提交的方式,有可能会提交错误的位移信息。在一次消息拉取的批次中,可能含有最大偏移量的消息会被消费者拦截器过滤,这一点与代码清单3-10中的实现一样。不过这个也很好解决,比如在过滤之后的消息集中的头部或尾部设置一个状态消息,专门用来存放这一批消息的最大偏移量。
到目前为止, 无论固定消息TTL, 还是自定义消息TTL, 都是在消费者客户端通过拦截器来实现的, 其实这个功能也可以放在Kafka服务端来实现, 而且具体实现也并不太复杂。不过这样会降低系统的灵活性和扩展性,并不建议这么做,通过扩展客户端就足以应对此项功能。
队列是存储消息的载体,延时队列存储的对象是延时消息。所谓的“延时消息”是指消息被发送以后,并不想让消费者立刻获取,而是等待特定的时间后,消费者才能获取这个消息进行消费, 延时队列一般也被称为“延迟队列”。注意延时与TTL的区别, 延时的消息达到目标延时时间后才能被消费, 而TTL的消息达到目标超时时间后会被丢弃。
延时队列的使用场景有很多,比如:
在Kafka的原生概念中并没有“队列”的影子, Kafka中存储消息的载体是主题(更加确切地说是分区),我们可以把存储延时消息的主题称为“延时主题”,不过这种称谓太过于生僻。在其他消息中间件(比如Rabbit MQ) 中大多采用“延时队列”的称谓, 为了不让Kafka过于生分, 我们这里还是习惯性地沿用“延时队列”的称谓来表示Kafka中用于存储延时消息的载体。
原生的Kafka并不具备延时队列的功能, 不过我们可以对其进行改造来实现。Kafka实现延时队列的方式也有很多种, 在11.1节中我们通过消费者客户端拦截器来实现消息的TTL, 延时队列也可以使用这种方式实现。
不过使用拦截器的方式来实现延时的功能具有很大的局限性,某一批拉取到的消息集中有一条消息的延时时间很长,其他的消息延时时间很短而很快被消费,那么这时该如何处理呢?
下面考虑以下这几种情况:
(1)如果这时提交消费位移,那么延时时间很长的那条消息会丢失。
(2)如果这时不继续拉取消息而等待这条延时时间很长的消息到达延时时间,这样又会导致消费滞后很多,而且如果位于这条消息后面的很多消息的延时时间很短,那么也会被这条消息无端地拉长延时时间,从而大大地降低了延时的精度。
(3)如果这个时候不提交消费位移而继续拉取消息,等待这条延时时间很长的消息满足条件之后再提交消费位移,那么在此期间这条消息需要驻留在内存中,而且需要一个定时机制来定时检测是否满足被消费的条件,当这类消息很多时必定会引起内存的暴涨,另一方面当消费很大一部分消息之后这条消息还是没有能够被消费,此时如果发生异常,则会由于长时间的未提交消费位移而引起大量的重复消费。
有一种改进方案,如图11-1所示,消费者在拉取一批消息之后,如果这批消息中有未到达延时时间的消息,那么就将这条消息重新写入主题等待后续再次消费。这个改进方案看起来很不错,但是当消费滞后很多(消息大量堆积)的时候,原本这条消息只要再等待5秒就能够被消费,但这里却将其再次存入主题,等到再次读取到这条消息的时候有可能已经过了半小时。由此可见,这种改进方案无法保证延时精度,故而也很难真正地投入现实应用之中。
在了解了拦截器的实现方式之后,我们再来看另一种可行性方案:在发送延时消息的时候并不是先投递到要发送的真实主题(real topic) 中, 而是先投递到一些Kafka内部的主题(delay_topic) 中, 这些内部主题对用户不可见, 然后通过一个自定义的服务拉取这些内部主题中的消息,并将满足条件的消息再投递到要发送的真实的主题中,消费者所订阅的还是真实的主题。
延时时间一般以秒来计,若要支持2小时(也就是2×60×60=7200)之内的延时时间的消息,那么显然不能按照延时时间来分类这些内部主题。试想一个集群中需要额外的7200个主题,每个主题再分成多个分区,每个分区又有多个副本,每个副本又可以分多个日志段,每个日志段中也包含多个文件,这样不仅会造成资源的极度浪费,也会造成系统吞吐的大幅下降。如果采用这种方案, 那么一般是按照不同的延时等级来划分的, 比如设定5s、10s、30s、1min、2min、5min、10min、20min、30min、45min、1hour、2hour这些按延时时间递增的延时等级, 延时的消息按照延时时间投递到不同等级的主题中,投递到同一主题中的消息的延时时间会被强转为与此主题延时等级一致的延时时间,这样延时误差控制在两个延时等级的时间差范围之内(比如延时时间为17s的消息投递到30s的延时主题中,之后按照延时时间为30s进行计算,延时误差为13s)。虽然有一定的延时误差,但是误差可控,并且这样只需增加少许的主题就能实现延时队列的功能。
如图11-2所示, 生产者Producer发送若干延时时间不同的消息到主题real_topic_A和real_topic_B中, 消费者Consumer订阅并消费主题real_topic_A和real_topic_B中的消息, 对用户而言, 他看到的就是这样一个流程。但是在内部, Producer会根据不同的延时时间将消息划分为不同的延时等级,然后根据所划分的延时等级再将消息发送到对应的内部主题中,比如5s内的消息发送到delay_topic_1, 6s至10s的消息划分到delay_topic_2中。这段内部的转发逻辑需要开发人员对生产者客户端做一些改造封装(对应图10-12中的SDK客户端) , 可以根据消息的timestamp字段、headers字段(设置延时时间) , 以及生产者拦截器来实现具体的代码。
发送到内部主题(delay_topic_*) 中的消息会被一个独立的DelayService进程消费, 这个DelayService进程和Kafka broker进程以一对一的配比进行同机部署(参考图11-3) , 以保证服务的可用性。
针对不同延时级别的主题, 在DelayService的内部都会有单独的线程来进行消息的拉取,以及单独的DelayQueue(这里用的是JUC中DelayQueue) 进行消息的暂存。与此同时, 在DelayService内部还会有专门的消息发送线程来获取DelayQueue的消息并转发到真实的主题中。从消费、暂存再到转发, 线程之间都是一一对应的关系。如图11-4所示, DelayService的设计应当尽量保持简单,避免锁机制产生的隐患。
为了保障内部DelayQueue不会因为未处理的消息过多而导致内存的占用过大,DelayService会对主题中的每个分区进行计数, 当达到一定的阈值之后, 就会暂停拉取该分区中的消息。
有些读者可能会对这里DelayQueue的设置产生疑惑, DelayQueue的作用是将消息按照再次投递时间进行有序排序,这样下游的消息发送线程就能够按照先后顺序获取最先满足投递条件的消息。再次投递时间是指消息的时间戳与延时时间的数值之和,因为延时消息从创建开始起需要经过延时时间之后才能被真正投递到真实主题中。
同一分区中的消息的延时级别一样,也就意味着延时时间一样,那么对同一个分区中的消息而言, 也就自然而然地按照投递时间进行有序排列, 那么为何还需要DelayQueue的存在呢?因为一个主题中一般不止一个分区,分区之间的消息并不会按照投递时间进行排序,那么可否将这些主题都设置为一个分区呢?这样虽然可以简化设计,但同时却丢弃了动态扩展性,原本针对某个主题的发送或消费性能不足时,可以通过增加分区数进行一定程度上的性能提升。
前面我们也提到了,这种延时队列的实现方案会有一定的延时误差,无法做到秒级别的精确延时,不过一般应用对于延时的精度要求不会那么高,只要延时等级设定得合理,这个实现方案还是能够具备很大的应用价值。
那么有没有延时精度较高的实现方案?我们先来回顾一下前面的延时分级的实现方案,它首先将生产者生产的消息暂存到一个地方,然后通过一个服务去拉取符合再次投递条件的消息并转发到真实的主题。如图11-5所示,一般的延时队列的实现架构也大多类似。
后台服务获取消息之后马上会转发到真实的主题中,而订阅此主题的消费者也就可以及时地消费消息,在这一阶段中并无太大的优化空间。反观消息从生产者到缓存再到后台服务的过程中需要一个等待延时时间的行为,在这个过程中有很大的空间来做进一步的优化。
我们在6.3节中讲述过延时操作,其延时的精度很高,那么我们是否可以借鉴一下来实现延迟队列的功能呢?毕竟在Kafka中有现成的延时处理模块, 复用一下也未尝不可。第一种思路,在生产者这一层面我们采取延时操作来发送消息,这样原本立刻发送出去的消息被缓存在了客户端中以等待延时条件的满足。这种思路有明显的弊端:如果生产者中缓存的消息过多,则必然引起内存的暴涨;消息可靠性也很差,如果生产者发生了异常,那么这部分消息也就丢失了,除非配套相应的重发机制。
第二种思路, 在Kafka服务中增加一个前置缓存, 生产者还是正常将消息发往Kafka中,Kafka在判定消息是延时消息时(可以增加一个自定义协议, 与发送普通消息的PRODUCE协议分开, 比如DELAY_PRODUCE, 作为发送延时消息的专用协议) 就将消息封装成延时操作并暂存至缓存中,待延时操作触发时就会将消息发送到真实的主题中,整体架构上与图11-5中所描述的类似。这种思路也有消息可靠性的问题,如果缓存延时操作的那台服务器宕机,那么消息也会随之丢失,为此我们可以引入缓存多副本的机制,如图11-6所示。
生产者发送的消息不单单发往一个缓存中,而是发往多个缓存,待所有缓存都收到消息之后才算发送成功, 这一点和Kafka生产者客户端参数acks=-1的机理相通。每个broker中都会有一个延时操作的清理服务,彼此之间有主从的关系,任意时刻只有一个清理服务在工作,其余的清理服务都处于冷备状态。当某个延迟操作触发时会通知清理服务去清理其他延时操作缓存中对应的延时操作。这种架构虽然可以弥补消息可靠性的缺陷,但对于分布式架构中一些老生常谈的问题(比如缓存一致性、主备切换等)需要格外注意。
第二种思路还需要修改Kafka内核的代码, 对开发人员源码的掌握能力及编程能力也是一个不小的挑战, 后期系统的维护成本及Kafka社区的福利也是不得不考虑的问题。与此同时,这种思路和第一种思路一样会有内存暴涨的问题,单凭这个问题也可以判断出此种思路并不适合实际应用。
退一步思考, 我们并不需要复用Kafka中的延时操作的模块, 而是可以选择自己开发一个精度较高的延时模块,这里就用到了6.2节中提及的时间轮的概念,所不同的是,这里需要的是单层时间轮。而且延时消息也不再是缓存在内存中,而是暂存至文件中。时间轮中每个时间格代表一个延时时间,并且每个时间格也对应一个文件,整体上可以看作单层文件时间轮,如图11-7所示。
每个时间格代表1秒,若要支持2小时(也就是2x60x60=7200)之内的延时时间的消息,那么整个单层时间轮的时间格数就需要7200个,与此对应的也就需要7200个文件,听上去似乎需要庞大的系统开销,就单单文件句柄的使用也会耗费很多的系统资源。其实不然,我们并不需要维持所有文件的文件句柄, 只需要加载距离时间轮表盘指针(currentTime) 相近位置的部分文件即可,其余都可以用类似“懒加载”的机制来维持:若与时间格对应的文件不存在则可以新建,若与时间格对应的文件未加载则可以重新加载,整体上造成的时延相比于延时等级方案而言微乎其微。随着表盘指针的转动,其相邻的文件也会变得不同,整体上在内存中只需要维持少量的文件句柄就可以让系统运转起来。
读者有可能会有疑问,这里为什么强调的是单层时间轮。试想一下,如果这里采用的是多层时间轮,那么必然会有时间轮降级的动作,那就需要将高层时间轮中时间格对应文件中的内容写入低层时间轮,高层时间格中伴随的是读取文件内容、写入低层时间轮、删除已写入的内容的操作,与此同时,高层时间格中也会有新的内容写入。如果要用多层时间轮来实现,不得不增加繁重的元数据控制信息和繁杂的锁机制。对单层时间轮中的时间格而言,其对应的要么是追加文件内容,要么是删除整个文件(到达延时时间,就可以读取整个文件中的内容做转发,并删除整个文件)。采用单层时间轮可以简化工程实践,减少出错的可能,性能上也并不会比多层时间轮差。
采用时间轮可以解决延时精度的问题,采用文件可以解决内存暴涨的问题,那么剩下的还有一个可靠性的问题,这里就借鉴了图11-6中的多副本机制,如图11-8所示。生产者同样将消息写入多个备份(单层文件时间轮),待时间轮转动而触发某些时间格过期时就可以将时间格对应的文件内容(也就是延时消息)转发到真实主题中,并且删除相应的文件。与此同时,还会有一个后台服务专门用来清理其他时间轮中相应的时间格,这一套流程与图11-6中表达的流程相似。
单层文件时间轮的方案不需要修改Kafka内核的源码, 与前面第二种思路相比实现较为简单。单层文件时间轮的方案与延时级别的实现方案一样可以将延时服务(图11-8中单层时间轮与后台服务的整合体) 与Kafka进程进行一对一配比的同机部署, 以保证整体服务的可用性。
总体上而言,对于延时队列的封装实现,如果要求延时精度不是那么高,则建议使用延时等级的实现方案,毕竟实现起来简单明了。反之,如果要求高精度或自定义延时时间,那么可以选择单层文件时间轮的方案。
由于某些原因消息无法被正确地投递,为了确保消息不会被无故地丢弃,一般将其置于一个特殊角色的队列,这个队列一般称为死信队列。后续分析程序可以通过消费这个死信队列中的内容来分析当时遇到的异常情况,进而可以改善和优化系统。
与死信队列对应的还有一个“回退队列”的概念,如果消费者在消费时发生了异常,那么就不会对这一次消费进行确认,进而发生回滚消息的操作之后,消息始终会放在队列的顶部,然后不断被处理和回滚,导致队列陷入死循环。为了解决这个问题,可以为每个队列设置一个回退队列,它和死信队列都是为异常处理提供的一种机制保障。实际情况下,回退队列的角色可以由死信队列和重试队列来扮演。
无论Rabbit MQ中的队列, 还是Kafka中的主题, 其实质上都是消息的载体, 换种角度看待问题可以让我们找到彼此的共通性。我们依然可以把Kafka中的主题看作“队列”, 那么重试队列、死信队列的称谓就可以同延时队列一样沿用下来。
理解死信队列,关键是要理解死信。死信可以看作消费者不能处理收到的消息,也可以看作消费者不想处理收到的消息,还可以看作不符合处理要求的消息。比如消息内包含的消息内容无法被消费者解析,为了确保消息的可靠性而不被随意丢弃,故将其投递到死信队列中,这里的死信就可以看作消费者不能处理的消息。再比如超过既定的重试次数之后将消息投入死信队列,这里就可以将死信看作不符合处理要求的消息。
至于死信队列到底怎么用, 是从broker端存入死信队列, 还是从消费端存入死信队列, 需要先思考两个问题:死信有什么用?为什么用?从而引发怎么用。在Rabbit MQ中, 死信一般通过broker端存入, 而在Kafka中原本并无死信的概念, 所以当需要封装这一层概念的时候,就可以脱离既定思维的束缚,根据应用情况选择合适的实现方式,理解死信的本质进而懂得如何去实现死信队列的功能。
重试队列其实可以看作一种回退队列,具体指消费端消费消息失败时,为了防止消息无故丢失而重新将消息回滚到broker中。与回退队列不同的是, 重试队列一般分成多个重试等级,每个重试等级一般也会设置重新投递延时,重试次数越多投递延时就越大。举个例子:消息第一次消费失败入重试队列Q1,Q1的重新投递延时为5s,5s过后重新投递该消息;如果消息再次消费失败则入重试队列Q2,Q2的重新投递延时为10s,10s过后再次投递该消息。以此类推,重试越多次重新投递的时间就越久,为此还需要设置一个上限,超过投递次数就进入死信队列。重试队列与延时队列有相同的地方,都需要设置延时级别。它们的区别是:延时队列动作由内部触发,重试队列动作由外部消费端触发;延时队列作用一次,而重试队列的作用范围会向后传递。
消息路由是消息中间件中常见的一个概念, 比如在典型的消息中间件Rabbit MQ中就使用路由键Routing Key来进行消息路由。如图11-9所示, Rabbit MQ中的生产者将消息发送到交换器Exchange中, 然后由交换器根据指定的路由键来将消息路由到一个或多个队列中, 消费者消费的是队列中的消息。从整体上而言, Rabbit MQ通过路由键将原本发往一个地方的消息做了区分,然后让不同的消息者消费到自己要关注的消息。
Kafka默认按照主题进行路由, 也就是说, 消息发往主题之后会被订阅的消费者全盘接收,这里没有类似消息路由的功能来将消息进行二级路由,这一点从逻辑概念上来说并无任何问题。从业务应用上而言,如果不同的业务流程复用相同的主题,就会出现消息接收时的混乱,这种问题可以从设计上进行屏蔽,如果需要消息路由,那么完全可以通过细粒度化切分主题来实现。
除了设计缺陷, 还有一些历史遗留的问题迫使我们期望Kafka具备一个消息路由的功能。如果原来的应用系统采用了类似Rabbit MQ这种消息路由的生产消费模型, 运行一段时间之后又需要更换为Kafka, 并且变更之后还需要保留原有系统的编程逻辑。对此, 我们首先需要在这个整体架构中做一层关系映射, 如图11-10所示。这里将Kafka中的消费组与Rabbit MQ中的队列做了一层映射, 可以根据特定的标识来将消息投递到对应的消费组中, 按照Kafka中的术语来讲,消费组根据消息特定的标识来获取消息,其余的都可以被过滤。
具体的实现方式可以在消息的headers字段中加入一个键为“routing key”、值为特定业务标识的Header, 然后在消费端中使用拦截器挑选出特定业务标识的消息。Kafka中消息路由的实现架构如图11-11所示, 消费组Consumer Group l根据指定的Header标识rk 2和rk 3来消费主题Topic A和Topic B中所有对应的消息而忽略Header标识为rk 1的消息, 消费组Consumer Group 2 正好相反。
这里只是演示作为消息中间件家族之一的Kafka如何实现消息路由的功能, 不过消息路由在Kafka的使用场景中很少见, 如无特殊需要, 也不推荐刻意地使用它。
在使用消息中间件时,我们时常会遇到各种问题:消息发送成功了吗?为什么发送的消息在消费端消费不到?为什么消费端重复消费了消息?对于此类问题,我们可以引入消息轨迹来解决。消息轨迹指的是一条消息从生产者发出, 经由broker存储, 再到消费者消费的整个过程中, 各个相关节点的状态、时间、地点等数据汇聚而成的完整链路信息。生产者、broker、消费者这3个角色在处理消息的过程中都会在链路中增加相应的信息,将这些信息汇聚、处理之后就可以查询任意消息的状态,进而为生产环境中的故障排除提供强有力的数据支持。
对消息轨迹而言,最常见的实现方式是封装客户端,在保证正常生产消费的同时添加相应的轨迹信息埋点逻辑。无论生产,还是消费,在执行之后都会有相应的轨迹信息,我们需要将这些信息保存起来。这里可以参考Kafka中的做法, 它将消费位移信息保存在主题consumer_offset中。对应地, 我们同样可以将轨迹信息保存到Kafka的某个主题中, 比如图11-12中的主题trace_topic。
生产者在将消息正常发送到用户主题real topic之后(或者消费者在拉取到消息消费之后)会将轨迹信息发送到主题trace_topic中。这里有两种发送方式:第一种是直接通过KafkaProducer发送, 为了不对普通的消息发送造成影响, 可以采取“低功耗”的(比如异步、acks=0等) 发送配置,不过有可能会造成轨迹信息的丢失。另一种方式是将轨迹信息保存到本地磁盘,然后通过某个传输工具(比如Flume) 来同步到Kafka中, 这种方式对正常发送/消费逻辑的影响较小、可靠性也较高,但是需要引入额外的组件,增加了维护的风险。
消息轨迹中包含生产者、broker和消费者的消息, 但是图11-12中只提及了生产者和消费者的轨迹信息的保存而并没有提及broker信息的保存。生产者在发送消息之后通过确认信息(ProduceRequest对应的响应ProduceResponse,参考6.1节)来得知是否已经发送成功,而在消费端就更容易辨别一条消息是消费成功了还是失败了,对此我们可以通过客户端的信息反推出broker的链路信息。当然我们也可以在broker中嵌入一个前置程序来获得更多的链路信息,比如消息流入时间、消息落盘时间等。不过在broker内嵌前置程序, 如果有相关功能更新, 难免需要重启服务,如果只通过客户端实现消息轨迹,则可以简化整体架构、灵活部署,本节针对后者做相关的讲解。
一条消息对应的消息轨迹信息所包含的内容(包含生产者和消费者)如表11-1所示。
轨迹信息保存到主题trace_topic之后, 还需要通过一个专门的处理服务模块对消息轨迹进行索引和存储,方便有效地进行检索。在查询检索页面进行检索的时候可以根据具体的消息ID进行精确检索, 也可以根据消息的key、主题、发送/接收时间进行模糊检索, 还可以根据用户自定义的Tags信息进行有针对性的检索, 最终查询出消息的一条链路轨迹。图11-3中给出一个链路轨迹的示例,根据这个示例我们可以清楚地知道某条消息所处的状态。
消息审计是指在消息生产、存储和消费的整个过程之间对消息个数及延迟的审计,以此来检测是否有数据丢失、是否有数据重复、端到端的延迟又是多少等内容。
目前与消息审计有关的产品也有多个, 比如Chaperone(Uber) 、Confluent Control Center、Kafka Monitor(Linked In) , 它们主要通过在消息体(value字段) 或在消息头(headers字段)中内嵌消息对应的时间戳timestamp或全局的唯一标识ID(或者是两者兼备) 来实现消息的审计功能。
内嵌timestamp的方式主要是设置一个审计的时间间隔time_bucket_interval(可以自定义设置几秒或几分钟) , 根据这个time_bucket_interval和消息所属的timestamp来计算相应的时间桶(time bucket) 。
算法1:timestamp-timestamp号time_bucket_interval(这个算法在时间轮里也有提及)
算法2:(long) Math.floor((timestamp/time_bucket_interval)*time_bucket_interval)
根据上面的任意一种算法可以获得time_bucket的起始时间time_bucket_start, 那么这个time_bucket的时间区间可以记录为(time_bucket_start, time_bucket_start+time_bucket_interval) ,注意是左闭右开区间。每发送一条或消费一条消息, 可以根据消息中内嵌的timestamp来计算并分配到相应的time_bucket中, 然后对桶进行计数并存储, 比如可以简单地存储到Map
内嵌ID的方式就更加容易理解了,对于每一条消息都会被分配一个全局唯一标识ID,这个和11.5节讲述的消息轨迹中的消息ID是同一个东西。如果主题和相应的分区固定,则可以为每个分区设置一个全局的ID。当有消息发送时,首先获取对应的ID,然后内嵌到消息中,最后才将它发送到broker中。消费者进行消费审计时, 可以判断出哪条消息丢失、哪条消息重复。
如果还要计算端到端延迟, 那么就需要在消息中内嵌timestamp, 也就是消息中同时含有ID和timestamp, 细心的读者可能注意到这两类信息在消息轨迹的功能中也都包含了进去。的确如此,我们可以将消息轨迹看作细粒度化的消息审计,而消息审计可以看作粗粒度化的消息轨迹。
消息审计的实现模型也和消息轨迹的类似, 同样是通过封装自定义的SDK来实现的。图11-14中展示的是Confluent Control Center的消息审计的实现模型, 它通过生产者客户端和消费者客户端的拦截器来实现审计信息的保存, 这里的审计信息同样保存到Kafka中的某个主题中,最后通过Confluent Control Center进行最终的信息处理和展示。如果读者需要类似消息审计的功能,不妨参照此类的实现。
Kafka REST Proxy可以为Kafka集群提供一系列的REST API接口, 通过这些REST API接口可以在不使用Kafka原生的私有协议或和语言相关的客户端的情况下实现包括发送消息、消费消息、查看集群的状态和执行管理类操作等功能。
从整体设计上来说, Kafka REST Proxy预期要实现的是生产者客户端、消费者客户端和命令行工具所提供的所有功能。就目前而言, Kafka REST Proxy已经支持大部分功能, 但还有上升空间。官网资料显示目前已经支持的内容包括:
(1) 元数据。可以查看集群中大多数的元数据信息, 包括broker、主题、分区, 以及对应的配置信息。
(2) 消息发送。可以支持往指定主题或分区中发送消息。Kafka REST Proxy将多个生产者实例进行池化,待监听到用户发送消息的请求之后,会先将消息缓存起来,然后由生产者实例池来执行最后发送至Kafka集群中的请求。
(3)消费。消费者是有状态的,因为它要记录和提交消费位移,所以消费者实例需要和指定的Kafka REST Proxy实例捆绑起来以维持状态。消费位移既可以自动提交, 也可以手动提交。目前限定一个消费者实例一个线程, 用户可以使用多个消费者实例来提高吞吐量。Kafka REST Proxy支持旧版的消费者客户端, 对应的API版本为v 1, 同时支持新版的消费者客户端(KafkaConsumer) , 对应的API版本为v 2。
(4) 数据格式。Kafka REST Proxy用来进行读写的数据都是JSON, 而JSON内部的数据使用Base 64或Avro进行编码。如果采用Avro则需要再引入Confluent公司的另一个组件SchemaRegistry。
(5) 集群化部署与负载均衡。Kafka REST Proxy通过多实例部署来提高可靠性和可用性,以此提高系统的负载能力。还可以有效地运用各种负载均衡机制来提供负载均衡的保障。
目前Kafka REST Proxy处于规划中的内容包括:
(1)支持管理类操作。例如,创建、删除主题之类的操作,要实现这些功能还需要涉及一些安全机制。
(2) 支持多主题的生产请求。目前Kafka REST Proxy只能指定一个主题或一个分区来发送消息。大多数情况下,并不需要多主题的生产请求,这个可以通过发送多个单主题的生产请求来“婉转”实现。然而,如果能将多个单主题的请求组合成单个多主题的请求,则可以减少HTTP请求的次数, 进而可以提升一定的性能。
(3) 增加生产消费的配置。目前只有部分Kafka客户端的参数可以在Kafka REST Proxy中被重载,未来将支持重载更多的参数来提高灵活性。
Kafka REST Proxy的运行依赖于ZooKeeper、Kafka和Schema Registry。可以使用Confluent公司提供的完整的包来实现快速入门, Confluent企业版及开原版的下载地址为:https://www.confluent.io/download/,截至本书出版,最新的版本为4.1.1。
其中配置文件在src目录下, 管理脚本在bin目录下, 而share目录下是各类jar包。可以执行以下命令来依次启动confluent-4.1.1包内的ZooKeeper、Kafka、Registry Schema和REST Proxy:
bin/confluent start kafka-rest
如果已经安装并启动了自定义的Kafka及对应的ZooKeeper服务, 那么可以修改配置文件
etc/kafka-rest/kafka-rest.properties来指向自定义的Kafka和ZooKeeper服务:
zookeeper.connect=localhost:2181/kafka
bootstrap.servers=PLAINTEXT://localhost:9092
如果不需要Registry Schema提供的功能, 那么可以不用开启Registry Schema服务。可以直接开启Kafka REST Proxy服务:
bin/kafka-rest-start etc/kafka-rest/kafka-rest.properties
默认情况下Kafka REST Proxy监听HTTP请求的端口号为8082。
与开启对应的关闭命令为:bin/kafka-rest-stop。
如果需要构建并部署一个自定义版本的Kafka REST Proxy,则需要依赖于common、rest-utils和schema-registry, 对应的项目源码地址如下。
Kafka REST Proxy在处理HTTP请求(Request) 和响应(Response) 时会指定特定的内容格式, 这个内容格式包含3个部分:序列化格式(比如json) 、API版本号(比如v 2) 和内嵌的编码格式(比如json、binary和avro) 。就目前而言, 序列化格式只支持json, 而API版本号有v1或v2两种。
内嵌的编码格式是指生产或消费时的数据编码格式。比如HTTP请求的数据内容是一个JSON对象, 在这个JSON对象里会包含真正的消息内容,包含消息的key和value, 这里的编码格式就是指key和value的编码格式。可以使用binary的编码格式, 那么对应的内容格式为application/vnd.kafka.binary.v2+json。也可以使用json的编码格式, 与此对应的内容格式为application/vnd.kafka.json.v2+json。对avro而言, 会涉及Schema Registry, 为了专注于讲述Kafka REST Proxy的内容, 本节会撇清对Schema Registry的依赖, 涉及Schema Registry内容都会选择性地一笔带过。
内容格式的具体形式描述如下:
application/vnd.kafka[.embedded format] .[api_version] +[serialization_format]
如果请求中没有消息, 那么embedded format是可以省略的。比如获取某种类型的元数据信息时, 可以将内容格式设置为application/vnd.kafka.v 2+json。目前默认的内容格式为application/vnd.kafka.[embedded_format].v1+json,其中embedded_format的默认值为binary。当然, 内容格式还可以简化, 比如可以使用application/vnd.kafka+json以省去API版本信息, 这样内部会默认采用最新的稳定版。还可以直接将内容格式设置为application/json或application/octet-stream, 支持这两种格式主要是为了兼容性和易用性。
具体执行HTTP请求时, 可以设置HTTP报头Content-Type来表示发送端(客户端或服务端)发送的实体数据的内容格式,比如Content-Type:application/vnd.kafka.v2+json。如果请求有数据响应, 那么还需要设置HTTP报头Accept来指定发送方(客户端)希望接收的实体数据的内容格式,比如Accept:application/vnd.kafka.v2+json。
如果内容格式设置错误, 则会出现异常, 比如{“error_code”:415,“message”:“HTTP 415 Unsupported MediaType”} 。Kafka REST Proxy还支持内容格式的协商, 在不确定内容格式的情况下可以设定多个内容格式,每个内容格式后面紧跟一个权重的数值。示例如下:
Accept: application/vnd.kafka.v2+json; q=0.9,application/json; q=0.5
目前 Kafka REST Proxy 提供的RESTful API 接口及对应的功能如表 11-2 所示
11-2 RESTful API 接口列表及对应的功能
RESTful API从功能角度来划分可以分为Topics、Partitions、Consumers和Brokers这4大类, 从RESTful API版本角度来划分, 又可以分为v 1和v 2两个版本。篇幅限制, 这里只针对最新的v2版本来进行说明,v1版本的内容基本上是v2版本的一个子集。下面通过几个示例来看一下API的用法。
略。。。
略。。。
消息中间件的选型是很多个人乃至公司都会面临的一个问题。目前开源的消息中间件有很多, 比如ActiveMQ、Rabbit MQ、Kafka、Rocket MQ、Zero MQ等。不管选择其中的哪一款, 都会有用得不顺手的地方,毕竟不是为你量身定制的。有些“大厂”在长期的使用过程中积累了一定的经验,消息队列的使用场景也相对稳定固化,由于某种原因(比如目前市面上的消息中间件无法全部满足自身需求),并且它也具备足够的财力和人力而选择自研一款量身打造的消息中间件。但绝大多数公司还是选择不重复造轮子,那么选择一款合适的消息中间件就显得尤为重要了。就算是前者,在自研出稳定且可靠的相关产品之前还是会经历这样一个选型过程。
在整体架构中引入消息中间件,势必要考虑很多因素,比如成本及收益问题,怎么样才能达到最优的性价比?虽然消息中间件种类繁多,但各自都有侧重点,合适自己、扬长避短无疑是最好的方式。如果你对此感到从无所适从,本节的内容或许可以参考一二。
ActiveMQ是Apache出品的、采用Java语言编写的、完全基于JMS 1.1规范的、面向消息的中间件,为应用程序提供高效的、可扩展的、稳定和安全的企业级消息通信。不过由于历史包袱太重, 目前市场份额没有后面三种消息中间件多, 其最新架构被命名为Apollo, 号称下一代ActiveMQ, 有兴趣的读者可行自行了解。
RabbitMQ是采用Erlang语言实现的AM QP协议的消息中间件, 最初起源于金融系统, 用于在分布式系统中存储和转发消息。Rabbit MQ发展到今天, 被越来越多的人认可, 这和它在可靠性、可用性、扩展性、功能丰富等方面的卓越表现是分不开的。
RocketMQ是阿里开源的消息中间件, 目前已经捐献给Apache基金会, 它是由Java语言开发的,具备高吞吐量、高可用性、适合大规模分布式系统应用等特点,经历过“双11”的洗礼,实力不容小觑。
ZeroMQ号称史上最快的消息队列, 基于C/C++开发。Zero MQ是一个消息处理队列库, 可在多线程、多内核和主机之间弹性伸缩,虽然大多数时候我们习惯将其归入消息队列家族,但是和前面的几款有着本质的区别, Zero MQ本身就不是一个消息队列服务器, 更像是一组底层网络通信库, 对原有的Socket API上加上一层封装而已。
目前市面上的消息中间件还有很多, 比如腾讯系的Phx Queue、CMQ、CKafka, 又比如基于Go语言的NSQ, 有时人们也把类似Redis的产品看作消息中间件的一种, 当然它们都很优秀, 但是篇幅限制无法穷极所有, 下面会有针对性地挑选Rabbit MQ和Kafka两款典型的消息中间件来进行分析,力求站在一个公平、公正的立场来阐述消息中间件选型中的各个要点。对于Rabbit MQ感兴趣的读者可以参阅笔者的另一本著作《Rabbit MQ实战指南》, 里面对Rabbit MQ做了大量的详细介绍。
衡量一款消息中间件是否符合需求,需要从多个维度进行考察,首要的就是功能维度,这个直接决定了能否最大程度地实现开箱即用,进而缩短项目周期、降低成本等。如果一款消息中间件的功能达不到需求,那么就需要进行二次开发,这样会增加项目的技术难度、复杂度,以及延长项目周期等。
功能维度又可以划分多个子维度,大致可以分为以下几个方面。
优先级队列:优先级队列不同于先进先出队列,优先级高的消息具备优先被消费的特权,这样可以为下游提供不同消息级别的保证。不过这个优先级也需要有一个前提:如果消费者的消费速度大于生产者的速度, 并且broker中没有消息堆积, 那么对发送的消息设置优先级也就没有什么实质性的意义了, 因为生产者刚发送完一条消息就被消费者消费了, 就相当于broker中至多只有一条消息,对于单条消息来说优先级是没有什么意义的。
消费模式:消费模式分为推(push) 模式和拉(pull) 模式。推模式是指由broker主动推送消息至消费端, 实时性较好, 不过需要一定的流控机制来确保broker推送过来的消息不会压垮消费端。而拉模式是指消费端主动向broker请求拉取(一般是定时或定量) 消息, 实时性较推模式差,但可以根据自身的处理能力控制拉取的消息量。
延时队列:参考11.2节。
重试队列:参考11.3节。
死信队列:参考11.3节。
广播消费:消息一般有两种传递模式:点对点(P2P, Point-to-Point) 模式和发布/订阅(Pub/Sub) 模式。对点对点的模式而言, 消息被消费以后, 队列中不会再存储消息, 所以消息消费者不可能消费已经被消费的消息。虽然队列可以支持多个消费者,但是一条消息只会被一个消费者消费。发布/订阅模式定义了如何向一个内容节点发布和订阅消息,这个内容节点称为主题,主题可以认为是消息传递的中介,消息发布者将消息发布到某个主题,而消息订阅者从主题中订阅消息。主题使得消息的订阅者与消息的发布者互相保持独立,不需要进行接触即可保证消息的传递, 发布/订阅模式在消息的一对多广播时采用。Rabbit MQ是一种典型的点对点模式, 而Kafka是一种典型的发布/订阅模式。但是在Rabbit MQ中可以通过设置交换器类型来实现发布/订阅模式, 从而实现广播消费的效果。Kafka中也能以点对点的形式消费, 完全可以把其消费组(consumer group) 的概念看作队列的概念。不过对比来说, Kafka中因为有了消息回溯功能, 对广播消费的力度支持比Rabbit MQ要强。
回溯消费:一般消息在消费完成之后就被处理了,之后再也不能消费该条消息。消息回溯正好相反,是指消息在消费完成之后,还能消费之前被消费的消息。对消息而言,经常面临的问题是“消息丢失”,至于是真正由于消息中间件的缺陷丢失,还是由于使用方的误用而丢失,一般很难追查。如果消息中间件本身具备消息回溯功能,则可以通过回溯消费复现“丢失的”消息,进而查出问题的源头。消息回溯的作用远不止于此,比如还有索引恢复、本地缓存重建,有些业务补偿方案也可以采用回溯的方式来实现。
消息堆积+持久化:流量削峰是消息中间件中的一个非常重要的功能,而这个功能其实得益于其消息堆积能力。从某种意义上来讲,如果一个消息中间件不具备消息堆积的能力,那么就不能把它看作一个合格的消息中间件。消息堆积分内存式堆积和磁盘式堆积。Rabbit MQ是典型的内存式堆积,但这并非绝对,在某些条件触发后会有换页动作来将内存中的消息换页到磁盘(换页动作会影响吞吐) , 或者直接使用惰性队列来将消息直接持久化至磁盘中。Kafka是一种典型的磁盘式堆积,所有的消息都存储在磁盘中。一般来说,磁盘的容量会比内存的容量要大得多,磁盘式的堆积其堆积能力就是整个磁盘的大小。从另外一个角度讲,消息堆积也为消息中间件提供了冗余存储的功能。
消息过滤:消息过滤是指按照既定的过滤规则为下游用户提供指定类别的消息。以Kafka为例,完全可以将不同类别的消息发送至不同的主题中,由此可以实现某种意义的消息过滤,还可以根据分区对同一个主题中的消息进行二次分类。不过更加严格意义上的消息过滤应该是对既定的消息采取一定的方式, 按照一定的过滤规则进行过滤。同样以Kafka为例, 可以通过客户端提供的Consumer Interceptor接口或Kafka Streams的filter功能进行消息过滤。
消息轨迹:参考11.5节。
消息审计:参考11.6节。
多租户:也可以称为多重租赁技术,是一种软件架构技术,主要用来实现多用户的环境下公用相同的系统或程序组件, 并且仍可以确保各用户间数据的隔离性。Rabbit MQ就能够支持多租户技术, 每一个租户表示为一个vhost, 其本质上是一个独立的小型Rabbit MQ服务器, 又有自己独立的队列、交换器及绑定关系等, 并且它拥有自己独立的权限。vhost就像是物理机中的虚拟机一样,它们在各个实例间提供逻辑上的分离,为不同程序安全、保密地运送数据,它既能将同一个Rabbit MQ中的众多客户区分开, 又可以避免队列和交换器等命名冲突。
多协议支持:消息是信息的载体,为了让生产者和消费者都能理解所承载的信息(生产者需要知道如何构造消息,消费者需要知道如何解析消息),它们就需要按照一种统一的格式来描述消息,这种统一的格式称为消息协议。有效的消息一定具有某种格式,而没有格式的消息是没有意义的。一般消息层面的协议有AM QP、MQT T、STOMP、XMPP等(消息领域中的JMS更多的是一个规范而不是一个协议),支持的协议越多,其应用范围就会越广,通用性越强,比如Rabbit MQ能够支持MQT T协议就让其在物联网应用中获得一席之地。还有的消息中间件是基于本身的私有协议运转的, 典型的如Kafka。
跨语言支持:对很多公司而言, 其技术栈体系中会有多种编程语言, 如C/C++、Java、Go、PHP等, 消息中间件本身具备应用解耦的特性, 如果能够进一步支持多客户端语言, 那么就可以将此特性的效能扩大。跨语言的支持力度也可以从侧面反映出一个消息中间件的流行程度。
流量控制:流量控制(flowcontrol) 针对的是发送方和接收方速度不匹配的问题, 提供一种速度匹配服务来抑制发送速度,使接收方应用程序的读取速度与之相适应。通常的流控方法有stop-and-wait、滑动窗口和令牌桶等。
消息顺序性:顾名思义,消息顺序性是指保证消息有序。这个功能有一个很常见的应用场景就是CDC(Change Data Chap ture) , 以MySQL为例, 如果其传输的binlog的顺序出错, 比如原本是先对一条数据加1,然后乘以2,发送错序之后就变成了先乘以2后加1了,造成了数据不一致。
安全机制:在Kafka 0.9之后就增加了身份认证和权限控制两种安全机制。身份认证是指客户端与服务端连接进行身份认证, 包括客户端与broker之间、broker与broker之间、broker与ZooKeeper之间的连接认证, 目前支持SSL、SASL等认证机制。权限控制是指对客户端的读写操作进行权限控制, 包括对消息或Kafka集群操作权限控制。权限控制是可插拔的, 并支持与外部的授权服务进行集成。Rabbit MQ同样提供身份认证(TLS/SSL、SASL) 和权限控制(读写操作)的安全机制。
消息幂等性:为了确保消息在生产者和消费者之间进行传输, 一般有三种传输保障(deliveryguarantee) :At most once, 至多一次, 消息可能丢失, 但绝不会重复传输; Atleast once, 至少一次, 消息绝不会丢, 但可能会重复; Exactly once, 精确一次, 每条消息肯定会被传输一次且仅一次。大多数消息中间件一般只提供At most once和Atleast once两种传输保障, 第三种一般很难做到, 因此消息幂等性也很难保证。Kafka自0.11版本开始引入了幂等性和事务, Kafka的幂等性是指单个生产者对于单分区单会话的幂等,而事务可以保证原子性地写入多个分区,即写入多个分区的消息要么全部成功, 要么全部回滚, 这两个功能加起来可以让Kafka具备EOS(Exactly Once Semantic) 的能力。不过如果要考虑全局的幂等, 那么还需要从上下游各方面综合考虑,即关联业务层面,幂等处理本身也是业务层面需要考虑的重要议题。以下游消费者层面为例,有可能消费者消费完一条消息之后没有来得及确认消息就发生异常,等到恢复之后又得重新消费原来消费过的那条消息,那么这种类型的消息幂等是无法由消息中间件层面来保证的。如果要保证全局的幂等,那么需要引入更多的外部资源来保证,比如以订单号作为唯一性标识,并且在下游设置一个去重表。
事务性消息:事务本身是一个并不陌生的词汇, 事务是由事务开始(Begin Transaction) 和事务结束(End Transaction) 之间执行的全体操作组成的。支持事务的消息中间件并不在少数,Kafka和Rabbit MQ都支持, 不过此两者的事务是指生产者发送消息的事务, 要么发送成功, 要么发送失败。消息中间件可以作为用来实现分布式事务的一种手段,但其本身并不提供全局分布式事务的功能。
下面是对Kafka与Rabbit MQ功能的总结性对比及补充说明, 如表11-4所示。
功能维度是消息中间件选型中的一个重要的参考维度,但这并不是唯一的维度。有时候性能比功能还重要,况且性能和功能很多时候是相悖的,“鱼和熊掌不可兼得”。Kafka在开启幂等、事务功能的时候会使其性能降低, Rabbit MQ在开启rabbit mq_tracing插件的时候也会极大地影响其性能。消息中间件的性能一般是指其吞吐量, 虽然从功能维度上来说, Rabbit MQ的优势要大于Kafka, 但是Kafka的吞吐量要比Rabbit MQ高出1至2个数量级, 一般Rabbit MQ的单机QPS在万级别之内, 而Kafka的单机QPS可以维持在十万级别, 甚至可以达到百万级。
消息中间件的吞吐量始终会受到硬件层面的限制。就以网卡带宽为例,如果单机单网卡的带宽为1Gbps, 如果要达到百万级的吞吐, 那么消息体大小不得超过(1GB/8) /1000000, 约等于134B。换句话说,如果消息体大小超过134B,那么就不可能达到百万级别的吞吐。这种计算方式同样适用于内存和磁盘。
时延作为性能维度的一个重要指标,却往往在消息中间件领域被忽视,因为一般使用消息中间件的场景对时效性的要求并不是很高, 如果要求时效性完全可以采用RPC的方式实现。消息中间件具备消息堆积的能力,消息堆积越大也就意味着端到端的时延就越长,与此同时延时队列也是某些消息中间件的一大特色。那么为什么还要关注消息中间件的时延问题呢?消息中间件能够解耦系统,一个时延较低的消息中间件可以让上游生产者发送消息之后迅速返回,也可以让消费者更加快速地获取消息,在没有堆积的情况下可以让整体上下游的应用之间的级联动作更高效,虽然不建议在时效性很高的场景下使用消息中间件,但是如果使用的消息中间件在时延的性能方面比较优秀,那么对于整体系统的性能将会是一个不小的提升。
消息丢失是使用消息中间件时不得不面对的一个痛点,其背后的消息可靠性也是衡量消息中间件好坏的一个关键因素。尤其是在金融支付领域,消息可靠性尤为重要。然而说到可靠性必然要说到可用性,注意这两者之间的区别,消息中间件的可靠性是指对消息不丢失的保障程度;而消息中间件的可用性是指无故障运行的时间百分比,通常用几个9来衡量。
从狭义的角度来说,分布式系统架构是一致性协议理论的应用实现,对消息可靠性和可用性而言也可以追溯到消息中间件背后的一致性协议。Kafka采用的是类似PacificA的一致性协议, 通过IS R(In-Sync-Replica) 来保证多副本之间的同步, 并且支持强一致性语义(通过acks实现) 。对应的Rabbit MQ是通过镜像环形队列实现多副本及强一致性语义的。多副本可以保证在master节点宕机异常之后可以提升slave作为新的master而继续提供服务来保障可用性。
就目前而言, 在金融支付领域使用Rabbit MQ居多, 而在日志处理、大数据等方面Kafka使用居多, 随着Rabbit MQ性能的不断提升和Kafka可靠性的进一步增强, 相信彼此都能在以前不擅长的领域分得一杯羹。
这里还要提及的一方面是扩展能力,这里狭隘地将其归纳到可用性这一维度,消息中间件的扩展能力能够增强可用能力及范围, 比如前面提到的Rabbit MQ支持多种消息协议, 这就是基于其插件化的扩展实现。从集群部署上来讲, 归功于Kafka的水平扩展能力, 基本上可以达到线性容量提升的水平, 在Linked In实践介绍中就提及了部署超过千台设备的Kafka集群。
在消息中间件的使用过程中,难免会出现各种各样的异常情况,有客户端的,也有服务端的,那么怎样及时有效地进行监测及修复呢?业务线流量有峰值、低谷,尤其是电商领域,那么如何进行有效的容量评估,尤其是在大促期间?脚踢电源、网线被挖等事件层出不穷,如何有效地实现异地多活?这些都离不开消息中间件的衍生产品——运维管理。
运维管理也可以进一步细分,比如申请、审核、监控、告警、管理、容灾、部署等。
监控也可以做好流量统计与流量评估工作,一般申请、审核与公司内部系统交融性较大,不适
合使用开源类的产品。
申请、审核很好理解,在源头对资源进行管控,既可以有效校正应用方的使用规范,配和监控、告警也比较好理解,对消息中间件的使用进行全方位的监控,既可以为系统提供基准数据,也可以在检测到异常的情况时配合告警,以便运维、开发人员迅速介入。除了一般的监控项(比如硬件、GC等),对于消息中间件还需要关注端到端时延、消息审计、消息堆积等方面。对Rabbit MQ而言, 最正统的监控管理工具莫过于rabbit mq_management插件了, 社区内还有App Dynamics、Collect d、Data Dog、Ganglia、Munin、Nagios、New Relic、Prometheus、Zenoss等多种优秀的产品。Kafka在此方面也毫不逊色, 比如Kafka Manager、Kafka Monitor、Kafka Offset Monitor、Burrow、Chaperone、Confluent Control Center等产品, 尤其是Cruise, 还可以提供自动化运维的功能。
无论扩容、降级、版本升级、集群节点部署,还是故障处理,都离不开管理工具的应用,一个配套完备的管理工具集可以在遇到变更时做到事半功倍。故障可大可小,一般是一些应用异常,也可以是机器掉电、网络异常、磁盘损坏等单机故障,这些故障单机房内的多副本足以应付。如果是机房故障, 那么就涉及异地容灾了, 关键点在于如何有效地进行数据复制。对Kafka而言, 可以参考Mirror Marker、uReplicator等产品, 而Rabbit MQ可以参考Federation和Shovel。
对于目前流行的编程语言而言, 如Java、Python, 如果在使用过程中遇到了一些异常, 基本上可以通过搜索引擎的帮助来解决问题,因为一个产品用的人越多,踩过的“坑”也就越多,对应的解决方案也就越多。对于消息中间件同样适用,如果你选择了一种“生僻”的消息中间件,可能在某些方面得心应手,但是版本更新缓慢,在遇到棘手问题时也难以得到社区的支持而越陷越深;相反如果你选择了一种“流行”的消息中间件,其更新力度大,不仅可以迅速弥补之前的不足,而且也能顺应技术的快速发展来变更一些新的功能,这样可以让你以“站在巨人的肩膀上”。在运维管理维度我们提及了Kafka和Rabbit MQ都有一系列开源的监控管理产品,这些正是得益于其社区及生态的迅猛发展。
在进行消息中间件选型之前可以先问自己一个问题:是否真的需要一个消息中间件?在搞清楚这个问题之后,还可以继续问自己一个问题:是否需要自己维护一套消息中间件?很多初创型公司为了节省成本会选择直接购买消息中间件有关的云服务,自己只需要关注收/发消息即可,其余的都可以外包出去。
很多人面对消息中间件时会有一种自研的冲动, 你完全可以对Java中的ArrayBlockingQueue做一个简单的封装, 也可以基于文件、数据库、Redis等底层存储封装而形成一个消息中间件。消息中间件作为一个基础组件并没有想象中的那么简单,其背后还需要配套的管理来运维整个生态的产品集。自研还有会交接问题,如果文档不齐全、运作不规范将会带给新人带来噩梦般的体验。是否真的有自研的必要?如果不是KPI的压迫可以先考虑以下2个问题:
(1)目前市面上的消息中间件是否都无法满足目前的业务需求?
(2)团队是否有足够的能力、人力、财力和精力来支持自研?
很多人在进行消息中间件选型时会参考网络上的很多对比类的文章,但是其专业性、严谨性及其立场都有待考证,需要带着怀疑的态度去审视这些文章。比如有些文章会在没有任何限定条件及场景的情况下直接定义某款消息中间件最好,还有些文章没有指明消息中间件版本及测试环境就来做功能和性能对比分析,诸如此类的文章都可以弃之。
消息中间件选型犹如小马过河,选择合适的才最重要,这需要贴合自身的业务需求,技术服务于业务,大体上可以根据上一节提及的功能、性能等6个维度来一一进行筛选。更深层次的抉择在于你能否掌握其“魂”,了解其根本对于自己能够“对症下药”选择合适的消息中间件尤为重要。
消息中间件选型切忌一味地追求性能或功能,性能可以优化,功能可以二次开发。如果要在功能和性能方面做一个抉择,那么首选性能,因为总体上来说性能优化的空间没有功能扩展的空间大。然而对于长期发展而言,生态又比性能及功能都要重要。
很多时候,在可靠性方面也容易存在一个误区:想要找到一个产品来保证消息的绝对可靠,很不幸的是,世界上没有绝对的东西,只能说尽量趋于完美。想要尽可能保障消息的可靠性也并非单单靠消息中间件本身,还要依赖于上下游,需要从生产端、服务端和消费端这3个维度去努力保证。
消息中间件选型还有一个考量标准就是尽量贴合团队自身的技术栈体系,虽然说没有蹩脚的消息中间件, 只有蹩脚的程序员, 但是让一个C栈的团队去深挖Phx Queue, 总比去深挖Scala编写的Kafka要容易得多。
消息中间件大道至简:一发一存一消费,没有最好的消息中间件,只有最合适的消息中间件。