直接用SETNX不能防重复提交,因其仅判断锁存在与否,无法兼顾请求内容一致性与时间有效性;滑动窗口通过ZSET以请求指纹为member、时间戳为score,结合ZCOUNT与pipeline原子操作,精准实现“短时间相同操作仅处理一次”。

为什么直接用 Redis SETNX 不能防重复提交
用户点击按钮后连续发两个相同请求,服务端需要在极短时间内判断是否“刚处理过”。SETNX 虽然能加锁,但它只管“有没有锁”,不管“这个请求内容是否和上一个一模一样”。比如两次提交表单数据不同,但 SETNX 锁的是固定 key(如 "submit:uid:123"),就会误拦;反之,如果 key 按参数哈希(如 "submit:uid:123:hash:abc123"),又无法应对前端重复点提交同一份数据的场景——因为哈希值相同,SETNX 会拦截,看似 OK,但缺乏时间维度控制:用户 5 分钟前提交过,现在再点一次,不该拦。
真正要防的是「短时间内的相同操作」,不是「永远只允许一次」。滑动窗口能兼顾「内容一致性」+「时间有效性」,而单纯 SETNX 或 EXPIRE 配合固定 key 都做不到这点。
滑动窗口 key 设计必须包含请求指纹和时间桶
Redis 本身不支持原生滑动窗口,得靠有序集合(ZSET)模拟:把每次请求的唯一指纹作为 member,时间戳(秒级或毫秒级)作为 score,存进 ZSET,再用 ZREMRANGEBYSCORE 清理过期项。key 必须同时携带业务标识和时间粒度,否则窗口无法对齐。
- 错误示例:
"submit:uid:123"—— 缺少时间桶,无法做滑动清理 - 推荐格式:
"submit:uid:123:win:60s",其中60s表明窗口长度为 60 秒,实际使用中建议硬编码为常量,避免拼写不一致 - 请求指纹建议用
sha256(fmt.Sprintf("%s:%s:%v", c.ClientIP(), c.Request.URL.Path, body)),不用原始 body(含空格换行差异),也不用 query(GET 请求可能被 CDN 缓存或重写) - score 必须用
time.Now().Unix()(非UnixMilli()),否则ZREMRANGEBYSCORE在秒级窗口下精度冗余且增加存储体积
Gin 中间件里调用 ZADD + ZCOUNT 的顺序不能错
核心逻辑是:先算出当前窗口左边界(now - windowSize),ZCOUNT 查有多少 member 落在 [left, now] 区间;若 ≥ 阈值,拒绝;否则 ZADD 当前请求并设 EX 过期(比窗口长一点,防止 key 残留)。
容易踩的坑:
-
ZCOUNT必须在ZADD前执行,否则刚加进去就会计数,导致首次请求就被限流 - 不要用
ZCARD替代ZCOUNT:前者统计全部成员,不剔除过期项,滑动窗口失效 - Redis 命令必须用 pipeline 提交,否则
ZCOUNT和ZADD之间存在竞态——两个并发请求可能同时通过ZCOUNT,再各自ZADD,结果超限 - 示例关键片段(Go):
pipe := rdb.TxPipeline() pipe.ZCount(key, strconv.FormatInt(now-windowSec, 10), strconv.FormatInt(now, 10)) pipe.ZAdd(key, &redis.Z{Score: float64(now), Member: fingerprint}) pipe.Expire(key, time.Second*(windowSec+10)) _, err := pipe.Exec()
阈值判断后必须统一返回标准 HTTP 状态和 Header
防重复提交不是鉴权,不抛 401/403;也不是服务错误,不返回 500。标准做法是 429 Too Many Requests,并带 Retry-After 提示客户端稍后重试。
- Gin 中直接写:
c.AbortWithStatusJSON(429, gin.H{"error": "duplicate request rejected", "retry_after": 1}) - 注意:不要在中间件里
c.Next()后再检查,必须在进入业务 handler 前拦截,否则业务逻辑已执行(比如扣款、发消息),拦截就失去意义 - 前端拿到 429 后应禁用按钮并倒计时,而不是静默重试——否则可能绕过窗口限制(比如每秒发 5 个请求,每个都等 1 秒重试,实际形成稳定 5qps,超出设计阈值)
滑动窗口的精度依赖于时间戳粒度和 Redis 命令原子性,别为了省一个 pipeline 而用两次独立命令;另外,指纹生成阶段若忽略 body 解析失败(如 JSON 格式错误),应降级为 IP+Path 组合,否则空指纹会导致所有请求被当成重复。


















