启动优化越“异步化”,为什么线上越容易出“偶现”:问题不是慢,是初始化依赖被藏起来
把 200ms 的收益换成不可复现的竞态和排障成本,通常不值
首屏指标降下去了,线上却开始出现一种最烦的故障:偶现、难复现、像是玄学。
崩溃栈不稳定,日志看起来也都“正常”,偶尔还能自愈。回头看改动记录,大家都在做同一件事:把启动阶段的初始化拆散,延后,异步化,并发化,让冷启动更快。
问题不在于“慢没了”,而在于“依赖没了”,或者更准确地说,依赖被藏起来了。
这篇我想把一个真实排查里最关键的判断讲透:**启动优化的坑,往往不是把首屏做慢了,而是把首个业务交互放进了半初始化状态。**你省下的 200ms,最后可能全花在偶现崩溃、错状态、兜底互相覆盖,以及团队排障的时间上。
问题背景:首屏更快了,第一个点击偶现崩溃
故障描述很典型:
- Android 冷启动更快了,首屏白屏时间下降
- 线上有小比例用户,在“首屏后的第一个点击”偶现崩溃或错状态
- 崩溃栈有时在业务模块,有时在网络层,有时在埋点 SDK
- 本地和测试环境几乎复现不了,灰度也复现不稳定
这类问题最容易被误判成“线上环境差异”“机型兼容”“第三方 SDK 抽风”。但当它和一次启动优化改动高度相关时,我会先把它当成一个更朴素的东西:竞态条件。
核心判断:异步化不是优化手段,它是在改系统的就绪语义
很多启动优化的直觉是:
- 重 IO 的东西移到后台线程
- 重 CPU 的东西并行
- 非首屏关键的初始化 delay 到首屏之后
这些在指标上几乎总是“有效”的。
但它们同时做了一件更危险的事:把原来隐含在“顺序执行”里的依赖关系抹掉了。
以前你在 Application#onCreate() 里按顺序初始化:A -> B -> C。哪怕没有人写文档,系统也默认了一个事实:
- 当
onCreate()结束,A/B/C 至少已经跑过
后来你把它们拆成:
- A 立即执行
- B 交给一个异步任务
- C 交给另一个异步任务
这时 onCreate() 结束不再意味着“系统就绪”,它只意味着“我把任务扔出去了”。
而线上第一个点击,往往发生在你并不预期的时间点:首屏渲染完成,用户马上点,或者某个自动行为触发了导航。
于是第一个业务交互落在一个尴尬区间:
- 有些依赖已经初始化完了
- 有些还在跑
- 有些失败了但被悄悄兜底
- 有些还没开始,因为被 delay
这不是“慢”,这是状态不完整。
论证过程:问题怎么一步步收敛到“半初始化”
排这类偶现问题,我不会先盯崩溃栈。我先做三件事,把“不可复现”变成“可解释”。
1) 先画启动依赖图,不要画模块图
模块图回答“谁依赖谁”,但启动问题要回答的是:
- 哪些初始化是必须在首次交互前完成的
- 哪些初始化失败会影响业务语义
- 哪些初始化只是锦上添花
我会按“首次交互”这个边界,把启动依赖拆成三类:
- 必须就绪(Hard Ready):不就绪就不能允许进入关键路径,比如登录态、鉴权 token、路由表、关键线程模型(比如主线程/协程调度器的约束)、崩溃上报最小集。
- 可降级(Soft Ready):没就绪可以进入业务,但要可控地降级,比如推荐缓存、AB 实验、埋点增强字段。
- 可延后(Deferred):完全可以晚点做,且不影响首次交互语义,比如预热、图片解码器初始化、非关键 SDK。
这一步的价值是把争论从“异步不异步”变成“这个依赖在什么边界前必须完成”。
2) 给每个依赖一个“就绪契约”,否则异步化等于赌博
所谓就绪契约,就是明确两件事:
- 谁来判断它 ready 了
- 没 ready 的时候业务怎么走
没有就绪契约的异步化,常见表现是:
- 调用方以为初始化已经完成,直接用
- 初始化方以为调用方不会这么早用
- 两边都没错,线上错在“时序”
我见过最典型的一次崩溃,是把一个单例的初始化改成 lazy + async。
伪代码像这样:
object Foo {
@Volatile private var inited = false
fun initAsync() {
GlobalScope.launch(Dispatchers.IO) {
// 读配置/解密/拉取远端
inited = true
}
}
fun doWork() {
check(inited) { "Foo not initialized" }
// ...
}
}
首屏指标会变好,但 doWork() 的调用时机一旦提前到 init 结束之前,就变成“偶现”。
更糟的是,很多代码不会 check(inited),而是继续跑,产生错状态,直到更后面才爆。
3) 把竞态的“窗口”量出来,而不是靠感觉
异步化让问题出现的必要条件是:
- 首次交互发生在某个初始化完成之前
所以我会加两类日志(注意不是海量日志,是可对齐的时间点):
t0:进程启动/Application.onCreate开始t1:首屏可交互(不是首帧,是真正可点)t_ready(X):每个关键依赖 ready 的时间点
然后看一眼分布:
- 有多少比例
t1 < t_ready(Auth) - 有多少比例
t1 < t_ready(Router) - 以及它们是否和机型、网络、冷热启动、系统版本相关
一旦你能量化这个窗口,很多“偶现”会突然变得不神秘:它就是一个概率事件。
误区与失败案例:兜底写得越多,越像在制造更难排的故障
启动异步化之后,团队很自然会加兜底:
- 依赖没 ready 就用默认值
- 配置没拉到就走上次缓存
- AB 没拿到就落到 control
单看每一条都很合理,但它们会产生两个副作用。
误区 1:兜底把“依赖缺失”变成“语义漂移”
崩溃其实好排,错状态最难排。
比如登录态没 ready,你兜底成“未登录”。这在首个点击触发跳转时,会把用户带到错误页面。后续真正登录态 ready 了,又把页面状态打回去,于是出现“闪一下”“跳回去”“偶现退出登录”。
日志里你会看到一堆“正常”的分支:都是按设计兜底了。但用户体验是坏的,而且你很难把它和启动优化联想到一起。
误区 2:兜底互相覆盖,导致排障证据链断裂
依赖 A 没 ready,走了兜底路径。
同时依赖 B 没 ready,也走了兜底路径。
最后业务表现像是 B 的问题,但根因是 A。
更现实的是:你为了“不要崩”,把异常吞了,把失败记录成 debug,线上就只剩一个“结果不对”。
这就是“不可复现”的来源之一:你把关键的失败信号抹掉了。
怎么修:把“异步化”改成“可验证的就绪边界”
要解决这种问题,通常不是“把异步改回同步”,而是把系统的启动语义重新收紧。
我会按成本从低到高做三步。
1) 明确一个可执行的 Ready Gate
对 Hard Ready 的依赖,给一个统一的 gate:
- 首次交互前必须过 gate
- 过不了就阻断关键操作,或者给出明确的降级路径
比如在首个点击入口处(导航/路由/关键按钮)加一个小的检查:
- ready 了就继续
- 没 ready 就显示 loading,或者先排队
这一步的关键不是 UI,而是把“依赖未就绪”从隐性竞态变成显性状态。
2) 让初始化变成“有状态的任务”,而不是 fire-and-forget
很多初始化用 GlobalScope.launch 或线程池直接扔出去,失败了就失败了。
更可控的做法是:
- 每个初始化都有状态:
NotStarted / Running / Ready / Failed - 调用方拿到的是一个可 await 的句柄(哪怕你最终不 await)
伪代码:
sealed class InitState {
data object NotStarted : InitState()
data object Running : InitState()
data object Ready : InitState()
data class Failed(val error: Throwable) : InitState()
}
class Initializer {
@Volatile private var state: InitState = InitState.NotStarted
private val deferred = CompletableDeferred<Unit>()
fun start() {
if (state != InitState.NotStarted) return
state = InitState.Running
scope.launch(Dispatchers.IO) {
runCatching {
// do init
}.onSuccess {
state = InitState.Ready
deferred.complete(Unit)
}.onFailure {
state = InitState.Failed(it)
deferred.completeExceptionally(it)
}
}
}
suspend fun awaitReady() = deferred.await()
}
这让两件事成立:
- 你可以选择在哪里 await
- 你不会再“以为它好了”
3) 给延迟初始化设置边界和回滚开关
延迟初始化不是不行,但它需要边界条件:
- 哪些用户/场景可以延迟(比如仅冷启动,还是热启动也延迟)
- 失败时怎么处理(重试、禁用、回滚)
- 灰度如何观察(ready window 的分布、失败率、降级比例)
我更愿意把“启动异步化”做成一个可回滚的策略开关,而不是一次性代码改动。
因为你一旦线上发现偶现问题,能最快止血的通常不是“修好竞态”,而是“把这次异步化回滚”。
适用边界:什么时候异步化是赚的,什么时候是亏的
异步化是赚的前提是:
- 依赖是 Soft Ready 或 Deferred
- 就绪契约明确,失败有证据链
- ready window 小且稳定,且不会跨越首次交互
异步化是亏的典型场景:
- 依赖属于 Hard Ready,但你为了指标把它挪走
- 你用兜底掩盖失败,导致语义漂移
- 你没有 ready gate,让竞态变成概率事件
一句话总结:你能解释它什么时候没 ready,并且业务能在没 ready 时保持语义一致,异步化才算优化。
结尾
冷启动优化最容易被 KPI 驱动成一个单目标问题:把首屏做快。
但启动阶段真正要守住的是“系统什么时候算可用”。你把初始化拆得越碎,越需要把就绪语义写清楚,写到代码里,写到观测里。
否则你做的不是优化,是把确定性的慢,换成概率性的错。