目录
- 自定义注解实现接口幂等性方式
- 背景
- 常见场景
- 常见解决方案
- 实现方法
- 代码实现
- 思考与不足
- SpringBoot接口幂等性设计
- MVC方案
- 基于Token方式防止API接口幂等
- 实战教程
自定义注解实现接口幂等性方式
近期需要对接口进行幂等性的改造,特此记录下。
背景
在微服务架构中,幂等是一致性方面的一个重要概念。
一个幂等操作的特点是指其多次执行所产生的影响均与一次执行的影响相同。在业务中也就是指的,多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。
常见场景
1.用户重复操作
在产品订购下单过程中,由于网络延迟或者用户误操作等原因,导致多次提交。这时就会在后台执行多条重复请求,导致脏数据或执行错误等。
2.分布式消息重复消费
消息队列中由于某种原因消息二次发送或者被二次消费的时候,导致程序多次执行,从而导致数据重复,资源冲突等。
3.接口超时重试
由于网络波动,引起的重复请求,导致数据的重复等。
常见解决方案
1.token机制实现
由客户端发送请求获取Token,服务端生成全局唯一的ID作为token,并保存在redis中,同时返回ID给客户端。
客户端调用业务端的请求的时候需要携带token,由服务端进行校验,校验成功,则允许执行业务,不成功则表示重复操作,直接返回给客户端。
2.mysql去重
建立一个去重表,当客户端请求的时候,将请求信息存入去重表进行判断。由于去重表带有唯一索引,如果插入成功则表示可以执行。如果失败则表示已经执行过当前请求,直接返回。
3.基于redis锁机制
在redis中,SETNX表示 SET IF NOT EXIST的缩写,表示只有不存在的时候才可以进行设置,可以利用它实现锁的效果。
客户端请求服务端时,通过计算拿到代表这次业务请求的唯一字段,将该值存入redis,如果设置成功表示可以执行。失败则表示已经执行过当前请求,直接返回。
实现方法
基于种种考虑,本文将基于方法3实现幂等性方法。其中有两个需要注意的地方:
1.如何实现唯一请求编号进行去重?
本文将采用用户ID:接口名:请求参数进行请求参数的MD5摘要,同时考虑到请求时间参数的干扰性(同一个请求,除了请求参数都相同可以认为为同一次请求),排除请求时间参数进行摘要,可以在短时间内保证唯一的请求编号。
2.如何保证最小的代码侵入性?
本文将采用自定义注解,同时采用切面AOP的方式,最大化的减少代码的侵入,同时保证了方法的易用性。
代码实现
1.自定义注解
实现自定义注解,同时设置超时时间作为重复间隔时间。在需要使用幂等性校验的方法上面加上注解即可实现幂等性。
import java.lang.annotation.ElementType; | |
import java.lang.annotation.Retention; | |
import java.lang.annotation.RetentionPolicy; | |
import java.lang.annotation.Target; | |
/** | |
* @create 2021-01-18 16:40 | |
* 实现接口幂等性注解 | |
**/ | |
public AutoIdempotent { | |
long expireTime() default 10000; | |
} |
2.MD5摘要辅助类
通过传入的参数进行MD5摘要,同时去除需要排除的干扰参数生成唯一的请求ID。
import com.google.gson.Gson; | |
import com.hhu.consumerdemo.model.User; | |
import lombok.extern.slf4j.Slf4j; | |
import javax.xml.bind.DatatypeConverter; | |
import java.security.MessageDigest; | |
import java.util.*; | |
/** | |
* @create 2021-01-14 10:12 | |
**/ | |
public class ReqDedupHelper { | |
private Gson gson = new Gson(); | |
/** | |
* | |
* @param reqJSON 请求的参数,这里通常是JSON | |
* @param excludeKeys 请求参数里面要去除哪些字段再求摘要 | |
* @return 去除参数的MD5摘要 | |
*/ | |
public String dedupParamMD5(final String reqJSON, String... excludeKeys) { | |
String decreptParam = reqJSON; | |
TreeMap paramTreeMap = gson.fromJson(decreptParam, TreeMap.class); | |
if (excludeKeys!=null) { | |
List<String> dedupExcludeKeys = Arrays.asList(excludeKeys); | |
if (!dedupExcludeKeys.isEmpty()) { | |
for (String dedupExcludeKey : dedupExcludeKeys) { | |
paramTreeMap.remove(dedupExcludeKey); | |
} | |
} | |
} | |
String paramTreeMapJSON = gson.toJson(paramTreeMap); | |
String md5deDupParam = jdkMD5(paramTreeMapJSON); | |
log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5deDupParam, Arrays.deepToString(excludeKeys), paramTreeMapJSON); | |
return md5deDupParam; | |
} | |
private static String jdkMD5(String src) { | |
String res = null; | |
try { | |
MessageDigest messageDigest = MessageDigest.getInstance("MD5"); | |
byte[] mdBytes = messageDigest.digest(src.getBytes()); | |
res = DatatypeConverter.printHexBinary(mdBytes); | |
} catch (Exception e) { | |
log.error("",e); | |
} | |
return res; | |
} | |
//测试方法 | |
public static void main(String[] args) { | |
Gson gson = new Gson(); | |
User user1 = new User("1","2",18); | |
Object[] objects = new Object[]{"sss",11,user1}; | |
Map<String, Object> maps = new HashMap<>(); | |
maps.put("参数1",objects[0]); | |
maps.put("参数2",objects[1]); | |
maps.put("参数3",objects[2]); | |
String json1 = gson.toJson(maps); | |
System.out.println(json1); | |
TreeMap paramTreeMap = gson.fromJson(json1, TreeMap.class); | |
System.out.println(gson.toJson(paramTreeMap)); | |
} | |
} |
3.redis辅助Service
生成唯一的请求ID作为token存入redis,同时设置好超时时间,在超时时间内的请求参数将作为重复请求返回,而校验成功插入redis的请求Token将作为首次请求,进行放通。
本文采用的spring-redis版本为2.0以上,使用2.0以下版本的需要主要没有setIfAbsent方法,需要自己实现。
import com.xxx.xxx.utils.ReqDedupHelper; | |
import lombok.extern.slf4j.Slf4j; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.data.redis.core.StringRedisTemplate; | |
import org.springframework.stereotype.Service; | |
import java.util.concurrent.TimeUnit; | |
/** | |
* @create 2021-01-18 17:44 | |
**/ | |
public class TokenService { | |
private static final String TOKEN_NAME = "request_token"; | |
private StringRedisTemplate stringRedisTemplate; | |
public boolean checkRequest(String userId, String methodName, long expireTime, String reqJsonParam, String... excludeKeys){ | |
final boolean isConsiderDup; | |
String dedupMD5 = new ReqDedupHelper().dedupParamMD5(reqJsonParam, excludeKeys); | |
String redisKey = "dedup:U="+userId+ "M="+methodName+"P="+dedupMD5; | |
log.info("redisKey:{}", redisKey); | |
long expireAt = System.currentTimeMillis() + expireTime; | |
String val = "expireAt@" + expireAt; | |
// NOTE:直接SETNX不支持带过期时间,所以设置+过期不是原子操作,极端情况下可能设置了就不过期了 | |
if (stringRedisTemplate.opsForValue().setIfAbsent(redisKey, val)) { | |
if (stringRedisTemplate.expire(redisKey, expireTime, TimeUnit.MILLISECONDS)) { | |
isConsiderDup = false; | |
} else { | |
isConsiderDup = true; | |
} | |
} else { | |
log.info("加锁失败 failed!!key:{},value:{}",redisKey,val); | |
return true; | |
} | |
return isConsiderDup; | |
} | |
} |
4.AOP切面辅助类
aop切面,切住所有带有幂等注解的方法。进行幂等性的操作。
import com.google.gson.Gson; | |
import com.xxx.xxx.annotation.AutoIdempotent; | |
import com.xxx.xxx.service.TokenService; | |
import lombok.extern.slf4j.Slf4j; | |
import org.aspectj.lang.ProceedingJoinPoint; | |
import org.aspectj.lang.annotation.*; | |
import org.aspectj.lang.reflect.MethodSignature; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.stereotype.Component; | |
import java.util.HashMap; | |
import java.util.Map; | |
/** | |
* @author: | |
* @date: 2020-04-28 14:20 | |
*/ | |
public class AutoIdempontentHandler { | |
private Gson gson = new Gson(); | |
private static final String excludeKey = ""; | |
private static final String methodName = "methodName"; | |
private TokenService tokenService; | |
public void autoIdempontentHandler() { | |
} | |
public void doBefore() throws Throwable { | |
log.info("idempontentHandler..doBefore()"); | |
} | |
public Object doAround(ProceedingJoinPoint joinpoint) throws Throwable { | |
boolean checkres = this.handleRequest(joinpoint); | |
if(checkres){ | |
//重复请求,提示重复 报错 | |
log.info("重复性请求.."); | |
throw new Exception(); | |
} | |
return joinpoint.proceed(); | |
} | |
private Boolean handleRequest(ProceedingJoinPoint joinpoint) { | |
Boolean result = false; | |
log.info("========判断是否是重复请求======="); | |
MethodSignature methodSignature = (MethodSignature) joinpoint.getSignature(); | |
//获取自定义注解值 | |
AutoIdempotent autoIdempotent = methodSignature.getMethod().getDeclaredAnnotation(AutoIdempotent.class); | |
long expireTime = autoIdempotent.expireTime(); | |
// 获取参数名称 | |
String methodsName = methodSignature.getMethod().getName(); | |
String[] params = methodSignature.getParameterNames(); | |
//获取参数值 | |
Object[] args = joinpoint.getArgs(); | |
Map<String, Object> reqMaps = new HashMap<>(); | |
for(int i=0; i<params.length; i++){ | |
reqMaps.put(params[i], args[i]); | |
} | |
String reqJSON = gson.toJson(reqMaps); | |
result = tokenService.checkRequest("user1", methodsName, expireTime, reqJSON, excludeKey); | |
return result; | |
} | |
public void doAfter(Object retVal) throws Throwable { | |
log.debug("{}", retVal); | |
} | |
} |
5.注解的使用
在需要幂等性的方法上进行注解,同时设置参数保证各个接口的超时时间的不一致性。可以看到在5秒内是无法再次请求方法1的。
import com.xxx.xxx.annotation.AutoIdempotent; | |
import org.springframework.beans.factory.annotation.Value; | |
import org.springframework.web.bind.annotation.GetMapping; | |
import org.springframework.web.bind.annotation.PathVariable; | |
import org.springframework.web.bind.annotation.RestController; | |
/** | |
* @author | |
* @Date: 2020-01-03 14:16 | |
*/ | |
public class ConsumerController { | |
public String setValue( String index){ | |
return index + "1"; | |
} | |
public String setValue2( String index){ | |
return index + "2"; | |
} | |
} |
思考与不足
微服务架构中,幂等操作的特点是指任意多次执行所产生的影响均与一次执行的影响相同。但在实际设计的时候,却简单的进行所有请求进行重复。
然而,重试是降低微服务失败率的重要手段。因为网络波动、系统资源的分配不确定等因素会导致部分请求的失败。而这部分的请求中大部分实际上只需要进行简单的重试就可以保证成功。这才是幂等性真正需要实现的。暂时我并没有更好的解决方法,只能通过短时间的禁用,以及人为的决定何种方法进行幂等性校验来达到目的。欢迎有想法的和我一起探讨交流~
SpringBoot接口幂等性设计
MVC方案
多版本并发控制,该策略主要使用 update with condition(更新带条件来防止)来保证多次外部请求调用对系统的影响是一致的。在系统设计的过程中,合理的使用乐观锁,通过 version 或者 updateTime(timestamp)等其他条件,来做乐观锁的判断条件,这样保证更新操作即使在并发的情况下,也不会有太大的问题。
例如
select * from tablename where condition=#condition# // 取出要跟新的对象,带有版本 versoin | |
update tableName set name=#name#,version=version+1 where version=#version# |
在更新的过程中利用 version 来防止,其他操作对对象的并发更新,导致更新丢失。为了避免失败,通常需要一定的重试机制。
Token机制,防止页面重复提交
业务要求:页面的数据只能被点击提交一次。
发生原因:由于重复点击或者网络重发,或者 nginx 重发等情况会导致数据被重复提交
解决办法:
集群环境:采用 token 加 redis(redis 单线程的,处理需要排队)
单 JVM 环境:采用 token 加 redis 或 token 加 jvm 内存
处理流程:
数据提交前要向服务的申请 token,token 放到 redis 或 jvm 内存,token 有效时间
提交后后台校验 token,同时删除 token,生成新的 token 返回
token 特点:要申请,一次有效性,可以限流
基于Token方式防止API接口幂等
客户端每次在调用接口的时候,需要在请求头中,传递令牌参数,每次令牌只能用一次。
一旦使用之后,就会被删除,这样可以有效防止重复提交。
步骤:
- 生成令牌接口
- 接口中获取令牌验证
实战教程
要用到aop跟Redis , 所以在pom中添加
<!-- Redis-Jedis --> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-data-redis</artifactId> | |
</dependency> | |
<!-- springboot-aop 技术 --> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-aop</artifactId> | |
</dependency> |
这是通过注解实现接口幂等性,先写Redis逻辑
public class BaseRedisService { | |
private StringRedisTemplate stringRedisTemplate; | |
public void setString(String key, Object data, Long timeout) { | |
if (data instanceof String) { | |
String value = (String) data; | |
// 往Redis存值 | |
stringRedisTemplate.opsForValue().set(key, value); | |
} | |
if (timeout != null) { | |
// 带时间缓存 | |
stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS); | |
} | |
} | |
/** | |
* 查看是否有值 | |
* @param key 值 | |
* @return | |
*/ | |
public Object getString(String key) { | |
return stringRedisTemplate.opsForValue().get(key); | |
} | |
/** | |
* 删除Redis | |
* @param key 值 | |
*/ | |
public void delKey(String key) { | |
stringRedisTemplate.delete(key); | |
} | |
} |
然后写怎么生成token,保证每个token只用一次
public class RedisToken { | |
private BaseRedisService baseRedisService; | |
/** 缓存指定时间200秒 */ | |
private static final long TOKENTIMEOUT = 200; | |
/** | |
* 生成Token | |
*/ | |
public String getToken(){ | |
String token = UUID.randomUUID().toString(); | |
// 将token放到Redis中,用UUID保证唯一性 | |
baseRedisService.setString(token, token, TOKENTIMEOUT); | |
return token; | |
} | |
public synchronized boolean findToken(String tokenKey) { | |
String tokenValue = (String) baseRedisService.getString(tokenKey); | |
// 如果能够获取该(从redis获取令牌)令牌(将当前令牌删除掉) 就直接执行该访问的业务逻辑 | |
if (StringUtils.isEmpty(tokenValue)) { | |
return false; | |
} | |
// 保证每个接口对应的token 只能访问一次,保证接口幂等性问题,用完直接删掉 | |
baseRedisService.delKey(tokenValue); | |
return true; | |
} | |
} |
写一个工具类 请求是通过http请求还是from提交过来的,大部分都是form提交来的
public interface ConstantUtils { | |
/** | |
* http 中携带的请求 | |
*/ | |
static final String EXTAPIHEAD = "head"; | |
/** | |
* from 中提交的请求 | |
*/ | |
static final String EXTAPIFROM = "from"; | |
} |
写好了 现在就写我们的注解了,没带参数的是前后端不分离,直接跳页面,获取到token,带参数前后端不分离的
- 带参数的
@Target(value = ElementType.METHOD) | |
@Retention(RetentionPolicy.RUNTIME) | |
public @interface ExtApiIdempotent { | |
String value(); | |
} |
- 不带参数的
@Target(value = ElementType.METHOD) | |
@Retention(RetentionPolicy.RUNTIME) | |
public @interface ExtApiToken { | |
} |
写好这个 要是aop切点,要把注解切入进去
public class ExtApiAopIdempotent { | |
private RedisToken redisToken; | |
// 1.使用AOP环绕通知拦截所有访问(controller) | |
public void rlAop() { | |
} | |
/** | |
* 封装数据 | |
*/ | |
public HttpServletRequest getRequest() { | |
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); | |
HttpServletRequest request = attributes.getRequest(); | |
return request; | |
} | |
/** | |
* 前置通知 | |
*/ | |
public void before(JoinPoint point) { | |
// 获取被增强的方法相关信息 - 查看方法上是否有次注解 | |
MethodSignature signature = (MethodSignature) point.getSignature(); | |
ExtApiToken extApiToken = signature.getMethod().getDeclaredAnnotation(ExtApiToken.class); | |
if (extApiToken != null) { | |
// 可以放入到AOP代码 前置通知 | |
getRequest().setAttribute("token", redisToken.getToken()); | |
} | |
} | |
/** | |
* 环绕通知 | |
*/ | |
public Object doBefore(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { | |
// 获取被增强的方法相关信息 - 查看方法上是否有次注解 | |
MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature(); | |
ExtApiIdempotent declaredAnnotation = methodSignature.getMethod().getDeclaredAnnotation(ExtApiIdempotent.class); | |
if (declaredAnnotation != null) { | |
String values = declaredAnnotation.value(); | |
String token = null; | |
HttpServletRequest request = getRequest(); | |
if (values.equals(ConstantUtils.EXTAPIHEAD)) { | |
token = request.getHeader("token"); | |
} else { | |
token = request.getParameter("token"); | |
} | |
// 获取不到token | |
if (StringUtils.isEmpty(token)) { | |
return ResultTool.error(ExceptionNume.PARAMETER_ERROR); | |
} | |
// 接口获取对应的令牌,如果能够获取该(从redis获取令牌)令牌(将当前令牌删除掉) 就直接执行该访问的业务逻辑 | |
boolean isToken = redisToken.findToken(token); | |
// 接口获取对应的令牌,如果获取不到该令牌 直接返回请勿重复提交 | |
if (!isToken) { | |
return ResultTool.error(ExceptionNume.REPEATED_SUBMISSION); | |
} | |
} | |
Object proceed = proceedingJoinPoint.proceed(); | |
return proceed; | |
} | |
} |
controller层 大家可以测一下
private OrderInfoDAO infoDAO; | |
private RedisToken token; | |
// @Autowired | |
// private RedisTokenUtils redisTokenUtils; | |
// | |
// 从redis中获取Token | |
"/redisToken") | (|
public String getRedisToken() { | |
return token.getToken(); | |
} | |
"/addOrderExtApiIdempotent") | (|
ConstantUtils.EXTAPIFROM) | (|
public ResultBO<?> addOrderExtApiIdempotent( | |
String orderName, | |
String orderDes | |
) { | |
int result = infoDAO.addOrderInfo(orderName, orderDes); | |
return ResultTool.success(result); | |
} |
保证了只能请求一次。前后端没有分离的,@ExtApiToken带上注解会自动吧token携带过去