第2章:缓存层次架构:从内存到SSD,搜索引擎的多级缓存设计

大家好,我是你们的讲师。今天我们来聊聊搜索引擎里一个特别实在的话题——多级缓存架构。

说实话,我刚入行那会儿,觉得缓存嘛,不就是把热点数据往内存里一丢,完事儿。直到我参与的第一个搜索引擎项目上线,流量一上来,内存直接被打满,GC频繁到服务都快挂了。嗯,那次教训让我明白:缓存不是一层就能搞定的,得分层、分场景、分数据特性来设计

为什么需要多级缓存?

你想想看,搜索引擎每天要处理上亿次查询。每次查询,背后可能是几十毫秒甚至上百毫秒的磁盘IO。如果所有请求都直接打到底层存储,再好的硬件也扛不住。

我个人的习惯是,把缓存想象成一个金字塔。塔尖最快、最贵、容量最小;塔底最慢、最便宜、容量最大。搜索引擎的查询,应该尽可能在塔尖命中,实在不行再往下走。

核心原则: 让80%的查询在1毫秒内返回,剩下20%在10毫秒内返回。做不到?那你的缓存架构就有问题。

第一级:L1 内存缓存(最快,但最小)

这是离CPU最近的一层。通常用堆内缓存实现,比如 Guava Cache、Caffeine。我建议把热点词、高频查询结果放在这里。

举个例子,像“天气”、“新闻”、“今天”这种每天被搜几百万次的词,它们的搜索结果几乎不变。放在L1里,一次查询只要几十纳秒。

// 伪代码示例:L1缓存配置
Cache<String, SearchResult> l1Cache = Caffeine.newBuilder()
    .maximumSize(10_000)          // 最多1万个条目
    .expireAfterWrite(5, TimeUnit.SECONDS)  // 5秒过期
    .recordStats()                // 记录命中率
    .build();

避坑指南: 我曾经把L1缓存设得太大,结果GC频繁到服务抖动。记住,L1不是越大越好,一般控制在几百MB以内。你想想看,GC暂停几百毫秒,对搜索引擎来说就是灾难。

第二级:L2 分布式内存缓存(中等速度,中等容量)

当L1没命中时,请求会打到L2。这一层通常是分布式缓存,比如 Redis 或 Memcached。我习惯把L2设计成集群模式,数据分片存储。

为什么需要L2?因为单机内存有限。一个搜索引擎可能有几亿条索引数据,L1只能放最热的那一小部分。L2可以放几千万条,命中率能提升到90%以上。

特性 L1 内存缓存 L2 分布式缓存
访问延迟 < 0.1ms 1-5ms
容量 几百MB 几十GB
典型实现 Caffeine, Guava Redis, Memcached
数据一致性 弱一致性 最终一致性

这里有个细节要注意:L2的过期策略。我建议用LRU + TTL组合。为什么?因为搜索引擎的数据时效性很重要。比如新闻搜索结果,5分钟前的和现在的可能完全不同。

第三级:L3 SSD 缓存(慢,但容量巨大)

到了这一层,速度已经降到毫秒级了。但好处是容量大,一块NVMe SSD可以放几TB数据。我通常把L3设计成本地文件缓存,用LSM-Tree或者B+Tree来组织。

你可能会问:都到SSD了,为什么不直接查数据库?

原因很简单:SSD缓存的读取路径比数据库短得多。数据库要解析SQL、要加锁、要写日志。而SSD缓存就是简单的key-value读取,省去了很多开销。

// 伪代码:SSD缓存读取流程
public SearchResult getFromL3(String key) {
    // 1. 计算文件分片
    int shardId = hash(key) % NUM_SHARDS;
    // 2. 读取对应的SSTable文件
    SSTable ssTable = loadSSTable(shardId);
    // 3. 二分查找或布隆过滤器过滤
    if (bloomFilter.mightContain(key)) {
        return ssTable.get(key);
    }
    return null;
}

注意: SSD缓存最怕随机写。我曾经遇到过SSD写放大问题,一块盘用了三个月就报废了。后来改成顺序写 + 后台合并,才解决了这个问题。说白了,SSD缓存的设计要遵循“写少读多”的原则。

第四级:L4 远端存储(最慢,但最全)

这是最后一道防线。通常是HDFS、S3或者分布式文件系统。所有缓存都查不到时,才会走到这里。延迟一般在几十毫秒到几百毫秒之间。

我个人习惯把L4当作“兜底”层。它的数据是全量的,但访问成本最高。所以,L4的命中率应该控制在5%以下。如果超过这个数,说明你的L1-L3缓存设计有问题。

多级缓存的协同策略

好了,现在我们有四层缓存了。但问题来了:它们之间怎么配合?

我常用的策略叫“逐级回退 + 异步预热”。

  1. 查询先打L1,命中直接返回
  2. L1没命中,查L2,同时异步把结果写入L1
  3. L2没命中,查L3,同时异步写入L2和L1
  4. L3没命中,查L4,同时异步写入L3、L2、L1

这样做的好处是:热点数据会逐渐“上浮”到更快的缓存层。第一次查询可能很慢,但第二次就快了。

实战经验: 我在项目中遇到过缓存雪崩的问题。所有缓存同时过期,请求直接打到L4,数据库差点挂掉。后来我用了“过期时间抖动”策略——每个key的过期时间加一个随机值,比如5秒±3秒。这样就不会同时过期了。

缓存一致性怎么保证?

这是个大问题。搜索引擎的数据是会更新的,比如新文章发布、旧文章删除。如果缓存不更新,用户搜到的就是脏数据。

我建议采用“写穿透 + 主动失效”策略:

  • 数据更新时,先写数据库,再主动删除所有缓存层中的对应key
  • 下次查询时,缓存没命中,从数据库拉最新数据,重新填充缓存

你可能会问:为什么不直接更新缓存?因为并发写容易出问题。删掉缓存让下次查询重新加载,反而更简单可靠。

总结一下

多级缓存架构,说白了就是用空间换时间,用分层换效率。L1解决最热数据的极速访问,L2解决次热数据的中速访问,L3解决冷数据的中低速访问,L4兜底。

我记得有一次线上事故,L2的Redis集群挂了,但L1和L3扛住了大部分流量,服务只是轻微降级。这就是多级缓存的价值——没有单点故障,每一层都是彼此的备份

下一章,我们会深入聊聊缓存淘汰策略。LRU、LFU、ARC……这些算法到底怎么选?到时候见。