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

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

11013 次点击
所在节点    程序员
74 条回复
lihongjie0209
2019-07-04 12:04:48 +08:00
@itning 幂等意味着并发的时候线程安全, 业务代码要改
sin30
2019-07-04 12:38:12 +08:00
我觉得这个表单重复提交的问题还应该放在更大的尺度上去看,而不仅仅是前端和后端的交互上面。
sin30
2019-07-04 12:41:54 +08:00
如果你这个后端的 API 不仅仅是给前端提供服务的,还可以给第三方提供服务,通过他们的后端程序来进行调用。
这时针对 PUT, POST 的重复性校验就是个累赘,因为免不齐就有一些接口是要批量被调用去更新的,POST 接口本身就不是幂等的,你这么一做,硬是搞成几秒钟之内的重复调用被后端给拦截掉了。
到时候你还得写个白名单,某些接口可以允许不拦截。
limuyan44
2019-07-04 12:54:24 +08:00
@lihongjie0209 谁说请求频率限制就是针对所有请求的? url 限制频率不是正常操作吗
micean
2019-07-04 13:41:06 +08:00
重复提交这种屁大点的事情还要后端来擦屁股?
sun019
2019-07-04 13:43:30 +08:00
没那么复杂吧
discuz formhash 实现参考
$_SGLOBAL['formhash'] = substr(md5(substr($_SGLOBAL['timestamp'], 0, -7).'|'.$_SGLOBAL['supe_uid'].'|'.md5($_SCONFIG['sitekey']).'|'.$hashadd), 8, 8);
Felldeadbird
2019-07-04 13:52:00 +08:00
后端和前端协商一个值,这个值提交后就失效 不就解决了吗?
qhxin
2019-07-04 14:32:06 +08:00
1、接口层面防止网络重放;
2、事务操作;
hantsy
2019-07-04 14:33:29 +08:00
好像 Form (或者 Cookie ) 内置一个 CSRF Token (每次生成的不一样),可以保证同一数据提交一次。

Java 传统的很多架构都支持,JSF,Struts 等

前后分离,REST 交互,Angular、SpringSecurity ( Spring 有官方教程, https://spring.io/guides/tutorials/spring-security-and-angular-js/) 可以配合使用了。
9684xtpa
2019-07-04 15:03:28 +08:00
@lihongjie0209 #61 冪等并发线程安全?你加一个操作记录表不就行了?
abelmakihara
2019-07-04 16:44:39 +08:00
前端加个防抖 debounce
提交后开始 loading 按钮 disabled
这已经尽力了
pockry
2019-07-04 17:56:53 +08:00
数据库为某个字段设个唯一索引不就行吗?这样不管是同一个人的重复提交还是不同人的重复内容都能防止啊,前端做 disable,后端做数据库返回的错误处理啊
momocraft
2019-07-04 18:08:41 +08:00
客户端和代理(包括透明和不透明)都可能重放请求。根据墨非定律,还没见过无非是后果不严重或时间不够久。

因此这个问题的彻底解决只可能在服务器或之后,比如以某个 nonce (不一定要是 digest) 为 key 来保证幂等。
Heanes
2019-07-05 09:58:25 +08:00
唯一 request-id

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

https://ex.noerr.eu.org/t/579765

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

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

© 2021 V2EX