摘要:从一次"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,自己确认才可靠