TCP协议全解:从三次握手到拥塞控制,页面文件如何完整送达浏览器 页面文件能不能完整送到浏览器这件事的兜底责任基本都落在TCP协议身上。前几天我排查一个线上页面偶尔打不开的问题查完了服务器CPU、内存、磁盘和后端日志一切正常最后抓包才知道问题出在TCP层的握手请求大量超时重传服务端的连接队列被占满了。TCP藏在HTTP底下平时没人看它一眼但一出问题整个页面可能就卡死。这篇是这个系列的下一篇我把TCP协议如何保证页面文件完整送达浏览器这件事彻底讲透。不聊那些空洞的概念就讲三次握手的真实意义、数据是怎么切成一小块一小块运过去的、确认和重传是怎么兜底的、流量控制和拥塞控制又是怎么在“快”和“稳”之间做权衡最后教你在浏览器开发者工具里直接看到TCP的工作痕迹。想搞懂浏览器请求链路的前端开发者、刚摸到网络基础的后端新人或者只是想知道网页加载慢到底谁背锅的人这篇都适合。1. 页面文件送达浏览器为什么必须靠TCP兜底1.1 一段HTML的旅程从服务器到浏览器的“非官方路线”在浏览器地址栏输入一个网址、按下回车你的HTML文件是这样被送到的服务器操作系统把文件读出来交给网络协议栈切成一个个数据包经过服务器网卡出去穿过交换机、路由器可能还要经过几段光纤和运营商网络最后抵达你本机的网卡再一路向上提交给浏览器。这条链路非常长沿途的每个设备都可能成为一个“坑”路由器的缓存满了会直接丢包交换机的瞬断会让数据包走岔路线路噪声会把数据包的内容改坏。你可以把整个过程想象成快递运输大件家具。快递公司不会把一整件家具塞进一辆车里运走而是把它拆成好几个包裹分别装车、分别运输。问题是这些包裹可能走不同的路、在不同的中转站停留甚至有一个在中途丢了。如果收件人拿到一堆残破的零件能拼出一个完整家具吗大概率不行。互联网的底层IP协议就是这个“不太靠谱的快递公司”它只承诺尽量把每个数据包送到目的地但不保证不丢、不重复、不按顺序。数据包到达的顺序和发送顺序完全不同也都是常有的事。所以必须在IP之上加一层东西专门负责“把家具完整拼好再交付”。这就是TCP存在的原因。1.2 HTTP只管写地址TCP才管送货上门很多人把HTTP和TCP混在一起其实它们的职责分得非常清楚。HTTP是应用层协议浏览器用它发出“我要GET这个页面”的请求服务器用HTTP格式把HTML响应回来。HTTP本身没有任何机制去确认文件到底有没有完整到达——它甚至不会在数据丢失后主动重发。数据能否完整到达这件事HTTP完全交给了下层TCP。可以这样类比HTTP是快递单上面写了“我要这个货”IP是快递地址决定了包裹往哪里走TCP才是那个押运员负责清点货物、编号打包、跟踪每一个包裹少了哪个都得补送最后确认全部到齐才签字交付。HTTPS里的TLS协议也一样它在TCP连接建立之后才加密数据相当于给押运车加了一把锁但押运这件事本身仍然是TCP在做。这个分层设计不是随便定的而是历史沉淀下来的最优解应用层可以专注于语义不必操心传输细节传输层专注于可靠性不必关心网页里写的是什么。浏览器之所以能拿着完整页面数据去解析渲染正是因为它从socket里读到的字节流已经被TCP整理得干干净净。1.3 数据为什么要先“切块”MTU与MSS既然要可靠运输为什么不能把整个页面文件塞进一个数据包里直接运过去答案很简单底层网络根本装不下。以最常见的以太网为例单个数据帧的最大负载通常是1500字节这个值叫MTU。一个数据帧像一个标准的快递车厢大小是固定的。TCP要把应用层数据装进这个车厢就得先给数据加上TCP头部至少20字节再交给IP加上IP头部至少20字节。算下来TCP在每一段数据里最多能放1460字节的业务数据这个数值就是MSS。我随便算一个数给你看假设你的网页主文档加内联样式、脚本一共约600KB也就是614400字节。用MSS为1460字节去切需要614400 / 1460大约421个TCP段每个段加上TCP头和IP头后在网络上形成421个独立的数据包。TCP把这421个包全部安全送达浏览器才能拿到完整的614400字节文件。少任何一个字节页面都渲染不了。所以TCP的朴素思路是把一个“大文件到达”的问题转化成几百个“小数据段到达”的问题。每一段单独编号、单独确认、单独补发最后在接收方按序号重新拼成完整文件。明白这点接下来讲的序号、确认、重传就全通了。2. 三次握手连接建立时双方到底在对什么暗号2.1 三次握手的完整过程与报文内容浏览器想下载页面第一步不是发HTTP请求而是先和服务器建立一条TCP连接。这条连接不是直接拉根线而是双方通过三次握手确认“你可以发送、我可以接收”才开工。具体三步是这样的步骤发送方向报文类型包含的信息含义1浏览器 - 服务器SYN初始序号 seqx“我想建立连接我的起始字节序号是x”2服务器 - 浏览器SYNACK服务器初始序号 seqy确认号 ackx1“收到你的请求我的起始序号是y我确认你的x”3浏览器 - 服务器ACK确认号 acky1“收到你的确认我们的会话正式开始”注意三次握手阶段不携带任何页面业务数据纯粹是双方在“摸清彼此的状态”。浏览器发出第一个SYN后如果服务端能回SYNACK说明服务端有能力接收和发送浏览器再回ACK说明浏览器这边也确认了服务端的发送能力。到这步双方才达成共识连接可用开始传页面数据。2.2 为什么必须是三次少一次会怎样我第一次学TCP的时候也觉得奇怪握手为什么非要三次两次不行吗后来想明白关键场景是“网络中滞留的旧连接请求”在捣乱。假设客户端发出第一个SYN请求因为网络拥堵这个包在路上耽误了很久。客户端等了一会儿没回应于是重新发出第二个SYN这次很快服务端响应、连接建立、数据传完、连接关闭。一切正常。但就在这时候那个“迟到”的第一个SYN终于到达了服务端。如果采用两次握手服务端无法分辨这个迟到的SYN是不是一个全新连接请求它会立刻分配资源、建立一条连接然后耐心等待客户端发送数据。但客户端这边早就放弃这条连接了根本不会发任何数据过来。服务端就只能傻等这条连接白白占用资源慢慢超时才能释放。在攻击场景下大量伪造的SYN包甚至可以这样打满服务端的资源。三次握手能解决这个问题服务端收到迟到的SYN后回SYNACK客户端发现这个序号和当前会话对不上会直接回一个RST报文服务端收到RST就知道“哦这条连接没人要”立刻释放资源不会挂起任何状态。所以第三次握手真正的作用是让服务端确认“客户端的接收能力没问题”同时把历史脏包挡在门外。2.3 握手失败的现场从超时重传到连接拒绝握手阶段如果出了问题浏览器的表现非常典型一直转圈、超时、连接被重置。原因基本集中在两个地方。第一个是SYN本身丢了。客户端发出SYN后如果一段时间没收到SYNACK它会按照指数退避的策略重发大概1秒、2秒、4秒、8秒往后翻倍。浏览器里的表现就是连接建立耗时越来越长页面迟迟不加载。这时候用抓包工具看能看到大量重复的SYN包。第二个是服务端连接队列满了。服务端在正式accept一个连接之前会先把连接放进队列。队列有半连接队列和全连接队列之分容量都有限。一旦队列被占满新的SYN到达后要么被静默丢弃要么直接回RST。浏览器这边看到的要么是超时要么是“ERR_CONNECTION_RESET”。有人在排查这类问题时只盯着后端代码其实后端日志里很可能什么都看不到因为新请求根本没进到应用层全被堵在TCP的队列里了。这也是我在文章开头提到的那个线上故障的来源程序一切正常但TCP层的入口被堵死了。2.4 握手开销怎么摊薄keep-alive与连接复用三次握手至少需要一个网络往返时间RTT。如果RTT是50ms每次请求都要先握手再传数据那么页面里几十个资源光是握手就要花一大把时间。HTTP/1.1之后的默认行为是Connection: keep-alive一条TCP连接建好之后不立即关闭继续给后续的HTTP请求复用。这样整个页面加载过程中大部分资源共用一条连接握手费用只付一次。HTTP/2更进一步允许多个请求在一条TCP连接里并行传输也就是多路复用不需要因为浏览器的并发连接数限制而去排队另开连接。但这里也有隐藏代价TCP是可靠传输的如果这条共享连接里某一个数据段丢了后续所有请求都得等这个段补上才能继续交付这就是TCP队头阻塞的由来。HTTP/3为了解决它直接把TCP换成基于UDP的QUIC协议把原来TCP负责的可靠性和连接管理都挪到了用户态。这部分后面写浏览器网络优化时值得单独聊。3. 端到端完整交付序号、确认与重传构成的三道保险3.1 按字节编号文件被切成碎块但顺序不能乱TCP可靠性的核心是按“字节流”编号而不是按“数据包”编号。发送方把应用层传下来的数据看作一连串的字节切段后给每一段标注其起始字节序号。比如那个614400字节的页面文件第一段数据对应字节0到1459它的序号就是0第二段对应字节1460到2919序号就是1460。每一段到达接收方后接收方根据序号就能知道它在整个文件中的精确位置即使几段数据在路上乱了顺序也能在缓冲区里按序号摆正再拼装。为什么不按“第几个包”编号因为TCP本质上运送的不是包而是连续字节流。接收方根本不关心发送方一次发了多少、分了多少次它只关心“我手里的字节连没连起来”。只要字节0到1459和1460到2919都到了不管它们到达的先后顺序如何TCP都可以把它们拼接成无缝的字节流交付给浏览器。这个设计带来的一个自然推论是浏览器从系统socket读到的数据天然是有序且完整的字节流。它完全感知不到底层丢了几个包、重传了几次就像你收到拼好的家具而不是一堆零件。3.2 累积确认一句“我收到哪了”代替单独回复每个包接收方每收到TCP段不会针对每个包逐个回复“我收到了这个”而是回一个ACK确认号意思是“在这个序号之前的字节已经全部到位请从这个序号继续发。”比如接收方已经收到0到1459回ACK1460之后又收到2920到4379但它还没收到1460到2919这时它不会回ACK4380而是继续回ACK1460。发送方一看到连续几次都是ACK1460就知道“后面的数据到了但1460这块丢了”。这种累计确认的好处是省流量、抗乱序。如果数据段到达顺序是1、2、4、3接收方在3到达之前不会乱报确认号它始终只在缓冲区里攒着等到1、2、3都齐了才一次性确认整个连续区间。浏览器最终拿到的HTML必须是这种连续接缝的字节流少任何中间段都提交不上去。3.3 两种重传时机超时重传与快速重传既然网络可能丢包TCP就得能“补件”。补件靠的是重传但重传不能太急也不能太慢。第一种是超时重传。发送方发出一个段后会启动一个重传计时器叫RTO。过了RTO还没收到ACK就认为这个包丢了重发。RTO不是固定值它会根据最近的往返时间动态估算。设得太短正常的慢ACK都会被当成丢包疯狂重传浪费带宽设得太长真丢包时又反应迟钝页面等半天。第二种是快速重传。如果发送方连续收到三个相同的ACK比如连续三次ACK1460说明网络并没有断只是0到1459这个包丢了后续包都到了才会一直重复确认1460。这时TCP不等超时立刻重传0到1459那段。三种重复ACK已经足够说明问题等超时器反而白白浪费时间。重传是TCP“完整送达”最重要的兜底手段。你打开一个图片很多的页面时网络可能已经悄悄丢了几十个包但TCP在底层默默重传你完全无感。只有丢包率高到TCP重传都忙不过来时你才会看到下载速度断崖式下跌页面一直转圈。3.4 校验、去重与重组浏览器拿到的必须是完整且无损坏的数据除了丢包数据还可能被传输途中的噪声改坏。TCP头部里有一个校验和字段接收方会重新计算校验和如果不匹配就直接判定这个段损坏把它丢掉。注意丢弃后接收方不会回复ACK发送方等不到确认就会重传等于自动“换一件新的”。重复的情况也会发生如果ACK丢了发送方以为数据没到重传一遍接收方就会收到两个序号相同的段。TCP的处理方式很简单后到的重复副本直接丢弃。由于之前没有一个可靠的包边界概念按序号判断唯一性非常干净利落。最后是重组。接收缓冲区把所有到达的段按序号排好维护一个“已连续确认到哪个字节”的指针。只有当某段数据和之前的连续区间对齐时才把这段数据交给上层。浏览器侧看到的永远是一股完整、有序、不重复、不残缺的字节流这也是“页面文件能被完整送达”这一目标在协议层最直接的体现。4. 流量控制与拥塞控制想快反而要学会慢4.1 接收窗口吃不下就别硬塞如果发送方自顾自地发不管接收方能不能处理结果只会一团糟。接收方内存有限CPU正在忙别的事数据到达后没地方放最后只能丢包、重传反而更慢。TCP的解决办法是滑动窗口机制里的接收窗口rwnd。接收方在每次返回的TCP头部“窗口”字段里告诉对方“我当前还能接收多少字节”。应用层读得快窗口变大应用层读不过来窗口变小。发送方实际一次能发多少数据不能超过这个窗口值这就是流量控制。类比一下后厨不能以最大能力一个劲炒菜得看客人当下能吃多少。客人说“我这顿只能吃三道菜”后厨上十道最后全倒掉浪费又误事。TCP的接收窗口就是客人给后厨的实时反馈。对页面加载的实际影响如果浏览器所在设备内存紧张或CPU占满接收窗口会明显变小发送方会暂停发送等窗口恢复继续推进。所以你会看到页面下载到一半不动了但不一定是网络断了也可能只是接收端“暂时吃不下”。4.2 拥塞窗口网络真不真数据来说话接收窗口只考虑了“接收方能不能接下”但没考虑“中间网络能不能扛住”。一条数据从服务器到浏览器路过多少路由器、多少瓶颈链路发送方自己根本看不见。为了不被中间链路淹没TCP还要维护一个拥塞窗口cwnd表示“我推测当前网络能承受的数据量”。实际发送上限是接收窗口和拥塞窗口的较小值。刚建立连接时TCP对网络状况一无所知所以cwnd从一个很小的值开始试探。每成功发完一轮没丢包就认为“路还宽”加码继续发一旦发现丢包就认为“路变窄了”立刻降速。这个“试探着塞、反馈来调”的思路就是拥塞控制的基本逻辑。4.3 慢启动与拥塞避免页面下载速度为什么是慢慢爬上去的慢启动这个名字有迷惑性它不是让连接一直慢而是从一个小窗口起步指数级往上冲。连接刚建立时cwnd可能只有一个MSS左右发一小段收到ACK后翻倍成两个MSS再翻倍成四个短短几个RTT就能涨到不小的值。等到cwnd涨到一个预设阈值ssthreshTCP进入拥塞避免阶段。这时增速从指数变成线性每个RTT只增加少量数据相当于“稳着加”避免直接撞到网络容量的临界点上导致大量丢包。真正的拥塞信号是丢包。如果发生超时重传说明网络拥塞非常严重cwnd会降到一个很低的值重新进入慢启动。如果只是快速重传说明只是轻微丢包cwnd减半后继续爬升。这也是你下载大文件、加载多图页面时速度经常是“前几秒慢吞吞后面突然飞起”的原因TCP在慢启动阶段正一点点试探带宽等它摸清链路容量传输速度才真正提起来。HTTP/1.1时代浏览器开多个并行TCP连接本质上就是同时让多条连接各自去“爬坡”用并发弥补单条连接的慢启动开销。阶段触发条件窗口变化方式对页面加载的影响慢启动新连接开始每轮RTT翻倍初期速度低但上升快拥塞避免达到ssthresh阈值每轮RTT线性增长速度平稳避免冲击网络超时重传后网络严重拥塞cwnd降到很低重新慢启动下载速度可能骤降快速重传后网络轻度拥塞cwnd减半快速恢复速度短暂下降后回升4.4 限流如何反向保证“完整送达”很多人觉得拥塞控制和“页面完整到达”八竿子打不着但恰恰是这层“限流”反向保证了网页能送达。如果所有发送方都不限流一股脑把数据灌进网络沿途路由器的队列瞬间爆掉丢包率飙升丢包又引发大量重传重传继续挤占网络形成恶性循环最终谁的数据都送不出去。这叫拥塞崩溃。TCP的拥塞控制本质上是每个发送端的分布式自律大家都自觉控制发送速率为整个网络留出喘息空间。虽然单个连接偶尔觉得“被限速了”但整套机制护航下页面数据反而能平稳、完整地到达浏览器。这里就明白了可靠性的另一面不是“无条件猛发”而是“有节奏地发”。5. 如何在浏览器开发者工具里看见TCP的工作痕迹5.1 请求生命周期里TCP藏在哪几段时间打开浏览器开发者工具的Network面板以Chromium内核浏览器为例勾选大请求行模式点开任意请求的Timing标签能看到一串阶段TCP就藏在其中几段。Queueing / Stalled请求在本地排队等待浏览器调度还没真正发出。DNS Lookup域名解析耗时不属于TCP的部分。Initial ConnectionTCP握手阶段对应之前讲的三次握手。SSL如果有HTTPS在这个阶段完成TLS握手。Request Sent发送HTTP请求头部和正文的时间通常只有几毫秒。Waiting (TTFB)从请求发出到收到服务器首个响应字节的时间包含网络往返和后端处理。Content Download持续接收响应内容的时间这段就是TCP在源源不断传输页面数据。其中Initial Connection直接对应TCP握手的时间Content Download则是TCP传输数据的时间。弄清楚这些阶段你才敢说“页面慢是因为网络还是因为代码”。5.2 用Timing面板定位网络层问题看Initial Connection的耗时最有诊断价值。正常情况下同城机房往返可能只有几毫秒跨地域几十毫秒跨洲一两百毫秒都算正常。如果这个值稳定超过几百毫秒甚至上秒基本可以断定是网络路径或服务端握手能力出了问题。Initial Connection时间又长又稳RTT太大或者服务端连接队列接近打满。Initial Connection忽长忽短怀疑SYN重传网络丢包或者服务端队列抖动。Waiting TTFB很长优先查后端接口耗时、数据库慢查询、代理转发这个和TCP关系不大。Content Download很长查带宽、源站吞吐、CDN回源速度还有本地网速。现象可能原因优先排查点Initial Connection长且稳定RTT高、连接队列满网络路径、服务端accept队列Initial Connection忽长忽短SYN重传、握手丢包抓包看SYN重传次数Waiting TTFB长后端处理慢、代理慢后端日志、数据库慢查询Content Download长带宽不足、接收窗口小带宽测试、源站吞吐这套判断帮我在不少项目里快速缩小了问题范围曾经有个接口在测试环境秒开、生产环境偶尔要等十几秒一看Timing是Waiting TTFB爆红根本没碰TCP层最后定位到生产环境的网关转发策略改完就正常了。5.3 常用命令快速核查TCP连接状态浏览器开发者工具看到的是客户端视角如果想从服务端侧确认TCP状态可以用系统自带的命令。重点看两个东西TCP重传统计和监听队列占用。# 查看TCP统计信息重点看重传相关字段 netstat -s | grep -A 5 Tcp: # 查看监听端口的队列占用情况 ss -lnt # 抓取访问80端口的SYN包观察是否有重复重传需要相应权限 tcpdump -i any tcp[tcpflags] tcp-syn ! 0 and port 80netstat -s输出里如果看到重传段数量占总发送段的比例明显偏高说明网络丢包或者链路不稳。ss -lnt里Listening socket的Send-Q列是backlog上限Recv-Q列是当前等待accept的连接数。如果Recv-Q长期接近Send-Q说明连接队列快满了新连接随时可能被丢被拒。tcpdump抓包则是排查客户端侧有没有反复重传SYN的最直接手段。需要提醒一句调整系统内核参数比如somaxconn、backlog这些属于系统调优范畴不要在线上随意乱改先定位到准确原因再动。5.4 一套排查页面加载慢的顺序先查TCP再查业务每次遇到页面加载慢我习惯按固定顺序排查。先用Timing面板确定卡在哪一段Initial Connection、Waiting、还是Content Download。如果是Initial Connection异常在客户端抓包看SYN重传在服务端看ss队列和TCP重传统计顺手确认安全策略有没有丢包。如果是Waiting TTFB异常去查后端接口处理时间和代理转发TCP基本排除。如果是Content Download异常测带宽、测源站吞吐再确认接收窗口有没有被系统内存压力压小。如果是整个请求链路都慢就换个网络复现一下把本地网络因素先排除掉。这套顺序说白了就是先把“TCP这层有没有问题”这件事做成一个判断题而不是一上来就扎进业务代码里瞎猜。我在实际排查中最大的体会是页面打不开或者慢很多时候根子就在传输层但服务端日志里干干净净因为请求根本没到达应用层。先学会看Timing面板再学会看TCP连接状态能绕开非常多假象。以后遇到页面加载问题别急着改代码先去开发者工具里看一眼TCP留下的时间戳答案往往就在那里。