最近在做网关层的重构,把我们服务里的限流模块从单机版一路推到了分布式集群版。很多兄弟一听到限流就直接想上 Redis,但其实脱离业务并发量和架构阶段去谈方案都是扯淡。
今天直奔主题,结合我平时跑的 Benchmark 数据,聊聊从单机 golang.org/x/time/rate 到分布式限流的落地和踩坑演进。
一、 单机王牌:golang.org/x/time/rate
官方扩展库里的 rate.Limiter 是基于**令牌桶算法(Token Bucket)**实现的。简单直接,支持突发流量(Burst),非常适合单实例进程内限流。
1. 核心用法
package main
import (
"context"
"fmt"
"time"
"golang.org/x/time/rate"
)
func main() {
// 每秒生成 10 个令牌,桶容量为 20(允许突发 20 QPS)
limiter := rate.NewLimiter(rate.Limit(10), 20)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
// 1. 阻塞等待:适合消息消费
if err := limiter.Wait(ctx); err != nil {
fmt.Println("Wait 超时或被取消:", err)
}
// 2. 非阻塞判断:适合 HTTP 接口直接拒绝 (429 Too Many Requests)
if limiter.Allow() {
fmt.Println("放行请求")
} else {
fmt.Println("触发限流")
}
}
2. 底层实现与 Benchmark 表现
很多人以为令牌桶内部有个后台 Goroutine 在定时往桶里放 Token,其实根本没有。
rate.Limiter 内部只记录了上一次取令牌的时间戳 last 和当前剩余令牌数 tokens。每次调用 Allow() 或 Reserve() 时,通过 time.Since(last) 动态推算这段时间内新增了多少令牌(懒加载计算),然后拿一把 sync.Mutex 做保护。
我写了个简单的 Benchmark,看看 8 核并发争抢这把互斥锁时的损耗:
goos: darwin
goarch: arm64
pkg: demo/ratelimit
BenchmarkRateLimiter_Allow-8 13842159 86.42 ns/op 0 B/op 0 allocs/op
BenchmarkUberRateLimit_Take-8 24183742 49.31 ns/op 0 B/op 0 allocs/op
注:对比的是 Uber 的 go.uber.org/ratelimit(基于漏桶算法,使用 atomic.Value 做无锁 CAS)。
从数据可以看出,rate.Limiter 在高并发场景下由于 sync.Mutex 的锁竞争,单次判定耗时大概在 86ns 左右,零内存分配。对 99% 的单机业务场景来说,这个开销完全可以忽略不计。
二、 为什么单机限流不够用?
当我们的服务从单实例扩容到 50 个 Pod 时,问题就来了:
- 流量不均:负载均衡(如 Nginx / K8s Ingress)无法保证绝对轮询,某些 Pod 容易被打爆。
- 总阈值漂移:原本下游只扛得住 5000 QPS,每个 Pod 配 100 QPS。如果 HPA 自动缩容到 20 个 Pod,总放行流量就跌到了 2000 QPS;如果扩容到 100 个 Pod,下游直接被 10000 QPS 冲垮。
这时候必须把限流状态从本地内存抽离出来,下沉到集中式存储(通常是 Redis)。
三、 分布式限流:Redis + Lua 脚本
分布式限流的核心原则:判定与扣减必须保证原子性。最成熟的做法就是使用 Redis 执行 Lua 脚本。
这里给出生产环境最常用的**滑动时间窗口计数器(Sliding Window Counter)**实现:
1. Lua 脚本 (sliding_window.lua)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- 清理窗口外的过期数据
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
-- 获取当前窗口内的请求数
local current_requests = redis.call('ZCARD', key)
if current_requests < limit then
-- 写入当前请求的时间戳作为 score 和 member
redis.call('ZADD', key, now, now)
-- 设置 key 过期时间,防止冷数据常驻
redis.call('EXPIRE', key, math.ceil(window / 1000))
return 1
else
return 0
end
2. Go 客户端封装
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var slidingWindowLua = redis.NewScript(`
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
local current = redis.call('ZCARD', key)
if current < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, math.ceil(window / 1000))
return 1
else
return 0
end
`)
type RedisLimiter struct {
client *redis.Client
}
func (r *RedisLimiter) Allow(ctx context.Context, key string, window time.Duration, limit int64) (bool, error) {
now := time.Now().UnixMilli()
windowMs := window.Milliseconds()
res, err := slidingWindowLua.Run(ctx, r.client, []string{key}, now, windowMs, limit).Result()
if err != nil {
return false, err
}
return res.(int64) == 1, nil
}
四、 性能剖析与避坑指南
上完 Redis 分布式限流之后,千万别觉得万事大吉了。我们在压测和生产环境遇到过两个典型问题:
1. 网络 RTT 拖慢核心链路
- 单机
rate.Limiter只要 86ns; - 跨节点访问同机房 Redis,一次网络 RTT 大概 0.5ms ~ 1.5ms(500,000ns ~ 1,500,000ns)。
- 耗时直接飙升了上千倍。
优化方案:批量预取令牌(Local Batch Allocation)
Pod 每次不向 Redis 申请 1 个 Token,而是批量申请(比如一次拿 100 个),放在本地 rate.Limiter 里消费,用完了再去 Redis 同步。把网络 RTT 频率降低两个数量级。
2. ZSET 大 Key 与热 Key 崩塌
上面的滑动窗口如果遇上单 Key QPS 突破 5w 的超热点(比如秒杀接口),Redis 单分片 CPU 会直接被打满,ZSET 的成员过多也会消耗大量内存。
优化方案:
- 极端高并发接口放弃滑动窗口,改用 Redis 单 String Key 自增(INCRBY)+ 固定窗口,虽然边界流量会有 2 倍突刺,但性能极高(O(1) 复杂度)。
- 配合本地缓存(如
sync.Map或 BigCache)做两级限流:本地先挡掉 80% 的非法请求,剩下的再去打 Redis。
五、 总结与选型建议
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
x/time/rate | 单机服务、后台 Worker、低成本网关 | 极快(<100ns)、零网络依赖 | 无法跨节点共享状态 |
| Redis + Lua (滑动窗口) | 严谨的分布式接口限流(如支付、下单) | 精确、平滑、跨 Pod 共享 | Redis 网络 RTT、热 Key 压力 |
| 本地批量 + 集中分配 | 超高并发网关层、海量微服务集群 | 平衡了网络延迟与全局限流准确度 | 实现复杂度高、存在一定额度浪费 |
架构没有银弹。并发量不高时,单机 rate.Limiter 加点余量足够扛打;并发规模上来后,上 Redis + Lua 时一定要做好 Redis 故障时的降级(Fail-open)策略,别让限流组件反倒成了系统挂掉的第一诱因。
许可协议:CC BY-NC 4.0
更新于 2 小时前
觉得文章有帮助?点个赞吧!
0 条评论


