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

图1 CDN 节点超时后的客户端自动切换示意
文章目录
-
- 全局常量约定
- 一、问题背景:CDN 挂掉时,客户端不能只能等恢复
- 二、方案目标:业务层无感,网络层动态换 Host
- 三、整体架构:Interceptor + LineService + EventListener
- 四、业务线维度:不要把所有请求塞进同一个线路池
- 五、DynamicHostInterceptor:运行时重写请求 Host
- 六、为什么拦截器顺序很关键
- 七、固定 Host 透传:不是所有请求都能动态切
- 八、线路选择策略:远端配置、本地缓存、包内默认
- 九、选路算法:从缓存到实时排序的完整链路
- 十、测速触发:不要每个请求都测速
- 十一、质量回收:用 EventListener 记录真实请求耗时
- 十二、线路排序:未尝试、成功率、耗时
- 十三、HTTP 和 WebSocket 要分开治理
- 十四、容易踩坑的点
- 十五、可观测性:没有日志就没有线路治理
- 十六、上线策略:先观测,再切流
- 十七、总结
- 附录 A:核心常量与默认配置
全局常量约定
动态线路能力属于网络层基础设施,为避免代码中出现大量魔法值,私有请求头、各类超时/冷却阈值均统一收口管理。
- 客户端内部控制 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 实例;如果多个并发请求共用同一个实例,startTime、url、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_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 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 是网络底层能力,上线要谨慎。
推荐节奏:
- 只记录不切换:先接入 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"
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
更多推荐




所有评论(0)