TL;DR
- 教科书 LRU = 哈希表 + 双向链表,
get/put都 O(1)。但把热元素挪到队首这个动作,链表在 n = 10⁴ 时最坏要搬 9999 个元素,双向链表固定写 6 个指针——差约 1667 倍,而且每次get都要写- 三条工程分叉:JDK 在哈希节点上就地挂
before/after指针,不新建链表;Redis 连链表都不建,用对象头里 24 bit 时钟 + 随机采样 + 淘汰池;InnoDB 保留链表但用 3/8 切出旧区,让全表扫描只污染旧区- Redis 的 24 bit 时钟分辨率是 1000 ms,绕回周期 2²⁴ 秒 = 194.18 天。闲置 200 天的 key,
estimateObjectIdleTime上报的 idle 只有 5.82 天——越久没被访问,反而越难被淘汰- 采样数
maxmemory-samples默认 5,单轮命中"最旧 0.1%"的概率只有 0.499%;提到 16 个是 1.588%,256 个是 22.598%。精度真正的来源是跨轮累积的淘汰池(容量 16),不是把单轮样本数堆大- InnoDB 全表扫描 1 万页后:纯 LRU 热点存活率 0.0%,两段式(旧区 3/8)62.5%。这个数字等于
1 − 3/8,且与扫描长度无关——扫描从 1000 页涨到 10 万页,存活率不动
一、内存扩了一倍,命中率没回来
某电商详情页缓存,Redis 单实例 maxmemory 8gb、策略 allkeys-lru。上线时热点命中率 96%,两个月后掉到 71%。值班同学的第一反应是容量不够,把内存扩到 16 GB,重启,命中率回来了三天,第四天又跌回 72%。
抓 INFO stats 看 evicted_keys,每秒在涨;再看业务侧日志,凌晨两点有一个定时任务全量拉取在售商品(约 12 万条 key)写进 Redis 做对账。这个任务跑完,白天热点命中率必然塌一轮。
数据摆出来是这样的:
| 现象 | 表面解释 | 实际机制 |
|---|---|---|
evicted_keys 每秒飙升 | 内存不够 | 对账任务把 12 万条冷数据塞进缓存,每条都是"刚刚写入"→ 在 LRU 眼里全是新数据 |
| 热点命中率从 96% 掉到 71% | 热点被淘汰了 | 全量写入把热点的"最近使用时间"挤成相对陈旧,采样时被优先选中 |
| 扩内存到 16 GB,三天后复发 | 容量还是不够 | 容量只推迟了触发时间;插入 12 万条冷数据这件事本身才是病因 |
allkeys-lru 改成 volatile-lru 没用 | 策略没选对 | 热点 key 也没设 TTL,volatile- 系列直接无候选可淘汰 |
这份表格里没有一个格子指向"内存不够"。问题的形状是:一个把"最近被访问过"当作"将来还会被访问"的算法,遇到一次性灌入的冷数据时,会把热数据误判成陈旧数据。
这篇回答三件事:
- 哈希表 + 双向链表到底解决了什么、代价是什么——为什么它的
get是"写操作" - Redis 与 JDK 为什么都放弃了教科书实现——24 bit 时钟、随机采样、淘汰池,以及
LinkedHashMap那一行++modCount的含义 - InnoDB 保留链表却把扫描的伤害压到零——3/8 这个比例从哪来,量化后能救回多少热点
二、教科书答案,以及它没说的三件事
2.1 先看它解决了什么
缓存淘汰的接口只有两个语义:读(get)要标记"这个 key 刚被用过",写(put)要在超容量时挑一个"最久没用过"的删掉。
哈希表能 O(1) 定位,但答不出"谁最久没用过";双向链表能 O(1) 插删,但答不出"某个 key 在哪"。把两者叠起来:
- 哈希表:
key → 链表节点指针,负责 O(1) 定位 - 双向链表:按访问新鲜度排序,队首最新、队尾最旧,负责 O(1) 维护顺序
get 命中时,从哈希表拿到节点指针,把它从当前位置摘下来挂到队首;put 新 key 时,同样挂队首,同时看容量是否超限,超了就删队尾。全部 O(1)。
2.2 "把节点挪到队首"这个动作,代价被讲少了一半
链表实现的代价,教科书通常只说"O(1)"。把常数摊开看。假设链表长度为 n,要挪的元素恰好在队尾(最坏情形):
- 数组实现:元素存储在连续内存里,"挪到队首"意味着它前面的 n−1 个元素要整体后移一格。n = 10⁴ 时是 9999 次元素搬移。
- 双向链表实现:设 head / tail 两个哨兵节点,被挪节点为
cur,则只有 6 次指针写:
prev->next = cur->next; // ① 前驱跳过 cur
cur->next->prev = prev; // ② 后继回指前驱
cur->prev = tail; // ③ cur 的前驱指向旧尾
cur->next = NULL; // ④ cur 的后继置空
tail->next = cur; // ⑤ 旧尾的后继指向 cur
tail = cur; // ⑥ 更新尾哨兵
9999 : 6 ≈ 1667。这是同一个逻辑动作在两种布局下的写操作次数比,跟"O(1) vs O(n)"说的是同一件事,但只有摊开成具体数字,才知道差距落在什么量级。

