话不多说,直接上干货。限流这事儿,写业务代码时大家都在用,但很多同学要么直接导个包无脑裸奔,要么直接写个 Redis INCR 糊弄过去。今天把我们团队从单机抗压到分布式平滑限流的演进过程扒一扒,顺手贴下压测数据和踩过的坑。
一、 单机限流王炸:x/time/rate 到底有多快?
在绝大多数非集群强依赖的场景下,单机限流就能挡住 80% 的恶意刷量。Go 官方扩展包 golang.org/x/time/rate 是纯正的令牌桶(Token Bucket)算法实现。
1. 核心用法
它最常用的三个方法:Allow()、Reserve() 和 Wait()。在 Web 接口层,一般直接用 Allow():
package main
import (
"net/http"
"golang.org/x/time/rate"
)
var limiter = rate.NewLimiter(rate.Limit(5000), 10000) // r=5000/s, burst=10000
func RateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next(w, r)
}
}
2. Benchmark 剖析:小心锁竞争
很多同学以为令牌桶有个单独的后台 goroutine 在定时塞 token,其实根本没有。x/time/rate 完全是惰性计算(通过 now - last 计算时间差动态补 token)。
为了看它的性能极限,我用单机(Apple M2 Pro,10 核)跑了一组 Benchmark:
func BenchmarkRateLimiter_Allow(b *testing.B) {
limiter := rate.NewLimiter(rate.Inf, 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
limiter.Allow()
}
}
func BenchmarkRateLimiter_Allow_Parallel(b *testing.B) {
limiter := rate.NewLimiter(rate.Inf, 1000)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
limiter.Allow()
}
})
}
结果非常说明问题:
goos: darwin
goarch: arm64
BenchmarkRateLimiter_Allow-10 33682914 35.61 ns/op 0 B/op 0 allocs/op
BenchmarkRateLimiter_Allow_Parallel-10 7124804 168.4 ns/op 0 B/op 0 allocs/op
- 单 goroutine 下:35.6 ns 一次调用,零内存分配,快得飞起。
- 高并发争抢下:因为内部维护了一个全局的
sync.Mutex,耗时飙到了 168.4 ns。如果你的服务单实例 QPS 达到 10w+,这个锁竞争就会开始吞噬 CPU 周期。 - 避坑指南:超大流量下,建议按 UserID 或 IP 分桶,做
sync.Map+ 局部 Limiter,或者分片(Sharded Limiter)减小锁粒度。
二、 从单机到分布式:为什么固定窗口是个大坑?
服务上了 K8s,多 Pod 部署时,单机限流就无法控制下游总流量了。这时候大家第一反应通常是 Redis 的 INCR + EXPIRE(固定窗口)。
但固定窗口有个致命缺陷——临界区流量翻倍。
假设限制 1 秒 100 次,黑客在 00:00.900 打来 100 次,在 00:01.050 又打来 100 次。这两批请求各自都在自己的时间窗口内合法,但实际上在 150ms 内瞬间冲进来 200 次请求,下游数据库直接被打穿。
所以生产环境要做分布式,起步就是滑动窗口(Sliding Window Log)。
三、 分布式滑动窗口实战:Redis + Lua
滑动窗口最优雅的方案是借助 Redis 的 ZSET:
score和member都记录请求到达的时间戳(毫秒级)。- 每次请求进来,先移除窗口之外的旧数据:
ZREMRANGEBYSCORE key 0 (now - window)。 - 统计当前窗口内的数据量:
ZCARD key。 - 如果未超限,
ZADD写入当前时间戳,并设置窗口过期时间;超限则拒绝。
为了保证原子性,必须打包进 Lua 脚本。
1. Lua 脚本实现
-- KEYS[1]: 限流 key (如 ratelimit:api:user_123)
-- ARGV[1]: 当前毫秒时间戳
-- ARGV[2]: 窗口大小 (毫秒)
-- ARGV[3]: 窗口内最大允许请求数
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local clear_before = now - window
-- 1. 清理过期成员
redis.call('ZREMRANGEBYSCORE', key, 0, clear_before)
-- 2. 获取当前窗口内请求总数
local current_requests = redis.call('ZCARD', key)
-- 3. 判断是否超限
if current_requests < limit then
-- 写入当前请求
redis.call('ZADD', key, now, now)
-- 刷新 key 过期时间,避免冷数据常驻内存
redis.call('PEXPIRE', key, window)
return 1
else
return 0
end
2. Go 侧封装
package main
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
var slidingWindowScript = redis.NewScript(`... 上述 Lua 内容 ...`)
type RedisSlidingLimiter struct {
client *redis.Client
window time.Duration
limit int
}
func (l *RedisSlidingLimiter) Allow(ctx context.Context, key string) (bool, error) {
now := time.Now().UnixMilli()
windowMs := l.window.Milliseconds()
res, err := slidingWindowScript.Run(ctx, l.client, []string{key}, now, windowMs, l.limit).Int()
if err != nil {
return false, err
}
return res == 1, nil
}
四、 避坑与压测:天下没有免费的午餐
用 ZSET 方案很爽,但千万别直接无脑上大流量链路,以下是几个必须关注的坑点:
1. 性能开销剖析
我通过本地 redis-server(单线程跑满)做压测:
- 原生
INCR吞吐大约能抗 85,000 QPS。 - ZSET 滑动窗口脚本由于包含
ZREMRANGEBYSCORE+ZCARD+ZADD+PEXPIRE四个操作,单实例 Redis 吞吐暴跌至 28,000 ~ 32,000 QPS。 - 网络 RTT 惩罚:即使走内网,一次 Redis 交互通常也要
0.5ms - 1.5ms。直接在入口网关全量做 Redis 交互,主链路 P99 会显著被拉长。
2. ZSET 大 Key 与内存风暴
如果你的限流阈值很大(例如 10 秒允许 50,000 次),ZSET 内部会维护几万个元素,序列化和范围删除非常耗 CPU,内存也会直接炸裂。
- 优化方案:高并发大阈值场景,放弃精准的 ZSET 方案,改用滑动计数器(Sliding Window Counter),即切分成 5 到 10 个小格(Bucket)做计数求和。
3. 必须设计降级策略
Redis 挂了,业务接不接客?
很多团队限流报错直接抛 500,本末倒置。
在生产环境,必须用熔断器(如 Hystrix-go 或自研三色标记)包一层:Redis 连不上或调用超时(建议超时设为 15ms),默认直接降级为 Allow 并打报警日志,绝对不能因为限流器故障把主干业务拖垮。
五、 选型结论
- 纯接口防刷、小粒度防护:直接上
x/time/rate,无网络开销,开箱即用。高频下若有锁竞争,做分片 Limiter 即可。 - 集群精准配额控制:
- 小流量、强精准度:直接用文中的 Redis + Lua + ZSET。
- 大流量、允许微小误差:改用 Redis 分格滑动计数器。
- 超大并发秒杀级场景:千万别每次请求打 Redis,采用 Token 批量预拉取模式(本地限流 + 定时从 Redis 批量扣批额度),把网络交互降维两个数量级。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


