Window TinyLFU算法

一、简介

判断一个缓存的好坏最核心的指标命中率、性能以及资源的占用等指标。淘汰策略是影响缓存命中率的重要因素。一般比较简单的缓存就会直接用到 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 sketch;
//window区的LRU queue(FIFO)
final AccessOrderDeque> accessOrderWindowDeque;
//probation区的LRU queue(FIFO)
final AccessOrderDeque> accessOrderProbationDeque;
//protected区的LRU queue(FIFO)
final AccessOrderDeque> 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 node = accessOrderWindowDeque().peek();
  //如果window区超过了最大的限制,那么就要把“多出来”的记录做处理
  while (windowWeightedSize() > windowMaximum()) {
    // The pending operations will adjust the size to reflect the correct weight
    if (node == null) {
      break;
    }
    //下一个节点
    Node 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 victim = accessOrderProbationDeque().peekFirst();
  //candidate是probation queue的尾部,也就是刚从window晋升来的
  Node 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 previous = candidate.getPreviousInAccessOrder();
      Node evict = candidate;
      candidate = previous;
      candidates--;
      evictEntry(evict, RemovalCause.SIZE, 0L);
      continue;
    } else if (candidate == null) {
      Node 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 evict = victim;
      victim = victim.getNextInAccessOrder();
      evictEntry(evict, RemovalCause.COLLECTED, 0L);
      continue;
    } else if (candidateKey == null) {
      candidates--;
      @NonNull Node evict = candidate;
      candidate = candidate.getPreviousInAccessOrder();
      evictEntry(evict, RemovalCause.COLLECTED, 0L);
      continue;
    }

    //放不下的节点直接处理掉
    if (candidate.getPolicyWeight() > maximum()) {
      candidates--;
      Node evict = candidate;
      candidate = candidate.getPreviousInAccessOrder();
      evictEntry(evict, RemovalCause.SIZE, 0L);
      continue;
    }

    //根据节点的统计频率frequency来做比较,看看要处理掉victim还是candidate
    //admit是具体的比较规则,看下面
    candidates--;
    //如果candidate胜出则淘汰victim
    if (admit(candidateKey, victimKey)) {
      Node evict = victim;
      victim = victim.getNextInAccessOrder();
      evictEntry(evict, RemovalCause.SIZE, 0L);
      candidate = candidate.getPreviousInAccessOrder();
    } else {
      //如果是victim胜出,则淘汰candidate
      Node 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 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 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算法)