打造高性能多级缓存:Redis + Caffeine 两级缓存架构设计与落地实践

打造高性能多级缓存:Redis + Caffeine 两级缓存架构设计与落地实践
打造高性能多级缓存:Redis + Caffeine 两级缓存架构设计与落地实践

打造高性能多级缓存:Redis + Caffeine 两级缓存架构设计与落地实践

在高性能高并发的服务架构设计中,缓存是突破系统瓶颈的绝对利器。在实际的工业级项目中,我们通常将热点数据存放在 Redis 或 Memcached 等分布式缓存中间件中。当访问未命中时,再回源到数据库进行查询,以此降低数据库负载并大幅缩短响应延迟。

然而,随着系统并发量的不断飙升,分布式缓存也渐渐暴露出其性能天花板:网络 I/O 带来的序列化与网络传输开销、高频交互带来的 Redis 实例连接池压力等。为了突破这一瓶颈,“两级缓存”架构应运而生。通过将进程内的本地缓存(Caffeine / Guava Cache)作为一级缓存(L1),将分布式缓存(Redis)作为二级缓存(L2),能够彻底压榨服务器硬件性能,大幅度提高程序响应速度。

本文将为你深度拆解这一多级缓存架构的演进、技术选型,并提供完整的生产级 Spring Boot 协同落地实践。

打造高性能多级缓存:Redis + Caffeine 两级缓存架构设计与落地实践


为什么需要引入本地缓存?

传统的单级分布式缓存架构(仅 Redis)在应对极高并发时存在以下痛点:

1. 网络吞吐限制:每次读取缓存都要进行一次网络请求,在大促或大流量突发时,带宽和连接池可能会成为性能瓶颈。

2. 序列化成本:Redis 中存储的内容通常需要通过 JSON 或 JDK 反序列化为 Java 对象,高频的读取和反序列化操作会消耗大量的 CPU 算力。

3. 单点热 key 击穿:虽然 Redis 支持高并发,但在面对秒杀等超大流量的热点 key 时,单台 Redis 实例可能被瞬间打爆。

本地缓存(进程内缓存)直接在 JVM 堆内存中开辟空间,其数据获取直接走 CPU 的内存寻址,访问速度在纳秒级,免去了所有的网络延迟与反序列化损耗。通过“本地缓存 + 远程分布式缓存”的强强联合,其读取流程如下:

Mermaid Diagram


本地缓存方案选型:谁才是进程内缓存之王?

要在 JVM 内部实现一个安全、健壮且高效的本地缓存,我们需要考量并发安全、最大容量限制、缓存淘汰算法(如 LRU、LFU)、过期删除策略、统计监控等维度。目前 Java 生态中主要有以下几种主流实现方式:

1. ConcurrentHashMap 简易实现

缓存本质上是内存中的 KV 结构,通过 ConcurrentHashMap 可以非常容易地搭建起一个支持并发读写的缓存。

优点:简单,不需要引入第三方依赖,无任何侵入性。

缺点:缺乏数据过期删除、最大容量淘汰限制(无防 OOM 机制)、缓存命中率统计等丰富特性。若自己手写维护淘汰机制,稳定性和可靠性很难得到保证。

2. Guava Cache

Google 团队基于 Java 精心打磨的增强库包,其内置的 Guava Cache 是 Java 生态中普及度极高、极其稳定的经典进程内缓存方案。

特性:支持最大容量限制、支持基于写入时间与访问时间的过期淘汰、支持缓存命中率统计。

算法:基于 LRU(最近最少使用) 算法实现。

3. Caffeine (强烈推荐)

作为新一代的本地缓存之王,Caffeine 可以看作是 Guava Cache 的升级再版。它是基于 Java 8 特性重写的本地缓存库,在各项性能指标上几乎逼近理论最优值。

核心优势:Caffeine 并没有采用传统的 LRU 或 LFU,而是采用了一种融合了两者优点的 W-TinyLFU 算法。它仅用微乎其微的内存消耗即可记录极高历史频次的访问记录,从而将缓存命中率提升到了前所未有的高度。

4. Ehcache

一个纯 Java 实现的成熟缓存框架,也是 Hibernate 官方默认推荐的二级缓存提供商。

特性:同 Caffeine 相比,Ehcache 提供的特性极为厚重,支持堆内存储、堆外存储(Off-Heap)和磁盘持久化存储。同时,它还内置了多种分布式集群数据共享机制。

算法:支持 LRU、LFU 和 FIFO 淘汰。

本地缓存性能横向对比

下图展示了在主流并发场景下,Caffeine、Guava Cache 与 Ehcache 的读写吞吐性能对比。从中可以直观地看出 Caffeine 在读写效率上的碾压级优势:

缓存实现 核心淘汰算法 内存存储支持 吞吐量性能表现 适用场景建议
Caffeine W-TinyLFU 仅支持堆内 (Heap) 🚀 极致性能 (理论最优) 绝大部分主流 Spring Boot 项目的本地一级缓存首选
Guava Cache LRU 仅支持堆内 (Heap) 📊 中等偏上 遗留旧系统的集成和基础小微工具类开发
Ehcache LRU / LFU / FIFO 支持堆内、堆外及磁盘持久化 📉 较低 需要持久化或大量使用堆外内存(避免 GC)的超大缓存场景

本地缓存致命缺陷:数据一致性如何解决?

既然本地缓存如此强大,为什么不直接全量使用本地缓存?因为它最大的死穴就在于无法天然支持多节点的分布式一致性

