🌐 第三章 网络间的通用语言TCP/IP
📄 一句话概括

前面几节一直在说"TCP 可靠",可从没打开过它。这一节把 TCP 本体摊开:它给应用程序的承诺是"一根不会丢字的管子",为了兑现这句承诺,它在报文头里塞进了序号、确认号、标志位、窗口四个字段,靠"按字节编号 → 累积确认 → 超时重传 → 滑动窗口连续发"四步把 3.2 那个"尽力而为、丢了不补"的 IP 网络包装成可靠服务。最后还有一个从没提过、却天天在坑你的性质:TCP 是字节流,它不保留你写了几次。

📝 本节和 3.5 的分工(别重复,也别混)

3.5 讲的是流程——三次握手、发一块确认一块、四次挥手,是"看一遍动画"。 本节讲的是机制——那些规矩靠报文头里的哪个字段实现、为什么这样设计,是"拆开引擎看零件"。 一句话:3.5 是操作手册,3.18 是原理图。

一、TCP 给应用程序的四条承诺

1.5 说过"电脑本身不上网,程序才上网"。那么程序找 TCP 要了什么?四条:

承诺 什么意思 反面(UDP 给什么)
① 面向连接 说话前先握手,说完了打招呼收场 没有连接,拿起来就喊
② 可靠 丢了我补、乱了我排,不丢字节、不重不漏、顺序不错 发出去就不管了
③ 字节流 你交给我的字节,我会按顺序、一个不少递到对面——但我不记得你分了几次交(第五节细讲) 一次一个数据报,边界清楚
④ 全双工 两个方向各有一条独立通道,可以同时收发 也可以双向,但没"连接"这回事
❗ 核心认知:这四条**不是网络的属性,是 TCP 拿开销买的服务**

底层 IP 只保证"尽力而为"(3.2),线路丢包、乱序、重复是常态而不是故障(1.4 的逐跳接力,每跳都可能出岔子)。

所以"可靠"不是天上掉下来的——是 TCP 在传输层一层层补出来的。代价也明摆着:要等确认(时间)、要留副本等重传(空间)、要维护连接状态(内存)。可靠是花钱买的,这句话本节才算真正兑现。

二、TCP 报文段首部:可靠机制的零件清单(本节第一张图)

TCP 把要干的事全写在自己这 20 字节的头上。对照 3.6:这是"数据段(Segment)"三个字的实物——往里装一层 TCP 头,数据包就升级成了数据段。

字段 位宽 干什么
源端口 / 目的端口 各 16 位 谁发给谁的程序(3.17 的正式出处)
序号(Sequence Number) 32 位 本报文段第一个字节的编号——注意是字节号,不是"第几个包"
确认号(Acknowledgment Number) 32 位 我下一个想收到的字节编号——等于告诉对方"这之前我全收到了"
标志位 6 位 SYN(我想建连接)、ACK(确认号有效)、FIN(我说完了)、RST(重来,连接作废)、PSH(别攒了,赶紧交上去)、URG(有紧急数据)
窗口大小 16 位 我还能再接多少字节——流量控制的全部依据
校验和 16 位 头和数据一起算(对比 3.7:IP 首部校验和只管头)
紧急指针 16 位 配合 URG,实战基本不用

两个值得单独拎出来的观察:

💡 ① 四个高亮字段 = 上一张图里"可靠"的全部零件

序号管"这段从哪个字节开始"、确认号管"我收到哪了"、标志位管"这句话是什么性质"、窗口管"我还能吃多少"。

3.5 讲的那些规矩(编号、应答、窗口、握手、挥手),全都是往这四个字段里填数字而已。没有第四个字段,前三个的规矩一条都跑不起来。

⚠️ ② TCP 头里**没有"总长度"字段**

对比 3.7 的 IP 头——那里有"总长度",因为 IP 需要知道整个包多大。

TCP 不需要:它外面已经套了 IP 包,"这个报文段多长"可以直接从 IP 头的总长度里倒推(减掉 IP 头长度就是 TCP 段长)。分层的好处在这露了一手——上一层知道的事,下一层不用重复写一遍。

