std::list + std::unordered_map 不适合 lru-k,因其无法 o(1) 更新中间时间戳,每次 get() 需遍历找最老位置并重排,平均 o(k),高 qps 下吞吐降超 30%;而环形数组+单调 tick 可实现真 o(1) 第 k 次时间推导,配合热度桶分层采样与 k=2 的工程最优权衡,兼顾命中率与性能。

为什么 std::list + std::unordered_map 不能直接用于 LRU-K
它只适合 LRU-1。LRU-K 要求淘汰依据是「第 K 次访问时间」,不是最新一次——这意味着每次 get() 都得更新并维护一个长度为 K 的历史时间戳序列。用 std::list 节点存 std::vector<uint64_t></uint64_t> 或类似结构,会导致:
- 每次访问都要 shift 数组、重排索引,平均开销 O(K),K=4 就是 4 次 cache miss
- 无法 O(1) 定位“最老那次访问”位置,必须遍历找最小 tick,驱逐时还得扫全量 history_
- 节点内塞多个时间戳破坏内存局部性,
cache line利用率骤降 -
std::list::splice()只能挪整节点,没法只更新某次时间戳——误套用 LRU-1 模式会导致刚满 K 次的热数据比沉寂很久的冷数据更早被淘汰
怎么用环形数组实现真 O(1) 的第 K 次时间推导
核心是把「第 K 次访问时间」变成可计算值,不存全序历史。每个 key 对应一个固定大小的环形缓冲区:
- 用
std::array<uint64_t k></uint64_t>存最近 K 次访问的单调递增 tick(如++global_tick) - 配一个
size_t cursor表示下次写入位置,bool full标记是否填满过 -
get(key)时仅执行:access_ticks[cursor % K] = now_tick; ++cursor;,全程 O(1) - 第 K 次访问时间 =
access_ticks[(cursor - K + 1 + K) % K](注意:必须加 K 再取模,避免负数取模未定义行为) - tick 类型必须是
uint64_t,防止溢出;但环形索引必须用size_t,不可混用
淘汰时不扫全量,怎么用热度桶快速定位最冷 key
数据库缓冲池常驻数万页,每轮驱逐若遍历全部 history_,延迟毛刺明显。正确做法是分层采样:
- 计算每个 key 的「距今第 K 次访问时长」:
now_tick - kth_tick - 用位移快速散列到桶:
(now_tick - kth_tick) >> shift,shift取 6–8(对应 64–256ms 粒度) - 桶数量控制在 32–64 个以内,每个桶用
std::vector<key></key>存候选 key,不做排序 - 只扫描最冷的 1–2 个桶;若为空,向前合并相邻桶(防卡住)
- 每 100 次
put()后重建桶:把now_tick - kth_tick > 1s的 key 移到新桶,防冷数据滞留
K 值设多少才合理?别盲目堆高
K=2 已覆盖绝大多数偶发污染场景,再往上性价比断崖下跌:
- K=2:内存开销 +15%,命中率 +22%,QPS +18%
- K=3:开销 +30%,命中率 +25%,QPS +12%
- K=5:开销 +60%,命中率仅 +26%,QPS 反降 5%
- K>4 后环形数组写放大加剧,
cache line失效更频繁,且真实数据库(如 PostgreSQL)缓冲池几乎不用纯 LRU-K,因维护成本陡增 - 务必分离「访问记录」和「数据实体」:热页 buffer 和
LruKEntry必须解耦,否则驱逐时无法原子释放内存
真正难的不是写对 access_ticks[(cursor - K + 1 + K) % K],而是让环形数组在 CPU 缓存中连续布局、避免 false sharing,以及热度桶的散列粒度与业务访问周期匹配——这两点不调好,O(1) 更新也救不了整体延迟。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











