news 2026/8/15 10:48:47

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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}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-LimitX-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_IP10 次/分/IPrl:login:<ip>防撞库、防暴力破解
刷新 tokenREFRESH_PER_MIN_PER_IP30 次/分/IPrl:refresh:<ip>refresh 比登录频繁,放宽一档
通用 APIAPI_PER_MIN_PER_USER120 次/分/用户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);}};

两个设计点:

  1. 惰性清理:只有当 Map 超过 5000 条才扫一遍,平时零开销。对防刷场景,绝大多数 key 活不过一个窗口(60 秒),自然会被下一次扫到清掉。
  2. 全量扫描而非精准删除:扫描成本 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-LimitX-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 优化校阅,转发请注明首发地址,谢谢大家!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/15 10:48:27

Ubuntu Firefox与应用商店启动失败:从日志分析到系统修复全指南

1. 问题现象与初步排查&#xff1a;当Ubuntu的“门”突然打不开时最近在折腾Ubuntu系统时&#xff0c;遇到了一个挺典型但又让人有点烦躁的问题&#xff1a;系统自带的Firefox浏览器和应用商店&#xff08;Ubuntu Software&#xff09;突然就打不开了。具体表现是&#xff0c;点…

作者头像 李华
网站建设 2026/8/15 10:44:22

快速排序与桶排序:从分治思想到工程优化的高效排序算法解析

1. 项目概述&#xff1a;从“排序”到“高效排序”的思维跃迁 在编程和算法学习的路上&#xff0c;排序算法是个绕不开的坎。很多人学完冒泡、选择、插入排序后&#xff0c;觉得排序不过如此&#xff0c;直到遇到海量数据&#xff0c;看着程序“转圈圈”才意识到问题所在。今天…

作者头像 李华
网站建设 2026/8/15 10:43:37

无需环境配置,OpenClaw 小龙虾 Win10 快速落地教程

OpenClaw 小龙虾 v2.9.3&#xff1a;Windows10 桌面自动化 AI 智能体搭建与排错 摘要&#xff1a;想要体验能够直接操作电脑桌面的 AI 智能体&#xff0c;OpenClaw 是一个不错的选择。不少使用者在 Windows10 上面部署时&#xff0c;会遇到系统拦截、权限报错、路径识别异常等各…

作者头像 李华
网站建设 2026/8/15 10:36:00

AI 软件开发实战教程(五):把产品规则变成能落地的工程架构

“AI 软件开发实战教程”系列第 5 篇&#xff1a;产品规划批准以后&#xff0c;不急着创建项目和安装框架&#xff0c;先把并发、幂等、隐私、后台任务和测试边界说清楚&#xff0c;让后面的开发计划真正可执行。上一篇完成了关键外部服务验证&#xff0c;“邻行”也通过了正式…

作者头像 李华
网站建设 2026/8/15 10:35:42

Git推送失败:解决“failed to push some refs”错误的完整指南

1. 问题初探&#xff1a;为什么你的代码推不上去&#xff1f;“error: failed to push some refs” 这个提示&#xff0c;对于任何一个用过 Git 的人来说&#xff0c;都像是一个老朋友——一个时不时就来拜访&#xff0c;并且每次来都带着点小麻烦的老朋友。它通常出现在你信心…

作者头像 李华