Flink 已经是流计算的事实标准,当前国内外做实时计算或流计算一般都会选择 Flink 和 Flink SQL。另外,Flink 也是是家喻户晓的流批一体大数据计算引擎。
然而,目前 Flink 也面临着挑战。比如虽然现在大规模应用都以流计算为主,但 Flink 批计算的应用并不广泛,想要进一步推动真正意义上的流批一体落地,需要推动业界更多地落地 Flink 批计算,需要更积极地拥抱现有的离线生态。当前业界离线生态主要以 Hive 为主,因此我们在过去版本中做了很多与 Hive 相关的集成,包括 Hive Catalog、Hive 语法兼容、Hive UDF 兼容、流式写入 Hive 等。在 Flink 1.16 版本中,我们进一步提升了 HiveSQL 的兼容度,还支持了 HiveServer2 的协议兼容。
所以,为什么 Flink 要去支持 Hive SQL 的迁移?一方面,我们希望吸引更多的 Hive 离线数仓用户,通过用户来不断打磨批计算引擎,对齐主流批计算引擎。另一方面,通过兼容 Hive SQL,来降低现有离线用户使用 Flink 开发离线业务的门槛。除此之外,另外,生态是开源产品的最大门槛。Flink 已经拥有非常丰富的实时生态工具,但离线生态依然较为欠缺。通过兼容 Hive 生态可以快速融入 Hive 离线生态工具和平台,降低用户接入的成本。最后,这也是实现流批一体的重要一环,我们希望推动业界尝试统一的流计算和批计算引擎,再统一流计算和批计算 SQL。
从用户角度来看,Hive SQL 为什么要迁移到 Flink SQL 上?
对于平台方而言,统一流批计算引擎,只需维护一套 Flink 引擎,可以降低维护成本,提升团队研发效率。另外,可以利用 Flink + Gateway+ HiveSQL 兼容,快速建设一套 OLAP 系统。Flink 的另一优势是拥有丰富的 connector 生态,可以借助 Flink 丰富的数据源实现强大的联邦查询。比如不仅可以在 Hive 数仓里做 ad-hoc 查询,也可以将 Hive 表数据与 MySQL、HBase、Iceberg、Hudi 等数据源做联邦查询等。
对于离线数仓用户而言,可以用 Hive SQL 写流计算作业,极大降低实时化改造成本。使用的依然是以前的 HiveSQL 语法,但是可以运行在 streaming 模式下。在此基础之上也可以进一步探索流批一体 SQL 层以及流批一体数仓层的建设。
但是 Flink 支持 HiveSQL 的迁移面临着很多挑战,主要有以下三个方面:
接下来我们重点讲解下 Hive 兼容相关的工作。
Hive 语法的兼容并没有完全造出一套新的 SQL 引擎,而是复用了 Flink SQL 的很多核心流程和代码。我们抽象出了可插拔的 parser 层来支持和扩展不同的语法。Flink SQL 会经过 Flink Parser 转换成 Flink RelNode,再经过 Logical Plan 优化为 Physical Plan,最后转换为 Job Graph 提交执行。为了支持 Hive 语法兼容,我们引入了 Hive Parser 组件,来将 Hive SQL 转化成 Flink RelNode。这个过程中,复用了大部分 Hive 现有的 SQL 解析逻辑,保证语法层的兼容(均基于 Calcite)。之后 RelNode 复用同样的流程和代码转化成 LogicalPlan、Physical Plan、JobGraph,最后提交执行。
从架构上看,Hive 语法兼容并不复杂,但这是一个“魔鬼在细节”的工作。上图为部分 Flink1.16 版本里 Flink Hive 兼容相关的 issue,涉及 query 兼容、类型系统、语义、行为、DDL、DML、辅助查询命令等非常多语法功能。累计完成的 issue 数达近百个。
Flink1.16 版本将 Hive 兼容度从 85% 提升至 94.1%。兼容度测试主要依靠 Hive qtest 测试集,其中包含 12,000 多个测试 case,覆盖了 Hive 目前所有主流语法功能。没有兼容的一部分包括 ACID 功能(业界使用较少),如果除去 ACID 功能,兼容度已达 97%以上。
SQLGateway 是 Flink SQL 的 server 层组件,是单独的进程,对标 HiveServer2 组件。从 Flink 整体架构上看,SQLGateway 处于中间位置。
向下,封装了用户 API 的 Flink SQL 和 Hive SQL。不管是 Flink SQL 还是 Hive SQL,都使用 Flink 流批一体的 Runtime 来执行,可以运行在批模式,也可以运行在流模式。Flink 的资源也可以部署运行在 YARN、K8S、Flink standalone 集群上。
向上,SQLGateway 提供了可插拔协议层 Endpoint,目前提供了 HiveServer2 和 REST 两种协议实现。通过 HiveServer2 Endpoint,用户可以将 Hive 生态的很多工具和组件(Zeppelin、Superset、Beeline、DBeaver 等)连接到 SQL Gateway,提供流批统一的 SQL 服务并兼容 Hive SQL。通过 REST 协议可以使用 Postman、curl 命令或自己通过 Python、Java 编程来访问,提供完善和灵活的流计算服务。将来,Endpoint 能力也会继续扩展,比如可以提供更高性能的 gRPC 协议或兼容 PG 协议。
目前快手正在与 Flink 社区紧密合作,推进流批一体的落地。目前快手迁移 Hive SQL 作业到 Flink SQL 作业已经取得了初步的进展,已有上千个作业完成了迁移。快手的迁移主要策略为双跑平台,已有业务继续运行,双跑平台有智能路由组件,可以通过指定规则或 pattern 识别出作业,投递到 MapReduce、Spark 或 Flink 上运行。初期的运行较为谨慎,会通过白名单机制指定某些作业先运行在 Flink,观察其稳定性与性能,对比其结果一致性,后续逐步通过规则来放量。更多的实践经验与细节可以关注 Flink Forward Asia 2022 上分享的《Hive SQL 迁移到 Flink SQL 在快手的实践》。
Demo:Hive SQL 如何迁移到 Flink SQL
接下来演示一下 Hive SQL 如何迁移到 Flink SQL。我们已经搭建好一个 YARN 集群,以及 Hive 相关组件,包括 HiveServer2 的服务。我们使用 Zeppelin 做数据可视化和 SQL 查询。我们将演示 Hive SQL 迁移到 Flink SQL 只需改一行地址,Zeppelin 体验并无二致,SQL 也无需修改。完整的 Demo 视频请观看完整的演讲视频:https://www.bilibili.com/video/BV1BV4y1T7d4
未来,Flink 将在以下三个方面持续演进:
从广义的概念上讲,能够捕获数据变更的技术, 我们都可以称为 CDC 技术。通常我们说的 CDC 技术是一种用于捕获数据库中数据变更的技术。CDC 技术应用场景也非常广泛,包括:
Flink CDC 基于数据库日志的 Change Data Caputre 技术,实现了全量和增量的一体化读取能力,并借助 Flink 优秀的管道能力和丰富的上下游生态,支持捕获多种数据库的变更,并将这些变更实时同步到下游存储。
目前,Flink CDC 的上游已经支持了 MySQL、MariaDB、PG、Oracle、MongoDB 等丰富的数据源,对 Oceanbase、TiDB、SQLServer 等数据库的支持也已经在社区的规划中。
Flink CDC 的下游则更加丰富,支持写入 Kafka、Pulsar 消息队列,也支持写入 Hudi、Iceberg 等数据湖,还支持写入各种数据仓库。
同时,通过 Flink SQL 原生支持的 Changelog 机制,可以让 CDC 数据的加工变得非常简单。用户可以通过 SQL 便能实现数据库全量和增量数据的清洗、打宽、聚合等操作,极大地降低了用户门槛。 此外, Flink DataStream API 支持用户编写代码实现自定义逻辑,给用户提供了深度定制业务的自由度。
Flink CDC 技术的核心是支持将表中的全量数据和增量数据做实时一致性的同步与加工,让用户可以方便地获每张表的实时一致性快照。比如一张表中有历史的全量业务数据,也有增量的业务数据在源源不断写入,更新。Flink CDC 会实时抓取增量的更新记录,实时提供与数据库中一致性的快照,如果是更新记录,会更新已有数据。如果是插入记录,则会追加到已有数据,整个过程中,Flink CDC 提供了一致性保障,即不重不丢。
那么 Flink CDC 技术能给现有的数据入仓入湖架构带来什么样的改变呢?我们可以先来看看传统数据入仓的架构。
在早期的数据入仓架构中,一般会每天 SELECT 全量数据导入数仓后再做离线分析。这种架构有几个明显的缺点:
到了数据仓库的 2.0 时代,数据入仓进化到了 Lambda 架构,增加了实时同步导入增量的链路。整体来说,Lambda 架构的扩展性更好,也不再影响业务的稳定性,但仍然存在一些问题:
对于传统数据入仓架构存在的问题,Flink CDC 的出现为数据入湖架构提供了一些新思路。借助 Flink CDC 技术的全增量一体化实时同步能力,结合数据湖提供的更新能力,整个架构变得非常简洁。我们可以直接使用 Flink CDC 读取 MySQL 的全量和增量数据,并直接写入和更新到 Hudi 中。
这种简洁的架构有着明显的优势。首先,不会影响业务稳定性。其次,提供分钟级产出,满足近实时业务的需求。同时,全量和增量的链路完成了统一,实现了一体化同步。最后,该架构的链路更短,需要维护的组件更少。
Flink CDC 的核心特性可以分成四个部分:
在 Flink CDC 1.x 版本时,MySQL CDC 存在三大痛点,影响了生产可用性。
简单来说,增量快照读取算法的核心思路就是在全量读取阶段把表分成一个个 chunk 进行并发读取,在进入增量阶段后只需要一个 task 进行单并发读取 binlog 日志,在全量和增量自动切换时,通过无锁算法保障一致性。这种设计在提高读取效率的同时,进一步节约了资源。实现了全增量一体化的数据同步。这也是流批一体道路上一个非常重要的落地。
Flink CDC 是一个流式入湖友好的框架。在早期版本的 Flink CDC 设计中,没有考虑数据湖场景,全量阶段不支持 Checkpoint,全量数据会在一个 Checkpoint 中处理,这对依靠 Checkpoint 提交数据的数据湖很不友好。Flink CDC 2.0 设计之初考虑了数据湖场景,是一种流式入湖友好的设计。设计上将全量数据进行分片,Flink CDC 可以将 checkpoint 粒度从表粒度优化到 chunk 粒度,大大减少了数据湖写入时的 Buffer 使用,对数据湖写入更加友好。
Flink CDC 区别于其他数据集成框架的一个核心点,就是在于 Flink 提供的流批一体计算能力。这使得 Flink CDC 成为了一个完整的 ETL 工具,不仅仅拥有出色的 E 和 L 的能力,还拥有强大的 Transformation 能力。因此我们可以轻松实现基于异构数据源的数据湖构建。
在上图左侧的 SQL 中,我们可以将 MySQL 中的实时产品表、实时订单表和 PostgreSQL 中的实时物流信息表进行实时关联,即 Streaming Join,关联后的结果实时更新到 Hudi 中,非常轻松地完成异构数据源的数据湖构建。
在 OLTP 系统中,为了解决单表数据量大的问题,通常采用分库分表的方式将单个大表进行拆分以提高系统的吞吐量。但是为了方便数据分析,通常需要将分库分表拆分出的表在同步到数据仓库、数据湖时,再合并成一个大表。Flink CDC 可以轻松完成这个任务。
在上图左侧的 SQL 中,我们声明了一张 user_source 表去捕获所有 user 分库分表的数据,我们通过表的配置项 database-name、table-name 使用正则表达式来匹配这些表。并且,user_source 表也定义了两个 metadata 列来区分数据是来自哪个库和表。在 Hudi 表的声明中,我们将库名、表名和原表的主键声明成 Hudi 中的联合主键。在声明完两张表后,一条简单的 INSERT INTO 语句就可以将所有分库分表的数据合并写入 Hudi 的一张表中,完成基于分库分表的数据湖构建,方便后续在湖上的统一分析。
Flink CDC 是一个独立的开源项目,项目代码托管在 GitHub 上。采取小步快跑的发布节奏,今年社区已经发布了 5 个版本。1.x 系列的三个版本推出了一些小功能;2.0 版本 MySQL CDC 支持了无锁读取、并发读取、断点续传等高级功能,commits 达到了 91 个,贡献者达到了 15 人;2.1 版本则支持了 Oracle、MongoDB 数据库,commits 达到了115个,贡献者达到了28人。社区的 commits 和 贡献者增长非常明显。
文档和帮助手册也是开源社区非常重要的一部分,为了更好地帮助用户,Flink CDC 社区推出了版本化的文档网站,如 2.1 版本的文档 。文档中还提供了很多快速入门的教程,用户只要有个 Docker 环境就能上手 Flink CDC。此外,还提供了 FAQ 指导手册),快速解决用户遇到的常见问题。
在过去的 2021 年,Flink CDC 社区取得了迅速的发展,GitHub 的 PR 和 issue 相当活跃,GitHub Star 更是年度同比增长 330%。
Flink CDC 入湖入仓在阿里巴巴也有大规模的实践和落地,过程中也遇到了一些痛点和挑战。我们会介绍下我们是如何改进和解决的。
我们先来看下 CDC 入湖遇到的一些痛点和挑战。这是某个用户原有的 CDC 数据入湖架构,分为两个链路:
这个架构虽然利用了 Hudi 的更新能力,无需周期性地调度全量合并任务,能做到分钟级延迟。但是全量和增量仍是割裂的两个作业,全量和增量的切换仍需要人工的介入,并且需要指定一个准确的增量启动位点,否则的话就会有丢失数据的风险。可以看到这种架构是流批割裂的,并不是一个统一的整体。刚刚雪尽也介绍了 Flink CDC 最大的一个优势之一就是全增量的自动切换,所以我们用 Flink CDC 替换了用户原有的入湖架构。
但是用户用了 Flink CDC 后,遇到的第一个痛点就是需要将 MySQL 的 DDL 手工映射成 Flink 的 DDL。手工映射表结构是比较繁琐的,尤其是当表和字段数非常多的时候。而且手工映射也容易出错,比如 说 MySQL 的 BIGINT UNSINGED,它不能映射成 Flink 的 BIGINT,而是要映射成 DECIMAL(20)。 如果系统能自动帮助用户自动去映射表结构就会简单安全很多。
用户遇到的另一个痛点是表结构的变更导致入湖链路难以维护。例如用户有一张表,原先有 id 和 name 两列,突然增加了一列 Address。新增的这一列数据可能就无法同步到数据湖中,甚至导致入湖链路的挂掉,影响稳定性。除了加列的变更,还可能会有删列、类型变更等等。国外的 Fivetran 做过一个调研报告 ,发现 60% 的公司,schema 每个月都会变化,30% 每周都会变化。这说明基本每个公司都会面临 schema 变更带来的数据集成上的挑战。
最后一个是整库入湖的挑战。因为用户主要使用 SQL,这就需要为每个表的数据同步链路定义一个 INSERT INTO 语句。有些用户的 MySQL 实例中甚至有上千张的业务表,用户就要写上千个 INSERT INTO 语句。更令人望而生却的是,每一个 INSERT INTO 任务都会创建至少一个数据库连接,读取一次 Binlog 数据。千表入湖的话就需要上千个连接,上千次的 Binlog 重复读取。这就会对 MySQL 和网络造成很大的压力。
刚刚我们介绍了 CDC 数据入湖的很多痛点和挑战,我们可以站在用户的角度想一想,数据库入湖这个场景用户到底想要的是什么呢?我们可以先把中间的数据集成系统看成一个黑盒,用户会期望这个黑盒提供什么样的能力来简化入湖的工作呢?
这四个核心功能基本组成了用户理想中所期待的数据集成系统,而这一切如果只需要一行 SQL,一个Job就能完成的话,那就更完美了。我们把中间的这个系统称为 “全自动化数据集成”,因为它全自动地完成了数据库的入湖,解决了目前遇到的几个核心痛点。而且目前看来,Flink 是实现这一目标非常适合的引擎。
所以我们花了很多精力,基于 Flink 去打造这个 “全自动化数据集成”。主要就是围绕刚刚说的这四点。
为了支持整库同步,我们还引入了 CDAS 和 CTAS 的数据同步语法。它的语法非常简单,CDAS 语法就是 create database as database,主要用于整库同步,像这里展示的这行语句就完成了从 MySQL 的 tpc_ds 库,整库同步至 Hudi 的 ods 库中。与之类似的,我们还有一个 CTAS 语法,可以方便的用来支持表级别的同步,还可以通过正则表达式指定库名和表名,来完成分库分表合并同步。像这里就完成了 MySQL 的 user 分库分表合并到了 Hudi 的 users 表中。CDAS CTAS 的语法,会自动地去目标端创建目标表,然后启动一个 Flink Job 自动同步全量 + 增量的数据,并且也会实时同步表结构变更。
之前提到千表入湖时,建立的数据库连接过多,Binlog 重复读取会造成源库的巨大压力。为了解决这个问题,我们引入了 source 合并的优化,我们会尝试合并同一作业中的 source,如果都是读的同一数据源,则会被合并成一个 source 节点,这时数据库只需要建立一个连接,binlog 也只需读取一次,实现了整库的读取,降低了对数据库的压力。
为了更直观地了解我们是如何简化数据入湖入仓的工作,我们还额外提供了一个 Demo 视频,感兴趣的朋友可以在 Flink Forward Asia 2021 大会上,观看《Flink CDC 如何简化实时数据入湖入仓》的分享。
最后关于 Flink CDC 的未来主要有三个方面的规划。
也希望有更多的志同道合之士能加入到 Flink CDC 开源社区的建设和贡献中,一起打造新一代的数据集成框架!
]]>数据仓库是一个集成的(Integrated),面向主题的(Subject-Oriented),随时间变化的(Time-Variant),不可修改的(Nonvolatile)数据集合,用于支持管理决策。这是数据仓库之父 Bill Inmon 在 1990 年提出的数据仓库概念。该概念里最重要的一点就是“集成的”,其余特性都是一些方法论的东西。因为数据仓库首先要解决的问题,就是数据集成,就是将多个分散的、异构的数据源整合在一起,消除数据孤岛,便于后续的分析。这个不仅适用于传统的离线数仓,也同样适用于实时数仓,或者是现在火热的数据湖。首先要解决的就是数据集成的问题。如果说业务的数据都在一个数据库中,并且这个数据库还能提供非常高效的查询分析能力,那其实也用不着数据仓库和数据湖上场了。
数据集成就是我们常称作 ETL 的过程,分别是数据接入(Extract)、数据清洗转换打宽(Transformation)、以及数据的入仓入湖(Load),分别对应三个英文单词的首字母,所以叫 ETL。ETL 的过程也是数仓搭建中最具工作量的环节。那么 Flink 是如何改善这个 ETL 的过程的呢?我们先来看看传统的数据仓库的架构。
传统的数据仓库,实时和离线数仓是比较割裂的两套链路,比如实时链路通过 Flume和 Canal 实时同步日志和数据库数据到 Kafka 中,然后在 Kafka 中做数据清理和打宽。离线链路通过 Flume 和 Sqoop 定期同步日志和数据库数据到 HDFS 和 Hive。然后在 Hive 里做数据清理和打宽。
这里我们主要关注的是数仓的前半段的构建,也就是到 ODS、DWD 层,我们把这一块看成是广义的 ETL 数据集成的范围。那么在这一块,传统的架构主要存在的问题就是这种割裂的数仓搭建这会造成很多重复工作,重复的资源消耗,并且实时、离线底层数据模型不一致,会导致数据一致性和质量难以保障。同时两个链路的数据是孤立的,数据没有实现打通和共享。
那么 Flink 能给这个架构带来什么改变呢?
基于 Flink SQL 我们现在可以方便地构建流批一体的 ETL 数据集成,与传统数仓架构的核心区别主要是这几点:
所以基于流批一体的架构,我们能获得的收益:
接下来我们会针对这个架构中的各个部分,结合场景案例展开进行介绍,包括数据接入,数据入仓入湖,数据打宽。
现在数据仓库典型的数据来源主要来自日志和数据库,日志接入现阶段已经非常成熟了,也有非常丰富的开源产品可供选择,包括 Flume,Filebeat,Logstash 等等都能很方便地采集日志到 Kafka 。这里我们就不作过多展开。
数据库接入会复杂很多,常见的几种 CDC 同步工具包括 Canal,Debezium,Maxwell。Flink 通过 CDC format 与这些同步工具做了很好的集成,可以直接消费这些同步工具产生的数据。同时 Flink 还推出了原生的 CDC connector,直连数据库,降低接入门槛,简化数据同步流程。
我们先来看一个使用 CDC format 的例子。现在常见的方案是通过 Debezium 或者 Canal 去实时采集 MySQL 数据库的 binlog,并将行级的变更事件同步到 Kafka 中供 Flink 分析处理。在 Flink 推出 CDC format 之前,用户要去消费这种数据会非常麻烦,用户需要了解 CDC 工具的数据格式,将 before,after 等字段都声明出来,然后用 ROW_NUMBER 做个去重,来保证实时保留最后一行的语义。但这样使用成本很高,而且也不支持 DELETE 事件。
现在 Flink 支持了 CDC format,比如这里我们在 with 参数中可以直接指定 format = ‘debezium-json’,然后 schema 部分只需要填数据库中表的 schema 即可。Flink 能自动识别 Debezium 的 INSERT/UPDATE/DELETE 事件,并转成 Flink 内部的 INSERT/UPDATE/DELETE 消息。之后用户可以在该表上直接做聚合、join 等操作,就跟操作一个 MySQL 实时物化视图一样,非常方便。
在 Flink 1.12 版本中,Flink 已经原生支持了大部分常见的 CDC format,比如 Canal json、Debezium json、Debezium avro、Maxwell 等等。同时 Flink 也开放了 CDC format 的接口,用户可以实现自己的 CDC format 插件来对接自己公司的同步工具。
除此之外,Flink 内部原生支持了 CDC 的语义,所以可以很自然地直接去读取 MySQL 的 binlog 数据并转成 Flink 内部的变更消息。所以我们推出了 MySQL CDC connector,你只需要在 with 参数中指定 connector=mysql-cdc,然后 select 这张表就能实时读取 MySQL 中的全量 +CDC 增量数据,无需部署其他组件和服务。你可以把 Flink 中定义的这张表理解成是 MySQL 的实时物化视图,所以在这张表上的聚合、join 等结果,跟实时在 MySQL 中运行出来的结果是一致的。相比于刚刚介绍的 Debezium,Canal 的架构,CDC connector 在使用上更加简单易用了,不用再去学习和维护额外组件,数据不需要经过 Kafka 落地,减少了端到端延迟。而且支持先读取全量数据,并无缝切换到 CDC 增量读取上,也就是我们说的是流批一体,流批融合的架构。
我们发现 MySQL CDC connector 非常受用户的欢迎,尤其是结合 OLAP 引擎,可以快速构建实时 OLAP 架构。实时 OLAP 架构的一个特点就是将数据库数据同步到 OLAP 中做即席查询,这样就无需离线数仓了。
以前是怎么做的呢?
之前用户一般先用 datax 做个全量同步,然后用 canal 同步实时增量到 Kafka,然后从 Kafka 同步到 OLAP,这种架构比较复杂,链路也很长。现在很多公司都在用 Flink+ClickHouse 来快速构建实时 OLAP 架构。我们只需要在 Flink 中定义一个 mysql-cdc source,一个 ClickHouse sink,然后提交一个 insert into query 就完成了从 MySQL 到 ClickHouse 的实时同步工作,非常方便。而且,ClickHouse 有一个痛点就是 join 比较慢,所以一般我们会把 MySQL 数据打成一张大的明细宽表数据,再写入 ClickHouse。这个在 Flink 中一个 join 操作就完成了。而在 Flink 提供 MySQL CDC connector 之前,要在全量+增量的实时同步过程中做 join 是非常麻烦的。
当然,这里我们也可以把 ClickHouse 替换成其他常见的 OLAP 引擎,比如阿里云的 Hologres。我们发现在阿里云上有很多的用户都采用了这套链路和架构,因为它可以省掉数据同步服务和消息中间件的成本,对于很多中小公司来说,在如今的疫情时代,控制成本是非常重要的。当然,这里也可以使用其他 OLAP 引擎,比如 TiDB。TiDB 官方也在最近发过一篇文章介绍这种 Flink+TiDB 的实时 OLAP架构。
刚刚我们介绍了基于 Flink SQL 可以非常方便地做数据接入,也就是 ETL 的 Extract 的部分。接下来,我们介绍一下 Flink SQL 在数据入仓入湖方面的能力,也就是 Load 的部分。
我们回顾下刚刚的流批一体的架构图,其中最核心的部分就是 Kafka 数据的流式入仓,正是这一流程打通了实时和离线数仓,统一了数仓的基础公共数据,提升了离线数仓的时效性,所以我们针对这一块展开讲一讲。
使用 Flink SQL 做流式数据入仓,非常的方便,而且 1.12 版本已经支持了小文件的自动合并,解决了小文件的痛点。可以看下右边这段代码,先在 Flink SQL 中使用 Hive dialect 创建一张 Hive 的结果表,然后通过 select from kafka 表 insert into Hive 表这样一个简单 query,就可以提交任务实时将 Kafka 数据流式写入 Hive。
如果要开启小文件合并,只需要在 Hive 表参数中加上 auto-compaction = true,那么在流式写入这张 Hive 表的时候就会自动做小文件的 compaction。小文件合并的原理,是 Flink 的 streaming sink 会起一个小拓扑,里面 temp writer 节点负责不断将收到的数据写入临时文件中,当收到 checkpoint 时,通知 compact coordinator 开始做小文件合并,compact coordinator 会将 compaction 任务分发给多个 compact operator 并发地去做小文件合并。当 compaction 完成的时候,再通知 partition committer 提交整个分区文件可见。整个过程利用了 Flink 自身的 checkpoint 机制完成 compaction 的自动化,无需起另外的 compaction 服务。这也是 Flink 流式入仓对比于其他入仓工具的一个核心优势。
除了流式入仓,Flink 现在也支持流式入湖。以 Iceberg 举例,基于 Iceberg 0.10,现在可以在 Flink SQL 里面直接 create 一个 Iceberg catalog,在 Iceberg catalog 下可以 create table 直接创建 Iceberg表。然后提交 insert into query 就可以将流式数据导入到 Iceberg 中。然后在 Flink 中可以用 batch 模式读取这张 Iceberg 表,做离线分析。不过 Iceberg 的小文件自动合并功能目前还没有发布,还在支持中。
刚刚介绍的是纯 append 数据流式入仓入湖的能力,接下来介绍 CDC 数据流式入仓入湖的能力。我们先介绍 CDC 数据入 Kafka 实时数仓。其实这个需求在实时数仓的搭建中是非常常见的,比如同步数据库 binlog 数据到 Kafka 中,又比如 join,聚合的结果是个更新流,用户想把这个更新流写到 Kafka 作为中间数据供下游消费。
这在以前做起来会非常的麻烦,在 Flink 1.12 版本中,Flink 引入了一个新的 connector ,叫做 upsert-kafka,原生地支持了 Kafka 作为一个高效的 CDC 流式存储。
为什么说是高效的,因为存储的形式是与 Kafka log compaction 机制高度集成的,Kafka 会对 compacted topic 数据做自动清理,且 Flink 读取清理后的数据,仍能保证语义的一致性。而且像 Canal, Debezium 会存储 before,op_type 等很多无用的元数据信息,upsert-kafka 只会存储数据本身的内容,节省大量的存储成本。使用上的话,只需要在 DDL 中声明 connector = upsert-kafka,并定义 PK 即可。
比如我们这里定义了 MySQL CDC 的直播间表,以及一个 upsert-kafka 的结果表,将直播间的数据库同步到 Kafka 中。那么写入 Kafka 的 INSERT 和 UPDATE 都是一个带 key 的普通数据,DELETE 是一个带 key 的 NULL 数据。Flink 读取这个 upsert-kafka 中的数据时,能自动识别出 INSERT/UPDATE/DELETE 消息,消费这张 upsert-kafka 表与消费 MySQL CDC 表的语义一致。并且当 Kafka 对 topic 数据做了 compaction 清理后,Flink 读取清理后的数据,仍能保证语义的一致性。
CDC 数据入 Hive 数仓会麻烦一些,因为 Hive 本身不支持 CDC 的语义,现在的一种常见方式是先将 CDC 数据以 changelog-json 格式流式写入到 HDFS。然后起个 batch 任务周期性地将 HDFS 上的 CDC 数据按照 op 类型分为 INSERT, UPDATE, DELETE 三张表,然后做个 batch merge。
前面介绍了基于 Flink SQL 的 ETL 流程的 Extract 和 Load,接下来介绍 Transformation 中最常见的数据打宽操作。
数据打宽是数据集成中最为常见的业务加工场景,数据打宽最主要的手段就是 Join,Flink SQL 提供了丰富的 Join 支持,包括 Regular Join、Interval Join、Temporal Join。
Regular Join 就是大家熟知的双流 Join,语法上就是普通的 JOIN 语法。图中案例是通过广告曝光流关联广告点击流将广告数据打宽,打宽后可以进一步计算广告费用。从图中可以看出,曝光流和点击流都会存入 join 节点的 state,join 算子通过关联曝光流和点击流的 state 实现数据打宽。Regular Join 的特点是,任意一侧流都会触发结果的更新,比如案例中的曝光流和点击流。同时 Regular Join 的语法与传统批 SQL 一致,用户学习门槛低。但需要注意的是,Regular join 通过 state 来存储双流已经到达的数据,state 默认永久保留,所以 Regular join 的一个问题是默认情况下 state 会持续增长,一般我们会结合 state TTL 使用。
Interval Join 是一条流上需要有时间区间的 join,比如刚刚的广告计费案例中,它有一个非常典型的业务特点在里面,就是点击一般发生在曝光之后的 10 分钟内。因此相对于 Regular Join,我们其实只需要关联这10分钟内的曝光数据,所以 state 不用存储全量的曝光数据,它是在 Regular Join 之上的一种优化。要转成一个 Interval Join,需要在两个流上都定义时间属性字段(如图中的 click_time 和 show_time)。并在 join 条件中定义左右流的时间区间,比如这里我们增加了一个条件:点击时间需要大于等于曝光时间,同时小于等于曝光后 10 分钟。与 Regular Join 相同, Interval Join 任意一条流都会触发结果更新,但相比 Regular Join,Interval Join 最大的优点是 state 可以自动清理,根据时间区间保留数据,state 占用大幅减少。Interval Join 适用于业务有明确的时间区间,比如曝光流关联点击流,点击流关联下单流,下单流关联成交流。
Temporal join (时态表关联) 是最常用的数据打宽方式,它常用来做我们熟知的维表 Join。在语法上,它需要一个显式的 FOR SYSTEM_TIME AS OF 语句。它与 Regular Join 以及 Interval Join 最大的区别就是,维度数据的变化不会触发结果更新,所以主流关联上的维度数据不会再改变。Flink 支持非常丰富的 Temporal join 功能,包括关联 lookup DB,关联 changelog,关联 Hive 表。在以前,大家熟知的维表 join 一般都是关联一个可以查询的数据库,因为维度数据在数据库里面,但实际上维度数据可能有多种物理形态,比如 binlog 形式,或者定期同步到 Hive 中变成了 Hive 分区表的形式。在 Flink 1.12 中,现在已经支持关联这两种新的维表形态。
Temporal Join Lookup DB 是最常见的维表 Join 方式,比如在用户点击流关联用户画像的案例中,用户点击流在 Kafka 中,用户实时画像存放在 HBase 数据库中,每个点击事件通过查询并关联 HBase 中的用户实时画像完成数据打宽。Temporal Join Lookup DB 的特点是,维表的更新不会触发结果的更新,维度数据存放在数据库中,适用于实时性要求较高的场景,使用时我们一般会开启 Async IO 和内存 cache 提升查询效率。
在介绍 Temporal Join Changelog 前,我们再看一个 Lookup DB 的例子,这是一个直播互动数据关联直播间维度的案例。这个案例中直播互动数据(比如点赞、评论)存放在 Kafka 中,直播间实时的维度数据(比如主播、直播间标题)存放在 MySQL 中,直播互动的数据量是非常大的,为了加速访问,常用的方案是加个高速缓存,比如把直播间的维度数据通过 CDC 同步,再存入 Redis 中,再做维表关联。这种方案的问题是,直播的业务数据比较特殊,直播间的创建和直播互动数据基本是同时产生的,因此互动数据可能早早地到达了 Kafka 被 Flink 消费,但是直播间的创建消息经过了 Canal, Kafka,Redis, 这个链路比较长,数据延迟比较大,可能导致互动数据查询 Redis 时,直播间数据还未同步完成,导致关联不上直播间数据,造成下游统计分析的偏差。
针对这类场景,Flink 1.12 支持了 Temporal Join Changelog,通过从 changelog在 Flink state 中物化出维表来实现维表关联。刚刚的场景有了更简洁的解决方案,我们可以通过 Flink CDC connector 把直播间数据库表的 changelog 同步到 Kafka 中,注意我们看下右边这段 SQL,我们用了 upsert-kafka connector 来将 MySQL binlog 写入了 Kafka,也就是 Kafka 中存放了直播间变更数据的 upsert 流。然后我们将互动数据 temporal join 这个直播间 upsert 流,便实现了直播数据打宽的功能。
注意我们这里 FOR SYSTEM_TIME AS OF 不是跟一个 processing time,而是左流的 event time,它的含义是去关联这个 event time 时刻的直播间数据,同时我们在直播间 upsert 流上也定义了 watermark,所以 temporal join changelog 在执行上会做 watermark 等待和对齐,保证关联上精确版本的结果,从而解决先前方案中关联不上的问题。
我们详细解释下 temporal join changelog 的过程,左流是互动流数据,右流是直播间 changelog。直播间 changelog 会物化到右流的维表 state 中,state 相当于一个多版本的数据库镜像, 主流互动数据会暂时缓存在左流的 state 中,等到 watermark 到达对齐后再去查维表 state 中的数据。比如现在互动流和直播流的 watermark 都到了10:01分,互动流的这条 10:01 分评论数据就会去查询维表 state,并关联上 103 房间的信息。当 10:05 这条评论数据到来时,它不会马上输出,不然就会关联上空的房间信息。它会一直等待,等到左右两流的 watermark 都到 10:05 后,才会去关联维表 state 中的数据并输出。这个时候,它能关联上准确的 104 房间信息。
总结下,Temporal Join Changelog 的特点是实时性高,因为是按照 event time 做的版本关联,所以能关联上精确版本的信息,且维表会做 watermark 对齐等待,使得用户可以通过 watermark 控制迟到的维表数。Temporal Join Changelog 中的维表数据都是存放在 temporal join 节点的 state 中,读取非常高效,就像是一个本地的 Redis 一样,用户不再需要维护额外的 Redis 组件。
在数仓场景中,Hive 的使用是非常广泛的,Flink 与 Hive 的集成非常友好,现在已经支持 Temporal Join Hive 分区表和非分区表。我们举个典型的关联 Hive 分区表的案例:订单流关联店铺数据。店铺数据一般是变化比较缓慢的,所以业务方一般会按天全量同步店铺表到 Hive 分区中,每天会产生一个新分区,每个分区是当天全量的店铺数据。
为了关联这种 Hive 数据,只需我们在创建 Hive 分区表时指定右侧这两个红圈中的参数,便能实现自动关联 Hive 最新分区功能,partition.include = latestb 表示只读取 Hive 最新分区,partition-name 表示选择最新分区时按分区名的字母序排序。到 10 月 3 号的时候,Hive 中已经产生了 10 月 2 号的新分区, Flink 监控到新分区后,就会重新加载10月2号的数据到 cache 中并替换掉10月1号的数据作为最新的维表。之后的订单流数据关联上的都是 cache 10 月 2 号分区的数据。Temporal join Hive 的特点是可以自动关联 Hive 最新分区,适用于维表缓慢更新,高吞吐的业务场景。
总结一下我们刚刚介绍的几种在数据打宽中使用的 join:
最后我们来总结下 Flink 在 ETL 数据集成上的能力。这是目前 Flink 数据集成的能力矩阵,我们将现有的外部存储系统分为了关系型数据库、KV 数据库、消息队列、数据湖、数据仓库 5 种类型,可以从图中看出 Flink 有非常丰富的生态,并且对每种存储引擎都有非常强大的集成能力。
横向上我们定义了 6 种能力,分别是:
可以看到 Flink 对各个系统的数据接入能力、维度打宽能力、入仓/入湖能力都已经非常完善了。在 CDC 流式读取上,Flink 已经支持了主流的数据库和 Kafka 消息队列。在数据湖方向,Flink 对 Iceberg 的流式读取和 CDC 写入的功能也即将在接下来的 Iceberg 版本中发布。从这个能力矩阵可以看出,Flink 的数据集成能力是非常全面的。
在未来的版本中,我们也将持续优化 Flink 数据集成的能力,扩展上下游生态。也非常欢迎大家使用和反馈。
]]>随着数据时效性对企业的精细化运营越来越重要,“实时即未来”、“实时数仓”、“数据湖” 成为了近几年炙手可热的词。流计算领域的格局也在这几年发生了巨大的变化,Apache Flink 在流批一体的方向上不断深耕,Apache Spark 的近实时处理有着一定的受众,Apache Kafka 也有了 ksqlDB 高调地进军流计算,而 Apache Storm 却开始逐渐地退出历史的舞台。
每一种引擎有其优势的地方,如何选择适合自己业务的流计算引擎成了一个由来已久的话题。除了比较各个引擎提供的不同的功能矩阵之外,性能是一个无法绕开的评估因素。基准测试(benchmark)就是用来评估系统性能的一个重要和常见的过程。
本文将探讨流计算基准测试设计上的难点,分享我们是如何设计一个流计算基准测试框架 —— Nexmark,以及将来的规划。
目前在流计算领域中,还没有一个行业标准的基准测试。目前业界较为人知的流计算 benchmark 是五年前雅虎 Storm 团队发布的 Yahoo Streaming Benchmarks。雅虎的原意是因为业界缺少反映真实场景的 benchmark,模拟了一个简单的广告场景来比较各个流计算框架,后来被广泛引用。具体场景是从 Kafka 消费的广告的点击流,关联 Redis 中的广告所属的 campaign 信息,然后做时间窗口聚合计数。
然而,正是因为雅虎团队太过于追求还原真实的生产环境,导致这些外部系统服务(Kafka, Redis)成为了作业的瓶颈。Ververica 曾在这篇文章中做过一个扩展实验,将数据源从 Kafka 替换成了一个内置的 datagen source,性能提升了 37 倍!由此可见,引入的 Kafka 组件导致了无法准确反映引擎真实的性能。更重要的一个问题是,Yahoo Benchmark 只包含一个非常简单的,类似 “Word Count” 的作业,它无法全面地反映当今复杂的流计算系统和业务。试想,谁会用一个简单的 “Word Count” 去衡量比较各个数据库之间的性能差异呢?正是这些原因使得 Yahoo Benchmark 无法成为一个行业标准的基准测试。这也正是我们想要解决的问题。
因此,我们认为一个行业标准的基准测试应该具备以下几个特点:
可复现性
可复现性是使得 benchmark 被信任的一个重要条件。许多 benchmark 的结果是难以重现的。有的是因为只摆了个 benchmark 结果图,用于生成这些结果的代码并没有公开。有的是因为用于 benchmark 的硬件不容易被别人获取到。有的是因为 benchmark 依赖的服务太多,致使测试结果不稳定。
能代表和覆盖行业真实的业务场景( query 量)
例如数据库领域非常著名的 TPC-H、TPC-DS 涵盖了大量的 query 集合,来捕获查询引擎之间细微的差别。而且这些 query 集合都立于真实业务场景之上(商品零售行业),数据规模大,因此也很受一些大数据系统的青睐。
能调整作业的负载(数据量、数据分布)
在大数据领域,不同的数据规模对于引擎来说可能会是完全不同的事情。例如 Yahoo Benchmark 中使用的 campaign id 只有 100 个,使得状态非常小,内存都可以装的下。这样使得同步 IO 和 checkpoint 等的影响可以忽略不计。而真实的场景往往要面对大状态,面临的挑战要复杂困难的多。像 TPC-DS 的数据生成工具会提供 scalar factor 的参数来控制数据量。其次在数据分布上最好也能贴近真实世界的数据,如有数据倾斜,及调整倾斜比例。从而能全面、综合地反映业务场景和引擎之间地差异。
有统一的性能衡量指标和采集汇总工具
基准测试的性能指标的定义需要清晰、一致,且能适用于各种计算引擎。然而流计算的性能指标要比传统批处理的更难定义、更难采集。是流计算 benchmark 最具挑战性的一个问题,这也会在下文展开描述。
我们也研究了很多其他的流计算相关的基准测试,包括:StreamBench、HiBench、BigDataBench,但是它们都在上述几个基本面有所欠缺。基准测试的行业标杆无疑是 TPC 发布的一系列 benchmark,如 TPC-H,TPC-DS。然而这些 benchmark 是面向传统数据库、传统数仓而设计的,并不适用于今天的流计算系统。例如 benchmark 中没有考虑事件时间、数据的乱序、窗口等流计算中常见的场景。因此我们不得不考虑重新设计并开源一个流计算基准测试框架——Nexmark。
地址:https://github.com/nexmark/nexmark
为了提供一个满足以上几个基本面的流计算基准测试,我们设计和开发了 Nexmark 基准测试框架,并努力让其成为流计算领域的标准 benchmark 。
Nexmark 基准测试框架来源于 NEXMark 研究论文,以及 Apache Beam Nexmark Suite,并在其之上进行了扩展和完善。Nexmark 基准测试框架不依赖任何第三方服务,只需要部署好引擎和 Nexmark,通过脚本 nexmark/bin/run_query.sh all
即可等待并获得所有 query 下的 benchmark 结果。下面我们将探讨 Nexmark 基准测试在设计上的一些决策。
如上所述,Yahoo Benchmark 使用了 Kafka 数据源,却使得最终结果无法准确反映引擎的真实性能。此外,我们还发现,在 benchmark 快慢流双流 JOIN 的场景时,如果使用了 Kafka 数据源,慢流会超前消费(快流易被反压),导致 JOIN 节点的状态会缓存大量超前的数据。这其实不能反映真实的场景,因为在真实的场景下,慢流是无法被超前消费的(数据还未产生)。所以我们在 Nexmark 中使用了 datagen source,数据直接在内存中生成,数据不落地,直接向下游节点发送。多个事件流都由单一的数据生成器生成,所以当快流被反压时,也能抑制慢流的生成,较好地反映了真实场景。
与之类似的,我们也移除了外部 sink 的依赖,不再输出到 Kafka/Redis,而是输出到一个空 sink 中,即 sink 会丢弃收到的所有数据。
通过这种方式,我们保证了瓶颈只会在引擎自身,从而能精确地测量出引擎之间细微的差异。
批处理系统 benchmark 的 metric 通常采用总体耗时来衡量。然而流计算系统处理的数据是源源不断的,无法统计 query 耗时。因此,我们提出三个主要的 metric:吞吐、延迟、CPU。Nexmark 测试框架会自动帮我们采集 metric,并做汇总,不需要部署任何第三方的 metric 服务。
吞吐
吞吐(throughput)也常被称作 TPS,描述流计算系统每秒能处理多少条数据。由于我们有多个事件流,所有事件流都由一个数据生成器生成,为了统一观测角度,我们采用数据生成器的 TPS,而非单一事件流的 TPS。我们将一个 query 能达到的最大吞吐,作为其吞吐指标。例如,针对 Flink 引擎,我们通过 Flink REST API 暴露的 <source_operator_name>.numRecordsOutPerSecond
metric 来获取当前吞吐量。
延迟
延迟(Latency)描述了从数据进入流计算系统,到它的结果被输出的时间间隔。对于窗口聚合,Yahoo Benchmark 中使用 output_system_time - window_end
作为延迟指标,这其实并没有考虑数据在窗口输出前的等待时间,这种计算结果也会极大地受到反压的影响,所以其计算结果是不准确的。一种更准确的计算方式应为 output_system_time - max(ingest_time)
。然而在非窗口聚合,或双流 JOIN 中,延迟又会有不同的计算方式。
所以延迟的定义和采集在流计算系统中有很多现实存在的问题,需要根据具体 query 具体分析,这在《Benchmarking Distributed Stream Data Processing Systems》中有详细的讨论,这也是我们目前还未在 Nexmark 中实现延迟 metric 的原因。
CPU
资源使用率是很多流计算 benchmark 中忽视的一个指标。由于在真实生产环境,我们并不会限制流计算引擎所能使用的核数,从而给系统更大的弹性。所以我们引入了 CPU 使用率,作为辅助指标,即作业一共消耗了多少核。通过吞吐/cores
,可以计算出平均每个核对于吞吐的贡献。对于进程的 CPU 使用率的采集,我们没有使用 JVM CPU load,而是借鉴了 YARN 中的实现,通过采样 /proc/<pid>/stat
并计算获得,该方式可以获得较为真实的进程 CPU 使用率。因此我们的 Nexmark 测试框架需要在测试开始前,先在每台机器上部署 CPU 采集进程。
Nexmark 的业务模型基于一个真实的在线拍卖系统。所有的 query 都基于相同的三个数据流,三个数据流会有一个数据生成器生成,来控制他们之间的比例、数据偏斜、关联关系等等。这三个数据流分别是:
我们一共定义了 16 个 query,所有的 query 都使用 ANSI SQL 标准语法。基于 SQL ,我们可以更容易地扩展 query 测试集,支持更多的引擎。然而,由于 Spark 在流计算功能上的限制,大部分的 query 都无法通过 Structured Streaming 来实现。因此我们目前只支持测试 Flink SQL 引擎。
我们也支持配置调整作业的负载,包括数据生成器的吞吐量以及吞吐曲线、各个数据流之间的数据量比例、每个数据流的数据平均大小以及数据倾斜比例等等。具体的可以参考 Source DDL 参数。
我们在阿里云的三台机器上进行了 Nexmark 针对 Flink 的基准测试。每台机器均为 ecs.i2g.2xlarge 规格,配有 Xeon 2.5 GHz CPU (8 vCores) 以及 32 GB 内存,800 GB SSD 本地磁盘。机器之间的带宽为 2 Gbps。
测试了 flink-1.11 版本,我们在这 3 台机器上部署了 Flink standalone 集群,由 1 个 JobManager,8 个 TaskManager (每个只有 1 slot)组成,都是 4 GB内存。集群默认并行度为 8。开启 checkpoint 以及 exactly once 模式,checkpoint 间隔 3 分钟。使用 RocksDB 状态后端。测试发现,对于有状态的 query,每次 checkpoint 的大小在 GB 级以上,所以有效地测试的大状态的场景。
Datagen source 保持 1000 万每秒的速率生成数据,三个数据流的数据比例分别是 Bid: 92%,Auction: 6%,Person: 2%。每个 query 都先运行 3 分钟热身,之后 3 分钟采集性能指标。
运行 nexmark/bin/run_query.sh all
后,打印测试结果如下:
我们开发和设计 Nexmark 的初衷是为了推出一套标准的流计算 benchmark 测试集,以及测试流程。虽然目前仅支持了 Flink 引擎,但在当前也具有一定的意义,例如:
当然,我们也计划持续改进和完善 Nexmark 测试框架,例如支持 Latency metric,支持更多的引擎,如 Spark Structured Streaming, Spark Streaming, ksqlDB, Flink DataStream 等等。也欢迎有志之士一起加入贡献和扩展。
]]>上周四在 Flink 中文社区钉钉群中直播分享了《Demo:基于 Flink SQL 构建流式应用》,直播内容偏向实战演示。这篇文章是对直播内容的一个总结,并且改善了部分内容,比如除 Flink 外其他组件全部采用 Docker Compose 安装,简化准备流程。读者也可以结合视频和本文一起学习。完整分享可以观看视频回顾:https://www.bilibili.com/video/av90560012
Flink 1.10.0 于近期刚发布,释放了许多令人激动的新特性。尤其是 Flink SQL 模块,发展速度非常快,因此本文特意从实践的角度出发,带领大家一起探索使用 Flink SQL 如何快速构建流式应用。
本文将基于 Kafka, MySQL, Elasticsearch, Kibana,使用 Flink SQL 构建一个电商用户行为的实时分析应用。本文所有的实战演练都将在 Flink SQL CLI 上执行,全程只涉及 SQL 纯文本,无需一行 Java/Scala 代码,无需安装 IDE。本实战演练的最终效果图:
一台装有 Docker 和 Java8 的 Linux 或 MacOS 计算机。
本实战演示所依赖的组件全都编排到了容器中,因此可以通过 docker-compose
一键启动。你可以通过 wget
命令自动下载该 docker-compose.yml
文件,也可以手动下载。
mkdir flink-demo; cd flink-demo; |
该 Docker Compose 中包含的容器有:
docker-compose.yml
中 datagen 的 speedup
参数来调整生成速率(重启 docker compose 才能生效)。category
),预先填入了子类目与顶级类目的映射关系,后续作为维表使用。在启动容器前,建议修改 Docker 的配置,将资源调整到 4GB 以及 4核。启动所有的容器,只需要在 docker-compose.yml
所在目录下运行如下命令。
docker-compose up -d |
该命令会以 detached 模式自动启动 Docker Compose 配置中定义的所有容器。你可以通过 docker ps
来观察上述的五个容器是否正常启动了。 也可以访问 http://localhost:5601/ 来查看 Kibana 是否运行正常。
另外可以通过如下命令停止所有的容器:
docker-compose down |
我们推荐用户手动下载安装 Flink,而不是通过 Docker 自动启动 Flink。因为这样可以更直观地理解 Flink 的各个组件、依赖、和脚本。
flink-1.10.0
):https://www.apache.org/dist/flink/flink-1.10.0/flink-1.10.0-bin-scala_2.11.tgzcd flink-1.10.0
通过如下命令下载依赖 jar 包,并拷贝到 lib/
目录下,也可手动下载和拷贝。因为我们运行时需要依赖各个 connector 实现。
wget -P ./lib/ https://repo1.maven.org/maven2/org/apache/flink/flink-json/1.10.0/flink-json-1.10.0.jar | \ |
将 conf/flink-conf.yaml
中的 taskmanager.numberOfTaskSlots
修改成 10,因为我们会同时运行多个任务。
./bin/start-cluster.sh
,启动集群。bin/sql-client.sh embedded
启动 SQL CLI。便会看到如下的松鼠欢迎界面。Datagen 容器在启动后会往 Kafka 的 user_behavior
topic 中持续不断地写入数据。数据包含了2017年11月27日一天的用户行为(行为包括点击、购买、加购、喜欢),每一行表示一条用户行为,以 JSON 的格式由用户ID、商品ID、商品类目ID、行为类型和时间组成。该原始数据集来自阿里云天池公开数据集,特此鸣谢。
我们可以在 docker-compose.yml
所在目录下运行如下命令,查看 Kafka 集群中生成的前10条数据。
docker-compose exec kafka bash -c 'kafka-console-consumer.sh --topic user_behavior --bootstrap-server kafka:9094 --from-beginning --max-messages 10' |
{"user_id": "952483", "item_id":"310884", "category_id": "4580532", "behavior": "pv", "ts": "2017-11-27T00:00:00Z"} |
有了数据源后,我们就可以用 DDL 去创建并连接这个 Kafka 中的 topic 了。在 Flink SQL CLI 中执行该 DDL。
CREATE TABLE user_behavior ( |
如上我们按照数据的格式声明了 5 个字段,除此之外,我们还通过计算列语法和 PROCTIME()
内置函数声明了一个产生处理时间的虚拟列。我们还通过 WATERMARK 语法,在 ts 字段上声明了 watermark 策略(容忍5秒乱序), ts 字段因此也成了事件时间列。关于时间属性以及 DDL 语法可以阅读官方文档了解更多:
在 SQL CLI 中成功创建 Kafka 表后,可以通过 show tables;
和 describe user_behavior;
来查看目前已注册的表,以及表的详细信息。我们也可以直接在 SQL CLI 中运行 SELECT * FROM user_behavior;
预览下数据(按q
退出)。
接下来,我们会通过三个实战场景来更深入地了解 Flink SQL 。
我们先在 SQL CLI 中创建一个 ES 结果表,根据场景需求主要需要保存两个数据:小时、成交量。
CREATE TABLE buy_cnt_per_hour ( |
我们不需要在 Elasticsearch 中事先创建 buy_cnt_per_hour
索引,Flink Job 会自动创建该索引。
统计每小时的成交量就是每小时共有多少 “buy” 的用户行为。因此会需要用到 TUMBLE 窗口函数,按照一小时切窗。然后每个窗口分别统计 “buy” 的个数,这可以通过先过滤出 “buy” 的数据,然后 COUNT(*)
实现。
INSERT INTO buy_cnt_per_hour |
这里我们使用 HOUR
内置函数,从一个 TIMESTAMP 列中提取出一天中第几个小时的值。使用了 INSERT INTO
将 query 的结果持续不断地插入到上文定义的 es 结果表中(可以将 es 结果表理解成 query 的物化视图)。另外可以阅读该文档了解更多关于窗口聚合的内容:https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/table/sql/queries.html#group-windows
在 Flink SQL CLI 中运行上述查询后,在 Flink Web UI 中就能看到提交的任务,该任务是一个流式任务,因此会一直运行。
可以看到凌晨是一天中成交量的低谷。
我们已经通过 Docker Compose 启动了 Kibana 容器,可以通过 http://localhost:5601 访问 Kibana。首先我们需要先配置一个 index pattern。点击左侧工具栏的 “Management”,就能找到 “Index Patterns”。点击 “Create Index Pattern”,然后通过输入完整的索引名 “buy_cnt_per_hour” 创建 index pattern。创建完成后, Kibana 就知道了我们的索引,我们就可以开始探索数据了。
先点击左侧工具栏的”Discovery”按钮,Kibana 就会列出刚刚创建的索引中的内容。
接下来,我们先创建一个 Dashboard 用来展示各个可视化的视图。点击页面左侧的”Dashboard”,创建一个名为 ”用户行为日志分析“ 的Dashboard。然后点击 “Create New” 创建一个新的视图,选择 “Area” 面积图,选择 “buy_cnt_per_hour” 索引,按照如下截图中的配置(左侧)画出成交量面积图,并保存为”每小时成交量“。
另一个有意思的可视化是统计一天中每一刻的累计独立用户数(uv),也就是每一刻的 uv 数都代表从0点到当前时刻为止的总计 uv 数,因此该曲线肯定是单调递增的。
我们仍然先在 SQL CLI 中创建一个 Elasticsearch 表,用于存储结果汇总数据。主要有两个字段:时间和累积 uv 数。
CREATE TABLE cumulative_uv ( |
为了实现该曲线,我们可以先通过 OVER WINDOW 计算出每条数据的当前分钟,以及当前累计 uv(从0点开始到当前行为止的独立用户数)。 uv 的统计我们通过内置的 COUNT(DISTINCT user_id)
来完成,Flink SQL 内部对 COUNT DISTINCT 做了非常多的优化,因此可以放心使用。
CREATE VIEW uv_per_10min AS |
这里我们使用 SUBSTR
和 DATE_FORMAT
还有 ||
内置函数,将一个 TIMESTAMP 字段转换成了 10分钟单位的时间字符串,如: 12:10
, 12:20
。关于 OVER WINDOW 的更多内容可以参考文档:https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/table/sql/queries.html#aggregations
我们还使用了 CREATE VIEW 语法将 query 注册成了一个逻辑视图,可以方便地在后续查询中对该 query 进行引用,这有利于拆解复杂 query。注意,创建逻辑视图不会触发作业的执行,视图的结果也不会落地,因此使用起来非常轻量,没有额外开销。由于 uv_per_10min
每条输入数据都产生一条输出数据,因此对于存储压力较大。我们可以基于 uv_per_10min
再根据分钟时间进行一次聚合,这样每10分钟只有一个点会存储在 Elasticsearch 中,对于 Elasticsearch 和 Kibana 可视化渲染的压力会小很多。
INSERT INTO cumulative_uv |
提交上述查询后,在 Kibana 中创建 cumulative_uv
的 index pattern,然后在 Dashboard 中创建一个”Line”折线图,选择 cumulative_uv
索引,按照如下截图中的配置(左侧)画出累计独立用户数曲线,并保存。
最后一个有意思的可视化是类目排行榜,从而了解哪些类目是支柱类目。不过由于源数据中的类目分类太细(约5000个类目),对于排行榜意义不大,因此我们希望能将其归约到顶级类目。所以笔者在 mysql 容器中预先准备了子类目与顶级类目的映射数据,用作维表。
在 SQL CLI 中创建 MySQL 表,后续用作维表查询。
CREATE TABLE category_dim ( |
同时我们再创建一个 Elasticsearch 表,用于存储类目统计结果。
CREATE TABLE top_category ( |
第一步我们通过维表关联,补全类目名称。我们仍然使用 CREATE VIEW 将该查询注册成一个视图,简化逻辑。维表关联使用 temporal join 语法,可以查看文档了解更多:https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/table/streaming/joins.html#join-with-a-temporal-table
CREATE VIEW rich_user_behavior AS |
最后根据 类目名称分组,统计出 buy
的事件数,并写入 Elasticsearch 中。
INSERT INTO top_category |
提交上述查询后,在 Kibana 中创建 top_category
的 index pattern,然后在 Dashboard 中创建一个”Horizontal Bar”条形图,选择 top_category
索引,按照如下截图中的配置(左侧)画出类目排行榜,并保存。
可以看到服饰鞋包的成交量远远领先其他类目。
Kibana 还提供了非常丰富的图形和可视化选项,感兴趣的用户可以用 Flink SQL 对数据进行更多维度的分析,并使用 Kibana 展示出可视化图,并观测图形数据的实时变化。
在本文中,我们展示了如何使用 Flink SQL 集成 Kafka, MySQL, Elasticsearch 以及 Kibana 来快速搭建一个实时分析应用。整个过程无需一行 Java/Scala 代码,使用 SQL 纯文本即可完成。期望通过本文,可以让读者了解到 Flink SQL 的易用和强大,包括轻松连接各种外部系统、对事件时间和乱序数据处理的原生支持、维表关联、丰富的内置函数等等。希望你能喜欢我们的实战演练,并从中获得乐趣和知识!
]]>上周六在深圳分享了《Flink SQL 1.9.0 技术内幕和最佳实践》,会后许多小伙伴对最后演示环节的 Demo 代码非常感兴趣,迫不及待地想尝试下,所以写了这篇文章分享下这份代码。希望对于 Flink SQL 的初学者能有所帮助。完整分享可以观看 Meetup 视频回顾 :https://developer.aliyun.com/live/1416
演示代码已经开源到了 GitHub 上:https://github.com/wuchong/flink-sql-submit 。
这份代码主要由两部分组成:1) 能用来提交 SQL 文件的 SqlSubmit 实现。2) 用于演示的 SQL 示例、Kafka 启动停止脚本、 一份测试数据集、Kafka 数据源生成器。
通过本实战,你将学到:
笔者一开始是想用 SQL Client 来贯穿整个演示环节,但可惜 1.9 版本 SQL CLI 还不支持处理 CREATE TABLE 语句。所以笔者就只好自己写了个简单的提交脚本。后来想想,也挺好的,可以让听众同时了解如何通过 SQL 的方式,和编程的方式使用 Flink SQL。
SqlSubmit 的主要任务是执行和提交一个 SQL 文件,实现非常简单,就是通过正则表达式匹配每个语句块。如果是 CREATE TABLE
或 INSERT INTO
开头,则会调用 tEnv.sqlUpdate(...)
。如果是 SET
开头,则会将配置设置到 TableConfig
上。其核心代码主要如下所示:
EnvironmentSettings settings = EnvironmentSettings.newInstance() |
在 flink-sql-submit 项目中,我们准备了一份测试数据集(来自阿里云天池公开数据集,特别鸣谢),位于 src/main/resources/user_behavior.log
。数据以 JSON 格式编码,大概长这个样子:
{"user_id": "543462", "item_id":"1715", "category_id": "1464116", "behavior": "pv", "ts": "2017-11-26T01:00:00Z"} |
为了模拟真实的 Kafka 数据源,笔者还特地写了一个 source-generator.sh
脚本(感兴趣的可以看下源码),会自动读取 user_behavior.log 的数据并以默认每毫秒1条的速率灌到 Kafka 的 user_behavior
topic 中。
有了数据源后,我们就可以用 DDL 去创建并连接这个 Kafka 中的 topic(详见 src/main/resources/q1.sql
)。
CREATE TABLE user_log ( |
注:可能有用户会觉得其中的
connector.properties.0.key
等参数比较奇怪,社区计划将在下一个版本中改进并简化 connector 的参数配置。
连接 MySQL 可以使用 Flink 提供的 JDBC connector。例如
CREATE TABLE pvuv_sink ( |
假设我们的需求是计算每小时全网的用户访问量,和独立用户数。很多用户可能会想到使用滚动窗口来计算。但这里我们介绍另一种方式。即 Group Aggregation 的方式。
INSERT INTO pvuv_sink |
它使用 DATE_FORMAT
这个内置函数,将日志时间归一化成“年月日小时”的字符串格式,并根据这个字符串进行分组,即根据每小时分组,然后通过 COUNT(*)
计算用户访问量(PV),通过 COUNT(DISTINCT user_id)
计算独立用户数(UV)。这种方式的执行模式是每收到一条数据,便会进行基于之前计算的值做增量计算(如+1),然后将最新结果输出。所以实时性很高,但输出量也大。
我们将这个查询的结果,通过 INSERT INTO
语句,写到了之前定义的 pvuv_sink
MySQL 表中。
注:在深圳 Meetup 中,我们有对这种查询的性能调优做了深度的介绍。
本实战演示环节需要安装一些必须的服务,包括:
flink-1.9.0
):https://www.apache.org/dist/flink/flink-1.9.0/flink-1.9.0-bin-scala_2.11.tgzflink-1.9.0/lib/
目录下。因为我们运行时需要依赖各个 connector 实现。flink-1.9.0/conf/flink-conf.yaml
中的 taskmanager.numberOfTaskSlots
修改成 10,因为我们的演示任务可能会消耗多于1个的 slot。flink-1.9.0/bin/start-cluster.sh
,启动集群。运行成功的话,可以在 http://localhost:8081 访问到 Flink Web UI。
另外,还需要将 Flink 的安装路径填到 flink-sql-submit 项目的 env.sh
中,用于后面提交 SQL 任务,如我的路径是
FLINK_DIR=/Users/wuchong/dev/install/flink-1.9.0 |
将安装路径填到 flink-sql-submit 项目的 env.sh
中,如我的路径是
KAFKA_DIR=/Users/wuchong/dev/install/kafka_2.11-2.2.0 |
在 flink-sql-submit
目录下运行 ./start-kafka.sh
启动 Kafka 集群。
jps
,如果看到 Kafka
进程和 QuorumPeerMain
进程即表明启动成功。$ docker pull mysql |
在 MySQL 中创建一个 flink-test
的数据库,并按照上文的 schema 创建 pvuv_sink 表。
flink-sql-submit
目录下运行 ./source-generator.sh
,会自动创建 user_behavior
topic,并实时往里灌入数据。flink-sql-submit
目录下运行 ./run.sh q1
, 提交成功后,可以在 Web UI 中看到拓扑。在 MySQL 客户端,我们也可以实时地看到每个小时的 pv uv 值在不断地变化。
本文带大家搭建基础集群环境,并使用 SqlSubmit 提交纯 SQL 任务来学习了解如何连接外部系统。flink-sql-submit/src/main/resources/q1.sql
中还有一些注释掉的调优参数,感兴趣的同学可以将参数打开,观察对作业的影响。关于这些调优参数的原理,可以看下我在深圳 Meetup 上的分享《Flink SQL 1.9.0 技术内幕和最佳实践》。
注: 本教程实践基于 Ververica 开源的 sql-training 项目。基于 Flink 1.7.2 。
本文将通过五个实例来贯穿 Flink SQL 的编程实践,主要会涵盖以下几个方面的内容。
本文假定您已具备基础的 SQL 知识。
本文教程是基于 Docker 进行的,因此你只需要安装了 Docker 即可。不需要依赖 Java、Scala 环境、或是IDE。
注意:Docker 默认配置的资源可能不太够,会导致运行 Flink Job 时卡死。因此推荐配置 Docker 资源到 3-4 GB,3-4 CPUs。
本次教程的环境使用 Docker Compose 来安装,包含了所需的各种服务的容器,包括:
我们已经提供好了Docker Compose 配置文件,可以直接下载 docker-compose.yml 文件。
然后打开命令行窗口,进入存放 docker-compose.yml
文件的目录,然后运行以下命令:
docker-compose up -d |
set COMPOSE_CONVERT_WINDOWS_PATHS=1 |
docker-compose
命令会启动所有所需的容器。第一次运行的时候,Docker 会自动地从 Docker Hub 下载镜像,这可能会需要一段时间(将近 2.3GB)。之后运行的话,几秒钟就能启动起来了。运行成功的话,会在命令行中看到以下输出,并且也可以在 http://localhost:8081 访问到 Flink Web UI。
运行下面命令进入 Flink SQL CLI 。
docker-compose exec sql-client ./sql-client.sh |
该命令会在容器中启动 Flink SQL CLI 客户端。然后你会看到如下的欢迎界面。
Docker Compose 中已经预先注册了一些表和数据,可以运行 SHOW TABLES;
来查看。本文会用到的数据是 Rides
表,这是一张出租车的行车记录数据流,包含了时间和位置信息,运行 DESCRIBE Rides;
可以查看表结构。
Flink SQL> DESCRIBE Rides; |
Rides 表的详细定义见 training-config.yaml。
例如我们现在只想查看发生在纽约的行车记录。
注:Docker 环境中已经预定义了一些内置函数,如 isInNYC(lon, lat)
可以确定一个经纬度是否在纽约,toAreaId(lon, lat)
可以将经纬度转换成区块。
因此,此处我们可以使用 isInNYC
来快速过滤出纽约的行车记录。在 SQL CLI 中运行如下 Query:
SELECT * FROM Rides WHERE isInNYC(lon, lat); |
SQL CLI 便会提交一个 SQL 任务到 Docker 集群中,从数据源(Rides 流存储在Kafka中)不断拉取数据,并通过 isInNYC
过滤出所需的数据。SQL CLI 也会进入可视化模式,并不断刷新展示过滤后的结果:
也可以到 http://localhost:8081 查看 Flink 作业的运行情况。
我们的另一个需求是计算搭载每种乘客数量的行车事件数。也就是搭载1个乘客的行车数、搭载2个乘客的行车… 当然,我们仍然只关心纽约的行车事件。
因此,我们可以按照乘客数psgCnt
做分组,使用 COUNT(*)
计算出每个分组的事件数,注意在分组前需要先过滤出isInNYC
的数据。在 SQL CLI 中运行如下 Query:
SELECT psgCnt, COUNT(*) AS cnt |
SQL CLI 的可视化结果如下所示,结果每秒都在发生变化。不过最大的乘客数不会超过 6 人。
为了持续地监测纽约的交通流量,需要计算出每个区块每5分钟的进入的车辆数。我们只关心至少有5辆车子进入的区块。
此处需要涉及到窗口计算(每5分钟),所以需要用到 Tumbling Window 的语法。“每个区块” 所以还要按照 toAreaId
进行分组计算。“进入的车辆数” 所以在分组前需要根据 isStart
字段过滤出进入的行车记录,并使用 COUNT(*)
统计车辆数。最后还有一个 “至少有5辆车子的区块” 的条件,这是一个基于统计值的过滤条件,所以可以用 SQL HAVING 子句来完成。
最后的 Query 如下所示:
SELECT |
在 SQL CLI 中运行后,其可视化结果如下所示,每个 area + window_end 的结果输出后就不会再发生变化,但是会每隔 5 分钟会输出一批新窗口的结果。因为 Docker 环境中的source我们做了10倍的加速读取(相对于原始速度),所以演示的时候,大概每隔30秒就会输出一批新窗口。
从实例2和实例3的结果显示上,可以体验出来 Window Aggregate 与 Group Aggregate 是有一些明显的区别的。其主要的区别是,Window Aggregate 是当window结束时才输出,其输出的结果是最终值,不会再进行修改,其输出流是一个 Append 流。而 Group Aggregate 是每处理一条数据,就输出最新的结果,其结果是在不断更新的,就好像数据库中的数据一样,其输出流是一个 Update 流。
另外一个区别是,window 由于有 watermark ,可以精确知道哪些窗口已经过期了,所以可以及时清理过期状态,保证状态维持在稳定的大小。而 Group Aggregate 因为不知道哪些数据是过期的,所以状态会无限增长,这对于生产作业来说不是很稳定,所以建议对 Group Aggregate 的作业配上 State TTL 的配置。
例如统计每个店铺每天的实时PV,那么就可以将 TTL 配置成 24+ 小时,因为一天前的状态一般来说就用不到了。
SELECT DATE_FORMAT(ts, 'yyyy-MM-dd'), shop_id, COUNT(*) as pv |
当然,如果 TTL 配置地太小,可能会清除掉一些有用的状态和数据,从而导致数据精确性地问题。这也是用户需要权衡地一个参数。
上一小节介绍了 Window Aggregate 和 Group Aggregate 的区别,以及 Append 流和 Update 流的区别。在 Flink 中,目前 Update 流只能写入支持更新的外部存储,如 MySQL, HBase, ElasticSearch。Append 流可以写入任意地存储,不过一般写入日志类型的系统,如 Kafka。
这里我们希望将“每10分钟的搭乘的乘客数”写入Kafka。
我们已经预定义了一张 Kafka 的结果表 Sink_TenMinPsgCnts
(training-config.yaml 中有完整的表定义)。
在执行 Query 前,我们先运行如下命令,来监控写入到 TenMinPsgCnts
topic 中的数据:
docker-compose exec sql-client /opt/kafka-client/bin/kafka-console-consumer.sh --bootstrap-server kafka:9092 --topic TenMinPsgCnts --from-beginning |
每10分钟的搭乘的乘客数可以使用 Tumbling Window 来描述,我们使用 INSERT INTO Sink_TenMinPsgCnts
来直接将 Query 结果写入到结果表。
INSERT INTO Sink_TenMinPsgCnts |
我们可以监控到 TenMinPsgCnts
topic 的数据以 JSON 的形式写入到了 Kafka 中:
最后我们实践一下将一个持续更新的 Update 流写入 ElasticSearch 中。我们希望将“每个区域出发的行车数”,写入到 ES 中。
我们也已经预定义好了一张 Sink_AreaCnts
的 ElasticSearch 结果表(training-config.yaml 中有完整的表定义)。该表中只有两个字段 areaId
和 cnt
。
同样的,我们也使用 INSERT INTO
将 Query 结果直接写入到 Sink_AreaCnts
表中。
INSERT INTO Sink_AreaCnts |
在 SQL CLI 中执行上述 Query 后,Elasticsearch 会自动地创建 area-cnts
索引。Elasticsearch 提供了一个 REST API 。我们可以访问
area-cnts
索引的详细信息: http://localhost:9200/area-cnts area-cnts
索引的统计信息: http://localhost:9200/area-cnts/_statsarea-cnts
索引的内容:http://localhost:9200/area-cnts/_search随着 Query 的一直运行,你也可以观察到一些统计值(_all.primaries.docs.count
, _all.primaries.docs.deleted
)在不断的增长:http://localhost:9200/area-cnts/_stats
本文带大家使用 Docker Compose 快速上手 Flink SQL 的编程,并对比 Window Aggregate 和 Group Aggregate 的区别,以及这两种类型的作业如何写入到 外部系统中。感兴趣的同学,可以基于这个 Docker 环境更加深入地去实践,例如运行自己写的 UDF , UDTF, UDAF。查询内置地其他源表等等。
]]>本文将以 Apache Flink 为例,介绍如何参与社区贡献,如何成为 Apache Committer。
我们先来了解下一个小白在 Apache 社区中的成长路线是什么样的。
Apache 软件基金会(Apache Software Foundation,ASF)在开源软件界大名鼎鼎。ASF 能保证旗下 200 多个项目的社区活动运转良好,得益于其独特的组织架构和良好的制度。
用户 (User): 通过使用社区的项目构建自己的业务架构的开发者都是Apache的用户。
贡献者 (Contributor): 帮助解答用户的问题,贡献代码或文档,在邮件列表中参与讨论设计和方案的都是 Contributor。
提交者 (Committer): 贡献多了以后,就有可能经过 PMC 的提议和投票,邀请你成为 Committer。成为 Committer 也就意味着正式加入 Apache了,不但拥有相应项目的写入权限还有 apache.org 的专属邮箱。成为 Committer 的一个福利是可以免费使用 JetBrains 家的全套付费产品,包括全宇宙最好用的 IntelliJ IDEA (这是笔者当初成为 Committer 的最大动力之一)。
PMC: Committer 再往上走就是 PMC,这个必须由现有 PMC 成员提名。PMC 主要负责保证开源项目的社区活动都能运转良好,包括 Roadmap 的制定,版本的发布,Committer 的提拔。
ASF Member 相当于是基金会的“股东”,有董事会选举的投票权,也可以参与董事会竞选。ASF Member 也有权利决定是否接受一个新项目,主要关注 Apache 基金会本身的发展。ASF Member 通常要从 Contributor, Committer 等这些角色起步,逐步通过行动证明自己后,才可能被接受成为ASF Member。
Apache 社区的成员分类,权限由低到高,像极了我们在公司的晋升路线,一步步往上走。
成为 Apache Committer 并没有一个确切的标准,但是 Committer 的候选人一般都是长期活跃的贡献者。成为 Committer 并没有要求必须有巨大的架构改进贡献,或者多少行的代码贡献。贡献文档、参与邮件列表的讨论、帮助回答问题都是很重要的增加贡献,提升影响力的方式。
所以如何成为 Committer 的问题归根结底还是如何参与贡献,以及如何开始贡献的问题。
成为 Committer 的关键在于持之以恒。不同项目,项目所处的不同阶段,成为 Committer 的难度都不太一样,笔者之前也持续贡献了近一年才有幸成为了 Committer。但是只要能坚持,保持活跃,持续贡献,为项目做的贡献被大家认可后,成为 Committer 也只是时间问题了。
参与贡献 Apache 项目有许多途径,包括提Bug,提需求,参与讨论,贡献代码和文档等等。
1.用自己的邮箱给 dev-subscribe@flink.apache.org 发送任意邮件。
2.收到官方确认邮件。
3.回复该邮件,内容随意,表示确认即可。
4.确认后,会收到一封欢迎邮件,表示订阅成功。
注:自2019年7月开始,经过社区讨论,将开始执行新的 JIRA workflow,不再需要去 dev 邮件列表申请 contributor 权限。
如果有感兴趣的 JIRA,可以直接在 JIRA 下面留言,对于复杂的 issue,需要先阐明实现方案。然后会有 Committer/PMC assign issue 给你。
推荐从简单的开始做起。例如中文翻译的issue。
认领了 issue 后建议尽快开始开发,本地的开发环境建议使用 IntelliJ IDEA。在开发过程中有几个注意点:
在提交之前,先更新 master 分支,并通过 git rebase -i master
命令,将自己的提交置顶(也可以通过 IDEA > VCS > Git > Rebase 可视化界面来做 rebase)。同时保证自己的提交信息中只有一个 commit,commit message 遵循规范格式。Commit 格式是 “[FLINK-XXX] [YYY] ZZZ”,其中 XXX 是 JIRA ID,YYY 是 component 名字,ZZZ 是 JIRA title。例如 [FLINK-5385] [core] Add a helper method to create Row object。
要创建一个 pull request,需要将这个开发分支推到自己 fork 的 Flink 仓库中。并在 fork 仓库页面(https://github.com/<your-user-name>/flink
)点击 “Compare & pull request” 或者 “New pull request” 按钮,开始创建一个 PR。确保 base 是 apache/flink master
,head 是刚刚的开发分支。另外在编辑框中按提示提供尽可能丰富的PR描述,然后点击 “Create pull request”。
提交 PR 后会收到修改建议,只需要为这些修改 追加commit 就行,commit message 随意。注意不要 rebase/squash commits。追加 commit 能方便地看出距离上次的改动,而 rebase/squash 会导致 reviewer 不得不从头到尾重新看一遍 diff。
当 PR 获得 Committer 的 +1 认可后,就可以等待被 merge 到主干分支了。merge 的工作会由 Committer 来完成,Committer 会将你的分支再次 rebase 到最新的master 之上,并将多个 commits 合并成一个,完善 commit 信息,做最后的测试检查,最后会 merge 到 master 。
此时在 Flink 仓库的 commit 历史中就能看到自己的提交信息了。恭喜你成为了 code contributor!
在我看来,成为 Apache Committer 的小窍门有几点:
希望通过本文能让大家了解到,成为 Contributor 并没有想象中那么难,成为 Committer 也不是不可能,只要怀有开源的热情,找到自己感兴趣的项目,在开源贡献中成长,持之以恒,付出总会有回报的。
成为 Apache Committer 不仅仅是一种光环和荣誉,更多的是一种责任,代表着社区的信任,期盼着你能为社区做更多的贡献。所以成为 Committer 远不是终点,而是一个更高起点,毕竟 Committer 之上还有 PMC 呢 ;-)。
]]>这次 Blink 开源的主要目的是让社区的开发者们能尽早地尝试一些他们感兴趣的功能与改进。我觉得最核心的贡献包括:
Stream SQL 上的功能补齐和性能优化经阿里内部多年千锤百炼打磨而来,毫无疑问是社区用户们最为翘首以盼的功能,笔者认为这部分的开源和回馈能迅速将 Flink SQL 的流式计算能力提升到高度成熟级别。
同时,Blink 对Batch SQL 上的完善和优化弥补了 Flink 长久以来在批处理能力上的不足,这也为未来Flink深度统一批流大业打下了更为坚实的基础。
另外值得一提的是这次Blink开源的新UI,是由 NG-ZORRO 的作者 vthinkxie 亲自操刀,一改以往 Apache 项目 Web UI 略为朴素的特点。新UI的简洁美观程度比起商业化产品可谓有过之而无不及,其易用性更是得到阿里内部的深度检验与高度认可。
回到 Blink 开源本身,要谈论其意义首先要清楚 Blink 与 Flink 的关系。从阿里多篇公开的报导中我们也了解到 “Blink 永远不会成为另外一个项目,如果后续进入 Apache 一定是成为 Flink 的一部分”,这点从 Blink 最后是以 Flink 的分支方式开源进一步得到了印证。另外 Blink 开源的核心改进将来都有希望合并进 Flink,这必将极大加速 Flink 的发展,同时也会为 Flink 打开更广阔的舞台。对于 Flink 用户来说,无疑也是个好消息,用户不仅能享受到更好用的产品,同时也拥有了更多想象的空间。相信未来几年的大数据领域,也会因此而变得格外精彩。
接下来聊一个大家都格外关心的话题,Blink 的特性将如何合并到 Flink 中
合并 Blink 的方案是在社区公开讨论的,目前方案已经基本确定,从社区大神 Stephan 和 Timo 发起的方案讨论邮件中我们可以了解到合并的大致方案。
首先总体方针:尽可能地与目前的 Table API 保持兼容,使用户无感知地升级。
改动最大的两个模块会是:(1) SQL/Table Query Processor,(2) batch scheduling/failover/shuffle 。
对于 Query Processor(下文简称 QP),社区计划逐渐地构建起一个基于Blink的 QP,默认仍使用 Flink 原生的 QP,Blink QP 会以插件的方式存在,用户可以使用时可以配置成用 Blink QP 来执行(这非常像 Beam 的 runner 架构)。直到 Blink QP 完全合并进来了且稳定了,会作为默认的 QP 实现,而 Flink QP 最终会被移除。为了完成这个方案,flink-table 模块需要做一些模块的拆解和重构,为了尽快拆解完模块和重构,社区可能会短期内暂缓一些 Table API/SQL 的功能开发和贡献。更详细的方案可以看下 FLIP-32(见文末链接[3])。目前这块相关的开发工作已经在进行中了。
对于 batch scheduling & failover 来说,目前已经有一个在进行中的调度重构方案了 FLINK-10429。当这个完成后,Blink 的实现可以以插件的方式作为新的 scheduler 和 failover handler 加入。在测试完备后可以作为默认的策略。对于 Shuffle Service,目前也已经有一个在讨论中的方案了:见文末链接[4]。
就个人的感受而言,最近 Flink 社区正在发生两件重要的事情。一是上文说的 Blink 开源,另一件就是社区对中文用户的日益重视。
Apache Flink 社区最近为中文用户建立了官方的中文邮件列表,提供了一个官方的渠道给中文用户做问题交流。起因是社区在最近的统计中发现,过去三个月内访问 flink.apache.org 官方网站的用户中有 30% 来自中国。
感兴趣的用户可以通过下面的流程来订阅:
1、发送任意邮件到 user-zh-subscribe@flink.apache.org
2、收到官方确认邮件
3、回复该邮件 confirm 即可订阅
订阅成功后将收到 Flink 官方的中文邮件列表的消息,就可以用中文在上面问问题和帮助别人解答问题了。
这件事对笔者触动比较大,因为 Apache 以往一向推崇使用英文交流,肯为中文用户单独开设中文邮件列表的顶级项目实为罕见,据我所知 Flink 是第一个。相信这个举动也是社区做的一次较大的创新和尝试,同时这也从侧面反映了来自中国的开源力量在世界的舞台上获得了越来越多的关注与尊重。同时也期待着可以有更多 Flink 中文社区志愿者参与到这个项目中来,共建社区生态,为中国的开源事业贡献一份力量。
之前由中文社区的小伙伴们共同翻译的中文文档(托管在 https://flink-china.org/ ),现在计划将贡献给 Apache Flink,并已在邮件列表中发起讨论。讨论的内容包括如何在 Flink 主干分支中同时维护中英文文档,如何做中英文文档同步和翻译,中文文档的地址和链接等等。更详细的支持计划可以看下 链接[6]。
文档是一个开源项目重要的组成部分,也是参与开源贡献、融入开源社区的一种方式。感兴趣的同学可以关注下这个邮件的动态,参与到后续的翻译贡献中去。
从社区里发生的和正在发生的一些事情中可以看出,Apache Flink 社区正在拥抱中文用户,能将中文用户以如此高的优先级来支持的社区真不多。这对于国内用户来说无疑是一个积极的信号,同时这一系列举动也很可能会影响接下来几年国内大数据生态圈的变化。
原文:https://data-artisans.com/blog/4-steps-flink-application-production-ready
作者:Nico Kruber, Markos Sfikas
译者:云邪(Jark)
本文阐述了使 Flink 应用达到生产就绪状态所需要的一些配置步骤。在以下部分中,我们概述了重要的配置参数,这些参数是技术领导、DevOps、工程师们在将 Flink 应用程序上线生产之前都需要仔细考虑的。Apache Flink 为大多数配置都提供了开箱即用的默认选项,在许多情况下,它们是POC阶段(概念验证)或探索 Flink 不同 API 和抽象的很好的起点。
然而,将 Flink 应用程序投入生产还需要额外的配置,这些配置可以高效地调整应用的规模,使其达到生产就绪状态,并能与不同系统之间保持兼容,以保证未来迭代升级的需求。
下面几点是我们收集的需要在 Flink 应用上线前做的检查:
Flink 的 keyed state 是由 key group 进行组织的,并分布在 Flink 算子(operator)的各个并发实例上。Key group 是用来分布和影响 Flink 应用程序可扩展性的最小原子单元,每个算子上的 key group 个数即为最大并发数(maxParallelism),可以手动配置也可以直接使用默认配置。默认值粗略地使用 operatorParallelism * 1.5
,下限 128,上限 32768 。可以通过 setMaxParallelism(int maxParallelism)
来手动地设定作业或具体算子的最大并发。
任何进入生产的作业都应该指定最大并发数。但是,一定要仔细考虑后再决定该值的大小。因为一旦设置了最大并发度(无论是手动设置,还是默认设置),之后就无法再对该值做更新。想要改变一个作业的最大并发度,就只能将作业从全新的状态重新开始执行。目前还无法在更改最大并发度后,从上一个 checkpoint 或 savepoint 恢复。
最大并发度的取值建议设定一个足够高的值以满足应用未来的可扩展性和可用性,同时,又要选一个相对较低的值以避免影响应用程序整体的性能。这是由于一个很高的最大并发度会导致 Flink 需要维护大量的元数据(用于扩缩容),这可能会增加 Flink 应用程序的整体状态大小。
对于有状态的 Flink 应用,推荐给每个算子都指定唯一用户ID(UUID)。 严格地说,仅需要给有状态的算子设置就足够了。但是因为 Flink 的某些内置算子(如 window)是有状态的,而有些是无状态的,可能用户不是很清楚哪些内置算子是有状态的,哪些不是。所以从实践经验上来说,我们建议每个算子都指定上 UUID。
Flink 算子的 UUID 可以通过 uid(String uid)
方法指定。算子 UUID 使得 Flink 有效地将算子的状态从 savepoint 映射到作业修改后(拓扑图可能也有改变)的正确的算子上,这是 savepoint 在 Flink 应用中正常工作的一个基本要素。
当前 Flink 还不支持状态后端之间的互换功能,也就是当我们用内存状态后端做了一个 savepoint,我们无法把作业改成 RocksDB 状态后端然后恢复。所以,开发人员和工程负责人在将作业投向生产之前要仔细考虑好该 Flink 应用的最合适的状态后端类型。
关于 Flink 当前支持的三种不同的状态后端类型,可以阅读我们的上一篇文章:《Flink 小贴士 (4): 如何选择状态后端》
对于生产用例来说,强烈建议使用 RocksDB 状态后端,因为这是目前唯一一种支持大型状态和异步操作(如快照过程)的状态后端,异步操作能使 Flink 不阻塞正常数据流的处理的情况下做快照操作。另一方面,使用 RocksDB 状态后端可能存在性能折衷,因为所有状态访问和检索都需要序列化(和反序列化)来跨越 JNI 边界,这与内存状态后端相比可能会影响应用程序的吞吐量。
高可用性(HA)配置确保了 Flink 应用中 JobManager 组件发生潜在故障后的自动恢复,从而将停机时间降到最低。JobManager 的主要职责是协调 Flink 的部署,例如调度和适当的资源分配。
默认情况下,Flink 为每个集群设置一个 JobManager 实例。这会导致单点故障(SPOF):如果 JobManager 崩溃了,则无法提交新的作业,而且正在运行的程序也会失败。因此,强烈建议为生产用例配置高可用性(HA)。
上述 4 个步骤总结自社区的最佳实践,使得 Flink 应用能够保持状态的同时任意地扩缩容,处理更大规模的数据和状态,并提高系统的可用性。我们强烈建议您在将应用投入生产之前,仔细阅读上述步骤。
]]>原文:https://data-artisans.com/blog/broadcast-state-pattern-flink-considerations
作者:Markos Sfikas
译者:云邪(Jark)
在 Apache Flink 1.5.0 中引入了广播状态(Broadcast State)。本文将描述什么是广播状态模式(Broadcast State Pattern),广播状态与其他的 Operator State 有什么区别,最后,我们在 Flink 中使用该功能时需要考虑的一些重要的注意事项。
广播状态模式指的一种流应用程序,其中低吞吐量的事件流(例如,包含一组规则)被广播到某个 operator 的所有并发实例中,然后针对来自另一条原始数据流中的数据(例如金融或信用卡交易)进行计算。 广播状态模式的一些典型应用案例如下:
为了实现这样的应用,关键组件是广播状态,我们将在下文详细描述。
广播状态是 Apache Flink 中支持的第三种类型的 operator state。广播状态使得 Flink 用户能够以容错、一致、可扩缩容地将来自广播的低吞吐的事件流数据存储下来。来自另一条数据流的事件可以流经同一 operator 的各个并发实例,并与广播状态中的数据一起处理。有关其他类型的状态,以及如何使用请访问 Flink 官方文档。
广播状态与其他 operator state 之间有三个主要区别。与其余的 operator state 相反,广播状态:
可以查阅我们之前的博客文章,探索 Apache Flink 中使用广播状态的实践指南。
对于急切开始使用广播状态的 Flink 用户,Apache Flink 官方文档提供了有关 API 的详细指南,以及在应用程序中如何使用该功能。在使用广播状态时要记住以下4个重要事项:
使用广播状态,operator task 之间不会相互通信
这也是为什么(Keyed)-BroadcastProcessFunction
上只有广播的一边可以修改广播状态的内容。用户必须保证所有 operator 并发实例上对广播状态的修改行为都是一致的。或者说,如果不同的并发实例拥有不同的广播状态内容,将导致不一致的结果。
广播状态中事件的顺序在各个并发实例中可能不尽相同
虽然广播流的元素保证了将所有元素(最终)都发给下游所有的并发实例,但是元素的到达的顺序可能在并发实例之间并不相同。因此,对广播状态的修改不能依赖于输入数据的顺序。
所有 operator task 都会快照下他们的广播状态
在 checkpoint 时,所有的 task 都会 checkpoint 下他们的广播状态,并不仅仅是其中一个,即使所有 task 在广播状态中存储的元素是一模一样的。这是一个设计倾向,为了避免在恢复期间从单个文件读取而造成热点。然而,随着并发度的增加,checkpoint 的大小也会随之增加,这里会存在一个并发因子 p 的权衡。Flink 保证了在恢复/扩缩容时不会出现重复数据和少数据。在以相同或更小并行度恢复时,每个 task 会读取其对应的检查点状态。在已更大并行度恢复时,每个 task 读取自己的状态,剩余的 task (p_new-p_old)会以循环方式(round-robin)读取检查点的状态。
RocksDB 状态后端目前还不支持广播状态
广播状态目前在运行时保存在内存中。因为当前,RocksDB 状态后端还不适用于 operator state。Flink 用户应该相应地为其应用程序配置足够的内存。
原文:https://data-artisans.com/blog/differences-between-savepoints-and-checkpoints-in-flink
作者:Stefan Richter, Dawid Wysakowicz, Markos Sfikas
译者:云邪(Jark)
在本文中,我们将阐述 Savepoint 和 Checkpoint 是什么,它们主要用在什么时候,以及对比它们的主要区别。
Savepoint 是用来为整个流应用程序在某个“时间点”(point-in-time)的生成快照的功能。该快照包含了输入源的位置信息,数据源读取到的偏移量(offset),以及整个应用的状态。借助 Chandy-Lamport 算法的变体,我们可以无需停止应用程序而得到一致的快照。Savepoint 包含了两个主要元素:
上述有关 Savepoint 的介绍听起来和之前文章中介绍的 Checkpoint 很像。Checkpoint 是 Flink 用来从故障中恢复的机制,快照下了整个应用程序的状态,当然也包括输入源读取到的位点。如果发生故障,Flink 将通过从 Checkpoint 加载应用程序状态并从恢复的读取位点继续应用程序的处理,就像什么事情都没发生一样。
可以阅读之前 Flink 小贴士的一篇关于 Flink 如何管理 Kafka 消费位点的文章。
Savepoint 和 Checkpoint 是 Apache Flink 作为流处理框架非常独特的两个特性。Savepoint 和 Checkpoint 在实现中看起来也很相似,但是,这两个功能主要有以下3个不同点:
目标:从概念上讲,Flink 的 Savepoint 和 Checkpoint 的不同之处很像传统数据库中备份与恢复日志之间的区别。Checkpoint 的主要目标是充当 Flink 中的恢复机制,确保能从潜在的故障中恢复。相反,Savepoint 的主要目标是充当手动备份、恢复暂停作业的方法。
实现:Checkpoint 和 Savepoint 在实现上也有不同。Checkpoint 被设计成轻量和快速的机制。它们可能(但不一定必须)利用底层状态后端的不同功能尽可能快速地恢复数据。例如,基于 RocksDB 状态后端的增量检查点,能够加速 RocksDB 的 checkpoint 过程,这使得 checkpoint 机制变得更加轻量。相反,Savepoint 旨在更多地关注数据的可移植性,并支持对作业做任何更改而状态能保持兼容,这使得生成和恢复的成本更高。
生命周期:Checkpoint 是自动和定期的,它们由 Flink 自动地周期性地创建和删除,无需用户的交互。相反,Savepoint 是由用户手动地管理(调度、创建、删除)的。
虽然流式应用程序处理的数据是持续地生成的(“运动中”的数据),但是存在着想要重新处理之前已经处理过的数据的情况。Savepoint 可以在以下情况下使用:
Checkpoint 和 Savepoint 是 Flink 中两个不同的功能,它们满足了不同的需求,以确保一致性、容错性,和满足作业升级、BUG 修复、迁移、A/B测试等。这两个功能相结合,可以确保应用程序的状态在不同的场景和环境中保持不变。
]]>原文:https://data-artisans.com/blog/stateful-stream-processing-apache-flink-state-backends
作者:Seth Wiesman, Markos Sfikas
译者:云邪(Jark)
本文我们将深入探讨有状态的流处理,更确切地说是 Apache Flink 中不同的状态后端(state backend)。在以下部分,我们将介绍 Apache Flink 的 3 种状态后端,它们的局限性以及根据具体案例需求选择最合适的状态后端。
在有状态的流处理中,当开发人员启用了 Flink 中的 checkpoint 机制,那么状态将会持久化以防止数据的丢失并确保发生故障时能够完全恢复。选择何种状态后端,将决定状态持久化的方式和位置。
Flink 提供了三种可用的状态后端:MemoryStateBackend
,FsStateBackend
,和RocksDBStateBackend
。
MemoryStateBackend
是将状态维护在 Java 堆上的一个内部状态后端。键值状态和窗口算子使用哈希表来存储数据(values)和定时器(timers)。当应用程序 checkpoint 时,此后端会在将状态发给 JobManager 之前快照下状态,JobManager 也将状态存储在 Java 堆上。默认情况下,MemoryStateBackend 配置成支持异步快照。异步快照可以避免阻塞数据流的处理,从而避免反压的发生。
使用 MemoryStateBackend 时的注意点:
akka.framesize
调整 akka 帧大小(通过配置文档了解更多)。何时使用 MemoryStateBackend:
FsStateBackend 需要配置的主要是文件系统,如 URL(类型,地址,路径)。举个例子,比如可以是:
当选择使用 FsStateBackend 时,正在进行的数据会被存在 TaskManager 的内存中。在 checkpoint 时,此后端会将状态快照写入配置的文件系统和目录的文件中,同时会在 JobManager 的内存中(在高可用场景下会存在 Zookeeper 中)存储极少的元数据。
默认情况下,FsStateBackend 配置成提供异步快照,以避免在状态 checkpoint 时阻塞数据流的处理。该特性可以实例化 FsStateBackend 时传入 false 的布尔标志来禁用掉,例如:
new FsStateBackend(path, false); |
使用 FsStateBackend 时的注意点:
何时使用 FsStateBackend:
RocksDBStateBackend 的配置也需要一个文件系统(类型,地址,路径),如下所示:
RocksDB 是一种嵌入式的本地数据库。RocksDBStateBackend 将处理中的数据使用 RocksDB 存储在本地磁盘上。在 checkpoint 时,整个 RocksDB 数据库会被存储到配置的文件系统中,或者在超大状态作业时可以将增量的数据存储到配置的文件系统中。同时 Flink 会将极少的元数据存储在 JobManager 的内存中,或者在 Zookeeper 中(对于高可用的情况)。RocksDB 默认也是配置成异步快照的模式。
使用 RocksDBStateBackend 时的注意点:
何时使用 RocksDBStateBackend:
当使用 RocksDB 时,状态大小只受限于磁盘可用空间的大小。这也使得 RocksDBStateBackend 成为管理超大状态的最佳选择。使用 RocksDB 的权衡点在于所有的状态相关的操作都需要序列化(或反序列化)才能跨越 JNI 边界。与上面提到的堆上后端相比,这可能会影响应用程序的吞吐量。
不同状态后端满足不同场景的需求,在开始开发应用程序之前应该仔细考虑和规划后选择。这可确保选择了正确的状态后端以最好地满足应用程序和业务需求。
]]>原文:https://data-artisans.com/blog/watermarks-in-apache-flink-made-easy
作者:David Anderson
译者:云邪(Jark)
当人们第一次使用 Flink 时,经常会对 watermark 感到困惑。但其实 watermark 并不复杂。让我们通过一个简单的例子来说明为什么我们需要 watermark,以及它的工作机制是什么样的。
在下文中的例子中,我们有一个带有时间戳的事件流,但是由于某种原因它们并不是按顺序到达的。图中的数字代表事件发生的时间戳。第一个到达的事件发生在时间 4,然后它后面跟着的是发生在更早时间(时间 2)的事件,以此类推:
注意这是一个按照事件时间处理的例子,这意味着时间戳反映的是事件发生的时间,而不是处理事件的时间。事件时间(Event-Time)处理的强大之处在于,无论是在处理实时的数据还是重新处理历史的数据,基于事件时间创建的流计算应用都能保证结果是一样的。
注:可以访问 Apache Flink 文档,了解更多有关时间的概念,如 event-time, processing-time, ingestion-time。
现在假设我们正在尝试创建一个流计算排序算子。也就是处理一个乱序到达的事件流,并按照事件时间的顺序输出事件。
数据流中的第一个元素的时间是 4,但是我们不能直接将它作为排序后数据流的第一个元素并输出它。因为数据是乱序到达的,也许有一个更早发生的数据还没有到达。事实上,我们能预见一些这个流的未来,也就是我们的排序算子至少要等到 2 这条数据的到达再输出结果。
有缓存,就必然有延迟。
如果我们做错了,我们可能会永远等待下去。首先,我们的应用程序从看到时间 4 的数据,然后看到时间 2 的数据。是否会有一个比时间 2 更早的数据到达呢?也许会,也许不会。我们可以一直等下去,但可能永远看不到 1 。
最终,我们必须勇敢地输出 2 作为排序流的第一个结果。
我们需要的是某种策略,它定义了对于任何带时间戳的事件流,何时停止等待更早数据的到来。
这正是 watermark 的作用,他们定义了何时不再等待更早的数据。
Flink 中的事件时间处理依赖于一种特殊的带时间戳的元素,成为 watermark,它们会由数据源或是 watermark 生成器插入数据流中。具有时间戳 t
的 watermark 可以被理解为断言了所有时间戳小于或等于 t
的事件都(在某种合理的概率上)已经到达了。
译注:此处原文是“小于”,译者认为应该是 “小于或等于”,因为 Flink 源码中采用的是 “小于或等于” 的机制。
何时我们的排序算子应该停止等待,然后将事件 2 作为首个元素输出?答案是当收到时间戳为 2(或更大)的 watermark 时。
我们可以设想不同的策略来生成 watermark。
我们知道每个事件都会延迟一段时间才到达,而这些延迟差异会比较大,所以有些事件会比其他事件延迟更多。一种简单的方法是假设这些延迟不会超过某个最大值。Flink 把这种策略称作 “有界无序生成策略”(bounded-out-of-orderness)。当然也有很多更复杂的方式去生成 watermark,但是对于大多数应用来说,固定延迟的方式已经足够了。
如果想要构建一个类似排序的流应用,可以使用 Flink 的 ProcessFunction
。它提供了对事件时间计时器(基于 watermark 触发回调)的访问,还提供了可以用来缓存数据的托管状态接口。
如果想要了解更多有关 Apache Flink 的 ProcessFunction
的实践案例,可以访问我的上一篇文章《Flink 零基础实战教程:如何计算实时热门商品》 了解如何使用 ProcessFunction
实现 TopN 功能。
作者:云邪(Jark)
原文链接:http://wuchong.me/blog/2018/11/09/flink-tech-evolution-introduction/
Apache Flink 是近年来越来越流行的一款开源大数据计算引擎,它同时支持了批处理和流处理,也能用来做一些基于事件的应用。使用官网的一句话来介绍 Flink 就是 “Stateful Computations Over Streams”。
首先 Flink 是一个纯流式的计算引擎,它的基本数据模型是数据流。流可以是无边界的无限流,即一般意义上的流处理。也可以是有边界的有限流,这样就是批处理。因此 Flink 用一套架构同时支持了流处理和批处理。其次,Flink 的一个优势是支持有状态的计算。如果处理一个事件(或一条数据)的结果只跟事件本身的内容有关,称为无状态处理;反之结果还和之前处理过的事件有关,称为有状态处理。稍微复杂一点的数据处理,比如说基本的聚合,数据流之间的关联都是有状态处理。
Apache Flink 之所以能越来越受欢迎,我们认为离不开它最重要的四个基石:Checkpoint、State、Time、Window。
首先是Checkpoint机制,这是 Flink 最重要的一个特性。Flink 基于 Chandy-Lamport 算法实现了分布式一致性的快照,从而提供了 exactly-once 的语义。在 Flink 之前的流计算系统(如 Strom,Samza)都没有很好地解决 exactly-once 的问题。提供了一致性的语义之后,Flink 为了让用户在编程时能够更轻松、更容易地去管理状态,引入了托管状态(managed state)并提供了 API 接口,让用户使用起来感觉就像在用 Java 的集合类一样。除此之外,Flink 还实现了 watermark 的机制,解决了基于事件时间处理时的数据乱序和数据迟到的问题。最后,流计算中的计算一般都会基于窗口来计算,所以 Flink 提供了一套开箱即用的窗口操作,包括滚动窗口、滑动窗口、会话窗口,还支持非常灵活的自定义窗口以满足特殊业务的需求。
在 Flink 1.0.0 时期,加入了 State API,即 ValueState、ReducingState、ListState 等等。State API 可以认为是 Flink 里程碑式的创新,它能够让用户像使用 Java 集合一样地使用 Flink State,却能够自动享受到状态的一致性保证,不会因为故障而丢失状态。包括后来 Apache Beam 的 State API 也从中借鉴了很多。
在 Flink 1.1.0 时期,支持了 Session Window 以及迟到数据容忍的功能。
在 Flink 1.2.0 时期,提供了 ProcessFunction,这是一个 Lower-level 的API,用于实现更高级更复杂的功能。它除了能够注册各种类型的 State 外,还支持注册定时器(支持 EventTime 和 ProcessingTime),常用于开发一些基于事件、基于时间的应用程序。
在 Flink 1.3.0 时期,提供了 Side Output 功能。算子的输出一般只有一种输出类型,但是有些时候可能需要输出另外的类型,比如除了输出主流外,还希望把一些异常数据、迟到数据以侧边流的形式进行输出,并分别交给下游不同节点进行处理。简而言之,Side Output 支持了多路输出的功能。
在 Flink 1.5.0 时期,加入了BroadcastState。BroadcastState是对 State API 的一个扩展。它用来存储上游被广播过来的数据,这个 operator 的每个并发上存的BroadcastState里面的数据都是一模一样的,因为它是从上游广播而来的。基于这种State可以比较好地去解决 CEP 中的动态规则的功能,以及 SQL 中不等值Join的场景。
在 Flink 1.6.0 时期,提供了State TTL功能、DataStream Interval Join功能。State TTL实现了在申请某个State时候可以在指定一个生命周期参数(TTL),指定该state过了多久之后需要被系统自动清除。在这个版本之前,如果用户想要实现这种状态清理操作需要使用ProcessFunction注册一个Timer,然后利用Timer的回调手动把这个State清除。从该版本开始,Flink框架可以基于TTL原生地解决这件事情。另外 DataStream Interval Join 功能也叫做 区间Join。例如左流的每一条数据去Join右流前后5分钟之内的数据,这种就是5分钟的区间Join。
在 Flink 1.0.0 时期,Table API (结构化数据处理API)和 CEP(复杂事件处理API)这两个框架被首次加入到仓库中。Table API 是一种结构化的高级 API,支持 Java 语言和 Scala 语言,类似于 Spark 的 DataFrame API。但是当时社区对于 SQL 的需求很大,而 SQL 和 Table API 非常相近,他们都是一种处理结构化数据的语言,实现上可以共用很多内容。所以在 Flink 1.1.0 里面,社区基于Apache Calcite对整个 Table 模块做了重构,使得同时支持了 Table API 和 SQL 并共用了大部分代码。
在 Flink 1.2.0 时期,社区在Table API和SQL上支持丰富的内置窗口操作,包括Tumbling Window、Sliding Window、Session Window。
在 Flink 1.3.0 时期,社区首次提出了Dynamic Table这个概念,借助Dynamic Table,流和批之间可以相互进行转换。流可以是一张表,表也可以是一张流,这是流批统一的基础之一。其中Retraction机制是实现Dynamic Table的基础,基于Retraction才能够正确地实现多级Aggregate、多级Join,才能够保证流式 SQL 的语义与结果的正确性。另外,在该版本中还支持了 CEP 算子的可伸缩容(即改变并发)。
在 Flink 1.5.0 时期,在 Table API 和 SQL 上支持了Join操作,包括无限流的 Join 和带窗口的 Join。还添加了 SQL CLI 支持。SQL CLI 提供了一个类似Shell命令的对话框,可以交互式执行查询。
Checkpoint机制在Flink很早期的时候就已经支持,是Flink一个很核心的功能,Flink 社区也一直努力提升 Checkpoint 和 Recovery 的效率。
在 Flink 1.0.0 时期,提供了 RocksDB 状态后端的支持,在这个版本之前所有的状态数据只能存在进程的内存里面,JVM 内存是固定大小的,随着数据越来越多总会发生 FullGC 和 OOM 的问题,所以在生产环境中很难应用起来。如果想要存更多数据、更大的State就要用到 RocksDB。RocksDB是一款基于文件的嵌入式数据库,它会把数据存到磁盘,同时又提供高效的读写性能。所以使用RocksDB不会发生OOM这种事情。
在 Flink 1.1.0 时期,支持了 RocksDB Snapshot 的异步化。在之前的版本,RocksDB 的 Snapshot 过程是同步的,它会阻塞主数据流的处理,很影响吞吐量。在支持异步化之后,吞吐量得到了极大的提升。
在 Flink 1.2.0 时期,通过引入KeyGroup的机制,支持了 KeyedState 和 OperatorState 的可扩缩容。也就是支持了对带状态的流计算任务改变并发的功能。
在 Flink 1.3.0 时期,支持了 Incremental Checkpoint (增量检查点)机制。Incemental Checkpoint 的支持标志着 Flink 流计算任务正式达到了生产就绪状态。增量检查点是每次只将本次 checkpoint 期间新增的状态快照并持久化存储起来。一般流计算任务,GB 级别的状态,甚至 TB 级别的状态是非常常见的,如果每次都把全量的状态都刷到分布式存储中,这个效率和网络代价是很大的。如果每次只刷新增的数据,效率就会高很多。在这个版本里面还引入了细粒度的recovery的功能,细粒度的recovery在做恢复的时候,只需要恢复失败节点的联通子图,不用对整个 Job 进行恢复,这样便能够提高恢复效率。
在 Flink 1.5.0 时期,引入了本地状态恢复的机制。因为基于checkpoint机制,会把State持久化地存储到某个分布式存储,比如HDFS,当发生 failover 的时候需要重新把数据从远程HDFS再下载下来,如果这个状态特别大那么下载耗时就会较长,failover 恢复所花的时间也会拉长。本地状态恢复机制会提前将状态文件在本地也备份一份,当Job发生failover之后,恢复时可以在本地直接恢复,不需从远程HDFS重新下载状态文件,从而提升了恢复的效率。
在 Flink 1.2.0 时期,提供了Async I/O功能。Async I/O 是阿里巴巴贡献给社区的一个呼声非常高的特性,主要目的是为了解决与外部系统交互时网络延迟成为了系统瓶颈的问题。例如,为了关联某些字段需要查询外部 HBase 表,同步的方式是每次查询的操作都是阻塞的,数据流会被频繁的I/O请求卡住。当使用异步I/O之后就可以同时地发起N个异步查询的请求,不会阻塞主数据流,这样便提升了整个job的吞吐量,提升CPU利用率。
在 Flink 1.3.0 时期,引入了HistoryServer的模块。HistoryServer主要功能是当job结束以后,会把job的状态以及信息都进行归档,方便后续开发人员做一些深入排查。
在 Flink 1.4.0 时期,提供了端到端的 exactly-once 的语义保证。Exactly-once 是指每条输入的数据只会作用在最终结果上有且只有一次,即使发生软件或硬件的故障,不会有丢数据或者重复计算发生。而在该版本之前,exactly-once 保证的范围只是 Flink 应用本身,并不包括输出给外部系统的部分。在 failover 时,这就有可能写了重复的数据到外部系统,所以一般会使用幂等的外部系统来解决这个问题。在 Flink 1.4 的版本中,Flink 基于两阶段提交协议,实现了端到端的 exactly-once 语义保证。内置支持了 Kafka 的端到端保证,并提供了 TwoPhaseCommitSinkFunction
供用于实现自定义外部存储的端到端 exactly-once 保证。
在 Flink 1.5.0 时期,Flink 发布了新的部署模型和处理模型(FLIP6)。新部署模型的开发工作已经持续了很久,该模型的实现对Flink核心代码改动特别大,可以说是自 Flink 项目创建以来,Runtime 改动最大的一次。简而言之,新的模型可以在YARN, MESOS调度系统上更好地动态分配资源、动态释放资源,并实现更高的资源利用率,还有提供更好的作业之间的隔离。
除了 FLIP6 的改进,在该版本中,还对网站栈做了重构。重构的原因是在老版本中,上下游多个 task 之间的通信会共享同一个 TCP connection,导致某一个 task 发生反压时,所有共享该连接的 task 都会被阻塞,反压的粒度是 TCP connection 级别的。为了改进反压机制,Flink应用了在解决网络拥塞时一种经典的流控方法——基于Credit的流量控制。使得流控的粒度精细到具体某个 task 级别,有效缓解了反压对吞吐量的影响。
Flink 同时支持了流处理和批处理,目前流计算的模型已经相对比较成熟和领先,也经历了各个公司大规模生产的验证。社区在接下来将继续加强流计算方面的性能和功能,包括对 Flink SQL 扩展更丰富的功能和引入更多的优化。另一方面也将加大力量提升批处理、机器学习等生态上的能力。
]]>my-flink-project
项目框架。通过本文你将学到:
本案例将实现一个“实时热门商品”的需求,我们可以将“实时热门商品”翻译成程序员更好理解的需求:每隔5分钟输出最近一小时内点击量最多的前 N 个商品。将这个需求进行分解我们大概要做这么几件事情:
这里我们准备了一份淘宝用户行为数据集(来自阿里云天池公开数据集,特别感谢)。本数据集包含了淘宝上某一天随机一百万用户的所有行为(包括点击、购买、加购、收藏)。数据集的组织形式和MovieLens-20M类似,即数据集的每一行表示一条用户行为,由用户ID、商品ID、商品类目ID、行为类型和时间戳组成,并以逗号分隔。关于数据集中每一列的详细描述如下:
列名称 | 说明 |
---|---|
用户ID | 整数类型,加密后的用户ID |
商品ID | 整数类型,加密后的商品ID |
商品类目ID | 整数类型,加密后的商品所属类目ID |
行为类型 | 字符串,枚举类型,包括(‘pv’, ‘buy’, ‘cart’, ‘fav’) |
时间戳 | 行为发生的时间戳,单位秒 |
你可以通过下面的命令下载数据集到项目的 resources
目录下:
$ cd my-flink-project/src/main/resources |
这里是否使用 curl 命令下载数据并不重要,你也可以使用 wget 命令或者直接访问链接下载数据。关键是,将数据文件保存到项目的 resources
目录下,方便应用程序访问。
在 src/main/java/myflink
下创建 HotItems.java
文件:
package myflink; |
与上文一样,我们会一步步往里面填充代码。第一步仍然是创建一个 StreamExecutionEnvironment
,我们把它添加到 main 函数中。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); |
在数据准备章节,我们已经将测试的数据集下载到本地了。由于是一个csv文件,我们将使用 CsvInputFormat
创建模拟数据源。
注:虽然一个流式应用应该是一个一直运行着的程序,需要消费一个无限数据源。但是在本案例教程中,为了省去构建真实数据源的繁琐,我们使用了文件来模拟真实数据源,这并不影响下文要介绍的知识点。这也是一种本地验证 Flink 应用程序正确性的常用方式。
我们先创建一个 UserBehavior
的 POJO 类(所有成员变量声明成public
便是POJO类),强类型化后能方便后续的处理。
/** 用户行为数据结构 **/ |
接下来我们就可以创建一个 PojoCsvInputFormat
了, 这是一个读取 csv 文件并将每一行转成指定 POJO
类型(在我们案例中是 UserBehavior
)的输入器。
// UserBehavior.csv 的本地文件路径 |
下一步我们用 PojoCsvInputFormat
创建输入源。
DataStream<UserBehavior> dataSource = env.createInput(csvInput, pojoType); |
这就创建了一个 UserBehavior
类型的 DataStream
。
当我们说“统计过去一小时内点击量”,这里的“一小时”是指什么呢? 在 Flink 中它可以是指 ProcessingTime ,也可以是 EventTime,由用户决定。
在本案例中,我们需要统计业务时间上的每小时的点击量,所以要基于 EventTime 来处理。那么如果让 Flink 按照我们想要的业务时间来处理呢?这里主要有两件事情要做。
第一件是告诉 Flink 我们现在按照 EventTime 模式进行处理,Flink 默认使用 ProcessingTime 处理,所以我们要显式设置下。
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime); |
第二件事情是指定如何获得业务时间,以及生成 Watermark。Watermark 是用来追踪业务事件的概念,可以理解成 EventTime 世界中的时钟,用来指示当前处理到什么时刻的数据了。由于我们的数据源的数据已经经过整理,没有乱序,即事件的时间戳是单调递增的,所以可以将每条数据的业务时间就当做 Watermark。这里我们用 AscendingTimestampExtractor
来实现时间戳的抽取和 Watermark 的生成。
注:真实业务场景一般都是存在乱序的,所以一般使用
BoundedOutOfOrdernessTimestampExtractor
。
DataStream<UserBehavior> timedData = dataSource |
这样我们就得到了一个带有时间标记的数据流了,后面就能做一些窗口的操作。
在开始窗口操作之前,先回顾下需求“每隔5分钟输出过去一小时内点击量最多的前 N 个商品”。由于原始数据中存在点击、加购、购买、收藏各种行为的数据,但是我们只需要统计点击量,所以先使用 FilterFunction
将点击行为数据过滤出来。
DataStream<UserBehavior> pvData = timedData |
由于要每隔5分钟统计一次最近一小时每个商品的点击量,所以窗口大小是一小时,每隔5分钟滑动一次。即分别要统计 [09:00, 10:00), [09:05, 10:05), [09:10, 10:10)… 等窗口的商品点击量。是一个常见的滑动窗口需求(Sliding Window)。
DataStream<ItemViewCount> windowedData = pvData |
我们使用.keyBy("itemId")
对商品进行分组,使用.timeWindow(Time size, Time slide)
对每个商品做滑动窗口(1小时窗口,5分钟滑动一次)。然后我们使用 .aggregate(AggregateFunction af, WindowFunction wf)
做增量的聚合操作,它能使用AggregateFunction
提前聚合掉数据,减少 state 的存储压力。较之.apply(WindowFunction wf)
会将窗口中的数据都存储下来,最后一起计算要高效地多。aggregate()
方法的第一个参数用于
这里的CountAgg
实现了AggregateFunction
接口,功能是统计窗口中的条数,即遇到一条数据就加一。
/** COUNT 统计的聚合函数实现,每出现一条记录加一 */ |
.aggregate(AggregateFunction af, WindowFunction wf)
的第二个参数WindowFunction
将每个 key每个窗口聚合后的结果带上其他信息进行输出。我们这里实现的WindowResultFunction
将主键商品ID,窗口,点击量封装成了ItemViewCount
进行输出。
/** 用于输出窗口的结果 */ |
现在我们得到了每个商品在每个窗口的点击量的数据流。
为了统计每个窗口下最热门的商品,我们需要再次按窗口进行分组,这里根据ItemViewCount
中的windowEnd
进行keyBy()
操作。然后使用 ProcessFunction
实现一个自定义的 TopN 函数 TopNHotItems
来计算点击量排名前3名的商品,并将排名结果格式化成字符串,便于后续输出。
DataStream<String> topItems = windowedData |
ProcessFunction
是 Flink 提供的一个 low-level API,用于实现更高级的功能。它主要提供了定时器 timer 的功能(支持EventTime或ProcessingTime)。本案例中我们将利用 timer 来判断何时收齐了某个 window 下所有商品的点击量数据。由于 Watermark 的进度是全局的,
在 processElement
方法中,每当收到一条数据(ItemViewCount
),我们就注册一个 windowEnd+1
的定时器(Flink 框架会自动忽略同一时间的重复注册)。windowEnd+1
的定时器被触发时,意味着收到了windowEnd+1
的 Watermark,即收齐了该windowEnd
下的所有商品窗口统计值。我们在 onTimer()
中处理将收集的所有商品及点击量进行排序,选出 TopN,并将排名信息格式化成字符串后进行输出。
这里我们还使用了 ListState<ItemViewCount>
来存储收到的每条 ItemViewCount
消息,保证在发生故障时,状态数据的不丢失和一致性。ListState
是 Flink 提供的类似 Java List
接口的 State API,它集成了框架的 checkpoint 机制,自动做到了 exactly-once 的语义保证。
/** 求某个窗口中前 N 名的热门点击商品,key 为窗口时间戳,输出为 TopN 的结果字符串 */ |
最后一步我们将结果打印输出到控制台,并调用env.execute
执行任务。
topItems.print(); |
直接运行 main 函数,就能看到不断输出的每个时间点的热门商品ID。
本文的完整代码可以通过 GitHub 访问到。本文通过实现一个“实时热门商品”的案例,学习和实践了 Flink 的多个核心概念和 API 用法。包括 EventTime、Watermark 的使用,State 的使用,Window API 的使用,以及 TopN 的实现。希望本文能加深大家对 Flink 的理解,帮助大家解决实战上遇到的问题。
]]>Flink 可以运行在 Linux, Max OS X, 或者是 Windows 上。为了开发 Flink 应用程序,在本地机器上需要有 Java 8.x 和 maven 环境。
如果有 Java 8 环境,运行下面的命令会输出如下版本信息:
$ java -version |
如果有 maven 环境,运行下面的命令会输出如下版本信息:
$ mvn -version |
另外我们推荐使用 ItelliJ IDEA (社区免费版已够用)作为 Flink 应用程序的开发 IDE。Eclipse 虽然也可以,但是 Eclipse 在 Scala 和 Java 混合型项目下会有些已知问题,所以不太推荐 Eclipse。下一章节,我们会介绍如何创建一个 Flink 工程并将其导入 ItelliJ IDEA。
我们将使用 Flink Maven Archetype 来创建我们的项目结构和一些初始的默认依赖。在你的工作目录下,运行如下命令来创建项目:
mvn archetype:generate \ |
你可以编辑上面的 groupId, artifactId, package 成你喜欢的路径。使用上面的参数,Maven 将自动为你创建如下所示的项目结构:
$ tree my-flink-project |
我们的 pom.xml 文件已经包含了所需的 Flink 依赖,并且在 src/main/java 下有几个示例程序框架。接下来我们将开始编写第一个 Flink 程序。
启动 IntelliJ IDEA,选择 “Import Project”(导入项目),选择 my-flink-project 根目录下的 pom.xml。根据引导,完成项目导入。
在 src/main/java/myflink 下创建 SocketWindowWordCount.java
文件:
package myflink; |
现在这程序还很基础,我们会一步步往里面填代码。注意下文中我们不会将 import 语句也写出来,因为 IDE 会自动将他们添加上去。在本节末尾,我会将完整的代码展示出来,如果你想跳过下面的步骤,可以直接将最后的完整代码粘到编辑器中。
Flink 程序的第一步是创建一个 StreamExecutionEnvironment
。这是一个入口类,可以用来设置参数和创建数据源以及提交任务。所以让我们把它添加到 main 函数中:
StreamExecutionEnvironment see = StreamExecutionEnvironment.getExecutionEnvironment(); |
下一步我们将创建一个从本地端口号 9000 的 socket 中读取数据的数据源:
DataStream<String> text = env.socketTextStream("localhost", 9000, "\n"); |
这创建了一个字符串类型的 DataStream
。DataStream
是 Flink 中做流处理的核心 API,上面定义了非常多常见的操作(如,过滤、转换、聚合、窗口、关联等)。在本示例中,我们感兴趣的是每个单词在特定时间窗口中出现的次数,比如说5秒窗口。为此,我们首先要将字符串数据解析成单词和次数(使用Tuple2<String, Integer>
表示),第一个字段是单词,第二个字段是次数,次数初始值都设置成了1。我们实现了一个 flatmap
来做解析的工作,因为一行数据中可能有多个单词。
DataStream<Tuple2<String, Integer>> wordCounts = text |
接着我们将数据流按照单词字段(即0号索引字段)做分组,这里可以简单地使用 keyBy(int index)
方法,得到一个以单词为 key 的Tuple2<String, Integer>
数据流。然后我们可以在流上指定想要的窗口,并根据窗口中的数据计算结果。在我们的例子中,我们想要每5秒聚合一次单词数,每个窗口都是从零开始统计的:。
DataStream<Tuple2<String, Integer>> windowCounts = wordCounts |
第二个调用的 .timeWindow()
指定我们想要5秒的翻滚窗口(Tumble)。第三个调用为每个key每个窗口指定了sum
聚合函数,在我们的例子中是按照次数字段(即1号索引字段)相加。得到的结果数据流,将每5秒输出一次这5秒内每个单词出现的次数。
最后一件事就是将数据流打印到控制台,并开始执行:
windowCounts.print().setParallelism(1); |
最后的 env.execute
调用是启动实际Flink作业所必需的。所有算子操作(例如创建源、聚合、打印)只是构建了内部算子操作的图形。只有在execute()
被调用时才会在提交到集群上或本地计算机上执行。
下面是完整的代码,部分代码经过简化(代码在 GitHub 上也能访问到):
package myflink; |
要运行示例程序,首先我们在终端启动 netcat 获得输入流:
nc -lk 9000 |
如果是 Windows 平台,可以通过 https://nmap.org/ncat/ 安装 ncat 然后运行:
ncat -lk 9000 |
然后直接运行SocketWindowWordCount
的 main 方法。
只需要在 netcat 控制台输入单词,就能在 SocketWindowWordCount
的输出控制台看到每个单词的词频统计。如果想看到大于1的计数,请在5秒内反复键入相同的单词。
Cheers! 🎉
]]>原文:https://data-artisans.com/blog/how-apache-flink-manages-kafka-consumer-offsets
作者:Fabian Hueske, Markos Sfikas
译者:云邪(Jark)
在本周的《Flink Friday Tip》中,我们将结合例子逐步讲解 Apache Flink 是如何与 Apache Kafka 协同工作并确保来自 Kafka topic 的消息以 exactly-once 的语义被处理。
检查点(Checkpoint)是使 Apache Flink 能从故障恢复的一种内部机制。检查点是 Flink 应用状态的一个一致性副本,包括了输入的读取位点。在发生故障时,Flink 通过从检查点加载应用程序状态来恢复,并从恢复的读取位点继续处理,就好像什么事情都没发生一样。你可以把检查点想象成电脑游戏的存档一样。如果你在游戏中发生了什么事情,你可以随时读档重来一次。
检查点使得 Apache Flink 具有容错能力,并确保了即时发生故障也能保证流应用程序的语义。检查点是以固定的间隔来触发的,该间隔可以在应用中配置。
Apache Flink 中实现的 Kafka 消费者是一个有状态的算子(operator),它集成了 Flink 的检查点机制,它的状态是所有 Kafka 分区的读取偏移量。当一个检查点被触发时,每一个分区的偏移量都被存到了这个检查点中。Flink 的检查点机制保证了所有 operator task 的存储状态都是一致的。这里的“一致的”是什么意思呢?意思是它们存储的状态都是基于相同的输入数据。当所有的 operator task 成功存储了它们的状态,一个检查点才算完成。因此,当从潜在的系统故障中恢复时,系统提供了 excatly-once 的状态更新语义。
下面我们将一步步地介绍 Apache Flink 中的 Kafka 消费位点是如何做检查点的。在本文的例子中,数据被存在了 Flink 的 JobMaster 中。值得注意的是,在 POC 或生产用例下,这些数据最好是能存到一个外部文件系统(如HDFS或S3)中。
如下所示,一个 Kafka topic,有两个partition,每个partition都含有 “A”, “B”, “C”, ”D”, “E” 5条消息。我们将两个partition的偏移量(offset)都设置为0.
Kafka comsumer(消费者)开始从 partition 0 读取消息。消息“A”正在被处理,第一个 consumer 的 offset 变成了1。
消息“A”到达了 Flink Map Task。两个 consumer 都开始读取他们下一条消息(partition 0 读取“B”,partition 1 读取“A”)。各自将 offset 更新成 2 和 1 。同时,Flink 的 JobMaster 开始在 source 触发了一个检查点。
接下来,由于 source 触发了检查点,Kafka consumer 创建了它们状态的第一个快照(”offset = 2, 1”),并将快照存到了 Flink 的 JobMaster 中。Source 在消息“B”和“A”从partition 0 和 1 发出后,发了一个 checkpoint barrier。Checkopint barrier 用于各个 operator task 之间对齐检查点,保证了整个检查点的一致性。消息“A”到达了 Flink Map Task,而上面的 consumer 继续读取下一条消息(消息“C”)。
Flink Map Task 收齐了同一版本的全部 checkpoint barrier 后,那么就会将它自己的状态也存储到 JobMaster。同时,consumer 会继续从 Kafka 读取消息。
Flink Map Task 完成了它自己状态的快照流程后,会向 Flink JobMaster 汇报它已经完成了这个 checkpoint。当所有的 task 都报告完成了它们的状态 checkpoint 后,JobMaster 就会将这个 checkpoint 标记为成功。从此刻开始,这个 checkpoint 就可以用于故障恢复了。值得一提的是,Flink 并不依赖 Kafka offset 从系统故障中恢复。
在发生故障时(比如,某个 worker 挂了),所有的 operator task 会被重启,而他们的状态会被重置到最近一次成功的 checkpoint。Kafka source 分别从 offset 2 和 1 重新开始读取消息(因为这是完成的 checkpoint 中存的 offset)。当作业重启后,我们可以期待正常的系统操作,就好像之前没有发生故障一样。如下图所示:
如果想了解更多有关如何最佳地使用 Apache Flink 与 Apache Kafka,以及一些常见问题,可以访问我们这篇文章 Kafka + Flink: A Practical, How-To Guide。
]]>原文:https://data-artisans.com/blog/6-things-to-consider-when-defining-your-apache-flink-cluster-size
作者:Fabian Hueske
译者:云邪(Jark)
译注:原文标题是“6 things to consider when defining your Apache Flink cluster size”,其实在确定作业所需资源时要考虑的事情是一样的,而作业所需资源的问题是用户更经常遇到的问题,所以这里将标题修改了下。
本文是我们博客新开系列《Flink Friday Tip》的首篇文章。该系列主要涵盖了易理解的最佳实践、如何提高Flink 性能的建议、以及如何最佳地使用 Flink 的各种功能。
译注:这也是我为什么打算开始翻译这个系列的原因,不过我可能做不到每周五同步更新,但是我会尽量做到每周更新,所以翻译过来的系列名叫做《Flink小贴士》。
在 Apache Flink 社区中我们被经常问及的一件事是:如何规划和计算一个 Flink 集群的大小(或者说如何确定一个 Flink 作业所需的资源)。确定集群的大小很显然是决定于多种因素的,例如应用场景,应用的规模,以及特定的服务等级协议(SLA)。另外应用程序中的 checkpoint 类型(增量 vs 全量)和 Flink 作业处理是连续还是突发的也都会影响到 Flink 集群的大小。
以下6个方面是确定 Flink 集群大小时最先要考虑的一些因素:
1. 记录数和每条记录的大小
确定集群大小的首要事情就是估算预期进入流计算系统的每秒记录数(也就是我们常说的吞吐量),以及每条记录的大小。不同的记录类型会有不同的大小,这将最终影响 Flink 应用程序平稳运行所需的资源。
2. 不同 key 的数量和每个 key 存储的 state 大小
应用程序中不同 key 的数量和每个 key 所需要存储的 state 大小,都将影响到 Flink 应用程序所需的资源,从而能高效地运行,避免任何反压。
3. 状态的更新频率和状态后端的访问模式
第三个考虑因素是状态的更新频率,因为状态的更新通常是一个高消耗的动作。而不同的状态后端(如 RocksDB,Java Heap)的访问模式差异很大,RocksDB 的每次读取和更新都会涉及序列化和反序列化以及 JNI 操作,而 Java Heap 的状态后端不支持增量 checkpoint,导致大状态场景需要每次持久化的数据量较大。这些因素都会显著地影响集群的大小和 Flink 作业所需的资源。
4. 网络容量
网络容量不仅仅会收到 Flink 应用程序本身的影响,也会受到可能正在交互的 Kafka、HDFS 等外部服务的影响。这些外部服务可能会导致额外的网络流量。例如,启用 replication 可能会在网络的消息 broker 之间产生额外的流量。
5. 磁盘带宽
如果你的应用程序依赖了基于磁盘的状态后端,如 RocksDB,或者考虑使用 Kafka 或 HDFS,那么磁盘的带宽也需要纳入考虑。
6. 机器数量及其可用 CPU 和内存
最后但并非最不重要的,在开始应用部署前,你需要考虑集群中可用机器的数量及其可用的 CPU 和内存。这最终确保了在将应用程序投入生产之后,集群有充足的处理能力。
更多需要考虑的特定方面是你或者你的组织能接受的SLA(服务等级协议)。例如,考虑你的组织能接受的宕机时间,能接受的延迟和最大吞吐。这些 SLA 都会对 Flink 集群大小产生影响。
在确定 Flink 作业所需资源数目时,上述所有的因素应该能起到良好的指示作用,另外这也算是提供了 Flink 作业如何正常运行的指引。你也需要始终考虑增加一些资源的缓冲用于作业恢复时的追赶和处理一些负载高峰。例如,当你的 Flink 发生故障时,系统会需要额外的资源来做恢复工作以及从 Kafka topic 或其他消息客户端追上最新的数据。
如果你对这个话题感兴趣,可以访问我们早期的博客了解更多细节。下图总结展示了上文讨论的6点考虑项,你可以下载保存以备不时之需。
]]>作者:刘迪珊
整理:上海-星辰(Flink China社区志愿者)
本文整理自8月11日在北京举行的Flink
Meetup,分享嘉宾刘迪珊(2015年加入美团数据平台。致力于打造高效、易用的实时计算平台,探索不同场景下实时应用的企业级解决方案及统⼀化服务)。
上图呈现的是当前美团实时计算平台的简要架构。最底层是数据缓存层,可以看到美团测的所有日志类的数据,都是通过统一的日志收集系统收集到Kafka。Kafka作为最大的数据中转层,支撑了美团线上的大量业务,包括离线拉取,以及部分实时处理业务等。在数据缓存层之上,是一个引擎层,这一层的左侧是我们目前提供的实时计算引擎,包括Storm和Flink。Storm在此之前是 standalone 模式的部署方式,Flink由于其现在运行的环境,美团选择的是On YARN模式,除了计算引擎之外,我们还提供一些实时存储功能,用于存储计算的中间状态、计算的结果、以及维度数据等,目前这一类存储包含Hbase、Redis以及ES。在计算引擎之上,是趋于五花八门的一层,这一层主要面向数据开发的同学。实时数据开发面临诸多问题,例如在程序的调试调优方面就要比普通的程序开发困难很多。在数据平台这一层,美团面向用户提供的实时计算平台,不仅可以托管作业,还可以实现调优诊断以及监控报警,此外还有实时数据的检索以及权限管理等功能。除了提供面向数据开发同学的实时计算平台,美团现在正在做的事情还包括构建元数据中心。这也是未来我们想做SQL的一个前提,元数据中心是承载实时流系统的一个重要环节,我们可以把它理解为实时系统中的大脑,它可以存储数据的Schema,Meta。架构的最顶层就是我们现在实时计算平台支撑的业务,不仅包含线上业务日志的实时查询和检索,还涵盖当下十分热门的实时机器学习。机器学习经常会涉及到搜索和推荐场景,这两个场景最显著特点:一、会产生海量实时数据;二、流量的QPS相当高。此时就需要实时计算平台承载部分实时特征的提取工作,实现应用的搜索推荐服务。还有一类是比较常见的场景,包括实时的特征聚合,斑马Watcher(可以认为是一个监控类的服务),实时数仓等。
以上就是美团目前实时计算平台的简要架构。
美团实时计算平台的现状是作业量现在已经达到了近万,集群的节点的规模是千级别的,天级消息量已经达到了万亿级,高峰期的消息量能够达到千万条每秒。
美团在调研使用Flink之前遇到了一些痛点和问题:
实时计算精确性问题:在调研使用Flink之前美团很大规模的作业是基于Storm去开发的,Storm主要的计算语义是At-Least-Once,这种语义在保证正确性上实际上是有一些问题的,在Trident之前Storm是无状态的处理。虽然Storm
Trident提供了一个维护状态的精确的开发,但是它是基于串行的Batch提交的,那么遇到问题在处理性能上可能会有一点瓶颈。并且Trident是基于微批的处理,在延迟上没有达到比较高的要求,所以不能满足一些对延迟比较高需求的业务。
流处理中的状态管理问题:基于之前的流处理过程中状态管理的问题是非常大的一类问题。状态管理除了会影响到比如说计算状态的一致性,还会影响到实时计算处理的性能以及故障恢复时候的能力。而Flink最突出的一个优势就是状态管理。
实时计算表义能力的局限性:在实时计算之前很多公司大部分的数据开发还是面向离线的场景,近几年实时的场景也慢慢火热起来了。那与离线的处理不同的是,实时的场景下,数据处理的表意能力可能有一定的限制,比如说他要进行精确计算以及时间窗口都是需要在此之上去开发很多功能性的东西。
开发调试成本高:近千结点的集群上已经跑了近万的作业,分布式的处理的引擎,手工写代码的方式,给数据开发的同学也带来了很高开发和调试的成本,再去维护的时候,运维成本也比较高。
在上面这些痛点和问题的背景下,美团从去年开始进行Flink的探索,关注点主要有以下4个方面:
ExactlyOnce计算能力
状态管理能力
窗口/Join/时间处理等等
SQL/TableAPI
下面带大家来看一下,美团从去年投入生产过程中都遇到了哪些问题,以及一些解决方案,分为下面三个部分:
资源隔离的考虑:分场景、按业务
资源隔离的策略:
智能调度目的也是为了解决资源不均的问题,现在普通的调度策略就是基于CPU,基于内存去调度的。除此之外,在生产过程中也发现了一些其他的问题,比如说Flink是会依赖本地磁盘,进行依赖本地磁盘做本地的状态的存储,所以磁盘IO,还有磁盘的容量,也是一类考虑的问题点,除此之外还包括网卡流量,因为每个业务的流量的状态是不一样的,分配进来会导致流量的高峰,把某一个网卡打满,从而影响其他业务,所以期望的话是说做一些智能调度化的事情。目前暂时能做到的是从cpu和内存两方面,未来会从其他方面做一些更优的调度策略。
节点/网络故障
JobManagerHA
自动拉起
与Storm不同的是,知道Storm在遇到异常的时候是非常简单粗暴的,比如说有发生了异常,可能用户没有在代码中进行比较规范的异常处理,但是没有关系,因为worker会重启作业还会继续执行,并且他保证的是At-Least-Once这样的语义,比如说一个网络超时的异常对他而言影响可能并没有那么大,但是Flink不同的是他对异常的容忍度是非常的苛刻的,那时候就考虑的是比如说会发生节点或者是网络的故障,那JobManager单点问题可能就是一个瓶颈,JobManager那个如果挂掉的话,那么可能对整个作业的影响就是不可回复的,所以考虑了做HA,另外一个就是会去考虑一些由于运维的因素而导致的那作业,还有除此之外,可能有一些用户作业是没有开启CheckPoint,但如果是因为节点或者是网络故障导致挂掉,希望会在平台内层做一些自动拉起的策略,去保证作业运行的稳定性。
上下游容错
我们的数据源主要是Kafka,读写Kafka是一类非常常见的实时流处理避不开的一个内容,而Kafka本身的集群规模是非常比较大的,因此节点的故障出现是一个常态问题,在此基础上我们对节点故障进行了一些容错,比如说节点挂掉或者是数据均衡的时候,Leader会切换,那本身Flink的读写对Leader的切换容忍度没有那么高,在此基础上我们对一些特定场景的,以及一些特有的异常做的一些优化,进行了一些重试。
容灾
多机房
流热备
容灾可能大家对考虑的并不多,比如说有没有可能一个机房的所有的节点都挂掉了,或者是无法访问了,虽然它是一个小概率的事件,但它也是会发生的。所以现在也会考虑做多机房的一些部署,包括还有Kafka的一些热备。
在实践过程中,为了解决作业管理的一些问题,减少用户开发的一些成本,我们做了一些平台化的工作,下图是一个作业提交的界面展示,包括作业的配置,作业生命周期的管理,报警的一些配置,延迟的展示,都是集成在实时计算平台的。
在监控上我们也做了一些事情,对于实时作业来讲,对监控的要求会更高,比如说在作业延迟的时候对业务的影响也比较大,所以做了一些延迟的报警,包括作业状态的报警,比如说作业存活的状态,以及作业运行的状态,还有未来会做一些自定义Metrics的报警。自定义Metrics是未来会考虑基于作业处理本身的内容性,做一些可配置化的一些报警。
实时计算引擎提供统一日志和Metrics方案
为业务提供按条件过滤的日志检索
为业务提供自定义时间跨度的指标查询
基于日志和指标,为业务提供可配置的报警
另外就是刚刚提到说在开发实时作业的时候,调优和诊断是一个比较难的痛点,就是用户不是很难去查看分布式的日志,所以也提供了一套统一的解决方案。这套解决方案主要是针对日志和Metrics,会在针对引擎那一层做一些日志和Metrics的上报,那么它会通过统一的日志收集系统,将这些原始的日志,还有Metrics汇集到Kafka那一层。今后Kafka这一层大家可以发现它有两个下游,一方面是做日志到ES的数据同步,目的的话是说能够进入日志中心去做一些日志的检索,另外一方面是通过一些聚合处理流转到写入到OpenTSDB把数据做依赖,这份聚合后的数据会做一些查询,一方面是Metrics的查询展示,另外一方面就是包括实做的一些相关的报警。
下图是当前某一个作业的一个可支持跨天维度的Metrics的一个查询的页面。可以看到说如果是能够通过纵向的对比,可以发现除了作业在某一个时间点是因为什么情况导致的?比如说延迟啊这样容易帮用户判断一些他的做作业的一些问题。除了作业的运行状态之外,也会先就是采集一些节点的基本信息作为横向的对比
下图是当前的日志的一些查询,它记录了,因为作业在挂掉之后,每一个ApplicationID可能会变化,那么基于作业唯一的唯一的主键作业名去搜集了所有的作业,从创建之初到当前运行的日志,那么可以允许用户的跨Application的日志查询。
为了适配这两类MQ做了不同的事情,对于线上的MQ,期望去做一次同步多次消费,目的是避免对线上的业务造成影响,对于的生产类的Kafka就是线下的Kafka,做了一些地址的地址的屏蔽,还有基础基础的一些配置,包括一些权限的管理,还有指标的采集。
下面会给大家讲两个Flink在美团的真实使用的案例。第一个是Petra,Petra其实是一个实时指标的一个聚合的系统,它其实是面向公司的一个统一化的解决方案。它主要面向的业务场景就是基于业务的时间去统计,还有计算一些实时的指标,要求的话是低时延,他还有一个就是说,因为它是面向的是通用的业务,由于业务可能是各自会有各自不同的维度,每一个业务可能包含了包括应用通道机房,还有其他的各自应用各个业务特有的一些维度,而且这些维度可能涉及到比较多,另外一个就是说它可能是就是业务需要去做一些复合的指标的计算,比如说最常见的交易成功率,他可能需要去计算支付的成功数,还有和下单数的比例。另外一个就是说统一化的指标聚合可能面向的还是一个系统,比如说是一些B端或者是R段的一些监控类的系统,那么系统对于指标系统的诉求,就是说我希望指标聚合能够最真最实时最精确的能够产生一些结果,数据保证说它的下游系统能够真实的监控到当前的信息。右边图是我当一个Metrics展示的一个事例。可以看到其他其实跟刚刚讲也是比较类似的,就是说包含了业务的不同维度的一些指标汇聚的结果。
业务场景:
基于业务时间(事件时间)
多业务维度:如应用、通道、机房等
复合指标计算:如交易成功率=支付成功数/下单数
低延迟:秒级结果输出
Exactlyonce的精确性保障
维度计算中数据倾斜
对晚到数据的容忍能力
在用Flink去做实时指标复核的系统的时候,着重从这几方面去考虑了。第一个方面是说精确的计算,包括使用了FLink和CheckPoint的机制去保证说我能做到不丢不重的计算,第一个首先是由统一化的Metrics流入到一个预聚合的模块,预聚合的模块主要去做一些初始化的一些聚合,其中的为什么会分预聚合和全量聚合主要的解决一类问题,包括就刚刚那位同学问的一个问题,就是数据倾斜的问题,比如说在热点K发生的时候,当前的解决方案也是通过预聚合的方式去做一些缓冲,让尽量把K去打散,再聚合全量聚合模块去做汇聚。那其实也是只能解决一部分问题,所以后面也考虑说在性能的优化上包括去探索状态存储的性能。下面的话还是包含晚到数据的容忍能力,因为指标汇聚可能刚刚也提到说要包含一些复合的指标,那么符合的指标所依赖的数据可能来自于不同的流,即便来自于同一个流,可能每一个数据上报的时候,可能也会有晚到的情况发生,那时候需要去对数据关联做晚到的容忍,容忍的一方面是说可以设置晚到的Lateness的延迟,另一方面是可以设置窗口的长度,但是其实在现实的应用场景上,其实还有一方面考虑就是说除了去尽量的去拉长时间,还要考虑真正的计算成本,所以在这方面也做了一些权衡,那么指标基本就是经过全量聚合之后,聚合结果会回写Kafka,经过数据同步的模块写到OpenTSDB去做,最后去grafana那做指标的展示,另一方面可能去应用到通过Facebook包同步的模块去同步到报警的系统里面去做一些指标,基于指标的报警。
下图是现在提供的产品化的Petra的一个展示的机示意图,可以看到目前的话就是定义了某一些常用的算子,以及维度的配置,允许用户进行配置话的处理,直接去能够获取到他期望要的指标的一个展示和汇聚的结果。目前还在探索说为Petra基于Sql做一些事情,因为很多用户也比较就是在就是习惯上也可以倾向于说我要去写Sql去完成这样的统计,所以也会基于此说依赖Flink的本身的对SQl还有TableAPI的支持,也会在Sql的场景上进行一些探索。
第二类应用就是机器学习的一个场景,机器学习的场景可能会依赖离线的特征数据以及实时的特征数据。一个是基于现有的离线场景下的特征提取,经过了批处理,流转到了离线的集群。另外一个就是近线模式,近线模式出的数据就是现有的从日志收集系统流转过来的统一的日志,经过Flink的处理,就是包括流的关联以及特征的提取,再做模型的训练,流转到最终的训练的集群,训练的集群会产出P的特征,还有都是Delta的特征,最终将这些特征影响到线上的线上的特征的一个训练的一个服务上。这是一个比较常见的,比如说比较就是通用的也是比较通用的一个场景,目前的话主要应用的方可能包含了搜索还有推荐,以及一些其他的业务。
未来的话可能也是通过也是期望在这三方面进行做一些更多的事情,刚刚也提到了包括状态的管理,第一个是状态的统一的,比如说Sql化的统一的管理,希望有统一的配置,帮用户去选择一些期望的回滚点。另外一个就是大状态的性能优化,因为比如说像做一些流量数据的双流的关联的时候,现在也遇到了一些性能瓶颈的问题,对于说啊基于内存型的状态,基于内存型的数据的处理,以及基于RocksDB的状态的处理,做过性能的比较,发现其实性能的差异还是有一些大的,所以希望说在基于RocksDBBackend的上面能够去尽量去更多的做一些优化,从而提升作业处理的性能。第二方面就是Sql,Sql的话应该是每一个位就是当前可能各个公司都在做的一个方向,因为之前也有对Sql做一些探索,包括提供了基于Storm的一些Sql的表示,但是可能对于之前的话对于与语义的表达可能会有一些欠缺,所以希望说在基于Flink可去解决这些方面的事情,以及包括Sql的并发度的一些配置的优化,包括Sql的查询的一些优化,都希望说在Flink未来能够去优化更多的东西,去真正能使Sql应用到生产的环境。
另外一方面的话就是会进行新的场景的也在做新的场景的一些探索,期望是比如说包括刚刚也提到说除了流式的处理,也期望说把离线的场景下的数据进行一些合并,通过统一的Sql的API去提供给业务做更多的服务,包括流处理,还有批处理的结合。
]]>我是2015年7月毕业后加入的公司,当时进入的是中间件-实时计算 JStorm 团队。JStorm 是用 Java 语言代替 Clojure 语言重写了 Apache Storm,并在原有的基础上做了诸多性能和功能优化。JStorm 是阿里巴巴开源的几个明星产品之一,在国内的用户非常多。很多国内做流计算,实时计算的应该都知道 JStorm。
但是我当时并不知道 JStorm 或是 Storm,我当时只知道 Spark,也不懂什么是实时计算,流计算。所以
都是入职之后现学,看文档,看源码,学习 Clojure。差不多11月份的时候,阿里正式向 Apache 基金会捐赠了 JStorm。这是阿里捐赠给 Apache 的第一个项目,后面还有 Apache RocketMQ, Apache Weex。JStorm 火了一把,当时有很多报道转载这件事情,还放了一张我们团队油头垢面、屌丝气十足的合照。
在第一个半年里,我重写了 JStorm 的开源 UI,参与了管控平台的开发,做了 JStorm 的一些开发工作。到了半年 Review 的时候,我觉得我做的并不好(虽然老板一直鼓励我做的挺好…)。其实作为一个新人,半年时间是很难做出成绩的。但是从一开始我对自己的期望就比较高,所以失望也比较大。而且看到同期的应届生半年就有非常出色的成果,相比之下就相形见绌了。那段时间,自己的心情也比较低落,比较迷茫。我觉得我脑子不笨,学习能力也不差,工作也很努力,为什么就做不出什么“成果”呢?
在阿里中间件,有很多非常耀眼的新人,有的在我还在熟悉工作的时候人家已经成为了项目 owner,有的已经成为了 Apache 项目的 PMC,有的一年就晋升到了 P6。他们有很多地方是值得学习的,聪明,拼搏,机遇,是我觉得最重要的几个关键词。但是工作后你就会发现,哪有那么多的机会在等你呢。不过,关于个人成长和晋升,阿里有句老话叫做“没有坑,就让自己先成为萝卜”,我非常认同,是萝卜的话,那个坑是迟早的事情。
2015 年是流计算百花齐放的时代,各个流计算框架层出不穷。Storm, JStorm, Heron, Flink, Spark Streaming, Google Dataflow (后来的 Beam) 等等。其中 Flink 的一致性语义和最接近 Dataflow 模型的开源实现,使其成为流计算框架中最耀眼的一颗。我也是看中了 Flink 在流计算上的先进性,所以在 2016 年春节过后回来上班的第一天,我跟老板提议,希望能去研究 Flink,我们团队需要有个人透彻了解 Flink 的原理(我希望成为 Flink 的萝卜,事实证明这为我之后带来了很多机会)。Boss 同意了,并且给了我一个 KPI:一年内成为 Flink Committer。
后来我才知道搜索部门已经研究 Flink 有一年了,并且有了个内部版本,名叫“Blink”。所以之后,我们便与 Blink 开始共建 Table API & SQL。我也是在那个时候进入 Flink 社区工作。那个时候在社区工作是非常孤独,非常艰难的,因为很多时候没有人可以一起讨论,一个人在社区推进事情也很困难。不过,当你推进的提议被社区所接受并落地的那种心情是非常有成就感的。
记得刚进入社区工作的时候,我也只能挑一些非常简单的修复 Bug 的任务,学习社区的工作流程,用我非常蹩脚的英文在 GitHub 上与他们交流。有时候,每写一句话都要粘贴到 Google 翻译中翻译一遍,确保自己语句没有问题。我对学习英语没什么天赋,大学考英语四级考了三次才过。但是孰能生巧,现在我也可以流畅地在 GitHub 、邮件列表上与他们交流,能洋洋洒洒地写几千单词的英文设计文档与社区讨论,能与 dataArtisans 电话会议讨论设计细节。英语是 IT 人士非常重要的软实力,我觉得至少要做到读英文技术文章不吃力,其次要做到能用英语流畅交流技术。我现在已经越来越体会到英语的重要性,我觉得英语在某些方面甚至决定了你的潜力,为此我还买了一个半年的英语课程,每天练习自己的听力和口语,现在已经坚持了一个半月了,希望能坚持到底。
在社区工作了将近一年,终于在 2017 年春节的时候,收到了社区邀请我成为 Flink Committer 的邮件,这是对我过去一年工作的肯定,也算是踩点完成了 KPI … 我很荣幸是阿里第一位成为 Flink Committer 的。截止到目前,阿里已经发展了 5 位 Flink Committer,当然都在我们大团队 😉。
阿里还有一句老话叫“拥抱变化”。大概五月份的时候,为了打造世界级的流计算引擎和服务,我们团队和 Blink 都加入了新成立的计算平台事业部。在新的事业部,我迎来了自己的第一次晋升。晋升的过程非常愉快。令我印象深刻的是,在这么一个P5到P6的晋升面试上居然要出动1个P10,1个P9,3个P8,1个HRG的阵容。公司也真是舍得下成本啊。
虽然“拥抱变化”了,不过在新事业部我做的事情没什么改变,仍然是 Flink/Blink SQL 相关的工作。从一开始我们就意识 SQL 在抽象和统一用户业务逻辑上的强大之处。而且,流和批的计算可以自然而然的在传统SQL这一层统一。SQL 可以把一个非常复杂的计算用简单的抽象表达出来。这是我认为我现在工作有意思有挑战的地方。在社区方面,Flink SQL 总共有 5 位 Committer,我们团队占据了其中三位。我们与社区的合作是非常紧密的,平均每个星期都有与 dataArtisans (Flink 背后的公司) 的视频会议,讨论每周的技术设计细节。可以说,流计算 SQL 在开源范围内我们是比较领先的,甚至是在定义流计算SQL的标准。dataArtisans 的 CTO Stephan 说,阿里巴巴是 Flink 现在的最大的贡献者。确实如此,不仅在 Flink SQL,在 Runtime 方面,我们帮助社区贡献了若干从大规模部署到性能,再到容错方面的优化。这些优化使得 Flink 的易用性和性能得到了大大的提升。
哈哈,是的,这里不免落俗地发个招聘广告。阿里实时计算团队(杭州,北京,美国)诚邀各种牛人(存储,计算,分布式,大数据,甚至前端)加入,有感兴趣的可以直接联系我:imjark#gmail.com 。
]]>Async I/O 是阿里巴巴贡献给社区的一个呼声非常高的特性,于1.2版本引入。主要目的是为了解决与外部系统交互时网络延迟成为了系统瓶颈的问题。
流计算系统中经常需要与外部系统进行交互,比如需要查询外部数据库以关联上用户的额外信息。通常,我们的实现方式是向数据库发送用户a
的查询请求,然后等待结果返回,在这之前,我们无法发送用户b
的查询请求。这是一种同步访问的模式,如下图左边所示。
图中棕色的长条表示等待时间,可以发现网络等待时间极大地阻碍了吞吐和延迟。为了解决同步访问的问题,异步模式可以并发地处理多个请求和回复。也就是说,你可以连续地向数据库发送用户a
、b
、c
等的请求,与此同时,哪个请求的回复先返回了就处理哪个回复,从而连续的请求之间不需要阻塞等待,如上图右边所示。这也正是 Async I/O 的实现原理。
使用 Async I/O 的前提是需要一个支持异步请求的客户端。当然,没有异步请求客户端的话也可以将同步客户端丢到线程池中执行作为异步客户端。Flink 提供了非常简洁的API,让用户只需要关注业务逻辑,一些脏活累活比如消息顺序性和一致性保证都由框架处理了,多么棒的事情!
使用方式如下方代码片段所示(来自官网文档):
/** 'AsyncFunction' 的一个实现,向数据库发送异步请求并设置回调 */ |
AsyncDataStream
有两个静态方法,orderedWait
和 unorderedWait
,对应了两种输出模式:有序和无序。
AsyncDataStream.(un)orderedWait
的主要工作就是创建了一个 AsyncWaitOperator
。AsyncWaitOperator
是支持异步 IO 访问的算子实现,该算子会运行 AsyncFunction
并处理异步返回的结果,其内部原理如下图所示。
如图所示,AsyncWaitOperator
主要由两部分组成:StreamElementQueue
和 Emitter
。StreamElementQueue 是一个 Promise 队列,所谓 Promise 是一种异步抽象表示将来会有一个值(参考 Scala Promise 了解更多),这个队列是未完成的 Promise 队列,也就是进行中的请求队列。Emitter 是一个单独的线程,负责发送消息(收到的异步回复)给下游。
图中E5
表示进入该算子的第五个元素(”Element-5”),在执行过程中首先会将其包装成一个 “Promise” P5
,然后将P5
放入队列。最后调用 AsyncFunction
的 ayncInvoke
方法,该方法会向外部服务发起一个异步的请求,并注册回调。该回调会在异步请求成功返回时调用 AsyncCollector.collect
方法将返回的结果交给框架处理。实际上 AsyncCollector
是一个 Promise ,也就是 P5
,在调用 collect
的时候会标记 Promise 为完成状态,并通知 Emitter 线程有完成的消息可以发送了。Emitter 就会从队列中拉取完成的 Promise ,并从 Promise 中取出消息发送给下游。
上文提到 Async I/O 提供了两种输出模式。其实细分有三种模式: 有序,ProcessingTime 无序,EventTime 无序。Flink 使用队列来实现不同的输出模式,并抽象出一个队列的接口(StreamElementQueue
),这种分层设计使得AsyncWaitOperator
和Emitter
不用关心消息的顺序问题。StreamElementQueue
有两种具体实现,分别是 OrderedStreamElementQueue
和 UnorderedStreamElementQueue
。UnorderedStreamElementQueue
比较有意思,它使用了一套逻辑巧妙地实现完全无序和 EventTime 无序。
有序比较简单,使用一个队列就能实现。所有新进入该算子的元素(包括 watermark),都会包装成 Promise 并按到达顺序放入该队列。如下图所示,尽管P4
的结果先返回,但并不会发送,只有 P1
(队首)的结果返回了才会触发 Emitter 拉取队首元素进行发送。
ProcessingTime 无序也比较简单,因为没有 watermark,不需要协调 watermark 与消息的顺序性,所以使用两个队列就能实现,一个 uncompletedQueue
一个 completedQueue
。所有新进入该算子的元素,同样的包装成 Promise 并放入 uncompletedQueue
队列,当uncompletedQueue
队列中任意的Promise返回了数据,则将该 Promise 移到 completedQueue
队列中,并通知 Emitter 消费。如下图所示:
EventTime 无序类似于有序与 ProcessingTime 无序的结合体。因为有 watermark,需要协调 watermark 与消息之间的顺序性,所以uncompletedQueue
中存放的元素从原先的 Promise 变成了 Promise 集合。如果进入算子的是消息元素,则会包装成 Promise 放入队尾的集合中。如果进入算子的是 watermark,也会包装成 Promise 并放到一个独立的集合中,再将该集合加入到 uncompletedQueue
队尾,最后再创建一个空集合加到 uncompletedQueue
队尾。这样,watermark 就成了消息顺序的边界。只有处在队首的集合中的 Promise 返回了数据,才能将该 Promise 移到 completedQueue
队列中,由 Emitter 消费发往下游。只有队首集合空了,才能处理第二个集合。这样就保证了当且仅当某个 watermark 之前所有的消息都已经被发送了,该 watermark 才能被发送。过程如下图所示:
分布式快照机制是为了保证状态的一致性。我们需要分析哪些状态是需要快照的,哪些是不需要的。首先,已经完成回调并且已经发往下游的元素是不需要快照的。否则,会导致重发,那就不是 exactly-once 了。而已经完成回调且未发往下游的元素,加上未完成回调的元素,就是上述队列中的所有元素。
所以快照的逻辑也非常简单,(1)清空原有的状态存储,(2)遍历队列中的所有 Promise,从中取出 StreamElement
(消息或 watermark)并放入状态存储中,(3)执行快照操作。
恢复的时候,从快照中读取所有的元素全部再处理一次,当然包括之前已完成回调的元素。所以在失败恢复后,会有元素重复请求外部服务,但是每个回调的结果只会被发往下游一次。
本文的原理和实现分析基于 Flink 1.3 版本。
首先 Table API 是一种关系型API,类 SQL 的API,用户可以像操作表一样地操作数据,非常的直观和方便。用户只需要说需要什么东西,系统就会自动地帮你决定如何最高效地计算它,而不需要像 DataStream 一样写一大堆 Function,优化还得纯靠手工调优。另外,SQL 作为一个“人所皆知”的语言,如果一个引擎提供 SQL,它将很容易被人们接受。这已经是业界很常见的现象了。值得学习的是,Flink 的 Table API 与 SQL API 的实现,有 80% 的代码是共用的。所以当我们讨论 Table API 时,常常是指 Table & SQL API。
Table & SQL API 还有另一个职责,就是流处理和批处理统一的API层。Flink 在runtime层是统一的,因为Flink将批任务看做流的一种特例来执行,这也是 Flink 向外鼓吹的一点。然而在编程模型上,Flink 却为批和流提供了两套API (DataSet 和 DataStream)。为什么 runtime 统一,而编程模型不统一呢? 在我看来,这是本末倒置的事情。用户才不管你 runtime 层是否统一,用户更关心的是写一套代码。这也是为什么现在 Apache Beam 能这么火的原因。所以 Table & SQL API 就扛起了统一API的大旗,批上的查询会随着输入数据的结束而结束并生成有限结果集,流上的查询会一直运行并生成结果流。Table & SQL API 做到了批与流上的查询具有同样的语法,因此不用改代码就能同时在批和流上跑。
Table API 始于 Flink 0.9,Flink 0.9 是一个类库百花齐放的版本,众所周知的 Table API, Gelly, FlinkML 都是在这个版本加进去的。Flink 0.9 大概是在2015年6月正式发布的,在 Flink 0.9 发布之前,社区对 SQL 展开过好几次争论,不过当时社区认为应该首先完善 Table API 的功能,再去搞SQL,如果两头一起搞很容易什么都做不好。而且在整个Hadoop生态圈中已经有大量的所谓 “SQL-on-Hadoop” 的解决方案,譬如 Apache Hive, Apache Drill, Apache Impala。”SQL-on-Flink”的事情也可以像 Hadoop 一样丢给其他社区去搞。
不过,随着 Flink 0.9 的发布,意味着抽象语法树、代码生成、运行时函数等都已经成熟,这为SQL的集成铺好了前进道路。另一方面,用户对 SQL 的呼声越来越高。2015年下半年,Timo 大神也加入了 dataArtisans,于是对Table API的改造开始了。2016 年初的时候,改造基本上完成了。我们也是在这个时间点发现了 Table API 的潜力,并加入了社区。经过这一年的努力,Flink 已经发展成 Apache 中最火热的项目之一,而 Flink 中最活跃的类库目前非 Table API 莫属。这其中离不开国内公司的支持,Table API 的贡献者绝大多数都来自于阿里巴巴和华为,并且主导着 Table API 的发展方向,这是非常令国人自豪的。而我在社区贡献了一年后,幸运地成为了 Flink Committer。
这里不会详细介绍 Table API & SQL 的使用,只是做一个展示。更多使用细节方面的问题请访问官网文档。
下面这个例子展示了如何用 Table API 处理温度传感器数据。计算每天每个以room
开头的location的平均温度。例子中涉及了如何使用window,event-time等。
val sensorData: DataStream[(String, Long, Double)] = ??? |
下面的例子是展示了如何用 SQL 来实现。
val sensorData: DataStream[(String, Long, Double)] = ??? |
Flink 非常明智,没有像Spark那样重复造轮子(Spark Catalyst),而是将 SQL 校验、SQL 解析以及 SQL 优化交给了 Apache Calcite。Calcite 在其他很多开源项目里也都应用到了,譬如Apache Hive, Apache Drill, Apache Kylin, Cascading。Calcite 在新的架构中处于核心的地位,如下图所示。
新的架构中,构建抽象语法树的事情全部交给了 Calcite 去做。SQL query 会经过 Calcite 解析器转变成 SQL 节点树,通过验证后构建成 Calcite 的抽象语法树(也就是图中的 Logical Plan)。另一边,Table API 上的调用会构建成 Table API 的抽象语法树,并通过 Calcite 提供的 RelBuilder 转变成 Calcite 的抽象语法树。
以上面的温度计代码为样例,Table API 和 SQL 的转换流程如下,绿色的节点代表 Flink Table Nodes,蓝色的节点代表 Calcite Logical Nodes。最终都转化成了相同的 Logical Plan 表现形式。
之后会进入优化器,Calcite 会基于优化规则来优化这些 Logical Plan,根据运行环境的不同会应用不同的优化规则(Flink提供了批的优化规则,和流的优化规则)。这里的优化规则分为两类,一类是Calcite提供的内置优化规则(如条件下推,剪枝等),另一类是是将Logical Node转变成 Flink Node 的规则。这两类规则的应用体现为下图中的①和②步骤,这两步骤都属于 Calcite 的优化阶段。得到的 DataStream Plan 封装了如何将节点翻译成对应 DataStream/DataSet 程序的逻辑。步骤③就是将不同的 DataStream/DataSet Node 通过代码生成(CodeGen)翻译成最终可执行的 DataStream/DataSet 程序。
代码生成是 Table API & SQL 中最核心的一块内容。表达式、条件、内置函数等等是需要CodeGen出具体的Function 代码的,这部分跟Spark SQL的结构很相似。CodeGen 出的Function以字符串的形式存在。在提交任务后会分发到各个 TaskManager 中运行,在运行时会使用 Janino 编译器编译代码后运行。
目前 Table API 对于批和流都已经支持了基本的Selection, Projection, Union,以及 Window 操作(包括固定窗口、滑动窗口、会话窗口)。SQL 的话由于 Calcite 在最近的版本中才支持 Window 语法,所以目前 Flink SQL 还不支持 Window 的语法。并且 Table API 和 SQL 都支持了UDF,UDTF,UDAF(开发中)。
Dynamic Tables
Dynamic Table 就是传统意义上的表,只不过表中的数据是会变化更新的。Flink 提出 Stream <--> Dynamic Table 之间是可以等价转换的。不过这需要引入Retraction机制。有机会的话,我会专门写一篇文章来介绍。-->
Joins
包括了支持流与流的 Join,以及流与表的 Join。
SQL 客户端
目前 SQL 是需要内嵌到 Java/Scala 代码中运行的,不是纯 SQL 的使用方式。未来需要支持 SQL 客户端执行提交 SQL 纯文本运行任务。
并行度设置
目前 Table API & SQL 是无法设置并行度的,这使得 Table API 看起来仍像个玩具。
在我看来,Flink 的 Table & SQL API 是走在时代前沿的,在很多方面在做着定义业界标准的事情,比如 SQL 上Window的表达,时间语义的表达,流和批语义的统一等。在我看来,SQL 拥有更天然的流与批统一的特性,并且能够自动帮用户做很多SQL优化(下推、剪枝等),这是 Beam 所做不到的地方。当然,未来如果 Table & SQL API 发展成熟的话,剥离出来作为业界标准的流与批统一的API也不是不可能(叫BeamTable,BeamSQL ?),哈哈。这也是我非常看好 Table & SQL API,认为其大有潜力的一个原因。当然就目前来说,需要走的路还很长,Table API 现在还只是个玩具。
当我们需要分析用户的一段交互的行为事件时,通常的想法是将用户的事件流按照“session”来分组。session 是指一段持续活跃的期间,由活跃间隙分隔开。通俗一点说,消息之间的间隔小于超时阈值(sessionGap)的,则被分配到同一个窗口,间隔大于阈值的,则被分配到不同的窗口。目前开源领域大部分的流计算引擎都有窗口的概念,但是没有对 session window 的支持,要实现 session window,需要用户自己去做完大部分事情。而当 Flink 1.1.0 版本正式发布时,Flink 将会是开源流计算领域第一个内建支持 session window 的引擎。
在 Flink 1.1.0 之前,Flink 也可以通过自定义的window assigner和trigger来实现一个基本能用的session window。release-1.0
版本中提供了一个实现 session window 的 example:SessionWindowing。这个session window范例的实现原理是,基于GlobleWindow这个window assigner,将所有元素都分配到同一个窗口中,然后指定一个自定义的trigger来触发执行窗口。这个trigger的触发机制是,对于每个到达的元素都会根据其时间戳(timestamp)注册一个会话超时的定时器(timestamp+sessionTimeout),并移除上一次注册的定时器。最新一个元素到达后,如果超过 sessionTimeout 的时间还没有新元素到达,那么trigger就会触发,当前窗口就会是一个session window。处理完窗口后,窗口中的数据会清空,用来缓存下一个session window的数据。
但是这种session window的实现是非常弱的,无法应用到实际生产环境中的。因为它无法处理乱序 event time 的消息。 而在即将到来的 Flink 1.1.0 版本中,Flink 提供了对 session window 的直接支持,用户可以通过SessionWindows.withGap()
来轻松地定义 session widnow,而且能够处理乱序消息。Flink 对 session window 的支持主要借鉴自 Google 的 DataFlow 。
假设有这么个场景,用户点开手机淘宝后会进行一系列的操作(点击、浏览、搜索、购买、切换tab等),这些操作以及对应发生的时间都会发送到服务器上进行用户行为分析。那么用户的操作行为流的样例可能会长下面这样:
通过上图,我们可以很直观地观察到,用户的行为是一段一段的,每一段内的行为都是连续紧凑的,段内行为的关联度要远大于段之间行为的关联度。我们把每一段用户行为称之为“session”,段之间的空档我们称之为“session gap”。所以,理所当然地,我们应该按照 session window 对用户的行为流进行切分,并计算每个session的结果。如下图所示:
为了定义上述的窗口切分规则,我们可以使用 Flink 提供的 SessionWindows
这个 widnow assigner API。如果你用过 SlidingEventTimeWindows
、TumlingProcessingTimeWindows
等,你会对这个很熟悉。
DataStream input = … |
这样,Flink 就会基于元素的时间戳,自动地将元素放到不同的session window中。如果两个元素的时间戳间隔小于 session gap,则会在同一个session中。如果两个元素之间的间隔大于session gap,且没有元素能够填补上这个gap,那么它们会被放到不同的session中。
为了实现 session window,我们需要扩展 Flink 中的窗口机制,使得能够支持窗口合并。要理解其原因,我们需要先了解窗口的现状。在上一篇文章中,我们谈到了 Flink 中 WindowAssigner 负责将元素分配到哪个/哪些窗口中去,Trigger 决定了一个窗口何时能够被计算或清除。当元素被分配到窗口之后,这些窗口是固定的不会改变的,而且窗口之间不会相互作用。
对于session window来说,我们需要窗口变得更灵活。基本的思想是这样的:SessionWindows
assigner 会为每个进入的元素分配一个窗口,该窗口以元素的时间戳作为起始点,时间戳加会话超时时间为结束点,也就是该窗口为[timestamp, timestamp+sessionGap)
。比如我们现在到了两个元素,它们被分配到两个独立的窗口中,两个窗口目前不相交,如图:
当第三个元素进入时,分配到的窗口与现有的两个窗口发生了叠加,情况变成了这样:
由于我们支持了窗口的合并,WindowAssigner
可以合并这些窗口。它会遍历现有的窗口,并告诉系统哪些窗口需要合并成新的窗口。Flink 会将这些窗口进行合并,合并的主要内容有两部分:
总之,结果是三个元素现在在同一个窗口中了:
需要注意的是,对于每一个新进入的元素,都会分配一个属于该元素的窗口,都会检查并合并现有的窗口。在触发窗口计算之前,每一次都会检查该窗口是否可以和其他窗口合并,直到trigger触发后,会将该窗口从窗口列表中移除。对于 event time 来说,窗口的触发是要等到大于窗口结束时间的 watermark 到达,当watermark没有到,窗口会一直缓存着。所以基于这种机制,可以做到对乱序消息的支持。
这里有一个优化点可以做,因为每一个新进入的元素都会创建属于该元素的窗口,然后合并。如果新元素连续不断地进来,并且新元素的窗口一直都是可以和之前的窗口重叠合并的,那么其实这里多了很多不必要的创建窗口、合并窗口的操作,我们可以直接将新元素放到那个已存在的窗口,然后扩展该窗口的大小,看起来就像和新元素的窗口合并了一样。
FLINK-3174 这个JIRA中有对 Flink 如何支持 session window 的详细说明,以及代码更新。建议可以结合该 PR 的代码来理解本文讨论的实现原理。
为了扩展 Flink 中的窗口机制,使得能够支持窗口合并,首先 window assigner 要能合并现有的窗口,Flink 增加了一个新的抽象类 MergingWindowAssigner
继承自 WindowAssigner
,这里面主要多了一个 mergeWindows
的方法,用来决定哪些窗口是可以合并的。
public abstract class MergingWindowAssigner<T, W extends Window> extends WindowAssigner<T, W> { |
所有已经存在的 assigner 都继承自 WindowAssigner
,只有新加入的 session window assigner 继承自 MergingWindowAssigner
,如:ProcessingTimeSessionWindows
和EventTimeSessionWindows
。
另外,Trigger 也需要能支持对合并窗口后的响应,所以 Trigger 添加了一个新的接口 onMerge(W window, OnMergeContext ctx)
,用来响应发生窗口合并之后对trigger的相关动作,比如根据合并后的窗口注册新的 event time 定时器。
OK,接下来我们看下最核心的代码,也就是对于每个进入的元素的处理,代码位于WindowOperator.processElement
方法中,如下所示:
public void processElement(StreamRecord<IN> element) throws Exception { |
其实这段代码写的并不是很clean,并且不是很好理解。在第六行中有用到MergingWindowSet
,这个类很重要所以我们先介绍它。这是一个用来跟踪窗口合并的类。比如我们有A、B、C三个窗口需要合并,合并后的窗口为D窗口。这三个窗口在底层都有对应的状态集合,为了避免代价高昂的状态替换(创建新状态是很昂贵的),我们保持其中一个窗口作为原始的状态窗口,其他几个窗口的数据合并到该状态窗口中去,比如随机选择A作为状态窗口,那么B和C窗口中的数据需要合并到A窗口中去。这样就没有新状态产生了,但是我们需要额外维护窗口与状态窗口之间的映射关系(D->A),这就是MergingWindowSet
负责的工作。这个映射关系需要在失败重启后能够恢复,所以MergingWindowSet
内部也是对该映射关系做了容错。状态合并的工作示意图如下所示:
然后我们来解释下processElement的代码,首先根据window assigner为新进入的元素分配窗口集合。接着进入第一个条件块,取出当前的MergingWindowSet
。对于每个分配到的窗口,我们就会将其加入到MergingWindowSet
中(addWindow
方法),由MergingWindowSet
维护窗口与状态窗口之间的关系,并在需要窗口合并的时候,合并状态和trigger。然后根据映射关系,取出结果窗口对应的状态窗口,根据状态窗口取出对应的状态。将新进入的元素数据加入到该状态中。最后,根据trigger结果来对窗口数据进行处理,对于session window来说,这里都是不进行任何处理的。真正对窗口处理是由定时器超时后对完成的窗口调用processTriggerResult
。
本文在上一篇文章:Window机制的基础上,深入讲解了 Flink 是如何支持 session window 的,核心的原理是窗口的合并。Flink 对于 session window 的支持很大程度上受到了 Google DataFlow 的启发,所以也建议阅读下 DataFlow 的论文。
在流处理应用中,数据是连续不断的,因此我们不可能等到所有数据都到了才开始处理。当然我们可以每来一个消息就处理一次,但是有时我们需要做一些聚合类的处理,例如:在过去的1分钟内有多少用户点击了我们的网页。在这种情况下,我们必须定义一个窗口,用来收集最近一分钟内的数据,并对这个窗口内的数据进行计算。
窗口可以是时间驱动的(Time Window,例如:每30秒钟),也可以是数据驱动的(Count Window,例如:每一百个元素)。一种经典的窗口分类可以分成:翻滚窗口(Tumbling Window,无重叠),滚动窗口(Sliding Window,有重叠),和会话窗口(Session Window,活动间隙)。
我们举个具体的场景来形象地理解不同窗口的概念。假设,淘宝网会记录每个用户每次购买的商品个数,我们要做的是统计不同窗口中用户购买商品的总数。下图给出了几种经典的窗口切分概述图:
上图中,raw data stream 代表用户的购买行为流,圈中的数字代表该用户本次购买的商品个数,事件是按时间分布的,所以可以看出事件之间是有time gap的。Flink 提供了上图中所有的窗口类型,下面我们会逐一进行介绍。
就如名字所说的,Time Window 是根据时间对数据流进行分组的。这里我们涉及到了流处理中的时间问题,时间问题和消息乱序问题是紧密关联的,这是流处理中现存的难题之一,我们将在后续的 EventTime 和消息乱序处理 中对这部分问题进行深入探讨。这里我们只需要知道 Flink 提出了三种时间的概念,分别是event time(事件时间:事件发生时的时间),ingestion time(摄取时间:事件进入流处理系统的时间),processing time(处理时间:消息被计算处理的时间)。Flink 中窗口机制和时间类型是完全解耦的,也就是说当需要改变时间类型时不需要更改窗口逻辑相关的代码。
Tumbling Time Window
如上图,我们需要统计每一分钟中用户购买的商品的总数,需要将用户的行为事件按每一分钟进行切分,这种切分被成为翻滚时间窗口(Tumbling Time Window)。翻滚窗口能将数据流切分成不重叠的窗口,每一个事件只能属于一个窗口。通过使用 DataStream API,我们可以这样实现:
// Stream of (userId, buyCnt) |
Sliding Time Window
但是对于某些应用,它们需要的窗口是不间断的,需要平滑地进行窗口聚合。比如,我们可以每30秒计算一次最近一分钟用户购买的商品总数。这种窗口我们称为滑动时间窗口(Sliding Time Window)。在滑窗中,一个元素可以对应多个窗口。通过使用 DataStream API,我们可以这样实现:
val slidingCnts: DataStream[(Int, Int)] = buyCnts |
Count Window 是根据元素个数对数据流进行分组的。
Tumbling Count Window
当我们想要每100个用户购买行为事件统计购买总数,那么每当窗口中填满100个元素了,就会对窗口进行计算,这种窗口我们称之为翻滚计数窗口(Tumbling Count Window),上图所示窗口大小为3个。通过使用 DataStream API,我们可以这样实现:
// Stream of (userId, buyCnts) |
Sliding Count Window
当然Count Window 也支持 Sliding Window,虽在上图中未描述出来,但和Sliding Time Window含义是类似的,例如计算每10个元素计算一次最近100个元素的总和,代码示例如下。
val slidingCnts: DataStream[(Int, Int)] = vehicleCnts |
在这种用户交互事件流中,我们首先想到的是将事件聚合到会话窗口中(一段用户持续活跃的周期),由非活跃的间隙分隔开。如上图所示,就是需要计算每个用户在活跃期间总共购买的商品数量,如果用户30秒没有活动则视为会话断开(假设raw data stream是单个用户的购买行为流)。Session Window 的示例代码如下:
// Stream of (userId, buyCnts) |
一般而言,window 是在无限的流上定义了一个有限的元素集合。这个集合可以是基于时间的,元素个数的,时间和个数结合的,会话间隙的,或者是自定义的。Flink 的 DataStream API 提供了简洁的算子来满足常用的窗口操作,同时提供了通用的窗口机制来允许用户自己定义窗口分配逻辑。下面我们会对 Flink 窗口相关的 API 进行剖析。
得益于 Flink Window API 松耦合设计,我们可以非常灵活地定义符合特定业务的窗口。Flink 中定义一个窗口主要需要以下三个组件。
Window Assigner:用来决定某个元素被分配到哪个/哪些窗口中去。
如下类图展示了目前内置实现的 Window Assigners:
Trigger:触发器。决定了一个窗口何时能够被计算或清除,每个窗口都会拥有一个自己的Trigger。
如下类图展示了目前内置实现的 Triggers:
Evictor:可以译为“驱逐者”。在Trigger触发之后,在窗口被处理之前,Evictor(如果有Evictor的话)会用来剔除窗口中不需要的元素,相当于一个filter。
如下类图展示了目前内置实现的 Evictors:
上述三个组件的不同实现的不同组合,可以定义出非常复杂的窗口。Flink 中内置的窗口也都是基于这三个组件构成的,当然内置窗口有时候无法解决用户特殊的需求,所以 Flink 也暴露了这些窗口机制的内部接口供用户实现自定义的窗口。下面我们将基于这三者探讨窗口的实现机制。
下图描述了 Flink 的窗口机制以及各组件之间是如何相互工作的。
首先上图中的组件都位于一个算子(window operator)中,数据流源源不断地进入算子,每一个到达的元素都会被交给 WindowAssigner。WindowAssigner 会决定元素被放到哪个或哪些窗口(window),可能会创建新窗口。因为一个元素可以被放入多个窗口中,所以同时存在多个窗口是可能的。注意,Window
本身只是一个ID标识符,其内部可能存储了一些元数据,如TimeWindow
中有开始和结束时间,但是并不会存储窗口中的元素。窗口中的元素实际存储在 Key/Value State 中,key为Window
,value为元素集合(或聚合值)。为了保证窗口的容错性,该实现依赖了 Flink 的 State 机制(参见 state 文档)。
每一个窗口都拥有一个属于自己的 Trigger,Trigger上会有定时器,用来决定一个窗口何时能够被计算或清除。每当有元素加入到该窗口,或者之前注册的定时器超时了,那么Trigger都会被调用。Trigger的返回结果可以是 continue(不做任何操作),fire(处理窗口数据),purge(移除窗口和窗口中的数据),或者 fire + purge。一个Trigger的调用结果只是fire的话,那么会计算窗口并保留窗口原样,也就是说窗口中的数据仍然保留不变,等待下次Trigger fire的时候再次执行计算。一个窗口可以被重复计算多次知道它被 purge 了。在purge之前,窗口会一直占用着内存。
当Trigger fire了,窗口中的元素集合就会交给Evictor
(如果指定了的话)。Evictor 主要用来遍历窗口中的元素列表,并决定最先进入窗口的多少个元素需要被移除。剩余的元素会交给用户指定的函数进行窗口的计算。如果没有 Evictor 的话,窗口中的所有元素会一起交给函数进行计算。
计算函数收到了窗口的元素(可能经过了 Evictor 的过滤),并计算出窗口的结果值,并发送给下游。窗口的结果值可以是一个也可以是多个。DataStream API 上可以接收不同类型的计算函数,包括预定义的sum()
,min()
,max()
,还有 ReduceFunction
,FoldFunction
,还有WindowFunction
。WindowFunction 是最通用的计算函数,其他的预定义的函数基本都是基于该函数实现的。
Flink 对于一些聚合类的窗口计算(如sum,min)做了优化,因为聚合类的计算不需要将窗口中的所有数据都保存下来,只需要保存一个result值就可以了。每个进入窗口的元素都会执行一次聚合函数并修改result值。这样可以大大降低内存的消耗并提升性能。但是如果用户定义了 Evictor,则不会启用对聚合窗口的优化,因为 Evictor 需要遍历窗口中的所有元素,必须要将窗口中所有元素都存下来。
上述的三个组件构成了 Flink 的窗口机制。为了更清楚地描述窗口机制,以及解开一些疑惑(比如 purge 和 Evictor 的区别和用途),我们将一步步地解释 Flink 内置的一些窗口(Time Window,Count Window,Session Window)是如何实现的。
Count Window 是使用三组件的典范,我们可以在 KeyedStream
上创建 Count Window,其源码如下所示:
// tumbling count window |
第一个函数是申请翻滚计数窗口,参数为窗口大小。第二个函数是申请滑动计数窗口,参数分别为窗口大小和滑动大小。它们都是基于 GlobalWindows
这个 WindowAssigner 来创建的窗口,该assigner会将所有元素都分配到同一个global window中,所有GlobalWindows
的返回值一直是 GlobalWindow
单例。基本上自定义的窗口都会基于该assigner实现。
翻滚计数窗口并不带evictor,只注册了一个trigger。该trigger是带purge功能的 CountTrigger。也就是说每当窗口中的元素数量达到了 window-size,trigger就会返回fire+purge,窗口就会执行计算并清空窗口中的所有元素,再接着储备新的元素。从而实现了tumbling的窗口之间无重叠。
滑动计数窗口的各窗口之间是有重叠的,但我们用的 GlobalWindows assinger 从始至终只有一个窗口,不像 sliding time assigner 可以同时存在多个窗口。所以trigger结果不能带purge,也就是说计算完窗口后窗口中的数据要保留下来(供下个滑窗使用)。另外,trigger的间隔是slide-size,evictor的保留的元素个数是window-size。也就是说,每个滑动间隔就触发一次窗口计算,并保留下最新进入窗口的window-size个元素,剔除旧元素。
假设有一个滑动计数窗口,每2个元素计算一次最近4个元素的总和,那么窗口工作示意图如下所示:
图中所示的各个窗口逻辑上是不同的窗口,但在物理上是同一个窗口。该滑动计数窗口,trigger的触发条件是元素个数达到2个(每进入2个元素就会触发一次),evictor保留的元素个数是4个,每次计算完窗口总和后会保留剩余的元素。所以第一次触发trigger是当元素5进入,第三次触发trigger是当元素2进入,并驱逐5和2,计算剩余的4个元素的总和(22)并发送出去,保留下2,4,9,7元素供下个逻辑窗口使用。
同样的,我们也可以在 KeyedStream
上申请 Time Window,其源码如下所示:
// tumbling time window |
在方法体内部会根据当前环境注册的时间类型,使用不同的WindowAssigner创建window。可以看到,EventTime和IngestTime都使用了XXXEventTimeWindows
这个assigner,因为EventTime和IngestTime在底层的实现上只是在Source处为Record打时间戳的实现不同,在window operator中的处理逻辑是一样的。
这里我们主要分析sliding process time window,如下是相关源码:
public class SlidingProcessingTimeWindows extends WindowAssigner<Object, TimeWindow> { |
首先,SlidingProcessingTimeWindows
会对每个进入窗口的元素根据系统时间分配到(size / slide)
个不同的窗口,并会在每个窗口上根据窗口结束时间注册一个定时器(相同窗口只会注册一份),当定时器超时时意味着该窗口完成了,这时会回调对应窗口的Trigger的onProcessingTime
方法,返回FIRE_AND_PURGE,也就是会执行窗口计算并清空窗口。整个过程示意图如下:
如上图所示横轴代表时间戳(为简化问题,时间戳从0开始),第一条record会被分配到[-5,5)和[0,10)两个窗口中,当系统时间到5时,就会计算[-5,5)窗口中的数据,并将结果发送出去,最后清空窗口中的数据,释放该窗口资源。
Session Window 是一个需求很强烈的窗口机制,但Session也比之前的Window更复杂,所以 Flink 也是在即将到来的 1.1.0 版本中才支持了该功能。由于篇幅问题,我们将在后续的 Session Window 的实现 中深入探讨 Session Window 的实现。
DataStream
是 Flink 流处理 API 中最核心的数据结构。它代表了一个运行在多个分区上的并行流。一个 DataStream
可以从 StreamExecutionEnvironment
通过env.addSource(SourceFunction)
获得。
DataStream 上的转换操作都是逐条的,比如 map()
,flatMap()
,filter()
。DataStream 也可以执行 rebalance
(再平衡,用来减轻数据倾斜)和 broadcaseted
(广播)等分区转换。
val stream: DataStream[MyType] = env.addSource(new FlinkKafkaConsumer08[String](...)) |
上述 DataStream 上的转换在运行时会转换成如下的执行图:
如上图的执行图所示,DataStream 各个算子会并行运行,算子之间是数据流分区。如 Source 的第一个并行实例(S1)和 flatMap() 的第一个并行实例(m1)之间就是一个数据流分区。而在 flatMap() 和 map() 之间由于加了 rebalance(),它们之间的数据流分区就有3个子分区(m1的数据流向3个map()实例)。这与 Apache Kafka 是很类似的,把流想象成 Kafka Topic,而一个流分区就表示一个 Topic Partition,流的目标并行算子实例就是 Kafka Consumers。
KeyedStream
用来表示根据指定的key进行分组的数据流。一个KeyedStream
可以通过调用DataStream.keyBy()
来获得。而在KeyedStream
上进行任何transformation都将转变回DataStream
。在实现中,KeyedStream
是把key的信息写入到了transformation中。每条记录只能访问所属key的状态,其上的聚合函数可以方便地操作和保存对应key的状态。
WindowedStream
代表了根据key分组,并且基于WindowAssigner
切分窗口的数据流。所以WindowedStream
都是从KeyedStream
衍生而来的。而在WindowedStream
上进行任何transformation也都将转变回DataStream
。
val stream: DataStream[MyType] = ... |
上述 WindowedStream 的样例代码在运行时会转换成如下的执行图:
Flink 的窗口实现中会将到达的数据缓存在对应的窗口buffer中(一个数据可能会对应多个窗口)。当到达窗口发送的条件时(由Trigger控制),Flink 会对整个窗口中的数据进行处理。Flink 在聚合类窗口有一定的优化,即不会保存窗口中的所有值,而是每到一个元素执行一次聚合函数,最终只保存一份数据即可。
在key分组的流上进行窗口切分是比较常用的场景,也能够很好地并行化(不同的key上的窗口聚合可以分配到不同的task去处理)。不过有时候我们也需要在普通流上进行窗口的操作,这就是 AllWindowedStream
。AllWindowedStream
是直接在DataStream
上进行windowAll(...)
操作。AllWindowedStream 的实现是基于 WindowedStream 的(Flink 1.1.x 开始)。Flink 不推荐使用AllWindowedStream
,因为在普通流上进行窗口操作,就势必需要将所有分区的流都汇集到单个的Task中,而这个单个的Task很显然就会成为整个Job的瓶颈。
双流 Join 也是一个非常常见的应用场景。深入源码你可以发现,JoinedStreams 和 CoGroupedStreams 的代码实现有80%是一模一样的,JoinedStreams 在底层又调用了 CoGroupedStreams 来实现 Join 功能。除了名字不一样,一开始很难将它们区分开来,而且为什么要提供两个功能类似的接口呢??
实际上这两者还是很点区别的。首先 co-group 侧重的是group,是对同一个key上的两组集合进行操作,而 join 侧重的是pair,是对同一个key上的每对元素进行操作。co-group 比 join 更通用一些,因为 join 只是 co-group 的一个特例,所以 join 是可以基于 co-group 来实现的(当然有优化的空间)。而在 co-group 之外又提供了 join 接口是因为用户更熟悉 join(源于数据库吧),而且能够跟 DataSet API 保持一致,降低用户的学习成本。
JoinedStreams 和 CoGroupedStreams 是基于 Window 上实现的,所以 CoGroupedStreams 最终又调用了 WindowedStream 来实现。
val firstInput: DataStream[MyType] = ... |
上述 JoinedStreams 的样例代码在运行时会转换成如下的执行图:
双流上的数据在同一个key的会被分别分配到同一个window窗口的左右两个篮子里,当window结束的时候,会对左右篮子进行笛卡尔积从而得到每一对pair,对每一对pair应用 JoinFunction。不过目前(Flink 1.1.x)JoinedStreams 只是简单地实现了流上的join操作而已,距离真正的生产使用还是有些距离。因为目前 join 窗口的双流数据都是被缓存在内存中的,也就是说如果某个key上的窗口数据太多就会导致 JVM OOM(然而数据倾斜是常态)。双流join的难点也正是在这里,这也是社区后面对 join 操作的优化方向,例如可以借鉴Flink在批处理join中的优化方案,也可以用ManagedMemory来管理窗口中的数据,并当数据超过阈值时能spill到硬盘。
在 DataStream 上有一个 union 的转换 dataStream.union(otherStream1, otherStream2, ...)
,用来合并多个流,新的流会包含所有流中的数据。union 有一个限制,就是所有合并的流的类型必须是一致的。ConnectedStreams
提供了和 union 类似的功能,用来连接两个流,但是与 union 转换有以下几个区别:
如下 ConnectedStreams 的样例,连接 input
和 other
流,并在input
流上应用map1
方法,在other
上应用map2
方法,双流可以共享状态(比如计数)。
val input: DataStream[MyType] = ... |
当并行度为2时,其执行图如下所示:
本文介绍通过不同数据流类型的转换图来解释每一种数据流的含义、转换关系。后面的文章会深入讲解 Window 机制的实现,双流 Join 的实现等。
]]>StreamGraph 和 JobGraph 都是在 Client 端生成的,也就是说我们可以在 IDE 中通过断点调试观察 StreamGraph 和 JobGraph 的生成过程。
JobGraph 的相关数据结构主要在 org.apache.flink.runtime.jobgraph
包中。构造 JobGraph 的代码主要集中在 StreamingJobGraphGenerator
类中,入口函数是 StreamingJobGraphGenerator.createJobGraph()
。我们首先来看下StreamingJobGraphGenerator
的核心源码:
public class StreamingJobGraphGenerator { |
StreamingJobGraphGenerator
的成员变量都是为了辅助生成最终的JobGraph。createJobGraph()
函数的逻辑也很清晰,首先为所有节点生成一个唯一的hash id,如果节点在多次提交中没有改变(包括并发度、上下游等),那么这个id就不会改变,这主要用于故障恢复。这里我们不能用 StreamNode.id
来代替,因为这是一个从1开始的静态计数变量,同样的Job可能会得到不一样的id,如下代码示例的两个job是完全一样的,但是source的id却不一样了。然后就是最关键的chaining处理,和生成JobVetex、JobEdge等。之后就是写入各种配置相关的信息。
// 范例1:A.id=1 B.id=2 |
下面具体分析下关键函数 setChaining
的实现:
// 从source开始建立 node chains |
每个 JobVertex 都会对应一个可序列化的 StreamConfig, 用来发送给 JobManager 和 TaskManager。最后在 TaskManager 中起 Task 时,需要从这里面反序列化出所需要的配置信息, 其中就包括了含有用户代码的StreamOperator。
setChaining
会对source调用createChain
方法,该方法会递归调用下游节点,从而构建出node chains。createChain
会分析当前节点的出边,根据Operator Chains中的chainable条件,将出边分成chainalbe和noChainable两类,并分别递归调用自身方法。之后会将StreamNode中的配置信息序列化到StreamConfig中。如果当前不是chain中的子节点,则会构建 JobVertex 和 JobEdge相连。如果是chain中的子节点,则会将StreamConfig添加到该chain的config集合中。一个node chains,除了 headOfChain node会生成对应的 JobVertex,其余的nodes都是以序列化的形式写入到StreamConfig中,并保存到headOfChain的 CHAINED_TASK_CONFIG
配置项中。直到部署时,才会取出并生成对应的ChainOperators,具体过程请见理解 Operator Chains。
本文主要对 Flink 中将 StreamGraph 转变成 JobGraph 的核心源码进行了分析。思想还是很简单的,StreamNode 转成 JobVertex,StreamEdge 转成 JobEdge,JobEdge 和 JobVertex 之间创建 IntermediateDataSet 来连接。关键点在于将多个 SteamNode chain 成一个 JobVertex的过程,这部分源码比较绕,有兴趣的同学可以结合源码单步调试分析。下一章将会介绍 JobGraph 提交到 JobManager 后是如何转换成分布式化的 ExecutionGraph 的。
]]>为了更高效地分布式执行,Flink会尽可能地将operator的subtask链接(chain)在一起形成task。每个task在一个线程中执行。将operators链接成task是非常有效的优化:它能减少线程之间的切换,减少消息的序列化/反序列化,减少数据在缓冲区的交换,减少了延迟的同时提高整体的吞吐量。
我们仍以经典的 WordCount 为例(参考前文Job例子),下面这幅图,展示了Source并行度为1,FlatMap、KeyAggregation、Sink并行度均为2,最终以5个并行的线程来执行的优化过程。
上图中将KeyAggregation和Sink两个operator进行了合并,因为这两个合并后并不会改变整体的拓扑结构。但是,并不是任意两个 operator 就能 chain 一起的。其条件还是很苛刻的:
Operator chain的行为可以通过编程API中进行指定。可以通过在DataStream的operator后面(如someStream.map(..)
)调用startNewChain()
来指示从该operator开始一个新的chain(与前面截断,不会被chain到前面)。或者调用disableChaining()
来指示该operator不参与chaining(不会与前后的operator chain一起)。在底层,这两个方法都是通过调整operator的 chain 策略(HEAD、NEVER)来实现的。另外,也可以通过调用StreamExecutionEnvironment.disableOperatorChaining()
来全局禁用chaining。
那么 Flink 是如何将多个 operators chain在一起的呢?chain在一起的operators是如何作为一个整体被执行的呢?它们之间的数据流又是如何避免了序列化/反序列化以及网络传输的呢?下图展示了operators chain的内部实现:
如上图所示,Flink内部是通过OperatorChain
这个类来将多个operator链在一起形成一个新的operator。OperatorChain
形成的框框就像一个黑盒,Flink 无需知道黑盒中有多少个ChainOperator、数据在chain内部是怎么流动的,只需要将input数据交给 HeadOperator 就可以了,这就使得OperatorChain
在行为上与普通的operator无差别,上面的OperaotrChain就可以看做是一个入度为1,出度为2的operator。所以在实现中,对外可见的只有HeadOperator,以及与外部连通的实线输出,这些输出对应了JobGraph中的JobEdge,在底层通过RecordWriterOutput
来实现。另外,框中的虚线是operator chain内部的数据流,这个流内的数据不会经过序列化/反序列化、网络传输,而是直接将消息对象传递给下游的 ChainOperator 处理,这是性能提升的关键点,在底层是通过 ChainingOutput
实现的,源码如下方所示,
注:HeadOperator和ChainOperator并不是具体的数据结构,前者指代chain中的第一个operator,后者指代chain中其余的operator,它们实际上都是StreamOperator
。
private static class ChainingOutput<T> implements Output<StreamRecord<T>> { |
在架构概览中我们介绍了 TaskManager 是一个 JVM 进程,并会以独立的线程来执行一个task或多个subtask。为了控制一个 TaskManager 能接受多少个 task,Flink 提出了 Task Slot 的概念。
Flink 中的计算资源通过 Task Slot 来定义。每个 task slot 代表了 TaskManager 的一个固定大小的资源子集。例如,一个拥有3个slot的 TaskManager,会将其管理的内存平均分成三分分给各个 slot。将资源 slot 化意味着来自不同job的task不会为了内存而竞争,而是每个task都拥有一定数量的内存储备。需要注意的是,这里不会涉及到CPU的隔离,slot目前仅仅用来隔离task的内存。
通过调整 task slot 的数量,用户可以定义task之间是如何相互隔离的。每个 TaskManager 有一个slot,也就意味着每个task运行在独立的 JVM 中。每个 TaskManager 有多个slot的话,也就是说多个task运行在同一个JVM中。而在同一个JVM进程中的task,可以共享TCP连接(基于多路复用)和心跳消息,可以减少数据的网络传输。也能共享一些数据结构,一定程度上减少了每个task的消耗。
每一个 TaskManager 会拥有一个或多个的 task slot,每个 slot 都能跑由多个连续 task 组成的一个 pipeline,比如 MapFunction 的第n个并行实例和 ReduceFunction 的第n个并行实例可以组成一个 pipeline。
如上文所述的 WordCount 例子,5个Task可能会在TaskManager的slots中如下图分布,2个TaskManager,每个有3个slot:
默认情况下,Flink 允许subtasks共享slot,条件是它们都来自同一个Job的不同task的subtask。结果可能一个slot持有该job的整个pipeline。允许slot共享有以下两点好处:
我们将 WordCount 的并行度从之前的2个增加到6个(Source并行度仍为1),并开启slot共享(所有operator都在default共享组),将得到如上图所示的slot分布图。首先,我们不用去计算这个job会其多少个task,总之该任务最终会占用6个slots(最高并行度为6)。其次,我们可以看到密集型操作 keyAggregation/sink 被平均地分配到各个 TaskManager。
SlotSharingGroup
是Flink中用来实现slot共享的类,它尽可能地让subtasks共享一个slot。相应的,还有一个 CoLocationGroup
类用来强制将 subtasks 放到同一个 slot 中。CoLocationGroup
主要用于迭代流中,用来保证迭代头与迭代尾的第i个subtask能被调度到同一个TaskManager上。这里我们不会详细讨论CoLocationGroup
的实现细节。
怎么判断operator属于哪个 slot 共享组呢?默认情况下,所有的operator都属于默认的共享组default
,也就是说默认情况下所有的operator都是可以共享一个slot的。而当所有input operators具有相同的slot共享组时,该operator会继承这个共享组。最后,为了防止不合理的共享,用户也能通过API来强制指定operator的共享组,比如:someStream.filter(...).slotSharingGroup("group1");
就强制指定了filter的slot共享组为group1
。
那么多个tasks(或者说operators)是如何共享slot的呢?
我们先来看一下用来定义计算资源的slot的类图:
抽象类Slot
定义了该槽位属于哪个TaskManager(instance
)的第几个槽位(slotNumber
),属于哪个Job(jobID
)等信息。最简单的情况下,一个slot只持有一个task,也就是SimpleSlot
的实现。复杂点的情况,一个slot能共享给多个task使用,也就是SharedSlot
的实现。SharedSlot能包含其他的SharedSlot,也能包含SimpleSlot。所以一个SharedSlot能定义出一棵slots树。
接下来我们来看看 Flink 为subtask分配slot的过程。关于Flink调度,有两个非常重要的原则我们必须知道:(1)同一个operator的各个subtask是不能呆在同一个SharedSlot中的,例如FlatMap[1]
和FlatMap[2]
是不能在同一个SharedSlot中的。(2)Flink是按照拓扑顺序从Source一个个调度到Sink的。例如WordCount(Source并行度为1,其他并行度为2),那么调度的顺序依次是:Source
-> FlatMap[1]
-> FlatMap[2]
-> KeyAgg->Sink[1]
-> KeyAgg->Sink[2]
。假设现在有2个TaskManager,每个只有1个slot(为简化问题),那么分配slot的过程如图所示:
注:图中 SharedSlot 与 SimpleSlot 后带的括号中的数字代表槽位号(slotNumber)
Source
分配slot。首先,我们从TaskManager1中分配出一个SharedSlot。并从SharedSlot中为Source
分配出一个SimpleSlot。如上图中的①和②。FlatMap[1]
分配slot。目前已经有一个SharedSlot,则从该SharedSlot中分配出一个SimpleSlot用来部署FlatMap[1]
。如上图中的③。FlatMap[2]
分配slot。由于TaskManager1的SharedSlot中已经有同operator的FlatMap[1]
了,我们只能分配到其他SharedSlot中去。从TaskManager2中分配出一个SharedSlot,并从该SharedSlot中为FlatMap[2]
分配出一个SimpleSlot。如上图的④和⑤。Key->Sink[1]
分配slot。目前两个SharedSlot都符合条件,从TaskManager1的SharedSlot中分配出一个SimpleSlot用来部署Key->Sink[1]
。如上图中的⑥。Key->Sink[2]
分配slot。TaskManager1的SharedSlot中已经有同operator的Key->Sink[1]
了,则只能选择另一个SharedSlot中分配出一个SimpleSlot用来部署Key->Sink[2]
。如上图中的⑦。最后Source
、FlatMap[1]
、Key->Sink[1]
这些subtask都会部署到TaskManager1的唯一一个slot中,并启动对应的线程。FlatMap[2]
、Key->Sink[2]
这些subtask都会被部署到TaskManager2的唯一一个slot中,并启动对应的线程。从而实现了slot共享。
本文主要介绍了Flink中计算资源的相关概念以及原理实现。最核心的是 Task Slot,每个slot能运行一个或多个task。为了拓扑更高效地运行,Flink提出了Chaining,尽可能地将operators chain在一起作为一个task来处理。为了资源更充分的利用,Flink又提出了SlotSharingGroup,尽可能地让多个task共享一个slot。
注:本文比较偏源码分析,所有代码都是基于 flink-1.0.x 版本,建议在阅读本文前先对Stream API有个了解,详见官方文档。
StreamGraph 相关的代码主要在 org.apache.flink.streaming.api.graph
包中。构造StreamGraph的入口函数是 StreamGraphGenerator.generate(env, transformations)
。该函数会由触发程序执行的方法StreamExecutionEnvironment.execute()
调用到。也就是说 StreamGraph 是在 Client 端构造的,这也意味着我们可以在本地通过调试观察 StreamGraph 的构造过程。
StreamGraphGenerator.generate
的一个关键的参数是 List<StreamTransformation<?>>
。StreamTransformation
代表了从一个或多个DataStream
生成新DataStream
的操作。DataStream
的底层其实就是一个 StreamTransformation
,描述了这个DataStream
是怎么来的。
StreamTransformation的类图如下图所示:
DataStream 上常见的 transformation 有 map、flatmap、filter等(见DataStream Transformation了解更多)。这些transformation会构造出一棵 StreamTransformation 树,通过这棵树转换成 StreamGraph。比如 DataStream.map
源码如下,其中SingleOutputStreamOperator
为DataStream的子类:
public <R> SingleOutputStreamOperator<R> map(MapFunction<T, R> mapper) { |
从上方代码可以了解到,map转换将用户自定义的函数MapFunction
包装到StreamMap
这个Operator中,再将StreamMap
包装到OneInputTransformation
,最后该transformation存到env中,当调用env.execute
时,遍历其中的transformation集合构造出StreamGraph。其分层实现如下图所示:
另外,并不是每一个 StreamTransformation 都会转换成 runtime 层中物理操作。有一些只是逻辑概念,比如 union、split/select、partition等。如下图所示的转换树,在运行时会优化成下方的操作图。
union、split/select、partition中的信息会被写入到 Source –> Map 的边中。通过源码也可以发现,UnionTransformation
,SplitTransformation
,SelectTransformation
,PartitionTransformation
由于不包含具体的操作所以都没有StreamOperator成员变量,而其他StreamTransformation的子类基本上都有。
DataStream 上的每一个 Transformation 都对应了一个 StreamOperator,StreamOperator是运行时的具体实现,会决定UDF(User-Defined Funtion)的调用方式。下图所示为 StreamOperator 的类图(点击查看大图):
可以发现,所有实现类都继承了AbstractStreamOperator
。另外除了 project 操作,其他所有可以执行UDF代码的实现类都继承自AbstractUdfStreamOperator
,该类是封装了UDF的StreamOperator。UDF就是实现了Function
接口的类,如MapFunction
,FilterFunction
。
我们通过在DataStream上做了一系列的转换(map、filter等)得到了StreamTransformation集合,然后通过StreamGraphGenerator.generate
获得StreamGraph,该方法的源码如下:
// 构造 StreamGraph 入口函数 |
最终都会调用 transformXXX
来对具体的StreamTransformation进行转换。我们可以看下transformOnInputTransform(transform)
的实现:
private <IN, OUT> Collection<Integer> transformOnInputTransform(OneInputTransformation<IN, OUT> transform) { |
该函数首先会对该transform的上游transform进行递归转换,确保上游的都已经完成了转化。然后通过transform构造出StreamNode,最后与上游的transform进行连接,构造出StreamNode。
最后再来看下对逻辑转换(partition、union等)的处理,如下是transformPartition
函数的源码:
private <T> Collection<Integer> transformPartition(PartitionTransformation<T> partition) { |
对partition的转换没有生成具体的StreamNode和StreamEdge,而是添加一个虚节点。当partition的下游transform(如map)添加edge时(调用StreamGraph.addEdge
),会把partition信息写入到edge中。如StreamGraph.addEdgeInternal
所示:
public void addEdge(Integer upStreamVertexID, Integer downStreamVertexID, int typeNumber) { |
如下程序,是一个从 Source 中按行切分成单词并过滤输出的简单流程序,其中包含了逻辑转换:随机分区shuffle。我们会分析该程序是如何生成StreamGraph的。
DataStream<String> text = env.socketTextStream(hostName, port); |
首先会在env中生成一棵transformation树,用List<StreamTransformation<?>>
保存。其结构图如下:
其中符号*
为input指针,指向上游的transformation,从而形成了一棵transformation树。然后,通过调用StreamGraphGenerator.generate(env, transformations)
来生成StreamGraph。自底向上递归调用每一个transformation,也就是说处理顺序是Source->FlatMap->Shuffle->Filter->Sink。
如上图所示:
virtuaPartitionNodes
中。最后可以通过 UI可视化 来观察得到的 StreamGraph。
本文主要介绍了 Stream API 中 Transformation 和 Operator 的概念,以及如何根据Stream API编写的程序,构造出一个代表拓扑结构的StreamGraph的。本文的源码分析涉及到较多代码,如果有兴趣建议结合完整源码进行学习。下一篇文章将介绍 StreamGraph 如何转换成 JobGraph 的,其中设计到了图优化的技巧。
]]>要了解一个系统,一般都是从架构开始。我们关心的问题是:系统部署成功后各个节点都启动了哪些服务,各个服务之间又是怎么交互和协调的。下方是 Flink 集群启动后架构图。
当 Flink 集群启动后,首先会启动一个 JobManger 和一个或多个的 TaskManager。由 Client 提交任务给 JobManager,JobManager 再调度任务到各个 TaskManager 去执行,然后 TaskManager 将心跳和统计信息汇报给 JobManager。TaskManager 之间以流的形式进行数据的传输。上述三者均为独立的 JVM 进程。
可以看到 Flink 的任务调度是多线程模型,并且不同Job/Task混合在一个 TaskManager 进程中。虽然这种方式可以有效提高 CPU 利用率,但是个人不太喜欢这种设计,因为不仅缺乏资源隔离机制,同时也不方便调试。类似 Storm 的进程模型,一个JVM 中只跑该 Job 的 Tasks 实际应用中更为合理。
本文所示例子为 flink-1.0.x 版本
我们使用 Flink 自带的 examples 包中的 SocketTextStreamWordCount
,这是一个从 socket 流中统计单词出现次数的例子。
首先,使用 netcat 启动本地服务器:
$ nc -l 9000 |
然后提交 Flink 程序
$ bin/flink run examples/streaming/SocketTextStreamWordCount.jar \ |
在netcat端输入单词并监控 taskmanager 的输出可以看到单词统计的结果。
SocketTextStreamWordCount
的具体代码如下:
public static void main(String[] args) throws Exception { |
我们将最后一行代码 env.execute
替换成 System.out.println(env.getExecutionPlan());
并在本地运行该代码(并发度设为2),可以得到该拓扑的逻辑执行计划图的 JSON 串,将该 JSON 串粘贴到 http://flink.apache.org/visualizer/ 中,能可视化该执行图。
但这并不是最终在 Flink 中运行的执行图,只是一个表示拓扑节点关系的计划图,在 Flink 中对应了 SteramGraph。另外,提交拓扑后(并发度设为2)还能在 UI 中看到另一张执行计划图,如下所示,该图对应了 Flink 中的 JobGraph。
看起来有点乱,怎么有这么多不一样的图。实际上,还有更多的图。Flink 中的执行图可以分成四层:StreamGraph -> JobGraph -> ExecutionGraph -> 物理执行图。
例如上文中的2个并发度(Source为1个并发度)的 SocketTextStreamWordCount
四层执行图的演变过程如下图所示(点击查看大图):
这里对一些名词进行简单的解释。
那么 Flink 为什么要设计这4张图呢,其目的是什么呢?Spark 中也有多张图,数据依赖图以及物理执行的DAG。其目的都是一样的,就是解耦,每张图各司其职,每张图对应了 Job 不同的阶段,更方便做该阶段的事情。我们给出更完整的 Flink Graph 的层次图。
首先我们看到,JobGraph 之上除了 StreamGraph 还有 OptimizedPlan。OptimizedPlan 是由 Batch API 转换而来的。StreamGraph 是由 Stream API 转换而来的。为什么 API 不直接转换成 JobGraph?因为,Batch 和 Stream 的图结构和优化方法有很大的区别,比如 Batch 有很多执行前的预分析用来优化图的执行,而这种优化并不普适于 Stream,所以通过 OptimizedPlan 来做 Batch 的优化会更方便和清晰,也不会影响 Stream。JobGraph 的责任就是统一 Batch 和 Stream 的图,用来描述清楚一个拓扑图的结构,并且做了 chaining 的优化,chaining 是普适于 Batch 和 Stream 的,所以在这一层做掉。ExecutionGraph 的责任是方便调度和各个 tasks 状态的监控和跟踪,所以 ExecutionGraph 是并行化的 JobGraph。而“物理执行图”就是最终分布式在各个机器上运行着的tasks了。所以可以看到,这种解耦方式极大地方便了我们在各个层所做的工作,各个层之间是相互隔离的。
后续的文章,将会详细介绍 Flink 是如何生成这些执行图的。由于我目前关注 Flink 的流处理功能,所以主要有以下内容:
所以目前,越来越多的大数据项目开始自己管理JVM内存了,像 Spark、Flink、HBase,为的就是获得像 C 一样的性能以及避免 OOM 的发生。本文将会讨论 Flink 是如何解决上面的问题的,主要内容包括内存管理、定制的序列化工具、缓存友好的数据结构和算法、堆外内存、JIT编译优化等。
Flink 并不是将大量对象存在堆上,而是将对象都序列化到一个预分配的内存块上,这个内存块叫做 MemorySegment
,它代表了一段固定长度的内存(默认大小为 32KB),也是 Flink 中最小的内存分配单元,并且提供了非常高效的读写方法。你可以把 MemorySegment 想象成是为 Flink 定制的 java.nio.ByteBuffer
。它的底层可以是一个普通的 Java 字节数组(byte[]
),也可以是一个申请在堆外的 ByteBuffer
。每条记录都会以序列化的形式存储在一个或多个MemorySegment
中。
Flink 中的 Worker 名叫 TaskManager,是用来运行用户代码的 JVM 进程。TaskManager 的堆内存主要被分成了三个部分:
taskmanager.network.numberOfBuffers
来配置。(阅读这篇文章了解更多Network Buffer的管理)MemoryManager
管理的,由众多MemorySegment
组成的超大集合。Flink 中的算法(如 sort/shuffle/join)会向这个内存池申请 MemorySegment,将序列化后的数据存于其中,使用完后释放回内存池。默认情况下,池子占了堆内存的 70% 的大小。注意:Memory Manager Pool 主要在Batch模式下使用。在Steaming模式下,该池子不会预分配内存,也不会向该池子请求内存块。也就是说该部分的内存都是可以给用户代码使用的。不过社区是打算在 Streaming 模式下也能将该池子利用起来。
Flink 采用类似 DBMS 的 sort 和 join 算法,直接操作二进制数据,从而使序列化/反序列化带来的开销达到最小。所以 Flink 的内部实现更像 C/C++ 而非 Java。如果需要处理的数据超出了内存限制,则会将部分数据存储到硬盘上。如果要操作多块MemorySegment就像操作一块大的连续内存一样,Flink会使用逻辑视图(AbstractPagedInputView
)来方便操作。下图描述了 Flink 如何存储序列化后的数据到内存块中,以及在需要的时候如何将数据存储到磁盘上。
从上面我们能够得出 Flink 积极的内存管理以及直接操作二进制数据有以下几点好处:
MemoryManager
中,这些MemorySegment
一直呆在老年代而不会被GC回收。其他的数据对象基本上是由用户代码生成的短生命周期对象,这部分对象可以被 Minor GC 快速回收。只要用户不去创建大量类似缓存的常驻型对象,那么老年代的大小是不会变的,Major GC也就永远不会发生。从而有效地降低了垃圾回收的压力。另外,这里的内存块还可以是堆外内存,这可以使得 JVM 内存更小,从而加速垃圾回收。OutOfMemoryErrors
可以有效地被避免。目前 Java 生态圈提供了众多的序列化框架:Java serialization, Kryo, Apache Avro 等等。但是 Flink 实现了自己的序列化框架。因为在 Flink 中处理的数据流通常是同一类型,由于数据集对象的类型固定,对于数据集可以只保存一份对象Schema信息,节省大量的存储空间。同时,对于固定大小的类型,也可通过固定的偏移位置存取。当我们需要访问某个对象成员变量的时候,通过定制的序列化工具,并不需要反序列化整个Java对象,而是可以直接通过偏移量,只是反序列化特定的对象成员变量。如果对象的成员变量较多时,能够大大减少Java对象的创建开销,以及内存数据的拷贝大小。
Flink支持任意的Java或是Scala类型。Flink 在数据类型上有很大的进步,不需要实现一个特定的接口(像Hadoop中的org.apache.hadoop.io.Writable
),Flink 能够自动识别数据类型。Flink 通过 Java Reflection 框架分析基于 Java 的 Flink 程序 UDF (User Define Function)的返回类型的类型信息,通过 Scala Compiler 分析基于 Scala 的 Flink 程序 UDF 的返回类型的类型信息。类型信息由 TypeInformation
类表示,TypeInformation 支持以下几种类型:
BasicTypeInfo
: 任意Java 基本类型(装箱的)或 String 类型。BasicArrayTypeInfo
: 任意Java基本类型数组(装箱的)或 String 数组。WritableTypeInfo
: 任意 Hadoop Writable 接口的实现类。TupleTypeInfo
: 任意的 Flink Tuple 类型(支持Tuple1 to Tuple25)。Flink tuples 是固定长度固定类型的Java Tuple实现。CaseClassTypeInfo
: 任意的 Scala CaseClass(包括 Scala tuples)。PojoTypeInfo
: 任意的 POJO (Java or Scala),例如,Java对象的所有成员变量,要么是 public 修饰符定义,要么有 getter/setter 方法。GenericTypeInfo
: 任意无法匹配之前几种类型的类。前六种数据类型基本上可以满足绝大部分的Flink程序,针对前六种类型数据集,Flink皆可以自动生成对应的TypeSerializer,能非常高效地对数据集进行序列化和反序列化。对于最后一种数据类型,Flink会使用Kryo进行序列化和反序列化。每个TypeInformation中,都包含了serializer,类型会自动通过serializer进行序列化,然后用Java Unsafe接口写入MemorySegments。对于可以用作key的数据类型,Flink还同时自动生成TypeComparator,用来辅助直接对序列化后的二进制数据进行compare、hash等操作。对于 Tuple、CaseClass、POJO 等组合类型,其TypeSerializer和TypeComparator也是组合的,序列化和比较时会委托给对应的serializers和comparators。如下图展示 一个内嵌型的Tuple3<Integer,Double,Person> 对象的序列化过程。
可以看出这种序列化方式存储密度是相当紧凑的。其中 int 占4字节,double 占8字节,POJO多个一个字节的header,PojoSerializer只负责将header序列化进去,并委托每个字段对应的serializer对字段进行序列化。
Flink 的类型系统可以很轻松地扩展出自定义的TypeInformation、Serializer以及Comparator,来提升数据类型在序列化和比较时的性能。
Flink 提供了如 group、sort、join 等操作,这些操作都需要访问海量数据。这里,我们以sort为例,这是一个在 Flink 中使用非常频繁的操作。
首先,Flink 会从 MemoryManager 中申请一批 MemorySegment,我们把这批 MemorySegment 称作 sort buffer,用来存放排序的数据。
我们会把 sort buffer 分成两块区域。一个区域是用来存放所有对象完整的二进制数据。另一个区域用来存放指向完整二进制数据的指针以及定长的序列化后的key(key+pointer)。如果需要序列化的key是个变长类型,如String,则会取其前缀序列化。如上图所示,当一个对象要加到 sort buffer 中时,它的二进制数据会被加到第一个区域,指针(可能还有key)会被加到第二个区域。
将实际的数据和指针加定长key分开存放有两个目的。第一,交换定长块(key+pointer)更高效,不用交换真实的数据也不用移动其他key和pointer。第二,这样做是缓存友好的,因为key都是连续存储在内存中的,可以大大减少 cache miss(后面会详细解释)。
排序的关键是比大小和交换。Flink 中,会先用 key 比大小,这样就可以直接用二进制的key比较而不需要反序列化出整个对象。因为key是定长的,所以如果key相同(或者没有提供二进制key),那就必须将真实的二进制数据反序列化出来,然后再做比较。之后,只需要交换key+pointer就可以达到排序的效果,真实的数据不用移动。
最后,访问排序后的数据,可以沿着排好序的key+pointer区域顺序访问,通过pointer找到对应的真实数据,并写到内存或外部(更多细节可以看这篇文章 Joins in Flink)。
随着磁盘IO和网络IO越来越快,CPU逐渐成为了大数据领域的瓶颈。从 L1/L2/L3 缓存读取数据的速度比从主内存读取数据的速度快好几个量级。通过性能分析可以发现,CPU时间中的很大一部分都是浪费在等待数据从主内存过来上。如果这些数据可以从 L1/L2/L3 缓存过来,那么这些等待时间可以极大地降低,并且所有的算法会因此而受益。
在上面讨论中我们谈到的,Flink 通过定制的序列化框架将算法中需要操作的数据(如sort中的key)连续存储,而完整数据存储在其他地方。因为对于完整的数据来说,key+pointer更容易装进缓存,这大大提高了缓存命中率,从而提高了基础算法的效率。这对于上层应用是完全透明的,可以充分享受缓存友好带来的性能提升。
Flink 基于堆内存的内存管理机制已经可以解决很多JVM现存问题了,为什么还要引入堆外内存?
但是强大的东西总是会有其负面的一面,不然为何大家不都用堆外内存呢。
MemorySegment
,这个申请在堆上会更廉价。Flink用通过ByteBuffer.allocateDirect(numBytes)
来申请堆外内存,用 sun.misc.Unsafe
来操作堆外内存。
基于 Flink 优秀的设计,实现堆外内存是很方便的。Flink 将原来的 MemorySegment
变成了抽象类,并生成了两个子类。HeapMemorySegment
和 HybridMemorySegment
。从字面意思上也很容易理解,前者是用来分配堆内存的,后者是用来分配堆外内存和堆内存的。是的,你没有看错,后者既可以分配堆外内存又可以分配堆内存。为什么要这样设计呢?
首先假设HybridMemorySegment
只提供分配堆外内存。在上述堆外内存的不足中的第二点谈到,Flink 有时需要分配短生命周期的 buffer,这些buffer用HeapMemorySegment
会更高效。那么当使用堆外内存时,为了也满足堆内存的需求,我们需要同时加载两个子类。这就涉及到了 JIT 编译优化的问题。因为以前 MemorySegment
是一个单独的 final 类,没有子类。JIT 编译时,所有要调用的方法都是确定的,所有的方法调用都可以被去虚化(de-virtualized)和内联(inlined),这可以极大地提高性能(MemroySegment的使用相当频繁)。然而如果同时加载两个子类,那么 JIT 编译器就只能在真正运行到的时候才知道是哪个子类,这样就无法提前做优化。实际测试的性能差距在 2.7 被左右。
Flink 使用了两种方案:
方案1:只能有一种 MemorySegment 实现被加载
代码中所有的短生命周期和长生命周期的MemorySegment都实例化其中一个子类,另一个子类根本没有实例化过(使用工厂模式来控制)。那么运行一段时间后,JIT 会意识到所有调用的方法都是确定的,然后会做优化。
方案2:提供一种实现能同时处理堆内存和堆外内存
这就是 HybridMemorySegment
了,能同时处理堆与堆外内存,这样就不需要子类了。这里 Flink 优雅地实现了一份代码能同时操作堆和堆外内存。这主要归功于 sun.misc.Unsafe
提供的一系列方法,如getLong方法:
sun.misc.Unsafe.getLong(Object reference, long offset) |
这里我们看下 MemorySegment
及其子类的实现。
public abstract class MemorySegment { |
可以发现,HybridMemorySegment 中的很多方法其实都下沉到了父类去实现。包括堆内堆外内存的初始化。MemorySegment
中的 getXXX
/putXXX
方法都是调用了 unsafe 方法,可以说MemorySegment
已经具有了些 Hybrid 的意思了。HeapMemorySegment
只调用了父类的MemorySegment(byte[] buffer, Object owner)
方法,也就只能申请堆内存。另外,阅读代码你会发现,许多方法(大量的 getXXX/putXXX)都被标记成了 final,两个子类也是 final 类型,为的也是优化 JIT 编译器,会提醒 JIT 这个方法是可以被去虚化和内联的。
对于堆外内存,使用 HybridMemorySegment
能同时用来代表堆和堆外内存。这样只需要一个类就能代表长生命周期的堆外内存和短生命周期的堆内存。既然HybridMemorySegment
已经这么全能,为什么还要方案1呢?因为我们需要工厂模式来保证只有一个子类被加载(为了更高的性能),而且HeapMemorySegment比heap模式的HybridMemorySegment要快。
下方是一些性能测试数据,更详细的数据请参考这篇文章。
Segment | Time |
---|---|
HeapMemorySegment, exclusive | 1,441 msecs |
HeapMemorySegment, mixed | 3,841 msecs |
HybridMemorySegment, heap, exclusive | 1,626 msecs |
HybridMemorySegment, off-heap, exclusive | 1,628 msecs |
HybridMemorySegment, heap, mixed | 3,848 msecs |
HybridMemorySegment, off-heap, mixed | 3,847 msecs |
本文主要总结了 Flink 面对 JVM 存在的问题,而在内存管理的道路上越走越深。从自己管理内存,到序列化框架,再到堆外内存。其实纵观大数据生态圈,其实会发现各个开源项目都有同样的趋势。比如最近炒的很火热的 Spark Tungsten 项目,与 Flink 在内存管理上的思想是及其相似的。
目前主流的流处理系统 Storm/JStorm/Spark Streaming/Flink 都已经提供了反压机制,不过其实现各不相同。
Storm 是通过监控 Bolt 中的接收队列负载情况,如果超过高水位值就会将反压信息写到 Zookeeper ,Zookeeper 上的 watch 会通知该拓扑的所有 Worker 都进入反压状态,最后 Spout 停止发送 tuple。具体实现可以看这个 JIRA STORM-886。
JStorm 认为直接停止 Spout 的发送太过暴力,存在大量问题。当下游出现阻塞时,上游停止发送,下游消除阻塞后,上游又开闸放水,过了一会儿,下游又阻塞,上游又限流,如此反复,整个数据流会一直处在一个颠簸状态。所以 JStorm 是通过逐级降速来进行反压的,效果会较 Storm 更为稳定,但算法也更复杂。另外 JStorm 没有引入 Zookeeper 而是通过 TopologyMaster 来协调拓扑进入反压状态,这降低了 Zookeeper 的负载。
那么 Flink 是怎么处理反压的呢?答案非常简单:Flink 没有使用任何复杂的机制来解决反压问题,因为根本不需要那样的方案!它利用自身作为纯数据流引擎的优势来优雅地响应反压问题。下面我们会深入分析 Flink 是如何在 Task 之间传输数据的,以及数据流如何实现自然降速的。
Flink 在运行时主要由 operators 和 streams 两大组件构成。每个 operator 会消费中间态的流,并在流上进行转换,然后生成新的流。对于 Flink 的网络机制一种形象的类比是,Flink 使用了高效有界的分布式阻塞队列,就像 Java 通用的阻塞队列(BlockingQueue)一样。还记得经典的线程间通信案例:生产者消费者模型吗?使用 BlockingQueue 的话,一个较慢的接受者会降低发送者的发送速率,因为一旦队列满了(有界队列)发送者会被阻塞。Flink 解决反压的方案就是这种感觉。
在 Flink 中,这些分布式阻塞队列就是这些逻辑流,而队列容量是通过缓冲池(LocalBufferPool
)来实现的。每个被生产和被消费的流都会被分配一个缓冲池。缓冲池管理着一组缓冲(Buffer
),缓冲在被消费后可以被回收循环利用。这很好理解:你从池子中拿走一个缓冲,填上数据,在数据消费完之后,又把缓冲还给池子,之后你可以再次使用它。
在解释 Flink 的反压原理之前,我们必须先对 Flink 中网络传输的内存管理有个了解。
如下图所示展示了 Flink 在网络传输场景下的内存管理。网络上传输的数据会写到 Task 的 InputGate(IG) 中,经过 Task 的处理后,再由 Task 写到 ResultPartition(RS) 中。每个 Task 都包括了输入和输入,输入和输出的数据存在 Buffer
中(都是字节数据)。Buffer 是 MemorySegment 的包装类。
TaskManager(TM)在启动时,会先初始化NetworkEnvironment
对象,TM 中所有与网络相关的东西都由该类来管理(如 Netty 连接),其中就包括NetworkBufferPool
。根据配置,Flink 会在 NetworkBufferPool 中生成一定数量(默认2048)的内存块 MemorySegment(关于 Flink 的内存管理,后续文章会详细谈到),内存块的总数量就代表了网络传输中所有可用的内存。NetworkEnvironment 和 NetworkBufferPool 是 Task 之间共享的,每个 TM 只会实例化一个。
Task 线程启动时,会向 NetworkEnvironment 注册,NetworkEnvironment 会为 Task 的 InputGate(IG)和 ResultPartition(RP) 分别创建一个 LocalBufferPool(缓冲池)并设置可申请的 MemorySegment(内存块)数量。IG 对应的缓冲池初始的内存块数量与 IG 中 InputChannel 数量一致,RP 对应的缓冲池初始的内存块数量与 RP 中的 ResultSubpartition 数量一致。不过,每当创建或销毁缓冲池时,NetworkBufferPool 会计算剩余空闲的内存块数量,并平均分配给已创建的缓冲池。注意,这个过程只是指定了缓冲池所能使用的内存块数量,并没有真正分配内存块,只有当需要时才分配。为什么要动态地为缓冲池扩容呢?因为内存越多,意味着系统可以更轻松地应对瞬时压力(如GC),不会频繁地进入反压状态,所以我们要利用起那部分闲置的内存块。
在 Task 线程执行过程中,当 Netty 接收端收到数据时,为了将 Netty 中的数据拷贝到 Task 中,InputChannel(实际是 RemoteInputChannel)会向其对应的缓冲池申请内存块(上图中的①)。如果缓冲池中也没有可用的内存块且已申请的数量还没到池子上限,则会向 NetworkBufferPool 申请内存块(上图中的②)并交给 InputChannel 填上数据(上图中的③和④)。如果缓冲池已申请的数量达到上限了呢?或者 NetworkBufferPool 也没有可用内存块了呢?这时候,Task 的 Netty Channel 会暂停读取,上游的发送端会立即响应停止发送,拓扑会进入反压状态。当 Task 线程写数据到 ResultPartition 时,也会向缓冲池请求内存块,如果没有可用内存块时,会阻塞在请求内存块的地方,达到暂停写入的目的。
当一个内存块被消费完成之后(在输入端是指内存块中的字节被反序列化成对象了,在输出端是指内存块中的字节写入到 Netty Channel 了),会调用 Buffer.recycle()
方法,会将内存块还给 LocalBufferPool (上图中的⑤)。如果LocalBufferPool中当前申请的数量超过了池子容量(由于上文提到的动态容量,由于新注册的 Task 导致该池子容量变小),则LocalBufferPool会将该内存块回收给 NetworkBufferPool(上图中的⑥)。如果没超过池子容量,则会继续留在池子中,减少反复申请的开销。
下面这张图简单展示了两个 Task 之间的数据传输以及 Flink 如何感知到反压的:
不要忘了:记录能被 Flink 处理的前提是,必须有空闲可用的 Buffer。
结合上面两张图看:Task 1 在输出端有一个相关联的 LocalBufferPool(称缓冲池1),Task 2 在输入端也有一个相关联的 LocalBufferPool(称缓冲池2)。如果缓冲池1中有空闲可用的 buffer 来序列化记录 “A”,我们就序列化并发送该 buffer。
这里我们需要注意两个场景:
这种固定大小缓冲池就像阻塞队列一样,保证了 Flink 有一套健壮的反压机制,使得 Task 生产数据的速度不会快于消费的速度。我们上面描述的这个方案可以从两个 Task 之间的数据传输自然地扩展到更复杂的 pipeline 中,保证反压机制可以扩散到整个 pipeline。
下方的代码是初始化 NettyServer 时配置的水位值参数。
// 默认高水位值为2个buffer大小, 当接收端消费速度跟不上,发送端会立即感知到 |
当输出缓冲中的字节数超过了高水位值, 则 Channel.isWritable() 会返回false。当输出缓存中的字节数又掉到了低水位值以下, 则 Channel.isWritable() 会重新返回true。Flink 中发送数据的核心代码在 PartitionRequestQueue
中,该类是 server channel pipeline 的最后一层。发送数据关键代码如下所示。
private void writeAndFlushNextMessageIfPossible(final Channel channel) throws IOException { |
核心发送方法中如果channel不可写,则会跳过发送。当channel再次可写后,Netty 会调用该Handle的 channelWritabilityChanged
方法,从而重新触发发送函数。
另外,官方博客中为了展示反压的效果,给出了一个简单的实验。下面这张图显示了:随着时间的改变,生产者(黄色线)和消费者(绿色线)每5秒的平均吞吐与最大吞吐(在单一JVM中每秒达到8百万条记录)的百分比。我们通过衡量task每5秒钟处理的记录数来衡量平均吞吐。该实验运行在单 JVM 中,不过使用了完整的 Flink 功能栈。
首先,我们运行生产task到它最大生产速度的60%(我们通过Thread.sleep()来模拟降速)。消费者以同样的速度处理数据。然后,我们将消费task的速度降至其最高速度的30%。你就会看到背压问题产生了,正如我们所见,生产者的速度也自然降至其最高速度的30%。接着,停止消费task的人为降速,之后生产者和消费者task都达到了其最大的吞吐。接下来,我们再次将消费者的速度降至30%,pipeline给出了立即响应:生产者的速度也被自动降至30%。最后,我们再次停止限速,两个task也再次恢复100%的速度。总而言之,我们可以看到:生产者和消费者在 pipeline 中的处理都在跟随彼此的吞吐而进行适当的调整,这就是我们希望看到的反压的效果。
在 Storm/JStorm 中,只要监控到队列满了,就可以记录下拓扑进入反压了。但是 Flink 的反压太过于天然了,导致我们无法简单地通过监控队列来监控反压状态。Flink 在这里使用了一个 trick 来实现对反压的监控。如果一个 Task 因为反压而降速了,那么它会卡在向 LocalBufferPool
申请内存块上。那么这时候,该 Task 的 stack trace 就会长下面这样:
java.lang.Object.wait(Native Method) |
那么事情就简单了。通过不断地采样每个 task 的 stack trace 就可以实现反压监控。
Flink 的实现中,只有当 Web 页面切换到某个 Job 的 Backpressure 页面,才会对这个 Job 触发反压检测,因为反压检测还是挺昂贵的。JobManager 会通过 Akka 给每个 TaskManager 发送TriggerStackTraceSample
消息。默认情况下,TaskManager 会触发100次 stack trace 采样,每次间隔 50ms(也就是说一次反压检测至少要等待5秒钟)。并将这 100 次采样的结果返回给 JobManager,由 JobManager 来计算反压比率(反压出现的次数/采样的次数),最终展现在 UI 上。UI 刷新的默认周期是一分钟,目的是不对 TaskManager 造成太大的负担。
Flink 不需要一种特殊的机制来处理反压,因为 Flink 中的数据传输相当于已经提供了应对反压的机制。因此,Flink 所能获得的最大吞吐量由其 pipeline 中最慢的组件决定。相对于 Storm/JStorm 的实现,Flink 的实现更为简洁优雅,源码中也看不见与反压相关的代码,无需 Zookeeper/TopologyMaster 的参与也降低了系统的负载,也利于对反压更迅速的响应。
]]>Flink 运行在所有类 UNIX 环境上,例如 Linux、Mac OS X 和 Cygwin(对于Windows),而且要求集群由一个master节点和一个或多个worker节点组成。在安装系统之前,确保每台机器上都已经安装了下面的软件:
如果你的集群还没有完全装好这些软件,你需要安装/升级它们。例如,在 Ubuntu Linux 上, 你可以执行下面的命令安装 ssh 和 Java :
sudo apt-get install ssh |
*译注:安装过Hadoop、Spark集群的用户应该对这段很熟悉,如果已经了解,可跳过。**
为了能够启动/停止远程主机上的进程,master节点需要能免密登录所有worker节点。最方便的方式就是使用ssh的公钥验证了。要安装公钥验证,首先以最终会运行Flink的用户登录master节点。所有的worker节点上也必须要有同样的用户(例如:使用相同用户名的用户)。本文会以 flink 用户为例。非常不建议使用 root 账户,这会有很多的安全问题。
当你用需要的用户登录了master节点,你就可以生成一对新的公钥/私钥。下面这段命令会在 ~/.ssh 目录下生成一对新的公钥/私钥。
ssh-keygen -b 2048 -P '' -f ~/.ssh/id_rsa |
接下来,将公钥添加到用于认证的authorized_keys
文件中:
cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys |
最后,将authorized_keys
文件分发给集群中所有的worker节点,你可以重复地执行下面这段命令:
scp ~/.ssh/authorized_keys <worker>:~/.ssh/ |
将上面的<worker>
替代成相应worker节点的IP/Hostname。完成了上述拷贝的工作,你应该就可以从master上免密登录其他机器了。
ssh <worker> |
Flink 需要master和worker节点都配置了JAVA_HOME
环境变量。有两种方式可以配置。
一种是,你可以在conf/flink-conf.yaml
中设置env.java.home
配置项为Java的安装路径。
另一种是,sudo vi /etc/profile
,在其中添加JAVA_HOME
:
export JAVA_HOME=/path/to/java_home/ |
然后使环境变量生效,并验证 Java 是否安装成功
$ source /etc/profile #生效环境变量 |
进入下载页面。请选择一个与你的Hadoop版本相匹配的Flink包。如果你不打算使用Hadoop,选择任何版本都可以。
在下载了最新的发布包后,拷贝到master节点上,并解压:
tar xzf flink-*.tgz |
在解压完之后,你需要编辑conf/flink-conf.yaml
配置Flink。
设置jobmanager.rpc.address
配置项为你的master节点地址。另外为了明确 JVM 在每个节点上所能分配的最大内存,我们需要配置jobmanager.heap.mb
和taskmanager.heap.mb
,值的单位是 MB。如果对于某些worker节点,你想要分配更多的内存给Flink系统,你可以在相应节点上设置FLINK_TM_HEAP
环境变量来覆盖默认的配置。
最后,你需要提供一个集群中worker节点的列表。因此,就像配置HDFS,编辑conf/slaves文件,然后输入每个worker节点的 IP/Hostname。每一个worker结点之后都会运行一个 TaskManager。
每一条记录占一行,就像下面展示的一样:
192.168.0.100 |
译注:conf/master文件是用来做JobManager HA的,在这里不需要配置
每一个worker节点上的 Flink 路径必须一致。你可以使用共享的 NSF 目录,或者拷贝整个 Flink 目录到各个worker节点。
scp -r /path/to/flink <worker>:/path/to/ |
请查阅配置页面了解更多关于Flink的配置。
特别的,这几个
taskmanager.heap.mb
)taskmanager.numberOfTaskSlots
)parallelism.default
)taskmanager.tmp.dirs
)是非常重要的配置项。
下面的脚本会在本地节点启动一个 JobManager,然后通过 SSH 连接所有的worker节点(slaves文件中所列的节点),并在每个节点上运行 TaskManager。现在你的 Flink 系统已经启动并运行了。跑在本地节点上的 JobManager 现在会在配置的 RPC 端口上监听并接收任务。
假定你在master节点上,并在Flink目录中:
bin/start-cluster.sh |
要停止Flink,也有一个 stop-cluster.sh 脚本。
你可以使用 bin/jobmanager.sh 和 bin/taskmanager 脚本来添加 JobManager 和 TaskManager 实例到你正在运行的集群中。
bin/jobmanager.sh (start cluster)|stop|stop-all |
bin/taskmanager.sh start|stop|stop-all |
确保你是在需要启动/停止相应实例的节点上运行的这些脚本。
]]>进入下载页面。如果你想让Flink与Hadoop进行交互(如HDFS或者HBase),请选择一个与你的Hadoop版本相匹配的Flink包。当你不确定或者只是想运行在本地文件系统上,请选择Hadoop 1.2.x对应的包。
Flink 可以运行在 Linux、Mac OS X 和 Windows 上。本地模式的安装唯一需要的只是 Java 1.7.x或更高版本。接下来的指南假定是类Unix环境,Windows用户请参考 Flink on Windows。
你可以执行下面的命令来查看是否已经正确安装了Java了。
java -version |
这条命令会输出类似于下面的信息:
java version "1.8.0_51" |
对于本地模式,Flink是可以开箱即用的,你不用去更改任何的默认配置。
开箱即用的配置会使用默认的Java环境。如果你想更改Java的运行环境,你可以手动地设置环境变量JAVA_HOME
或者conf/flink-conf.yaml
中的配置项env.java.home
。你可以查阅配置页面了解更多关于Flink的配置。
你现在就可以开始运行Flink了。解压已经下载的压缩包,然后进入新创建的flink目录。在那里,你就可以本地模式运行Flink了:
$ tar xzf flink-*.tgz |
你可以通过观察logs目录下的日志文件来检查系统是否正在运行了:
$ tail log/flink-*-jobmanager-*.log |
JobManager 同时会在8081端口上启动一个web前端,你可以通过 http://localhost:8081 来访问。
如果你想要在 Windows 上运行 Flink,你需要如上文所述地下载、解压、配置 Flink 压缩包。之后,你可以使用使用 Windows 批处理文件(.bat文件)或者使用 Cygwin 运行 Flink 的 JobMnager。
使用 Windows 批处理文件本地模式启动Flink,首先打开命令行窗口,进入 Flink 的 bin/ 目录,然后运行 start-local.bat 。
注意:Java运行环境必须已经加到了 Windows 的%PATH%
环境变量中。按照本指南添加 Java 到%PATH%
环境变量中。
$ cd flink |
之后,你需要打开新的命令行窗口,并运行flink.bat
。
使用 Cygwin 你需要打开 Cygwin 的命令行,进入 Flink 目录,然后运行start-local.sh
脚本:
$ cd flink |
如果你是从 git 安装的 Flink,而且使用的 Windows git shell,Cygwin会产生一个类似于下面的错误:
c:/flink/bin/start-local.sh: line 30: $'\r': command not found |
这个错误的产生是因为 git 运行在 Windows 上时,会自动地将 UNIX 换行转换成 Windows 换行。问题是,Cygwin 只认 Unix 换行。解决方案是调整 Cygwin 配置来正确处理换行。步骤如下:
确定 home 目录,通过输入
cd;pwd |
它会返回 Cygwin 根目录下的一个路径。
在home目录下,使用 NotePad, WordPad 或者其他编辑器打开.bash_profile
文件,然后添加如下内容到文件末尾:(如果文件不存在,你需要创建它)
export SHELLOPTS |
保存文件,然后打开一个新的bash窗口。
]]>这一年,是我人生的又一个转折点。因为,我终于走出了校园,迈进了社会。阿里是一座大学,让我很快就适应了这里的生活。尤其是园区的五个食堂可以每天都吃不同的食堂,午饭有补贴、晚饭免费、夜宵免费,害的我半年就胖了10斤…斤…
言归正传,今年主要就分为两个阶段,前半年是在校的毕业期,后半年是入职的菜鸟期。
这半年主要是与毕设各种斗智斗勇以及和好友们胡吃海塞中度过。还有趁离开北京前,把还没去过的地方逛了下。
有时看着实验室群里学弟学妹们的聊天,他们也正在经历着我半年前的经历。正巧,收到了汤老师的来信,问我工作的近况。突然,好想念老师们,好想念在北京的兄弟们。所以计划今年在学弟们毕业前,回一趟学校见见老师同学们。
在毕业之后,我把shadowsocks免费服务和码农圈关掉了。经过半年的运营,码农圈的用户数已经将近2000了。可是非常遗憾,毕业之后没有精力去维护它们,犹豫了良久,还是关停了。
很幸运地能进入阿里,很幸运地能进入牛人云集的中间件团队,更幸运的是我进入的团队做的正是我喜欢的东西(大数据、流处理、JStorm/Storm)。在这里,我遇到了我的中国好老板,中国好师兄。刚来到团队时,主要负责jstorm的监控系统和管控平台。作为一名后端开发,在中间件中前端能力居然成了我的强项,虽然我对前端也有兴趣,但我还是更想做核心后端。
所以,需要学的东西好多,Clojure、Netty、Thrift、ZooKeeper、高并发、Spark、Flink…有学不完的东西,每天都很充实,却也每天都觉得时间不够用。以前在学校,一周七天都能拿来学这些东西,现在工作忙了,只能等到下班后或是周末,然而周末又会有好多琐碎的事情。一周能坐下来持续学习的时间真的好少。不过我们团队的好处是,在工作不忙时可以在工作时间去学习、去阅读源码,Boss也支持,简直就是领着工资在学习。
这一年,团队发生的大事是 JStorm 进入了 Apache 基金会。社区有一个JStorm Merge的规划,也就是Storm 2.0会基于JStorm将Clojure核心代码替换成Java。最近Storm马上就会发布1.0版本,之后会冻结核心相关的feature,开始全力做jstorm merge。希望届时,自己能为社区做尽量多的贡献。
这一年,我在Github上开源的项目总共收到了450多个star。进入JStorm团队后,也有更多的机会活跃在JStorm社区和Storm社区。
这一年,开源社区对整个工业界的影响很大。开源社区对我的影响也很大。深刻地体会到,这是个卧虎藏龙的地方,这里有取之不尽的知识,这里就像大学的象牙塔一样引无数人攀登。
大型的开源项目有很多值得学习的东西,优秀的架构设计,漂亮整洁的代码,就像文档一样的代码注释,以及测试、code review、文档、流程规范等等。就算是BAT绝大多数团队都没有做到。所以,参与开源项目不仅是一件“镀金”的经验,更是能大幅提升自己的技术能力。
这是我工作的第一年,起初刚毕业时心情比较浮躁,现在慢慢心沉下来了。目标清晰,只需低头拉车踏踏实实做研发。这些今年的TODO List。
毋庸置疑,首先你得有台Macbook,这是脱离鼠标提升效率的第一步。所以本文基本上都是推荐Mac上的工具。
我的笔记需求很简单,1. 支持Markdown与预览 2. 支持笔记分类管理 3. 简洁美观。哦,要是能直接在Markdown中粘贴图片就更好了。MWeb是我目前用过这么多产品里唯一全符合这些要求的。已购。
EvenNote不支持Markdown,太重。Mou缺少文档管理。Cmd Markdown,离线版还有待改进。
Sublime是一款具有丰富扩展功能的编辑器。作为前端开发者,完全可以用如此轻量的工具作为前端IDE。
Atom的推出就是要取代Sublime的。两者功能差不多,可以说Atom深受Sublime哲学的影响。Atom对于包管理更加方便,代码补全也是出色的功能之一。优秀的界面设计,让我这视觉动物忍不住就用上了。就是相对Sublime而言,做的有些重了。
Java IDE的不二之选。强大,强大,强大,记得一定要上Ultimate版,资金充足就付费,不充足就先用破解,记得靠IDEA赚到钱了得回来补上。用惯后会极大提高开发速度。重复代码自动检查、代码规范提示等功能还能帮你纠正编码规范。快捷键尽量用默认的,不要用Eclipse快捷键,虽然一开始会有点难以适应,但是用久了会发现爽的飞起。IDEA是可以为之单独写篇文章安利的产品,此处不再多说。另外Jetbrains家族的产品都很良心,RubyMine、Pycharm、WebStorm都是不错的IDE。
Dash是一个API文档浏览器,以及代码片段管理工具。作为一名程序员,每天必不可少的动作就是查各种API文档,为了搜一个函数打开好几个web窗口是很常见的事。Dash可以提高我们的效率,尤其是我为它绑定了shift+space
的快捷键之后,在全屏IDE中我可以直接呼出dash查询想要的类/函数。已购。
自带的Terminal其实也还行,不过有很多理由让我们用iTerm 2。例如设置主题、各种快捷键、方便的复制查找。再配合上Oh My Zsh ,简直爽到爆!
Shadowsocks在Mac上的客户端。[蜡烛]
iOS 9的一个神级API,以及给力的app开发者,终于带给iOS用户们一个安全、低成本、最大网络速度、无连接状态、国内外分流的完美解决方案。终于可以在碎片时间获取国外的最新资讯了。
Mac自带的字典其实已经很方便了,三指轻按在阅读英文文档时非常方便,但不能满足查单词的需求。而Mac上的词典确实比较少,也就这款用的比较顺手,我绑定了option+space
快捷键,可以轻松从顶部呼出搜索栏。
奇妙清单是一款任务管理工具,可用于记事提醒、工作安排、待办清单、项目管理等工作,重点的是它免费且跨平台支持 iOS、Android、Windows、Mac、网页版等。虽然同类优秀的TODO产品众多,不过这款产品清一色的五星好评值得你拥有。目前我一直用它来管理工作、生活、学习上的事项,用的很顺。支持国产免费软件。
作为程序员,免不了要画些流程图什么的。OmniGraffle绝对是Mac上最好用的流程图软件,画出来的图颜值爆表。当然,这是收费的。
Chrome插件
关于Chrome插件我这里只推荐两个吧。一个是围脖是个好图床,可以方便的通过粘贴、拖拽将图片上传到新浪微博图床,并拿到链接。另一个是Proxy SwitchyOmega,SwitchySharp的升级版,搭配ss能代理工具使用。
Vars 是一种引用类型,它可以有一个被所有线程共享的root binding,并且每个线程还能拥有自己(thread-local)的值。
defdef
定义, 将会影响全局定义。
(def v 2) ; -> 2 |
letlet
定义, 将会影响自身生命周期内,以及自己作用域内,如果超出自己的作用域,则无效。
(def name "jark") |
bindingbinding
, 将会影响自身生命周期以及自己作用域内,即使超出自身作用域,也都有效。
这个例子演示了binding和set!一起使用,用set!来修改一个由binding bind的Var的线程本地的值。
(def ^:dynamic v 1) ; 需要声明成"dynamic",v才能用binding来改变值。 |
好吧,说了这么多,其实Clojure不鼓励我们使用Vars,因为Vars在线程间不能很好地协作。
Refs是用来协调对于一个或者多个binding的并发修改的。
ref
; 用ref函数创建一个可变的引用(reference),一个空的歌曲集合 |
validator
类似数据库,可以为ref添加“约束”,在数据更新的时候需要通过validator函数的验证,如果验证不通过,整个事务将回滚。
(def validate-song |
dosync & ref-set
; 改变引用指向的内容,使用ref-set函数 |
alter & commute
更改引用有点暴力也比较少见,更常见的更新是根据当前状态更新,比如加一首歌进去。
; 先查询集合内容,然后往集合里添加歌曲,然后更新整个集合 |
注意alter后跟的函数会把ref值当做第一个参数,所以这里使用cons就不行了,因为cons要求第一个参数是加入的元素。
commute函数是是对alter的优化,commute可以同时进行修改(并不影响ref最终的值)。通常情况下,一般优先使用alter,除非在遇到明显的性能瓶颈并且对顺序不是那么关心的时候,可以考虑用commute替换。
Atoms 提供了比使用Refs&STM更简单的更新当个值的方法。它不受事务的影响。有点像Java的原子类(Atomic)。
有三个函数可以修改一个Atom的值:reset!
,compare-and-set!
和swap!
。
(def counter (atom 1)) ; 指定 counter 为Atom类型 |
Agents 是用来把一些事情放到另外一个线程来做(一般不需要事务控制的),用来控制状态的异步更新。
(def counter (agent 0)) ; 使用agent函数定义一个初始值为0的agent |
send和send-off的区别在于,send是将任务交给一个固定大小的线程池执行(默认大小是CPU核数+2)。因此send执行的任务最好不要有阻塞的操作。而send-off则使用没有大小限制(取决于内存)的线程池。因此,send-off比较适合任务有阻塞的操作,如IO读写之类。注意,所有的agent是共用这些线程池。
这篇笔记原先是想放在一篇文章里的,谁知太长了,只好分成了三篇。以下是我觉得学习Clojure不错的网上资源,需者自取。
匿名函数
匿名函数(fn) (fn [x y] (+ x y))
创建一个匿名函数, fn 和 lambda 类似,fn还有一个简写形式 #(+ %1 %2)
。如果只有一个参数,那么可以用 %
代替 %1
defdef
可以将一个匿名函数绑定到一个name上。
(def my-add (fn [x y] (+ x y))) |
defn
defn 是 def 与 fn 的简写。一般我们也都是用defn。
(defn my-add |
declare
函数定义必须在函数调用前,如果想在定义前使用函数,必须声明它(declare function-names)
defn- & defmacro-
defn- 定义私有函数,他们仅在自己的 namespace 可见
参数的解构
解构可以用在一个函数或者宏的参数里面来把一个集合里面的一个或者几个元素抽取到一些本地binding里面去。它可以用let
,&
,:as
等。
(defn my-add [numbers] |
&
符号可以在解构里面用来获取集合里面剩下的元素。:as
关键字可以用来获取对于整个被解构的集合的访问。
导入
(import |
创建对象
使用new关键字创建对象(new class-name args)
,也可以使用语法糖简化(class-name. args)
。
例如:(new String "abc")
或者 (String. "abc")
。
方法调用
使用.
,(. class-or-instance method-name args)
或者 (.method-name class-or-instance args)
例如:
(def s (new String "abc")) |
连续调用方法(.. class-or-object (method1 args) (method2 args) ...)
前一个函数的返回值,作为后一个函数的第一个参数。
例如:
(.. s (toString) (charAt 1)) ; -> \b |
静态变量/静态方法
对于静态方法和静态变量,可以使用ClassName/field
的方式来调用。例如:
Integer/MIN_VALUE ; -> -2147483648 |
if
if的语法是(if condition then-expr else-expr)
,第一个参数是条件,第二个表达式是条件成立时执行,第三个表达式可选,在条件不成立时执行。如果需要执行多个表达式,包在do
里。
(if is-weekend |
when & whennot
宏when 和when-not 提供和if类似的功能,用它们可以更方便地写inline condition。
(when is-weekend (println "play")) |
if-let & when-letif-let
给一个变量绑定一个值,如果这个值为 true,则选择一个语句来执行,否则选择另一个。when-let
跟if-let
类似,不同之处还是在于没有else分支。
(defn process-next [waiting-line] |
condp
condp 宏跟其他语言里面的switch/case语句差不多。它接受两个参数,一个谓词参数 (通常是= 或者instance?) 以及一个表达式作为第二个参数。
(defn choose [value] |
cond
cond 宏接受任意个 谓词/结果表达式 的组合。按照顺序测试所有谓词,直到有一个为true,返回其结果。有点像一直else if
。
(defn chosse [t] |
dotimesdotimes
执行给定的表达式一定次数, 一个本地binding会被给定值:从0到一个给定的数值。如果这个本地binding是不需要的,也可以用_
来代替。
(dotimes [card-number 3] |
输出为
deal card number 0 |
whilewhile
会一直执行一个表达式只要指定的条件为true。
(def c 3) |
dorun+for & doseqdoseq
用来遍历集合在上面已经讲过了。
for
的执行主体只可以采用单行语句,而doseq
可以采用多行语句,并且for
产生 lazy sequence。同时用:when
和:while
还可以做一些过滤,它们的区别在于:when
会迭代所有bindings并执行满足条件的主体,:while
会迭代所有bindings并执行满足条件的主体 直到 条件为false后跳出。
(def cols "ABCD") |
执行结果如下,两段代码的结果是一致的。
for demo |
由于jvm的关系,clojure不会自动进行尾递归优化(tail call optimization),在尾递归的地方,你应该明确的使用 recur
这个关键词,而不是函数名。
(defn my-add [x y] |
第一个不会进行尾递归优化,第二个会进行尾递归优化。
loop
和recur
配合可以实现和循环类似的效果。
(loop [n number factorial 1] |
注意,recur
只能出现在special form的最后一行。
false 和 nil 解释为 false
true 和其他任意值,包括 0 都被认为是 true
测试两值关系
<,<=,=,not=,==,>,>=,compare,distinct? 以及identical?.
测试逻辑关系
and,or,not,true?,false? 和nil?
测试集合
empty?,not-empty,every?,not-every?,some? 以及not-any?.
测试数字
even?,neg?,odd?,pos? 以及zero?.
测试对象类型
class?,coll?,decimal?,delay?,float?,fn?,instance?,integer?,isa?,keyword?,list?,macro?,map?,number?,seq?,set?,string? 以及vector?
require
导入clojure库。
(require 'clojure.string) ; 注意前面的单引号 |
clojure里面命名空间和方法名之间的分隔符是/而不是java里面使用的
(clojure.string/join "$" [1 2 3]) ; -> "1$2$3" |
alias
alias 函数给一个命名空间指定一个别名来简化操作。以及处理类名冲突问题。
(alias 'su 'clojure.string) |
referrefer
可以使指定的命名空间里的函数在当前命名空间里可以不需要包名前缀就能访问。
(refer 'clojure.string) |
use
我们通常把require 和refer 结合使用, 所以clojure提供了一个use , 它相当于require和refer的简洁形式。
(use 'clojure.string) |
ns
ns宏可以改变当前的默认名字空间。它支持这些指令::require
,:use
和:import
,后面的参数不需要quote。这些其实是它们对应的函数的另外一种方式,一般建议使用这些指令而不是函数。
(ns com.example.library |
宏是在读入期(而不是编译期)就进行实际代码替换的一个机制。
反引号(`)
防止宏体内的任何一个表达式被evaluate。这意味着宏体里面的代码会原封不动地替换到使用这个宏的所有的地方 – 除了以波浪号开始的那些表达式。
~
和~@
当一个名字前面被加了一个波浪号,并且还在反引号里面,它的值会被替换的。如果这个名字代表的是一个序列,那么我们可以用 ~@
来替换序列里面的某个具体元素。
id#
在一个标识符背后加上 # 意味着生成一个唯一的symbol, 比如 foo# 实际可 能就是 foo_004 可以看作是let与gensym的等价物,这在避免符号捕捉时很有用。
Clojure是一个动态类型的,运行在JVM(JDK5.0以上),并且可以和java代码互操作的函数式语言。这个语言的主要目标之一是使得编写一个有多个线程并发访问数据的程序变得简单。
Clojure的发音和单词closure是一样的。Clojure之父是这样解释Clojure名字来历的
“我想把这就几个元素包含在里面: C (C#), L (Lisp) and J (Java). 所以我想到了 Clojure, 而且从这个名字还能想到closure;它的域名又没有被占用;而且对于搜索引擎来说也是个很不错的关键词,所以就有了它了.”
true
, false
常用函数:
not
and
or
nil
,只有false与nil会被计算为false,其它的都为true
常用函数:
nil?
字符的表示要在前面加反斜杠 \a
\b
\c
…
数字1
, 2
…
常用函数:
+
, -
, *
/
(分数形式),quot
(商),rem
(余数)inc
,dec
min
, max
=
,<
,<
,>
,>=
zero?
,pos?
,neg?
,number?
字符串,如 “hello”
常用函数
str
: 拼接多个字符串subs
: 子字符串(0为开始下标)string?
关键字,常用于Map中的key,:tag
,:doc
变量名,一个命名空间中唯一。如(def x 1)
,就申请了一个'user/x
的Symbol。
list, vector, set, map
clojure中的任何变量都是不变的,当对一个变量进行修改时,都会产生一个新的变量(clojure会使用共享内存的方式,只会消耗很小的内存代价)。有点像scala中的val哦。
count
返回集合里面的元素个数
(count [19 "yellow" true]) ; -> 3 |
reverse
把集合里面的元素反转
(reverse [1 2 3]) ; -> (3 2 1) |
map
对一个集合内的每个元素都调用一个指定的方法。每个元素调用方法后的返回值再构成一个新的集合返回。
; 使用匿名函数对每个元素加3 |
apply
把集合里的所有元素都作为函数参数做一次调用,并返回这个函数的返回值。
(apply + [2 4 7]) ; -> 13 |
conj & cons
conj (conjoin), 添加一个元素到集合里面去。如果是list类型,则新元素插到队头;如果是vector类型,则新元素插到队尾。
cons (construct),也是添加一个元素到集合里面去。只不过新元素都会在队头。返回类型都会变成seq。
; cons用以向列表或向量的起始位置添加元素 |
常用的集合操作
;从集合中取一个元素 |
Lists 相当于Java中的LinkedList。
创建
(def stooges (list "Moe" "Larry" "Curly")) |
some
检测一个集合中是否包含某个元素,需要跟一个谓词函数和一个集合。
(some #(= % "Moe") stooges) ; -> true |
into
把两个集合里面的元素合并成一个新的大list
(into [1 2] [3 4]) ; -> [1 2 3 4] |
类似于数组。这种集合对于从最后面删除一个元素,或者获取最后面一个元素是非常高效的(O(1))。这意味着对于向vector里面添加元素使用conj被使用cons更高效。
创建
(def stooges (vector "Moe" "Larry" "Curly")) |
get
获取vector里面指定索引的元素,从map中取value也是用的get。get 和 nth 有点类似。
; Usage: (get map key) , (get map key not-found) |
assoc
可以对 vectors 和 maps进行操作。替换指定索引的元素。
(assoc stooges 2 "Shemp") ; -> ["Moe" "Larry" "Shemp"] |
subvec
获取一个给定vector的子vector。
; Usage: (subvec v start) , (subvec v start end) |
contains?
是否包含某元素。可以操作在set和map上。
(contains? stooges "Moe") ; -> true |
Sets 自己也可以作为一个函数。当以这种方式来用的时候,返回值要么是这个元素,要么是nil。 这个比起contains?函数来说更简洁。
(stooges "Moe") ; -> "Moe" |
disj
去掉给定的set里面的一些元素,返回一个新的set。
(def more-stooges (conj stooges "Shemp")) ; -> #{"Moe" "Larry" "Curly" "Shemp"} |
Maps 保存从key到value的a对应关系 — key和value都可以是任意对象。和set类似,map也分为排序的(sorted-hash)和不排序的(hash-map)。
创建
(def colors (hash-map :red 1, :yellow 9, :blue 4, :black 3)) |
Map可以作为它的key的函数,同时如果key是keyword的话,那么key也可以作为map的函数。下面是三种获取:red
所对应的值的方法。
(get colors :red) |
contains? & keys & vals
contains? 用来检测某个key存不存在。keys 和 vals 可以获得map中的键集合和值集合。
(contains? colos :red) ; -> true |
assoc & dissoc
assoc 会创建一个新的map,同时添加任意对新的key-value对, 如果某个给定的key已经存在了,那么它的值会被更新。
dissoc 会创建一个新的map,同时去掉了给定的那些key。
(assoc colors :red "red" :white "white") |
遍历
遍历map可以使用宏doseq
,把key bind到color, 把value bind到v。name函数返回一个keyword的字符串名字。
(doseq [[color v] colors] |
输出为:
The value of yellow is 9. |
或者使用Destructing。
内嵌
map的值也可以是一个map。
(def person { |
为了获取这个人的employer的address的city的值,我们一般有三种方法。
; 使用get-in函数 |
修改一个内嵌的key值,我们可以使用assoc-in
或者update-in
,不同之处在于update-in
的新值是通过一个给定的函数来计算出来的。
(assoc-in person [:employer :address :city] "Clayton") |
StructMaps 和普通map类似,它的作用其实是用来模拟java里面的javabean。
定义
(def vehicle-struct (create-struct :make :model :year :color)) |
实例化
使用struct
函数实例化一个StructMap对象,相当于java里面的new关键字。提供给struct的参数的顺序必须和定义时提供的keyword的顺序一致,后面的参数可以忽略。如果忽略,那么对应key的值就是nil。
(def vehicle (struct vehicle-struct "Toyota" "Prius" 2009)) |
accessor
accessor 函数可以创建一个类似java里面的getXXX的方法, 它的好处是可以避免hash查找, 它比普通的hash查找要快。
(def make (accessor vehicle-struct :make)) ; 注意使用的是def而不是defn |
我们编写一个account.thrift
的文件。
namespace java me.wuchong.thrift.generated |
然后在命令行下运行如下命令:thrift --gen java account.thrift
则会在当前目录生成gen-java
目录,该目录下会按照namespace定义的路径名一次一层层生成文件夹,如下图所示,在指定的包路径下生成了4个类。

到此为止,thrift已经完成了其工作。接下来我们需要做的就是实现Account
接口里的具体逻辑。我们创建一个AccountService
类,实现Account.Iface
接口。逻辑非常简单,将用户账户信息缓存在内存中,实现登录注册的功能,并且对一些非法输入状况抛出异常。
package me.wuchong.thrift.impl; |
我们实现了服务的具体逻辑,接下来需要启动该服务。这里我们需要用到thrift的依赖包。在pom.xml中加入对thrift的依赖。
<dependency> |
注:如果你的依赖中没有加入slf4j的实现,则需要加上slf4j-log4j12或者logback的依赖,因为thrift有用到slf4j
启动服务的实现如下:
package me.wuchong.thrift.impl; |
运行之后,可以在控制台看到输出:Starting the Account server...
目前服务已经启动,则在客户端就可以进行RPC调用了。启动客户端的代码如下:
package me.wuchong.thrift.impl; |
运行客户端,其结果如下所示。
Login failed!! please check your username and password |
而此时,服务端会打印出收到的请求信息。
Starting the Account server... |
你可以发现,只需要几行代码,我们就实现了高效的RPC通信。
##参考资料
]]>Thrift 最初由Facebook开发,而后捐献给Apache,目前已广泛应用于业界。Thrift 正如其官方主页介绍的,“是一种可扩展、跨语言的服务开发框架”。简而言之,它主要用于各个服务之间的RPC通信,其服务端和客户端可以用不同的语言来开发。只需要依照IDL(Interface Description Language)定义一次接口,Thrift工具就能自动生成 C++, Java, Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, JavaScript, Node.js, Smalltalk, and OCaml等语言的代码。
Thrift的安装还是有些繁琐的,跟着官方文档的走就可以。如果你是Mac OS X, 这里有更方便的方法。
brew install boost |
不过注意上述方法默认安装的最新版。
thrfit的类型系统包括了基本类型,比如bool, byte, double, string和int。也提供了特殊类型如binary,提供了structs(等同于无继承的class),还提供了容器类型(list,set,map)。
注:thrift不支持无符号整数类型,因为很多编程语言不存在无符号类型,比如java
binary: 未编码的字节序列
枚举的定义形式和Java的Enum定义差不多,例如:
enum Sex { |
集合中的元素可以是除了service之外的任何类型,包括exception。
结构体中包含一系列的强类型域,等同于无继承的class。可以看出struct写法很类似C语言的结构体。
struct Example { |
thrift提供两个关键字required
,optional
,分别用于表示对应的字段时必填的还是可选的。例如:
struct People { |
表示name是必填的,age是可选的。
当一个结构体中,field之间的关系是互斥的,即只能有一个field被使用被赋值。我们可以用union来声明这个结构体,而不是一堆堆optional的field,语意上也更明确了。例如:
union JavaObjectArg { |
可以自定义异常类型,所定义的异常会继承对应语言的异常基类,例如java,就会继承 java.lang.Exception
。
exception InvalidOperation { |
thrift定义服务相当于Java中创建Interface一样,创建的service经过代码生成命令之后就会生成客户端和服务端的框架代码。定义形式如下:
service StringCache { |
thrift的命名空间相当于Java中的package的意思,主要目的是组织代码。thrift使用关键字namespace定义命名空间,例如:
namespace java me.wuchong.thrift |
注意末尾不能有分号,由此生成的代码,其包路径结构为me.wuchong.thrift
下一篇文章,将通过一个简单的实例来了解thrift的使用。
##参考资料
]]>举个栗子,一个图片压缩服务:
又或者一个缓存服务:
基本上每一个服务、应用都需要做一个监控系统,这需要尽量以少量的代码,实现统计某类数据的功能。
以 Java 为例,目前最为流行的 metrics 库是来自 Coda Hale 的 dropwizard/metrics,该库被广泛地应用于各个知名的开源项目中。例如 Hadoop,Kafka,Spark,JStorm 中。
本文就结合范例来主要介绍下 dropwizard/metrics 的概念和用法。
我们需要在pom.xml
中依赖 metrics-core
包:<dependencies>
<dependency>
<groupId>io.dropwizard.metrics</groupId>
<artifactId>metrics-core</artifactId>
<version>${metrics.version}</version>
</dependency>
</dependencies>
注:在POM文件中需要声明 ${metrics.version}
的具体版本号,如 3.1.0
MetricRegistry
类是Metrics的核心,它是存放应用中所有metrics的容器。也是我们使用 Metrics 库的起点。
MetricRegistry registry = new MetricRegistry(); |
每一个 metric 都有它独一无二的名字,Metrics 中使用句点名字,如 com.example.Queue.size。当你在 com.example.Queue 下有两个 metric 实例,可以指定地更具体:com.example.Queue.requests.size 和 com.example.Queue.response.size 。使用MetricRegistry
类,可以非常方便地生成名字。
MetricRegistry.name(Queue.class, "requests", "size") |
Metircs 提供了 Report 接口,用于展示 metrics 获取到的统计数据。metrics-core
中主要实现了四种 reporter: JMX, console, SLF4J, 和 CSV。 在本文的例子中,我们使用 ConsoleReporter 。
最简单的度量指标,只有一个简单的返回值,例如,我们想衡量一个待处理队列中任务的个数,代码如下:public class GaugeTest {
public static Queue<String> q = new LinkedList<String>();
public static void main(String[] args) throws InterruptedException {
MetricRegistry registry = new MetricRegistry();
ConsoleReporter reporter = ConsoleReporter.forRegistry(registry).build();
reporter.start(1, TimeUnit.SECONDS);
registry.register(MetricRegistry.name(GaugeTest.class, "queue", "size"),
new Gauge<Integer>() {
public Integer getValue() {
return q.size();
}
});
while(true){
Thread.sleep(1000);
q.add("Job-xxx");
}
}
}
运行之后的结果如下:-- Gauges ------------------------------------------------
com.alibaba.wuchong.metrics.GaugeTest.queue.size
value = 6
其中第7行和第8行添加了ConsoleReporter,可以每秒钟将度量指标打印在屏幕上,理解起来会更清楚。
但是对于大多数队列数据结构,我们并不想简单地返回queue.size()
,因为java.util
和java.util.concurrent
中实现的#size()
方法很多都是 O(n) 的复杂度,这会影响 Gauge 的性能。
Counter 就是计数器,Counter 只是用 Gauge 封装了 AtomicLong
。我们可以使用如下的方法,使得获得队列大小更加高效。
public class CounterTest { |
运行之后的结果大致如下:add job : Job-15
add job : Job-16
take job : Job-8
take job : Job-10
add job : Job-19
15-8-1 16:11:31 ============================================
-- Counters ----------------------------------------------
java.util.Queue.pending-jobs.size
count = 5
Meter度量一系列事件发生的速率(rate),例如TPS。Meters会统计最近1分钟,5分钟,15分钟,还有全部时间的速率。
public class MeterTest { |
运行结果大致如下:request
15-8-1 16:23:25 ============================================
-- Meters ------------------------------------------------
com.alibaba.wuchong.metrics.MeterTest.request.tps
count = 134
mean rate = 2.13 events/second
1-minute rate = 2.52 events/second
5-minute rate = 3.16 events/second
15-minute rate = 3.32 events/second
注:非常像 Unix 系统中 uptime 和 top 中的 load。
Histogram统计数据的分布情况。比如最小值,最大值,中间值,还有中位数,75百分位, 90百分位, 95百分位, 98百分位, 99百分位, 和 99.9百分位的值(percentiles)。
比如request的大小的分布:public class HistogramTest {
public static Random random = new Random();
public static void main(String[] args) throws InterruptedException {
MetricRegistry registry = new MetricRegistry();
ConsoleReporter reporter = ConsoleReporter.forRegistry(registry).build();
reporter.start(1, TimeUnit.SECONDS);
Histogram histogram = new Histogram(new ExponentiallyDecayingReservoir());
registry.register(MetricRegistry.name(HistogramTest.class, "request", "histogram"), histogram);
while(true){
Thread.sleep(1000);
histogram.update(random.nextInt(100000));
}
}
}
运行之后结果大致如下:-- Histograms --------------------------------------------
java.util.Queue.queue.histogram
count = 56
min = 1122
max = 99650
mean = 48735.12
stddev = 28609.02
median = 49493.00
75% <= 72323.00
95% <= 90773.00
98% <= 94011.00
99% <= 99650.00
99.9% <= 99650.00
Timer其实是 Histogram 和 Meter 的结合, histogram 某部分代码/调用的耗时, meter统计TPS。
public class TimerTest { |
运行之后结果如下:-- Timers ------------------------------------------------
com.alibaba.wuchong.metrics.TimerTest.get-latency
count = 38
mean rate = 1.90 calls/second
1-minute rate = 1.66 calls/second
5-minute rate = 1.61 calls/second
15-minute rate = 1.60 calls/second
min = 13.90 milliseconds
max = 988.71 milliseconds
mean = 519.21 milliseconds
stddev = 286.23 milliseconds
median = 553.84 milliseconds
75% <= 763.64 milliseconds
95% <= 943.27 milliseconds
98% <= 988.71 milliseconds
99% <= 988.71 milliseconds
99.9% <= 988.71 milliseconds
初次之外,Metrics还提供了 HealthCheck 用来检测某个某个系统是否健康,例如数据库连接是否正常。还有Metrics Annotation,可以很方便地实现统计某个方法,某个值的数据。感兴趣的可以点进链接看看。
一般情况下,当我们需要统计某个函数被调用的频率(TPS),会使用Meters。当我们需要统计某个函数的执行耗时时,会使用Histograms。当我们既要统计TPS又要统计耗时时,我们会使用Timers。
本文代码已上传至GitHub。
]]>Scrapy框架的高度灵活性得益于其数据管道的架构设计,开发者可以通过简单的配置就能轻松地添加新特性。我们可以通过如下的方式添加一个pipline。
settings.set("ITEM_PIPELINES", {'pipelines.DataBasePipeline': 300}) |
这里ITEM_PIPELINES
是一个Python字典,其中key保存的pipline类在项目中的位置,value为整型值,确定了他们运行的顺序,item按数字从低到高的顺序,通过pipeline,通常将这些数字定义在0-1000范围内。
在上一篇博客中,我们已经介绍了使用SQLAlchemy 作为我们的ORM。同样的,为了将爬取的文章保存到数据库,我们先要有一个Article
模型,包含了 URL,标题,正文等字段。
from sqlalchemy import Column, String , DateTime, Integer |
之后在DataBasePipeline
中,我们需要生成Aticle
对象,并将item中对应的字段赋给Aticle
对象,最后通过SQLAlchemy将文章插入到数据库中。
from model.config import DBSession |
为了防止同一个网页爬取两遍,我们使用Redis来去重,因为 Redis 作为Key/Value数据库在这个场景是非常适合的。我们认为一个URL能唯一代表一个网页。所以使用URL作为键值存储。
我们希望在存储之前就进行去重操作,所以需要更改下ITEM_PIPELINES
的配置。
settings.set("ITEM_PIPELINES" , { |
DuplicatesPipeline
长这个样子。
from scrapy.exceptions import DropItem |
当检测到Item已经存在,会抛出DropItem 异常,被丢弃的item将不会被之后的pipeline组件所处理。
最后,运行脚本,你能看到我们的程序欢快地跑起来了。
python run.py |
你可以在 GitHub 上看到本文的完整项目。
注:本文使用的 Scrapy 版本是 0.24,GitHub 上的master分支已支持 Scrapy 1.0
本系列的三篇文章
]]>具体要实现的目标是这样的,有一张Rule
表用来存储各个网站的爬取规则,Scrapy获取Rule
表中的记录后,针对每一条rule自动生成一个spider,每个spider去爬它们各自网站的数据。这样我们只需要维护Rule表中的规则(可以写个Web程序来维护),而不用针对上千个网站写上千个spider文件了。
我们使用 SQLAlchemy 来映射数据库,Rule表的结构如下:from sqlalchemy import Column, String , DateTime, Integer
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Rule(Base):
__tablename__ = 'rules'
id = Column(Integer, primary_key=True)
name = Column(String)
allow_domains = Column(String)
start_urls = Column(String)
next_page = Column(String)
allow_url = Column(String)
extract_from = Column(String)
title_xpath = Column(String)
body_xpath = Column(String)
publish_time_xpath = Column(String)
source_site_xpath = Column(String)
enable = Column(Integer)
接下来我们要重新定制我们的spider,命名为DeepSpider
,让他能够通过rule参数初始化。我们令DeepSpider
继承自 CrawlSpider
,一个提供了更多强大的规则(rule)来提供跟进link功能的类。deep_spider.py
长这个样子:
# -*- coding: utf-8 -*- |
要注意的是start_urls
,rules
等都初始化成了对象的属性,都由传入的rule
对象初始化,parse_item
方法中的抽取规则也都有rule
对象提供。
为了同时运行多个spider,我们需要稍稍修改上节中的运行脚本run.py
,如下所示:
# -*- coding: utf-8 -*- |
我们从数据库中查出启用的rules,并对于rules中每一个规则实例化一个DeepSpider
对象。这儿的一个小技巧是建立了一个RUNNING_CRAWLERS
列表,新建立的DeepSpider
对象 spider 都会加入这个队列。在 spider 运行完毕时会调用spider_closing
方法,并将该spider从RUNNING_CRAWLERS
移除。最终,RUNNING_CRAWLERS
中没有任何spider了,我们会停止脚本。
运行run.py
后,就能对Rule表中网站进行爬取了,但是我们现在还没有对爬下来的结果进行存储,所以看不到结果。下一篇博客,我们将使用 Scrapy 提供的强大的 Pipline 对数据进行保存并去重。
现在我们可以往Rule表中加入成百上千个网站的规则,而不用添加一行代码,就可以对这成百上千个网站进行爬取。当然你完全可以做一个Web前端来完成维护Rule表的任务。当然Rule规则也可以放在除了数据库的任何地方,比如配置文件。
由于本人刚接触 Scrapy 不久,如有理解不当之处或是更好的解决方案,还请不吝赐教 :)
你可以在 GitHub 上看到本文的完整项目。
注:本文使用的 Scrapy 版本是 0.24,GitHub 上的master分支已支持 Scrapy 1.0
本系列的三篇文章
###参考资料
]]>这时候,我迫切地希望能有一个框架可以通过只写一份spider代码和维护多个网站的爬取规则,就能自动抓取这些网站的信息,很庆幸 Scrapy 可以做到这点。鉴于国内外关于这方面资料太少,所以我将这段时间来的经验和代码分享成了本文。
为了讲清楚这件事,我分成了三篇文章来叙述:
本篇文章主要介绍如何使用编程的方式运行Scrapy爬虫。
在开始本文之前,你需要对 Scrapy 有所熟悉,知道 Items、Spider、Pipline、Selector 的概念。如果你是 Scrapy 新手,想了解如何用Scrapy开始爬取一个网站,推荐你先看看官方的教程。
运行一个Scrapy爬虫可以通过命令行的方式(scrapy runspider myspider.py
)启动,也可以使用核心API通过编程的方式启动。为了获得更高的定制性和灵活性,我们主要使用后者的方式。
我们使用官方教程中的 Dmoz 例子来帮助我们理解使用编程方式启动spider。我们的 spider 文件 dmoz_spider.py
长这个样子:
import scrapy |
接下来我们需要写一个脚本run.py
,来运行DmozSpider:
from dmoz_spider import DmozSpider |
然后运行python run.py
就启动了我们的爬虫了,但是由于我们这里没有对爬下来的结果进行任何的存储操作,所以看不到结果。你可以写一个 item pipline 用来将数据存储到数据库,使用settings.set
接口将这个 pipline 配置到ITEMS_PIPLINE
,我们将在第三篇文章中具体讲解这部分内容。下一篇博客将会介绍如何通过维护多个网站的爬取规则来抓取各个网站的数据。
你可以在 GitHub 上看到本文的完整项目。
注:本文使用的 Scrapy 版本是 0.24,GitHub 上的master分支已支持 Scrapy 1.0
刚拿到纸书的第一印象是“哇,好薄啊!”,不过浓缩的都是精华。这更像是一本迷你武林秘籍,在你练功遇到瓶颈时,拿出这本小册子读一读,说不定就找到了突破的方式。纸书与电子书在内容上的差别不大,主要是调整了目录的结构,加了些插图和tips。虽然是第二次读这本书,也有一些新的收获,所以就写了篇文章记录下。
电子书的书名叫《程序员跳槽全攻略》,纸书的书名叫《程序员必读的职业规划书》。从「跳槽攻略」到「职业规划」的改变,一方面是措辞上更加严肃和严谨了,另一方面是这本书在定位上不仅面向在职程序员,还面向了在校学生们。
作为一名即将离开大学校园的应届毕业生,我深深认为在校生们应该看看这本书。私以为毕业后的第一份工作对个人的成长和影响是非常重要的,正确地选择人生的第一份工作是职业规划中的重要一课。而许多在校生对自己的职业没有很清晰的规划,大多数不知道该往什么技术方向发展。应聘PHP,可能只是PHP用最熟练,谈不上喜欢,谈不上规划。看完这本书后,你可能对于要选择哪条技术道路更加清晰。
职业规划说白了就是为了实现人生目标而做的规划。比如我的理想是升职、加薪、迎娶白富美、当上CTO。为了当上CTO的终极目标,必须规划好当前一步。精通一门语言、积累高并发系统的开发经验、做好几个开源项目、让自己的博客UV过千,每一件事都是为了实现终极目标而做出的努力。有了人生目标,做每一件事都会变得有意义有动力,做成每件事的成就感又会让下件事更有动力。
站在风口不一定能飞起来,但站在冰山上必然会沉下去。
互联网技术变化非常快,新技术层出不穷,但是并不是所有技术都有平等的待遇,相反总是有些技术突然之间变得炙手可热,有些技术不温不火逐渐没落。在调整个人定位上本书给了两个建议,(1)学会观察技术趋势(2)投资新兴市场和细分市场。
学会观察技术趋势真是说的容易做到难。未来总是难以预测的,在没有足够的技术敏感性的时候,就看看技术大牛们都在用什么吧。对于应届生来说,书中提到「可以选择一些得到大量投资的行业,通常而言,他们代表了未来的发展方向,比如云计算、大数据、移动互联网、智能硬件、共享经济、互联网金融等」。
投资新兴市场和细分市场方面书中讲了几个例子,有个例子是如果应聘了乌云平台PHP开发工程师,那么「在乌云工作几个月以后,你就能写出来可能是国内最安全的PHP代码…这就是细分市场,比你懂安全的没你懂PHP、比你懂PHP的没你懂安全」。
长辈总是劝戒我们要低调做人,但是程序员应该高调树立个人品牌。原因我就不说了,看书去吧。关键是如何树立个人品牌?
书中列了以下几个建议:
其实,关键就是「分享」二字。「平时遇到的大小问题可以零星发在微博上。相对大量的内容,可以写成文章发在博客上。比较系统的内容,可以在相应文章的基础上整理成迷你书」。个人觉得不错的内容可以提交到开发者头条和CSDN极客头条,借助平台来推广。博客的内容质量是最重要的,只要你持续分享高质量的干货,就不愁没有读者。
开源项目是重磅杀器。很多人认为开始开源项目很难,其实只是不敢迈出第一步而已。找一些自己在在项目时遇到的费时费事的小细节做好,然后开源就可以了。或者用自己新学的语言造个自己感兴趣的轮子,然后开源。或者用开源的形式做一个应用。我自己最近也在做一个开源应用,贵在实践。
本书主要分为原理篇、准备篇、操作篇。推荐好好读读准备篇,会有很多收获。对于正在找工作的同学,操作篇也是非常实用的。
]]>本文将分两部分介绍,第一部分讲解使用 HBase 新版 API 进行 CRUD 基本操作;第二部分讲解如何将 Spark 内的 RDDs 写入 HBase 的表中,反之,HBase 中的表又是如何以 RDDs 形式加载进 Spark 内的。
为了避免版本不一致带来不必要的麻烦,API 和 HBase环境都是 1.0.0 版本。HBase 为单机模式,分布式模式的使用方法类似,只需要修改HBaseConfiguration
的配置即可。
开发环境中使用 SBT 加载依赖项name := "SparkLearn"
version := "1.0"
scalaVersion := "2.10.4"
libraryDependencies += "org.apache.spark" %% "spark-core" % "1.3.0"
libraryDependencies += "org.apache.hbase" % "hbase-client" % "1.0.0"
libraryDependencies += "org.apache.hbase" % "hbase-common" % "1.0.0"
libraryDependencies += "org.apache.hbase" % "hbase-server" % "1.0.0"
新版 API 中加入了 Connection
,HAdmin
成了Admin
,HTable
成了Table
,而Admin
和Table
只能通过Connection
获得。Connection
的创建是个重量级的操作,由于Connection
是线程安全的,所以推荐使用单例,其工厂方法需要一个HBaseConfiguration
。val conf = HBaseConfiguration.create()
conf.set("hbase.zookeeper.property.clientPort", "2181")
conf.set("hbase.zookeeper.quorum", "master")
//Connection 的创建是个重量级的工作,线程安全,是操作hbase的入口
val conn = ConnectionFactory.createConnection(conf)
使用Admin
创建和删除表val userTable = TableName.valueOf("user")
//创建 user 表
val tableDescr = new HTableDescriptor(userTable)
tableDescr.addFamily(new HColumnDescriptor("basic".getBytes))
println("Creating table `user`. ")
if (admin.tableExists(userTable)) {
admin.disableTable(userTable)
admin.deleteTable(userTable)
}
admin.createTable(tableDescr)
println("Done!")
HBase 上的操作都需要先创建一个操作对象Put
,Get
,Delete
等,然后调用Table
上的相对应的方法try{
//获取 user 表
val table = conn.getTable(userTable)
try{
//准备插入一条 key 为 id001 的数据
val p = new Put("id001".getBytes)
//为put操作指定 column 和 value (以前的 put.add 方法被弃用了)
p.addColumn("basic".getBytes,"name".getBytes, "wuchong".getBytes)
//提交
table.put(p)
//查询某条数据
val g = new Get("id001".getBytes)
val result = table.get(g)
val value = Bytes.toString(result.getValue("basic".getBytes,"name".getBytes))
println("GET id001 :"+value)
//扫描数据
val s = new Scan()
s.addColumn("basic".getBytes,"name".getBytes)
val scanner = table.getScanner(s)
try{
for(r <- scanner){
println("Found row: "+r)
println("Found value: "+Bytes.toString(
r.getValue("basic".getBytes,"name".getBytes)))
}
}finally {
//确保scanner关闭
scanner.close()
}
//删除某条数据,操作方式与 Put 类似
val d = new Delete("id001".getBytes)
d.addColumn("basic".getBytes,"name".getBytes)
table.delete(d)
}finally {
if(table != null) table.close()
}
}finally {
conn.close()
}
首先要向 HBase 写入数据,我们需要用到PairRDDFunctions.saveAsHadoopDataset
。因为 HBase 不是一个文件系统,所以saveAsHadoopFile
方法没用。
def saveAsHadoopDataset(conf: JobConf): Unit
Output the RDD to any Hadoop-supported storage system, using a Hadoop JobConf object for that storage system
这个方法需要一个 JobConf 作为参数,类似于一个配置项,主要需要指定输出的格式和输出的表名。
Step 1:我们需要先创建一个 JobConf。//定义 HBase 的配置
val conf = HBaseConfiguration.create()
conf.set("hbase.zookeeper.property.clientPort", "2181")
conf.set("hbase.zookeeper.quorum", "master")
//指定输出格式和输出表名
val jobConf = new JobConf(conf,this.getClass)
jobConf.setOutputFormat(classOf[TableOutputFormat])
jobConf.set(TableOutputFormat.OUTPUT_TABLE,"user")
Step 2: RDD 到表模式的映射
在 HBase 中的表 schema 一般是这样的:
row cf:col_1 cf:col_2
而在Spark中,我们操作的是RDD元组,比如(1,"lilei",14)
, (2,"hanmei",18)
。我们需要将 RDD[(uid:Int, name:String, age:Int)]
转换成 RDD[(ImmutableBytesWritable, Put)]
。所以,我们定义一个 convert 函数做这个转换工作
def convert(triple: (Int, String, Int)) = { |
Step 3: 读取RDD并转换//read RDD data from somewhere and convert
val rawData = List((1,"lilei",14), (2,"hanmei",18), (3,"someone",38))
val localData = sc.parallelize(rawData).map(convert)
Step 4: 使用saveAsHadoopDataset
方法写入HBaselocalData.saveAsHadoopDataset(jobConf)
Spark读取HBase,我们主要使用SparkContext
提供的newAPIHadoopRDD
API将表的内容以 RDDs 的形式加载到 Spark 中。
val conf = HBaseConfiguration.create() |
更完整的代码已上传到 Gist 。
OS: Ubuntu 14.04.1 LTS (GNU/Linux 3.13.0-32-generic x86_64)
Java: jdk1.7.0_75
Hadoop: hadoop-2.6.0
Hbase: hbase-1.0.0
集群机器:
IP | HostName | Mater | RegionServer |
---|---|---|---|
10.4.20.30 | master | yes | no |
10.4.20.31 | slave1 | no | yes |
10.4.20.32 | slave2 | no | yes |
假设你已经安装部署好了 Hadoop 集群和 Java,可以参考 Spark on YARN 集群部署手册 这篇文章。
可以从官方下载地址下载 HBase 最新版本,推荐 stable 目录下的二进制版本。我下载的是 hbase-1.0.0-bin.tar.gz 。确保你下载的版本与你现存的 Hadoop 版本兼容(兼容列表)以及支持的JDK版本(HBase 1.0.x 已经不支持 JDK 6 了)。
解压缩tar -zxvf hbase-1.0.0-bin.tar.gz
cd hbase-1.0.0
编辑hbase-env.sh
文件,修改 JAVA_HOME 为你的路径。# The java implementation to use. Java 1.7+ required.
export JAVA_HOME=/home/spark/workspace/jdk1.7.0_75
编辑conf/hbase-site.xml
文件:<configuration>
<property>
<name>hbase.rootdir</name>
<value>hdfs://master:9000/hbase</value>
</property>
<property>
<name>hbase.cluster.distributed</name>
<value>true</value>
</property>
<property>
<name>hbase.zookeeper.quorum</name>
<value>master,slave1,slave2</value>
</property>
<property>
<name>hbase.zookeeper.property.dataDir</name>
<value>/home/spark/workspace/zookeeper/data</value>
</property>
</configuration>
其中第一个属性指定本机的hbase的存储目录,必须与Hadoop集群的core-site.xml
文件配置保持一致;第二个属性指定hbase的运行模式,true代表全分布模式;第三个属性指定 Zookeeper 管理的机器,一般为奇数个;第四个属性是数据存放的路径。这里我使用的默认的 HBase 自带的 Zookeeper。
配置regionservers,在regionservers文件中添加如下内容:slave1
slave2
regionservers
文件列出了所有运行hbase的机器(即HRegionServer)。此文件的配置和Hadoop中的slaves文件十分相似,每行指定一台机器的主机名。当HBase启动的时候,会将此文件中列出的所有机器启动。关闭时亦如此。我们的配置意为在 slave1, slave2, slave3 上都将启动 RegionServer。
将配置好的 hbase 文件分发给各个 slavescp -r hbase-1.0.0 spark@slave1:~/workspace/
scp -r hbase-1.0.0 spark@slave2:~/workspace/
HBase 会在同一时间打开大量的文件句柄和进程,超过 Linux 的默认限制,导致可能会出现如下错误。2010-04-06 03:04:37,542 INFO org.apache.hadoop.hdfs.DFSClient: Exception increateBlockOutputStream java.io.EOFException
2010-04-06 03:04:37,542 INFO org.apache.hadoop.hdfs.DFSClient: Abandoning block blk_-6935524980745310745_1391901
所以编辑/etc/security/limits.conf
文件,添加以下两行,提高能打开的句柄数量和进程数量。注意将spark
改成你运行 HBase 的用户名。
spark - nofile 32768 |
还需要在 /etc/pam.d/common-session
加上这一行:session required pam_limits.so
否则在/etc/security/limits.conf
上的配置不会生效。
最后还要注销(logout
或者exit
)后再登录,这些配置才能生效!使用ulimit -n -u
命令查看最大文件和进程数量是否改变了。记得在每台安装 HBase 的机器上运行哦。
在master上运行cd ~/workspace/hbase-1.0.0
bin/start-hbase.sh
在 master 运行 jps
应该会有HMaster
进程。在各个 slave 上运行jps
应该会有HQuorumPeer
,HRegionServer
两个进程。
在浏览器中输入 http://master:16010 可以看到 HBase Web UI 。
]]>软件环境:
Ubuntu 14.04.1 LTS (GNU/Linux 3.13.0-32-generic x86_64)
Hadoop: 2.6.0
Spark: 1.3.0
本例中的演示均为非 root 权限,所以有些命令行需要加 sudo,如果你是 root 身份运行,请忽略 sudo。下载安装的软件建议都放在 home 目录之上,比如~/workspace
中,这样比较方便,以免权限问题带来不必要的麻烦。
我们将搭建1个master,2个slave的集群方案。首先修改主机名vi /etc/hostname
,在master上修改为master
,其中一个slave上修改为slave1
,另一个同理。
在每台主机上修改host文件vi /etc/hosts
10.1.1.107 master
10.1.1.108 slave1
10.1.1.109 slave2
配置之后ping一下用户名看是否生效ping slave1
ping slave2
安装Openssh serversudo apt-get install openssh-server
在所有机器上都生成私钥和公钥ssh-keygen -t rsa #一路回车
需要让机器间都能相互访问,就把每个机子上的id_rsa.pub
发给master节点,传输公钥可以用scp来传输。scp ~/.ssh/id_rsa.pub spark@master:~/.ssh/id_rsa.pub.slave1
在master上,将所有公钥加到用于认证的公钥文件authorized_keys
中cat ~/.ssh/id_rsa.pub* >> ~/.ssh/authorized_keys
将公钥文件authorized_keys
分发给每台slavescp ~/.ssh/authorized_keys spark@slave1:~/.ssh/
在每台机子上验证SSH无密码通信ssh master
ssh slave1
ssh slave2
如果登陆测试不成功,则可能需要修改文件authorized_keys的权限(权限的设置非常重要,因为不安全的设置安全设置,会让你不能使用RSA功能 )chmod 600 ~/.ssh/authorized_keys
从官网下载最新版 Java 就可以,Spark官方说明 Java 只要是6以上的版本都可以,我下的是 jdk-7u75-linux-x64.gz
在~/workspace
目录下直接解压tar -zxvf jdk-7u75-linux-x64.gz
修改环境变量sudo vi /etc/profile
,添加下列内容,注意将home路径替换成你的:export WORK_SPACE=/home/spark/workspace/
export JAVA_HOME=$WORK_SPACE/jdk1.7.0_75
export JRE_HOME=/home/spark/work/jdk1.7.0_75/jre
export PATH=$JAVA_HOME/bin:$JAVA_HOME/jre/bin:$PATH
export CLASSPATH=$CLASSPATH:.:$JAVA_HOME/lib:$JAVA_HOME/jre/lib
然后使环境变量生效,并验证 Java 是否安装成功$ source /etc/profile #生效环境变量
$ java -version #如果打印出如下版本信息,则说明安装成功
java version "1.7.0_75"
Java(TM) SE Runtime Environment (build 1.7.0_75-b13)
Java HotSpot(TM) 64-Bit Server VM (build 24.75-b04, mixed mode)
Spark官方要求 Scala 版本为 2.10.x,注意不要下错版本,我这里下了 2.10.4,官方下载地址(可恶的天朝大局域网下载 Scala 龟速一般)。
同样我们在~/workspace
中解压tar -zxvf scala-2.10.4.tgz
再次修改环境变量sudo vi /etc/profile
,添加以下内容:export SCALA_HOME=$WORK_SPACE/scala-2.10.4
export PATH=$PATH:$SCALA_HOME/bin
同样的方法使环境变量生效,并验证 scala 是否安装成功$ source /etc/profile #生效环境变量
$ scala -version #如果打印出如下版本信息,则说明安装成功
Scala code runner version 2.10.4 -- Copyright 2002-2013, LAMP/EPFL
从官网下载 hadoop2.6.0 版本,这里给个我们学校的镜像下载地址。
同样我们在~/workspace
中解压tar -zxvf hadoop-2.6.0.tar.gz
cd ~/workspace/hadoop-2.6.0/etc/hadoop
进入hadoop配置目录,需要配置有以下7个文件:hadoop-env.sh
,yarn-env.sh
,slaves
,core-site.xml
,hdfs-site.xml
,maprd-site.xml
,yarn-site.xml
在hadoop-env.sh
中配置JAVA_HOME
# The java implementation to use. |
在yarn-env.sh
中配置JAVA_HOME
# some Java parameters |
在slaves
中配置slave节点的ip或者host,
slave1 |
修改core-site.xml
<configuration> |
修改hdfs-site.xml
<configuration> |
修改mapred-site.xml
<configuration> |
修改yarn-site.xml
<configuration> |
将配置好的hadoop-2.6.0
文件夹分发给所有slaves吧scp -r ~/workspace/hadoop-2.6.0 spark@slave1:~/workspace/
在 master 上执行以下操作,就可以启动 hadoop 了。cd ~/workspace/hadoop-2.6.0 #进入hadoop目录
bin/hadoop namenode -format #格式化namenode
sbin/start-dfs.sh #启动dfs
sbin/start-yarn.sh #启动yarn
可以通过jps
命令查看各个节点启动的进程是否正常。在 master 上应该有以下几个进程:$ jps #run on master
3407 SecondaryNameNode
3218 NameNode
3552 ResourceManager
3910 Jps
在每个slave上应该有以下几个进程:$ jps #run on slaves
2072 NodeManager
2213 Jps
1962 DataNode
或者在浏览器中输入 http://master:8088 ,应该有 hadoop 的管理界面出来了,并能看到 slave1 和 slave2 节点。
进入官方下载地址下载最新版 Spark。我下载的是 spark-1.3.0-bin-hadoop2.4.tgz。
在~/workspace
目录下解压tar -zxvf spark-1.3.0-bin-hadoop2.4.tgz
mv spark-1.3.0-bin-hadoop2.4 spark-1.3.0 #原来的文件名太长了,修改下
cd ~/workspace/spark-1.3.0/conf #进入spark配置目录 |
在spark-env.sh
末尾添加以下内容(这是我的配置,你可以自行修改):export SCALA_HOME=/home/spark/workspace/scala-2.10.4
export JAVA_HOME=/home/spark/workspace/jdk1.7.0_75
export HADOOP_HOME=/home/spark/workspace/hadoop-2.6.0
export HADOOP_CONF_DIR=$HADOOP_HOME/etc/hadoop
SPARK_MASTER_IP=master
SPARK_LOCAL_DIRS=/home/spark/workspace/spark-1.3.0
SPARK_DRIVER_MEMORY=1G
注:在设置Worker进程的CPU个数和内存大小,要注意机器的实际硬件条件,如果配置的超过当前Worker节点的硬件条件,Worker进程会启动失败。
vi slaves
在slaves文件下填上slave主机名:slave1
slave2
将配置好的spark-1.3.0
文件夹分发给所有slaves吧scp -r ~/workspace/spark-1.3.0 spark@slave1:~/workspace/
sbin/start-all.sh |
用jps
检查,在 master 上应该有以下几个进程:$ jps
7949 Jps
7328 SecondaryNameNode
7805 Master
7137 NameNode
7475 ResourceManager
在 slave 上应该有以下几个进程:$jps
3132 DataNode
3759 Worker
3858 Jps
3231 NodeManager
进入Spark的Web管理页面: http://master:8080
#本地模式两线程运行 |
注意 Spark on YARN 支持两种运行模式,分别为yarn-cluster
和yarn-client
,具体的区别可以看这篇博文,从广义上讲,yarn-cluster适用于生产环境;而yarn-client适用于交互和调试,也就是希望快速地看到application的输出。
Git 最核心的一个概念就是工作流。工作区(Workspace)是电脑中实际的目录;暂存区(Index)像个缓存区域,临时保存你的改动;最后是版本库(Repository),分为本地仓库和远程仓库。下图真是一图胜千言啊,就无耻盗图了。
git remote add origin git@server-name:path/repo-name.git #添加一个远程库 |
git remote #要查看远程库的信息 |
git push origin master #推送到远程master分支 |
git clone git@server-name:path/repo-name.git #克隆远程仓库到本地(能看到master分支) |
git fetch origin pull/ID/head:BRANCHNAME |
$ git branch --set-upstream branch-name origin/branch-name
,可以建立起本地分支和远程分支的关联,之后可以直接git pull
从远程抓取分支。
另外,git pull
= git fetch
+ merge
to local
$ git push origin --delete bugfix |
项目往前推进的过程中,远程仓库上经常会增加一些分支、删除一些分支。 所以有时需要与远程同步下分支信息。
git fetch -p |
-p
就是修剪的意思。它在fetch之后删除掉没有与远程分支对应的本地分支,并且同步一些远程新创建的分支和tag。
git log --pretty=oneline filename #一行显示 |
git reset --hard HEAD^ #回退到上一个版本 |
用HEAD表示当前版本,上一个版本就是HEAD^
,上上一个版本就是HEAD^^
,HEAD~100
就是上100个版本。
git status #查看工作区、暂存区的状态 |
git diff #查看未暂存的文件更新 |
使用内建的图形化git:gitk
,可以更方便清晰地查看差异。当然 Github 客户端也不错。
git rm <file> #直接删除文件 |
git stash #储藏当前工作 |
git branch develop #只创建分支 |
git checkout master #切换到主分支 |
git tag #列出现有标签 |
###创建标签git tag v0.1 #新建标签,默认位 HEAD
git tag v0.1 cb926e7 #对指定的 commit id 打标签
git tag -a v0.1 -m 'version 0.1 released' #新建带注释标签
git checkout <tagname> #切换到标签 |
设置 commit 的用户和邮箱
git config user.name "xx" #设置 commit 的用户 |