<audio id="audio" title="29 | 流式计算：如何通过集群实现实时计算？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/88/5f8498f16a0c7f1286c952770e7ea788.mp3"></audio>

你好，我是陶辉。

上节课我们介绍了在有边界的存量数据上进行的MapReduce离线计算，这节课我们来看看对于无边界数据，怎样实时地完成流式计算。

对于不再变化的存量数据，可以通过分而治之的MapReduce技术将数据划分到多台主机上并行计算，由于待处理的数据量很大，我们只能获得分钟级以上的时延。当面对持续实时产生动态数据的场景时，业务上通常需要在秒级时延中及时地拿到运算结果。

比如，商家为了拉新促活，会为特定的用户群体（比如新用户或者不活跃用户）推出优惠活动，为了防止“羊毛党”通过大量主机并行地**“薅羊毛”**，系统要能实时地聚合分析所有优惠券的使用者特点，再基于业务规则及时地封掉“羊毛党”帐号或者IP地址，才可以控制住风险范围，提高营销活动的收益。那对于整个系统持续生成的大量订单数据，怎样才能提供秒级的聚合分析结果呢？

最初的流式计算方案，是在时间维度上定期地将数据分片，再基于MapReduce思想在空间维度的多台主机上实现并行计算，这样也能获得实时计算结果。然而，对每片数据执行批量计算，想要在秒级甚至毫秒级拿到计算结果并不容易。当网络不稳定时，数据会因为报文延误而乱序，简单的基于时序分片会导致计算结果失真。当数据之间具有明显的业务关系时，固定的时间窗口更是难以得到预期的分析结果。

接下来我们就深入学习一下流式计算的工作原理，以及流式计算常用的数据分片窗口。

## 流式计算是如何实现的？

在数据库、HDFS等分布式系统中存放的静态数据，由于拥有清晰的边界，所以被称为InBound Data有边界数据。然而，线上运行中的互联网产品生命周期并不确定，它产生的数据有明确的开始，却没有截止时间点。对于这样有始无终的实时数据流，我们把它称为OutBound Data无边界数据，如下图所示：

<img src="https://static001.geekbang.org/resource/image/bf/a0/bfe991c2d38eb5c2f3f2d271d7fbd7a0.jpg" alt="">

从业务需求上看，有边界数据与无边界数据的计算目的是完全不同的。比如对于分布式监控系统，我们需要基于IP地址、用户帐号、请求类型等许多特征进行定时的聚合统计，例如获取每分钟内所有请求处理时延的平均值、中位数、最大值等，监控系统性能。此时，可以根据请求执行结果的产生时间对数据进行分片计算。比如，下表中有7条监控数据，需要求出每分钟请求时延的平均值。

<img src="https://static001.geekbang.org/resource/image/f1/36/f124f93afffdb388660d4236418ff236.jpg" alt="">

如果我们按照分钟整点对数据进行分片，就可以在02:00时对蓝色的消息1、2求出窗口内的平均时延192毫秒，并立刻返回结果。之后当接收完红色的消息3、4、5后，在第2分钟结束时再对3个数字求出平均值。以此类推。

**这种设计思想就是基于固定时间窗口的批处理解决方案。**当然，并不是一定要等到时间窗口结束时，才对这一批次的所有数据统一计算。我们完全可以在每个消息到来时，就计算出中间状态，当所在的时间窗口结束时，再将中间状态转换为最终结果。仍然以上表为例，我们可以在每个监控事件到达时，计算出请求时延和以及当前窗口内的事件个数，这样，在窗口结束时我们只需要将时延和除以事件个数，就能得到平均值。

<img src="https://static001.geekbang.org/resource/image/7b/y5/7b5015d8221b32150c4bd1bfcd17byy5.jpg" alt="">

因此，中间状态可以更均衡地使用计算资源，提高流式计算的整体性能。我们既可以把中间状态放在内存中，也可以把它持久化到本地磁盘中获取更高的可用性，为了方便计算节点的调度，我们通常还会将备份状态存放至远端的数据库。

