20、缓存策略:内存缓存、磁盘缓存与网络缓存的三级缓存设计

缓存这东西,说白了就是用空间换时间。我在早期做项目时,总觉得缓存就是存个数据而已,后来被线上OOM和图片加载卡顿狠狠教育了一顿,才明白缓存设计的好坏,直接决定了App的生死。

今天咱们聊聊三级缓存——内存、磁盘、网络。这三层怎么配合,才能让App又快又稳。

为什么需要三级缓存?

你想想看,用户打开App,最怕什么?转圈、白屏、加载慢。三级缓存就是为了解决这个问题。

  • 内存缓存:最快,但容量小,App被杀就没了
  • 磁盘缓存:较快,容量大,App重启还在
  • 网络缓存:最慢,但数据源永远在那里

三级缓存的核心思想:读取时从最快层开始找,写入时层层同步

核心原则:读取走捷径,写入走全套。

三级缓存架构图 内存缓存(L1) 速度:纳秒级 | 容量:MB级 磁盘缓存(L2) 速度:毫秒级 | 容量:GB级 网络缓存(L3) 速度:秒级 | 容量:无限 读取路径 写入路径 ← 优先读取 ← 次优先 ← 兜底

内存缓存:最快但最脆弱

内存缓存,就是把数据放在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默认是不缓存的。我建议在客户端也做一层兜底——如果服务端没给缓存头,客户端自己决定缓存策略。

三级缓存如何协同工作?

三级缓存不是各管各的,而是一个完整的链路。读取时从最快层开始,写入时层层同步。

我画了一个读取流程,你感受一下:

  1. 读内存:有数据直接返回,没有就走下一步
  2. 读磁盘:有数据就返回,同时回写到内存缓存
  3. 读网络:拉取数据,同时回写到内存和磁盘
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组合
  • 缓存一致性靠版本号或主动清除

嗯,这套方案我在多个项目里验证过,效果都不错。你可以在自己的项目里试试,有问题随时交流。

公众号:蓝海资料掘金营,微信deep3321