数据库基础体系 · 第 135/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

数据质量与治理:血缘、口径、约束、校验、责任人和变更审计

数据治理不是给表增加几列“负责人”和“更新时间”,也不是定期运行几条空值检查 SQL。它要回答一组相互关联的问题:

  • 一个指标由哪些字段、表、任务和业务规则产生?
  • “订单金额”“活跃用户”“已支付”这些名称,在不同系统中是否代表同一个概念?
  • 哪些错误必须由数据库拒绝,哪些错误只能通过批量或跨系统校验发现?
  • 发现异常后,谁负责判断、修复、批准和验证?
  • 表结构、规则、数据和任务发生变化时,如何知道谁在什么时候做了什么?
  • 在复制、异步任务、跨库事务和故障恢复中,哪些质量保证仍然成立?

本文以 PostgreSQL 和 MySQL 8.4 的公开稳定语义为基础。示例会明确数据库引擎;涉及事务时,默认讨论单个数据库实例内的事务,不把跨数据库、消息队列或数据仓库的原子性假设为数据库自动提供的能力。


一、先建立对象:数据质量治理到底治理什么

1. 数据质量是数据相对于约束和用途的符合程度

“数据质量高”不是绝对属性。一个字段可以满足数据库类型约束,却不满足业务使用要求。

例如:

birth_date = '2024-01-01'

它可能是合法的 date,也可能不符合“用户必须年满 18 岁”的业务规则。数据库类型只能判断它是不是日期,不能自动知道业务语义。

因此,数据质量应至少拆成以下维度:

  1. 完整性(completeness):应该存在的数据是否存在。
  2. 有效性(validity):值是否符合类型、格式、范围和枚举规则。
  3. 一致性(consistency):相关字段、表或系统之间是否满足关系。
  4. 唯一性(uniqueness):不应重复的业务实体是否重复。
  5. 及时性(timeliness):数据是否在约定时间内到达或更新。
  6. 准确性(accuracy):数据是否与被描述的现实事实一致。

前五项通常可以由数据库约束、查询和作业部分保证;准确性经常需要外部事实、人工确认或业务流程参与,不能仅靠数据库推导。

2. 约束、校验和治理规则不是同一个层次

可以将规则分为四层:

层次 典型对象 主要作用 失败时机
类型与结构 integerdateNOT NULL 防止值无法存储或违反基础结构 写入时
行级约束 CHECKUNIQUE、主键 判断单行或单列组合是否合法 写入时
关系约束 外键、唯一索引 保证表内实体关系 写入或删除时
跨行、跨表、跨系统校验 对账、总额核对、延迟检测 发现局部约束无法表达的问题 定时或事件触发

这四层并非互相替代。例如:

  • NOT NULL 可以阻止缺失,但不能保证字符串不是空串。
  • CHECK (amount >= 0) 可以阻止负数,但不能保证金额与支付系统一致。
  • 外键可以保证引用的键存在,但不能保证引用的客户处于“可下单”状态。
  • 批量对账可以发现跨系统丢单,但不能代替本地事务中的主键和外键。

3. 数据治理是规则、元数据、责任和变更控制的闭环

本文中的“治理”指围绕数据建立并持续执行的控制体系,至少包括:

  • 定义:字段和指标的名称、含义、单位、粒度和时间口径。
  • 来源与血缘:数据从哪里来,经过哪些转换,最终被谁使用。
  • 约束与校验:什么数据可以进入,什么异常必须被发现。
  • 责任人:谁拥有业务定义,谁负责技术运行,谁处理异常。
  • 审计:谁改变了结构、规则、数据或治理元数据。
  • 处置:异常如何分级、隔离、修复、复核和关闭。

仅有数据字典而没有执行规则,治理停留在文档;仅有约束而没有口径和责任,系统会产生“合法但错误”的数据。


二、数据血缘:从“字段来源”到“影响范围”

1. 血缘的定义

**数据血缘(data lineage)**描述数据对象之间的来源、转换和使用关系。最小形式是有向图:

G=(V,E)G=(V,E)

其中:

  • VV 是节点,例如数据库、表、列、文件、消息主题、任务、指标和报表;
  • EE 是有向边,例如“读取”“转换”“写入”“依赖”“展示”。

一条字段级血缘可以表示为:

raw.orders.paid_at
    -- CAST、时区转换、过滤 status='PAID' -->
dwd.order_facts.paid_at
    -- 按 customer_id 去重、按月聚合 -->
ads.customer_monthly_metrics.paid_order_count
    -- 指标定义 -->
报表:月活跃付费客户

血缘不仅是“这个字段来自哪个字段”,还应记录:

  • 转换表达式;
  • 过滤条件;
  • 连接条件;
  • 聚合粒度;
  • 时间窗口;
  • 任务版本;
  • 运行批次或消息偏移量;
  • 失败、重试和回补情况。

否则只能得到静态依赖,无法解释某一批数据为何产生。

2. 三种常见血缘

2.1 设计时血缘

