龙空技术网

如何防止订单重复提交

思考与人生同在 440

前言:

今天大家对“f5刷新页面不闪”大体比较关心,你们都想要知道一些“f5刷新页面不闪”的相关内容。那么小编同时在网上网罗了一些关于“f5刷新页面不闪””的相关内容,希望各位老铁们能喜欢,你们一起来了解一下吧!

目录:1. 概念 初识重复提交

2.产生重复提交的原因

3.解题思路

前端

后端

4.落地

概念

订单重复提交即指在第一次请求已经成功的情况下,人为的进行多次操作,导致不满足幂等要求的服务多次改变状态。

这里提到了幂等性,那么什么是接口的幂等性呢。

幂等是指多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致

产生重复提交的原因

由于重复点击或者网络重发

eg:

点击提交按钮多次;点击刷新按钮;使用浏览器后退按钮重复之前的操作,导致重复提交表单;使用浏览器历史记录重复提交表单;浏览器重复的HTTP请求;nginx重发等情况;分布式RPC的try重发等;

主要有 2 个部分:

前端用户操作网络请求可能存在重试

当然也不排除一些用户的恶意操作。

解题思路

前端

方案一:禁用按钮提交

设置标志位,提交之后禁止按钮。像一些短信验证码的按钮一般都会加一个前端的按钮禁用,毕竟发短信是需要钞票滴~

ps: 以前写前端就用过这种方式。

优点

简单。基本可以防止重复点击提交按钮造成的重复提交问题。

缺陷

前进后退操作,或者F5刷新页面等问题并不能得到解决。

最重要的一点,前端的代码只能防止不懂js的用户,如果碰到懂得js的编程人员,那js方法就没用了。

方案二:设置HTTP报头

设置HTTP报头,控制表单缓存,使得所控制的表单不缓存信息,这样用户就无法通过重复点击按钮去重复提交表单。

<meta http-equiv="Cache-Control" content="no-cache, must-revalidate">

但是这样做也有局限性,用户在提交页面点击刷新也会造成表单的重复提交。

方案三:通过 PRG 设计模式

用来防止F5刷新重复提交表单。

PRG模式通过响应页面Header返回HTTP状态码进行页面跳转替代响应页面跳转过程。

具体过程如下:

客户端用POST方法请求服务器端数据变更,服务器对客户端发来的请求进行处理重定向到另一个结果页面上,客户端所有对页面的显示请求都用get方法告知服务器端,这样做,后退再前进或刷新的行为都发出的是get请求,不会对server产生任何数据更改的影响。

这种方法实现起来相对比较简单,但此方法也不能防止所有情况。例如用户多次点击提交按钮;恶意用户避开客户端预防多次提交手段,进行重复提交请求;

后端

幂等性

如果是注册或存入数据库的操作,可以通过在数据库中字段设立唯一标识来解决,这样在进行数据库插入操作时,因为每次插入的数据都相同,数据库会拒绝写入。这样也避免了向数据库中写入垃圾数据的情况,同时也解决了表单重复提交问题。但是这种方法在业务逻辑上感觉是说不过去的,本来该有的逻辑,却因为数据库该有的设计隐藏了。而且这种方法也有一定的功能局限性,只适用于某系特定的插入操作。

实现方式

这种操作,都需要有一个唯一标识。数据库中做唯一索引约束,重复插入直接报错。

缺点

有很大的约束性。一般都是最后的一道防线,当请求走到数据库层的时候,一般已经消耗了较多的资源。

session 方法

Java 使用Token令牌防止表单重复提交的步骤:

在服务器端生成一个唯一的随机标识号,专业术语称为Token(令牌),同时在当前用户的Session域中保存这个Token。将Token发送到客户端的Form表单中,在Form表单中使用隐藏域来存储这个Token,表单提交的时候连同这个Token一起提交到服务器端。在服务器端判断客户端提交上来的Token与服务器端生成的Token是否一致,如果不一致,那就是重复提交了,此时服务器端就可以不处理重复提交的表单。如果相同则处理表单提交,处理完后清除当前用户的Session域中存储的标识号。

下面的场景将拒绝处理用户提交的表单请求

存储Session域中的Token(令牌)与表单提交的Token(令牌)不同。当前用户的Session中不存在Token(令牌)。

这里的 session 按照单机和分布式,可以使用 redis/mysql 等解决分布式的问题。

这种方法算是比较经典的解决方案,但是需要前后端的配合。

下面来介绍通过加锁的方式,实现纯后台修改的实现。

为什么要设置一个隐藏域?

这个问题我一开始没想明白,我认为,进入页面的时候设置一个session并且再token设值,添加的时候把这个值删掉。然后这样我们再按F5的时候就没办法重复提交了(因为这个时候判断session为空)。我觉得这样就ok了,设置hidden域感觉没任何必要。

