还在写普通的CRUD代码?缓存数据冷热分离、缓存击穿、缓存穿透、双写/读写不一致、服务器雪崩你了解吗?

前言

你是否还在写普通的CRUD代码?是否还在苦恼如何提高自己的代码水平?今天,我们将深入探讨一些高级的缓存策略和数据一致性问题,通过实际案例和解决方案,帮助大家写出更加健壮和高效的代码。


文章在开始之前,大家先思考以下问题。之后我将通过一段初级的代码和Redisson,逐步去解决下面问题并提高代码的质量。

  • 缓存数据冷热分离:如何根据数据访问频率来优化缓存,以减少访问延迟和提高系统性能。
  • 缓存击穿:当高访问量的数据过期时,如何避免对后端数据库的大量并发请求。
  • 缓存穿透:如何应对查询不存在的数据导致的缓存和数据库压力。
  • 双写/读写不一致:在缓存和数据库之间同步数据时,如何解决数据不一致的问题。
  • 服务器雪崩:当大量缓存同时失效时,如何防止系统崩溃。

我先写一段大家平常可能用到的一种缓存架构。

@Service
@RequiredArgsConstructor
public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product>
    implements ProductService{

    private final ProductMapper productMapper;
    private final RedissonClient redisson;
    private final RedisUtil redisUtil;
    

    @Override
    public Product queryProductById(Long productId) {
        Product product = null;
        //1、先从redis里面查
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + productId;
            //1.1、获取商品json串
        String productStr = redisUtil.get(productKey);
        if (!StrUtil.isEmpty(productStr)){
            //1.2、拿到商品直接返回
            product = JSONUtil.toBean(productStr, Product.class);
            return product;
        }
        //2、如果redis里面没有,则从数据库查
        product = productMapper.selectById(productId);
        if (product!=null){
            //2.1、存入缓存
            redisUtil.set(productKey, JSONUtil.toJsonStr(product));
        }
        return product;
    }

    @Override
    @Transactional
    public void addProduct(Product product) {
        //这里就简单写了
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId();
        redisUtil.delete(productKey);
        int insert = productMapper.insert(product);
        if (insert > 0) {
            redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId(), JSONUtil.toJsonStr(product));
        }
    }
    @Override
    @Transactional
    public void updateProduct(Product product) {
        //这里就简单写了
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId();
        redisUtil.delete(productKey);
        int update = productMapper.updateById(product);
        if (update > 0) {
            redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId(), JSONUtil.toJsonStr(product));
        }
    }
}

 上面是一段普通的CRUD的代码,许多场景都是读多写少,这里我们主要看queryProductById这个方法里面的代码,先从缓存里面拿数据,缓存没有就从数据库里面拿。这个逻辑好像没有问题,在开发环境也没出什么大问题,稍微压测一下也不会出什么大问题,如果出现问题我们也可以接收。这里我先抛砖引玉,我们先解决缓存数据冷热分离这个问题。

以淘宝京东为例,他们的数据库里面可能存了上亿条商品的数据,如果我们按照上面代码去往redis里面存数据,几亿条数据全部存入缓存,那缓存得多大才能装的下。这个时候就引入了“冷数据”和“热点数据”这个两个概念了,我们只将那些浏览量多的、热门的商品数据存入到缓存里面,将那些很少去有人浏览购买的商品直接放在数据库里面,不去做缓存。

接下来我们修改代码,将存入的商品数据添加过期时间,这样不去浏览的商品数据过期自动销毁。

//假设过期时间设置为24小时
redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId(), JSONUtil.toJsonStr(product),
                    RedisKeyPrefixConst.PRODUCT_CACHE_TIMEOUT, TimeUnit.SECONDS );

 但是我们又希望热点商品数据一直存在缓存里面。我们可以给热点商品数据 “ 续命 ”。

public Product queryProductById(Long productId) {
        Product product = null;
        //1、先从redis里面查
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + productId;
            //1.1、获取商品json串
        String productStr = redisUtil.get(productKey);
        if (!StrUtil.isEmpty(productStr)){
            //1.2、拿到商品直接返回
            product = JSONUtil.toBean(productStr, Product.class);
            //1.3、读延期(热点商品续命)
            redisUtil.expire(productKey, PRODUCT_CACHE_TIMEOUT, TimeUnit.SECONDS);
            return product;
        }
        //2、如果redis里面没有,则从数据库查
        product = productMapper.selectById(productId);
        if (product!=null){
            //2.1、存入缓存
            redisUtil.set(productKey, JSONUtil.toJsonStr(product),
            genProductCacheTimeout(),TimeUnit.SECONDS);
        }
        return product;
    }

