Android网络优化系列 · 第3/5篇
从DNS到连接池,打造极速网络体验
第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱
第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路
第3篇:连接优化与复用:让每一次握手都物超所值(本篇)
⏳ 第4篇:数据压缩与缓存策略:把带宽用到极致
⏳ 第5篇:网络监控与容灾:让网络问题无处遁形
从一次"诡异的300ms"说起
上篇我们拿下了DNS------解析耗时P99从2100ms砍到180ms,劫持率从0.8%降到0.01%。你以为胜利了,直到有人扔过来一个性能trace:
"为什么同一个接口,第一次请求和第二次请求差了300ms?DNS已经缓存命中了啊。"
打开trace一看:DNS解析确实是0ms(命中缓存),但多了一段300ms的"connectStart → connectEnd"。这300ms是什么?是TCP三次握手(1 RTT)+ TLS 1.2握手(2 RTT)。RTT约100ms时,三次握手就是300ms的"入场费"------还没发一个字节的业务数据呢。
更绝的是,这不是个例。我们一查线上数据:连接复用率只有62%------每10个请求里有接近4个需要重新建连。乘以300ms的连接成本,这是一笔巨大的性能税。
这就是今天的主题:怎么把"新建连接"的次数降到最低,把"每次连接"的成本压到最小。
一次握手到底花多少钱
先把账算清楚。假设客户端到服务端的RTT(往返时延)是100ms(4G网络的典型值):
建连成本拆解
• TCP三次握手:1 RTT = 100ms
• TLS 1.2握手:2 RTT = 200ms(ClientHello → ServerHello+证书 → 密钥交换 → Finished)
• 合计 = 300ms,一个字节的业务数据都还没发
弱网放大效应(RTT=300ms时)
• TCP + TLS 1.2 = 3 × 300ms = 900ms
• 首屏如果要请求3个不同域名 → 串行建连 = 2.7秒
900ms什么概念?用户点一个按钮,等了将近一秒才开始传数据,加上服务端处理时间和数据传输时间,首屏轻松超过2秒。Google的研究表明,页面加载每慢100ms,转化率下降1.11%。你这一个连接建立就丢了10%的转化。
这就是为什么连接优化的ROI极高------你不是在优化一个请求,而是在优化所有请求共享的底座。
OkHttp连接池:最好的握手是不握手
最快的连接建立方式,是根本不建立------复用已有的连接。OkHttp的ConnectionPool就是干这件事的:把已经完成TCP+TLS握手的连接缓存起来,下一个相同目标的请求直接拿来用。
默认配置的隐患
ini
// OkHttp 默认连接池配置
val defaultPool = ConnectionPool(
maxIdleConnections = 5, // 最多保留5个空闲连接
keepAliveDuration = 5, // 空闲连接存活5分钟
timeUnit = TimeUnit.MINUTES
)
对Demo App来说够用。但想想生产环境你的App同时在和多少域名通信:
• api.yourapp.com --- 主接口
• cdn.yourapp.com --- 静态资源/配置
• img.yourapp.com --- 图片
• tracker.yourapp.com --- 埋点
• push.yourapp.com --- 长连接推送
• 若干第三方SDK域名...
5个空闲连接装不下这些域名。低频域名的连接不断被挤出池子,下次用到又要重新握手。
生产级连接池调参
ini
/**
* 生产级连接池配置
* 原则:覆盖所有高频域名的常驻连接,又不过度占用资源
*/
val productionPool = ConnectionPool(
maxIdleConnections = 15, // 覆盖全部高频域名,每个域名至少1-2个空闲连接
keepAliveDuration = 10, // 10分钟------移动端用户操作间隔可能较长
timeUnit = TimeUnit.MINUTES
)
val client = OkHttpClient.Builder()
.connectionPool(productionPool)
.build()
但有一个反直觉的点:maxIdleConnections不是越大越好。每个空闲连接占约几十KB内存和一个文件描述符(fd)。Android默认的fd上限是1024,连接池囤太多空闲连接会挤压业务的fd余量。我们压测下来**,15-20是一个比较好的平衡点**。
keepAliveDuration的陷阱
keepAliveDuration要配合服务端的Keep-Alive超时来设。客户端的必须小于服务端的。否则出现这个经典bug:
客户端认为连接还活着(在keepAliveDuration内),但服务端已经关了连接(超过了Nginx的keepalive_timeout 75s)。客户端在这个"半死"连接上发请求 → 收到RST → OkHttp检测到后重试 → 白白多花一个RTT。
ini
// Nginx默认 keepalive_timeout 75s
// 客户端设60s,留15s安全余量
val safePool = ConnectionPool(
maxIdleConnections = 15,
keepAliveDuration = 60, // 比服务端的75s少15s
timeUnit = TimeUnit.SECONDS
)
// 如果你的keepAliveDuration设成10分钟,但服务端只有75秒
// 那60秒后到10分钟之间的所有复用都会遇到RST
// 这是线上最常见的"偶发请求多花一个RTT"的原因
用数据说话:连接复用率监控
调参不能靠猜,要靠数据。OkHttp的EventListener接口让你精确看到每一个连接的生命周期:
kotlin
class ConnectionMetricsListener : EventListener() {
private var connectStartMs = 0L
override fun connectStart(
call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy
) {
connectStartMs = System.currentTimeMillis()
}
override fun connectionAcquired(
call: Call, connection: Connection
) {
// 如果connectionAcquired之前没有触发connectStart → 复用
val isReused = connectStartMs == 0L
Metrics.increment(
if (isReused) "conn_reused" else "conn_new"
)
}
override fun connectEnd(
call: Call, inetSocketAddress: InetSocketAddress,
proxy: Proxy, protocol: Protocol?
) {
val handshakeMs = System.currentTimeMillis() - connectStartMs
Metrics.histogram("tcp_tls_handshake_ms", handshakeMs)
Metrics.tag("protocol", protocol?.toString() ?: "unknown")
}
}
val client = OkHttpClient.Builder()
.connectionPool(productionPool)
.eventListenerFactory { ConnectionMetricsListener() }
.build()
盯两个核心指标:连接复用率 (目标>85%)和新建连接的握手耗时P95。如果复用率掉到70%以下,说明连接池配置有问题------要么maxIdleConnections不够,要么keepAliveDuration太短,要么域名太分散。
HTTP/2多路复用:一条连接,并发百请求
连接池解决了"别重复建连"的问题,但HTTP/1.1还有一个根本缺陷:一个连接同一时刻只能处理一个请求(Head-of-Line Blocking)。想并发10个请求?必须开10个连接,每个都要付300ms的握手费。
HTTP/2的多路复用彻底打破了这个限制------在一条TCP连接上,把请求/响应拆成帧(Frame),用Stream ID标识归属,多个请求的帧交错传输,接收端按Stream ID重新组装。
实际收益有三个层面:
1. 多路复用(Multiplexing)------一条连接并发几百个请求,不再需要为并发开多连接
2. 头部压缩(HPACK) ------一个典型API请求头500字节~2KB,100个请求就是100-200KB的重复数据。HPACK通过静态表(61个预定义常见头)+ 动态表(连接生命周期内的头部缓存),把连续请求的头部压缩率做到85-95%。第一个请求800字节头,后续只传30-50字节。
3. 流优先级(Stream Priority)------多请求共用连接时,你可以告诉服务端"首屏API优先级最高,图片次之,埋点最低"。服务端据此调度响应顺序,关键资源先到。
OkHttp的HTTP/2:零配置但不是零优化
OkHttp从3.x起默认支持HTTP/2------只要服务端支持,TLS握手时通过ALPN协商自动升级。你不需要改一行代码。但"默认支持"和"用好"是两码事:
scss
/**
* HTTP/2 最佳实践配置
*/
val http2Client = OkHttpClient.Builder()
.connectionPool(productionPool)
// 确保TLS版本支持ALPN协商
.connectionSpecs(listOf(
ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2)
.build()
))
// PING心跳:HTTP/2 PING帧检测连接健康度
.pingInterval(30, TimeUnit.SECONDS)
.build()
pingInterval这个参数极易被忽略但极为重要。HTTP/2连接是长活的,移动端网络频繁切换(WiFi↔4G、进出电梯),连接可能已经断了但双方都不知道。PING帧是心跳探测,30秒一次,及时发现死连接并丢弃。没有这个配置,请求可能发到一个已经断开的连接上,等超时后才发现------白白浪费十几秒。
域名收敛:HTTP/2时代的必修课
HTTP/1.1时代有个"常识"------域名发散:把静态资源分散到img1.cdn.com, img2.cdn.com...绕开每域名6连接的限制。
在HTTP/2时代,这变成了反模式。一条连接就能并发几百个请求,你分散域名反而导致:每个域名单独建连+TLS握手、HPACK动态表无法跨连接共享、连接池里一堆同质低效连接。
正确做法是域名收敛 。如果业务原因必须多域名,至少确保它们解析到同一IP、共用同一张通配符证书(或SAN证书覆盖所有子域名)。OkHttp会自动进行连接合并(Connection Coalescing):
arduino
// OkHttp 连接合并条件(全部满足才触发):
// 1. 两个域名解析到同一IP
// 2. TLS证书匹配(SAN包含两个域名,或通配符如 *.yourapp.com)
// 3. 目标端口相同
//
// 实际效果:api.yourapp.com 和 cdn.yourapp.com 虽然域名不同
// 但共享同一条HTTP/2连接 → 握手成本只付一次
//
// 你需要确保的基础设施条件:
// - DNS解析到同一组IP(通过CDN配置或自有LB)
// - 使用通配符证书:*.yourapp.com
我们把7个子域名收敛成3个+通配符证书后,活跃连接数从平均12条降到4条,连接复用率直接从62%跳到了78%(还没做其他优化)。
TLS 1.3:省一个RTT的质变
连接复用解决了"能不能不握手"的问题。TLS 1.3解决的是"不得不握手时能不能更快"。
TLS 1.2握手要2个RTT。TLS 1.3把这砍到了1个RTT ,恢复会话(Session Resumption)可以做到0-RTT。怎么做到的?
首次连接=1 RTT:TLS 1.3合并了密钥协商和认证步骤。ClientHello里直接附带密钥参数(Key Share),服务端在ServerHello里一次性返回证书和密钥参数。一个来回搞定。
会话恢复=0-RTT:之前连过这个服务器?客户端缓存PSK(Pre-Shared Key),下次连接时ClientHello里直接带PSK+加密后的应用数据------握手还没完成,数据已经发出去了。
** 0-RTT的安全权衡**
0-RTT数据不具备前向保密性,且存在重放攻击风险。只适合幂等请求(GET),绝不能用于POST/PUT等写操作。服务端也需要做重放保护(如单次ticket机制)。
低版本Android启用TLS 1.3
Android 10+原生支持TLS 1.3。但你的用户不全是Android 10+。覆盖低版本设备的方案:
kotlin
/**
* 在Application.onCreate()中启用TLS 1.3
* 覆盖Android 7+ 设备
*/
fun enableTls13(context: Context) {
try {
// 方案1:Google Play Services(有GMS的设备)
ProviderInstaller.installIfNeeded(context)
} catch (e: Exception) {
try {
// 方案2:Conscrypt(无GMS的设备,如华为)
Security.insertProviderAt(Conscrypt.newProvider(), 1)
} catch (e2: Exception) {
Log.w("TLS", "TLS 1.3 not available on this device", e2)
}
}
}
升级后的收益一目了然:
连接建立总耗时(RTT=100ms)
• HTTP/1.1 + TLS 1.2 = 3 RTT = 300ms
• HTTP/2 + TLS 1.3(首次)= 2 RTT = 200ms
• HTTP/2 + TLS 1.3(0-RTT恢复)= 1 RTT = 100ms
• 连接池复用 = 0ms
QUIC/HTTP3:干掉TCP的最后一个队头阻塞
HTTP/2解决了HTTP层的队头阻塞,但TCP层的还在。什么意思?
TCP是有序可靠的字节流。一个TCP包丢了,即使后续包已经到达,TCP也不让应用层读------必须等丢失的包重传成功。HTTP/2下多个Stream共用一条TCP连接,一个Stream的丢包阻塞所有Stream。
移动端丢包率远高于有线网络(4G正常1-3%,弱信号下10%+)。一个讽刺的事实:高丢包环境下,HTTP/2可能比HTTP/1.1更慢------因为HTTP/1.1开了6个连接,一个连接丢包不影响其他5个。
QUIC从传输层根治了这个问题------基于UDP,每个Stream是独立传输单元,丢包只影响自身。加上0-RTT建连和连接迁移(WiFi切4G无缝续上),QUIC是移动端的最终形态。
Android端QUIC接入
scss
// 方案:Cronet(Chromium网络栈独立封装,QUIC原生支持)
// Google Play Services 内置,不增加APK体积
implementation("com.google.android.gms:play-services-cronet:18.1.0")
val engine = CronetEngine.Builder(context)
.enableQuic(true)
.enableHttp2(true)
.addQuicHint("api.yourapp.com", 443, 443) // 跳过Alt-Svc发现阶段
.setStoragePath(cacheDir.absolutePath)
.enableHttpCache(
CronetEngine.Builder.HTTP_CACHE_DISK,
10L * 1024 * 1024 // 10MB
)
.build()
// 桥接OkHttp API(保留拦截器生态)
implementation("com.google.net.cronet:cronet-okhttp:0.1.0")
val client = OkHttpClient.Builder()
.addInterceptor(CronetInterceptor.newBuilder(engine).build())
.build()
QUIC落地的几个坑
• 服务端先行:Nginx 1.25+ 支持HTTP/3,Caddy原生支持,但你得确保运维同学配好了
• UDP封锁:部分企业网络和运营商限制UDP流量。QUIC连接建不起来时,必须有TCP降级策略------Cronet会自动处理这个
• Alt-Svc发现延迟:正常流程是第一次走TCP,服务端通过Alt-Svc头告知"我支持QUIC",第二次才切。addQuicHint可以跳过这一步
• 调试困难:QUIC流量是加密的UDP包,tcpdump/Wireshark抓包比TCP难很多。qlog是QUIC的官方日志格式,建议开启
我们的QUIC灰度数据(10%流量,持续2周):
QUIC vs HTTP/2(相同服务端,相同用户群)
• 建连耗时 P50:QUIC 80ms vs HTTP/2 190ms(-58%)
• 弱网(丢包>5%)请求成功率:QUIC 96% vs HTTP/2 87%
• WiFi→4G切换期间请求失败率:QUIC 0.3% vs HTTP/2 4.2%
• UDP被封锁回退TCP的比例:约3%
连接迁移的数据最亮眼------网络切换导致的失败率从4.2%降到0.3%。这对通勤场景(WiFi→地铁4G→无信号→恢复信号)的体验提升是质的。
连接预建立:利用"认知间隙"
上一篇讲了DNS预解析------在用户操作之前把域名解析好。这一步更进一步:不仅解析好DNS,连TCP+TLS握手也提前做完。
原理很简单:用户的眼睛和大脑在处理页面跳转动画的300-500ms内,网络层在后台把下一个页面需要的连接建好放进池里。用户真正触发请求时,连接已经ready,请求直接发。
kotlin
/**
* 连接预热管理器
* 在关键节点提前建连,让后续请求零等待
*/
object ConnectionWarmUp {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 场景→需要预热的URL
private val configs = mapOf(
"app_launch" to listOf(
"https://api.yourapp.com/health",
"https://cdn.yourapp.com/ping"
),
"enter_product_list" to listOf(
"https://api.yourapp.com/product/prefetch"
),
"enter_payment" to listOf(
"https://pay.yourapp.com/health"
)
)
/**
* 触发预热------HEAD请求建连后立即关闭,连接留在池中
*/
fun warmUp(scene: String) {
val urls = configs[scene] ?: return
scope.launch {
urls.forEach { url ->
launch {
runCatching {
val req = Request.Builder().url(url).head().build()
client.newCall(req).execute().close()
}
}
}
}
}
}
// 使用时机:onCreate中触发(此时转场动画正在播放)
class ProductDetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ConnectionWarmUp.warmUp("enter_product_list")
// 转场动画约300ms,此时后台正在建连
// onResume中发真正的请求时,连接已经在池里了
}
}
弱网场景:连接降级与智能重试
前面讲的都是"好网络下怎么更快"。移动端的现实是,永远有用户在电梯里、地铁上、荒郊野外。弱网策略不是锦上添花,是底线。
实时网络质量探测
kotlin
/**
* 基于滑动窗口的网络质量分级
* 从EventListener收集RTT和失败数据,实时判断网络状况
*/
enum class NetworkGrade {
EXCELLENT, // RTT < 100ms, 丢包 < 1%
GOOD, // RTT < 300ms, 丢包 < 3%
MODERATE, // RTT < 800ms, 丢包 < 5%
POOR, // RTT < 2000ms, 丢包 < 10%
TERRIBLE // 更差
}
object NetworkProbe {
private val window = ArrayDeque<Long>(20) // 最近20次RTT
private var fails = 0
private var total = 0
fun onRtt(ms: Long) {
if (window.size >= 20) window.removeFirst()
window.addLast(ms); total++
}
fun onFail() { fails++; total++ }
fun grade(): NetworkGrade {
if (window.isEmpty()) return NetworkGrade.GOOD
val avg = window.average().toLong()
val loss = if (total > 0) fails.toFloat() / total else 0f
return when {
avg NetworkGrade.EXCELLENT
avg NetworkGrade.GOOD
avg NetworkGrade.MODERATE
avg NetworkGrade.POOR
else -> NetworkGrade.TERRIBLE
}
}
}
自适应策略+指数退避重试
kotlin
/**
* 自适应拦截器:根据网络质量调整超时和请求参数
*/
class AdaptiveInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
when (NetworkProbe.grade()) {
NetworkGrade.EXCELLENT, NetworkGrade.GOOD -> {
return chain
.withConnectTimeout(15, TimeUnit.SECONDS)
.withReadTimeout(30, TimeUnit.SECONDS)
.proceed(request)
}
NetworkGrade.MODERATE -> {
// 请求中清图片,加长超时
request = request.newBuilder()
.header("X-Image-Quality", "medium").build()
return chain
.withConnectTimeout(25, TimeUnit.SECONDS)
.withReadTimeout(45, TimeUnit.SECONDS)
.proceed(request)
}
NetworkGrade.POOR, NetworkGrade.TERRIBLE -> {
// 缩略图 + 标记降级 + 极端超时
request = request.newBuilder()
.header("X-Image-Quality", "low")
.header("X-Degradation", "true").build()
return chain
.withConnectTimeout(30, TimeUnit.SECONDS)
.withReadTimeout(60, TimeUnit.SECONDS)
.proceed(request)
}
}
}
}
/**
* 指数退避重试:失败后等待时间翻倍 + 随机抖动
* 避免服务端恢复时被重试洪流冲垮
*/
class RetryInterceptor(
private val maxRetries: Int = 3,
private val baseMs: Long = 500,
private val maxMs: Long = 8000
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var lastErr: IOException? = null
repeat(maxRetries + 1) { attempt ->
try {
val resp = chain.proceed(request)
if (resp.code in listOf(502, 503) && attempt < maxRetries) {
resp.close()
Thread.sleep(delay(attempt))
return@repeat
}
return resp
} catch (e: IOException) {
lastErr = e
if (attempt < maxRetries) Thread.sleep(delay(attempt))
}
}
throw lastErr ?: IOException("Retry exhausted")
}
private fun delay(attempt: Int): Long {
val exp = minOf(baseMs * (1L shl attempt), maxMs)
return exp + (exp * Random.nextFloat() * 0.5f).toLong()
// 500→1000→2000→4000... + 0~50%随机抖动
}
}
为什么加随机抖动(Jitter)?想象服务端刚从故障恢复,百万客户端同时重试。如果都是精确的1s、2s、4s间隔,流量会形成周期性脉冲,一波波冲击服务端。加了随机抖动,重试流量被打散均匀,服务端压力平滑得多。这是分布式系统的基本卫生。
综合实战效果
我们在同一个日活500万的App上,叠加了全部连接优化(上一篇DNS优化已上线):
优化组合拳
-
连接池调优(5→15空闲连接,keepAlive对齐服务端)
-
域名收敛(7→3域名 + 通配符证书启用连接合并)
-
TLS 1.3全量升级(Conscrypt覆盖低版本设备)
-
连接预建立(App启动 + 页面跳转两个时机)
-
HTTP/2 PING心跳30s + 死连接清理
-
弱网自适应 + 指数退避重试
AB测试结果(叠加DNS优化后的增量)
• 连接复用率:62% → 91%(+29pp)
• 新建连接握手耗时P50:220ms → 130ms(TLS 1.3贡献)
• 首屏接口总耗时P50:280ms → 195ms(-85ms)
• 首屏接口总耗时P99:900ms → 520ms(-380ms)
• 弱网请求成功率:82% → 94%(+12pp)
最关键的数字是连接复用率从62%到91%------每10个请求有9个免去了握手延迟,直接发数据。配合DNS缓存命中(P50=0ms),大部分请求从"用户点击"到"数据开始传输"的延迟接近于零。
弱网成功率82%→94%也很有价值。那12%原本会白屏的用户,现在至少能看到降级内容。这是用户留存率的隐形守护者。
小结与选型决策
这篇的核心思路很简单------尽量不建连(复用),不得不建连时尽量快(TLS 1.3/QUIC),连上后保持住别丢(心跳+降级)。具体结论:
• 连接池调参+监控复用率,目标>85%
• HTTP/2多路复用+域名收敛+连接合并,大幅减少活跃连接数
• TLS 1.3全量升级,首次握手省100ms,0-RTT恢复省200ms
• QUIC根治TCP队头阻塞,弱网环境质变(但需服务端配合)
• 连接预建立利用认知间隙,用户体感零延迟
• 弱网策略是底线不是锦上添花:探测→降级→退避重试
落地优先级建议:
第一步(零风险/立竿见影):连接池调参 + TLS 1.3启用 + PING心跳
第二步(中等投入):域名收敛 + 连接预建立 + 复用率监控
第三步(提升长尾):弱网探测 + 自适应降级 + 指数退避重试
第四步(长线投资):QUIC灰度 → 全量
到这一步,DNS和连接都已经优化到位------"找到服务器"和"连上服务器"的成本都压到了极致。接下来的瓶颈在数据本身 。下一篇我们聊数据压缩与缓存策略:怎么让该传的数据更小,怎么让不该传的数据根本不传。同样一个接口,传100KB和传10KB的差距,在3G网络下可以是好几秒。
Android网络优化系列 · 第3/5篇
从DNS到连接池,打造极速网络体验
第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱
第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路
第3篇:连接优化与复用:让每一次握手都物超所值(本篇)
⏳ 第4篇:数据压缩与缓存策略:把带宽用到极致
⏳ 第5篇:网络监控与容灾:让网络问题无处遁形
--- 系列持续更新中,关注不迷路 ---