1. 前言
1.1 什么是消息中间件
在大型互联网或企业级环境中,由于系统规模庞大、业务复杂,系统间通信的需求则会变得越来越频繁。通常的系统间通信的方式主要是RPC,但这种方式不但会大大增加系统间的耦合度,并且在很多场景下,也并不能满足实际需求,而消息中间件则是承担这项任务的一个非常好的选择。
1.2 常见应用场景
在互联网行业中,不管是社区、电商、搜索还是其他领域,消息系统都有着广泛的应用,比如服务解耦、消息通知、数据分发、异构数据源同步等等;如下图所示:
场景一、业务系统中的服务解耦和消息通知
场景二、异构数据源之间的数据同步
在实际开发中,我们还会发现更多可以或者必须使用消息中间件的场景,恰当的将消息系统运用到我们的架构设计当中,将会使系统间的耦合性大大降低,实现方案由复杂变为简单,稳定性也会大大增强。
1.3 大数据环境下的消息中间件
在大数据环境下,最突出的一个特点就是数据量非常大,对性能要求非常高。以腾讯为例,七大事业群上千个产品、系统每天都要产生大量的数据,尤其是像QQ、微信这样拥有广泛用户的产品;其中很多都要通过大数据平台来进行处理。
在实际开发中,我们发现,面对前端千差万别的业务数据,后端各式各样处理需求以及越来越大的数据量,亟需一个高性能的中间系统,来对前端数据进行统一的采集、存储,同时能够按需分发给后端各个处理平台。
Tube系统便是为了解决这种需求场景而诞生的。
接下来,我们就将介绍一下分布式消息中间件系统的主要设计原则,以及Tube系统在实现上采用的一些方案和策略。
2. Tube系统概述
2.1 什么是Tube
Tube是一个分布式消息中间件系统,目前主要用于大数据接入平台TDBank当中,作为其核心的数据存储和分发系统;Tube结构如下图所示:
Tube的系统架构思想源于Apache Kafka,在实现上,则完全采取了自己的方式,并且使用了更加优化的分区管理和分配机制和全新节点通讯流程,同时还基于Netty和Google Protobuf自主开发了高性能的底层RPC通讯模块;这些实现使得Tube在保证实时性和一致性的前提下,具有了更高的吞吐能力,更适合作为处理海量数据的消息中间件;
2.2 功能与特性
Tube实现JMS规范中的发布/订阅模型,且发布端、订阅端的可使用集群模式,同时支持发布端和消费端的负载均衡;消费端以Group分组,同一分组下的消费者消费同一份数据,且可以重复消费多次;不同分组之间相互独立、互不影响。通常情况下,Tube可以做到消息的“零误差”,即不丢失、不重复、不损坏;极端场景下,也仅会丢失或重复少量的消息。
在性能方面,单个Tube集群可稳定承载10w以上的客户端(生产者/消费者),单台broker并发写入可达10w TPS,使用1k大小的消息包进行测试(机器配置:12核2.1GHz CPU带超线程、64G内存,Raid 5级磁盘阵列)时,可跑满千兆网卡带宽;Tube在绝大多数场景下可以将消息的延迟限制在毫秒级,最坏场景下也可保证秒级的延迟(10s以内,视具体参数配置而定);
2.3 架构设计
Tube系统主要有三部分组成:客户端(producer&consumer)、服务端(broker)和中心节点;其中,客户端包括Producer和Consumer API,主要用来生产和消费消息数据;服务端则是用来接受Topic的发布和订阅,并存储消息数据;而中心节点主要为Master节点,用来收集和监控整个集群的状态。
另外,目前在生产环境部署的Tube集群还使用了Zookeeper,主要用来保存Consumer的消费位置和Master HA的主备选举,不过Zookeeper的存在主要是历史遗留,全新的Tube系统设计是可以摆脱对Zookeeper依赖的。
当前Tube系统的整体架构如下图所示:
其中,Broker负责数据的存储,为集群部署模式,Producer为生产者,它将消息发送到broker集群,Consumer为消费者,从Broker读取消息到自己的本地处理;Producer和Consumer可以是单机模式,也可以是集群模式;Master为中控节点,负责收集整个集群的状态信息,并控制消费者的分区消费平衡。
Tube采用了Kafka的分区设计思想,其各个节点主要交互流程如下:
a. 消息按照内容分为多类,每一类消息有一个Topic,每个Topic可以包含多个分区,分布在若干Broker上;Producer可以生产某一个或多个Topic数据,此后Consumer可以指定这些Topic的数据进行消费;
b. Broker向Master汇报自身信息,包括自身id、状态以及提供哪些Topic的发布和订阅服务,每个Topic下包含多少分区;
c. Producer向Master通报要发布的Topic名称,Master给Producer返回哪些Broker可以接收这些Topic的数据;此后Producer可直接向这些Broker生产数据;
d. Consumer向Master通报要订阅的Topic名称,Master给Consumer返回可以从哪些Broker获取数据;此后Consumer则直接向这些Broker拉取数据;不同业务Consumer通过Group来区分,同一个Group下的Consumer消费同一份数据,每个Consumer会负责消费其中的一部分分区,Consumer之间消费的分区互不重叠,Consumer和分区的消费对应关系由Master统一分配;不同Group下的Consumer消费互不影响,既可以消费相同Topic的数据,也可以消费不同Topic的数据;
e. 集群节点间通过心跳和Master保持状态同步,当状态发生变化时,Master会生成相应事件并负责通知相关节点;
f. 为了避免中心节点的单点存在,Master采用主备模式,并通过Zookeeper来进行选举。
3. 经典消息模型
JMS规范中定义了两种消息系统的模型,一个是点对点(Point to Point,简称PTP或P2P)模型,另一种是发布/订阅(Publish/Subscribe,简称P/S)模型,下面分别进行简单介绍。
3.1 点对点模型
在典型的点对点模型中,主要包含三个要素,消息的发送者(Sender),消息的接收者(Receiver)和存储消息的队列(Message Queen);发送者首先将消息发送到队列,然后接收者从队列读取消息,其流程如下所示:
P2P是个简单的“一对一”消息模型,Sender发送消息到指定队列,Receiver从指定队列读取消息,虽然Receiver从物理上讲也可以有多个,但是逻辑上还是一个单一的整体,即一条消息只能被其中的一个Receiver处理。
3.2 发布订阅模型
发布/订阅模型同样是由三部分组成,即消息的生产者(Producer)、消息的消费者(Consumer)以及消息主题(Topic);Producer通过发布(Publish)操作,将消息发布到指定的Topic下,而Consumer则通过订阅(Subscribe)操作来消费Topic下的消息;其关系如下图所示:
与P2P模型不同的是,P/S模型是一对多或者是多对多的关系,即Producer和Consumer都可以有很多,二者通过Topic联系到一起;
在P/S模型中,Producer将消息发送到Topic之后,就完全不关心之后发生的事情了;无论是哪些Consumer消费了这个消息,消费到什么位置了,都与Producer无关;而同样Consumer也不关系自己消费的消息到底是哪个Producer发送的,它只关心消息的内容。
除了以上差异外,两种模型其实还有很多其他细节差异,这里不再一一赘述。
3.3 Push vs Pull
在消息系统的设计中,另一个需要权衡的就是Push方式和Pull方式的选择。
熟悉微博或社区产品的同学可能都比较熟悉这两个概念;比如在微博中,一个大V可能有成百上千万的粉丝,一个“无聊”的粉丝也可能关注几百上千个大V或朋友,那么这就带来一个问题,当一个会员发一条微博消息时,应该如何将该消息的内容传递到他的粉丝那里?同样,当一个用户刷新微博页面的时候,应该如何获取他所关注的人的信息?
由于体量的原因,这里的信息传递即会存在一个Push和Pull的选择问题。
所谓Push模型,即是当Producer发出的消息到达后,服务端马上将这条消息投递给Consumer;而Pull则是服务端收到这条消息后什么也不做,只是等着Consumer主动到自己这里来读,即Consumer这里有一个“拉取”的动作。
选择推(Push)方式还是拉(Pull)方式是所有大型消息系统中必须要面对的问题,因为这直接关乎到整个系统的使用场景,吞吐量、实时性以及性能问题;这里边主要有如下几个问题需要探讨。
存在多个(组)Consumer
在P/S模式下的消息系统中,一份消息往往会被多个(组)消费者消费,这是一个很常见的场景。采用Push模型的情况下,服务端需要记录每个Consumer对Topic的订阅关系,以便当Topic下有消息到达时,能够及时将其推送给对应的Consumer,这个绝大多数时候不是问题,但是一旦当Consumer的数量很大同时消息的生产又比较快时,推送操作便会成为服务器的一个沉重负担,如果要接连不断的同时向成千上万个Consumer推送消息,还要处理(部分)推送失败的情况,服务器甚至可能崩溃掉,这种场景下Pull模式显然更具优势。
Consumer同时订阅的多个Topic
如果一个Consumer同时订阅了多个Topic,在采用Pull模式时,一旦有Consumer来拉取数据,服务端都需要查询每个Topic下是否新的消息,以决定是否给Consumer返回数据,而如果Consumer订阅的Topic非常多,服务器就需要耗费大量的精力来做遍历查询,即使其中的90%的Topic下都没有新的数据产生;这种情况下,虽然有很多折衷的方案可以使用,但单就模式来讲,采用Push显然比单纯的Pull更合适。
部分或全部Consumer不在线;
在消息系统中,Producer和Consumer是完全解耦的,Producer发送消息时,并不要求Consumer一定要在线,对于Consumer也是同样的道理,这也是消息通信区别于RPC通信的主要特点;但是对于Consumer不在线的情况,却有很多值得讨论的场景;
首先,在Consumer偶然宕机或下线的情况下,Producer的生产是可以不受影响的,当Consumer上线后,可以继续之前的消费,此时消息数据不会丢失;但是如果Consumer长期宕机或是由于机器故障无法再次启动时,就会出现问题,即服务端需不需要为Consumer保留数据,以及保留多久的数据等等;
在采用Push方式时,因为无法预知Consumer的宕机或下线是短暂的还是持久的,如果一直为该Consumer保留自宕机开始的所有历史消息,那么即便其他所有的Consumer都已经消费完成,数据也无法清理掉,随着时间的积累,队列的长度会越来越大,此时无论消息是暂存于内存还是持久化到磁盘上(采用Push模型的系统,一般都是将消息队列维护于内存中,以保证推送的性能和实时性,这一点会在后边详细讨论),都将对服务端造成巨大压力,甚至可能影响到其他Consumer的正常消费,尤其当消息的生产速率非常快时更是如此;但是如果不保留数据,那么等该Consumer再次起来时,则要面对丢失数据的问题;
折中的方案貌似是给数据设定一个超时时间,当Consumer宕机时间超过这个阈值时,则清理数据;但这个时间阈值也并太容易确定;
在采用Pull模型时,情况会有所改善;服务端不再关心Consumer的状态,而是采取“你来了我才服务”的方式,Consumer是否能够及时消费数据,服务端不会做任何保证。
Producer的速率大于Consumer的速率;
对于Producer速率大于Consumer速率的情况,有两种可能性需要讨论,第一种是Producer本身的效率就要比Consumer高(比如说,Consumer端处理消息的业务逻辑可能很复杂,或者涉及到磁盘、网络等I/O操作);另一种则是Consumer出现故障,导致短暂时间内无法消费或消费不畅。
Push方式由于无法得知当前Consumer的状态,所以只要有数据产生,便会不断地进行推送,在以上两种情况下时,可能会导致Consumer的负载进一步加重,甚至是崩溃,除非Consumer有合适的反馈机制能够让服务端知道自己的状况。而采取Pull的方式问题就简单了许多,由于Consumer是主动到服务端拉取数据,此时只需要降低自己访问频率就好了。
消息的实时性
采用Push的方式时,一旦消息达到,服务端即可马上将其推送给服务端,这种方式的实时性显然是非常好的;而采用Pull方式时,为了不给服务端造成压力(尤其是当数据量不足时,不停的轮询显得毫无意义),需要控制好自己轮询的间隔时间,但这必然会给实时性带来一定的影响。
4. 关于消息一致性的保证
在消息的一致性模型中,主要包含三个方面的内容,分别是消息的可靠性,消息的顺序性和消息重复,下面分别讨论。
4.1 消息的可靠性
首先可靠性必须是第一位的,一个消息系统如果无法提供一个可靠的数据服务,那它几乎没有存在的价值。但是需要注意的是,在有些业务中,数据必须绝对的可靠,比如交易、银行、证券等领域;但是在有些场景中,只需要做到基本可靠即可;
在互联网大数据场景下,由于体量非常巨大,而数据的使用又偏向统计和分析,所以其对数据的可靠性要求并不像交易、金融等业务系统那么高,可以在一定程度上容忍少量的数据错误或丢失,而这一点也是Tube实现高吞吐量的一个前提。
目前Tube系统主要在两个地方可能会有数据丢失,第一是Tube采取了Consumer信任模型,即数据一旦被Consumer拉取到本地,就默认会消费成功,如果Consumer在实际消费的过程中出现错误(Tube可以保证消息数据的正确性,所以这种错误一般是由业务方消费逻辑或代码问题导致),则Tube并不负责恢复,所以这里存在一个丢失数据的风险;再一个就是由于操作系统pagecache的利用,服务器断电或宕机而可能带来的数据丢失。
4.2 顺序的消息性
由于Tube沿用了Kafka的分区设计思想,而分区的数据消费之间是没有先后顺序关系的,而且Tube支持消息的异步方式发送;在这种方式下,网络并不能保证先发送的消息就一定会先到达服务端,所以Tube一般不提供顺序性的保证;
目前系统结构下,只有使用单一分区并且采用同步发送接口的情况下,才可以实现消息的顺序写入和读取;但是这样的使用方式,其性能和吞吐则会大打折扣,完全背离了Tube服务于海量消息传输的初衷。
5. 实时性与吞吐
关于实时性和吞吐,我们在前边Push和Pull方式选择中已经有过一些讨论;一般来说,在分布式系统中,实时性和吞吐是相悖的,通常情况下,二者只能偏于其一。
一个消息系统能达到什么样的吞吐量、性能和实时性,主要取决于三个部分:客户端消费模型、服务端存储模型以及底层通信模型。其中,客户端消费模型主要是指前面的Push和Pull模型,服务端存储模型和底层通信模型我们将在接下来的章节进行详细介绍。