10

网络协议与缓存

1,136 行约 58 分钟34 个代码块33 张表格更新于 2026-11-03

10 · 网络协议与缓存(详解版)

一句话定位:前端所有数据都通过网络到达。协议决定「多快、多可靠」,缓存决定「要不要再跑一趟」,鉴权决定「谁能拿」。这三件事共同决定了页面的真实速度与安全边界。

读完本篇你应该能做到

  1. 看懂一次请求在 Network 面板里的每一段耗时,并判断瓶颈在 DNS、连接、服务端还是传输;
  2. 正确配置 HTTP 缓存、CDN 缓存与跨域,避免「更新不生效」和「预检失败」;
  3. 说清 HTTP/1.1、HTTP/2、HTTP/3 的差异与适用场景;
  4. 设计一套可解释的鉴权与缓存方案,并能对安全与成本负责。

0. 领域导论

0.1 这个领域解决什么问题

现象根因归属
接口 P99 800ms,后端说「我只用了 50ms」连接复用差、跨地域、TLS 握手、排队链路耗时
带 Cookie 的跨域请求全部失败Access-Control-Allow-Origin: * 与 credentials 互斥CORS
发布后部分用户仍是旧页面HTML 被缓存,或 Service Worker 未更新缓存策略
用户从第三方站点跳转后登录态丢失SameSite=Lax/Strict 拦截了跨站 CookieCookie 属性

网络问题最难的地方是:它不在你的代码里。你必须能读懂协议、读懂面板、读懂响应头。

0.2 一个类比

把一次请求想成寄快递:

  • DNS = 查地址(把域名翻译成 IP)。
  • TCP/TLS 握手 = 打电话确认收件、核对身份(HTTPS 的证书校验)。
  • HTTP 请求 = 填快递单(方法、路径、头、体)。
  • CDN = 前置仓:货在离你最近的仓库,直接送。
  • 缓存 = 家里已有的存货:没过期就直接用,不用再买。
  • 鉴权 = 取件码:没有码,仓库不放货。

理解了这条链路,你就知道「为什么首屏慢不一定是接口慢」——可能是查地址慢、打电话慢、仓库远。

0.3 学习地图

10 网络协议与缓存
  ├─ 前置:05 浏览器(同源、存储、渲染)
  ├─ 紧接:11 性能(TTFB/LCP)、12 安全(CORS/XSS/CSRF)
  ├─ 交叉:14 BFF(服务端转发与聚合)、09 构建(资源 hash)
  └─ 长期:17 架构(多地域与容灾)

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础会用 fetch、看状态码、配简单跨域接口联调能定位 404/500/CORS 报错
L2 进阶懂缓存协商、HTTP/2、鉴权方案缓存与鉴权配置能解释 304 与预检
L3 高级懂 TLS/QUIC、连接复用、实时通信链路优化、协议选型能读懂瀑布图每一段
L4 专家建缓存体系、多地域容灾、成本治理CDN/网关方案、SLO能对可用性与成本负责

1. L1 基础:先看懂一次请求

1.1 报文长什么样

GET /api/orders?page=1 HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer xxx
Cookie: sid=abc
If-None-Match: "v123"

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: private, max-age=60
ETag: "v124"
Content-Length: 245

{"list":[],"total":0}

三部分:起始行(方法/路径/状态)、头(元信息)、体(数据)。前端 90% 的问题都能从「头」里找到答案。

1.2 方法与状态码

方法语义幂等可缓存
GET读取是是
POST创建/提交否极少
PUT全量替换是否
PATCH局部更新不一定否
DELETE删除是否
状态码含义前端常见处理
200 / 201成功 / 已创建正常渲染
204成功无内容不要再解析 body
301 / 302永久/临时重定向注意 POST 变 GET
304协商缓存命中用本地副本
400 / 422参数/校验错误展示字段级错误
401未认证跳登录或刷新 token
403无权限展示无权限页,不要跳登录
404不存在空状态/404 页
429限流退避重试 + 提示
500 / 502 / 504服务端/网关错误兜底 + 上报

401 与 403 的区别是面试高频题,也是产品体验的分水岭:401 是「你是谁我不知道」,403 是「我知道你是谁,但你不能看」。

1.3 必须认识的请求头

Accept: application/json              # 期望的响应类型
Content-Type: application/json        # 请求体的类型
Authorization: Bearer <token>         # 令牌
Cookie: sid=abc; theme=dark           # 自动携带
Origin: https://app.example.com       # 跨域来源(浏览器自动加)
Referer: https://app.example.com/list # 来源页面
If-None-Match: "v123"                 # 协商缓存
Accept-Encoding: gzip, br             # 支持的压缩

1.4 必须认识的响应头

Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
ETag: "v124"
Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT
Vary: Accept-Encoding
Access-Control-Allow-Origin: https://app.example.com
Set-Cookie: sid=abc; HttpOnly; Secure; SameSite=Lax
Content-Encoding: br

1.5 同源与跨域:为什么会被拦

同源 = 协议 + 域名 + 端口三者完全相同。

页面请求同源?原因
https://a.comhttps://a.com/api是全部一致
https://a.comhttp://a.com/api否协议不同
https://a.comhttps://api.a.com否域名不同
https://a.comhttps://a.com:8443否端口不同

关键认知:跨域是浏览器的限制,不是服务器的限制。请求可能已经发出、服务器也处理了,只是浏览器不给你读响应。

1.6 简单请求与预检

简单请求(不触发 OPTIONS):
  方法 ∈ {GET, HEAD, POST}
  且 Content-Type ∈ {text/plain, multipart/form-data, application/x-www-form-urlencoded}
  且 无自定义头

其余 → 先发 OPTIONS 预检,通过后才发真实请求
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

四个必记规则:

  1. Allow-Origin 为 * 时不能带 Cookie(credentials);
  2. 带 credentials 时必须回显具体来源,且响应要 Allow-Credentials: true;
  3. 非简单响应头(如 X-Total-Count)需要 Access-Control-Expose-Headers 才能被 JS 读到;
  4. Max-Age 内不重复预检,能显著减少请求数。
