解决表单重复提交问题的8种解决方案

愿天堂没有BUG

共 19005字,需浏览 39分钟

 ·

2021-12-13 13:26

提出问题: 解决表单重复提交

一 前置知识

1 HTTP是无状态的超文本传输协议,是用于从万维网服务器传输超文本到本地浏览器的传输协议,HTTP是在TCP/IP协议模型上的应用层的一种传输协议
2 查看HTTP请求报文
HTTP请求报文由3部分组成: 请求行+请求头+请求体

POST /user HTTP/1.1                       // 请求行
Host: www.user.com
Content-Type: application/x-www-form-urlencoded
Connection: Keep-Alive
User-agent: Mozilla/5.0. // 以上是请求头

name=world // 请求体(可选,如get请求时可选)
复制代码

请求行中包含了请求方法,比如上面例子中请求行的POST
3 HTTP协议中的9种方法(其中HTTP1.0定义了三种请求方法:GET, POST 和 HEAD方法,HTTP1.1新增了五种请求方法:OPTIONS, PUT, DELETE, TRACE 和 CONNECT 方法)

OPTIONS: OPTIONS请求与HEAD类似,一般也是用于客户端查看服务器的性能。这个方法会请求服务器返回该资源所支持的所有HTTP请求方法,该方法会用'*'来代替资源名称,向服务器发送OPTIONS请求,可以测试服务器功能是否正常。

HEAD: HEAD方法与GET方法一样,都是向服务器发出指定资源的请求。但是,服务器在响应HEAD请求时不会回传资源的内容部分,即:响应主体。这样,我们可以不传输全部内容的情况下,就可以获取服务器的响应头信息。HEAD方法常被用于客户端查看服务器的性能。

GET: GET请求会显示请求指定的资源。一般来说GET方法应该只用于数据的读取,而不应当用于会产生副作用的非幂等的操作中。它期望的应该是而且应该是安全的和幂等的。这里的安全指的是,请求不会影响到资源的状态。

POST: POST请求会 向指定资源提交数据,请求服务器进行处理,如:表单数据提交、文件上传等,请求数据会被包含在请求体中。POST方法是非幂等的方法,因为这个请求可能会创建新的资源或/和修改现有资源。

PUT/PATCH: PUT请求会身向指定资源位置上传其最新内容,PUT方法是幂等的方法。通过该方法客户端可以将指定资源的最新数据传送给服务器取代指定的资源的内容。

PATCH是对PUT方法的补充,用来对已知资源进行局部更新

二者的不同点:
1.PATCH一般用于资源的部分更新,而PUT一般用于资源的整体更新。
2.当资源不存在时,PATCH会创建一个新的资源,而PUT只会对已在资源进行更新。
3.PUT 是幂等的,PATCH是非幂等的
4.PATCH方法出现的较晚,它在2010年的RFC 5789标准中被定义。\

DELETE: 请求服务器删除请求的URI所标识的资源,用于删除

TRACE: TRACE请求服务器回显其收到的请求信息,该方法主要用于HTTP请求的测试或诊断

CONNECT: CONNECT方法是HTTP/1.1协议预留的,能够将连接改为管道方式的代理服务器。通常用于SSL加密服务器的链接与非加密的HTTP代理服务器的通信。\

我们看看维基百科对幂等的解释:
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。所以,对于编辑表单的请求,我们使用PUT,可以不用做任何保护操作,即多次重复提交也不会对系统造成任何改变,这个时候可能会有杠精说:我是用POST请求后台接口,然后使用update的SQL更新数据库不也是一样的吗? 根据REST规范接口:每个资源都有对应的URI,不同的HTTP Method对应的对资源不同的操作,GET(读取资源信息)、POST(添加资源)、PUT(更新资源信息)、DELETE(删除资源)。几乎所有的计算机语言都可以通过HTTP协议同REST服务器通信。所以POST请求最好只是用来添加资源,PUT请求用来更新资源信息。

二 解决方法

1 确保按钮只能点击一次