更要紧的一点:这个动作发生在 get 里。缓存系统的读远多于写,所以链表实现把"每次读"变成了"一次哈希查找 + 一次链表写"。读多写少的结构里,把写塞进读路径,是最容易在压测里才暴露的那类设计代价。
2.3 哨兵节点省掉的是分支,不是内存
上面 6 次指针写之所以能写成无分支的直线代码,靠的是两个哨兵节点。没有哨兵时,"挪到队首"要先判断三件事:是不是队首、是不是队尾、链表是否为空——每种情况走一条不同的分支。
哨兵的代价是两个不存业务数据的节点(16 字节量级)。在某些嵌入式缓存实现里,如果缓存容量下界只有几个元素,两个哨兵的占比就不划算,此时用"环形链表 + 单指针"或"数组手写游标"更省。
三、JDK 的答案:不新建链表,把指针挂在哈希节点上
3.1 LinkedHashMap 选择了侵入式改造
Java 标准库要提供 LRU,LinkedHashMap 的做法不是"一个 HashMap + 一个 LinkedList",而是直接改哈希节点结构:给每个 Entry 加 before / after 两个字段,让哈希桶里的节点同时串成一条双向链表。
这样做的收益是省掉一份指针表和一次额外分配。代价是 Entry 结构体变大(多两个引用字段),且链表关系与哈希结构强耦合——这也是为什么这套机制只在 LinkedHashMap 里能用,HashMap 本身不提供。
关键开关是构造参数 accessOrder:
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
accessOrder = false(默认)时按插入顺序迭代,true 时按访问顺序——后者才具备 LRU 语义。
3.2 afterNodeAccess:get 为什么被改写了
LinkedHashMap 重写了 get,在返回之前插入了一次顺序调整:
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e,
b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
逐个分支拆开看设计意图:
accessOrder && (last = tail) != e——两个短路条件。若accessOrder为假,整个方法体只花一次判断;若被访问元素本来就是队尾(刚被访问过),直接返回,不用做任何指针操作。热点 key 连续命中时,这里几乎是空转。p.after = null先摘出p,再分四种情况缝合:p是队首(b == null)、p是队尾(a == null)、p在中间、以及链表只有一个元素。这段的分支数量就是哨兵不存在的代价。tail = p更新尾指针。++modCount——这一行是全文最值得记住的一行,下面单独说。
3.3 ++modCount:一个写在 get 里的自增
modCount 是 java.util 集合的结构性修改计数器,迭代器的 fail-fast 靠它:迭代开始时记下 expectedModCount,每次 next() 比对,不一致就抛 ConcurrentModificationException。
afterNodeAccess 里的 ++modCount 意味着:在 accessOrder = true 的 LinkedHashMap 上,一次纯读的 get 也被登记为结构性修改。 于是这段代码会抛异常:
Map<String, String> cache = new LinkedHashMap<>(16, 0.75f, true);
cache.put("a", "1"); cache.put("b", "2"); cache.put("c", "3");
for (String k : cache.keySet()) {
cache.get("a"); // 触发 afterNodeAccess → ++modCount
// 下一次 next() 时 expectedModCount 不一致 → ConcurrentModificationException
}
这不是 bug,是接口契约的诚实表达:accessOrder = true 时,get 确实会改变迭代顺序。它带来的两个实战约束:
- 不能在遍历中做 LRU 读。要遍历就用
entrySet()先快照,或改用ConcurrentHashMap+ 显式链表。 LinkedHashMap全程非线程安全。get会写tail、modCount、以及被挪节点的before/after,并发get能直接把链表指针改坏(丢节点或成环)。要并发就得外层加锁,或换 Caffeine / Guava 这类分段实现。
一句话概括 JDK 的选择:把链表指针侵入到哈希节点里省内存,代价是读操作有写副作用,必须自己扛并发。
四、Redis 的答案:连链表都不建
4.1 一个 redisObject 里 24 bit 的位置
Redis 要维护几千万个 key 的访问时间。给每个 key 挂双向链表指针,内存开销按对象数量线性增长,Redis 不愿付这笔账。它的方案是把访问时间塞进对象头:
#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* 16777215 */
#define LRU_CLOCK_RESOLUTION 1000 /* ms */
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time or LFU data */
int refcount;
void *ptr;
};
lru 字段 24 位。全局时钟函数每秒走一格:
unsigned int LRU_CLOCK(void) {
return mstime()/LRU_CLOCK_RESOLUTION & LRU_CLOCK_MAX;
}
三个常数一起决定了这套设计的精度边界:
| 常数 | 值 | 后果 |
|---|---|---|
| 位宽 | 24 bit | 最大值 16777215,只能表示到一个上限就绕回 |
| 分辨率 | 1000 ms | 同一秒内的多次访问完全不可区分 |
| 绕回周期 | 2²⁴ 秒 | 194.18 天 |
分辨率这一条最容易被忽略。1000 ms 意味着:一个 key 在 09:00:00.100 被访问,另一个在 09:00:00.900 被访问,两者记下的时间戳完全相同。想知道"这两个 key 谁更新",24 位时钟答不出来。这是拿时间精度换内存的明码标价。
4.2 194.18 天绕回:越旧的 key 反而越难被淘汰
estimateObjectIdleTime 负责把时间戳换算成"闲置了多久":
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}
两个分支处理的是"全局时钟还在对象时间戳之后"与"已经绕回"两种情况。else 分支的算式看起来是把绕回的差值补回来,但因为全程在 24 位模空间里做减法,它算出的结果仍然是真实 idle 对 2²⁴ 取模后的值。
穷举验证(tools/verify-lru-invariants.js,2.5 亿组取值,覆盖真实 idle 从 1 秒到 3 个绕回周期):
- 上报值从不等于真实 idle 的累加值(只要真实 idle 超过一个周期)
- 上报值与
(真实 idle mod 2²⁴)的偏差只可能是 0 或 −1 个 tick(少算 1 tick 占 70.0%,属该分支的固有取整行为,不是随机误差) 真实 idle = 200 天 → 上报 5.82 天

