Caffeine(二)淘汰数据实现

533 阅读8分钟

淘汰数据算法

LFU和LRU

淘汰策略是影响缓存命中率的因素之一,常见的淘汰数据算法有LFU(Least Frequency Used,最近不经常使用)和LRU(Least Recently Used,最近最少使用)。

  • LFU优先淘汰掉最不经常使用的数据,需要维护一个表示使用频率的字段。缺点是(1)维护使用频率的字段会占据一定的空间;(2)无法合理的更新新出现的热点数据,旧的热点数据存储在缓存中不容易被淘汰
  • LRU优先淘汰掉最久未访问到的数据,缺点是不能很好地应对偶然的突发流量。例如一个热点数据在前一段时间经常访问,但在此时有很多冷门数据进入缓存,则会将热点数据淘汰出缓存

W-TinyLFU

Caffine使用了W-TinyLFU 算法,解决了 LRU 和LFU上述的缺点。W-TinyLFU的基础是TinyLFU,TinyLFU主要做了两件事:

  1. 采用Count-Min Sketch算法降低频率信息带来的内存消耗
  2. 维护一个PK机制保障新上的热点数据能够进入缓存,当有新的记录插入时,可以让它跟老的记录进行PK,输者就会被淘汰,这样一些老的、不再需要的数据就会被淘汰

image.png

Count-Min Sketch

Count-Min Sketch是一个用来计数的算法,在数据大小比较多大时,可以通过牺牲准确性减少数据的存储空间。它主要包含三个参数:

  • Hash哈希函数的数量:kk
  • 计数表格列的数量:mm
  • 内存中使用的空间:kmk*m

Count-Min Sketch只需要kmk*m个空间大小就可以统计每个数据出现的次数,相比每一个数据维护一个频率值,空间节省了很多。但是Count-Min Sketch计算出来的频率值是某个数据出现的最大次数,而不是准确次数。

初始化出现次数算法步骤:

  1. 先初始化二维数组,大小为kmk*m,值全部初始化为0
  2. 第i个hash函数当作行值i,该函数计算好的hash值模m当作列值j
  3. 二维数组第i行第j列的值加1

计算最大出现次数算法步骤:

  1. 遍历所有的hash函数,第i个hash函数当作行值i,该函数计算好的hash值当作列值j
  2. 比较全部(i,j)位置上的值,取最小值,该值即为数据出现的最大次数

实例:

三个hash函数(1)h1(x)=ASCII(x)h1(x)=ASCII(x); (2)h2(x)=2+ASCII(x)h2(x)=2 + ASCII(x); (3)h3(x)=4ASCII(x)h3(x)=4 * ASCII(x)。表格列的数量为5,即k=3,m=5,初始化二维表格如下:

image.png

插入字母B,则h1(B)=66;h2(B)=68;h3(B)=264h1(B) =66;h2(B)=68;h3(B)=264,分别对m=5取模,得到三组值(h1, 1)、(h2, 3)、(h3, 4),对应的二维数组均加1,则二维表格变为:

image.png

当计算字母B的出现的最大频率时,先计算三个hash值,即$h1(B) =66;h2(B)=68;h3(B)=264,分别对m=5取模,得到三组值(h1, 1)=1、(h2, 3)=1、(h3, 4)=1,取最小值为1,则B出现的最大次数为1。

Caffeine实现

计算频率

Caffine对Count-Min Sketch的实现在FrequencySketch类中,Caffine对该算法进行了优化,其不再使用二维数组,而只使用一个long类型的一维数组。且Caffine认为缓存的访问频率不需要特别大,只需要15就足够,一般认为达到15次的频率算是很高了,因此只需要4bit就可以表示,一个long有64bit,可以存储16个这样的统计数,这也使得存储效率提高了16倍。当频率超过15次之后,Caffine还有频率衰退减半的操作。

FrequencySketch中的属性以及初始化

