74. STL与缓存系统:LRU缓存、LFU缓存、缓存策略

缓存,说白了就是用空间换时间。你想想看,程序里最耗时的操作是什么?无非就是磁盘I/O、网络请求、数据库查询这些。如果每次都要去取,性能肯定扛不住。缓存就是把那些"热数据"留在内存里,下次直接拿,省去重复计算的成本。

我做了这么多年C++开发,几乎每个项目都离不开缓存。从Web服务器的页面缓存,到数据库的查询结果缓存,再到CPU的指令缓存——缓存无处不在。今天我们就来聊聊两种最经典的缓存策略:LRU和LFU,以及如何用STL来实现它们。

核心要点:LRU(最近最少使用)和LFU(最不经常使用)是两种最主流的缓存淘汰策略。LRU关注"时间局部性",LFU关注"频率局部性"。实际项目中,我通常根据数据访问模式来选择。

LRU缓存:最近最少使用

LRU的思路很简单:如果数据最近被访问过,那么将来被访问的概率也更高。所以当缓存满了,就淘汰最久没被访问的那个。

实现LRU缓存,我们需要两个核心数据结构:

  • 哈希表:用于O(1)时间查找缓存项
  • 双向链表:用于维护访问顺序,最近访问的放在头部,最久未访问的放在尾部

STL里没有现成的双向链表?别急,std::list就是双向链表。哈希表用std::unordered_map。两者一结合,就能实现高效的LRU缓存。

#include <unordered_map>
#include <list>
#include <utility>

template<typename Key, typename Value>
class LRUCache {
public:
    LRUCache(size_t capacity) : capacity_(capacity) {}

    Value get(const Key& key) {
        auto it = cache_.find(key);
        if (it == cache_.end()) {
            throw std::runtime_error("Key not found");
        }
        // 把访问的节点移到链表头部
        access_list_.splice(access_list_.begin(), access_list_, it->second.second);
        return it->second.first;
    }

    void put(const Key& key, const Value& value) {
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            // 更新值,并移到头部
            it->second.first = value;
            access_list_.splice(access_list_.begin(), access_list_, it->second.second);
            return;
        }
        // 如果缓存满了,淘汰最久未使用的(链表尾部)
        if (cache_.size() >= capacity_) {
            Key old_key = access_list_.back();
            access_list_.pop_back();
            cache_.erase(old_key);
        }
        // 插入新节点到链表头部
        access_list_.push_front(key);
        cache_[key] = {value, access_list_.begin()};
    }

private:
    size_t capacity_;
    std::list<Key> access_list_;  // 维护访问顺序
    std::unordered_map<Key, std::pair<Value, typename std::list<Key>::iterator>> cache_;
};

我的经验:这里用std::list::splice而不是先erase再push_front,因为splice是O(1)的,而erase+push_front是O(n)。我曾经在一个高并发服务里用错了,结果性能直接掉了一个数量级。

LFU缓存:最不经常使用

LFU的思路不同:它记录每个缓存项被访问的次数,淘汰时选访问次数最少的。如果次数相同,再淘汰最久未访问的。

