前后端分离的情况下表单重复提交的解决方案思考

2019-07-03 20:46:26 +08:00
 lihongjie0209

约束条件

  1. 前后端分离, 无法使用重定向等依赖于浏览器的技术
  2. 不对前端有任何要求, 比如说提交表单之前申请一个 Token. 提交之后 disable button 之类的

期望的结果

  1. 有效性, 最起码的要求,不能有表单重复提交也不能误报
  2. 透明性, 对前端透明, 前端无感知
  3. 性能, 当然是越快越好

需要解决的问题

后端怎么判断一个表单重复

  1. 已提交的表单应该存储一个指纹(hash)
  2. 新的表单应该和已提交的对比, 如果存在就认为是重复提交

怎么给一个表单建立指纹

  1. 首先, 按照 REST 接口的标准, GET 或者是 HEAD 方法是没有副作用的, 所以我们只对 POST, DELETE 方法做指纹. 实际项目中其实只用到了 POST, 所以下面的方案都是按照 POST 方法作为说明.
  2. POST 方法数据应该都存储在 Body 中, 最简单的我们可以对 Body 的内容做 hash, 如 hash(body). 但是这种方法有问题, 假如 /endpoint1 和 /endpoint2 提交的数据是一样的, 那么这个指纹就无效了
  3. POST URL 也应该作为 hash 的一部分: hash(url + body). 这种方法也会有问题, 不同用户提交相同的表单会误报
  4. 假如这个表单不需要登录就可以提交, 那么我们需要对匿名用户做指纹采集, 最简单的方案就是 User agent 和 IP 地址了, hash(ua + ip + url + body)
  5. 假如这个表单需要登录才可以提交, 我们可以直接用用户的 ID 进行 hash: hash(userId + url + body)

表单重复提交的间隔

  1. 用户提交一个表单一段时间之后是允许再次提交相同的表单的, 所以指纹记录应该有一个有效期
  2. 有效期应该是一个固定的值, 既不能影响用户体验, 也不能误报

实现

实现这个功能是需要注意表单重复提交的危害在于并发问题, 所以实现必须是线程安全的.

定义一下接口

interface FormHashContainer{

	// 添加成功之后返回 true, 如果有重复,返回 false
	boolean putIfAbsent(Sting hash, Date expireAt)

}

单节点实现

单节点可以使用 hashmap 实现, key 为 hash, value 为过期时间

基本逻辑为:

  1. 首先查看 hash 是否存在
  2. 如果存在, 检查过期时间, 如果未过期, 返回 false, 如果过期, 更新过期时间, 返回 true
  3. 如果不存在, 添加到 hashmap 中, 返回 true

需要解决的问题

  1. 线程安全 上述三步操作并非原子操作, 需要保证线程安全
  2. 性能 性能不应该影响过大

尝试方案 1: 一把锁


lock.lock()
try{
// step1
// step2
// step3
}finaly{


lock.unlock();
}

缺点很明显, 所有的 POST 请求到这里都会串行, 影响系统并发

尝试方案 2: 读优化

对于绝大多数的请求都是正常的, 非重复提交的, 所以正常请求不应该受到影响.


Date d = hashmap.putIfAbsent(key, value)

if(d == null){

	return true;
}else{

	lock.lock()
	try{
        // step1
        // step2
        // step3
	}finaly{
	lock.unlock();
}
    
}

读优化之后性能应该会有所提升, 对于一般的应用也就足够了.

尝试方案 3: 使用更加复杂的数据结构

可以考虑使用类似字典树的数据结构, 但是只有 2 -3 层, 每次只锁一个父节点, 这种数据结构实现起来比较复杂, 实际意义也不大.

关于如果过期指纹的问题

如果长期不进行清理, 那么 hashmap 会越来越大, 所以我们应该有一个过期方案来释放空间

方案 1: 发现重复请求之后进行全局清理

当发现重复请求之后, 会持有锁, 在这个阶段进行清理是线程安全的, 并且重复请求对于用户来说没有什么实际意义, 所以哪怕响应慢一点也无所谓.

方案 2: 后台线程定时清理

后台跑一个线程定时清理, 清理的时候也应该持有锁, 但是对于非重复请求没有任何性能影响.

多节点实现

当然是 redis 了, // todo

10528 次点击
所在节点    程序员
74 条回复
lihongjie0209
2019-07-04 10:50:31 +08:00
@limuyan44 后端做一次, 前端 100 个表单做 100 次
source
2019-07-04 10:54:15 +08:00
@lihongjie0209 前端在发请求的地方做一下拦截就行了,100 个表单 100 次说的太夸张了
freakxx
2019-07-04 10:59:49 +08:00
这个情况我感觉还是有些问题。

