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过期 + 主动失效的组合。

具体做法:

  1. 设置合理的TTL(比如5-10分钟)
  2. 数据更新时,主动删除对应的缓存
  3. 下次查询时,缓存未命中,自动加载最新数据

这样做的好处是:即使主动失效失败了(比如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
  • 一定要设置过期时间,不要偷懒
  • 监控缓存命中率,及时调整策略

缓存没有银弹。每种策略都有适用场景,关键是要理解背后的原理,然后根据业务特点做选择。

缓存策略知识体系 本地缓存 分布式缓存 注解驱动 Caffeine expireAfterWrite maximumSize Redis Lettuce / Jedis setex / expire @Cacheable @CacheEvict 拦截器实现 失效策略:TTL过期 | 主动失效 | 延迟双删 缓存穿透 缓存击穿 缓存雪崩

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