前言
元旦放假哪也没去一个人在家里闷得慌,突然间想写点东西打发打发时间,刚好想起前几天在公司听到一些同事在讨论线上数据库出现数据重复的问题,据说是因为接口与前端都没有做重复提交的约束导致的问题,因为我没有参与到相关业务的开发中,所以具体情况不了解,只是听他们在讨论过程中知道一点就是有可能是用户误操作导致接口出现并发问题,还猜测有可能是用户端通过程序脚本的方式来刷接口,虽说后端API使用了Https协议加密传输,但还是有被刷的可能性,也有同学表示说接口要加个同步锁来排重等等。初一听好像没有什么问题,仔细想下也是有问题的,加上之前有在简书上看过一篇关于怎么处理接口幂等的文章,觉得写的很赞(如何避免重复下单),这里就再结合自己过去在一些项目中对于这种重复提交的做法做下记录总结吧。
什么是接口幂等
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中,即f(f(x)) = f(x).简单的来说就是一个操作多次执行产生的结果与一次执行产生的结果一致。有些系统操作天生就具有幂等性例如数据库的select语句,但更多时候是需要程序员来做保证的,尤其是在分布式系统环境中,接口能不能做到保证幂等性对系统的影响可能是非常大的,例如很常见的支付下单等场景,由于分布式环境中网络的复杂性,用户误操作,网络抖动,消息重复,服务超时导致业务自动重试等等各种情况都可能会使线上数据产生了不一致,造成生产事故。
如何解决幂等性问题
首先我发现很多人在做项目的过程中一旦发现程序出现了所谓高并发问题(其实很多时候是在意淫哈,哪有那么多高并发),第一个反应就是加锁,不管是分布式锁还是单机锁,好像加了锁并发问题就真的不存在了似的,确实在很多情况下加锁是能解决问题的,但程序也变成了单线程执行, 还得注意锁不要加错了地方(先要搞清楚程序需要同步的临界区是什么)否则不但没能解决问题还降低系统TPS造成性能影响。而说到锁很多人的第一个反应就是Jdk提供的同步锁synchronized,一般情况下同步锁确实能解决多线程访问临界区造成的数据安全问题即并发问题,同步锁的一般使用方式要么是锁住整个方法要么是方法内部锁住一个程序片段,不管哪一种先要明白锁的是当前这个类的实例对象,即多个线程同时访问代码片段时访问的是同一个对象(如果每个线程都会创建一个新的实例对象的话,加锁也就毫无意义了)比方说Spring受管的bean,默认情况下都是单实例的,也就是说多线程共享的,这个时候才需要考虑并发的问题。而我们平时在做项目的过程中,除了要完成业务开发之外,还得多想想业务之外的一些东西比如接口需不需要保证幂等,代码有没有很强的扩展性等等,我觉得这也是高级程序员和初级程序员之间的区别吧。回到文章开头提到的问题,假设我们的数据库里面有一张表是order 字段有id,userId,planId(计划ID),money.createTime这几个字段,前端用户在下单的同时就是针对某一个计划提交了一条数据(业务要保证只能提交一次,不了解真实情况所以这业务是假设的),那么这个时候如果说我们的业务伪代码中是这么写的:1
2
3
4
5
6public void saveOrder(userId,planId,money){
boolean exist = select(userId,planId);
if(exist){
insertOrder(userId,planId,money);
}
}
那么这种情况下加同步锁是可以保证多个线程并发访问不会有问题,但如果在insert之前没有先从db中select出来就直接insert了,那么加锁也是白加,因为锁的本质也是在排队,第一个请求执行完之后,紧接着等待队列中的第二个请求一样会执行。另外一个问题是单机锁无法解决系统集群或者分布式的场景,要知道现在大部分的互联网应用都是集群或分布式的,JDK的同步锁也只能锁住单个进程,系统由于负载均衡,并发的两个线程不一定就请求到同一台服务器,所以这种场景下加锁很大几率是无效的,当然分布式锁可以解决这两个问题比方说下面这个采用redis的setnx实现的一个简易版的锁,伪代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20//加锁
public static boolean acquiredLock(key,expired,timeout,timeUnit){
try(Jedis jedis = getJedis()){
long time = System.nanoTime();
while (System.nanoTime() - time < timeUnit.toNanos(timeout)){
long lock = jedis.setnx(key, UUID.randomUUID().toString());
if (lock == 1) {
jedis.expire(key, expired);
return true;
}
}
}
return false;
}
//解锁
public static void unLock(key) {
try (Jedis jedis = getJedis()) {
jedis.del(key);
}
}
我们在程序中可以先定义一个字符串常量的key,并根据实际情况控制好timeout,那么当第一个线程进来的时候拿到了锁就执行下面的业务,另一个线程发现锁已经被拿走了,就执行返回失败或者给个友好的提示“不能重复提交”之类的,伪代码如下:1
2
3
4
5
6
7public void saveOrder(userId,planId,money){
if(acquiredLock(key,timeout)){
insertOrder(userId,planId,money);
}else{
throw new RuntimeException("不能重复下单");
}
}
以上这段代码初看好像也没什么问题,但是采用redis来做控制也是有很多坑的,比方说这个超时时间就很不好控制还得考虑redis挂掉了怎么处理,还要注意解锁,搞不好会变成死锁等等,这里我也不进行详细讨论,所以加锁这种方案在这里基本上是不适用的,那么该怎么做呢,其实方案很多,但首先我们得先分析出现数据问题的根源才好做出相应的解决方案,除了上面我们看到的那篇文章的链接里面,博主提到的几种情况:客户端bug,网络不稳定导致的服务超时,app闪退或者人工强退等等都是很常见的问题,事实上类似这种问题都无法仅仅通过客户单或者服务端就能解决的,我们项目出现的问题很可能就是服务端和客户端都没做处理(猜测),其实我觉得这算是一个常识,客户端至少也得做一个提交之后按钮的置灰功能吧,虽然对于很多人来讲这没什么用但是针对大量的小白用户来说已经可以阻止他们误操作了,所以说接口校验的原则(请求的合法性,参数的正确性等)应该是前后端一起做的。至于具体解决方案总结下就是利用db的唯一索引约束结合客户端来保证接口的幂等.比如做法可以是:
1.我们可以给表字段userId和planId加上联合唯一索引约束dedup_key.
2.在业务层中要显示的去捕获抛出来的异常,再做多一层异常的包装,这样子返回给客户端的才是友好的提示信息,伪代码如下:1
2
3
4
5
6
7
8
9
10
11
12public void saveOrder(userId,planId,money) throws BusinessException {
try {
insertOrder(userId,planId,money);
}catch (RuntimeException e) {
if (e.getMessage().contains("Duplicate entry")
&& e.getMessage().contains("dedup_key")){
throw new BusinessException ("不能重复下单");
}else{
throw e;//其他类型的异常要往外抛出
}
}
}
上面这种实现虽然可以解决问题而且代码看起来也挺简洁,但并不是一种好的做法,因为如果我们在插入订单表之前又做了其他的一些关联表的插入或修改,那么一旦发现订单表的插入失败这个时候是不是还要去回滚之前所做的一些操作呢又比如说之前使用过MQ发送过消息那又要如何处理消息回滚呢,所以上面这种做法会额外增加系统复杂性,更好的实现应该是不在业务表上面建唯一索引了,而是独立出一张表token_table,通常称为排重表或者令牌表,表中主要有一个字段unique_key,并且在这个字段上面建一个unique index,那么这时候可以使用上面采用过的通用方案即并发时由数据库自动抛出异常,业务service来捕获最终返回给客户端友好的提示,或者我们还可以利用mysql的insert ignore特性来处理这个问题。
顺便简单普及下mysql的insert ignore特性,这是mysql提供的三种可以防止重复插入数据的方式之一(另外两种是Replace和ON DUPLICATE KEY UPDATE 这两种也能解决重复提交问题,这里不展开描述,具体可以参考MySql避免重复插入记录方法),如果表table中有主键pkId那么重复插入相同的pkId不会抛出异常[Err] 1062 - Duplicate entry ‘1’ for key ‘PRIMARY’,而是直接返回结果0,如果表中某个字段建有唯一索引,同样的除了第一次插入返回1外,其余皆返回0,那么我们就可以在业务方法中这样做:
1.先使用insert ignore插入一条数据到令牌表中,得到返回的值为0或者为1
2.在service方法中无需显示捕获异常,只需判断第一步获取到的结果,如果大于0则说明是第一次插入此时拿到令牌,则可以往下走,否则抛出重复提交的异常给客户端提示即可,代码也很简洁,伪代码如下所示:1
2
3
4
5
6
7
8public void saveOrder(userId,planId,money){
int token = insert ignore token_table(unique_key) value(uniqueKey);
if(token>0){
insertOrder(userId,planId,money);
}else{
throw new BusinessException ("不能重复下单");
}
}
以上所列出来的方案都是属于业务本身存在唯一标示的字段(userId+planId),但如果业务本身不存在这样的字段来建unique index该怎么处理呢,一般有两种处理方式,第一种是由客户端来生成,而且每次生成之后要cache起来以便下次使用的时候能辨别出是否是重复的请求具体可参考开头提到的那篇文章,第二种则是由服务端根据业务具体情况来统一生成全局标示,做成一个全局的微服务,但需要考虑的东西比较多架构实现也比较复杂,可参考美团的技术文章分布式系统互斥性与幂等性问题的分析与解决
还有一种解决方案是利用数据库的锁机制来处理即共享读锁+普通索引。
下面这个截图来自MySQL技术内幕InnoDB存储引擎这本书,这种方式我没在生产中使用过,但理论上来说应该也是可行的。
最后再说说类似那种使用程序来使接口无限被重放的情况,其实我一直都认为这不算是接口的主要职能了,接口的主要职责是处理业务逻辑,其他的安全措施应该交给框架层面来统一解决,但对于小系统来讲可能没有那么完善的基础设施,所以该做的还是要做的比方说接口必须要登录认证,可以结合nginx,redis做限制访问,后台还可以按照制定好的规则算法来对接口参数做排序和加密,防止 客户端构造出非法的请求等等。
最后总结
1.同步锁(单线程,集群可能会失效)
2.分布式锁如redis(实现复杂)
2.业务字段加唯一约束(简单)
3.令牌表+唯一约束(简单推荐)
4.mysql的insert ignore或者on duplicate key update(简单)
5.共享锁+普通索引(简单)
6.利用MQ或者Redis扩展(排队)
7.其他方案如多版本控制MVCC 乐观锁 悲观锁 状态机等。。。
对客户端请求排队或者单线程都可以处理幂等问题,需要根据具体业务选择合适的方案但必须前后端一起做,前端做了可以提升用户体验,后端则可以保证数据安全。