用了TCP就一定不会丢包吗?

摘要:从一次"TCP连接正常但数据传输失败"的诡异故障出发,深度剖析TCP可靠性的边界和局限。通过滑动窗口、超时重传、拥塞控制的原理图解,以及缓冲区溢出、连接异常断开的真实案例,揭秘为什么TCP只保证可靠传输不保证不丢包、应用层缓冲区满了会怎样、以及如何检测TCP丢包。配合时序图展示重传机制,给出高可靠性场景的应用层确认方案。


💥 翻车现场

周五下午,哈吉米收到用户投诉。

用户反馈:"我上传了100张图片,但后台只显示95张,丢了5张!"

哈吉米查看代码:

// 文件上传接口
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) {
    // 保存文件
    String url = fileService.save(file);
    return Result.ok(url);
}

查看日志

2024-10-07 15:23:45 [INFO] 上传文件: photo1.jpg, 大小: 2MB
2024-10-07 15:23:46 [INFO] 上传文件: photo2.jpg, 大小: 2MB
...
2024-10-07 15:23:58 [INFO] 上传文件: photo95.jpg, 大小: 2MB

(缺少photo96-100的日志)

哈吉米:"明明用户上传了100张,为什么只收到95张?TCP不是可靠传输吗?"

查看网络监控:

网络监控:
- TCP重传率:0.01%
- 连接重置:5次(RST包)
- 上传时间:15:23:45 - 15:23:58(13秒)

哈吉米:"有5次连接重置(RST),正好丢了5张图片!"

南北绿豆和阿西噶阿西来了。

南北绿豆:"TCP可靠传输不等于不丢包!连接异常断开,数据照样丢。"
哈吉米:"???"
阿西噶阿西:"来,我给你讲讲TCP丢包的5种场景。"


🤔 TCP可靠传输的真相

TCP保证什么?

南北绿豆在白板上写下TCP的保证。

TCP保证的是:
在连接正常的情况下,数据可靠传输

具体保证:
1. 数据按序到达(顺序保证)
2. 数据不重复(去重)
3. 数据不出错(校验和)
4. 丢包会重传(超时重传)

但:
如果连接异常(断开、RST)→ 数据可能丢失 ❌

阿西噶阿西:"所以TCP只保证在连接正常的前提下可靠传输,不是绝对不丢包!"


🕳️ TCP丢包场景1:连接异常断开

场景:发送缓冲区溢出

场景:
1. 客户端快速发送数据(100MB)
2. 网络慢(带宽小)
3. 发送缓冲区满了(默认256KB)
4. 数据积压
5. 内核发送不出去
6. 超过重传次数
7. 连接重置(RST)
8. 数据丢失 ❌

发送缓冲区溢出时序图

sequenceDiagram
    participant App as 应用
    participant SendBuffer as 发送缓冲区(256KB)
    participant TCP as TCP协议栈
    participant Network as 网络

    App->>SendBuffer: 1. write(100MB数据)
    Note over SendBuffer: 缓冲区满(256KB)
    SendBuffer->>App: 2. 阻塞(等待缓冲区有空间)
    
    SendBuffer->>TCP: 3. 发送数据包
    TCP->>Network: 4. 发送到网络(慢,1MB/s)
    
    Note over SendBuffer: 256KB缓冲区,1MB/s速度<br/>需要0.25秒才能清空
    
    Note over App: 应用等待0.25秒
    
    alt 网络正常
        Network->>TCP: 5. ACK确认
        TCP->>SendBuffer: 6. 清空缓冲区
        SendBuffer->>App: 7. write()返回
    else 网络故障
        Note over Network: 数据包丢失,无ACK
        TCP->>TCP: 8. 超时重传(默认15次)
        Note over TCP: 重传15次仍失败
        TCP->>App: 9. 连接重置(RST)
        Note over App: 数据丢失 ❌
    end

接收缓冲区溢出

