摘要:分享一套金融 App 生产级 CDN 多线路容灾方案,基于 OkHttp 拦截器 + EventListener 实现,包含架构设计、代码实现、踩坑点与灰度上线流程。

在这里插入图片描述

图1 CDN 节点超时后的客户端自动切换示意

全局常量约定

动态线路能力属于网络层基础设施,为避免代码中出现大量魔法值,私有请求头、各类超时/冷却阈值均统一收口管理。

  • 客户端内部控制 Header 统一使用 X-Line- 前缀,请求发出前必须批量移除,禁止透传到服务端;
  • 所有策略阈值优先读取远程配置,远程配置不存在时使用本地默认兜底值,方便线上故障动态调参;
  • 完整常量代码、远程配置工具类详见 附录 A:核心常量与默认配置

一、问题背景:CDN 挂掉时,客户端不能只能等恢复

很多 App 的网络架构里,Retrofit 或 OkHttp 会配置一个固定 BaseUrl。正常情况下这很简单,但金融交易 App 的问题在于:用户分布广、访问频率高、核心链路实时性强,一旦某条 CDN 线路异常,影响会被迅速放大。

典型故障包括:

  • 某个 CDN 域名在部分地区解析失败。
  • 某个运营商链路质量突然劣化。
  • 单个 CDN 节点异常,导致请求耗时飙升。
  • DNS 缓存污染或解析到不可用节点。
  • 某条业务线访问正常,另一条业务线访问异常。

如果客户端只有一个固定域名,能做的事情很有限:要么等待 CDN 恢复,要么依赖服务端或运维层调整解析,要么发版切换域名。对于金融 App 来说,这些方案都不够快。

更稳妥的做法是:客户端内置多线路能力,在运行时根据线路质量动态选择最优 CDN Host,业务层只感知统一的 API 能力,不感知真实域名变化。

二、方案目标:业务层无感,网络层动态换 Host

这套方案的核心目标不是“把域名写成可配置”,而是把 CDN 线路选择做成客户端网络层的基础能力。

目标可以拆成几条:

  • 业务代码仍然使用稳定的逻辑 BaseUrl,不直接关心 CDN 域名。
  • 网络层在请求发出前,根据业务线选择当前最优 CDN Host。
  • CDN 故障或请求变慢时,客户端能触发测速并更新最优线路。
  • 特殊接口支持固定 Host 透传,避免误切换。
  • 远端配置失败时,能够降级到本地缓存或包内默认线路。
  • 全链路可观测,能知道请求最终走了哪条线路、耗时多少、为什么切换。

整体链路可以理解为:

业务请求
  -> Retrofit 生成 Request
  -> DynamicHostInterceptor 判断是否需要换 Host
  -> LineService 根据业务线选择最优 CDN
  -> OkHttp 发起真实请求
  -> NetworkEventListener 回收耗时和错误原因
  -> 更新线路质量缓存

三、整体架构:Interceptor + LineService + EventListener

一个可维护的动态 Host 架构,至少需要三部分协作。

1. DynamicHostInterceptor

负责在 OkHttp 请求发出前改写 URL Host。它不做测速,也不保存复杂状态,只根据当前请求的业务类型,向线路服务查询“当前最优 Host”,然后生成新的 URL。

2. LineService

负责线路池管理、远端配置、本地缓存、默认线路、最优线路选择、测速触发、线路质量更新。它是整个多线路能力的状态中心。

3. NetworkEventListener

负责监听 OkHttp 请求生命周期,记录 DNS、连接、TLS、请求发送、响应接收、请求结束等关键耗时,并将请求结果反馈给 LineService。

这三个组件的职责边界很重要:

  • Interceptor 只负责“请求前选 Host”。
  • EventListener 只负责“请求后回收质量数据”。
  • LineService 负责“线路如何选、如何存、如何降级”。

如果把这三类逻辑都塞到一个拦截器里,短期能跑,长期会变成网络层黑盒:切了哪条线路、为什么切、什么时候恢复、是否触发测速,都很难排查。

四、业务线维度:不要把所有请求塞进同一个线路池

金融 App 往往不只有一类 API。常见业务线包括:

  • 行情 / 现货类 API
  • 合约类 API
  • 账户 / 资产类 API
  • DEX 类 API
  • 静态资源 / 文件类 API
  • H5 / WebView 链路
  • WebSocket 链路

不同业务线的域名、CDN、负载均衡和可用性策略可能完全不同。比如行情接口访问慢,不代表资产接口也慢;HTTP 线路质量好,也不代表 WebSocket 握手一定快。

因此,线路池建议按维度拆分:

LinePool
  ├─ spot_http
  ├─ spot_ws
  ├─ future_http
  ├─ future_ws
  ├─ dex_api
  ├─ dex_resource
  └─ h5_web

Host 选择时也必须带上业务线:

enum class BizLine {
    SPOT,
    FUTURE,
    DEX,
    FILE,
    H5
}

落到实现上,建议不要在 Interceptor 里直接维护多个 List<String>,而是把线路池封装成一个稳定的模型。拦截器只问“这个业务线当前最优 Host 是谁”,不关心配置来源和排序细节。

