在实际开发中,不知道大家有没有遇到过 “ 测试人员 ” 疯狂点击提交按钮,结果产生脏数据的问题?最终导致一条数据会产生多条记录,这篇博客主要核心是为了解决这一类事情(BUG)提供一个简单的方案。
--------- 甚至我前端使用 disabled 或者 loading 都没有完全能阻止这类BUG,只能说有效减少部分,所以我就在想如何搞一个简单办法,最好是能通用的方案呢?
任意多次执行所产生的影响均与一次执行的影响相同。
简单理解就是:只做一次(可以做多次,但是效果都是第一次的效果)
为什么叫做简单幂等?因为这里的实现方案并不是完全幂等的实现,只是并发幂等实现,如果需要完全幂等,还需要在具体service操作一些校验, 这里其实方案有点类似 “token机制” 和 “分布式锁”。
主要用到哪些知识点?
@RequestMapping("/simple-idempotent")
public String simpleIdempotent(String params) throws InterruptedException {
// 先从数据库里边查询是否存在该记录
Object object = this.getOne(params);
if(object == null) {
log.info("{}", "请求成功, 正在操作业务");
// 请求成功, 自增
atomicInteger.incrementAndGet();
// 模拟处理业务耗时3秒
Thread.sleep(3000);
// 保存入库
this.save(params);
return "handle success";
} else {
log.info("{}", "数据已经存在, 不执行任何操作");
return "数据已经存在, 不执行任何操作";
}
}
问题描述:
这里存在的问题主要是当有多个线程同时进入 Object object = this.getOne(params); 这行代码的时候, 都是返回null, 因此都会一直往下执行, 最终导致新增多个记录
知道问题出现的原因,那就有解决的思路了,这里主要解决,如果同一个的参数同时(只要这个参数还没处理完成之前进入的)进入这个 simpleIdempotent 方法,那就抛出异常,或者等待
,从而实现 Object object = this.getOne(params); 的 “准确性”。
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @description 简单幂等
* @date 2020-04-18 21:14:11
* @author houyu for.houyu@foxmail.com
*/
@Retention(RetentionPolicy.RUNTIME)
@Target( {ElementType.METHOD})
@Documented
public @interface SimpleIdempotent {
}
import cn.shaines.fastboot.common.core.extra.BusinessException;
import cn.shaines.fastboot.common.core.extra.Constant;
import cn.shaines.fastboot.common.utils.CoreUtil;
import cn.shaines.fastboot.common.utils.JSONUtil;
import cn.shaines.fastboot.common.utils.SpringContextUtil;
import cn.shaines.fastboot.common.utils.validator.AssertUtils;
import java.nio.charset.Charset;
import java.time.Duration;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
/**
* @description 简单幂等切面
* @date 2020-04-18 21:14:11
* @author houyu for.houyu@foxmail.com
*/
@Slf4j
@Aspect
@Component
public class SimpleIdempotentAspect {
private static final String keyPrefix = Constant.appName + ":idempotent:simple:";
@Autowired
private RedisTemplate redisTemplate;
@Pointcut("@annotation(cn.shaines.fastboot.common.core.idempotent.SimpleIdempotent)")
public void requiresIdempotent() {}
@Before("requiresIdempotent()")
public void doBefore(JoinPoint point) {
// MethodSignature methodSignature = (MethodSignature) point.getSignature();
// Method method = methodSignature.getMethod();
// SimpleIdempotent annotation = method.getAnnotation(SimpleIdempotent.class);
Object[] args = point.getArgs();
AssertUtils.check(args == null || args.length == 0, "参数为空");
String jsonString = JSONUtil.toJSONString(args);
String hash = CoreUtil.md5(jsonString.getBytes(Charset.defaultCharset()));
this.check(hash);
}
private void check(String hash) {
String key = keyPrefix + hash;
// 设置初始值, 也可以不设置, 但是建议设置
// (这里设置10秒钟, 也就是说这个请求10秒钟之内如果没有完成, 相同参数的请求也会进来)
Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent(key, 0, Duration.ofSeconds(10));
if(ifAbsent == null || !ifAbsent) {
throw new BusinessException("重复提交");
}
// 自增1
Long increment = redisTemplate.opsForValue().increment(key);
if(increment == null || increment.intValue() != 1) {
// 基于redis线程安全的自增, 保证准确性
throw new BusinessException("重复提交");
}
HttpServletRequest request = SpringContextUtil.getHttpServletRequest();
if(request != null) {
// 基于请求存储一下这个key, 请求完之后删除这个key, 那么相同的参数其他就会可以进来了
request.setAttribute(keyPrefix, key);
}
}
@After("requiresIdempotent()")
public void doAfter(JoinPoint point) {
HttpServletRequest request = SpringContextUtil.getHttpServletRequest();
if(request != null) {
String key = (String) request.getAttribute(keyPrefix);
if(StringUtils.isBlank(key)) {
return;
}
// 删除key
redisTemplate.delete(key);
}
}
}
@SimpleIdempotent // 添加简单幂等注解
@RequestMapping("/simple-idempotent")
public String simpleIdempotent(String params) throws InterruptedException {
// 先从数据库里边查询是否存在该记录
Object object = this.getOne(params);
if(object == null) {
log.info("{}", "请求成功, 正在操作业务");
// 请求成功, 自增
atomicInteger.incrementAndGet();
// 模拟处理业务耗时3秒(3秒内 相同的params都会返回重复提交提示)
Thread.sleep(3000);
// 保存入库
this.save(params);
return "handle success";
} else {
log.info("{}", "数据已经存在, 不执行任何操作");
return "数据已经存在, 不执行任何操作";
}
}
这里测试可以使用压测工具 jmeter 或者 postman 什么的一类工具都可以, 我这里图方便就直接使用代码测试了~~
public static void main(String[] args) {
CountDownLatch runLatch = new CountDownLatch(1);
String url = "http://localhost:10002/test/simple-idempotent?params=xxx";
for(int i = 0; i < 20; i++) {
new Thread(() -> {
try {
runLatch.await();
} catch(InterruptedException e) {
e.printStackTrace();
}
// HttpClient 是我的一个工具类, 不必纠结
Response<String> response = HttpClient.buildHttpClient().buildRequest(url).execute(BodyHandlers.ofString());
System.out.println("==>>>" + response.getBody());
}).start();
}
try {
// 这里简单等待一下创建线程, 当然也可以使用 CountDownLatch 来进行精准等待
Thread.sleep(2000);
} catch(InterruptedException e) {
e.printStackTrace();
}
// 开始执行请求
runLatch.countDown();
}
测试结果
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>handle success
可以看出来,20个并发请求, 19个被拦截掉,只有一个进入controller, 因为进入controller需要3秒执行业务,因此最后才打印出来
相同参数
进入接口,只有一个在处理,其他的被拦截掉),因此并不是完全的控制了幂等,如果需要完全幂等,建议1. 数据库建立唯一约束 2. Service判断记录是否存在