设计时血缘来自 SQL、ETL 配置、数据模型和任务代码。例如:

INSERT INTO mart.customer_revenue(customer_id, revenue)
SELECT customer_id, SUM(amount)
FROM dwd.order_fact
WHERE status = 'PAID'
GROUP BY customer_id;

可以提取出:

dwd.order_fact.customer_id -> mart.customer_revenue.customer_id
dwd.order_fact.amount      -> mart.customer_revenue.revenue
dwd.order_fact.status      -> 过滤条件

优点是完整、稳定、可在任务运行前检查。缺点是 SQL 动态生成、存储过程、外部脚本和人工导入可能难以静态解析。

2.2 运行时血缘

运行时血缘记录某次实际执行:

job_id=job_20250301_001
source_snapshot=orders_binlog@position=...
target_batch=2025-03-01
started_at=...
finished_at=...
status=SUCCESS

它回答的是:“这份产出实际由哪一次输入和哪一次任务产生?”

运行时血缘对于回溯和重放尤其重要。静态 SQL 可能没有改变,但输入快照、规则参数或依赖任务版本改变了,结果仍然可能不同。

2.3 业务血缘

业务血缘把技术字段映射到业务概念:

“已支付订单数”
= COUNT(DISTINCT order_id)
  WHERE payment_status = 'PAID'
  AND order_status <> 'CANCELLED'
  AND paid_at < 统计截止时间

它需要定义:

  • 统计对象:订单还是支付单;
  • 去重键:order_id 还是 payment_id
  • 状态集合;
  • 时间字段和时区;
  • 统计粒度;
  • 是否包含退款、撤销和测试订单。

业务血缘不应被“字段名相同”替代。两个系统都存在 amount,不代表一个是含税订单金额,另一个也是。

3. 血缘图中的边不能只写“依赖”

对于数据治理,以下边的语义不同:

  • reads:任务读取了某表;
  • derives:目标字段由源字段计算得到;
  • filters:源数据影响是否进入结果;
  • joins:源表通过某条件参与匹配;
  • aggregates:多个源行汇总成一个目标行;
  • publishes:数据被发布到下游;
  • defines:指标依赖某个口径定义。

例如:

SELECT c.customer_id, SUM(o.amount)
FROM customer c
JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'PAID'
GROUP BY c.customer_id;

如果只记录“报表依赖 customerorders”,则无法解释:

  • 没有订单的客户为什么不出现在结果中;
  • status 变更为什么会影响指标;
  • customer_id 的连接是否会造成一对多放大;
  • SUM(amount) 是否包含退款。

4. 血缘的正向和反向用途

正向血缘(upstream lineage):从结果追溯来源,用于:

  • 指标异常回溯;
  • 数据修复;
  • 审计取证;
  • 解释某个值的计算过程。

反向血缘(downstream lineage):从源字段查找影响对象,用于:

  • Schema 变更评估;
  • 下线字段前的影响分析;
  • 权限和敏感数据传播分析;
  • 任务迁移和故障通知。

例如要删除 orders.amount,反向血缘至少需要找到:

orders.amount
 -> dwd.order_fact.amount
 -> mart.customer_revenue.revenue
 -> 财务日报
 -> 月度结算流程

只有知道终点,才能判断删除是普通清理,还是破坏财务流程。

5. 血缘的边界

数据库通常知道本地 SQL、表和约束,却不一定知道:

  • 应用程序在写入前做了哪些转换;
  • 导出的 CSV 被谁手工修改;
  • 消息队列中的字段如何映射到目标表;
  • BI 工具中保存的计算字段;
  • 业务指标的自然语言定义。

因此,血缘采集应组合多个来源:

  1. 数据库对象和约束;
  2. SQL 或任务代码解析;
  3. 作业运行记录;
  4. 消息、文件和 API 的契约;
  5. 指标定义和人工确认。

反例是只依靠数据库查询日志。日志可能受采样、保留期、权限和动态 SQL 影响,也不能可靠表达尚未执行的新任务。


三、数据口径:同一个名字不等于同一个指标

1. 口径的组成

数据口径是对一个字段、事件或指标的可执行定义。一个完整口径至少包含:

Definition=(Object, Grain, Filter, Time, Unit, Dedup, Null, Version)Definition = (Object,\ Grain,\ Filter,\ Time,\ Unit,\ Dedup,\ Null,\ Version)

分别表示:

  • Object:统计对象,例如订单、支付单、用户;
  • Grain:粒度,例如一行一个订单、一天一个用户;
  • Filter:过滤条件;
  • Time:使用哪个时间字段、时区和截止边界;
  • Unit:元、分、件、人数等单位;
  • Dedup:如何去重;
  • Null:空值如何解释;
  • Version:规则何时生效。

例如,“日活跃用户”不能只写成 DAU。至少应定义:

