#DevOps

数据库时态数据模式:追踪历史变化

"这个价格上周是多少?"——普通表只存当前值,时态表存历史。本文讲清时态数据的几种模式(审计日志/版本号行/有效时间范围/PG 时态表扩展)、SQL:2011 时态表标准、CDC 替代方案,以及什么时候该上时态、什么时候审计日志够。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月14日·更新于 2026年7月23日

数据库时态数据模式:追踪历史变化

先抛一个你大概率会撞上的场景。

财务找过来:「这个 SKU 上周三的进货价是多少?我们要核对一笔账。」你打开 product_prices 表,查到一行 price = 89.5,自信回她「89.5」。她皱眉:「不对,上周二我们调过价,周三应该是 92。」你一愣——表里只有一条记录,最新值 89.5 是昨天才调的,周二那次调整早就被 UPDATE 覆盖了,痕迹全无。

你翻日志、翻备份、翻工单,折腾一下午才拼出答案。财务丢下一句:「下次能不能直接查?」就走了。

这不是 bug,是你的表设计只能回答"现在",不能回答"过去"。普通的表是一块不断被擦写的白板——每次 UPDATE 都是一次有损的覆盖,旧值永久消失。而绝大多数业务系统默认就是这样设计的,直到某个合规审计、某次纠纷、某次回溯查询砸到你头上,你才会意识到:有些数据,你不主动存下它的历史,它就真的没有历史。

这就是时态数据(temporal data)要解决的问题。这篇文章把追踪历史变化的几种数据库模式彻底讲清——从最轻量的审计日志,到工业级的 SQL:2011 系统版本表,再到 CDC 替代方案,最后给你一套"什么时候该上"的判断框架。读完你会明白一句话:时态不是免费的,但该上的时候不上,代价是拿业务的时间线去赌。


一、什么是时态数据:不只是"当前值"

先把概念对齐。普通表存的是当前状态(current state)——users 表里那一行就是用户此刻的信息,prices 表里那一行就是商品此刻的价格。一次 UPDATE 把旧值干掉,新值顶上,表永远只反映"现在"。

时态数据存的是随时间演变的状态序列——不只是"这个商品现在多少钱",而是"它在任意一个时间点值多少钱"。换句话说,时态表能回答这类问题:

  • 上周三这个 SKU 的价格是多少?
  • 这个用户去年三月填的收货地址是什么?
  • 这份合同从哪天到哪天是生效的?
  • 这笔订单的状态在什么时候从"待支付"变成了"已发货"?

这些问题有个共同特征:它们问的是"某时刻的状态",而不是"现在的状态"。普通表回答不了,因为它把时间维度丢掉了。

两个时间维度:valid time 与 system time

要把时态讲透,绕不开这两个概念。很多团队搞砸时态设计,根因就是没分清这两者。

有效时间(valid time / business time)——数据在业务世界里为真的时间。比如一份合同 valid_from = 2025-01-01、valid_to = 2025-12-31,是说这份合同在现实里从元旦到年底有效。你可以提前录入一条"下周生效"的价格——它的 valid time 在未来,但你现在就插进去了。

系统时间(system time / transaction time)——数据在数据库里被记录/修改的时间。比如你今天 10:00 把一条价格从 89 改成 92,那么"89 这个值"的系统时间是 [某历史时刻, 今天 10:00),"92 这个值"的系统时间是 [今天 10:00, ∞)。系统时间由数据库管理,用户不该手填。

两者的区别是经典的:一份合同可能 1 月 1 日就生效了(valid time),但你是 1 月 5 日才把它录进系统的(system time);也可能你 12 月就提前录入了明年生效的条款(valid time 在未来,system time 在过去)。 把两个维度都建模,叫双时态(bi-temporal),能回答"上周三系统里记录的、当时认为有效的价格是多少"这种烧问题。金融、保险、医疗里很常见,绝大多数场景只要其中一个就够。

text
                  业务时间轴 (valid time)
   合同生效 ───────────────────────────── 合同失效
   ═══════════════════════════════════════

                              录入系统          修改价格
   ───────────────────────────●─────────────────●──────────►
                              │                 │
                          系统时间轴 (system time)

下面四种模式,本质都是在回答"用哪一种时间维度、用什么结构存下来"。


二、模式一:审计日志(最轻量,先从这开始)

