数据库基础体系 · 第 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. 单时态模型

有效时间模型可以写成:

R(e,x,V)R(e, x, V)

其中:

  • e:实体,例如商品;
  • x:属性值,例如价格;
  • V = [v_{from}, v_{to}):该值的有效时间区间。

对同一个实体,如果业务规则要求任一时刻只能有一个有效值,则必须满足:

r1,r2,r1.e=r2.er1.Vr2.V=\forall r_1,r_2,\quad r_1.e = r_2.e \Rightarrow r_1.V \cap r_2.V = \varnothing

也就是同一实体的两个有效区间不能重叠。

如果允许未来排期,那么记录可以是:

商品 A,100 元,[2024-01-01, 2024-02-01)
商品 A,120 元,[2024-02-01, 2024-03-01)
商品 A,150 元,[2024-03-01, 无穷)

如果只保存当前行:

商品 A,150 元

就无法回答历史有效值和未来排期问题。


2. 双时态模型

同时保存有效时间和系统时间:

R(e,x,V,S)R(e, x, V, S)

其中:

  • 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,查询条件是:

vVsSv \in V \land s \in 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

更新时在一个事务中:

  1. 向历史表插入旧状态或新版本;
  2. 更新当前表;
  3. 写入审计元数据;
  4. 一起提交。

优点是当前查询简单:

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_byreason 是追溯元数据,不属于有效时间本身。

插入连续价格版本:

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;

关键点是:

  1. SELECT ... FOR UPDATE 锁住当前版本;
  2. 在同一事务中关闭旧范围;
  3. 在同一事务中插入新范围;
  4. 让约束作为最后一道保护。

但是,这段 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”的历史。

概念上的操作是:

  1. 找到当前系统版本;
  2. 将旧版本的 system_during 上界关闭为当前事务时间;
  3. 插入新的系统版本;
  4. 新版本可以有被修正后的有效时间。

示例:

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 语义:事务结束后设置值自动恢复。连接池环境中尤其重要,否则下一个请求可能继承前一个请求的身份。

触发器审计的因果链

这段设计成立的原因是:

  1. UPDATE 产生行级变化;
  2. AFTER 触发器在同一个事务中执行;
  3. 审计插入与业务更新共享提交结果;
  4. 业务事务回滚时,审计记录也会回滚;
  5. 事务提交后,审计行与业务更新一同对其他事务可见。

但这也带来一个常被忽略的事实:

数据库内的触发器审计不是天然不可篡改的。

拥有足够权限的角色可能:

  • 禁用触发器;
  • 修改审计表;
  • 删除审计记录;
  • 直接绕过应用写入数据库。

如果审计用于高强度合规或取证,应考虑:

  • 限制业务角色权限;
  • 让审计表只允许专用写入路径;
  • 将审计副本异步发送到隔离存储;
  • 使用数据库日志、WAL/binlog、CDC 或外部不可变存储;
  • 明确数据库管理员本身是否属于可信边界。

触发器也有真实限制:

  • 批量更新会产生大量审计行;
  • 审计行增加事务写放大;
  • jsonb 保存整行可能导致存储膨胀;
  • 触发器失败会使原业务事务失败;
  • 应用通过超级用户或其他表直接修改时,审计覆盖范围可能改变;
  • 审计表本身再被审计,可能形成递归或噪声。

九、PostgreSQL MVCC 不是业务审计历史

PostgreSQL 使用 MVCC 保存并发控制所需的行版本。事务可以在自己的快照中看到某些旧版本,但这些版本不是面向业务的历史表。

MVCC 版本可能被:

  • VACUUM 清理;
  • 行更新链隐藏;
  • 事务可见性规则限制;
  • 与业务有效时间完全无关。

因此,下面的推理是错误的:

“数据库采用 MVCC,所以可以查询任意时间点的业务历史。”

