大数据实时流计算详解

86 篇文章 49 订阅

开篇词-攻克实时流计算难点,掌握大数据未来

我曾任职于华为 2012 实验室高斯部门,负责实时分析型内存数据库 RTANA、华为公有云 RDS 服务的研发工作。目前,我专注于移动反欺诈解决方案的研发。针对公司业务需求,我开发了一个实时流计算系统,并在此基础上完成了风控系统的研发。最终,这个系统被一个独角兽收购。

最近这两年,越来越多的业务和数据分析对实时性提出更高的要求,与之对应解决实时计算问题的流计算框架,也开始流行起来。

因为工作原因,常有人问我有关实时流计算系统的问题。整体观察下来我发现:很多时候,他们并非不知道这些框架 ,也并非不熟悉这些框架的 API 和工作原理,而是不清楚如何将框架,运用到实时业务中去,也就不能很好地解决落地问题

业务功能要求实时,我该怎么落地?

在此之前,我想先说一点:如果你的业务相对简单,通过查数据库的方式,就能够做到毫秒级返回,那也没有必要去研究更复杂的技术。正所谓,“如无必要,勿增实体”,保持一切简单就好。

但是当请求非常多、数据量非常大,并且对请求时延要求非常严格时,比如,必须在毫秒甚至微秒级返回,那么问题就变得复杂了。比如这些场景:

实时检测异常的反欺诈或风控系统;
实时展示业务报表的大屏系统;
实时计算用户兴趣偏好的推荐系统;
实时统计过车流量的智能交通系统。

面对以上业务场景,如果按照传统数据库增删查改的方法,需要将数据全部记录在数据库中,然后在查询时,再即时遍历和计算。很明显,这种方案不管是存储空间,还是计算时间,成本都非常高,已经不能有效地进行实时计算了。

因此,原本习惯了做增删查改业务逻辑开发的人员,在初次接触实时流计算业务场景时,不可避免地会遇到种种难题,比如以下几点

  1. 需要统计的时间窗口很长,数据量也很大。比如 “相同设备在 3 个月内注册事件的次数”,此时,如果你想实时计算获得结果,就不能够通过遍历数据库的方式来实现了。

  2. 需要统计的变量,其值域非常大。比如 “同一用户在 6 个月内使用不同 IP 个数”,如果是数亿用户和数亿 IP,你还能够用集合来记录这些不同的值吗?更何况,还需要在指定的时间范围内进行计算。

  3. 一次完整的业务,可能需要计算数十个甚至数百个特征。比如,实时风控系统中,风控模型的输入便是如此。为了保证用户体验,风控系统必须在数秒甚至数百毫秒内返回。

  4. 有些问题的算法,天然就很复杂,数据量又很大,如何做到实时计算呢?比如,社交网络的二度关联分析,还有许多复杂的统计学习和机器学习模型。

  5. 甚至有些时候,产品和开发人员都不清楚,是否需要或者能够,使用实时流计算技术。或许难以置信,但这样的公司和开发人员,真的不在少数。

如果想切实解决这些难题,就需要透过现象看本质。我认为,之所以会出现上面的种种难题,主要是因为以下五种原因

  • 一是,缺乏对实时流计算技术以及它的适用场景的整体认识;

  • 二是,不知道如何用 “流” 来实现各种业务逻辑的异步和高并发计算;

  • 三是,不知道如何针对 “流” 这种独特的数据模式,设计实时算法;

  • 四是,对各种流计算框架的认识只停留在 API 调用层面,而没有理解其背后的设计原理,也就是 “流” 这种计算模式的,核心概念和关键技术点;

  • 五是,缺少对一些已有案例的借鉴和思考。

如何解决实时流计算问题?

既然明确了问题,接下来我们应该怎样克服呢?我认为可以从系统架构和实时算法两个方面来突破。

系统架构

从架构师的角度看,要为产品设计一个好的实现方案,既要有足够的技术储备,也要充分理解具体的业务问题。通过分析各类实时业务场景,我们可以发现,大多数方案都是基于 “流计算” 技术的。

“流计算”本质上是一种 “异步” 编程方法。业务数据像 “流水” 一样,通过“管道”,也就是“队列”,持续不断地流到各个环节的子系统中,然后由各个环节的子系统独立处理。所以,为了更快地处理“流”,可以通过增加管道的数量,来提高流计算系统的并行处理能力。

目前,开源的流计算框架虽然有许多(比如 Storm、Spark Streaming、Samza 和 Flink),但其实这些主流框架背后,都有着一套类似的设计思路和架构模式。它们都涉及流数据状态、流信息状态、反向压力、消息可靠性等概念。先行理解这套设计思路和架构模式,可以帮助你快速掌握,所有主流流计算框架的工作原理

实时算法

系统架构提供了整体的计算框架,但要实现具体的业务功能,还需要针对 “流数据” 设计合适的算法。 毕竟,与传统 “块数据” 相比,“流数据”需要连续不断并且实时地进行处理。

对于实时流计算中的算法,最最核心的问题,在于解决 “大数据量” 和“实时计算”之间的矛盾。数据量一大,几乎所有事情都会变得复杂和缓慢。“大数据量”的问题,集中在四个方面:时间窗口很长、业务请求量很大、内存受限、数据跨网络访问。

为了实现 “实时计算” 的效果,需要你针对算法做非常精心的设计。所幸的是,这些算法的设计和实现也是有规律可循的。 你只需要掌握几种特定类型的算法,比如计数、求和、均值、方差、直方图、分位数、HyperLogLog 等。而对于更加复杂的算法,如果不能直接进行实时计算,那我们可以通过 Lambda 架构来解决!

课程设计思路

本课程就是从 “系统架构” 和 “实时算法” 这两个方面,来带你理解实时流计算系统。为此,我为你设计了以下学习路径。(注意,模块三为 “实时算法” 部分,其余模块为 “系统架构” 相关。)

模块一,实时流计算入门。我将介绍流计算系统的整体架构和使用场景,以及入门流计算前,需掌握的编程基础,比如 NIO 和异步编程,以及异步系统中的 OOM 和反向压力问题。

借此,你会对实时流计算系统有个整体的认识,并对 “流” 的本质有个初步理解。

模块二,自己动手做一个流计算框架。 我将介绍如何从 JDK 里最基础的工具类,一步步开发出一个分布式流计算框架。

通过这种自己动手的方式,希望帮助你理解流计算系统的核心概念及实现原理。

模块三,核心技术篇。我将详细讲解流计算能够解决哪些类型的问题,包括流数据操作时间维度聚合计算关联图谱分析事件序列分析模型学习和预测等。此外,还将讨论流计算过程中非常重要的状态管理问题,并带你思考如何最终将前面的流计算框架扩展为分布式系统

借此,你会掌握实时流计算中涉及的各种算法,这些算法会有助于你解决各种实时业务场景中的问题。

模块四,开源流计算框架原理解析及实战。 我将详细对比和分析,各种开源流计算框架的具体实现,来巩固你对流计算核心概念和技术的理解,并带你正确理解这些框架的 API 设计,以便你在各种业务场景下,能够灵活地使用它们,最终实现各种复杂的业务逻辑。

此外,我还会通过两个案例,也就是实时风控和实时数据同步,来带你理解如何将开源流计算框架,运用到具体的业务场景中。

讲师寄语

本课程对实时流计算技术的关键点,做了提纲挈领的分析和讲解,期望你能够从点到面而知全局,迅速领悟大多数流计算框架的本质,在方案选型和软件开发时,做到胸有成竹。

在流计算技术尚未在国内兴起之前,我就根据公司业务需要,从头开始设计并实现了自己的流计算框架。这是我的实战经验总结,它经得起事实验证。

未来,实时流计算技术必然会成为大数据的主流模式,数据不仅以 “流” 的方式被处理,还以 “流” 的方式被存储。希望这个课,给你切实的帮助。

01-实时流计算的通用架构

为什么把本课时作为第一课时呢?因为通过本课时,你将构建起对流计算技术和系统的整体认识,这样既可以为后面的课时打下基础,又可以对设计和开发实时流计算应用有所启发。

任何一个系统的产生,都是为了解决一个具体的问题。实时流计算技术的诞生,就是为了更快更完整地获取数据,更快更充分地挖掘出数据价值。

我们不妨先来看看几个实时流计算技术的应用场景。根据这些场景,我们可以大体上知道它的通用架构。

实时流计算技术应用场景

图 1 是某打车软件公司交通热点路段分析及可视化系统的示意图。

在这个系统中,从车载设备上发出的数据,被一个基于 Kafka API 的数据采集模块接收,然后发送到 Spark Streaming 模块进行处理,并且还使用机器学习模型进行分析,然后分析的结果以 JSON 的形式存储到数据库中,并提供给可视化模块进行展示和分析。

我们再来看另一个金融风控的例子。图 2 是一个基于 Flink 的实时欺诈检测平台。

在这个平台中,从手机等各种支付渠道产生的交易数据,被数据采集服务器收集起来,并发送到 Kafka。然后 Flink 从 Kafka 中将交易数据取出来,采用基于机器学习的风控模型,进行风险分析和评估。然后分析的结果再次发送到 Kafka,后续支付网关就可以根据这些交易的欺诈风险等级,来允许或阻止交易进行。

实时流计算系统通用架构

比较上面两个场景的流计算系统组成,我们不难发现这些系统,都包含了五个部分:数据采集、数据传输、数据处理、数据存储和数据展现

事实上,也正是这五个部分,构成了一般通用的实时流计算系统,它们之间的组成关系如下图 3 所示。

在上图 3 中,数据采集模块用于接收来自各种数据源的数据,比如互联网上的各种移动设备、物联网上的各种传感器,内部网络中部署在各个服务模块上的日志代理等。数据采集模块收集到这些数据后,对数据进行一定整理,再将数据发送到数据传输模块。

数据传输模块通常是消息中间件,比如 Kafka,之后再由数据处理模块从数据传输模块中取出数据来进行处理。数据处理模块是流计算系统的核心,在这个模块中会实现流计算应用的各种业务功能。

之后,计算结果被重新发送到数据传输模块,并由数据存储模块取出后,保存到各种类型的数据库中。最后,数据展示模块会通过 API 或者 UI 的方式对结果进行展示。

下面我来逐一详细介绍下通用架构的五个部分。

数据采集

俗话说 “巧妇难为无米之炊”,有数据了,我们才能进行流计算,所以我们先来看看应该怎样采集数据。

数据采集,就是从各种数据源收集数据的过程,比如浏览器、手机、工业传感器、日志代理等。怎样开发一个数据采集服务呢?最简单的方式,就是用 Spring Boot 开发一个 REST 服务,这样,我们就可以用 HTTP 请求的方式,从浏览器、手机等终端设备,将数据发送到数据采集服务器。

这么一看,数据采集服务器似乎很简单!其实不然,这中间还是有很多问题需要认真考虑。如果考虑不周的话,很可能你花冤枉钱买了许多服务器,但是系统的性能却依旧十分可怜。

为了避免在以后的开发中出现这种问题,这里我想跟你分享下我在日常开发 Web 服务时考虑的五个关键点。

  • 第一点是吞吐量。我们一般用 TPS(Transactions Per Second),也就是每秒处理事务数,来描述系统的吞吐量。当吞吐量要求不高时,选择的余地往往更大些。你可以随意采用阻塞 IO ,或非阻塞 IO 的编程框架。但是当吞吐量要求很高时,通常就只能选择非阻塞 IO 的编程框架了。如果采用阻塞 IO 方式时,需要开启数千个线程,才能使吞吐量最大化,就可以考虑换成非阻塞 IO 的方案了。

  • 第二点是时延。当吞吐量和时延同时有性能要求时,我一般是先保证能够满足时延要求,然后在此基础上,再尽可能提高吞吐量。如果一个服务实例的吞吐量,满足不了要求,就部署多个服务实例。对于互联网上的应用,如果吞吐量很大,为保证时延,还需要使用类似于 CDN 的方案。

  • 第三点是发送方式。数据可以逐条发送,也可以批次发送。相比逐条发送而言,批次发送每次的网络 IO 耗时更多,为了提升接收服务器的吞吐能力,我一般也会采用 Netty 这样的非阻塞 IO 框架。

  • 第四点是连接方式。使用长连接还是短连接,一般由具体的场景决定。当有大量连接需要维持时,就需要使用非阻塞 IO 服务器框架,比如 Netty。而当连接数量较少时,采用长连接和连接池的方案,一般也会非常显著提升请求处理的性能。

  • 第五点是连接数量。如果数据源相对固定,比如微服务之间的调用,那我们可以采用长连接配合连接池的方案,这样一般会非常显著地提升请求处理的性能。但当数据源很多或经常变化时,应该将连接保持时间(Keep Alive Timeout)设置为一个合理的值。

总的来说,在大多数情况下,数据接收服务器选择诸如 Netty 的非阻塞 IO 方案,都会更加合适。

数据采集之后,我们一般还需要做些简单的处理,比如提取出感兴趣的字段,或者对字段进行调整等,然后再将调整好的字段,组成格式统一的数据,比如 JSON、AVRO、Protobuf 等。最后将整理好的数据,发往到数据传输系统。

数据传输

我们这里说的数据传输,是指流数据在各个模块间流转的过程。

流计算系统中,一般是采用消息中间件进行数据传输的,比如 Apache Kafka、RabbitMQ 等,在微服务系统中一般是采用 HTTP 或 RPC 的方式进行数据传输。这是流计算系统与微服务系统最明显的区别。

在选择消息中间件时,你需要重点考虑五个方面的问题:吞吐量、时延、高可用、持久化和水平扩展。为什么呢?

这是因为,吞吐量和时延,通常是由产品和业务需求决定的。比如,产品要求系统能够支持 10K 的 TPS,并且 99% 消息的时延不能超过 100ms,那我们部署的消息中间,吞吐量一定要显著超过 10K,时延要显著低于 100ms,因为还需要留出非常大的空间,来处理业务逻辑。

而高可用和持久化,则是保证我们系统,能够正确稳定运行的重要因素。

高可用是指消息中间件的一个或多个节点,在发生故障时,仍然能够持续提供正常服务。比如双 11 的零点,大家都在拼命剁手,此时如果因为一个节点磁盘写满,而导致整个系统不能下单,那真的就是瞬间错失一个亿的小目标了。

