缓存设计
前言
在许多场景下使用缓存可以显著提高系统性能,减轻数据库或其他后端服务的压力,但是缓存不是解决性能问题的银弹,它也有其局限性和潜在问题。如果不加以谨慎使用,缓存反而可能引入更多的复杂性和问题。
缓存常见问题
比较常见的问题是缓存和数据库一致性、缓存雪崩/穿透/击穿等。所以在使用缓存之前需要考虑业务能否接受这些问题带来的负面影响。
数据一致性
我们可以先来看下使用缓存的方式。
读操作:
首先检查缓存中是否有对应的结果。
如果缓存命中,则直接从缓存中返回结果,跳过对其他层的查询。
如果缓存未命中,会执行查询操作(比如从数据库获取数据),然后将结果存入缓存,便于后续请求直接从缓存读取。
写操作:
当更新数据库中的数据时,通常需要同步更新或删除缓存中的数据,以保持缓存和数据库的一致性。
写入时常见的两种操作方式是:
更新缓存:将新数据同时写入缓存和数据库。
删除缓存:在写入数据库后删除缓存中的数据,下次读取时缓存会自动从数据库重新加载最新数据。
以上就是Cache Aside 模式,即 “旁路缓存模式”,Spring Cache 默认采用的就是 Cache Aside 模式。
然而在并发请求下,就会出现缓存和数据库数据不一致的情况,那么采用哪种方案可以进行规避呢?
先更新数据库,还是先更新缓存?
先更新数据库,再更新缓存;
先更新缓存,再更新数据库;
以上不管选择哪种方式,数据库和缓存操作分成了 2 步进行,无法保持原子性,无法确保执行的先后顺序,那么在并发的情况下,很大概率会出现数据一致性的情况。
比如,先更新数据库方案,出现数据库为 B,缓存为 A 的数据不一致情况(频率高)。
比如:先更新缓存方案,出现数据库为 A,缓存为 B 的数据不一致情况(频率高)。
先更新数据库,还是先删除缓存?
先删除缓存,再更新数据库;
先更新数据库,再删除缓存;
先来看下先删除缓存,再更新数据库
,出现数据库为 A,缓存为 B 的数据不一致情况(频率高)。
再来看下先更新数据库,再删除缓存
,也出现数据库为 A,缓存为 B 的数据不一致情况(频率低)
在实际情况下,出现上述问题的概率较低,因为缓存的写入速度要远远高于数据库操作。那么读请求 B 会早于写请求 A ,就不会出现数据不一致情况。
但是网络是会出现抖动的,上述情况出现时,要不等缓存自动过期,或者采用延迟双删、人工干预等手段来修复数据之间的不一致。
如果业务可以接受一定的数据不一致,那么可以直接采用【先更新数据库,再删除缓存】 的方案。
缓存雪崩/击穿/穿透
在引入缓存层后,就会有缓存异常的三个问题,那就是缓存雪崩、缓存击穿、缓存穿透。
缓存雪崩:
💡缓存雪崩简单来说,指的是 大规模缓存失效 (常见于设置缓存时使用相同的过期时间,出现同时缓存过期)导致的海量请求直接查询数据库。压力到数据库的 CPU 或者内存,可能导致宕机或系统崩溃。
注:服务集体宕机也是导致缓存雪崩的重要原因。
缓存击穿:
💡缓存击穿指的是热点数据无法在缓存中获取(一般是热点 Key 过期),高并发场景下大量请求都直接指向数据库,这些并发请求可能瞬间压垮数据库 DB。
注:形象的说,就好比在缓存构建的防火墙上凿开了一个洞,然后压力都指向了数据库。
缓存穿透:
💡缓存穿透简单来说,就是查询的数据即不存在于数据库,又不存在于缓存中,每次请求都直接到数据库获取数据。这可以理解为缓存命中率的问题,也常见于被攻击的场景。(恶意用户或者爬虫等高频繁访问是穿透的高发场景)
整理一张表格,可以更好地了解缓存雪崩/击穿/穿透的区别以及解决方案。
缓存选型
那么在项目中如何进行缓存框架的选型呢?分为单体应用和分布式应用两种情况。
单体系统
本地缓存:Caffeine ,缺点是没有持久化,在应用重启后缓存会全部失效。
中间件缓存:Redis、Memcached ,在应用重启不会失效,但是每次还是需要进行 IO 访问。
分布式系统
中间件缓存:Redis、Memcached ,在应用重启不会失效,但是每次还是需要进行 IO 访问。
分布式本地缓存 + Redis Pub/Sub 机制:Caffeine + Redis Pub/Sub 机制,通过 Reids 消息通知分布式系统进行缓存数据的删除。
Caffeine 本地缓存 + Redis 缓存 :组合的多级缓存,当出现数据不一致时处理起来更加麻烦。
缓存实现
基于 Spring 通过 CacheManager 定义多种类型的缓存,便于项目不同的缓存需求。
@Slf4j
@Configuration
@EnableCaching
@Profile("!test")
public class CacheConfiguration {
public static final String CACHE_EVENT_TOPIC = "DISTRIBUTED_LOCAL_CACHE_EVENT_TOPIC";
@Value("${yueji.cache.redis.time-to-live}")
private String ttl;
@Value("${yueji.cache.caffeine.initial-capacity:20000}")
private int initialCapacity;
@Value("${yueji.cache.caffeine.maximum-size:20000}")
private int maximumSize;
@Value("${yueji.cache.caffeine.expire-after-access:30D}")
private Duration expireAfterAccess;
@Bean
@ConditionalOnClass({Caffeine.class, CaffeineCacheManager.class})
public Caffeine<Object, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(initialCapacity)
.maximumSize(maximumSize)
.expireAfterWrite(expireAfterAccess)
// 开启metrics监控
.recordStats();
}
@Primary
@Bean(name = "caffeineCacheManager")
@ConditionalOnProperty(prefix = "yueji.cache", name = "type", havingValue = "caffeine")
@ConditionalOnClass({Caffeine.class, CaffeineCacheManager.class})
public CacheManager caffeineCacheManager(Caffeine<Object, Object> caffeineCache) {
CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
caffeineCacheManager.setCaffeine(caffeineCache);
// 不缓存空值
caffeineCacheManager.setAllowNullValues(false);
return caffeineCacheManager;
}
@Bean(name = "redisCacheManager")
@ConditionalOnProperty(prefix = "yueji.cache", name = "type", havingValue = "redis")
@ConditionalOnClass({RedisTemplate.class, RedisCacheManager.class})
public CacheManager redisCacheManager(@Autowired RedisTemplate<Object, Object> redisTemplate) {
RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory();
if (Objects.isNull(connectionFactory)) {
throw new NullPointerException("connection factory is null");
}
RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory);
RedisCacheConfiguration redisCacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(DurationStyle.SIMPLE.parse(ttl))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisTemplate.getValueSerializer()));
return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration);
}
@Bean(name = "distributedCacheManager")
@ConditionalOnProperty(prefix = "yueji.cache", name = "type", havingValue = "distributed_cache")
@ConditionalOnClass({Caffeine.class, CaffeineCacheManager.class, RedissonClient.class})
public DistributedCacheManager distributedCacheManager(
Caffeine<Object, Object> caffeineCache,
RedissonClient redissonClient,
ApplicationContext applicationContext) {
RTopic cacheMessageTopic = redissonClient.getTopic(CACHE_EVENT_TOPIC);
// 构建 distributedLocalCacheManager
DistributedCacheManager distributedLocalCacheManager = new DistributedCacheManager(cacheMessageTopic, applicationContext);
distributedLocalCacheManager.setCaffeine(caffeineCache);
distributedLocalCacheManager.setAllowNullValues(true);
// 添加 Redis 的 Pub/Sub 消息监听器
cacheMessageTopic.addListener(
CacheEvent.class,
(channel, msg) -> {
DistributedCache cache = (DistributedCache) distributedLocalCacheManager.getCache(msg.getCacheName());
if (Objects.nonNull(cache)) {
if (Objects.nonNull(msg.getCacheKey())) {
cache.onlyEvictLocal(msg.getCacheKey());
} else {
cache.onlyClearLocal();
}
}
});
return distributedLocalCacheManager;
}
}
使用时,通过切换不同的 cacheManager 即可:
/**
* Spring EL表达式,使用#提取参数
*/
@Cacheable(cacheManager = "caffeineCacheManager", cacheNames = "localeDTOs", key = "'all'", sync = true)
public List<LocaleDTO> getAllLocales() {
List<LocaleDO> localeDOs = localeDOMapper.selectByExample(new LocaleDOExample());
return localeDOs.stream()
.map(LocaleDTOConverter.CONVERTER::doToDto)
.collect(Collectors.toList());
}
个人不是很推荐使用多级组合缓存的方案,如果是单体系统,直接使用本地缓存,如果是分布式系统,直接使用 Redis 集中式缓存。除非你的项目对于性能有极致的要求,同时要减少缓存异常带来的负面问题,可以考虑一些特殊的缓存方案。
小结
除了缓存之外,还有许多其他方式可以显著提升系统性能。这些方式涉及不同的层面,包括数据库优化、应用架构优化、网络优化、代码优化、前端性能优化等。
在可以避免或业务可接受缓存带来负面问题的一些场景中,引入缓存是一个相当不错的选择。比如:数据访问频繁、但更新很少,就可以考虑引入缓存。