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

《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:提升接口性能与一致性控制》

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

Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:提升接口性能与一致性控制

很多团队一开始做缓存,通常只有一层 Redis:先查 Redis,没有再查数据库。这个方案足够常见,也足够好用。
但当接口 QPS 上来、热点数据集中、网络抖动变多时,只有 Redis 这一层往往会暴露几个问题:

  • 每次请求都要走一次网络
  • Redis 扛住了数据库压力,但自己也可能成为瓶颈
  • 缓存更新时,多个节点之间容易出现短暂不一致
  • 热点 key 失效瞬间,容易打穿到数据库

这篇文章我不想只讲概念,而是带你从一个可运行的 Spring Boot 示例入手,做一个本地缓存(Caffeine) + Redis 分布式缓存的多级缓存方案,并结合 Spring Cache 做统一接入。重点会放在两件事上:

  1. 怎么提升接口性能
  2. 怎么控制缓存一致性边界

如果你已经会用 Spring Boot 和 Redis,那么本文正适合你继续往前走一步。


背景与问题

先看一个典型场景:商品详情接口。

业务特点通常是这样的:

  • 读多写少
  • 热点商品访问集中
  • 对实时性有要求,但不是“强一致到毫秒级”
  • 希望改造成本低,尽量沿用 Spring Cache 注解能力

如果只有数据库:

  • QPS 一高,数据库先吃不消

如果只有 Redis:

  • DB 压力降了,但应用每次仍要远程访问 Redis
  • 热点 key 会集中打到 Redis
  • Redis 失效时,可能形成瞬时流量穿透

这时多级缓存的价值就出来了:

  • 一级缓存(L1):应用内本地缓存,命中快,纳秒/微秒级
  • 二级缓存(L2):Redis,跨实例共享
  • 数据源:数据库或下游服务

目标是:

  • 常见热点查询命中本地缓存,减少网络开销
  • 应用实例间通过 Redis 共享数据
  • 数据更新时,通过统一失效策略尽量控制不一致窗口

前置知识与环境准备

本文示例使用:

  • JDK 17
  • Spring Boot 3.x
  • Spring Cache
  • Redis
  • Caffeine
  • Maven

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-validation</artifactId>
    </dependency>
</dependencies>

核心原理

先别急着写代码,先把访问路径理顺。

多级缓存访问流程

  1. 先查本地缓存 Caffeine
  2. 本地没命中,再查 Redis
  3. Redis 没命中,再查数据库
  4. 查到结果后,回填 Redis 和本地缓存
  5. 更新数据时,先更新数据库,再删除/刷新缓存

对应关系如下:

flowchart TD
    A[请求到达] --> B{L1 本地缓存命中?}
    B -- 是 --> C[直接返回]
    B -- 否 --> D{L2 Redis 命中?}
    D -- 是 --> E[回填 L1 并返回]
    D -- 否 --> F[查询数据库]
    F --> G[写入 Redis]
    G --> H[写入 L1]
    H --> I[返回结果]

为什么不用“只靠 Spring Cache 默认实现”

Spring Cache 本质上是一个缓存抽象,它很好用,但默认更偏向“单缓存管理器”思路。
如果要做真正的多级缓存,常见做法有两种:

  1. 业务代码里手动写 L1/L2 逻辑
  2. 自定义 Cache / CacheManager,把多级逻辑封装进去

本文采用第二种:
对业务层依然暴露 @Cacheable / @CacheEvict / @CachePut,但底层实现多级缓存。

这样好处很明显:

  • 业务代码干净
  • 策略统一
  • 后续方便调整 TTL、序列化、统计指标

方案设计

组件职责划分

  • CaffeineCache:本地热点缓存,速度最快
  • RedisCache:分布式共享缓存
  • MultiLevelCache:组合两者,对外表现为一个 Spring Cache
  • MultiLevelCacheManager:统一创建缓存实例

更新一致性策略

这个问题最容易被轻描淡写,但实际最关键。

本文采用的是工程上最常见、性价比最高的方案:

  • 读操作:缓存未命中时回源数据库
  • 写操作:先更新数据库,再删除 L1/L2 缓存
  • 不要优先更新缓存值,而是优先失效缓存

原因很简单:

  • 更新缓存值涉及并发覆盖、序列化差异、局部字段更新等复杂问题
  • 删除缓存更稳妥,后续读取自然回填
  • 虽然有短暂不一致窗口,但通常是可控的

更新链路如下:

sequenceDiagram
    participant Client as 客户端
    participant App as 应用服务
    participant DB as 数据库
    participant L1 as Caffeine
    participant L2 as Redis

    Client->>App: 更新商品
    App->>DB: UPDATE 商品数据
    DB-->>App: 更新成功
    App->>L1: 删除缓存
    App->>L2: 删除缓存
    App-->>Client: 返回成功

    Client->>App: 查询商品
    App->>L1: get
    L1-->>App: miss
    App->>L2: get
    L2-->>App: miss
    App->>DB: select
    DB-->>App: 商品数据
    App->>L2: put
    App->>L1: put
    App-->>Client: 返回数据

实战代码(可运行)

下面给出一个简化但可运行的示例。为了聚焦缓存逻辑,数据库层我先用内存 Map 模拟,你可以很容易替换成 MyBatis/JPA。


1. 配置文件

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 3000ms

server:
  port: 8080

cache:
  caffeine:
    maximum-size: 1000
    expire-after-write: 60s
  redis:
    ttl: 300s

2. 启动类

package com.example.multicache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

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

3. 实体类

package com.example.multicache.model;

import java.io.Serializable;
import java.math.BigDecimal;

public class Product implements Serializable {
    private Long id;
    private String name;
    private BigDecimal price;
    private Long version;

    public Product() {
    }

    public Product(Long id, String name, BigDecimal price, Long version) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.version = version;
    }

    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 Long getVersion() {
        return version;
    }

    public void setVersion(Long version) {
        this.version = version;
    }
}

4. 模拟 Repository

package com.example.multicache.repository;

import com.example.multicache.model.Product;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class ProductRepository {

    private final Map<Long, Product> storage = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        storage.put(1L, new Product(1L, "iPhone", new BigDecimal("6999.00"), 1L));
        storage.put(2L, new Product(2L, "MacBook", new BigDecimal("12999.00"), 1L));
    }

    public Product findById(Long id) {
        sleep(100);
        return storage.get(id);
    }

    public Product save(Product product) {
        sleep(100);
        Product old = storage.get(product.getId());
        long version = old == null ? 1L : old.getVersion() + 1;
        product.setVersion(version);
        storage.put(product.getId(), product);
        return product;
    }

    private void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

5. Redis 配置

package com.example.multicache.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.time.Duration;

@ConfigurationProperties(prefix = "cache")
public class CacheProperties {
    private Caffeine caffeine = new Caffeine();
    private Redis redis = new Redis();

    public Caffeine getCaffeine() {
        return caffeine;
    }

    public void setCaffeine(Caffeine caffeine) {
        this.caffeine = caffeine;
    }

    public Redis getRedis() {
        return redis;
    }

    public void setRedis(Redis redis) {
        this.redis = redis;
    }

    public static class Caffeine {
        private long maximumSize = 1000;
        private Duration expireAfterWrite = Duration.ofSeconds(60);

        public long getMaximumSize() {
            return maximumSize;
        }

        public void setMaximumSize(long maximumSize) {
            this.maximumSize = maximumSize;
        }

        public Duration getExpireAfterWrite() {
            return expireAfterWrite;
        }

        public void setExpireAfterWrite(Duration expireAfterWrite) {
            this.expireAfterWrite = expireAfterWrite;
        }
    }

    public static class Redis {
        private Duration ttl = Duration.ofMinutes(5);

        public Duration getTtl() {
            return ttl;
        }

        public void setTtl(Duration ttl) {
            this.ttl = ttl;
        }
    }
}
package com.example.multicache.config;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.*;

@Configuration
@EnableConfigurationProperties(CacheProperties.class)
public class CacheConfig {

    @Bean
    public RedisCacheConfigurationSupport redisCacheConfigurationSupport(
            RedisConnectionFactory connectionFactory,
            CacheProperties cacheProperties
    ) {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );

        RedisSerializer<Object> serializer = new GenericJackson2JsonRedisSerializer(objectMapper);

        return new RedisCacheConfigurationSupport(
                RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory),
                serializer,
                cacheProperties
        );
    }

    @Bean
    public CacheManager cacheManager(RedisCacheConfigurationSupport support) {
        return new MultiLevelCacheManager(support);
    }
}

6. 多级缓存实现

package com.example.multicache.config;

import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.serializer.RedisSerializer;

public class RedisCacheConfigurationSupport {
    private final RedisCacheWriter redisCacheWriter;
    private final RedisSerializer<Object> valueSerializer;
    private final CacheProperties cacheProperties;

    public RedisCacheConfigurationSupport(
            RedisCacheWriter redisCacheWriter,
            RedisSerializer<Object> valueSerializer,
            CacheProperties cacheProperties
    ) {
        this.redisCacheWriter = redisCacheWriter;
        this.valueSerializer = valueSerializer;
        this.cacheProperties = cacheProperties;
    }

    public RedisCacheWriter getRedisCacheWriter() {
        return redisCacheWriter;
    }

    public RedisSerializer<Object> getValueSerializer() {
        return valueSerializer;
    }

    public CacheProperties getCacheProperties() {
        return cacheProperties;
    }
}
package com.example.multicache.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.lang.NonNull;

import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class MultiLevelCacheManager implements CacheManager {

    private final RedisCacheConfigurationSupport support;
    private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();

    public MultiLevelCacheManager(RedisCacheConfigurationSupport support) {
        this.support = support;
    }

    @Override
    public Cache getCache(@NonNull String name) {
        return cacheMap.computeIfAbsent(name, this::createCache);
    }

    private Cache createCache(String name) {
        com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache =
                Caffeine.newBuilder()
                        .maximumSize(support.getCacheProperties().getCaffeine().getMaximumSize())
                        .expireAfterWrite(support.getCacheProperties().getCaffeine().getExpireAfterWrite())
                        .build();

        return new MultiLevelCache(
                name,
                caffeineCache,
                support.getRedisCacheWriter(),
                support.getValueSerializer(),
                support.getCacheProperties().getRedis().getTtl()
        );
    }

    @Override
    public Collection<String> getCacheNames() {
        return Collections.unmodifiableSet(cacheMap.keySet());
    }
}
package com.example.multicache.config;

import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.Callable;

public class MultiLevelCache extends AbstractValueAdaptingCache {

    private final String name;
    private final Cache<Object, Object> localCache;
    private final RedisCacheWriter redisCacheWriter;
    private final RedisSerializer<Object> valueSerializer;
    private final Duration ttl;

    public MultiLevelCache(
            String name,
            Cache<Object, Object> localCache,
            RedisCacheWriter redisCacheWriter,
            RedisSerializer<Object> valueSerializer,
            Duration ttl
    ) {
        super(true);
        this.name = name;
        this.localCache = localCache;
        this.redisCacheWriter = redisCacheWriter;
        this.valueSerializer = valueSerializer;
        this.ttl = ttl;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public Object getNativeCache() {
        return this;
    }

    @Override
    protected Object lookup(@NonNull Object key) {
        Object localValue = localCache.getIfPresent(key);
        if (localValue != null) {
            return localValue;
        }

        byte[] redisKey = buildKey(key);
        byte[] bytes = redisCacheWriter.get(name, redisKey);
        if (bytes != null) {
            Object value = valueSerializer.deserialize(bytes);
            localCache.put(key, toStoreValue(value));
            return value;
        }
        return null;
    }

    @Override
    public <T> T get(@NonNull Object key, Callable<T> valueLoader) {
        ValueWrapper wrapper = get(key);
        if (wrapper != null) {
            return (T) wrapper.get();
        }
        try {
            T value = valueLoader.call();
            put(key, value);
            return value;
        } catch (Exception e) {
            throw new ValueRetrievalException(key, valueLoader, e);
        }
    }

    @Override
    public void put(@NonNull Object key, @Nullable Object value) {
        Object storeValue = toStoreValue(value);
        localCache.put(key, storeValue);

        byte[] redisKey = buildKey(key);
        byte[] redisValue = valueSerializer.serialize(fromStoreValue(storeValue));
        redisCacheWriter.put(name, redisKey, redisValue, ttl);
    }

    @Override
    public ValueWrapper putIfAbsent(@NonNull Object key, @Nullable Object value) {
        ValueWrapper existing = get(key);
        if (existing == null) {
            put(key, value);
        }
        return existing;
    }

    @Override
    public void evict(@NonNull Object key) {
        localCache.invalidate(key);
        redisCacheWriter.remove(name, buildKey(key));
    }

    @Override
    public void clear() {
        localCache.invalidateAll();
    }

    private byte[] buildKey(Object key) {
        return (name + "::" + key).getBytes(StandardCharsets.UTF_8);
    }
}

这里有个边界要说明:
clear() 这里只清了本地缓存,没有全量清 Redis。生产上如果要支持按 cacheName 清理 Redis,需要结合 key 前缀扫描或统一 key namespace 设计,但要谨慎使用,避免 SCAN 带来额外压力。


7. 业务 Service

package com.example.multicache.service;

import com.example.multicache.model.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

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

    @CacheEvict(cacheNames = "product", key = "#product.id")
    public Product update(Product product) {
        return productRepository.save(product);
    }
}

这里采用的是“更新数据库后删除缓存”的思路。
因为 @CacheEvict 默认在方法成功返回后执行,所以比较符合我们的预期。


8. Controller

package com.example.multicache.controller;

import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

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

    private final ProductService productService;

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

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

    @PutMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestBody @Valid UpdateProductRequest request) {
        Product product = new Product();
        product.setId(id);
        product.setName(request.getName());
        product.setPrice(request.getPrice());
        return productService.update(product);
    }

    public static class UpdateProductRequest {
        @NotNull
        private String name;

        @NotNull
        private BigDecimal price;

        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;
        }
    }
}

逐步验证清单

跑起来后,建议你按下面顺序验证,我平时也是这么检查的。

1. 首次查询走数据库

curl http://localhost:8080/products/1

第一次会比较慢,因为会 sleep 100ms 模拟数据库查询。

2. 第二次查询命中本地缓存

再次执行:

curl http://localhost:8080/products/1

这次通常明显更快。

3. 重启应用后验证 Redis 命中

  • 第一次请求让 Redis 中有数据
  • 重启应用
  • 再次请求

此时本地缓存已空,但 Redis 仍在,应该走 Redis 再回填本地缓存。

4. 更新后验证缓存失效

curl -X PUT http://localhost:8080/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"iPhone 16","price":7999.00}'

然后再次查询:

curl http://localhost:8080/products/1

应该拿到新值,并重新写回缓存。


多级缓存一致性控制:你真正要关心什么

很多文章喜欢说“保证缓存一致性”,但如果你做过线上业务,就会知道这句话其实要加限定词。
大部分互联网业务做的是最终一致性,而不是强一致性。

常见一致性问题

1. 更新数据库成功,但删除缓存失败

这是最常见的问题之一。
结果是旧缓存继续存在,直到 TTL 到期。

解决思路:

  • 删除缓存失败时重试
  • 结合消息队列做异步补偿
  • 关键数据增加短 TTL
  • 给缓存值带版本号或更新时间,读时做兜底校验

2. 并发读写导致脏数据回填

场景大致是:

  1. 线程 A 查询旧数据
  2. 线程 B 更新数据库并删缓存
  3. 线程 A 把旧数据重新写回缓存

这就是经典的“旧值回填”。

可以用下面的思路降低风险:

  • 写后删除缓存,并延迟二次删除
  • 使用版本号校验
  • 对极热点 key 做互斥更新
  • 缩短热点缓存 TTL

下面是这个问题的时序图:

sequenceDiagram
    participant A as 查询线程A
    participant B as 更新线程B
    participant DB as 数据库
    participant Cache as 缓存

    A->>DB: 查询旧数据
    B->>DB: 更新新数据
    B->>Cache: 删除缓存
    DB-->>A: 返回旧数据
    A->>Cache: 回填旧数据

3. 多实例本地缓存不一致

你有 3 个应用节点,每个节点都有自己的 Caffeine。
某个节点更新后只删除了自己的 L1,本地缓存就会不一致。

这也是多级缓存落地时最容易被忽略的点。

解决办法通常有三类:

  • 简单方案:L1 TTL 设短一些,接受短时不一致
  • 进阶方案:通过 Redis Pub/Sub 广播失效消息,所有节点同步清理本地缓存
  • 重型方案:引入专门缓存同步组件

本文示例为了保持可运行和聚焦核心逻辑,没有把 Pub/Sub 展开。但生产上如果你真要大规模上多级缓存,我建议把它补上。


常见坑与排查

这部分很重要,因为“代码能跑”和“线上稳定”是两回事。

坑 1:Spring Cache 注解不生效

典型原因:

  • 没加 @EnableCaching
  • 方法是 private
  • 同类内部方法调用,绕过了代理
  • 异常导致 @CacheEvict 没执行

排查建议:

@Cacheable(cacheNames = "product", key = "#id")
public Product getById(Long id) {
    System.out.println("load from repository...");
    return productRepository.findById(id);
}

如果第二次调用仍打印日志,大概率缓存没生效。


坑 2:序列化失败或反序列化类型异常

常见报错:

  • SerializationException
  • ClassCastException

原因通常是:

  • Redis 使用了 JDK 序列化,版本升级不兼容
  • 不同服务对同一个 key 的序列化方式不一致
  • 泛型对象反序列化丢类型信息

建议:

  • 统一使用 JSON 序列化
  • 不同服务共享缓存时,提前约定 key 和 value 格式
  • 复杂对象不要随意改字段语义

坑 3:缓存穿透

请求的数据本来就不存在,例如 id = 99999999

如果每次都查不到,那就会次次打到数据库。

处理方式:

  • 对空值做短 TTL 缓存
  • 接口层做参数校验
  • 对恶意 ID 做限流或拦截

如果你决定缓存空值,要特别注意 TTL 要短,避免误伤“刚新增数据”。


坑 4:缓存雪崩

大量 key 在同一时刻过期,流量瞬间回源数据库。

建议:

  • TTL 加随机抖动
  • 热点 key 永不过期 + 主动刷新
  • 数据库前面加互斥锁或单飞机制

坑 5:热点 key 击穿

某个超级热点 key 失效后,大量请求同时回源。

解决方式:

  • 单 key 互斥重建
  • 使用逻辑过期
  • 后台异步刷新

Spring Cache 默认不直接解决这个问题,必要时要自己扩展。


安全/性能最佳实践

这一节给一些更偏线上实践的建议,尽量务实。

1. Redis key 设计要可读、可治理

建议格式:

{应用名}:{业务}:{环境}:{主键}

例如:

mall:product:prod:1

好处:

  • 方便排查
  • 避免多系统 key 冲突
  • 方便迁移和清理

2. 本地缓存不要设太大

Caffeine 很快,但它吃的是 JVM 堆内存。
本地缓存一旦设太大,会带来:

  • Full GC 风险增加
  • 内存挤占业务对象
  • 热点不明显时收益有限

经验建议:

  • 只缓存真正热点数据
  • 先从几千到几万条量级试
  • 用监控看命中率和堆使用率,不要拍脑袋

3. TTL 分层设计

不要所有缓存都 5 分钟。
不同业务应该有不同生命周期:

  • 商品详情:几分钟到十几分钟
  • 用户权限:几十秒到几分钟
  • 配置字典:更长
  • 空值缓存:10~60 秒

如果 TTL 一刀切,通常不是最优。


4. 给缓存加监控

至少关注这些指标:

  • L1 命中率
  • L2 命中率
  • 数据库回源次数
  • Redis RTT
  • 缓存序列化耗时
  • 缓存大小与驱逐次数

如果没有这些指标,你很难知道: “是多级缓存真的优化了性能,还是只是让系统更复杂了。”


5. 不要缓存敏感数据明文

如果缓存内容包含:

  • token
  • 身份信息
  • 权限数据
  • 手机号、身份证号等隐私字段

至少要考虑:

  • 是否真的需要缓存
  • 是否脱敏
  • 是否需要加密
  • Redis 访问权限是否隔离
  • key 是否会泄露业务语义

缓存不是天然安全区,这点特别容易被忽略。


6. 多节点场景下补齐 L1 失效广播

如果系统实例较多,而你又高度依赖本地缓存命中率,我建议加一层本地缓存失效通知。
典型做法:

  • 更新后删除 Redis
  • 发布失效事件到 Redis Pub/Sub
  • 所有应用实例订阅并删除自己的 Caffeine key

结构如下:

flowchart LR
    A[实例A 更新数据] --> B[更新DB]
    B --> C[删除Redis缓存]
    C --> D[发布失效消息]
    D --> E[实例A 清理L1]
    D --> F[实例B 清理L1]
    D --> G[实例C 清理L1]

这一步不是“必须第一天就做”,但如果你要把多级缓存用于核心接口,它通常值得做。


适用边界与取舍分析

多级缓存不是银弹,我建议你先看业务是否适合。

适合的场景

  • 读多写少
  • 热点明显
  • 允许秒级以内最终一致
  • 接口对 RT 很敏感
  • 应用实例较多,Redis 压力偏高

不太适合的场景

  • 强一致要求极高
  • 写非常频繁
  • 数据变化后必须全节点瞬时一致
  • 缓存对象过大,序列化成本高
  • 热点不明显,本地缓存收益小

如果是库存、余额、强事务链路这类场景,我通常不会优先推荐这套方案。


一个更稳的生产落地建议

如果你准备把本文方案带到生产,我建议按下面节奏推进:

  1. 第一阶段:先上 Redis 单层缓存,跑通指标
  2. 第二阶段:给热点接口加 Caffeine 本地缓存
  3. 第三阶段:补齐 L1 失效广播
  4. 第四阶段:增加互斥重建、空值缓存、TTL 抖动
  5. 第五阶段:完善监控、压测、故障演练

这样做的好处是:
你不会在第一天就把系统搞得非常复杂,但每一步都能看到明确收益。


总结

这篇文章我们完成了一套基于 Spring Cache + Redis + Caffeine 的多级缓存实战,核心思路可以概括成几句话:

  • L1 本地缓存负责快
  • L2 Redis 负责共享
  • 数据库负责最终真实数据
  • 写操作优先更新 DB,再删除缓存
  • 一致性目标是可控的最终一致,不要幻想零成本强一致

如果你现在要落地,我给你三个直接可执行的建议:

  1. 先从读多写少的热点接口开始,别一上来全站铺开
  2. 先实现失效而不是复杂更新,删除缓存通常比更新缓存更稳
  3. 一定补监控和压测,不然你不知道多级缓存到底是在提速还是在制造复杂度

最后再强调一个边界:
多级缓存很适合“性能优化型问题”,但它不是所有一致性问题的终极答案。
当业务进入强一致、高并发写入、跨节点同步极敏感的场景时,应该优先回到业务建模、事务边界和数据架构本身,而不是把希望全押在缓存上。

如果你愿意在这个基础上继续往前一步,下一个很自然的演进方向就是:给 L1 增加 Redis Pub/Sub 失效广播,以及为热点 key 增加互斥重建机制。 这两步做完,线上可用性会明显更稳。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》