在当下众多互联网应用场景下,实时数据产生的速率根据时间的变化会有着翻天覆地的变化。我们既可能面对诸如外卖订单、住房成交量、双十一订单这些场景,其数据量有周期性且在局部的时间内会有可预知的突发的数据峰值;也可能面对微博热搜、路况事故信息这一类无法预知的突发的数据量激增。
以上特性通俗来讲,就是流量数据到达量有峰有谷,且可能不可预知。正因如此,Pravega作为一款流存储的产品,必须能够应对瞬时的数据洪峰,做到“削峰填谷”,让系统自动地伴随数据到达速率的变化而伸缩,既能够在数据峰值时进行扩容提升瞬时处理能力,又能够在数据谷值时进行缩容节省运行成本,而读写客户端无需额外进行调整。
这一特性对于面向企业开发产品尤其重要,Devops开销在企业中都会被归入产品TCO(Total Cost of Ownership) , 所以产品自身的动态自适应能力将会是必备条件,这也是回应我们在系列文章第二篇中提到的三大挑战最后一条。
下面我们就详细讲述Pravega动态弹性伸缩特性的实现和应用实例。
对于分布式消息系统来说,一个设计良好的,可扩展的分区机制必不可少。分区机制使得读写的并行化成为可能,而一个良好的分区扩展机制使得企业在面临业务增长时可以变得更得心应手。和许多基于静态分区,或者需要手动扩展分区(如Kafka)的系统不同的是,Pravega可以根据数据负载动态地伸缩Stream,以此来实时地应对流量负载的变化。
在当前的大数据技术环境下,我们通过将数据拆分成多个分区并独立处理来获得并行性。 例如,Hadoop通过HDFS和map-reduce实现了批处理并行化。 对于流式工作负载,我们今天要使用多消息队列或Kafka分区来实现并行化。 这两个选项都有同样的问题:分区机制会同时影响读客户端和写客户端。 面对持续数据处理的读/写,我们的扩展要求往往会有不同,而一个同时影响读写的分区机制会增加系统的复杂性。 此外,虽然你可以通过添加队列或分区来进行扩展,但这需要分别对读、写客户端和存储进行手动调整,然后需要手动协调调整后的参数。 这样的操作很复杂,而且不是动态的,并需要人工介入。
而使用Pravega的话,我们可以轻松地、弹性并且独立地扩展数据的摄入、存储和处理,即协调数据管道中每个组件的扩展。
Pravega对动态伸缩的支持源自于把Stream被划分成Segment的想法。 在之前的文章中有介绍过,一个Stream可以具有一个或多个Segment。我们可以把一个Segment类比成一个分区,写入Stream的任何数据都会根据指定路由键,通过哈希计算路由至某一个Segment。 实际应用场景下,我们建议应用开发者基于一些有应用意义的字段,比如customer-id,timestamp,machine-id等来生成路由键,这样就可以确保将同类的应用数据路由至同一个Segment。
Segment是Stream中最基本的并行单元。
Stream可以被配置为随着更多数据写入而增加Segment的数量,并在数据量下降时缩小Segment数。 我们将这种配置称为Stream的服务级目标(Service Level Objective,SLO)。Pravega监控输入到Stream的数据速率,并根据SLO在Stream中动态增加或移除Segment。 当需要增加Segment时,Pravega会通过拆分Segment来生成更多的Segment;而当需要减少Segment数量时,Pravega通过合并Segment来减少Segment数量。
实际应用中,应用程序还可以对接Pravega提供的元数据,根据Stream的伸缩性来做相应的伸缩。举例来讲,Flink可以根据元数据中的Segment数量来调整Flink作业的并行度,或者可以依赖容器平台(如Cloud Foundry,Mesos/Marathon,Kubernetes或者Docker Stack)提供的动态扩缩容机制来动态调整容器实例的数量,以此来应对数据流量的变化。
Pravega根据一致性散列算法将路由键散列至“键空间”,该键空间被划分为多个分区,分区数量和Segment数量相一致,同时保证每一个Segment保存着一组路由键落入同一区间的事件。
根据路由键,我们将一个Stream拆分成了若干个Segment,每一个Segment保存着一组路由键落入同一区间的事件,并且拥有着相同的SLO。
同时,Segment可以被封闭(seal),一个被封闭的Segment将禁止写入。这一概念在动态伸缩中将发挥重要作用。
假设某制造企业有400个传感器,分别编号为0~399,我们将编号做为routing key,并将其散列分布到(0, 1)的键空间中(Pravega也支持将非数值型的路由键散列到键空间中)。随着部分传感器传输频率的变化,我们来观察其Segment的变化。
如图1所示,在0~1区间的键空间中,Segment的合并和拆分导致了路由键随着时间的推移而被路由至不同的Segment。
上图所示的Stream从时间t0开始,它被配置成具有动态伸缩功能。 如果写入流的数据速率不变,则段的数量不会改变。
在时间点t1,Pravega监控器注意到数据速率的增加,并且选择将Segment 1拆分成Segment 2和Segment 3两部分,这个过程我们称之为Scale-up事件。在t1之前,路由键散列到键空间上半部的(值为200~399)的事件将被放置在Segment 1中,而路由键散列到键空间下半部的(值为0~199)的事件则被放置在Segment 0中。在t1之后,Segment 1被拆分成Segment 2和Segment 3;Segment 1则被封闭,即不再接受写入。 此时,具有路由键300及以上的事件被写入Segment 3,而路由键在200和299之间的事件将被写入Segment 2。Segment 0则仍然保持接受与t1之前相同范围的事件。
在t2时间点,我们看到另一个Scale-up事件。这次事件将Segment 0拆分成Segment 4和Segment 5。Segment 0因此被封闭而不再接受写入。
具有相邻路由键散列空间的Segment也可以被合并,比如在t3时间点,Segment 2和Segment 5被合并成为Segment 6,Segment 2和Segment 5都会被封闭,而t3之后,之前写入Segment 2和Segment 5的事件,也就是路由键在100和299之间的事件将被写入新的Segment 6中。合并事件的发生表明Stream上的负载正在减少。
如图2,在“现在”这个时刻,只有Segment 3,6和4处于活动状态,并且所有活跃的Segment将会覆盖整个键空间。在上述的规则2和3中,即使输入负载达到了定义的阈值,Pravega也不会立即触发scale-up/down的事件,而是需要负载在一段足够长的时间内超越策略阈值,这也避免了过于频繁的伸缩策略影响读写性能。
我们在创建Stream时,会使用伸缩规则来配置Stream,该规则定义了Stream如何响应其负载变化。 目前Stream支持三种配置规则:
static ScalingPolicy fixed(int numSegments)
其中numSegment
指stream中固定的segment数量
static ScalingPolicy byEventRate(int targetRate, int scaleFactor, int minNumSegments)
其中targetRate
指每个segment所能承受的最大负载(每秒的event数量),scaleFactor
是指每一次scale-up事件中的分裂系数,即segment一分为几,如上例应设为2,minNumSegments
指stream中所有的segment数量的最小值,用以防止过度scale-down。
static ScalingPolicy byDataRate(int targetKBps, int scaleFactor, int minNumSegments)
其中targetKBps
指每个segment所能承受的最大负载(每秒数据量大小,以KB计数),其他同上。
使用时,在创建Stream时,将对应的ScalingPolicy
对象传递给Stream的配置对象StreamConfig
即可。
StreamManager streamManager = StreamManager.create(controllerURI); StreamConfiguration streamConfig = StreamConfiguration.builder() .scalingPolicy(ScalingPolicy.byEventRate(100, 2, 1)) .build();streamManager.createStream(scope, streamName, streamConfig);
Pravega从设计初始就旨在解决流式数据的读写客户端独立扩展问题,以求达到读写扩展具有弹性,互不影响。我们来看一下以下两种场景:
场景1:写速率\u0026lt;处理速率
在图3中,处理速度大于写入速度,所以虽然只有一个写客户端,我们仍然可以将Stream拆分成多个Segment,由读客户端reader#1来读路由键区间为ka … kc的事件,而客户端reader#2读路由键区间为kd … kf的事件。在同一读者组(Reader Group)内的读客户端会根据自身读客户端数量,自动以负载均衡的方式对应到零到多个不同的segment实现并行的读。而Pravega的弹性伸缩机制也允许读者组跟踪segment的缩放并采取适当的措施,例如:在运行时添加或删除读客户端实例,使整个系统能够以协调的方式动态扩展。Pravega团队已经和Flink社区合作,通过监听segment数量改变Flink读取和处理Pravega数据的并行度,实现了Flink Pravega Source的动态伸缩。
场景2: 写速率 \u0026gt; 处理速率
在图4中,处理速度小于写入速度,所以我们可以在写客户端进行并行化(由应用完成),但只需分配一个读客户端来读。由于有了stream和segment的抽象,数据存储的真正的分区会在stream内部实现,只要路由键不发生改变,写客户端的并行、数据量的增加并不会影响数据的正常分区。
现实情况下,我们往往会处于上述两种情况之间,并且伴随着数据源的变化和时间的推进而发生改变。对写客户端来说,Segment的拓扑是透明的,它们只需负责路由键的分区。对读客户端来说,只需简单指向Stream,而Segment的动态变化会自动反馈给读客户端。
至此,读客户端和写客户端可以分别独立地进行弹性缩放,而不受彼此影响。
我们使用由美国纽约市政府授权开源的出租车数据(http://www.nyc.gov/html/tlc/html/about/trip_record_data.shtml),包括上下车时间,地点,行程距离,逐项票价,付款类型、乘客数量等字段。我们把历史数据集模拟成了流式数据实时地写入Pravega。所取的数据集涵盖的是2015年3月的黄色出租车的行程数据,其数据量为1.9GB,包括近千万条记录,每条记录17个字段。我们选取了其中12个小时的数据,形成如图4所示数据统计:
黄色和绿色的出租车行程记录包括捕获提货和下车日期/时间,接送和下车地点,行程距离,逐项票价,费率类型,付款类型和司机报告的乘客数量的字段。我们把历史数据集模拟成了流式数据实时地写入Pravega。所取的数据集涵盖的是2015年3月的黄色出租车的行程数据,其数据量为1.9GB,包括近千万条记录,每条记录17个字段。我们选取了其中12个小时的数据,形成如图5所示数据统计:
由上图我们可以观察到,数据流量在早上4点左右处于谷点,而在早晨9点左右达到峰值。峰值流量的写入字节数大约为谷点流量的10倍。我们将Stream的伸缩规则配置为上述规则2(基于大小的伸缩规则)。
相对应地,Stream的Segment热点图如图6所示动态变化:
从上图可以看出,从晚11点至凌晨2点,Segment逐渐合并;从早晨6点至10点,Segment逐步拆分。从拆分次数来看,大部分Segment总共拆分3次,小部分拆分4次,这也印证了流量峰值10倍于谷底的统计值(3 \u0026lt; lg10 \u0026lt; 4)。
我们使用出租车行程中的出发点坐标位置来作为路由键。当高峰来临时,繁忙地段产生的大量事件会导致Segment被拆分,从而会有更多的读客户端来进行处理;当谷峰来临时,非繁忙地段产生的事件所在的Segment会进行合并,部分的读客户端会下线,剩下的读客户端会处理更多地理区块上产生的事件。
Pravega的动态伸缩机制可以让应用开发和运维人员不必关心因流量变化而导致的分区变化需要,无需手动调度集群。分区的流量监控和相应变化由Pravega来进行,从而使流量变化能够实时而且平滑地体现到应用程序的伸缩上。
独立伸缩机制使得生产者和消费者可以各自独立地进行伸缩,而不影响彼此。整个数据处理管道因此变得富有弹性,可以应对实时数据的不断变化,结合实际处理能力而做出最为适时的反应。
Pravega根据Apache 2.0许可证开源,0.4版本已于近日发布。我们欢迎对流式存储感兴趣的大咖们加入Pravega社区,与Pravega共同成长。本篇文章为Pravega系列第四篇,后面的文章标题如下(标题根据需求可能会有更新):
http://pravega.io/docs/latest/pravega-concepts/#autoscaling-the-number-of-stream-segments-can-vary-over-time
http://pravega.io/docs/latest/key-features/#auto-scaling