实时流计算系统设计数据处理:流计算到底计算什么?流数据操作~

流计算到底在计算什么?

到目前为止,通过前面介绍我们已经有了流数据源,再后面我们已经实现了一个用于构建单节点流计算应用的框架。

接下来,我们就要用它们做一些真正有意义的事情了。那么问题来了,我们千辛万苦构建的流计算系统到底能够计算什么呢?总的来说,我们使用流计算主要是为了计算以下几类问题。

1.流数据操作

流数据操作可以说是流计算系统与生俱来的能力,它本身是针对数据流的转化或转移处理,所以实现和使用起来都相对更加直观。流数据操作的内容主要包括3类:对数据进行清洗、规整和结构化,对不同来源的数据进行关联及合并,以及在不同系统之间搬运数据。这3类操作通过一些常用的流式API就可以实现,4.2节会详细讨论这些流式API。

2.单点特征计算

一个事件包含的用户是否在黑名单中?发生事件的设备是否是模拟器?温度传感器传来的温度事件是否已经超出正常温度范围?发送消息设备的IP是否是代理?一次交易的金额是否属于大额交易?手机是否有SIM卡?诸如此类的问题,要么可以通过黑白名单,要么能够通过特定的规则计算而得到答案,实现起来相对简单,我们将这类特征称为单点特征。

3.时间维度聚合特征计算

相同设备1小时内的注册事件次数、相同银行卡号的7天交易事件次数、过去30天内同一IP段的交易金额、过去1分钟高温事件次数、过去5分钟日志告警事件的次数……诸如此类特征在风控、预警、监控等各种场景中都有非常广泛的应用。通过分析不难发现,这类特征都有一个共同特点,即它们均需要在时间维度对数据进行聚合运算。因此,我们称这类特征为时间维度聚合特征。

4.关联图谱特征计算

除了时间维度的聚合分析外,我们还经常进行空间维度的聚合分析,即“关联图谱”分析。例如,在一些风控场景中,我们需要计算用户账户使用IP的个数、同一手机号码发生在不同城市的个数、同一设备关联用户的数目、同一用户关联设备的数目、同一推荐人推荐的用户数等特征。以设备关联用户数为例,如果某个设备上注册的用户很多,那么它的风险就比较高,毕竟正常情况下我们只会用自己的手机注册自己的账号,而不会帮其他几十、上百人注册账号。

5.事件序列分析

数据流中的数据不只单纯在时间上有着先来后到的关系,数据和数据之间也有着联系。例如,用户在手机上安装新App,可能是先单击了某个广告链接,然后下载并安装App,最后成功注册账号。

从“单击”到“下载”,再到“安装”和“注册”,这就完成了一次将广告转化为用户的过程。再如,在网络欺诈识别场景中,如果用户在新建账号后立马发生大量交易行为,那么这种“新建账号”到“10分钟内5次交易”的行为就是一种非常可疑的行为了。诸如此类从数据流表示的事件流中检测并筛选出符合特定模式或行为的事件序列的过程,称为CEP。流计算经常用来解决CEP问题。

6.模型学习和预测

随着流计算越来越流行和普及,越来越多的原本主要针对离线批量数据的统计和机器学习模型也被用于流数据。例如,在风控系统中,我们计算特征后,还需要把这些特征输入评分模型进行风险评分。根据不同的使用场景,使用的评分模型可能是基于规则的模型,也可能是基于机器学习的模型。传统的机器学习模型主要通过离线训练而来,但现在越来越多的模型会直接基于流数据在线训练和更新。

再如,在异常检测应用中,我们会在线统计并估计变量的分布参数,然后根据训练出的分布模型判断变量之后的取值是否异常。这种同时在线更新和预测的做法在流计算应用中也越来越常见。

这里总结了6类流计算问题。总体而言,我们在以后的流计算应用开发过程中,所面对的计算问题都会八九不离十地归于这6类计算问题中的一种或多种。

所以,在接下来的内容中,除了“单点特征”外,我们将逐一分析各类问题的计算方法。

至于“单点特征”,由于其实现相对简单(或者说难点不在于流计算这部分),且主要与具体业务场景相关,所以我们就不具体讨论了。

流数据操作

