
实现这个功能是需要注意表单重复提交的危害在于并发问题, 所以实现必须是线程安全的.
定义一下接口
interface FormHashContainer{ // 添加成功之后返回 true, 如果有重复,返回 false boolean putIfAbsent(Sting hash, Date expireAt) } 单节点可以使用 hashmap 实现, key 为 hash, value 为过期时间
基本逻辑为:
lock.lock() try{ // step1 // step2 // step3 }finaly{ lock.unlock(); } 缺点很明显, 所有的 POST 请求到这里都会串行, 影响系统并发
对于绝大多数的请求都是正常的, 非重复提交的, 所以正常请求不应该受到影响.
Date d = hashmap.putIfAbsent(key, value) if(d == null){ return true; }else{ lock.lock() try{ // step1 // step2 // step3 }finaly{ lock.unlock(); } } 读优化之后性能应该会有所提升, 对于一般的应用也就足够了.
可以考虑使用类似字典树的数据结构, 但是只有 2 -3 层, 每次只锁一个父节点, 这种数据结构实现起来比较复杂, 实际意义也不大.
如果长期不进行清理, 那么 hashmap 会越来越大, 所以我们应该有一个过期方案来释放空间
当发现重复请求之后, 会持有锁, 在这个阶段进行清理是线程安全的, 并且重复请求对于用户来说没有什么实际意义, 所以哪怕响应慢一点也无所谓.
后台跑一个线程定时清理, 清理的时候也应该持有锁, 但是对于非重复请求没有任何性能影响.
当然是 redis 了, // todo
1 petelin 2019-07-03 20:49:55 +08:00 via iPhone 同一个请求打到两台机器呢 |
2 lihongjie0209 OP @petelin 怎么办到的??? |
3 petelin 2019-07-03 20:54:28 +08:00 via iPhone 不好意思 同样的 body “指纹” 的两个请求 打到两台机器呢 |
4 lihongjie0209 OP @petelin 假如这个表单不需要登录就可以提交, 那么我们需要对匿名用户做指纹采集, 最简单的方案就是 User agent 和 IP 地址了, hash(ua + ip + url + body) 假如这个表单需要登录才可以提交, 我们可以直接用用户的 ID 进行 hash: hash(userId + url + body) |
5 Caballarii 2019-07-03 20:56:13 +08:00 @petelin 最后一句话,当然是 redis 了 |
6 Claudius 2019-07-03 22:27:32 +08:00 hash(body)的话,如果 body 中包含时间戳呢 |
7 swulling 2019-07-03 22:39:02 +08:00 via iPhone 后端为什么要管这个,一般的做法是 API 设计的时候就需要传入格式 为 uuid 的 request id,相同则抛弃。这个直接做到 API gateway 那里,业务代码都不用管 |
8 FreeEx 2019-07-03 22:46:48 +08:00 via iPhone 对 request body 进行 hash 有点多余,因为不会让用户在短时间内提交多次表单,即使内容不同。 |
9 npe 2019-07-03 23:33:21 +08:00 via Android 如果是实际业务场景,即便你重复提交了表单,也无法验证通过,因为你业务不允许啊。 |
10 npe 2019-07-03 23:35:35 +08:00 via Android @npe 要解决真正的重复提交很简单,你也说了,用 token,页面进入服务器给个 token,提交前检查,提交后删除。 |
11 txy3000 2019-07-03 23:55:16 +08:00 via Android 那你的 hash map 就是用来作缓存吗? 最后也得持久化到硬盘? 你的目的是为了减少后端业务层对重复数据的查重造成对硬盘 IO 的频繁访问? 不然为什么要做这么多工作 引入这么多复杂度 ? 一脸懵 b |
12 xuanbg 2019-07-04 07:20:27 +08:00 我们用的是限流策略,可对每个接口配置。同一用户的同样数据 3 秒内只能提交一次,网关上就卡掉了。 |
13 lihongjie0209 OP |
14 lihongjie0209 OP @txy3000 是为了避免不必要的并发问题, 没有必要持久化 |
15 lihongjie0209 OP @npe 使用 token 前端会很麻烦 |
16 lihongjie0209 OP @xuanbg 也是一种思路 |
17 lihongjie0209 OP @swulling id 谁来生成, 前端使用是否透明? |
18 swulling 2019-07-04 09:13:27 +08:00 via iPhone @lihongjie0209 为什么要前端透明?前后端本来就是一体的,功能做到哪里更合理就做到哪里。 |
19 lhx2008 2019-07-04 09:17:02 +08:00 via Android 单点意义不大,如果后端实在要做也可以抽出来一个网关来做。前端做提交 id 控制就差不多了,如果有人要恶意提交这个方法也不管用。 |
20 lihongjie0209 OP @lhx2008 本来就不是解决恶意提交的问题, 是为了对前端透明的前提下解决重复提交的问题 |
21 lihongjie0209 OP @swulling 使用 token 之前, 前端代码 postForm() 使用 token 之后前端的代码 if (用户之前的请求还没有返回) String token = getPreviousToken() else{ String token = getNewToken() } postForm(token) 前端需要维护请求的状态, 这也只是最简单的情况, 一旦业务流程复杂了, 状态维护会更加复杂 |
22 momocraft 2019-07-04 09:33:17 +08:00 既然你意识到了目的只是区分重复提交,为什么还要在服务器生成 token 呢? |
23 lihongjie0209 OP @momocraft 我生成的是指纹, 没有指纹, 怎么确定是否为重复提交 |
24 swulling 2019-07-04 09:39:05 +08:00 |
25 lhx2008 2019-07-04 09:39:23 +08:00 via Android @lihongjie0209 如果从技术来说,putIfAbsent 反而是这里不是线程安全,lock 下面反而没有意义吧。 应该用 concurrenthashmap,先 get 一次,没有的话,加锁 putifabsent 一个对象,有的话就直接返回,就是单例模式的一个改版。 |
26 swulling 2019-07-04 09:45:33 +08:00 @lihongjie0209 找到我司的一个 API 设计范式,你可以参考 client token 由前端通过 用户、服务、API 参数 hash 生成,全局唯一 当服务端收到带有 client token 的请求时,检查用户是否曾经发送过同一个 token。如是,则应检查 API 的参数是否完全一致,一致则 do nothing,直接返回成功。如果不一致,抛一个 4xx 错误,因为理论上是不可能的。 client token 前端可以通过库来实现,使用的时候完全透明 |
27 lhx2008 2019-07-04 09:50:00 +08:00 via Android 至于更新时间,应该用别的线程来做淘汰,比如缓存轮子 coffine |
28 lihongjie0209 OP @swulling 你这个相当于把我提到的指纹逻辑放到了前端, 原理是一致的 |
29 lihongjie0209 OP @lhx2008 必须要用 concurrenthashmap, 我没写清楚 |
31 lihongjie0209 OP @swulling 我的想法是前端做不做都可以, 但是我后端一定要做, 并且要独立做, 不依赖前端 |
32 renothing 2019-07-04 10:00:01 +08:00 @lihongjie0209 如果只是为了杜绝请求重放.后端记录一次 request-id 即可. |
33 lihongjie0209 OP @renothing request-id 怎么生成 |
34 renothing 2019-07-04 10:02:56 +08:00 @lihongjie0209 webserver 啊!随便哪个 webserver 都能生成 request-id |
35 cccssss 2019-07-04 10:26:57 +08:00 我的做法是,前端生成一个 uuid,select not exist uuid {insert into} 乐观锁的一种应用吧 我没明白同样的表单一定时间要允许重复提交的意义是什么,insert 两条一样的数据?还是第一次 insert 第二次 update ? 或者一条数据 delete 两遍? |
36 wysnylc 2019-07-04 10:29:57 +08:00 一个新手入门问题,随便百度都有答案 加一个标记让多次提交表单保持幂等,这样在二次提交则可以识别该请求为重复 多百度啊少年 |
37 lihongjie0209 OP @wysnylc 标记生成算法说明一下 |
38 lihongjie0209 OP @cccssss 你的每一个表单刚好对应一个数据库记录?? |
39 cccssss 2019-07-04 10:44:18 +08:00 @lihongjie0209 一个表单插入多张表?和插入一张表有区别么?或者我理解有出入,不是防止一次请求重复提交? select 后边可以跟一个 insert 也可以跟多个啊…… 就如 @wysnylc 的意思,只是想法子将一个写请求改造成幂等的请求。 数据库乐观锁不就是用来解决这个问题的么 |
40 limuyan44 2019-07-04 10:47:10 +08:00 via Android 目的是啥?又解决不了重放,这么麻烦还不如按钮 disabled 前后端分离又不代表不是一个项目,适合在哪里做就谁改。 |
41 lihongjie0209 OP @limuyan44 后端做一次, 前端 100 个表单做 100 次 |
42 source 2019-07-04 10:54:15 +08:00 @lihongjie0209 前端在发请求的地方做一下拦截就行了,100 个表单 100 次说的太夸张了 |
43 metamask 2019-07-04 10:59:49 +08:00 这个情况我感觉还是有些问题。 post 本来就是非幂等, 如果在这里要做一个 “强行”的处理来解决只是操作上的问题,我感觉是不太合理的; 这种属于交互上的问题的东西,感觉从交互上解决是比较合理的。 |
44 lihongjie0209 OP @source disable 按钮本来就是每个表单都要做的 |
45 lihongjie0209 OP @freakxx 交互怎么处理那是前端事, 后端要为数据负责 |
46 metamask 2019-07-04 11:03:11 +08:00 如果这个做法要套上“意义”的话, 那么往 防重放机制 走 可能有一些借鉴; |
47 source 2019-07-04 11:07:12 +08:00 @lihongjie0209 现在主流的 request 库都有拦截层,在拦截层上按你的业务需求把“重复提交”的请求拦下来就行了,不需要去给按钮设置 disable,从代码上实现而不是从控制用户行为上实现 |
48 OSF2E 2019-07-04 11:07:36 +08:00 楼主真是为不靠谱的前端同事操碎了心 |
49 xwbz2018 2019-07-04 11:07:59 +08:00 我这里做的是每个非 Get 的请求把用户 token 和请求地址(不包含 Host )、数据 md5 一下,然后使用 redis 去重,响应结束前把 redis 数据清除。为了防止异常,redis 还要设置三秒过期时间。还有一个复杂一点的版本,就是 redis 去重时要考虑到高并发同时拿到锁的情况。 另外网络不稳定造成这种情况也是很无奈啊 |
50 flyingghost 2019-07-04 11:08:54 +08:00 技术方案凭什么要对前端透明?是因为前端不归后端管而且老大又惹不起吗? 而且有个疑问:有 2 个请求势必有 2 个响应回来,搞不好后发先至都是有可能的。如果真的前端透明,如何区分、处理这两个响应? 要拦截,也应该是请求方做拦截比较合适啊。比如说封装到前端底层库里面。业务层只管 util.post(),由底层去做判断去重和状态管理,哪怕直接黑吃了第二个请求也行。 而且原则上来说,一件错误应当尽可能的消灭在更早的阶段,而不是往后延伸到系统深处统一处理。比如简单却有效的 doubleclick.js ,直接在事件层就把双击误操作消灭掉了。带来的好处是可以尽量避免制造不必要的约束和暗逻辑并且带到系统的各个角落。 |
52 wysnylc 2019-07-04 11:19:26 +08:00 @lihongjie0209 #36 uuid,时间戳,任何能保证幂等的都行 建议 ip+uid+订单号(或者随机数) 随便弄个就得了 |
53 limuyan44 2019-07-04 11:21:35 +08:00 via Android 还是没搞懂后端这么做的意义是什么?如果不是为了解决重放那么和正常的限制请求频率区别在哪里,费这么大劲这么做最终到底会有什么特技。不需要交互的去重就是后端自己在玩,所有来自外部的请求都是不同的。还有这种 100 个按钮做 100 次的问题我不想继续讨论下去,至少我还没遇见过会说出类似话的技术人员。 |
54 lihongjie0209 OP @OSF2E 自己能解决的问题就不去协调前端了 |
55 lihongjie0209 OP @limuyan44 就是为了解决表单重复提交的问题 只有 POST 请求做处理 请求频率限制要限制是所有请求 重放用的是基于时间的 hash, 也是限制所有的请求 100 个表单 disable100 个按钮有什么问题? |
56 jasonding 2019-07-04 11:37:38 +08:00 前后端结合处理最方便,如果只后端做,事倍功半且并不能百分百保证解决的都是重复提交而不是连续两次相同的提交 |
57 lihongjie0209 OP @jasonding 相同提交的话也必须是串行, 指纹也设置了过期时间 |
58 Snail233 2019-07-04 11:55:01 +08:00 重复提交问题,前段可以解决的吧。就拿 axios 来说,你发送前可以在拦截器设置判断啊,有重复的直接 abort()。但是,这种也就防止前端的操作,你要模拟就不行了,那还是看后端。但,一般 restful api 的话,有这种要求么? |
59 itning 2019-07-04 11:57:44 +08:00 via Android 重复提交保持幂等性就行了 |
60 hereIsChen 2019-07-04 12:00:42 +08:00 我都是前端加锁,在提交的时候锁上,等到反馈回来了再解锁 |
61 lihongjie0209 OP @itning 幂等意味着并发的时候线程安全, 业务代码要改 |
62 sin30 2019-07-04 12:38:12 +08:00 我觉得这个表单重复提交的问题还应该放在更大的尺度上去看,而不仅仅是前端和后端的交互上面。 |
63 sin30 2019-07-04 12:41:54 +08:00 如果你这个后端的 API 不仅仅是给前端提供服务的,还可以给第三方提供服务,通过他们的后端程序来进行调用。 这时针对 PUT, POST 的重复性校验就是个累赘,因为免不齐就有一些接口是要批量被调用去更新的,POST 接口本身就不是幂等的,你这么一做,硬是搞成几秒钟之内的重复调用被后端给拦截掉了。 到时候你还得写个白名单,某些接口可以允许不拦截。 |
64 limuyan44 2019-07-04 12:54:24 +08:00 via Android @lihongjie0209 谁说请求频率限制就是针对所有请求的? url 限制频率不是正常操作吗 |
65 micean 2019-07-04 13:41:06 +08:00 重复提交这种屁大点的事情还要后端来擦屁股? |
66 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); |
67 Felldeadbird 2019-07-04 13:52:00 +08:00 后端和前端协商一个值,这个值提交后就失效 不就解决了吗? |
68 qhxin 2019-07-04 14:32:06 +08:00 1、接口层面防止网络重放; 2、事务操作; |
69 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/) 可以配合使用了。 |
70 9684xtpa 2019-07-04 15:03:28 +08:00 @lihongjie0209 #61 等并发线程安全?你加一个操作记录表不就行了? |
71 abelmakihara 2019-07-04 16:44:39 +08:00 前端加个防抖 debounce 提交后开始 loading 按钮 disabled 这已经尽力了 |
72 pockry 2019-07-04 17:56:53 +08:00 数据库为某个字段设个唯一索引不就行吗?这样不管是同一个人的重复提交还是不同人的重复内容都能防止啊,前端做 disable,后端做数据库返回的错误处理啊 |
73 momocraft 2019-07-04 18:08:41 +08:00 客户端和代理(包括透明和不透明)都可能重放请求。根据墨非定律,还没见过无非是后果不严重或时间不够久。 因此这个问题的彻底解决只可能在服务器或之后,比如以某个 nonce (不一定要是 digest) 为 key 来保证幂等。 |
74 Heanes 2019-07-05 09:58:25 +08:00 唯一 request-id |