然而简直是图样图森破,对于一般用户这样当然是可以的。

但是对于恶意用户呢?假如恶意用户开两个浏览器窗口(同一浏览器的窗口共用一个session)这样窗口1提交完,系统删掉session,窗口1停留着,他打开第二个窗口进入这个页面,系统又为他们添加了一个session,这个时候窗口1按下F5,那么直接重复提交!

所以,我们必须得用hidden隐藏一个uuid的token,并且在后台比较它是否与session中的值一致,只有这样才能保证F5是不可能被重复提交的!

直接加锁

为了避免短时间的重复提交,直接使用加锁的方式。

优点:不需要前端配合修改,纯后端。

缺点:无法像 token 方法,准确的限定为单次。只能限制固定时间内的操作一次。

个人理解:前端的方式依然是防君子不防小人。直接通过限制固定时间内无法操作,来限制重复提交。这个时间不能太长,也不能太短,一般建议为 10S 左右,根据自己的真实业务调整。锁也是同样的道理,token 其实也可以理解为一种特殊的锁。锁同样可以分为单机锁+分布式的锁。

落地

关于Redis 分布式锁

1)不了解的同学戳这里 ==> Redis分布式锁的正确实现方式。

2)使用Redis 是为了在负载均衡部署,如果是单机的部署的项目可以使用一个线程安全的本地Cache 替代 Redis。

Code

这里只贴出 AOP 类和测试类,完整代码:Github,Gitee

@Aspect@Componentpublic class RepeatSubmitAspect {     private final static Logger LOGGER = LoggerFactory.getLogger(RepeatSubmitAspect.class);     @Autowired    private RedisLock redisLock;     @Pointcut("@annotation(noRepeatSubmit)")    public void pointCut(NoRepeatSubmit noRepeatSubmit) {    }     @Around("pointCut(noRepeatSubmit)")    public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {        int lockSeconds = noRepeatSubmit.lockTime();         HttpServletRequest request = RequestUtils.getRequest();        Assert.notNull(request, "request can not null");         // 此处可以用token或者JSessionId        String token = request.getHeader("Authorization");        String path = request.getServletPath();        String key = getKey(token, path);        String clientId = getClientId();         boolean isSuccess = redisLock.tryLock(key, clientId, lockSeconds);         if (isSuccess) {            LOGGER.info("tryLock success, key = [{}], clientId = [{}]", key, clientId);            // 获取锁成功, 执行进程            Object result;            try {                result = pjp.proceed();             } finally {                // 解锁                redisLock.releaseLock(key, clientId);                LOGGER.info("releaseLock success, key = [{}], clientId = [{}]", key, clientId);             }             return result;         } else {            // 获取锁失败,认为是重复提交的请求            LOGGER.info("tryLock fail, key = [{}]", key);            return new ResultBean(ResultBean.FAIL, "重复请求,请稍后再试", null);        }     }     private String getKey(String token, String path) {        return token + path;    }     private String getClientId() {        return UUID.randomUUID().toString();    } }

多线程测试

测试代码如下,模拟十个请求并发同时提交

@Componentpublic class RunTest implements ApplicationRunner {     private static final Logger LOGGER = LoggerFactory.getLogger(RunTest.class);     @Autowired    private RestTemplate restTemplate;     @Override    public void run(ApplicationArguments args) throws Exception {        System.out.println("执行多线程测试");        String url=";;        CountDownLatch countDownLatch = new CountDownLatch(1);        ExecutorService executorService = Executors.newFixedThreadPool(10);         for(int i=0; i<10; i++){            String userId = "userId" + i;            HttpEntity request = buildRequest(userId);            executorService.submit(() -> {                try {                    countDownLatch.await();                    System.out.println("Thread:"+Thread.currentThread().getName()+", time:"+System.currentTimeMillis());                    ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);                    System.out.println("Thread:"+Thread.currentThread().getName() + "," + response.getBody());                 } catch (InterruptedException e) {                    e.printStackTrace();                }            });        }         countDownLatch.countDown();    }     private HttpEntity buildRequest(String userId) {        HttpHeaders headers = new HttpHeaders();        headers.setContentType(MediaType.APPLICATION_JSON);        headers.set("Authorization", "yourToken");        Map<String, Object> body = new HashMap<>();        body.put("userId", userId);        return new HttpEntity<>(body, headers);    } }

成功防止重复提交,控制台日志如下,可以看到十个线程的启动时间几乎同时发起,只有一个请求提交成功了。

参考资料:

标签: #f5刷新页面不闪 #java如何防止订单重复提交 #java防止接口重复调用