从「框架内部报错」到「请求头被网关截断」:一次 Sentry 排障与前端 Cookie 误用复盘
线上 Sentry 突然冒出一类报错,栈顶全是框架内部代码,乍看像是框架本身的 bug。顺着排查下去,根因却落在一个最不起眼的地方——请求头被网关截断;而把请求头撑爆的,是前端往 Cookie 里塞了本不该放的大业务数据。
这篇文章把这次排查做一次通用化复盘:怎么用 Sentry 定位这类「伪框架错误」、网关为什么会截断 header、前端哪些误用会撑大请求头、四种本地存储该怎么选,以及清理历史脏 Cookie 时一个很容易踩的 js-cookie 坑。
一个看起来像框架 bug 的服务端报错
线上一个 SSR 应用(Next.js App Router)报出一条错误:
Error: The router state header was sent but could not be parsed.
栈顶全在框架编译产物里(next-server runtime),没有一行业务代码。第一反应很容易是「框架 bug / 版本问题」。但先别急着甩锅给框架,Sentry 上几个字段能快速澄清这条错误的「身份」:
-
platform = node、os.name = <某 Linux 发行版>、server_name = <某容器实例>→ 这是服务端抛的,不是用户浏览器。 -
mechanism = 框架自动捕获、handled = no→ 框架在请求处理链路里自己抛出并上报的,不是业务 try/catch。 -
Users Impacted = 0→ 服务端错误没有用户级上下文(不代表没影响用户,只是这条记录里没绑定用户)。
看到「栈顶在框架 runtime + 服务端 + 自动上报」,先把它当成请求处理链路的问题,而不是某个业务分支的 bug。
用 Sentry 把范围收敛:三个聚合维度
要判断「是不是某次发版引入的」,最快的办法是按维度聚合事件,而不是一条条看 stack:
- 按 release 聚合:错误集中在某一两个版本 → 大概率版本相关(回归 / 新老版本错配);如果横跨所有版本同时发生 → 多半是环境 / 基础设施层面的系统性问题,与某次发版无关。
- 按 url / 路由聚合:集中在特定路由,还是全站铺开?全站铺开 + 服务端,更像基础设施而非某个页面的代码。
- 按 environment / browser 聚合:确认影响面与人群。
这次的数据是:跨所有 release、覆盖全部路由、全部发生在服务端。三个维度叠加,结论很清楚——不是某次发版的锅,问题在请求到达应用之前的链路上,也就是网关。
经验法则:栈顶在框架 runtime、跨版本、服务端、全站——优先怀疑请求链路(网关 / CDN / 反向代理),而不是业务代码。
网关为什么会截断 header
HTTP 请求头不是无限大的。几乎所有反向代理 / 网关 / WAF 都对 header 大小有上限。以 nginx 为例:
-
large_client_header_buffers、client_header_buffer_size控制单个请求头行与缓冲区大小,常见默认是 8KB 量级。 - 超过上限时,典型行为有两种:直接拒绝(返回
431 Request Header Fields Too Large或400 Bad Request),或在某些链路上截断后继续转发。 - 一旦某个长 header 被截断成残缺值,后端再去解析它就会失败——于是你在应用层看到的是「解析失败」,但破坏其实发生在更早的网关层。
SSR 框架对此尤其敏感:现代框架在软导航 / 预取时会带上较长的内部请求头。比如 Next.js 的 Next-Router-State-Tree 编码了当前路由树,层级越深这个头越长。它本身加上其它请求头叠加在一起,一旦总量越过网关上限,后端拿到的就是残缺头 → 解析报错。
flowchart LR
A[浏览器 携带超大 Cookie] --> B[网关 检查 header 大小]
B --> C[超过上限 截断或返回 4xx]
C --> D[SSR 读取路由状态头失败]
D --> E[Sentry 服务端报错]
关键点:应用层的「解析失败」往往只是表象,真正的破坏在网关层;而把总 header 撑到超限的,常常是前端。
前端的隐形元凶:什么在悄悄撑大请求头
最容易被忽视的,是 Cookie:
- Cookie 会随每个同域请求自动塞进请求头——你不显式发送,它也跟着走。
- 一旦往 Cookie 里写大业务数据(聊天的首条消息、表单草稿 JSON、缓存的接口结果……),或者按会话不断累积一批 Cookie,请求头就会持续变大,直到某天突破网关上限。
- 其它来源:fetch / axios 拦截器里无脑追加的自定义头、超大的
Authorization、把状态塞进 URL 又被某层网关写进头,等等。但 Cookie 是最隐蔽的,因为它是自动发送的,写的时候你根本不会联想到「请求头」。
一句话概括这类误用:把本不需要服务端读取的大数据放进了 Cookie。它带来的「自动随请求发送」对这些数据是纯粹的负担。
本地存储四选一:关键看「会不会进请求头」
很多人选本地存储时只看「容量」和「是否持久」,但对这个问题,最关键的维度是会不会自动进请求头——只有 Cookie 会。
| 方案 | 容量(量级) | 随请求自动发送 | 读写方式 | 作用域 | 生命周期 | 服务端可读 | 适用场景 |
|---|---|---|---|---|---|---|---|
| Cookie | ~4KB / 条 | ✅ 每个同域请求 | 同步 | domain + path,可跨子域 | 可设过期时间 | ✅ | 需服务端在请求时读取的小标识(登录态 / 语言 / 灰度 / AB) |
| localStorage | ~5–10MB | ❌ | 同步(阻塞主线程) | 同源,跨标签页共享 | 持久,需手动清 | ❌ | 较大的客户端持久数据(非敏感、不需服务端) |
| sessionStorage | ~5–10MB | ❌ | 同步 | 同源,单标签页隔离 | 标签页关闭即清 | ❌ | 单标签页临时态(表单草稿 / 向导步骤) |
| IndexedDB | 数百 MB ~ GB(按磁盘配额) | ❌ | 异步(事务 / Promise) | 同源,跨标签页共享 | 持久,需手动清 | ❌ | 大量 / 结构化 / 二进制数据(消息缓存 / 离线数据 / 文件) |
选型可以简化成几条:
- 需要服务端在请求时读到的小标识(登录态、语言、灰度、AB 分流)→ Cookie,并严格控制大小。
- 客户端用、量较大、不需服务端 → localStorage 或 IndexedDB。
- 只在单个标签页临时用、关掉就该消失 → sessionStorage。
- 大量 / 结构化 / 二进制 / 需要按 key 查询 → IndexedDB。
反过来记一条红线:凡是不需要服务端读取的数据,都不要放 Cookie,大数据更是绝对禁止。Cookie「自动随每个请求发送」的特性,对这类数据只有坏处。
治理:把大值移出 header,并清理历史脏 Cookie
迁移本身不难——把大值从 Cookie 改存到 IndexedDB 就行。但有一个常被漏掉的收尾动作:只改了「新写入」,没清「旧残留」。
用户浏览器里早先写入的大 Cookie 不会自己消失,它会继续随每个请求发送,问题照旧。尤其当一段流量已经切到新代码、另一段还在老代码时,老用户携带的历史脏 Cookie 会一直把请求头顶在高位。
清理历史脏 Cookie 的实践:
- 时机:应用启动早期(客户端),一次性扫描并删除已废弃前缀的 Cookie。
- 幂等、无副作用:这些 Cookie 已经没有任何读取方,删除是安全的。
- 写法:JS 没有「删除 Cookie」的 API,只能用「空值 + 过去的过期时间」覆盖,告诉浏览器立即删掉它。
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
-
domain / path 必须与写入时一致才能命中删除。来源未知时,可对几种常见组合(host-only、主域
.example.com)都写一遍覆盖删除。不用担心污染或异常:已过期的Set-Cookie浏览器不会真正存储,所以不匹配的尝试只是无操作;document.cookie的 setter 对非法输入是静默忽略(不像localStorage会抛异常)。 -
局限与兜底:客户端清理对每个用户的首个请求无效(Cookie 已经在入站请求里了),之后才会变干净。要在首个响应就清,可以在服务端用响应的
Set-Cookie把它置为过期,或在中间件里处理。网关 header 上限可以适当调高作为兜底,但那是止血而非根因修复——根因永远是「谁把大值放进了 header」。
一个容易踩的坑:js-cookie 可能「删不掉」
项目里通常用 js-cookie 这类库管理 Cookie。但删除特殊名字的 Cookie 时,它可能静默失败。
js-cookie 在写入 / 删除时会对 Cookie 名做 URL 编码(encodeURIComponent 再做一次有限的反转义白名单)。如果名字里含 @、:、(、)、, 这类字符,就会被编码:
Cookies.remove('@app:first-msg:123');
// 实际写出的删除项名字 ≈ %40app%3Afirst-msg%3A123
而浏览器里这条 Cookie 的真实名字是字面的 @app:first-msg:123(如果它当初是用别的方式 / 别的库以字面名写入的)。两个名字对不上——删除项指向了一个根本不存在的 Cookie → 删了个寂寞,原 Cookie 还在。
这正是清理这类历史脏 Cookie 时要用 raw document.cookie 写字面名的原因。两条可复用经验:
-
特殊字符命名的 Cookie(含
@ : ( ) ,等),读写 / 删除前先确认编码两端一致;不一致时直接用document.cookie写字面名。 - 跨库 / 跨历史数据:用 A 写、用 B 删,要警惕两者的「名 / 值编码策略」不同导致的不匹配。这类问题在编辑器预览、本地都看不出来,只有线上真实数据才暴露。
小结:一份可复用的 checklist
- 线上出现「框架内部」报错:先用 Sentry 的
platform / os / server_name分清 client / server,再按release聚合排除版本错配;跨版本 + 全站 + 服务端 → 怀疑网关 / 请求链路。 - header 被截断或 4xx:检查网关 header 上限,但根因常在前端把大值放进了 header(尤其是 Cookie)。
- 本地存储选型:记住「只有 Cookie 会自动进请求头」;不需服务端读取的大数据,一律不进 Cookie。
- 迁移存储后:主动清理历史脏 Cookie,别只改写入不删旧值。
- 删 Cookie:空值 + 过去过期时间 + 匹配 domain/path;特殊字符名要警惕 js-cookie 编码导致的「删不掉」。
很多线上「疑难杂症」最后都收敛到一个朴素的工程原则上:请求头是稀缺资源,别往里塞业务数据。