对象:用户
粒度:用户-自然日
行为:当天发生过一次有效登录或有效业务事件
去重:COUNT(DISTINCT user_id)
时间:Asia/Shanghai 自然日
排除:测试账号、已删除账号
空值:不产生用户记录,不计为 0 用户
版本:v2,自 2025-01-01 生效

2. 口径冲突的完整算例

假设某天有以下事件:

user_id event_time(UTC) event_type is_test
1 2025-01-01 23:30 login false
1 2025-01-02 00:30 login false
2 2025-01-01 15:00 login true
3 2025-01-01 15:00 page_view false

定义 A:

登录用户数,按 UTC 日期,排除测试账号

结果:

  • 2025-01-01:用户 1,共 1;
  • 2025-01-02:用户 1,共 1。

定义 B:

UTC 时间转为北京时间后:

  • 用户 1 的两次登录都属于 2025-01-02;
  • 用户 3 的页面访问属于 2025-01-01。

结果:

  • 2025-01-01:用户 3,共 1;
  • 2025-01-02:用户 1,共 1。

两个结果都可以由正确 SQL 计算出来,但它们不是同一个指标。若报表只显示“活跃用户”,数据质量团队无法单凭数值判断哪一个正确。

3. 时间边界必须明确

对时间区间,推荐使用半开区间:

[start, end)[start,\ end)

例如查询 2025 年 1 月:

WHERE event_time >= TIMESTAMP '2025-01-01 00:00:00'
  AND event_time <  TIMESTAMP '2025-02-01 00:00:00'

这样相邻区间:

[2025-01-01, 2025-02-01)
[2025-02-01, 2025-03-01)

不会重复计算 2025-02-01 00:00:00 的事件。

反例:

WHERE event_time BETWEEN '2025-01-01' AND '2025-01-31'

BETWEEN 是两端包含的。若字段是时间戳,'2025-01-31' 通常表示当天零点,而不是整天结束,容易漏掉 1 月 31 日的大部分数据。即使改成 23:59:59,也会受到小数秒精度影响。

4. 口径应当可执行、可版本化

不可执行的定义:

收入 = 系统里所有收入

可执行的定义:

收入 v3:
- 对象:已完成订单
- 金额:order_fact.net_amount,单位分
- 条件:order_status='COMPLETED'
- 时间:completed_at,按 UTC
- 排除:测试商户
- 退款:从退款事实表按退款完成时间扣除
- 生效:2025-04-01 00:00 UTC

口径变更不能直接覆盖原文。至少应保留:

metric_name
version
definition
effective_from
effective_to
approved_by
change_ticket

否则历史报表无法回答“当时为什么是这个数”。

5. NULL 是口径的一部分

NULL 表示未知、缺失或不适用,不能自动等同于 0、空字符串或 false。

例如订单折扣:

discount_amount = NULL

可能表示:

  1. 尚未计算;
  2. 不适用;
  3. 数据缺失;
  4. 确实没有折扣但系统约定用 NULL 表示。

如果直接:

SUM(discount_amount)

在 PostgreSQL 和 MySQL 中,聚合会忽略 NULL;但全是 NULL 或没有行时,SUM 的结果可能是 NULL,而不是 0。若业务定义要求“无折扣为 0”,应显式写出:

COALESCE(SUM(discount_amount), 0)

COALESCE 只能改变展示或计算结果,不能修复“缺失”和“确实为零”被混用的问题。正确做法是先确定口径,再决定是否允许 NULL,以及是否需要额外的状态字段。


四、数据库约束:让不变量在写入边界成立

1. 不变量与约束

**不变量(invariant)**是在系统允许的状态中始终成立的条件。例如:

row, amount0\forall row,\ amount \ge 0

表示每一行的 amount 必须大于等于 0。

若把该条件交给数据库约束,则每个成功提交的事务都应满足:

Stateafter commitCState_{after\ commit} \models C

其中 CC 是约束集合。约束的价值在于:错误数据不会先进入正式表,再依靠下游发现。

2. 一个 PostgreSQL 示例

以下示例使用 PostgreSQL:

