背景与问题
在 Spring Boot 项目里,很多人第一次做缓存,往往是这样的路径:
- 先给查询接口加
@Cacheable - 底层接 Redis
- 上线后发现 QPS 是上来了,但问题也跟着来了
典型现象包括:
- 热点 Key 被瞬时打爆,Redis 压力飙升
- 本地 JVM 明明还有大量空闲内存,却每次都要走远程 Redis
- 更新数据库后,缓存偶发读到旧值
- 多实例部署后,A 节点更新了缓存,B 节点仍然读旧数据
- 某个大 Key 过期时,流量瞬间回源数据库
这时候,单一 Redis 缓存通常不够了。更稳妥的思路是:
本地缓存(L1) + Redis 缓存(L2) + 数据库(DB)
同时配合合理的一致性策略、失效策略和防击穿/穿透/雪崩手段。
如果你已经会用 Spring Cache,那么这篇文章重点不是教你“怎么加注解”,而是带你把它真正用到高并发场景里,形成一套能上线、能排障、能扩展的方案。
方案目标与取舍分析
先说结论:多级缓存不是“永远更好”,而是用空间换延迟、用复杂度换吞吐。
为什么要做多级缓存
单 Redis 缓存的问题主要在于:
- 网络 IO 无法避免
- Redis 仍可能成为瓶颈
- 热点请求全打到同一层,集中风险大
引入本地缓存后:
- L1 本地缓存:纳秒到微秒级访问,适合热点数据
- L2 Redis:跨实例共享,容量更大
- DB:最终数据源
典型适用场景
适合做多级缓存的场景:
- 商品详情、类目树、配置项、字典数据
- 用户画像摘要、首页聚合结果
- 热门榜单、推荐结果快照
- 读多写少、允许短暂最终一致
不太适合直接套用的场景:
- 强一致金融数据
- 秒级内频繁更新且读写比接近 1:1
- 大对象且更新成本非常高的数据
方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 仅本地缓存 | 极快、无网络开销 | 多实例不一致、容量受限 | 单机场景、开发环境 |
| 仅 Redis 缓存 | 架构简单、共享一致 | 网络开销、热点集中 | 中小规模读缓存 |
| 本地 + Redis 多级缓存 | 延迟低、抗压强 | 实现复杂、一致性成本高 | 高并发读多写少 |
| 旁路缓存 + MQ 广播失效 | 一致性更好 | 链路更长 | 多实例、高一致要求 |
我的经验是:如果你已经是多实例部署、接口存在明显热点、Redis QPS 有压力,那么多级缓存基本值得做。
核心原理
多级缓存的核心不在“加两层”,而在于读写路径设计与一致性边界控制。
1. 读路径:Cache Aside + 多级查找
标准流程:
- 先查本地缓存 L1
- L1 未命中,再查 Redis L2
- L2 命中后回填 L1
- L2 也未命中,再查数据库
- 数据库查到后写入 L2 和 L1
flowchart TD
A[请求到达] --> B{L1本地缓存命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{L2 Redis命中?}
D -- 是 --> E[回填L1]
E --> C
D -- 否 --> F[查询数据库]
F --> G{查到数据?}
G -- 是 --> H[写入Redis]
H --> I[写入L1]
I --> C
G -- 否 --> J[写入空值缓存/短TTL]
J --> K[返回空结果]
2. 写路径:先更新库,再删除缓存
缓存一致性里,一个常见原则是:
更新数据库后,删除缓存,而不是直接更新缓存。
为什么?
- 直接更新两层缓存容易出现部分成功、部分失败
- 删除缓存更简单,后续读请求可触发重新加载
- 搭配延迟双删或消息通知,可进一步降低脏读概率
典型写流程:
- 更新数据库
- 删除 Redis 缓存
- 删除本地缓存
- 必要时广播其他节点删除本地缓存
sequenceDiagram
participant Client as Client
participant App as App实例A
participant DB as MySQL
participant Redis as Redis
participant MQ as MQ/Redis PubSub
participant AppB as App实例B
Client->>App: 更新请求
App->>DB: update
DB-->>App: success
App->>Redis: delete key
App->>MQ: 发布失效消息
MQ-->>AppB: 通知删除本地缓存
App-->>Client: 返回成功
3. 一致性本质:不是“绝对一致”,而是“可控的不一致窗口”
在分布式系统里,缓存和数据库天然是两套存储。你需要接受一个现实:
- 强一致成本很高
- 大多数业务追求的是最终一致 + 可观测 + 可恢复
所以设计时要明确边界:
- 允许几百毫秒到几秒的不一致吗?
- 是否能接受读到旧值但不能接受写丢失?
- 如果本地缓存和 Redis 短时不一致,业务是否可容忍?
这几个问题,决定了你是用简单删除策略,还是要引入消息队列、版本号、逻辑过期甚至分布式锁。
架构设计:一个可落地的多级缓存模型
这里我给出一个比较实用的中级方案:
- L1:Caffeine 本地缓存
- L2:Redis
- Cache API:Spring Cache
- 一致性通知:Redis Pub/Sub(或 MQ)
- 防击穿:热点 Key 加锁 /
sync = true - 防穿透:空值缓存
- 防雪崩:TTL 随机化
整体架构图
flowchart LR
U[用户请求] --> A[Spring Boot实例1]
U --> B[Spring Boot实例2]
A --> A1[L1 Caffeine]
B --> B1[L1 Caffeine]
A --> R[Redis L2]
B --> R
R --> D[MySQL]
A --> P[Pub/Sub]
B --> P
缓存层职责建议
L1 本地缓存
职责:
- 吸收热点流量
- 降低 Redis 压力
- 提供极低访问延迟
特点:
- 容量小
- TTL 短
- 只存热点、小对象、读多写少数据
L2 Redis
职责:
- 跨实例共享
- 存储相对更大范围的数据
- 缓冲数据库压力
特点:
- TTL 可略长于本地缓存
- 适合做统一缓存层
- 可结合监控分析命中率和热点分布
实战代码(可运行)
下面给出一套简化但能跑的示例。为了突出多级缓存思路,我用一个“商品详情查询”场景来演示。
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-validation</artifactId>
</dependency>
</dependencies>
2. application.yml
server:
port: 8080
spring:
cache:
type: none
redis:
host: 127.0.0.1
port: 6379
timeout: 2000ms
logging:
level:
org.springframework.cache: debug
这里我把 spring.cache.type 设成 none,原因是我们要自己组合多级缓存,而不是只用默认单一实现。
3. 启动类
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);
}
}
4. 实体类
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;
public Product() {
}
public Product(Long id, String name, BigDecimal price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public Product setId(Long id) {
this.id = id;
return this;
}
public String getName() {
return name;
}
public Product setName(String name) {
this.name = name;
return this;
}
public BigDecimal getPrice() {
return price;
}
public Product setPrice(BigDecimal price) {
this.price = price;
return this;
}
}
5. 模拟数据库 Repository
package com.example.multicache.repository;
import com.example.multicache.model.Product;
import org.springframework.stereotype.Repository;
import jakarta.annotation.PostConstruct;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class ProductRepository {
private final Map<Long, Product> db = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
db.put(1L, new Product(1L, "机械键盘", new BigDecimal("399.00")));
db.put(2L, new Product(2L, "显示器", new BigDecimal("1299.00")));
}
public Product findById(Long id) {
simulateSlowQuery();
return db.get(id);
}
public Product updatePrice(Long id, BigDecimal price) {
Product product = db.get(id);
if (product != null) {
product.setPrice(price);
}
return product;
}
private void simulateSlowQuery() {
try {
Thread.sleep(150);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
6. 多级缓存配置
这里我们自定义一个 CacheManager,返回一个组合缓存:先本地、再 Redis。
package com.example.multicache.config;
import com.example.multicache.cache.MultiLevelCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
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.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.*;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.ConcurrentHashMap;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisSerializationContext.SerializationPair<Object> valueSerializationPair =
RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer());
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(valueSerializationPair);
RedisCacheManager redisCacheManager = new RedisCacheManager(
RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory),
redisCacheConfiguration
);
return new CacheManager() {
private final ConcurrentHashMap<String, Cache> cacheMap = new ConcurrentHashMap<>();
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, cacheName -> {
Cache redisCache = redisCacheManager.getCache(cacheName);
return new MultiLevelCache(
cacheName,
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(30))
.build(),
redisCache
);
});
}
@Override
public Collection<String> getCacheNames() {
return Collections.emptyList();
}
};
}
@Bean
public GenericJackson2JsonRedisSerializer redisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
7. 多级缓存实现
package com.example.multicache.cache;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import java.util.concurrent.Callable;
public class MultiLevelCache implements org.springframework.cache.Cache {
private final String name;
private final Cache<Object, Object> localCache;
private final org.springframework.cache.Cache redisCache;
public MultiLevelCache(String name,
Cache<Object, Object> localCache,
org.springframework.cache.Cache redisCache) {
this.name = name;
this.localCache = localCache;
this.redisCache = redisCache;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
public ValueWrapper get(Object key) {
Object localValue = localCache.getIfPresent(key);
if (localValue != null) {
return new SimpleValueWrapper(localValue);
}
ValueWrapper redisValue = redisCache.get(key);
if (redisValue != null && redisValue.get() != null) {
localCache.put(key, redisValue.get());
return redisValue;
}
return null;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(Object key, Class<T> type) {
Object localValue = localCache.getIfPresent(key);
if (localValue != null && type.isInstance(localValue)) {
return (T) localValue;
}
T redisValue = redisCache.get(key, type);
if (redisValue != null) {
localCache.put(key, redisValue);
}
return redisValue;
}
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
Object localValue = localCache.getIfPresent(key);
if (localValue != null) {
return (T) localValue;
}
T redisValue = redisCache.get(key, () -> null);
if (redisValue != null) {
localCache.put(key, redisValue);
return redisValue;
}
try {
T value = valueLoader.call();
if (value != null) {
put(key, value);
}
return value;
} catch (Exception e) {
throw new ValueRetrievalException(key, valueLoader, e);
}
}
@Override
public void put(Object key, Object value) {
localCache.put(key, value);
redisCache.put(key, value);
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
ValueWrapper existing = get(key);
if (existing == null) {
put(key, value);
}
return existing;
}
@Override
public void evict(Object key) {
localCache.invalidate(key);
redisCache.evict(key);
}
@Override
public boolean evictIfPresent(Object key) {
localCache.invalidate(key);
return redisCache.evictIfPresent(key);
}
@Override
public void clear() {
localCache.invalidateAll();
redisCache.clear();
}
@Override
public boolean invalidate() {
localCache.invalidateAll();
return redisCache.invalidate();
}
}
8. Service 层
这里演示 @Cacheable 和 @CacheEvict 的配合。
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;
import java.math.BigDecimal;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = "product", key = "#id", sync = true)
public Product getProduct(Long id) {
System.out.println("load from db, id=" + id);
return productRepository.findById(id);
}
@CacheEvict(cacheNames = "product", key = "#id")
public Product updatePrice(Long id, BigDecimal price) {
return productRepository.updatePrice(id, price);
}
}
这里的 sync = true 很有用:
- 同一个 JVM 内,多个线程同时查同一个未命中 Key
- 只会有一个线程执行加载逻辑
- 能减少热点 Key 在本地层的并发击穿
但注意,它只能约束单实例内,对多实例无效。这个坑很多人第一次会忽略。
9. Controller
package com.example.multicache.controller;
import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import jakarta.validation.constraints.DecimalMin;
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 getById(@PathVariable Long id) {
return productService.getProduct(id);
}
@PutMapping("/{id}/price")
public Product updatePrice(@PathVariable Long id,
@RequestParam @NotNull @DecimalMin("0.01") BigDecimal price) {
return productService.updatePrice(id, price);
}
}
10. 如何验证多级缓存是否生效
你可以这样测试:
第一次查询
curl http://localhost:8080/products/1
预期:
- 控制台输出
load from db, id=1 - 数据来自 DB,随后写入 Redis 和本地缓存
第二次查询
curl http://localhost:8080/products/1
预期:
- 不再输出
load from db - 命中本地缓存或 Redis
更新价格
curl -X PUT "http://localhost:8080/products/1/price?price=499.00"
再次查询
curl http://localhost:8080/products/1
预期:
- 因为
@CacheEvict已删除缓存,重新走 DB 加载新值
进一步增强:跨实例本地缓存一致性
上面的代码已经能跑,但还有一个关键问题:
某个实例删掉了自己的本地缓存,其他实例的本地缓存怎么办?
这就是多级缓存里最常见的一致性问题。
思路:失效通知广播
做法一般有两种:
- Redis Pub/Sub
- MQ(Kafka/RabbitMQ)
当某个节点更新数据后:
- 删除 Redis 缓存
- 发布失效消息
- 所有应用节点收到消息后,删除本地缓存
一个简化的失效消息模型
package com.example.multicache.message;
import java.io.Serializable;
public class CacheMessage implements Serializable {
private String cacheName;
private String key;
public CacheMessage() {
}
public CacheMessage(String cacheName, String key) {
this.cacheName = cacheName;
this.key = key;
}
public String getCacheName() {
return cacheName;
}
public void setCacheName(String cacheName) {
this.cacheName = cacheName;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
}
实际工程里,我更建议:
- 消息体包含
cacheName、key、version、timestamp - 有条件的话加业务类型,方便排障
容量估算与参数建议
很多缓存方案不是死在代码,而是死在参数随便配。
1. 本地缓存大小怎么估
一个简化公式:
本地缓存内存 ≈ 热点对象平均大小 × 热点对象数量 × 冗余系数
比如:
- 单个商品详情序列化后约 2 KB
- 热点商品 2 万个
- 冗余系数按 1.5 算
则单机大约需要:
2 KB × 20000 × 1.5 ≈ 60 MB
如果你的应用单机可用堆内存本来就不多,本地缓存不宜设太大,否则容易增加 GC 压力。
2. TTL 怎么配
推荐原则:
- L1 TTL 短于 L2 TTL
- L1:10s ~ 60s
- L2:5min ~ 30min
- 再加随机抖动,避免集中过期
示例:
- 本地缓存:30 秒
- Redis:10 分钟 + 0~120 秒随机值
3. 命中率怎么看
至少要监控:
- L1 命中率
- L2 命中率
- DB 回源率
- 热点 Key 分布
- 缓存平均加载耗时
- 缓存删除失败次数
经验上:
- L1 命中率偏低,说明热点识别不准或 TTL 太短
- L2 命中率偏低,说明缓存策略有问题或大量穿透
- DB 回源突增,通常是批量失效、雪崩或某层缓存异常
常见坑与排查
这一部分我尽量写得“像现场”,因为很多问题不是不知道原理,而是线上一出事不知道先看哪。
坑 1:@Cacheable 不生效
常见原因
- 没加
@EnableCaching - 方法被
private修饰 - 同类内部调用,绕过了 Spring 代理
- Key 表达式写错
- 返回值不可序列化,Redis 存储失败
排查建议
先确认这几件事:
1. 该 Bean 是否被 Spring 管理
2. 调用是否经过代理对象
3. 日志里是否有 Cache interceptor 输出
4. Redis 中是否真的产生了对应 key
如果是类内自调用,例如:
public Product a(Long id) {
return this.getProduct(id);
}
这种情况下缓存注解通常不会生效。我当时第一次踩这个坑,盯着代码看了半天,以为是 Redis 配错了,结果根本没走代理。
坑 2:本地缓存和 Redis 数据不一致
原因
- 只删了 Redis,没删本地
- 多实例之间没有广播失效
- 更新和删除顺序不合理
- 删除失败没有重试
排查路径
- 先看 DB 里的数据是否正确
- 再看 Redis key 是否已删除或更新
- 最后看实例本地缓存是否仍命中旧值
如果某台机器总是读旧值,而 Redis 已经是新值,八成就是本地缓存未失效。
坑 3:缓存击穿
现象
某个热点 Key 过期瞬间,大量请求同时回源 DB。
方案
@Cacheable(sync = true):单机内有效- Redis 分布式锁:跨实例控制
- 热点 Key 逻辑过期 + 后台异步刷新
- 永不过期 + 主动失效
对于超级热点数据,我通常不完全依赖物理 TTL,而是加一层逻辑过期,这样即使缓存“过期”,也不会瞬间全部打到 DB。
坑 4:缓存穿透
现象
查询大量不存在的数据,每次都打到数据库。
方案
- 缓存空值,TTL 设短一点
- 布隆过滤器
- 入参校验,拦截明显非法请求
坑 5:缓存雪崩
现象
大量 Key 同时过期,Redis 或 DB 压力突然暴涨。
方案
- TTL 加随机值
- 热点数据预热
- 多级缓存分担
- 限流与降级
- 避免大批量统一时间写入缓存
安全/性能最佳实践
这一节不只是“建议”,很多其实是线上能不能稳住的关键。
1. 不要缓存敏感数据明文
如果缓存里包含:
- 用户手机号
- 身份证号
- token
- 权限快照
要谨慎处理,至少做到:
- 敏感字段脱敏
- 关键数据加密
- 合理设置 TTL
- 控制 Redis 访问权限
- 禁止无鉴权的管理接口暴露缓存内容
2. Key 设计要稳定、可读、可控
推荐格式:
业务前缀:对象类型:主键[:版本]
例如:
product:detail:1
user:profile:1001
config:feature:coupon:v2
不要直接把整个对象 JSON 当 key,也不要让 key 里混入随机无意义参数,否则很难排障和治理。
3. TTL 一定要分层配置
建议:
- 本地缓存 TTL 更短
- Redis TTL 更长
- 对热点 Key 加随机抖动
例如:
L1 = 30s
L2 = 600s + random(0~120s)
4. 大对象要谨慎缓存
一个 500 KB 的对象,哪怕命中率高,也可能带来:
- Redis 网络开销大
- 序列化/反序列化成本高
- 本地缓存占用堆内存
- Full GC 风险增加
更好的做法:
- 只缓存必要字段
- 拆分成多个 Key
- 聚合结果缓存时控制大小
5. 给缓存失败留退路
缓存不是主存储,所以必须允许失败:
- Redis 超时后要能回源
- 本地缓存异常不能拖垮主流程
- 删除缓存失败要记录日志并告警
- 关键写操作可做重试或补偿任务
6. 监控比“写对代码”更重要
至少加这些指标:
cache.l1.hitcache.l2.hitcache.db.loadcache.evict.successcache.evict.failredis.command.latencycache.rebuild.count
没有监控,你很难知道当前是“缓存生效了”,还是“只是看起来没报错”。
一致性策略进阶建议
如果你的业务对一致性要求更高,可以按复杂度逐级升级:
方案 A:更新库后删除缓存
最简单,适合大部分读多写少场景。
优点:
- 实现简单
- 成本低
缺点:
- 存在短暂脏读窗口
方案 B:延迟双删
流程:
- 更新 DB
- 删除缓存
- 延迟几百毫秒再删一次
适合解决并发下“删缓存后又被旧请求回填”的问题。
但注意:
- 延迟时间不好拍脑袋定
- 并不能解决所有极端时序问题
方案 C:删除缓存 + MQ 通知
适合多实例场景,解决本地缓存一致性。
优点:
- 工程上较平衡
- 易扩展
缺点:
- 增加消息链路
- 需要处理消息丢失、重复消费
方案 D:版本号/逻辑时钟控制
高要求场景可在缓存值里带版本号:
- 新版本只能覆盖旧版本
- 避免乱序更新导致旧值覆盖新值
这个方案更复杂,但在复杂并发写场景下很有效。
一个实用的落地建议清单
如果你准备在现有 Spring Boot 项目里上线这套方案,我建议按下面顺序推进:
- 先落地 L2 Redis + Spring Cache
- 补上命中率、回源率、Redis 延迟监控
- 再引入 L1 Caffeine 吸收热点
- 更新链路增加缓存删除日志与告警
- 多实例场景补失效广播
- 针对热点 Key 增加击穿保护
- 针对不存在数据增加空值缓存/布隆过滤器
- 压测验证 TTL、容量和命中率参数
不要一上来就把所有高级特性堆满。
缓存体系最忌讳“功能很多,但没人说得清什么时候会脏、什么时候会挂、挂了怎么恢复”。
总结
基于 Spring Cache + Redis 做高并发多级缓存,关键不在于注解本身,而在于你是否把下面几件事想清楚了:
- 读路径:L1 -> L2 -> DB 的回填顺序
- 写路径:更新库后删除缓存,而不是强行双写
- 一致性:接受最终一致,控制不一致窗口
- 多实例问题:本地缓存必须有失效广播机制
- 高并发风险:击穿、穿透、雪崩都要提前防
- 可运维性:命中率、回源率、延迟和失败数必须可观测
如果让我给一个中级开发者最实用的建议,那就是:
先实现“简单但正确”的多级缓存,再逐步增强一致性,不要一开始追求完美强一致。
因为在真实项目里,能稳定跑、能快速排障、能解释清楚边界,比“理论最优”更重要。
只要你把本地缓存、Redis、失效通知和监控这四件事串起来,这套方案基本就具备了在高并发业务中落地的价值。