给那些热数据继续添加过期时间,每次访问都会给其”续命“,这样就保证了缓存里面一直保留的都是热点数据 。我们继续分析代码,前端商家要上架许多商品,不可能一个一个商品的上架,一般会全选或批量添加。这就会造成一个问题,许多商品都有相同的过期时间,如果他们同时过期,过期后又有大量的请求去访问他们,这些请求全部打到数据库,会导致我们数据库压力过大甚至挂掉。这个问题就是缓存击穿。

缓存击穿(失效):由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉。

对于这种情况我们在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间。我们修改代码:

//我们添加一个生成随机过期时间的方法
private Integer genProductCacheTimeout() {
        return PRODUCT_CACHE_TIMEOUT + new Random().nextInt(5) * 60 * 60;
    }

redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId(), JSONUtil.toJsonStr(product),
                    genProductCacheTimeout(), TimeUnit.SECONDS );

 说完缓存击穿,我们就来聊一聊缓存穿透:

缓存穿透是指查询一个根本不存在的数据, 缓存层和存储层都不会命中, 通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层。缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。

造成缓存穿透的基本原因有两个:

  1.  自身业务代码或者数据出现问题。
  2.  一些恶意攻击、 爬虫等造成大量空命中。

我们分析上面代码, 如果有一些黑客模拟请求或者使用压测工具频繁的用非法参数去请求我们的接口,这个时候会有大量的空请求打到数据库,会造成数据库压力剧增,很有可能把数据库打垮。我们优化代码去解决缓存穿透问题

到这里我们的代码健壮性算是小成了,我们继续思考问题,如果一条冷数据突然变成热点数据,同一时间大量的请求打到我们数据库,我们应该怎么办?

我们肯定是希望第一个请求走数据库,后面的请求走缓存。这个时候我们加锁可以将并行转为串行,让请求排着队一个一个来。

    public Product queryProductById(Long productId) {
        Product product = null;
        //1、先从redis里面查
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + productId;
            //1.1、获取商品json串
        String productStr = redisUtil.get(productKey);
        if (!StrUtil.isEmpty(productStr)){
            //1.4、判断是否是空缓存
            if (EMPTY_CACHE.equals(productStr)){
                return null;
            }
            //1.2、拿到商品直接返回
            product = JSONUtil.toBean(productStr, Product.class);
            //1.3、读延期(热点商品续命)
            redisUtil.expire(productKey, PRODUCT_CACHE_TIMEOUT, TimeUnit.SECONDS);
            return product;
        }
        synchronized (this){
            //复制上面查缓存操作
             productStr = redisUtil.get(productKey);
            if (!StrUtil.isEmpty(productStr)){
                //1.4、判断是否是空缓存
                if (EMPTY_CACHE.equals(productStr)){
                    return null;
                }
                //1.2、拿到商品直接返回
                product = JSONUtil.toBean(productStr, Product.class);
                //1.3、读延期(热点商品续命)
                redisUtil.expire(productKey, PRODUCT_CACHE_TIMEOUT, TimeUnit.SECONDS);
                return product;
            }
            //2、如果redis里面没有,则从数据库查
            product = getProductFromDatabase(productId);
            return product;
        }
    }

//先简单重构代码下代码,上面代码还要修改,后面再重构 
    private Product getProductFromDatabase(Long productId) {
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + productId;
        Product product = productMapper.selectById(productId);
        if (product != null) {
            redisUtil.set(productKey, JSONUtil.toJsonStr(product), genProductCacheTimeout(), TimeUnit.SECONDS);
        } else {
            redisUtil.set(productKey, EMPTY_CACHE, genEmptyCacheTimeout(), TimeUnit.SECONDS);
        }
        return product;
    }

 这里我们参考实现单例模式双重检测锁技术,我们加锁并在锁里面再次查询缓存,第一个请求之后,后面来的请求到达查缓存操作,发现缓存有数据就直接返回了。

注意: 虽然我们解决了上面的问题,但是我们的锁对象是this(ProductServiceImpl这个Bean对象),这又会出现一个严重的问题(哎,bug永远改不完。。。。),当一个商品的请求进来就把整个bean锁住,其他商品的请求进不来,非常非常非常影响系统性能!!!(一个人拉屎,把所有人关在厕所外面)

 public Product queryProductById(Long productId) {
        Product product = null;
        //1、先查缓存
        product = getProductFromCache(productId);
        if (product!=null){
            return product;
        }
        //加分布式锁解决热点缓存并发重建问题
        RLock hotCreateCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
        hotCreateCacheLock.lock();
        try {
            //2、查缓存
            product = getProductFromCache(productId);
            if (product!=null){
                return product;
            }
            //3、如果redis里面没有,则从数据库查
            product = getProductFromDatabase(productId);
            return product;
        }finally {
               hotCreateCacheLock.unlock();
        }
    }
    //重构代码
    private Product getProductFromCache(Long productId) {
        Product product = null;
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + productId;
        String productStr = redisUtil.get(productKey);
        if (!StrUtil.isEmpty(productStr)) {
            if (EMPTY_CACHE.equals(productStr)) {
                return null;
            }
             product = JSONUtil.toBean(productStr, Product.class);
            redisUtil.expire(productKey, PRODUCT_CACHE_TIMEOUT, TimeUnit.SECONDS);
        }
        return product;
    }

