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

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

各位看官,限流这件事,放在单机或者常驻容器里,基本就是个中间件的事:装个 express-rate-limit,或者前端挂个 Nginx limit_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 或多实例也不会把计数弄丢:

typescript 复制代码
// middleware/ratelimit.ts
import type { Context, MiddlewareHandler } from "hono";
import { err } from "@/lib/errors";
import type { AppBindings } from "@/middleware/auth";

/** 单窗口计数 */
interface WindowCounter {
  count: number;       // 当前窗口内已放行计数
  windowStart: number; // 当前窗口起点(秒)
}

// 跨请求持久化:挂到 globalThis,防止实例回收/HMR 丢失
const g = globalThis as unknown as { __rateLimitStore?: Map<string, WindowCounter> };
const store: Map<string, WindowCounter> = g.__rateLimitStore ?? new Map();
g.__rateLimitStore = store;

然后是限流工厂本身:

typescript 复制代码
export interface RateLimitOptions {
  key: string | ((c: Context<AppBindings>) => string); // 限流键:按 IP / 用户动态生成
  limit: number;     // 窗口内允许的最大请求数
  windowSec: number; // 窗口时长(秒)
  code?: string;     // 超限错误码(默认 RATE_LIMIT)
}

export const rateLimit =
  (opts: RateLimitOptions): MiddlewareHandler<AppBindings> =>
  async (c, next) => {
    // dev 环境暂停限流:本地联调避免刷新/轮询误伤自己
    if (c.env.ENV === "dev") {
      await next();
      return;
    }
    const key = typeof opts.key === "function" ? opts.key(c) : opts.key;
    const nowSec = Math.floor(Date.now() / 1000);
    const windowStart = Math.floor(nowSec / opts.windowSec) * opts.windowSec;

    const cur = store.get(key);
    let count: 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) throw err(opts.code ?? "RATE_LIMIT", "请求过于频繁,请稍后再试");
    c.header("X-RateLimit-Limit", String(opts.limit));
    c.header("X-RateLimit-Remaining", String(Math.max(0, opts.limit - count)));
    await next();
  };

逻辑很简单:算出现在属于哪个窗口,没有就新建(计数 1),有就 +1,超了就抛 RATE_LIMIT(对应 HTTP 429)。同时把 X-RateLimit-LimitX-RateLimit-Remaining 写进响应头,让前端知道「你还能发几条」。

三、限流键与三档限额

限流键决定了「按谁来限」。匿名端点(登录、刷新 token)按客户端 IP 限,登录态接口按用户 ID 限------这样既防了「一个 IP 疯狂撞库」,也防了「一个账号高频刷接口」:

typescript 复制代码
// 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,按用户限;未登录回落到 IP
const apiRateLimit = rateLimit({
  key: (c) => {
    const u = c.get("user");
    return u ? 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。所以加了一个清理机制:

typescript 复制代码
const MAX_ENTRIES = 5000;

// 仅当超过阈值时才全量扫描,清掉「不在当前窗口」的旧 key
const maybeSweep = (windowStart: number): void => {
  if (store.size < MAX_ENTRIES) return;
  for (const [k, v] of store) {
    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 的第一段:

typescript 复制代码
export const clientIp = (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 或者自建中心化计数才是正解------只是那时你要准备好为延迟和成本买单。选型之前,先想清楚你到底要「精确」还是要「够用」。


相关阅读

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
sRect2 天前
用ESP32-S3开发板开发一个接收消息的BB机
android·serverless·嵌入式
FungLeo2 天前
Node 后端实战 · D1 那些坑:100 参数上限逼出的批量写入重构
node.js·serverless·d1 数据库·sqli
摆烂菜鸡沧9963 天前
【项目踩坑记录】Cloudflare部署记录与修改二级域名
cloudflare·网站部署
阿里云云原生9 天前
AI 应用的统一流量门户:深度解析阿里云 AI 网关 Serverless 新架构与智能路由
serverless
openYuanrong分布式计算引擎12 天前
openYuanrong 打造 Agent 时代的企业级分布式底座
人工智能·分布式·ai·serverless
掉鱼的猫13 天前
Solon 的 10 种 HTTP 服务器:改一行依赖,换一个引擎
java·serverless
网络研究院14 天前
Cloudflare 推出 Precursor 行为监测系统,全会话追踪技术引爆 AI 机器人防御战与隐私新争议
网络·人工智能·机器人·系统·cloudflare·反爬虫
薛少杰15 天前
阿里云国际站:函数计算ModuleNotFoundError?Python依赖打包与层配置实战
serverless·阿里云函数计算·python依赖打包·层配置·modulenotfounderror