本文使用虚构域名、通用示意代码和示意数据,不包含真实业务地址、内部架构标识或公司源码。
一次 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、构建配置和应用失败策略,可能在一次发布中同时发生变化。需要分别验证:
- 新旧版本是否生成相同的文档 URL 和 HTTP Referer。
- 运行环境是否正确注入了相同配置。
- 实际安装依赖是否一致。
- 初始化时机是否变化。
- 脚本失败后,是继续降级还是向上抛错。
尤其需要区分:
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 容量事故。