翻一遍最近看到的简历和面试记录会发现一个挺有意思的现象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()、连接池的建连逻辑建连超时、连接被拒绝监听队列 backlogServerSocket(port, backlog)高并发下大量请求建连失败TIME_WAIT主动关闭方连接池配置本地端口耗尽无法发起新连接HTTP Keep-AliveHttpClient 连接池复用策略Connection reset、NoHttpResponseException字节流无边界自定义协议、Netty 解码器粘包、半包解析失败序列化JSON / 二进制序列化框架跨版本反序列化失败按这张表往下走你会发现网络这块其实是 Java 后端知识体系里少有的、每一层都能被观测到的部分。不像 JVM 的某些优化细节黑盒感很强网络的每一层都有对应的命令、日志、抓包手段能把状态打出来。这也是我愿意花时间整理它的原因它可验证。2. 连接的建立握手、两个队列和 Java 的 accept大多数人学三次握手学的是客户端发 SYN服务端回 SYNACK客户端回 ACK。这个描述没错但它把服务端那一侧简化得太厉害了。真实的服务端不是收到 SYN 就立刻交给应用的中间有队列有超时有溢出。2.1 服务端其实有两个队列第一个叫半连接队列SYN queue。服务端收到 SYN、回了 SYNACK 之后这个连接还没收到客户端的最终 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堆积在服务端。服务端端口是固定的比如 8080TIME_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 8080ss看连接状态分布、队列堆积、端口占用ss -tanp | grep 8080dig/nslookup确认域名解析结果和耗时dig api.example.internaltcpdump抓包看完整交互过程tcpdump -i eth0 port 8080 -w a.pcapjstack看线程卡在哪里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.ttl60。这个坑不在于配置难在于你平时根本想不到 DNS 还有缓存这回事。第二个是关于setSoTimeout的。这个参数只对读操作生效而且它不会关闭连接只是抛一个SocketTimeoutException。抛出来之后这个连接的状态是不确定的——可能已经收到了部分数据。如果你捕获异常后直接复用这个连接继续发请求会出现数据错乱。正确做法是捕获到读超时之后把这个连接标记为不可用直接废弃不要试图继续用。第三个是关于日志里的耗时统计。很多人在请求入口打一个时间戳出口打一个相减得到总耗时。但这个数在网络问题排查里常常会误导你因为它包含了连接池排队的时间、包含了重试的时间。我有一次就是因为这个总耗时误判成了下游慢结果查了半天发现时间都花在本地排队上。后来我改成至少分三段打点拿到连接耗时、发送到收到首字节耗时、读完耗时。分得细了问题定位快很多。第四个是关于压测的。压测环境和生产环境的网络拓扑往往不一样压测时延迟低、带宽足很多连接复用、超时、重试的问题暴露不出来。如果条件允许压测最好能跨机房做或者在压测客户端上加一点人为延迟模拟真实网络。这个建议听起来有点反直觉压测不就是追求极限吗但网络类的 bug 恰恰需要不完美的环境才能暴露。真正扎实的网络功底不是能背出多少个协议细节而是看到一条错误日志的时候脑子里能立刻画出这条请求经过了哪几层、每一层可能出什么问题、下一步该用什么工具去验证。我自己的经验是每遇到一次网络问题就把这次的排查路径记下来几次之后你会发现套路高度重复那套重复的路径就是你自己的知识体系。这个系列后面还会继续整理 JVM、并发这些主题思路都是一样的概念必须能落到代码和现象上否则背了也没用。