当流数据进入系统后,通常会对数据做一些整理,如提取感兴趣的字段、统一数据格式、过滤不合条件事件、合并不同来源数据流等。虽然不同系统处理数据的具体方法不尽相同,但经过多年的实践和积累,业界针对流数据的操作目标和手段有了一些共同的认识,并已逐步形成一套通用的有关流数据操作的API集合。在本节中,我们将讨论一些基础的流数据操作方法,几乎所有的流计算平台都会提供这些方法的实现,而其他功能更丰富的流式API也构建在这些方法的基础上。

4.2.1 过滤

过滤(filter)基本上可以说是最简单的流计算操作了,它用于在数据流上筛选出符合指定条件的元素,并将筛选出的元素作为新的流输出。流的过滤是一个容易理解且容易实现的操作。例如,我们现在需要监控仓库的环境温度,在火灾发生前提前预警以避免火灾,那么就可以采用过滤功能,从来自于传感器的记录环境温度的事件流中过滤出温度高于100℃的事件。我们使用Flink实现如下:

DataStream<JSONObject> highTemperatureStream = temperatureStream.filter(x ->

x.getDouble("temperature") > 100);

上面的Lambda表达式“x->x.getDouble("temperature")>100”即过滤火灾高温事件的条件。

图4-1展示了过滤操作的作用,它将一个具有多种形状的数据流,转化为只含圆形的数据流。当然在实际开发过程中,我们可以将“形状”替换为任何东西。

图4-1 过滤操作

4.2.2 映射

映射(map)用于将数据流中的每个元素转化为新元素,并将新元素输出为数据流。同样以仓库环境温度监控为例,但这次我们不是将高温事件过滤出来,而是采用数据工程师在做特征工程时常用的一种操作:二值化。我们在原始环境温度事件中,添加一个新的布尔(boolean)类型字段,用于表示该事件是否是高温事件。同样,我们使用Flink实现如下:

DataStream<JSONObject> enhancedTemperatureStream = temperatureStream.map(x -> {

x.put("isHighTemperature", x.getDouble("temperature") > 100);

return x;

});

在上面示意代码的Lambda表达式中,通过原始事件的temperature字段判断是否为高温事件后附加到事件上,最后返回附加了高温信息的事件。

图4-2展示了映射操作的作用,它将一个由圆形组成的数据流,转化为由五角星形状组成的数据流。同样在实际开发过程中,我们可以将“形状”具象为任何东西。对数据流中的数据做转化或信息增强,正是映射操作的重要作用。

图4-2 映射操作

4.2.3 展开映射

展开映射(flatMap)用于将数据流中的每个元素转化为N个新元素,其中N∈[0 + ∞)。相比映射而言,展开映射是一个更加灵活的方法,因为映射只能一对一地对数据流元素进行转化,而展开映射能1对N地对数据流元素进行转化。下面举一个展开映射在社交活动分析中使用的例子。现在有一组代表用户信息的数据流,其中每个元素记录了用户(用user字段表示)及其好友列表(用friends数组字段表示)信息,现在我们要分析各个用户与其各个好友之间的亲密程度,以判断他们之间是否是“塑料兄弟”或“塑料姐妹”。我们要先将用户及其好友列表一一展开,展开后的每个元素代表了用户及其某一个好友之间的关系。

下面是采用Flink实现的例子。

DataStream<String> relationStream = socialWebStream.flatMap(new

FlatMapFunction<JSONObject, String>() {

@Override

public void flatMap(JSONObject value, Collector<String> out) throws Exception {

List<String> collect = value.getJSONArray("friends").stream()

.map(y -> String.format("%s->%s", value.getString("user"), y))

.collect(Collectors.toList());

collect.forEach(out::collect);

}

});

在上面代码的展开映射方法中,我们使用Java 8的流式API,将用户的好友列表friends展开,与用户形成一对对的好友关系记录(用“%s->%s”格式表示),最终由out::collect收集起来,写入输出数据流中。

图4-3展示了展开映射操作的作用,它将一个由包含小圆形在内的正方形组成的数据流,展开转化为由小圆形组成的数据流。在实际开发过程中,我们还经常使用展开映射实现Map/Reduce或Fork/Join计算模式中的Map或Fork操作。更有甚者,由于展开映射的输出元素个数能够为0,我们有时候连Reduce或Join操作也可以使用展开映射操作实现。

图4-3 展开映射操作

4.2.4 聚合

聚合(reduce)用于将数据流中的元素按照指定方法进行聚合,并将聚合结果作为新的流输出。由于流数据具有时间序列的特征,所以聚合操作不能像诸如Hadoop等批处理计算框架那样作用在整个数据集上。

