API网关里配置限流规则时,令牌桶算法几乎是首选方案,但真正在生产环境把令牌桶配好、配准的人并不多。多数人照着文档把rate和burst一填就上线,结果要么限流过严误伤正常请求,要么突发流量直接把下游打垮。问题的核心在于,令牌桶的三个参数——速率、容量、令牌发放间隔——不是独立存在的,它们和业务流量模型、下游承载能力、客户端重试策略强耦合。

令牌桶算法的真实运行机制

令牌桶不是简单的“每秒放N个请求”,它是一个持续补充令牌的池子。桶的容量是burst,令牌以恒定速率rate生成,桶满则丢弃多余令牌。请求到达时,如果桶里有令牌就取走一个放行,没有令牌就直接拒绝或排队。这个机制的关键在于:rate决定了长期平均通过率,burst决定了能承受的瞬时并发峰值。很多人把burst设得很大,以为这样就能应对突发流量,但如果下游只能扛500QPS,你把burst设成2000,rate设成1000,瞬时2000个请求打过去,下游直接超时熔断,限流形同虚设。

参数配置的核心公式与误区

配置令牌桶时,首先要明确两个数字:下游真实承载上限S,以及业务能接受的最大排队延迟T。rate应该设为S的80%到90%,留出缓冲空间。burst的计算公式是burst = rate * T,T通常取1到2秒。比如下游承载1000QPS,你能接受200ms的额外延迟,那burst就是1000 * 0.2 = 200。这样当瞬时流量冲到1200时,前200个请求立刻放行,后续请求以1000QPS的速度通过,最坏情况下第1200个请求排队200ms。如果下游承载能力未知,必须先做压测拿到真实数据,而不是凭感觉填数字。

常见误区有三个:一是把burst设成rate的几倍但不考虑延迟容忍度,导致排队时间过长客户端超时;二是rate设得等于下游极限值,没有任何缓冲,下游稍有抖动就雪崩;三是忽略令牌桶的预热特性——冷启动时空桶,首批请求可能被限流,需要配合预热机制或在发布时先用小流量填满令牌桶。

不同网关产品的实现差异

Nginx的limit_req模块使用漏桶算法,但可以通过burst和nodelay参数模拟令牌桶行为。配置burst=100 nodelay时,就是典型的令牌桶——burst个请求立即通过,超出的按rate排队。Kong的rate-limiting插件默认是固定窗口计数器,要使用令牌桶需要开启local策略并配置redis集群做全局计数。APISIX的limit-req插件原生支持令牌桶,基于resty.limit.req库实现,每个工作进程独立维护令牌桶,适合单节点限流。对于需要全局限流的场景,APISIX支持用redis或etcd做集中式计数,但会引入网络延迟。

Envoy的本地限流使用令牌桶过滤器,配置在Listener或Route层级,token_bucket的max_tokens对应burst,tokens_per_fill对应每次填充的令牌数,fill_interval对应填充间隔。这三个参数的关系是rate = tokens_per_fill / fill_interval。Envoy的全局限流需要额外部署ratelimit service,通过gRPC调用,延迟通常在几毫秒,但高并发下可能成为瓶颈。

分布式环境下的令牌桶一致性难题

单机令牌桶好实现,但API网关通常是多节点部署,全局限流必须解决分布式计数问题。最简单的做法是用Redis维护一个全局令牌桶,用Lua脚本保证原子性。下面是一个生产可用的Redis令牌桶Lua脚本:

-- Redis令牌桶限流Lua脚本
-- KEYS[1]: 令牌桶key
-- ARGV[1]: 请求的令牌数(通常为1)
-- ARGV[2]: 令牌生成速率(每秒)
-- ARGV[3]: 桶的最大容量
-- ARGV[4]: 当前时间戳(毫秒)

local key = KEYS[1]
local requested = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local now = tonumber(ARGV[4])

-- 获取上次令牌数量和上次更新时间
local bucket = redis.call('HMGET', key, 'tokens', 'last_update')
local tokens = tonumber(bucket[1])
local last_update = tonumber(bucket[2])