持久化则是指消息中间件里的消息,写入磁盘等存储介质后,重启时消息不会丢失。比如在消息中间件 Kafka 中,同一份数据在不同的物理节点上,保存多个副本,即使一个节点的数据,完全丢失,也能够通过其他节点上的数据副本,恢复出原来的数据。

水平扩展也是个非常重要的考量因素。当业务量逐渐增加时,原先的消息中间件处理能力逐渐跟不上,这时需要增加新的节点,以提升消息中间件的处理能力。比如 Kafka 可以通过增加 Kafka 节点和 topic 分区数的方式水平扩展处理能力。

总的来说,数据传输系统就像人体的血管,承载了实时流计算系统中数据的传输。一个高吞吐、低时延、支持高可用和持久化,且能水平扩展的数据传输系统,是构建优秀实时流计算应用的基础。目前,像 Kafka 和 Pulsar 都是不错的数据传输系统选择。

数据处理

接下来,我们来看下流计算系统的核心模块,即数据处理。为什么说数据处理,是流计算系统的核心呢?这是因为在数据处理模块,我们将实现各种业务功能,比如数据过滤、聚合计算、CEP、模型训练等。

我们构建实时流计算系统的目的,就是为了解决具体的业务问题。总的来说,这些业务问题可以分为以下四类。

  • 第一类是数据转化。数据转化包括对流数据的抽取、清洗、转换和加载。比如使用 filter 函数过滤出符合条件的流数据,使用 map 函数给流数据增加新的字段。再比如更复杂的 Flink SQL CDC,也属于数据转化的内容。

  • 第二类是在流数据上,统计各种指标,比如计数、求和、均值、标准差、极值、聚合、关联、直方图等。

  • 第三类是模式匹配。模式匹配是指在流数据上,寻找预先设定的事件序列模式。比如我们常说的 CEP,也就是复杂事件处理,就属于模式匹配。

  • 第四类是模型学习和预测。基于流的模型学习算法,可以实时动态地训练或更新模型参数,继而根据模型做出预测,能更加准确地描述数据背后当时正在发生的事情。

数据处理是流计算的核心,也是一个流计算应用开发人员最应该掌握的知识点。这部分的内容是非常丰富且有一定难度的,我将在本课程的模块三中,对数据处理问题进行详细讲解。

数据存储

使用实时流计算技术,一顿操作猛如虎,结果不记录,或者不输出结果的话,那就是算了个寂寞。所以数据处理过程中,必然会涉及,数据存储的问题。而数据存储,是一个非常麻烦的问题,特别是在实时流计算领域,这种大数据、低时延、高吞吐的场景,对我们的数据存储方案,挑战是非常大的。

不知道你是否考虑过这个问题,为什么软件行业,有那么多不同种类的数据库?MySQL、MongoDB、Redis、HBase、ElasticSearch、CockroachDB…… 随便想一下,就可以列举出数十种数据库。

这是因为每种数据库,其实都有其擅长的使用场景,没有一种数据库能够在所有场景下都能胜任,所以我在这里先抛砖引玉,针对实时流计算中几种最常见的场景,讲解下应该选择怎样的存储方案。

在实时风控场景下,我们经常需要计算诸如 “过去一天同一设备上登录的不同用户数” 这种类型的查询。在数据量较小时,使用传统关系型数据库和结构化查询语言是个不错的选择。

但当数据量变得很大后,这种基于关系型数据库的方案会变得越来越吃力,直到最后根本不可能在实时级别的时延内完成计算。这个时候,如果采用像 Redis 这样的 NoSQL 数据库并结合优化的算法设计,就能够做到实时查询,并获得更高的吞吐能力。所以相比传统 SQL 数据库,实时流计算中会更多地使用 NoSQL 数据库。

很多时候,我们需要将实时流计算的状态或者结果存储下来,以供其他服务根据一个或多个健,来查询一条特定的,实时计算记录。那这个时候,我们可以选择像 MongoDB 这样的 NoSQL 数据库。当然,这个时候如果在 MongoDB 之上,再配上一个 Redis 缓存也是极好的。

还有些时候,我们需要在 UI 上展现实时流计算的结果。不知道其他人是怎样想的,反正在我这个后端开发眼里,那些产品同学总喜欢在 UI 上设计一些 “莫名其妙” 的交互式查询,比如任意可选的查询条件和查询方式。那这个时候,我们选择的存储方案,就一定不能太“僵硬”,此时采用像 ElasticSearch 这样搜索引擎一类的存储方案,一定是个明智的选择。

总的来说,在相对复杂的业务场景下,实时流计算可能只是系统中的一个环节。我们需要针对不同的计算类型和查询目的,选择合适的存储方案。当一种数据库满足不了业务的需求时,我们还会将相同的数据,存入多种不同的存储。毕竟到目前为止,还没有一种能称之为 “银弹” 的数据库。

数据展现

最后就是数据展现模块了,数据展现是将数据呈现给最终用户的过程。

数据展现的形式,可以是 API,也可以是 UI。

  • API 相对简单,比如用 Spring Boot 就很容易开发一个 REST API 服务。

  • UI 目前越来越多的是采用 Web UI 的方式。

基于 Web 的 UI 有很多优点。一方面,其部署和访问都非常简单,只需要启动 Web 服务,然后在浏览器访问即可。另一方面,各种丰富的前端框架和数据可视化框架,为开发提供了更多的便利和选择,比如前端常用的框架,就有 React、Vue、Angular 等,然后常用的数据可视化框架,则有 ECharts、D3.js 等。

由于数据展示,更加偏向于前端(包括 UI 设计、JS、CSS 和 HTML 等),这与实时流计算的主体,并无太强关联,所以我在本课程中,不会专门讨论数据展示的内容。

小结

今天,我依据几种不同场景的流计算系统,总结了一个通用的流计算系统架构,然后带你了解了这个架构中各个模块在整个系统中起到的作用。

相信你在以后开发实时流计算应用时,十有八九会用到上面这种架构,并且一定会碰到我跟你讲的这些问题,尤其是数据采集、数据处理和数据存储三个模块。

  • 数据采集模块的难点,一定会在高并发和高吞吐场景下暴露出来,这点需要你对 NIO 和异步编程有非常深刻的理解。

  • 数据处理模块的难点,则主要表现在与业务的贴合。这要求你对流计算能够解决哪些问题有比较深刻的理解,并需要熟练掌握解决这些问题的算法。

  • 数据存储模块的难点,则主要表现在能否根据具体的使用场景,选择最合适的存储方案。而实时流计算中,会涉及多种不同类型的数据存储问题。

不过不用担心,在接下来的课程中,我将会为你详细讲解 NIO 和异步编程的问题。至于数据处理和数据存储的内容,则会在本课程的模块三进行详细讨论。

那么你在工作中,有没有遇到比较难解决的实时计算或流计算问题呢?你可以先把这些问题放在留言区,我会时刻关注,并在后续文章为你着重强调哦!

本课时精华:

02-异步和高并发:为什么 NIO 是异步和高并发编程的基础

为什么在讲流计算之前,要先讲异步和高并发的问题呢?

  • 其一,是因为 “流” 本质是异步的,可以说 “流计算” 也是一种形式的异步编程。

  • 其二,是因为对于一个流计算系统而言,其起点一定是数据采集,没数据就什么事情都做不了,而数据采集通常就会涉及 IO 问题,如何设计一个高性能的 IO 密集型应用,异步和并发编程既是过不去的坎,也是我们掌握高性能 Java 编程的基础。

所以,在这个课时中,我们就从数据采集模块切入,通过开发一个高性能的数据采集模块,从实战中理解 NIO、异步和高并发的原理。这样,当你以后开发高性能服务时,比如需要支持数万甚至数百万并发连接的 Web 服务时,就知道如何充分发挥出硬件资源的能力,就可以用最低的硬件成本,来达到业务的性能要求。

为了更方便地说明问题,我们今天的讨论,以从互联网上采集数据为例。具体来说,如下图 1 所示,数据通过 REST 接口,从手机或网页端,发送到数据采集服务器。

图 1 基于 REST 协议的数据采集服务器

BIO 连接器的问题

由于是面向互联网采集数据,所以我们要实现的数据采集服务器,就是一个常见的 Web 服务。说到 Web 服务开发,作为 Java 开发人员,十有八九会用到 Tomcat。毕竟 Tomcat 一直是 Spring 生态的默认 Web 服务器,使用面是非常广的。

但使用 Tomcat 需要注意一个问题。在 Tomcat 7 及之前的版本中, Tomcat 默认使用的是 BIO 连接器, BIO 连接器的工作原理如下图 2 所示。

图 2 BIO 连接器工作原理

当使用 BIO 连接器时,Tomcat 会为每个客户端请求,分配一个独立的工作线程进行处理。这样,如果有 100 个客户端同时发送请求,就需要同时创建 100 个工作线程。如果有 1 万个客户端同时请求,就需要创建 1 万个工作线程。而如果是 100 万个客户端同时请求呢?是不是需要创建 100 万个工作线程?

所以,BIO 连接器的最大问题是它的工作线程和请求连接是一一对应耦合起来的。当同时建立的请求连接数比较少时,使用 BIO 连接器是合适的,因为这个时候线程数是够用的。但考虑下,像 BATJ 等大厂的使用场景,哪家不是成万上亿的用户,哪家不是数十万、数百万的并发连接。在这些场景下,使用 BIO 连接器就根本行不通了。

所以,我们需要采取新的方案,这就是 Tomcat NIO 连接器。

使用 NIO 支持百万连接

毫无意外的是,从 Tomcat 8 开始,Tomcat 已经将 NIO 设置成了它的默认连接器。所以,如果你此时还在使用 Tomcat 7 或之前的版本的话,需要检查下你的服务器,究竟使用的是哪种连接器。

图 3 NIO 连接器工作原理

图 3 是 NIO 连接器的工作原理。可以看出,NIO 连接器相比 BIO 连接器,主要做出了两大改进

  • 一是,使用 “队列” 将请求接收器和工作线程隔开;

  • 二是,引入选择器来更加精细地管理连接套接字。

NIO 连接器的这两点改进,带来了两个非常大的好处。

  • 一方面,将请求接收器和工作线程隔离开,可以让接收器和工作线程,各自尽其所能地工作,从而更加充分地使用 IO 和 CPU 资源。

  • 另一方面,NIO 连接器能够保持的并发连接数,不再受限于工作线程数量,这样无须分配大量线程,数据接收服务器就能支持大量并发连接了。

所以,使用 NIO 连接器,我们解决了百万并发连接的问题。但想要实现一个高性能的数据采集服务器,光使用 NIO 连接器还不够。因为当系统支持百万并发连接时,也就意味着我们的系统是一个吞吐量非常高的系统。这就要求我们在实现业务逻辑时,需要更加精细地使用 CPU 和 IO 资源。否则,千辛万苦改成 NIO 的努力,就都白白浪费了。

如何优化 IO 和 CPU 都密集的任务

考虑实际的应用场景,当数据采集服务器在接收到数据后,往往还需要做三件事情:

  • 一是,对数据进行解码;

  • 二是,对数据进行规整化,包括字段提取、类型统一、过滤无效数据等;

  • 三是,将规整化的数据发送到下游,比如消息中间件 Kafka。

在这三个步骤中,1 和 2 主要是纯粹的 CPU 计算,占用的是 CPU 资源,而 3 则是 IO 输出,占用的是 IO 资源。每接收到一条数据,我们都会执行以上三个步骤,所以也就构成了类似于图 4 所示的这种循环。

图 4 CPU 和 IO 都密集型任务

从图 4 可以看出,数据采集服务器是一个对 CPU 和 IO 资源的使用都比较密集的场景。为什么我们会强调这种 CPU 和 IO 的使用都比较密集的情况呢?因为这是破解 “NIO 和异步” 为什么比 “BIO 和同步” 程序,性能更优的关键所在!下面我们就来详细分析下。

如果想提高 IO 利用率,一种简单且行之有效的方式,是使用更多的线程。这是因为当线程执行到涉及 IO 操作或 sleep 之类的函数时,会触发系统调用。线程执行系统调用,会从用户态进入内核态,之后在其准备从内核态返回用户态时,操作系统将触发一次线程调度的机会。对于正在执行 IO 操作的线程,操作系统很有可能将其调度出去。这是因为触发 IO 请求的线程,通常需要等待 IO 操作完成,操作系统就会暂时让其在一旁等着,先调度其他线程执行。当 IO 请求的数据准备好之后,线程才再次获得被调度的机会,然后继续之前的执行流程。

但是,是不是能够一直将线程的数量增加下去呢?不是的!如果线程过多,操作系统就会频繁地进行线程调度和上下文切换,这样 CPU 会浪费很多的时间在线程调度和上下文切换上,使得用于有效计算的时间变少,从而造成另一种形式的 CPU 资源浪费。

所以,针对 IO 和 CPU 都密集的任务,其优化思路是,尽可能让 CPU 不把时间浪费在等待 IO 完成上,同时尽可能降低操作系统消耗在线程调度上的时间

那具体如何做到这两点呢?这就是接下来要讲的,“NIO”结合 “异步” 方法了。

NIO 结合异步编程

既然要说异步,那什么是异步?举个生活中的例子。当我们做饭时,在把米和水放到电饭锅,并按下电源开关后,不会干巴巴站在一旁等米饭煮熟,而是会利用这段时间去炒菜。当电饭锅的米饭煮熟之后,它会发出嘟嘟的声音,通知我们米饭已经煮好。同时,这个时候我们的菜肴,也差不多做好了。

在这个例子中,我们没有等待电饭锅煮饭,而是让其在饭熟后提醒我们,这种做事方式就是 “异步” 的。反过来,如果我们一直等到米饭煮熟之后再做菜,这就是 “同步” 的做事方式。

对应到程序中,我们的角色就相当于 CPU ,电饭锅煮饭的过程,就相当于一次耗时的 IO 操作,而炒菜的过程,就相当于在执行一段算法。很显然,异步的方式能更加有效地使用 CPU 资源。

那在 Java 中,应该怎样完美地将 NIO 和异步编程结合起来呢?这里我采用了 Netty 框架,和 CompletableFuture 异步编程工具类。具体可以看看这段代码(完整代码):

