前言
接口幂等性的定义是:多次调用接口方法要保证和单次调用的结果一致。
针对开发中主要的增删改查来说,查询和删除操作天然具有幂等性,我们主要关心的就是新增和更新操作。其中更新操作存在幂等性(用户信息修改–重复提交,数据库的值也都是一样的)和非幂等性(商品支付–重复提交,会导致用户账户扣除多笔金额)的情况,要根据具体业务来看。以下内容都是基于新增操作接口来实现接口幂等性。
幂等性的使用场景
在实际项目开发的过程中,以下几种场景会造成非幂等的情况。
1,前端重复提交
这个场景在平时开发工作中非常多见,比如创建用户的时候,前端没有控制创建按钮不能重复点击,导致多次重复请求提交到后端。
2,接口超时重试
我们系统前端发送请求后,可能因为网络问题,请求了5s后还没有收到后端返回的结果,则会进行重试请求,一共进行3次重试,则可能会导致多次重复请求提交到后端。
3,消息重复消费
系统引入MQ后,MQ生产端和消费端都有重试机制,也就是同一消息会被重复消费。
保证幂等性的几种方式
1. 基于唯一索引。
一般是添加业务ID作为唯一索引,如果插入相同业务ID就直接报错。但在实际项目开发中,很多业务表都是逻辑删除,业务ID不能作为唯一索引。在这种情况下想要使用唯一索引的话,需要在数据表中有修改时间或者删除时间的字段,然后使用业务ID和时间字段做联合索引确保唯一性。此方法适用于系统中本身修改删除的业务逻辑中就有时间记录,不需要额外改造逻辑,而且因为是报错只适合作为兜底措施。
2. 基于分布式锁。
分布式锁去实现的话,每次请求过来就,先去尝试获取分布式锁,如果获得成功,就直接执行业务逻辑,获取失败的,就舍弃请求。可以使用redis去实现分布式锁。
3. 基于Token。
一共分为两种方式。
第一步,业务接口请求前,客户端先去请求获取token,服务端生成token返回。
第二步,客户端带着token去请求服务端的业务接口,服务端首先结合Redis进行判断,一共三种情况。
(1)如果Redis存在此token并且value值不是初始值,直接获取Redis中的value值并返回。
(2)如果Redis存在此token并且value值是初始值,则暂停一秒再去获取Redis中的value值,如果发现value值依然是初始值,则抛弃本次请求,否则直接返回。
(3)Redis无此token,保存token与初始化值到Redis中。
如果没有返回结果,则会继续执行接口,将结果存入Redis之前,再去验证Redis中是否存在token与初始值,如果初始值已经被覆盖为了返回值,则通过唯一性索引(如果使用此机制,不会触发该判断,会在重复执行业务时抛出异常)或者触发事务回滚来保证幂等性。
下图这种方式和上图主要区别是客户端生成唯一token,然后接口发起重试请求的时候也是保持同一token,其他地方一致。
具体实现是由AOP切面 + 注解来先实现的,代码如下。
(1)幂等性注解定义
/**
* @author hizoo
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyIdempotent {
//以下属性按需使用,可自行添加
/**
* 超过请求幂等性提示信息
*/
String message() default "请勿重复提交";
/**
* 该请求的唯一key
*/
String key() default "";
/**
* key的过期时间 等于请求最小间隔市场
*/
long expireTime() default 20L;
}
(2)幂等性功能切面逻辑实现
/**
* @author hizoo
*/
@Aspect
@Component
public class MyIdempotentAspect {
@Autowired
private RedisUtils redisUtils;
/**
* 接口首次调用存入的默认值
*/
private String INIT_VALUE = "init";
@Pointcut("@annotation(com.xxx.xxx.annotation.MyIdempotent)")
public void myPoint() {
}
@Around(value = "myPoint()")
public Object apiIdempotentCheck(ProceedingJoinPoint pjp) throws Throwable {
//获取头文件中的uuid作为key(前端生成的)
String token = RequestUtils.getUuid();
//获取接口上的自定义幂等性注解
Signature signature = pjp.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
MyIdempotent annotation = method.getAnnotation(MyIdempotent.class);
//获取接口间隔时间
long expireTime = annotation.expireTime();
//无注解直接放行
if (annotation == null) {
return pjp.proceed();
}
//如果redis存在此token并且value值不是初始值,直接获取redis中的value值返回。
if (redisUtils.hasKey(token) && !INIT_VALUE.equals(redisUtils.get(token))) {
ResultInfo resultInfo = JSON.parseObject(redisUtils.get(token).toString(), ResultInfo.class);
return resultInfo;
}
//如果redis存在此token且是初始值,则暂停一秒再去获取redis中的value值。
else if (redisUtils.hasKey(token) && INIT_VALUE.equals(redisUtils.get(token))) {
//这个休眠时间可以根据项目中接口的实际情况进行设置调整
sleep(1000);
if(redisUtils.hasKey(token) && !INIT_VALUE.equals(redisUtils.get(token))){
ResultInfo resultInfo = JSON.parseObject(redisUtils.get(token).toString(), ResultInfo.class);
return resultInfo;
}
return null;
} else {
redisUtils.set(token, INIT_VALUE, expireTime, TimeUnit.SECONDS);
}
//接口执行
Object proceed = pjp.proceed();
if(redisUtils.hasKey(token) && INIT_VALUE.equals(redisUtils.get(token))) {
redisUtils.set(token, JSON.toJSONString(proceed), expireTime, TimeUnit.SECONDS);
}
else{
throw new OverseasException("数据插入失败!");
}
return proceed;
}
}
(3)新增接口上使用幂等性注解
/**
* 新增
*/
@RequestMapping(method = RequestMethod.POST, path = "/create")
@MyIdempotent
@Transactional
public ResultInfo create(@RequestBody DataInfo dataInfo) {
return dataInfoService.insert(dataInfo);
}
小结
使用接口幂等性处理后,重复数据入库这一问题基本得到解决。但是我这个项目属于老系统改造,无法在所有模块涉及的表都去添加唯一性索引做兜底措施,使用的事务机制。所以在实际项目运行过程中,遇到比较极端的网络波动情况,仍然可能会有重复数据入库。欢迎大家讨论分析,提出更好的解决方案,相互学习,共同进步!