本文主要来源于 处理重复请求的三种方式
服务端如何高效的处理重复请求
对其整理和总结,用于学习记录。
重复请求常用的处理方式就是幂等性处理,幂等性可以理解为:无论执行了多少次重复请求,数据只会处理一次,在数据库里也只会有一条数据。和数据库的唯一索引是一样的。
方式一:前端处理
优点:前端可在用户点击之后将按钮锁定,不可点击,直到后端返回数据才释放锁。前端处理是最为简单有效,且对用户比较友好的方式。可用提示语提醒用户等待。
缺点:无法应对直接刷后端接口的情况,如果用户直接调后端接口,无法处理(属于特殊情况)。
方式1:ajax防止重复提交
这里我用登录页面功能作为例子讲解,按下面的方法,用户在点击登录后,按钮会不可点击,直到后端返回结果,关键js代码如下:
function login(){
//1.去用户名密码
var user=$("#inputName").val();
var pwd=$("#inputPassword").val();
//2.让提交按钮失效,以实现防止按钮重复点击
$("#loginBtn").attr('disabled', 'disabled');
//3.给用户提供友好状态提示
$("#loginBtn").text('登录中...');
//4.异步提交
$.ajax({
type:"POST",
url:"/verifylogin",
dataType:"json",
data:{
"username":user,
"password":pwd
},
success : function(data) {
//登录成功跳转
},
error:function(){
//5.失败后,登录按钮重新有效
$("#loginBtn").removeAttr('disabled');
alert("登录 发生错误");
}
});
}
方式2:vue按钮防止短时间内多次点击
定义了一个Vue指令,名为preventReClick
它的作用是防止用户在2秒内重复点击某个按钮。
当用户点击按钮时,按钮的disabled属性会被设置为true,并且按钮的背景颜色会被设置为#ccc,边框会被设置为none,鼠标样式也会被设置为not-allowed。
2秒后,按钮会被重置,disabled属性会被设置为false,背景颜色被设置为#002FA8,边框被设置为1px solid #002FA8,鼠标样式也会被设置为pointer。
import Vue from 'vue'
const preventReClick = Vue.directive('preventReClick',{
inserted: function(el, binding, vNode, oldVnode){
el.addEventListener('click', () => {
if(!el.disabled){
el.disabled = true
el.style.backgroundColor = '#ccc'
el.style.border = 'none'
el.style.cursor = 'not-allowed'
setTimeout(() => {
el.disabled = false
el.style.backgroundColor = '#002FA8'
el.style.border = '1px solid #002FA8'
el.style.cursor = 'pointer'
}, 2000)
}
})
}
})
export default {
preventReClick
}
使用方法也很简单
在main.js里引用
//防频繁点击
import preventReClick from '@/utils/directive'
Vue.use( preventReClick )
直接调用即可
<el-button v-preventReClick>确认</el-button>
方式二:后端AOP切面处理或者拦截器处理
后端处理逻辑是需要使用额外的存储数据,记录用户访问接口次数。在重复提交时拦截请求,对于重复的请求直接返回就行,不做任何处理。
方式:自定义注解+AOP
这里我用springboot2+redis+maven 构造示例,请求的思路是将用户访问记录缓存,5s内,请求的记录在redis中有记录时直接抛出异常。
自定义注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface AccessLimit {
//有效时间
int seconds() default 5;
//有效时间内最大计数
int maxCount() default 1;
}
切面方式
/**
* 切面类:实现限流校验
*/
@Aspect
@Component
public class AccessLimitAspect {
@Resource
private RedisTemplate<String, Integer> redisTemplate;
/**
* 这里我们使用注解的形式
* 当然,我们也可以通过切点表达式直接指定需要拦截的package,需要拦截的class 以及 method
*/
@Pointcut("@annotation(com.example.demospringmybatisplus.annotation.AccessLimit)")
public void limitPointCut() {
}
/**
* 环绕通知
*/
@Around("limitPointCut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
// 获取被注解的方法
MethodInvocationProceedingJoinPoint mjp = (MethodInvocationProceedingJoinPoint) pjp;
MethodSignature signature = (MethodSignature) mjp.getSignature();
Method method = signature.getMethod();
// 获取方法上的注解
AccessLimit accessLimit = method.getAnnotation(AccessLimit.class);
if (accessLimit == null) {
// 如果没有注解,则继续调用,不做任何处理
return pjp.proceed();
}
/**
* 代码走到这里,说明有 AccessLimit 注解,那么就需要做限流校验了
* 1、这里可以使用Redis的API做计数校验
* 2、这里也可以使用Lua脚本做计数校验,都可以
*/
//获取request对象
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 受限的redis 缓存key ,因为这里用浏览器做测试,我就用sessionid 来做唯一key,如果是app ,可以使用 用户ID 之类的唯一标识。
String limitKey = request.getServletPath() + request.getSession().getId();
// 从缓存中获取,当前这个请求访问了几次
Integer redisCount = redisTemplate.opsForValue().get(limitKey);
if (redisCount == null) {
//初始 次数
redisTemplate.opsForValue().set(limitKey, 1, accessLimit.seconds(), TimeUnit.SECONDS);
} else {
if (redisCount >= accessLimit.maxCount()) {
throw new RuntimeException("接口阈值超过限制!");
}
// 次数自增
redisTemplate.opsForValue().increment(limitKey);
}
return pjp.proceed();
}
}
拦截器方式
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter {
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
//获取方法中的注解,是否有限制注解
AccessLimit accessLimit = handlerMethod.getMethodAnnotation(AccessLimit.class);
if (Objects.isNull(accessLimit)) {
return true;
}
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
//获取入参
String userId = request.getParameter("userId");
if (Objects.isNull(userId)) {
return false;
}
ValueOperations operations = redisTemplate.opsForValue();
Integer count = (Integer) operations.get(userId);
//第一次访问,userId作为key,value为1
if (count == null) {
operations.set(userId, 1);
redisTemplate.expire(userId, seconds, TimeUnit.SECONDS);
} else if (count < maxCount) {
operations.increment(userId);
} else {
//超出访问次数
throw new Exception("请求过快");
}
}
return true;
}
}
测试接口
@AccessLimit
@GetMapping("/test/accessLimit")
public String testAccessLimit(@RequestParam("userId") String userId) {
return "正常返回";
}
@RequestMapping("findList")
//4秒内只能访问2次
@AccessLimit(maxCount = 2, seconds = 4)
public List<? extends BaseDataVo> findList(@RequestBody SearchVo searchVo, @RequestParam String tableName) {
return dataOperationService.findList(searchVo, tableName);
}
通过重复请求就会抛出异常
方式三:数据库唯一索引
(1) 数据库处理就是设置唯一索引,可设联合唯一索引用来处理重复数据。
缺点:如果业务场景就是应该存储重复的数据,则该种方式不可用。
(2)利用唯一请求编号去重
你可能会想到的是,只要请求有唯一的请求编号,那么就能借用Redis做这个去重——只要这个唯一请求编号在redis存在,证明处理过,那么就认为是重复的。
String KEY = "REQ12343456788";//请求唯一编号
long expireTime = 1000;// 1000毫秒过期,1000ms内的重复请求会认为重复
long expireAt = System.currentTimeMillis() + expireTime;
String val = "expireAt@" + expireAt;
//redis key还存在的话要就认为请求是重复的
Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime), RedisStringCommands.SetOption.SET_IF_ABSENT));
final boolean isConsiderDup;
if (firstSet != null && firstSet) {// 第一次访问
isConsiderDup = false;
} else {// redis值已存在,认为是重复了
isConsiderDup = true;
}
方式四:业务参数去重
很多的场景下,请求并不会带这样的唯一编号,计算请求参数的摘要作为参数标识,剔除部分时间因子,最后对其MD5加密。
请求去重工具类ReqDedupHelper
public class ReqDedupHelper {
/**
*
* @param reqJSON 请求的参数,这里通常是JSON
* @param excludeKeys 请求参数里面要去除哪些字段再求摘要
* @return 去除参数的MD5摘要
*/
public String dedupParamMD5(final String reqJSON, String... excludeKeys) {
String decreptParam = reqJSON;
TreeMap paramTreeMap = JSON.parseObject(decreptParam, TreeMap.class);
if (excludeKeys!=null) {
List<String> dedupExcludeKeys = Arrays.asList(excludeKeys);
if (!dedupExcludeKeys.isEmpty()) {
for (String dedupExcludeKey : dedupExcludeKeys) {
paramTreeMap.remove(dedupExcludeKey);
}
}
}
String paramTreeMapJSON = JSON.toJSONString(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) {
//两个请求一样,但是请求时间差一秒
String req = "{\n" +
"\"requestTime\" :\"20190101120001\",\n" +
"\"requestValue\" :\"1000\",\n" +
"\"requestKey\" :\"key\"\n" +
"}";
String req2 = "{\n" +
"\"requestTime\" :\"20190101120002\",\n" +
"\"requestValue\" :\"1000\",\n" +
"\"requestKey\" :\"key\"\n" +
"}";
//全参数比对,所以两个参数MD5不同
String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req);
String dedupMD52 = new ReqDedupHelper().dedupParamMD5(req2);
System.out.println("req1MD5 = "+ dedupMD5+" , req2MD5="+dedupMD52);
//去除时间参数比对,MD5相同
String dedupMD53 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");
String dedupMD54 = new ReqDedupHelper().dedupParamMD5(req2,"requestTime");
System.out.println("req1MD5 = "+ dedupMD53+" , req2MD5="+dedupMD54);
}
日志输出:
req1MD5 = 9E054D36439EBDD0604C5E65EB5C8267 , req2MD5=A2D20BAC78551C4CA09BEF97FE468A3F
req1MD5 = C2A36FED15128E9E878583CAAAFEFDE9 , req2MD5=C2A36FED15128E9E878583CAAAFEFDE9
日志说明:
一开始两个参数由于requestTime是不同的,所以求去重参数摘要的时候可以发现两个值是不一样的。
第二次调用的时候,去除了requestTime再求摘要(第二个参数中传入了”requestTime”),则发现两个摘要是一样的,符合预期。
完整的业务参数去重解决方案
String userId= "12345678";//用户
String method = "pay";//接口名
String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");//计算请求参数摘要,其中剔除里面请求时间的干扰
String KEY = "dedup:U=" + userId + "M=" + method + "P=" + dedupMD5;
long expireTime = 1000;// 1000毫秒过期,1000ms内的重复请求会认为重复
long expireAt = System.currentTimeMillis() + expireTime;
String val = "expireAt@" + expireAt;
// NOTE:直接SETNX不支持带过期时间,所以设置+过期不是原子操作,极端情况下可能设置了就不过期了,后面相同请求可能会误以为需要去重,所以这里使用底层API,保证SETNX+过期时间是原子操作
Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime),
RedisStringCommands.SetOption.SET_IF_ABSENT));
final boolean isConsiderDup;
if (firstSet != null && firstSet) {
isConsiderDup = false;
} else {
isConsiderDup = true;
}
方式五:自定义starter实现接口或方法限流功能
源码下载地址,欢迎star!
demo-springboot-mybatisplus