高并发缓存框架

本文探讨了高并发环境下缓存框架遇到的问题及解决方案,包括缓存击穿、缓存穿透、缓存雪崩以及数据库双写不一致的情况,并给出了具体的Java代码示例,如使用随机过期时间、存储空值、分布式锁以及引入JVM二级缓存等策略来优化缓存系统。
摘要由CSDN通过智能技术生成

高并发缓存框架

常规缓存使用:

public class ProductService {

    @Resource
    private ProductDao productDao;

    private static final Integer PRODUCT_CACHE_TIMEOUT = 60*60*24;


    @Transactional
    public Product create(Product product){
        Product result =  productDao.create(product);
        RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result));
        return result;
    }

    @Transactional
    public Product update(Product product){
        Product result =  productDao.update(product);
        RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result));
        return result;
    }

    @Transactional
    public Product get(Long productId){
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
        String productStr = RedisUtils.get(productCacheKey,String.class);
        if (StringUtils.isNotEmpty(productStr)){
            Product product = JSON.parseObject(productStr, Product.class);
            return product;
        }
        Product result =  productDao.get(productId);
        if (result!=null){
            RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result));
        }
        return result;
    }
}

场景一

如京东、淘宝这种大型互联网公司,商品数量有几十亿甚至几百亿商品,那么这个时候把商品信息存在redis里面显然不可取,该如何解决?

1. 问题解析

向这种大型互联网公司的海量数据,实际只有百分1的数据需要经常访问,也就是说99%的数据是不会访问的,那么这个时候把所有数据都放入redis就显得不合理了

2. 解决思路

把数据做一下隔离,只把1%的数据放入redis缓存,进行冷热分离。只需要在上面代码加上PRODUCT_CACHE_TIMEOUT 缓存的超时时间,如下:

public class ProductService {

    @Resource
    private ProductDao productDao;

    private static final long PRODUCT_CACHE_TIMEOUT = 60*60*24;


    @Transactional
    public Product create(Product product){
        Product result =  productDao.create(product);
        RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
                PRODUCT_CACHE_TIMEOUT);
        return result;
    }

    @Transactional
    public Product update(Product product){
        Product result =  productDao.update(product);
        RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
                PRODUCT_CACHE_TIMEOUT);
        return result;
    }

    @Transactional
    public Product get(Long productId){
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
        String productStr = RedisUtils.get(productCacheKey,String.class);
        if (StringUtils.isNotEmpty(productStr)){
            Product product = JSON.parseObject(productStr, Product.class);
            return product;
        }
        Product result =  productDao.get(productId);
        if (result!=null){
            RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
                    PRODUCT_CACHE_TIMEOUT);
        }
        return result;
    }
}

场景二

缓存穿透、缓存雪崩、缓存击穿、缓存数据库双写不一致

1、缓存击穿

由于大批量缓存在同一时间失效,导致大量的请求击穿了缓存数据库,直接进行了数据库查询,导致数据库瞬间压力过大,严重的甚至导致数据库挂掉。

解决思路

可以将大批量导入的数据存入缓存的时候加上随机时间,错开商品的过期时间,这样就可以防止大量数据缓存在同一时间过期。

在场景一代码基础上添加过期时间+随机时间的方法:

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

修改原有设置缓存代码:

修改前:RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
                    PRODUCT_CACHE_TIMEOUT);
修改后:RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
                genProductCacheTimeout());

2、缓存穿透

用户访问的数据既不在缓存当中,也不在数据库中。出于容错的考虑,如果从底层数据库查询不到数据,则不写入缓存。这就导致每次请求都会到底层数据库进行查询,缓存也失去了意义。当高并发或有人利用不存在的Key频繁攻击时,数据库的压力骤增,甚至崩溃,这就是缓存穿透问题。

缓存穿透发生的场景一般有两类:
  • 来数据是存在的,但由于某些原因(误删除、主动清理等)在缓存和数据库层面被删除了,但前端或前置的应用程序依旧保有这些数据;
  • 恶意攻击行为,利用不存在的Key或者恶意尝试导致产生大量不存在的业务数据请求。
