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

《Spring Boot 中基于 Spring Cache + Redis 实现高并发缓存设计与常见一致性问题实战》

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

背景与问题

在大多数 Spring Boot 业务系统里,缓存几乎都是“性能优化第一刀”。

尤其是读多写少的场景:商品详情、用户画像、配置字典、活动规则、文章内容……如果每次都打数据库,系统在高并发下很快会遇到几个典型问题:

  • 数据库 QPS 顶不住
  • 热点数据被频繁读取,RT 抖动明显
  • 某些 key 失效瞬间,大量请求直接冲击 DB,形成缓存击穿
  • 批量过期或者 Redis 抖动时,造成缓存雪崩
  • 恶意查询不存在的数据,形成缓存穿透
  • 更麻烦的是:缓存和数据库不一致

很多团队上来就一句话:“加个 Redis 缓存就好了。”
但真到线上,高并发场景里真正难的,从来不是把缓存“用起来”,而是把它用稳、用准、用得可控

这篇文章我会从 Spring Boot + Spring Cache + Redis 的组合出发,带你做一套能跑、能扩展、也能解释一致性边界的实践方案。重点不只是注解怎么写,而是:

  1. 为什么这么设计
  2. 在高并发下会发生什么
  3. 出问题时怎么排查
  4. 哪些一致性问题能解决,哪些只能降低概率

方案全景与取舍分析

先给出一个结论:Spring Cache 很适合做“业务缓存接入层”,Redis 负责承载热点数据,而缓存一致性要靠策略组合,而不是单点技巧。

为什么选 Spring Cache

Spring Cache 的优势很明显:

  • 对业务代码侵入低
  • 注解式接入成本小
  • 可以统一缓存 key、TTL、序列化方式
  • 后续替换底层实现相对容易

它很适合以下场景:

  • 查询结果缓存
  • 读多写少对象缓存
  • 配置/字典类缓存
  • 单体到微服务阶段的统一缓存接入

但它也有边界:

  • 不适合做复杂缓存编排
  • 不适合做强一致性保证
  • 对热点 key 防护、分布式锁、异步重建等能力,需要额外补

常见方案对比

方案优点缺点适用场景
纯手写 RedisTemplate灵活、可控样板代码多,维护成本高高定制缓存逻辑
Spring Cache + Redis开发快、统一性强高级策略需要扩展大多数业务查询缓存
本地缓存 + Redis 二级缓存性能更好一致性更复杂热点极高、低延迟要求
Canal/CDC 驱动缓存失效自动化强链路复杂、运维成本高中大型系统、跨服务数据同步

本文重点讨论第二种:Spring Cache + Redis,并补齐高并发设计中的关键细节。


核心原理

1. 基本读写路径

最基础的缓存设计可以概括为:

  • 读:先查缓存,没命中再查 DB,并回填缓存
  • 写:先更新 DB,再删除缓存

为什么不是“先更新缓存,再更新 DB”?
因为缓存本质上更适合做派生数据,DB 才是事实来源。先写库再删缓存,是业内最常见且相对稳妥的方案。

flowchart TD
    A[请求读取数据] --> B{Redis命中?}
    B -- 是 --> C[直接返回缓存]
    B -- 否 --> D[查询数据库]
    D --> E{数据存在?}
    E -- 是 --> F[写入Redis并设置TTL]
    E -- 否 --> G[写入空值短TTL或布隆过滤拦截]
    F --> H[返回结果]
    G --> H

2. Spring Cache 的工作方式

Spring Cache 本质上是基于 AOP 对方法进行增强。最常用的几个注解:

  • @Cacheable:查缓存,未命中则执行方法并写入缓存
  • @CachePut:执行方法,并强制更新缓存
  • @CacheEvict:删除缓存
  • @Caching:组合多个缓存操作

但在实际项目里,我一般会建议:

  • 查询用 @Cacheable
  • 更新/删除后优先用 @CacheEvict 清理缓存
  • 少用 @CachePut 做“更新 DB 同时更新缓存”,因为它容易让逻辑看起来一致,实际上却放大并发竞争窗口

3. 高并发下的一致性问题从哪来

缓存一致性不是“缓存值和数据库永远相同”,而是:

在你能接受的时间窗口内,缓存与数据库偏差可控,且不会持续错误。

最常见的竞争场景如下。

