100个HTTP请求到底开多少TCP连接?协议、连接池与并发模型深度解析
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.1keep-alive串行复用1到N个取决于并发度同一连接上请求串行HTTP/2多路复用1个同域名一个连接跑所有请求HTTP/3QUIC多路复用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_connections10, # 连接池数量按host pool_maxsize10, # 每个池最大连接数 max_retries3 ) session.mount(http://, adapter) session.mount(https://, adapter)pool_maxsize10意味着对同一个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(bOK) 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_workers100) as executor: results list(executor.map(fetch, range(100))) print(fAll {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_connections1, pool_maxsize1) session.mount(http://, adapter) def fetch(i): r session.get(http://127.0.0.1:8080/test) return r.status_code with ThreadPoolExecutor(max_workers100) as executor: results list(executor.map(fetch, range(100))) print(fAll {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_connections100, pool_maxsize100) session.mount(http://, adapter) def fetch(i): r session.get(http://127.0.0.1:8080/test) return r.status_code with ThreadPoolExecutor(max_workers100) as executor: results list(executor.map(fetch, range(100))) print(fAll {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(http2True) as client: tasks [client.get(https://127.0.0.1:8443/test) for _ in range(100)] responses await asyncio.gather(*tasks) print(fAll {len(responses)} requests done) asyncio.run(main())在HTTP/2下100个请求通过1个TCP连接并发发送。服务端只会记录到1个源端口。这就是多路复用的威力。4.6 实测数据汇总场景连接池配置协议实测TCP连接数总耗时相对值默认requestspool_maxsize10HTTP/1.1101.0x单连接pool_maxsize1HTTP/1.113.5x全并发pool_maxsize100HTTP/1.1~1000.4xHTTP/2不适用HTTP/210.3x这个表格很直观地说明了问题连接数不是固定的而是由你的配置决定的。而且连接数越多并发度越高总耗时越短——但代价是更多的资源消耗。提示实际测试中由于网络延迟、服务端处理速度等因素数据会有波动。建议在自己的环境里多跑几次取平均值。5. 连接数背后的代价为什么不能无脑开100个连接5.1 每个TCP连接的系统开销建立一个TCP连接不是没有成本的。从操作系统层面看每个连接都需要一个文件描述符fdLinux默认单进程上限是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重传增多最后把连接池调回50QPS反而回到了峰值。这个教训说明连接池大小要跟服务端的处理能力匹配不是越大越好。6. 线上排查实战怎么确认实际建立了多少TCP连接6.1 用ss命令查看连接状态Linux上查看TCP连接最常用的命令是ss# 查看所有到指定端口的TCP连接 ss -tn dst :8080 # 统计各状态的连接数 ss -tn dst :8080 | awk NR1 {print $1} | sort | uniq -c # 查看某个进程的所有TCP连接 ss -tnp | grep pidss比老旧的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, proxiesNone): conn super().get_connection(url, proxies) self.connection_count 1 return conn session requests.Session() adapter CountingAdapter(pool_maxsize10) session.mount(http://, adapter) # ... 发请求 ... print(fTotal 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 -nss -s增大fd上限扩大端口范围注意CLOSE_WAIT堆积通常是自己代码的问题——收到了服务端的FIN但没有调用close。这种情况在用了连接池但忘记释放响应对象时特别常见。7. 不同语言和框架的默认行为对比7.1 Python生态requests库底层是urllib3默认pool_connections10、pool_maxsize10。注意pool_connections是池的数量按hostpool_maxsize是每个池的最大连接数。aiohttp是异步的默认连接池大小是100。可以通过TCPConnector的limit参数调整connector aiohttp.TCPConnector(limit50, limit_per_host10) session aiohttp.ClientSession(connectorconnector)httpx支持HTTP/2默认连接池大小是100可以通过Limits调整limits httpx.Limits(max_connections50, max_keepalive_connections10) client httpx.Client(limitslimits, http2True)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默认Transportvar 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 浏览器行为浏览器对同域名的并发连接限制浏览器每域名最大连接数Chrome6Firefox6Safari6Edge6HTTP/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里如果用了streamTrue但没有调用response.close()或读取完响应体连接就不会被释放回池子。时间长了连接池耗尽新请求只能新建连接。# 危险做法 r session.get(url, streamTrue) # 没有读取r.content也没有r.close() # 安全做法 with session.get(url, streamTrue) 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_reuse1 sysctl -w net.ipv4.tcp_max_tw_buckets50000但更好的做法是从应用层开启keep-alive减少短连接。9. 回到最初的问题我的答案是什么如果现在有人问我“同时发起100个HTTP请求总共会建立多少个TCP连接”我会这样回答这取决于四个条件协议版本、域名分布、客户端连接池配置、并发模型。在HTTP/1.1下如果100个请求打同一个域名客户端连接池上限是N那实际连接数是min(100, N)。在HTTP/2下同域名通常就是1个连接。如果域名分散在M个域名上那连接数还要乘以域名的影响。但更重要的是这个问题真正考察的不是你能不能背出一个数字而是你知不知道连接数是可以控制的以及怎么控制。在实际项目里我通常会根据服务端的承载能力和客户端的资源情况把连接池调到一个合理的值然后用压测验证。这个值可能是10可能是50也可能是100但一定不是拍脑袋决定的。最后分享一个我在实际项目中总结的经验连接池大小应该设置为“服务端能舒适处理的并发数”而不是“客户端想发多少就发多少”。客户端无限制地建连接最终只会把服务端打垮然后大家一起变慢。控制连接数本质上是在做流量整形和资源保护。这个思路比记住任何一个具体数字都有用。