std::list不能直接用于带过期时间的LRU缓存,因其不存储时间戳且无法O(1)批量清理过期节点;需自定义含时间戳的节点结构,结合懒清理与定向剔除保障时效性与性能。

为什么 std::list 不能直接用于带过期时间的 LRU 缓存
因为 std::list 只维护访问顺序,不保存时间戳,也没法在 O(1) 时间内批量清理过期节点。你不能靠遍历判断每个节点是否超时——那会把 get() 和 put() 拉回 O(n),违背 LRU 的设计初衷。
真实场景中,过期检查必须和访问/插入解耦:不是每次操作都扫一遍链表,而是让过期清理变成「懒执行 + 定向剔除」。
- 每次
get()或put()时,只检查链表尾部(最久未用且可能过期)的若干节点,删到第一个未过期的为止 - 不强制立即清空所有过期项,避免毛刺;但保证缓存容量和时效性不持续劣化
- 时间戳必须存在节点内部(比如用
std::chrono::steady_clock::time_point),不能只存外部 map 里——否则链表节点移动时时间信息就断连了
如何在双向链表节点里安全嵌入时间戳
别用裸指针或手动 new,容易漏删时间字段;也别把 std::chrono::time_point 单独塞进 map —— 那样链表节点和时间戳是两张皮,erase() 时根本不知道该删哪个时间。
正确做法是定义一个聚合结构体,把 key、value、时间戳、前后指针全捆在一起:
立即学习“C++免费学习笔记(深入)”;
struct CacheNode {
int key;
int value;
std::chrono::steady_clock::time_point expire_at;
CacheNode* prev;
CacheNode* next;
CacheNode(int k, int v, auto tp) : key(k), value(v), expire_at(tp), prev(nullptr), next(nullptr) {}
};这样每次新建节点、移动节点、删除节点,时间戳都随节点生命周期自然管理,不会错位。
- 用
std::chrono::steady_clock,不用system_clock—— 系统时间可能被 NTP 调整,导致误判过期 - 过期时间建议用相对偏移计算:
auto now = std::chrono::steady_clock::now(); expire_at = now + std::chrono::seconds(30); - 注意
CacheNode析构时不需要显式释放prev/next指针——它们只是引用,真正内存由缓存类统一delete
get() 操作中怎么兼顾访问更新 + 过期驱逐
很多人只记得把节点移到头部,却忘了:这个节点本身可能已经过期。不能返回脏数据,也不能让它继续占着位置。
所以 get() 的逻辑必须是原子的「查 → 判 → 删(如过期)→ 返回(或返回空)」:
- 先从哈希表
key_to_node_找到对应CacheNode* - 检查
node->expire_at ,成立则立即从链表摘除、从 map 删除、<code>delete node,然后直接返回空值(比如 -1) - 不过期才执行常规 LRU 更新:摘下节点、插入头部、更新时间戳(如果策略是「访问即刷新过期时间」)
- 注意:是否刷新过期时间取决于业务——有些场景要求「写入后固定过期」,那就不能在
get()里改expire_at
put() 后的过期清理为什么不能只看容量
只靠 size() > capacity_ 触发淘汰,会漏掉「容量没满但大量节点已过期」的情况。尤其当缓存长期低频访问,又设了短过期时间(比如 5 秒),链表尾部可能堆满僵尸节点。
所以 put() 结束后,必须额外做一轮「尾部过期扫描」:
- 从 tail 开始向前遍历,只要
node->expire_at 就删,直到遇到第一个未过期节点或到达 head - 控制扫描上限(比如最多删 3 个),防止单次
put()耗时突增;剩余过期项留待下次操作清理 - 不要用 while 循环无限制扫到底——极端情况下链表全是过期项,会导致卡顿
- 如果业务对实时性敏感,可另起一个低优先级线程定期调用
cleanup_expired(),但主线程仍保留懒清理兜底
真正难的不是实现双向链表,而是让时间语义和访问语义在同一个结构里不打架。时间戳一旦和节点脱钩,或者清理时机选错,缓存就会开始返回过期数据,而你还以为是 key 没命中。


