我们引入分布式锁去解决我们的问题。

 优化了代码后,返回给前端的product就有两种情况,有数据和没有数据。剩下的就交给前端了,如果返回空对象,就给用户一个提示。

其实到这里代码的健壮性已经很不错了,在不是特别高并发场景下也不会出现什么大问题,出现一些小问题我们也可以容忍。但是,我们不能止步于此,接下来我们解决双写和读写不一致的问题。我们先要知道为什么会出现数据库和缓存不一致的问题,知道这个问题怎么产生才能去解决并优化我们的代码。

1. 双写不一致的情况

 上面两个线程1和2,执行同一段代码,因为系统卡顿或者其他原因,导致线程1在准备更新缓存时卡了一会,线程2在这个间隙把缓存更新了,等线程1恢复之后,又更新了一次缓存,就导致了数据库跟缓存不一致。

2. 读写不一致的情况

如上图,假设有三个线程1、2、3,线程1执行写操作库存改为10,写完删除缓存,线程1执行完操作后,线程3查缓存没有,又去查数据库,在查数据库的过程中,线程2把数据库库存改为6,线程3更新缓存库存为10,但实际上数据库库存为6,缓存为10,此时就出现读写不一致的问题。

对应到具体代码如下:

注意: 虽然这种情况发生的概率比较小,但是我们不能不知道。 

解决方案:

1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。

2、就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。

3、如果不能容忍缓存数据不一致,可以通过加分布式读写锁保证并发读写或写写的时候按顺序排好队,读读的时候相当于无锁。

4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。

 优化我们代码:

    public void updateProduct(Product product) {
        //这里就简单写了
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId();
        redisUtil.delete(productKey);
        RLock updateProductLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
        updateProductLock.lock();
        try {
            int update = productMapper.updateById(product);
            if (update > 0) {
                redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId(), JSONUtil.toJsonStr(product),
                        genProductCacheTimeout(), TimeUnit.SECONDS );
            }
        }catch (Exception e){
            //处理具体业务异常
            e.printStackTrace();
        }finally {
            updateProductLock.unlock();
        }
    }


 private Product getProductFromDatabase(Long productId) {
        Product product = null;
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + productId;
        RLock updateProductLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
        updateProductLock.lock();
        try {
             product = productMapper.selectById(productId);
            if (product != null) {
                redisUtil.set(productKey, JSONUtil.toJsonStr(product), genProductCacheTimeout(), TimeUnit.SECONDS);
            } else {
                redisUtil.set(productKey, EMPTY_CACHE, genEmptyCacheTimeout(), TimeUnit.SECONDS);
            }
        }catch (Exception e){
          //这里可以处理具体的异常,这里不是重点,就不处理了
          e.printStackTrace();
        } finally {
            updateProductLock.unlock();
        }
        return product;
    }
//其他的就不演示了

 写到这里,我们的代码已经变得非常的复杂了,可能有的小伙伴就会有疑问,一个CRUD写那么复杂的代码?加那么多锁会不会影响性能?

 我们缓存里面存的大多都是热点数据,刚开始走一次全部代码,后续全部走缓存,不会进入到下面代码,就算是冷门数据,走一次全部代码对系统性能也不会有太大的影响。我们写下面加锁代码是为了防止高并发情况下小概率事件的发生,如果并发量不高,其实不写也可以。 

加完锁之后,也要思考能不能对加的锁进行优化,毕竟加锁确实会影响系统的性能。

我们换成读锁后,读请求到执行锁内代码是并行操作。 

 我们将锁换成读写锁后,性能提高了非常多。

  • 读读操作:不互斥。
  • 读写操作:写完之后才让读。
  • 写写操作:互斥。

详细的逻辑,大家可以去看Redisson分布式锁的官方文档或着看源码也可以。

我们一共加了两把锁,现在继续优化另一把锁。

//加分布式锁解决热点缓存并发重建问题
RLock hotCreateCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
hotCreateCacheLock.lock();

 假如说同一热点商品需要重构缓存,一时间涌入大量的请求,第一个请求进来后,执行逻辑加锁解锁,后面的请求排队执行相同的加锁解锁逻辑。能不能只执行一次加锁解锁逻辑,其他的请求走缓存,查到就返回。优化代码:

//加分布式锁解决热点缓存并发重建问题
RLock hotCreateCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
//hotCreateCacheLock.lock();
Boolean lockResult = hotCreateCacheLock.tryLock(1,TimeUnit.SECONDS);
/*
    1------->代表锁内代码大概1秒内执行完,执行完后锁失效;
            如果1秒内没执行完,出bug,请求全部打到数据库。
 */

