正确方案是用before update和before delete将old.*写入历史表并设valid_to=now(),insert不产生历史;历史表需建(id,valid_from)联合索引并按时间分区管理。

触发器里不能直接用 AFTER INSERT 同步写历史表
很多初学者一上来就写 AFTER INSERT 触发器,往历史表里插一条带 NOW() 的快照,结果发现并发插入时时间戳重复、事务回滚后历史数据却没删、甚至主表还没提交历史表已落盘——这根本不是快照,是脏写。真正可用的方案必须和主事务绑定:用 BEFORE UPDATE 和 BEFORE DELETE 把旧行存进历史表,INSERT 本身不产生“历史”,它只新增当前态。
-
BEFORE UPDATE触发时,把OLD.*插入历史表,并加valid_to = NOW() -
BEFORE DELETE触发时,同样存OLD.*,但valid_to设为NOW()(表示该版本终止) - 主表只保留最新状态,历史表靠
valid_from/valid_to闭区间标记生命周期 - 避免在触发器里调用
SLEEP()、外部函数或复杂子查询,会拖慢主事务
SELECT ... AS OF TIMESTAMP 在 MySQL 和 PostgreSQL 中完全不可用
别被文档误导——MySQL 原生不支持 AS OF 语法,PostgreSQL 从 15 开始才实验性支持 SYSTEM_TIME,且要求开启 timescaledb 或用 pg_temporal 扩展。真要实现时间旅行查询,得自己拼 SQL:WHERE valid_from '2024-06-01' OR valid_to IS NULL)。注意 valid_to IS NULL 表示该行仍是当前有效版本。
- 历史表必须在
(id, valid_from)上建联合索引,否则时间范围查询全表扫描 - 如果业务常查“某时刻最新状态”,可额外加
valid_to IS NULL索引优化当前态查询 - 不要用
BETWEEN写时间区间,它包含端点,容易漏掉valid_to = '2024-06-01 00:00:00'这种边界情况
触发器无法捕获批量更新(UPDATE ... WHERE IN (...))的中间态
一个 UPDATE users SET status = 'archived' WHERE id IN (1,2,3,4,5) 只触发一次 BEFORE UPDATE,但你要存 5 条历史记录。触发器里的 OLD.* 是逐行可见的,但必须用 FOR EACH ROW 显式声明,否则默认是语句级(statement-level),OLD 根本不可用。
- MySQL 5.7+ / PostgreSQL 必须写
FOR EACH ROW,否则触发器不生效 - PostgreSQL 触发器函数返回
OLD会跳过原操作,返回NEW才继续执行更新——这点和 MySQL 相反 - 如果主表有外键或级联更新,先确认触发器执行顺序:MySQL 中
BEFORE触发器在约束检查前,可能绕过外键校验
历史表膨胀快,不清理等于埋雷
每天百万级更新,半年历史表就上亿行,SELECT 变慢只是表象,更危险的是 ALTER TABLE 加字段卡住主库 DDL,或者备份时间翻倍导致 RPO 失控。别等出事再想归档——从第一天就要设计分区或 TTL。
- MySQL 可按
valid_from做 RANGE 分区,每月一个分区,过期后ALTER TABLE ... DROP PARTITION - PostgreSQL 推荐用
pg_partman自动管理按时间分区的历史表 - 切忌用
DELETE FROM history_table WHERE valid_to ,大删易锁表;改用 <code>TRUNCATE PARTITION或分批DELETE LIMIT 10000配合SLEEP(0.1)
UPDATE 和 DELETE 都走同一套历史逻辑——ORM 自动生成的语句、DBA 手工跑的修复脚本、第三方同步工具,任何一个绕过触发器,快照链就断了。