存储是否自动随请求发送容量能否被 JS 读适合
Cookie是(同源/符合 SameSite)约 4KBHttpOnly 时不能会话标识
localStorage否约 5MB能偏好、非敏感缓存
sessionStorage否约 5MB能单标签页会话
IndexedDB否大能结构化离线数据

安全建议:会话令牌放 HttpOnly; Secure; SameSite=Lax 的 Cookie,比放 localStorage 更抗 XSS 窃取。

1.8 HTTPS 到底做了什么

能力说明
机密性传输加密,中间人看不到内容
完整性被篡改能被发现
身份认证证书证明「你连的确实是这个站」

前端要关心的:混合内容(HTTPS 页面加载 HTTP 资源会被拦)、HSTS(强制 HTTPS)、证书过期(全站白屏)。

1.9 缓存键与 Vary:缓存的「身份」

缓存不是按 URL 存一份,而是按缓存键存多份。键算错,就会出现「A 看到 B 的内容」。

缓存键 ≈ 方法 + 规范化 URL + Vary 指定的请求头
响应头作用常见误用
Vary: Accept-Encoding区分压缩格式几乎必加
Vary: Cookie按 Cookie 区分会让缓存碎片化,通常应改为按业务 key
Vary: User-Agent区分设备取值太多,命中率骤降
Cache-Control: private只允许浏览器缓存CDN 不应缓存
s-maxage共享缓存(CDN)的 TTL与 max-age 混用导致浏览器/CDN 不一致
stale-while-revalidate先给旧内容,后台更新对强一致页面不适用

URL 规范化:?a=1&b=2 与 ?b=2&a=1、尾斜杠、大小写、utm_* 参数,都会产生不同缓存键。CDN 侧要统一排序、剔除无关参数,否则命中率会被无声地拉低。

缓存投毒:攻击者构造一个带恶意头的请求,让 CDN 缓存了错误响应,所有用户都拿到。防御要点:只按白名单头做键、不回显未校验的请求头、对 Host/X-Forwarded-* 严格校验。

前端可控的部分:

// 浏览器缓存策略:强制缓存 + 过期后协商
fetch('/api/config', { cache: 'force-cache' });   // 优先用缓存
fetch('/api/user', { cache: 'no-store' });        // 不缓存(含隐私数据)
fetch('/api/list', { cache: 'no-cache' });        // 每次都协商

判断缓存是否生效:看响应头的 Age、X-Cache: HIT/MISS、CF-Cache-Status;不要只看「第二次快了一点」。

1.10 L1 小结与自检

主题关键结论
报文起始行 + 头 + 体,问题多在头里
状态码401 与 403 语义不同,处理也不同
跨域浏览器限制;预检决定能否发真实请求
Cookie自动携带,但要配好 Secure/SameSite
HTTPS加密 + 完整性 + 身份

自检题

  1. 为什么跨域请求「后端日志里有,前端却报错」?——请求已发出并被服务端处理,但响应缺少合法的 CORS 头,浏览器拦截了读取;看 Network 的 CORS 报错与响应头即可确认。
  2. Allow-Origin: * 为什么不能配 credentials?——规范禁止通配符与凭证共存,否则任意站点都能带着用户 Cookie 读取响应;必须回显具体 Origin 并做白名单校验。
  3. 401 和 403 的前端处理为什么不能一样?——401 是未认证,应刷新 token 或跳登录;403 是已认证但无权限,应提示无权限,反复登录没有意义。
  4. 为什么 Vary: User-Agent 会降低缓存命中率?——UA 取值极多,每个细微版本都会生成新键,缓存被切得极碎;应只按设备类型等少量枚举值区分。

2. L2 进阶:缓存、协议与鉴权

2.1 HTTP 缓存的两条路径

强缓存:Cache-Control / Expires
  命中 → 不发请求,直接用本地副本(200 from disk cache)

协商缓存:ETag / Last-Modified
  未命中强缓存 → 带 If-None-Match / If-Modified-Since 询问
    → 未变:304(不传 body,省带宽)
    → 已变:200 + 新内容
# 强缓存 1 年,内容不可变(配合 hash 文件名)
Cache-Control: public, max-age=31536000, immutable

# HTML:每次都验证,保证发布即时生效
Cache-Control: no-cache

# 完全不允许缓存(含中间代理)
Cache-Control: no-store

# 过期后先用旧的,同时后台刷新
Cache-Control: max-age=60, stale-while-revalidate=600
指令含义常见误用
max-age新鲜期(秒)给 HTML 设太长导致发版不生效
no-cache可以存,但每次要验证被误解为「不缓存」
no-store完全不许存用在所有接口上,失去缓存收益
immutable新鲜期内不验证用在会变的 URL 上
private仅浏览器可缓存与 CDN 共享缓存冲突
s-maxage共享缓存(CDN)新鲜期与 max-age 混用导致行为不一致

经典组合:

index.html          → no-cache(每次都验证)
/assets/*.a1b2.js   → max-age=31536000, immutable(文件名带 hash)
/api/list           → private, max-age=30, stale-while-revalidate=60

2.2 一次带缓存的完整时序

1. 浏览器查强缓存:新鲜 → 直接用(200 from cache),结束
2. 不新鲜 → 带 If-None-Match: "v1" 发请求
3. 服务端比对:一致 → 304(无 body)
4. 浏览器用本地副本渲染
5. 不一致 → 200 + 新 ETag + 新内容

为什么 304 仍然是一次往返:它省的是带宽,不是延迟。要省延迟必须靠强缓存或 CDN 就近返回。

2.3 CDN 缓存与回源

用户 → 边缘节点(L1)
        ├─ 命中 → 直接返回(几毫秒)
        └─ 未命中 → 回源站(几十到几百毫秒)→ 缓存 → 返回
概念含义配置要点
TTL边缘缓存时长静态资源长、HTML 短
回源边缘未命中时向上游取控制回源带宽成本
预热发版前主动推送到边缘避免首批用户慢
刷新/purge主动失效发版后立即生效
缓存键用什么区分缓存注意不要把 Cookie 纳入(会击穿命中率)

最常见事故:CDN 把带用户信息的响应缓存成公共副本。防护:Cache-Control: private/no-store + 缓存键不含身份。

2.4 HTTP/1.1 的瓶颈

问题表现影响
队头阻塞前一个响应没完,后面排队并发受限
明文头部每个请求重复几百字节浪费带宽
连接数限制每域名 6 个左右资源多时排队

HTTP/1.1 时代的优化手段(现在很多已过时):雪碧图、域名分片、资源合并、内联。HTTP/2 之后这些多数变成反模式。

2.5 HTTP/2:多路复用

一条 TCP 连接
  ├─ Stream 1: /index.html
  ├─ Stream 3: /app.js
  └─ Stream 5: /api/list
(帧交错传输,互不阻塞)
特性收益注意
多路复用一个连接并发多请求TCP 层仍可能队头阻塞
头部压缩(HPACK)减少重复头动态表有状态
二进制分帧解析更高效抓包需解码
服务器推送主动推资源已被主流废弃
流优先级关键资源优先实现差异大

对构建的影响:不再需要为了减少请求而合并一切;按路由拆包反而更优(缓存粒度更细)。

2.6 HTTP/3 与 QUIC

HTTP/2 over TCP:丢一个包 → 整条连接等重传(TCP 队头阻塞)
HTTP/3 over QUIC(UDP):每个流独立重传 → 只影响丢包的那条流
维度HTTP/2HTTP/3
传输层TCP + TLS 1.2/1.3QUIC(UDP 上实现)
握手TCP + TLS 两次往返合并,1-RTT,可 0-RTT
丢包影响全连接阻塞仅单流
连接迁移IP 变则断支持(连接 ID)
部署广泛需要 UDP 放行,部分网络受限

前端能做的:确保 CDN/网关支持并开启;不要在客户端硬编码协议;用 Alt-Svc 让浏览器自动升级。

2.7 鉴权方案的取舍

方案令牌存哪优点风险
Session + CookieHttpOnly Cookie服务端可撤销、抗 XSS 窃取需要 CSRF 防护、跨域配置复杂
JWT(localStorage)JS 可读无状态、易跨服务XSS 可直接偷走;无法主动失效
JWT(Cookie)HttpOnly Cookie兼顾无状态与抗窃取仍需 CSRF 防护
OAuth2 + PKCE授权码换 token第三方授权标准流程复杂,回调安全要求高

JWT 的真相:它不是「更安全的 Session」,而是「把状态放到客户端」的权衡。无法即时吊销是它的结构性缺陷,缓解手段是短过期 + 刷新令牌 + 黑名单。

/**
 * 刷新令牌的并发控制:多个请求同时 401 时,只刷新一次。
 * 否则会出现「刷新风暴」,把旧 refresh token 用废。
 */
let refreshing = null;

async function request(url, options = {}) {
  let res = await fetch(url, { ...options, credentials: 'include' });

  if (res.status === 401) {
    refreshing ??= fetch('/api/refresh', { method: 'POST', credentials: 'include' })
      .finally(() => { refreshing = null; });
    const ok = await refreshing;
    if (ok.ok) res = await fetch(url, { ...options, credentials: 'include' });
    else location.replace('/login');
  }

  return res;
}

2.8 CORS 在工程中的三个落点

  1. 开发环境:Vite/Webpack devServer 代理,避免跨域(最省事);
  2. 生产同域:BFF 或网关统一域名,天然同源;
  3. 确实跨域:精确配置 Allow-Origin 白名单 + Allow-Credentials + 预检缓存。

不要用「关闭浏览器安全策略」解决跨域——那只是把问题留到线上。

2.9 令牌放哪里:XSS 与 CSRF 的权衡

存放位置XSS 能否读取CSRF 风险跨域成本适用
localStorage能(脚本可读)低(不会自动携带)低同源 SPA 的短期方案
内存变量刷新即失,攻击窗口小低低访问令牌 + 静默刷新
httpOnly Cookie不能高(需 SameSite/CSRF token)中(需 credentials)Web 主流的稳妥方案
BFF 会话不能低(同源)低同构/BFF 架构

核心结论:没有「零风险」的存放位置,只有把风险放到更难利用的一侧。localStorage 把风险交给 XSS;httpOnly Cookie 把风险交给 CSRF,而 CSRF 可以用 SameSite=Lax/Strict + CSRF token 防住。

推荐组合:

访问令牌:内存(短期,5–15 分钟)
刷新令牌:httpOnly + Secure + SameSite=Lax Cookie
刷新接口:校验 Origin/Referer + CSRF token

并发刷新是工程上最容易出 bug 的地方:多个请求同时 401,会触发多次刷新,导致令牌互相失效。

/**
 * 单飞刷新:同一时刻只允许一个刷新请求,其余请求排队等待。
 * @param {() => Promise<string>} refresh 刷新函数,返回新 token
 */
function createTokenManager(refresh) {
  let pending = null;

  return {
    async get() {
      if (pending) return pending;
      pending = refresh().finally(() => { pending = null; });
      return pending;
    },
  };
}

四个常见坑:

坑后果修复
刷新令牌也放 localStorageXSS 一次即长期失守刷新令牌放 httpOnly Cookie
401 时无限重试请求风暴、账号被锁刷新失败即登出,限制重试次数
刷新期间旧请求继续发令牌错乱、部分失败请求排队,刷新后统一重放
登出只清本地令牌仍可用服务端吊销 + 刷新令牌轮换

2.10 L2 小结与自检

主题关键结论
强缓存不请求;immutable + hash 文件名
协商缓存304 省带宽不省往返
CDN命中率取决于 TTL 与缓存键
HTTP/2多路复用,合并资源不再是默认优化
HTTP/3QUIC 解决 TCP 队头阻塞
鉴权Cookie 抗 XSS,JWT 无状态但难吊销

自检题

  1. no-cache 和 no-store 的区别?各自适合什么资源?——no-cache 可以缓存但每次使用前必须校验,适合 HTML;no-store 完全不落地,适合敏感接口与个人数据。
  2. 为什么 HTTP/2 下「雪碧图/合并 JS」可能反而更差?——多路复用让请求数不再是大瓶颈,合并会降低缓存粒度、让首屏加载无用字节,还会阻塞关键资源。
  3. JWT 无法即时失效,业务上怎么缓解?——缩短有效期加 refresh token,服务端维护黑名单或用户版本号,权限变更用短 TTL 或推送失效。
  4. 为什么「访问令牌放内存、刷新令牌放 httpOnly Cookie」更稳?——访问令牌短期且脚本可读窗口小;刷新令牌不可被 XSS 读取,配合 SameSite 与 CSRF token 抵御 CSRF,兼顾两类攻击。

3. L3 高级:握手、实时通信与链路优化

3.1 TLS 1.3 握手为什么快

TLS 1.2:TCP 三次握手 → TLS 两次往返 → 才发 HTTP(约 3 RTT)
TLS 1.3:TCP 三次握手 → TLS 一次往返 → 发 HTTP(约 2 RTT)
0-RTT:复用上次会话参数,首个请求即可带数据(但有重放风险)

0-RTT 的风险:早期数据可能被重放,只能用于幂等请求(GET),不能用于支付、下单。

3.2 连接复用与「排队」

浏览器对同一域名:
  HTTP/1.1 → 约 6 条连接,超出排队
  HTTP/2   → 1 条连接多路复用,但受 TCP 窗口与优先级影响

判断方法:Network 瀑布图里,如果多个请求的 Queueing 很长、Connection Start 有 Stalled,就是连接不足或代理限制。

3.3 资源优先级与 Early Hints

HTTP/1.1 103 Early Hints
Link: </app.css>; rel=preload; as=style
Link: </hero.webp>; rel=preload; as=image

服务端在最终响应前先告诉浏览器「接下来要这些资源」,浏览器可以提前下载。对「服务端渲染慢」的站点收益明显。

3.4 WebSocket / SSE / WebTransport

方案方向协议适合限制
轮询双向(伪)HTTP低频、简单延迟与浪费
长轮询双向(伪)HTTP兼容性要求高连接占用
SSE服务端 → 客户端HTTP通知、日志、AI 流式输出单向、HTTP/1.1 下有连接数限制
WebSocket双向WS聊天、协作、游戏需要心跳、重连、代理支持
WebTransport双向HTTP/3低延迟、多流生态与兼容性尚早
/**
 * SSE:最适合「服务端持续推送、客户端不常发」的场景。
 * 相比 WebSocket,它自带断线重连与事件 id,实现成本低。
 */
const es = new EventSource('/api/notifications');
es.onmessage = (e) => append(JSON.parse(e.data));
es.onerror = () => {
  // 浏览器会自动重连;如需退避,可关闭后手动重建
};
/**
 * WebSocket:必须自己做心跳与重连。
 */
function connect(url) {
  const ws = new WebSocket(url);
  let timer;

  ws.onopen = () => {
    timer = setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), 30_000);
  };
  ws.onclose = () => {
    clearInterval(timer);
    setTimeout(() => connect(url), Math.min(30_000, retry * 2)); // 指数退避
  };
  return ws;
}

3.5 压缩与传输优化

内容推荐说明
文本(JS/CSS/HTML/JSON)Brotli(br)比 gzip 再小 15%–20%
图片WebP/AVIF按浏览器能力降级
视频H.264/AV1 + Range分片与码率自适应
已压缩格式(png/zip/mp4)不再压缩浪费 CPU
Accept-Encoding: gzip, deflate, br
Content-Encoding: br
Vary: Accept-Encoding

Vary 缺失的后果:CDN 可能把 br 版本返回给不支持 br 的客户端,导致乱码。

3.6 断点续传与 Range

GET /video.mp4
Range: bytes=0-1048575

HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1048575/10485760
Accept-Ranges: bytes

用于大文件下载、视频拖动、失败重试只补缺失部分。

3.7 DNS 与连接预热

<!-- 提前解析域名(不建立连接) -->
<link rel="dns-prefetch" href="//api.example.com" />
<!-- 提前建连 + TLS(省握手) -->
<link rel="preconnect" href="https://api.example.com" crossorigin />

成本:preconnect 会占用连接资源,只对「确定马上要用」的域名使用,控制在 2–4 个。

3.8 代理与网关带来的「隐形延迟」

环节常见问题观察点
Nginxproxy_buffering 缓冲流式响应SSE 不实时
网关默认超时 30s,长请求被断504
负载均衡会话未粘滞,登录态漂移偶发 401
服务网格重试放大流量雪崩

SSE/流式场景必配:

proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;

3.9 网络排查:把一次请求拆成时间片

DevTools 的 Timing 面板把请求拆成若干段,每段对应不同的责任方:

阶段含义高值通常说明
Queueing排队等待连接数打满、优先级低
Stalled等待可用连接同域请求过多、HTTP/1.1 限制
DNS Lookup域名解析DNS 慢、无预解析
Initial connectionTCP 握手跨地域、丢包
SSLTLS 握手证书链长、未启用 TLS 1.3
Request sent发送请求通常很小
Waiting (TTFB)等首字节服务端处理慢、回源慢
Content Download下载响应体积大、带宽低、未压缩
/**
 * 用 Resource Timing 采集分段耗时,定位慢在「连接」还是「服务端」。
 * @param {string} url 资源地址
 */
function getTiming(url) {
  const entry = performance.getEntriesByName(url).at(-1);
  if (!entry) return null;

  return {
    dns: entry.domainLookupEnd - entry.domainLookupStart,
    tcp: entry.connectEnd - entry.connectStart,
    tls: entry.secureConnectionStart
      ? entry.connectEnd - entry.secureConnectionStart
      : 0,
    ttfb: entry.responseStart - entry.requestStart,
    download: entry.responseEnd - entry.responseStart,
    total: entry.duration,
  };
}

四类典型现象与判断:

现象优先怀疑验证方式
大量请求 StalledHTTP/1.1 连接上限、同域资源过多切 HTTP/2 或分域,观察是否消失
TTFB 高但下载快服务端/网关/回源看服务端耗时与 CDN X-Cache
TTFB 正常但下载慢体积、带宽、压缩看 Content-Encoding 与 gzip 后大小
首包快、后续慢流式输出被缓冲检查代理缓冲与 Transfer-Encoding

工具:DevTools Network(单请求)、Performance(整体瀑布)、HAR(可离线分析)、chrome://net-export(连接层)、WebPageTest(多地域多网络)。

一个常被忽略的点:测试环境的「本机 → 同城服务器」几乎无法复现真实网络问题;必须用限速、跨地域节点或真实用户监控(RUM)。

3.10 L3 小结与自检

主题关键结论
TLS 1.31-RTT,0-RTT 只用于幂等请求
连接HTTP/2 单连接多路复用,仍受 TCP 影响
推送Early Hints 可提前发现资源
实时通知用 SSE,双向用 WebSocket
压缩文本用 br,已压缩格式别再压
网关流式响应必须关缓冲

自检题

  1. 为什么 SSE 在 HTTP/1.1 下容易「卡住」?怎么解决?——同域 6 个连接上限被长连接占满,其他请求只能排队;改用 HTTP/2 多路复用或独立域名,并加心跳防止代理断开。
  2. TLS 0-RTT 为什么不能用于下单请求?——0-RTT 数据可被重放,非幂等操作会重复执行;只应用于幂等的 GET 类请求。
  3. 视频拖动进度条用到了哪个 HTTP 能力?——Range 请求头加 206 Partial Content,服务端声明 Accept-Ranges: bytes。
  4. 请求 Stalled 很久,通常是前端还是服务端问题?——多半是浏览器连接调度:同域连接被占满、优先级低或 HTTP/1.1 并发限制;应查请求瀑布与协议版本,而不是先查后端。

4. L4 专家:缓存体系、容灾与成本

4.1 一套可解释的缓存体系

┌──────────────┐  未命中   ┌──────────────┐  未命中   ┌──────────────┐
│ 浏览器缓存    │ ───────▶ │ CDN 边缘缓存  │ ───────▶ │ 源站/服务端   │
│ 强缓存+协商   │          │ TTL + purge  │          │ 数据缓存/DB  │
└──────────────┘          └──────────────┘          └──────────────┘
层缓存对象失效策略关键指标
浏览器静态资源、HTMLhash、no-cache命中率
CDNHTML、图片、JSTTL、purge、预热回源率
服务端接口结果key 版本、TTL、主动失效缓存命中率
数据库查询结果事务、TTL慢查询比例

设计原则:

  1. 按变化频率分层:越稳定的越往前放;
  2. 按个性化隔离:私有数据不进共享层;
  3. 每一层都要能失效:没有失效手段的缓存是定时炸弹;
  4. 缓存键要完整:语言、设备、灰度分组都要进 key,否则会串内容。

4.2 多地域与容灾

策略做法代价
多活多地同时提供服务,DNS/Anycast 调度数据一致性复杂
主备主机房故障切备切换时长、数据回追
边缘计算逻辑下沉到边缘状态与调试复杂
降级接口失败返回兜底数据需要产品接受

前端能做的容灾:

/**
 * 接口兜底:失败时用缓存或默认值,避免整页白屏。
 */
async function loadWithFallback(key, fetcher, fallback) {
  try {
    const data = await fetcher();
    localStorage.setItem(key, JSON.stringify(data));
    return data;
  } catch {
    const cached = localStorage.getItem(key);
    return cached ? JSON.parse(cached) : fallback;
  }
}

4.3 安全相关的协议配置

配置作用建议
HSTS强制 HTTPSmax-age=31536000; includeSubDomains
SameSite限制跨站携带 Cookie默认 Lax,敏感操作 Strict
HttpOnly禁止 JS 读取会话 Cookie 必加
Secure仅 HTTPS 发送必加
CORS 白名单限制来源禁止反射任意 Origin
预检缓存减少 OPTIONSMax-Age 合理设置

第三方 Cookie 逐步退场:依赖三方 Cookie 的埋点、SSO、广告会失效,需要迁移到第一方域名或服务端转发。

4.4 度量:怎么证明网络优化有效

指标定义目标参考
TTFB首字节< 800ms(P75)
DNS/连接耗时建连阶段< 100ms(有预热)
缓存命中率CDN/浏览器> 80%
回源率CDN 回源比例< 20%
错误率5xx/网络失败< 0.1%
传输体积单页总字节按业务定预算

方法:RUM 采集真实用户数据 → 按地域/运营商/页面拆分 → 找到长尾 → 针对性优化(预热、就近接入、协议升级)。

4.5 成本视角

成本影响因素优化手段
CDN 流量体积 × 请求数压缩、图片格式、缓存命中
回源带宽回源率提高 TTL、预热、合并请求
服务端QPS × 耗时缓存、聚合、限流
跨地域传输距离与数据量边缘聚合、字段裁剪

判断标准:每 1% 的缓存命中率提升,能省多少回源成本?没有这个数字,优化就没有优先级。

