最近对自己手撕的HTTP反向代理,进行了压测,并与Nginx进行对比,以便于排查其中的隐患问题。
一、HTTP基础
1.1 版本
HTTP 协议版本对比
1. HTTP/0.9(1991)
- 特点:仅支持 GET,无请求头,仅传输 HTML,连接后立即关闭。
- 缺点:功能极其有限。
2. HTTP/1.0(1996)
- 改进:支持多种方法、请求头、图片等资源,但仍使用短连接。
- 缺点:每次请求都重新建立 TCP 连接,性能低。
3. HTTP/1.1(1999)
- 改进:默认持久连接(Keep-Alive)、管道化(pipelining)、Host 头、多种优化,支持Server-Sent Events (SSE) 服务器推送(比如 chatgpt 的实时 eventstream 推送)。
- 缺点:队头阻塞(HOL Blocking),同一条 TCP 连接 只能按顺序发送请求和接收响应。如果第一个请求处理很慢,后面的请求都要等它完成,TCP 连接有限。
4. HTTP/2(2015)
- 改进:二进制分层、多路复用(Multiplexing)、头部压缩。
- 缺点:仍受 TCP 队头阻塞影响。所有主流浏览器只支持
HTTP/2 over TLS(h2)
,都不支持 HTTP/2 over cleartext TCP(h2c)
5. HTTP/3(2022)
- 改进:基于 QUIC(UDP),解决队头阻塞,0-RTT 连接(客户端在无需完整的握手过程的情况下,立即发送数据,从而减少延迟),内置 TLS 1.3。
- 缺点:部署成本较高,仍在推广阶段。
HTTP/2 已广泛使用,HTTP/3 未来可能成为主流,但 HTTP/1.1 仍是基础。
1.1.1 不同版本对Keep-Alive的理解
Keep-Alive 是一种复用同一个 TCP 连接进行多个 HTTP 请求/响应的机制,避免频繁建立和断开连接。
HTTP/1.0
- 默认行为
- 默认使用短连接(即每次请求响应后断开 TCP 连接)。
- 开启Keep-Alive
- 客户端请求头需添加:
Connection: keep-alive
- 服务器响应头也需添加:
Connection: keep-alive
HTTP/1.1
默认行为
- 默认启用长连接(keep-alive)。
- 若服务器未返回
Connection: close
,则表示使用长连接。
关闭Keep-Alive
- 客户端请求头需添加:
Connection: close
- 服务器返回头添加:
Connection: close
HTTP/2
- 默认行为
- 不再使用Connection标头,参考
- 长连接。
1.1.2 Pipelining与Multiplexing
底层的 TCP 是串行的、按顺序发送字节流,它没有真正的物理并行通道。
但是HTTP1.1和HTTP2分别在应用层实现了Pipelining(管道化)与Multiplexing(多路复用)
Pipelining
HTTP/1.1 的 pipelining
特点
- 同一 TCP 连接中,客户端可连续发送多个请求,无需等待响应。
- 响应必须按请求顺序返回(FIFO)。
- 服务端不能乱序响应,必须按请求顺序响应。
示例流程
1
| req-A, req-B, resp-A, resp-B
|
以vertx的httpclient为例,发起一个http1.1的pipelining请求。
1 2 3 4
| HttpClient httpClient = Vertx.vertx().createHttpClient(new HttpClientOptions().setKeepAlive(true) .setPipelining(true) .setMaxPoolSize(1)); sendHttp(httpClient);
|
实际上请求是提前发出去了,但是响应依然是按顺序处理的。前一个处理完、响应后,再处理下一个。

比如我的api是5秒响应。我通过1个连接使用pipelining请求两次。那么两个全部响应总共耗时需要十秒。不管vertx还是tomcat的实现,都是顺序执行的。源码如下。

