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

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

10296 次点击
所在节点    程序员
74 条回复
petelin
2019-07-03 20:49:55 +08:00
同一个请求打到两台机器呢
lihongjie0209
2019-07-03 20:51:49 +08:00
@petelin 怎么办到的???
petelin
2019-07-03 20:54:28 +08:00
不好意思 同样的 body “指纹” 的两个请求 打到两台机器呢
lihongjie0209
2019-07-03 20:55:40 +08:00
@petelin
假如这个表单不需要登录就可以提交, 那么我们需要对匿名用户做指纹采集, 最简单的方案就是 User agent 和 IP 地址了, hash(ua + ip + url + body)
假如这个表单需要登录才可以提交, 我们可以直接用用户的 ID 进行 hash: hash(userId + url + body)
Caballarii
2019-07-03 20:56:13 +08:00
@petelin 最后一句话,当然是 redis 了
Claudius
2019-07-03 22:27:32 +08:00
hash(body)的话,如果 body 中包含时间戳呢
swulling
2019-07-03 22:39:02 +08:00
后端为什么要管这个,一般的做法是 API 设计的时候就需要传入格式
为 uuid 的 request id,相同则抛弃。这个直接做到 API gateway 那里,业务代码都不用管
FreeEx
2019-07-03 22:46:48 +08:00
对 request body 进行 hash 有点多余,因为不会让用户在短时间内提交多次表单,即使内容不同。
npe
2019-07-03 23:33:21 +08:00
如果是实际业务场景,即便你重复提交了表单,也无法验证通过,因为你业务不允许啊。
npe
2019-07-03 23:35:35 +08:00
@npe 要解决真正的重复提交很简单,你也说了,用 token,页面进入服务器给个 token,提交前检查,提交后删除。
txy3000
2019-07-03 23:55:16 +08:00
那你的 hash map 就是用来作缓存吗? 最后也得持久化到硬盘? 你的目的是为了减少后端业务层对重复数据的查重造成对硬盘 IO 的频繁访问? 不然为什么要做这么多工作 引入这么多复杂度 ? 一脸懵 b
xuanbg
2019-07-04 07:20:27 +08:00
我们用的是限流策略,可对每个接口配置。同一用户的同样数据 3 秒内只能提交一次,网关上就卡掉了。
lihongjie0209
2019-07-04 08:56:45 +08:00
@npe
即便你重复提交了表单,也无法验证通过

要达到这样的要求, 那你所有的业务代码必须做并发处理, 不然都是线程不安全的
lihongjie0209
2019-07-04 08:57:20 +08:00
@txy3000
是为了避免不必要的并发问题, 没有必要持久化
lihongjie0209
2019-07-04 08:57:46 +08:00
@npe 使用 token 前端会很麻烦
lihongjie0209
2019-07-04 08:58:13 +08:00
@xuanbg 也是一种思路
lihongjie0209
2019-07-04 09:05:23 +08:00
@swulling id 谁来生成, 前端使用是否透明?
swulling
2019-07-04 09:13:27 +08:00
@lihongjie0209 为什么要前端透明?前后端本来就是一体的,功能做到哪里更合理就做到哪里。
lhx2008
2019-07-04 09:17:02 +08:00
单点意义不大,如果后端实在要做也可以抽出来一个网关来做。前端做提交 id 控制就差不多了,如果有人要恶意提交这个方法也不管用。
lihongjie0209
2019-07-04 09:20:55 +08:00
@lhx2008 本来就不是解决恶意提交的问题, 是为了对前端透明的前提下解决重复提交的问题

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

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

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

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

© 2021 V2EX