接口的幂等设计

什么是接口幂等

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中,即f(f(x)) = f(x).简单的来说就是一个操作多次执行产生的结果与一次执行产生的结果一致。有些系统操作天生就具有幂等性例如数据库的select语句,但更多时候是需要程序员来做保证的,尤其是在分布式系统环境中,接口能不能做到保证幂等性对系统的影响可能是非常大的,例如很常见的支付下单等场景,由于分布式环境中网络的复杂性,用户误操作,网络抖动,消息重复,服务超时导致业务自动重试等等各种情况都可能会使线上数据产生了不一致,造成生产事故。

如何解决幂等性问题

首先我发现很多人在做项目的过程中一旦发现程序出现了所谓高并发问题(其实很多时候是在意淫哈,哪有那么多高并发),第一个反应就是加锁,不管是分布式锁还是单机锁,好像加了锁并发问题就真的不存在了似的,确实在很多情况下加锁是能解决问题的,但程序也变成了单线程执行, 还得注意锁不要加错了地方(先要搞清楚程序需要同步的临界区是什么)否则不但没能解决问题还降低系统TPS造成性能影响。而说到锁很多人的第一个反应就是Jdk提供的同步锁synchronized,一般情况下同步锁确实能解决多线程访问临界区造成的数据安全问题即并发问题,同步锁的一般使用方式要么是锁住整个方法要么是方法内部锁住一个程序片段,不管哪一种先要明白锁的是当前这个类的实例对象,即多个线程同时访问代码片段时访问的是同一个对象(如果每个线程都会创建一个新的实例对象的话,加锁也就毫无意义了)比方说Spring受管的bean,默认情况下都是单实例的,也就是说多线程共享的,这个时候才需要考虑并发的问题。而我们平时在做项目的过程中,除了要完成业务开发之外,还得多想想业务之外的一些东西比如接口需不需要保证幂等,代码有没有很强的扩展性等等,我觉得这也是高级程序员和初级程序员之间的区别吧。回到文章开头提到的问题,假设我们的数据库里面有一张表是order 字段有id,userId,planId(计划ID),money.createTime这几个字段,前端用户在下单的同时就是针对某一个计划提交了一条数据(业务要保证只能提交一次,不了解真实情况所以这业务是假设的),那么这个时候如果说我们的业务伪代码中是这么写的:

 public void saveOrder(userId,planId,money){
boolean exist = select(userId,planId);
if(exist){
insertOrder(userId,planId,money);
}
}

那么这种情况下加同步锁是可以保证多个线程并发访问不会有问题,但如果在insert之前没有先从db中select出来就直接insert了,那么加锁也是白加,因为锁的本质也是在排队,第一个请求执行完之后,紧接着等待队列中的第二个请求一样会执行。另外一个问题是单机锁无法解决系统集群或者分布式的场景,要知道现在大部分的互联网应用都是集群或分布式的,JDK的同步锁也只能锁住单个进程,系统由于负载均衡,并发的两个线程不一定就请求到同一台服务器,所以这种场景下加锁很大几率是无效的,当然分布式锁可以解决这两个问题比方说下面这个采用redis的setnx实现的一个简易版的锁,伪代码如下:

 //加锁
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,那么当第一个线程进来的时候拿到了锁就执行下面的业务,另一个线程发现锁已经被拿走了,就执行返回失败或者给个友好的提示“不能重复提交”之类的,伪代码如下:

 public 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.在业务层中要显示的去捕获抛出来的异常,再做多一层异常的包装,这样子返回给客户端的才是友好的提示信息,伪代码如下:

public 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提供的三种可以防止重复插入数据的方式之一(另外两种是ReplaceON 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则说明是第一次插入此时拿到令牌,则可以往下走,否则抛出重复提交的异常给客户端提示即可,代码也很简洁,伪代码如下所示:

public 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 乐观锁 悲观锁 状态机等。。。

对客户端请求排队或者单线程都可以处理幂等问题,需要根据具体业务选择合适的方案但必须前后端一起做,前端做了可以提升用户体验,后端则可以保证数据安全。

在微服务架构下,我们在完成一个订单流程时经常遇到下面的场景:

  • 一个订单创建接口,第一次调用超时了,然后调用方重试了一次
  • 在订单创建时,我们需要去扣减库存,这时接口发生了超时,调用方重试了一次
  • 当这笔订单开始支付,在支付请求发出之后,在服务端发生了扣钱操作,接口响应超时了,调用方重试了一次
  • 一个订单状态更新接口,调用方连续发送了两个消息,一个是已创建,一个是已付款。但是你先接收到已付款,然后又接收到了已创建
  • 在支付完成订单之后,需要发送一条短信,当一台机器接收到短信发送的消息之后,处理较慢。消息中间件又把消息投递给另外一台机器处理

以上问题,就是在单体架构转成微服务架构之后,带来的问题。当然不是说单体架构下没有这些问题,在单体架构下同样要避免重复请求。但是出现的问题要比这少得多。

为了解决以上问题,就需要保证接口的幂等性,接口的幂等性实际上就是接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。有些接口可以天然的实现幂等性,比如查询接口,对于查询来说,你查询一次和两次,对于系统来说,没有任何影响,查出的结果也是一样。

除了查询功能具有天然的幂等性之外,增加、更新、删除都要保证幂等性。那么如何来保证幂等性呢?

全局唯一ID

如果使用全局唯一ID,就是根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。如果不存在则把全局ID,存储到存储系统中,比如数据库、redis等。如果存在则表示该方法已经执行。

从工程的角度来说,使用全局ID做幂等可以作为一个业务的基础的微服务存在,在很多的微服务中都会用到这样的服务,在每个微服务中都完成这样的功能,会存在工作量重复。另外打造一个高可靠的幂等服务还需要考虑很多问题,比如一台机器虽然把全局ID先写入了存储,但是在写入之后挂了,这就需要引入全局ID的超时机制。

使用全局唯一ID是一个通用方案,可以支持插入、更新、删除业务操作。但是这个方案看起来很美但是实现起来比较麻烦,下面的方案适用于特定的场景,但是实现起来比较简单。

去重表

这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。

插入或更新

这种方法插入并且有唯一索引的情况,比如我们要关联商品品类,其中商品的ID和品类的ID可以构成唯一索引,并且在数据表中也增加了唯一索引。这时就可以使用InsertOrUpdate操作。在mysql数据库中如下:

insert into goods_category (goods_id,category_id,create_time,update_time)
values(#{goodsId},#{categoryId},now(),now())
on DUPLICATE KEY UPDATE
update_time=now()

多版本控制

这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等

boolean updateGoodsName(int id,String newName,int version);

在实现时可以如下

update goods set name=#{newName},version=#{version} where id=#{id}and version<${version}

状态机控制

这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100。付款失败为99

在做状态机更新时,我们就这可以这样控制

update `order` set status=#{status} where id=#{id} and status<#{status}

以上就是保证接口幂等性的一些方法。


发表评论
留言与评论(共有 0 条评论)
   
验证码:

相关文章

推荐文章

'); })();