Nuxt 应用部署在 ECS 双容器(nginx + node-app)上跑了一周相安无事,今早撞了个怪事:浏览器清除缓存后首次进入,满屏 net::ERR_ABORTED 429 (Too Many Requests);再刷新一次又一切正常——因为静态文件都缓存上了。这个"只有第一次坏"的特征,直接把病灶指向了限流误伤并发静态请求。

一句话结论

limit_req 挂在 location / 上,把 Nitro 代服务的静态分块(/_nuxt/*.js,本次构建 72 个)也计进了限流。首屏一次性并发 72 个 chunk + 1 个 HTML,远超 burst 上限,多出来的全被判 429。静态文件请求本就不该算进限流。

案发现场的配置

limit_req_zone $binary_remote_addr zone=node_all:10m rate=10r/s;
limit_req_status 429;

location / {                     # ← 匹配一切 URL
  limit_req zone=node_all burst=20 nodelay;
  proxy_pass http://node-app:3000;
}

rate=10r/s + burst=20 nodelay 是令牌桶语义:每秒补 10 个令牌、允许瞬间超额 20 个立即放行。按客户端 IP 计数,一次能容忍 ≈30 个请求。首屏 73 个并发砸进同一个桶,40 多个撞墙——浏览器把被拒的资源加载显示为 ERR_ABORTED 429。

豁免的原理:limit_req 是"跟着 location 走"的

最容易看混的一点:limit_req_zone 是全局定义,limit_req 是 location 内启用。某个请求最终由哪个 location 处理,就只受那个 location 里写的 limit_req 约束;那个 location 没写,请求压根不去扣令牌——不存在什么"白名单函数",纯粹是路由结果换了个没有检查站的通道。

而 nginx 给请求挑 location 的优先级规则:

  1. = 精确匹配
  2. 最长前缀匹配先记下;若该前缀带 ^~ 修饰符 → 直接采用,正则全部不再试
  3. 否则依次试 ~(区分大小写)/ ~*(忽略)正则,第一个命中者赢
  4. 正则都不中 → 回落到第 2 步记下的普通前缀

我第一版就是照这套规则写的:加 location ^~ /_nuxt/(不写 limit_req)豁免主战场,再加一条扩展名正则 ~* \.(js|css|png|...)$ 兜底零散静态——限流问题当场解决。

但 v1 埋了两个雷,都是"手痒覆盖上游"造成的

我在豁免 location 里顺手写了 expires 30d + add_header Cache-Control "public, immutable",自以为是加分。部署后拿真 chunk 探了一下 Nitro 自己发的头:

cache-control: public, max-age=31536000, immutable   ← Nitro 原生就发一年期

尴尬了——_nuxt/ 文件名全是内容 hash(如 B2X86jzn.js),改一个字节名字必变,官方设计就是"永不过期随便缓存"。我的 30d 反而把一年降级成一个月;更要命的是响应里出现两条 cache-control 叠加(max-age=2592000 + public, immutable),不同中间代理对重复头的处理不一致,属于埋雷。

教训一句话:改缓存头之前,先 curl -D - 探上游默认值,别盲目覆盖。

终版:把黑名单翻成白名单

正当我在豁免规则上越写越复杂时,被一句话点醒:“路径里有 /api/ 的都是接口,限流就行,剩下的不限制。”

这本质上是把限流模型从黑名单(默认全限、逐个豁免)翻转为白名单(默认放开、只限贵资源)。对"哪些请求消耗大"的判断,白名单天然更贴近事实:这个应用里只有 SSR 页面和 API 真正消耗 Node,静态文件本来就便宜,给便宜的东西配紧箍咒纯属设计浪费。最终配置只剩三层:

# 1) 登录/认证:正则优先命中,严限 1r/s(防爆破)
location ~ ^/api/(v2/auth/identity|(auth/)?(login|signin|register|token)) {
  limit_req zone=node_login burst=5 nodelay;
  proxy_pass http://node-app:3000;
}

# 2) 所有业务接口:统一 10r/s + burst 20
location /api/ {
  limit_req zone=node_all burst=20 nodelay;
  proxy_pass http://node-app:3000;
}

# 3) 其余(页面 + 静态资源):不限流;缓存头交回 Nitro 自管
location / {
  proxy_pass http://node-app:3000;
}

注意 nginx 的匹配顺序正好配合:/api/login 虽然字面上也落在 /api/ 前缀里,但正则优先级高于普通前缀,所以登录请求先被第 1 条截住走 1r/s 严限——正则 > 前缀 > 兜底,一层比一层宽。

实测四组全过:静态分块 30 连击全 200(改前必爆);页面 30 连击全 200;/api/login 12 连击 = 前 8 个 401(业务响应)+ 后 4 个 429(严限生效);_nuxt 响应只剩一条规范的一年期缓存头。

再往下挖一层:hash 文件名 + HTML 永不缓存的两极设计

这套修复之所以敢让静态"缓存到天荒地老",前提是入口 HTML 每次现拉。整个链条:

HTML(永不缓存,每次访问现取)
  └─ 里面引用"本次构建"的 chunk 文件名清单
       ├─ 文件名变了 = URL 变了 = 必重拉
       └─ 文件名没变 = URL 没变 = 命中一年缓存,连询问都不发(immutable)

所以重新部署不刷新浏览器缓存,部署改的是"要什么"的清单(HTML),缓存管的是"有什么"(hash 文件)——变者必重拉,不变者白嫖,改一个组件只重拉一个文件。铁律是"同名必同内容":文件名是内容的 hash,结构上免疫缓存中毒,再也不用手动 ?v= 升版本号。

反过来,HTML 一旦被误长缓存,就是整套体系的死穴:旧清单点名要一个已被新版删除的 chunk 名 → 缓存里恰好没有 → 服务器 404 → JS 挂载失败白屏。所以正确姿势是两极分化:清单(HTML)永远最新,清单点名的东西(chunk)永久有效,中间态"HTML 缓存一小时"最坏。

现状里 HTML 是 Nitro SSR 直出、不带 cache-control,浏览器每次导航都回源,属于"默认正确"。哪天前面套一层 CDN,就必须给 HTML 显式加 Cache-Control: no-cache(允许存副本但每次回源验证)——CDN 对可缓存性的默认野心比浏览器大得多,这一行从可选变必加。

明说的取舍

兜底 location / 完全不限流 = 有人恶意狂刷首页(每次都走 SSR,贵)时 nginx 不设防。以这台 2G 个人服务器的流量画像,现实风险约等于零,不值得为它加回复杂度;真被刷的那天,给兜底挂个宽松 zone(比如 30r/s)就是单行改动。规则越少,出怪事时排查面越小——这比"理论上更周全"值钱。

回滚与安全垫

每步改动前都先存带日期后缀的副本(原始版、中间版各留一份),流程一律 nginx -t 通过才 nginx -s reload,改坏随时一条回滚命令恢复。


本文数据均为 2026-10-23 上午在自有生产服务器实测:Nuxt 4.5.1 构建产物、72 个静态分块、nginx 1.21.5。