找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2021

积分

0

好友

291

主题
发表于 3 天前 | 查看: 11| 回复: 0

前言:为什么加了 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 倍以上的性能提升只是一个可预期的结果。关于更多高并发与系统架构的深度讨论,欢迎在云栈社区进行交流。




上一篇:Linux共享内存底层机制解析:从内核映射到进程通信实战
下一篇:接手陈年代码触发S0故障,刚入职的新人需要背锅吗?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-9 17:59 , Processed in 0.184210 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表