名词解释

RDT(Reliable Data Transfer,可靠数据传输)协议

通过 ARQ 技术以实现 RDT 协议,TCP 就是一种 RDT 协议。

ARQ(Automatic Repeat reQuest,自动重传请求)

ARQ 是一种用于数据通信中的错误控制方法,旨在保证数据传输的可靠性。ARQ 协议通过在发送端和接收端之间引入确认机制来检测并纠正传输过程中出现的数据丢失或损坏问题。

  • 停等式(Stop-and-Wait)ARQ:最简单的形式,每发送一个数据包后就停止并等待确认。只有在收到 ACK 之后才会发送下一个数据包。这种方法效率较低,因为发送方在等待确认时处于空闲状态。
  • 回退 N 步(Go-Back-N,GBN)ARQ:允许发送方连续发送多个数据包而不需要立即等待每个数据包的确认。如果某个数据包未被正确接收,发送方需要重传从那个数据包开始到当前的所有后续数据包。
  • 选择性重传(Selective Repeat,SR)ARQ:类似于回退 N 步 ARQ,但更高效。它只需要重传那些确实丢失或损坏的数据包,而不是从出错点开始的所有后续数据包。这减少了不必要的重传,提高了效率。

MTU(Maximum Transmission Unit,数据链路层帧大小)

MTU 是链路层(数据链路层 / Layer 2)的最大传输单元大小,表示一个帧可以承载的最大数据量(不包括链路层头部和尾部校验),单位是字节(Byte)。

💡 注意:MTU 的值指的是 IP 层及其以上(TCP、UDP 等)的数据总长度上限。

📌 常见 MTU 值:

网络类型 默认 MTU(字节)
以太网(Ethernet) 1500
PPPoE 1492
WLAN(Wi-Fi) 通常也是 1500
隧道接口 可能更小(如 1400 或更低)

MSS(Maximum Segment Size,TCP 分段的参数)

MSS 是 TCP 层的一个参数,表示单个 TCP 段中可以承载的最大应用层数据量(不含 TCP 和 IP 头部)

❗ MSS = MTU - IP 头部 - TCP 头部

通常标准头部是:

  • IPv4 头部:20 字节
  • TCP 头部:20 字节
    => 所以 MSS = 1500 - 20 - 20 = 1460 字节

对于 TCP 来说,它是尽量避免分片的,为什么?因为如果在 IP 层进行分片的话,其中的某片数据也许会丢失,这会增大 TCP 协议重传数据包分组的机率,真要重传的时候还必须重传整个 TCP 分组(进行 IP 分片前的数据包)。

因此 TCP/IP 协议会自动探测从源到目的路径上最小的 MTU,并据此调整发送的 MSS,防止 IP 分片。

但是对于 UDP 协议而言,这个协议本身是无连接的协议,对数据包的到达顺序以及是否正确到达并不关心,所以一般 UDP 应用对分片没有特殊要求。

📌 MSS 举例:

协议组合 MTU IP 头 TCP 头 MSS
IPv4 + TCP 1500 20 20 1460
IPv6 + TCP 1500 40 20 1440
PPPoE + IPv4 1492 20 20 1452

MTU 和 MSS 的关系图解

1
2
3
4
5
6
7
8
9
+------------------------------------------+
| Ethernet Frame |
+------------+----------+------------------+
| MAC头(14B) | 类型(2B) | Data (1500B, MTU) |
+------------+----------+------------------+

+----------------+-----------------+-----------------------+
| IP Header (20B)| TCP Header (20B)| Data (max 1460B,MSS) |
+----------------+-----------------+-----------------------+

TCP 半连接队列与全连接队列

从三次握手流程可知,客户端会向服务端发两次请求:

  1. 第一次请求就会放在 TCP 半连接队列
  2. 第二次请求就会将请求从半连接队列里拿出放到全连接队列里。

SYN 洪泛攻击就是攻击半连接队列导致其溢出,后续的所有客户端请求都将被直接抛弃。

TCP

TCP 包结构如下:
TCP 包结构

三次握手与四次挥手

TCP 三次握手与四次挥手均可视作以下两个行为的拼接:

  • 客户端不确定服务端状态,向服务端发送请求并等待回应,服务端回应
  • 服务端不确定客户端状态,向客户端发送请求并等待回应,客户端回应

三次握手

