<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>限流 on 老朱的赛博工位</title><link>https://blog.zjkun.top/tags/%E9%99%90%E6%B5%81/</link><description>Recent content in 限流 on 老朱的赛博工位</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Fri, 23 Oct 2026 10:40:00 +0800</lastBuildDate><atom:link href="https://blog.zjkun.top/tags/%E9%99%90%E6%B5%81/index.xml" rel="self" type="application/rss+xml"/><item><title>一次 429 排查记：nginx 限流误伤静态资源，和它背后的缓存分工</title><link>https://blog.zjkun.top/posts/nginx-429-ratelimit-static-assets/</link><pubDate>Fri, 23 Oct 2026 10:40:00 +0800</pubDate><guid>https://blog.zjkun.top/posts/nginx-429-ratelimit-static-assets/</guid><description>清除浏览器缓存后首次进站，控制台一片 net::ERR_ABORTED 429——nginx 的 limit_req 挂在 location / 上，把 72 个 Nuxt 静态分块也算进了 10r/s。修复从「静态豁免黑名单」演化到「只限 /api/ 白名单」，顺带挖透 nginx location 匹配优先级、hash 文件名一年 immutable 缓存、HTML 永不缓存这套前端缓存分工。</description><content:encoded><![CDATA[<p>Nuxt 应用部署在 ECS 双容器（nginx + node-app）上跑了一周相安无事，今早撞了个怪事：<strong>浏览器清除缓存后首次进入，满屏 <code>net::ERR_ABORTED 429 (Too Many Requests)</code>；再刷新一次又一切正常</strong>——因为静态文件都缓存上了。这个&quot;只有第一次坏&quot;的特征，直接把病灶指向了限流误伤并发静态请求。</p>
<h2 id="一句话结论">一句话结论</h2>
<p><code>limit_req</code> 挂在 <code>location /</code> 上，把 Nitro 代服务的静态分块（<code>/_nuxt/*.js</code>，本次构建 72 个）也计进了限流。首屏一次性并发 72 个 chunk + 1 个 HTML，远超 burst 上限，多出来的全被判 429。<strong>静态文件请求本就不该算进限流。</strong></p>
<h2 id="案发现场的配置">案发现场的配置</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">limit_req_zone</span> $binary_remote_addr <span style="color:#e6db74">zone=node_all:10m</span> <span style="color:#e6db74">rate=10r/s</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">limit_req_status</span> <span style="color:#ae81ff">429</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">location</span> <span style="color:#e6db74">/</span> {                     <span style="color:#75715e"># ← 匹配一切 URL
</span></span></span><span style="display:flex;"><span>  <span style="color:#f92672">limit_req</span> <span style="color:#e6db74">zone=node_all</span> <span style="color:#e6db74">burst=20</span> <span style="color:#e6db74">nodelay</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://node-app:3000</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>rate=10r/s + burst=20 nodelay</code> 是令牌桶语义：每秒补 10 个令牌、允许瞬间超额 20 个立即放行。按客户端 IP 计数，一次能容忍 ≈30 个请求。首屏 73 个并发砸进同一个桶，40 多个撞墙——浏览器把被拒的资源加载显示为 ERR_ABORTED 429。</p>
<h2 id="豁免的原理limit_req-是跟着-location-走的">豁免的原理：limit_req 是&quot;跟着 location 走&quot;的</h2>
<p>最容易看混的一点：<strong><code>limit_req_zone</code> 是全局定义，<code>limit_req</code> 是 location 内启用</strong>。某个请求最终由哪个 location 处理，就只受那个 location 里写的 limit_req 约束；那个 location 没写，请求压根不去扣令牌——不存在什么&quot;白名单函数&quot;，纯粹是路由结果换了个没有检查站的通道。</p>
<p>而 nginx 给请求挑 location 的优先级规则：</p>
<ol>
<li><code>=</code> 精确匹配</li>
<li><strong>最长前缀匹配</strong>先记下；若该前缀带 <code>^~</code> 修饰符 → 直接采用，正则全部不再试</li>
<li>否则依次试 <code>~</code>（区分大小写）/ <code>~*</code>（忽略）正则，第一个命中者赢</li>
<li>正则都不中 → 回落到第 2 步记下的普通前缀</li>
</ol>
<p>我第一版就是照这套规则写的：加 <code>location ^~ /_nuxt/</code>（不写 limit_req）豁免主战场，再加一条扩展名正则 <code>~* \.(js|css|png|...)$</code> 兜底零散静态——限流问题当场解决。</p>
<h2 id="但-v1-埋了两个雷都是手痒覆盖上游造成的">但 v1 埋了两个雷，都是&quot;手痒覆盖上游&quot;造成的</h2>
<p>我在豁免 location 里顺手写了 <code>expires 30d</code> + <code>add_header Cache-Control &quot;public, immutable&quot;</code>，自以为是加分。部署后拿真 chunk 探了一下 Nitro 自己发的头：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>cache-control: public, max-age=31536000, immutable   ← Nitro 原生就发一年期
</span></span></code></pre></div><p>尴尬了——<code>_nuxt/</code> 文件名全是内容 hash（如 <code>B2X86jzn.js</code>），改一个字节名字必变，官方设计就是&quot;永不过期随便缓存&quot;。我的 30d <strong>反而把一年降级成一个月</strong>；更要命的是响应里出现<strong>两条 cache-control 叠加</strong>（<code>max-age=2592000</code> + <code>public, immutable</code>），不同中间代理对重复头的处理不一致，属于埋雷。</p>
<p>教训一句话：<strong>改缓存头之前，先 <code>curl -D -</code> 探上游默认值，别盲目覆盖。</strong></p>
<h2 id="终版把黑名单翻成白名单">终版：把黑名单翻成白名单</h2>
<p>正当我在豁免规则上越写越复杂时，被一句话点醒：&ldquo;路径里有 <code>/api/</code> 的都是接口，限流就行，剩下的不限制。&rdquo;</p>
<p>这本质上是把限流模型从<strong>黑名单（默认全限、逐个豁免）<strong>翻转为</strong>白名单（默认放开、只限贵资源）</strong>。对&quot;哪些请求消耗大&quot;的判断，白名单天然更贴近事实：这个应用里只有 SSR 页面和 API 真正消耗 Node，静态文件本来就便宜，给便宜的东西配紧箍咒纯属设计浪费。最终配置只剩三层：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#75715e"># 1) 登录/认证：正则优先命中，严限 1r/s（防爆破）
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">location</span> ~ <span style="color:#e6db74">^/api/(v2/auth/identity|(auth/)?(login|signin|register|token))</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">limit_req</span> <span style="color:#e6db74">zone=node_login</span> <span style="color:#e6db74">burst=5</span> <span style="color:#e6db74">nodelay</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://node-app:3000</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 2) 所有业务接口：统一 10r/s + burst 20
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">location</span> <span style="color:#e6db74">/api/</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">limit_req</span> <span style="color:#e6db74">zone=node_all</span> <span style="color:#e6db74">burst=20</span> <span style="color:#e6db74">nodelay</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://node-app:3000</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 3) 其余（页面 + 静态资源）：不限流；缓存头交回 Nitro 自管
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">location</span> <span style="color:#e6db74">/</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://node-app:3000</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>注意 nginx 的匹配顺序正好配合：<code>/api/login</code> 虽然字面上也落在 <code>/api/</code> 前缀里，但正则优先级高于普通前缀，所以登录请求先被第 1 条截住走 1r/s 严限——<strong>正则 &gt; 前缀 &gt; 兜底</strong>，一层比一层宽。</p>
<p>实测四组全过：静态分块 30 连击全 200（改前必爆）；页面 30 连击全 200；<code>/api/login</code> 12 连击 = 前 8 个 401（业务响应）+ 后 4 个 429（严限生效）；<code>_nuxt</code> 响应只剩一条规范的一年期缓存头。</p>
<h2 id="再往下挖一层hash-文件名--html-永不缓存的两极设计">再往下挖一层：hash 文件名 + HTML 永不缓存的两极设计</h2>
<p>这套修复之所以敢让静态&quot;缓存到天荒地老&quot;，前提是入口 HTML 每次现拉。整个链条：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>HTML（永不缓存，每次访问现取）
</span></span><span style="display:flex;"><span>  └─ 里面引用&#34;本次构建&#34;的 chunk 文件名清单
</span></span><span style="display:flex;"><span>       ├─ 文件名变了 = URL 变了 = 必重拉
</span></span><span style="display:flex;"><span>       └─ 文件名没变 = URL 没变 = 命中一年缓存，连询问都不发（immutable）
</span></span></code></pre></div><p>所以<strong>重新部署不刷新浏览器缓存，部署改的是&quot;要什么&quot;的清单（HTML），缓存管的是&quot;有什么&quot;（hash 文件）</strong>——变者必重拉，不变者白嫖，改一个组件只重拉一个文件。铁律是&quot;同名必同内容&quot;：文件名是内容的 hash，结构上免疫缓存中毒，再也不用手动 <code>?v=</code> 升版本号。</p>
<p>反过来，HTML 一旦被误长缓存，就是整套体系的死穴：旧清单点名要一个已被新版删除的 chunk 名 → 缓存里恰好没有 → 服务器 404 → JS 挂载失败白屏。所以正确姿势是两极分化：<strong>清单（HTML）永远最新，清单点名的东西（chunk）永久有效</strong>，中间态&quot;HTML 缓存一小时&quot;最坏。</p>
<p>现状里 HTML 是 Nitro SSR 直出、不带 cache-control，浏览器每次导航都回源，属于&quot;默认正确&quot;。哪天前面套一层 CDN，就必须给 HTML 显式加 <code>Cache-Control: no-cache</code>（允许存副本但每次回源验证）——CDN 对可缓存性的默认野心比浏览器大得多，这一行从可选变必加。</p>
<h2 id="明说的取舍">明说的取舍</h2>
<p>兜底 <code>location /</code> 完全不限流 = 有人恶意狂刷首页（每次都走 SSR，贵）时 nginx 不设防。以这台 2G 个人服务器的流量画像，现实风险约等于零，不值得为它加回复杂度；真被刷的那天，给兜底挂个宽松 zone（比如 30r/s）就是单行改动。<strong>规则越少，出怪事时排查面越小</strong>——这比&quot;理论上更周全&quot;值钱。</p>
<h2 id="回滚与安全垫">回滚与安全垫</h2>
<p>每步改动前都先存带日期后缀的副本（原始版、中间版各留一份），流程一律 <code>nginx -t</code> 通过才 <code>nginx -s reload</code>，改坏随时一条回滚命令恢复。</p>
<hr>
<p><em>本文数据均为 2026-10-23 上午在自有生产服务器实测：Nuxt 4.5.1 构建产物、72 个静态分块、nginx 1.21.5。</em></p>
]]></content:encoded></item></channel></rss>