class LinePool(config: ApiLineConfig) {
    private val pools by lazy {
        mapOf(
            BizLine.SPOT to Pool(config.spotHosts),
            BizLine.FUTURE to Pool(config.futureHosts),
            BizLine.DEX to Pool(config.dexHosts),
            BizLine.FILE to Pool(config.fileHosts),
            BizLine.H5 to Pool(config.h5Hosts)
        )
    }

    fun getBest(bizLine: BizLine, protocol: ProtocolType): HostInfo {
        return pools.getValue(bizLine).rank(protocol).first()
    }

    data class Pool(private val hosts: List<HostInfo>) {
        fun rank(protocol: ProtocolType): List<HostInfo> {
            return rankLines(hosts.filter { it.protocol == protocol })
        }
    }
}

业务线较多时,线路池可以按需懒加载,避免 App 启动阶段一次性解析所有线路配置。真正要注意的是懒加载后的线程安全和首次访问耗时,核心业务线可以在网络模块初始化后低优先级预热。

业务线拆清楚后,线路切换才不会“牵一发动全身”。某条 DEX 线路异常,不应该影响行情 API;某个 H5 域名访问慢,也不应该影响交易下单接口。

五、DynamicHostInterceptor:运行时重写请求 Host

拦截器的核心逻辑并不复杂:判断请求是否允许切换 Host,如果允许,则根据业务线查询最优线路,重建 URL 后继续请求。

示例代码如下:

class DynamicHostInterceptor(
    private val bizLine: BizLine,
    private val lineService: LineService
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val originRequest = chain.request()

        if (originRequest.keepOriginalHost()) {
            return chain.proceed(originRequest.cleanLineHeaders())
        }

        val fixedHost = originRequest.header(NetLineHeader.HEADER_FIXED_HOST)
            ?.takeIf { it.isNotBlank() }

        val targetHost = if (fixedHost != null) {
            lineService.parseFixedHost(fixedHost)
        } else {
            lineService.getBestHost(
                bizLine = bizLine,
                protocol = ProtocolType.HTTP,
                useCache = true
            )
        } ?: return chain.proceed(originRequest.cleanLineHeaders())

        if (!targetHost.isValidForRequest(isDebug = BuildConfig.DEBUG)) {
            return chain.proceed(originRequest.cleanLineHeaders())
        }

        val newUrl = originRequest.url.newBuilder()
            .scheme(targetHost.scheme)
            .host(targetHost.host)
            .port(targetHost.port)
            .build()

        val newRequest = originRequest.newBuilder()
            .url(newUrl)
            .build()
            .cleanLineHeaders()

        return chain.proceed(newRequest)
    }
}

/** 判断当前请求是否禁止动态切换 Host。 */
fun Request.keepOriginalHost(): Boolean {
    return header(NetLineHeader.HEADER_KEEP_ORIGINAL_HOST)
        ?.equals("true", ignoreCase = true) == true
}

/** 清理网络层私有 Header,禁止透传到服务端。 */
fun Request.cleanLineHeaders(): Request {
    val builder = newBuilder()
    headers.names().forEach { name ->
        if (NetLineHeader.isPrivateHeader(name)) {
            builder.removeHeader(name)
        }
    }
    return builder.build()
}

fun HostInfo.isValidForRequest(isDebug: Boolean): Boolean {
    if (scheme != "http" && scheme != "https") return false
    if (host.isBlank()) return false
    if (port !in 1..65535) return false

    // 金融 App 生产环境建议强制 HTTPS,HTTP 只允许测试环境使用。
    if (!isDebug && scheme != "https") return false

    return true
}

这里有几个实现细节:

  • keepOriginalHost() 用于固定 Host 透传,注意要做空安全判断。
  • NetLineHeader.HEADER_FIXED_HOST 用于特殊接口指定 Host。
  • lineService.getBestHost() 只返回当前业务线的最优线路。
  • 重建 URL 前必须校验 targetHost 合法性:scheme 只允许 http / https,生产环境建议强制 https;校验失败时不要继续改写 Host。
  • 重建 URL 时只改 scheme / host / port,完整保留 path / query / method / body,这是动态 Host 方案的核心约束。
  • 控制类 Header 要在真正请求前批量移除,避免透传到服务端;后续新增 X-Line- 私有 Header 时,也不需要逐个补 removeHeader()

最后一条尤其重要。动态 Host 切换只能改变网络入口,不能改变业务请求语义。如果重建 URL 时误改了 path、query、请求方法或请求体,本质上就不是“线路切换”,而是篡改了业务请求。

六、为什么拦截器顺序很关键

动态 Host 拦截器最好放在 OkHttp 应用拦截器的靠前位置。原因是后续很多能力可能依赖最终 URL:

  • Header 组装
  • 签名计算
  • 日志上报
  • 监控埋点
  • 缓存 Key
  • Sentry / APM 网络事件

推荐顺序:

DynamicHostInterceptor
  -> HeaderInterceptor
  -> CacheInterceptor
  -> MonitorInterceptor
  -> LoggingInterceptor

