一、什么是幂等性
幂等性是系统服务对外一种承诺,承诺只要调用接口成功,外部多次调用对系统的影响是一致的。声明为幂等的服务会认为外部调用失败是常态,并且失败之后必然会有重试。
二、接口幂等性
对于同一笔支付信息如果我其中某一次处理成功了,我虽然又接收到了消息,但是这时我不处理了,即保证接口的 幂等性。
维基百科上的定义:
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
**在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。**幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“setTrue()”函数就是一个幂等函数,无论多次执行,其结果都是一样的,更复杂的操作幂等保证是利用唯一交易号(流水号)实现.
任意多次执行所产生的影响均与一次执行的影响相同,这是幂等性的核心特点。其实在我们编程中主要操作就是CURD,其中读取(Retrieve)操作和删除(Delete)操作是天然幂等的,受影响的就是创建(Create)、更新(Update)。
三、幂等性场景
对于一些业务场景影响比较大的,接口的幂等性是个必须要考虑的问题,例如金钱的交易方面的接口。否则一个错误的、考虑不周的接口可能会给公司带来巨额的金钱损失
- 前端重复提交:提交订单,用户快速重复点击多次,造成后端生成多个内容重复的订单。
- 接口超时重试:对于给第三方调用的接口,为了防止网络抖动或其他原因造成请求丢失,这样的接口一般都会设计成超时重试多次。
- 消息重复消费:MQ消息中间件,消息重复消费。
- **网络波动:因网络波动,可能会引起重复请求
- **用户重复操作:**用户在使用产品时,可能会误操作而触发多笔交易,或者因为长时间没有响应,而有意触发多笔交易。
- **未关闭的重试机制:**技术人员人为的错误,因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制
四、“天然”的幂等和需要“人工”的幂等
4.1 CRUD分析
CRUD是指在做计算处理时的[增加](Create)、读取(Read)、更新(Update)和删除(Delete)几个单词的首字母简写。主要被用在描述软件系统中数据库或者持久层的基本操作功能。
操作 | 幂等性 |
---|---|
新增类请求(C) | 数据库自增主键,不具备幂等性 |
查询类动作(R) | 重复查询不会产生或变更新的数据,因此查询是天然具备幂等性 |
基于主键的计算式更新(U) | 不具备幂等性,即:UPDATE goods SET number=number-1 WHERE id=1 |
基于主键的非计算式更新(U) | 具备幂等性,即:UPDATE goods SET number=newNumber WHERE id=1 |
基于条件查询的更新(U) | 不一定具有幂等性(需要根据实际情况进行分析判断) |
基于主建的删除(D) | 具备幂等性 |
业务层面都是逻辑删除(即Update操作)(U) | 不具备幂等性 |
4.2 HTTP方法分析
按照restful规范定义的接口,使用http方法,应该严格遵循http方法语义:
方法 | 幂等性 | 对应CRUD操作 |
---|---|---|
POST | 不安全且不幂等 | C |
GET | 安全且幂等 | R |
PUT | 不安全但幂等 | U |
DELETE | 不安全但幂等 | D |
4.3 “天然”的幂等
GET,PUT,DELETE都是幂等操作,而POST不是,以下进行分析:
-
首先GET请求很好理解,对资源做查询多次,此实现的结果都是一样的。
PUT请求的幂等性可以这样理解,将A修改为B,它第一次请求值变为了B,再进行多次此操作,最终的结果还是B,与一次执行的结果是一样的,即属 -
CURD中所说的基于主键的非计算式更新,所以PUT是幂等操作。
-
同理可以理解DELETE操作,第一次将资源删除后,后面多次进行此删除请求,最终结果是一样的,将资源删除掉了。
4.4 需要“人工”的幂等
POST不是幂等操作,因为一次请求添加一份新资源,二次请求则添加了两份新资源,多次请求会产生不同的结果,因此POST不是幂等操作。
以SQL为例:
-
SELECT col1 FROM tab1 WHER col2=2,无论执行多少次都不会改变状态,是天然的幂等。
-
UPDATE tab1 SET col1=1 WHERE col2=2,无论执行成功多少次状态都是一致的,因此也是幂等操作。
-
UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次执行的结果都会发生变化,这种不是幂等的。
-
insert into user(userid,name) values(1,‘a’) 如userid为唯一主键,即重复操作上面的业务,只会插入一条用户数据,具备幂等性。
-
如userid不是主键,可以重复,那上面业务多次操作,数据都会新增多条,不具备幂等性。
-
delete from user where userid=1,多次操作,结果一样,具备幂等性
五、幂等性实现方式
对于和web端交互的接口,我们可以在前端拦截一部分,例如防止表单重复提交,按钮置灰、隐藏、不可点击等方式。
但是前端做控制实际效益不是很高,懂点技术的都会模拟请求调用你的服务,所以安全的策略还是需要从后端的接口层来做。
那么后端要实现分布式接口的幂等性有哪些策略方式呢?主要可以从以下几个方面来考虑实现:
5.1 Token机制
针对前端重复连续多次点击的情况,例如用户购物提交订单,提交订单的接口就可以通过 Token 的机制实现防止重复提交。
主要流程就是:
- 服务端提供了发送token的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取token,服务器会把token保存到redis中。(微服务肯定是分布式了,如果单机就适用jvm缓存)。
- 然后调用业务接口请求时,把token携带过去,一般放在请求头部。
- 服务器判断token是否存在redis中,存在表示第一次请求,这时把redis中的token删除,继续执行业务。
- 如果判断token不存在redis中,就表示是重复操作,直接返回重复标记给client,这样就保证了业务代码,不被重复执行。
5.2 数据库去重表
往去重表里插入数据的时候,利用数据库的唯一索引特性,保证唯一的逻辑。唯一序列号可以是一个字段,例如订单的订单号,也可以是多字段的唯一性组合。例如设计如下的数据库表。
CREATE TABLE `t_idempotent` (
`id` int(11) NOT NULL COMMENT 'ID',
`serial_no` varchar(255) NOT NULL COMMENT '唯一序列号',
`source_type` varchar(255) NOT NULL COMMENT '资源类型',
`status` int(4) DEFAULT NULL COMMENT '状态',
`remark` varchar(255) NOT NULL COMMENT '备注',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`modify_by` bigint(20) DEFAULT NULL COMMENT '修改人',
`modify_time` datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`)
UNIQUE KEY `key_s` (`serial_no`,`source_type`, `remark`) COMMENT '保证业务唯一性'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='幂等性校验表';
我们注意看如下这几个关键性字段,
- serial_no:唯一序列号的值,在这里我设置的是通过注解
@IdempotentKey
来标识请求对象中的字段,通过对他们 MD5 加密获取对应的值。 - source_type:业务类型,区分不同的业务,订单,支付等。
- remark:是由标识字段的拼接成的字符串,拼接符为 “|”。
由于数据建立了 serial_no
,source_type
, remark
三个字段组合构成的唯一索引,所以可以通过这个来去重达到接口的幂等性,具体的代码设计如下,
public class PaymentOrderReq {
/**
* 支付宝流水号
*/
@IdempotentKey(order=1)
private String alipayNo;
/**
* 支付订单ID
*/
@IdempotentKey(order=2)
private String paymentOrderNo;
/**
* 支付金额
*/
private Long amount;
}
因为支付宝流水号和订单号在系统中是唯一的,所以唯一序列号可由他们组合 MD5 生成,具体的生成方式如下:
private void getIdempotentKeys(Object keySource, Idempotent idempotent) {
TreeMap<Integer, Object> keyMap = new TreeMap<Integer, Object>();
for (Field field : keySource.getClass().getDeclaredFields()) {
if (field.isAnnotationPresent(IdempotentKey.class)) {
try {
field.setAccessible(true);
keyMap.put(field.getAnnotation(IdempotentKey.class).order(),
field.get(keySource));
} catch (IllegalArgumentException | IllegalAccessException e) {
logger.error("", e);
return;
}
}
}
generateIdempotentKey(idempotent, keyMap.values().toArray());
}
生成幂等Key,如果有多个key可以通过分隔符 “|” 连接,
private void generateIdempotentKey(Idempotent idempotent, Object... keyObj) {
if (keyObj.length == 0) {
logger.info("idempotentkey is empty,{}", keyObj);
return;
}
StringBuilder serialNo= new StringBuilder();
for (Object key : keyObj) {
serialNo.append(key.toString()).append("|");
}
idempotent.setRemark(serialNo.toString());
idempotent.setSerialNo(md5(serialNo));
}
一切准备就绪,则可对外提供幂等性校验的接口方法,接口方法为:
public <T> void idempotentCheck(IdempotentTypeEnum idempotentType, T keyObj) throws IdempotentException {
Idempotent idempotent = new Idempotent();
getIdempotentKeys(keyObj, idempotent );
if (StringUtils.isBlank(idempotent.getSerialNo())) {
throw new ServiceException("fail to get idempotentkey");
}
idempotentEvent.setSourceType(idempotentType.name());
try {
idempotentMapper.saveIdempotent(idempotent);
} catch (DuplicateKeyException e) {
logger.error("idempotent check fail", e);
throw new IdempotentException(idempotent);
}
}
当然这个接口的方法具体在项目中合理的使用就看项目要求了,可以通过@Autowire
注解注入到需要使用的地方,但是缺点就是每个地方都需要调用。我个人推荐的是自定义一个注解,在需要幂等性保证的接口上加上该注解,然后通过拦截器方法拦截使用。这样简单便不会造成代码侵入和污染。
另外,使用数据库防重表的方式它有个严重的缺点,那就是系统容错性不高,如果幂等表所在的数据库连接异常或所在的服务器异常,则会导致整个系统幂等性校验出问题。如果做数据库备份来防止这种情况,又需要额外忙碌一通了啊。
5.3 Redis实现
上面介绍过防重表的设计方式和伪代码,也说过它的一个很明显的缺点。所以我们另外介绍一个Redis的实现方式。
Redis实现的方式就是将唯一序列号作为Key,唯一序列号的生成方式和上面介绍的防重表的一样,value可以是你想填的任何信息。唯一序列号也可以是一个字段,例如订单的订单号,也可以是多字段的唯一性组合。当然这里需要设置一个 key 的过期时间,否则 Redis 中会存在过多的 key。具体校验流程如下图所示,实现代码也很简单这里就不写了。
5.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;
}
//...
}
假设当前状态是已支付,这时候如果支付接口又接收到了支付请求,则会抛异常或拒绝此次处理。