解决思路
1、缓存空值在redis,代码如下
	//添加空置常量
    private static final String EMPTY_CACHE="{}";
	//修改原有get方法,如下:
	   @Transactional
    public Product get(Long productId){
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
        String productStr = RedisUtils.get(productCacheKey,String.class);
        if (StringUtils.isNotEmpty(productStr)){
        //判断当缓存值为空串的时候,直接返回null
            if (EMPTY_CACHE.equals(productStr)){
                return null;
            }
            Product product = JSON.parseObject(productStr, Product.class);
            return product;
        }
        Product result =  productDao.get(productId);
        if (result!=null){
            RedisUtils.set(productCacheKey, JSON.toJSONString(result),
                    genProductCacheTimeout());
        }else {
        //当不存在的时候存储空串到缓存
            RedisUtils.set(productCacheKey, EMPTY_CACHE);
        }
        return result;
    }

3、缓存雪崩

(1)一次大V直播带货导致线上商品系统崩溃
问题解析

大V带货,带的大部分都是冷门的商品,这些商品都是存储在数据库中,并没有在缓存中,当大v带货3、2、1上链接的时候,同时会有几万的并发击穿缓存,直达数据库,且几万并发都可以查询到商品,那么在解决完缓存穿透的代码上,系统会重复重建无数次的缓存,从而浪费性能,甚至宕机,从而形成一系列的连锁反应,造成系统崩溃等情况,这就是缓存雪崩(Cache Avalanche)。

解决方法
1、DCL,双层检测锁,加锁,代码如下:
    @Transactional
    public Product get(Long productId){
        Product product =null;
                String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
        String productStr = RedisUtils.get(productCacheKey,String.class);
        if (StringUtils.isNotEmpty(productStr)){
            if (EMPTY_CACHE.equals(productStr)){
                return null;
            }
             product = JSON.parseObject(productStr, Product.class);
            return product;
        }
        synchronized (this){
             productStr = RedisUtils.get(productCacheKey,String.class);
            if (StringUtils.isNotEmpty(productStr)){
                if (EMPTY_CACHE.equals(productStr)){
                    return null;
                }
                 product = JSON.parseObject(productStr, Product.class);
                return product;
            }
            product =  productDao.get(productId);
            if (product!=null){
                RedisUtils.set(productCacheKey, JSON.toJSONString(product),
                        genProductCacheTimeout());
            }else {
                RedisUtils.set(productCacheKey, EMPTY_CACHE);
            }
        }
        return product;
    }
(2) 突发性热点缓存重建导致系统压力暴增。

存在的问题, synchronized (this)中锁的对象,有问题,比如
李佳奇上链接的商品是1088号商品,同时另一个大V罗永浩上链接的商品是10888,那么由于商品不一样,synchronized (this)锁的对象是一样的,谁先上链接获得锁,另一个大V上商品就得排队,导致所有i的商品都得排队。

1、解决思路

使用分布式锁,redisson,方法如下:

  1. 导入maven包:
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.6.5</version>
        </dependency>
  1. 创建分布式锁的前缀常量
    private static final String LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX="lock:product:hot_cache_crete_prefix";
  1. 修改get方法加锁方式
    @Transactional
    public Product get(Long productId){
        Product product =null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
        product = getProductFormCache(productCacheKey);
        if (product!=null){
            return product;
        }
        RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX + productId);
        hotCacheLock.lock();
        try {
            product = getProductFormCache(productCacheKey);
            if (product!=null){
                return product;
            }
            product =  productDao.get(productId);
            if (product!=null){
                RedisUtils.set(productCacheKey, JSON.toJSONString(product),
                        genProductCacheTimeout());
            }else {
                RedisUtils.set(productCacheKey, EMPTY_CACHE);
            }
        }finally {
            hotCacheLock.unlock();
        }
        return product;
    }

2、分布式锁优化

使用hotCacheLock.tryLock(3, TimeUnit.SECONDS); 代替hotCacheLock.lock();

