从一个 Referer 引发的 403,到 SSR 服务失稳:Astro 流式渲染与缓存失效分析

本文使用虚构域名、通用示意代码和示意数据,不包含真实业务地址、内部架构标识或公司源码。

一次 SSR 服务升级后,页面出现了看似矛盾的现象:

  • 页面请求返回 200 OK,正文却不完整。
  • 响应头配置了缓存时间,连续访问仍未观察到缓存命中。
  • 一个静态脚本的加载失败,逐渐放大为整个 SSR 服务的稳定性问题。

最终确认,最初的触发点是:JSDOM 加载远程脚本时携带的 Referer 不符合阿里云 OSS 防盗链规则,资源请求被返回 403。

但 403 本身并不足以解释整个故障。真正需要分析的是,这个依赖错误如何穿过应用渲染、HTTP 传输和多层缓存,最终将压力集中到 SSR 服务。

一、事故起点:JSDOM 发出的请求,也受 OSS 防盗链限制

JSDOM 可以在 Node.js 中创建模拟浏览器环境。如果启用了脚本执行和资源加载,文档中的远程脚本就会被下载并执行。

例如,下面是一个通用示意:

xml 复制代码
const dom = new JSDOM(
    `
        <html>
            <head>
                <script src="https://assets.example.net/ui.js"></script>
            </head>
            <body></body>
        </html>
    `,
    {
        url: 'https://www.example.com/',
        runScripts: 'dangerously',
        resources: 'usable'
    }
);

对 SSR 应用而言,ui.js 虽然是静态资源,却可能承担自定义组件注册等工作,成为服务端渲染的前置依赖。

此时存在两次独立的 HTTP 请求:

markdown 复制代码
用户请求 HTML
    → SSR 服务接收请求

SSR 服务请求 ui.js
    → OSS 或资源 CDN 接收请求

浏览器能正常加载资源,并不能证明 SSR 服务发出的请求也能通过访问检查。

1. referrer 与 Referer 不是同一个配置概念

这里有三个容易混淆的值:

名称 含义
JSDOM 的 url 当前模拟文档的地址
JSDOM 的 referrer document.referrer,即该文档的来源
HTTP Referer 资源请求实际发送的请求头,拼写沿用历史形式

在本次排查的 JSDOM 实现中,加载脚本等子资源时,请求的 Referer 来自模拟文档 URL。

因此,给 JSDOM 设置 referrer,不等于设置了脚本请求的 HTTP Referer。应在资源请求边界检查实际请求头,而不是只查看配置对象。

如果文档 URL 没有正确注入,JSDOM 可能使用默认的:

css 复制代码
about:blank

由此产生的请求来源,就可能不符合资源端要求。

2. OSS 为什么返回 403?

阿里云 OSS 可以通过 Referer 白名单、黑名单,以及是否允许空 Referer,对请求进行防盗链检查。不符合规则的请求可能被拒绝并返回 403。阿里云 OSS 防盗链文档

例如,资源端只允许:

arduino 复制代码
Referer: https://www.example.com/

而 SSR 请求实际携带了未被允许的来源,或者没有有效 Referer,便可能被拒绝。

服务端最初看到的日志却可能只有:

css 复制代码
script load failed: Event { isTrusted: [Getter] }

这是脚本加载失败事件,不包含足够的网络错误信息。继续监听 JSDOM 底层资源错误后,才能看到:

less 复制代码
Could not load script
cause: Status code: 403

到这一步,才算把问题从"脚本加载异常"定位到"资源访问策略拒绝"。

还需要注意:Referer 防盗链适合限制资源引用方式,但不能替代身份认证;服务端客户端可以自行构造这个请求头。

二、一个脚本 403,为什么能让整个页面失败?

关键取决于应用如何处理依赖失败。

假设 SSR 过程需要先完成:

css 复制代码
创建 JSDOM
    → 下载组件脚本
    → 执行脚本并注册组件
    → 渲染公共组件
    → 返回组件 HTML

一旦下载得到 403,脚本加载就会失败。

如果初始化逻辑拒绝 Promise,而上层组件没有降级处理,错误将沿调用链向上传播:

markdown 复制代码
资源返回 403
    → JSDOM 初始化失败
    → 公共组件渲染失败
    → 页面正文生成失败

这里的设计边界是:一个远程资源请求,成为了整个 HTML 响应的强依赖。

如果该组件被大量页面共用,影响范围就不会局限于某一个 URL。

