Summary
前面几篇,我们看着 TCP 一层层往上搭:序号、确认、重传、排序、去重、流量控制、拥塞控制。每一样都是为了同一个目标——让应用拿到一段完美的字节流。
现在换个角度问:这些保障,是不是每个应用都想要?
答案是否定的,而且否定得很彻底。看几个场景:
- 视频通话丢了一帧,你希望它重传吗?不。等重传的那 100ms,画面已经该播下一帧了——迟到的正确数据,还不如及时的错误数据。
- DNS 查询只有一个来回,几十字节。为它做三次握手建连接、再四次挥手拆掉,握手的开销比数据本身大好几倍。
- 游戏里角色的位置每秒更新几十次。第 3 帧的坐标丢了?没关系,第 4 帧马上就到,而且更新——重传一个已经过时的坐标毫无意义。
这些场景的共同点是:TCP 提供的保障不但用不上,反而成了负担。
于是有了 UDP(User Datagram Protocol,用户数据报协议)。它的定位可以用一句话概括:在 IP 之上只加了最少的东西,然后把所有决定权交还给你。
UDP 到底加了什么:一张只有 8 字节的头
TCP 头部至少 20 字节,有十几个字段。UDP 头部固定 8 字节,只有 4 个字段:
0 16 31
┌───────────────────┬───────────────────┐
│ 源端口 (16) │ 目标端口 (16) │
├───────────────────┼───────────────────┤
│ 长度 (16) │ 校验和 (16) │
└───────────────────┴───────────────────┘
8 字节,全部字段就这些
对照着看它没有什么,比看它有什么更能说明问题:
| TCP 有 | UDP 有吗 | 意味着 |
|---|---|---|
| 序列号 | ❌ | 不保证顺序,先发的可能后到 |
| 确认号 / ACK | ❌ | 不知道对方收到没有 |
| 重传机制 | ❌ | 丢了就是丢了,不补 |
| 窗口 / 流量控制 | ❌ | 不管对方接不接得住 |
| 拥塞控制 | ❌ | 不管网络堵不堵,照发 |
| 连接状态 | ❌ | 不用握手,发了就走 |
| 校验和 | ✅ | 这个留下了,能发现数据损坏(但只是丢弃,不重传) |
所以 UDP 提供的服务只有两样:用端口号把数据交给主机上正确的那个程序(这叫多路复用/分用),以及用校验和发现数据是不是坏了。除此之外,它就是 IP 的一层薄薄的包装纸。
RFC 768 定义 UDP 的整份文档只有 3 页,对比 TCP 的 RFC 9293 有 100 多页。这个体量差,就是两种设计哲学最直观的注脚。
最关键的差别:数据报 vs 字节流
这是 UDP 和 TCP 最容易被忽略、但实践中最重要的区别,比"可靠不可靠"还要影响你写代码的方式。
TCP 是面向字节流的:你 Write 两次,对方可能一次 Read 全收到(粘包),也可能分三次读到(拆包)。TCP 不保留消息边界,你必须自己做分帧。
UDP 是面向数据报(datagram)的:一次 WriteTo 就是一个独立的包,对方一次 ReadFrom 恰好收到这一个包,一个不多一个不少。
TCP(字节流):像往水管里倒水
发送:Write("AB") Write("CD")
接收:可能读到 "ABCD" / "A"+"BCD" / "AB"+"CD" ← 边界消失了
UDP(数据报):像一个个独立的包裹
发送:WriteTo("AB") WriteTo("CD")
接收:要么收到完整的 "AB",要么这个包整个丢了
绝不会收到 "ABC" 或半个 "A" ← 边界被完整保留
这带来一个反直觉的好处:用 UDP 反而不用处理粘包问题。 你在 TCP 上辛辛苦苦写的长度前缀分帧逻辑,在 UDP 上根本不需要——协议本身就帮你保住了边界。
代价当然是:这个包要么完整到达,要么整个消失,没有中间状态。所以 UDP 的可靠性是"全有或全无“的:你不用担心收到半条消息,但要有心理准备完全收不到。
一个必须记住的实践要点:UDP 的包不要太大。 一旦超过路径 MTU(通常 1500 字节,减去 IP 和 UDP 头,安全载荷约 1472 字节),IP 层就要分片。任意一片丢了,整个包就废了。UDP 又不重传,于是丢包率被成倍放大。
所以基于 UDP 自研协议时,一条经验法则是:单包控制在 1200 字节以内(为各种隧道封装留余量)。QUIC 的初始包大小就定在这个量级,不是巧合。
UDP 的性能优势从哪来
“UDP 比 TCP 快"这句话被说滥了,但快在哪、快多少,值得说清楚。它的优势主要来自三处,而且只有第三处是真正的大头。
① 头部小(8 vs 20+ 字节)。 省 12 字节。对 1500 字节的包来说不到 1%,基本可以忽略——除非你在发海量的极小包。
② 不用握手。 TCP 建连接要一个 RTT(HTTPS 加上 TLS 要 2-3 个)。对 DNS 这种"一问一答"的场景,这就是从 3 个 RTT 降到 1 个,延迟直接降到三分之一。对短交互,这是决定性的。
③ 没有队头阻塞(Head-of-Line Blocking)。这才是最关键的一条。
TCP 必须按序把数据交给应用。如果第 3 个包丢了,第 4、5、6 个包哪怕早就到了,也只能在内核缓冲区里干等——因为 TCP 承诺过"有序”,它不能把第 4 个包先交上去。
TCP 的队头阻塞:
到达情况: [1] [2] [ X ] [4] [5] [6]
↑ 第3个丢了
应用能读到: 1, 2, ...然后卡住...
4/5/6 明明躺在缓冲区里,但必须等 3 重传到达才能一起交付
↑ 等待时间 = 至少一个 RTT,可能更久
UDP:
应用直接收到 1, 2, 4, 5, 6 —— 缺的那个自己看着办
对视频、游戏这类"新数据比旧数据有价值"的场景,队头阻塞是灾难性的:为了等一个已经过时的旧帧,把后面所有新帧全卡住,画面直接冻住。而用 UDP,后面的帧照常播,顶多某一帧有点瑕疵。
这也解释了一个曾经困扰很多人的问题:HTTP/2 明明实现了多路复用(一条连接跑多个请求),为什么在弱网下反而比 HTTP/1.1 还慢? 因为它的多路复用是在应用层做的,底下还是一条 TCP 连接——任何一个流丢了包,TCP 就把整条连接卡住,所有流一起遭殃。HTTP/2 消灭了应用层的队头阻塞,却把 TCP 层的队头阻塞放大了。这正是 HTTP/3 要换到 UDP 上的根本原因。
UDP 的真实应用场景
把上面的分析落到具体场景,UDP 的用武之地可以归成四类。
实时音视频:迟到的数据没有价值
视频通话、直播、语音聊天(WebRTC、RTP/RTCP)。核心逻辑:画面/声音是有"播放时刻"的,过了那个时刻,数据再正确也没用了。
这类应用不是"不要可靠性”,而是在应用层自己实现有选择的可靠性:关键帧(I 帧)丢了值得重传,普通帧(P 帧)丢了就用前后帧插值糊弄过去。这种精细的取舍,TCP 那种"一视同仁全部重传"的策略给不了。
短交互:握手成本比数据还大
DNS 是最经典的例子。
DNS 也支持 TCP,主要用在响应太大(超过 512 字节,比如 DNSSEC 签名)或做区域传送时。这是个很好的例子:同一个应用,可以按数据大小在两个传输层协议之间切换。
NTP(时间同步) 同理——而且它还有个特殊考量:TCP 的重传和缓冲会引入不确定的延迟,而 NTP 的全部工作就是精确测量延迟。用 TCP 反而会污染测量结果。
广播 / 多播:TCP 根本做不到
TCP 是点对点的——它的连接由一对 socket 唯一确定,天生只能一对一。想给局域网里所有设备发一条消息?TCP 得建 N 条连接发 N 次。
UDP 无连接,所以天然支持:
- 广播(broadcast):发给本网段所有主机。DHCP 就靠它——你的设备刚接入网络时还没有 IP 地址,只能吼一嗓子"谁能给我分配个 IP",这在 TCP 里根本无法想象(建连接需要知道对方地址)。
- 多播(multicast):发给订阅了某个组的一批主机。IPTV、金融行情推送(交易所把行情用多播推给所有券商,一份数据全网复用)。
- 服务发现:mDNS/Bonjour(局域网里发现打印机、AirPlay 设备)、SSDP。
自己造轮子:在 UDP 上重建可靠性
这是最有意思的一类,也和你的方向最相关。
思路是:UDP 不是"没有可靠性",而是"把可靠性的设计权交给你"。 如果 TCP 的策略不适合你的场景,你可以在 UDP 之上实现一套定制的可靠传输:
- QUIC / HTTP3:在 UDP 上重新实现了连接、加密、可靠传输和拥塞控制,但避开了 TCP 的队头阻塞——多个流互相独立,一个流丢包不影响别的流。
- KCP:开源的快速可靠协议,用"浪费 10%-20% 带宽换取 30%-40% 的延迟降低",游戏加速常用。
- QUIC 也解决了另一个 TCP 死结:连接迁移。 TCP 连接由四元组(源IP+源端口+目标IP+目标端口)标识,你从 WiFi 切到 4G,IP 变了,连接必然断掉。QUIC 用一个独立的连接 ID 标识连接,IP 变了连接照样活着——手机上刷视频从家里走到街上不卡顿,靠的就是这个。
为什么这些都建在 UDP 上而不是直接建在 IP 上? 因为现实世界里到处是 NAT 和防火墙,它们只认识 TCP 和 UDP;你发一个新的 IP 协议号出去,大概率半路就被丢了。UDP 是唯一一条能穿过现有网络设备、又足够"薄"的路。 这是个非常现实的工程妥协。
Go 里怎么写 UDP
Go 的 net 包对 UDP 的支持很直接,但编程模型和 TCP 有本质区别,不能照搬。
服务端:没有 Accept,只有一个 socket
func udpServer() {
addr, _ := net.ResolveUDPAddr("udp", ":9000")
conn, err := net.ListenUDP("udp", addr) // 注意:没有 Listen + Accept 两步
if err != nil {
log.Fatal(err)
}
defer conn.Close()
buf := make([]byte, 1500) // 一次读一个完整数据报,按 MTU 开缓冲
for {
// ReadFromUDP 返回:读到的字节数 + 对方地址
n, remoteAddr, err := conn.ReadFromUDP(buf)
if err != nil {
continue
}
// 关键:这 n 个字节就是对方一次 WriteTo 发的完整内容,边界天然保留
go handle(conn, remoteAddr, append([]byte(nil), buf[:n]...))
}
}
func handle(conn *net.UDPConn, addr *net.UDPAddr, data []byte) {
// 回复时必须显式指定发给谁——因为没有"连接"这回事
conn.WriteToUDP([]byte("pong"), addr)
}
和 TCP 写法的三个关键区别,每一个都源于"UDP 无连接":
- 没有
Accept()。TCP 是每个客户端一条独立的net.Conn,UDP 是所有客户端共用同一个 socket。你没法"为每个客户端起一个 goroutine 循环读",只能在一个循环里收所有人的包,靠remoteAddr区分谁是谁。 - 每次收发都要带地址。
ReadFromUDP告诉你包是谁发的,WriteToUDP必须指明发给谁。没有连接,就没有"默认对端"这个概念。 buf[:n]必须拷贝一份再交给 goroutine。上面代码里的append([]byte(nil), buf[:n]...)不能省——buf会在下一轮循环被覆写,直接把它传给 goroutine 就是数据竞争。
客户端:DialUDP 的"伪连接"
raddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9000")
conn, _ := net.DialUDP("udp", nil, raddr)
defer conn.Close()
conn.Write([]byte("ping")) // 可以直接 Write,不用每次带地址
buf := make([]byte, 1500)
conn.SetReadDeadline(time.Now().Add(2 * time.Second)) // 必须设超时!
n, err := conn.Read(buf)
if err != nil {
// 超时了——UDP 不会告诉你"对方没收到",只能靠超时判断
log.Println("timeout, maybe retry")
}
这里有个容易误解的点:DialUDP 并没有真的建立连接(不会发任何握手包)。它只是在本地记录了默认对端地址,让你能用 Read/Write 而不必每次带地址,顺便让内核过滤掉其他地址发来的包。
它有一个实际的副作用值得知道:“connected” 的 UDP socket 能收到 ICMP 错误。如果对方端口没程序监听,对端会回一个 ICMP “端口不可达”,此时你的 Write/Read 可能返回 connection refused 错误。而未 connect 的 UDP socket 收不到这个信息,发出去石沉大海,连错误都没有。
必须自己做的三件事
用 UDP 就意味着TCP 帮你干的活现在归你了。至少这三件必须自己处理:
// ① 超时 —— UDP 永远不会主动告诉你"失败了"
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
// ② 重试 + 退避
for attempt := 0; attempt < 3; attempt++ {
conn.Write(req)
conn.SetReadDeadline(time.Now().Add(backoff(attempt)))
if n, err := conn.Read(buf); err == nil {
return buf[:n], nil
}
}
// ③ 请求-响应配对 —— 因为响应可能乱序到达,必须自己编号
type Packet struct {
RequestID uint32 // 自己加的序号,用来把响应对上请求
Payload []byte
}
第三点尤其容易被忽略:UDP 的响应可能乱序。你连发请求 A、B,可能先收到 B 的响应。没有请求 ID,你根本不知道手里这个响应是谁的。 DNS 协议头里那个 16 位的 Transaction ID,存在的理由就是这个。
缓冲区与丢包
conn.SetReadBuffer(4 << 20) // 接收缓冲区 4MB
这个设置在 UDP 上比 TCP 上更要命。原因是:UDP 没有流量控制。
后果是:如果你的程序读得不够快,内核接收缓冲区满了,新来的包会被直接丢弃,而且没有任何通知——不重传、不报错,你甚至不知道自己丢了包。
所以高吞吐的 UDP 服务有两条纪律:
- 调大接收缓冲区,吸收突发流量。
- 收包循环里绝不做耗时操作——收到就立刻扔进 channel 交给 worker 处理,主循环马上回去收下一个包。
想知道丢了多少,Linux 上可以查:
netstat -su | grep -i "packet receive errors\|receive buffer errors"
ss -ulnp # 看 UDP socket 的 Recv-Q,持续不为 0 说明应用读得慢
TCP vs UDP 决策表
把前面所有内容压成一张可以直接查的表:
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接(三次握手) | 无连接,发了就走 |
| 可靠性 | 保证不丢不乱不重 | 不保证任何一样 |
| 数据边界 | 字节流,要自己分帧 | 数据报,边界天然保留 |
| 顺序 | 保证 | 不保证 |
| 流量控制 | 有(rwnd) | 无 |
| 拥塞控制 | 有(cwnd) | 无(要自己做,否则会伤害网络) |
| 队头阻塞 | 有 | 无 |
| 头部开销 | 20+ 字节 | 8 字节 |
| 通信模式 | 只能一对一 | 支持广播 / 多播 |
| 典型应用 | HTTP、SSH、数据库、区块同步 | DNS、DHCP、NTP、音视频、游戏、QUIC、节点发现 |
怎么选,三个判断题就够:
- 数据丢了要不要补? 要 → TCP。(“补了也没用”/“过时就作废” → UDP)
- 是不是要传一大批有序数据? 是 → TCP。(短小的一问一答 → UDP)
- 能不能接受为了旧数据卡住新数据? 不能 → UDP。
还有一条必须记住的责任提醒:用 UDP 时,拥塞控制的责任落到了你头上。 之前讲TCP的时候讲过 1986 年的拥塞崩溃——TCP 之所以能撑起互联网,靠的是每条连接都自觉限速。如果你写一个 UDP 程序不管不顾地全速发包,你就是在占别人的便宜,并且在把网络推向崩溃。这在学术上有个名字叫 TCP 友好性(TCP-friendliness)。所以 QUIC、KCP 这些协议都实现了自己的拥塞控制,不是为了自己快,而是为了不破坏公共秩序。
方向关联
节点发现用 UDP,数据传输用 TCP。 ,这是 P2P 网络的通用模式。以太坊 Discovery v4/v5 跑在 UDP 上,RLPx 数据传输跑 TCP。理解了"探测阶段大量陌生对端、成功率低"这个特点,你就明白为什么不能用 TCP 硬来。
NAT 穿透基本只能用 UDP。 VPN与隧道讲的打洞(hole punching),在 UDP 上成功率显著高于 TCP——因为 UDP 无连接,双方"同时往外捅一下"就能在各自 NAT 上留下映射;TCP 的状态机严格得多,同时打开(simultaneous open)很多 NAT 设备不支持。所以 WebRTC、libp2p 的打洞主要走 UDP,TCP 打洞是兜底方案。 这对做去中心化网络是硬知识。
QUIC 与跨链服务的未来。 如果你的报价服务要连境外 RPC 节点,QUIC(HTTP/3)带来的两个好处很实在:0-RTT 重连(省掉握手,对高频短请求收益明显)和连接迁移(网络切换不断连)。目前主流 RPC 服务商已陆续支持 HTTP/3,值得关注。
别用 UDP 传资金相关的消息。 这条是反面提醒:跨链消息、交易广播这类"丢一条就是资金损失"的场景,可靠性是刚需,老老实实用 TCP,或者用 QUIC 这种自带可靠层的协议。UDP 的"快"是拿保障换的,而资金流转恰恰是最不能省保障的地方。
行情推送的取舍。 如果你要做实时行情分发,这是个真实的设计选择题:行情数据"最新的才有价值,旧的丢了无所谓"——非常符合 UDP 的场景特征,专业金融行情系统确实大量用多播 UDP。但要在应用层做好序号和快照重传机制,否则客户端会因为丢包而算错状态。