ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TCP可靠传输与Java Socket实战:从三次握手到粘包处理

TCP可靠传输与Java Socket实战:从三次握手到粘包处理 别以为TCP只要背会“三次握手、四次挥手”就能应付面试了。我面过不少人问“TCP怎么保证可靠传输”能流利背出确认重传、滑动窗口的不少但一追问“那你写Socket时粘包怎么处理”、“服务端怎么扛住几千个连接”很多人就卡住了。这说明什么说明底层的可靠传输机制和上层的Socket实战在很多人脑海里是脱节的两张皮。这篇就把这条线彻底打通从TCP协议栈为什么要设计成可靠传输到Java里ServerSocket和Socket到底怎么用、怎么应对真实生产环境里的各种坑一次讲透。这篇内容适合真正想入门Java网络编程的同学也适合准备Java面试的人查漏补缺。我会尽量少堆术语多用生活类比和可跑的代码希望对你有实在帮助。1. 内容整体设计与思路拆解1.1 为什么先讲可靠传输再讲Socket实战很多教程上来就让你写一个ServerSocketaccept一个连接然后read流、write流跑通了就算会Socket了。但这种方式有一个很大的隐患你没有建立起“传输层模型”一旦程序出问题你根本不知道是从哪一层开始错的。以我个人的习惯我会把网络编程分成三层来看。最下面是操作系统帮我们维护的TCP协议栈负责数据拆分、路由寻址、丢包重传。中间是Java提供的Socket抽象层它把TCP协议栈封装成了两个流InputStream和OutputStream对应用层来说就像一个管道。最上面才是你写的业务代码比如HTTP解析、JSON序列化、消息队列投递。如果不理解最下面那层你就很难想明白几个经典现象为什么客户端明明断了服务端readLine会一直阻塞为什么我发了两条数据服务端一次就全读出来了为什么TCP连接这么“重”服务端不能无限开线程这些问题只有回到TCP协议本身才能找到答案。所以第一章先花篇幅把可靠传输机制讲透再进入实战这个顺序不建议跳。1.2 从“可靠”二字看TCP的设计初衷TCP的全称是Transmission Control Protocol传输控制协议。它和UDP最大的区别就一个词可靠。这种可靠不是网络本身可靠恰恰相反IP层只负责尽力把数据包送出去它不保证顺序、不保证不丢、不保证不重复。TCP则是在这个不可靠的IP层之上封装出了一套“可信的信使系统”。举个例子你寄一个贵重包裹快递员可能中途弄丢也可能把两个包裹顺序搞乱。TCP做的事情就是要求快递员必须回执丢了就再发一份顺序乱了就按编号重新排好。这个“编号”就是TCP协议头里的序列号也是整个可靠传输的基石。所以你在面试里被问“TCP如何保证可靠传输”并不能只背一个“确认重传”而是要把下面这几个机制串成一个体系数据校验和检测包在传输过程中有没有被改坏序列号与确认应答让接收方知道来了什么东西、缺了什么超时重传发出去后等不到确认立刻重发滑动窗口控制发送速度防止淹没接收方流量控制与拥塞控制兼顾双方处理能力和网络承载能力这些机制不是孤立的它们共同作用才让TCP敢于对上层拍胸脯说“我这层不会丢数据”。后面我会逐个拆开讲并关联对应的Java代码场景。2. 核心细节解析与实操要点2.1 解码TCP三次握手与四次挥手三次握手和四次挥手是面试问烂了的东西但能讲得清楚的人真的不多。先说三次握手的本质是什么同步双方的初始序列号。为什么需要序列号因为TCP要把数据按字节流编号接收方拿到数据才能排序和去重。既然是初始化就必然要让双方都知道对方的起始序列号是多少。过程是这样的客户端发送SYN带着自己生成的初始序列号x服务端收到后回SYN ACK带上自己的初始序列号y同时确认客户端的x客户端再回ACK确认收到服务端的y这里很多人追问为什么不是两次握手道理很简单因为服务端无法确认“自己发的SYN客户端是否收到”。如果只有两步服务端认为自己连上了但客户端可能因为丢包根本没收到服务端的SYN导致客户端一直等、服务端一直等双方状态不一致。三次握手能确保双方都确认“我能发、你能收”。四次挥手相对好理解一些。TCP连接是全双工的两条方向的数据通道需要各自关闭。所以主动关闭方发FIN表示“我的数据发完了”被动方回ACK表示“收到你的FIN”被动方再发FIN表示“我的数据也发完了”主动方回ACK然后进入TIME_WAIT状态TIME_WAIT经常被忽略但它特别重要。主动关闭方会停留大约2MSL时长一般是2到4分钟。为什么一是怕最后一个ACK丢了要留着重新发二是怕旧连接的数据包残留在网络里复用相同端口时串到新连接上。这个状态在生产环境极其常见表现为大量TIME_WAIT连接占着端口不释放后面我会在问题排查里说怎么应对。2.2 可靠传输的幕后黑手滑动窗口与重传机制只靠“发一个包、等一个确认”的简单停等协议传输效率太低了。假如北京到上海往返延迟是30毫秒每发一个包等30毫秒确认那即使带宽再大也跑不满。TCP的解决方案就是滑动窗口允许在未收到ACK的情况下连续发送多个数据包。窗口大小由接收方的接收能力决定这就是流量控制。接收方在每次ACK时会带上自己还能接收多少字节发送方据此调整自己的发送速度防止对方缓冲区爆掉。这就好比快递仓库告诉你“我一天最多能接收1000件你按这个速度发。”拥塞控制则是从网络整体角度出发的。如果网络已经堵成一片发送速度再快也没用只会加剧丢包。TCP用慢启动、拥塞避免、快速重传、快速恢复这一套机制动态调整发送速率。写Java代码时你感知不到这些但理解之后你就能明白为什么长连接比短连接更高效因为慢启动阶段很吃亏复用连接后窗口已经开到很大传输效率自然高。实战里还有一个高频问题就是粘包和拆包。TCP是流式协议它不关心你应用层怎么分割消息。就好比你往河里倒了一桶水对岸接到的就是一条连续的水流没有贴着“第1桶、第2桶”的标签。所以写Socket通信时必须自己定义消息边界常见做法有三种固定长度每条消息固定N个字节不足补空格。实现最简单但浪费空间。特殊分隔符比如以换行符结尾。适合简单文本协议但正文里不能出现该分隔符。长度字段前缀在消息头里写一个int表示后面正文的字节数。最通用实际项目用得最多。后面实战环节我会用“长度字段前缀”这种方式写一个demo帮大家彻底解决粘包问题。2.3 Socket的生命周期与TCP状态机的关系Java的Socket其实是对TCP连接状态机的封装。当你在Java里new Socket(host, port)时底层就主动发起了三次握手。这个过程中端口的选择、超时设置、连接状态都由操作系统帮你维护。你需要注意几个隐藏点TCP连接超时new Socket默认会一直阻塞直到连接成功或报错。生产环境建议设置connectTimeout否则对方IP不通时你的线程可能卡很久。TCP_NODELAY默认情况下Socket为了减少小包数量会启用Nagle算法把多个小数据合并后再发送。这在实时交互场景会造成明显延迟可以调用setTcpNoDelay(true)关闭。SO_TIMEOUT控制读操作阻塞的最大时长。如果不设置readLine可能在连接半开时永久阻塞。这三个参数在调优时极其重要后面代码里我都会演示。3. 实操过程与核心环节实现3.1 从选型开始TCP还是UDP写网络程序第一件事不是敲代码是选协议。TCP和UDP的区别用一句话概括TCP要可靠、要连接、要保证顺序代价是慢UDP不要这些就是发代价是可能丢数据。选哪一个是需求决定的不是技术偏好决定的。举几个典型场景文件传输、数据库连接、RPC调用必须用TCP丢一个字节都完蛋语音通话、视频直播、游戏位置同步丢几帧人眼根本察觉不到用UDP更合适日志上报、监控指标这种允许少量丢失的有些团队也会选UDP因为省去握手和重传能扛更高并发所以面试被问“TCP和UDP区别”别只会说“TCP可靠UDP快”这种定性回答要能结合业务场景给出选型判断这才叫理解。3.2 手写第一个Java TCP通信Demo下面我写一个最基础的服务端和客户端。服务端监听8080端口收到消息后原样返回也就是经典的EchoServer。这个demo虽然简单但涵盖了ServerSocket、Socket、InputStream、OutputStream这四大核心要素。先看服务端import java.io.*; import java.net.*; public class EchoServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8080); System.out.println(server started on port 8080); while (true) { Socket socket serverSocket.accept(); System.out.println(client connected: socket.getRemoteSocketAddress()); // 每个连接开一个线程处理 new Thread(() - handleClient(socket)).start(); } } private static void handleClient(Socket socket) { try (BufferedReader in new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter(socket.getOutputStream(), true)) { String line; while ((line in.readLine()) ! null) { System.out.println(receive: line); out.println(echo: line); } } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } }再看客户端import java.io.*; import java.net.*; public class EchoClient { public static void main(String[] args) throws IOException { Socket socket new Socket(127.0.0.1, 8080); socket.setSoTimeout(3000); BufferedReader in new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter(socket.getOutputStream(), true); out.println(hello server); String response in.readLine(); System.out.println(server response: response); socket.close(); } }这段代码看起来能跑但存在好几个问题。第一是服务端每个连接一个线程并发一高就会OOM。第二是readLine这种方式依赖换行符一旦消息体里包含换行、或者使用二进制数据就会有问题。第三是没处理半包只是用行缓冲勉强规避了粘包真实场景不够用。3.3 生产级优化线程池与消息定界针对上面三个问题我提供一个更实用的版本。第一用线程池管理线程防止无限创建线程导致资源耗尽。ExecutorService executor Executors.newFixedThreadPool(16); while (true) { Socket socket serverSocket.accept(); executor.submit(() - handleClient(socket)); }第二消息定界使用“长度字段前缀”。具体做法是先把消息转成byte数组前4个字节用int写入消息长度再写正文。读取时先读满4个字节解析出长度再按长度读正文。服务端处理逻辑改造后大概是这样private static void handleClient(Socket socket) { try (DataInputStream in new DataInputStream(socket.getInputStream()); DataOutputStream out new DataOutputStream(socket.getOutputStream())) { while (true) { int len in.readInt(); byte[] body new byte[len]; in.readFully(body); String message new String(body, StandardCharsets.UTF_8); System.out.println(receive: message); byte[] response (echo: message).getBytes(StandardCharsets.UTF_8); out.writeInt(response.length); out.write(response); out.flush(); } } catch (EOFException e) { System.out.println(client closed connection); } catch (IOException e) { e.printStackTrace(); } }这个写法用DataInputStream.readInt和readFully正好会等满一个int或等满整个body避免了粘包和半包问题。客户端也需要对称实现先写长度、再写消息体读的时候先读长度、再读完整内容。第三设置好TCP参数。Socket socket new Socket(); socket.connect(new InetSocketAddress(10.0.0.1, 8080), 3000); // 连接超时3秒 socket.setSoTimeout(5000); // 读超时5秒 socket.setTcpNoDelay(true); // 关闭Nagle算法降低延迟这些参数看起来不起眼但在生产环境里是救命的。比如你对一个不存在的IP发起连接默认可能要等两分钟才报错设置了connectTimeout后3秒就能快速失败。3.4 多客户端并发与线程模型演进上面的线程池版本能撑住一些并发但业界有更成熟的线程模型。我大致梳理一下发展路线方便你理解为什么有的框架要设计得像它们那样。第一代模型是BIO也就是Blocking IO一个客户端连接对应一个线程。优点是实现简单、代码直观缺点就是线程和连接强绑定连接数上去之后线程数疯狂膨胀上下文切换开销极大。我之前见过一个服务端直接开5000个线程内存和CPU直接崩溃的例子。第二代模型是NIONon-blocking IO用Selector一个线程轮询成千上万个连接只在连接真正可读可写时才分配资源。Java NIO底层的Selector在Linux上是epoll实现性能很高。但是NIO的编程模型非常反人类Buffer、Channel、Selector三件套让无数新手摸不着头脑。所以后来才有Netty这类框架基于NIO但封装成事件驱动的友好模型你只需要写ChannelHandler回调就行。所以最新版本的server代码我是建议用Netty的因为代码量的减少不是一点半点。但本文第一目标是讲清楚TCP和Socket的底层逻辑所以BIO的写法必须会。否则你直接用Netty遇到空轮询、内存泄漏这种问题你根本不知道底层发生了什么。先学会走再学跑。4. 常见问题与排查技巧实录4.1 高频报错速查表我整理了平时工作和带新人时遇见最多的几个报错写成一张表方便大家排查。这里我强烈建议大家收藏因为每一个都是真实生产环境血泪换来的。报错信息可能原因解决思路java.net.BindException: Address already in use端口被占用Windows执行netstat -ano找PIDLinux执行lsof -i:端口确认是不是上次的进程没退干净java.net.ConnectException: Connection refused目标端口没有服务在监听或防火墙拦截先本机telnet 127.0.0.1 端口测试排除服务端问题再检查防火墙和安全组java.net.SocketTimeoutException: Read timed out读超时设置太短或对端处理太慢结合业务调整SO_TIMEOUT或检查对端是否有死锁、长任务阻塞java.io.EOFException对端异常关闭连接另一方还在读看对端日志排查是否代码提前close或进程崩了java.net.ProtocolException: Connection reset一端写入数据时另一端已经关闭连接规范关闭顺序服务端不要抢在客户端读完前closeaddress already in use: JVM_BindJVM启动时端口被占用这是因为上一次进程存活或同一进程内new ServerSocket同一个端口多次这张表没法覆盖所有场景但覆盖了90%以上的入门问题。出现报错时别先怀疑依赖和框架要按“网线通不通、端口通不通、防火墙通不通、对端服务有没有挂”这个顺序排查效率最高。4.2 踩坑实录TIME_WAIT过多把端口耗尽有次做压测服务端短连接频繁建立和关闭压测跑到一半日志突然刷出一堆“Cannot assign requested address”服务端也没法accept新连接了。排查后发现问题有两个层面。第一是客户端主动关闭连接后进入大量TIME_WAIT状态占满了可用端口。生产环境里如果短链接场景特别多可以考虑开启Linux的tcp_tw_reuse参数来复用TIME_WAIT状态的连接同时设置tcp_timestamps为1。这个参数的意义在于只要时间戳递增内核就认为旧连接已经安全结束可以复用这个端口。第二是服务端有很多CLOSE_WAIT状态的连接。CLOSE_WAIT出现的原因是被动关闭方没有正确关闭Socket通常是因为代码里read循环退出条件写错了或者异常路径里漏了close。遇到这种问题不要只杀进程要用jstack把线程栈dump下来看到底卡在哪一行。我第一次做TCP调优时到处找“有什么参数能解决CLOSE_WAIT”而实际上没有一劳永逸的参数。CLOSE_WAIT的本质是应用层漏了close必须靠代码修复。后来我养成了一个习惯所有用Socket的地方一律try-with-resources自动关闭或者finally里关绝不裸奔。4.3 调试TCP连接的工具链排查网络问题单靠println日志效率太低。我常用的工具从小到大telnet IP 端口开发环境快速试端口通不通nc -vz IP 端口检查端口可访问性比telnet更清晰tcpdump抓包看三次握手和挥手的状态Wireshark可视化分析TCP重传、乱序、延迟本地调试利器netstat -anp查看当前系统所有TCP连接状态统计lsof -i:port查哪个进程占用了指定端口拿Wireshark举个场景客户端连服务端很慢排查半天不知道卡在哪。抓包一看发现客户端发SYN后一直没收到SYN-ACK。再结合traceroute看路径发现中间某一层丢包严重网络问题就定位到了。如果你只盯着应用代码可能折腾一天也找不到原因。4.4 高频面试题TCP与HTTP夹层的关系很多初学者搞不清TCP和HTTP的关系。简单来说HTTP是基于TCP的应用层协议它定义了请求和响应的文本语义但传输本身还是走TCP的可靠通道。当你发一个HTTP请求时浏览器先通过TCP三次握手建立连接然后发送HTTP报文服务端处理完再通过同一个TCP连接回包。正因为HTTP是应用层协议它不负责可靠性。可靠性是TCP保证的。这也解释了为什么某些面试题会问“HTTPS的502和504区别”502是网关从上游拿到非法响应504是网关在超时时间内没等到上游响应这两者都隐含了TCP连接层面的变化但定位问题要靠全链路的日志和抓包。实际项目里如果你用Netty写了一个TCP服务还没有接入HTTP意味着客户端必须自己定义消息头和消息体自己处理粘包和安全认证。这比发HTTP请求麻烦得多但换来的是极低的协议开销和高吞吐适合需要海量长连接的业务比如IoT设备接入、消息推送通道。5. 从Demo走向真实项目的进阶建议5.1 为什么建议读一遍Netty的源码再谈网络编程我见过一些人刚学会ServerSocket就问“Java怎么写高性能服务器”我的建议是先不要急着上Netty把BIO的几个demo写熟、把TCP状态机的核心机理弄明白再去看Netty会觉得顺理成章。Netty的核心抽象EventLoop本质就是NIO事件循环线程绑定你用明白Selector之后再看EventLoop就是眼熟的东西。Netty有几个设计思路特别值得偷师用零拷贝减少数据复制次数用ByteBuf池化降低GC压力用pipeline机制把协议编解码和业务处理解耦用背压机制控制上下游速率这几点每一条展开都是一篇长文。我给大家的建议是先跑通一个最简单的Netty EchoServer然后断点调试看数据从Socket到ChannelHandler的完整流转比干看源码有效得多。5.2 写TCP通信必经的三种开发环境最后说一个很多新手会忽略的问题。你在本地Windows上写好的TCP程序到Linux服务器上部署可能遇到三个大坑。第一Windows和Linux的行分隔符不同。如果你用BufferedReader.readLine解析数据在Windows上写入的\n会被解析成\r\n还是\n取决于你用什么方式构造字符串可能直接导致消息定界错乱。建议统一用字节流的长度字段方式不要依赖文本行。第二Linux默认文件描述符上限可能很低。TCP连接在Linux里占文件描述符默认1024上限意味着你只能维护非常少的连接数。部署高并发服务前先检查ulimit -n必要时调高。第三防火墙和云安全组一定要一起检查。有时候本地连不通不是服务端没启动而是安全组没放行端口。我一个朋友前几年上线服务排查了一整天才发现是云控制台安全组没开8080端口白白浪费一天。这些坑我全踩过分享出来希望大家少走点弯路。写在最后的一点个人体会做网络编程这些年我最大的体会是TCP这家伙真是“细节魔鬼”。面试时背三个状态、四个挥手很容易可真到线上出了诡异问题你脑子里如果没有完整的协议栈模型就会病急乱投医。写Java Socket也没有捷径就是多写、多抓包、多压测。遇到Connection reset不要慌先看是不是对端提前把连接关了遇到大量TIME_WAIT不要乱调参数先想清楚自己的连接生命周期设计有没有问题。等你能从一个普通的EchoServer改造成一个支撑高并发的NIO服务端时你对Java网络编程的理解就真正上一个台阶了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进