TCP 三次握手

  • 第一次握手,客户端首先发起请求,期望得到服务端的回应
  • 第二次握手,服务端不仅回应客户端还想确定客户端状态,因此将两次请求合为一次
  • 第三次握手,客户端回应服务端

按上述逻辑,既然第二次握手可以将服务端的回应和请求合并,在第三次握手的时候也可以将客户端的回应和数据合并。

这并非不可实现,QUIC 的 1 RTT 握手(不仅握手,甚至还交换了密钥)就是这么设计的。

大量 SYN 包发送给服务端服务端会发生什么事情?

会导致 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃。

四次挥手

TCP 四次挥手

  • 第一次挥手,客户端主动发起挥手请求
  • 第二次挥手,服务端回应
  • 第三次挥手,服务端主动发起挥手请求
  • 第四次挥手,客户端回应

按上述逻辑,第二次挥手与第三次挥手在服务端没有数据要传输的情况下显然是可以合并的。

「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。

第二次和第三次挥手能合并嘛

注意:考虑到 HTTP/1.1 默认开启长连接,而断开长连接的场景如下:

  • 长连接超时,这个是最主要的场景,因为服务端为了响应快,就算发完数据了也基本不会主动断开长连接
  • 客户端或者服务端发送 Connection: close 主动断开长连接,主要是客户端发,比如 B/S 架构中的客户端和服务端都是自己维护的,当然希望服务端每时每刻的占用能少一点

对于上述场景,大部分 webserver 的实现都是由服务端主动断开 TCP 连接,因此上图的客户端与服务端的位置应当是相反的,这也不难解释为什么会有 服务端出现大量的 TIME_WAIT 是什么原因 这个问题了。

为什么 TIME_WAIT 等待的时间是 2MSL?

服务器出现大量 TIME_WAIT 状态的原因有哪些?

什么场景下服务端会主动断开连接呢?

  • 第一个场景:HTTP 没有使用长连接
  • 第二个场景:HTTP 长连接超时
  • 第三个场景:HTTP 长连接的请求数量达到上限

解决方案,一言以蔽之,先查是不是代码 Bug,然后视业务而定调小 HTTP 长连接的 Timeout 时长。

服务器出现大量 CLOSE_WAIT 状态的原因有哪些?

CLOSE_WAIT 状态是只要数据发送完就会自己退出的状态,因此基本是代码 Bug(除此之外还有什么?笔者不知道,小林 coding 也没写)。

拥塞控制算法

  • 慢启动(Slow Start):在连接开始时或网络出现丢包后,发送方以指数增长的方式增加拥塞窗口大小。
  • 拥塞避免(Congestion Avoidance):当拥塞窗口达到某个阈值后,采用线性增长方式缓慢增加拥塞窗口大小。
  • 快速重传(Fast Retransmit):一旦接收方检测到丢失了一个分组,它会立即发送多次重复的 ACK 给发送方,促使发送方在未等到超时的情况下就进行重传。
  • 快速恢复(Fast Recovery):与快速重传配合使用,在检测到丢包时不立即进入慢启动状态,而是尝试通过调整拥塞窗口和阈值来快速恢复正常的数据传输。

Tahoe、Reno 和 New Reno

早期的 TCP 拥塞控制算法仅有慢启动拥塞避免两个阶段:
TCP 原始拥塞控制算法

Tahoe 和 Reno

当收到 3 个相同的 ACK 时,TCP 不再等待当前窗口的计时器,而会直接认为当前窗口的包已经超时,重新发送当前窗口的包。这是 Tahoe 版 TCP 引入的快速重传算法。

当收到 3 个相同的 ACK 时,TCP 认为既然还能收到 ACK,应该只是局部拥塞,因此会:

  1. ssthresh=cwnd/2ssthresh = cwnd/2
  2. cwnd=ssthreshcwnd = ssthresh

这就是 Reno 版 TCP 引入的快速恢复算法,否则初始版本会在第二步将 cwnd=1cwnd = 1

快速恢复算法是基于快速重传算法的,因此 Reno 版 TCP 的流程如下所示:
TCP Reno 算法

New Reno

Reno 版 TCP 只考虑了一个包丢失的情况。设想一个场景:

  1. cwnd=9cwnd = 9,发送了 1-9 号包
  2. 当拥塞发生时,3、4 号包丢失,触发了 3 号包的快速重传和快速恢复
  3. cwnd 被设置为 4,发送了 3-6 号包
  4. 遗憾的是 4 号包再次丢失,但 Reno 收到了 3 号包的 ack 后就会退出快速恢复阶段,因此又会触发 4 号包的快速重传和快速恢复
  5. cwnd 被设置为 2