最后这一条的工程后果很直接:一个闲置了 200 天的冷 key,在 LRU 眼里"只闲置了 5.82 天",比一个闲置 7 天的 key 还新。 采样淘汰时它会被排到后面,长期占着内存。
绕回在真实业务里的命中条件是"key 存在超过 194 天且长期无访问"。真正常见的场景是永久有效的配置类 key、白名单、字典表——它们本来就该长期驻留,所以这个问题在实践中极少被抓到,但它确实存在,Redis 官方文档也明确写了 LRU_CLOCK_MAX 的语义。
4.3 单轮采样能命中多旧?用组合数算出来
既然不建链表,淘汰时就得随机抽样。evictionPoolPopulate 每次从字典里随机取 server.maxmemory_samples 个 key:
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict,
struct evictionPoolEntry *pool) {
int j, k, count;
dictEntry *samples[server.maxmemory_samples];
count = dictGetSomeKeys(sampledict, samples, server.maxmemory_samples);
for (j = 0; j < count; j++) {
/* ... 按策略计算 idle ... */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
idle = estimateObjectIdleTime(o);
} else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
idle = 255 - LFUDecrAndReturn(o);
}
/* 按 idle 插入有序池 */
}
}
maxmemory-samples 默认值在 redis.conf 里写得很直白:
# By default Redis will check five keys and pick the one that was
# used least recently, you can change the sample size using the following
# configuration directive.
#
# The default of 5 produces good enough results. 10 Approximates very closely
# true LRU but costs more CPU. 3 is faster but not very accurate.
#
# maxmemory-samples 5
"近似得多准"这个问题可以算。设 key 总数 N,全库最旧的 K 个 key 里任意一个被抽中的概率(每轮抽 s 个、无放回):
$$P = 1 - \frac{\binom{N-K}{s}}{\binom{N}{s}}$$
代入 N = 10⁶、s = 5(默认值):
| 目标 | 单轮命中概率 |
|---|---|
| 命中"全局唯一最旧"(K = 1) | 0.00050% |
| 命中最旧 0.1%(K = 1000) | 0.499% |
| 命中最旧 1%(K = 10000) | 4.90% |
换采样数看收益(N = 10⁶,K = 1000):
maxmemory-samples | 单轮命中最旧 0.1% 的概率 |
|---|---|
| 1 | 0.100% |
| 5(默认) | 0.499% |
| 10 | 0.996% |
| 16 | 1.588% |
| 64 | 6.203% |
| 256 | 22.598% |
把单轮概率从 0.5% 提到 1.6%,采样数要翻 3 倍;提到 22.6%,要翻 51 倍。靠堆样本数换精度,收益是线性的而开销是线性的——这条路走到头也到不了"精确 LRU"。