同样重要的是,静态资源的 403 并不会自动变成页面请求的 403。资源请求和页面请求是两次 HTTP 交换,页面最终如何响应,由应用决定。

三、Astro 流式渲染为什么会先返回 200?

Astro 的按需渲染支持 HTML streaming:页面内容可以随着生成过程逐步发送,而不必等待整页完成后再统一输出。Astro 官方文档

可以将完整缓冲渲染理解为:

css 复制代码
完成所有必要渲染
    → 确认成功
    → 发送 200 和完整 HTML

流式渲染则允许:

css 复制代码
生成页面前部
    → 开始发送 200、响应头和 HTML
    → 继续生成后续内容
    → 完成后正常结束响应

它降低了开始交付 HTML 的等待时间,但也改变了错误处理的边界。

1. Response 已经创建,不代表正文已经完成

流式 SSR 可以把正文表示成异步序列或可读流:

arduino 复制代码
// 概念示意,不对应业务实现
return new Response(htmlStream, {
    status: 200,
    headers: {
        'Content-Type': 'text/html'
    }
});

此时 Response 已存在,但 htmlStream 后面的内容还可能需要:

  • 请求数据。
  • 等待子组件初始化。
  • 执行服务端组件代码。
  • 完成异步渲染。

所以,返回 Response 与完成页面渲染,是两个不同的时间点。

2. 渲染结果被持续写入网络响应

Node 适配层的工作可以概括为:

scss 复制代码
// 概念示意
writeHeaders(response.status, response.headers);

for await (const chunk of response.body) {
    writeBody(chunk);
}

endResponse();

如果正文读取过程中发生异常,循环无法正常执行到结束。

在本次分析的 Astro Node 实现中,正文读取失败后的处理路径会尝试写入错误文本,再销毁响应。它不会把已经发送的 200 重新改成 500。

3. 状态码究竟有没有发生变化?

对于已经开始传输的页面响应,通常没有发生"200 变成 500"。

更准确的过程是:

阶段 状态
响应提交前 应用仍可决定返回 200、重定向或 5xx
200 响应头已发送 该响应的最终状态已提交
后续渲染失败 正文传输失败
连接或流被终止 客户端同时看到 200 和传输错误

HTTP 不允许在同一个普通响应的正文中,再发送一套状态行去覆盖前面的状态。

因此,200 只能说明收到了成功状态头,不能证明整份响应成功完成。

四、HTTP 如何表示"200 之后传输失败"?

要区分应用渲染失败与 HTTP 消息结束,需要继续向协议层看。

1. HTTP/1.1:正文必须满足消息长度规则

如果响应携带:

css 复制代码
Content-Length: 100000

但只收到 30,000 字节就断开,客户端能够识别消息不完整。

对于无法预先确定最终长度的流式响应,HTTP/1.1 常用:

makefile 复制代码
Transfer-Encoding: chunked

正文按带长度的块发送,并以零长度块正常结束。缺少终止块意味着消息不完整。RFC 9112:不完整消息处理

因此,客户端可能已经读到了:

复制代码
HTTP/1.1 200 OK

也已经展示部分页面,但整个响应仍以传输失败告终。

2. HTTP/2:200 与流错误可以同时存在

HTTP/2 不使用 HTTP/1.1 的 chunked 编码,而是通过帧传递消息。

简化的正常过程如下:

ini 复制代码
HEADERS::status = 200
DATA:第一部分 HTML
DATA:第二部分 HTML
DATA:最后部分,携带 END_STREAM

正文结束也可以通过尾部 HEADERS 表达。关键是协议层需要正确完成流。RFC 9113:HTTP 消息交换

如果中途失败,客户端可能看到:

yaml 复制代码
HTTP/2 200

curl: (92) HTTP/2 stream was not closed cleanly:
INTERNAL_ERROR

这里的 INTERNAL_ERROR 是 HTTP/2 流错误,不是 HTTP 状态码 500。

在多层代理环境下,源站可能发生的是 HTTP/1.1 响应中断,CDN 再把它表现为客户端连接上的 HTTP/2 流错误。仅凭客户端错误名称,不能直接定位具体在哪一跳失败。

3. HTML 截断位置不一定是异常位置

组件输出、Node 写入、代理缓冲、压缩和网络帧之间,没有固定的一一对应关系。

markdown 复制代码
组件生成片段
    → 应用缓冲
    → Node 写出
    → 代理重新分块
    → 客户端接收