int sampleSize;
int tableMask;
long[] table;
int size;
public void ensureCapacity(@NonNegative long maximumSize) {
  // 判断输入参数是否合法
  requireArgument(maximumSize >= 0);
  int maximum = (int) Math.min(maximumSize, Integer.MAX_VALUE >>> 1);
  if ((table != null) && (table.length >= maximum)) {
    return;
  }

  // 初始化数组,数组长度为大于maximum的最小的2的整次幂
  // 2的整次幂方便通过&操作来模拟取余操作
  table = new long[(maximum == 0) ? 1 : ceilingNextPowerOfTwo(maximum)];
  tableMask = Math.max(0, table.length - 1);
  sampleSize = (maximumSize == 0) ? 10 : (10 * maximum);
  if (sampleSize <= 0) {
    sampleSize = Integer.MAX_VALUE;
  }
  size = 0;
}

static int ceilingNextPowerOfTwo(int x) {
    // 左移得到大于x的最小的2的整次幂
    return 1 << (Integer.SIZE - Integer.numberOfLeadingZeros(x - 1));
}

increment()方法

increment()方法用于增加数据的频率值,当频率不超过15时,则增加频率值;当存放的数据数量等于预设值的10倍(上诉参数中size == sampleSize)时,则对元素的频率进行向下采样,这样实现了一个频率减小的机制,允许过期的长期的数据退出数组

public void increment(@NonNull E e) {
  // 检查table频率数组是否为null
  if (isNotInitialized()) {
    return;
  }

  // 使用spread()方法再次哈希e的hash值
  int hash = spread(e.hashCode());
  
  // (1)64位中每4位表示一个频率值,即一共16个频率值
  // (2)16个频率值又连续4个一组表示一个数据的频率,即一共4组
  // (3)对照上诉Count-Min Sketch过程,一个64位相当于是一个4*4的二维数组
  // start就是用来定位是属于哪一组频率值的
  // hash&3只能得到二进制00,01,10,11四种取值,再左移2位只能得到0,4,8,12四个值
  int start = (hash & 3) << 2;

  // 得到四个table[]数组的坐标
  int index0 = indexOf(hash, 0);
  int index1 = indexOf(hash, 1);
  int index2 = indexOf(hash, 2);
  int index3 = indexOf(hash, 3);

  // 每一组频率值中有4个,判断在4个数据中同一组每一个是否能增加频率
  // 一个记录的最大频率值为15
  boolean added = incrementAt(index0, start);
  added |= incrementAt(index1, start + 1);
  added |= incrementAt(index2, start + 2);
  added |= incrementAt(index3, start + 3);

  // size变量就是所有记录的数量,即每个记录加频率1,这个size都会加1
  // sampleSize为初始化数组值得10倍
  // 当达到阈值时,所有的频率统计除以2
  if (added && (++size == sampleSize)) {
    reset();
  }
}

// 对给定的哈希代码应用补充的哈希函数,以防止出现质量差的哈希函数。
int spread(int x) {
  x = ((x >>> 16) ^ x) * 0x45d9f3b;
  x = ((x >>> 16) ^ x) * 0x45d9f3b;
  return (x >>> 16) ^ x;
}

static final long[] SEED = new long[] {
    0xc3a5c85c97cb3127L, 0xb492b66fbe98f273L, 0x9ae16a3b2f90404fL, 0xcbf29ce484222325L};
int indexOf(int item, int i) {
  // 种子乘以item
  long hash = SEED[i] * item;
  // hash右移32位,将高32位放置再低32位,并将高32位置0
  // 即低32位+高32位结果放在低32位上,高32位不变
  hash += hash >>> 32;
  // 舍弃高32位,即只保留hash值的高32+低32的结果
  // tableMask为2的整次幂-1,其二进制表示为前面全为0,后面全为1,例如000111
  // 两者取余可以得到一个小于tableMask的整数,即在数组中的位置
  return ((int) hash) & tableMask;
}


boolean incrementAt(int i, int j) {
  // j表示属于哪一组频率值的,左移两位相当于乘以4,即得到在64位bit中的位置
  // 例如j的取值位0,4,8,12对应的offset分别为0,16,32,48
  int offset = j << 2;
  // 0xfL表示15,即频率的阈值
  // mask表示将15左移offset位,即将1111左移offset位
  // mask中的1可以和offset,offset+1,offset+2,offset+3位重合
  long mask = (0xfL << offset);
  // table[i] & mask将table[i]的64位bit中的[offset,offset+4]这4位置1,其他位置0
  // mask是[offset,offset+4]这4位为1,其他为0的数
  // 即可判断table[i] & mask是否等于15
  if ((table[i] & mask) != mask) {
    // 如果未达到阈值,则+1
    table[i] += (1L << offset);
    return true;
  }
  return false;
}

