关于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;
}

无本地缓存压测结果

本地缓存压测结果

评论