场景:
1. 服务器发送大量数据
2. 客户端处理慢(如写磁盘慢)
3. 接收缓冲区满了(默认256KB)
4. 客户端通告窗口为0(告诉服务器别发了)
5. 服务器停止发送
6. 客户端长时间不读取数据
7. 超时,连接断开
8. 数据丢失 ❌

🕳️ TCP丢包场景2:应用层没读取完数据就关闭

问题代码

// ❌ 错误:没读完数据就关闭
Socket socket = new Socket("server", 8080);

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

// 立即关闭(可能服务器还没发完响应)
socket.close();  // 发送FIN

// 如果服务器还在发送数据:
// 1. 客户端收到数据包
// 2. 但socket已关闭
// 3. 回复RST(连接重置)
// 4. 服务器收到RST,连接断开
// 5. 服务器剩余数据丢失 ❌

正确写法

// ✅ 正确:读取完所有响应再关闭
Socket socket = new Socket("server", 8080);

// 发送请求
OutputStream os = socket.getOutputStream();
os.write("request".getBytes());
os.flush();

// 关闭写(告诉服务器:我不发了)
socket.shutdownOutput();  // 发送FIN

// 继续读取响应
InputStream is = socket.getInputStream();
byte[] buffer = new byte[1024];
while (is.read(buffer) != -1) {
    // 处理数据
}

// 全部读取完,再关闭连接
socket.close();

🕳️ TCP丢包场景3:连接超时

场景:长时间无数据传输

场景:
1. TCP连接建立
2. 10分钟没有数据传输(双方都不发送)
3. 中间的NAT设备超时(NAT超时时间通常5分钟)
4. NAT删除连接映射
5. 客户端发送数据 → 无法到达服务器
6. 超时重传多次失败
7. 连接断开 ❌

解决方案:心跳保活

// TCP KeepAlive(系统层面)
Socket socket = new Socket();
socket.setKeepAlive(true);  // 开启TCP KeepAlive

// Linux内核参数
sysctl -a | grep tcp_keepalive
net.ipv4.tcp_keepalive_time = 7200    # 7200秒(2小时)无数据才发探测包
net.ipv4.tcp_keepalive_intvl = 75     # 探测间隔75秒
net.ipv4.tcp_keepalive_probes = 9     # 探测9次失败,关闭连接

// 问题:默认2小时才发探测包,太久了


// 应用层心跳(推荐)
@Scheduled(fixedDelay = 30000)  // 每30秒
public void sendHeartbeat() {
    if (socket != null && socket.isConnected()) {
        socket.getOutputStream().write("ping".getBytes());
        socket.getOutputStream().flush();
    }
}

🕳️ TCP丢包场景4:对端进程崩溃

场景

场景:
1. 客户端和服务器正常通信
2. 服务器进程突然崩溃(kill -9、OOM)
3. 操作系统发送RST(连接重置)
4. 客户端收到RST
5. 连接断开
6. 正在传输的数据丢失 ❌

检测连接断开

// 发送数据时检测
try {
    socket.getOutputStream().write(data);
    socket.getOutputStream().flush();
} catch (SocketException e) {
    // 连接断开(对端发送RST)
    log.error("连接断开", e);
}

// 读取数据时检测
int len = socket.getInputStream().read(buffer);
if (len == -1) {
    // 对端关闭连接(收到FIN)
    log.info("对端关闭连接");
}

🕳️ TCP丢包场景5:网络分区

场景

场景:
1. 客户端和服务器正常通信
2. 中间的网络设备故障(路由器、交换机)
3. 网络分区(客户端和服务器网络不通)
4. TCP重传(默认15次)
5. 重传全部失败
6. 连接超时,断开
7. 数据丢失 ❌

🛡️ 如何保证不丢数据?

方案1:应用层确认(推荐⭐⭐⭐⭐⭐)

// 服务端
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) {
    // 保存文件
    String fileId = fileService.save(file);
    
    // 返回fileId(应用层确认)
    return Result.ok(fileId);
}

