Redis缓存架构以及线上问题分析

   public Product update1(Product product) {
        Product productResult = productDao.update(product);
        redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), JSON.toJSONString(productResult));
        return productResult;
    }

    public Product get1(Long productId) throws InterruptedException {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        String productStr = redisUtil.get(productCacheKey);
        
        if (!StringUtils.isEmpty(productStr)) {
            product = JSON.parseObject(productStr, Product.class);
            return product;
        }
        
        product = productDao.get(productId);
        if (product != null)
            redisUtil.set(productCacheKey,JSON.toJSONString(product));
        return product;
    }

上述代码问题:

所有数据都缓存至redis内存,缓存存储容量浪费,高频访问数据数据少:(在电商系统中,热点数据与冷门数据比例差距很大,如京东商品非常多,但热点商品少,冷门数据多。上述代码会将所有数据都进行缓存)

优化方式:

尽可能将热点数据放入缓存,冷门数据不放。

大规模商品缓存数据冷热分离实战

解决:商品维护进缓存时添加过期时间,冷门数据最多存活过期时间内,经常访问的数据每次查询时进行缓存过期时间延期(读延期)
简单数据冷热分离:读延期
public Product update1(Product product) {
        Product productResult = productDao.update(product);
            redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), JSON.toJSONString(productResult),PRODUCT_CACHE_TIMEOUT,TimeUnit.SECONDS); //读延期
        return productResult;
    }

    public Product get1(Long productId) throws InterruptedException {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        String productStr = redisUtil.get(productCacheKey);

        if (!StringUtils.isEmpty(productStr)) {
            product = JSON.parseObject(productStr, Product.class);
             //读延期
            redisUtil.expire(productCacheKey,PRODUCT_CACHE_TIMEOUT,TimeUnit.SECONDS);
            return product;
        }
        product = productDao.get(productId);
        if (product != null)
      			redisUtil.set(productCacheKey,JSON.toJSONString(product),PRODUCT_CACHE_TIMEOUT,TimeUnit.SECONDS);
        return product;
    }
问题:如果电商平台批量导入大量商品(上千万),当前代码设置的缓存key过期时间相同,会导致大批量商品同时到期,缓存同时失效,此时大量请求访问这些商品直接到达数据库进行查询,造成数据瞬间压力过大,甚至直接宕机。(缓存击穿)

缓存击穿/缓存失效

场景:由于大批量缓存在同一时间失效导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉。此时可以在批量添加缓存时,给这批数据的缓存时间设置为一个时间段内的随机时间

解决方式:添加随机时间

    private Integer genProductCacheTimeout() {
        return PRODUCT_CACHE_TIMEOUT + new Random().nextInt(5) * 60 * 60;  //根据业务系统设置随机过期时间
    }

缓存穿透:

缓存穿透指查询一个根本不存在的数据, 缓存层和存储层都不会命中。
如:
   -秒杀商品时,如商品信息意外从系统删除,缓存与数据库中都被删除。此时大量请求查询缓存后在查询数据库都没有数据。
   -黑客攻击时访问一个不存在商品id

解决方式

	-缓存空对象,并设置过期时间,防止多次访问。
	-布隆过滤器,某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。

缓存空对象代码

public static final String EMPTY_CACHE = "{}";
    public Product get1(Long productId) throws InterruptedException {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        String productStr = redisUtil.get(productCacheKey);

        if (!StringUtils.isEmpty(productStr)) {
            if(EMPTY_CACHE.equals(productStr)){
                //空缓存延期,防止多次访问,进行缓存重构。
                redisUtil.expire(productCacheKey,60 + new Random().nextInt(30),TimeUnit.SECONDS);
                return null; // 返回空,与返回商品区分
            }
            product = JSON.parseObject(productStr, Product.class);
            redisUtil.expire(productCacheKey,genProductCacheTimeout(),TimeUnit.SECONDS);
            return product;
        }

        product = productDao.get(productId);
        if (product != null){
            redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);
        }else {
            //放空缓存。。TTL,防止大量空缓存信息放入缓存
            redisUtil.set(productCacheKey,EMPTY_CACHE,60 + new Random().nextInt(30),TimeUnit.SECONDS);
        }
        return product;
    }

突发性热点缓存重建导致系统压力暴增问题分析

当存在某个冷门商品,缓存数据已经过期,当他突然变成热点数据需要重构缓存时,出现大量请求进行访问,所有请求会直接访问至数据库。造成数据库性能下降,从轻微延迟到完全崩溃。

解决方式:添加分布式锁 并且 使用 DCL 双重检测查询

