ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TCP与UDP原理对比:从传输层设计到Java网络编程选型与排错

TCP与UDP原理对比:从传输层设计到Java网络编程选型与排错 1. 一次线上事故说明为什么TCP和UDP必须从原理上理解1.1 那次我盲目选了TCP踩到的坑先说个真事。几年前我接手一个内部监控平台需要把几千台机器的CPU、内存指标实时上报到中心节点。我当时想都没想就选了TCP理由是TCP可靠、不会丢数据JavaSE里写个Socket连接发数据又简单。结果上线当天就出问题中心节点的连接数飙到几万Accept队列被打满大量连接超时整个监控平台比被监控的系统先挂了。后来排查才发现监控指标这种丢了下一轮还能补回来的场景根本不需要TCP的严格可靠交付用UDP反而更合适——每个数据报独立发送服务端无状态扛并发能力强得多。那次之后我彻底明白一件事JavaSE网络编程难点从来不在API怎么调而在你知不知道TCP和UDP各自的设计边界在哪。这事儿搞不明白代码写得再漂亮到线上都是隐患。1.2 传输层在TCP/IP模型中的位置及端口的意义很多人学TCP/IP模型知道四层是应用层→传输层→网络层→网络接口层但没真正理解传输层存在的意义。打个比方网络层IP协议负责把数据包从一台机器送到另一台机器它只认IP地址相当于快递只认寄到哪里。但一台机器上有几十上百个进程在同时跑数据包到了之后该交给哪个进程这就需要传输层在数据包上再贴一个标签——端口号。端口号是16位整数0~65535其中0~1023是知名端口比如HTTP用80、HTTPS用443、DNS用53。JavaSE里你写new ServerSocket(8080)其实就是在告诉系统8080这个端口归我这个进程外面来的数据包凡是目标端口是8080的都送到我这里来。TCP和UDP就是传输层最常见的两种实现方案它们解决的是同一个问题——进程间通信的寻址与数据交付——但设计哲学完全不同。TCP面向连接、可靠、字节流像个专人专送、逐件验收、坏了重寄的快递。UDP无连接、不可靠、数据报像个随手扔进邮筒、丢了不赔的平信。这俩的差异从报文格式到状态机再到Java代码写法一环扣一环。下面我按UDP→TCP的顺序拆开讲最后落到选型和排错上。2. UDP原理拆解无连接不是简单而是性能换省心2.1 UDP报文格式只有四个关键字段的轻量协议先看UDP头部总共8个字节就四个字段字段长度作用源端口16位发送方进程端口目的端口16位接收方进程端口长度16位UDP头数据的总长度单位字节校验和16位对头和数据进行校验发现错误就丢包相比TCP头动辄20字节起步UDP头小、无状态、无连接管理逻辑这带来两个直接结果协议开销极小每个包的处理不需要维护连接上下文CPU和内存成本低。无法保证数据一定到达。校验和只是检测数据是否损坏损坏了直接丢弃不通知发送方也不重发。很多初学者以为UDP只是比TCP少了几次握手这是个很大的误解。UDP的不可靠不是偶尔不可靠而是整个协议设计里压根没有重传、确认、顺序保证、流量控制这些东西。它把是否可靠这个问题完全推给了上面的应用层。2.2 无连接、尽力而为UDP为何不保证可靠无连接是UDP最大的特点。TCP建立连接之前要经历三次握手UDP不需要——发送方只要知道对方的IP和端口直接调send就能把数据扔出去。这意味着两件事传输过程中没有状态。发送方不知道对方是否收到接收方也不需要预先分配资源来接待某个连接。服务端开一个DatagramSocket监听端口来一个包处理一个处理完就忘掉天然无状态、易横向扩展。延迟低。少了握手RTT、少了确认等待UDP的首包延迟通常比TCP低很多。游戏里的动作同步、语音通话用的基本都是UDP原因就在这里——TCP一旦丢包要重传重传造成延迟抖动对实时交互的体验是毁灭性的。当然代价也明显如果网络丢包率高了UDP丢的就是实实在在的数据。所以基于UDP做业务必须在应用层做补偿比如靠业务逻辑容忍丢数据监控指标跳过一轮或者自己实现序列号重传机制比如QUIC协议干的活。2.3 JavaSE里用DatagramSocket收发UDP报文附代码Java对UDP的支持非常直白核心就两个类DatagramSocket负责收发DatagramPacket负责承载数据。发送端示例import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; import java.nio.charset.StandardCharsets; public class UdpSender { public static void main(String[] args) throws Exception { // 1. 创建DatagramSocket不绑定端口由系统随机分配 try (DatagramSocket socket new DatagramSocket()) { String message Hello, UDP!; byte[] data message.getBytes(StandardCharsets.UTF_8); // 2. 构造数据报数据 目标地址IP 端口 InetAddress address InetAddress.getByName(127.0.0.1); DatagramPacket packet new DatagramPacket( data, data.length, address, 9999 ); // 3. 发送不需要提前建立任何连接 socket.send(packet); } } }接收端示例import java.net.DatagramPacket; import java.net.DatagramSocket; import java.nio.charset.StandardCharsets; public class UdpReceiver { public static void main(String[] args) throws Exception { // 1. 绑定9999端口接收发往该端口的数据报 try (DatagramSocket socket new DatagramSocket(9999)) { byte[] buffer new byte[4096]; while (true) { // 2. 准备一个空包用于接收注意传入缓冲区 DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); // 3. 从包中取出数据注意取实际收到长度 String message new String( packet.getData(), 0, packet.getLength(), StandardCharsets.UTF_8 ); System.out.println(收到来自 packet.getAddress() : packet.getPort() 的消息: message); } } } }这里的几个坑我实测都踩过DatagramPacket传入的byte[]缓冲区如果数据超过缓冲区长度超出的部分会被静默截断。所以收包缓冲区要按业务的最大报文长度来设计别用固定小数组。receive()是阻塞的而且UDP接收方没有连接断开的概念服务端几乎都是死循环receive配合线程池处理业务。UDP报文最大长度受MTU限制典型MTU 1500字节时建议应用层单报文不要超过1400字节否则IP层会产生分片分片丢失会导致整个数据报被丢弃可靠性反而更差。3. TCP三次握手四次挥手教科书之外的真实链路3.1 三次握手设计意图同步序列号与防历史连接TCP和UDP最大的不同就是面向连接。这个连接不是物理上层面上拉了一条线而是通信双方在内存里维护一组状态记录对方的能力和当前传输进度。建立这套状态的过程就是三次握手。三次握手流程人人都会背SYN → SYNACK → ACK。但面试官真正想听的是为什么不能是两次或四次。关键原因有两个需要同步序列号。TCP的可靠传输核心是字节流编号——每个字节都有一个序号接收方按序号重组数据、去重、确认。握手时必须让对方知道我的初始序列号是多少才能开始编号。所以前两次交互要携带各自的初始序列号一来一回刚好交换完成。防止历史连接失效连接干扰。如果只有两次握手可能出现这种情况客户端发了一个SYN网络延迟很久客户端等不及重发了新SYN结果旧的SYN先到了服务端服务端回了SYNACK并建立起资源客户端却根本不认这个连接。三次握手里服务端在收到客户端的ACK之前不会进入ESTABLISHED状态而客户端发现ACK对应的是旧序列号可以发RST终止它甩掉历史包袱。至于为什么不握手四次因为第二次握手时服务端的SYN和ACK可以合并成一个报文发出去。反正都是要发合并一次省一个RTT设计上完全够用。3.2 四次挥手状态机TIME_WAIT和CLOSE_WAIT两个深坑终止连接需要四次挥手原因是TCP是全双工的——两条方向独立的单向通道每一方向都要单独关闭。A发送FIN表示我不再发数据了B回ACK表示收到但我可能还有数据要发等B也发完B再发FINA回ACK连接才彻底关闭。这就是为什么有两个FIN、两个ACK。实际开发中最常出问题的不是四次挥手本身而是两个半关闭状态CLOSE_WAIT收到对方的FIN后本端不主动调用close()关闭自己的方向连接就卡在这里。Java里最常见的场景是服务端从Socket输入流读到-1或异常后没有在finally里关闭Socket导致连接堆积。线上netstat看到大量CLOSE_WAIT基本就是代码漏了关闭资源。TIME_WAIT主动关闭方发送最后一个ACK后要等待2个MSL最长报文段寿命才彻底释放连接。如果有人大量主动发起短连接并主动关闭端口会因TIME_WAIT堆积而暂时不能重用。Java里反复new Socket()连同一个服务端的场景容易踩这个坑后续连接只能换端口或者等TIME_WAIT过期。3.3 用netstat观察握手挥手实测笔记理论说完了实际怎么看状态Java写一个最简单的TCP客户端连上后保持阻塞import java.net.Socket; public class TcpProbe { public static void main(String[] args) throws Exception { try (Socket socket new Socket(127.0.0.1, 8888)) { System.out.println(连接建立: socket); Thread.sleep(30_000); // 保持连接30秒方便观察状态 } } }连接过程中用netstat -anpo | grep 8888观察握手后会看到服务端监听状态LISTEN双方连接ESTABLISHED客户端主动关闭后立即查看客户端方向出现TIME_WAIT。有一次我排查连接数异常就是靠不停抓netstat输出发现服务端有大量CLOSE_WAIT顺着lsof -i :端口找到进程号再jstack导出线程栈一查就是从输入流读到EOF后忘记close()。排错建议Java服务端处理完一个请求务必在finally里关闭Socket和流或者用try-with-resources自动管理。不要以为GC会帮你回收Socketfinalize早就被废弃了连接不手动关操作系统层面的描述符会一直占着。4. 可靠传输机制的环环相扣重传、滑动窗口与拥塞控制TCP可靠不是靠魔法而是几个机制联合工作的结果。这一节把它们拆开讲明白。4.1 确认应答、超时重传与快速重传TCP给每个字节分配序列号接收方收到数据后要回一个ACK告诉发送方我收到哪一位了。如果发送方发出去的数据在RTO超时重传时间内没收到ACK就重传。RTO怎么定不能太短网络抖动导致无辜重传也不能太长丢包半天才发现。TCP采用自适应算法根据历史往返时间RTT动态计算所以早期TCP对网络波动的反应是慢半拍的。后来针对超时才重传的低效增加了快速重传机制如果接收方收到乱序数据连发三个重复ACK发送方就知道前面的包丢了不用等超时立即重传。这个机制的代价是三个重复ACK这个条件有时会被乱序触发导致没必要重传但对整体效率还是提高明显。4.2 滑动窗口与发送/接收缓冲区如果你真的让TCP一个一个包发、等ACK再发下一个吞吐量会惨不忍睹——一个RTT只能发一个包跨地域的RTT可能几十毫秒带宽再高也没用。TCP的解法是滑动窗口允许发送方在没收到ACK的情况下连续发送多个字节具体最多能发多少由接收方通告的窗口大小决定。接收方在自己的TCP缓冲区里告诉发送方我还能收多少字节这叫通告窗口。发送方不能超过这个窗口发送数据否则会把对方缓冲区挤爆。Java里对应的坑是Socket下面有发送缓冲区和接收缓冲区分别通过setSendBufferSize和setReceiveBufferSize设置但这个值和操作系统的内核缓冲区有些复杂关系。如果你吞吐量上不去先查这两个值是不是太小。4.3 拥塞控制、MSS与Java Server的缓冲区调优网络拥塞是TCP自己也需要面对的问题。TCP不仅关心接收方能不能收下还要关心中间的链路能不能承载这就是拥塞控制。核心思路是慢启动、拥塞避免、快恢复慢启动一开始发送量很小先试探每收到一轮ACK发送量翻倍指数增长试探网络的容忍度。拥塞避免增长到慢启动阈值后改为线性增长。一旦出现丢包或重复ACK认为网络拥塞立刻大幅降低发送量。这套机制保证了TCP在高并发下不会把网络打爆。但代价是突发性丢包场景下TCP的吞吐量会断崖式下降这也是很多实时业务宁用UDP的原因。**MSS最大报文段长度**决定TCP单次能装多少应用数据受MTU影响。对Java服务端来说可以适当调大收发缓冲区比如serverSocket.setReceiveBufferSize(1 * 1024 * 1024)配合内核参数调整比如Linux的net.core.rmem_max能明显提升大数据量传输的性能。4.4 粘包与拆包问题为什么TCP要处理这个而UDP不用这是Java网络面试必问也是实操必踩的坑。TCP是字节流协议它没有消息边界这个概念。你调两次write写了A和B接收方可能一次read读到AB也可能分几次读。这叫粘包和拆包。为什么UDP没有这个问题因为UDP是数据报协议recvfrom一次就拿一个完整的报文边界天然存在。解决TCP粘包的主流方案有四种固定长度报文每个消息固定多少字节不足补位。简单粗暴适合长度固定的指令。分隔符比如按\n分割。HTTP头就是这么干的。长度前缀消息头里放一个int表示消息体长度接收方先读4字节拿长度再按长度读消息体。最常用。自定义协议把长度和类型都放进头部扩展性强。Java里手写长度前缀协议核心是DataInputStream配合readFullyimport java.io.DataInputStream; import java.io.DataOutputStream; import java.net.Socket; public class LengthFieldCodec { private static final int MAX_BODY_LENGTH 10 * 1024 * 1024; // 10MB public static void writeMessage(DataOutputStream out, byte[] body) throws Exception { // 写入4字节长度大端序 out.writeInt(body.length); out.write(body); out.flush(); } public static byte[] readMessage(DataInputStream in) throws Exception { int length in.readInt(); if (length 0 || length MAX_BODY_LENGTH) { throw new IllegalArgumentException(非法消息长度: length); } byte[] body new byte[length]; in.readFully(body); // 读满length字节否则阻塞 return body; } }这个readFully特别重要它就是帮你把拆包处理掉的——一次没读够长度它会在循环里继续读直到拿满一个完整消息。我第一次写自定义协议时手动循环read结果怎么都读不对换成readFully后世界清净了。5. JavaSE网络编程落地TCP与UDP的代码级差异5.1 最小可运行TCP服务端与客户端含线程池处理并发TCP编程核心类就三个ServerSocket、Socket、InetAddress。服务端的最小骨架import java.io.*; import java.net.ServerSocket; import java.net.Socket; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class TcpServer { public static void main(String[] args) throws Exception { ExecutorService pool Executors.newFixedThreadPool(16); try (ServerSocket server new ServerSocket(8888)) { System.out.println(服务端监听 8888 端口); while (true) { Socket socket server.accept(); // 阻塞等待新连接 pool.submit(() - handleClient(socket)); } } } private static void handleClient(Socket socket) { try (socket; BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter( socket.getOutputStream(), true)) { String line; // readLine读到EOF返回null即客户端关闭 while ((line in.readLine()) ! null) { System.out.println(收到: line); out.println(echo: line); } } catch (IOException e) { e.printStackTrace(); } } }客户端最小骨架import java.io.*; import java.net.Socket; public class TcpClient { public static void main(String[] args) throws Exception { try (Socket socket new Socket(127.0.0.1, 8888); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter( socket.getOutputStream(), true)) { out.println(Hello, TCP!); System.out.println(收到: in.readLine()); } } }有个细节值得注意服务端我把handleClient放线程池里而不是每次新建Thread。因为TCP连接是要维护状态的一个连接通常存活一段时间如果每个连接一个裸线程并发上来直接能把线程数打满内存和上下文切换开销都很大。Java里如果业务是短连接还可以考虑用virtual threads或者Netty那套EventLoop模型原理都一样——别让线程数跟连接数画等号。5.2 DatagramSocket的缓冲区、并发与丢包经验回到UDPJava里DatagramSocket并发场景有几个和TCP完全不同的特点同一个Socket可被多个线程同时receive吗实测不建议。DatagramSocket的receive不是线程安全的多个线程同时调会导致数据报被错误地分配到某个线程甚至丢包。正确做法是单线程receive收到后丢给线程池处理。接收缓冲区过小造成丢弃。DatagramSocket默认接收缓冲区可能只有几十KB如果业务突发流量大内核缓冲区满了之后后续到达的UDP报文会被直接丢弃。可以通过setReceiveBufferSize调大但注意这个值只是给内核一个参考最终还要看操作系统层面有没有放开上限。对方离线不会通知你。TCP断开会触发异常或EOFUDP没有任何通知机制。你发多少包给对方对方没回你完全不知道。所以基于UDP做业务要么接受对方没收到就算了要么靠定时心跳来探测对端是否存活。我自己写过一段UDP监控上报组件线上的教训是UDP服务端必须做好幂等处理的准备。因为可能同一份数据被重复发送应用层重试也可能乱序到达处理逻辑不能假设收到的顺序就是发送顺序。5.3 连接复用、半关闭、SO_TIMEOUT等常见问题Java网络编程除了写通还要注意下面这些连接管理细节连接复用TCP建立连接要三次握手短连接频繁建立销毁握手RTT握手状态开销很可观。Java里可以用连接池管理Socket或者用Keep-Alive保持连接。每次请求new Socket()在高并发下绝对是性能杀手。半关闭Socket.shutdownOutput()表示我不发送了但仍可以接收。某些协议比如HTTP请求结束需要保留接收通道这时候用半关闭比直接close()优雅。注意关闭InputStream或OutputStream中的任意一个TCP会直接关闭双向通道所以想半关闭得用shutdownInput/shutdownOutput。SO_TIMEOUTsocket.setSoTimeout(5000)设置读超时。这是一个必须养成的习惯——你无法保证对端永远会回复。不设置SO_TIMEOUT一次read可能无限期阻塞线程池被几个僵尸连接卡死整个服务端就瘫痪了。设置超时后捕获SocketTimeoutException做重试或关闭。6. 从原理到选型TCP/UDP决策逻辑与线上排错6.1 决策表根据业务场景选择TCP还是UDP学完原理最终要落到我到底该用哪个这个问题上。我的经验是先别管什么TCP可靠UDP快这种口号按业务诉求一个个对号入座业务特征推荐原因文件传输、邮件、网页、API调用TCP数据不能丢错一个字节都不行即时消息IMTCP消息有强顺序性需要确认收到语音、视频、游戏动作同步UDP延迟敏感容忍零星丢包但不能卡顿监控指标、日志上报UDP高并发丢一轮无影响消费延迟低服务发现、心跳探测UDP消息小、频率高不需要连接管理自研实时传输类似直播串流转发UDP应用层补偿既要低延迟又要可控可参考TCP原理自研可靠机制有一个非常实用的判断标准如果这条数据丢了会发生什么如果丢了就是事故选TCP如果丢了只是损失一个采样点选UDP。把判断标准提出来很多纠结当场就解了。6.2 线上排查常用命令和抓包技巧最后分享几个我常年用的排查命令netstat -anpo | grep 端口查看连接状态排查TIME_WAIT、CLOSE_WAIT堆积。ss -s系统级别的连接统计一眼看总共多少TCP连接。lsof -i :端口看哪个进程占用了端口。tcpdump -i eth0 tcp port 8888抓包看握手、挥手、重传是否异常。ping、mtr排查网络层丢包和延迟。Windows下用netstat -ano加tasklist /FI PID eq 进程号Windows的TCP状态分析同样适用。抓包后的常见判断大量TCP Retransmission要么网络丢包要么MTU问题要么接收方缓冲区满了。顺着抓包看哪个方向丢包再对症下药。三次握手老在SYN_SENT反复可能服务端Accept队列满了或者防火墙拦截了ACK。四次挥手只见FIN不见ACK可能有半关闭场景或者应用层没关闭Socket。有次线上接口偶发超时排查一圈没头绪tcpdump一抓发现是TCP重传占了相当比例但ping又没有丢包。后来怀疑是网卡多队列和软中断问题调整了网卡队列数之后重传明显下降。这种问题纯靠看代码是看不出来的必须有抓包意识。最后再分享一个小技巧写Java网络程序时我习惯在本地起一个tcpdump或者用Wireshark回环抓包每次写完协议先抓一把看报文对不对。特别是自定义TCP协议抓包能看到每个字节的排列比自己瞎猜是不是粘包了强太多。UDP程序则多用nc -u命令行工具配合测试快速确认对端端口通不通。这套代码抓包命令的闭环比任何框架文档都管用。
返回列表