缓存击穿:当热点 key 过期,数据库瞬间被压垮
假设你负责一个商品详情服务,商品信息缓存在 Redis 中,缓存 key 的过期时间设为 30 分钟。平时请求量平稳,缓存命中率 95% 以上,数据库负载很低。但某个爆款商品突然被大量用户访问,恰好在同一时刻该商品的缓存过期,于是成千上万个请求同时发现缓存 miss,全部穿透到数据库查询。数据库连接池瞬间被打满,查询延迟从 5ms 飙升到 2s,紧接着出现大量超时和错误。这就是典型的缓存击穿:一个热点 key 过期,大量并发请求同时回源,数据库成为瓶颈。
缓存击穿与缓存雪崩不同。缓存雪崩是大量 key 同时过期,导致整体流量冲击数据库;而缓存击穿是单个热点 key 过期,但并发量极高,同样能压垮数据库。常见的缓解思路包括:设置热点 key 永不过期、使用互斥锁(Mutex)保证只有一个线程回源、或者使用 Spring Cache 提供的 sync 属性。
Spring 的 @Cacheable 注解默认不提供同步保护。当多个线程同时调用同一个方法且缓存 miss 时,它们会各自执行目标方法,各自回源数据库,造成重复查询。sync 属性正是为了解决这个问题而设计的。
@Cacheable 的同步语义与限制
@Cacheable 注解的 sync 属性在官方 Javadoc 中描述为:“Synchronize the invocation of the underlying method if several threads are attempting to load a value for the same key.” 即当多个线程尝试为同一个 key 加载值时,同步底层方法的调用。
但同步是有代价的,官方文档明确列出了两个限制:
unless()属性不被支持。因为unless是在方法执行后根据结果决定是否缓存,而同步模式下,其他线程需要等待第一个线程的结果,无法在方法执行前得知结果,因此无法使用unless。- 只能指定一个 cache name。因为同步机制需要针对单个缓存操作进行加锁,多个缓存名称会破坏同步的粒度。
这两个限制在实际使用中需要特别注意。如果你原本依赖 unless 来过滤某些结果(例如不缓存空值),那么使用 sync=true 后,你需要改用其他方式,比如在方法内部判断并返回一个特殊值,或者使用 CacheErrorHandler 处理异常。
同步加载的实现机制:CacheInterceptor 与 Cache.get(key, Callable)
Spring Cache 的同步功能由 CacheInterceptor 拦截器实现。当 @Cacheable(sync=true) 的方法被调用时,CacheInterceptor 会执行以下流程:
- 解析缓存名称和 key。
- 调用
Cache.get(key)尝试获取缓存值。 - 如果缓存 miss,则调用
Cache.get(key, Callable)方法,传入一个Callable,该Callable封装了目标方法的调用逻辑。 Cache.get(key, Callable)是同步加载的核心:它保证对于同一个 key,只有一个线程会执行Callable,其他线程会阻塞等待结果。
关键在于 Cache.get(key, Callable) 的实现。不同的 Cache 实现有不同的同步策略。
以 Caffeine 为例,Caffeine 的 Cache.get(key, Callable) 使用 ConcurrentHashMap 的 compute 方法,保证同一个 key 的加载操作是原子的。当多个线程同时调用 get(key, callable) 时,只有一个线程会执行 callable,其他线程会阻塞直到结果可用。Caffeine 内部还支持配置过期策略和最大容量,并且提供了 CacheLoader 接口,可以异步加载。
而对于 Redis,Spring Data Redis 提供了 RedisCache 实现,其 get(key, Callable) 方法在默认情况下并不保证同步,它只是简单地调用 get(key),如果 miss 则执行 callable 并放入缓存。这意味着直接使用 Redis 作为缓存提供者时,sync=true 并不能真正防止缓存击穿,因为多个线程可能同时执行 callable。
因此,在使用 Redis 时,需要额外配置锁机制,例如使用 Redis 的 SETNX 命令实现分布式锁,或者使用 Redisson 等库提供的锁。Spring 官方文档也指出,同步功能依赖于底层 Cache 实现的支持,对于不支持原子加载的缓存提供者,需要自行处理。
贯穿场景:商品详情缓存与锁竞争
让我们回到商品详情服务。假设我们使用 Caffeine 作为本地缓存,并使用 @Cacheable(cacheNames = "product", key = "#id", sync = true) 来缓存商品信息。当某个商品 id 的缓存过期时,多个线程同时请求该商品,Caffeine 会保证只有一个线程执行数据库查询,其他线程等待结果。这确实避免了缓存击穿。
但同步加载引入了锁竞争问题。Cache.get(key, Callable) 的同步粒度是 key 级别的,也就是说,对于不同的 key,锁是独立的,不会互相阻塞。这看起来很好,但要注意,Caffeine 内部使用 ConcurrentHashMap,其 compute 方法会锁定该 key 对应的 bin(哈希桶)。如果多个 key 恰好映射到同一个 bin,它们会竞争同一把锁。虽然这种竞争通常很短暂,但在极端高并发下,可能成为瓶颈。
此外,如果目标方法执行时间较长(例如数据库查询耗时 200ms),那么等待的线程会阻塞在 get 调用上,占用线程资源。如果并发量极大,等待线程数可能堆积,导致线程池耗尽。
再看 Redis 场景。假设我们使用 Redis 作为缓存,并配置了 sync=true,但 RedisCache 的默认实现并不支持同步加载。此时,我们需要自己实现分布式锁。常见的做法是:
- 在缓存 miss 时,尝试获取分布式锁(例如 SETNX lock:product:123)。
- 如果获取锁成功,再次检查缓存(双检),如果仍 miss,则执行数据库查询并写入缓存,最后释放锁。
- 如果获取锁失败,则等待一小段时间后重试,或者直接读取缓存(可能已被其他线程写入)。
这种方式可以防止缓存击穿,但引入了分布式锁的竞争。在高并发下,大量线程会竞争同一把锁,导致锁等待和超时。如果锁的粒度是 key 级别的,那么不同 key 的锁是独立的,但同一 key 的竞争依然存在。
不同缓存提供者的锁机制对比
下表对比了 Caffeine、Redis(默认)和 Redis(配合分布式锁)在 sync=true 下的行为:
| 缓存提供者 | 同步加载支持 | 锁粒度 | 锁竞争风险 | 适用场景 |
|---|---|---|---|---|
| Caffeine | 是(使用 ConcurrentHashMap 的 compute) | key 级别(内部哈希桶) | 低(本地锁,无网络开销) | 单机应用,本地缓存 |
| Redis(默认 RedisCache) | 否(get 和 put 是独立操作) | 无锁 | 无(但无法防止击穿) | 分布式缓存,但需要额外配置 |
| Redis + 分布式锁(如 SETNX) | 是(需自行实现) | key 级别(分布式锁) | 高(网络开销和锁等待) | 分布式缓存,且需要同步保护 |
从表中可以看出,Caffeine 的同步加载是天然支持的,且锁竞争风险较低,因为锁是 JVM 内部的,没有网络开销。而 Redis 默认不支持同步加载,需要借助分布式锁,这会引入网络开销和锁竞争。
锁竞争与性能权衡
使用 sync=true 时,锁竞争是不可避免的,但可以通过合理设计降低其影响。
锁粒度:尽量使用 key 级别的锁,避免使用全局锁。Caffeine 的 compute 方法天然是 key 级别的。对于 Redis 分布式锁,锁的 key 应该包含缓存 key,例如 lock:product:123,这样不同商品的锁互不干扰。
锁超时:在分布式锁中,必须设置锁的超时时间,防止持有锁的线程崩溃导致死锁。同时,获取锁的等待时间不宜过长,否则会阻塞大量线程。
双检锁:在获取锁后,再次检查缓存是否已被其他线程写入,可以避免重复查询数据库。
本地缓存与分布式缓存结合:使用 Caffeine 作为一级缓存,Redis 作为二级缓存。当本地缓存 miss 时,先查询 Redis,如果 Redis 也 miss,则使用分布式锁回源数据库。这样可以减少对 Redis 的访问,降低分布式锁的竞争。
热点 key 永不过期:对于已知的热点 key,可以设置为永不过期,但在更新时主动更新缓存,或者使用后台任务刷新。
异常处理:自定义 CacheErrorHandler
当缓存操作抛出异常时,默认的 CacheErrorHandler 会传播异常,导致业务方法调用失败。但在某些场景下,我们不希望缓存异常影响主流程。例如,在商品详情服务中,如果 Redis 暂时不可用,我们希望降级为直接查询数据库,而不是抛出异常。
Spring 允许通过实现 CacheErrorHandler 接口来自定义异常处理逻辑。该接口定义了四个方法:
handleCacheGetError(RuntimeException exception, Cache cache, Object key):处理缓存读取异常。handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value):处理缓存写入异常。handleCacheEvictError(RuntimeException exception, Cache cache, Object key):处理缓存清除异常。handleCacheClearError(RuntimeException exception, Cache cache):处理缓存清空异常。
一个常见的实现是:在 handleCacheGetError 中记录日志并返回,不抛出异常,这样当缓存读取失败时,方法会继续执行,直接访问数据库。但需要注意,这可能导致缓存击穿,因为缓存不可用时,所有请求都会回源。因此,需要结合降级策略,例如使用本地缓存或限流。
在 handleCachePutError 中,通常可以忽略异常,因为缓存写入失败不会影响业务结果,但会降低缓存命中率。
自定义 CacheErrorHandler 需要注册为 Spring Bean,例如:
@Configuration
public class CacheConfig {
@Bean
public CacheErrorHandler cacheErrorHandler() {
return new CacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
// 记录日志,不抛出异常
log.warn("Cache get failed for key: {}", key, exception);
}
// 其他方法类似
};
}
}
注意,如果 handleCacheGetError 不抛出异常,那么 Cache.get(key) 会返回 null,Spring 会认为缓存 miss,从而执行目标方法。这可能导致缓存击穿,因此需要谨慎使用。
配置建议与最佳实践
综合以上分析,针对缓存击穿和锁竞争问题,可以给出以下配置建议:
-
优先使用 Caffeine 作为本地缓存,并开启
sync=true。Caffeine 的同步加载是高效的,且锁竞争风险低。适合单机应用或微服务实例内的缓存。 -
如果必须使用 Redis 作为缓存,不要依赖
sync=true,而是自行实现分布式锁。使用 Redisson 等成熟库可以简化实现,并提供了可重入锁、公平锁等特性。 -
设置合理的锁超时和等待时间。锁超时时间应大于数据库查询的最长耗时,但不宜过长,以免阻塞其他线程。等待时间可以设置为 100ms 左右,超时后直接查询数据库(但需要限制并发)。
-
使用双检锁。在获取锁后再次检查缓存,避免重复查询。
-
结合本地缓存与分布式缓存。使用 Caffeine 作为一级缓存,Redis 作为二级缓存,减少对 Redis 的访问和分布式锁的竞争。
-
对于热点 key,考虑永不过期,并通过后台任务定期刷新。
-
自定义 CacheErrorHandler,处理缓存异常,避免缓存故障影响业务。但要注意降级策略,防止缓存击穿。
总结
@Cacheable(sync=true) 是 Spring Cache 提供的同步加载机制,它通过 Cache.get(key, Callable) 保证同一 key 只有一个线程回源,从而防止缓存击穿。但同步加载引入了锁竞争,特别是在分布式缓存场景下,锁竞争可能成为新的瓶颈。理解不同缓存提供者的锁机制,合理配置锁超时和等待时间,并结合本地缓存和异常处理,才能有效平衡缓存击穿防护与锁竞争。
在实际项目中,应根据缓存提供者、并发量和业务容忍度,选择合适的方案。Caffeine 适合本地缓存,Redis 需要分布式锁,而本地缓存与分布式缓存的结合往往是最佳实践。
附录:同步加载流程示意
以下流程图展示了 @Cacheable(sync=true) 在 Caffeine 下的同步加载流程:
flowchart TD
A[请求到达] --> B{缓存命中?}
B -- 是 --> C[返回缓存值]
B -- 否 --> D[调用 Cache.get(key, Callable)]
D --> E{是否有线程正在加载?}
E -- 否 --> F[执行 Callable 查询数据库]
F --> G[写入缓存]
G --> H[返回结果]
E -- 是 --> I[阻塞等待结果]
I --> H
在 Redis 场景下,如果使用分布式锁,流程会变为:
flowchart TD
A[请求到达] --> B{缓存命中?}
B -- 是 --> C[返回缓存值]
B -- 否 --> D[尝试获取分布式锁]
D -- 成功 --> E{再次检查缓存?}
E -- 命中 --> F[释放锁]
F --> C
E -- 未命中 --> G[查询数据库]
G --> H[写入缓存]
H --> I[释放锁]
I --> J[返回结果]
D -- 失败 --> K[等待后重试或直接查询]
K --> J
注意,在 Redis 场景中,如果获取锁失败,直接查询数据库可能导致缓存击穿,因此需要限制并发或使用其他降级策略。