计算机网络串记
名词解释
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 | +------------------------------------------+ |
TCP 半连接队列与全连接队列
从三次握手流程可知,客户端会向服务端发两次请求:
- 第一次请求就会放在 TCP 半连接队列
- 第二次请求就会将请求从半连接队列里拿出放到全连接队列里。
SYN 洪泛攻击就是攻击半连接队列导致其溢出,后续的所有客户端请求都将被直接抛弃。
TCP
TCP 包结构如下:
三次握手与四次挥手
TCP 三次握手与四次挥手均可视作以下两个行为的拼接:
- 客户端不确定服务端状态,向服务端发送请求并等待回应,服务端回应
- 服务端不确定客户端状态,向客户端发送请求并等待回应,客户端回应
三次握手
- 第一次握手,客户端首先发起请求,期望得到服务端的回应
- 第二次握手,服务端不仅回应客户端还想确定客户端状态,因此将两次请求合为一次
- 第三次握手,客户端回应服务端
按上述逻辑,既然第二次握手可以将服务端的回应和请求合并,在第三次握手的时候也可以将客户端的回应和数据合并。
这并非不可实现,QUIC 的 1 RTT 握手(不仅握手,甚至还交换了密钥)就是这么设计的。
大量 SYN 包发送给服务端服务端会发生什么事情?
会导致 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃。
四次挥手
- 第一次挥手,客户端主动发起挥手请求
- 第二次挥手,服务端回应
- 第三次挥手,服务端主动发起挥手请求
- 第四次挥手,客户端回应
按上述逻辑,第二次挥手与第三次挥手在服务端没有数据要传输的情况下显然是可以合并的。
「没有数据要发送」并且「开启了 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 也没写)。
服务器拔网线\断电\正常关机\kill 进程,客户端会收到什么?
场景 | 服务器行为 | 客户端收到包 | 客户端应用层反应 (例如 read/write) | 感知速度 |
---|---|---|---|---|
拔网线/断电 | 物理中断,无任何包发出 | 无 | 等待数据时,read() 持续阻塞;发送数据时,write() 多次重传后返回超时错误 | (ETIMEDOUT) 慢 (分钟级) |
正常关机 | 进程优雅退出,发送 FIN 包 | FIN | read() 返回 0 (EOF) | 快 |
kill |
进程捕获信号,优雅退出,发送 FIN 包 | FIN | read() 返回 0 (EOF) | 快 |
kill -9 |
进程被内核强制终结,内核发送 RST 包 | RST | read() 或 write() 立即返回连接重置错误 (ECONNRESET) | 非常快 |
拥塞控制算法
- 慢启动(Slow Start):在连接开始时或网络出现丢包后,发送方以指数增长的方式增加拥塞窗口大小。
- 拥塞避免(Congestion Avoidance):当拥塞窗口达到某个阈值后,采用线性增长方式缓慢增加拥塞窗口大小。
- 快速重传(Fast Retransmit):一旦接收方检测到丢失了一个分组,它会立即发送多次重复的 Ack 给发送方,促使发送方在未等到超时的情况下就进行重传。
- 快速恢复(Fast Recovery):与快速重传配合使用,在检测到丢包时不立即进入慢启动状态,而是尝试通过调整拥塞窗口和阈值来快速恢复正常的数据传输。
Tahoe、Reno 和 New Reno
早期的 TCP 拥塞控制算法仅有慢启动与拥塞避免两个阶段:
Tahoe 和 Reno
当收到 3 个相同的 Ack 时,TCP 不再等待当前窗口的计时器,而会直接认为当前窗口的包已经超时,重新发送当前窗口的包。这是 Tahoe 版 TCP 引入的快速重传算法。
当收到 3 个相同的 Ack 时,TCP 认为既然还能收到 ACK,应该只是局部拥塞,因此会:
这就是 Reno 版 TCP 引入的快速恢复算法,否则初始版本会在第二步将 。
快速恢复算法是基于快速重传算法的,因此 Reno 版 TCP 的流程如下所示:
New Reno
Reno 版 TCP 只考虑了一个包丢失的情况。设想一个场景:
- ,发送了 1-9 号包
- 当拥塞发生时,3、4 号包丢失,触发了 3 号包的快速重传和快速恢复
- cwnd 被设置为 4,发送了 3-6 号包
- 遗憾的是 4 号包再次丢失,但 Reno 收到了 3 号包的 ack 后就会退出快速恢复阶段,因此又会触发 4 号包的快速重传和快速恢复
- 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 时间内重传多个数据包。
D-SACK (Duplicate SACK)
D-SACK 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 Ack 包丢了。也就是能区分下述两个场景:
Ack 回应丢失 | 发出去的包延迟到达 |
---|---|
![]() |
![]() |
同时接到最新的 Ack 和重复的 SACK | 首先接到了最新的 Ack=3000,然后再次接到最新的 Ack 和重复的 SACK |
从 Ack 接受顺序就能分辨出这两个场景,由此可以做更精细的控制(做什么控制?小林 coding 没有写,笔者也不知道)。
CUBIC
以下内容参考自:
Reno 的拥塞避免阶段是线性增长的。线性逼近管道容量 相当于一次查询 (capacity-seeking),但长肥管道从 到 的线性遍历太慢,期间一旦遭遇丢包,则前功尽弃。
长肥管道:具有高带宽 - 延迟积(Bandwidth-Delay Product, BDP)的网络路径。这个术语形象地描述了那些虽然传输速率很高(“肥”),但由于距离远或经过多个路由导致延迟较大(“长”)的网络连接特性。
可以明显看到每次 TD(Duplicated Acks)的时候,Reno 都要线性增长至最大值再重新减半,以此往复。
但这样对带宽的利用率不是很高,希望最好利用起来这条线段的上半区域,于是有了 BIC 算法。
发生丢包时定义最大拥塞窗口为 ,采用乘性减减小拥塞窗口至 。BIC 使用用二分搜索的思想快速逼近 ,每经过一个 RTT,便将窗口设置到 和 的中点,如果没有丢包,则更新 为当前 cwnd 值,一直持续到接近 ,直到二者之差小于 ,这很好地利用了线性增长曲线的上半部分空间。
但是在一个 RTT 里面增长过多会造成传输上的抖动,因而 BIC-TCP 选取了另外取了两个参考值,称为 和 ,如果中点和当前 cwnd 值的差大于 的话,那么 cwnd 就只增长 。
由上可以看出 BIC 这个二分法调参较为复杂,因此又有了直接使用三次多项式拟合 BIC 曲线的 CUBIC 算法。
CUBIC 用一个三次多项式代替了 BIC 增长曲线,对窗口增长机制进行了简化,保证了快速探测合适的拥塞窗口。并且不再以 RTT 为单位,窗口增长函数仅仅取决于连续的两次拥塞事件的时间差,保证了公平性。
BBR (Bottleneck Bandwidth and RTT)
由 Google 提出的一种新型拥塞控制算法,它试图找到并利用瓶颈带宽,从而最大化吞吐量,减少排队延迟。
KCP
KCP 是一个快速可靠协议,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议(如 UDP)的收发,需要使用者自己定义下层数据包的发送方式,以 callback 的方式提供给 KCP。连时钟都需要外部传递进来,内部不会有任何一次系统调用。
… …
TCP 是为流量设计的(每秒内可以传输多少 KB 的数据),讲究的是充分利用带宽。而 KCP 是为流速设计的(单个数据包从一端发送到一端需要多少时间),以 10%-20% 带宽浪费的代价换取了比 TCP 快 30%-40% 的传输速度。TCP 信道是一条流速很慢,但每秒流量很大的大运河,而 KCP 是水流湍急的小激流。
主要是因为原神用了这个协议,笔者知道了所以记录一下。
细微的调参
- RTOx2 => RTOx1.5,TCP 超时计算是 RTOx2,KCP 启动快速模式后不 x2,只是 x1.5(实验证明 1.5 这个值相对比较好),提高了传输速度。
- 延迟 Ack => 非延迟 Ack,TCP 为了充分利用带宽,默认情况下会启用延迟 Ack。其基本思想是不立即对每一个接收到的数据包发送确认(ACK),而是等待一段时间,看是否有机会将这个 Ack 与其他数据一起发送出去。延迟 Ack 导致超时计算会算出较大 RTT 时间,并延长了丢包时的判断过程。KCP 的 Ack 是否延迟发送可以调节。
对重传的优化
- 触发快速重传 3Acks => 2Acks(假如作者的例子就是想说明这点的话):
- 发送端发送了 1,2,3,4,5 几个包
- 收到远端的 Ack: 1, 3, 4, 5
- 当收到 ACK3 时,KCP 知道 2 被跳过 1 次,收到 ACK4 时,知道 2 被跳过了 2 次,此时可以认为 2 号丢失,不用等超时,直接重传 2 号包,大大改善了丢包时的传输速度。
- 全部重传 => 选择性重传,UNA => UNA+Ack,SACK 也能实现,KCP 用了差不多的思想,即 UNA+Ack —— ARQ 模型响应有两种,UNA(此编号前所有包已收到,如 TCP)和 ACK(该编号包已收到),光用 UNA 将导致全部重传,光用 Ack 则丢失成本太高,以往协议都是二选其一,而 KCP 协议中,除去单独的 Ack 包外,所有包都有 UNA 信息。
非退让流控
KCP 正常模式同 TCP 一样使用公平退让法则,即发送窗口大小由四要素决定:
- 发送缓存大小
- 接收端剩余接收缓存大小
- 丢包退让,也就是快速恢复阶段
- 慢启动
但传送及时性要求很高的小数据时,可选择通过配置跳过后两步,仅用前两项来控制发送频率。以牺牲部分公平性及带宽利用率之代价,换取了开着 BT 都能流畅传输的效果。
锐评
KCP 虽然说是协议,但更像是某种拥塞控制算法 + ARQ + 必要的胶水代码,因此也可以简单的视作某种拥塞控制算法。由于当前(2025/03/25)TCP 协议是各种意义上的传输层标准,所谓的拥塞控制算法都是要考虑 TCP 的,因此只能把 KCP 放在这个小节了。
KCP 采取了激进的重传策略,然而,所谓的激进是基于这样一个客观现实——绝大部分系统仍在使用 TCP 的 CUBIC 算法。
CUBIC 相对保守(在乎传输公平性,君子协议),而 KCP 舍弃了传输公平性则相当于侵犯了这部分公共空间。假如大家都使用 KCP,那么大家都不会变快,只有在大部分人用 CUBIC 的时候,使用 KCP 的人才能得到收益。
KCP 协议除了游戏也多用在翻墙上,所以 wall 也是会对这个流量进行管控的。
HTTP/1.x 协议
长连接/持久连接(Keep Alive)
开启与配置长连接
唯一需要注意的就是选择题会考一下长连接字段。开启长连接是使用 Connection
字段,即:
1 | Connection: keep-alive # 开启,HTTP/1.0 默认关闭,需显式开启 |
而 Keep-Alive
字段则是开启长连接后才有用的字段,用以配置长连接,可以包含两个参数:
- timeout:表示连接应保持打开状态的最长时间(以秒为单位)。在这段时间内,服务器应当等待客户端发送新的请求。
- max:表示在关闭连接之前,允许在该连接上发送的最大请求数。
因此,一个典型的 HTTP 请求头可能如下所示:
1 | Connection: keep-alive |
然而,在实际应用中,超时设置通常是在服务器配置文件中手动配置的,而不是通过 HTTP 头部来指定。
长连接的小缺点
长连接也有一个小小的缺点——比较耗服务器资源。
虽然长连接可以靠客户端主动发送 Connection: close
断开连接,但是为了减少延迟、提高性能与用户体验,现代 websever 默认支持并鼓励使用长连接,因此长连接的断开基本只能靠 timeout。
服务器维护大量的不活跃长连接自然是会比较消耗资源的,并且这也是服务器出现大量处于 Timewait 的 TCP 连接的根本原因:
问题来了,什么场景下服务端会主动断开连接呢?
- 第一个场景:HTTP 没有使用长连接,根据大多数现代 websever 的实现,不管哪一方禁用了 Keep-Alive,都是由服务端主动关闭连接。
- 第二个场景:HTTP 长连接超时,还得是大量 HTTP 长连接超时。
- 第三个场景:HTTP 长连接的请求数量达到上限
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 的全新应用层协议,未来可期。
需要清楚的知道,QUIC 作为颠覆性协议的前提是现在通行的协议组合是 HTTP/1.1
+TLS 1.2
+TCP CUBIC
,而 QUIC 可以看作是 HTTP/2
+TLS 1.3
+TCP BBR
的组合,QUIC 有机结合了这个组合中每一项相对于它的老版本的优点,因此可称颠覆性的协议。
从另一个角度说,本身也是新版本的协议(HTTP/2
、TLS 1.3
和 TCP BBR
)推行半天一个也推不动,不如直接把新东西全都用上凑成一个性能超高的新协议然后直接推行这个新协议。
对 HTTP/2 的优化
QUIC 不仅继承了 HTTP/2 对 HTTP/1.1 的优化,由于抛弃了 TCP,对 HTTP/2 也做出一个改进。
消除队头阻塞
HTTP/2 通过多路复用(Multiplexing)技术,解决了 HTTP/1.1 时代的应用层队头阻塞:一个慢请求不会再阻塞后续请求的发送。然而,这却暴露出了一个更深层次的问题——TCP 层的队头阻塞。
HTTP/2 的所有请求“流(Stream)”都运行在一条 TCP 连接之上。TCP 是一个严格保证顺序的协议,它要求数据包按序到达。如果其中一个数据包丢失了,TCP 协议栈会暂停处理后续所有已到达的数据包,直到那个丢失的包被重传并成功接收。
QUIC 则从根本上解决了这个问题。它基于 UDP,而 UDP 本身不保证顺序。QUIC 在 UDP 之上实现了自己的一套可靠的、可多路复用的“流”机制。每个 HTTP 请求被映射到一个独立的 QUIC 流上。
关键在于,这些 QUIC 流在逻辑上是完全独立的。如果承载流 A 数据的一个 UDP 包丢失了,它只会阻塞流 A 的处理。流 B 和 流 C 的数据包只要成功到达,就可以被 QUIC 层正常解析和提交给上层应用,完全不受影响。这就相当于把一条车道变成了 10 条并行的车道,一辆车抛锚,其他车道的车依然可以畅行无阻。
TLS 1.3 对 TLS 1.2 的优化
因为不想单开一节所以放到这。下面的优化不仅是 QUIC 的特点,也确实基本都是 TLS 1.3 对 TLS 1.2 的优化。
更快建立连接(第一次 1 RTT,后续可能 0 RTT)
优化首先来自将 TLS 握手与 TCP 握手过程合并在一起。由于 TCP 握手只是探测通信双方活性,很容易可以想到在握手过程中加入 TLS 交换密钥的信息就可以达成既探测活性又交换密钥的目的。
然后是享受了 TLS 1.3 对 TLS 1.2 的优化,主要优化要点就在于 TLS 1.3 可以达成 1 RTT 握手,而 TLS 1.2 需要 2 RTT。
因此 QUIC 的握手可以直接从 3 RTT(TCP's 1-RTT
+TLS 1.2's 2-RTT
)优化成 1 RTT。
0 RTT 建连也称作连接迁移。
在深入探讨 RTT 减少之前,需要澄清一个常见的误区:ECDHE 相较于 RSA 的主要优点是计算性能,而非前向保密性本身。
前向保密性(Forward Secrecy)的核心思想是“一次一密”,即每次会话都使用临时的、用完即弃的密钥对。这样,即使服务器的长期私钥泄露,攻击者也无法解密之前截获的通信流量。这个特性来源于密钥的“临时性”(Ephemeral),而非具体的算法。
无论是 DHE 还是 ECDHE,名字中的“E”就代表了“Ephemeral”。它们的优势在于生成临时密钥对的计算开销极小。相比之下,为每个连接都生成一对临时的 RSA 密钥对,其计算成本高到无法接受。因此,ECDHE 的高效性能,才使得“为每次连接都实现前向保密性”在实践中变得可行和普及。
那么,TLS 1.3 究竟精简了什么过程,才将建连从 2 RTT 缩减为 1 RTT 呢?
答案在于客户端的“抢跑”和“猜测”。
- 在 TLS 1.2 中,握手是一个“问 - 答 - 问 - 答”的串行过程:
- RTT 1 (去): 客户端发送 ClientHello,告诉服务器:“我支持这些加密套件和曲线,你选一个吧。”
- RTT 1 (回): 服务器收到后,选择一个套件(如 ECDHE)和一个曲线,然后把自己的临时公钥等信息通过 ServerKeyExchange 发回给客户端。
- RTT 2 (去): 客户端必须等收到服务器的选择后,才能生成自己的临时公钥,并通过 ClientKeyExchange 发给服务器。这个等待,就是导致第 2 个 RTT 的根本原因。
- RTT 2 (回): 服务器收到客户端的公钥,计算出共享密钥,握手完成。
- 在 TLS 1.3 中,流程被重构为“我猜你行,你若行,一次搞定”:
- RTT 1 (去): 客户端不再等待,它会猜测服务器最可能支持的密钥交换算法(比如 x25519 曲线),直接在第一个 ClientHello 包里就附上自己为该算法生成的临时公钥(这部分数据放在一个叫 key_share 的扩展里)。
- RTT 1 (回): 服务器收到 ClientHello,如果发现客户端猜对了(大概率事件),它就可以立即用客户端发来的公钥和自己的私钥计算出共享密钥。然后,它把自己的公钥和 Finished 验证消息等一起加密发回。
通过这种方式,TLS 1.3 大胆地砍掉了 ServerKeyExchange 和 ClientKeyExchange 这两个独立的消息,将它们的职能合并到了 ClientHello 和 ServerHello 中,从而将两次网络往返压缩为了一次。
连接迁移
这一优化来源于 TLS 1.2 就引入的会话恢复(Session Resumption)机制,这一机制的思想就是让之前建立过连接的客户端只需要 1 RTT 就能与服务器通信(完整过程需要 2 RTT,省了 1 RTT)。
而 TLS 1.3 也有这一机制,并且更加安全也更有噱头,即 0 RTT 建连(也就是完整建连需要 1 RTT,省下 1 RTT 就是 0 RTT,但是大家都懂的,1->0 永远比 2->1 更有噱头)。
既然可以 0 RTT 建连,自然也可以称作是“迁移”。
要理解 TLS 1.3 的优化,我们必须先看看 TLS 1.2 Session Ticket 机制的原理、优化与不足。
Session Ticket 机制与优化 (2-RTT -> 1-RTT)
在一次完整的 TLS 1.2 握手(2-RTT)成功后,服务器可以将这次会话的关键信息(如主密钥、加密套件等)打包成一个数据块,然后用一个只有自己知道的、长期有效的密钥(Session Ticket Encryption Key, STEK)进行加密,生成一个 Session Ticket 发给客户端。客户端像拿到一张“快速通行证”一样,将它缓存起来。
当客户端再次访问该服务器时,它会在 ClientHello 中带上这张“通行证”。服务器收到后,用自己的 STEK 解密,如果验证通过,就能瞬间恢复之前的会话状态,跳过耗费资源的公钥密码运算和证书交换环节,直接进入加密通信。这个恢复过程只需要一次往返(1-RTT),确实实现了您所说的“省了 1 RTT”。
Session Ticket 的严重不足:破坏前向保密性
这个机制最大的问题出在那个长期有效的 STEK 上。如果在未来的某个时刻,这个 STEK 泄露了,攻击者就能用它解密之前截获的所有 Session Ticket,从而计算出每一次会话的主密钥。这意味着,攻击者可以回头解密过去所有使用该 Ticket 恢复的会话流量。这就完全破坏了前向保密性,是 TLS 1.2 时代一个广受诟病的安全缺陷。
TLS 1.3 的优化:更安全、更有噱头的 0-RTT
TLS 1.3 重新设计了会话恢复机制,称之为预共享密钥(Pre-Shared Key, PSK)模式。它解决了前向保密性的问题,并在此基础上实现了 0-RTT。
- 修复安全漏洞:在 TLS 1.3 中,客户端即使使用 PSK(即 Ticket)进行会话恢复,也必须同时发起一次新的 ECDHE 密钥交换(在 ClientHello 中带上新的 key_share)。最终的会话密钥是由 PSK 和这次新的 ECDHE 交换共同派生出来的。这样一来,即使 Ticket 泄露,攻击者没有本次交换的临时私钥,依然无法计算出会话密钥,从而完美地保留了前向保密性。
- 实现 0-RTT:正因为有了 PSK 作为基础,客户端可以大胆地做一件事:在发送第一个 ClientHello 的同时,就用这个 PSK 加密一些“早期数据”(Early Data),比如一个 HTTP GET 请求,然后一起发给服务器。服务器收到后,用 PSK 解密并马上处理这个请求。这样,从客户端的角度看,它根本没有等待任何握手完成,应用数据就发出去了,实现了“零往返”的极致体验。当然,这种模式有重放攻击的风险,只适用于幂等请求。
代码实现
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 整个处理大致可以分为如下几步:
–> (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 操作。
显然基于 Unix Domain socket 也能跑。