单纯用Redis锁挡不住重复提交,因前端重复点击导致并发请求同时通过SETNX校验,且锁释放非原子、Token未标准化、AOP代理失效、前后端未协同等漏洞共同造成防重失效。

为什么单纯用Redis锁挡不住重复提交
因为前端可能在用户没看到响应时就重复点了两次,两次请求几乎同时到达,都通过了SETNX(或SET ... NX EX)校验——尤其当锁过期时间设得偏长、且Redis主从异步复制存在延迟时,SETNX在从节点上可能返回true,导致两个线程都拿到锁。
更常见的是:锁释放时机不对。比如用try-finally里直接jedis.del(),但业务方法抛异常后锁已被释放,下个请求进来又抢到锁,而前一个请求还在执行——这不是并发,是“伪串行”,照样写两遍数据库。
- 必须用Lua脚本保证“判断锁归属 + 删除”原子性,例如
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 token:123 abcdef - 锁的value不能用固定字符串,得是唯一请求标识(如UUID),否则AOP切面里删错别人的锁
- 别依赖系统时间做锁超时,用Redis的
EX参数控制,避免NTP校时导致锁提前失效
@Around切面里怎么安全提取和校验Token
很多人把Token从Header里取出来就直接当key拼进Redis,结果遇到大小写混用、空格、URL编码不一致等问题,导致同一个请求生成多个token key,锁形同虚设。
Token必须标准化后再参与校验:全小写、trim、URL decode(如果前端传的是encode过的),且建议加一层签名防篡改,比如HMAC-SHA256(userId + timestamp, secret),服务端重新算一遍比对。
立即学习“Java免费学习笔记(深入)”;
- 别用
HttpServletRequest.getParameter()取Token——GET/POST混传容易漏,统一走request.getHeader("X-Request-Token") - Token有效期建议≤5秒,超过就拒绝,防止用户长时间没操作后突然提交
- 切面里别用
ProceedingJoinPoint.getArgs()直接改参,要用WebContext或RequestContextHolder拿当前请求上下文
Spring AOP + RedisTemplate组合时的事务陷阱
如果被切的方法本身在@Transactional里,而你又在切面里用redisTemplate.opsForValue().setIfAbsent()去加锁,会发现锁没生效——因为Redis操作和DB事务不在同一上下文,Redis成功了,DB回滚了,锁却还挂着,后续请求全被拦住。
根本问题在于:AOP默认是proxy-target-class=false,基于接口代理,而@Transactional也是接口代理,两者嵌套时,内部方法调用绕过代理,事务和切面都失效。
- 启动类加
@EnableAspectJAutoProxy(proxyTargetClass = true),强制CGLIB代理 - Redis锁操作必须独立于业务事务,用
RedisCallback或execute()绕过Spring事务同步器 - 别在
@AfterReturning里删锁——异常时不会触发,改用@After+joinPoint.proceed()包裹后的finally块
怎么让前端真正配合防重,而不是只靠后端硬扛
后端锁再严,前端按钮不置灰、不拦截重复点击,照样压垮接口。真正的组合拳,是前后端约定一套轻量协议。
比如:每次页面加载时,后端返回一个formToken(带timestamp和签名),前端提交时把这个token放进X-Form-Token头;后端校验通过后立即失效该token,并返回新token供下次使用。这样即使F5刷新,旧token也作废。
- 不要让前端自己生成UUID当token——没服务端校验就是摆设
- Token必须绑定用户session或userId,不能全局共享,否则A用户刷出B用户的token
- 网关层可加简单拦截:对同一
userId + method + path在1秒内超过2次的请求,直接返回429 Too Many Requests,减轻业务层压力
防重不是加一层锁就完事,是Token生命周期管理、Redis原子操作、AOP代理模式、前后端协作四件事咬死才能闭环。漏掉任意一环,重复提交就会从“偶发”变成“稳定复现”。










