数据库基础体系 · 第 80/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
时态数据与审计历史:有效时间、系统时间、版本表和可追溯性
在普通业务表中,一行数据通常表示“当前状态”:
customer_id = 42
status = 'active'
但许多业务问题并不是查询当前值:
- 客户在 2024 年 3 月 1 日当时是什么状态?
- 这条价格从什么时候开始生效?
- 价格虽然从 1 月 1 日起适用,但数据库是在 1 月 10 日才收到这次修正,能否区分这两个时间?
- 当前值是谁、在什么事务中、通过什么请求修改的?
- 数据被改写后,能否恢复修改前的状态?
- 数据库审计记录与 CDC 事件是否能互相替代?
这些问题涉及几个容易混淆的概念:有效时间(valid time)、系统时间(system time)、版本表(version table)和可追溯性(traceability)。它们相关,但不是同一件事。
本文以 PostgreSQL 当前稳定版本公开语义为主给出可运行示例,并补充 MySQL 8.4 的边界。这里的“系统时间”指数据库记录或确认某个事实的时间,不等于数据库内部 MVCC 版本的创建时间,也不等于 CDC 消费者收到事件的时间。
一、先区分四种时间
1. 有效时间:事实在业务世界中何时成立
有效时间描述的是:
一个业务事实在现实业务语义中适用的时间区间。
例如:
商品 A 的价格为 100 元,有效期为:
[2024-01-01 00:00:00, 2024-02-01 00:00:00)
这里采用左闭右开区间:
- 包含
2024-01-01 00:00:00 - 不包含
2024-02-01 00:00:00
因此,下一条价格可以从 2024-02-01 00:00:00 开始,不会与上一条重叠。
有效时间可能是:
- 已经发生的历史时间;
- 当前时间;
- 未来计划生效的时间;
- 只有日期精度,例如某项政策按自然日生效;
- 具有时区含义,例如国际订单的生效时间。
有效时间不是数据库写入时间。下面两条记录可以同时成立:
业务有效时间:2024-01-01
数据库写入时间:2024-01-10
这通常表示“1 月 10 日才收到一条追溯到 1 月 1 日生效的业务事实”。
2. 系统时间:数据库何时知道或记录了事实
系统时间描述的是:
数据库从什么时候开始把某个版本视为已知事实,以及从什么时候开始不再把它视为当前系统认知。
例如:
| price | 有效时间 | 系统记录时间 |
|---|---|---|
| 100 | 1 月 1 日至 2 月 1 日 | 1 月 1 日记录 |
| 90 | 1 月 1 日至 2 月 1 日 | 1 月 10 日记录 |
在 1 月 5 日查询数据库的历史认知时,答案是 100;在 1 月 11 日查询“当时数据库认为 1 月 1 日价格是多少”时,答案可能是 90。
系统时间解决的是:
“数据库在某个时刻知道什么?”
有效时间解决的是:
“业务事实在什么时刻适用?”
二者是不同坐标轴。
3. 事务时间、墙上时钟时间与提交时间
工程上还要区分三种经常被统称为“时间”的值。
事务时间
数据库事务获得的时间。以 PostgreSQL 为例:
SELECT
transaction_timestamp(),
statement_timestamp(),
clock_timestamp();
在一个事务中:
transaction_timestamp()在整个事务期间保持不变;statement_timestamp()在每条语句开始时确定;clock_timestamp()返回调用瞬间的实际时钟时间,可能在同一条语句内变化。
如果审计记录需要表达“本次事务的统一观察时间”,通常使用事务时间更合理。否则,同一事务更新的多行可能出现细微不同的时间。
提交时间
事务真正提交的时间。它与事务开始时间不同:
事务开始:10:00:00
执行更新:10:00:01
等待锁:10:00:08
提交: 10:00:10
许多审计设计把 recorded_at 设为事务开始时间,但这并不表示事务已经对其他事务可见。若业务需要严格表达“数据从何时对外生效”,应明确采用提交语义,或者在应用层记录提交确认时间。
墙上时钟时间
操作系统时钟返回的当前时间。它可能受时钟同步、人工调整或虚拟化环境影响。排序和去重不能只依赖它;同一时间戳不保证事件顺序,时间倒退也可能发生。
二、时态数据的形式化模型
设一条业务实体为 e,某个属性值为 x。
1. 单时态模型
有效时间模型可以写成:
其中:
e:实体,例如商品;x:属性值,例如价格;V = [v_{from}, v_{to}):该值的有效时间区间。
对同一个实体,如果业务规则要求任一时刻只能有一个有效值,则必须满足:
也就是同一实体的两个有效区间不能重叠。
如果允许未来排期,那么记录可以是:
商品 A,100 元,[2024-01-01, 2024-02-01)
商品 A,120 元,[2024-02-01, 2024-03-01)
商品 A,150 元,[2024-03-01, 无穷)
如果只保存当前行:
商品 A,150 元
就无法回答历史有效值和未来排期问题。
2. 双时态模型
同时保存有效时间和系统时间:
其中:
V:业务有效区间;S:系统认知区间。
一条记录可能是:
价格 100:
有效时间 [2024-01-01, 2024-02-01)
系统时间 [2024-01-01, 2024-01-10)
价格 90:
有效时间 [2024-01-01, 2024-02-01)
系统时间 [2024-01-10, 无穷)
这不是重复数据。两条记录的有效时间相同,但系统时间不同:
- 第一条表示数据库早期的认知;
- 第二条表示收到修正后的认知。
给定业务时间 v 和系统时间 s,查询条件是:
例如:
“数据库在 2024-01-05 时,认为 2024-01-15 的价格是多少?”
需要同时筛选:
valid_from <= '2024-01-15'
AND '2024-01-15' < valid_to
AND recorded_from <= '2024-01-05'
AND '2024-01-05' < recorded_to
双时态模型的代价是显著增加:修正一条历史事实时,不能简单 UPDATE 当前行,否则会破坏过去的系统认知。
三、版本表是什么,它解决什么问题
版本表是把同一业务实体的多个历史状态作为多行保存,而不是覆盖原行。
常见结构如下:
account_status_version
----------------------
entity_id
version_no
status
valid_from
valid_to
recorded_at
changed_by
change_reason
一行表示某个版本,而不是“当前实体本身”。
1. 当前表加历史表
一种常见设计是:
customer customer_history
-------- ----------------
customer_id history_id
current_status customer_id
updated_at version_no
status
valid_from
valid_to
recorded_at
changed_by
更新时在一个事务中:
- 向历史表插入旧状态或新版本;
- 更新当前表;
- 写入审计元数据;
- 一起提交。
优点是当前查询简单:
SELECT *
FROM customer
WHERE customer_id = 42;
缺点是当前表与历史表可能出现分裂:
- 当前表更新成功,历史表写入失败;
- 一部分业务代码只改当前表;
- 恢复时不知道哪个表才是权威来源。
如果采用此模式,当前表与历史表必须在同一数据库事务中维护,并尽可能让业务写入入口集中化。
2. 单一版本表
也可以只使用版本表:
customer_status_version
-----------------------
customer_id
status
valid_from
valid_to
is_current
当前查询:
SELECT customer_id, status
FROM customer_status_version
WHERE is_current = true;
历史查询:
SELECT customer_id, status
FROM customer_status_version
WHERE customer_id = 42
ORDER BY valid_from;
优点是历史和当前状态来源统一。缺点是当前查询必须带过滤条件,且必须保证版本区间和当前标记的一致性。
is_current 是冗余字段。它可以提高查询便利性,但也增加一致性风险。更严格的设计可以通过 valid_to IS NULL 表示当前版本,避免同时维护 is_current 和时间边界。
四、PostgreSQL 中建立有效时间版本表
PostgreSQL 提供范围类型和 GiST 索引能力,但它没有像某些数据库产品那样提供一个通用的 SYSTEM VERSIONING 表开关。下面的时态语义由表结构、约束、触发器和事务共同实现。
1. 使用半开时间范围
下面的例子使用商品价格:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE product_price_version (
product_id bigint NOT NULL,
price numeric(12, 2) NOT NULL CHECK (price >= 0),
-- [valid_from, valid_to),上界为空表示无限未来
valid_during tstzrange NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
changed_by text NOT NULL,
reason text,
CHECK (NOT isempty(valid_during)),
EXCLUDE USING gist (
product_id WITH =,
valid_during WITH &&
)
);
各部分的作用是:
tstzrange表示带时区的时间范围;NOT isempty防止插入空区间;product_id WITH =与valid_during WITH &&的组合表示:
同一商品的有效区间不能重叠;recorded_at默认使用事务时间;changed_by和reason是追溯元数据,不属于有效时间本身。
插入连续价格版本:
INSERT INTO product_price_version
(product_id, price, valid_during, changed_by, reason)
VALUES
(1001, 100.00,
tstzrange('2024-01-01 00:00:00+00',
'2024-02-01 00:00:00+00', '[)'),
'pricing-service',
'January price');
INSERT INTO product_price_version
(product_id, price, valid_during, changed_by, reason)
VALUES
(1001, 120.00,
tstzrange('2024-02-01 00:00:00+00',
NULL, '[)'),
'pricing-service',
'February price');
第二个范围的上界为无穷。因为第一个范围在 2 月 1 日结束,第二个范围从 2 月 1 日开始,二者不重叠。
查询某个业务时刻的价格:
SELECT product_id, price
FROM product_price_version
WHERE product_id = 1001
AND valid_during @> timestamptz '2024-01-15 12:00:00+00';
@> 表示范围包含右侧值。
查询当前有效价格:
SELECT product_id, price
FROM product_price_version
WHERE valid_during @> transaction_timestamp();
这里的“当前”是查询事务看到的当前时间和当前数据库快照的组合。它并不等于“最后提交的业务版本”在所有并发场景下都已经被其他事务看见。
2. 范围约束不等于完整业务约束
排他约束只能防止区间重叠,不能自动保证:
- 区间之间没有空洞;
- 第一段必须从某个业务起始日期开始;
- 只能存在一个无限上界;
- 价格修改必须来自合法审批流程;
- 未来版本不能修改已经结算的月份。
例如下面的数据不重叠,但有空洞:
[2024-01-01, 2024-02-01)
[2024-03-01, 2024-04-01)
如果业务要求连续覆盖,则需要额外的事务逻辑或定期完整性检查。数据库约束要表达的是明确、局部且可验证的规则;“整个实体的历史必须连续”通常需要过程化检查。
五、并发更新:为什么“先查再插”不够
下面的应用逻辑看似合理:
1. SELECT 当前版本
2. 计算 valid_to
3. INSERT 新版本
但两个事务可能同时执行:
事务 A:读取当前版本 [2024-01-01, 无穷)
事务 B:读取当前版本 [2024-01-01, 无穷)
事务 A:插入 [2024-02-01, 无穷)
事务 B:插入 [2024-03-01, 无穷)
如果原版本也没有先关闭,排他约束会拒绝插入;如果应用先执行了不完整的更新,则可能产生错误的边界或丢失更新。
一种针对“只允许修改当前版本”的 PostgreSQL 写法是:
BEGIN;
WITH locked AS (
SELECT product_id, price, valid_during
FROM product_price_version
WHERE product_id = 1001
AND upper_inf(valid_during)
FOR UPDATE
)
UPDATE product_price_version
SET valid_during = tstzrange(
lower(valid_during),
timestamptz '2024-02-01 00:00:00+00',
'[)'
)
WHERE product_id = 1001
AND upper_inf(valid_during);
INSERT INTO product_price_version
(product_id, price, valid_during, changed_by, reason)
VALUES
(1001, 120.00,
tstzrange('2024-02-01 00:00:00+00',
NULL, '[)'),
'pricing-service',
'Price change');
COMMIT;
关键点是:
- 用
SELECT ... FOR UPDATE锁住当前版本; - 在同一事务中关闭旧范围;
- 在同一事务中插入新范围;
- 让约束作为最后一道保护。
但是,这段 SQL 仍然假设当前版本已经存在。如果两个事务同时为一个尚不存在的商品创建第一条版本,可能没有可锁定的行。这种情况需要:
- 先锁定稳定存在的父表行;
- 对实体使用事务级 advisory lock;
- 或使用更严格的隔离级别,并正确处理序列化失败后重试。
SERIALIZABLE 不是“自动解决并发”的开关。它可能让事务以序列化失败结束,应用必须回滚并重试整个事务。
六、系统时间和双时态版本表
如果只记录 recorded_at,可以知道“什么时候写入”,但不能准确表示一个版本何时停止作为系统当前认知。例如修正旧事实时,需要关闭旧的系统区间。
一个简化的双时态结构如下:
CREATE TABLE product_price_bitemporal (
version_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL,
price numeric(12, 2) NOT NULL CHECK (price >= 0),
valid_during tstzrange NOT NULL,
system_during tstzrange NOT NULL,
changed_by text NOT NULL,
reason text,
CHECK (NOT isempty(valid_during)),
CHECK (NOT isempty(system_during))
);
插入一条最初知道的事实:
INSERT INTO product_price_bitemporal
(product_id, price, valid_during, system_during, changed_by, reason)
VALUES
(1001, 100.00,
tstzrange('2024-01-01+00', '2024-02-01+00', '[)'),
tstzrange(transaction_timestamp(), NULL, '[)'),
'pricing-service',
'Initial import');
假设 1 月 10 日收到修正:价格其实从 1 月 1 日开始是 90 元。不能直接把 price 从 100 改成 90,因为那会抹掉“数据库曾经认为价格为 100”的历史。
概念上的操作是:
- 找到当前系统版本;
- 将旧版本的
system_during上界关闭为当前事务时间; - 插入新的系统版本;
- 新版本可以有被修正后的有效时间。
示例:
BEGIN;
-- 在真实代码中,应先锁定待修正的系统当前版本
SELECT *
FROM product_price_bitemporal
WHERE product_id = 1001
AND system_during @> transaction_timestamp()
AND valid_during @> timestamptz '2024-01-15 00:00:00+00'
FOR UPDATE;
UPDATE product_price_bitemporal
SET system_during = tstzrange(
lower(system_during),
transaction_timestamp(),
'[)'
)
WHERE product_id = 1001
AND system_during @> transaction_timestamp()
AND valid_during @> timestamptz '2024-01-15 00:00:00+00';
INSERT INTO product_price_bitemporal
(product_id, price, valid_during, system_during, changed_by, reason)
VALUES
(1001, 90.00,
tstzrange('2024-01-01+00', '2024-02-01+00', '[)'),
tstzrange(transaction_timestamp(), NULL, '[)'),
'pricing-service',
'Corrected retroactively on 2024-01-10');
COMMIT;
这里有一个重要边界:上述查询和更新只适用于简单情况。对于一个有效时间范围内存在多条系统版本、部分修正、撤销或重放的模型,必须明确“同一有效时刻在同一系统时刻是否只能有一个版本”的业务规则。不能机械地为两个范围各添加一个排他约束,就认为二维时态一致性已经解决。
二维矩形的重叠规则比一维时间区间复杂。常见做法是:
- 规定每次修正必须覆盖完整的业务区间;
- 把原版本切分为多个片段;
- 使用存储过程集中执行切分;
- 通过批量校验查询检查二维一致性。
七、审计历史:它记录什么,不能替代什么
审计历史记录的是数据变更行为和上下文,典型字段包括:
谁修改:actor
修改对象:table、primary_key
修改动作:INSERT、UPDATE、DELETE
修改前值:old row
修改后值:new row
何时发生:transaction time / statement time
请求关联:request_id、trace_id
为什么修改:reason
它回答的是:
哪个主体在什么上下文中执行了什么数据变化?
而版本表回答的是:
这个业务对象有哪些可查询的状态版本?
二者可以共用数据,但语义不同。
例如,审计表中的一条记录:
UPDATE product SET price = 90 WHERE product_id = 1001
并不能自动说明:
- 90 元从哪个业务日期开始有效;
- 这是修正还是新排期;
- 旧值在哪些订单中已经被使用;
- 这条变更是否经过审批。
反过来,版本表有完整的有效区间,也不一定记录了:
- 哪个 API 请求触发了修改;
- 哪个用户执行了操作;
- 修改前后客户端传入了哪些字段;
- 操作是否重试过。
八、PostgreSQL 触发器审计示例
下面建立一个通用的行级审计表:
CREATE TABLE product_audit (
audit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
changed_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
table_name text NOT NULL,
row_key jsonb NOT NULL,
old_row jsonb,
new_row jsonb,
actor text NOT NULL,
request_id text
);
触发器函数:
CREATE OR REPLACE FUNCTION audit_product_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_actor text;
v_request_id text;
BEGIN
v_actor := current_setting('app.actor', true);
v_request_id := current_setting('app.request_id', true);
IF v_actor IS NULL OR v_actor = '' THEN
v_actor := current_user;
END IF;
INSERT INTO product_audit (
action,
table_name,
row_key,
old_row,
new_row,
actor,
request_id
)
VALUES (
TG_OP,
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
CASE
WHEN TG_OP = 'DELETE'
THEN jsonb_build_object('product_id', OLD.product_id)
ELSE
jsonb_build_object('product_id', NEW.product_id)
END,
CASE WHEN TG_OP IN ('UPDATE', 'DELETE')
THEN to_jsonb(OLD) END,
CASE WHEN TG_OP IN ('INSERT', 'UPDATE')
THEN to_jsonb(NEW) END,
v_actor,
v_request_id
);
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$;
将触发器绑定到业务表:
CREATE TRIGGER product_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON product
FOR EACH ROW
EXECUTE FUNCTION audit_product_change();
应用在事务内设置上下文:
BEGIN;
SELECT set_config('app.actor', 'user:42', true);
SELECT set_config('app.request_id', 'req-8f3c', true);
UPDATE product
SET price = 90.00
WHERE product_id = 1001;
COMMIT;
true 表示 SET LOCAL 语义:事务结束后设置值自动恢复。连接池环境中尤其重要,否则下一个请求可能继承前一个请求的身份。
触发器审计的因果链
这段设计成立的原因是:
UPDATE产生行级变化;AFTER触发器在同一个事务中执行;- 审计插入与业务更新共享提交结果;
- 业务事务回滚时,审计记录也会回滚;
- 事务提交后,审计行与业务更新一同对其他事务可见。
但这也带来一个常被忽略的事实:
数据库内的触发器审计不是天然不可篡改的。
拥有足够权限的角色可能:
- 禁用触发器;
- 修改审计表;
- 删除审计记录;
- 直接绕过应用写入数据库。
如果审计用于高强度合规或取证,应考虑:
- 限制业务角色权限;
- 让审计表只允许专用写入路径;
- 将审计副本异步发送到隔离存储;
- 使用数据库日志、WAL/binlog、CDC 或外部不可变存储;
- 明确数据库管理员本身是否属于可信边界。
触发器也有真实限制:
- 批量更新会产生大量审计行;
- 审计行增加事务写放大;
jsonb保存整行可能导致存储膨胀;- 触发器失败会使原业务事务失败;
- 应用通过超级用户或其他表直接修改时,审计覆盖范围可能改变;
- 审计表本身再被审计,可能形成递归或噪声。
九、PostgreSQL MVCC 不是业务审计历史
PostgreSQL 使用 MVCC 保存并发控制所需的行版本。事务可以在自己的快照中看到某些旧版本,但这些版本不是面向业务的历史表。
MVCC 版本可能被:
VACUUM清理;- 行更新链隐藏;
- 事务可见性规则限制;
- 与业务有效时间完全无关。
因此,下面的推理是错误的:
“数据库采用 MVCC,所以可以查询任意时间点的业务历史。”
MVCC 解决的是事务隔离和并发读写,不保证:
- 历史版本永久保留;
- 可以按业务有效日期查询;
- 可以知道是谁修改了值;
- 可以重建经过多次修正的业务事实;
- 可以作为合规审计证据。
PostgreSQL 的 WAL 也不是直接可查询的审计表。WAL 首先服务于崩溃恢复、复制和物理变化记录。通过逻辑解码可以提取逻辑变更,但其保留周期、解码插件、权限和部署运维决定了它是否能承担审计职责。
十、MySQL 8.4 的边界
MySQL 8.4 提供:
DATE、DATETIME、TIMESTAMP等时间类型;CURRENT_TIMESTAMP默认值与自动更新时间能力;- InnoDB 事务和 MVCC;
- binary log,可用于复制和 CDC;
- 触发器;
CHECK约束语法和约束校验。
但不能把 MySQL 的这些能力误认为通用的系统版本表功能。MySQL 8.4 并没有一个等同于某些产品中 SYSTEM VERSIONING 的通用内建表语义,用户仍需自行设计:
- 版本表;
valid_from、valid_to;- 当前行标记;
- 触发器;
- 并发控制;
- 历史保留和修正逻辑。
MySQL 也没有 PostgreSQL EXCLUDE USING gist 这种可直接表达“同一实体的时间区间不能重叠”的约束。常见替代方案是在事务中锁定实体的稳定父行或已有版本,再检查重叠:
SELECT version_id, valid_from, valid_to
FROM product_price_version
WHERE product_id = ?
AND valid_from < ?
AND (valid_to IS NULL OR valid_to > ?)
FOR UPDATE;
然后根据查询结果决定是否插入。这里的重叠条件对应半开区间:
如果 valid_to 为空表示无限未来,则需要在 SQL 中单独处理。
但是,SELECT ... FOR UPDATE 只能锁住已找到的记录。对“没有任何版本”的新实体,不能依靠锁住不存在的行来阻止并发插入。可以:
- 先锁定
product父表中的实体行; - 使用唯一键让初始化只有一个成功者;
- 捕获死锁或唯一键冲突后重试;
- 在设计中避免依赖不存在行的锁。
使用 MySQL 时还应核对:
- 存储引擎是否为 InnoDB;
- 事务隔离级别;
DATETIME(6)或TIMESTAMP(6)的精度;- binary log 的格式与保留周期;
- row image 是否包含足够的前后值;
- 复制和 CDC 工具对事务边界的处理。
ON UPDATE CURRENT_TIMESTAMP 只能自动维护一个时间列,它不等于历史版本,也不记录修改者、旧值或请求上下文。
十一、查询历史时必须明确“截至哪个时间”
“查询历史”不是一个完整的问题,至少要说明查询轴。
1. 按有效时间查询
-- 查询 2024-01-15 业务上有效的价格
SELECT product_id, price
FROM product_price_version
WHERE product_id = 1001
AND valid_during @> timestamptz '2024-01-15 00:00:00+00';
这回答的是:
业务日期为 1 月 15 日时,应该适用什么价格?
2. 按系统时间回看
如果表保存了系统区间:
-- 查询数据库在 2024-01-05 的认知
SELECT product_id, price
FROM product_price_bitemporal
WHERE product_id = 1001
AND valid_during @> timestamptz '2024-01-15 00:00:00+00'
AND system_during @> timestamptz '2024-01-05 00:00:00+00';
这回答的是:
在 1 月 5 日这个系统观察时刻,数据库认为 1 月 15 日的价格是多少?
3. 查询当前系统认知下的历史业务状态
SELECT product_id, price
FROM product_price_bitemporal
WHERE product_id = 1001
AND valid_during @> timestamptz '2024-01-15 00:00:00+00'
AND upper_inf(system_during);
这回答的是:
按数据库当前最新认知,1 月 15 日的价格是多少?
三个问题的 SQL 看起来相似,但语义完全不同。报表、结算、合规调查必须在接口或查询文档中明确采用哪个时间轴。
十二、版本表、审计表、CDC 的边界
这三者经常一起出现,但产生机制不同。
1. 版本表
版本表是业务建模的一部分:
当前值和历史值都作为业务数据保存
它通常能回答:
- 某个业务时刻的状态;
- 版本之间的有效区间;
- 当前状态如何由历史版本组成。
它的写入通常与业务事务绑定。
2. 审计表
审计表是变更记录:
谁在什么上下文中对哪一行执行了什么动作
它通常能回答:
- 修改者;
- 请求 ID;
- 修改前后值;
- 删除行为;
- 修改原因。
它不一定适合高效执行业务时间查询。
3. CDC
CDC 是把数据库变更传播到其他系统的机制。常见来源包括:
- PostgreSQL WAL 的逻辑解码;
- MySQL binary log;
- 数据库触发器写出的事件表;
- 应用层事件。
CDC 事件通常包含:
source position
transaction identifier
operation
key
before/after
commit metadata
它主要解决:
- 将变化发送到搜索系统、数仓、缓存或其他服务;
- 增量同步;
- 事件驱动处理。
CDC 不自动等于可追溯业务历史,因为:
- 事件可能过期;
- 下游可能只保留最终状态;
- 消费者可能重复处理;
- 不同表的事件需要自行关联;
- source offset 是日志位置,不是业务有效时间;
- 事件顺序通常只在特定分区、连接或事务边界内有保证;
- 消费成功与业务含义落库成功可能不是同一个事务。
Debezium 等工具可以读取 PostgreSQL 或 MySQL 的日志并输出变更事件,但必须单独设计:
- 事件唯一标识;
- 事务边界;
- offset 持久化;
- 重复消费;
- schema 演进;
- 删除事件;
- 初始快照与实时日志的衔接。
4. 事件重复与幂等
假设下游收到两次相同的价格更新:
event_id = e1
product_id = 1001
price = 90
如果下游直接插入版本表,可能产生两个重复版本。常见做法是保存来源事件 ID:
CREATE TABLE applied_change (
source_system text NOT NULL,
event_id text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
PRIMARY KEY (source_system, event_id)
);
处理时在同一事务中:
- 插入
applied_change; - 若主键冲突,说明事件已处理,安全退出;
- 否则写入业务版本;
- 提交。
这只能处理“同一个事件重复到达”。它不能解决:
- 不同事件对同一实体乱序;
- 业务时间冲突;
- CDC 事件丢失;
- 快照事件与增量事件重复;
- 一个事务跨多个实体时的完整重建。
十三、可追溯性不是“多加几个时间字段”
可追溯性是从当前或历史结果反向建立证据链的能力:
业务结果
→ 版本记录
→ 变更记录
→ 请求/操作者
→ 数据库事务
→ CDC 或日志位置
→ 下游处理记录
要让链条成立,每一层必须有稳定关联键。常用字段包括:
entity_id 业务实体 ID
version_id 业务版本 ID
audit_id 数据库审计记录 ID
request_id 请求关联 ID
trace_id 分布式链路 ID
event_id 事件唯一 ID
transaction_id 数据库事务标识
source_offset WAL/binlog 或 CDC 位点
这些 ID 的用途不同:
request_id关联一次应用请求;trace_id关联跨服务调用;transaction_id关联同一数据库事务中的变化;source_offset定位 CDC 源日志位置;version_id定位业务状态版本;event_id用于事件幂等。
不能用一个字段冒充所有含义。例如,数据库事务 ID 不应直接当作全球永久唯一的业务事件 ID。不同数据库的事务标识有生命周期、复用或格式限制;CDC 位点也通常只在特定数据库实例和日志生命周期内有意义。
十四、常见错误与失败表现
错误一:只保存 updated_at,却声称支持历史查询
status = 'active'
updated_at = '2024-01-10'
这只能说明当前行最后一次更新时间。没有旧值,就不能恢复修改前状态;没有有效时间,就不能知道该状态何时在业务上生效。
错误二:把数据库写入时间当作业务生效时间
用户在 1 月 10 日录入“从 1 月 1 日起涨价”,如果系统只使用 created_at = 1 月 10 日,报表会错误地认为 1 月 1 日至 1 月 9 日仍使用旧价,除非业务规则确实如此。
错误三:使用单调递增 ID 推断时间顺序
序列号、AUTO_INCREMENT 和 UUID 都有各自语义:
- 事务可能先分配 ID,后提交;
- 回滚会产生空洞;
- 多节点写入不共享单一顺序;
- UUID 通常没有时间顺序;
- CDC 可能按分区交付。
ID 可以作为标识,不能无条件作为“先后发生”的证明。
错误四:认为触发器审计等于不可篡改审计
拥有表权限的人可以修改审计表;拥有足够数据库权限的人甚至可以改变触发器。高可信审计需要隔离写入权限和外部留存。
错误五:把 CDC 最后收到的事件当作业务最终状态
网络延迟、重试、分区、快照和乱序都可能使消费时间晚于提交时间。下游必须根据事件元数据和业务版本规则处理,而不是简单按到达顺序覆盖。
错误六:没有定义边界精度
以下两个值可能代表不同事实:
2024-01-01 00:00:00
2024-01-01 00:00:00.123456
如果业务按自然日结算,应使用 date 或明确把时间归一化;如果业务按瞬时事件排序,则需要足够精度和明确时区。没有边界约定,区间查询会产生难以诊断的“少一天”或“重复一秒”问题。
十五、如何诊断时态数据错误
遇到“历史结果不对”时,可以按以下因果链检查。
1. 先确认查询轴
询问查询实际需要的是:
- 业务有效时间;
- 数据库系统认知时间;
- 变更发生时间;
- 事务提交时间;
- CDC 消费时间。
如果问题没有明确时间轴,SQL 再正确也可能回答错问题。
2. 检查区间边界
对某个实体按时间排序:
SELECT
product_id,
valid_during,
lower(valid_during) AS valid_from,
upper(valid_during) AS valid_to
FROM product_price_version
WHERE product_id = 1001
ORDER BY lower(valid_during);
重点检查:
- 是否存在空区间;
- 是否存在重叠;
- 是否存在不应有的空洞;
- 是否同时存在多个无限上界;
- 时区和精度是否一致。
3. 检查事务和请求关联
审计表中应能通过 request_id、actor、事务时间或事件 ID 找到对应变更。若只能看到最终值而找不到操作来源,说明可追溯链已经断裂。
4. 检查并发失败
观察是否出现:
- 唯一键冲突;
- 排他约束冲突;
- 死锁;
- 序列化失败;
- 事务重试后重复写入;
- 连接池复用了错误的
actor或request_id。
5. 检查 CDC 源和下游
需要区分:
数据库已经提交
数据库日志已经产生
CDC 已读取
消息已发送
消费者已确认
下游事务已提交
这些状态不是同一时刻。缺失某一步时,不能简单把问题归因于“数据库没有记录”。
十六、如何选择模型
可以从业务问题反推数据模型:
只需要当前状态
使用普通表,并保留必要的 created_at、updated_at。不要因为“可能以后需要历史”就无条件保存完整 JSON 快照。
需要业务历史
使用版本表,并明确:
- 有效时间精度;
- 区间是否允许重叠;
- 是否允许未来排期;
- 是否允许空洞;
- 版本切换的并发规则;
- 是否需要恢复到旧版本。
需要知道数据库过去的认知
使用系统时间或双时态模型。尤其适合:
- 追溯历史报表为什么当时得出某个结果;
- 处理追溯修正;
- 合规调查;
- 需要区分“事实发生时间”和“事实被获知时间”的场景。
需要知道谁改了什么
使用审计历史,记录前后值、操作者、请求 ID 和原因。触发器适合覆盖数据库内的通用行级变更,但高可信场景还需要外部留存和权限隔离。
需要同步其他系统
使用 CDC 或明确的应用事件。不要把 CDC 日志直接当作面向业务查询的版本表;必要时由下游以幂等方式构建自己的读取模型。
时态数据的核心不是给表增加几个 *_at 字段,而是明确每个时间值的语义、每个版本的生命周期以及每次修改如何留下证据。有效时间描述业务事实何时成立,系统时间描述数据库何时知道该事实,版本表保存可查询的状态演进,审计历史记录变更行为,CDC 则负责把变化传播到数据库之外。只有把这些层次分开,再通过事务、约束和稳定关联键连接起来,历史查询和可追溯性才具有可验证的含义。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:多租户数据库设计:共享表、独立 Schema、独立库和数据隔离
- 下一篇:数据库 CDC:日志捕获、Debezium、Schema 演进、顺序和重复消费
- 延伸:数据库 Schema 设计:数据类型、主外键、约束、NULL 与演进
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论