-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathatom.xml
502 lines (285 loc) · 353 KB
/
atom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>时间与精神的小屋</title>
<subtitle>专注思考的时候,时间仿佛也静下来了</subtitle>
<link href="/atom.xml" rel="self"/>
<link href="https://link3280.github.io/"/>
<updated>2024-12-12T05:35:42.742Z</updated>
<id>https://link3280.github.io/</id>
<author>
<name>Paul Lin</name>
</author>
<generator uri="http://hexo.io/">Hexo</generator>
<entry>
<title>Iceberg 实践随想</title>
<link href="https://link3280.github.io/2024/12/12/Iceberg-%E5%AE%9E%E8%B7%B5%E9%9A%8F%E6%83%B3/"/>
<id>https://link3280.github.io/2024/12/12/Iceberg-实践随想/</id>
<published>2024-12-12T04:18:50.000Z</published>
<updated>2024-12-12T05:35:42.742Z</updated>
<content type="html"><![CDATA[<p>在过去的一年多,笔者工作中心逐渐从 Flink 转移到 Iceberg 上。Iceberg 近年发展迅猛,在与 Hudi、Delta 并称的数据湖御三家竞争中脱颖而出,目前基本已是事实标准。这点在不久前的 Databricks 和 Snowflake 这对老对手在 Iceberg 话语权的针锋相对上就足以体现[1]。</p><p>虽然自数据湖(准确来说应该是数据湖表格式)兴起已经过去四五个年头,但直至今日数据湖仍未称得上成熟,加上 Hive 迁移数据湖涉及到的业务改造工作量巨大,不少核心业务和老业务仍未有动力推进,可以说数据湖仍有很多未竟之事。文本谈下笔者在 Iceberg 实践中的随想,笔之所至即思之所至。</p><a id="more"></a><h2 id="数据湖与云原生"><a href="#数据湖与云原生" class="headerlink" title="数据湖与云原生"></a>数据湖与云原生</h2><p>得益于 S3 等对象存储的成功,或者说碍于云上块存储的高昂价格,上云的大数据存储系统纷纷将底座迁移到 S3 上,而其中最为核心是数据仓库的云原生改造。然而,作为数据仓库的事实标准,Hive 是基于文件系统来构建的,若直接移植到对象存储会严重水土不服,具体包括:</p><ul><li>Hive 的元数据仅管理到 partition 粒度,而非文件粒度,这导致每次查询 planning 的时候都需要获取 partition 下的文件,然而该操作只适合层次结构的文件系统,在扁平对象存储上相当于一个基于前缀的范围查询,性能糟糕不合适频繁调用。</li><li>基于上一点延伸,因为 Hive planning 会列出(<code>ls</code>) partition 目录来决定输入文件,所以 Hive 同时也利用了文件系统常用的以 <code>.</code> 未前缀的隐藏文件机制来隔离未 commit 的临时文件,待写入完成再 rename 为正式文件。不难猜到,这套机制在对象存储上也无法应用,因为对象的 rename 相当于重写一个新对象,是非常昂贵的操作。</li></ul><p>为此,早在 2018 年左右 Databricks 和 Netflix 分别研发并开源了 DeltaLake 和 Iceberg。有意思的是,Hudi 起源更早,其目标是解决 Hive 不支持 update/detele 的问题,但随着 Databricks 在 2020 年提出的 Lakehouse 热词,三者都被划归同一领域,发展也逐渐趋同,可谓异途同归。</p><h2 id="Iceberg-核心创新"><a href="#Iceberg-核心创新" class="headerlink" title="Iceberg 核心创新"></a>Iceberg 核心创新</h2><p>在笔者看来,以 Iceberg 为代表的 Lakehouse 存储格式的核心创新在于以下三点元数据的设计,其余特性均是它们的延伸:</p><p><center><img src="/img/iceberg-thoughts/img1.iceberg-features.png" alt="img1.iceberg-features" title="img1.iceberg 特性"></center></p><ul><li>元数据追踪数据文件列表,即 manifest</li><li>元数据以 append-only 方式记录</li><li>元数据在与数据文件存储在相同的存储系统,而不是独立存储在 db</li></ul><h3 id="元数据追踪数据文件列表"><a href="#元数据追踪数据文件列表" class="headerlink" title="元数据追踪数据文件列表"></a>元数据追踪数据文件列表</h3><p>首先,数据文件列表被纳入元数据,这使得文件可以精细化管理。文件的路径可以灵活设置,不需要在同个目录下,是否读取由引擎在读时决定。引擎根据文件的统计信息提前判断是否要打开该文件读取,即左右 Iceberg 性能的关键 Min/Max 过滤和布隆过滤器。</p><p>其次,由于文件路径不再重要,分区与文件路径解耦,所以Iceberg 的分区可以且必须支持动态设置,即 Hidden Partition 和 Partition Evolution 两项功能。</p><p>最后,文件的种类也可以拓展,不仅限于 data file,还能新增 detele file 以支持行级的更新和删除。</p><h3 id="元数据以-append-only-方式记录"><a href="#元数据以-append-only-方式记录" class="headerlink" title="元数据以 append-only 方式记录"></a>元数据以 append-only 方式记录</h3><p>元数据以 append-only 的方式写入而不是原地更新,这使得元数据具备数据库操作日志一样的历史追踪能力,在此基础上 Iceberg 也提供 MVCC 能力。不同于数据库,Iceberg 的快照并不是读时产生的,而是写时产生的,通常只会保存最近若干次数的写入产生的快照,每个快照都是完整可读的。用户读取指定的历史快照,即 Iceberg 的 Time Travel 特性;用户命名某个快照或者对某个快照进行分叉的更新,即 Git-like 的 branch/tag 特性。</p><h3 id="元数据在与数据文件存储在相同的存储系统"><a href="#元数据在与数据文件存储在相同的存储系统" class="headerlink" title="元数据在与数据文件存储在相同的存储系统"></a>元数据在与数据文件存储在相同的存储系统</h3><p>元数据被直接存储在与数据文件相同的存储系统上,这使得元数据非常开放。一开始接触到该设计时笔者略有惊讶,因为元数据的 SLA 和访问权限显著高于普通数据,按传统应存储在专用的存储系统。例如最常见的关系型数据库,有完善的访问控制、成熟的容灾恢复和优秀的读写性能。</p><p>然而,舍弃掉数据库的种种优点,换来的是元数据的拓展能力和开放性。元数据不再受限于数据库的性能,能承载的表数量仅受普通数据存储的容量限制,没有类型 Hive Metastore 及其数据库的单点问题。同时,元数据的文件可被任意引擎通过多种 Catalog 读写,尤其 Catalog 有 Nessie、Hive Metore、Polaris 等等,各有所长满足不同需求。</p><h2 id="Iceberg-痛点"><a href="#Iceberg-痛点" class="headerlink" title="Iceberg 痛点"></a>Iceberg 痛点</h2><h3 id="运维管理"><a href="#运维管理" class="headerlink" title="运维管理"></a>运维管理</h3><p>Iceberg 的性能很大程度上依赖于元数据和数据文件的高效组织,就像关系型数据库会通过拆分和合并节点来维护 btree 索引的性能。然而 Iceberg 没有守护进程,日常运维需要依赖用户手动执行 Spark 存储过程,例如小文件的合并、manifest 的重写。虽有 Amoro[2] 这样的湖仓管理系统可以接手运维管理,但毕竟从外部管理会额外引入定时扫描元数据的成本,而且 Amoro 的运维操作在 Iceberg 看来并没有更高优先级,所以可能与普通用户的写操作冲突,不能算非常优雅。</p><h3 id="写并发有限"><a href="#写并发有限" class="headerlink" title="写并发有限"></a>写并发有限</h3><p>Iceberg 通过 Catalog 提供锁,然而大数据场景决定锁的粒度不可能非常精细,例如最常用的 Hive Catalog 利用了 Hive Metastore 的表锁。默认情况下,Iceberg 任意 commit 都需要获取表锁,虽然整体 commit 的时间可能只需要几毫秒,但对于频繁更新的场景显然是很大的影响。若有锁冲突,Iceberg 会 backoff 重试最多 4 次,但 Iceberg 的锁并没有优先级区分,有可能会让某个 commit 饿死。</p><h3 id="缺乏主键,实时更新困难"><a href="#缺乏主键,实时更新困难" class="headerlink" title="缺乏主键,实时更新困难"></a>缺乏主键,实时更新困难</h3><p>Iceberg 最初是为批计算设计,对于流计算并不友好。例如对于更新操作,Iceberg 提供每次重写数据的 COW 和基于 delete 文件的 MOR。虽说 MOR 适合写多读少的流计算,但 delete 文件有 position delete 和 equality delete 两种均不太好用:前者需要引擎知晓更新目标行所在的数据文件位置,这对于流计算几乎是不可能的;而后者性能奇差,几乎处于不可用的状态,Iceberg 社区也在着手废弃这个功能[3]。</p><p>这也是为什么 Flink 社区会孵化出 Paimon 这样一个为流计算而生的数据湖表格式的缘故。Paimon 与 Hudi 一样有行主键,对更新操作非常友好。目前 Paimon 发展迅速,同时已经支持兼容 Iceberg 的元数据,但还需要时间证明自己。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>回顾历史不难发现随着数据总量增长,数据仓库技术逐渐趋于开放。最早的数据仓库是 MPP 思路,例如 Teradata,无论数据还是元数据都是封闭的;后续 Hive 出现基于 HDFS 构建数据仓库,将数据文件权限开放出来;现在数据湖表格式的出现面向云原生的对象存储构建数据仓库,将元数据的权限也开放出来。当然,这个趋势并不一定会一直持续,随着降本增效抑制数据增长和软硬件技术的发展,易用的 MMP 数据仓库或许会重新成为主流。</p><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ol><li><a href="https://juicefs.com/zh-cn/blog/user-stories/hdfs-to-object-storage-juicefs" target="_blank" rel="external">https://juicefs.com/zh-cn/blog/user-stories/hdfs-to-object-storage-juicefs</a></li><li><a href="https://github.com/apache/amoro" target="_blank" rel="external">https://github.com/apache/amoro</a></li><li><a href="https://lists.apache.org/thread/z0gvco6hn2bpgngvk4h6xqrnw8b32sw6" target="_blank" rel="external">https://lists.apache.org/thread/z0gvco6hn2bpgngvk4h6xqrnw8b32sw6</a></li></ol>]]></content>
<summary type="html">
<p>在过去的一年多,笔者工作中心逐渐从 Flink 转移到 Iceberg 上。Iceberg 近年发展迅猛,在与 Hudi、Delta 并称的数据湖御三家竞争中脱颖而出,目前基本已是事实标准。这点在不久前的 Databricks 和 Snowflake 这对老对手在 Iceberg 话语权的针锋相对上就足以体现[1]。</p>
<p>虽然自数据湖(准确来说应该是数据湖表格式)兴起已经过去四五个年头,但直至今日数据湖仍未称得上成熟,加上 Hive 迁移数据湖涉及到的业务改造工作量巨大,不少核心业务和老业务仍未有动力推进,可以说数据湖仍有很多未竟之事。文本谈下笔者在 Iceberg 实践中的随想,笔之所至即思之所至。</p>
</summary>
<category term="Iceberg" scheme="https://link3280.github.io/categories/Iceberg/"/>
<category term="数据湖" scheme="https://link3280.github.io/tags/%E6%95%B0%E6%8D%AE%E6%B9%96/"/>
<category term="Iceberg" scheme="https://link3280.github.io/tags/Iceberg/"/>
</entry>
<entry>
<title>基于 Kyuubi 实现分布式 Flink SQL 网关</title>
<link href="https://link3280.github.io/2023/10/12/%E5%9F%BA%E4%BA%8E-Kyuubi-%E5%AE%9E%E7%8E%B0%E5%88%86%E5%B8%83%E5%BC%8F-Flink-SQL-%E7%BD%91%E5%85%B3/"/>
<id>https://link3280.github.io/2023/10/12/基于-Kyuubi-实现分布式-Flink-SQL-网关/</id>
<published>2023-10-12T13:57:24.000Z</published>
<updated>2023-10-14T02:52:40.294Z</updated>
<content type="html"><![CDATA[<p>Apache Kyuubi [1] 是一个分布式多租户的 SQL 网关,主要功能为接受用户的通过 JDBC/REST 等协议提交的 SQL 并根据多租户隔离策略路由给其管理的 SQL 引擎执行。在最新的 Kyuubi 1.8 版本,Kyuubi Flink Engine 迁移到 Flink SQL Gateway(下简称 FSG) API 之上并支持 Flink Application 模式,这让我们能借助 Kyuubi 快速部署生产可用的分布式 Flink SQL 网关。</p><a id="more"></a><h2 id="为什么需要-Kyuubi"><a href="#为什么需要-Kyuubi" class="headerlink" title="为什么需要 Kyuubi"></a>为什么需要 Kyuubi</h2><p>相信不少读者首先会想到的问题是,Flink 已经提供 SQL Gateway,为什么还需要引入 Kyuubi 呢?其实关键答案就在 Kyuubi 描述中: 一是分布式,二是多租户,两者相辅相成。网关负载能力需要水平拓展,那么自然会演进到分布式;在分布式环境下需要保证会话的亲和性,那么自然产生多租户路由的需求;而多租户对隔离性有较高要求,因此反过来也会促进分布式架构的发展。</p><p>SQL Gateway 和 SQL Client 的组合能开箱即用并帮助我们快速跑通 demo,然而在企业生产环境,单个实例往往难以满足用户请求,此外不同业务用户对于 Flink 版本、内置依赖、资源配置常有不同需求,这导致我们不得不维护多个 SQL Gateway 实例。更令人头疼的是,一个实例可能在多个小用户间共享,也可能被一个大用户独占,用户与 SQL Gateway 实例间成多对多的映射关系。除此以外,高可用和负载均衡也是不得不面对的难题。种种问题造成 SQL Gateway 的运维管理成本居高不下,而 Kyuubi 的出现则很大程度地解决了这个问题。</p><h2 id="Kyuubi-基本原理"><a href="#Kyuubi-基本原理" class="headerlink" title="Kyuubi 基本原理"></a>Kyuubi 基本原理</h2><p><center><img src="/img/kyuubi-flink-engine/img1.kyuubi-architecture.png" alt="图1. Kyuubi 架构" title="图1. Kyuubi 架构"></center></p><p>Kyuubi 主要有 Client、Server 和 Engine 三个模块:</p><ul><li>Kyuubi Client 比较简单,既有 <code>kyuubi-beeline</code> 这样开箱即用的客户端,也可选择 DBeaver 或 JDBC/REST 之类的开放工具或协议。</li><li>Kyuubi Server 是最为核心的模块,主要职责为:<ul><li>为 Client 提供包括 多种协议的 Frontend,接受 Connection。</li><li>管控 Engine 的生命周期并将用户请求路由到合适的 Engine。</li><li>管理 Session / Operation 状态。</li></ul></li><li>Kyuubi Engine 是承载实际计算负载的模块,负责将 Kyuubi Server 的请求翻译为引擎原生操作,支持 Spark / Flink / Trino 等多种计算引擎。</li></ul><p>不少读者可能已经发现,FSG 的定位类似于 Kyuubi 的 Flink Engine (实际上 Kyuubi Flink Engine 的确内嵌一个 FSG),而 Kyuubi Server 这层抽象正是解决分布式和多租户问题的关键。</p><p>Kyuubi 架构中 Client 与 Server、Server 与 Engine 之间的通信均有服务发现和负载均衡,这意味着不同模块之间是非常松耦合的。Client 可以连到任意一个相同 Namespace 的 Kyuubi Server 上,Kyuubi Server 也能访问同个 Namespace 下的任意 Engine。对于 Batch 场景的有状态短连接,Kyuubi 会持久化相关状态到数据库并通过 Server 间转发确保连接总是能落到同个 Server。这样的设计使得 Kyuubi 具有优秀的水平拓展能力。</p><p><center><img src="/img/kyuubi-flink-engine/img2.kyuubi-router.png" alt="图2. Kyuubi 会话路由" title="图2. Kyuubi 会话路由"></center></p><p>如上文所说,多租户请求的路由是 Kyuubi Server 的核心功能。Kyuubi Server 提供不同程度的 Engine Share Level 以满足多租户的隔离需求,同时根据 Engine Share Level 来选取请求对应的 Engine。</p><table><thead><tr><th>Share Level</th><th>描述</th><th>共享性</th><th>隔离性</th><th>适合场景</th></tr></thead><tbody><tr><td>CONNECTION</td><td>每个连接独占一个 Engine</td><td>最低</td><td>最高</td><td>大数据量 ETL 或 Ad-hoc 查询或实时作业</td></tr><tr><td>USER</td><td>每个用户独占一个 Engine</td><td>中等</td><td>中等</td><td>普通 ETL 或者 Ad-hoc 查询或实时作业</td></tr><tr><td>GROUP</td><td>每个用户组共享一个 Engine</td><td>高</td><td>低</td><td>普通 ETL 或者 Ad-hoc 查询或实时作业</td></tr><tr><td>SERVER</td><td>全部用户共享一个 Engine</td><td>最高</td><td>最低</td><td>管理员操作</td></tr></tbody></table><p>此外,Engine 的生命周期由 Server 自动管控。如果当前没有合适的 Engine,那么 Server 就会启动一个 Engine;而如果有 Engine 空闲超过一定时间, Engine 会自动关闭来节省资源。</p><h2 id="额外好处"><a href="#额外好处" class="headerlink" title="额外好处"></a>额外好处</h2><p>Kyuubi 除了上述最基础的优势以外,对比直接使用 FSG 还会有额外的好处。其中有因为开发进度不同而导致的短期差异,也有因为项目定位不同而可能存在的长期差异。</p><h3 id="Application-部署模式"><a href="#Application-部署模式" class="headerlink" title="Application 部署模式"></a>Application 部署模式</h3><p>Kyuubi 在最新的 1.8 版本支持了 Flink on YARN Application 模式,而目前 FSG 的 Application 模式尚在讨论阶段(见 FLIP-316 [2])。值得关注的是,长期来说两者对于 Application 模式的实现方式并不相同。</p><p>要理解背后的差异,首先简单回顾 Flink SQL 的基础。我们执行一条 Flink SQL 会经历如下几个阶段:</p><ol><li>解析: 用户提交的 SQL 首先会被 Parser 解析为逻辑执行计划</li><li>优化: 逻辑执行计划经过 Planner Optimizer 优化,会生成物理执行计划</li><li>编译: 物理执行计划再通过 Planner CodeGen 代码生成 Java 代码(DataStream Transformation),构建 JobGraph</li><li>执行: 将 JobGraph 提交给 JobManager,后者申请 TaskManager 执行作业</li></ol><p>对于 Flink Session 模式或者 Per-Job 模式而言,前 3 个步骤均在 Flink Client 端的 TableEnvironment 完成,而且编译产出的 JobGraph 可序列化并包含全部作业信息,因此十分适合用于划分 SQL 网关和 Flink 集群的界限。换句话说,网关完成 SQL 解析、优化、编译之后,将 JobGraph 以 REST 或提交到 HDFS/S3 等分布式存储即可。</p><p>然而,Application 模式对架构提出了新挑战。Application 模式下 JobGraph 的构建必须在 JobManager 端进行,这意味着编译也需要在 JobManager 端进行。为此,我们需要将步骤 2 产生的物理执行计划序列化后并上传到 JobManager 端,而这个序列化对象便是 Json Plan [3]。Json Plan 在 Flink 1.14 引入,是 Flink 物理执行计划 ExecNodeGraph 的序列化表示,原本主要是为 Flink SQL 的跨版本升级设计,现在用于 FSG 与 JobManager 的通信也是开箱即用十分方便。而 FSG 支持 Application 模式的议案 FLIP-316 正是基于 Json Plan 来设计。</p><p><center><img src="/img/kyuubi-flink-engine/img3.fsg-app-mode.png" alt="图3. FSG Application Mode 架构" title="图3. FSG Application Mode 架构"></center></p><p>然而 Json Plan 当前还是有一定的局限性,其中最大的限制是 Json Plan 只支持 Streaming 模式的 INSERT 语句和 STATEMENT SET 语句,这导致 Application 模式暂时无法支持 Batch 模式和 SELECT,限制了数据探索的场景。</p><p><center><img src="/img/kyuubi-flink-engine/img4.kyuubi-app-mode.png" alt="图4. Kyuubi Application Mode 架构" title="图4. Kyuubi Application Mode 架构"></center></p><p>相对地,Kyuubi Flink Engine 的 Application 模式采用类似 Spark Cluster 模式的架构,直接在 JobManager 端的 TableEnvironment 完成 SQL 的解析、优化、编译和执行,作业信息无需跨进程传递,故不受 Json Plan 的限制。通过 Kyuubi,我们可以像使用 Spark 一样使用 Flink 做任意的数据探索。</p><h3 id="可观测性"><a href="#可观测性" class="headerlink" title="可观测性"></a>可观测性</h3><p>在企业级应用中,可观测性无疑是重要的基础需求。Kyuubi 提供丰富的 Metric 和 Reporter,并且提供内嵌在 Kyuubi Server 的 Web UI,让我们随时掌握网关的负载情况。</p><p><center><img src="/img/kyuubi-flink-engine/img5.kyuubi-web-ui.png" alt="图5. Kyuubi Web UI" title="图5. Kyuubi Web UI"></center></p><p>在 Metric 方面,Kyuubi Metric 可以分为几大类:</p><ul><li>Connection Metric: 监控不同状态的 Connection 数目</li><li>Operation Metric: 监控不同状态的 Operation 数目以及执行时长</li><li>线程池 Metric: 监控 Kyuubi Server 服务线程池是否充裕</li><li>Engine Metric: 监控不同用户的不同状态的 Engine 数目</li><li>RPC 调用 Metric: 监控 Kyuubi Server 与 Engine 之前不同 RPC 调用的频率及时间</li></ul><p>在 Report 方面,Kyuubi 提供最流行的 Prometheus Reporter 和 JMX Reporter,如果需要基于日志的 Metric 也可选择 SLF4J Reporter、JSON Reporter 或 Console Reporter。</p><h2 id="未来展望"><a href="#未来展望" class="headerlink" title="未来展望"></a>未来展望</h2><p>从 Spark 专用到多引擎支持,Kyuubi 正一步步地往 “SQL Gateway 的标准解决方案” 的目标迈进。而在 Flink Engine 支持上,未来 Kyuubi 将进一步重点补齐 on K8s 和伪装认证能力,完善用户在不同的环境下使用 Flink SQL 体验。</p><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ol><li><a href="https://github.com/apache/kyuubi" target="_blank" rel="external">Apache Kyuubi Github Repo</a> </li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-316%3A+Introduce+SQL+Driver" target="_blank" rel="external">FLIP-316: Introduce SQL Driver</a></li><li><a href="https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=191336489" target="_blank" rel="external">FLIP-190: Support Version Upgrades for Table API & SQL Programs</a></li></ol>]]></content>
<summary type="html">
<p>Apache Kyuubi [1] 是一个分布式多租户的 SQL 网关,主要功能为接受用户的通过 JDBC/REST 等协议提交的 SQL 并根据多租户隔离策略路由给其管理的 SQL 引擎执行。在最新的 Kyuubi 1.8 版本,Kyuubi Flink Engine 迁移到 Flink SQL Gateway(下简称 FSG) API 之上并支持 Flink Application 模式,这让我们能借助 Kyuubi 快速部署生产可用的分布式 Flink SQL 网关。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
<category term="Kyuubi" scheme="https://link3280.github.io/tags/Kyuubi/"/>
</entry>
<entry>
<title>谈谈 Flink Shuffle 演进</title>
<link href="https://link3280.github.io/2022/08/14/%E8%B0%88%E8%B0%88-Flink-Shuffle-%E6%BC%94%E8%BF%9B/"/>
<id>https://link3280.github.io/2022/08/14/谈谈-Flink-Shuffle-演进/</id>
<published>2022-08-14T14:27:02.000Z</published>
<updated>2022-08-15T13:04:06.245Z</updated>
<content type="html"><![CDATA[<p>在分布式计算中,Shuffle 是非常关键但常常容易被忽视的一环。比如著名的 MapReduce 的命名跳过 Shuffle ,只包含其前后的 Map 跟 Reduce。背后原因一方面是 Shuffle 是底层框架在做的事情,用户基本不会感知到其存在,另一方面是 Shuffle 听起来似乎是比较边缘的基础服务。然而,笔者认为大数据计算与在线服务最基础的区别之一正在于 Shuffle。</p><a id="more"></a><p>众所周知,分布式算法的基础在于分治,而分治的三步为: 分解(Divide)、解决(Conquer)、合并(Combine),其中最为核心的分解与合并两步都与 Shuffle 密不可分。除了数据同步之类的完全并行(Embarrassingly Parallel)作业,大多数分布式计算作业都会包含一到多轮 Shuffle,而这些 Shuffle 的本质则是将上一轮计算的中间结果按照下一轮计算需要的方式重新组织。例如,在著名的 WordCount 案例中,在 Map 阶段的数据是随机分布的,而在 Shuffle 过后则是按照单词为分区 Key 来分布。而剩余的完全并行作业,本质上并不是在处理一个需要分治的大问题,而是处理重复的大量小问题,这样的需求其实跟普通的 Web 服务是类似的,若不考虑效率,完全可以用微服务框架来实现。</p><p>如果说数据分治的核心在于 Shuffle,那么计算分治的核心则在于调度器,两者相辅相成,比如流式调度和批式调度会搭配不同的 Shuffle。对于实践流批一体的 Flink 而言,Shuffle 面临的问题比其他计算引擎更加复杂,因此 Flink 做了更多的优化,包括流计算的 Pipelined Shuffle、批计算的 Blocking Shuffle 以及结合二者特点的 Hybird Shuffle。</p><h2 id="流计算的-Pipelined-Shuffle"><a href="#流计算的-Pipelined-Shuffle" class="headerlink" title="流计算的 Pipelined Shuffle"></a>流计算的 Pipelined Shuffle</h2><p>Flink 流计算的 Shuffle 相对简单,主要原因是所有 Task 同时在运行,上下游 Task 可以通过网络流式地传输中间结果,不需要落盘,这种 Shuffle 被称为 Pipelined Shuffle。</p><p>相信不少读者都接触过 Flink DAG 的边类型。当我们在 DAG 构建中使用 Partition (将数据分区)相关的操作,比如 DataStream 的 <code>keyBy</code> 或 <code>rescale</code>、SQL 中的 <code>Group By</code>,Flink 会引入一轮 Shuffle,体现在可视化的 DAG 上就是上下游划分到不同的两个节点,两者以一条边相连。边的类型有 <code>HASH</code>、<code>BROADCAST</code>、<code>REBALANCE</code> 等等(见下图)。</p><p><center><p><img src="/img/flink-shuffle/img1.flink-logical-edges.png" alt="图1. Flink DAG 的边" title="图1. Flink DAG 的边"></p></center></p><p></p><p>尽管逻辑上的 Partition 有多种多样的算法,产生的边五花八门,但它们的区别仅在于产出的结果如何划分给不同的下游 Subtask,所以从底层的 Shuffle 看来要做的事情是一样的:将中间结果提供给不同的下游 Subtask 读取。结合下图用 Flink 的话语讲,Partition 算法决定如何划分出 Subpartition,而 Shuffle 决定如何将 Subpartition 传递给 InputGate。</p><p><center><p><img src="/img/flink-shuffle/img2.flink-shuffle-implement.png" alt="图2. Flink Shuffle 实现" title="图2. Flink Shuffle 实现"></p></center></p><p></p><p>面对 Pipelined Shuffle 的需求,最容易想到的实现方式便是上游 Subtask 所在 TaskManager 直接通过网络推给下游 Subtask 的 TaskManager。事实上,Flink 也的确是这么做的。Flink 在 TaskManager 里内嵌了基于 Netty 的 Shuffle Service,计算得出的中间数据会存到 TaskManager 的缓存池中,由 Netty 去定时轮询发送给下游。</p><p><center><p><img src="/img/flink-shuffle/img3.pipeline_shuffle.gif" alt="图3. 内置 Netty Shuffle" title="图3. 内置 Netty Shuffle"></p></center></p><p></p><p>Pipelined Shuffle 实现上有很多值得研究的地方,其中最重要的是 Flink 1.5 版本引入的 Credit-Based 流控机制。简单来说,Credit-Based 流控实现了类似 TCP 滑动窗口的机制,让上游 Subtask 依据下游 Subtask 的空闲 buffer(Credit)来发送数据,避免多个 Subtask 共用的一条 TCP 链接因为其中一个 Subtask 被阻塞。感兴趣的读者推荐阅读《批流统一计算引擎的动力源泉—Shuffle机制的重构与优化》这篇博客[11]。</p><h2 id="批计算的-Blocking-Shuffle"><a href="#批计算的-Blocking-Shuffle" class="headerlink" title="批计算的 Blocking Shuffle"></a>批计算的 Blocking Shuffle</h2><p>批计算的上下游 Subtask 通常不会同时调度起来,所以上游产出数据首先需要落盘存储,等下游调度起来再去读取,这种方式被称为 Blocking Shuffle。自 Flink 开始定位为流批一体计算引擎后,社区便持续对 Flink 批计算的 Blocking Shuffle 进行改良。</p><p>首先是 Flink 1.9 将 Shuffle Service 与计算解耦,改为插件化的架构(见 FLIP-31[3])。在此之前,Shuffle Service 作为 TaskManager 职责之一,绑定使用 TaskManager 内置的 Netty Shuffle Service。Netty Shuffle Service 在 Pipelined Shuffle 的场景下会直接通过 TCP 流式发送数据,而在 Blocking Shuffle 的场景下则会先写本地文件,再等下游 Subtask 拉取。然而,后一种情况会导致问题是,上游已经结束的 Subtask 想要释放 TM 的资源,必须先等下游 Subtask 被调度起来并拉完数据,这会造成资源的浪费甚至死锁。更加重要的是,在某些批计算场景下(比如交互式查询),同一批中间数据可能会被消费多次,这是 TaskManager 兼任的 Shuffle Service 无法满足的。</p><p>熟悉 Spark 的读者可能会想起 Spark 的 ESS (External Shuffle Service) 和 RSS (Remote Shuffle Service)。前者支持 Spark Executor 本地部署 Shuffle Service,比如部署在 YARN NodeManager 里的 YARN Shuffle Service,而后者支持在远端部署 Shuffle Service,比如阿里的 Aliyun RSS[12]、腾讯刚贡献给 Apache 的 Uniffle(原名 Firestorm)[13]。Flink 参考Spark 的经验,在 FLIP-31 中同时考虑了 ESS 和 RSS 的需求,为后续迭代奠定了良好基础。</p><p>其次,Flink 1.12、1.13 引入并完善了 Blocking Shuffle 的 Sort-Merge 实现(见 FLIP-148[4])。Blocking Shuffle 有 Hash Shuffle 和 Sort-Merge Shuffle 两种常见策略。在此之前,Flink 只支持比较简单的 Hash Shuffle,而缺少性能更好更适合生产使用的 Sort-Merge Shuffle。</p><p>简单而言,Hash Shuffle 是将数据按照下游每个消费者一个文件的形式组织,当并行度高时会产生大量的文件,容易耗光操作系统的文件描述符,并产生大量随机 IO 对 HDD 磁盘不友好,此外每个文件需要一个独立 Buffer 占内存过多。</p><p><center><p><img src="/img/flink-shuffle/img4.hash-shuffle.png" alt="图4. Hash Shuffle" title="图4. Hash Shuffle"></p></center></p><p></p><p>相比之下,Sort-Merge Shuffle 是将上游所有的结果写入同一个文件,文件内部再按照下游消费者的 ID 进行排序并维护索引,下游有读取数据请求时,则按照索引来读取大文件中的某一段。</p><p><center><p><img src="/img/flink-shuffle/img5.sort-shuffle.png" alt="图5. Sort Shuffle" title="图5. Sort Shuffle"></p></center></p><p></p><p>参考 Spark,Spark 在 1.1 版本引入 Sort-Merge Shuffle,并在 1.2 版本用其替代 Hase Shuffle,成为默认的 Shuffle 策略。说句题外话,一方面 Spark 1.1 2014 年发布,而 Flink 1.12 2020 年发布,Flink 在批计算落后于 Spark 6 年,而另一方面,Spark 今年(2022)新宣布的流计算 ProjectLightspeed(Structured Streaming 升级版)[14]要做的特性基本上是 Flink 5 年前已经实现的,可谓有趣的对称。Flink 在批计算上落后于 Spark,正如同 Spark 在流计算上落后于 Flink。</p><h2 id="批场景下流批一体的-Hybrid-Shuffle"><a href="#批场景下流批一体的-Hybrid-Shuffle" class="headerlink" title="批场景下流批一体的 Hybrid Shuffle"></a>批场景下流批一体的 Hybrid Shuffle</h2><p>如上文所讲,流计算用 Pipelined Shuffle,批计算用 Blocking Shuffle,那么流批一体用什么 Shuffle 呢?大家很容易联系到本节要讨论的 Hybrid Shuffle,但遗憾的是这句话大概只对一半,因为目前的 Hybrid Shuffle 只针对批场景有效。</p><p>众所周知,Flink 遵循 “批是流的特殊案例” 的流批一体理念,因而 Flink 中的批计算是能以流计算的方式去跑的。然而,大多数情况下我们不会这么做,因为批场景有额外的特点能让我们进行优化,比如借助 Blocking Shuffle 可以解耦上下游,让它们无需同时运行,相当于用时间换空间,让作业资源门槛比 Pipelined Shuffle 更低。这点也体现在 Flink 的配置上: Flink 的批作业可以通过 <code>execution.batch-shuffle-mode</code> 指定 Shuffle 模式,默认为 Blocking 模式(其余模式还有 Pipelined 和 Hybird)。</p><p>Blocking Shuffle 带来的一个限制是排斥上下游同时运行,因为上游计算结束之前,下游是没办法访问到其不完整的结果数据的,即使调度下游 Subtask 也只会让其空跑。这点在批计算的角度看来很正常,但对于流批一体的 Flink 而言其实是有优化空间的。设想如果在执行上游作业时,集群有空余资源能跑下游作业,那么我们是不是可以尽量 fallback 回 Pipelined Shuffle,用空间换时间,让作业更快完成?</p><p>基于这个思路,Flink 社区在 FLIP-235 [8] 中提出了 Hybird Shuffle。Hybird Shuffle 支持以内存(Pipelined Shuffle 风格)或文件(Blocking Shuffle 风格)的方式存储上游产出的结果数据,原则是优先内存,内存满了后 spill 到文件(见下图)。无论是在内存或者文件中,所有数据在产出后即对下游可见,因此可以同时支持流式的消费或批式的消费。</p><p><center><p><img src="/img/flink-shuffle/img6.hybird-shuffle.png" alt="图6. Hybird Shuffle 的数据生产和消费" title="图6. Hybird Shuffle 的数据生产和消费"></p></center></p><p></p><p>以 WordCount 作业为例,假设一共有 2 个 Map 和 2 个 Reduce,但现在计算资源只有 3 个 slot,采用不同的 Shuffle 有以下效果:</p><ul><li>Blocking Shuffle: 先调度 2 个 Map,再调度 2 个 Reduce,有 1 个 slot 被浪费。</li><li>Pipelined Shuffle: 要求 4 个 slot,因此作业无法运行。</li><li>Hybird Shuffle: 先调度 2 个 Map 和 1 个 Reduce,剩余一个 Reduce 等三者任意一个完成后再调度(见图 7)。</li></ul><p><center><p><img src="/img/flink-shuffle/img7.word-count-shuffle.png" alt="图7. Hybird Shuffle 下的 Wordcount" title="图7. Hybird Shuffle 下的 Wordcount"></p></center></p><p></p><p>从图中可以看到,Map 产出的 Subpartition 1 被下游的 Reduce 1 流式读取,因此数据很可能是缓存在内存中;而 Subpartition 2 由于消费者 Reduce 2 还未运行,所以数据可能会在内存满之后 spill 到磁盘,等 Reduce 2 启动后再读取。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Shuffle 是分布式计算中关键的一环,它与计算调度相辅相成,成为分布式计算分治的基础。对于流批一体的 Flink 而言,Shuffle 不仅要满足流计算调度、批计算调度,还要满足流批一体的调度。前两个场景的 Shuffle 经过多年的发展目前在业界已经比较成熟,而最后的流批一体 Shuffle 还有不少探索的空间。Flink 1.16 版本即将引入的 Hybird Shuffle 针对批场景的流批一体 Shuffle 进行优化,使得 Flink 可以在运行时根据资源情况灵活决定使用类似流计算的 Shuffle 还是批计算的 Shuffle,以提高空闲资源利用率。</p><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ol><li><a href="https://flink.apache.org/2021/10/26/sort-shuffle-part1" target="_blank" rel="external">Sort-Based Blocking Shuffle Implementation in Flink - Part One</a></li><li><a href="https://flink.apache.org/2021/10/26/sort-shuffle-part2.html" target="_blank" rel="external">Sort-Based Blocking Shuffle Implementation in Flink - Part Two</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-31%3A+Pluggable+Shuffle+Service?src=contextnavpagetreemode" target="_blank" rel="external">FLIP-31: Pluggable Shuffle Service</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-148%3A+Introduce+Sort-Based+Blocking+Shuffle+to+Flink" target="_blank" rel="external">FLIP-148: Introduce Sort-Based Blocking Shuffle to Flink</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-184%3A+Refine+ShuffleMaster+lifecycle+management+for+pluggable+shuffle+service+framework" target="_blank" rel="external">FLIP-184: Refine ShuffleMaster lifecycle management for pluggable shuffle service framework</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-199%3A+Change+some+default+config+values+of+blocking+shuffle+for+better+usability" target="_blank" rel="external">FLIP-199: Change some default config values of blocking shuffle for better usability</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-184%3A+Refine+ShuffleMaster+lifecycle+management+for+pluggable+shuffle+service+framework" target="_blank" rel="external">FLIP-209: Support to run multiple shuffle plugins in one session cluster</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-235%3A+Hybrid+Shuffle+Mode" target="_blank" rel="external">FLIP-235: Hybrid Shuffle Mode Skip to end of metadata</a></li><li><a href="https://zhuanlan.zhihu.com/p/385698953" target="_blank" rel="external">Flink 1.13,面向流批一体的运行时与 DataStream API 优化</a></li><li><a href="https://flink.apache.org/news/2020/03/24/demo-fraud-detection-2.html" target="_blank" rel="external">Advanced Flink Application Patterns Vol.2: Dynamic Updates of Application Logic</a></li><li><a href="https://flink-learning.org.cn/article/detail/797d11204081e29bd5ef543819668c0f" target="_blank" rel="external">批流统一计算引擎的动力源泉—Shuffle机制的重构与优化</a></li><li><a href="https://github.com/alibaba/RemoteShuffleService" target="_blank" rel="external">Aliyun Remote Shuffle Service</a></li><li><a href="https://github.com/Tencent/Firestorm" target="_blank" rel="external">Firestorm</a></li><li><a href="https://www.databricks.com/blog/2022/06/28/project-lightspeed-faster-and-simpler-stream-processing-with-apache-spark.html" target="_blank" rel="external">Project Lightspeed: Faster and Simpler Stream Processing With Apache Spark</a></li><li><a href="https://flink.apache.org/2020/12/15/pipelined-region-sheduling.html" target="_blank" rel="external">Improvements in task scheduling for batch workloads in Apache Flink 1.12</a></li></ol>]]></content>
<summary type="html">
<p>在分布式计算中,Shuffle 是非常关键但常常容易被忽视的一环。比如著名的 MapReduce 的命名跳过 Shuffle ,只包含其前后的 Map 跟 Reduce。背后原因一方面是 Shuffle 是底层框架在做的事情,用户基本不会感知到其存在,另一方面是 Shuffle 听起来似乎是比较边缘的基础服务。然而,笔者认为大数据计算与在线服务最基础的区别之一正在于 Shuffle。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
<category term="流批一体" scheme="https://link3280.github.io/tags/%E6%B5%81%E6%89%B9%E4%B8%80%E4%BD%93/"/>
</entry>
<entry>
<title>Flink Savepoint vs 数据库 Savepoint</title>
<link href="https://link3280.github.io/2022/06/13/Flink-Savepoint-vs-%E6%95%B0%E6%8D%AE%E5%BA%93-Savepoint/"/>
<id>https://link3280.github.io/2022/06/13/Flink-Savepoint-vs-数据库-Savepoint/</id>
<published>2022-06-13T15:32:22.000Z</published>
<updated>2022-06-13T15:42:14.693Z</updated>
<content type="html"><![CDATA[<p>前不久笔者在 Flink 社区讨论 Flink SQL 中 Savepoint 的 SQL 语法时(见 FLIP-222 [3]),曾提议过参考数据库的 Savepoint 语法。虽然最终未能获得 Flink 社区的大多数赞成,但也引发了笔者的好奇心: Flink Savepoint 和传统的数据库 Savepoint 究竟有何异同?于是经过笔者一番调研与思考,便有了这篇文章。</p><a id="more"></a><h2 id="Savepoint-功能"><a href="#Savepoint-功能" class="headerlink" title="Savepoint 功能"></a>Savepoint 功能</h2><p>就功能而言,由两者共用 Savepoint 一词可见其语义之相近,均用于保存状态数据。Flink Savepoint 用于持久化作业当前的状态,以便后续用于恢复作业状态到该时间点;数据库 Savepoint 用于保存当前事务当前的状态,后续可用于事务回滚。</p><p>从表面上看,除了对象不同,两者最大的不同点在于 Savepoint 作用的范围。Flink Savepoint 可以作用于作业的整个运行周期,而数据库 Savepoint 只能作用于单个事务中。然而,若我们将一个 Flink 作业视为一个超长事务,那么两种 Savepoint 的作用范围也是一致的。</p><p><center><img src="/img/savepoint-comparison/img1.savepoint_scope.png" alt="图1. Savepoint 范围" title="图1. Savepoint 范围"></center></p><p>更为有趣的是,数据库 Savepoint 常与 Nested Transaction (内嵌事务)一起出现。顾名思义,Nested Transaction 即事务中的事务,可独立进行 Commit 或者 Rollback,不会与外层事务的状态相互影响。大多数常见的数据库,包括 MySQL、PostgresSQL 等等,并没有直接支持 Nested Transaction,而是提供 Savepoint 作为替代方案。具体来说,即用户可以在一个事务的范围里声明一个 Savepoint,此后的所有操作可以被视为一个 Nested Transaction。若有需要,用户直接回滚事务到该 Savepoint,因为后续操作也被回滚,因此看起来的效果就跟 Nested Transaction 被回滚了一样。</p><p>熟悉 Flink 的读者一定很快联想到一个概念。没错,就是 Flink Checkpoint。Flink Checkpoint 本身是两阶段提交的事务(2PC),对于作为外层事务的 Flink 作业而言,无疑是 Nested Transaction(当然,其中还有很多细节问题,留待下文再谈)。</p><p>综上所述,我们可以得到如下的相似映射 。</p><table><thead><tr><th>Flink</th><th>数据库</th></tr></thead><tbody><tr><td>作业</td><td>事务</td></tr><tr><td>Savepoint</td><td>Savepoint</td></tr><tr><td>Checkpoint</td><td>内嵌事务</td></tr></tbody></table><h2 id="Savepoint-度量"><a href="#Savepoint-度量" class="headerlink" title="Savepoint 度量"></a>Savepoint 度量</h2><p>Flink Savepoint 与数据库 Savepoint 保存作业或事务的状态数据,但由于 Flink 作业为流式执行、数据库事务为批式执行,两者的状态基于不同的度量指标: Flink Savepoint 以数据为基准,而数据库事务则以操作(Operation,即 SQL statement)为基准。</p><p><center><img src="/img/savepoint-comparison/img2.flink_savepoint_progress.png" alt="图2. Flink Savepoint 执行流程" title="图2. Flink Savepoint 执行流程"></center></p><p>具体而言,Flink 作业可能包含多个 Operation (Flink 对应概念为 Task),在流式计算模式下,这些 Operation 会被同时调度、同时执行,并且可能永远不会终止。因此,Flink Savepoint 是独立于作业之外的,并不需要等待某个 Operation 完成。Flink Savepoint 会对所有的操作进行快照,记录它们正在处理中的数据和中间状态(见图 2)。</p><p>一般而言,快照通常需要 Stop-The-World (STW),不过 Flink 采用 Chandy-Lamport 算法,向数据注入特殊的 Barrier 并以之为基础进行快照,避免了 STW,因此可以说 Flink Savepoint 是以数据为基准的。</p><p><center><img src="/img/savepoint-comparison/img3.database_savepoint_progress.png" alt="图3. 数据库 Savepoint 执行流程" title="图3. 数据库 Savepoint 执行流程"></center></p><p>而另一方面,数据库事务可视为是合并到一个逻辑单位中的操作(数据库中一般称为 Statement)序列,这些操作会像批计算一样顺序执行。显然,两个 Operation 之间是很好的 Savepoint STW 快照时机,所以数据库 Savepoint 是以特殊的 Statement 的方式嵌入到事务里面,因此可以说数据库 Savepoint 是以操作为基准的。</p><h2 id="DDL-支持"><a href="#DDL-支持" class="headerlink" title="DDL 支持"></a>DDL 支持</h2><p>除了常见的 DML (Data Manipulation Language,比如 <code>insert</code>、<code>update</code>、<code>delete</code>),数据库事务还可能包含 DDL (Data Definition Language,比如 <code>alter table</code>、<code>create index</code>),这个特性被称为 Transactional DDL [3]。Transactional DDL 在系统发版升级场景非常有用,但只有少数的主流数据库支持。比如 MySQL、Oracle 不支持 Transactional DDL,而 PostgreSQL 则支持。</p><p>由于 Flink 作业只涉及 DML,不涉及 DDL (DDL 在 Flink 中属于非作业操作 <code>Non-Job Operation</code>),因此以作业运行周期为范围的 Flink Savepoint 自然无法被纳入到 Savepoint 里。回滚到某个 Savepoint 并不能回滚 Savepoint 以后的 DDL 造成的 Schema 变更,无论是在流计算模式还是批计算模式。</p><h2 id="Savepoint-权属"><a href="#Savepoint-权属" class="headerlink" title="Savepoint 权属"></a>Savepoint 权属</h2><p>数据库 Savepoint 属于事务的一部分,其数据主要存储在数据库内部的内存或者日志里,由数据库管理系统控制,不会暴露给用户。相比之下,Flink Savepoint 则开放得多: 我们不仅可以声明 Savepoint 的权属(在 Flink 1.14 及之前版本,Savepoint Owner 只能为用户),还可以将 Savepoint 搬来搬去,甚至对其进行修改或直接生成一个新 Savepoint。</p><p>笔者认为 Flink 与传统数据库截然不同的 Savepoint 管理方式主要有两点原因:</p><ul><li>Flink 支持执行任意用户代码,并且用户代码可以将任意状态信息以任意序列化方式存到 Savepoint 中,这意味着 Flink 并不掌握所有恢复 Savepoint 需要的信息,因此只能将控制权交给用户。</li><li>Flink 并不存储数据(撇开尚不成熟的 Table Store 不谈),通常会依赖外部数据存储,因而外部数据存储的变更可能导致 Savepoint 的不兼容,导致 Flink 不能做到 Savepoint 的 Self Contained。</li></ul><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Flink Savepoint 与传统数据库的 Savepoint 在功能定位十分接近,都用于对状态进行快照,而且与 Savepoint 相关的概念都大同小异。然而,在 Savepoint 特性和实现细节方面却大相径庭,其中主要体现在 Savepoint 度量(基于数据或基于操作)、DDL 支持、Savepoint 权属三个方面。</p><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><p>[1] <a href="https://en.wikipedia.org/wiki/Savepoint" target="_blank" rel="external">Wikipidia: Savepoint</a><br>[2] <a href="https://en.wikipedia.org/wiki/Nested_transaction" target="_blank" rel="external">Wikipidia: Nested Transaction</a><br>[3] <a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-222%3A+Support+full+job+lifecycle+statements+in+SQL+client" target="_blank" rel="external">FLIP-222: Support full job lifecycle statements in SQL client</a> </p>]]></content>
<summary type="html">
<p>前不久笔者在 Flink 社区讨论 Flink SQL 中 Savepoint 的 SQL 语法时(见 FLIP-222 [3]),曾提议过参考数据库的 Savepoint 语法。虽然最终未能获得 Flink 社区的大多数赞成,但也引发了笔者的好奇心: Flink Savepoint 和传统的数据库 Savepoint 究竟有何异同?于是经过笔者一番调研与思考,便有了这篇文章。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>当谈论 Immutability 的时候,我们在谈论什么</title>
<link href="https://link3280.github.io/2022/04/07/%E5%BD%93%E8%B0%88%E8%AE%BA-Immutability-%E7%9A%84%E6%97%B6%E5%80%99%EF%BC%8C%E6%88%91%E4%BB%AC%E5%9C%A8%E8%B0%88%E8%AE%BA%E4%BB%80%E4%B9%88/"/>
<id>https://link3280.github.io/2022/04/07/当谈论-Immutability-的时候,我们在谈论什么/</id>
<published>2022-04-07T14:27:02.000Z</published>
<updated>2022-04-07T15:20:14.441Z</updated>
<content type="html"><![CDATA[<p>谈起 Immutability (不可变性),相信大多数读者先想起的是编程语言中的 <code>final</code>、<code>const</code> 之类的常量关键词或 <code>ImmutableMap</code> 之类的数据结构。不可否认,它们是日常开发中的实用工具,但这仅是 Immutability 的最基础应用,而在更深入的领域,比如编程范式、数据库、服务架构设计,同样无不处处体现着 Immutability 的理念。Immutability 通常意味着用直接赋值以外的方式来表达更新,本文就来谈谈这些方式提供何种特性及其如何让我们的程序设计受益。</p><a id="more"></a><h1 id="并发控制"><a href="#并发控制" class="headerlink" title="并发控制"></a>并发控制</h1><p>Immutability 最明显的优势在于并发控制。</p><p>并发控制是现代程序设计最为核心的问题之一。多线程编程在释放单机算力的同时也带来了线程安全问题,以最小成本实现多线程的并发控制,成为几乎所有高性能应用绕不开的问题。最为典型的例子便是数据库,为平衡性能和并发控制的效果(即事务隔离性),主流数据库都会提供不同的事务隔离等级供用户设置。然而,处理线程安全问题通常依赖各种锁,而锁除了会显著拖慢性能,还大大增加系统复杂度,容易出现死锁等各类疑难问题。</p><p>避免线程安全问题的一个办法便是 Immutability。线程安全问题的本质在于 Shared State(多线程公用的状态)可能被某个线程改变,而该操作对于其他线程来说是不知情的。这在面向过程/对象编程中习以为常的事情,在函数式编程观点看来却是非常危险的。函数式编程摒弃了 Shared State,而是采用不可变的对象作为函数的输入和输出,这些对象类似于局部变量仅可在函数内访问,因此函数可以被安全地传递到任意线程上执行,并且无需考虑锁同步。这样的特性令函数式编程完美适配并行计算场景,因此事实上数据密集型计算框架(比如 Spark/Flink/Pandas 等)几乎都以函数式编程为主。</p><p>当然,基于 Immutability 的并发控制并不专属于函数式编程,在面向过程/对象编程中我们依然能在系统设计中发挥 Immutability 的威力,基本的思路是: 线程安全问题来自于多线程访问变量的不确定性,那么不如将状态的访问收敛到一个线程里,再用显式的消息传递来实现状态的读写。这样我们确保了对象状态在多线程的环境下依然有良好的封装——状态不但属于某个对象,而且属于某个线程,因此不会出现任意能访问到对象的线程都能将对象的状态乱搞一通的情况。</p><center><p><img src="/img/immutability/img1.seq_chart_multi_thread.png" alt="图1. 两个线程同时访问任意对象" title="图1. 两个线程同时访问对象(图来自 Akka 文档)"></p></center><p>打个比方,一家初创公司有销售、采购、行政三个负责人,每个负责人都有权动用公司账户并分别记账,那么公司的账目一定非常混乱很容易出现对不上的情况,而若有财务负责人来专门管帐,每个部门有收入支出则通知财务处理即可,更加规范易于管理。</p><p>熟悉设计模式的读者可能会想起 Command 模式[2]或者 Actor 模式[3],熟服务架构的读者可能会想起微服务——它们都基于消息传递进行跨进程或线程的直接通信,而不是依赖对内存或者外部存储的共同访问权限间接协作。可能有读者会问,函数式编程还可以理解,基于消息传递为什么是 Immutability 设计呢?其实很简单:不同线程或进程之间唯一共享的是不可变的消息,而消息包含的命令和参数均是确定性的,这与函数式编程中的函数非常相近。</p><h1 id="信息完整性"><a href="#信息完整性" class="headerlink" title="信息完整性"></a>信息完整性</h1><p>Immutability 另外一项重要特性是信息完整性。</p><p>简单地用新值覆盖旧值会导致信息的丢失,原本旧值是什么无从考证,导致很难维护系统的数据一致性。解决这个问题的常见办法是通过日志来记录数据的每一项变更,每次变更时新增一个版本,同时保留旧版本,相当于自带版本控制。事实上,在数据库领域等数据一致性关键的领域,不可变的日志可以说是容错机制、数据同步和审计的基石,比如 MySQL 的 binlog、MongoDB 的 oplog 和实现事务常用的 WAL(Write Ahead Log)。在日志强大能力背后,其实是 Immutability 对于信息完整性的保护。</p><p>在如今的微服务架构时代,横跨多个服务调用的事务十分常见,维护数据一致性的复杂度很大程度上由基础设施转移到了应用层,因此我们可以发现 Immutability 的理念在应用层愈发流行。事实上,近年来一些热门概念,比如 Event Sourcing、CRQS、SAGA,都是建立在 Immutability 的基础之上。这些新架构使用异步的通信机制,通常倡导除了在常规地更新服务状态的同时,将状态变化以事件日志的形式保存下来,这些事件日志可用于排查问题、重建整个某个时间点的服务状态或者作为通知(Notification)同步给一起协作的其他服务。</p><p>考虑一个电商交易场景的微服务架构,有订单(Order)、支付(Payment)和库存(Inventory)三个独立的服务。新建一个订单,需要先后调用支付服务和库存服务,用 SAGA 实现的流程如下图所示:</p><center><p><img src="/img/immutability/img2.saga-flow.png" alt="图2. SAGA 工作流程" title="图2. SAGA 工作流程"></p></center><p>其中蓝色的为常规事务,黄色为用于回滚规范事务的补偿事务(Compensating Transaction),常规事务跟补偿事务是一对一的关系。如果某个常规事务出错,那么系统会逆序执行该事务及其前置事务的补偿事务。每个事务的执行结果都会以事件消息的形式通知 SAGA 的中心化节点 Coordinator 并被作为日志记录下来(见下图的 Saga Log)。</p><center><p><img src="/img/immutability/img3.saga-transaction-log.png" alt="图3. SAGA 分布式事务日志" title="图3. SAGA 分布式事务日志"></p></center><p>通过不可变的事务日志,SAGA 将多个独立的本地事务串联起来,成为一个松耦合的分布式事务。</p><h1 id="性能"><a href="#性能" class="headerlink" title="性能"></a>性能</h1><p>Immutability 引起最多的担忧在于它对性能(时间复杂度和空间复杂度)的影响,毕竟每次更新都产生一个完整的拷贝。诚然,若不考虑并发控制,Immutability 大多数情况下的确不如原址更新性能好(尽管可以通过 COW (Copy-On-Write) 数据结构来降低性能损耗),然而在 on-disk 场景下 Immutability 可能却是更有效率的方式。</p><p>on-disk 场景下 IOPS 非常珍贵,比起占用的空间,大家更在乎一次读或写会导致多少次磁盘 IO,即数据库领域常见的空间放大、读放大和写放大三个指标。Immutability 虽然在空间放大和读放大上不占优,但在写放大的表现上却非常优秀,其中最为典型的例子便是 LSM-tree。</p><p>简单来说,对于一次写操作(比如更新一条 1 KB 大小的记录),传统的 B-Tree 需要通过索引查找到目标记录对应的页(Page),然后原址覆盖掉整一页(通常 4K 大小),而 LSM-tree 以追加写的方式代替原址更新,只需要在最新的文件写入记录的最新值。虽然有人会说提及 LSM-tree 后续合并文件的 Compaction 同样会带来写放大,但上述过程也没有算上 B-Tree 需要的 WAL。</p><p>此外,因为 LSM-Tree 一直是追加写的缘故,IO 一直也是大块数据的顺序写,这对于传统 HDD 来说尤为重要。即使是已经采用 SSD,大块的连续写入也有助于减少磁盘空间碎片和提高压缩性能。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>变量与赋值作为计算机程序中最为基础的概念,绝大多数程序员已经习惯以它们为基础的思维方式,但 Immutability 并不小众,反而凭借着在并发控制、信息完整性和性能上的优势在不少新领域大放异彩。其中部分原因来自于,相比起变赋值是计算机科学为提高资源复用率发明的操作,Immutability 更贴近纯粹的数学,因此更加不容易出现竞争条件、信息丢失这些计算机科学独有的问题。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://en.wikipedia.org/wiki/Thread_safety#Levels_of_thread_safety" target="_blank" rel="external">Wikipeida: Thread Safety</a></li><li><a href="https://en.wikipedia.org/wiki/Command_pattern" target="_blank" rel="external">Wikipeida: Command Pattern</a></li><li><a href="https://www.baeldung.com/cs/saga-pattern-microservices" target="_blank" rel="external">Saga Pattern in Microservices</a></li><li><a href="">Designing Data-Intensive Applications</a></li><li><a href="https://doc.akka.io/docs/akka/current/typed/guide/actors-motivation.html" target="_blank" rel="external">Akka Document</a></li><li><a href="https://zh.wikipedia.org/zh/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A8%8B%E5%BA%8F%E7%9A%84%E6%9E%84%E9%80%A0%E5%92%8C%E8%A7%A3%E9%87%8A" target="_blank" rel="external">计算机程序的构造和解释</a></li></ol>]]></content>
<summary type="html">
<p>谈起 Immutability (不可变性),相信大多数读者先想起的是编程语言中的 <code>final</code>、<code>const</code> 之类的常量关键词或 <code>ImmutableMap</code> 之类的数据结构。不可否认,它们是日常开发中的实用工具,但这仅是 Immutability 的最基础应用,而在更深入的领域,比如编程范式、数据库、服务架构设计,同样无不处处体现着 Immutability 的理念。Immutability 通常意味着用直接赋值以外的方式来表达更新,本文就来谈谈这些方式提供何种特性及其如何让我们的程序设计受益。</p>
</summary>
<category term="随想" scheme="https://link3280.github.io/categories/%E9%9A%8F%E6%83%B3/"/>
<category term="随想" scheme="https://link3280.github.io/tags/%E9%9A%8F%E6%83%B3/"/>
</entry>
<entry>
<title>Flink 流批一体中的数据边界</title>
<link href="https://link3280.github.io/2022/02/13/Flink-%E6%B5%81%E6%89%B9%E4%B8%80%E4%BD%93%E4%B8%AD%E7%9A%84%E6%95%B0%E6%8D%AE%E8%BE%B9%E7%95%8C/"/>
<id>https://link3280.github.io/2022/02/13/Flink-流批一体中的数据边界/</id>
<published>2022-02-13T07:27:02.000Z</published>
<updated>2022-02-13T07:30:43.572Z</updated>
<content type="html"><![CDATA[<p>众所周知,流场景和批场景最为根本的区别在于 Data Boundness(数据集有界性)。Data Boundness 将数据分为 Bounded 和 Un-Bounded。在业界过去多年的实践中,两者分别绑定对应领域的存储系统和计算引擎,然而在流批一体的趋势下,领域的边界在逐渐弱化。例如,消息队列通常用作流场景,但 Pravega 的 StreamCut 支持将指定队列中某一段消息作为批处理的输入[1]。在混合使用流批的场景下,不少原本大家习以为常的设定都需要重新去审视,其中的一项便是数据集内部的边界。</p><a id="more"></a><h1 id="存储边界与计算边界"><a href="#存储边界与计算边界" class="headerlink" title="存储边界与计算边界"></a>存储边界与计算边界</h1><p>数据边界不仅包括数据集整体的逻辑边界,也包括数据集内部的存储单元逻辑边界,比如 HDFS 等文件系统的文件及底层的 Block、Kafka 等消息队列的 Partition 等等。数据边界在批处理中扮演着十分关键的角色,比如作为分治基础,比如标识计算的结束。</p><p>在批计算中,整个 Job 会被数据边界划分为多个小 Task,每个小 Task 都可以视为一个事务,计算是由数据的边界驱动的。如果将事务看成计算的逻辑单元,那么一个计算的逻辑单元的数据输入就对应一至多个存储的逻辑单元,因此我们可以说计算和存储是<strong>对齐的</strong>。例如,在 MapReduce 中,一个 Map 的输入对应一个 Split,而一个 Split 由一至多个 HDFS Block 组成,但不会出现一个 Map 对应 1.5 个 Block 的情况。</p><center><p><img src="/img/flink-data-boundness/img1.data-divide-batch.png" alt="图1. 批计算的对齐边界" title="图1. 批计算的对齐边界"></p></center><p>在流计算中,虽然计算是连续不断的,但出于容错等原因,仍然会将计算划分为多个事务处理。以主流实时计算引擎 Apache Flink 来说,Flink 通常会定时触发两阶段提交(2PC)事务,也就是常说的 Checkpoint。Checkpoint 会向数据流注入 Checkpoint Barrier,作为每个 Checkpoint 对应数据的边界。以 Checkpoint Barrier 划分的数据单元和数据源本身的逻辑存储单元并无关系,因此两者的边界通常不会重合,我们可以说它们是<strong>非对齐的</strong>。例如 Flink 读取 Kafka 数据,并不需要感知到 Partition 底层的 Segment,而 Kafka 也没有将这样的数据边界暴露给用户。</p><center><p><img src="/img/flink-data-boundness/img2.data-divide-streaming.png" alt="图2. 流计算的非对齐边界" title="图2. 流计算的非对齐边界"></p></center><p>在流批一体场景下,引擎常要读写有边界的数据集。取决于不同存储系统,不对齐的边界可能导致流计算的容错、可维护性都大打折扣。主要问题有数据血缘、结束条件和可重复读,下文逐一分析。</p><h1 id="数据血缘"><a href="#数据血缘" class="headerlink" title="数据血缘"></a>数据血缘</h1><p>数据血缘指的是输入数据到输出数据之间依赖关系。如上文所说,批计算的输入数据边界与计算边界是对齐的,而计算边界很自然地又体现在输出数据的边界上。这点很容易理解,因为一个计算事务结束必然会 commit 数据,而这些数据会以文件、对象为单元独立存储,不会跟其他事务的数据混在一起。以文件、对象为单位,我们很容易追踪到数据上下游的血缘关系。</p><center><p><img src="/img/flink-data-boundness/img3.data-lineage-batch.png" alt="图3. 流计算的血缘关系" title="图3. 流计算的血缘关系"></p></center><p>清晰的血缘关系能大大提高数据的可维护性。如果出现脏数据或者程序 bug 等异常,需要回滚计算时,我们可以方便地识别出异常的数据,删掉重新计算或者写 ad-hoc 脚本修复数据。比如在上图中的输入文件 1 出现问题,那么我们只需要处理事务 1 的输出数据即可,影响范围是十分明确的。此外,批计算的输入输出通常是以时间索引的(比如 Hive 中常用的天或小时分区),因此我们还可以依据时间来回滚事务。</p><p>然而,在流式计算中,即使输入数据是存在边界的,这样的边界信息并不会体现在计算上,计算仍是连续不断的,辅以周期性的事务。在触发 Checkpoint 快照的时候,Flink 会记录当前正在读取和正在写的文件的 Offset,作为对应事务的数据边界。</p><center><p><img src="/img/flink-data-boundness/img4.data-lineage-streaming.png" alt="图4. 流计算的血缘关系" title="图4. 流计算的血缘关系"></p></center><p>这意味着 Flink 计算时是无视存储逻辑单元边界的,边界信息被限制在与存储系统打交道的 Connector 中,这样的设计更符合单一职责原则,更加优雅,但也导致了存储边界信息以及血缘关系的丢失。当出需要回滚事务时,我们很难识别出影响范围,只能基于时间来过滤数据而不能直接回滚对应事务。</p><p>比如若发现上图中的文件 1 某条数据不准确,我们很难识别出需要回滚事务 1 还是事务 2,或者两者都需要,因此只能选择比较安全的做法,回滚全部事务。更加严重的问题在于,如果异常作业除了 Source/Sink 有还别的有状态的算子,那么我们无法直接丢弃原先的 Checkpoint 重新开始,只能从有限的几个可选 Checkpoint 中选一个来恢复,而这个 Checkpoint 记录的输入输出文件及其 Offset 又不一定符合当前最新状态,可能造成作业恢复状态后提交事务失败。</p><p>解决数据血缘丢失的关键在于,Flink Checkpoint 记录的数据存储 Offset 应当同步持久化到外部,最好可以有存储系统的原生支持。如此一来,即使事务数据即使没有对齐存储单元,要追踪和操作事务涉及的数据也比较方便。举个大家熟悉的例子就是 Kafka 的 Consumer Group Offset。不过 Kafka 仍有个问题在于 Consumer Group Offset 没有版本控制,所以只能记录最新的一组 Offset。在这点上,Pravega 允许多组 StreamCut 则更加友好。</p><h1 id="结束条件"><a href="#结束条件" class="headerlink" title="结束条件"></a>结束条件</h1><p>相对于数据血缘主要是业务应用层面问题,结束条件则更多是计算引擎层面的问题,而且是流批一体最大的障碍之一。幸运的是这些问题在最新的 Flink 1.14 都得到了基本解决。</p><p>在批计算中,计算的事务和输入数据的边界是对齐的,因此输入数据结束则代表事务可提交;而在流计算中,计算的事务是由周期性 Checkpoint 而不是输入数据边界驱动的,因此事务可提交的标识是输入数据结束加上 Checkpoint 快照成功。这点在 Flink 1.14 中有所体现,现在 Flink 可以在 Bounded Source 结束以后会马上触发一个 Checkpoint,来提交最后一个事务的数据,不过为保持与之前版本的行为一致,这个功能暂时在默认情况下是关闭的。</p><p>另外一个跟结束条件相关的问题是,在混合使用 Bounded 数据集和 Un-Bounded 数据集的情况下,会遇到 Bounded 数据集已经输入完毕(因此 Task 为 Finished)但整体作业还在运行的情况,这时 Flink 需要继续能进行正确 Checkpoint。这个问题听起来不算难,但其实有非常复杂的实现细节需要考虑,感兴趣的同学可以阅读 FLIP-147 [4]。本文只列举其中最为核心的三个备选解决方案,其中最后一个为被采纳的最终方案:</p><ul><li>让已经结束的 Source Task 继续保持在 Running 状态,不要转为 Finished 状态。这个方案比较投机取巧,但有点滥用了 Task 状态,带来的后果就是不能依靠 Source 结束产生的 <code>EndOfPartition</code> 事件来代表输出结束,而是要另外引入新的事件。</li><li>让 Task 转为 Finished,同时记录 Finished Task 的 State 到 Checkpoint。这个行为听起来很自然,但实现起来有诸多问题,比如 Task 转为 Finished 前的最后一次 Checkpoint 包含着这个 Task 最终的 State,而作业后续的每次 Checkpoint 都会引用它,导致 Checkpoint 难以清理。这是因为 Task 变为 Finished 后状态不再可以访问,所以不能从当前的算子从获取。</li><li>让 Task 转为 Finished,但不记录 Finished Task的 State 到 Checkpoint。这个方案相当于将 Finished Task 的 State 丢弃掉,因此在这之前的一个 Checkpoint 需要触发相关算子将中间结果全部 flush,效果类似 <code>stop-with-savepoint --drain</code> 命令。</li></ul><h1 id="数据源可重复读"><a href="#数据源可重复读" class="headerlink" title="数据源可重复读"></a>数据源可重复读</h1><p>数据源可重复读是 Flink Checkpoint 机制对数据源的要求之一,意味着 Flink 作业在进行主动或被动的重启之后,仍然可以依据 Checkpoint 记录的状态重新读取跟之前相同的数据。数据源可重复读是数据准确性基本保证。跟数据库领域的可重复读类似,数据源可重复读要求数据源在被读取期间不被同时发生的更新修改操作所影响。</p><p>从严格意义来说,数据源可重复与本文主题数据边界并没有必然关系。但 Bounded 存储系统通常基于文件、对象等可更新的抽象概念,而 Un-Bounded 存储系统通常基于消息队列这样不可更新的抽象概念,所以比起 Un-Bounded 存储,Bounded 存储需要额外考虑可重复读的问题。</p><p>如果一个文件、对象被流计算作业所读,可以认为它涉入了一个生命周期等同于作业能回滚的最大时长的长事务。由于 Bounded 存储系统通常没有类似数据库 MVCC 的多版本控制,因此在这个长事务期间,文件必须保持不变,以确保若作业出现事务回滚(也就是作业恢复至之前的某个 Checkpoint)时,读取到的数据还是跟以前一致的。这对于主要用作数据仓库或者数据归档场景的 HDFS、S3 来说问题并不是很大,因为数据写入之后常常不会再更新,但也有一些例外的情况,比如要对数据进行压缩合并或者作为冷数据降级到更便宜的存储系统上。所以在实际生产中,一般还是明确需要限制流计算可以回滚的最大时长,在超过这个阈值之后解除数据不可更新的限制。</p><p>另外一个更加有趣的场景发生在流计算直接读取数据库时(虽然生产环境很少这么做)。数据库的更新操作要比大数据存储频繁得多,而且优先级更高,没有办法要求数据库锁表不更新,只能依靠 MVCC 来保证写不影响读。然而,MVCC 的作用范围只有单个数据库事务,对齐到 Flink 端就是单个 Checkpoint,而 Flink 要求的可重复读是横跨多个 Checkpoint 的。这个问题是笔者在开发一个第三方的 Flink MongoDB Connector [5]时遇到的,以直接读取的方式实现的 Source 很难配合 Flink Checkpoint 机制,因此还是应该以 CDC 方式来读取数据库。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>不难看出,Flink 虽然已经实现流批一体引擎及其跟各种存储系统的接口,但在批场景下的结合传统 Bounded 存储系统的使用体验距离传统批计算引擎还有一定的距离或差异。当然,这也是 Iceberg、Hudi 等数据湖在近年来异军突起的原因。在批计算场景下,这些数据湖屏蔽底层文件、并发写和多版本控制的特性可以很好地弥补传统 Bounded 存储系统与 Flink 的间隙,同时也支持接近数据库的 ACID,满足 Serving 需求。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="http://pravega.io/docs/v0.6.0/streamcuts/#streamcut-with-batchclient" target="_blank" rel="external">Pravega: StreamCut with BatchClient</a></li><li><a href="https://developer.aliyun.com/article/783112" target="_blank" rel="external">Flink 执行引擎:流批一体的融合之路</a></li><li><a href="https://flink.apache.org/news/2021/09/29/release-1.14.0.html#the-unified-batch-and-stream-processing-experience" target="_blank" rel="external">Apache Flink 1.14.0 Release Announcement</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-147%3A+Support+Checkpoints+After+Tasks+Finished#FLIP147:SupportCheckpointsAfterTasksFinished-Option1.Preventtasksfromfinishing" target="_blank" rel="external">FLIP-147: Support Checkpoints After Tasks Finished</a></li><li><a href="https://github.com/mongo-flink/mongo-flink" target="_blank" rel="external">MongoFlink</a></li></ol>]]></content>
<summary type="html">
<p>众所周知,流场景和批场景最为根本的区别在于 Data Boundness(数据集有界性)。Data Boundness 将数据分为 Bounded 和 Un-Bounded。在业界过去多年的实践中,两者分别绑定对应领域的存储系统和计算引擎,然而在流批一体的趋势下,领域的边界在逐渐弱化。例如,消息队列通常用作流场景,但 Pravega 的 StreamCut 支持将指定队列中某一段消息作为批处理的输入[1]。在混合使用流批的场景下,不少原本大家习以为常的设定都需要重新去审视,其中的一项便是数据集内部的边界。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
<category term="随想" scheme="https://link3280.github.io/tags/%E9%9A%8F%E6%83%B3/"/>
</entry>
<entry>
<title>流计算与服务网格</title>
<link href="https://link3280.github.io/2021/11/02/%E6%B5%81%E8%AE%A1%E7%AE%97%E4%B8%8E%E6%9C%8D%E5%8A%A1%E7%BD%91%E6%A0%BC/"/>
<id>https://link3280.github.io/2021/11/02/流计算与服务网格/</id>
<published>2021-11-02T15:53:48.000Z</published>
<updated>2021-11-07T14:50:18.887Z</updated>
<content type="html"><![CDATA[<p>流计算(Stream Processing)和服务网格(Service Mesh)本分别属于大数据和在线服务两个不同的领域,放在一起比较并不常见,但从本质而言两者都是分布式的运行时系统(Runtime)或基础设施,并提供专用的编程 API 或 SDK 供用户在其上运行任意代码。若不论效率,流计算提供的海量数据流式处理功能改为使用服务网格同样可以实现,反之某些服务网格提供的在线服务通过一定改造也可以运行在流计算引擎之上。当然,实现中大概没有人会将它们的使用场景搞混,但随着实时数据服务与在线服务的边界逐渐模糊,在某些数据密集型系统里,流计算和以服务网络为代表的微服务的确都可以作为技术选型的选项,比如事件驱动(Event Driven)应用。</p><a id="more"></a><p>因此,本文将对比流计算与以服务网络为代表的微服务基础设施之间的异同,希望能帮助大家从不一样的角度来理解两种当前发展迅速的技术。由于笔者并不是微服务或服务网格专家,不能保证所有理解正确,如内容有纰漏之处还请读者不吝指教。</p><h1 id="服务网格简述"><a href="#服务网格简述" class="headerlink" title="服务网格简述"></a>服务网格简述</h1><p>因为本文读者大多数是大数据背景,对服务网格可能并不熟悉,所以先简单介绍下服务网格的基本概念。</p><p>在微服务之前的单体系统(Monolithic)时代,业务逻辑集中在少数的进程中,服务进程的可拓展性、可用性、容错性尤为重要,而进程间的依赖关系并不复杂,服务间调用的流量也较少,因此工程师的关注点主要在服务本身,流量调度通常用简单的 Nginx 则可满足。但随着微服务的流行,系统不同模块被拆分为多个独立的微服务,每个服务承载的业务逻辑简化,但迅速膨胀的服务数量以及依赖关系却成为管理的瓶颈。单体系统的模块间调用演变成基于网络的服务调用,复杂性从单个服务内部转移到了多个服务的治理上,此时各类微服务框架应运而生。</p><p>在微服务框架发展早期,SDK 或开发框架等应用层的解决方案毫无疑问是主流,其中最为著名的便是 Spring Cloud[11]。Spring Cloud 对微服务提供了非常完善的支持,同时延续了 Spring 家族一贯的强大生态。然而在近几年容器技术逐渐成熟的背景下,以 Kubernetes 为代表的容器编排和与之配套的以 Istio 为代表的服务网格异军突起,凭借更轻量级和适用面更广的优势获得了更多的关注。</p><center><p><img src="/img/streaming-service-mesh/img1.service-mesh-architecture.png" alt="图1.服务网格架构" title="图1.服务网格架构"></p></center><p>在服务编排之上,服务网格将服务中原本的非业务功能抽取出来作为基础设施,这些功能包括服务发现、健康检查、流量路由、负载均衡、容错重试、认证授权、故障注入和可观察性等。服务网格分为两个组件: 数据面板(Data Plane)和控制面板(Control Plane)。数据面板由一组边车代理(Sidecar Proxy)组成,而边车代理通常为系统自动在服务器(通常是 Kubernetes 的 Pod)中注入的通信服务器,以类似网络安全里中间人攻击的方式进行流量劫持,在服务无感知的情况下接管其对外的通信[3];而控制面板则负责边车代理的配置和管理,以操纵流量。</p><h1 id="流计算与服务网格的对比"><a href="#流计算与服务网格的对比" class="headerlink" title="流计算与服务网格的对比"></a>流计算与服务网格的对比</h1><p>如本文开头所说,流计算与服务网格均提供通用目的的分布式的基础设施和编程 API/SDK,因此两者在大体功能上难免有相近之处,但由于各自面向场景的不同,在实现上各有侧重点。下文将详细讨论这里些不同背后的权衡取舍,即为什么要这么设计。</p><h2 id="分布式运行时环境"><a href="#分布式运行时环境" class="headerlink" title="分布式运行时环境"></a>分布式运行时环境</h2><p>流计算的分布式 Runtime 通常是主从架构,由计算引擎的 Master 和 Worker 进程组成(比如 Flink 的 JobManager 和 TaskManager、Spark 的 Driver 和 Executor)。Master 负责 Worker 管理和计算的调度,Worker 负责提供计算资源和具体的用户代码执行。Runtime 首先要调度资源,然后才可以接受请求调度用户代码,这样的设计被称为两级调度(Two-Level Scheduling),即资源和计算任务是分开调度的。</p><p>两级调度的 Runtime 架构是比较重的,优点是用户代码可以非常轻,通常以 UDF 的方式存在,提供类似 FaaS (Function As a Service)的体验,并且方便多次调度复用资源(例如 Flink 重试策略的 Local Recovery);缺点显然是首次运行初始化的时间较长,因为资源准备和计算任务调度是分开两步的。</p><p>相比之下,服务网格的 Runtime 环境则复杂一点,职责分散在容器编排系统系统(Kubernetes)、容器 Runtime(Docker/containerd)、服务网格的控制面板(Istio/Consul 等)和数据面板(Envoy)四者上。Kubernetes 和容器 Runtime 负责底层的服务编排和运行,而服务网格负责上层的流量调度。与流计算不同,服务网格的服务调度是一级调度,服务本身绑定了资源,比如 Kubernetes 的 Pod。这样的设计很符合在线场景,因为用户代码以容器镜像的方式打包并通过仓库分发,只需要容器 Runtime 即可独立运行,并不需要一个中心化的 Master 来调度任务。然而,不同于流计算的 Worker 属于同个用户并且任务的优先级基本相同,在线服务可能千差万别,可能来自不同用户,有不同调用模式和不同优先级,因此需要更加灵活和强大的服务调用管理(服务调用体现为网络流量,因此又称为流量管理)。为此,服务网格在服务编排的基础上再提供一层流量管理,为每个服务部署一个 Sidecar Proxy 组成数据面板进行流量劫持,并提供统一的控制面板进行细粒度管理。</p><center><p><img src="/img/streaming-service-mesh/img2.xaas.png" alt="图2. XaaS 对比" title="图2. XaaS 对比"></p></center><p>服务网格的 Runtime 相对而言比较轻,因为服务本身已经是比较重的可执行程序,而且对业务无入侵是最重要的特性之一。类比流计算提供的 FaaS,服务网格毫无疑问是提供 PaaS(见图2)。通常情况下,一个已经容器化部署的微服务架构系统几乎不用业务改造就可以迁移到服务网格之上(Istio 等主流控制面板一般提供对业务透明的代理注入)。这样设计的优点是对用户使用模式没有假设或者前置条件,因此非常灵活;缺点是架构比较复杂,管理比较分散,例如用户代码的分发和运行依赖容器 Runtime 及其仓库,服务编排依赖 Kubernetes,流量管理依赖服务网格,而不像流计算那样一站式的解决方案。</p><h2 id="流量管理"><a href="#流量管理" class="headerlink" title="流量管理"></a>流量管理</h2><p>上文简单提到流计算的流量管理简单,而服务网格的流量管理复杂,本节将深入分析两者的不同。</p><p>根据 2019 年 KubeCon 上微软联合 Linkerd 、Consul 等厂商发布的服务网格接口(Service Mesh Interfaces)[6],当前服务网格的核心功能分为以下四点,均以流量(Traffic)为核心。</p><ul><li>流量访问控制(Traffic Access Control): 配置 Pod 间的访问权限以及根据用户身份进行流量的路由。</li><li>流量规范(Traffic Spec): 配置服务间的基于流量特征的路由。不同的协议有专用的一套资源和路由,例如 <code>HTTPRouteGroup</code> 为 HTTP 协议专用的资源,提基于 HTTP Path/Header/Method 等特征的路由。</li><li>流量拆分(Traffic Split): 按照百分比拆分流量到不同的服务。除了用于服务实例间的负载均衡,也可用于金丝雀发布和 A/B 测试的场景。</li><li>流量度量(Traffic Metrics): 通用的流量统计,比如 HTTP 请求的错误率、响应的延迟等。</li></ul><p>就这四个功能点而言,流计算中的流量控制或缺少某个功能,或实现了基本功能但特性上比较少,而当然这些设计都是基于使用场景权衡之后的结果。</p><ul><li>不支持访问控制: 流计算通常以作业集群方式隔离不同用户的作业,而且不同作业间不需要直接网络通信,因此不需要细粒度的访问控制。</li><li>简单的流量规范: 相比与服务网格支持多种应用层协议,流计算通常使用更高效的 Socket 传输数据,因此并没有协议特定的路由规则,而是简单地按照数据本身的属性来路由,比如用户点击数据中按照用户 ID 来进行 <code>KeyBy</code> 或 <code>PartitionBy</code>。值得注意的是,流计算的路由规则是隐式的,用户只要指定作业计算逻辑,系统会自动规划如何路由,或者用大数据领域的词称为 <code>Shuffle</code>。之所以有这样的差别,主要是通常一个在线服务的不同实例都是完全并行的(Embarrassingly Parallel)[7] ,即计算逻辑不需要通过分治来解决,单实例即可完成,所以流量路由是可选的(出于负载均衡、会话亲和性等目的),而不像流计算一样是必须的(分治的划分子问题和合并结果)。</li><li>流量拆分: 流计算假设同个任务的不同实例都是相同的,包括代码版本、资源等,因此一般没有流量拆分的需求。如果一个算子有多个下游,通常以 RoudRobin 的方式来均等拆分流量。虽然用户也可以实现动态配置的 Partitioner 来定制流量拆分,但应用场景十分有限。</li><li>流量度量: 流计算 Runtime 通常会提供通用的流量度量,包括延迟、QPS 等等,但请求错误率通常不会提供。不像在线业务可以按照请求隔离错误,流计算中所有的错误都会导致作业计算不准确,所以一个任意的小错误也会导致整个作业异常,可能整个集群的 Worker 都要从某个快照开始重新计算。</li></ul><h2 id="数据存储架构"><a href="#数据存储架构" class="headerlink" title="数据存储架构"></a>数据存储架构</h2><p>作为微服务架构的延伸,服务网格通常应用 <code>Datebase Per Service</code> 的数据库架构分散管理数据,即每个微服务有独立的数据库,并且只允许直接访问自己的数据库。不同服务的数据库可以放在相同或不同的数据管理系统实例上,只要在逻辑上是分开访问即可。这样的目的是给数据划分明确的边界,避免微服务间的耦合,不过也带来一定的性能损失。</p><p>这样的数据存储架构其实跟流计算的状态持久化十分相似。以 Flink 为例,在以 Runtime 集群为单位的全局状态存储后端(StateBackend)背后,Flink 的每个算子有独立的本地状态存储后端,不同算子间的状态完全隔离。在此基础上,Flink 再提供算子状态的数据分区(即 KeyedState),按照业务特征来隔离物理存储,类似 MySQL 的分库分表。如果并行度发生变化,Flink 可以统一地对数据流和状态进行重新的分区。</p><h2 id="分布式事务"><a href="#分布式事务" class="headerlink" title="分布式事务"></a>分布式事务</h2><p>作为使用多个存储系统的分布式系统,流计算与服务网格均不得不面对分布式事务的难题。实现分布式事务通常有两种思路:一是把分布式事务看作横跨多个服务的一个大事务,典型的实现算法是 <code>2PC</code>(两阶段提交)和 Paxos(BTW,事实上 <code>2PC</code> 也可以被看作是 Paxos 算法容忍零错误时的特例[8]);二是把分布式事务看作一系列本地(子)事物并分步执行,这种方案被称为 SAGA 模式[9]。</p><p><code>2PC</code> 引入协调者(Coordinator)来掌控所有事务的参与者(Participants)。事务开始进入 Pre-Commit 阶段,协调者节点向所有参与者节点询问是否可提交事务,参与者做提交准备(比如写 WAL)并根据结果决定是否同意。第二阶段为 Commit 阶段,协调者根据参与者反馈作出决策。若所有参与者均同意,则进入下个阶段;否则终止(Abort)事务,协调者通知参与者进行回滚操作。</p><p><code>2PC</code> 实现简单且提供较强的事务保证,因此在业界应用广泛,然而也有一定的局限性:</p><ul><li><code>2PC</code> 事务的提交和回滚依赖于参与者本地事务的提交和回滚,但是 NoSQL 或消息队列等存储系统不一定支持事务。</li><li><code>2PC</code> 事物的进度依赖于协调者单点,如果协调者出现故障,整个事务就会卡住,直到协调者恢复。虽然有 <code>2PC</code> 的改进版本可以通过 Peer 间通信等方式缓解该问题,但又会出现更多衍生问题。 </li><li><code>2PC</code> 是阻塞的,性能有明显的木桶效应,总体性能取决于最慢的一个参与者。</li></ul><p>相比之下,SAGA 模式以牺牲一定程度的原子性和隔离性为代价,降低了分布式事务的门槛。SAGA 原本的目的是避免长时间运行的大事务锁定数据库资源太久,导致事务冲突频繁甚至死锁,因此用多个子事务的方式来分步执行,降低每次加锁的范围。因为在 SAGA 事务未结束前,子事务便会提交,因此 SAGA 要求为每个子事务设计相应的补偿事物(Compensatmg Transaction),用于在 SAGA 事务终止时消除已提交的事务的影响。比如对于新建订单的事务,对应的补偿事务便是取消该订单。</p><p>从实现的架构而言,SAGA 可以分为中心化的编排(Orchestration)模式和去中心化的协同(Choreography)模式。</p><center><p><img src="/img/streaming-service-mesh/img3.orchestration-based-saga.jpeg" alt="图3. 编排模式 SAGA" title="图3. 编排模式 SAGA"></p></center><p>编排模式与 <code>2PC</code> 一样引入一个中心化的编排者(Orchestrator)来负责 SAGA 事务的决策。编排者与所有服务通信,依照 SAGA 事务的定义按序触发本地事务或异常恢复。</p><center><p><img src="/img/streaming-service-mesh/img4.choreography-based-saga.jpeg" alt="图4. 协同模式 SAGA" title="图4. 协同模式 SAGA"></p></center><p>而协同模式下,SAGA 事务的决策被分散在各个服务上,每个服务通过消息队列直接与其他服务通信,在成功完成当前步骤的 SAGA 事务后,通过事件通知后序步骤的服务执行下一步,或在当前步骤失败后,通过事件通知前序服务的服务回滚前一步。因为服务间通过事件消息协同,协同模式的 SAGA 又被称为事件驱动(Event Driven)。如果对协同模式的 SAGA 进一步放宽约束,允许子事务并行执行,那么还可以细分出一种称为 Parallel Pipelines 的变体。</p><p>RedHat 的一篇博客[10]对上述几种分布式事务进行了很好的总结,借用其中两张图来概括选型上的基本考量。</p><center><p><img src="/img/streaming-service-mesh/img5.distributed-transaction-patterns-charateristics.png" alt="图5. 分布式事务特性对比" title="图5. 分布式事务特性对比"></p></center><center><p><img src="/img/streaming-service-mesh/img6.data-consistency-scalability.png" alt="图6. 分布式事务的一致性与水平拓展能力" title="图6. 分布式事务的一致性与水平拓展能力"></p></center><p>对于流计算而言,长时间的大事务并不是问题:流计算 Source 的数据通常是不可变的(Append-Only,不支持更新),比如 Kafka/Pulsar 等消息队列或 CDC 数据流,所以不需要锁;而 Sink 的数据存储要么也是不可变的,要么是专门准备给流计算写入的,与其他业务完全隔离,所以很少出现与其他用户的事务冲突的问题。显然,在这样的背景下,提供较强一致性的 <code>2PC</code> 会是不二的选择。事实上,笔者所接触过的流计算引擎都使用 <code>2PC</code> 实现分布式事务,包括 Flink、Spark Structured Streaming、Kafka Streams。</p><p>而对微服务而言,由于无法假定服务的业务使用模式,比如对一致性要求如何、需要何种级别的事务隔离、数据库是否支持事务,所以一般把方案暴露给用户自由选择。常见支持的选项有 <code>XA</code>/<code>TCC</code> 等 <code>2PC</code> 的实现和编排模式或协同模式的 SAGA。在早期微服务时代,Spring Cloud 等云原生基础设施或 Seate[12] 等中间件都提供分布式事务的支持,但现在进入服务网格崛起的后微服务时代,Istio 等头部项目却未对分布式事务有所规划,原因也很简单: 分布式事务与业务联系紧密,离不开应用层的支持,但服务网格对业务无入侵的特性让其也失去了在应用层做事的空间。</p><h2 id="编程模型"><a href="#编程模型" class="headerlink" title="编程模型"></a>编程模型</h2><p>本文开头有说到,流计算和服务网格有一些共性,但很难让人将它们想到一起,其中很大原因可以归咎于两者的编程模型非常不同。但试想如果流计算提供与 Web 开发框架类似的编程接口,每个函数注册好服务地址,接受请求并处理返回结果给调用者,是不是也一定程度上能达到微服务的效果呢?实际上,业界的确有这样的尝试,例如基于 Flink 的 Serverless 框架 StateFun[13]。</p><p>StateFun 在 Flink 之上提供更接近在线场景的面向消息的 API 函数,而且允许任意函数间通过注册的地址相互发消息。这意味着 StateFun 不再需要在编译期构建一个静态的 DAG(有向无环图),打破了流计算由系统控制数据流的传统。这样的编程模型可以更好地适应在线服务不同请求差异大和相互之间基本隔离的特性。有趣的是,若不考虑 Master 节点,StateFun 集群基本是微服务架构(见下图),与传统的微服务框架 Spring Cloud 有意外的相似之处。</p><center><p><img src="/img/streaming-service-mesh/img7.flink-stateful-functions.png" alt="图7. StateFun 架构" title="图7. StateFun 架构"></p></center><p>不过值得注意的是,StateFun 受限于底层 Flink Runtime 为吞吐量优化的异步网络传输,因此服务间的调用接口也只有异步的。相比之下,服务网格完全不对应用层有假设,使用同步的 HTTP/REST 服务、通过消息队列解耦的异步 Event Driven 服务或其他类型的服务都完全取决于用户。</p><h2 id="容错机制"><a href="#容错机制" class="headerlink" title="容错机制"></a>容错机制</h2><p>上文流量管理部分谈到流计算不提供错误率,因为通常所有的数据都属于一个业务单元(即作业),如果出现异常就会导致整个作业的计算结果不准确,所以结果通常只有正常和失败两种。相对地,在线服务的业务单元通常在请求级别,一个请求错误并不影响其他请求,所以能按比率进行统计。错误造成的不同后果导致流计算和服务网格有非常不同的容错机制。</p><p>流计算通过分布式事务定时进行 Checkpoint 快照,在默认情况下,如果某个节点计算出现异常(包括机器故障等系统原因或代码 bug 等业务原因),与之有依赖关系的上下游节点全部需要进行重启恢复,将状态回滚至最近一个成功的快照并进行重试。当回滚发生时,在内存中正被处理的数据会被丢弃掉,然后在作业重试后从快照中读取(比如 Spark)或者重新从数据源读取(比如 Flink)。</p><p>在服务网格中,容错通常是在请求级别的,不会涉及其他的请求和服务实例。对于一个提供 HTTP/REST 这样同步接口的服务,如果某个实例出现异常,通常系统会将流量自动切换到相同服务的其他实例,并自动进行请求的重试。对于 Event Driven 的服务,Kafka/Pulsar 等消息队列通常提供多次消费的持久化能力和负载均衡的消费模式,在某个实例异常时,其他实例可以自动接管其未处理完的事件。</p><p>顺带一提,StateFun 尽管面向 Event Driven 的微服务场景,但容错机制依然沿用了 Flink 的 Checkpoint 快照方式,导致某个服务的异常会引起全部服务的重启(官方称之为“回滚整个世界”)。初看下这是很明显的 overkilled,但因为 Flink 本身节点间的通信并没有使用外部的提供持久化能力的消息队列,要让上游节点重发事件不得不也回滚上游的服务,直到 Source 节点从外部重读消息,所以 StateFun 的做法估计也是不得已而为之。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>本文主要从分布式运行时环境、数据存储架构、分布式事务、编程模型和容错机制几个角度去对比流计算和服务网格两项不同领域的分布式基础设施。实际上,受限于篇幅和笔者时间精力,还有更多角度未纳入讨论,比如反压熔断、进出(南北)流量等,但也足以体现两者设计差异背后的考量</p><p>由于流计算面向的场景主要是外部依赖较少、业务类型比较确定的大数据计算,因此提供更“重”的基础设施(比如有统一数据存储和分布式事务的运行环境)和更新”轻”的编程 API;而服务网格面向的是差异性很大的在线服务,因此需要提供更”轻”更灵活的 API,比如无入侵的流量劫持,但同时也导致基础设施只能比较”轻”,无法做分布式事务这样比较深度的支持。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://buoyant.io/what-is-a-service-mesh" target="_blank" rel="external">What’s a service mesh? And why do I need one?</a> </li><li><a href="https://blog.envoyproxy.io/service-mesh-data-plane-vs-control-plane-2774e720f7fc" target="_blank" rel="external">Service mesh data plane vs. control plane</a> </li><li><a href="https://istio.io/latest/docs/ops/deployment/architecture/" target="_blank" rel="external">Istio Architecture</a> </li><li>[周志明.凤凰架构[M]北京:机械工业出版社,2021] </li><li><a href="https://platform9.com/blog/kubernetes-service-mesh-a-comparison-of-istio-linkerd-and-consul/" target="_blank" rel="external">Kubernetes Service Mesh: A Comparison of Istio, Linkerd, and Consul</a> </li><li><a href="https://github.com/servicemeshinterface/smi-spec" target="_blank" rel="external">Service Mesh Interface Spec</a> </li><li><a href="https://en.wikipedia.org/wiki/Embarrassingly_parallel" target="_blank" rel="external">Embarrassingly parallel</a> </li><li><a href="https://www.microsoft.com/en-us/research/uploads/prod/2004/01/twophase-revised.pdf" target="_blank" rel="external">Consensus on Transaction Commit</a> </li><li><a href="https://microservices.io/patterns/data/saga.html" target="_blank" rel="external">Pattern: Saga</a> </li><li><a href="https://developers.redhat.com/articles/2021/09/21/distributed-transaction-patterns-microservices-compared#" target="_blank" rel="external">Distributed transaction patterns for microservices compared</a> </li><li><a href="https://spring.io/projects/spring-cloud" target="_blank" rel="external">Spring Cloud</a> </li><li><a href="https://github.com/seata/seata" target="_blank" rel="external">Seata</a> </li><li><a href="https://github.com/apache/flink-statefun" target="_blank" rel="external">Flink Stateful Functions</a></li></ol>]]></content>
<summary type="html">
<p>流计算(Stream Processing)和服务网格(Service Mesh)本分别属于大数据和在线服务两个不同的领域,放在一起比较并不常见,但从本质而言两者都是分布式的运行时系统(Runtime)或基础设施,并提供专用的编程 API 或 SDK 供用户在其上运行任意代码。若不论效率,流计算提供的海量数据流式处理功能改为使用服务网格同样可以实现,反之某些服务网格提供的在线服务通过一定改造也可以运行在流计算引擎之上。当然,实现中大概没有人会将它们的使用场景搞混,但随着实时数据服务与在线服务的边界逐渐模糊,在某些数据密集型系统里,流计算和以服务网络为代表的微服务的确都可以作为技术选型的选项,比如事件驱动(Event Driven)应用。</p>
</summary>
<category term="随想" scheme="https://link3280.github.io/categories/%E9%9A%8F%E6%83%B3/"/>
<category term="流计算" scheme="https://link3280.github.io/tags/%E6%B5%81%E8%AE%A1%E7%AE%97/"/>
<category term="服务网格" scheme="https://link3280.github.io/tags/%E6%9C%8D%E5%8A%A1%E7%BD%91%E6%A0%BC/"/>
<category term="微服务" scheme="https://link3280.github.io/tags/%E5%BE%AE%E6%9C%8D%E5%8A%A1/"/>
</entry>
<entry>
<title>网易游戏 FlinkSQL 平台化实践</title>
<link href="https://link3280.github.io/2021/09/13/%E7%BD%91%E6%98%93%E6%B8%B8%E6%88%8F-FlinkSQL-%E5%B9%B3%E5%8F%B0%E5%8C%96%E5%AE%9E%E8%B7%B5/"/>
<id>https://link3280.github.io/2021/09/13/网易游戏-FlinkSQL-平台化实践/</id>
<published>2021-09-13T13:04:06.000Z</published>
<updated>2021-09-13T15:12:21.845Z</updated>
<content type="html"><![CDATA[<p>随着近年来流式 SQL 理论逐渐完善,在实时流计算场景中的提供与离线批计算类似的 SQL 开发体验成为可能,GCP Dataflow、Apache Flink、Apache Kafka、Apache Pulsar 都纷纷推出 SQL 支持。在开源领域中,Flink SQL 毫无疑问是流式 SQL 领域最为流行的框架之一,但由于 Flink SQL 缺乏类似 Hive Server2 的服务端组件,各大厂对 Flink SQL 平台化的实现方案各不相同,而本文将介绍在网易游戏在 Flink SQL 平台化上的探索和实践。</p><a id="more"></a><h1 id="发展历程"><a href="#发展历程" class="headerlink" title="发展历程"></a>发展历程</h1><p>Flink SQL 的平台化与实时计算平台的架构密不可分,下文将简单介绍网易游戏实时计算平台的发展历程。</p><p>网易游戏实时计算平台 Streamfly 取名自电影《驯龙高手》中的 Stormfly,由于显然我们已经从 Storm 迁移到 Flink,所以将 Stormfly 中的 Storm 替换成了更为通用的 Stream。</p><center><p><img src="/img/streamflysql/img1.streamfly-history.png" alt="图1. Streamfly 发展历程" title="图1. Streamfly 发展历程"></p></center><p>Streamfly 建立于 2019 年,前身是离线作业平台 Omega 下的名为 Lambda 的子系统。Lambda 作为实时作业平台,在设计之初支持 Storm、Spark Streaming 和 Flink 三种实时计算框架。出于松耦合设计和公司技术栈的考虑,Lambda 以 Golang 作为开发语言,并采用与 YARN 类似的动态生成 shell 脚本的方式来调用不同框架的命令行接口。这样松耦合的接口方式给我们带来很大的灵活性,比如我们可以轻松支持多个版本的 Flink,不需要强制用户随着系统版本升级,但同时也为后续的 Flink SQL 平台化方案埋下了伏笔。</p><p>在 2019 年底的时候,我们对 Flink SQL 平台化做了第一次的探索,即 StreamflySQL v1。考虑到 Streamfly 本身是 Golang 所写,无法调用 Flink client 的本地,而且当时 Flink Client 接口仍不太适合平台集成[1],于是我们决定使用通用模版 jar 加上包含 SQL 的作业配置的方式来实现 Flink SQL 平台化。</p><p>然而,由于基于 jar 的方式带来的用户体验问题以及当时 Flink SQL 特性还不够完善,StreamflySQL v1 上线以后并未获得用户的青睐,大多数用户在调研之后仍继续使用 jar 的方式来开发和管理 Flink 应用。因此,我们在 2020 年年底对 Flink SQL 进行了新一轮调研,对 StreamflySQL 进行了重构,即 StreamflySQL v2。</p><p>StreamflySQL v2 弃用了通用 jar 的方式,而是在原有 Lambda 作业平台之上新建了 Flink SQL 平台服务端,提供类似 Hive Server2 或者 Kyuubi 的纯 SQL 服务,极大地提升了用户体验。</p><h1 id="StreamflySQL-v1(基于模板-jar)"><a href="#StreamflySQL-v1(基于模板-jar)" class="headerlink" title="StreamflySQL v1(基于模板 jar)"></a>StreamflySQL v1(基于模板 jar)</h1><h2 id="实现方案"><a href="#实现方案" class="headerlink" title="实现方案"></a>实现方案</h2><p>如上文所述,StreamflySQL v1 使用基于通用模版 jar 加配置的方式来实现 Flink SQL 作业,主要包含三个模块: Flink 模板 jar、作为配置中心的后端和提供 SQL 编辑器交互的前端。总体架构如下图所示(为简洁只画出请求的路径,省略了返回的路径)。</p><center><p><img src="/img/streamflysql/img2.streamflysql-v1-architecture.png" alt="图2. Streamfly v1 架构" title="图2. StreamflySQL v1 架构"></p></center><p>一个 Flink SQL 作业的提交流程为:</p><ol><li>用户打开 SQL 编辑器,前端请求元数据,包括 Catalog、Database、Table 等。</li><li>后端转发请求给对应数据源的元数据中心(网易游戏采用分散式的元数据管理,即各组件管理自己的元数据并提供 REST API)。</li><li>用户根据元数据写好 SQL,设置内存、并行度等作业运行配置,提交作业。</li><li>后端对 SQL 和配置进行检查,调用 Lambda API 基于预上传的模版 jar 创建并启动作业。</li><li>Lambda 执行 flink run 命令,启动 Flink client 进程。</li><li>Flink Client 加载并执行模板 jar 的 main 函数,其中会注册多个 Catalog,并通过这些 Catalog 访问元数据。</li><li>Client 进程完成 SQL 的解析、优化,以 per-job 模式提交 YARN application 和 Flink JobGraph。</li></ol><p>对于 Lambda 实时作业平台而言,Flink SQL 作业与其他作业无异,除了作业新建是由 StreamflySQL 后端自动生成以外,其他都运维管理都可以直接在 Lambda 上操作。事实上,除了 StreamflySQL v1,网易游戏还有很多基于 Flink 的服务都是以模板 jar 加动态作业配置的架构实现的。</p><h2 id="痛点"><a href="#痛点" class="headerlink" title="痛点"></a>痛点</h2><p>基于模板 jar 的方式可以较为简单地实现 Flink SQL 的需求,但距离像传统 RDBMS 或者 Hive 的 SQL 终端还有很大的差距,最主要的痛点有以下几个。</p><h3 id="1-响应慢"><a href="#1-响应慢" class="headerlink" title="1. 响应慢"></a>1. 响应慢</h3><p>对于每个 SQL 作业,StreamflySQL v1 都需要启动一个 Flink Client 进程并提交一个 YARN application,其中 JVM 启动、上传依赖到 HDFS 和等待 YARN 分配 container 来启动 jobmanager 都需要比较长的时间,总体下来通常要 1-2 分钟。尤其对于一些复杂的 SQL,由于 SQL 优化的时间较长,总体的时间可能需要 5 分钟以上。</p><p>虽然 Lambda 中 Flink Client 的执行部分是异步的,但用户仍需要等到作业顺利在集群上成功跑起来或者报错退出才可以确认最终提交结果,这样的响应时间对于需要多次调整 SQL 的用户来说是十分影响效率的。</p><h3 id="2-调试难"><a href="#2-调试难" class="headerlink" title="2. 调试难"></a>2. 调试难</h3><p>新开发 Flink SQL 作业通常需要一个调试阶段,而调试的基本需求是: </p><ul><li>调试的 SQL 和最终的线上 SQL 保持一致</li><li>不能影响对线上的数据产生影响</li><li>能方便快捷地获取到执行结果</li></ul><p>针对调试需求,StreamflySQL v1 使用替换 Sink 方式来对于数据进行隔离,即提供一个调试的选项,如果开启则在 Flink SQL 翻译 JobGraph 时将原有的 Sink 替换为一个 PrintSink,并且用本地启动 Flink MiniCluster 执行而不是提交到 YARN 集群。PrintSink 会将输出打印以特定格式到标准输出里(并提供限流功能),而日志将在作业结束或者超时后(调试作业最多执行 15 分钟)被一并返回给 StreamflySQL 服务端。服务端会将其中属于输出结果的部分从日志提取出来,返回前端展示。</p><center><p><img src="/img/streamflysql/img3.streamflysql-v1-debugging.png" alt="图3. Streamfly v1 调试实现方案" title="图3. StreamflySQL v1 调试实现方案"></p></center><p>比起正式执行,调试采用本地执行省去了初始化远程执行环境(YARN application)的时间,同时本地进程更方便采集标准输出。这样的实现方案对于简单的作业而言是可行的,然而缺点也很明显:</p><ul><li>对于比较复杂的作业,SQL 优化可能会占用大部分调试时间导致超时,并给 Lambda 服务端造成比较大的压力。</li><li>无法调试时间窗口较长的作业或者需要 Bootstrap State 的作业。</li><li>执行结果需要等作业结束时一并返回,而不能流式返回,因此用户仍需要等 10 分钟以上。</li><li>对 Flink Table 模块入侵比较多,完全不优雅。</li></ul><h3 id="3-只能执行单条-DML"><a href="#3-只能执行单条-DML" class="headerlink" title="3. 只能执行单条 DML"></a>3. 只能执行单条 DML</h3><p>由于 StreamflySQL v1 只支持作业类型的 SQL 语句,所以只能执行形如 <code>insert into ... select ...</code> 的 DML 语句,无法执行 DDL(比如 <code>create table</code>)、DSL(比如 <code>select</code>) 或 DCL(比如 <code>grant</code>)。而实际上,如果要专门创建一个 Flink Environment 去执行一条 DDL 等语句也明显 overkilled,况且许多 DDL 只对当前 Environment 生效,比如 <code>set</code> 语句。这导致 SQL 编辑器变得有些空有其表,实际能支持的操作十分有限,根本原因是 Environment 生命周期与 Flink Client 进程绑定,而缺乏常驻的 Environment。</p><p>此外,由于当时 Flink SQL 还不支持 StatementSet 的多条 SQL 执行,所以 DML 也被限制为一条,这很大程度上限制了批处理用户的使用(虽然目前在 Streamfly 上使用 Flink 做批处理的用户并不多)。</p><h1 id="StreamflySQL-v2(基于-SQL-Gateway)"><a href="#StreamflySQL-v2(基于-SQL-Gateway)" class="headerlink" title="StreamflySQL v2(基于 SQL Gateway)"></a>StreamflySQL v2(基于 SQL Gateway)</h1><h2 id="实现方案-1"><a href="#实现方案-1" class="headerlink" title="实现方案"></a>实现方案</h2><p>由于 StreamflySQL v1 的种种问题,加上社区出现更多可以借鉴的 Flink SQL 落地经验,在 2020 年底我们对 Flink SQL 平台化方案进行了新一轮的调研,并最终选择了基于 Ververica 的 Flink SQL Gateway[3] 进行新的 Flink SQL 平台开发。</p><p>Flink SQL Gateway 是一个类似 Spark Thrift Server 的应用,提供基于 REST API 的 SQL 接口,但只是一个原型,不具备生产级别的特性。针对于此,我们对其进行了多项改进(下文会逐项解释),并集成到 SpringBoot 应用里,即 StreamflySQL v2 服务端(为简单起见,下文 StreamflySQL 默认指 v2 版本)。</p><p>其中有个比较关键的问题是,StreamflySQL(或者说 SQL Gateway)有和 Lambda 一样的提交作业能力,那么该二者间的关系是如何?如果 StreamflySQL 绕过 Lambda 提交作业,那么相当于有两个独立入口,认证授权、监控告警、计费、审计等通用功能都需要重复建设,而且非常不利于统一管理。</p><p>经过研究,我们最终定下的方案是: 利用 Flink Session Cluster 的资源和作业分离的特性来对两个系统进行分工。具体而言,Lambda 需要新增 Session Cluster 的作业类型,而 StreamflySQL 首先调用 Lambda 新建 Session Cluster,此后再直接和 Session Cluster 交互,包括 SQL 提交和作业管理等。这样的好处是能复用 Lambda 的大部分能力,Lambda 仍然作为运维管理的唯一入口。</p><center><p><img src="/img/streamflysql/img4.streamflysql-v2-architecture.png" alt="图4. Streamfly v2 架构" title="图4. StreamflySQL v2 架构"></p></center><p>新的架构下,用户执行 SQL 的流程如下:</p><ol><li>首先初始化 SQL 会话,若已有则跳转至步骤 5。</li><li>后端创建 Lambda Session Cluster 类型作业并启动。</li><li>Lambda 执行 yarn-session.sh 启动 Flink client 进程。</li><li>Flink client 提交 YARN application 初始化 Flink Session Cluster。</li><li>用户提交 SQL。</li><li>后端解析 SQL,判断是会生成 Flink 作业 DML/DSL 则执行步骤 7,否则直接通过 Catalog 执行并返回结果。</li><li>后端完成 SQL 优化和翻译,编译 Flink JobGraph 提交至 Flink Session Cluster。</li></ol><p>新版 Streamfly 大大改善了用户体验,获得不错的效果,但开发过程中并不是一帆风顺,下文将分享我们遇到的主要挑战和解决方案。</p><h2 id="挑战及解决方案"><a href="#挑战及解决方案" class="headerlink" title="挑战及解决方案"></a>挑战及解决方案</h2><h3 id="1-元数据持久化"><a href="#1-元数据持久化" class="headerlink" title="1. 元数据持久化"></a>1. 元数据持久化</h3><p>社区的 Flink SQL Gateway 在生产中应用的最大难点在于会话、作业的元数据并没有持久化,这意味着如果进程重启,所以元数据都会丢失。上文有提到,我们将 Flink SQL Gateway 集成到 SpringBoot 项目里,因此很自然地将 SQL Gateway 作为封装一个 Service,并将元数据存储到数据库。本地的 Flink Environment 会作为缓存,若不存在则自动从数据库重建。</p><p>此外,在 SQL Gateway 原本会在启动时加载 Flink Configuration 且会用于创建所有会话,然而在实际场景中,不同会话会有不同的配置,最典型的便是 cluster ID(on-YARN 环境下即 YARN application ID)。因此我们提供了运行时的配置覆盖功能,即为每个会话存储优先级更高的配置项,在初始化会话创建 Environment 时,系统会合并从 <code>FLINK_CONF_DIR</code> 加载而来的默认配置和数据库存储的配置项。</p><h3 id="2-多租户(认证-资源)"><a href="#2-多租户(认证-资源)" class="headerlink" title="2. 多租户(认证/资源)"></a>2. 多租户(认证/资源)</h3><p>网易游戏大部分组件都使用 Kerberos 认证,而认证是 SQL Gateway 原先并不具备的。更加关键的是,由于 StreamflySQL 是通用平台,必需支持多租户的能力。用过 Hadoop 生态 Kerberos 集成的同学应该了解,这并不是一件容易的事。主要原因是 Hadoop 提供的 Kerberos 接口 <code>UserGroupInformation</code>(下简称 UGI)的很多状态是 static 的,这意味着认证是 JVM 级别的。</p><p>摆在我们面前的选项有两个: 一是分别用自定义 Classloader 包住每个会话(底层是一个 Flink Environment),因此各自的 UGI 是隔离的;二是利用 Hadoop 的 proxy user 特性,将 StreamflySQL 设置为超级用户,并伪装(impersonate)成代理的用户。</p><p>因为 SQL Gateway 本来就有会话级别的 UserCodeClassloader,所以一开始我们尝试了 Classloader 隔离的方案。然而由于 UGI 的使用散落在各个组件 lib 的代码中,要完全将 Hadoop 生态相关的调用模块化难度较大,后续我们转为了 proxy user 的方案。具体来说,系统先登录为超级用户,然后替代理用户获取不同组件的 delegation token,最后伪装为代理用户以 delegation token 而不是 Kerberos TGT 来进行认证。</p><p>另外在多租户的资源隔离方面,我们底层通过 Lambda 在用户自己的队列启动 Session Cluster,因此每个用户的集群资源天然就是隔离的,避免了类似 Spark Thrift Server 只能使用公用队列而导致资源混用的问题。</p><h3 id="3-水平拓展"><a href="#3-水平拓展" class="headerlink" title="3. 水平拓展"></a>3. 水平拓展</h3><p>上文提到我们将主要的状态存储到数据库,StreamflySQL 服务端基本是无状态的,因此可以方便地水平拓展。水平拓展能力对于 StreamflySQL 尤为重要,除单点问题以外,Flink SQL 优化编译有可能打满 CPU 单核,单实例的资源显然是不够的。然而如果同一个会话的 SQL 请求随机被分发到多个不同的实例,则会导致每个服务实例都需要初始化一个 Environment,浪费资源并导致响应时间显著增长。更加重要的是,会话某些状态是不合适持久化的,比如 <code>select</code> DSL 会让 StreamflySQL 服务端开启一个 TCP 链接接收从集群作业回传的结果数据集,如果同个会话的请求被路由到其他实例,显然无法读取到结果。</p><p>为此我们采用了亲和性的负载均衡策略,基于会话 ID 进行路由,确保同一个会话的请求尽可能路由到同一个实例。当然这只确保大部分情况下正常,如果服务实例挂掉或者维护重启,那么 <code>select</code> 的结果还是会丢失。但考虑到 <code>select</code> 一般用于调试或数据探索,所以是可以接受的范围内。</p><h3 id="4-作业状态管理"><a href="#4-作业状态管理" class="headerlink" title="4. 作业状态管理"></a>4. 作业状态管理</h3><p>社区的 SQL Gateway 未考虑作业的 SQL 状态,而这在生产环境是不可缺失的。对于普通基于 jar 的作业而言,Lambda 在作业启动时会默认搜索最近的一个成功的 Checkpoint 或 Savepoint 用于恢复,具体策略如下:</p><ol><li>若上一次执行最后用 <code>stop/canel with savepoint</code> 方式停止并成功留下 Savepoint 则用该 Savepoint 恢复。</li><li>若上次执行未留下成功 Savepoint,则从该作业所有执行的 Savepoint/Checkpoint 路径查找,取修改时间最近一个用于作业恢复。注意这里查找范围不是上次执行而是全部执行,原因是在作业变更出现异常需要回滚的情况下,目标的 Checkpoint 可能是在更早的几次执行的目录里。</li></ol><p>然而对于 StreamflySQL 来说,却不能直接复用 Lambda 这个功能,一方面是因为 Lambda 只管 Session Cluster 的健康状态,对里面的 Flink SQL 作业并不知情,另一方面是 Lambda 的搜索策略基于 Per-Job 模式每个集群只有一个 Flink Job 的假设,否则有可能会搜索到别的 Job 的 Checkpoint。</p><p>针对这个问题 StreamflySQL 实现了类似 Flink History Server 的基于 JobManager archive 的查找策略,即通过 Flink Job ID 找到已完成作业的最后状态信息,提取其中 Checkpoint 列表,获取其中最新完成的 Checkpoint。若无已完成 Checkpoint,则取 Restored Checkpoint 用于恢复状态。</p><h1 id="未来展望"><a href="#未来展望" class="headerlink" title="未来展望"></a>未来展望</h1><p>目前 StreamflySQL v2 虽然基本达到了预期的效果,但仍在计划中的事项还有非常多,其中比较重要的有:</p><ol><li>Flink SQL 作业的状态迁移(State Migration),即用户对 SQL 进行变更后,如何从原先的 Savepoint 进行恢复。这点据笔者了解业界暂时没有很好的办法,只能通过变更类型来告知用户风险,比如通常而言加减字段不会造成 Savepoint 的不兼容,但如果新增一个 join 表,造成的影响就很难说了。因此后续 StreamflySQL 可能会加入默认开启的执行计划分析,来告知用户变更前后的状态兼容性,如果需要的话,可能允许用户强制覆盖自动生成 Operator ID。</li><li>Flink SQL 作业目前仍未支持细粒度的资源管理,用户只能通过作业级别的并行度和会话级别的 TaskManager 内存设置来控制资源,这对于在同一会话中运行多个作业的场景不太友好。后续希望可以参与并推动社区在 Table/SQL API 细粒度资源配置方面的进度。</li><li>StreamflySQL 在 SQL Gateway 上进行了不少的改进,其中比较通用的 commit 希望可以推回给社区,推动 FLIP-91[4] 的进展。</li></ol><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-74%3A+Flink+JobClient+API" target="_blank" rel="external">FLIP-74: Flink JobClient API</a> </li><li><a href="https://developer.aliyun.com/article/776079" target="_blank" rel="external">Flink SQL 1.11 on Zeppelin 平台化实践</a> </li><li><a href="https://github.com/ververica/flink-sql-gateway" target="_blank" rel="external">Flink SQL Gateway</a> </li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-91%3A+Support+SQL+Client+Gateway" target="_blank" rel="external">FLIP-91: Support SQL Client Gateway</a> </li></ol>]]></content>
<summary type="html">
<p>随着近年来流式 SQL 理论逐渐完善,在实时流计算场景中的提供与离线批计算类似的 SQL 开发体验成为可能,GCP Dataflow、Apache Flink、Apache Kafka、Apache Pulsar 都纷纷推出 SQL 支持。在开源领域中,Flink SQL 毫无疑问是流式 SQL 领域最为流行的框架之一,但由于 Flink SQL 缺乏类似 Hive Server2 的服务端组件,各大厂对 Flink SQL 平台化的实现方案各不相同,而本文将介绍在网易游戏在 Flink SQL 平台化上的探索和实践。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
<category term="实时计算" scheme="https://link3280.github.io/tags/%E5%AE%9E%E6%97%B6%E8%AE%A1%E7%AE%97/"/>
<category term="网易" scheme="https://link3280.github.io/tags/%E7%BD%91%E6%98%93/"/>
</entry>
<entry>
<title>浅谈大数据的过去、现在和未来</title>
<link href="https://link3280.github.io/2021/06/23/%E6%B5%85%E8%B0%88%E5%A4%A7%E6%95%B0%E6%8D%AE%E7%9A%84%E8%BF%87%E5%8E%BB%E3%80%81%E7%8E%B0%E5%9C%A8%E5%92%8C%E6%9C%AA%E6%9D%A5/"/>
<id>https://link3280.github.io/2021/06/23/浅谈大数据的过去、现在和未来/</id>
<published>2021-06-23T14:10:50.000Z</published>
<updated>2021-07-16T15:33:54.379Z</updated>
<content type="html"><![CDATA[<p>相信身处于大数据领域的读者多少都能感受到,大数据技术的应用场景正在发生影响深远的变化: 随着实时计算、Kubernetes 的崛起和 HTAP、流批一体的大趋势,之前相对独立的大数据技术正逐渐和传统的在线业务融合。关于该话题,笔者早已如鲠在喉,但因拖延症又犯迟迟没有动笔,最终借最近参加多项会议收获不少感悟的契机才能克服懒惰写下这片文章。</p><a id="more"></a><p>本文旨在简单回顾大数据的历史,然后概括当前的主要发展趋势以及笔者的思考,最后不免主观地展望未来。</p><h2 id="过去:先进与落后并存"><a href="#过去:先进与落后并存" class="headerlink" title="过去:先进与落后并存"></a>过去:先进与落后并存</h2><p>大数据起源于 21 世纪初 Web 2.0[1] 带来的互联网爆发性增长,当时 Google、雅虎等头部公司的数据量级已经远超单机可处理,并且其中大部分数据是网页文本这样的非结构化、半结构化数据,用传统的数据库基本无法处理,因此开始探索新型的数据存储和计算技术。在 2003-2006 年里,Google 发布了内部研发成果的论文,即被称为 Google 三驾马车的 GFS、MapReduce 和 Bigtable 论文。在此期间,雅虎基于 GFS/MapReduce 论文建立了开源的 Hadoop 项目,奠定了后续十多年大数据发展的基础,也在同时大数据一词被广泛被用于描述这类数据量过大或过于复杂而无法通过传统单机技术处理的系统[2]。</p><p>然而,虽然以 MapReduce 作为代表的通用数据存储计算框架在搜索引擎场景获得巨大成功,但是在于之存在竞争关系的数据库社区看来,MapReduce 是一次巨大的倒退(”A major step backwards”)[3]。主要原因大致如下:</p><ul><li>编程模型的巨大倒退,缺乏 schema 和高级数据访问语言</li><li>实现非常原始,基本是暴力遍历而不是使用索引</li><li>理念落后,是 25 年前的技术实现</li><li>缺少当时 DBMS 标配的大部分特性,比如事务、数据更新</li><li>与当时 DBMS 用户依赖的工具不兼容</li></ul><p>在笔者看来,这篇论文直言不讳地指出了大数据系统的不足,时至今日仍非常有指导意义。而此后的十多年,也正是大数据系统逐渐完善弥补这些缺陷的过程,比如 Hive/Spark 填补了高级编程模型的空白,Parquet/ORC 等存储格式给文件添加了索引,如今的数据湖又在实现缺失的 ACID 事务特性。不过值得一提的是,这些批评是对于通用数据库场景而言,因为搜索引擎场景针对的是无结构化/非结构化数据,而且 Google 搜索本身就是一个巨大的倒排索引(因此无需额外索引)。</p><p>由于大数据系统特性上的种种不足和技术栈的独立性,大数据在过去的十多年中虽然发展迅猛,各种项目百花齐放,但应用场景仍很大程度上局限在数据仓库、机器学习等数据准确性要求没有那么高的场景下。其中很多项目也在设计之初就定位在某些细分应用场景而不是通用场景,比如 Hive 定位为数据仓库,Storm 定位为对于离线数据仓库的实时增量补充[5]。虽然这可以视为支持大数据量级而做的 trade-off,但客观上也造成了大数据生态圈的非常复杂,要完整地用好大数据,通常要引入至少十余个组件,无论对于大数据团队还是用户而言都有较高的门槛。</p><h2 id="现在:百花齐放与融合统一"><a href="#现在:百花齐放与融合统一" class="headerlink" title="现在:百花齐放与融合统一"></a>现在:百花齐放与融合统一</h2><p>所谓天下大势分久必合,一方面大数据生态中各类组件独立的开发使用成本在业务稳定后已经成为不可小觑的开支,另一方面技术发展也使得不少组件有共享底层设施或技术栈的基础,因此 “融合” 将是当下最为明显的趋势,具体分为几个方向: 计算的流批一体、存储的流批一体、在离线服务混部、HTAP。</p><h3 id="计算的流批一体"><a href="#计算的流批一体" class="headerlink" title="计算的流批一体"></a>计算的流批一体</h3><p>计算的流批一体指的是用同一套计算框架同时来实现流计算和批计算,目标是解决 Lambda 架构离线批处理和实时流处理两个不同编程模型的重复数据管道的问题。</p><center><p><img src="/img/bigdata-thoughts/img1.lambda-architecture.jpg" alt="图1. Lambda 架构" title="图1. Lambda 架构"></p></center><p>之所以会形成这样的架构,主要原因是实时流计算发展早期无法提供准确一次的语义(Exactly-Once Semantics),在出现异常重试或数据延迟的情况下很容易导致数据少算或多算,因此需要依赖成熟可靠的离线批计算来定时修正数据。两者在数据准确性上的差别主要来源于:离线批计算的数据是有界的(因此不用考虑数据是否完整)且允许较高延迟,因而几乎不需要在数据准确性和延迟间做 trade-off;而实时流计算非常依赖输入数据的低延迟,如果某个时间点产生的业务数据没有及时被处理,那么它很可能被错误地算入下个统计计算窗口,可能导致前后两个窗口的数据都不准确。</p><p>然而,2015 年 Google Dataflow Model 论文的发布[6]厘清了流处理和批处理的对立统一的关系,即批处理是流处理的特例,这为流批一体的大趋势奠定了基础。本文不打算过于深入 Dataflow Model 内容,简单来说,论文引入了对于流处理至关重要的两个概念:Watermark 和 Accumulation Mode(结果累积模式)。Watermark 由数据本身的业务时间提取而成(这被称为 Event Time 时间特性),表示对输入数据的业务时间的估计。依据 Watermark 而不是数据处理时间来触发计算,这样可以很大程度上解决流计算对延迟的依赖问题。另一方面,Accumulation Mode 定义了流计算不同执行产生的结果之间的关系,从而使得流计算可以先输出不完整的中间结果,然后再逐步修正,最终收敛至准确结果。</p><p>在开源界,最早采用流批一体计算模型的计算框架 Flink/Beam 等,在经过几年的迭代后流批一体已经逐渐达到生产可用,并陆续在前沿的公司落地。由于流批一体涉及到大量业务改造,在目前 Lambda 架构已经稳定运行多年的情况下,推动存量业务的改造的主要动力来源有:</p><ol><li>降本增效。避免同时建设两套数据管道的机器和人力成本。</li><li>对齐口径。批处理的 schema 与流处理的 schema 可能存在不一致,比如同一个指标在批处理可能是天粒度,而流处理是分钟粒度。这样的不一致导致同时使用流和批的结果时容易出错。</li></ol><p>值得注意的是,流批一体并不是将 Lambda 架构中的离线管道改为与实时管道相同的引擎,并与之前一样双跑,而是令作业可以灵活在两种模式上自由切换。通常来说,对延迟不敏感的业务可以用批的模式执行来提高资源利用率,而当业务变为延迟敏感时可以无缝切换为实时流处理模式。而在需要修正实时计算结果时,也可以直接采用 Kappa 架构[7]的方式复制一个作业以批模式来重刷部分数据。</p><h2 id="存储的流批一体"><a href="#存储的流批一体" class="headerlink" title="存储的流批一体"></a>存储的流批一体</h2><p>众所周知,批处理中常读写文件系统,用文件作为存储抽象;而流处理中常读写消息队列,用队列作为存储抽象。在 Lambda 架构中,我们常常要将同时数据写入 HDFS、S3 等文件系统或对象存储供批处理使用,并写入 Kafka 等消息队列供流处理使用。尽管消息队列通过只保留最近一段时间的数据来减少数据存储成本,但这样两套系统的冗余仍造成很大的机器资源开销和人力资源成本。在计算的流批一体大趋势下,存储的流批一体的推进自然也是顺水推舟。</p><p>不过不同于计算有 Dataflow Model 这样能让业界达成 “批处理是流处理特例” 共识的重量级论文,存储的流批一体仍处在基于文件系统和基于消息队列两种流派不相伯仲的状况。基于文件来实现队列特性的代表是 Iceberg/Hudi/DeltaLake 等数据湖,而以队列来实现文件特性的代表是 Pulsar/Pravega 等新型消息队列系统。</p><p>在笔者看来,文件存储和队列存储经过一定的改进都可以满足流批一体的需求,比如 Pulsar 支持将数据归档到分级存储并可选择 Segment(文件) API 或 Message(队列) API 来读取,而 Iceberg 支持文件的批量读取或流式地监听文件。然而结合计算的流批一体而言,两者在写入更新 API 方面有根本的不同,并且该不同点进一步导致了两者的许多不同特性:</p><ol><li>更新方式。虽然文件和队列在大数据场景下通常都是以 Append 方式写入,但文件支持对已经写入数据的更新,而队列则不允许直接更新,而是通过写入新数据加 Compact 删除旧数据的方式来间接更新。这意味着在批处理中读写队列或在流处理中读写文件都有一些不自然(下文会详细说明)。在数据湖等基于文件的存储中,流式读取通常以监听 Changelog 的方式实现;而在基于队列的存储中,批处理要重算更新结果,则无法直接删除或覆盖之前已经写入队列的结果,要么转为 Changelog 要么重建一个新队列。</li><li>版本控制。由于更新方式的不同,文件中的数据是可变的,而队列中的数据是不可变的。文件表示某个时间点的状态,因此数据湖需要版本控制以增加回溯的功能;而相对地,队列则表示一段时间内状态变化的事件,本来有 Event Sourcing 的能力,因此不需要版本控制。</li><li>并行写入。文件有唯一的写锁,只允许单个进程写入。数据湖通常以整个目录作为一个表暴露给用户,如果有多并行写入,则在该目录下为每个并行进程新增基于文件的快照进行隔离(MVCC)。而相对地,队列本来就支持并行写入,因此无需快照隔离。其实这个差异也是由于两者不同的更新方式导致的,因为队列 Append-Only 的方式保证了并发写入也不会导致数据丢失,而文件则不然。</li></ol><p>通过上述的分析,相信不少读者已经隐约感觉到:<strong>基于文件的存储类似流表二象性中的表,适合用于保存可以被查询的可变状态(计算的最终结果或中间结果),而基于队列的存储类似表示流表二象性中的流,适合用于保存被流计算引擎读取的事件流(Changelog 数据)</strong>。</p><center><p><img src="/img/bigdata-thoughts/img2.stream-table-conversion.png" alt="图2. 流表二象性(队列文件二象性)" title="图2. 流表二象性(队列文件二象性)"></p></center><p>虽然流表二象性能使得两者可以交替使用,但若使用不当会导致数据在流表两种状态间进行不必要的转换,并给下游业务造成额外的麻烦。具体来讲,如果文件系统中存的是 Changelog 数据,那么下游进行流式读取(监听)时,读到的是 Changelog 的 Changelog,完全不合理。相对地,如果消息队列存的是非 Changelog 数据,那么该队列则丢失了更新的能力,任何更新都会导致消息不同版本的同时存在。由于目前 Changelog 类型一般由 CDC 或者流计算的聚合、Join 产生,还未推广到一般的 MQ 使用场景,所以后一种问题更常发生。但笔者认为,Changelog 是更加流原生的格式,未来大概会标准化并普及到队列存储中,目前非 Changelog 的数据则可以被看作是 Append-Only 业务的特例。</p><p>上述的结论可以被应用到当前热门的实时数仓建设中。除了 Lambda 架构,当前实时数仓架构主要有 Kappa 架构和实时 OLAP 变体两种[9],无论哪种通常都使用 Kafka/Pulsar 等 MQ 作为 ODS/DWD/DWS 等中间层的存储,OLAP 数据库或 OLTP 数据库作为 ADS 应用层的储存。这样的架构主要问题在于不够灵活,比如若想直接基于 DWD 层做一些 Ad-hoc 分析,那么常要将 DWD 层 MQ 中的数据再导出到数据库再做查询。</p><center><p><img src="/img/bigdata-thoughts/img3.kappa-downsides.png" alt="图3. Kappa 架构痛点" title="图3. Kappa 架构痛点(图源自 FFA 分享[10])"></p></center><p>可能有读者会问,如果使用 Flink 直接读 MQ 数据来算呢?其实是可以的,因为像 Pulsar 也提供了无限期的存储,但效率会比较低,主要原因是 MQ 无法提供索引来实现谓词下推等优化[10],另外经过聚合或者 Join 的数据是 Changelog 格式,数据流中会包含旧版本的冗余数据。因此业界有新的趋势是用 Iceberg 等数据湖来代替 MQ 作为数仓中间层的存储,这样的优点是能比较好地对接离线数仓及其长久以来的业务模式,而代价则是数据延迟可能变为近实时。以本文 “文件适合存储状态” 的观点来讲,实时数仓中需要被业务查询的表的确更适合用文件存储,因为业务需要的是状态,而不关心变更历史。</p><h2 id="在离线混部"><a href="#在离线混部" class="headerlink" title="在离线混部"></a>在离线混部</h2><p>在离线混部指的是将在线业务与大数据场景的实时、离线业务混合部署在相同的物理集群上,目的是提高机器的利用率。由于历史原因,在线业务和大数据业务的技术栈是相对独立的,因而理所当然地分开部署: 在线业务使用为 k8s/Mesos 代表的集群管理器,而大数据业务通常使用 Hadoop 生态原生的 YARN 作为集群管理器。然而随着集群规模的扩大,资源利用率不足的问题日益突显,例如通常 CPU 平均占用不足 20%。解决问题的最佳办法便是打破不同业务独立集群的边界实现混部,并利用业务资源的潮汐现象和优先级进行动态的资源分配。实际上很多公司在离线混部已经有多年的探索,而最近一两年 k8s 的迅猛发展大大加速了业务(包括大数据)上云的进度,因而在离线混部再次成为热点。</p><p>在离线混部技术的难点主要是统一集群管理器、资源隔离和资源调度这几点,下文逐点展开。</p><p>首先,统一在离线的集群管理器是混部的基础。目前大多数公司是 k8s 与 YARN 并存的状态,但在云原生的大趋势下,大数据组件也逐步对 k8s 提供头等的支持,看起来 k8s 一统集群资源只是时间问题。不过 k8s 的要做到这点也绝非一路平坦,一是 k8s 的一级调度设计并不能很好地满足很多批计算作业的复杂调度,二是 k8s 当前能掌控的集群规模一般在 5000 节点左右,比起 YARN 差了一个量级[11]。因此在当前阶段,业界大多是选择 YARN on k8s 的方式来渐进式地迁移。常见的做法是在 k8s pod 里启动 NM,让 YARN 部分 NM 节点运行在 k8s 上。</p><center><p><img src="/img/bigdata-thoughts/img4.yarn-operator.png" alt="图4. YARN-NM 运行在 k8s pod 里" title="图4. 腾讯云 YARN-NM 运行在 k8s pod 内"></p></center><p>然后,资源隔离是混部的核心。虽然 k8s 提供资源管理,但是仅限于 CPU、内存两个维度,而网络和磁盘 IO 却暂未纳入考虑[12]。这对于在混部大数据业务而言显然是不够的,因为大数据业务可以很轻松地将机器的网络或磁盘打满,严重影响在线业务。要达到生产的资源隔离,通常需要 Linux 内核级别的支持,这超出本文的范围和笔者的知识储备,不再详述。</p><p>最后,资源调度是服务质量的保证。调度器需要考虑物理节点的资源异构、同类业务充分打散分布和业务的部署偏好来优化调度,优化效率并最大程度避免相互干扰。此外,集群调度器会按照优先级来进行资源超发。在业务低峰期,空闲的资源可以用于跑优先级低、延迟不敏感的离线作业,然而在业务出现突发流量或发现在线作业受到离线作业干扰时,集群调度器需要快速让离线作业退出并让出资源。</p><h2 id="HTAP"><a href="#HTAP" class="headerlink" title="HTAP"></a>HTAP</h2><p>HTAP 全称是 Hybrid Transactional Analytical Processing (混合事务分析处理),即同时支持在线事务查询和分析查询。前文所说的计算和存储的流批一体是实时和离线技术栈上的融合,在离线混部是大数据业务与在线业务运维管理上的融合,而 HTAP 就是最终的大数据和在线业务技术栈上的融合。自 2014 年 Gartner 提出该概念后,HTAP 成为了数据库领域最为热门的方向。除了简化 OLTP 和 OLAP 两套技术栈的复杂架构外,HTAP 还有一个重要的需求背景: 随着数据场景从企业内部决策支持,到用作为线上增值服务的算法模型输入(比如推荐、广告),再到直接作为面向用户的数据服务(比如淘宝生意参谋、滴滴行车轨迹等),OLTP 和 OLAP 的边界正变得越来越模糊。</p><p>HTAP 从架构来看分为两类: 单系统同时服务于 OLTP 和 OLAP,或有两套系统分别服务于 OLTP 和 OLAP。现在业界比较热门的 TiDB、OceanBase 和 Google 的 F1 Lightning 都属于后者。在这类系统中,OLTP 和 OLAP 分别有独立的存储和计算引擎,并依靠内建的同步机制来将 OLTP 系统中的行存数据同步到 OLAP 系统转为适合分析业务的列存数据。在此之上,查询优化器对外提供统一的查询入口,将不同类型的查询分别路由到合适的系统中。</p><center><p><img src="/img/bigdata-thoughts/img5.f1-lightning-architecture.png" alt="图5. F1 Lightning 架构" title="图5. F1 Lightning 架构"></p></center><p>比起传统的基于 Hadoop 生态的数据仓库,HTAP 的优点是:</p><ol><li>内置可靠的数据同步机制,避免建立 OLTP 库到数据仓库的复杂 ETL 管道,同时也提高了数据一致性(比如 TiDB 和 F1 Lightning 都提供与 OLTP 一致的可重复读一致性)。</li><li>对用户友好的统一查询接口,屏蔽了底层引擎的复杂性,大大降低了 OLAP 的门槛。这使得在有授权的情况下,线上业务团队能利用 OLAP 进行轻量级数据分析,而数据分析团队也能利用 OLTP 进行快速的点查。</li><li>数据安全性更有保障。将数据在不同组件间移动容易造成权限不一致和安全漏洞,而 HTAP 可以复用 OLTP 的数据权限和避免数据跨组件访问来避免这些问题。</li></ol><p>虽然 HTAP 的愿景非常美好,但要构建经得起业务检验的 HTAP 系统并不容易。数据库和大数据领域先后有多次尝试,不过目前算得上成功的案例屈指可数,其主要难点在于:</p><ol><li>OLTP 和 OLAP 资源的隔离。由于 OLAP 常包含一些资源密集的复杂查询,OLTP 和 OLAP 公用的组件很容易产生资源竞争,从而干扰优先级更高的 OLTP 查询。在早些年的案例中,共享计算和存储的 HTAP 都不能获得很好的效果,因此最近的 HTAP 数据库都在硬件级别进行两者负载的隔离,也就是独立的存储和计算。</li><li>数据同步机制如何确保数据一致性和新鲜度(freshness)。不同于基于 Hadoop 的数据仓库通常允许小时级别的数据延迟和不一致窗口,HTAP 通常承诺强一致性以保证一个查询无论被路由到 OLTP 系统还是 OLAP 系统都能获得一致结果,这对数据同步机制的性能和容错性都提出很高的要求。目前在 HTAP 领域称得上 State of the art 的两个数据库里,F1 Lightning 使用无入侵的 CDC 方式进行同步,TiDB 基于 Raft 算法进行数据复制。前者松耦合,但实现比较复杂;后者更加简洁优雅,但会受 OLTP 设计的约束,比如复制的数据块大小需要与 OLTP 一致[16]。</li><li>如何利有机结合 OLTP 和 OLAP 工作负载。目前的 HTAP 像同一个门面后的两套独立系统,一个查询要么交给 OLTP 处理,要么交给 OLAP 处理,并没有产生 1 + 1 > 2 的化学反应。IBM 指出,真正的 HTAP 是在同一个事务里高效地处理 OLTP 和 OLAP 两种工作负载[15]。要做到这点,靠数据同步的 HTAP 架构大概难以做到,需要从分布式事务算法层面来解决。</li></ol><p>尽管 HTAP 还未被广泛应用,但可以预见未来将在很大程度上影响数据仓库架构。在数据规模不大、分析需求简单的场景下,HTAP 将成为最为流行的解决方案。</p><h2 id="未来:回归本质"><a href="#未来:回归本质" class="headerlink" title="未来:回归本质"></a>未来:回归本质</h2><p>“融合” 是大数据当前发展的大势,这点从历史的发展规律角度可以窥见其必然性。对于新出现的技术挑战,在最初的探索期各类解决方案总是层出不穷,其中采用 Greenfield 方式的解决方案可能会将已有的基础推倒重来,相比原有技术带来一定的退化(Regression)。退化限制了新技术的应用场景,导致新旧两种技术的双轨制,但只要核心功能没有太大变化,这样的割裂这往往只是暂时的。</p><p>回顾大数据的发展历史,“大数据” 一词原本用于描述数据规模、多样性和处理性能给数据管理带来的挑战,而后续被用于描述为处理这类问题而构建的数据系统,即 “大数据系统”。由于这类系统基于与传统数据不同的基础构建,并舍弃后者标配的事务特性,导致难以应用到线上业务,通常只用于数据仓库、机器学习等对数据延迟、数据准确性要求稍微低一点的场景,而这类业务场景又逐渐被称为 “大数据业务”。</p><p>然而,大数据技术本质是数据密集型的分布式系统,而随着分布式系统的发展和普及,大数据系统在功能特性和业务场景的限制终将被打破,与新出现的以 Spanner 为代表的 NewSQL 分布式数据库并无明显界限。届时,”大数据” 一词也许会和很多 buzzword 一样逐渐消失在历史的长河,回归到通用的分布式系统的本质。水平扩展、优秀容错性、高可用的分布式特性将成为各种系统的标配,无论在 OLTP 或者 OLAP 场景。</p><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ol><li><a href="https://en.wikipedia.org/wiki/Web_2.0" target="_blank" rel="external">Wikipedia - Web 2.0</a></li><li><a href="https://en.wikipedia.org/wiki/Big_data" target="_blank" rel="external">Wikipedia - Big data</a></li><li><a href="https://courses.cs.washington.edu/courses/csep544/21sp/papers/map-reduce-step-backwards-2008.pdf" target="_blank" rel="external">MapReduce: A major step backwards</a></li><li><a href="https://web.archive.org/web/20130723080959/http://blogs.gartner.com/doug-laney/files/2012/01/ad949-3D-Data-Management-Controlling-Data-Volume-Velocity-and-Variety.pdf" target="_blank" rel="external">3D Data Management: Controlling Data Volume, Velocity, and Variety</a></li><li><a href="http://nathanmarz.com/blog/how-to-beat-the-cap-theorem.html" target="_blank" rel="external">How to beat the CAP theorem</a></li><li><a href="https://developer.aliyun.com/article/780857" target="_blank" rel="external">为什么阿里云要做流批一体?</a></li><li><a href="https://www.oreilly.com/radar/questioning-the-lambda-architecture/" target="_blank" rel="external">Questioning the Lambda Architecture</a></li><li><a href="https://developer.aliyun.com/article/782707" target="_blank" rel="external">Stream is the new file</a></li><li><a href="https://mp.weixin.qq.com/s/l--W_GUOGXOWhGdwYqsh9A" target="_blank" rel="external">基于 Flink 的典型 ETL 场景实现方案</a></li><li><a href="https://developer.aliyun.com/article/781534" target="_blank" rel="external">Flink + Iceberg 全场景实时数仓的建设实践</a></li><li><a href="https://draveness.me/kuberentes-limitations/" target="_blank" rel="external">谈谈 Kubernetes 的问题和局限性</a></li><li><a href="https://github.com/kubernetes/kubernetes/issues/27000" target="_blank" rel="external">Kubernetes#27000: limiting bandwidth and iops per container</a></li><li><a href="http://www.vldb.org/pvldb/vol13/p3072-huang.pdf" target="_blank" rel="external">TiDB: A Raft-based HTAP Database</a></li><li><a href="http://www.vldb.org/pvldb/vol13/p3313-yang.pdf" target="_blank" rel="external">F1 Lightning: HTAP as a Service</a></li><li><a href="https://researcher.watson.ibm.com/researcher/files/us-ytian/sigmod-htaptut.pdf" target="_blank" rel="external">Hybrid Transactional/Analytical Processing: A Survey</a></li><li><a href="https://cloud.tencent.com/developer/article/1718993" target="_blank" rel="external">读论文 - F1 Lightning: HTAP as a Service</a></li></ol>]]></content>
<summary type="html">
<p>相信身处于大数据领域的读者多少都能感受到,大数据技术的应用场景正在发生影响深远的变化: 随着实时计算、Kubernetes 的崛起和 HTAP、流批一体的大趋势,之前相对独立的大数据技术正逐渐和传统的在线业务融合。关于该话题,笔者早已如鲠在喉,但因拖延症又犯迟迟没有动笔,最终借最近参加多项会议收获不少感悟的契机才能克服懒惰写下这片文章。</p>
</summary>
<category term="大数据" scheme="https://link3280.github.io/categories/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
<category term="大数据" scheme="https://link3280.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
<category term="随想" scheme="https://link3280.github.io/tags/%E9%9A%8F%E6%83%B3/"/>
<category term="分布式系统" scheme="https://link3280.github.io/tags/%E5%88%86%E5%B8%83%E5%BC%8F%E7%B3%BB%E7%BB%9F/"/>
</entry>
<entry>
<title>详解 Flink 容器化环境下的 OOM Killed</title>
<link href="https://link3280.github.io/2021/01/02/%E8%AF%A6%E8%A7%A3-Flink-%E5%AE%B9%E5%99%A8%E5%8C%96%E7%8E%AF%E5%A2%83%E4%B8%8B%E7%9A%84-OOM-Killed/"/>
<id>https://link3280.github.io/2021/01/02/详解-Flink-容器化环境下的-OOM-Killed/</id>
<published>2021-01-02T03:21:40.000Z</published>
<updated>2021-01-05T12:18:54.902Z</updated>
<content type="html"><![CDATA[<p>在生产环境中,Flink 通常会部署在 YARN 或 k8s 等资源管理系统之上,进程会以容器化(YARN 容器或 docker 等容器)的方式运行,其资源会受到资源管理系统的严格限制。另一方面,Flink 运行在 JVM 之上,而 JVM 与容器化环境并不是特别适配,尤其 JVM 复杂且可控性较弱的内存模型,容易导致进程因使用资源超标而被 kill 掉,造成 Flink 应用的不稳定甚至不可用。</p><a id="more"></a><p>针对这个问题,Flink 在 1.10 版本对内存管理模块进行了重构,设计了全新的内存参数。在大多数场景下 Flink 的内存模型和默认已经足够好用,可以帮用户屏蔽进程背后的复杂内存结构,然而一旦出现内存问题,问题的排查和修复都需要比较多的领域知识,通常令普通用户望而却步。</p><p>为此,本文将解析 JVM 和 Flink 的内存模型,并总结在工作中遇到和在社区交流中了解到的造成 Flink 内存使用超出容器限制的常见原因。由于 Flink 内存使用与用户代码、部署环境、各种依赖版本等因素都有紧密关系,本文主要讨论 on YARN 部署、Oracle JDK/OpenJDK 8、Flink 1.10+ 的情况。此外,特别感谢 @宋辛童(Flink 1.10+ 新内存架构的主要作者)和 @唐云(RocksDB StateBackend 专家)在社区的答疑,令笔者受益匪浅。</p><h1 id="JVM-内存分区"><a href="#JVM-内存分区" class="headerlink" title="JVM 内存分区"></a>JVM 内存分区</h1><p>对于大多数 Java 用户而言,日常开发中与 JVM Heap 打交道的频率远大于其他 JVM 内存分区,因此常把其他内存分区统称为 Off-Heap 内存。而对于 Flink 来说,内存超标问题通常来自 Off-Heap 内存,因此对 JVM 内存模型有更深入的理解是十分必要的。</p><p>根据 JVM 8 Spec[1],JVM 管理的内存分区如下图:</p><center><p><img src="/img/flink-oom-killed/img1.jvm-memory-overview.jpg" alt="img1. JVM 8 内存模型" title="img1. JVM 8 内存模型"></p></center><p>除了上述 Spec 规定的标准分区,在具体实现上 JVM 常常还会加入一些额外的分区供进阶功能模块使用。以 HotSopt JVM 为例,根据 Oracle NMT[5] 的标准,我们可以将 JVM 内存细分为如下区域:</p><ul><li>Heap: 各线程共享的内存区域,主要存放 <code>new</code> 操作符创建的对象,内存的释放由 GC 管理,可被用户代码或 JVM 本身使用。</li><li>Class: 类的元数据,对应 Spec 中的 Method Area (不含 Constant Pool),Java 8 中的 Metaspace。</li><li>Thread: 线程级别的内存区,对应 Spec 中的 PC Register、Stack 和 Natvive Stack 三者的总和。</li><li>Compiler: JIT (Just-In-Time) 编译器使用的内存。</li><li>Code Cache: 用于存储 JIT 编译器生成的代码的缓存。</li><li>GC: 垃圾回收器使用的内存。</li><li>Symbol: 存储 Symbol (比如字段名、方法签名、Interned String) 的内存,对应 Spec 中的 Constant Pool。</li><li>Arena Chunk: JVM 申请操作系统内存的临时缓存区。</li><li>NMT: NMT 自己使用的内存。</li><li>Internal: 其他不符合上述分类的内存,<strong>包括用户代码申请的 Native/Direct 内存</strong>。</li><li>Unknown: 无法分类的内存。</li></ul><p>理想情况下,我们可以严格控制各分区内存的上限,来保证进程总体内存在容器限额之内。但是过于严格的管理会带来会有额外使用成本且缺乏灵活度,所以在实际中为了 JVM 只对其中几个暴露给用户使用的分区提供了硬性的上限,而其他分区则可以作为整体被视为 JVM 本身的内存消耗。</p><p>具体可以用于限制分区内存的 JVM 参数如下表所示(值得注意的是,业界对于 JVM Native 内存并没有准确的定义,本文的 Native 内存指的是 Off-Heap 内存中非 Direct 的部分,与 Native Non-Direct 可以互换)。</p><table><thead><tr><th>JVM 分区</th><th>内存类型</th><th>硬上限参数</th><th>备注</th></tr></thead><tbody><tr><td>Heap</td><td>Heap 内存</td><td>-Xmx</td><td>最常用的内存参数</td></tr><tr><td>Class</td><td>Native 内存</td><td>-XX:MaxMetaspaceSize</td><td></td></tr><tr><td>Internal(Direct)</td><td>Direct 内存</td><td>-XX: MaxDirectMemorySize</td><td>通过 <code>java.nio.ByteBuffer#allocateDirect</code> 申请的内存,通常用于 IO 模块</td></tr><tr><td>Internal(Non-Direct)</td><td>Native 内存</td><td>无</td><td>JVM 本身使用、JNI 使用或通过 Java 非安全内部类 <code>sun.misc.Unsafe</code> 申请的内存</td></tr></tbody></table><p>从表中可以看到,使用 Heap、Metaspace 和 Direct 内存都是比较安全的,但非 Direct 的 Native 内存情况则比较复杂,可能是 JVM 本身的一些内部使用(比如下文会提到的 <code>MemberNameTable</code>),也可能是用户代码引入的 JNI 依赖,还有可能是用户代码自身通过 <code>sun.misc.Unsafe</code> 申请的 Native 内存。理论上讲,用户代码或第三方 lib 申请的 Native 内存需要用户来规划内存用量,而 Internal 的其余部分可以并入 JVM 本身的内存消耗。而实际上 Flink 的内存模型也遵循了类似的原则。</p><h1 id="Flink-TaskManager-内存模型"><a href="#Flink-TaskManager-内存模型" class="headerlink" title="Flink TaskManager 内存模型"></a>Flink TaskManager 内存模型</h1><p>首先回顾下 Flink 1.10+ 的 TaskManager 内存模型。</p><center><p><img src="/img/flink-oom-killed/img2.flink-mem-model.png" alt="img2. Flink TaskManager 内存模型" title="img2. Flink TaskManager 内存模型"></p></center><p>显然,Flink 框架本身不仅会包含 JVM 管理的 Heap 内存,也会申请自己管理 Off-Heap 的 Native 和 Direct 内存。在笔者看来,Flink 对于 Off-Heap 内存的管理策略可以分为三种:</p><ul><li>硬限制(Hard Limit): 硬限制的内存分区是 Self-Contained 的,Flink 会保证其用量不会超过设置的阈值(若内存不够则抛出类似 OOM 的异常),</li><li>软限制(Soft Limit): 软限制意味着内存使用长期会在阈值以下,但可能短暂地超过配置的阈值。</li><li>预留(Reserved): 预留意味着 Flink 不会限制分区内存的使用,只是在规划内存时预留一部分空间,但不能保证实际使用会不会超额。</li></ul><p>结合 JVM 的内存管理来看,一个 Flink 内存分区的内存溢出会导致何种后果,判断逻辑如下:</p><ol><li>若是 Flink 有硬限制的分区,Flink 会报该分区内存不足。否则进入下一步。</li><li>若该分区属于 JVM 管理的分区,在其实际值增长导致 JVM 分区也内存耗尽时,JVM 会报其所属的 JVM 分区的 OOM (比如 <code>java.lang.OutOfMemoryError: Jave heap space</code>)。否则进入下一步。</li><li>该分区内存持续溢出,最终导致进程总体内存超出容器内存限制。在开启严格资源控制的环境下,资源管理器(YARN/k8s 等)会 kill 掉该进程。</li></ol><p>为直观地展示 Flink 各内存分区与 JVM 内存分区间的关系,笔者整理了如下的内存分区映射表:</p><center><p><img src="/img/flink-oom-killed/img3.flink-mem-partitions-upper-limit.png" alt="img3. Flink 分区及 JVM 分区内存限制关系" title="img3. Flink 分区及 JVM 分区内存限制关系"></p></center><p>根据之前的逻辑,在所有的 Flink 内存分区中,只有不是 Self-Contained 且所属 JVM 分区也没有内存硬限制参数的 JVM Overhead 是有可能导致进程被 OOM kill 掉的。作为一个预留给各种不同用途的内存的大杂烩,JVM Overhead 的确容易出问题,但同时它也可以作为一个兜底的隔离缓冲区,来缓解来自其他区域的内存问题。</p><p>举个例子,Flink 内存模型在计算 Native Non-Direct 内存时有一个 trick:</p><blockquote><p>Although, native non-direct memory usage can be accounted for as a part of the framework off-heap memory or task off-heap memory, it will result in a higher JVM’s direct memory limit in this case.</p></blockquote><p>虽然 Task/Framework 的 Off-Heap 分区中可能含有 Native Non-Direct 内存,而这部分内存严格来说属于 JVM Overhead,不会被 JVM <code>-XX:MaxDirectMemorySize</code> 参数所限制,但 Flink 还是将它算入 <code>MaxDirectMemorySize</code> 中。这部分预留的 Direct 内存配额不会被实际使用,所以可以留给没有上限 JVM Overhead 占用,达到为 Native Non-Direct 内存预留空间的效果。</p><h1 id="OOM-Killed-常见原因"><a href="#OOM-Killed-常见原因" class="headerlink" title="OOM Killed 常见原因"></a>OOM Killed 常见原因</h1><p>与上文分析一致,实践中导致 OOM Killed 的常见原因基本源于 Native 内存的泄漏或者过度使用。因为虚拟内存的 OOM Killed 通过资源管理器的配置很容易避免且通常不会有太大问题,所以下文只讨论物理内存的 OOM Killed。</p><h2 id="RocksDB-Native-内存的不确定性"><a href="#RocksDB-Native-内存的不确定性" class="headerlink" title="RocksDB Native 内存的不确定性"></a>RocksDB Native 内存的不确定性</h2><p>众所周知,RocksDB 通过 JNI 直接申请 Native 内存,并不受 Flink 的管控,所以实际上 Flink 通过设置 RocksDB 的内存参数间接影响其内存使用。然而,目前 Flink 是通过估算得出这些参数,并不是非常精确的值,其中有以下的几个原因。</p><p>首先是部分内存难以准确计算的问题。RocksDB 的内存占用有 4 个部分[6]: </p><ul><li>Block Cache: OS PageCache 之上的一层缓存,缓存未压缩的数据 Block。</li><li>Indexes and filter blocks: 索引及布隆过滤器,用于优化读性能。</li><li>Memtable: 类似写缓存。</li><li>Blocks pinned by Iterator: 触发 RocksDB 遍历操作(比如遍历 RocksDBMapState 的所有 key)时,Iterator 在其生命周期内会阻止其引用到的 Block 和 Memtable 被释放,导致额外的内存占用[10]。</li></ul><p>前三个区域的内存都是可配置的,但 Iterator 锁定的资源则要取决于应用业务使用模式,且没有提供一个硬限制,因此 Flink 在计算 RocksDB StateBackend 内存时没有将这部分纳入考虑。</p><p>其次是 RocksDB Block Cache 的一个 bug[8][9],它会导致 Cache 大小无法严格控制,有可能短时间内超出设置的内存容量,相当于软限制。</p><p>对于这个问题,通常我们只要调大 JVM Overhead 的阈值,让 Flink 预留更多内存即可,因为 RocksDB 的内存超额使用只是暂时的。</p><h2 id="glibc-Thread-Arena-问题"><a href="#glibc-Thread-Arena-问题" class="headerlink" title="glibc Thread Arena 问题"></a>glibc Thread Arena 问题</h2><p>另外一个常见的问题就是 glibc 著名的 64 MB 问题,它可能会导致 JVM 进程的内存使用大幅增长,最终被 YARN kill 掉。</p><p>具体来说,JVM 通过 glibc 申请内存,而为了提高内存分配效率和减少内存碎片,glibc 会维护称为 Arena 的内存池,包括一个共享的 Main Arena 和线程级别的 Thread Arena。当一个线程需要申请内存但 Main Arena 已经被其他线程加锁时,glibc 会分配一个大约 64 MB (64 位机器)的 Thread Arena 供线程使用。这些 Thread Arena 对于 JVM 是透明的,但会被算进进程的总体虚拟内存(VIRT)和物理内存(RSS)里。</p><p>默认情况下,Arena 的最大数目是 <code>cpu 核数 * 8</code>,对于一台普通的 32 核服务器来说最多占用 16 GB,不可谓不可观。为了控制总体消耗内存的总量,glibc 提供了环境变量 <code>MALLOC_ARENA_MAX</code> 来限制 Arena 的总量,比如 Hadoop 就默认将这个值设置为 4。然而,这个参数只是一个软限制,所有 Arena 都被加锁时,glibc 仍会新建 Thread Arena 来分配内存[11],造成意外的内存使用。</p><p>通常来说,这个问题会出现在需要频繁创建线程的应用里,比如 HDFS Client 会为每个正在写入的文件新建一个 <code>DataStreamer</code> 线程,所以比较容易遇到 Thread Arena 的问题。如果怀疑你的 Flink 应用遇到这个问题,比较简单的验证方法就是看进程的 pmap 是否存在很多大小为 64MB 倍数的连续 anon 段,比如下图中蓝色几个的 65536 KB 的段就很有可能是 Arena。</p><center><p><img src="/img/flink-oom-killed/img4.pmap-thread-arena.png" alt="img4. pmap 64 MB arena" title="img4. pmap 64 MB arena"></p></center><p>这个问题的修复办法比较简单,将 <code>MALLOC_ARENA_MAX</code> 设置为 1 即可,也就是禁用 Thread Arena 只使用 Main Arena。当然,这样的代价就是线程分配内存效率会降低。不过值得一提的是,使用 Flink 的进程环境变量参数(比如 <code>containerized.taskmanager.env.MALLOC_ARENA_MAX=1</code>)来覆盖默认的 <code>MALLOC_ARENA_MAX</code> 参数可能是不可行的,原因是在非白名单变量(<code>yarn.nodemanager.env-whitelist</code>)冲突的情况下, NodeManager 会以合并 URL 的方式来合并原有的值和追加的值,最终造成 <code>MALLOC_ARENA_MAX="4:1"</code> 这样的结果。</p><p>最后,还有一个更彻底的可选解决方案,就是将 glibc 替换为 Google 家的 tcmalloc 或 Facebook 家的 jemalloc [12]。除了不会有 Thread Arena 问题,内存分配性能更好,碎片更少。在实际上,Flink 1.12 的官方镜像也将默认的内存分配器从 glibc 改为 jemelloc [17]。 </p><h2 id="JDK8-Native-内存泄漏"><a href="#JDK8-Native-内存泄漏" class="headerlink" title="JDK8 Native 内存泄漏"></a>JDK8 Native 内存泄漏</h2><p>Oracle Jdk8u152 之前的版本存在一个 Native 内存泄漏的 bug[13],会造成 JVM 的 Internal 内存分区一直增长。</p><p>具体而言,JVM 会缓存字符串符号(Symbol)到方法(Method)、成员变量(Field)的映射对来加快查找,每对映射称为 <code>MemberName</code>,整个映射关系称为 <code>MemeberNameTable</code>,由 <code>java.lang.invoke.MethodHandles</code> 这个类负责。在 Jdk8u152 之前,<code>MemberNameTable</code> 是使用 Native 内存的,因此一些过时的 <code>MemberName</code> 不会被 GC 自动清理,造成内存泄漏。</p><p>要确认这个问题,需要通过 NMT 来查看 JVM 内存情况,比如笔者就遇到过线上一个 TaskManager 的超过 400 MB 的 <code>MemeberNameTable</code>。</p><center><p><img src="/img/flink-oom-killed/img5.jdk8-member-name-table-leak.png" alt="img5. JDK8 MemberNameTable Native 内存泄漏" title="img5. JDK8 MemberNameTable Native 内存泄漏"></p></center><p>在 JDK-8013267[14] 以后,<code>MemeberNameTable</code> 从 Native 内存被移到 Java Heap 当中,修复了这个问题。然而,JVM 的 Native 内存泄漏问题不止一个,比如 C2 编译器的内存泄漏问题[15],所以对于跟笔者一样没有专门 JVM 团队的用户来说,升级到最新版本的 JDK 是修复问题的最好办法。</p><h2 id="YARN-mmap-内存算法"><a href="#YARN-mmap-内存算法" class="headerlink" title="YARN mmap 内存算法"></a>YARN mmap 内存算法</h2><p>众所周知,YARN 会根据 <code>/proc/${pid}</code> 下的进程信息来计算整个 container 进程树的总体内存,但这里面有一个比较特殊的点是 mmap 的共享内存。mmap 内存会全部被算进进程的 VIRT,这点应该没有疑问,但关于 RSS 的计算则有不同标准。</p><p>依据 YARN 和 Linux <code>smaps</code> 的计算规则,内存页(Pages)按两种标准划分:</p><ul><li>Private Pages: 只有当前进程映射(mapped)的 Pages</li><li>Shared Pages: 与其他进程共享的 Pages</li><li>Clean Pages: 自从被映射后没有被修改过的 Pages</li><li>Dirty Pages: 自从被映射后已经被修改过的 Pages</li></ul><p>在默认的实现里,YARN 根据 <code>/proc/${pid}/status</code> 来计算总内存,所有的 Shared Pages 都会被算入进程的 RSS,即便这些 Pages 同时被多个进程映射[16],这会导致和实际操作系统物理内存的偏差,有可能导致 Flink 进程被误杀(当然,前提是用户代码使用 mmap 且没有预留足够空间)。</p><p>为此,YARN 提供 <code>yarn.nodemanager.container-monitor.procfs-tree.smaps-based-rss.enabled</code> 配置选项,将其设置为 <code>true</code> 后,YARN 将根据更准确的 <code>/proc/${pid}/smap</code> 来计算内存占用,其中很关键的一个概念是 PSS。简单来说,PSS 的不同点在于计算内存时会将 Shared Pages 均分给所有使用这个 Pages 的进程,比如一个进程持有 1000 个 Private Pages 和 1000 个会分享给另外一个进程的 Shared Pages,那么该进程的总 Page 数就是 1500。</p><p>回到 YARN 的内存计算上,进程 RSS 等于其映射的所有 Pages RSS 的总和。在默认情况下,YARN 计算一个 Page RSS 公式为:</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div></pre></td><td class="code"><pre><div class="line">Page RSS = Private_Clean + Private_Dirty + Shared_Clean + Shared_Dirty</div></pre></td></tr></table></figure><p>因为一个 Page 要么是 Private,要么是 Shared,且要么是 Clean 要么是 Dirty,所以其实上述公示右边有至少三项为 0 。而在开启 <code>smaps</code> 选项后,公式变为:</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div></pre></td><td class="code"><pre><div class="line">Page RSS = Min(Shared_Dirty, PSS) + Private_Clean + Private_Dirty</div></pre></td></tr></table></figure><p>简单来说,新公式的结果就是去除了 Shared_Clean 部分被重复计算的影响。</p><p>虽然开启基于 <code>smaps</code> 计算的选项会让计算更加准确,但会引入遍历 Pages 计算内存总和的开销,不如 直接取 <code>/proc/${pid}/status</code> 的统计数据快,因此如果遇到 mmap 的问题,还是推荐通过提高 Flink 的 JVM Overhead 分区容量来解决。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>本文首先介绍 JVM 内存模型和 Flink TaskManager 内存模型,然后据此分析得出进程 OOM Killed 通常源于 Native 内存泄漏,最后列举几个常见的 Native 内存泄漏原因以及处理办法,包括 RocksDB 内存占用的不确定性、glibc 的 64MB 问题、JDK8 MemberNameTable 泄露和 YARN 对 mmap 内存计算的不准确。由于笔者水平有限,不能保证全部内容均正确无误,若读者有不同意见,非常欢迎留言指教一起探讨。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><p>1.<a href="https://docs.oracle.com/javase/specs/jvms/se8/jvms8.pdf" target="_blank" rel="external">The Java® Virtual Machine Specification Java SE 8 Edition</a><br>2.<a href="https://www.betsol.com/blog/java-memory-management-for-java-virtual-machine-jvm/#Get_more_stuff_like_this" target="_blank" rel="external">Java Memory Management for Java Virtual Machine (JVM)</a><br>3.<a href="https://developers.redhat.com/blog/2017/03/14/java-inside-docker/" target="_blank" rel="external">Java inside docker: What you must know to not FAIL</a><br>4.<a href="http://coding-geek.com/jvm-memory-model/" target="_blank" rel="external">JVM memory model</a><br>5.<a href="https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr022.html" target="_blank" rel="external">NMT Memory Categories</a><br>6.<a href="https://github.com/facebook/rocksdb/wiki/Memory-usage-in-RocksDB" target="_blank" rel="external">Memory usage in RocksDB</a><br>7.<a href="https://github.com/ververica/frocksdb/blob/49bc897d5d768026f1eb816d960c1f2383396ef4/include/rocksdb/write_buffer_manager.h#L52" target="_blank" rel="external">write_buffer_manager.h#L52</a><br>8.<a href="https://github.com/facebook/rocksdb/issues/6247" target="_blank" rel="external">Nullptr when costing memory used in memtable to block cache</a><br>9.<a href="https://issues.apache.org/jira/browse/FLINK-15532" target="_blank" rel="external">[FLINK-15532] Enable strict capacity limit for memory usage for RocksDB</a><br>10.<a href="https://github.com/facebook/rocksdb/wiki/Iterator#resource-pinned-by-iterators-and-iterator-refreshing" target="_blank" rel="external">Resource pinned by iterators and iterator refreshing</a><br>11.<a href="https://www.easyice.cn/archives/341#MALLOC_ARENA_MAX" target="_blank" rel="external">MALLOC_ARENA_MAX=1 与 MALLOC_ARENA_MAX=4有什么区别?</a><br>12.<a href="https://club.perfma.com/article/1709425" target="_blank" rel="external">一次 Java 进程 OOM 的排查分析(glibc 篇)</a><br>13.<a href="https://bugs.openjdk.java.net/browse/JDK-8162795" target="_blank" rel="external">[REDO] MemberNameTable doesn’t purge stale entries</a><br>14.<a href="https://bugs.openjdk.java.net/browse/JDK-8013267" target="_blank" rel="external">move MemberNameTable from native code to Java heap, use to intern MemberNames</a><br>15.<a href="https://www.cnblogs.com/perfma/p/12935785.html" target="_blank" rel="external">假笨说-又发现一个导致JVM物理内存消耗大的Bug(已提交Patch)</a><br>16.<a href="https://stackoverflow.com/questions/53653545/observing-shared-mapped-file-memory-in-linux" target="_blank" rel="external">Observing shared mapped file memory in linux</a><br>17.<a href="https://issues.apache.org/jira/browse/FLINK-19125" target="_blank" rel="external">[FLINK-19125] Avoid memory fragmentation when running flink docker image</a></p>]]></content>
<summary type="html">
<p>在生产环境中,Flink 通常会部署在 YARN 或 k8s 等资源管理系统之上,进程会以容器化(YARN 容器或 docker 等容器)的方式运行,其资源会受到资源管理系统的严格限制。另一方面,Flink 运行在 JVM 之上,而 JVM 与容器化环境并不是特别适配,尤其 JVM 复杂且可控性较弱的内存模型,容易导致进程因使用资源超标而被 kill 掉,造成 Flink 应用的不稳定甚至不可用。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>网易游戏基于 Flink 的流式 ETL 建设</title>
<link href="https://link3280.github.io/2020/12/20/%E7%BD%91%E6%98%93%E6%B8%B8%E6%88%8F%E5%9F%BA%E4%BA%8E-Flink-%E7%9A%84%E6%B5%81%E5%BC%8F-ETL-%E5%BB%BA%E8%AE%BE/"/>
<id>https://link3280.github.io/2020/12/20/网易游戏基于-Flink-的流式-ETL-建设/</id>
<published>2020-12-20T04:05:32.000Z</published>
<updated>2020-12-20T04:19:04.703Z</updated>
<content type="html"><![CDATA[<p>文本由笔者在 Flink Forward Asia 2020 上的分享《网易游戏基于 Flink 的流式 ETL 建设》整理而成。</p><a id="more"></a><h2 id="一-业务背景"><a href="#一-业务背景" class="headerlink" title="一. 业务背景"></a>一. 业务背景</h2><h3 id="网易游戏-ETL-服务概况"><a href="#网易游戏-ETL-服务概况" class="headerlink" title="网易游戏 ETL 服务概况"></a>网易游戏 ETL 服务概况</h3><p>网易游戏的基础数据主要日志方式采集,这些日志通常是非结构化或半结构化数据,需要经过数据集成 ETL 才可以入库至实时或离线的数据仓库。此后,业务用户才可以方便地用 SQL 完成大部分数据计算,包括实时的 Flink SQL 和离线的 Hive 或 Spark。</p><center><p><img src="/img/ffa-netease-games-etl/img1.data_pipeline_overview.jpeg" alt="图1. 网易游戏 ETL 服务概况" title="图1. 网易游戏 ETL 服务概况"></p></center><p>网易游戏数据集成的数据流与大多数公司大同小异,主要有游戏客户端日志、游戏服务端日志和其他周边基础的日志,比如 Nginx access log、数据库日志等等。这些日志会被采集到统一的 Kafka 数据管道,然后经由 ETL 入库服务写入到 Hive 离线数据仓库或者 Kafka 实时数据仓库。</p><p>这是很常见的架构,但在我们在需求方面是有一些比较特殊的情况。</p><h3 id="网易游戏流式-ETL-需求特点"><a href="#网易游戏流式-ETL-需求特点" class="headerlink" title="网易游戏流式 ETL 需求特点"></a>网易游戏流式 ETL 需求特点</h3><center><p><img src="/img/ffa-netease-games-etl/img2.requirements.jpeg" alt="图2. 网易游戏流式 ETL 需求特点" title="图2. 网易游戏流式 ETL 需求特点"></p></center><p>首先,不同于互联网、金融等行业基本常用 MySQL、Postgres 等的关系型数据库,游戏行业常常使用 MongoDB 这类 schema-free 的文档型数据库。这给我们 ETL 服务带来的问题是并没有一个线上业务的准确的 schema 可以依赖,在实际数据处理中,多字段或少字段,甚至一个字段因为玩法迭代变更为完全不同的格式,这样的情况都是可能发生的。这样的数据异构问题给我们 ETL 的数据清洗带来了比较高的成本。</p><p>其次,也是由于数据库选型的原因,大部分业务的数据库模式都遵循了反范式设计,会刻意以复杂内嵌的字段来避免表间的 join。这种情况给我们带来的一个好处是,在数据集成阶段我们不需要去实时地去 join 多个数据流,坏处则是数据结构可能会非常复杂,多层嵌套十分常见。</p><p>然后,由于近年来实时数仓的流行,我们也同样在逐步建设实时数据仓库,所以复用现有的 ETL 管道,提取转换一次,加载到实时离线两个数据仓库,成为一个很自然的发展方向。</p><p>最后,我们的日志类型多且变更频繁,比如一个玩法复杂的游戏,可能有 1,000 个以上的日志类型,每两周可能就会有一次发版。在这样的背景下 ETL 出现异常数据是不可避免的。因此我们需要提供完善的异常处理,让业务可以及时得知数据异常和通过流程修复数据。</p><h3 id="日志分类及特点"><a href="#日志分类及特点" class="headerlink" title="日志分类及特点"></a>日志分类及特点</h3><center><p><img src="/img/ffa-netease-games-etl/img3.log_catalog.jpeg" alt="图3. 日志分类及特点" title="图3. 日志分类及特点"></p></center><p>为了更好地针对不同业务使用模式优化,我们对不同日志类型的业务提供了不同的服务。我们的日志通常分为三个类型:运营日志、业务日志和程序日志。</p><p>运营日志记录的是玩家行为事件,比如登录帐号、领取礼包等。这类日志是最为重要日志,有固定的格式,也就是特定 header + json 的文本格式。数据的主要用途是做数据报表、数据分析还有游戏内的推荐,比如玩家的组队匹配推荐。</p><p>业务日志记录的是玩家行为以外的业务事件,这个就比较广泛,比如 Nginx access log、CDN 下载日志等等,这些完全没有固定格式,可能是二进制也可能是文本。主要用途类似于运营日志,但更加丰富和定制化。</p><p>程序日志记录是程序的运行情况,也就是平时我们通过日志框架打的 INFO、ERROR 这类日志。程序日志主要用途是检索定位运行问题,通常是写入 ES,但有时数量过大或者需要提取指标分析时,也会写入数据仓库。</p><h3 id="网易游戏-ETL-服务剖析"><a href="#网易游戏-ETL-服务剖析" class="headerlink" title="网易游戏 ETL 服务剖析"></a>网易游戏 ETL 服务剖析</h3><center><p><img src="/img/ffa-netease-games-etl/img4.data_pipeline_detail.jpeg" alt="图4. 网易游戏 ETL 服务剖析" title="图4. 网易游戏 ETL 服务剖析"></p></center><p>针对这些日志分类,我们具体提供了三类 ETL 入库的服务。首先是运营日志专用的 ETL,这会根据运营日志的模式进行定制化。然后是通用的面向文本日志的 EntryX ETL 服务,它会服务于运营日志以外的所有日志。最后是 EntryX 无法支持的特殊 ETL 需求,比如有加密或者需要进行特殊转换的数据,这种情况下我们就会针对性地开发 ad-hoc 作业来处理。</p><h2 id="二-运营日志专用-ETL"><a href="#二-运营日志专用-ETL" class="headerlink" title="二. 运营日志专用 ETL"></a>二. 运营日志专用 ETL</h2><h3 id="运营日志-ETL-发展历程"><a href="#运营日志-ETL-发展历程" class="headerlink" title="运营日志 ETL 发展历程"></a>运营日志 ETL 发展历程</h3><center><p><img src="/img/ffa-netease-games-etl/img5.operation_etl_history.jpeg" alt="图5. 运营日志 ETL 发展历程" title="图5. 运营日志 ETL 发展历程"></p></center><p>运营日志 ETL 服务有着一个比较久的历史。大概在 2013 年,网易游戏就建立了基于 Hadoop Streaming + Python 预处理/后处理的第一版离线 ETL 框架。这套框架是平稳运行了多年。</p><p>在 2017 年的时候,随着 Spark Streaming 的崭露头角,我们开发了基于 Spark Streaming 的第二个版本,相当于一个 POC,但因为微批调优困难且小文件多等问题没有上线应用。</p><p>时间来到 2018 年,当时 Flink 已经比较成熟,我们也决定将业务迁移到 Flink 上,所以我们很自然地开发了基于 Flink DataStream 的第三版运营日志 ETL 服务。这里面比较特殊的一点就是,因为长久以来我们业务方积累了很多 Python 的 ETL 脚本,然后新版最重要的一点就是要支持这些 Python UDF 的无缝迁移。</p><h3 id="运营日志-ETL-架构"><a href="#运营日志-ETL-架构" class="headerlink" title="运营日志 ETL 架构"></a>运营日志 ETL 架构</h3><p>接下来看下两个版本的架构对比。</p><center><p><img src="/img/ffa-netease-games-etl/img6.operation_etl_diff.jpeg" alt="图6. 运营日志 ETL 架构" title="图6. 运营日志 ETL 架构"></p></center><p>在早期 Hadoop Streaming 的版本里面,数据首先会被 dump 到 HDFS 上,然后 Hadoop Streaming 启动 Mapper 来读取数据并通过标准输入的方式传递给 Python 脚本。Python 脚本里面会分为三个模块:首先预处理 UDF,这里通常会进行基于字符串的替换,一般用作规范化数据,比如有些海外合作厂商的时间格式可能跟我们不同,那么就可以在这里进行统一。预处理完的数据会进入通用的解析/转换模块,这里我们会根据运营日志的格式来解析数据,并进行通用转换,比如滤掉测试服数据。通用模块之后,最后还有一个后处理模块进行针对字段的转换,比如常见的汇率转换。之后数据会通过标准输出返回给 Mapper,然后 Mapper 再将数据批量写到 Hive 目录中。</p><p>我们用 Flink 重构后,数据源就由 HDFS 改为直接对接 Kafka,而 IO 模块则用 Flink 的 Source/Sink Operator 来代替原本的 Mapper,然后中间通用模块可以直接重写为 Java,剩余的预处理和后处理则是我们需要支持 Python UDF 的地方。</p><h3 id="Python-UDF-实现"><a href="#Python-UDF-实现" class="headerlink" title="Python UDF 实现"></a>Python UDF 实现</h3><center><p><img src="/img/ffa-netease-games-etl/img7.operation_etl_python_udf.jpeg" alt="图7. Python UDF 实现" title="图7. Python UDF 实现"></p></center><p>在具体实现上,我们在 Flink ProcessFunction 之上加入了 Runner 层,Runner 层负责跨语言的执行。技术选型上是选了 Jython,而没有选择 Py4j,主要因为 Jython 可以直接在 JVM 里面去完成计算,不需要额外启动 Python 进程,这样开发和运维管理成本都比较低。而 Jython 带来的限制,比如不支持 pandas 等基于 c 的库,这些对于我们的 Python UDF 来说都是可接受的。</p><p>整个调用链是,ProcessFunction 在 TaskManager 被调用时会在 open 函数延迟初始化 Runner,这是因为 Jython 是不可序列化的。Runner 初始化时会负责资源准备,包括将依赖的模块加入 PYTHONPATH,然后根据配置反射调用 UDF 函数。</p><p>调用时,对于预处理 UDF Runner 会把字符串转化为 Jython 的 PyUnicode 类型,而对于后处理 UDF 则会把解析后的 Map 对象转为 Jython 的 PyDcitionary,分别作为两者的输入。UDF 可以调用其他模块进行计算,最终返回 PyObject,然后 Runner 再将其转换成 Java String 或者 Map,返回给 ProcessFunction 输出。</p><h3 id="运营日志-ETL-运行时"><a href="#运营日志-ETL-运行时" class="headerlink" title="运营日志 ETL 运行时"></a>运营日志 ETL 运行时</h3><center><p><img src="/img/ffa-netease-games-etl/img8.operation_etl_runtime.jpeg" alt="图8. 运营日志 ETL 运行时" title="图8. 运营日志 ETL 运行时"></p></center><p>刚刚是 UDF 模块的局部视图,我们再来看下整体的 ETL 作业视图。首先在我们提供了通用的 Flink jar,当我们生成并提交 ETL 作业到作业平台时,调度器会执行通用的 main 函数构建 Flink JobGraph。这时会从我们的配置中心,也就是 ConfigServer,拉取 ETL 配置。ETL 配置中包含使用到的 Python 模块,后端服务会扫描其中引用到的其他模块,把它们统一作为资源文件通过 YARN 分发功能上传到 HDFS 上。在 Flink JobManager 和 TaskManager 启动时,这些 Python 资源会被 YARN 自动同步到工作目录上备用。这就是整个作业初始化的过程。</p><p>然后因为 ETL 规则的小变更是很频繁的,比如新增一个字段或者变更一下过滤条件,如果我们每次变更都需要重启作业,那么作业重启带来的不可用时间会对我们的下游用户造成比较糟糕的体验。因此,我们对变更进行了分类,对于一些不影响 Flink JobGraph 的轻量级变更支持热更新。实现的方式是每个 TaskManager 启动一个热更新线程,定时轮询配置中心同步配置。</p><h2 id="三-EntryX-通用-ETL"><a href="#三-EntryX-通用-ETL" class="headerlink" title="三. EntryX 通用 ETL"></a>三. EntryX 通用 ETL</h2><p>接下来介绍我们的通用 ETL 服务 EntryX。这里的通用可以分为两层意义,首先是数据格式上的通用,支持非结构化到结构化的各种文本数据,其次是用户群体的通用,目标用户覆盖数据分析、数据开发等传统用户,和业务程序、策划这些数据背景较弱的用户。</p><h3 id="EntryX-基本概念"><a href="#EntryX-基本概念" class="headerlink" title="EntryX 基本概念"></a>EntryX 基本概念</h3><center><p><img src="/img/ffa-netease-games-etl/img9.entryx_requirement.jpeg" alt="图9. EntryX 基本概念" title="图9. EntryX 基本概念"></p></center><p>先介绍 EntryX 的三个基本概念,Source、StreamingTable 和 Sink。用户需要分别配置这个三个模块,系统会根据这些自动生成 ETL 作业。</p><p>Source 是 ETL 作业的输入源,通常是从业务端采集而来的原始日志 topic,或者是经过分发过滤后的 topic。这些 topic 可能只包含一种日志,但更多情况下会包含多种异构日志。</p><p>接下来 StreamingTable,一个比较通俗的名称就是流表。流表定义了 ETL 管道的主要元数据,包括如何转换数据,还有根据转换好的数据定义的流表 schema,将数据 schema 化。流表 schema 是最为关键的概念,它相当于 Table DDL,主要包括字段名、字段数据类型、字段约束和表属性等。为了更方便对接上下游,流表 schema 使用的是自研的 SQL-Like 的类型系统,里面会支持我们一些拓展的数据类型,比如 JSON 类型。</p><p>最后 Sink 负责流表到目标存储的物理表的映射,比如映射到目标 Hive 表。这里主要需要 schema 的映射关系,比如流表哪个字段映射到目标表哪个字段,流表哪个字段用作目标 Hive 表分区字段。在底层,系统会自动根据 schema 映射关系来提取字段,并将数据转换为目标表的存储格式,加载到目标表。</p><h3 id="EntryX-ETL-管道"><a href="#EntryX-ETL-管道" class="headerlink" title="EntryX ETL 管道"></a>EntryX ETL 管道</h3><center><p><img src="/img/ffa-netease-games-etl/img10.entryx_design.jpeg" alt="图10. EntryX ETL 管道" title="图10. EntryX ETL 管道"></p></center><p>再来看下 EntryX ETL 管道的具体实现。蓝色部分是外部存储系统,而绿色部分则是 EnrtyX 的内部模块。</p><p>数据首先从对接采集的原始数据 Topic 流入,经过 Source 摄入到 Filter。Filter 负责根据关键词过滤数据,通常来说我们要求过滤完的数据是有相同 schema 的。经过这两步数据完成 Extract,来到 Transform 阶段。</p><p>Transform 第一步是解析数据,也就是这里的 Parser。Parser 支持 JSON/Regex/Csv 三种解析,基本可以覆盖所有案例。第二步是对数据进行转换,这是由 Extender 负责的。Extender 通过内置函数或 UDF 计算衍生字段,最常见的是将 JSON 对象拉平展开,提取出内嵌字段。最后是 Formatter,Formatter 会根据之前用户定义的字段逻辑类型,将字段的值转为对应的物理类型。比如一个逻辑类型为 BIGINT 的字段,我们在这里会统一转为 Java long 的物理类型。 </p><p>数据完成 Transform 之后来到最后的 Load 阶段。Load 第一步是决定数据应该加载到哪个表。Splitter 模块会根据每个表的入库条件(也就是一个表达式)来分流数据,然后再到第二步的 Loader 来负责将数据写到具体的外部存储系统。目前我们支持 Hive/Kafka 两种存储,Hive 支持 Text/Parquet/JSON 三种格式,而 Kafka 支持 JSON 和 Avro 两种格式。</p><h3 id="实时离线统一-Schema"><a href="#实时离线统一-Schema" class="headerlink" title="实时离线统一 Schema"></a>实时离线统一 Schema</h3><center><p><img src="/img/ffa-netease-games-etl/img11.entryx_schema.jpeg" alt="图11. 实时离线统一 Schema" title="图11. 实时离线统一 Schema"></p></center><p>在 Entryx 的设计里数据可以被写入实时和离线两个数据仓库,也就是说同一份数据,但在不同的存储系统中以不同格式表示。从 Flink SQL 的角度来说是 schema 部分相同,但 connector 和 format 不同的两个表。而 schema 部分经常会随业务变更,而 connector 和 format(也就是存储系统和存储格式)是相对稳定的。那么一个很自然的想法就是,能不能将 schema 部分提取出来独立维护?实际上,这个抽象的 schema 已经存在了,就是我们在 ETL 提取的流表 schema。</p><p>在 EntryX 里面,流表 schema 是与序列化器、存储系统无关的 schema,作为 <code>Single Source of Truth</code>。基于流表 schema,加上存储系统信息和存储格式信息,我们就可以衍生出具体的物理表的 DDL。目前我们主要是支持 Hive/Kafka,如果之后要拓展至支持 ES/HBase 表也是非常方便。</p><h3 id="实时数据仓库集成"><a href="#实时数据仓库集成" class="headerlink" title="实时数据仓库集成"></a>实时数据仓库集成</h3><center><p><img src="/img/ffa-netease-games-etl/img12.entryx_realtime_datawarehouse_integration.jpeg" alt="图12. 实时数据仓库集成" title="图12. 实时数据仓库集成"></p></center><p>EntryX 一个重要的定位是作为实时仓库的统一入口。刚刚其实已经多次提到 Kafka 表,但还没有说实时数仓是怎么做的。实时数仓的常见问题是 Kafka 并没有原生支持 schema 元数据的持久化。目前社区的主流解决方案是基于 Hive MetaStore 来保存 Kafka 表的元数据,并复用 HiveCatalog 来直接对接到 Flink SQL。</p><p>但这对于我们来说使用 Hive MetaStore 主要有几个问题:一是在实时作业里引入 Hive 依赖并与 Hive 耦合,这是很重的依赖,导致定义的表很难被其他组件复用,包括 Flink DataStream 用户;二是我们已经有 Kafka SaaS 平台 Avatar 来管理物理 schema,比如 Avro schema,如果再引入 Hive MetaStore 会导致元数据的割裂。因此,我们是拓展了 Avatar 平台的 schema 注册中心,同时支持逻辑 schema 和物理 schema。</p><p>那么实时数仓和 EntryX 的集成关系是:首先我们有 EntryX 的流表 schema,在新建 Sink 的时候调用 Avatar 的 schema 接口,根据映射关系生成逻辑 schema,而 Avatar 再根据 Flink SQL 类型与物理类型的映射关系生成 topic 的物理 schema。</p><p>与 Avatar schema 注册中心配套的还有我们自研的 KafkaCatalog,它负责读取 topic 的逻辑和物理 schema 来生成 Flink SQL 的 TableSource 或 TableSink。而对于一些 Flink SQL 以外的用户,比如 Flink DataStream API 的用户,他们也可以直接读取物理 schema 来享受到数据仓库的便利。</p><h3 id="EntryX-运行时"><a href="#EntryX-运行时" class="headerlink" title="EntryX 运行时"></a>EntryX 运行时</h3><p>和运营日志 ETL 类似,在 EntryX 运行时,系统会基于通用的 jar 和配置生成 Flink 作业,但这里有两种情况需要特别处理。</p><center><p><img src="/img/ffa-netease-games-etl/img13.entryx_runtime.jpeg" alt="图13. EntryX 运行时" title="图13. EntryX 运行时"></p></center><p>首先是一个 Kafka topic 往往有几十甚至上千种日志,那么对应其实有也几十甚至上千的流表,如果每个流表都单独运行在一个作业里,那么一个 topic 会可能会被读上千遍,这是非常大的浪费。因此,在作业运行时提供一个优化策略,可以将同个 source 的不同流表合并到一个作业里跑。比如图中,某个手游上传了 3 种日志到 Kafka,用户分别配置了玩家注册、玩家登录、领取礼包三个流表,那么我们可以这三个流表合并起来到一个作业,共享同一个 Kafka Source。</p><p>另外的一个优化是,一般情况下我们可以按照之前“提取转换一次,加载一次”的思路来将数据同时写到 Hive 和 Kafka,但是由于 Hive 或者说 HDFS 毕竟是离线系统,实时性比较差,写入在一些负载比较高的 HDFS 老集群经常会出现反压,同时阻塞上游,导致 Kafka 的写入也受到影响。在这种情况下,我们通常要分离加载到实时和离线的 ETL 管道,具体会取决于业务的 SLA 还有 HDFS 的性能。</p><h2 id="四-调优实践"><a href="#四-调优实践" class="headerlink" title="四.调优实践"></a>四.调优实践</h2><p>接下来给大家分享下我们在 ETL 建设中的调优实践经验。</p><h3 id="HDFS-写入调优"><a href="#HDFS-写入调优" class="headerlink" title="HDFS 写入调优"></a>HDFS 写入调优</h3><center><p><img src="/img/ffa-netease-games-etl/img14.hdfs_write_tuning.jpeg" alt="图14. HDFS 写入调优" title="图14. HDFS 写入调优"></p></center><p>首先是 HDFS 写入的调优。流式写入 HDFS 场景中老生常谈的一个问题便是小文件过多。通常来说小文件和实时性是鱼与熊掌不可兼得。如果要延迟低,那么我们需要频繁地滚动文件来提交数据,必然导致小文件过多。</p><p>小文件过多主要造成两个问题:一从 HDFS 集群管理角度看,小文件会占用大量的文件数和 block 数,浪费 NameNode 内存;二是从用户角度看,读写效率都会降低,因为写的时候要更频繁地调用 RPC 和 flush 数据,造成更多的阻塞,有时甚至造成 checkpoint 超时,而读时则需要打开更多的文件才能读完数据。</p><h3 id="HDFS-写入调优-数据流预分区"><a href="#HDFS-写入调优-数据流预分区" class="headerlink" title="HDFS 写入调优 - 数据流预分区"></a>HDFS 写入调优 - 数据流预分区</h3><p>我们在优化小文件问题时做的一点调优是对数据流先做一遍预分区,具体来说,便是在 Flink 作业内部先基于目标 Hive 表进行一次 keyby 分区,让同一个表的数据尽量集中在少数的几个 subtask 上。</p><center><p><img src="/img/ffa-netease-games-etl/img15.hdfs_prepartitioning.jpeg" alt="图15. HDFS 写入调优 - 数据流预分区" title="图15. 数据流预分区"></p></center><p>举个例子,假设 Flink 作业并行度为 n,而目标 Hive 分区数为 m 个。因为每个 subtask 都有可能读到任意分区的数据,在默认的各 subtask 完全并行的情况下,每个 subtask 都会写所有分区,造成总体的写入文件数是 n * m。假设 n 是 100,m 是 1000,按 10 分钟滚一次文件算,每天会造成 14,400,000 个文件,这对于很多老集群来说是非常大的压力。</p><p>如果经过数据流分区的优化之后,我们就可以限制住 Flink 并行度带来的增长。比如我们 keyby hive 表字段,并加入范围为 0-s 整数的盐来避免数据倾斜,那么分区最多会被 s 个 subtask 读写。假设 s 是 5,比起原先 n 是 100,那么我们就将原本的文件数降低为原来 20 分之一。</p><h3 id="基于-OperatorState-的-SLA-统计"><a href="#基于-OperatorState-的-SLA-统计" class="headerlink" title="基于 OperatorState 的 SLA 统计"></a>基于 OperatorState 的 SLA 统计</h3><center><p><img src="/img/ffa-netease-games-etl/img16.sla_utils.jpeg" alt="图16. 基于 OperatorState 的 SLA 统计" title="图16. 基于 OperatorState 的 SLA 统计"></p></center><p>第二个我想分享的是我们的 SLA 统计工具。背景是我们的用户经常会通过 Web UI 来进行调试和问题的排查,比如不同 subtask 的输入输出数目,但这些 metric 会因为作业重启或者 failover 而重置,因此我们开发了基于 OperatorState 的 SLA-Utils 工具来统计数据的输入和分类输出。这个工具设计得非常轻量级,可以很容易集成到我们自己的服务或者用户的作业里面。</p><p>在 SLA-Utils 里面,我们支持了三种 metric。首先是标准的 metric,有 recordsIn/recordsOut/recordsDropped/recordsErrored,分别对应输入记录数/正常输出记录数/被过滤掉的记录数/处理异常的记录数。通常来说 recordsIn 就等于后面三者的总和。第二种用户可以自定义的 metric,通常可以用于记录更详细的原因,比如是 recordsEventTimeDropped 代表数据是因为 event time 被过滤的。</p><p>那么上述两种 metric 静态的,也就是说 metric key 在作业运行前就要确定,此外 SLA-Utils 还支持在运行时动态注册的 TTL metric。这种 metric 通常有动态生成的日期作为前缀,在经过 TTL 的时间之后被自动清理。TTL metric 主要可以用于做天级别时间窗口的统计。这里比较特别的一点是,因为 OperatorState 是不支持 TTL 的,SLA-Utils 是在每次进行 checkpoint 快照的时候进行一次过滤,剔除掉过期的 metric,以实现 TTL 的效果。</p><p>那么在 State 保存了 SLA 指标之后要做的就是暴露给用户。我们目前的做法是通过 Accumulater 的方式来暴露,优点是 Web UI 有支持,开箱即用,同时 Flink 可以自动合并不同的 subtask 的 metric。缺点在于没有办法利用 metric reporter 来 push 到监控系统,同时因为 Acuumulater 是不能在运行时动态注销的,所以使用 TTL metric 会有内存泄漏的风险。因此,在未来我们也考虑支持 metric group 来避免这些问题。</p><h3 id="数据容错及恢复"><a href="#数据容错及恢复" class="headerlink" title="数据容错及恢复"></a>数据容错及恢复</h3><p>最后再分享下我们在数据容错和恢复上的实践。</p><center><p><img src="/img/ffa-netease-games-etl/img17.error_tolerrance.jpeg" alt="图17. 数据容错" title="图17. 数据容错"></p></center><p>以很多最佳实践相似,我们用 SideOutput 来收集 ETL 各环节中出错的数据,汇总到一个统一的错误流。错误记录中包含我们预设的错误码、原始输入数据以及错误类和错误信息。一般情况下,错误数据会被分类写入 HDFS,用户通过监控 HDFS 目录可以得知数据是否正常。</p><center><p><img src="/img/ffa-netease-games-etl/img18.error_recovery.jpeg" alt="图18. 数据恢复" title="图18. 数据恢复"></p></center><p>那么存储好异常数据后,下一步就是要恢复数据。这通常有两种情况。一是数据格式异常,比如日志被截断导致不完整或者时间戳不符合约定格式,这种情况下我们一般通过离线批作业来修复数据,重新回填到原有的数据管道。二是 ETL 管道异常,比如数据实际的 schema 有变更但流表配置没有更新,可能会导致某个字段都是空值,这时我们的处理办法是:首先更新线上的流表配置为最新,保证不再产生更多异常数据,这时 hive 里面仍有部分分区是异常的。然后,我们发布一个独立的补数作业来专门修复异常的数据,输出的数据会写到一个临时的目录,并在 hive metastore 上切换 partition 分区的 location 来替换掉原来的异常目录。因此这样的一个补数流程对离线查询的用户来说是透明的。最后我们再在合适的时间替换掉异常分区的数据并恢复 location。</p><h2 id="五-未来规划"><a href="#五-未来规划" class="headerlink" title="五.未来规划"></a>五.未来规划</h2><p>最后介绍下我们的未来规划。</p><center><p><img src="/img/ffa-netease-games-etl/img19.further_plan.jpeg" alt="图19. 未来规划" title="图19. 未来规划"></p></center><p>第一个是数据湖的支持。目前我们的日志绝大多数都是 append 类型,不过随着 CDC 和 Flink SQL 业务的完善,我们可能会有更多的 update、delete 的需求,因此数据湖是一个很好的选择。</p><p>第二个会提供更加丰富的附加功能,比如实时的数据去重和小文件的自动合并。这两个都是对业务方非常实用的功能。</p><p>最后是一个支持 PyFlink。目前我们的 Python 支持只覆盖到数据集成阶段,后续数据仓库的 Python 支持我们是希望通过 PyFlink 来实现。</p>]]></content>
<summary type="html">
<p>文本由笔者在 Flink Forward Asia 2020 上的分享《网易游戏基于 Flink 的流式 ETL 建设》整理而成。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>详解 Flink 实时应用的确定性</title>
<link href="https://link3280.github.io/2020/06/12/%E8%AF%A6%E8%A7%A3-Flink-%E5%AE%9E%E6%97%B6%E5%BA%94%E7%94%A8%E7%9A%84%E7%A1%AE%E5%AE%9A%E6%80%A7/"/>
<id>https://link3280.github.io/2020/06/12/详解-Flink-实时应用的确定性/</id>
<published>2020-06-12T14:09:03.000Z</published>
<updated>2020-06-13T03:25:27.589Z</updated>
<content type="html"><![CDATA[<p>确定性(Determinism)是计算机科学中十分重要的特性,确定性的算法保证对于给定相同的输入总是产生相同的输出。在分布式实时计算领域,确定性是业界一直难以解决的课题,由此导致用离线计算修正实时计算结果的 Lambda 架构成为大数据领域过去近十年的主流架构。而在最近几年随着 Google The Dataflow Model 的提出,实时计算和离线计算的关系逐渐清晰,在实时计算中提供与离线计算一致的确定性成为可能。本文将基于流行实时计算引擎 Apache Flink,梳理构建一个确定性的实时应用要满足什么条件。</p><a id="more"></a><h1 id="确定性与准确性"><a href="#确定性与准确性" class="headerlink" title="确定性与准确性"></a>确定性与准确性</h1><p>比起确定性,准确性(Accuracy)可能是我们接触更多的近义词,大多数场景下两者可以混用,但其实它们稍有不同: 准确的东西一定是确定的,但确定性的东西未必百分百准确。在大数据领域,不少算法可以根据需求调整成本和准确性的平衡,比如 HyperLogLog 去重统计算法给出的结果是有一定误差的(因此不是准确的),但却同时是确定性的(重算可以得到相同结果)。</p><p>要分区确定性和准确性的缘故是,准确性与具体的业务逻辑紧密耦合难以评估,而确定性则是通用的需求(除去少数场景用户故意使用非确定性的算法)。当一个 Flink 实时应用提供确定性,意味着它在异常场景的自动重试或者手动重流数据的情况下,都能像离线作业一般产出相同的结果,这将很大程度上提高用户的信任度。</p><h1 id="影响-Flink-应用确定性的因素"><a href="#影响-Flink-应用确定性的因素" class="headerlink" title="影响 Flink 应用确定性的因素"></a>影响 Flink 应用确定性的因素</h1><h2 id="投递语义"><a href="#投递语义" class="headerlink" title="投递语义"></a>投递语义</h2><p>常见的投递语义有 <code>At-Most-Once</code>、<code>At-Least-Once</code> 和 <code>Exactly-Once</code> 三种。严格来说只有 <code>Exactly-Once</code> 满足确定性的要求,但如果整个业务逻辑是幂等的, 基于<code>At-Least-Once</code> 也可以达到结果的确定性。</p><p>实时计算的 <code>Exactly-Once</code> 通常指端到端的 <code>Exactly-Once</code>,保证输出到下游系统的数据和上游的数据是一致的,没有重复计算或者数据丢失。要达到这点,需要分别实现读取数据源(Source 端)的 <code>Exactly-Once</code>、计算的 <code>Exactly-Once</code> 和输出到下游系统(Sink 端)的 <code>Exactly-Once</code>。</p><p>其中前面两个都比较好保证,因为 Flink 应用出现异常会自动恢复至最近一个成功 checkpoint,Pull-Based 的 Source 的状态和 Flink 内部计算的状态都会自动回滚到快照时间点,而问题在于 Push-Based 的 Sink 端。Sink 端是否能顺利回滚依赖于外部系统的特性,通常来说需要外部系统支持事务,然而不少大数据组件对事务的支持并不是很好,即使是实时计算最常用的 Kafka 也直到 2017 年的 0.11 版本才支持事务,更多的组件需要依赖各种 trick 来达到某种场景下的 <code>Exactly-Once</code>。</p><p>总体来说这些 trick 可以分为两大类:</p><ul><li>依赖写操作的幂等性。比如 HBase 等 KV 存储虽然没有提供跨行事务,但可以通过幂等写操作配合基于主键的 Upsert 操作达到 <code>Exactly-Once</code>。不过由于 Upsert 不能表达 Delete 操作,这种模式不适合有 Delete 的业务场景。</li><li>预写日志(WAL,Write-Ahead-Log)。预写日志是广泛应用于事物机制的技术,包括 MySQL、PostgreSQL 等成熟关系型数据库的事物都基于预写日志。预写日志的基本原理先将变更写入缓存区,等事务提交的时候再一次全部应用。比如 HDFS/S3 等文件系统本身并不提供事务,因此实现预写日志的重担落到了它们的用户(比如 Flink)身上。通过先写临时的文件/对象,等 Flink Checkpoint 成功后再提交,Flink 的 FileSystem Connector 实现了 <code>Exactly-Once</code>。然而,预写日志只能保证事务的原子性和持久性,不能保证一致性和隔离性。为此 FileSystem Connector 通过将预写日志设为隐藏文件的方式提供了隔离性,至于一致性(比如临时文件的清理)则无法保证。</li></ul><p>为了保证 Flink 应用的确定性,在选用官方 Connector,特别是 Sink Connector 时,用户应该留意官方文档关于 Connector 投递语义的说明[3]。此外,在实现定制化的 Sink Connector 时也需要明确达到何种投递语义,可以参考利用外部系统的事务、写操作的幂等性或预写日志三种方式达到 <code>Exactly-Once</code> 语义。</p><h2 id="函数副作用"><a href="#函数副作用" class="headerlink" title="函数副作用"></a>函数副作用</h2><p>函数副作用是指用户函数对外界造成了计算框架意料之外的影响。比如典型的是在一个 Map 函数里将中间结果写到数据库,如果 Flink 作业异常自动重启,那么数据可能被写两遍,导致不确定性。对于这种情况,Flink 提供了基于 Checkpoint 的两阶段提交的钩子(<code>CheckpointedFunction</code> 和 <code>CheckpointListener</code>),用户可以用它来实现事务,以消除副作用的不确定性。另外还有一种常见的情况是,用户使用本地文件来保存临时数据,这些数据在 Task 重新调度的时候很可能丢失。其他的场景或许还有很多,总而言之,如果需要在用户函数里改变外部系统的状态,请确保 Flink 对这些操作是知情的(比如用 State API 记录状态,设置 Checkpoint 钩子)。</p><h2 id="Processing-Time"><a href="#Processing-Time" class="headerlink" title="Processing Time"></a>Processing Time</h2><p>在算法中引入当前时间作为参数是常见的操作,但在实时计算中引入当前系统时间,即 Processing Time,是造成不确定性的最常见也最难避免的原因。对 Processing 的引用可以是很明显、有完善文档标注的,比如 Flink 的 Time Characteristic,但也可能是完全出乎用户意料的,比如来源于缓存等常用的技术。为此,笔者总结了几类常见的 Processing Time 引用:</p><ul><li><p>Flink 提供的 Time Characteristic。Time Characteristic 会影响所有使用与时间相关的算子,比如 Processing Time 会让窗口聚合使用当前系统时间来分配窗口和触发计算,造成不确定性。另外,Processing Timer 也有类似的影响。</p></li><li><p>直接在函数里访问外部存储。因为这种访问是基于外部存储某个 Processing Time 时间点的状态,这个状态很可能在下次访问时就发生了变化,导致不确定性。要获得确定性的结果,比起简单查询外部存储的某个时间点的状态,我们应该获取它状态变更的历史,然后根据当前 Event Time 去查询对应的状态。这也是 Flink SQL 中 Temporary Table Join 的实现原理[1]。</p></li><li><p>对外部数据的缓存。在计算流量很大的数据时,很多情况下用户会选择用缓存来减轻外部存储的负载,但这可能会造成查询结果的不一致,而且这种不一致是不确定的。无论是使用超时阈值、LRU(Least Recently Used)等直接和系统时间相关的缓存剔除策略,还是 FIFO(First In First Out)、LFU(Less Frequently Used)等没有直接关联时间的剔除策略,访问缓存得到的结果通常和消息的到达顺序相关,而在上游经过 shuffle 的算子里面这是难以保证的(没有 shuffle 的 Embarrassingly Parallel 作业是例外)。</p></li><li><p>Flink 的 StateTTL。StateTTL 是 Flink 内置的根据时间自动清理 State 的机制,而这里的时间目前只提供 Processing Time,无论 Flink 本身使用的是 Processing Time 还是 Event Time 作为 Time Characteristic。BTW,StateTTL 对 Event Time 的支持可以关注 FLINK-12005[2]。</p></li></ul><p>综合来讲,要完全避免 Processing Time 造成的影响是非常困难的,不过轻微的不确定性对于业务来说通常是可以接受的,我们要做的更多是提前预料到可能的影响,保证不确定性在可控范围内。</p><h2 id="Watermark"><a href="#Watermark" class="headerlink" title="Watermark"></a>Watermark</h2><p>Watermark 作为计算 Event Time 的机制,其中一个很重要的用途是决定实时计算何时要输出计算结果,类似文件结束标志符(EOF)在离线批计算中达到的效果。然而,在输出结果之后可能还会有迟到的数据到达,这称为窗口完整性问题(Window Completeness)。</p><p>窗口完整性问题无法避免,应对办法是要么更新计算结果,要么丢弃这部分数据。因为离线场景延迟容忍度较大,离线作业可以推迟一定时间开始,尽可能地将延迟数据纳入计算。而实时场景对延迟有比较高的要求,因此一般是输出结果后让状态保存一段时间,在这段时间内根据迟到数据持续更新结果(即 Allowed Lateness),此后将数据丢弃。因为定位,实时计算天然可能出现更多被丢弃的迟到数据,这将和 Watermark 的生成算法紧密相关。</p><p>虽然 Watermark 的生成是流式的,但 Watermark 的下发是断点式的。Flink 的 Watermark 下发策略有 Periodic 和 Punctuated 两种,前者基于 Processing Time 定时触发,后者根据数据流中的特殊消息触发。</p><p><center><img src="/img/flink-determinism/img1.periodic-watermark.png" alt="图1. Periodic Watermark 正常状态与重放追数据状态" title="图1. Periodic Watermark 正常状态与重放追数据状态"></center></p><p>基于 Processing Time 的 Periodic Watermark 具有不确定。在平时流量平稳的时候 Watermark 的提升可能是阶梯式的(见图1(a));然而在重放历史数据的情况下,相同长度的系统时间内处理的数据量可能会大很多(见图1(b)),并且伴有 Event Time 倾斜(即有的 Source 的 Event Time 明显比其他要快或慢,导致取最小值的总体 Watermark 被慢 Watermark 拖慢),导致本来丢弃的迟到数据,现在变为 Allowed Lateness 之内的数据(见图1中红色元素)。</p><p><center><img src="/img/flink-determinism/img2.punctuated-watermark.png" alt="图2. Punctuated Watermark 正常状态与重放追数据状态" title="图2. Punctuated Watermark 正常状态与重放追数据状态"></center></p><p>相比之下 Punctuated Watermark 更为稳定,无论在正常情况(见图2(a))还是在重放数据的情况(见图2(b))下,下发的 Watermark 都是一致的,不过依然有 Event Time 倾斜的风险。对于这点,Flink 社区起草了 FLIP-27 来处理[4]。基本原理是 Source 节点会选择性地消费或阻塞某个 partition/shard,让总体的 Event Time 保持接近。</p><p>除了 Watermark 的下发有不确定之外,还有个问题是现在 Watermark 并没有被纳入 Checkpoint 快照中。这意味着在作业从 Checkpoint 恢复之后,Watermark 会重新开始算,导致 Watermark 的不确定。这个问题在 FLINK-5601[5] 有记录,但目前只体现了 Window 算子的 Watermark,而在 StateTTL 支持 Event Time 后,或许每个算子都要记录自己的 Watermark。</p><p>综上所述,Watermark 目前是很难做到非常确定的,但因为 Watermark 的不确定性是通过丢弃迟到数据导致计算结果的不确定性的,只要没有丢弃迟到数据,无论中间 Watermark 的变化如何,最终的结果都是相同的。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>确定性不足是阻碍实时计算在关键业务应用的主要因素,不过当前业界已经具备了解决问题的理论基础,剩下的更多是计算框架后续迭代和工程实践上的问题。就目前开发 Flink 实时应用而言,需要注意投递语义、函数副作用、Processing Time 和 Watermark 这几点造成的不确定性。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://flink.apache.org/2019/05/14/temporal-tables.html" target="_blank" rel="external">Flux capacitor, huh? Temporal Tables and Joins in Streaming SQL</a></li><li><a href="https://issues.apache.org/jira/browse/FLINK-12005" target="_blank" rel="external">[FLINK-12005][State TTL] Event time support</a></li><li><a href="https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/connectors/guarantees.html" target="_blank" rel="external">Fault Tolerance Guarantees of Data Sources and Sinks</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-27%3A+Refactor+Source+Interface" target="_blank" rel="external">FLIP-27: Refactor Source Interface</a></li><li><a href="https://issues.apache.org/jira/browse/FLINK-5601" target="_blank" rel="external">[FLINK-5601] Window operator does not checkpoint watermarks</a></li></ol>]]></content>
<summary type="html">
<p>确定性(Determinism)是计算机科学中十分重要的特性,确定性的算法保证对于给定相同的输入总是产生相同的输出。在分布式实时计算领域,确定性是业界一直难以解决的课题,由此导致用离线计算修正实时计算结果的 Lambda 架构成为大数据领域过去近十年的主流架构。而在最近几年随着 Google The Dataflow Model 的提出,实时计算和离线计算的关系逐渐清晰,在实时计算中提供与离线计算一致的确定性成为可能。本文将基于流行实时计算引擎 Apache Flink,梳理构建一个确定性的实时应用要满足什么条件。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>Flink 1.11 Unaligned Checkpoint 解析</title>
<link href="https://link3280.github.io/2020/06/08/Flink-1-11-Unaligned-Checkpoint-%E8%A7%A3%E6%9E%90/"/>
<id>https://link3280.github.io/2020/06/08/Flink-1-11-Unaligned-Checkpoint-解析/</id>
<published>2020-06-07T16:21:56.000Z</published>
<updated>2020-06-13T02:06:33.590Z</updated>
<content type="html"><![CDATA[<p>作为 Flink 最基础也是最关键的容错机制,Checkpoint 快照机制很好地保证了 Flink 应用从异常状态恢复后的数据准确性。同时 Checkpoint 相关的 metrics 也是诊断 Flink 应用健康状态最为重要的指标,成功且耗时较短的 Checkpoint 表明作业运行状况良好,没有异常或反压。然而,由于 Checkpoint 与反压的耦合,反压反过来也会作用于 Checkpoint,导致 Checkpoint 的种种问题。针对于此,Flink 在 1.11 引入 Unaligned Checkpint 来解耦 Checkpoint 机制与反压机制,优化高反压情况下的 Checkpoint 表现。</p><a id="more"></a><h1 id="当前-Checkpoint-机制简述"><a href="#当前-Checkpoint-机制简述" class="headerlink" title="当前 Checkpoint 机制简述"></a>当前 Checkpoint 机制简述</h1><p>相信不少读者对 Flink Checkpoint 基于 Chandy-Lamport 算法的分布式快照已经比较熟悉,该节简单回顾下算法的基础逻辑,熟悉算法的读者可放心跳过。</p><p>Chandy-Lamport 算法将分布式系统抽象成 DAG(暂时不考虑有闭环的图),节点表示进程,边表示两个进程间通信的管道。分布式快照的目的是记录下整个系统的状态,即可以分为节点的状态(进程的状态)和边的状态(信道的状态,即传输中的数据)。因为系统状态是由输入的消息序列驱动变化的,我们可以将输入的消息序列分为多个较短的子序列,图的每个节点或边先后处理完某个子序列后,都会进入同一个稳定的全局统状态。利用这个特性,系统的进程和信道在子序列的边界点分别进行本地快照,即使各部分的快照时间点不同,最终也可以组合成一个有意义的全局快照。</p><center><p><img src="/img/flink-unaligned-checkpoint/img1.checkpoint-barrier.png" alt="图1. Checkpoint Barrier" title="图1. Checkpoint Barrier"></p></center><p>从实现上看,Flink 通过在 DAG 数据源定时向数据流注入名为 Barrier 的特殊元素,将连续的数据流切分为多个有限序列,对应多个 Checkpoint 周期。每当接收到 Barrier,算子进行本地的 Checkpoint 快照,并在完成后异步上传本地快照,同时将 Barrier 以广播方式发送至下游。当某个 Checkpoint 的所有 Barrier 到达 DAG 末端且所有算子完成快照,则标志着全局快照的成功。</p><center><p><img src="/img/flink-unaligned-checkpoint/img2.barrier-alignment.png" alt="图2. Barrier Alignment" title="图2. Barrier Alignment"></p></center><p>在有多个输入 Channel 的情况下,为了数据准确性,算子会等待所有流的 Barrier 都到达之后才会开始本地的快照,这种机制被称为 Barrier 对齐。在对齐的过程中,算子只会继续处理的来自未出现 Barrier Channel 的数据,而其余 Channel 的数据会被写入输入队列,直至在队列满后被阻塞。当所有 Barrier 到达后,算子进行本地快照,输出 Barrier 到下游并恢复正常处理。</p><p>比起其他分布式快照,该算法的优势在于辅以 Copy-On-Write 技术的情况下不需要 “Stop The World” 影响应用吞吐量,同时基本不用持久化处理中的数据,只用保存进程的状态信息,大大减小了快照的大小。</p><h1 id="Checkpoint-与反压的耦合"><a href="#Checkpoint-与反压的耦合" class="headerlink" title="Checkpoint 与反压的耦合"></a>Checkpoint 与反压的耦合</h1><p>目前的 Checkpoint 算法在大多数情况下运行良好,然而当作业出现反压时,阻塞式的 Barrier 对齐反而会加剧作业的反压,甚至导致作业的不稳定。</p><p>首先, Chandy-Lamport 分布式快照的结束依赖于 Marker 的流动,而反压则会限制 Marker 的流动,导致快照的完成时间变长甚至超时。无论是哪种情况,都会导致 Checkpoint 的时间点落后于实际数据流较多。这时作业的计算进度是没有被持久化的,处于一个比较脆弱的状态,如果作业出于异常被动重启或者被用户主动重启,作业会回滚丢失一定的进度。如果 Checkpoint 连续超时且没有很好的监控,回滚丢失的进度可能高达一天以上,对于实时业务这通常是不可接受的。更糟糕的是,回滚后的作业落后的 Lag 更大,通常带来更大的反压,形成一个恶性循环。</p><p>其次,Barrier 对齐本身可能成为一个反压的源头,影响上游算子的效率,而这在某些情况下是不必要的。比如典型的情况是一个的作业读取多个 Source,分别进行不同的聚合计算,然后将计算完的结果分别写入不同的 Sink。通常来说,这些不同的 Sink 会复用公共的算子以减少重复计算,但并不希望不同 Source 间相互影响。</p><center><p><img src="/img/flink-unaligned-checkpoint/img3.barrier-alignment-case.png" alt="图3. Barrier Alignment 阻塞上游 Task" title="图3. Barrier Alignment 阻塞上游 Task"></p></center><p>假设一个作业要分别统计 A 和 B 两个业务线的以天为粒度指标,同时还需要统计所有业务线以周为单位的指标,拓扑如上图所示。如果 B 业务线某天的业务量突涨,使得 Checkpoint Barrier 有延迟,那么会导致公用的 Window Aggregate 进行 Barrier 对齐,进而阻塞业务 A 的 FlatMap,最终令业务 A 的计算也出现延迟。</p><p>当然这种情况可以通过拆分作业等方式优化,但难免引入更多开发维护成本,而且更重要的是这本来就符合 Flink 用户常规的开发思路,应该在框架内尽量减小出现用户意料之外的行为的可能性。</p><h1 id="Unaligned-Checkpoint"><a href="#Unaligned-Checkpoint" class="headerlink" title="Unaligned Checkpoint"></a>Unaligned Checkpoint</h1><p>为了解决这个问题,Flink 在 1.11 版本引入了 Unaligned Checkpoint 的特性。要理解 Unaligned Checkpoint 的原理,首先需要了解 Chandy-Lamport 论文中对于 Marker 处理规则的描述:</p><center><p><img src="/img/flink-unaligned-checkpoint/img4.chandy-lamport-marker-handling.png" alt="图4. Chandy-Lamport Marker 处理" title="图4. Chandy-Lamport Marker 处理"></p></center><p>其中关键是 <code>if q has not recorded its state</code>,也就是接收到 Marker 时算子是否已经进行过本地快照。一直以来 Flink 的 Aligned Checkpoint 通过 Barrier 对齐,将本地快照延迟至所有 Barrier 到达,因而这个条件是永真的,从而巧妙地避免了对算子输入队列的状态进行快照,但代价是比较不可控的 Checkpoint 时长和吞吐量的降低。实际上这和 Chandy-Lamport 算法是有一定出入的。</p><p>举个例子,假设我们对两个数据流进行 equal-join,输出匹配上的元素。按照 Flink Aligned Checkpoint 的方式,系统的状态变化如下(图中不同颜色的元素代表属于不同的 Checkpoint 周期):</p><center><p><img src="/img/flink-unaligned-checkpoint/img5.aligned-checkpoint-status.png" alt="图5. Aligned Checkpoint 状态变化" title="图5. Aligned Checkpoint 状态变化"></p></center><ul><li>图 a: 输入 Channel 1 存在 3 个元素,其中 <code>2</code> 在 Barrier 前面;Channel 2 存在 4 个元素,其中 <code>2</code>、<code>9</code>、<code>7</code> 在 Barrier 前面。</li><li>图 b: 算子分别读取 Channel 一个元素,输出 <code>2</code>。随后接收到 Channel 1 的 Barrier,停止处理 Channel 1 后续的数据,只处理 Channel 2 的数据。</li><li>图 c: 算子再消费 2 个自 Channel 2 的元素,接收到 Barrier,开始本地快照并输出 Barrier。</li></ul><p>对于相同的情况,Chandy-Lamport 算法的状态变化如下:</p><center><p><img src="/img/flink-unaligned-checkpoint/img6.chandy-lamport-status.png" alt="图6. Chandy-Lamport 状态变化" title="图6. Chandy-Lamport 状态变化"></p></center><ul><li>图 a: 同上。</li><li>图 b: 算子分别处理两个 Channel 一个元素,输出结果 <code>2</code>。此后接收到 Channel 1 的 Barrier,算子开始本地快照记录自己的状态,并输出 Barrier。</li><li>图 c: 算子继续正常处理两个 Channel 的输入,输出 <code>9</code>。特别的地方是 Channel 2 后续元素会被保存下来,直到 Channel 2 的 Barrier 出现(即 Channel 2 的 <code>9</code> 和 <code>7</code>)。保存的数据会作为 Channel 的状态成为快照的一部分。</li></ul><p>两者的差异主要可以总结为两点:</p><ol><li>快照的触发是在接收到第一个 Barrier 时还是在接收到最后一个 Barrier 时。</li><li>是否需要阻塞已经接收到 Barrier 的 Channel 的计算。</li></ol><p>从这两点来看,新的 Unaligned Checkpoint 将快照的触发改为第一个 Barrier 且取消阻塞 Channel 的计算,算法上与 Chandy-Lamport 基本一致,同时在实现细节方面结合 Flink 的定位做了几个改进。</p><p>首先,不同于 Chandy-Lamport 模型的只需要考虑算子输入 Channel 的状态,Flink 的算子有输入和输出两种 Channel,在快照时两者的状态都需要被考虑。</p><p>其次,无论在 Chandy-Lamport 还是 Flink Aligned Checkpoint 算法中,Barrier 都必须遵循其在数据流中的位置,算子需要等待 Barrier 被实际处理才开始快照。而 Unaligned Checkpoint 改变了这个设定,允许算子优先摄入并优先输出 Barrier。如此一来,第一个到达 Barrier 会在算子的缓存数据队列(包括输入 Channel 和输出 Channel)中往前跳跃一段距离,而被”插队”的数据和其他输入 Channel 在其 Barrier 之前的数据会被写入快照中(图中黄色部分)。</p><center><p><img src="/img/flink-unaligned-checkpoint/img7.barrier-overtake-data.png" alt="图7. Barrier 越过数据" title="图8. Barrier 越过数据"></p></center><p>这样的主要好处是,如果本身算子的处理就是瓶颈,Chandy-Lamport 的 Barrier 仍会被阻塞,但 Unaligned Checkpoint 则可以在 Barrier 进入输入 Channel 就马上开始快照。这可以从很大程度上加快 Barrier 流经整个 DAG 的速度,从而降低 Checkpoint 整体时长。</p><p>回到之前的例子,用 Unaligned Checkpoint 来实现,状态变化如下:</p><center><p><img src="/img/flink-unaligned-checkpoint/img8.unaligned-checkpoint-status.png" alt="图8. Unaligned-Checkpoint 状态变化" title="图8. Unaligned-Checkpoint 状态变化"></p></center><ul><li>图 a: 输入 Channel 1 存在 3 个元素,其中 <code>2</code> 在 Barrier 前面;Channel 2 存在 4 个元素,其中 <code>2</code>、<code>9</code>、<code>7</code> 在 Barrier 前面。输出 Channel 已存在结果数据 <code>1</code>。</li><li>图 b: 算子优先处理输入 Channel 1 的 Barrier,开始本地快照记录自己的状态,并将 Barrier 插到输出 Channel 末端。</li><li>图 c: 算子继续正常处理两个 Channel 的输入,输出 <code>2</code>、<code>9</code>。同时算子会将 Barrier 越过的数据(即输入 Channel 1 的 <code>2</code> 和输出 Channel 的 <code>1</code>)写入 Checkpoint,并将输入 Channel 2 后续早于 Barrier 的数据(即 <code>2</code>、<code>9</code>、<code>7</code>)持续写入 Checkpoint。</li></ul><p>比起 Aligned Checkpoint 中不同 Checkpoint 周期的数据以算子快照为界限分隔得很清晰,Unaligned Checkpoint 进行快照和输出 Barrier 时,部分本属于当前 Checkpoint 的输入数据还未计算(因此未反映到当前算子状态中),而部分属于当前 Checkpoint 的输出数据却落到 Barrier 之后(因此未反映到下游算子的状态中)。这也正是 Unaligned 的含义: 不同 Checkpoint 周期的数据没有对齐,包括不同输入 Channel 之间的不对齐,以及输入和输出间的不对齐。而这部分不对齐的数据会被快照记录下来,以在恢复状态时重放。换句话说,从 Checkpoint 恢复时,不对齐的数据并不能由 Source 端重放的数据计算得出,同时也没有反映到算子状态中,但因为它们会被 Checkpoint 恢复到对应 Channel 中,所以依然能提供只计算一次的准确结果。</p><p>当然,Unaligned Checkpoint 并不是百分百优于 Aligned Checkpoint,它会带来的已知问题就有:</p><ol><li>由于要持久化缓存数据,State Size 会有比较大的增长,磁盘负载会加重。</li><li>随着 State Size 增长,作业恢复时间可能增长,运维管理难度增加。</li></ol><p>目前看来,Unaligned Checkpoint 更适合容易产生高反压同时又比较重要的复杂作业。对于像数据 ETL 同步等简单作业,更轻量级的 Aligned Checkpoint 显然是更好的选择。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>Flink 1.11 的 Unaligned Checkpoint 主要解决在高反压情况下作业难以完成 Checkpoint 的问题,同时它以磁盘资源为代价,避免了 Checkpoint 可能带来的阻塞,有利于提升 Flink 的资源利用率。随着流计算的普及,未来的 Flink 应用大概会越来越复杂,在未来经过实战打磨完善后 Unaligned Checkpoint 很有可能会取代 Aligned Checkpoint 成为 Flink 的默认 Checkpoint 策略。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-76%3A+Unaligned+Checkpoints" target="_blank" rel="external">FLIP-76: Unaligned Checkpoints</a></li><li><a href="http://research.microsoft.com/en-us/um/people/lamport/pubs/chandy.pdf" target="_blank" rel="external">Distributed Snapshots: Determining Global States of Distributed Systems</a></li><li><a href="https://ci.apache.org/projects/flink/flink-docs-stable/internals/stream_checkpointing.html" target="_blank" rel="external">Flink Docs: Data Streaming Fault Tolerance</a></li><li><a href="http://apache-flink-mailing-list-archive.1008284.n3.nabble.com/Checkpointing-under-backpressure-td31616.html" target="_blank" rel="external">Checkpointing Under Backpressure</a></li><li><a href="https://zhuanlan.zhihu.com/p/87131964" target="_blank" rel="external">Flink Checkpoint 问题排查实用指南</a></li></ol>]]></content>
<summary type="html">
<p>作为 Flink 最基础也是最关键的容错机制,Checkpoint 快照机制很好地保证了 Flink 应用从异常状态恢复后的数据准确性。同时 Checkpoint 相关的 metrics 也是诊断 Flink 应用健康状态最为重要的指标,成功且耗时较短的 Checkpoint 表明作业运行状况良好,没有异常或反压。然而,由于 Checkpoint 与反压的耦合,反压反过来也会作用于 Checkpoint,导致 Checkpoint 的种种问题。针对于此,Flink 在 1.11 引入 Unaligned Checkpint 来解耦 Checkpoint 机制与反压机制,优化高反压情况下的 Checkpoint 表现。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>Flink 流批一体的实践与探索</title>
<link href="https://link3280.github.io/2020/03/30/Flink-%E6%B5%81%E6%89%B9%E4%B8%80%E4%BD%93%E7%9A%84%E5%AE%9E%E8%B7%B5%E4%B8%8E%E6%8E%A2%E7%B4%A2/"/>
<id>https://link3280.github.io/2020/03/30/Flink-流批一体的实践与探索/</id>
<published>2020-03-30T15:37:18.000Z</published>
<updated>2020-04-04T08:24:03.002Z</updated>
<content type="html"><![CDATA[<p>自 Google Dataflow 模型被提出以来,流批一体就成为分布式计算引擎最为主流的发展趋势。流批一体意味着计算引擎同时具备流计算的低延迟和批计算的高吞吐高稳定性,提供统一编程接口开发两种场景的应用并保证它们的底层执行逻辑是一致的。对用户来说流批一体很大程度上减少了开发维护的成本,但同时这对计算引擎来说是一个很大的挑战。作为 Dataflow 模型的最早采用者之一,Apache Flink 在流批一体特性的完成度上在开源项目中是十分领先的。本文将基于社区资料和笔者的经验,介绍 Flink 目前(1.10)流批一体的现状以及未来的发展规划。</p><a id="more"></a><h1 id="概况"><a href="#概况" class="headerlink" title="概况"></a>概况</h1><p>相信不少读者都知道,Flink 遵循 Dataflow 模型的理念: 批处理是流处理的特例。不过出于批处理场景的执行效率、资源需求和复杂度各方面的考虑,在 Flink 设计之初流处理应用和批处理应用尽管底层都是流处理,但在编程 API 上是分开的。这允许 Flink 在执行层面仍沿用批处理的优化技术,并简化掉架构移除掉不需要的 watermark、checkpoint 等特性。</p><center><p><img src="/img/unified-stream-batch/img1.old_architecture.png" alt="图1. Flink 经典架构" title="图1. Flink 经典架构"></p></center><p>在 Flink 架构上,负责物理执行环境的 Runtime 层是统一的流处理,上面分别有独立的 DataStream 和 DataSet 两个 API,两者基于不同的任务类型(Stream Task/Batch Task)和 UDF 接口(Transformation/Operator)。而更上层基于关系代数的 Table API 和 SQL API 虽然表面上是统一的,但实际上编程入口(Environment)是分开的,且内部将流批作业分别翻译到 DataStream API 和 DataSet API 的逻辑也是不一致的。</p><p>因此,要实现真正的流批一体,Flink 需完成 Table/SQL API 的和 DataStream/DataSet API 两层的改造,将批处理完全移植到流处理之上,并且需要兼顾作为批处理立身之本的效率和稳定性。目前流批一体也是 Flink 长期目标中很重要一点,流批一体的完成将标志着 Flink 进入 2.x 的新大版本时代。</p><p>流批一体完成以后理想的架构如下:</p><center><p><img src="/img/unified-stream-batch/img2.new-architecture.png" alt="图2. Flink 未来架构" title="图2. Flink 未来架构"></p></center><p>其中 Planner 从 Table/SQL API 层独立出来变为可插拔的模块,而原先的 DataStream/DataSet 层则会简化为只有 DataStream(图 2 中的 StreamTransformation 和 Stream Operator 是 Stream DAG 的主要内容,分别表示 UDF 和执行 UDF 的算子),DataSet API 将被废弃。</p><h1 id="Table-SQL-API-的改进"><a href="#Table-SQL-API-的改进" class="headerlink" title="Table/SQL API 的改进"></a>Table/SQL API 的改进</h1><p>Table/SQL API 的改造开始得比较早,截止 1.10 版本发布已经达到阶段性的流批一体目标。然而在 1.7 版本时,Table API 只是作为基于 DataStream/DataSet API 的 lib,并没有得到社区的重点关注。而当时阿里的 Blink 已经在 Table/SQL 上做了大量的优化,为了合并 Blink 的先进特性到 Flink,阿里的工程师推进社区重构了 Table 模块的架构[5]并将 Table/SQL API 提升为主要编程 API。自此 Table 层中负责将 SQL/Table API 翻译为 DataStream/DataSet API 的代码被抽象为可插拔的 Table Planner 模块,而 Blink 也将主要的特性以 Blink Planner 的形式贡献给社区,于是有了目前两个 Planner 共存的状态。</p><center><p><img src="/img/unified-stream-batch/img3.backward-compatibility.png" alt="图3. Flink 目前过渡架构" title="图3. Flink 目前过渡架构"></p></center><p>Flink 默认的 Legacy Planner 会将 SQL/Table 程序翻译为 DataStream 或 DataSet 程序,而新的 Blink Planner 则统一翻译为 DataStream 程序。也就是说通过 Blink Planner,Flink Table API 事实上已经实现了流批一体的计算。要了解 Blink Planner 是如何做到的,首先要对 Planner 的工作原理有一定的了解。</p><p>Legacy Planner 对于用户逻辑的表示在 Flink 架构中不同层的演变过程如下:</p><center><p><img src="/img/unified-stream-batch/img4.legacy-planner-architecture.png" alt="图4. Legacy Planner 架构" title="图4. Legacy Planner 架构"></p></center><ol><li>用基于 Calcite 的 SQL parser 解析用户提交的 SQL,将不同类型的 SQL 解析为不同 Operation(比如 DDL 对应 CreateTableOperation,DSL 对应 QueryOperation),并将 AST 以关系代数 Calcite RelNode 的形式表示。</li><li>根据用户指定 TableEnvironment 的不同,分别使用不同的翻译途径,将逻辑关系代数节点 RelNode 翻译为 Stream 的 Transformation 或者 Batch 的 Operator Tree。</li><li>调用 DataStream 和 DataSet 对应环境的方法将 Transformation 或 Operator Tree 翻译为包含执行环境配置的作业表示,即 StreamGraph 或 Plan。</li><li>优化 StreamGraph 和 Plan,并包装为可序列化的 JobGraph。</li></ol><p>因为 Batch SQL 与 Streaming SQL 在大部分语法及语义上是一致的,不同点在于 Streaming SQL 另有拓展语法的来支持 Watermark、Time Characteristic 等流处理领域的特性,因此 SQL parser 是 Batch/Stream 共用的。关键点在于对于关系代数 RelNode 的翻译上。</p><center><p><img src="/img/unified-stream-batch/img5.relnode-uml.png" alt="图5. Legacy Planner RelNode 类图" title="图5. Flink RelNode 类图"></p></center><p>Flink 基于 Calcite RelNode 拓展了自己的 FlinkRelNode,FlinkRelNode 有三个子类 FlinkLogicalRel、DataSetRel 和 DataStreamRel。FlinkLogicalRel 表示逻辑的关系代数节点,比如常见的 Map 函数对应的 FlinkLogicalRel 是 DataStreamCalc。DataSetRel 和 DataStreamRel 则分别表示 FlinkLogicalRel 在批处理和流处理下各自的物理执行计算。</p><p>在 SQL 优化过程中,根据编程入口的不同 FlinkLogicalRel 被转化为 DataSetRel 或 DataStreamRel。BatchTableEnvironment 使用 BatchOptimizer 基于 Calcite Rule 的优化,而 StreamTableEnvironment 使用 StreamOptimizer 进行优化。比如 TableScan 这样一个 RelNode,在 Batch 环境下被翻译为 BatchTableSourceScan,在 Stream 环境下被翻译为 StreamTableSourceScan,而这两类物理关系代数节点将可以直接映射到 DataSet 的 Operator 或 DataStream 的 Transformation 上。</p><p>上述的方式最大的问题在于 Calcite 的优化规则无法复用,比如对数据源进行过滤器下推的优化,那么需要给 DateSetRel 和 DataStreamRel 分别做一套,而且 DataSet 和 DataStream 层的算子也要分别进行相应的修改,开发维护成本很高,而这也是 Blink Planner 推动流批一体的主要动力。</p><p>如上文所说,Blink Planner 做的最重要的一点就是废弃了 DataSet 相关的翻译途径,将 DateSetRel 也移植到 DataStream 之上,那么前提当然是 DataStream 要可以表达 DataSet 的语义。熟悉批处理的同学可能会有疑问: 批处理特有的排序等算子,在 DataStream 中是没有的,这将如何表达?事实上 Table Planner 广泛采用了动态代码生成,可以绕过 DataStream API 直接翻译至底层的 Transformation 和 StreamOperator 上,并不一定需要 DataStream 有现成的算子,因此使用 Blink Planner 的 Table API 与 DataStream API 的关系更多是并列的关系。这也是 FLIP-32[5] 所提到的解耦 Table API 和 DataStream/DataSet API 的意思:</p><blockquote><p><strong>Decouple table programs from DataStream/DataSet API</strong><br>Allow table programs to be self-contained. No need for a Stream/ExecutionEnvironment entrypoint anymore. A table program definition is just API that reads and writes to catalog tables.</p></blockquote><p>Table 改造完成后整个 API 架构如下,这也是目前 1.10 版本已经实现的架构:</p><center><p><img src="/img/unified-stream-batch/img6.blink-planner-architecture.png" alt="图6. Blink Planner 架构" title="图6. Blink Planner 架构"></p></center><p>事实上,早前版本的 DataStream 对批作业的支持并不是太好,为了支持 Blink Planner 的 Batch on Stream,DataStream 方面也先做了不少的优化。这些优化是对于 Table API 是必要的,因此在 Blink Planner 合并到 Flink master 的前置工作,这将和 DataStream 还未完成的改进一起放在下文分析。另外虽然 Blink Planner 在计算上是流批一体的,但 Flink Table API 的 TableSource 和 TableSink 仍是流批分离的,这意味着目前绝大数批处理场景的基于 BatchTableSource/BatchTableSink 的 Table 无法很好地跟流批一体的计算合作,这将在 FLIP-95[9] 中处理。</p><h1 id="DataStream-API-的改进"><a href="#DataStream-API-的改进" class="headerlink" title="DataStream API 的改进"></a>DataStream API 的改进</h1><p>在 DataStream API 方面,虽然目前的 DataStream API 已经可以支持有界数据流,但这个支持并不完整且效率上比起 DataSet API 仍有差距。为了实现完全的流批一体,Flink 社区准备在 DataStream 引入 BoundedStream 的概念来表示有界的数据流,完全从各种意义上代替 DataSet。BoundedStream 将是 DataStream 的特例,同样使用 Transformation 和 StreamOperator,且同时需要继承 DataSet 的批处理优化。这些优化可以分为 Task 线程模式、调度策略及容错和计算模型及算法这几部分。</p><h2 id="Task-线程模型"><a href="#Task-线程模型" class="headerlink" title="Task 线程模型"></a>Task 线程模型</h2><p>批处理业务场景通常更重视高吞吐,出于这点考虑,Batch Task 是 pull-based 的,方便 Task 批量拉取数据。Task 启动后会主动通过 DataSet 的 Source API InputFormat 来读取外部数据源,每个 Task 同时只读取和处理一个 Split。</p><p>相比之下,一般流处理业务场景则更注重延迟,因此 Stream Task 是 push-based 的。DataStream 的 Source API SourceFunction 会被独立的 Source Thread 执行,并一直读取外部数据,源源不断地将数据 push 给 Stream Task。每个 Source Thread 可以并发读取一个到多个 Split/Partition/Shard。</p><center><p><img src="/img/unified-stream-batch/img7.source-thread-model.png" alt="图7. Stream/Batch 线程模型" title="图7. Stream/Batch 线程模型(图来源 Flink Forward)"></p></center><p>为了解决 Task 线程模型上的差异,Flink 社区计划重构 Source API 来统一不同外部存储和业务场景下的 Task 线程模型。总体的思路是新增一套新的 Source API,可以支持多种线程模型,覆盖流批两种业务需求,具体可见 FLIP-27[6] 或笔者早前的一篇博客[7]。目前 FLIP-27 仍处于初步的开发阶段。</p><h2 id="调度策略及容错"><a href="#调度策略及容错" class="headerlink" title="调度策略及容错"></a>调度策略及容错</h2><p>众所周知,批处理作业和流处理作业在 Task 调度上是很不同的。批处理作业的多个 Task 并不需要同时在线,可以根据依赖关系先调度一批 Task,等它们结束后再运行另一批。相反地,流作业的所有 Task 需要在作业启动的时候就全部被调度,然后才可以开始处理数据。前一种调度策略通常称为懒调度(Lazy Scheduling),后一种通常称为激进调度(Eager Scheduling)。为了实现流批一体,Flink 需要在 StreamGraph 中同时支持这两种调度模式,也就是说新增懒调度。</p><p>随调度而来的问题还有容错,这并不难理解,因为 Task 出现错误后需要重新调度来恢复。而懒调度的一大特点是,Task 计算的中间结果需要保存在某个高可用的存储中,然后下个 Task 启动后才能去获取。而在 1.9 版本以前,Flink 并没有持久化中间结果。这就导致了如果该 TaskManager 崩溃,中间结果会丢失,整个作业需要从头读取数据或者从 checkpoint 来恢复。这对于实时流处理来说是很正常的,然而批处理作业并没有 checkpoint 这个概念,批处理通常依赖中间结果的持久化来减小需要重算的 Task 范围,因此 Flink 社区引入了可插拔的 Shuffle Service 来提供 Suffle 数据的持久化以支持细粒度的容错恢复,具体可见 FLIP-31[8]。</p><h2 id="计算模型及算法"><a href="#计算模型及算法" class="headerlink" title="计算模型及算法"></a>计算模型及算法</h2><p>与 Table API 相似,同一种计算在流处理和批处理中的算法可能是不同的。典型的一个例子是 Join: 它在流处理中表现为两个流的元素的持续关联,任何一方的有新的输入都需要跟另外一方的全部元素进行关联操作,也就是最基础的 Nested-Loop Join;而在批处理中,Flink 可以将它优化为 Hash Join,即先读取一方的全部数据构建 Hash Table,再读取另外一方进行和 Hash Table 进行关联(见图8)。</p><center><p><img src="/img/unified-stream-batch/img8.join-batch-optimization.png" alt="图8. Join 批处理优化" title="图8. Join 批处理优化"></p></center><p>这种差异性本质是算子在数据集有界的情况下的优化。拓展来看,数据集是否有界是 Flink 在判断算子如何执行时的一种优化参数,这也印证了批处理是流处理的特例的理念。因此从编程接口上看,BoundedStream 作为 DataStream 的子类,基于输入的有界性可以提供如下优化:</p><ul><li>提供只可以应用于有界数据流的算子,比如 sort。</li><li>对某些算子可以进行算法上的优化,比如 join。</li></ul><p>此外,批处理还有个特点是不需要在计算时输出中间结果,只要在结束时输出最终结果,这很大程度上避免了处理多个中间结果的复杂性。因此,BoundedStream 还会支持非增量(non-incremental)执行模式。这主要会作用于与 Time Charateritic 相关的算子:</p><ul><li>Processing Time Timer 将被屏蔽。</li><li>Watermark 的提取算法不再生效,Watermark 直接从开始时的 -∞ 跳到结束时的 +∞。</li></ul><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>基于批处理是流处理的特例的理念,用流处理表达批处理在语义上是完全可行的,而流批一体的难点在于批处理场景作为特殊场景的优化。对 Flink 而言,难点主要体现批处理作业在 Task 线程模型、调度策略和计算模型及算法的差异性上。目前 Flink 已经在偏声明式的 Table/SQL API 上实现了流批一体,而更底层偏过程式的 DataStream API 也将在 Flink 2.0 实现流批一体。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://www.infoq.cn/article/Wj8SYfBoVkgQoVP-Y2uf" target="_blank" rel="external">Flink 流批一体的技术架构以及在阿里的实践</a></li><li><a href="https://www.alibabacloud.com/blog/whats-all-involved-with-blink-merging-with-apache-flink_595401" target="_blank" rel="external">What’s All Involved with Blink Merging with Apache Flink?</a></li><li><a href="https://matt33.com/2019/12/09/flink-job-graph-3/" target="_blank" rel="external">Flink Streaming 作业如何转化为 JobGraph</a></li><li><a href="https://flink.apache.org/news/2019/02/13/unified-batch-streaming-blink.html" target="_blank" rel="external">Batch as a Special Case of Streaming and Alibaba’s contribution of Blink</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-32%3A+Restructure+flink-table+for+future+contributions" target="_blank" rel="external">FLIP-32: Restructure flink-table for future contributions</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-27%3A+Refactor+Source+Interface?src=contextnavpagetreemode" target="_blank" rel="external">FLIP-27: Refactor Source Interface</a></li><li><a href="http://www.whitewood.me/2020/02/11/%E6%BC%AB%E8%B0%88-Flink-Source-%E6%8E%A5%E5%8F%A3%E9%87%8D%E6%9E%84/" target="_blank" rel="external">漫谈 Flink Source 接口重构</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-31%3A+Pluggable+Shuffle+Service" target="_blank" rel="external">FLIP-31: Pluggable Shuffle Service</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-95%3A+New+TableSource+and+TableSink+interfaces" target="_blank" rel="external">FLIP-95: New TableSource and TableSink interfaces</a></li></ol>]]></content>
<summary type="html">
<p>自 Google Dataflow 模型被提出以来,流批一体就成为分布式计算引擎最为主流的发展趋势。流批一体意味着计算引擎同时具备流计算的低延迟和批计算的高吞吐高稳定性,提供统一编程接口开发两种场景的应用并保证它们的底层执行逻辑是一致的。对用户来说流批一体很大程度上减少了开发维护的成本,但同时这对计算引擎来说是一个很大的挑战。作为 Dataflow 模型的最早采用者之一,Apache Flink 在流批一体特性的完成度上在开源项目中是十分领先的。本文将基于社区资料和笔者的经验,介绍 Flink 目前(1.10)流批一体的现状以及未来的发展规划。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>Flink Table 的三种 Sink 模式</title>
<link href="https://link3280.github.io/2020/02/26/Flink-Table-%E7%9A%84%E4%B8%89%E7%A7%8D-Sink-%E6%A8%A1%E5%BC%8F/"/>
<id>https://link3280.github.io/2020/02/26/Flink-Table-的三种-Sink-模式/</id>
<published>2020-02-26T13:19:52.000Z</published>
<updated>2020-02-26T13:25:34.037Z</updated>
<content type="html"><![CDATA[<p>作为计算引擎 Flink 应用的计算结果总要以某种方式输出,比如调试阶段的打印到控制台或者生产阶段的写到数据库。而对于本来就需要在 Flink 内存保存中间及最终计算结果的应用来说,比如进行聚合统计的应用,输出结果便是将内存中的结果同步到外部。就 Flink Table/SQL API 而言,这里的同步会有三种模式,分别是 Append、Upsert 和 Retract。实际上这些输出计算结果的模式并不限于某个计算框架,比如 Storm、Spark 或者 Flink DataStream 都可以应用这些模式,不过 Flink Table/SQL 已有完整的概念和内置实现,更方便讨论。</p><a id="more"></a><h1 id="基础原理"><a href="#基础原理" class="headerlink" title="基础原理"></a>基础原理</h1><p>相信接触过 Streaming SQL 的同学都有了解或者听过流表二象性,简单来说流和表是同一事实的不同表现,是可以相互转换的。流和表的表述在业界不尽相同,笔者比较喜欢的一种是: 流体现事实在时间维度上的变化,而表则体现事实在某个时间点的视图。如果将流比作水管中流动的水,那么表将是杯子里静止的水。</p><p>将流转换为表的方法对于大多数读者都不陌生,只需将聚合统计函数应用到流上,流很自然就变为表(值得注意的是,Flink 的 Dynamic Table 和表的定义有细微不同,这将在下文讲述)。比如对于一个计算 PV 的简单流计算作业,将用户浏览日志数据流安 url 分类统计,变成 <code>(url, views)</code> 这样的一个表。然而对于如何将表转换成流,读者则未必有这么清晰的概念。</p><p>假设一个典型的实时流计算应用的工作流程可以被简化为下图:</p><center><p><img src="/img/flink-sink-pattern/img1.programing-model.png" alt="图1. Flink 编程模型"></p></center><p>其中很关键的一点是 Transformation 是否聚合类型的计算。若否,则输出结果依然是流,可以很自然地使用原本流处理的 Sink(与外部系统的连接器);若是,则流会转换为表,那么输出的结果将是表,而一个表的输出通常是批处理的概念,不能直接简单地用流处理的 Sink 来表达。</p><p>这时有个很朴素的想法是,我们能不能避免批处理那种全量的输出,每次只输出表的 diff,也就是 changelog。这也是表转化为流的方法: 持续观察表的变化,并将每个变化记录成日志输出。因此,流和表的转换可以以下图表示:</p><center><p><img src="/img/flink-sink-pattern/img2.stream-table-conversion.png" alt="图2. Flink 编程模型"></p></center><p>其中表的变化具体可以分为 <code>INSERT</code>、<code>UPDATE</code> 和 <code>DELETE</code> 三类,而 Flink 根据这些变化类型分别总结了三种结果的输出模式。</p><table><thead><tr><th>模式</th><th>INSERT</th><th>UPDATE</th><th>DELETE</th></tr></thead><tbody><tr><td>Append</td><td>支持</td><td>不支持</td><td>不支持</td></tr><tr><td>Upsert</td><td>支持</td><td>支持</td><td>支持</td></tr><tr><td>Retract</td><td>支持</td><td>支持</td><td>支持</td></tr></tbody></table><p>通常来说 Append 是最容易实现但功能最弱的,Retract 是最难实现而功能最强的。下文分别谈谈三种模式的特点和应用场景。</p><h1 id="Append-输出模式"><a href="#Append-输出模式" class="headerlink" title="Append 输出模式"></a>Append 输出模式</h1><p>Append 是最为简单的输出模式,只支持追加结果记录的操作。因为结果一旦输出以后便不会再有变更,Append 输出模式的最大特性是不可变性(immutability),而不可变性最令人向往的优势便是安全,比如线程安全或者 Event Sourcing 的可恢复性,不过同时也会给业务操作带来限制。通常来说,Append 模式会用于写入不方便做撤回或者删除操作的存储系统的场景,比如 Kafka 等 MQ 或者打印到控制台。</p><p>在实时聚合统计中,聚合统计的结果输出是由 Trigger 决定的,而 Append-Only 则意味着对于每个窗口实例(Pane,窗格)Trigger 只能触发一次,则就导致无法在迟到数据到达时再刷新结果。通常来说,我们可以给 Watermark 设置一个较大的延迟容忍阈值来避免这种刷新(再有迟到数据则丢弃),但代价是却会引入较大的延迟。</p><p>不过对于不涉及聚合的 Table 来说,Append 输出模式是非常好用的,因为这类 Table 只是将数据流的记录按时间顺序排在一起,每条记录间的计算都是独立的。值得注意的是,从 DataFlow Model 的角度来看未做聚合操作的流不应当称为表,但是在 Flink 的概念里所有的流都可以称为 Dynamic Table。笔者认为这个设计也有一定的道理,原因是从流中截取一段出来依然可以满足表的定义,即”某个时间点的视图”,而且我们可以争辩说<code>不聚合</code>也是一种聚合函数。</p><h1 id="Upsert-输出模式"><a href="#Upsert-输出模式" class="headerlink" title="Upsert 输出模式"></a>Upsert 输出模式</h1><p>Upsert 是 Append 模式的升级版,支持 Append-Only 的操作和在有主键的前提下的 UPDATE 和 DELETE 操作。Upsert 模式依赖业务主键来实现输出结果的更新和删除,因此非常适合 KV 数据库,比如<br>HBase、JDBC 的 TableSink 都使用了这种方式。</p><p>在底层,Upsert 模式下的结果更新会被翻译为 (Boolean, ROW) 的二元组。其中第一个元素表示操作类型,<code>true</code> 对应 <code>UPSERT</code> 操作(不存在该元素则 <code>INSERT</code>,存在则 <code>UPDATE</code>),<code>false</code> 对应 <code>DELETE</code> 操作,第二个元素则是操作对应的记录。如果结果表本身是 Append-Only 的,第一个元素会全部为 <code>true</code>,而且也无需提供业务主键。</p><p>Upsert 模式是目前来说比较实用的模式,因为大部分业务都会提供原子或复合类型的主键,而在支持 KV 的存储系统也非常多,但要注意的是不要变更主键,具体原因会在下一节谈到。</p><h1 id="Retract-输出模式"><a href="#Retract-输出模式" class="headerlink" title="Retract 输出模式"></a>Retract 输出模式</h1><p>Retract 是三种输出模式中功能最强大但实现也最复杂的一种,它要求目标存储系统可以追踪每个条记录,而且这些记录至少在一定时间内都是可以撤回的,因此通常来说它会自带系统主键,不必依赖于业务主键。然而由于大数据存储系统很少有可以精确到一条记录的更新操作,因此目前来说至少在 Flink 原生的 TableSink 中还没有能在生产环境中满足这个要求的。</p><p>不同于 Upsert 模式更新时会将整条记录重新输出,Retract 模式会将更新分成两条表示增减量的消息,一条是 <code>(false, OldRow)</code> 的撤回(Retract)操作,一条是 <code>(true, NewRow)</code> 的积累(Accumulate)操作。这样的好处是,在主键出现变化的情况下,<code>Upsert</code> 输出模式无法撤回旧主键的记录,导致数据不准确,而 <code>Retract</code> 模式则不存在这个问题。</p><p>举个例子,假设我们将电商订单按照承运快递公司进行分类计数,有如下的结果表。</p><table><thead><tr><th>公司</th><th>订单数</th></tr></thead><tbody><tr><td>中通</td><td>2</td></tr><tr><td>圆通</td><td>1</td></tr><tr><td>顺丰</td><td>3</td></tr></tbody></table><p>那么如果原本一单为中通的快递,后续更新为用顺丰发货,对于 Upsert 模式会产生 <code>(true, (顺丰, 4))</code> 这样一条 changelog,但中通的订单数没有被修正。相比之下,Retract 模式产出 <code>(false, (中通, 1))</code> 和 <code>(true, (顺丰, 1))</code> 两条数据,则可以正确地更新数据。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>Flink Table Sink 的三种模式本质上是如何监控结果表并产生 changelog,这可以应用于所有需要将表转为流的场景,包括同一个 Flink 应用的不同表间的联动。三种模式中 Append 模式只支持表的 <code>INSERT</code>,最为简单;Upsert 模式依赖业务主键提供 <code>INSERT</code>、<code>UPDATE</code> 和 <code>DELETE</code> 全部三类变更,比较实用;Retract 模式同样支持三类变更且不要求业务主键,但会将 <code>UPDATE</code> 翻译为旧数据的撤回和新数据的累加,实现上比较复杂。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://www.confluent.io/blog/making-sense-of-stream-processing/" target="_blank" rel="external">Stream Processing, Event Sourcing, and Data Streaming Explained</a></li><li><a href="https://www.confluent.io/blog/introducing-kafka-streams-stream-processing-made-simple/" target="_blank" rel="external">Introducing Kafka Streams: Stream Processing Made Simple</a></li><li><a href="https://ci.apache.org/projects/flink/flink-docs-stable/dev/table/streaming/dynamic_tables.html" target="_blank" rel="external">Dynamic Tables</a></li><li><a href="https://blog.jrwang.me/2019/2019-10-16-flink-sourcecode-stream-and-dynamic-table/" target="_blank" rel="external">Flink 源码阅读笔记(18)- Flink SQL 中的流和动态表</a></li></ol>]]></content>
<summary type="html">
<p>作为计算引擎 Flink 应用的计算结果总要以某种方式输出,比如调试阶段的打印到控制台或者生产阶段的写到数据库。而对于本来就需要在 Flink 内存保存中间及最终计算结果的应用来说,比如进行聚合统计的应用,输出结果便是将内存中的结果同步到外部。就 Flink Table/SQL API 而言,这里的同步会有三种模式,分别是 Append、Upsert 和 Retract。实际上这些输出计算结果的模式并不限于某个计算框架,比如 Storm、Spark 或者 Flink DataStream 都可以应用这些模式,不过 Flink Table/SQL 已有完整的概念和内置实现,更方便讨论。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>漫谈 Flink Source 接口重构</title>
<link href="https://link3280.github.io/2020/02/11/%E6%BC%AB%E8%B0%88-Flink-Source-%E6%8E%A5%E5%8F%A3%E9%87%8D%E6%9E%84/"/>
<id>https://link3280.github.io/2020/02/11/漫谈-Flink-Source-接口重构/</id>
<published>2020-02-11T12:24:52.000Z</published>
<updated>2020-02-11T12:27:30.984Z</updated>
<content type="html"><![CDATA[<p>对于大多数 Flink 应用开发者而言,无论使用高级的 Table API 或者是底层的 DataStream/DataSet API,Source 都是首先接触到且使用最多的 Operator 之一。然而其实从 2018 年 10 月开始,Flink 社区就开始计划重构这个稳定了多年的 Source 接口[1],以满足更大规模数据以及对接更丰富的 connector 的要求,另外还有更重要的一个目的: 统一流批两种计算模式。重构后的 Source 接口在概念和使用方式上都会有较大不同,无论对 Flink 应用开发者还是 Flink 社区贡献者来说都是十分值得关注的,所以本文将从”为什么要这样设计”的角度来谈谈 Source 接口重构的前因后果。这会涉及到较多的底层架构内容,要求读者有一定的基础或者有探索的兴趣。</p><a id="more"></a><h1 id="现有-Source-接口"><a href="#现有-Source-接口" class="headerlink" title="现有 Source 接口"></a>现有 Source 接口</h1><p>目前(Flink 1.9)Source 接口分为 DataStream/DataSet/Table API 三个不同的栈,但因为 Table API 是基于前两者的封装,我们在讨论底层接口的时候可以先排除掉它。Source 接口在 DataStream/DataSet API 中同样是负责数据的生成或摄入,但除此之外的功能有不小的差异。</p><h2 id="DataStream-API"><a href="#DataStream-API" class="headerlink" title="DataStream API"></a>DataStream API</h2><p>在 DataStream API 中 Source 对应的核心接口为 SourceFunction 以及 SourceContext。前者直接继承 Function 接口与 Operator 交互,负责通用的状态管理(比如初始化或取消);后者代表运行时的上下文,负责与单条记录级别的数据的交互。此外还有其他一些辅助类型的类或接口,整体的类图设计如下:</p><center><p><img src="/img/flip-27/img1.datastream_source.png" alt="图一. DataStream API 的 Source 接口" title="图一. DataStream API 的 Source 接口"></p></center><p>其中 ParallelSourceFunction 进一步继承 SourceFunction,标记该 Source 为可并行化的,否则直接实现 SourceFunction 的 Source 的并行度只能为 1。而 RichParallelSourceFunction 则是在 ParallelSourceFunction 基础之上再结合 AbstractRichFunction,提供有状态的并行 Source 基类。用户要实现一个 Source,可以选择 SourceFunction、ParallelSourceFunction 或<br>RichParallelSourceFunction 中任意一个来作为切入口。但值得注意的是,如果 Source 是有状态的,那么为了保证一致性,状态的更新和正常的数据输出是不可以并行的。为此,SourceContext 提供了 Checkpoint 锁来方便 Source 进行同步阻塞。</p><p>运行时,Source 主要通过 SourceContext 来控制数据的输出。从 SourceContext 接口的方法即可以看出,Source 在接受到数据后的主要工作有以下几点:</p><ol><li>从外部摄入数据或生成数据,输出到下游。</li><li>为数据生成 Event Time Timestamp(仅在 Time Characteristic 为 Event Time 时有用),比如 Kafka Source 的 Partition 级别的 Event time。</li><li>计算 Watermark 并输出(仅在 Time Charateristic 为 Event Time 时有用)。</li><li>当暂时不会有新数据时将自己标记为 Idle,以避免下游一直等待自己的 Watermark。</li></ol><h2 id="DataSet-API"><a href="#DataSet-API" class="headerlink" title="DataSet API"></a>DataSet API</h2><p>在 DataSet API 中 Source 对应的核心接口为 InputFormat。InputFormat 命名风格上借鉴了 Hadoop 的风格,在功能上也比较相近,具体有以下三点: </p><ol><li>描述输入的数据如何被划分为不同的 InputSplit(继承于 InputSplitSource)。</li><li>描述如何从单个 InputSplit 读取记录,具体包括如何打开一个分配到的 InputSplit,如何从这个 InputSplit 读取一条记录,如何得知记录已经读完和如何关闭这个 InputSplit。</li><li>描述如何获取输入数据的统计信息(比如文件的大小、记录的数目),以帮助更好地优化执行计划。</li></ol><p>第 1、3 两点功能会被 JobManager (JobMaster) 在调度 Exection 时使用,而第 2 点读取数据功能则会在运行时被 TaskManager 使用。</p><p>围绕 InputFormat,DataSet 还提供一系列接口,总体的类图如下:</p><center><p><img src="/img/flip-27/img2.dataset_source.png" alt="图二. DataSet API 的 Source 接口" title="图二. DataSet API 的 Source 接口"></p></center><ul><li>InputSplitSource 为 InputFormat 的超类,负责划分 InputSplit (第一点功能),不再赘述。</li><li>InputSplit 表示一个逻辑分区,必要的信息其实只有 Split 的 ID(或者下标),InputFormat 会根据这个 ID 来读取输入数据的对应分区。</li><li>RichInputFormat 拓展 InputFormat,加上 <code>openInputFormat()</code> 和 <code>closeInputFormat()</code> 方法来管理运行时的状态。比起 InputFormat 的 <code>open()</code> 和 <code>close()</code> 是在每个 InputSplit 级别调用,它们是在每次 Task Exectuion 级别调用,而每次 Task Exectuion 可以读多个 InputSplit。比如 TaskManager 要读取 HBase Table,那么它要打开和关闭一个 HTable 的连接,这个连接可以在多读多个 TableInputSplit 时复用。</li><li>ReplicatingInputFormat 拓展 RichInputFormat,为输入数据提供广播的能力。换句话说,通过 ReplicatingInputFormat 输入的数据会被每个实例重复读取,典型的应用是 Join 操作。</li></ul><p>有趣的是,除了上述典型的 DataSet 场景,InputFormat 还可以在 Streaming 场景中使用。通过 <code>StreamExecutionEnvironment#createInput(InputFormat)</code>,Flink 可以持续监控一个文件系统目录。InputFormat 会被传递给 <code>ContinuousFileReaderOperator</code>,后者是一个非并行化的算子(并行度只能为 1),会将目录新增的文件作为 <code>FileInputSplit</code> 传递给下游的 <code>ContinuousFileReaderOperator</code>,然后 <code>ContinuousFileReaderOperator</code> 再使用 InputFormat 来读取这些 InputSplit。所以虽然架构设计上不是特别一致,但 InputFormat 一定程度上是体现了流批统一的思想的。</p><h1 id="存在的问题"><a href="#存在的问题" class="headerlink" title="存在的问题"></a>存在的问题</h1><h2 id="不符合流批一体要求"><a href="#不符合流批一体要求" class="headerlink" title="不符合流批一体要求"></a>不符合流批一体要求</h2><p>首先,目前的 Source 接口栈最显而易见的问题 DataStream 和 DataSet 在 API 设计上的不统一。这个很大程度上是出于历史原因,在 Flink 最初开发之时业界普遍认为批处理和流处理是相对独立的,而直到 2016 年 Google 《The Dataflow Model》等文章的发表,业界才有比较完整的理论支持来统一两者。所以 Flink 社区当时分离开 DataStream 和 DataSet 来分别迭代开发也十分正常,但这也成了流批融合的新趋势下的负担。</p><p>现在的状况是 DataStream 和 DataSet 共享很多相同的代码,比如面向用户代码 UDF 的基础接口 Function 或计算逻辑的通用 RichFunction 都代码在 <code>flink-core</code> 中,但分场景使用的代码则分别存在于 <code>flink-java</code> 和 <code>flink-streaming-java</code> 中。典型的是两者在 Operator 上也是不同的,前者使用 <code>org.apache.flink.api.java.operators.Operator</code> 作为基类,而后者使用的是 <code>org.apache.flink.streaming.api.operators.StreamOperator</code>。StreamSource 和 DataSource 是 DataStream 和 DataSet 代表数据源的 Operator,它们分别封装了上文所说的 SourceFunction 和 InputFormat 两个接口。总体的关系大致如下(省去了部分非关键的类或接口)。</p><center><p><img src="/img/flip-27/img3.function_api.png" alt="图三. DataStream 与 DataSet Source 接口的关系" title="图三. DataStream 与 DataSet Source 接口的关系"></p></center><p>从图中可以方便地看到一个很不协调的地方便是 StreamSource 属于 AbstractUdfStreamOperator,因此可以直接使用 Function 接口,但是 DataSource 却不属于 SingleInputUdfOperator (这里应该是 AbstractUdfOperator 才合理,但 DataSet API 没有提供这层抽象),因此具体的读取数据源逻辑不是写在 Function 中,而是写在 InputFormat 中,这就造成需要为同一种外部存储系统开发维护两套重复性很高 Source。</p><h2 id="不便动态发现数据源变更"><a href="#不便动态发现数据源变更" class="headerlink" title="不便动态发现数据源变更"></a>不便动态发现数据源变更</h2><p>分布式存储系统通常都以某种”存储块”的方式来实现水平拓展,这种”存储块”在不同系统中有不同的命名,常见的有 Split/Partition/Shard,下游的计算引擎也会按照这些”存储块”的粒度进行工作分配。</p><p>在批处理计算中,输入数据源以作业启动时读取的元数据为准, Split 的数目不会在运行时改变,不需要动态监控数据源变化,但需要根据 TaskManager 处理的进度来动态分配 Split。因此 Flink DataSet 的做法是抽象出 Split Assigner(属于 InputFormat 的一部分)。作业启动时 Split Assigner 会读取数据源的元数据,随后一直运行在 JobManager 端负责将现有 Split 分配给空余的 TaskManager,直至所有的 Split 都完成处理 。</p><p>而实时流处理则却通常要动态发现新增的 Split,然后分配到现有的 TaskManager 上,比如 Kafka 的 Partition Discovery,同时也需要动态分配新 Partition 这样的一个机制。通常来说 Source 需要在初始化时新建一个线程来负责检测数据源的变更,若有则需要重新调整工作分配。不过与 DataSet 统一 JobManager 端中心化分配不同,DataStream 做动态检测的线程运行在每个 TaskManager 上,新发现的 Partition 是依靠每个 SubTask 按预先的规则分配。这样背后的原因是,InputFormat 设计了一部分逻辑运行在 JobManager 上,而 SourceFunction 则完全运行在 TaskManager 上,缺乏一个中心化的管理者。</p><h2 id="不便-Source-Subtask-间的协作"><a href="#不便-Source-Subtask-间的协作" class="headerlink" title="不便 Source Subtask 间的协作"></a>不便 Source Subtask 间的协作</h2><p>不同于 DataSet 中 Source Subtask 之间几乎是完全独立的,DataStream 中 Source Subtask 通常需要某种程度上的协作,比如不同 Subtask 之间的 Event Time 对齐。 </p><p>Event Time 对齐的背景是 Subtask 间的 Event Time 进度可能是不同的,但下游 Watermark 总是取最低者,这就导致对于基于 Watermark 的算子来说,它一直从 Event Time 快的 Subtask 摄入数据但这些数据总是得不到清理,进一步造成该算子的 State 逐渐膨胀。</p><p>解决这个问题的思路是,在一个 Source Subtask 自己 Event Time 明显先进于其他 Source Subtask 时,与其继续摄入数据并让下游自己缓存,不如直接阻塞自己的消费来等其他 Source Subtask 跟上,这就称为 Event Time 对齐。</p><p>Event Time 对齐要求 Source Subtask 间的协作,通常需要在 Master 节点上新增一个协调者(Coordinator),由协调者来管控 Split/Partition 的元数据(这点在目前的 SourceFunction 接口上是做不到的),来判定某个 Source Subtask 是否需要阻塞。另外,相似的协作需求还有 Work Stealing 等。</p><h2 id="容易造成瓶颈的-Checkpoint-Lock"><a href="#容易造成瓶颈的-Checkpoint-Lock" class="headerlink" title="容易造成瓶颈的 Checkpoint Lock"></a>容易造成瓶颈的 Checkpoint Lock</h2><p>在 DataStream 作业中,为了保证 State 更新和输出记录的一致性,两者是要通过 Checkpoint Lock 来进行同步的。SourceFunction 可以通过 SourceContext 来获取 Checkpoint Lock,例如如下代码:</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div></pre></td><td class="code"><pre><div class="line">public void run(SourceContext<T> ctx) {</div><div class="line"> while (isRunning) {</div><div class="line"> synchronized (ctx.getCheckpointLock()) {</div><div class="line"> ctx.collect(count);</div><div class="line"> count++;</div><div class="line"> }</div><div class="line">}</div><div class="line">}</div></pre></td></tr></table></figure><p>而问题在于这个锁并不是公平锁,也就是说 SourceFunction 有可能一直占据 Checkpoint Lock 导致 Checkpoint 被阻塞。另外这种比较重度的锁也不符合 actor 或者说 mailbox 模式的非阻塞设计。</p><h2 id="线程缺乏统一管理"><a href="#线程缺乏统一管理" class="headerlink" title="线程缺乏统一管理"></a>线程缺乏统一管理</h2><p>在 DataStream 应用中,Source 通常会需要一些 IO 线程来避免阻塞 Task 主线程,而这些线程目前是每个 Source 独立实现,这就造成各个 Source 需要自己设计复杂的线程模型。比如常用的 Kafka Connector,每个 FlinkKafkaConsumer 会额外启动一个 Fetcher 线程负责调用 Kafka Consumer API 进行消费,然后通过阻塞队列交给 TaskThread 来进行消费。</p><h1 id="改进思路"><a href="#改进思路" class="headerlink" title="改进思路"></a>改进思路</h1><h2 id="统一流批-Source-接口"><a href="#统一流批-Source-接口" class="headerlink" title="统一流批 Source 接口"></a>统一流批 Source 接口</h2><p>作为统一流批处理算子的最前一环,Source 接口首先需要被按照 Flink 推崇的”批处理是流处理的特例”的思想重新设计。按照社区长期目标,Flink 会新增 BoundedDataStream 来逐步取代 DataSet,而 BoundedDataStream 基于目前的 DataStream API,算子基本可以复用。</p><p>按照 FLIP-27,新的 Source 接口暂停名为 <code>Source</code>,它类似一个工厂类,主要构造 <code>SplitEnumerator</code> 和 <code>SplitReader</code>(该两者的作用将在下一节提及),并且可以同时为流批服务。<code>Source</code> 的使用方式将大致如下:</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div><div class="line">12</div></pre></td><td class="code"><pre><div class="line">ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();</div><div class="line"> </div><div class="line">FileSource<MyType> theSource = new ParquetFileSource("fs:///path/to/dir", AvroParquet.forSpecific(MyType.class));</div><div class="line"> </div><div class="line">// The returned stream will be a DataStream if theSource is unbounded.</div><div class="line">// After we add BoundedDataStream which extends DataStream, the returned stream will be a BoundedDataStream.</div><div class="line">// This allows users to write programs working in both batch and stream execution mode.</div><div class="line">DataStream<MyType> stream = env.source(theSource);</div><div class="line"> </div><div class="line">// Once we add bounded streams to the DataStream API, we will also add the following API.</div><div class="line">// The parameter has to be bounded otherwise an exception will be thrown.</div><div class="line">BoundedDataStream<MyType> batch = env.boundedSource(theSource);</div></pre></td></tr></table></figure><p>值得注意的是,<code>ExecutionEnvironment</code> 将根据 <code>Source#isBounded</code> 直接返回 DataStream 或者 BoundedDataStream,无需用户切换 Environmnet。</p><h2 id="独立出数据源的工作发现和分配"><a href="#独立出数据源的工作发现和分配" class="headerlink" title="独立出数据源的工作发现和分配"></a>独立出数据源的工作发现和分配</h2><p>在底层,<code>Source</code> 构造出的 <code>SplitEnumerator</code> 和 <code>SplitReader</code> 将分别负责发现和分配 Split 和 Split 的实际读取处理。相比之下,目前在 DataSet API 中的发现和分配 Split 由 JobManager 统一负责,而 DataStream 的对应工作则由 TaskManager 各自完成。</p><center><p><img src="/img/flip-27/img4.split-enumerator-reader.png" alt="图四. SplitEnumerator 和 SplitReader" title="图四. SplitEnumerator 和 SplitReader"></p></center><p><code>SplitEnumerator</code> 在作业启动时以单并行度运行,读取数据源元数据并构建 Split,按照分配策略将 Split 分配给 SplitReader,类似于现在 InputFormat 构造的 <code>SplitAssigner</code>,但不同点在于还要额外管理 DataStream 的管理工作,比如 Checkpoint 和 Watermark。<code>SplitEnumerator</code> 有三种现实方案: 运行在 JobManager 上(目前 <code>SplitAssigner</code> 的做法),或者以一个单并行度 Task 的方式运行在 TaskManager 上(类似目前 <code>ContinuousFileMonitoringFunction</code> 的做法),或者以一个新独立组件的方式运行。目前社区是比较偏向使用独立组件的方式,但未完全确定。感兴趣的读者可以研究下各种方案的优劣,相信可以从中学到不少东西。</p><p><code>SplitReader</code> 负责的工作则类似目前 DataStream 的 SourceFunction,不同点在于除了被动地接受 Split,<code>SplitReader</code> 还可以主动向 <code>SplitEnumerator</code> 请求 Split,这主要是满足批处理场景的需求。</p><p>通过这样的清晰分工,Source 的抽象性大大提升,新 Source 的开发和现有 Source 的迭代都更有规范可遵循,对用户来说也更容易理解。</p><h2 id="新增-Source-Subtask-间的通信机制"><a href="#新增-Source-Subtask-间的通信机制" class="headerlink" title="新增 Source Subtask 间的通信机制"></a>新增 Source Subtask 间的通信机制</h2><p>按照新架构,在运行期间 <code>SplitEnumerator</code> 和 <code>SplitReader</code> 不时会需要通信协作,比如分配新 Split 或 Event Time 对齐。这个通信将复用大部分现有的 JobManager 和 TaskManager 的 RPC 机制(基于 <code>SplitEnumerator</code> 以独立组件运行在 JobManager 端的方案),在这基础上加上 Operator 级别的协调者,比如上文提到的 Source 协调者。</p><center><p><img src="/img/flip-27/img5.source_component_rpc.png" alt="图五. Source 组件间的通信" title="图五. Source 组件间的通信"></p></center><p>其中 SourceEvent 是 <code>SplitEnumerator</code> 和 <code>SplitReader</code> 通信的消息,比如 <code>SplitEnumerator</code> 新分配 Split 或者 <code>SplitReader</code> 处理完已分配 Split 主动请求新 Split。而 OperatorEvent 则是更通用化的 Operator 协调者与 Operator 通信的消息。</p><h2 id="SplitReader-线程模型"><a href="#SplitReader-线程模型" class="headerlink" title="SplitReader 线程模型"></a>SplitReader 线程模型</h2><p>上文提到 Checkpoint Lock 是现在 Source 的瓶颈之一,这是因为 Checkpoint 和计算任务是由不同线程来执行,而新接口将遵循单线程的 actor/mailbox 模式,所以不再需要 Checkpoint Lock 来同步线程。</p><p>根据 FLIP-27 的设计,<code>SplitReader</code> 将调用外部存储系统客户端 API 读取数据,转换为目标数据类型后,push 到一个缓冲区(Buffer 或者 Queue),然后 Flink 内部的 Source Loop 线程再读取这个缓冲中的数据。</p><p>根据外部存储系统客户端的 API 调用方式(阻塞、非阻塞、异步)和 Flink 执行模式(流处理/批处理)的不同,Source 可以分为以下几种模式:</p><p>1) 单 Split 串行</p><center><p><img src="/img/flip-27/img6.single_split.png" alt="图六. 单 Split 串行" title="图六. 单 Split 串行"></p></center><p>这种模式通常符合批处理场景,比如 File Source、Database Source。工作流程是作业启动时 <code>SplitEnumerator</code> 会将 Split 分配到每个 <code>SplitReader</code> 的 Split Queue 中,然后 <code>SplitReader</code> 会逐一串行处理,并输出到 Buffer 供后续线程读取。</p><p>2)多 Split 多路复用</p><center><p><img src="/img/flip-27/img7.multi_split_multiplexed.png" alt="图七. 多 Split 多路复用" title="图七. 多 Split 多路复用"></p></center><p>多 Split 多路复用通常适用于流处理场景一个客户端可以处理多个 Split 的情况。典型的例子就是单个 Kafka Consumer 可以消费多个 Topic 的多个 Partition。工作流程是作业启动时 <code>SplitEnumerator</code> 会批量分配现有 Split 给 <code>SplitReader</code>,后者启动一个 IO 线程读取所有的 Split,处理后输出到 Buffer 或 Queue 供后续线程读取。</p><p>3) 多 Split 多线程</p><center><p><img src="/img/flip-27/img8.multi_split_multi_threaded.png" alt="图八. 多 Split 多线程" title="图八. 多 Split 多线程"></p></center><p>多 Split 多线程通常适用于流处理场景每个客户端只处理单个 Split 的情况,比如 Kinesis Consumer 会为每个 Kinesis Shard 单独起一个线程来读取数据。工作流程类似于 Kafka,不过每条 IO 线程都有单独的输出队列,这样下游可以选择性地读取某个 Shard 的数据,这对于 Event Time 对齐的特性十分重要。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>综上所述,目前的 Source 接口不符合流批的发展趋势,同时因为缺乏 Flink 引擎内置线程模型的支持,开发新的 Source 和为现有 Source 开发 Event Time 对齐等功能都十分不方便。为此 Flink 社区起草了 FLIP-27 来重构 Source 接口,核心是统一流批两种执行模式的 Source 架构,但底层的调度和算法则根据 Source 类型来判断。新接口的核心是 <code>SplitEnumerator</code> 和 <code>SplitReader</code>,前者负责发现和分配 Split、触发 Checkpoint 等管理工作, 后者负责 Split 的实际读取处理。此外,新增 Operator 间的通信机制,让 Source Subtask 之间可以协调完成 Event Time 对齐等新特性。最后,<code>SplitReader</code> 底层封装了通用的线程模型,相比目前的 <code>SourceFunction</code> 大大简化了 Source 的实现。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=95653748" target="_blank" rel="external">FLIP-27: Refactor Source Interface</a></li><li><a href="https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=95653748#FLIP-27:RefactorSourceInterface-where_run_enumerator" target="_blank" rel="external">Where To Run Enumerator</a></li><li><a href="https://lists.apache.org/thread.html/70484d6aa4b8e7121181ed8d5857a94bfb7d5a76334b9c8fcc59700c@%3Cdev.flink.apache.org%3E" target="_blank" rel="external">[DISCUSS] FLIP-27: Refactor Source Interface</a></li></ol>]]></content>
<summary type="html">
<p>对于大多数 Flink 应用开发者而言,无论使用高级的 Table API 或者是底层的 DataStream/DataSet API,Source 都是首先接触到且使用最多的 Operator 之一。然而其实从 2018 年 10 月开始,Flink 社区就开始计划重构这个稳定了多年的 Source 接口[1],以满足更大规模数据以及对接更丰富的 connector 的要求,另外还有更重要的一个目的: 统一流批两种计算模式。重构后的 Source 接口在概念和使用方式上都会有较大不同,无论对 Flink 应用开发者还是 Flink 社区贡献者来说都是十分值得关注的,所以本文将从”为什么要这样设计”的角度来谈谈 Source 接口重构的前因后果。这会涉及到较多的底层架构内容,要求读者有一定的基础或者有探索的兴趣。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>Flink DataStream 关联维表实战</title>
<link href="https://link3280.github.io/2020/01/16/Flink-DataStream-%E5%85%B3%E8%81%94%E7%BB%B4%E8%A1%A8%E5%AE%9E%E6%88%98/"/>
<id>https://link3280.github.io/2020/01/16/Flink-DataStream-关联维表实战/</id>
<published>2020-01-16T12:24:09.000Z</published>
<updated>2020-01-16T12:25:31.468Z</updated>
<content type="html"><![CDATA[<p>上篇博客提到 Flink SQL 如何 Join 两个数据流,有读者反馈说如果不打算用 SQL 或者想自己实现底层操作,那么如何基于 DataStream API 来关联维表呢?实际上由于 Flink DataStream API 的灵活性,实现这个需求的方式是非常多样的,但是大部分用户很难在设计架构时就考虑得很全面,可能会走不少弯路。针对于此,笔者根据工作经验以及社区资源整理了用 DataStream 实现 Join 维表的常见方式,并给每种的方式优劣和适用场景给出一点可作为参考的个人观点。</p><a id="more"></a><h1 id="衡量指标"><a href="#衡量指标" class="headerlink" title="衡量指标"></a>衡量指标</h1><p>总体来讲,关联维表有三个基础的方式:实时数据库查找关联(Per-Record Reference Data Lookup)、预加载维表关联(Pre-Loading of Reference Data)和维表变更日志关联(Reference Data Change Stream),而根据实现上的优化可以衍生出多种关联方式,且这些优化还可以灵活组合产生不同效果(不过为了简单性这里不讨论同时应用多种优化的实现方式)。对于不同的关联方式,我们可以从以下 7 个关键指标来衡量(每个指标的得分将以 1-5 五档来表示):</p><ol><li>实现简单性: 设计是否足够简单,易于迭代和维护。</li><li>吞吐量: 性能是否足够好。</li><li>维表数据的实时性: 维度表的更新是否可以立刻对作业可见。</li><li>数据库的负载: 是否对外部数据库造成较大的负载(负载越低分越高)。</li><li>内存资源占用: 是否需要大量内存来缓存维表数据(内存占用越少分越高)。</li><li>可拓展性: 在更大规模的数据下会不会出现瓶颈。</li><li>结果确定性: 在数据延迟或者数据重放情况下,是否可以得到一致的结果。</li></ol><p>和大多数架构设计一样,这三类关联方式不存在绝对的好坏,更多的是针对业务场景在各指标上的权衡取舍,因此这里的得分也仅仅是针对通用场景来说。</p><h1 id="实时数据库查找关联"><a href="#实时数据库查找关联" class="headerlink" title="实时数据库查找关联"></a>实时数据库查找关联</h1><p>实时数据库查找关联是在 DataStream API 用户函数中直接访问数据库来进行关联的方式。这种方式通常开发量最小,但一般会给数据库带来很大的压力,而且因为关联是基于 Processing Time 的,如果数据有延迟或者重放,会得到和原来不一致的数据。</p><h2 id="同步数据库查找关联"><a href="#同步数据库查找关联" class="headerlink" title="同步数据库查找关联"></a>同步数据库查找关联</h2><p>同步实时数据库查找关联是最为简单的关联方式,只需要在一个 Map 或者 FlatMap 函数中访问数据库,处理好关联逻辑后,将结果数据输出。</p><p><center><img src="/img/flink-datastream-join/img1.sync-db-lookup.png" alt="图1.同步数据库查找关联架构" title="图1.同步数据库查找关联架构"></center></p><p>这种方式的主要优点在于实现简单、不需要额外内存且维表的更新延迟很低,然而缺点也很明显: 1. 因为每条数据都需要请求一次数据库,给数据库造成的压力很大;2. 访问数据库是同步调用,导致 subtak 线程会被阻塞,影响吞吐量;3. 关联是基于 Processing Time 的,结果并不具有确定性;4. 瓶颈在数据库端,但实时计算的流量通常远大于普通数据库的设计流量,因此可拓展性比较低。</p><p><center><img src="/img/flink-datastream-join/img2.sync-db-lookup.png" alt="图2.同步数据库查找关联关键指标" title="图2.同步数据库查找关联关键指标"></center></p><p>从应用场景来说,同步数据库查找关联可以用于流量比较低的作业,但通常不是最好的选择。</p><h2 id="异步数据库查找关联"><a href="#异步数据库查找关联" class="headerlink" title="异步数据库查找关联"></a>异步数据库查找关联</h2><p>异步数据库查找关联是通过 AsyncIO[2]来访问外部数据库的方式。利用数据库提供的异步客户端,AsyncIO 可以并发地处理多个请求,很大程度上减少了对 subtask 线程的阻塞。</p><p>因为数据库请求响应时长是不确定的,可能导致后输入的数据反而先完成计算,所以 AsyncIO 提供有序和无序两种输出模式,前者会按请求返回顺序输出数据,后者则会缓存提前完成计算的数据,并按输入顺序逐个输出结果。</p><p><center><img src="/img/flink-datastream-join/img3.async-db-lookup.png" alt="图3.异步数据库查找关联架构" title="图3.异步数据库查找关联架构"></center></p><p>比起同步数据库查找关联,异步数据库查找关联稍微复杂一点,但是大部分的逻辑都由 Flink AsyncIO API 封装,因此总体来看还是比较简单。然而,有序输出模式下的 AsyncIO 会需要缓存数据,且这些数据会被写入 checkpoint,因此在内容资源方面的得分会低一点。另一方面,同步数据库查找关联的吞吐量问题得到解决,但仍不可避免地有数据库负载高和结果不确定两个问题。</p><p><center><img src="/img/flink-datastream-join/img4.async-db-lookup.png" alt="图4.异步数据库查找关联关键指标" title="图4.异步数据库查找关联关键指标"></center></p><p>从应用场景来说,异步数据库查找关联比较适合流量低的实时计算。</p><h2 id="带缓存的数据库查找关联"><a href="#带缓存的数据库查找关联" class="headerlink" title="带缓存的数据库查找关联"></a>带缓存的数据库查找关联</h2><p>为了解决上述两种关联方式对数据库造成太大压力的问题,可以引入一层缓存来减少直接对数据库的请求。缓存并一般不需要通过 checkpoint 机制持久化,因此简单地用一个 WeakHashMap 或者 Guava Cache 就可以实现。</p><p><center><img src="/img/flink-datastream-join/img5.cached-db-lookup.png" alt="图5.带缓存的数据库查找关联架构" title="图5.带缓存的数据库查找关联架构"></center></p><p>虽然在冷启动的时候仍会给数据库造成一定压力,但后续取决于缓存命中率,数据库的压力将得到一定程度的缓解。然而使用缓存带来的问题是维表的更新并不能及时反应到关联操作上,当然这也和缓存剔除的策略有关,需要根据维度表更新频率和业务对过时维表数据的容忍程度来设计。</p><p><center><img src="/img/flink-datastream-join/img6.cached-db-lookup.png" alt="图6.带缓存的数据库查找关联关键指标" title="图6.带缓存的数据库查找关联关键指标"></center></p><p>总而言之,带缓存的数据库查找关联适合于流量比较低,且对维表数据实时性要求不太高或维表更新比较少的业务场景。</p><h1 id="预加载维表关联"><a href="#预加载维表关联" class="headerlink" title="预加载维表关联"></a>预加载维表关联</h1><p>相比起实时数据库查找在运行期间为每条数据访问一次数据库,预加载维表关联是在作业启动时就将维表读到内存中,而在后续运行期间,每条数据都会和内存中的维表进行关联,而不会直接触发对数据的访问。与带缓存的实时数据库查找关联相比,区别是后者如果不命中缓存还可以 fallback 到数据库访问,而前者如果不名中则会关联不到数据。</p><h2 id="启动预加载维表"><a href="#启动预加载维表" class="headerlink" title="启动预加载维表"></a>启动预加载维表</h2><p>启动预加载维表是最为简单的一种方式,即在作业初始化的时候,比如用户函数的 <code>open()</code> 方法,直接从数据库将维表拷贝到内存中。维表并不需要用 State 来保存,因为无论是手动重启或者是 Flink 的错误重试机制导致的重启,<code>open()</code> 方法都会被执行,从而得到最新的维表数据。</p><p><center><img src="/img/flink-datastream-join/img7.startup-preloading.png" alt="图7.启动预加载维表架构" title="图7.启动预加载维表架构"></center></p><p>启动预加载维表对数据库的压力只持续很短时间,但因为是拷贝整个维表所以压力是很大的,而换来的优势是在运行期间不需要再访问数据库,可以提高效率,有点类似离线计算。相对地,问题在于运行期间维表数据不能更新,且对 TaskManager 内存的要求比较高。</p><p><center><img src="/img/flink-datastream-join/img8.startup-preloading.png" alt="图8.启动预加载维表关键指标" title="图8.启动预加载维表关键指标"></center></p><p>启动预加载维表适合于维表比较小、变更实时性要求不高的场景,比如根据 ip 库解析国家地区,如果 ip 库有新版本,重启作业即可。</p><h2 id="启动预加载分区维表"><a href="#启动预加载分区维表" class="headerlink" title="启动预加载分区维表"></a>启动预加载分区维表</h2><p>对于维表比较大的情况,可以启动预加载维表基础之上增加分区功能。简单来说就是将数据流按字段进行分区,然后每个 Subtask 只需要加在对应分区范围的维表数据。值得注意的是,这里的分区方式并不是用 keyby 这种通用的 hash 分区,而是需要根据业务数据定制化分区策略,然后调用 <code>DataStream#partitionCustom</code>。比如按照 <code>userId</code> 等区间划分,0-999 划分到 subtask 1,1000-1999 划分到 subtask 2,以此类推。而在 <code>open()</code> 方法中,我们再根据 subtask 的 id 和总并行度来计算应该加载的维表数据范围。</p><p><center><img src="/img/flink-datastream-join/img9.startup-partition-preloading.png" alt="图9.启动预加载分区维表架构" title="图9.启动预加载分区维表架构"></center></p><p>通过这种分区方式,维表的大小上限理论上可以线性拓展,解决了维表大小受限于单个 TaskManager 内存的问题(现在是取决于所有 TaskManager 的内存总量),但同时给带来设计和维护分区策略的复杂性。</p><p><center><img src="/img/flink-datastream-join/img10.startup-partition-preloading.png" alt="图10.启动预加载分区维表关键指标" title="图10.启动预加载分区维表关键指标"></center></p><p>总而言之,启动预加载分区维表适合维表比较大而变更实时性要求不高的场景,比如用户点击数据关联用户所在地。</p><h2 id="启动预加载维表并定时刷新"><a href="#启动预加载维表并定时刷新" class="headerlink" title="启动预加载维表并定时刷新"></a>启动预加载维表并定时刷新</h2><p>除了维表大小的限制,启动预加载维表的另一个主要问题在于维度数据的更新,我们可以通过引入定时刷新机制的办法来缓解这个问题。定时刷新可以通过 Flink ProcessFucntion 提供的 Timer 或者直接在 <code>open()</code> 初始化一个线程(池)来做这件事。不过 Timer 要求 KeyedStream,而上述的 <code>DataStream#partitionCustom</code> 并不会返回一个 KeyedStream,因此两者并不兼容。而如果使用额外线程定时刷新的办法则不受这个限制。</p><p><center><img src="/img/flink-datastream-join/img11.startup-preloading-refresh.png" alt="图11.启动预加载维表并定时刷新架构" title="图11.启动预加载维表并定时刷新架构"></center></p><p>比起基础的启动预加载维表 ,这种方式在于引入比较小复杂性的情况下大大缓解了的维度表更新问题,但也给维表数据库带来更多压力,因为每次 reload 的时候都是一次请求高峰。</p><p><center><img src="/img/flink-datastream-join/img12.startup-preloading-refresh.png" alt="图12.启动预加载维表并定时刷新关键指标" title="图12.启动预加载维表并定时刷新关键指标"></center></p><p>启动预加载维表和定时刷新的组合适合维表变更实时性要求不是特别高的场景。取决于定时刷新的频率和数据库的性能,这种方式可以满足大部分关联维表的业务。</p><h2 id="启动预加载维表-实时数据库查找"><a href="#启动预加载维表-实时数据库查找" class="headerlink" title="启动预加载维表 + 实时数据库查找"></a>启动预加载维表 + 实时数据库查找</h2><p>启动预加载维表还可以和实时数据库查找混合使用,即将预加载的维表作为缓存给实时关联时使用,若未名中则 fallback 到数据库查找。</p><p><center><img src="/img/flink-datastream-join/img13.startup-preloading-realtime-lookup.png" alt="图13.启动预加载维表结合实时数据库查找架构" title="图13.启动预加载维表结合实时数据库查找架构"></center></p><p>这种方式实际是带缓存的数据库查找关联的衍生,不同之处在于相比冷启动时未命中缓存导致的多次实时数据库访问,该方式直接批量拉取整个维表效率更高,但也有可能拉取到不会访问到的多余数据。下面雷达图中显示的是用异步数据库查找,如果是同步数据库查找吞吐量上会低一些。</p><p><center><img src="/img/flink-datastream-join/img14.startup-preloading-realtime-lookup.png" alt="图14.启动预加载维表结合实时数据库查找关键指标" title="图14.启动预加载维表结合实时数据库查找关键指标"></center></p><p>这种方式和带缓存的实时数据库查找关联基本相同,适合流量比较低,且对维表数据实时性要求不太高或维表更新比较少的业务场景。</p><h1 id="维表变更日志关联"><a href="#维表变更日志关联" class="headerlink" title="维表变更日志关联"></a>维表变更日志关联</h1><p>不同于上述两者将维表作为静态表关联的方式,维表变更日志关联将维表以 changelog 数据流的方式表示,从而将维表关联转变为两个数据流的 join。这里的 changelog 数据流类似于 MySQL 的 binlog,通常需要维表数据库端以 push 的方式将日志写到 Kafka 等消息队列中。Changelog 数据流称为 build 数据流,另外待关联的主要数据流成为 probe 数据流。</p><p>维表变更日志关联的好处在于可以获取某个 key 数据变化的时间,从而使得我们能在关联中使用 Event Time(当然也可以使用 Processing Time)。</p><h2 id="Processing-Time-维表变更日志关联"><a href="#Processing-Time-维表变更日志关联" class="headerlink" title="Processing Time 维表变更日志关联"></a>Processing Time 维表变更日志关联</h2><p>如果基于 Processing Time 做关联,我们可以利用 keyby 将两个数据流中关联字段值相同的数据划分到 KeyedCoProcessFunction 的同一个分区,然后用 ValueState 或者 MapState 将维表数据保存下来。在普通数据流的一条记录进到函数时,到 State 中查找有无符合条件的 join 对象,若有则关联输出结果,若无则根据 join 的类型决定是直接丢弃还是与空值关联。这里要注意的是,State 的大小要尽量控制好。首先是只保存每个 key 最新的维度数据值,其次是要给 State 设置好 TTL,让 Flink 可以自动清理。</p><p><center><img src="/img/flink-datastream-join/img15.processing-time-join.png" alt="图15.Processing Time 维表变更日志关联架构" title="图15.Processing Time 维表变更日志关联架构"></center></p><p>基于 Processing Time 的维表变更日志关联优点是不需要直接请求数据库,不会对数据库造成压力;缺点是比较复杂,相当于使用 changelog 在 Flink 应用端重新构建一个维表,会占用一定的 CPU 和比较多的内存和磁盘资源。值得注意的是,我们可以利用 Flink 提供的 RocksDB StateBackend,将大部分的维表数据存在磁盘而不是内存中,所以并不会占用很高的内存。不过基于 Processing Time 的这种关联对两个数据流的延迟要求比较高,否则如果其中一个数据流出现 lag 时,关联得到的结果可能并不是我们想要的,比如可能会关联到未来时间点的维表数据。</p><p><center><img src="/img/flink-datastream-join/img16.processing-time-join.png" alt="图16.Processing Time 维表变更日志关联关键指标" title="图16.Processing Time 维表变更日志关联关键指标"></center></p><p>基于 Processing Time 的维表变更日志关联比较适用于不便直接访问数据的场景(比如维表数据库是业务线上数据库,出于安全和负载的原因不能直接访问),或者对维表的变更实时性要求比较高的场景(但因为数据准确性的关系,一般用下文的 Event Time 关联会更好)。</p><h2 id="Event-Time-维表变更日志关联"><a href="#Event-Time-维表变更日志关联" class="headerlink" title="Event Time 维表变更日志关联"></a>Event Time 维表变更日志关联</h2><p>基于 Event Time 的维表关联实际上和基于 Processing Time 的十分相似,不同之处在于我们将维表 changelog 的多个时间版本都记录下来,然后每当一条记录进来,我们会找到对应时间版本的维表数据来和它关联,而不是总用最新版本,因此延迟数据的关联准确性大大提高。不过因为目前 State 并没有提供 Event Time 的 TTL,因此我们需要自己设计和实现 State 的清理策略,比如直接设置一个 Event Time Timer(但要注意 Timer 不能太多导致性能问题),再比如对于单个 key 只保存最近的 10 个版本,当有更新版本的维表数据到达时,要清理掉最老版本的数据。</p><p><center><img src="/img/flink-datastream-join/img17.event-time-join.png" alt="图17.Event Time 维表变更日志关联架构" title="图17.Event Time 维表变更日志关联架构"></center></p><p>基于 Event Time 的维表变更日志关联相对基于 Processing Time 的方式来说是一个改进,虽然多个维表版本导致空间资源要求更大,但确保准确性对于大多数场景来说都是十分重要的。相比 Processing Time 对两个数据的延迟都有要求,Event Time 要求 build 数据流的延迟低,否则可能一条数据到达时关联不到对应维表数据或者关联了一个过时版本的维表数据,</p><p><center><img src="/img/flink-datastream-join/img18.event-time-join.png" alt="图18.Event Time 维表变更日志关联关键指标" title="图18.Event Time 维表变更日志关联关键指标"></center></p><p>基于 Event Time 的维表变更日志关联比较适合于维表变更比较多且对变更实时性要求较高的场景 同时也适合于不便直接访问数据库的场景。</p><h2 id="Temporal-Table-Join"><a href="#Temporal-Table-Join" class="headerlink" title="Temporal Table Join"></a>Temporal Table Join</h2><p>Temporal Table Join 是 Flink SQL/Table API 的原生支持,它对两个数据流的输入都进行了缓存,因此比起上述的基于 Event Time 的维表变更日志关联,它可以容忍任意数据流的延迟,数据准确性更好。Temporal Table Join 在 SQL/Table API 使用时是十分简单的,但如果想在 DataStream API 中使用,则需要自己实现对应的逻辑。</p><p>总体思路是使用一个 CoProcessFunction,将 build 数据流以时间版本为 key 保存在 MapState 中(与基于 Event Time 的维表变更日志关联相同),再将 probe 数据流和输出结果也用 State 缓存起来(同样以 Event Time 为 key),一直等到 Watermark 提升到它们对应的 Event Time,才把结果输出和将两个数据流的输入清理掉。</p><p>这个 Watermark 触发很自然地是用 Event Time Timer 来实现,但要注意不要为每条数据都设置一遍 Timer,因为一旦 Watermark 提升会触发很多个 Timer 导致性能急剧下降。比较好的实践是为每个 key 只注册一个 Timer。实现上可以记录当前未处理的最早一个 Event Time,并用来注册 Timer。当前 Watermark。每当 Watermark 触发 Timer 时,我们检查处理掉未处理的最早 Event Time 到当前 Event Time 的所有数据,并将未处理的最早 Event Time 更新为当前时间。</p><p><center><img src="/img/flink-datastream-join/img19.temporal-table-join.png" alt="图19.Temporal Table Join 架构" title="图19.Temporal Table Join 架构"></center></p><p>Temporal Table Join 的好处在于对于两边数据流的延迟的容忍度较大,但作为代价会引入一定的输出结果的延迟,这也是基于 Watermark 机制的计算的常见问题,或者说,妥协。另外因为吞吐量较大的 probe 数据流也需要缓存,Flink 应用对空间资源的需求会大很多。最好,要注意的是如果维表变更太慢,导致 Watermark 提升太慢,会导致 probe 数据流被大量缓存,所以最好要确保 build 数据流尽量实时,同时给 Source 设置一个比较短的 idle timeout。</p><p><center><img src="/img/flink-datastream-join/img20.temporal-table-join.png" alt="图20.Temporal Table Join 关键指标" title="图20.Temporal Table Join 关键指标"></center></p><p>Temporal Table Join 这种方式最为复杂,但数据准确性最好,适合一些对数据准确性要求高且可以容忍一定延迟(一般分钟级别)的关键业务。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>用 Flink DataStream API 实现关联维表的方式十分丰富,可以直接访问数据库查找(实时数据库查找关联),可以启动时就将全量维表读到内存(预加载维表关联),也可以通过维表的 changelog 在 Flink 应用端实时构建一个新的维表(维表变更日志关联)。我们可以从实现简单性、吞吐量、维表数据的实时性、数据库的负载、内存资源占用、可拓展性和结果确定性这 7 个维度来衡量一个具体实现方式,并根据业务需求来选择最合适的实现。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://www.ververica.com/about/events-talks" target="_blank" rel="external">WEBINAR: 99 Ways to Enrich Streaming Data with Apache Flink</a></li><li><a href="https://ci.apache.org/projects/flink/flink-docs-release-1.9/dev/stream/operators/asyncio.html" target="_blank" rel="external">Asynchronous I/O for External Data Access</a></li></ol>]]></content>
<summary type="html">
<p>上篇博客提到 Flink SQL 如何 Join 两个数据流,有读者反馈说如果不打算用 SQL 或者想自己实现底层操作,那么如何基于 DataStream API 来关联维表呢?实际上由于 Flink DataStream API 的灵活性,实现这个需求的方式是非常多样的,但是大部分用户很难在设计架构时就考虑得很全面,可能会走不少弯路。针对于此,笔者根据工作经验以及社区资源整理了用 DataStream 实现 Join 维表的常见方式,并给每种的方式优劣和适用场景给出一点可作为参考的个人观点。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>Flink SQL 如何实现数据流的 Join</title>
<link href="https://link3280.github.io/2019/12/15/Flink-SQL-%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E6%95%B0%E6%8D%AE%E6%B5%81%E7%9A%84-Join/"/>
<id>https://link3280.github.io/2019/12/15/Flink-SQL-如何实现数据流的-Join/</id>
<published>2019-12-15T09:04:57.000Z</published>
<updated>2019-12-17T13:34:58.666Z</updated>
<content type="html"><![CDATA[<p>无论在 OLAP 还是 OLTP 领域,Join 都是业务常会涉及到且优化规则比较复杂的 SQL 语句。对于离线计算而言,经过数据库领域多年的积累 Join 的语义以及实现已经十分成熟,然而对于近年来刚兴起的 Streaming SQL 来说 Join 却处于刚起步的状态。其中最为关键的问题在于 Join 的实现依赖于缓存整个数据集,而 Streaming SQL Join 的对象却是无限的数据流,内存压力和计算效率在长期运行来说都是不可避免的问题。下文将结合 SQL 的发展解析 Flink SQL 是如何解决这些问题并实现两个数据流的 Join。</p><a id="more"></a><h1 id="离线-Batch-SQL-Join-的实现"><a href="#离线-Batch-SQL-Join-的实现" class="headerlink" title="离线 Batch SQL Join 的实现"></a>离线 Batch SQL Join 的实现</h1><p>传统的离线 Batch SQL (面向有界数据集的 SQL)有三种基础的实现方式,分别是 Nested-loop Join、Sort-Merge Join 和 Hash Join。</p><p>Nested-loop Join 最为简单直接,将两个数据集加载到内存,并用内嵌遍历的方式来逐个比较两个数据集内的元素是否符合 Join 条件。Nested-loop Join 虽然时间效率以及空间效率都是最低的,但胜在比较灵活适用范围广,因此其变体 BNL 常被传统数据库用作为 Join 的默认基础选项。</p><p>Sort-Merge Join 顾名思义,分为两个 Sort 和 Merge 阶段。首先将两个数据集进行分别排序,然后对两个有序数据集分别进行遍历和匹配,类似于归并排序的合并。值得注意的是,Sort-Merge 只适用于 Equi-Join(Join 条件均使用等于作为比较算子)。Sort-Merge Join 要求对两个数据集进行排序,成本很高,通常作为输入本就是有序数据集的情况下的优化方案。</p><p>Hash Join 同样分为两个阶段,首先将一个数据集转换为 Hash Table,然后遍历另外一个数据集元素并与 Hash Table 内的元素进行匹配。第一阶段和第一个数据集分别称为 build 阶段和 build table,第二个阶段和第二个数据集分别称为 probe 阶段和 probe table。Hash Join 效率较高但对空间要求较大,通常是作为 Join 其中一个表为适合放入内存的小表的情况下的优化方案。和 Sort-Merge Join 类似,Hash Join 也只适用于 Equi-Join。</p><h1 id="实时-Streaming-SQL-Join"><a href="#实时-Streaming-SQL-Join" class="headerlink" title="实时 Streaming SQL Join"></a>实时 Streaming SQL Join</h1><p>相对于离线的 Join,实时 Streaming SQL(面向无界数据集的 SQL)无法缓存所有数据,因此 Sort-Merge Join 要求的对数据集进行排序基本是无法做到的,而 Nested-loop Join 和 Hash Join 经过一定的改良则可以满足实时 SQL 的要求。</p><p>我们通过例子来看基本的 Nested Join 在实时 Streaming SQL 的基础实现(案例及图来自 Piotr Nowojski 在 Flink Forward San Francisco 的分享[2])。</p><p><center><p><img src="/img/streaming-join/img1.join-in-continuous-query-1.png" alt="图1. Join-in-continuous-query-1" title="图1. Join-in-continuous-query-1"></p></center></p><p></p><p>Table A 有 <code>1</code>、<code>42</code> 两个元素,Table B 有 <code>42</code> 一个元素,所以此时的 Join 结果会输出 42。</p><p><center><p><img src="/img/streaming-join/img2.join-in-continuous-query-2.png" alt="图2. Join-in-continuous-query-2" title="图2. Join-in-continuous-query-2"></p></center></p><p></p><p>接着 Table B 依次接受到三个新的元素,分别是 <code>7</code>、<code>3</code>、<code>1</code>。因为 <code>1</code> 匹配到 Table A 的元素,因此结果表再输出一个元素 <code>1</code>。</p><p><center><p><img src="/img/streaming-join/img3.join-in-continuous-query-3.png" alt="图3. Join-in-continuous-query-3" title="图3. Join-in-continuous-query-3"></p></center></p><p></p><p>随后 Table A 出现新的输入 <code>2</code>、<code>3</code>、<code>6</code>,<code>3</code> 匹配到 Table B 的元素,因此再输出 <code>3</code> 到结果表。</p><p>可以看到在 Nested-Loop Join 中我们需要保存两个输入表的内容,而随着时间的增长 Table A 和 Table B 需要保存的历史数据无止境地增长,导致很不合理的内存磁盘资源占用,而且单个元素的匹配效率也会越来越低。类似的问题也存在于 Hash Join 中。</p><p>那么有没有可能设置一个缓存剔除策略,将不必要的历史数据及时清理呢?答案是肯定的,关键在于缓存剔除策略如何实现,这也是 Flink SQL 提供的三种 Join 的主要区别。</p><h1 id="Flink-SQL-的-Join"><a href="#Flink-SQL-的-Join" class="headerlink" title="Flink SQL 的 Join"></a>Flink SQL 的 Join</h1><h2 id="Regular-Join"><a href="#Regular-Join" class="headerlink" title="Regular Join"></a>Regular Join</h2><p>Regular Join 是最为基础的没有缓存剔除策略的 Join。Regular Join 中两个表的输入和更新都会对全局可见,影响之后所有的 Join 结果。举例,在一个如下的 Join 查询里,Orders 表的新纪录会和 Product 表所有历史纪录以及未来的纪录进行匹配。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div></pre></td><td class="code"><pre><div class="line">SELECT * FROM Orders</div><div class="line">INNER JOIN Product</div><div class="line">ON Orders.productId = Product.id</div></pre></td></tr></table></figure><p>因为历史数据不会被清理,所以 Regular Join 允许对输入表进行任意种类的更新操作(insert、update、delete)。然而因为资源问题 Regular Join 通常是不可持续的,一般只用做有界数据流的 Join。</p><h2 id="Time-Windowed-Join"><a href="#Time-Windowed-Join" class="headerlink" title="Time-Windowed Join"></a>Time-Windowed Join</h2><p>Time-Windowed Join 利用窗口的给两个输入表设定一个 Join 的时间界限,超出时间范围的数据则对 JOIN 不可见并可以被清理掉。值得注意的是,这里涉及到的一个问题是时间的语义,时间可以是指计算发生的系统时间(即 Processing Time),也可以是指从数据本身的时间字段提取的 Event Time。如果是 Processing Time,Flink 根据系统时间自动划分 Join 的时间窗口并定时清理数据;如果是 Event Time,Flink 分配 Event Time 窗口并依据 Watermark 来清理数据。</p><p>以更常用的 Event Time Windowed Join 为例,一个将 Orders 订单表和 Shipments 运输单表依据订单时间和运输时间 Join 的查询如下:</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div></pre></td><td class="code"><pre><div class="line">SELECT *</div><div class="line">FROM </div><div class="line">Orders o, </div><div class="line">Shipments s</div><div class="line">WHERE </div><div class="line">o.id = s.orderId AND</div><div class="line">s.shiptime BETWEEN o.ordertime AND o.ordertime + INTERVAL '4' HOUR</div></pre></td></tr></table></figure><p>这个查询会为 Orders 表设置了 <code>o.ordertime > s.shiptime- INTERVAL '4'HOUR</code> 的时间下界(图4),</p><p><center><p><img src="/img/streaming-join/img4.time-window-orders-lower-bound.png" alt="图4. Time-Windowed Join 的时间下界 - Orders 表" title="图4. Time-Windowed Join 的时间下界 - Orders 表"></p></center></p><p></p><p>并为 Shipmenets 表设置了 <code>s.shiptime >= o.ordertime</code> 的时间下界(图5)。</p><p><center><p><img src="/img/streaming-join/img5.time-window-shipment-lower-bound.png" alt="图5. Time-Windowed Join 的时间下界 - Shipment 表" title="图5. Time-Windowed Join 的时间下界 - Shipment 表"></p></center></p><p></p><p>因此两个输入表都只需要缓存在时间下界以上的数据,将空间占用维持在合理的范围。</p><p>不过虽然底层实现上没有问题,但如何通过 SQL 语法定义时间仍是难点。尽管在实时计算领域 Event Time、Processing Time、Watermark 这些概念已经成为业界共识,但在 SQL 领域对时间数据类型的支持仍比较弱[4]。因此,定义 Watermark 和时间语义都需要通过编程 API 的方式完成,比如从 DataStream 转换至 Table 时定义,而不能单纯靠 SQL 完成。这方面的支持 Flink 社区计划通过拓展 SQL 方言来完成,感兴趣的读者可以通过 FLIP-66[7] 来追踪进度。</p><h2 id="Temporal-Table-Join"><a href="#Temporal-Table-Join" class="headerlink" title="Temporal Table Join"></a>Temporal Table Join</h2><p>虽然 Timed-Windowed Join 解决了资源问题,但也限制了使用场景: Join 两个输入流都必须有时间下界,超过之后则不可访问。这对于很多 Join 维表的业务来说是不适用的,因为很多情况下维表并没有时间界限。针对这个问题,Flink 提供了 Temporal Table Join 来满足用户需求。</p><p>Temporal Table Join 类似于 Hash Join,将输入分为 Build Table 和 Probe Table。前者一般是纬度表的 changelog,后者一般是业务数据流,典型情况下后者的数据量应该远大于前者。在 Temporal Table Join 中,Build Table 是一个基于 append-only 数据流的带时间版本的视图,所以又称为 Temporal Table。Temporal Table 要求定义一个主键和用于版本化的字段(通常就是 Event Time 时间字段),以反映记录内容在不同时间的内容。</p><p>比如典型的一个例子是对商业订单金额进行汇率转换。假设有一个 Oders 流记录订单金额,需要和 RatesHistory 汇率流进行 Join。RatesHistory 代表不同货币转为日元的汇率,每当汇率有变化时就会有一条更新记录。两个表在某一时间节点内容如下:</p><p><center><p><img src="/img/streaming-join/img6.temporal-table-join-example.png" alt="图6. Temporal Table Join Example" title="图6. Temporal Table Join Example]"></p></center></p><p></p><p>我们将 RatesHistory 注册为一个名为 Rates 的 Temporal Table,设定主键为 currency,版本字段为 time。</p><p><center><p><img src="/img/streaming-join/img7.temporal-table-registration.png" alt="图7. Temporal Table Registration" title="图7. Temporal Table Registration]"></p></center></p><p></p><p>此后给 Rates 指定时间版本,Rates 则会基于 RatesHistory 来计算符合时间版本的汇率转换内容。</p><p><center><p><img src="/img/streaming-join/img8.temporal-table-content.png" alt="图8. Temporal Table Content" title="图8. Temporal Table Content]"></p></center></p><p></p><p>在 Rates 的帮助下,我们可以将业务逻辑用以下的查询来表达:</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div></pre></td><td class="code"><pre><div class="line">SELECT </div><div class="line">o.amount * r.rate</div><div class="line">FROM</div><div class="line">Orders o,</div><div class="line">LATERAL Table(Rates(o.time)) r</div><div class="line">WHERE</div><div class="line">o.currency = r.currency</div></pre></td></tr></table></figure><p>值得注意的是,不同于在 Regular Join 和 Time-Windowed Join 中两个表是平等的,任意一个表的新记录都可以与另一表的历史记录进行匹配,在 Temporal Table Join 中,Temoparal Table 的更新对另一表在该时间节点以前的记录是不可见的。这意味着我们只需要保存 Build Side 的记录直到 Watermark 超过记录的版本字段。因为 Probe Side 的输入理论上不会再有早于 Watermark 的记录,这些版本的数据可以安全地被清理掉。</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>实时领域 Streaming SQL 中的 Join 与离线 Batch SQL 中的 Join 最大不同点在于无法缓存完整数据集,而是要给缓存设定基于时间的清理条件以限制 Join 涉及的数据范围。根据清理策略的不同,Flink SQL 分别提供了 Regular Join、Time-Windowed Join 和 Temporal Table Join 来应对不同业务场景。</p><p>另外,尽管在实时计算领域 Join 可以灵活地用底层编程 API 来实现,但在 Streaming SQL 中 Join 的发展仍处于比较初级的阶段,其中关键点在于如何将时间属性合适地融入 SQL 中,这点 ISO SQL 委员会制定的 SQL 标准并没有给出完整的答案。或者从另外一个角度来讲,作为 Streaming SQL 最早的开拓者之一,Flink 社区很适合探索出一套合理的 SQL 语法反过来贡献给 ISO。</p><h1 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h1><ol><li><a href="https://flink.apache.org/2019/05/14/temporal-tables.html" target="_blank" rel="external">Flux capacitor, huh? Temporal Tables and Joins in Streaming SQL</a></li><li><a href="https://www.slideshare.net/FlinkForward/flink-forward-san-francisco-2019-how-to-join-two-data-streams-piotr-nowojski" target="_blank" rel="external">How to Join Two Data Streams? - Piotr Nowojski</a></li><li><a href="https://ci.apache.org/projects/flink/flink-docs-release-1.9/dev/table/streaming/joins.html#joins-in-continuous-queries" target="_blank" rel="external">Joins in Continuous Queries</a></li><li><a href="https://cs.ulb.ac.be/public/_media/teaching/infoh415/tempfeaturessql2011.pdf" target="_blank" rel="external">Temporal features in SQL:2011</a></li><li><a href="https://mysqlserverteam.com/hash-join-in-mysql-8/" target="_blank" rel="external">Hash join in MySQL 8</a></li><li><a href="https://en.wikipedia.org/wiki/SQL:2011" target="_blank" rel="external">SQL:2011</a></li><li><a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-66%3A+Support+Time+Attribute+in+SQL+DDL" target="_blank" rel="external">FLIP-66: Support Time Attribute in SQL DDL</a></li></ol>]]></content>
<summary type="html">
<p>无论在 OLAP 还是 OLTP 领域,Join 都是业务常会涉及到且优化规则比较复杂的 SQL 语句。对于离线计算而言,经过数据库领域多年的积累 Join 的语义以及实现已经十分成熟,然而对于近年来刚兴起的 Streaming SQL 来说 Join 却处于刚起步的状态。其中最为关键的问题在于 Join 的实现依赖于缓存整个数据集,而 Streaming SQL Join 的对象却是无限的数据流,内存压力和计算效率在长期运行来说都是不可避免的问题。下文将结合 SQL 的发展解析 Flink SQL 是如何解决这些问题并实现两个数据流的 Join。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
<category term="SQL" scheme="https://link3280.github.io/tags/SQL/"/>
</entry>
<entry>
<title>如何分析及处理 Flink 反压</title>
<link href="https://link3280.github.io/2019/11/03/Flink-%E5%8F%8D%E5%8E%8B%E5%88%86%E6%9E%90%E5%8F%8A%E5%A4%84%E7%90%86/"/>
<id>https://link3280.github.io/2019/11/03/Flink-反压分析及处理/</id>
<published>2019-11-03T11:49:21.000Z</published>
<updated>2019-11-03T12:06:04.979Z</updated>
<content type="html"><![CDATA[<p>反压(backpressure)是实时计算应用开发中,特别是流式计算中,十分常见的问题。反压意味着数据管道中某个节点成为瓶颈,处理速率跟不上上游发送数据的速率,而需要对上游进行限速。由于实时计算应用通常使用消息队列来进行生产端和消费端的解耦,消费端数据源是 pull-based 的,所以反压通常是从某个节点传导至数据源并降低数据源(比如 Kafka consumer)的摄入速率。</p><a id="more"></a><p>关于 Flink 的反压机制,网上已经有不少博客介绍,中文博客推荐这两篇[1][2]。简单来说,Flink 拓扑中每个节点(Task)间的数据都以阻塞队列的方式传输,下游来不及消费导致队列被占满后,上游的生产也会被阻塞,最终导致数据源的摄入被阻塞。而本文将着重结合官方的博客[4]分享笔者在实践中分析和处理 Flink 反压的经验。</p><h2 id="反压的影响"><a href="#反压的影响" class="headerlink" title="反压的影响"></a>反压的影响</h2><p>反压并不会直接影响作业的可用性,它表明作业处于亚健康的状态,有潜在的性能瓶颈并可能导致更大的数据处理延迟。通常来说,对于一些对延迟要求不太高或者数据量比较小的应用来说,反压的影响可能并不明显,然而对于规模比较大的 Flink 作业来说反压可能会导致严重的问题。</p><p>这是因为 Flink 的 checkpoint 机制,反压还会影响到两项指标: checkpoint 时长和 state 大小。前者是因为 checkpoint barrier 是不会越过普通数据的,数据处理被阻塞也会导致 checkpoint barrier 流经整个数据管道的时长变长,因而 checkpoint 总体时间(End to End Duration)变长。后者是因为为保证 EOS(Exactly-Once-Semantics,准确一次),对于有两个以上输入管道的 Operator,checkpoint barrier 需要对齐(Alignment),接受到较快的输入管道的 barrier 后,它后面数据会被缓存起来但不处理,直到较慢的输入管道的 barrier 也到达,这些被缓存的数据会被放到state 里面,导致 checkpoint 变大。这两个影响对于生产环境的作业来说是十分危险的,因为 checkpoint 是保证数据一致性的关键,checkpoint 时间变长有可能导致 checkpoint 超时失败,而 state 大小同样可能拖慢 checkpoint 甚至导致 OOM (使用 Heap-based StateBackend)或者物理内存使用超出容器资源(使用 RocksDBStateBackend)的稳定性问题。因此,我们在生产中要尽量避免出现反压的情况(顺带一提,为了缓解反压给 checkpoint 造成的压力,社区提出了 FLIP-76: Unaligned Checkpoints[4] 来解耦反压和 checkpoint)。</p><h2 id="定位反压节点"><a href="#定位反压节点" class="headerlink" title="定位反压节点"></a>定位反压节点</h2><p>要解决反压首先要做的是定位到造成反压的节点,这主要有两种办法: 1.通过 Flink Web UI 自带的反压监控面板;2.通过 Flink Task Metrics。前者比较容易上手,适合简单分析,后者则提供了更加丰富的信息,适合用于监控系统。因为反压会向上游传导,这两种方式都要求我们从 Source 节点到 Sink 的逐一排查,直到找到造成反压的根源原因[4]。下面分别介绍这两种办法。</p><h3 id="反压监控面板"><a href="#反压监控面板" class="headerlink" title="反压监控面板"></a>反压监控面板</h3><p>Flink Web UI 的反压监控提供了 SubTask 级别的反压监控,原理是通过周期性对 Task 线程的栈信息采样,得到线程被阻塞在请求 Buffer(意味着被下游队列阻塞)的频率来判断该节点是否处于反压状态。默认配置下,这个频率在 0.1 以下则为 <code>OK</code>,0.1 至 0.5 为 <code>LOW</code>,而超过 0.5 则为 <code>HIGH</code>。</p><center><p><img src="/img/flink-backpressure-handling/back-pressure-sampling-high.png" alt="图1. Flink 1.8 的 Web UI 反压面板" title="图1. Flink 1.8 的 Web UI 反压面板(来自官方博客)"></p></center><p>如果处于反压状态,那么有两种可能性:</p><ol><li>该节点的发送速率跟不上它的产生数据速率。这一般会发生在一条输入多条输出的 Operator(比如 flatmap)。</li><li>下游的节点接受速率较慢,通过反压机制限制了该节点的发送速率。</li></ol><p>如果是第一种状况,那么该节点则为反压的根源节点,它是从 Source Task 到 Sink Task 的第一个出现反压的节点。如果是第二种情况,则需要继续排查下游节点。值得注意的是,反压的根源节点并不一定会在反压面板体现出高反压,因为反压面板监控的是发送端,如果某个节点是性能瓶颈并不会导致它本身出现高反压,而是导致它的上游出现高反压。总体来看,如果我们找到第一个出现反压的节点,那么反压根源要么是就这个节点,要么是它紧接着的下游节点。</p><p>那么如果区分这两种状态呢?很遗憾只通过反压面板是无法直接判断的,我们还需要结合 Metrics 或者其他监控手段来定位。此外如果作业的节点数很多或者并行度很大,由于要采集所有 Task 的栈信息,反压面板的压力也会很大甚至不可用。</p><h3 id="Task-Metrics"><a href="#Task-Metrics" class="headerlink" title="Task Metrics"></a>Task Metrics</h3><p>Flink 提供的 Task Metrics 是更好的反压监控手段,但也要求更加丰富的背景知识。首先我们简单回顾下 Flink 1.5 以后的网路栈,熟悉的读者可以直接跳过。</p><p>TaskManager 传输数据时,不同的 TaskManager 上的两个 Subtask 间通常根据 key 的数量有多个 Channel,这些 Channel 会复用同一个 TaskManager 级别的 TCP 链接,并且共享接收端 Subtask 级别的 Buffer Pool。在接收端,每个 Channl 在初始阶段会被分配固定数量的 Exclusive Buffer,这些 Buffer 会被用于存储接受到的数据,交给 Operator 使用后再次被释放。Channel 接收端空闲的 Buffer 数量称为 Credit,Credit 会被定时同步给发送端被后者用于决定发送多少个 Buffer 的数据。在流量较大时,Channel 的 Exclusive Buffer 可能会被写满,此时 Flink 会向 Buffer Pool 申请剩余的 Floating Buffer。这些 Floating Buffer 属于备用 Buffer,哪个 Channel 需要就去哪里。而在 Channel 发送端,一个 Subtask 所有的 Channel 会共享同一个 Buffer Pool,这边就没有区分 Exclusive Buffer 和 Floating Buffer。</p><center><p><img src="/img/flink-backpressure-handling/credit-based-network.png" alt="图2. Flink Credit-Based 网络" title="图2. Flink Credit-Based 网络"></p></center><p>我们在监控反压时会用到的 Metrics 主要和 Channel 接受端的 Buffer 使用率有关,最为有用的是以下几个 Metrics:</p><table><thead><tr><th>Metris</th><th>描述</th></tr></thead><tbody><tr><td>outPoolUsage</td><td>发送端 Buffer 的使用率</td></tr><tr><td>inPoolUsage</td><td>接收端 Buffer 的使用率</td></tr><tr><td>floatingBuffersUsage(1.9 以上)</td><td>接收端 Floating Buffer 的使用率</td></tr><tr><td>exclusiveBuffersUsage (1.9 以上)</td><td>接收端 Exclusive Buffer 的使用率</td></tr></tbody></table><p>其中 inPoolUsage 等于 floatingBuffersUsage 与 exclusiveBuffersUsage 的总和。</p><p>分析反压的大致思路是:如果一个 Subtask 的发送端 Buffer 占用率很高,则表明它被下游反压限速了;如果一个 Subtask 的接受端 Buffer 占用很高,则表明它将反压传导至上游。反压情况可以根据以下表格进行对号入座(图片来自官网):</p><center><p><img src="/img/flink-backpressure-handling/1.8-backpressure-table.png" alt="图3. 反压分析表" title="图3. 反压分析表"></p></center><p>outPoolUsage 和 inPoolUsage 同为低或同为高分别表明当前 Subtask 正常或处于被下游反压,这应该没有太多疑问。而比较有趣的是当 outPoolUsage 和 inPoolUsage 表现不同时,这可能是出于反压传导的中间状态或者表明该 Subtask 就是反压的根源。如果一个 Subtask 的 outPoolUsage 是高,通常是被下游 Task 所影响,所以可以排查它本身是反压根源的可能性。如果一个 Subtask 的 outPoolUsage 是低,但其 inPoolUsage 是高,则表明它有可能是反压的根源。因为通常反压会传导至其上游,导致上游某些 Subtask 的 outPoolUsage 为高,我们可以根据这点来进一步判断。值得注意的是,反压有时是短暂的且影响不大,比如来自某个 Channel 的短暂网络延迟或者 TaskManager 的正常 GC,这种情况下我们可以不用处理。</p><p>对于 Flink 1.9 及以上版本,除了上述的表格,我们还可以根据 floatingBuffersUsage/exclusiveBuffersUsage 以及其上游 Task 的 outPoolUsage 来进行进一步的分析一个 Subtask 和其上游 Subtask 的数据传输。</p><center><p><img src="/img/flink-backpressure-handling/1.9-backpressure-table.png" alt="图4. Flink 1.9 反压分析表" title="图4. Flink 1.9 反压分析表"></p></center><p>通常来说,floatingBuffersUsage 为高则表明反压正在传导至上游,而 exclusiveBuffersUsage 则表明了反压是否存在倾斜(floatingBuffersUsage 高、exclusiveBuffersUsage 低为有倾斜,因为少数 channel 占用了大部分的 Floating Buffer)。</p><p>至此,我们已经有比较丰富的手段定位反压的根源是出现在哪个节点,但是具体的原因还没有办法找到。另外基于网络的反压 metrics 并不能定位到具体的 Operator,只能定位到 Task。特别是那种 embarrassingly parallel(易并行)的作业(所有的 Operator 会被放入一个 Task,因此只有一个节点),反压 metrics 则排不上用场。</p><h2 id="分析具体原因及处理"><a href="#分析具体原因及处理" class="headerlink" title="分析具体原因及处理"></a>分析具体原因及处理</h2><p>定位到反压节点后,分析造成原因的办法和我们分析一个普通程序的性能瓶颈的办法是十分类似的,可能还要更简单一点,因为我们要观察的主要是 Task Thread。</p><p>在实践中,很多情况下的反压是由于数据倾斜造成的,这点我们可以通过 Web UI 各个 SubTask 的 Records Sent 和 Record Received 来确认,另外 Checkpoint detail 里不同 SubTask 的 State size 也是一个分析数据倾斜的有用指标。</p><p>此外,最常见的问题可能是用户代码的执行效率问题(频繁被阻塞或者性能问题)。最有用的办法就是对 TaskManager 进行 CPU profile,从中我们可以分析到 Task Thread 是否跑满一个 CPU 核:如果是的话要分析 CPU 主要花费在哪些函数里面,比如我们生产环境中就偶尔遇到卡在 Regex 的用户函数(ReDoS);如果不是的话要看 Task Thread 阻塞在哪里,可能是用户函数本身有些同步的调用,可能是 checkpoint 或者 GC 等系统活动导致的暂时系统暂停。当然,性能分析的结果也可能是正常的,只是作业申请的资源不足而导致了反压,这就通常要求拓展并行度。值得一提的,在未来的版本 Flink 将会直接在 WebUI 提供 JVM 的 CPU 火焰图[5],这将大大简化性能瓶颈的分析。</p><p>另外 TaskManager 的内存以及 GC 问题也可能会导致反压,包括 TaskManager JVM 各区内存不合理导致的频繁 Full GC 甚至失联。推荐可以通过给 TaskManager 启用 G1 垃圾回收器来优化 GC,并加上 <code>-XX:+PrintGCDetails</code> 来打印 GC 日志的方式来观察 GC 的问题。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>反压是 Flink 应用运维中常见的问题,它不仅意味着性能瓶颈还可能导致作业的不稳定性。定位反压可以从 Web UI 的反压监控面板和 Task Metric 两者入手,前者方便简单分析,后者适合深入挖掘。定位到反压节点后我们可以通过数据分布、CPU Profile 和 GC 指标日志等手段来进一步分析反压背后的具体原因并进行针对性的优化。</p><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><p>1.<a href="http://wuchong.me/blog/2016/04/26/flink-internals-how-to-handle-backpressure/" target="_blank" rel="external">Flink 原理与实现:如何处理反压问题</a><br>2.<a href="https://mp.weixin.qq.com/s?src=11&timestamp=1571628927&ver=1925&signature=cHpaczGLH6QninlmHmM0mDKbb2-fuTMw83YjIFQFa7iN3omCrdlL51zCKo7N0n1uwM7*9JL-DmsQXhR*1Uh0YiUpVLHEzklFN9KUK33PVeF2fnoXcr0cDPPZ2s8HmK-D&new=1" target="_blank" rel="external">一文彻底搞懂 Flink 网络流控与反压机制</a><br>3.<a href="http://www.whitewood.me/2018/05/13/Flink-%E8%BD%BB%E9%87%8F%E7%BA%A7%E5%BC%82%E6%AD%A5%E5%BF%AB%E7%85%A7-ABS-%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86/" target="_blank" rel="external">Flink 轻量级异步快照 ABS 实现原理</a><br>4.<a href="https://flink.apache.org/2019/07/23/flink-network-stack-2.html" target="_blank" rel="external">Flink Network Stack Vol. 2: Monitoring, Metrics, and that Backpressure Thing</a><br>5.<a href="https://issues.apache.org/jira/browse/FLINK-13550" target="_blank" rel="external">Support for CPU FlameGraphs in new web UI</a></p>]]></content>
<summary type="html">
<p>反压(backpressure)是实时计算应用开发中,特别是流式计算中,十分常见的问题。反压意味着数据管道中某个节点成为瓶颈,处理速率跟不上上游发送数据的速率,而需要对上游进行限速。由于实时计算应用通常使用消息队列来进行生产端和消费端的解耦,消费端数据源是 pull-based 的,所以反压通常是从某个节点传导至数据源并降低数据源(比如 Kafka consumer)的摄入速率。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
<entry>
<title>Flink 1.10 细粒度资源管理解析</title>
<link href="https://link3280.github.io/2019/10/17/Flink-1-10-%E7%BB%86%E7%B2%92%E5%BA%A6%E8%B5%84%E6%BA%90%E7%AE%A1%E7%90%86%E8%A7%A3%E6%9E%90/"/>
<id>https://link3280.github.io/2019/10/17/Flink-1-10-细粒度资源管理解析/</id>
<published>2019-10-17T12:44:50.000Z</published>
<updated>2019-10-17T13:05:59.466Z</updated>
<content type="html"><![CDATA[<p>相信不少读者在开发 Flink 应用时或多或少会遇到在内存调优方面的问题,比如在我们生产环境中遇到最多的 TaskManager 在容器化环境下占用超出容器限制的内存而被 YARN/Mesos kill 掉[1],再比如使用 heap-based StateBackend 情况下 State 过大导致 GC 频繁影响吞吐。这些问题对于不熟悉 Flink 内存管理的用户来说十分难以排查,而且 Flink 晦涩难懂的内存配置参数更是让用户望而却步,结果是往往将内存调大至一个比较浪费的阈值以尽量避免内存问题。</p><a id="more"></a><p>对于作业规模不大的普通用户而言,这些通常在可以接受的范围之内,但对于上千并行度的大作业来说,浪费资源的总量会非常可观,而且进程的不稳定性导致的作业恢复时间也会比普通作业长得多,因此阿里巴巴的 Blink 团队针对内存管理机制做了大量的优化,并于近期开始合并到 Flink。本文的内容主要基于阿里团队工程师宋辛童在 Flink Forward Beijing 的分享[1],以及后续相关的几个 FLIP 提案。</p><h1 id="Flink-目前(1-9)的内存管理"><a href="#Flink-目前(1-9)的内存管理" class="headerlink" title="Flink 目前(1.9)的内存管理"></a>Flink 目前(1.9)的内存管理</h1><p>TaskManager 作为 Master/Slave 架构中的 Slave 提供了作业执行需要的环境和资源,最为重要而且复杂,因此 Flink 的内存管理也主要指 TaskManager 的内存管理。</p><p>TaskManager 的资源(主要是内存)分为三个层级,分别是最粗粒度的进程级(TaskManager 进程本身),线程级(TaskManager 的 slot)和 SubTask 级(多个 SubTask 共用一个 slot)。</p><center><p><img src="/img/flink-new-mem-management/taskmanager-memory-hierachy.png" alt="图1.TaskManager 资源层级" title="图1.TaskManager 资源层级"></p></center><p>在进程级,TaskManager 将内存划分为以下几块:</p><ul><li>Heap Memory: 由 JVM 直接管理的 heap 内存,留给用户代码以及没有显式内存管理的 Flink 系统活动使用(比如 StateBackend、ResourceManager 的元数据管理等)。</li><li>Network Memory: 用于网络传输(比如 shuffle、broadcast)的内存 Buffer 池,属于 Direct Memory 并由 Flink 管理。</li><li>Cutoff Memory: 在容器化环境下进程使用的物理内存有上限,需要预留一部分内存给 JVM 本身,比如线程栈内存、class 等元数据内存、GC 内存等。</li><li>Managed Memory: 由 Flink Memory Manager 直接管理的内存,是数据在 Operator 内部的物理表示。Managed Memory 可以被配置为 on-heap 或者 off-heap (direct memory)的,off-heap 的 Managed Memory 将有效减小 JVM heap 的大小并减轻 GC 负担。目前 Managed Memory 只用于 Batch 类型的作业,需要缓存数据的操作比如 hash join、sort 等都依赖于它。</li></ul><p>根据 Managed Memory 是 on-heap 或 off-heap 的不同,TaskManager 的进程内存与 JVM 内存分区关系分别如下:</p><center><p><img src="/img/flink-new-mem-management/taskmanager-memory-partitions.png" alt="图2.TaskManager 内存分区" title="图2.TaskManager 内存分区"></p></center><p>在线程级别,TaskManager 会将其资源均分为若干个 slot (在 YARN/Mesos/K8s 环境通常是每个 TaskManager 只包含 1 个 slot),没有 slot sharing 的情况下每个 slot 可以运行一个 SubTask 线程。除了 Managed Memory,属于同一 TaskManager 的 slot 之间基本是没有资源隔离的,包括 Heap Memory、Network Buffer、Cutoff Memory 都是共享的。所以目前 slot 主要的用处是限制一个 TaskManager 的 SubTask 数。</p><p>从作为资源提供者的 TaskManager 角度看, slot 是资源的最小单位,但从使用者 SubTask 的角度看,slot 的资源还可以被细分,因为 Flink 的 slot sharing 机制。默认情况下, Flink 允许多个 SubTask 共用一个 slot 的资源,前提是这些 SubTask 属于同一个 Job 的不同 Task。以官网的例子来说,一个拓扑为 <code>Source(6)-map(6)-keyby/window/apply(6)-sink(1)</code> 的作业,可以运行在 2 个 slot 数为 3 的 TaskManager 上(见图3)。</p><center><p><img src="/img/flink-new-mem-management/slot-sharing.png" alt="图3.TaskManager Slot Sharing" title="图3.TaskManager Slot Sharing"></p></center><p>这样的好处是,原本一共需要 19 个 slot 的作业,现在只需要作业中与 Task 最大并行度相等的 slot, 即 6 个 slot 即可运行起来。此外因为不同 Task 通常有不同的资源需求,比如 source 主要使用网络 IO,而 map 可能主要需要 cpu,将不同 Task 的 subtask 放到同一 slot 中有利于资源的充分利用。</p><p>可以看到,目前 Flink 的内存管理是比较粗粒度的,资源隔离并不是很完整,而且在不同部署模式下(Standalone/YARN/Mesos/K8s)或不同计算模式下(Streaming/Batch)的内存分配也不太一致,为深度平台化及大规模应用增添了难度。</p><h1 id="Flink-1-10-细粒度的资源管理"><a href="#Flink-1-10-细粒度的资源管理" class="headerlink" title="Flink 1.10 细粒度的资源管理"></a>Flink 1.10 细粒度的资源管理</h1><p>为了改进 Flink 内存管理机制,阿里巴巴的工程师结合 Blink 的优化经验分别就进程、线程、SubTask(Operator)三个层面分别提出了 3 个 FLIP,均以 1.10 为目标 release 版本。下面将逐一介绍每个提案的内容。</p><h2 id="FLIP-49-统一-TaskExecutor-的内存配置"><a href="#FLIP-49-统一-TaskExecutor-的内存配置" class="headerlink" title="FLIP-49: 统一 TaskExecutor 的内存配置"></a>FLIP-49: 统一 TaskExecutor 的内存配置</h2><h3 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h3><p>TaskExecutor 在不同部署模式下具体负责作业执行的进程,可以简单视为 TaskManager。目前 TaskManager 的内存配置存在不一致以及不够直观的问题,具体有以下几点:</p><ul><li>流批作业内容配置不一致。Managed Memory 只覆盖 DataSet API,而 DataStream API 的则主要使用 JVM 的 heap 内存,相比前者需要更多的调优参数且内存消耗更难把控。</li><li>RocksDB 占用的 native 内存并不在内存管理里,导致使用 RocksDB 时内存需要很多手动调优。</li><li>不同部署模式下,Flink 内存计算算法不同,并且令人难以理解。</li></ul><p>针对这些问题,FLIP-49[4] 提议通过将 Managed Memory 的用途拓展至 DataStream 以解决这个问题。DataStream 中主要占用内存的是 StateBackend,它可以从管理 Managed Memory 的 MemoryManager 预留部分内存或分配内存。通过这种方式同一个 Flink 配置可以运行 Batch 作业和 Streaming 作业,有利于流批统一。</p><h3 id="改进思路"><a href="#改进思路" class="headerlink" title="改进思路"></a>改进思路</h3><p>总结一下,目前 DataStream 和 DataSet 的内存使用可以分为如下几类:</p><table><thead><tr><th>场景</th><th>内存类型</th><th>内存分配方式</th><th>内存限制</th></tr></thead><tbody><tr><td>Streaming(Heap-based StateBackend)</td><td>heap</td><td>隐式分配</td><td>小于 JVM heap size</td></tr><tr><td>Streaming(RocksDB StateBackend)</td><td>off-heap</td><td>隐式分配</td><td>只受限于机器内存</td></tr><tr><td>Batch</td><td>heap 或 off-heap</td><td>显式通过 MemoryManager 分配</td><td>不大于显式分配的内存数</td></tr></tbody></table><p>可以看到目前 DataStream 作业的内存分配没有经过 MemoryManager 而是直接向 JVM 申请,容易造成 heap OOM 或者物理内存占用过大[3],因此直接的修复办法是让 MemoryManager 了解到 StateBackend 的内存占用。这会有两种方式,一是直接通过 MemoryManager 申请内存,二是仍使用隐式分配的办法,但需要通知 MemoryManager 预留这部分内存。此外 MemoryManager 申请 off-heap 的方式也会有所变化,从 <code>ByteBuffer#allocateDirect()</code> 变为 <code>Unsafe#allocateMemory()</code>,这样的好处是显式管理的 off-heap 内存可以从 JVM 的 <code>-XX:MaxDirectMemorySize</code> 参数限制中分离出来。</p><p>另外 MemoryManager 将不只可以被配置为 heap/off-heap,而是分别拥有对应的内存池。这样的好处是在同一个集群可以运行要求不同类型内存的作业,比如一个 FsStateBackend 的 DataStream 作业和一个 RocksDBStateBackend 的 DataStream 作业。heap/off-heap 的比例可以通过参数配置,1/0 则代表了完全的 on-heap 或者 off-heap。</p><p>改进之后 TaskManager 的各内存分区如下:</p><center><p><img src="/img/flink-new-mem-management/taskmanager-memory-partitions.png" alt="图4.TaskManager 新内存结构" title="TaskManager 新内存结构"></p></center><table><thead><tr><th>分区</th><th>内存类型</th><th>描述</th><th>配置项</th><th>默认值</th></tr></thead><tbody><tr><td>Framework Heap Memory</td><td>heap</td><td>Flink 框架消耗的 heap 内存</td><td>taskmanager.memory.<br>framework.heap</td><td>128mb</td></tr><tr><td>Task Heap Memory</td><td>heap</td><td>用户代码使用的 heap 内存</td><td>taskmanager.memory.<br>task.heap</td><td>无</td></tr><tr><td>Task Off-Heap Memory</td><td>off-heap</td><td>用户代码使用的 off-heap 内存</td><td>taskmanager.memory.<br>task.offheap</td><td>0b</td></tr><tr><td>Shuffle Memory</td><td>off-heap</td><td>网络传输/suffle 使用的内存</td><td>taskmanager.memory.<br>shuffle.[min/max/fraction]</td><td>min=64mb, max=1gb, fraction=0.1</td></tr><tr><td>Managed Heap Memory</td><td>heap</td><td>Managed Memory 使用的 heap 内存</td><td>taskmanager.memory.<br>managed.[size/fraction]</td><td>fraction=0.5</td></tr><tr><td>Managed Off-heap Memory</td><td>off-heap</td><td>Managed Memory 使用的 off-heap 内存</td><td>taskmanager.memory.<br>managed.offheap-fraction</td><td>0.0</td></tr><tr><td>JVM Metaspace</td><td>off-heap</td><td>JVM metaspace 使用的 off-heap 内存</td><td>taskmanager.memory.jvm-metaspace</td><td>192mb</td></tr><tr><td>JVM Overhead</td><td>off-heap</td><td>JVM 本身使用的内存</td><td>taskmanager.memory.jvm-overhead.[min/max/fraction]</td><td>min=128mb, max=1gb, fraction=0.1)</td></tr><tr><td>Total Flink Memory</td><td>heap & off-heap</td><td>Flink 框架使用的总内存,是以上除 JVM Metaspace 和 JVM Overhead 以外所有分区的总和</td><td>taskmanager.memory.total-flink.size</td><td>无</td></tr><tr><td>Total Process Memory</td><td>heap & off-heap</td><td>进程使用的总内存,是所有分区的总和,包括 JVM Metaspace 和 JVM Overhead</td><td>taskmanager.memory.total-process.size</td><td>无</td></tr></tbody></table><p>值得注意的是有 3 个分区是没有默认值的,包括 Framework Heap Memory、Total Flink Memory 和 Total Process Memory,它们是决定总内存的最关键参数,三者分别满足不同部署模式的需要。比如在 Standalone 默认下,用户可以配置 Framework Heap Memory 来限制用户代码使用的 heap 内存;而在 YARN 部署模式下,用户可以通过配置 YARN container 的资源来间接设置 Total Process Memory。</p><h2 id="FLIP-56-动态-slot-分配"><a href="#FLIP-56-动态-slot-分配" class="headerlink" title="FLIP-56: 动态 slot 分配"></a>FLIP-56: 动态 slot 分配</h2><h3 id="背景-1"><a href="#背景-1" class="headerlink" title="背景"></a>背景</h3><p>目前 Flink 的资源是预先静态分配的,也就是说 TaskManager 进程启动后 slot 的数目和每个 slot 的资源数都是固定的而且不能改变,这些 slot 的生命周期和 TaskManager 是相同的。Flink Job 后续只能向 TaskManager 申请和释放这些 slot,而没有对 slot 资源数的话语权。</p><center><p><img src="/img/flink-new-mem-management/static-slot.png" alt="图5. 静态 slot 分配" title="图5. 静态 slot 分配"></p></center><p>这种粗粒度的资源分配假定每个 SubTask 的资源需求都是大致相等的,优点是较为简单易用,缺点在于如果出现 SubTask 的资源需求有倾斜的情况,用户则需要按其中某个 SubTask 最大资源来配置总体资源,导致资源浪费且不利于多个作业复用相同 Flink 集群。</p><h3 id="改进思路-1"><a href="#改进思路-1" class="headerlink" title="改进思路"></a>改进思路</h3><p>FLIP-56[5] 提议通过将 TaskManager 的资源改为动态申请来解决这个问题。TaskManager 启动的时候只需要确定资源池大小,然后在有具体的 Flink Job 申请资源时再按需动态分配 slot。Flink Job 申请 slot 时需要附上资源需求,TaskManager 会根据该需求来确定 slot 资源。</p><center><p><img src="/img/flink-new-mem-management/dynamic-slot.png" alt="图6. 动态 slot 分配" title="图6. 动态 slot 分配"></p></center><p>值得注意的是,slot 资源需求可以是 <code>unknown</code>。提案引入了一个新的默认 slot 资源要求配置项,它表示一个 slot 占总资源的比例。如果 slot 资源未知,TaskManager 将按照该比例切分出 slot 资源。为了保持和现有静态 slot 模型的兼容性,如果该配置项没有被配置,TaskManager 会根据 slot 数目均等分资源生成 slot。</p><p>目前而言,该 FLIP 主要涉及到 Managed Memory 资源,TaskManager 的其他资源比如 JVM heap 还是多个 slot 共享的。</p><h2 id="FLIP-53-细粒度的算子资源管理"><a href="#FLIP-53-细粒度的算子资源管理" class="headerlink" title="FLIP-53: 细粒度的算子资源管理"></a>FLIP-53: 细粒度的算子资源管理</h2><h3 id="背景-2"><a href="#背景-2" class="headerlink" title="背景"></a>背景</h3><p>FLIP-56 使得 slot 的资源可以根据实际需求确定,而 FLIP-53 则探讨了 Operator (算子)层面如何表达资源需求,以及如何根据不同 Operator 的设置来计算出总的 slot 资源。</p><p>目前 DataSet API 以及有可以指定 Operator 资源占比的方法(TaskConfig 和 ChainedDriver),因此这个 FLIP 只涉及到 DataStream API 和 Table/SQL API (先在 Blink Planner 实现)。不过提案并没有包括用户函数 API 上的变化(类似新增 <code>dataStream.setResourceSpec()</code> 函数),而是主要讨论 DataStream 到 StreamGraph 的翻译过程如何计算 slot 资源。改进完成后,这三个 API 的资源计算逻辑在底层会是统一的。</p><h3 id="改进思路-2"><a href="#改进思路-2" class="headerlink" title="改进思路"></a>改进思路</h3><p>要理解 Flink 内部如何划分资源,首先要对 Flink 如何编译用户代码并部署到分布式环境的过程有一定的了解。</p><center><p><img src="/img/flink-new-mem-management/flink-graph.jpg" alt="图7. Flink 作业编译部署流程" title="图7. Flink 作业编译部署流程"></p></center><p>以 DataStream API 为例,用户为 DataStream 新增 Operator 时,Flink 在底层会将以一个对应的 Transform 来封装。比如 <code>dataStream.map(new MyMapFunc())</code> 会新增一个 <code>OneInputTransformation</code> 实例,里面包括了序列化的 <code>MyMapFunc</code> 实例,以及 Operator 的配置(包括名称、uid、并行度和资源等),并且记录了它在拓扑中的前一个 Transformation 作为它的数据输入。</p><p>当 <code>env.execute()</code> 被调用时,在 client 端 StreamGraphGenerator 首先会遍历 Transformation 列表构造出 StreamGraph 对象(每个 Operator 对应一个 StreamNode),然后 StreamingJobGraphGenerator 再将 StreamGraph 翻译成 DataStream/DataSet/Table/SQL 通用的 JobGraph(此时会应用 chaining policy 将可以合并的 Operator 合并为 OperatorChain,每个 OperatorChain 或不能合并的 Operator 对应一个 JobVertex),并将其传给 JobManager。</p><p>JobManager 收到 JobGraph 后首先会将其翻译成表示运行状态的 ExecutionGraph,ExecutionGraph 的每个节点称为 ExecutionJobVertex,对应一个 JobVertex。ExecutionJobVertex 有一个或多个并行度且可能被调度和执行多次,其中一个并行度的一次执行称为 Execution,JobManager 的 Scheduler 会为每个 Execution 分配 slot。</p><p>细粒度的算子资源管理将以下面的方式作用于目前的流程:</p><ol><li>用户使用 API 构建的 Operator(以 Transformation 表示)会附带 <code>ResourceSpecs</code>,描述该 Operator 需要的资源,默认为 <code>unknown</code>。</li><li>当生成 JobGraph 的时候,StreamingJobGraphGenerator 根据 <code>ResourceSpecs</code> 计算出每个 Operator 占的资源比例(主要是 Managed Memory 的比例)。</li><li>进行调度的时候,Operator 的资源将被加总成为 Task 的 <code>ResourceProfiles</code> (包括 Managed Memory 和根据 Task 总资源算出的 Network Memory)。这些 Task 会被划分为 SubTask 实例被部署到 TaskManager 上。</li><li>当 TaskManager 启动 SubTask 的时候,会根据各 Operator 的资源占比划分 Slot Managed Memory。划分的方式可以是用户指定每个 Operator 的资源占比,或者默认均等分。</li></ol><p>值得注意的是,Scheduler 的调度有分 EAGER 模式和 LAZY_FROM_SOURCE 两种模式,分别用于 Stream 作业和 Batch 作业,它们会影响到 slot 的资源计算。Stream 类型的作业要求所有的 Operator 同时运行,因此资源的需求是急切的(EAGER);而 Batch 类型的作业可以划分为多个阶段,不同阶段的 Operator 不需要同时运行,可以等输入数据准备好了再分配资源(LAZY_FROM_SOURCE)。这样的差异导致如果要充分利用 slot,Batch 作业需要区分不同阶段的 Task,同一时间只考虑一个阶段的 Task 资源。</p><p>解决的方案是将 slot sharing 的机制拓展至 Batch 作业。默认情况下 Stream 作业的所有 Operator 都属于 default sharing group,所以全部 Operator 都能共用都一个 slot。对于 Batch 作业而言,我们将整个 JobGraph 根据 suffle 划分为一至多个 Region,每个 Region 属于独立的 sharing group,因而不会被放到同一个 slot 里面。</p><center><p><img src="/img/flink-new-mem-management/slot-sharing-group.png" alt="图8. 不同作业类型的 Slot Sharing Group" title="图8. 不同作业类型的 Slot Sharing Group"></p></center><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>随着 Flink 的越来越大规模地被应用于各种业务,目前资源管理机制的灵活性、易用性不足的问题越发凸显,新的细粒度资源管理机制将大大缓解这个问题。此外,新资源管理机制将统一流批两者在 runtime 层资源管理,这也为将最终的流批统一打下基础。对于普通用户而言,这里的大多数变动是透明的,主要的影响应该是出现新的内存相关的配置项需要了解一下。</p><p>1.<a href="https://issues.apache.org/jira/browse/FLINK-13477" target="_blank" rel="external">[FLINK-13477] Containerized TaskManager killed because of lack of memory overhead</a><br>2.<a href="https://www.bilibili.com/video/av68914405/?p=3" target="_blank" rel="external">机遇与挑战:Apache Flink 资源管理机制解读与展望</a><br>3.<a href="https://issues.apache.org/jira/browse/FLINK-7289" target="_blank" rel="external">[FLINK-7289] Memory allocation of RocksDB can be problematic in container environments</a><br>4.<a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-49%3A+Unified+Memory+Configuration+for+TaskExecutors?src=contextnavpagetreemode" target="_blank" rel="external">FLIP-49: Unified Memory Configuration for TaskExecutors</a><br>5.<a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-56%3A+Dynamic+Slot+Allocation" target="_blank" rel="external">FLIP-56: Dynamic Slot Allocation</a><br>6.<a href="https://cwiki.apache.org/confluence/display/FLINK/FLIP-53%3A+Fine+Grained+Operator+Resource+Management" target="_blank" rel="external">FLIP-53: Fine Grained Operator Resource Management</a> </p>]]></content>
<summary type="html">
<p>相信不少读者在开发 Flink 应用时或多或少会遇到在内存调优方面的问题,比如在我们生产环境中遇到最多的 TaskManager 在容器化环境下占用超出容器限制的内存而被 YARN/Mesos kill 掉[1],再比如使用 heap-based StateBackend 情况下 State 过大导致 GC 频繁影响吞吐。这些问题对于不熟悉 Flink 内存管理的用户来说十分难以排查,而且 Flink 晦涩难懂的内存配置参数更是让用户望而却步,结果是往往将内存调大至一个比较浪费的阈值以尽量避免内存问题。</p>
</summary>
<category term="Flink" scheme="https://link3280.github.io/categories/Flink/"/>
<category term="Flink" scheme="https://link3280.github.io/tags/Flink/"/>
</entry>
</feed>