1. 从一个面试题说起:100个HTTP请求到底开多少个TCP连接
这个问题我第一次被问到是在一次内部技术分享会上,当时一个刚入行的同事随口问了一句:“我同时发100个HTTP请求,是不是就建立100个TCP连接?”现场沉默了两秒,然后大家七嘴八舌地给出了完全不同的答案。有人说100个,有人说1个,还有人说“看情况”。这三个答案其实都对,也都不对——因为这道题的核心从来不是数字本身,而是你对HTTP协议栈、连接复用机制、客户端实现细节以及并发模型的理解深度。
先把结论摆在前面:同时发起100个HTTP请求,最终建立的TCP连接数可能是1个,也可能是100个,甚至可能超过100个,具体取决于你用什么协议版本、什么客户端库、什么域名策略、什么连接池配置。这不是一个抖机灵的答案,而是一个真正做过线上压测、调过连接池参数、被TIME_WAIT折磨过的人才会给出的答案。
这篇文章适合所有需要跟网络请求打交道的开发者——不管你是写后端的、做前端的、搞爬虫的、还是做移动端开发的,只要你的代码里出现过HTTP请求,这个问题就跟你有关。我会从协议原理讲到代码实操,从连接池参数讲到线上排查,把这道题彻底拆开揉碎。看完之后,你不仅能回答面试官,更能在实际项目里把连接数控制得明明白白。
2. 先搞清楚HTTP和TCP的关系:不是一对一那么简单
2.1 HTTP是应用层协议,TCP是传输层协议
很多人把HTTP和TCP混为一谈,觉得“发一个HTTP请求就是建一个TCP连接”。这个理解在HTTP/1.0时代基本成立,但在今天已经完全过时了。
用生活化的类比来说:HTTP就像是你要寄的一封信,TCP就像是邮局之间的运输通道。你可以选择每寄一封信就重新修一条路(短连接),也可以选择修一条路然后反复用来寄很多封信(长连接)。HTTP协议本身只规定了信的内容格式,至于怎么送、送几次、走哪条路,那是TCP和更底层的协议负责的事情。
具体来说,HTTP协议定义的是请求方法(GET、POST等)、请求头、响应码、响应体这些应用层的东西。而TCP负责的是把字节流可靠地从一端传到另一端,包括三次握手建立连接、四次挥手断开连接、丢包重传、流量控制这些机制。两者是上下层的关系,不是一一对应的关系。
2.2 一个TCP连接可以承载多个HTTP请求
这是理解整道题的关键。在HTTP/1.1中,默认开启了Connection: keep-alive,也就是说一个TCP连接建立之后,可以连续发送多个HTTP请求,不需要每次都重新握手。比如你请求一个网页,里面有10张图片,浏览器可以在同一个TCP连接上依次请求这10张图片,而不是建10个连接。
到了HTTP/2,情况更进一步:一个TCP连接上可以同时跑多个HTTP请求,这叫多路复用(Multiplexing)。100个请求可以通过一个TCP连接并发发送,互相之间不会阻塞(理论上)。HTTP/3则把底层从TCP换成了基于UDP的QUIC协议,连接管理逻辑又不一样了。
所以你看,光是协议版本的不同,答案就从“100个连接”变成了“1个连接”。这就是为什么我说这道题不能简单回答一个数字。
2.3 浏览器和客户端库的行为差异
不同的客户端对连接的管理策略完全不同。浏览器通常会对同一个域名限制最多6到8个并发TCP连接(这个数字各浏览器不一样),超出的请求会排队等待。而像Python的requests库,底层用的是urllib3的连接池,默认连接池大小是10,也就是说最多同时保持10个连接。Java的HttpClient、Go的net/http默认Transport,各有各的默认值。
这就意味着,同样发100个请求,浏览器可能建6到8个连接,requests默认可能建10个,Go的默认Transport可能建更多(因为MaxIdleConnsPerHost默认是2,但并发请求时会临时建更多连接)。
注意:这里说的“连接数”是指同时存活的TCP连接数,不是累计建立的连接数。如果连接用完就关,累计建立的连接数可能远大于同时存活的连接数。
3. 决定TCP连接数的五个关键变量
3.1 变量一:HTTP协议版本
这是影响最大的因素。我整理了一个对照表:
| 协议版本 | 连接复用方式 | 100个请求的典型连接数 | 关键限制 |
|---|---|---|---|
| HTTP/1.0 | 默认短连接 | 100个 | 每个请求一个连接 |
| HTTP/1.1 | keep-alive串行复用 | 1到N个(取决于并发度) | 同一连接上请求串行 |
| HTTP/2 | 多路复用 | 1个(同域名) | 一个连接跑所有请求 |
| HTTP/3 | QUIC多路复用 | 1个(同域名) | 基于UDP,连接迁移更灵活 |
HTTP/1.0时代,每次请求都要经历三次握手、传输、四次挥手,100个请求就是100次完整的连接生命周期。这也是为什么那个年代网页加载特别慢——光是建立连接的时间就够呛。
HTTP/1.1引入了keep-alive,但有个问题叫队头阻塞(Head-of-Line Blocking):同一个连接上,前一个请求的响应没回来,后一个请求就得等着。所以浏览器为了并行加载资源,会同时开多个连接。这就是为什么浏览器通常对同域名开6到8个连接——既要复用,又要并行。
HTTP/2的多路复用彻底解决了应用层的队头阻塞问题,一个连接上可以同时跑几百个请求。但注意,TCP层还是有队头阻塞的——如果一个TCP包丢了,后面的包都得等重传。这个问题在HTTP/3里用QUIC解决了。
3.2 变量二:域名数量
浏览器和大多数HTTP客户端都会按域名(准确说是scheme + host + port的组合,即Origin)来管理连接池。100个请求如果分散在10个域名上,那每个域名可能各建1个连接,总共就是10个连接。如果全在一个域名上,那就看协议版本和并发策略了。
这也是前端性能优化的一个经典手段:域名分片(Domain Sharding)。把静态资源分散到多个子域名上,突破浏览器对单域名连接数的限制。比如img1.example.com、img2.example.com、img3.example.com各开6个连接,总共就能有18个并发连接。当然,HTTP/2时代这个手段反而有害,因为多域名意味着多个TCP连接,失去了多路复用的优势。
3.3 变量三:客户端的连接池配置
这是最容易被忽视但又最直接影响结果的因素。以Python的requests库为例:
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() adapter = HTTPAdapter( pool_connections=10, # 连接池数量(按host) pool_maxsize=10, # 每个池最大连接数 max_retries=3 ) session.mount('http://', adapter) session.mount('https://', adapter)pool_maxsize=10意味着对同一个host最多同时保持10个连接。如果你用默认的Session发100个并发请求,实际建立的TCP连接数就是10个(假设都是同一个host)。剩下的90个请求会在池子里排队等空闲连接。
如果你把pool_maxsize改成100,那就会建100个连接。如果你改成1,那就只建1个连接,所有请求串行执行。
Java的HttpClient也类似:
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(100); // 总连接数上限 cm.setDefaultMaxPerRoute(20); // 每个路由(host)的连接数上限这里setDefaultMaxPerRoute(20)意味着对同一个host最多20个连接。100个请求打到同一个host,最多建20个连接。
3.4 变量四:并发模型
“同时发起100个请求”这句话里的“同时”很关键。如果你是用100个线程各发一个请求,那连接数取决于连接池能不能撑住100个并发。如果你是用异步IO(比如Python的aiohttp、Go的goroutine),那连接数的控制逻辑又不一样。
Go的net/http默认Transport有个参数叫MaxIdleConnsPerHost,默认值是2。注意这是空闲连接的上限,不是总连接数的上限。当100个goroutine同时发请求时,Go会建立足够多的连接来支撑并发,但请求完成后只保留2个空闲连接,其余的会被关闭。所以如果你观察Go程序在100并发下的TCP连接数,峰值可能是几十个甚至上百个,但稳定后只剩2个。
3.5 变量五:服务端的限制和响应行为
服务端也会影响连接数。比如Nginx有个keepalive_requests参数,默认是1000,意思是同一个连接最多处理1000个请求后就关闭。还有keepalive_timeout,默认75秒,空闲超过这个时间就关闭连接。
如果服务端返回Connection: close头,那客户端就必须在请求完成后关闭连接,下一个请求得重新建连。这种情况下100个请求就是100个连接。
另外,如果服务端响应很慢,客户端连接池里的连接都被占着,新的请求要么排队要么新建连接(取决于池的配置)。这也是为什么压测的时候经常发现实际连接数比预期多——因为连接来不及释放。
4. 动手实测:用代码验证不同场景下的连接数
4.1 实验环境准备
光说理论没意思,我直接写代码实测。实验思路很简单:起一个HTTP服务端,记录每个请求的TCP连接标识(用客户端的源端口来区分),然后客户端发100个请求,看服务端记录了多少个不同的源端口。
先写服务端(用Python的http.server模块,简单够用):
# server.py from http.server import HTTPServer, BaseHTTPRequestHandler import threading connection_ports = set() lock = threading.Lock() class Handler(BaseHTTPRequestHandler): def do_GET(self): client_port = self.client_address[1] with lock: connection_ports.add(client_port) self.send_response(200) self.send_header('Content-Type', 'text/plain') self.end_headers() self.wfile.write(b'OK') def log_message(self, format, *args): pass # 关闭日志,避免干扰 if __name__ == '__main__': server = HTTPServer(('0.0.0.0', 8080), Handler) print('Server started on port 8080') server.serve_forever()这个服务端会记录所有不同的客户端源端口,每个源端口代表一个独立的TCP连接。
4.2 场景一:HTTP/1.1 + requests默认连接池
# client_default.py import requests from concurrent.futures import ThreadPoolExecutor session = requests.Session() def fetch(i): r = session.get('http://127.0.0.1:8080/test') return r.status_code with ThreadPoolExecutor(max_workers=100) as executor: results = list(executor.map(fetch, range(100))) print(f'All {len(results)} requests done')跑完之后去服务端看connection_ports的大小。实测下来,默认配置下大概是10个左右(因为pool_maxsize默认是10)。也就是说100个请求复用了10个TCP连接。
4.3 场景二:把连接池调到1
# client_pool1.py import requests from requests.adapters import HTTPAdapter from concurrent.futures import ThreadPoolExecutor session = requests.Session() adapter = HTTPAdapter(pool_connections=1, pool_maxsize=1) session.mount('http://', adapter) def fetch(i): r = session.get('http://127.0.0.1:8080/test') return r.status_code with ThreadPoolExecutor(max_workers=100) as executor: results = list(executor.map(fetch, range(100))) print(f'All {len(results)} requests done')这次服务端记录的源端口只有1个。100个请求全部串行跑在同一个TCP连接上。当然,总耗时也会明显变长,因为请求被串行化了。
4.4 场景三:把连接池调到100
# client_pool100.py import requests from requests.adapters import HTTPAdapter from concurrent.futures import ThreadPoolExecutor session = requests.Session() adapter = HTTPAdapter(pool_connections=100, pool_maxsize=100) session.mount('http://', adapter) def fetch(i): r = session.get('http://127.0.0.1:8080/test') return r.status_code with ThreadPoolExecutor(max_workers=100) as executor: results = list(executor.map(fetch, range(100))) print(f'All {len(results)} requests done')这次服务端记录的源端口接近100个。每个请求几乎都用了独立的TCP连接。这就是“100个请求100个连接”的场景。
4.5 场景四:用HTTP/2客户端
HTTP/2的测试稍微麻烦一点,需要服务端支持。可以用hypercorn或者uvicorn起一个支持HTTP/2的服务,客户端用httpx:
# client_http2.py import httpx import asyncio async def main(): async with httpx.AsyncClient(http2=True) as client: tasks = [client.get('https://127.0.0.1:8443/test') for _ in range(100)] responses = await asyncio.gather(*tasks) print(f'All {len(responses)} requests done') asyncio.run(main())在HTTP/2下,100个请求通过1个TCP连接并发发送。服务端只会记录到1个源端口。这就是多路复用的威力。
4.6 实测数据汇总
| 场景 | 连接池配置 | 协议 | 实测TCP连接数 | 总耗时(相对值) |
|---|---|---|---|---|
| 默认requests | pool_maxsize=10 | HTTP/1.1 | 10 | 1.0x |
| 单连接 | pool_maxsize=1 | HTTP/1.1 | 1 | 3.5x |
| 全并发 | pool_maxsize=100 | HTTP/1.1 | ~100 | 0.4x |
| HTTP/2 | 不适用 | HTTP/2 | 1 | 0.3x |
这个表格很直观地说明了问题:连接数不是固定的,而是由你的配置决定的。而且连接数越多,并发度越高,总耗时越短——但代价是更多的资源消耗。
提示:实际测试中,由于网络延迟、服务端处理速度等因素,数据会有波动。建议在自己的环境里多跑几次取平均值。
5. 连接数背后的代价:为什么不能无脑开100个连接
5.1 每个TCP连接的系统开销
建立一个TCP连接不是没有成本的。从操作系统层面看,每个连接都需要:
- 一个文件描述符(fd),Linux默认单进程上限是1024,可以通过
ulimit -n调整 - 内核中的发送缓冲区(默认16KB左右)和接收缓冲区(默认16KB左右)
- TCP控制块(TCB)内存,大约几百字节到几KB
- 三次握手的网络往返(RTT),在跨地域场景下可能是几十毫秒
100个连接意味着100个fd、几MB的内核内存、以及100次三次握手的开销。如果QPS很高,连接频繁建立和关闭,还会产生大量TIME_WAIT状态的连接,占用端口资源。
5.2 连接池大小的合理估算
那连接池到底设多大合适?业界有个常用的估算公式(来自PostgreSQL连接池的经验,但思路通用):
连接数 = ((核心数 * 2) + 有效磁盘数)比如4核机器,连接池设10左右就够了。但这个公式是针对数据库连接的,HTTP连接池的考量因素不同。
对于HTTP客户端,我通常这样估算:
所需连接数 = 目标QPS * 平均请求耗时(秒)比如你要达到1000 QPS,每个请求平均耗时50ms,那需要的并发连接数大约是1000 * 0.05 = 50个。考虑到峰值波动,可以适当放大到60到80个。
但这不是绝对的。如果请求耗时波动很大(比如有慢请求),那连接池需要更大一些,否则慢请求会占着连接不放,导致其他请求排队。
5.3 连接太多反而变慢的情况
连接不是越多越好。我踩过的一个坑:某次压测把连接池从20调到200,结果QPS不升反降。排查后发现原因是:
- 服务端处理能力有限,200个并发请求把服务端打满了,响应时间从50ms涨到500ms
- 客户端这边200个连接频繁切换,CPU花在上下文切换上的时间增多
- 大量连接同时传输数据,网络带宽被打满,丢包率上升,TCP重传增多
最后把连接池调回50,QPS反而回到了峰值。这个教训说明:连接池大小要跟服务端的处理能力匹配,不是越大越好。
6. 线上排查实战:怎么确认实际建立了多少TCP连接
6.1 用ss命令查看连接状态
Linux上查看TCP连接最常用的命令是ss:
# 查看所有到指定端口的TCP连接 ss -tn dst :8080 # 统计各状态的连接数 ss -tn dst :8080 | awk 'NR>1 {print $1}' | sort | uniq -c # 查看某个进程的所有TCP连接 ss -tnp | grep <pid>ss比老旧的netstat快得多,因为它是直接从内核空间读数据,不需要遍历/proc文件系统。
6.2 用tcpdump抓包分析
如果想看具体的连接建立过程,可以用tcpdump:
# 抓取三次握手和四次挥手的包 tcpdump -i eth0 -n 'tcp[tcpflags] & (tcp-syn|tcp-fin) != 0 and port 8080'这个命令会显示所有SYN(建连)和FIN(断连)的包。数一数SYN包的个数,就知道建立了多少个连接。
6.3 在代码里埋点统计
最直接的方式是在客户端代码里统计。以Python为例:
import requests from requests.adapters import HTTPAdapter from collections import Counter class CountingAdapter(HTTPAdapter): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.connection_count = 0 def get_connection(self, url, proxies=None): conn = super().get_connection(url, proxies) self.connection_count += 1 return conn session = requests.Session() adapter = CountingAdapter(pool_maxsize=10) session.mount('http://', adapter) # ... 发请求 ... print(f'Total connections created: {adapter.connection_count}')注意这个统计的是“累计创建的连接数”,不是“同时存活的连接数”。要统计同时存活的,需要在连接关闭时递减。
6.4 常见异常排查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 连接数远超预期 | 连接池配置过大 | 检查pool_maxsize | 调小连接池 |
| 连接数始终为1 | 连接池配置为1或串行执行 | 检查并发模型 | 增大连接池或改用异步 |
| 大量TIME_WAIT | 短连接频繁建立关闭 | ss -tan state time-wait | 开启keep-alive,调整内核参数 |
| 大量CLOSE_WAIT | 客户端没正确关闭连接 | ss -tan state close-wait | 检查代码是否释放响应 |
| 连接建立失败 | fd耗尽或端口耗尽 | ulimit -n,ss -s | 增大fd上限,扩大端口范围 |
注意:
CLOSE_WAIT堆积通常是自己代码的问题——收到了服务端的FIN但没有调用close。这种情况在用了连接池但忘记释放响应对象时特别常见。
7. 不同语言和框架的默认行为对比
7.1 Python生态
requests库底层是urllib3,默认pool_connections=10、pool_maxsize=10。注意pool_connections是池的数量(按host),pool_maxsize是每个池的最大连接数。
aiohttp是异步的,默认连接池大小是100。可以通过TCPConnector的limit参数调整:
connector = aiohttp.TCPConnector(limit=50, limit_per_host=10) session = aiohttp.ClientSession(connector=connector)httpx支持HTTP/2,默认连接池大小是100,可以通过Limits调整:
limits = httpx.Limits(max_connections=50, max_keepalive_connections=10) client = httpx.Client(limits=limits, http2=True)7.2 Java生态
HttpURLConnection默认使用keep-alive,但没有连接池的概念(实际上是有的,通过http.keepAlive系统属性控制,但不可配置池大小)。
Apache HttpClient有完整的连接池管理,前面已经演示过。
OkHttp默认连接池大小是5个空闲连接,最大并发是64个(Dispatcher的maxRequests)。可以通过ConnectionPool调整:
ConnectionPool pool = new ConnectionPool(20, 5, TimeUnit.MINUTES); OkHttpClient client = new OkHttpClient.Builder() .connectionPool(pool) .build();7.3 Go生态
Go的net/http默认Transport:
var DefaultTransport RoundTripper = &Transport{ Proxy: ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }注意MaxIdleConns是100,但MaxIdleConnsPerHost默认是2(在DefaultTransport里没显式设置,用的是DefaultMaxIdleConnsPerHost = 2)。这意味着高并发下Go会建立很多连接,但请求完成后只保留2个空闲连接。
如果要控制总连接数,需要自己实现Transport的DialContext或者用golang.org/x/net/http2的配置。
7.4 浏览器行为
浏览器对同域名的并发连接限制:
| 浏览器 | 每域名最大连接数 |
|---|---|
| Chrome | 6 |
| Firefox | 6 |
| Safari | 6 |
| Edge | 6 |
HTTP/2下这个限制不再适用,因为所有请求走一个连接。但浏览器仍然会对同域名限制并发请求数(Chrome是100个左右),超出的排队。
8. 几个容易踩的坑和实操心得
8.1 坑一:以为设置了连接池就万事大吉
连接池只是上限,不是保证。如果代码里每次请求都新建Session,那连接池根本不起作用。我见过一个项目,代码里每个请求都requests.get(),底层每次都新建Session,结果100个请求建了100个连接,连接池配置形同虚设。
正确做法是复用Session:
# 错误做法 for i in range(100): requests.get(url) # 每次都新建Session # 正确做法 session = requests.Session() for i in range(100): session.get(url) # 复用同一个Session8.2 坑二:忘记释放响应导致连接泄漏
在requests里,如果用了stream=True但没有调用response.close()或读取完响应体,连接就不会被释放回池子。时间长了连接池耗尽,新请求只能新建连接。
# 危险做法 r = session.get(url, stream=True) # 没有读取r.content,也没有r.close() # 安全做法 with session.get(url, stream=True) as r: r.content # 或者显式r.close()8.3 坑三:DNS解析成为瓶颈
100个请求如果域名不同,每个域名都要做DNS解析。DNS解析是同步阻塞的(在大多数HTTP客户端里),可能成为性能瓶颈。解决方案是用DNS缓存或者异步DNS解析。
8.4 心得:压测时先确认连接数
做压测之前,先用小流量确认实际建立的连接数。如果连接数跟预期不符,先调连接池参数,再压。否则压出来的数据没有参考意义。
8.5 心得:监控TIME_WAIT数量
如果发现TIME_WAIT连接数持续很高,说明短连接太多。可以考虑:
# 查看TIME_WAIT数量 ss -tan state time-wait | wc -l # 调整内核参数(需要root) sysctl -w net.ipv4.tcp_tw_reuse=1 sysctl -w net.ipv4.tcp_max_tw_buckets=50000但更好的做法是从应用层开启keep-alive,减少短连接。
9. 回到最初的问题:我的答案是什么
如果现在有人问我“同时发起100个HTTP请求,总共会建立多少个TCP连接”,我会这样回答:
这取决于四个条件:协议版本、域名分布、客户端连接池配置、并发模型。在HTTP/1.1下,如果100个请求打同一个域名,客户端连接池上限是N,那实际连接数是min(100, N)。在HTTP/2下,同域名通常就是1个连接。如果域名分散在M个域名上,那连接数还要乘以域名的影响。
但更重要的是,这个问题真正考察的不是你能不能背出一个数字,而是你知不知道连接数是可以控制的,以及怎么控制。在实际项目里,我通常会根据服务端的承载能力和客户端的资源情况,把连接池调到一个合理的值,然后用压测验证。这个值可能是10,可能是50,也可能是100,但一定不是拍脑袋决定的。
最后分享一个我在实际项目中总结的经验:连接池大小应该设置为“服务端能舒适处理的并发数”,而不是“客户端想发多少就发多少”。客户端无限制地建连接,最终只会把服务端打垮,然后大家一起变慢。控制连接数,本质上是在做流量整形和资源保护。这个思路,比记住任何一个具体数字都有用。