这是 80% 场景该用的方案,也是最容易被忽视的方案。思路很简单:主表保持普通表不动(照样 UPDATE 覆盖),另开一张表记录每一次变更事件。

sql
-- 主表:照常,只存当前值
CREATE TABLE product_prices (
  id          bigint PRIMARY KEY,
  product_id  bigint NOT NULL,
  price       numeric(10,2) NOT NULL,
  updated_at  timestamptz NOT NULL DEFAULT now()
);

-- 审计日志表:记每一次变更
CREATE TABLE price_change_log (
  id           bigserial PRIMARY KEY,
  target_id    bigint NOT NULL,        -- 哪条价格被改了
  old_price    numeric(10,2),          -- 改之前的值(NULL = 新增)
  new_price    numeric(10,2),          -- 改之后的值(NULL = 删除)
  changed_by   text,                   -- 谁改的
  changed_at   timestamptz NOT NULL DEFAULT now()
);

写入靠应用层在同一个事务里插一条日志,或者干脆用触发器自动记:

sql
CREATE OR REPLACE FUNCTION log_price_change() RETURNS trigger AS $$
BEGIN
  INSERT INTO price_change_log (target_id, old_price, new_price, changed_by)
  VALUES (OLD.id, OLD.price, NEW.price, current_user);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER price_audit
  AFTER UPDATE ON product_prices
  FOR EACH ROW EXECUTE FUNCTION log_price_change();

它的优点全是"轻":

  • 主表零侵入,原来怎么写还怎么写,UPDATE 照样覆盖,性能不变。
  • 查"谁在什么时候改了什么"这种审计问题,直接 SELECT * FROM price_change_log WHERE target_id = 42 ORDER BY changed_at,一行 SQL。
  • 存储增长可控——只记"变化事件",不频繁变更的表几乎不涨。

它的硬伤是:很难回答"某时刻的状态"。 想知道"上周三的价格是多少",你得把 target_id = 42 的所有日志按时间排序,找到上周三之前最后一次变更,用它的 new_price——也就是要重放(replay)变更历史才能还原快照。变更一多,重放逻辑又慢又容易写错。

所以审计日志的定位很清楚:它是"账本",记的是事件;不是"快照",存不了状态。 你的需求是合规留痕、追责、排查"谁动过数据",用审计日志就够了;需求是"回到任意历史时刻看当时的状态",审计日志就力不从心了。

我的经验:先上审计日志,永远不亏。 它成本极低、收益确定,哪怕后面真要上时态表,审计日志也是互补的——时态表回答状态,审计表回答事件。


三、模式二:版本号行(INSERT 新行,不 UPDATE)

审计日志是"事件在外",版本号行是"历史在内"。核心动作就一句:把 UPDATE 改写成 INSERT,旧行不删,新行追加。

sql
CREATE TABLE contract_versions (
  id            bigserial PRIMARY KEY,
  contract_id   bigint NOT NULL,       -- 逻辑上的同一份合同
  version       int NOT NULL,          -- 第几个版本
  content       text NOT NULL,
  created_by    text NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);

-- 唯一约束:同一合同同一版本不能重复
CREATE UNIQUE INDEX ON contract_versions (contract_id, version);

要改合同?不要 UPDATE,INSERT 一条 version + 1 的新行:

sql
INSERT INTO contract_versions (contract_id, version, content, created_by)
SELECT contract_id, version + 1, $1_new_content, $2_user
FROM contract_versions
WHERE contract_id = 99
ORDER BY version DESC
LIMIT 1;

查"最新版":

sql
SELECT content FROM contract_versions
WHERE contract_id = 99
ORDER BY version DESC LIMIT 1;

查"上周三用的哪个版本":

sql
SELECT content FROM contract_versions
WHERE contract_id = 99 AND created_at <= '2025-07-16'
ORDER BY version DESC LIMIT 1;

这个模式适合"版本感很强"的业务——合同、文档、配置、Policy、wiki。这类数据的天然语义就是"有 V1、V2、V3",业务用户也习惯说"切回上一版"。版本号本身就是业务概念,存下来天经地义。

它的代价:

  1. "当前值"不再是一行,而是"最大的 version 那行"。所有查当前值的 SQL 都得带上 ORDER BY version DESC LIMIT 1,或者维护一个 is_current 标记列。容易忘、容易写错。
  2. 同一逻辑实体的行会越攒越多,查询时要小心别扫历史版本——必须靠 (contract_id, version) 索引命中最新行。
  3. 不适合"频繁小变更"的数据。价格一天调三次、库存每分钟都在变,版本号会爆炸式增长(一天几千个版本),这种数据该用下一节的时间范围模式。

