网络服务在面临客户端瞬时大量请求时,若不加控制很容易导致线程池耗尽或数据库连接被占满。令牌桶是一种经典的流量整形模型,它以恒定速率向桶中放入令牌,每个请求必须取到令牌才能继续执行,桶内令牌总数设有上限。当系统空闲时令牌累积,一旦突发请求到来,已累积的令牌可以被一次性消耗,从而吸收短时高峰,而在持续高压下又因补充速度受限起到限流作用。理解这一机制后,用Ruby写一个简单的令牌桶并不需要依赖复杂中间件。

令牌桶的核心原理与参数设定
令牌桶的本质是一个带时间属性的计数器。我们假设桶的容量为capacity,令牌生成速率为rate个每秒。每次请求到达时,先根据当前时间与上一次补充时间的差值,计算出应该补充多少令牌,并将桶内令牌数控制在容量以内。随后如果桶内令牌大于等于一,则取出一个令牌并放行请求,否则拒绝。这样的设计使得系统在低负载时逐渐攒满令牌,高负载突发时可立即消耗存量令牌,而不会被匀速限流卡死。
参数设定直接决定了系统的行为特征。capacity越大,允许吸收的突发量越高,但也可能让后端在瞬间承受更大压力;rate越小,长期平均吞吐越低。在Ruby应用中,如果服务主要处理HTTP接口,通常可以将rate设为后端数据库能稳定承受的每秒查询数,而capacity设为日常高峰倍数的两到三倍。需要注意,令牌补充使用浮点数计算能避免整点截断带来的限流偏差,但也应防止浮点误差累积,可在每次补充后对微小负值做归零处理。
与漏桶算法相比,令牌桶更适用于存在明显用户行为突发的场景。漏桶强制以恒定速率流出请求,即使桶空了也无法应对瞬时高峰;令牌桶则把“是否能突发”的选择权交给了容量参数。对于Ruby这种常用于敏捷业务开发的语音,令牌桶可以用极少量代码嵌入到控制器前置过滤器中,不需要像网关那样独立部署组件,这也是它在轻量服务中流行的原因。
Ruby中的基础线程安全实现
在单进程多线程的Ruby服务里,令牌桶对象可能被多个线程同时访问,因此补充令牌和取令牌的操作需要加锁。下面示例用Mutex保证同一时间只有一个线程修改状态,避免竞争导致令牌被多发或计数错乱。代码中Time.now使用浮点秒数,便于精确计算补充量。
require 'time'
class TokenBucket
def initialize(rate, capacity)
@rate = rate.to_f
@capacity = capacity.to_f
@tokens = capacity.to_f
@last = Time.now.to_f
@lock = Mutex.new
end
def allow?
@lock.synchronize do
now = Time.now.to_f
# 根据时间差补充令牌
delta = now - @last
@tokens = [@tokens + delta * @rate, @capacity].min
@last = now
if @tokens >= 1.0
@tokens -= 1.0
true
else
false
end
end
end
end
bucket = TokenBucket.new(10, 20)
threads = 30.times.map do
Thread.new do
100.times { bucket.allow? }
end
end
threads.each(&:join)
上述实现中,每次调用allow?都会先加锁再计算,逻辑直观且不易出错。在多数Ruby Web服务如Puma的多线程模式下,这种写法能稳定运行。不过当并发极高时,互斥锁可能成为微小瓶颈,因为所有请求都要串行经过临界区。如果服务部署在多进程模型下,比如Unicorn,每个进程拥有独立桶,整体限流效果会变成进程数乘单进程容量,这一点在配置时需额外考虑,或者通过共享存储如Redis来跨进程统一计数。
为了验证突发吸收效果,可以设想速率设为每秒10个、容量20。系统空闲两秒后桶满,此时突然来了20个请求,前20个都会成功,之后若继续每秒来15个,则只有10个能成功,其余被限流。这样的表现正好符合“允许短时突发、长期平滑”的预期。在Ruby代码里可以通过在循环间插入sleep来模拟空闲与突发,观察allow?返回值的分布。
无锁方案与并发场景下的取舍
如果希望减少锁开销,可以使用原子类配合时间戳来近似实现。Ruby的Concurrent::AtomicReference能安全地替换令牌数,但补充逻辑仍需小心处理时间窗口竞争。一种思路是将上次补充时间也放入原子结构,用循环比较并交换(CAS)来更新,失败则重试。这种方式在冲突少的场景效率更高,但代码复杂度明显上升,且Ruby的全局锁在某些版本中会让CAS收益打折。
require 'concurrent'
require 'time'
class AtomicBucket
def initialize(rate, capacity)
@rate = rate.to_f
@capacity = capacity.to_f
@state = Concurrent::AtomicReference.new([capacity.to_f, Time.now.to_f])
end
def allow?
loop do
tokens, last = @state.get
now = Time.now.to_f
new_tokens = [tokens + (now - last) * @rate, @capacity].min
if new_tokens >= 1.0
new_tokens -= 1.0
if @state.compare_and_set([tokens, last], [new_tokens, now])
return true
end
else
if @state.compare_and_set([tokens, last], [new_tokens, now])
return false
end
end
end
end
end
无锁版本在单进程内多个轻量线程争用限流器时,通常比Mutex版本延迟更低,因为它避免了线程挂起与唤醒。但如果请求处理本身已涉及数据库或外部API调用,限流器的锁开销占比极小,此时基础加锁实现更易维护。另外在JRuby等真正并行执行的环境下,无锁方案优势会更明显,而在MRI Ruby中由于GIL存在,多线程并行受限制,选择依据更多来自代码可读性而非绝对性能。
实际项目中还应考虑令牌桶的精度与监控。可以在Ruby进程内暴露一个统计接口,定期记录放行与拒绝次数,当拒绝率异常升高时说明后端已无法跟上突发,这时应调大capacity或扩容服务。若使用Redis实现跨进程桶,要注意网络往返也会消耗时间,可将脚本用Lua在服务端原子执行,避免多次往返。无论哪种方案,令牌桶的核心思想不变:用时间换空间,用存量令牌消纳难以预测的短时高峰。