三、可靠是怎么实现的:四大支柱

支柱一:按字节编号(不是按包编号)

这是最容易想当然的一处偏差。

TCP 给数据流里的每一个字节都编了号,序号字段填的是"本报文段里第一个字节的编号"。

假设从 1000 开始发 Hello(5 个字节):

字节编号:   1000 1001 1002 1003 1004
内容:        H    e    l    l    o

这一个报文段的序号 = 1000,长度为 5。下一个段就从 1005 接着编。

为什么按字节编,不按"第几个包"编? 因为报文段长度是变的(受 MTU 限制,3.4),如果按包编号,重传时长度一变,编号的含义就全乱了。按字节编,编号就与"数据本身"绑死——重传第 1000~1004 这一段,编号永远是 1000,一目了然。

💡 起始序号是随机挑的

3.5 握手时交换的那两个"起始序号",不是从 0 开始,而是随机值。原因在安全:如果固定从 0 起,攻击者就能猜出你的序号,往连接里塞伪造数据(这正是 TCP 序列号攻击的原理)。

支柱二:累积确认

确认号 = 我下一个期望收到的字节编号。这句话有两层意思:

我回你 ACK 2000 意思是
2000 之前的字节 全都收到了(不用一个一个确认)
2000 开始的字节 我还没收到,从这儿开始给我

这就是累积确认——一个数字确认了一大批字节,比"每个包回一次"省得多。

反过来,发送方只要收到 ACK,就知道 ACK 号之前的东西可以放心扔掉副本了(不用再留着等重传)。TCP 的内存就是这么省下来的。

⚠️ 常见误读

"ACK 2000 = 我收到了 2000 号这个字节" —— 错。是"我收到了 2000 之前的,现在等 2000"。差一个字节,重传逻辑就全错了。

支柱三:超时重传 + 快速重传

没收到确认 = 没送到(3.5 的核心思想),那怎么知道"该重传了"?两条路:

机制 触发条件 特点
超时重传 发出去开始计时,到点还没等到 ACK 慢但兜底,一定有它
快速重传 收到 3 个重复 ACK(连说三次"我还在等 2000") 快,不用干等超时

快速重传的巧思:如果 2000 号那个段丢了,但它后面的 2000+、2000+ 都到了,接收方每收到一个"后面的段"就回一次"我还在等 2000"——发三次,发送方就明白不是晚到了,是真丢了,立刻重传,不必等超时闹钟响。

💡 超时时间不是固定的

超时计时器的时间是根据这条连接实测的往返时间(RTT)动态估的——网络快就设短,慢就设长。设太短会疯狂重传白耗带宽,设太长则丢了半天不补。这个"往返时间探测"是 3.5 Demo19 里 curl -v 看不到、但一直在后台跑的东西。

支柱四:滑动窗口——不"发一个等一个"(本节第二张图)

这是本节最该带走的一张图。

如果有个人问你"TCP 怎么保证可靠",十有八九你答"发一个等一个",然后把 TCP 的效率想得极差。 实际不是这样:

停等协议 滑动窗口
做法 发一个段 → 停下等 ACK → 再发下一个 窗口内连着发好几个 → 边发边收 ACK → 窗口往前滑
线路利用 大量空转(一个往返才送一个段) 一直有货在跑
缺点 带宽白扔,越远越慢(RTT 长) 要缓冲、要排序

第二张图上半部分就是"停等"的样子——每个数据段后面拖着一段空转;下半部分是滑动窗口:窗口是一次允许在途、还没被确认的字节数,四个段连着发出去,收到累积确认后窗口整体右移,接着发 5、6。

一句话区别:停在"等",滑在"窗"——TCP 用窗口允许"边走边等",把等待时间用别的工作填上了。

❗ 窗口大小是"我还能吃多少",不是"你能发多少"

窗口字段由接收方填,说的是"我这边缓冲区还能接多少字节"。所以它是一根动态的流量阀(3.5 讲过的流量控制),不是常数。

缓冲区快满了 → 窗口报小;满了 → 窗口报 0,发送方立刻停止发送(但还要定期发一个探测包问"腾出位置了吗",防止死锁)。这就是 3.5 那句"快的不许欺负慢的"的零件级解释。

四、别把两种"控制"混成一个

这是本节最容易滑过去的一处,两者都叫"窗口",但管的完全不是一回事:

流量控制 拥塞控制
谁在管 接收方(对面累不累) 发送方自己(路上堵不堵)
依据 报文头里的窗口字段 丢包、RTT 变长这些网络信号
想解决 别把接收方缓冲区撑爆 别把中间网络压垮
手段 窗口大小 慢启动、拥塞避免、快重传、快恢复

发送方实际能发出的量 = min(接收窗口,拥塞窗口)——两个都满足才敢发。这条式子值得记:网络状态差时,即使接收方说"我还能吃很多",发送方也会自己收着发。

💡 慢启动:从 1 个段开始,指数翻倍

一条连接刚建好时,发送方根本不知道网络能吃多少,贸然猛发就会造成大面积丢包。所以它从很小的量起步、每收到确认就翻倍地涨(1→2→4→8…),撞到天花板再退回线性增长。

这也解释了一个日常感受:刚打开的网页/刚点开的视频,前一两秒总是慢的——不是线路慢,是 TCP 在试探。这也是为什么"长连接"比"每次重开"体验好。

五、字节流没有"消息边界"——本节最坑人的一条

现在说开头那个"从没提过、却天天在坑你"的性质。

TCP 承诺的是"字节不丢、不乱、不重",但它压根不记得"你分了几次交给我"。

你送三次:   "你好"       "在吗"        "再见"
             ↓ TCP 不管你怎么切的,它只看见一串连续的字节 ↓
对面收到:   "你好在吗再见"      ← 它不知道该在哪里断开

这叫粘包(其实不是"包粘了",是TCP 根本没有包的概念)。反过来也可能:你送一次的一大块,对面要 read 好几次才收全,这叫拆包。

❗ 那"消息边界"谁负责?—— 应用层自己定

三条最常用的路子:

办法 例子
加分隔符 HTTP 头用 \r\n 分行;Java 的 readLine() 就是"以 \n 当边界"
先说长度 先发 4 个字节写"接下来有 500 字节",对面先读长度再读正文
定长 每条消息固定 100 字节,不够的补空

TCP通信 里那些"看着奇怪"的写法,到这里全有了解释: - 为什么读文字必须套 BufferedReader + readLine()?—— 因为你得自己指定 \n 作为边界,read() 给不了你边界。 - 为什么发完要 shutdownOutput() 喊一声"我说完了"?—— 因为"说完"这件事,TCP 的字节流里根本没有这个信号,得靠关闭输出方向来传达(对面 read() 返回 -1)。 - 为什么 byte[1024] 小推车一次读不完整个文件?—— 字节流是水龙头,不是箱子,一次能接多少看对面送来多少、看缓冲区多大。

⚠️ Nagle 算法:还有一个"攒小包"的家伙在

TCP 为了效率,默认会把几个小的写操作攒起来一起发(减少包头开销)——这是 Nagle 算法。它会让"连续两次 write"更容易被对面"一次 read"读走。

对实时性敏感的程序(游戏、远程桌面、实时对战)会关掉它:Java 里 socket.setTcpNoDelay(true)。这是本节唯一一个"值得动手改一行代码"的地方。

六、TCP 的代价:为什么不是所有应用都用它

既然可靠,为什么还要 UDP?(3.3 的对比表在这里落地)

代价 具体表现 谁受不了这个代价 → 用 UDP
建立连接要时间 三次握手一个往返,才能发第一个字节 秒级查询(DNS 查询就一次往返,握手再花一次不划算)
确认要等往返 丢包要等超时才发现,实时场景"迟到=作废" 视频通话、直播、游戏——旧帧重传回来也没用了
要维护状态 每条连接一份缓冲、序号、计时器 一台服务器要扛几十万连接的场景(状态太贵)
拥塞控制会自我限速 网络差时主动减速,但直播宁可花屏也不愿变卡 实时流媒体

一句话:TCP 是"不许错"的协议,UDP 是"不许等"的协议。要哪个,看业务最怕什么。

七、动手 Demo32:看 TCP 的可靠机制在干什么

  1. 看本机的重传账本 netstat -s -p tcp 找「重新传输的段」/ Segments Retransmitted 那一项。
  2. 平时应当很小甚至为 0(线路干净)
  3. 下载一个大文件的同时再跑一遍,这个数字会开始涨——这就是 TCP 在偷偷补丢失的段,你在下载界面里看不到任何异常

  4. 看 TCP 的参数底牌 netsh int tcp show global 重点看两项:

  5. 「接收窗口自动调优级别」(Receive Window Auto-Tuning)—— Windows 的动态窗口开关,本节第三·四支柱的实物
  6. 「拥塞控制提供程序」—— Windows 默认在跑哪种拥塞控制算法(如 cubic / ctcp)

  7. 本节最实证的一步:亲手制造"粘包"(收 13.2 旧账)

在 13.2 的 Demo23 基础上改两行——

java // 客户端:连续写两次 OutputStream os = socket.getOutputStream(); os.write("A".getBytes()); os.flush(); os.write("B".getBytes()); os.flush(); java // 服务端:只读一次 byte[] buf = new byte[1024]; int len = is.read(buf); System.out.println("一次读到 " + len + " 字节:" + new String(buf, 0, len)); 在 127.0.0.1 上跑,大概率打印"一次读到 2 字节:AB"——你写两次,对面读一次。 - 这就是"TCP 是字节流,没有消息边界"的铁证(第五支柱) - 想让它变成两次?给第二次 write 前加个 Thread.sleep(50),或者调 socket.setTcpNoDelay(true)

  1. 30 秒实验:数一数你有多少条连接在跑 netstat -an | findstr ESTABLISHED 数一数条数。每一条背后都是一套独立的序号、确认号、窗口、计时器在跑——TCP 的"可靠"是一条连接一本账,不是全局一本账。这也解释了 3.17 那个"客户端临时号池只有 16384 个"为什么是个天花板。
❓ 自测一下
  1. TCP 给应用程序的四条承诺分别是什么?
  2. 序号是按什么编号的?为什么不按"第几个包"编?
  3. 收到 ACK 2000,意思是什么?"我收到了 2000 号字节"对不对?
  4. 超时重传和快速重传,判别条件分别是什么?
  5. 滑动窗口解决了"停等协议"的什么问题?
  6. 窗口大小是谁填的?报 0 意味着什么?
  7. 流量控制和拥塞控制,谁在管、依据什么、解决什么?
  8. TCP 为什么不保留"消息边界"?应用层怎么补?
  9. Java 里的 readLine() 和 shutdownOutput(),分别对应本节的哪个概念?

答案: ① 面向连接(先握手)、可靠(不丢不重不乱)、字节流(顺序递送一串字节,但不记你分了几次交)、全双工(两个方向各一条通道)。 ② 按字节编号,序号字段填的是"本报文段第一个字节的编号"。因为报文段长度可变(受 MTU 限制),按包编号在重传时长度一变含义就乱;按字节编,编号与数据本身绑死。 ③ 意思是"2000 之前的字节我全收到了,从 2000 开始还没收到"。所以"我收到了 2000 号字节"是错的,差一个字节,重传范围就判错了。这就是累积确认。 ④ 超时重传:发出后计时到点还没等到 ACK。快速重传:收到 3 个重复 ACK(对方连喊三次"我还在等 X")——说明不是晚到,是真丢了,不必等超时闹钟。 ⑤ 解决线路空转:停等是"一个往返只送一个段",RTT 越长浪费越大;滑动窗口允许窗口内的多个段同时在途,边发边等确认,把等待时间填满。 ⑥ 接收方填,说的是"我的缓冲区还能接多少字节"。报 0 表示缓冲区满了,发送方立即停发(并定期发探测包探路,防止死锁)。 ⑦ 流量控制由接收方管(别撑爆对方的缓冲区),依据报文头里的窗口字段;拥塞控制由发送方自己管(别压垮中间网络),依据丢包、RTT 变长等网络信号,手段是慢启动、拥塞避免等。真正能发出的量 = min(接收窗口,拥塞窗口)。 ⑧ 因为 TCP 只承诺"字节不丢、不乱、不重",它的世界里只有一串连续字节,没有"包"也没有"消息"这个概念,所以它不知道你分了几次交。应用层要自己定边界:加分隔符(HTTP 用 \r\n)、先说长度、定长三条路。 ⑨ readLine() 对应"加分隔符定边界"(以 \n 为界,因为 TCP 给不了边界,只能自己定);shutdownOutput() 对应"字节流里没有'我说完了'这个信号"——得靠关闭输出方向,让对面 read() 拿到 -1 来传达"没了"。

八、几条实战规矩

1. 永远不要假设"一次 write 对应一次 read"

这是本节最实用的一条。代码写成"我发一条,对面就收到一条"是迟早要出 bug 的。要按"读到字节流、按自己定的边界去切"来写。

2. 发文字必须定边界,别用裸 read()

要在 Java 里传文本,老老实实 BufferedReader.readLine();要传对象或自定义格式,先说长度最稳。

3. 实时性场景关掉 Nagle

socket.setTcpNoDelay(true);

少一次攒包的延迟,代价是包数变多。游戏、远程桌面、实时对战该关。

4. TCP 的"顺序"只在同一条连接内成立

两条不同的连接之间没有任何先后保证。所以多线程开两条连接各发一半数据,对面收到的顺序可能交叉——要保序就得挤在同一条连接里。

5. 连不上的那个报错,和 TCP 的标志位有关

Connection refused(13.2 的规矩一)——技术底细是 TCP 回了 RST:客户端发 SYN 敲门,那头端口上没有程序在听,操作系统直接回一个 RST("重来,这连接作废"),Java 就抛了异常。

所以"Connection refused"其实是好消息——它说明网络是通的、对方主机活着,只是那个端口没人守。真正可怕的是一直没反应(SYN 发出去石沉大海,最后超时)——那才可能是网络或防火墙的问题。

九、常见误区

  1. ❌ "TCP 的序号是按包编号的" → 按字节编号,序号填的是"本报文段第一个字节的编号";这是重传能精确补缺的前提
  2. ❌ "ACK 2000 = 收到了 2000 号字节" → 是"2000 之前的全收到了,从 2000 开始还没收到",差一个字节就会判错重传范围
  3. ❌ "TCP 是发一个等一个" → 那是停等协议。TCP 用滑动窗口,窗口内多个段同时在途,边发边等确认
  4. ❌ "窗口大小是固定值" → 由接收方动态填,可能变小甚至报 0;这才是流量控制
  5. ❌ "窗口就是拥塞控制" → 两个不同的东西:窗口是接收方说的(对面累不累),拥塞控制是网络状况决定的(路上堵不堵);实际发送量取两者较小值
  6. ❌ "TCP 会帮我保留消息边界" → 不保留,TCP 是纯字节流,根本没有"包/消息"这个概念;边界要应用层自己定(粘包问题的全部根源)
  7. ❌ "TCP 保证数据一定送达" → 它只保证"在连接存活期间尽力重传"。对方进程崩了、断网太久,TCP 最终会放弃并报错——可靠性是有边界的
  8. ❌ "TCP 更先进,UDP 迟早淘汰" → 取舍不同:TCP 不许错、UDP 不许等。视频通话、直播、DNS 查询、游戏都用 UDP,且活得很好
  9. ❌ "有了 TCP,下层丢包也无所谓" → TCP 的重传只对丢失的少量数据有效;大面积丢包时它只能降速(拥塞控制),网络真堵了,TCP 也救不回速度

十、下一步学什么

相关笔记