1背景介绍

1.1 幂等性定义

数学定义 
在数学里,幂等有两种主要的定义:

  • 在某二元运算下,幂等元素是指被自己重复运算(或对于函数是为复合)的结果等于它自己的元素。例如,乘法下唯一两个幂等实数为0和1,即s*s=s

  • 某一元运算为幂等的时,其作用在任一元素两次后会和其作用一次的结果相同。例如,高斯符号便是幂等的,即f(f(x))=f(x)

HTTP规范定义 
在HTTP/1.1规范中幂等性的定义是:

A request method is considered "idempotent" if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request. Of the request methods defined by this specification, PUT, DELETE, and safe request methods are idempotent.

        HTTP的幂等性指的是一次和多次请求某一个资源应该具有相同的副作用。如通过PUT接口将数据的Status置为1,无论是第一次执行还是多次执行,获取到的结果应该是相同的,即执行完成之后Status =1。

1.2 幂等概念

        微服务架构中,幂等是一致性方面的一个重要概念。幂等(Idempotent)是一个数学领域与计算机学的概念,常见于抽象代数中。而在编程中,一个幂等操作的特点是指其任意多次执行所产生的影响均与一次执行的影响相同。

        有人会简单的认为,直接禁止所有重试即可。然而,重试是降低微服务失败率的重要手段。因为网络波动、系统资源分配的不确定性、跨机房的请求等等原因,都会或多或少的导致一小部分请求的失败。而这部分失败的请求中,又有大部分请求其实只需要简单重试几次,即可成功。

1.3 重试机制

  • 降低微服务失败率

  • 提高至四个或五个9

  • 提高微服务架构的容错性

  • 提高微服务架构的高可靠性

2 幂等分析

2.1 幂等场景

        可能会发生重复请求或消费的场景,在微服务架构中是随处可见的。以下是笔者梳理的几个常见场景:

  • 网络波动:因网络波动,可能会引起重复请求

  • 分布式消息消费:任务发布后,使用分布式消息服务来进行消费

  • 用户重复操作:用户在使用产品时,可能会无意的触发多笔交易,甚至没有响应而有意触发多笔交易

  • 未关闭的重试机制:因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制(如Nginx重试、RPC通信重试或业务层重试等)

2.2 CRUD分析

  • 新增类请求(C)

    • 数据库自增主键,不具备幂等性

  • 查询类动作(R)

    • 重复查询不会产生或变更新的数据,因此查询是天然具备幂等性

  • 更新类请求(U)

    • 基于主键的计算式Update,不具备幂等性,即:UPDATE goods SET number=number-1 WHERE id=1

    • 基于主键的非计算式Update,具备幂等性,即:UPDATE goods SET number=newNumber WHERE id=1

    • 基于条件查询的Update,不一定具有幂等性(需要根据实际情况进行分析判断)

  • 删除类请求(D)

    • 基于主建的Delete具备幂等性

    • 一般业务层面都是逻辑删除(即Update操作),而基于主键的逻辑删除操作也是具有幂等性的

2.3 幂等重要性

针对一个微服务架构,如果不支持幂等操作,那将会出现以下情况:

  • 电商超卖现象

  • 重复转账、扣款或付款

  • 重复增加金币、积分或优惠券

超卖现象
        比如某商品的库存为1,此时用户1和用户2并发购买该商品,用户1提交订单后该商品的库存被修改为0,而此时用户2并不知道的情况下提交订单,该商品的库存再次被修改为-1这就是超卖现象。

        究其深层原因,是因为数据库底层的写操作和读操作可以同时进行,虽然写操作默认带有隐式锁(即对同一数据不能同时进行写操作)但是读操作默认是不带锁的,所以当用户1去修改库存的时候,用户2依然可以都到库存为1,所以出现了超卖现象。

        解决方案A:可以对读操作加上显式锁(即在select …语句最后加上for update)这样一来用户1在进行读操作时用户2就需要排队等待了。但问题来了,如果该商品很热门并发量很高那么效率就会大大的下降,如何解决呢?(解决方案B)

        解决方案B:我们可以有条件有选择的在读操作上加锁,比如可以对库存做一个判断,当库存小于一个量时开始加锁,让购买者排队,这样一来就解决了超卖现象。

3 何种接口提供幂等性

3.1 HTTP幂等性