4.6 L4 自检

  1. 一个全球访问的产品,如何设计 DNS、CDN、源站的分层?——GeoDNS/Anycast 就近接入,CDN 边缘缓存静态与可缓存 HTML,区域源站多活;动态请求走最优回源与连接复用,并准备容灾切换。
  2. 缓存命中率从 70% 提到 90%,怎么证明收益?——换算回源请求数与带宽、源站成本、TTFB 与 p75 的变化;用同期对照与分地域数据,给出成本节省与体验收益。
  3. 三方 Cookie 退场后,现有 SSO 与埋点怎么迁移?——SSO 改第一方 Cookie 加重定向,或使用 CHIPS/Storage Access;埋点转第一方域或服务端上报,建立统一 ID 并评估覆盖率损失。

5. 综合实战:接口 P99 800ms 的排查与治理

背景:列表接口 P75 120ms,P99 800ms,用户偶发「转圈很久」。

步骤 1 · 分段测量

DNS 0ms(已缓存)
连接 0ms(复用)
TLS 0ms
TTFB 780ms   ← 瓶颈在这里
下载 20ms

结论:不是前端、不是传输,是服务端处理或排队。

步骤 2 · 定位服务端

假设验证结果
慢查询看 DB 慢日志命中,某查询在数据量大时全表扫
连接池耗尽看等待队列高峰期排队
缓存击穿看缓存命中率大 key 过期瞬间回源
网关超时重试看上游 QPS 翻倍存在重试放大

步骤 3 · 治理

/**
 * 前端侧能做的三件事:
 * 1) 请求去重(同一 key 并发只发一次)
 * 2) 缓存兜底(stale-while-revalidate 思路)
 * 3) 超时与退避(避免用户无限等待)
 */
const inflight = new Map();

function getJSON(url, { timeout = 8000 } = {}) {
  if (inflight.has(url)) return inflight.get(url);

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);

  const p = fetch(url, { signal: controller.signal })
    .then((r) => {
      if (!r.ok) throw new Error(String(r.status));
      return r.json();
    })
    .finally(() => {
      clearTimeout(timer);
      inflight.delete(url);
    });

  inflight.set(url, p);
  return p;
}

服务端侧:加索引、缓存热点、连接池调优、限制重试次数。

步骤 4 · 验收

  • P99 < 300ms
  • 缓存命中率 > 85%
  • 超时请求有明确兜底 UI
  • 重试不会造成流量放大(有上限与退避)
  • 关键接口有 SLO 与告警

6. 常见误区对照表

误区事实
「跨域是后端没配好」是浏览器策略;后端配的是「允许谁读」
「no-cache 是不缓存」是「缓存但每次验证」;不缓存是 no-store
「304 很快」省带宽不省往返,仍要看 TTFB
「HTTP/2 就不用拆包了」拆包仍利于缓存粒度与按需加载
「JWT 比 Session 安全」各有风险;JWT 难吊销,Cookie 需防 CSRF
「CDN 缓存越久越好」发布不生效、个性化串号都是这么来的
「preconnect 越多越好」占连接与 CPU,2–4 个足够
「压缩一定更快」已压缩格式再压是浪费 CPU
「WebSocket 适合所有实时场景」单向推送用 SSE 更简单可靠
「0-RTT 全面提速」有重放风险,只能用于幂等请求

7. 面试高频题(附答题思路)

Q1:从输入 URL 到收到响应,网络层发生了什么? DNS → TCP → TLS → HTTP 请求 → 服务端 → 响应 → 渲染。每段说一个可优化的点(缓存、preconnect、HTTP/2、CDN)。

Q2:强缓存与协商缓存的区别? 是否发请求;Cache-Control/Expires vs ETag/Last-Modified;304 的收益与局限;给资源类型配比。

Q3:CORS 预检什么时候触发?怎么减少? 非简单请求(方法/头/类型);Max-Age 缓存预检;同域或 BFF 规避。

Q4:HTTP/1.1、2、3 的差异? 队头阻塞层次不同;多路复用;QUIC 与 0-RTT;对构建策略的影响。

Q5:Cookie、localStorage、JWT 怎么选? 按威胁模型:XSS vs CSRF;会话撤销需求;跨域场景;给组合方案。

Q6:如何设计一套缓存策略? 按资源变化频率分层;hash 文件名 + 长缓存;HTML 短缓存;私有数据隔离;失效与预热;度量命中率。

Q7:SSE 与 WebSocket 怎么选? 方向、协议开销、重连、代理兼容;AI 流式输出首选 SSE。

Q8:接口慢怎么排查? Network 分段 → 区分 DNS/连接/TTFB/下载 → 服务端指标 → 缓存与重试放大 → 前端去重与兜底。


8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
协议演进是否理解「队头阻塞」在不同层的含义区分应用层与传输层 HOL「HTTP/2 解决了所有问题」
缓存能否解释一次命中/未命中的完整判定说出缓存键、Vary、失效顺序「加 max-age 就行」
跨域是否知道请求发没发出去能按预检、凭证、响应头顺序排错「后端加 * 就行」
鉴权存储位置与攻击面的关系说清 XSS 与 CSRF 的不同防线「token 放 localStorage 方便」
排错会不会读时序用 Timing 面板与 Server-Timing 定位只会看瀑布图截图

8.2 追问链

链 1 · HTTP/1.1 → HTTP/2 → HTTP/3

  • 面试官:HTTP/1.1 的队头阻塞是什么?
    • 要点:同一连接上请求串行,前一个响应不完整会阻塞后续响应;浏览器用 6 个并发连接缓解,但带来连接开销与优先级竞争。
  • 追问:HTTP/2 的多路复用解决了什么,又留下了什么?
    • 要点:用二进制分帧把请求拆成 stream,在同一 TCP 连接上并发交错传输,解决应用层 HOL;但 TCP 层仍要求按序交付,丢一个包会阻塞该连接上所有 stream,这就是 TCP 层 HOL。
  • 再追问:QUIC 怎么解决,代价是什么?
    • 要点:基于 UDP 在用户态实现可靠传输,stream 独立重传,丢包只影响所在 stream;0-RTT 可复用会话,但 0-RTT 数据有重放风险,只能用于幂等请求。

