如果一条 SQL 可以使用流式处理来执行,具体要如何实现?我们格外感兴趣的是 Group By 和 Join 操作的实现。 在了解过 Stream SQL 的基本原理之后,我们进一步感兴趣的问题是现存的系统(特别是 Flink)的相关实现细节。

接下来的文章我们就来讨论这些问题。在增量 SQL 查询算法这一章首先来介绍诸如 Flink 这类系统所采用的实现 Stream SQL 查询的理论, 在流式处理与时间控制这一章,我们将讨论 Stream 处理系统的一些基本的概念和如何操作时间。最后, 我们将会讨论 Apache Flink 的能力与局限,致力于对 Flink 执行相关任务方法的进行一个简单刻画。特别地,我们会花费一些篇幅在 Flink 内部状态的管理。

增量 SQL 查询算法

一般来讲,给定一条 SQL 如果其源数据表中有一些数据发生了改变,我们需要重新全量执行这条 SQL 才能得到更新过的结果。 增量 SQL 查询则意味着我们可以只依赖源数据的改变量,局部地执行查询并更新原来的结果。使用增量模型, 我们往往可以得到更快的执行方案。很显然,Stream SQL 执行就是增量 SQL 查询:新到达的数据就是在一张“源数据表” 当中新加入的数据项。

为了介绍增量 SQL 查询算法,首先来看一些术语的解释:

视图(View):是在关系型数据库当中被保存的一个查询 物化视图(Materialized View):是将一个视图的内容“物化/预计算保存”下来的结果 表(Table):是一类可以支持扫描(Scan)或查询(Query)的数据源 索引(Index):是附加于表或者视图上用于加快查询的数据结构

请注意,在本文的讨论当中,索引指代的是一类需要引用原始数据的数据结构,也就是说索引只包含了原始数据当中的一部分信息。 包含原数据的全部信息并且可以无损翻译回去的可以认为是物化视图。另外,物化视图显然也是一种表:他支持扫描和查询操作。

容易得到,增量 SQL 查询的问题就是物化视图内容维护的问题。我们显然希望增量地维护物化视图的内容, 而不是每当源数据表改变就全量刷新。

物化视图的概念最早由 Oracle 和 SQL Server 等商业数据库作为索引的一种补充而引入。 学术界和工业界至今已经积累了很多相关研究,从而形成了一整套方法论。 很多现代的流式处理系统也采用了这些方案:Stream SQL 是物化视图维护问题的一个子问题。

为了阐释为什么物化视图是一种有效地加速查询的功能,我们先来花一些时间在 SQL 查询的优化与执行规划问题上。

SQL 优化与执行规划

在 SQL 查询优化原理与 Volcano Optimizer 介绍 一文中,我们已经对相关算法进行了详细介绍。简单重复一下的话,作为一种声明式的查询语言,我们可以将 SQL 转化成一种叫关系代数(Relation Algebra)的抽象表示来进行计算。这样的一种抽象表示被称为 SQL 执行的逻辑计划(Logical Plan)。 逻辑计划一般由一棵算子(Operator)组成的的树表示,这棵树表示了算子之间的依赖关系和执行顺序。特别地, 我们还可以为这课树表示的方案及其每棵子树进行执行花费的估算。下图展示的就是一条 SQL 的执行方案。

逻辑计划与成本估算

上图左边是逻辑计划,右侧是对计划成本的估算,其中展示的算子有 Join、Project 和 Filter 等。这里 Project 表示的是对上游输入的每个元素进行变换处理(如选取列、对每行进行数值变换等)的算子。利用右侧成本估算进行 SQL 查询优化的优化器被称为基于成本(Cost-based)的优化器。在实现上, 每个抽象算子可以结合子树传入的成本和自己的内部实现向上返回一个对自己成本的估算。也就是说成本估算是递归执行的。

比较先进的优化器大多基于成本估算,其基本原理是反复应用查询变换的规则将原查询修改为语义相同等另一方案, 在这一过程中不断估算成本并最终选取成本最低的方案。关于高效实现这一思路的 Volcano/Cascades 算法, 前面提到的文章已经进行了充分介绍,这里不再赘述。

在本文的语境当中,值得关注的是这种优化器提供的两种特别的可能性:

将逻辑计划转换为物理计划的能力 将整棵子树转换为单个算子节点返回预计算结果的能力

