Skip to content

第39章 TCP、UDP 和 QUIC 的对比

在技术选型时,单纯用“延迟低”或“可靠”来衡量传输协议是远远不够的。我们更应该关注应用的真实需求:你要传输的是连续的字节流、相互独立的消息,还是并发的多条数据流?数据如果迟到了,还有没有实际价值?网络切换时连接需不需要迁移?加密要在哪一层做?当然,还要考虑当前的网络环境和基础设施是否支持。

这一章,我们会把 TCP、UDP 和 QUIC 放在同一个维度下进行对比,并在最后用一个可控的 UDP 实验来为本书画上句号。

一、三种协议的传输抽象

TCP:可靠、有序的双向字节流

TCP 的核心理念是先建立连接,然后为每个方向上的字节分配序号(Sequence Number)。它向应用层提供的是连续、有序的字节流。一旦发生丢包,TCP 会自动通过重传机制来恢复;它还内置了流量控制(Flow Control)来避免接收端被压垮,并利用拥塞控制(Congestion Control)来动态调节网络中的在途数据量。不过,由于 TCP 只是无边界的字节流,应用层必须自己想办法(比如通过长度字段或特殊分隔符)来切分出完整的业务消息。

在 TCP 中,“有序”这个约束覆盖了整条连接。如果前面的某个段(Segment)丢了,后面哪怕已经到达的数据也会被堵在接收端的缓冲区里,直到缺失的缺口被补齐(这就是常说的队头阻塞)。在操作系统层面,TCP 的实现通常深埋于内核之中,已经非常成熟稳定。借助抓包工具,我们可以直观地观察到 Seq、Ack、窗口(Window)大小、重传行为以及各种连接状态。

UDP:松散、独立的数据报

与 TCP 不同,UDP 收发数据的基本单位是数据报(Datagram)。每个 UDP 报文只带有极简的首部信息:源端口、目的端口、长度和校验和。接收方在调用 API 读取时,一次只读出一个完整的数据报。如果应用层准备的接收缓冲区太小,装不下整个报文,多出来的部分就会被截断甚至直接丢弃。

UDP 协议本身“一穷二白”:没有握手建连,没有重传机制,不保证顺序,更没有接收窗口和拥塞控制。数据报在网络中完全可能丢失、重复或者乱序到达。如果你想在 UDP 之上实现可靠性,应用层就必须自己造轮子,根据实际场景引入请求 ID、序号、确认(ACK)、重传、前向纠错(FEC)、速率控制以及安全握手等机制。另外需要特别注意的是,如果你打算在公网上持续发送大量 UDP 报文,你的应用层协议仍然需要遵循拥塞控制的底线原则,并且要自己把控好数据报的大小,避免超过网络路径的 MTU(最大传输单元)。

顺便提一句,当你在代码里对 UDP Socket 调用 connect 时,操作系统其实只是在本地记录了默认的对端地址,顺便帮你过滤掉来自其他地址的数据,网络上并不会产生任何真实的“UDP 握手”报文。

QUIC:基于 UDP 的安全连接与多条流

QUIC 巧妙地构建在 UDP 之上,补齐了 UDP 所缺失的所有能力:它实现了连接管理,融合了 TLS 1.3 的安全握手,并提供可靠流、确认机制、丢包恢复以及流量与拥塞控制。不同于深埋内核的 TCP,QUIC 的实现通常位于用户空间,这使得它的协议迭代速度极快,与应用的集成也更加灵活。

一条 QUIC 连接能够同时承载多条并发的流(Stream)。最核心的改进在于:它的“可靠与有序”是限制在单条流内部的。如果某条流发生了丢包,只有那条流会被阻塞,其他流只要数据完整,就可以立刻把数据交给应用层。这就彻底解决了在多路复用场景下,TCP 字节流因为某个包丢失而导致整个连接队头阻塞的顽疾。当然,由于这些流共享同一条物理路径和整条连接的拥塞控制上下文,丢包依然会导致整条连接的可用发送窗口缩小。

