搞移动端开发的十有八九都遇到过这种情况用户打开App首页先看到一个白屏或者转圈等两三秒数据才出来要是正好在电梯里、地铁上信号一差用户可能直接退出去换竞品。首页加载慢这个问题最直接的解药就是缓存机制一句话说就是把重复要用的数据放在离用户更近、读取更快的地方省掉网络请求和数据库查询这两条最耗时的链路。这篇文章我从移动端App的角度把缓存机制拆开揉碎聊一遍重点解决两件事一是怎么靠缓存把首页加载效率提上去二是怎么缓解数据库的查询压力让SQLite和远端数据库都不至于被打垮。内容适合刚接触缓存、被线上卡顿问题折磨过的客户端开发也适合做性能优化的同学拿来当实操参考。1. 缓存机制与首页加载的底层逻辑1.1 首页慢的根源是什么很多人一提起首页慢就怪接口慢但实际从点击图标到看到首页内容整条链路上到处都是耗时点进程启动、Activity初始化、布局解析、网络请求、JSON解析、数据库回填、列表渲染。每一步都有开销单纯优化一个环节往往解决不了问题。我习惯把这条链路拆成一张表格来看每个环节都能找到对应的优化空间环节典型耗时是否可控进程启动与Activity初始化100ms ~ 500ms部分可控网络请求弱网环境下1s ~ 5s不可控JSON解析50ms ~ 500ms可控数据库查询10ms ~ 200ms可控列表渲染50ms ~ 300ms可控网络请求是最不可控的一块尤其是弱网和跨地域访问时RTT往返时延甚至会到几秒。缓存机制的价值就在这里它能把“每次都走网络”变成“大部分时候走本地”直接把用户等待方差降下来。数据库查询看起来只要几十毫秒但很多App会把首页接口返回的数据先落库再渲染如果查询写法糟糕、没走索引或者直接在主线程执行叠加网络和解析时间用户感知到的就不只是卡而是ANR。所以数据库层面的缓存不只是给服务端减压客户端本地数据库同样需要缓存手段来减少重复查询。1.2 缓存到底在解决什么问题缓存本质上是“空间换时间”核心目标有三个减少网络IO、减少计算和磁盘IO、提升用户体验。举一个最简单的例子。某个首页接口返回的数据是用户看到的一屏商品列表假设这个接口每天被调用上千万次其中90%的请求访问的都是同一份热门数据。如果没有缓存数据库就要扛下上千万次查询加了一层缓存以后绝大多数请求会在缓存层直接命中数据库只承担剩下的很小一部分查询压力。移动端本地也是同一个道理。SQLite查一次只需要几十毫秒但如果在同一个页面里反复查询同一个用户资料、同一个配置项叠加起来就会造成明显的卡顿。把这些热点数据放到内存缓存里后续读取速度可以提升到微秒级数据库查询次数则降了一个数量级。与此同时用户的体验也顺了第二次进入首页不再需要等网络而是直接从内存里把数据拉出来。1.3 哪些数据适合进缓存不是所有数据都适合加缓存强行缓存实时性要求高的数据反而会出事。适合进缓存的数据有三个特征读多写少、实时性要求不高允许秒级甚至分钟级延迟、重复访问率高。我在实际项目里会先把数据按类型分清楚再决定各自的缓存策略数据类型典型例子缓存策略静态配置城市列表、功能开关、版本信息长TTL优先磁盘缓存个性化数据用户首页信息流、个人设置短TTL内存磁盘双写热点数据首页Banner、爆款商品内存缓存优先强实时数据支付状态、聊天消息、股票价格不缓存或极短TTL隐私敏感数据账单明细、聊天记录加密存储或避免缓存这个分类做不好后面设计缓存键和过期时间都会很被动。最典型的反面例子是把实时库存这种强实时数据缓存了5分钟用户下单前明明看到有货提交订单却提示无货这种问题比加载慢更劝退。2. 缓存分层架构与方案选型2.1 移动端的三层缓存模型移动端缓存通常可以划分为三层内存缓存、磁盘缓存含本地数据库、网络层缓存。读取顺序也是由快到慢先内存再磁盘最后网络。可以用下面这条链路来描述完整流程内存缓存命中直接返回 未命中 - 磁盘缓存/本地数据库命中回填内存并返回 未命中 - 发起网络请求成功后写入内存 写磁盘第一层内存缓存的速度是最快的微秒级访问适合存放当前Session内高频访问的小数据。它的问题是进程被杀就丢容量也受限于App可用内存。典型实现是LruCache或LinkedHashMap。第二层磁盘缓存和本地数据库负责持久化。进程被杀、手机重启之后数据还在App冷启动时可以直接从这层恢复首页数据避免每次都重新请求网络。典型实现是DiskLruCache和SQLite/Room。第三层网络层缓存则是基于HTTP协议本身的缓存能力比如OkHttp的Cache模块配合服务端返回的Cache-Control头客户端可以直接复用304响应。这一层很多人忽略但它能省下的带宽和流量很可观。2.2 数据库和缓存怎么配合在移动端缓存层和本地数据库经常是重叠的。严格来说SQLite/Room既是一个持久化存储也可以承担磁盘缓存的功能。我常用的做法是这样的首页接口返回的JSON先写入内存缓存供当次启动快速使用同时写入SQLite表供下次冷启动恢复使用。启动时先查内存缓存未命中再查SQLiteSQLite也没有才发起网络请求。这样数据库的角色从“每次都要查”变成了“网络请求失败或进程被杀时的兜底”数据库性能自然就上来了。需要注意缓存写库不能走主线程。SQLite的写操作如果发生在UI线程很容易触发ANR。Room配合协程可以很轻松地把插入操作切到IO线程同时用事务把多次写入合并成一次减少磁盘fsync次数。高频读操作也要尽量避免在SQLite上做而是优先命中内存缓存才能真正减少数据库压力。2.3 具体技术选型参考选型这件事没有标准答案但有比较稳妥的搭配。我整理了移动端常用的几类缓存方案和它们适合的场景技术方案适合场景关键注意点LruCache内存缓存存放小对象、JSON字符串必须重写sizeOf否则可能OOMDiskLruCache磁盘缓存存放文件型数据key需要做hash文件名长度有限制Room / SQLite结构化数据缓存、本地持久化做好索引和事务避免主线程IOOkHttp CacheHTTP缓存自动处理304需要配置Cache目录服务端配合返回缓存头DataStore / SharedPreferences小配置项、轻量KV不适合存大量数据频繁写会有性能问题我还想提醒一点不要一上来就把所有页面都套上缓存先挑读多写少、用户感知最强的页面动手。缓存机制本身也有维护成本比如版本升级后数据不兼容、清理策略、缓存命中率打点这些都需要人力去保障。先在小范围跑通再逐步推广比一次引入五套缓存方案要靠谱得多。3. 实战落地让首页从3秒变成0.3秒3.1 缓存键设计是命中率的根基缓存键设计是很多开发者容易忽略但又特别关键的一环。键设计得不好同一个接口因为参数顺序不同、字段多了一个空值就会生成不同的key缓存命不中等于白做了。我的做法是把请求的方法、路径、所有参数排序后拼起来再加上用户维度标识最后做一次MD5生成固定长度的字符串作为keyfun buildCacheKey( method: String, path: String, params: MapString, Any?, userId: String? ): String { val sortedParams params.toSortedMap() .filter { it.value ! null } .map { ${it.key}${it.value} } .joinToString() val raw $method#$path?$sortedParamsuid${userId ?: guest} return raw.md5() }这里有两个细节值得展开说。第一个是参数排序。Map默认迭代顺序不一定稳定同样的请求可能因为参数插入顺序不同产生两个key。toSortedMap能保证无论客户端怎么传参最终生成的缓存键始终一致。第二个是userId维度。不区分用户做缓存一旦切换账号就会出现A用户看到了B用户的隐私数据这是线上事故级别的Bug。所以退出登录时一定要记得清理用户维度的缓存。3.2 内存缓存层LruCache落地内存缓存我优先用AndroidX的LruCache因为它内置了LRU淘汰策略容量达到上限时会自动移除最久未使用的条目。核心代码很简单val maxMemory (Runtime.getRuntime().maxMemory() / 1024).toInt() val cacheSize maxMemory / 8 val homeCache object : LruCacheString, String(cacheSize) { override fun sizeOf(key: String, value: String): Int { return value.toByteArray().size.coerceAtLeast(1) } } fun getHomeCache(key: String): String? homeCache.get(key) fun saveHomeCache(key: String, json: String) { homeCache.put(key, json) }我见过很多次线上内存泄漏和OOM原因基本都出在同一个地方没有重写sizeOf。LruCache默认按“条目数量”计算大小如果你不重写sizeOf一个几百KB的JSON和一个几字节的字符串会被当成一样的权重缓存容量完全失控。所以要严格按value占用的字节数来计算。另一个常见问题是LruCache本身不是线程安全的虽然它的get和put方法内部有synchronized但如果你的业务逻辑里做了“先get判断再put覆盖”这样的复合操作仍然需要自己加锁。否则多线程并发读写时数据可能会互相覆盖。3.3 磁盘缓存层DiskLruCache实操DiskLruCache是Google官方推荐的磁盘缓存实现但API比较原始需要自己封装。它有两点要求key必须符合[a-z0-9_-]{1,64}正则所以常见的做法是先把key做SHA-256或MD5再使用同时edit()返回null时说明该key正在被其他线程编辑需要处理并发重试。一个简化但可用的封装长这样class DiskCache( private val cacheDir: File, private val appVersion: Int, private val maxSize: Long ) { private val diskLruCache DiskLruCache.open(cacheDir, appVersion, 1, maxSize) fun put(key: String, value: String) { val editor diskLruCache.edit(key.sha256()) ?: return editor.set(0, value) editor.commit() } fun get(key: String): String? { val snapshot diskLruCache.get(key.sha256()) ?: return null return snapshot.getString(0) } }磁盘操作一定要放到子线程这点我再强调一次。哪怕只有几十毫秒的磁盘IO放到主线程也会造成掉帧严重时直接ANR。我通常用协程的IO调度器包一层对上层暴露suspend函数。DiskLruCache的maxSize需要结合实际数据量来定。对首页数据来说50MB以内的磁盘缓存基本够用太大了反而会在缓存目录遍历时产生额外耗时。版本号appVersion也很重要App升级后如果缓存结构有变化需要递增版本号让旧缓存失效否则解析旧JSON会直接崩。3.4 SQLite/Room作为持久化双保险磁盘缓存虽然能持久化但它的查询能力很弱只能按key取整个文件。如果首页数据需要部分更新、按条件查询还是得靠SQLite。用Room做缓存表核心思路是建一张cache表字段包括缓存key、JSON内容、创建时间和过期时间Entity(tableName home_cache) data class HomeCacheEntity( PrimaryKey val cacheKey: String, val json: String, val createTime: Long, val expireTime: Long ) Dao interface HomeCacheDao { Query(SELECT * FROM home_cache WHERE cacheKey :key) suspend fun getByKey(key: String): HomeCacheEntity? Insert(onConflict OnConflictStrategy.REPLACE) suspend fun insert(entity: HomeCacheEntity) }这里有个细节缓存表最好不要加太多索引因为这是一个KV结构primary key已经能覆盖绝大多数查询场景。索引过多会增加插入和更新的成本得不偿失。写入时注意用事务。Room的OnConflictStrategy.REPLACE虽然会自动执行更新但高频插入缓存时把所有写入包在一个withTransaction里可以明显减少SQLite的fsync次数性能会好很多。3.5 首页加载流程改造改造前的首页加载流程很直接启动 - 请求网络 - 解析JSON - 渲染列表。这个流程在弱网下体验非常差白屏时间等于网络耗时。改造后的流程变成了这样首页启动 - 读内存缓存命中则直接渲染 - 未命中读磁盘缓存/SQLite命中则先渲染旧数据 - 同时后台发起网络请求 - 请求成功更新内存缓存 磁盘缓存刷新UI - 请求失败保留旧缓存数据提示“上次更新于xx”这个模式在业界叫做“陈旧数据优先展示stale-while-revalidate”。用户第一眼看到的是本地旧数据界面不是空白的网络数据回来后UI再静默更新。这样做用户几乎感知不到转圈加载体验提升非常明显。3.6 量化收益用数据说话优化做完之后一定要有打点和量化不然你没法跟产品、后端解释这轮改动到底值不值。我在项目里会重点监控三个指标首页TTITime to Interactive用户可交互时间、缓存命中率、数据库查询次数。举一组我实际项目里优化后的数据变化指标改造前改造后首页TTI2.8s0.4s弱网下加载失败率12%1.5%单次启动数据库查询次数8次2次需要说明的是TTI降低主要靠“先展示本地缓存”的策略数据库查询次数的下降靠的是内存缓存命中。两个指标互相配合才能让用户觉得快且稳。4. 缓存一致性、过期策略与数据库保护4.1 缓存更新的几种模式移动端也会遇到缓存一致性问题典型的例子是用户在首页看到了商品价格是100元点进详情页后价格却变成了120元这就是首页缓存数据和服务端最新数据不一致造成的。在服务端常用Cache Aside模式来更新缓存移动端的更新逻辑也可以借鉴读操作先读缓存命中则返回未命中则读数据库/网络再回写缓存。写操作先更新数据库/服务端再删除缓存或更新缓存。这里值得解释一下为什么写操作是“删除缓存”而不是“更新缓存”。直接更新缓存会带来并发问题两个线程同时写缓存后写的可能把先写的旧值覆盖掉或者写入一个中间态。删除缓存则简单得多下次读取时发现没有缓存自然会去读最新数据再回填。在移动端最常见的写操作是用户点赞、收藏、修改个人信息。处理方式是先提交服务端成功后更新本地数据库和内存缓存中对应的字段同时触发一次受影响的列表刷新。如果网络失败则回滚本地变更避免本地缓存与服务端长时间不一致。4.2 过期策略TTL是缓存机制的灵魂给缓存设置一个合理的过期时间TTL是所有策略里投入产出比最高的一件事。我在实际项目中通常按数据维度设置不同的TTL数据场景建议TTL原因首页Banner5分钟允许更新延迟但不能太久用户信息流1分钟用户对新鲜度敏感城市列表/配置项1天基本不变缓存价值最大实时库存不缓存强实时缓存反而有害TTL不一定需要真的定时删除数据。为了减少额外IO我一般只在读取时判断当前时间 - createTime TTL 就认为缓存过期需要重新请求。过期数据在内存里多存在一会儿没关系因为下次读的时候它已经不可用了。这才是更省资源的姿势。4.3 防止数据库被打垮服务端视角与本地视角标题里提到“数据库性能”我不展开讲服务端架构只说结论缓存机制是数据库的最后一道防线。在高并发场景下比如电商大促首页QPS可能冲到上万如果每个请求都去查数据库数据库连接池会很快耗尽然后整站雪崩。加一层缓存后大部分请求在缓存层直接返回数据库的QPS可以降到原来的十分之一甚至百分之一。本地数据库也同理。一个列表页反复进入如果每次都去SQLite查相同的数据单用户看不出问题但乘以DAU后就是一个不小的负担。用内存缓存挡住高频重复查询SQLite只负责低频写入和冷启动恢复这是对数据库性能很有效的保护。4.4 淘汰策略LRU、LFU与容量控制缓存容量不可能无限大必须有淘汰策略。LruCache和DiskLruCache默认都是LRU也就是“最近最少使用优先淘汰”。这种策略适合大多数场景上次刚访问过的数据短时间内再次访问的概率最高。LFU最不经常使用则适合访问频率非常稳定的数据比如某些固定的配置项但它在移动端用得不多因为维护访问频率本身也要额外开销。容量控制的经验值内存缓存建议设为App最大可用内存的八分之一左右不要贪多要给图片解码、复杂布局这些真正的内存大户留空间。磁盘缓存放50MB以内就够绝大多数业务用了。容量再大缓存清理和目录遍历都会逐渐变成新的性能负担。5. 常见问题与排查技巧实录5.1 缓存穿透、击穿、雪崩同款三兄弟这三个概念最初来自服务端缓存但移动端缓存同样会遇到只是表现形态不同。缓存穿透是指请求的数据在缓存和数据库里都不存在。比如用户查询一个不存在的商品ID缓存没有数据库也没有每次请求都会穿透到数据库。解决思路是缓存空值即使接口返回的是一个空对象或null也把它缓存下来但TTL要设短一些避免空数据长期占用空间。更严格的场景可以用布隆过滤器先判断数据是否存在。缓存击穿是指某一个热点数据过期的瞬间大量并发请求同时打到数据库。在移动端对应的场景是冷启动时多个组件同时发现缓存miss于是同时发起网络请求。解决办法是锁合并同一个缓存key只允许一个请求发往网络其他请求等待这个请求完成后共享结果。我用过的简化版本是ConcurrentHashMap配合CompletableFuturekey存在时说明已经有请求在途其余协程直接等待同一个Future。缓存雪崩是指大量缓存同时过期导致数据库压力骤增。给TTL加上随机扰动就能很大程度上规避比如统一5分钟TTL实际执行时加0到60秒的随机偏移。这样热点数据就不会步调一致地集体过期。5.2 客户端缓存特有的一些坑除了上面三个经典问题移动端还有几个特别容易踩的坑。第一个坑是缓存数据膨胀。缓存写入没有上限或者淘汰机制没生效时间久了磁盘被缓存文件占满。解决思路是设置DiskLruCache的maxSize同时定期清理过期条目。别忘了在做缓存写入时判断当前缓存目录大小超出阈值时先把最老的缓存删掉再写新的。第二个坑是缓存脏数据。网络异常时服务端可能返回一个兜底数据或者错误码如果业务代码没有判断就直接写入缓存用户下次打开首页会一直看到错误数据。所以写缓存前一定要校验返回数据的数据结构、字段完整性和业务状态码校验不过就丢弃。第三个坑是用户切换导致的串数据。前面提到过缓存key必须带用户维度同时退出登录时主动清理该用户的缓存区域。别只清理内存磁盘缓存和本地数据库里的用户数据也要一起清理否则下次登录新账号时旧账号数据会短暂泄漏。5.3 排查工具与实战思路线上缓存出问题靠肉眼是看不出来的需要一套组合拳。我用得最多的是Android Studio自带的Profiler它能直观看到内存占用曲线和磁盘IO行为。结合StrictMode可以检测主线程上的磁盘读写和数据库访问但凡首页滑动时触发StrictMode告警基本就是有IO操作泄漏到主线程了。打点系统是另一个关键工具。我会给每次缓存读、缓存写、网络请求都加上日志记录耗时和命中状态。优化上线后通过日志看缓存命中率是否达到预期如果命中率太低就从缓存键设计、TTL设置、用户操作习惯三个方向排查。Room Inspector可以查看SQLite里缓存表的情况确认过期数据是否被正确清理索引是否生效。如果缓存表数据量增长过快说明淘汰策略没起作用需要回头检查清理任务的触发时机。5.4 一条自查清单把常见的坑整理成一张速查表排障的时候直接对照着看现象排查方向首页还是慢看打点日志确认缓存命中率是否偏低内存增长快检查LruCache的sizeOf是否按字节重写磁盘缓存不生效检查key的hash规则是否一致、appVersion是否变化数据串用户检查缓存key是否包含userId、退出登录是否清理缓存数据显示旧检查TTL是否过长网络刷新逻辑是否在后台执行主线程卡顿用StrictMode查磁盘缓存、SQLite操作是否落到主线程数据库中缓存表膨胀检查淘汰策略和过期清理任务是否正常执行这套清单是我每次做缓存性能优化复盘时固定的检查项。照着走一遍大部分问题都能定位到根因。最后再分享一点个人体会。我做过一个电商App的首页优化一开始目标只是“加载快一点”后来发现缓存机制本质上是在管理用户的等待预期。缓存做得好的项目用户感知到的不是快而是稳定——弱网下面页面还在数据回来以后悄悄更新。优化完首页之后我养成一个习惯每次接到性能问题先问自己三个问题——这个数据能不能缓存缓存在哪一层过期多久这三个问题想明白了问题基本就解决了一半。这套思路不只适用于首页任何读多写少的场景都可以套用。你可以先用首页做一个实验把TTI打点和缓存命中率记下来再决定要不要推广到其他页面。