一、令牌桶算法与分布式系统的适配痛点
1.1 单机令牌桶 vs 分布式令牌桶的差异
单机令牌桶就像一个部门专属的饮水机,每个部门自己管自己的水桶,加水、接水都按规则来,不会和其他部门冲突。而分布式系统是多个部门共享空间,每个部门都有自己的饮水机,总用水量根本没法统一控制——比如要求整个公司每秒最多接100杯水,每个部门各管各的,总用水量可能超过5倍,就会出现水不够、秩序混乱的问题。
1.2 分布式场景下原生令牌桶的问题
原生的单机令牌桶部署到分布式环境时,会出现“限流失效”的bug:比如电商大促时,恶意用户开了100台服务器,每台都按规则只接自己的10次请求,总请求量就会达到1000次,直接压垮服务。核心原因就是没有统一的规则,各实例自己算自己的令牌,全局的流量阈值被打破了。
二、令牌桶算法在分布式系统中的架构优化方案
2.1 基于Redis的分布式令牌桶核心思路
要解决这个问题,得把“水桶”放到公司的公共区域,让所有部门共用一个饮水机,这个公共区域的工具就是Redis——一个存数据的中间件,能被所有服务器访问。把令牌桶的关键数据(剩余令牌数、上次更新时间)存在Redis里,再用Lua脚本保证所有操作是“原子性”的,就像大家接水时必须拿统一的序列号,不会出现多个人同时接同一杯水的混乱。
2.2 具体优化后的代码示例
// 技术栈:Go 1.20 + Redis Go客户端(github.com/go-redis/redis/v8)
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"time"
)
var ctx = context.Background()
// DistributedTokenBucket 分布式令牌桶结构
type DistributedTokenBucket struct {
rdb *redis.Client // Redis客户端实例
bucketKey string // Redis中存储令牌桶数据的唯一键
capacity int // 令牌桶最大容量(最多能攒的令牌数)
rate int // 每秒生成令牌的速率
lastUpdate int64 // 上次更新令牌的时间戳(毫秒)
tokens float64 // 当前桶内剩余令牌数
}
// NewDistributedTokenBucket 初始化全局分布式令牌桶
func NewDistributedTokenBucket(rdb *redis.Client, key string, capacity int, rate int) *DistributedTokenBucket {
return &DistributedTokenBucket{
rdb: rdb,
bucketKey: key,
capacity: capacity,
rate: rate,
}
}
// Allow 检查当前请求是否允许通过,返回true则合法,false则被限流
func (tb *DistributedTokenBucket) Allow() (bool, error) {
// Lua脚本:保证操作原子性,避免多实例同时修改Redis导致数据混乱
// 脚本逻辑:计算当前时间差生成的新令牌,检查是否有足够令牌,更新桶数据
script := `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 从Redis获取上次更新时间和剩余令牌,默认值为初始容量
local lastUpdate = tonumber(redis.call('HGET', key, 'lastUpdate') or now)
local tokens = tonumber(redis.call('HGET', key, 'tokens') or capacity)
-- 计算时间差,生成对应数量的新令牌,最多不超过桶容量
local elapsed = now - lastUpdate
local newTokens = math.min(capacity, tokens + (elapsed / 1000) * rate)
-- 检查是否有1个以上令牌,有则消耗1个,无则限流
local allowed = newTokens >= 1
if allowed then newTokens = newTokens - 1 end
-- 更新Redis中的桶数据,保证后续请求拿到的是最新状态
redis.call('HSET', key, 'lastUpdate', now)
redis.call('HSET', key, 'tokens', newTokens)
return allowed and 1 or 0
`
// 执行Lua脚本,传入Redis键和参数
result, err := tb.rdb.Eval(ctx, script, []string{tb.bucketKey}, tb.capacity, tb.rate, time.Now().UnixMilli()).Result()
if err != nil {
return false, err
}
return result.(int64) == 1, nil
}
func main() {
// 初始化Redis客户端,连接本地Redis服务
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // 无密码时留空
DB: 0, // 使用默认数据库
})
// 初始化分布式令牌桶:全局容量100,每秒生成10个令牌
tb := NewDistributedTokenBucket(rdb, "global_api_limit_bucket", 100, 10)
// 模拟10次请求,测试限流效果
for i := 0; i < 10; i++ {
allowed, err := tb.Allow()
if err != nil {
fmt.Printf("第%d次请求异常: %v\n", i+1, err)
continue
}
if allowed {
fmt.Printf("第%d次请求: 允许通过\n", i+1)
} else {
fmt.Printf("第%d次请求: 被限流\n", i+1)
}
time.Sleep(100 * time.Millisecond) // 模拟请求间隔
}
}
三、优化方案的应用场景
3.1 API网关限流
所有外部用户的请求都会经过API网关,网关部署在多个实例上,用这个全局令牌桶控制总请求速率,比如每秒最多1000次,不管有多少个网关实例,都不会被恶意爬虫或攻击打挂,相当于公司大门的保安,控制总进门人数。
3.2 微服务间调用的流量控制
微服务架构下,服务之间互相调用,比如订单服务调用库存服务,两个服务都部署在多个实例,用同一个Redis里的令牌桶,控制总调用量,比如每秒500次,避免库存服务被调用太频繁,出现响应慢、宕机的问题。
3.3 消息队列的消费限流
Kafka、RabbitMQ等消息队列的消费者,可能有多个实例,用这个方案控制消费总速率,比如每秒消费1000条消息,避免下游服务被消费压力压垮,相当于快递站控制总卸货速度,不让堆积太多货。
四、方案的技术优缺点分析
4.1 优势
核心优势是全局统一控制,不管多少实例都能共享同一个限流规则,不会出现流量突破阈值的问题;兼容性好,只要能连Redis的服务都能用上;灵活调整,改Redis里的容量、速率就行,不用改代码;原子操作保证数据一致,不会出现多实例修改混乱。
4.2 劣势
依赖Redis,要是Redis单点故障,限流功能就失效了(可以降级为单机限流,但会损失全局一致性);比单机令牌桶多了一次Redis请求,延迟略高,不适合对延迟要求极高的场景;Lua脚本的调试成本比单机代码高,新手容易写错逻辑。
五、使用时的注意事项
5.1 Redis 实例的性能保障
Redis不能只用单点,要部署成集群或哨兵模式,避免单点故障;要保证Redis的响应速度,因为每次请求都要查Redis,Redis慢的话,整个限流逻辑也会慢;不要在Redis的桶key里存无关数据,防止key被误删导致限流失效。
5.2 时间同步问题的规避
所有应用服务器的时间必须同步,用NTP服务校准,要是服务器时间差超过1秒,计算令牌的时候就会多算或少算,比如时间差10秒、速率每秒10个,就会多算100个令牌,瞬间放行100个请求,压垮系统。
5.3 容量阈值的动态调整
要根据实际流量调整桶的容量和速率,比如电商大促时临时调高速率,平时调低;不要设太大的容量,不然令牌攒太多,100秒没请求就会攒1000个,瞬间放行1000个请求,远超系统承受能力,反而出问题。
六、方案总结
基于Redis的分布式令牌桶优化方案,完美解决了单机令牌桶在分布式场景下的限流失效问题,逻辑简单、易实现,适合绝大多数需要全局流量控制的场景,只要注意Redis稳定性、时间同步和阈值调整,就能有效避免流量突增、恶意请求导致的服务雪崩,提升系统的稳定性和资源利用率。
Comments