QUIC 抛弃了传统的“IP + 端口”四元组寻址方式,转而使用 Connection ID(连接标识符)来唯一识别连接,这就为连接迁移和路径验证奠定了基础。比如,当你的手机从 Wi-Fi 切换到蜂窝网络,或是中间的 NAT 映射发生改变时,TCP 连接肯定会断开,但 QUIC 却能平滑迁移,保持业务不断线(目前 QUIC v1 的主动迁移是由客户端发起的,且需满足握手已确认、服务端策略允许以及新路径验证通过等条件)。此外,QUIC 默认全程加密,绝大多数传输控制信息都被保护了起来。这对安全是好事,但对网络诊断是个挑战——我们很难像抓 TCP 那样直接看出端倪,往往需要依赖端点导出的日志(如 qlog)和会话密钥。

二、多维比较

维度TCPUDPQUIC
应用抽象双向字节流独立数据报连接内多条流,可扩展数据报
可靠性可靠交付连续字节由应用层协议决定流内可靠;QUIC DATAGRAM 可提供非可靠消息
有序范围整条连接每个方向无内建顺序每条流内部独立有序
建立三次握手;TLS 另行握手无传输层握手传输与 TLS 集成握手,恢复时可实现 0-RTT
加密由 TLS 等上层协议提供由 DTLS 或应用层实现协议内核深度集成 TLS 1.3
拥塞控制内置应用层自行负责内置
多路复用依赖 HTTP/2 等应用层协议应用层自行定义传输层原生支持多路复用
路径变化四元组改变通常导致连接断开应用可继续向新地址发送依赖 Connection ID 和路径验证实现平滑迁移
实现位置通常实现在操作系统内核操作系统内核提供数据报 API通常作为用户空间库建立在 UDP 之上
网络部署成熟稳定、无处不在覆盖广,但在某些网络中会被限速或拦截依赖 UDP 可达性,需设计降级回退机制
观察方式可清晰观察 Seq/Ack、状态机和控制字段可见数据报长、ICMP 及明文字段仅可见包长、时序、Connection ID,重度依赖端点日志

很多人认为 UDP “调一次 API 就能把数据发出去”,这其实只是一种代码层面的一厢情愿。一旦你的 UDP 应用需要身份认证、可靠性和拥塞控制,你一样需要在应用层实现握手交互、维护复杂的连接状态、管理各种计时器,并付出额外的网络往返(RTT)代价。相反,如果在 TCP 或 QUIC 上复用一条已经建立好的连接,后续的请求同样可以实现“直接写入,立刻发送”。归根结底,实际的业务延迟是由底层的连接状态、物理 RTT、网络排队、丢包率以及服务端的处理速度共同决定的。

三、HTTP/1.1、HTTP/2 与 HTTP/3

让我们回头看看本书第1章提到的现代浏览器网络访问拓扑:

text
HTTP/1.1 → TLS → TCP → IP
HTTP/2   → TLS → TCP → IP
HTTP/3   → QUIC(集成 TLS)→ UDP → IP

这清晰地展示了浏览器访问 HTTPS 站点的典型协议栈(当然,如果是明文的 HTTP/1.1 或 h2c,会省略 TLS 层)。在 HTTP/1.1 时代,我们通常靠复用连接或开启多条并发 TCP 连接来提升效率。到了 HTTP/2,它创新性地将多个 HTTP Stream 的数据帧(Frame)多路复用进了一条 TCP 字节流中;但这带来了一个致命缺陷:只要底层的某一个 TCP 段(Segment)丢了,整个 TCP 连接的接收端都得停下来等待重传补齐缺口,在此之前,哪怕其他 Stream 的数据已经完整到达,也无法交付给应用层。

HTTP/3 则彻底换了地基,把 HTTP Stream 直接映射到了 QUIC Stream 上。在 QUIC 里,某条流发生丢包只会阻塞它自己,其他流的数据依然可以畅通无阻地交付。不过,物理法则依然存在:整条连接级别的拥塞控制、服务端的 CPU 处理能力、请求优先级以及链路总带宽,依然会从全局层面影响所有请求的表现。