当我们对系统进行多节点集群部署时,每一个节点都有属于自己 JVM 独立的本地缓存。如果某个商品的数据发生了更新,且我们直接把修改写入了数据库。此时,如果只清理了接收到更新请求的那个节点的本地缓存,其他节点中的旧本地缓存依然会存活直到自然过期。这就导致了用户在访问不同服务器节点时,会看到“忽新忽旧”的混乱数据。

为了解决多节点本地缓存一致性问题,业界有两种主流的闭环架构设计:

解决方案 1:MQ 消息广播通知(应用层推送)

当数据发生变更时,由发起更新请求的业务服务异步向 MQ(如 Redis Pub/Sub、RocketMQ 广播模式、RabbitMQ 扇出交换机)发送一条“缓存失效广播”。集群中的所有节点在收到该消息后,各自调用本地 Caffeine 实例执行 invalidate 清空失效 key。

解决方案 2:Canal 订阅 Binlog + MQ 联动(数据库层推送)

为了让业务代码彻底与“通知缓存失效”逻辑解耦,可以使用 Canal 订阅 MySQL 数据库的 Binlog 变更日志。一旦感知到对应表结构有 Update 或 Delete 变更,Canal 会抓取变更并转化为消息推送到 MQ。应用服务节点只需统一监听该 MQ 队列,即可实现对本地一级缓存和远程 Redis 二级缓存的同步清理。


Spring Boot 实战:打造 Redis + Caffeine 二级缓存机制

下面我们将编写一个基于 Spring Boot 的生产级双向协同伪代码。其基本思想是封装读、写逻辑,实现透明化双层读取,并处理多节点清空逻辑。

1. 引入核心 Maven 依赖

在你的 pom.xml 中引入 Spring Data Redis、Caffeine 以及必要的依赖:

<dependencies>
<!-- Redis 缓存依赖 -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>

<!-- Caffeine 缓存依赖 -->
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>

<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependencies>

2. 多级缓存协同服务核心实现

TwoLevelCachePractice.java 中,我们利用了双重检查加锁(Double-Checked Locking)模式来防止高并发时在底层数据库上发生缓存击穿:

package com.example.cache;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class TwoLevelCachePractice {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

// 配置一级缓存:最大 1000 个,写入后 60 秒失效
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();

/**
* 获取数据流程
*/
public Object getOrderData(String orderId) {
// Step 1: 读一级本地缓存 Caffeine
Object data = localCache.getIfPresent(orderId);
if (data != null) {
log.info("L1 Caffeine Hit: {}", orderId);
return data;
}

// Step 2: 读二级分布式缓存 Redis
data = redisTemplate.opsForValue().get(orderId);
if (data != null) {
log.info("L2 Redis Hit: {}, backfilling L1", orderId);
localCache.put(orderId, data);
return data;
}

// Step 3: 二者皆失,读取 DB
synchronized (this) {
// 双重校验以防并发穿透
data = localCache.getIfPresent(orderId);
if (data != null) return data;
data = redisTemplate.opsForValue().get(orderId);
if (data != null) {
localCache.put(orderId, data);
return data;
}

log.info("Cache missed. Fetching from Database for: {}", orderId);
data = fetchFromDatabase(orderId);

// 分别向 Redis 和 Caffeine 写入回填缓存
redisTemplate.opsForValue().set(orderId, data, 10, TimeUnit.MINUTES);
localCache.put(orderId, data);
}
return data;
}

/**
* 更新数据与缓存剔除
*/
public void updateOrder(String orderId, Object newData) {
// 先更库
updateDatabase(orderId, newData);
// 后清二级缓存
redisTemplate.delete(orderId);
// 清当前节点本地一级缓存
localCache.invalidate(orderId);
// MQ 广播清理其他节点
publishCacheEvictionMessage(orderId);
}

public void handleCacheEvictionMessage(String orderId) {
log.info("Evicting L1 local cache on this node for: {}", orderId);
localCache.invalidate(orderId);
}

private Object fetchFromDatabase(String orderId) {
return "OrderInfo{id='" + orderId + "', status='PAID'}";
}

private void updateDatabase(String orderId, Object newData) {}

private void publishCacheEvictionMessage(String orderId) {
redisTemplate.convertAndSend("cache-eviction-channel", orderId);
}
}

最佳实践与避坑警告

1. 冷启动与缓存预热:系统刚上线或大促刚开始时本地缓存为空。大量请求可能瞬间穿过 L1 甚至 L2 压垮数据库。对于核心高热 Key,建议上线前启动预热脚本将 Redis 和本地缓存铺设完整。

2. 本地缓存大小(OOM 防范):一级缓存大小必须合理配置。通过 maximumSize 对最大缓存对象条数进行严格限制,或者使用堆外内存存储非常庞大的缓存内容,避免由于一级缓存存储过多对象导致 JVM 发生频繁的 Full GC 甚至 OOM 异常。

3. 过期时间差异性(防止缓存雪崩):为了避免大量热点数据在同一时间点在本地和 Redis 里集体过期导致性能雪崩,L1 本地缓存和 L2 远程缓存的过期时间必须错开。一般推荐 L1 过期时间 << L2 过期时间(例如本地缓存 1 分钟,Redis 缓存 10 分钟),如此即便本地缓存失效,依然可以从极近的 Redis 获取回填,大大提高了系统容错能力。


公众号二维码

长按二维码关注 “边学边练”

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有

相关阅读