《Java 网络编程基础》2.2 UDP 数据报、MTU 与丢包

UDP 一次 send 就是一条消息,但消息可能丢、可能被截断。本节用 DatagramSocket/DatagramPacket 实测数据报边界、100 字节缓冲接收 2000 字节报文时的静默截断、70000 字节报文的 Message too long,以及 4096 字节接收缓冲下 5000 个包只收到 3 个的丢包结果,并给出 linkd 指标上报的 UDP 边界。

本节目标:掌握 UDP 的消息边界、尺寸上限与丢失语义,知道哪些场景该用、哪些不该用。
适用版本:JDK 25(Temurin 25.0.5+8)。

承接的问题

linkd 的 /healthz 走 TCP,每次请求都有连接或至少一次完整的请求-响应往返。监控代理想每秒上报一次 requests、active_connections、p99 三个数字,如果也走 TCP,就要维护连接、处理超时和重连;数字本身丢一两个并不致命。UDP 看起来正合适。

但把指标切到 UDP 后,面板上出现整段空白。有人判断「linkd 挂了」,实际进程正常,只是那几秒的 UDP 包在接收端缓冲区溢出时被内核丢掉了。UDP 的 send 不会报错,发送方根本不知道。

这一节把 UDP 的四个边界拆开:消息边界、尺寸边界、丢失与乱序、适用场景。所有结论都落到 DatagramSocket 和 DatagramPacket 的行为上。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

一个数据报就是一条消息

UDP 保留了消息边界:一次 send 对应一次 receive,不会像 TCP 那样把两次写合并,也不会把一次写拆成多次读。

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

public class UdpEchoDemo {
    public static void main(String[] args) throws Exception {
        InetAddress loop = InetAddress.getLoopbackAddress();
        try (DatagramSocket receiver = new DatagramSocket(0, loop)) {
            receiver.setSoTimeout(500);
            int port = receiver.getLocalPort();
            try (DatagramSocket sender = new DatagramSocket()) {
                sender.send(new DatagramPacket("hello".getBytes(), 5, loop, port));
                sender.send(new DatagramPacket("world".getBytes(), 5, loop, port));

                byte[] buf = new byte[64];
                for (int i = 0; i < 2; i++) {
                    DatagramPacket p = new DatagramPacket(buf, buf.length);
                    receiver.receive(p);
                    System.out.println("datagram " + i + ": length=" + p.getLength()
                            + " payload=[" + new String(p.getData(), 0, p.getLength()) + "]");
                }
            }
        }
    }
}

本机输出:

datagram 0: length=5 payload=[hello]
datagram 1: length=5 payload=[world]

两次 send 得到两次 receive,每次 getLength() 都是 5。receive 要么返回一个完整数据报,要么在 setSoTimeout 到期后抛 SocketTimeoutException,不存在「读到半个数据报」。这和 TCP 的 read 形成直接对比:TCP 的返回值是字节数,UDP 的 receive 返回的是一条消息。

三个容易忽略的细节:

  • DatagramPacket 的缓冲区在构造时固定。getData() 返回整个数组,长度可能大于 getLength();正确用法是按 getLength() 截取,而不是 new String(p.getData())。
  • DatagramSocket 是无连接的。send 只指定目标地址,不需要先 connect;调用 connect 只是给这个 socket 设置默认目标并过滤来源,不会建连。
  • send 返回只表示数据交给了内核。内核可能随后因为缓冲区满、路由失败或 MTU 问题丢弃它,发送方收不到通知。

单条 UDP 数据报的理论上限是 65507 字节(IPv4:65535 减去 20 字节 IP 头与 8 字节 UDP 头);IPv6 在无扩展头时是 65527 字节。但「能发」不等于「能完好到达」,下一节看 MTU。

MTU、IP 分片与 1472

以太网的 MTU 是 1500 字节。IPv4 头 20 字节、UDP 头 8 字节,所以不分片的最大 UDP 载荷是 1472 字节;IPv6 头 40 字节,对应 1452 字节。超过这个值,IP 层会分片,接收端重组;任何一片丢失,整个数据报就丢了。

