一天夜里
凌晨三点,告警铃声刺耳的响起,运维群上弹出Redis容量上限警告——使用率已达75%,即将触顶。
还在苦苦给需求debug的oncall同学小王揉了揉惺忪的双眼,看到这条告警,心里一紧:”糟了,缓存要爆了!”。二话不说,熟练的打开运维平台,啪嚓啪擦一顿操作,把redis给扩容了一倍。
看着告警终于消停下来,小王长舒一口气:”搞定,收工!”
殊不知扩容带来的翻倍成本领导都看在眼里,在第二周复盘的时候,小王被领导叫去”喝茶”…
扩容只是拖延,内存很昂贵
在内存条涨价500%的2025,只能祈祷它不会在这几年坏掉。。。

在刚刚的场景中,redis快要到上限,如果选择扩容,结果就是加了一大堆内存。等到高峰期过了,使用率又尴尬的低。
容量只是表象,如果扩容频繁,说明缓存的使用姿势有问题,无脑的扩容带来了成本的增加,最为致命的是掩盖了真正的问题。
既然在业务中使用redis作为缓存,就是希望能用”空间换时间”,所以高命中率是相当重要的。同时我们希望既然redis集群有这么多内存,就将每一byte的空间都尽量的利用上。
但现实往往是骨感的——很多业务由于历史原因或者设计不当,缓存里堆砌了大量”冷数据”,占着茅坑不拉屎。这些数据要么是历史遗留的脏数据,要么是被一次性查询后再也无人问津的”僵尸key”。它们就像你家里那些买回来只用了一次的家电,占地方,吃灰没商量。更要命的是,这些冷数据会把热点数据挤出去,导致缓存命中率下降,间接逼得你继续扩容——形成恶性循环。
所以真正的问题不是”缓存快满了要扩容”,而是”缓存里到底装了什么有用的东西”。与其傻傻的加内存,不如先看看能不能把那些占着空间却没人用的数据清理掉,让真正热的数据有容身之处。
内存淘汰算法
LRU,忘记最久的先走
LRU(Least Recently Used,最近最少使用)是一种经典的缓存淘汰策略。它的核心思想是:最近被访问过的数据,在未来也更可能被访问。因此,当缓存空间不足时,应当优先淘汰那些最久未被访问的数据。
从算法实现角度来看,LRU需要维护一个数据访问顺序的记录。最直观的做法是使用双向链表配合哈希表:每次访问数据时,将该数据移动到链表头部;当需要淘汰时,直接从链表尾部移除。这种实现方式的时间复杂度为O(1),但需要额外的内存来维护链表结构。
LRU的适用场景:适用于数据访问具有时间局部性的业务——即热点数据集中在最近一段时间内频繁访问的场景。例如:热点新闻列表、实时行情数据、用户会话信息等。这类场景的特点是”风水轮流转”,今天的热点可能明天就冷了。
LRU的局限性:如果某些冷门数据被意外访问一次,就会被标记为”最近访问”而逃脱淘汰,这可能导致真正的热点数据被挤出去。另外,对于扫描型操作(如全表查询),LRU可能会污染缓存。
// LRU 实现:双向链表 + 哈希表
// 时间复杂度:get/put 均为 O(1)
class LRUCache {
private int capacity; // 缓存容量
private Map<Integer, Node> cache = new HashMap<>(); // 哈希表:O(1) 查找
private Node head = new Node(); // 虚拟头节点(最近使用的在头部)
private Node tail = new Node(); // 虚拟尾节点(最久未使用的在尾部)
public LRUCache(int capacity) {
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
public int get(int key) {
if (!cache.containsKey(key)) return -1;
Node node = cache.get(key);
moveToHead(node); // 访问后移到头部(标记为最近使用)
return node.value;
}
public void put(int key, int value) {
if (cache.containsKey(key)) {
Node node = cache.get(key);
node.value = value;
moveToHead(node);
} else {
Node newNode = new Node(key, value);
cache.put(key, newNode);
addToHead(newNode); // 新节点加入头部
if (cache.size() > capacity) {
Node tail = removeTail(); // 超过容量,删除尾部(最久未使用)
cache.remove(tail.key);
}
}
}
// 将节点移动到链表头部
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
// 在头部添加节点
private void addToHead(Node node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
// 删除指定节点
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 删除尾部节点(最久未使用)
private Node removeTail() {
Node node = tail.prev;
removeNode(node);
return node;
}
// 双向链表节点
class Node {
int key, value;
Node prev, next;
Node(int k, int v) { key = k; value = v; }
Node() {}
}
}
LFU,来的最频的留下
LFU(Least Frequently Used,最不经常使用)与LRU不同,它关注的不是数据何时被访问,而是数据被访问的频次。LFU的核心假设是:被访问次数越多的数据,未来继续被访问的可能性越大。因此,淘汰时应当优先清除访问频率最低的数据。
从实现角度,LFU需要为每个数据项维护一个访问计数器。一种常见的实现方式是使用最小堆(Min Heap)配合哈希表:访问计数器最小的数据位于堆顶,淘汰时直接取出堆顶元素即可。维护堆的时间复杂度为O(log n)。
LFU的适用场景:适用于访问分布高度不均匀的业务——即存在明显的”热门”和”冷门”数据。例如:热门商品推荐、排行榜数据、长期占据热搜的话题等。这类场景的特点是”强者恒强”,头部数据的访问频次远超其他数据。
LFU的局限性:一是一旦某个数据曾经是热点,它的计数器就会累积,即使该数据已经不再被访问,也能在缓存中存活较长时间——这被称为”缓存污染”问题。二是需要为每个key维护计数器,增加了内存开销。三是在访问模式突然变化时(如热点更替),LFU的适应速度不如LRU。
LRU vs LFU的选择:如果你的业务特点是访问热点相对稳定、头部数据访问频次远高于其他数据,选择LFU会更高效;如果你的业务特点是访问热点快速变化、更强调”最近”的访问记录,选择LRU更为合适。
// LFU 实现:双哈希表 + 双向链表
// 时间复杂度:get/put 均为 O(1)
// 核心思想:用 freqMap 维护每个频次对应的节点链表,淘汰时从最小频次链表中移除
class LFUCache {
private int capacity; // 缓存容量
private int minFreq; // 当前最小访问频次
private Map<Integer, Node> cache = new HashMap<>(); // key -> 节点
private Map<Integer, LinkedHashSet<Node>> freqMap = new HashMap<>(); // freq -> 节点链表
public LFUCache(int capacity) {
this.capacity = capacity;
minFreq = 0;
}
public int get(int key) {
if (!cache.containsKey(key)) return -1;
Node node = cache.get(key);
updateFreq(node); // 更新访问频次
return node.value;
}
public void put(int key, int value) {
if (capacity == 0) return;
if (cache.containsKey(key)) {
Node node = cache.get(key);
node.value = value;
updateFreq(node);
} else {
// 缓存已满,删除最少使用的节点
if (cache.size() == capacity) {
LinkedHashSet<Node> minSet = freqMap.get(minFreq);
Node toRemove = minSet.iterator().next(); // 取出任意一个最小频次的节点
minSet.remove(toRemove);
cache.remove(toRemove.key);
}
// 创建新节点,初始频次为 1
Node newNode = new Node(key, value, 1);
cache.put(key, newNode);
freqMap.computeIfAbsent(1, k -> new LinkedHashSet<>()).add(newNode);
minFreq = 1; // 新插入的节点频次至少为 1
}
}
// 更新节点频次:从旧频次链表移除,加入新频次链表
private void updateFreq(Node node) {
int oldFreq = node.freq;
LinkedHashSet<Node> oldSet = freqMap.get(oldFreq);
oldSet.remove(node);
// 如果旧频次链表为空,移除该频次,并更新 minFreq
if (oldSet.isEmpty()) {
freqMap.remove(oldFreq);
if (minFreq == oldFreq) minFreq++;
}
// 频次 +1,加入新频次的链表
node.freq++;
freqMap.computeIfAbsent(node.freq, k -> new LinkedHashSet<>()).add(node);
}
// 节点:包含 key、value 和访问频次 freq
class Node {
int key, value, freq;
Node(int k, int v, int f) { key = k; value = v; freq = f; }
}
}
内存满了
先排查是不是大key的问题
遇到这类问题,可以先看看是不是大key造成的问题,再针对不合理的业务代码进行修改。
- 用redis自带的 –bigkey 参数可以扫描每种数据结构top1的大key,比如占用最大的string(不过复合结构类型如hash,只能返回包含元素最多的key,但是元素多不代表占用大)
// -i 可以指定扫描的间隔
redis-cli -p 6379 --bigkeys -i 3
- 使用Redis自带的
SCAN命令,SCAN命令可以返回匹配的key,然后再用strlenhlenllen等命令返回长度和成员数量等等。不过还是解决长度不代表大小的问题。
对于集合类型,还可以使用MEMORY USAGE(Redis 4.0+)命令,能返回键值对占用的内存空间。 - 使用
redis-rdb-tools分析工具
分析Redis RDB快照文件的开源工具。可以根据需求自定义分析Redis实例中所有key的内存占用情况。
找到大Key之后,千万别用 DEL 删除它!Redis主操作是单线程的,阻塞,监控告警会响爆的!
可以用 UNLINK 命令,将这个Key逻辑删除,之后再由后台线程进行回收。
如果Redis版本是老古董了,对于 hash、list、set、zset 这些数据类型可以分批删除。每次移除一部分数据,保持工作线程的可用。
配置合理的内存淘汰策略
刚刚提到了内存淘汰策略,其实redis是提供了一些淘汰策略的。开启redis的缓存淘汰策略,需要设置 maxmemory 参数来设置Redis的最大内存使用量,并通过 maxmemory-policy 选择合适的淘汰策略。
默认的淘汰策略是 noevicition,当内存达到 maxmemory 设置的限制时,拒绝写入新的数据。其他的淘汰策略,参考表格中的说明
| 淘汰策略 | 说明 | 场景 |
|---|---|---|
| noeviction | 默认的淘汰策略,拒绝写入新的数据 | 数据绝对不能丢失 |
| volatile-lru | (ttl only)优先淘汰最近最少使用的key。 | 适用于存放热点数据集群 |
| volatile-lfu | (ttl only)优先淘汰使用频率最低的key。 | 适用于访问频次差异大场景 |
| allkeys-lru | 对所有键,淘汰最近最少使用的key。 | 不关心是否自动过期, 适用于存放热点数据集群 |
| allkeys-lfu | 对所有键,淘汰使用频率最低的key。 | 不关心是否自动过期, 适用于访问频次差异大场景 |
参考
【Redis】—- Redis 默认缓存淘汰策略,和八种淘汰策略_redis 默认淘汰策略-CSDN博客
Redis缓存淘汰策略 – 欢乐豆123 – 博客园