Executor decoderExecutor = ExecutorHelper.createExecutor(2, "decoder");


Executor ectExecutor = ExecutorHelper.createExecutor(8, "ect");


Executor senderExecutor = ExecutorHelper.createExecutor(2, "sender");


@Override


protected void channelRead0(ChannelHandlerContext ctx, HttpRequest req) throws Exception {


    CompletableFuture


            .supplyAsync(() -> this.decode(ctx, req), this.decoderExecutor)


            .thenApplyAsync(e -> this.doExtractCleanTransform(ctx, req, e), this.ectExecutor)


            .thenApplyAsync(e -> this.send(ctx, req, e), this.senderExecutor);


}

在上面的代码中,由于 Netty 框架本身已经处理好 NIO 的问题,所以我们的工作重点放在实现 “异步” 处理上。Netty 框架里的 channelRead0 函数,是实现业务逻辑的地方,于是我在这个函数中,将请求处理逻辑细分为,解码(decode)、规整化(doExtractCleanTransform)、发送(send)三个步骤,然后使用 CompletableFuture 类的方法,将这三个步骤串联起来,构成了最终的异步调用链。

至此,我们终于将数据采集服务的整个请求处理过程,都彻彻底底地异步化。所有 CPU 密集型任务和 IO 密集性任务都被隔离开,在各自分配的线程里独立运行,彼此互不影响。这样, CPU 和 IO 资源,都能够得到充分利用,程序的性能也能够彻底释放出来。

小结

今天,我们为了实现了高性能的数据采集服务器,详细分析了 NIO 和异步编程的工作原理,其中,还涉及了一些有关操作系统进行线程调度的知识。我们实现的基于 Netty 的,数据采集服务器,将 NIO 和异步编程技术结合起来,整个请求处理过程都是异步的,最大限度地发挥出, CPU 和 IO 资源的使用效率。

但是,有关异步的内容,还没完全讨论完。在接下来的课程中,我们将着重讨论异步系统的一些问题。我们后面会发现,异步系统的这些问题,也会出现在流计算系统中。

相信通过今天的学习,你对高并发的基础,也就是 NIO 和异步编程,已经有一定理解。那你知道如何在 Spring 框架下,实现 NIO 和异步编程吗?在留言区写出你的想法吧。

本课时精华:

异步编程

在02课我们使用了 Netty 并配合 Java 8 中的 CompletableFuture 类,构建了一个完全异步执行的数据采集服务器。经过这种改造,CPU 和 IO 的使用效率被充分发挥出来,显著提高了服务器在高并发场景下的性能。

异步系统中的 OOM 问题

回想下02课中,基于 Netty 和 CompletableFuture 类的数据采集服务器,关键是下面这部分代码(请参见完整代码):

public static ExecutorService createExecutor(int nThreads, String threadNamePrefix) {


return Executors.newFixedThreadPool(nThreads, threadNameThreadFactory(threadNamePrefix));


}


final private Executor decoderExecutor = createExecutor(2, "decoder");


final private Executor ectExecutor = createExecutor(8, "ect");


final private Executor senderExecutor = createExecutor(2, "sender");


@Override


protected void channelRead0(ChannelHandlerContext ctx, HttpRequest req) {


    CompletableFuture


            .supplyAsync(() -> this.decode(ctx, req), this.decoderExecutor)


            .thenApplyAsync(e -> this.doExtractCleanTransform(ctx, req, e), this.ectExecutor)


            .thenApplyAsync(e -> this.send(ctx, req, e), this.senderExecutor);


}

从上面的代码可以看出,我们在进行请求处理时,采用了 CompletableFuture 类提供的异步执行框架。在整个执行过程中,请求的处理逻辑都是提交给每个步骤各自的执行器,来进行处理,比如 decoderExecutor、ectExecutor 和 senderExecutor。

仔细分析下这些执行器你就会发现,在上面异步执行的过程中,没有任何阻塞的地方。只不过每个步骤都将它要处理的任务,存放在了执行器的任务队列中。每个执行器,如果它处理得足够快,那么任务队列里的任务都会被及时处理。这种情况下不存在什么问题。

但是,一旦有某个步骤处理的速度比较慢,比如在图 1 中,process 的速度比不上 decode 的速度,那么,消息就会在 process 的输入队列中积压。而由于执行器的任务队列,默认是非阻塞且不限容量的。这样,任务队列里积压的任务,就会越来越多。终有一刻,JVM 的内存会被耗尽,然后抛出 OOM 异常,程序就退出了。

所以,为了避免 OOM 的问题,我们必须对上游输出给下游的速度做流量控制。那怎么进行流量控制呢?

一种方式,是严格控制上游的发送速度。比如,控制上游每秒钟只能发送 1000 条消息。这种方法是可行的,但是非常低效。如果实际下游每秒钟能够处理 2000 条消息,那么,上游每秒钟发送 1000 条消息,就会使得下游一半的性能没有发挥出来。如果下游因为某种原因,性能降级为每秒钟只能处理 500 条消息,那么在一段时间后,同样会发生 OOM 问题。

所以,我们该如何进行流量控制呢?这里有一种更优雅的方法,也就是反向压力

反向压力原理

在反向压力的方案中,上游能够根据下游的处理能力,动态地调整输出速度。当下游处理不过来时,上游就减慢发送速度,当下游处理能力提高时,上游就加快发送速度。

反向压力的思想,已经成为流计算领域的共识,并且形成了反向压力相关的标准,也就是 Reactive Streams

上面的图 2 描述了 Reactive Streams 的工作原理。当下游的消息订阅者,从上游的消息发布者接收消息前,会先通知消息发布者自己能够接收多少消息。然后消息发布者就按照这个数量,向下游的消息订阅者发送消息。这样,整个消息传递的过程都是量力而行的,就不存在上下游之间因为处理速度不匹配,而造成的 OOM 问题了。

目前,一些主流的异步框架都开始支持 Reactive Streams 标准,比如 RxJava、Reactor、Akka Streams、Vert.x 等。这足以说明, OOM 和反向压力问题在异步系统中是多么重要!

实现反向压力

现在,我们回到 Netty 数据采集服务器。那究竟该怎样为这个服务器加上反向压力的功能呢?

前面我们分析了异步执行的过程,之所以会出现 OOM 问题,主要还是因为,接收线程在接收到新的请求后,触发了一系列任务。这些任务都会被存放在任务队列中,并且这些任务队列,都是非阻塞且不限容量的。

因此,要实现反向压力的功能,只需要从两个方面来进行控制。

  1. 其一是,执行器的任务队列,它的容量必须是有限的。

  2. 其二是,当执行器的任务队列已经满了时,就阻止上游继续提交新的任务,直到任务队列,重新有新的空间可用为止。

按照上面这种思路,我们就可以很容易地实现反向压力。下面的图 3 就展示了,使用容量有限的阻塞队列,实现反向压力的过程。

当 process 比 decode 慢时,运行一段时间后,位于 process 前的任务队列就会被填满。当 decode 继续往里面提交任务时,就会被阻塞,直到 process 从这个任务队列中取走任务为止。

以上说的都是实现原理。那具体用代码该怎样实现呢?下面就是这样一个具备反向压力能力的 ExecutorService 的具体实现。

private final List<ExecutorService> executors;


private final Partitioner partitioner;


private Long rejectSleepMills = 1L;


public BackPressureExecutor(String name, int executorNumber, int coreSize, int maxSize, int capacity, long rejectSleepMills) {


this.rejectSleepMills = rejectSleepMills;


this.executors = new ArrayList<>(executorNumber);


for (int i = 0; i < executorNumber; i++) {


        ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(capacity);


this.executors.add(new ThreadPoolExecutor(


                coreSize, maxSize, 0L, TimeUnit.MILLISECONDS,


                queue,


new ThreadFactoryBuilder().setNameFormat(name + "-" + i + "-%d").build(),


new ThreadPoolExecutor.AbortPolicy()));


    }


this.partitioner = new RoundRobinPartitionSelector(executorNumber);


}


@Override


public void execute(Runnable command) {


boolean rejected;


do {


try {


           rejected = false;


           executors.get(partitioner.getPartition()).execute(command);


        } catch (RejectedExecutionException e) {


            rejected = true;


try {


                TimeUnit.MILLISECONDS.sleep(rejectSleepMills);


            } catch (InterruptedException e1) {


                logger.warn("Reject sleep has been interrupted.", e1);


            }


        }


    } while (rejected);


}

在上面的代码中,BackPressureExecutor 类在初始化时,新建一个或多个 ThreadPoolExecutor 对象,作为执行任务的线程池。这里面的关键点有两个。

  • 第一个是,在创建 ThreadPoolExecutor 对象时,采用 ArrayBlockingQueue。这是一个容量有限的阻塞队列。因此,当任务队列已经满了时,就会停止继续往队列里添加新的任务,从而避免内存无限大,造成 OOM 问题。

  • 第二个是,将 ThreadPoolExecutor 拒绝任务时,采用的策略设置为 AbortPolicy。这就意味着,在任务队列已经满了的时候,如果再向任务队列提交任务,就会抛出 RejectedExecutionException 异常。之后,我们再通过一个 while 循环,在循环体内,捕获 RejectedExecutionException 异常,并不断尝试,重新提交任务,直到成功为止。

这样,经过上面的改造,当下游的步骤执行较慢时,它的任务队列就会占满。这个时候,如果上游继续往下游提交任务,它就会不停重试。这样,自然而然地降低了上游步骤的处理速度,从而起到了流量控制的作用。

接下来,我们就可以在数据接收服务器中,使用这个带有反向压力功能的 BackPressureExecutor 了(请参见完整代码)。

final private Executor decoderExecutor = new BackPressureExecutor("decoderExecutor",


1, 2, 1024, 1024, 1);


final private Executor ectExecutor = new BackPressureExecutor("ectExecutor",


1, 8, 1024, 1024, 1);


final private Executor senderExecutor = new BackPressureExecutor("senderExecutor",


1, 2, 1024, 1024, 1);


@Override


protected void channelRead0(ChannelHandlerContext ctx, HttpRequest req) {


    CompletableFuture


            .supplyAsync(() -> this.decode(ctx, req), this.decoderExecutor)


            .thenApplyAsync(e -> this.doExtractCleanTransform(ctx, req, e), this.ectExecutor)


            .thenApplyAsync(e -> this.send(ctx, req, e), this.senderExecutor);


}

从上面的代码可以看出,我们只需把 decode、doExtractCleanTransform 和 send 等每一个步骤用到的执行器,都替换成 BackPressureExecutor 即可。这样,就实现了反向压力功能,其他部分的代码,不需要做任何改变!

最后,还需要说明下的是,在 BackPressureExecutor 的实现中,为什么需要封装多个执行器呢?这是因为,使用 M * N 个线程,有三种不同的方法:

  • 第一种是,每个执行器使用 1 个线程,然后使用个 M * N 执行器;

  • 第二种是,每个执行器使用 M * N 个线程,然后使用 1 个执行器;

  • 第三种是,每个执行器使用 M 个线程,然后使用 N 个执行器。

在不同场景下,三种使用方式的性能表现会有所不同。根据我的经验,主要是因为,队列的生产者之间,存在着相互竞争,然后队列的消费者之间,也存在着相互竞争。所以,如果你要使用这个类的话,还是需要根据实际的使用场景,分配合适的队列数和线程数,避免对同一个队列的竞争,过于激烈。这样,有利于提升程序的性能。

小结

今天,我用反向压力的功能进行流量控制,解决了异步系统中的 OOM 问题。对于一个能够在生产环境上稳定运行的系统来说,任何使用了异步技术的地方,都需要尤其注意 OOM 问题。

其实,解决异步系统 OOM 问题的方法,并不限于反向压力。比如,我们在使用线程池时,设置线程的数量,这也是一种保护措施。但是,我们今天着重强调的是反向压力的方法。这是因为,反向压力在流计算系统中,有着非常重要的地位。像目前的流计算框架,比如 Flink、Spark Streaming 等,都支持反向压力。可以说,如果没有反向压力的功能,任何一个流计算系统,都会时时刻刻有着 OOM 崩溃的风险。

在今天的讨论中,我们已经多次用到了上游、下游,甚至是 Reactive Streams 这种,直接与 “流” 相关的字眼。我们已经隐隐约约感受到,“流”与 “异步” 之间,有着千丝万缕的关系。在接下来的课程中,我们还会专门讨论到,它们之间的关联关系。

相信通过今天的课程,你在以后使用异步编程时,一定会注意到系统的 OOM 问题。你在以往的编程中,有没有遇到过 OOM 问题呢?有的话,可以在评论区留言,我看到后会和你一起分析解决!

本课时精华:

04-流与异步:为什么说掌握流计算先要理解异步编程

在前面的课时中,我们详细分析了 “异步” 的工作原理,并且在解决异步系统的 OOM 问题时,使用了 “反向压力” 的方法。在讨论过程中,我们已经明确地使用到,诸如上游、下游、streams 这样的概念都暗示着我们,“流”和 “异步” 之间有着某种关联。

所以今天,我们就借助于目前四种主流的异步编程方案,来详细分析下 “流” 和“异步”之间这种紧密关系。

异步编程框架

说到 “异步编程” 或者“高并发编程”,你首先想到的是什么呢?

根据我以往当面试官的经验:

  • 青铜级的求职者,一般会说多线程、synchronized、锁等知识,更有甚者还会扯到 Redis 神马的。很显然,这类求职者对异步和高并发编程,其实是没有什么概念的;

  • 白银级的求职者,则会说线程池、executor、ConcurrentHashMap 等,这类同学对异步和高并发编程,已经有了初步认识,但却还不够深入;

  • 王者级别的求职者,则会对 NIO、Netty、CompletableFuture 等技术如数家珍,甚至还会谈到 Fiber。

其实很多时候,我问求职者的问题,都是在实际开发过程中,需要使用或注意的知识点,要求并不苛刻。毕竟面试的目的,是尽快招到合适的开发人员一起做事,而不是为了刁难人家。但可惜的是,我遇到最多的是青铜,少有白银,王者则更是稀有了。我自己也曾面试过某 BAT 大厂之一,记得当时最后一轮技术面,是三个不同部门的老大同时面我。他们问了我很多问题,其中印象最深的一个,就是关于异步和高并发编程的问题。当时我从 “流” 的角度,结合 NIO 和 CompletableFuture 等工具,跟他们详细讲解了我在平时开发过程中,总结出的最佳实践方案。最后,我顺利拿到了 offer。

