第1章:网络协议基础回顾 — 延迟从哪来?

做低延迟系统,第一件事就是搞清楚:延迟到底藏在哪?

很多人一上来就调内核参数、换网卡、搞DPDK。但说实话,如果连TCP握手多花了几毫秒都说不清,后面优化全是瞎忙活。

这一章,我带你把TCP/IP协议栈、UDP、以及几个常见应用层协议(HTTP、WebSocket、gRPC)的延迟特性过一遍。不扯理论,只讲实战中真正影响延迟的点。

1.1 TCP/IP协议栈 — 延迟的“大头”

先看一张图,这是TCP/IP协议栈的完整路径。你发的每个包,都要走完这条路。

TCP/IP协议栈数据包路径 应用层 传输层(TCP) 网络层(IP) 链路层 网络 (物理传输) 应用层 传输层(TCP) 网络层(IP) 链路层 ① 应用层处理延迟 ② TCP握手延迟(1.5RTT) ③ 路由/排队延迟 ④ 网卡中断/拷贝延迟

这张图我画了很多次。每次给团队讲延迟优化,我都先指着它问:你觉得哪一步最慢?

大多数人会猜网络传输。其实不对。真正吃时间的,是协议栈内部的几件事。

TCP三次握手 — 逃不掉的1.5个RTT

这是TCP最“出名”的延迟来源。你发一个SYN,等SYN-ACK回来,再发ACK确认。一来一回,至少1.5个RTT就没了。

我在项目中遇到过一个问题:客户端和服务端在同一个机房,延迟本来只有0.5ms。但每次建连都要等1.5个RTT,也就是0.75ms。看起来不多,但如果是高频交易场景,每秒建几千次连接呢?

关键数据:

  • 同机房RTT ≈ 0.1-0.5ms → 握手延迟 ≈ 0.15-0.75ms
  • 同城跨机房RTT ≈ 1-3ms → 握手延迟 ≈ 1.5-4.5ms
  • 跨地域RTT ≈ 30-100ms → 握手延迟 ≈ 45-150ms

避坑指南:我曾经在压测时发现,每次请求都新建TCP连接,导致吞吐量上不去。后来改用连接池,延迟直接降了40%。记住:能复用连接就别新建

拥塞控制 — 慢启动的“温柔陷阱”

TCP的拥塞控制,说白了就是“试探性发送”。刚开始只能发一个包,等确认了再翻倍。这个机制叫慢启动。

你想想看,如果一次要传1MB数据,慢启动阶段可能得花好几个RTT才能把窗口撑大。对于短连接来说,这个开销尤其明显。

我记得有一次帮一个游戏团队优化登录协议。他们每次登录要传200KB的配置数据,但连接只维持几秒钟。结果80%的时间都花在慢启动上。后来我们改成预连接+预推送,才把登录延迟从2秒压到300ms。

粘包与Nagle算法 — 你等我还是我等你?

Nagle算法是为了减少小包数量。它的逻辑是:等缓冲区攒够了再发。但如果你发的是交互式小包(比如游戏操作指令),这个“等”就会引入额外延迟。

为什么会这样?因为Nagle算法会等上一个包被确认后,才发下一个包。如果应用层发了一个小包,它可能等上200ms(默认的延迟ACK定时器)才发出去。

警告:低延迟场景下,务必禁用Nagle算法。设置TCP_NODELAY选项。我见过太多人因为没关这个,导致延迟莫名其妙多了几百毫秒。

1.2 UDP协议 — 快,但别太乐观

UDP没有握手,没有拥塞控制,没有重传。说白了就是“发了就不管”。所以它的延迟理论上比TCP低很多。

但别高兴太早。UDP也有自己的坑。

无状态带来的问题

UDP不保证顺序,不保证到达。如果你的应用需要可靠传输,就得自己在应用层实现。而应用层的重传逻辑,往往比TCP的内核实现慢得多。

