目录
一、什么是幂等性?
二、哪些请求天生就是幂等的?
三、为什么需要幂等
1.超时重试
2.异步回调
3.消息队列
四、实现幂等的关键因素
关键因素1
关键因素2
五、引入幂等性后对系统的影响
六、Restful API 接口的幂等性
实战Aop注解+redis解决幂等性
reidis解决主要流程
添加所需依赖
application简单配置
封装自定义注解
封装统一请求入参对象
Header
Order
编辑RequestData
定义Aop切面
生成token
编辑 启动测试
简单总结
源码
一、什么是幂等性?
简单来说,就是对一个接口执行重复的多次请求,与一次请求所产生的结果是相同的,听起来非常容易理解,但要真正的在系统中要始终保持这个目标,是需要很严谨的设计的,在实际的生产环境下,我们应该保证任何接口都是幂等的
在HTTP/1.1中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
二、哪些请求天生就是幂等的?
首先,我们要知道查询类的请求一般都是天然幂等的,除此之外,删除请求在大多数情况下也是幂等的,但是ABA场景下除外。
举一个简单的例子
比如,先请求了一次删除A的操作,但由于响应超时,又自动请求了一次删除A的操作,如果在两次请求之间,又插入了一次A,而实际上新插入的这一次A,是不应该被删除的,这就是ABA问题,不过,在大多数业务场景中,ABA问题都是可以忽略的。
除了查询和删除之外,还有更新操作,同样的更新操作在大多数场景下也是天然幂等的,其例外是也会存在ABA的问题,更重要的是,比如执行update table set a = a + 1 where v = 1这样的更新就非幂等了。
最后,就还剩插入了,插入大多数情况下都是非幂等的,除非是利用数据库唯一索引来保证数据不会重复产生。
在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题,如:
- 前端重复提交表单:在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
- 用户恶意进行刷单:例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
- 接口超时重复提交:很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
- 消息进行重复消费:当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。
三、为什么需要幂等
1.超时重试
当发起一次RPC请求时,难免会因为网络不稳定而导致请求失败,一般遇到这样的问题我们希望能够重新请求一次,正常情况下没有问题,但有时请求实际上已经发出去了,只是在请求响应时网络异常或者超时,此时,请求方如果再重新发起一次请求,那被请求方就需要保证幂等了。
2.异步回调
异步回调是提升系统接口吞吐量的一种常用方式,很明显,此类接口一定是需要保证幂等性的。
3.消息队列
现在常用的消息队列框架,比如:Kafka、RocketMQ、RabbitMQ在消息传递时都会采取At least once原则(也就是至少一次原则,在消息传递时,不允许丢消息,但是允许有重复的消息),既然消息队列不保证不会出现重复的消息,那消费者自然要保证处理逻辑的幂等性了。
四、实现幂等的关键因素
关键因素1
幂等唯一标识,可以叫它幂等号或者幂等令牌或者全局ID,总之就是客户端与服务端一次请求时的唯一标识,一般情况下由客户端来生成,也可以让第三方来统一分配。
关键因素2
有了唯一标识以后,服务端只需要确保这个唯一标识只被使用一次即可,一种常见的方式就是利用数据库的唯一索引
五、引入幂等性后对系统的影响
幂等性是为了简化客户端逻辑处理,能放置重复提交等操作,但却增加了服务端的逻辑复杂性和成本,其主要是:
- 把并行执行的功能改为串行执行,降低了执行效率。
- 增加了额外控制幂等的业务逻辑,复杂化了业务功能;
所以在使用时候需要考虑是否引入幂等性的必要性,根据实际业务场景具体分析,除了业务上的特殊要求外,一般情况下不需要引入的接口幂等性。
六、Restful API 接口的幂等性
现在流行的 Restful 推荐的几种 HTTP 接口方法中,分别存在幂等行与不能保证幂等的方法,如下:
- √ 满足幂等
- x 不满足幂等
- - 可能满足也可能不满足幂等,根据实际业务逻辑有关
实战Aop注解+redis解决幂等性
reidis解决主要流程
- ① 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。
- ② 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。
- ③ 然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。
- ④ 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。
- ⑤ 客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。
- ⑥ 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。
- ⑦ 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。
注意,在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。
添加所需依赖
<dependencies>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<version>1.8.5</version> <!-- 替换成你需要的版本 -->
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>
<!-- web开发场景启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
application简单配置
封装自定义注解
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(value = ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { /** * 参数名,表示将从哪个参数中获取属性值。 * 获取到的属性值将作为KEY。 * * @return */ String name() default ""; /** * 属性,表示将获取哪个属性的值。 * * @return */ String field() default ""; /** * 参数类型 * * @return */ Class type(); }
封装统一请求入参对象
Header
Order
RequestData
定义Aop切面
import com.example.redisidempotence.annotation.Idempotent;
import com.example.redisidempotence.compent.RedisIdempotentStorage;
import com.example.redisidempotence.vo.RequestData;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.Map;
@Aspect
@Component
public class IdempotentAspect {
@Resource
private RedisIdempotentStorage redisIdempotentStorage;
@Pointcut("@annotation(com.example.redisidempotence.annotation.Idempotent)")
public void idempotent() {
}
@Around("idempotent()")
public Object methodAround(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Idempotent idempotent = method.getAnnotation(Idempotent.class);
String field = idempotent.field();
String name = idempotent.name();
Class clazzType = idempotent.type();
String token = "";
Object object = clazzType.newInstance();
Map<String, Object> paramValue = AopUtils.getParamValue(joinPoint);
if (object instanceof RequestData) {
RequestData idempotentEntity = (RequestData) paramValue.get(name);
token = String.valueOf(AopUtils.getFieldValue(idempotentEntity.getHeader(), field));
}
if (redisIdempotentStorage.delete(token)) {
return joinPoint.proceed();
}
if(token.isEmpty()){
return "token校验失败,token不可为空";
}
return "不能重复请求";
}
}
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.CodeSignature;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public class AopUtils {
public static Object getFieldValue(Object obj, String name) throws Exception {
Field[] fields = obj.getClass().getDeclaredFields();
Object object = null;
for (Field field : fields) {
field.setAccessible(true);
if (field.getName().toUpperCase().equals(name.toUpperCase())) {
object = field.get(obj);
break;
}
}
return object;
}
public static Map<String, Object> getParamValue(ProceedingJoinPoint joinPoint) {
Object[] paramValues = joinPoint.getArgs();
String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
Map<String, Object> param = new HashMap<>(paramNames.length);
for (int i = 0; i < paramNames.length; i++) {
param.put(paramNames[i], paramValues[i]);
}
return param;
}
}
生成token
定义redis服务接口及实现方法
生成token接口
模拟业务请求controller
启动测试
首先拿取到token
将拿取到的token当做header中token传入到模拟的业务请求中
可以看到,将拿取到的token放在模拟真实请求的业务中时,只有第一次请求返回成功,随后的请求都返回"不能重复请求",或者token传空时也返回了相应的token校验信息,从而实现了幂等性
简单总结
幂等性是开发当中很常见也很重要的一个需求,尤其是支付、订单等与金钱挂钩的服务,保证接口幂等性尤其重要。在实际开发中,我们需要针对不同的业务场景我们需要灵活的选择幂等性的实现方式:
- 对于下单等存在唯一主键的,可以使用“唯一主键方案”的方式实现。
- 对于更新订单状态等相关的更新场景操作,使用“乐观锁方案”实现更为简单。
- 对于上下游这种,下游请求上游,上游服务可以使用“下游传递唯一序列号方案”更为合理。
- 类似于前端重复提交、重复下单、没有唯一ID号的场景,可以通过 Token 与 Redis 配合的“防重 Token 方案”实现更为快捷。
以下是各种方法解决幂等性的优劣比较
源码
幂等性源码demo