如果先做签名,再切 Host,而签名规则又包含 host,就可能导致服务端验签失败。如果先记录监控,再切 Host,监控里看到的是逻辑域名,不是真正访问的 CDN 域名,排障时会误判。

因此需要明确一个原则:凡是依赖最终 URL 的逻辑,都应该放在 DynamicHostInterceptor 之后。

七、固定 Host 透传:不是所有请求都能动态切

动态 Host 不能对所有请求一刀切。部分接口必须保持原始 Host 或由业务指定固定 Host。

常见场景包括:

  • 线路配置接口本身。
  • 线路测速接口。
  • 登录、支付、风控、KYC 等特殊安全接口。
  • 三方 SDK 上传或回调接口。
  • 文件上传、日志上传等要求固定域名的接口。
  • 签名强绑定 Host 的接口。

可以通过注解或 Header 标记:

interface ConfigApi {

    @Headers(NetLineHeader.HEADER_KEEP_ORIGINAL_HOST_TRUE)
    @GET("/config/line")
    suspend fun lineConfig(): ApiResponse<LineConfig>

    @GET("/config/app")
    suspend fun appConfig(
        @Header(NetLineHeader.HEADER_FIXED_HOST) host: String? = null
    ): ApiResponse<AppConfig>
}

实现时要注意:这类 Header 是客户端内部控制字段,最终请求前应移除,避免服务端收到无意义字段。

八、线路选择策略:远端配置、本地缓存、包内默认

线路选择不要只依赖远端配置。因为远端配置本身也可能请求失败。

推荐三级降级:

远端线路配置
  -> 本地缓存线路
  -> 包内默认线路

首次安装时,客户端至少要有一份包内默认线路。启动后拉取远端线路配置并更新 MMKV 缓存。后续启动时优先使用 MMKV 缓存中的历史最优线路,不要每次都从第一条开始。

本地线路状态建议使用 MMKV 持久化,包括历史最优线路、最近耗时、成功次数、失败次数、最近错误类型等。线路状态属于高频读、低到中频写的数据,启动、请求前选路、请求后质量回收都会访问;不建议使用 SharedPreferences 承载较大的线路池或频繁更新的质量数据,性能和一致性都不如专门的轻量 KV 存储稳定。

LineService 可以提供类似接口:

interface LineService {
    fun getBestHost(
        bizLine: BizLine,
        protocol: ProtocolType,
        useCache: Boolean = true
    ): HostInfo

    fun parseFixedHost(fixedHost: String): HostInfo?

    fun updateRemoteConfig(config: LineConfig)

    fun updateHostQuality(
        bizLine: BizLine,
        host: String,
        costTime: Long,
        dnsCostTime: Long,
        tlsCostTime: Long,
        success: Boolean,
        error: Throwable?
    )

    fun getDetectUrl(bizLine: BizLine): HttpUrl

    fun updateDetectResult(
        bizLine: BizLine,
        host: String,
        costTime: Long,
        success: Boolean
    )
}

其中 HostInfo 不建议只保存字符串,最好包含完整结构:

data class HostInfo(
    // 静态配置字段:来自远端配置、MMKV 缓存或 assets 默认配置
    val scheme: String,              // http / https,生产环境建议只允许 https
    val host: String,                // CDN Host,不包含 path 和 query
    val port: Int,                   // 端口,通常为 443
    val protocol: ProtocolType,      // HTTP / WebSocket 等协议维度
    val priority: Int,               // 配置优先级,表达默认线路顺序
    val weight: Int,                 // 灰度或容量权重,避免所有流量压到单线路

    // 运行质量字段:来自 EventListener、测速任务和本地质量回收
    val score: Double,               // 聚合评分,由成功率、耗时、错误类型计算
    val used: Boolean,               // 是否已经被真实请求或测速验证过
    val successCount: Int,           // 近期成功次数,可按滑动窗口统计
    val failCount: Int,              // 近期失败次数,可按错误类型加权
    val avgCostTime: Long?,          // 平均耗时,建议使用近期窗口而不是全量均值
    val lastCostTime: Long?,         // 最近一次请求或测速耗时
    val lastSuccess: Boolean?,       // 最近一次是否成功
    val lastError: String?           // 最近一次错误类型,日志中不要带完整敏感 URL
)

这里的 priority 通常来自配置,用来表达产品或运维侧的默认顺序;weight 适合表达线路容量或灰度比例;score 则是客户端根据成功率、耗时、错误类型计算出来的动态评分。三者不要混成一个字段,否则线上排障时很难判断“这条线路是配置优先,还是质量优先”。

包内默认线路在生产环境中也不建议明文存储。原因很简单:CDN 域名如果直接写在 APK 里,攻击者反编译后可以拿到完整入口列表,再配合批量探测或攻击绕过部分防护策略。更稳妥的做法是将包内默认线路做 AES 加密或同等级别的静态保护,并且只在网络模块初始化时执行一次解密。解密后的完整 Host 列表禁止打印到日志、控制台或异常堆栈里,线上日志最多记录线路编号、业务线、是否命中默认线路等脱敏信息。

