题干与适用场景
订单表允许每个租户有一个可选外部引用:同一租户最多一条记录可以是 NULL。你会如何在 PostgreSQL 18 中实现这个约束,处理历史重复数据、并发写入和回滚?
PostgreSQL 的唯一索引默认把 NULL 视为彼此不同,因此多个 NULL 不会冲突。PostgreSQL 18 的 NULLS NOT DISTINCT 选项让唯一索引把 NULL 视为相等。题目考察数据库约束设计、迁移安全和并发语义,不是把业务规则简单塞进应用代码。
面试官考察点
- 是否准确解释默认 NULL 唯一性与
NULLS NOT DISTINCT的差异。 - 是否知道该选项作用于唯一 B-tree 索引或唯一约束,而不是普通查询比较。
- 是否会先发现并处理历史重复的 NULL 和非 NULL 组合。
- 是否能设计在线迁移、锁影响、并发写入和失败回滚。
- 是否评估 ORM、复制、分区和下游数据契约的兼容性。
回答前需要澄清的问题
- 约束范围是全表,还是每个租户、区域或有效状态一组?
- NULL 的业务含义是“尚未分配”、未知,还是明确的共享引用?
- 历史数据是否已经有多个 NULL 或大小写、空字符串等伪重复?
- 写入流量、可接受锁时间和迁移窗口是多少?
- 应用、ORM、CDC 和报表是否假设 NULL 可以重复?
30 秒回答框架
“我先确认 NULL 的业务含义和约束范围,再审计历史重复数据。对全表规则可使用带 NULLS NOT DISTINCT 的唯一索引或唯一约束;租户范围则把租户列放入复合唯一键。迁移前清理或决策历史冲突,在线创建索引并监测锁和写入延迟,验证应用与 CDC 仍能处理唯一冲突。上线后让数据库成为并发下的最终裁判;若冲突处理不符合业务,就回滚约束和应用策略,而不是继续依赖竞态的预检查。”
分步骤深入解答
第一步:确认 NULL 和约束范围
先区分“未知值”和“占位状态”。如果 NULL 表示尚未分配,最多一个 NULL 的规则可能合理;如果它表示未知且允许多个,就不应强行唯一。确定约束是全表还是按租户、区域、有效状态分组,并决定复合键的列顺序和是否需要部分索引。
第二步:选择数据库表达
PostgreSQL 18 的唯一索引支持 NULLS NOT DISTINCT;默认行为是 NULL 之间不相等,允许多个 NULL。可以使用唯一约束让数据模型更直观,也可以直接创建唯一 B-tree 索引以配合在线迁移。该选项只改变唯一索引的等值判断,不改变 WHERE value = NULL 的三值逻辑,也不等价于把列改成 NOT NULL。
第三步:审计并整理历史数据
按约束键分组统计多个 NULL、空字符串、大小写变体和已删除但仍占用键的记录。为每组冲突定义业务选择:合并订单、补上外部引用、保留一条并迁移其他记录,或明确豁免。清理脚本要可重放、可审计,并在影子环境先验证结果;不能让创建索引时才暴露未决冲突。
第四步:设计在线迁移
先发布兼容的应用错误处理,再在低峰期创建唯一索引或约束。对大表优先评估并发创建、锁级别、磁盘空间和写入延迟;迁移期间持续检查冲突和长事务。若必须分租户逐步启用,就按租户批次建立规则并记录完成水位。不要先删除旧的应用预检查,直到数据库约束和错误映射已就绪。
第五步:处理并发和下游契约
数据库唯一索引在并发插入或更新时提供最终裁决,应用的“先查询再插入”只能优化用户体验,不能替代约束。将唯一冲突映射为可重试或可展示的业务错误,避免无限重试。检查 CDC、复制、ORM schema、报表和缓存是否把 NULL 重复当成正常情况,并更新数据契约和告警。
第六步:验证、监控与回滚
在 staging 和灰度租户验证四类组合:一个 NULL、第二个 NULL、相同非 NULL、不同非 NULL;再覆盖更新、删除后重建和并发写入。监控索引构建、锁等待、冲突率、应用错误和下游延迟。若冲突率或业务语义不符合预期,可先停止新写入路径、删除新约束、恢复兼容错误处理,再保留审计数据分析原因。
高质量示范回答
我会先确认 NULL 的语义和约束范围。若规则是每个租户最多一个可选引用,就把租户列纳入复合唯一键,并在 PostgreSQL 18 使用 NULLS NOT DISTINCT;它让同一键中的 NULL 也参与唯一性判断,但不改变普通 SQL 的三值逻辑,也不替代 NOT NULL。
上线前按租户和键审计多个 NULL、空字符串、大小写变体和软删除记录,定义合并或补值方案。先发布唯一冲突的应用处理,再在低峰期建立索引或约束,监控锁、空间、长事务和冲突。用一个 NULL、第二个 NULL、相同与不同非 NULL、更新、删除重建和并发测试验证。数据库约束是最终裁判,CDC、ORM、报表和缓存也要同步更新;若业务冲突率不接受,按预案删除约束并回滚应用策略。
常见错误
- 以为 UNIQUE 默认只允许一个 NULL → PostgreSQL 默认把 NULL 视为不同 → 明确使用
NULLS NOT DISTINCT。 - 把它当成 NOT NULL → 该选项仍允许一个 NULL → 区分缺失值语义与唯一性。
- 只在应用层先查再插入 → 并发请求仍会竞态 → 让数据库唯一索引做最终裁决。
- 忽略空字符串和大小写变体 → 业务重复可能不表现为 NULL 冲突 → 先定义规范化和清理策略。
- 直接在线创建而不清理历史 → 旧重复会让迁移失败或阻塞 → 先审计、决策并监控长事务。
- 只改数据库不改下游 → ORM、CDC 和报表可能仍假设 NULL 可重复 → 更新数据契约和错误映射。
追问及应对
NULLS NOT DISTINCT 会改变普通查询中的 NULL 比较吗?
不会。它改变唯一索引判断 NULL 是否相等;WHERE value = NULL 仍遵循 SQL 三值逻辑,应使用 IS NULL。因此查询语义、索引约束语义和列是否允许 NULL 要分开说明。
历史上已有两个 NULL 时,如何无停机迁移?
先按租户和业务状态选定保留记录,合并或补齐其他记录,并把决策写入审计表。发布冲突错误处理后分批建立索引,监控锁和长事务;无法在窗口内清理的租户暂缓启用,不能让索引创建承担业务决策。
多租户规则应该用复合唯一键还是部分索引?
如果规则是所有状态都唯一,把租户列和引用列放入复合唯一键;如果只约束有效记录,可考虑限定有效状态的部分索引。选择取决于删除、恢复、状态迁移和查询契约,并要用并发测试证明边界。