令牌桶限流:Promise异步API访问保护组件设计
令牌桶限流的核心逻辑必须在 Promise 链中阻塞等待 一个常见的误解是,限流器只要返回一个 Promise 就万事大吉了。但仔细想想,如果桶里没令牌时,直接返回 Promise.resolve(),调用方就会立刻执行,这等于没有限流。真正的令牌桶,精髓在于“等”——当令牌不足时,必须让调用方老老
令牌桶限流的核心逻辑必须在 Promise 链中阻塞等待
一个常见的误解是,限流器只要返回一个 Promise 就万事大吉了。但仔细想想,如果桶里没令牌时,直接返回 Promise.resolve(),调用方就会立刻执行,这等于没有限流。真正的令牌桶,精髓在于“等”——当令牌不足时,必须让调用方老老实实地等待,直到有令牌可用再执行。
这意味着,组件内部必须维护一个等待队列。当请求到来而令牌不足时,需要把待执行的函数 fn 及其参数 args 封装起来,延迟其 Promise 的 resolve 时机。这里有个关键区别需要厘清:限流是“请稍后再试”,而不是“直接拒绝服务”。如果发现桶空就立刻 throw 或返回 rejected Promise,那就变成了简单的拒绝,失去了“流量整形”的本意。
- 每次调用都需要原子性地尝试获取一个令牌(
acquire(1))。 - 如果成功,立即执行
fn(...args)。 - 如果失败,则需要计算等待时间(公式通常是
(1 - availableTokens) * refillInterval),然后通过setTimeout或类似Promise.delay的机制来推迟执行。 - 这里有个细节要注意:不能简单地用
await new Promise(r => setTimeout(r, ms))来模拟等待,因为在高并发场景下,多个请求可能同时触发独立的等待计时,会导致令牌补充计划混乱,无法保证公平性和准确性。等待机制需要与一个全局的、共享的令牌补充计划协同工作。
refill 机制必须独立于请求驱动,且避免定时器叠加
令牌补充的逻辑不能偷懒。如果只在每次请求到来时才临时计算“这次该补几个令牌”,那么在突发高并发下,补充动作会严重滞后,甚至因为多个请求同时触发计算而导致重复调度,打乱节奏。
正确的做法是启动一个独立的、长期运行的定时器(比如 setInterval)来负责补充令牌。但这里容易踩坑:如果在每个桶实例的构造函数里都无条件地启动一个 setInterval,那么创建大量实例时,定时器数量会爆炸,消耗不必要的性能。同样,使用 setTimeout 进行递归调用时,如果忘记清理前一个定时器,也会导致内存泄漏和意外的“加速”补充。
- 使用
refillTimer = setInterval(...)启动一个全局唯一的定时器。为了减少冷启动延迟,建议在构造后立即触发第一次补充。 - 必须在实例销毁时(例如提供一个
destroy()方法)调用clearInterval(refillTimer)来清理定时器,避免内存泄漏。 - 补充间隔(
refillInterval)的设置很有讲究。它应该远小于单次请求的平均耗时。例如,如果 API 调用通常需要几十毫秒,那么将间隔设为 10ms 就比 1s 要好得多。间隔太长会导致桶更新缓慢,无法有效控制突发流量。 - 每次补充的令牌增量建议固定为 1 个。避免使用浮点数(比如每 10ms 补 0.1 个),因为多次浮点运算后可能产生精度误差,导致令牌总数出现偏差。
Promise 包装器要透传原函数的 resolve/reject 行为
限流器返回的 Promise,其最终状态必须与原始函数 fn 完全一致。也就是说,如果原函数成功,限流器返回的 Promise 也应该 resolve 相同的值;如果原函数失败,则应该 reject 相同的 error 对象,并且最好能保留完整的调用栈信息。否则,上层的错误处理逻辑会失效。
一个典型的反模式是,在包装器里统一 catch 住错误,然后抛出一个新的、通用的 Error。这样做虽然简单,但丢失了原始错误的上下文,对于调试和精细化错误处理非常不利。
- 避免这样写:
fn(...args).then(...).catch(e => Promise.reject(new Error(...)))。 - 正确的写法是直接返回原函数的 Promise:
return fn(...args)。将成功或失败的处理权完全交给调用方。 - 如果确实需要增加超时控制(防止某个请求因等待令牌而永久挂起),超时应该设置在获取令牌(acquire)的等待阶段,而不是包裹整个
fn的执行过程。 - 另外需要注意,即使原始
fn是同步函数,也应该将其包装成 Promise(例如Promise.resolve(fn(...args))),以保证接口的一致性。
多实例共享桶状态时,key 化与清理成本不可忽视
在实际应用中,限流往往需要根据不同的维度进行,比如按用户ID、API接口路径、客户端IP等。这时候就不能所有请求共用一个桶了,需要根据 key 来动态创建或复用对应的桶实例。
然而,这引入了新的复杂度:如果 key 的数量无限增长(例如,key 是包含时间戳的动态URL),那么桶实例的 Map 就会不断膨胀,最终导致内存泄漏。一个没有自带过期淘汰策略的 Map,本质上就是一个垃圾收集器。
- 可以使用
Map来存储key → bucket的映射关系,但必须配套实现一个过期清理策略。 - 一个简单的方案是,在每个桶实例上记录最后访问时间。每次根据 key 获取桶时,都检查一下该桶是否已经超过设定的存活时间(例如5分钟无访问),如果是,则将其清理掉。
- 更稳健的方案是直接使用现成的 LRU Cache 库(比如 Node.js 中的
lru-cache),通过设置maxAge(最大存活时间)和max(最大数量)来自动管理实例的生命周期。 - 尽量不要用普通的
Object来当 Map 用,因为 Object 的 key 只能是字符串,这会导致{id: 123}和{id: '123'}被识别为同一个 key,引发难以调试的问题。 - Key 的设计也尽量保持扁平化,避免使用嵌套对象或函数作为 key,否则在判断相等性时会非常麻烦。
说到底,令牌桶不是一个简单的“开/关”阀门,而是一个精密的“节拍器”。它的复杂性始终围绕着时间精度、状态一致性以及等待队列的公平性。尤其是在处理一些长耗时操作(如文件上传、复杂数据库查询)时,如果一个限流器没有妥善考虑重入或中断机制,那么堆积起来的等待 Promise 很可能悄无声息地拖垮整个应用的内存。