从抓包的视角来看,HTTP/2 over TLS 虽然内容被加密,但你依然能清晰地看到 TCP 的三次握手、Seq/Ack 交互以及 TLS 记录的轮廓;而 HTTP/3 在抓包软件里,几乎全是一团团跑在 UDP 上的神秘 QUIC 报文。要想解密并分析 HTTP 层的具体内容,你必须拿到客户端或服务端导出的会话密钥(如 SSLKEYLOGFILE),并使用兼容 QUIC 的分析工具(如新版 Wireshark)。

四、场景选择

Web 与通用 API

由于传统的 HTTP 生态、企业级防火墙和各种网关代理都已经非常成熟,TCP + TLS + HTTP/2 的组合在今天依然拥有极高的部署成功率和兼容性。然而,如果你的服务面向的是现代移动设备(它们通常内置了 HTTP/3 支持),或者场景中充斥着高并发的小请求、且用户频繁在 Wi-Fi 和蜂窝网络间切换,QUIC 的 0-RTT 建连和消除队头阻塞的能力就会大放异彩。在真实的生产环境中,业内主流的做法是同时开放 HTTP/2 和 HTTP/3,让客户端根据协商结果(如 Alt-Svc)和实际的网络可达性自动做出最优选择。

DNS

传统的 DNS 绝大部分时间都在使用极度精简的 UDP 报文进行一问一答,只有在遇到响应报文过大被截断(Truncated)、或者进行区域传送(Zone Transfer)时,才会切换到 TCP。而现代 DNS 也在使用 DoT(DNS over TLS)、DoH(DNS over HTTPS)甚至 DoQ(DNS over QUIC)。到底用哪种,往往取决于你对响应体积、隐私保护、连接复用效率以及基础设施兼容性的综合考量。

DoQ 是什么?

DoQ(DNS over QUIC)在 QUIC 连接中传输加密 DNS 查询。它利用 QUIC 的安全握手、流复用和独立丢包恢复,避免同一连接上一条查询丢包阻塞所有其他查询。

DoQ 与 DoH 都能加密 DNS 内容,承载协议、端口和基础设施集成方式不同。

实时音视频与在线游戏

在音视频直播或通话中,如果某一帧画面或声音错过了播放的“死线”,哪怕重传回来了也毫无意义。因此,这类场景通常深度定制基于 UDP 的实时传输协议(如 WebRTC),由应用层自己精细控制重传窗口、前向纠错(FEC)、抗抖动缓冲(Jitter Buffer)以及拥塞策略。

在线游戏也是如此:玩家的位置坐标等高频快照数据通常通过 UDP 发送,丢了就丢了,下一个马上就来;而登录认证、支付扣款、关键状态同步等则必须走绝对可靠的 TCP 通道。有趣的是,现代的 QUIC 协议通过同时提供可靠流(Stream)和不可靠数据报(QUIC DATAGRAM)扩展,让开发者可以在同一个安全加密的连接里,优雅地混搭这两种截然不同的语义。

遥测、控制与文件传输

像物联网设备上报周期性遥测数据,往往只需要简单的序号就能过滤重复或识别丢包。但如果是下发关键控制指令,就必须引入身份认证、幂等性设计、可靠的执行反馈和操作审计机制了。对于文件传输而言,最重要的诉求就是完整、有序地交付数据,TCP + TLS 或 QUIC 流都能完美胜任。在拍板做技术选型时,除了看协议理论上有多好,你还需要脚踏实地地评估第三方库的成熟度、代理网关的支持力度、解密带来的 CPU 开销、现有运维诊断工具链的兼容性,以及团队的知识储备。

五、在 UDP 上补可靠性意味着什么

