ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Node 后端实战 · Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战

2026/8/15 10:48:51 拓冰建站 浏览量
Node 后端实战 · Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战 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}fromhono;import{err}from/lib/errors;importtype{AppBindings}from/middleware/auth;/** 单窗口计数 */interfaceWindowCounter{count:number;// 当前窗口内已放行计数windowStart:number;// 当前窗口起点秒}// 跨请求持久化挂到 globalThis防止实例回收/HMR 丢失constgglobalThisasunknownas{__rateLimitStore?:Mapstring,WindowCounter};conststore:Mapstring,WindowCounterg.__rateLimitStore??newMap();g.__rateLimitStorestore;然后是限流工厂本身exportinterfaceRateLimitOptions{key:string|((c:ContextAppBindings)string);// 限流键按 IP / 用户动态生成limit:number;// 窗口内允许的最大请求数windowSec:number;// 窗口时长秒code?:string;// 超限错误码默认 RATE_LIMIT}exportconstrateLimit(opts:RateLimitOptions):MiddlewareHandlerAppBindingsasync(c,next){// dev 环境暂停限流本地联调避免刷新/轮询误伤自己if(c.env.ENVdev){awaitnext();return;}constkeytypeofopts.keyfunction?opts.key(c):opts.key;constnowSecMath.floor(Date.now()/1000);constwindowStartMath.floor(nowSec/opts.windowSec)*opts.windowSec;constcurstore.get(key);letcount:number;if(!cur||cur.windowStart!windowStart){// 没有记录或窗口已翻页 → 开新窗口计数从 1 起count1;store.set(key,{count,windowStart});}else{cur.count1;countcur.count;}maybeSweep(windowStart);if(countopts.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按用户限未登录回落到 IPconstapiRateLimitrateLimit({key:(c){constuc.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_IP10 次/分/IPrl:login:ip防撞库、防暴力破解刷新 tokenREFRESH_PER_MIN_PER_IP30 次/分/IPrl:refresh:iprefresh 比登录频繁放宽一档通用 APIAPI_PER_MIN_PER_USER120 次/分/用户rl:api:userId防单账号刷爆后端注意登录给得最紧10 次/分因为这是匿名端点最坏情况下攻击者可以拿它做撞库而正常用户一分钟登录十次已经是极端情况了不会误伤。四、dev 短路别在联调时把自己限死代码里第一件事是判断c.env.ENV dev就直接放行。这个不是偷懒——本地联调时前端可能一秒发好几个请求、HMR 疯狂刷新、轮询接口反复跑如果限流开着最先被挡的就是开发自己。线上靠环境变量区分dev 直接短路跳过省心。五、内存不是无限的防 Map 膨胀挂globalThis的内存 Map如果只进不出长期运行下去会越攒越大——尤其按 IP 限流时每个陌生 IP 都会占一个 key。所以加了一个清理机制constMAX_ENTRIES5000;// 仅当超过阈值时才全量扫描清掉「不在当前窗口」的旧 keyconstmaybeSweep(windowStart:number):void{if(store.sizeMAX_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是我主动接受的取舍。原因有三防刷、防失控只需要「量级正确」不需要「精确拦截第 N1 次」。攻击者想绕过得同时打穿多个实例且每个都卡在临界点成本远高于收益如果真要全局精确得上 Durable Objects 或者中心化计数带来的是延迟和额外成本对本系统不划算真正高价值的「精确防护」比如防撞库我是叠加在别处的登录除了 IP 限流还有账号级失败锁定失败 N 次锁账号见下文补充。所以限流方案的选择本质是「你要的是精确还是够用」。我的判断是边缘 API 的通用限流要够用账号安全的精确防护另走专用逻辑。七、取客户端 IP 的坑按 IP 限流第一步就是把「客户端真实 IP」取对。Cloudflare 边缘会注入cf-connecting-ip这是最可信的来源但万一没有比如本地或某些代理链回退到x-forwarded-for的第一段exportconstclientIp(c:ContextAppBindings):stringc.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 优化校阅转发请注明首发地址谢谢大家