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

图1 CDN 节点超时后的客户端自动切换示意
文章目录
全局常量约定 一、问题背景:CDN 挂掉时,客户端不能只能等恢复 二、方案目标:业务层无感,网络层动态换 Host 三、整体架构:Interceptor + LineService + EventListener 四、业务线维度:不要把所有请求塞进同一个线路池 五、DynamicHostInterceptor:运行时重写请求 Host 六、为什么拦截器顺序很关键 七、固定 Host 透传:不是所有请求都能动态切 八、线路选择策略:远端配置、本地缓存、包内默认 九、选路算法:从缓存到实时排序的完整链路 十、测速触发:不要每个请求都测速 十一、质量回收:用 EventListener 记录真实请求耗时 十二、线路排序:未尝试、成功率、耗时 十三、HTTP 和 WebSocket 要分开治理 十四、容易踩坑的点 十五、可观测性:没有日志就没有线路治理 十六、上线策略:先观测,再切流 十七、总结 附录 A:核心常量与默认配置 A.1 网络私有 Header 定义 A.2 线路策略阈值常量 A.3 远程配置工具类
全局常量约定
动态线路能力属于网络层基础设施,为避免代码中出现大量魔法值,私有请求头、各类超时/冷却阈值均统一收口管理。
客户端内部控制 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 classBizLine{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))}fungetBest(bizLine: BizLine, protocol: ProtocolType): HostInfo {return pools.getValue(bizLine).rank(protocol).first()}data class Pool(private val hosts: List<HostInfo>) {funrank(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 funintercept(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 falseif (host.isBlank()) return falseif (port !in 1..65535) return false// 金融 App 生产环境建议强制 HTTPS,HTTP 只允许测试环境使用。if (!isDebug && scheme != "https") return falsereturn 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 funlineConfig(): ApiResponse<LineConfig>@GET("/config/app")suspend funappConfig(@Header(NetLineHeader.HEADER_FIXED_HOST) host: String? = null): ApiResponse<AppConfig>}
实现时要注意:这类 Header 是客户端内部控制字段,最终请求前应移除,避免服务端收到无意义字段。
八、线路选择策略:远端配置、本地缓存、包内默认
线路选择不要只依赖远端配置。因为远端配置本身也可能请求失败。
推荐三级降级:
远端线路配置-> 本地缓存线路-> 包内默认线路
首次安装时,客户端至少要有一份包内默认线路。启动后拉取远端线路配置并更新 MMKV 缓存。后续启动时优先使用 MMKV 缓存中的历史最优线路,不要每次都从第一条开始。
本地线路状态建议使用 MMKV 持久化,包括历史最优线路、最近耗时、成功次数、失败次数、最近错误类型等。线路状态属于高频读、低到中频写的数据,启动、请求前选路、请求后质量回收都会访问;不建议使用 SharedPreferences 承载较大的线路池或频繁更新的质量数据,性能和一致性都不如专门的轻量 KV 存储稳定。
LineService 可以提供类似接口:
interface LineService {fungetBestHost(bizLine: BizLine,protocol: ProtocolType,useCache: Boolean = true): HostInfofunparseFixedHost(fixedHost: String): HostInfo?funupdateRemoteConfig(config: LineConfig)funupdateHostQuality(bizLine: BizLine,host: String,costTime: Long,dnsCostTime: Long,tlsCostTime: Long,success: Boolean,error: Throwable?)fungetDetectUrl(bizLine: BizLine): HttpUrlfunupdateDetectResult(bizLine: BizLine,host: String,costTime: Long,success: Boolean)}
其中 HostInfo 不建议只保存字符串,最好包含完整结构:
data class HostInfo(// 静态配置字段:来自远端配置、MMKV 缓存或 assets 默认配置val scheme: String, // http / https,生产环境建议只允许 httpsval host: String, // CDN Host,不包含 path 和 queryval port: Int, // 端口,通常为 443val 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()fungetBestHost(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>()funcanSwitch(bizLine: BizLine): Boolean {val key = bizLine.nameval now = SystemClock.elapsedRealtime()val lastTime = lastSwitchTime[key] ?: 0Lif (now - lastTime < LineConstants.getSwitchIntervalMs()) {return false}lastSwitchTime[key] = nowreturn true}}
这里有一个容易忽略的点:MMKV 缓存不是为了偷懒,而是为了稳定。如果每个请求都重新计算最优线路,可能会因为一次偶发抖动频繁切 Host,反而破坏连接复用和问题定位。更合理的做法是:正常情况下使用历史最优线路,只有请求变慢、连续失败、App 启动或用户进入核心页面时,再触发线路质量更新。
排序算法可以直接对应前面的策略:
private funrankLines(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 + failCountif (total == 0) return 0freturn 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 {funfrom(error: String?): LineErrorType {return values().firstOrNull { it.name == error } ?: UNKNOWN}}}
这段排序有三个取向:
- 未尝试线路优先级高于已尝试线路
,防止历史最优线路一直垄断流量,新线路永远没有验证机会。 - 成功率优先于耗时
,一条稳定的 300ms 线路,通常比一条偶尔 80ms、经常超时的线路更适合作为默认入口。 - 失败线路不会永久禁用
,只是被降权。后续测速或真实请求恢复后,它仍然可以重新回到前面。 - 错误类型要区别对待
,DNS 失败、TLS 失败、连接超时更像线路问题;用户断网、HTTP 4xx 不应该过度惩罚 CDN 线路。
线路来源也要按层级兜底,而不是只取远端配置:
远端配置线路 -> rankLines() 排序 -> 取最优↓ 远端为空或全部超时本地 MMKV 缓存线路 -> rankLines() 排序 -> 取最优↓ 缓存为空或不可用assets 内置加密线路 -> 解密 -> rankLines() 排序 -> 取最优↓ assets 也异常代码内硬编码默认线路
对应到代码,可以把线路来源兜底封装在一个方法里,保证上层选路永远能拿到至少一条可用候选:
private fungetLinesWithFallback(bizLine: BizLine,protocol: ProtocolType): List<HostInfo> {val remoteLines = remoteConfigStore.getLines(bizLine, protocol)if (remoteLines.isNotEmpty()) return remoteLinesval cacheLines = localLineStore.getLines(bizLine, protocol)if (cacheLines.isNotEmpty()) return cacheLinesval assetsLines = assetsLineProvider.decryptLines(bizLine, protocol)if (assetsLines.isNotEmpty()) return assetsLinesreturn 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_MSprivate val states = ConcurrentHashMap<BizLine, DetectState>()suspend fundetectIfNeeded(bizLine: BizLine) {val state = states.getOrPut(bizLine) { DetectState() }val currentTime = SystemClock.elapsedRealtime()if (state.running.get()) returnif (currentTime - state.lastDetectTime < coolDownMs) returnif (!state.running.compareAndSet(false, true)) return// 冷却时间从测速开始计算,失败或超时也要进入冷却,避免故障期间反复打测速接口。state.lastDetectTime = currentTimetry {// 测速接口需要标记固定兜底 Host,不走 DynamicHostInterceptor。withTimeout(LineConstants.TIMEOUT_THRESHOLD_MS) {doDetect(bizLine)}} finally {state.running.set(false)}}private suspend fundoDetect(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)@Volatilevar lastDetectTime = 0L}}
十一、质量回收:用 EventListener 记录真实请求耗时
拦截器负责请求前改 Host,但它不适合统计完整网络生命周期。OkHttp 提供的 EventListener 更适合做质量回收。
可以记录:
callStart dnsStart / dnsEnd connectStart / connectEnd secureConnectStart / secureConnectEnd responseHeadersStart callEnd / callFailed
这里有一个 OkHttp 官方实践上的关键点:不要把 EventListener 做成全局单例。OkHttp 中每个 Call 都应该对应一个独立的 EventListener 实例;如果多个并发请求共用同一个实例,startTime、url、DNS 耗时、TLS 耗时等成员变量会互相覆盖,最终统计出来的数据就是错的。
正确方式是使用 EventListener.Factory 为每个请求创建独立实例:
class LineEventListenerFactory(private val bizLine: BizLine,private val lineService: LineService) : EventListener.Factory {override funcreate(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 = 0Lprivate var dnsStartTime = 0Lprivate var tlsStartTime = 0Lprivate var dnsCostTime = 0Lprivate var tlsCostTime = 0Lprivate var url: HttpUrl? = nulloverride funcallStart(call: Call) {// 使用 elapsedRealtime 统计间隔耗时,不受用户或系统校时影响。startTime = SystemClock.elapsedRealtime()url = call.request().url}override fundnsStart(call: Call, domainName: String) {dnsStartTime = SystemClock.elapsedRealtime()}override fundnsEnd(call: Call, domainName: String, inetAddressList: List<InetAddress>) {dnsCostTime = SystemClock.elapsedRealtime() - dnsStartTime}override funsecureConnectStart(call: Call) {tlsStartTime = SystemClock.elapsedRealtime()}override funsecureConnectEnd(call: Call, handshake: Handshake?) {tlsCostTime = SystemClock.elapsedRealtime() - tlsStartTime}override funcallEnd(call: Call) {report(call, success = true, error = null)}override funcallFailed(call: Call, ioe: IOException) {report(call, success = false, error = ioe)}private funreport(call: Call, success: Boolean, error: Throwable?) {val cost = SystemClock.elapsedRealtime() - startTimeval host = call.request().url.hostlineService.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 + failCountWindowif (total == 0) return 0freturn successCountWindow.toFloat() / total}
上面 rankLines() 只是基础版。实际项目里还应把错误类型纳入评分,例如 DNS 失败、TLS 失败、HTTP 5xx、连接超时的权重不应该一样。DNS 失败和 TLS 失败更接近线路不可用,应该比业务 5xx 降权更重;移动网络切换、用户断网这类本地网络问题,则不应该过度惩罚 CDN 线路。
排序时要避免过度敏感。单次失败不一定说明线路不可用,可能只是用户当时断网。可以结合滑动窗口、失败次数、错误类型来判断。窗口时长可以走远程配置,例如默认 5 分钟;窗口过期后重置近期成功 / 失败计数,让排序结果更接近当前网络质量。
十三、HTTP 和 WebSocket 要分开治理
HTTP 线路质量好,不代表 WebSocket 线路质量也好。
原因包括:
HTTP 请求是短连接或连接复用,WebSocket 是长连接。 WebSocket 更依赖握手耗时和持续稳定性。 某些 CDN 对 WebSocket 支持策略不同。 HTTP 接口成功不代表推送链路稳定。
因此线路池至少要区分:
spot_httpspot_wsfuture_httpfuture_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_HOST、FIXED_HOST、BIZ_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 funintercept(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 是网络底层能力,上线要谨慎。
推荐节奏:
- 只记录不切换
:先接入 EventListener,统计各线路耗时和失败率。 - 灰度切换低风险业务
:例如配置、行情、资源类接口。 - 扩大到核心 API
:确认签名、缓存、监控、证书没有问题后,再覆盖交易相关接口。 - 开启动态测速
:将请求慢、连续失败等触发条件接入线路检测。 - 增加远程开关
:出现异常时能一键关闭动态 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"funisPrivateHeader(name: String): Boolean {return name.startsWith(PRIVATE_HEADER_PREFIX, ignoreCase = true)}}
A.2 线路策略阈值常量
所有阈值优先读取远程配置,默认值作为兜底:
object LineConstants {private const val DEFAULT_TIMEOUT_THRESHOLD_MS = 3_000Lprivate const val DEFAULT_SLOW_REQUEST_THRESHOLD_MS = 2_000Lprivate const val DEFAULT_DETECT_COOL_DOWN_MS = 30 * 1000Lprivate const val DEFAULT_SWITCH_INTERVAL_MS = 10 * 1000Lprivate const val DEFAULT_WINDOW_DURATION_MS = 5 * 60 * 1000Lconst val HIGH_SUCCESS_RATE = 0.9fconst val MIN_USABLE_SUCCESS_RATE = 0.5fconst val RANK_UNUSED_LINE_SCORE = 10_000const val RANK_HIGH_SUCCESS_BASE_SCORE = 5_000const val RANK_USABLE_BASE_SCORE = 2_000const val RANK_SUCCESS_RATE_MULTIPLIER = 1_000fungetTimeoutThresholdMs(): Long {return RemoteLineConfig.getLong("line_timeout_threshold", DEFAULT_TIMEOUT_THRESHOLD_MS)}fungetSlowRequestThresholdMs(): Long {return RemoteLineConfig.getLong("line_slow_request_threshold", DEFAULT_SLOW_REQUEST_THRESHOLD_MS)}fungetDetectCoolDownMs(): Long {return RemoteLineConfig.getLong("line_detect_cool_down", DEFAULT_DETECT_COOL_DOWN_MS)}fungetSwitchIntervalMs(): Long {return RemoteLineConfig.getLong("line_switch_interval", DEFAULT_SWITCH_INTERVAL_MS)}fungetWindowDurationMs(): Long {return RemoteLineConfig.getLong("line_window_duration", DEFAULT_WINDOW_DURATION_MS)}}
A.3 远程配置工具类
封装远程配置 + 本地缓存读取逻辑:
object RemoteLineConfig {fungetLong(key: String, defaultValue: Long): Long {// 实际项目接入远程配置 + MMKV 缓存return defaultValue}}
本文为一线金融移动端工程实践总结,持续分享架构、性能、稳定性相关技术内容,欢迎交流~ Github: https://github.com/brycegao
夜雨聆风