
TCP 这个协议干网络这行的人几乎没有不跟它打交道的但真正能把它的报文结构、字段含义、设计取舍讲清楚的人并不多。很多人对 TCP 的理解停留在可靠传输三次握手四次挥手这几个词上面试能背真到了抓包分析、调优排障的时候就卡壳。这篇东西我打算把 TCP 的协议结构、主要特点和应用场景从头捋一遍重点放在为什么这么设计和实际工作中怎么用上而不是罗列字段定义。适合刚接触网络编程的开发者、准备系统学习传输层的同学以及那些天天写业务代码但想搞明白底层到底发生了什么的工程师。读完你至少能做到看到一个 TCP 报文能说出每个字段在干什么理解 TCP 为什么长这样以及知道什么场景该用它、什么场景该躲开它。1. 先把 TCP 放回它该在的位置1.1 传输层到底在解决什么问题要理解 TCP得先搞清楚传输层这一层存在的意义。网络层IP 协议负责的是把数据包从一台主机送到另一台主机它只管送到不管送的过程中丢没丢、顺序乱没乱、送到的内容对不对。你可以把 IP 层想象成一个只管把信投进对方信箱的邮递员信有没有被拆过、是不是按顺序到的、有没有半路丢了他一概不负责。传输层就是在 IP 层这个尽力而为的基础上往上补一层端到端的保障。它要解决的核心问题是两台主机上的进程之间怎么通信。注意是进程不是主机。一台主机上跑着浏览器、聊天软件、数据库客户端IP 层分不清这些数据该给谁传输层用端口号把这个问题解决了。所以传输层的两大职责一个是进程到进程的寻址端口另一个是根据应用需求提供不同级别的传输质量。TCP 和 UDP 就是这条路上两种截然不同的选择。UDP 基本就是IP 层加个端口简单粗暴发出去就不管了TCP 则是在 IP 层之上堆了一整套可靠性机制。理解这个定位很重要因为后面 TCP 所有的复杂设计本质上都是在为可靠性这三个字买单。1.2 TCP 与 UDP 的分工不是优劣而是取舍新手最容易犯的错是把 TCP 当成高级版 UDP觉得 UDP 是简陋的、不靠谱的。这个认知得纠正过来。TCP 和 UDP 没有谁更高级它们是针对不同需求的两套方案各自都有必须付出的代价。TCP 为了保证可靠付出了什么连接建立要握手、断开要挥手、每个报文要确认、丢了要重传、乱了要排序、发快了要拥塞控制。这些机制每一样都要消耗时间、内存和带宽。UDP 把这些全省了换来的是极低的延迟和极简的处理逻辑。所以实时音视频、在线游戏、DNS 查询这些场景反而更偏爱 UDP——丢一帧画面用户根本察觉不到但如果为了等一个重传卡上 200 毫秒体验就崩了。我在实际项目里见过不少团队不管什么场景一律上 TCP结果做实时对战游戏时延迟怎么都压不下来最后不得不改成 UDP 加自定义的可靠性策略。这个教训说明选协议之前先问自己你的应用到底更怕丢数据还是更怕延迟。怕丢数据的文件传输、网页、数据库用 TCP怕延迟的实时流、游戏、广播考虑 UDP。这个判断标准比背一堆特性对比表管用得多。1.3 一个 TCP 连接在系统里到底是什么很多人把连接理解成一个抽象概念其实在操作系统里一个 TCP 连接是非常具体的东西——它是一块内核维护的数据结构里面存着源 IP、目的 IP、源端口、目的端口、序列号、窗口大小、各种定时器、发送缓冲区、接收缓冲区的指针等等。业界常说的四元组源 IP、源端口、目的 IP、目的端口唯一标识一个连接就是因为这四样东西组合起来才能在内核的连接表里定位到那一条记录。这个认知对排障特别有用。比如你遇到端口被占用的报错本质上是内核连接表里已经有一条记录占着那个四元组了你看到TIME_WAIT状态堆积那是内核在等一个定时器到期才释放这条记录。理解了连接是内核里的实体你排查问题时就会自然地去想内核现在维护着哪些状态、哪些定时器在跑而不是停留在应用层的黑盒视角。后面讲协议结构和状态机的时候这个视角会一直用得上。2. TCP 报文头逐字段拆解每个字节都有它的道理2.1 固定头部 20 字节的整体布局TCP 报文头最短 20 字节最长 60 字节因为选项字段最多 40 字节。这 20 字节的固定部分是整个协议的大脑所有可靠性机制都靠这些字段协作完成。我习惯把它分成四组来记寻址组源端口、目的端口、顺序组序列号、确认号、控制组数据偏移、标志位、窗口、校验与紧急组校验和、紧急指针。先看寻址组。源端口和目的端口各 16 位范围 0 到 65535。这里有个实操细节值得说0 到 1023 是知名端口普通应用没权限绑定1024 到 49151 是注册端口49152 往上才是客户端临时端口的常用区间。你在写服务端程序时如果绑了个 80 端口却提示权限不足不是代码问题是端口号落在了特权区间。另外服务端监听时通常绑0.0.0.0表示监听所有网卡绑127.0.0.1则只接受本机连接这个区别在容器化部署时经常踩坑——绑了127.0.0.1的服务容器外怎么都连不上。顺序组是 TCP 可靠性的核心。序列号32 位标识本报文数据部分第一个字节在整个字节流中的位置注意它数的是字节不是报文这是理解 TCP 的关键。确认号32 位表示我期望收到的下一个字节的序列号也就是它之前的所有字节我都收到了。这两个字段配合构成了 TCP 的累积确认机制。这里有个容易混淆的点序列号的初始值不是从 0 或 1 开始的而是连接建立时随机生成的ISN这个设计是为了安全防止攻击者猜测序列号伪造报文。2.2 标志位六个比特控制整个连接的生命周期标志位只有 6 个有效比特早期还有 2 个保留位但每一个都举足轻重。我用一张表把它们和实际场景对应起来比干背定义好记标志位全称含义典型场景URGUrgent紧急指针有效带外数据实际很少用ACKAcknowledgment确认号有效连接建立后几乎所有报文都置 1PSHPush提示接收方尽快交付应用交互式应用减少延迟RSTReset重置连接连接异常、端口未监听时返回SYNSynchronize同步序列号三次握手前两步FINFinish发送方数据发完了四次挥手这里面最值得展开的是RST和PSH。RST 是很多人排障时的关键线索——当你用工具连一个没开服务的端口对方回的就是 RST而不是默默不理你。所以连接被拒绝和连接超时是两码事前者说明对方主机可达但端口没监听收到 RST后者说明报文发出去石沉大海可能被防火墙丢了或者对方主机不可达。这个区分能帮你快速定位问题在网络哪一层。PSH 位则常被误解。它不是必须立刻发而是告诉接收方别再攒了赶紧把缓冲区里的数据交给应用。很多交互式协议比如 SSH、Telnet会置这个位来降低延迟。但现代实现里是否置 PSH 往往由内核根据发送缓冲区情况自动决定应用层很少需要手动干预。我见过有人为了降低延迟在应用层强行设置 PSH结果适得其反因为打乱了内核的批量发送策略。2.3 窗口、校验和与选项字段的实战意义窗口字段16 位是流量控制的抓手它告诉对方我当前接收缓冲区还能装多少字节。这个值直接决定了发送方能连续发多少数据而不等确认。窗口为 0 时发送方必须停下来这就是所谓的零窗口。实际调优时窗口大小是个关键参数——窗口太小带宽再高也跑不满因为发送方总在等确认窗口太大接收方缓冲区压力大。后面讲窗口缩放选项时会细说。校验和16 位覆盖 TCP 头部、数据和伪头部包含源/目的 IP用于检测传输过程中的比特错误。这里有个实操经验现在很多网卡支持校验和卸载checksum offload你用抓包工具看到的校验和可能是错的因为计算被推迟到网卡硬件做了。如果你抓包发现校验和不对就断定报文损坏很可能冤枉了它。排查时先确认抓包点是不是在卸载发生之前。选项字段是 TCP 保持生命力的关键长度可变最多 40 字节。常见的选项包括最大报文段长度MSS、窗口缩放因子、选择性确认SACK、时间戳。MSS 在握手时协商决定了单个 TCP 报文最多能带多少数据它和 MTU 的关系是 MSS MTU - IP 头 - TCP 头以太网下通常是 1460。这个值协商不好会导致分片性能大打折扣。窗口缩放则解决了 16 位窗口最大只能表示 65535 字节的局限通过移位把窗口上限扩展到约 1GB这是高带宽长距离传输所谓长肥管道能跑满的前提。3. TCP 的可靠性不是一句口号是四套机制叠出来的3.1 序列号加确认号字节流的账本TCP 把应用层交下来的数据看成一串连续的字节流而不是一个个独立报文。序列号就是给这串字节流编的号每个字节都有唯一编号。发送方发出数据后接收方用确认号告诉它我已经连续收到你第 N 个字节之前的所有内容了。这种累积确认的好处是简单一个确认能覆盖一大片坏处是如果中间丢了一个字节后面的全得重传哪怕它们都到了。举个具体例子帮你建立直觉。假设发送方发了三个报文分别携带字节 1-100、101-200、201-300。如果第一个报文丢了接收方收到第二个时会回确认号 1意思是我还在等你从第 1 个字节开始收到第三个还是回确认号 1。发送方一看发了三个确认号都是 1就知道第一个报文丢了触发重传。这就是为什么 TCP 的重传判断依赖重复确认——收到三个相同的确认号三个重复 ACK通常意味着中间有报文丢失快速重传机制就被触发了。这里有个容易忽略的细节确认号是期望的下一个字节不是最后收到的字节。所以如果接收方收到了 1-100它回的确认号是 101而不是 100。这个差一的问题在写自定义协议解析时经常出错务必记牢。3.2 超时重传与快速重传两条腿走路光有确认号还不够如果确认本身丢了或者接收方压根没收到数据发送方怎么知道要重传靠定时器。发送方每发一个报文就启动一个重传定时器超时没收到确认就重传。这个超时时间RTO不是拍脑袋定的而是根据往返时间RTT动态估算的用的是一套平滑加权算法避免网络抖动导致误判。但超时重传太慢了——要等定时器到期可能是几百毫秒甚至几秒。所以 TCP 还有快速重传如果发送方连续收到三个针对同一数据的重复确认就不等定时器了立刻重传丢失的报文。这个机制把丢包恢复时间从秒级降到一个 RTT 级别对性能提升巨大。我在实际调优中遇到过一种情况网络轻微丢包但延迟很低结果吞吐量上不去。抓包一看快速重传频繁触发但每次重传后又因为拥塞控制把窗口砍半来回震荡。这种场景下选择性确认SACK就派上用场了——它让接收方能告诉发送方我收到了哪些不连续的块发送方只重传真正丢的那部分而不是把丢失点之后的全部重发。SACK 在高丢包率的长距离链路上几乎是必开的选项。3.3 流量控制别把接收方撑爆可靠性解决了数据别丢但还有个问题发送方如果发得太快接收方处理不过来缓冲区满了新来的数据只能丢掉然后触发重传恶性循环。流量控制就是解决这个的靠的就是前面说的窗口字段。接收方在每个确认报文里带上自己的接收窗口大小发送方据此调整发送节奏保证在途数据不超过对方窗口。这个机制是端到端的跟网络中间设备无关。窗口为 0 时发送方停止发送但会定期发窗口探测报文防止对方窗口更新通知丢失导致双方死锁。这里有个实战中的坑窗口大小是接收方当前剩余缓冲区的反映如果应用层读数据太慢缓冲区被占满窗口就会缩到很小甚至为 0发送方被迫停等。这时候你从网络层看一切正常但吞吐量就是上不去。排查这类问题得看应用层是不是有阻塞操作拖慢了读取速度而不是一味调网络参数。我见过一个服务因为日志同步写磁盘太慢导致 TCP 接收窗口长期偏小最后定位到的是磁盘 IO 问题跟网络一点关系没有。3.4 拥塞控制给整个网络留条活路流量控制管的是别撑爆接收方拥塞控制管的是别撑爆网络。这两者经常被混淆但对象完全不同。网络中间的路由器缓冲区有限如果所有连接都拼命发路由器排队溢出大量丢包整个网络陷入拥塞崩溃。TCP 的拥塞控制就是通过动态调整发送速率来避免这个局面。经典的四阶段是慢启动、拥塞避免、快速重传、快速恢复。慢启动阶段拥塞窗口指数增长快速探测网络容量到达阈值后转为线性增长拥塞避免检测到丢包就砍窗口。这套机制的精髓在于试探——网络容量是未知的只能通过不断加压直到出现丢包信号来推断边界。现代内核普遍用 CUBIC 或 BBR 这类更先进的算法。CUBIC 用三次函数调整窗口在高带宽长距离场景下比传统算法表现好BBR 则干脆不把丢包当拥塞信号而是直接测量带宽和 RTT 来建模。选哪个算法要看你的网络特征——高丢包链路 BBR 往往更稳传统数据中心内 CUBIC 足够。这个选择没有标准答案得实测。4. 面向连接的真正含义连接是有生命周期的4.1 三次握手不只是打招呼三次握手的标准解释是确认双方的收发能力都正常但它的实际作用远不止于此。第一次 SYN 携带客户端的初始序列号第二次 SYNACK 携带服务端的初始序列号和对自己收到 SYN 的确认第三次 ACK 确认服务端的序列号。三步走完双方都确认了对方的序列号起点也确认了双向通道畅通。为什么是三次不是两次核心原因是防止历史连接请求造成混乱。假设一个很久以前发出的 SYN 延迟到达了服务端如果只握两次手服务端会直接建立连接并分配资源但客户端根本没想连这个连接就白白占着资源。三次握手让客户端有机会在第三步拒绝这个陈旧的连接请求。这个设计思想值得所有做协议设计的人学习任何状态同步都需要双向确认单向确认不足以排除历史残留。握手阶段还有个常被忽略的性能点SYN 队列和 Accept 队列。服务端收到 SYN 后连接先进入半连接队列SYN 队列握手完成后移入全连接队列Accept 队列等应用层调用 accept 取走。如果应用层 accept 太慢全连接队列满了新连接会被丢弃或拒绝。高并发服务调优时这两个队列的长度是必调参数队列太小会在流量高峰时出现莫名其妙的连接失败。4.2 四次挥手与 TIME_WAIT 的深意断开连接要四次挥手因为 TCP 是全双工的两个方向要分别关闭。主动关闭方发 FIN对方回 ACK此时主动方进入 FIN_WAIT_2对方数据发完后再发 FIN主动方回 ACK 并进入 TIME_WAIT等待 2 倍报文最大生存时间2MSL后才真正关闭。TIME_WAIT 是面试高频题也是实际运维中最常被问到的状态。它存在的理由有两个一是保证最后一个 ACK 能到达对方如果 ACK 丢了对方会重发 FIN处于 TIME_WAIT 的一方还能再回一次 ACK二是让本次连接的所有报文在网络中消散避免它们干扰后续使用相同四元组的新连接。很多人看到服务器上大量 TIME_WAIT 就慌其实这是正常现象尤其是主动关闭连接的一方。真正需要关注的是 TIME_WAIT 是否耗尽了可用端口。调优手段有调整内核参数缩短等待时间、开启端口复用等但更根本的是从架构上减少短连接——用连接池复用长连接比调参数治标治本。我见过一个服务每秒建几千个短连接TIME_WAIT 堆积如山最后改成连接池后问题自然消失参数一个没动。4.3 状态机视角下的异常连接把 TCP 的连接过程理解成一个状态机排障时会清晰很多。一个连接从 CLOSED 出发经过 LISTEN、SYN_SENT、SYN_RCVD、ESTABLISHED再到关闭路径上的 FIN_WAIT_1、FIN_WAIT_2、CLOSE_WAIT、LAST_ACK、TIME_WAIT每个状态对应内核里的一组定时器和行为。实际排障中两个状态特别值得警惕。CLOSE_WAIT 大量堆积说明对方已经发了 FIN但本端应用没有调用 close 关闭连接——这几乎总是应用层的 bug比如忘记释放连接、异常路径没走 finally。这个状态堆积不会自己消失只会越积越多直到耗尽文件描述符。SYN_RCVD 异常多则可能是遭遇了 SYN 洪泛半连接队列被占满正常握手无法完成。用ss -tan或netstat -tan看状态分布是排查连接问题的第一步。我习惯先看各状态的数量如果 ESTABLISHED 正常但 CLOSE_WAIT 上百基本可以锁定是应用没正确关闭连接如果 SYN_RCVD 异常高就往安全防护方向查。这个看状态分布定位问题域的方法比盲目抓包高效得多。5. 什么场景该用 TCP什么场景该绕开5.1 TCP 的主场一切不能容忍数据缺失的场景TCP 最典型的应用场景是那些数据完整性优先于实时性的场合。网页浏览HTTP/HTTPS、文件传输FTP、SFTP、邮件SMTP、POP3、IMAP、数据库连接、远程登录SSH这些场景里丢一个字节都可能导致文件损坏、页面错乱、命令执行错误所以必须用 TCP 的可靠机制兜底。拿 HTTP 举例一个网页的 HTML、CSS、JS、图片任何一部分缺失都会导致渲染异常。TCP 保证这些字节按序、完整地到达应用层才能安心地按格式解析。再比如数据库的 SQL 语句少一个字符语义就变了这种场景对可靠性是零容忍的。所以这些协议清一色建立在 TCP 之上不是没有道理的。这里要强调一个常被忽视的点TCP 的可靠性是字节流级别的它保证你发出去的字节序列完整有序地到达但它不保证应用层消息的边界。也就是说你发两次 100 字节接收方可能一次读到 200 字节也可能分三次读到。这就是所谓的粘包问题——其实不是 TCP 的错是应用层没做好消息定界。解决办法是在应用层加长度前缀或分隔符这是所有基于 TCP 的自定义协议都必须处理的问题。5.2 TCP 的软肋延迟敏感与广播多播TCP 在延迟敏感场景下的劣势根源在于它的重传和拥塞控制机制。一旦丢包哪怕只丢一个后续数据都得等重传这个等待可能是几十到几百毫秒。对于实时语音、视频会议、在线竞技游戏这类应用几百毫秒的卡顿是灾难性的。所以这些场景普遍选择 UDP然后在应用层做取舍——比如视频丢一帧就丢一帧用插值补上绝不为了等重传而卡住整个画面。另一个 TCP 无能为力的场景是广播和多播。TCP 是点对点的一个连接只能连两端天生不支持一对多。如果你要做局域网服务发现、视频直播分发就得用 UDP 的多播能力。这也是为什么很多发现协议比如一些设备自动发现机制都基于 UDP 实现。还有一类场景值得单独提短连接高频请求。如果你的应用是发一个请求、收一个响应、立刻关闭的模式TCP 的三次握手和四次挥手开销占比会很高尤其是跨地域的高延迟链路光握手就可能耗掉上百毫秒。这种场景要么用连接池复用连接要么考虑用 UDP 加应用层可靠性类似某些 RPC 框架的做法。盲目用短连接 TCP性能会很惨。5.3 混合策略在 TCP 之上做应用层优化实际工程中很少有非黑即白的选择。更常见的做法是在 TCP 之上做应用层优化扬长避短。比如 HTTP/2 的多路复用就是在一条 TCP 连接上并发多个请求减少了连接建立开销但 HTTP/2 也继承了 TCP 的队头阻塞问题——一个流丢包会阻塞所有流这也是 HTTP/3 转向基于 UDP 的 QUIC 协议的动因。再比如消息队列很多实现用 TCP 保证消息不丢但在应用层做批量确认、异步刷盘等优化平衡可靠性和吞吐。这些例子说明理解 TCP 的机制不是为了死守它而是为了知道它的边界在哪什么时候该在它之上加东西什么时候该换掉它。我个人的经验是默认用 TCP遇到明确的延迟或吞吐瓶颈时再针对性地评估是否换 UDP 或在应用层优化。这个顺序比一上来就追求极致性能要稳妥得多。6. 几个我踩过或见过的坑6.1 MSS 协商不当导致的分片性能问题有一次排查一个跨机房传输慢的问题抓包发现大量 IP 分片。原因是链路上某段 MTU 比以太网标准的 1500 小比如某些隧道封装会减小 MTU但 TCP 握手时协商的 MSS 还是按 1500 算的导致大报文到了那段链路被分片。分片的代价是只要丢一个分片整个原始报文都得重传效率极低。解决办法是调整 MSS 或者启用路径 MTU 发现PMTUD。但 PMTUD 依赖 ICMP 报文如果中间有设备屏蔽了 ICMP发现机制就失效反而更糟。所以稳妥的做法是在关键链路上显式设置合适的 MSS 上限。这个坑的教训是不要假设端到端 MTU 一致尤其是涉及隧道、专线、跨运营商的时候。6.2 应用层读太慢引发的窗口塌缩前面提过窗口塌缩这里展开说一个具体案例。某服务吞吐量上不去网络指标看着都正常抓包发现接收窗口长期在几百字节徘徊。查下来是应用层处理逻辑里有个同步的远程调用每次读数据后都要等远程返回才继续读导致接收缓冲区长期被占。网络层完全无辜问题在应用层的处理模型。这类问题的排查思路是先看窗口是不是持续偏小如果是就往应用层读取速度查而不是调 TCP 参数。TCP 的流量控制是忠实地反映应用层消费能力的窗口小说明应用层吃得慢调网络参数治标不治本。6.3 对 RST 的误判有次线上出现偶发的连接中断日志里报connection reset by peer。第一反应是网络抖动但抓包发现是对方主动发的 RST。进一步查是对方服务重启时旧连接在内核里还残留着新进程起来后收到旧连接的数据内核直接回 RST。这种情况在服务滚动发布时很常见。应对办法是在应用层做好重连和幂等不要假设连接永远有效。同时服务端优雅关闭时应该先停止接受新请求、处理完在途请求、再关闭监听而不是粗暴地 kill 进程。这个坑让我意识到RST 不一定是网络问题很多时候是连接生命周期管理没做好。6.4 长连接保活配置缺失还有个经典问题中间有防火墙或 NAT 设备时长时间空闲的 TCP 连接会被悄悄清理掉但两端应用都不知道下次发数据时才发现连接已死。表现就是隔一段时间第一次请求必失败重试就好。解决办法是开启 TCP 的保活机制keepalive定期发探测报文维持连接活跃。但系统默认的保活时间往往长达两小时对很多场景太长了需要在应用层或 socket 选项里调短。更主动的做法是在应用层做心跳这样既能保活又能及时发现对端异常。这个配置在跨网络的长连接场景里几乎是必做的省了它迟早出问题。TCP 这套协议从设计到现在几十年了中间修修补补加了窗口缩放、SACK、各种拥塞控制算法但核心骨架一直没变。把它的报文结构、可靠性机制、连接生命周期这三块吃透再结合抓包工具多观察真实流量比看十篇教程都管用。我个人的习惯是遇到任何网络异常先抓包看状态、看标志位、看窗口大部分问题在这一步就能定位个八九不离十。协议这东西纸上得来终觉浅多抓几次包那些字段和状态自然就活了。