Skip to content

TCP 可靠性保证:确认、重传、流量控制、拥塞控制

TCP 为什么能保证「可靠传输」?

面试时很多人会说「TCP 是可靠协议」,但追问「怎么保证可靠的」,就答不上来了。

TCP 的可靠性靠四把「锁」:确认机制、重传机制、流量控制、拥塞控制

TCP 可靠性的四大机制

┌─────────────────────────────────────────────────────────────┐
│                    TCP 可靠性保证体系                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐     │
│  │   确认机制   │   │   重传机制   │   │   流量控制   │     │
│  │  (ACK/NACK) │   │(超时/快速)   │   │ (滑动窗口)   │     │
│  └─────────────┘   └─────────────┘   └─────────────┘     │
│                                                             │
│                      ┌─────────────┐                       │
│                      │   拥塞控制   │                       │
│                      │(慢启动/避免) │                       │
│                      └─────────────┘                       │
│                                                             │
│  确认机制 + 重传机制 → 保证数据不丢失                        │
│  流量控制        → 保证接收方不会溢出                       │
│  拥塞控制        → 保证网络不会拥塞                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

确认机制(Acknowledgment)

基本原理

发送方每发送一个数据段,接收方都要回复一个 ACK(确认)。

发送方                                      接收方
   │                                            │
   │  ──── 数据段 (seq=100, len=100) ────────> │
   │                                            │
   │  <────── ACK (ack=200) ─────────────────── │
   │                                            │
   │  确认收到 seq=100 开始的数据,共 100 字节   │
   │  下次期望收到 seq=200                      │

累积确认 vs 选择确认

累积确认(TCP 默认)

ACK=200 表示 seq=0-199 都已收到
即使中间丢了 seq=50-99,ACK 还是 200
发送方会重传 seq=50-199

选择确认(SACK,Selective Acknowledgment)

ACK=200, SACK=[500-600, 700-800]
告诉发送方:seq=0-199 收到了,seq=500-600 和 700-800 也收到了
发送方只需要重传 seq=200-499
java
// Java 启用 SACK
Socket socket = new Socket();
socket.setPerformancePreferences(0, 2, 1);  // 低延迟配置

捎带确认(Piggybacking)

正常情况下,ACK 是独立发送的。但如果此时接收方也要发送数据,可以把 ACK 捎带到数据段里:

发送方 ──── 数据 ────────────────────────────────────> 接收方
             (带上 ACK=200)

接收方 ──── 数据 (ack=200) ─────────────────────────> 发送方
             (数据带上对 seq=100 的确认)

重传机制(Retransmission)

超时重传

发送方发送数据后,启动一个计时器(RTO,Retransmission Timeout)。如果在 RTO 内没收到 ACK,就重传。

发送方 ──── 数据 (seq=100) ───────────> 接收方
            启动 RTO 计时器
            ...
            RTO 超时,没收到 ACK
            ...
            重传 ──── 数据 (seq=100) ───────────> 接收方

RTO 的计算是 TCP 的精髓之一。RFC 6298 定义了标准算法:

java
// RTT 和 RTO 计算(简化版)
public class RtoCalculator {
    private double srtt;  // 平滑往返时间
    private double rttvar; // RTT 变化
    private double rto;

    public RtoCalculator() {
        this.srtt = 0;
        this.rttvar = 0;
        this.rto = 1;  // 初始值 1 秒
    }

    // 更新 RTT
    public void updateRtt(double rtt) {
        if (srtt == 0) {
            // 首次采样
            srtt = rtt;
            rttvar = rtt / 2;
        } else {
            // RFC 6298 公式
            rttvar = 0.875 * rttvar + 0.125 * Math.abs(srtt - rtt);
            srtt = 0.875 * srtt + 0.125 * rtt;
        }
        // RTO = 1 秒(最小值)到 60 秒(最大值)
        rto = Math.max(1, Math.min(60, srtt + 4 * rttvar));
    }

    public double getRto() {
        return rto;
    }
}

快速重传

超时重传等待时间太长。快速重传利用重复 ACK 来快速检测丢包:

