likes
comments
collection
share

如何利用随机化的 SQL 测试来帮助检测错误

作者站长头像
站长
· 阅读数 6

作者:Martin Liu & Noel Kwan,RisingWave Labs 内核开发工程师

动机

SQLSmith 是一个用于自动生成和测试 SQL 查询的工具,它旨在通过生成随机的有效 SQL 查询并在目标数据库上执行这些查询来探索数据库系统的功能和限制。如果查询导致数据库崩溃,或者产生了意外的错误,那么我们就探查到了一个错误。

在使用和实现我们自己的 SQLSmith 之前,Risingwave 也尝试过另一种由 AFL++驱动的方法(aflplus.plus/)。

它生成随机的二进制数据,并由 Risingwave 的前端进行检查,以查看它们是否在语法和语义上是正确的。前端包括:

  1. Lexer:根据 SQL 规范将输入分解为标记,将其拆分为关键字、标识符、文字、操作符、标点符号等较小的单元。

  2. Parser:将标记重组成抽象语法树(AST),以根据 SQL 的语法规则表示层次结构。AST 捕捉了标记之间的关系及其语法含义。例如“Join”具有两个输入,可以是表或子查询。

  3. Binder:确定查询的实际含义。例如在 select count(user) from foo 中,foo 是一个包含多个列的表。绑定器需要通过查询数据库目录来确定列 user 是否实际存在。它还帮助检测数据类型和相关操作的兼容性,例如非时间戳数据类型无法进行 + 8 seconds 的操作。

  4. Optimizer:一旦我们有一个在语法和语义上都是正确的查询,我们可以通过各种转换规则来优化查询,例如将过滤条件下推或枚举不同的 join 顺序以选取最佳顺序。

然而,由这样一个通用的模糊测试器生成的随机二进制数据很难通过解析器甚至词法分析器,这意味着我们几乎无法测试到绑定器、优化器和执行引擎,使用特定领域的模糊测试工具要更有效。

我们发现有三个开源的 SQLSmith 实现可用:

  1. 第一个 SQLSmith 借鉴了 CSmith的思想:github.com/anse1/sqlsm…

  2. CockroachDB的 SQLSmith 实现:github.com/cockroachdb…

  3. PingCAP的 SQLSmith 实现:github.com/PingCAP-QE/…

但是,以下几个原因阻止我们直接使用它们来测试 RisingWave:

  1. 它们不支持 Risingwave 的所有语法和功能。作为一种流式数据库,Risingwave 具有针对流式工作负载的特定领域功能,例如与时间相关的操作,如时间窗口、水印、时间过滤等。

  2. 它们支持不同的语法或更多的功能。例如,它们可能支持不同于 PostgreSQL 的 SQL 方言。即使它们与 PostgreSQL 兼容,Risingwave 也没有实现 100% 的 PostgreSQL 兼容性,这意味着我们必须禁用一些功能并相应地进行调整。

  3. Risingwave 支持批处理 SQL 和流式 SQL。批处理和流式引擎的底层实现是独立的。因此,除了测试查询是否能够通过 RW 的前端并且在没有错误的情况下执行之外,我们还可以将正确性测试嵌入到 SQLSmith 中,即通过批处理和流式引擎运行相同查询的两个结果集进行比较。这种技术通常被称为差分测试:en.wikipedia.org/wiki/Differ…

因此,我们选择在 Rust 中构建自己的 SQLSmith。

如何使用 SQLSmith 进行测试

我们在每个 pull request 中运行 SQLSmith 测试,以及来自 SQLSmith 生成的查询的快照。这帮助我们在 Risingwave 的前端和执行引擎中发现了许多错误。

pull request

起初,我们为每个 pull request 使用不同的随机种子运行 SQLSmith,因为我们认为每次生成不同的查询集可以最大程度地覆盖代码。然而,在开发的早期阶段,遇到许多重要或不重要的错误是不可避免的。我们的 CI/CD 流程会阻止无法通过测试的 pull request 合并。因此,开发人员被迫修复那些优先级较低的错误,即在实际使用中用户很难编写的查询。

