一次看似简单的 Nginx 配置缺失,背后牵出的是 HTTP 协议中一个被严重低估的机制。本文从一个真实生产报错出发,层层深入
Expect: 100-continue的协议原理、工程实现与架构权衡。
一、事故现场:一个令人困惑的 400 错误
1.1 报错信息
某天,数据团队反馈 Flink 写入 StarRocks 的任务大面积失败,Nginx 日志中出现大量如下错误:
bash
$ curl -T data.csv http://starrocks-fe:8030/api/mydb/mytable/_stream_load
# 响应:
{
"Status": "Fail",
"Message": "There is no 100-continue header"
}
1.2 第一反应:这什么头?
大多数工程师的第一反应是:
"我传个文件,为什么要一个没听说过的 header?"
事实上,Expect: 100-continue 是 HTTP/1.1 协议(RFC 7231)中定义的一个流控协商机制 ,在数据导入、大文件上传等场景中被广泛使用。StarRocks 的 Stream Load、阿里云 MaxCompute Tunnel、AWS S3 Multipart Upload 等系统都将其作为协议强制要求。
1.3 根因定位
排查链路:
scss
Flink Connector → Nginx (反向代理) → StarRocks FE
问题出在 Nginx 默认配置会丢弃 Expect 头 。当请求经过 Nginx 代理时,客户端发出的 Expect: 100-continue 未被透传到后端 StarRocks,导致 FE 认为客户端没有遵循协议,直接返回 400。
修复只需两行:
nginx
location /api/ {
proxy_http_version 1.1;
proxy_set_header Expect $http_expect; # ← 关键:透传 Expect 头
proxy_pass http://starrocks_backend;
}
⚠️
proxy_http_version 1.1是前提条件。HTTP/1.0 不支持Expect语义。
二、什么是 100-continue?
2.1 一句话定义
100-continue 是客户端在发送请求体(Body)之前,先向服务端"请示"是否允许发送的协商机制。
2.2 没有它的世界
arduino
Client Server
│ │
│─── Headers + 500MB Body 一股脑发过去 ───→│
│ │ ← 收到 500MB 后才发现:
│ │ 权限不够 / 表不存在 / 限流
│←────── 403 / 404 / 429 ─────────────────│
│ │
│ 💀 500MB 白传了,带宽、时间、IO 全浪费 │
2.3 有了它的世界
arduino
Client Server
│ │
│─── Headers + Expect: 100-continue ────→ │
│ │ ← 先校验:权限✓ 表存在✓ 限流✓
│←────── 100 Continue ────────────────────│ ← "可以发,我准备好了"
│ │
│─── 500MB Body ──────────────────────────→│
│ │
│←────── 200 OK ──────────────────────────│
如果校验不通过:
arduino
Client Server
│ │
│─── Headers + Expect: 100-continue ────→ │
│ │ ← 校验失败:Quota 超限
│←────── 403 QuotaExceeded ───────────────│ ← 直接拒绝,Body 0 字节传输
│ │
│ ✅ 0 字节浪费,立即重试或降级 │
2.4 协议规范(RFC 7231 §5.1.1)
markdown
Expect = "100-continue"
客户端:
- 发送请求头后,必须等待服务端响应
- 收到 100 Continue → 发送 Body
- 收到 4xx/5xx → 不发送 Body,处理错误
- 超时未响应 → 可自行决定是否发送 Body
服务端:
- 收到 Expect: 100-continue → 必须回复 100 或 4xx
- 不能忽略该头部
- 不能先收 Body 再拒绝(违反协议)
三、工作原理:时序与状态机
3.1 完整时序图
css
┌────────┐ ┌────────┐
│ Client │ │ Server │
└───┬────┘ └───┬────┘
│ │
│ ① TCP 三次握手 (建立连接) │
│◄──────────────────────────────────────►│
│ │
│ ② HTTP 请求头 │
│ PUT /api/db/table/_stream_load │
│ Content-Length: 524288000 │
│ Expect: 100-continue │
│───────────────────────────────────────►│
│ │
│ ③ 服务端前置校验 │
│ • 鉴权 Token 有效? │
│ • 目标表/分区存在? │
│ • Quota 是否超限? │
│ • 磁盘水位是否安全? │
│ │
│ ④a 校验通过 │
│ HTTP/1.1 100 Continue │
│◄───────────────────────────────────────│
│ │
│ ⑤ 发送 Body (500MB) │
│═══════════════════════════════════════►│
│ │
│ ⑥ 最终响应 │
│ HTTP/1.1 200 OK │
│◄───────────────────────────────────────│
│ │
│ ──── 或者 ──── │
│ │
│ ④b 校验失败 │
│ HTTP/1.1 403 Forbidden │
│ Body: {"error": "QuotaExceeded"} │
│◄───────────────────────────────────────│
│ │
│ (Body 从未被发送,0 字节浪费) │
│ │
3.2 关键设计约束
| 规则 | 说明 |
|---|---|
| 客户端必须等待 | 发出 Expect 后不能立即发 Body(除非超时) |
| 服务端必须响应 | 不能静默忽略,必须回 100 或 4xx |
| 100 是中间响应 | 不是最终响应,之后还会有 200/4xx/5xx |
| 连接不关闭 | 整个过程复用同一个 TCP 连接 |
| 代理必须透传 | Nginx/Tengine 需显式配置转发 Expect 头 |
四、Nginx 代理中的坑
4.1 为什么 Nginx 默认会"吃掉" Expect 头?
Nginx 作为反向代理,默认行为:
nginx
# 默认行为(等效):
proxy_http_version 1.0; # ← HTTP/1.0 不支持 Expect
proxy_set_header Expect ""; # ← 主动清空 Expect 头
这是历史遗留设计------早期 HTTP/1.0 代理不理解 Expect,为避免后端困惑直接丢弃。
4.2 正确配置
nginx
upstream starrocks {
server fe1:8030;
server fe2:8030;
}
server {
listen 8080;
location /api/ {
proxy_http_version 1.1; # 必须 1.1
proxy_set_header Expect $http_expect; # 透传 Expect
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Stream Load 通常 Body 很大,需要调整以下参数
client_max_body_size 10g;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off; # 大文件建议关闭缓冲
proxy_pass http://starrocks;
}
}
4.3 验证方法
bash
# 方法1:curl -v 观察握手过程
curl -v -T data.csv http://nginx:8080/api/db/table/_stream_load
# 期望看到:
# > Expect: 100-continue
# < HTTP/1.1 100 Continue ← 如果看到这行,说明透传成功
# < HTTP/1.1 200 OK
# 方法2:抓包确认
tcpdump -i eth0 -A 'tcp port 8030' | grep -i "100-continue"
五、应用场景
5.1 场景总览
| 系统 | 是否强制 | 典型 Body 大小 | 说明 |
|---|---|---|---|
| StarRocks Stream Load | ✅ 强制 | 64MB ~ 数GB | 协议契约 |
| 阿里云 MaxCompute Tunnel | ✅ 强制 | 64MB ~ 256MB | SDK 硬编码 |
| AWS S3 PutObject (>2MB) | SDK 自动添加 | 5MB ~ 5GB | 非强制但推荐 |
| HDFS WebHDFS | ❌ 不要求 | 任意 | 大文件有益 |
| 通用 REST API | ❌ 不要求 | 通常 < 10MB | 视情况决定 |
5.2 阿里实际案例:MaxCompute Tunnel Upload
业务背景:DataWorks 数据集成将业务库(MySQL/Oracle)、日志、OSS 对象批量导入 MaxCompute 表。
arduino
┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ DataWorks │ │ Tengine │ │ MaxCompute │
│ Sync Engine │────→│ (Nginx 分支) │────→│ Tunnel Server │
│ │ │ │ │ │
│ HttpClient │ │ proxy_http_ │ │ 强制校验 │
│ 自动加 │ │ version 1.1 │ │ Expect 头 │
│ Expect: │ │ proxy_set_ │ │ │
│ 100-continue │ │ header Expect │ │ 校验通过→回100 │
│ │ │ $http_expect │ │ 校验失败→回4xx │
└──────────────┘ └─────────────────┘ └──────────────────┘
规模数据:
- 单次 Block:64MB ~ 256MB
- 单任务并发:32 ~ 128 通道
- 单 Region 并发连接:数万
- 前置校验拒绝率(高峰期):2% ~ 8%
量化收益:
| 指标 | 无 100-continue | 有 100-continue | 改善 |
|---|---|---|---|
| 失败请求耗时 | ~2.1s | ~3ms | 700x |
| 无效带宽 (高峰) | ~30GB/min | ~0 | 100% |
| Tunnel P99 延迟 | 850ms | 120ms | 7x |
| 失败恢复时间 | 分钟级 | 秒级 | 60x |
5.3 你的场景:StarRocks + Flink + Nginx
arduino
┌────────────┐ ┌────────┐ ┌──────────────┐
│ Flink │ │ Nginx │ │ StarRocks │
│ Connector │────→│ 代理 │────→│ FE / BE │
│ │ │ │ │ │
│ HttpClient │ │ 需配置 │ │ 强制要求 │
│ 硬编码 │ │ 透传 │ │ Expect 头 │
│ Expect头 │ │ Expect │ │ │
└────────────┘ └────────┘ └──────────────┘
与阿里内部链路完全同构:Tengine ↔ Nginx,Tunnel ↔ StarRocks,DataWorks ↔ Flink。
六、使用对比:有 vs 无
6.1 行为对比
bash
# ❌ 无 Expect 头 → StarRocks 直接拒绝
$ curl -T data.csv http://starrocks:8030/api/db/tbl/_stream_load
{"Status":"Fail","Message":"There is no 100-continue header"}
# ✅ 有 Expect 头 → 正常导入
$ curl -H "Expect: 100-continue" -T data.csv \
http://starrocks:8030/api/db/tbl/_stream_load
{"Status":"Success","NumberLoadedRows":100000}
6.2 性能对比(模拟 100MB 文件,1% 失败率)
| 指标 | 无 100-continue | 有 100-continue |
|---|---|---|
| 成功请求耗时 | 800ms | 801ms (+1ms) |
| 失败请求耗时 | 801ms (白传) | 2ms (快速拒绝) |
| 期望耗时 | 800.01ms | 793.01ms |
| 失败时带宽浪费 | 100MB | 0 |
| 128 并发下失败带宽 | 12.8GB/批 | 0 |
6.3 代码对比
Java (HttpClient):
java
// ❌ 不带 Expect → StarRocks 400
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://sr:8030/api/db/tbl/_stream_load"))
.PUT(HttpRequest.BodyPublishers.ofFile(path))
.build();
// ✅ 带 Expect → 正常
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://sr:8030/api/db/tbl/_stream_load"))
.header("Expect", "100-continue") // ← 关键
.PUT(HttpRequest.BodyPublishers.ofFile(path))
.build();
curl:
bash
# -T 自动添加 Expect: 100-continue
curl -T file.csv http://sr:8030/api/db/tbl/_stream_load
# -d @file 超过 1024 字节才自动添加
curl -d @file.csv http://sr:8030/api/db/tbl/_stream_load
Go (net/http):
go
// Go 标准库从不自动添加,必须手动设置
req, _ := http.NewRequest("PUT", url, body)
req.Header.Set("Expect", "100-continue") // ← 必须手动
七、什么时候该用?什么时候不该用?
7.1 判断矩阵
| 场景 | 是否需要 | 原因 |
|---|---|---|
| StarRocks Stream Load | ✅ 强制 | 协议契约,与大小无关 |
| MaxCompute Tunnel | ✅ 强制 | SDK 硬编码,与大小无关 |
| 通用 API,Body < 10KB | ❌ 不需要 | RTT 开销 > 传 Body 开销 |
| 通用 API,Body 10KB~1MB,失败率 < 1% | ⚠️ 可选 | 收益有限 |
| 通用 API,Body > 1MB,失败率 > 1% | ✅ 推荐 | 正收益 |
| 通用 API,Body > 100MB | ✅ 强烈推荐 | 失败代价极高 |
| 内网微服务 RPC | ❌ 通常不用 | 延迟敏感,失败率极低 |
7.2 核心判断公式
css
使用 100-continue 的期望收益 = 失败率 × Body传输时间 - RTT
当 失败率 × Body传输时间 > RTT 时,使用有正收益。
7.3 一个常见误区
"我的程序保证 99.9% 成功率,100-continue 纯属浪费。"
这个推理有三个漏洞:
| 假设 | 现实 |
|---|---|
| "99.9% 可用 = 99.9% 接受 Body" | ❌ 服务可用 ≠ 接受数据(限流/Schema变更/Quota) |
| "额外 RTT 很贵" | ❌ 同连接上几十字节响应,内网 < 0.2ms |
| "程序能保证成功率" | ❌ 无法保证外部系统行为不变 |
💡 类比 :100-continue 是数据导入的"安全带"。你 99.9% 的行程不会撞车,但不会因此拆掉安全带------因为它保护的不是平均情况,而是最坏情况下的灾难性后果。
八、RTT 到底是什么?
在讨论中反复出现"额外 RTT",这里做一个精确定义:
8.1 RTT = Round-Trip Time(往返时延)
arduino
Client Server
│ │
│─────── 请求 ──────────────────→│ ← t1 (单程传播)
│ │ ← 处理耗时
│←────── 响应 ──────────────────│ ← t2 (单程传播)
│ │
RTT = t1 + 处理时间 + t2
8.2 在 100-continue 中,"额外 RTT" 特指
css
正常:Headers+Body → 200 OK (1 次 RTT)
Expect:Headers → [100 Continue] → Body → 200 OK (2 次 RTT)
↑
这就是"额外 RTT"
仅一个几十字节的中间响应
≠ 新建连接 / DNS / TLS
8.3 各环境 RTT 量级
| 环境 | RTT | 感知 |
|---|---|---|
| 同机器 loopback | < 0.01ms | 无 |
| 同机房 | 0.05 ~ 0.3ms | 无 |
| 跨 AZ | 0.5 ~ 2ms | 极轻 |
| 同城公网 | 5 ~ 20ms | 轻 |
| 跨省 | 20 ~ 50ms | 可感知 |
| 跨国 | 100 ~ 300ms | 明显 |
在内网环境下,额外 RTT ≈ 传输 1~10KB 数据的时间,对于 MB 级以上的 Body 完全可忽略。
九、各语言/工具的默认行为速查
| 客户端 | 是否自动添加 | 阈值 | 备注 |
|---|---|---|---|
curl -T |
✅ 始终 | 无阈值 | 只要用 -T 就加 |
curl -d @file |
✅ 有条件 | > 1024B | POST 数据超 1KB |
| Java HttpURLConnection | ❌ | --- | 需手动设置 |
| Java HttpClient (11+) | ❌ | --- | 需手动设置 |
| Apache HttpClient | ⚠️ 可配置 | 默认开启 | ExpectContinueEnabled |
| Python requests | ⚠️ 部分版本 | > 1MB | 行为不一致 |
| Go net/http | ❌ | --- | 必须手动 |
| Flink StarRocks Connector | ✅ 硬编码 | 无阈值 | 内部自动处理 |
| MaxCompute SDK | ✅ 硬编码 | 无阈值 | 协议强制 |
十、最佳实践清单
✅ DO
- 对接 StarRocks Stream Load 时,所有请求 必须带
Expect: 100-continue - Nginx 代理层配置
proxy_http_version 1.1+proxy_set_header Expect $http_expect - 大文件上传(> 1MB)到通用 HTTP 服务时,主动添加该头
- 设置合理的 Expect 超时(通常 1~3s),超时后决定是否继续发送 Body
- 监控
100 Continue响应延迟,作为服务端前置校验耗时的指标
❌ DON'T
- 不要按文件大小判断是否需要------协议强制场景与大小无关
- 不要在 HTTP/1.0 下使用------协议不支持
- 不要忘记 Nginx 的
client_max_body_size限制 - 不要在生产环境依赖"程序保证成功率"来跳过 Expect 机制
- 不要把 100-continue 当作性能优化------它是安全机制
十一、总结
matlab
┌─────────────────────────────────────────────────────────┐
│ │
│ 100-continue 的本质: │
│ │
│ 不是性能优化,是数据导入协议的安全契约。 │
│ 不是"大文件才需要",是"协议要求就需要"。 │
│ 不是"服务不可用才生效",是"任何拒绝都能快速生效"。 │
│ │
│ 在 StarRocks / MaxCompute 等系统中: │
│ 它的存在不是为了优化那 0.1% 的失败, │
│ 而是为了确保 100% 的请求都经过前置校验后才投入资源。 │
│ │
└─────────────────────────────────────────────────────────┘
下次当你在 Nginx 日志中看到 There is no 100-continue header,你已经知道:这不是一个"奇怪的报错",而是数据导入协议在告诉你------请先敲门,再进屋。