所谓的物理计划,指的是方案中每个算子都包含了执行这个方案所需要的信息的方案。在有的实现中, 物理计划当中的算子可以直接被调用执行。将计划划分为逻辑计划和物理计划的意义在于, 我们可以为语义相同的算子提供不同的物理实现。比方说如下图所示,针对 Join 节点我们有两种不同的实现方法: 使用哈希表的 Hash Join 和使用排序方法的 Sort Join。这两种实现方法有不同的适用场景, 比方说,Hash Join 一般性能较好,但是适用的数据规模有限,而使用外部排序的方法 Sort Join 一般可以支持非常大的数据范围。在现代数据库的优化器当中,这两种物理算子的选取是依据方案的成本估计来选取的。

成本估计与物理方案选取

可以看到,在上述方案选取当中,尽管 Hash Join 本身可以以更高的性能执行,然而 Sort Join 可以具备返回有序结果的特性。 因此,在规划时更上层的算子可以利用这一特性,反而可以得到更优的成本估算,因此包含 Sort Join 算子的方案会最后胜出。 在这里,逻辑计划到物理计划的转变同样也是通过规则来实现的,我们可以看到在这一模型中, 查询优化和物理计划的生成被统一成一个过程,不得不说是十分精巧的设计。

使用预计算结果代换整棵子树更容易理解:获得子树计算结果最快的方式就是直接从预计算结果当中读取。 这恰恰就是使用物化视图的思路所在:将所有的物化视图注册在优化器当中, 优化器就有可能自动发现一个方案可以利用这些预计算结果。

物化视图

物化视图增量维护的简单算法

如上文所述,物化视图就是对一条 SQL 查询的缓存。由于 SQL 是一门封闭的查询语言,它具有以下两条特点:

如果一颗树是 SQL 的代数表示,那这棵树的所有子树也是某条 SQL 查询的代数表示 将一颗表示 SQL 的树表示插入到另一颗树中作为子树,得到的树仍然是 SQL 的代数表示

因此,如果我们使用子查询或者视图进行查询,我们就可以将子查询和视图的代数表示直接插入到父查询之中, 作为一个整体优化和计算。这一过程称为子查询/视图展开。反过来,视图/物化视图也就可以完全由 SQL 查询来定义。 特别地,我们可以把视图/物化视图直接作为一个表来看待,在思考问题的时候不去考虑他的内部结构, 这样就比较容易进行讨论。

因此,对物化视图进行增量维护的最简单算法就是从根算子开始,将其左右两颗子树作为整体看作“似表(Table-Like)”。 显然,这些似表都支持扫描和查询功能。与一般的 SQL 查询不同,在增量 SQL 查询中,当一个表的内容改变, 我们希望这些表将内容的修改表示成包含增加的行和减少的行的增量表(Delta Table)的形式。 这些增量表将会被送入上层算子进行处理。

当上层算子接收到增量表了之后,他可以通过三种数据来源来判断要如何增量地执行这一查询, 并进一步将生成的增量表向上发送。这三种数据来源是:

这一算子自己维护的内部状态 某一子节点向上发送的增量表 把子树当成表并发送请求查询其内容

下图展示了这一框架:

增量执行模型

在最上层的根节点完成这样的计算后所得的增量表,就可以应用在物化视图原先保存的结果上,从而得到新的结果。 而如果我们能为每一个算子,如 Project、Filter 和 Join 都设计这样的执行方案(实现对应的物理算子) 是不是我们就可以为任意的 SQL 实现增量查询了呢?答案是肯定的: 对于任何标准 SQL 里的算子, 我们总可以将其转换成上述产生增量表的形式。也就是说我们α(T+ΔT) = α(T)+ Q( T,ΔT)。 这里 α 代表一个算子, T 是基表, ΔT 是增量表,Q 是一个以 T 和 ΔT 为参数的查询。 显然 Q( T,ΔT) 即我们所需输出增量表。

尽管上述增量维护的简单算法(或称为代数算法)提供了一条实现通用增量执行的道路, 仍有一些问题值得讨论:

时间成本:执行增量刷新操作的时间花费 空间花费:为了支持增量刷新,算子内部需要保存的状态大小 刷新时机:激发刷新操作的正确时机是什么?

问题3的答案很简单,通过时算子缓冲一些修改批量执行或者添加其他的控制策略,可以有效的提高执行效率,节约资源。 本文不对这一问题进一步展开,而将主要关注时间和空间花费两方面。

查询放大问题及其解决思路