版本号行是审计日志和时态表之间的中间态:比审计日志多了"快照"能力(每行就是个完整快照),比真正的时态表少了"区间查询"的原生支持。用在版本边界清晰、变更不频繁的场景,是性价比很高的选择。


四、模式三:有效时间范围(valid_from / valid_to,工业主力)

这是手工实现时态最常用的模式,也是后面 SQL:2011 时态表的底层原型。每一行带一个时间区间,表示"这行数据在这个区间内为真"。

sql
CREATE TABLE product_prices (
  id           bigserial PRIMARY KEY,
  product_id   bigint NOT NULL,
  price        numeric(10,2) NOT NULL,
  valid_from   timestamptz NOT NULL,
  valid_to     timestamptz NOT NULL DEFAULT 'infinity'  -- infinity 表示"至今有效"
);

CREATE INDEX ON product_prices (product_id, valid_from, valid_to);

valid_to = 'infinity' 是个常见的哨兵值,表示"这行还没过期,是当前有效的那行"。PostgreSQL 的 timestamptz 原生支持 'infinity',比较和索引都正常工作。

调价的时候,不要 UPDATE 现有行的 price,而是分两步:

  1. 把当前有效行的 valid_to 收口到此刻(UPDATE ... SET valid_to = now());
  2. INSERT 一条新行,valid_from = now(),valid_to = 'infinity'。
sql
-- 调价:SKU 42 从现在起改成 92
WITH closed AS (
  UPDATE product_prices
    SET valid_to = now()
    WHERE product_id = 42 AND valid_to = 'infinity'
    RETURNING product_id
)
INSERT INTO product_prices (product_id, price, valid_from, valid_to)
VALUES (42, 92.00, now(), 'infinity');

这两步必须在一个事务里,否则会出现"同一段时间没有任何有效价格"的空洞。

查询"上周三的价格是多少"

这是时态数据的招牌查询,标准写法:

sql
SELECT price
FROM product_prices
WHERE product_id = 42
  AND valid_from <= '2025-07-16'::timestamptz
  AND valid_to   >  '2025-07-16'::timestamptz;

逻辑:找到那行"从周三之前开始、且持续到周三之后还没结束"的记录。这就是开头那个"上周三价格是多少"的标准答案。

防重叠:排除约束(exclusion constraint)

这个模式最容易翻车的地方是同一商品的两段有效区间重叠——比如你不小心调了两次价,结果出现两行都说自己在 7 月 16 日有效,查出来两条记录,业务直接懵。手工写事务很难完全杜绝,得靠数据库约束兜底。

PostgreSQL 的杀手锏是排除约束 + GIST 索引。更优雅的写法是用区间类型 tstzrange 替代两个时间列:

sql
CREATE EXTENSION IF NOT EXISTS btree_gist;   -- 让 GIST 支持 = 操作

CREATE TABLE product_prices (
  id           bigserial PRIMARY KEY,
  product_id   bigint NOT NULL,
  price        numeric(10,2) NOT NULL,
  valid_range  tstzrange NOT NULL DEFAULT tstzrange(now(), NULL)
);

-- 同一 product_id 的有效区间禁止重叠
ALTER TABLE product_prices
  ADD CONSTRAINT no_overlap
  EXCLUDE USING GIST (
    product_id WITH =,
    valid_range WITH &&
  );

&& 是区间重叠运算符,product_id WITH = 保证只对同一商品做检查。有了它,你想插一段和现存区间重叠的记录,数据库直接拒绝,从根上消灭"同一段时间两个价格"。

查询也可以用区间运算符,更简洁:

sql
-- @> 表示区间包含某时刻
SELECT price FROM product_prices
WHERE product_id = 42
  AND valid_range @> '2025-07-16'::timestamptz;

这个模式是"手工时态"的工业主力,比版本号更适合频繁变更的数据,比审计日志多了原生快照能力。代价是:所有写入都要走"收口旧行 + 插新行"的两步事务,应用层得包一层,忘了一次就会出数据空洞。


五、模式四:SQL:2011 时态表(系统版本表,DB 原生)

