
1. 先彻底搞懂TCP socket 到底是个什么东西很多朋友学了几年网络编程张口闭口TCP socket但你要是追问他一句socket到底是个什么他多半会愣住。这个现象我在面试里见得太多了。先说个我观察到的普遍误区很多人把 socket 当成一个协议。不对socket 是个 API 抽象——它是操作系统提供给应用层的一座桥让应用程序能够通过文件描述符的方式去操控网络通信。它既不是传输层的协议本体也不是网络包本身它是一组函数接口和内核数据结构的管理入口。你想象一下这个场景你在北京朋友在上海你要给他寄一箱樱桃。你俩之间并没有一条真正物理存在的樱桃管道你只是把箱子交给了快递系统。TCP 协议就是快递系统的运输规则而 socket 则是你手里那个寄件窗口——你主动去这个窗口填单子、交箱子、签收包裹。应用程序写代码时打交道最多的就是 socket而不是直接去摆弄 TCP 报文段。这个窗口的形态在你眼里是一个文件描述符FD可以用 read/write 或 recv/send 去操作。那么 TCP socket 的具体工作过程是啥我简单梳理一遍这张流程不要求背诵但要求你理解到说出来不卡壳的程度服务端调用 socket() 创建监听套接字返回一个 fd服务端调用 bind() 将该 fd 绑定到固定的 IP 和端口上服务端调用 listen() 进入监听状态内核开始接受外部连接请求客户端调用 socket() 创建套接字随后 connect() 发出连接请求连接建立成功后客户端和服务端各拿到一个已连接套接字进入数据收发阶段数据收发完成后双方调用 close()或 shutdown()关闭连接释放资源。这里必须强调一个关键点监听套接字和已连接套接字是两个东西。listen() 创建的那个 fd 只负责接客它不能收发数据真正收数据的是 accept() 产生的那一批新 fd。很多刚入门的朋友在这个地方懵掉老以为 accept 之后还是同一个 fd 在收发数据抓包的时候自然就对不上号。另外很多人混淆 UDP socket 和 TCP socket 的编程模型差异。UDP 是无连接的你用同一个 fd 就能 sendto / recvfrom 对任意地址收发TCP socket 则必须先建立连接连接四元组源 IP、源端口、目的 IP、目的端口一旦确定数据就沿着这个连接跑。这个差异不是编程风格的不同它根子上是协议传输模型的分野——TCP 是面向字节流的可靠传输UDP 是面向数据报的不可靠传输。流stream和数据报datagram这两个词你品一品就能感觉到差别TCP 是一根水管里的连续水流UDP 是一个一个独立投递的包裹。讲到这有人会问那我直接用 socket内核怎么知道把数据可靠地送到对端答案在于 TCP 协议栈帮你在内核里做了大量工作。换句话说你只管把数据交给内核后续的拆分、编号、确认、重传、排序、去重、流量控制全部由 TCP 协议栈自动完成。这也是TCP socket 可靠这句话的真正含义——可靠不是 socket 这个 API 带来的而是 TCP 协议在内核里的那一整套可靠传输机制。理解到这一层我们才有资格聊可靠性。接下来我把 TCP 可靠性的底层机制一层层拆开。2. 三次握手与四次挥手连接建立和释放里藏着哪些为什么学 TCP 的人几乎都会背建立连接三次握手释放连接四次挥手。但一问为什么是三次而不是两次很多人就只能打哈哈。这一节我用协议设计者的视角帮你理清楚。2.1 为什么需要三次握手三次握手的本质目的是让双方确认彼此具备收发能力并同步初始序列号ISNInitial Sequence Number。我拆开讲。TCP 是可靠传输而可靠性最大的前提是——收发双方对数据块的编号有共识。如果没有一个双方认可的起始编号后面所有的按序号重组按序号确认全都无从谈起。所以握手第一件事就是彼此交换 ISN。那为什么非要三个报文段你看这个场景。假设只有两次握手客户端发了 SYN服务端回了 SYNACK服务端认为连接已经建立了开始等着收数据。但假如这个 SYN 在网络里被延迟了很久网络拥堵或路由振荡客户端等不及就重传了一个新的 SYN然后用新连接开始传数据。这时旧的那个 SYN 又漂到了服务端服务端以为是新连接请求又回了一个 SYNACK并且耗费资源建立了一条客户端根本不知道的连接。这条半废连接白白占用服务端的内存和端口资源大量堆积下来服务端会被拖垮。三次握手里客户端在收到服务端的 SYNACK 之后还要再回一个 ACK。这个 ACK 的核心价值就是告诉服务端你发的 SYN 我也收到了我确认这个链路双向是通的而且我知道你发的 ISN 是多少了。 因为客户端再回复这个 ACK 时已经携带了我收到了你 SYN的确认信息所以服务端收到它之后才进入 ESTABLISHED 状态。只有两个方向都走一遍请求-确认并且两端都确认对方知道了自己的序号才算真正把可靠的通信基础打牢。2.2 三次握手的真实时序和状态机变化纯文字说状态机有点干我用一个非常典型的过程表格来捋一遍。这个表格也是排查连接问题时的速查手册步骤发送方报文内容发送后状态客户端接收后状态服务端1客户端SYN, seqxSYN_SENT收到后进入 SYN_RCVD2服务端SYNACK, seqy, ackx1收到后进入 ESTABLISHEDSYN_RCVD → 发送后仍为 SYN_RCVD3客户端ACK, seqx1, acky1保持 ESTABLISHED收到后进入 ESTABLISHED这里有个细节值得注意服务端在第二步发出 SYNACK 后状态并不是直接变成 ESTABLISHED而是停留在 SYN_RCVD直到收到客户端的第三步 ACK。如果第三步 ACK 丢了服务端会一直停留在 SYN_RCVD 并持续重传 SYNACK。这就是代码里经常遇到的connect 成功了但服务端连接数上不去的一种潜在原因。另外一个经常被忽视的坑Linux 内核中存在一个半连接队列SYN Queue和一个全连接队列Accept Queue。SYN_RCVD 状态的连接放在半连接队列ESTABLISHED 状态但还没被应用层 accept() 取走的连接放在全连接队列。当客户端 connect() 成功不代表服务端应用层已经调用了 accept()很可能这个连接只是在内核的全连接队列里排着队。如果全连接队列满了服务端会按系统参数决定是丢弃新连接还是直接回 RST。2.3 四次挥手为什么要四次以及 TIME_WAIT 那点事很多人背四次挥手背得很熟FIN → ACK → FIN → ACK。但你有没有想过为什么释放连接要四次比建立还多一次原因是 TCP 连接是单工独立关闭的。通信双方各自维护一个方向的数据流每一方向都需要单独关闭。也就是A 发 FIN 表示我的数据发完了请你别再等我发数据了B 回 ACK 表示我收到你的关闭请求——但这时 B 可能还有没发完的数据所以 B 不立刻发 FIN而是继续发数据直到 B 也发完了B 再发 FINA 回 ACK连接彻底关闭。因为两个方向各需要一次 FIN 和一次 ACK所以典型场景就是四次交互。TIME_WAIT 是这里最容易考也最容易踩坑的状态。主动关闭连接的那一端先发 FIN 的那一端在收到对端的 FIN 并回复最后一个 ACK 之后不会立刻进入 CLOSED而是进入 TIME_WAIT 状态默认等待 2MSLMaximum Segment Lifetime报文最大存活时间Linux 上通常为 60 秒2MSL 就是 120 秒之后才彻底关闭。为什么要有 TIME_WAIT两个原因第一防止最后一个 ACK 丢失。如果 A 回复的 ACK 丢了B 会重发 FINA 只有在 TIME_WAIT 状态下才能重新回复 ACK。第二让旧连接的所有迟到报文自然消亡防止它们干扰使用了同一四元组的新连接。做过高并发服务的同学肯定见过这个场景服务端主动关闭连接后大量连接进入 TIME_WAIT导致同一个端口和远端地址的新连接无法快速建立因为上一个连接的四元组还没释放。这个坑我后面在实战章节细说。3. TCP 可靠性的四个支柱序号、确认、重传、校验如何协同工作现在进入本文的核心议题TCP 到底靠什么做到可靠一句话概括TCP 通过序列号 确认应答 超时/快速重传 校验和构建了一个有反馈、有纠错、有兜底的传输闭环。而这一切都是构建在不可靠的 IP 网络之上。3.1 为什么 IP 不可靠TCP 却能可靠要理解 TCP 的可靠必须先承认 IP 层尽力而为。IP 数据包可能在传输途中丢失、乱序、重复甚至被截断。TCP 解决这个问题不是靠改进 IP 层而是在 IP 之上增加了一层校验与重传机制。你寄一箱樱桃快递公司IP可能丢件、送错门栋、晚送几天——但快递单号序列号和签收确认ACK这套机制让你能发现丢件并催件补发重传。形象吧所以 TCP 可靠性的本质是以增加冗余控制信息头部和重传消耗为代价在不可靠的网络上抽象出一个对应用层表现为可靠字节流的通道。3.2 序列号一切可靠的基石TCP 的每个字节都被分配一个序列号。发送端把应用层的数据看成一个连续的字节流第一个字节的序号就是 ISN之后每个字节的序号依次 1。接收端收到报文后根据序号就能知道这段数据在整条字节流中的位置从而完成去重如果收到重复的报文比如重传导致的重复序号能立刻识别出来并丢弃排序如果报文乱序到达接收端先缓存再按序号拼接成完整的字节流交付给应用层确认接收端通过序号告诉发送端我已经收到第 N 个字节之前的所有数据。我补充一个容易混淆的点TCP 报文段头部的 seq 字段表示的是当前报文段第一个字节的序号而不是这是第几个报文段。对比一下 UDPUDP 只保证数据报边界不保证顺序它根本没有序号的概念。这就是流字节流抽象和数据报独立报文抽象的差别。3.3 确认应答与累计确认不确认就重传接收端收到数据后会回一个确认号ack含义是我期望收到你下一个字节的序号同时隐含表示这个序号之前的数据我都收到了。这种确认叫累计确认Cumulative ACK它不需要每个字节单独回一个 ACK只需确认到某个序号之前的连续字节全部到达。累计确认有个小小的模糊地带如果接收端收到了 seq1001 和 seq3001 两个报文段但中间的 seq2001 丢了它应该回什么 ack答案是回 ack2001期望收到 2001因为 2001 之前的字节是不连续的它不能确认 2001 之后的数据。这种机制能让发送端感知到中间有洞但代价是发送端无法知道洞后面到底还有多少数据已经到达。这正是后来引入SACKSelective Acknowledgment选择性确认的原因。SACK 允许接收端在 ACK 里额外携带我已经收到了哪些不连续的区间的信息比如我收到了 3001~4000 的数据但你 2001 还没收到。发送端知道了这个信息就只需精准重传丢失的那部分而不是把 3001~4000 的数据全部再发一遍。排障时你会发现启用 SACK 后丢包率高、带宽利用率低的问题会得到非常明显的缓解。3.4 重传策略超时重传和快速重传哪个更聪明发送端怎么判断该重传了两种途径超时重传RTO 超时发送一个报文段后启动一个定时器。如果在规定时间内没有收到对应的 ACK就重传该段。这个规定时间由RTTRound-Trip Time往返时延动态计算得出不是固定值。Linux/RFC 里通过 EWMA指数加权移动平均平滑 RTT 采样再考虑方差抖动最终得出 RTO。网络抖动大的时候RTO 被放大丢包恢复就会变慢网络平稳时RTO 较短恢复更快。快速重传Fast Retransmit如果发送端连续收到 3 次相同的 ACK即接收端连续三次都在期待同一个序号说明中间有报文丢了不必等超时就直接重传。之所以定在3 次是为了防止由于报文乱序导致的误判——如果只是顺序颠倒了接收端可能因为缓存而回一个重复的 ACK但连续 3 个重复 ACK 大概率意味着真的丢了。实际生产环境中快速重传通常比超时重传生效更快但它有个短板它只重传被卡住的那一个序号的数据。比如发送了 seq 1~5其中 2 丢了快速重传只补发 2而 3、4、5 虽然其实也到了但接收端因为 2 的空洞无法确认。这就导致了不必要的后续大量重传。配合 SACK 使用问题才能解决得比较干净。3.5 校验和防烂包的最后防线TCP 头部的 checksum 字段对头和数据做补码求和校验。接收端重新计算校验如果发现不一致直接丢弃该报文并触发接收不到 ACK 的发送端重传。这个机制主要防物理层/链路层的比特错误。到这里你应该明白一件事TCP 可靠性不是某一个单独的魔法机制而是一堆机制层层嵌套、互为备份。序号负责定位确认负责反馈重传负责补救校验负责体检。缺了任何一个环节可靠性环都会断。4. 滑动窗口与流量控制不把接收方冲垮的智慧可靠性不止是丢包重传还包括不能把对方撑死。试想发送端带宽很大一个劲儿发数据接收端应用程序读得慢、内存不够大数据全积压在接收缓冲区里最终会导致缓冲区溢出、内存被耗尽、连接被迫重置。TCP 用滑动窗口Sliding Window机制来解决这个问题。4.1 窗口是什么TCP 接收端在每次 ACK 里会通过窗口字段Window Size告诉发送端我现在还有多少接收缓冲空间可用。 发送端据此限制自己的在途数据量保证这些数据加在一起不会超过接收端的承受能力。这个在途数据量的上限就是发送窗口。窗口在不停移动随着 ACK 到达窗口右边界向前滑动新的数据才能被发出去。有一个非常常见的记忆误区很多人以为滑动窗口只是纯发送端的流量控制手段其实它首先是接收端主导的。接收端通过调整通告窗口Advertised Window就相当于拧大了或拧小了水龙头。如果应用程序读数据慢缓冲区快满了接收端把通告窗口改小发送端就会自动减速。如果缓冲区满了通告窗口降为 0发送端就停发进入零窗口状态。这就很像你和高铁站进站口的闸机闸机口只能容纳那么多人窗口后面的人必须等前面的人通过后被确认接收后闸机才放行新的人进入。4.2 零窗口的死锁与窗口探测零窗口时出现一个经典问题如果发送端停发数据后接收端后续缓冲区有空间了会发送一个窗口非零的 ACK 来唤醒发送端。但这个 ACK 本身可能丢失。如果发送端傻等双方就会进入死锁——接收端以为自己已经告知了可以发了发送端却一直没等到这个消息。TCP 的解法是窗口探测Window Probe当发送端在零窗口后的某个时刻周期性发一个 1 字节的探测报文触发接收端重新回复当前窗口大小。即使探测报文的 ACK 丢了过段时间再探测总能打破死锁。这个机制说明TCP 在协议设计层面就把ACK 丢失这种极端情况考虑了进去。4.3 糊涂窗口综合征Silly Window Syndrome你在实际开发中可能遇到过网络延迟不高、数据吞吐量却极低每个报文段都又小又碎。这就是糊涂窗口综合征。触发的场景很典型接收端应用程序每次只读 1 字节腾出 1 字节的缓冲空间于是通告窗口变成 1。发送端一看窗口有 1 字节就屁颠屁颠发一个只装 1 字节数据的报文。一来二去网络里充满了大量小报文有效载荷比例极低带宽全浪费在头部开销上了。解决方案是让双方都克制。接收端延迟通告小窗口只有当缓冲区腾出足够大的空间比如达到最大报文段长度 MSS 的一半或者空间已空出一段时间才更新窗口。发送端则采用Nagle 算法来攒数据当有已发送数据还没被确认时发送端不立即发送新的小报文而是等后面的小数据攒成一个更大的报文再发。注意Nagle 算法对实时交互类应用不一定是好事——Telnet/SSH 这种需要低延迟的场景Nagle 和延迟 ACK 叠加会让用户感觉卡了一拍。这里给个实际建议做高频交易或实时音视频传输的应用经常需要关闭 Nagle 算法设置 TCP_NODELAY但代价是网络上可能出现更多小报文。到底划不划算取决于你的业务对延迟敏感还是对吞吐量敏感。别跟着人云亦云要用数据说话。5. 拥塞控制网络环境下的车距管理流量控制管的是发送端和接收端之间的速度匹配它只管两个端点。但真正决定网络是否拥堵的是整条链路上所有路由器和交换机的中转能力。如果所有发送端都不管链路瓶颈一股脑儿往网络里灌数据就会出现网络拥塞路由器开始丢包而这反过来又触发大量重传让拥塞更严重。TCP 需要一套机制来感知并规避拥塞——这就是拥塞控制。5.1 四大算法慢启动、拥塞避免、快重传、快恢复经典的 TCP 拥塞控制由四个算法组成慢启动Slow Start连接建立后拥塞窗口 cwnd 从很小的值比如 1 个 MSS开始每收到一个 ACK窗口翻倍增长。这个阶段指数上升目的是快速探测可用带宽。慢启动的慢不是说增长慢而是指起始慢从 1 开始逐步试探防止一开始就冲爆网络。拥塞避免Congestion Avoidance当 cwnd 达到慢启动阈值 ssthresh 后进入加法增长阶段每个 RTT 窗口只增加 1 个 MSS。这个阶段类比成路况良好但车距渐满你要克制住加速的冲动。快速重传 快速恢复Fast Retransmit and Fast Recovery收到 3 个重复 ACK 时TCP 认为网络还不算太糟因为还有 ACK 源源不断回来于是执行快速重传并且把 ssthresh 减半来放慢后续增长速度但 cwnd 仍维持在较高水平继续发送而不是回到慢启动的 1 从头再来避免吞吐量断崖式下降。这里有一个容易被忽视的细节现代 Linux 缺省采用CUBIC拥塞控制算法它在高带宽、高延迟链路上表现极好。局域网里你用默认配置问题不大但如果做跨地域的数据同步或 CDN 回源可以尝试在发送端调拥塞控制算法实测延迟和丢包表现会有肉眼可见的差异。5.2 网络队头阻塞拥塞之外的另一个痛说到 TCP 的缺点队头阻塞是绕不开的。TCP 是字节流接收端必须按序号把数据拼好才交付应用层。假设发送端依次发送了 1~10 号报文其中 5 号丢了即使 6~10 号全部到达接收端也无法把 6~10 交付给应用层必须等 5 号重传并到达之后才能继续。真实世界里的类比是高速公路上一辆货车翻车后面所有车都得排队等着明明后面的车都能走但因为占住了车道全部被堵住。HTTP/2 多路复用之所以在丢包场景下表现不佳根本原因就在这里。这也是为什么现在 HTTP/3 直接改用基于 UDP 的 QUIC从传输层重新设计把多路复用 头部压缩 加密全部融合绕开了 TCP 的队头阻塞。但你如果只是写普通的业务接口、做数据同步、传文件TCP 的队头阻塞影响基本可以忽略。它是协议设计层面的取舍——用顺序交付的确定性换取了部分并发效率。别被各种TCP 已死的论调带偏现实中绝大多数应用依然跑在 TCP 上。6. 实战排障与调优我在生产环境里踩过的 TCP 相关的坑了解协议原理之后终究要落到出了问题怎么查。这一节分享几个我在日常开发和运维里反复踩过、也帮别人排查过的典型坑。6.1 TIME_WAIT 过多导致端口耗尽场景高并发短连接服务端比如大量 RPC 短连接、爬虫抓取服务主动关闭连接的一端暴露出大量 TIME_WAIT 连接致使可用端口被占满新连接无法建立。排查手段用 netstat 看一眼netstat -ant | awk {print $6} | sort | uniq -c | sort -n如果 TIME_WAIT 数量达到几万甚至十几万基本就能锁定问题。解决方案按优先级排序检查应用层是否真的需要主动关闭连接。如果可以对端关闭让被动关闭的 TIME_WAIT 落在对端开启net.ipv4.tcp_tw_reuse1允许在 TIME_WAIT 状态下复用连接仅对主动关闭方的出站连接有效需要同时开启时间戳选项net.ipv4.tcp_timestamps1调小net.ipv4.tcp_fin_timeout和net.ipv4.tcp_max_tw_buckets这是治标不是治本不建议过度调。提示不要上来就开tcp_tw_recycle这个参数在 NAT 环境下会引发严重的连接问题远端不同机器的 RTT 差异导致合法连接被丢弃Linux 4.12 以后已经移除。我见过不止一个团队因为这个参数线上半夜出事故。6.2 半连接队列溢出和全连接队列溢出现象客户端 connect 偶尔超时服务端应用日志正常但ss -lnt显示Send-Q很大或者 dmesg 里出现TCP: request_sock_TCP: Possible SYN flooding on port。这类问题的本质是内核 accept 队列溢出了。解决的路径是提高应用层 accept 速度——检查是不是服务端单线程串行 accept导致处理不过来调大全连接队列net.core.somaxconn和进程 listen 时的 backlog 参数两者取小调整半连接队列net.ipv4.tcp_max_syn_backlog适当开启net.ipv4.tcp_syncookies1在 SYN 洪泛时保证部分连接仍能建立。注意 Syncookie 不是为了提升吞吐它只是兜底别把它当性能方案用。6.3 粘包与拆包面向字节流的原生烦恼前面说过 TCP 是字节流不保留应用层消息边界。你连续 send 三次 10 字节的数据接收端可能一次读到 30 字节也可能分两次读到 2010。这就是粘包和拆包。它不算缺陷但确实让所有做 TCP 自定义协议的人头疼。常用解法有四种每个消息固定长度简单但浪费空间消息头里塞长度字段最通用推荐特殊分隔符如 HTTP 用 \r\n\r\n 隔开头部适合文本协议让每次 send/recv 完全对应只在既无延迟 ACK 也无 Nagle 干扰、且双方都及时收发的小消息场景下才可控生产环境别依赖它。我个人最推荐第二种头部 4 字节存包体长度 包体。解码时先攒够一个完整头再按头里的长度字段攒包体包体收齐后交付给上层。Netty、Go 的网络库、Java 的 LengthFieldBasedFrameDecoder本质上都是这套思路。6.4 抓包定位神秘 ACK和重传风暴当连接表现异常但应用代码看不出问题时我最常用的工具是 tcpdump Wireshark。tcpdump -i eth0 -nn (tcp port 8080) -w /tmp/cap.pcap抓完看四样东西重传序号是否连续重复 ACK 的频次SYN 重传次数判断对端是否回应了正常 SYN 只发一次就得到响应窗口被压到多少判断接收端是否频频通告 0 窗口。真实案例有一个同事说服务偶发变慢查日志耗时正常但整体 RT 偏高。我抓包一看频繁出现TCP Dup ACK和TCP Fast Retransmission根源是网卡中断不均导致某个核上的软中断处理不过来出现少量丢包。换个多队列网卡并做 RPSReceive Packet Steering之后重传直接从 5% 降到 0.1% 以下。这种问题你要是不抓包光看应用代码是永远定位不到的。6.5 针对低延迟场景的 socket 选项调优如果你做的是交易、网关、实时信令这类低延迟服务这几个 socket 选项值得认真考虑TCP_NODELAY禁用 Nagle 算法避免延迟 ACK 和 Nagle 互相等待造成 40ms 级别的毛刺延迟SO_KEEPALIVE应用层心跳之外可以开启 TCP 保活但默认参数探测间隔很长7200 秒如果想用记得同时调net.ipv4.tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probesSO_REUSEADDR/SO_REUSEPORT前者解决 TIME_WAIT 状态下端口不能立即绑定重启的问题后者允许多进程/多线程绑定同一端口实现内核级负载均衡但注意同一组 socket 的负载均衡算法要看内核版本TCP_QUICKACK可以临时关闭延迟 ACK优化单次请求-响应模型的时延但只适合短平快交互。最后我再说一个亲测的体会协议栈调参这种操作一定要先在压测环境做了前后对比再上生产不要迷信任何网上的最优参数。换一个内核版本有些参数的含义和默认值都变了照着老文章抄作业容易翻车。把基础原理吃透遇到问题时你才能判断该调哪里、不该调哪里——这才是深入了解 TCP socket 和 TCP 可靠性真正有用的地方。