上文提到,增量查询的简单算法(代数算法)有时需要通过查询上游表来获得必要的数据,从而生成输出给下游的增量表。 一个问题就是增量表的计算公式 Q( T,ΔT) 的执行效率。遗憾的是,在前述算法当中的一些相当简单的算子(如 Project)等, 这一公式的执行效率都不高,有的甚至隐含着需要全表刷新。下面是一些例子:

Project 查询放大问题:考虑简单查询SELECT MAX(a, 42) FROM example。当下游给定了一个删除某以a = 100 的行, 这时因为在物化视图当中,每一个因 MAX 函数执行而产生的数值 42 都是相同的,但 Project 算子又必须保留元素的排序, 从而出现了不知道删除哪条记录的情况,因此必须全量查询原表并重新刷新视图 聚合计算查询放大问题:考虑查询SELECT count(distinct a) FROM example 由于表中 a 元素可能会有重复, 在没有其他附加信息的情况下,每次增量查询执行只能重新扫描全表。这种情况在执行 SELECT a, count(distinct b) FROM example 的时候也会出现,根据 (a, b) 数据的分布,相关聚合算子 AGG 可能需要进行规模不等的扫描。 TopK (Sort Limit) 查询放大问题,设想查询SELECT a FROM example ORDER BY a LIMIT 10。很显然, 虽然输出的视图里只包含10条元素,然而为了处理某一增量表,相关查询不得不重新扫描全表来选取最小的十个数字. Theta Join 问题。对于比较自由的 Join 条件,如查询 SELECT T1.a, T2.a FROM T1 JOIN T2 ON T1.a * T2.a < 100 尽管最终符合条件的元素数量并不多,但仍然可能需要对两个表进行大量查询

然而,解决查询放大问题并没有一定之规。现有的方法主要是针对各种特定情况进行优化。几种可能的思路:

确保处理过程当中的行唯一性。也就是说首先为每一行指定一个唯一的 ID,保证这些 ID 在整个计算过程中不断地在算子之间传递。 有些操作如 Group By 等需要根据条件修改这些 ID。保证这些 ID 被增量表中的每一行携带。 这样就容易获知应该修改目标视图当中的哪些行。然而对于比较自由的 Join 算子,可能无法为下游行生成合理的 ID, 这也是有些系统只支持 EquiJoin(带有相等条件的 Join)的原因。 使得算子保存额外的内部状态,将 Q( T,ΔT) 变换为 Q’( T,ΔT,S) 这里 S 是算子的内部状态。 这样得到的函数 Q’ 就可以支持更加快速的查询并使得扫描的范围更小。额外的内部状态可能包括对子树内容的物化(缓存)本身, 也可以有根据查询条件或 Join 条件生成的索引等。实现这种思路没有一劳永逸的方法, 大多需要根据SQL 优化与执行规划 这一章介绍的优化模型进行统筹考虑和方案选择。也就是说提供多种物理计划算子以待选择 使用近似计算。对于某些聚合查询,如COUNT(DISTINCT value))和 TopK 等,可以使用 HyperLogLog 等算法进行近似计算, 并将结果保存在内部状态中。这样查询虽然输出了近似结果,但是在时间和空间上都获得了优化 限制或扩充语义。一方面可以对 SQL 在某些方面的能力进行限制,从而防止全量查询的发生。 另一方面可以增强 SQL 的建模能力,加入诸如 Window 之类的概念,使得用户可以更好地描述自己的需求, 并将相关查询的扫描范围限制在一定的规模。这种方式往往常用于 Stream SQL,在之后会进一步介绍相关内容

在上述各种方案都无法有效解决问题的时候,一种方法就是完全退化为全量刷新。 这是因为某些场景下增量查询的执行可能比全量刷新具有更高的成本,这时根据 SQL 的成本估算选择全量刷新执行是更明智的。

修改放大问题及其解决思路

实现 SQL 增量执行最棘手的问题是修改放大问题。这一问题指的是源数据表当中一条简单的修改, 在算子增量执行的时候会产生大量的下游修改。也就是说某一个算子接收了一个很小的增量表,却向下游输出巨大的增量表。 这种情况往往出现在使用 Join 的时候。在这里,我们假设系统已经根据上文当中的查询放大问题进行了优化, 因此算子输出的增量表的内容都是必要的。研究下面的 SQL 的例子:

SELECT A.a, B.b FROM A CROSS JOIN B

作为一个没有条件的 Cross Join,很显然,A或B表当中的任何修改都会导致另外一个表的全部内容被查询并插入到增量表。 在一些即使有限制条件,但是数据分布比较倾斜的 Join 场景下也会出现这样的问题。这时往往只有一些无奈的选择:

延迟刷新:通过选择时机,预防连续小的修改产生连续的批量计算操作 限制/扩充语义:和解决查询放大问题相同,通过限制和扩充语义,只满足用户的一部分需求。 特别是在流处理系统当中,引入一些新的 Join 形式和 Window 的概念,反而可以增强用户表达和实现流式处理需求的能力

在后文的流处理模型介绍一章将会进一步介绍新引入的流式 Join 形式。

可自我维护性(Self-maintainability)

一个算子被称为可自我维护的,当他可以完全使用内部状态处理增量表并输出数据给下游。 也就是说,对于可自我维护的算子,其增量表生成函数的形式是 Q’'(ΔT,S) ,其中 ΔT 是增量表、 S 是内部状态。

可自我维护性在流处理和分布式查询场景下十分有用。首先对于流处理来说,输入表也许是不可重入的, 也就是说你不能轻易地查询任意久远之前的数据。这时就要求算子能支持一定的可自我维护性, 避免反向查询输入流的操作。对于分布式查询来说,不同算子可能运行在不同的机器上, 因此跨算子的查询因为网络延迟的原因往往会比较低效。

有一些算子,如 Filter 等天然就可自我维护。另外一些算子(Join、Agg 等) 往往需要通过全量物化自己代表的视图才获得,这种做法往往需要消耗大量的状态空间。 当然也存在一些空间消耗比较适中的特别解决方案,但是他们都要根据其参数和输入数据分布, 通过成本估算来选定算法来实现,没有通用的解法。

一般来说,一个算子是否可以自我维护在 SQL 优化和计划生成阶段就完成了,但也有一些研究着眼于动态可自我维护性。 这种方法实现的算子会维护一些特别的状态,以便于分析输入的增量表,对于其中可以自行解决的项目直接利用内部状态计算, 其他的部分再反向查询。

值得注意的一点是,可自我维护性并不是取得高查询性能的必要条件。查询的执行器应该综合考虑时间和空间成本的平衡, 在整个查询产生的算子树上选择部分合适的节点物化内容和实现可自我维护性。从这里可以看出, 查询优化器及其相关算法增量 SQL 处理过程当中的重要作用。事实上,根据输入数据的分布的统计数据和算子的特性, 建立查询成本估计的数学模型是一项非常重要且紧缺的技能。优化器人才招募中!(CMU Advanced Database 课程)

流式处理与时间操作

流(Stream)可以被看作一种无界(Unbounded)且只可追加的(Append-Only)的数据表。 如果把新来的事件看作插入条目的增量表,我们就可以用之前提到的物化视图维护算法增量地执行 SQL。 Apache Flink 和 Samza 之类的系统大多采用了这种方式。

由于流处理系统的输入是无限增长的,我们希望能就以下问题进行讨论:

如何在流处理系统当中处理时间,并利用这一特性限制内部状态的大小 如何扩展 SQL 以支持描述时间方面的需求,使得执行器更好地理解需求并执行

流处理系统的时间操作

在现实世界的分布式系统当中处理流主要面对的问题有:

绝对时钟(Absolute Clock)问题:现实世界当中的时钟往往不精确,而且在分布式系统当中实现时间绝对同步是(物理上)不可能的 时间倾斜(Time Skew)问题:由于系统中必然会存在网络延迟、网络中断和系统崩溃等问题, 发送到系统的消息和系统内部的消息很可能失序乃至丢失

对于第一个问题,现代操作系统普遍使用事件驱动(Event-Driven)模型来处理。通过使用消息本身携带的生成时间(Event-Time) 而不是系统接收到消息的时间(Processing Time)来进行处理。这样的系统当中,时钟是由事件来驱动的。 没有新的事件到开,系统的状态就如同冻结起来了一样。

激发器(Trigger)是一类特别的事件,当这种事件的消息被接收到时,某些任务会被激发和执行。 可以被当作激发器的消息有很多:

新的数据从消息队列中到达 某一算子计算产生的增量表发送到下游算子 下游算子对上游算子发送的 ACK 消息

