这篇文章来介绍一下实时计算领域的新霸主 Apache Flink。Flink 最早于 2008 年诞生于柏林理工大学,然后在 2014 年进入 Apache 基金会孵化器,毕业之后迅速走红。在 2015 年,关于 Flink 的论文问世,也就是 Apache Flink: Stream and Batch Processing in a Single Engine。从论文的题目也可以看出 Flink 的定义一个批流一体的计算引擎,这个理念也很契合 Google Dataflow 的理念。当然 Flink 的设计很多借鉴了 Google Dataflow 模型的思想,比如一些语义。这篇文章简单介绍一下 Flink,错误之处还请指正。
0. Overview
在之前的一篇文章 现代流式计算的基石:Google DataFlow 中说过 Flink 的两个主要优势在于:1. 实现了 Google Dataflow/Beam 的编程模型,2. 使用分布式异步快照算法 Chandy-Lamport 的变体。而这其中的 Dataflow/Beam 语义模型目前应该是 Flink 支持的最好的。虽然 Spark Structured Streaming 也开始实现借鉴 Dataflow/Beam 语义模型,但是在 continuous mode 上面支持还是偏弱。还有一点比较主要的是,Spark 的核心还是在于 Spark SQL 和 AI 方面,实时计算这块的发展的优先级要弱很多。
1. Google Dataflow/Beam
参考我之前的一篇文章: 现代流式计算的基石:Google DataFlow 。
Flink 中的 event time/processing time,window,watermark 等思想都是继承自 DataFlow,这里就不再赘述了。
2. Flink 编程模型
在这个 Declarative API 流行的时代,Flink 的编程模型也提供了不同抽象程度的 API,按抽象程度高低划分如下:SQL > Table API > DataStream/DataSet API > Stateful Stream Process。其中 SQL 和 Table API 都可以称为 Declarative API。
2.1 SQL
下面是一个 SQL API 的示例。
val env = StreamExecutionEnvironment.getExecutionEnvironment
val tableEnv = TableEnvironment.getTableEnvironment(env)
// read a DataStream from an external source
val ds: DataStream[(Long, String, Integer)] = env.addSource(...)
// SQL query with an inlined (unregistered) table
val table = ds.toTable(tableEnv, 'user, 'product, 'amount)
val result = tableEnv.sqlQuery(
s"SELECT SUM(amount) FROM $table WHERE product LIKE '%Rubber%'")
Flink 的 SQL parse 用的是 Apache Calcite,也是一个在 SQL 领域非常出名的开源项目。关于 Apache Calcite 的具体细节,后面有时间再写。
2.2 Table API
所谓 Table API,简单来说就是将 SQL 语句解析成具体对应的函数。正常在 SQL API 这个层面上,有的时候 Runtime ERROR 无法在编译期间检测出来,比如 select a from table
这个 SQL 查询只有在运行期间才能确定 field a 是否存在。对于这个问题 Flink SQL 能不能在编译期间检测出来,我不是很确定。
使用 Table API 还有一个显著的好处在于我们写代码的时候可以利用 IDE 的 autocomplete 功能直接看到 Table API 的支持范围。下面是 Table API 的示例代码。
// environment configuration
// ...
// specify table program
val orders: Table = tEnv.scan("Orders") // schema (a, b, c, rowtime)
val result: Table = orders
.filter('a.isNotNull && 'b.isNotNull && 'c.isNotNull)
.select('a.lowerCase() as 'a, 'b, 'rowtime)
.window(Tumble over 1.hour on 'rowtime as 'hourlyWindow)
.groupBy('hourlyWindow, 'a)
.select('a, 'hourlyWindow.end as 'hour, 'b.avg as 'avgBillingAmount)
2.3 DataStream/DataSet API
在前面的 SQL 抑或是 Table API,我们都将数据源当成一张表来处理。但是因为 Declarative API 暴露出来的信息有限,如果需要处理逻辑具有更强的表现力(比如自定义处理逻辑),则可以使用 DataStream/DataSet API。DataStream 和 DataSet 在 Flink 中是完全不同的 API。DataSet 一般用来处理有界数据(bounded data),也就是静态数据,而 DataStream 一般用来处理有界/无界数据(bounded data or unbouned data)。对于 DataStream 来说,一般需要更低的延迟以及更多和 stream 处理相关的技术,而 DataSet 因为用来处理静态数据,所以可以通过一些底层优化进行更高效的处理。下面是 DataStream API 的示例代码。
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.windowing.time.Time
object WindowWordCount {
def main(args: Array[String]) {
val env = StreamExecutionEnvironment.getExecutionEnvironment
val text = env.socketTextStream("localhost", 9999)
val counts = text.flatMap { _.toLowerCase.split("\\W+") filter { _.nonEmpty } }
.map { (_, 1) }
.keyBy()
.timeWindow(Time.seconds(5))
.sum(1)
counts.print()
env.execute("Window Stream WordCount")
}
}
和 DataStream 相比,DataSet
public class WordCountExample {
public static void main(String[] args) throws Exception {
final ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
DataSet<String> text = env.fromElements(
"Who's there?",
"I think I hear them. Stand, ho! Who's there?");
DataSet<Tuple2<String, Integer>> wordCounts = text
.flatMap(new LineSplitter())
.groupBy()
.sum(1);
wordCounts.print();
}
public static class LineSplitter implements FlatMapFunction<String, Tuple2<String, Integer>> {
@Override
public void flatMap(String line, Collector<Tuple2<String, Integer>> out) {
for (String word : line.split(" ")) {
out.collect(new Tuple2<String, Integer>(word, 1));
}
}
}
}
2.4 Stateful Stream Processing
按照官方的说明,所谓 Stateful Stream Processing 就是将 Process Function 和 DataStream API 结合起来做状态计算。通过 ProcessFunction,用户可以:1. 处理一个或者多个 stream 中的 event 数据;2. 使用 state 数据;3. 针对 event time 和 processing time 做不同的处理。下面是一个代码的示例:
// the source data stream
val stream: DataStream[Tuple2[String, String]] = ...
// apply the process function onto a keyed stream
val result: DataStream[Tuple2[String, Long]] = stream
.keyBy()
.process(new CountWithTimeoutFunction())
/**
* The data type stored in the state
*/
case class CountWithTimestamp(key: String, count: Long, lastModified: Long)
/**
* The implementation of the ProcessFunction that maintains the count and timeouts
*/
class CountWithTimeoutFunction extends ProcessFunction[(String, String), (String, Long)] {
/** The state that is maintained by this process function */
lazy val state: ValueState[CountWithTimestamp] = getRuntimeContext
.getState(new ValueStateDescriptor[CountWithTimestamp]("myState", classOf[CountWithTimestamp]))
override def processElement(value: (String, String), ctx: Context, out: Collector[(String, Long)]): Unit = {
// initialize or retrieve/update the state
val current: CountWithTimestamp = state.value match {
case null =>
CountWithTimestamp(value._1, 1, ctx.timestamp)
case CountWithTimestamp(key, count, lastModified) =>
CountWithTimestamp(key, count + 1, ctx.timestamp)
}
// write the state back
state.update(current)
// schedule the next timer 60 seconds from the current event time
ctx.timerService.registerEventTimeTimer(current.lastModified + 60000)
}
override def onTimer(timestamp: Long, ctx: OnTimerContext, out: Collector[(String, Long)]): Unit = {
state.value match {
case CountWithTimestamp(key, count, lastModified) if (timestamp == lastModified + 60000) =>
out.collect((key, count))
case _ =>
}
}
}
3. Flink Runtime
Flink 可以有多种部署模式:standalone,on Mesos, on YARN, on Kubernetes。Flink 作业可以通过 client 提交,运行时二手游戏账号转让架构主要包括三个部分:client,JobManager,TaskManager。
- client: 负责提交作业,提交完作业可以退出,可以理解成 gateway。
- JobManager: 类似 Spark 里面的 Driver,负责 Task 的调度等工作。
- TaskManager: 类似 Spark 里面的 Executor,负责运行具体的 Task,按 Task Slot 分。
- Task 就是一个具体的执行单元。
Task
Flink 和 Spark 中的 Task 不太相同,Flink 里面的 Task 是一个 Operator 链,可以认为是可以放在一个进程一起执行的最长的 Operator 链。如下图。这么做的好处也很明显:在一个进程里面通信相比于进程间或者 RPC 调用吞吐更大,并且延迟更低。
4. Stateful Processing
正常我们的计算逻辑都是针对 event 的单独处理,但是很多时候我们需要整合多个 event 或者多个 stream 的数据或者历史数据进行处理,这个时候就需要 Stateful,也就是状态计算。熟悉 Spark Streaming 的同学可以类比 updateStateByKey 和 mapWithState。
Flink 中的 state 支持两种类型:Keyed State 和 Operator State。Keyed State 只能基于 KeyedStream 使用,是和 Key 关联的,每个 key 可能对应一个 state 数据。Operator State 是和 Operator 实例关联,Operator State 里面可能包含很多个 key 对应的 state。
Keyed State 和 Operator State 都有两种不同形式的表示:Managed 和 Raw State。Managed State 是由 Flink Runtime 管理,Raw State 由 Operator 自己管理,区别在于关于 Raw State 的信息,Flink 完全不了解,只会讲这些状态数据当成字节数据处理。考虑到序列化等 overhead,一般都推荐使用 Managed State。下面介绍一下 Flink 中支持的 Managed Keyed State,Operator State 就不介绍了。
- ValueState:非常简单,state 数据就是一个 T 类型的单值数据。可以通过 update(T) 和 value() 接口进行操作。
- ListState: 和 ValueState 的区别在于 state 数据不是单值而是一个 List。支持的操作有:add(T),addAll(List),get(),update(List).
- ReducingState: 数据也是一个 T 类型的单值。和 ListState 类似,只是在每次 add(T) 添加数据的时候会自动通过 reduceFunction 进程 reduce 操作。
- AggregatingState:区别于 ReducingState 在于聚合之后的数据类型可以变化。
- FoldingState : 类似 AggregatingState,已经 deprecated.
- MapState: state 数据是一个 Map 类似,这次的操作有:put(UK, UV),putAll(Map),get(UK),entries(),keys(),values()。
下面是一个具体使用 ValueState 的 scala 代码官方例子,在这个例子中 (Long, Long) 类似就对应上面的数据类型 T 类型。
class CountWindowAverage extends RichFlatMapFunction[(Long, Long), (Long, Long)] {
private var sum: ValueState[(Long, Long)] = _
override def flatMap(input: (Long, Long), out: Collector[(Long, Long)]): Unit = {
// access the state value
val tmpCurrentSum = sum.value
// If it hasn't been used before, it will be null
val currentSum = if (tmpCurrentSum != null) {
tmpCurrentSum
} else {
(0L, 0L)
}
// update the count
val newSum = (currentSum._1 + 1, currentSum._2 + input._2)
// update the state
sum.update(newSum)
// if the count reaches 2, emit the average and clear the state
if (newSum._1 >= 2) {
out.collect((input._1, newSum._2 / newSum._1))
sum.clear()
}
}
override def open(parameters: Configuration): Unit = {
sum = getRuntimeContext.getState(
new ValueStateDescriptor[(Long, Long)]("average", createTypeInformation[(Long, Long)])
)
}
}
object ExampleCountWindowAverage extends App {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.fromCollection(List(
(1L, 3L),
(1L, 5L),
(1L, 7L),
(1L, 4L),
(1L, 2L)
)).keyBy(_._1)
.flatMap(new CountWindowAverage())
.print()
// the printed output will be (1,4) and (1,5)
env.execute("ExampleManagedState")
}
State TTL
Flink 支持针对单个 key 的 TTL。
val ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build
val stateDescriptor = new ValueStateDescriptor[String]("text state", classOf[String])
stateDescriptor.enableTimeToLive(ttlConfig)
但是对于超时的 key 的清理是惰性清理策略,也就是说只有在用户显示调用超时的 key 的时候才会去执行清理工作。目前 background 的自动清理方案也有,但是还不是很成熟,暂且按住不表。
State 后端存储
作为状态数据,为了应对系统的 failover,我们很多时候需要将系统 state 数据进行定时的 checkpoint 并存储起来,也就是我们常说的分布式快照(Distributed Snapshot)。Flink 的分布式快照算法使用的是 Chandy-Lamport 算法的变体,可以实现异步快照和增量快照。checkpoint 可以通过三种方式进行存储:
- MemoryStateBackend
- FsStateBackend
- RocksDBStateBackend
MemoryStateBackend 将 state 数据作为 Java Heap 中的一个 Object。当做 checkpoint 的时候,state 数据会被存储到 JobManager 的 Java Heap 内存中。MemoryStateBackend 的缺点非常明显,受限于内存大小以及网络通信(Flink JobManager 和 TaskManager 直接传输使用的是 akka,默认的大小限制是 10Mb)。所以 MemoryStateBackend 一般用于 local 模式调试使用。
FsStateBackend 也就是将 checkpoint 保存在文件系统中,比如 HDFS,默认使用异步 snapshot 存储。整个系统的运行状态 state 数据还是存储在 TaskManager 的内存中,在做 checkpoint 的时候才将整个系统的 snapshot 异步存储到文件系统中,同时会在 JobManager 的内存中存储少量的元数据。好处在于可以存储更大的 state 数据。
RocksDBStateBackend 类似 FsStateBackend,区别在于 TaskManager 的 state 数据存储在 RocksDB 里面。RocksDB 是由 FaceBook 开源的一种适用于 fast storage 的嵌入式、持久化 kv 存储的存储引擎,可以类比 Google 的 LevelDB。RocksDB 目前在很多分布式的场景下用来存储一些 metadata。RocksDBStateBackend 相比于 FsStateBackend 的优势非常明显,不会受限于 TaskManager 的内存大小,只会受限于磁盘大小,但是缺点也很明显,overhead 更重。除此之外,这种模式是唯一支持增量 snapshot 的。
5. Exactly-once 语义
所谓 exactly-once 是说对于进入到系统的数据,每条数据只处理一次。一个合格的分布式计算引擎是可以保证在引擎内部的处理是满足 exactly-once 语义的,但是涉及到 source 和 sink 的时候并是所有的引擎都能满足 exactly-once 语义的,也就是常说的 end-to-end exactly-once 语义,中文一般称作端到端。我们这里要讨论的是 Flink 的 end-to-end exactly-once 语义如何保证。
再讨论 Flink 之前,我们先看一下常规的方法如何保证?首先对于引擎内部的 exactly-once 语义利用 checkpoint 都是可以满足的。对于 source 来说,我们只要记录下 source 处理的 offset (也可以记录在 checkpoint 中)以及 source 端支持 replay 的话,那么 source 端也是可以支持。对于 sink 端的话,如果 sink 端支持幂等写入的话,也就是同一条设计到写的操作对系统只产生一次结果,那么 sink 端也是可以满足 exactly-once 语义的。除此之外,如果 sink 端支持 transaction commit 的话也是可以的。Flink 的做法就是类似 transaction commit 的方式,具体的方法是使用 two phase commit,也就是两阶段提交协议。
两阶段提交协议在分布式事务领域是一种非常基础的技术,简单来说就是当 transaction 跨多个节点时,引入一个 coordinator (协调者),每个节点将自己的操作结果告知 coordinator(这个阶段是 pre-commit),由 coordinator 根据情况来决定整个 transaction 的成功与否(这个阶段是 commit)。pre-commit 和 commit 合在一起称为 two phase commit。
6. CEP
CEP,全称是 Complex Event Processing,是上个世纪九十年代产生的概念,用来分析事件流。与正则表达式用来匹配字符串类似,CEP 是利用特定的规则来匹配事件流。在去年 (2018) 的 Flink Forward 上面,滴滴分享了自己在 CEP 上面的实践,感兴趣的可以看一看。
为什么要在这里提一下 CEP,因为在一些实时流的场景中应用还是很广泛的,比如反作弊。我们去年的业务场景在这方面也是有很强烈的需求,只不过当时开始技术选型的时候,没有人告诉我可以使用 Flink CEP。