Android网络优化系列 · 第5/5篇(完结)
从DNS到连接池,打造极速网络体验
第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱
第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路
第3篇:连接优化与复用:让每一次握手都物超所值
第4篇:数据压缩与缓存策略:把带宽用到极致
第5篇:网络监控与容灾:让网络问题无处遁形(本篇·完结)
从一次"幽灵故障"说起
去年双十一,我们的App突然收到大量投诉------"加载不出来"、"一直转圈"。但诡异的是:服务端监控一切正常,接口处理时间稳定在50ms以内,错误率不到0.1%。
排查了两个小时才定位到原因:某个省份的联通用户被DNS调度到了一个异常CDN节点------TCP能建立连接,但数据传输断断续续,实际吞吐量不到正常值的十分之一。
这种故障最让人崩溃:服务端看不到、后端监控抓不到、只有终端能感知。如果我们当时有完善的端侧网络监控,10分钟就能定位而不是盲查两小时。
这就是为什么我把"网络监控与容灾"放在系列最后一篇------前四篇教你怎么把网络做快,这篇教你怎么知道它慢了,以及慢了之后怎么自动兜底。优化做得再好,没有监控就是盲人走路,没有容灾就是裸奔。
OkHttp EventListener:端侧网络的CT扫描仪
第一篇简单提过EventListener,这次彻底用到极致。它是OkHttp提供的全链路埋点钩子,能精确拿到DNS、连接、TLS、发送、接收每个阶段的耗时,且零侵入------不用改任何业务代码。
生产级监控Listener
不是demo版的打个log完事,而是真正线上跑了半年的版本:
class NetMonitorListener(
private val reporter:
NetMetricReporter
) : EventListener() {
private val t = NetTiming()
override fun callStart(
call: Call
) {
t.callStart = System.nanoTime()
t.url = call.request().url
.toString()
}
override fun dnsStart(
call: Call,
name: String
) { t.dnsStart = System.nanoTime() }
override fun dnsEnd(
call: Call,
name: String,
ips: List<InetAddress>
) {
t.dnsEnd = System.nanoTime()
t.resolvedIp = ips.firstOrNull()
?.hostAddress
}
override fun connectStart(
call: Call,
addr: InetSocketAddress,
proxy: Proxy
) { t.connectStart = System.nanoTime() }
override fun secureConnectStart(
call: Call
) { t.tlsStart = System.nanoTime() }
override fun secureConnectEnd(
call: Call,
hs: Handshake?
) { t.tlsEnd = System.nanoTime() }
override fun connectEnd(
call: Call,
addr: InetSocketAddress,
proxy: Proxy,
protocol: Protocol?
) {
t.connectEnd = System.nanoTime()
t.protocol = protocol?.toString()
}
override fun responseHeadersStart(
call: Call
) {
t.responseStart =
System.nanoTime()
}
override fun responseBodyEnd(
call: Call,
byteCount: Long
) {
t.responseEnd = System.nanoTime()
t.bytes = byteCount
}
override fun callEnd(
call: Call
) {
t.callEnd = System.nanoTime()
t.success = true
reporter.report(t.toMetric())
}
override fun callFailed(
call: Call,
e: IOException
) {
t.callEnd = System.nanoTime()
t.success = false
t.errorType = classifyError(e)
reporter.report(t.toMetric())
}
}
错误分类:不要把所有异常扔进一个桶
线上网络错误五花八门,如果不做分类,监控大盘就是一坨无法下钻的数字。我的分类逻辑:
private fun classifyError(
e: IOException
): String = when {
e is UnknownHostException
-> "dns_fail"
e is ConnectException
-> "connect_fail"
e is SSLException
-> "tls_fail"
e is SocketTimeoutException
-> "timeout"
e.message?.contains("canceled")
== true -> "canceled"
e.message?.contains("reset")
== true -> "conn_reset"
else ->
"unknown_${e.javaClass.simpleName}"
}
这样在Grafana上按 error_type 维度拆分,一眼就能看出来今天是DNS挂了还是证书过期了。某天 dns_fail 突然飙升------八成是运营商DNS抽风;tls_fail 集中出现------赶紧检查证书有效期。
采样策略:别把监控后端搞崩了
日活百万的App,一天几亿次网络请求,每次都上报?你的监控后端先扛不住。策略很简单:异常全量、慢请求全量、正常请求采样。
class SmartSampler(
private var sampleRate: Float
= 0.1f
) {
// 远程可配,出问题时调到1.0
fun updateRate(r: Float) {
sampleRate = r
}
fun shouldReport(
metric: NetMetric
): Boolean = when {
// 失败请求:100%上报
!metric.success -> true
// 慢请求(>3s):100%上报
metric.totalMs > 3000 -> true
// 正常请求:按采样率
else ->
Random.nextFloat() < sampleRate
}
}
踩坑经验:采样率一定要支持远程热更新。我们之前写死了10%,有一次定位长尾问题怎么都复现不了------因为那个case刚好没被采样到。后来改成远程配置,排查时临时调100%,问题秒出。
主动探测:不等用户投诉才发现问题
EventListener是被动监控------有业务请求才有数据。但很多时候你需要主动探测网络质量:App启动时判断当前网络等级、从后台恢复时快速评估、用户切网后马上探一把。
class NetworkProber(
private val client: OkHttpClient
) {
// 探测endpoint:空body,只测链路RTT
private val probeUrl =
"https://probe.example.com/ping"
enum class Quality {
EXCELLENT, // 3s 或失败
}
suspend fun probe(): Quality {
val start =
System.currentTimeMillis()
return try {
client.newCall(
Request.Builder()
.url(probeUrl)
.head().build()
).await()
val rtt =
System.currentTimeMillis() -
start
classify(rtt)
} catch (_: Exception) {
Quality.TERRIBLE
}
}
private fun classify(
rtt: Long
) = when {
rtt Quality.EXCELLENT
rtt Quality.GOOD
rtt Quality.MODERATE
rtt Quality.POOR
else -> Quality.TERRIBLE
}
}
探测驱动的动态策略
知道网络好不好之后,关键是自动调整行为------这才是监控的真正价值:
class AdaptiveStrategy(
private val prober: NetworkProber
) {
fun getTimeout(
q: Quality
): Long = when (q) {
EXCELLENT -> 10_000L
GOOD -> 15_000L
MODERATE -> 20_000L
POOR -> 30_000L
TERRIBLE -> 45_000L
}
fun getImageQuality(
q: Quality
) = when (q) {
EXCELLENT, GOOD ->
ImageQuality.HIGH
MODERATE ->
ImageQuality.MEDIUM
POOR, TERRIBLE ->
ImageQuality.THUMBNAIL
}
fun shouldPreload(
q: Quality
) = q == EXCELLENT || q == GOOD
fun getRetryCount(
q: Quality
) = when (q) {
EXCELLENT, GOOD -> 1
MODERATE -> 2
POOR, TERRIBLE -> 3
}
}
这套方案跑了大半年的数据:弱网超时率降了40%(不再用固定10s超时"逼"弱网用户),好网络场景的首屏时间反而提升了------因为敢预加载了。
弱网模拟:不要等上线才发现问题
扎心事实:大部分开发者测试网络功能时的环境------公司WiFi,满格信号,延迟5ms。什么代码都跑得飞快。等到用户在地铁里断断续续的4G下使用时,问题才暴露。
四种方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| Emulator限速 | 零成本内建 | 不能模拟丢包抖动 |
| Charles限速 | 真机可用,GUI方便 | HTTPS需配证书 |
| OkHttp Interceptor | 代码级精确控制 | 只影响OkHttp请求 |
| Linux tc+netem | 最真实,系统级 | 需root/共享网络 |
我的日常搭配:开发阶段用Interceptor(秒切场景),CI用Emulator限速跑弱网用例,发版前Charles完整测一轮。给个Interceptor实现:
class WeakNetSimulator(
private val delayMs: Long = 2000,
private val jitterMs: Long = 500,
private val dropRate: Float = 0.1f
) : Interceptor {
override fun intercept(
chain: Interceptor.Chain
): Response {
// 模拟随机丢包
if (Random.nextFloat()
< dropRate) {
throw SocketTimeoutException(
"[SIM] packet loss"
)
}
// 模拟延迟+抖动
val actual = delayMs +
Random.nextLong(
-jitterMs, jitterMs
)
Thread.sleep(actual)
return chain.proceed(
chain.request()
)
}
}
血泪教训:这个Interceptor千万别出现在Release包里!别只靠
BuildConfig.DEBUG控制------把整个类放在debugImplementation的source set里,编译时物理隔离最安全。我们组有人曾经因为条件判断写反了,让10%的线上用户体验了"弱网模拟"......
容灾降级:网络挂了不等于App挂了
监控告诉你"出事了",容灾决定"出事之后怎么办"。目标很清晰:核心功能在极端网络下依然可用,哪怕是降级版本。
多域名自动切换
最基本也是最有效的容灾手段:主域名挂了,自动切备用。做好了能解决80%的可用性问题:
class DomainFailover(
private val domains: List<String>
) : Interceptor {
// ["api.example.com",
// "api-bk.example.com",
// "api.example.cn"]
private val fails =
ConcurrentHashMap<
String, AtomicInteger>()
private val current =
AtomicReference(domains[0])
override fun intercept(
chain: Interceptor.Chain
): Response {
val req = chain.request()
var lastErr: IOException? = null
for (domain in sortedDomains()) {
try {
val newUrl = req.url
.newBuilder()
.host(domain).build()
val resp = chain.proceed(
req.newBuilder()
.url(newUrl).build()
)
// 成功 → 重置计数
fails[domain]?.set(0)
current.set(domain)
return resp
} catch (e: IOException) {
fails.getOrPut(domain) {
AtomicInteger()
}.incrementAndGet()
lastErr = e
}
}
throw lastErr
?: IOException(
"All domains failed")
}
private fun sortedDomains() =
domains.sortedBy {
if (it == current.get()) 0
else fails[it]?.get() ?: 1
}
}
三级兜底:网络→缓存→预埋数据
比域名切换更彻底的方案------让关键页面在完全没网时也不白屏:
class ResilientRepo<T>(
private val api: ApiService,
private val cache: LocalCache<T>,
private val fallback:
AssetFallback<T>
) {
suspend fun getData(
key: String
): DataResult<T> {
// Level 1: 网络
try {
val data = api.fetch(key)
cache.save(key, data)
return DataResult.fresh(data)
} catch (_: Exception) { }
// Level 2: 本地缓存
cache.get(key)?.let {
return DataResult.stale(it)
}
// Level 3: 预埋兜底包
fallback.get(key)?.let {
return DataResult.fallback(it)
}
return DataResult.empty()
}
}
数据获取的三级容灾
发起数据请求
↓
网络请求成功?
是 → 返回最新数据 + 更新缓存
否 → 降级到本地缓存 ↓
↓
缓存命中?
是 → 返回缓存(标记stale状态)
否 → 降级到Asset兜底包 ↓
↓
展示兜底数据(不白屏)
UI层配合 DataResult 的状态来显示不同的提示:fresh时正常展示,stale时顶部加个"数据可能不是最新"的横条,fallback时提示"网络不可用,显示离线数据"。用户虽然拿不到最新数据,但至少App没崩没白屏------这在用户体验上是天壤之别。
CDN节点健康检测
回到开头那个"幽灵故障",最终方案是在端侧做CDN节点健康检测------当某个IP表现异常时,主动屏蔽它:
class CdnHealthChecker {
// IP → 解禁时间戳
private val blocked =
ConcurrentHashMap<String, Long>()
fun markBad(ip: String) {
// 屏蔽5分钟
blocked[ip] =
System.currentTimeMillis() +
5 * 60_000
}
fun filterHealthy(
ips: List<InetAddress>
): List<InetAddress> {
val now =
System.currentTimeMillis()
val healthy = ips.filter {
val exp =
blocked[it.hostAddress]
exp == null || now > exp
}
// 全挂了就返回全量,避免死锁
return healthy.ifEmpty { ips }
}
}
配合第二篇讲的自定义DNS接口,在DNS解析结果返回时过滤掉被标记为异常的IP。这样下次请求自动绕过故障节点,用户甚至感知不到出过问题。
告警体系:让监控真正有用
监控不告警=没监控。但告警做不好=狼来了。我踩过的坑总结成一个原则:分层、带上下文、做收敛。
告警分层模型
P0 致命 --- 整体成功率 < 95% | TTFB P99 > 10s → 电话+短信+群通知
P1 严重 --- 某域名/ISP失败率 > 10% → 群通知+Oncall DM
P2 警告 --- DNS P95 > 500ms | 慢请求率 > 5% → 群通知
P3 观察 --- 接口P95同比上涨20% → 日报汇总
三个血泪教训:
• 用趋势告警,不用绝对值。"P95是500ms"不一定有问题,但"P95比昨天同期涨了200%"一定有鬼。
• 告警要带维度上下文。光说"错误率升高"没用,说"联通+广东+dns_fail飙升300%"才能秒级定位。
• 5分钟收敛窗口。同一问题5分钟内只通知一次,附带影响用户数。否则值班人手机先炸了。
串联全局:一个完整的OkHttpClient
最后,把这篇和前四篇的能力串起来,这是我们线上的OkHttpClient配置:
fun buildProductionClient():
OkHttpClient {
val health = CdnHealthChecker()
val reporter =
NetMetricReporter(reportUrl)
return OkHttpClient.Builder()
// DNS优化(第2篇)+健康过滤
.dns(SmartDns(httpDns, health))
// 域名容灾(本篇)
.addInterceptor(
DomainFailover(domains)
)
// 连接池(第3篇)
.connectionPool(
ConnectionPool(
32, 5, MINUTES
)
)
// HTTP/2多路复用(第3篇)
.protocols(listOf(
Protocol.HTTP_2,
Protocol.HTTP_1_1
))
// 全链路监控(本篇)
.eventListenerFactory {
NetMonitorListener(reporter)
}
.build()
}
每一行配置背后都是这个系列某一篇的内容。这就是"体系"的意思------单点优化有上限,但当DNS + 连接池 + 压缩 + 监控 + 容灾组合在一起时,效果是乘法不是加法。
系列完结:回顾与展望
| 篇目 | 解决什么 | 核心收益 |
|---|---|---|
| 第1篇 全链路拆解 | 知道慢在哪 | 建立性能心智模型 |
| 第2篇 DNS优化 | 首连接耗时 | 首请求快200ms+ |
| 第3篇 连接优化 | 握手成本 | 复用率>90% |
| 第4篇 压缩缓存 | 传输体积 | 流量省60%+ |
| 第5篇 监控容灾 | 发现并兜住异常 | 故障定位10min→秒级 |
做性能优化有个原则我想在最后强调一次:先测量,再优化,别靠猜。今天这篇讲的监控体系其实是所有优化的起点------你得先知道慢在哪,才知道该改哪。
网络优化是个永远做不完的事。WebSocket长连接优化、基于ML的质量预测、QUIC在生产环境的灰度方案......每个方向都能再写一个系列。但基础就是这五篇的内容------把基本功做扎实了,80%的网络问题都能搞定。
系列完结。如果这五篇对你的工作有帮助,转给你的团队同事------网络优化从来不是一个人的事。下一个系列大概率会聊Android启动速度优化,到时候再见。
Android网络优化系列 · 全部完结
第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱
第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路
第3篇:连接优化与复用:让每一次握手都物超所值
第4篇:数据压缩与缓存策略:把带宽用到极致
第5篇:网络监控与容灾:让网络问题无处遁形(本篇)