如用户点击查询或提交订单号,按钮变灰或页面显示loding状态(例如展示例如遮罩层等组件)专用于防止用户重复点击。

2 在Session存放唯一标识

用户进入页面时,服务端生成一个唯一的标识值,存到session中,同时将它写入表单的隐藏域中,用户在输入信息后点击提交,在服务端获取表单的隐藏域字段的值来与session中的唯一标识值进行比较,相等则说明是首次提交,就处理本次请求,然后删除session唯一标识,不相等则标识重复提交,忽略本次处理。

3 缓存队列

将请求快速的接收下来,放入缓冲队列中,后续使用异步任务处理队列的数据,过滤掉重复请求,我们可以用LinkedList来实现队列,一个HashSet来实现去重。此方法优点是异步处理、高吞吐,但是不能及时返回请求结果,需要后续轮询处理结果。

4 token+redis

这种方式分成两个阶段:获取token和业务操作阶段。

以支付为例:
第一阶段,在进入到提交订单页面之前,需要在订单系统根据当前用户信息向支付系统发起一次申请token请求,支付系统将token保存到redis中,作为第二阶段支付使用 第二阶段,前端订单系统拿着申请到的token发起支付请求,第一时间删除redis中的token,支付系统会检查redis中是否存在该token,如果有,表示第一次请求支付,开始处理支付逻辑,处理完成后删除redis中的token 当重复请求时候,检查redis中token是否存在,若不存在,则为重复请求

5 基于乐观锁来实现

如果更新已有数据,可以进行加锁更新,也可以设计表结构时使用version来做乐观锁,这样既能保证执行效率,又能保证幂等。乐观锁version字段在更新业务数据时值要自增。

sql为:update table set version = version + 1 where id =1 and version =#{version }

6 Axios拦截器

Axios的介绍: axios 是一个轻量的 HTTP客户端

基于 XMLHttpRequest 服务来执行 HTTP 请求,支持丰富的配置,支持 Promise,支持浏览器端和 Node.js 端。自Vue2.0起,尤大宣布取消对 vue-resource 的官方推荐,转而推荐 axios。现在 axios 已经成为大部分 Vue 开发者的首选

特性:

1 从浏览器中创建 XMLHttpRequests
2 从 node.js 创建 http请求
3 支持 Promise API
4 拦截请求和响应
5 转换请求数据和响应数据
6 取消请求
7 自动转换JSON 数据
8 客户端支持防御XSRF
复制代码

注意这个特性6取消请求:

6.1 基本使用

//安装
npm install axios --S
//导入
import axios from 'axios'
//封装Axios
//利用node环境变量来作判断,用来区分开发、测试、生产环境
if (process.env.NODE_ENV === 'development') {
axios.defaults.baseURL = 'http://dev.xxx.com'
} else if (process.env.NODE_ENV === 'production') {
axios.defaults.baseURL = 'http://prod.xxx.com'
}
复制代码

6.2 创建如下文件夹

6.3 在lib目录下创建axios.js文件:

/* eslint-disable */
import axios from "axios";
import { baseURL } from "@/config";
import md5 from "js-md5";
// 网络请求记录map结构
let pending = {};
//取消请求
let CancelToken = axios.CancelToken;
class HttpRequest {
constructor(baseUrl = baseURL) {
this.baseUrl = baseUrl;
this.queue = {};
}
getInsideConfig(auth) {
var config = {
baseURL: this.baseUrl,
headers: {
Authorization: auth
}
};
return config;
}
distory(url) {
delete this.queue[url];
if (!Object.keys(this.queue).length) {
//Spin.hide()
}
}
interceptors(instance, url) {
instance.interceptors.request.use(
config => {
//检查json数据中是否包含repetitiveRequestLimit属性,若包含,则为此请求添加幂等校验
if(config.data.hasOwnProperty("repetitiveRequestLimit")){
let key = md5(`${config.url}&${config.method}&${JSON.stringify(config.data)}`);
config.cancelToken = new CancelToken(c => {
if (pending[key]) {
if (Date.now() - pending[key] > 5000) {
// 超过5s,删除对应的请求记录,重新发起请求
delete pending[key];
} else {
// 5s以内的已发起请求,取消重复请求
c("repeated");
}
}
});
// 记录当前的请求,已存在则更新时间戳
pending[key] = Date.now();
}else{
console.log('我是没有repetitiveRequestLimit的请求')
}
return config;
},
error => {
return Promise.reject(error);
}
);
instance.interceptors.response.use(
res => {
this.distory(url);
var { data } = res;
return data;
},
error => {
// 错误的请求结果处理,这里的代码根据后台的状态码来决定错误的输出信息
if (error && error.response) {
switch (error.response.status) {
case 400:
error.message = "错误请求";
break;
case 401:
error.message = "未授权,请重新登录";
break;
case 403:
error.message = "拒绝访问";
break;
case 404:
error.message = "请求错误,未找到该资源";
break;
case 405:
error.message = "请求方法未允许";
break;
case 408:
error.message = "请求超时";
break;
case 500:
error.message = "服务器端出错";
break;
case 501:
error.message = "网络未实现";
break;
case 502:
error.message = "网络错误";
break;
case 503:
error.message = "服务不可用";
break;
case 504:
error.message = "网络超时";
break;
case 505:
error.message = "http版本不支持该请求";
break;
default:
error.message = `连接错误${error.response.status}`;
}
} else {
error.message = "连接到服务器失败";
}
return Promise.reject(error.message);
}
);
}
request(options) {
var instance = axios.create();
options = Object.assign(this.getInsideConfig(localStorage.getItem("Authorization")), options);
this.interceptors(instance, options.url);
return instance(options);
}
}
export default HttpRequest;
复制代码

6.4 config/index.js

//这里可以根据node环境来设置后台Url
//利用node环境变量来作判断,用来区分开发、测试、生产环境
/* eslint-disable */
export var baseURL = process.env.NODE_ENV === 'development'?' http://localhost:8080':' http://localhost:8081'
复制代码

6.5 api/baseIndex.js

/* eslint-disable */
import HttpRequest from "@/lib/axios";
var axios = new HttpRequest();
export default axios;
复制代码

6.6 api/requestdemo1

/* eslint-disable */
import axios from './baseIndex'
//原生redis实现分布式锁测试
export var getRedisLock = (object) => {
return axios.request({
url: "/demo1/testRedisLock",
method: "post",
data:object
});
};
//redisson分布式锁测试
export var getRedissonLock = (object) => {
return axios.request({
url: "/demo1/testRedisson",
method: "post",
data:object
});
};
复制代码

6.7 vue页面引入





复制代码

6.8 测试

7 Redis分布式锁

锁我们都知道,在程序中的作用就是同步工具,保证共享资源在同一时刻只能被一个线程访问,Java中的锁我们都很熟悉了,像synchronized 、Lock都是我们经常使用的,但是Java的锁只能保证单机的时候有效,分布式集群环境就无能为力了,而对于解决表单重复提交这个问题的后台解决方案,我们就可以使用到分布式锁。

分布式锁需要满足的特性有这么几点:

1、互斥性:在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁

2、高可用性:在分布式场景下,一小部分服务器宕机不影响正常使用,这种情况就需要将提供分布式锁的服务以集群的方式部署

3、防止锁超时:如果客户端没有主动释放锁,服务器会在一段时间之后自动释放锁,防止客户端宕机或者网络不可达时产生死锁

4、独占性:加锁解锁必须由同一台服务器进行,也就是锁的持有者才可以释放锁,不能出现你加的锁,别人给你解锁了

7.1 那么Redis分布式锁的本质是什么呢?

介绍两个Redis获得锁的指令(这两个指令包含的获取锁和设置过期时间这两个操作是原子操作):

1  SETNX:意思是 SET if Not exists , 用法是:SETEX key seconds value