实现LFU要复杂一些。我习惯用三个哈希表:

  • 一个存键值对
  • 一个存每个键的访问频率
  • 一个存每个频率对应的键集合(用std::list
#include <unordered_map>
#include <list>
#include <climits>

template<typename Key, typename Value>
class LFUCache {
public:
    LFUCache(size_t capacity) : capacity_(capacity), min_freq_(0) {}

    Value get(const Key& key) {
        if (cache_.find(key) == cache_.end()) {
            throw std::runtime_error("Key not found");
        }
        // 增加访问频率
        increaseFreq(key);
        return cache_[key].first;
    }

    void put(const Key& key, const Value& value) {
        if (capacity_ == 0) return;

        if (cache_.find(key) != cache_.end()) {
            cache_[key].first = value;
            increaseFreq(key);
            return;
        }

        if (cache_.size() >= capacity_) {
            // 淘汰频率最低且最久未使用的
            Key old_key = freq_list_[min_freq_].back();
            freq_list_[min_freq_].pop_back();
            cache_.erase(old_key);
            key_freq_.erase(old_key);
        }

        // 插入新节点,频率为1
        cache_[key] = {value, 1};
        key_freq_[key] = 1;
        freq_list_[1].push_front(key);
        min_freq_ = 1;
    }

private:
    void increaseFreq(const Key& key) {
        int old_freq = key_freq_[key];
        int new_freq = old_freq + 1;
        key_freq_[key] = new_freq;

        // 从旧频率列表移除
        freq_list_[old_freq].remove(key);
        if (freq_list_[old_freq].empty() && old_freq == min_freq_) {
            min_freq_++;
        }

        // 插入新频率列表
        freq_list_[new_freq].push_front(key);
    }

    size_t capacity_;
    int min_freq_;
    std::unordered_map<Key, std::pair<Value, int>> cache_;  // 键 -> (值, 频率)
    std::unordered_map<Key, int> key_freq_;                   // 键 -> 频率
    std::unordered_map<int, std::list<Key>> freq_list_;      // 频率 -> 键列表
};

注意:LFU有一个经典问题——"缓存污染"。如果一个热点数据突然不再被访问,它可能因为之前的高频率而长期占据缓存。我曾经在广告推荐系统里遇到过这个问题,后来加了衰减机制才解决。

缓存策略的选择

实际项目中,选哪种策略要看数据访问模式:

场景 推荐策略 原因
网页缓存 LRU 用户通常短时间内反复访问同一页面
数据库查询缓存 LFU 某些查询被频繁执行,频率稳定
CDN缓存 LRU + TTL 内容时效性重要,LRU配合过期时间
推荐系统 LFU + 衰减 用户兴趣会变化,需要衰减历史频率

嗯,这里要补充一点:没有银弹。我见过很多团队一开始选了LRU,后来发现某些热点数据被频繁访问但间隔较长,LRU反而把它们淘汰了。这时候LFU可能更合适。反过来,如果数据访问模式变化很快,LFU的"记忆"反而成了负担。

SVG:LRU与LFU核心逻辑对比

LRU vs LFU 核心逻辑对比 LRU 缓存 Head K3 K1 K2 Tail 淘汰策略:淘汰尾部(最久未访问) 访问K3 → K3移到头部 缓存满 → 淘汰K2(尾部) 哈希表(O(1)查找) K1 → 链表节点指针 K3 → 链表节点指针 LFU 缓存 Freq=1 Freq=2 Freq=3 K2 K1 K3 淘汰策略:淘汰最低频率 访问K2 → K2频率+1,移到Freq=2 缓存满 → 淘汰Freq=1中的最久未访问 三个哈希表协同工作 cache_: K1→(值, 频率) key_freq_: K1→频率 freq_list_: 频率→键列表

实战中的坑与优化

我踩过不少缓存的坑,分享几个印象深刻的:

  • 线程安全:STL容器不是线程安全的。高并发场景下,一定要加锁或用无锁数据结构。我习惯用std::shared_mutex做读写分离。
  • 内存管理:缓存项如果太大,要考虑内存上限。我曾经没做限制,结果缓存把堆内存吃光了,程序直接OOM。
  • 过期策略:纯LRU/LFU没有过期机制。实际项目中要配合TTL(生存时间),定期清理过期数据。
  • 缓存穿透:查询一个不存在的数据,每次都会穿透缓存打到数据库。可以用布隆过滤器做第一道防线。

我的建议:如果项目对性能要求极高,可以考虑用boost::intrusive::list替代std::list,减少内存分配开销。不过代码复杂度会上升,要权衡。

好了,关于LRU和LFU缓存,今天就聊到这里。这两种策略是缓存系统的基石,理解了它们,你就能应对大部分缓存场景。实际项目中,我通常还会结合TTL、预加载、异步刷新等机制,让缓存系统更健壮。

公众号:蓝海资料掘金营,微信deep3321