一、学习目标
- 理解为什么需要缓存,以及缓存与数据库的分工。
- 掌握 Redis 五种核心数据结构及典型使用场景。
- 会在 Spring Boot 中集成 Spring Data Redis 与 Spring Cache。
- 熟练实现 Cache-Aside 旁路缓存读写模式。
- 掌握 Spring Cache 注解式缓存的用法与边界。
- 理解并应对缓存穿透、缓存击穿、缓存雪崩三类常见问题。
- 理解缓存与数据库一致性的基本策略与取舍。
- 掌握 Redis Key 设计规范、TTL 设置、Big Key 规避。
- 把第40天订单查询、第41天慢 SQL、第42天并发写,延伸到读多写少场景的缓存加速。
二、为什么第43天要学 Redis 缓存
第40到42天重点在数据库:建表、复杂查询、事务、锁。
但真实系统中会遇到:
- 商品详情、用户信息、订单详情被高频访问,数据库压力大。
- 首页热点数据、配置字典几乎不变,每次都查库浪费资源。
- 秒杀、大促时读请求暴增,数据库容易成为瓶颈。
- 第41天优化过的 SQL 在超高 QPS 下仍然扛不住。
缓存的作用:
- 把热点数据放在内存中,读取速度比磁盘数据库快几个数量级。
- 降低数据库连接数和 IO 压力。
- 支撑高并发读场景。
第43天目标:从只会查数据库,升级到会用 Redis 做读加速,并处理常见缓存问题。
三、缓存基础概念
3.1 什么是缓存
缓存是把可能再次使用的数据临时存放在更快访问的存储介质中。
常见层次,从快到慢:
text
CPU 缓存 -> 本地内存缓存 -> Redis 分布式缓存 -> 数据库 -> 磁盘
3.2 缓存适合什么数据
适合缓存:
- 读多写少。
- 热点数据。
- 允许短暂不一致,最终一致即可。
- 查询成本高、结果相对稳定。
不适合缓存:
- 强实时一致性要求极高,例如账户余额最终仍以数据库为准。
- 写多读少。
- 数据量极大且几乎无重复访问。
- 敏感数据未加密、未做权限隔离。
3.3 本地缓存 vs 分布式缓存
| 对比项 | 本地缓存 Caffeine Guava | 分布式缓存 Redis |
|---|---|---|
| 存储位置 | 应用 JVM 内存 | 独立 Redis 服务 |
| 多实例共享 | 否,各实例各一份 | 是,所有实例共享 |
| 速度 | 极快,无网络 | 快,有网络开销 |
| 容量 | 受 JVM 限制 | 可集群扩展 |
| 一致性 | 各实例独立,弱 | 多实例共享,较强 |
| 典型场景 | 配置、字典、极热小数据 | 会话、商品详情、分布式锁 |
企业项目常见组合是本地缓存加 Redis 二级缓存,第43天先掌握 Redis,第44天再做多级缓存。
四、Redis 核心数据结构
4.1 String 字符串
最基础类型,可存字符串、数字、JSON 序列化后的对象。
bash
SET user:1001:name "Alice"
GET user:1001:name
SET order:2001 '{"id":2001,"status":"PAID"}' EX 3600
INCR product:3001:view
DECR product:3001:stock
典型场景:
- 简单 KV 缓存。
- 计数器,如浏览量、点赞数。
- 分布式锁,配合 SET NX EX。
- 幂等键,衔接第38天 Idempotency-Key。
4.2 Hash 哈希
类似 Java 的 Map,适合存对象的多个字段。
bash
HSET user:1001 id 1001 name Alice email a@example.com
HGET user:1001 name
HGETALL user:1001
HDEL user:1001 email
典型场景:
- 用户信息、商品信息多字段缓存。
- 比整个对象 JSON 更便于单字段更新,但实际项目仍常用 JSON String 简单直接。
4.3 List 列表
有序列表,支持头尾插入弹出。
bash
LPUSH queue:notify "msg1"
RPOP queue:notify
LRANGE queue:notify 0 -1
LLEN queue:notify
典型场景:
- 简单消息队列。
- 最新 N 条记录,如最新订单列表、操作记录。
4.4 Set 集合
无序、不重复。
bash
SADD user:1001:tags Java Redis
SISMEMBER user:1001:tags Java
SMEMBERS user:1001:tags
SCARD user:1001:tags
典型场景:
- 标签、去重。
- 共同关注、共同好友。
- 抽奖、秒杀去重,记录已参与用户。
4.5 ZSet 有序集合
带分数的有序集合,按分数排序。
bash
ZADD rank:score 95 user:1001
ZADD rank:score 88 user:1002
ZREVRANGE rank:score 0 9 WITHSCORES
ZSCORE rank:score user:1001
典型场景:
- 排行榜。
- 延迟队列,分数为到期时间戳。
- 按时间范围取数据。
4.6 选型建议,结合订单项目
| 数据 | 推荐结构 | Key 示例 |
|---|---|---|
| 订单详情 | String JSON | order:detail:2001 |
| 用户资料缓存 | String 或 Hash | user:profile:1001 |
| 商品库存简单场景 | String 数字 | product:stock:3001 |
| 热门商品排行 | ZSet | product:hot:rank |
| 用户最近订单 ID 列表 | List | user:1001:recent_orders |
| 秒杀已购用户去重 | Set | seckill:3001:users |
五、Spring Boot 集成 Redis
5.1 依赖 Maven
xml
<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>
连接池,生产建议引入:
xml
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
5.2 application.yml
yaml
spring:
data:
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 3000ms
cache:
type: redis
redis:
time-to-live: 3600000
cache-null-values: false
key-prefix: "app:"
use-key-prefix: true
说明:
- time-to-live:默认过期时间毫秒,3600000 即 1 小时。
- cache-null-values false:不缓存 null,有助于缓解穿透,配合布隆过滤器更佳。
- key-prefix:多应用共用 Redis 时避免 key 冲突。
5.3 Redis 配置类,序列化
默认 JDK 序列化可读性差,建议使用 JSON。
java
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.annotation.EnableCaching;
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.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<Object> jsonSerializer = jacksonSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
Jackson2JsonRedisSerializer<Object> jsonSerializer = jacksonSerializer();
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer))
.disableCachingNullValues();
Map<String, RedisCacheConfiguration> configs = new HashMap<>();
configs.put("user:profile", defaultConfig.entryTtl(Duration.ofMinutes(30)));
configs.put("order:detail", defaultConfig.entryTtl(Duration.ofHours(2)));
configs.put("product:detail", defaultConfig.entryTtl(Duration.ofHours(6)));
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configs)
.build();
}
private Jackson2JsonRedisSerializer<Object> jacksonSerializer() {
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL);
return new Jackson2JsonRedisSerializer<>(mapper, Object.class);
}
}
说明:
- RedisTemplate 用于手动读写 Redis。
- RedisCacheManager 配合 @Cacheable 等注解使用。
- Key 用 String,Value 用 JSON,便于在 Redis 客户端中查看和排查问题。
- 不同 cacheName 可配置不同 TTL。
5.4 启动验证
本地安装 Redis 后启动应用,用 redis-cli 验证:
bash
redis-cli ping
# 返回 PONG 表示连通
写一个简单测试:
java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class RedisSmokeTest {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void testRedis() {
stringRedisTemplate.opsForValue().set("test:key", "hello", 60, TimeUnit.SECONDS);
String value = stringRedisTemplate.opsForValue().get("test:key");
// value 应为 hello
}
}
六、Cache-Aside 旁路缓存模式
6.1 什么是 Cache-Aside
Cache-Aside 是最常用的缓存模式,应用程序自己管理缓存与数据库的读写。
读流程:
text
1. 先查缓存
2. 缓存命中,直接返回
3. 缓存未命中,查数据库,写入缓存,返回
写流程:
text
1. 先更新数据库
2. 再删除缓存
这是第43天必须掌握的核心模式。
6.2 读操作实现,订单详情
java
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
@Service
public class OrderDetailCacheService {
private static final String KEY_PREFIX = "order:detail:";
private static final long TTL_SECONDS = 3600;
private final OrderRepository orderRepository;
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
public OrderDetailCacheService(OrderRepository orderRepository,
StringRedisTemplate redis,
ObjectMapper objectMapper) {
this.orderRepository = orderRepository;
this.redis = redis;
this.objectMapper = objectMapper;
}
public OrderDetailDTO getById(Long orderId) {
String key = KEY_PREFIX + orderId;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return fromJson(cached);
}
OrderEntity entity = orderRepository.getById(orderId);
if (entity == null) {
return null;
}
OrderDetailDTO dto = toDetail(entity);
long ttl = TTL_SECONDS + ThreadLocalRandom.current().nextInt(300);
redis.opsForValue().set(key, toJson(dto), ttl, TimeUnit.SECONDS);
return dto;
}
private OrderDetailDTO toDetail(OrderEntity entity) {
OrderDetailDTO dto = new OrderDetailDTO();
dto.setId(entity.getId());
dto.setStatus(entity.getStatus());
dto.setTotalAmount(entity.getTotalAmount());
return dto;
}
private String toJson(OrderDetailDTO dto) {
try {
return objectMapper.writeValueAsString(dto);
} catch (JsonProcessingException e) {
throw new IllegalStateException("序列化失败", e);
}
}
private OrderDetailDTO fromJson(String json) {
try {
return objectMapper.readValue(json, OrderDetailDTO.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("反序列化失败", e);
}
}
}
注意 TTL 加了随机值,这是为了防止雪崩,后面第八节会讲。
6.3 写操作,先更新库再删缓存
java
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderCommandService {
private static final String KEY_PREFIX = "order:detail:";
private final OrderRepository orderRepository;
private final StringRedisTemplate redis;
public OrderCommandService(OrderRepository orderRepository, StringRedisTemplate redis) {
this.orderRepository = orderRepository;
this.redis = redis;
}
@Transactional(rollbackFor = Exception.class)
public void payOrder(Long orderId) {
OrderEntity order = orderRepository.getById(orderId);
if (order == null) {
throw new IllegalArgumentException("订单不存在");
}
order.setStatus("PAID");
orderRepository.updateById(order);
redis.delete(KEY_PREFIX + orderId);
}
}
为什么写操作优先删缓存而不是更新缓存:
- 更新缓存需要重新组装 DTO,逻辑复杂。
- 删除缓存更简单,下次读时自动回填。
- 避免并发下旧数据覆盖新数据的风险。
6.4 为什么是先更新数据库再删缓存
常见两种顺序:
| 顺序 | 问题 |
|---|---|
| 先删缓存再更新库 | 删缓存后、更新库前,另一个读请求可能把旧数据再次写入缓存 |
| 先更新库再删缓存 | 更常用,极端并发下仍可能短暂不一致,但窗口更小 |
企业实践中,先更新数据库再删除缓存是主流选择。对一致性要求极高的场景,可配合延迟双删、消息队列、Canal 等方案。
6.5 延迟双删,了解
java
@Transactional(rollbackFor = Exception.class)
public void updateOrder(OrderEntity order) {
String key = KEY_PREFIX + order.getId();
redis.delete(key);
orderRepository.updateById(order);
redis.delete(key);
// 异步在 500ms 后再删一次,降低并发窗口内脏数据概率
asyncExecutor.schedule(() -> redis.delete(key), 500, TimeUnit.MILLISECONDS);
}
第43天理解思路即可,不必一开始就上复杂方案。
6.6 缓存与事务提交顺序,重要
不要在事务方法一开始就删缓存:
java
// 不推荐
@Transactional
public void payOrder(Long orderId) {
redis.delete(key); // 事务未提交,其他线程可能读到旧库数据并回填缓存
orderRepository.updateById(order);
}
更稳妥的做法是数据库事务提交后再删缓存,可用事务事件监听:
java
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Transactional(rollbackFor = Exception.class)
public void payOrder(Long orderId) {
OrderEntity order = orderRepository.getById(orderId);
order.setStatus("PAID");
orderRepository.updateById(order);
publisher.publishEvent(new OrderPaidEvent(orderId));
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPaid(OrderPaidEvent event) {
redis.delete("order:detail:" + event.getOrderId());
}
七、Spring Cache 声明式缓存
7.1 基本注解
| 注解 | 作用 |
|---|---|
| @Cacheable | 有缓存则返回,无则执行方法并缓存结果 |
| @CachePut | 总是执行方法并更新缓存 |
| @CacheEvict | 删除缓存 |
| @Caching | 组合多个缓存操作 |
7.2 @Cacheable 示例
java
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class UserProfileService {
private final UserRepository userRepository;
public UserProfileService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Cacheable(value = "user:profile", key = "#userId", unless = "#result == null")
public UserProfileDTO getProfile(Long userId) {
UserEntity user = userRepository.getById(userId);
if (user == null) {
return null;
}
return toProfile(user);
}
private UserProfileDTO toProfile(UserEntity user) {
UserProfileDTO dto = new UserProfileDTO();
dto.setId(user.getId());
dto.setNickname(user.getNickname());
dto.setEmail(user.getEmail());
return dto;
}
}
实际 Redis 中的 key 类似 app:user:profile::1001,其中 app: 来自 spring.cache.redis.key-prefix。
常用属性:
- value 或 cacheNames:缓存名。
- key:SpEL 表达式,如 #userId、#user.id。
- condition:满足条件才缓存,如 condition = "#userId > 0"。
- unless:结果满足条件则不缓存,如 unless = "#result == null"。
7.3 @CachePut 示例
总是执行方法并把返回值写入缓存,适合更新后刷新缓存。
java
import org.springframework.cache.annotation.CachePut;
@CachePut(value = "user:profile", key = "#userId")
public UserProfileDTO updateProfile(Long userId, UpdateProfileRequest req) {
UserEntity user = userRepository.getById(userId);
user.setNickname(req.getNickname());
userRepository.updateById(user);
return toProfile(user);
}
7.4 @CacheEvict 示例
java
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserUpdateService {
private final UserRepository userRepository;
public UserUpdateService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@CacheEvict(value = "user:profile", key = "#userId")
@Transactional(rollbackFor = Exception.class)
public void updateNickname(Long userId, String nickname) {
UserEntity user = userRepository.getById(userId);
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
user.setNickname(nickname);
userRepository.updateById(user);
}
}
清空整个缓存区可用 allEntries:
java
@CacheEvict(value = "user:profile", allEntries = true)
public void reloadAllUsers() {
// 批量刷新后清空该缓存名下所有 key
}
7.5 手动 Cache-Aside vs Spring Cache
| 方式 | 优点 | 缺点 |
|---|---|---|
| 手动 RedisTemplate | 灵活,逻辑完全可控 | 代码量多 |
| Spring Cache 注解 | 代码简洁,注解驱动 | 复杂场景不够灵活 |
建议:
- 简单查询缓存用 @Cacheable。
- 复杂逻辑、空值处理、分布式锁防击穿、布隆过滤器,用手动 Cache-Aside。
7.6 Spring Cache 注意点
- 注解基于 AOP 代理,同类自调用不生效,原理同第42天事务失效。
- @Cacheable 方法内部异常时不会缓存。
- disableCachingNullValues 时不缓存 null,穿透仍可能发生,需配合业务校验。
八、缓存三大问题
8.1 缓存穿透
现象:查询一个根本不存在的数据,缓存没有,数据库也没有,每次请求都打穿到数据库。
场景:
text
GET /api/v1/orders/99999999
恶意或异常请求用大量不存在的 id 轰炸接口。
危害:数据库压力骤增,缓存失去保护作用。
解决方案一,缓存空值,短 TTL:
java
public OrderDetailDTO getById(Long orderId) {
String key = KEY_PREFIX + orderId;
String cached = redis.opsForValue().get(key);
if (cached != null) {
if ("NULL".equals(cached)) {
return null;
}
return fromJson(cached);
}
OrderEntity entity = orderRepository.getById(orderId);
if (entity == null) {
redis.opsForValue().set(key, "NULL", 300, TimeUnit.SECONDS);
return null;
}
OrderDetailDTO dto = toDetail(entity);
redis.opsForValue().set(key, toJson(dto), TTL_SECONDS, TimeUnit.SECONDS);
return dto;
}
空值 TTL 要短,例如 5 分钟,避免长期占用内存,也避免数据真的创建后长期读到空。
解决方案二,接口层参数校验:
java
if (orderId == null || orderId <= 0) {
throw new IllegalArgumentException("订单ID非法");
}
解决方案三,布隆过滤器,进阶:
查询前先判断 id 是否可能存在,不存在直接返回,减少数据库访问。适合数据量大、恶意穿透频繁的场景。
第43天先掌握缓存空值加参数校验即可。
8.2 缓存击穿
现象:某个热点 key 在过期瞬间,大量并发请求同时打到数据库。
场景:
text
热门商品详情缓存刚好过期
大促瞬间 1000 个请求同时查同一商品
危害:数据库瞬时压力暴增,可能拖垮服务。
解决方案一,互斥锁,只让一个线程查库回填:
java
public OrderDetailDTO getByIdWithLock(Long orderId) {
String key = KEY_PREFIX + orderId;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return fromJson(cached);
}
String lockKey = "lock:order:detail:" + orderId;
boolean locked = tryLock(lockKey, 10);
try {
// 双重检查,可能别的线程已经回填
cached = redis.opsForValue().get(key);
if (cached != null) {
return fromJson(cached);
}
OrderEntity entity = orderRepository.getById(orderId);
if (entity == null) {
redis.opsForValue().set(key, "NULL", 300, TimeUnit.SECONDS);
return null;
}
OrderDetailDTO dto = toDetail(entity);
redis.opsForValue().set(key, toJson(dto), TTL_SECONDS, TimeUnit.SECONDS);
return dto;
} finally {
if (locked) {
unlock(lockKey);
}
}
}
private boolean tryLock(String lockKey, long expireSeconds) {
Boolean success = redis.opsForValue()
.setIfAbsent(lockKey, "1", java.time.Duration.ofSeconds(expireSeconds));
return Boolean.TRUE.equals(success);
}
private void unlock(String lockKey) {
redis.delete(lockKey);
}
这个互斥锁是简化版,第44天会讲带唯一 token 和 Lua 释放的正确分布式锁。
解决方案二,逻辑过期,不设真实 TTL,后台异步刷新,适合热点数据几乎不能过期的情况。
解决方案三,热点 key 永不过期加定时刷新,后台任务定期主动更新缓存,避免过期瞬间击穿。
8.3 缓存雪崩
现象:大量 key 在同一时间过期,或 Redis 整体不可用,请求全部涌向数据库。
场景:
- 凌晨批量导入数据,所有缓存 TTL 都是 3600 秒,同时失效。
- Redis 宕机,所有读请求打穿数据库。
解决方案一,过期时间加随机值:
java
long ttl = 3600 + ThreadLocalRandom.current().nextInt(300);
redis.opsForValue().set(key, toJson(dto), ttl, TimeUnit.SECONDS);
避免大量 key 在同一秒过期。
解决方案二,Redis 高可用,主从复制、哨兵或集群、多可用区部署。
解决方案三,限流与降级,接口限流,衔接第38天 Resilience4j。Redis 不可用时返回降级数据或友好错误,而不是压垮数据库。
解决方案四,多级缓存,本地 Caffeine 加 Redis,Redis 挂了本地还能扛一部分读请求,第44天展开。
8.4 三大问题对比
| 问题 | 原因 | 典型场景 | 核心方案 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 恶意 id、非法参数 | 空值缓存、布隆过滤器、参数校验 |
| 击穿 | 单个热点 key 过期 | 爆款商品、热门订单 | 互斥锁、逻辑过期、后台刷新 |
| 雪崩 | 大量 key 同时失效或 Redis 故障 | 批量过期、Redis 宕机 | 随机 TTL、高可用、限流降级、多级缓存 |
九、缓存与数据库一致性
9.1 一致性级别
| 级别 | 说明 | 典型场景 |
|---|---|---|
| 强一致 | 缓存与数据库始终相同 | 极少用缓存,或同步更新 |
| 最终一致 | 短暂不一致,一段时间后一致 | 大多数互联网读多写少场景 |
缓存场景通常接受最终一致。用户改昵称后,缓存可能几秒内还是旧昵称,可接受。
9.2 更新策略对比
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 先删缓存再更新库 | delete 然后 update db | 实现简单 | 并发下可能回填旧值 |
| 先更新库再删缓存 | update db 然后 delete | 主流方案 | 删缓存失败需补偿 |
| 先更新库再更新缓存 | update db 然后 update cache | 读快 | 写复杂,并发易错 |
| 异步订阅 binlog | Canal 等同步缓存 | 解耦 | 架构复杂 |
第43天默认掌握先更新数据库再删除缓存。
9.3 删缓存失败怎么办
java
@Transactional(rollbackFor = Exception.class)
public void updateOrder(OrderEntity order) {
orderRepository.updateById(order);
try {
redis.delete(KEY_PREFIX + order.getId());
} catch (Exception e) {
log.error("删除缓存失败, orderId={}", order.getId(), e);
retryQueue.offer(order.getId());
}
}
可配合重试队列、定时任务扫描不一致、消息队列异步删缓存。
9.4 哪些数据适合缓存
适合:订单详情读多、用户资料、商品详情、配置字典、首页推荐列表短 TTL。
谨慎缓存:库存数量应用第42天原子 SQL 或第44天 Redis 预扣单独设计、实时余额、强一致状态机中间态。
十、Key 设计规范
10.1 命名规则
text
业务:模块:类型:id
示例:
text
order:detail:2001
user:profile:1001
product:stock:3001
lock:order:pay:2001
idem:resp:550e8400-e29b-41d4-a716-446655440000
规则:
- 用冒号分层,便于按前缀扫描,生产慎用 KEYS,用 SCAN。
- 见名知意,避免 key1、temp。
- 多应用共用 Redis 时加应用前缀,如 emcs:order:detail:2001。
10.2 TTL 设置建议
| 数据类型 | 建议 TTL | 说明 |
|---|---|---|
| 用户资料 | 30 分钟到 2 小时 | 变更不频繁 |
| 订单详情 | 1 到 2 小时 | 下单后读多 |
| 商品详情 | 2 到 6 小时 | 变更较少 |
| 空值占位 | 3 到 5 分钟 | 防穿透 |
| 分布式锁 | 10 到 30 秒 | 防死锁 |
| 幂等响应 | 24 小时 | 衔接第38天 |
10.3 避免 Big Key
- 单个 value 不宜过大,如超过 1MB。
- 大列表不要整个塞进一个 key,可分页或拆分。
- Big Key 会导致阻塞、网络传输慢、内存分布不均。
十一、RedisTemplate 常用操作速查
11.1 String
java
redis.opsForValue().set("key", "value", 60, TimeUnit.SECONDS);
String val = redis.opsForValue().get("key");
Long count = redis.opsForValue().increment("counter:view");
Boolean absent = redis.opsForValue().setIfAbsent("lock:key", "1", Duration.ofSeconds(10));
redis.delete("key");
redis.expire("key", 30, TimeUnit.SECONDS);
11.2 Hash
java
redis.opsForHash().put("user:1001", "name", "Alice");
Object name = redis.opsForHash().get("user:1001", "name");
Map<Object, Object> all = redis.opsForHash().entries("user:1001");
redis.opsForHash().delete("user:1001", "name");
11.3 List
java
redis.opsForList().leftPush("queue:task", "job1");
String job = redis.opsForList().rightPop("queue:task");
List<String> list = redis.opsForList().range("queue:task", 0, -1);
Long size = redis.opsForList().size("queue:task");
11.4 Set
java
redis.opsForSet().add("tags:java", "Spring", "Redis");
Boolean member = redis.opsForSet().isMember("tags:java", "Redis");
Set<String> members = redis.opsForSet().members("tags:java");
Long card = redis.opsForSet().size("tags:java");
11.5 ZSet
java
redis.opsForZSet().add("rank:hot", "product:3001", 100);
Set<String> top = redis.opsForZSet().reverseRange("rank:hot", 0, 9);
Double score = redis.opsForZSet().score("rank:hot", "product:3001");
redis.opsForZSet().incrementScore("rank:hot", "product:3001", 1);
十二、与前面课程的整合
12.1 订单详情 API 加缓存
Controller 不变,Service 走缓存:
java
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/orders")
public class OrderApiController {
private final OrderDetailCacheService orderDetailCacheService;
public OrderApiController(OrderDetailCacheService orderDetailCacheService) {
this.orderDetailCacheService = orderDetailCacheService;
}
@GetMapping("/{id}")
public OrderDetailDTO getById(@PathVariable Long id) {
OrderDetailDTO dto = orderDetailCacheService.getById(id);
if (dto == null) {
throw new NotFoundException("订单不存在");
}
return dto;
}
}
12.2 支付后删缓存,衔接第42天
java
@Transactional(rollbackFor = Exception.class)
public void payOrder(Long orderId) {
OrderEntity order = orderRepository.getById(orderId);
if (!"CREATED".equals(order.getStatus())) {
throw new IllegalStateException("订单状态不允许支付");
}
order.setStatus("PAID");
boolean ok = orderRepository.updateById(order);
if (!ok) {
throw new OptimisticLockException("并发更新冲突");
}
redis.delete("order:detail:" + orderId);
}
12.3 幂等键继续用 Redis,衔接第38天
第38天 IdempotencyService 用 Redis 存幂等键,与今天学的 String 和 TTL 完全一致,可统一到同一套 Redis 配置中。
12.4 分页列表是否缓存
订单列表分页查询过滤条件多、变化快,一般不整体缓存 Page 结果。可缓存热点用户的最近订单 ID 列表,再批量查详情,或缓存统计类结果如今日订单数,TTL 设短。
十三、监控与排查
13.1 常用 redis-cli 命令
bash
GET order:detail:2001
TTL order:detail:2001
TYPE order:detail:2001
INFO memory
INFO stats
DBSIZE
生产环境用 SCAN 代替 KEYS 遍历:
bash
SCAN 0 MATCH order:detail:* COUNT 100
13.2 日志建议
缓存相关日志应包含 key、hit 或 miss、业务 id、耗时,不要打印完整大 JSON 到日志。
java
if (cached != null) {
log.debug("cache hit, key={}", key);
return fromJson(cached);
}
log.debug("cache miss, key={}", key);
13.3 简单压测对比
对同一订单详情接口,分别测无缓存直接查库和有缓存第二次命中,观察 QPS 与平均响应时间,可用 JMeter、ab 或手写循环。
十四、实战任务
任务 1,环境搭建,约 30 分钟
- 本地安装并启动 Redis。
- Spring Boot 项目加入 spring-boot-starter-data-redis 和 spring-boot-starter-cache。
- 配置 application-dev.yml,写好 RedisConfig。
- 写一个测试用例,set 和 get 成功。
任务 2,订单详情 Cache-Aside,约 1.5 小时
- 实现 OrderDetailCacheService.getById,先读 Redis,miss 再读库并回填。
- Key 用 order:detail:{id},TTL 1 小时加随机 0 到 300 秒。
- GET /api/v1/orders/{id} 走缓存服务。
- 用日志或断点确认第二次请求为 cache hit。
任务 3,写操作删缓存,约 1 小时
- 在 payOrder 或 updateOrder 中,数据库更新成功后删除 order:detail:{id}。
- 验证支付后立刻查详情,应拿到新状态,而不是旧缓存。
- 进阶,用 AFTER_COMMIT 事件在事务提交后删缓存。
任务 4,Spring Cache 用户资料,约 1 小时
- UserProfileService.getProfile 加 @Cacheable。
- updateNickname 加 @CacheEvict。
- 在 Redis 中观察 key 生成与删除。
任务 5,三大问题演练,约 1.5 小时
- 穿透,请求不存在的 orderId,实现空值缓存,TTL 5 分钟。
- 击穿,模拟热点 key 过期,用 setIfAbsent 互斥锁,保证只有一个线程查库。
- 雪崩,批量写入缓存时 TTL 加随机值,说明原理即可。
任务 6,自检清单
- 能画出 Cache-Aside 读写的流程图。
- 能解释穿透、击穿、雪崩的区别与对策。
- 能说明为什么写操作优先删缓存而不是更新缓存。
- 能说明为什么先更新库再删缓存。
- 能说出 @Cacheable 和手动 RedisTemplate 的适用场景。
- 能设计合理的 Redis key 命名和 TTL。
十五、常见错误与避坑
- 缓存与事务顺序,不要在事务未提交时删缓存,应在 AFTER_COMMIT 后删。
- 序列化不一致,RedisTemplate 和 @Cacheable 使用不同序列化方式会导致格式不一致,统一在 RedisConfig 配置。
- 缓存 null,disableCachingNullValues 时不缓存 null,穿透仍可能发生,需配合业务校验或空值策略。
- 过度缓存,写多读少、实时性强的接口直接查库更简单。
- KEYS 阻塞,生产环境遍历用 SCAN,禁止在大库上用 KEYS。
- 大 value,避免把超大对象或大列表整体缓存。