CDN边缘节点是距离用户最近的流量入口,把限速逻辑放在这里执行,可以避免请求先回源再判断的延迟。但边缘节点数量多、状态分散,简单的固定窗口计数容易产生边界突发,漏桶又会把突发流量削得太死。令牌桶算法在速率控制和突发容忍之间取得了平衡,而Lua脚本轻量、可嵌入的特点很适合在CDN边缘运行。下面从算法原理开始,逐步给出可运行的实现。

令牌桶算法解决什么问题
固定窗口限速通常以秒或分钟为单位重置计数器,比如限制每客户端每分钟最多60次请求。这种方式实现简单,但在窗口切换瞬间,客户端可以在上一秒末尾和下一秒开头连续发出接近两倍的请求,形成边界突发。滑动窗口能缓解这个问题,但需要存储更细粒度的时间戳,内存开销和计算成本更高。漏桶模型则强制请求以恒定速率流出,突发流量会被直接排队或丢弃,对正常业务不够友好。
令牌桶的核心思路是在桶中持续放入令牌,放入速率等于允许的平均请求速率。每个请求到达时必须先取走一个令牌,取不到就拒绝或限速。桶有最大容量,多余的令牌会被丢弃,因此桶满后允许客户端一次性消费桶内所有令牌,产生受控突发。例如容量设置为20,速率设置为10个每秒,那么客户端在桶满时可以瞬间发出20个请求,之后速率回落到10。这个特性非常适合CDN场景:正常浏览可能偶发加载多个静态资源,受控突发不会误伤用户。
与漏桶相比,令牌桶对突发的容忍更符合真实业务模型。漏桶输出速率恒定,即使当前桶内积压很多请求也不会加速处理;令牌桶则可以积累额度,在需要时快速消耗。对于CDN边缘节点来说,突发通常来自同一客户端的并行连接,令牌桶能让这些连接通过,同时仍限制长期平均速率。
Lua脚本在CDN边缘的实现要点
在CDN边缘执行Lua脚本通常依赖OpenResty或支持LuaJIT的Nginx环境。共享内存字典ngx.shared.dict适合保存令牌桶状态,它提供原子操作,单节点内可以避免大部分竞态。由于共享字典只能存储标量值,需要把令牌数量和最后补充时间拆成两个键,或者序列化成JSON字符串。对于多节点CDN集群,本地共享字典只能保证单节点限速,如果想实现全局统一限速,则要借助Redis执行原子Lua脚本。下面先给出本地共享字典的实现。
local limit = ngx.shared.limit local key = ngx.var.binary_remote_addr local rate = 10 local capacity = 20 local now = ngx.now() local tokens_key = key .. ':tokens' local last_key = key .. ':last' local tokens = limit:get(tokens_key) local last = limit:get(last_key) if tokens == nil then tokens = capacity last = now end local elapsed = now - last local add_tokens = elapsed * rate if add_tokens > 0 then tokens = math.min(capacity, tokens + add_tokens) last = now end if tokens < 1 then limit:set(tokens_key, tokens) limit:set(last_key, last) return ngx.exit(429) end tokens = tokens - 1 limit:set(tokens_key, tokens) limit:set(last_key, last)
这段代码把每个客户端IP作为key,初始桶为满容量。每次请求先根据当前时间与上次补充时间的差值计算应新增令牌,然后判断是否至少有一个令牌。不够直接返回429状态码,够则扣除一个令牌后放行。代码里增加了add_tokens大于0的判断,是为了避免在elapsed为0或极短时频繁写入共享字典。在实际压测中,这种优化可以减少锁竞争和内存写入次数。
如果CDN边缘节点有几十台甚至上百台,本地共享字典会导致同一个IP的请求分散到不同节点时限速被放大。比如两个节点各允许10个每秒,客户端轮询这两个节点实际能得到接近20的速率。要解决全局一致性问题,可以把令牌桶状态放到Redis,由所有边缘节点共享。下面给出一个Redis Lua脚本,它利用Redis单线程执行命令的特性保证原子性。
-- KEYS[1]: 限速key
-- ARGV[1]: rate
-- ARGV[2]: capacity
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = redis.call('time')[1] + redis.call('time')[2] / 1000000
local bucket = redis.call('hmget', key, 'tokens', 'last')
local tokens = tonumber(bucket[1])
local last = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
last = now
end
local elapsed = now - last
local add_tokens = elapsed * rate
if add_tokens > 0 then
tokens = math.min(capacity, tokens + add_tokens)
last = now
end
if tokens < 1 then
redis.call('hmset', key, 'tokens', tokens, 'last', last)
return 0
end
tokens = tokens - 1
redis.call('hmset', key, 'tokens', tokens, 'last', last)
return 1
Redis版本的思路与本地共享字典一致,但所有边缘节点调用同一个Redis实例,因此客户端无论被调度到哪个节点,消耗的都是同一个桶。需要注意Redis的time命令返回秒和微秒两部分,脚本里把微秒折算成小数秒,使令牌补充计算足够精确。生产环境建议为这些Redis键设置合理的TTL,避免长期不活跃的客户端留下大量过期状态。
生产环境中的调优与注意事项
速率和容量两个参数需要根据业务实际调整。速率一般对应允许的长期平均请求数,容量则影响突发峰值大小。以API接口为例,如果正常客户端可能在页面加载时同时发出8个请求,之后保持1个每秒的轮询,那么容量至少设置为10,速率可以设为2到5。容量太小会导致正常突发被拒绝,容量太大又会削弱限速效果。建议先在测试环境模拟最大突发流量,观察令牌桶是否出现大量429,再逐步收紧参数。
多节点下如果采用Redis集中式方案,一定要关注Redis的可用性和网络延迟。边缘节点和Redis之间的每次请求都会增加几毫秒到几十毫秒的额外开销,在高QPS场景可能成为瓶颈。可以用本地共享字典作为一级缓存,把集中式限速只用于需要全局精确控制的接口。也可以按照节点数量对容量和速率做加权分配,但这种方式在节点扩缩容时需要重新计算,维护成本较高。
监控和日志同样关键。CDN边缘脚本中应记录限速命中次数、被拒绝的客户端IP分布、令牌桶剩余量等指标,便于判断是正常限速还是误伤。出现误伤时可以先检查客户端是否经过代理或NAT出口,大量用户共享同一个出口IP会迅速耗尽令牌,此时需要结合更细粒度的标识,比如用户ID、设备指纹或URL路径分别建桶。另外,429响应最好带上Retry-After头,告诉客户端何时可以重试,避免客户端无脑重试加剧压力。
最后还要考虑时钟差异。本地共享字典使用ngx.now()基于系统时钟,边缘节点之间如果时钟不同步,对基于时间的令牌补充会造成偏差。Redis脚本虽然使用Redis服务器时间,但如果Redis与边缘节点时钟不一致,也会影响跨节点调度的公平性。建议统一使用NTP同步时间,或者改用单调时钟来记录时间差。总的来说,令牌桶加Lua的方案在CDN边缘限速中足够灵活,理解清楚状态存储和并发模型后,可以稳定控制流量、保护源站。

