令牌桶算法是网络流量整形中最常用的限流方案之一,它通过一个虚拟的令牌桶来协调请求通过速率。桶中令牌以固定速率持续生成,每个请求到来时需要先从桶中取走一个令牌,取不到则请求被拒绝或等待。桶容量设定了短期突发流量的上限,也就是当桶满时多余的令牌会被丢弃,避免无限积压。这种机制天然适合需要平滑速率调整的场景,比如API网关根据下游服务负载动态调低放行速率,或者下载器根据网络状况实时调整带宽占用。

要将速率调整做得平滑,关键不在于频繁修改限流参数,而在于保证令牌补充逻辑的一致性。Ruby作为一门动态语言,借助互斥锁和单调时钟可以很简洁地实现一个线程安全的令牌桶。下面从算法模型、核心类实现、速率调整策略和测试验证几个部分展开。
令牌桶的计数模型与速率调整需求
令牌桶在任意时刻的状态由三个变量决定:当前令牌数、桶容量、令牌填充速率。每次检查请求时,需要先根据距离上次补充的时间差计算新增令牌数,再判断是否足够消耗。这一过程如果用系统墙上时钟来做,可能会因为NTP时间调整或系统休眠导致时间倒退,从而计算出负的令牌增量。所以在Ruby中推荐使用单调时钟,它只往前增长,不受系统时间修改影响,最适合计算时间间隔。
速率调整的典型场景是运行时动态改变填充速率。比如一个文件传输服务,平时允许每秒生成10个令牌,检测到上游带宽紧张时要把速率降到每秒2个令牌。如果直接修改填充速率变量,原本桶里积攒的大量令牌会在新速率下被快速消耗,形成流量突刺;反之,如果速率提升,新速率下令牌补充变快,又可能出现瞬间大量放行。要避免这些问题,需要在调整速率时先完成一次令牌补充,并按比例修正当前令牌数,让桶内水位与新速率相匹配。
实践中常采用“按比例缩放”策略:假设旧速率为4令牌/秒,新速率为2令牌/秒,桶内当前有6个令牌,那么调整后应将令牌数缩放为6乘以(2/4)等于3,这样可以保持相同的突发时间窗口。这种方式对短连接流量整形尤其有效,能防止速率切换瞬间的请求冲击。
用Ruby实现线程安全的令牌桶限流器
下面给出一个完整的TokenBucket类,使用Mutex保护共享状态,通过Process.clock_gettime获取单调时间。初始化时指定桶容量和填充速率,速率单位统一为每秒令牌数。代码中的refill_tokens方法负责根据时间差补充令牌,最多补到容量上限。allow_request?方法在同步块内先补充令牌,再判断是否满足消耗条件。
require 'thread'
class TokenBucket
def initialize(capacity:, fill_rate:)
@capacity = capacity.to_f
@fill_rate = fill_rate.to_f
@tokens = @capacity
@last_refill = monotonic_time
@mutex = Mutex.new
end
def allow_request?
@mutex.synchronize do
refill_tokens
if @tokens >= 1.0
@tokens -= 1.0
true
else
false
end
end
end
def adjust_rate(new_rate)
@mutex.synchronize do
refill_tokens
old_rate = @fill_rate
# 按比例缩放当前令牌,避免速率突变造成突刺
if old_rate > 0
@tokens = @tokens * (new_rate.to_f / old_rate)
end
@tokens = [[@tokens, 0.0].max, @capacity].min
@fill_rate = new_rate.to_f
end
end
private
def refill_tokens
now = monotonic_time
elapsed = now - @last_refill
@tokens += elapsed * @fill_rate
@tokens = @capacity if @tokens > @capacity
@last_refill = now
end
def monotonic_time
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
上面的实现中,adjust_rate方法是平滑调整的核心。它先调用refill_tokens把当前时间之前的令牌补齐,然后根据新旧速率的比例缩放现有令牌数,最后用clamp逻辑确保令牌数不超出合法范围。这样做的好处是,无论速率从高到低还是从低到高,桶内令牌对应的突发时间长度基本保持不变,不会产生额外的流量尖峰。
需要注意的是,在Ruby并发环境下,所有对@tokens、@fill_rate、@last_refill的读写都必须在同一个互斥锁保护下进行,否则会出现竞态条件。上面的代码把公共方法都放进synchronize块,私有方法refill_tokens只在锁内部被调用,保证了数据一致性。另外,单调时钟的使用使得程序即使运行在虚拟机中或系统休眠后恢复,时间差计算也不会出错。
速率调整的平滑策略与补偿机制
仅仅按比例缩放令牌数还不够,某些场景下还需要加入额外的补偿逻辑。例如一个限流器长期处于满桶状态,此时要把速率从每秒100令牌降到每秒1令牌,按比例缩放后令牌数可能还是接近容量上限,因为旧速率下桶一直是满的,缩放后仍然会保留大量令牌,低速率下这些令牌会允许短时间大量请求通过。针对这种情况,可以在调整速率时引入一个“目标突发时间”参数,超过该时间对应的令牌数就强制丢弃,只保留与目标突发时间相匹配的令牌量。
更精细的做法是区分速率上升和下降两种调整方向。速率下降时,主动丢弃超出新速率在目标突发时间窗口内能产生的令牌数,防止积压令牌被快速消耗;速率上升时,则不需要额外丢弃,只做常规补充即可。可以通过在adjust_rate中增加可选参数smooth_window来实现,默认值为1秒,表示调整后最多保留新速率下1秒内能产生的令牌数。实现时计算出max_allowed_tokens = new_rate * smooth_window,然后将@tokens与它比较取较小值。
除了令牌数的补偿,填充速率本身的变更也可以做渐进式处理。例如在1秒内分多次线性地从旧速率过渡到新速率,而不是一次性跳变。对于Ruby实现来说,可以启动一个后台线程按小步长更新@fill_rate,每次间隔固定时间。这种方法适合对流量曲线要求特别平滑的场景,但会增加线程管理复杂度。对于大多数应用,一次性调整配合令牌缩放已经足够,而且不会引入额外的延迟和调度开销。
另外,实际业务中往往希望限流器具备“尝试获取多个令牌”或“带超时等待”的能力。可以在TokenBucket类中增加try_acquire(count, timeout)方法,在锁内循环检查令牌数,不足时使用条件变量等待直到有令牌或超时。这种阻塞式获取在Ruby的Queue和ConditionVariable配合下可以实现,但要注意超时时间与单调时钟的配合,避免使用Time.now带来的时间跳变问题。
测试不同速率切换下的行为稳定性
要验证平滑速率调整是否生效,需要编写单元测试模拟时间流逝和速率变化。Ruby的minitest框架中可以注入一个假时钟,通过手动推进时间来控制令牌补充量。下面的测试用例展示了在初始满桶状态下,将速率从10令牌/秒调低到2令牌/秒,检查桶内令牌数是否被正确缩放,以及后续请求能否以期望速率通过。
require 'minitest/autorun'
class TokenBucketTest < Minitest::Test
def setup
@bucket = TokenBucket.new(capacity: 20, fill_rate: 10)
end
def test_adjust_rate_scales_tokens
# 消耗一些令牌
5.times { @bucket.allow_request? }
# 当前令牌数约为15(取决于时间,实际测试中需用假时钟精确控制)
# 速率从10降到2,按比例缩放令牌
@bucket.adjust_rate(2)
# 由于无法直接读取内部令牌数,可通过连续请求验证
# 在极短时间内能通过的请求数应该与缩放后令牌数一致
allowed = 0
100.times { allowed += 1 if @bucket.allow_request? }
assert_operator allowed, :<, 20
end
end
上面的测试用例使用minitest断言,通过统计短时间内允许通过的请求数来间接验证令牌缩放效果。实际生产环境中,更推荐使用一个可注入时钟的测试替身,例如定义一个Clock模块并允许在测试中替换monotonic_time方法,从而精确控制时间步进。这样可以在不依赖真实等待的情况下测试速率调整的边界条件,比如速率降为0、速率调高到无穷大、桶容量为0等。
性能方面,TokenBucket的allow_request?方法在每次请求时都会获取互斥锁,包括获取单调时间、计算时间差、更新令牌数。在高并发场景下,这个互斥锁可能成为瓶颈。优化思路是使用原子操作或ReadWriteLock,但对于Ruby MRI来说,Mutex已经足够高效,因为大多数限流检查都是极短临界区。如果确实需要更高的吞吐,可以考虑将限流状态放入Redis等外部存储,通过Lua脚本保证原子性,但那样会牺牲单机低延迟的优势。单机场景下,本文给出的实现足以应对每秒数万次请求的检查频率。
总的来说,Ruby实现令牌桶算法并不复杂,关键在于处理好时间基准、速率调整时的令牌缩放以及线程安全。通过合理的补偿机制,可以让速率调整过程足够平滑,避免流量尖刺对下游服务造成冲击。