上一篇我们提到,网络层的IP协议是"尽力而为"的——它会丢包、乱序、重复、损坏,却什么都不保证。而你用 Go 写 conn.Read,拿到的却是一段完整、有序、正确的字节流。这中间的落差,全靠 TCP 的一套可靠传输机制填平。这套机制的思想(编号、确认、超时重传、去重),不只是网络知识,更是一切"在不可靠信道上做可靠通信"问题的通用解法——包括你以后要设计的跨链消息协议。
首先需要明确:不可靠的 IP协议 会带来哪四类问题
TCP 要对付的,是 IP 层可能造成的四种"事故":
- 丢包:数据包在某个拥塞的路由器被丢弃,对方根本没收到。
- 乱序:不同包走了不同路径,后发的先到。
- 重复:某个包因超时被重发,结果原包和重发包都到了。
- 损坏:传输中比特翻转,数据出错。
TCP 的目标:让应用层完全感知不到这些事故,拿到的永远是"发送方按顺序写入的、原封不动的字节流"。下面的每个机制,都是针对上述某类问题的解药。
基石:序号(Seq)+ 确认(ACK)
TCP 可靠性的地基只有一句话:给每个字节编号,收到就回确认,没确认的就重发。
这套办法其实不是计算机发明的。想想寄重要文件的挂号信:每封信有个编号,对方收到要签回执;你手里留着底单,超过约定天数没等到回执,就照着底单再寄一份。编号解决"这是第几封"(顺序和去重),回执解决"到没到"(确认),底单解决"没到怎么办"(重传)。
编号 + 确认 + 重传,这三件套就是所有可靠传输的全部骨架,TCP 只是把它做到了字节粒度。这套机制统称 ARQ(自动重传请求,Automatic Repeat reQuest)。
序号:给字节流编号
TCP 是面向字节流的,它给发送的每一个字节都编了号(起点是握手时协商的随机 ISN,见上一篇)。一个 TCP 报文段的 seq 字段,就是它携带的第一个字节的编号。
有了编号,乱序和重复就好办了:接收方按序号把收到的段重新排好序,并丢弃序号重复的数据。这样即使 IP 层把包搞乱、搞重复,接收方也能还原出正确顺序、去掉冗余。
确认:累积确认(Cumulative ACK)
这里有个容易搞反的地方:TCP 的确认号回的不是"我收到了第 N 个",而是**“我下一个想要第 N 个”**。
这个设计很聪明。看电视剧追更的时候,你不会说"我看完了第 5 集",而是说"我要看第 6 集了"——后者同时表达了"前 5 集我都看完了"和"接下来给我第 6 集"两个意思,而且更不容易出歧义。
具体说:接收方每收到数据,就回一个带 ACK 标志的报文,其 ack 字段表示"我期待你下一个发的字节编号",等价于"ack 之前的所有字节我都收到了"。这叫累积确认:
发送方发出字节 1~1000(seq=1)、1001~2000(seq=1001)
接收方都收到 → 回 ack=2001 ("2001 之前的我全收到了,请从 2001 开始发")
累积确认的好处是容错:即使某个 ACK 丢了,只要后面更大的 ACK 到了,发送方一样知道前面都收到了。它的弱点后面第 5 节(SACK)会补。
注意 TCP 的 ACK 通常"搭便车"(piggyback): 确认不必单独发一个空包,而是捎带在反方向的数据报文里(全双工,两个方向都在传数据),省流量。
滑动窗口
保证可靠的最朴素的方法是停止等待:发一个段,傻等它的 ACK,收到了再发下一个。它能保证可靠,但慢得离谱。
慢到什么程度值得算一笔账。假设一个来回(RTT)要 100ms,每个段 1500 字节:那么一秒钟最多发 10 个段,也就是 15 KB/s——不管你家宽带是 100M 还是 1000M,都是这个数。因为瓶颈根本不在带宽,而在"你有 90% 以上的时间在干等"。
这就像只有一辆货车的物流公司:货车开到目的地、卸完货、空车开回来,才敢装第二车。路修得再宽也没用,因为路上永远只有一辆车。
而解法也很直白:多派几辆车,不等第一辆回来就发第二辆。
这就是流水线(pipelining):不等前一个的 ACK,连续发出多个段,让"发送"和"等待确认"重叠起来。
但也不能撒开了发——货车派太多,对方仓库卸不下(接收方缓冲区有限),路上还会堵死(网络拥塞)。所以要限定"已经上路、还没收到回执的货有多少",这个上限就是窗口(window)。
发送窗口示意(窗口大小 = 允许"在途未确认"的字节数):
已确认 │ 已发送、等待确认 │ 可以发送 │ 还不能发
─────────┼─────────────────┼──────────┼──────────▶ 字节序号
└────── 发送窗口 ──┘
收到 ack 后,窗口整体向右"滑动",腾出空间发新数据
窗口随着 ACK 的到来不断向右滑动,所以叫滑动窗口。它既提高了效率,又给"在途数据量"设了上限。
窗口到底多大? 这由两个东西共同决定:接收方的处理能力(流量控制)和网络的拥塞程度(拥塞控制)。
重传机制之一:超时重传(RTO)
发出去的数据,如果迟迟收不到 ACK,发送方就认为它丢了,重新发送。这就是超时重传。
真正的难点不在"重发"这个动作,而在那个"迟迟"——等多久才算丢了? 这个等待时间叫 RTO(Retransmission TimeOut),它的取值是个典型的两难。
RTO 不能是固定值
- RTO 设太短:网络只是稍微慢一点、ACK 还在路上,就误判丢包、重发,造成不必要的重传,加剧拥塞。
- RTO 设太长:真丢包了还傻等半天,恢复慢,延迟高。
所以 RTO 必须根据实际网络状况动态调整——具体说,要紧跟 RTT(Round-Trip Time,一个数据一来一回的时间) 的变化。网络快时 RTO 小,网络慢时 RTO 大。
怎么估计:平滑 RTT + 波动
TCP 持续测量每个段的 RTT 样本,用加权移动平均算出平滑值,同时根据其波动幅度(Jacobson/Karels 算法,RFC 6298 的简化描述):
SRTT(平滑 RTT)≈ 大部分用历史值 + 一小部分用新样本,让估计平滑不抖。RTTVAR(RTT 偏差)= 跟踪 RTT 的波动大小。RTO = SRTT + 4 × RTTVAR。波动越大,RTO 留的余量越大,避免误判。
两个重要细节
- Karn 算法:重传的段不能用来采样 RTT。因为收到 ACK 时你分不清它确认的是"原始段"还是"重传段",RTT 算不准。所以对重传的段不采样 RTT。
- 指数退避:如果重传后还是超时,说明网络可能真的很糟,每重传一次就把 RTO 翻倍(1s→2s→4s…),避免在拥塞的网络里火上浇油。这和以太坊等系统里的重试退避是同一种思想。
重传机制之二:快速重传(Fast Retransmit)
超时重传有个毛病:等一个 RTO 太久了。如果网络中间只丢了一个段,后面的段其实都到了,却要干等一整个超时周期,太浪费。
有没有办法不靠"等",而是靠"看出来"?有——从接收方的反应里读出线索。
想象你在念一份清单给同事记录:“第 1 条…第 2 条…第 3 条…"。如果第 2 条他没听清,他会怎么反应?他会一直打断你说”第 2 条?""第 2 条?""第 2 条?"——你听到他连喊三次要第 2 条,根本不用等,立刻就知道第 2 条丢了。
快速重传用的就是这个思路:
规则:接收方每收到一个"失序"的段,就立刻重发一个"它当前期待的 ACK"(重复 ACK)。发送方一旦收到 3 个重复的 ACK,不等超时,立即重传那个缺失的段。
发送方发出 段1 段2 段3 段4 段5
段2 丢了,段3/4/5 都到了但失序:
收到段1 → 回 ack=2
收到段3 → 期待的还是段2 → 回 ack=2 (第1个重复)
收到段4 → 回 ack=2 (第2个重复)
收到段5 → 回 ack=2 (第3个重复)
发送方收到 3 个 ack=2 → 立即重传段2,不必等 RTO
为什么是 3 个重复 ACK 而不是 1 个?因为1~2 个重复 ACK 很可能只是网络乱序(段2 稍微晚到而已),不一定是丢包;连续 3 个才比较确信"段2 真丢了"。这是"快速反应"和"避免误判"之间的权衡。
累积确认的短板与 SACK(选择性确认)
累积确认有个尴尬:它的表达能力太弱了,只能说"我按顺序收到了第几个",没法说"我收到了中间不连续的哪几段"。
还是念清单的例子:同事只会说"我要第 2 条",却没法告诉你"其实第 3、4、5 条我都记下来了,只缺第 2 条"。于是你干着急——保险起见,只好从第 2 条开始把后面全部重念一遍,而其中大部分他早就记好了。
上面例子里就是这样:段 2 丢了但段 3/4/5 都收到了,接收方却只能一直回 ack=2,发送方不知道 3/4/5 已经到了,超时后可能把 3/4/5 也一起重发,白白浪费带宽——而在网络已经拥塞到丢包的时候,这种浪费是雪上加霜。
SACK(Selective ACK,选择性确认) 解决这个问题:在 TCP 头部的选项里,接收方额外告诉发送方"我已经收到了哪些不连续的区块"(比如"我收到了 3001~5000")。这样发送方就只重传真正缺失的段2,不再重发已经收到的 3/4/5,大幅提高丢包时的效率。现代 TCP 普遍启用 SACK。
数据完整性:校验和 + 去重 + 排序
前面解决了丢和乱,还剩"损坏"和"重复":
- 校验和(Checksum):TCP 头部有一个 16 位校验和,覆盖头部 + 数据(还含一个"伪首部",含 IP 地址,用来防止送错主机)。接收方重新计算,对不上就丢弃这个段——丢弃后就等同于"丢包",触发发送方重传。(注意:16 位校验和很弱,只能查出部分错误,所以对数据完整性要求极高的应用还会在应用层再加更强的校验。)
- 去重:靠序号。收到序号已经确认过的数据,直接丢弃。这能对付 IP 层的重复包,以及自己超时重传导致的"原包+重传包都到了"。
- 排序:接收方有个接收缓冲区,把失序到达的段先缓存,等缺的段补齐后,再按序号顺序拼好、交给应用。所以应用永远拿到有序数据。
一次完整的可靠传输
把上面所有机制合到一起,TCP 发送一段数据的完整逻辑:
- 数据按字节编号,切成若干段,在滑动窗口允许的范围内连续发出(流水线)。
- 每发一个段,启动一个重传计时器(RTO,按 RTT 动态算)。
- 接收方收到段:校验和不对就丢弃;失序就缓存;正确就放进接收缓冲区,回累积 ACK(+ SACK 告知已收区块)。
- 发送方收到 ACK:窗口右滑,发送新数据。
- 若某段超时没收到 ACK → 超时重传(RTO 指数退避)。
- 若收到 3 个重复 ACK → 快速重传,不等超时。
- 接收方按序号排序、去重,把连续无缺口的数据交给应用。
结果:应用层通过 Read 拿到的,永远是发送方 Write 进去的那串字节,顺序一致、无丢无重无错。 所有的丢包、乱序、重复、损坏,都在 TCP 内部被消化掉了。
从 Go 的视角看(以及一个必知的坑)
对 Go 程序来说,TCP 的可靠性是完全透明的——操作系统内核实现了上面全部机制,你只管读写:
n, err := conn.Write(data) // 把 data 拷进内核发送缓冲区就返回
buf := make([]byte, 4096)
n, err = conn.Read(buf) // 从内核接收缓冲区读出已就绪的、有序的字节
但有两个极其重要、面试和实战都高频的认知,必须建立:
① Write 返回 ≠ 对方已收到。 conn.Write 返回,只代表数据被拷贝进了内核的发送缓冲区,内核会负责后续的分段、发送、重传。它绝不代表对方应用已经收到并处理了。 想确认对方真正收到,必须靠应用层自己的确认(比如对方回一个业务响应)。这一点在设计任何网络协议、消息系统、跨链桥时都是铁律。
② 面向字节流 → “粘包/拆包”。 TCP 是字节流,没有"消息边界"的概念。你连续 Write 了两条消息 "AB" 和 "CD",对方一次 Read 可能读到 "ABCD"(粘包),也可能分两次读到 "A" 和 "BCD"(拆包)。因为 TCP 只保证"字节有序到达",不保证"一次 Write 对应一次 Read"。解决办法是在应用层自己定义消息边界,常见三种:
- 固定长度:每条消息定长。
- 分隔符:用特殊字符(如
\n)分隔——bufio.Scanner就是干这个的。 - 长度前缀(最常用):每条消息前面加一个头,写明"接下来的消息有多少字节",接收方先读长度、再读对应字节。gRPC、多数 RPC 协议都用这种。
// 长度前缀分帧:先写 4 字节长度(网络字节序,呼应上上篇的大小端),再写消息体
func writeFrame(conn net.Conn, payload []byte) error {
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
if _, err := conn.Write(hdr[:]); err != nil { return err }
_, err := conn.Write(payload)
return err
}
粘包不是 TCP 的 bug,而是"字节流"的必然结果——理解了本篇的"TCP 是面向字节流的可靠通道",就理解了为什么必须自己分帧。