如何保证接口的幂等性

幂等性介绍

接口幂等性:多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。

幂等性出现的场景

  1. 前端重复提交。

    用户在客户端,点击按钮,发送后端请求数据服务,后端需要,存储客户端提交的数据,由于后端没有及时返回状态,导致用户一直在客户端重复点击按钮,发送相同的数据。就会出现在数据库中创建同样的数据,这就是接口没有幂等性出现的bug。

  2. 接口超时重试。

    对于给第三方调用的接口,有可能会因为网络原因而调用失败,这时,一般在设计的时候会对接口调用加上失败重试的机制。如果第一次调用已经执行了一半时,发生了网络异常。这时再次调用时就会因为脏数据的存在而出现调用异常。

  3. 消息重复消费。

    在使用消息中间件来处理消息队列,且手动 ack 确认消息被正常消费时。如果消费者突然断开连接,那么已经执行了一半的消息会重新放回队列。
    当消息被其他消费者重新消费时,如果没有幂等性,就会导致消息重复消费时结果异常,如数据库重复数据,数据库数据冲突,资源重复等。

幂等性解决方案

  1. 基于前端按钮判断。

用户点击一次按钮后,立即更改按钮状态,使按钮不可点击状态,防止用户一个请求,重复多次提交。但是这种方法,只能用于前端重复提交,还是有漏洞,比如用户可以通过修改代码的方式,从新使按钮变为可点击提交的状态。

  1. 基于token机制实现。

    通过token 机制实现接口的幂等性,这是一种比较通用性的实现方法。示意图如下:

    image

    具体流程步骤:

    1. 客户端会先发送一个请求去获取 token,服务端会生成一个全局唯一的 ID 作为 token 保存在 redis 中,同时把这个 ID 返回给客户端(全局唯一 ID 可以用百度的 uid-generator、美团的 Leaf 去生成)。
    2. 客户端第二次调用业务请求的时候必须携带这个 token。
    3. 服务端会校验这个 token,如果校验成功,则执行业务,并删除 redis 中的 token(对 redis 中是否存在 token 以及删除的代码逻辑建议用 Lua 脚本实现,保证原子性)。
    4. 如果校验失败,说明 redis 中已经没有对应的 token,则表示重复操作,直接返回指定的结果给客户端。
  2. 基于mysql实现。

    实现方式是利用 mysql 唯一索引的特性。示意图如下:

    image

    具体流程步骤:

    1. 建立一张去重表,其中某个字段需要建立唯一索引。
    2. 客户端去请求服务端,服务端会将这次请求的一些信息插入这张去重表中。
    3. 因为表中某个字段带有唯一索引,如果插入成功,证明表中没有这次请求的信息,则执行后续的业务逻辑。
    4. 如果插入失败,则代表已经执行过当前请求,直接返回。
  3. 基于redis实现。

    实现方式是基于 SETNX 命令实现的。

    SETNX key value:将 key 的值设为 value ,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。

    该命令在设置成功时返回 1,设置失败时返回 0。

    示意图如下:

    image

    具体流程步骤:

    1. 客户端先请求服务端,会拿到一个能代表这次请求业务的唯一字段。
    2. 将该字段以 SETNX 的方式存入 redis 中,并根据业务设置相应的超时时间。
    3. 如果设置成功,证明这是第一次请求,则执行后续的业务逻辑。
    4. 如果设置失败,则代表已经执行过当前请求,直接返回。
  4. 基于悲观锁实现。

    实现方式是基于悲观锁来实现的。将更改的数据行锁住,在同一时刻只允许一个请求获得锁,更新数据,其他的请求则等待。

    通常情况下通过如下sql锁住单行数据:
    select * from 表名 列名=value for update;

    具体流程如下:

    image

    具体步骤:

    1. 多个请求同时根据id查询用户信息。
    2. 判断余额是否不足100,如果余额不足,则直接返回余额不足。
    3. 如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。
    4. 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。
    5. 第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。
    6. 如果余额不足,说明是重复请求,则直接返回成功。

    悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。
    此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。

  5. 基于乐观锁实现。

    实现方式是基于乐观锁来实现的。需要在表中增加一个timestamp或者version字段,这里以version字段为例。

    在更新数据之前先查询一下数据:

    select id,amount,version from user id=123;

    如果数据存在,假设查到的version等于1,再使用id和version字段作为查询条件更新数据:

    update user set amount=amount+100,version=version+1
    where id=123 and version=1;

    更新数据的同时version+1,然后判断本次update操作的影响行数,如果大于0,则说明本次更新成功,如果等于0,则说明本次更新没有让数据变更。

    由于第一次请求version等于1是可以成功的,操作成功后version变成2了。这时如果并发的请求过来,再执行相同的sql:

    update user set amount=amount+100,version=version+1 where id=123 and version=1;

    该update操作不会真正更新数据,最终sql的执行结果影响行数是0,因为version已经变成2了,where中的version=1肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为version值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。

    具体流程如下:

    image

    具体步骤:

    1. 先根据id查询用户信息,包含version字段
    2. 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1
    3. 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。
    4. 如果影响0行,说明是重复请求,则直接返回成功。
  6. 基于状态机实现。

    实现方式是基于状态机来实现的。

    业务表是有状态的,比如订单表中有:1-下单、2-已支付、3-完成、4-撤销等状态。如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等性。

    假如id=123的订单状态是已支付,现在要变成完成状态。

    update order set status=3 where id=123 and status=2;

    第一次请求时,该订单的状态是已支付,值是2,所以该update语句可以正常更新数据,sql执行结果的影响行数是1,订单状态变成了3。

    后面有相同的请求过来,再执行相同的sql时,由于订单状态变成了3,再用status=2作为条件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是0,即不会真正的更新数据。但为了保证接口幂等性,影响行数是0时,接口也可以直接返回成功。

    具体流程图如下:

    image

    具体步骤:

    1. 用户通过浏览器发起请求,服务端收集数据。
    2. 根据id和当前状态作为条件,更新成下一个状态
    3. 判断操作影响行数,如果影响了1行,说明当前操作成功,可以进行其他数据操作。
    4. 如果影响了0行,说明是重复请求,直接返回成功。

    主要特别注意的是,该方案仅限于要更新的表有状态字段,并且刚好要更新状态字段的这种特殊情况,并非所有场景都适用。


如何保证接口的幂等性
http://www.wangxiaohuan.com/2020/03/30/2020-03-30/
作者
吃素的左撇子
发布于
2020年3月30日
许可协议