网络排障专项
网络排障的重点不是背 HTTP 状态码, 而是把一次请求拆成 DNS → TCP → TLS → HTTP → 业务解析 → 本地缓存 / 重试. Android 面试尤其看你能否用 OkHttp, Charles, 日志和弱网策略定位真实线上问题.
一, 移动端网络排障总流程
先按链路分层, 不要一上来就改超时或重试. 每一层都要有证据: 耗时, 错误码, 异常类型, 抓包结果, 服务端日志.
用户反馈慢/失败
↓
确认网络类型/Wi-Fi/蜂窝/代理/VPN
↓
DNS 解析 → TCP 连接 → TLS 握手 → HTTP 请求/响应
↓
OkHttp EventListener/Interceptor 日志
↓
Charles/服务端 trace 对照
↓
重试,降级,缓存或服务端修复
| 现象 | 优先看什么 | 常见原因 |
|---|---|---|
| 首次请求慢 | DNS/TCP/TLS 阶段耗时 | DNS 慢, 冷连接, 证书链慢 |
| 只有 HTTPS 失败 | TLS 与证书 | 证书过期, SNI, Pinning, 系统时间错误 |
| 4xx | 请求与鉴权 | token 过期, 参数错, 权限不足, 限流 |
| 5xx | 服务端 / 网关 | 上游超时, 发布故障, 容量不足 |
| 弱网下大量失败 | 超时/重试/连接池 | 超时太短, 非幂等重试, 连接复用异常 |
二, DNS 慢与解析失败
DNS 问题常表现为 “首包慢, 部分地区失败, 切 Wi-Fi/蜂窝表现不同”.Android 端要区分 DNS 解析耗时和后续连接耗时.
- 原因: 运营商 DNS 慢/污染, IPv6/IPv4 选择问题, DNS 缓存过期, 内网域名不可达, DoH/HTTPDNS 配置异常.
- 证据: OkHttp
EventListener.dnsStart/dnsEnd耗时, 解析到的 IP, 网络类型, 地区, 失败异常. - 策略: 合理 DNS 缓存, HTTPDNS/DoH, Happy Eyeballs, 失败 IP 黑名单, 按域名维度监控.
- Android 注意: 不要在主线程解析域名; 多域名, 多 IP 兜底要避免无限重试导致电量和流量浪费.
面试话术: 先证明是不是 DNS 慢, 再谈替代方案; HTTPDNS 能绕过运营商 DNS, 但要处理 HTTPS 证书域名校验, 调度准确性和缓存过期.
三, TLS 失败, 证书错误与 Pinning 调试
TLS 失败要按 “证书链, 域名, 时间, 协议套件, Pinning, 代理抓包” 逐项排除.
| 错误类型 | 可能原因 | 排查方向 |
|---|---|---|
| 证书过期 / 未生效 | 服务端证书时间错误 | 检查证书有效期与设备时间 |
| Hostname verification failed | 证书 SAN 不含域名 | 检查域名, SNI, CDN 证书 |
| Trust anchor not found | 自签 / 链不完整 | 补齐中间证书, network security config |
| Pinning failure | 公钥 / 证书指纹不匹配 | 检查发布环境 pin 列表与轮换策略 |
| Handshake failed | TLS 版本 / 套件不兼容 | 老设备, 服务端协议配置, ALPN |
Charles 调试与 Pinning 策略:
- 开发 / 测试包可使用 debug-only
network_security_config信任用户 CA. - Pinning 必须区分 debug/release: debug 可关闭或使用测试 pin, release 严格校验.
- 不要为了抓包在线上包硬编码关闭 Pinning; 应该用构建变体, 白名单测试域名或内部证书.
- Pinning 要支持证书轮换: 至少保留当前和备用公钥 pin.
Charles 配置步骤 (操作步骤, 未在本仓库执行验证):(1) 让测试设备与 Charles 主机处在可达网络, 按工具显示的主机和端口设置 Wi-Fi HTTP 代理;(2) 在设备浏览器访问 Charles 提示的地址并仅为测试设备安装其 CA;(3) 在 Charles 的 SSL Proxying 中仅加入测试域名 / 端口;(4) 使用仅 debug 变体信任用户 CA;(5) 完成后移除代理和测试 CA. Android 7 (API 24) 起, 默认不信任用户安装 CA 的应用应通过 debug-only networkSecurityConfig 明确配置, release 不应携带该信任配置. 抓包内容仍按敏感数据规则脱敏.
以下为上下文片段, 资源文件位于 src/debug/res/xml/network_security_config.xml, 并只允许测试域名; 引用它的 <application> 节位于 src/debug/AndroidManifest.xml. 仅在 targetSdk >= 24 且需要信任用户安装 CA 的 debug 测试场景配置, release source set 不引用它. 用户 CA 信任与 CertificatePinner 是两层独立策略: debug 可对测试 host 不配置 pin, release 仍为生产 host 配置当前 / 备用 pin.
<network-security-config><domain-config cleartextTrafficPermitted="false"><domain includeSubdomains="true">api.test.example</domain><trust-anchors><certificates src="system" /><certificates src="user" /></trust-anchors></domain-config></network-security-config>
<application android:networkSecurityConfig="@xml/network_security_config" />
system 与 user 同时列出, 表示仅为 debug 测试域名在正常系统公网信任锚上追加 Charles 用户 CA, 并非用用户 CA 替换系统锚; release 继续只使用其生产信任与 pinning 策略.
四, HTTP 4xx/5xx 与业务错误定位
HTTP 状态码要先分清 “协议层状态” 和 “业务层 code”.移动端常见误区是看到 500 就重试, 看到 401 就清登录态, 但真实原因可能更细.
- 400: 参数格式, 签名, 时间戳, 序列化字段缺失.
- 401: token 过期, 刷新 token 失败, 设备被踢, 匿名接口误带错误凭证.
- 403: 无权限, 风控拦截, 地区 / 灰度策略不允许.
- 404/405: 路径, 环境, 方法不一致, 常见于测试环境配置错.
- 429: 限流, 客户端要退避而不是立刻重试.
- 500/502/503/504: 服务端, 网关, 上游依赖或超时; 客户端要带 traceId 找服务端对日志.
谁在报错 (区分): 500 是应用自身异常; 502 是网关 (nginx/LB) 连不上上游或上游不可达; 503 是服务过载或正在维护, 常伴随 Retry-After; 504 是网关转发给上游后等待响应超时. 客户端对应动作: 502/504 偏网关与上游链路, 带 traceId 查网关和上游日志; 503 按 Retry-After 退避或降级, 不要无脑重试放大过载.
Android 实践:
- Interceptor 统一注入 traceId, 版本, 网络类型, 便于端云对齐.
- 401 刷新 token 要做单飞 (single flight), 避免并发请求同时刷新.
- 4xx 多数不应盲重试; 5xx 可对幂等请求有限退避重试.
状态码协议层语义分类见 见 29.
五, 弱网络重试, 连接池与超时设置
弱网策略要平衡成功率, 电量, 流量和业务副作用. 不是 “失败就重试三次” 这么简单.
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.callTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
.build()
- connectTimeout: TCP 建连超时, 过短会误伤弱网, 过长会拖慢失败反馈.
- readTimeout/writeTimeout: 读写单次阻塞超时, 适合控制传输阶段.
- callTimeout: 一次 call 总预算, 避免 DNS/TLS/重试叠加无限拉长.
- 连接池: 复用 TCP/TLS 连接降低握手成本; 但域名/IP/证书变化, 长时间 idle, 网络切换会导致旧连接失效.
- 重试原则: GET / 查询类可退避重试; 下单, 支付, 登录态变更必须依赖幂等 key 或服务端去重.
- 退避策略: 指数退避 + jitter, 避免所有客户端同时重试放大故障.
为什么是这个量级 (推导逻辑, 示意值): 超时是端到端预算, 不是拍脑袋. 先按 RTT 分位数估算: 正常网络 P50 在几十 ms 级, 3G/弱 WiFi 的 P99 可达秒级; 一次 HTTPS 请求要 1~2 个 RTT 建连 + 1~2 个 RTT TLS 握手 + 1 个 RTT 请求/响应, 所以 connectTimeout=10s 是给最差网络建连的预算, callTimeout=30s 是一次 call 的总预算兜底, 防止 DNS/TLS/重试叠加无限拉长. 具体值应基于线上耗时分位数重新校准: 与其让 1% 的请求拖 60s, 不如让 99% 的请求在预算内尽快失败并走降级/重试.
下面为伪代码, 说明可观测的退避计算, 不能直接作为 OkHttp 自动重试实现. Retry-After, 业务截止时间与服务端幂等语义优先于客户端猜测.
if request is idempotent OR has server-supported idempotency key
AND failure is transient
AND attempt < 3
AND now < requestDeadline:
cap = min(8 seconds, 500 ms * 2^attempt)
delay = random(0, cap) # full jitter
schedule one retry after delay
else:
surface failure or enqueue business-defined compensation
POST /orders 只有服务端按 Idempotency-Key 去重并返回同一业务结果时才可安全重试; 仅 “客户端生成了 UUID” 不足以保证. 每次重试须携带同一 traceId 的关联字段和新的 attempt 序号, 避免把一次操作误计为多次业务请求.
六, OkHttp Interceptors, EventListener 与 Charles
OkHttp 排障工具分两类: Interceptor 看请求 / 响应内容, EventListener 看阶段耗时. 两者结合才能回答 “慢在哪里”.
- Application Interceptor: 业务层, 适合加公共 header, 日志, 签名, token, 业务错误处理.
- Network Interceptor: 网络层, 能看到重定向, 网络响应, 缓存细节.
- EventListener: 记录 DNS, connect, secureConnect, requestHeaders, responseHeaders 等阶段耗时.
- Charles: 验证请求是否发出, header/body 是否正确, TLS 证书链, 代理环境下服务端响应.
排障 checklist:
- 打印 URL, method, traceId, 状态码, 异常类型, 各阶段耗时.
- Charles 对照请求头, body, 证书和响应.
- 断网/弱网/代理/VPN/IPv6 场景复现.
- 和服务端用 traceId 对齐网关日志.
- 修复后保留监控指标, 避免同类问题复发.
七, 断点上传: 协商, 恢复与服务端一致性
断点上传不是客户端记住已上传字节数就完成. 客户端和服务端必须以同一个上传会话和已确认范围为准, 否则网络中断, 重试或并发上传会造成重复片段和错误合并.
| 环节 | 客户端责任 | 服务端责任 |
|---|---|---|
| 创建会话 | 请求上传会话, 保存 uploadId, 文件大小, 分片大小和文件摘要 | 返回唯一会话与过期时间, 记录目标对象和协议版本 |
| 上传分片 | 为每片携带分片序号或字节范围, 长度和分片校验值 | 校验范围, 长度和摘要, 原子记录已确认分片 |
| 中断恢复 | 查询服务端已确认范围 / 分片清单, 仅补传缺失部分 | 返回权威状态, 拒绝过期或不匹配的会话 |
| 完成合并 | 请求完成并携带文件级摘要或幂等完成键 | 确认所有片段齐全后合并, 校验完整性, 原子发布结果 |
最小协议流程
- 客户端计算或读取文件大小与文件级校验值, 请求创建上传会话, 保存服务端返回的
uploadId和分片规则. - 每个分片使用稳定的分片标识, 或使用
Content-Range等明确偏移 / 结束位置的协议字段. 同一片的重试必须携带相同会话和分片标识. - 服务端按
(uploadId, partNumber)或等价范围建立幂等写入: 已收到且校验相同的重复请求返回已确认结果, 校验不同则拒绝而非覆盖. - 网络失败后, 客户端先查询服务端状态. 本地偏移只能作为恢复提示, 不能覆盖服务端权威记录. 对可恢复的超时采用有限退避重试, 对 4xx, 校验失败或会话过期提示重新创建或重新选择文件.
- 客户端调用完成接口后, 服务端检查片段连续性, 总长度和文件级校验值. 合并与发布状态须具备原子性, 使重复完成请求返回同一结果而不会产生多个对象.
并行分片可提高吞吐, 但要限制并发以免争抢带宽和内存. 文件被用户替换, 分片大小改变, 登录身份切换或服务端会话过期时, 不能沿用旧的 uploadId. 上传进度应区分 “已发送” 和 “服务端已确认”, 后者才可用于断点恢复.
八, Apache HttpClient, HttpURLConnection 与 OkHttp
Apache HttpClient 的历史原理
Apache HttpClient 以请求对象, 连接管理和可配置的 HTTP 执行链封装网络访问, 曾作为 Android 平台提供的 Apache HTTP 客户端之一. 它的连接复用和请求配置模型解决了早期 Java/Android 网络调用的工程需求.
当前状态与版本边界
Android 在 Android 6.0 (API 23) 移除了平台内置 Apache HTTP 客户端. 依赖平台版本的旧代码在 API 23 及以上不能继续假定该实现存在; 即使通过额外依赖恢复兼容, 也会引入维护, 安全更新和依赖冲突成本. 新项目不应继续选择 Apache HttpClient.
现代替代与差异
HttpURLConnection 与 OkHttp 是并列的独立 HTTP 客户端 API / 实现选择. HttpURLConnection 是 Android 平台提供的基础 API, 适合依赖较少且愿意自行处理线程, 流关闭, 状态码, 缓存和重试边界的场景. OkHttp 自身提供连接池, HTTP/2, 拦截器, 同步/异步 Call 模型和更完整的取消与超时控制; Retrofit 通常构建在 OkHttp 之上. 两者都必须在非主线程执行 I/O, 并依据业务幂等性而非客户端库自动重试行为决定写请求恢复策略.
分层证据与日志边界
按 DNS → TCP/QUIC → TLS → HTTP → 代理/VPN → 应用协议分层保存证据, 同时覆盖 IPv4/IPv6, NAT64, 网络切换和系统时间. OkHttp EventListener 可观察调用阶段但不是抓包器; Cronet/QUIC 日志能力和字段受版本影响. 生产日志不得记录 token, cookie, 完整请求体或证书私钥, 诊断开关要限时, 采样和审计.
以下为上下文片段, 挂到 OkHttpClient 的 eventListenerFactory. MetricSink 是调用方提供的线程安全接口; host 先小写, 去尾点后必须命中 allowlist, 字段名经白名单限制, 不记录 URL query, Authorization, Cookie 或 body. 工厂为每个 call 生成唯一 call_id 和独立 listener, 不能在 listener 实例间共享可变计时状态; 同一 call 的 EventListener 回调按顺序发生, 但 sink 仍须能接收并发 call 的写入.
interface MetricSink { fun record(name: String, valueMs: Long, tags: Map<String, String>) }
class SafeEventListenerFactory(private val sink: MetricSink) : EventListener.Factory {
private val allowedHosts = setOf("api.test.example")
override fun create(call: Call): EventListener {
val host = call.request().url.host.lowercase().removeSuffix(".")
return SafeEventListener(sink, UUID.randomUUID().toString(), host, host in allowedHosts)
}
}
class SafeEventListener(
private val sink: MetricSink,
private val callId: String,
private val host: String,
private val enabled: Boolean,
) : EventListener() {
private val starts = mutableMapOf<String, Long>()
private var failureRecorded = false
private fun start(name: String) { if (enabled) starts[name] = System.nanoTime() }
private fun end(name: String) {
starts.remove(name)?.let { started ->
sink.record(name, (System.nanoTime() - started) / 1_000_000, mapOf("call_id" to callId, "host" to host))
}
}
private fun activeStage(): String = starts.keys.lastOrNull() ?: "unknown"
private fun finishActiveStages() = starts.keys.toList().forEach(::end)
private fun failureCategory(error: IOException): String = when (error) {
is UnknownHostException -> "dns"
is SSLException -> "tls"
is SocketTimeoutException -> "timeout"
is ConnectException -> "connect"
else -> "io"
}
private fun recordFailure(category: String, stage: String) {
if (enabled && !failureRecorded) {
failureRecorded = true
sink.record("failure", 0, mapOf("call_id" to callId, "host" to host, "category" to category, "stage" to stage))
}
}
override fun callStart(call: Call) = start("call")
override fun dnsStart(call: Call, domainName: String) = start("dns")
override fun dnsEnd(call: Call, domainName: String, inetAddressList: List<InetAddress>) = end("dns")
override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) = start("connect")
override fun connectEnd(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?) = end("connect")
override fun connectFailed(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?, ioe: IOException) {
end("secure_connect")
end("connect")
if (enabled) {
sink.record(
"connect_attempt_failure",
0,
mapOf("call_id" to callId, "host" to host, "category" to failureCategory(ioe)),
)
}
}
override fun secureConnectStart(call: Call) = start("secure_connect")
override fun secureConnectEnd(call: Call, handshake: Handshake?) = end("secure_connect")
override fun requestHeadersStart(call: Call) = start("request_headers")
override fun requestHeadersEnd(call: Call, request: Request) = end("request_headers")
override fun requestBodyStart(call: Call) = start("request_body")
override fun requestBodyEnd(call: Call, byteCount: Long) = end("request_body")
override fun responseHeadersStart(call: Call) = start("response_headers")
override fun responseHeadersEnd(call: Call, response: Response) = end("response_headers")
override fun responseBodyStart(call: Call) = start("response_body")
override fun responseBodyEnd(call: Call, byteCount: Long) = end("response_body")
override fun callEnd(call: Call) = end("call")
override fun callFailed(call: Call, ioe: IOException) {
val failedStage = activeStage()
finishActiveStages() // connectEnd/secureConnectEnd 可能不会在失败路径到达
recordFailure(failureCategory(ioe), failedStage)
}
}
val client = OkHttpClient.Builder()
.eventListenerFactory(SafeEventListenerFactory(metricSink))
.build()
案例: 仅 Android 7 测试机 HTTPS 失败
现象: 同一 release candidate 在新设备成功, Android 7 测试机报 SSLHandshakeException.证据: EventListener 显示 DNS/connect 成功, secureConnect 前失败; Charles 不参与时仍复现; 网关日志没有 traceId.根因: CDN 部署遗漏中间证书, 部分平台无法从缓存 / 网络补全链.修复: 在预发补齐服务器发送的证书链并保留旧链兼容验证.验证: 用目标 API 级别真机, 无 Charles 代理和新旧网络各发起请求, 确认握手成功, 业务 200 以及没有放宽客户端信任策略. 该案例说明证据不能被 “debug 包能抓到包” 替代.
自测: (1) debug 测试 host 经 Charles 成功, 预期 system 公网证书仍可验证; release 不信任用户 CA, 且生产 CertificatePinner 不被关闭;(2) 生产 host 不在 allowlist 时不输出 host 标签;(3) 并发 100 个 call, 分别覆盖冷连接, 连接复用, 无 body, 缓存命中以及 DNS/connect/TLS/request/response 各阶段失败, 预期每个 call_id 的事件不串线且符合实际路径: 复用连接没有新的 connect/TLS, no-body 没有 body 事件, 缓存命中按实现可能没有网络阶段; 每次 route/attempt 的 connectFailed 结束其 secure_connect/connect 计时并记录可重复的 connect_attempt_failure, 若后续 route 成功则没有 call 级 failure, 只有最终 callFailed 才关闭其余活跃计时并记录低基数 category 与 failed stage.
高频面试题
Q1: 用户反馈接口慢, 你怎么定位? 先拆阶段: DNS, TCP, TLS, 请求上传, 服务端处理, 响应下载. 用 OkHttp EventListener 记录阶段耗时, Interceptor 打 traceId, Charles / 服务端日志对照, 再判断是客户端网络, 证书, 连接池还是服务端问题.
Q2: TLS 证书错误常见原因有哪些? 证书过期, 设备时间错误, 证书链不完整, 域名与 SAN 不匹配, 老设备 TLS 套件不兼容, Pinning 指纹不匹配, Charles 代理证书未被 debug 包信任.
Q3: 弱网重试怎么设计? 只对幂等或有幂等 key 的请求重试, 设置总 callTimeout, 使用指数退避和 jitter, 限制次数, 网络恢复后再补偿. 非幂等业务必须服务端去重, 不能客户端盲重发.
Q4: OkHttp Interceptor 和 EventListener 区别? Interceptor 适合改请求, 加 header, 签名, 日志和处理响应; EventListener 更适合记录 DNS/TCP/TLS/请求/响应各阶段耗时, 用于定位慢在哪里.
易错点 / 追问
- 易错: 把 5xx 全部归因客户端; 应带 traceId 找服务端 / 网关日志.
- 追问: HTTPDNS 访问 IP 时 HTTPS 证书怎么校验? 仍要用原始域名做 SNI/Hostname verification, 不能简单把 URL host 改成 IP 后跳过校验.
- 易错: 为了 Charles 抓包关闭 release Pinning; 正确做法是 debug-only 策略或测试域名.
- 追问: 为什么 429 不该立即重试? 它表示限流, 立即重试会放大压力, 应按服务端提示或指数退避.
- 追问: TIME_WAIT 和 CLOSE_WAIT 怎么区分? TIME_WAIT 出现在主动关闭方, 等 2MSL 让旧报文消散; CLOSE_WAIT 是被动关闭方收到对端 FIN 后没调用 close, socket 未释放: 看到 CLOSE_WAIT 增多先查代码里连接是否关闭, TIME_WAIT 增多则查连接复用. 语义与完整对照表见 见 29.