前面三种都是"我们自己拼"。SQL:2011 标准把这个能力标准化了,叫系统版本表(system-versioned table)——数据库自己帮你管历史,你只管写当前值,历史查询用专门的语法。

核心机制是系统时间(system time):每行带一个 SYSTEM_TIME 区间,表示"这行从哪个事务时刻到哪个事务时刻是数据库认定的当前值"。UPDATE 或 DELETE 一行时,数据库自动把旧版本搬到一张关联的历史表里,并填好它的系统时间区间——你写应用代码时完全无感,就像操作普通表。

sql
-- SQL:2011 标准语法(以 MariaDB / SQL Server / DB2 类似形态示意)
CREATE TABLE product_prices (
  id          bigint PRIMARY KEY,
  product_id  bigint NOT NULL,
  price       numeric(10,2) NOT NULL,
  valid_from  timestamptz NOT NULL DEFAULT now(),
  valid_to    timestamptz NOT NULL DEFAULT 'infinity',
  PERIOD FOR SYSTEM_TIME (valid_from, valid_to)
) WITH SYSTEM VERSIONING;

查"上周三的价格是多少"——注意这个专门的历史查询语法:

sql
SELECT price FROM product_prices
FOR SYSTEM_TIME AS OF '2025-07-16'
WHERE product_id = 42;

FOR SYSTEM_TIME AS OF 是 SQL:2011 的关键语法,数据库会自动去当前表 + 历史表里捞对应时刻的版本。你不用关心历史存在哪、怎么拼——这是它最大的价值。

支持原生系统版本表的数据库:

数据库支持情况
DB2早期支持者,最完整
SQL Server(2016+)SYSTEM_VERSIONING = ON,需配历史表
MariaDB(10.3+)WITH SYSTEM VERSIONING,开箱即用
Oracle不完全遵循 SQL:2011,但 Flashback 提供类似能力
PostgreSQL不支持原生 SQL:2011 系统版本表(截至 PG 17)

注意最后一行。PostgreSQL 至今没有原生的 WITH SYSTEM VERSIONING——这是 PG 阵营长期被吐槽的点。PG 用户要做系统版本表,得靠扩展或手工实现,下一节专门讲。

系统版本表和模式三(有效时间范围)有个本质区别值得划重点:系统版本表记的是 system time(数据库事务时间),你查的"上周三"是按"数据库当时记录的版本"来回溯;而模式三的 valid_from/valid_to 记的是 valid time(业务时间),你可以插入一条"下周才生效"的记录。 两者不是一回事,别混用。系统版本表不能回答"这份合同下个月生效时值多少钱"(因为系统现在还不知道未来),但能回答"上周三系统里这个商品是多少钱"。记住:系统版本表是"事后追溯",有效时间是"事先约定"。


六、PostgreSQL 怎么做时态表

PG 没有原生 SQL:2011 系统版本表,但这不意味着 PG 玩不了时态——恰恰相反,PG 社区在这个方向上攒了成熟的套路。三条路:扩展、TimescaleDB、手工触发器。

路线一:temporal_tables 扩展(首选)

社区里最知名的 PG 时态扩展叫 temporal_tables(Vladimir Arkhipov 写的,GitHub 上长期维护),它给你补上了系统版本表的能力——你建一张当前表 + 一张结构相同的影子历史表,挂一个触发器,剩下的它管。

sql
-- 当前表:多一个 sys_period 列记录系统时间区间
CREATE TABLE product_prices (
  id          bigint PRIMARY KEY,
  product_id  bigint NOT NULL,
  price       numeric(10,2) NOT NULL,
  sys_period  tstzrange NOT NULL DEFAULT tstzrange(now(), NULL)
);

-- 历史表:结构照抄当前表
CREATE TABLE product_prices_history (LIKE product_prices);

-- 挂版本化触发器:UPDATE/DELETE 时自动把旧版本搬进历史表
CREATE TRIGGER versioning_trigger
  BEFORE UPDATE OR DELETE ON product_prices
  FOR EACH ROW EXECUTE FUNCTION versioning(
    'product_prices_history',   -- 历史表名
    'sys_period',               -- 系统时间列
    true                        -- 是否把 UPDATE 拆成 DELETE+INSERT
  );

挂上之后,你照常 UPDATE product_prices SET price = 92 WHERE id = 42,扩展自动:

  1. 把旧版本(price=89.5)连同它的 sys_period 收口后插进 product_prices_history;
  2. 在当前表更新 price,并把 sys_period 的下界设成 now()。