http1.1常规用法,存在队头阻塞问题,即一个http长连接,如果前面是个慢请求,那个后面这个快请求也只能排队。
pipelining的出现目的是为了解决该问题。但估计是实现困难的原因,像上面的vertx和tomcat实现,也都是用顺序执行实现的。因此,就目前的实现来看,pipelining毫无价值的。浏览器也直接弃用该特性。
Multiplexing
HTTP/2 的 multiplexing
特点
- 同一连接中,多个请求和响应可以并发进行。
- 请求与响应会被拆分成多个帧,交错传输,通过 stream ID 进行区分。
- 不再需要顺序处理,响应可以乱序返回。
示例流程
1
| req-A-1, req-B-1, req-B-2, resp-B-1, resp-B-2, req-A-2, resp-A-1, resp-A-2
|
multiplexing解决了http1.1的队头阻塞问题。即使前面是个慢请求,也不会影响后面的快请求。
vertx使用h2c实现的代码示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| import io.vertx.core.Vertx; import io.vertx.core.http.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory;
import java.util.Arrays; import java.util.Collections; import java.util.concurrent.locks.LockSupport;
public class MultiplexingDemo {
private static final Logger log = LoggerFactory.getLogger(MultiplexingDemo.class);
public static void startServer(Vertx vertx, HttpServer httpServer) { httpServer.requestHandler(req -> { if (req.path().equals("/slow")) { vertx.setTimer(5000, id -> { req.response().setStatusCode(201).end(req.connection().remoteAddress() + " slow response"); }); } else { req.response().end(req.connection().remoteAddress() + " fast response"); } }).listen(8080).onFailure(e -> { System.exit(1); }); }
public static void sendHttp(Vertx vertx, HttpClient httpClient, String url) { RequestOptions requestOptions = new RequestOptions() .setMethod(HttpMethod.GET) .setAbsoluteURI(url); httpClient.request(requestOptions).onFailure(e -> { log.error("client: request error", e); }).onSuccess(req -> { log.info("client: {} connected", req.connection().localAddress()); req.send().onComplete(ar -> { if (ar.succeeded()) { log.info("client: {} statusCode {}", req.connection().localAddress(), ar.result().statusCode()); } else { log.error("client: {} send error", req.connection().localAddress(), ar.cause()); } }); }); }
public static void main(String[] args) { Vertx vertx = Vertx.vertx(); HttpServerOptions httpServerOptions = new HttpServerOptions() .setAlpnVersions(Collections.unmodifiableList(Arrays.asList(HttpVersion.HTTP_1_1, HttpVersion.HTTP_2))) .setUseAlpn(true) .setHttp2ClearTextEnabled(true); HttpServer httpServer = vertx.createHttpServer(httpServerOptions); HttpClientOptions httpClientOptions = new HttpClientOptions() .setTrustAll(true) .setVerifyHost(false) .setUseAlpn(true) .setMaxPoolSize(1) .setProtocolVersion(HttpVersion.HTTP_2); HttpClient httpClient = vertx.createHttpClient(httpClientOptions);
startServer(vertx, httpServer); sendHttp(vertx, httpClient, "http://localhost:8080/slow"); sendHttp(vertx, httpClient, "http://localhost:8080/slow"); sendHttp(vertx, httpClient, "http://localhost:8080/fast");
LockSupport.park(); } }
|
HTTP2的多路复用确实是一个大升级,解决了HTTP1.1的队头阻塞问题。但是所有主流浏览器只支持 HTTP/2 over TLS(h2)
,都不支持 HTTP/2 over cleartext TCP(h2c)
1.1.3 不同版本对包含响应体的标识
记录不同HTTP版本,都是如何通过响应头来判断是否有响应体的。
http1.0
http1.1
- Content-Length
- Transfer-Encoding: chunked
http2
- 不再依赖上述响应头,而是通过二进制的特定标识符来判断响应体是否结束。
1.2 Chrome浏览器抓包HTTP版本
默认情况下,浏览器抓包是没有协议版本信息的。需要自己右键、勾选。如下图。