在HTTP规范中定义GET、PUT和DELETE方法应该具有幂等性,具体如下:

  • GET方法

The GET method requests transfer of a current selected representatiofor the target resourceGET is the primary mechanism of information retrieval and the focus of almost all performance optimizations. Hence, when people speak of retrieving some identifiable information via HTTP, they are generally referring to making a GET request.

        GET方法是向服务器查询,不会对系统产生副作用,具有幂等性(不代表每次请求都是相同的结果)。

  • PUT方法

The PUT method requests that the state of the target resource be created or replaced with the state defined by the representation enclosed in the request message payload.

        也就是说PUT方法首先判断系统中是否有相关的记录,如果有记录则更新该记录,如果没有则新增记录。

  • DELETE 方法

The DELETE method requests that the origin server remove the association between the target resource and its current functionality. In effect, this method is similar to the rm command in UNIX: it expresses a deletion operation on the URI mapping of the origin server rather than an expectation that the previously associated information be deleted.

        DELETE方法是删除服务器上的相关记录。

3.2 实际业务案例

        现在简化为这样一个系统,用户购买商品的订单系统与支付系统;订单系统负责记录用户的购买记录已经订单的流转状态(orderStatus),支付系统用于付款,提供:

/**
 * 用于付款,扣除用户的余额
 **/
boolean pay(int accountid,BigDecimal amount);

        订单系统与支付系统通过分布式网络交互描述如下:

订单幂等性


        这种情况下,支付系统已经扣款,但是订单系统因为网络原因,没有获取到确切的结果,因此订单系统需要重试。由上图可见,支付系统并没有做到接口的幂等性,订单系统第一次调用和第二次调用,用户分别被扣了两次钱,不符合幂等性原则(同一个订单,无论是调用了多少次,用户都只会扣款一次)。如果需要支持幂等性,付款接口需要修改为以下接口:

 

boolean pay(int orderId,int accountId,BigDecimal amount);

    通过orderId来标定订单的唯一性,付款系统只要检测到订单已经支付过,则第二次调用不会扣款而会直接返回结果:

 

订单支持幂等性


        在不同的业务中不同接口需要有不同的幂等性,特别是在分布式系统中,因为网络原因而未能得到确定的结果,往往需要支持接口幂等性。

3.3 分布式应用幂等性

        随着分布式应用及微服务的普及,因为网络原因而导致调用应用未能获取到确切的结果从而导致重试,这就需要被调用应用具有幂等性。例如上文所阐述的支付系统,针对同一个订单保证支付的幂等性,一旦订单的支付状态确定之后,以后的操作都会返回相同的结果,对用户的扣款也只会有一次。这种接口的幂等性,简化到数据层面的操作:

update userAmount set amount = amount - 'value' ,paystatus = 'paid' where orderId= 'orderid' and paystatus = 'unpay'

        其中value是用户要减少的订单,paystatus代表支付状态,paid代表已经支付,unpay代表未支付,orderid是订单号。在上文中提到的订单系统,订单具有自己的状态(orderStatus),订单状态存在一定的流转。订单首先有提交(0)→付款中(1)→付款成功(2)/ 付款失败(3),简化之后其流转路径如图:

 

订单状态流转的幂等性


    当orderStatus = 1 时,其前置状态只能是0,也就是说将orderStatus由0->1 是需要幂等性的:

update Order set orderStatus = 1 where OrderId = 'orderid' and orderStatus = 0

        当orderStatus 处于0,1两种状态时,对订单执行0->1 的状态流转操作应该是具有幂等性的。这时候需要在执行update操作之前检测orderStatus是否已经=1,如果已经=1则直接返回true即可。

        但是如果此时orderStatus = 2,再进行订单状态0->1 时操作就无法成功,但是幂等性是针对同一个请求的,也就是针对同一个requestid保持幂等,这时候再执行:

update Order set orderStatus = 1 where OrderId = 'orderid' and orderStatus = 0

        接口会返回失败,系统没有产生修改,如果再发一次,requestid是相同的,对系统同样没有产生修改。

4 解决方案

4.1 全局唯一ID

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

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

数据库唯一约束: 通过业务设计,确定几个可以唯一识别订单字段设置为唯一索引,

这种方式好处就是简单,代码基本不做改动,