public static final String LOCK_PRODUCT_HOT_CACHE_PREFIX = "lock:product:hot_cache:";
public Product get1(Long productId) throws InterruptedException {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        product = getProductFromCache1(productCacheKey);

        if (product != null){
            // 返回空对象,与存在商品
            return product;
        }
        //添加分布式锁,解决热点缓存并发重建问题,无缓存时只有持有锁请求进行缓存构建
        RLock hotCreateCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
        hotCreateCacheLock.lock();
        try {
            product = getProductFromCache1(productCacheKey);
            if (product != null){
                return product;
            }
            product = productDao.get(productId);
            if (product != null){
                redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);
            }else {
                redisUtil.set(productCacheKey,EMPTY_CACHE,60 + new Random().nextInt(30),TimeUnit.SECONDS);
            }
        }finally {
            hotCreateCacheLock.unlock();
        }
        return product;
    }
//重复代码提出
private Product getProductFromCache1(String productCacheKey) {
        Product product = null;
        String productStr = redisUtil.get(productCacheKey);
        if (!StringUtils.isEmpty(productStr)) {
            if (EMPTY_CACHE.equals(productStr)) {
                //空缓存延期,防止多次访问。
                redisUtil.expire(productCacheKey, 60 + new Random().nextInt(30), TimeUnit.SECONDS);
                return new Product(); // 返回空对象,商品不存在,与返回商品区分,与返回null区分
            }
            product = JSON.parseObject(productStr, Product.class);
            //读延期
            redisUtil.expire(productCacheKey, genProductCacheTimeout(), TimeUnit.SECONDS);
        }
        return product;
    }

代码问题:try catch 代码块获取缓存为空继续执行,开始同步重构缓存数据, 可能会出现缓存与数据库双写不一致问题。

	如果此时线程执行到此处获取数据库数据进行缓存重建,同时存在另一个线程调用update方法执行数据更新,此时可能造成缓存与数据库双写不一致问题。

Redis分布式锁解决缓存与数据库双写不一致问题

双写不一致
线程3执行查数据库stock=10 ,与更新缓存中间时出现异常执行延迟,线程2此时执行写数据库并更新缓存,线程3恢复后又继续执行更新缓存,导致数据库与缓存之间数据不一致。如下图所示:
在这里插入图片描述

读写并发不一致

线程3执行查数据库stock=10 ,与更新缓存中间时出现异常执行延迟,线程2此时执行写数据库并删除缓存,线程3恢复后又继续执行更新缓存,导致数据库与缓存之间数据不一致。如下图所示:
在这里插入图片描述
解决方式:Redis分布式锁

public static final String LOCK_PRODUCT_UPDATE_PREFIX = "lock:product:update:";
    public Product get2(Long productId) throws InterruptedException {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        product = getProductFromCache1(productCacheKey);

        if (product != null){
            return product;
        }
        RLock hotCreateCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
        hotCreateCacheLock.lock();
        try {
            product = getProductFromCache1(productCacheKey);
            if (product != null){
                return product;
            }
            // 解决缓存与数据库双写不一致问题  (锁要一致)
            RLock productUpdateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
            productUpdateLock.lock();
            try {
                product = productDao.get(productId);
                if (product != null){
                    redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);
                }else {
                    redisUtil.set(productCacheKey,EMPTY_CACHE,60 + new Random().nextInt(30),TimeUnit.SECONDS);
                }
            }finally {
                productUpdateLock.unlock();
            }
        }finally {
            hotCreateCacheLock.unlock();
        }
        return product;
    }
    
public Product update1(Product product) {
        Product productResult = null;
        // (锁要一致)
        RLock productUpdateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
        productUpdateLock.lock();
        try {
            productResult = productDao.update(product);
            redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), JSON.toJSONString(productResult),genProductCacheTimeout(),TimeUnit.SECONDS);
        }finally {
            productUpdateLock.unlock();
        }
        return productResult;
    }

代码臃肿问题:
查询没有缓存是代码执行路径很长,需要查询到数据库。但实际可能只有1%的热点数据,经常被访问。绝大数请求查询到缓存就直接返回了。并且热点数据存在读延期。
用大量代码解决小概率事件。但90%的场景只会执行一小块代码,效率依然高。

