39、设计原则在缓存系统中的应用:LRU/LFU缓存设计,装饰器模式实现缓存统计
缓存系统,说白了就是拿空间换时间。你想想看,一个高频访问的数据,每次都要去数据库或者远程服务拉一遍,那延迟和压力都受不了。我早年做游戏后端时,一个排行榜接口每秒被调用上千次,每次都要从MySQL里算一遍排名——结果可想而知,数据库直接被打挂了。
后来我引入了缓存层,问题才解决。但缓存也不是随便写个map就完事的,你得考虑淘汰策略、统计监控、扩展性。今天我们就聊聊,怎么用设计原则来指导缓存系统的设计。
缓存淘汰策略:LRU与LFU
先说说两种最常见的淘汰策略。
LRU(Least Recently Used):淘汰最久没被访问的数据。它的核心假设是——如果一个数据最近被访问过,那么未来被访问的概率也高。
LFU(Least Frequently Used):淘汰访问频率最低的数据。它的核心假设是——访问次数多的数据,未来更可能被访问。
我在项目中遇到过一个问题:用LRU缓存一个新闻列表,结果热点新闻一过,那些旧新闻还占着缓存位置,因为它们在刚发布时被访问过,后来就没人看了。换成LFU之后,低频旧数据很快就被淘汰了。
核心区别:LRU关注“时间局部性”,LFU关注“频率局部性”。没有绝对的好坏,取决于业务场景。
LRU缓存的设计
LRU的经典实现是“哈希表+双向链表”。哈希表保证O(1)查找,双向链表保证O(1)移动和删除。
class LRUCache {
private:
struct Node {
int key, value;
Node *prev, *next;
Node(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {}
};
int capacity;
Node *head, *tail; // 虚拟头尾节点
unordered_map<int, Node*> cache;
void removeNode(Node* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
void addToHead(Node* node) {
node->next = head->next;
node->prev = head;
head->next->prev = node;
head->next = node;
}
void moveToHead(Node* node) {
removeNode(node);
addToHead(node);
}
Node* removeTail() {
Node* node = tail->prev;
removeNode(node);
return node;
}
public:
LRUCache(int cap) : capacity(cap) {
head = new Node(-1, -1);
tail = new Node(-1, -1);
head->next = tail;
tail->prev = head;
}
int get(int key) {
if (!cache.count(key)) return -1;
Node* node = cache[key];
moveToHead(node);
return node->value;
}
void put(int key, int value) {
if (cache.count(key)) {
Node* node = cache[key];
node->value = value;
moveToHead(node);
} else {
if (cache.size() >= capacity) {
Node* removed = removeTail();
cache.erase(removed->key);
delete removed;
}
Node* node = new Node(key, value);
cache[key] = node;
addToHead(node);
}
}
};
个人习惯:我写LRU时喜欢用虚拟头尾节点,这样能省去大量判空逻辑。你试试看,代码会清爽很多。
LFU缓存的设计
LFU比LRU复杂一些。你需要维护每个数据的访问频率,并且当缓存满时,淘汰频率最低的那个。如果多个数据频率相同,还得淘汰最久没用的那个——说白了就是LRU的变种。
我常用的实现是“频率到桶的映射”,每个桶里放一个双向链表,链表里放相同频率的节点。
class LFUCache {
private:
struct Node {
int key, value, freq;
Node *prev, *next;
Node(int k, int v) : key(k), value(v), freq(1), prev(nullptr), next(nullptr) {}
};
struct FreqList {
Node *head, *tail;
FreqList() {
head = new Node(-1, -1);
tail = new Node(-1, -1);
head->next = tail;
tail->prev = head;
}
void remove(Node* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
void addToHead(Node* node) {
node->next = head->next;
node->prev = head;
head->next->prev = node;
head->next = node;
}
bool empty() { return head->next == tail; }
Node* removeTail() {
Node* node = tail->prev;
remove(node);
return node;
}
};
int capacity, minFreq;
unordered_map<int, Node*> cache;
unordered_map<int, FreqList*> freqMap;
void increaseFreq(Node* node) {
int oldFreq = node->freq;
freqMap[oldFreq]->remove(node);
if (freqMap[oldFreq]->empty() && oldFreq == minFreq) {
minFreq++;
}
node->freq++;
if (!freqMap.count(node->freq)) {
freqMap[node->freq] = new FreqList();
}
freqMap[node->freq]->addToHead(node);
}
public:
LFUCache(int cap) : capacity(cap), minFreq(0) {}
int get(int key) {
if (!cache.count(key)) return -1;
Node* node = cache[key];
increaseFreq(node);
return node->value;
}
void put(int key, int value) {
if (capacity == 0) return;
if (cache.count(key)) {
Node* node = cache[key];
node->value = value;
increaseFreq(node);
} else {
if (cache.size() >= capacity) {
Node* removed = freqMap[minFreq]->removeTail();
cache.erase(removed->key);
delete removed;
}
Node* node = new Node(key, value);
cache[key] = node;
minFreq = 1;
if (!freqMap.count(1)) {
freqMap[1] = new FreqList();
}
freqMap[1]->addToHead(node);
}
}
};
我曾经踩过的坑:LFU里minFreq的维护很容易出错。当你删除一个节点后,如果那个频率的链表空了,而minFreq正好等于那个频率,你得更新minFreq。我一开始忘了这个,结果缓存满了之后永远淘汰不了数据,内存直接爆了。
装饰器模式:给缓存加统计
现在缓存的核心功能有了。但产品经理说:“能不能加个统计?我想看缓存命中率、访问次数、淘汰次数。”
你当然可以直接在LRUCache和LFUCache里加统计代码。但这样违反了开闭原则——对扩展开放,对修改关闭。每次加新功能都要改核心类,代码会越来越臃肿。
这时候装饰器模式就派上用场了。它允许你动态地给对象添加职责,而不修改原有代码。
// 缓存接口
class Cache {
public:
virtual ~Cache() = default;
virtual int get(int key) = 0;
virtual void put(int key, int value) = 0;
};
// 统计装饰器
class CacheStatsDecorator : public Cache {
private:
Cache* wrapped;
int hitCount;
int missCount;
int evictCount;
public:
CacheStatsDecorator(Cache* cache)
: wrapped(cache), hitCount(0), missCount(0), evictCount(0) {}
int get(int key) override {
int result = wrapped->get(key);
if (result != -1) {
hitCount++;
} else {
missCount++;
}
return result;
}
void put(int key, int value) override {
int oldSize = getSize(); // 假设有这个方法
wrapped->put(key, value);
if (getSize() <= oldSize) {
evictCount++;
}
}
double getHitRate() {
int total = hitCount + missCount;
return total == 0 ? 0.0 : (double)hitCount / total;
}
void printStats() {
std::cout << "命中次数: " << hitCount << "\n"
<< "未命中次数: " << missCount << "\n"
<< "淘汰次数: " << evictCount << "\n"
<< "命中率: " << getHitRate() << "\n";
}
};
使用起来也很简单:
Cache* lru = new LRUCache(100);
Cache* statsLru = new CacheStatsDecorator(lru);
statsLru->put(1, 10);
statsLru->get(1); // 命中
statsLru->get(2); // 未命中
// 输出统计
dynamic_cast<CacheStatsDecorator*>(statsLru)->printStats();
我建议:装饰器模式特别适合这种场景——你想给现有类加功能,但又不想改它的代码。而且你可以叠加多个装饰器,比如再加一个“日志装饰器”、“延迟监控装饰器”,每个装饰器只关注一件事,符合单一职责原则。
知识体系结构图
总结
今天的内容其实就三个要点:
- LRU和LFU是两种经典的缓存淘汰策略,各有适用场景。LRU实现简单,适合时间局部性强的场景;LFU更精准,但实现复杂一些。
- 装饰器模式让我们在不修改核心类的前提下,给缓存加上统计、日志等额外功能。这符合开闭原则。
- 设计原则不是纸上谈兵。你看,单一职责让每个类只做一件事,开闭原则让系统易于扩展——这些原则在缓存系统里体现得淋漓尽致。
嗯,缓存系统看起来简单,但真要设计好,里面门道不少。下次你再写缓存的时候,不妨想想今天聊的这些原则,说不定能帮你少踩几个坑。