if tokens == nil then
    -- 首次初始化,桶满
    tokens = capacity
    last_update = now
end

-- 计算新增令牌数
local delta = (now - last_update) * rate / 1000
tokens = math.min(capacity, tokens + delta)
last_update = now

-- 判断令牌是否足够
local allowed = tokens >= requested
if allowed then
    tokens = tokens - requested
end

-- 更新Redis
redis.call('HMSET', key, 'tokens', tokens, 'last_update', last_update)
redis.call('EXPIRE', key, 60)  -- 60秒无请求自动清理

return allowed and 1 or 0

这个脚本的问题在于每次请求都要调Redis,高并发时Redis压力大。优化方案有两个:一是本地预取令牌,每个网关节点从Redis批量获取一批令牌,在本地再做二次分配,减少Redis调用频率;二是使用异步批量上报,本地先扣减,定期同步到Redis做全局协调。这两种方案都会引入一定的精度损失,需要在准确性和性能之间做取舍。

多维度限流的令牌桶组合策略

实际业务中,单一维度的全局限流远远不够。一个典型的组合策略是:全局维度控制总流量,API维度控制单个接口,用户维度防止单用户滥用,IP维度防御爬虫。每个维度独立维护令牌桶,请求必须同时通过所有维度的限流检查。这种多级限流在实现时要注意检查顺序——先检查开销小的维度,比如IP维度的本地令牌桶,再检查开销大的维度,比如需要调Redis的用户维度。这样可以快速拒绝明显超限的请求,减少不必要的远程调用。

对于租户场景,还需要支持动态配额调整。比如VIP用户分配更高的rate和burst,普通用户使用默认配额。这要求令牌桶的参数能根据请求属性动态选择,而不是写死在配置里。Kong和APISIX都支持通过插件配置中的变量来动态指定限流参数,Envoy则需要通过ratelimit service的descriptors来实现分层限流。

令牌桶与熔断降级的联动

令牌桶只能控制请求通过的速率,无法感知下游服务的实际健康状态。当下游服务响应变慢时,如果令牌桶还在按原速率放行,积压的请求会迅速耗尽连接池,导致整个调用链崩溃。正确的做法是把令牌桶的rate和熔断器状态联动:当熔断器进入半开状态时,令牌桶的rate自动降级为正常值的10%,只允许少量请求探测下游恢复情况;当熔断器打开时,令牌桶的rate直接降为零,快速失败。这种联动可以通过网关的插件链实现,在限流插件中读取熔断器状态,动态调整令牌生成速率。

另一个容易被忽略的点是令牌桶的排队机制。当请求被限流排队时,网关需要维护一个等待队列。如果队列过长,内存会暴涨。必须给队列设置上限,超过上限直接返回429状态码,而不是无限排队。同时,排队中的请求要支持超时取消,客户端断开连接时立即从队列中移除,避免资源泄漏。

监控与调优的实战指标

令牌桶上线后,监控必须跟上。核心指标有三个:限流拒绝率、实际通过率、令牌桶饱和度。限流拒绝率突然飙升,要么是正常流量增长,要么是下游变慢导致请求堆积。实际通过率低于rate,说明令牌桶配置偏保守或者下游处理能力下降。令牌桶饱和度是当前令牌数除以容量,长期接近零说明burst不够用,长期接近满说明rate设得过高。

调优时遵循一个原则:先压测确定下游极限,再按极限的80%设rate,burst根据可接受延迟反推,上线后观察一周,根据监控数据微调。不要一次性调整多个参数,每次只改一个,观察效果后再改下一个。很多故障都是因为同时调大rate和burst,以为能提升吞吐,结果把下游打挂。

令牌桶不是银弹,它解决的是流量整形问题,不解决下游容量问题。如果下游处理能力本身就弱,再精细的限流也只是让系统死得慢一点。真正的解法是限流加弹性伸缩,当令牌桶饱和度持续偏低时,自动触发下游服务的扩容,从根本上提升系统承载能力。