所以,回到问题本身,当我们谈论 “异步” 和“高并发”编程时,到底是在说什么呢?通过第 02 课时的学习,我们已经知道,“高并发”其实是我们要解决的问题,而 “异步” 则是为了更有效地利用 CPU 和 IO 资源,来解决 “高并发” 问题时的编程方式。在 “高并发” 场景下,我们通常会使用 “异步” 的编程方式,来提升 CPU 和 IO 的使用效率,从而提高程序性能。

所以进一步地,我们的问题落在了选择 “异步” 编程方案上。那究竟怎样实现异步编程呢?其实,异步编程的框架非常多,目前主流的异步编程可以分为四类模式:Promise、Actor、ReactiveX 和纤程(或者说协程)。下面我们逐一讨论。

Promise 模式

Promise 模式是非常基本的异步编程模式,在 JavaScript、Java、Python、C++、C# 等语言中,都有 Promise 模式的实现。Promise 正如其名,代表了一个异步操作在其完成时会返回并继续执行的承诺

Promise 模式在前端 JavaScript 开发中,是非常常见的。这是因为 JavaScript 本身是单线程的,为了解决诸如并发网络请求的问题,JavaScript 使用了大量异步编程的技巧。早期的 JavaScript 还不支持 Promise 模式,为了实现异步编程,采用的都是回调的方式。但是回调会有一个问题,就是所谓的 “回调陷阱”。

举个例子,当你需要依次调用 A、B、C、D 四次网络请求时,如果采用回调的编程方式,那么四次网络请求的回调函数,会依次嵌套起来。这样,整个回调函数的实现会非常长,逻辑会异常复杂,不容易理解和维护。

为了解决 “回调陷阱” 的问题,JavaScript 引入了 Promise 模式。类似于下面这样:

let myPromise = new Promise(function(myResolve, myReject) {


  setTimeout(function() { myResolve("Hello World!"); }, 5000);


});


myPromise.then(function(value) {


document.getElementById("test").innerHTML = value;


})

在上面的这段 JavaScript 代码中,实现了一个异步的定时器。定时器定时 5 秒后返回,然后将 id 为 “test” 的元素设置为 “Hello World!”。

可以看出,Promise 模式将嵌套的回调过程,变成了平铺直叙的 Promise 链,这极大地简化了异步编程的复杂程度。

那在 Java 中的 Promise 模式呢?在 Java 8 之前,JDK 是不支持 Promise 模式的。好在 Java 8 为我们带来了 CompletableFuture 类,这就是 Promise 模式的实现。比如在 03 课时的异步执行代码,正是一个 Promise 链。

 CompletableFuture


            .supplyAsync(() -> this.decode(ctx, req), this.decoderExecutor)


            .thenApplyAsync(e -> this.doExtractCleanTransform(ctx, req, e), this.ectExecutor)


            .thenApplyAsync(e -> this.send(ctx, req, e), this.senderExecutor);

在上面的代码中,我们使用的 executor 都是带队列的线程池,也就是类似于下面这样。

从上面的图 1 可以看出,这个过程有生产者,有队列,有消费者,是不是已经非常像 “流”?

当然,CompletableFuture 类也可以使用其他类型的 executor,比如,使用栈管理线程的 executor。在这种 executor 的实现中,每次调用 execute() 方法时,都是从栈中取出一个线程来执行任务,像这种不带任务队列的执行器,就和 “流” 相差甚远了。

Actor 模式

Actor 模式是另外一种非常著名的异步编程模式。在这种模式中,用 Actor 来表示一个个的活动实体,这些活动实体之间以消息的方式,进行通信和交互。

Actor 模式非常适用的一种场景是游戏开发。比如 DotA 游戏里的小兵,就可以用一个个 Actor 表示。如果要小兵去攻击防御塔,就给这个小兵 Actor 发一条消息,让它移动到塔下,再发一条消息,让它攻击塔。

必须强调的是,Actor 模式最好是构建在纤程上,这样 Actor 才能随心所欲地想干吗就干吗,你写代码时就不会有过多的约束。

如果 Actor 是基于线程构建,那么当存在较多 Actor 时,Actor 的代码就不宜做过多 IO 或 sleep 操作。但大多数情况下,IO 操作都是难以避免的,所以为了减少 IO 和 sleep 操作对其他 Actor 的影响,应将涉及 IO 操作的 Actor 与其他非 IO 操作的 Actor 隔离开。给涉及 IO 操作的 Actor 分配专门的线程,不让这些 Actor 和其他非 IO 操作的 Actor 分配到相同的线程。这样可以保证 CPU 和 IO 资源,都能充分利用,提高了程序的性能。

在 JVM 平台上,比较有名的 Actor 模式是 Akka。但是 Akka 是构建在线程而非纤程上,所以使用起来就存在上面说的这些问题。

如果你要用 Akka 的话,需要注意给以 IO 操作为主的 Actor ,分配专门的线程池。另外,Akka 自身不具备反向压力功能,所以使用起来时,还需要自己进行流量控制才行。

我自己曾经实现过,感觉还是有点小麻烦的。主要的问题在于 Actor 系统对邮箱的定位,已经要求邮箱,也就是 Actor 用于接收消息的队列,最好不要阻塞。所以如果是做流量控制的话,就不能直接将邮箱,设置为容量有限的阻塞队列,这样在 Actor 系统中,非常容易造成死锁。

ReactiveX 模式

ReactiveX 模式又称之为响应式扩展,它是一种观察者模式。在 Java 中,ReactiveX 模式的实现是 RxJava。ReactiveX 模式的核心是观察者(Observer)和被观察者(Observable)。被观察者(Observable)产生一系列的事件,比如网络请求、数据库操作、文件读取等,然后观察者会观察到这些事件,之后就触发一系列后续动作。

下面就是使用 RxJava 编写的一段异步执行代码。

Observable observable = Observable.create(new Observable.OnSubscribe<String>() {


@Override


public void call(Subscriber<? super String> subscriber) {


        subscriber.onNext("Hello");


        subscriber.onNext("World");


        subscriber.onNext("!!!");


        subscriber.onCompleted();


    }


});


Observer<String> observer = new Observer<String>() {


@Override


public void onNext(String s) {


        Log.d(tag, "onNext: " + s);


    }


@Override


public void onCompleted() {


        Log.d(tag, "onCompleted called");


    }


@Override


public void onError(Throwable e) {


        Log.d(tag, "onError called");


    }


};


observable.subscribe(observer);

在上面的代码中,被观察者依次发出 “Hello”“World”“!!!” 三个事件,然后观察者观察到这三个事件后,就将每个事件打印出来。

非常有趣的是,ReactiveX 将其自身定义为一个异步编程库,却明确地将被观察者的事件序列,按照 “无限流”(infinite streams)的方式来进行处理,还实现了 Reactive-Streams 标准,支持反向压力功能。

你说,是不是他们也发现了,流和异步之间有着相通之处呢?

不过,相比 Java 8 的 CompletableFuture,我觉得这个 RxJava 还是显得有些复杂,理解和使用起来都更加麻烦,但明显的优势又没有,所以我不太推荐使用这种异步编程模式。

我在之前的工作中,也有见过其他同事在 Android 开发时使用这种模式。所以,如果你感兴趣的话,也可以了解一下。如果是我,我就直接使用 CompletableFuture 了。

纤程 / 协程模式

最后是纤程(fiber)模式,也称之为协程(coroutine)模式。应该说纤程是最理想的异步编程方案了,没有之一!它是用 “同步方式写异步代码” 的最高级别形态。

下面的图 3 是纤程的工作原理,纤程是一种用户态的线程,其调度逻辑在用户态实现,从而避免过多地进出内核态,进行调度和上下文切换。

实现纤程的关键,是要在执行过程中,能够在恰当的时刻和地方中断,并将 CPU 让给其他纤程使用。具体实现起来就是,将 IO 操作委托给少量固定线程,再使用另外少量线程负责 IO 状态检查和纤程调度,再使用另外一批线程执行纤程。

这样,少量线程就可以支撑大量纤程的执行,从而保证了 CPU 和 IO 资源的使用效率,提升了程序的性能。

使用纤程还可以极大地降低异步和并发编程的难度。但可惜的是,当前 Java 还不支持纤程。Java 对纤程的支持还在路上,你可以查阅一个被称之为 Loom 的项目来跟踪进度。所以这里,我就先借助当前另外一款火爆的编程语言 Golang,来对纤程做一番演示。

下面就是一个 Golang 协程的示例代码。

package main


import (


"fmt"


"time"


)


func produce(queue chan int) {


for i := 0; true; i++ {


      time.Sleep(1 * time.Second)


      queue <- i


      fmt.Printf("produce item[%d]\n", i)


   }


}


func consume(queue chan int) {


for {


      e := <-queue


      fmt.Printf("consume item[%d]\n", e)


   }


}


func waitForever() {


for {


      time.Sleep(1 * time.Second)


   }


}


func main() {


   queue := make(chan int, 10)


go produce(queue)


go consume(queue)


   waitForever()


}

在上面的代码中,我们使用了 Golang 最核心的两个概念,即 goroutine 和 channel。Golang 最推崇的并发编程思路就是,通过通信来共享内存,而不是通过共享内存来进行通信。所以,我们可以非常直观地看到,这里的 channel 就是一个容量有限的阻塞队列,天然就具备了反向压力的能力。

所以,Golang 这种生产者、队列、消费者的模式,不就是 “流” 的一种雏形吗?

异步和流之间的关系

至此,我们已经讨论了四种不同的异步编程模式。除了像 async/await 这样的异步编程语法糖外,上面讨论的四种模式,基本覆盖了当前所有主流的异步编程模式。这里稍微提一下,async/await 这个异步编程语法糖,还是非常有趣的,Python 和 JavaScript 都支持,建议你了解一下。

我们再回过头来看下,这四种异步编程模式,它们都已经暗含了 “流” 的影子。

首先是 Promise 模式,当 CompletableFuture 使用的执行器,是带队列的线程池时,Promise 异步调用链的过程,在底层就是事件在队列中 “流” 转的过程。

然后是 Actor 模式,每个 Actor 的邮箱就是一个非阻塞的队列,Actor 之间的通信过程,就是消息在这些非阻塞队列之间 “流” 转的过程。

接下来就是 ReactiveX 模式,将自己定义为异步编程库的 ReactiveX,明确地将事件按照 “无限流” 的方式来处理,还实现了 Reactive-Streams 标准,支持反向压力功能。

最后是纤程和协程,Golang 语言明确将 “队列” 作为了异步和并发编程时最主要的通信模式,甚至将“通过通信来共享内存,而不是通过共享内存来进行通信”,作为一种编程哲学思想来进行推崇。

所以,在四种异步编程模式中,我们都能够发现 “队列” 的身影,而 “队列” 正是 “流” 计算系统最重要的组成结构。我们可以利用这种结构,来实现 “流” 计算的过程。

有 “队列” 的系统,注定了它会是一个异步的执行过程,这也意味着 “流” 这种计算模式注定了是“异步” 的。异步系统中存在的 OOM 问题,在 “流” 计算系统中也存在,而且同样是使用 “反向压力” 的方式来解决。

小结

今天,我们分析了四种主流的异步编程模式,并讨论了 “流” 和“异步”之间的关系。总的来说,“流”和 “异步” 是相通的。其中,“异步”是 “流” 的本质,而 “流” 是“异步”的一种表现形式

至此,我们已经讨论完了所有有关异步编程的内容。可以看到,我在模块一中,花了整整三个课时,来讲解有关 NIO、异步编程和流计算的工作原理,以及它们之间的关联关系。这是因为这些知识,不仅仅是后面流计算内容的基础,它们也可以被用于其他任何高并发编程场景。而且根据我多年的工作经验,我坚定地认为,理解 NIO 和异步编程,是程序员成为大神的必要条件。所以,我希望你能够彻底地掌握和理解模块一的内容,它们一定会对于你以后的工作,有所帮助!

下一讲,我们将正式进入流计算系统的模块。那对于 “流” 和“异步”之间的关系,你还有什么问题或想法呢?有的话可以在留言区写下来,我看到后会进行分析和解答!

最后,用一个脑图来概括下本课时讲解的内容,以便于你理解。

05-有向无环图(DAG):如何描述、分解流计算过程

今天,我们来聊聊如何用 Java 中最常见的工具类,开发一个简单的流计算框架,你会进一步在源码细节的层面,看到异步和流是如何相通的。另外,虽然这个框架简单,但它是我们从 Java 异步编程,迈入流计算领域的第一步,同时它也反映出了所有流计算框架中,最基础也是最核心的组件,即用于传递流数据的队列,和用于执行流计算的线程。

学完本课时,你将领悟到 “流” 独特的计算模式,就像理解了 23 种设计模式后,有助于我们编写优秀的程序一样。你理解了 “流” 这种计算模式后,也有助以后理解各种开源流计算框架

在开始做事情前,我们对于自己将来要做的事情,应该是 “心中有丘壑” 的。所以,我们也应该先知道,该怎样去描述一个流计算过程。

为此,我们首先可以看一些开源流计算框架是怎样做的。

开源流计算框架是怎样描述流计算过程的

首先,我们看下大名鼎鼎的 Spark 大数据框架。在 Spark 中,计算步骤是被描述为有向无环图的,也就是我们常说的 DAG 。在 Spark 的 DAG 中,节点代表了数据( RDD ,弹性分布式数据集),边则代表转换函数。

下面的图 1 是 Spark 将 DAG 分解为运行时任务的过程。我们可以看出,最左边的 RDD1 到 RDD4 ,以及表示这些 RDDs 之间依赖关系的有向线段,共同构成了一个 DAG 有向无环图。

我们可以看到,Spark 是这样将 DAG 解析为最终执行的任务的。首先,DAG 被分解成一系列有依赖关系的并行计算任务集合。然后,这些任务集合被提交到 Spark 集群,再由分配的线程,执行具体的每一个任务。

