网络监控与容灾:让网络问题无处遁形

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篇:网络监控与容灾:让网络问题无处遁形(本篇)

相关推荐
问心无愧05131 小时前
ctf show web入门 89
android·前端·笔记
高旭的旭1 小时前
Android Perfetto Profilers Skills 简明使用指南
android
alexhilton10 小时前
Android上的ZeroMQ:用发布/订阅模式连接Linux服务
android·kotlin·android jetpack
风别鹤11 小时前
Cocos Creator无法识别Android SDK
android
应用市场11 小时前
Android A/B 无缝更新机制深度剖析
android·网络
企客宝CRM11 小时前
2026年中小企业CRM选型指南:企客宝CRM处于什么位置?
android·算法·企业微信·rxjava·crm
simplepeng13 小时前
我通过3个小改动将Compose重组减少了78%
android
应用市场13 小时前
Android分区表深度解析:GPT、各分区作用与布局实战
android·gpt
应用市场14 小时前
Android Recovery 模式工作原理与定制实战
android