不能只靠触发器判断历史表唯一性,因高并发下SELECT COUNT(*)查历史表易死锁或漏判;唯一性必须由带时间范围的联合唯一索引(如(order_id, valid_from))强制保障,且valid_from须为业务生效时间、索引字段顺序须与主键一致。

为什么不能只靠触发器判断历史表唯一性
直接在触发器里 SELECT COUNT(*) 查历史表是否已存在同主键记录,高并发下极易死锁或漏判。因为触发器执行时事务未提交,其他并发事务可能正持有同一行的锁,或刚插入但尚未提交——这时你查不到它,却仍允许当前写入,最终导致重复。真正可靠的唯一保障必须由数据库底层索引强制执行,不是靠触发器“算出来”。
必须建带时间范围的联合唯一索引
历史表不能只对业务主键(如 order_id)建 UNIQUE 约束,否则无法支持版本叠加。正确做法是用 (order_id, valid_from) 或 (order_id, valid_to) 建唯一索引,其中 valid_from 是每条历史记录生效起始时间。例如:
CREATE UNIQUE INDEX idx_hist_order_valid ON history_orders (order_id, valid_from);
这样既能防止同一订单在相同时刻生成多条“活跃”记录,又允许同一订单在不同时间点有多条历史版本。
-
valid_from必须设为TIMESTAMP WITH TIME ZONE DEFAULT NOW(),不能用created_at替代——后者是写入时间,不反映业务逻辑生效时刻 - 若业务要求“某订单当天只能有一条有效版本”,则索引需改为函数索引:
CREATE UNIQUE INDEX ON history_orders ((order_id, DATE(valid_from))) - MySQL 5.7 不支持函数索引,得改用生成列;PostgreSQL 和 MySQL 8.0+ 可直接用
AFTER 触发器中必须显式控制 valid_to
INSERT/UPDATE/DELETE 操作必须全部走 AFTER 行级触发器,且在函数内按操作类型分别处理 valid_to:
- INSERT:新记录
valid_to = NULL,不设valid_to的默认值,避免误覆盖 - UPDATE:先将
OLD.*插入历史表,并显式设valid_to = CURRENT_TIMESTAMP;再确保主表更新已完成(不能依赖触发器去改主表) - DELETE:插入
OLD.*到历史表,valid_to = CURRENT_TIMESTAMP,并标记op_type = 'D'
漏掉任何一种操作类型的 valid_to 赋值,都会导致历史表出现逻辑断层或重复生效区间。
复合主键场景下索引字段顺序不能错
如果业务主键本身就是复合的(如 (tenant_id, doc_no)),历史表的唯一索引字段顺序必须与主表主键完全一致。例如主表是:
PRIMARY KEY (tenant_id, doc_no)
那么历史表索引必须是:
CREATE UNIQUE INDEX idx_hist_tenant_doc_valid ON history_docs (tenant_id, doc_no, valid_from);
反过来写成 (doc_no, tenant_id, valid_from) 会导致查询和唯一性校验失效——B-tree 索引只对最左前缀生效,而业务查询通常以 tenant_id 为第一筛选条件。
最容易被忽略的是:索引字段顺序、触发器中 valid_to 的赋值时机、以及函数索引在旧版数据库中的兼容性处理。这三处任一出错,历史表的“唯一性”就只是纸面约定。










