目录

网络-08 TCP 可靠传输

前置阅读:网络-07 TCP 连接管理

1. TCP 首部

./tcp首部格式.png

主要字段的作用:

  • Source Port和Destination Port:分别占用16位,表示源端口号和目的端口号;用于区别主机中的不同进程,而IP地址是用来区分不同的主机的,源端口号和目的端口号配合上IP首部中的源IP地址和目的IP地址就能唯一的确定一个TCP连接;
  • Sequence Number:用来标识从TCP发端向TCP收端发送的数据字节流,它表示在这个报文段中的的第一个数据字节在数据流中的序号;主要用来解决网络报乱序的问题;
  • Acknowledgment Number:32位确认序列号包含发送确认的一端所期望收到的下一个序号,比如收到了12345这5个字节的数据,序列号是1,那确认序列号就是6,因为下次需要从第6个字节发了。因此,确认序号应当是上次已成功收到数据字节序号加1。不过,只有当标志位中的ACK标志(下面介绍)为1时该确认序列号的字段才有效。主要用来解决不丢包的问题;
  • Offset:首部长度,或者理解为数据部分距离整个tcp报文开始的偏移量,需要这个值是因为任选字段的长度是可变的。这个字段占4bit(最大能表示15,但这里1代表4个字节,即首部长度为4*15=60个字节),因此TCP最多有60字节的首部。然而,没有任选字段,正常的长度是20字节;
  • TCP Flags:TCP首部中有6个标志比特,它们中的多个可同时被设置为1,主要是用于操控TCP的状态机的,依次为URG,ACK,PSH,RST,SYN,FIN。每个标志位的意思如下:
  • URG:发送端的缓存窗口中的数据是顺序发送,如果想插队先发送这段数据,可设置该标志位1。配合紧急指针使用。
  • ACK:此标志表示应答域有效,就是说前面所说的TCP应答号将会包含在TCP数据包中;有两个取值:0和1,为1的时候表示应答域有效,反之为0;连接建立后ACK都要是1。
  • PUSH:这个标志位表示Push操作。所谓Push操作就是指在数据包到达接收端以后,立即传送给应用程序,而不是在缓冲区中排队,意思就是在接收端进行插队,可以和URG类比记忆。
  • RST:发生了异常,需要重新建立链接。
  • SYN:表示同步序号,用来建立连接。SYN标志位和ACK标志位搭配使用,当连接请求的时候,SYN=1,ACK=0;连接被响应的时候,SYN=1,ACK=1;这个标志的数据包经常被用来进行端口扫描。扫描者发送一个只有SYN的数据包,如果对方主机响应了一个数据包回来 ,就表明这台主机存在这个端口;但是由于这种扫描方式只是进行TCP三次握手的第一次握手,因此这种扫描的成功表示被扫描的机器不很安全,一台安全的主机将会强制要求一个连接严格的进行TCP的三次握手;
  • FIN: 表示发送端已经达到数据末尾,也就是说双方的数据传送完成,没有数据可以传送了,发送FIN标志位的TCP数据包后,连接将被断开。这个标志的数据包也经常被用于进行端口扫描。
  • 窗口:表示我本地的接收缓存窗口还能接收多少数据。
  • 可选项:最大报文段长度MSS等。

2. 重传机制:停止等待与滑动窗口

停止等待协议和滑动窗口协议。 停止等待就是A发给B,B收到后回复确认,A收到后再发。A收到确认之前是要停止等待的,所以效率比较低,早期链路层就是这样的。 第二种方案是滑动窗口,连续发多个,如果有丢失的怎么办?理论上有两种方案:1. 选择重传(SR);2. 回退N帧(GBN)。

TCP 实际用的是哪种? 严格来说两者都不完全是。TCP 的基础机制是累积确认 + 快速重传:ACK 只能表达"我已经连续收到了 N 之前的所有字节",无法表达"我收到了 N,但没收到 N-1"。所以在只有基础机制时,发送方对丢失之后的数据其实是不知情的,行为更接近 GBN。

要做到真正的选择重传,需要 **SACK(Selective Acknowledgment,RFC 2018)**这个可选扩展——它在 TCP 首部选项里额外携带"我已收到的不连续区间",发送方据此只重传真正缺失的段。SACK 需要双方在三次握手时通过 SACK-Permitted 选项协商,它不是 TCP 的默认行为,而是一个可选项(不过现代操作系统基本都默认开启,Linux 下由 net.ipv4.tcp_sack 控制)。

./回退N帧.png

./选择重传.png

另外tcp还有个优化就是快重传,不用等到超时到期就能知道丢失了。

3. TCP 怎么保证可靠传输

校验、序号、确认、重传。

TCP 默认采用累积确认(cumulative ACK)。即接收方回复的 ack 号是 n,说明 n 号之前的数据都已经接收成功了。

