ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java基于UDP的可靠通讯系统:从序号确认到滑动窗口的工程实践

Java基于UDP的可靠通讯系统:从序号确认到滑动窗口的工程实践 简介这份源码资源面向Java网络编程学习者与分布式系统入门开发者围绕UDP协议不可靠性这一核心难点给出了一套可运行的可靠通信系统完整实现。压缩包共132个文件以43个java源文件与63个class编译文件为主体另含9个xml配置、3个jar依赖及少量图片与工程配置文件整体约1.13MB目录按Client与Server两端划分结构清晰便于对照阅读。源码覆盖序列号与确认机制、超时重传、CRC校验、流量控制等可靠性策略并借助DatagramSocket与DatagramPacket完成数据封装与收发客户端负责请求发起与响应处理服务器端负责循环监听、确认应答与业务逻辑处理。已有187人学习适合作为课程设计、网络编程实验或分布式通信入门的参考范例帮助读者理解UDP之上如何补齐可靠传输的关键环节。1. 从「丢包就崩」到「自己扛住」Java 基于 UDP 的可靠通讯系统到底在做什么用 Java 写网络通讯很多人第一反应是 TCPSocket、ServerSocket一包read和write阻塞着来代码短、心智负担低。可一旦场景换成实时对战、设备状态上报、音视频信令、内网广播发现TCP 的队头阻塞和重传策略反而成了拖累——一个包卡住后面全排队。这时候大家会转向 UDP然后立刻撞上同一个问题UDP 不保证送达、不保证顺序、不保证不重复业务层收到的可能是一堆乱序、缺失甚至重复的数据报。「Java 基于 UDP 协议的可靠通讯系统」要解决的正是这个矛盾底层仍然用 UDP 的DatagramSocket收发但在应用层自己实现一套可靠性机制把「不可靠」补回来。它适合两类人一类是做 Java 课程设计、需要一套能跑起来、能演示、能讲清楚原理的完整源码另一类是做物联网、游戏后端、内网工具的工程师需要在 UDP 的低延迟上叠加可控的可靠传输。这篇不空谈协议而是把序号、确认、重传、去重、窗口这些机制拆成能落地的代码结构让你看完能自己搭一套最小可用的可靠 UDP 通道也知道哪些参数一调就翻车。2. 可靠 UDP 的骨架序号、确认与重传怎么在 Java 里落地2.1 为什么不能直接给 DatagramSocket 套一层 while 循环最常见的错误写法是发送端send完就以为对方收到了接收端receive到就处理中间没有任何反馈。UDP 本身没有连接状态send成功只代表数据交给了本机协议栈不代表对端网卡收到了更不代表应用层处理了。要让它可靠必须在应用层引入三个东西序号判断顺序和丢包、确认ACK告诉发送方收到了、超时重传没收到 ACK 就重发。在 Java 里DatagramPacket承载的是字节数组所以序号、ACK 标志、数据长度这些控制信息得自己塞进包头。常见做法是设计一个固定长度的头部比如前 4 字节放序号接着 1 字节放标志位数据/ACK/心跳再 2 字节放数据长度后面才是真正的业务数据。这样接收端拆包时先读头再决定怎么处理。2.2 一个可复用的数据包结构设计下面这段代码定义了一个最小可用的可靠 UDP 包头包含序号、标志位和长度。它不依赖任何第三方库纯 JDK 就能跑。import java.nio.ByteBuffer; public class ReliablePacket { public static final byte TYPE_DATA 0; public static final byte TYPE_ACK 1; public static final byte TYPE_HEARTBEAT 2; private int seq; // 序号用于排序和确认 private byte type; // 包类型 private byte[] payload; // 业务数据 public ReliablePacket(int seq, byte type, byte[] payload) { this.seq seq; this.type type; this.payload payload null ? new byte[0] : payload; } // 序列化成字节数组头部固定 9 字节4 序号 1 类型 4 数据长度 public byte[] toBytes() { ByteBuffer buf ByteBuffer.allocate(9 payload.length); buf.putInt(seq); buf.put(type); buf.putInt(payload.length); buf.put(payload); return buf.array(); } // 从字节数组反序列化 public static ReliablePacket fromBytes(byte[] raw, int length) { ByteBuffer buf ByteBuffer.wrap(raw, 0, length); int seq buf.getInt(); byte type buf.get(); int len buf.getInt(); byte[] data new byte[len]; buf.get(data); return new ReliablePacket(seq, type, data); } public int getSeq() { return seq; } public byte getType() { return type; } public byte[] getPayload() { return payload; } }逻辑说明toBytes把序号、类型、长度按固定顺序写入保证接收端能按同样顺序解析。参数上序号用int足够覆盖大多数场景如果做长时间高频传输可以换成long但头部会多 4 字节。payload长度建议控制在 1200 字节以内避免超过常见 MTU 导致 IP 层分片分片一丢就是整包重传效率反而下降。2.3 发送端超时重传与 ACK 等待发送端不能发完就扔得维护一个「已发送但未确认」的队列。每发一个数据包记录序号、发送时间、重传次数然后等 ACK。如果超过 RTO重传超时时间还没收到对应 ACK就重发。import java.net.*; import java.util.concurrent.*; public class ReliableSender { private final DatagramSocket socket; private final InetAddress target; private final int targetPort; private final ConcurrentHashMapInteger, Long pending new ConcurrentHashMap(); private static final long RTO_MS 300; // 初始重传超时 private static final int MAX_RETRY 5; // 最大重传次数 public ReliableSender(DatagramSocket socket, InetAddress target, int targetPort) { this.socket socket; this.target target; this.targetPort targetPort; } public void sendReliable(int seq, byte[] data) throws Exception { ReliablePacket pkt new ReliablePacket(seq, ReliablePacket.TYPE_DATA, data); byte[] raw pkt.toBytes(); int retry 0; while (retry MAX_RETRY) { socket.send(new DatagramPacket(raw, raw.length, target, targetPort)); pending.put(seq, System.currentTimeMillis()); // 等待 ACK实际项目中用独立接收线程处理这里简化演示 if (waitForAck(seq, RTO_MS)) { pending.remove(seq); return; } retry; } throw new RuntimeException(包 seq 重传 MAX_RETRY 次仍失败); } private boolean waitForAck(int seq, long timeout) { // 真实实现由接收线程收到 ACK 后唤醒这里用轮询示意 long deadline System.currentTimeMillis() timeout; while (System.currentTimeMillis() deadline) { if (!pending.containsKey(seq)) return true; try { Thread.sleep(10); } catch (InterruptedException ignored) {} } return false; } }逻辑说明pending记录未确认的序号waitForAck在超时前检查该序号是否被移除。参数上RTO_MS设 300 毫秒适合局域网跨公网建议 500 到 800 毫秒起步并根据 RTT 动态调整。MAX_RETRY设 5 次是经验值再多说明链路已经不可用继续重传只会加重拥塞。2.4 接收端去重、排序与 ACK 回发接收端收到数据包后先看序号如果是已经处理过的直接丢弃但补发 ACK如果是期望的下一个序号交给业务层并推进窗口如果比期望的大说明中间丢了先缓存同时回发 ACK 告诉发送端「我收到了这个但前面缺了」。import java.net.*; import java.util.*; public class ReliableReceiver { private final DatagramSocket socket; private int expectedSeq 0; private final TreeMapInteger, byte[] buffer new TreeMap(); private final SetInteger received new HashSet(); public ReliableReceiver(DatagramSocket socket) { this.socket socket; } public void listen() throws Exception { byte[] buf new byte[1500]; while (true) { DatagramPacket dp new DatagramPacket(buf, buf.length); socket.receive(dp); ReliablePacket pkt ReliablePacket.fromBytes(dp.getData(), dp.getLength()); if (pkt.getType() ReliablePacket.TYPE_ACK) { // 交给发送端处理这里略 continue; } int seq pkt.getSeq(); // 回发 ACK无论是否重复 sendAck(seq, dp.getAddress(), dp.getPort()); if (received.contains(seq)) { continue; // 重复包丢弃 } received.add(seq); if (seq expectedSeq) { deliver(pkt.getPayload()); expectedSeq; // 检查缓存里有没有连续的下一个 while (buffer.containsKey(expectedSeq)) { deliver(buffer.remove(expectedSeq)); expectedSeq; } } else if (seq expectedSeq) { buffer.put(seq, pkt.getPayload()); // 乱序先缓存 } } } private void sendAck(int seq, InetAddress addr, int port) throws Exception { ReliablePacket ack new ReliablePacket(seq, ReliablePacket.TYPE_ACK, new byte[0]); byte[] raw ack.toBytes(); socket.send(new DatagramPacket(raw, raw.length, addr, port)); } private void deliver(byte[] data) { // 业务处理入口 System.out.println(交付数据长度: data.length); } }逻辑说明expectedSeq是当前期望收到的序号buffer缓存乱序到达的包received记录已处理的序号用于去重。参数上buf开 1500 字节是常见 MTU 上限如果业务数据更大需要在应用层分片而不是让 UDP 自己分。TreeMap保证缓存按序号有序方便连续交付时按顺序取出。3. 把可靠性做扎实滑动窗口、心跳与断线重连3.1 停等协议够用吗什么时候必须上滑动窗口上面演示的是「停等」思路发一个等一个 ACK。局域网小数据量没问题但一旦 RTT 是 50 毫秒每秒最多传 20 个包带宽利用率极低。真实系统里常见做法是引入滑动窗口允许发送方连续发多个包接收方累积确认窗口内的包可以并行在途。在 Java 里实现滑动窗口核心是维护一个发送窗口base和nextSeq。base指向最早未确认的序号nextSeq指向下一个可用的序号。只有nextSeq - base windowSize时才能继续发。收到 ACK 后base向前滑动。窗口大小建议从 16 或 32 起步太大在丢包严重时会导致大量重传太小则吞吐上不去。3.2 心跳包与超时判定怎么知道对方还活着UDP 没有连接状态双方都不知道对方是否还在线。常见做法是每隔固定时间发一个心跳包TYPE_HEARTBEAT对方收到后回一个 ACK。如果连续 N 个心跳周期没收到任何回应就判定断线触发重连或告警。public class HeartbeatManager { private static final long INTERVAL_MS 3000; // 心跳间隔 private static final int MAX_MISS 3; // 连续丢失次数上限 private int missCount 0; private long lastRecvTime System.currentTimeMillis(); public void onHeartbeatSent() { if (System.currentTimeMillis() - lastRecvTime INTERVAL_MS * MAX_MISS) { System.out.println(对端疑似断线触发重连); // 重连逻辑 } } public void onAnyPacketReceived() { lastRecvTime System.currentTimeMillis(); missCount 0; } }逻辑说明lastRecvTime记录最后一次收到任何包的时间INTERVAL_MS * MAX_MISS就是判定断线的阈值。参数上心跳间隔 3 秒适合大多数内网场景移动网络可以放宽到 5 到 10 秒避免频繁心跳耗电。注意心跳包本身也要走可靠通道否则心跳丢了会误判断线。3.3 序号回绕与 long 溢出的处理如果用int做序号传到 21 亿左右会溢出回绕。短时间跑没问题但长时间运行的系统必须处理。常见做法是用无符号比较判断seq是否在expectedSeq的「前方」而不是简单比大小。Java 没有无符号 int可以用Integer.compareUnsigned或者干脆用long并定期重置。// 判断 seq 是否在 expected 之后考虑回绕 public static boolean isAfter(int seq, int expected) { return (seq - expected) 0 (seq - expected) Integer.MAX_VALUE / 2; }逻辑说明seq - expected在回绕时仍然能得到正确的相对距离只要距离不超过一半的序号空间。参数上如果预计系统连续运行超过几天且频率很高建议直接用long省去回绕判断的心智负担。4. 避坑与排查可靠 UDP 最容易翻车的 5 个地方4.1 现象接收端频繁收到重复包业务重复处理原因ACK 在回程丢了发送方超时重传接收方虽然已经处理过但没有正确去重。很多简化实现只在seq expectedSeq时记录乱序缓存的包没有加入去重集合。解决维护一个独立的received集合任何序号只要处理过就加入收到重复包直接丢弃但补发 ACK。集合不能无限增长可以定期清理小于expectedSeq - windowSize的旧序号。4.2 现象局域网跑得好好的一上公网就大量超时原因公网 RTT 波动大固定 RTO 要么太短导致误重传要么太长导致吞吐低。另外公网 MTU 可能更小1200 字节的包被分片后丢一片就整包重传。解决RTO 根据实测 RTT 动态调整常见公式是RTO SRTT 4 * RTTVAR。包大小降到 1000 字节以内并在应用层做分片重组而不是依赖 IP 分片。4.3 现象发送方窗口开大后丢包率反而上升原因窗口太大一次性往网络里灌太多包中间路由器队列溢出导致连锁丢包。UDP 没有拥塞控制发送方不自知。解决引入简单的拥塞控制比如慢启动窗口从 1 开始每收到一个 ACK 翻倍直到阈值后线性增长。或者至少限制在途包数量不要超过带宽 * RTT的估算值。4.4 现象接收端DatagramSocket.receive阻塞心跳和 ACK 处理不及时原因单线程既收数据又处理业务业务一慢ACK 回发延迟发送方误判超时重传。解决接收线程只负责收包、拆包、回 ACK把业务数据丢进阻塞队列由独立线程池处理。这样 ACK 路径始终短平快不受业务耗时影响。4.5 现象程序退出时端口没释放重启报Address already in use原因DatagramSocket没有正确关闭或者关闭顺序不对导致底层端口仍被占用。解决在finally块里调用socket.close()并确保发送线程、接收线程都收到停止信号后再关闭。如果做测试频繁重启可以设置setReuseAddress(true)但生产环境慎用避免多个实例抢同一端口。5. 进阶技巧用可配置参数和日志把可靠 UDP 调明白走到这里一套最小可用的可靠 UDP 通道已经能跑起来了。但真实项目里最耗时间的不是写代码而是调参和排查。我自己的习惯是把所有关键参数抽到一个配置类里运行时可以热更新同时给每个包的关键路径打上带序号的日志。这样一旦出问题翻日志就能看出是丢包、乱序还是重传策略不对。public class ReliableConfig { public static volatile int windowSize 32; public static volatile long rtoMs 300; public static volatile int maxRetry 5; public static volatile int heartbeatIntervalMs 3000; public static volatile int mtu 1200; public static void loadFromArgs(String[] args) { for (String arg : args) { if (arg.startsWith(--window)) windowSize Integer.parseInt(arg.split()[1]); if (arg.startsWith(--rto)) rtoMs Long.parseLong(arg.split()[1]); if (arg.startsWith(--mtu)) mtu Integer.parseInt(arg.split()[1]); } } }逻辑说明用volatile保证多线程可见性loadFromArgs让同一份代码在不同网络环境下不用重新编译。参数上windowSize在局域网可以开到 64跨公网建议 16 到 32rtoMs局域网 200 到 300公网 500 起步mtu保守用 1200如果确认链路支持 jumbo frame 可以适当放大但不要超过 1400。验证方法上我一般会写一个简单的统计类记录发送数、重传数、ACK 数、去重数每隔几秒打印一次。如果重传率超过 5%说明 RTO 或窗口需要调如果去重数很高说明 ACK 丢得厉害得检查回程链路。下面是一个统计输出的样子指标含义健康范围sendCount发送数据包总数—retryCount重传次数小于发送数的 5%ackCount收到的 ACK 数接近发送数dupCount去重丢弃数小于发送数的 2%timeoutCount超时次数越小越好最后说一个我踩过的坑早期为了省事把 ACK 和业务数据放在同一个DatagramSocket上收结果业务数据一大ACK 被挤在后面发送方疯狂重传整个通道雪崩。后来把 ACK 处理放到接收线程的最前面收到任何包先判断类型ACK 立即处理业务数据才入队重传率直接从 20% 降到 1% 以下。这个习惯我一直保留到现在控制路径永远优先于数据路径。希望帮到你。本文还有配套的精品资源点击获取
返回列表