
简介这是一份面向Java网络编程学习者与分布式系统入门者的UDP可靠通信系统完整源码围绕如何在无连接、不可靠的UDP协议之上实现序列号确认、超时重传、CRC校验与流量控制等可靠性机制展开客户端与服务器端代码分离便于对照理解请求发起、循环接收、确认响应与业务处理的完整链路。压缩包共132个文件以43个java源文件与63个class编译文件为主体另含9个xml配置、3个jar依赖及少量图片与工程配置文件整体约1.13MB结构清晰、便于导入IDE直接运行调试。目前已有187人学习下载。通过研读源码读者可掌握DatagramSocket与DatagramPacket的实际用法、数据包序列化与反序列化、确认与重传策略的落地方式并获得网络异常处理与排错思路对网络编程课程设计、分布式通信实验及面试准备均有参考价值。1. 从「UDP 不可靠」到「业务可靠」Java 可靠通讯系统到底在解决什么很多人第一次听到「基于 UDP 的可靠通讯系统」第一反应是矛盾UDP 协议本身不保证送达、不保证顺序、不保证不重复凭什么在上面盖一栋「可靠」的楼这个问题在 Java 面试题里也常被追问——「udp 和 tcp 协议的区别」背得滚瓜烂熟真让你动手写一个跑得起来的可靠层多数人卡在重传队列和滑动窗口的边界上。标题里的这套程序源码本质上就是干这件事在 Java 里用 DatagramSocket 收发数据报自己实现确认、重传、排序、去重把「不可靠的管道」包装成「业务层可信的流」。它适合谁做物联网设备上报、局域网内低延迟指令下发、游戏帧同步、内网音视频信令这类场景的 Java 工程师。这些场景有个共同点TCP 的队头阻塞和重连开销让人难受但又不能真的裸用 UDP 丢包丢到业务崩。你要的不是「UDP 比 TCP 快」这种口号而是一套能看懂、能改参数、能定位丢包原因的可靠层实现。下面按「先立住原理、再动手复现、最后踩坑收尾」的节奏拆开讲。2. 可靠层怎么搭从数据报结构到重传队列的完整设计2.1 为什么不用 TCP三个真实场景的选型账先把选型理由说清楚否则后面所有设计都是空中楼阁。TCP 在内网低丢包环境下几乎无懈可击但有三类场景它会让你难受。第一类是实时性优先的指令通道TCP 的重传会带来秒级延迟抖动而业务只关心「最新状态」旧包丢了就丢了重传反而有害。第二类是多播/广播需求TCP 是点对点的UDP 天然支持一对多做设备发现、局域网广播时 TCP 根本没法用。第三类是对连接状态敏感的长连接TCP 断线重连要重新握手而 UDP 无连接配合应用层心跳可以做到秒级感知、快速恢复。但选 UDP 不等于放弃可靠性而是把「可靠性」从传输层上移到应用层由业务自己定义什么叫可靠。比如指令通道可以定义「允许丢旧包、不允许乱序执行」文件传输可以定义「必须完整、允许慢」。这种「按业务定制可靠语义」的能力才是自建可靠层的真正价值。代价是你得自己处理序号、确认、重传、去重、拥塞控制这一整套东西工作量不小所以只建议在 TCP 确实不合适的场景下动手。2.2 数据报结构设计一个包头要装哪些字段可靠层的第一步是定义自己的包格式。UDP 的 DatagramPacket 只负责搬运字节包头得你自己设计。一个够用的包头通常包含这些字段魔数校验是不是本协议的包、版本号方便后续升级、包类型数据/确认/心跳/断开、序列号seq用于排序和去重、确认号ack用于确认收到、时间戳用于 RTT 计算和超时判断、窗口大小用于流控、载荷长度、校验和。下面是一个可直接抄的包头定义用 ByteBuffer 手动序列化避免 Java 序列化的体积和兼容性问题public class PacketHeader { public static final int MAGIC 0xCAFEBABE; // 协议魔数收到包先校验 public static final byte TYPE_DATA 1; public static final byte TYPE_ACK 2; public static final byte TYPE_HEARTBEAT 3; public int magic; // 4 字节固定值 public byte version; // 1 字节协议版本 public byte type; // 1 字节包类型 public int seq; // 4 字节发送序列号 public int ack; // 4 字节确认号 public long timestamp; // 8 字节发送时间戳(ms) public short window; // 2 字节接收窗口剩余 public short payloadLen; // 2 字节载荷长度 public int checksum; // 4 字节载荷校验和 public static final int HEADER_SIZE 30; // 固定头长度收发都按这个偏移解析 public byte[] encode() { ByteBuffer buf ByteBuffer.allocate(HEADER_SIZE); buf.putInt(magic); buf.put(version); buf.put(type); buf.putInt(seq); buf.putInt(ack); buf.putLong(timestamp); buf.putShort(window); buf.putShort(payloadLen); buf.putInt(checksum); return buf.array(); } public static PacketHeader decode(byte[] data, int offset) { ByteBuffer buf ByteBuffer.wrap(data, offset, HEADER_SIZE); PacketHeader h new PacketHeader(); h.magic buf.getInt(); h.version buf.get(); h.type buf.get(); h.seq buf.getInt(); h.ack buf.getInt(); h.timestamp buf.getLong(); h.window buf.getShort(); h.payloadLen buf.getShort(); h.checksum buf.getInt(); return h; } }逻辑说明encode 和 decode 必须严格对称字段顺序、字节数一个都不能错否则收到的包全是乱码。HEADER_SIZE 是 30 字节收发两端都按这个偏移切载荷改字段时两边必须同步改。参数说明seq 用 int 而不是 short是因为高吞吐场景下 short 的 65535 很快回绕回绕处理是新手最容易翻车的地方timestamp 用 long 存毫秒用于计算 RTT 和判断超时checksum 只校验载荷不校验包头因为包头本身有魔数和长度做基本校验全校验会拖慢热路径。2.3 发送端滑动窗口 超时重传的最小实现可靠层的核心在发送端。最朴素的实现是「发一个等一个 ACK」但这样吞吐极低RTT 一高就废。工程上常用滑动窗口允许连续发多个未确认的包收到 ACK 后窗口右移。窗口大小决定了在途未确认包的上限太小浪费带宽太大容易压垮接收端。public class ReliableSender { private final DatagramSocket socket; private final InetSocketAddress remote; private final int windowSize; // 窗口大小在途未确认包上限 private final long retransmitTimeout; // 重传超时(ms) private int nextSeq 0; // 下一个待分配的序列号 private int base 0; // 窗口左边界最早未确认的 seq private final MapInteger, SentPacket inFlight new ConcurrentHashMap(); public ReliableSender(DatagramSocket socket, InetSocketAddress remote, int windowSize, long retransmitTimeout) { this.socket socket; this.remote remote; this.windowSize windowSize; this.retransmitTimeout retransmitTimeout; } public void send(byte[] payload) throws IOException { // 窗口满了就等简单阻塞式流控 while (nextSeq - base windowSize) { checkTimeout(); sleepQuietly(1); } int seq nextSeq; PacketHeader h buildHeader(seq, payload); byte[] packet concat(h.encode(), payload); socket.send(new DatagramPacket(packet, packet.length, remote)); inFlight.put(seq, new SentPacket(packet, System.currentTimeMillis())); } public void onAck(int ackSeq) { // 累积确认ackSeq 及之前的都认为收到 for (int s base; s ackSeq; s) { inFlight.remove(s); } base Math.max(base, ackSeq 1); } private void checkTimeout() throws IOException { long now System.currentTimeMillis(); for (Map.EntryInteger, SentPacket e : inFlight.entrySet()) { SentPacket sp e.getValue(); if (now - sp.sendTime retransmitTimeout) { socket.send(new DatagramPacket(sp.raw, sp.raw.length, remote)); sp.sendTime now; // 重置计时避免连环重传 } } } }逻辑说明send 先做窗口检查满了就自旋等待并顺便检查超时这是最简实现生产环境应该用条件变量或事件循环替代自旋。onAck 用累积确认收到 ackSeq 就把 base 到 ackSeq 全部清掉实现简单但要求接收端按序确认。checkTimeout 遍历在途包超时就重发并重置计时。参数说明windowSize 建议从 32 起步内网可以调到 128retransmitTimeout 不能拍脑袋定应该基于 RTT 动态算初始值可以设 200ms后面用 RTT 的 1.5 到 2 倍。这里有个血泪经验重传后一定要重置 sendTime否则下一轮检查会立刻再重传形成重传风暴把网络打爆。2.4 接收端乱序缓存与去重怎么落地接收端要解决两个问题乱序到达的包怎么按序交给业务重复到达的包怎么丢弃。做法是维护一个期望序列号 expectSeq收到 seq 等于 expectSeq 就交付并推进收到大于 expectSeq 的先缓存进乱序队列收到小于 expectSeq 的直接丢弃说明是重复包或过期包。public class ReliableReceiver { private int expectSeq 0; // 期望的下一个序列号 private final MapInteger, byte[] outOfOrder new TreeMap(); // 乱序缓存 private final int maxBuffer; // 乱序缓存上限防内存打爆 public ReliableReceiver(int maxBuffer) { this.maxBuffer maxBuffer; } public Listbyte[] onPacket(PacketHeader h, byte[] payload) { Listbyte[] delivered new ArrayList(); if (h.seq expectSeq) { return delivered; // 重复或过期直接丢 } if (h.seq expectSeq) { delivered.add(payload); expectSeq; // 检查缓存里有没有能接上的 while (outOfOrder.containsKey(expectSeq)) { delivered.add(outOfOrder.remove(expectSeq)); expectSeq; } } else { if (outOfOrder.size() maxBuffer) { outOfOrder.put(h.seq, payload); } // 缓存满了就丢靠发送端超时重传兜底 } return delivered; } }逻辑说明onPacket 返回本次可以交付给业务的包列表可能一次交付多个缓存接上时。去重靠 seq 小于 expectSeq 判断排序靠 TreeMap 自动按 seq 升序。参数说明maxBuffer 是内存保护阀值建议设为窗口大小的 2 到 4 倍太小会频繁丢缓存导致重传太大会被恶意或异常流量打爆内存。注意这里没有处理序列号回绕int 回绕周期很长一般业务跑不到但如果你的 seq 用 short 就必须处理回绕比较时不能用简单的大小于。3. 动手跑通Java 可靠通讯系统的最小可运行骨架3.1 环境准备与项目结构这套东西不依赖任何第三方框架JDK 8 以上就能跑这也是它适合拿来学习和改造的原因。项目结构建议按职责分层别全塞一个类里否则调 bug 时你会后悔reliable-udp/ ├── src/main/java/com/example/reliable/ │ ├── packet/PacketHeader.java // 包头编解码 │ ├── packet/PacketType.java // 包类型常量 │ ├── core/ReliableSender.java // 发送端可靠逻辑 │ ├── core/ReliableReceiver.java // 接收端可靠逻辑 │ ├── core/ReliableSession.java // 会话管理收发合一 │ ├── net/UdpEndpoint.java // DatagramSocket 封装 │ └── demo/EchoServer.java // 演示服务端 │ └── demo/EchoClient.java // 演示客户端 └── pom.xml分层的好处是包头改动只动 packet 包重传策略改动只动 core 包网络收发改动只动 net 包。很多新手把所有逻辑写在一个 500 行的类里最后连自己都不敢改。UdpEndpoint 这层建议把 socket 的收发单独抽出来用两个线程分别跑收和发收线程只负责解析包和分发发线程只负责从发送队列取包发出职责清晰出问题好定位。3.2 服务端与客户端的最小闭环先跑通一个 echo 闭环验证可靠层能正确收发和确认再往上加业务。服务端绑定端口循环收包解析包头交给 ReliableReceiver 处理收到数据后回 ACK。public class EchoServer { public static void main(String[] args) throws Exception { DatagramSocket socket new DatagramSocket(9000); ReliableReceiver receiver new ReliableReceiver(256); byte[] buf new byte[1500]; // 避免超过 MTU 导致 IP 分片 System.out.println(server listening on 9000); while (true) { DatagramPacket dp new DatagramPacket(buf, buf.length); socket.receive(dp); PacketHeader h PacketHeader.decode(dp.getData(), 0); if (h.magic ! PacketHeader.MAGIC) continue; // 非法包直接丢 byte[] payload Arrays.copyOfRange(dp.getData(), PacketHeader.HEADER_SIZE, PacketHeader.HEADER_SIZE h.payloadLen); Listbyte[] delivered receiver.onPacket(h, payload); for (byte[] data : delivered) { System.out.println(recv: new String(data)); } // 回 ACK确认号用当前期望序列号减一 PacketHeader ack new PacketHeader(); ack.magic PacketHeader.MAGIC; ack.version 1; ack.type PacketHeader.TYPE_ACK; ack.ack receiver.getExpectSeq() - 1; byte[] ackBytes ack.encode(); socket.send(new DatagramPacket(ackBytes, ackBytes.length, dp.getAddress(), dp.getPort())); } } }逻辑说明buf 用 1500 字节是因为以太网 MTU 通常 1500超过会触发 IP 分片分片丢失会放大丢包率这是 UDP 编程的常识坑。收到包先校验魔数非法包直接丢避免解析异常。ACK 的确认号用 expectSeq 减一表示「这个号之前的我都收到了」。参数说明端口 9000 可改但客户端要同步缓冲区大小别超过 MTU 减去 IP 头和 UDP 头的 28 字节实际载荷建议控制在 1400 以内。客户端逻辑对称构造包、发送、收 ACK、调 onAck这里不重复贴照着服务端反向写即可。3.3 关键参数怎么调窗口、超时、缓冲区参数调不好可靠层要么慢要么崩。下面这张表是我在几个内网项目里总结的起步值具体要按你的 RTT 和丢包率微调参数建议起步值调整依据调错的后果窗口大小 windowSize32RTT 高、带宽大就调大太小吞吐低太大压垮接收端重传超时 RTO200ms基于实测 RTT 的 1.5~2 倍太小重传风暴太大恢复慢接收缓冲区 maxBuffer窗口的 2~4 倍乱序严重就调大太小频繁丢缓存太大内存风险单包载荷1400 字节MTU 1500 减去头开销超 MTU 触发分片丢包率飙升心跳间隔3s按业务对断线感知的要求太长断线发现慢太短浪费带宽RTO 的动态计算是重点每次收到 ACK 时用 timestamp 算一个 RTT 样本用加权平均平滑RTO 取平滑 RTT 加几倍偏差。Jacobson 算法是经典做法公式是 SRTT (1-α)SRTT α·RTTRTTVAR (1-β)RTTVAR β·|SRTT-RTT|RTO SRTT 4·RTTVARα 取 0.125β 取 0.25。这套算法在 TCP 里验证了几十年直接搬过来用就行别自己发明。4. 避坑与排查可靠层上线后最容易翻车的五个点4.1 现象吞吐上不去CPU 却不高原因通常是窗口太小或者发送端在自旋等待。自旋等待会空转 CPU但如果窗口小实际瓶颈在窗口而不是 CPU表现就是 CPU 不高但吞吐也上不去。解决先把 windowSize 调大试试如果吞吐跟着涨说明是窗口限制同时把自旋等待改成条件变量或阻塞队列避免空转。另一个隐藏原因是接收端处理慢ACK 回得晚发送端窗口推不动这时候要查接收端的业务处理是不是同步阻塞了收包线程。4.2 现象重传率异常高网络看着没丢包先查 RTO 是不是设太小。内网 RTT 可能只有几毫秒但你 RTO 设了 50ms正常抖动就触发重传。用抓包工具看实际 RTT 分布把 RTO 设到 P99 RTT 的 1.5 倍以上。再查重传后有没有重置计时没重置会导致同一批包被反复重传。还有一个玄学原因发送端和接收端时钟不同步用本地时间戳算 RTT 会算出负值或异常值RTT 计算必须用同一端的时钟发送端发时记时间收到对应 ACK 时用本地时间减不要用包头里的 timestamp 直接减。4.3 现象接收端内存持续上涨八成是乱序缓存没设上限或者上限设了但没生效。检查 maxBuffer 的判断逻辑确认缓存满了之后是真的丢弃而不是继续 put。另一个原因是去重逻辑失效重复包被当成新包缓存导致缓存里堆满重复数据。排查方法打印 expectSeq 和缓存 size 的比值正常情况缓存 size 应该远小于 expectSeq如果接近甚至超过说明去重或推进逻辑有问题。4.4 现象偶发数据错乱校验和却通过校验和只校验载荷如果包头被篡改比如 seq 字段错位校验和是发现不了的。常见原因是收发两端包头字段顺序不一致或者某端改了字段没同步。排查把收到的原始字节 dump 出来按偏移逐字段解析和发送端对比。另一个原因是多线程共用 ByteBuffer没有做线程隔离导致解析时数据被另一个线程覆盖。ByteBuffer 不是线程安全的每个解析路径要么用局部变量要么加锁。4.5 现象程序跑一段时间后卡死典型是死锁或资源泄漏。检查发送端的窗口等待逻辑如果用的是 synchronized 加 wait确认 notify 有没有在正确的地方调用漏掉 notify 会让发送线程永久等待。资源泄漏常见于 DatagramSocket 没关、线程池没 shutdown。排查用 jstack 看线程状态卡在 WAITING 的基本就是等待逻辑出问题用 jmap 看对象数量inFlight 或 outOfOrder 持续增长就是泄漏。上线前建议加一个定时打印关键指标的日志窗口占用、在途包数、重传次数、缓存大小出问题时这些数字比任何猜测都有用。5. 进阶技巧把可靠层做成能验证、能压测的工程件可靠层写完不算完得能证明它可靠。我一般会做三件事单元测试、故障注入、压测。单元测试覆盖包头编解码的对称性、滑动窗口的边界窗口满、窗口空、累积确认跨多个包、乱序缓存的接续逻辑。故障注入用代理层随机丢包、随机延迟、随机重复验证重传和去重是否按预期工作。压测用固定速率灌包观察吞吐、重传率、RTT 分布随丢包率的变化曲线。一个具体的验证技巧写一个可配置丢包率的 UDP 代理夹在客户端和服务端之间丢包率从 0 逐步加到 30%记录每个丢包率下的有效吞吐和重传次数。正常实现应该表现为丢包率上升重传次数上升有效吞吐下降但不归零如果吞吐直接归零说明重传或窗口推进有 bug。这个曲线比任何口头保证都有说服力。// 故障注入代理按概率丢包用于验证可靠层 public class LossyProxy { private final double lossRate; // 0.0 ~ 1.0 private final Random random new Random(); public void forward(DatagramPacket dp, DatagramSocket out) throws IOException { if (random.nextDouble() lossRate) { return; // 模拟丢包直接丢弃 } out.send(dp); } }逻辑说明代理收到包后按 lossRate 概率决定是否转发丢掉的包对两端都不可见等价于网络丢包。参数说明lossRate 从 0.01 开始逐步加到 0.3每次跑够 1 万个包再统计样本太小结果不可信。注意代理本身不要引入额外延迟否则测出来的 RTT 不准。还有一个容易被忽略的点可靠层的日志要能开关压测时关掉日志否则 IO 会成为瓶颈测出来的吞吐是假的。我习惯用一个静态的 debug 开关压测前关掉排查时打开。另外序列号回绕虽然 int 周期长但如果你的业务是 7x24 跑几个月还是建议在比较逻辑里用无符号比较或者定期重置会话别等到回绕那天才发现问题。这套东西我从最早的「发一个等一个」版本改到滑动窗口再改到动态 RTO中间翻过重传风暴、内存泄漏、时钟不同步的坑最大的教训是可靠层的每个参数都要有实测数据支撑拍脑袋设的值迟早会在某个流量高峰把你叫起来。先把最小闭环跑通再用故障注入逼出问题最后压测确认边界这个顺序别乱。希望帮到你。本文还有配套的精品资源点击获取