缺点也很明显,所以的重复请求要穿透整个链路,一直到数据库才能判重,对链路上资源消耗很大,会给数据库带来巨大的压力,即使服务扩容,TPS也很难上去

4.2 数据库表加唯一索引,防止新增脏数据

这种方法适用于在业务中有唯一标识的插入场景中。

比如对订单号进行加唯一索引,防止生成重复订单。
如果不加索引的后果是:当根据订单号去支付,支付表生成两条重复的订单号,然后去支付宝、微信、易宝支付去支付,付款完成后,第三方异步回调接口,本地接口首先根据订单号查询实体,发现查询到两条,系统就会抛出异常。
 

4.3 插入或更新

        这种方法插入并且有唯一索引的情况,比如我们要关联商品品类,其中商品的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()

4.4 多版本控制

MVCC(Multi-Version Concurrency Control 多版本并发控制),操作时带上版本号:update t1 set x=y ,version=version+1 where version=xxx,

优点是提升了并发响应能力,实现也简单

缺点是只适用更新接口,还是会将重复请求达到数据库,数据库压力较大

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

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

        在实现时可以如下:

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

4.5 状态机控制

状态机机制,本质上是MVCC方式的变种:

订单有多个业务状态,每次操作数据会带上一个状态,只有在上一个状态匹配的情况下会更新数据,

优缺点和MVCC大同小异,

但这种机制解决了插入的问题,不仅仅适用在更新接口

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

update goods_order set status=#{status} where id=#{id} and status<#{status}

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

 

4.6 Token机制

  1. 数据提交前要向服务的申请token,token放到redis中,token有效时间 
  2. 提交后后台校验token,同时删除token,生成新的token返回 

Token机制,这是一种非常高效的幂等机制,在性能和功能上都达到很好的效果

Token机制的核心就是要求客户端的每次请求里必须携带一个UUID,

产生UUID的算法很多,如:雪花算法,ObjectID,常用的开发语言也有对应的实现,即使client生成UUID有困难,也可以调用ID生成器预先生成一批缓存到本地,这里就不一一展开。

有了这个UUID,可以在多个环节实现拦截,而且这种判断是非常高效的,几乎都是O(1)的时间复杂度,远比数据库唯一约束判断快,譬如在nginx里,我们可以使用lua获取到请求里的UUID,将该UUID放入leveldb、redis、memcache,下次请求时判断该值是否存在,存在直接返回错误否则放行

如果不是使用nginx或者实施上述方案有困难,可以在网关层实现,对于java语言(其他语言也类似),可以做到透明化处理:

在网关里注册Idempotentfilter,该filter提取UUID,同样通过leveldb、redis、memcache等高速缓存判断是否可以放行,显然token方案可以尽早发现和拦截订单,大大降低资源消耗,减少数据库压力,对业务也是透明无浸入,但只依赖token机制显然是有缺陷的:

当缓存服务出错ldb文件丢失,redis数据意外清空,memcache意外重启等,会导致我们的UUID标志丢失,这时就需要数据库来兜底,数据库的唯一约束是不可或缺的,当出现这些极端情况时,即使请求达到DB,也不会造成数据重复。

有幂等需求的接口,建议采用token机制实现高效的排除重复请求,当然最终落地式需要结合具体的业务场景。

 

4.7 分布式锁

还是拿插入数据的例子,如果是分布式系统,构建全局唯一索引比较困难,例如唯一性的字段无法确定。那么这时候就可以引入分布式锁,通过第三方的系统(Redis或Zookeeper),在业务系统插入数据或更新数据,获取分布式锁,然后做操作,之后再释放锁。这样其实是把多线程并发锁的思路引入了多个系统,也就是分布式系统中的解决思路。

要注意的是,某个长流程处理过程要求不能并发执行,可以在流程执行之前根据某个标志(用户ID+后缀等)获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完成后,释放分布式锁(分布式锁需要第三方系统提供))。

 

5 总结

        幂等性设计不能脱离业务来讨论,一般情况下,去重表同时也是业务数据表,而针对分布式的去重ID,可以参考以下几种方式:

  • UUID

  • Snowflake

  • 数据库自增ID

  • 业务本身的唯一约束

  • 业务字段+时间戳拼接

参考链接:

https://juejin.im/post/5d4f8bb3518825237b5bde9c

https://www.cnblogs.com/yanggb/p/11147001.html

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