浅谈接口幂等性设计

本文这里介绍下如何保证服务接口的幂等性

abstract.png

概述

Idempotent幂等作为一个抽象代数中的概念。其同样广泛应用于计算机科学领域,意为任意多次执行所产生的影响均与一次执行的影响相同。实际业务中,由于存在表单的重复提交、MQ消息的重复消费、服务间调用的重试机制等原因,服务接口的幂等性就显得非常重要了。这里从RESTful API的角度对部分常见类型请求的幂等性特点进行分析

  • GET:查询操作。具有天然的幂等性
  • POST:新增操作。显然请求一次与请求多次造成的结果是不同的,即不是幂等的
  • PUT:更新操作。如果是以绝对值进行更新(例如a=10),则其是幂等的;而如果是通过增量的方式进行更新(例如a=a+10),则显然不是幂等的
  • DELETE:删除操作。其需要具体分析,例如根据唯一值进行删除,则是幂等的;而如果是根据条件进行删除,则就不一定是幂等的。例如每次删除某字段最大的记录

从上可以看出,通常我们所说接口的幂等性也基本是对写操作(新增、修改、删除)而言的。下面就来介绍几种常见的幂等性设计方案

唯一性约束

利用数据库的唯一性约束。当第一次数据库新增记录成功后,后续再尝试新增插入相同的数据记录。即会由于违反唯一性约束而操作失败。在具体实践过程中,可以直接在业务表上对某个具有唯一性特征的业务字段添加唯一性约束。此种设计适用于对业务记录的新增操作,典型地如用户注册

而如果业务表没有业务字段可以用来保证唯一性要求,怎么办呢?其实也很简单。在业务表所在的数据库中单独建立一张包含全局ID字段的防重表,并对全局ID字段使用唯一性约束。客户端进行业务请求时首先从服务端获取一个全局的唯一的ID,然后携带该全局ID发起业务请求。而在服务端处理业务请求过程中,先向防重表插入全局ID、再执行业务操作。同时结合本地事务以实现如果业务操作失败后,防重表的记录也可以被回滚,从而保证后续重试可以成功。可以看到通过引入全局ID、本地防重表的设计方案,大大拓展了唯一性约束方案的适用领域,使其不仅仅适用于新增操作

乐观锁

前面提到对于更新操作,如果是以绝对值进行更新则是幂等的,例如将count修改为10(count=10);而如果是以增量的方式进行更新的,例如将count增加10(count=count+10)。每次的重试操作都会导致count值不断累加,从而出现幂等性问题。那么对于后者这种情况,我们可以使用乐观锁机制,首先为该表增加一个version版本号字段。每次修改前,客户端需要先获取欲修改记录的version版本号;然后客户端携带该版本号信息发起更新请求。更新的时候,一方面修改记录中的相关业务字段,同时也会对该记录的version版本号字段进行自增。这样即使客户端发起重试请求,由于更新条件中的版本号还是之前的,无法在数据库中匹配到相应的记录。自然也就无法成功进行修改

状态机

该方案同样适用于更新操作。对于某些场景而言,可以结合业务设计一个单向的状态机。比如就订单记录而言,其状态可以有:待支付、支付中、支付成功、支付失败。这样我们在订单表中就可以增加一个status状态字段。每次修改操作不仅将status作为条件,同时也会将status字段修改到下一个状态值。这样本次修改成功、后续即使发起了重试请求,也会因为由于状态条件不匹配无法找到相应的记录而无法进行修改。事实上,该方案与乐观锁机制本质上是一致的

Token机制

通过Token机制实现接口的幂等性,更加具有普适性。其基本流程如下:

  1. 客户端向服务端请求获取一个Token,服务端收到请求后会生成一个全局唯一的ID作为Token并存放到Redis当中,同时将Token值返回给客户端
  2. 客户端携带Step 1获取到的Token值发起真正的业务请求
  3. 服务端收到业务请求后,校验请求的Token值是否有效。具体地,判断客户端请求携带的Token值是否存在于Redis当中。如果Redis当中存在该Token值则说明校验成功,进入Step 4;如果Redis当中不存在该Token值,则说明校验失败。换言之,这是一次重复的操作,本次请求无需进行业务处理
  4. 删除Redis当中相应的Token值
  5. 执行业务操作

在该方案中,比较有争议的点在于是先删除Redis中的Token还是先执行业务操作?

  • 先删除Redis中的Token,即上文的流程顺序。则一方面需要保证 判断Token是否存在于Redis、删除Redis中的Token 这两个操作的原子性,可以通过Lua脚本来实现。以避免高并发环境下,多个请求同时通过了Token校验。而如果不想用Lua脚本的话,也可以选择直接删除Token、然后利用返回值判断是否删除成功的方式来进行Token校验;另一方面,如果Token被成功删除了,但业务操作却由于某种原因未得到成功执行,会导致后续的重试请求也无法完成业务操作。因为此时Token已经被删除掉了。解决办法也很简单,客户端重新获取一个新Token再次发起业务请求即可
  • 先执行业务操作,则需要保证这之后的 删除Redis中的Token操作 一定要执行成功,否则即会导致出现幂等性问题。与此同时还需要对整个流程(Token校验、执行业务、删除Token)加锁,以避免重试的请求同时通过了Token校验
    综上所述,推荐的处理方式是先删除Token再执行业务操作

基于Redis的分布式锁

事实上,前面提到的基于全局ID、防重表的唯一性约束方案设计,本质上相当于是基于数据库的分布式锁。类似地,我们同样可以利用基于Redis的分布式锁实现幂等性。具体地

  1. 客户端向服务端请求获取一个全局ID,服务端会返回一个全局唯一的ID给客户端
  2. 客户端携带Step 1获取到的全局ID发起真正的业务请求
  3. 服务端收到业务请求后,向Redis中存储该请求携带的全局ID、配置一定的TTL过期时间。这里要求 当且仅当Redis中不存在该Key时才会进行存储、并且需要Redis操作成功 才会视为设置成功;如果Redis中已经存在该Key则不再进行设置并直接视为设置失败
  4. 根据Step 3的执行结果,如果设置成功,则说明是第一次请求,允许执行业务操作;反之,则视为重复请求,不允进行业务操作

这里关于Step 3的实现方式,一方面可以通过setnx、expire命令实现,并结合Lua脚本保证原子性;另一方面,如果不想使用Lua脚本,则可以使用set命令。Redis从2.6.12版本开始对set命令进行了增强,提供了对NX、EX选项的支持

0%