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; 就能用。
● ● ●
参考来源
- 01
duckdb_dq 仓库:https://github.com/alitrack/duckdb_dq - 02
社区注册表 PR:#2583,https://github.com/duckdb/community-extensions/pull/2583 - 03
DuckDB 社区扩展列表:https://duckdb.org/community_extensions/ - 04
Great Expectations:https://greatexpectations.io/
我自己的经验是,规则能不能活下来,取决于它写起来多容易、结果看起来多直接。写进 SQL、存进表里,半年后还有人记得去跑它。
你的管道里最想先查哪张表?留言告诉我,我把这张表的画像和候选规则跑给你看。