防刷机制关键在限流粒度、redis原子操作与拒绝逻辑前置:ip级限流需incr+expire原子执行,用户级须先解析token再取ip,双维度key应含userid与ip哈希,响应需带x-ratelimit-remaining和retry-after头。

直接上结论:防刷机制不是“加个中间件就完事”,关键在「限流粒度选对 + Redis 原子操作写稳 + 拒绝逻辑前置」。别等请求进了业务层才判断,那已经晚了。
用 redis.Incr + redis.Expire 实现 IP 级固定窗口限流
这是最常用、也最容易出错的方案。核心是用一个带过期时间的计数器,每请求一次就 Incr,超限就拦。但注意:Expire 和 Incr 必须原子执行,否则可能漏设过期时间,导致 key 永久存在。
- 错误写法:
rdb.Incr(ctx, key)后再rdb.Expire(ctx, key, window)—— 中间若服务重启或 panic,key 就没过期时间 - 正确做法:用
TxPipeline()或 Lua 脚本保证原子性;简单场景下推荐SetEx配合GetSet模拟,但更稳妥的是直接用INCR+EXPIRE的 pipeline - key 命名建议包含维度和时间戳,比如
rate_limit:ip:192.168.1.100:2026040713(按小时切),避免跨窗口污染 - 如果只限制“每分钟 5 次”,
window = 1 * time.Minute即可,但要注意 Redis 的EXPIRE是相对当前时间,不是窗口起始时间
用户级限流必须区分 parseToken 和 getClientIP 的调用顺序
双维度(IP + Token)防刷时,顺序错了会漏判或误杀。必须先解析 Token 获取用户标识,再取 IP;不能反过来,否则匿名用户会被当成同一 IP 多次触发限流。
-
parseToken(r)失败(比如 token 过期、签名错)要立即返回http.StatusUnauthorized,不进计数逻辑 -
getClientIP(r)要兼容 Nginx 转发场景,优先读X-Forwarded-For,但需校验可信代理列表,防伪造 - 组合 key 建议格式:
rate_limit:user:<code>userID:ip:hashedIP,其中userID来自 token,hashedIP是 IP 的 sha256 前 8 位,防止 key 过长 - Redis 计数前务必先
Exists(key),只有 key 存在才INCR;否则新用户第一次请求会因 key 不存在而返回 0,误判为未超限
golang.org/x/time/rate.Limiter 适合单机但别滥用在 HTTP 中间件里
标准库的 rate.Limiter 是令牌桶实现,轻量、无依赖,但只适用于单实例部署。直接塞进中间件里用,容易踩两个坑:
- 没做 per-user 隔离:所有请求共用一个
Limiter实例,等于全局限流,不是接口防刷 - 没处理并发竞争:多个 goroutine 同时调
Allow(),rate.Limiter内部有锁,但你得确保每个用户有自己的实例,比如用sync.Map缓存:limiterMap.LoadOrStore(userID, rate.NewLimiter(1000, 1000)) - 它不支持自动过期,窗口重置得靠你自己维护时间戳;固定窗口不如 Redis 方便,滑动窗口更难落地
- 高并发下频繁创建/查找 Limiter 实例会有 GC 压力,建议预热 + 复用,不要每次请求都 new
拒绝响应时别忘了写 X-RateLimit-Remaining 和 Retry-After 头
这不只是“友好提示”,而是让客户端(尤其是合规 SDK 或前端重试逻辑)能合理退避。很多团队只写状态码,结果客户端疯狂重试,反而加重攻击效果。
- 超限时,
w.Header().Set("X-RateLimit-Remaining", "0")是基础,配合w.Header().Set("Retry-After", "60")(单位秒) - 正常响应也要带剩余配额,比如
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(maxReq - current)),方便前端监控或调试 - 注意:这些 header 不影响限流逻辑本身,但缺失会导致下游系统无法感知节流状态,容易引发误报或重复提交
- 别在中间件里
defer r.Body.Close()——net/http已自动处理;但如果你手动io.ReadAll(r.Body)了,必须补io.Copy(ioutil.Discard, r.Body),否则后续 handler 拿不到 body
真正难的不是写通逻辑,而是怎么让限流 key 的生命周期和业务语义对齐。比如登录态失效后,对应的限流 key 应该立刻失效,而不是等 Redis 自动过期;又比如灰度发布时,新老版本 token 解析方式不同,可能导致同一个用户被算成两个人。这些细节,比算法本身更消耗线上排障时间。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