上诉过程如图所示:

image.png

reset()方法

reset()方法用于将所有记录的频率值除以2,可以让缓存保持“新鲜”,剔除掉过往频率很高但之后不经常使用的缓存。

void reset() {
  int count = 0;
  // 遍历table中的每一个元素
  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);
}

frequency()方法

frequency()方法可以获取某个值的频率,该过程和上诉increment()类似

public int frequency(@NonNull E e) {
  if (isNotInitialized()) {
    return 0;
  }

  // 2次hash
  int hash = spread(e.hashCode());
  // 获取哪一组频率值
  int start = (hash & 3) << 2;
  int frequency = Integer.MAX_VALUE;
  // 遍历该组频率值下的4个频率,并取最小值
  for (int i = 0; i < 4; i++) {
    int index = indexOf(hash, i);
    int count = (int) ((table[index] >>> ((start + i) << 2)) & 0xfL);
    frequency = Math.min(frequency, count);
  }
  return frequency;
}

使用Window

Caffeine通过测试发现TinyLFU在面对突发性的稀疏流量(sparse bursts)时表现很差,因为新的记录(new items)还没来得及建立足够的频率就被剔除出去了,这就使得命中率下降。于是Caffine在TinyLFU的基础上增加了一个Window Cache。

image.png

W-TinyLFU主要包含两个缓存模块,主缓存是SLRU(Segmented LRU,即分段LRU),SLRU包括一个protected和一个probation的缓存区。另一个缓存区是Window Cache,当有新的记录插入时,会先存入WindowCache,这样就可以避免sparse bursts问题了。 image.png

当window区满了,就会根据LRU把candidate(即淘汰出来的元素)放到probation区,如果probation区也满了,就把candidate和probation将要淘汰的元素victim,两个进行“PK”,胜者留在probation,输者就要被淘汰了。

Window区和Segemented区的比例会根据统计数据去动态调整,如果应用程序的缓存随着时间变化比较快的话,增加window区的比例可以提高命中率;如果缓存都是比较固定不变的话,增加Main Cache区(protected区 +probation区)的比例会有较好的效果。

W-TinyLFU数据结构

基本的数据结构

// 最大的个数限制
long maximum;

// 当前的个数
long weightedSize;

// window区的最大限制
long windowMaximum;

// window区当前的个数
long windowWeightedSize;

// protected区的最大限制
long mainProtectedMaximum;

// protected区当前的个数
long mainProtectedWeightedSize;

// 下一次需要调整的大小(还需要进一步计算)
double stepSize;

// window区需要调整的大小
long adjustment;

// 命中计数
int hitsInSample;

// 不命中的计数
int missesInSample;

// 上一次的缓存命中率
double previousSampleHitRate;

// 频率计算和维护类
final FrequencySketch<K> sketch;

// window区队列
final AccessOrderDeque<Node<K, V>> accessOrderWindowDeque;

// probation区队列
final AccessOrderDeque<Node<K, V>> accessOrderProbationDeque;

// protected区队列
final AccessOrderDeque<Node<K, V>> accessOrderProtectedDeque;

各个分区的默认比例

// 主空间专用的最大加权容量的初始百分比。
static final double PERCENT_MAIN = 0.99d;

// protected区最大加权容量的初始百分比。
static final double PERCENT_MAIN_PROTECTED = 0.80d;

// 与上次命中率之差的阈值
static final double HILL_CLIMBER_RESTART_THRESHOLD = 0.05d;

// 步长(调整)的大小(跟最大值maximum的比例)
static final double HILL_CLIMBER_STEP_PERCENT = 0.0625d;

// 步长的衰减比例
static final double HILL_CLIMBER_STEP_DECAY_RATE = 0.98d;

evictEntries()方法

evictEntries()方法用于当缓存超过最大值时,淘汰数据

void evictEntries() {
  if (!evicts()) {
    return;
  }
  // 淘汰window区的记录,candidates表示淘汰的个数
  int candidates = evictFromWindow();
  
  // 淘汰Main区的记录
  evictFromMain(candidates);
}

