Node 后端实战 · Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战
各位看官,限流这件事,放在单机或者常驻容器里,基本就是个中间件的事:装个express-rate-limit,或者前端挂个 Nginxlimit_req,完事。但我把这个系统搬上 Cloudflare Workers 之后,发现「限流」这件小事,在边缘架构下被彻底重写了——你以为自己在做「计数」,其实是在跟「无状态」「最终一致性」「冷启动」这三个东西搏斗。
这篇文章不讲概念,只讲我在 Workers 上做限流时,为什么最终放弃了 KV 中央计数,改用 Worker 实例内存里的固定窗口计数,以及这个选择背后真实的代价。代码都是生产里跑着的真东西。
一、先说清楚:边缘架构下为什么不能「中央计数」
传统限流的核心是「一个可信的、唯一的计数器」:所有请求打到它,它累加、判断、放行。Redis 干这个最合适,因为它是中心化的、强一致的。
但 Workers 不是这么玩的:
- 你的代码不是跑在一台机器上,而是跑在 Cloudflare 全球几百个数据中心的边缘节点上,每个请求落在哪个节点、哪个实例(官方叫 island),你控制不了;
- 每个实例都是无状态、按需冷启动的,请求结束实例可能被回收,下次请求又是一个全新的实例;
- 唯一能让你跨实例共享状态的,是 KV 或者 Durable Objects——但 KV 是最终一致性(写入后别的节点可能几秒才看到),Durable Objects 是单点强一致但有冷启动和额外成本。
所以「在 Workers 上做精确全局限流」这件事,从根上就不是免费的。三种方案的取舍,我画一张表:
| 方案 | 精确性 | 额外延迟 | 额外成本 | 主要坑 |
|---|---|---|---|---|
| KV 中央计数 | 差(最终一致,写入滞后导致计数偏旧) | 每请求至少 1 次 KV 读/写 | KV 有写入次数配额,高并发下先撞配额 | 限流判断基于「旧值」,要么放多了,要么把正常用户误杀 |
| Durable Objects | 高(单点强一致) | 首次访问有冷启动(~几百 ms) | 按请求数计费,贵 | 复杂、要单独维护一个 DO 类,小项目杀鸡用牛刀 |
| 实例内存固定窗口 | 不精确(per-island,非全局) | 零(纯内存) | 零(不占 KV/DO 配额) | 同一客户端可能被多个实例各放行一次,限流是「近似」的 |
我的系统是一个多租户 SaaS,限流的目的不是「精算到个位数」,而是防刷、防失控、防单用户把实例打挂。在这种诉求下,「per-island 的近似限流」完全够用,而 KV 的写入配额和最终一致性反而是实打实的雷。所以我选了第三方案。
二、核心实现:固定窗口 + globalThis 防丢失
固定窗口是最朴素的算法:把时间切成一段段的窗口(比如 60 秒一个),窗口内计数,超过上限就拒,窗口翻页就清零重数。它不完美(窗口边界会有两倍突发),但实现简单、内存友好,对防刷足够。
关键难点是:Worker 实例会被回收,普通模块级变量也会跟着没。所以计数器必须挂到globalThis上,这样同一个 isolate(实例)内的多次请求复用同一份内存,HMR 或多实例也不会把计数弄丢:
// middleware/ratelimit.tsimporttype{Context,MiddlewareHandler}from"hono";import{err}from"@/lib/errors";importtype{AppBindings}from"@/middleware/auth";/** 单窗口计数 */interfaceWindowCounter{count:number;// 当前窗口内已放行计数windowStart:number;// 当前窗口起点(秒)}// 跨请求持久化:挂到 globalThis,防止实例回收/HMR 丢失constg=globalThisasunknownas{__rateLimitStore?:Map<string,WindowCounter>};conststore:Map<string,WindowCounter>=g.__rateLimitStore??newMap();g.__rateLimitStore=store;然后是限流工厂本身:
exportinterfaceRateLimitOptions{key:string|((c:Context<AppBindings>)=>string);// 限流键:按 IP / 用户动态生成limit:number;// 窗口内允许的最大请求数windowSec:number;// 窗口时长(秒)code?:string;// 超限错误码(默认 RATE_LIMIT)}exportconstrateLimit=(opts:RateLimitOptions):MiddlewareHandler<AppBindings>=>async(c,next)=>{// dev 环境暂停限流:本地联调避免刷新/轮询误伤自己if(c.env.ENV==="dev"){awaitnext();return;}constkey=typeofopts.key==="function"?opts.key(c):opts.key;constnowSec=Math.floor(Date.now()/1000);constwindowStart=Math.floor(nowSec/opts.windowSec)*opts.windowSec;constcur=store.get(key);letcount:number;if(!cur||cur.windowStart!==windowStart){// 没有记录,或窗口已翻页 → 开新窗口,计数从 1 起count=1;store.set(key,{count,windowStart});}else{cur.count+=1;count=cur.count;}maybeSweep(windowStart);if(count>opts.limit)throwerr(opts.code??"RATE_LIMIT","请求过于频繁,请稍后再试");c.header("X-RateLimit-Limit",String(opts.limit));c.header("X-RateLimit-Remaining",String(Math.max(0,opts.limit-count)));awaitnext();};逻辑很简单:算出现在属于哪个窗口,没有就新建(计数 1),有就 +1,超了就抛RATE_LIMIT(对应 HTTP 429)。同时把X-RateLimit-Limit和X-RateLimit-Remaining写进响应头,让前端知道「你还能发几条」。
三、限流键与三档限额
限流键决定了「按谁来限」。匿名端点(登录、刷新 token)按客户端 IP 限,登录态接口按用户 ID 限——这样既防了「一个 IP 疯狂撞库」,也防了「一个账号高频刷接口」:
// routes/auth.ts —— 登录与刷新,按 IP 限rateLimit({key:(c)=>`rl:login:${clientIp(c)}`,limit:RATE_LIMIT.LOGIN_PER_MIN_PER_IP,windowSec:60}),rateLimit({key:(c)=>`rl:refresh:${clientIp(c)}`,limit:RATE_LIMIT.REFRESH_PER_MIN_PER_IP,windowSec:60}),// app.ts —— 通用 API,按用户限;未登录回落到 IPconstapiRateLimit=rateLimit({key:(c)=>{constu=c.get("user");returnu?KvKeys.apiRate(u.id):`rl:api:anon:${c.req.header("cf-connecting-ip")??"unknown"}`;},limit:RATE_LIMIT.API_PER_MIN_PER_USER,windowSec:60,});限额定义在constants.ts,三档各司其职:
| 场景 | 常量 | 限额 | 键 | 目的 |
|---|---|---|---|---|
| 登录 | LOGIN_PER_MIN_PER_IP | 10 次/分/IP | rl:login:<ip> | 防撞库、防暴力破解 |
| 刷新 token | REFRESH_PER_MIN_PER_IP | 30 次/分/IP | rl:refresh:<ip> | refresh 比登录频繁,放宽一档 |
| 通用 API | API_PER_MIN_PER_USER | 120 次/分/用户 | rl:api:<userId> | 防单账号刷爆后端 |
注意登录给得最紧(10 次/分),因为这是匿名端点,最坏情况下攻击者可以拿它做撞库;而正常用户一分钟登录十次已经是极端情况了,不会误伤。
四、dev 短路:别在联调时把自己限死
代码里第一件事是判断c.env.ENV === "dev"就直接放行。这个不是偷懒——本地联调时,前端可能一秒发好几个请求、HMR 疯狂刷新、轮询接口反复跑,如果限流开着,最先被挡的就是开发自己。线上靠环境变量区分,dev 直接短路跳过,省心。
五、内存不是无限的:防 Map 膨胀
挂globalThis的内存 Map,如果只进不出,长期运行下去会越攒越大——尤其按 IP 限流时,每个陌生 IP 都会占一个 key。所以加了一个清理机制:
constMAX_ENTRIES=5000;// 仅当超过阈值时才全量扫描,清掉「不在当前窗口」的旧 keyconstmaybeSweep=(windowStart:number):void=>{if(store.size<MAX_ENTRIES)return;for(const[k,v]ofstore){if(v.windowStart!==windowStart)store.delete(k);}};两个设计点:
- 惰性清理:只有当 Map 超过 5000 条才扫一遍,平时零开销。对防刷场景,绝大多数 key 活不过一个窗口(60 秒),自然会被下一次扫到清掉。
- 全量扫描而非精准删除:扫描成本 O(n),但只在阈值触发时跑一次,且 n 上限被 MAX_ENTRIES 兜住,不会无限增长。这是「用偶尔的一次 O(n) 换平时零成本」的取舍。
六、per-island 的代价:不精确是代价,也是取舍
这是内存方案最该讲清楚的地方。因为计数只活在「当前这个实例」的内存里,不同边缘节点的实例各有各的计数器,所以一个客户端如果请求被调度到 3 个不同实例,理论上最多能被放行limit × 3次。
这不是 bug,是我主动接受的取舍。原因有三:
- 防刷、防失控只需要「量级正确」,不需要「精确拦截第 N+1 次」。攻击者想绕过,得同时打穿多个实例且每个都卡在临界点,成本远高于收益;
- 如果真要全局精确,得上 Durable Objects 或者中心化计数,带来的是延迟和额外成本,对本系统不划算;
- 真正高价值的「精确防护」(比如防撞库)我是叠加在别处的:登录除了 IP 限流,还有账号级失败锁定(失败 N 次锁账号),见下文补充。
所以限流方案的选择,本质是「你要的是精确,还是够用」。我的判断是:边缘 API 的通用限流,要够用;账号安全的精确防护,另走专用逻辑。
七、取客户端 IP 的坑
按 IP 限流,第一步就是把「客户端真实 IP」取对。Cloudflare 边缘会注入cf-connecting-ip,这是最可信的来源;但万一没有(比如本地或某些代理链),回退到x-forwarded-for的第一段:
exportconstclientIp=(c:Context<AppBindings>):string=>c.req.header("cf-connecting-ip")??c.req.header("x-forwarded-for")??"unknown";这里有个隐性风险:x-forwarded-for是客户端可以伪造的请求头。所以我把它作为「回退」而非「首选」——首选永远是 Cloudflare 自己填的cf-connecting-ip。如果只信x-forwarded-for,攻击者随便改个头就能绕过 IP 限流。真实部署里,因为请求一定经过 Cloudflare 边缘,cf-connecting-ip几乎总是存在,回退分支更多是兜底本地调试。
八、把剩余额度告诉前端
最后一行细节:X-RateLimit-Limit和X-RateLimit-Remaining这两个响应头。它们不是装饰——前端拿到Remaining,就能在用户快触顶时提前提示「操作太频繁,稍后再试」,而不是等返回 429 才一脸懵。符合 RFC 6585 的惯例,很多前端限流库也认这两个头。
小结:限流的边界
在边缘架构下做限流,我最终的结论是:不要用「中央存储」去追求一个虚假的精确,而是用「实例内存的固定窗口」换零延迟和零配额消耗,并接受它是 per-island 的近似。配合账号级失败锁定补上安全短板,整体既防得住,又不误伤正常用户。
如果你的场景对精确性要求极高(比如按量计费 API、要严格封顶),那 Durable Objects 或者自建中心化计数才是正解——只是那时你要准备好为延迟和成本买单。选型之前,先想清楚你到底要「精确」还是要「够用」。
相关阅读
- Serverless 导出 CSV 总超时?用 Queue + R2 异步任务彻底解决
- Node 后端实战 · 多租户 SaaS 的数据隔离
- Node 后端实战 · JWT 双密钥轮转与 token 版本号
- Node 后端实战 · D1 那些坑
- Node 后端实战 · 架构决策全景
- Koa 实现 JWT 会话与鉴权,前后端分离项目通用方案
- RSA 非对称加密在 Node 中的应用实战
- MySQL 生产环境备份与恢复完整方案
- Ubuntu 下 Nginx 反向代理与 HTTPS 配置实战
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!