缓存策略:Redis集成、本地缓存(Caffeine)、缓存失效策略

缓存这东西,说白了就是用空间换时间。我在项目里见过太多因为缓存没用好导致的线上事故——要么缓存穿透把数据库打崩,要么缓存雪崩让系统直接瘫痪。今天咱们就把缓存这块彻底聊透。

为什么需要多级缓存?

你想想看,一个请求过来,如果每次都查数据库,那数据库的压力得有多大?我见过一个电商项目,高峰期每秒几万次查询,数据库CPU直接飙到100%。后来加了缓存,数据库负载降到了20%以下。

但单级缓存也有问题。Redis再快,网络开销也摆在那。本地缓存虽然快,但容量有限。所以实际项目中,我习惯用两级缓存:

  • 一级缓存(本地):Caffeine,毫秒级响应,存热点数据
  • 二级缓存(分布式):Redis,存全量缓存数据

读取顺序:先查本地 → 没命中查Redis → 还没命中查数据库,然后回填到两级缓存。

核心原则:本地缓存存热点,Redis存全量。热点数据变化快,本地缓存过期时间要短。

多级缓存架构流程图 客户端请求 一级缓存(Caffeine) 命中率约70% · 响应时间 <1ms 二级缓存(Redis) 命中率约25% · 响应时间 1-5ms 数据库(兜底) 命中率约5% · 响应时间 10-50ms 读取顺序: ① 查Caffeine ② 未命中查Redis ③ 未命中查DB ④ 回填到两级缓存 💡 热点数据自动提升 到一级缓存

Redis集成实战

我在项目中用的是Spring Data Redis,配合Lettuce连接池。配置起来其实很简单,但有几个坑要注意。

依赖与配置

// build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.apache.commons:commons-pool2") // 连接池
# application.yml
spring:
  redis:
    host: 192.168.1.100
    port: 6379
    password: yourpassword
    lettuce:
      pool:
        max-active: 16
        max-idle: 8
        min-idle: 4
        time-between-eviction-runs: 30s

我的经验:连接池的max-active别设太大,16-32就够用。设太大反而会因为连接数过多导致Redis性能下降。我曾经踩过这个坑,把max-active设成100,结果Redis连接数打满,响应时间飙升。

序列化问题

默认的JdkSerializationRedisSerializer有个大问题——存进去的是二进制,没法在Redis客户端直接查看。我习惯用Jackson2JsonRedisSerializer:

@Configuration
class RedisConfig {

    @Bean
    fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, Any> {
        val template = RedisTemplate<String, Any>()
        template.connectionFactory = redisConnectionFactory
        
        // 使用Jackson序列化
        val serializer = Jackson2JsonRedisSerializer(Any::class.java)
        val mapper = ObjectMapper().apply {
            activateDefaultTyping(
                PolymorphicTypeValidatorImpl(),
                ObjectMapper.DefaultTyping.NON_FINAL
            )
            setSerializationInclusion(JsonInclude.Include.NON_NULL)
        }
        serializer.setObjectMapper(mapper)
        
        template.keySerializer = StringRedisSerializer()
        template.valueSerializer = serializer
        template.hashKeySerializer = StringRedisSerializer()
        template.hashValueSerializer = serializer
        
        return template
    }
}

注意:activateDefaultTyping要谨慎使用,它会在JSON里写入类型信息。如果对外暴露接口,可能有安全风险。内部使用没问题。

Caffeine本地缓存

Caffeine是Java生态里性能最好的本地缓存库,没有之一。我做过压测,它的读写性能比Guava Cache高出30%以上。

基本用法

// 构建一个缓存实例
val cache: Cache<String, UserProfile> = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)  // 写入后10分钟过期
    .maximumSize(10_000)                      // 最多存1万个条目
    .recordStats()                            // 开启统计
    .build()

// 读取缓存(带自动加载)
fun getUserProfile(userId: String): UserProfile {
    return cache.get(userId) { key ->
        // 缓存未命中时,从这里加载数据
        userRepository.findById(key)
    }
}

高级配置

配置项 说明 推荐值
expireAfterWrite 写入后固定时间过期 5-15分钟
expireAfterAccess 最后一次访问后过期 30分钟
maximumSize 最大条目数 取决于内存,一般1万-10万
weakKeys/weakValues 使用弱引用,允许GC回收 视场景而定

