凌晨两点,我被报警电话薅起来——订单服务的数据库连接池被打满,大量请求直接 504。查了一圈,发现是营销活动引来了三倍流量,而我们那套基于内存的单机令牌桶限流,在三副本的 Pod 里各自为战,全局限流形同虚设。那一刻我深刻体会到:只要状态不共享,限流就是个摆设。
问题拆解
这种事在微服务架构里太常见了。业务侧为了防止下游被打垮,通常会限流,比如“每个实例最多接受 200 QPS”。部署三个实例,你以为全局流量会被压到 600 QPS,但实际上流量均衡稍有倾斜,某个实例的 200 用完、其他两个还有余量,最终打到下游的峰值轻松超 900。这就是单机限流在多实例场景下的致命缺陷——限流逻辑被实例边界切割,全局视角下变成“纸上限流”。
根因很简单:令牌桶的 「当前令牌数」和「最后填充时间」 这些状态全在内存里,没有跨实例共享。常规的 Redis 计数固定窗口方案(比如 INCR + EXPIRE)虽然能共享状态,但存在边界突刺问题——第一秒的最后 100ms 和第二秒的最初 100ms 可能叠加出双倍流量,对下游依然危险。我们需要一个既共享状态、又能平滑流量的分布式令牌桶。
方案设计
选型:Go + Redis + Lua 脚本实现分布式令牌桶。
为什么不选其他方案?
- Nginx/网关限流:增加一层代理跳数,且和业务逻辑解耦后很难做精细化控制(比如按用户、按接口的混合限流)。
- 纯 Redis 滑动窗口:可以用 ZSET 实现,但每次要清理过期成员,内存和 CPU 开销大,而且算法复杂度高,容易写出性能瓶颈。
- Go 生态的分布式限流库:很多已经年久失修,或者只支持简单的固定窗口,对令牌桶的支持不够灵活。
最终架构就很简单:把令牌桶的核心状态(tokens、last_refill_time)存到 Redis 里,通过一段 Lua 脚本原子地计算和更新,保证无论多少请求并发过来,Redis 单线程模型都能保证互斥。应用层封装一个 DistributedTokenBucket,内部集成降级逻辑:如果 Redis 不可用(超时、连接断开),自动 fallback 到本地的 golang.org/x/time/rate 令牌桶。这样一来,即使 Redis 整个宕机,也不会把下游直接冲垮,而是退化成单机限流——保护下游的底线还在。
核心实现
下面这段代码定义了 Lua 脚本,它负责原子性地完成“令牌生成 + 消费判断”。把时间戳作为参数传入,避免依赖多实例的系统时钟不一致(都用应用服务器时间相对公平,或者改用 redis.call('TIME') 也行,看你对一致性的偏执程度)。
// 这段代码解决:如何用一段 Lua 保证“计算新增令牌 -> 判断是否足够 -> 扣减”的原子性
const tokenBucketLua = `
local key = KEYS[1] -- 令牌桶 key
local rate = tonumber(ARGV[1]) -- 每秒生成令牌数
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
local requested = tonumber(ARGV[4]) -- 请求令牌数
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
if tokens == nil then
-- 首次访问,初始化令牌桶
tokens = capacity
last_refill = now
end
-- 计算经过的时间及新增令牌数
local delta = math.max(0, now - last_refill)
local new_tokens = math.floor(delta * rate / 1000)
tokens = math.min(capacity, tokens + new_tokens)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
-- 更新 Redis 中的状态,并设置一个合理的 TTL 防止冷数据残留
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return {allowed, tokens}
`
接下来是 Go 结构体与核心 Take 方法。它的职责是执行 Lua、处理 Redis 错误,并在失败时启动降级。
// 这段代码解决:封装 Redis 调用,提供限流入口,并在 Redis 不可用时降级到本地令牌桶
import (
"context"
"errors"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
type DistributedTokenBucket struct {
rdb *redis.Client
script *redis.Script
key string
rate float64 // 令牌/秒
capacity int // 桶容量
fallback *rate.Limiter // 本地降级限流器
}
func NewDistributedTokenBucket(rdb *redis.Client, key string, ratePerSec float64, capacity int) *DistributedTokenBucket {
// 本地降级器:容量和速率取全局值的一部分,保护下游
fallbackLimiter := rate.NewLimiter(rate.Limit(ratePerSec), capacity)
return &DistributedTokenBucket{
rdb: rdb,
script: redis.NewScript(tokenBucketLua),
key: key,
rate: ratePerSec,
capacity: capacity,
fallback: fallbackLimiter,
}
}
func (dtb *DistributedTokenBucket) Take(ctx context.Context, n int) (bool, error) {
now := time.Now().UnixMilli()
result, err := dtb.script.Run(ctx, dtb.rdb, []string{dtb.key}, dtb.rate, dtb.capacity, now, n).Result()
if err != nil {
// Redis 不可用 → 降级
return dtb.fallback.AllowN(time.Now(), n), nil
}
// result 是 []interface{},第一个元素是 allowed 标记
arr, ok := result.([]interface{})
if !ok || len(arr) != 2 {
return false, errors.New("unexpected redis response")
}
allowed, _ := arr[0].(int64)
return allowed == 1, nil
}
为了让你立刻集成到 HTTP 服务里,这里再给出一个中间件示例,直接在请求入口拦住超频流量。
// 这段代码解决:如何把分布式令牌桶嵌入 HTTP 中间件,一刀切拦截超限请求
import (
"net/http"
"strconv"
)
func RateLimitMiddleware(dtb *DistributedTokenBucket) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
allowed, err := dtb.Take(r.Context(), 1)
if err != nil || !allowed {
w.Header().Set("X-RateLimit-Limited", strconv.FormatBool(true))
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":"too many requests"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
踩坑记录
坑一:多实例时钟不同步导致令牌发放不均
现象:压测时发现,明明全局限流 600 QPS,但某一台机器抢到了 80% 的令牌,其他两台几乎被饿死。原因是应用服务器时间不同,传入 now 参数的时间戳快的实例会在“时间窗口”里占到便宜。
解决:强制使用 Redis 的 TIME 命令获取时间,在 Lua 里改为 local t = redis.call('TIME') 然后拼接毫秒。但注意 TIME 有微弱延迟,对限流精度影响在可接受范围内。官方文档没明说 TIME 的精度,但实测偏差在 1ms 以内。
坑二:Redis 连接池配置不当,高并发下降级失效
现象:线上流量一上来,Take 方法返回大量 context deadline exceeded,触发降级。但本地 Limiter 因为配置的速率过小(只取了 1/3),导致大量请求直接被拒,客户投诉“服务像死了一样”。排查发现 Redis 连接池 MaxActive 只有 50,远小于并发量。
解决:把连接池调到 200,并给 Redis 调用加上 50ms 的超时控制,同时本地降级限流器的速率提升到全局速率的 80%,让降级时有“大部分流量可以过”,而非一刀切拒绝。这个平衡点是官方文档永远不会告诉你的。
效果验证
用 wrk 对三实例服务做压测,全局令牌桶设置 600 QPS、容量 100。优化前(单机内存限流各 200),下游实际收到流量峰值 937 QPS,DB CPU 飙到 90%;优化后(Redis 分布式令牌桶),下游流量始终稳定在 600 ± 20 QPS,抖动极小。即使在中间故意 Kill 掉 Redis 并观测降级行为,下游峰值也只短暂上冲到 700,随后回落——降级策略稳稳接住了。
| 场景 | 下游实际峰值 QPS | 下游稳定性 |
|---|---|---|
| 单机内存限流(3副本各200) | 937 | 崩溃 |
| Go+Redis 分布式令牌桶 | 620 | 平稳 |
| Redis 宕机 + 本地降级 | 700 | 短暂波动后恢复 |
可直接用的代码/工具
如果你已经有了 Redis,一行命令就能拉起示例(Go 1.21+):
go get github.com/redis/go-redis/v9 golang.org/x/time/rate
然后把上面的代码粘贴进去,替换你的 Redis 地址,立刻获得分布式限流能力。
#Go #Redis #后端 #限流 #分布式
关于作者
我是一名实战派后端/架构开发者,长期在 Go 和分布式系统里摸爬滚打,踩过的坑比写过的文还多。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省了一个通宵,可以请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege