在这里插入图片描述
你正在设计一个用户系统,要求 “用户名必须唯一”
同时,为了数据安全,你使用了 “软删除” (Soft Delete),即删除用户时不物理删除,而是设置 deleted_at = NOW()
冲突爆发:

  1. 用户 A 注册了 alice,后来注销了(deleted_at 有值)。
  2. 第二天,另一个新用户(或者用户 A 后悔了)想再次注册 alice
  3. 数据库报错: 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,而 NULLNULL 是和平共处的!


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)) 
);

效果验证:
  1. 注册 Alice (成功)
    INSERT INTO users (username, deleted_at) VALUES ('alice', NULL);
  • 索引存的值:'alice'
  1. 软删除 Alice (成功)
    UPDATE users SET deleted_at = NOW() WHERE username = 'alice';
  • 索引存的值:NULL
  1. 再次注册 Alice (成功!)
    INSERT INTO users (username, deleted_at) VALUES ('alice', NULL);
  • 索引存的值:'alice'
  • 注意: 此时表里有两条 alice,一条是删掉的(索引为 NULL),一条是活着的(索引为 ‘alice’)。互不冲突。
  1. 再次注册 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 下,只能有一行数据的 statusACTIVE
索引设计:

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. 避坑指南

虽然这个功能很香,但有几个注意事项:

  1. 语法细节:
    创建表达式索引时,表达式必须包在双层括号 (( ... )) 中。这是 MySQL 的语法强制要求。
  2. 查询命中问题:
    这个索引的主要目的是**“约束(Constraint)”,而不是“加速查询(Search)”**。
    如果你执行 SELECT * FROM users WHERE username = 'alice',MySQL 可能会用到这个索引(如果优化器足够聪明),也可能不会。
  • 如果你想利用这个索引加速查询,你的 WHERE 子句最好也写成和索引定义一样的形式:
    WHERE (IF(deleted_at IS NULL, username, NULL)) = 'alice' (但这很丑,不推荐)。
  • 建议: 把这个索引用作“唯一性守门员”,查询加速还是靠常规的 username 索引。
  1. 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 开发者多年的“软删除与唯一索引冲突”的世纪难题。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