4.4 淘汰池:精度真正的来源
Redis 的解法不是加大单轮样本,而是让候选跨轮存活。evictionPoolPopulate 把每轮采样到的候选按 idle 插入一个固定容量 16 的有序池:
#define EVPOOL_SIZE 16
#define EVPOOL_CACHED_SDS_SIZE 255
struct evictionPoolEntry {
unsigned long long idle; /* Object idle time */
sds key;
sds cached;
int dbid;
};
池的插入逻辑在一处细节上很讲究:候选按 idle 升序排列,新候选如果比池中最差的那个还差(k == 0 且池已满),直接丢弃;否则插入并挤掉最差的一个。
淘汰主循环 performEvictions 每轮做两件事:从池尾(idle 最大的位置)取一个淘汰,然后腾出的空位留给下一轮采样填充。
这样,虽然单轮只看 5 个样本,但每轮淘汰后池里会残留 15 个上一轮的较优候选参与下一轮比较。轮数一多,池里聚集的是整个过程中见过的"最陈旧的一批",而不是每轮重来的 5 个随机数。
| 机制 | 单轮参与比较的候选数 | 跨轮记忆 |
|---|---|---|
| 只有采样 | s = 5 | 无 |
| 采样 + 淘汰池 | 池内残留(≤16) + 本轮采样 = ≤21 | 有,上限 16 |
池的容量 16 是权衡点:再大,memmove 移动候选的成本和内存占用上升;再小,跨轮积累的效果不够。候选 key 名字的复制也做了优化——EVPOOL_CACHED_SDS_SIZE = 255 以内的 key 名直接复用池内预分配的 cached SDS,避免每轮 sdsdup 分配释放。
五、LFU:同一份 24 bit 的第二种用法
5.1 位域拆成两半
Redis 4.0 引入 LFU 时,没有给对象加字段,而是把原来那 24 位拆开复用:
| 位置 | 位宽 | 含义 |
|---|---|---|
| 高 16 位 | 16 bit | ldt(Last Decrement Time),上次衰减时间,分钟级 |
| 低 8 位 | 8 bit | logc(Logarithmic Counter),访问频率计数 |
o->lru >> 8 取 ldt,o->lru & 255 取 logc——这也解释了为什么 LFUDecrAndReturn 里写的是 255 而不是 (1<<8)-1:
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8;
unsigned long counter = o->lru & 255;
unsigned long num_periods = server.lfu_decay_time ?
LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
注意 num_periods 是按真实经过时间算出来的周期数,再一次性从 counter 里减掉。它不是"每隔一分钟去扫一遍所有 key 减 1"——衰减是惰性的、访问时才计算的。这条设计消掉了后台扫描整库的成本,代价是同一时刻只有被访问到的 key 才会更新计数。
代价是计数器只有 8 位,上限 255。要在一个 8 位空间里表达"从 1 次访问到 100 万次访问"的差异,不能线性加。
5.2 LFULogIncr:概率递增与 LFU_INIT_VAL
#define LFU_INIT_VAL 5
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}
三个细节:
- 递增是概率性的。
counter不每次加 1,而是以概率p加 1。p随counter增大而减小,这就把线性增长压成了对数增长。 baseval减法保证新键有起步优势。counter ≤ LFU_INIT_VAL时baseval = 0→p = 1,此时每次访问必然加 1。新键从 5 起步而不是 0,前几次访问能快速拉开与"从未被访问"的差距,避免刚写入就被淘汰。lfu_log_factor控制区分度。p = 1/(baseval × factor + 1),factor 越大,涨得越慢。
因为 p 只依赖当前 counter,这个过程是马尔可夫链,抵达某个 counter 值的期望访问次数可以精确递推(E[到达 c+1] − E[到达 c] = 1/p(c) = baseval × factor + 1):
| 访问次数 | counter 值(factor = 10) | 官方表对照 |
|---|---|---|
| 100 | 9 | 10 |
| 1,000 | 19 | 18 |
| 100,000 | 146 | 142 |
| 1,000,000 | 255(饱和) | 255 |
递推值与 redis.conf 注释里那张经验表(100 → 10、1000 → 18、100K → 142、1M → 255)整体吻合,差值在 4 以内。差异来自两处:期望值描述的是"平均需要多少次",官方表统计的是实际运行的采样结果;另外 rand() 是伪随机数,短周期内分布本身有偏。
lfu_log_factor 的影响(同样递推,抵达 counter = 142 所需期望访问次数):
lfu_log_factor | 抵达 counter = 142 的期望访问次数 |
|---|---|
| 0 | 137 |
| 1 | 9,453 |
| 10(默认) | 93,297 |
| 100 | 931,737 |
factor = 0 时每次访问必涨,counter 几乎等价于线性计数,8 位会在 250 次访问后就饱和——对高频 key 完全失去区分度。factor = 100 时涨得太慢,要 93 万次访问才能到 142,低频 key 之间也拉不开差距。默认值 10 是在"8 位空间的动态范围"和"区分敏感度"之间取的折中。
衰减侧由 lfu-decay-time(默认 1 分钟)控制。它解决的是"一个 key 十年前火过就一直占着内存":counter 会随闲置时间按周期递减,从 255 掉到 0 只是时间问题。
六、InnoDB 的答案:把链表切成两段
6.1 3/8 与 innodb_old_blocks_pct
InnoDB 的 buffer pool 保留了完整的 LRU 链表,但插入点不在队首,而在中点:
When InnoDB reads a page into the buffer pool, it initially inserts it at the midpoint (the head of the old sublist).
这条"中点插入"(midpoint insertion)把链表分成两段:
| 段 | 位置 | 含义 |
|---|---|---|
| new / young sublist | 队首一侧 | 最近被访问过的页 |
| old sublist | 队尾一侧 | 访问不频繁、淘汰候选 |
默认比例是 3/8 归 old sublist——对应配置项 innodb_old_blocks_pct 的默认值 37。
关键机制在于晋升规则:页被读入时先落在 old 段头部。如果它是被用户请求(如一条 SQL)读进来的,第一次访问立即把它移入 young 段头部;但如果它只是被全表扫描顺带读进来,在 innodb_old_blocks_time(默认 1000 ms)窗口内的访问不会让它晋升。
于是全表扫描的伤害被限制在 old 段内部:扫描页在 old 里进进出出,挤掉的也是 old 里的其它扫描页,young 段里的热点页一动不动。
6.2 量化:扫描之后热点还剩多少
tools/verify-lru-invariants.js 里用确定性全量模拟(无随机数,任何人复跑结果一致)验证这个机制。设定容量 1000 页、热点集 1000 页、扫描 10000 页:
| 实现 | 扫描后热点存活率 |
|---|---|
| 纯 LRU(插入即置队首) | 0.0% |
| 两段式(old 段占 3/8) | 62.5% |
62.5% 就是 1 − 3/8。
这个结论可以推广。old 段比例记作 f,则 young 段容量占比为 1 − f,扫描页从不进入 young 段,所以热点存活率恒等于 1 − f:
| f(old 段比例) | 热点存活率(模拟值) | 理论值 1 − f |
|---|---|---|
| 0 | 100.0% | 100.0% |
| 0.1 | 90.0% | 90.0% |
| 0.25 | 75.0% | 75.0% |
| 0.375(默认 37%) | 62.5% | 62.5% |
| 0.5 | 50.0% | 50.0% |
| 0.75 | 25.0% | 25.0% |
| 0.9 | 10.0% | 10.0% |
更值得记住的是这个数字与扫描长度无关。把扫描页数从 1000 拉到 100000(跨两个数量级):
| 扫描页数 | 热点存活率 |
|---|---|
| 100 | 90.0% |
| 375 | 62.5% |
| 1,000 | 62.5% |
| 10,000 | 62.5% |
| 100,000 | 62.5% |
扫描页数一旦超过 old 段容量,存活率就稳定在 62.5% 不动。InnoDB 不需要限制扫描的大小,它只需要保证扫描页不晋升。 这是两段式 LRU 与"纯 LRU + 各种限流补丁"的本质区别:前者从机制上切断传播路径,后者只能减缓传播速度。

