外网通过 Nginx 访问 Doris Stream Load:一次 HTTP 307 疑难排查与完整代理方案
注:文中 IP、端口、账号密码均已替换为示例代称。
一、背景
生产环境中 Doris 集群部署在内网,外部的 Flink 实时作业需要通过公网 NAT 映射把数据 Stream Load 进 Doris。链路如下:
外网 Flink 作业
│
▼
公网IP:10501 (公网 NAT 映射)
│
▼
内网IP_1:8130 (内网 Nginx 反向代理)
│
├──► FE 内网IP_2:8131 (导入入口,返回 307 重定向)
└──► BE 内网IP_1/内网IP_2/内网IP_3:8041 (真正接收数据)
直接暴露端口不可行,于是用 Nginx 做反向代理。看似简单的反代,实际踩了 Doris Stream Load 协议机制和 HTTP Expect: 100-continue 语义两个大坑。本文完整记录原理、排查过程和最终方案。
二、先理解 Doris Stream Load 的"两跳"协议
Stream Load 不是"一个请求打到底",而是标准的两跳流程:
1) 客户端 PUT /api/{db}/{table}/_stream_load ──────► FE
2) FE 返回 307 Temporary Redirect
Location: http://user:pass@BE_IP:8041/api/{db}/{table}/_stream_load
3) 客户端跟随重定向,把数据 PUT 给 BE ──────► BE
4) BE 返回 200 + JSON(Status: Success)
两个关键事实:
- FE 只做"指路",数据实际进 BE 。FE 返回的 307 Location 里甚至直接带了 Basic 认证凭据(
user:pass@host)。 - 任意一台 BE 都可以作为导入协调者。也就是说,客户端完全可以跳过 FE,直接 PUT 到任何一台 BE,Doris 内部会自行完成事务协调。这一点是后面方案的理论基础。
由此引出第一个问题:FE 重定向给出的 Location 是内网 BE 地址(如 内网IP_1:8041),外网客户端根本不可达。
2.1 第一层方案:改写 307 的 Location
Nginx 的 proxy_redirect 可以把 FE 返回的 Location 改写成"经 Nginx 回跳"的路径:
原始 Location: http://doris_user:xxx@10.0.0.30:8041/api/db/t/_stream_load
改写后: http://公网IP:10501/be/内网IP_1:8041/api/db/t/_stream_load
配合 /be/<ip>:<port>/ 前缀 location 把回跳流量转给对应 BE,链路就通了。curl 测试完全正常。
但------Flink Doris Connector 却开始报 307 错误,而 curl 却始终成功。这就是第二个、也是更隐蔽的坑。
三、Flink 报 HTTP/1.1 307 Temporary Redirect,curl 却成功?
3.1 现象
flink-doris-connector(1.17-25.1.0)作业日志:
table dwd_xxx_detail stream load stopped for label doris-xxx-...
ERROR org.apache.doris.flink.sink.writer.DorisStreamLoad
Failed to execute load, cause
org.apache.doris.flink.exception.StreamLoadException:
stream load error: HTTP/1.1 307 Temporary Redirect
at DorisStreamLoad.handlePrecommitResponse(DorisStreamLoad.java:307)
同一台机器上手动 curl 却一切正常。官方文档对该错误的常见建议(升级 connector、设置 auto-redirect=false、改 Unique Key batch 模式)在本环境均不适用:版本已是 25.1.0,公网IP也直连不了内网 BE。
3.2 读源码:Connector 的 HTTP 语义
下载 flink-doris-connector-1.17-25.1.0 源码确认了三处关键实现:
① 请求头硬编码 Expect: 100-continue(HttpPutBuilder.java):
java
public HttpPutBuilder addCommonHeader() {
header.put(HttpHeaders.EXPECT, "100-continue");
return this;
}
② 请求体是不可重放的流(DorisStreamLoad.java:360):
java
InputStreamEntity entity = new InputStreamEntity(recordStream);
recordStream 是实时数据管道流,InputStreamEntity 不可重复读取(isRepeatable() == false)------数据发出去就没了,无法倒带重发。
③ 客户端允许跟随 PUT 重定向(HttpUtil.java),即 connector 本身是支持 307 跳转的------理论上。
3.3 抓包锁定真凶:Nginx 抢先回了 100 Continue
用 curl verbose 模式完整抓包(保存为 curl100.txt),时序令人意外:
> PUT /api/test_db/test_table/_stream_load HTTP/1.1
> Expect: 100-continue
...(请求头已发完,等待 100 Continue)
< HTTP/1.1 100 Continue ← 注意!这是 nginx 立刻回的,不是 Doris
* We are completely uploaded and fine ← 客户端认为可以继续,数据体全部发出
< HTTP/1.1 307 Temporary Redirect ← FE 真正的响应这时才到
* Please rewind output before next send ← 想重发?流不可倒带!
根因链条完整闭合:
- Flink connector 的 PUT 带
Expect: 100-continue,语义是"先别收数据,等服务器确认"; - Nginx 见到该头会立即回
HTTP/1.1 100 Continue------这是 nginx 内置行为,没有任何配置项可以关闭; - Connector 收到 100 后立刻发出数据体,而数据体是不可重放的流;
- 等 FE 真正的 307 响应到达时,数据已经发完、无法重放,HttpClient 无法执行重定向;
- 307 响应被 connector 当作最终结果,抛出
StreamLoadException。
而 curl 之所以成功:curl 上传的是磁盘文件,可倒带重读,收到 307 后能原样重发一遍。同一个代理、同一份数据,客户端的"可重放性"差异造成了截然不同的结果。
教训:curl 测通了 ≠ 程序也能通。验证代理链路必须用与生产程序一致的 HTTP 语义(不可重放流、Expect 头、重定向策略)去测。
四、最终方案:Stream Load 直连 BE,釜底抽薪绕开 307
既然 307 重定向是问题之源,而 Doris 又允许任意 BE 作为导入协调者,那就让 Nginx 把 _stream_load 数据请求直接负载均衡到 3 台 BE,根本不经过 FE:
_stream_load 请求 → Nginx upstream 轮询 → BE(直接 200,全程无 307)
其他请求 → FE(/api/bootstrap、_stream_load_2pc 等)
优点:
- 无需重放、无需跟随重定向,任何 HTTP 客户端(含不可重放流)都能工作;
- 3 台 BE 轮询,天然分摊 Flink 并行度带来的多路并发导入;
- Flink 侧零改动,连接器配置原样保留。
五、完整 Nginx 配置(带逐段注释)
nginx
# Doris Stream Load 反向代理配置
# 外部链路: 公网IP:10501 -> 内网IP_1:8130(nginx)
# FE: 内网IP_2:8131 BE: 内网IP_1/内网IP_2/内网IP_3:8041
upstream doris_fe {
server 内网IP_2:8131;
}
# Stream Load 数据通道:3 台 BE 轮询(任一 BE 均可作为导入协调者)
upstream doris_be {
server 内网IP_1:8041;
server 内网IP_2:8041;
server 10.0.0.32:8041;
}
# 回跳地址使用客户端原始 Host(含端口),无 Host 头时兜底为本机监听地址
map $http_host $sl_host {
default $http_host;
"" $host:$server_port;
}
server {
listen 8130;
server_name _;
# 【关键1】Doris Stream Load 参数头含下划线(column_separator/columns/label 等),
# nginx 默认丢弃下划线头,不开这个导入会静默丢参数
underscores_in_headers on;
# 【关键2】导入数据不限制大小
client_max_body_size 0;
# 【关键3】长连接超时放宽(大数据量导入耗时较长)
proxy_connect_timeout 60s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
proxy_http_version 1.1;
# 【关键4】流式转发,导入数据不落盘缓冲;
# 若开启缓冲,Flink 长时间持续写入会被 nginx 攒在磁盘/内存,超时且失去背压
proxy_request_buffering off;
proxy_buffering off;
# 【关键5】用 $http_host 保留客户端原始端口
# (外网经 10501 进来时,FE 的回跳 Location 必须仍指回 10501 而不是 8130)
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 显式透传 Expect 头(保留完整语义,便于观察与后续调整)
proxy_set_header Expect $http_expect;
# ---- Stream Load 数据通道:直连 BE 轮询,跳过 FE 307 重定向 ----
# 【核心方案】nginx 见 Expect: 100-continue 会立刻回 100 Continue(内置行为,
# 无法关闭),Flink connector 的数据体是不可重放的 InputStreamEntity,会在收到
# FE 的 307 前就发出数据,导致重定向时无法重放而报错;直连 BE 则全程无 307。
# $ 锚点确保不匹配 _stream_load_2pc;正则 location 的 proxy_pass 不能带 URI 部分
location ~ ^/api/[^/]+/[^/]+/_stream_load$ {
proxy_pass http://doris_be;
}
# ---- BE 回跳通道:保留给仍走 FE 重定向的客户端(如 curl) ----
location /be/10.0.0.30:8041/ {
proxy_pass http://10.0.0.30:8041/;
}
location /be/10.0.0.31:8041/ {
proxy_pass http://10.0.0.31:8041/;
}
location /be/10.0.0.32:8041/ {
proxy_pass http://10.0.0.32:8041/;
}
# ---- 其余请求(/api/bootstrap、_stream_load_2pc 等)转发到 FE ----
# FE 的 307 Location 形如 http://user:pass@BE_IP:8041/...,
# 需把带凭据前缀改写为经 nginx 的 /be/ 路径
location / {
proxy_pass http://doris_fe;
proxy_redirect http://doris_user:your_password@10.0.0.30:8041/ http://$sl_host/be/10.0.0.30:8041/;
proxy_redirect http://doris_user:your_password@10.0.0.31:8041/ http://$sl_host/be/10.0.0.31:8041/;
proxy_redirect http://doris_user:your_password@10.0.0.32:8041/ http://$sl_host/be/10.0.0.32:8041/;
# 兜底:无凭据前缀的重定向
proxy_redirect http://10.0.0.30:8041/ http://$sl_host/be/10.0.0.30:8041/;
proxy_redirect http://10.0.0.31:8041/ http://$sl_host/be/10.0.0.31:8041/;
proxy_redirect http://10.0.0.32:8041/ http://$sl_host/be/10.0.0.32:8041/;
}
}
配置要点速查
| 指令 | 作用 | 不配置的后果 |
|---|---|---|
underscores_in_headers on |
放行下划线请求头 | column_separator、label 等参数被静默丢弃,导入报错或数据格式错乱 |
client_max_body_size 0 |
不限制请求体 | 大文件导入被 413 拒绝 |
proxy_request_buffering off |
请求体流式转发 | 长时间流式写入被缓冲落盘,超时/OOM |
proxy_set_header Host $http_host |
保留原始 Host 含端口 | 回跳地址变成 8130,外网客户端不可达 |
proxy_redirect 凭据前缀改写 |
处理 FE 带 user:pass@ 的 Location |
客户端跟随到内网地址失败 |
正则 location + proxy_pass http://doris_be; |
直连 BE 轮询 | ------注意:正则 location 的 proxy_pass 不能带 URI 部分(不能写尾部斜杠) |
六、验证方法(四层递进)
-
不带重定向跟随的 PUT (模拟 connector 失败场景):
修复前返回 307,修复后直接
HTTP 200 + Status: Success,证明不再发生重定向。 -
curl 端到端 :
--location-trusted全流程成功,确认兼容老链路。 -
并发导入:3 路并行 PUT 全部成功,验证 BE 轮询在多 subtask 下正常。
-
模拟 jar 压测 :用与 connector 完全一致的 HTTP 语义(
InputStreamEntity不可重放流 +Expect: 100-continue+ PUT 允许重定向)编写 Java 测试 jar,并增加"慢速管道流"模式(每 200ms 喂一行数据,模拟 Flink 持续写入)× 3 并行,全部成功,且响应中ReceiveDataTimeMs ≈ 1950ms证明数据是边产生边流经 Nginx 的(流式转发生效)。
七、经验总结
-
Stream Load 直连 BE 是合法姿势。任意 BE 都能做导入协调者,需要绕开 FE 重定向时(跨网络、代理层、特殊客户端)直接打 BE 即可,这是 Doris 官方支持的行为。
-
Nginx 对
Expect: 100-continue的处理是内置的"立即回 100",不可配置关闭。凡是代理层背后有 307/308 重定向的场景(Doris、部分对象存储、HDFS WebHDFS 等),都要警惕"客户端提前发数据 → 无法重放"问题。 -
curl 能过不代表程序能过。差异往往在请求体可重放性、Expect 头、重定向上。验证反代链路要尽量复刻生产客户端的 HTTP 行为,最稳妥的方式是用同款 HTTP 客户端库写一个模拟 jar。
-
排查 HTTP 疑难问题,verbose 抓包是决定性手段 。
curl -v完整记录交互时序(本例中100 Continue出现在307之前这一行就是破案关键);再配合源码确认客户端行为,形成完整证据链,避免猜测。 -
Nginx 反代 Doris 的细节清单:下划线头、Host 带端口、流式转发、Body 不限大小、重定向改写要覆盖"带凭据"和"不带凭据"两种 Location 形态。
-
注意运维平台重写配置。本环境集群管理平台会在重启时重写 fe.conf,FE HTTP 端口曾从 端口A 变到 端口B,导致代理上游失效。依赖端口稳定的代理配置要建立端口变更的感知手段(巡检或告警)。
-
2PC(两阶段提交)场景的差异 :
sink.enable-2pc=true时,commit/abort 走_stream_load_2pc接口且必须经 FE。本文方案用$锚点把该路径继续留给 FE,未开 2PC 的作业不受影响;若未来开启 2PC,需单独验证该链路(该接口请求体为空,不存在 100-continue 重放问题,通常可正常工作)。 -
映射端口要与实际监听对齐 。排查"连不上"先看目标机器上该端口是否真的有进程监听(
ss -ltnp),再查防火墙与映射规则,避免在 NAT 层反复打转。