令牌桶限流必须阻塞等待而非立即返回Promise,核心是维护等待队列、原子获取令牌、独立定时补桶(固定增量、全局唯一可清理)、透传原函数Promise状态,并按key隔离桶实例且带TTL清理。
直接返回 Promise.resolve() 而不控制执行时机,就等于没限流。真正的令牌桶必须让调用方“等够时间再执行”,而不是“立刻返回一个已 resolve 的 Promise”。这意味着限流器内部要维护一个队列,当令牌不足时,把待执行的 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 计划令牌补充不能靠每次请求时临时算“该补几个”,否则高并发下 refill 会严重滞后或重复调度。正确做法是启动一个长期运行的 setInterval,但必须确保它全局唯一、可清理。
容易踩的坑是:在构造函数里无条件 setInterval,导致每次新建实例都起一个定时器;或者用 setTimeout 递归但未清除前一个,造成内存泄漏和 refill 加速。
refillTimer = setInterval(...) 启动,首次 refill 在构造后立即触发(避免冷启动延迟)destroy())时调用 clearInterval(refillTimer)
refillInterval)应远小于单次请求耗时,例如设为 10ms,而非 1s —— 否则桶更新太慢,burst 行为不可控限流器返回的 Promise 必须和原始 fn 具有完全一致的终态:成功时 resolve 相同值,失败时 reject 相同 error,且保留 stack trace。否则上层无法做正确错误处理。
典型反模式是统一 catch 后转成新 error,或忽略原 Promise 的 rejection reason。
fn(...args).then(...).catch(e => Promise.reject(new Error(...)))
return fn(...args) —— 直接返回原 Promise,由调用方自行处理fn 执行fn 是同步函数,仍要 wrap 成 Promise(Promise.resolve(fn(...args))),保持接口统一实际场景中,往往要按用户 ID、API 路径、IP 等维度分别限流。这时不能共用一个桶,而要基于 key 动态创建/复用桶实例 —— 但 key 若无限增长(如带时间戳的 URL),会导致内存泄漏。
没有内置 TTL 的 Map 就等于在积累垃圾。
Map 存储 key → bucket 映射,但必须配套过期策略LRU cache 库(如 lru-cache),设置 maxAge 和 max
Object 当 map(key 会被强制转字符串,{id: 123} 和 {id: '123'} 冲突)令牌桶不是“开关”,而是“节拍器”——它的复杂点永远在时间精度、状态一致性、以及等待队列的公平性上。尤其当你的 API 有长耗时操作(如文件上传、数据库大查询)时,一个没考虑重入或中断的限流器,反而会让堆积的等待 Promise 把内存拖垮。