2  PSETEX:用法是:PSETEX key milliseconds value

(这个命令和SETEX命令相似,但它以毫秒为单位设置 key 的生存时间,而不是像SETEX命令那样,以秒为单位)

Redis获取锁的最常见写法:

从Redis 2.6.12 版本开始,SET命令可以通过参数来实现和SETNX、SETEX、PSETEX 三个命令相同的效果

SET key value NX EX seconds:加上NX、EX参数后,效果就相当于SETEX

例子:

可以根据当前登陆人的id和请求的uri作为锁的名字,当把key为lock的值设置为"Java"后,再设置成别的值就会失败,即获得锁返回1,未获得锁返回0
所以这条命令体现了锁的互斥性,即在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁,设置锁的超时时间还做到了防止锁超时

那么问题来了,锁的value值真的可以像上面那边设置的很随意嘛?

7.2 value的值如何设置?

答案: 应该独特唯一,这样就实现了分布式锁的独占性

如果value值不唯一可能会出现如下请求?

1.服务器1获取锁成功
2.服务器1在某个操作上阻塞了太长时间
3.设置的key过期了,锁自动释放了
4.服务器2获取到了对应同一个资源的锁
5.服务器1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉服务器2持有的锁,这样就会造成问题

设置value的方法如下:
方法1:UUID

String uuid = UUID.randomUUID().toString();
复制代码

方法2:当前线程id

String id = Thread.currentThread().getId() + "";
复制代码

方法3:分布式雪花算法id生成器

参考:基于Snowflake算法的分布式ID生成器
码云: https://gitee.com/yu120/neural
复制代码

7.3 如何保证Redis锁高可用呢?

高可用的大概定义是: “高可用性”(High Availability)通常来描述一个系统经过专门的设计,从而减少停工时间,而保持其服务的高度可用性,即在分布式场景下,一小部分服务器宕机不影响正常使用。

不推荐:  Redis 单副本

不推荐原因如下:如果redis是单master模式的,当这台机宕机的时候,那么所有的客户端都获取不到锁了

推荐:Redis 多副本(主从), Redis Sentinel(哨兵), Redis Cluster

推荐原因: 为了提高可用性,假设部署主从架构的redis,1个master加1个slave,因为redis的主从同步是异步进行的,可能会出现客户端1设置完锁后,master挂掉,原来master中的数据都会转移到原来的slave中,然后slave提升为master,这样就不会丢失锁。

7.4 Demo实践

7.4.1 项目中引入Jedis客户端



org.springframework.boot
spring-boot-starter-data-redis


io.lettuce
lettuce-core





redis.clients
jedis

复制代码

Redis分布式锁工具类:

/**

  • @description:

  • @author: geekAntony

  • @create: 2021-01-17 16:52

**/

public class RedisLockUtil {

// key的持有时间,5ms
private long EXPIRE_TIME = 5;

// 等待超时时间,1s
private long TIME_OUT = 1000;

// redis命令参数,相当于nx和px的命令合集
private SetParams params = SetParams.setParams().nx().px(EXPIRE_TIME);

// redis连接池,连的是本地的redis客户端
JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);

