外网通过 Nginx 访问 Doris Stream Load:一次 HTTP 307 疑难排查与完整代理方案

外网通过 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)

两个关键事实:

  1. FE 只做"指路",数据实际进 BE 。FE 返回的 307 Location 里甚至直接带了 Basic 认证凭据(user:pass@host)。
  2. 任意一台 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 却始终成功。这就是第二个、也是更隐蔽的坑。

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 ← 想重发?流不可倒带!

根因链条完整闭合:

  1. Flink connector 的 PUT 带 Expect: 100-continue,语义是"先别收数据,等服务器确认";
  2. Nginx 见到该头会立即回 HTTP/1.1 100 Continue------这是 nginx 内置行为,没有任何配置项可以关闭;
  3. Connector 收到 100 后立刻发出数据体,而数据体是不可重放的流;
  4. 等 FE 真正的 307 响应到达时,数据已经发完、无法重放,HttpClient 无法执行重定向;
  5. 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_separatorlabel 等参数被静默丢弃,导入报错或数据格式错乱
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 部分(不能写尾部斜杠)

六、验证方法(四层递进)

  1. 不带重定向跟随的 PUT (模拟 connector 失败场景):

    修复前返回 307,修复后直接 HTTP 200 + Status: Success,证明不再发生重定向。

  2. curl 端到端--location-trusted 全流程成功,确认兼容老链路。

  3. 并发导入:3 路并行 PUT 全部成功,验证 BE 轮询在多 subtask 下正常。

  4. 模拟 jar 压测 :用与 connector 完全一致的 HTTP 语义(InputStreamEntity 不可重放流 + Expect: 100-continue + PUT 允许重定向)编写 Java 测试 jar,并增加"慢速管道流"模式(每 200ms 喂一行数据,模拟 Flink 持续写入)× 3 并行,全部成功,且响应中 ReceiveDataTimeMs ≈ 1950ms 证明数据是边产生边流经 Nginx 的(流式转发生效)。

七、经验总结

  1. Stream Load 直连 BE 是合法姿势。任意 BE 都能做导入协调者,需要绕开 FE 重定向时(跨网络、代理层、特殊客户端)直接打 BE 即可,这是 Doris 官方支持的行为。

  2. Nginx 对 Expect: 100-continue 的处理是内置的"立即回 100",不可配置关闭。凡是代理层背后有 307/308 重定向的场景(Doris、部分对象存储、HDFS WebHDFS 等),都要警惕"客户端提前发数据 → 无法重放"问题。

  3. curl 能过不代表程序能过。差异往往在请求体可重放性、Expect 头、重定向上。验证反代链路要尽量复刻生产客户端的 HTTP 行为,最稳妥的方式是用同款 HTTP 客户端库写一个模拟 jar。

  4. 排查 HTTP 疑难问题,verbose 抓包是决定性手段curl -v 完整记录交互时序(本例中 100 Continue 出现在 307 之前这一行就是破案关键);再配合源码确认客户端行为,形成完整证据链,避免猜测。

  5. Nginx 反代 Doris 的细节清单:下划线头、Host 带端口、流式转发、Body 不限大小、重定向改写要覆盖"带凭据"和"不带凭据"两种 Location 形态。

  6. 注意运维平台重写配置。本环境集群管理平台会在重启时重写 fe.conf,FE HTTP 端口曾从 端口A 变到 端口B,导致代理上游失效。依赖端口稳定的代理配置要建立端口变更的感知手段(巡检或告警)。

  7. 2PC(两阶段提交)场景的差异sink.enable-2pc=true 时,commit/abort 走 _stream_load_2pc 接口且必须经 FE。本文方案用 $ 锚点把该路径继续留给 FE,未开 2PC 的作业不受影响;若未来开启 2PC,需单独验证该链路(该接口请求体为空,不存在 100-continue 重放问题,通常可正常工作)。

  8. 映射端口要与实际监听对齐 。排查"连不上"先看目标机器上该端口是否真的有进程监听(ss -ltnp),再查防火墙与映射规则,避免在 NAT 层反复打转。

相关推荐
Aision_1 小时前
代码安全学习手记(二):SCA 软件成分分析原理与 OWASP Dependency-Check 集成实战
运维·人工智能·学习·安全·web安全·网络安全
xrkhy1 小时前
redis-shake的使用windows版本
数据库·windows·redis
yinshuzhineng1 小时前
如何提升生产线自动化水平,降低人工成本?
运维·人工智能·自动化·制造
码云骑士2 小时前
110-多模型热切换-API网关-LiteLLM代理-负载均衡故障转移
运维·python·负载均衡
小王C语言2 小时前
MySQL 内置函数:日期函数、字符串函数、数学函数、其他函数
数据库·mysql
QuartusII72 小时前
sw202X安装教程
运维·windows
菜是原罪2 小时前
ECS CPU 100% 故障排查与安全事件复盘报告
运维·服务器·网络安全
qq_2153978973 小时前
docker镜像打包
运维·docker·容器
ClouGence3 小时前
当 AI 开始直接操作数据库,传统数据库管理工具还有必要吗?
数据库·后端·agent