网络流量整形中的令牌桶算法常用于限制接口或链路的请求速率。当系统从单进程部署演进为多节点集群时,每个节点各自维护一个本地令牌桶会造成全局限流不准:节点A余量充足而节点B已耗尽,恶意流量可绕过瓶颈打满集群。分布式同步的核心,是让所有实例看到同一份桶状态,并以原子方式完成取桶、计算补充、扣减这三步。Ruby凭借丰富的Redis客户端与轻量协程,适合编写这类同步逻辑。
令牌桶分布式同步的底层原理
令牌桶的本质是一个随时间线性补充、被请求原子消耗的计数器。在单机中,我们用一个变量记录余量,用时间戳记录上次补充时刻,每次请求时先按流逝时间补桶再判断余量。分布式环境下,这个变量和时间戳必须放在所有节点都能访问且支持原子读写的存储中,最常见的是Redis。关键难点在于“读-改-写”必须不可分割,否则两个节点同时读到余量10,各扣5,实际扣了10但存储只减了5,限流失效。
解决该问题不能依赖应用层加锁,因为Ruby多进程或容器调度会让锁不可靠。正确做法是利用Redis的Lua脚本:把取桶、按当前时间补桶、扣减、写回全部放进一段脚本,由Redis单线程执行,天然原子。时间基准应使用Redis服务器时间或统一NTP时钟,避免某节点系统时间回拨造成补桶计算错误。下面的Lua脚本展示了核心逻辑,Ruby端通过eval调用即可。
-- KEYS[1] 桶的key,ARGV[1] 当前毫秒,ARGV[2] 容量,ARGV[3] 速率(个/毫秒),ARGV[4] 请求令牌数
local cap = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
local now = tonumber(ARGV[1])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if not tokens then
tokens = cap
ts = now
end
local delta = math.floor((now - ts) * rate)
if delta > 0 then
tokens = math.min(cap, tokens + delta)
ts = now
end
if tokens >= need then
tokens = tokens - need
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ts)
return 1
else
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ts)
return 0
end
上述方案中,桶的元数据以哈希结构存于Redis,包含tokens与ts两个字段。由于Lua脚本在Redis内串行执行,任何数量的Ruby节点并发请求都不会出现竞态。实际生产中还要考虑Key过期:若某接口长期无流量,可设置TTL让桶自动消失,下次访问重新初始化为满桶,避免无效内存占用。
Ruby端的同步封装与两种刷新策略
在Ruby中,我们可以用一个类封装桶的同步调用。底层使用redis-rb客户端,将Lua脚本预加载以减少网络往返。每次请求进入时,实例获取当前时间并调用脚本,根据返回值决定放行或拒绝。这种“每次请求都远程同步”的做法数据最准,但增加了RTT。对于延迟敏感服务,可采用本地缓存+异步同步策略。
一种常见写法是轮询拉取:Ruby节点每50毫秒从Redis读取一次桶状态到本地内存,请求时先扣本地再异步上报。该方案实现简单,但同步间隔内各节点状态仍可能偏离,适合允许少量超发的场景。另一种是基于Redis Pub/Sub的事件推送:任意节点扣减后发布消息,其他节点订阅并立即更新本地副本。推送实时性好,但要处理网络闪断导致的漏消息,需辅以定时全量校准。下面给出Ruby封装示例,采用每次远程原子扣减的严谨模式。
require 'redis'
class DistributedTokenBucket
SCRIPT = <<-LUA
-- 同前文的Lua脚本内容
LUA
def initialize(redis, key, capacity, rate_per_sec)
@redis = redis
@key = key
@capacity = capacity
@rate = rate_per_sec / 1000.0
@sha = @redis.script(:load, SCRIPT)
end
def allow?(need = 1)
now = (Time.now.to_f * 1000).to_i
@redis.evalsha(@sha, keys: [@key], argv: [now, @capacity, @rate, need]) == 1
end
end
如果选择Pub/Sub模式,Ruby代码需另起一个线程订阅频道,在收到其他节点消息时调用redis.hgetall刷新本地哈希。要注意的是,订阅连接与命令连接应分离,否则阻塞订阅会卡住正常请求。无论哪种策略,都建议把桶的Key按业务维度拆分,如“api:user:123”,避免单一热点Key在集群下成为瓶颈。
多节点租约与时钟漂移的应对
当Redis本身以哨兵或集群部署时,极端情况可能出现脑裂,此时可引入Redlock风格租约:Ruby节点在修改桶前先向多数Redis实例申请短期租约,获租后才能写桶。这能防止少数节点在分区期间独立扣减造成超发。租约超时时间应小于桶补充满所需时间,保证分区恢复后状态可收敛。以下伪代码展示申请与释放思路。
def acquire_lease(resource, ttl_ms)
locks = 0
@redis_nodes.each do |node|
locks += 1 if node.set("lock:#{resource}", '1', nx: true, px: ttl_ms)
end
locks >= (@redis_nodes.size / 2 + 1)
end
时钟漂移是另一隐形杀手。若某Ruby宿主机时钟比Redis快,它计算出的补充量会偏大,导致提前放行。统一使用Redis的TIME命令返回的时间戳可规避此问题,代价是每次请求多一次命令。折中方案是由运维保证NTP对齐,应用内做时钟跳变检测:若发现本地与上次记录偏差超阈值,强制从中心拉取。此外,速率参数建议以浮点令牌每毫秒存储,避免整型截断在低速率时永远不补充。
综合来看,Ruby实现分布式令牌桶同步并不复杂,重点在于把原子性交给Redis、把时间基准收口到单一来源、把网络异常纳入校准设计。对于中小集群,远程原子脚本已足够;大型多区域部署可叠加租约与异步推送,在准确与性能间取得平衡。
token_bucketdistributed_syncRuby修改时间:2026-08-14 13:33:40