有了激发器之后,我们的模型当中就不需要存在物理时间, 采用了这种事件驱动模型或者反应式设计模型的系统可以变得更加函数式和无状态。

值得注意的是,每个算子可以自行决定自己处理激发器的逻辑。它们可以自由地忽略、收集、聚合和发送事件。 这些逻辑设计有可能有助于提高系统的性能和降低通讯开销。

接下来考虑时间倾斜问题,可以回忆一下 TCP 是如何处理丢包和不按顺序到达的包的: 为每个包编号并维护已经获得 ACK 的包的编号。在流式系统里也采用了类似的方法。

水印(Watermark)就是用来处理这一问题的。简单来说,水印就是根据消息的事件时间来决定一条消息应该被处理还是被丢弃的标记。 下图展示了水印起作用的方式:

水印和激发器

上图中,从右到左是消息到达的时间,在某时刻,消息8通过激发器激发了一次对水印的修改。此时水印的时间限制被修改为 4 。 这意味着之后到达的标号时间小于4 的消息都会被丢弃。在消息 8 之后到达的消息 7 和 5,虽然时间戳比消息 8 要早, 但是因为仍在 Watermark 的范围里,因此会被考虑在内。最后到达的消息有时间标号 9,他是一条当前观察到过的消息之后的消息, 因此也会被处理。

从上述讨论当中我们可以看到:

水印应该永远小于当前处理过的事件的时间戳 水印是通过激发器的激发来移动的,算子可以自己决定移动水印的时间,而不是每个接收到的事件都会改变水印 水印必须是单调递增的。否则,一旦水印向前移动,我们无法知道是否已经有被包含在水印范围里的消息被丢弃

水印不仅仅是处理时间偏移问题的利器,他也有助于实现限制算子内部状态大小的逻辑。算子可以通过检查水印标记的时间, 将自己内部状态中较老的不会被用到的条目移除掉。在实现严格单次发送(Exactly-Once Delivery)的系统中, 正确管理水印对于防止移除之后仍可能被查询到的条目非常重要。因此,在比较先进的系统中使用者都可以指定一个如下形式的函数:

在上述公式中,水印 是由处理过的消息的时间 

、从下游接收到的 ACK 消息附带的时间 和系统的其他环境参数 Env 所决定的。算子决定水印的逻辑十分有灵活性,但是设计这样一个函数也需要一些灵感:

如果水印前进的太慢,算子的内部状态可能膨胀于过大 如果水印前进太快,过多的消息可能被丢弃掉

窗口(Window)是一种设计出来让用户更好地描述它们对时间的需求的工具。 他可以让用户在一个窗口里以有限时间/数据范围的方式操作数据,同时也为进一步优化时间空间成本提供了可能。

流处理系统提供的常见的窗口类型有:

固定窗口(Fixed Window):长度固定的窗口,每个窗口一个紧跟着一个将时间维度划分成片段 滑动窗口(Sliding Window):长度固定,但每个窗口的开始时间相比于前一个窗口都有一个固定的时间偏移 会话窗口(Session Window):使用事件的属性和相互的时间间隔把他们组织在一个窗口里。这些窗口的开始时间、 持续长度等都会变化。这类窗口用于用户追踪、线索跟踪等场景十分有效

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

最后

这份文档从构建一个键值数据库的关键架构入手,不仅带你建立起全局观,还帮你迅速抓住核心主线。除此之外,还会具体讲解数据结构、线程模型、网络框架、持久化、主从同步和切片集群等,帮你搞懂底层原理。相信这对于所有层次的Redis使用者都是一份非常完美的教程了。

整理不易,觉得有帮助的朋友可以帮忙点赞分享支持一下小编~

你的支持,我的动力;祝各位前程似锦,offer不断!!! 《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》,点击传送门即可获取! 内容对你有帮助,可以扫码获取!!(备注Java获取)**

最后

这份文档从构建一个键值数据库的关键架构入手,不仅带你建立起全局观,还帮你迅速抓住核心主线。除此之外,还会具体讲解数据结构、线程模型、网络框架、持久化、主从同步和切片集群等,帮你搞懂底层原理。相信这对于所有层次的Redis使用者都是一份非常完美的教程了。

[外链图片转存中…(img-79Wpa08L-1712223599355)]

整理不易,觉得有帮助的朋友可以帮忙点赞分享支持一下小编~

你的支持,我的动力;祝各位前程似锦,offer不断!!! 《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》,点击传送门即可获取!

推荐文章

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: