跳转到内容
123xiao | 无名键客

《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:热点数据一致性与性能优化指南》

字数: 0 阅读时长: 1 分钟

Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:热点数据一致性与性能优化指南

在业务系统里,缓存几乎是绕不过去的话题。尤其是商品详情、用户画像、配置中心、首页聚合这些“读多写少”的场景,单靠数据库扛流量,通常很快就会吃紧。

很多团队一开始会直接上 Redis,确实能挡住一大波查询压力。但只用 Redis 也不是终点:

  • 网络来回有延迟
  • Redis 在高并发热点 Key 下也会成为瓶颈
  • 数据更新时,本地进程里可能还残留旧值
  • Spring Cache 默认更偏“单层缓存”,多级组合要自己补一层设计

这篇文章我会带你从实际工程视角,在 Spring Boot 中实现一个本地缓存 + Redis 缓存的多级缓存方案,并重点解决两个问题:

  1. 热点数据性能优化:尽量让热点请求优先命中本地缓存
  2. 缓存一致性控制:更新数据库后,如何尽量让本地与 Redis 不长期脏读

文章会给出一套可运行代码,并讲清楚常见坑和排查思路。


背景与问题

先看一个很典型的请求链路:

flowchart LR
    A[客户端请求] --> B[应用服务]
    B --> C{本地缓存命中?}
    C -- 是 --> D[直接返回]
    C -- 否 --> E{Redis命中?}
    E -- 是 --> F[回填本地缓存]
    F --> D
    E -- 否 --> G[查询数据库]
    G --> H[写入Redis]
    H --> I[写入本地缓存]
    I --> D

如果只有 Redis 这一层,虽然比查库快,但对于超热点数据,应用每次仍要走网络请求 Redis。
而加入本地缓存后,好处很明显:

  • 减少 Redis 压力
  • 降低接口 RT
  • 提升热点场景吞吐

但问题马上也来了:

1. 本地缓存天然是“每个实例一份”

服务部署 5 个节点,每个节点都有自己的 JVM 本地缓存。
你更新了某个商品价格,Redis 删掉了,但其他节点的本地缓存如果没失效,还会继续返回旧数据。

2. Spring Cache 默认没帮你解决跨节点本地缓存同步

@Cacheable@CacheEvict 用起来很舒服,但它不负责“通知其他应用实例删除本地缓存”。

3. 热点 Key 容易引发缓存击穿

某个超热点数据过期瞬间,大量请求同时穿透到数据库。

4. 缓存雪崩、穿透、脏读会互相叠加

如果没有 TTL 随机化、空值缓存、互斥重建等策略,线上问题往往不是一个一个来,而是一起爆。


核心原理

这套方案本质上是Cache Aside + 多级缓存 + 主动失效通知

设计目标

  • L1:本地缓存(Caffeine)
    • 极低延迟
    • 容量有限
    • 生命周期短
  • L2:Redis 缓存
    • 多实例共享
    • 容量更大
    • 支持 TTL
  • DB:数据库
    • 最终数据源

读流程

  1. 先查本地缓存
  2. 本地没命中,再查 Redis
  3. Redis 没命中,再查数据库
  4. 查到结果后,回填 Redis 和本地缓存

写流程

  1. 更新数据库
  2. 删除 Redis 缓存
  3. 发布缓存失效消息
  4. 各节点收到消息后,删除自己的本地缓存

这是业界非常常见的一条路,原因很简单:
缓存更新远比缓存读取复杂,最稳妥的方式通常不是“更新缓存”,而是“删缓存”。”


整体架构图

flowchart TB
    subgraph App1[应用实例 A]
        A1[Controller]
        A2[Spring Cache]
        A3[Caffeine 本地缓存]
    end

    subgraph App2[应用实例 B]
        B1[Controller]
        B2[Spring Cache]
        B3[Caffeine 本地缓存]
    end

    R[(Redis)]
    MQ[Redis Pub/Sub]
    DB[(MySQL)]

    A1 --> A2 --> A3
    A2 --> R
    B1 --> B2 --> B3
    B2 --> R

    A2 --> DB
    B2 --> DB

    A2 --> MQ
    B2 --> MQ
    MQ --> A3
    MQ --> B3

前置知识与环境准备

技术栈

  • JDK 8+
  • Spring Boot 2.x
  • Spring Cache
  • Spring Data Redis
  • Caffeine
  • Maven
  • Redis 5+

本文示例目标

实现一个商品查询接口:

  • GET /products/{id}:查询商品
  • PUT /products/{id}:更新商品价格

并满足:

  • 查询优先命中本地缓存
  • 本地缓存 miss 后查 Redis
  • 更新后清理 Redis + 广播清理本地缓存
  • 防止热点 Key 并发击穿

核心实现思路

这里我不直接依赖 Spring Cache 的单一 CacheManager,而是自己实现一个组合缓存,把 Caffeine 和 Redis 串起来。

这样做的好处是逻辑可控,尤其适合处理中级项目里的多级缓存细节。

查询与更新时序

sequenceDiagram
    participant Client as 客户端
    participant App as 应用服务
    participant L1 as Caffeine
    participant L2 as Redis
    participant DB as MySQL
    participant PS as Pub/Sub

    Client->>App: GET /products/1001
    App->>L1: get(product:1001)
    alt 本地命中
        L1-->>App: 返回数据
        App-->>Client: 200 OK
    else 本地未命中
        App->>L2: get(product:1001)
        alt Redis命中
            L2-->>App: 返回数据
            App->>L1: put(product:1001)
            App-->>Client: 200 OK
        else Redis未命中
            App->>DB: select by id
            DB-->>App: 商品数据
            App->>L2: put with ttl
            App->>L1: put
            App-->>Client: 200 OK
        end
    end

    Client->>App: PUT /products/1001
    App->>DB: update
    App->>L2: delete(product:1001)
    App->>PS: publish evict message
    PS-->>App: 所有实例删除本地缓存
    App-->>Client: 200 OK

实战代码(可运行)

下面给一套精简但可落地的实现。

1. Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

2. 配置文件

spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1
    username: sa
    password:
    driver-class-name: org.h2.Driver

  redis:
    host: localhost
    port: 6379

  h2:
    console:
      enabled: true

logging:
  level:
    root: info

3. 初始化 SQL

schema.sql

CREATE TABLE product (
    id BIGINT PRIMARY KEY,
    name VARCHAR(64),
    price DECIMAL(10,2),
    update_time TIMESTAMP
);

data.sql

INSERT INTO product(id, name, price, update_time)
VALUES (1001, '机械键盘', 399.00, CURRENT_TIMESTAMP);

INSERT INTO product(id, name, price, update_time)
VALUES (1002, '显示器', 1299.00, CURRENT_TIMESTAMP);

4. 启动类

package com.example.multicache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MultiCacheApplication {
    public static void main(String[] args) {
        SpringApplication.run(MultiCacheApplication.class, args);
    }
}

5. 实体类

package com.example.multicache.model;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public class Product {
    private Long id;
    private String name;
    private BigDecimal price;
    private LocalDateTime updateTime;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }

    public LocalDateTime getUpdateTime() {
        return updateTime;
    }

    public void setUpdateTime(LocalDateTime updateTime) {
        this.updateTime = updateTime;
    }
}

6. Repository

package com.example.multicache.repository;

import com.example.multicache.model.Product;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;

@Repository
public class ProductRepository {

    private final JdbcTemplate jdbcTemplate;

    public ProductRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public Product findById(Long id) {
        return jdbcTemplate.query(
                "select id, name, price, update_time from product where id = ?",
                rs -> {
                    if (rs.next()) {
                        Product p = new Product();
                        p.setId(rs.getLong("id"));
                        p.setName(rs.getString("name"));
                        p.setPrice(rs.getBigDecimal("price"));
                        Timestamp ts = rs.getTimestamp("update_time");
                        p.setUpdateTime(ts == null ? null : ts.toLocalDateTime());
                        return p;
                    }
                    return null;
                },
                id
        );
    }

    public int updatePrice(Long id, BigDecimal price) {
        return jdbcTemplate.update(
                "update product set price = ?, update_time = ? where id = ?",
                price, Timestamp.valueOf(LocalDateTime.now()), id
        );
    }
}

7. Caffeine 配置

package com.example.multicache.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class LocalCacheConfig {

    @Bean
    public Cache<String, Object> localCache() {
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .expireAfterWrite(30, TimeUnit.SECONDS)
                .build();
    }
}

这里本地缓存 TTL 我故意配得比较短。
原因是:本地缓存越快,越容易脏;本地缓存越短,越能降低脏读时间窗口。


8. Redis 配置

package com.example.multicache.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class RedisConfig {

    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        return new StringRedisTemplate(factory);
    }
}

9. 多级缓存服务

package com.example.multicache.cache;

import com.example.multicache.model.Product;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Component
public class MultiLevelCache {

    private static final String NULL_MARKER = "__NULL__";

    private final Cache<String, Object> localCache;
    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    public MultiLevelCache(Cache<String, Object> localCache,
                           StringRedisTemplate redisTemplate,
                           ObjectMapper objectMapper) {
        this.localCache = localCache;
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    public Product getProduct(String key) {
        Object localValue = localCache.getIfPresent(key);
        if (localValue != null) {
            if (NULL_MARKER.equals(localValue)) {
                return null;
            }
            return (Product) localValue;
        }

        String redisValue = redisTemplate.opsForValue().get(key);
        if (redisValue != null) {
            if (NULL_MARKER.equals(redisValue)) {
                localCache.put(key, NULL_MARKER);
                return null;
            }
            try {
                Product product = objectMapper.readValue(redisValue, Product.class);
                localCache.put(key, product);
                return product;
            } catch (Exception e) {
                throw new RuntimeException("Redis 反序列化失败", e);
            }
        }
        return null;
    }

    public void putProduct(String key, Product product, Duration ttl) {
        try {
            String json = objectMapper.writeValueAsString(product);
            redisTemplate.opsForValue().set(key, json, ttl);
            localCache.put(key, product);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("序列化失败", e);
        }
    }

    public void putNull(String key, Duration ttl) {
        redisTemplate.opsForValue().set(key, NULL_MARKER, ttl);
        localCache.put(key, NULL_MARKER);
    }

    public void evict(String key) {
        redisTemplate.delete(key);
        localCache.invalidate(key);
    }

    public void evictLocal(String key) {
        localCache.invalidate(key);
    }

    public boolean tryLock(String key) {
        Boolean ok = redisTemplate.opsForValue().setIfAbsent("lock:" + key, "1", 5, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(ok);
    }

    public void unlock(String key) {
        redisTemplate.delete("lock:" + key);
    }
}

这段代码顺手处理了两个常见问题:

  • 空值缓存:避免缓存穿透
  • 分布式短锁:避免缓存击穿时大量并发同时查库

当然,生产环境分布式锁最好做得更严谨,比如带唯一 value 校验再删除,我后面会讲。


10. Redis Pub/Sub 配置

package com.example.multicache.config;

import com.example.multicache.listener.CacheEvictMessageListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Configuration
public class RedisPubSubConfig {

    public static final String CACHE_EVICT_TOPIC = "cache:evict:topic";

    @Bean
    public ChannelTopic cacheEvictTopic() {
        return new ChannelTopic(CACHE_EVICT_TOPIC);
    }

    @Bean
    public RedisMessageListenerContainer redisContainer(
            RedisConnectionFactory connectionFactory,
            CacheEvictMessageListener listener,
            ChannelTopic topic) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listener, topic);
        return container;
    }
}

11. 本地缓存失效监听器

package com.example.multicache.listener;

import com.example.multicache.cache.MultiLevelCache;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;

@Component
public class CacheEvictMessageListener implements MessageListener {

    private final MultiLevelCache multiLevelCache;

    public CacheEvictMessageListener(MultiLevelCache multiLevelCache) {
        this.multiLevelCache = multiLevelCache;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = new String(message.getBody());
        multiLevelCache.evictLocal(key);
    }
}

12. 业务 Service

package com.example.multicache.service;

import com.example.multicache.cache.MultiLevelCache;
import com.example.multicache.model.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.Duration;

@Service
public class ProductService {

    private static final Duration PRODUCT_TTL = Duration.ofMinutes(10);
    private static final Duration NULL_TTL = Duration.ofMinutes(2);

    private final ProductRepository productRepository;
    private final MultiLevelCache multiLevelCache;
    private final StringRedisTemplate redisTemplate;
    private final ChannelTopic cacheEvictTopic;

    public ProductService(ProductRepository productRepository,
                          MultiLevelCache multiLevelCache,
                          StringRedisTemplate redisTemplate,
                          ChannelTopic cacheEvictTopic) {
        this.productRepository = productRepository;
        this.multiLevelCache = multiLevelCache;
        this.redisTemplate = redisTemplate;
        this.cacheEvictTopic = cacheEvictTopic;
    }

    public Product getById(Long id) {
        String key = buildKey(id);
        Product cached = multiLevelCache.getProduct(key);
        if (cached != null) {
            return cached;
        }

        boolean locked = multiLevelCache.tryLock(key);
        try {
            if (locked) {
                Product doubleCheck = multiLevelCache.getProduct(key);
                if (doubleCheck != null) {
                    return doubleCheck;
                }

                Product dbData = productRepository.findById(id);
                if (dbData == null) {
                    multiLevelCache.putNull(key, NULL_TTL);
                    return null;
                }

                multiLevelCache.putProduct(key, dbData, PRODUCT_TTL);
                return dbData;
            } else {
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                return multiLevelCache.getProduct(key);
            }
        } finally {
            if (locked) {
                multiLevelCache.unlock(key);
            }
        }
    }

    public void updatePrice(Long id, BigDecimal price) {
        int updated = productRepository.updatePrice(id, price);
        if (updated > 0) {
            String key = buildKey(id);
            redisTemplate.delete(key);
            redisTemplate.convertAndSend(cacheEvictTopic.getTopic(), key);
            multiLevelCache.evictLocal(key);
        }
    }

    private String buildKey(Long id) {
        return "product:" + id;
    }
}

这里的更新逻辑我用了:

  • 先更新 DB
  • 再删 Redis
  • 再发消息删本地缓存
  • 当前实例自己也删一遍本地

这是一个相对务实的做法。
如果你问“能不能完全强一致?”——缓存体系里通常很难,更多是尽量缩小不一致窗口


13. Controller

package com.example.multicache.controller;

import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public Product getById(@PathVariable Long id) {
        return productService.getById(id);
    }

    @PutMapping("/{id}")
    public String updatePrice(@PathVariable Long id, @RequestParam BigDecimal price) {
        productService.updatePrice(id, price);
        return "ok";
    }
}

14. 运行与验证

启动 Redis,再启动应用后,按下面步骤验证。

第一次查询

curl http://localhost:8080/products/1001

预期行为:

  • 本地缓存 miss
  • Redis miss
  • 查 DB
  • 回填 Redis 和本地缓存

第二次查询

curl http://localhost:8080/products/1001

预期行为:

  • 直接命中本地缓存

更新价格

curl -X PUT "http://localhost:8080/products/1001?price=499.00"

预期行为:

  • 更新 DB
  • 删除 Redis key
  • 广播本地缓存失效

再次查询

curl http://localhost:8080/products/1001

预期行为:

  • 重新查库并回填缓存
  • 返回新价格

逐步验证清单

如果你想更系统地确认效果,我建议按这个清单来:

  • 第一次请求是否能查到数据库
  • 第二次请求是否明显更快
  • Redis 中是否存在 product:1001
  • 更新后 Redis key 是否被删除
  • 多实例部署时,其他实例本地缓存是否失效
  • 查询不存在商品时,是否写入空值缓存
  • 高并发压测下,数据库 QPS 是否明显低于请求量

如果你想和 Spring Cache 注解结合

上面的代码是“手工控制型”实现,可读性强,也更适合理解多级缓存原理。
但很多项目已经大量使用 @Cacheable。这时可以有两个方向:

方向一:保留 Spring Cache 注解,底层自定义 Cache

你可以实现自己的 org.springframework.cache.Cache,内部先查 Caffeine,再查 Redis。
这样业务层还能继续写:

@Cacheable(cacheNames = "product", key = "#id")
public Product getById(Long id) {
    return productRepository.findById(id);
}

方向二:读走 Spring Cache,写走显式失效逻辑

也就是:

  • 查询接口继续 @Cacheable
  • 更新接口 @CacheEvict
  • 外加 Redis Pub/Sub 通知清理其他节点本地缓存

我个人经验是:
当缓存策略开始涉及热点保护、空值缓存、随机 TTL、消息广播、多层回填时,显式代码往往比纯注解更好维护。


常见坑与排查

这一节很重要。我自己踩过的坑,基本都集中在这里。

1. 本地缓存命中后一直返回旧数据

现象

数据库已经更新,Redis 也删了,但接口还是查到旧值。

原因

某个实例的 Caffeine 本地缓存没有及时失效。

排查方式

  • 检查 Redis Pub/Sub 消息是否成功发出
  • 检查监听器是否正常消费
  • 检查 key 拼接规则是否完全一致
  • 检查本地缓存 TTL 是否过长

建议

  • 本地缓存 TTL 比 Redis 更短
  • 所有缓存 key 收口在一个统一方法里生成
  • 监听日志打出来,不要闷头 silent fail

2. 更新后偶发读到旧值

原因

这是典型的“删除缓存 + 并发读”窗口问题:

  1. 线程 A 更新 DB
  2. 线程 B 恰好在删除缓存前读到旧缓存
  3. 短时间返回旧数据

解决思路

  • 接受最终一致性
  • 对强一致要求高的数据,不走缓存
  • 或在更新链路增加版本号校验

3. 热点 Key 过期瞬间数据库被打爆

原因

缓存击穿。大量线程同时发现 key 失效,然后一起查库。

当前示例的处理

  • Redis 短锁
  • 双重检查
  • 未抢到锁的线程短暂 sleep 后重试

更稳妥的升级方案

  • 使用逻辑过期 + 后台异步重建
  • 或基于 Redisson 实现更安全的分布式锁

4. 查询不存在的数据,Redis 压力很大

原因

缓存穿透。每次都 miss,最后都打到 DB。

解决

  • 对空对象做短 TTL 缓存
  • 对非法参数做前置校验
  • 可以加布隆过滤器进一步拦截

5. 某一时刻大量 Key 一起失效

原因

缓存雪崩。TTL 完全相同。

解决

为 TTL 加随机值,比如 10 分钟 ± 60 秒。

例如:

int randomSeconds = ThreadLocalRandom.current().nextInt(0, 60);
Duration ttl = Duration.ofMinutes(10).plusSeconds(randomSeconds);

6. JSON 反序列化失败

常见原因

  • 实体类字段变更
  • LocalDateTime 默认序列化格式不统一
  • 多服务版本不一致

建议

  • 缓存对象尽量使用稳定 DTO
  • 统一 ObjectMapper 配置
  • 升级字段时尽量向后兼容

一致性边界:什么时候“够用了”?

做缓存最容易陷入一个误区:
想同时要极致性能、极低复杂度、严格强一致。

现实里这三者很难兼得。

可以粗略这么分:

适合本文方案的场景

  • 商品详情
  • 配置项
  • 用户主页聚合信息
  • 读多写少的字典数据
  • 容忍秒级以内最终一致

不适合只靠本文方案的场景

  • 库存扣减
  • 账户余额
  • 交易状态强校验
  • 风控规则实时判定

这类数据如果一致性优先,往往要把缓存降级为“辅助读优化”,甚至直接绕过缓存。


安全/性能最佳实践

1. 分布式锁删除时要校验 owner

上面的 unlock 示例为了易懂,直接删了 key。
生产环境更安全的做法是:

  • 加锁时写入唯一 requestId
  • 解锁时 Lua 脚本判断 value 是否匹配
  • 只删除自己的锁

否则极端情况下可能误删别人的锁。


2. Redis 不要当永久存储

缓存就是缓存。
建议:

  • 必配 TTL
  • 明确容量上限与淘汰策略
  • 监控命中率、内存、慢查询

3. 本地缓存要限制大小

Caffeine 非常快,但不是无限大。
如果 maximumSize 不设,热点系统很容易把 JVM 内存吃爆。


4. 热点缓存 TTL 分层

可以按数据热度分层:

  • 超热点:本地 1030 秒,Redis 510 分钟
  • 普通热点:本地 3060 秒,Redis 1030 分钟
  • 冷数据:只放 Redis 或干脆不放本地

这比“一刀切 TTL”效果更好。


5. 监控指标一定要补齐

至少要有:

  • 本地缓存命中率
  • Redis 命中率
  • DB 回源次数
  • 热点 key 排行
  • 缓存失效消息消费成功率
  • 接口 TP99 / TP999

很多缓存问题不是“代码有 bug”,而是没有监控,等出事才猜。


6. 大 Value 不要进本地缓存

如果一个对象几十 KB 甚至更大,本地缓存收益会迅速下降,还可能造成:

  • GC 压力变大
  • 序列化耗时增大
  • 网络与内存双重浪费

建议只缓存高频、小而稳定的数据。


7. 使用统一 Key 规范

例如:

product:{id}
user:profile:{userId}
config:{group}:{key}

统一好处很大:

  • 便于排查
  • 便于批量分析
  • 避免不同模块 key 冲突

8. 谨慎使用 @Cacheable(sync = true)

很多人看到它会以为能直接解决击穿。
它只能在单机方法级别做同步,对多实例下的 Redis 热点问题帮助有限,不能替代分布式锁或逻辑过期方案。


进阶优化建议

如果你准备把这套方案真正用于线上,我建议继续往下做三步升级:

升级 1:TTL 随机化

避免批量同时过期。

升级 2:缓存失效消息持久化

Redis Pub/Sub 很轻,但它不是可靠消息
如果某个实例重启中,消息可能错过。
更稳妥的方式:

  • Redis Stream
  • Kafka
  • RocketMQ

这样可以做消费重试与补偿。

升级 3:逻辑过期

对于极热点 key,不直接依赖物理 TTL 过期,而是:

  • 缓存数据里带逻辑过期时间
  • 访问时即便过期,也先返回旧值
  • 后台线程异步刷新

这能显著降低击穿概率,适合首页、榜单、推荐位等场景。


多级缓存状态变化示意

stateDiagram-v2
    [*] --> Miss
    Miss --> LocalHit: L1命中
    Miss --> RedisHit: L1未命中/L2命中
    Miss --> DbLoad: L1未命中/L2未命中
    RedisHit --> RefillLocal: 回填本地
    RefillLocal --> Served
    DbLoad --> RefillRedis
    RefillRedis --> RefillLocal
    LocalHit --> Served
    Served --> [*]

总结

这篇文章我们完成了一套比较实用的方案:

  • Caffeine 做本地一级缓存
  • Redis 做共享二级缓存
  • Cache Aside 模式组织读写
  • 空值缓存 防穿透
  • 分布式短锁 防击穿
  • Redis Pub/Sub 做跨实例本地缓存失效通知

如果你只记住几个关键点,我建议是这 5 条:

  1. 先本地、再 Redis、最后数据库,热点性能会明显改善
  2. 更新优先删缓存,不要迷恋“更新缓存”
  3. 本地缓存 TTL 要短于 Redis TTL
  4. 跨节点本地缓存一定要有失效通知机制
  5. 不要追求缓存绝对强一致,要明确业务边界

最后给一个落地建议:

  • 如果你的系统是中等流量、读多写少、允许短暂最终一致,本文方案非常合适
  • 如果你的系统是超高并发热点场景,请继续引入逻辑过期、可靠消息、分布式锁增强
  • 如果你的数据是交易核心数据,缓存只能做辅助优化,不要承担一致性主链路

缓存从来不是“加个注解就结束”的事。真正有价值的地方,恰恰在这些边界条件和细节处理上。希望这篇文章能帮你把多级缓存真正跑起来,而不是停留在概念图上。


分享到:

上一篇
《Java开发踩坑实战:排查并修复线程池误用导致的内存暴涨与请求超时问题》
下一篇
《自动化测试中的稳定性治理实战:定位并消除 Flaky Test 的方法与工具链设计》