避坑指南:我曾经把expireAfterWrite设成1小时,结果热点数据在内存里堆积,导致频繁Full GC。后来改成10分钟过期 + 最大1万条,GC问题就解决了。本地缓存不是越大越好,要控制好内存占用。

缓存失效策略

这是缓存设计里最核心的部分。失效策略没选好,轻则数据不一致,重则系统雪崩。

三种常见策略

  1. TTL过期:最简单的策略,设置固定过期时间。适合变化不频繁的数据。
  2. 主动失效:数据更新时,主动删除或更新缓存。适合写多读少的场景。
  3. 懒加载+过期:读取时检查是否过期,过期则重新加载。适合读多写少的场景。

缓存穿透、击穿、雪崩

这三个问题,面试必问,项目必遇。我一个个说。

缓存穿透:查询一个不存在的数据,缓存和数据库都没有。每次请求都直接打到数据库。

// 解决方案:缓存空值
fun getUser(userId: String): User? {
    // 先查缓存
    val cached = redisTemplate.opsForValue().get("user:$userId")
    if (cached != null) {
        // 如果是空值标记,直接返回null
        if (cached == "NULL_VALUE") return null
        return cached as User
    }
    
    // 查数据库
    val user = userRepository.findById(userId)
    if (user == null) {
        // 缓存空值,过期时间设短一点,比如5分钟
        redisTemplate.opsForValue().set("user:$userId", "NULL_VALUE", 5, TimeUnit.MINUTES)
        return null
    }
    
    // 缓存真实数据
    redisTemplate.opsForValue().set("user:$userId", user, 30, TimeUnit.MINUTES)
    return user
}

缓存击穿:一个热点key过期,大量请求同时打到数据库。

// 解决方案:互斥锁
fun getHotProduct(productId: String): Product {
    // 先查缓存
    var product = redisTemplate.opsForValue().get("product:$productId") as? Product
    if (product != null) return product
    
    // 尝试获取锁
    val lockKey = "lock:product:$productId"
    val lockAcquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS)
    
    if (lockAcquired == true) {
        try {
            // 双重检查,防止重复加载
            product = redisTemplate.opsForValue().get("product:$productId") as? Product
            if (product != null) return product
            
            // 查数据库
            product = productRepository.findById(productId)
            redisTemplate.opsForValue().set("product:$productId", product, 30, TimeUnit.MINUTES)
            return product
        } finally {
            redisTemplate.delete(lockKey) // 释放锁
        }
    } else {
        // 没拿到锁,等待重试
        Thread.sleep(100)
        return getHotProduct(productId) // 递归重试
    }
}

缓存雪崩:大量key同时过期,或者Redis宕机,导致数据库被打爆。

我的做法

  • 过期时间加随机值:比如基础30分钟,再加0-5分钟的随机偏移
  • 本地缓存兜底:Redis挂了,Caffeine还能扛一阵
  • 限流降级:数据库扛不住时,直接返回旧缓存数据或友好提示

两级缓存同步方案

本地缓存和Redis之间怎么同步?这是个难题。我目前用的方案是:

// 数据更新时,同时失效两级缓存
fun updateUser(user: User) {
    // 1. 更新数据库
    userRepository.save(user)
    
    // 2. 删除Redis缓存
    redisTemplate.delete("user:${user.id}")
    
    // 3. 删除本地缓存(通过Redis消息通知其他服务)
    redisTemplate.convertAndSend("cache:invalidate", "user:${user.id}")
}

// 监听失效消息
@RedisListener(channel = "cache:invalidate")
fun handleCacheInvalidation(key: String) {
    localCache.invalidate(key)
}

注意:这种方案有短暂的不一致窗口。如果对一致性要求极高,可以考虑先删缓存再更新数据库,或者用分布式锁。但大多数业务场景下,最终一致性就够用了。

监控与统计

缓存用得好不好,得看数据说话。我习惯在项目里加一套缓存监控:

// Caffeine统计
val stats = cache.stats()
println("命中率: ${stats.hitRate()}")
println("加载耗时: ${stats.averageLoadPenalty()} ns")

// Redis统计
val redisStats = redisTemplate.execute { connection ->
    connection.serverCommands().info("stats")
}

命中率低于80%就要警惕了。要么缓存容量太小,要么过期时间太短。我一般把目标定在90%以上。

总结一下:缓存设计没有银弹。热点数据用本地缓存扛,全量数据用Redis存,数据库做兜底。失效策略要根据业务场景选,穿透、击穿、雪崩这三个坑一定要提前预防。监控数据要盯着,发现问题及时调整。


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