代理主键把一行记录的身份与可能变化的业务值分开。客户修改邮箱时,订单仍引用同一个客户编号,而不必跟着邮箱一起改引用。这对于易变或较宽的业务键很有用,但稳定性来自数据库约束与应用规则,不是“自动生成”四个字本身。一个生成的数字不会天然不可修改、保密、全局唯一,也不能代替访问授权。

生成身份与约束唯一性

PostgreSQL identity 列从关联序列取得默认值。GENERATED ALWAYS AS IDENTITY 会拒绝普通显式赋值,GENERATED BY DEFAULT 则允许调用方提供值。即使使用 ALWAYS,导入时仍可通过 OVERRIDING SYSTEM VALUE 显式覆盖。两种形式都不会单独保证值唯一;需要拒绝重复标识时,仍须声明 PRIMARY KEY 或 UNIQUE 约束。

主键同时保证唯一与非空,并提供可供引用使用的索引。Identity 负责怎样生成默认值,约束负责哪些最终值允许存储,这两项职责不能混淆。导入时尤其如此:显式写入一个很大的编号,不会自动把序列推进到那个位置。若没有协调序列状态,后续生成值可能最终与导入行相撞,直到主键报错才暴露问题。

单独保留业务唯一规则

代理编号不会阻止相同业务实体重复出现。如果邮箱是唯一登录标识,应继续保留明确的唯一约束,并先决定规范化规则。普通 text 的 UNIQUE 使用该列的比较语义,不会暗中替应用处理大小写折叠、空白或各种邮箱等价规则。迁移之前就要明确这些行为,否则数据库认为不同的值,应用可能认为是同一账号,或者反过来。

子表如果不能出现悬空引用,就需要真实的外键约束。邮箱不是引用键时,修改它不会改变订单指向的客户,因为订单保存的是 id。这缩小了这一项修改的范围,却不能承诺所有业务改名都变成简单操作:搜索索引、审计记录、外部集成和缓存仍可能依赖邮箱,需要按实际调用链更新。数据模型只解决它真正覆盖的关系。

坏掉的样子

把序列编号当成创建顺序或提交顺序,在并发下会产生错误结果。多个事务可能先后取号却反向提交,失败语句也可能留下空洞。展示排序应使用明确的时间字段并补充稳定决胜字段,不能根据编号连续性推断是否丢单。顺序编号也容易被猜到;即使另加不透明的公开标识,授权仍必须检查请求者与资源之间的关系,不能靠“不容易猜”保护数据。

导入时覆盖 identity 是另一种陷阱。插入的显式编号远大于序列当前位置,序列仍可能继续从较小值生成,最终碰到已有主键。应在受控导入流程中协调序列状态,并排除或正确协调并发写入。另开一个会话查询 max(id) 再加一,不是并发安全的分配算法;两个调用方可能同时得到相同结果。

迁移还可能意外删掉原自然键承担的唯一规则。换键之前要检查重复值与 null,保留真正需要的业务唯一约束,并沿所有真实引用更新。也不必仅因代理键常见就给每张表增加一列和一个索引;稳定自然键如果已经准确表达契约,也可能是合适选择。决策依据应是业务值的可变性、引用范围和数据语义。

快速验证

把下面代码保存为 identity.sql,运行 psql -X -v ON_ERROR_STOP=1 -f identity.sql,连接一个可丢弃的 PostgreSQL 18 数据库。实验使用临时表并在末尾回滚,不依赖已有 users 表,也不需要读取真实用户邮箱。所有地址都采用保留的 example.test 域名,仅用于构造可重复的数据。

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE users (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL UNIQUE
);
CREATE TEMP TABLE orders (
  id integer PRIMARY KEY,
  user_id bigint NOT NULL REFERENCES users(id)
);
INSERT INTO users(email) VALUES ('a@example.test') RETURNING id AS owner_id \gset
INSERT INTO orders VALUES (1, :owner_id);
UPDATE users SET email='renamed@example.test' WHERE id=:owner_id;
SELECT u.id, u.email FROM orders o JOIN users u ON u.id=o.user_id;
DO $$ BEGIN
  INSERT INTO users(email) VALUES ('renamed@example.test');
  RAISE EXCEPTION 'duplicate email accepted';
EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'duplicate email rejected';
END $$;
DO $$ BEGIN
  INSERT INTO users(id,email) VALUES (99,'manual@example.test');
  RAISE EXCEPTION 'explicit identity accepted without override';
EXCEPTION WHEN generated_always THEN RAISE NOTICE 'explicit identity rejected';
END $$;
INSERT INTO users(id,email) OVERRIDING SYSTEM VALUE VALUES (100,'import@example.test');
INSERT INTO users(email) VALUES ('next@example.test') RETURNING id;
DO $$ BEGIN
  IF (SELECT email FROM users u JOIN orders o ON u.id=o.user_id)
     IS DISTINCT FROM 'renamed@example.test' THEN
    RAISE EXCEPTION 'rename broke the reference';
  END IF;
  IF (SELECT id FROM users WHERE email='next@example.test') IS DISTINCT FROM 3 THEN
    RAISE EXCEPTION 'unexpected identity sequence position';
  END IF;
END $$;
ROLLBACK;

邮箱修改后,订单仍然关联到编号一的客户。重复邮箱被唯一约束拒绝,而这次失败插入已经消耗了序列值二。没有覆盖子句的显式 identity 插入被拒绝;受控覆盖随后写入编号一百,但下一条正常生成记录拿到的是三,说明显式导入没有重定位序列。代码同时断言引用仍然正确,以及这个隔离夹具中的具体序列结果。

这些行为已在 PostgreSQL 18.6 验证。最后回滚清理实验对象,不能据此宣称普通业务事务中的序列不会有空洞。这个练习把生成机制、主键约束、外键完整性、业务唯一性与访问控制分开说明,避免把所有涉及 id 的规则都误认为由 identity 列自动完成。真正迁移时还要验证既有数据与并发写入流程,而不是只复制建表语句。

来源