某知名App因网络库连接超时导致支付失败工程师亲测修复内存泄漏与加密漏洞的全过程与避坑指南
说实话,那天凌晨三点的公司工位,空调嗡嗡响,我盯着日志屏幕,眼眶发酸。支付失败率突然飙到 12%,客服炸了,产品总监在群里连发 47 条语音。这不是普通的 bug,这是真金白银在流失。
今天把这三个月的踩坑血泪史摊开来讲,希望能帮到正在经历同样噩梦的你。
一、那个该死的超时,到底是谁的锅?
1.1 现象:支付请求石沉大海
我们的 App 用的是自研的 HTTP 网络层,底层封装了 OkHttp 3.x。某天晚高峰,监控大屏上「支付接口响应延迟 P99」从 800ms 瞬间拉到 15s+,部分请求直接超时。
用户点击「确认支付」,转圈转了三秒,弹出「网络异常,请重试」。
更诡异的是,这个问题只在特定运营商+特定城市复现,而且随机性极强。同样的设备、同样的网络,上午正常,下午可能就崩。
1.2 排查:从怀疑人生到找到元凶
我一开始以为是服务端问题,但服务端日志显示请求全部正常接收、正常返回。这就很奇怪了——请求发出去了,响应也回来了,但客户端没收着。
开始怀疑 SSL 握手失败、DNS 解析超时、连接池爆满……折腾了一周,最后用 tcpdump 抓包,发现一个细节:
部分 TCP 连接处于
FIN_WAIT2状态,长期无法关闭,导致连接池被占满,新请求拿不到连接,只能排队等待超时。
进一步深挖,发现是 OkHttp 连接池的一个已知问题:在 TLS 1.3 + 非默认 cipher suite 的场景下,某些 Android 系统的 SSLSocket 实现会在握手完成后延迟关闭连接,而 OkHttp 的连接回收机制没有正确处理这种情况。
1.3 修复:升级 + 手动兜底
第一步:把 OkHttp 从 3.14.9 升级到 4.9.3,这个问题在 4.x 版本中已经修复了连接回收逻辑。
// build.gradle.kts
implementation("com.squareup.okhttp3:okhttp:4.9.3")
但升级不是银弹,因为我们的低版本兼容策略要求不能全量升级。所以我加了一个连接回收的兜底逻辑:
class ConnectionLeakGuardInterceptor : Interceptor {
// 记录每个连接的创建时间
private val connectionRegistry = ConcurrentHashMap<String, Long>()
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val connectionKey = "${request.url.host}:${request.url.port}"
val startTime = System.currentTimeMillis()
// 标记连接开始使用
connectionRegistry[connectionKey] = startTime
return try {
chain.proceed(request)
} finally {
// 请求完成后延迟清理,避免过早回收正在复用的连接
connectionRegistry.remove(connectionKey)
}
}
/**
* 定期扫描,清理超过 60s 未使用的连接
* 防止连接池被异常连接占满
*/
fun scheduleStaleConnectionCleanup() {
executorService.scheduleAtFixedRate({
val now = System.currentTimeMillis()
val staleThreshold = 60_000L
connectionRegistry.entries.removeAll { (key, timestamp) ->
now - timestamp > staleThreshold
}
}, 0, 30, TimeUnit.SECONDS)
}
}
同时,我在 OkHttpClient 初始化时增加了连接池的手动配置:
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.connectionPool(ConnectionPool(
maxIdleConnections = 10, // 降低最大空闲连接数
keepAliveDuration = 30, TimeUnit.SECONDS // 缩短空闲连接存活时间
))
.addInterceptor(ConnectionLeakGuardInterceptor())
.build()
关键点:不要盲目调大连接池!很多开发者为了「性能」把 maxIdleConnections 设成 100,结果在连接泄漏场景下直接撑爆内存。我们的经验是:连接池大小 = 并发请求峰值 × 1.5,宁小勿大。
二、内存泄漏:那个吃掉 2GB 内存的「隐形杀手」
2.1 发现:OOM 日志背后的真相
支付超时修复后第二天,监控又报警了——低端机内存占用持续攀升,部分设备直接 OOM 崩溃。
MAT 分析出来一个惊人的结果:有大量 PaymentResultFragment 实例没有被回收,总计占用超过 800MB。
Analysis of hprof dump: payment_app.hprof
Heap size: 2.1GB
Largest memory leaks:
├─ com.app.feature.payment.PaymentResultFragment (847MB)
│ └─ static fields holding Activity reference
├─ com.app.network.ApiClient$Companion (312MB)
│ └─ singleton holding retrofit instance with activity context
└─ java.util.concurrent.ConcurrentHashMap (256MB)
└─ entries not cleared on process death
2.2 根因:单例持有 Context 的经典错误
罪魁祸首是我们早期的一个「工具类」设计:
// 早期错误写法(已被我们痛骂并修复)
object ApiClient {
// ❌ 错误:用 Application Context 没问题,但 Retrofit 初始化时传入了 Activity Context
@Volatile
private var instance: Retrofit? = null
fun getInstance(activity: Activity): Retrofit {
return instance ?: synchronized(this) {
// ❌ 这里把 activity 传入了 OkHttp 的缓存目录,间接持有 Context
val cacheDir = File(activity.externalCacheDir, "http_cache")
instance = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(buildClient(cacheDir)) // cacheDir 路径依赖 activity
.build()
instance!!
}
}
}
虽然 ApiClient 是单例,但 Retrofit 的 HTTP 缓存目录如果依赖 Activity,就会在每次初始化时隐式持有 Activity 引用,导致 Fragment 无法被 GC。
2.3 修复:彻底解耦 Context 依赖
object ApiClient {
@Volatile
private var instance: Retrofit? = null
/**
* 修复后的单例:使用 Application Context,完全解耦 Activity
*/
fun getInstance(context: Context): Retrofit {
// 必须用 Application Context
val appContext = context.applicationContext
return instance ?: synchronized(this) {
val cacheDir = File(appContext.cacheDir, "http_cache")
// 确保缓存目录不依赖 Activity
instance = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(buildClient(cacheDir, appContext))
.build()
instance!!
}
}
/**
* 提供一个显式的清理方法,在 App 退出时调用
*/
fun clearInstance() {
instance?.let { retrofit ->
retrofit.dispatcher.executorService.shutdown()
retrofit.client.connectionPool.evictAll()
retrofit.client.cache?.close()
}
instance = null
}
}
// 在 Application 的 onTerminate 或进程死亡前清理
class PaymentApp : Application() {
override fun onCreate() {
super.onCreate()
// 注册进程死亡监听,确保单例能被清理
ProcessLifecycleOwner.get().lifecycle.addObserver(LifecycleObserver {
onEvent(ON_DESTROY) { ApiClient.clearInstance() }
})
}
}
2.4 验证:用 LeakCanary 2.0 持续监控
// debug 版本启用 LeakCanary
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.10")
我们在支付模块加了自动化内存测试,每次支付流程结束后强制 GC,然后用 LeakCanary 的 assertActivityDestroyed() 验证:
@RunWith(AndroidJUnit4::class)
class PaymentMemoryLeakTest {
@get:Rule
val activityTestRule = ActivityTestRule(PaymentActivity::class.java)
@Test
fun `支付完成后的 Fragment 应被正确回收`() {
val activity = activityTestRule.activity
// 模拟支付流程
activity.findViewById<Button>(R.id.btn_pay).performClick()
// 等待支付完成
Thread.sleep(2000)
// 返回,触发 Fragment 销毁
activity.onBackPressed()
// 强制 GC
System.gc()
System.gc()
// LeakCanary 会在 5s 内检测泄漏,如果没触发则说明无泄漏
Thread.sleep(6000)
// 这里用 LeakCanary 的 API 验证(实际项目中可以用更优雅的方式)
// 如果存在泄漏,LeakCanary 会弹窗并生成 heap dump
}
}
避坑 Tip:别信「内存稳定就没泄漏」。很多泄漏是渐进式的,App 用了三天才爆内存。要用自动化测试+LeakCanary 持续监控,尤其是支付、订单这种长生命周期模块。
三、加密漏洞:HTTPS 不是免死金牌
3.1 发现:抓包抓到心脏骤停
支付超时修复后,安全团队做了一次渗透测试。结果报告里赫然写着:
严重:SSL Pinning 失效,中间人攻击可行
我一开始不信,自己用 Charles Proxy 抓包,果然——App 的 HTTPS 请求全部被成功解密。
3.2 根因:自签名证书信任链被绕过
我们的 App 做了 SSL Pinning,但代码写得像这样:
// ❌ 危险写法:Pinning 证书只在特定条件下生效
class UnsafeSslPinner : CertificatePinner {
override fun pin(connection: Connection, certificates: List<Certificate>) {
// 只在非 DEBUG 模式下 pin
if (BuildConfig.DEBUG) {
Log.w("SSL", "DEBUG 模式跳过 pinning") // 生产包也可能有 DEBUG flag残留
return // 直接 return 了!
}
// 只 pin 了一个证书,其他证书全部放行
val pinnedCert = certificates.firstOrNull { it is X509Certificate }
if (pinnedCert != null && isPinned(pinnedCert)) {
return // 只验证了第一个证书
}
// 其他证书不验证,直接放行
Log.w("SSL", "未找到匹配证书,放行")
}
}
这段代码有三个致命问题:
- DEBUG 模式直接跳过 pinning——打包时如果
DEBUG标志没清干净,生产包也会失效 - 只 pin 了一个证书——中间人可以用其他有效证书绕过
- 没有失败处理——pin 失败时直接放行,应该直接断开连接
3.3 修复:严格的双证书 Pinning
class StrictCertificatePinner : CertificatePinner {
companion object {
// 同时 pin 服务端证书 + CA 证书(双保险)
private val PIN_SET = setOf(
"sha256/$(Base64.encode(toServerCertSha256(), Base64.NO_WRAP))",
"sha256/$(Base64.encode(toCaCertSha256(), Base64.NO_WRAP))"
)
fun toServerCertSha256(): String {
val cert = loadCertFromRaw(R.raw.server_cert)
return sha256(cert.encoded)
}
fun toCaCertSha256(): String {
val cert = loadCertFromRaw(R.raw.ca_cert)
return sha256(cert.encoded)
}
fun sha256(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(data)
return Base64.encodeToString(hash, Base64.NO_WRAP)
}
private fun loadCertFromRaw(resId: Int): X509Certificate {
val inputStream = AppContextProvider.get().resources.openRawResource(resId)
val factory = CertificateFactory.getInstance("X.509")
return factory.generateCertificate(inputStream) as X509Certificate
}
}
override fun pin(connection: Connection, certificates: List<Certificate>) {
// 获取所有证书(服务端证书 + 中间证书 + CA 证书)
val x509Certificates = certificates.filterIsInstance<X509Certificate>()
if (x509Certificates.isEmpty()) {
throw SSLPeerUnverifiedException("无可验证的证书链")
}
// 验证每个证书是否在 pin 集合中(至少一个匹配即可)
val certificatePinner = CertificatePinner.DEFAULT
val isPinned = x509Certificates.any { cert ->
val pin = "sha256/$(Base64.encodeToString(cert.publicKey.encoded, Base64.NO_WRAP))"
// 实际应该用证书的指纹,不是公钥指纹
true // 简化演示,实际需要比对证书指纹
}
if (!isPinned) {
throw SSLPeerUnverifiedException("证书验证失败:服务端证书指纹不匹配")
}
}
}
// OkHttpClient 配置
val client = OkHttpClient.Builder()
.certificatePinner(CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
.build())
.build()
3.4 额外加固:本地存储加密
支付成功后,我们的 App 会在本地缓存部分敏感信息(订单号、金额)。这些缓存必须加密:
class SecurePaymentCache @Inject constructor(
@ApplicationContext private val context: Context
) {
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
private val keyStore = KeyStore.getInstance("AndroidKeyStore")
init {
keyStore.load(null)
generateKeyPair()
}
/**
* 生成非对称密钥对,私钥存储在 AndroidKeyStore 中,不可导出
*/
private fun generateKeyPair() {
if (!keyStore.containsAlias(PAYMENT_KEY_ALIAS)) {
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
PAYMENT_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
.build()
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_RSA,
"AndroidKeyStore"
)
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKeyPair()
}
}
/**
* 加密存储支付数据
*/
fun savePaymentData(orderId: String, data: PaymentData) {
val cipher = Cipher.getInstance(KeyProperties.TRANSFORMATION_RSA_OAEP)
cipher.init(Cipher.ENCRYPT_MODE, getPublicKey())
val encryptedBytes = cipher.doFinal(Json.encodeToString(data).toByteArray())
val encryptedBase64 = Base64.encodeToString(encryptedBytes, Base64.NO_WRAP)
preferences.edit()
.putString("payment_$orderId", encryptedBase64)
.apply()
}
/**
* 解密读取支付数据
*/
fun getPaymentData(orderId: String): PaymentData? {
val encryptedBase64 = preferences.getString("payment_$orderId", null) ?: return null
val encryptedBytes = Base64.decode(encryptedBase64, Base64.NO_WRAP)
val cipher = Cipher.getInstance(KeyProperties.TRANSFORMATION_RSA_OAEP)
cipher.init(Cipher.DECRYPT_MODE, getPrivateKey())
val decryptedBytes = cipher.doFinal(encryptedBytes)
return Json.decodeFromString(PaymentData.serializer(), decryptedBytes.decodeToString())
}
private fun getPublicKey(): PublicKey {
return keyStore.getKey(PAYMENT_KEY_ALIAS, null) as PublicKey
}
private fun getPrivateKey(): PrivateKey {
return keyStore.getKey(PAYMENT_KEY_ALIAS, null) as PrivateKey
}
companion object {
private const val PAYMENT_KEY_ALIAS = "payment_encryption_key"
}
}
关键点:别用 SecretKey 硬编码在代码里!AndroidKeyStore 才是正道,私钥永远不出硬件安全模块。
四、避坑总结:血泪换来的经验
4.1 网络层
- 连接池别贪大:
maxIdleConnections建议不超过 20,keepAliveDuration不超过 60s - 超时设置要分层:connectTimeout < readTimeout,差值至少 5s
- 重试要克制:支付接口坚决不设自动重试,失败直接抛给用户
4.2 内存管理
- 单例不持有 Context:尤其是 Activity Context,用
applicationContext - 生命周期感知:所有后台任务绑定到 LifecycleOwner
- 定期 GC + LeakCanary:支付这种核心模块,每次发版前跑一轮内存测试
4.3 安全
- SSL Pinning 必须严格:至少 pin 两个证书,失败必须断开连接
- 本地敏感数据必须加密:用 AndroidKeyStore 生成密钥,别自己造轮子
- 代码混淆 + 防调试:Release 包开启
minifyEnabled,检测 Root 和调试器
五、最后想说
那天凌晨修完 bug 后,我坐在工位上点了一杯咖啡,看着监控大屏上支付成功率慢慢回到 99.8%,突然觉得这三个月的脱发都值了。
做支付这种核心业务,每一个字节的内存、每一毫秒的延迟、每一行加密代码,都直接关系到用户的钱袋子。别想当然,别省测试,别信「应该没问题」。
希望这篇长文能帮到你。如果踩到了同样的坑,评论区见,一起吐槽。
注:本文涉及的代码均为简化示例,实际生产环境请根据业务场景调整。安全无小事,建议定期做渗透测试。
