# 逻辑删除和唯一索引
# 1、背景
数据唯一性校验是日常开发中非常常见的一个需求,比如:注册的用户名不能重复。
在中小型项目(未分库分表)中,唯一索引是解决这类问题非常有效的一个方案。
以 MySQL 为例,建表语句如下:
create table sys_user
(
id bigint not null auto_increment comment '主键',
username varchar(100) comment '用户名',
create_time datetime default current_timestamp comment '创建时间',
update_time datetime default current_timestamp on update current_timestamp comment '更新时间',
primary key (id),
unique key uk_username (username)
) comment '用户表';
第一次插入数据:
insert into sys_user(username) values('foo');
insert into sys_user(username) values('foo')
Affected rows: 1
时间: 0.002s
再次执行上面的语句,插入用户名为 foo 的数据将会被唯一索引限制,插入失败:
insert into sys_user(username) values('foo')
1062 - Duplicate entry 'foo' for key 'uk_username'
时间: 0.002s
使用唯一索引来校验数据有如下一些明显的优势:
降低了业务代码的复杂度
在并发场景下始终能保证数据校验的可靠性
# 2、问题演进,逻辑删除的引入
现在的很多系统中,一般不会轻易删除数据库的数据,而是通过引入删除标记字段的方式进行标记删除,即通常所说的逻辑删除。
给 sys_user 表添加删除标记字段 deleted:
alter table sys_user add column deleted bit default 0 comment '删除标识(0存在,1删除)' after username;
此时逻辑删除 foo 用户:
update sys_user set deleted = 1 where username = 'foo' and deleted = 0;
此时从业务角度考虑,foo 用户已被删除,应该可以新注册同名的 foo 用户了;
但从数据库索引角度思考,此时还是插入不了新的 foo 用户。
引出的问题就是逻辑删除和唯一索引冲突了。
# 3、联合索引方案
分析已删除的表记录和即将要插入的表记录区别:
已删除的 foo 用户 | 需要新插入的 foo 用户 |
---|---|
username: foo deleted: 1 | username: foo deleted: 0 |
从业务上,以上两种记录是合法的,不应该被唯一索引限制。
由此很容易想到使用联合索引来解决这个问题:
-- 删除原索引
drop index uk_username on sys_user;
-- 添加联合索引
alter table sys_user add unique index uk_username_deleted(username, deleted);
此时,新增 -> 删除 -> 再新增已经可以解决了。(小进步)🎉
-- 清理测试数据
truncate table sys_user;
-- 新增用户
insert into sys_user(username) values('foo');
-- 删除用户
update sys_user set deleted = 1 where username = 'foo' and deleted = 0;
-- 再新增用户
insert into sys_user(username) values('foo');
查询表数据:
+----+----------+---------+---------------------+---------------------+
| id | username | deleted | create_time | update_time |
+----+----------+---------+---------------------+---------------------+
| 1 | foo | 1 | 2021-11-29 23:16:28 | 2021-11-29 23:16:33 |
| 2 | foo | 0 | 2021-11-29 23:16:37 | 2021-11-29 23:16:37 |
+----+----------+---------+---------------------+---------------------+
但是,再删除又会冲突了。
第二次删除 foo 用户会出现两条 username = foo,deleted = 1 的记录,违背了联合索引的约束。
# 4、联合索引方案优化
从上面的方案可以看出,要保证逻辑删除和唯一索引不冲突,同一个用户,就需要保证每一次删除的删除标识不重复。
我的方案
将逻辑删除字段 deleted 改为和主键 id 字段类型一致;
deleted 为 0 表示该条记录存在,deleted 非 0(等于主键 id)表示该条记录已被删除。
-- 修改字段类型,bit -> bigint
alter table sys_user modify column deleted bigint default 0 comment '删除标识(0存在,非0删除)';
-- 清洗历史已删除的数据
update sys_user set deleted = id where deleted = 1;
最终,逻辑删除 foo 用户的 SQL 如下:
update sys_user set deleted = id where username = 'foo' and deleted = 0;
至此,多次执行增删操作,可验证完美解决了逻辑删除和唯一索引的冲突问题。🎉🎉🎉
+----+----------+---------+---------------------+---------------------+
| id | username | deleted | create_time | update_time |
+----+----------+---------+---------------------+---------------------+
| 1 | foo | 1 | 2021-11-29 23:16:28 | 2021-11-29 23:16:33 |
| 2 | foo | 2 | 2021-11-29 23:16:37 | 2021-11-29 23:25:28 |
| 3 | foo | 3 | 2021-11-29 23:27:02 | 2021-11-29 23:27:07 |
| 4 | foo | 4 | 2021-11-29 23:27:08 | 2021-11-29 23:27:10 |
| 5 | foo | 0 | 2021-11-29 23:27:12 | 2021-11-29 23:27:12 |
+----+----------+---------+---------------------+---------------------+
# 5、拓展
MySQL 唯一索引的字段长度是有限制的,如果对于大字段需要做唯一索引,可以考虑使用一列附加列,用于存储大字段的 MD5 值,然后对 MD5 值的列增加唯一索引。