前面几节一直在说"TCP 可靠",可从没打开过它。这一节把 TCP 本体摊开:它给应用程序的承诺是"一根不会丢字的管子",为了兑现这句承诺,它在报文头里塞进了序号、确认号、标志位、窗口四个字段,靠"按字节编号 → 累积确认 → 超时重传 → 滑动窗口连续发"四步把 3.2 那个"尽力而为、丢了不补"的 IP 网络包装成可靠服务。最后还有一个从没提过、却天天在坑你的性质:TCP 是字节流,它不保留你写了几次。
3.5 讲的是流程——三次握手、发一块确认一块、四次挥手,是"看一遍动画"。 本节讲的是机制——那些规矩靠报文头里的哪个字段实现、为什么这样设计,是"拆开引擎看零件"。 一句话:3.5 是操作手册,3.18 是原理图。
一、TCP 给应用程序的四条承诺
1.5 说过"电脑本身不上网,程序才上网"。那么程序找 TCP 要了什么?四条:
| 承诺 | 什么意思 | 反面(UDP 给什么) |
|---|---|---|
| ① 面向连接 | 说话前先握手,说完了打招呼收场 | 没有连接,拿起来就喊 |
| ② 可靠 | 丢了我补、乱了我排,不丢字节、不重不漏、顺序不错 | 发出去就不管了 |
| ③ 字节流 | 你交给我的字节,我会按顺序、一个不少递到对面——但我不记得你分了几次交(第五节细讲) | 一次一个数据报,边界清楚 |
| ④ 全双工 | 两个方向各有一条独立通道,可以同时收发 | 也可以双向,但没"连接"这回事 |
底层 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 讲的那些规矩(编号、应答、窗口、握手、挥手),全都是往这四个字段里填数字而已。没有第四个字段,前三个的规矩一条都跑不起来。
对比 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→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] 小推车一次读不完整个文件?—— 字节流是水龙头,不是箱子,一次能接多少看对面送来多少、看缓冲区多大。
TCP 为了效率,默认会把几个小的写操作攒起来一起发(减少包头开销)——这是 Nagle 算法。它会让"连续两次 write"更容易被对面"一次 read"读走。
对实时性敏感的程序(游戏、远程桌面、实时对战)会关掉它:Java 里 socket.setTcpNoDelay(true)。这是本节唯一一个"值得动手改一行代码"的地方。
六、TCP 的代价:为什么不是所有应用都用它
既然可靠,为什么还要 UDP?(3.3 的对比表在这里落地)
| 代价 | 具体表现 | 谁受不了这个代价 → 用 UDP |
|---|---|---|
| 建立连接要时间 | 三次握手一个往返,才能发第一个字节 | 秒级查询(DNS 查询就一次往返,握手再花一次不划算) |
| 确认要等往返 | 丢包要等超时才发现,实时场景"迟到=作废" | 视频通话、直播、游戏——旧帧重传回来也没用了 |
| 要维护状态 | 每条连接一份缓冲、序号、计时器 | 一台服务器要扛几十万连接的场景(状态太贵) |
| 拥塞控制会自我限速 | 网络差时主动减速,但直播宁可花屏也不愿变卡 | 实时流媒体 |
一句话:TCP 是"不许错"的协议,UDP 是"不许等"的协议。要哪个,看业务最怕什么。
七、动手 Demo32:看 TCP 的可靠机制在干什么
- 看本机的重传账本
netstat -s -p tcp找「重新传输的段」/Segments Retransmitted那一项。 - 平时应当很小甚至为 0(线路干净)
-
下载一个大文件的同时再跑一遍,这个数字会开始涨——这就是 TCP 在偷偷补丢失的段,你在下载界面里看不到任何异常
-
看 TCP 的参数底牌
netsh int tcp show global重点看两项: - 「接收窗口自动调优级别」(Receive Window Auto-Tuning)—— Windows 的动态窗口开关,本节第三·四支柱的实物
-
「拥塞控制提供程序」—— Windows 默认在跑哪种拥塞控制算法(如
cubic/ctcp) -
本节最实证的一步:亲手制造"粘包"(收 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)
- 30 秒实验:数一数你有多少条连接在跑
netstat -an | findstr ESTABLISHED数一数条数。每一条背后都是一套独立的序号、确认号、窗口、计时器在跑——TCP 的"可靠"是一条连接一本账,不是全局一本账。这也解释了 3.17 那个"客户端临时号池只有 16384 个"为什么是个天花板。
- TCP 给应用程序的四条承诺分别是什么?
- 序号是按什么编号的?为什么不按"第几个包"编?
- 收到 ACK 2000,意思是什么?"我收到了 2000 号字节"对不对?
- 超时重传和快速重传,判别条件分别是什么?
- 滑动窗口解决了"停等协议"的什么问题?
- 窗口大小是谁填的?报 0 意味着什么?
- 流量控制和拥塞控制,谁在管、依据什么、解决什么?
- TCP 为什么不保留"消息边界"?应用层怎么补?
- 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 发出去石沉大海,最后超时)——那才可能是网络或防火墙的问题。
九、常见误区
- ❌ "TCP 的序号是按包编号的" → 按字节编号,序号填的是"本报文段第一个字节的编号";这是重传能精确补缺的前提
- ❌ "ACK 2000 = 收到了 2000 号字节" → 是"2000 之前的全收到了,从 2000 开始还没收到",差一个字节就会判错重传范围
- ❌ "TCP 是发一个等一个" → 那是停等协议。TCP 用滑动窗口,窗口内多个段同时在途,边发边等确认
- ❌ "窗口大小是固定值" → 由接收方动态填,可能变小甚至报 0;这才是流量控制
- ❌ "窗口就是拥塞控制" → 两个不同的东西:窗口是接收方说的(对面累不累),拥塞控制是网络状况决定的(路上堵不堵);实际发送量取两者较小值
- ❌ "TCP 会帮我保留消息边界" → 不保留,TCP 是纯字节流,根本没有"包/消息"这个概念;边界要应用层自己定(粘包问题的全部根源)
- ❌ "TCP 保证数据一定送达" → 它只保证"在连接存活期间尽力重传"。对方进程崩了、断网太久,TCP 最终会放弃并报错——可靠性是有边界的
- ❌ "TCP 更先进,UDP 迟早淘汰" → 取舍不同:TCP 不许错、UDP 不许等。视频通话、直播、DNS 查询、游戏都用 UDP,且活得很好
- ❌ "有了 TCP,下层丢包也无所谓" → TCP 的重传只对丢失的少量数据有效;大面积丢包时它只能降速(拥塞控制),网络真堵了,TCP 也救不回速度
十、下一步学什么
- ☑ 3.19 用TCP对数据进行分割处理(序列号、ACK号) —— ✅ 已完成 → 用TCP对数据进行分割处理(序列号、ACK号):本节说"序号按字节编",那一节看 10000 字节真被切成 7 段、号怎么一路递推上去
- ☐ 3.20(按教材顺序) —— 报个标题接着走:按教材脉络,接下来大概率是 UDP(不可靠但快的那一半)、DNS 与域名(把名字换成 IP),或 第三章小结。等你报标题。
相关笔记
- 用TCP对数据进行分割处理(序列号、ACK号) —— 下一节:本节挑出的"序号、确认号"两个字段,在那一节看它们真干活的现场(切段、编号、重复 ACK)
- 接收和传输数据的规则(使用TCP/IP进行通信) —— 本节的操作手册版:握手、确认、重传、窗口、挥手的完整流程,本节拆的是它背后的零件
- 为驱动应用程序而做准备的网络分层(传输层与应用层) —— "TCP 保价快递 vs UDP 同城闪送"的最早出处,本节把 TCP 这半边彻底拆开
- 根据端口号分配数据到应用程序(端口号、已知端口号) —— 端口号就是 TCP 头里的源/目的端口两个字段;"临时号池 16384 个"的上限在本节有了另一重解释
- 数据的称谓(信息、数据段、数据包、数据帧) —— "数据段 Segment"就是本节这张头的实物;加 TCP 头 = 升级成数据段
- 数据的发送和接收(IP、IP数据包、路由) —— 对照阅读:IP 头有"总长度"和"首部校验和只管头",TCP 头没有总长度、校验和却管上数据
- 收发数据时的规则(数据打包) —— "包 = 头部 + 数据"的最早出处;MTU 决定了每个数据段最长能装多少
- 负责传输数据的网络分层(网络接口层与网际层) —— "IP 尽力而为、丢了不补"挖的坑,本节看到 TCP 拿四个字段把它填平
- 数据是否成功传输给接收方(ICMP) —— 对比:IP 出问题靠 ICMP 报信但不修复,TCP 出问题自己补——"只报告不修复"的反面
- 网络的网络(互联网) —— 逐跳接力、线路不可靠,正是 TCP 存在的全部理由
- TCP通信 —— 本节收的最大一笔旧账:为什么
readLine()要套缓冲、为什么必须shutdownOutput()、为什么byte[1024]一次拉不完 - UDP通信 —— 对照面:无连接、无确认、无窗口,换来的是一次一个数据报、边界清清楚楚