看完 Spark,我们再来看另外一个最近更加火爆的流计算框架 Flink。在 Flink 中,我们是采用了 JobGraph 这个概念,来描述流计算的过程的。下图 2 是 Flink 将 JobGraph 分解为运行时的任务的过程,这幅图来自 Flink 的官方文档

我们很容易看出,左边的 JobGraph 不就是 DAG 有向无环图嘛!其中 JobVertex A 到 JobVertex D,以及表示它们之间依赖关系的有向线段,共同构成了 DAG 有向无环图。这个 DAG 被分解成右边一个个并行且有依赖关系的计算节点,这相当于原始 DAG 的并行化版本。之后在运行时,就是按照这个并行化版本的 DAG 分配线程并执行计算任务。

上面介绍的两种流计算框架具体是怎样解析 DAG 的,在本课时你可以暂时不必关心这些细节,只需要知道业界一般都是采用 DAG 来描述流计算过程即可。像其他的一些开源流计算框架,比如 Storm 和 Samza 也有类似的 DAG 概念,这里因为篇幅原因就不一一详细讲解了。

综合这些实例我们可以看出,在业界大家通常都是用 DAG 来描述流计算过程的。

用 DAG 描述流计算过程

所以,接下来我们实现自己的流计算框架,也同样采用了 DAG(有向无环图)来描述流的执行过程。如下图 3 所示。

这里,我们对 DAG 的概念稍微做些总结。可以看到上面这个 DAG 图,是由两种元素组成,也就是代表节点的圆圈,和代表节点间依赖关系的有向线段。

DAG 有以下两种不同的表达含义。

  • 一是,如果不考虑并行度,那么每个节点表示的是计算步骤,每条边表示的是数据在计算步骤之间的流动,比如图 3 中的 A->C->D。

  • 二是,如果考虑并行度,那么每个节点表示的是计算单元,每条边表示的是,数据在计算单元间的流动。这个就相当于将表示计算步骤的 DAG 进行并行化任务分解后,形成的并行化版本 DAG。

上面这样讲可能会有些抽象,下面我们用一个具体的流计算应用场景,来进行更加详细地讲解。

在风控场景中,我们的核心是风控模型和作为模型输入的特征向量。这里我们重点讨论下,如何计算特征向量的问题。

在通常的风控模型中,特征向量可能包含几十个甚至上百个特征值,所以为了实现实时风控的效果,需要并行地计算这些特征值。否则,如果依次串行计算上百个特征值的话,即使一个特征只需要 100ms,100 个特征计算完也要 10 秒钟了。这样就比较影响用户体验,毕竟刷个二维码还要再等 10 秒钟才能付款,这就很恼人了。

为了实现并行提取特征值的目的,我们设计了下图 4 所示的,特征提取流计算过程 DAG。

在上面的图 4 中,假设风控事件先是存放在 Kafka 消息队列里。现在,我们先用两个 “接收” 节点,将消息从 Kafka 中拉取出来。然后,发送给一个 “解码” 节点,将事件反序列化为 JSON 对象。接下来,根据风控模型定义的特征向量,将这个 JSON 对象进行 “特征分解” 为需要并行执行的 “特征计算” 任务。当所有 “特征计算” 完成后,再将所有结果 “聚合” 起来,这样就构成了完整的特征向量。最后,我们就可以将包含了特征向量的事件,“输出”到下游的风险评分模块。

很显然,这里我们采用的是前面所说的第二种 DAG 含义,即并行化的 DAG。

接下来,我们就需要看具体如何,实现这个并行化的 DAG 。看着图 4 这个 DAG,我们很容易想到,可以给每个节点分配一个线程,来执行具体的计算任务。而在节点之间,就用队列(Queue) ,来作为线程之间传递数据的载体。

具体而言,就是类似于下图 5 所描述的过程。一组线程从其输入队列中取出数据进行处理,然后输出给下游的输入队列,供下游的线程继续读取并处理。

看到这里,你对用 DAG 描述流计算过程,是不是已经做到 “心中有丘壑” 了?接下来,我们就将心中的丘壑真真实实画出来,做成一幅看得见摸得着的山水画。

用线程和队列实现 DAG

前面说到,我们准备用线程来实现 DAG 的节点,也就是计算步骤或计算单元,具体实现如下面的代码所示。需要注意的是,我这里为了限制篇幅和过滤无效信息,只保留代码的主体部分,对于一些不影响整体理解的代码分支和变量申明等做了删减。本课时的完整代码可以看这里

public abstract class AbstractStreamService<I, O> {


private List<Queue<I>> inputQueues;


private List<Queue<O>> outputQueues;


private boolean pipeline() throws Exception {


        List<I> inputs = poll(inputQueues);


        List<O> outputs = process(inputs);


        offer(outputQueues, outputs)


    }


@Override


public void start() {


        thread = new Thread(() -> {


while (!stopped) {


                        pipeline()


                }


        });


        thread.start();


    }


}

在上面的代码中,我定义了一个抽象类 AbstractStreamService。它的功能是从其输入队列,也就是 inputQueues 中,拉取(poll)消息,然后经过处理(process)后,发送到下游的输入队列,也就是 outputQueues 中去。

在 AbstractStreamService 中,为了在线程和线程之间传输数据,也就是实现 DAG 中节点和节点之间的有向线段,我们还需要定义消息传递的载体,也就是队列 Queue 接口,具体定义如下:

public interface Queue<E> {


E poll(long timeout, TimeUnit unit) throws InterruptedException;


boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException;


}

上面的接口定义了两个方法,其中 offer 用于上游的节点向下游的节点传递数据,poll 则用于下游的节点向上游的节点拉取数据。

现在,用于描述 DAG 节点的 AbstractStreamService 类,和用于描述 DAG 有向线段的 Queue 接口,都已经定义清楚。接下来就只需要将它们按照 DAG 的各个节点和有向线段组合起来,就可以构成一个完整的流计算过程了。

但这里还有个问题,上面流计算过程没有实现流的 “分叉”(Fork)和“聚合”(Join)。而“分叉” 和“聚合”的操作,在流计算过程中又是非常频繁出现的。所以,这里我们对问题稍微做些转化,即借用 Future 类,来实现这种 Fork/Join 的计算模式。

我们先看分叉( Fork )的实现。

private class ExtractorRunnable implements Runnable {


@Override


public void run() {


        JSONObject result = doFeatureExtract(event, feature);


        future.set(result);


    }


}


private ListenableFuture<List<JSONObject>> fork(final JSONObject event) {


    List<SettableFuture<JSONObject>> futures = new ArrayList<>();


final String[] features = {"feature1", "feature2", "feature3"};


for (String feature : features) {


        SettableFuture<JSONObject> future = SettableFuture.create();


        executorService.execute(new ExtractorRunnable(event, feature, future));


        futures.add(future);


    }


return Futures.allAsList(futures);


}

在上面的代码中,Fork 方法将事件需要提取的特征,分解为多个任务(用 ExtractorRunnable 类表示),并将这些任务提交给专门进行特征提取的执行器(ExecutorService)执行。执行的结果用一个 List<SettableFuture<JSONObject>> 对象来表示,然后通过 Futures.allAsList 将这些 SettableFuture 对象,封装成了一个包含所有特征计算结果的 ListenableFuture<List<JSONObject>> 对象。
这样,我们就非常方便地,完成了特征的分解和并行计算。并且,我们得到了一个用于在之后获取所有特征计算结果的 ListenableFuture 对象。

接下来就是聚合(Join)的实现了。

private JSONObject join(final ListenableFuture<List<JSONObject>> future) {


    List<JSONObject> features = future.get(extractTimeout, TimeUnit.MILLISECONDS);


    JSONObject featureJson = new JSONObject();


for (JSONObject feature : features) {


        featureJson.putAll(feature);


    }


    event.put("features", featureJson);


return event

在上面的代码中,由于在 Fork 时已经将所有特征计算的结果,用 ListenableFuture<List<JSONObject>> 对象封装起来,故而在 Join 方法中,用 future.get() 就可以获取所有特征计算结果。而且,为了保证能够在一定的时间内,结束对这条消息的处理,我们还指定了超时时间,也就是 extractTimeout。

当收集了所有的特征后,将它们添加到消息 JSON 对象的 features 字段。至此,我们也就完成了完整特征向量的全部计算过程。

让流计算框架稳定可靠

接下来,我们整体分析下这个风控特征计算过程的 DAG,在实际运行起来时有什么特点。

首先,DAG 中的每个节点都是通过队列隔离开的,每个节点运行的线程都是相互独立的互不干扰,这正是 “异步” 系统最典型的特征。

然后就是,节点和节点之间的队列,我们并没指定其容量是有限还是无限的,以及是阻塞的还是非阻塞的,这在实际生产环境中会造成一个比较严重的问题。

我们回顾下图 4 所示的风控特征计算过程 DAG,如果 “特征计算” 节点较慢,而数据 “接收” 和“解码”节点又很快的话,会出现什么情况呢?毫无疑问,如果没有“反向压力”,数据就会不断地在“队列“中积累起来,直到最终 JVM 内存耗尽,抛出 OOM 异常,程序崩溃退出。

事实上,由于 DAG 中所有上下游节点之间都是独立运行的,所以这种上下游之间速度不一致的情况随处可见。如果不处理好 “反向压力” 的问题,系统时时刻刻都有着 OOM 的危险。

所以,那我们应该怎样在流计算框架中加入 “反向压力” 的能力呢?其实也很简单,只需在实现队列 Queue 接口时,使用容量有限且带阻塞功能的队列即可,比如像下面这样。

public class BackPressureQueue<E> extends ArrayBlockingQueue<E> implements Queue<E>{


public ArrayBlockingQueuePipe(int capacity) {


super(capacity);


    }


}

可以看出,我们实现的 BackPressureQueue 是基于 ArrayBlockingQueue 的。也就是说,它的容量是有限的,而且是一个阻塞队列。这样当下游比上游的处理速度更慢时,数据在队列里积压起来。而当队列里积压的数据达到队列的容量上限时,就会阻塞上游继续往这个队列写入数据。从而,上游也就自动减慢了自己的处理速度。

至此,我们就实现了一个流计算框架,并且这个框架支持反向压力,在生产环境能够安全平稳地运行。

小结

今天,我们用最基础的线程(Thread)和阻塞队列(ArrayBlockingQueue)实现了一个简单的流计算框架。麻雀虽小,但五脏俱全。我们可以从中了解到一个流计算框架的基本骨架,也就是用于传输流数据的队列,以及用于处理流数据的线程。

这个框架足够我们做一些业务逻辑不太复杂的功能模块,但是它有以下问题。

  • 一是,能够实现的 DAG 拓扑结构有限。比如,在实现 Fork/Join 功能时,我们还需要借助 SettableFuture 和 ListenableFuture 的功能,这样对于实现一个 DAG 拓扑来说,并不纯粹和优雅。

  • 二是,给每个节点的计算资源只能静态配置,不能根据实际运行时的状况动态分配计算资源。

为了解决这些问题,在接下来的课时中,我们将采用 Java 8 中初次登场的 CompletableFuture 类,来对这个流计算框架进行改造。

到时候,我们将会得到一个更加简洁,但功能更强大的流计算框架。并且我们将能够更加深刻地理解异步系统和流计算系统之间的关联关系。

那么,在学完今天的课程后,你还有什么疑问呢?可以将你的问题放到留言区,我会时刻关注,并在后续文章为你解答哦!

本课时精华:

06-CompletableFuture:如何理解 Java 8 新引入的异步编程类

今天,我们一起来看下如何理解 Java8 引入的新异步编程类,CompletableFuture。

在第 05 时,我们直接用 “线程” 和“阻塞队列”构建实现了一个简单的流计算框架。这个框架帮助我们理解了流计算系统的基本实现原理,但是它用起来不是非常方便,需要配合框架写一些业务无关的代码。

所以,今天我们的目标就是对这个框架进行改造。我们不再用原始的 “线程” 和“阻塞队列”,而是使用 Java 8 中引入的 CompletableFuture 类。你将看到,用 CompletableFuture 这个异步编程类,实现的流计算框架是多么灵活好用。

Java 8 为啥引入 CompletableFuture 类

在 Java 8 之前,我们写异步代码的时候,主要还是依靠 ExecutorService 类和 Future 类。Future 类提供了 get 方法,用于在任务完成时获取任务结果。但是,Future 类的 get 方法有个缺点,它是阻塞的,需要同步等待结果返回。这就在事实上让原本异步执行的过程,重新退化成了同步的过程,失去了异步的作用。

为了避免这种问题,不同的第三方库提供了不同的解决方案,比如 Guava 库中的 SettableFuture/ListenableFuture、Netty 中的 Future 和 ChannelFuture 等。这些解决方案都是通过注册监听或回调的方式,形成回调链,从而实现了真正意义上的异步执行。

与此形成鲜明对比的是,JDK 自己却一直没有真正的异步编程工具类。

所以,在被 JavaScript、C# 等诸多语言嫌弃后,Java 8 终于推出了自己的异步编程方案,这就是 CompletableFuture 类。

CompletableFuture 类采用回调的方式实现异步执行,并提供了大量有关构建异步调用链的 API。这些 API 使得 Java 异步编程变得无比灵活和方便,极大程度地解放了 Java 异步编程的生产力。可以说,CompletableFuture 类仅凭一己之力,将 Java 异步编程提升到了一个全新的境界

所以,接下来我们就先看看 CompletableFuture 类都有哪些神奇的方法。

常用的 CompletableFuture 类方法

如果你查看 Java 8 中 CompletableFuture 类的源码,你会发现这个类有 80 多个可以公共访问的方法。并且这些方法中,很多方法的名字相似,它们的注释说明也似乎大同小异。再加上本身这个类是新引入的异步编程工具类。所以,对于初次接触这个类,以及对异步编程并不熟悉的开发人员来说,很容易没有头绪,不知道具体怎么使用这些方法,也不知道从哪个方法开始。

但爽哥想说的是,不用担心,当你理解这个类后,你会发现 CompletableFuture 类的所有这些方法之间,是有非常强的逻辑性的。通过这些方法,你可以构建出各种各样的异步调用链过程。

所以,为了帮助你更具逻辑性地理解 CompletableFuture 类。我将通过 “产品在流水线上被一步一步加工,直到产品加工完成后被装入仓库” 的过程,来依次讲解 CompletableFuture 类中最主要的几个类的使用逻辑。在模块一时,我们已经讲过 “流” 和“异步”是相通的,所以这里借助流水线的方式来讲解 CompletableFuture 类也是十分合适的。

1. 既然要生产产品,那么首先就是将毛坯产品放在流水线的 “起点” 处。这个过程,是通过 supplyAsync 方法来完成的。supplyAsync 方法的定义如下:

public static <U> CompletableFuture<U> supplyAsync(


    Supplier<U> supplier, Executor executor)

supplyAsync 是开启 CompletableFuture 异步调用链的方法之一。使用这个方法,会将 supplier 封装为一个任务提交给 executor 执行,然后返回一个记录任务执行状态和结果的 CompletableFuture 对象。之后可以在这个 CompletableFuture 对象上挂接各种回调动作。
所以说,supplyAsync 可以作为 “流” 的起点。

2. 当毛坯产品放在流水线上后,它就在流水线上传动起来。之后,当毛坯产品传动到加工位置时,就需要对其进行 “加工” 了。而 “加工” 就是通过 thenApplyAsync 方法来完成的。

public <U> CompletableFuture<U> thenApplyAsync(


    Function<? super T,? extends U> fn, Executor executor)

thenApplyAsync 用于在 CompletableFuture 对象上挂接一个转化函数。当 CompletableFuture 对象完成时,将它的结果作为输入参数调用转化函数。转化函数在执行各种逻辑后,返回另一种类型的数据作为输出。
这么一看,thenApplyAsync 的作用就是对 “流” 上的数据进行处理。

3. 当毛坯产品在流水线上最终被加工完成后,就变成了一个成品。所以,接下来我们就需要将这个成品装箱入库。而 “装箱入库” 的动作,就是通过 thenAcceptAsync 方法来完成的。

public CompletableFuture<Void> thenAcceptAsync(


    Consumer<? super T> action, Executor executor)

thenAcceptAsync 用于在 CompletableFuture 对象上挂接一个接收函数。当 CompletableFuture 对象完成时,将它的结果作为输入参数调用接收函数。与 thenApplyAsync 类似,接收函数可以执行各种逻辑,但不同的是,接收函数不会返回任何类型数据,或者说返回类型是 void。

所以,thenAcceptAsync 可以作为 “流” 的终点。

4. 现在需要对流水线进行升级改造,将流水线的其中一段,改造成 “另外一条” 流水线。那这个工作,就是通过 thenComposeAsync 方法来实现的。

public <U> CompletableFuture<U> thenComposeAsync(


    Function<? super T, ? extends CompletionStage<U>> fn, Executor executor)

thenComposeAsync 理解起来会复杂些,但它真的是一个非常重要的方法,请你务必理解它。
thenComposeAsync 在 API 形式上与 thenApplyAsync 类似,但是它的转化函数返回的不是一般类型的对象,而是一个 CompletionStage 对象,或者说得更具体点,实际中通常就是一个 CompletableFuture 对象。这意味着,我们可以在原来的 CompletableFuture 调用链上,插入另外一个调用链,从而形成一个新的调用链。这正是 compose(组成、构成) 的含义所在。

所以,thenComposeAsync 的作用,就像是在 “流” 的某个地方,插入了另外一条“流”。

5. 现在老板为了鼓励工人更加努力的工作,就告诉大家谁先完成作业就给谁发奖金。那这个 “谁先完成就由谁领奖金” 的机制,是通过 applyToEither 方法来实现的。

public <U> CompletableFuture<U> applyToEither(


    CompletionStage<? extends T> other, Function<? super T, U> fn)

使用 applyToEither 可以实现两个 CompletableFuture 谁先完成,就由谁执行回调函数的功能。这也是一个非常有用的方法,爽哥经常用它来实现定时超期的功能。

6. 有一天车间运来了一个大货箱,一个人搬不动,所以老板就让 “大家忙完手头的事后一起来搬运这个大货箱”。那这种“大家一起完成后再执行某个动作” 的过程,就是由 allOf 方法来实现的。

public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs)

