Skip to content
第 53 章 后端 ⏱ 10 分钟阅读

第 53 章:Redis 缓存实战 ​

学习目标 ​

  • 集成 Redis
  • 学会缓存模式(旁路缓存 / 自动过期)
  • 避开缓存常见问题

一、集成 ​

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yaml
spring:
  redis:
    host: localhost
    port: 6379
    password: xxx
    lettuce:                                  # 默认客户端
      pool:
        max-active: 16
        max-idle: 8
        min-idle: 2

直接用 Spring Boot 自带的 StringRedisTemplate(无需配置):

java
@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:默认 RedisTemplate JDK 序列化有乱码前缀。本节直接用 StringRedisTemplate 规避(它本身就是 String 序列化)。

二、常用操作 ​

java
@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 转):

java
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) ​

最常用的模式:

java
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(声明式缓存) ​

java
@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 才生效。

开启:

java
@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 都走缓存。

java
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 同时过期) ​

java
// ❌ 同一时间 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 锁是跨进程的分布式锁。

java
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

动手练习 ​

  1. 实现一个 UserCache,用 Cache Aside 模式
  2. 故意查 10000 次不存在的 ID,看是否打挂 DB

下一章:第 54 章:消息队列(RabbitMQ + Kafka 入门) →

本站基于 VitePress 构建 · 由 StackHub 团队维护