关于防重复提交


 由于本人从事电商开发工作,项目中面对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压测 配置如下

java防止重复请求一个借口 接口防止重复请求_重复提交

java防止重复请求一个借口 接口防止重复请求_java防止重复请求一个借口_02

启动线程组测试

java防止重复请求一个借口 接口防止重复请求_http接口_03

java防止重复请求一个借口 接口防止重复请求_http接口_04

发现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”。其余要么返回”请求正在处理中”,要么就是”已经购买过了,无法再次购买”,此时防重复提交已经很好的实现你的功能。

总结与思考


这里做几点思考,切面加锁时间如何考虑?如果请求参数没有用户信息,或者说请求参数不在请求体里面如何处理?比如保存收货地址这种,我们改如何考虑防重?对于创建订单来说,如果页面不返回上一级菜单,再次提交如何返回同一个订单号去拉起支付?下篇接着优化。。敬请期待!