本文字数:5651;估计阅读时间:15 分钟
Meetup活动
ClickHouse Shanghai User Group第2届 Meetup 火热报名中,详见文末海报!

近年来,模糊测试 (Fuzzing) 已成为热门研究方向,广泛用于发现软件缺陷,包括崩溃、错误输出和安全漏洞。数据库同样是研究重点,许多专门针对数据库的模糊测试工具相继问世。
ClickHouse 也积极采用模糊测试工具进行测试。多年来,ClickHouse 采用了多种模糊测试工具,包括 SQLancer、SQLsmith、AST fuzzer 以及近期的 WINGFUZZ fuzzer。
在审查 ClickHouse 现有的测试基础设施时,我发现这些模糊测试工具存在明显不足——它们无法在保证查询正确性的同时,生成复杂度较高的 SQL 语句。
因此,在过去 5 个月里,我开发了 BuzzHouse,这是一款新型模糊测试工具,旨在弥补 ClickHouse 现有测试体系的这一缺陷。迄今,BuzzHouse 已发现约 100 个 ClickHouse 相关问题。在这篇文章中,我将分享数据库模糊测试中的关键考虑因素。
在讨论数据库模糊测试的挑战之前,我们先来了解一下模糊测试的概念。模糊测试(Fuzzing)是一种测试方法,它通过向程序输入随机、意外或无效的数据,来发现潜在的错误、崩溃或安全漏洞。通过模拟不可预测的用户行为,模糊测试能够揭示传统测试难以覆盖的边界情况,因此被广泛应用于提升软件的稳定性和安全性,涵盖操作系统、编译器和数据库等多个领域。
数据库系统本身十分复杂,涉及查询处理与优化、数据存储、缓冲管理,以及像 ClickHouse 这样的分布式数据库还涉及跨多个节点的计算。由于这些复杂性,开发一个高效的数据库模糊测试工具并不容易。此外,数据库系统的各个层面都需要测试,而这需要多种不同类型的输入,进一步增加了测试的难度。
如何生成 SQL 查询?
数据库的模糊测试通常从最顶层开始,即从客户端的查询解析和处理入手。大多数数据库(包括 ClickHouse)都使用 SQL 作为主要的查询语言,因此,模糊测试工具通常会自动生成 SQL 查询。然而,如何生成这些查询是模糊测试设计中的关键问题。有些工具采用完全随机的方式生成 SQL 语句,而另一些则基于历史查询数据来生成。虽然两种方法各有优缺点,但不同的策略可能会导致某些边界情况未被覆盖,从而遗漏潜在的 Bug。我们以 TPC-H Q5 查询为例,来看这种差异可能带来的影响:
SELECT
n_name,
sum(l_extendedprice * (1 - l_discount)) AS revenue
FROM
customer,
orders,
lineitem,
supplier,
nation,
region
WHERE
c_custkey = o_custkey
AND l_orderkey = o_orderkey
AND l_suppkey = s_suppkey
AND c_nationkey = s_nationkey
AND s_nationkey = n_nationkey
AND n_regionkey = r_regionkey
AND r_name = 'ASIA'
AND o_orderdate >= DATE '1994-01-01'
AND o_orderdate < DATE '1994-01-01' + INTERVAL '1' year
GROUP BY
n_name
ORDER BY
revenue DESC;
当用户编写 SQL 查询时,他们通常默认所有的表和列都是有效的。然而,如果模糊测试工具仅生成现有的表和列,那么因引用不存在的表或列而引发的错误就无法被检测到。
另一个复杂的情况是 SQL 分组(grouping)。SQL 语法规则较为严格,使得随机生成查询更具挑战性。例如,为了保证查询正确,查询结果(projection)必须包含 n_name 列,或者将其他列用于聚合函数。此外,还需遵循一些 SQL 语义规则,例如 `WHERE` 子句中不能使用聚合函数,窗口函数不能嵌套在聚合函数内。正因如此,随机生成正确 SQL 查询的难度较大。如果严格遵循这些规则,虽然能保证 SQL 语法的正确性,但也会缩小可生成查询的范围,并减少可能发现的错误数量。
某些 SQL 语句(如 `DROP` 和 `DETACH`)会使表变得不可用,因此模糊测试工具需要考虑这些因素,确保查询仍然使用有效的表。虽然可以跟踪这些语句的影响,但如果再加入 SQL 视图等依赖于表的功能,管理这些关系就会变得更复杂。
随着测试范围的扩大,例如引入更多 SQL 函数、查询子句、数据类型、不同的表/列/数据库组合,以及 ClickHouse 特有的 `INSERT` 或 `OPTIMIZE` 语句,或者支持更多的表引擎,可能的查询组合数量将急剧增长。这使得生成正确查询或测试新功能变得更加困难。正如前面提到的,我们可以选择严格约束查询范围,以减少错误,但这可能会降低测试的覆盖率;或者允许一定程度的随机性,以提高查询的多样性。在 BuzzHouse 中,我的目标是在大多数情况下生成正确的查询,但仍会在少数情况下随机生成查询,以确保模糊测试的广度和有效性。
定位错误结果
当前最先进的模糊测试技术使 BuzzHouse 能够发现返回错误结果的查询,而 SQLsmith 等其他模糊测试工具无法实现这一点。BuzzHouse 采用了以下策略:
数据回读测试:导出表数据并重新读取,以验证数据格式的正确性。 等效查询对比:运行等效的 SQL 查询,并使用“预言机”方法(oracle-based approach)对比查询结果。这种策略最早由 SQLancer 提出。 不同设置的执行对比:在不同的数据库配置下运行相同的查询,例如调整线程数、启用或禁用外部排序和分组,或选择不同的 JOIN 算法,以检测可能的执行差异。 跨数据库结果比对:用另一个关系数据库中的表替换当前表,应用相同的查询条件,并通过相同的排序规则或全局聚合查询对比计算结果,以验证查询逻辑的一致性。
以下是通过数据回读测试发现的一个问题:在将表数据导出并重新插入后,数据块的存储顺序发生了变化,导致 LowCardinality 类型的排序结果出现异常。
SET allow_suspicious_low_cardinality_types = 1;
CREATE TABLE t0 (c0 LowCardinality(Nullable(Int))) ENGINE = MergeTree()
ORDER BY (c0) SETTINGS allow_nullable_key = 1;
INSERT INTO TABLE t0 (c0) VALUES (1);
INSERT INTO TABLE t0 (c0) VALUES (0), (NULL);
SELECT c0 FROM t0 ORDER BY c0 DESC NULLS LAST;
-- 1
-- NULL
-- 0
是否应该使用代码覆盖率?
当前最先进的模糊测试技术(如 AFL 和 libFuzzer)依赖代码覆盖率来探索新的代码路径。这种方法在理论上很有吸引力,但在数据库领域却面临诸多挑战。首先,SQL 语言有着严格的语义规则,需要遵循特定的约束。其次,许多数据库 bug 需要更复杂的输入场景,例如多个客户端的并发访问或服务器的重启。第三,基于代码覆盖率的模糊测试运行速度较慢,因为它的变异方式是对查询进行微小调整,而这会降低生成 SQL 查询的多样性。此外,为了维持测试的连贯性,还需要对生成的数据集进行备份,以便在后续测试中复用。在 BuzzHouse 中,我们决定暂不采用代码覆盖率,而是专注于补充现有具备该功能的模糊测试工具。
事件触发的概率设定
模糊测试工具的行为通常由概率驱动,而不同的概率设定会直接影响它能生成的 SQL 事件。例如,在选择下一个要执行的查询时,可以采用如下概率分布:
10% 创建一个新表 (CREATE TABLE)。 50% 执行 SELECT 查询(分析型数据库中最常见,因此应提高其占比)。 20% 插入数据 (INSERT)。 5% 删除表 (DROP TABLE)。 5% 删除表中的部分数据(ClickHouse 中的轻量级删除)。 10% 执行 ALTER 语句。
这个分布看起来合理,但仔细分析会发现潜在的问题:如果每 20 个查询就会生成一个 `DROP` 语句,那么 `DROP` 操作的执行成功率可能会高于 `CREATE TABLE`,导致数据库中的表可能被频繁删除,而没有足够的新表创建。此外,极端情况下,所有表可能会被 `DROP` 掉,导致测试无法继续进行。同时,如果 `INSERT`、`UPDATE` 和 `DELETE` 语句执行频率较高,而表结构不稳定(频繁 `DROP`),就难以进行长期测试。所有这些看似微小的决策,都可能极大地影响模糊测试工具最终的测试效果。
在构建模糊测试工具时,除了前面提到的关键点,还有许多重要的设计决策需要考虑。以下是一些核心问题,以及 BuzzHouse 针对这些问题的解决方案:
是否需要支持多个并行客户端?如何进行同步? 目前,BuzzHouse 仅支持单个客户端运行,未实现多客户端同步机制。 现有模糊测试工具主要运行在客户端侧,那么如何测试服务器端? 服务器端的模糊测试需要一个单独的工具,而 BuzzHouse 主要用于客户端查询的测试。 如何发现非崩溃类问题,例如错误的查询结果、异常的错误消息或性能问题?BuzzHouse 目前可以检测部分错误查询结果。针对性能问题,可以在后续阶段通过基准测试比较连续查询的执行时间。 查询的复杂度应该如何控制? 以 TPC-H Q5 为例,查询的规模和联接表的数量会影响模糊测试的效率。尤其是大规模的笛卡尔积操作会产生庞大的中间数据,导致测试变慢。BuzzHouse 提供了“深度”(depth)和“宽度”(width)参数,可用于调整查询规模。 表在数据库目录中的生命周期应如何管理?元数据如何更新?BuzzHouse 确保目录中始终保留至少 3 张表,并且 `ALTER` 语句会作用于所有表,但不会影响那些依赖于外部数据库的表。 如何有效分析错误消息?许多数据库错误不会直接导致崩溃,而是返回异常的错误消息。因此,除了崩溃检测外,BuzzHouse 未来可能会维护一个错误消息白名单,用于比对 ClickHouse 预期返回的错误。 如何区分合理的慢查询与异常的慢查询?这一点较难精确界定,不过可以通过对比 ClickHouse 与其他数据库的查询性能来分析哪些查询可能存在问题。
以上这些因素使得模糊测试工具的设计变得异常复杂,一个简单的架构决策都会影响其测试范围和发现问题的能力。
为了确保测试的有效性,BuzzHouse 采用了一些关键策略来尽可能生成符合 SQL 语法的正确查询:
为函数调用生成正确数量的参数。
仅使用查询中涉及表的列,避免引用不存在的字段。
跟踪 CREATE、ALTER 和 DROP 语句的变更,确保目录的一致性。
始终保持 SQL 语法正确。
然而,实现这些功能的代价是代码库的复杂性增加,并且需要在 ClickHouse 版本更新时进行维护。此外,某些特殊情况仍无法被测试,例如 SELECT 1 FROM idontexist; 这样的查询不会被生成。
BuzzHouse 旨在弥补现有 ClickHouse 模糊测试工具的关键缺口。
通过生成复杂但语法正确的查询,并识别超出简单崩溃范围的问题,它补充了当前数据库测试工具的生态:
AFL 和 libFuzzer:基于代码覆盖率的模糊测试。
SQLsmith:用于生成复杂 SQL 查询(https://github.com/anse1/sqlsmith)。
SQLancer:用于验证查询的正确性(https://github.com/sqlancer/sqlancer) 。
Pstress:用于高负载测试,计划未来集成到 ClickHouse CI (https://github.com/Percona-QA/pstress)。
Sysbench:用于长时间(超过 1 小时)的持续工作负载测试,也计划用于 CI(https://github.com/akopytov/sysbench)。
BuzzHouse:用于随机生成基于数据库目录的查询。
AST fuzzer:通过变异测试查询,可能与其他模糊测试工具结合使用。
自定义服务器模糊测试脚本:可用于测试服务器端,并集成其他现有的模糊测试工具。
BuzzHouse 已经发现超过 100 个新问题,这表明采用多样化的模糊测试方法对提升 ClickHouse 这样的数据库的稳定性和可靠性至关重要。
目前,BuzzHouse 即将合并到 ClickHouse 源代码中。如果你感兴趣,欢迎查看相关的 Pull Request【https://github.com/ClickHouse/ClickHouse/pull/71085】。
好消息:ClickHouse Shanghai User Group第2届 Meetup 已经开放报名了,将于2025年03月01日在上海 阿里巴巴徐汇滨江园区X区3层X7-301龙门书院 举行,扫码免费报名
注册ClickHouse中国社区大使,领取认证考试券

ClickHouse社区大使计划正式启动,首批过审贡献者享原厂认证考试券!
试用阿里云 ClickHouse企业版
轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G

征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com