那段时间,高德APP的投诉量突然飙升,原因集中在几个看似不相关的场景:导航过程中路线加载转圈、隧道里路况刷新卡死、地下停车场出口迟迟等不到定位恢复。后台一查,问题根本不在地图引擎,而是网络层——DNS解析超时、TCP连接反复失败、弱网下的重试策略非但没有救回请求,反而把用户通道彻底堵死。于是我们下决心对高德APP的网络链路做一次系统性改造,方向就是从DNS解析到弱网传输的全链路优化。
这篇文章想把这套优化实践完整复盘出来,主要包括:如何建立可量化的网络指标体系、DNS环节从LocalDNS迁移到HTTPDNS的落地细节、连接与传输层怎么把成本压到最低、弱网下的超时与重试策略怎么定、弱网测试如何建模与复现,以及上线前必须做好的监控验证。负责移动端网络优化的同学、做地图或出行类业务的开发者,以及所有被线上网络问题折腾过的人,应该都能从中找到可复用的思路。
1. 网络优化的起点:先建立能度量一切的指标体系
1.1 一次线上事故暴露的度量真空
刚开始我们并没有直接动代码,而是先做了一次全面的线上请求耗时拆解。结果很尴尬:监控平台上只有“请求失败率”和“平均耗时”两个指标,真出了线上问题,连“慢在哪一段”都说不清。
记得某个一线城市的晚高峰,路况接口超时率突然从1%飙到15%。服务端同事说网关一切正常,客户端同学说代码没发新版,两边拿着各自的监控面板对质,谁也没法证明问题出在哪。最后从几十个用户日志里手工翻,才发现大量请求卡在“TCP连接建立”阶段,压根没到服务端。原因其实是那个区域基站拥塞,新建连接全部超时。这次事故之后,我们想明白了一件事:网络优化第一优先级不是改协议,而是先把监控指标建起来,否则后续所有优化都是在黑暗中开枪。
1.2 把一次网络请求拆成六个阶段
移动端一次HTTPS请求,从用户点击到页面呈现,中间要经历六个关键阶段:
| 阶段 | 定义 | 常见耗时区间 | 主要优化手段 |
|---|---|---|---|
| DNS解析 | 域名解析为IP地址 | 20-200ms | HTTPDNS、预解析 |
| TCP建连 | 三次握手 | 1个RTT | 连接复用、连接池 |
| TLS握手 | HTTPS安全握手 | 1-2个RTT | TLS1.3、会话恢复 |
| 请求上行 | 客户端发送请求体 | 与上行带宽相关 | 请求瘦身、压缩 |
| TTFB | 等待服务端返回首字节 | 50-500ms | 服务端优化、就近接入 |
| 内容下载 | 接收响应体 | 与包大小和带宽相关 | 压缩、缓存、增量更新 |
为什么要拆这么细?因为网络优化最大的风险是“瞎猜”。平均耗时下降,不代表用户感知变好,必须同时盯P50和P95。P50代表大多数用户的体验,P95代表最差情况的天花板。弱网场景下,少量极端耗时会把平均值拉得很高,但真正影响口碑的是P95那部分用户。把六个阶段拆开之后,每个阶段都有独立的优化手段,排查问题时也能直接定位到具体环节。
1.3 北极星指标与优化基线
拆解完阶段后,我们定了四个北极星指标:首包成功率、首屏耗时、网络错误率、弱网请求占比。这四个指标不能只看整体均值,必须按网络类型(Wi-Fi/4G/5G)、运营商、区域维度分别统计。
指标的基线不是拍脑袋定的,而是采集了至少两周的全量上报数据。这里有个经验:全量上报很耗流量和电量,我们最终采取“慢请求必报 + 正常请求随机采样10%”的策略。所谓慢请求,是指耗时超过1秒或失败的网络请求,这类数据本身就有排查价值,全部保留;正常请求按10%采样,足以估算大盘趋势,又不会给用户带来额外负担。
基线数据出来之后,团队内部达成了共识:DNS解析阶段P95要降到50ms以内,首包成功率要从99.0%提升到99.5%以上。有了清晰目标,后面的优化才有的放矢。
2. DNS环节:从LocalDNS到HTTPDNS的改造与取舍
2.1 传统DNS在移动端为什么又慢又不稳
本地DNS(运营商LocalDNS)的解析过程,是从客户端发一个UDP请求到运营商递归服务器,递归服务器如果没有缓存,还要去根服务器、顶级域服务器、权威服务器逐级迭代查询。整个过程在移动网络下很容易达到上百毫秒,遇到弱网直接超时也不罕见。
传统DNS在移动端有三个老问题:
- 慢。移动网络链路本来就长,加上运营商递归服务器的策略差异,解析耗时波动很大。高德APP域名多,冷启动阶段涉及路线、路况、检索等多个域名,每个都走一遍本地DNS,几百毫秒就没了。
- 容易被干扰。公共Wi-Fi环境或者运营商链路上,DNS响应可能被篡改,返回一个错误IP,轻则请求失败,重则被导到无关页面。
- 调度不精准。运营商LocalDNS通常没有按用户真实地理位置返回就近节点的能力。我们实测过,有南方城市的用户被解析到华北的节点,地图瓦片加载相当于跨省传输,速度自然感人。
2.2 HTTPDNS的原理与落地细节
HTTPDNS的思路很直接:客户端不再走本地UDP 53端口,而是通过HTTP接口向自建或第三方DNS服务器请求域名对应的IP列表。返回结果一般是JSON,里面带IP列表、TTL、运营商线路等信息。
核心接入逻辑可以简化成下面的伪代码:
class HttpDnsManager( private val dnsServer: String ) { private val cache = ConcurrentHashMap<String, DnsRecord>() suspend fun getIp(host: String): String? { // 先读本地缓存,命中且未过期直接返回 cache[host]?.let { record -> if (!record.isExpired()) return record.ips.first() } return refresh(host) } private suspend fun refresh(host: String): String? { return try { // 向HTTPDNS服务端发起解析请求 val resp = httpClient.get("$dnsServer/dns?host=$host") val record = parse(resp.body()) // 缓存时间不能完全信任服务端返回的TTL, // 客户端要设置一个最大上限 cache[host] = record record.ips.first() } catch (e: Exception) { // 解析失败返回null,由调用方降级到系统DNS null } } }接入HTTPDNS有几个坑必须提前处理。
第一个坑是缓存策略。服务端返回的TTL哪怕只有30秒,在高频请求下也会产生大量重复解析。我们的做法是:基础TTL缓存,同时叠加一个客户端最大缓存上限,比如10分钟;冷启动时主动刷新一次核心域名,请求失败时再主动老化一次。
第二个坑是降级容灾。HTTPDNS服务也可能挂,客户端到HTTPDNS服务器的网络也可能断。必须保证HTTPDNS不可用时可以快速回退到LocalDNS,不能让DNS解析成为单点。我们当时的降级策略是:HTTPDNS解析超时设为1.5秒,超时或者HTTP状态码非200时,立刻切回系统默认解析。
第三个坑是IP直连后的Host头处理。拿到IP后,请求URL里的域名要替换成IP,但HTTP请求头里的Host字段必须保留原域名,否则服务端无法识别虚拟主机,HTTPS证书校验也会失败。
2.3 域名分级与预解析策略
高德APP的域名有好几十个,不可能全部走HTTPDNS,那样既耗流量又可能拖慢启动。我们把域名按重要程度分成了三类:
- 核心请求域名:路线规划、路况、搜索、导航,必须走HTTPDNS,要求解析结果稳定可靠。
- 中等重要域名:图片、日志上报、配置下发,走LocalDNS加短缓存即可,偶尔解析慢不影响主流程。
- 低优先级域名:活动页埋点、运营数据统计之类的请求,晚个几百毫秒无所谓,用系统默认解析就行。
预解析的触发时机也有讲究。我们最终只在三个位置做预解析:APP冷启动后马上预解析首屏需要的核心域名;用户发起导航之前预解析路线沿途可能要访问的服务域名;网络状态从不可用恢复为可用时,重新预解析一遍核心域名。预解析如果放错位置,比如启动时把所有域名全解析一遍,在弱网下只会让网络链路雪上加霜。
3. 连接与传输层:把每一次请求的成本压到最低
3.1 一次HTTPS请求的握手成本不容小觑
DNS解析完成后,紧接着就是TCP建连和TLS握手。TCP三次握手大约消耗1个RTT,TLS1.2握手要2个RTT,加一起就是3个RTT。在信号良好的Wi-Fi下,RTT大概是10到20毫秒,影响不大;但在4G弱网环境下,RTT能到100毫秒以上,光建连接就要300多毫秒。服务端哪怕只处理50毫秒,用户感知的首包时间也已经接近400毫秒。
如果每个请求都新建连接,这成本完全不可接受。所以连接复用在整条优化链路里优先级非常高。
3.2 连接池、Keep-Alive与HTTP/2多路复用
移动端的连接池和服务端不太一样。服务端连接池只需要按目标地址维护,移动端还要考虑网络类型切换。Wi-Fi和4G切换后,旧连接基本失效,如果直接从连接池里拿出来用,大概率超时。我们的连接池按“目标域名 + 当前网络类型 + 是否需要代理”三个维度做Key,网络切换时主动清理旧条目,避免拿到死连接。
在此基础上,我们把基础网络库全面切到HTTP/2。HTTP/2的多路复用允许一个TCP连接上同时跑多个请求,每个请求独立Stream,互不阻塞;头部压缩HPACK对地图这种请求头里带大量认证信息的接口收益尤其明显。切换之后,最直观的变化是并发请求不再排队等新建连接,整体连接数降了一个数量级。
3.3 长连接与QUIC的评估结论
地图导航场景还有一个特殊需求:实时路况推送需要长时间维持一条连接。TCP长连接在移动网络下要面对心跳保活、NAT超时、网络切换断连一堆问题。我们当时认真评估过QUIC(HTTP/3)。
QUIC最吸引我们的能力是连接迁移。手机从Wi-Fi切到4G,TCP连接基本要重建,但QUIC使用Connection ID识别连接,IP变了连接ID不变,上层会话可以继续。这对导航场景太重要了——用户一边走路一边导航,跨基站或切换热点时,路况推送通道不应该断掉。
不过QUIC落地也有现实阻碍:UDP流量在某些网络环境会被限速甚至丢弃,部分中间设备对UDP长连接不友好;客户端和服务端的CPU开销比TCP更高。我们当时的结论是,不全面切换,先拿实时路况推送通道做试点。如果你们团队人力有限,建议先把TCP/TLS和HTTP/2优化到位,再考虑QUIC。
4. 弱网环境下的请求策略:不是所有重试都是对的
4.1 弱网识别与分级:信号格数不等于网络质量
很多人以为手机信号格数代表网络质量,这是个大误区。演唱会现场4G信号满格,但基站拥塞,实际可用带宽可能不到0.5Mbps;高铁上信号格数随时跳动,切换基站时丢包率飙升。信号格数只能说明终端和基站之间的无线链路状态,不代表端到端网络质量。
我们采用的弱网判定方案是“请求实测数据打分”:客户端基于最近N次请求的DNS耗时、建连耗时、TTFB、丢包率和下行速度,用滑动窗口计算一个网络质量分数,每30秒左右更新一次。然后按分数把网络分为四个等级:
| 网络等级 | RTT | 丢包率 | 可用带宽 | 应对策略 |
|---|---|---|---|---|
| 良好 | < 200ms | < 2% | > 1Mbps | 正常请求,不做额外限制 |
| 中等 | 200-500ms | 2%-5% | 300Kbps-1Mbps | 降低图片质量,开启压缩 |
| 弱网 | 500-1000ms | 5%-10% | 100-300Kbps | 缩短超时,仅发核心请求 |
| 极弱 | > 1000ms | > 10% | < 100Kbps | 取消非必要请求,等待恢复 |
这套分级不是死的,会根据不同业务场景动态调整。比如导航中的路线刷新在极弱网络下可以选择暂停,但用户手动发起的路线请求仍然要尝试。
4.2 超时、重试与退避:别把弱网用户拖死
弱网环境下,超时时间设置很考验功力。设得过大,用户会看一个请求转圈几十秒;设得过小,好网络下也可能误判失败。我们的做法是不让超时一刀切,而是按网络等级动态调整:
| 请求类型 | 良好网络连接超时/读超时 | 弱网连接超时/读超时 | 极弱网络连接超时/读超时 |
|---|---|---|---|
| 核心请求(路线/路况) | 3s / 5s | 2s / 5s | 1.5s / 4s |
| 普通请求(图片/检索) | 2s / 4s | 1.5s / 3s | 1s / 2s |
| 日志上报 | 2s / 3s | 1s / 2s | 不请求 |
注意核心请求在弱网下连接超时反而短,是因为弱网下新建连接成功率很低,与其让用户干等,不如快速失败,走缓存或提示用户。读超时留得稍长一些,因为请求一旦发出去,服务端可能还在处理。
重试策略更得谨慎。移动端最容易犯的错是“失败就立即重试,连续重试三次”,这在线上故障时会造成重试风暴,把本来就紧张的服务端压垮。我们给重试定了三条铁律:
- 只有可重试的失败才重试。网络超时、5xx可以重试;4xx、DNS解析失败这类不需要重试。
- 重试必须退避加抖动。第一次等1秒,第二次等2秒,第三次等4秒,每次加0-10%的随机抖动,避免客户端步调一致。
- 幂等性是重试的前提。定位上报、日志上报这类幂等请求可以重试;下单、确认路线这类强一致请求绝不自动重试。
4.3 请求瘦身、压缩与预取:让弱网传输的数据更少
弱网环境下,传更少的数据才是硬道理。我们在数据体积上做了四件事:
- 所有HTTP响应开启Brotli压缩,压缩率比gzip再提升10%-20%。
- 地图瓦片从JPEG逐步迁移到WebP,同清晰度下体积减少30%左右。
- 接口数据从JSON逐步迁移到Protobuf,特别是高频的路况、位置上报接口,体积直接减半。
- 瓦片按级别做增量更新,用户只下载变化的部分,而不是整包刷新。
预取是另一个关键手段。用户发起导航时,先把路线方案和沿途交通事件请求发出去;用户搜索某个POI时,提前把POI周边的路况数据拉下来。因为导航启动后的几秒内网络通常还可用,等用户开进隧道再请求就晚了。预取不能太贪心,预取太多无效数据会白白消耗流量和电量,我们结合用户行为做预测,命中率能做到50%以上,不命中的数据直接丢弃。
5. 弱网模拟与多端联调:如何复现线上网络问题
5.1 常用弱网模拟工具的选型
优化策略做了一大堆,总要验证到底靠不靠谱。日常开发中我们试过好几类弱网模拟工具,各有优劣:
| 工具 | 平台 | 能模拟的参数 | 缺点 | 适用阶段 |
|---|---|---|---|---|
| Charles | macOS/Windows | 带宽、延迟、丢包、MTU | 手机需要配代理,性能一般 | 日常功能联调 |
| Fiddler | Windows | 限速为主,丢包能力弱 | 丢包模拟不直观 | Windows环境联调 |
| Network Link Conditioner | macOS/iOS模拟器 | 带宽、延迟、丢包、DNS延迟 | 真机需要配合Mac调试 | 首屏加载、接口超时自测 |
| Android模拟器 | Android | 延迟、丢包、带宽 | 模拟器和真机差异大 | 快速功能验证 |
| 自研弱网代理 | Linux | 带宽、延迟、丢包、抖动、乱序 | 需要一定的开发维护成本 | CI回归、压测 |
Charles和Fiddler适合开发阶段快速验证,但模拟力度有限。Network Link Conditioner可以模拟3G/4G网络和自定义参数,但对iOS之外的环境支持有限。我们最终在CI环境里自建了一个弱网代理,本质是在Linux服务器上用tc和netem在网络层注入可控的延迟和丢包。
# 在代理网卡上模拟200ms延迟 + 5%丢包 sudo tc qdisc add dev eth0 root netem delay 200ms loss 5% # 清除规则 sudo tc qdisc del dev eth0 root netem再次强调,这条命令只在测试环境使用,线上服务器千万不要乱执行。自建代理的好处是可以把弱网参数做成配置化,测试同学写用例时直接指定带宽、延迟、丢包率,CI流水线里自动跑,省去大量手工调试时间。
5.2 按真实场景建模的弱网用例
弱网测试不能只调一组“慢速网络”参数,要贴近真实场景。我们沉淀了一套场景化用例库:
| 场景 | 带宽 | 延迟 | 丢包 | 验证重点 |
|---|---|---|---|---|
| 地下停车场/隧道 | 2Mbps | 100ms | 5% | 路线加载失败后恢复、缓存是否兜底 |
| 电梯 | 1Mbps | 200ms | 10% | 快速进出电梯时连接恢复、请求不悬挂 |
| 演唱会/大型赛事 | 0.5Mbps | 50ms | 2% | 低带宽拥塞下图片加载策略、请求优先级 |
| 高铁 | 10Mbps | 60ms | 10% + 抖动 | 基站切换时连接不断、重试不风暴 |
每个场景不仅验证“请求能不能成功”,还关注“失败后用户体验是否可接受”。比如地下停车场场景,我们要求路线加载失败后5秒内出现重试按钮或缓存路线,绝不允许一直转圈。
这些用例后来接入了发布流水线,每次发版前自动跑一遍核心接口,有效防止网络策略被业务迭代意外改坏。强烈建议有条件的团队也这么做,成本不高,收益非常稳定。
5.3 线上弱网问题怎么定位
弱网问题在测试环境复现后,第一步不是看业务代码,而是先确认请求到底有没有发出去、卡在哪个网络环节。用抓包工具看最直接。
抓包时一般看几个信号:
- 有SYN发出但没有SYN-ACK:服务器不可达或中间网络丢弃。
- SYN-ACK回来但客户端没回ACK:多半是客户端本地网络问题,比如弱网下上行能力不足。
- TLS ClientHello发出后没有ServerHello:TLS握手被中间设备干扰或服务端TLS配置异常。
- 请求发出后迟迟没有第一个响应包:服务端业务处理慢或网关堵塞,需要结合服务端日志判断。
为此,客户端每个网络请求都要生成一个traceId,发请求时通过HTTP头透传给服务端,客户端日志和服务端日志都用同一个traceId。这样排查问题时,无论先看客户端还是服务端,都能把整条链路串起来。我们踩过很多次“各看各的日志、谁也没法证明自己没问题”的坑,traceId是解药。
6. 优化上线的最后一公里:监控验证与问题排查
6.1 建立网络监控大盘
优化不是上线就结束,监控必须变成日常机制。我们的网络监控大盘包含四块:
- 请求成功率:按域名、网络类型、运营商维度实时统计。
- 各阶段耗时:DNS解析、TCP建连、TLS握手、TTFB、下载耗时的P50/P90/P95。
- 错误码分布:超时、连接拒绝、DNS失败、HTTP状态码等分类聚合。
- 弱网请求占比:按城市、时段观察弱网流量变化。
每天早上团队过一遍大盘,任何指标突变都能第一时间发现。有一次我们发现某城市P95耗时突然翻倍,点开一看是该区域一个HTTPDNS节点的丢包率异常,立刻把流量切到了备用节点。
6.2 灰度发布与效果评估
网络优化最怕的就是拍脑袋全量上线。任何策略改动,都要先小流量灰度,维度可以是用户ID末尾一位、APP版本、城市、网络类型。我们常用的灰度节奏是2%、5%、10%、20%、50%、100%逐步放量,每一档至少观察24小时,确认无异常再继续放。
效果评估不能只看网络指标,还得看业务指标。之前有一次连接复用灰度,请求耗时明显下降,但用户反馈反而变差,查了半天发现是新的IP调度策略把用户导到了距离更近但负载很高的节点。后来我们规定,灰度评估必须同时观察以下指标:
- 网络成功率、各阶段耗时P95。
- 业务首屏成功率、导航成功率。
- 崩溃率、卡顿率。
- 主路径PV/UV转化率。
6.3 三个线上排查实战案例
最后分享三个实战案例,都是真实发生过的问题,希望你能少走弯路。
第一个案例是关于HTTPDNS降级救命的。某次某运营商递归DNS出现大面积异常,很多APP用户访问超时。因为我们已经接入了HTTPDNS并且降级策略做得比较完善,一部分流量走HTTPDNS正常返回,一部分降级到LocalDNS仍然受影响,但整体恢复速度比同行快了大约两小时。这次之后,我们把HTTPDNS的覆盖率从70%提升到了90%以上,并且要求所有核心域名必须走HTTPDNS。
第二个案例是隧道中请求悬挂。用户投诉进隧道再出来,APP长时间无响应。排查发现,TCP连接在隧道内超时后,客户端在恢复网络时仍然拿着旧连接发送数据,一直在等旧连接超时;等超时时间到了又开始指数退避,几十秒内没有发出任何新请求。修复方案是监听网络切换事件,主动关闭旧连接池,强制新建连接。这次之后我们在连接池里增加了“网络代际”标记,每次网络恢复都会触发全局连接失效。
第三个案例是活动接口的重试风暴。一次运营活动开始时瞬时并发很大,服务端少量请求返回503,客户端自动重试。因为重试抖动不够随机,大量客户端在同一秒发起第二次请求,服务端压力进一步放大。后来我们在SDK层做了全局重试去抖:同一接口、同一用户、1分钟内最多自动重试2次,且重试间隔完全随机化。这个机制之后再也没因为重试引发过故障。
如果让我给准备做网络优化的团队一条最实用的建议:先别急着换协议、换框架,把每一段网络耗时精准量化,先采两周基线数据再动手改第一行代码。网络优化没有银弹,HTTPDNS、连接复用、QUIC这些都只是手段,真正重要的是建立起一套“可度量、可定位、可验证”的闭环。有了这个闭环,优化方向自然会清晰地浮出水面。