你写应用代码完全无感,但 product_prices_history 里默默攒下了完整历史。查"上周三的价格":

sql
SELECT price FROM product_prices
WHERE product_id = 42
  AND sys_period @> '2025-07-16'::timestamptz
UNION ALL
SELECT price FROM product_prices_history
WHERE product_id = 42
  AND sys_period @> '2025-07-16'::timestamptz;

(当前表里也可能命中,因为它存的是"至今有效"那行;所以 union 两张表。)如果你嫌每次 union 麻烦,可以建一个 view 把两张表 union 起来,再对 view 查询。

提醒:社区里你能搜到几个名字相近的扩展(pg_temporal、temporal_tables 等)。用得最广、文档最全的是 temporal_tables(github.com/arkhipov/temporal_tables),安装前先确认你装的是这个,别被同名小项目带偏。

路线二:TimescaleDB(时序味儿重的历史)

如果你的历史数据本质上是时间序列——比如 IoT 设备状态、价格快照每分钟一条、监控指标——与其用版本表,不如直接用 TimescaleDB 的 hypertable,按时间自动分区。它不是"系统版本表"的替代,而是另一种思路:不存"变更事件",而是定期打点存全量快照。每分钟一条价格快照,查"上周三价格"就是查最接近那个时刻的一条快照。适合数据本身就是连续时间序列的场景,不适合"低频变更 + 需要精确到变更瞬间"的场景。

路线三:手工触发器(无扩展依赖)

如果你所在的平台不允许装扩展(云数据库、受限环境),就用纯 SQL 触发器自己实现,逻辑和 temporal_tables 一样:

sql
CREATE OR REPLACE FUNCTION version_product_prices() RETURNS trigger AS $$
BEGIN
  -- 把旧版本(收口后的系统时间)插进历史表
  INSERT INTO product_prices_history (id, product_id, price, sys_period)
  VALUES (OLD.id, OLD.product_id, OLD.price,
          tstzrange(lower(OLD.sys_period), now()));
  -- 当前行更新 sys_period 下界
  NEW.sys_period := tstzrange(now(), NULL);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER versioning_trigger
  BEFORE UPDATE ON product_prices
  FOR EACH ROW EXECUTE FUNCTION version_product_prices();

手工实现的好处是零外部依赖、行为完全可控;坏处是你得自己处理 DELETE、边界条件、并发竞态——temporal_tables 替你踩过的坑,你都得重踩一遍。我的建议是:能用扩展就用扩展,不能用再手写,手写的话参考 temporal_tables 的源码,别自己拍脑袋。


七、查询时态数据:四种典型问句

时态数据建模是为查询服务的。把常见的四类问句和对应 SQL 摆出来,你对照着看自己该用哪种模式。

问句一:某商品在某时刻的价格是多少?(point-in-time)

sql
-- 模式三:有效时间范围
SELECT price FROM product_prices
WHERE product_id = 42
  AND valid_from <= '2025-07-16'::timestamptz
  AND valid_to   >  '2025-07-16'::timestamptz;

这是最高频的时态查询,模式三/模式四都能漂亮回答,审计日志要重放、版本号要 ORDER BY ... LIMIT 1——立见高下。

问句二:这个商品的价格变更历史?(history of a row)

sql
SELECT valid_from, valid_to, price
FROM product_prices
WHERE product_id = 42
ORDER BY valid_from;

时态表天然支持,审计日志也能回答(甚至更擅长,因为它还记了 changed_by)。这是审计日志不输时态表的地方。

问句三:上周三全表快照?(full snapshot)

sql
SELECT *
FROM product_prices
WHERE valid_from <= '2025-07-16'::timestamptz
  AND valid_to   >  '2025-07-16'::timestamptz;

整表回溯。注意这会扫全表,数据量大时要靠 (valid_from, valid_to) 上的索引或者按时间分区来扛。审计日志在这个查询上彻底崩盘——它得把每条记录的历史都重放一遍,几乎不可行。

问句四:价格第一次涨到 100 以上是什么时候?(first occurrence)

sql
SELECT MIN(valid_from) AS first_above_100
FROM product_prices
WHERE product_id = 42 AND price >= 100;

