V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
lihongjie0209
V2EX  ›  程序员

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

  •  
  •   lihongjie0209 · 2019-07-03 20:46:26 +08:00 · 10248 次点击
    这是一个创建于 1730 天前的主题,其中的信息可能已经有所发展或是发生改变。

    约束条件

    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

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

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

    postForm()


    使用 token 之后前端的代码

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

    postForm(token)


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

    另外好的前端就不会出现重复提交的问题,提交后把 form disable 了,怎么重复提交?
    lhx2008
        25
    lhx2008  
       2019-07-04 09:39:23 +08:00 via Android
    @lihongjie0209 如果从技术来说,putIfAbsent 反而是这里不是线程安全,lock 下面反而没有意义吧。
    应该用 concurrenthashmap,先 get 一次,没有的话,加锁 putifabsent 一个对象,有的话就直接返回,就是单例模式的一个改版。
    swulling
        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 前端可以通过库来实现,使用的时候完全透明
    lhx2008
        27
    lhx2008  
       2019-07-04 09:50:00 +08:00 via Android
    至于更新时间,应该用别的线程来做淘汰,比如缓存轮子 coffine
    lihongjie0209
        28
    lihongjie0209  
    OP
       2019-07-04 09:54:14 +08:00
    @swulling 你这个相当于把我提到的指纹逻辑放到了前端, 原理是一致的
    lihongjie0209
        29
    lihongjie0209  
    OP
       2019-07-04 09:54:44 +08:00
    @lhx2008 必须要用 concurrenthashmap, 我没写清楚
    renothing
        30
    renothing  
       2019-07-04 09:55:00 +08:00
    @swulling 我感觉你和楼主说的不是一回事
    你说的是请求重放问题, 楼主说的是 form 内容重复问题.
    lihongjie0209
        31
    lihongjie0209  
    OP
       2019-07-04 09:55:34 +08:00
    @swulling 我的想法是前端做不做都可以, 但是我后端一定要做, 并且要独立做, 不依赖前端
    renothing
        32
    renothing  
       2019-07-04 10:00:01 +08:00
    @lihongjie0209 如果只是为了杜绝请求重放.后端记录一次 request-id 即可.
    lihongjie0209
        33
    lihongjie0209  
    OP
       2019-07-04 10:02:02 +08:00
    @renothing request-id 怎么生成
    renothing
        34
    renothing  
       2019-07-04 10:02:56 +08:00
    @lihongjie0209 webserver 啊!随便哪个 webserver 都能生成 request-id
    cccssss
        35
    cccssss  
       2019-07-04 10:26:57 +08:00
    我的做法是,前端生成一个 uuid,select not exist uuid {insert into}
    乐观锁的一种应用吧
    我没明白同样的表单一定时间要允许重复提交的意义是什么,insert 两条一样的数据?还是第一次 insert 第二次 update ?
    或者一条数据 delete 两遍?
    wysnylc
        36
    wysnylc  
       2019-07-04 10:29:57 +08:00
    一个新手入门问题,随便百度都有答案
    加一个标记让多次提交表单保持幂等,这样在二次提交则可以识别该请求为重复
    多百度啊少年
    lihongjie0209
        37
    lihongjie0209  
    OP
       2019-07-04 10:36:11 +08:00
    @wysnylc 标记生成算法说明一下
    lihongjie0209
        38
    lihongjie0209  
    OP
       2019-07-04 10:36:53 +08:00
    @cccssss 你的每一个表单刚好对应一个数据库记录??
    cccssss
        39
    cccssss  
       2019-07-04 10:44:18 +08:00
    @lihongjie0209 一个表单插入多张表?和插入一张表有区别么?或者我理解有出入,不是防止一次请求重复提交?
    select 后边可以跟一个 insert 也可以跟多个啊……
    就如 @wysnylc 的意思,只是想法子将一个写请求改造成幂等的请求。
    数据库乐观锁不就是用来解决这个问题的么
    limuyan44
        40
    limuyan44  
       2019-07-04 10:47:10 +08:00 via Android   ❤️ 1
    目的是啥?又解决不了重放,这么麻烦还不如按钮 disabled 前后端分离又不代表不是一个项目,适合在哪里做就谁改。
    lihongjie0209
        41
    lihongjie0209  
    OP
       2019-07-04 10:50:31 +08:00
    @limuyan44 后端做一次, 前端 100 个表单做 100 次
    source
        42
    source  
       2019-07-04 10:54:15 +08:00   ❤️ 1
    @lihongjie0209 前端在发请求的地方做一下拦截就行了,100 个表单 100 次说的太夸张了
    freakxx
        43
    freakxx  
       2019-07-04 10:59:49 +08:00
    这个情况我感觉还是有些问题。

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

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

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

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

    而且原则上来说,一件错误应当尽可能的消灭在更早的阶段,而不是往后延伸到系统深处统一处理。比如简单却有效的 doubleclick.js ,直接在事件层就把双击误操作消灭掉了。带来的好处是可以尽量避免制造不必要的约束和暗逻辑并且带到系统的各个角落。
    swulling
        51
    swulling  
       2019-07-04 11:09:45 +08:00
    @renothing 嗯,request id 解决重放,client token 解决重复提交
    wysnylc
        52
    wysnylc  
       2019-07-04 11:19:26 +08:00
    @lihongjie0209 #36 uuid,时间戳,任何能保证幂等的都行 建议 ip+uid+订单号(或者随机数) 随便弄个就得了
    limuyan44
        53
    limuyan44  
       2019-07-04 11:21:35 +08:00 via Android
    还是没搞懂后端这么做的意义是什么?如果不是为了解决重放那么和正常的限制请求频率区别在哪里,费这么大劲这么做最终到底会有什么特技。不需要交互的去重就是后端自己在玩,所有来自外部的请求都是不同的。还有这种 100 个按钮做 100 次的问题我不想继续讨论下去,至少我还没遇见过会说出类似话的技术人员。
    lihongjie0209
        54
    lihongjie0209  
    OP
       2019-07-04 11:22:15 +08:00
    @OSF2E 自己能解决的问题就不去协调前端了
    lihongjie0209
        55
    lihongjie0209  
    OP
       2019-07-04 11:25:35 +08:00
    @limuyan44
    就是为了解决表单重复提交的问题 只有 POST 请求做处理
    请求频率限制要限制是所有请求
    重放用的是基于时间的 hash, 也是限制所有的请求
    100 个表单 disable100 个按钮有什么问题?
    jasonding
        56
    jasonding  
       2019-07-04 11:37:38 +08:00
    前后端结合处理最方便,如果只后端做,事倍功半且并不能百分百保证解决的都是重复提交而不是连续两次相同的提交
    lihongjie0209
        57
    lihongjie0209  
    OP
       2019-07-04 11:41:31 +08:00
    @jasonding 相同提交的话也必须是串行, 指纹也设置了过期时间
    Snail233
        58
    Snail233  
       2019-07-04 11:55:01 +08:00
    重复提交问题,前段可以解决的吧。就拿 axios 来说,你发送前可以在拦截器设置判断啊,有重复的直接 abort()。但是,这种也就防止前端的操作,你要模拟就不行了,那还是看后端。但,一般 restful api 的话,有这种要求么?
    itning
        59
    itning  
       2019-07-04 11:57:44 +08:00 via Android   ❤️ 1
    重复提交保持幂等性就行了
    hereIsChen
        60
    hereIsChen  
       2019-07-04 12:00:42 +08:00
    我都是前端加锁,在提交的时候锁上,等到反馈回来了再解锁
    lihongjie0209
        61
    lihongjie0209  
    OP
       2019-07-04 12:04:48 +08:00
    @itning 幂等意味着并发的时候线程安全, 业务代码要改
    sin30
        62
    sin30  
       2019-07-04 12:38:12 +08:00
    我觉得这个表单重复提交的问题还应该放在更大的尺度上去看,而不仅仅是前端和后端的交互上面。
    sin30
        63
    sin30  
       2019-07-04 12:41:54 +08:00
    如果你这个后端的 API 不仅仅是给前端提供服务的,还可以给第三方提供服务,通过他们的后端程序来进行调用。
    这时针对 PUT, POST 的重复性校验就是个累赘,因为免不齐就有一些接口是要批量被调用去更新的,POST 接口本身就不是幂等的,你这么一做,硬是搞成几秒钟之内的重复调用被后端给拦截掉了。
    到时候你还得写个白名单,某些接口可以允许不拦截。
    limuyan44
        64
    limuyan44  
       2019-07-04 12:54:24 +08:00 via Android
    @lihongjie0209 谁说请求频率限制就是针对所有请求的? url 限制频率不是正常操作吗
    micean
        65
    micean  
       2019-07-04 13:41:06 +08:00
    重复提交这种屁大点的事情还要后端来擦屁股?
    sun019
        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);
    Felldeadbird
        67
    Felldeadbird  
       2019-07-04 13:52:00 +08:00
    后端和前端协商一个值,这个值提交后就失效 不就解决了吗?
    qhxin
        68
    qhxin  
       2019-07-04 14:32:06 +08:00
    1、接口层面防止网络重放;
    2、事务操作;
    hantsy
        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/) 可以配合使用了。
    9684xtpa
        70
    9684xtpa  
       2019-07-04 15:03:28 +08:00
    @lihongjie0209 #61 冪等并发线程安全?你加一个操作记录表不就行了?
    abelmakihara
        71
    abelmakihara  
       2019-07-04 16:44:39 +08:00
    前端加个防抖 debounce
    提交后开始 loading 按钮 disabled
    这已经尽力了
    pockry
        72
    pockry  
       2019-07-04 17:56:53 +08:00
    数据库为某个字段设个唯一索引不就行吗?这样不管是同一个人的重复提交还是不同人的重复内容都能防止啊,前端做 disable,后端做数据库返回的错误处理啊
    momocraft
        73
    momocraft  
       2019-07-04 18:08:41 +08:00
    客户端和代理(包括透明和不透明)都可能重放请求。根据墨非定律,还没见过无非是后果不严重或时间不够久。

    因此这个问题的彻底解决只可能在服务器或之后,比如以某个 nonce (不一定要是 digest) 为 key 来保证幂等。
    Heanes
        74
    Heanes  
       2019-07-05 09:58:25 +08:00
    唯一 request-id
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   1367 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 35ms · UTC 23:34 · PVG 07:34 · LAX 16:34 · JFK 19:34
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.