Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:热点数据一致性与性能优化指南
在业务系统里,缓存几乎是绕不过去的话题。尤其是商品详情、用户画像、配置中心、首页聚合这些“读多写少”的场景,单靠数据库扛流量,通常很快就会吃紧。
很多团队一开始会直接上 Redis,确实能挡住一大波查询压力。但只用 Redis 也不是终点:
- 网络来回有延迟
- Redis 在高并发热点 Key 下也会成为瓶颈
- 数据更新时,本地进程里可能还残留旧值
- Spring Cache 默认更偏“单层缓存”,多级组合要自己补一层设计
这篇文章我会带你从实际工程视角,在 Spring Boot 中实现一个本地缓存 + Redis 缓存的多级缓存方案,并重点解决两个问题:
- 热点数据性能优化:尽量让热点请求优先命中本地缓存
- 缓存一致性控制:更新数据库后,如何尽量让本地与 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:数据库
- 最终数据源
读流程
- 先查本地缓存
- 本地没命中,再查 Redis
- Redis 没命中,再查数据库
- 查到结果后,回填 Redis 和本地缓存
写流程
- 更新数据库
- 删除 Redis 缓存
- 发布缓存失效消息
- 各节点收到消息后,删除自己的本地缓存
这是业界非常常见的一条路,原因很简单:
缓存更新远比缓存读取复杂,最稳妥的方式通常不是“更新缓存”,而是“删缓存”。”
整体架构图
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. 更新后偶发读到旧值
原因
这是典型的“删除缓存 + 并发读”窗口问题:
- 线程 A 更新 DB
- 线程 B 恰好在删除缓存前读到旧缓存
- 短时间返回旧数据
解决思路
- 接受最终一致性
- 对强一致要求高的数据,不走缓存
- 或在更新链路增加版本号校验
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 分层
可以按数据热度分层:
- 超热点:本地 10
30 秒,Redis 510 分钟 - 普通热点:本地 30
60 秒,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 条:
- 先本地、再 Redis、最后数据库,热点性能会明显改善
- 更新优先删缓存,不要迷恋“更新缓存”
- 本地缓存 TTL 要短于 Redis TTL
- 跨节点本地缓存一定要有失效通知机制
- 不要追求缓存绝对强一致,要明确业务边界
最后给一个落地建议:
- 如果你的系统是中等流量、读多写少、允许短暂最终一致,本文方案非常合适
- 如果你的系统是超高并发热点场景,请继续引入逻辑过期、可靠消息、分布式锁增强
- 如果你的数据是交易核心数据,缓存只能做辅助优化,不要承担一致性主链路
缓存从来不是“加个注解就结束”的事。真正有价值的地方,恰恰在这些边界条件和细节处理上。希望这篇文章能帮你把多级缓存真正跑起来,而不是停留在概念图上。