直接用redis.Incr做限流会出错,因为Incr与Expire非原子操作,高并发下Expire可能失败导致key永久存在,或多个客户端同时创建无过期时间的key造成计数混乱。

为什么直接用 redis.Incr 做限流会出错
很多初学者一上来就写 redis.Incr + redis.Expire,以为能限制每分钟最多 10 次请求。但实际跑起来发现:并发高时计数超限、窗口边界不准、重置时机混乱。根本原因是这两个命令不是原子的——Expire 可能失败或被覆盖,而 Incr 在 key 不存在时会自动创建但不带过期时间,导致后续所有请求都打在同一个没过期的 key 上。
- 必须用 Lua 脚本把「增、查、设过期」三步打包成原子操作
- 不能依赖客户端判断是否首次访问再调
Expire,那是竞态温床 - Redis 6.2+ 的
INCRBYEX可替代部分场景,但 Go 客户端(如github.com/redis/go-redis/v9)默认不封装它,得手写Eval
用 redis.Eval 实现滑动窗口限流的最小可行脚本
滑动窗口比固定窗口更平滑,适合 API 网关类场景。核心是用一个有序集合(ZSET)存时间戳,每次请求清理旧成员再统计当前窗口内数量。Go 里调用的关键不是语法多难,而是脚本返回值怎么解析、错误怎么区分。
- 脚本里用
zremrangebyscore清理过期时间戳,再用zcard获取当前数量,最后zadd新时间戳 - Go 中传参顺序必须和脚本
KEYS[1]、ARGV[1]严格对应,比如:rdb.Eval(ctx, script, []string{"rate:api:/user"}, time.Now().Unix(), windowSec, maxCount) - 返回值是整数,但
redis.Int解包可能 panic,务必用redis.IntVal并检查err != nil - 别在脚本里用
now()—— Redis 服务器时间和应用服务器时间可能差几秒,统一用客户端传入的time.Now().Unix()
github.com/bsm/ratelimit 库在 Redis 场景下的真实约束
这个库常被推荐,但它底层用的是固定窗口(INCR + EXPIRE),且默认不启用原子脚本。直接 new 一个 ratelimit.RedisStore,如果没手动 patch Script 字段,就会掉进前面说的竞态坑里。
- 必须显式设置
Script字段为自定义 Lua 脚本,否则它只用Incr - 它的
Take方法返回bool, time.Duration,第二个值是“还需等待多久”,但这个等待时间是基于窗口剩余时间粗略估算的,不是精确滑动窗口结果 - 不支持按用户 ID + IP 复合 key,得自己拼
fmt.Sprintf("rl:%s:%s", userID, ip),拼错一个字符就失效 - 测试时容易漏掉
context.WithTimeout,Redis 延迟高时整个 HTTP 请求卡住,得加熔断逻辑
生产环境必须校验的三个 Redis 配置点
本地跑通不等于线上可用。很多限流失效是因为 Redis 配置和网络行为没对齐。
立即学习“go语言免费学习笔记(深入)”;
-
maxmemory-policy不能是noeviction—— 否则 key 永不过期,内存涨满后新INCR直接报OOM command not allowed when used memory > 'maxmemory' - 客户端连接池
MaxIdleConns和MaxActiveConns必须大于峰值 QPS,否则限流器自己先排队,误判为“超限” - Redis 集群模式下,key 必须用
{}包裹确保路由到同一 slot,比如rate:{user123}:api,否则EVAL脚本跨 slot 执行失败,报CROSSSLOT Keys in request don't hash to the same slot
滑动窗口的精度、Lua 脚本的健壮性、Redis 集群的 key 设计——这三处任一出错,限流就从“保护服务”变成“随机拦截”。调试时别只看 Go 日志,得同时 redis-cli monitor 抓真实命令流,才能看清是脚本没执行,还是返回值被误读。


