此外,每个 PR 生成的查询一旦测试通过就被遗忘了,这并不理想。因为 SQLSmith 检测到的相同错误可能会在将来再次出现。如果我们可以将所有失败的测试用例放入集合中,并定期测试它们,这样我们就不会再次犯同样的错误。

每周快照生成

因此,我们使用 SQLSmith 生成 SQL 查询的快照以维护一个稳定的测试集。

由于采用了快照的方法,我们可以轻松地剪裁查询集。我们可以删除那些触发不重要错误或之前未被 Risingwave 正确拒绝的无效查询。

一旦发现了错误,它将导致我们的“运行预生成 SQL 查询”工作流失败,阻止合并 pull request。我们仍然会保存失败的查询集,以便我们可以测试和重现这些错误。同时,我们每周生成一组新的查询,以确保高覆盖率。

SQLSmith 内部机制

SQLSmith 生成以下类型的查询:

  1. 数据定义语言(DDL)查询,例如 CREATE TABLE、CREATE MATERIALIZED VIEW。

  2. 数据操作语言(DML)查询,例如 INSERT、UPDATE、DELETE。

  3. 批处理查询,例如 SELECT。

  4. 流式查询,例如 CREATE MATERIALIZED VIEW。

  5. 配置数据库行为的会话变量,例如 SET RW_ENABLE_TWO_PHASE_AGG TO FALSE;,用于使优化器输出特定的查询计划,开启或关闭特定优化。

DDL 语句允许我们运行批处理查询或在我们创建的表上创建新的物化视图;DML 语句增、删、改数据,以触发批处理和流处理引擎的不同代码路径。

会话变量允许我们在设置这些变量后测试数据库的新行为。这带来新的差分测试机会。比如通过设定不同的优化器会话变量,使得优化器输出语义相同的不同执行计划。不同执行计划仍应该输出相同的结果。

SQLSmith 查询生成内部机制

查询生成过程遵循自顶向下的递归方法。此过程同时适用于批处理和流式查询,其中某些不被流式引擎支持的 SQL 功能会选择性地禁用。

以下是生成复杂查询的一般示例:

  1. 生成 WITH 子句。

  2. 生成集合表达式,即 SELECT 语句。

  3. 生成 ORDER BY 子句。

对于集合表达式,我们按照以下步骤生成:

  1. 生成 FROM 表达式,包括任何必要的连接。

  2. 生成 WHERE 表达式。

  3. 生成其他 SELECT 查询的部分。

  4. 生成 SELECT 项列表。

在生成 SELECT 项列表时,该过程首先选择一种类型,然后使用该类型调用 gen_expr。这可能会生成以下内容:

  1. 标量值。

  2. 列。

  3. 函数。

  4. 聚合函数。

  5. 类型转换。

  6. 其他类型的表达式,例如 "CASE ... WHEN" 语句。

所有这些都有助于确保各种类型的 SQL 语句经过良好测试且可靠。如果您对进一步的细节感兴趣,可以阅读我们的开发者文档:github.com/risingwavel…

发现Bug

SQLSmith 已经在 Risingwave 的前端、执行引擎和存储引擎中发现了近 40 个bug。如果您有兴趣,可以通过在 GitHub 上搜索查看详细信息:github.com/risingwavel…

以下是我们发现的一些有趣的错误:

(1)github.com/risingwavel…

用 SQLSmith 来测试从一种数据类型到另一种数据类型的不同 CAST 非常有用。由于 cast 有很多类型的组合,人工编写测试用例非常繁琐低效。在这个例子中,SQLSmith 发现了 NULL 强制转换问题。这是由于 Binder 中的 implicit_cast 没有被正确执行。

(2)github.com/risingwavel…

SQLSmith 在主键等于 null 时发现了 FULL JOIN 的错误。它展示了我们在处理流式查询时优化器的一个特殊的限制,而批处理则不受这个的限制。

(3)github.com/risingwavel…