流数据的聚合操作必然指定了窗口(或者说这样做才有更加实际的意义),这些窗口可以基于时间、事件或会话(session)等。

同样以社交活动分析为例,这次我们需要每秒钟统计一次10秒内用户活跃事件数。使用Flink实现如下。

DataStream<Tuple2<String, Integer>> countStream = socialWebStream

.map(x -> Tuple2.of("count", 1))

.returns(Types.TUPLE(Types.STRING, Types.INT))

.timeWindowAll(Time.seconds(10), Time.seconds(1))

.reduce((count1, count2) -> Tuple2.of("count", count1.f1 + count2.f1));

在上面的代码片段中,socialWebStream是用户活跃事件流,我们使用timeWindowAll指定每隔1秒,对10秒窗口内的数据进行一次计算。

reduce方法的输入是一个用于求和的Lambda表达式。在实际执行时,这个求和Lambda表达式会依次将每条数据与前一次计算的结果相加,最终完成对窗口内全部流数据的求和计算。如果将求和操作换成其他“二合一”的计算,则可以实现相应功能的聚合运算。由于使用了窗口,所以聚合后流的输出不再像映射运算那样逐元素地输出,而是每隔一段时间才会输出窗口内的聚合运算结果。如前面的示例代码中,就是每隔1秒输出10秒窗口内的聚合计算结果。

图4-4展示了聚合操作的作用,它将一个由带有数值的圆形组成数据流,以3个元素为窗口,进行求和聚合运算,并输出为新的数据流。

在实际开发过程中,我们可选择不同的窗口实现、不同的窗口长度、不同的聚合内容、不同的聚合方法,从而在流数据上实现各种各样的聚合操作。

图4-4 聚合操作

4.2.5 关联

关联(join)用于将两个数据流中满足特定条件的元素对组合起来,按指定规则形成新元素,并将新元素输出为数据流。在关系型数据库中,关联操作是非常常用的查询手段,这是由关系型数据库的设计理念(即数据库的3种设计范式)决定的。而在流数据领域,由于数据来源的多样性和在时序上的差异性,数据流之间的关联也成为一种非常自然的需求。以常见场景为例,假设我们收集的事件流同时被输入两个功能不同的子系统以做处理,它们各自处理的结果同样以数据流的方式输出。现在需要将这两个子系统的输出流按照相同事件id合并起来,以汇总两个子系统对同一事件的处理结果。在这个合并过程中,两个数据流之间的元素是“一对一”的对应关系。这种情况实现起来相对简单。

相比关系型数据库表间的关联操作,流数据的关联在语义和实现上都更加复杂些。由于流的无限性,只有在类似于前面“一对一”等非常受限的使用场景下,不限时间窗口的关联设计和实现才有意义,也相对简单。在大多数使用场景下,我们需要引入“窗口”来对关联的流数据进行时间同步,即只对两个流中处于相同时间窗口内的数据进行关联操作。

即使引入了窗口,流数据的关联依旧复杂。当窗口时间很长,窗口内的数据量很大(需要将部分数据存入磁盘),而关联的条件又比较宽泛(如关联条件不是等于而是大于)时,那么流之间的关联计算将非常慢(不是相对于关系型数据库慢,而是相对于实时计算的要求慢),基本上我们也别指望能够非常快速地获得两个流关联的结果了。反过来讲,如果关联的周期很短,数据量不大,而我们能够使用的内存又足够将这些数据都放入内存,那么关联操作就能够相对快速地实现。

同样以社交网络分析为例子,这次我们需要将两个不同来源的事件流,按照用户id将它们关联起来,汇总为一条包含用户完整信息的数据流。

以下就是在Flink中实现这个功能的示例代码。

DataStream<JSONObject> joinStream = socialWebStream.join(socialWebStream2)

.where(x1 -> x1.getString("user"))

.equalTo(x2 -> x2.getString("user"))

.window(TumblingEventTimeWindows.of(Time.seconds(10), Time.seconds(1)))

.apply((x1, x2) -> {

JSONObject res = new JSONObject();

res.putAll(x1);

res.putAll(x2);

return res;

});

在上面的代码片段中,socialWebStream和socialWebStream2分别是两个来源的用户事件流,我们使用where和equalTo指定关联的条件,即按照user字段的值相等关联起来。然后使用window指定每隔1秒,对10秒窗口内的数据进行关联计算。最后利用apply方法,指定了合并计算的方法。

流的关联是一个我们经常想用但又容易让人头疼的操作。因为稍不注意,关联操作的性能就会惨不忍睹。关联操作需要保存大量的状态,尤其是窗口越长,需要保存的数据越多。因此,当使用流数据的关联功能时,应尽可能让窗口较短。

图4-5展示了采用内联接(inne.join)的关联操作,它将两个各带id和部分字段的数据流分成相同的时间窗口后,按照id相等进行内联接关联,最后输出两个流内联接后的数据流。

图4-5 关联操作

4.2.6 分组

如果说各种流计算应用或流计算框架最终能够实现分布式计算,实现高并发和高吞吐,那么最大的功臣莫过于“分组”(key By)操作的实现了。分组操作是实现并行流计算的最主要手段,它将流划分为不相交的分区流,分组键相同的消息被划分到相同的分区流中,各个分区流在逻辑上相互独立,具有各自独立的运行时上下文。这就带来两个非常大的好处。

1)流分组后,能够被分配到不同的计算节点上执行,从而实现了CPU、内存、磁盘等资源的分布式使用和扩展。

2)分区流具有独立的运行时上下文,就像线程局部量一样,对于涉及运行时状态的流计算任务来说,这极大地简化了安全处理并发问题的难度。

以电商场景为例,假设我们要在“双十一抢购”那天,实时统计各个商品的销量以展现在监控大屏上。使用Flink实现如下。

DataStream<Tuple2<String, Integer>> keyedStream = transactionStream

.map(x -> Tuple2.of(x.getString("product"), x.getInteger("number")))

.returns(Types.TUPLE(Types.STRING, Types.INT))

.keyBy(0)

.window(TumblingEventTimeWindows.of(Time.seconds(10)))

.sum(1);

在上面的代码中,transactionStream代表交易数据流,在取出了分别代表商品和销量的product字段和number字段后,我们使用keyBy方法根据商品对数据流进行分组,然后每10秒统计一次10秒内的各商品销售总量。

图4-6 分组操作

图4-6展示了数据流的分组操作。通过分组操作,将原本包含多种形状的数据流划分为多个包含单一形状的数据流。当然,这里的“多个”是指逻辑上的多个,它们在物理上可以是多个流,也可以是一个流,这就与具体的并行度设置有关了。

4.2.7 遍历

遍历(foreach)是对数据流的每个元素执行指定方法的过程。遍历与映射非常相似又非常不同。说它们相似是因为遍历和映射都是将一个表达式作用在数据流上,只不过遍历使用的是“方法”(没有返回值的函数),而映射使用的是“函数”。说它们不同是因为遍历和映射语义大不相同,从API语义上来讲,映射的作用是对数据流进行转换,但遍历并非对数据流进行转换,而是“消费”数据流。也就是说,数据流在经过遍历后也就终结了。所以,我们通常使用遍历操作对数据流进行各种I/O操作,如写入文件、存入数据库、输出到显示器等。

下面的Flink示例代码及图4-7均展示了将数据流输出到显示屏的功能。

transactionStream.addSink(new PrintSinkFunction<>()).name("Print to Std. Out")

时间维度聚合特征计算

按时间维度对数据进行聚合是非常常见的计算类型,这很容易理解。例如,你是一个公司的老板,你想知道公司这个月的运营情况,你肯定要问这个月的销售额和成本各是多少,而不会去问每一笔买卖。再如,你是某个仓库的安全管理员,每天需要检查仓库的环境是否安全,你最关注的肯定是仓库当日的最高温度、最低温度和平均温度是多少。又或者你是某个网站的运营人员,想知道网站最近的流量怎样,你肯定要问最近一段时间的网站访问量(PV)和独立访客量(UV)。实际开发工作也是如此,大部分数据系统的主要工作其实就是对数据做各种维度的聚合运算,如计数(count)、求和(sum)、均值(avg)、方差(variance)、最小(min)、最大(max)等。由于流数据可以看作一种特殊的时间序列,在时间维度上对数据做各种聚合运算也是很常见的操作。

以风控场景为例,我们经常需要计算一些时间维度聚合特征,如过去一周内在同一个设备上交易的次数、过去一天同一用户的交易总金额、过去一周同一用户在同一I.C段的申请贷款次数等。如果用SQL描述上面的统计量,分别如下:

# 过去一周内在同一个设备上交易的次数

SELECT COUNT(*) FROM stream

WHERE event_type = "transaction"

AND timestamp >= 1530547200000 and timestamp < 1531152000000

GROUP BY device_id;

# 过去一天同一用户的总交易金额

SELECT SUM(amount) FROM stream

WHERE event_type = "transaction"

AND timestamp >= 1531065600000 and timestamp < 1531152000000

GROUP BY user_id;

# 过去一周同一用户在同一IP C段申请贷款次数

SELECT COUNT(*) FROM stream

WHERE event_type = "loan_application"

AND timestamp >= 1530547200000 and timestamp < 1531152000000

GROUP BY ip_seg24;上面的SQL语句让我们很容易想到关系型数据库。关系型数据库在执行这类SQL时,如果没有构建索引,那么执行引擎就会遍历整个表,过滤出符合条件的记录,然后按GROU.BY指定的字段对数据分组并进行聚合运算。

那当我们面对的是流数据时,应该怎样实现这类聚合运算呢?一种简单的策略是采用与前文所述关系型数据库实现聚合运算时相同的方法。当数据到来时,先把它保存到缓冲区,然后遍历窗口内的所有数据,过滤出符合指定条件的事件并进行计数或求和等聚合运算,最后输出聚合结果。

但是大多数情况下将这种简单的方式运用到实时流计算中时,都会遇到性能问题。因为如果将每条消息都保存在缓冲区中,当窗口较长、数据量较大时,会占用很多内存。而且每次的计算需要遍历所有的数据,这无疑会消耗过多的计算资源,同时增加了计算所耗的时间。

因此,我们需要尽可能地降低计算复杂度,并且只保留必要的聚合信息,而不需要保存所有原始数据。非常幸运的是,对于各种聚合类型的运算,我们都能够找到一个(或者一组)指标,用于记录聚合后的结果。例如,对于count计算这个指标是“记录数”,对于sum计算这个指标是“总和”,对于avg计算这组指标是“总和”和“记录数”,对于min计算这个指标是“最小值”,对于max计算这个指标是“最大值”等。

我们以count计算来详细说明优化后的做法。首先,在每个时间窗口内,给变量的每一种可能的取值分配一个用于保存记录数的寄存器。然后当数据到达时,根据变量的取值及其所在的窗口,选中对应的记录数寄存器,然后将该记录数寄存器的值加一。这样,当窗口结束时,每个记录数寄存器的取值就是该时间窗口内变量在某个分组下的计数值了。同样,对于其他类型的时间维度聚合特征的计算,都可以按照这种思路来实现。表4-1列举了几种聚合计算在采用寄存器方法实现时所需要的寄存器个数及各个寄存器的含义。

表4-1 各种聚合计算使用的寄存器含义

虽然采用寄存器的方案极大地减少了内存的使用量,也降低了计算的复杂度,但是这种方案依旧存在问题。由于采用寄存器来记录聚合计算的中间值,也就涉及“状态”的存储问题。或许乍看之下我们会觉得,寄存器无非存储一个数字而已,又能够占用多少空间?但稍微仔细分析下就会发现问题。是的,我们为变量的每个可能的值都分配了一个或一组寄存器,虽然寄存器的个数不多,如在表4-1中使用寄存器最多的方差也就用了3个寄存器。当我们进行聚合分析的变量具有一个较低的势[1]时,那么一切尚且良好。

但是,实际的情况是,我们用于分组聚合时的分组变量往往具有比原本预想的高得多的势。例如,统计用户每天的登入次数,那中国有10多亿人口呢!(当然并非所有人都会上网。)再如,需要统计每个IP访问网站的次数,那全球有40多亿IP呢!

再加上,有时候我们需要聚合的是一些复合变量,如统计过去一周同一用户在同一IP C段申请贷款次数,这种情况如果严格按照理论值计算(也就是笛卡儿积),那将是天文数字了。所以,至少我们不能将这些状态都存放在本地内存里。通常,我们需要将这些寄存器状态保存到外部存储器中,如Redis、Ignite或本地磁盘。并且,我们还需要为这些状态设置过期时间(TTL),将过期的状态清理掉,一方面为新的状态腾出空间,另一方面避免了占据空间的无限增长。

  • 15
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值