完整代码: 

@Service
@RequiredArgsConstructor
public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product>
    implements ProductService{

    private final ProductMapper productMapper;
    private final RedissonClient redisson;
    private final RedisUtil redisUtil;
    public static final Integer PRODUCT_CACHE_TIMEOUT = 60 * 60 * 24;
    public static final String EMPTY_CACHE = "{}";
    public static final String LOCK_PRODUCT_HOT_CACHE_PREFIX = "lock:product:hot_cache:";
    public static final String LOCK_PRODUCT_UPDATE_PREFIX = "lock:product:update:";
    @Override
    public Product queryProductById(Long productId) {
        Product product = null;
        //1、先查缓存
        product = getProductFromCache(productId);
        if (product!=null){
            return product;
        }
        //加分布式锁解决热点缓存并发重建问题
        RLock hotCreateCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
        hotCreateCacheLock.lock();
        //hotCreateCacheLock.tryLock(1,TimeUnit.SECONDS);
        try {
            //2、查缓存
            product = getProductFromCache(productId);
            if (product!=null){
                return product;
            }
            //3、如果redis里面没有,则从数据库查
            product = getProductFromDatabase(productId);
            return product;
        }finally {
               hotCreateCacheLock.unlock();
        }
    }

     //这里就简单写了
    @Override
    @Transactional
    public void addProduct(Product product) {
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId();
        redisUtil.delete(productKey);
        int insert = productMapper.insert(product);
        if (insert > 0) {
            redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId(), JSONUtil.toJsonStr(product),
                    genProductCacheTimeout(), TimeUnit.SECONDS );
        }
    }
    @Override
    @Transactional
    public void updateProduct(Product product) {
        //这里就简单写了
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId();
        redisUtil.delete(productKey);
//        RLock updateProductLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
        RLock writeLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId()).writeLock();
        writeLock.lock();
        try {
            int update = productMapper.updateById(product);
            if (update > 0) {
                redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + product.getId(), JSONUtil.toJsonStr(product),
                        genProductCacheTimeout(), TimeUnit.SECONDS );
            }
        }catch (Exception e){
            //处理具体业务异常
            e.printStackTrace();
        }finally {
            writeLock.unlock();
        }
    }
    /**
     * 从Redis缓存中获取产品信息
     *
     * @param productId 产品ID
     * @return 缓存中的产品信息,如果未找到则返回null
     */
    private Product getProductFromCache(Long productId) {
        Product product = null;
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + productId;
        String productStr = redisUtil.get(productKey);
        if (!StrUtil.isEmpty(productStr)) {
            if (EMPTY_CACHE.equals(productStr)) {
               // return null;
                //优化,防止缓存穿透,直接返回一个空对象
                return new Product();
            }
             product = JSONUtil.toBean(productStr, Product.class);
            redisUtil.expire(productKey, PRODUCT_CACHE_TIMEOUT, TimeUnit.SECONDS);
        }
        return product;
    }

    /**
     * 从数据库中获取产品信息
     *
     * @param productId 产品ID
     * @return 数据库中的产品信息,如果未找到则返回null
     */
    private Product getProductFromDatabase(Long productId) {
        Product product = null;
        String productKey = RedisKeyPrefixConst.PRODUCT_CACHE_PREFIX + productId;
        //RLock updateProductLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
        RLock readLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + productId).readLock();
        readLock.lock();
        try {
             product = productMapper.selectById(productId);
            if (product != null) {
                redisUtil.set(productKey, JSONUtil.toJsonStr(product), genProductCacheTimeout(), TimeUnit.SECONDS);
            } else {
                redisUtil.set(productKey, EMPTY_CACHE, genEmptyCacheTimeout(), TimeUnit.SECONDS);
            }
        }catch (Exception e){
          //这里可以处理具体的异常,这里不是重点,就不处理了
          e.printStackTrace();
        } finally {
            readLock.unlock();
        }
        return product;
    }

    private Integer genProductCacheTimeout() {
        return PRODUCT_CACHE_TIMEOUT + new Random().nextInt(5) * 60 * 60;
    }
    private Integer genEmptyCacheTimeout() {
        return 60 + new Random().nextInt(30);
    }
}

上面的所有方案和优化都不是非必须的,要看具体的业务场景和接收程度,没有完美的解决方案,只有相对的取舍,要希望数据一致就会牺牲性能。  

补充:

        在上面的基础上我们还可以采用多级缓存架构,将JVM内存缓存和redis缓存结合在一起。 

具体实现过程我就不在详细写了,有兴趣的小伙伴可以试一试。目前也有许多热门的开源中间件,如:EhCacheMemcached等等。

  • 25
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小杰不秃头

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值