九、选路算法:从缓存到实时排序的完整链路

前面定义了 lineService.getBestHost(),但真正决定方案质量的是它的内部选路链路。一个可落地的实现至少要处理五件事:策略降级、缓存命中、实时排序、全超时兜底、缓存更新。

private val lineSwitchGuard = LineSwitchGuard()

fun getBestHost(
    bizLine: BizLine,
    protocol: ProtocolType,
    useCache: Boolean
): HostInfo {
    // 1. 策略降级开关:线上异常时直接回到默认顺序
    if (isStrategyDowngrade()) {
        return getFirstLine(bizLine, protocol)
    }

    // 2. 缓存命中:避免每次请求都重新排序
    if (useCache) {
        val cached = getCachedBest(bizLine, protocol)
        if (cached != null && !cached.isTimeout()) {
            return cached
        }
    }

    // 3. 实时排序:从当前可用线路池中选择最优线路
    val lines = getLinesWithFallback(bizLine, protocol)
    val best = rankLines(lines).first()

    // 4. 切换熔断:避免短时间内在多个 Host 之间反复横跳
    val guardedBest = if (lineSwitchGuard.canSwitch(bizLine)) {
        best
    } else {
        getCachedBest(bizLine, protocol) ?: best
    }

    // 5. 全超时兜底:当前线路池质量很差时回到包内默认线路
    val target = if (guardedBest.isTimeout()) {
        getDefaultLine(bizLine, protocol)
    } else {
        guardedBest
    }

    // 6. 更新 MMKV 缓存:下一次请求可以直接命中历史最优线路
    updateCache(bizLine, protocol, target)
    return target
}

private fun HostInfo.isTimeout(): Boolean {
    return (lastCostTime ?: Long.MAX_VALUE) >= LineConstants.getTimeoutThresholdMs()
}

class LineSwitchGuard {
    private val lastSwitchTime = ConcurrentHashMap<String, Long>()

    fun canSwitch(bizLine: BizLine): Boolean {
        val key = bizLine.name
        val now = SystemClock.elapsedRealtime()
        val lastTime = lastSwitchTime[key] ?: 0L

        if (now - lastTime < LineConstants.getSwitchIntervalMs()) {
            return false
        }

        lastSwitchTime[key] = now
        return true
    }
}

这里有一个容易忽略的点:MMKV 缓存不是为了偷懒,而是为了稳定。如果每个请求都重新计算最优线路,可能会因为一次偶发抖动频繁切 Host,反而破坏连接复用和问题定位。更合理的做法是:正常情况下使用历史最优线路,只有请求变慢、连续失败、App 启动或用户进入核心页面时,再触发线路质量更新。

排序算法可以直接对应前面的策略:

private fun rankLines(lines: List<HostInfo>): List<HostInfo> {
    return lines.sortedByDescending { host ->
        val avgCost = host.avgCostTime ?: LineConstants.getTimeoutThresholdMs()
        val errorPenalty = host.errorPenalty()

        when {
            // 1. 从未用过的线路:最高优先级,给新线路验证机会
            !host.used -> LineConstants.RANK_UNUSED_LINE_SCORE + (100 - host.priority)

            // 2. 历史成功率高:成功率优先,再看耗时
            host.successRate() >= LineConstants.HIGH_SUCCESS_RATE -> {
                LineConstants.RANK_HIGH_SUCCESS_BASE_SCORE +
                    host.weight +
                    (LineConstants.getTimeoutThresholdMs() - avgCost).coerceAtLeast(0) -
                    errorPenalty
            }

            // 3. 有成功记录但不够稳定:降权,但不直接禁用
            host.successRate() >= LineConstants.MIN_USABLE_SUCCESS_RATE -> {
                LineConstants.RANK_USABLE_BASE_SCORE +
                    (host.successRate() * LineConstants.RANK_SUCCESS_RATE_MULTIPLIER).toInt() -
                    errorPenalty
            }

            // 4. 失败率高:排到最后,等待后续探测恢复
            else -> {
                (host.successRate() * LineConstants.RANK_SUCCESS_RATE_MULTIPLIER).toInt() -
                    errorPenalty
            }
        }
    }
}

private fun HostInfo.successRate(): Float {
    val total = successCount + failCount
    if (total == 0) return 0f
    return successCount.toFloat() / total
}

private fun HostInfo.errorPenalty(): Int {
    return LineErrorType.from(lastError).weight * 100
}

enum class LineErrorType(val weight: Int) {
    DNS_FAILURE(10),
    TLS_HANDSHAKE_FAILED(8),
    CONNECT_TIMEOUT(5),
    HTTP_5XX(3),
    HTTP_4XX(1),
    LOCAL_NETWORK_ERROR(0),
    UNKNOWN(2);

    companion object {
        fun from(error: String?): LineErrorType {
            return values().firstOrNull { it.name == error } ?: UNKNOWN
        }
    }
}

