- Published
DataOps 第二集:数据质量门禁——Great Expectations 实战
Author
Robin
前言:那三小时的"倒贴"补贴
周三上午十点,财务在群里贴了一张截图:昨晚大促的满减券结算明细里,出现了一批金额为负的订单——系统非但没收钱,还倒贴了用户一笔"补贴"。从凌晨一点到四点,整整三个小时,没人发现。
根因查出来让人血压上升:上游订单服务为了支持"退款单",把 settle_amount 字段放开了负值;下游结算 ETL 照单全收,直接把负数当成了应发金额。管道没报错、任务全绿、报表准时产出——只是钱发错了。
开篇我们讲过"静默的错误":上游改字段名,ETL 默默写 NULL。这一集的事故是它的孪生兄弟:上游改了字段的"含义",下游浑然不觉。 数据和代码最大的区别就在这里——代码错了会抛异常、会挂掉;数据错了,它一声不吭,照常流进报表、模型和钱包。
DataOps 的答案是:给数据装上门禁。 数据进仓库之前,先过一道可执行的检查;检查不过,门就关上。这道门禁,就叫 Great Expectations。
一、数据质量问题的本质:约定丢失
先把问题想清楚。数据事故千奇百怪,但病根通常只有一个:上下游之间的约定,只存在于文档、口头和"想当然"里,没有可执行的表达。
- 上游说"这个字段非空",但没有任何东西在它为空时拦下来
- 下游假设"金额不会为负",但这个假设只活在某个人的脑子里
- 有人改了枚举值,通知了群,没通知管道
传统做法是"人肉巡检":每天早上有人看一眼报表,凭感觉发现不对。这本质上是把正确性外包给了人的责任心——而责任心是最不可靠的 SLA。
软件工程早就解决了同类问题:接口契约用测试表达,CI 不过就合并不了代码。Great Expectations 就是把这一套搬到数据世界:把"数据应该长什么样"写成代码,让机器替你守夜。
顺带说一句版本的事,免得你踩坑:GX 在 2024 年 8 月发布了 1.0,API 大变(后面实战部分全部用 1.x 写法);0.18.x 已于 2025 年 10 月 1 日停止维护。网上大量教程还是 0.x 的 Validator、BatchRequest 写法,直接抄会跑不起来。认准 gx.get_context + context.data_sources 这套新 API。本文基于 GX 1.x(截至 2026 年 10 月已迭代到 1.23,据公开资料),Python 要求 3.10 以上。
二、Great Expectations 的核心概念
GX 的域模型可以一句话概括:Data Context → Data Source → Data Asset → Batch Definition → Batch,配上 Expectation → Expectation Suite → Validation Definition → Checkpoint 这条验证链。听着唬人,其实每个概念都对应一个直觉:
- Expectation(期望):一条断言。"
order_id不为空""amount在 0 到 100000 之间""status只能是 paid/refunded/cancelled 之一"。GX 内置了 300 多个,开箱即用,也支持自定义。 - Expectation Suite(期望套件):一组 Expectation 的集合。你可以把它理解成一张表的"数据契约"——这张表长什么样、什么能出现、什么绝不能出现,全写在这里。Suite 进 git 版本控制,改动可 review、可回滚。
- Batch Definition / Batch:Suite 是"规则",Batch 是"这次要检查的数据"。Batch Definition 把"哪张表、哪个分区"和 Suite 绑在一起。
- Validation Definition:Suite + Batch Definition 的绑定——"用这套规则,验这批数据"。
- Checkpoint(检查点):真正可运行的单元。它包含一个或多个 Validation Definition,外加跑完之后的 Actions:更新文档、发告警、写指标。
checkpoint.run()返回成功或失败——这就是你的质量门禁。 - Data Docs:每次验证自动生成的 HTML 报告,谁跑的、哪条挂了、挂掉的值长什么样,一目了然。事故复盘时,这是最有说服力的证据。
用软件工程的语言翻译一下:Expectation 是单元测试的 assert,Suite 是测试文件,Checkpoint 是 CI 里的 test job,Data Docs 是测试报告。思想完全相通,只是被测对象从代码变成了数据。
三、实战:从 0 写一个质量门禁
光讲概念不解渴,直接动手。下面是一个完整可运行的脚本:造一份掺了脏数据的订单表,写 Suite,跑 Checkpoint,看门禁怎么拦下它。
pip install "great_expectations>=1.23" pandas
import pandas as pd
import great_expectations as gx
# 1. 造一份"订单"数据:故意掺三处脏
df = pd.DataFrame(
{
"order_id": ["A001", "A002", "A003", "A002", None], # 重复 + 空值
"amount": [120.5, -9.9, 300.0, 45.0, 88.0], # 负数!
"status": ["paid", "paid", "refunded", "paid", "paid"],
}
)
# 2. Context:ephemeral 模式,不落文件,适合脚本和 CI
context = gx.get_context(mode="ephemeral")
# 3. 数据源 -> 数据资产 -> 批次定义
data_source = context.data_sources.add_pandas("orders_source")
asset = data_source.add_dataframe_asset(name="orders")
batch_definition = asset.add_batch_definition_whole_dataframe("daily_batch")
# 4. Suite:这张表的"契约",每一条都是血泪换来的
suite = context.suites.add(gx.ExpectationSuite(name="orders_contract"))
suite.add_expectation(
gx.expectations.ExpectTableRowCountToBeBetween(min_value=1)
)
suite.add_expectation(
gx.expectations.ExpectColumnValuesToBeUnique(column="order_id")
)
suite.add_expectation(
gx.expectations.ExpectColumnValuesToNotBeNull(column="order_id")
)
suite.add_expectation(
gx.expectations.ExpectColumnValuesToBeBetween(column="amount", min_value=0)
)
suite.add_expectation(
gx.expectations.ExpectColumnValuesToBeInSet(
column="status", value_set=["paid", "refunded", "cancelled"]
)
)
# 5. Validation Definition:规则 + 数据绑一起
validation_definition = context.validation_definitions.add(
gx.ValidationDefinition(
name="orders_daily_validation",
data=batch_definition,
suite=suite,
)
)
# 6. Checkpoint:可运行的门禁,跑完自动更新 Data Docs
checkpoint = context.checkpoints.add(
gx.Checkpoint(
name="orders_daily_checkpoint",
validation_definitions=[validation_definition],
actions=[gx.checkpoint.UpdateDataDocsAction(name="update_data_docs")],
)
)
result = checkpoint.run(batch_parameters={"dataframe": df})
print("门禁通过:", result.success)
if not result.success:
print(result.describe()) # 哪条挂了、挂了多少行,一目了然
raise SystemExit(1)
跑起来,门禁会被三处脏数据拦下:order_id 重复(A002 出现两次)、order_id 为空、amount 出现负数。result.describe() 会告诉你每条 Expectation 的通过情况和坏掉的行数。
注意几个实战细节:
mode="ephemeral":不写本地项目文件,Suite 和 Checkpoint 都在内存里,CI 跑完即焚,干净。要持久化、要 Data Docs 留档、要团队协作,就用mode="file"指向一个 GX 项目目录。- 数据源不只 pandas:
context.data_sources下面有add_postgres、add_snowflake、add_spark等等,对应的 asset 是add_table_asset(name=..., table_name=...),批次定义是add_batch_definition_whole_table(...)。一套 Suite 写法,换数据源基本不用改。 - 先 warning,再 fail:新表第一次上门禁,建议先把严格的 Expectation 设成只告警不阻断,跑一周看误报率,再收紧成硬门禁。否则第一天就可能因为"历史脏数据"把正常管道全拦停——门禁的第一要务是别误杀。
四、Contract Testing:把约定写进上下游之间
门禁拦得住自己管道里的脏数据,但开篇那种事故的根子在上游。这就引出了 contract testing(契约测试):把上下游之间的数据约定,写成双方共同遵守的可执行测试。
做法分两层:
第一层:下游定义契约,上游负责不破坏。 结算团队把 orders_contract 这个 Suite 交给订单团队:"只要你们产出的数据能通过这套 Suite,随便你们怎么重构;过不了,发布就卡住。"上游的 CI 在每次变更后跑一遍下游的 Checkpoint——字段改名、枚举新增、负值放开,都会在合并代码前暴露,而不是凌晨三点暴露。
第二层:契约进版本控制,当成 API 文档用。 Suite 文件本身就是最好的数据文档:新同学想知道 status 有哪些取值,看 Suite 比看 wiki 靠谱,因为 Suite 是会被执行的真相,wiki 是可能过期的传说。Data Docs 则自动把每次验证结果变成可浏览的网页,相当于给数据契约配了个"健康看板"。
这里要区分 GX 和 Pydantic、pandera 这类库:后者是行级/内存级的校验,适合在应用代码里"进来一条验一条";GX 是管道级的门禁,面向的是"整批数据进仓库前验一次",自带文档、历史记录和告警动作。两者不冲突——应用层用 Pydantic 拦非法输入,管道层用 GX 守批量质量,各司其职。
五、把门禁接进 CI/CD
门禁只有进了流水线才有牙齿。两种最常见的接法:
接法一:Airflow 里当关卡。 在 DAG 里加一个 PythonOperator 跑 checkpoint,result.success 为 False 就抛异常。Airflow 的依赖机制会保证:门禁不过,下游的聚合、报表、模型训练任务一个都不会启动。脏数据被物理隔离在门外,而不是流进去再等人发现。
def run_quality_gate(**context):
import great_expectations as gx
gx_context = gx.get_context(mode="file", project_root_dir="/opt/gx")
checkpoint = gx_context.checkpoints.get("orders_daily_checkpoint")
result = checkpoint.run()
if not result.success:
raise ValueError(f"质量门禁未通过:\n{result.describe()}")
接法二:GitHub Actions 里验契约。 上游仓库的 PR 流水线里加一步:拉取下游的 Suite,对本次变更产出的样本数据跑 checkpoint。Schema 变更想合进主分支?先过契约这一关。
# .github/workflows/data-contract.yml
name: data-contract
on: [pull_request]
jobs:
contract-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install "great_expectations>=1.23" pandas
- run: python contracts/run_checkpoints.py # 跑所有下游 Suite
再配上 Checkpoint 的 Actions:失败时发 Slack 告警、成功时更新 Data Docs。一套组合拳下来,数据质量就从"靠人盯"变成了"靠流水线守"——这正是开篇说的 DataOps:把 DevOps 的工程化纪律,系统地应用到数据管道上。
结语
回到开篇那三小时的"倒贴"补贴。如果当时有一道门禁,ExpectColumnValuesToBeBetween(column="settle_amount", min_value=0) 这一行,就会在第一批负数出现时关上大门,顺手发一条告警。事故的发现时间会从"三小时后财务看截图"变成"三秒后 Slack 收到通知"。
Great Expectations 不是银弹——它拦不住业务逻辑本身的错误,也定义不了"什么是对的"。但它解决了一个更底层的问题:把"数据应该长什么样"从人的脑子搬进代码,让约定可执行、让破坏可感知、让事故可复盘。
下一集,我们聊数据的"体检报告":数据可观测性——新鲜度、数据量、分布漂移,异常自动告警,而不是等老板发现。下周三见。