分布式基础 - 接口幂等性

340

偶然看到幂等性设计的概念,今天趁机学学分布式设计中,接口的幂等性是如何设计的~

一、概念

幂等,指的是任意多次执行所产生的影响均与一次执行的影响相同

幂等方法,可以是相同参数重复执行,并能获得相同结果的方法,这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变

1.1 业务 CRUD

其中的读取 Retrieve 和删除 Delete 是天然幂等的,受影响的就是创建 Create 和更新 Update

1.2 HTTP 协议

HTTP 中有各种各样的请求方法,其中幂等的方法有

  • GET
  • HEAD
  • PUT
  • DELETE

非幂等方法有:

  • POST

二、应用

接口的幂等性设计,也有很多业务的应用场景

业务中考虑幂等性的地方一般是接口的重复请求,会有以下几种常见场景导致接口重复被请求:

  • 前端重复提交:提交订单,用户快速重复点击多次,造成后端生成多个内容重复的订单
  • 接口超时重试:对于第三方调用接口,为了防止网络抖动或其他原因导致丢失,接口一般会设计成超时重试多次
  • 消息重复消费:防止 MQ 中的消息被重复消费
  • 金融交易业务:防止支付请求可能被重复发送

三、实现

3.1 前端简单拦截

控制前端的组件可见性来拦截重复提交

  • 防止表单重复提交、不可点击的方案(按钮置灰、隐藏按钮)

3.2 Token 机制

利用 Token + Redis 验证来防止重复提交

  • Client 没有 token,请求 Server,获取 token
  • Server 生成 token,并缓存到 Redis 中,返回 token 给 Client
  • Client 带着 Token(放到报文头),请求 Server 进行业务操作
  • Server 首先验证 Token,在 Redis 查询该 Token 是否存在
    • Redis 有 Token,说明是第一次提交,执行业务,并删除 Token
    • Redis 没 Token,说明是重复提交,不执行业务,返回拒绝请求

可以看到,生成的每一次 Token 的有效期都是一次业务未执行到执行完期间,每一次业务操作对应着一个唯一的 Token

3.3 Redis 缓存唯一序列号

比起 Token,可以直接把业务相关的信息缓存到 Redis 中,报文头部不需要存东西,对于客户端来说更轻量级

例如,一次支付请求可以这么做:

  • Server 首先验证进来的支付请求,检查缓存是否有订单 ID
  • Redis 里面没有订单 ID,缓存支付请求的订单 ID ,然后去做相应的业务操作
    • 无论支付操作成功还是失败,在支付操作结果返回后,在 Redis 里删除该订单 ID 的 Key
  • Redis 里面有订单 ID,直接返回重复请求的提示结果,支付终止

所以,基于 Redis 来说,可以这么存储:

  • Key:唯一序列号,生成唯一的 Key 可以借助雪花算法和自定义的字段组合来生成
  • Value:任何想填的信息,比如订单的相关信息等

例如,对于 KafKa 的重复消费,也可以使用这样的方式来做,那之前,先看看 Kafka 的重复消费是指的什么

KafKa 的消费过程是这样的

  • 生产者生产一个消息,给 Kafka
  • Kafka 格式化生产消息,也就是每个消息会有一个 offset,把数据格式化成 msg | offset 这样的形式
  • 消费者与 ZK 协调,ZK 记录消费者当前消费到的消息的 offset,消费者会从 ZK 指定的 offset 顺序消费
  • 消费者会定期提交一次当前消费的 offset,而不是消费一条就提交一次

那么由于定期提交的延迟性,会导致消费者重复消费的现象,例如:

三个消息,消费者已经已经消费完了,但还没提交当前消费的最新 offset 给 ZK,ZK 记录的消费者下一条 offset 还是 151

如果消费者此时重启,那么重启后还是会跟请求 ZK ,得到最新的 offset,但还是 151 ,那么会重新把 151 ~ 153 offset 的消息再重新消费一次

而如果在消费者消费前增加了 Redis 的消费重复验证,就可以避免重复了,每次消费后,就缓存下最新的 offset 到 Redis,整个 set 指令执行的过程是很快的,于是下一次出现情况,拿到的数据是旧消息,验证不是最新的就丢弃,不消费

3.4 状态机

很多业务是一个状态流转的过程业务,每个状态都有前置状态和后置状态,以及最后的结束状态,例如,流程管理可能有:

  • 待审批
  • 审批中
  • 驳回
  • 重新发起
  • 审批通过
  • 审批拒绝

再例如,订单管理可能有:

  • 待提交
  • 待支付
  • 已支付
  • 取消

如果现在一个支付请求是一个已支付的状态,支付接口又收到了该支付的支付请求,那么就会抛出异常或拒绝此次处理

状态机的设计,可以通过一个枚举来设计:

public enum OrderStatusEnum {

    UN_SUBMIT(0, 0, "待提交"),
    UN_PADING(0, 1, "待支付"),
    PAYED(1, 2, "已支付待发货"),
    DELIVERING(2, 3, "已发货"),
    COMPLETE(3, 4, "已完成"),
    CANCEL(0, 5, "已取消"),
    ;

    //前置状态
    private int preStatus;

    //状态值
    private int status;

    //状态描述
    private String desc;

    OrderStatusEnum(int preStatus, int status, String desc) {
        this.preStatus = preStatus;
        this.status = status;
        this.desc = desc;
    }

    //...
}

四、参考