sequenceDiagram
    participant C1 as 请求A-读
    participant C2 as 请求B-写
    participant R as Redis
    participant DB as MySQL

    C1->>R: 查询缓存
    R-->>C1: 未命中
    C2->>DB: 更新数据
    DB-->>C2: 更新成功
    C2->>R: 删除缓存
    R-->>C2: 删除成功
    C1->>DB: 查询旧数据(读到旧值)
    DB-->>C1: 返回旧值
    C1->>R: 回填旧值到缓存
    R-->>C1: 成功

这就是经典的**“删缓存后,旧值回填”**问题。

出现它的原因不是“删缓存方案错了”,而是并发窗口真实存在。工程上一般采用几种手段降低概率:

  • 延迟双删
  • 给缓存加合理 TTL
  • 对热点 key 加互斥重建
  • 对重要数据引入消息驱动失效
  • 对一致性要求极高的数据,绕过缓存或缩短缓存时间

4. 容量估算要提前做

很多缓存系统线上变慢,不是代码差,而是容量没估准

一个粗略估算公式:

Redis内存 ≈ key数量 × (key长度 + value序列化后大小 + 元数据开销)

例如:

  • 200 万个商品详情缓存
  • 平均 value 2 KB
  • key 平均 40 B
  • Redis 元数据、哈希桶、碎片等按 30%~50% 预留

大致内存需求:

200万 × 2KB ≈ 4GB
加上 key 和额外开销,实际建议至少预留 6GB~8GB

这只是静态估算。还要考虑:

  • 突发热点
  • 过期抖动
  • 主从复制缓冲
  • AOF / RDB 带来的内存波峰

实战代码(可运行)

下面给出一个可直接落地的 Spring Boot 示例。
示例目标:

  • 使用 Spring Cache + Redis
  • 查询商品详情时自动缓存
  • 更新商品时删除缓存
  • 解决缓存空值、TTL、热点 key 基本问题
  • 给出可扩展的高并发设计点

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-data-redis</artifactId>
    </dependency>

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

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

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

2. application.yml

spring:
  application:
    name: cache-demo
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      timeout: 3000ms
  cache:
    type: redis

server:
  port: 8080

logging:
  level:
    org.springframework.cache: debug

3. 启用缓存

package com.example.cachedemo;

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

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

4. Redis 缓存配置

这里我建议统一做三件事:

  • key 加前缀,避免冲突
  • value 用 JSON 序列化,便于排查
  • 不同缓存空间配置不同 TTL
package com.example.cachedemo.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class CacheConfig extends CachingConfigurerSupport {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        Jackson2JsonRedisSerializer<Object> serializer =
                new Jackson2JsonRedisSerializer<>(Object.class);

        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        serializer.setObjectMapper(mapper);

        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer))
                .prefixCacheNameWith("demo:");

        RedisCacheConfiguration productConfig = defaultConfig.entryTtl(Duration.ofMinutes(30));

        return RedisCacheManager.builder(factory)
                .cacheDefaults(defaultConfig)
                .withCacheConfiguration("product", productConfig)
                .build();
    }
}

这里故意用了 disableCachingNullValues(),因为很多团队不想让 Spring Cache 自动缓存 null。
但这也意味着缓存穿透需要我们手动处理,后面会讲。

5. 实体与模拟 DAO

package com.example.cachedemo.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;
    }
}
package com.example.cachedemo.repository;

import com.example.cachedemo.model.Product;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;

@Repository
public class ProductRepository {

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

    public ProductRepository() {
        storage.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), 1L));
        storage.put(2L, new Product(2L, "显示器", new BigDecimal("1299.00"), 1L));
    }

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

    public Product updatePrice(Long id, BigDecimal newPrice) {
        simulateDbCost();
        Product product = storage.get(id);
        if (product == null) {
            return null;
        }
        product.setPrice(newPrice);
        product.setVersion(product.getVersion() + 1);
        return product;
    }

    private void simulateDbCost() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException ignored) {
        }
    }
}

6. Service:查询缓存与更新删缓存

package com.example.cachedemo.service;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import java.math.BigDecimal;
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 = "#id")
    public Product updatePrice(Long id, BigDecimal newPrice) {
        return productRepository.updatePrice(id, newPrice);
    }
}

这里是最经典的写法:

  • getById():先查缓存,没命中再查库并回填
  • updatePrice():更新 DB 后删除缓存

很多项目到这里就结束了,但在高并发下还不够。

7. Controller

