MySQL 联合唯一索引遇到 NULL:公共记录为什么会重复
设计多租户配置表时,我想用 tenant_id = NULL 表示公共配置,再给 (tenant_id, skill_name) 加联合唯一索引。直觉上,公共配置和 Skill 名称应该只能有一行,实际插入两条相同的公共记录时,MySQL 并不会报重复键错误。
这个行为容易被误判成索引失效。索引工作正常,冲突判断遇到的是 SQL 对 NULL 的特殊语义。
一个最小例子
1 | |
两条 INSERT 都可以成功,表里出现两条:
1 | |
MySQL 的唯一索引允许多行包含 NULL。联合索引也遵循这条规则:参与唯一键的列存在 NULL 时,重复元组可以同时存在。
NULL 不是普通的“空值”
SQL 使用三值逻辑处理 NULL。下面的表达式结果不是 TRUE:
1 | |
结果是 UNKNOWN。NULL 表示未知或缺失,数据库不会把两个未知值当成相等值。唯一约束据此允许多条带 NULL 的记录。
查询也有同样的边界:
1 | |
下面这种写法也不能代替 IS NULL:
1 | |
公共记录需要显式写成:
1 | |
公共范围用 NULL 还是 0
NULL 的含义更自然:它表示这条记录没有绑定具体租户。保留这个设计也可以,只是唯一性不能完全交给普通的 (tenant_id, skill_name) 唯一索引,需要额外的生成列、表达式索引或应用层约束。
这次的 Skill 设计更倾向用 tenant_id = 0 表示公共范围:
1 | |
公共 Skill 使用 0:
1 | |
同一个公共 Skill 再插入一条时,唯一索引会直接拒绝。租户覆盖查询也可以写成:
1 | |
这条约定有一个前提:0 必须被系统保留,不能作为真实租户 ID。数据库约束和业务约定需要同时成立,单独依赖其中一层都不够。
放回 Skill 的表设计
Skill 历史和当前指针分开保存:
1 | |
skill_snapshot 追加保存每次修改,skill_current 只指向当前版本。公共 Snapshot 和公共 Current 使用 tenant_id = 0 时,UNIQUE (tenant_id, skill_name) 可以保证同一个范围内只有一个 Current,回滚也只需要切换指针。
留下的判断
NULL 适合表达“没有租户归属”,0 适合把公共范围当成一个可以参与索引比较的保留域。两种设计都能工作,差异落在唯一约束、查询写法和数据清理成本上。
在需要“每个范围、每个名称只能有一个当前版本”的表里,我会优先让公共范围使用非空保留值,并在数据库中加唯一约束。这样规则能在并发写入时继续生效,查询也少一个 IS NULL 分支。