MySQL唯一索引为何会出现重复数据?逻辑删除下的解决方案及代码示例

471 阅读6分钟

在MySQL中,唯一索引是用来保证某些字段组合的值在表中不重复的关键机制。但很多开发者会遇到明明加了唯一索引,却仍然出现重复数据的情况。本文用最简单的语言,结合实际案例,详细讲解这个问题的根源及如何在逻辑删除场景下正确使用唯一索引。

1. 唯一索引基础知识

  • 唯一索引保证索引字段的组合值在表中唯一。
  • 例如,给表中nameagecity三个字段创建联合唯一索引,理论上不允许三者完全相同的两条记录存在。
CREATE TABLE test (
  id INT NOT NULL PRIMARY KEY,
  name VARCHAR(255),
  age INT,
  city VARCHAR(255),
  UNIQUE KEY unique_name_age_city (name, age, city)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2. 为什么唯一索引仍然允许重复数据?

关键原因:字段中存在NULL值

MySQL中,NULL表示“未知值”,而且NULL与任何值(包括另一个NULL)比较结果都是“不等”的。

  • 所以,唯一索引中只要有字段是NULL,MySQL允许多条记录在该字段为NULL的情况下重复存在。
  • 这意味着,联合唯一索引中只要有一列为NULL,索引约束就会失效。

示例:

INSERT INTO test (id, name, age, city) VALUES (1, '张三', NULL, '北京');
INSERT INTO test (id, name, age, city) VALUES (2, '张三', NULL, '北京'); -- 不报错,插入成功

尽管namecity相同,但ageNULL,MySQL允许重复。

3. 解决NULL导致唯一索引失效的方法

  • 避免字段为NULL:给字段设置NOT NULL约束,或者给字段设置默认值(如数字用0,字符串用空字符串)代替NULL
CREATE TABLE test (
  id INT NOT NULL PRIMARY KEY,
  name VARCHAR(255) NOT NULL DEFAULT '',
  age INT NOT NULL DEFAULT 0,
  city VARCHAR(255) NOT NULL DEFAULT '',
  UNIQUE KEY unique_name_age_city (name, age, city)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这样,插入数据时不会出现NULL,唯一索引生效。

4. 逻辑删除与唯一索引冲突问题

什么是逻辑删除?

  • 逻辑删除是指数据不从数据库物理删除,而是通过一个字段(如deletedis_deleted)标记为已删除。
  • 例如,deleted=0表示未删除,deleted=1表示已删除。

逻辑删除导致唯一索引冲突的场景

假设name字段唯一,且有逻辑删除字段deleted

idnamedeleted
1张三0
2张三1
  • 当用户删除张三(deleted=1),再插入一条name='张三'的数据时,如果唯一索引只在name字段上,会报唯一索引冲突错误。
  • 如果把deleted字段也加入唯一索引(name, deleted),当多次删除同一条数据时,deleted=1的记录会重复,仍然报错。

5. 逻辑删除场景下保证唯一索引生效的三种常用方案

方案编号方案名称说明优缺点示例代码片段
方案1删除状态递增删除时将deleted字段值设置为当前最大删除状态+1唯一索引生效,需修改查询条件(deleted>0表示已删除)见下文
方案2增加时间戳字段在唯一索引中加入时间戳字段,删除时更新时间戳不需改查询逻辑,但极端并发时可能重复见下文
方案3增加删除ID字段新增delete_id字段,删除时设置为当前记录主键ID,未删除为0推荐方案,兼顾唯一性和查询简便,无需改查询逻辑见下文

方案1:删除状态递增示例

CREATE TABLE test (
  id INT PRIMARY KEY,
  name VARCHAR(255),
  age INT,
  city VARCHAR(255),
  deleted INT DEFAULT 0,
  UNIQUE KEY unique_name_age_city_deleted (name, age, city, deleted)
);

-- 插入数据
INSERT INTO test VALUES (1, '张三', 18, '北京', 0);

-- 删除数据,将deleted设置为1
UPDATE test SET deleted = 1 WHERE id = 1;

-- 再次插入相同数据
INSERT INTO test VALUES (2, '张三', 18, '北京', 0);

-- 再删除,将deleted设置为2
UPDATE test SET deleted = 2 WHERE id = 2;
  • 每次删除时,deleted字段递增,保证唯一索引字段组合唯一。
  • 查询未删除数据时使用WHERE deleted=0

方案2:增加时间戳字段示例

CREATE TABLE test (
  id INT PRIMARY KEY,
  name VARCHAR(255),
  age INT,
  city VARCHAR(255),
  deleted INT DEFAULT 0,
  delete_time TIMESTAMP DEFAULT '1970-01-01 00:00:00',
  UNIQUE KEY unique_name_age_city_deleted_time (name, age, city, deleted, delete_time)
);

-- 删除时更新删除时间
UPDATE test SET deleted = 1, delete_time = NOW() WHERE id = 1;

-- 插入新数据deleted=0,delete_time默认1970-01-01
INSERT INTO test VALUES (2, '张三', 18, '北京', 0, '1970-01-01 00:00:00');
  • 时间戳保证每次删除记录唯一。
  • 查询未删除数据时WHERE deleted=0

方案3:增加删除ID字段(推荐)

CREATE TABLE test (
  id INT PRIMARY KEY,
  name VARCHAR(255),
  age INT,
  city VARCHAR(255),
  deleted INT DEFAULT 0,
  delete_id INT DEFAULT 0,
  UNIQUE KEY unique_name_age_city_deletedid (name, age, city, deleted, delete_id)
);

-- 删除时,deleted=1,delete_id设置为当前主键ID
UPDATE test SET deleted = 1, delete_id = id WHERE id = 1;

-- 新插入数据,deleted=0,delete_id=0
INSERT INTO test VALUES (2, '张三', 18, '北京', 0, 0);
  • delete_id为每条删除记录赋予唯一标识,避免唯一索引冲突。
  • 查询未删除数据时WHERE deleted=0

6. 处理历史数据中重复记录

如果表中已有重复数据,新增唯一索引会失败。解决思路:

  • 找出重复记录中“保留”的一条,将其delete_id设置为0或1。
  • 其余重复记录设置delete_id为对应主键ID。
  • 这样可以区分重复数据,成功创建唯一索引。

7. 大字段无法直接创建唯一索引的解决方案

  • MySQL InnoDB最大索引长度限制约为1000字节。
  • 对于大字段(如长文本),不能直接创建唯一索引。
  • 解决方案:对大字段做哈希(MD5、SHA等),存储哈希值字段,再对哈希字段创建唯一索引。
ALTER TABLE test ADD COLUMN content_hash CHAR(32);

-- 插入时计算hash值
UPDATE test SET content_hash = MD5(large_text_field);

-- 创建唯一索引
CREATE UNIQUE INDEX unique_name_age_city_hash ON test(name, age, city, content_hash);
  • 需注意哈希冲突的极小概率。

8. 总结

  • MySQL唯一索引允许字段为NULL,且多个NULL值不会被视为冲突,导致唯一索引失效。
  • 解决方法是避免NULL,用默认值代替。
  • 逻辑删除导致唯一索引冲突,推荐使用“删除ID字段”方案,既保证唯一性,又方便查询。
  • 历史数据处理和大字段索引也有对应解决方案。

参考代码示例汇总

-- 创建表,避免NULL,增加逻辑删除字段和删除ID字段
CREATE TABLE user (
  id INT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(100) NOT NULL DEFAULT '',
  age INT NOT NULL DEFAULT 0,
  city VARCHAR(100) NOT NULL DEFAULT '',
  deleted TINYINT NOT NULL DEFAULT 0,
  delete_id INT NOT NULL DEFAULT 0,
  UNIQUE KEY unique_user (username, age, city, deleted, delete_id)
);

-- 插入数据
INSERT INTO user (username, age, city, deleted, delete_id) VALUES ('张三', 18, '北京', 0, 0);

-- 逻辑删除操作
UPDATE user SET deleted = 1, delete_id = id WHERE id = 1;

-- 再次插入相同数据,不冲突
INSERT INTO user (username, age, city, deleted, delete_id) VALUES ('张三', 18, '北京', 0, 0);

-- 查询未删除数据
SELECT * FROM user WHERE deleted = 0;

通过以上基础知识和实用方案,大家可以有效避免MySQL唯一索引失效和逻辑删除带来的重复数据问题,保证数据的完整性和业务的正常运行。