package com.example.cachedemo.controller;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.service.ProductService;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/products")
@Validated
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);
    }

    @PostMapping("/{id}/price")
    public Product updatePrice(@PathVariable Long id,
                               @RequestParam @NotNull @DecimalMin("0.01") BigDecimal price) {
        return productService.updatePrice(id, price);
    }
}

8. 运行与验证

先请求两次:

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

预期现象:

  • 第一次查库并写缓存
  • 第二次直接走 Redis,响应更快

更新价格:

curl -X POST "http://localhost:8080/products/1/price?price=399.00"

再查:

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

预期现象:

  • 更新时删除缓存
  • 下一次查询重新加载最新数据

进阶设计:高并发下怎么把它做稳

上面的代码能跑,但线上高并发会遇到更具体的问题。下面按“真实事故”的思路来讲。

1. 缓存击穿:热点 key 失效瞬间压垮 DB

场景很常见:

  • 商品 1 是爆款
  • Redis 里这个 key 刚好过期
  • 一瞬间 5000 个请求同时进来
  • 全部穿透到 DB

Spring Cache 默认不会帮你解决这个问题。

方案一:互斥锁重建缓存

可以对热点 key 做单飞控制,只允许一个线程去查库,其余线程稍等再读缓存。

示例:

package com.example.cachedemo.service;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import java.time.Duration;
import java.util.Objects;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class ProductHotKeyService {

    private final StringRedisTemplate stringRedisTemplate;
    private final ProductRepository productRepository;

    public ProductHotKeyService(StringRedisTemplate stringRedisTemplate,
                                ProductRepository productRepository) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.productRepository = productRepository;
    }

    public Product getHotProduct(Long id) {
        String lockKey = "lock:product:" + id;
        String cacheKey = "manual:product:" + id;

        String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
        if (cacheValue != null) {
            return Jsons.fromJson(cacheValue, Product.class);
        }

        Boolean locked = stringRedisTemplate.opsForValue()
                .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

        if (Boolean.TRUE.equals(locked)) {
            try {
                cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
                if (cacheValue != null) {
                    return Jsons.fromJson(cacheValue, Product.class);
                }

                Product product = productRepository.findById(id);
                if (product != null) {
                    stringRedisTemplate.opsForValue().set(
                            cacheKey,
                            Jsons.toJson(product),
                            Duration.ofMinutes(30)
                    );
                }
                return product;
            } finally {
                stringRedisTemplate.delete(lockKey);
            }
        }

        try {
            Thread.sleep(50);
        } catch (InterruptedException ignored) {
        }

        cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
        return Objects.isNull(cacheValue) ? null : Jsons.fromJson(cacheValue, Product.class);
    }
}

JSON 工具类:

package com.example.cachedemo.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

public final class Jsons {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    private Jsons() {
    }

