try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
doSamething();
return true;
return false;
} finally {
unlock(lockKey,requestId);
不过需要在finally
代码块中释放锁
。
其中lockKey是由商品表中的name和model组合而成的,requestId是每次请求的唯一标识,以便于它每次都能正确得释放锁。还需要设置一个过期时间expireTime,防止释放锁失败,锁一直存在,导致后面的请求没法获取锁。
如果只是单个商品,或者少量的商品需要复制添加,则加分布式锁没啥问题。
主要流程如下:
可以在复制添加商品之前,先尝试加锁。如果加锁成功,则在查询商品是否存在,如果不存在,则添加商品。此外,在该流程中如果加锁失败,或者查询商品时不存在,则直接返回。
加分布式锁的目的是:保证查询商品和添加商品的两个操作是原子性的操作。
但现在的问题是,我们这次需要复制添加的商品数量很多,如果每添加一个商品都要加分布式锁的话,会非常影响性能。
显然对于批量接口,加redis分布式锁,不是一个理想的方案。
6. 统一mq异步处理
前面我们已经聊过,在批量复制商品的接口,我们是通过RocketMQ的顺序消息,单线程异步复制添加商品的,可以暂时解决商品重复的问题。
但那只改了一个添加商品的入口,还有其他添加商品的入口。
能不能把添加商品的底层逻辑统一一下,最终都调用同一段代码。然后通过RocketMQ的顺序消息,单线程异步添加商品。
主要流程如下图所示:
这样确实能够解决重复商品的问题。
但同时也带来了另外两个问题:
现在所有的添加商品功能都改成异步了,之前同步添加商品的接口如何返回数据呢?这就需要修改前端交互,否则会影响用户体验。
之前不同的添加商品入口,是多线程添加商品的,现在改成只能由一个线程添加商品,这样修改的结果导致添加商品的整体效率降低了。
由此,综合考虑了一下各方面因素,这个方案最终被否定了。
7. insert on duplicate key update
其实,在mysql中存在这样的语法,即:insert on duplicate key update
。
在添加数据时,mysql发现数据不存在,则直接insert
。如果发现数据已经存在了,则做update
操作。
不过要求表中存在唯一索引
或PRIMARY KEY
,这样当这两个值相同时,才会触发更新操作,否则是插入。
现在的问题是PRIMARY KEY是商品表的主键,是根据雪花算法
提前生成的,不可能产生重复的数据。
但由于商品表有逻辑删除功能,导致唯一索引在商品表中创建不了。
由此,insert on duplicate key update这套方案,暂时也没法用。
此外,insert on duplicate key update在高并发的情况下,可能会产生死锁
问题,需要特别注意一下。
感兴趣的小伙伴,也可以找我私聊。
其实insert on duplicate key update的实战,我在另一篇文章《我用kafka两年踩过的一些非比寻常的坑》中介绍过的,感兴趣的小伙伴,可以看看。
8. insert ignore
在mysql中还存在这样的语法,即:insert ... ignore
。
在insert语句执行的过程中:mysql发现如果数据重复了,就忽略,否则就会插入。
它主要是用来忽略,插入重复数据产生的Duplicate entry 'XXX' for key 'XXXX'
异常的。
不过也要求表中存在唯一索引
或PRIMARY KEY
。
但由于商品表有逻辑删除功能,导致唯一索引在商品表中创建不了。
由此可见,这个方案也不行。
温馨的提醒一下,使用insert ... ignore也有可能会导致死锁
。
9. 防重表
之前聊过,因为有逻辑删除功能,给商品表加唯一索引,行不通。
后面又说了加分布式锁,或者通过mq单线程异步添加商品,影响创建商品的性能。
那么,如何解决问题呢?
我们能否换一种思路,加一张防重表
,在防重表中增加商品表的name和model字段作为唯一索引。
CREATE TABLE `product_unique` (
`id` bigint(20) NOT NULL COMMENT 'id',
`name` varchar(130) DEFAULT NULL COMMENT '名称',
`model` varchar(255) NOT NULL COMMENT '规格',
`user_id` bigint(20) unsigned NOT NULL COMMENT '创建用户id',
`user_name` varchar(30) NOT NULL COMMENT '创建用户名称',
`create_date` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_name_model` (`name`,`model`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品防重表';
其中表中的id可以用商品表的id,表中的name和model就是商品表的name和model,不过在这张防重表中增加了这两个字段的唯一索引。
视野一下子被打开了。
在添加商品数据之前,先添加防重表。如果添加成功,则说明可以正常添加商品,如果添加失败,则说明有重复数据。
防重表添加失败,后续的业务处理,要根据实际业务需求而定。
如果业务上允许添加一批商品时,发现有重复的,直接抛异常,则可以提示用户:系统检测到重复的商品,请刷新页面重试。
try {
transactionTemplate.execute((status) -> {
productUniqueMapper.batchInsert(productUniqueList);
productMapper.batchInsert(productList);
return Boolean.TRUE;
} catch(DuplicateKeyException e) {
throw new BusinessException("系统检测到重复的商品,请刷新页面重试");
在批量插入数据时,如果出现了重复数据,捕获DuplicateKeyException
异常,转换成BusinessException
这样运行时的业务异常。
还有一种业务场景,要求即使出现了重复的商品,也不抛异常,让业务流程也能够正常走下去。
try {
transactionTemplate.execute((status) -> {
productUniqueMapper.insert(productUnique);
productMapper.insert(product);
return Boolean.TRUE;
} catch(DuplicateKeyException e) {
product = productMapper.query(product);
在插入数据时,如果出现了重复数据,则捕获DuplicateKeyException
,在catch
代码块中再查询一次商品数据,将数据库已有的商品直接返回。
如果调用了同步添加商品的接口,这里非常关键的一点,是要返回已有数据的id,业务系统做后续操作,要拿这个id操作。
当然在执行execute之前,还是需要先查一下商品数据是否存在,如果已经存在,则直接返回已有数据,如果不存在,才执行execute方法。这一步千万不能少。
Product oldProduct = productMapper.query(product);
if(Objects.nonNull(oldProduct)) {
return oldProduct;
try {
transactionTemplate.execute((status) -> {
productUniqueMapper.insert(productUnique);
productMapper.insert(product);
return Boolean.TRUE;
} catch(DuplicateKeyException e) {
product = productMapper.query(product);
return product;
千万注意:防重表和添加商品的操作必须要在同一个事务中,否则会出问题。
顺便说一下,还需要对商品的删除功能做特殊处理一下,在逻辑删除商品表的同时,要物理删除防重表。用商品表id作为查询条件即可。
说实话,解决重复数据问题的方案挺多的,没有最好的方案,只有最适合业务场景的,最优的方案。
此外,如果你对重复数据衍生出的幂等性问题感兴趣的话,可以看看我的另一篇文章《高并发下如何保证接口的幂等性?》,里面有非常详细的介绍。
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。