滑动窗口限流不能用time.now()+map,因其本质是固定窗口,存在临界突刺、并发panic和性能瓶颈;正确做法是单机用预分配[]int64+atomic,分布式用redis zset+lua脚本,并以服务端time命令获取毫秒级一致时间戳。

滑动窗口限流在 Go 中不能靠 time.Now() + map 简单拼凑,否则不是真滑动,而是固定窗口;并发写 map 会 panic,全局锁又拖垮性能——得用原子操作 + 预分配 slice 或 Redis ZSET 才靠谱。
为什么直接用 time.Now().Unix() 判断时间差是错的
这种写法本质是固定窗口:比如窗口设为 60 秒,你用 now.Unix() / 60 取整算桶 ID,那所有请求都被硬切进“0–59s”“60–119s”这样的整秒段。但真实场景中,用户在第 59 秒发了 100 次,第 61 秒又发 100 次,系统认为它们属于两个隔离窗口,完全没感知到这 2 秒内实际有 200 次——这就是临界突刺问题。
- 滑动窗口必须支持「以当前请求时间为终点、向前回溯 N 秒」的动态统计
- 正确做法是维护带时间戳的队列或分片桶,每个桶代表一个时间片(如 10ms),并保留最近 N 个片的计数
- 别用
time.Truncate()对齐窗口起点——它返回time.Time,做 key 或比较时易因精度/时区隐式出错 - 对齐应手动算:
now.UnixMilli() - now.UnixMilli()%windowMs,确保所有请求落在同一套时间轴上
单机场景下用 []int64 + atomic 实现轻量滑动窗口
适合 QPS 中等、不跨进程的场景(如网关单实例)。核心是避免锁和 GC,靠预分配数组 + 原子计数。
- 窗口总长
windowSec(秒),分片数shards,每片时长 =windowSec * 1e9 / shards(纳秒) - 当前片索引 =
(now.UnixNano() / shardDuration) % int64(shards),注意先转int64防溢出 - 过期判断必须用绝对时间:
slice[i]对应的时间戳是i * shardDuration,和now.UnixNano() - windowSec*1e9比较,不能只看索引差 - 更新必须用
atomic.AddInt64(&slice[i], 1),不能直接slice[i]++ - 每次请求进来时「懒清理」:遍历所有片,把时间戳早于窗口起点的位置清零(或跳过累加)
分布式场景必须用 Redis ZSET + Lua 脚本
INCR + EXPIRE 是固定窗口,且 EXPIRE 不准时;多实例下各机器本地时间差 > 窗口粒度(如 1 秒),结果直接失效。唯一可靠方案是 ZSET 存时间戳 + Lua 原子执行三步:清理、统计、写入。
- score 必须是毫秒级整数,统一精度;member 不能重复,建议用
fmt.Sprintf("%d_%s", now, uuid.NewString()[:8]) - Lua 脚本里必须调
redis.call("TIME")获取服务端时间,拼成毫秒时间戳:seconds * 1000 + microseconds / 1000 - 清理操作
ZREMRANGEBYSCORE必须放在ZADD之前,否则刚插入的请求可能被下一波清理掉 -
client.Eval(ctx, script, []string{key}, args)的第二个参数是 KEYS 列表,第三个才是 ARGV;ARGV 全是字符串,必须用tonumber()转型 - 返回值是
int64,Go 侧要显式转:result.(int64) == 1
最容易被忽略的边界点:时间对齐与单调性
线上容器环境常因 NTP 同步、虚拟机休眠恢复导致 time.Now() 突然回跳或跳进,造成窗口计算错乱。别依赖绝对时间戳做桶索引;改用单调时钟差值:time.Since(startTime)。另外,Redis 服务端时间必须开启 NTP 同步(推荐 chrony),误差控制在 100ms 内——否则哪怕脚本再严谨,时间戳一偏,整个滑动窗口就垮了。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











