3. 缓存策略:写直达、写回、缓存淘汰算法(LRU、LFU、FIFO)原理

好了,咱们正式开始聊缓存的写入策略和淘汰算法。说真的,这部分内容我每次做性能调优都得翻出来看看。你想想看,缓存这玩意儿,读得快不快,大家都知道。但数据怎么写进去,满了又怎么踢出去,这才是真正的技术活。

我在项目中见过太多例子了:缓存配置看着挺豪华,结果一压测,要么数据不一致,要么缓存命中率低得可怜。说白了,就是策略选错了。

这一章我们就死磕两个核心问题:数据怎么写,以及满了怎么办。

3.1 写策略:数据落地的两种路子

先聊聊写策略。其实就两大类:写直达(Write-Through) 和 写回(Write-Back)。这俩名字听着绕,核心区别就是:写操作刷到下一级存储的时机不一样。

3.1.1 写直达(Write-Through)

写直达是最简单、最稳妥的路子。啥意思?CPU 要写数据,不光写到缓存里,还同步写到下一级存储(比如内存或磁盘)。

写直达核心流程:

  1. 数据写入缓存。
  2. 数据同步写入下一级存储。
  3. 确认两者都写完了,才算写入完成。

我个人习惯在数据一致性要求极高的场景下用这个策略。比如财务系统的核心账目数据,你绝不允许丢一笔。写直达的优点就是:数据永远不会丢,缓存和底层存储始终一致。

注意:写直达的代价也很明显。每次写操作都要等底层存储确认,延迟上去了。我见过有人在小规模高并发场景用这个,结果延迟硬生生从微秒级变成了毫秒级。所以它适合“写少读多”的场景。

3.1.2 写回(Write-Back)

写回就聪明多了,但也更复杂。它的玩法是:只写到缓存里,然后立马告诉 CPU “写完了”。底层存储?先不管!等缓存里的这块数据要被踢出去了(比如被淘汰了),或者系统主动刷一波,再写到底层。

这里有个关键概念——脏位(Dirty Bit)。每个缓存行都有一个脏位,标记这块数据到底有没有被改过。

我的避坑指南:我曾经在一个数据库缓存层用过写回,结果机器意外断电,内存数据全丢了,底层存储里还是旧数据。所以,用写回一定要搭配掉电保护或者定期刷盘机制,否则别怪我没提醒你。

特性 写直达 写回
写延迟 高(等底层存储确认) 低(只写缓存)
数据一致性 强(缓存与存储一致) 弱(存在脏数据窗口)
写带宽消耗 高(每次写都刷下去) 低(批量刷写)
掉电风险 安全 数据易丢失

嗯,这里要注意了。写回策略虽然快,但它把数据安全的锅甩给了硬件。我记得在某款处理器设计时,CPU 用写回,但专门加了个刷回指令,让软件在某些关键节点手动刷缓。

3.2 缓存淘汰算法:满了怎么办?

缓存空间是有限的,这是铁律。一旦满了,新数据要进来,就必须“踢”一个老数据出去。怎么踢?这就有讲究了。

我们常见的三大算法:LRU、LFU、FIFO。每个我都踩过坑,咱们一个个讲。

3.2.1 LRU(最近最少使用)

LRU 的逻辑很简单:淘汰最长时间没有被访问的那条数据。它的理论基础是局部性原理——刚用过的数据,大概率马上还会用。

我个人比较喜欢 LRU。它的表现通常很稳定,而且实现起来并不复杂。最常见的数据结构是哈希表 + 双向链表。

class LRUCache {
    // 哈希表 + 双向链表
    private Node head, tail;
    private Map<Integer, Node> map;
    private int capacity;

    public int get(int key) {
        if (!map.containsKey(key)) return -1;
        Node node = map.get(key);
        // 将访问的节点移到头部
        moveToHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {
            node.value = value;
            moveToHead(node);
        } else {
            Node newNode = new Node(key, value);
            if (map.size() == capacity) {
                // 淘汰尾部节点
                removeTail();
            }
            addToHead(newNode);
            map.put(key, newNode);
        }
    }
}

实践要点:

  • LRU 的坑大多在并发安全上。多线程环境下,链表操作很脆。
  • 我建议在实现时加上 synchronized 或者用 ConcurrentLinkedHashMap 这种现成方案。

3.2.2 LFU(最不经常使用)

LFU 的逻辑不一样:它统计每个数据的访问频率,频率最低的先淘汰。

听起来很科学?其实问题一堆。

我曾经掉进去的坑:某个社交 APP 的热点数据统计,我一开始用了 LFU。结果呢?一些老数据因为早期访问频繁,计数特别高,就永远赖在缓存里不走。这叫“缓存污染”。新发的爆款内容反而进不来。说白了,LFU 对新数据不友好,容易造成“历史包袱”。

所以实际项目中,单纯 LFU 很少用。我们往往加个时间衰减因子,或者与 LRU 混合使用。

3.2.3 FIFO(先进先出)

FIFO 最简单:数据按照进入缓存的顺序排队,先来的先淘汰。它不考虑访问频率,也不考虑时间。

嗯,这里是重点。FIFO 虽然简单,但性能往往是最差的。为什么?因为它完全无视了访问模式。你辛辛苦苦把热点数据拉到缓存里,结果一个不太常用的老数据占着位置,FIFO 却先把热点数据给踢出去了。

一个小技巧:除非你是做极其简单的流水线处理,否则别碰 FIFO。我见过一些新手在单体应用里用 FIFO,结果缓存命中率不到 20%。换成 LRU 后,直接飙到 80% 以上。

3.3 核心逻辑关系图

说了这么多,我们把这三者的关系和各自的地位串一下,这样你脑子里就有框架了。

缓存策略核心逻辑体系 缓存策略 写策略 淘汰算法 写直达 写回 LRU LFU FIFO 关键说明: • 写直达:数据同步写入缓存和内存,强一致性,性能开销大。 • 写回:只写缓存,脏位标记,延迟低但存在数据丢失风险。 • LRU:淘汰最久未访问的数据,利用局部性,综合表现优秀。 • LFU:淘汰访问频率最低的数据,易产生“历史包袱”问题。 • FIFO:仅按进入顺序淘汰,完全不考虑访问模式,性能最差。

3.4 怎么选?聊聊我的实践心得

讲完了原理,那我到底该用哪个?嗯,这里我给大家几个硬指标,你可以对着选:

  • 写频率 < 10% 且一致性要求极高 → 用写直达
  • 写频率 > 30% 且能接受短暂不一致 → 用写回(记得加掉电保护)
  • 普通场景 → LRU 是万金油,无脑选它基本不会错
  • 访问模式极其规律(比如先来后到的流水线)→ 可以试试 FIFO,但要做好被替代的准备
  • 千万别用 LFU 处理近期热点,除非你加了时间衰减

我的最后一点建议:大部分情况下,直接用现成的 LinkedHashMap(Java)或者 Redis 自带的近似 LRU 算法就够了。不要自己造轮子,除非你很清楚边界在哪里。