1. 项目缘起与整体设计思路
UDP 这套东西,刚入行的朋友一听“可靠传输”四个字,第一反应往往是“这不是 TCP 干的活吗,你折腾 UDP 图啥”。我当初接这个需求时也是这个反应。但真到了某些特定场景里,TCP 反而会变成拖后腿的那一方。比如内网批量分发大文件、跨机房做日志归档、点对点做数据同步,这些场景有几个共同特点:链路质量相对可控、对吞吐量敏感、不希望被 TCP 的拥塞控制“一刀切”地压速。TCP 的拥塞窗口在丢包时会直接腰斩,恢复又慢,一条本来能跑满千兆的链路,可能因为偶尔的丢包掉到几十兆。而 UDP 没有这些包袱,怎么发、发多快、丢了怎么补,全由应用层自己说了算。
这个项目的核心目标很明确:用 Java 的 DatagramSocket 实现一套应用层的可靠传输协议,把一个大文件(几百 MB 到几个 GB)从一端完整、正确地送到另一端,同时尽量榨干链路带宽。说白了,就是把 TCP 里那些保证可靠性的机制——确认、重传、排序、去重、流量控制——自己在应用层重新实现一遍,但实现方式针对“大文件单向传输”这个具体场景做裁剪和优化,去掉通用协议里那些用不上的复杂度。
为什么选 Java?因为 Java 的 NIO 提供了DatagramChannel,配合ByteBuffer和Selector,能比较优雅地做非阻塞收发;而且 Java 跨平台,写出来的东西在 Linux、Windows、macOS 上都能跑,团队里谁都能接手。当然,纯DatagramSocket的阻塞模型也能做,只是并发一上来线程模型会比较难看,这个后面细说。
整套设计我拆成四个层次来想:分片层负责把文件切成合适大小的数据块;协议层定义包头格式,塞进序号、确认号、标志位这些字段;传输控制层管发送窗口、超时重传、拥塞退避;应用层负责文件读写、进度统计、断点续传的元数据管理。这四层各管一摊,耦合度低,调试的时候能一层一层单独验证,不至于一出问题就抓瞎。
适合谁来参考这份东西?我觉得有三类人比较对口:一是做后端或中间件、想深入理解可靠传输原理的开发者;二是做内网工具、需要自己掌控传输策略的工程师;三是学生或转行者,想找一个比“手写 TCP 三次握手”更贴近真实工程的练手项目。前提是你得会基本的 Java 网络编程,知道DatagramPacket怎么用,不然看下去会有点吃力。
2. 核心机制拆解与关键参数选择
2.1 包头设计:每个字节都要抠
UDP 本身只给你一个数据报边界,剩下的全靠自己在 payload 里塞元数据。包头设计是整个协议的地基,设计得不好,后面要么浪费带宽,要么扩展困难。我最终定下来的包头是16 字节固定头,结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 魔数 | 2 字节 | 固定值,用于快速识别非法包 |
| 版本号 | 1 字节 | 协议版本,方便后续升级 |
| 标志位 | 1 字节 | SYN / ACK / FIN / DATA / HEARTBEAT |
| 序号 | 4 字节 | 数据包序号,从 0 递增 |
| 确认号 | 4 字节 | 累计确认,表示“这个序号之前我都收到了” |
| 窗口大小 | 2 字节 | 接收方剩余缓冲区能容纳的包数 |
| 数据长度 | 2 字节 | payload 实际字节数 |
为什么序号用 4 字节?因为大文件分片后包数量可能上百万,2 字节的 65535 根本不够用,会回绕,回绕处理起来极其恶心。4 字节能表示 42 亿个包,按每包 1400 字节算,能覆盖约 5.6 TB 的文件,足够用了。确认号用累计确认而不是选择确认,是因为大文件单向传输场景下,接收端基本是顺序到达,累计确认实现简单、包头小,偶尔乱序靠重传兜底就行,没必要上 SACK 那套复杂逻辑。
标志位用一个字节的位图,0x01是 SYN,0x02是 ACK,0x04是 FIN,0x08是 DATA,0x10是心跳。这样判断包类型只需要做位与运算,比字符串比较快得多。
2.2 分片大小:1400 字节的由来
分片大小这个参数,很多人随手写个 1024 或者 8192,其实是有讲究的。以太网的 MTU 是 1500 字节,减去 IP 头 20 字节、UDP 头 8 字节,留给应用层的最大空间是1472 字节。再减去我们自己的 16 字节包头,payload 最大就是 1456 字节。我取1400 字节作为 payload 大小,留了几十字节的余量,防止某些链路有额外的封装开销导致分片。
注意:一旦 IP 层发生分片,只要其中一片丢了,整个数据报都得重传,效率会断崖式下跌。所以宁可 payload 小一点,也不要触发 IP 分片。这是实测踩过的坑,早期用 1472 跑,丢包率一高吞吐直接崩。
2.3 发送窗口与接收缓冲区
发送窗口决定了“在没收到确认之前,最多能发出去多少个包”。窗口太小,链路利用率上不去,因为发送端老是停下来等确认;窗口太大,一旦丢包,重传的量会很大,而且接收端缓冲区容易溢出。
我用的计算方式是带宽时延积(BDP)。假设链路带宽 100 Mbps,往返时延 RTT 是 20 ms,那么 BDP = 100 Mbps × 0.02 s = 2 Mb = 250 KB。按每包 1400 字节算,大约需要 178 个包才能填满管道。所以发送窗口初始值设为256 个包,留一点余量。接收端的缓冲区则设为窗口的 2 倍,即 512 个包的槽位,用环形数组实现,避免频繁扩容。
窗口不是固定不变的,后面会讲怎么根据 ACK 的到达情况动态调整。
2.4 超时重传:RTO 怎么算才不抽风
超时重传时间(RTO)如果写死一个固定值,比如 200 ms,那在不同网络环境下表现会天差地别。局域网里 200 ms 太长,丢包了要等半天才重传;跨地域链路 200 ms 又太短,正常延迟就被误判成丢包,导致无谓重传,越重传越堵。
我用的是自适应 RTO,参考了经典算法但做了简化。每次收到 ACK,测量一个 RTT 样本,然后:
- 平滑 RTT:
SRTT = (1 - α) × SRTT + α × RTT,α 取 0.125 - 偏差:
RTTVAR = (1 - β) × RTTVAR + β × |SRTT - RTT|,β 取 0.25 - 最终:
RTO = SRTT + 4 × RTTVAR
初始 SRTT 设为 100 ms,RTTVAR 设为 50 ms,所以初始 RTO 是 300 ms。这个值在大多数网络下都比较稳。另外加了个下限,RTO 最小不低于 50 ms,防止在超低延迟局域网里频繁误重传。
2.5 拥塞控制:简单但够用的 AIMD
UDP 没有拥塞控制,如果不加限制地猛发,网络一堵,丢包率飙升,重传风暴会把链路彻底打死。我在发送端加了一个简化版的AIMD(加性增、乘性减)拥塞窗口:
- 每收到一个有效 ACK,拥塞窗口
cwnd += 1(慢启动阶段翻倍增长) - 检测到超时重传,
cwnd = cwnd / 2,同时ssthresh = cwnd - 实际发送窗口 =
min(发送窗口, cwnd)
这套逻辑比 TCP 的完整实现简单得多,但对付大文件单向传输足够了。实测在模拟丢包 1% 的环境下,吞吐能稳定在链路带宽的 70% 以上,比不加拥塞控制时的 30% 好太多。
3. 完整实操流程与核心代码实现
3.1 发送端主循环:从文件到数据报
发送端的整体流程是:打开文件 → 建立连接(握手)→ 循环读取分片并发送 → 处理 ACK 和重传 → 发送 FIN 结束。核心循环我用一个while驱动,配合Selector做非阻塞收发。
先看文件分片和发送的核心逻辑:
public class ReliableUdpSender { private static final int PAYLOAD_SIZE = 1400; private static final int HEADER_SIZE = 16; private static final int WINDOW_SIZE = 256; private DatagramChannel channel; private InetSocketAddress remote; private long base = 0; // 窗口起始序号 private long nextSeq = 0; // 下一个待发序号 private long fileSize; private RandomAccessFile file; private Map<Long, byte[]> sentPackets = new ConcurrentHashMap<>(); private Map<Long, Long> sendTimeMap = new ConcurrentHashMap<>(); public void sendFile(Path path) throws IOException { file = new RandomAccessFile(path.toFile(), "r"); fileSize = file.length(); long totalPackets = (fileSize + PAYLOAD_SIZE - 1) / PAYLOAD_SIZE; // 握手阶段:发送 SYN,等待 SYN-ACK handshake(); while (base < totalPackets) { // 窗口未满,继续发送 while (nextSeq < base + WINDOW_SIZE && nextSeq < totalPackets) { byte[] payload = readChunk(nextSeq); byte[] packet = buildPacket(nextSeq, payload); channel.send(ByteBuffer.wrap(packet), remote); sentPackets.put(nextSeq, packet); sendTimeMap.put(nextSeq, System.currentTimeMillis()); nextSeq++; } // 等待 ACK,超时则重传 waitForAck(); } sendFin(); } private byte[] readChunk(long seq) throws IOException { long offset = seq * PAYLOAD_SIZE; int len = (int) Math.min(PAYLOAD_SIZE, fileSize - offset); byte[] buf = new byte[len]; file.seek(offset); file.readFully(buf); return buf; } }readChunk用RandomAccessFile而不是FileInputStream,是为了支持断点续传——如果传输中断,下次可以从任意序号对应的偏移量重新读,不用从头来。这个设计在传几个 GB 的大文件时特别有用,中断一次重传全部的话,心态会崩。
3.2 包头组装与解析
包头的组装我用ByteBuffer来做,比手动移位清晰得多:
private byte[] buildPacket(long seq, byte[] payload) { ByteBuffer buf = ByteBuffer.allocate(HEADER_SIZE + payload.length); buf.putShort((short) 0x55AA); // 魔数 buf.put((byte) 1); // 版本 buf.put((byte) 0x08); // DATA 标志 buf.putInt((int) seq); // 序号 buf.putInt(0); // 确认号,数据包填 0 buf.putShort((short) WINDOW_SIZE); // 窗口 buf.putShort((short) payload.length); // 数据长度 buf.put(payload); return buf.array(); }解析的时候反过来,先读魔数校验,不匹配直接丢弃,防止把别的程序的 UDP 包误收进来。这个魔数校验看着不起眼,但在共享网络环境里能省掉很多莫名其妙的 bug。
3.3 接收端:乱序处理与累计确认
接收端的核心是一个环形缓冲区,按序号取模定位槽位。收到包后先检查序号是否在窗口内,是就放入对应槽位,然后尝试推进累计确认号:
public class ReliableUdpReceiver { private static final int BUFFER_SIZE = 512; private byte[][] buffer = new byte[BUFFER_SIZE][]; private long expectedSeq = 0; // 期望收到的下一个序号 private long highestAck = -1; public void onPacket(byte[] data, InetSocketAddress from) { ByteBuffer buf = ByteBuffer.wrap(data); short magic = buf.getShort(); if (magic != (short) 0x55AA) return; // 非法包,丢弃 buf.get(); // version byte flags = buf.get(); long seq = buf.getInt() & 0xFFFFFFFFL; buf.getInt(); // ack buf.getShort(); // window short len = buf.getShort(); byte[] payload = new byte[len]; buf.get(payload); if ((flags & 0x08) != 0) { // DATA 包 int slot = (int) (seq % BUFFER_SIZE); if (seq >= expectedSeq && buffer[slot] == null) { buffer[slot] = payload; } // 尝试推进 expectedSeq while (buffer[(int) (expectedSeq % BUFFER_SIZE)] != null) { writeToFile(expectedSeq, buffer[(int) (expectedSeq % BUFFER_SIZE)]); buffer[(int) (expectedSeq % BUFFER_SIZE)] = null; expectedSeq++; } highestAck = expectedSeq - 1; sendAck(highestAck); } } }这里有个细节:收到重复包时不要重复写文件。判断依据就是buffer[slot] == null,如果槽位已经有数据了,说明这个包之前收过,直接忽略,但仍然要回一个 ACK,因为对方可能没收到之前的确认。
3.4 断点续传的元数据管理
大文件传输最怕传到 90% 断了,一切归零。我在发送端和接收端各维护一个.meta文件,记录已确认的字节偏移量。发送端每次收到 ACK 就更新元数据,接收端每次推进expectedSeq也更新。重启后双方先交换元数据,从较小的那个偏移量继续传。
private void saveMeta(long confirmedBytes) { try (FileWriter fw = new FileWriter(metaPath.toFile())) { fw.write(String.valueOf(confirmedBytes)); } catch (IOException e) { // 元数据写失败不影响主流程,下次全量重传即可 } }提示:元数据文件建议每收到 100 个 ACK 落一次盘,不要每个包都写,否则磁盘 IO 会成为瓶颈。我早期每个 ACK 都写,结果传输速度被磁盘拖慢了一半。
3.5 实测数据与调优记录
在局域网两台机器之间传一个 2 GB 的文件,千兆网卡直连,实测数据如下:
| 配置 | 平均吞吐 | 耗时 | 重传率 |
|---|---|---|---|
| 窗口 64,固定 RTO 200ms | 320 Mbps | 52s | 8.2% |
| 窗口 256,自适应 RTO | 780 Mbps | 21s | 1.1% |
| 窗口 512,自适应 RTO + AIMD | 860 Mbps | 19s | 0.6% |
窗口从 64 提到 256 是提升最大的一步,因为 64 个包填不满千兆链路的管道。再往上提到 512 收益就递减了,而且接收端缓冲区压力变大,内存占用从 30 MB 涨到 60 MB。最终我选了 256 作为默认值,兼顾吞吐和内存。
4. 常见问题排查与避坑经验
4.1 问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方法 |
|---|---|---|---|
| 传输卡在某个序号不动 | ACK 包丢失或对端没收到 | 抓包看 ACK 是否发出 | 增加 ACK 重传,接收端定期重发最近 ACK |
| 吞吐突然掉到很低 | 触发拥塞控制乘性减 | 看 cwnd 变化日志 | 检查是否误判丢包,调大 RTO 下限 |
| 接收端文件损坏 | 乱序写入或重复写入 | 校验文件 MD5 | 检查槽位判空逻辑,加写入前序号校验 |
| 内存持续增长 | sentPackets 未清理 | 看 Map 大小 | 收到 ACK 后及时 remove 已确认的包 |
| 跨平台传输失败 | 字节序问题 | 检查 ByteBuffer 默认序 | 统一用ByteOrder.BIG_ENDIAN |
4.2 踩过的坑:ACK 风暴
早期实现里,接收端每收到一个数据包就回一个 ACK。结果在高速传输时,ACK 包的数量和数据包一样多,上行带宽被 ACK 占了一大半,而且发送端处理 ACK 的 CPU 开销也很高。后来改成延迟确认 + 累计确认:接收端每收到 8 个包,或者距离上次发 ACK 超过 20 ms,才回一个 ACK,ACK 里带当前最高的累计确认号。这一改,ACK 数量降到原来的八分之一,吞吐直接涨了 15%。
4.3 踩过的坑:缓冲区溢出导致丢包
接收端环形缓冲区如果满了,新来的包只能丢弃。但丢弃之后发送端并不知道,还在按窗口猛发,结果就是大量重传。我的解决办法是在 ACK 里带上接收窗口的剩余空间,发送端根据这个值动态收缩发送窗口。当接收窗口小于某个阈值(比如 32 个包)时,发送端主动降速,等接收端消费完再提速。这个机制加上之后,接收端的丢包率从 3% 降到了 0.2%。
4.4 踩过的坑:文件句柄泄漏
RandomAccessFile和DatagramChannel都是需要显式关闭的资源。我一开始在finally块里只关了 channel,忘了关 file,结果传了几十个文件之后程序报“too many open files”。后来统一用 try-with-resources 管理,或者在一个shutdown()方法里按顺序关闭所有资源,问题才解决。
注意:
DatagramChannel关闭后,如果还有线程在receive()上阻塞,会抛AsynchronousCloseException,这个异常要单独捕获并静默处理,不要当成错误往上抛。
4.5 独家心得:先跑通再优化
这个项目我最大的体会是,不要一上来就追求完美协议。我第一版只做了最简单的“发一个等一个 ACK”,吞吐只有几兆,但它能正确传完文件。有了这个正确性基线,后面加窗口、加拥塞控制、加断点续传,每加一个功能都能对比测试,确认没有引入回归。如果一开始就堆所有机制,出了问题根本不知道是哪一层导致的。
另外,日志要分级。传输过程中每个包的收发都打日志的话,磁盘 IO 会把性能拖垮。我的做法是默认只打 WARN 以上,需要调试时通过启动参数打开 DEBUG,并且 DEBUG 日志写到独立文件,避免污染主日志。
4.6 后续可以扩展的方向
这套东西目前只支持单向传输,如果要做双向,需要在包头里同时维护两个方向的序号和确认号,复杂度会上升不少。另一个方向是加加密,在 payload 层做 AES 加密,包头加一个校验字段,防止数据被篡改。还有就是多线程发送,把文件分成几段,每个线程负责一段,共享一个拥塞窗口,这个在超高带宽链路上能进一步榨性能,但线程间的窗口同步要做好,不然会互相踩踏。
我个人在实际操作中的体会是,UDP 可靠传输这东西,原理不难,难的是各种边界情况的处理——丢包、乱序、重复、超时、缓冲区满、对端崩溃。每一个都要有对应的策略,而且策略之间还会互相影响。把这些问题一个一个踩过来,对网络协议的理解会比看十本书都深。