先看本机两个接口的实际 MTU:

ifconfig lo0 | grep mtu
ifconfig en0 | grep mtu

本机输出:

lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500

loopback 的 MTU 是 16384,en0 是 1500。这意味着一个 2000 字节的 UDP 数据报在 loopback 上不会分片,在 en0 上却会。很多「UDP 大包没问题」的结论,都是只在 loopback 上试过。

下面程序发一个 2000 字节数据报,但用 100 字节缓冲接收;再尝试发 70000 字节:

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;

public class UdpSizeDemo {
    public static void main(String[] args) throws Exception {
        InetAddress loop = InetAddress.getLoopbackAddress();
        try (DatagramSocket receiver = new DatagramSocket(0, loop)) {
            receiver.setSoTimeout(500);
            int port = receiver.getLocalPort();
            try (DatagramSocket sender = new DatagramSocket()) {
                byte[] big = new byte[2000];
                sender.send(new DatagramPacket(big, big.length, loop, port));
                byte[] small = new byte[100];
                DatagramPacket p = new DatagramPacket(small, small.length);
                receiver.receive(p);
                System.out.println("recv buffer=" + small.length + ", getLength=" + p.getLength());
            }
        }

        try (DatagramSocket socket = new DatagramSocket(0, loop)) {
            byte[] huge = new byte[70_000];
            try {
                socket.send(new DatagramPacket(huge, huge.length, loop, 9));
            } catch (SocketException e) {
                System.out.println("70000 bytes: " + e.getMessage());
            }
        }
    }
}

本机输出:

recv buffer=100, getLength=100
70000 bytes: Message too long

第一行是 UDP 最危险的坑之一:2000 字节的数据报被塞进 100 字节缓冲,receive 不抛异常,只返回 100 字节,剩余 1900 字节被静默丢弃。getLength() 返回的是缓冲区大小,不是原始数据报长度,所以调用方无法从这次调用判断是否截断。对不可信来源,接收缓冲要按协议允许的最大报文分配,或者至少 65507 字节;如果协议自己限制在 1200 字节以内,就按 1200 分配并在应用层校验长度。

第二行是尺寸上限:70000 字节超过 UDP 长度字段能表达的范围,send 直接抛 SocketException: Message too long。这个错误是本地参数错误,不是网络错误,不会因为换一台机器就消失。

工程上建议把应用层单条数据报控制在 1200 字节以内。这个值低于 IPv6 的最小 MTU 1280(减去 40 字节 IPv6 头和 8 字节 UDP 头后是 1232),也低于常见隧道和 VPN 的 MTU,能避开大多数分片。需要传更大的数据,应该在应用层分片并自己处理丢失,而不是依赖 IP 分片。

丢包、乱序与没有重传

UDP 不重传、不排序、不做拥塞控制。发送方唯一能确定的是「内核接受了这个数据报」,不是「对端收到了」。

下面程序把接收缓冲设为 4096 字节,发送 5000 个 1000 字节的数据报,接收端先睡 800 毫秒再读:

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;