经常有开发者觉得 TCP 太笨重,想在 UDP 上自己搓一个可靠的传输协议。但假设你要基于 UDP 可靠地传输一份大文件,你至少需要在代码里硬磕以下这些难题:

  • 为消息或字节分配序号,自己实现大报文的分片与到达后的重组;
  • 设计 ACK 确认机制,过滤重复包,精准估算超时时间并优雅发起重传;
  • 维护一个能处理乱序到达的缓冲区,并严密控制内存上限以防 OOM;
  • 实现流量控制和拥塞控制算法,动态探测路径 MTU,以防把网络彻底堵死;
  • 设计安全的握手流程、身份认证机制,管理密钥的轮换,还要防范重放攻击;
  • 巧妙处理 NAT 映射超时,应对 IP 地址变更,优雅地关闭连接,还要兼顾不同版本的协议协商;
  • 在各种极端的弱网环境下保证恢复能力,对其他并发连接保持公平(不恶意抢占带宽),并在多个操作系统上跑通严格的互操作测试。

你看到了吗?上面这一长串清单,正是像 TCP 这种成熟传输协议历经几十年风霜打磨出的核心结晶。除非你的业务有着极其特殊的时效性诉求,并且你的团队拥有顶尖的协议工程能力,否则,请不要轻易去挑战底层的黑暗森林。对于绝大多数需要可靠传输的常规业务,老老实实直接采用 TCP+TLS 或 QUIC,能显著降低系统的正确性风险。

六、Windows 实验:亲眼看见 UDP 数据报语义

接下来,我们通过一个简单的 Python 程序来直观感受一下 UDP 粗犷的数据报语义。这段代码只绑定了本地的回环地址 127.0.0.1:3939。我们在服务端代码里做了点手脚:故意把序号是 3 的倍数的请求丢掉不回覆,并让奇数序号的响应故意延迟发送。这样就能极其稳定地在本地复现出丢包和乱序现象。它只是在模拟应用层能观察到的结果,绝对安全,不会弄乱你的系统网络配置。

python
import socket, sys, threading, time

ADDRESS = ("127.0.0.1", 3939)

def run_server():
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.bind(ADDRESS)
        print("UDP server", ADDRESS)
        while True:
            data, peer = sock.recvfrom(2048)
            sequence = int(data.decode().split(":")[1])
            print("one datagram", sequence, "bytes", len(data))
            if sequence % 3 == 0:
                print("controlled drop", sequence)
                continue
            def reply(payload=data, target=peer, seq=sequence):
                time.sleep(0.15 if seq % 2 else 0.01)
                sock.sendto(payload, target)
            threading.Thread(target=reply, daemon=True).start()

def run_client():
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.settimeout(0.2)
        for sequence in range(1, 7):
            sock.sendto(f"SEQ:{sequence}".encode(), ADDRESS)
        received = []
        deadline = time.monotonic() + 1
        while time.monotonic() < deadline:
            try:
                data, _ = sock.recvfrom(2048)
                received.append(int(data.decode().split(":")[1]))
            except socket.timeout:
                pass
        print("received order", received)
        print("missing", sorted(set(range(1, 7)) - set(received)))

run_server() if sys.argv[1] == "server" else run_client()

请打开两个 PowerShell 窗口,分别运行:

powershell
python .\udp39.py server
python .\udp39.py client

你会看到,客户端收到的序号顺序大致是 [2, 4, 1, 5],并明确报告丢失了 [3, 6]。这里有两个关键现象:第一,不管顺序多乱,你每次调用 recvfrom,拿到的都是一个完完整整的数据报,绝对不会出现半个报文;第二,如果你的业务逻辑要求必须拿到所有 1 到 6 的数据,那么很遗憾,你现在就得在应用层自己写代码来处理 [3, 6] 的超时重传、排重以及各种速率限制逻辑了。

在运行上述脚本的同时,打开 Wireshark,监听本机的 Npcap Loopback Adapter,你可以使用这个过滤器:

text
udp.port == 3939 || tcp.port == 3939 || quic

为了形成鲜明对比,你可以把第26章讲解的那个基于长度字段的 TCP 服务拿过来,端口改成 TCP 3939,同样发六条消息。对比抓包结果你会发现:TCP 的抓包里充满了密密麻麻的 Seq/Ack 确认,以及因为底层机制导致的数据分段(应用层不得不通过长度字段一点点拼凑出六条有序的原始消息);而 UDP 的抓包则干脆利落,清晰地显示了 6 个独立的请求数据报,以及应用层故意忽略掉的那 2 个回应。

最后,顺便检查一下你本机的 curl 是否已经拥有了拥抱 HTTP/3 的能力:

powershell
curl.exe --version

如果输出的 FeaturesProtocols 列表中包含 HTTP3,恭喜你,你可以直接用 curl.exe --http3-only 命令去访问一个你控制的 HTTP/3 测试节点并抓包验证。如果还不支持,也没关系,保留好前面的 TCP 和 UDP 实验结论,等以后有了现成的 HTTP/3 实验环境再来补充体验 QUIC 抓包。在日常的网络调试中,提前摸清手里工具箱的底细,也是工程师必备的基本功。

七、Linux 扩展与选择清单

如果你有一台 Linux 机器,把上面的 UDP Python 脚本丢上去跑一下,然后通过下面这两个命令,对照看看 Socket 的状态与真实的抓包:

bash
ss -uapn | grep 3939
sudo tcpdump -ni lo 'udp port 3939 or tcp port 3939'

最后,在真实的业务中面临传输协议选型时,不妨在团队开会时依次把这 7 个问题拿出来灵魂拷问一下:

  1. 我们的应用层需要怎样的交付单位?是一条绵延不断的字节流、一个个独立的消息数据报,还是互相不干扰的多条并发数据流?
  2. 我们的业务数据,到底哪些是必须 100% 可靠送达且不能乱序的?哪些是丢了也无所谓、过时不候的?
  3. 我们是否非常依赖诸如底层原生加密、无缝连接迁移、0-RTT 极速建连,或者 QUIC 的不可靠数据报扩展(DATAGRAM)?
  4. 我们系统运行的网络环境(比如死板的企业内网防火墙、复杂的移动运营商网络、各种层级的代理和云端负载均衡)对 UDP 报文的放行率到底有多高?会不会被无情拦截或限速?
  5. 我们的客户端和服务端生态里,有没有靠谱的第三方协议库?一旦出 bug 了,升级机制作不作美?能不能方便地导出密钥日志来进行抓包排查?
  6. 如果决定自己造轮子(或者魔改开源方案),我们的团队有没有能力在复杂的弱网环境下,持续验证拥塞控制算法的有效性、连接的公平性以及与其他实现的互操作性?
  7. 如果高大上的协议走不通,降级回退到老旧协议时,我们会牺牲掉哪些业务语义?我们在监控大盘上能不能清晰地分辨出目前真实的协议流量占比?

请记住,任何“高大上”的协议名称,仅仅是为你提供了一个较高的起点底座。一个系统真正的端到端网络表现,是由你的代码实现细节、沿途的物理网络链路质量、系统高负载下的处理能力,以及你应用层的架构设计共同决定的。

理解检查

  1. TCP、UDP 和 QUIC 分别向应用层提供了怎样的基础数据抽象单位?
  2. 在多路复用场景下,QUIC 是如何巧妙化解传输层队头阻塞(Head-of-Line Blocking)难题的?
  3. 如果硬要在 UDP 上实现可靠传输,应用层需要自己捏出哪些硬核机制?
  4. 为什么说“UDP 没有握手阶段”并不意味着业务就能省掉这些网络交互开销?
  5. 同样遇到单个报文丢失,HTTP/2 和 HTTP/3 在交付各个并发 Stream 的数据时,表现有何本质区别?
  6. 实时音视频、高危控制信令、以及大文件传输,这三类业务在传输语义上的痛点分别是什么?
  7. 为什么在准备大干一场引入 QUIC 时,非要先摸底本地网络对 UDP 的宽容度以及现有的运维基建能力?

延伸阅读


上一章:第38章 TCP 安全基础 · 所属篇:第七篇 · 下一页:附录 · 教程总览

Licensed under CC BY-NC-SA 4.0.