缓存策略:Redis集成、本地缓存(Caffeine)、缓存失效策略
缓存这东西,说白了就是用空间换时间。我在项目里见过太多因为缓存没用好导致的线上事故——要么缓存穿透把数据库打崩,要么缓存雪崩让系统直接瘫痪。今天咱们就把缓存这块彻底聊透。
为什么需要多级缓存?
你想想看,一个请求过来,如果每次都查数据库,那数据库的压力得有多大?我见过一个电商项目,高峰期每秒几万次查询,数据库CPU直接飙到100%。后来加了缓存,数据库负载降到了20%以下。
但单级缓存也有问题。Redis再快,网络开销也摆在那。本地缓存虽然快,但容量有限。所以实际项目中,我习惯用两级缓存:
- 一级缓存(本地):Caffeine,毫秒级响应,存热点数据
- 二级缓存(分布式):Redis,存全量缓存数据
读取顺序:先查本地 → 没命中查Redis → 还没命中查数据库,然后回填到两级缓存。
核心原则:本地缓存存热点,Redis存全量。热点数据变化快,本地缓存过期时间要短。
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问题就解决了。本地缓存不是越大越好,要控制好内存占用。
缓存失效策略
这是缓存设计里最核心的部分。失效策略没选好,轻则数据不一致,重则系统雪崩。
三种常见策略
- TTL过期:最简单的策略,设置固定过期时间。适合变化不频繁的数据。
- 主动失效:数据更新时,主动删除或更新缓存。适合写多读少的场景。
- 懒加载+过期:读取时检查是否过期,过期则重新加载。适合读多写少的场景。
缓存穿透、击穿、雪崩
这三个问题,面试必问,项目必遇。我一个个说。
缓存穿透:查询一个不存在的数据,缓存和数据库都没有。每次请求都直接打到数据库。
// 解决方案:缓存空值
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