分布式锁优化
1 :电商网站读多写少场景。可以使用分布式读写锁

    public Product get3(Long productId) throws InterruptedException {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        product = getProductFromCache1(productCacheKey);

        if (product != null){
            return product;
        }
        RLock hotCreateCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
        hotCreateCacheLock.lock();
        try {
            product = getProductFromCache1(productCacheKey);
            if (product != null){
                return product;
            }
            // 解决缓存与数据库双写不一致问题  读锁:多个请求同时读 ,可以同时加锁,同步执行
            RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
            RLock rLock = readWriteLock.readLock();
            rLock.lock();  
            try {
                product = productDao.get(productId);
                if (product != null){
                    redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);
                }else {
                    redisUtil.set(productCacheKey,EMPTY_CACHE,60 + new Random().nextInt(30),TimeUnit.SECONDS);
                }
            }finally {
                rLock.unlock();
            }
        }finally {
            hotCreateCacheLock.unlock();
        }
        return product;
    }
  public Product update(Product product) {
        Product productResult = null;
        RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
        RLock writeLock = readWriteLock.writeLock();
        writeLock.lock();
        try {
            productResult = productDao.update(product);
            redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), JSON.toJSONString(productResult),
                    genProductCacheTimeout(), TimeUnit.SECONDS);
        } finally {
            writeLock.unlock();
        }
        return productResult;
    }

分布式读写锁:

当读请求并行执行,锁重入 + 1  
读写锁加锁判断逻辑:先判断锁模式mode ,如果是read 则重入次数+1 ,如果是write 等待锁释放,排队执行。
            当写锁被持有时,后续的读锁请求会等待写锁释放;
            当读锁被持有时,后续的写锁请求会等待所有读锁释放,一旦有一个写锁在排队,后续到达的读锁请求必须等待。
             根据数据库读写操作确定使用读或写锁

2 锁优化 - 阻塞转有限等待
当有上万线程重建缓存时,存在大量线程排队,除第一线程重构缓存外,其他线程都时经过加锁,读取redis缓存数据返回数据,此时可以使用 boolean tryLock(long time, TimeUnit unit) 优化,time时间内未加锁成功返回false。
如果可以确定第一个线程重构缓存执行时间,设置线程的尝试加锁等待时间(预估缓存重建时间 + 缓冲时间),不在锁上无限等待,失败后直接去执行"超时后的重试逻辑"。减少线程阻塞/唤醒的开销,避免无意义的锁等待队列,让大部分线程快速进入"等待+重试"的逻辑。
在这里插入图片描述

11、超大规模访问导致系统崩溃
假如出现热点事件达到上亿规模,redis并发基本只能达到十万,无法扛住超大规模访问, 可能直接打垮redis,并且导致系统其他环节陆续崩溃,最终导致整个服务瘫痪。

缓存雪崩

   缓存雪崩指的是缓存层支撑不住或宕掉后, 流量会像奔逃的野牛一样, 打向后端存储层。

解决方式

   1:限流  针对redis最高访问做限流
   2:多加一层缓存/多级缓存架构。
   			- jvm进程级别缓存框架 : Ehcache、Caffeine
   			- 代码层面使用jvm内存缓存,将热点商品放入缓存。
   				- jvm内存级别可以抗住百万级并发。同时集群部署时存在多个节点分担流量。
   				- redis只有一个集群,jvm进程缓存可以在多个节点同步分担压力

在代码层面使用jvm内存缓存方式:设置redis缓存时同时给jvm级设置缓存,获取缓存优先从jvm级缓存获取。

// map有容量限制,实际使用时不可取,缓存必须有限制,这是生产系统的基本要求。
public static Map<String, Product> productMap = new ConcurrentHashMap<>(); // jvm进程级缓存
 private Product getProductFromCache1(String productCacheKey) {
        Product product = null;
        product= productMap.get(productCacheKey);
        if (product != null){
            // 直接查询jvm进程缓存
            return product;
        }
        String productStr = redisUtil.get(productCacheKey);
        if (!StringUtils.isEmpty(productStr)) {
            if (EMPTY_CACHE.equals(productStr)) {
                redisUtil.expire(productCacheKey, 60 + new Random().nextInt(30), TimeUnit.SECONDS);
                return new Product();
            }
            product = JSON.parseObject(productStr, Product.class);
            redisUtil.expire(productCacheKey, genProductCacheTimeout(), TimeUnit.SECONDS);
        }
        return product;
    }
