约束条件
- 前后端分离, 无法使用重定向等依赖于浏览器的技术
- 不对前端有任何要求, 比如说提交表单之前申请一个 Token. 提交之后 disable button 之类的
期望的结果
- 有效性, 最起码的要求,不能有表单重复提交也不能误报
- 透明性, 对前端透明, 前端无感知
- 性能, 当然是越快越好
需要解决的问题
后端怎么判断一个表单重复
- 已提交的表单应该存储一个指纹(hash)
- 新的表单应该和已提交的对比, 如果存在就认为是重复提交
怎么给一个表单建立指纹
- 首先, 按照 REST 接口的标准, GET 或者是 HEAD 方法是没有副作用的, 所以我们只对 POST, DELETE 方法做指纹. 实际项目中其实只用到了 POST, 所以下面的方案都是按照 POST 方法作为说明.
- POST 方法数据应该都存储在 Body 中, 最简单的我们可以对 Body 的内容做 hash, 如 hash(body). 但是这种方法有问题, 假如 /endpoint1 和 /endpoint2 提交的数据是一样的, 那么这个指纹就无效了
- POST URL 也应该作为 hash 的一部分: hash(url + body). 这种方法也会有问题, 不同用户提交相同的表单会误报
- 假如这个表单不需要登录就可以提交, 那么我们需要对匿名用户做指纹采集, 最简单的方案就是 User agent 和 IP 地址了, hash(ua + ip + url + body)
- 假如这个表单需要登录才可以提交, 我们可以直接用用户的 ID 进行 hash: hash(userId + url + body)
表单重复提交的间隔
- 用户提交一个表单一段时间之后是允许再次提交相同的表单的, 所以指纹记录应该有一个有效期
- 有效期应该是一个固定的值, 既不能影响用户体验, 也不能误报
实现
实现这个功能是需要注意表单重复提交的危害在于并发问题, 所以实现必须是线程安全的.
定义一下接口
interface FormHashContainer{
// 添加成功之后返回 true, 如果有重复,返回 false
boolean putIfAbsent(Sting hash, Date expireAt)
}
单节点实现
单节点可以使用 hashmap 实现, key 为 hash, value 为过期时间
基本逻辑为:
- 首先查看 hash 是否存在
- 如果存在, 检查过期时间, 如果未过期, 返回 false, 如果过期, 更新过期时间, 返回 true
- 如果不存在, 添加到 hashmap 中, 返回 true
需要解决的问题
- 线程安全 上述三步操作并非原子操作, 需要保证线程安全
- 性能 性能不应该影响过大
尝试方案 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
前端加个防抖 debounce