这段排序有三个取向:

  • 未尝试线路优先级高于已尝试线路,防止历史最优线路一直垄断流量,新线路永远没有验证机会。
  • 成功率优先于耗时,一条稳定的 300ms 线路,通常比一条偶尔 80ms、经常超时的线路更适合作为默认入口。
  • 失败线路不会永久禁用,只是被降权。后续测速或真实请求恢复后,它仍然可以重新回到前面。
  • 错误类型要区别对待,DNS 失败、TLS 失败、连接超时更像线路问题;用户断网、HTTP 4xx 不应该过度惩罚 CDN 线路。

线路来源也要按层级兜底,而不是只取远端配置:

远端配置线路 -> rankLines() 排序 -> 取最优
  ↓ 远端为空或全部超时
本地 MMKV 缓存线路 -> rankLines() 排序 -> 取最优
  ↓ 缓存为空或不可用
assets 内置加密线路 -> 解密 -> rankLines() 排序 -> 取最优
  ↓ assets 也异常
代码内硬编码默认线路

对应到代码,可以把线路来源兜底封装在一个方法里,保证上层选路永远能拿到至少一条可用候选:

private fun getLinesWithFallback(
    bizLine: BizLine,
    protocol: ProtocolType
): List<HostInfo> {
    val remoteLines = remoteConfigStore.getLines(bizLine, protocol)
    if (remoteLines.isNotEmpty()) return remoteLines

    val cacheLines = localLineStore.getLines(bizLine, protocol)
    if (cacheLines.isNotEmpty()) return cacheLines

    val assetsLines = assetsLineProvider.decryptLines(bizLine, protocol)
    if (assetsLines.isNotEmpty()) return assetsLines

    return listOf(getHardcodedLine(bizLine, protocol))
}

最后一层“代码内硬编码默认线路”只应该作为极端兜底存在,不要放完整线路池,也不要承载灰度策略。它的价值是保证 App 在配置链路、缓存、assets 都异常时,仍然有一个最低限度的网络入口。

十、测速触发:不要每个请求都测速

线路测速是必要的,但不能滥用。测速本身也是网络请求,如果触发太频繁,会增加服务端压力,甚至让故障期间的情况更糟。

推荐触发时机:

  • App 启动后,低优先级触发一次。
  • 用户主动刷新核心页面时触发。
  • 请求耗时超过阈值时触发,例如超过 LineConstants.SLOW_REQUEST_THRESHOLD_MS
  • 某条线路连续失败达到阈值时触发。
  • WebSocket 握手失败或连接耗时过长时触发。

关键约束:

  • 同一业务线同一时间只允许一个测速任务。
  • 同一业务线要有冷却时间,例如 30s,避免故障期间反复测速引发雪崩。
  • 测速要有超时时间,例如 3s。
  • 测速结果写入 MMKV 缓存。
  • 失败原因也要保存,不能只保存耗时。
  • 测速接口必须强制使用固定兜底 Host,不参与动态线路切换,避免“测速链路自己也被错误线路带偏”。
  • 测速请求要轻量,优先使用专用 /line/ping 一类接口或 HEAD 请求,不要占用交易、资产等业务接口资源。

简单的 single-flight + 冷却控制:

class LineDetector(
    private val okHttpClient: OkHttpClient,
    private val lineService: LineService
) {
    // 测速冷却时间:30 秒,避免故障场景下频繁测速引发雪崩。
    private val coolDownMs = LineConstants.DETECT_COOL_DOWN_MS
    private val states = ConcurrentHashMap<BizLine, DetectState>()

    suspend fun detectIfNeeded(bizLine: BizLine) {
        val state = states.getOrPut(bizLine) { DetectState() }
        val currentTime = SystemClock.elapsedRealtime()

        if (state.running.get()) return
        if (currentTime - state.lastDetectTime < coolDownMs) return
        if (!state.running.compareAndSet(false, true)) return

        // 冷却时间从测速开始计算,失败或超时也要进入冷却,避免故障期间反复打测速接口。
        state.lastDetectTime = currentTime

        try {
            // 测速接口需要标记固定兜底 Host,不走 DynamicHostInterceptor。
            withTimeout(LineConstants.TIMEOUT_THRESHOLD_MS) {
                doDetect(bizLine)
            }
        } finally {
            state.running.set(false)
        }
    }

    private suspend fun doDetect(bizLine: BizLine) {
        val detectUrl = lineService.getDetectUrl(bizLine)
        val request = Request.Builder()
            .url(detectUrl)
            .head()
            .header(NetLineHeader.HEADER_KEEP_ORIGINAL_HOST, "true")
            .build()

        val startTime = SystemClock.elapsedRealtime()
        withContext(Dispatchers.IO) {
            okHttpClient.newCall(request).execute().use { response ->
                lineService.updateDetectResult(
                    bizLine = bizLine,
                    host = detectUrl.host,
                    costTime = SystemClock.elapsedRealtime() - startTime,
                    success = response.isSuccessful
                )
            }
        }
    }

    private class DetectState {
        val running = AtomicBoolean(false)
        @Volatile
        var lastDetectTime = 0L
    }
}

十一、质量回收:用 EventListener 记录真实请求耗时

