前言
前段时间踩到了一个坑:给一个引擎表添加了唯一索引,最后发现数据还是重复的。
到底发生了什么?
本文通过一个坑的经历来谈谈唯一索引和一些有趣的知识点。
1. 修复问题站点
前段时间,为了防止产品组中产生重复数据,我特地添加了一个防重复表。
问题出在产品组的防修复表上。
具体表结构如下:
CREATE TABLE `product_group_unique` (
`id` bigint NOT NULL,
`category_id` bigint NOT NULL,
`unit_id` bigint NOT NULL,
`model_hash` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`in_date` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
为了保证数据的唯一性,我为产品组防重复表建立了唯一索引:
alter table product_group_unique add unique index
ux_category_unit_model(category_id,unit_id,model_hash);
根据类别号、单元号、产品组属性的哈希值可以唯一确定一个产品组。
为产品组防重复表创建唯一索引后,第二天查看数据,发现表中有重复数据:
表中的第二个和第三个数据是重复的。
为什么是这样?
2、唯一索引字段包含null
如果仔细查看表中的数据,你会发现一个特殊的地方:产品组属性的哈希值(字段)可能为空,即产品组不允许配置任何属性。
将字段等于 100 的重复数据插入到表中:
结果:
从上图可以看出,MySQL的唯一性约束已经生效,重复数据已经被拦截。
接下来,我们再插入两条空数据。 第三条数据与第二条数据具有相同的字段值。
从图中可以看出,执行成功了。
也就是说,如果唯一索引字段中出现空值,则唯一约束不会生效。
最终插入的数据如下:
当该字段不为空时,不会产生重复数据。
当字段为空时,会生成重复数据。
需要特别注意的是:用于创建唯一索引的字段不能为空,否则MySQL的唯一性约束可能失效。
3、逻辑删除表加唯一索引
我们都知道,唯一索引非常简单且易于使用,但有时,将它们添加到表中并不容易。
不信的话,我们一起往下看吧。
通常,如果要删除表中的一条记录,可以使用语句操作。
例如:
delete from product where id=123;
该操作属于物理删除,即记录被删除后,通过后续的SQL语句无法查到。 (不过可以通过其他技术手段找回,那是后话了)
还有一类逻辑删除,主要是通过语句来操作。
例如:
update product set delete_status=1,edit_time=now(3)
where id=123;
逻辑删除需要在表中额外增加删除状态字段来记录数据是否已被删除。 在所有的业务查询中,都需要过滤掉已删除的数据。
这样删除数据后,数据还在表中,但是删除的数据被逻辑过滤了。
事实上,对于这样一个逻辑删除的表,是不可能添加唯一索引的。
为什么?
假设产品表中的名称和型号已添加唯一索引。 如果用户删除一条记录,则该记录将被设置为1。后来,用户发现不对劲,又添加了完全相同的产品。
由于唯一索引的存在,用户第二次添加商品将会失败。 即使该产品已被删除,也无法再次添加。
这个问题显然很严重。
有人可能会说:把name、model、三个字段同时做成唯一索引还不够吗?
答:这确实可以解决用户逻辑删除商品后重新添加同一个商品后无法添加的问题。 但如果第二次添加该产品,则会再次删除。 用户第三次添加相同的产品。 问题不也出现了吗?
可见,如果表有逻辑删除功能,则不方便创建唯一索引。
但是,如果您确实想向包含逻辑删除的表添加唯一索引该怎么办?
3.1 删除状态+1
前面我们知道,如果表有逻辑删除功能,那么创建唯一索引就很不方便。
根本原因是,记录被删除后,会被设置为1,默认为0。当第二次删除同一条记录时,会被设置为1,但是因为创建了唯一索引( name、model、三个字段同时做成唯一索引),数据库中已经存在1条记录,所以这次会操作失败。
我们为什么不换个思路:不要担心 1,它意味着删除。 如果是1、2、3等,只要大于1,就表示删除。
这样的话,每次删除时,都会获取同一条记录的最大删除状态,然后加1。
这样,数据操作流程就变成了:
添加记录a,=0。
删除记录a,=1。
添加记录a,=0。
删除记录a,=2。
添加记录a,=0。
删除记录a,=3。
由于记录a每次被删除时都是不同的,因此可以保证唯一性。
这种方案的优点是:不需要调整字段,非常简单直接。
缺点是可能需要修改SQL逻辑,尤其是一些查询SQL语句。 有的用=1来判断删除状态,需要改为>=1。
3.2 添加时间戳字段
逻辑删除表很难添加唯一索引的最根本原因是逻辑删除。
为什么我们不添加一个字段来专门处理逻辑删除功能呢?
答:可以添加时间戳字段。
将name、model、四个字段同时做成唯一索引
添加数据时,该字段写入默认值1。
那么一旦有逻辑删除操作,时间戳就会自动写入该字段。
这样,即使同一条记录被逻辑删除多次且每次生成的时间戳不同,也能保证数据的唯一性。
时间戳通常精确到秒。
除非在极端并发场景中,否则对同一记录的两个不同逻辑删除操作会产生相同的时间戳。
此时时间戳可以精确到毫秒。
该方案的优点是无需改变现有代码逻辑,通过添加新字段即可实现数据唯一性。
缺点是:在极端情况下,仍然可能产生重复数据。
3.3 添加id字段
其实添加时间戳字段就可以基本解决问题。 但在极端情况下,仍然可能会产生重复数据。
有什么办法可以解决这个问题吗?
答:添加主键字段:。
这个方案的思路和添加时间戳字段是一样的,即添加数据时设置默认值1,然后逻辑删除时赋值给当前记录的主键id。
将name、model、四个字段同时做成唯一索引。
这可能是最好的解决方案。 无需修改现有删除逻辑即可保证数据的唯一性。
4、如何为重复的历史数据添加唯一索引?
前面提到,如果表有逻辑删除功能,那么添加唯一索引就不太容易了。 不过通过本文介绍的三种方案,可以顺利添加唯一索引。
但一个来自灵魂的问题:如果某个表存在历史重复数据,如何添加索引?
最简单的方法就是添加一个防重复表,然后将数据初始化进去。
你可以这样写一条sql:
insert into product_unqiue(id,name,category_id,unit_id,model)
select max(id), select name,category_id,unit_id,model from product
group by name,category_id,unit_id,model;
这样做是可以的,但是今天的主题是直接在原表上添加唯一索引,并且不防止表重复。
那么,如何添加这个唯一索引呢?
其实可以借鉴上一节添加id字段的思路。
添加一个字段。
但是,在为表创建唯一索引之前,必须先进行数据处理。
获取同一条记录的最大id:
select max(id), select name,category_id,unit_id,model from product
group by name,category_id,unit_id,model;
然后将该字段设置为 1。
然后将其他相同记录的字段设置为当前主键。
这样就可以区分历史重复数据。
所有字段都设置好值后,就可以给name、model、and这四个字段添加唯一索引了。
完美的。
5、大字段添加唯一索引
接下来我们来说一个有趣的话题:如何给大字段添加唯一索引。
有时,我们需要同时为多个字段添加唯一索引,例如名称、型号等。
但如果模型字段很大,这会导致唯一索引,可能会占用更多的存储空间。
我们都知道唯一索引,也可以使用索引。
如果索引的每个节点都存储大量数据,则检索效率会很低。
因此,有必要限制唯一索引的长度。
目前MySQL存储引擎中索引允许的最大长度为3072字节,其中键的最大长度为1000字节。
如果字段太大,超过1000字节,显然不可能添加唯一索引。
此时,有什么解决办法吗?
5.1 添加哈希字段
我们可以添加一个哈希字段,取大字段的哈希值,生成一个更短的新值。 这个值可以通过一些哈希算法生成,固定长度为16位或者32位等。
我们只需要为名称、哈希和字段添加唯一索引。
这样就避免了唯一索引过长的问题。
但同时也会带来一个新的问题:
一般哈希算法会产生哈希冲突,即两个不同的值通过哈希算法生成相同的值。
当然,如果还有其他可以区分的字段,比如姓名,并且业务允许这样的重复数据而不写入数据库,这种方案也是可行的。
5.2 无唯一索引
如果确实很难添加唯一索引,就不要添加唯一索引,通过其他技术手段保证唯一性。
如果新数据条目较少,例如只有作业或数据导入,可以在单个线程中顺序执行,以确保表中的数据不重复。
如果新数据有很多条目,mq 消息最终将在 mq 消费者中的单个线程中发送和处理。
5.3 redis分布式锁
由于字段太大,MySQL中很难添加唯一索引。 为什么不使用redis分布式锁呢?
但如果直接加到name、model、上,加redis分布式锁,显然没有意义,效率也不会高。
我们可以结合5.1章节,使用name、model、生成一个hash值,然后锁定这个新值。
即使遇到哈希冲突也没关系。 在并发的情况下,毕竟是小概率事件。
6.批量插入数据
有的小伙伴可能会认为既然有redis分布式锁,那就不需要唯一索引了。
那是你没有遇到过的场景,批量插入数据。
如果查询后发现有一个:list数据,需要批量插入数据库。
如果使用redis分布式锁,需要这样做:
for(Product product: list) {
try {
String hash = hash(product);
rLock.lock(hash);
//查询数据
//插入数据
} catch (InterruptedException e) {
log.error(e);
} finally {
rLock.unlock();
}
}
每条数据都需要在循环中锁定。
这样的表现肯定不会好。
当然也有朋友持反对意见,说批量操作用redis就够了吗?
就是说一次性锁定500条或者1000条数据,然后用完之后一次性释放这些锁?
想想就有点不靠谱,这锁有多大。
极容易造成锁超时。 比如业务代码还没执行完,锁过期时间就已经过期了。
对于这种批量操作,如果这时候使用mysql的唯一索引,就可以直接批量操作,一条SQL语句就可以搞定。
数据库会自动判断是否有重复数据,并报错。 仅当没有重复数据时才允许插入数据。