/**
* 加锁
*
* @param value
* 线程的id,或者其他可识别当前线程且不重复的字段
* @return
*/
public boolean lock(String key,String value) {
Long start = System.currentTimeMillis();
Jedis jedis = jedisPool.getResource();
try {
for (;;) {
// SET命令返回OK ,则证明获取锁成功
String lock = jedis.set(key, value, params);
if ("OK".equals(lock)) {
return true;
}
// 否则循环等待,在TIME_OUT时间内仍未获取到锁,则获取失败
long l = System.currentTimeMillis() - start;
if (l >= TIME_OUT) {
return false;
}
try {
// 休眠一会,不然反复执行循环会一直失败
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally {
jedis.close();
}
}

/**
* 解锁
*
* @param value
* 线程的id,或者其他可识别当前线程且不重复的字段
* @return
*/
public boolean unlock(String key,String value) {
Jedis jedis = jedisPool.getResource();
// 删除key的lua脚本
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then" + " return redis.call('del',KEYS[1]) " + "else"
+ " return 0 " + "end";
try {
String result =
jedis.eval(script, Collections.singletonList(key), Collections.singletonList(value)).toString();
return "1".equals(result);
} finally {
jedis.close();
}
}
}
复制代码

前端控制器:

/**
* @program: structure
* @description:
* @author: geekAntony
* @create: 2021-01-19 22:59
**/
@RestController
@RequestMapping("/demo1")
public class TestRedisLock {

private static RedisLockUtil demo = new RedisLockUtil();

@PostMapping(value = "/testRedisLock")
public String add(@RequestBody Person person) {
String id = Thread.currentThread().getId() + "";
boolean isLock = demo.lock("redislockName",id);
try {
//拿到锁的话执行业务操作...
if (isLock) {
//模拟3s业务操作
TimeUnit.SECONDS.sleep(3);
}else{
return "请不要重复发送表单请求";
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 在finally中释放锁
demo.unlock("redislockName",id);
}
return "完成业务逻辑";
}
}
复制代码

测试:

8 使用Redisson分布式锁

引入Redisson依赖:

 
org.redisson
redisson
3.13.4

复制代码

application.yml:

#Redis 配置
spring:
redis:
host: 127.0.0.1
port: 6379
database: 1
password:
timeout: 10000
jedis:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
#自定义分布式 Redis 客户端 Redisson 配置
redisson:
type: stand-alone #redis服务器部署类型,stand-alone:单机部署、cluster:机器部署.默认为单机部署
address: redis://127.0.0.1:6379 #单机时必须是redis://开头.
database: 1
复制代码

基础信息配置类:

@Data //lombok
@ConfigurationProperties(prefix = "redisson")
public class RedssionProperties {

/**
* redis服务器部署类型。
* stand-alone:单机部署
* cluster:集群部署.
*/
private String type = "stand-alone";
/**
* Redis 服务器地址
*/
private String address;
/**
* 用于Redis连接的数据库索引
*/
private int database = 0;
/**
* Redis身份验证的密码,如果不需要,则应为null
*/
private String password;
/**
* Redis最小空闲连接量
*/
private int connectionMinimumIdleSize = 24;
/**
* Redis连接最大池大小
*/
private int connectionPoolSize = 64;
/**
* Redis 服务器响应超时时间,Redis 命令成功发送后开始倒计时(毫秒)
*/
private int timeout = 3000;
/**
* 连接到 Redis 服务器时超时时间(毫秒)
*/
private int connectTimeout = 10000;
}
复制代码

Redisson配置类

@Configuration
@EnableConfigurationProperties(RedssionProperties.class)
public class RedissonConfig {

private final RedssionProperties redssionProperties;

/**
* 从 Spring 容器中获取 {@link RedssionProperties}实例
*/
public RedissonConfig(RedssionProperties redssionProperties) {
this.redssionProperties = redssionProperties;
}


/**
* redis 服务器单机部署时,创建 RedissonClient 实例,交由 Spring 容器管理
* 只有当配置了 redisson.type=stand-alone 时,才继续生成 RedissonClient 实例并交由 Spring 容器管理
*
* @return
*/
@Bean
@ConditionalOnProperty(prefix = "redisson", name = "type", havingValue = "stand-alone")
public RedissonClient redissonClient() {
/**
* Config:Redisson 配置基类:
* SingleServerConfig:单机部署配置类,MasterSlaveServersConfig:主从复制部署配置
* SentinelServersConfig:哨兵模式配置,ClusterServersConfig:集群部署配置类。
* useSingleServer():初始化 redis 单服务器配置。即 redis 服务器单机部署
* setAddress(String address):设置 redis 服务器地址。格式 -- redis://主机:端口,不写时,默认为 redis://127.0.0.1:6379
* setDatabase(int database): 设置连接的 redis 数据库,默认为 0
* setPassword(String password):设置 redis 服务器认证密码,没有时设置为 null,默认为 null
* RedissonClient create(Config config): 使用提供的配置创建同步/异步 Redisson 实例
* Redisson 类实现了 RedissonClient 接口,真正需要使用的就是这两个 API
*/
Config config = new Config();
config.useSingleServer()
.setAddress(redssionProperties.getAddress())
.setDatabase(redssionProperties.getDatabase())
.setPassword(redssionProperties.getPassword())
.setConnectionPoolSize(redssionProperties.getConnectionPoolSize())
.setConnectionMinimumIdleSize(redssionProperties.getConnectionMinimumIdleSize())
.setTimeout(redssionProperties.getTimeout())
.setConnectTimeout(redssionProperties.getConnectTimeout());
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
复制代码

测试:

具体详细前端代码上文可见:






复制代码

后台控制器:

@RestController
@RequestMapping("/demo1")
public class TestRedisLock {

@Autowired
private RedissonClient redissonClient;

private static Logger logger = LoggerFactory.getLogger(TestRedisLock.class);
/**
* RedissonClient.getLock(String name):可重入锁
* boolean tryLock(long waitTime, long leaseTime, TimeUnit unit):尝试获取锁
* 1、waitTime:获取锁时的等待时间,超时自动放弃,线程不再继续阻塞,方法返回 false
* 2、leaseTime:获取到锁后,指定加锁的时间,超时后自动解锁
* 3、如果成功获取锁,则返回 true,否则返回 false。
*/
@PostMapping(value = "/testRedisson")
public String addDemo1(@RequestBody Person person) {
String result = "订单[" + person.getOrderNumber() + "]支付成功.";
//这里可以加入登录用户的id等数据
String key = person.getOrderNumber();
/**
* getLock(String name):按名称返回锁实例,实现了一个非公平的可重入锁,因此不能保证线程获得顺序
* lock():获取锁,如果锁不可用,则当前线程将处于休眠状态,直到获得锁为止
*/
RLock lock = redissonClient.getLock(key);
boolean tryLock = false;
try {
//waitTime是尝试加锁时间,最多等待1s,上锁60s以后自动解锁
tryLock = lock.tryLock(1, 60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
//上锁失败,则会进入此if
if (!tryLock) {
return "订单[" + person.getOrderNumber() + "]正在支付中,请耐心等待!";
}
try {
logger.info("查询支付状态");
TimeUnit.SECONDS.sleep(1);
logger.info("正在支付订单[" + person.getOrderNumber() + "]");
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
result = "订单号xxx [" + person.getOrderNumber() + "]支付失败:" + e.getMessage();
} finally {
/**
* boolean isLocked():检查锁是否被任何线程锁定,被锁定时返回 true,否则返回 false.
* unlock():释放锁, Lock 接口的实现类通常会对线程释放锁(通常只有锁的持有者才能释放锁)施加限制,
* 如果违反了限制,则可能会抛出(未检查的)异常。如果锁已经被释放,重复释放时,会抛出异常。
*/
if (lock.isLocked()) {
lock.unlock();
}
}
return result;
}
}
复制代码

测试结果:

三 总结

以上方案

解决方案1,实现起来较为简单,项目开发前期或者不是特别重要的接口中可以使用此方法

解决方案2,3,4不推荐

解决方案5 基于乐观锁来实现,个人感觉占硬盘存储空间,但是实现简单,较为稳定,建议使用

解决方案6 比较新颖,可以在项目中尝试

解决方案7 是Redis实现分布式锁的Demo,依赖高可用Redis

解决方案8 是生产环境中比较流行的解决方式,依赖高可用Redis

参考文章:

基于Redis的分布式锁实现

juejin.cn/post/684490…

这才叫细:带你深入理解Redis分布式锁

mp.weixin.qq.com/s?__biz=MzI…


作者:geekAntony
链接:https://juejin.cn/post/6935751997157031966
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。



浏览 191
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报