hotCacheLock.tryLock(3, TimeUnit.SECONDS);源码解析:

    /**
     * Acquires the lock if it is free within the given waiting time and the
     * current thread has not been {@linkplain Thread#interrupt interrupted}.
     *
     * <p>If the lock is available this method returns immediately
     * with the value {@code true}.
     * If the lock is not available then
     * the current thread becomes disabled for thread scheduling
     * purposes and lies dormant until one of three things happens:
     * <ul>
     * <li>The lock is acquired by the current thread; or
     * <li>Some other thread {@linkplain Thread#interrupt interrupts} the
     * current thread, and interruption of lock acquisition is supported; or
     * <li>The specified waiting time elapses
     * </ul>
     *
     * <p>If the lock is acquired then the value {@code true} is returned.
     *
     * <p>If the current thread:
     * <ul>
     * <li>has its interrupted status set on entry to this method; or
     * <li>is {@linkplain Thread#interrupt interrupted} while acquiring
     * the lock, and interruption of lock acquisition is supported,
     * </ul>
     * then {@link InterruptedException} is thrown and the current thread's
     * interrupted status is cleared.
     *
     * <p>If the specified waiting time elapses then the value {@code false}
     * is returned.
     * If the time is
     * less than or equal to zero, the method will not wait at all.
     *
     * <p><b>Implementation Considerations</b>
     *
     * <p>The ability to interrupt a lock acquisition in some implementations
     * may not be possible, and if possible may
     * be an expensive operation.
     * The programmer should be aware that this may be the case. An
     * implementation should document when this is the case.
     *
     * <p>An implementation can favor responding to an interrupt over normal
     * method return, or reporting a timeout.
     *
     * <p>A {@code Lock} implementation may be able to detect
     * erroneous use of the lock, such as an invocation that would cause
     * deadlock, and may throw an (unchecked) exception in such circumstances.
     * The circumstances and the exception type must be documented by that
     * {@code Lock} implementation.
     *
     * @param time the maximum time to wait for the lock
     * @param unit the time unit of the {@code time} argument
     * @return {@code true} if the lock was acquired and {@code false}
     *         if the waiting time elapsed before the lock was acquired
     *
     * @throws InterruptedException if the current thread is interrupted
     *         while acquiring the lock (and interruption of lock
     *         acquisition is supported)
     */
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

从参数描述可以看出,第一个参数设置了最大等待时间,第二个参数是单位:
hotCacheLock.tryLock(3, TimeUnit.SECONDS);意思就是等待3秒,3秒后串转并,直接向下执行

4、缓存数据库双写不一致问题

在这里插入图片描述
线程3在从第二步到第三步的时候卡了一下,此时线程2全部执行完,此时就导致缓存数据库双写不一致问题。

解决方法:

使用分布式锁去解决缓存数据库双写不一致的问题,因为分布式锁的原理是吧并行转换成串行,因此分布式锁与高并发系统设计是相违背的,因此需要极力去优化分布式锁。

1、解决不一致问题
  1. 创建商品更新key
    private static final String LOCK_PRODUCT_UPDATE_PREFIX = "lock:product:update";

  1. 修改get方法,在查询数据库数据前加上更新锁,代码如下
	@Transactional
    public Product get(Long productId) {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        product = getProductFormCache(productCacheKey);
        if (product != null) {
            return product;
        }
        //解决热点缓存问题
        RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX + productId);
        hotCacheLock.lock();
        try {
            product = getProductFormCache(productCacheKey);
            if (product != null) {
                return product;
            }
            //解决缓存数据库双写不一致问题
            RLock updateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
            updateLock.lock();
            try {
                product = productDao.get(productId);
                if (product != null) {
                    RedisUtils.set(productCacheKey, JSON.toJSONString(product),
                            genProductCacheTimeout());
                } else {
                    RedisUtils.set(productCacheKey, EMPTY_CACHE, genProductCacheTimeout());
                }
            } finally {
                updateLock.unlock();
            }
        } finally {
            hotCacheLock.unlock();
        }
        return product;
    }
  1. 在更新方法前添加和查询方法中 一样的更新锁,代码如下:
    @Transactional
    public Product update(Product product) {
        //解决缓存数据库双写不一致问题
        RLock updateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
        updateLock.lock();
        Product result = null;
        try {
             result = productDao.update(product);
            RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE + result.getId(), JSON.toJSONString(result),
                    genProductCacheTimeout());
        }finally {
            updateLock.unlock();
        }

        return result;
    }
2、分布式锁性能优化

