软删除的噩梦:为什么 `username` 唯一索引总报错?
MySQL 8.0 的是一个被严重低估的特性。虽然它没有 PostgreSQL 的WHERE子句那么直观易读,但它通过巧妙利用 NULL的非唯一性,成功解决了困扰 MySQL 开发者多年的“软删除与唯一索引冲突”的世纪难题。

你正在设计一个用户系统,要求 “用户名必须唯一”。
同时,为了数据安全,你使用了 “软删除” (Soft Delete),即删除用户时不物理删除,而是设置 deleted_at = NOW()。
冲突爆发:
- 用户 A 注册了
alice,后来注销了(deleted_at有值)。 - 第二天,另一个新用户(或者用户 A 后悔了)想再次注册
alice。 - 数据库报错:
Duplicate entry 'alice' for key 'username'。
绝望:
虽然上一条 alice 已经逻辑删除了,但它在物理表里还在!唯一索引依然生效。
你想要的是:“只有当 deleted_at 为 NULL 时,用户名才需要唯一;如果已经删除了,随便重复。”
1. 核心差异:PG 的优雅 vs MySQL 的无奈
在 PostgreSQL 中,解决这个问题只需要一行代码:
-- PG 写法:直接加 WHERE
CREATE UNIQUE INDEX idx_unique_username
ON users (username)
WHERE deleted_at IS NULL;
这叫 Partial Index(部分索引),它只索引那些没被删除的行。
但在 MySQL 中,直到今天也不支持 CREATE INDEX ... WHERE 这种语法。
在 MySQL 8.0 之前,你唯一的选择是去掉数据库的唯一约束,把校验逻辑挪到代码里(会有并发风险),或者搞一个复杂的组合索引(username + deleted_at),但这依然解决不了“多次删除同一个用户名”的问题(因为 timestamps 不同)。
2. 救星登场:MySQL 8.0 函数索引
MySQL 8.0.13+ 引入了 Functional Key Parts,允许我们对“表达式的结果”建立索引。
核心黑科技原理:利用 SQL 中的 NULL 特性。
在 SQL 标准中,**NULL 不等于 NULL**。
这意味着,在唯一索引(Unique Index)中,你可以存储多个 NULL 值,而不会触发 Duplicate Entry 错误。
我们的策略:
创建一个索引,它的值计算逻辑如下:
- 如果这行是有效数据(
deleted_at IS NULL):索引存username。 - 如果这行是已删数据(
deleted_at有值):索引存NULL。
这样一来,有效的 alice 会和有效的 alice 冲突;但已删除的 alice 会变成 NULL,而 NULL 和 NULL 是和平共处的!
3. 实战代码:曲线救国
我们使用 CASE WHEN 表达式或者是 IF 函数来构建这个索引。
DDL 写法:
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50),
deleted_at TIMESTAMP NULL
);
-- 重点来了:注意那两层括号 (( ... ))
CREATE UNIQUE INDEX idx_unique_active_username
ON users (
(IF(deleted_at IS NULL, username, NULL))
);
效果验证:
- 注册 Alice (成功)
INSERT INTO users (username, deleted_at) VALUES ('alice', NULL);
- 索引存的值:
'alice'
- 软删除 Alice (成功)
UPDATE users SET deleted_at = NOW() WHERE username = 'alice';
- 索引存的值:
NULL
- 再次注册 Alice (成功!)
INSERT INTO users (username, deleted_at) VALUES ('alice', NULL);
- 索引存的值:
'alice'。 - 注意: 此时表里有两条
alice,一条是删掉的(索引为 NULL),一条是活着的(索引为 ‘alice’)。互不冲突。
- 再次注册 Alice (失败!)
INSERT INTO users (username, deleted_at) VALUES ('alice', NULL);
- 索引存的值:
'alice'。 - 报错:
Duplicate entry 'alice' for key 'idx_unique_active_username'。 - 完美! 这正是我们想要的“条件唯一”。
4. 更多实战场景
除了软删除,这个技巧在其他状态管理的场景下也极好用。
场景一:单一“生效”版本 (Single Active Version)
业务: 一个合同(Contract)可以有多个草稿(Draft)或历史版本(Archived),但只能有一个 “执行中” (Active) 的版本。
表结构: contract_id, version, status (‘DRAFT’, ‘ACTIVE’, ‘ARCHIVED’)
需求: 同一个 contract_id 下,只能有一行数据的 status 是 ACTIVE。
索引设计:
CREATE UNIQUE INDEX idx_one_active_contract
ON contracts (
contract_id,
(CASE WHEN status = 'ACTIVE' THEN 1 ELSE NULL END)
);
原理: 对于非 Active 的行,第二列全是 NULL,不做唯一性校验。对于 Active 的行,第二列是 1,配合 contract_id 形成唯一约束。
场景二:每人每天只能领一次奖 (Daily Limit)
业务: 用户领奖记录表。
需求: 某些特殊的奖品 type = 'SPECIAL',限制每天只能领一次。普通奖品不限。
索引设计:
CREATE UNIQUE INDEX idx_special_daily_limit
ON user_rewards (
user_id,
date,
(CASE WHEN type = 'SPECIAL' THEN 1 ELSE NULL END)
);
5. 避坑指南
虽然这个功能很香,但有几个注意事项:
- 语法细节:
创建表达式索引时,表达式必须包在双层括号(( ... ))中。这是 MySQL 的语法强制要求。 - 查询命中问题:
这个索引的主要目的是**“约束(Constraint)”,而不是“加速查询(Search)”**。
如果你执行SELECT * FROM users WHERE username = 'alice',MySQL 可能会用到这个索引(如果优化器足够聪明),也可能不会。
- 如果你想利用这个索引加速查询,你的 WHERE 子句最好也写成和索引定义一样的形式:
WHERE (IF(deleted_at IS NULL, username, NULL)) = 'alice'(但这很丑,不推荐)。 - 建议: 把这个索引用作“唯一性守门员”,查询加速还是靠常规的
username索引。
- JSON 字段的唯一约束:
这也是 Functional Key Parts 的拿手好戏。你可以直接从 JSON 列中提取某个 key 建立唯一索引:CREATE UNIQUE INDEX idx_email ON users ((CAST(info->>'$.email' AS CHAR(50))));
6. 总结
MySQL 8.0 的 Functional Key Parts 是一个被严重低估的特性。
虽然它没有 PostgreSQL 的 WHERE 子句那么直观易读,但它通过巧妙利用 NULL 的非唯一性,成功解决了困扰 MySQL 开发者多年的“软删除与唯一索引冲突”的世纪难题。
更多推荐
所有评论(0)