做Android开发做到两三年之后,很多人会碰到一个说不清道不明的瓶颈:业务代码写起来很顺,可是一遇到网络问题就发怵。为什么连接超时之后不能直接重试?为什么同一个接口在WiFi下表现正常,一切到移动网络就频繁卡顿?为什么服务端说“明明返回了”,App这边却一直报解析异常?这些问题的根因,基本都能回溯到计算机网络这门“基础课”上。这篇东西是我完整一轮Android计算机网络实战学习之后沉淀下来的总结,原原本本记录了从TCP/IP协议栈、HTTP/HTTPS原理出发,到亲手写一个轻量级网络库,再到对照OkHttp/Retrofit做细节优化,最后收敛到弱网调试和线上问题排查的全过程。无论你是刚想把网络短板补齐的初级开发者,还是已经写过几个网络封装类、但没系统梳理过网络体系的中级Android开发,这篇内容都可以当成一份按图索骥的参考。
1. 为什么我在Android进阶计划里专门加了一轮网络实战
1.1 一个线上问题让我把计算机网络重新捡起来
让我真正下定决心补网络课的,是一次不算特别复杂的线上反馈。用户说App首页转圈时间特别长,有时候甚至要等十几秒才出内容。当时先怀疑是不是服务端响应慢,结果后端同学拉日志告诉我接口本身耗时只有几十毫秒。又怀疑是不是CDN出问题,排查了一圈也没有结论。最后抓包看时间线才发现,域名解析阶段消耗掉了大部分时间——DNS服务器那边返回很慢,客户端自身又没有做合理的缓存,等于每次冷启动后头几个请求都卡在“从域名到IP”这一步。
那次之后我就意识到,自己在学校学过的计算机网络知识基本已经还给老师了。什么三次握手、拥塞窗口、TLS协商,背题的时候倒背如流,真到线上定位问题时完全串不起来。原因是当初纯粹是“为了考试而学”,没有把这些协议放到Android的真实运行环境里去理解。移动端的网络环境远比PC复杂:网络切换频繁、RTT普遍更高、弱网场景到处都是,同样是超时,在不同阶段发生,处理方式可能完全相反。
所以这一轮学习我给自己定的目标是:不看那种几百页的理论教材,而是拿“一个Android请求从发出到收到响应”这条完整链路做主线,把每一个环节上涉及的网络协议、代码配置、真实坑点全部过一遍。这条链路走通之后,再遇到什么奇奇怪怪的网络问题,脑子里至少有一张地图,知道该往哪个方向排查。
1.2 学习范围怎么划:不是把教材背一遍,而是围着请求链路走一遍
计算机网络的教材体系非常庞大,从物理层的调制解调,到数据链路层的MAC地址,再到路由器的各种选路协议,如果照着课本一章章啃,没个半年下不来,而且大部分内容对做应用层开发的Android工程师来说,是一辈子也用不上的“冷知识”。
我的做法是站在一个普通请求的视角去划范围。一个请求在Android端会经过这些环节:应用层构造HTTP报文 -> DNS解析域名 -> 建立TCP连接(遇到HTTPS还要先做TLS握手)-> 发送请求数据 -> 等待服务端响应 -> 读取并解析响应体。这条链路上每一环,都需要理解它为什么这么设计,以及在Android端有没有对应的API可以控制和优化。
那些“请求发出之后、数据在骨干网里怎么被路由器转发”的细节,我选择只做科普级了解,不在上面花时间。因为Android开发者能够影响的边界,基本到“发起连接”和“管理连接”为止。反过来,一旦边界画清楚了,很多概念就自动定位了:例如HTTP报文结构影响你怎么封装参数、TCP连接复用影响你怎么设计连接池、TLS握手影响你对HTTPS性能的预期。这轮学习结束之后我自己最大的收获不是背下了多少个协议细节,而是建立了一套“请求链路地图”,后续的所有代码和排查,都是在这张地图上做文章。
2. 先把这几块网络基础吃透,后面的实战才不会懵
2.1 四层模型看移动端:再复杂的协议也离不开分层的思路
谈TCP/IP,必须先谈分层。大学课本喜欢把OSI七层模型挂在嘴边,但实际互联网跑的是TCP/IP四层模型:应用层、传输层、网络层、链路层。Android开发打交道最多的是应用层和传输层,网络层和链路层更多由系统、路由器去处理,我们只需要知道它们大致做了什么。
我用一个日常点外卖的流程来解释四层模型,特别容易记。应用层相当于你拿着菜单点菜,HTTP协议负责把“我要一份红烧肉、不要辣”这个诉求按约定格式写好;传输层相当于外卖平台的派单系统,它保证这份订单有没有被商家收到、有没有被骑手取走,对应到TCP的确认和重传机制;网络层相当于路网规划,IP协议决定了这份订单从哪个区域调度、走哪条主干道;链路层则是实际执行“骑车穿街过巷”的过程,对应到物理链路上的帧传输。
Android端有一个容易被忽略的事实:链路层会因为WiFi和蜂窝网络切换而发生彻底变化。你可能在同一个App页面里,前一秒请求还走在WiFi的链路上,后一秒系统自动切到4G/5G,之前建立的TCP连接已经失效了。这种场景下,应用层看到的现象就是“连接被重置”或“请求超时”。如果理解了分层,你就明白这不是服务端的问题,而是链路层状态变了,需要做的是监听网络切换并进行连接重建。
2.2 TCP握手、连接池与拥塞控制:慢的原因往往藏在这些细节里
TCP是传输层最核心的协议,它提供的可靠性建立在确认、重传、排序等一系列机制之上。建立连接需要三次握手,释放连接需要四次挥手。握手过程简单说就是客户端先问“在吗”,服务端回“在的,你能听清吗”,客户端再回“能听清,我们开始吧”。这三次对话在网络上各需要一个往返时间,也就是3个RTT的一部分(实际上三次握手在前两次完成时,第三个ACK可以和首个请求数据一起发出,但TLS握手还需要额外RTT)。
移动网络下的RTT通常在50到200毫秒之间,相比局域网内小于5毫秒的RTT要慢一个数量级。所以一次全新HTTPS请求,在移动网络上光是建立TCP连接加上TLS协商,就可能消耗掉300毫秒以上,这还不包括DNS解析和真正传数据的时间。这就是为什么HTTP连接池如此重要——复用已经建立好的连接,可以省掉每次握手和TLS协商的开销。
除了握手,TCP还有一个直接影响移动端体验的机制:拥塞控制。TCP发送方会维护一个拥塞窗口,刚建立连接时窗口很小,然后以“慢启动”的方式逐步扩大发送量。如果在弱网环境下发生丢包,拥塞窗口会急剧收缩,发送速率骤降。我见过不少同学在弱网测试时发现“请求变慢了”,第一反应是去调大超时时间,但实际上丢包率不变,调大超时只是让失败变得更慢,正确的思路是控制并发请求数、压缩请求体、以及做好请求的优先级调度。
2.3 HTTP/1.1、HTTP/2与HTTP/3:每个版本更迭都在解决一类真实问题
HTTP协议一路演进,本质上都是在解决“多个资源如何更快更高效地到达客户端”的问题。HTTP/1.1时代,一个TCP连接同一时间只能处理一个请求,而且后面的请求要等前面的响应返回才能发送,这就是经典的队头阻塞。那时候浏览器的应对策略是同时开6个连接,但连接数量终归有限,每个连接又要承担握手开销。
HTTP/2的核心改进是把所有请求和响应都切成二进制帧,在一个TCP连接上通过“流”并发传输。帧与帧之间可以交错,理论上不再需要排队等前面的响应。但这里还有一个隐藏问题:虽然应用层的队头阻塞解决了,TCP层的丢包重传仍旧会阻塞整条连接,一个包丢了,后面所有已到的数据也得等重传完成才能继续处理。
HTTP/3干脆把传输层换成了基于UDP的QUIC协议,在用户态实现了可靠传输和多路复用,每个流之间互相独立,一个流丢包只影响自己,不再拖累其他流。Android端在OkHttp 4.x上可以通过配置Protocol列表启用HTTP/2,HTTP/3的支持则依赖底层实现和系统能力,目前生产环境大规模使用的还不算多。作为移动端开发,理解这些版本差异的意义在于:当你在线上看到“并发明明不高但响应就是慢”的时候,要能想到HTTP协议版本对连接复用的影响,而不是盲目加服务器资源。
2.4 HTTPS握手与证书校验:加密不是后端单方面的工作
很多人觉得HTTPS是后端的事情,App端只要把URL里的http换成https就行。实际上TLS握手的完整性和证书校验,客户端是绝对的主角之一。
TLS握手的过程大致是:客户端先发ClientHello,告诉服务端自己支持的TLS版本和加密套件;服务端回ServerHello,选好加密套件,同时下发自己的证书;客户端验证证书是否可信,然后双方通过密钥交换算法协商出会话密钥,之后的所有数据都用会话密钥加密传输。整个过程通常需要2个RTT,这也是“HTTPS比HTTP慢”的主要来源。
Android端最容易踩的坑是自签名证书。开发阶段连的测试环境经常是自签名证书,有些同学图省事,直接写一个信任所有证书的X509TrustManager,结果App成功连上测试环境,但线上也失去了证书校验能力,等于HTTPS白做了。我见过某公司一整个版本的App都带着这种“万能信任”的代码上线,安全性形同虚设。正确做法是开发阶段只在调试包里放开证书校验,或者只对测试域名做特殊信任,发布包必须走系统标准校验。进阶一点的做法是用CertificatePinner固定服务端的证书公钥,就算系统信任链被突破,攻击者也无法用其他证书冒充服务端。
从实战角度看,理解TLS握手还有一个实际收益:你能正确判断耗时分布。用抓包工具看一条HTTPS请求的时间线,如果发现secureConnect阶段占了很大比重,那问题大概率在TLS配置或网络RTT上,而不是服务端接口慢。
3. 手写一个轻量级Android网络库:把原理落进代码
3.1 网络库的分层设计:先画清楚边界再动手写
纸上谈兵终觉浅,这一轮学习最有收获的部分,是我真的花了几天时间手写了一个轻量级网络库。当然我没有打算跟OkHttp叫板,目标很单纯:把前面学的协议知识落到代码里去,看看一个网络请求从调用到回调,内部究竟要经过哪些环节。
写之前我画了一个简单的分层图。最上层是API接口层,对外提供get、post这类方法,调用方只需要传URL、参数、回调;中间是请求调度层,负责维护线程池和请求队列,控制并发数,决定一个请求什么时候真正开始执行;再往下是协议处理层,负责把请求参数拼成符合HTTP规范的报文,并解析响应数据;最底层是连接管理层,管Socket连接的创建、复用和销毁,同时负责超时控制。
这么一分层,边界清楚了很多。API层不做任何网络操作,调度层不知道自己发的是HTTP还是HTTPS,协议层不关心数据走哪个Socket,连接层不感知业务。这套思路和OkHttp做的基本同构。我原来以为“手写网络库”是一个练代码能力的事,做完了才发现真正能让你技术成长的,是画好这些边界之后,再往每一层里填细节。
3.2 请求调度:线程池、队列与并发限制
网络请求天然不能跑在主线程,所以线程池和调度策略是第一关。Android上有多种线程池选择,但我并没有直接用ExecutorService现成的新建线程池,而是仿照OkHttp的Dispatcher思路,设计了两层限制:全库最大并发数,以及同一域名下的最大并发数。
为什么要有域名级别的限制?因为HTTP/1.1时代同一个域名能有效利用的连接数量有限,如果无脑并发20个请求到同一域名,TCP连接会互相争抢带宽,整体吞吐反而下降。实现上我用了一个阻塞队列保存等待中的任务,用计数器控制并发,每当一个请求结束,就激活下一个任务。关键代码不算长,但线程安全细节不少。比如计数器的增减必须和任务完成状态严格同步,不然活跃连接数会出现“幽灵值”,导致任务队列永远等不到新的许可。
class RequestDispatcher( private val maxRequests: Int = 64, private val maxRequestsPerHost: Int = 5 ) { private val executor = ThreadPoolExecutor( 4, maxRequests, 60, TimeUnit.SECONDS, ArrayBlockingQueue(maxRequests * 2) ) fun enqueue(call: Call) { executor.execute { // 真正的执行逻辑:创建Socket、发送报文、读取响应 } } }我在调度这一层还做了一件事:请求优先级。头像加载、列表首屏请求这种用户能感知的请求应该优先于埋点上报这类后台任务。优先级的实现没有用复杂的优先级队列,而是把任务分成两个队列,高优先级队列永远先取。这个设计后来在弱网场景下效果非常明显,首屏关键请求不再被一堆后台请求堵住。
3.3 连接管理、重试与超时:把异常控制交给框架
连接管理是网络库的核心难点。我实现的连接池原理不算复杂:用一个Map保存“host+port”到连接集合的映射,发请求时先从池里取一个空闲连接,用完放回去,超过闲置时间的连接定期清理。真正难的是处理“连接已失效但池子不知道”的情况。最经典的场景是服务端因为空闲超时主动断开了连接,客户端Socket还在池里,下次拿它发请求时,第一次write可能成功了,但read会立刻收到EOF。
这个问题我在手写网络库时用了一个粗暴但有效的方案:从池里取出连接后,先判断Socket的isConnected和isClosed状态,再把读超时临时设得很短(比如50毫秒)探测一下。但这个方案有副作用,正常请求有可能被误判成失效连接。后来我看了OkHttp的实现,发现它处理得更聪明,它会把连接建立时间、最近一次使用时间都记下来,在真正发请求前做一层“复用检查”,同时把异常情况通过重试机制兜住:如果一个连接在写请求之前就失败了,就换池里下一个连接再试一次;如果请求已经发出去了才失败,那就不能盲目重试,因为服务端可能已经执行了请求,重试可能造成重复写入。
重试和超时是紧密相关的。我的网络库里有三组超时参数:连接超时、读超时、写超时。连接超时覆盖DNS解析和TCP握手阶段,读超时是发完请求后等待响应头的最长时间,写超时是发送请求体的最长时间。三者的默认值完全不该一样,我见过有些项目把三个值都设成一样的20秒,结果连接都建立不了时,光等DNS解析就能卡住20秒,用户体验极差。合理做法是连接超时较短(比如5-10秒),读超时看业务而定(列表请求可以15秒,上传大文件要更长),写超时建议不低于连接超时。
4. 打开OkHttp和Retrofit的联动链路:实战库背后在做什么
4.1 拦截器链:一次请求依次穿过哪些关卡
手写轮子是为了理解原理,生产环境我仍然推荐直接用OkHttp。它最漂亮的设计就是拦截器链模式。我第一次看OkHttp源码时觉得链式调用有点绕,把它拆开之后就明白了:一次请求会被一层一层拦截器依次处理,每个拦截器决定是否继续往下传递,拿到响应后再一层层返回。
拦截器分为两类。Application Interceptor(应用拦截器)在链条最外层,它能看到用户最原始的请求和最终响应,但看不到重试、重定向等内部过程;Network Interceptor(网络拦截器)在内层,它能看到实际发出去的请求和实际收到的响应,包括重试和重定向后的结果。两个拦截器经常被混淆,抓包时也只认网络拦截器的日志才准确,因为应用拦截器看到的请求可能是“计划中”的请求,和实际线上发的不是同一个。
自定义拦截器的写法也很直接,实现Interceptor接口,在intercept方法里拿到chain,调用chain.proceed(request)继续执行。一个常见需求是全局日志打印:
class LoggingInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val start = System.currentTimeMillis() Log.d("NetLib", "--> ${request.method} ${request.url}") val response = chain.proceed(request) Log.d("NetLib", "<-- ${response.code} ${request.url} 耗时 ${System.currentTimeMillis() - start}ms") return response } }注意这里的响应必须原样返回,不能提前吃掉,否则上层就收不到完整数据了。如果你需要打印响应体,最好使用response.peekBody()而不是直接读body,因为body只能被消费一次。
4.2 超时、重试与幂等:参数不能随便调
不少项目直接给OkHttp设置一个全局超时时间,然后线上出了问题就把它调大,这是最典型的“治标不治本”。OkHttp的connectTimeout、readTimeout、writeTimeout三个参数也需要分别设置,前面手写网络库时提到的原则在这里同样适用。我见过一个后台日志系统,全局readTimeout设成了30秒,实际服务端响应只要2秒,但在弱网环境下,网络抖动很容易把2秒的请求拖成10秒,而30秒的超时又让失败反馈变得极慢,用户早就放弃操作了。
更关键的是重试策略。OkHttp默认在连接失败时会自动重试一次,前提是请求可以被安全重试。它内部会根据请求方法、是否已发送请求体等信息做判断。如果是POST请求且请求体已经写入,再重试就可能产生重复数据。自己实现RetryInterceptor时,一定要把幂等性放在第一位。什么时候可以无脑重试?GET请求、请求体为空的POST、以及服务端明确做了幂等处理的接口。其余情况要么不重试,要么提示用户手动重试。
class RetryInterceptor( private val maxRetryTimes: Int = 2 ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { var retryCount = 0 while (true) { try { return chain.proceed(chain.request()) } catch (e: IOException) { if (retryCount >= maxRetryTimes) { throw e } retryCount++ val delayMillis = 300L * (1 shl retryCount) // 300ms、600ms、1200ms指数退避 Thread.sleep(delayMillis) } } } }指数退避是重试策略里值得记住的技巧。固定间隔的重试会在同一时刻集中撞击服务端,指数退避则让后续重试的间隔逐次放大,能给服务端留出恢复时间。我在线上遇到过服务端发布重启时客户端大量瞬间重试,用指数退避之后,恢复期间的重试请求数量明显陡降。
4.3 自定义DNS与其他网络优化点
OkHttp还有一个被很多人忽略的可扩展点:Dns接口。默认情况下,域名解析走系统的netd,也就是底层DNS服务器解析,整个过程对开发者来说是黑盒。但在实际项目中我发现,系统DNS的缓存时间不一定适合你的业务。有的域名IP变更频繁,系统却缓存了很长时间,客户端一直连到旧IP上;有的域名系统不缓存,每次都触发完整解析,平白多了几十毫秒。
通过自定义Dns实现,可以控制解析策略和缓存策略。OkHttp的Dns接口只需要实现一个lookup方法,返回IP地址列表。比较简单的优化是给解析结果加一层自己的LRU缓存,并设置合理的TTL。更进一步的做法是做HTTPDNS,绕过系统DNS的UDP解析,直接通过HTTP请求向专门的解析服务获取IP列表,好处是可以自己控制缓存、调度和故障切换。我自己实现过一版简单的HTTPDNS接入,复杂度确实比默认DNS高不少,但带来的收益在弱网和网络切换频繁的场景下非常可观。
class CustomDns(private val delegate: Dns = Dns.SYSTEM) : Dns { override fun lookup(hostname: String): List<InetAddress> { val cacheKey = hostname cache[cacheKey]?.let { return it } val addresses = delegate.lookup(hostname) cache[cacheKey] = addresses return addresses } }这里有个小坑:自定义Dns里不要做过于耗时的同步操作,因为它在请求的关键路径上,阻塞太久会直接把连接建立时间拖慢。解析结果放到子线程处理或者提前预热会更稳。
5. 调试与排查实录:这些坑我都替你踩过
5.1 弱网环境:不是把超时调大就完事
弱网是移动端特有的排查场景,PC上很难复现,但又真实存在。我踩过的第一个坑是混淆了“连接超时”和“读超时”。弱网环境下,TCP握手阶段就可能失败,抛出的是ConnectException;连接建立成功但服务端响应迟迟不来,抛出的才是SocketTimeoutException。两种异常的处理策略完全不同:前者可以安全重试,后者需要看业务场景,盲目重试大概率只是浪费一次资源。
我在做弱网优化时养成了一个习惯:抓包工具的限速功能一定要会用。把网络限速到几十KB/s并加入丢包模拟后,很多平时测不出来的问题就现形了。最常见的问题是:请求体或响应体稍微大一点,整个请求的耗时就成倍增长;图片列表页在弱网下因为并发请求太多,把带宽全部抢走,首屏关键请求反而被挤到后面。我优化时把图片请求和接口请求拆成了两个调度池,并给接口请求更高的优先级,弱网下的首屏速度提升非常明显。
还有一类问题容易被归因成“手机不行”:网络切换。从WiFi切到蜂窝网络时,原来基于WiFi的TCP连接不能继续使用,如果客户端没有监听ConnectivityManager的ACTION_CONNECTIVITY_CHANGED广播并重建连接池,就会出现“用户走出办公室就没网”的假象。实际上网络是通的,失效的只是旧连接。处理方式很简单:网络类型变化时清空连接池,让后续请求重新建立连接。
5.2 前后端联调中的协议细节坑
联调阶段最能暴露协议理解的不足。第一个经典坑是Content-Length不一致。服务端响应的Header里写的长度比实际响应体长,或者短,客户端在读取body时会报EOFException或者一直等不到数据。我跟某个后端联调时遇到过一种隐蔽情况:后端服务的框架在网关层做了压缩,但Content-Length还是压缩前的大小,导致OkHttp读取到一半就结束。定位方式很简单,抓包看原始响应,对比Header里的Content-Length和实际body字节数。
第二个坑是响应体的字符集。大部分时候Retrofit默认按UTF-8解析,但如果服务端返回的Content-Type里没有显式声明charset,同时实际用的又不是UTF-8,就会出现中文乱码。这个问题在JSON返回时少见,但在一些老接口返回纯文本或XML时会遇到。正确的修法是让后端在Response Header里明确声明charset,客户端不要自己瞎猜。
第三个坑是报文格式的包容性。HTTP Header的字段名不分大小写,但有些后端框架返回的Header名是大写开头(例如Content-Type),有些是小写(例如content-type),兼容的HTTP解析器应该统一处理。OkHttp内部做了归一化,而我手写的网络库里一开始对Header做了严格的Map匹配,结果部分接口解析不出Content-Type导致响应体被当成未知编码,后来改成忽略大小写才解决问题。这个细节虽然小,但暴露了我对HTTP协议“字段名不区分大小写”这一点的轻视。
5.3 本地抓包与全链路耗时分析
调试HTTP/HTTPS请求时,抓包是效率最高的手段。电脑端抓包工具可以查看完整的请求头、响应头和body,还能模拟各种网络状态。在Android开发中最常用的方式是让手机连接同一个局域网,然后把手机的HTTP代理指向电脑上抓包工具的监听端口。抓包HTTPS需要先在手机上安装抓包工具的CA根证书,这一步在调试机上做就可以了。
但抓包工具不是万能的,遇到客户端不走系统代理的情况就抓不到,而且部分坏运营商网络环境还会干扰代理端口。这时候另一个方案就很有用:直接在OkHttp里挂EventListener,把整个请求的生命周期事件打印出来。EventListener能拿到dnsStart、dnsEnd、connectStart、connectEnd、secureConnectStart、secureConnectEnd、requestHeadersStart、requestHeadersEnd这些阶段,配合时间戳就能画出一条完整的请求时间轴。
class TimelineListener : EventListener() { override fun dnsStart(call: Call, domainName: String) { logTime("dnsStart: $domainName") } override fun dnsEnd(call: Call, domainName: String, inetAddressList: List<InetAddress>) { logTime("dnsEnd: ${inetAddressList.joinToString()}") } override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) { logTime("connectStart: $inetSocketAddress") } override fun secureConnectEnd(call: Call) { logTime("secureConnectEnd") } }这个方法让我养成了一个非常好的排查习惯:遇到网络慢,先看时间轴,哪一个阶段占比最大就盯哪一个。曾经有个接口服务端响应只要30毫秒,但客户端每次都卡,时间轴显示dnsStart到dnsEnd之间的空挡接近300毫秒,定位到是自定义DNS的缓存逻辑里加了一把全局锁,所有域的解析请求在排队。这种问题靠抓包工具根本看不出来,只有EventListener能定位到准确环节。
使用EventListener要注意别在回调里做耗时操作,因为它运行在OkHttp的调度线程上,阻塞时间长了会影响整个请求池的效率。我一般是把事件和纳秒时间戳记录到内存队列里,请求结束后再统一输出。
最后聊几点实在的心得
这轮网络实战学习下来,我个人最大的感受是:计算机网络这门课并不是背完就扔的纸面理论,它更像一张拓扑地图。知道地图上有哪些节点、每个节点负责什么、节点之间怎么连接,遇到故障时才能按图索骥找到断点。
如果让我给同样想补网络的Android开发者一个建议,我会说:不要急着把《TCP/IP详解》啃完,而是先在电脑上装一个抓包工具,随便打开一个App,完整看一次某个接口从DNS解析到响应返回的原始过程。看多了之后再去学协议细节,你会发现每个字段、每个标志位都有它存在的现实理由。
还有一个很实用的小技巧:给自己布置一个“把所有请求的时间线画出来”的小任务,对请求做一次静态体检——DNS解析耗时多少、TCP连接耗时多少、TLS握手耗时多少、服务端响应用了多少。我每到一个新项目都会做这件事,往往能发现一些“别人觉得正常”但实际可以优化的网络路径。计算机网络不是一锤子买卖,它是一套越用越顺手的地图工具,平常多画几遍,线上才不会迷路。