丢失多个包这一场景会导致即使有快速恢复算法,拥塞窗口仍然会指数级减少。

因此,希望同一时间丢失的包只会导致拥塞窗口减少一次就是New Reno 版 TCP的核心思想。

简言之,上述场景 New Reno 会减少一次拥塞窗口,然后在 ack 到 9 之前都处于快速恢复状态(或者说,不退回拥塞避免状态,因为只要不退回就不会再次进入快速恢复状态导致拥塞窗口减少)。

SACK 与 D-SACK

SACK

Tahoe 和 Reno 都无法实现 SR ARQ 算法,只能重传丢失的当前包或者重传当前发送窗口中所有的包,SACK 可以解决这个问题。

SACK 通过在 TCP option field 中增加一个 SACK 选项来让接收端在返回 Duplicate ACK 时,将已经收到的数据区段(连续收到的数据范围)返回给发送端,数据区段与数据区段之间的间隔就是接收端没有收到的数据。

这样发送端就知道哪些数据包已经收到,哪些该重传,因此 SACK 的发送端可以在一个 RTT 时间内重传多个数据包。
TCP SACK 算法

D-SACK (Duplicate SACK)

D-SACK 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了。也就是能区分下述两个场景:

ACK 回应丢失 发出去的包延迟到达
TCP D-SACK 发送延迟 TCP D-SACK 发送延迟
同时接到最新的 ACK 和重复的 SACK 首先接到了最新的 ACK=3000,然后再次接到最新的 ACK 和重复的 SACK

从 ACK 接受顺序就能分辨出这两个场景,由此可以做更精细的控制(做什么控制?小林 coding 没有写,笔者也不知道)。

CUBIC

以下内容参考自:

Reno 的拥塞避免阶段是线性增长的。线性逼近管道容量 WmaxW_{max} 相当于一次查询 (capacity-seeking),但长肥管道0.5Wmax0.5*W_{max}WmaxW_{max} 的线性遍历太慢,期间一旦遭遇丢包,则前功尽弃。

长肥管道:具有高带宽 - 延迟积(Bandwidth-Delay Product, BDP)的网络路径。这个术语形象地描述了那些虽然传输速率很高(“肥”),但由于距离远或经过多个路由导致延迟较大(“长”)的网络连接特性。

TCP Reno 拥塞避免整体趋势
可以明显看到每次 TD(Duplicated Acks)的时候,Reno 都要线性增长至最大值再重新减半,以此往复。

但这样对带宽的利用率不是很高,希望最好利用起来这条线段的上半区域,于是有了 BIC 算法
TCP BIC 算法

发生丢包时定义最大拥塞窗口为 WmaxW_{max},采用乘性减减小拥塞窗口至 WminW_{min}。BIC 使用用二分搜索的思想快速逼近 WmaxW_{max},每经过一个 RTT,便将窗口设置到 WmaxW_{max}WminW_{min} 的中点,如果没有丢包,则更新 WminW_{min} 为当前 cwnd 值,一直持续到接近 WmaxW_{max},直到二者之差小于 SminS_{min},这很好地利用了线性增长曲线的上半部分空间。

但是在一个 RTT 里面增长过多会造成传输上的抖动,因而 BIC-TCP 选取了另外取了两个参考值,称为 SmaxS_{max}SminS_{min},如果中点和当前 cwnd 值的差大于 SmaxS_{max} 的话,那么 cwnd 就只增长 SmaxS_{max}

由上可以看出 BIC 这个二分法调参较为复杂,因此又有了直接使用三次多项式拟合 BIC 曲线的 CUBIC 算法
TCP CUBIC 算法

CUBIC 用一个三次多项式代替了 BIC 增长曲线,对窗口增长机制进行了简化,保证了快速探测合适的拥塞窗口。并且不再以 RTT 为单位,窗口增长函数仅仅取决于连续的两次拥塞事件的时间差,保证了公平性。

BBR (Bottleneck Bandwidth and RTT)

由 Google 提出的一种新型拥塞控制算法,它试图找到并利用瓶颈带宽,从而最大化吞吐量,减少排队延迟。

