微信传文件卡在99%不动,视频播放器转圈半天出不来画面,游戏打到一半突然掉线重连——这些日常里的体验问题,往底层追,十有八九都能落到同一个技术点上:可靠传输协议怎么保证数据不丢、不乱、不重。而要说清楚这个事,绕不开的就是计算机网络基础课里的连续 ARQ 协议。
ARQ 全称 Automatic Repeat reQuest,自动重传请求。听名字就能猜个大概:发送方把数据发出去,接收方收到后回一个确认,发送方如果发现超时没收到确认,就重传一次。这套机制是数据链路层和传输层保证可靠传输的基石。连续 ARQ 是 ARQ 家族里最实用的形态,它决定了“发送方可以连续发多少数据不等待”“出错之后怎么补偿”。不管你是准备面试、考研、考证,还是正在写网络通信相关的代码,把连续 ARQ 吃透,再看 TCP、HTTP/3 甚至 RDMA 的可靠性设计,都会轻松很多。
这篇文章我不打算念教材,而是结合我实际调试网络协议栈和数据通信模块时的经验,把连续 ARQ 的来龙去脉、两种实现方式、窗口与序号的设计细节、以及那些坑踩多了才明白的要点,一起拆开讲清楚。
1. 为什么会有连续 ARQ:从停等协议说起
1.1 最简单的可靠传输:停等 ARQ 的工作原理
要理解连续 ARQ,得先看它的前身——停等协议,也叫 Stop-and-Wait ARQ。它的逻辑极其朴素:发一帧,停下来等确认,收到 ACK 再发下一帧,超时没收到就重发当前帧。
举个例子。你在命令行里用 scp 传一个 100MB 的文件,如果底层用的完全是停等协议,发送方每发一个数据包(假设 1500 字节),就要等接收方回复。这个等待时间取决于往返时延 RTT。在局域网里 RTT 可能只有 0.5 毫秒,感觉还好;但如果链路跨地域,RTT 到了 100 毫秒,那发送方有 99% 的时间都在干等,信道利用率会低到让人崩溃。
信道利用率怎么算?简单说就是“真正用于发送数据的时间”除以“从开始发送到收到确认的总时间”。假设数据帧发送耗时 T,往返时延为 R,理想情况下每次发送周期就是 T + R。利用率大约是 T / (T + R)。R 一大,利用率直线下降。
1.2 停等协议的瓶颈和连续 ARQ 的动机
停等协议最大的问题不是不能保证可靠,而是太慢了。它把“确认”这个环节完全串行化了:一帧一确认,确认不到,后续全部阻塞。这就好比你去银行办业务,取号、叫号、办完,再重新取号,每次都等全流程走完才办下一笔,效率可想而知。
连续 ARQ 的思路非常直接:把串行改成流水线。发送方不再等每一帧的确认,而是在收到第一个确认之前,就可以连续把多个帧发出去。这样信道里始终有数据在跑,利用率大幅提升。
但“连续发”也引入了新问题:如果中间的某一帧丢了或者错了,接收方怎么告诉发送方?发送方要重传哪些帧?是只重传丢失的那一帧,还是把后面已经发送的帧全部重新再传一遍?这两个问题的答案,分别对应了连续 ARQ 的两种具体实现:回退 N 帧(Go-Back-N,GBN)和选择重传(Selective Repeat,SR)。
我当年刚学到这里时有个误解,觉得只要允许连续发就叫连续 ARQ,具体怎么处理错误是细节。后来真正写模拟代码才发现,错误处理策略直接决定了协议的性能甚至正确性。往下看就明白了。
2. 连续 ARQ 的两种实现:回退 N 帧与选择重传
2.1 滑动窗口机制的核心思想
连续 ARQ 的底层支撑是滑动窗口。滑动窗口本质上是一个容量限制:发送方最多可以发送“窗口大小”个未确认的帧,接收方也有一个接收窗口来决定能缓存哪些帧。
你可以把窗口理解成交通管制里的绿灯时长。发送窗口就是“在收到反馈之前,最多可以放行多少辆车”。窗口越大,路上车越多,链路利用率越高;但如果窗口太大,超过了网络的承受能力,就会堵车和丢包。接收窗口则决定“哪些车可以被收下,哪些车必须靠边等”。GBN 和 SR 的核心差别,就在接收窗口的大小和处理乱序帧的态度上。
2.2 回退 N 帧协议怎么工作
GBN,Go-Back-N,回溯 N 步。它的规则是:
- 发送窗口大小大于 1,可以连续发多个帧。
- 接收窗口大小等于 1,接收方只接受按序到达的帧。如果某个帧出错或丢失,则丢弃该帧以及之后所有到达的帧,不缓存任何乱序数据。
- 发送方收到确认时,按累计确认处理:确认序号 N 表示“N 之前的所有帧都正确收到了”。
- 发送方只有收到超时事件才触发重传,且重传的是超时帧以及它之后的所有已发送但未确认的帧。
举个例子。发送方一次发送了帧 0、1、2、3、4。中途帧 2 在网络里丢了,帧 3、4 顺利到达接收方。因为接收窗口是 1,接收方期望的是帧 2,那么帧 3、4 就算到了也会被丢弃,不发任何确认。发送方在超时计时器到期后,发现帧 2 没收到确认,于是把帧 2、3、4 全部重新发送。这就是“回退 N 步”名称的由来。
GBN 的好处是接收方实现极简单,不需要任何缓存,也不需要排序。坏处是,一旦链路误码率不低,重传量会被放大——丢一帧,后面跟着重传一大串,信道里满是重复数据。所以 GBN 适合误码率低、丢包少的链路。
2.3 选择重传协议怎么工作
SR,Selective Repeat,选择性重传。它的改进点在于:接收窗口大小大于 1,可以缓存乱序到达的帧。当某帧丢失时,接收方只把丢失的那一帧丢弃,后续到达的正确帧先放进缓冲区,等到缺失帧重传到达后,再一次性交付给上层。
还是同样的例子。帧 0、1、2、3、4 依次发送,帧 2 丢失,帧 3、4 到达。接收方此时会缓存帧 3、4,并对其中的每个帧单独发确认。发送方收到帧 3、4 的确认后,知道它们没丢,于是只需要重传帧 2。等到帧 2 重传到达,接收方把 2、3、4 按序交付上层。
SR 的优点自然是省带宽,不浪费重传。缺点也明显:接收方需要一块足够大的缓存来保存乱序帧,发送方需要为每个已发送帧维护独立的超时计时器。收发双方的控制逻辑比 GBN 复杂得多。
实际工程里,纯粹只用 GBN 或纯粹只用 SR 的协议栈其实不多,更多是缝合形态。比如 TCP 的基础确认是累计确认,像 GBN;但它又允许接收方缓存乱序数据,超时后往往只重传最早未确认的 Segment,又像 SR。真正理解这两种协议,再看 TCP 的快速重传与 SACK 扩展,就特别顺了。
3. 核心细节拆解:窗口大小、序号空间与超时重传
3.1 序号空间和窗口大小的数学关系
这是连续 ARQ 协议里最容易被忽略也最“致命”的细节。很多人写协议模拟代码时,直接把窗口开满整个序号空间,结果程序跑着跑着就出现歧义:接收方分不清一个新到的帧到底是一份旧的重传,还是一份新的数据。
要讲清楚这个问题,先说序号空间。假设序号字段用 n 比特表示,那么序号范围是 0 到 2^n - 1,总共 2^n 个序号。因为序号会循环使用(比如从 7 回到 0),所以接收方必须能区分“序号相同的不同帧”。
对于 GBN,发送窗口最大不能超过 2^n - 1。为什么不是 2^n?用一个常见的反例说明。假设 n = 2,序号空间是 0、1、2、3,发送窗口大小设为 4。发送方发出帧 0、1、2、3,接收方全部收到并确认,但所有 ACK 都丢了。发送方超时后以为四个帧全丢了,于是重传帧 0、1、2、3。可此时接收方已经滑动窗口等待新的帧 0(因为序号循环),它收到重传的帧 0 后,会当作新帧接收,从而造成重复。窗口设为 3 就能避免这种歧义:发送方最多发 0、1、2,即使全部 ACK 丢失,接收方也不会在下一轮把重传帧误判为新帧,因为接收窗口滑动后最多只等 3、0、1,重传的 2 会被识别为旧帧。
对于 SR,发送窗口最大只能到 2^(n-1)。这是因为 SR 的接收窗口大小等于发送窗口大小,如果窗口超过序号空间的一半,收发双方的可用序号范围会有重叠,接收方就会把新帧误判为旧帧。n = 2 时,SR 的窗口最多为 2。
我在做协议模拟实验时,为了验证这个结论,把 SR 的窗口设成 3,结果很快就出现了接收方重复交付数据的情况。排查到最后,就是序号重叠问题。这个坑,最好是提前在设计阶段就避开,而不是靠运行时协议栈去兜底。窗口大小和序号位数的关系,是连续 ARQ 设计的顶层约束,一个稳定的实现一定严格满足该约束。
3.2 超时计时器的设定与重传策略
超时计时器是连续 ARQ 的中枢。计时器设得太短,数据其实还在传输途中,发送方就急着重传,白白浪费带宽。设得太长,真正丢了包时发现得晚,对端体验直接劣化。
最常见的工程标准是让超时时间略大于 RTT(往返时延)。但 RTT 本身是波动的,所以实践中会用 Smoothed RTT 等算法动态更新。我在调试一个小型文件传输工具时曾把超时设成固定值,结果在弱网环境下 RTT 从 20ms 暴涨到 800ms,发送方大量误判超时,网络上全是重复包,吞吐率掉到原来的十分之一。后来改成基于 EWMA 的动态 RTT 估算,才稳定下来。
关于计时器数量,GBN 一般只维护一个全局计时器,因为发送方总是按序发送,最早未确认帧就是整个窗口的“队首”,它一旦超时,把队首和后续的全重传就行。SR 则不同,每个窗口内未确认的帧都需要独立的计时器,否则无法判断“到底哪个帧超时了”。这个差异在实现里很明显:SR 的发包模块要维护一个计时器数组,代码和内存都更重,但换来的收益是重传粒度细。
有不少面试题爱问“为什么 TCP 不用纯 GBN 的超时模型”,其实答案就在这个层次:纯 GBN 一次超时要重传整个窗口,代价过大;TCP 虽然基于累计确认,但超时后只重传最早的 Segment,再靠 ACK 推进窗口,本质上是吸收了 GBN 的简单确认优点和 SR 的精确重传优点。
3.3 累计确认与选择确认的区别
连续 ARQ 里的确认方式直接关系到发送方对自己传输状态的认知。
GBN 使用累计确认。接收方发送的 ACK N 代表“N 之前的所有帧都收到了”。好处是 ACK 开销小,偶尔丢失一个 ACK 影响也不大——比如接收方收到帧 0、1、2,发回 ACK 1、ACK 2、ACK 3,如果 ACK 2 丢了,只要 ACK 3 到了,发送方就知道 0、1、2、3 之前的状态都没问题。这种冗余性让 GBN 在可靠链路里非常抗 ACK 丢包。
SR 更常见的是每个帧独立确认。接收方收到帧 3 就给 ACK 3,收到帧 4 就给 ACK 4,不管帧 2 有没有到。这带来一个隐藏问题:发送方可能先收到 ACK 4,再收到 ACK 3,需要缓存和排序。而且独立确认的 ACK 丢失会影响协议性能,因为没有累计确认兜底。
TCP 的选择确认扩展 SACK 可以理解为在累计确认基础上补充了独立确认的能力。接收方在一个选项字段里告诉发送方“连续收到的数据块有哪些”,发送方就能清楚知道哪些 Sequence 丢失了,从而精准重传。学习连续 ARQ 时明白了 GBN 和 SR 的确认差异,再看 SACK 的设计动机就不会懵。
4. 连续 ARQ 的工程应用与场景适配
4.1 在数据链路层与 TCP 中的身影
数据链路层里,HDLC 的某些模式就采用了窗口机制,常见的是 GBN 变体。它适合链路质量较好的场景,因为重传代价小,实现也不复杂。传统 PPP、帧中继等协议的设计里同样能看到类似思路。
传输层里,TCP 的确认机制和重传策略带着连续 ARQ 的影子。TCP 的发送窗口由拥塞窗口和接收窗口共同决定,但底层“允许连续发送多个数据段、等待确认、超时重传”的骨架,就是连续 ARQ。UDP 本身不保证可靠,但基于 UDP 的应用,比如某些自定义可靠 UDP 协议栈,会自己实现类似 SR 的选择性重传,搭配序列号和确认号字段,才能达到比 TCP 更低的延迟。
我参与过一个模拟项目 X,要在 UDP 之上做一个可靠传输层,起初直接选了类似 GBN 的逻辑:连续发送,窗口内队首超时则重传整个窗口。内网跑测试没问题,但换到跨网段后,误码上升,重传量爆炸。后面切成 SR 风格:接收方用位图记录乱序包状态,发送方维护待重传集合,在丢包率 1% 的链路上吞吐提升了一倍多。这个对比让我深刻体会到两种策略的适用边界。
4.2 窗口大小与性能的工程权衡
窗口大小是连续 ARQ 工程实现里的核心参数。太大,不仅可能导致序号歧义,还会让接收方缓冲区膨胀、网络拥塞加剧。太小,链路利用率上不去,尤其是高带宽长延迟链路,也就是常说的 BDP(带宽时延积)问题。
经典的计算方式是:要达到满速传输,发送窗口至少等于带宽乘以 RTT。例如 10Gbps 链路、RTT 为 1ms,那么窗口至少要 10Gb/s × 0.001s = 10Mb,大约 1250KB。如果窗口比这个小,链路就吃不饱。搜一下 TCP tuning 的话题,你会发现很多所谓“网络性能调优”问题,本质就是让窗口覆盖住 BDP。
但这并不是说窗口越大越好。窗口增大会提高接收方的内存占用,也会放大错误恢复的代价。GBN 的窗口尤其不能盲目设大,因为一次超时重传整个窗口的惩罚太高。SR 的窗口可以适当大一些,但接收缓冲乱序包也要内存。工程上往往用一个折中值,并根据实时丢包率动态调整。
我在某跨平台系统的网络模块里见过一种设计:根据最近一个时间窗口的丢包率自动调整发送窗口,丢包率高时降低窗口,减少重传负担;链路好时逐步增大窗口,提升吞吐。这套思路跟 TCP 的拥塞控制很像,本质也是从连续 ARQ 的流量控制思路里演化出来的。
5. 常见问题与排查技巧实录
5.1 序号回绕问题排查记
连续 ARQ 里最经典的问题之一就是序号回绕。序号空间有限,发到最大值后会回转到 0。如果序号位数不够,回绕速度快到让接收方无法区分新旧帧,数据就会出错。
我在写一个模拟图像传输的 Demo 时,给序列号只分配了 8 比特,也就是 0 到 255。链路速率一高,窗口内疯狂发包,很快就从 255 回到 0。接收方把回绕后的旧帧当新帧,图像直接花屏。排查过程花了不少时间,一开始以为是编码问题,后面抓包对比序列号才发现是回绕导致的重复交付。
解决思路有两个方向。一个是增大序号字段位数,比如 TCP 用 32 比特序列号,再配合时间戳防止回绕歧义。另一个是保证未确认帧的窗口大小不超过序号空间的一半或减一,这在 GBN 和 SR 里分别是 2^n - 1 和 2^(n-1)。设计协议时,没必要老想着压缩序号位数省带宽,先把正确性保住,再考虑优化。
5.2 窗口停滞与死锁问题
窗口停滞通俗讲就是发送方还有数据要发,但窗口已经用尽,所有已发数据都还没收到确认,于是一直等。更麻烦的是,如果接收方的 ACK 全部丢失,发送方会无限等待下去,这就是死锁。
解决死锁的常规手段是持续超时重传。发送方对最早未确认帧重复触发超时,直到该帧的 ACK 到达。另一个手段是接收方主动发送“窗口更新”报文,即便没有新数据确认,也告知发送方窗口状态。TCP 里有持续计时器(Persist Timer)就是为了防止接收窗口为 0 时的死锁,这个机制往前溯,也是从滑动窗口协议的死锁预防思路一脉相承的。
我踩过的一个坑是:在自定义可靠 UDP 协议里,接收方处理速度慢,窗口填满后发送方不再发包,而接收方处理完数据后发送的窗口更新报文丢失,发送方就傻等了。后来在发送方加了一个“零窗口探测”定时器,定期发很小的探测包去问接收方窗口状态,问题才解决。
5.3 实际网络环境中的重传风暴
弱网环境下的重传风暴是另一个常见问题。表面上看,网络延迟高,丢包多,发送方疯狂重传。其实根因往往是超时设置不合理,导致大量数据包其实没丢,只是迟到了。
排查重传风暴,我的习惯是先抓包看 RTT 分布,再对比超时时间。如果绝大多数重传发生在 RTT 的两倍以内,基本可以断定是超时太紧。把超时时间放宽 30% 到 50%,重传率通常立竿见影地下降。
另一个隐蔽原因是确认包本身占用了过多带宽。如果链路是对称的,ACK 太多会反过来影响数据方向。GBN 的累计确认天然省 ACK,SR 的独立确认开销大。所以我做低带宽链路方案时,优先考虑累计确认加选择性重传的混合策略:确认报文中携带一个可选的乱序接收位图,平时只发累计 ACK,发现异常才带位图。既省了确认开销,又保留了 SR 的精准重传能力。
再补充一个经验:连续 ARQ 的性能跟网络丢包的分布模型关系很大。随机丢包率低时,GBN 和 SR 差距不大;一旦出现突发丢包,比如无线网络里的突发干扰,GBN 会在短时间内把整个窗口重传好几遍,造成雪崩。设计无线场景的可靠传输,一定要优先 SR 或带 SACK 的混合方案。这是我拿模拟数据和真实弱网测试反复对比后得出的结论。
5.4 常见问题速查表
下面这张表是我在实际调试中反复用到的速查表,整理出来给你。
| 现象 | 可能原因 | 排查方向 | 常用对策 |
|---|---|---|---|
| 接收端出现重复数据 | 序号位数不足或窗口过大送回绕 | 抓包检查序列号分布 | 增大序号字段或缩小窗口上限 |
| 发送端长时间不发数据 | 窗口被填满且 ACK 丢失 | 查看发送窗口和接收窗口状态 | 增加零窗口探测定时器 |
| 重传率异常高但丢包率不高 | 超时时间设得太紧 | 对比 RTT 分布与超时时间 | 放宽超时或改为动态 RTT 估算 |
| 弱网下吞吐严重下降 | 突发丢包导致 GBN 重传雪崩 | 观察丢包时间分布 | 切换 SR 或开启 SACK |
| 确认报文占用大量带宽 | 独立确认 ACK 过多 | 统计 ACK 包占比 | 使用累计确认或频谱优化 |
这五列不是教科书理论,全是从现网和实验环境里一点点抠出来的。
我个人在实际操作中的体会是,连续 ARQ 往往是在“简单”和“高效”之间反复权衡。GBN 简洁但粗暴,SR 精细但复杂。真正到了做协议栈或通信模块的时候,几乎不会直接用单一模型,而是把累计确认、选择重传、动态超时、窗口自适应这些要素,按具体场景揉成一个混合方案。
最后分享一个小技巧:手写协议模拟器时,别急着追求性能,先保证“最坏情况”下不出现死锁和交付重复。用随机丢包脚本、随机延迟脚本去暴力测试,比任何静态推导都更能暴露窗口与序号的边界问题。连续 ARQ 是这些可靠机制的最小性模型,把它设计稳了,别的协议都是在这个骨架上一层一层加演化出来的。