发送方 ──── 数据 (seq=100) ────────> 收到
发送方 ──── 数据 (seq=200) ────────> 收到
发送方 ──── 数据 (seq=300) ────────> 丢失!
发送方 ──── 数据 (seq=400) ────────> 收到

接收方 ──── ACK (ack=200) ────────> 
接收方 ──── ACK (ack=200) ────────>  3 个重复 ACK
接收方 ──── ACK (ack=200) ────────> 

收到 3 个重复 ACK,立即重传 seq=300

触发条件:收到 3 个或更多重复 ACK(TCP Reno 及之后版本)。

重传机制的配合

丢包场景下的重传策略:

1. 超时重传:最慢的兜底方案
   - RTO 到达后重传
   - 适用于严重丢包

2. 快速重传:快速的丢包检测
   - 3 个重复 ACK 触发
   - 优于超时重传

3. SACK:精准的重传
   - 告诉发送方哪些收到了
   - 只重传真正丢的部分

流量控制(Flow Control)

滑动窗口的引入

如果发送方发得太快,接收方处理不过来,会导致数据丢失。

TCP 通过滑动窗口来控制发送方的发送速度:

┌─────────────────────────────────────────────────────────────────┐
│                        发送方滑动窗口                            │
├──────────┬─────────────────────────────────────────┬────────────┤
│ 已发送   │              可发送区域                  │   未分配   │
│ 已确认   │  已发送未确认  │   可发送   │   不可发送  │   窗口外   │
├──────────┼─────────────────────────────────────────┼────────────┤
│ seq=100  │  100-199  │  200-299  │  300-399  │  seq>=400  │
│          │  已发送 ACK │  允许发送  │  窗口外    │            │
│          │  未收到 ACK │           │           │            │
└──────────┴─────────────────────────────────────────┴────────────┘
              ↑                          ↑
         左边收到 ACK 后          右边收到 ACK 后
            向右滑                    向右滑

接收方的窗口通告

接收方在 ACK 中告诉发送方自己的窗口大小:

TCP 头部中的窗口字段:
┌──────────────────────────────────────────────────────────────┐
│  ...        │        窗口大小 (16 位)          │  ...        │
└──────────────────────────────────────────────────────────────┘


              接收方告诉发送方:
              「我还有多少接收缓冲区空间」

零窗口与窗口探测

如果接收方的缓冲区满了,会通告窗口为 0:

发送方 ──── 数据 ────────────────────────────────> 接收方
             ...
接收方缓冲区满,无法接收新数据
             ...
接收方 ──── ACK (win=0) ───────────────────────> 发送方
             窗口大小为 0,暂停发送

发送方收到零窗口后,停止发送数据

但发送方不能一直等,要定期探测窗口是否恢复:

发送方 ──── 窗口探测 (1 字节) ────────────────> 接收方
接收方 ──── ACK (win=?) ───────────────────────> 发送方
             如果 win > 0,恢复发送

拥塞控制(Congestion Control)

为什么需要拥塞控制?

流量控制解决的是「接收方处理不过来」的问题。

拥塞控制解决的是「网络处理不过来」的问题。

场景:
- 路由器缓存队列满了
- 丢包激增
- 延迟飙升
- 整个网络陷入拥塞状态

拥塞控制的目标:
- 避免往已经拥塞的网络里塞更多数据
- 在拥塞时减少发送速率
- 在空闲时逐步增加发送速率

核心算法:慢启动 + 拥塞避免

┌─────────────────────────────────────────────────────────────┐
│                    拥塞控制算法                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  cwnd (拥塞窗口)                                            │
│     │                                                       │
│     │  慢启动阶段:指数增长                                   │
│     │     │                                                 │
│  24 │     │                                                │
│     │     │              拥塞避免:线性增长                   │
│  16 │     │                   │                             │
│     │     │                   │                            │
│  12 │     │                   │       发生拥塞               │
│     │     │                   │            │                 │
│  8  │     │                   │            ▼                 │
│     │     │                   │         cwnd = ssthresh     │
│  4  │     │                   │            │                 │
│     │     │                   │            ▼                 │
│  2  │     │                   │         减半                │
│     │     │                   │            │                 │
│  1  │     │                   │            ▼                 │
│     └─────┴───────────────────┴────────────┴──────────────► │
│         1  2  4  8  12  16  20  24  时间(RTT)              │
│                                                             │
│  ssthresh:慢启动阈值,到达后进入拥塞避免                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

