高并发下的分布式缓存 | 设计和实现LFU缓存

LFU缓存

什么是 LFU 缓存?

最少使用频率 (LFU) 是一种缓存管理算法,在这种算法中,系统会记录缓存中每个数据被访问的次数。当缓存空间不足时,系统会删除访问频率最低的数据项,以便为新的数据腾出空间。

LFU 缓存问题描述

我们的目标是设计一个LFU 缓存,需要支持以下操作:

  • LFUCache(int capacity): 初始化LFU缓存,并设定缓存的容量。
  • int get(int key): 如果键存在于缓存中,则返回该键的值;否则,返回 -1。
  • void put(int key, int value): 如果键存在,则更新其值;如果键不存在,将新的key-value对加入缓存。如果缓存满了,应在插入新数据删除最少使用频率的数据。如果存在多个访问频率相同的数据(平局情况),则在这些数据中将最近最少使用的数据删除,这种情况相当于将LFU和LRU结合,在删除元素时,先比较使用频率,再根据时间戳决定删除哪个元素。

当对缓存中某个数据执行 get 或 put 操作时,该数据的频率将增加。get 和 put 操作的时间复杂度应为 O(1)。

LFU 缓存实现

使用暴力方法实现 LFU 缓存

我们初始化一个大小等于缓存大小的数组。每个元素存储键、值、频率以及该键被访问的时间戳。

class Element {
    int key;
    int val;
    int frequency;
    long timeStamp;

    public Element(int k, int v) {
        key = k;
        val = v;
        frequency = 1;
        timeStamp = System.currentTimeMillis(); // 使用系统时间戳
    }

    @Override
    public String toString() {
        return "{" + key + "=" + val + ", freq=" + frequency + ", timeStamp=" + timeStamp + "}";
    }
}

public class LFUCache {
    private final Element[] cache;
    private final int capacity;

    public LFUCache(int capacity) {
        this.capacity = capacity;
        this.cache = new Element[capacity];
    }

    public int get(int key) {
        for (Element element : cache) {
            if (element != null && element.key == key) {
                element.frequency++; // 增加频率
                element.timeStamp = System.currentTimeMillis(); // 更新时间戳
                return element.val;
            }
        }
        return -1; // 如果没找到,返回 -1
    }

    public void put(int key, int value) {
        // 检查缓存中是否已经有这个元素
        for (Element element : cache) {
            if (element != null && element.key == key) {
                element.val = value;
                element.frequency++; // 增加频率
                element.timeStamp = System.currentTimeMillis(); // 更新时间戳
                return;
            }
        }

        // 如果缓存未满,直接插入新的元素
        for (int i = 0; i < capacity; i++) {
            if (cache[i] == null) {
                cache[i] = new Element(key, value);
                return;
            }
        }

        // 如果缓存已满,找到频率最低的元素(LFU),如果频率相同则选择最久未使用的(LRU)
        int lfuIndex = 0;
        int minFrequency = cache[0].frequency;
        long oldestTimeStamp = cache[0].timeStamp;

        for (int i = 1; i < capacity; i++) {
            if (cache[i].frequency < minFrequency ||
                    (cache[i].frequency == minFrequency && cache[i].timeStamp < oldestTimeStamp)) {
                lfuIndex = i;
                minFrequency = cache[i].frequency;
                oldestTimeStamp = cache[i].timeStamp;
            }
        }

        // 替换最少使用的元素
        cache[lfuIndex] = new Element(key, value);
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        for (int i = 0; i < capacity; i++) {
            if (cache[i] != null) {
                sb.append(cache[i].toString());
                if (i < capacity - 1) sb.append(", ");
            }
        }
        sb.append("]");
        return sb.toString();
    }

    public static void main(String[] args) {
        LFUCache cache = new LFUCache(3);

        cache.put(1, 10);
        cache.put(2, 20);
        cache.put(3, 30);
        System.out.println(cache); // 打印缓存内容

        cache.get(1);  // 访问 key 1,增加其频率
        cache.get(2);  // 访问 key 2,增加其频率
        cache.put(4, 40); // 缓存已满,删除频率最低的 key 3
        System.out.println(cache); // 打印缓存内容
        
        cache.put(5, 50); // 缓存已满,删除频率最低的 key 4
        System.out.println(cache); // 打印缓存内容
    }
}

//输出
[{1=10, freq=1, timeStamp=1722865924851}, {2=20, freq=1, timeStamp=1722865924851}, {3=30, freq=1, timeStamp=1722865924852}]
[{1=10, freq=2, timeStamp=1722865924875}, {2=20, freq=2, timeStamp=1722865924875}, {4=40, freq=1, timeStamp=1722865924875}]
[{1=10, freq=2, timeStamp=1722865924875}, {2=20, freq=2, timeStamp=1722865924875}, {5=50, freq=1, timeStamp=1722865924875}]

int get(int key): 我们遍历数组,并将缓存中每个元素的键与给定键进行比较。如果找到相等的键,则增加该元素的频率并更新时间戳。如果未找到,则返回 -1。时间复杂度为 O(n)。

void put(int key, int value): 如果数组未满,我们创建一个频率为 1 且时间戳为当前时间的新元素插入到数组中,如果数组已满,我们删除访问频率最少的元素。为此,我们遍历数组并找到频率最低的元素。如果频率相同,则选择最近最少使用的元素(最旧的时间戳)。然后再插入新元素。时间复杂度为 O(n)。

使用更高效的方法实现 LFU 缓存

首先,我们将实现 O(1) 时间复杂度的插入和访问操作。为此,我们需要两个Map,一个存储键=>值,另一个存储键=>访问次数/频率。

public class LFUCache {
    private Map<Integer, Integer> valueMap = new HashMap<>();
    private Map<Integer, Integer> frequencyMap = new HashMap<>();
    private final int size;

    public LFUCache(int capacity) {
        size = capacity;
    }
    
    public int get(int key) {
        if (!valueMap.containsKey(key)) {
            return -1;
        }
        frequencyMap.put(key, frequencyMap.get(key) + 1);
        return valueMap.get(key);
    }
    
    public void put(int key, int value) {
        if (!valueMap.containsKey(key)) {
            valueMap.put(key, value);
            frequencyMap.put(key, 1);
        } else {
            valueMap.put(key, value);
            frequencyMap.put(key, frequencyMap.get(key) + 1);
        }
    }
}

在上述代码中,我们还没有实现缓存淘汰策略:当缓存大小达到最大容量时,我们需要找到访问频率最低的数据。

在当前实现中,我们必须遍历 frequencyMap 的所有元素才能找到访问频率最低的元素,这需要 O(n) 时间。

此外,如果缓存中有多个数据的访问频率相同时,在当前实现中我们无法找到最近最少使用的数据。

使用单链表实现 LFU 缓存

为了解决上面的问题,我们添加了一个新的数据结构,即一个有序映射,其中键是数据访问频率,值是具有相同频率的元素列表。

现在,新的数据可以添加到频率为 1 的链表末尾。由于映射按频率排序,我们可以在 O(1) 时间内找到最低频率的列表。此外,我们可以在 O(1) 时间内删除列表的第一个元素(访问频率最低的元素),因为它是最近最少使用的。

代码如下:

public class LFUCache {
    private Map<Integer, Integer> valueMap = new HashMap<>();
    private Map<Integer, Integer> countMap = new HashMap<>();
    //有序的映射,键是频率,值是一个按访问顺序排列的键的列表。它用于记录每个访问频率下有哪些键。
    private TreeMap<Integer, List<Integer>> frequencyMap = new TreeMap<>();
    private final int size;

    public LFUCache(int capacity) {
        size = capacity;
    }
    
    public int get(int key) {
        //缓存中不存在指定key
        if (!valueMap.containsKey(key) || size == 0) {
            return -1;
        }
        
        //从 frequencyMap 中移除该频率列表中的该键。非O(1)时间复杂度度
        int frequency = countMap.get(key);    
        frequencyMap.get(frequency).remove(new Integer(key));
        if (frequencyMap.get(frequency).size() == 0) {
            frequencyMap.remove(frequency);
        }
        frequencyMap.computeIfAbsent(frequency + 1, k -> new LinkedList<>()).add(key);
        countMap.put(key, frequency + 1);
        return valueMap.get(key);
    }
    
    public void put(int key, int value) {
        if (!valueMap.containsKey(key) && size > 0) {
            if (valueMap.size() == size) {
                int lowestCount = frequencyMap.firstKey();
                int keyToDelete = frequencyMap.get(lowestCount).remove(0);
                if (frequencyMap.get(lowestCount).size() == 0) {
                    frequencyMap.remove(lowestCount);
                }
                valueMap.remove(keyToDelete);
                countMap.remove(keyToDelete);
            }
            valueMap.put(key, value);
            countMap.put(key, 1);
            frequencyMap.computeIfAbsent(1, k -> new LinkedList<>()).add(key);
        } else if (size > 0) {
            valueMap.put(key, value);
            int frequency = countMap.get(key);
            frequencyMap.get(frequency).remove(new Integer(key));
            if (frequencyMap.get(frequency).size() == 0) {
                frequencyMap.remove(frequency);
            }
            frequencyMap.computeIfAbsent(frequency + 1, k -> new LinkedList<>()).add(key);
            countMap.put(key, frequency + 1);
        }
    }
}

因此,这种实现插入和删除操作都是 O(1)时间复杂度,即常量时间操作。

使用双链表实现 LFU 缓存 (Java)

在单链表实现 LFU 缓存淘汰算法时,我们将访问操作的时间复杂度增加到了 O(n)。所有具有相同频率的数据元素都在一个链表中。如果其中一个元素被访问,我们需要将其移动到下一个频率的链表中。我们必须先遍历链表找到该元素,这在最坏情况下需要 O(n) 操作。

为了解决这个问题,我们需要以某种方式直接在链表中访问该数据,如果我们能做到这一点,就可以在 O(1) 时间内从当前频率链表中删除该元素,并在 O(1) 时间内将其移动到下一个频率链表的末尾。

为此,我们需要一个双链表。我们将创建一个节点,存储元素的键、值和在链表中的位置。我们将把链表转换为双链表。

public class LFUCache {

    private Map<Integer, Node> valueMap = new HashMap<>();
    private Map<Integer, Integer> countMap = new HashMap<>();
    private TreeMap<Integer, DoubleLinkedList> frequencyMap = new TreeMap<>();
    private final int size;

    public LFUCache(int n) {
        size = n;
    }

    public int get(int key) {
        if (!valueMap.containsKey(key) || size == 0) {
            return -1;
        }

        Node nodeToDelete = valueMap.get(key);
        Node node = new Node(key, nodeToDelete.value());
        int frequency = countMap.get(key);
        frequencyMap.get(frequency).remove(nodeToDelete);
        removeIfListEmpty(frequency);
        valueMap.remove(key);
        countMap.remove(key);
        valueMap.put(key, node);
        countMap.put(key, frequency + 1);
        frequencyMap.computeIfAbsent(frequency + 1, k -> new DoubleLinkedList()).add(node);
        return valueMap.get(key).value;
    }

    public void put(int key, int value) {
        if (!valueMap.containsKey(key) && size > 0) {
            Node node = new Node(key, value);

            if (valueMap.size() == size) {
                int lowestCount = frequencyMap.firstKey();
                Node nodeToDelete = frequencyMap.get(lowestCount).head();
                frequencyMap.get(lowestCount).remove(nodeToDelete);
                removeIfListEmpty(lowestCount);

                int keyToDelete = nodeToDelete.key();
                valueMap.remove(keyToDelete);
                countMap.remove(keyToDelete);
            }
            frequencyMap.computeIfAbsent(1, k -> new DoubleLinkedList()).add(node);
            valueMap.put(key, node);
            countMap.put(key, 1);
        } else if (size > 0) {
            Node node = valueMap.get(key);
            Node nodeToInsert = new Node(key, value);
            int frequency = countMap.get(key);
            frequencyMap.get(frequency).remove(node);
            removeIfListEmpty(frequency);
            valueMap.remove(key);
            countMap.remove(key);
            valueMap.put(key, nodeToInsert);
            countMap.put(key, frequency + 1);
            frequencyMap.computeIfAbsent(frequency + 1, k -> new DoubleLinkedList()).add(nodeToInsert);
        }
    }

    private void removeIfListEmpty(int frequency) {
        if (frequencyMap.get(frequency).size() == 0) {
            frequencyMap.remove(frequency);
        }
    }
}

class Node {
    private final int key;
    private final int value;
    private Node previous;
    private Node next;

    public Node(int k, int v) {
        key = k;
        value = v;
    }

    public int key() {
        return key;
    }

    public int value() {
        return value;
    }
}

class DoubleLinkedList {
    private Node head = new Node(0, 0);
    private Node tail = new Node(0, 0);
    private int size = 0;

    public DoubleLinkedList() {
        head.next = tail;
        tail.previous = head;
    }

    public void add(Node node) {
        node.previous = tail.previous;
        node.previous.next = node;
        node.next = tail;
        tail.previous = node;
        size++;
    }

    public void remove(Node node) {
        node.previous.next = node.next;
        node.next.previous = node.previous;
        size--;
    }

    public Node head() {
        return head.next;
    }

    public int size() {
        return size;
    }
}

双链表确保我们可以在常数时间内删除节点和插入节点。总的来说,插入和访问操作都是 O(1) 时间复杂度。

LRU缓存的实际应用

LFU算法在以下场景中通常比 LRU算法更有效:

访问频率明显偏向某些数据的场景:

在一些应用中,某些数据或资源的访问频率远高于其他数据,形成了所谓的“热点数据”。例如,在电商网站中,主页、爆款商品页面,或者内容平台上的热门文章,通常会被频繁访问。

LFU 能更好地识别并保留这些频繁访问的数据,即使在这些数据最近没有被访问的情况下,仍然会将它们保留在缓存中。而 LRU 则可能因为这些数据暂时未被访问而将其移出缓存,导致后续访问时出现缓存未命中(cache miss)的情况。

短期内访问模式多变,长期内存在固定热点的场景:

例如,在一个社交媒体平台上,某些用户或内容在短期内可能突然变得非常热门,但从长远来看,只有那些优质的内容始终受到用户的关注。

LFU 在这种情况下会优先保留长期热点数据,不容易被那些短期流行数据“冲出”缓存。,LRU 更注重最近的访问情况,可能因为短期的访问高峰而替换掉长期热门的内容,进而降低缓存命中率。

  • 15
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值