链 2 · 强缓存 → 协商缓存 → 缓存键

  • 面试官:浏览器什么时候直接用缓存?
    • 要点:先看 Cache-Control(max-age、s-maxage、no-store、no-cache、must-revalidate),新鲜则直接用;no-cache 不是不缓存,而是每次必须校验;过期后带 If-None-Match / If-Modified-Since 走协商,命中返回 304。
  • 追问:ETag 和 Last-Modified 谁的优先级高?
    • 要点:ETag 更精确(内容哈希或版本),服务端应优先比对;Last-Modified 精度只到秒,且文件时间可能被部署工具改动。
  • 再追问:CDN 上为什么会出现「同 URL 不同内容」?
    • 要点:缓存键没包含 Vary 指定的头(如 Accept-Encoding、Accept-Language),或未按租户/设备分键;也可能是回源时命中不同源站。排查要看响应头 Age、X-Cache、Vary。

链 3 · 简单请求 → 预检 → 凭证

  • 面试官:什么情况会触发预检?
    • 要点:方法不是 GET/HEAD/POST,或 Content-Type 不是三者之一,或带有自定义请求头;预检是 OPTIONS,需要服务端返回 Access-Control-Allow-* 且包含实际请求的方法与头。
  • 追问:预检结果会缓存吗?
    • 要点:会,由 Access-Control-Max-Age 控制(各浏览器有上限,Chrome 约 2 小时);缓存键包含 origin、方法、头,任一变化都会重新预检。
  • 再追问:带 Cookie 的跨域要注意什么?
    • 要点:前端 credentials: 'include',服务端 Access-Control-Allow-Credentials: true 且 Allow-Origin 不能是 *;Cookie 需 SameSite=None; Secure。任一处缺失,表现都是「请求发了但没有 Cookie」或「响应被拦」。

链 4 · CDN 缓存 → 回源 → 一致性

  • 面试官:发布后用户仍拿到旧页面,怎么排查?
    • 要点:先看 HTML 的缓存策略(是否 no-cache/短 TTL),再看 CDN 是否执行了 purge、边缘节点是否全部生效;最后确认浏览器缓存与服务端模板版本。
  • 追问:为什么静态资源可以长缓存,HTML 不行?
    • 要点:静态资源带内容 hash,内容变则 URL 变,可设一年;HTML 是入口,必须尽快指向新 hash,通常用短 TTL + 协商或主动刷新。
  • 再追问:stale-while-revalidate 会不会让用户看到旧数据?
    • 要点:会,但只在一个短窗口内,且后台会更新;对价格、库存等强一致数据要禁用或配合版本号。

链 5 · 鉴权 → 并发刷新 → 失败处理

  • 面试官:多个请求同时 401,怎么保证只刷新一次?
    • 要点:用单飞(single-flight)锁:第一个请求发起刷新,其余等待同一个 Promise;刷新成功后统一重放,失败则统一登出。
  • 追问:刷新令牌轮换后,旧令牌还能用吗?
    • 要点:应失效并检测重放;服务端给每个刷新令牌家族记录版本,发现旧令牌被使用则吊销整个家族。
  • 再追问:重放请求要注意什么?
    • 要点:只重放幂等请求;非幂等请求要由业务层确认是否已执行,避免重复下单;设置最大重试次数与超时。

8.3 手写题

题 1 · 带超时、重试与取消的请求封装

/**
 * 请求封装:超时、指数退避重试、外部取消。
 * @param {string} url
 * @param {RequestInit & {timeout?: number, retries?: number, signal?: AbortSignal}} [options]
 * @returns {Promise<Response>}
 */