// 客户端
public void uploadFile(File file) {
    try {
        Result result = restTemplate.postForObject(
            "/upload", 
            new FileEntity(file), 
            Result.class
        );
        
        if (result.getCode() == 0) {
            String fileId = result.getData();
            log.info("上传成功:{}", fileId);  // 应用层确认 ✅
        } else {
            log.error("上传失败,重试");
            retry(file);  // 重试
        }
        
    } catch (Exception e) {
        log.error("上传异常,重试", e);
        retry(file);  // 重试
    }
}

方案2:幂等性设计

// 每个文件生成唯一ID
String fileId = md5(file);  // 根据内容生成MD5

// 上传时带上fileId
Result result = restTemplate.postForObject(
    "/upload?fileId=" + fileId, 
    file, 
    Result.class
);

// 服务端幂等性判断
@PostMapping("/upload")
public Result upload(@RequestParam String fileId, 
                     @RequestParam("file") MultipartFile file) {
    
    // 检查是否已上传
    if (fileService.exists(fileId)) {
        return Result.ok(fileId);  // 已上传,返回成功(幂等)
    }
    
    // 保存文件
    fileService.save(fileId, file);
    
    return Result.ok(fileId);
}

方案3:断点续传

// 大文件分片上传
public void uploadLargeFile(File file) {
    String fileId = md5(file);
    long fileSize = file.length();
    int chunkSize = 1024 * 1024;  // 每片1MB
    int totalChunks = (int) Math.ceil((double) fileSize / chunkSize);
    
    for (int i = 0; i < totalChunks; i++) {
        // 读取分片
        byte[] chunk = readChunk(file, i * chunkSize, chunkSize);
        
        // 上传分片(带上分片号)
        Result result = uploadChunk(fileId, i, totalChunks, chunk);
        
        if (result.getCode() != 0) {
            log.error("分片{}上传失败,重试", i);
            i--;  // 重试当前分片
        }
    }
    
    // 通知服务器:所有分片上传完成,合并文件
    mergeChunks(fileId, totalChunks);
}

🎯 TCP的可靠性机制

机制1:超时重传

发送端:
1. 发送数据包(seq=1)
2. 启动重传定时器(RTO,通常200ms-1s)
3. 等待ACK

如果收到ACK:
→ 停止定时器,发送下一个包

如果超时未收到ACK:
→ 重传数据包(seq=1)
→ 重传次数+1
→ 重传15次仍失败 → 连接断开 ❌

时序图

sequenceDiagram
    participant Sender as 发送端
    participant Network as 网络
    participant Receiver as 接收端

    Sender->>Network: 1. 发送seq=1
    Note over Sender: 启动重传定时器(200ms)
    
    rect rgb(255, 182, 193)
        Note over Network: 数据包丢失
    end
    
    Note over Sender: 200ms超时
    Sender->>Network: 2. 重传seq=1
    Network->>Receiver: 3. 数据到达
    Receiver->>Network: 4. 发送ACK=1
    Network->>Sender: 5. ACK到达
    Note over Sender: 收到ACK,停止重传

机制2:滑动窗口

滑动窗口:
控制发送速度,避免接收端处理不过来

示例:
窗口大小:4个包

发送端:
[1][2][3][4] ← 窗口(可以发送)
 ↑ 已发送
[5][6][7][8] ← 等待窗口滑动

接收端返回ACK=1:
→ 窗口右移1位
[2][3][4][5] ← 新窗口

机制3:拥塞控制

拥塞窗口(cwnd):
根据网络情况动态调整发送速度

慢启动:
初始cwnd = 1
收到ACK → cwnd = 2
收到ACK → cwnd = 4
收到ACK → cwnd = 8
...(指数增长)

拥塞避免:
到达阈值后,线性增长
cwnd = cwnd + 1

快速重传:
收到3个重复ACK → 立即重传(不等超时)

快速恢复:
重传后,减半cwnd

🎯 应用层如何检测丢包?

方法1:序列号

// 发送端
public void send(String message) {
    long seq = sequenceGenerator.getAndIncrement();  // 递增序列号
    
    Packet packet = new Packet();
    packet.setSeq(seq);
    packet.setData(message);
    
    socket.send(packet);
}

