分布式基础 - 接口幂等性
偶然看到幂等性设计的概念,今天趁机学学分布式设计中,接口的幂等性是如何设计的~
一、概念
幂等,指的是任意多次执行所产生的影响均与一次执行的影响相同
幂等方法,可以是相同参数重复执行,并能获得相同结果的方法,这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变
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;
}
//...
}