Java学习第43天 - Redis 缓存基础、Cache-Aside 模式与缓存一致性

一、学习目标

  • 理解为什么需要缓存,以及缓存与数据库的分工。
  • 掌握 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 分钟

  1. 本地安装并启动 Redis。
  2. Spring Boot 项目加入 spring-boot-starter-data-redis 和 spring-boot-starter-cache。
  3. 配置 application-dev.yml,写好 RedisConfig。
  4. 写一个测试用例,set 和 get 成功。

任务 2,订单详情 Cache-Aside,约 1.5 小时

  1. 实现 OrderDetailCacheService.getById,先读 Redis,miss 再读库并回填。
  2. Key 用 order:detail:{id},TTL 1 小时加随机 0 到 300 秒。
  3. GET /api/v1/orders/{id} 走缓存服务。
  4. 用日志或断点确认第二次请求为 cache hit。

任务 3,写操作删缓存,约 1 小时

  1. 在 payOrder 或 updateOrder 中,数据库更新成功后删除 order:detail:{id}。
  2. 验证支付后立刻查详情,应拿到新状态,而不是旧缓存。
  3. 进阶,用 AFTER_COMMIT 事件在事务提交后删缓存。

任务 4,Spring Cache 用户资料,约 1 小时

  1. UserProfileService.getProfile 加 @Cacheable。
  2. updateNickname 加 @CacheEvict。
  3. 在 Redis 中观察 key 生成与删除。

任务 5,三大问题演练,约 1.5 小时

  1. 穿透,请求不存在的 orderId,实现空值缓存,TTL 5 分钟。
  2. 击穿,模拟热点 key 过期,用 setIfAbsent 互斥锁,保证只有一个线程查库。
  3. 雪崩,批量写入缓存时 TTL 加随机值,说明原理即可。

任务 6,自检清单

  • 能画出 Cache-Aside 读写的流程图。
  • 能解释穿透、击穿、雪崩的区别与对策。
  • 能说明为什么写操作优先删缓存而不是更新缓存。
  • 能说明为什么先更新库再删缓存。
  • 能说出 @Cacheable 和手动 RedisTemplate 的适用场景。
  • 能设计合理的 Redis key 命名和 TTL。

十五、常见错误与避坑

  • 缓存与事务顺序,不要在事务未提交时删缓存,应在 AFTER_COMMIT 后删。
  • 序列化不一致,RedisTemplate 和 @Cacheable 使用不同序列化方式会导致格式不一致,统一在 RedisConfig 配置。
  • 缓存 null,disableCachingNullValues 时不缓存 null,穿透仍可能发生,需配合业务校验或空值策略。
  • 过度缓存,写多读少、实时性强的接口直接查库更简单。
  • KEYS 阻塞,生产环境遍历用 SCAN,禁止在大库上用 KEYS。
  • 大 value,避免把超大对象或大列表整体缓存。
相关推荐
云技纵横1 小时前
线程池 OOM 实战:无界队列配错,5 万个任务撑爆 JVM
后端
渣波1 小时前
拒绝 SQL 焦虑!手把手带你用 NestJS + Prisma + DTO 写出“防弹”级后端代码
javascript·数据库·后端
用户61541317281272 小时前
# 写接口自动化时,我在断言上栽过的两个跟头
后端
SamDeepThinking2 小时前
Java微服务练习方式
java·后端·微服务
IT_陈寒2 小时前
Vue的响应式真把我坑惨了,原来问题出在这
前端·人工智能·后端
codedx3 小时前
LangChain 和 LangGraph 构建的 Agent 项目模版
后端·langchain·agent
葫芦和十三3 小时前
图解 MongoDB 08|ESR 原则:复合索引的字段顺序怎么定
后端·mongodb·agent
葫芦和十三11 小时前
图解 MongoDB 07|索引类型:七种索引,七种访问形状
后端·mongodb·agent
朦胧之12 小时前
AI 编程-老项目改造篇
java·前端·后端