时态表一行 SQL,审计日志得写重放逻辑。这类"跨版本聚合"查询是时态表的强项,也是审计日志的死穴。

一句话总结这节的判断:你的核心查询是"回到某时刻看状态"或"全表快照",必须上时态表(模式三/四);核心查询只是"谁改过什么",审计日志就够。


八、CDC 替代方案:让应用无感

还有一条完全不同的路:不改表结构、不写触发器,用变更数据捕获(CDC)从数据库日志里旁路抽变更流。

典型架构是 Debezium + Kafka。Debezium 监听 PostgreSQL 的 WAL(或 MySQL 的 binlog),把每一次 INSERT/UPDATE/DELETE 解析成结构化事件,发到 Kafka;下游消费者把事件落到一个专门的历史存储(Elasticsearch、Iceberg、ClickHouse、另一个 PG 库)里。

text
   应用 ──正常读写──▶ PostgreSQL ──WAL──▶ Debezium ──▶ Kafka ──▶ 历史存储
                       (主表不动)                                       (查历史来这)

CDC 的吸引力全是"不侵入":

  • 应用零改动。UPDATE 照样覆盖,主表照样只存当前值,开发完全不用关心时态。
  • 跨库通用。同一套 Debezium 既能抓 PG 又能抓 MySQL,历史存储还能选更适合分析引擎。
  • 天然适合数据分析。变更流落成数仓表后,BI、报表、机器学习直接查,不和业务库抢资源。

它的代价也全是"重":

  1. 延迟。Debezium → Kafka → 下游落库是一条异步链路,秒级延迟算正常,几分钟也不稀奇。你想查"三秒前的价格",CDC 还没落下来,查不到。
  2. 基础设施复杂度爆炸。Kafka + Connect + Schema Registry + 下游存储 + 监控告警,一套下来运维成本不低。小团队为了一个价格历史上一整套 CDC 管线,杀鸡用牛刀。
  3. point-in-time 查询不友好。CDC 落的是事件流,要回答"上周三的价格"还得在下游自己拼快照,和审计日志一样要重放,除非下游专门建了时态索引。

所以 CDC 适合什么场景? 数据要进数仓做分析、有多个下游消费者、能容忍秒级延迟、团队已经有 Kafka 基建——这种时候 CDC 完胜时态表(时态表是 OLTP 数据库内部的,跨系统共享历史反而不便)。

反过来,CDC 不适合什么场景? 强一致 point-in-time 查询、低延迟、单库内闭环、小团队——这种时候老老实实在 PG 里建时态表,别给自己加一层 Kafka。

两者不是非此即彼,工业界常见组合是:OLTP 库内用时态表(或审计日志)解决业务回溯,CDC 同时把变更流推给数仓做长期分析——各司其职。


九、什么时候该上时态,什么时候审计日志就够

讲了这么多方案,最关键的问题是判断:你这个场景,到底该上哪一档? 我给一套实战决策框架。

该上时态表(模式三/四)的场景:

  • 金融、对账、清算。价格、利率、汇率、手续费,回溯查询是刚需,错一笔就是钱。
  • 合规与审计。SOX、GDPR、医疗记录(HIPAA)、税务,法规要求"能还原任意历史时刻的状态"。
  • 合同、保单、协议。这类数据天然有"有效区间"语义(生效日到失效日),valid time 是核心业务概念。
  • 定价、促销、关税。商品价格、税率随时间变化,财务和运营都要回溯"那笔交易当时该用什么价"。
  • 主数据管理(MDM)。客户、物料、组织架构的历史变更,下游系统要订阅"某一时刻的权威版本"。

审计日志就够的场景:

  • 简单 CRUD 业务。用户资料、文章、评论,记个 updated_at 加审计日志,足够回答"谁改过"。
  • 状态机变更。订单从"待支付"到"已发货",事件流本身就是审计日志,不需要时态表。
  • 草稿、临时数据。本身生命周期就短,存历史没意义。

该用版本号行(模式二)的场景:

  • 文档、合同、配置、Policy——版本是业务概念,用户会说"切回 V2"。

该上 CDC 的场景:

  • 历史数据要进数仓、要给多个下游系统消费、能容忍延迟、已有 Kafka 基建。