完整来看,可靠性来自这些机制共同作用:

  • 校验和:覆盖 TCP 首部、数据以及由 IP 地址等构造的伪首部,既检查内容,也防止报文被交付给错误的端点;
  • 字节序号:TCP 给字节编号,不是给“应用消息”编号,用于重排乱序数据和识别重复数据;
  • 累积 ACKACK=n 表示 n 之前的连续字节都已收到;
  • 重传:包括超时重传和三个重复 ACK 触发的快速重传;
  • SACK:告知发送方已经收到的不连续区间,避免重复发送完整窗口;
  • 流量控制与拥塞控制:分别避免压垮接收方和网络。

收到乱序报文时,接收方通常先把它放进乱序队列,并继续回复当前期待的 ACK。缺失段补齐后,连续数据才交给应用。收到重复报文时,序号能让内核识别并丢弃重复内容,所以应用不会读到两份相同字节。

4. 超时重传

./1538277874498.png

如图所示,接收方接收到数据后要给客户端响应,来告诉别人自己收到了数据。 如果压根就没发到服务端,肯定不会收到服务端的响应,客户端等,等到超时后重传上个丢失的数据。 超时时间不是固定值,而是根据每条连接的 RTT 动态计算。

4.1 RTO 为什么不能简单等于 RTT

网络时延会波动。RTO(Retransmission Timeout)太短,会把只是迟到的包误判为丢失,制造无谓重传;太长,真正丢包后恢复又很慢。

TCP 会维护:

  • SRTT:平滑后的往返时间;
  • RTTVAR:RTT 的波动程度;
  • RTO:大致按 SRTT + 4 × RTTVAR 计算,并受最小/最大值约束。

重传后收到 ACK 时,无法判断它确认的是原包还是重传包,所以不能直接用这次样本更新 RTT,这就是 Karn 算法要解决的歧义。

一次超时往往意味着链路状况较差。每次连续超时后 RTO 会做指数退避(例如 1s、2s、4s、8s),避免重传进一步加剧拥塞。这解释了线上现象:连接失败有时不是均匀等待,而是越等越久。

Linux 可通过 ss -ti 查看连接的 rttrtocwnd 和重传统计:

ss -ti dst <目标IP>

5. 确认丢失

客户端成功发M1到服务端,服务端响应确认,但是确认包丢失了,客户端一样还会等,重传M1,这时候服务端收到了重复的数据,就会舍弃第二次收到的包,并再发对M1的响应。

6. 确认迟到

客户端在发送数据M1后,服务端发送响应,但是迟到了,客户端没有及时收到响应,超时后重发,服务端舍弃重复收到的数据并响应M1,此时客户端收到迟到的响应,并舍弃响应。

7. 粘包与拆包

粘包、拆包问题的产生原因笔者归纳为以下3种:

  1. socket缓冲区与滑动窗口
  2. MSS/MTU限制
  3. Nagle算法

socket缓冲区与滑动窗口

每个TCP socket在内核中都有一个发送缓冲区(SO_SNDBUF )和一个接收缓冲区(SO_RCVBUF),TCP的全双工的工作模式以及TCP的滑动窗口便是依赖于这两个独立的buffer的填充状态。

SO_SNDBUF:

进程发送的数据的时候假设调用了一个send方法,最简单情况(也是一般情况),将数据拷贝进入socket的内核发送缓冲区之中,然后send便会在上层返回。换句话说,send返回之时,数据不一定会发送到对端去(和write写文件有点类似),send仅仅是把应用层buffer的数据拷贝进socket的内核发送buffer中。

SO_RCVBUF:

把接受到的数据缓存入内核,应用进程一直没有调用read进行读取的话,此数据会一直缓存在相应socket的接收缓冲区内。再啰嗦一点,不管进程是否读取socket,对端发来的数据都会经由内核接收并且缓存到socket的内核接收缓冲区之中。read所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的buffer里面,仅此而已。

滑动窗口:

TCP连接在三次握手的时候,会将自己的窗口大小(window size)发送给对方,其实就是SO_RCVBUF指定的值。之后在发送数据的时,发送方必须要先确认接收方的窗口没有被填充满,如果没有填满,则可以发送。

每次发送数据后,发送方将自己维护的对方的window size减小,表示对方的SO_RCVBUF可用空间变小。

当接收方处理开始处理SO_RCVBUF 中的数据时,会将数据从socket 在内核中的接受缓冲区读出,此时接收方的SO_RCVBUF可用空间变大,即window size变大,接受方会以ack消息的方式将自己最新的window size返回给发送方,此时发送方将自己的维护的接受的方的window size设置为ack消息返回的window size。

