第 53 章:Redis 缓存实战
学习目标
- 集成 Redis
- 学会缓存模式(旁路缓存 / 自动过期)
- 避开缓存常见问题
一、集成
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>spring:
redis:
host: localhost
port: 6379
password: xxx
lettuce: # 默认客户端
pool:
max-active: 16
max-idle: 8
min-idle: 2直接用 Spring Boot 自带的 StringRedisTemplate(无需配置):
@Service
@RequiredArgsConstructor
public class UserCache {
private final StringRedisTemplate redis; // ① 直接注入,零配置
private final ObjectMapper mapper; // ② 复用 Spring 的 JSON 转换器
public void setUser(Long id, User user) {
redis.opsForValue().set("user:" + id,
mapper.writeValueAsString(user), // ③ 自己转 JSON
30, TimeUnit.MINUTES);
}
public User getUser(Long id) {
String json = redis.opsForValue().get("user:" + id);
return json == null ? null : mapper.readValue(json, User.class);
}
}💡 为什么不用自定义
RedisTemplate+ Jackson? Spring Boot 默认的RedisTemplate用 JDK 序列化会产生乱码前缀,自定义又得写一坨RedisConfig。实战里更省事的写法是直接用StringRedisTemplate+ 自己手动ObjectMapper转 JSON——零配置、字段可控、可读性好。
⚠️ 坑 1:默认
RedisTemplateJDK 序列化有乱码前缀。本节直接用StringRedisTemplate规避(它本身就是 String 序列化)。
二、常用操作
@Service
@RequiredArgsConstructor
public class UserCache {
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
public void setUser(Long id, User user) {
String json = mapper.writeValueAsString(user);
redis.opsForValue().set("user:" + id, json, 30, TimeUnit.MINUTES); // 30 分钟过期
}
public User getUser(Long id) {
String json = redis.opsForValue().get("user:" + id);
return json == null ? null : mapper.readValue(json, User.class);
}
public void delete(Long id) {
redis.delete("user:" + id);
}
public boolean hasKey(Long id) {
return redis.hasKey("user:" + id);
}
}Hash(value 是字符串,所以也要 JSON 转):
redis.opsForHash().put("user", "name", mapper.writeValueAsString(user));
String json = (String) redis.opsForHash().get("user", "name");
User user = mapper.readValue(json, User.class);List / Set / ZSet 类似。
三、旁路缓存(Cache Aside)
最常用的模式:
public User getById(Long id) {
String key = "user:" + id;
// ① 先查缓存
String json = redis.opsForValue().get(key);
if (json != null) return mapper.readValue(json, User.class);
// ② 缓存没有查 DB
User user = userMapper.selectById(id);
if (user == null) return null;
// ③ 写回缓存
redis.opsForValue().set(key, mapper.writeValueAsString(user), 30, TimeUnit.MINUTES);
return user;
}
public void update(User user) {
userMapper.updateById(user);
redis.delete("user:" + user.getId()); // 改完删缓存,下次读重新加载
}⚠️ 坑 2:改完缓存是「删」而不是「改」。改涉及多个字段、还要算过期时间,直接删最简单。下次查询再加载,期间短暂不一致可以接受。
四、@Cacheable(声明式缓存)
@Cacheable(value = "user", key = "#id") // 读:有缓存返缓存,无缓存执行方法并存缓存
public User getById(Long id) {
return userMapper.selectById(id);
}
@CachePut(value = "user", key = "#user.id") // 写:方法返回值更新缓存
public User update(User user) {
userMapper.updateById(user);
return user;
}
@CacheEvict(value = "user", key = "#id") // 删:清缓存
public void delete(Long id) {
userMapper.deleteById(id);
}💡
value/key是什么? Redis 里实际存的 key =value + "::" + key。
value = "user"→ 命名空间/前缀,区分不同业务(user/order/product)key = "#id"→ 这一次调用的具体标识(SpEL 引用参数名)例:
getById(1L)→ Redis 里 key 是"user::1";getById(2L)→"user::2"。类上必须加
@EnableCaching才生效。
开启:
@SpringBootApplication
@EnableCaching
public class App { }⚠️ 坑 3:
@Cacheable的 key 是 SpEL 表达式,#id是方法的参数名。java@Cacheable(key = "#userId") // 你的方法参数名是 userId,这里也得是 userId public User getById(Long userId) { }
五、缓存穿透(查不存在的数据)
💡 穿透 = 请求打到了 DB,不是绕过 DB。查不存在的数据(id=-1、id=999999),缓存始终不命中,每次都打到 DB,DB 被无数空请求打挂。解决:DB 查不到时缓存个空值,让第二次开始的同 ID 请求走缓存,不再打 DB。
流程:
第 1 次请求 id=999999 → 缓存 miss → 查 DB(DB 也没)→ 缓存 "null"(1 分钟过期)
第 2 次请求 id=999999 → 缓存命中("null") → 直接返回 null,不打 DB ✓
第 3 次请求 id=999999 → 缓存命中("null") → 直接返回 null,不打 DB ✓关键:DB 只被打一次,之后同 ID 都走缓存。
private static final String EMPTY = "null"; // 标记"这个 key 不存在"
public User getById(Long id) {
String key = "user:" + id;
String json = redis.opsForValue().get(key);
if (EMPTY.equals(json)) return null; // 命中空值缓存,直接返回
if (json != null) return mapper.readValue(json, User.class);
User user = userMapper.selectById(id);
if (user == null) {
redis.opsForValue().set(key, EMPTY, 1, TimeUnit.MINUTES); // 缓存空值,短过期
return null;
}
redis.opsForValue().set(key, mapper.writeValueAsString(user), 30, TimeUnit.MINUTES);
return user;
}或者用布隆过滤器(见后续章节)。
六、缓存雪崩(大量 Key 同时过期)
// ❌ 同一时间 TTL,过期时数据库被打挂
redis.opsForValue().set(key, json, 30, TimeUnit.MINUTES);
// ✅ 加随机抖动
int ttl = 30 + new Random().nextInt(10); // 30-40 分钟
redis.opsForValue().set(key, json, ttl, TimeUnit.MINUTES);七、缓存击穿(热点 Key 过期瞬间)
💡 关键代码解释:
setIfAbsent(lockKey, "1", 10s)= "key 不存在才设",原子抢锁——返回true表示抢到锁(执行try块查 DB + 写缓存),返回false表示别人持锁(sleep(100)等一会儿,递归重试,此时缓存大概率已经被人写好)。
10 秒过期是兜底:持锁线程崩了的话,锁 10 秒后自动释放,不会导致系统永久卡死。为什么不用
synchronized? synchronized 只锁单 JVM,多实例部署时各机器互不干扰,起不到"全局一锁"的作用。Redis 锁是跨进程的分布式锁。
public User getById(Long id) {
String key = "user:" + id;
String json = redis.opsForValue().get(key);
if (json != null) return mapper.readValue(json, User.class);
String lockKey = "lock:user:" + id;
if (!redis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) { // 抢锁
Thread.sleep(100);
return getById(id); // 等 100ms 再重试
}
try {
User user = userMapper.selectById(id);
redis.opsForValue().set(key, mapper.writeValueAsString(user), 30, TimeUnit.MINUTES);
} finally {
redis.delete(lockKey);
}
return null;
}或者用 Redisson(现成的分布式锁)。
八、本章小结
| 要点 | 关键 |
|---|---|
| 序列化 | StringRedisTemplate + 手动 ObjectMapper 转 JSON |
| Cache Aside | 查 → DB → 写缓存,改 → 删缓存 |
| 穿透 | 空值缓存(字符串 "null")/ 布隆过滤器 |
| 雪崩 | 加随机 TTL |
| 击穿 | 分布式锁 / Redisson |
动手练习
- 实现一个
UserCache,用 Cache Aside 模式 - 故意查 10000 次不存在的 ID,看是否打挂 DB