一个上传服务支持大文件,用户抱怨"传 2GB 视频,最后被拒'文件太大'"——2GB 已经传完,才收到 413. 而协议里本来有 Expect: 100-continue:客户端"先发头",服务端"同意再发体". 那它为什么没生效?因为"中间有一层代理"把 Expect 头"去掉了".
100-continue 是 HTTP/1.1 里"唯一实用的 1xx 响应":它把"拒绝"提前到"发 body 之前"。而它的失败模式很朴素:任何一个中间层"不转发 Expect 头""或"不返回 100",这个机制就静默失效.
"先问再发"这个比喻很贴切,但它有个"隐含成本":多一次往返——于是"每个请求都要多一个 RTT"。这也是它"只对大 body 开启"的原因:curl 的默认阈值是"1KB 以上才加 Expect",而"几百字节的 JSON"根本不值得"多一个来回"。
1. 它省的是什么
| 项 | 无 100-continue |
有 |
|---|---|---|
| 先发什么 | 头 + 体(一起) | 先头,等 100 再发体 |
| 被拒时 | 已传完(浪费带宽) | 只传了头 |
| 往返 | 1 次 | 2 次(头 → 100 → 体) |
第二项是"它的全部价值":"校验失败"能在"传输开始前"发生——**于是"2GB 的拒绝"变成"1KB 的拒绝".
"抓包"是唯一可靠的判断方式,而它只要看"两行":
// 三步"看清 100-continue"(本地可复现)
1 curl -v -T big.bin http://host/upload → 看请求头里"Expect: 100-continue"
2 看响应:有没有"HTTP/1.1 100 Continue"这一行
3 若"没有 100 但体已发" → 客户端"等到超时",或者"中间层剥掉了 Expect"
// 判据:有 Expect、无 100 → 服务端/代理没配合
2. "静默失效"的三种原因
| 原因 | 机制 |
|---|---|
代理"去掉 Expect" |
服务端"根本不知道"客户端的意图 |
服务端"不返回 100" |
客户端"等不到",按"超时后直接发"处理 |
| 客户端"不等" | 有的实现"发了头就发体" |
第二项"最微妙":客户端"等 100 有超时"(常见 1 秒)——**于是"服务端想拒绝"却没跑到那一步,"客户端已经开传了".
"网关提前拒"是最便宜的一层,而它"一行配置"就够:
// 两行"提前拒"(nginx)
client_max_body_size 100m; # 超过就直接 413(不发体)
client_body_buffer_size 128k; # 小缓冲:头一到就能判
// 判据:2GB 请求"在发体之前"收到 413
"413 提前"与"413 事后"在"日志"上长得一样,但在"带宽账"上差得很远:"2GB × 每天 50 次失败上传" = 每天 100GB 白传——而"带宽"是"云上最容易被忽视的成本之一"。所以"网关限额"这件事,不只是"体验优化",也是"成本优化"。
3. "服务端"该怎么实现
| 做法 | 说明 |
|---|---|
| 网关"检查头后直接返回 413" | 最有效(在"最外层"拒绝) |
| 应用"读头后返回 100 或 4xx" | 要"框架支持" |
反代"转发 Expect" |
配置项(常默认关闭) |
第一项是"最实际"的:"在 nginx 层用 client_max_body_size 直接拒"——**于是"2GB 的请求"在"发体之前"就被 413 挡住.
"客户端先校验"在浏览器里是"唯一可控"的一环,而它只要三行:
// 三步"前端先拦"(上传前)
const f = input.files[0];
if (f.size > 100 * 1024 * 1024) { alert("超过 100MB"); return; }
// 再配合"后端返回的限额"渲染提示(避免硬编码)
// 判据:用户"不必等 2GB 传完"就知道结果
4. "三种边界"与"客户端行为"
| 客户端 | 行为 |
|---|---|
curl -T(大文件) |
默认加 Expect: 100-continue(>1KB 时) |
| 浏览器(XHR/fetch) | 不加(由 UA 决定) |
Java HttpClient |
需显式 expectContinue(true) |
第二项很关键:"浏览器上传"几乎"不用 100-continue"——**于是"前端上传被拒"仍然"传完才知道".
"反代剥掉 Expect"这件事,要"显式打开转发",而它"默认常是关的":
// 三步"确认转发"(按组件)
1 nginx:查"是否 proxy_pass 时保留 Expect"(部分版本默认剥)
2 网关/ LB:查"1xx 透传"开关(有的只转发 200+)
3 应用:查框架"是否自动回 100"(有些要显式开启)
// 判据:抓包里有"Expect"但服务端日志"没有该头" → 中间层剥了
"HTTP/2" 让这件事"换了形态":它"没有 Expect: 100-continue 这套沿用"——取而代之的是"流控窗口"——于是"客户端可以先发一小段、看服务端是否 RST_STREAM"。而"迁移到 h2 之后仍想'提前拒'"的人,会发现"原来那套不适用了",得"在应用层做'先查后传'(两段式 API)".
5. "让它生效"的三步
| 步骤 | 动作 |
|---|---|
1 客户端"发 Expect" |
且"等 100"(带超时) |
| 2 中间层"转发" | 反代/网关"不要剥掉" |
| 3 服务端"按需返回" | 要么 100,要么"直接 4xx" |
三步缺一,就"退回'传完才拒'"——而"缺哪一步"要靠"抓包"看(Expect 在不在、100 有没有回)。
"校验放在体之后"是最常见的实现错误,而它把"提前拒"变成"事后拒":
// 两种"校验位置"
1 头之后、体之前:看"Content-Length/类型/用户额度" → 可"提前拒"
2 体之后:要"读完文件"才能算哈希/扫描 → 无法提前
// 判据:能"只靠头判断"的校验,就该"放在头之后"
6. 排错:五种"上传被拒太晚"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 传完才 413 | 无 100-continue |
网关"提前拒" |
Expect 被剥 |
代理配置 | 打开"转发" |
| 客户端"等不到 100" | 服务端未返回 | 补"100 响应" |
| 浏览器"照传不误" | UA 不加 Expect |
前端"先查大小" |
| 偶发"100 后仍被拒" | 校验在"体之后" | 把校验"提前到头" |
7. 边界:什么情况"别指望 100-continue"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 浏览器上传 | 多数 UA 不发 Expect |
前端"先查大小/类型" |
| 小请求(< 1KB) | 传完也无所谓 | 不必 |
| HTTP/2、HTTP/3 | 语义不同(无 1xx 沿用) | 用"帧级"控制 |
| 中间层"多且不可控" | 静默失效 | 网关"统一限额" |
判断口径一句话:"这个请求的 body,会不会"大到'传完才发现不合法'""。会(大文件、批量导入),就要"网关提前拒 + 客户端先校验";不会(表单、JSON),"传完再拒"用户也无感——**而"指望 100-continue 在所有链路上生效",是把"协议能力"当成了"部署事实".
8. 自测
100-continue省的是什么?为什么它"只对'会被拒的请求'有价值"?- 三种"静默失效"原因里,哪一项"最微妙"?客户端等
100的超时会造成什么? - 为什么"网关层直接
413"最实际? - 浏览器上传为什么"多数不用
100-continue"?这对"前端体验"意味着什么? - "三步生效"里缺一步会怎样?怎么判断缺了哪一步?
- 判断"能否指望
100-continue"的那个问题是什么?
补一个"产品"视角:"上传失败"这件事,用户能接受"被拒",不能接受"白等"——而"2GB 传完才告诉你'太大'"正是"白等"的极端形态。所以"上传体验"的优先级应当是:前端先拦 > 网关限额 > 100-continue;前两条"一定生效",第三条"看链路脸色"。
而"看得懂链路"这件事,比"记住协议特性"更能解决实际问题。
而链路"每一跳的行为",都值得"抓包看一眼"。
9. 一句话总结
100-continue 是"协议层的提前拒绝",而它的生效需要"客户端 + 中间层 + 服务端"三方配合——三方里任何一方"不配合",它就"静默退化成'传完才拒'"。于是工程上的稳妥做法是"两头兜":网关设限额(提前拒)+ 客户端先校验(不发),而 100-continue 只是"锦上添花"。