什么是幂等性(Indempotent)

幂等性(Indempotent)是数学中的一个概念。
对于接口而言,以相同的参数调用这个接口一次和多次时,对系统产生的影响是相同的,那么我们就认为这个接口是一个幂等接口。

为什么需要幂等性

并不是所有接口都需要保证幂等性。以相同的请求调用这个接口一次或多次,需要给调用方返回一致的结果时,就要考虑将这个接口设计成幂等接口。

什么情况下会产生接口幂等性问题

  • 网络波动,可能会引起重复请求
  • 用户重复操作,用户在操作时候可能会无意触发多次下单交易,甚至没有响应而有意触发多次交易应用
  • 使用了失效或超时重试机制(Nginx重试、RPC重试或业务层重试等)
  • 页面重复刷新
  • 使用浏览器后退按钮重复之前的操作,导致重复提交表单
  • 使用浏览器历史记录重复提交表单
  • 浏览器重复的HTTP请求
  • 定时任务重复执行
  • 用户双击提交按钮

    幂等的使用场景

  1. 微信领红包接口:对于一个红包,领一次和领多次具有一样的效果,故该场景下领红包接口需保证幂等性。
  2. 订单创建接口:在创建订单时,第一次调用返回超时了,重试机制一般会再次调用这个接口。此时我们不能因为这个接口被调了两次,就创建两个一样的订单;因此需保证幂等性。
  3. 库存扣减接口
  4. 支付接口

    如何保证幂等性

    前端设计

    从前端设计角度考虑,是通过防止重复提交来保证幂等性(但无法完全保证)。
    防止重复提交更多的是不让用户发起多次一样的请求。比如用户在线购物下单时点了提交订单按钮,但是由于网络原因响应很慢,此时用户比较心急,多次点击了订单提交按钮。这种情况下就可能会造成多次下单。
    一般防止重复提交的方案有将订单按钮置灰,跳转到结果页等。主要还是从客户端的角度来解决重复提交的问题。
    幂等更多的是在重复请求已经发生,或是无法避免的情况下,采取一定的技术手段让这些重复请求不给系统带来副作用。

    1.按钮只能点击一次

    用户点击按钮后,将按钮置灰,或者显示loading状态。

    2.页面重定向-PRG模式

    PRG模式,即 Post-Redirect-Get,当客户提交表单后,去执行一个客户端的重定向,跳转到提交成功页面,避免用户按F5刷新导致的重复提交,也能消除按浏览器后退键导致的重复提交问题。目前绝大多数公司都是这么做的,如淘宝,京东等。

    后端设计

    1.来源+序列号

    这是一种比较好理解的通用的方案。
    当调用接口时,参数中必须传入来源 source 字段和序列号 seq 字段(实际项目中可用其他唯一标识字段代替,如序列号 uuid 等),服务端接收到请求,先判断自己是否是一个幂等接口,如果不是幂等接口就正常处理请求。
    如果是一个幂等接口,就将 source 和 seq 组成联合主键,去数据库表或 Redis 中查询。如果没有查询到,说明没处理过这个请求,然后正常处理请求就行了。处理完之后将处理结果和 source 和 seq 信息一起存入数据库或 Redis 中。
    如果根据 source 和 seq 能查询到,说明已经处理过这个请求了,直接将处理的结果返回即可。
    这种方案非常简单,而且易于理解,比较通用。但是如果请求量很大的话,存放请求记录的表会很大,这个时候可以将一段时间之前的记录删除,以提升性能。

    2.唯一索引

    此方案适合用于执行新增(create)操作的接口。
    对业务唯一的字段加上唯一索引,这样当数据重复时,插入数据会抛出异常。
    比如新增用户接口,我们将用户表中的身份证字段加上唯一索引。当同一个请求调用两次时,我们可以先 select 后 insert。先根据身份证字段查询下用户是否存在,不存在的话再新增。存在的话就返回新增失败,或者直接新增也行,数据库会抛异常,我们对异常处理返回前台就行了。
    大家可能会有一个疑问,我同一个请求调用两次,第一返回新增成功,第二次返回失败,返回的结果不同啊。这个接口还是幂等接口吗?
    此处,重申下概念,幂等强调的是接口一次调用和多次调用产生的效果是一样的。这边调用一次和调用多次都是新增了一个对象,所以还是满足幂等的。

    3. 乐观锁

    此方案适用于执行更新(update)操作的接口。
    乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。 我们一般通过数据库来实现乐观锁,比较通用的做法是增加一个时间戳(timestamp)或版本号(version)字段。
    此处以版本号(version)为例进行说明。

  5. 先根据标识信息(如用户ID)查询用户信息,获取当前 version 信息

    1. select id,amount,version from id=123;
  6. 如果数据存在,假设此次查询结果 version = 1,再使用 version 和 id 字段作为查询条件去执行更新操作

  1. update user set amount = amount+100, version = version+1 -- version是第一步查询到的值,此处假设为1 where id=123 and version = 1;
  1. 根据 update 更新操作影响的行数是否大于0,来判断此次更新操作是否成功
  2. 第1次请求 version = 1 是可以成功的,操作成功后 version 变成 2 了。这时如果并发的请求过来,再执行相同的SQL

    1. update user set amount = amount+100, version = version+1 where id=123 and version = 1;
  3. 该 update 操作不会真正更新数据,最终 SQL 执行结果影响行数是0。

    4. 状态机控制

    此方法适合在有状态机流转的情况下。
    比如订单业务系统中,订单的状态有待支付,支付中,支付成功,支付失败。设计时最好只支持状态的单向改变,比如待支付一定在支付中的前面,这样在更新的时候就可以加上对当前订单状态的限定条件。例如想把订单状态更新为支付成功,则之前的订单状态必须为支付中。
    代码实现中,对于状态字段,可以使用 int 类型,并且通过值的大小来做幂等。比如待支付订单为0,支付中为1,支付成功为100。在做状态机更新时,我们就可以这样控制

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

    5. 分布式锁

    执行方法时。现根据业务唯一ID获取分布式锁。若获取成功则执行;若获取锁不成功则不执行。分布式锁可以基于Redis,Zookeeper,MySQL来实现。
    此处以Redis分布式锁在订单业务中的应用为例进行说明

  4. 用户通过浏览器发起请求,服务端生成订单号 code 作为唯一业务字段。

  5. 使用 Redis 的 set 命令,将该订单 code 设置到 Redis 中,同时设置超时时间。
  6. 判断是否设置成功,如果设置成功,说明是第一次请求,则进行后续数据操作。
  7. 如果设置失败,说明是重复请求,则直接返回成功。