因此,正文截断在一段 JavaScript 中间,只能说明最后收到的字节在那里,不能证明那段 JavaScript 导致了服务端异常。

五、为什么 CDN 和 Varnish 没有形成有效缓存?

先看简化架构:

markdown 复制代码
客户端
    → CDN
    → Varnish
    → Astro SSR
    → 远程脚本资源

CDN 和 Varnish 都可能支持边回源、边向下游发送正文。

一次缓存未命中的请求,可能经历:

markdown 复制代码
收到源站 200 和缓存头
    → 开始接收正文
    → 同时向下游转发
    → 正文接收中途失败

"缓存策略允许存储"和"缓存对象完整接收成功"是两个阶段。

1. 缓存时间不是成功写入的凭证

下面的响应头:

arduino 复制代码
Cache-Control: public, max-age=120

表达的是缓存策略和新鲜度信息,不保证正文已经完整传输。

同样,一个在响应头阶段生成的缓存调试字段,也不能天然证明后续正文接收成功。必须确认该字段的生成位置和语义。

缓存层如果只收到半份正文,就不能因为头部写着 200 和有效期,就把它直接当成完整页面复用。

2. HTTP 规范如何要求处理不完整响应?

RFC 9111 允许缓存有条件地保存不完整响应,但必须明确标记为不完整。它不能被直接用于回答普通的完整页面请求,除非已经补齐;符合条件的范围请求则另有规则。RFC 9111 §3.3

因此,不应简单地说"CDN 绝对不会存任何部分数据"。

更准确的描述是:

异常中断的响应,不能作为一份完整 HTML 对象正常复用。

3. Varnish 流式转发时也不能撤回 200

Varnish 如果已经把源站响应头和部分正文发给下游,随后发现后端停止发送,就无法重新用一个完整 503 替换前面的响应。

Varnish 官方文档明确区分了两种情况:

  • 开启 streaming:后端中断可能通过断开连接向客户端表现,客户端得到部分响应。
  • 关闭 streaming:等待完整回源后再发送,有机会在失败时向客户端返回 503。Varnish Content streaming

缓存未命中与流式转发并不冲突:代理可以正在传递数据,却最终没有获得一份能正常复用的完整对象。

4. 从观察到因果,还需要日志关联

本次外部观察包括:

  • 页面返回 200。
  • 下载以流错误结束。
  • 正文在中途截断。
  • 连续请求未观察到边缘 HIT。

这些现象与"回源正文失败,无法形成有效缓存"一致。

但仅凭外部响应头,仍不能证明所有内部缓存层的具体行为。还需要排除:

  • 不同节点或缓存键。
  • Cookie、Set-Cookie 相关策略。
  • 强制绕过或不可缓存规则。
  • 对象过期、失效和回源重验证。
  • Varnish grace 或旧对象回退行为。

因此,完整事故归因应关联同一请求的 SSR 异常、Varnish 后端读取错误与 CDN 回源结果。

六、缓存未命中如何把局部故障放大到 SSR 服务?

假设健康状态下,大部分页面请求可以由缓存直接回答。SSR 只承担其中一小部分。

当受影响页面无法产生新的完整缓存对象,已有缓存又过期或失效,后续访问就可能持续回源:

markdown 复制代码
请求进入
    → 缓存无法提供可复用对象
    → SSR 重新渲染
    → 脚本再次被 OSS 拒绝
    → 响应中断
    → 下一次请求继续回源

这里的"所有请求打到 SSR",应理解为受影响缓存键、且没有可用缓存回退的请求。其他页面、其他节点或仍有旧对象的请求,不一定经历同样过程。

1. SSR 压力可能增长几十倍甚至更多

以下仅为示意数据。

假设:

  • 入口流量为每秒 5,000 个请求。
  • 正常情况下,综合缓存命中率为 99%。

SSR 正常承担:

scss 复制代码
5,000 × (1 − 99%) = 50 请求/秒

如果受影响流量失去缓存保护,接近全部回源:

复制代码
SSR 承担接近 5,000 请求/秒

源站面对的是约 100 倍的请求增长,而不只是多了一些脚本错误。

多层缓存原本是容量设计的一部分。缓存对象无法生成时,实际工作量会重新分配给应用服务器。

2. 一次页面回源,还可能放大成多次依赖操作

SSR 页面往往不仅加载脚本,还会同时执行:

  • 内容与配置查询。
  • 导航、推荐等公共数据请求。
  • 模板和组件渲染。
  • 日志和链路数据上报。