CREATE TABLE customer (
    customer_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       TEXT NOT NULL,
    status      TEXT NOT NULL
        CHECK (status IN ('ACTIVE', 'SUSPENDED', 'DELETED')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT customer_email_not_blank
        CHECK (btrim(email) <> ''),
    CONSTRAINT customer_email_unique
        UNIQUE (email)
);

CREATE TABLE orders (
    order_id     BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id  BIGINT NOT NULL,
    amount_cents BIGINT NOT NULL
        CHECK (amount_cents >= 0),
    status       TEXT NOT NULL
        CHECK (status IN ('PENDING', 'PAID', 'CANCELLED')),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT orders_customer_fk
        FOREIGN KEY (customer_id)
        REFERENCES customer(customer_id)
);

这些约束分别保证:

  • 主键:每个实体有唯一标识,且不能为 NULL;
  • NOT NULL:字段必须有值;
  • CHECK:值必须在允许范围;
  • UNIQUE:邮箱不能重复;
  • 外键:订单引用的客户必须存在;
  • 默认值:未显式提供创建时间时由数据库生成。

它们只保证已经表达的条件。数据库不会从 status='PAID' 自动推断“必须存在支付记录”,因为这需要另一张表和更复杂的业务规则。

3. CHECK 的三值逻辑陷阱

SQL 使用三值逻辑:TRUEFALSEUNKNOWNNULL 参与比较时,结果通常是 UNKNOWN

在 PostgreSQL 和 MySQL 8.4 中,CHECK 约束拒绝条件为 FALSE 的情况;如果表达式结果为 TRUEUNKNOWN,通常不会因该 CHECK 失败。

因此:

CHECK (amount_cents > 0)

并不等价于“amount_cents 必须为正数”。如果 amount_cents 可以为 NULL,则 NULL 可能通过 CHECK

应根据意图写成:

amount_cents BIGINT NOT NULL
    CHECK (amount_cents > 0)

如果允许 0:

amount_cents BIGINT NOT NULL
    CHECK (amount_cents >= 0)

类似地:

CHECK (start_at < end_at)

若两个字段允许 NULL,可能无法保证“有值时起止顺序正确”之外的更强条件。应明确:

start_at TIMESTAMPTZ NOT NULL,
end_at   TIMESTAMPTZ NOT NULL,
CHECK (start_at < end_at)

4. UNIQUE 与 NULL 不是“绝对唯一”

唯一性与 NULL 的行为受数据库语义影响。常见语义是:多个 NULL 不被视为彼此相等,因此唯一约束可以存在多行 NULL。

如果业务要求“每个账户都必须有唯一外部编号”,应使用:

external_id TEXT NOT NULL UNIQUE

而不是只写:

external_id TEXT UNIQUE

如果业务要求“只有非 NULL 值唯一”,则允许 NULL 的唯一约束通常正好表达该意图;但仍需确认目标引擎和具体索引语义。

邮箱还存在大小写和空白问题:

'Alice@example.com'
' alice@example.com '

数据库可以保证两个字符串字节不同,但业务可能认为它们是同一邮箱。此时必须先定义规范化规则,再通过生成列、表达式索引或写入层统一处理;不能把普通 UNIQUE 当作业务实体唯一性的完整实现。

5. 外键保证存在性,不保证业务状态

外键约束保证:

orders.customer_idcustomer.customer_idorders.customer\_id \in customer.customer\_id

它不保证:

customer.status = 'ACTIVE'

也不保证客户没有被冻结、订单金额与账户额度匹配。

若要求“只有 ACTIVE 客户可以创建订单”,有几种实现路径:

  1. 应用在事务中检查;
  2. 数据库触发器检查;
  3. 重新设计状态模型,使业务不变量可由约束表达;
  4. 写入后由校验任务发现异常。

在多并发场景下,单纯的应用查询可能有竞态:

事务 A:读取客户为 ACTIVE
事务 B:将客户改为 SUSPENDED 并提交
事务 A:仍然创建订单

若这个不变量必须在数据库提交边界成立,需要考虑锁、隔离级别、触发器或其他数据库级方案。具体方案要结合并发模型和性能代价,不能把“先查再写”视为原子操作。

6. PostgreSQL 与 MySQL 的约束边界

PostgreSQL

PostgreSQL 支持主键、唯一约束、外键、CHECK、排他约束等机制。对已有大表增加约束时,可以先使用:

ALTER TABLE orders
ADD CONSTRAINT orders_amount_nonnegative
CHECK (amount_cents >= 0) NOT VALID;

NOT VALID 表示不立即扫描并验证已有行,但新插入或更新的行仍会受到约束检查。之后可以在业务低峰期执行:

ALTER TABLE orders
VALIDATE CONSTRAINT orders_amount_nonnegative;

若历史数据不满足规则,验证会失败;这不是约束失效,而是准确暴露了存量问题。修复数据后再验证,或重新评估规则。

MySQL 8.4

MySQL 8.4 的 CHECK 约束属于受支持并执行的约束,适用于行级表达式。但它不会自动解决跨行、跨表和跨系统规则。MySQL 的外键行为还受存储引擎、索引和动作配置影响,生产中应确认使用 InnoDB,并验证删除、更新动作是否符合业务要求。

不要把 PostgreSQL 的 NOT VALID 语法移植到 MySQL;两者 DDL 能力和锁定行为不能按名称类推。


五、校验:约束无法表达的质量问题如何被发现

1. 校验不是“再写一遍约束”

**校验(validation)**是对数据状态执行规则判断,并产生通过、失败或异常明细。它既可以在写入时执行,也可以在数据落库后执行。

可以把校验结果抽象为:

check_run(
    check_id,
    dataset,
    snapshot_or_batch,
    started_at,
    finished_at,
    status,
    failed_count,
    threshold,
    rule_version
)

异常明细则记录:

check_failure(
    check_run_id,
    business_key,
    observed_value,
    expected_condition,
    detected_at,
    disposition
)

只有记录“失败了”而没有批次、规则版本和样本键,后续很难复现。

2. 四类校验规则

2.1 单字段校验

SELECT COUNT(*) AS invalid_count
FROM orders
WHERE amount_cents < 0
   OR amount_cents IS NULL;

如果表定义已经有 NOT NULLCHECK,这条查询主要用于确认历史数据、导入旁路或复制结果是否异常。

2.2 组合字段校验

SELECT COUNT(*) AS invalid_count
FROM subscription
WHERE end_at IS NOT NULL
  AND start_at IS NOT NULL
  AND end_at < start_at;

它表达的是跨字段关系,通常可以进一步固化为 CHECK

2.3 跨表一致性校验

例如订单汇总与订单明细:

SELECT h.order_id,
       h.total_cents,
       COALESCE(SUM(i.quantity * i.unit_price_cents), 0) AS detail_total_cents
FROM order_header h
JOIN order_item i ON i.order_id = h.order_id
GROUP BY h.order_id, h.total_cents
HAVING h.total_cents <>
       COALESCE(SUM(i.quantity * i.unit_price_cents), 0);

这类规则需要聚合和多表关系,不能简单用单行 CHECK 表达。

2.4 跨系统对账

设源系统某天完成订单集合为 SS,仓库事实表对应集合为 DD。最基本的覆盖率是:

coverage=SDScoverage = \frac{|S \cap D|}{|S|}

但只看覆盖率不够,还应查看:

missing=SD,extra=DSmissing = S-D,\quad extra = D-S

以及金额差:

amount_diff=xSamountS(x)xDamountD(x)amount\_diff = \sum_{x \in S} amount_S(x) - \sum_{x \in D} amount_D(x)

即使 coverage=100%,金额转换错误、重复写入或状态映射错误仍可能存在。

3. 校验的状态流转

一个可审计的校验流程可以是:

SCHEDULED
  -> RUNNING
  -> PASSED
  -> PUBLISHED

RUNNING
  -> FAILED
  -> QUARANTINED
  -> REPAIRED
  -> RECHECKED
  -> PUBLISHED

关键点是:失败数据不应默默覆盖正式结果。

例如仓库每日装载可按以下顺序进行:

  1. 将数据写入临时批次表;
  2. 记录 batch_id
  3. 执行行数、主键、范围、跨表和对账检查;
  4. 通过后切换批次可见性;
  5. 失败则标记批次不可发布,保留输入和失败明细。

这比“先写正式表,再删除异常行”更容易回滚和解释。

4. 校验中的快照一致性

批量校验必须明确读取哪个数据状态。若校验过程中源表仍在变化,可能出现:

步骤 1:读取订单行数
步骤 2:新订单提交
步骤 3:读取金额总和

行数和金额总和可能来自不同状态,校验结果因此自相矛盾。

在同一 PostgreSQL 事务中,可以根据事务隔离级别读取一致快照;MySQL InnoDB 也有一致性读语义,但具体锁定读与普通快照读的行为不同。校验任务应明确:

  • 是检查某个固定批次;
  • 还是在一个一致性快照中扫描;
  • 是否需要阻止并发修改;
  • 是否接受检查期间的增量差异。

跨系统校验通常不能依靠单数据库事务保证一致快照,应使用源端和目标端的批次号、提取时间、日志位点或水位线定义比较边界。


六、把规则放在正确位置:写入拒绝、隔离还是事后发现

可以用一个简单判断过程选择控制点。

第一步:规则是否是单行、确定性且高频执行的?

例如:

amount_cents >= 0
status ∈ {'PENDING','PAID','CANCELLED'}

这类规则适合数据库约束,因为它们在每次写入时都能低成本判断。

第二步:规则是否跨多行或跨表?

例如:

订单头金额 = 明细金额之和

如果要求任何时刻都成立,可以考虑把写入路径收敛到事务和数据库逻辑中;如果存在批量导入、异步更新或历史兼容阶段,则需要校验任务和异常隔离。

第三步:规则是否跨系统?

例如:

支付平台成功金额 = 订单系统已支付金额

数据库无法在本地事务中直接保证远程系统状态。应使用对账、幂等键、批次水位和补偿流程。

第四步:错误是否允许进入隔离区?

有些数据无法立即修复,但也不能进入下游:

raw_event -> quarantine_event -> 修复/人工确认 -> clean_event

“先接受再校验”不是放弃质量,而是明确把未验证数据放入不同状态和数据域。最危险的做法是把未验证数据直接当作已验证数据发布。


七、责任人:不是通讯录,而是可执行的职责分工

1. 责任人的不同角色

“负责人”至少应拆成以下角色:

  • 业务 Owner:定义业务含义、口径、允许范围和优先级。
  • 数据 Steward:维护字典、血缘、质量规则和异常协调。
  • 技术 Owner:负责表、任务、接口、索引和运行稳定性。
  • 生产者 Owner:负责源数据生成和写入契约。
  • 消费者 Owner:负责下游使用、迁移和兼容。
  • 审批人:批准高风险规则或破坏性变更。
  • 值班响应人:在质量事件发生时执行应急处理。

同一人可以承担多个角色,但角色不能只写成一个模糊的 owner 字段。

2. 责任必须与对象和动作绑定

一个有用的责任记录应回答:

对象:orders.amount_cents
业务定义负责人:财务数据产品负责人
技术运行负责人:订单服务团队
源头修复负责人:订单服务团队
下游报表负责人:经营分析团队
质量规则批准人:财务控制人
SLA:每日 09:00 前完成校验
升级路径:超过 30 分钟未响应通知值班负责人

责任人表可以存储为治理元数据:

CREATE TABLE data_asset_owner (
    asset_name       TEXT PRIMARY KEY,
    business_owner   TEXT NOT NULL,
    technical_owner  TEXT NOT NULL,
    steward          TEXT NOT NULL,
    effective_from   TIMESTAMPTZ NOT NULL,
    effective_to     TIMESTAMPTZ,
    CHECK (effective_to IS NULL OR effective_to > effective_from)
);

真正重要的是它是否被流程使用:

  • 质量告警自动路由给技术 Owner;
  • 口径变更必须由业务 Owner 批准;
  • 破坏性 Schema 变更通知消费者 Owner;
  • 无 Owner 的数据资产不能进入关键报表目录。

3. 责任边界的反例

如果某指标异常时:

报表团队认为源系统负责;
源系统认为仓库转换负责;
仓库团队认为口径本来就不明确。

那么即使有完善的监控,也没有闭环。责任应该沿血缘分配,并明确“发现、判断、修复、批准、验证”分别由谁完成。


八、变更审计:记录什么变了、谁变的、为什么变

1. 审计对象的四种类型

8.1 数据行审计

记录业务数据的新增、修改和删除:

entity_type
entity_id
operation
changed_by
changed_at
request_id
before_value
after_value
reason

它回答:“某个订单的金额从多少变成多少?”

8.2 Schema 审计

记录表、列、约束、索引和权限变化:

ALTER TABLE orders ADD COLUMN currency_code ...
ALTER TABLE orders DROP CONSTRAINT ...

它回答:“谁在什么时候删除了约束?”

8.3 治理元数据审计

记录口径、负责人、质量规则、血缘和敏感级别的变化:

metric = 'revenue'
old_version = 2
new_version = 3
changed_by = ...
approved_by = ...
effective_from = ...

它回答:“为什么同一个指标在本月发生了定义变化?”

8.4 运行审计

记录任务、校验、批次和发布状态:

job_run_id
input_batch
output_batch
rule_version
status
error_count

它回答:“这张报表使用了哪次任务产出的哪一批数据?”

2. 数据审计表的 PostgreSQL 示例

以下示例在 PostgreSQL 中使用行级触发器:

CREATE TABLE orders_history (
    history_id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id     BIGINT NOT NULL,
    operation    TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    changed_at   TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
    changed_by   TEXT NOT NULL DEFAULT current_user,
    request_id   TEXT,
    old_row      JSONB,
    new_row      JSONB
);

CREATE OR REPLACE FUNCTION audit_orders_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO orders_history
            (order_id, operation, request_id, new_row)
        VALUES
            (NEW.order_id, TG_OP, current_setting('app.request_id', true),
             to_jsonb(NEW));
        RETURN NEW;

    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO orders_history
            (order_id, operation, request_id, old_row, new_row)
        VALUES
            (NEW.order_id, TG_OP, current_setting('app.request_id', true),
             to_jsonb(OLD), to_jsonb(NEW));
        RETURN NEW;

    ELSE
        INSERT INTO orders_history
            (order_id, operation, request_id, old_row)
        VALUES
            (OLD.order_id, TG_OP, current_setting('app.request_id', true),
             to_jsonb(OLD));
        RETURN OLD;
    END IF;
END;
$$;

CREATE TRIGGER trg_audit_orders
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_orders_change();

应用在事务中设置请求标识:

BEGIN;

SELECT set_config('app.request_id', 'req-20250301-0001', true);

UPDATE orders
SET status = 'PAID'
WHERE order_id = 1001;

COMMIT;

预期结果是:

  • orders 中订单状态更新;
  • 同一事务中写入一条 orders_history
  • 若更新因约束失败,事务回滚,审计行也随事务回滚;
  • request_id 将数据库变更关联到应用请求。

这里的 current_user 是数据库会话用户,不一定是最终登录用户。因此应用应通过受控会话变量或专用审计机制传入业务操作者,并防止普通业务代码任意伪造。审计表本身还需要访问控制,否则“有记录但任何人都能修改”不具备可靠审计价值。

3. 审计触发器的边界

触发器适合捕获同一数据库内的行变化,但有几个重要边界:

  • 只有经过该表写入路径的变化才会触发;
  • 触发器可能增加写入延迟;
  • 批量更新可能产生大量历史行;
  • UPDATE 审计可能记录整行,而不是只记录变化列;
  • 直接修改审计表或绕过数据库的外部文件无法自动覆盖;
  • 触发器和业务写入处于同一事务,主事务回滚时审计也会回滚。

因此,审计记录与业务事务绑定是一项一致性优点,也是一项可用性边界:如果需要“即使主事务回滚也记录尝试”,就不能只依赖同一事务内的普通审计表,需要外部日志、数据库审计扩展或独立写入路径,并重新处理可靠性和隐私问题。

4. Schema 变更审计

PostgreSQL 和 MySQL 都提供元数据查看能力,但“查看当前结构”不等于“自动拥有完整历史”。

生产中通常组合:

  1. DDL 迁移文件纳入版本控制;
  2. 迁移工具记录版本、执行人、时间和校验值;
  3. 数据库权限限制直接 DDL;
  4. 数据库日志或审计插件捕获实际 DDL;
  5. 变更单记录原因、风险、回滚和批准;
  6. 变更后执行结构和质量验证。

PostgreSQL 的 pg_cataloginformation_schema,MySQL 的 information_schema 主要描述当前或可查询的元数据;它们不会自动替代完整的历史变更账本。MySQL 的 DDL 事务行为、隐式提交和锁影响也不能按 PostgreSQL 的事务性 DDL 经验直接推断。执行前必须确认具体语句的事务和锁语义。


九、演进与审计历史:质量规则也需要版本

1. 约束演进的基本问题

给已有表增加 NOT NULLCHECK,需要先回答:

历史数据是否满足新规则?
新写入从什么时候开始满足?
修复期间下游是否继续读取?
失败时如何恢复?

直接执行:

ALTER TABLE orders
ALTER COLUMN amount_cents SET NOT NULL;

如果已有 NULL,操作会失败;即使成功,也要评估锁和扫描成本。

一种较安全的 PostgreSQL 路径是:

  1. 先统计和隔离违规行;
  2. 修复或标记违规行;
  3. 对新数据先启用约束;
  4. 后台验证历史数据;
  5. 最后将规则纳入正式发布流程。

CHECK ... NOT VALID 可用于部分约束的分阶段验证,但它不是“忽略历史数据后永远不管”。必须最终验证,或者明确历史数据永久处于旧规则版本。

2. 兼容性演进的例子

把:

amount

从“元的小数”改为“分的整数”,不是简单改类型。风险包括:

  • 小数精度截断;
  • 汇率或税额计算变化;
  • 下游仍按元解释;
  • 历史数据和新数据单位不同;
  • 报表聚合出现 100 倍差异。

更可追溯的迁移方式是:

旧字段 amount(元)
新增字段 amount_cents(分)
双写或回填
校验 amount_cents = round(amount * 100)
切换读取
停止旧字段写入
下线旧字段

每一步都应记录迁移版本、校验结果和生效时间。若期间允许双写,必须定义两列不一致时的权威字段和修复策略。

3. 业务历史不能只靠当前值

如果订单状态从 PENDING 变成 PAID 再变成 REFUNDED,当前表只保存:

status = 'REFUNDED'

无法回答“何时支付”“谁触发退款”“状态持续多久”。这时需要版本表或事件表:

CREATE TABLE order_status_history (
    history_id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id     BIGINT NOT NULL,
    status       TEXT NOT NULL,
    valid_from   TIMESTAMPTZ NOT NULL,
    valid_to     TIMESTAMPTZ,
    changed_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    changed_by   TEXT NOT NULL,
    CHECK (valid_to IS NULL OR valid_to > valid_from)
);

这里要区分:

  • 有效时间(valid time):业务事实在现实中生效的时间;
  • 系统时间(system time):数据库知道或记录该事实的时间;
  • 审计时间:谁在什么时候进行了操作。

一条迟到数据可能具有:

valid_from = 2025-01-01
changed_at = 2025-01-05

若只使用 changed_at,会把业务发生时间误认为数据录入时间,历史指标可能被错误归入 1 月 5 日。


十、一个端到端质量控制流程

以订单数据进入分析层为例,控制链可以设计为:

订单服务
  -> orders 约束写入
  -> CDC/导出批次
  -> raw_orders
  -> 清洗和口径转换
  -> dwd_order_fact
  -> 质量校验
  -> 发布到 mart
  -> 报表和指标

第一步:源表约束

源表拒绝:

缺失订单 ID
负金额
未知订单状态
不存在的客户
重复外部订单号

这些规则必须在事务提交前失败。

第二步:批次和运行血缘

每次导出记录:

source_system
source_table
extract_started_at
extract_finished_at
source_watermark
job_version
batch_id

目标表保留:

batch_id
source_order_id
transformation_version
loaded_at

这样能区分:

源数据本身错误
转换逻辑错误
重复装载
漏装
迟到数据

第三步:进入隔离区

若状态映射失败:

source_status = 'REVERSED'

而当前规则不认识 REVERSED,不要把它默认为 CANCELLED。应写入隔离表:

quarantine_reason = 'UNKNOWN_STATUS'
raw_value = 'REVERSED'
rule_version = 'order_status_map_v4'

默认映射为取消可能使数据“看起来完整”,但语义已经被悄悄改变。

第四步:执行校验

至少执行:

订单主键唯一
事实表订单数不超过源批次数量
金额非负
状态值在口径允许集合
订单头与明细金额一致
源端与目标端按批次对账
当天数据在 SLA 内到达

每条规则都需要:

规则 ID
规则版本
阈值
批次
样本
责任人
处置状态

第五步:通过后发布

不要让下游直接读取正在装载的中间表。可以通过:

  • 批次状态表;
  • 分区切换;
  • 视图只选择 status='VALIDATED' 的批次;
  • 事务内更新“当前可见批次”。

如果发布和校验不在同一事务或没有状态门禁,可能出现报表读取到半批数据。


十一、故障路径、诊断和生产取舍

1. 约束失败

表现:

INSERT/UPDATE 返回约束错误
事务回滚
应用收到数据库异常

诊断顺序:

  1. 查看具体约束名;
  2. 定位输入行和请求;
  3. 判断是调用方错误、历史规则变化还是并发冲突;
  4. 修复输入或迁移逻辑;
  5. 不要通过删除约束来“恢复成功率”,除非已完成风险评估和替代校验部署。

2. 校验失败

表现:

任务执行成功,但批次状态为 FAILED
下游仍应看不到该批次
产生失败明细和告警

若任务只返回非零退出码,却没有保留失败样本、输入批次和规则版本,重跑时可能无法判断失败是否由数据、代码或环境造成。

3. 血缘缺失

表现:

字段下线后多个报表异常
无法找到直接消费者
只能依靠搜索日志和人工询问

诊断方法:

  • 检查任务代码和 SQL;
  • 查询数据库对象依赖;
  • 检查调度平台和消息契约;
  • 比对历史运行批次;
  • 建立变更前影响扫描。

数据库对象依赖通常无法覆盖动态 SQL、外部脚本和 BI 内部计算,因此“没有查到依赖”不等于“没有消费者”。

4. 审计不可用

常见失败包括:

  • 只记录 updated_at,没有旧值;
  • 只记录数据库账号,没有业务操作者;
  • 审计表与主表不在同一事务,导致二者不一致;
  • 审计记录可被同一管理员直接删除;
  • JSON 全量记录过大,查询和存储成本失控;
  • 关键字段脱敏不当,审计表反而扩大敏感数据暴露面。

审计设计必须在完整性、查询性、性能和隐私之间取舍。对高价值字段可以记录列级差异;对低价值字段可以记录行快照或版本号;对不可篡改要求高的场景,应把审计日志导出到受控的追加式存储,并保留校验摘要或签名链。

5. “强约束”也有代价

约束越靠近写入边界,越能阻止错误,但也可能:

  • 增加写入延迟;
  • 使历史脏数据无法直接迁移;
  • 让部分导入流程失败;
  • 造成应用版本兼容问题;
  • 在高并发下引入锁竞争。

因此不应把所有规则都放进触发器或复杂约束。规则应按“必须在提交时成立”“允许延迟发现”“只能跨系统对账”分层。关键不是约束数量,而是每条不变量都有明确的执行位置和失败处置。


十二、如何判断治理是否真正有效

一个治理控制至少应具备以下可验证属性:

  1. 规则明确:能写成 SQL、约束、程序判断或可测量的阈值。
  2. 对象明确:知道作用于哪张表、哪列、哪个批次或哪个指标。
  3. 时间明确:知道从何时生效,使用哪个时间字段。
  4. 结果可重现:保留输入快照、规则版本和任务版本。
  5. 失败可隔离:异常不会无标记地进入正式消费层。
  6. 责任可路由:失败后能找到判断和修复负责人。
  7. 变更可追溯:能回答谁、何时、为何改变规则或结构。
  8. 恢复可验证:修复后重新运行校验,而不是直接把状态改成成功。

可以用一个简单的控制闭环表示:

SourceContractConstraint/ValidationLineageOwnerAuditRepairRevalidationSource \rightarrow Contract \rightarrow Constraint/Validation \rightarrow Lineage \rightarrow Owner \rightarrow Audit \rightarrow Repair \rightarrow Revalidation

其中:

  • Contract 定义输入和输出的结构、口径与兼容性;
  • Constraint/Validation 执行质量规则;
  • Lineage 记录来源和影响;
  • Owner 决定谁处理;
  • Audit 保存变化证据;
  • Repair 修复数据、代码或定义;
  • Revalidation 证明修复确实生效。

如果缺少任何一个环节,系统可能仍能运行,但无法稳定解释数据、定位责任和恢复历史。

数据质量的核心不是让所有数据永远不出错,而是让错误尽可能早地被阻止或发现,让合法数据的语义清楚,让每次转换和变更都可追溯,并让异常在明确的责任边界内得到验证和关闭。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。