一个常见的过度设计值得警惕:为了"将来可能要查历史",给所有表无脑加 valid_from/valid_to。 结果是每个查询都得带时间谓词,写入都得两步事务,开发心智负担翻倍,而 90% 的表一辈子都没人查过它的历史。时态是有成本的,只给真正需要的表上。 我的判断标准很简单:如果你今天回答不出"上周三这个值是多少"会出事(赔钱、违规、纠纷),就上时态表;如果只是"心里不踏实",审计日志足够让你踏实。


十、代价:存储增长与查询复杂度

最后把代价讲清楚,免得你以为时态表是银弹。

代价一:存储随变更线性增长。 模式二/三/四的共同点是——每一次变更都至少多一行。一个一天调三次价的 SKU,一年就是一千多行历史。一张高频变更的表(比如实时库存),如果不加管控,几个月就能把历史表撑到 TB 级。应对手段:

  • 历史表按时间分区。product_prices_history 按 valid_to 月度分区,老分区可以单独压缩、归档、甚至挪到冷存储。PG 原生声明式分区完全够用。
  • 设置保留期(retention)。合规要求保留 7 年就留 7 年,没要求的业务历史保留 1~2 年足够,老数据定时 DETACH + 归档。
  • 区分冷热。当前表永远小,历史表可以大——这是模式四(系统版本表)相对模式三(单表区间)的一个隐性优势,历史天然分离。

代价二:查询复杂度上升。 每个查询都要带时间谓词,复杂业务里时间条件层层嵌套,SQL 可读性下降。索引策略也得跟着调整:point-in-time 查询要靠 (entity_id, valid_from, valid_to) 复合索引或者 GIST 区间索引,写错就全表扫描。EXPLAIN 是你在这条路上的好朋友。

代价三:写入路径变重。 模式三的"收口旧行 + 插新行"两步事务、模式四的触发器,都让单次写入的代价高于普通 UPDATE。高频写入表上要测一下吞吐影响,必要时把历史落盘异步化(但那又引入一致性问题,得权衡)。

代价四:并发与一致性。 模式三的两步事务如果不加锁,两个并发调价可能各自收口、各自插新行,搞出重叠区间或空洞。这就是为什么前面强调要用排除约束兜底——把并发正确性交给数据库,别指望应用层加锁能写对。

把这些代价摆出来,你就会明白为什么我反复说"先上审计日志"——审计日志的代价几乎是零,而时态表的代价是真实且持续的。 只有当收益(能回答历史状态查询)明确超过这些代价时,才值得升级。


小结

  1. 时态数据回答的是"某时刻的状态",不是"现在的状态"。 普通表用 UPDATE 覆盖旧值、丢掉历史;时态建模把时间维度留下来,能回溯任意时刻。建模前先分清 valid time(业务时间)和 system time(系统时间),双时态只在金融/医疗这类极敏感场景才需要。

  2. 四档模式按强度递增。审计日志(最轻,记事件,难还原状态)→ 版本号行(INSERT 新版本,适合合同/文档)→ 有效时间范围(valid_from/valid_to + 排除约束,工业手工主力)→ SQL:2011 系统版本表(DB 原生管历史,FOR SYSTEM_TIME AS OF 查询)。强度越高能力越强,代价也越大。

  3. PostgreSQL 没有原生 SQL:2011 系统版本表,但有三条成熟路线。首选 temporal_tables 扩展(当前表 + 影子历史表 + 触发器,应用无感);时序味儿重的考虑 TimescaleDB;受限环境用纯 SQL 触发器手写,但要参考成熟实现、别自己踩坑。

  4. CDC 是另一条路,不是时态表的替代品。Debezium + Kafka 不侵入应用、跨库通用、天然喂数仓,但有秒级延迟、运维重、point-in-time 查询不友好。OLTP 内部回溯用时态表,跨系统分析用 CDC,两者各司其职常共存。

  5. 该不该上,看"回答不出历史会不会出事"。 金融/合规/合同/定价——会出事,必须上时态表;简单 CRUD/状态机/草稿——不会出事,审计日志 + updated_at 足够。别为了"心里不踏实"给所有表无脑加时间区间,那是过度设计。代价是真实的:存储随变更线性增长、查询复杂度上升、写入变重、并发要靠排除约束兜底。

金句:普通表是块擦了就没了的白板,时态表是本能回放的录像。录像不是免费的——但该装监控的房间你不装,出了事你拿什么回放?时态设计的全部功夫,就在于分清哪些数据值得录像,哪些擦了就擦了。

Related

相关推荐