页面最终失败,不代表这些已启动的工作都被及时取消。

因此,页面请求回源增加,也可能带动下游 API、日志系统和数据库压力上升。

3. 初始化失败重试会形成持续消耗

如果脚本初始化采用"成功后复用,失败后下次再试"的逻辑:

markdown 复制代码
初始化成功
    → 保存实例
    → 后续复用

初始化失败
    → 清除初始化状态
    → 后续重新尝试

持续性的 Referer 拒绝,就会使后一个分支持续发生。

同一进程内即使做了并发去重,也只会合并同一时间的初始化请求:

markdown 复制代码
第一批请求共享一次初始化
    → 失败

第二批请求共享下一次初始化
    → 再次失败

它不能替代失败冷却和熔断。多个进程、多个实例也通常不会自动共享同一个初始化状态。

七、从高回源到后端崩溃,中间发生了什么?

可以用下面的因果链概括风险:

css 复制代码
```mermaid
flowchart TD
    A[资源请求 Referer 不符合规则] --> B[OSS 返回 403]
    B --> C[JSDOM 初始化失败]
    C --> D[组件异常传播到页面渲染]
    D --> E[已开始的 HTML 响应异常中断]
    E --> F[无法形成可复用的完整缓存对象]
    F --> G[受影响请求持续回源]
    G --> H[SSR 与依赖操作增加]
    H --> I[延迟和资源占用上升]
    I --> J[超时、重试或实例重启]
    J --> H
```

其中,服务失稳可能通过多种方式发生,不能只凭错误日志认定一定是内存溢出。

1. 并发在途请求堆积

在近似稳定的条件下,可以用下面的关系理解容量:

复制代码
在途请求数 ≈ 请求到达速率 × 平均处理时间

请求增加时,系统变慢;系统变慢后,每个请求存活更久,又会增加同时占用资源的请求数。

进入过载后,这个关系不再是精确预测,但它揭示了一个关键反馈:流量上涨与延迟上涨会共同放大并发占用。

2. CPU、内存和事件循环压力增加

反复创建模拟 DOM、执行初始化、处理失败和序列化错误,都会消耗 CPU 与内存。

如果失败路径没有正确关闭已创建的 JSDOM、终止相关请求或清理计时器,还可能延长对象和资源的存活时间。

这些都属于需要通过 profiling、堆快照和资源指标验证的风险,不能直接把它们写成已确认的内存泄漏。

3. 日志放大故障成本

一个资源拒绝可能产生多层错误:

markdown 复制代码
资源加载错误
    → 初始化异常
    → 组件异常
    → 页面流异常

高请求量下,错误栈生成、JSON 序列化、日志队列和上报都会增加负担。

因此,错误日志数不能直接等同于失败页面请求数。

4. 重启让服务重新进入冷启动

实例因资源阈值、健康检查失败或其他原因重启后:

markdown 复制代码
本地缓存消失
    → 初始化重新开始
    → Referer 问题仍然存在
    → 再次失败

如果流量没有被隔离,新实例会继续接收请求,形成"重启后仍无法恢复"的循环。

要确定最终崩溃原因,应检查退出码、OOM 事件、CPU、RSS、事件循环延迟、连接数和健康检查记录,而不能仅根据页面 200 或 JSDOM 403 推断。

八、为什么升级后才出现?

升级是问题出现的时间边界,但不一定是根因所在。

Astro、Node、JSDOM、构建配置和应用失败策略,可能在一次发布中同时发生变化。需要分别验证:

  1. 新旧版本是否生成相同的文档 URL 和 HTTP Referer。
  2. 运行环境是否正确注入了相同配置。
  3. 实际安装依赖是否一致。
  4. 初始化时机是否变化。
  5. 脚本失败后,是继续降级还是向上抛错。

尤其需要区分:

markdown 复制代码
请求层面的回归
    → 原本允许的请求,现在被 OSS 拒绝

失败传播层面的变化
    → 原本局部失败,现在中断整页

两者可以同时存在。

如果旧逻辑吞掉初始化错误,而新逻辑开始拒绝 Promise,升级可能暴露了旧问题;如果新环境改变了请求来源,则可能产生了新的 403。需要对照请求和执行结果,不能只对照框架版本号。

九、如何修复,并阻止故障再次扩大?

1. 修正资源访问契约

将 SSR 实际发送的 Referer 与 OSS 允许规则对齐,并校验必要配置,避免缺失时静默使用默认文档地址。

