在多实例部署的Ruby服务中,如果每个进程都在内存里维护一个漏桶计数器,限流效果会随实例数量成倍衰减。例如三个实例各自允许每秒100次请求,整体可能达到每秒300次,完全偏离了接口的流量合同。解决这个问题需要把漏桶状态从进程内存迁移到Redis,并借助分布式锁或原子脚本协调并发更新。本文基于Redis实现一个全局漏桶限流器,覆盖桶状态建模、锁粒度控制、Ruby编码和故障降级。

一、漏桶算法为什么需要全局化
漏桶算法把请求看作水滴,桶以固定速率漏水,也就是处理请求。桶有容量上限,当进水量超过桶容量时,后续水滴会被拒绝或等待。和令牌桶不同,漏桶强制输出速率恒定,更适合保护下游慢服务、削峰填谷。关键参数有两个:桶容量 capacity 表示允许积压的最大请求数;漏水速率 leak_rate 表示每秒可以处理的请求数。算法需要维护当前水位和上次漏水时间。每次请求到达时,先按经过时间乘以漏水速率扣除水量,再加上本次请求,如果新水位不超过容量则放行,否则拒绝。
单机实现通常用数组或计数器保存水位,用线程互斥保护。但Ruby服务一旦以多个Unicorn、Puma或Sidekiq进程部署,每个进程的内存相互隔离。即使使用进程内锁,也无法阻止多个实例同时放行超额请求。要形成全局统一的流量整形,必须把这些状态放到所有实例都能访问的Redis中。Redis的读写是单线程的,但一次检查加上扣减包含多次命令,多个客户端仍然可能交错执行,因此需要分布式锁或Lua脚本保证原子性。
二、Redis中如何保存桶状态和获取分布式锁
全局漏桶状态使用Redis哈希保存比较直观。对于一个命名为 api:leaky:payments 的桶,可以设置字段 capacity、water_level、last_leak_time。capacity是固定值,water_level是当前水位,last_leak_time是上次漏水时间戳。每次请求到来时,读取这三个字段,计算出经过时间里漏掉的水量,更新水位和时间。这个过程包含读取、计算、写入三个步骤,并发下会出现竞态:两个请求可能基于同样的旧水位判断,都认为自己可以放行。
为避免竞态,可以引入分布式锁。锁的键可以使用桶键加后缀 :lock,例如 api:leaky:payments:lock。获取锁使用Redis命令 SET lock_key token NX PX 3000。其中NX表示仅当键不存在时写入,PX表示毫秒级过期。token使用随机字符串,保证只有持有者能释放锁。Ruby的redis客户端提供了 set 方法,可以一次传入 nx: true, px: 3000。如果获取失败,说明其他实例正在更新桶状态,可以短暂休眠后重试,或者直接返回限流结果。需要注意锁过期时间必须大于一次桶状态更新耗时,否则会出现两个客户端同时进入临界区。
def with_redis_lock(key, ttl_ms: 3000, retry_times: 5, sleep_ms: 20)
token = SecureRandom.hex(16)
attempts = 0
loop do
acquired = @redis.set(key, token, nx: true, px: ttl_ms)
if acquired
begin
return yield
ensure
if @redis.get(key) == token
@redis.del(key)
end
end
end
attempts += 1
if attempts >= retry_times
raise 'could not acquire redis lock'
end
sleep(sleep_ms / 1000.0)
end
end
释放锁前需要校验token,防止锁已经过期并被其他请求获取后被误删。如果业务执行时间可能超过TTL,需要锁续期机制。但在这个限流场景中临界区非常短,通常几毫秒,3000ms足够。另一个方案是使用Redis的EVAL执行Lua脚本,将检查与扣减合并为一条命令,天然原子,可减少锁竞争。后文给出实现。
三、Ruby实现的全局漏桶限流器
下面实现一个 GlobalLeakyBucket 类。构造时传入Redis连接、桶键、容量和漏水速率。核心方法是 allow?,它返回true或false,同时可返回需要等待的秒数。为了原子更新,这里使用Lua脚本直接完成漏水计算和水位判断,Redis服务端执行脚本期间不会交错执行其他命令。这样在多实例场景下,即使不获取分布式锁,也能保证桶状态的更新是原子的。
Lua脚本接收capacity和leak_rate作为参数,从哈希中读取water_level和last_leak_time。如果哈希不存在,初始化为0。当前时间由客户端传入,避免不同实例系统时间不一致。脚本逻辑如下:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'water_level', 'last_leak_time')
local water_level = tonumber(data[1]) or 0
local last_leak_time = tonumber(data[2]) or now
local elapsed = math.max(0, now - last_leak_time)
local leaked = elapsed * leak_rate
local current = math.max(0, water_level - leaked)
if current < capacity then
current = current + 1
redis.call('HSET', key, 'water_level', current, 'last_leak_time', now)
redis.call('PEXPIRE', key, 60000)
return 1
else
redis.call('HSET', key, 'water_level', current, 'last_leak_time', now)
redis.call('PEXPIRE', key, 60000)
return 0
end
Ruby调用时只需要一个eval方法。脚本返回1表示放行,返回0表示拒绝。LUA_SCRIPT可以从文件读取,避免在Ruby代码中维护大段字符串。具体实现如下:
class GlobalLeakyBucket
# LUA_SCRIPT 与上文一致,可从文件或常量读取
LUA_SCRIPT = File.read('/opt/ruby/bucket.lua')
def initialize(redis, key:, capacity:, leak_rate:)
@redis = redis
@key = key
@capacity = capacity
@leak_rate = leak_rate.to_f
end
def allow?
now = Time.now.to_f
result = @redis.eval(LUA_SCRIPT, keys: [@key], argv: [@capacity, @leak_rate, now])
result == 1
end
end
实际使用时可以再包装一个 wait 方法,当被拒绝时返回稍后重试的秒数,可以用水位与容量差除以漏水速率计算。对于不需要等待的强限制,可以直接返回429响应或抛出自定义异常。同时在Redis中设置PEXPIRE,能清理长期不活跃的桶键,避免大量键堆积。
如果团队不想维护Lua脚本,只使用分布式锁加普通Ruby逻辑也可以。但锁会使每次请求多一次或多次Redis往返,在限流器这种高频组件里延迟会明显上升。Lua脚本在单条命令中完成全部状态更新,不需要额外锁,是更推荐的方案。分布式锁则适合那些业务逻辑更复杂、无法压缩到Lua的限流场景,例如需要同时写多个桶或需要与后台任务互斥时。
四、分布式锁与Lua脚本的取舍及故障降级
分布式锁在这里的作用是保护非原子的读改写序列。即使Redis本身单线程,如果客户端先GET再做SET,中间另一个客户端可能插入更新。锁能把这段逻辑串行化,但代价是额外的锁竞争。对于漏桶限流,使用Lua脚本可以把读改写合并,Redis会原子地执行整段脚本,完全不需要客户端锁。这也避免了锁超时、锁误删、重试风暴等问题。因此在实际项目中,优先考虑Lua脚本原子方案。
但有些情况下仍需要分布式锁。例如桶状态不只放在一个键,而是分散在多个键或需要与数据库记录同步,这时候Lua脚本可能不够灵活。还有当漏桶规则需要动态加载,且加载过程不能并发执行时,可以用锁保护配置刷新。Ruby中获取锁建议设置短过期时间和随机token,并在ensure中安全释放。释放前要比较token,防止锁已经过期又被其他请求获取后被误删。
故障降级方面,Redis不可用时需要明确策略。可以选择拒绝请求,即快速失败,保护下游服务;也可以选择放行请求,但要承担限流失效风险。对于交易支付等关键接口,通常采用拒绝策略。对于非关键查询接口,可以在本地内存中退化到单机漏桶。需要监控桶水位、拒绝次数、锁等待时间和Redis命令延迟,及时调整容量和漏水速率。还可以给桶键设置合理的TTL,并用定时任务清理泄漏状态。
全局漏桶并不复杂,关键是不要把限流状态放在进程内存,而是统一到Redis。优先用Lua脚本保证原子性,在灵活性和可维护性之间做取舍。Ruby实现只需要一个eval调用,配合容量、速率和时间参数,即可让多个实例共享同一个流量整形器。加上分布式锁兜底和故障降级策略,就能在分布式系统中实现稳定可靠的全局漏桶。