前言
随着大数据产品的日渐丰富以及数据应用场景需求的增加,TDBank作为腾讯大数据平台的数据接入环节的位置也越发显得重要(见下图)。截止目前为止TDBank日均接入数据已经超过2W亿条每天(约600TB/天),并且数据量还在持续不断上升。Tube 作为整个数据接入体系的存储层发挥着重要作用。Tube作为一个面向高吞吐高性能的分布式消息中间件,其性能及稳定性在万亿级数据体量下经受住了考验。
但对于一些高价值、高敏感度的数据时,我们亟需一个高可靠高可用的消息中间件。(Tube在极端场景下,比如物理宕机无法恢复的情况下可能会造成部分数据不可用。Tube的高性能、高吞吐得益于其批量刷盘,数据驻留于内存一段时间可能存在丢失的风险,这也是其高吞吐所需付出的代价)。因此我们设计了新一代消息系统Hippo以满足具有高可靠高可用应用场景的业务需求,用以支撑广告计费,交易流水等高价值数据的业务。
Tdbank作为数据接入环节的位置
消息中间件的位置
消息中间件优势
屏蔽异构平台的细节:发送方、接收方系统之间不需要了解双方,只需认识消息。
异步:消息堆积能力;发送方接收方不需同时在线,发送方接收方不需同时扩容(削峰)。
解耦:防止引入过多的API给系统的稳定性带来风险;调用方使用不当会给被调用方系统造成压力,被调用方处理不当会降低调用方系统的响应能力。
复用:一次发送多次消费。
传统消息系统数据丢失风险点
消息系统最大的作用是解藕系统之间的依赖及异步化各系统间的调用。作为系统间消息存储和转发环节,其可靠性对于某些有着精确要求(消息一条都不能丢失)的系统来说显得特别重要。在探讨Hippo的实现之前先来窥探下消息从发送到被存储再到被消费的整个环节存在数据丢失风险的场景。
数据的发送:数据从生产端Producer发往存储端Broker的过程
成功:最常见的场景,broker端收到消息并存储成功,给producer返回成功响应且producer收到成功响应信息。
失败:broker端由于繁忙处理不过来直接向producer响应失败,且producer端收到失败响应信息。
超时:对于这种不确定的场景(网络波动、系统异常、连接异常等)所带来的超时则相对复杂。producer收到超时异常可能由如下几种场景导致,可能是发送过程中连接发生异常,数据在到达broker端之前就丢失了,也可能数据到达了broker端且在broker端存储成功了但是在给producer返回结果时发生了超时或网络异常。
从以上三种场景可知,前两种producer收到确定的结果都很好处理,失败做相应的记录或者重发即可。但是对于超时则无计可施,这是网络方面的一个老大难题,超时对于分布式系统来说就简直就是一个噩梦,客户端没有足够的信息判断broker端到底是否处理成功。假设为了保证可靠我们对于所有超时的场景都统一当失败处理进行消息的重发,那么就有可能导致broker端存储到重复的消息,这又引发了对数据进行去重的问题,对于分布式系统做去重就需要引入一个全局的校验节点,毫无疑问这会让系统的整体性能大打折扣。关于重复的问题后续会有进一步的说明,在此不再赘诉。
数据的存储:数据在Broker端驻留期间,可能造成丢失或损坏
不同的存储级别带来的可靠级别会有很大差别。对于在内存中的数据则无法保证其可靠,断电则意味数据丢失。对于持久化于磁盘中的数据也可能由于出现磁道损坏等硬件故障导致数据的丢失。因此冗余就成了系统保证数据可靠存储的必要手段。当然为了保证可靠而进行冗余会引入一致性问题,不同的一致性级别对系统的性能也会有不同程度的影响,一致性和可用性(性能)就好比是天秤的两端,必须对其作出权衡和取舍。
数据的消费:数据从Broker端到消费端Consumer的过程
网络异常:数据在broker端被成功的读取,但是发往consumer端的过程中网络异常导致数据丢失,如果broker端已经更新了消费偏移量将导致这部分数据不能再次被消费到从而造成数据丢失。
消费端异常:数据从broker端发往consumer端的过程没有异常,但是数据成功抵达consumer端后进行消费过程中consumer突然故障导致消费行为没有执行完毕从而造成数据丢失。
从以上场景可知消费过程中出现数据丢失的风险主要有两点,一是网络传输过程,另外一个是数据成功抵达consumer端之后由于consumer端的不稳定导致数据没有被完全消费。为了避免数据在没有被消费而面临丢失的风险,需要对每一批数据的消费情况进行确认,只有在消费被确认之后broker端才会更新数据消费偏移量,以此来保证数据消费的可靠性。
系统逻辑结构图
系统交互图
Hippo系统存在四种角色,分别为生产者(producer)、 消费者(consumer)、存储层(broker)、中心控制节点(controller)
Controller :
以组的形势存在,三台controller一主两备组成一个组(主备controller存在心跳检测以便在主故障的时候能够自动failover)承担着整个系统节点数据的收集、状态的共享及事件的分发角色。提供控制台界面,根据当前收集到的正常运行的broker节点信息,可以指定给某个特定的broker组下发topic及queue添加事件。
Broker:
以组的形势存在,三台broker一主两备组成一个组,由主broker向controller定期汇报心跳以告知controller当前组的存活状态,心跳携带当前组所管理的topic及queue信息。数据在broker以多副本的方式存储,Master broker为数据写入入口,并把数据实时同步给同组的两台Slave broker,主备broker之间存在心跳检测功能,一旦Slave broker发现Master broker故障或者收不到Master broker的心跳那么两台Slave broker之间会从新发起一次选举以产生新的Master broker,这个过程完全不用人工介入系统自动切换。因此在broker端不存在单点情况,数据冗余存储在不同的物理机器中,即使存在机器宕机或磁盘损坏的情况也不影响系统可靠对外提供服务。
Producer:
轮询发送:向controller发布某个topic的信息,controller返回相应topic所在的所有broker组对应的IP端口及queue信息。producer轮询所获取的broker组信息列表发送消息并保持与controller的心跳,以便在broker组存在变更时,能够通过controller及时获取到最新的broker组信息。
Consumer:
负载均衡:每个consumer都隶属于一个消费组,向controller订阅某个topic的消息,controller除了返回相应topic对应的所有broker组信息列表之外还会返回与当前消费者处于同一个组的其它消费者信息列表,当前消费者获取到这两部分信息之后会进行排序然后按照固定的算法进行负载均衡以确定每个消费者具体消费哪个队列分区。同时每个consumer都会定期的向controller上报心跳,一旦消费组有节点数量的变更或broker组存在变更,controller都会及时的通过心跳响应返回给当前组所有存活的consumer节点以进行新一轮的负载均衡。
消费确认:
consumer进行消费的过程中对队列分区是以独占的形式存在的,即一个队列在一个消费组中只能被一个消费者占有并消费。为了保证消费的可靠对于每次拉取的数据,都需要consumer端在消费完成之后进行一次确认,否则下次拉取还是从原来的偏移量开始。
限时锁定:
为了使某个consumer宕机其占有的队列分区能够顺利的释放并被其他consumer获取到,需要在每个消费者拉取数据与确认回调之间设置一个超时时间,一旦超过这个时间还没确认,那么队列自动解锁,解锁之后的队列最终能够被别的存活消费者占有并消费。
Consumer与Queue的关系
消费者数量 = 队列数量
消费者数量 > 队列数量
消费者数量 < 队列数量
关于数据可靠性
我们考虑以下几个方面:
消息存储可靠性:WAL+持久化;数据存储多副本;存储节点自动failover;
消息传输可靠性:ACK机制;数据CRC校验;
消息投递可靠性:producer->broker 数据存储后才返回成功确认;broker->consumer 数据处理完成后需进行确认
服务(Qos)级别:不能丢消息;At-least-once 可能会有重复*;极端情况下通过客户端进行数据去重;
关于数据可用性
Broker宕机或硬件故障
对于broker组只要每个组内依旧有超过半数的节点存活,那么这个broker组便能继续对外提供服务。如果Slave Broker宕掉不会对生产消费端有任何影响,如果是Master Broker宕机那么会自动的从两个slave中选出一个新的master。对于宕掉的机器通过监控手段发现后人工重启便会自动的同步宕机过程中滞后于同组节点的数据,直到追上最新数据为止。
Controller宕机或硬件故障
对于controller与broker的情况类似,但是即使controller整个组都同时宕机也不会对当前的系统造成不可用的情况,当前系统将会继续维持目前的平衡状态,任何的broker组变更,consumer变更都不会再次触发系统的均衡,直到controller恢复为止。
存储层容错特点:
集群内部过半的服务器写成功才给客户端响应成功;
服务故障自动切换对用户透明;
宕机恢复后自动同步数据;
机器、磁盘故障无法恢复情况,提供运维工具将故障机器从当前组移出加入新机器即可;
支持跨IDC部署(异地容灾、Network Partitions Tolerance)
关于性能
数据写入过程剖析
写入性能
支持多组复制流部署提升系统吞吐量并最大化利用物理机器的资源;
消费性能
Batch fetch:可自定义批量拉取的条数,通过一次拉取多条消息以减少网络交互的次数提升消费端性能。
数据优先从内存获取,只有缓存没有命中的情况才访问磁盘(LRU算法进行内存管理,数据无积压情况基本上是从内存消费性能可观)。
多队列并行消费提高性能:对于单个队列数据的发送是并行的,但出于保证数据互斥消费(只被一个消费者消费)的需要消费必须是互斥串行的,并行发送串行消费在发送量很大的场景下就会造成消费端处理不过来从而造成消费滞后,为了避免消费滞后可以通过将数据分散到多个队列中去,通过多队列并行消费以提升消费端性能。
关于稳定性
线程隔离
由于不同接口操作存在特征差异,CPU密集型操作通常能够迅速释放线程,对于IO密型操作则可能会导致线程长时间由于等待IO而被占用,为了保证系统某些关键路径不被流控或由于没有空闲线程而无法得到执行,采用不同线程池隔离不同特征的接口,防止接口间相互干扰,保证系统稳定运行。
流量控制
为了保证系统在高水位运行时不被压垮,需要对系统的整体读写流量做相应限制,采用有界队列的方式积压由于当前系统繁忙而不能马上处理的请求,队列大小可配置,根据系统每秒吞吐量设置相应的阀值。一旦队列没有可用空间(线程处理不过来,请求已经出现积压)为了避免对系统造成进一步的压力broker端此时会拒绝继续服务而直接给请求端响应失败以达到流控的目的。
集群隔离
根据业务的特征分集群部署隔离,防止业务之间相互干扰
关于扩展(Scale out)
producer、consumer都支持集群部署模式
broker组支持动态横向扩展
关于数据重复
在极端异常情况下可能导致数据重复的场景有两个,一是生产端发送数据时出现超时,这时重发数据可能导致broker端存储到相同的数据。第二种场景则是在consumer消费时成功拉取到数据且已消费完成但在提交的瞬间consumer宕机了,这时当前被消费的队列就可能由于负载均衡而被其他consumer占有并拉取到被之前consumer消费完但未提交的数据。对于第一种场景的解决方案是在服务端进行去重。对于第二种场景则需要在consumer端定义去重接口由业务方自己实现去重逻辑,因为只有consumer端知道数据的消费情况。这两种方案都会给系统引入更大的复杂性和增加一定的性能损耗。由于网络的不可确定性(经典的拜占庭将军问题),消费端数据重复在异常情况下是无法避免的问题,因此consumer进行消费时需确保消费逻辑的幂等性。
服务端去重规划:
broker端在特定消息条数范围之内进行去重,producer生产的每条数据都会携带一个去重特征值用于服务端缓存并进行去重校验,由于producer生产数据是轮询的方式,因此问题关键在于如何将超时重发的数据发往同一个broker端,通常的做法是采用hash方式,但hash的弊端也很明显,其问题在于无法应对hash空间变化的场景,一旦broker缩容或扩容hash定位就会失效。可以通过在消息发送时记录当前消息的发送目标路径并对于失败和超时两种场景区别处理,对于超时则给消息打上超时标记对于失败则不做任何标记,在重试时通过超时标记来预判采用之前记录的发送路径还是轮询的方式以达到去重的目的。服务端去重只能保证接收到发送端的数据不重复存储,从服务端投递到消费端的数据的去重依旧必须由消费端自己保证。
关于顺序性
局部有序:
数据在多个队列之间是按发送时间有序的,即每个队列的数据都是在相似的时间间隔范围上按时间递增分布的,局部有序优点就是支持多队列能够提供更高的发送性能,适用于消费端对数据的消费没有绝对要求但是又不能在数据局部时间差距太大的场景。
绝对有序:
数据落地到单队列上,发送端只能用单线程同步发送消息并且每发送下一条消息之前都需要保证已收上一条消息的成功返回确认,由于是单队列单线程同步发送因此吞吐量会有所下降,适用于发送峰值不高且对数据消费有绝对顺序要求的场景。
绝对有序性能提升规划:
参照TCP协议的实现原理,TCP为了保证数据包的顺序为每个数据包的发送颁发一个顺序且唯一标识当前数据包的ID,为了最大化点利用网络资源并提升性能,采用批量发送的方式而不是逐一发送确认的方式,利用每个数据包发送来回的窗口时间迭代发送多个数据包,最终在服务端进行组包排序。对于丢包的情况,发送端对没有收到服务端回应的包在一定的超时时间之后进行重发。同理消息的发送可以采用相同的方式,服务端只会持久化消息ID连续的消息,对于有间断的情况则需要等待相应空缺的消息ID所对应的消息(可能是网络延迟抵达晚了,可能是丢包了在等待超时机制触发重试)抵达后再进行持久化。这种方式能够解决上述绝对有序的单线程同步发送的低性能场景,通过异步发送的方式达到提升发送吞吐量的目的。