20、缓存策略:内存缓存、磁盘缓存与网络缓存的三级缓存设计
缓存这东西,说白了就是用空间换时间。我在早期做项目时,总觉得缓存就是存个数据而已,后来被线上OOM和图片加载卡顿狠狠教育了一顿,才明白缓存设计的好坏,直接决定了App的生死。
今天咱们聊聊三级缓存——内存、磁盘、网络。这三层怎么配合,才能让App又快又稳。
为什么需要三级缓存?
你想想看,用户打开App,最怕什么?转圈、白屏、加载慢。三级缓存就是为了解决这个问题。
- 内存缓存:最快,但容量小,App被杀就没了
- 磁盘缓存:较快,容量大,App重启还在
- 网络缓存:最慢,但数据源永远在那里
三级缓存的核心思想:读取时从最快层开始找,写入时层层同步。
核心原则:读取走捷径,写入走全套。
内存缓存:最快但最脆弱
内存缓存,就是把数据放在App进程的内存里。读取速度是纳秒级的,但App一被杀,数据就没了。
我个人习惯用 LruCache 来做内存缓存。为什么?因为它自带LRU淘汰策略,不会让内存无限膨胀。
class MemoryCache(private val maxSize: Int = 4 * 1024 * 1024) {
private val lruCache = object : LruCache<String, Bitmap>(maxSize) {
override fun sizeOf(key: String, value: Bitmap): Int {
return value.byteCount // 按字节数计算大小
}
}
fun get(key: String): Bitmap? = lruCache.get(key)
fun put(key: String, bitmap: Bitmap) {
lruCache.put(key, bitmap)
}
fun remove(key: String) {
lruCache.remove(key)
}
fun clear() {
lruCache.evictAll()
}
}
避坑指南:我曾经在项目中直接用HashMap做缓存,结果内存涨到300MB被系统杀后台。后来换成LruCache,设置最大4MB,问题就解决了。记住:内存缓存一定要设上限。
磁盘缓存:持久化但别太慢
磁盘缓存,就是把数据写到文件或数据库里。App重启后数据还在,但读取速度是毫秒级的。
磁盘缓存我推荐用 DiskLruCache 或者直接用 Room 数据库。前者适合文件型数据(图片、视频),后者适合结构化数据(JSON、列表)。
class DiskCache(private val cacheDir: File) {
private val cache = DiskLruCache.open(cacheDir, 1, 1, 50 * 1024 * 1024) // 50MB上限
fun get(key: String): InputStream? {
val snapshot = cache.get(key) ?: return null
return snapshot.getInputStream(0)
}
fun put(key: String, inputStream: InputStream) {
val editor = cache.edit(key) ?: return
editor.newOutputStream(0).use { output ->
inputStream.copyTo(output)
}
editor.commit()
}
fun remove(key: String) {
cache.remove(key)
}
fun close() {
cache.close()
}
}
注意:磁盘缓存不要在主线程读写。我曾经在UI线程读一个10MB的图片,直接卡了500ms,用户反馈说App点不动。后来用协程切到IO线程,问题解决。
网络缓存:兜底方案
网络缓存,就是当内存和磁盘都没有数据时,从服务器拉取。这是最慢的一层,但也是数据最全的一层。
网络缓存通常配合 OkHttp 的缓存机制来做。OkHttp支持服务端返回的 Cache-Control 头,自动管理缓存有效期。
val cacheDir = File(context.cacheDir, "http_cache")
val cache = Cache(cacheDir, 10 * 1024 * 1024) // 10MB
val okHttpClient = OkHttpClient.Builder()
.cache(cache)
.addInterceptor { chain ->
val request = chain.request()
// 无网络时强制使用缓存
if (!isNetworkAvailable()) {
val forcedCacheRequest = request.newBuilder()
.header("Cache-Control", "only-if-cached")
.build()
chain.proceed(forcedCacheRequest)
} else {
chain.proceed(request)
}
}
.build()
关键点:网络缓存要配合服务端做。如果服务端没返回 Cache-Control,OkHttp默认是不缓存的。我建议在客户端也做一层兜底——如果服务端没给缓存头,客户端自己决定缓存策略。
三级缓存如何协同工作?
三级缓存不是各管各的,而是一个完整的链路。读取时从最快层开始,写入时层层同步。
我画了一个读取流程,你感受一下:
- 读内存:有数据直接返回,没有就走下一步
- 读磁盘:有数据就返回,同时回写到内存缓存
- 读网络:拉取数据,同时回写到内存和磁盘
class CacheManager(
private val memoryCache: MemoryCache,
private val diskCache: DiskCache,
private val networkDataSource: NetworkDataSource
) {
suspend fun getData(key: String): Data {
// 1. 读内存
memoryCache.get(key)?.let {
return it
}
// 2. 读磁盘
diskCache.get(key)?.let {
memoryCache.put(key, it) // 回写内存
return it
}
// 3. 读网络
val data = networkDataSource.fetch(key)
memoryCache.put(key, data) // 回写内存
diskCache.put(key, data) // 回写磁盘
return data
}
}
个人经验:我在一个新闻App里用了这套三级缓存,首页加载从3秒降到了200ms。用户反馈说「打开App就有内容,不用等」。其实核心就是:让用户感觉不到网络的存在。
缓存淘汰策略
缓存不能无限增长,必须淘汰旧数据。常见的淘汰策略有:
| 策略 | 原理 | 适用场景 |
|---|---|---|
| LRU(最近最少使用) | 淘汰最久没被访问的数据 | 通用场景,最常用 |
| LFU(最不经常使用) | 淘汰访问次数最少的数据 | 热点数据集中场景 |
| FIFO(先进先出) | 淘汰最早进入缓存的数据 | 数据时效性强的场景 |
| TTL(过期时间) | 数据超过指定时间就淘汰 | 新闻、广告等有时效的数据 |
我个人习惯用 LRU + TTL 组合。LRU保证热点数据不被淘汰,TTL保证过期数据不展示给用户。
缓存一致性
缓存最头疼的问题是什么?数据不一致。内存里是旧数据,磁盘里是新数据,用户看到的就是混乱的。
解决办法:
- 写入时同步:写入网络后,同步更新内存和磁盘
- 设置过期时间:数据过期后强制从网络拉取
- 使用版本号:每次更新数据时递增版本号,读取时对比版本
我曾经踩过的坑:在一个电商App里,用户下单后库存没及时更新,导致超卖。后来我改成「写入网络成功后,主动清除相关缓存」,下次读取时强制走网络,问题才解决。
总结
三级缓存设计,说白了就是:内存快但小,磁盘大但慢,网络全但最慢。把它们组合起来,让用户感觉不到加载的存在。
记住几个要点:
- 内存缓存一定要设上限,用LruCache
- 磁盘缓存用DiskLruCache或Room,别在主线程读写
- 网络缓存配合OkHttp,客户端做兜底
- 读取从最快层开始,写入层层同步
- 缓存淘汰用LRU+TTL组合
- 缓存一致性靠版本号或主动清除
嗯,这套方案我在多个项目里验证过,效果都不错。你可以在自己的项目里试试,有问题随时交流。