KCP

KCP 是一个快速可靠协议,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议(如 UDP)的收发,需要使用者自己定义下层数据包的发送方式,以 callback 的方式提供给 KCP。连时钟都需要外部传递进来,内部不会有任何一次系统调用。

整个协议只有 ikcp.h, ikcp.c 两个源文件,可以方便的集成到用户自己的协议栈中。也许你实现了一个 P2P,或者某个基于 UDP 的协议,而缺乏一套完善的 ARQ 可靠协议实现,那么简单的拷贝这两个文件到现有项目中,稍微编写两行代码,即可使用。

skywind3000/kcp

主要是因为原神用了这个协议,笔者知道了所以记录一下。

KCP 虽然说是协议,但更像是某种拥塞控制算法 + ARQ + 必要的胶水代码,因此也可以简单的视作某种拥塞控制算法。由于当前(2025/03/25)TCP 协议是各种意义上的传输层标准,所谓的拥塞控制算法都是要考虑 TCP 的,因此只能把 KCP 放在这个小节了。

KCP 采取了激进的重传策略,然而,所谓的激进是基于这样一个客观现实——绝大部分系统仍在使用 TCP 的 CUBIC 算法。

CUBIC 相对保守(在乎传输公平性,君子协议),而 KCP 舍弃了传输公平性则相当于侵犯了这部分公共空间。假如大家都使用 KCP,那么大家都不会变快,只有在大部分人用 CUBIC 的时候,使用 KCP 的人才能得到收益。

KCP 协议除了游戏也多用在翻墙上,所以 wall 也是会对这个流量进行管控的。
KCP 现状

HTTP/1.x 协议

长连接/持久连接(Keep Alive)

开启与配置长连接

唯一需要注意的就是选择题会考一下长连接字段。开启长连接是使用 Connection 字段,即:

1
2
Connection: keep-alive  # 开启,HTTP/1.0 默认关闭,需显式开启
Connection: close # 关闭,HTTP/1.1 默认开启,需显式关闭

Keep-Alive 字段则是开启长连接后才有用的字段,用以配置长连接,可以包含两个参数:

  1. timeout:表示连接应保持打开状态的最长时间(以秒为单位)。在这段时间内,服务器应当等待客户端发送新的请求。
  2. max:表示在关闭连接之前,允许在该连接上发送的最大请求数。

因此,一个典型的 HTTP 请求头可能如下所示:

1
2
Connection: keep-alive
Keep-Alive: timeout=5, max=100

然而,在实际应用中,超时设置通常是在服务器配置文件中手动配置的,而不是通过 HTTP 头部来指定。

长连接的小缺点

长连接也有一个小小的缺点——比较耗服务器资源。

虽然长连接可以靠客户端主动发送 Connection: close 断开连接,但是为了减少延迟、提高性能与用户体验,现代 websever 默认支持并鼓励使用长连接,因此长连接的断开基本只能靠 timeout。

服务器维护大量的不活跃长连接自然是会比较消耗资源的,并且这也是服务器出现大量处于 Timewait 的 TCP 连接的根本原因:

问题来了,什么场景下服务端会主动断开连接呢?

  • 第一个场景:HTTP 没有使用长连接,根据大多数现代 websever 的实现,不管哪一方禁用了 Keep-Alive,都是由服务端主动关闭连接。
  • 第二个场景:HTTP 长连接超时,还得是大量 HTTP 长连接超时。
  • 第三个场景:HTTP 长连接的请求数量达到上限

服务端出现大量的 timewait 有哪些原因?

HTTP/2 协议

HTTP/2 兼容 HTTP/1.1,不像 HTTP/3 直接敢叫日月换新天了。

对 HTTP/1.x 的优化

头部压缩

二进制帧

TCP 连接复用

Server Push

与 HTTP/1.x、Websocket、SSE 的对比

HTTP/2 并非 HTTP/1.x 的完整替代

在 HTTP/1.x 中用以加速访问速度的技术类似于域名分片、资源合并和资源内联等入侵入式技术在 HTTP/2 世界中正好适得其反。

  • 域名分片,HTTP/2 对于同一域名使用一个 TCP 连接足矣,过多 TCP 连接浪费资源而且效果不见得一定好,而且资源分域会破坏 HTTP/2 的优先级特性,还会降低头部压缩效果
  • 资源合并(多张小图拼接大图),资源合并会不利于缓存机制,而且单文件过大对于 HTTP/2 的传输不好,尽量做到细粒化更有利于 HTTP/2 传输。
  • 资源内联(图片转 base64 编码直接存 HTML 里),HTTP/2 支持 Server-Push,相比较内联优势更大效果更好,而且内联的资源不能有效缓存。如果有共用,多页面内联也会造成浪费