反过来,f 取值也不是越大越好:f = 0.9 时虽然扫描抵抗性最强(只损失 10% 热点),但 young 段只剩 10% 空间,正常业务的页淘汰压力全压在这 10% 上,命中率会塌。innodb_old_blocks_pct 的取值范围是 5–95,默认 37 是在两种失效模式之间取的平衡点。
七、决策表:四种实现怎么选
| 场景 | 选择 | 理由 | 代价 |
|---|---|---|---|
| 单机、单线程、容量 ≤ 10 万 | LinkedHashMap(accessOrder=true) + 重写 removeEldestEntry | 标准库自带,零依赖;get 的指针写成本可忽略 | 非线程安全;遍历中 get 抛 ConcurrentModificationException |
| 并发读为主、容量百万级 | Caffeine / Guava Cache | 分段 + 无锁读路径;W-TinyLFU 抗扫描能力优于纯 LRU | 引入第三方依赖;maximumSize 与 expireAfter 组合语义需实测 |
| key 数千万、内存敏感 | Redis allkeys-lru | 每个 key 只多 24 bit;无链表、无指针表 | 近似淘汰;1000 ms 分辨率;194.18 天绕回 |
| 有稳定热点集、怕突发扫描 | Redis allkeys-lfu | 频率维度抗"一次性灌入",LFU_INIT_VAL 保护新键 | counter 仅 8 位,饱和后不再区分;需设 lfu-decay-time |
| 数据库页缓存、有全表扫描 | InnoDB 两段式 LRU(保持默认 37) | young 段对扫描免疫,热点存活率 = 1 − f | young 段越小,正常业务命中率越低 |
| 需要严格精确 LRU | 显式链表 + 每访问加锁 | 唯一能给出精确顺序的方案 | 读路径带锁写入;高并发下锁竞争成为瓶颈 |
一条判据:如果你的系统里存在"一次性批量灌入大量只在短时间内被访问的数据",就不要用纯 LRU。 换 LFU、换两段式,或者把这类写入绕开缓存——比扩内存有效。
八、速答卡与三句心法
8.1 模板:手写一个线程安全的 LRU
class LRUCache<K, V> {
private final int capacity;
private final Map<K, Node<K, V>> index; // 哈希表:key → 节点
private final Node<K, V> head, tail; // 两个哨兵
private final ReentrantLock lock = new ReentrantLock();
LRUCache(int capacity) {
this.capacity = capacity;
this.index = new HashMap<>(capacity * 2);
head = new Node<>(null, null);
tail = new Node<>(null, null);
head.next = tail;
tail.prev = head;
}
public V get(K key) {
lock.lock();
try {
Node<K, V> n = index.get(key);
if (n == null) return null;
unlink(n); // 从当前位置摘出
pushFront(n); // 挂到队首
return n.value;
} finally { lock.unlock(); }
}
public void put(K key, V value) {
lock.lock();
try {
Node<K, V> n = index.get(key);
if (n != null) { n.value = value; unlink(n); pushFront(n); return; }
if (index.size() == capacity) {
Node<K, V> victim = tail.prev; // 队尾前一个 = 最旧
unlink(victim);
index.remove(victim.key);
}
Node<K, V> node = new Node<>(key, value);
index.put(key, node);
pushFront(node);
} finally { lock.unlock(); }
}
private void unlink(Node<K, V> n) {
n.prev.next = n.next;
n.next.prev = n.prev;
}
private void pushFront(Node<K, V> n) {
n.next = head.next;
n.prev = head;
head.next.prev = n;
head.next = n;
}
static class Node<K, V> {
K key; V value; Node<K, V> prev, next;
Node(K k, V v) { key = k; value = v; }
}
}
三个容易写错的点:unlink 与 pushFront 必须分开(合并写会在"节点已在队首"时把指针改坏);put 更新已有 key 时也要 pushFront;淘汰时先 unlink 再 index.remove,顺序反了会读到已删节点。
8.2 速答卡(8 题)
| 问题 | 要点 |
|---|---|
| LRU 为什么需要哈希表 + 双向链表两个结构? | 哈希表答"key 在哪",链表答"谁最旧"。缺一个就有一个操作退化成 O(n) |
为什么链表实现的 get 是写操作? | 命中要把节点挪到队首,改 6 处指针。读多写少的场景下这是实打实的写放大 |
| 哨兵节点解决什么问题? | 消掉"是否队首 / 是否队尾 / 是否空表"三类分支,让指针操作成为无分支直线代码 |
LinkedHashMap 实现 LRU 的两个必要条件? | 构造时 accessOrder = true;子类重写 removeEldestEntry 返回 true |
为什么 accessOrder = true 时遍历中 get 会抛异常? | afterNodeAccess 里有 ++modCount,迭代器 fail-fast 检测到结构性修改 |
| Redis 为什么不用精确 LRU? | 精确 LRU 要给每个 key 维护链表节点,内存开销随 key 数线性增长;24 bit 时钟是常数开销 |
maxmemory-samples 调大能接近精确 LRU 吗? | 单轮概率随样本数线性增长(5 → 0.499%,256 → 22.598%),靠调参到不了精确。精度来自跨轮累积的淘汰池(容量 16) |
LFU 的 LFU_INIT_VAL = 5 是干什么的? | counter ≤ 5 时递增概率为 1,让新键快速拉开与"从未访问"的差距,避免刚写入就被淘汰 |
8.3 三句心法
- 哈希表答"在哪",链表答"多旧";LRU 的复杂度由这两个答案的维护成本决定。
- 工业实现都不追求精确 LRU,因为"精确"要为每个 key 付常数内存,而近似方案用采样 + 池 + 分段把误差压到可接受。
- 缓存失效的根因通常不是容量,而是"一次性灌入的冷数据"改写了新鲜度排序——治法是隔离,不是扩内存。
思考题
- 把
maxmemory-samples从 5 调到 10,单轮命中"最旧 0.1%"的概率从 0.499% 涨到 0.996%(约 2 倍)。按这个趋势,调到 500(100 倍)能不能把概率提到接近 100%?用上面的组合数公式算一下s = 500时是多少,再判断这条路有没有尽头。 - Redis 的
lru字段分辨率是 1000 ms。如果业务要求"同一秒内的访问也要排出先后",24 位够不够?如果改成 1 ms 分辨率且维持 194 天周期,需要多少位? - InnoDB 的 young 段占 5/8。按 §6.2 的公式,热点存活率应该是 62.5% 还是别的数?为什么这个数在扫描页数从 1000 涨到 100000 时不变——增长的是哪一部分?
475



被折叠的 条评论
为什么被折叠?