1.3 编写反向代理注意事项
在实现 HTTP 反向代理时,需要关注以下关键点,以确保请求和响应的正确转发:
- 移除逐跳标头(参考):逐跳标头(Hop-by-Hop Headers)仅适用于当前连接,不应在 请求头 和 响应头 中转发
- Connection
- TE
- Transfer-Encoding
- Keep-Alive
- Proxy-Authorization
- Proxy-Authentication
- Trailer
- Upgrade
- 判断请求体的存在(参考):通过请求头判断是否包含请求体
Content-Length
:指定请求体的长度,适用于小型请求。Transfer-Encoding: chunked
:采用分块传输,不需要预先计算请求体大小,适用于大文件等流式传输。Content-Length
和 Transfer-Encoding
不能同时存在,否则请求格式无效。
- 不应直接转发
Host
请求头:代理服务器应根据请求的目标动态设置 Host
头,而不是直接转发客户端的 Host
头 - 记录原始客户端信息:代理服务器需要在请求头中添加以下标头,以保留原始访问者信息:
X-Forwarded-For
:记录原始客户端的 IP 地址,以便后端服务器获取真实访问者信息。X-Forwarded-Proto
:指示原始请求使用的是 HTTP 还是 HTTPS。
- 重写
Location
响应头:当目标服务器返回 Location
头进行重定向时,代理服务器进行 URL 重写。 - 正确处理响应头
Content-Length
和 Transfer-Encoding
- HTTP/1.1
- 响应头必须包含
Content-Length
或 Transfer-Encoding
之一,否则客户端可能无法正确解析响应内容。
- HTTP/2
- 响应头 可以没有
Content-Length
和 Transfer-Encoding
,因为 HTTP/2 采用了 帧(frame) 机制进行数据传输,已经内置了流量控制和帧分块的能力
以上是我在实现 HTTP 反向代理时留意的关键点,更多细节可参考 What Proxies Must Do。
1.4 OkHttpClient示例
健壮的构建一个OkHttpClient,可以参考如下示例代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| import okhttp3.ConnectionPool; import okhttp3.OkHttpClient;
import javax.net.ssl.*; import java.security.SecureRandom; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.concurrent.TimeUnit;
public OkHttpClient okHttpClient() { OkHttpClient.Builder builder = new OkHttpClient().newBuilder() .retryOnConnectionFailure(true) .connectionPool(new ConnectionPool(5, 5, TimeUnit.MINUTES)) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS); builder.followRedirects(false); builder.followSslRedirects(false); builder.hostnameVerifier(new HostnameVerifier() { @Override public boolean verify(final String s, final SSLSession sslSession) { return true; }
@Override public final String toString() { return "NO_OP"; } }); try { X509TrustManager x509TrustManager = new X509TrustManager() { @Override public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
}
@Override public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
}
@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } }; TrustManager[] trustManagers = { x509TrustManager }; SSLContext sslContext = SSLContext.getInstance("SSL"); sslContext.init(null, trustManagers, new SecureRandom()); builder.sslSocketFactory(sslContext.getSocketFactory(), x509TrustManager); } catch (Exception ignore) {
} return builder.build(); }
|
1.5 SSE示例
下面提供使用springweb实现的sse示例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import javax.servlet.http.HttpServletResponse; import java.util.UUID;
@RestController public class SSEController {
@GetMapping("/api/sse") public ResponseBodyEmitter sse(HttpServletResponse response) { ResponseBodyEmitter emitter = new ResponseBodyEmitter(); response.setContentType("text/event-stream;charset=UTF-8"); final String id = UUID.randomUUID().toString(); new Thread(() -> { for (int i = 0; i < 10; i++) { try { emitter.send("event: message"); emitter.send("\n"); emitter.send("id: " + id); emitter.send("\n"); emitter.send("data: 现在时间是" + System.currentTimeMillis()); emitter.send("\n\n"); Thread.sleep(500); } catch (Exception e) { e.printStackTrace(); } } emitter.complete(); }).start(); return emitter; } }
|

二、性能比较
2.1 硬件配置
通用环境
服务器环境
客户端-Windows
- 硬件:
12C-32G
- IP:
10.0.0.1
- 用途: jmeter
网关-Linux
- 硬件:
2C-2G
- IP:
10.0.0.10
- 用途: gateway
后端-Linux
- 硬件:
2C-2G
- IP:
10.0.0.9
- 用途: backend
- backend1: 最大并发受硬件影响,业务10秒响应
- backend2: 最大并发受硬件影响,业务立即响应
获取局域网间带宽,可以使用iperf3这个工具。Linux通过yum -y install iperf3
进行安装,Windows直接去下载后安装。命令使用方式如下。
1 2 3 4
| iperf3 -s -p 80
iperf3 -c 10.0.0.10 -p 80
|
经测试,三台机器间的局域网带宽约为3Gbps。
2.1.1 Windows
Windows需要调整一下动态端口的范围。避免出现端口耗尽的情况。
1 2 3 4 5
| netsh int ipv4 show dynamicport tcp
netsh int ipv4 set dynamicport tcp start=1024 num=64512
|
2.1.2 Linux
Linux进行如下配置。
1.) 编辑/etc/sysctl.conf
,按照下述参数进行配置。修改后使用sysctl -p
生效, 使用sysctl -a
查看当前配置。
1 2 3 4 5 6
| fs.file-max=1100000
fs.nr_open=1050000
net.ipv4.ip_local_port_range = 1024 64512
|
2.) 编辑/etc/security/limits.conf
,按照下述参数进行配置。重新打开会话会生效。
1 2 3 4
|
root soft nofile 1010000 root hard nofile 1010000
|
Linux监控CPU/Memory使用率、并计算平均使用率的脚本如下。
该工具不够直观,建议使用Jmeter自带的服务器监测插件,参考JMeter压测HTTP接口示例 - 言成言成啊
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| #!/bin/bash
TOTAL_TIME=119
INTERVAL=1
CPU_LOG="cpu_usage.log" MEM_LOG="mem_usage.log"
> $CPU_LOG > $MEM_LOG
END_TIME=$((SECONDS + TOTAL_TIME)) while [ $SECONDS -lt $END_TIME ]; do CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4}') echo "CPU-$(date +"%Y-%m-%d %H:%M:%S"): $CPU_USAGE%" | tee -a $CPU_LOG
MEM_USAGE=$(free | awk '/Mem/{printf("%.2f%%", $3*100/$2 ) }') echo "Memory-$(date +"%Y-%m-%d %H:%M:%S"): $MEM_USAGE" | tee -a $MEM_LOG
echo "-------------------------------------------------------" sleep $INTERVAL done
AVG_CPU=$(awk -F': ' '{gsub(/%/, "", $2); sum+=$2} END {print sum/NR}' $CPU_LOG) echo "Average CPU Usage: $AVG_CPU%"
AVG_MEM=$(awk -F': ' '{gsub(/%/, "", $2); sum+=$2} END {print sum/NR}' $MEM_LOG) echo "Average Memory Usage: $AVG_MEM%"
|
2.2 压测方案
压测条件
- 并发:6000
- 持续:60秒
- 评测标准:网关的吞吐量越趋近于后端服务的吞吐量,网关性能越佳。
- 均开启keepalive

