数据库时态数据模式:追踪历史变化
"这个价格上周是多少?"——普通表只存当前值,时态表存历史。本文讲清时态数据的几种模式(审计日志/版本号行/有效时间范围/PG 时态表扩展)、SQL:2011 时态表标准、CDC 替代方案,以及什么时候该上时态、什么时候审计日志够。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
数据库时态数据模式:追踪历史变化
先抛一个你大概率会撞上的场景。
财务找过来:「这个 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),能回答"上周三系统里记录的、当时认为有效的价格是多少"这种烧问题。金融、保险、医疗里很常见,绝大多数场景只要其中一个就够。
业务时间轴 (valid time)
合同生效 ───────────────────────────── 合同失效
═══════════════════════════════════════
录入系统 修改价格
───────────────────────────●─────────────────●──────────►
│ │
系统时间轴 (system time)
下面四种模式,本质都是在回答"用哪一种时间维度、用什么结构存下来"。
二、模式一:审计日志(最轻量,先从这开始)
这是 80% 场景该用的方案,也是最容易被忽视的方案。思路很简单:主表保持普通表不动(照样 UPDATE 覆盖),另开一张表记录每一次变更事件。
-- 主表:照常,只存当前值
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()
);
写入靠应用层在同一个事务里插一条日志,或者干脆用触发器自动记:
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,旧行不删,新行追加。
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 的新行:
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;
查"最新版":
SELECT content FROM contract_versions
WHERE contract_id = 99
ORDER BY version DESC LIMIT 1;
查"上周三用的哪个版本":
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",业务用户也习惯说"切回上一版"。版本号本身就是业务概念,存下来天经地义。
它的代价:
- "当前值"不再是一行,而是"最大的 version 那行"。所有查当前值的 SQL 都得带上
ORDER BY version DESC LIMIT 1,或者维护一个is_current标记列。容易忘、容易写错。 - 同一逻辑实体的行会越攒越多,查询时要小心别扫历史版本——必须靠
(contract_id, version)索引命中最新行。 - 不适合"频繁小变更"的数据。价格一天调三次、库存每分钟都在变,版本号会爆炸式增长(一天几千个版本),这种数据该用下一节的时间范围模式。
版本号行是审计日志和时态表之间的中间态:比审计日志多了"快照"能力(每行就是个完整快照),比真正的时态表少了"区间查询"的原生支持。用在版本边界清晰、变更不频繁的场景,是性价比很高的选择。
四、模式三:有效时间范围(valid_from / valid_to,工业主力)
这是手工实现时态最常用的模式,也是后面 SQL:2011 时态表的底层原型。每一行带一个时间区间,表示"这行数据在这个区间内为真"。
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,而是分两步:
- 把当前有效行的
valid_to收口到此刻(UPDATE ... SET valid_to = now()); INSERT一条新行,valid_from = now(),valid_to = 'infinity'。
-- 调价: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');
这两步必须在一个事务里,否则会出现"同一段时间没有任何有效价格"的空洞。
查询"上周三的价格是多少"
这是时态数据的招牌查询,标准写法:
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 替代两个时间列:
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 = 保证只对同一商品做检查。有了它,你想插一段和现存区间重叠的记录,数据库直接拒绝,从根上消灭"同一段时间两个价格"。
查询也可以用区间运算符,更简洁:
-- @> 表示区间包含某时刻
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: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;
查"上周三的价格是多少"——注意这个专门的历史查询语法:
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 上长期维护),它给你补上了系统版本表的能力——你建一张当前表 + 一张结构相同的影子历史表,挂一个触发器,剩下的它管。
-- 当前表:多一个 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,扩展自动:
- 把旧版本(price=89.5)连同它的
sys_period收口后插进product_prices_history; - 在当前表更新 price,并把
sys_period的下界设成now()。
你写应用代码完全无感,但 product_prices_history 里默默攒下了完整历史。查"上周三的价格":
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 一样:
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)
-- 模式三:有效时间范围
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)
SELECT valid_from, valid_to, price
FROM product_prices
WHERE product_id = 42
ORDER BY valid_from;
时态表天然支持,审计日志也能回答(甚至更擅长,因为它还记了 changed_by)。这是审计日志不输时态表的地方。
问句三:上周三全表快照?(full snapshot)
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)
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 库)里。
应用 ──正常读写──▶ PostgreSQL ──WAL──▶ Debezium ──▶ Kafka ──▶ 历史存储
(主表不动) (查历史来这)
CDC 的吸引力全是"不侵入":
- 应用零改动。
UPDATE照样覆盖,主表照样只存当前值,开发完全不用关心时态。 - 跨库通用。同一套 Debezium 既能抓 PG 又能抓 MySQL,历史存储还能选更适合分析引擎。
- 天然适合数据分析。变更流落成数仓表后,BI、报表、机器学习直接查,不和业务库抢资源。
它的代价也全是"重":
- 延迟。Debezium → Kafka → 下游落库是一条异步链路,秒级延迟算正常,几分钟也不稀奇。你想查"三秒前的价格",CDC 还没落下来,查不到。
- 基础设施复杂度爆炸。Kafka + Connect + Schema Registry + 下游存储 + 监控告警,一套下来运维成本不低。小团队为了一个价格历史上一整套 CDC 管线,杀鸡用牛刀。
- 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。高频写入表上要测一下吞吐影响,必要时把历史落盘异步化(但那又引入一致性问题,得权衡)。
代价四:并发与一致性。 模式三的两步事务如果不加锁,两个并发调价可能各自收口、各自插新行,搞出重叠区间或空洞。这就是为什么前面强调要用排除约束兜底——把并发正确性交给数据库,别指望应用层加锁能写对。
把这些代价摆出来,你就会明白为什么我反复说"先上审计日志"——审计日志的代价几乎是零,而时态表的代价是真实且持续的。 只有当收益(能回答历史状态查询)明确超过这些代价时,才值得升级。
小结
-
时态数据回答的是"某时刻的状态",不是"现在的状态"。 普通表用 UPDATE 覆盖旧值、丢掉历史;时态建模把时间维度留下来,能回溯任意时刻。建模前先分清 valid time(业务时间)和 system time(系统时间),双时态只在金融/医疗这类极敏感场景才需要。
-
四档模式按强度递增。审计日志(最轻,记事件,难还原状态)→ 版本号行(INSERT 新版本,适合合同/文档)→ 有效时间范围(valid_from/valid_to + 排除约束,工业手工主力)→ SQL:2011 系统版本表(DB 原生管历史,
FOR SYSTEM_TIME AS OF查询)。强度越高能力越强,代价也越大。 -
PostgreSQL 没有原生 SQL:2011 系统版本表,但有三条成熟路线。首选
temporal_tables扩展(当前表 + 影子历史表 + 触发器,应用无感);时序味儿重的考虑 TimescaleDB;受限环境用纯 SQL 触发器手写,但要参考成熟实现、别自己踩坑。 -
CDC 是另一条路,不是时态表的替代品。Debezium + Kafka 不侵入应用、跨库通用、天然喂数仓,但有秒级延迟、运维重、point-in-time 查询不友好。OLTP 内部回溯用时态表,跨系统分析用 CDC,两者各司其职常共存。
-
该不该上,看"回答不出历史会不会出事"。 金融/合规/合同/定价——会出事,必须上时态表;简单 CRUD/状态机/草稿——不会出事,审计日志 +
updated_at足够。别为了"心里不踏实"给所有表无脑加时间区间,那是过度设计。代价是真实的:存储随变更线性增长、查询复杂度上升、写入变重、并发要靠排除约束兜底。
金句:普通表是块擦了就没了的白板,时态表是本能回放的录像。录像不是免费的——但该装监控的房间你不装,出了事你拿什么回放?时态设计的全部功夫,就在于分清哪些数据值得录像,哪些擦了就擦了。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。