拦截器负责请求前改 Host,但它不适合统计完整网络生命周期。OkHttp 提供的 EventListener 更适合做质量回收。

可以记录:

  • callStart
  • dnsStart / dnsEnd
  • connectStart / connectEnd
  • secureConnectStart / secureConnectEnd
  • responseHeadersStart
  • callEnd / callFailed

这里有一个 OkHttp 官方实践上的关键点:不要把 EventListener 做成全局单例。OkHttp 中每个 Call 都应该对应一个独立的 EventListener 实例;如果多个并发请求共用同一个实例,startTimeurl、DNS 耗时、TLS 耗时等成员变量会互相覆盖,最终统计出来的数据就是错的。

正确方式是使用 EventListener.Factory 为每个请求创建独立实例:

class LineEventListenerFactory(
    private val bizLine: BizLine,
    private val lineService: LineService
) : EventListener.Factory {

    override fun create(call: Call): EventListener {
        return LineEventListener(bizLine, lineService)
    }
}

val okHttpClient = OkHttpClient.Builder()
    .eventListenerFactory(LineEventListenerFactory(bizLine, lineService))
    .build()

请求结束后,将实际 Host、耗时、状态码、失败原因回写给 LineService:

class LineEventListener(
    private val bizLine: BizLine,
    private val lineService: LineService
) : EventListener() {

    private var startTime = 0L
    private var dnsStartTime = 0L
    private var tlsStartTime = 0L
    private var dnsCostTime = 0L
    private var tlsCostTime = 0L
    private var url: HttpUrl? = null

    override fun callStart(call: Call) {
        // 使用 elapsedRealtime 统计间隔耗时,不受用户或系统校时影响。
        startTime = SystemClock.elapsedRealtime()
        url = call.request().url
    }

    override fun dnsStart(call: Call, domainName: String) {
        dnsStartTime = SystemClock.elapsedRealtime()
    }

    override fun dnsEnd(call: Call, domainName: String, inetAddressList: List<InetAddress>) {
        dnsCostTime = SystemClock.elapsedRealtime() - dnsStartTime
    }

    override fun secureConnectStart(call: Call) {
        tlsStartTime = SystemClock.elapsedRealtime()
    }

    override fun secureConnectEnd(call: Call, handshake: Handshake?) {
        tlsCostTime = SystemClock.elapsedRealtime() - tlsStartTime
    }

    override fun callEnd(call: Call) {
        report(call, success = true, error = null)
    }

    override fun callFailed(call: Call, ioe: IOException) {
        report(call, success = false, error = ioe)
    }

    private fun report(call: Call, success: Boolean, error: Throwable?) {
        val cost = SystemClock.elapsedRealtime() - startTime
        val host = call.request().url.host

        lineService.updateHostQuality(
            bizLine = bizLine,
            host = host,
            costTime = cost,
            dnsCostTime = dnsCostTime,
            tlsCostTime = tlsCostTime,
            success = success,
            error = error
        )
    }
}

这一步很关键。没有质量回收,所谓“动态切换”只能依赖静态配置;有了真实请求反馈,客户端才能逐步形成自己的线路质量缓存。除总耗时外,DNS 耗时和 TLS 握手耗时也建议单独记录:DNS 耗时异常通常指向解析、运营商或域名污染问题;TLS 耗时或失败则更容易定位到证书、SNI、CDN 边缘节点配置问题。

十二、线路排序:未尝试、成功率、耗时

线路排序不要只看最近一次耗时。一个更稳定的排序策略可以是:

未尝试线路 > 近期成功线路 > 成功率高线路 > 平均耗时低线路 > 失败线路

为什么未尝试线路优先级要高?因为如果所有用户都一直使用历史最优线路,新的可用线路永远没有机会被验证;当历史线路故障时,客户端也缺少备用线路质量数据。

如果不希望 HostInfo 同时承载配置字段和运行状态,也可以把运行态拆成独立的 LineState。这里建议使用近期滑动窗口,而不是全量累计成功 / 失败次数,否则一条线路早期的历史故障可能长期污染排序结果。

data class LineState(
    val host: String,
    val used: Boolean,
    val successCountWindow: Int,
    val failCountWindow: Int,
    val windowStartTime: Long,
    val avgCostTime: Long?,
    val lastCostTime: Long?,
    val lastSuccess: Boolean?,
    val lastError: String?,
    val updatedAt: Long
)

fun LineState.successRateInWindow(): Float {
    val total = successCountWindow + failCountWindow
    if (total == 0) return 0f
    return successCountWindow.toFloat() / total
}

上面 rankLines() 只是基础版。实际项目里还应把错误类型纳入评分,例如 DNS 失败、TLS 失败、HTTP 5xx、连接超时的权重不应该一样。DNS 失败和 TLS 失败更接近线路不可用,应该比业务 5xx 降权更重;移动网络切换、用户断网这类本地网络问题,则不应该过度惩罚 CDN 线路。

排序时要避免过度敏感。单次失败不一定说明线路不可用,可能只是用户当时断网。可以结合滑动窗口、失败次数、错误类型来判断。窗口时长可以走远程配置,例如默认 5 分钟;窗口过期后重置近期成功 / 失败计数,让排序结果更接近当前网络质量。