// 接收端
private long expectedSeq = 0;

public void receive(Packet packet) {
    if (packet.getSeq() == expectedSeq) {
        // 序列号正确
        process(packet);
        expectedSeq++;
    } else if (packet.getSeq() > expectedSeq) {
        // 丢包了
        log.error("检测到丢包:期望seq={}, 实际seq={}", expectedSeq, packet.getSeq());
        requestRetransmit(expectedSeq);  // 请求重传
    }
}

方法2:应用层ACK

// 发送端
public void sendWithAck(String message) {
    String messageId = UUID.randomUUID().toString();
    
    // 发送消息
    send(messageId, message);
    
    // 等待应用层ACK(超时3秒)
    boolean acked = waitForAck(messageId, 3000);
    
    if (!acked) {
        // 超时,重发
        log.warn("消息{}未收到ACK,重发", messageId);
        sendWithAck(message);  // 递归重试
    }
}

// 接收端
public void receive(String messageId, String message) {
    // 处理消息
    process(message);
    
    // 发送应用层ACK
    sendAck(messageId);
}

方法3:消息队列的ACK机制

// RabbitMQ的ACK机制
@RabbitListener(queues = "order.queue")
public void handleMessage(Message message, Channel channel) {
    try {
        // 处理消息
        processOrder(message);
        
        // 手动ACK(确认消息已处理)
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        
    } catch (Exception e) {
        // 处理失败,NACK(消息重新入队)
        channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
    }
}

🎓 面试标准答案

题目:用了TCP就一定不会丢包吗?

答案

不一定!

TCP保证的是

  • 在连接正常的情况下,数据可靠传输
  • 通过超时重传、顺序保证、校验和保证可靠性

但以下情况会丢包

1. 连接异常断开

  • 发送缓冲区溢出
  • 接收缓冲区溢出
  • 网络故障,重传15次失败
  • 连接重置(RST)

2. 应用层处理不当

  • 没读完数据就关闭连接
  • 异常退出(进程崩溃)

3. 网络分区

  • 长时间网络不通
  • 超过重传次数
  • 连接超时断开

4. 对端主机宕机

  • 服务器宕机
  • 操作系统发RST
  • 连接断开

如何保证不丢数据

应用层确认

  • 序列号检测
  • 应用层ACK
  • 重试机制
  • 幂等性设计

消息队列

  • MQ的ACK机制
  • 持久化
  • 重试队列

题目:TCP重传机制是怎样的?

答案

超时重传

  • 发送数据包,启动定时器
  • 超时未收到ACK → 重传
  • 最多重传15次(默认)
  • 仍失败 → 连接断开

快速重传

  • 收到3个重复ACK → 立即重传
  • 不等超时

重传参数

# Linux查看
sysctl -a | grep tcp_retries

net.ipv4.tcp_retries1 = 3   # 第一阶段重传次数
net.ipv4.tcp_retries2 = 15  # 第二阶段重传次数(总共15次)

重传时间

  • 第1次:200ms后
  • 第2次:400ms后
  • 第3次:800ms后
  • ...(指数退避)
  • 第15次:约13-30分钟后
  • 总耗时:约15分钟后放弃

🎉 结束语

一周后,哈吉米给文件上传加了应用层确认。

哈吉米:"加了应用层ACK后,上传100张图片,100%成功,不会丢了!"

南北绿豆:"对,TCP只保证连接正常时可靠传输,应用层必须有自己的确认机制。"

阿西噶阿西:"记住:TCP可靠≠不丢包,连接断开照样丢。"

哈吉米:"还有大文件传输,一定要分片+断点续传+应用层ACK。"

南北绿豆:"对,核心数据必须应用层确认,不能完全依赖TCP!"


记忆口诀

TCP可靠传输有前提,连接正常才保证
连接断开数据丢,缓冲区满也会丢
超时重传十五次,仍失败就断连接
应用层ACK是关键,序列号检测丢包
核心数据别依赖TCP,自己确认才可靠