post 本来就是非幂等,
如果在这里要做一个 “强行”的处理来解决只是操作上的问题,我感觉是不太合理的;

这种属于交互上的问题的东西,感觉从交互上解决是比较合理的。
lihongjie0209
2019-07-04 11:00:53 +08:00
@source disable 按钮本来就是每个表单都要做的
lihongjie0209
2019-07-04 11:01:54 +08:00
@freakxx 交互怎么处理那是前端事, 后端要为数据负责
freakxx
2019-07-04 11:03:11 +08:00
如果这个做法要套上“意义”的话,
那么往 防重放机制 走 可能有一些借鉴;
source
2019-07-04 11:07:12 +08:00
@lihongjie0209 现在主流的 request 库都有拦截层,在拦截层上按你的业务需求把“重复提交”的请求拦下来就行了,不需要去给按钮设置 disable,从代码上实现而不是从控制用户行为上实现
OSF2E
2019-07-04 11:07:36 +08:00
楼主真是为不靠谱的前端同事操碎了心
xwbz2018
2019-07-04 11:07:59 +08:00
我这里做的是每个非 Get 的请求把用户 token 和请求地址(不包含 Host )、数据 md5 一下,然后使用 redis 去重,响应结束前把 redis 数据清除。为了防止异常,redis 还要设置三秒过期时间。还有一个复杂一点的版本,就是 redis 去重时要考虑到高并发同时拿到锁的情况。

另外网络不稳定造成这种情况也是很无奈啊
flyingghost
2019-07-04 11:08:54 +08:00
技术方案凭什么要对前端透明?是因为前端不归后端管而且老大又惹不起吗?
而且有个疑问:有 2 个请求势必有 2 个响应回来,搞不好后发先至都是有可能的。如果真的前端透明,如何区分、处理这两个响应?

要拦截,也应该是请求方做拦截比较合适啊。比如说封装到前端底层库里面。业务层只管 util.post(),由底层去做判断去重和状态管理,哪怕直接黑吃了第二个请求也行。

而且原则上来说,一件错误应当尽可能的消灭在更早的阶段,而不是往后延伸到系统深处统一处理。比如简单却有效的 doubleclick.js ,直接在事件层就把双击误操作消灭掉了。带来的好处是可以尽量避免制造不必要的约束和暗逻辑并且带到系统的各个角落。
swulling
2019-07-04 11:09:45 +08:00
@renothing 嗯,request id 解决重放,client token 解决重复提交
wysnylc
2019-07-04 11:19:26 +08:00
@lihongjie0209 #36 uuid,时间戳,任何能保证幂等的都行 建议 ip+uid+订单号(或者随机数) 随便弄个就得了
limuyan44
2019-07-04 11:21:35 +08:00
还是没搞懂后端这么做的意义是什么?如果不是为了解决重放那么和正常的限制请求频率区别在哪里,费这么大劲这么做最终到底会有什么特技。不需要交互的去重就是后端自己在玩,所有来自外部的请求都是不同的。还有这种 100 个按钮做 100 次的问题我不想继续讨论下去,至少我还没遇见过会说出类似话的技术人员。
lihongjie0209
2019-07-04 11:22:15 +08:00
@OSF2E 自己能解决的问题就不去协调前端了
lihongjie0209
2019-07-04 11:25:35 +08:00
@limuyan44
就是为了解决表单重复提交的问题 只有 POST 请求做处理
请求频率限制要限制是所有请求
重放用的是基于时间的 hash, 也是限制所有的请求
100 个表单 disable100 个按钮有什么问题?
jasonding
2019-07-04 11:37:38 +08:00
前后端结合处理最方便,如果只后端做,事倍功半且并不能百分百保证解决的都是重复提交而不是连续两次相同的提交
lihongjie0209
2019-07-04 11:41:31 +08:00
@jasonding 相同提交的话也必须是串行, 指纹也设置了过期时间
Snail233
2019-07-04 11:55:01 +08:00
重复提交问题,前段可以解决的吧。就拿 axios 来说,你发送前可以在拦截器设置判断啊,有重复的直接 abort()。但是,这种也就防止前端的操作,你要模拟就不行了,那还是看后端。但,一般 restful api 的话,有这种要求么?
itning
2019-07-04 11:57:44 +08:00
重复提交保持幂等性就行了
hereIsChen
2019-07-04 12:00:42 +08:00
我都是前端加锁,在提交的时候锁上,等到反馈回来了再解锁

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/579765

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX