一、令牌桶算法与分布式系统的适配痛点

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稳定性、时间同步和阈值调整,就能有效避免流量突增、恶意请求导致的服务雪崩,提升系统的稳定性和资源利用率。