public class UdpLossDemo {
    public static void main(String[] args) throws Exception {
        InetAddress loop = InetAddress.getLoopbackAddress();
        int total = 5000;
        try (DatagramSocket receiver = new DatagramSocket(null)) {
            receiver.setReceiveBufferSize(4096);
            receiver.bind(new java.net.InetSocketAddress(loop, 0));
            receiver.setSoTimeout(500);
            int port = receiver.getLocalPort();
            System.out.println("actual receive buffer = " + receiver.getReceiveBufferSize());

            Thread sender = new Thread(() -> {
                try (DatagramSocket s = new DatagramSocket()) {
                    for (int i = 0; i < total; i++) {
                        ByteBuffer b = ByteBuffer.allocate(1000);
                        b.putInt(i);
                        b.put(new byte[996]);
                        s.send(new DatagramPacket(b.array(), 1000, loop, port));
                    }
                    System.out.println("sender finished " + total + " datagrams");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
            sender.start();
            Thread.sleep(800);

            int received = 0;
            int lastSeq = -1;
            int gaps = 0;
            int outOfOrder = 0;
            byte[] buf = new byte[1000];
            while (true) {
                DatagramPacket p = new DatagramPacket(buf, buf.length);
                try {
                    receiver.receive(p);
                } catch (SocketTimeoutException e) {
                    break;
                }
                int seq = ByteBuffer.wrap(buf, 0, 4).getInt();
                received++;
                if (seq <= lastSeq) {
                    outOfOrder++;
                } else if (lastSeq >= 0 && seq != lastSeq + 1) {
                    gaps++;
                }
                lastSeq = Math.max(lastSeq, seq);
            }
            sender.join();
            System.out.println("received=" + received + " lost=" + (total - received)
                    + " gapEvents=" + gaps + " outOfOrder=" + outOfOrder);
        }
    }
}

本机输出(单次运行,未做统计):

actual receive buffer = 4096
sender finished 5000 datagrams
received=3 lost=4997 gapEvents=0 outOfOrder=0

5000 个 send 全部正常返回,接收端只拿到 3 个,丢了 4997 个。原因不是网络,而是接收 socket 的缓冲只有 4096 字节,接收线程又睡了 800 毫秒;内核在缓冲满后直接丢弃新到的数据报,既不排队也不通知发送方。send 没有抛异常,因为对内核来说「把数据报放进发送路径」成功了。

gapEvents=0 和 outOfOrder=0 需要正确解读:循环只统计已收到的序号之间有没有跳变和逆序。第一次收到的序号可能已经是 4997,它前面的缺口没有前驱可比较,所以 gapEvents 仍是 0。loopback 本身保序,这个 0 不能推广成「UDP 保证顺序」。在真实网络里,多路径、队列调度和重传(如果是上层协议)都可能造成乱序。

UDP 也没有拥塞控制。应用发得比路径能承载的快,丢包会上升,而内核不会像 TCP 那样自动降速。要做限速、令牌桶、FEC 或选择性重传,都得在应用层写;写完这些,你已经在实现一个简化版的可靠传输协议。

UDP 适合什么、不适合什么

判断标准不是「UDP 更快」,而是「丢了这一条,业务能不能继续」。适合的场景通常满足:每条消息独立、可容忍少量丢失、时延比完整性重要。

场景为什么适合应用层要补什么
DNS 查询一问一答,包小,超时可重发超时、重试、TCP 回退
指标上报丢几个点不影响趋势序号、聚合、采样
服务发现/心跳周期性广播,下一次就能补上过期时间、去重
游戏状态同步只关心最新状态序号、丢弃过期包
音视频少量丢包可掩盖抖动缓冲、FEC
QUIC在用户态实现可靠传输拥塞控制、重传、加密

不适合的场景同样清楚:文件传输、支付指令、配置下发、任何「必须执行一次且不能重复」的操作。UDP 不保证到达、不保证只到一次、不保证顺序;要可靠,就得自己加 ACK、序号、重传、去重和拥塞控制。QUIC 就是这么做的,但它把复杂性放进了协议库,而不是消失。

在 linkd 里落地

linkd 的请求路径仍然是 TCP:LinkdMain 绑定 9090 的 ServerSocketChannel,EventLoop.register(SocketChannel, Handler) 只接受 SocketChannel,Connection.enqueue(ByteBuffer) 的契约也是有序字节流。UDP 不进入这条路径,它只做旁路指标。

linkd-app 里加一个尽力而为的上报器:

package com.plume.linkd.app;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicLong;

public final class StatsEmitter implements AutoCloseable {

    private final DatagramSocket socket;
    private final InetAddress target;
    private final int port;
    private final AtomicLong seq = new AtomicLong();

    public StatsEmitter(String host, int port) throws Exception {
        this.socket = new DatagramSocket();
        this.target = InetAddress.getByName(host);
        this.port = port;
    }

    public void emit(String name, long value) {
        String line = "linkd." + name + " " + value + " " + seq.incrementAndGet();
        byte[] data = line.getBytes(StandardCharsets.UTF_8);
        try {
            socket.send(new DatagramPacket(data, data.length, target, port));
        } catch (Exception e) {
            System.getLogger("linkd.stats")
                    .log(System.Logger.Level.DEBUG, "stats datagram dropped", e);
        }
    }

    @Override
    public void close() {
        socket.close();
    }
}

LinkdMain 启动时创建它,关闭时一起关:

try (var stats = new StatsEmitter("127.0.0.1", 9091)) {
    stats.emit("up", 1);
    loop.run();   // loop 是已创建的 EventLoop
}

这段落地代码有三个明确取舍:

  • 端口 9091 是本卷约定的本地指标端口,和 9090 的请求端口分开。指标只发往 loopback,不对外暴露。
  • emit 吞掉异常并记 DEBUG 日志。指标是尽力而为,不能因为监控端没开就让主流程失败;但也不能静默,至少要能在日志里看到。
  • 行里带自增 seq,接收端可以按序号发现缺口。发现缺口只说明「丢了」,不要自动重发;指标的价值在趋势,不在单点完整。

反过来说,任何需要确认的链路都不该走这个类。比如「配置变更已应用」这种事件,必须用 TCP 或带 ACK 的应用层协议;用 UDP 后收到 200 次重复应用,比丢一次更糟。

常见错误与边界

错误一:把 send 成功当送达。 本机 5000 次 send 全部返回,接收端只收到 3 个,发送方没有任何异常。UDP 没有 ACK,send 成功只代表内核接受了数据报。

错误二:接收缓冲小于数据报。 2000 字节数据报塞进 100 字节缓冲,receive 静默返回 100 字节,剩余部分丢弃。getLength() 无法区分「原始就是 100 字节」和「被截断到 100 字节」;要么按协议上限分配,要么在应用层带长度字段校验。

错误三:依赖 IP 分片传大消息。 70000 字节直接 Message too long;2000 字节在 loopback(MTU 16384)能过,在 en0(MTU 1500)会分片。任何一片丢失,整个数据报不可用,且部分防火墙会丢弃分片。把应用层报文控制在 1200 字节以内更稳。

错误四:用 loopback 的乱序结果推断公网。 本机 outOfOrder=0 只说明 loopback 保序。UDP 协议本身不提供顺序保证,接收端必须按序号处理乱序和重复。

边界:DatagramSocket 的接收是共享的。 多个线程同时对同一个 socket 调 receive,每个数据报只会被其中一个线程拿到,但哪个线程拿到不确定。需要并发处理时,用单个接收线程分发,或者用 DatagramChannel 配合 Selector(第 6 章)。

小结

  • UDP 保留消息边界:一次 send 对应一次 receive,receive 不会返回半个数据报。
  • DatagramPacket 缓冲区不足会静默截断,本机 2000 字节报文用 100 字节缓冲只拿到 100 字节,没有异常。
  • 单条 UDP 载荷上限是 65507 字节(IPv4),超过会 Message too long;工程上建议控制在 1200 字节以内,避开 IP 分片。
  • UDP 不重传、不排序、无拥塞控制;本机 5000 个包在 4096 字节接收缓冲下只收到 3 个,发送方毫无感知。
  • linkd 的请求路径保持 TCP,UDP 只用于 StatsEmitter 这类可丢的旁路指标,并带序号便于发现缺口。

UDP 让地址和端口直接暴露在应用代码里:绑定哪个地址、用哪个端口、如何判断端口被占用,都是下一节的内容。

阅读导航:上一节:2.1 TCP 连接、字节流与可靠性边界 · 下一节:2.3 InetAddress、端口与网络接口 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发