前言:为什么加了 Redis 还是慢?
“接口 RT 从 300 ms 优化到 30 ms” 通常遵循一个清晰的路径:
- 把数据库 IO 砍掉 → 引入远程缓存
- 把网络 IO 砍掉 → 引入本地缓存
- 把序列化成本砍掉 → 考虑零拷贝
一次远程 Redis 访问看似只有 1-2 ms,但在高并发场景下,CPU 上下文切换、序列化/反序列化以及网络抖动的影响会被放大,可能增至 5-10 ms。相比之下,本地缓存命中时的开销仅在几十纳秒级别。
本文将基于 Spring Boot 3 构建一个“三级金字塔”缓存模型:
L1 Caffeine本地缓存 → L2 Redis远程缓存 → L3 数据库
并提供包括背压处理、预热、热点 Key 以及大 Key 打散在内的完整方案。该方案无需额外中间件依赖,代码复制即可运行。
金字塔模型与数据热度分布
| 层级 |
延迟 |
容量 |
命中率目标 |
说明 |
| L1 Caffeine |
50 ns |
10 MB |
80% |
进程内,零网络开销 |
| L2 Redis |
1 ms |
100 GB |
15% |
可横向扩展的集群 |
| L3 MySQL |
10 ms+ |
TB 级 |
5% |
最终一致性数据源 |
经验表明,当单机 QPS 达到 1 万时,L1 缓存命中率每提升 1%,整体 CPU 使用率可下降约 3%。
环境与依赖(仅需3个)
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
无需额外组件,本地直接通过 java -jar 命令启动应用。
配置:使 Caffeine 和 Redis 协同工作
spring:
cache:
type: caffeine # 默认启用 L1 缓存
caffeine:
spec: maximumSize=10000,expireAfterWrite=60s
redis:
host: 127.0.0.1
port: 6379
timeout: 200ms
lettuce:
pool:
max-active: 64
核心封装:三级缓存模板
@Component
@Slf4j
public class CacheTemplate<K, V> {
private final Cache<K, V> local = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(60))
.recordStats() // 开启命中率监控
.build();
@Autowired
private RedisTemplate<K, V> redisTemplate;
/**
* 金字塔式查询
*/
public V get(K key, Supplier<V> dbFallback) {
// L1 本地缓存查询
V v = local.getIfPresent(key);
if (v != null) {
log.debug(“L1 hit {}”, key);
return v;
}
// L2 Redis 缓存查询
v = redisTemplate.opsForValue().get(key);
if (v != null) {
local.put(key, v); // 回填 L1 缓存
log.debug(“L2 hit {}”, key);
return v;
}
// L3 数据库查询
v = dbFallback.get();
if (v != null) {
set(key, v); // 双写 L1 和 L2
}
return v;
}
/**
* 双写(L1 + L2)
*/
public void set(K key, V value) {
local.put(key, value);
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(5));
}
/**
* 删除(L1 + L2)
*/
public void evict(K key) {
local.invalidate(key);
redisTemplate.delete(key);
}
@Scheduled(fixedDelay = 30_000)
public void printStats() {
log.info(“L1 hitRate={}”, local.stats().hitRate());
}
}
业务使用:一行代码集成缓存
@RestController
@RequestMapping(“/api/item”)
@RequiredArgsConstructor
public class ItemController {
private final CacheTemplate<Long, ItemDTO> cache;
private final ItemRepository itemRepository;
@GetMapping(“/{id}”)
public ItemDTO getItem(@PathVariable Long id) {
return cache.get(id, () -> itemRepository.findById(id).orElse(null));
}
@PostMapping
public void create(@RequestBody ItemDTO dto) {
ItemDTO saved = itemRepository.save(dto);
cache.set(saved.getId(), saved);
}
@DeleteMapping(“/{id}”)
public void delete(@PathVariable Long id) {
itemRepository.deleteById(id);
cache.evict(id);
}
}
启动应用后观察日志,理想的命中率分布可能如下:
L1 hit 0.83
L2 hit 0.15
DB hit 0.02
在此模型下,接口响应时间可能从 28 ms 降至 2 ms,同时 CPU 使用率下降 35%。
高并发下的4个常见问题与解决方案
| 问题 |
现象 |
解决方案 |
| 缓存穿透 |
大量并发查询数据库中不存在的 Key,导致数据库压力激增 |
在 get() 方法中,即使查询结果为 null,也将其缓存一小段时间(如5秒) |
| 热点 Key |
大量请求集中访问同一个 Key,可能导致单个 Redis 实例或线程过载 |
本地 L1 缓存可以消化掉绝大部分(如80%)的重复请求流量 |
| 大 Key |
单个 Value 过大(如 5 MB),占用大量网络带宽和内存 |
将大对象拆分为多个子 Key(如使用 Hash 结构分片存储),或对 Value 进行压缩 |
| 缓存雪崩 |
大量缓存在同一时刻(如60秒后)集中失效,引发数据库“惊群”效应 |
为 Caffeine 和 Redis 的过期时间(TTL)添加一个随机偏移量 |
随机 TTL 工具方法示例:
private Duration randomTTL(long baseSec) {
long delta = ThreadLocalRandom.current().nextLong(0, 300); // 在基础时间上增加0-300秒的随机值
return Duration.ofSeconds(baseSec + delta);
}
缓存预热与背压控制
在应用启动时,异步预热热门数据到本地缓存,可以有效避免冷启动瞬间的缓存穿透问题。
@EventListener(ApplicationReadyEvent.class)
public void warm() {
List<Long> hotIds = itemRepository.findHotIds(PageRequest.of(0, 200));
hotIds.parallelStream().forEach(id -> cache.set(id, itemRepository.findById(id).orElse(null)));
}
使用 parallelStream() 可以控制预热任务的并发度,默认的 ForkJoinPool.commonPool() 在多数场景下已足够。
压测结果对比
- 测试环境: Mac M2 8G,4个并发线程,持续60秒。
- 压测工具:
wrk2 -R 5000 -d 60s -c 50
| 指标 |
纯 DB 查询 |
L2 Redis 缓存 |
L1+Caffeine 多级缓存 |
性能提升 |
| 平均 RT |
28 ms |
5.1 ms |
1.9 ms |
约14倍 |
| P99 RT |
120 ms |
18 ms |
4 ms |
约30倍 |
| CPU 占用 |
65 % |
40 % |
25 % |
下降约60% |
| 网络出流量 |
180 MB/s |
12 MB/s |
0.8 MB/s |
下降约99% |
监控与告警配置
Caffeine 内置了统计功能,可以方便地与 Micrometer 集成,将指标输出到 Prometheus。
MeterBinder caffeineMetrics = registry ->
CaffeineMetrics.monitor(registry, local, “l1_cache”);
在 Grafana 等监控面板中,应重点关注以下指标:
l1_cache_hit_rate < 70% 时触发告警。
l1_cache_eviction_count 激增,通常意味着本地缓存容量不足。
Redis keyspace_hits / (hits+misses) < 50%,可能暗示存在大 Key 问题或缓存穿透。
扩展:自定义多级缓存注解
Spring Cache 抽象原生只支持单层缓存。你可以自定义一个 @MultiCacheable 注解来实现更优雅的多级缓存声明。
@Target(METHOD)
@Retention(RUNTIME)
public @interface MultiCacheable {
String[] cacheNames(); // 例如 {“l1”, “l2”}
String key();
}
通过 AOP 拦截器,按顺序(l1 → l2 → db)执行查询,使得业务代码完全无侵入。
结语
实现高性能的本地缓存远不止简单地添加一条 @Cacheable 注解。它需要一个系统性的设计:
- 金字塔模型: 依据数据访问热度进行合理分层。
- 防护机制: 结合背压与随机 TTL 来抵抗缓存雪崩。
- 可观测性: 通过预热和完备的监控确保系统状态可见。
当这三方面都得到妥善处理后,接口获得 10 倍以上的性能提升只是一个可预期的结果。关于更多高并发与系统架构的深度讨论,欢迎在云栈社区进行交流。