数据库基础体系 · 第 135/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
数据质量与治理:血缘、口径、约束、校验、责任人和变更审计
数据治理不是给表增加几列“负责人”和“更新时间”,也不是定期运行几条空值检查 SQL。它要回答一组相互关联的问题:
- 一个指标由哪些字段、表、任务和业务规则产生?
- “订单金额”“活跃用户”“已支付”这些名称,在不同系统中是否代表同一个概念?
- 哪些错误必须由数据库拒绝,哪些错误只能通过批量或跨系统校验发现?
- 发现异常后,谁负责判断、修复、批准和验证?
- 表结构、规则、数据和任务发生变化时,如何知道谁在什么时候做了什么?
- 在复制、异步任务、跨库事务和故障恢复中,哪些质量保证仍然成立?
本文以 PostgreSQL 和 MySQL 8.4 的公开稳定语义为基础。示例会明确数据库引擎;涉及事务时,默认讨论单个数据库实例内的事务,不把跨数据库、消息队列或数据仓库的原子性假设为数据库自动提供的能力。
一、先建立对象:数据质量治理到底治理什么
1. 数据质量是数据相对于约束和用途的符合程度
“数据质量高”不是绝对属性。一个字段可以满足数据库类型约束,却不满足业务使用要求。
例如:
birth_date = '2024-01-01'
它可能是合法的 date,也可能不符合“用户必须年满 18 岁”的业务规则。数据库类型只能判断它是不是日期,不能自动知道业务语义。
因此,数据质量应至少拆成以下维度:
- 完整性(completeness):应该存在的数据是否存在。
- 有效性(validity):值是否符合类型、格式、范围和枚举规则。
- 一致性(consistency):相关字段、表或系统之间是否满足关系。
- 唯一性(uniqueness):不应重复的业务实体是否重复。
- 及时性(timeliness):数据是否在约定时间内到达或更新。
- 准确性(accuracy):数据是否与被描述的现实事实一致。
前五项通常可以由数据库约束、查询和作业部分保证;准确性经常需要外部事实、人工确认或业务流程参与,不能仅靠数据库推导。
2. 约束、校验和治理规则不是同一个层次
可以将规则分为四层:
| 层次 | 典型对象 | 主要作用 | 失败时机 |
|---|---|---|---|
| 类型与结构 | integer、date、NOT NULL |
防止值无法存储或违反基础结构 | 写入时 |
| 行级约束 | CHECK、UNIQUE、主键 |
判断单行或单列组合是否合法 | 写入时 |
| 关系约束 | 外键、唯一索引 | 保证表内实体关系 | 写入或删除时 |
| 跨行、跨表、跨系统校验 | 对账、总额核对、延迟检测 | 发现局部约束无法表达的问题 | 定时或事件触发 |
这四层并非互相替代。例如:
NOT NULL可以阻止缺失,但不能保证字符串不是空串。CHECK (amount >= 0)可以阻止负数,但不能保证金额与支付系统一致。- 外键可以保证引用的键存在,但不能保证引用的客户处于“可下单”状态。
- 批量对账可以发现跨系统丢单,但不能代替本地事务中的主键和外键。
3. 数据治理是规则、元数据、责任和变更控制的闭环
本文中的“治理”指围绕数据建立并持续执行的控制体系,至少包括:
- 定义:字段和指标的名称、含义、单位、粒度和时间口径。
- 来源与血缘:数据从哪里来,经过哪些转换,最终被谁使用。
- 约束与校验:什么数据可以进入,什么异常必须被发现。
- 责任人:谁拥有业务定义,谁负责技术运行,谁处理异常。
- 审计:谁改变了结构、规则、数据或治理元数据。
- 处置:异常如何分级、隔离、修复、复核和关闭。
仅有数据字典而没有执行规则,治理停留在文档;仅有约束而没有口径和责任,系统会产生“合法但错误”的数据。
二、数据血缘:从“字段来源”到“影响范围”
1. 血缘的定义
**数据血缘(data lineage)**描述数据对象之间的来源、转换和使用关系。最小形式是有向图:
其中:
- 是节点,例如数据库、表、列、文件、消息主题、任务、指标和报表;
- 是有向边,例如“读取”“转换”“写入”“依赖”“展示”。
一条字段级血缘可以表示为:
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;
如果只记录“报表依赖 customer 和 orders”,则无法解释:
- 没有订单的客户为什么不出现在结果中;
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 工具中保存的计算字段;
- 业务指标的自然语言定义。
因此,血缘采集应组合多个来源:
- 数据库对象和约束;
- SQL 或任务代码解析;
- 作业运行记录;
- 消息、文件和 API 的契约;
- 指标定义和人工确认。
反例是只依靠数据库查询日志。日志可能受采样、保留期、权限和动态 SQL 影响,也不能可靠表达尚未执行的新任务。
三、数据口径:同一个名字不等于同一个指标
1. 口径的组成
数据口径是对一个字段、事件或指标的可执行定义。一个完整口径至少包含:
分别表示:
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. 时间边界必须明确
对时间区间,推荐使用半开区间:
例如查询 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
可能表示:
- 尚未计算;
- 不适用;
- 数据缺失;
- 确实没有折扣但系统约定用 NULL 表示。
如果直接:
SUM(discount_amount)
在 PostgreSQL 和 MySQL 中,聚合会忽略 NULL;但全是 NULL 或没有行时,SUM 的结果可能是 NULL,而不是 0。若业务定义要求“无折扣为 0”,应显式写出:
COALESCE(SUM(discount_amount), 0)
但 COALESCE 只能改变展示或计算结果,不能修复“缺失”和“确实为零”被混用的问题。正确做法是先确定口径,再决定是否允许 NULL,以及是否需要额外的状态字段。
四、数据库约束:让不变量在写入边界成立
1. 不变量与约束
**不变量(invariant)**是在系统允许的状态中始终成立的条件。例如:
表示每一行的 amount 必须大于等于 0。
若把该条件交给数据库约束,则每个成功提交的事务都应满足:
其中 是约束集合。约束的价值在于:错误数据不会先进入正式表,再依靠下游发现。
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 使用三值逻辑:TRUE、FALSE、UNKNOWN。NULL 参与比较时,结果通常是 UNKNOWN。
在 PostgreSQL 和 MySQL 8.4 中,CHECK 约束拒绝条件为 FALSE 的情况;如果表达式结果为 TRUE 或 UNKNOWN,通常不会因该 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. 外键保证存在性,不保证业务状态
外键约束保证:
它不保证:
customer.status = 'ACTIVE'
也不保证客户没有被冻结、订单金额与账户额度匹配。
若要求“只有 ACTIVE 客户可以创建订单”,有几种实现路径:
- 应用在事务中检查;
- 数据库触发器检查;
- 重新设计状态模型,使业务不变量可由约束表达;
- 写入后由校验任务发现异常。
在多并发场景下,单纯的应用查询可能有竞态:
事务 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 NULL 和 CHECK,这条查询主要用于确认历史数据、导入旁路或复制结果是否异常。
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 跨系统对账
设源系统某天完成订单集合为 ,仓库事实表对应集合为 。最基本的覆盖率是:
但只看覆盖率不够,还应查看:
以及金额差:
即使 coverage=100%,金额转换错误、重复写入或状态映射错误仍可能存在。
3. 校验的状态流转
一个可审计的校验流程可以是:
SCHEDULED
-> RUNNING
-> PASSED
-> PUBLISHED
RUNNING
-> FAILED
-> QUARANTINED
-> REPAIRED
-> RECHECKED
-> PUBLISHED
关键点是:失败数据不应默默覆盖正式结果。
例如仓库每日装载可按以下顺序进行:
- 将数据写入临时批次表;
- 记录
batch_id; - 执行行数、主键、范围、跨表和对账检查;
- 通过后切换批次可见性;
- 失败则标记批次不可发布,保留输入和失败明细。
这比“先写正式表,再删除异常行”更容易回滚和解释。
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 都提供元数据查看能力,但“查看当前结构”不等于“自动拥有完整历史”。
生产中通常组合:
- DDL 迁移文件纳入版本控制;
- 迁移工具记录版本、执行人、时间和校验值;
- 数据库权限限制直接 DDL;
- 数据库日志或审计插件捕获实际 DDL;
- 变更单记录原因、风险、回滚和批准;
- 变更后执行结构和质量验证。
PostgreSQL 的 pg_catalog、information_schema,MySQL 的 information_schema 主要描述当前或可查询的元数据;它们不会自动替代完整的历史变更账本。MySQL 的 DDL 事务行为、隐式提交和锁影响也不能按 PostgreSQL 的事务性 DDL 经验直接推断。执行前必须确认具体语句的事务和锁语义。
九、演进与审计历史:质量规则也需要版本
1. 约束演进的基本问题
给已有表增加 NOT NULL 或 CHECK,需要先回答:
历史数据是否满足新规则?
新写入从什么时候开始满足?
修复期间下游是否继续读取?
失败时如何恢复?
直接执行:
ALTER TABLE orders
ALTER COLUMN amount_cents SET NOT NULL;
如果已有 NULL,操作会失败;即使成功,也要评估锁和扫描成本。
一种较安全的 PostgreSQL 路径是:
- 先统计和隔离违规行;
- 修复或标记违规行;
- 对新数据先启用约束;
- 后台验证历史数据;
- 最后将规则纳入正式发布流程。
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 返回约束错误
事务回滚
应用收到数据库异常
诊断顺序:
- 查看具体约束名;
- 定位输入行和请求;
- 判断是调用方错误、历史规则变化还是并发冲突;
- 修复输入或迁移逻辑;
- 不要通过删除约束来“恢复成功率”,除非已完成风险评估和替代校验部署。
2. 校验失败
表现:
任务执行成功,但批次状态为 FAILED
下游仍应看不到该批次
产生失败明细和告警
若任务只返回非零退出码,却没有保留失败样本、输入批次和规则版本,重跑时可能无法判断失败是否由数据、代码或环境造成。
3. 血缘缺失
表现:
字段下线后多个报表异常
无法找到直接消费者
只能依靠搜索日志和人工询问
诊断方法:
- 检查任务代码和 SQL;
- 查询数据库对象依赖;
- 检查调度平台和消息契约;
- 比对历史运行批次;
- 建立变更前影响扫描。
数据库对象依赖通常无法覆盖动态 SQL、外部脚本和 BI 内部计算,因此“没有查到依赖”不等于“没有消费者”。
4. 审计不可用
常见失败包括:
- 只记录
updated_at,没有旧值; - 只记录数据库账号,没有业务操作者;
- 审计表与主表不在同一事务,导致二者不一致;
- 审计记录可被同一管理员直接删除;
- JSON 全量记录过大,查询和存储成本失控;
- 关键字段脱敏不当,审计表反而扩大敏感数据暴露面。
审计设计必须在完整性、查询性、性能和隐私之间取舍。对高价值字段可以记录列级差异;对低价值字段可以记录行快照或版本号;对不可篡改要求高的场景,应把审计日志导出到受控的追加式存储,并保留校验摘要或签名链。
5. “强约束”也有代价
约束越靠近写入边界,越能阻止错误,但也可能:
- 增加写入延迟;
- 使历史脏数据无法直接迁移;
- 让部分导入流程失败;
- 造成应用版本兼容问题;
- 在高并发下引入锁竞争。
因此不应把所有规则都放进触发器或复杂约束。规则应按“必须在提交时成立”“允许延迟发现”“只能跨系统对账”分层。关键不是约束数量,而是每条不变量都有明确的执行位置和失败处置。
十二、如何判断治理是否真正有效
一个治理控制至少应具备以下可验证属性:
- 规则明确:能写成 SQL、约束、程序判断或可测量的阈值。
- 对象明确:知道作用于哪张表、哪列、哪个批次或哪个指标。
- 时间明确:知道从何时生效,使用哪个时间字段。
- 结果可重现:保留输入快照、规则版本和任务版本。
- 失败可隔离:异常不会无标记地进入正式消费层。
- 责任可路由:失败后能找到判断和修复负责人。
- 变更可追溯:能回答谁、何时、为何改变规则或结构。
- 恢复可验证:修复后重新运行校验,而不是直接把状态改成成功。
可以用一个简单的控制闭环表示:
其中:
Contract定义输入和输出的结构、口径与兼容性;Constraint/Validation执行质量规则;Lineage记录来源和影响;Owner决定谁处理;Audit保存变化证据;Repair修复数据、代码或定义;Revalidation证明修复确实生效。
如果缺少任何一个环节,系统可能仍能运行,但无法稳定解释数据、定位责任和恢复历史。
数据质量的核心不是让所有数据永远不出错,而是让错误尽可能早地被阻止或发现,让合法数据的语义清楚,让每次转换和变更都可追溯,并让异常在明确的责任边界内得到验证和关闭。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:云托管数据库选型:责任边界、高可用、扩缩容、成本和退出策略
- 下一篇:数据库隐私与数据生命周期:分类、最小化、脱敏、留存和删除
- 延伸:数据库 Schema 设计:数据类型、主外键、约束、NULL 与演进
- 延伸:时态数据与审计历史:有效时间、系统时间、版本表和可追溯性
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论