此外,发送方可以连续的给接受方发送消息,只要保证对方的SO_RCVBUF空间可以缓存数据即可,即window size>0。当接收方的SO_RCVBUF被填充满时,此时window size=0,发送方不能再继续发送数据,要等待接收方ack消息,以获得最新可用的window size。

MSS/MTU分片

MTU (Maximum Transmission Unit,最大传输单元)是链路层对一次可以发送的最大数据的限制。MSS(Maximum Segment Size,最大分段大小)是TCP报文中data部分的最大长度,是传输层对一次可以发送的最大数据的限制。

以太网下 MSS 通常为 1460 字节(1500 - 20 IP首部 - 20 TCP首部)。应用层一次 send() 的数据如果超过 MSS,TCP 就必须把它拆成多个报文段发送——接收方读到的自然就不是一个完整的应用层消息,这就是"拆包"。详见 MSS和MTU

Nagle 算法

Nagle 算法要解决的是"小包泛滥"问题:如果应用层每次只发 1 个字节(比如 telnet 敲键盘),加上 20 字节 TCP 首部和 20 字节 IP 首部,有效载荷只占 1/41,网络利用率极低。

它的规则很简单——满足以下任一条件才真正把数据发出去:

  1. 积累的数据达到了 MSS 大小;
  2. 之前发出的数据全部收到了 ACK(即链路上没有未确认的数据在飞)。

否则就把小块数据先攒在缓冲区里等。效果就是多次 send() 的小数据会被合并成一个 TCP 报文段发出,这正是"粘包"最主要的来源。

接收端还有一个配套机制叫延迟确认(Delayed ACK):收到数据后不立即回 ACK,而是等一小会儿(Linux 下最多 40ms),期望能和自己要发的数据合并,或者等到多个报文段一起确认。

Nagle 和延迟确认同时开启时会互相拖累:发送方在等 ACK 才肯发下一批小数据,接收方却在故意延迟这个 ACK,一来一回凭空多出几十毫秒延迟。这对交互式应用和 RPC 是不能接受的。

所以对延迟敏感的场景要关掉 Nagle,即设置 TCP_NODELAY

// Go 中 TCPConn 默认就已经关闭了 Nagle(等价于 TCP_NODELAY = true)
// 如果确实需要开启 Nagle 攒包,要显式设置:
conn.(*net.TCPConn).SetNoDelay(false)

值得一提的是,Go 的 net.TCPConn 默认就设置了 TCP_NODELAY,也就是默认关闭 Nagle 算法,这和 C 语言里默认开启的行为相反,写网络程序时要注意这个差异。

小结:粘包/拆包的根源

说到底,“粘包"这个说法本身就带有误导性——TCP 是面向字节流的协议,它从设计上就不承诺保留应用层的消息边界。它眼里只有一串连续的字节,“包"的概念是应用层自己想象出来的。

所以正确的做法不是"解决粘包”,而是在应用层自己定义消息边界,常见三种方案:

  1. 固定长度:每条消息定长,简单但浪费;
  2. 分隔符:用特殊字符标记结尾(如 HTTP 头部用 \r\n\r\n),需要处理转义;
  3. 长度前缀:消息头里带上 body 长度(如 gRPC、Protobuf 的常见做法),最通用,推荐。

UDP 则不存在这个问题——它面向报文,保留边界,一次 sendto 对应一次 recvfrom

8. Go 中怎样正确使用 TCP 字节流

ReadWrite 的返回值只表示本次调用处理了多少字节

  • 一次 Write 的内容可能被拆成多次 Read
  • 多次 Write 的内容也可能被一次 Read 读到;
  • Write 可能出现短写,n < len(p) 时剩余内容仍要继续写;
  • Read 返回 n > 0 和非空 err 时,应先处理这 n 个字节,再处理错误。

发送完整缓冲区要循环:

func writeAll(w io.Writer, p []byte) error {
    for len(p) > 0 {
        n, err := w.Write(p)
        if err != nil {
            return err
        }
        if n == 0 {
            return io.ErrUnexpectedEOF
        }
        p = p[n:]
    }
    return nil
}

读取定长消息可用 io.ReadFull;变长协议则使用长度前缀或明确分隔符。永远不要把“一次 Read”当成“一条完整消息”。

网络超时也不能只依赖最外层 goroutine 的 context。裸 net.Conn 应设置 SetReadDeadline/SetWriteDeadline;取消 context 时,需要通过关闭连接或更新 deadline 唤醒正在阻塞的 IO。

9. 本章面试题

TCP 如何保证可靠传输?

六个机制配合:

  1. 校验和——检测传输过程中的数据错误;
  2. 序号——标识每个字节,解决乱序和重复;
  3. 确认(ACK)——采用累积确认,ack=n 表示 n 之前的数据都已收到;
  4. 超时重传——定时器到期未收到 ACK 就重传,超时时间动态计算
  5. 快重传——收到三个重复 ACK 立即重传,不等超时;
  6. 流量控制与拥塞控制——避免因过载导致的丢包(见 网络-09)。