int evictFromWindow() {
  int candidates = 0;
  // window queue的头部节点
  Node<K, V> node = accessOrderWindowDeque().peek();
  
  // 判断当前window区数量是否大于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++;

      // 移除节点后,调整windowWeightedSize
      setWindowWeightedSize(windowWeightedSize() - node.getPolicyWeight());
    }
    // 处理下一个节点
    node = next;
  }

  return candidates;
}


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();
  
  // 判断当前的个数是否大于最大的个数
  while (weightedSize() > maximum()) {
    if (candidates == 0) {
      candidate = null;
    }

    // 如果两个都为null,从protected和window中取数据
    if ((candidate == null) && (victim == null)) {
      if (victimQueue == PROBATION) {
        victim = accessOrderProtectedDeque().peekFirst();
        victimQueue = PROTECTED;
        continue;
      } else if (victimQueue == PROTECTED) {
        victim = accessOrderWindowDeque().peekFirst();
        victimQueue = WINDOW;
        continue;
      }

      break;
    }

    // victim权重为0,则取victim的下一个节点
    if ((victim != null) && (victim.getPolicyWeight() == 0)) {
      victim = victim.getNextInAccessOrder();
      continue;
    } else if ((candidate != null) && (candidate.getPolicyWeight() == 0)) {
      // candidate权重为0,取candidate上一个节点
      candidate = candidate.getPreviousInAccessOrder();
      candidates--;
      continue;
    }

    // 如果只有一个数据存在,淘汰另一个数据
    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;
    }

    // 如果该数据已经被垃圾回收器回收,则立即驱逐
    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;
    }

    // 淘汰频率较低的数据
    candidates--;
    if (admit(candidateKey, victimKey)) {
      Node<K, V> evict = victim;
      victim = victim.getNextInAccessOrder();
      evictEntry(evict, RemovalCause.SIZE, 0L);
      candidate = candidate.getPreviousInAccessOrder();
    } else {
      Node<K, V> evict = candidate;
      candidate = candidate.getPreviousInAccessOrder();
      evictEntry(evict, RemovalCause.SIZE, 0L);
    }
  }
}

// 比较candidate和victim的频率值
boolean admit(K candidateKey, K victimKey) {
  // victim的频率值
  int victimFreq = frequencySketch().frequency(victimKey);
  // candidate的频率值
  int candidateFreq = frequencySketch().frequency(candidateKey);
  if (candidateFreq > victimFreq) {
    return true;
  } else if (candidateFreq <= 5) {
    return false;
  }
  int random = ThreadLocalRandom.current().nextInt();
  return ((random & 127) == 0);
}

climb()方法

climb()方法主要用来调整window size的,使得Caffine可以使用当前的应用类型

// 调整驱逐策略,实现最佳的最近和频率淘汰配置
void climb() {
  if (!evicts()) {
    return;
  }
  
  // 调整window窗口大小
  determineAdjustment();
  demoteFromMainProtected();
  long amount = adjustment();
  if (amount == 0) {
    return;
  } else if (amount > 0) {
    increaseWindow();
  } else {
    decreaseWindow();
  }
}

// 调整window窗口大小
void determineAdjustment() {
  // 如果frequencySketch还没初始化,则返回
  if (frequencySketch().isNotInitialized()) {
    setPreviousSampleHitRate(0.0);
    setMissesInSample(0);
    setHitsInSample(0);
    return;
  }
  
  // 总请求量 = 命中次数 + 不命中次数
  int requestCount = hitsInSample() + missesInSample();
  
  // sampleSize = 10 * maximum,用sampleSize表示缓存是否使用频繁
  if (requestCount < frequencySketch().sampleSize) {
    return;
  }

  // 命中率的公式 = 命中 / 总请求
  double hitRate = (double) hitsInSample() / requestCount;
  
  // 本次命中率和上次命中率的差值
  double hitRateChange = hitRate - previousSampleHitRate();
  
  // 本次调整的大小,是由命中率的差值和上次的stepSize决定的
  double amount = (hitRateChange >= 0) ? stepSize() : -stepSize();
  
  // 下次的调整大小:
  // (1)如果命中率的之差大于0.05,则重置为0.065 * maximum
  // (2)否则按照0.98来进行衰减,即0.98 * amount
  double nextStepSize = (Math.abs(hitRateChange) >= HILL_CLIMBER_RESTART_THRESHOLD)
      ? HILL_CLIMBER_STEP_PERCENT * maximum() * (amount >= 0 ? 1 : -1)
      : HILL_CLIMBER_STEP_DECAY_RATE * amount;
  setPreviousSampleHitRate(hitRate);
  setAdjustment((long) amount);
  setStepSize(nextStepSize);
  setMissesInSample(0);
  setHitsInSample(0);
}