十三、HTTP 和 WebSocket 要分开治理

HTTP 线路质量好,不代表 WebSocket 线路质量也好。

原因包括:

  • HTTP 请求是短连接或连接复用,WebSocket 是长连接。
  • WebSocket 更依赖握手耗时和持续稳定性。
  • 某些 CDN 对 WebSocket 支持策略不同。
  • HTTP 接口成功不代表推送链路稳定。

因此线路池至少要区分:

spot_http
spot_ws
future_http
future_ws

WebSocket 质量回收也不同。它更关注:

  • 发起连接到连接成功的耗时。
  • 首次收到消息的耗时。
  • 连接持续时间。
  • 关闭码。
  • 错误原因。
  • 是否在后台、断网、切前台时断开。

HTTP 线路选择和 WebSocket 线路选择可以共用 LineService,但不要共用同一个质量字段。

十四、容易踩坑的点

1. TLS 证书和 SNI

HTTPS 请求最终使用哪个 host,TLS SNI 和证书校验就会围绕哪个 host 工作。动态切换 Host 时,目标 CDN 域名必须具备合法证书,否则会出现握手失败或证书校验失败。

不要随意在 URL host 和 HTTP Host Header 之间制造不一致,除非服务端和证书体系明确支持。一般情况下,优先修改 URL host,而不是手工伪造 Host Header。

备用线路上线前必须逐条做证书链校验,包括顶级域名、泛域名、SAN 域名是否覆盖当前 Host。一个直接的验证方式是:

openssl s_client -connect api-backup.example.com:443 -servername api-backup.example.com

如果备用 CDN 的证书没有配好,客户端切过去会直接 SSL 握手失败,这种故障比“不切换”更隐蔽,也更难从业务日志里定位。

更稳的做法是在线路配置更新后,对备用线路做低频证书预校验,并把校验结果写入线路状态。排序时可以优先过滤证书无效的 Host;如果全部线路都未校验通过,再回到原有兜底逻辑,避免因为预校验服务异常导致客户端无路可走。证书预校验要受远程开关和频率限制,不能在启动主线程或高峰期批量执行。

2. 签名逻辑和 Host 顺序

如果请求签名包含 host,必须确保签名发生在 Host 重写之后;或者签名协议明确使用固定逻辑域名,而不是实际 CDN 域名。否则切换 Host 后可能导致服务端验签失败。

3. 控制 Header 不要透传

KEEP_ORIGINAL_HOSTFIXED_HOSTBIZ_LINE 这类 Header 只给客户端网络层使用,请求发出前应移除,避免污染服务端日志或触发网关规则。

4. 连接池与并发限制

OkHttp 的连接池、maxRequestsPerHost 都和最终 host 有关。Host 动态切换后,并发分布会发生变化。高频接口需要关注连接复用、连接数和请求排队情况。

如果业务线差异很大,可以按业务线创建不同的 OkHttpClient 或连接池配置,例如交易、行情使用更高的连接配额,静态资源、日志上传使用独立客户端,避免非核心请求抢占核心链路连接资源。但不要为了“隔离”无节制创建大量客户端,否则连接池复用率会下降,内存和握手成本都会上升。

5. 线路配置接口不能依赖动态线路

获取线路配置的接口本身要能固定 Host 或使用默认线路。否则一旦动态线路失效,客户端可能连“更新线路配置”的能力都没有。

十五、可观测性:没有日志就没有线路治理

动态 Host 切换必须可观测,否则线上出了问题只能猜。

建议至少记录:

  • 业务线。
  • 原始 URL host。
  • 最终请求 host。
  • 是否命中固定 Host。
  • 是否命中 MMKV 缓存。
  • 是否发生降级。
  • 请求耗时。
  • DNS 耗时。
  • TLS 握手耗时。
  • HTTP 状态码。
  • 异常类型。
  • 当前选路原因。

示例结构:

data class LineTrace(
    val bizLine: BizLine,
    val originHost: String,
    val targetHost: String,
    val fixedHost: Boolean,
    val fromCache: Boolean,
    val downgrade: Boolean,
    val costTime: Long,
    val dnsCostTime: Long,
    val tlsCostTime: Long,
    val statusCode: Int?,
    val error: String?
)

这些日志要脱敏,也要受远程开关控制。正常情况下低采样上报,线上排障时临时提高采样。

如果需要和服务端日志串联,建议由拦截器生成并注入 Trace ID,而不是在 EventListener.callStart() 里修改请求。EventListener 只适合观察请求生命周期,不适合改变请求内容。

class TraceIdInterceptor(
    private val bizLine: BizLine,
    private val traceIdGenerator: TraceIdGenerator
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val traceId = traceIdGenerator.generate()
        val request = chain.request().newBuilder()
            .header("X-Trace-Id", traceId)
            .build()

        LineLogger.logLineEvent(
            LineLog(
                traceId = traceId,
                bizLine = bizLine.name,
                targetHost = request.url.host,
                eventType = "REQUEST_START"
            )
        )
        return chain.proceed(request)
    }
}