慢启动(Slow Start)

连接建立初期,cwnd 很小,不知道网络能承受多大流量。

初始 cwnd = 1 MSS(最大报文段大小)
每个 RTT 后,cwnd 加倍

cwnd = 1
    ↓ (1 RTT)
cwnd = 2
    ↓ (1 RTT)
cwnd = 4
    ↓ (1 RTT)
cwnd = 8
    ↓ ...
直到 cwnd 达到 ssthresh,进入拥塞避免

拥塞避免(Congestion Avoidance)

每个 ACK,cwnd 增加 1 MSS(而不是翻倍)

相当于每个 RTT,cwnd 增加 1 MSS

增长速率:线性

快速恢复(Fast Recovery)

快速重传后,不是完全从头开始,而是进入快速恢复:

Reno 算法:
1. 收到 3 个重复 ACK
2. ssthresh = cwnd / 2
3. cwnd = ssthresh + 3 * MSS(跳过 3 个报文)
4. 进入拥塞避免

Tahoe 算法(旧版):
1. 收到 3 个重复 ACK
2. ssthresh = cwnd / 2
3. cwnd = 1 MSS
4. 回到慢启动阶段

拥塞检测:丢包怎么发现?

丢包的两种表现:
1. 超时:最严重的拥塞,重新慢启动
2. 快速重传:较轻的拥塞,快速恢复

综合示例:TCP 可靠性在代码中的体现

java
import java.io.*;
import java.net.*;

public class ReliableTCPClient {
    public static void main(String[] args) {
        String host = "example.com";
        int port = 80;

        try (Socket socket = new Socket(host, port)) {
            // 设置 socket 选项(影响 TCP 行为)
            socket.setTcpNoDelay(true);  // 禁用 Nagle 算法,低延迟
            socket.setReceiveBufferSize(64 * 1024);  // 接收缓冲区
            socket.setSendBufferSize(64 * 1024);     // 发送缓冲区
            socket.setKeepAlive(true);   // TCP 保活
            socket.setSoTimeout(30000); // 读超时

            // 发送 HTTP 请求
            String request = "GET / HTTP/1.1\r\n" +
                           "Host: example.com\r\n" +
                           "Connection: close\r\n" +
                           "\r\n";

            OutputStream out = socket.getOutputStream();
            out.write(request.getBytes());
            out.flush();

            // 读取响应
            InputStream in = socket.getInputStream();
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            byte[] buffer = new byte[4096];
            int len;

            while ((len = in.read(buffer)) != -1) {
                baos.write(buffer, 0, len);
            }

            System.out.println("收到 " + baos.size() + " 字节");
            System.out.println(new String(baos.toByteArray()));

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

查看 TCP 参数

bash
# Linux 查看 TCP 参数
sysctl -a | grep tcp

# 常用参数
net.ipv4.tcp_rmem       # 接收缓冲区大小
net.ipv4.tcp_wmem       # 发送缓冲区大小
net.ipv4.tcp_congestion_control  # 拥塞控制算法
net.ipv4.tcp_slow_start_after_idle  # 空闲后是否重置 cwnd

面试追问方向

  • TCP 如何保证可靠传输?有哪些机制?
  • 确认机制是什么?什么是累积确认和选择确认?
  • 什么是 RTO?如何计算?
  • 什么是快速重传?和超时重传有什么区别?
  • 什么是滑动窗口?窗口大小如何协商?
  • 什么是零窗口?如何处理?
  • 什么是拥塞控制?和流量控制的区别是什么?
  • 慢启动、拥塞避免、快速恢复分别是怎样工作的?
  • 拥塞控制算法有哪些?BBR 和 CUBIC 的区别是什么?

基于 VitePress 构建