关于SpringBoot缓存的一些Stuff
数据库的设计目标是持久化存储和数据一致性,但这也导致它在 “速度” 和 “抗压力” 上存在天然短板,而缓存恰好能弥补这些不足。
无缓存
以酒店详情热点数据的查询为例,每次请求都会向数据库拿数据,这样在并发小的时候没什么问题,当并发起来的时候数据库的压力会相当大,连接耗尽甚至宕掉。。。
// HotelServiceImpl.java
@Slf4j
@Service
public class HotelServiceImpl extends ServiceImpl<HotelMapper, Hotel> implements HotelService {
// : /api/hotel/{id}
@Override
public Hotel getOneByIdNoneCached(Integer id) {
return this.getById(id);
}
}简单Redis缓存
这里加入Redis缓存 每次拿数据先从数据库拿,如果没有再从数据库拿
// HotelServiceImpl.java
@Slf4j
@Service
public class HotelServiceImpl extends ServiceImpl<HotelMapper, Hotel> implements HotelService {
private final StringRedisTemplate stringRedisTemplate;
public HotelServiceImpl(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
private void makeCacheToRedis(Integer id, Hotel hotel) {
log.info("创建Redis缓存 酒店({}): {}", id, hotel);
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
valueOperations.set(getCacheKey(id), JsonUtils.obj2json(hotel), 1, TimeUnit.MINUTES);
}
private Hotel getHotelFromRedisCache(Integer id) {
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
// 从Redis中查询是否有这个数据
String jsonStr = valueOperations.get(getCacheKey(id));
if (jsonStr != null) {
log.info("[HIT] Redis缓存 酒店ID: {}", id);
return JsonUtils.json2obj(jsonStr, Hotel.class);
}
throw new RuntimeException("NO CACHE");
}
// : /api/hotel/cache1/{id}
@Override
public Hotel getOneByIdCachedRedis(Integer id) {
try {
return getHotelFromRedisCache(id);
} catch (RuntimeException e) {
// NO CACHE
Hotel hotel = this.getById(id);
makeCacheToRedis(id, hotel);
return hotel;
}
}
}效率确实起来了
无缓存压测结果:

Redis缓存压测结果
几乎快了一倍:

简单Redis缓存的问题
单纯的Redis缓存会存在一些问题
缓存击穿:缓存过期失效,流量打进数据库
缓存雪崩:当大量的缓存同时失效,流量打进数据库
缓存穿透:攻击者构建大量数据库不存在的数据,流量不再走缓存,都是直接进入数据库
解决缓存"击穿"和"雪崩"
为了防止数据库压力过大 这里在从数据库获取数据前加了个互斥锁,只需要把所需要查询的酒店给锁上而不是整个块给锁上,这样从数据库查询其他酒店数据库就不会被阻塞,加快了效率。
并且在其他线程拿到锁资源时再次检查Redis缓存中 因为这个时刻缓存可能已经被重新创建了
雪崩问题的解决就是为缓存设置不同的到期时间 这里随机30 - 59分钟
// HotelServiceImpl.java
@Slf4j
@Service
public class HotelServiceImpl extends ServiceImpl<HotelMapper, Hotel> implements HotelService {
private final StringRedisTemplate stringRedisTemplate;
public HotelServiceImpl(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
private void makeCacheToRedis(Integer id, Hotel hotel) {
log.info("创建Redis缓存 酒店({}): {}", id, hotel);
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
valueOperations.set(
getCacheKey(id),
JsonUtils.obj2json(hotel),
(new Random()).nextInt(30) + 30, // 30 - 59分钟
TimeUnit.MINUTES
);
}
private Hotel getHotelFromRedisCache(Integer id) {
// 获取数据从Redis 无缓存抛出RuntimeException
}
// : /api/hotel/cache2/{id}
@Override
public Hotel getOneByIdCachedRedisBetter(Integer id) {
try {
// 从Redis中查询是否有这个数据
return getHotelFromRedisCache(id);
} catch (RuntimeException e) {
// NO CACHE: 准备从数据库拿数据
// 这里加锁是防止并发打在数据库 减少数据库压力
// 访问数据库是串行的 数据库获取数据成功后将会创建一个Redis缓存
// 第二次检查Redis缓存成功创建后 将会从Redis缓存返回
RLock locker = redissonClient.getLock(getLockKey(id));
locker.lock();
try {
// 此时第二个线程拿到锁资源后进来还是会去数据库拿数据 但是现在缓存可能已经被创建了
// 所以可以二次检查缓存状态 如果有那就走缓存
return getHotelFromRedisCache(id);
} catch (RuntimeException ex) {
// 仍然没有
Hotel hotel = this.getById(id);
makeCacheToRedis(id, hotel);
return hotel;
} finally {
locker.unlock();
}
}
}
}
解决缓存"穿透"
缓存穿透实际上就是一种黑客攻击行为,攻击者构造了大量不存在的数据,向服务发起请求。按之前的逻辑,服务器查到数据后将空数据缓存起来,如果流量小还好。但是大量不同且数据库中不存在的数据,服务会不断的向数据库获取数据,Redis中也会不断缓存一些没有意义的数据。
解决这个问题可以引入布隆过滤器,这里使用Guava的布隆过滤器实现
引入依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.5.0-jre</version>
</dependency>编写配置类 注意需要在布隆过滤器初始化时塞入数据 要不然所有查询都不会进入缓存或者数据库
// LocalCacheConfig.java
@Configuration
public class LocalCacheConfig {
@Bean
public BloomFilter<String> hotelBloomFilter() {
return BloomFilter
.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
10000, 0.00001
);
}
}
// BloomConfig.java
@Slf4j
@Configuration
public class BloomConfig implements InitializingBean {
private final HotelService hotelService;
private final BloomFilter<String> hotelBloomFilter;
public BloomConfig(HotelService hotelService, BloomFilter<String> hotelBloomFilter) {
this.hotelService = hotelService;
this.hotelBloomFilter = hotelBloomFilter;
}
@Override
public void afterPropertiesSet() throws Exception {
List<Hotel> hotels = hotelService.list();
for (Hotel hotel : hotels) {
hotelBloomFilter.put(hotelService.getCacheKey(hotel.getId()));
}
log.info("布隆过滤器初始化: {}条记录", hotels.size());
}
}布隆过滤器会存在"假阳"问题
// HotelServiceImpl.java
// : /api/hotel/cache3/{id}
@Override
public Hotel getOneByIdCachedRedisBetterBloom(Integer id) {
// 引入布隆过滤器
// true: 可能不存在
// false: 包不存在
if (!hotelBloomFilter.mightContain(getCacheKey(id))) {
return null;
}
// 如果有布隆过滤器中不存在的数据就放入
Hotel hotel = getOneByIdCachedRedisBetter(id);
if (hotel != null) {
hotelBloomFilter.put(getCacheKey(hotel.getId()));
}
return hotel;
}本地缓存
这下差不多可以解决黑客攻击问题 但是去Redis取数据始终有网络的IO开销,Guava提供了缓存的实现
// LocalCacheConfig
@Configuration
public class LocalCacheConfig {
@Bean
public BloomFilter<String> hotelBloomFilter() {
// Make BloomFilter...
}
@Bean
public Cache<@NonNull String, @NonNull Hotel> hotelCache() {
return CacheBuilder
.newBuilder()
.initialCapacity(20)
.maximumSize(100)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
}
}// HotelServiceImpl.java
// : /api/hotel/cache4/{id}
@Override
public Hotel getOneByIdCachedRedisBetterBloomWithLocalCache(Integer id) {
// 先从本地缓存中拿数据
Hotel localCache = getHotelFromLocalCache(id);
if (localCache != null) {
return localCache;
}
Hotel hotel = getOneByIdCachedRedisBetterBloom(id);
if (hotel != null) {
hotelBloomFilter.put(getCacheKey(hotel.getId()));
}
// 制造本地缓存
makeCacheToLocal(id, hotel);
return hotel;
}无本地缓存压测结果

本地缓存压测结果

评论