SQLSmith 检测到了在计划生成期间发生的 two_phase_stream_agg 错误。

💡 什么是两阶段聚合?对于分组聚合,Risingwave 的流式引擎可以选择两个查询计划之一:

1. 直接按照分组列进行数据 shuffle,然后进行聚合; 2.在 shuffle 之前进行聚合,以减少通过网络发送的数据量,然后进行第二阶段的聚合。

第二种方法适用于低基数数据,例如具有很少唯一值的列,因为减少可以显著降低数据量。

(4)github.com/risingwavel…

SQLSmith 发现由于 interval 类型溢出导致的哈希键计算错误。

未来改进

差分测试

其他 SQLSmith 的实现通常无法测试正确性,因为它们缺乏一个真实的检验标准或参照物。然而, Risingwave 同时拥有流式引擎和批处理引擎。每个引擎由一组不同的运算符实现,这些运算符针对每种用例进行了特定的优化,因此它们的计算逻辑有很大不同:

  1. 批处理 SQL 具有有限数量的输入数据。每个运算符可以在处理之前等待所有数据就绪,并完全将所有中间结果存储在内存中,而不是倒入到 S3 中。这是流处理所不能进行的优化。

  2. 流式 SQL 具有无限数量的输入数据,并且必须将中间结果存储在由远程对象存储支持的存储引擎中。同时,每个新的数据片段都会进行增量计算,并更新到物化视图中。

因此,我们生成相同的查询,通过批处理和流式计算引擎进行测试,并最后将它们的结果进行比较,以确定错误。当然,两个引擎的结果都可能是错误的。但是,它们以相同的方式出现错误的几率非常小。

Source 测试

SQLSmith 还可以进一步增强以测试各种 connector,例如从上游的数据系统中以某种格式读取数据。以 Risingwave 文档中的一个示例为例:www.risingwave.dev/docs/curren…

Risingwave 支持通过 6 种数据格式从 Kafka 获取数据:Avro、Upsert Avro、JSON、Upsert JSON、Protobuf 和 CSV。因此,Risingwave 为解析每种数据格式的代码路径提供了 6 种不同的实现。

SQLSmith 被设计为生成随机的有效或无效输入数据,具有不同的数据类型,例如 numeric、jsonb 等,以确保解析器能够正确解析有效数据并拒绝格式错误的数据。

SQL 简化器

通常,在我们发现一个失败的查询后,定位错误原因花费了我们很多时间,因为 SQLSmith 生成的查询非常复杂,难以刻意触发错误。因此,我们不得不手动执行类似二分搜索的算法:

  1. 尝试删除查询的一部分或用更简单的项替换它,看看错误是否仍然存在。

  2. 如果是,那么我们在未更改的查询部分中重复第一步。

  3. 如果不是,那么我们进一步调查已删除或替换的部分。

这个过程机械且耗时,未来可能引入自动化二分执行计划来定位问题,关于这个话题在 Risingwave 的 GitHub 上有讨论:github.com/risingwavel…

结论

如果没有 SQLSmith,我们将不得不手动生成测试用例,十分繁琐且低效。尽管我们可以将其他数据库的现有测试用例纳入,但它们可能与 Risingwave 的语法不兼容,我们必须手动进行调整。更重要的是,两种方法都只探索了比 SQLSmith 能力小得多的搜索空间。我们清楚地看到,投入时间来发展 SQLSmith 比手工测试能够发现更多的错误。SQLSmith 显著提高了我们在提供新功能、进行代码或 SQL 语法更改时的信心。

关于 RisingWave

RisingWave 是一款分布式 SQL 流处理数据库,旨在帮助用户降低实时应用的的开发成本。作为专为云上分布式流处理而设计的系统,RisingWave 为用户提供了与 PostgreSQL 类似的使用体验,并且具备比 Flink 高出 10 倍的性能以及更低的成本。了解更多:

公众号: RisingWave 中文开源社区

转载自:https://juejin.cn/post/7261808673034813499
评论
请登录