需要注意的是,分布式锁一定要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费 Redis 的存储空间,需要根据实际业务情况而定。

6.去重表

此方法适用于在业务中有唯一标识的插入场景中,比如在支付业务中,若一个订单只会支付一次,则订单ID可以作为唯一标识。
创建一张去重表,将业务唯一ID作为唯一索引,如订单号。当想针对订单做一系列操作时,先向去重表中插入一条记录,若插入成功,执行后续操作;若插入失败,数据库会抛出唯一约束异常,不执行后续操作。
去重表本质上可以看成基于 MySQL实现的分布式锁。

7.token机制

此种方案需要两次请求才能完成一次业务操作(增加了性能损耗和负载)

  1. 第一次请求获取token
  2. 第二次请求带着这个token,完成业务操作。

具体步骤如下

  1. 用户访问页面时,浏览器自动发起获取token请求。
  2. 服务端生成token,保存到redis中,然后返回给浏览器。
  3. 用户第2次通过浏览器发起请求时,携带该token。
  4. 在redis中查询该token是否存在,如果存在表示是第一次请求,做则后续的数据操作,并删除token。
  5. 如果不存在,说明是重复请求,则直接返回成功。


作者:变速风声
链接:https://juejin.cn/post/7098355055610298404
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

https://juejin.cn/post/7098355055610298404