背景与问题
在大多数 Spring Boot 业务系统里,缓存几乎都是“性能优化第一刀”。
尤其是读多写少的场景:商品详情、用户画像、配置字典、活动规则、文章内容……如果每次都打数据库,系统在高并发下很快会遇到几个典型问题:
- 数据库 QPS 顶不住
- 热点数据被频繁读取,RT 抖动明显
- 某些 key 失效瞬间,大量请求直接冲击 DB,形成缓存击穿
- 批量过期或者 Redis 抖动时,造成缓存雪崩
- 恶意查询不存在的数据,形成缓存穿透
- 更麻烦的是:缓存和数据库不一致
很多团队上来就一句话:“加个 Redis 缓存就好了。”
但真到线上,高并发场景里真正难的,从来不是把缓存“用起来”,而是把它用稳、用准、用得可控。
这篇文章我会从 Spring Boot + Spring Cache + Redis 的组合出发,带你做一套能跑、能扩展、也能解释一致性边界的实践方案。重点不只是注解怎么写,而是:
- 为什么这么设计
- 在高并发下会发生什么
- 出问题时怎么排查
- 哪些一致性问题能解决,哪些只能降低概率
方案全景与取舍分析
先给出一个结论: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 + 删除缓存”之后,如果担心旧读回填,可以加一次延迟删除:
- 先更新 DB
- 删除缓存
- sleep 几百毫秒
- 再删一次缓存
伪代码:
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. 版本号思路:避免旧值覆盖新值
如果你的数据对象天然有 version 或 updateTime,可以在回填缓存时做版本判断。
这招比较适合手写缓存逻辑,不太适合纯 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 没删掉,而是几类问题之一:
- 请求命中了应用本地缓存
- 主从复制延迟,读到从库旧数据
- 删除缓存后,旧读请求又把旧值回填了
- 多服务间缓存空间不一致
定位路径
- 先看 DB 值是否已更新
- 再看 Redis key 是否还存在
- 看应用日志是否有重新回填动作
- 确认是否读写分离导致从库旧读
- 确认是否存在本地缓存或网关缓存
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 做高并发缓存,是一条非常实用的工程路径:
- 它能快速提升读性能
- 能明显减轻数据库压力
- 对中多数业务场景足够友好
但要记住一个现实:
缓存一致性不是“绝对正确”,而是“在成本可接受的前提下,把错误窗口压到足够小”。
如果你只记三条,我建议记这三条:
- 查询走
@Cacheable,更新后优先删缓存,不要轻易迷信更新缓存 - 高并发下要补上防击穿、防穿透、防雪崩,而不是只靠 Redis 顶着
- 一致性问题本质是并发竞争问题,要用 TTL、双删、互斥重建、消息失效等组合拳
最后给一个很实际的边界建议:
- 如果业务允许秒级甚至分钟级最终一致,Spring Cache + Redis 很适合
- 如果业务要求强一致、读写严格同步,缓存只能谨慎使用,甚至不该放在关键链路上
把缓存当成性能层,而不是真相层,这件事想明白了,后面的设计就会稳很多。