async function request(url, { timeout = 8000, retries = 2, signal, ...init } = {}) {
  /** 单次请求:超时与外部取消合并 */
  const once = () => {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(new DOMException('timeout', 'TimeoutError')), timeout);
    const onAbort = () => controller.abort(signal.reason);
    signal?.addEventListener('abort', onAbort, { once: true });

    return fetch(url, { ...init, signal: controller.signal }).finally(() => {
      clearTimeout(timer);
      signal?.removeEventListener('abort', onAbort);
    });
  };

  for (let attempt = 0; ; attempt += 1) {
    try {
      const res = await once();
      // 5xx 可重试,4xx 不重试
      if (res.status >= 500 && attempt < retries) throw new Error(`HTTP ${res.status}`);
      return res;
    } catch (err) {
      const aborted = signal?.aborted || err.name === 'AbortError';
      if (aborted || attempt >= retries) throw err;
      // 指数退避 + 抖动,避免重试风暴
      const delay = 200 * 2 ** attempt + Math.random() * 100;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

追问:

  • 为什么超时要单独建 AbortController?→ 外部取消与超时是两种语义,合并后无法区分;也可用 AbortSignal.any。
  • 什么请求不能重试?→ 非幂等的 POST(下单、支付),除非有幂等键;重试前应判断方法与业务语义。
  • 重试抖动有什么用?→ 避免大量客户端在同一时刻重试造成雪崩。

题 2 · SSE 客户端

/**
 * SSE 客户端:解析 event/data/id,支持断线重连与 Last-Event-ID。
 * @param {string} url
 * @param {{onMessage: (data: string, event: string) => void, retry?: number}} handlers
 */
function createSSE(url, { onMessage, retry = 3000 }) {
  let controller = null;
  let lastId = '';
  let stopped = false;

  const connect = async () => {
    controller = new AbortController();
    const res = await fetch(url, {
      headers: lastId ? { 'Last-Event-ID': lastId } : {},
      signal: controller.signal,
    });
    if (!res.ok || !res.body) throw new Error(`SSE ${res.status}`);

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      // 事件以空行分隔,\r\n 与 \n 都要兼容
      const blocks = buffer.split(/\r?\n\r?\n/);
      buffer = blocks.pop() ?? '';

      for (const block of blocks) {
        let event = 'message';
        const data = [];
        block.split(/\r?\n/).forEach((line) => {
          if (line.startsWith('event:')) event = line.slice(6).trim();
          else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
          else if (line.startsWith('id:')) lastId = line.slice(3).trim();
        });
        if (data.length) onMessage(data.join('\n'), event);
      }
    }
  };

  const start = async () => {
    stopped = false;
    while (!stopped) {
      try {
        await connect();
      } catch (err) {
        if (stopped) return;
      }
      await new Promise((resolve) => setTimeout(resolve, retry));
    }
  };

  return {
    start,
    close() {
      stopped = true;
      controller?.abort();
    },
  };
}

追问:

  • 为什么要自己解析而不用 EventSource?→ 需要自定义请求头(鉴权)、POST、或读取非标准字段;代价是要自己处理重连与分块边界。
  • 一个中文被拆在两个 chunk 里怎么办?→ TextDecoder 用 { stream: true } 保留半个字符的字节。
  • 断线重连怎么避免重复消息?→ 服务端按 Last-Event-ID 续传,客户端按 id 去重。

8.4 场景设计题

题:一个跨域的后台系统要改造登录态,同时支持 Web 与 App 内嵌页,还要传大文件。你怎么设计?

评估点:是否按端与攻击面分方案、是否考虑大文件与断点。

参考方案:

  1. Web 端:Cookie + HttpOnly; Secure; SameSite=Lax(跨站场景用 None; Secure),配合 CSRF token(双提交或同步令牌);BFF 统一注入,前端不碰凭证。
  2. App 内嵌:由容器注入短期 token,走 Authorization 头;token 存内存 + 安全存储,不写 localStorage;刷新用静默续期。
  3. 跨域:精确配置 Allow-Origin 白名单与 Allow-Credentials;预检缓存设 Max-Age;错误统一走 401 刷新、403 提示。
  4. 大文件:分片(如 5MB)+ 并发 3 路 + 断点续传(记录已完成分片);秒传用文件哈希;上传走独立域名避免占用主域连接。
  5. 验证:401 比例、续期成功率、上传成功率与平均耗时、跨域错误率。

加分:指出 token 放 localStorage 会被 XSS 直接读取,Cookie 则要防 CSRF,两者是不同防线的取舍;指出分片大小要按网络自适应。

减分:用 * 配 Allow-Credentials;只答「加 token」不谈存储与刷新;大文件直传不做分片。

题 2:核心列表接口 P99 800ms、P50 120ms,CDN 命中率只有 20%,前端已经做了骨架屏但用户仍抱怨慢。你怎么定位并治理?

评估点:是否区分「单次慢」与「长尾慢」、是否从链路分段与缓存两层下手。

参考方案:

  1. 拆口径:先看分布:P50 正常说明多数请求没问题,P99 高说明是长尾(回源、跨地域、慢查询、重试)。按地域、运营商、版本、是否登录分桶。
  2. 分段:用 Resource Timing / 服务端 trace 拆 DNS、连接、TTFB、下载;TTFB 高则查服务端与回源,下载慢则查体积与压缩。
  3. 缓存:检查缓存键是否包含随机参数、Vary 是否过宽、CDN 是否按用户维度分键导致命中率低;把公共部分做成可缓存接口,个性化字段单独请求。
  4. 前端体验:骨架屏只能改善感知,不能减少等待;对首屏必需数据做并行请求与预取,对非关键数据延后加载。
  5. 验收:P99 降到 300ms 内、命中率提升到 70%+、错误率与超时率下降;用灰度对比确认不是流量结构变化带来的假象。

加分:指出「P50 好、P99 差」通常是长尾基础设施问题(跨地域、连接复用、慢查询),不是前端渲染问题;指出缓存命中率要按 URL 规范化后统计。

减分:只加骨架屏;只扩容不考虑缓存键;用平均值汇报。

8.5 最容易答错的点

常见说法纠正
「HTTP/2 没有队头阻塞」应用层没有,TCP 丢包仍会阻塞整条连接
「HTTP/3 一定比 HTTP/2 快」弱网收益明显,内网/低丢包环境差异很小
「no-cache 是不缓存」是「每次使用前必须校验」,no-store 才是不存
「304 没有响应体所以不耗流量」省的是 body,请求与响应头仍要往返
「CORS 是服务端拦截请求」是浏览器拦截响应读取,请求可能已到达服务端
「SameSite=Lax 能防所有 CSRF」只限制跨站携带,站内 XSS 与同站子域仍可发起
「localStorage 存 token 更安全」任何 XSS 都能读取,HttpOnly Cookie 才隔离 JS
「加 Cache-Control: max-age 就一定有缓存」还受 Vary、缓存键、CDN 配置与刷新策略影响
「P50 正常就说明接口没问题」长尾用户可能极慢,必须看 P95/P99 与分桶
「骨架屏能解决慢的问题」它只改善感知,等待时间与失败率不变
「CDN 命中率低就是 CDN 不行」多半是缓存键含随机参数、Vary 过宽或个性化分键
「token 刷新失败就重试」会触发请求风暴,应限制次数并统一登出

9. 延伸资源

  • MDN:HTTP 缓存、CORS、Cookie 属性、HTTP/2、HTTP/3
  • RFC 9111(HTTP 缓存)、RFC 9110(HTTP 语义)、RFC 9000(QUIC)
  • web.dev:Cache-Control、preconnect、Early Hints、HTTP/3
  • High Performance Browser Networking(O'Reilly,免费在线版)
  • Cloudflare / Fastly 博客:CDN 缓存键与回源优化
  • OWASP:Session Management、CSRF、CORS 配置指南
  • OAuth 2.0 Security Best Current Practice(含 PKCE)
  • Chrome 团队:第三方 Cookie 退场与 Privacy Sandbox 文档