我做过一个实时音视频项目,最初用UDP裸传。结果丢包率一上来,画面直接卡成PPT。后来我们自己在应用层做了FEC(前向纠错)和NACK(选择性重传),才把问题解决。

UDP的缓冲区问题

UDP的接收缓冲区默认很小(Linux上通常是208KB)。如果应用层读取不及时,数据包会被内核丢弃。这种丢包是静默的,你连个通知都收不到。

实战建议:

  • 调大UDP接收缓冲区:sysctl -w net.core.rmem_max=26214400
  • 使用SO_RCVBUF选项:setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size))
  • 应用层做丢包检测和补偿

1.3 应用层协议 — 延迟的“最后一公里”

协议栈的延迟说完了,但真正的噩梦在应用层。

HTTP/1.1 — 队头阻塞的“老顽固”

HTTP/1.1有个致命问题:队头阻塞。同一个连接上的请求必须排队,前一个没处理完,后一个就得等着。

你可能会说:“我用多个连接不就行了?” 嗯,浏览器确实会开6-8个并行连接。但每个连接都有自己的TCP握手和慢启动开销。而且连接数一多,服务器端的资源消耗也上去了。

我记得在优化一个API网关时,发现客户端用HTTP/1.1发了10个并发请求,但服务器端实际只处理了2个。剩下的8个都在排队。后来改成HTTP/2,延迟直接降了60%。

WebSocket — 全双工的“双刃剑”

WebSocket解决了HTTP的“请求-响应”模式,实现了真正的全双工通信。建连后,双方可以随时发数据。

但它的延迟问题在于:帧格式开销。每个WebSocket帧都有2-14字节的头部。如果发的是小数据(比如一个坐标点),头部开销占比可能超过50%。

我的做法:在WebSocket上传输小数据时,我会把多个小数据合并成一个帧,或者使用二进制帧(而不是文本帧)。这样可以减少帧头部的比例。

gRPC — 基于HTTP/2的“新贵”

gRPC使用HTTP/2作为传输层,支持多路复用、流控、头部压缩。它的延迟特性比HTTP/1.1好很多。

但gRPC也有自己的问题:Protobuf序列化/反序列化开销。虽然Protobuf比JSON快,但在高频调用场景下,序列化时间仍然不可忽视。

我做过一个测试:同样的数据结构,JSON序列化需要5μs,Protobuf只需要0.5μs。看起来差距很大,但如果每秒调用100万次,Protobuf能省下450ms的CPU时间。这450ms,在低延迟系统里就是天壤之别。

1.4 延迟对比一览表

协议 建连延迟 传输延迟 适用场景 我的评分
TCP 1.5 RTT 中等(受拥塞控制影响) 可靠传输、大文件 ⭐⭐⭐
UDP 0 低(无拥塞控制) 实时音视频、游戏 ⭐⭐⭐⭐
HTTP/1.1 1.5 RTT + 应用处理 高(队头阻塞) 传统Web API ⭐⭐
WebSocket 1.5 RTT(基于TCP) 低(全双工) 实时推送、聊天 ⭐⭐⭐⭐
gRPC 1.5 RTT(基于HTTP/2) 低(多路复用) 微服务、高频RPC ⭐⭐⭐⭐⭐

1.5 本章小结

说了这么多,其实就一句话:延迟不是凭空产生的,它藏在协议栈的每一层里

TCP的握手、慢启动、Nagle算法,UDP的缓冲区、丢包处理,HTTP的队头阻塞,WebSocket的帧开销,gRPC的序列化——每一个环节都可能成为瓶颈。

我个人的习惯是:先画路径图,再逐层分析。把数据从应用层到网卡的每一步都列出来,然后问自己:这一步能不能省?能不能并行?能不能用更轻量的方式替代?

下一章,我会带你深入Linux内核协议栈,看看那些“看不见”的延迟到底是怎么产生的。到时候你会发现,很多问题其实出在你完全没想到的地方。


公众号:蓝海资料掘金营,微信deep3321