[<img src="https://static001.geekbang.org/resource/image/e6/56/e6518cbc0f727a9f5fde40cdccbd2f56.png" alt="" title="图片来源：https://flink.apache.org/">](https://flink.apache.org/)

当然，流式计算最主要的性能提升思路，还是基于MapReduce思想，将同一窗口的数据从空间维度中分发到不同的计算节点进行并行的Map计算，再将Map映射出的结果Reduce为最终结果。由于流式计算天然是基于消息事件驱动的，因此它往往直接从Kafka等消息队列中获取输入数据，如同[[第27讲]](https://time.geekbang.org/column/article/261094) 的介绍，消息队列很容易协助流式计算实现数据拆分。

到这里，我们已经看到了实现流式计算的基本思路，其中基于固定时间窗口的数据划分方式还有很大的改进空间，目前它还无法解决较为复杂的有状态计算。所谓有状态计算，是指在时间窗口内，不同的消息之间会互相作用并影响最终的计算结果，比如求平均值就是这样一个例子，每个新到达的数据都会影响中间状态值。

<img src="https://static001.geekbang.org/resource/image/ce/da/ce5ef3b3fe17b5032b906a1f1658a6da.jpg" alt="">

相反，无状态计算处理到达的数据时，并不涉及窗口内的其他数据，处理流程要简单的多。例如，当监控到请求时延超过3秒时，就产生一条告警。此时，只需要单独地判断每个消息中的时延数据，就能够得到计算结果。

在真实的业务场景中，有状态计算还要更复杂。比如，对两个不同的数据源（可以理解为数据库中的表）做join连接时，采用内连接、外连接这两种不同的连接方式，就会影响到我们的时间窗口长度。再比如，当不同的事件具有逻辑关系时，窗口长度则应该由业务规则确定，不同的请求可能拥有不等的窗口大小。接下来，我们再来看看流式计算中的几种常见窗口。

## 如何通过窗口确定待计算的数据？

首先来看滑动窗口，它是从固定窗口衍生出的一种窗口。我们继续延续求每分钟平均值的例子，当业务上需要更平滑的曲线时，可以通过每20秒求最近1分钟请求时延的平均值实现，这就是滑动窗口，其中窗口长度则是1分钟，但每次计算完并不会淘汰窗口中的全部数据，而只是将步长向后移动20秒，即只淘汰最早20秒中的数据。当窗口长度与步长一致时，滑动窗口就退化成了固定窗口。

当然，我们还可以把窗口的计量单位从时间改为事件个数，此时可以称为计数窗口。仍然延续上面的例子，固定计数窗口可以改为求每100个访问记录的平均时延，滑动计数窗口可以改为每10条记录中求最近100个记录的平均时延。由于消息本身是有时序的，所以这些都可以称为时间驱动的窗口。事实上，还有另外一种事件驱动的窗口与此完全不同，如下图所示：

<img src="https://static001.geekbang.org/resource/image/75/57/75dd62dd3a90c027a4e8ae95389dea57.jpg" alt="">

固定窗口、滑动窗口并不会解析业务字段，区别对待图中不同的Key关键字，这就很难解决以下这类场景：当需要统计用户在一个店铺内浏览的商品数量时，就需要针对用户的店铺停留时长来设计动态的窗口大小。毕竟不同的用户在不同的店铺内停留时长不可能相同，此时，动态的窗口大小可以通过事件来驱动，我们称为会话窗口。

事实上，我们还面临着信息统计准确性上的问题。在基于时间驱动的窗口中，这里的时间其实是事件到达流式系统时产生的系统处理时间，而不是事件发生的时间。仍然以访问日志为例，每条日志都有明确的请求访问时间，但在分布式系统传输时，由于网络波动的传输时延，以及各主机节点应用层的处理时延，这些事件到达流式计算框架的顺序已经发生了变化。如果仍然以固定的时间窗口来处理，就会得到错误的统计结果。

为了避免乱序事件扰乱统计结果，我们可以使用水位线Watermark减少乱序概率。比如下图中，消息队列中的数字表示事件时间，其中事件7先于事件3、5到达了流式计算系统：

<img src="https://static001.geekbang.org/resource/image/dd/56/dda5bbfa757d04d41b26d5a97b4bca56.jpg" alt="">

如果设置了水位4，窗口就不再以事件顺序严格划分，而是通过水位上的时间来划分窗口，这样事件7就会放在第2个窗口中处理：

<img src="https://static001.geekbang.org/resource/image/bd/ce/bdcc6f9a03f5572882c0f59e5e8db2ce.jpg" alt="">

当然，并不是有了水位线，第1个窗口就会无限制的等下去。在经历一个时间段后，第1个窗口会认定窗口关闭（这未必准确），它会处理3、1、3、2这4个事件。基于业务规则，下一个水位被设置为9：

<img src="https://static001.geekbang.org/resource/image/7f/46/7fc2a8daa079d547a75bb257f08cd346.jpg" alt="">

这样第2个窗口会处理6、5、7事件，而事件9就放在了第3个窗口中处理：

<img src="https://static001.geekbang.org/resource/image/c0/0b/c032e4d98cfc51f29aeb4da6f4ec370b.jpg" alt="">

以此类推。根据业务特性和经验值，找到最大乱序时间差，再基于此设置合适的水位线，就能减轻乱序事件的影响。

## 小结

这一讲我们介绍了流式计算的实现原理，以及常用的几种分片窗口。

对于无边界的实时数据流，我们可以在时间维度上将其切分到不同的窗口中，再将每个窗口内的数据从空间维度上分发到不同的节点并行计算，在窗口结束时汇总结果，这就实现了流式计算。Apache [Flink](https://zh.wikipedia.org/wiki/Apache_Flink)、[Spark](https://en.wikipedia.org/wiki/Apache_Spark)、[Storm](https://en.wikipedia.org/wiki/Apache_Storm) 等开源产品都是这样的流式计算框架。

通过不同的窗口划分规则，可以实现不同的计算目的，包括以时间驱动的固定窗口、滑动窗口和计数窗口，以及以事件驱动的会话窗口。为了避免乱序事件的影响，还可以通过携带超时时间的Watermark水位，基于事件发生时间更精准地划分窗口。

## 思考题

最后，留给你一道讨论题。你知道Lambda架构吗？它通过分开部署的MapReduce、流式计算系统，分别完成离线计算与实时流计算，如下图所示：

[<img src="https://static001.geekbang.org/resource/image/bc/0f/bc3760eaf9e5789c2459fdb4e03ea00f.png" alt="" title="图片来源：https://www.oreilly.com/radar/questioning-the-lambda-architecture/">](https://www.oreilly.com/radar/questioning-the-lambda-architecture/)

这套系统的IT成本很高，因此大家致力于使用一套系统同时解决这两个问题。你认为这种解决方案是如何实现的？你又是如何看待流式计算发展方向的？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课让你有所收获，也欢迎你把今天的内容分享给你的朋友。