MVCC 解决的是事务隔离和并发读写,不保证:

  • 历史版本永久保留;
  • 可以按业务有效日期查询;
  • 可以知道是谁修改了值;
  • 可以重建经过多次修正的业务事实;
  • 可以作为合规审计证据。

PostgreSQL 的 WAL 也不是直接可查询的审计表。WAL 首先服务于崩溃恢复、复制和物理变化记录。通过逻辑解码可以提取逻辑变更,但其保留周期、解码插件、权限和部署运维决定了它是否能承担审计职责。


十、MySQL 8.4 的边界

MySQL 8.4 提供:

  • DATEDATETIMETIMESTAMP 等时间类型;
  • CURRENT_TIMESTAMP 默认值与自动更新时间能力;
  • InnoDB 事务和 MVCC;
  • binary log,可用于复制和 CDC;
  • 触发器;
  • CHECK 约束语法和约束校验。

但不能把 MySQL 的这些能力误认为通用的系统版本表功能。MySQL 8.4 并没有一个等同于某些产品中 SYSTEM VERSIONING 的通用内建表语义,用户仍需自行设计:

  • 版本表;
  • valid_fromvalid_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;

然后根据查询结果决定是否插入。这里的重叠条件对应半开区间:

[a,b)[c,d)    a<dc<b[a,b) \cap [c,d) \neq \varnothing \iff a < d \land c < b

如果 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)
);

处理时在同一事务中:

  1. 插入 applied_change
  2. 若主键冲突,说明事件已处理,安全退出;
  3. 否则写入业务版本;
  4. 提交。

这只能处理“同一个事件重复到达”。它不能解决:

  • 不同事件对同一实体乱序;
  • 业务时间冲突;
  • 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_idactor、事务时间或事件 ID 找到对应变更。若只能看到最终值而找不到操作来源,说明可追溯链已经断裂。

4. 检查并发失败

观察是否出现:

  • 唯一键冲突;
  • 排他约束冲突;
  • 死锁;
  • 序列化失败;
  • 事务重试后重复写入;
  • 连接池复用了错误的 actorrequest_id

5. 检查 CDC 源和下游

需要区分:

数据库已经提交
数据库日志已经产生
CDC 已读取
消息已发送
消费者已确认
下游事务已提交

这些状态不是同一时刻。缺失某一步时,不能简单把问题归因于“数据库没有记录”。


十六、如何选择模型

可以从业务问题反推数据模型:

只需要当前状态

使用普通表,并保留必要的 created_atupdated_at。不要因为“可能以后需要历史”就无条件保存完整 JSON 快照。

需要业务历史

使用版本表,并明确:

  • 有效时间精度;
  • 区间是否允许重叠;
  • 是否允许未来排期;
  • 是否允许空洞;
  • 版本切换的并发规则;
  • 是否需要恢复到旧版本。

需要知道数据库过去的认知

使用系统时间或双时态模型。尤其适合:

  • 追溯历史报表为什么当时得出某个结果;
  • 处理追溯修正;
  • 合规调查;
  • 需要区分“事实发生时间”和“事实被获知时间”的场景。

需要知道谁改了什么

使用审计历史,记录前后值、操作者、请求 ID 和原因。触发器适合覆盖数据库内的通用行级变更,但高可信场景还需要外部留存和权限隔离。

需要同步其他系统

使用 CDC 或明确的应用事件。不要把 CDC 日志直接当作面向业务查询的版本表;必要时由下游以幂等方式构建自己的读取模型。


时态数据的核心不是给表增加几个 *_at 字段,而是明确每个时间值的语义、每个版本的生命周期以及每次修改如何留下证据。有效时间描述业务事实何时成立,系统时间描述数据库何时知道该事实,版本表保存可查询的状态演进,审计历史记录变更行为,CDC 则负责把变化传播到数据库之外。只有把这些层次分开,再通过事务、约束和稳定关联键连接起来,历史查询和可追溯性才具有可验证的含义。


系列导航与关联阅读

官方资料

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