18、缓存策略:内存缓存(Caffeine)、Redis集成、缓存注解、失效策略
缓存这东西,说白了就是用空间换时间。我在做后端时,见过太多接口因为没加缓存,数据库被打到CPU飙升。也见过缓存策略设计不合理,导致数据不一致,用户看到过期数据跑来投诉。
这一章,我们就来聊聊Ktor里怎么玩缓存。从本地内存到分布式Redis,从手动管理到注解驱动,再到失效策略的坑,我都会结合自己的实战经验来讲。
18.1 为什么需要缓存?
先问个问题:你的API响应时间是多少?
如果超过200ms,用户就能感觉到卡顿。如果超过1秒,用户可能就关掉App了。缓存能帮你把响应时间从几百毫秒降到几毫秒。
我遇到过最夸张的情况:一个查询用户订单的接口,每次都要查数据库、调用外部服务、做一堆计算。响应时间平均800ms。加上缓存后,直接降到5ms。你想想看,这体验差距有多大。
缓存主要解决两个问题:
- 性能:减少重复计算,降低响应时间
- 保护:减轻数据库和下游服务的压力
18.2 内存缓存:Caffeine
本地缓存是最简单、最快的方案。数据存在应用进程里,没有网络开销。Ktor里我推荐用Caffeine,它是Guava Cache的升级版,性能更好。
核心要点:Caffeine是Java生态里最快的本地缓存库,没有之一。
18.2.1 添加依赖
// build.gradle.kts
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("io.ktor:ktor-server-core:$ktor_version")
18.2.2 基本用法
我个人习惯把缓存封装成一个单例或者依赖注入的Bean。这样方便管理和测试。
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import java.util.concurrent.TimeUnit
object UserCache {
val cache: Cache<String, UserProfile> = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
.maximumSize(10_000) // 最多缓存1万条
.recordStats() // 记录统计信息
.build()
}
// 使用
fun getUserProfile(userId: String): UserProfile {
return UserCache.cache.get(userId) { key ->
// 缓存未命中时,从数据库加载
userRepository.findById(key)
}
}
这里有个细节:get()方法的第二个参数是加载函数。缓存未命中时自动调用,并把结果存入缓存。这个设计很优雅,避免了先查缓存、再查数据库的重复代码。
18.2.3 缓存大小与淘汰策略
Caffeine支持多种淘汰策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| maximumSize | 基于条目数量 | 内存可控,推荐使用 |
| maximumWeight | 基于权重(如字节数) | 缓存对象大小不均时 |
| expireAfterWrite | 写入后固定时间过期 | 数据变化不频繁 |
| expireAfterAccess | 最后一次访问后固定时间过期 | 热点数据自动淘汰 |
| expireAfter | 自定义过期策略 | 需要动态计算过期时间 |
我的建议:如果内存不是特别紧张,优先用maximumSize + expireAfterWrite组合。简单、可预测、不容易出bug。
18.3 分布式缓存:Redis集成
本地缓存有个硬伤:多实例部署时,每个实例的数据不一致。用户请求打到不同服务器,看到的结果可能不一样。
这时候就需要Redis了。它是分布式缓存的事实标准。
18.3.1 添加依赖
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.lettuce:lettuce-core:6.3.1.RELEASE")
// 或者用 Jedis
// implementation("redis.clients:jedis:5.1.0")
我个人更倾向Lettuce,它是异步驱动的,性能更好,而且支持响应式编程。
18.3.2 连接配置
import io.lettuce.core.RedisClient
import io.lettuce.core.api.sync.RedisCommands
object RedisConfig {
private val client: RedisClient = RedisClient.create("redis://localhost:6379")
val connection = client.connect()
val sync: RedisCommands<String, String> = connection.sync()
}
嗯,这里要注意:生产环境一定要配置密码和连接池。我曾经见过有人直接把Redis暴露在公网上,结果被挖矿程序盯上了。
18.3.3 缓存操作封装
class RedisCache(private val redis: RedisCommands<String, String>) {
fun <T> get(key: String, type: Class<T>): T? {
val value = redis.get(key) ?: return null
return Json.decodeFromString(type, value)
}
fun <T> set(key: String, value: T, ttlSeconds: Long = 300) {
val json = Json.encodeToString(value)
redis.setex(key, ttlSeconds, json)
}
fun invalidate(key: String) {
redis.del(key)
}
}
这里我用了setex而不是set,因为设置过期时间是必须的。不设过期时间的缓存,就是内存泄漏。
18.4 缓存注解:让代码更简洁
手动管理缓存太啰嗦了。每个方法都要写get、set、过期时间。能不能像Spring那样,加个注解就搞定?
Ktor没有内置的缓存注解,但我们可以自己实现。用Kotlin的注解处理器或者AOP思想。
18.4.1 定义注解
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Cacheable(
val key: String,
val ttl: Long = 300,
val cacheName: String = "default"
)
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CacheEvict(
val key: String,
val cacheName: String = "default"
)
18.4.2 实现拦截器
class CacheInterceptor(private val cacheManager: CacheManager) {
suspend fun intercept(call: ApplicationCall, body: suspend () -> Any): Any {
val handler = call.attributes[KtorCallAttributes.handler]
val cacheable = handler::class.findAnnotation<Cacheable>()
if (cacheable != null) {
val key = resolveKey(cacheable.key, call)
val cached = cacheManager.get(key)
if (cached != null) return cached
val result = body()
cacheManager.set(key, result, cacheable.ttl)
return result
}
return body()
}
}
我曾经踩过的坑:注解的key解析一定要小心。如果key里包含用户ID,但解析时用了错误的参数位置,就会导致缓存错乱。A用户看到B用户的数据,这问题很严重。
18.5 失效策略:缓存一致性
缓存最头疼的问题是什么?数据不一致。
数据库更新了,缓存还是旧数据。用户看到的就是错误信息。
18.5.1 常见失效策略
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| TTL过期 | 设置固定过期时间 | 简单、无侵入 | 数据可能短暂不一致 |
| 主动失效 | 更新数据库后删除缓存 | 一致性高 | 需要额外代码 |
| 双写 | 同时更新数据库和缓存 | 实时性最好 | 并发下容易出问题 |
| 延迟双删 | 先删缓存,再更新数据库,延迟后再删一次 | 解决并发问题 | 实现复杂 |
18.5.2 我推荐的方案
对于大多数业务场景,我建议用TTL过期 + 主动失效的组合。
具体做法:
- 设置合理的TTL(比如5-10分钟)
- 数据更新时,主动删除对应的缓存
- 下次查询时,缓存未命中,自动加载最新数据
这样做的好处是:即使主动失效失败了(比如Redis宕机),TTL也能兜底,保证数据最终一致。
18.5.3 缓存穿透、击穿、雪崩
这三个问题,面试常问,实战也常遇到。
- 缓存穿透:查询一个不存在的数据。缓存没有,数据库也没有。每次请求都打到数据库。
- 缓存击穿:热点key突然失效,大量请求同时打到数据库。
- 缓存雪崩:大量key同时过期,数据库瞬间压力暴增。
解决方案:
// 缓存穿透:布隆过滤器
class BloomFilterCache(private val bloomFilter: BloomFilter<String>) {
fun get(key: String): String? {
if (!bloomFilter.mightContain(key)) return null
// 继续查缓存和数据库
}
}
// 缓存击穿:互斥锁
fun getWithMutex(key: String): String {
var value = cache.get(key)
if (value == null) {
synchronized(this) {
value = cache.get(key)
if (value == null) {
value = loadFromDb(key)
cache.set(key, value)
}
}
}
return value
}
// 缓存雪崩:过期时间加随机值
val ttl = 300 + Random.nextLong(0, 60) // 5分钟 + 随机0-60秒
cache.setex(key, ttl, value)
我的经验:缓存雪崩最容易发生在整点过期。比如所有缓存都设置10分钟过期,那每10分钟就会有一次数据库压力高峰。加随机值后,压力就分散了。
18.6 缓存监控与调优
缓存不是配好就完事了。你需要知道缓存命中率、内存使用情况、热点key分布。
Caffeine自带了recordStats(),可以获取统计信息:
val stats = UserCache.cache.stats()
println("命中率: ${stats.hitRate()}")
println("平均加载时间: ${stats.averageLoadPenalty()}ns")
println("淘汰数量: ${stats.evictionCount()}")
Redis可以用INFO命令查看内存使用,或者用MONITOR命令实时监控。
我个人习惯在监控面板上放三个指标:
- 缓存命中率(低于80%就要优化)
- 缓存大小(接近上限时预警)
- 过期淘汰数量(突然增加说明热点变化)
18.7 总结
缓存是后端性能优化的第一把刀。用好了,响应时间从秒级降到毫秒级。用不好,数据不一致、内存溢出、雪崩效应,各种问题接踵而来。
我的建议是:
- 读多写少的数据,优先加缓存
- 本地缓存用Caffeine,分布式缓存用Redis
- 一定要设置过期时间,不要偷懒
- 监控缓存命中率,及时调整策略
缓存没有银弹。每种策略都有适用场景,关键是要理解背后的原理,然后根据业务特点做选择。