结构化日志可以用统一模型输出,便于日志平台按业务线、事件类型和错误类型检索:

data class LineLog(
    val traceId: String,
    val bizLine: String,
    val targetHost: String,
    val eventType: String,
    val costTime: Long? = null,
    val errorType: String? = null,
    val timestamp: Long = System.currentTimeMillis()
)

十六、上线策略:先观测,再切流

动态 Host 是网络底层能力,上线要谨慎。

推荐节奏:

  1. 只记录不切换:先接入 EventListener,统计各线路耗时和失败率。
  2. 灰度切换低风险业务:例如配置、行情、资源类接口。
  3. 扩大到核心 API:确认签名、缓存、监控、证书没有问题后,再覆盖交易相关接口。
  4. 开启动态测速:将请求慢、连续失败等触发条件接入线路检测。
  5. 增加远程开关:出现异常时能一键关闭动态 Host,回到默认线路。

这里一定要保留策略降级开关。一旦发现 Host 切换策略有问题,可以关闭 DynamicHostInterceptor 和线路质量回收,直接使用默认域名,保证基础可用性。

十七、总结

CDN 线路动态切换的价值,不是让客户端“聪明地改域名”,而是让网络可用性从静态配置变成运行时能力。

一套成熟的方案至少包含:

  • OkHttp 拦截器运行时重写 Host。
  • 按业务线维护独立线路池。
  • 固定 Host 透传,保护特殊接口。
  • 远端配置、MMKV 缓存、包内默认三级降级。
  • EventListener 回收真实请求质量。
  • HTTP 与 WebSocket 分开治理。
  • 线路切换日志可观测、可降噪。
  • 灰度上线和策略降级开关。

对于金融交易 App 来说,CDN 故障不是小概率事件,而是必须提前设计的可用性场景。把这套能力沉淀在网络层后,业务模块不需要感知真实 CDN 变化,客户端也能在域名异常、区域链路劣化时更快恢复服务能力。

附录 A:核心常量与默认配置

A.1 网络私有 Header 定义

统一前缀管理,支持批量判断私有请求头:

object NetLineHeader {
    private const val PRIVATE_HEADER_PREFIX = "X-Line-"

    const val HEADER_KEEP_ORIGINAL_HOST = "X-Line-Keep-Original-Host"
    const val HEADER_FIXED_HOST = "X-Line-Fixed-Host"
    const val HEADER_KEEP_ORIGINAL_HOST_TRUE = "X-Line-Keep-Original-Host: true"

    fun isPrivateHeader(name: String): Boolean {
        return name.startsWith(PRIVATE_HEADER_PREFIX, ignoreCase = true)
    }
}

A.2 线路策略阈值常量

所有阈值优先读取远程配置,默认值作为兜底:

object LineConstants {
    private const val DEFAULT_TIMEOUT_THRESHOLD_MS = 3_000L
    private const val DEFAULT_SLOW_REQUEST_THRESHOLD_MS = 2_000L
    private const val DEFAULT_DETECT_COOL_DOWN_MS = 30 * 1000L
    private const val DEFAULT_SWITCH_INTERVAL_MS = 10 * 1000L
    private const val DEFAULT_WINDOW_DURATION_MS = 5 * 60 * 1000L

    const val HIGH_SUCCESS_RATE = 0.9f
    const val MIN_USABLE_SUCCESS_RATE = 0.5f
    const val RANK_UNUSED_LINE_SCORE = 10_000
    const val RANK_HIGH_SUCCESS_BASE_SCORE = 5_000
    const val RANK_USABLE_BASE_SCORE = 2_000
    const val RANK_SUCCESS_RATE_MULTIPLIER = 1_000

    fun getTimeoutThresholdMs(): Long {
        return RemoteLineConfig.getLong("line_timeout_threshold", DEFAULT_TIMEOUT_THRESHOLD_MS)
    }

    fun getSlowRequestThresholdMs(): Long {
        return RemoteLineConfig.getLong("line_slow_request_threshold", DEFAULT_SLOW_REQUEST_THRESHOLD_MS)
    }

    fun getDetectCoolDownMs(): Long {
        return RemoteLineConfig.getLong("line_detect_cool_down", DEFAULT_DETECT_COOL_DOWN_MS)
    }

    fun getSwitchIntervalMs(): Long {
        return RemoteLineConfig.getLong("line_switch_interval", DEFAULT_SWITCH_INTERVAL_MS)
    }

    fun getWindowDurationMs(): Long {
        return RemoteLineConfig.getLong("line_window_duration", DEFAULT_WINDOW_DURATION_MS)
    }
}

A.3 远程配置工具类

封装远程配置 + 本地缓存读取逻辑:

object RemoteLineConfig {
    fun getLong(key: String, defaultValue: Long): Long {
        // 实际项目接入远程配置 + MMKV 缓存
        return defaultValue
    }
}

本文为一线金融移动端工程实践总结,持续分享架构、性能、稳定性相关技术内容,欢迎交流~ Github: https://github.com/brycegao

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