深蓝黑背景品牌封面:底部栏式标题区,主标题「TCP 丢了一个包」,副标题「后面的包要等吗」;上半部分原创装饰——一列数据包方块从左向右飞行,中间一个方块以虚线红叉标记丢失,右侧接收端的队列出现一个空洞等待补位,寓意按序交付中的队头等待;左下标注「经典题解析 · 网络」,右下标注「Day 07 · 10-15」 图 1:本文封面,队列里那个空洞就是本题的主角。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。它考的不是「会重传」三个字,而是你能不能把丢包之后发送端、接收端、应用层三方各自在干什么讲成一条完整链路。

想象面试现场:「TCP 传输中丢了一个包,后面已经到达的包会交给应用层吗?重传是怎么被触发的?」

核心结论一句话

要等。TCP 承诺的是「可靠、按序」的字节流:丢了一个包,后面到的包只能待在接收缓冲区里,应用层读不到——这就是队头阻塞。直到丢失的包被重传补位,缓冲区里攒着的一段才会一次性按序交付。可靠的代价是等,这句话是整题的地基。

接收端:乱序包进缓冲区,不进门。

接收端维护一个「下一个期望序号」。包按序到达,立刻交付并把期望序号往前推;包跳过期望序号到达(比如 3 丢了、4 和 5 到了),只能进缓冲区挂着,同时接收端每收到一个乱序包就立刻回一个重复确认——确认号钉死在期望序号上,等于反复对发送端喊「我还在等 3」。TCP 基础规范(RFC 9293)定义了这套累积确认与重传队列机制:发送端为每个已发出的包保留副本,直到它被确认才从重传队列移除。

发送端:两条重传路径,一快一慢。

慢的是超时重传:每个包发出后启动计时器,超时没收到确认就重发。它是兜底,但超时值按往返时间的统计估计设置,注定保守——等它触发,几百毫秒就过去了。快的是快重传:拥塞控制规范(RFC 5681)规定,发送端连续收到 3 个重复确认,就把对应包视为已丢失,立刻补发,不等计时器。3 这个数是个工程折中:一两个重复确认可能只是网络乱序,误重传浪费带宽;四个又太迟钝。

一段模拟代码把全过程跑一遍。

下面这份极简接收端模拟(按序交付 + 缓冲 + 重复确认 + 快重传触发),用 Node.js 22.15.0 直接运行(node tcp-loss.js)即可:

'use strict';

class TcpReceiver {
  constructor() {
    this.nextExpected = 1;   // 下一个期望按序交付的序号
    this.buffer = new Map(); // 乱序到达、暂存的包
    this.delivered = [];     // 已交给应用层的序号
    this.dupAckSent = 0;     // 已连续发出的重复确认数
  }
  receive(seq) { // 返回要回给发送端的确认号
    if (seq === this.nextExpected) {
      this.delivered.push(seq);
      this.nextExpected++;
      while (this.buffer.has(this.nextExpected)) { // 空洞补上,连续段一次性交付
        this.buffer.delete(this.nextExpected);
        this.delivered.push(this.nextExpected);
        this.nextExpected++;
      }
      this.dupAckSent = 0;
      return { ack: this.nextExpected, event: 'deliver' };
    }
    if (seq > this.nextExpected) {
      this.buffer.set(seq, true); // 乱序包进缓冲区,应用层读不到
      this.dupAckSent++;
      return { ack: this.nextExpected, event: `dupAck#${this.dupAckSent}` };
    }
    return { ack: this.nextExpected, event: 'oldDup' };
  }
}

const rx = new TcpReceiver();
let retransmitted = false;
function step(label, seq) {
  if (seq === null) { console.log(`${label}:3 号包途中丢失`); return; }
  const { ack, event } = rx.receive(seq);
  console.log(`${label}:收到 ${seq} 号包 -> 回 ACK=${ack}(${event}) 应用层已读到 [${rx.delivered}]`);
  if (event.startsWith('dupAck') && rx.dupAckSent >= 3 && !retransmitted) {
    retransmitted = true;
    console.log('  -> 发送端连收 3 个重复确认,不等超时计时器,立刻补发 3 号包');
    step('快重传', 3);
  }
}

step('t1', 1); step('t2', 2); step('t3', null);
step('t4', 4); step('t5', 5); step('t6', 6);
console.log('最终应用层读到:', rx.delivered.join(','));

实测输出:

t1:收到 1 号包 -> 回 ACK=2(deliver) 应用层已读到 [1]
t2:收到 2 号包 -> 回 ACK=3(deliver) 应用层已读到 [1,2]
t3:3 号包途中丢失
t4:收到 4 号包 -> 回 ACK=3(dupAck#1) 应用层已读到 [1,2]
t5:收到 5 号包 -> 回 ACK=3(dupAck#2) 应用层已读到 [1,2]
t6:收到 6 号包 -> 回 ACK=3(dupAck#3) 应用层已读到 [1,2]
  -> 发送端连收 3 个重复确认,不等超时计时器,立刻补发 3 号包
快重传:收到 3 号包 -> 回 ACK=7(deliver) 应用层已读到 [1,2,3,4,5,6]
最终应用层读到: 1,2,3,4,5,6

注意 t4 到 t6 三行:4、5、6 号包明明已经到了,应用层视角却纹丝不动;3 号包一补位,四个包一次性交付。这就是队头阻塞的完整生命周期。

机制流程图:上部是丢包现场——发送端按序发出 1 到 5 号包,3 号包途中丢失,接收端收到 4、5 却只能放进缓冲区,应用层读不到,这就是队头阻塞。中部左右两条重传路径并排:左路超时重传——发送端为每个包维护计时器,超时未收到确认就重发,兜底但慢;右路快重传——接收端对后续包反复回同一个确认号,发送端连收三个重复确认就立刻补发,不等计时器。下部是重组交付——3 号包补位后,缓冲区里 3、4、5 一次性按序交给应用层,确认号随之推进。底部结论:可靠的代价是等;评估 QUIC 类新协议的价值,正是从这条队头阻塞开始的 图 2:丢包现场、两条重传路径与重组交付。

可能的追问与易错点。

「有没有办法不等?」协议内有缓解:SACK(选择性确认)让接收端在确认里附带「我已经收到了哪几段」,发送端可以精准补洞而不是从丢失点全部重发。但要根治队头阻塞只能换协议——QUIC 把可靠性做到流一级,一条流丢包不堵其他流,这正是它对 HTTP/3 的核心价值。「重复确认为什么是 3 个不是 1 个?」因为网络本身会乱序,包绕路后于后辈到达是常态,单个重复确认不足以判定丢失。易错点有二:一是以为乱序包会被丢弃——不会,缓冲区就是为它准备的(资源耗尽等极端策略除外);二是把重传只挂在超时上,讲不出快重传这条快路,等于只答了兜底路径。

回到面试现场,建议的答题节奏:先一句话给结论(要等,按序交付决定的),再分接收端与发送端讲缓冲区和两条重传路径,然后用 SACK 与 QUIC 展示你知道这条边界在哪。大模型流式输出走 TCP 时,丢包引发的队头阻塞就表现为「生成到一半卡住几秒又一吐一大段」——看懂了这道题,就看懂了这个线上现象。

你在抓包或排查里见过队头阻塞的真实现场吗?欢迎分享。