从一次请求吃透Java网络:连接、超时与线上排查
翻一遍最近看到的简历和面试记录,会发现一个挺有意思的现象:TCP 三次握手、四次挥手、HTTP 状态码、OSI 七层模型,这些答案几乎人人都能背,但真到了线上出现大量 CLOSE_WAIT、接口偶发Connection reset、或者日志里冒出一堆读超时的时候,很多人第一反应还是重启了再说。这就是我想写这份 Java 知识点整理的出发点——把计算机网络从"面试题清单"变成"能对着代码和线上现象使用的东西"。这篇是系列第 4 篇,主题就锁定在计算机网络,但我不打算按教科书顺序从物理层讲到应用层,那是给考试用的,不是给写代码的人用的。
我打算换一条主线:跟着一次真实的请求走一遍。从域名解析开始,到建立连接、连接被复用、数据发出去、服务端接住、消息被切分、被反序列化、业务处理完写回响应、最后连接怎么关掉。每一个环节我会说清楚三件事:原理是什么、在 Java 代码里对应到哪个 API 或哪个参数、出了问题长什么样。这条线索的好处是,你学到的每个概念都能挂到具体的调用栈上,而不是飘在空中。适合已经写过一两年 Java、能独立排查简单线上问题、但网络这块一直靠模糊记忆撑着的朋友,也适合准备面试但不想只背结论的同学。
1. 为什么网络知识在 Java 项目里总是"学完就忘"
我一直觉得,网络知识难学不是因为概念抽象,而是因为它和我们日常写代码的粒度对不上。你在业务代码里写的是orderService.query(id),中间隔着框架、HTTP 客户端、连接池、内核协议栈,最后才是一根网线上的电平变化。中间这几层全是框架帮你封好的,你平时根本看不见,所以一旦出问题,你连该看哪一层都不知道。
1.1 面试答案和线上现象的错位
拿"四次挥手"举例。面试的时候你能流畅说完 FIN、ACK、FIN、ACK,但线上告警跳出来说"某服务连接数暴涨,大量连接处于 CLOSE_WAIT",你能不能立刻反应过来这是代码里没有关闭 Socket 或者输入流?CLOSE_WAIT 的本质是对方发了 FIN 过来,你的内核回了 ACK,但你的应用程序没有调用close(),内核没法发自己那一侧的 FIN。这就意味着问题出在你的代码里,不在网络里。能直接定位到"是某个 HttpClient 的 Response 没关流"和"是网络抖动",中间差的不是知识点,是知识点的挂载位置。
再比如"TCP 是面向连接、可靠的、基于字节流的"。这三句话背下来只要三秒,但"基于字节流"这五个字,能直接决定你线上 RPC 框架要不要加粘包拆包处理。如果你没意识到 TCP 不保留消息边界,你会觉得"我明明发了一条完整的 JSON 过去,对面怎么就解不出来"。这不是你代码写错了,是你对协议的理解缺了一块。
1.2 这份整理的组织方式:跟着一次请求走
所以我这次的整理逻辑是:每一个网络概念,都要能找到它在 Java 里的对应物。
| 网络概念 | Java 里的对应物 | 出问题的典型表现 |
|---|---|---|
| TCP 三次握手 | Socket.connect()、连接池的建连逻辑 | 建连超时、连接被拒绝 |
| 监听队列 backlog | ServerSocket(port, backlog) | 高并发下大量请求建连失败 |
| TIME_WAIT | 主动关闭方,连接池配置 | 本地端口耗尽,无法发起新连接 |
| HTTP Keep-Alive | HttpClient 连接池复用策略 | Connection reset、NoHttpResponseException |
| 字节流无边界 | 自定义协议、Netty 解码器 | 粘包、半包解析失败 |
| 序列化 | JSON / 二进制序列化框架 | 跨版本反序列化失败 |
按这张表往下走,你会发现网络这块其实是 Java 后端知识体系里少有的、每一层都能被观测到的部分。不像 JVM 的某些优化细节黑盒感很强,网络的每一层都有对应的命令、日志、抓包手段能把状态打出来。这也是我愿意花时间整理它的原因:它可验证。
2. 连接的建立:握手、两个队列和 Java 的 accept
大多数人学三次握手,学的是"客户端发 SYN,服务端回 SYN+ACK,客户端回 ACK"。这个描述没错,但它把服务端那一侧简化得太厉害了。真实的服务端不是收到 SYN 就立刻交给应用的,中间有队列,有超时,有溢出。
2.1 服务端其实有两个队列
第一个叫半连接队列(SYN queue)。服务端收到 SYN、回了 SYN+ACK 之后,这个连接还没收到客户端的最终 ACK,处于SYN_RECV状态,就放在这个队列里。第二个叫全连接队列(accept queue)。三次握手完成、连接进入ESTABLISHED之后,连接被放进这个队列,等着应用层调用accept()把它取走。
这两个队列都有容量上限。全连接队列的容量,就是你调用new ServerSocket(port, backlog)时传的那个backlog参数决定的基础值(实际值还会受内核参数影响,取两者较小值)。这里面有个非常容易被忽略的点:如果应用层的 accept 速度跟不上,全连接队列会满,满了之后内核的行为取决于配置,通常是直接丢弃后续的握手完成通知或者回 RST。表现出来就是客户端明明能连上,服务端日志里却没有请求记录。
我在一个并发比较高的服务上见过这个现象:压测的时候客户端报"连接被重置",服务端 CPU 不高、GC 正常、业务日志一条都没有。查了半天发现是这个服务的 accept 线程被某个同步操作卡住了,全连接队列堆满,内核直接丢包。这个问题的排查思路很简单,ss -lnt看Send-Q那一列,如果 Recv-Q 接近 Send-Q,说明队列快满了。
# 查看监听端口的队列堆积情况 ss -lnt # 输出里 Recv-Q 是当前全连接队列长度,Send-Q 是队列上限 # 如果 Recv-Q 长期等于 Send-Q,说明 accept 跟不上用 Netty 的话,对应的是ChannelOption.SO_BACKLOG。默认值在不同版本里不太一样,我在需要扛压的服务上一般会显式设置到 1024 甚至更高,同时把内核的somaxconn调上去,因为实际的队列长度是min(backlog, somaxconn)。如果你只改了代码里的 backlog,没改内核参数,很可能一点效果都没有,这个坑我踩过。
2.2 连接池、TIME_WAIT 和端口耗尽
连接建立之后,真正麻烦的是连接怎么关。
TCP 里主动发起关闭的一方,会进入TIME_WAIT状态,等待 2MSL(一般是 60 秒)才真正释放。这个设计是为了保证最后一个 ACK 能可靠到达,以及让旧连接的残留报文在网络中自然消亡。这个机制本身没问题,问题出在谁来做主动关闭方。
这里有个很多人搞反的地方:短连接场景下,如果服务端配置了主动关闭(比如某些 HTTP 服务器的keepalive_timeout到了就关),那服务端就成了主动关闭方,TIME_WAIT堆积在服务端。服务端端口是固定的(比如 8080),TIME_WAIT堆积不会导致端口耗尽,但会消耗内存和文件描述符。反过来,如果是客户端主动关闭,TIME_WAIT会堆在客户端,这时候才可能出现本地端口耗尽——因为客户端每次发起连接都要用一个临时端口,端口范围通常只有两三万个,如果短时间内大量短连接且都处于TIME_WAIT,端口就不够用了。
# 统计当前 TIME_WAIT 连接数量 ss -tan state time-wait | wc -l # 查看本地端口范围 cat /proc/sys/net/ipv4/ip_local_port_range解决思路有两条。第一条是别用短连接,用连接池复用,这是根本方案。第二条是如果确实需要大量短连接,可以开tcp_tw_reuse(注意是 reuse 不是 recycle,后者在新内核里已经移除了,不要照抄老文章)。但我要提醒一句,tcp_tw_reuse对主动发起连接的场景有效,而且它依赖 TCP 时间戳,对端不支持时间戳的时候不会生效,所以它不是一个万能开关。
Java 里最典型的就是 HTTP 客户端。如果你每次请求都new一个客户端、每次请求完就关掉,那就是标准的短连接,TIME_WAIT会堆在你这一侧。正确做法是全局复用一个客户端实例,内部维护连接池。这个点我在第 3 节还会展开说。
2.3 一个容易漏掉的细节:连接建立失败和连接建立超时不是一回事
ConnectException: Connection refused和SocketTimeoutException: connect timed out,这两个错误在排查时的含义完全不同。
前者说明你的 SYN 到达了目标主机,但目标端口上没有程序在监听,内核直接回了 RST。这通常意味着服务没起来、端口配错了、或者被防火墙明确拒绝了。后者说明 SYN 发出去了,但没有收到任何响应,可能是网络不通、可能是对方全连接队列满了在丢包、也可能是中间设备静默丢弃。一个是有明确回音,一个是石沉大海。
这个区分在排查时的价值很高:看到 refused 就去查服务进程和端口,看到 timeout 就去查网络链路和队列。不要在 timeout 的时候反复重启服务,那基本没用。
3. HTTP 在 Java 代码里的三种超时与连接复用
HTTP 是 Java 后端接触最多的应用层协议,但很多人对它的理解停留在"GET 取数据、POST 提交数据、200 成功、404 找不到"。真正写起代码来,坑全在超时和连接复用上。
3.1 三种超时,名字像但意义完全不同
用任何主流 HTTP 客户端,你都会看到至少三种超时配置,很多人图省事全设成同一个值,这是要出问题的。
连接超时(connect timeout):从发起 TCP 连接到握手完成的最大等待时间。这个值反映的是网络可达性和对方 accept 的速度,通常设得比较短,1 到 3 秒足够。
读超时(read timeout / socket timeout):连接建立之后,等待对方返回数据的最大时间。这个值反映的是对方的业务处理耗时,必须根据接口的实际响应时间来定。你调一个报表导出接口,它本来就要跑 30 秒,你把读超时设成 2 秒,那不是接口有问题,是你配错了。
连接池获取超时(connection request timeout / pool timeout):这个最容易被忽略。连接池里的连接是有限的,如果池子被占满,新请求就得排队等一个空闲连接。这个等待时间也需要上限,否则请求会在这里无限期挂住,而且从日志上看不出任何网络错误,只会表现为"接口很慢"。
// Java 11 自带 HttpClient 的示例,注意 connectTimeout 只是连接超时 HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(2)) // 只覆盖建连阶段 .version(HttpClient.Version.HTTP_2) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://10.0.0.10:8080/api/query")) .timeout(Duration.ofSeconds(5)) // 覆盖整个请求(含等待响应) .GET() .build();提示:不同客户端对这几个超时的覆盖范围定义不完全一致,有的
timeout只管读,有的管整个请求生命周期。换客户端的时候一定要重新确认,别想当然照搬配置。
我自己踩过最疼的一次,是把一个下游接口的读超时设成了和连接超时一样的 1 秒。那个接口平时 200ms 返回,但每天固定时段会跑一个批量任务导致偶尔到 1.5 秒。结果就是每天那个时段有零星失败,量不大,监控上看不出规律,查了快两周才发现是超时值设得太紧。从那以后我给自己定了条规矩:读超时只根据对方 P99 耗时来定,不要拍脑袋。
3.2 Keep-Alive 是默认开启的,但服务端可能先关你
HTTP/1.1 默认就是长连接,Connection 头不写close就表示复用。Java 的客户端库基本都默认维护连接池。这里有个很隐蔽的坑:客户端认为连接可用,但服务端已经把它关了。
想象一下,客户端连接池里有个连接空闲了 30 秒,服务端的 keep-alive 超时是 20 秒,服务端已经主动把这个连接关掉了,发了个 FIN 过来。但客户端这边的连接池只是把连接标记为"空闲",它不一定实时感知到 FIN。等下一个请求复用这个连接时,写数据会成功(因为 TCP 层面还没报错),但读响应的时候就会拿到 RST,抛出Connection reset或者NoHttpResponseException。
这个问题在低流量服务上特别容易出现,因为连接空闲时间长。解决办法有两个方向:一是客户端的空闲连接检测时间要小于服务端的 keep-alive 超时,让客户端主动淘汰旧连接;二是客户端开启请求重试,遇到这类错误自动换个新连接重发。前者是预防,后者是兜底,我一般两个都配。
3.3 重试不是万能的,要卡住幂等这条线
接着上面说重试。很多人一听"加个重试"就无脑加上,结果把一个"扣款"请求重试了三次。
重试的前提是幂等。GET、PUT、DELETE 天然幂等,重试相对安全。POST 就不一定了,尤其是创建订单、发起支付这类操作,重试可能导致重复下单。如果确实要给非幂等接口加重试,得配合幂等键(比如请求带一个唯一 ID,服务端去重)才安全。
还有一个容易忽略的点:重试要有退避。如果下游只是短暂抖动,立刻重试大概率还是失败,还会加重下游压力。我习惯的配置是首次失败后等 100ms,第二次等 300ms,最多重试两次。重试次数不是越多越好,重试 5 次带来的总耗时可能已经超过上游的读超时了,用户看到的是超时,而不是成功。
4. 字节流没有边界:粘包拆包到底在解决什么
这一节是网络知识里对 Java 开发者最有实操价值的部分,因为只要你写过任何自定义协议、或者用过 RPC 框架,就一定绕不开。
4.1 粘包不是 TCP 的 bug
先把一个误解澄清掉:TCP 本身没有"包"的概念,它是字节流。你在应用层调write两次,各写了 100 字节,内核可能合并成一次 200 字节发出去;也可能因为你写得太快或者网络 MTU 限制,被拆成 60 + 140 两次到达。对 TCP 来说,它只保证"这 200 个字节按顺序、不丢失、不重复地到达",至于怎么分段,它不管。
所以"粘包"这个词其实有点误导,准确的说法是应用层没有定义消息边界。接收方拿到一段字节,不知道该从哪里切开,自然解不出消息。这个问题不是协议层的,是应用层的设计问题。
4.2 四种定界方案,各有各的代价
| 方案 | 怎么切分 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定长度 | 每条消息长度固定 | 实现最简单,解析最快 | 消息长度不能变,短消息浪费带宽 | 定长指令、硬件通信 |
| 特殊分隔符 | 用\n或自定义分隔符 | 可读性好,调试方便 | 消息体里不能出现分隔符,需要转义 | 文本协议、日志流 |
| 长度字段 | 报文头里写明正文长度 | 通用性最强,二进制友好 | 需要约定头部格式 | RPC、自定义二进制协议 |
| 自描述格式 | 协议自带结构标识 | 灵活 | 解析复杂度高 | 复杂协议 |
实际工程里,长度字段方案是绝对主流,几乎所有二进制 RPC 协议都是这么干的。它的结构通常是:魔数(4字节) + 版本(1字节) + 序列化方式(1字节) + 消息类型(1字节) + 状态(1字节) + 消息ID(8字节) + 正文长度(4字节) + 正文。前面的头部固定长度,读到头部就能知道正文多长,然后按长度继续读。
4.3 用 Netty 的解码器时,五个参数到底怎么填
Netty 里有个LengthFieldBasedFrameDecoder,专门处理这类协议。它有五个核心参数,我刚用的时候被绕晕过,后来总结了一个笨办法:先画出真实的字节布局,然后逐个参数往上对。
假设我们的协议是这样:
+--------+--------+----------+----------+ | 魔数 2 | 长度 2 | 消息ID 4 | 正文 N | +--------+--------+----------+----------+ ↑ ↑ 0字节 2字节,这个长度值 = 消息ID长度 + 正文长度 = 4 + N那么参数应该是:
new LengthFieldBasedFrameDecoder( 1024 * 1024, // maxFrameLength:整帧最大长度,防攻击 2, // lengthFieldOffset:长度字段从第 2 字节开始(跳过魔数) 2, // lengthFieldLength:长度字段本身占 2 字节 4, // lengthAdjustment:长度值只算了 ID+正文,还差后面的部分 // 这里长度值已经是完整的后半段长度了,所以不调整 6 // initialBytesToStrip:解析后剥掉前面 6 字节(魔数+长度) );我解释一下最容易搞混的lengthAdjustment。它的含义是:从"长度字段结束的位置"到"整帧结束的位置",还差多少字节。如果长度字段的值恰好等于它后面所有内容的长度,那这个参数就是 0。如果长度字段的值包含了它自己(这种情况少见但存在),就要填负数。
你如果懒得算,可以用一个土办法:先把lengthAdjustment设成 0,跑一个用例,看解出来的帧是不是正好。不对就按"实际整帧长度 - 长度字段值 - 长度字段偏移 - 长度字段长度"来算差多少。
注意:
initialBytesToStrip设成 0 的话,解码后的 ByteBuf 里还带着头部,业务 handler 需要自己跳过;设成头部长度的话,业务 handler 拿到的就是纯正文。两种都行,但团队里要统一,别一半人剥一半人不剥。
4.4 半包和粘包是一体两面
拆包的时候还有一个问题:一次read可能只读到了半个消息。比如长度字段说正文有 1000 字节,但这次只到了 300 字节。如果解码器不做处理,直接把 300 字节交给业务,业务就会解析失败。
Netty 的ByteToMessageDecoder机制天然解决了这个问题:如果解码器判断数据不够,就不往out里写东西,Netty 会把剩余数据留在累积缓冲区里,等下一批数据到了再试。所以自己写解码器的时候,判断"数据是否足够"这一步是必须的,不能假设每次都能读到完整消息。这个细节看起来小,但在高并发短连接场景下,出现半包的概率其实不低。
5. 序列化选型:别在所有场合都用 JSON
网络传输的最后一步是把对象变成字节,这一步就是序列化。这块的选择看起来很多,但真正需要考虑的维度其实就几个。
5.1 选序列化方式要衡量的六个维度
体积。JSON 是文本格式,字段名重复传输,体积通常是二进制的 2 到 5 倍。内网带宽不紧张的时候无所谓,跨机房、走公网、或者消息量特别大的时候,体积直接换算成成本和延迟。
速度。序列化和反序列化是 CPU 操作。JSON 解析需要做字符串匹配和类型推断,二进制格式直接按偏移读,速度差一个数量级很常见。如果你的接口 QPS 很高,序列化耗时占比会超出你的预期。
可读性。这是 JSON 最大的优势,抓包、看日志、手动构造请求都方便。排查问题的时候这个优势会被放大很多倍。
跨语言能力。如果服务端是 Java,客户端是别的语言,那序列化格式必须有对应语言的实现。这块是选型时的硬约束。
版本兼容性。接口难免要加字段、改字段。序列化框架能不能在"老版本反序列化新数据"和"新版本反序列化老数据"两个方向上都正常工作,直接决定了你能不能平滑发布。
安全性。有些序列化框架允许在数据里指定类型,反序列化时会去加载这个类,如果不做限制,恶意构造的数据可以触发任意类加载。这类问题在历史上出现过多次,如果你的数据来自不可信来源,不要用这类框架。
5.2 一个实际的选型对照
| 维度 | 文本格式(JSON) | 二进制格式 |
|---|---|---|
| 体积 | 大 | 小 |
| 速度 | 慢 | 快 |
| 可读性 | 好 | 差(需要工具解析) |
| 跨语言 | 好 | 取决于格式,多数良好 |
| 版本兼容 | 天然宽松(缺字段给默认值) | 需要按规则演进 |
| 调试成本 | 低 | 高 |
我的实际选择习惯是:对外 API 用 JSON,对内高频 RPC 用二进制格式,日志和埋点用 JSON。这个组合在大部分项目里都够用。
如果用的是二进制格式,有个规则必须刻在脑子里:字段编号一旦发布,绝对不要复用。你删掉了一个编号为 3 的字段,过段时间又加了个新字段编号 3,那么老版本的服务收到新数据时,会把新字段的值填到老字段上,这种 bug 极难排查,因为它不报错,只是数据莫名其妙不对。正确做法是删掉的编号永久作废,或者用reserved标记住。
另外,不要改字段的类型。把一个 int 字段改成 string,跨版本的时候必然出问题。宁可新加一个字段,也不要改老字段的类型。这是我在做服务拆分时被坑过一次之后定下的死规矩。
6. 线上网络问题怎么查:一条可复用的排查链路
前面讲的都是原理,这一节讲怎么用。我把自己排查网络问题的顺序总结成了一条链路,基本上按这个走能覆盖八成场景。
6.1 第一步永远是分清是哪一类问题
网络问题看起来五花八门,但落到底层其实只有三类:连不上、连上了但没响应、响应了但很慢。这三类的排查路径完全不同,先分类能省掉大量无用功。
连不上:错误信息通常是Connection refused、No route to host、UnknownHostException。前两个查服务进程和网络策略,第三个查 DNS。
连上了没响应:错误信息是各种Timeout、Read timed out、SocketTimeoutException。这时候要判断是对方没处理,还是处理了但响应回不来。抓包能直接看出来——如果看到请求发出去了,但对方一直没回 ACK 或者没回数据,那问题在对端。
响应慢:没有报错,就是慢。这时候要看是不是连接池排队、是不是 DNS 每次都在解析、是不是带宽打满了。
6.2 手里要有的几个工具
| 工具/命令 | 用来干什么 | 典型用法 |
|---|---|---|
ping/telnet | 快速确认基础连通性和端口可达 | telnet 10.0.0.10 8080 |
ss | 看连接状态分布、队列堆积、端口占用 | ss -tanp | grep 8080 |
dig/nslookup | 确认域名解析结果和耗时 | dig api.example.internal |
tcpdump | 抓包看完整交互过程 | tcpdump -i eth0 port 8080 -w a.pcap |
jstack | 看线程卡在哪里 | jstack <pid> | grep -A 20 "http" |
| 应用日志 | 看错误类型、重试次数、耗时分布 | 打点统计 P99 |
这几个工具里,ss和tcpdump是我用得最多的。ss的好处是快,一眼能看出连接状态分布,如果TIME_WAIT特别多,基本能锁定是短连接问题;如果CLOSE_WAIT特别多,基本能锁定是代码没关资源。
6.3 一次完整的排查过程
说一个我印象比较深的案例。某个内部服务调用下游时,每天有几次偶发的超时告警,量很小,一天也就十几次,但一直查不出原因。因为是偶发,各种监控图表上看起来都很正常。
第一步,我先确认了错误类型,日志里是Read timed out,不是连接超时,说明连接建立是正常的,卡在读响应阶段。这就把范围缩小到"对方处理慢"或者"响应回不来"。
第二步,统计了发生的时间分布,发现集中在几个固定时间点,跟整点有点关系。这个信息很有价值,说明大概率不是随机网络抖动,是某个周期性任务导致的。
第三步,我在客户端和服务端同时抓了短时间的包。对比之后发现,客户端发出请求后大概 200ms,服务端就回了响应,但客户端收到响应的时间是 5 秒后。这中间的时间差,说明问题不在服务端,也不在链路上,而是在客户端读取数据这一侧。
第四步,看了线程栈,发现处理响应的线程当时卡在等一个连接池的锁上。顺藤摸瓜找到配置,发现这个客户端的连接池maxConnectionsPerRoute设得很小(默认值往往只有个位数),而那个时间点恰好有个批量任务在并发调用,把池子占满了,其它请求虽然响应已经回来了,但线程排不上队去读。
最后的结论是:表面上是读超时,实际上是连接池太小导致的排队。这是一个非常典型的"现象和根因不在同一层"的例子。如果当时我只盯着"读超时"去调大超时时间,问题会暂时缓解,但根因还在,量一大就会再次爆发。
这个案例给我的教训是:超时类错误不要第一时间去调超时时间。先搞清楚时间花在哪了,是建连、是传输、是对端处理、还是本地排队。调参数是最容易做也最容易掩盖问题的一步。
7. 几组最容易混淆的概念对照
知识点整理到最后,最有用的是把长得很像的东西摆在一起对比。下面这几组,我在面试和实际工作中见过太多次搞混。
7.1 阻塞 / 非阻塞 与 同步 / 异步
这两组词经常被连在一起说,但它们是描述不同维度的东西。
阻塞与非阻塞描述的是"调用发出后,调用方要不要原地等"。阻塞 IO 是调read()之后线程就挂起了,直到有数据。非阻塞 IO 是调read()立刻返回,有数据给数据,没数据给个标志。
同步与异步描述的是"数据拷贝由谁来完成"。同步是指数据从内核缓冲区拷贝到用户缓冲区这一步,需要你的线程参与;异步是指内核把数据准备好并拷贝完之后,再通知你。
Java 传统的InputStream是同步阻塞 IO。NIO 是同步非阻塞(Selector帮你等,但读写还是你自己做,拷贝也在你的线程里)。真正的异步 IO 在 Java 里对应AsynchronousSocketChannel。这个区分在实际写代码时的意义是:NIO 省的是线程等待的时间,不是线程拷贝的时间,所以它不是银弹。
7.2 长连接与连接池
这两个概念经常被混为一谈。长连接说的是连接建立后不立即关闭,保持一段时间;连接池说的是把已经建立的连接管理起来,供多个请求复用。
连接池里的连接一定是长连接,但长连接不一定有连接池。比如你在一个循环里手动new Socket()然后不关,那也是长连接,但那只是连接泄漏。真正的连接池要解决的是:什么时候新建、什么时候淘汰、池满怎么排队、空闲多久回收、坏连接怎么检测。这些问题比"保持连接不关"复杂得多。
7.3 正向排查和反向排查
这个不算网络概念,但我觉得对排查问题很有用。正向排查是从现象推根因,比如看到超时就去查超时链路。反向排查是从假设出发,先假设"如果是我,会在哪一层出错",然后逐层验证。
网络问题的特点是横向层次多,正向排查容易在某一层卡住。我的习惯是两种混着用:先用正向排查快速定位到大致层次,再用反向排查验证假设。比如定位到客户端侧之后,我会假设"是不是连接池的问题",然后去看连接池的指标来验证。有假设的排查比漫无目的地看日志效率高很多。
8. 我在实际项目里踩过的几个网络相关的坑
最后分享几个具体的、从文档里看不到的教训,都是真金白银换来的。
第一个是关于 DNS 的。有个服务依赖一个内部域名,平时都正常,但有一次发版后大量报域名解析失败。查下来发现是 JVM 的 DNS 缓存默认是"永久缓存"(networkaddress.cache.ttl默认在某些配置下是 -1),而那次发版恰好遇到域名指向的 IP 变更,老 IP 缓存住了,新连接全打到已经下线的机器上。解决办法是在启动参数里显式设置缓存时间,比如-Dsun.net.inetaddr.ttl=60。这个坑不在于配置难,在于你平时根本想不到 DNS 还有缓存这回事。
第二个是关于setSoTimeout的。这个参数只对读操作生效,而且它不会关闭连接,只是抛一个SocketTimeoutException。抛出来之后,这个连接的状态是不确定的——可能已经收到了部分数据。如果你捕获异常后直接复用这个连接继续发请求,会出现数据错乱。正确做法是捕获到读超时之后把这个连接标记为不可用,直接废弃,不要试图继续用。
第三个是关于日志里的耗时统计。很多人在请求入口打一个时间戳,出口打一个,相减得到总耗时。但这个数在网络问题排查里常常会误导你,因为它包含了连接池排队的时间、包含了重试的时间。我有一次就是因为这个总耗时,误判成了下游慢,结果查了半天发现时间都花在本地排队上。后来我改成至少分三段打点:拿到连接耗时、发送到收到首字节耗时、读完耗时。分得细了,问题定位快很多。
第四个是关于压测的。压测环境和生产环境的网络拓扑往往不一样,压测时延迟低、带宽足,很多连接复用、超时、重试的问题暴露不出来。如果条件允许,压测最好能跨机房做,或者在压测客户端上加一点人为延迟,模拟真实网络。这个建议听起来有点反直觉(压测不就是追求极限吗),但网络类的 bug 恰恰需要"不完美"的环境才能暴露。
真正扎实的网络功底,不是能背出多少个协议细节,而是看到一条错误日志的时候,脑子里能立刻画出这条请求经过了哪几层、每一层可能出什么问题、下一步该用什么工具去验证。我自己的经验是,每遇到一次网络问题,就把这次的排查路径记下来,几次之后你会发现套路高度重复,那套重复的路径就是你自己的知识体系。这个系列后面还会继续整理 JVM、并发这些主题,思路都是一样的:概念必须能落到代码和现象上,否则背了也没用。