修复应覆盖真实请求,而不只是配置对象:

markdown 复制代码
配置值正确
    → JSDOM 文档 URL 正确
    → 实际 Referer 正确
    → 资源请求成功

如果资源访问同时经过 CDN 和 OSS,还应分别检查两层访问规则。不要用全局放开防盗链替代有范围的修复。

2. 将必要初始化前移到就绪检查

对于所有页面都依赖的渲染环境,可以在启动时预热。

markdown 复制代码
依赖初始化成功
    → 实例就绪
    → 开始接收业务流量

这样能够避免用户请求承担首次初始化,也能让错误配置在发布阶段暴露。

同时应设计合理的就绪与存活检查,防止配置错误引发无意义的持续重启。

3. 加入失败冷却、超时与清理

持续性的 403 不适合立即无限重试。

失败后可以进入短暂冷却,限制初始化并发,设置总超时,并清理创建到一半的环境。对于明确的权限错误,还应优先告警配置问题,而不是通过高频重试等待恢复。

4. 为公共组件定义降级与缓存策略

可选组件失败时,可以返回最小可用结构;关键依赖失败时,可以在响应开始前返回明确的 503。

如果降级后仍返回 200,必须同时决定该页面是否允许缓存:

markdown 复制代码
可接受的公共降级内容
    → 可以选择短时间缓存

不可接受或与用户状态有关的降级内容
    → 禁止缓存,或采用其他明确策略

不能只把 reject() 改成 resolve(),就认为稳定性问题已经解决。

5. 对关键 HTML 路径评估缓冲

对完整性要求高、缓存收益大的页面,可以选择:

  • 应用层完整渲染后再发送。
  • Varnish 层等待完整回源后再向下游发送。
  • 将首屏必需依赖前置,保留其他部分的流式输出。

完整缓冲会增加首字节等待和内存占用,也不能识别"业务缺内容但正常结束"的页面,因此应按路径和容量评估。

6. 让缓存故障不等于源站满负荷

可根据业务可接受程度配置旧内容回退、回源并发限制和熔断。

尤其是已有缓存过期时,如果允许在回源失败期间提供旧内容,就可能避免受影响流量同时压向 SSR。该策略取决于内容新鲜度要求和实际缓存配置,不能默认认为已经存在。

7. 成功监控必须覆盖响应结束

除了状态码,还应记录:

层级 必要信号
资源依赖 HTTP 状态、失败原因、初始化重试
SSR 渲染完成率、流异常、处理时间
Node 正常完成、提前关闭、发送字节数
Varnish 后端读取失败、可缓存性、命中情况
CDN 回源失败、边缘命中、传输错误
探测客户端 下载退出码、正文完整性、关键内容

针对这类故障,一条有效的验证链应是:

css 复制代码
资源访问成功
    → 依赖初始化完成
    → HTML 完整生成
    → HTTP 正常结束
    → 缓存对象可复用
    → 后续请求命中
    → SSR 回源量恢复

修复 Referer 后,仍需要逐项验证这条链路。只有资源权限、错误隔离、响应完整性与缓存回退都具备明确边界,一个静态脚本的 403 才不会再次扩大为整站 SSR 容量事故。

相关推荐
律宏阔11 分钟前
微信小程序 + Taro + TDesign 去掉 [&&&],让普通 Tailwind 类接管局部样式
前端·微信小程序·taro
费曼学习法12 分钟前
把 3 个内部服务封装成 MCP Server 的工程复盘:进程模型、超时与错误契约
前端
Captaincc12 分钟前
转译:《软件工程师现在该怎么办》
前端·后端·ai编程
天才熊猫君16 分钟前
给 Vue 的 reactive 套 Proxy,为什么响应式会失灵?
前端·javascript
htzyl20619 分钟前
别急着上微前端:从 6 个场景倒推 Module Federation 2.0 与 qiankun 2.x 的选型
前端·微服务
verohh19 分钟前
Cesium 多业务页面切换的资源隔离与架构设计:单 Viewer 复用还是多实例隔离
前端
律宏阔20 分钟前
微信小程序 + Taro 从 ESLint 迁移到 Oxlint 让项目代码检查速度提升近 12 倍
前端·typescript·eslint
用户308169987648522 分钟前
复制的代码为什么报错?BOM 等 10 种隐形字符 40 组实测
前端
Swot25 分钟前
SvelteKit 踩坑记:+page.js 里忘了 ...data,服务端数据全没了
前端·后端·svelte