public Product get5(Long productId) throws InterruptedException {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        product = getProductFromCache(productCacheKey);
        if (product != null) {
            return product;
        }
        RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
        hotCacheLock.lock();
        try {
            product = getProductFromCache(productCacheKey);
            if (product != null) {
                return product;
            }
            RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
            RLock rLock = readWriteLock.readLock();
            rLock.lock();
            try {
                product = productDao.get(productId);
                if (product != null) {
                    redisUtil.set(productCacheKey, JSON.toJSONString(product),
                            genProductCacheTimeout(), TimeUnit.SECONDS);
                    // 设置jvm进程级缓存
                    productMap.put(productCacheKey, product);
                } else {
                    redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeout(), TimeUnit.SECONDS);
                }
            } finally {
                rLock.unlock();
            }
        } finally {
            hotCacheLock.unlock();
        }
        return product;
    }

问题

 - 内存泄漏,map缓存不及时清除可能导致内存泄漏,最终内存溢出
 - web应用集群部署,存在多个web应用节点时,一个请求只会访问单个节点,导致只有单个节点有缓存数据。其他节点jvm内存无此次请求的缓存数据。

解决

    使用mp,或zk 发送到其他节点。但可能出现短时间数据不一致。
    备注:一般来说不建议直接在增删改查代码中进行mp操作,jvm进程级缓存应该存储热点中的热点商品,并且需要实时维护,所以可以通过外部系统如热点缓存计算系统单独对jvm进程级缓存进行维护,然后通知web应用,更新本地jvm进程级缓存。

为什么需要多级缓存?
使用本地缓存可以减少网络请求,提高性能,在分布式系统中是天然分布式缓存,并减少远程缓存的压力。

多级缓存缺点:
重启服务缓存丢失,进程空间大小有限,不支持大量数据存储,分布式场景中系统之间存在缓存不一致性,与远程缓存也可能不一致。

什么场景下需要多级缓存?
一般在高并发场景下使用,如热门商品详情页,热搜,热门帖子,热门帖子主页,

热点探测服务的原理与实现
什么是热点:
指在一段时间内,被广泛关注的物品或事件,例如微博热搜,热卖商品,热点新闻,明星直播等等
1:有限时间
2:流量高聚

在互联网领域,热点又主要分为 2 大类
1、有预期的热点:比如在电商活动当中推出的爆款联名限量款的商品,又或者是秒杀的会场活动等
2、无预期的热点:比如受到了黑客的恶意攻击,网络爬虫频繁访问,又或者突发新闻带来的流量冲击等
如:
MySQL 中被频繁访问的数据 ,如热门商品的主键 Id
Redis 缓存中被密集访问的 Key,如热门商品的详情需要 get goods$Id
恶意攻击或机器人爬虫的请求信息,如特定标识的 userId、机器 IP
频繁被访问的接口地址,如获取用户信息接口 /userInfo/ + userId

使用热点探测的好处
提升性能,规避风险
对于无预期的热数据(即突发场景下形成的热 Key),可能会对业务系统带来极大的风险,可将风险分为两个层次:
1、对数据层的风险
正常情况下,Redis 缓存单机就可支持十万左右 QPS,并能通过集群部署提高整体负载能力。对于并发量一般的系统,用 Redis 做缓存就足够了。但是对于瞬时过高并发的请求,因为 Redis 单线程原因会导致正常请求排队,或者因为热点集中导致分片集群压力过载而瘫痪,从而击穿到 DB 引起服务器雪崩
2、对应用服务的风险
每个应用在单位时间所能接受和处理的请求量是有限的,如果受到恶意请求的攻击,让恶意用户独自占用了大量请求处理资源,就会导致正常用户的请求无法及时响应。
因此,需要一套动态热 Key 检测机制,通过对需要检测的热 Key 规则进行配置,实时监听统计热 Key 数据,当无预期的热点数据出现时,第一时间发现他,并针对这些数据进行特殊处理。如本地缓存、拒绝恶意用户、接口限流 / 降级等

如何实现热点探测
分布式应用,对热 Key 的访问是分散在不同的机器上的,无法在本地独立地进行计算,因此,需要一个独立的、集中的热 Key 计算单元
我们可以简单理解为:分布式应用节点感知热点规则配置,将热点数据进行上报,工作节点进行热点数据统计,对于符合阈值的热点进行推送给客户端,应用收到热点信息进行本地缓存等策略这五个步骤:
1、热点规则:配置热 Key 的上报规则,圈出需要重点监测的 Key
2、热点上报:应用服务将自己的热 Key 访问情况上报给集中计算单元
3、热点统计:收集各应用实例上报的信息,使用滑动窗口算法计算 Key 的热度
4、热点推送:当 Key 的热度达到设定值时,推送热 Key 信息至所有应用实例
5、热点缓存:各应用实例收到热 Key 信息后,对 Key 值进行本地缓存

京东热点缓存探测JDHotkey

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