美文网首页
Window TinyLFU算法

Window TinyLFU算法

作者: lim快乐_无限 | 来源:发表于2021-04-14 18:12 被阅读0次

    一、简介

    判断一个缓存的好坏最核心的指标命中率、性能以及资源的占用等指标。淘汰策略是影响缓存命中率的重要因素。一般比较简单的缓存就会直接用到 LFU(Least Frequently Used,即最不经常使用) 或者LRU(Least Recently Used,即最近最少使用) ,而 Caffeine 使用了 W-TinyLFU 算法。

    二、LRU和LFU的缺点

    • LRU 实现简单,在一般情况下能够表现出很好的命中率,是一个“性价比”很高的算法,平时也很常用。虽然 LRU 对突发性的稀疏流量(sparse bursts)表现很好,但同时也会产生缓存污染,举例来说,如果偶然性的要对全量数据进行遍历,那么“历史访问记录”就会被刷走,造成污染。

    • 如果数据的分布在一段时间内是固定的话,那么 LFU 可以达到最高的命中率。但是 LFU 有两个缺点,第一,它需要给每个记录项维护频率信息,每次访问都需要更新,这是个巨大的开销;第二,对突发性的稀疏流量无力,因为前期经常访问的记录已经占用了缓存,偶然的流量不太可能会被保留下来,而且过去的一些大量被访问的记录在将来也不一定会使用上,这样就一直把“坑”占着了。

    三、TinyLFU

    TinyLFU 算法是专门为了解决 LFU 上述提到的两个问题而被设计出来的。

    • 解决第一个问题是采用了 Count–Min Sketch 算法。

    • 解决第二个问题是让记录尽量保持相对的“新鲜”(Freshness Mechanism),并且当有新的记录插入时,可以让它跟老的记录进行“PK”,输者就会被淘汰,这样一些老的、不再需要的记录就会被剔除。

    1、统计频率 Count–Min Sketch 算法

    Caffeine 对这个算法的实现在FrequencySketch类。Caffeine 是用了一个一维的数组;如果是数值类型的话,这个数需要用 int 或 long 来存储,但是 Caffeine 认为缓存的访问频率不需要用到那么大,只需要 15 就足够,一般认为达到 15 次的频率算是很高的了,而且 Caffeine 还有另外一个机制来使得这个频率进行衰退减半。如果最大是 15 的话,那么只需要 4 个 bit 就可以满足了,一个 long 有 64bit,可以存储 16 个这样的统计数,使得存储效率提高了 16 倍.

    追加方法

    frequencySketch().increment(key);
    
    //FrequencySketch的一些属性
    
    //种子数
    static final long[] SEED = { // A mixture of seeds from FNV-1a, CityHash, and Murmur3
     0xc3a5c85c97cb3127L, 0xb492b66fbe98f273L, 0x9ae16a3b2f90404fL, 0xcbf29ce484222325L};
    static final long RESET_MASK = 0x7777777777777777L;
    static final long ONE_MASK = 0x1111111111111111L;
    
    int sampleSize;
    //为了快速根据hash值得到table的index值的掩码
    //table的长度size一般为2的n次方,而tableMask为size-1,这样就可以通过&操作来模拟取余操作
    int tableMask;
    //存储数据的一维long数组
    long[] table;
    int size;
    
    /**
     * Increments the popularity of the element if it does not exceed the maximum (15). The popularity
     * of all elements will be periodically down sampled when the observed events exceeds a threshold.
     * This process provides a frequency aging to allow expired long term entries to fade away.
     *
     * @param e the element to add
     */
    public void increment(@NonNull E e) {
     if (isNotInitialized()) {
     return;
     }
    
     //根据key的hashCode通过一个哈希函数得到一个hash值
     int hash = spread(e.hashCode());
     //Caffeine把一个long的64bit划分成16个等分,每一等分4个bit。
     //这个start就是用来定位到是哪一个等分的,用hash值低两位作为随机数,再左移2位,得到一个小于16的值
     // start 值:0、4、8、12
     int start = (hash & 3) << 2;
    
     //indexOf方法的意思就是,根据hash值和不同种子得到table的下标index
     //这里通过四个不同的种子,得到四个不同的下标index
     int index0 = indexOf(hash, 0);
     int index1 = indexOf(hash, 1);
     int index2 = indexOf(hash, 2);
     int index3 = indexOf(hash, 3);
    
     //根据index和start(+1, +2, +3)的值,把table[index]对应的等分追加1
     boolean added = incrementAt(index0, start);
     added |= incrementAt(index1, start + 1);
     added |= incrementAt(index2, start + 2);
     added |= incrementAt(index3, start + 3);
    
     if (added && (++size == sampleSize)) {
     reset();
     }
    }
    
    /**
     * Increments the specified counter by 1 if it is not already at the maximum value (15).
     *
     * @param i the table index (16 counters)
     * @param j the counter to increment
     * @return if incremented
     */
    boolean incrementAt(int i, int j) {
     //这个j表示16个等分的下标,那么offset就是相当于在64位中的下标 16 * 4
     int offset = j << 2;
     //上面提到Caffeine把频率统计最大定为15,即0xfL
     //mask就是在64位中的掩码,即1111后面跟很多个0
     long mask = (0xfL << offset);
     //如果&的结果不等于15,那么就追加1。等于15就不会再加了
     if ((table[i] & mask) != mask) {
     table[i] += (1L << offset);
     return true;
     }
     return false;
    }
    
    /**
     * Returns the table index for the counter at the specified depth.
     *
     * @param item the element's hash
     * @param i the counter depth
     * @return the table index
     */
    int indexOf(int item, int i) {
     long hash = SEED[i] * item;
     hash += hash >>> 32;
     return ((int) hash) & tableMask;
    }
    
    /**
     * Applies a supplemental hash function to a given hashCode, which defends against poor quality
     * hash functions.
     */
    int spread(int x) {
     x = ((x >>> 16) ^ x) * 0x45d9f3b;
     x = ((x >>> 16) ^ x) * 0x45d9f3b;
     return (x >>> 16) ^ x;
    }
    

    读取方法

    @NonNegative
    public int frequency(@NonNull E e) {
     if (isNotInitialized()) {
     return 0;
     }
    
     int hash = spread(e.hashCode());
     //得到等分的下标,跟上面一样
     int start = (hash & 3) << 2;
     int frequency = Integer.MAX_VALUE;
     //循环四次,分别获取在table数组中不同的下标位置
     for (int i = 0; i < 4; i++) {
     int index = indexOf(hash, i);
     //定位到table[index] + 等分的位置,再根据mask取出计数值
     int count = (int) ((table[index] >>> ((start + i) << 2)) & 0xfL);
     //取四个中的较小值
     frequency = Math.min(frequency, count);
     }
     return frequency;
    }
    
    4.png
    2、保新机制

    为了让缓存保持“新鲜”,剔除掉过往频率很高但之后不经常的缓存,Caffeine 有一个新鲜度机制。就是当整体的统计计数达到某一个值时,那么所有记录的频率统计除以 2。

    //size变量就是所有记录的频率统计之,即每个记录加1,这个size都会加1
    //sampleSize一个阈值,从FrequencySketch初始化可以看到它的值为maximumSize的10倍
    if (added && (++size == sampleSize)) {
     reset();
    }
    
    void reset() {
     int count = 0;
     for (int i = 0; i < table.length; i++) {
     count += Long.bitCount(table[i] & ONE_MASK);
     table[i] = (table[i] >>> 1) & RESET_MASK;
     }
     size = (size >>> 1) - (count >>> 2);
    }
    

    四、window

    Caffeine 通过测试发现 TinyLFU 在面对突发性的稀疏流量(sparse bursts)时表现很差,因为新的记录(new items)还没来得及建立足够的频率就被剔除出去了,这就使得命中率下降。 于是 Caffeine 设计出一种新的算法,即 Window Tiny LFU(W-TinyLFU),并通过实验和实践发现 W-TinyLFU 比 TinyLFU 表现的更好。

    5.png

    它主要包括两个缓存模块,主缓存是 SLRU(Segmented LRU,即分段 LRU),SLRU 包括一个名为 protected 和一个名为 probation 的缓存区。通过增加一个缓存区(即 Window Cache),当有新的记录插入时,会先在 window 区呆一下,就可以避免稀疏流量问题。

    五、淘汰策略

    当 window 区满了,就会根据 LRU 把 candidate(即淘汰出来的元素)放到 probation 区,如果 probation 区也满了,就把 candidate 和 probation 将要淘汰的元素,两个进行“PK”,胜者留在 probation,输者就要被淘汰了。 而且经过实验发现当 window 区配置为总容量的 1%,剩余的 99%当中的 80%分给 protected 区,20%分给 probation 区时,这时整体性能和命中率表现得最好,所以 Caffeine 默认的比例设置就是这个。 不过这个比例 Caffeine 会在运行时根据统计数据(statistics)去动态调整,如果你的应用程序的缓存随着时间变化比较快的话,那么增加 window 区的比例可以提高命中率,相反缓存都是比较固定不变的话,增加 Main Cache 区(protected 区 +probation 区)的比例会有较好的效果。 淘汰策略所在类 BoundedLocalCache 关键属性

    //最大的个数限制
    long maximum;
    //当前的个数
    long weightedSize;
    //window区的最大限制
    long windowMaximum;
    //window区当前的个数
    long windowWeightedSize;
    //protected区的最大限制
    long mainProtectedMaximum;
    //protected区当前的个数
    long mainProtectedWeightedSize;
    
    final FrequencySketch<K> sketch;
    //window区的LRU queue(FIFO)
    final AccessOrderDeque<Node<K, V>> accessOrderWindowDeque;
    //probation区的LRU queue(FIFO)
    final AccessOrderDeque<Node<K, V>> accessOrderProbationDeque;
    //protected区的LRU queue(FIFO)
    final AccessOrderDeque<Node<K, V>> accessOrderProtectedDeque;
    
    @GuardedBy("evictionLock")
      void evictEntries() {
        if (!evicts()) {
          return;
        }
        //淘汰window区的记录
      int candidates = evictFromWindow();
      //淘汰Main区的记录
      evictFromMain(candidates);
      }
    
    //根据W-TinyLFU,新的数据都会无条件的加到admission window
    //但是window是有大小限制,所以要“定期”做一下“维护”
    @GuardedBy("evictionLock")
    int evictFromWindow() {
      int candidates = 0;
      //查看window queue的头部节点
      Node<K, V> node = accessOrderWindowDeque().peek();
      //如果window区超过了最大的限制,那么就要把“多出来”的记录做处理
      while (windowWeightedSize() > windowMaximum()) {
        // The pending operations will adjust the size to reflect the correct weight
        if (node == null) {
          break;
        }
        //下一个节点
        Node<K, V> next = node.getNextInAccessOrder();
        if (node.getWeight() != 0) {
          //把node定位在probation区
          node.makeMainProbation();
          //从window区去掉
          accessOrderWindowDeque().remove(node);
          //加入到probation queue,相当于把节点移动到probation区
          accessOrderProbationDeque().add(node);
          candidates++;
          //因为移除了一个节点,所以需要调整window的size
          setWindowWeightedSize(windowWeightedSize() - node.getPolicyWeight());
        }
        //处理下一个节点
        node = next;
      }
    
      return candidates;
    }
    
    @GuardedBy("evictionLock")
    void evictFromMain(int candidates) {
      int victimQueue = PROBATION;
      //victim是probation queue的头部
      Node<K, V> victim = accessOrderProbationDeque().peekFirst();
      //candidate是probation queue的尾部,也就是刚从window晋升来的
      Node<K, V> candidate = accessOrderProbationDeque().peekLast();
      //当cache不够容量时才做处理
      while (weightedSize() > maximum()) {
        // Stop trying to evict candidates and always prefer the victim
        if (candidates == 0) {
          candidate = null;
        }
    
        //对candidate为null且victim为bull的处理
        if ((candidate == null) && (victim == null)) {
          if (victimQueue == PROBATION) {
            victim = accessOrderProtectedDeque().peekFirst();
            victimQueue = PROTECTED;
            continue;
          } else if (victimQueue == PROTECTED) {
            victim = accessOrderWindowDeque().peekFirst();
            victimQueue = WINDOW;
            continue;
          }
    
          // The pending operations will adjust the size to reflect the correct weight
          break;
        }
    
        //对节点的weight为0的处理
        if ((victim != null) && (victim.getPolicyWeight() == 0)) {
          victim = victim.getNextInAccessOrder();
          continue;
        } else if ((candidate != null) && (candidate.getPolicyWeight() == 0)) {
          candidate = candidate.getPreviousInAccessOrder();
          candidates--;
          continue;
        }
    
        // Evict immediately if only one of the entries is present
        if (victim == null) {
          @SuppressWarnings("NullAway")
          Node<K, V> previous = candidate.getPreviousInAccessOrder();
          Node<K, V> evict = candidate;
          candidate = previous;
          candidates--;
          evictEntry(evict, RemovalCause.SIZE, 0L);
          continue;
        } else if (candidate == null) {
          Node<K, V> evict = victim;
          victim = victim.getNextInAccessOrder();
          evictEntry(evict, RemovalCause.SIZE, 0L);
          continue;
        }
    
        // Evict immediately if an entry was collected
        K victimKey = victim.getKey();
        K candidateKey = candidate.getKey();
        if (victimKey == null) {
          @NonNull Node<K, V> evict = victim;
          victim = victim.getNextInAccessOrder();
          evictEntry(evict, RemovalCause.COLLECTED, 0L);
          continue;
        } else if (candidateKey == null) {
          candidates--;
          @NonNull Node<K, V> evict = candidate;
          candidate = candidate.getPreviousInAccessOrder();
          evictEntry(evict, RemovalCause.COLLECTED, 0L);
          continue;
        }
    
        //放不下的节点直接处理掉
        if (candidate.getPolicyWeight() > maximum()) {
          candidates--;
          Node<K, V> evict = candidate;
          candidate = candidate.getPreviousInAccessOrder();
          evictEntry(evict, RemovalCause.SIZE, 0L);
          continue;
        }
    
        //根据节点的统计频率frequency来做比较,看看要处理掉victim还是candidate
        //admit是具体的比较规则,看下面
        candidates--;
        //如果candidate胜出则淘汰victim
        if (admit(candidateKey, victimKey)) {
          Node<K, V> evict = victim;
          victim = victim.getNextInAccessOrder();
          evictEntry(evict, RemovalCause.SIZE, 0L);
          candidate = candidate.getPreviousInAccessOrder();
        } else {
          //如果是victim胜出,则淘汰candidate
          Node<K, V> evict = candidate;
          candidate = candidate.getPreviousInAccessOrder();
          evictEntry(evict, RemovalCause.SIZE, 0L);
        }
      }
    }
    
    @GuardedBy("evictionLock")
    boolean admit(K candidateKey, K victimKey) {
      //分别获取victim和candidate的统计频率
      //frequency这个方法的原理和实现上面已经解释了
      int victimFreq = frequencySketch().frequency(victimKey);
      int candidateFreq = frequencySketch().frequency(candidateKey);
      //谁大谁赢
      if (candidateFreq > victimFreq) {
        return true;
    
        //如果相等,candidate小于5都当输了
      } else if (candidateFreq <= 5) {
        // The maximum frequency is 15 and halved to 7 after a reset to age the history. An attack
        // exploits that a hot candidate is rejected in favor of a hot victim. The threshold of a warm
        // candidate reduces the number of random acceptances to minimize the impact on the hit rate.
        return false;
      }
      //如果相等且candidate大于5,则随机淘汰一个
      int random = ThreadLocalRandom.current().nextInt();
      return ((random & 127) == 0);
    }
    
    @GuardedBy("evictionLock")
      void onAccess(Node<K, V> node) {
        if (evicts()) {
          K key = node.getKey();
          if (key == null) {
            return;
          }
          frequencySketch().increment(key);
          if (node.inWindow()) {
            reorder(accessOrderWindowDeque(), node);
          } else if (node.inMainProbation()) {
            reorderProbation(node);
          } else {
            reorder(accessOrderProtectedDeque(), node);
          }
          setHitsInSample(hitsInSample() + 1);
        } else if (expiresAfterAccess()) {
          reorder(accessOrderWindowDeque(), node);
        }
        if (expiresVariable()) {
          timerWheel().reschedule(node);
        }
      }
    
    @GuardedBy("evictionLock")
      void reorderProbation(Node<K, V> node) {
        if (!accessOrderProbationDeque().contains(node)) {
          // Ignore stale accesses for an entry that is no longer present
          return;
        } else if (node.getPolicyWeight() > mainProtectedMaximum()) {
          return;
        }
    
        // If the protected space exceeds its maximum, the LRU items are demoted to the probation space.
        // This is deferred to the adaption phase at the end of the maintenance cycle.
        setMainProtectedWeightedSize(mainProtectedWeightedSize() + node.getPolicyWeight());
        accessOrderProbationDeque().remove(node);
        accessOrderProtectedDeque().add(node);
        node.makeMainProtected();
      }
    

    相关文章

      网友评论

          本文标题:Window TinyLFU算法

          本文链接:https://www.haomeiwen.com/subject/lwdllltx.html