必须用redis.setnx配合过期时间实现原子性防刷,因其能一次性判断存在性并设值;key须带业务上下文与哈希手机号,校验需结合时间戳比对而非仅依赖redis过期机制。

用 Redis + SetNX 实现手机号维度原子防刷
直接写 redis.Set("sms:send:"+phone, "1", time.Minute*60) 是错的——它会覆盖已有值,起不到“限制间隔”的作用。必须用 redis.SetNX 配合过期时间,一次完成“判断是否存在 + 设置 + 设过期”三件事。
Key 要带业务前缀和哈希手机号,比如 "sms:send:h:"+sha256(phone),避免手机号明文泄露或被扫描;校验时不能只查 key 是否存在,还要比对当前时间戳与上次发送时间(存入 value 中),因为 Redis 过期是惰性删除,key 可能已过期但还没被清理。
- 错误写法:先
SetNX成功,再单独调Expire→ 若中间崩溃,key 永不过期 - 正确做法:用 Lua 脚本封装,或用
SetNX(key, value, expiration)原子操作(如 Redis Go 客户端支持SetNX(key, value, expiration)) - value 建议存 JSON 字符串,含
{"ts":1726912800,"ip":"1.2.3.4"},方便后续做 IP 关联分析
按 IP 和设备指纹做滑动窗口限流
Fiber 本身不提供滑动窗口,得靠 Redis 的 ZSET 或 INCR + EXPIRE 组合实现。单 IP 每小时最多 10 次、每天最多 30 次这类限制,不能靠内存变量,多实例下完全失效。
推荐用 INCR + EXPIRE 原子初始化窗口:第一次请求时用 SET key 1 EX 3600 NX 创建计数器并设 1 小时过期;后续请求统一 INCR key,再读取值判断是否超限。注意 INCR 返回的是新值,不是原值。
- IP 提取别直接用
c.IP(),Nginx 转发后是内网地址;优先取c.Get("X-Real-IP"),fallback 到c.IP() - 小程序场景建议加设备指纹,用前端生成的
device_id(存 localStorage)传过来,服务端拼进 key,如"sms:ipdev:"+ip+":"+deviceID - 滑动窗口若用
ZSET,需配合ZREMRANGEBYSCORE清理旧成员,否则内存持续增长
在 Fiber 中间件里组合校验并提前拒绝
中间件必须在业务 handler 执行前完成所有检查,且一旦拒绝就要立刻返回响应、绝不能调 c.Next()。Fiber 不会自动中断链路,return 后不写 c.Next() 才真正终止。
校验顺序建议:IP/设备限流 → 手机号频控 → 图形验证码(如有)→ 最后才调用短信 SDK。每一步失败都走 c.Status(429).JSON(map[string]string{"msg": "too many requests"}) 并 return。
- 不要在中间件里用
time.Sleep做延迟,它只阻塞当前 goroutine,不影响其他并发请求 - Redis 调用必须带 context 超时(如
ctx.Timeout(500 * time.Millisecond)),防止网络卡住拖垮整个服务 - 错误日志要打全关键字段:
phone、ip、user-agent、error,方便溯源攻击源
为什么不能只靠 rate.Limiter 做验证码防刷
rate.Limiter 适合单机登录接口这种有明确用户身份的场景,但验证码接口是未登录态,没有稳定 user ID,只能靠 IP 或手机号做 key。而手机号作为 key 会导致所有请求挤同一桶——比如 100 个不同 IP 给同一个手机号发码,rate.Limiter 根本拦不住。
更关键的是,rate.Limiter 的令牌桶是“先给再扣”,允许突发流量透支;而验证码必须严格“先检查再发”,否则可能刚校验完就并发进来第二个请求,导致重复发送。
- 手机号维度必须用外部存储(Redis)保证跨实例一致性
- IP 维度若用
rate.Limiter,必须为每个 IP 动态创建实例并用sync.Map缓存,但 burst 设置稍大就会漏防,不如 Redis 计数直观可控 - 真正上线时,这两层(手机号 + IP)必须同时启用,缺一不可:前者防撞库轰炸,后者防 IP 扫描器
最易忽略的一点:所有 Redis 操作必须封装成原子脚本或使用带过期的 SetNX,任何拆成两步的逻辑(比如先 GET 再 SET)在高并发下都会出现竞态漏洞。防刷不是加一层 if,而是把判断和状态变更锁死在同一操作里。