    public static String toJson(Object obj) {
        try {
            return MAPPER.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    public static <T> T fromJson(String str, Class<T> clazz) {
        try {
            return MAPPER.readValue(str, clazz);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

方案二:热点数据永不过期 + 异步刷新

如果某些 key 热得离谱,比如首页配置、活动模板、类目树,可以考虑:

  • Redis 逻辑过期
  • 后台线程异步刷新
  • 前台请求先返回旧值,确保可用性

这个策略更偏架构级,不适合所有业务,但对“读性能优先”的场景很好用。

stateDiagram-v2
    [*] --> Available
    Available --> ExpiredLogic: 到达逻辑过期时间
    ExpiredLogic --> Rebuilding: 后台线程抢到重建资格
    ExpiredLogic --> ServingStale: 未抢到重建资格
    ServingStale --> Available: 继续返回旧值
    Rebuilding --> Available: 刷新缓存完成

2. 缓存穿透:查不存在的数据

比如有人一直查 id = -1 或者某个根本不存在的订单号。
因为 DB 中没有,缓存也没有,每次都打到数据库。

应对方式

方式一:缓存空值,TTL 短一点

例如:

  • 不存在的数据也缓存
  • TTL 设 1~5 分钟
  • 避免持续穿透

如果你用 Spring Cache,需要自己决定是否允许缓存 null,或者包装一个特殊对象。

方式二:布隆过滤器

如果 key 空间稳定、数据量很大,可以用布隆过滤器先拦一层:

  • 不存在:大概率直接拦截
  • 存在:再查 Redis / DB

适用于:

  • 商品 ID
  • 用户 ID
  • 券模板 ID

但布隆过滤器有误判率,不能替代 DB 校验。

3. 缓存雪崩:大量 key 同时过期

如果你给一批缓存统一设置 TTL = 30 分钟,那么在某个整点它们可能一起失效。结果就是 Redis 命中率突然下降,DB 压力飙升。

应对方式

  • TTL 加随机值
  • 热点缓存分层
  • 对大批量 key 错峰预热
  • 关键服务限流降级

示例思路:

long ttl = 1800 + ThreadLocalRandom.current().nextLong(300);

也就是让过期时间在 30 分钟上下浮动几分钟,避免集中过期。

4. 双删策略:降低旧值回填概率

在“更新 DB + 删除缓存”之后,如果担心旧读回填,可以加一次延迟删除:

  1. 先更新 DB
  2. 删除缓存
  3. sleep 几百毫秒
  4. 再删一次缓存

伪代码:

public void updateProduct(Long id, BigDecimal price) {
    repository.updatePrice(id, price);
    redisTemplate.delete("product::" + id);

    CompletableFuture.runAsync(() -> {
        try {
            Thread.sleep(500);
        } catch (InterruptedException ignored) {
        }
        redisTemplate.delete("product::" + id);
    });
}

这招有没有用?

有用,但别神化。

它只能降低并发窗口中的脏数据残留概率,不能保证绝对一致。
我个人的经验是:

  • 中等一致性要求:可用
  • 强一致要求:不够
  • 超高并发热点数据:最好结合消息通知或版本控制

5. 版本号思路:避免旧值覆盖新值

如果你的数据对象天然有 versionupdateTime,可以在回填缓存时做版本判断。
这招比较适合手写缓存逻辑,不太适合纯 Spring Cache 注解透明完成。

思路:

  • DB 更新时 version + 1
  • 回填缓存时附带 version
  • 新旧值竞争时,只接受版本更高的数据

这不是所有业务都值得做,但对于“热点对象多线程更新”非常有帮助。


常见坑与排查

这一部分我尽量按“线上问题定位”来写,很多都是我自己或者团队里常踩的坑。

1. @Cacheable 不生效

常见原因

  • 方法不是 public
  • 同类内部自调用,AOP 没有代理到
  • 没加 @EnableCaching
  • key 表达式写错
  • 返回对象序列化失败

排查方式

先确认日志里有没有缓存切面痕迹,再看:

@Service
public class ProductService {

    public Product test(Long id) {
        return getById(id); // 这种内部调用通常不会触发缓存
    }

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

这种情况下,可以:

  • 把缓存方法拆到另一个 Bean
  • 或者从代理对象调用

2. Redis 中 key 看着不对

Spring Cache 默认会拼接缓存名和 key,常见格式像:

demo:product::1

如果你线上排查时按 product:1 去找,当然找不到。

建议:

  • 统一约定 key 规范
  • 线上排查文档里写清楚 Spring Cache 生成规则
  • 必要时自定义 KeyGenerator

3. 反序列化异常

常见报错:

  • 类结构变更后,旧缓存反序列化失败
  • JDK 序列化和 JSON 序列化混用
  • 泛型对象被反序列化成 LinkedHashMap

建议

  • 尽量统一 JSON 序列化
  • 对缓存对象做“向后兼容”设计
  • 发布重大字段变更时,提前清理相关缓存

4. 更新后还是读到旧值

这通常不是 Redis 没删掉,而是几类问题之一:

  • 请求命中了应用本地缓存
  • 主从复制延迟,读到从库旧数据
  • 删除缓存后,旧读请求又把旧值回填了
  • 多服务间缓存空间不一致

定位路径

  1. 先看 DB 值是否已更新
  2. 再看 Redis key 是否还存在
  3. 看应用日志是否有重新回填动作
  4. 确认是否读写分离导致从库旧读
  5. 确认是否存在本地缓存或网关缓存

5. 热点 key 导致 Redis CPU 飙高

有时候不是 DB 扛不住,而是 Redis 本身扛不住。比如超热点 key 被频繁访问,或者大 value 序列化过重。

排查建议

  • redis-cli --hotkeys 看热点
  • 看是否存在大 key
  • 检查 value 是否过大
  • 检查是否频繁全量更新同一个大对象

我的经验是:
缓存不是越“大而全”越好,通常应该缓存“刚好够用”的视图对象。


安全/性能最佳实践

这部分给一组更适合直接落地的建议。

1. Key 设计要可读、可控、可隔离

建议格式:

业务域:对象类型:主键[:扩展维度]

例如:

product:detail:1001
user:profile:20001
config:tenant:300:pay

如果走 Spring Cache,至少保证:

  • cacheName 有明确业务语义
  • key 尽量简单稳定
  • 避免把复杂对象直接拼到 key 里

2. TTL 不要“一刀切”

不同数据 TTL 应不同:

  • 商品详情:10~30 分钟
  • 字典配置:30~120 分钟
  • 风险规则:1~5 分钟
  • 不存在数据空值:1~3 分钟

一句话:TTL 是一致性与性能之间的旋钮。

3. 不要把缓存当数据库

Redis 适合高频访问的派生数据,不适合无限堆业务对象。
要特别关注:

  • 大 key
  • 冷数据长期占内存
  • 大量无效缓存不淘汰

建议定期做:

  • 命中率分析
  • 大 key 扫描
  • 过期键分布分析
  • 缓存空间治理

4. 热点数据考虑分级缓存

如果延迟要求很高,可以采用:

  • JVM 本地缓存(如 Caffeine)
  • Redis 二级缓存
  • DB 兜底

但引入本地缓存以后,一致性更复杂。
所以我的建议是:

  • 先把单层 Redis 缓存做好
  • 只有明确遇到网络延迟瓶颈,再上二级缓存

5. 删除缓存优先于更新缓存

对于大多数业务数据:

  • 推荐:更新 DB 后删除缓存
  • 谨慎:更新 DB 后同步更新缓存

因为后者在高并发时更容易出现覆盖问题,尤其多个写请求同时修改时更明显。

6. 重要缓存操作要有监控

至少要有这些指标:

  • Redis 命中率
  • 缓存加载耗时
  • key 过期数量
  • 热点 key 访问频次
  • DB fallback QPS
  • 缓存重建失败次数

没有监控,缓存问题通常只能靠“用户说慢了”才发现。

7. 防止缓存相关接口被滥用

缓存系统也有安全面:

  • 防止恶意构造大量不存在 key 导致穿透
  • 对关键查询接口做限流
  • 管理类缓存刷新接口要鉴权
  • 不要把敏感信息明文缓存

对于用户隐私、令牌、风控标记等内容,要额外关注:

  • 加密或脱敏
  • TTL 更短
  • 严格控制访问路径

一个更稳的落地建议

如果你现在要在项目里上缓存,我建议按下面顺序推进,而不是一步到位堆满所有技巧。

第一阶段:基础可用

  • Spring Cache + Redis 接入
  • 查询 @Cacheable
  • 更新后 @CacheEvict
  • 区分缓存空间 TTL
  • 统一序列化方式

第二阶段:高并发防护

  • 热点 key 互斥重建
  • 空值缓存防穿透
  • TTL 随机化防雪崩
  • 限流与降级

第三阶段:一致性增强

  • 延迟双删
  • 关键数据异步通知失效
  • 版本号 / 时间戳防旧值覆盖
  • 监控与告警闭环

这个顺序很重要。
因为缓存建设最怕两种极端:

  • 只会贴注解,线上靠运气
  • 一上来就做复杂架构,结果维护成本过高

总结

Spring Boot 里使用 Spring Cache + Redis 做高并发缓存,是一条非常实用的工程路径:

  • 它能快速提升读性能
  • 能明显减轻数据库压力
  • 对中多数业务场景足够友好

但要记住一个现实:

缓存一致性不是“绝对正确”,而是“在成本可接受的前提下,把错误窗口压到足够小”。

如果你只记三条,我建议记这三条:

  1. 查询走 @Cacheable,更新后优先删缓存,不要轻易迷信更新缓存
  2. 高并发下要补上防击穿、防穿透、防雪崩,而不是只靠 Redis 顶着
  3. 一致性问题本质是并发竞争问题,要用 TTL、双删、互斥重建、消息失效等组合拳

最后给一个很实际的边界建议:

  • 如果业务允许秒级甚至分钟级最终一致,Spring Cache + Redis 很适合
  • 如果业务要求强一致、读写严格同步,缓存只能谨慎使用,甚至不该放在关键链路上

把缓存当成性能层,而不是真相层,这件事想明白了,后面的设计就会稳很多。


分享到:

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