关于防重复提交
由于本人从事电商开发工作,项目中面对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; @Autowired private 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"; @PostConstruct public void postConstruct() { staticRedisTemplate = redisTemplate; } public static boolean lock(String lockkey, String certificate, int timeout) { return (boolean) (staticRedisTemplate.execute(new RedisCallback() { @Override public 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() { @Override public 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 { @Autowired private 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”。其余要么返回”请求正在处理中”,要么就是”已经购买过了,无法再次购买”,此时防重复提交已经很好的实现你的功能。
总结与思考
这里做几点思考,切面加锁时间如何考虑?如果请求参数没有用户信息,或者说请求参数不在请求体里面如何处理?比如保存收货地址这种,我们改如何考虑防重?对于创建订单来说,如果页面不返回上一级菜单,再次提交如何返回同一个订单号去拉起支付?下篇接着优化。。敬请期待!