(1)读写锁
使用读写锁代替分布式锁:代码如下:

  1、get方法中锁的替换
    @Transactional
    public Product get(Long productId) {
        Product product = null;
        String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
        product = getProductFormCache(productCacheKey);
        if (product != null) {
            return product;
        }
        //解决热点缓存问题
        RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX + productId);
        hotCacheLock.lock();
        try {
            product = getProductFormCache(productCacheKey);
            if (product != null) {
                return product;
            }
            //解决缓存数据库双写不一致问题
            //RLock updateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
            //updateLock.lock();
            //使用读写锁中读锁加锁
            RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
            RLock rLock = readWriteLock.readLock();
            rLock.lock();
            try {
                product = productDao.get(productId);
                if (product != null) {
                    RedisUtils.set(productCacheKey, JSON.toJSONString(product),
                            genProductCacheTimeout());
                } else {
                    RedisUtils.set(productCacheKey, EMPTY_CACHE, genProductCacheTimeout());
                }
            } finally {
                //updateLock.unlock();
                rLock.unlock();
            }
        } finally {
            hotCacheLock.unlock();
        }
        return product;
    }
    
2、update方法中锁的替换
 @Transactional
    public Product update(Product product) {
        //解决缓存数据库双写不一致问题
        //RLock updateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
        //updateLock.lock();
        //使用读写锁中的写锁加锁
        RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
        RLock wLock = readWriteLock.writeLock();
        wLock.lock();
        Product result = null;
        try {
             result = productDao.update(product);
            RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE + result.getId(), JSON.toJSONString(result),
                    genProductCacheTimeout());
        }finally {
            //updateLock.unlock();
            wLock.unlock();
        }

        return result;
    }
读写锁源码解析

差看源码

     RLock rLock = readWriteLock.readLock();

在这里插入图片描述

核心代码如下:

    @Override
    <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                            "local mode = redis.call('hget', KEYS[1], 'mode'); " +
                            "if (mode == false) then " +
                                  "redis.call('hset', KEYS[1], 'mode', 'write'); " +
                                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                                  "return nil; " +
                              "end; " +
                              "if (mode == 'write') then " +
                                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + 
                                      "local currentExpire = redis.call('pttl', KEYS[1]); " +
                                      "redis.call('pexpire', KEYS[1], currentExpire + ARGV[1]); " +
                                      "return nil; " +
                                  "end; " +
                                "end;" +
                                "return redis.call('pttl', KEYS[1]);",
                        Arrays.<Object>asList(getName()), 
                        internalLockLeaseTime, getLockName(threadId));
    }

实际上也是执行了一段lua脚本,核心脚本如下:

redis.call('hset', KEYS[1], 'mode', 'write'); 

在原有的基础上增加了模式mode;底层逻辑实际上是setnx命令。

  1. 当新请求进来的时候,如果是写入数据,先查询是否有锁,在判断模式是否是write,当有write锁存在的时候,需要等待前一个写锁执行完,写锁与写锁也是互斥的。
  2. 如果是查询数据,也会先检查锁是否存在,在检查是否有写的模式存在,如果存在则需要排队等待,没有则直接执行
  3. 当全部是查询请求,redis会先写入一个分布式锁,模式是读read,当又有新请求进来的时候,判断锁存不存在,然后判断模式有没有写模式,没有写模式,加锁次数+1,并行执行查询。
  4. 当释放锁的时候加锁次数-1,当减到0的时候,锁完全释放。

场景三(超高并发)

超高并发场景中,在上面代码的基础上,可能导致每秒几十万甚至上百万的请求到达redis,导致缓存数据库崩掉,从而导致各个web系统接连崩掉,产生了缓存雪崩。

解决方法,使用多级缓存架构

  1. 创建JVM二级缓存
private static Map<String,Product>  productMap = new ConcurrentHashMap<>();
  1. 修改get、update方法,在set缓存的时候,将缓存数据也放一份在map中。
  //JVM二级缓存,解决超高并发问题
  productMap.put(productCacheKey,product);

遗留问题,Map是放在堆内存中,随着时间的推移,会产生OOM的问题,其次因为是集群环境,当其中一个节点更新了缓存后,如何保证其他节点数据一致性问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值