点击上方 IT牧场 ,选择 置顶或者星标
技术干货每日送达
关于防重复提交
由于本人从事电商开发工作,项目中面对C端用户或多或少都会接触到提交保存或者修改的请求,例如创建订单,物流包裹签收,团员通知自提消息发送,这些接口因为涉及到数据库的保存或者修改,如果不做防重复提交,那么数据库要么增加无用的数据,或者出现错误的逻辑,要么消息重复发送造成用户骚扰这些不良后果。所以通用的防重复非常有必要,拦掉无效操作,也能避免程序出现错误。
关于防重复提交可以在前端做,也可以在后台做。本人从事后台开发工作,所以这里只讲后台如何防重复提交。
重复提交的定义
同一用户,同样参数,针对同一个http接口发起操作,由于网络抖动、弱网、用户误点,或者后端请求处理慢等造成服务端在同一时间收到用户的多次请求。
针对上述定义,我们可以有目的的设计我们的拦截规则。话不多说,下面撸实现,并配合jmeter来压测观察防重效果。
案例展示
项目环境准备springboot+redis来演示。
首先我们展示如果没有重复提交,demo会出现的问题。
@RestControllerpublic class OrderController{private Map map = new HashMap<>();@GetMapping("/createOrder/{userId}/{num}")public String createOrder(@PathVariable Integer userId,@PathVariable Integer num) throws InterruptedException {//这里模拟用户限购 假设每个用户只能买一个if (num > 1) {return "每个用户只能购买一份";}//通过map来模拟读库是否购买过if (map.get(userId) != null) {return "已经购买过了,无法再次购买";}//模拟创建订单逻辑 方便观察效果map.put(userId, num);return "创建订单成功,订单号为:" + new Random().nextInt(10000);}}
启动项目 jmeter压测 配置如下
启动线程组测试
发现10个并发请求,第三和第四个请求都返回了订单号,其余都返回的是”已经购买过了,无法再次购买”说明在并发情况下,逻辑出现了问题,并不能保证对同一用户限购。
防重复提交逻辑开发
定义如下注解,如果有小伙伴们不知道怎么定义注解,这种请自行百度或者谷歌吧
@Target({ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface PreventDuplicateSubmission {}
为配合注解使用,我们可以自定义切面来实现防重逻辑
首先实现个工具类 redis单机分布式锁
@Componentpublic class RedisTemplateUtil {public static RedisTemplate staticRedisTemplate;@Autowiredprivate RedisTemplate redisTemplate;/*** 删除分布式锁lua脚本*/private final static String unlockScript = "if tostring(redis.call('get', KEYS[1])) == tostring(ARGV[1]) " +"then return redis.call('del', KEYS[1]) else return 0 end";@PostConstructpublic void postConstruct() {staticRedisTemplate = redisTemplate;}public static boolean lock(String lockkey, String certificate, int timeout) {return (boolean) (staticRedisTemplate.execute(new RedisCallback() {@Overridepublic Object doInRedis(RedisConnection connection) throws DataAccessException {Jedis jedis = (Jedis) connection.getNativeConnection();String result = jedis.set(lockkey, JSON.toJSONString(certificate), "nx", "ex", timeout);return "OK".equals(result) ? true : false;}}));}public static void unlock(String lockkey, String certificate) {staticRedisTemplate.execute(new RedisCallback() {@Overridepublic Object doInRedis(RedisConnection connection) throws DataAccessException {Jedis jedis = (Jedis) connection.getNativeConnection();return jedis.eval(unlockScript, Collections.singletonList(lockkey),Collections.singletonList(JSON.toJSONString(certificate)));}});}}
再来实现切面逻辑
@Aspect@Componentpublic class PreventDuplicateSubmissionAspect {@Autowiredprivate HttpServletRequest request;@Pointcut("execution (* com.iyd.demoapp.controller..*.*(..)) && @annotation(preventDuplicateSubmission)")public void preventDuplicateSubmissionAspectPointcut(PreventDuplicateSubmission preventDuplicateSubmission) {}@Around(value = "preventDuplicateSubmissionAspectPointcut(preventDuplicateSubmission)")public Object preventDuplicateSubmissionAspectAround(ProceedingJoinPoint invocation,PreventDuplicateSubmission preventDuplicateSubmission) throws Throwable {//获取切面传入参数 这里是http请求参数Object[] args = invocation.getArgs();//同一路径String lockKeyParam = request.getRequestURI();if (args != null) {List list = Arrays.asList(args);JSONArray jsonArray = new JSONArray(list);//同一参数 由于demo中请求的参数包含userId信息 这里同一参数中已经包含同一用户// 这里如果有自己的用户信息可以自己拼装来保证同一用户lockKeyParam += jsonArray.toJSONString();}String lockkey = SecureUtil.md5(lockKeyParam);String certificate = UUID.randomUUID().toString();boolean lock = RedisTemplateUtil.lock(lockkey, certificate, 120);if (!lock) {//没有获取到锁 请求正在处理中return "请求正在处理中";}try {return invocation.proceed();} finally {RedisTemplateUtil.unlock(lockkey, certificate);}}}
最后是在需要防重复提交的方法上加上注解
@RestControllerpublic class OrderController{private Map map = new HashMap<>();@PreventDuplicateSubmission@GetMapping("/createOrder/{userId}/{num}")public String createOrder(@PathVariable Integer userId,@PathVariable Integer num) throws InterruptedException {//这里模拟用户限购 假设每个用户只能买一个if (num > 1) {return "每个用户只能购买一份";}//通过map来模拟读库是否购买过if (map.get(userId) != null) {return "已经购买过了,无法再次购买";}//模拟创建订单逻辑 方便观察效果map.put(userId, num);return "创建订单成功,订单号为:" + new Random().nextInt(10000);}}
此时我们用jmeter来压测,不管你怎么压测,对于这个demo来说,只能有一个请求返回”创建订单成功,订单号XXX”。其余要么返回”请求正在处理中”,要么就是”已经购买过了,无法再次购买”,此时防重复提交已经很好的实现你的功能。
总结与思考
这里做几点思考,切面加锁时间如何考虑?如果请求参数没有用户信息,或者说请求参数不在请求体里面如何处理?比如保存收货地址这种,我们改如何考虑防重?对于创建订单来说,如果页面不返回上一级菜单,再次提交如何返回同一个订单号去拉起支付?下篇接着优化。。敬请期待!
干货分享
最近将个人学习笔记整理成册,使用PDF分享。关注我,回复如下代码,即可获得百度盘地址,无套路领取!
•001:《Java并发与高并发解决方案》学习笔记;•002:《深入JVM内核——原理、诊断与优化》学习笔记;•003:《Java面试宝典》•004:《Docker开源书》•005:《Kubernetes开源书》•006:《DDD速成(领域驱动设计速成)》•007:全部•008:加技术群讨论
近期热文
关注我
喜欢就点个"在看"呗^_^