11.7、热点key重建优化

热点key重建优化

开发人员使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:

  • 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。

  • 重建缓存不能再短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。

在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载过大,甚至可能会让应用崩溃。

要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:

  • 减少重建缓存的次数。

  • 数据尽可能一致。

  • 较少的潜在危险。

  1. 互斥锁(mutex key)

    此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。

    下面代码使用Redis的setnx命令实现上述功能:

    String get (String key) {
        //从Redis中获取数据
        String value = redis.get(key);
        //如果value为空,则开始重构缓存
        if (value == null) {
            //只允许一个线程重构缓存,使用nx,并设置过期时间ex
            String mutexKey = "mutext:key:" + key;
            if (redis.set(mutexKey, "1", "ex 180", "nx")) {
                //从数据源获取数据
                value = db.get(key);
                //回写Redis,并设置过期时间
                redis.setex(key, timeout, value);
                //删除 key_mutex
                redis.delete(mutexKey);
            }
            // 其他线程休息50毫秒后重试
            else {
                Thread.sleep(50);
                get(key);
            }
        }
        return value;
    }
    

    1)从Redis获取数据,如果值不为空,则直接返回值;否则执行下面的2.1)和2.2)步骤。

    2.1)如果set(nx和ex)结果为true,说明此时已经有其他线程正在执行构建缓存的工作,那么当前线程将休息指定时间(例如这里是50毫秒,取决于构建缓存的速度)后,重新执行函数,直到获取到数据。

  2. 永远不过期

    “永远不过期”包含两层意思

    • 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。

    • 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

    从实战看,此方法有效杜绝了热点key产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。下面代码使用使用Redis进行模拟:

    String get (final String key) {
        V v = redis.get(key);
        String value = v.getValue();
        //逻辑过期时间
        long logicTimeout = v.getLogicTimeout();
        //如果逻辑过期时间小于当前时间,开始后台构建
        if (v.logicTimeout <= System.currentTimeMillis()) {
            String mutexKey = "mutex:key:" + key;
            if (redis.set(mutexKey, "1", "ex 180", "nx")) {
                //重构缓存
                threadPool.execute(new Runnable(){
                    public void run () {
                        String dbValue = db.get(key);
                        redis.set(key, (dbValue, newLogicTiemout));
                        redis.delete(mutexKey);
                    }
                });
            }
        }
        return value;
    }
    

    作为一个并发量较大的应用,在使用缓存时有三个目标:第一,加快用户访问速度,提高用户体验。第二,降低后端负载,减少潜在风险。第三,保证数据“尽可能”及时更新。下面将按照这三个维度对上述解决方案进行分析。

    • 互斥锁(mutex key):这种方案思路比较简单,但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在思索和线程池阻塞的风险,但是这种方法能够较好地降低后端存储负载,并在一致性上做得比较好。

    • “永远不过期”:这种方案由于没有设置真正的过期时间,实际上已经不存在热点key产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。

    下面是两种解决方法的对比:

    解决方案 优点 缺点
    简单分布式锁 1.思路简单。2.保证一致性 1.代码复杂度增大。2.存在死锁的风险。3.存在线程池阻塞的风险
    “永远不过期” 基本杜绝热点key问题 1.不保证一致性。2.逻辑过期时间增加代码维护成本和内存成本。

你可能感兴趣的:(11.7、热点key重建优化)