static final int QUEUE_TRANSFER_THRESHOLD = 1_000;
// 主区超过设定的最大值,则将节点从protected转移到probation
void demoteFromMainProtected() {
  // protected区的阈值和当前大小
  long mainProtectedMaximum = mainProtectedMaximum();
  long mainProtectedWeightedSize = mainProtectedWeightedSize();
  if (mainProtectedWeightedSize <= mainProtectedMaximum) {
    return;
  }

  for (int i = 0; i < QUEUE_TRANSFER_THRESHOLD; i++) {
    if (mainProtectedWeightedSize <= mainProtectedMaximum) {
      break;
    }

    // protected区的节点
    Node<K, V> demoted = accessOrderProtectedDeque().poll();
    if (demoted == null) {
      break;
    }
    // 和window节点加入到probation节点类似
    // 先给节点打上probation的标签
    demoted.makeMainProbation();
    // 将该节点加到probation区域
    accessOrderProbationDeque().add(demoted);
    // 减去对应的权重
    mainProtectedWeightedSize -= demoted.getPolicyWeight();
  }
  setMainProtectedWeightedSize(mainProtectedWeightedSize);
}

// 缩小mainCahce的Protected大小并增加windowCach的大小
void increaseWindow() {
  if (mainProtectedMaximum() == 0) {
    return;
  }
  
  // 获取调整值和protected区数据量两者的最小值
  long quota = Math.min(adjustment(), mainProtectedMaximum());
  
  // 减小protected区域的大小
  setMainProtectedMaximum(mainProtectedMaximum() - quota);
  // 增大window区域的大小
  setWindowMaximum(windowMaximum() + quota);
  // 主区超过设定的最大值,则将节点从protected转移到probation
  demoteFromMainProtected();

  for (int i = 0; i < QUEUE_TRANSFER_THRESHOLD; i++) {
    Node<K, V> candidate = accessOrderProbationDeque().peek();
    boolean probation = true;
    if ((candidate == null) || (quota < candidate.getPolicyWeight())) {
      candidate = accessOrderProtectedDeque().peek();
      probation = false;
    }
    if (candidate == null) {
      break;
    }

    int weight = candidate.getPolicyWeight();
    if (quota < weight) {
      break;
    }

    quota -= weight;
    if (probation) {
      accessOrderProbationDeque().remove(candidate);
    } else {
      setMainProtectedWeightedSize(mainProtectedWeightedSize() - weight);
      accessOrderProtectedDeque().remove(candidate);
    }
    setWindowWeightedSize(windowWeightedSize() + weight);
    accessOrderWindowDeque().add(candidate);
    candidate.makeWindow();
  }

  setMainProtectedMaximum(mainProtectedMaximum() + quota);
  setWindowMaximum(windowMaximum() - quota);
  setAdjustment(quota);
}

// 减小WindowCache的大小并增加mainCache的Protected大小
void decreaseWindow() {
  if (windowMaximum() <= 1) {
    return;
  }

  long quota = Math.min(-adjustment(), Math.max(0, windowMaximum() - 1));
  setMainProtectedMaximum(mainProtectedMaximum() + quota);
  setWindowMaximum(windowMaximum() - quota);

  for (int i = 0; i < QUEUE_TRANSFER_THRESHOLD; i++) {
    Node<K, V> candidate = accessOrderWindowDeque().peek();
    if (candidate == null) {
      break;
    }

    int weight = candidate.getPolicyWeight();
    if (quota < weight) {
      break;
    }

    quota -= weight;
    setMainProtectedWeightedSize(mainProtectedWeightedSize() + weight);
    setWindowWeightedSize(windowWeightedSize() - weight);
    accessOrderWindowDeque().remove(candidate);
    accessOrderProbationDeque().add(candidate);
    candidate.makeMainProbation();
  }

  setMainProtectedMaximum(mainProtectedMaximum() - quota);
  setWindowMaximum(windowMaximum() + quota);
  setAdjustment(-quota);
}