alitrack

duckdb_dq: 27 条数据质量规则,一条 SQL 全跑完

数据质量检查,过去是这么干的:写一堆 SQL 手工查 NULL、查重复、查越界,再攒一套 Python 脚本定时跑,跑完也没人看。规则散在几十个文件里,新同事不知道查过什么,旧规则没人敢删。

我写了个 DuckDB 扩展,把这事变成一条 SQL。

● ● ●

27 条规则,一条 SQL 跑完

duckdb_dq 是跑在 DuckDB 里的数据质量检查框架。不用 Python,不用外部服务,规则直接写成函数调用:

-- 单条检查:0 行 = 通过
SELECT * FROM expect_not_null('sales', 'amount');
-- 枚举值、正则、外键、自定义条件
SELECT * FROM expect_accepted_values('customers', 'status', 'active,inactive,suspended');
SELECT * FROM expect_match_regex('customers', 'email', '^[^@]+@[^@]+\\.com$');
SELECT * FROM expect_relationship('orders', 'customer_id', 'customers', 'id');
SELECT * FROM expect_custom_sql('orders', 'amount < 0');

像 expect_not_null、expect_unique、expect_in_range 这样的断言一共有 27 条,覆盖了 Great Expectations 的常用规则集:统计边界(min/max/mean/stddev/median/quantile)、比例(NULL 占比、去重占比)、结构(列类型、组合唯一)、否定(不在集合、不匹配正则)、排序、日期格式。

批量检查用一条 JSON 规则集:

SELECT * FROM validate_expectations('sales', '{
  "expect_table_row_count_between":   {"min": 100, "max": 10000000},
  "expect_column_values_not_null":    {"column": "order_id"},
  "expect_column_values_unique":      {"column": "order_id"},
  "expect_column_values_in_range":    {"column": "amount", "min": 0, "max": 100000},
  "expect_column_values_match_regex": {"column": "email", "pattern": "^[^@]+@[^@]+$"},
  "expect_column_relationship":       {"column": "customer_id", "to_table": "customers", "to_column": "id"},
  "expect_custom_sql":                {"sql": "{table}.amount < 0"}
}');

这套 JSON 规则本身可以当成机器可校验的数据契约,存进仓库、跟着表结构一起评审。

● ● ●

怎么做到的:断言编译成 SQL

expect_in_range('sales','amount',0,100) 内部就是一条这样的查询:

SELECT COUNT(*) FROM sales WHERE amount IS NULL OR amount < 0 OR amount > 100

也就是说:DuckDB 的向量引擎负责数数,Rust 这边一行数据都不碰。检查的规模从几千行到几亿行,耗时跟着 DuckDB 的查询优化走,不会因为规则写得烂就慢。

中间有个绕不过去的坑:DuckDB 不允许在函数回调里直接查主连接,否则死锁。我让扩展在初始化回调里提前建好一条独立连接,所有断言共用它。macOS ARM64 上连接必须建得足够早,晚了就失败——这个顺序问题花了不少时间才定位到。

● ● ●

它还能告诉你该查什么

大部分人不知道自己的表该查什么。我加了个 dq_suggest:先做一遍画像(每列的行数、NULL 占比、去重率、min/max),然后按严重程度给出候选规则——比如某列 NULL 率 40% 就建议查 not_null,某列 99% 都是同一个值就提示近常量。

SELECT * FROM dq_suggest('sales');
-- rule | column_name | severity | reason | params

建议出来的参数可以直接喂回 validate_expectations,形成闭环。

检查结果还能存历史:dq_run('daily_sales', 'sales', '{...}') 跑一次并落一行报告,dq_reports() 看时间序列——今天比上周多出的 3 条脏数据,一眼就能看见。

● ● ●

45 个测试用例

27 条断言配了 45 个测试用例,覆盖正常通过、边界、NULL、类型错误各种情况。测试直接在 DuckDB 里跑:加载扩展,执行 SQL 断言,对比结果。

● ● ●

提交进 DuckDB 官方扩展市场

发布比写代码麻烦。三个坑值得一提:

仓库原来设成私有。GitHub 上私有仓库的 Actions 走付费额度,CI 一跑就"秒挂"——2 秒失败、零步骤日志,看起来像 workflow 写错了,查了半天。改成 public 之后立刻全绿。

第二个坑是过期的子模块声明。.gitmodules 里写着 extension-ci-tools 是子模块,但文件早就直接提交进仓库了。官方的构建流水线会用 submodules: recursive 拉代码,遇到这种声明和实际不一致的情况直接 checkout 失败。删掉那行过期的声明就通了。

最后,注册表要求 extensions/dq/ 目录名和扩展名完全一致,description.yml 用固定的字段格式。我提交了 PR(duckdb/community-extensions#2583),CI 在 v1.5.5 上构建通过后,合入就能 INSTALL dq FROM community;。

● ● ●

怎么用

仓库在 github.com/alitrack/duckdb_dq,MIT 协议,tag v0.1.0。本地构建:

make configure
make release
(echo "LOAD './build/release/dq.duckdb_extension';"; cat test/dq_test.sql) | duckdb -unsigned

等注册表合入,直接 INSTALL dq FROM community; 就能用。

● ● ●

参考来源

  1. 01
    duckdb_dq 仓库:https://github.com/alitrack/duckdb_dq
  2. 02
    社区注册表 PR:#2583,https://github.com/duckdb/community-extensions/pull/2583
  3. 03
    DuckDB 社区扩展列表:https://duckdb.org/community_extensions/
  4. 04
    Great Expectations:https://greatexpectations.io/

我自己的经验是,规则能不能活下来,取决于它写起来多容易、结果看起来多直接。写进 SQL、存进表里,半年后还有人记得去跑它。

你的管道里最想先查哪张表?留言告诉我,我把这张表的画像和候选规则跑给你看。