最近发现MySQL数据库加入逻辑删除字段后会出现与原来的唯一索引产生冲突的问题。首先确立两个基本认识:唯一索引和逻辑删除都是必要的。
唯一索引
数据库中唯一索引字段具有唯一性,一些很显然必须唯一的字段在数据库中必须声明为unique,如个人的身份证ID。唯一索引是保障字段唯一的最后一道屏障,声明唯一索引是必要的。
逻辑删除字段
业务中存在需要重新找回数据的可能,因此,我们常常会在表中添加一个逻辑删除字段。如is_deleted=0表示未删除;is_deleted=1表示已删除。逻辑删除字段是必要的。
但在一个表中,唯一索引和逻辑删除字段同时存在会引发唯一索引冲突的问题。举个例子:
CREATE TABLE `person` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`idcard` varchar(20) NOT NULL COMMENT '身份证号,此字段具有唯一性',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_idcard` (`idcard`) USING BTREE COMMENT 'idcard字段建立唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
上述代码块创建的person表中我们声明了一个唯一索引idx_idcard
,此时还没有加入逻辑删除字段。
假设现在有这么个业务场景:鲁宾逊idcard号为'441900000000000000',他失踪多年,家人为其进行销户。销户操作员需要把其idcard号对应的记录删除。但经过多年海岛荒野求生后鲁宾逊又回来了,操作员需要为他恢复idcard号。很显然,在这张表中先进行(物理)删除后再插入同一个idcard记录并不会导致唯一索引idx_idcard
失效。
现在加入逻辑删除字段:
ALTER TABLE `person`
ADD COLUMN `is_deleted` TINYINT DEFAULT 0 AFTER `idcard`;
但此时把鲁宾逊(逻辑)删除后再插入会出现唯一索引冲突的问题。
有的读者会有疑问了:把图中的插入语句
INSERT INTO person (idcard, is_deleted) values ('441900000000000000', 0);
语句修改成UPDATE person SET is_deleted = 0 WHERE idcard = '441900000000000000';
,问题不就解决了吗?可以预见的是,这么做会在编写业务逻辑代码时需要加一层判断:当表中不含idcard = '441900000000000000'
时,采用INSERT语句;否则采用UPDATE语句。这样势必会造成编码上的困难,很容易造成业务出错。而且此表是简单表,若采用真实系统中更复杂的包含多个字段的表,会造成编码更大的麻烦。此问题不在本文讨论范围。简单起见我们插入只考虑INSERT,不考虑UPDATE。
什么??鲁宾逊被销户后不能再为其恢复户口了吗??那么把idcard与is_deleted做成唯一联合索引行不行呢?我们来尝试一下:先把唯一索引idx_idcard
删除,再把唯一联合索引(idcard, is_deleted)加进去。
ALTER TABLE `person`
DROP INDEX `idx_idcard` ,
ADD UNIQUE INDEX `idx_idcard_isdeleted` USING BTREE (`idcard`, `is_deleted`) COMMENT ''idcard和is_deleted建立唯一索引'' VISIBLE;
;
目前来看是可以的。鲁滨逊的身份证被逻辑删除后可以成功再次插入进去。但是这会带来新的问题:鲁宾逊再次出去漂流,又失踪了,伤心欲绝的家人需要再次为他办理销户,而销户操作员在对其身份证进行逻辑删除操作时遇到了问题:
因为唯一联合索引idx_idcard_isdeleted
的限制,无法让id为5的逻辑删除字段设置为1。也就是说,鲁宾逊在被逻辑删除过一次身份证后,无法再次进行逻辑删除。
解决方案:
使用时间戳作为删除标记
删除状态不以0 、1表示。而是以时间戳表示(将is_deleted更名为deleted_at,deleted_at的值为删除数据时的时间戳)。与唯一索引字段重新组合成唯一联合索引字段(idcard, deleted_at)。当deleted_at字段为NULL时,表示未删除。
ALTER TABLE `person`
DROP COLUMN `is_deleted`,
ADD COLUMN `deleted_at` TIMESTAMP NULL AFTER `idcard`,
ADD UNIQUE INDEX `idx_idcard_deletedat` (`idcard` ASC, `deleted_at` ASC) VISIBLE,
DROP INDEX `idx_idcard_isdeleted` ;
;
这下操作员成功地帮鲁宾逊销了两次户了,终于可以长吁一口气~
需要注意的是:如果idcard
字段在建表的时候没有标记为NOT NULL
、仅靠idx_idcard_isdeleted
唯一联合索引保证唯一性的话。会出现如图所示的问题:
可以看到此时idx_idcard_deletedat
唯一联合索引并没有起作用。在 MySQL 中,唯一索引的唯一性约束仅适用于非NULL
值。对于 NULL
值,MySQL 的唯一索引会将多个 NULL
值视为不相等的,不违反唯一性约束,并允许它们出现在多行数据中。
以下是关于MySQL查询数据时关于优化的题外小知识,跟本文主题无关,可不看:
但值得注意的是,在查询数据的时候,MySQL优化器会将所有NULL
值视为相等的。比如下图中id为9、10、12、13这四条记录,优化器会认为idcard
列不重复的值
的数量是:3(分别是NULL、441900000000000000、441911111111111111这三个值)。如果idcard
索引列中NULL
值特别多的话,优化器会认为idcard
列中平均一个值重复次数特别多(也就是NULL值重复次数特别多),所以会倾向于不使用索引进行访问。
想要修改MySQL优化器对NULL值的判定方式,可以修改名为innodb_stats_method
系统变量。该系统变量有3个候选值,分别为:nulls_equal
(默认值)、nulls_unequal
、nulls_ignored
。
其中,nulls_equal
:认为所有NULL
值都是相等的。nulls_unequal
:认为所有NULL
值都是不相等的。nulls_ignored
:直接把NULL
值忽略掉。
想了解MySQL优化器如何工作可另外参考书籍。本文不再赘述。
这种方案能解决无法多次逻辑删除的问题。
缺点:
- 需要改动业务逻辑代码。
建立删除表
建一张结构一模一样的删除表。其中主表中进行物理删除的同时要将删除的记录保存到删除表中。
缺点:
- 需要把对主表的数据物理删除和对删除表的数据插入控制在一个事务中。
- 大材小用。
保留is_deleted
字段,同时新建一个字段del_id
保留状态位为0、1的逻辑删除字段is_deleted
,同时新增一个字段del_id
,让del_id
默认值为0,其字段类型与主键id
保持一致。并且把原先的唯一索引字段 (idcard) 组合成唯一联合索引(idcard, del_id)。当需要进行逻辑删除时,把is_deleted
置为1
,同时把del_id
置为该删除行的主键id
。
缺点:
- 需要改动表结构。
总结一下以上三种方案:
方案1:使用时间戳作为删除标记;
方案2:建立删除表;
方案3:新增一个字段del_id;
结合来看。方案1和方案3的本质是一样的:都是建立保证唯一的联合索引。但对于已经在线上运行的业务。建议使用方案3,新增字段对正常的业务影响较小。方案2感觉有点大材小用,但它规避了逻辑删除字段带来的问题。