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

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 09:27:01 +08:00
@swulling
使用 token 之前, 前端代码

postForm()


使用 token 之后前端的代码

if (用户之前的请求还没有返回)
String token = getPreviousToken()
else{
String token = getNewToken()
}

postForm(token)


前端需要维护请求的状态, 这也只是最简单的情况, 一旦业务流程复杂了, 状态维护会更加复杂
momocraft
2019-07-04 09:33:17 +08:00
既然你意识到了目的只是区分重复提交,为什么还要在服务器生成 token 呢?
lihongjie0209
2019-07-04 09:36:32 +08:00
@momocraft 我生成的是指纹, 没有指纹, 怎么确定是否为重复提交
swulling
2019-07-04 09:39:05 +08:00
@lihongjie0209 写到 postForm 里不就完了

另外好的前端就不会出现重复提交的问题,提交后把 form disable 了,怎么重复提交?
lhx2008
2019-07-04 09:39:23 +08:00
@lihongjie0209 如果从技术来说,putIfAbsent 反而是这里不是线程安全,lock 下面反而没有意义吧。
应该用 concurrenthashmap,先 get 一次,没有的话,加锁 putifabsent 一个对象,有的话就直接返回,就是单例模式的一个改版。
swulling
2019-07-04 09:45:33 +08:00
@lihongjie0209 找到我司的一个 API 设计范式,你可以参考

client token 由前端通过 用户、服务、API 参数 hash 生成,全局唯一
当服务端收到带有 client token 的请求时,检查用户是否曾经发送过同一个 token。如是,则应检查 API 的参数是否完全一致,一致则 do nothing,直接返回成功。如果不一致,抛一个 4xx 错误,因为理论上是不可能的。

client token 前端可以通过库来实现,使用的时候完全透明
lhx2008
2019-07-04 09:50:00 +08:00
至于更新时间,应该用别的线程来做淘汰,比如缓存轮子 coffine
lihongjie0209
2019-07-04 09:54:14 +08:00
@swulling 你这个相当于把我提到的指纹逻辑放到了前端, 原理是一致的
lihongjie0209
2019-07-04 09:54:44 +08:00
@lhx2008 必须要用 concurrenthashmap, 我没写清楚
renothing
2019-07-04 09:55:00 +08:00
@swulling 我感觉你和楼主说的不是一回事
你说的是请求重放问题, 楼主说的是 form 内容重复问题.
lihongjie0209
2019-07-04 09:55:34 +08:00
@swulling 我的想法是前端做不做都可以, 但是我后端一定要做, 并且要独立做, 不依赖前端
renothing
2019-07-04 10:00:01 +08:00
@lihongjie0209 如果只是为了杜绝请求重放.后端记录一次 request-id 即可.
lihongjie0209
2019-07-04 10:02:02 +08:00
@renothing request-id 怎么生成
renothing
2019-07-04 10:02:56 +08:00
@lihongjie0209 webserver 啊!随便哪个 webserver 都能生成 request-id
cccssss
2019-07-04 10:26:57 +08:00
我的做法是,前端生成一个 uuid,select not exist uuid {insert into}
乐观锁的一种应用吧
我没明白同样的表单一定时间要允许重复提交的意义是什么,insert 两条一样的数据?还是第一次 insert 第二次 update ?
或者一条数据 delete 两遍?
wysnylc
2019-07-04 10:29:57 +08:00
一个新手入门问题,随便百度都有答案
加一个标记让多次提交表单保持幂等,这样在二次提交则可以识别该请求为重复
多百度啊少年
lihongjie0209
2019-07-04 10:36:11 +08:00
@wysnylc 标记生成算法说明一下
lihongjie0209
2019-07-04 10:36:53 +08:00
@cccssss 你的每一个表单刚好对应一个数据库记录??
cccssss
2019-07-04 10:44:18 +08:00
@lihongjie0209 一个表单插入多张表?和插入一张表有区别么?或者我理解有出入,不是防止一次请求重复提交?
select 后边可以跟一个 insert 也可以跟多个啊……
就如 @wysnylc 的意思,只是想法子将一个写请求改造成幂等的请求。
数据库乐观锁不就是用来解决这个问题的么
limuyan44
2019-07-04 10:47:10 +08:00
目的是啥?又解决不了重放,这么麻烦还不如按钮 disabled 前后端分离又不代表不是一个项目,适合在哪里做就谁改。

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

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

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

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

© 2021 V2EX