TCP 用的是选择重传(SR)还是回退 N 帧(GBN)?

严格来说两者都不完全是。

TCP 的基础机制是累积确认 + 快速重传。ACK 只能表达"我连续收到了 N 之前的所有字节”,无法表达"我收到了 N 但没收到 N-1",所以仅有基础机制时行为更接近 GBN。

要做到真正的选择重传,需要 SACK(RFC 2018)这个可选扩展——它在 TCP 选项里携带已收到的不连续区间,发送方据此只重传真正缺失的段。

SACK 需要双方在三次握手时通过 SACK-Permitted 选项协商,不是 TCP 的默认行为(虽然现代系统基本默认开启,Linux 下由 net.ipv4.tcp_sack 控制)。

快重传的机制是什么?判断和重传发生在哪一方?

接收方每收到一个失序报文段,就立刻重复发送它当前期望的 ack 号。发送方连续收到三个重复 ACK 即判定该段丢失并立即重传,不必等重传定时器超时。

注意方向:判断和重传的动作都在发送方。接收方只是通过重复 ACK 传递"我这里缺了东西"的信号,并没有"通知对方重发"的专门报文。

为什么是三个而不是一个?因为少量的重复 ACK 可能只是乱序(包走了不同路径),三个才足以判定为丢包。

什么是粘包和拆包?为什么会发生?怎么解决?

首先,“粘包"这个说法本身带有误导性。 TCP 是面向字节流的,它从设计上就不承诺保留应用层的消息边界——它眼里只有连续的字节,“包"是应用层想象出来的概念。

成因

  • 拆包:一次发送的数据超过 MSS,被拆成多个报文段;
  • 粘包Nagle 算法把多次小的 write() 合并成一个段发出;发送/接收缓冲区也会让数据连成一片。

正确做法不是"解决粘包”,而是在应用层自己定义消息边界

  1. 固定长度——简单但浪费;
  2. 分隔符——如 HTTP 头部用 \r\n\r\n,需处理转义;
  3. 长度前缀——消息头带 body 长度,最通用,推荐(gRPC、Protobuf 的做法)。

UDP 不存在这个问题——它面向报文、保留边界,一次 sendto 对应一次 recvfrom

Nagle 算法解决什么问题?为什么常要关掉它?

解决小包泛滥:应用每次只发 1 字节(如 telnet 敲键盘)时,加上 40 字节的 TCP+IP 首部,有效载荷仅占 1/41,网络利用率极低。

规则:满足任一条件才真正发送——① 积累数据达到 MSS;② 之前发出的数据全部收到 ACK。否则先攒着。

为什么要关:接收端还有延迟确认(Delayed ACK)机制,收到数据后不立即回 ACK(Linux 下最多 40ms)。两者同时开启会互相拖累——发送方等 ACK 才发下一批,接收方却故意延迟这个 ACK,凭空多出几十毫秒延迟。这对交互式应用和 RPC 不可接受。

关闭方式是设置 TCP_NODELAY。注意 Go 的 net.TCPConn 默认已关闭 Nagle,与 C 语言默认开启的行为相反。

累积确认是什么意思?确认丢失和确认迟到分别会怎样?

累积确认:接收方回的 ack=n 表示"n 之前的所有数据我都收到了”,而不是"我只收到了第 n 个"。

  • 确认丢失:发送方收不到 ACK,超时后重传。接收方发现是重复数据,丢弃重复的那份并重新发送 ACK
  • 确认迟到:发送方超时重传,接收方丢弃重复数据并再发 ACK;此时迟到的原 ACK 抵达,发送方直接丢弃它(已经处理过了)。

两种情况都能正确收敛,代价只是一次多余的传输。

TCP 的重传超时 RTO 是怎么确定的?为什么会指数退避?

TCP 根据 RTT 样本维护平滑值 SRTT 和波动值 RTTVAR,RTO 大致按 SRTT + 4×RTTVAR 计算。重传后的 ACK 无法判断确认的是原包还是重传包,Karn 算法会避开这种有歧义的 RTT 样本。

连续超时通常意味着网络状况很差,因此 RTO 按 1、2、4、8 秒一类方式指数退避,避免重传进一步加剧拥塞。

Go 中一次 Write 是否对应对端一次 Read?短读短写怎么处理?

不对应。TCP 只提供字节流,一次 Write 可能被拆成多次 Read,多次 Write 也可能被一次 Read 合并。

定长数据使用 io.ReadFull;变长协议使用长度前缀或分隔符。Write 后检查 n,未写完的部分继续写;Read 返回 n > 0 和错误时先处理这 n 个字节。