医院网站需要前置审批,京紫元年网站建设,制作公司网站需要购买域名和服务器吗,wordpress 登陆插件前言最近测试给我提了一个bug#xff0c;说我之前提供的一个批量复制商品的接口#xff0c;产生了重复的商品数据。追查原因之后发现#xff0c;这个事情没想象中简单#xff0c;可以说一波多折。1. 需求产品有个需求#xff1a;用户选择一些品牌#xff0c;点击确定按钮… 前言最近测试给我提了一个bug说我之前提供的一个批量复制商品的接口产生了重复的商品数据。追查原因之后发现这个事情没想象中简单可以说一波多折。1. 需求产品有个需求用户选择一些品牌点击确定按钮之后系统需要基于一份默认品牌的商品数据复制出一批新的商品。拿到这个需求时觉得太简单了三下五除二就搞定。我提供了一个复制商品的基础接口给商城系统调用。当时的流程图如下如果每次复制的商品数量不多使用同步接口调用的方案问题也不大。2. 性能优化但由于每次需要复制的商品数量比较多可能有几千。如果每次都是用同步接口的方式复制商品可能会有性能问题。因此后来我把复制商品的逻辑改成使用mq异步处理。改造之后的流程图复制商品的结果还需要通知商城系统这个方案看起来挺不错的。但后来出现问题了。3. 出问题了测试给我们提了一个bug说我之前提供的一个批量复制商品的接口产生了重复的商品数据。经过追查之后发现商城系统为了性能考虑也改成异步了。他们没有在接口中直接调用基础系统的复制商品接口而是在job中调用的。站在他们的视角流程图是这样的用户调用商城的接口他们会往请求记录表中写入一条数据然后在另外一个job中异步调用基础系统的接口去复制商品。但实际情况是这样的商城系统内部出现了bug在请求记录表中同一条请求产生了重复的数据。这样导致的结果是在job中调用基础系统复制商品接口时发送了重复的请求。刚好基础系统现在是使用RocketMQ异步处理的。由于商城的job一次会取一批数据比如:20条记录在极短的时间内其实就是在一个for循环中多次调用接口可能存在相同的请求参数连续调用复制商品接口情况。于是出现了并发插入重复数据的问题。为什么会出现这个问题呢4. 多线程消费RocketMQ的消费者为了性能考虑默认是用多线程并发消费的最大支持64个线程。例如RocketMQMessageListener(topic ${com.susan.topic:PRODUCT_TOPIC},consumerGroup ${com.susan.group:PRODUCT_TOPIC_GROUP})
Service
public class MessageReceiver implements RocketMQListenerMessageExt {Overridepublic void onMessage(MessageExt message) {String message new String(message.getBody(), StandardCharsets.UTF_8);doSamething(message);}
}也就是说如果在极短的时间内连续发送重复的消息就会被不同的线程消费。即使在代码中有这样的判断Product oldProduct query(hashCode);
if(oldProduct null) {productMapper.insert(product);
}在插入数据之前先判断该数据是否已经存在只有不存在才会插入。但由于在并发情况下不同的线程都判断商品数据不存在于是同时进行了插入操作所以就产生了重复数据。如下图所示5. 顺序消费为了解决上述并发消费重复消息的问题我们从两方面着手商城系统修复产生重复记录的bug。基础系统将消息改成单线程顺序消费。我仔细思考了一下如果只靠商城系统修复bug以后很难避免不出现类似的重复商品问题比如如果用户在极短的时间内点击创建商品按钮多次或者商城系统主动发起重试。所以基础系统还需进一步处理。其实RocketMQ本身是支持顺序消费的需要消息的生产者和消费者一起改。生产者改为rocketMQTemplate.asyncSendOrderly(topic, message, hashKey, new SendCallback() {Overridepublic void onSuccess(SendResult sendResult) {log.info(sendMessage success);}Overridepublic void onException(Throwable e) {log.error(sendMessage failed!);}
});重点是要调用rocketMQTemplate对象的asyncSendOrderly方法发送顺序消息。消费者改为RocketMQMessageListener(topic ${com.susan.topic:PRODUCT_TOPIC},consumeMode ConsumeMode.ORDERLY,consumerGroup ${com.susan.group:PRODUCT_TOPIC_GROUP})
Service
public class MessageReceiver implements RocketMQListenerMessageExt {Overridepublic void onMessage(MessageExt message) {String message new String(message.getBody(), StandardCharsets.UTF_8);doSamething(message);}
}接收消息的重点是RocketMQMessageListener注解中的consumeMode参数要设置成ConsumeMode.ORDERLY这样就能顺序消费消息了。修改后关键流程图如下两边都修改之后复制商品这一块就没有再出现重复商品的问题了。But修完bug之后我又思考了良久。复制商品只是创建商品的其中一个入口如果有其他入口跟复制商品功能同时创建新商品呢不也会出现重复商品问题虽说这种概率非常非常小。但如果一旦出现重复商品问题后续涉及到要合并商品的数据非常麻烦。经过这一次的教训一定要防微杜渐。不管是用户还是自己的内部系统从不同的入口创建商品都需要解决重复商品创建问题。那么如何解决这个问题呢6. 唯一索引解决重复商品数据问题最快成本最低最有效的办法是给表建唯一索引。想法是好的但我们这边有个规范就是业务表必须都是逻辑删除。而我们都知道要删除表的某条记录的话如果用delete语句操作的话。例如delete from product where id123;这种delete操作是物理删除即该记录被删除之后后续通过sql语句基本查不出来。不过通过其他技术手段可以找回那是后话了还有另外一种是逻辑删除主要是通过update语句操作的。例如update product set delete_status1,edit_timenow(3)
where id123;逻辑删除需要在表中额外增加一个删除状态字段用于记录数据是否被删除。在所有的业务查询的地方都需要过滤掉已经删除的数据。通过这种方式删除数据之后数据任然还在表中只是从逻辑上过滤了删除状态的数据而已。其实对于这种逻辑删除的表是没法加唯一索引的。为什么呢假设之前给商品表中的name和model加了唯一索引如果用户把某条记录删除了delete_status设置成1了。后来该用户发现不对又重新添加了一模一样的商品。由于唯一索引的存在该用户第二次添加商品会失败即使该商品已经被删除了也没法再添加了。这个问题显然有点严重。有人可能会说把name、model和delete_status三个字段同时做成唯一索引不就行了答这样做确实可以解决用户逻辑删除了某个商品后来又重新添加相同的商品时添加不了的问题。但如果第二次添加的商品又被删除了。该用户第三次添加相同的商品不也出现问题了由此可见如果表中有逻辑删除功能是不方便创建唯一索引的。5. 分布式锁接下来你想到的第二种解决数据重复问题的办法可能是加分布式锁。目前最常用的性能最高的分布式锁可能是redis分布式锁了。使用redis分布式锁的伪代码如下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)
) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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作为查询条件即可。说实话解决重复数据问题的方案挺多的没有最好的方案只有最适合业务场景的最优的方案。