CompletableFuture.allOf 的作用是将多个 CompletableFuture 合并成一个 CompletableFuture。这又是一个非常有用的方法,我们可以用它实现类似于 Map/Reduce 或 Fork/Join 的功能。

7. 流水线上工人在加工产品时,总会时不时地发生些意外情况,那发生意外情况后该怎么办呢?这就是由 exceptionally 方法来处理的。

public CompletableFuture<T> exceptionally(


        Function<Throwable, ? extends T> fn)

相比同步编程方式,异步程序发生异常时的问题会更加复杂。使用 exceptionally 方法,可以对异步调用链在执行过程中抛出的异常进行处理。

所以,通过这种 “流水线上加工商品” 的过程,我们很容易将 CompletableFuture 类中最主要的几个方法的功能和使用逻辑串起来。

后面你在使用 CompletableFuture 类构建异步调用链时,遇到难以理解的地方,可以时不时地联想下上面的流水线场景。

CompletableFuture 工作原理

前面介绍了 CompletableFuture 的几个常用 API,但光知道这些 API 还不足以体会到 CompletableFuture 的奥义和乐趣所在。我们最好还需要理解 CompletableFuture 类的内部工作原理。

所以接下来,我们借助于下面这段代码(完整代码参考)来详细分析下 CompletableFuture 类的工作原理:

CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(Tests::source, executor1);


CompletableFuture<String> cf2 = cf1.thenApplyAsync(Tests::echo, executor2);


CompletableFuture<String> cf3_1 = cf2.thenApplyAsync(Tests::echo1, executor3);


CompletableFuture<String> cf3_2 = cf2.thenApplyAsync(Tests::echo2, executor3);


CompletableFuture<String> cf3_3 = cf2.thenApplyAsync(Tests::echo3, executor3);


CompletableFuture<Void> cf3 = CompletableFuture.allOf(cf3_1, cf3_2, cf3_3);


CompletableFuture<Void> cf4 = cf3.thenAcceptAsync(x -> print("world"), executor4);

通过阅读 JDK 源码,并借助于 IDE 的断点(比如 Mac 环境下 IntelliJ IDEA 的 F7 和 F8 功能键逐步调试)调试功能,可以追踪出以上代码生成 CompletableFuture 异步调用链的过程,如下图 1 所示。

具体来说,CompletableFuture 的异步调用链是这样形成的。

首先,通过 CompletableFuture.supplyAsync 创建了一个任务 Tests::source,并交给 executor1 异步执行。用 cf1 来记录该任务在执行过程中的状态和结果。

然后,通过 cf1.thenApplyAsync,指定当 cf1(Tests::source) 完成时,需要回调的任务 Tests::echo。cf1 使用 stack 来管理这个后续要回调的任务 Tests::echo。用 cf2 来记录回调任务 Tests::echo 的执行状态和结果。

再然后,通过连续三次调用 cf2.thenApplyAsync,指定当 cf2(Tests::echo) 完成时,需要回调的后续三个任务:Tests::echo1、Tests::echo2 和 Tests::echo3。cf2 也是用 stack 来管理这三个后续需要执行的任务。

接着,通过 CompletableFuture.allOf,创建一个合并 cf3_1、cf3_2、cf3_3 的 cf3。这也意味着 cf3 只有在 cf3_1、cf3_2、cf3_3 都完成时才能完成。在 cf3 内部,是用一个树(Tree)结构来记录它和 cf3_1、cf3_2、cf3_3 的依赖关系。

最后,通过 cf3.thenAcceptAsync,指定了当 cf3 完成时,需要回调的任务,即 print。用 cf4 来记录 print 任务的状态和结果。

所以总的来说,就是 CompletableFuture 用 stack 来管理它在完成时后续需要回调的任务。当任务完成时,再通过依赖关系,找到后续需要处理的 CompletableFuture,并继续调用执行。这样,就构成了一个调用链,所有任务将按照该调用链依次执行。

采用 CompletableFuture 实现流计算框架

现在,我们已经对 CompletableFuture 的功能、API 和工作原理都有了一定认识。接下来就是实践了,这里,我们用 CompletableFuture 对 05 课时中的流计算框架进行改进。

闲话少叙,咱们先直接上代码(完整代码参考):

private final ExecutorService decoderExecutor = new BackPressureExecutor("decoderExecutor", 1, 2, 1024, 1024, 1);


private final ExecutorService extractExecutor = new BackPressureExecutor("extractExecutor", 1, 4, 1024, 1024, 1);


private final ExecutorService senderExecutor = new BackPressureExecutor("senderExecutor", 1, 2, 1024, 1024, 1);


private final ExecutorService extractService = new BackPressureExecutor("extractService", 1, 16, 1024, 1024, 1);


byte[] event = receiver.receive();


CompletableFuture


        .supplyAsync(() -> decoder.decode(event), decoderExecutor)


        .thenComposeAsync(extractor::extract, extractExecutor)


        .thenAcceptAsync(sender::send, senderExecutor)


        .exceptionally(e -> {


            logger.error("unexpected exception", e);


return null;


        });

看,是不是非常简单!

上面的代码中,receiver 读取消息后,通过 supplyAsync 方法,将其交给 decoder 解码。消息在解码后,再交给 extractor 进行特征提取。

由于 extractor 内部有个独立的 “流” 用于特征的并行计算,故采用 thenComposeAsync 将这个内部 “流” 插入到整体的 “流” 中来。

“流”的最后一步是将消息发送到 Kafka,所以使用 thenAcceptAsync 作为 “流” 的终点。

另外,为了让整个异步调用链在执行过程中不会出现 OOM 问题,我们还使用了带反向压力功能的执行器 BackPressureExecutor。这个执行器的原理和实现方法,我们已经在 03 课时讨论过,这里重新提醒下,就不再赘述了。

从上面的改造过程可以看出,CompletableFuture 本身就是一个非常好用的流计算框架。短短几行代码,就实现了我们在 05 课时中的所有功能。

小结

今天,我们使用 CompletableFuture 这个异步编程框架,实现了一个流计算应用。

我们可以回顾下,在 05 课时中,我们实现的流计算框架,其主要工作原理是,线程从队列中读取数据进行处理,然后输出到下游队列。而在今天的课时中,我们使用 CompletableFuture 实现的流计算过程,也是使用队列在整个处理过程中传递数据。

这两种实现的工作原理在本质是完全一致的,但很明显,使用 CompletableFuture 更加灵活方便。令人开心的是,CompletableFuture 类的方法还在不断增强中。

所以在以后的开发中,当你遇到复杂的业务问题时,不妨从 “流” 的角度分析问题。这样你会发现自己的考虑重点,将更多地放在业务本身上。即使再复杂的业务流程,也只需要多分解几个步骤就可以了。

这时候你设计出的程序结构,将会变得更加直观清晰,性能提升也会变得更加容易。

所以,关于 CompletableFuture 类你还有什么疑问或想法呢?可以在课程的留言区将你的问题或想法写下来,我在看到后会进行分析和讲解,或者在后续的课程中进一步补充说明。

本课时精华:

07-死锁:为什么流计算应用突然卡住,不处理数据了

今天,我们来讨论一个非常有趣的话题,也就是流计算系统中的死锁问题。

在第 06 课时,我们讲解了 CompletableFuture 这个异步编程类的工作原理,并用它实现了一个流计算应用。为了流计算应用不会出现 OOM 问题,我们还专门使用 BackPressureExecutor 执行器,实现了反向压力的功能。

另外,我们在 05 课时已经讲过,描述一个流计算过程使用的是 DAG,也就是 “有向无环图”。对于“有向”,我们知道这是代表着流数据的流向。而“无环” 又是指什么呢?为什么一定要是“无环”?

其实之所以要强调 “无环”,是因为在流计算系统中,当“有环” 和“反向压力”一起出现时,流计算系统将会出现 “死锁” 问题。而程序一旦出现“死锁”,那除非人为干预,否则程序将一直停止执行,也就是我们常说的“卡死”。这在生产环境是绝对不能容忍的。

所以,我们今天将重点分析流计算系统中的 “死锁” 问题。

为什么流计算过程不能有环

我们从一个简单的流计算过程开始,这个流计算过程的 DAG 如下图 1 所示。

DAG 描述了一个最简单的流计算过程,步骤 A 的输出给步骤 B 进行处理。

这个流计算过程用 CompletableFuture 实现非常简单。如下所示(请参考完整代码):

ExecutorService AExecutor = new BackPressureExecutor(


"AExecutor", 1, 1, 1, 10, 1);


ExecutorService BExecutor = new BackPressureExecutor(


"BExecutor", 1, 1, 1, 10, 1);


AtomicLong itemCounter = new AtomicLong(0L);


String stepA() {


    String item = String.format("item%d", itemCounter.getAndDecrement());


    logger.info(String.format("stepA item[%s]", item));


return item;


}


void stepB(String item) {


    logger.info(String.format("stepB item[%s]", item));


    sleep(10); 


}


void demo1() {


while (!Thread.currentThread().isInterrupted()) {


        CompletableFuture


                .supplyAsync(this::stepA, this.AExecutor)


                .thenAcceptAsync(this::stepB, this.BExecutor);


    }


}

上面的代码中,步骤 A 和步骤 B 使用了两个不同的执行器,即 AExecutor 和 BExecutor 。并且为了避免 OOM 问题,我们使用的执行器都是带反向压力功能的 BackPressureExecutor。

上面的程序运行起来没有任何问题。即便我们明确通过 sleep 函数,让 stepB 的处理速度只有 stepA 的十分之一,上面的程序都能够长时间的稳定运行(stepA 和 stepB 会不断打印出各自的处理结果,并且绝不会出现 OOM 问题)。

到此为止,一切都非常符合我们的预期。

但是现在,我们需要对图 1 的 DAG 稍微做点变化,让 B 在处理完后,将其结果重新输入给自己再处理一次。这种处理逻辑,在实际开发中也会经常遇到,比如 B 在处理失败时,就将处理失败的任务,重新添加到自己的输入队列,从而实现失败重试的功能。

修改后的 DAG 如下图所示。

很明显,上面的 DAG 在步骤 B 上形成了一个 “环”,因为有一条从 B 开始的有向线段,重新指向了 B 自己。相应的,前面的代码也需要稍微做点调整,改成下面的方式:

