滑动窗口限流:我们在 API Gateway 里是怎么做的
一个真实项目的限流实现记录。不用 Redis,不用令牌桶,纯内存 Go 实现,200 行代码搞定 per-user 限流。
背景
我们有一个多租户的 API Gateway(Go + Gin),后面挂了 4 个业务后端。每个用户通过 API Key 鉴权,不同用户有不同的请求频率限制(RPM,Requests Per Minute)。
需求很简单:
- 每个用户独立限流,互不影响
- 限额从数据库读,管理员随时可调
- 超限返回 429,不排队不等待
- 不引入 Redis(单机部署,没必要)
为什么选滑动窗口
常见的限流算法有四种:
| 算法 | 优点 | 缺点 |
|---|---|---|
| 固定窗口 | 实现最简单 | 窗口边界有突发问题 |
| 滑动窗口 | 无突发、精确 | 内存存时间戳 |
| 漏桶 | 匀速输出 | 不适合突发合理的场景 |
| 令牌桶 | 允许突发 | 实现复杂 |
我们选滑动窗口,原因:
- 不想让用户在窗口边界打出双倍请求(固定窗口的经典问题)
- 不需要匀速(用户打 API 本来就是突发的,只要总量不超就行)
- 内存占用极小(每个用户最多存 RPM 个时间戳,20 RPM = 160 字节)
固定窗口的突发问题
先说清楚为什么不用固定窗口。
假设限制 20 RPM,固定窗口按整分钟重置:
时间线:
|-------- 15:20:00 --------|-------- 15:21:00 --------|
↑ 计数器重置
15:20:58 用户打了 20 个请求 → 计数=20,刚好满,全部放行
15:21:01 用户又打 20 个请求 → 新窗口!计数=20,又满,全部放行
结果:3 秒内通过了 40 个请求。限流形同虚设。
滑动窗口怎么解决
滑动窗口不按整分钟切割,而是永远看"当前时刻往前推 60 秒"这个窗口:
15:21:01 来了一个请求
往回看 60 秒 → 15:20:01 到 15:21:01
这段时间内已经有 20 个了(15:20:58 那批)
→ 拒绝!
必须等到 15:20:58 那批"滑出"窗口(到 15:21:58 之后),才能继续请求。
没有边界突发问题。任何 60 秒的滑动窗口内,最多只有 RPM 个请求。
数据结构
核心就是一个 map,key 是用户 ID,value 是最近 60 秒内的请求时间戳数组:
type UserRateLimiter struct {
mu sync.Mutex
windows map[string]*slidingWindow
}
type slidingWindow struct {
timestamps []time.Time
rpm int
}
核心算法:Allow
每次请求进来,做四件事:
func (rl *UserRateLimiter) Allow(userID string, rpm int) bool {
if rpm <= 0 {
return true // 0 = 不限流(VIP 用户)
}
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-time.Minute) // 窗口起点:1 分钟前
w := rl.windows[userID]
if w == nil {
w = &slidingWindow{rpm: rpm}
rl.windows[userID] = w
}
w.rpm = rpm // 动态更新限额(管理员改了立即生效)
// 第一步:清理过期的时间戳
valid := w.timestamps[:0]
for _, ts := range w.timestamps {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
w.timestamps = valid
// 第二步:检查是否超限
if len(w.timestamps) >= rpm {
return false // 超了,429
}
// 第三步:记录本次请求
w.timestamps = append(w.timestamps, now)
return true
}
画个图:
用户 "东东",RPM = 3
时间线:
00:00 ─────────────────────────────────── 01:00 ─────── 01:15
│ │
├─ 请求1 (00:10) ✅ [00:10]
├─ 请求2 (00:25) ✅ [00:10, 00:25]
├─ 请求3 (00:40) ✅ [00:10, 00:25, 00:40]
├─ 请求4 (00:50) ❌ 已有3个,拒绝!
│
│ ... 时间过去 ...
│
└─ 请求5 (01:15)
cutoff = 01:15 - 60s = 00:15
清理:00:10 < 00:15 → 扔掉
剩下:[00:25, 00:40]
2 < 3 → ✅ 放行!
更新:[00:25, 00:40, 01:15]
在 Gateway 里怎么用
我们的 Gateway 鉴权流程:
请求进来
→ 提取 Bearer token
→ 调用户服务 /internal/check(返回 user_id + balance + rate_limit)
→ 余额检查(≤0 → 403)
→ 限流检查(Allow(user_id, rate_limit) → false → 429)
→ 放行,转发到后端
关键点:rate_limit 是从用户服务实时拿的,不是写死在代码里。管理员在后台改了某个用户的 RPM,下一次请求就用新值。不需要重启 Gateway。
// auth.go 中间件
func (ua *UserServiceAuth) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
// ... 鉴权逻辑 ...
// 限流检查
if !ua.rateLimiter.Allow(userID, result.RateLimit) {
c.AbortWithStatusJSON(429, gin.H{
"error": "rate limit exceeded",
"rate_limit": result.RateLimit,
"retry_after": 60,
})
return
}
c.Next()
}
}
429 响应体里带了 rate_limit 和 retry_after,客户端知道自己的限额是多少、该等多久。
内存管理:防泄漏
如果用户请求一次后再也不来了,他的时间戳数组会一直留在内存里。虽然数组会被清空(过期的都扔了),但 map entry 还在。
解决:后台每 5 分钟扫一次,超过 5 分钟没请求的用户直接从 map 里删掉:
func (rl *UserRateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
cutoff := now.Add(-5 * time.Minute)
for uid, w := range rl.windows {
if len(w.timestamps) == 0 ||
w.timestamps[len(w.timestamps)-1].Before(cutoff) {
delete(rl.windows, uid)
}
}
rl.mu.Unlock()
}
}
在 NewUserRateLimiter() 时用 go rl.cleanup() 启动这个后台 goroutine。
并发安全
整个 Allow 方法在 sync.Mutex 锁内执行。为什么不用 sync.RWMutex?
因为 Allow 既读(检查数量)又写(追加时间戳),每次调用都是写操作,RWMutex 没有意义。单个 Mutex 最简单,性能也够(锁内操作是纯内存数组操作,微秒级)。
内存占用分析
每个时间戳:8 字节(time.Time 内部是 int64)
每个用户最多存 RPM 个时间戳
RPM=20 的用户:20 × 8 = 160 字节
RPM=60 的用户:60 × 8 = 480 字节
1000 个活跃用户(RPM=20):160 KB
10000 个活跃用户:1.6 MB
完全不是问题。
实际效果
测试:把某用户 RPM 设为 3,快速打 5 个请求:
$ for i in 1 2 3 4 5; do
echo -n "$i:";
curl -s -o /dev/null -w "%{http_code} " -H "Authorization: Bearer $KEY" $URL/health;
done
1:200 2:200 3:200 4:429 5:429
前 3 个放行,第 4、5 个被拒。等 60 秒后窗口滑过,又能打了。
跟 Redis 方案的对比
| 维度 | 我们的方案(内存) | Redis 方案 |
|---|---|---|
| 依赖 | 无 | 需要 Redis 实例 |
| 延迟 | 微秒级(内存操作) | 毫秒级(网络 RTT) |
| 一致性 | 单机精确 | 分布式精确 |
| 多实例 | ❌ 每个实例独立计数 | ✅ 共享计数 |
| 适用场景 | 单机 / 少量实例 | 多实例水平扩展 |
| 运维成本 | 零 | 需要维护 Redis |
我们是单机部署,不需要多实例共享计数,所以内存方案完全够用。如果以后要水平扩展,再换 Redis 也就是把 Allow 方法的实现换掉,接口不变。
完整代码
package main
import (
"sync"
"time"
)
type UserRateLimiter struct {
mu sync.Mutex
windows map[string]*slidingWindow
}
type slidingWindow struct {
timestamps []time.Time
rpm int
}
func NewUserRateLimiter() *UserRateLimiter {
rl := &UserRateLimiter{
windows: make(map[string]*slidingWindow),
}
go rl.cleanup()
return rl
}
func (rl *UserRateLimiter) Allow(userID string, rpm int) bool {
if rpm <= 0 {
return true
}
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-time.Minute)
w, ok := rl.windows[userID]
if !ok {
w = &slidingWindow{rpm: rpm}
rl.windows[userID] = w
}
w.rpm = rpm
valid := w.timestamps[:0]
for _, ts := range w.timestamps {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
w.timestamps = valid
if len(w.timestamps) >= rpm {
return false
}
w.timestamps = append(w.timestamps, now)
return true
}
func (rl *UserRateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
cutoff := now.Add(-5 * time.Minute)
for uid, w := range rl.windows {
if len(w.timestamps) == 0 ||
w.timestamps[len(w.timestamps)-1].Before(cutoff) {
delete(rl.windows, uid)
}
}
rl.mu.Unlock()
}
}
总结
滑动窗口限流的核心就是:存时间戳,数数量,超了就拒。
适合的场景:
- 单机部署
- per-user 限流
- 不想引入外部依赖
- RPM 级别的限流(不是 QPS 万级)
不适合的场景:
- 多实例需要共享计数 → 用 Redis
- 超高 QPS(百万级)→ 用令牌桶或漏桶
- 需要精确到毫秒级的限流 → 用令牌桶
对我们来说,20 RPM 的 API Gateway,几十个用户,纯内存滑动窗口是最简单最合适的选择。
陕公网安备61011302002223号