言成言成啊 | Kit Chen's Blog

手撕HTTP反向代理

发布于2024-11-13 22:00:36,更新于2025-05-30 12:11:12,标签:java http nginx  文章会持续修订,转载请注明来源地址:https://meethigher.top/blog

最近对自己手撕的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

  • 默认行为
    1. 默认使用短连接(即每次请求响应后断开 TCP 连接)。
  • 开启Keep-Alive
    1. 客户端请求头需添加:Connection: keep-alive
    2. 服务器响应头也需添加:Connection: keep-alive

HTTP/1.1

  • 默认行为

    1. 默认启用长连接(keep-alive)。
    2. 若服务器未返回 Connection: close,则表示使用长连接。
  • 关闭Keep-Alive

    1. 客户端请求头需添加:Connection: close
    2. 服务器返回头添加:Connection: close

HTTP/2

  • 默认行为
    1. 不再使用Connection标头,参考
    2. 长连接。

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()
// 服务端支持与客户端进行协商,使用h2c(HTTP/2 cleartext)
// 常规情况下,h2只在开启了tls使用。如果不开启tls,需要指定使用的是h2c
.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)
// httpclient支持与后端服务进行协商使用使用http2
.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

  • Content-Length

http1.1

  • Content-Length
  • Transfer-Encoding: chunked

http2

  • 不再依赖上述响应头,而是通过二进制的特定标识符来判断响应体是否结束。

1.2 Chrome浏览器抓包HTTP版本

默认情况下,浏览器抓包是没有协议版本信息的。需要自己右键、勾选。如下图。

1.3 编写反向代理注意事项

在实现 HTTP 反向代理时,需要关注以下关键点,以确保请求和响应的正确转发:

  1. 移除逐跳标头参考):逐跳标头(Hop-by-Hop Headers)仅适用于当前连接,不应在 请求头响应头 中转发
    • Connection
    • TE
    • Transfer-Encoding
    • Keep-Alive
    • Proxy-Authorization
    • Proxy-Authentication
    • Trailer
    • Upgrade
  2. 判断请求体的存在参考):通过请求头判断是否包含请求体
    • Content-Length:指定请求体的长度,适用于小型请求。
    • Transfer-Encoding: chunked:采用分块传输,不需要预先计算请求体大小,适用于大文件等流式传输。
    • Content-LengthTransfer-Encoding 不能同时存在,否则请求格式无效。
  3. 不应直接转发 Host 请求头:代理服务器应根据请求的目标动态设置 Host 头,而不是直接转发客户端的 Host
  4. 记录原始客户端信息:代理服务器需要在请求头中添加以下标头,以保留原始访问者信息:
    • X-Forwarded-For:记录原始客户端的 IP 地址,以便后端服务器获取真实访问者信息。
    • X-Forwarded-Proto:指示原始请求使用的是 HTTP 还是 HTTPS。
  5. 重写 Location 响应头:当目标服务器返回 Location 头进行重定向时,代理服务器进行 URL 重写。
  6. 正确处理响应头 Content-LengthTransfer-Encoding
    • HTTP/1.1
      • 响应头必须包含 Content-LengthTransfer-Encoding 之一,否则客户端可能无法正确解析响应内容。
    • HTTP/2
      • 响应头 可以没有 Content-LengthTransfer-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() {
// 服务器keepalive为60s,客户端keepalive为300s。客户端发起一个请求,然后等待60秒后,再发起一个请求,客户端遇到了java.io.EOFException: \n not found;limit=0 content=
// 参考https://github.com/square/okhttp/issues/2738
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);
// 忽略HTTPS主机名验证
builder.hostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(final String s, final SSLSession sslSession) {
return true;
}

@Override
public final String toString() {
return "NO_OP";
}
});
// 信任所有HTTPS证书,包括CA证书、自签名证书等
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());
// sse中,\n\n表示一条消息的结束
emitter.send("\n\n");
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
}
emitter.complete();
}).start();
return emitter;
}
}

二、性能比较

2.1 硬件配置

通用环境

  • java: 8
  • jmeter: 5.6.3

服务器环境

  • 客户端-Windows

    • 硬件: 12C-32G
    • IP: 10.0.0.1
    • 用途: jmeter
  • 网关-Linux

  • 后端-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

# 修改动态端口范围。start+num−1≤65535
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
# 修改用户进程可以打开的最大文件数 
# 请注意这个最大文件数不要大于单个进程可以打开的最大文件数fs.nr_open
root soft nofile 1010000 # 对应软限制, 可以通过ulimit -Sn查看
root hard nofile 1010000 # 对应硬限制, 可以通过ulimit -Hn查看

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 使用率
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

# 计算平均 CPU 使用率
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,经过代理后,情况如下。

  1. nginx从头到尾,cpu使用率平稳,vertx会出现最初100%,后续平稳。6000的突发请求量。nginx可以一秒拉起,而Vertx则需要数秒(线程预热)。
  2. 关闭日志输出,可以显著提升性能。
  3. 默认配置下,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) {
// 配置 Vertx 实例
// 设置事件循环线程池大小为 CPU 核心数的 倍数,适用于 I/O 密集型任务
VertxOptions options = new VertxOptions()
.setEventLoopPoolSize(Runtime.getRuntime().availableProcessors() * 2);
Vertx vertx = Vertx.vertx(options);

// 配置 HTTP 客户端
HttpClientOptions httpClientOptions = new HttpClientOptions()
.setTrustAll(true) // 信任所有 HTTPS 证书(适用于内网或调试环境)
.setVerifyHost(false) // 关闭 HTTPS 域名校验(适用于自签名证书)
.setKeepAlive(true) // 启用 keep-alive 连接,减少 TCP 连接建立的开销
.setPipelining(true) // 启用 HTTP 1.1 请求管道化,提高请求响应效率(仅 HTTP 1.1 生效)
.setMaxWaitQueueSize(20000); // 最大等待队列 10000,超出会拒绝请求
// 配置连接池选项
PoolOptions poolOptions = new PoolOptions()
.setHttp1MaxSize(15000) // HTTP 1.x 连接池最大连接数
.setHttp2MaxSize(2000); // HTTP/2 连接池最大连接数

// 配置 HTTP 服务器
HttpServerOptions httpServerOptions = new HttpServerOptions()
.setAcceptBacklog(20000) // TCP 监听队列(backlog)大小,防止连接被拒绝
.setTcpFastOpen(true) // 启用 TCP Fast Open,减少 TCP 三次握手的延迟
.setTcpNoDelay(true) // 禁用 Nagle 算法,降低 HTTP 响应延迟
.setReusePort(true); // 允许多个进程或线程绑定到同一端口,提高多核并发能力

// vertx提供的简易http反代
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
/**
* 在压测时,性能比String.replace略优
*/
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 秒,如果可以优化的话,性能应该还能提一提。目前该问题尚未解决。

发布:2024-11-13 22:00:36
修改:2025-05-30 12:11:12
链接:https://meethigher.top/blog/2024/http-proxy-boot/
标签:java http nginx 
付款码 打赏 分享
Shift+Ctrl+1 可控制工具栏