Rust in Everything
基于 pingora-core 0.9.0

管线化的第二个请求没有回应,连接被关

主线位置:请求主线的最后一步「下游 keep-alive」,以及转发途中对下游的那次空闲读。reuse 怎么判断「读过头」、0.9.0 的 pipelining 怎么把多读的字节交给下一个会话,见 HTTP/1 服务端。本页讲默认设置下管线化客户端看到的两种结果。

flowchart TB
  two["客户端连发两个请求"] --> when{"第二个何时到"}
  when -->|和第一个同一次读| over["读过头"]
  over --> reuse["reuse 拒绝复用"]
  reuse --> drop["回第一个 关连接"]
  when -->|转发第一个途中| idle["空闲读读到字节"]
  idle --> err["Sent data after end of body"]
  err --> r400["第一个回 400"]
  on["打开 pipelining"] --> seq["两个都按顺序回"]

实验 crate exp-pingora/pita-lab,bin px:默认设置在 19260,PX_PIPE=1(early_request_filter 里 session.set_pipelining_enabled(true))在 19261,上游是 tools/echo_upstream.py(把请求行和头写回响应体,/sleep?s=N 先睡 N 秒)。客户端 tools/raw.py 用原始 TCP 发字节;|SLEEP| 处停 300 ms。M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2。

现象

两个请求一起到。 一次 sendall 写出两个 GET:

GET /p1 HTTP/1.1\r\nHost: a\r\n\r\nGET /p2 HTTP/1.1\r\nHost: a\r\n\r\n
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.14.10
Date: Thu, 29 Oct 2026 06:48:01 GMT
Content-Type: text/plain
Content-Length: 25
Connection: keep-alive

GET /p1 HTTP/1.1
Host: a

-- 188 bytes; closed by server (FIN)

只有 /p1 的响应,响应头写着 Connection: keep-alive,随后连接被关,/p2 没有到上游。debug 日志里只有一行 bytes overread on request, disallowing reuse。

第二个请求在第一个转发途中到。 第一个请求让上游睡 1 秒,300 ms 后再发第二个:

GET /sleep?s=1 HTTP/1.1\r\nHost: a\r\n\r\n|SLEEP|GET /p2 HTTP/1.1\r\nHost: a\r\n\r\n
HTTP/1.1 400 Bad Request
Server: Pingora
Date: Thu, 29 Oct 2026 06:49:04 GMT
Content-Length: 0
Cache-Control: private, no-store
Connection: close

-- 154 bytes; closed by server (FIN)
[2026-10-29T06:49:04Z ERROR pingora_proxy] Fail to proxy: Downstream ConnectError context: Peer: addr: 127.0.0.1:19262, scheme: HTTP cause:  context: Sent data after end of body, status: 400, tries: 1, retry: false, GET /sleep?s=1, Host: a

这次连第一个请求都没拿到响应:上游已经收到 /sleep?s=1 并开始处理,pingora 却回了 400,丢掉了上游连接。客户端没做错什么,HTTP/1.1 允许管线化(RFC 9112 §9.3.2),而结果取决于第二个请求的字节落在哪个时刻。

出处

两条路在 pingora-core/src/protocols/http/v1/server.rs 里:

  • 同一次读:reuse 看到 has_bytes_overread() && !self.pipelining_enabled,返回 None,连接关闭;Connection: keep-alive 早在写响应头时就写出去了。
  • 转发途中:proxy_h1 在等上游的同时调 read_body_or_idle 盯着下游,请求体已经读完时,这次读本该只看到 EOF 或什么都没有。读到了字节而 pipelining 没开:
            } else if self.pipelining_enabled {
                // ...
                self.body_reader.push_body_overread(&probe[..read]);
                self.pipelined_idle_bytes_stashed = true;
                debug!("pipelined request bytes stashed as overread ({read} bytes)");
                std::future::pending().await
            } else {
                Error::e_explain(ConnectError, "Sent data after end of body")
            }

这个 ConnectError 被归为下游错误,第一个请求以 400 结束。Sent data after end of body 这一支从 0.1.0 起就在;0.9.0 只是在它前面加了 pipelining 打开时的分支,并有一个测试 idle_read_still_raises_when_pipelining_disabled 专门确认关闭时仍然报错,所以这是有意保留的默认行为。set_pipelining_enabled 的文档注释也写明默认关闭时「request N+1 is dropped. Clients are expected to detect the close and retry on a fresh connection」,但没有提到第二种情形会让第 N 个请求也失败。

修法

打开 pipelining。仓库的 pingora-proxy/examples/pipelining.rs 就是这么写的,在 early_request_filter 里打开一次,之后经 HttpPersistentSettings 在同一个连接上一直生效:

  async fn early_request_filter(&self, session: &mut Session, _ctx: &mut ()) -> Result<()> {
    session.set_pipelining_enabled(true);
    Ok(())
  }

两种情形在 19261 上:

HTTP/1.1 200 OK
...
GET /p1 HTTP/1.1
Host: a
HTTP/1.1 200 OK
...
GET /p2 HTTP/1.1
Host: a

-- 376 bytes; idle timeout (connection still open)
HTTP/1.1 200 OK
GET /sleep?s=1 HTTP/1.1
HTTP/1.1 200 OK
GET /p2 HTTP/1.1
-- 383 bytes; idle timeout (connection still open)

(第二段用 grep 只留了状态行和回显的请求行。)上游日志:

upstream 58913 "GET /sleep?s=1 HTTP/1.1" 200 -
upstream 58913 "GET /p2 HTTP/1.1" 200 -

两个请求按顺序处理,/p2 在 /sleep?s=1 的响应写完之后才开始,复用了同一条上游连接。pingora 的 pipelining 只是顺序处理,不会并发转发。

怎么确认没中招

  • 用原始 TCP 一次写两个请求,数回来几个 HTTP/1.1 状态行;再让第一个请求慢一点、第二个请求晚一点到,看第一个是不是变成 400。
  • 日志里搜 Sent data after end of body(Downstream ConnectError,状态 400)和 debug 级别的 bytes overread on request, disallowing reuse。
  • 先弄清下游会不会管线化。本机的 curl 8.7.1 不会(curl --help all 里搜不到 pipelin),用 curl 测不出这个坑;自己写的客户端、压测工具和服务之间的调用要逐个确认,会的话先打开它。

小结

  • 0.9.0 默认关闭 pipelining。两个请求同一次读到:只回第一个,响应头说 keep-alive,然后关连接,第二个被丢掉。
  • 第二个请求在第一个转发途中到:read_body_or_idle 报 Sent data after end of body,第一个请求也以 400 失败。这一支从 0.1.0 就在,0.9.0 有测试确认关闭时照旧。
  • 在 early_request_filter 里 set_pipelining_enabled(true),两种情形都按顺序回两个 200。

💬 关联讨论

发起讨论
加载中...