HTTP/2 不是类似于 Websocket 或者 SSE 这样的推送技术的替代品

HTTP/2 的 Server Push 只能被浏览器来处理而不能够在程序代码中进行处理,它并不允许服务端直接向客户端程序本身发送数据,意即程序代码没有 API 可以用来获取这些事件的通知。

Websocket 技术可能会继续使用,但是 SSE 和其 EventSource API 同 HTTP/2 的能力相结合可以在多数场景下达到同样的效果,但是会更简单。

HTTP/3(QUIC)协议

基于 UDP 的全新应用层协议,未来可期。

对 HTTP/2 的优化

消除队头阻塞

更快建立连接(第一次 1 RTT,后续可能 0 RTT)

连接迁移

代码实现

gRPC 与 thrift 协议

Websocket

SSE

面经问答:

假设在无线网条件下,网络比较拥堵,你会选择 TCP 还是 UDP 通信

没什么标准答案,主要还是看应用。说到底不重要的消息丢了也就丢了,而重要的消息肯定不能丢,哪一个跟网络环境都没关系,总不能网络环境变差了,以前不重要的消息现在就变重要了,或者相反。

epoll 相关

有意思的问题与回答:

… … 读 redis 的话,即使 redis 和请求程序同一台机器上,也需要通过读写套接字来完成,这个流程包括

1.首先会涉及操作系统内核协议栈的处理,就 linux 来说,如果 redis 服务在本机,写套接字这个过程会在到达协议栈传输层时,发现目的 ip 来自本机,然后返回。redis 读取请求,通过套接字把结果返回。

2.整个过程过程会有四次用户态和内核态的交互,其中两次还包括结果数据拷贝。

3.Redis 底层通过 epoll 函数来监测是否有请求到来,如果不想单核 CPU 占用率到 100%,那么 epoll 的 idle time 这个参数最低只能配置成 1ms,这意味着响应的平均耗时应该在 500us。

批判另一位答主测试程序的问题.jpg

如果把这个测试 demo 修改下,每隔 10ms 读写一次(显然这种方式更贴近业务场景),那耗时就会暴增,理由上面说了,Redis 底层用 epoll 来监测套接字请求,同一个套接字短时间内发起大量读写,Redis 的 epoll 函数会批量读取请求然后去处理(设置的触发方式肯定是水平触发,意味着只需要和内核态交互一次就可以读取一个 fd 的多个 Req),导致平均耗时看起来不高,可一旦进入业务场景,零散读写 Redis,耗时就会大幅增加。

游戏服务器缓存为什么一般不直接 Redis,而是自己写代码写入计算机内存中呢?

解释之前,先看看 Redis 的多线程干了啥,redis 整个处理大致可以分为如下几步:

–> (1) read req & 解析 req --> (2) 执行 redis 数据结构内存操作 --> (3) write resp

考虑到瓶颈主要在网络,以及实现上的简单,所以第 (2) 步执行 redis 数据结构内存操作还是单线程执行,只是把第 (1)、(3) 步网络相关的操作多线程并发起来了。这样 pipeline + 多线程,肯定可以提升 redis 的 qps,尤其对相对较重的 write,pipeline 可以明显提升 qps;那为什么 read 提升不明显,因为 redis 通常是小 kv,相对轻量的 read pipeline 出去收益就不大了,而且一旦 pipeline 出去做,还要增加额外在线程间传递是的开销(例如 cache miss 等);而且 Redis 的第 (2) 步只能单线程的,如果它可以多线程线性扩展,那又是另一番景象了。

为什么 Redis 作者说多线程 IO 更大程度上是为了提升写的性能,而对读没什么大帮助?

**需要注意:**这个问题 redis 的读与写指的是 redis server 的读取客户端发过来的请求以及将处理结果写回缓冲区发回客户端这两个操作,而不是 redis 对于数据本身的 get/set 操作。

http 协议在 ip 协议之上对吗?

显然基于 Unix Domain socket 也能跑。