2.3 压测结果
性能方面,还是nginx更优!
此处直接记录结论了,毕竟也不是专业压测。单压backend,吞吐量可达2w,经过代理后,情况如下。
- nginx从头到尾,cpu使用率平稳,vertx会出现最初100%,后续平稳。6000的突发请求量。nginx可以一秒拉起,而Vertx则需要数秒(线程预热)。
- 关闭日志输出,可以显著提升性能。
- 默认配置下,nginx吞吐量在6500左右,vertx吞吐量在4500左右。针对keepalive进行配置优化后,nginx可达1.2w,vertx可达1.1w,但发现该情况下的配置,对于短连接又不友好。因此还是建议使用默认配置。
另外,vertx也提供了内置http反代,性能总体相比nginx,也略差。比我自己实现的要好点,因为我实现的内部有不少业务逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; import io.vertx.core.http.HttpClient; import io.vertx.core.http.HttpClientOptions; import io.vertx.core.http.HttpServerOptions; import io.vertx.core.http.PoolOptions; import io.vertx.httpproxy.HttpProxy;
public class App {
public static void main(String[] args) { VertxOptions options = new VertxOptions() .setEventLoopPoolSize(Runtime.getRuntime().availableProcessors() * 2); Vertx vertx = Vertx.vertx(options);
HttpClientOptions httpClientOptions = new HttpClientOptions() .setTrustAll(true) .setVerifyHost(false) .setKeepAlive(true) .setPipelining(true) .setMaxWaitQueueSize(20000); PoolOptions poolOptions = new PoolOptions() .setHttp1MaxSize(15000) .setHttp2MaxSize(2000);
HttpServerOptions httpServerOptions = new HttpServerOptions() .setAcceptBacklog(20000) .setTcpFastOpen(true) .setTcpNoDelay(true) .setReusePort(true);
HttpClient httpClient = vertx.createHttpClient(httpClientOptions, poolOptions); HttpProxy httpProxy = HttpProxy.reverseProxy(httpClient); httpProxy.origin(8080, "10.0.0.9"); vertx.createHttpServer(httpServerOptions).requestHandler(httpProxy).listen(8080); } }
|
2.4 优化点
2.4.1 DNS解析域名
我在测试时,发现使用 Vertx 编写的程序,总是在初次反代域名服务时特别慢。追踪溯源,发现是因为 Netty 内置的 DNS 解析是顺序解析的问题。
假如我配置了 DNS 解析超时为 60 秒,Netty 扫描机器发现有 4 个 DNS 服务器。那么他就会依次进行域名解析。如果第一个超时了,就会轮到下一个解析。如果前 3 个都不行,那么至少要耗费 3 分钟。
相比来说,直接使用 JDK InetAddress.getByName()
的效率就比 Netty 要高。这是因为 JDK 的 域名解析缓存来源于操作系统。而 Vertx 的域名解析缓存来源于应用。
我的解决办法,并没有操作 Vertx 的底层 API,而是添加了一套应用启动后异步的域名解析的预热逻辑。

2.4.2 String.replace效率低
我在使用火焰图进行性能分析的时候,发现某个方法耗时相对较长,而他的大部分时间,都集中在了 java 字符串的 replace 方法上面。
可以通过手写 replace 方法来提升性能,主要是使用 StringBuffer 来较少临时 String 实例数量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
public static String fastReplace(String text, String search, String replacement) { if (text == null || search == null || replacement == null || search.isEmpty()) { return text; } StringBuilder result = new StringBuilder(); int start = 0, index; while ((index = text.indexOf(search, start)) >= 0) { result.append(text, start, index).append(replacement); start = index + search.length(); } result.append(text.substring(start)); return result.toString(); }
|
在 500 万次 replace,性能提升接近 String.replace 方法的 8 倍。

2.4.3 GC的STW问题
在 Java 中,当程序发生 GC 时,就会出现 STW(Stop the World),JVM 会暂停所有的应用线程,只有 GC 线程在运行。
我测试压测 60 秒时,GC 时间约为 3 秒,如果可以优化的话,性能应该还能提一提。目前该问题尚未解决。