void demo1() {


while (!Thread.currentThread().isInterrupted()) {


        CompletableFuture


                .supplyAsync(this::stepA, this.AExecutor)


                .thenApplyAsync(this::stepB, this.BExecutor)


                .thenApplyAsync(this::stepB, this.BExecutor);


    }


}

上面的代码中,我们增加了一次 thenApplyAsync 调用,用于将 stepB 的输出重新作为其输入。需要注意的是,由于第二次 stepB 调用后没有再设置后续步骤,所以,虽然 DAG 上 “有环”,但 stepB 并不会形成死循环。

上面这段代码,初看起来并没什么问题,毕竟就是简单地新增了一个 “重试” 的效果嘛。但是,如果你实际运行上面这段代码就会发现,只需要运行不到 1 秒钟,上面这段程序就会 “卡” 住,之后控制台会一动不动,没有一条日志打印出来。

这是怎么回事呢?事实上这就是因为程序已经 “死锁” 了!

流计算过程死锁分析

说到 “死锁”,你一定会想到“锁” 的使用。一般情况下之所以会出现 “死锁”,主要是因为我们使用锁的方式不对,比如使用了不可重入锁,或者使用多个锁时出现了交叉申请锁的情况。这种情况下出现的“死锁” 问题,我们确确实实看到了 “锁” 的存在。

但当我们在使用流计算编程时,你会发现,“流”的编程方式已经非常自然地避免了 “锁” 的使用,也就是说我们并不会在 “流” 处理的过程中用到 “锁”。这是因为,当使用“流” 时,被处理的对象依次从上游流到下游。当对象在流到某个步骤时,它是被这个步骤的线程唯一持有,因此不存在对象竞争的问题。

但这是不是就说流计算过程中不会出现 “死锁” 问题呢?不是的。最直接的例子就是前面的代码,我们根本就没有用到 “锁”,但它还是出现了“死锁” 的问题。

所以,为什么会出现 “死锁” 呢?这里就需要我们仔细分析下了。下面的图 3 描绘了图 2 中的流计算过程之所以会发生死锁的原因。

在图 3 中,整个流计算过程有 A 和 B 这两个步骤,并且具备 “反向压力” 能力。这时候,如果 A 的输出已经将 B 的输入队列占满,而 B 的输出又需要重新流向 B 的输入队列,那么由于 “反向压力” 的存在,B 会一直等到其输入队列有空间可用。而 B 的输入队列又因为 B 在等待,永远也不会有空间被释放,所以 B 会一直等待下去。同时,A 也会因为 B 的输入队列已满,由于反向压力的存在,它也只能不停地等待下去。

如此一来,整个流计算过程就形成了一个死锁,A 和 B 两个步骤都会永远等待下去,这样就出现了我们前边看到的程序 “卡” 住现象。

形成 “环” 的原因

在图 2 所示的 DAG 中,我们是因为需要让 stepB 失败重试,所以 “随手” 就让 stepB 将其输出重新作为输入重新执行一次。这姑且算是一种比较特殊的需求吧。

但在实际开发过程中,我们的业务逻辑明显是可以分为多个依次执行的步骤,用 DAG 画出来时,也是 “无环” 的。但在写代码时,有时候一不小心,也会无意识地将一个本来无环的 DAG,实现成了有环的过程。下面图 4 就说明了这种情况。

在图 4 中,业务逻辑本来是 A 到 B 到 C 这样的 “无环” 图,结果由于我们给这三个不同的步骤,分配了同一个执行器 executor,实际实现的流计算过程就成了一个 “有环” 的过程

在这个 “有环” 的实现中,只要任意一个步骤的处理速度比其他步骤慢,就会造成执行器的输入队列占满。一旦输入队列占满,由于反向压力的存在,各个步骤的输出就不能再输入到队列中。最终,所有执行步骤将会阻塞,也就形成了死锁,整个系统也被 “卡” 死。

如何避免死锁

所以,我们在流计算过程中,应该怎样避免死锁呢?其实很简单,有三种方法。

一是不使用反向压力功能。只需要我们不使用反向压力功能,即使业务形成 “环” 了,也不会死锁,因为每个步骤只需要将其输出放到输入队列中,不会发生阻塞等待,所以就不会死锁了。但很显然,这种方法禁止使用。毕竟,没有反向压力功能,就又回到 OOM 问题了,这是万万不可的!

二是避免业务流程形成 “环”。这个方法最主要的作用,是指导我们在设计业务流程时,不要将业务流程设计成 “有环” 的了。否则如果系统有反向压力功能的话,容易出现类似于图 3 的死锁问题。

三是千万不要让多个步骤使用相同的队列或执行器。这个是最容易忽略的问题,特别是一些对异步编程和流计算理解不深的开发人员,最容易给不同的业务步骤分配相同的队列或执行器,在不知不觉中就埋下了死锁的隐患。

总的来说,在流计算过程中,反向压力功能是必不可少的,为了避免 “死锁” 的问题,流计算过程中的任何一个步骤,它的输出绝不能再重新流回作为它的输入

只需要注意以上几点,你就可以放心大胆地使用 “流” 式编程了,而且不用考虑 “锁” 的问题。由于没有了竞态问题,这既可以简化你编程的过程,也可以给程序带来显著的性能提升。

小结

今天,我们分析了流计算过程中的死锁问题。这是除 OOM 问题外,另一个需要尤其注意的问题。

我们之前说过,“流”的本质是 “异步” 的,并且你可以看到,我们今天实现描述流计算过程的 DAG 时,用的就是 CompletableFuture 这个异步编程框架。所以,其实流计算的这种死锁问题,在其他 “异步” 场景下也会出现。

如果你需要使用其他编程语言或其他异步编程框架(比如 Node.js 中的 async 和 await)进行程序开发的话,一定要注意以下问题:

  • 这个异步框架支持反向压力?没有不支持的话,是如何处理 OOM 问题的?

  • 这个异步框架会发生死锁吗?如果会死锁的话,是如何处理死锁问题的?

那么,你在以往的异步编程过程中,有没有遇到过死锁的问题呢?你可以将你遇到的问题,写在留言区!

本课时精华:

08-性能调优:如何优化流计算应用

今天,我们来讨论一个非常重要的话题,也就是流计算系统的性能调优问题。

到目前为止,我们的课程已经讲解了流计算编程的基础知识,开发了一个简单的流计算框架,并用它展示了如何根据 DAG 来实现一个流计算应用。

本来关于流计算系统基础架构方面的内容到此讲完了,但是在开始后面有关算法的内容之前,我想最后讨论一下有关流计算系统性能调优方面的问题,因为这中间也有很多有用且通用的知识。

所以,今天我就通过讨论有关流计算系统性能调优的知识,来对模块二的内容收个尾。

流计算应用的性能调优

对于任何系统而言,“优化” 都是一件重要的事情。一方面,“优化” 可以帮助改善系统设计、提升程序性能。另一方面,“优化” 也有助于你更加深刻地理解系统和技术本身。

系统 “优化” 是一件相对麻烦的事情,特别是一些业务逻辑复杂的场景。但是,如果你的程序或者系统,是按照 “流” 这种方式设计和开发的话,那么性能调优的过程实际上是非常有规律可循的。特别是在实现了反向压力的情况下,对于流计算应用的性能调优,可以说是一件轻松愉悦的事情。

优化机制

通过第 05 课时的学习,我们已经知道,一个流计算应用的执行过程是由 DAG 决定的。DAG 描述了流计算应用中各个执行步骤,以及数据的流动方向。因此,根据 DAG 的拓扑结构,我们已经对整个流计算应用的执行过程有了一个整体的认识。

所以接下来,针对流计算应用性能的优化,就是根据这个 DAG 按图索骥的过程了。

这里,我们以图 1 描述的流计算过程详细说明下。

通常而言,当实现了反向压力功能时,整个流计算应用的处理速度,就会受限于 DAG 中最慢的那个节点,并且 DAG 上各个节点的处理速度,最终都会趋近于同一个值,也就是最慢那个节点的处理速度。

比如图 1 中的 D 节点处理时延为 50ms,它是整个系统中最慢的节点,最终整个流计算应用的处理能力就不会超过 20 TPS。这就是我们常说的 “木桶效应”。

因此,如果我们这个时候只考量 TPS 的话,是不能够知道流计算作业具体是慢在哪个节点上的。我们需要换个角度,也就是应该考量每个节点处理事件的时延。如果某个节点的处理时延明显高于其他节点的时延,那就很可能是这个节点导致了系统整体性能的不佳。因此,我们优化的重点就是放到这个最慢的节点上。

当通过各种手段,比如改进算法、增加资源分配、减少线程竞争等措施,将这个最慢节点的时延降下来后,再次测量系统的整体性能。如果达到了预期的性能要求,就可以停止优化;如果还没有达到预期性能要求,则重复上面的过程,再次找到 DAG 中最慢的节点,优化改进和测试系统性能,直到系统性能最终达到预期为止。

优化工具

前面说到的是流计算系统性能优化的总体思路,那具体实践起来的话,是需要一些工具做支撑的。这里我跟你聊下我平时在优化流计算系统时,最常用的几种工具。

这些工具大体上可以分为两类,一类是监控工具,另一类是压测工具。

监控工具

  • 首先是 Metrics,用于在程序的一些关键逻辑处安装性能监控点,比如 Gauge 仪表盘、Counter 计数器、Meter 累加计数器、Histogram 直方图、Timer 计时器等。

  • 然后是 Zabbix 或者 Prometheus + Grafana,最主要是通过观察各种资源的使用情况,定位出程序压力高峰、资源使用效率、内存是否泄漏等一系列性能相关的问题。

  • 最后是 JConsole 或者 JVisualVM,主要是观察 JVM 运行时的状况,比如垃圾回收、线程状态、函数调用时间等,都能一目了然地观察到。

压测工具

  • 首先是 JMeter,主要是用于 HTTP 请求压力测试。

  • 然后是 Kafka,可以充分发挥它的消息重放功能,快速给流计算系统生成压力数据。

线程状态

不过,光有工具还是不够的,最终能否优化好程序,最主要还是需要我们对程序运行时状况能够透彻理解。除了对业务流程本身的理解之外,最重要的就是对 “线程状态” 的理解!

在 JVM 中,线程的状态如下图 2 所示。

具体来说,就是下面这几种状态。

  • 新建 (New):当通过 new Thread() 创建一个新的线程对象时,线程就处于新建状态,这个时候线程还没有开始运行。

  • 运行 (Runnable):线程正在被 JVM 执行,但它也可能是在等待操作系统的某些资源,比如 CPU。

  • 阻塞 (Blocked):线程因为等待监视器锁而阻塞,获取监视器锁是为了进入同步块或在调用 wait 方法后重入同步块。

  • 等待 (Waiting):线程在调用 Object.wait、Thread.join 或 LockSupport.park 方法后,进入此状态。waiting 状态的线程是在等待另外一个线程执行特定的动作。

  • 限时等待 (Timed Waiting):线程在调用 Thread.sleep、Object.wait(timeout)、Thread.join(timeout)、LockSupport.parkNanos 或 LockSupport.parkUntil 方法后,进入此状态。Timed Waiting 状态的线程也是在等待另外一个线程执行特定的动作,但是带有超期时间。

  • 终止状态 (Terminated):这是线程完成执行后的状态。

但是,当我们使用 JVisualVM 监控工具观察 JVM 实例运行状态时,会看到线程状态是按照另外一种方式划分的,具体如下。

  • 运行:对应 Runnable 状态。

  • 休眠:对应 Timed Waiting 状态,通过 Thread.sleep(timeout) 进入此状态。

  • 等待:对应 Waiting 和 Timed Waiting 状态,通过 Object.wait() 或 Object.wait(timeout) 进入此状态。

  • 驻留:对应 Waiting 和 Timed Waiting 状态,通过 LockSupport.park() 或 LockSupport.parkNanos(timeout)、LockSupport.parkUntil(timeout) 进入此状态。

  • 监视:对应 Blocked 状态,在等待进入 synchronized 代码块时进入此状态。

比如,下面的图 3 就是一个 JVisualVM 监控线程状态的例子。

根据线程状态优化程序性能

那究竟应该怎样根据上面 JVisualVM 观察到的线程状态,来优化程序性能呢?

当你在 JVisualVM 上看到某个 JVM 线程长时间处于 Runnable 状态时,并不代表它就是一直在被 CPU 执行,还有可能是处于 IO 状态。这个时候,需要借助于 top、dstat 等工具来分析 JVM 实例处于用户态和内核态的时间占比、磁盘和网络 IO 的吞吐量等信息。

虽然处于 Runnable 状态的线程并不代表它在执行,还有可能是正阻塞在等待 IO 操作完成的过程中。但你在性能调优时,还是应该让线程尽可能地处于 Runnable 状态。这是因为,处于 Runnable 状态的线程,要么表示 CPU 在执行,要么意味着它已经触发了 IO 操作,只是 IO 能力不足或者外部资源响应太慢,才导致了它的等待。

而如果是处于 Waiting、Timed Waiting 或 Blocked 状态,则说明程序可能存在以下问题。

  • (一是)工作量不饱和。 比如从输入队列拉取消息过慢,或者也可能是输入本身很少,但是在性能测试和优化时,本应该让系统处于压力饱和状态。

  • (二是)内耗严重。 比如锁使用不合理、synchronized 保护范围过大,导致竞态时间过长、并发性能低下。

  • (三是)资源分配不足。 比如分配给某个队列的消费者线程过少,导致队列的生产者长时间处于等待状态。

  • (四是)处理能力不足。 比如某个队列的消费者处理过慢,也会导致队列的生产者长时间处于等待状态。

至此,我们就可以对流计算应用的性能优化过程做个完整描述了。

首先,在你编写的流计算应用中,在实现 DAG 节点逻辑的地方,用 Metrics 等监控埋点工具,安装性能监控点。

然后,准备好流计算应用的压测环境(比如 Linux 云服务器),并安装好 Zabbix 等监控工具。

再然后,启动流计算应用,并使用 JMeter 或 Kafka 来进行压力测试。观察程序的吞吐量和时延等指标,看是否能够达到产品要求的性能。

接下来,就可以通过 Zabbix 和 JVisualVM 等工具,来分析程序究竟是哪个步骤耗时过多,以及耗时过多的原因了。

