SpringBoot接口幂等性设计

543 阅读7分钟

1. 什么是幂等

数学上的定义:f(f(x))=f(x)。x被函数f作用一次和作用无限次的结果是一样的。幂等性应用在软件系统中,可以把它简单定义为:某个函数或者某个接口使用相同参数调用一次或者无限次,其造成的后果是一致的,在实际应用中一般针对于接口进行幂等性设计。

例如:

  • 创建业务订单时,一次业务请求只能创建一个订单。
  • 用户发起一笔付款请求,应该只扣除用户账号一次钱,即使遇到网络重发或系统bug重发时,也只扣除一次钱。
  • 支付宝回调接口, 可能会多次回调, 必须处理重复回调。
  • 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次
  • 前端重复提交选中的数据,后台应该只产生对应本次提交的一个响应结果。
    等等

2. 产生原因

由于重复点击或者网络重发 eg:
(1) 点击提交按钮两次;
(2) 点击刷新按钮;
(3) 使用浏览器后退按钮重复之前的操作,导致重复提交表单;
(4) 使用浏览器历史记录重复提交表单;
(5) 浏览器重复的HTTP请求;
(6) nginx重发等情况;
(7) 分布式RPC的try重发等;

3.“天然”幂等以及“人工”幂等

(1)“天然”幂等

编程中常见幂等

  • select查询天然幂等
  • delete删除也是幂等,删除同一个多次效果一样
  • update直接更新某个值的,幂等
  • update更新累加操作的,非幂等
  • insert非幂等操作,每次新增一条

CRUD分析

操作 幂等性
新增类请求(C) 数据库自增主键,不具备幂等性
查询类动作(R) 重复查询不会产生或变更新的数据,因此查询是天然具备幂等性
基于主键的计算式更新(U) 不具备幂等性,即:UPDATE goods SET number=number-1 WHERE id=1
基于主键的非计算式更新(U) 具备幂等性,即:UPDATE goods SET number=3 WHERE id=1
基于主建的删除(D) 具备幂等性
业务层面都是逻辑删除(即Update操作)(U) 具备幂等性

RESTful

方法 幂等性 对应CRUD操作
POST 不幂等 C
GET 幂等 R
PUT 幂等 U
DELETE 幂等 D
PATCH 不幂等 U

GET、PUT、DELETE都是幂等操作,而POST不是,以下进行分析:
GET请求很好理解,是对资源进行多次查询。
PUT请求可以认为将A修改成B,接下来一直修改,都是为B,与之前结果一致,属于基于主键的非计算机更新。
DELETE请求则是将资源删除后,后面多次删除这条数据,结构一致。
PATCH请求是基于主键的计算式更新。

(2)“人工”幂等

POST请求不是幂等操作,一次请求就添加一次数据,如果需要在POST请求时加上人为的幂等,下面就来说说幂等实现的方法。

4. 常见解决方案

  • token令牌 -- 防止页面重复提交
  • 悲观锁 -- 获取数据的时候加锁(锁表或锁行)
    使用select ... for update ,这种和 synchronized 锁住先查再insert or update一样,但要避免死锁,效率也较差 针对单体 请求并发不大 可以推荐使用
  • 乐观锁 -- 基于版本号version实现, 在更新数据那一刻校验数据
    如果只是更新已有的数据,没有必要对业务进行加锁,设计表结构时使用乐观锁,一般通过version来做乐观锁,这样既能保证执行效率,又能保证幂等。例如: UPDATE tab1 SET col1=1,version=version+1 WHERE version=#version# 。但是,乐观锁存在失效的情况,就是常说的ABA问题。如果version版本一直是自增的就不会出现ABA的情况。
  • 去重表
    这种方法适用于业务中有唯一标识的场景。假定以上场景,一个订单对应一个唯一的订单号,一个订单支付一次,这时候订单号可作为唯一标识,并且把订单号作为唯一索引(查出来只取符合条件的一条数据),将订单号写入去重表的操作,放入一个事务当中,如果订单重复创建,数据库会抛出唯一约束,事务进行回滚。这其实也用到的唯一ID,但与全局唯一ID相比,只是单单针对一个业务流程而已。
    使用订单号orderNo做为去重表的唯一索引,每次请求都根据订单号向去重表中插入一条数据。第一次请求查询订单支付状态,当然订单没有支付,进行支付操作,无论成功与否,执行完后更新订单状态为成功或失败,删除去重表中的数据。后续的订单因为表中唯一索引而插入失败,则返回操作失败,直到第一次的请求完成(成功或失败)。可以看出防重表作用是加锁的功能。
  • 分布式锁 -- redis(jedis、redisson)或zookeeper实现
    防重表可以使用分布式锁代替,比如Redis。订单发起支付请求,支付系统会去Redis缓存中查询是否存在该订单号的Key,如果不存在,则向Redis增加Key为订单号。查询订单支付已经支付,如果没有则进行支付,支付完成后删除该订单号的Key。通过Redis做到了分布式锁,只有这次订单支付请求完成,下次请求才能进来。相比去重表,将并发做到了缓存中,较为高效。思路相同,同一时间只能完成一次支付请求。
  • 状态机 -- 状态变更, 更新数据时判断状态
    状态机是一种数学模型,通常体现为一个状态转换图,可以理解为自动门有两个状态,open和closed,closed状态下,如果读取开门信号,那么状态就会切换为open。open状态下如果读取关门信号,状态就会切换为closed。 我们可以设置状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为1,付款失败为-1。在做状态机更新时,我们就这可以这样控制: update goods_order set status = #{status} where id = #{id} and status = #{status}
  • 支付缓冲区
    把订单的支付请求都快速地接下来,一个快速接单的缓冲管道。后续使用异步任务处理管道中的数据,过滤掉重复的待支付订单。优点是同步转异步,高吞吐量。缺点是不能及时地返回支付结果,需要后续监听支付结果的异步返回。

5. 本文实现

本文采用通过redis + token机制实现接口幂等性校验

实现思路

为需要保证幂等性的每一次请求创建一个唯一标识token, 先获取token, 并将此token存入redis, 请求接口时, 将此token放到header或者作为请求参数请求接口, 后端接口判断redis中是否存在此token:
如果存在, 正常处理业务逻辑, 并从redis中删除此token, 那么, 如果是重复请求, 由于token已被删除, 则不能通过校验, 返回请勿重复操作提示
如果不存在, 说明参数不合法或者是重复请求, 返回提示即可。