最后,根据分析出的原因,不断优化程序流程、算法或资源,并重新进行压力测试,观察改进效果,直至达到产品要求的性能指标为止。

小结

今天,我们讨论了流计算系统性能优化的问题。流计算系统的优化过程,主要是根据 “木桶效应”,反复寻找 DAG 中最慢节点,然后想方设法缩短其处理时间的过程。

在我看来,“优化”是程序员提升自己技术水平最好的方法之一。因为,不断 “优化” 的过程,其实是在不断探索和发现自己知识不足的过程。所以,如果你在以往的工作中,忽略了 “优化” 的话,希望你能加强这方面的实践。

最后,你还碰到过哪些程序性能优化方面的问题呢?可以写在留言区。

本课时精华:


09-流数据操作:最基本的流计算功能

在前面的两个模块中,我们讨论的主要是构成流计算系统的基础框架。我们有了这个框架,接下来就应该用它解决实际的实时计算问题。而解决实际问题的过程,落到实处就是实现某种具体算法的过程。

所以在第三模块,我将依次讲解实时流计算系统中的几类算法问题。在以后的流计算应用开发过程中,你所面对的计算问题,都将八九不离十地归于这几类问题中的一种或多种。因此,对这几类问题进行分析归纳,总结出特定的算法模式,这是非常有意义的。

那么今天,我们就先来看第一类算法问题,即流数据操作的问题。

什么是流数据操作

流数据操作应该说是流计算系统与生俱来的能力,它是针对数据流的 “转化” 或“转移”处理。流数据操作的内容主要包括四类。

  • 一是流数据的清洗、规整和结构化。比如提取感兴趣字段、统一数据格式、过滤不合条件事件。

  • 二是流数据的关联及合并。比如在广告转化率分析中,将 “点击” 事件流和 “安装” 事件流关联起来。

  • 三是流数据的分发和并行处理。比如将一个包含了来自不同设备事件的数据流,按照设备 id 分发到不同的流中进行处理。

  • 四是流数据的转移和存储。比如将数据从 Kafka 转移到数据库里。

虽然不同系统实现以上四类流数据操作的具体方法不尽相同,但经过多年的实践和经验积累,业界针对流数据操作的目标和手段都有了一定的共识,并已逐步形成一套通用的 API 集合,几乎所有的流计算平台都会提供这些 API 的实现。比如:

  • 针对流数据的清洗、规整和结构化,抽象出 filter、map、flatMap、reduce 等方法;

  • 针对流数据的关联及合并,抽象出 join、union 等方法;

  • 针对流数据的分发和并行处理,抽象出 keyBy 或 groupBy 等方法;

  • 针对流数据的转移和存储,则抽象出 foreach 等方法。

这些 API 的功能各不相同,但它们在一起共同构成了一个灵活操作流数据的方法集合。

所以接下来,我们就选出几个最重要,且能够覆盖日常大多数使用场景的 API ,来对流数据操作这类算法问题,进行详细讲解。

过滤 filter

首先是过滤 filter 。顾名思义,“过滤” 就是在数据流上筛选出符合条件的数据。这个方法通常用于剔除流数据中你不想要的数据,比如不合预期的事件类型、不完整的数据记录等。或者,你也可以用这个方法来对流数据进行采样,比如只保留 1/10 的流数据,从而减少需要处理的数据量。

下面举一个具体的例子来讲下如何使用 filter 方法。比如,我们现在需要监控仓库的环境温度,在火灾发生前提前预警以避免火灾,那么我们就可以采用过滤功能,从来自于传感器的环境温度事件流中,过滤出温度高于 100 摄氏度的事件。

这里我们使用 Flink 来实现。如果你暂时还不熟悉 Flink 的话也没有关系,这里的代码很简单,只需要先了解下这些 API 的使用形式即可。另外,本课程后面还有专门的课时讲解 Flink 。

DataStream<JSONObject> highTemperatureStream = temperatureStream.filter(x -> x.getDouble("temperature") > 100);

在上面代码中,lambda 表达式 “x->x.getDouble(“temperature”)>100” 即过滤火灾高温事件的条件。

就像图 1 展示的一样,过滤操作的作用,是将一个具有多种形状的数据流,转化为只含圆形的数据流。当然,你在实际开发中,可以将 “形状” 替换为任何东西。比如,上面监控仓库环境温度的例子,“圆形”就对应着“高温事件”。

映射 map

然后是映射 map。“映射”用于将数据流中的每条数据转化为新的数据。它最大的价值在于对流数据进行信息增强,也就是将额外的信息附加到数据流中的数据上。比如,你只对哪些字段感兴趣、需要将数据转化为哪种格式、给数据添加一个新的字段等,这些 “信息” 在原来的流数据里是没有的,你可以通过 map 方法将这些信息附加到流数据上。

下面同样以仓库环境温度监控为例,来讲解 map 的使用方法。不过,这次我们不是将高温事件过滤出来,而是采用数据工程师在做特征工程时常用的一种操作,也就是 “二值化”。

我们在原始环境温度事件中,添加一个新的布尔(boolean)类型字段,用于表示该事件是否是高温事件。同样,使用 Flink 实现如下:

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


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


return x;


});

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

上图 2 展示了映射操作的作用,它将一个由圆形组成的数据流,转化为了五角星形状的数据流。同样在实际开发中,我们可以将 “形状” 具象为任何东西。

展开映射 flatMap

接下来是展开映射 flatMap。“展开映射” 用于将数据流中的每条数据转化为 N 条新数据。相比 map 而言, flatMap 是个更加灵活的方法,因为 map 只能 1 对 1 地对数据流元素进行转化,而 flatMap 能 1 对 N 地对数据流元素进行转化。

flatMap 最大的作用体现在 “flat” 上,也就是“展开摊平”。它最典型的使用场景就是,比如原本数据流中的数据有一个字段是数组,现在你需要将这个数组里的每个元素拆解开,然后分成一条条单独的数据,并形成一个新的数据流。

下面举一个 flatMap 在社交活动分析中使用的例子。现在有一组代表用户信息的数据流,其中每条数据记录了用户(用 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);


    }


});

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

图 3 展示了展开映射操作的作用,它将一个由包含小圆形在体内的正方形组成的数据流,展开转化为由小圆形组成的数据流。

在实际开发过程中,我们还经常使用 flatMap 实现 Map/Reduce 或 Fork/Join 计算模式中的 Map 或 Frok 操作。并且更有甚者,由于 flatMap 的输出元素个数能够为 0,所以我们有时候连 Reduce 或 Join 操作也可以使用 flatMap 操作实现。比如,在后面第 20 课时讲解用 Flink 实现风控系统时,你就会看到具体如何用 flatMap 实现针对流式处理的 Map/Reduce 计算模式。这里我们暂时就不展开了。

聚合 reduce

再接下来是聚合 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 表达式会依次将每条数据与前一次计算的结果相加,最终完成对窗口内全部流数据的求和计算。

如果将求和操作换成其他 “二合一” 的计算,则可以实现相应功能的聚合运算。由于使用了窗口,所以聚合后流的输出不再是像 map 运算那样逐元素地输出,而是每隔一段时间才会输出窗口内的聚合运算结果。

比如前面的示例代码中,就是每隔 1 秒钟输出 10 秒钟窗口内的聚合计算结果。

图 4 展示了聚合操作的作用,它将一个由带有数值的圆形组成数据流,以 3 个元素为窗口,进行求和聚合运算,并输出为新的数据流。在实际开发过程中,我们可选择不同的窗口实现、不同的窗口长度、不同的聚合内容、不同的聚合方法,从而在流数据上实现各种各样的聚合操作。

关联 join

我们接下来再看看相对比较复杂的关联 join 操作。“关联” 用于将两个数据流中满足特定条件的数据对组合起来,再按指定规则形成新数据,最后将新数据添加到输出数据流。

在关系型数据库中,关联操作是非常常用的操作手段,这是由关系型数据库的设计理念,也就是数据库的三种设计范式所决定的。而在流数据领域,由于数据来源的多样性和在时序上的差异性,数据流之间的关联也成为一种非常自然的需求。

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

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

同样以社交网络分析为例子,这次我们需要将两个不同来源的事件流,按照用户 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 方法,指定了合并计算的方法。

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

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

分组 key by

接下来我们再来看下流计算中非常重要的分组 key by 操作。如果说各种流计算框架最终能够实现分布式计算,实现高并发和高吞吐,那么最大的功臣莫过于 “分组” 操作的实现。

“分组” 操作是实现并行流计算的最主要手段,它将流划分为不相交的分区流,之后分组键相同的消息会被划分到相同的分区流中。并且,各个分区流在逻辑上互不干扰,具有各自独立的运行时上下文。这就带来两个非常大的好处。

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

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

以电商场景为例,假设我们要在 “双十一抢购” 那天,实时统计各个商品的销量以展现在监控大屏上。使用 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 秒内的各商品销售总量。

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

遍历 foreach

最后,我们来看下流数据的归宿,即遍历 foreach 操作。“遍历” 是对数据流的每个元素执行指定方法的过程。遍历与映射非常相似但又非常不同。

说相似是因为 foreach 和 map 都是将一个表达式作用在数据流上,只不过 foreach 使用是 “方法”(没有返回值的函数),而 map 使用的是 “函数”。

说不同是因为 foreach 和 map 语义大不相同。从 API 语义上来讲,map 作用是对数据流进行转换,但 foreach 并非对数据流进行转换,而是 “消费” 掉数据流。也就是说,数据流在经过 foreach 后也就终结了。所以我们通常使用 foreach 操作对数据流进行各种 IO 操作,比如写入文件、存入数据库、打印到显示器等。

下面的 Flink 示例代码以及图 7 均展示了将数据流打印到显示屏的功能。

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

到此为止,我们讨论了过滤 filter、映射 map、展开映射 flatMap、聚合 reduce、关联 join、分组 key by、遍历 foreach 这 7 个通用的流数据操作 API。这 7 个 API 是最基础的流式编程接口,几乎所有的开源流计算框架都提供了这些 API 的实现,而其他功能更丰富的 API 也会构建在这些方法基础之上。

流数据操作 API 总结

最后,为了更加清晰地理解流数据操作,我这里用一个表格对今天讲到的各个 API 做了一个比较和总结。

小结

今天,我们讨论了使用流计算技术可以解决的第一类算法问题,即流数据操作。

应该说,今天讲解的流数据操作 API ,既是流计算系统的基本功能,也是实现更复杂算法和功能的基础。在日常开发中,我们经常会使用到流数据操作。比如,大数据领域有个专门的岗位就是 “ETL 工程师”,对于“ETL 工程师” 而言,他们不可避免地会用到今天所讨论的这些 API 。

目前,有一些开源流计算框架(比如 Flink),直接提供了更方便好用的 SQL 来实现流数据操作,这当然是非常好的新功能。但它们在经过 SQL 层的解析后,最终也会对应到今天所讨论的这些相对底层的 API 。所以,如果你以前没有接触过这类流式编程 API 的话,今天的内容就需要好好理解下了。因为这些 API 以后你会经常用到,而且需要灵活地运用。

最后留一个小问题,你知道在 Flink 中都有哪几种 join 操作,以及每一种 join 操作的设计意图是怎样的呢?可以将你的想法或问题发表在留言区,我看到后会进行解答,或者在后面的课程中进一步补充说明。

下面是本课时的内容脑图,以便于你理解。

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

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

以电商场景为例,假设我们要在 “双十一抢购” 那天,实时统计各个商品的销量以展现在监控大屏上。使用 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 秒内的各商品销售总量。
[外链图片转存中…(img-lg3o9Z4i-1657699742775)]

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

遍历 foreach

最后,我们来看下流数据的归宿,即遍历 foreach 操作。“遍历” 是对数据流的每个元素执行指定方法的过程。遍历与映射非常相似但又非常不同。

说相似是因为 foreach 和 map 都是将一个表达式作用在数据流上,只不过 foreach 使用是 “方法”(没有返回值的函数),而 map 使用的是 “函数”。

说不同是因为 foreach 和 map 语义大不相同。从 API 语义上来讲,map 作用是对数据流进行转换,但 foreach 并非对数据流进行转换,而是 “消费” 掉数据流。也就是说,数据流在经过 foreach 后也就终结了。所以我们通常使用 foreach 操作对数据流进行各种 IO 操作,比如写入文件、存入数据库、打印到显示器等。

下面的 Flink 示例代码以及图 7 均展示了将数据流打印到显示屏的功能。

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

[外链图片转存中…(img-j8lLROne-1657699742775)]

到此为止,我们讨论了过滤 filter、映射 map、展开映射 flatMap、聚合 reduce、关联 join、分组 key by、遍历 foreach 这 7 个通用的流数据操作 API。这 7 个 API 是最基础的流式编程接口,几乎所有的开源流计算框架都提供了这些 API 的实现,而其他功能更丰富的 API 也会构建在这些方法基础之上。

流数据操作 API 总结

最后,为了更加清晰地理解流数据操作,我这里用一个表格对今天讲到的各个 API 做了一个比较和总结。

[外链图片转存中…(img-JjoUN3xR-1657699742777)]

小结

今天,我们讨论了使用流计算技术可以解决的第一类算法问题,即流数据操作。

应该说,今天讲解的流数据操作 API ,既是流计算系统的基本功能,也是实现更复杂算法和功能的基础。在日常开发中,我们经常会使用到流数据操作。比如,大数据领域有个专门的岗位就是 “ETL 工程师”,对于“ETL 工程师” 而言,他们不可避免地会用到今天所讨论的这些 API 。

目前,有一些开源流计算框架(比如 Flink),直接提供了更方便好用的 SQL 来实现流数据操作,这当然是非常好的新功能。但它们在经过 SQL 层的解析后,最终也会对应到今天所讨论的这些相对底层的 API 。所以,如果你以前没有接触过这类流式编程 API 的话,今天的内容就需要好好理解下了。因为这些 API 以后你会经常用到,而且需要灵活地运用。

最后留一个小问题,你知道在 Flink 中都有哪几种 join 操作,以及每一种 join 操作的设计意图是怎样的呢?可以将你的想法或问题发表在留言区,我看到后会进行解答,或者在后面的课程中进一步补充说明。

下面是本课时的内容脑图,以便于你理解。

[外链图片转存中…(img-G8NDrHIG-1657699742778)]

  • 6
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值