《Spring Boot 实战》8.2 Redis 缓存实践

把图书借阅服务的缓存落到 Redis:接入 starter、选序列化方案并厘清与 4.x Jackson 3 的关系、Lettuce 共享连接与 Jedis 池的取舍、键命名与内存估算、大 key 与热 key 的危害,以及 4.1 新增的 @RedisListener 自动配置。

本节目标:把上一节的 CacheManager 真正落到 Redis——接好 starter、选对序列化与连接方式、定好键命名,并认清大 key / 热 key 这两类线上事故的成因与对策。
适用版本:Spring Boot 4.1.x(Java 21)

8.2 Redis 缓存实践

上一节把缓存的声明交给了注解,把存储交给了 CacheManager。本节把存储具体化为 Redis:它要跨多个应用实例共享,因此不能再用进程内的 ConcurrentHashMap。这一步的决策点比注解多得多——序列化选错会让缓存不可读、不可升级;连接模型选错会在高并发下打满连接;键命名和容量规划没做,线上就会收到内存告警。

本节所有「运行输出」均为示例输出,用来展示格式与量级;本机只跑通了纯 Spring Boot 应用,未搭建真实 Redis 实例。

8.2.1 接入:starter 与最小配置

依赖只有一行。注意 4.x 的模块化命名:starter 名不变(spring-boot-starter-data-redis),但对应模块改叫 spring-boot-data-redis。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

第二行 commons-pool2 只有用连接池时才需要。Lettuce 默认共享单连接、不池化,所以纯 Lettuce 可以不加;一旦开了 spring.data.redis.lettuce.pool.* 或改用 Jedis,没有它就会在启动时报找不到池实现。

最小配置(4.x 属性前缀是 spring.data.redis.*,不是旧版的 spring.redis.*):

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: ${REDIS_PASSWORD:}
      database: 0
      timeout: 2s
      connect-timeout: 2s

timeout 是命令超时,connect-timeout 是建连超时,两个都要设。默认无超时意味着 Redis 一慢,业务线程就会成片挂起——这是「Redis 抖动放大成应用雪崩」的经典路径。

8.2.2 序列化:默认的 JDK 序列化不能上生产

RedisTemplate 的默认序列化器是 JdkSerializationRedisSerializer。它把对象用 Java 原生序列化写进 Redis,问题很集中:

问题具体表现
不可读redis-cli get 出来是一堆 \xac\xed 开头的二进制,排障基本靠猜
强绑定类结构类加字段、改包名、换版本都可能 InvalidClassException
体积大同一条数据通常比 JSON 大 30% 以上,直接放大内存成本
安全风险反序列化不可信字节流是已知攻击面

生产上应显式配置:key 用字符串、value 用 JSON。

@Configuration
public class RedisConfig {

    @Bean
    RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf,
                                                RedisSerializer<Object> jsonSerializer) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(cf);
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);
        return template;
    }
}

注意 key 和 hashKey 都要设成字符串,只设 key 而漏了 hashKey,用 Hash 结构时仍会写出二进制键。

8.2.3 Jackson 3 与 JSON 序列化方案的关系

Spring Data Redis 长期提供的 GenericJackson2JsonRedisSerializer,顾名思义基于 Jackson 2。而 Spring Boot 4.x 已把首选 JSON 库切到 Jackson 3:包名从 com.fasterxml.jackson 变为 tools.jackson(例外是注解包 com.fasterxml.jackson.annotation 仍在原位),自动配置改用 JsonMapper / XmlMapper,且自定义 ObjectMapper bean 不再能替换自动配置。

这就带来一个必须说清的边界:任何硬编码依赖 com.fasterxml.jackson 的序列化器,在 4.x 里都跑在 Jackson 2 那条线上,与自动配置的 Jackson 3 是两套栈。有两种处理方式:

  • 优先:自己提供一个 RedisSerializer<Object>,内部用 Boot 自动配置好的 Jackson 3 JsonMapper(tools.jackson.databind.json.JsonMapper)读写 JSON,把它注入上面的 RedisTemplate 与 RedisCacheConfiguration。这样序列化口径和应用其余部分(HTTP 响应体)保持一致。
  • 过渡:若依赖的库仍要求 Jackson 2,可引 spring-boot-jackson2 模块并使用基于 Jackson 2 的序列化器,代价是同时存在两套 JSON 栈。
@Configuration
public class RedisJsonConfig {

    @Bean
    RedisSerializer<Object> jsonSerializer(JsonMapper jsonMapper) {
        return new RedisSerializer<>() {
            @Override
            public byte[] serialize(Object value) {
                if (value == null) {
                    return new byte[0];
                }
                try {
                    return jsonMapper.writeValueAsBytes(value);
                } catch (Exception e) {
                    throw new IllegalStateException("Redis 序列化失败", e);
                }
            }

            @Override
            public Object deserialize(byte[] bytes) {
                if (bytes == null || bytes.length == 0) {
                    return null;
                }
                try {
                    return jsonMapper.readValue(bytes, Object.class);
                } catch (Exception e) {
                    throw new IllegalStateException("Redis 反序列化失败", e);
                }
            }
        };
    }
}

这段代码展示了「用自动配置的 Jackson 3 JsonMapper 做 Redis 序列化」的做法,不依赖某个具体的 4.x 序列化器类名。若你要用 Spring Data Redis 自带的 JSON 序列化器,请以其 4.x 文档为准确认当前类名与 Jackson 版本对应关系——不要照搬 3.x 时代的类名到 4.x 项目。

用 Object.class 反序列化到通用类型有个代价:返回的是 Map / List 而非原始类型,取用时要么强转失败、要么得做类型转换。要精确还原类型,需要在 JSON 里写入类型信息(@class 字段)并在反序列化时据此选择目标类型,这正是 GenericJackson2JsonRedisSerializer 内部做的事。选择自带序列化器还是自实现,本质是「要不要把类型元数据写进缓存值」的取舍:写了更省心但值更大、且与类名强绑定;不写更省空间但要求调用方自己知道类型。

8.2.4 连接池:Lettuce 共享连接 vs Jedis 池

Spring Boot 4.x 默认客户端是 Lettuce,也可切 Jedis:

spring:
  data:
    redis:
      client-type: lettuce   # 或 jedis

两者模型完全不同,不是「换个实现」那么简单:

维度LettuceJedis
连接模型基于 Netty,默认共享一条线程安全连接,多路复用每个连接非线程安全,必须靠连接池
默认是否需要 pool不需要(shareNativeConnection=true)需要,否则并发下要不断建连
阻塞命令会占用连接,需谨慎每个操作独占一条池连接
需要池的场景阻塞命令(BLPOP 等)、事务、想限制并发时基本总是需要

关键认知:Lettuce 不加池不是「省配置」,而是它的默认工作方式。一条共享连接靠 Netty 多路复用就能扛住常规的读写;盲目给 Lettuce 加池反而会引入「连接数量与池参数不匹配」的调优负担。只有当你要用阻塞命令、或想给某类操作单独限流时,才给 Lettuce 配池:

spring:
  data:
    redis:
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 2
          max-wait: 500ms
        shutdown-timeout: 200ms
        read-from: replica-preferred   # 3.5 起支持,读优先走副本

read-from(spring.data.redis.lettuce.read-from)用于读多写少的缓存场景,让读请求优先落到副本,减轻主节点压力——它只在 Lettuce 下有效。

8.2.5 静态主从:4.0 新增的接入方式

除了哨兵(Sentinel)与集群(Cluster),4.0 为 Lettuce 增加了静态主从配置,直接给节点列表:

spring:
  data:
    redis:
      masterreplica:
        nodes:
          - redis-master:6379
          - redis-replica-1:6379
          - redis-replica-2:6379

spring.data.redis.masterreplica.nodes 是 4.0 新增属性(见 4.0 Release Notes),仅 Lettuce 支持。它适合主从拓扑固定、不依赖自动故障转移的场景;需要自动选主仍应用 Sentinel。

8.2.6 键命名规范与内存估算

键命名要满足三条:可读、可批量定位、可估算容量。推荐的层级式命名:

cache:book:{id}              # 图书详情
cache:bookByIsbn:{isbn}      # 按 ISBN 查
cache:loanStats:{memberId}   # 会员借阅统计

冒号分层便于 SCAN 匹配(如 SCAN 0 MATCH cache:book:*)和监控按前缀统计。两条硬性建议:给所有缓存键统一前缀(与业务数据隔离,便于按前缀清理),键里不要放无界值(如把整个查询条件 JSON 塞进 key,会造出超长键)。

内存估算按「键大小 + 值大小 + 每键固定开销」逐项累加。Redis 每个键本身有几十字节的对象头与字典开销,键名越长这笔固定成本越大。估算方法不要拍脑袋:

# 示例输出:查看单个键占用的内存(含对象开销)
127.0.0.1:6379> MEMORY USAGE cache:book:1001
(integer) 336

# 示例输出:全局内存与峰值
127.0.0.1:6379> INFO memory
used_memory_human:12.50M
used_memory_peak_human:18.20M
maxmemory_human:256.00M

MEMORY USAGE 返回的是这条键的真实占用,用它乘以条目数就能得到量级估算;INFO memory 看总量与是否逼近 maxmemory。容量上限必须显式设 maxmemory 并配淘汰策略,否则 Redis 会一直吃内存直到触发系统 OOM:

# 属于 Redis 服务端配置(redis.conf 或 CONFIG SET),不是 Spring 属性
maxmemory 256mb
maxmemory-policy allkeys-lru

给纯缓存实例用 allkeys-lru 或 allkeys-lfu;若实例里混存了不能丢的数据,用 volatile-* 系列并确保缓存键都设了 TTL——否则淘汰时可能挑不到可淘汰的键。

8.2.7 大 key 与热 key

线上缓存事故绝大多数可归为两类:

类型定义危害对策
大 key单个值过大(如一个几十 KB 的 JSON、几十万元素的集合)读写慢、网络传输大、删除时阻塞主线程、集群迁移卡顿拆分结构、压缩值、改用 Hash 分片、避免整集合读写
热 key单个键的 QPS 远高于其他(如全站首页配置)单分片/单节点被打满,成为全局瓶颈本地缓存兜底、键加随机后缀打散、读写分离走副本

大 key 的发现靠 redis-cli --bigkeys(扫描各类型最大的键)或 --memkeys(按内存排序):

# 示例输出:扫描大 key(节选)
redis-cli --bigkeys
# Scanning the entire keyspace to find biggest keys.
# Biggest string found 'cache:loanStats:all' has 51200 bytes

热 key 的发现不能靠离线扫描,得靠实时监控:看 INFO commandstats 里单键命令量,或用 Redis 的 MONITOR / 慢查询日志,或在上层用 Micrometer 给每个缓存名的访问量打点。

对策上,大 key 是数据建模问题(不该把一个大对象整体塞进一个键),热 key 是访问分布问题(该把压力摊开或前移)。两者都别指望靠调大 maxmemory 解决。

8.2.8 4.1 新增:@RedisListener 自动配置

4.1 新增了对 Spring Data Redis @RedisListener 端点的自动配置(见 4.1 Release Notes)。要点:

  • 应用没有自定义 RedisMessageListenerContainer 时,自动配置会注册一个默认容器,于是标注了 @RedisListener 的方法能被自动发现并调用,无需额外接线。
  • 可选参数见 spring.data.redis.listener.*。
  • 需要额外容器时,用 RedisMessageListenerContainerConfigurer 在自建容器上套用与自动配置一致的默认值。
  • spring-boot-starter-data-redis 现在额外声明了对 spring-messaging 的依赖,正是这个特性所需。

它解决的是「缓存失效广播」这类需求:某个节点更新了图书数据,通过 Redis 发布订阅把失效事件广播给其他节点,各节点清掉本地缓存。示例结构如下(具体注解属性以 Spring Data Redis 4.x 文档为准):

@Component
public class CacheInvalidationSubscriber {

    // 4.1 起由自动配置发现并绑定到默认的 RedisMessageListenerContainer
    @RedisListener
    public void onBookChanged(BookChangedEvent event) {
        // 收到其他节点广播的失效事件后,清理本地缓存
        localCache.invalidate(event.bookId());
    }
}

注意 @RedisListener 是消息订阅能力,不是缓存抽象的一部分——它和 @Cacheable 解决的是两件事:前者让节点间能互相通知「数据变了」,后者负责本地读写缓存。8.3 讲一致性时会把两者接起来。

小结

  • 依赖加 spring-boot-starter-data-redis;用连接池(Lettuce pool 或 Jedis)时还必须加 commons-pool2。4.x 属性前缀是 spring.data.redis.*。
  • timeout 与 connect-timeout 必须显式设置,否则 Redis 变慢会直接拖垮业务线程。
  • 默认的 JDK 序列化不可读、体积大、与类结构强绑定、有安全风险;生产用「字符串键 + JSON 值」,且 key 与 hashKey 都要设字符串序列化器。
  • 4.x 首选 JSON 库是 Jackson 3(包名 tools.jackson);GenericJackson2JsonRedisSerializer 基于 Jackson 2。优先用自动配置的 Jackson 3 JsonMapper 自实现序列化器,避免两套 JSON 栈并存。
  • Lettuce 默认共享单连接、靠多路复用,默认不需要池;Jedis 非线程安全、基本总需要池。需要池的场景是阻塞命令、事务或主动限流。
  • 4.0 新增静态主从 spring.data.redis.masterreplica.nodes,仅 Lettuce 支持。
  • 键用层级命名并统一前缀;内存按「键 + 值 + 每键固定开销」估算,用 MEMORY USAGE 与 INFO memory 核实,并显式设 maxmemory 与淘汰策略。
  • 大 key 是建模问题(拆分/压缩),热 key 是访问分布问题(本地缓存/打散/走副本);发现分别靠 --bigkeys 与实时监控。
  • 4.1 的 @RedisListener 自动配置让订阅端点免接线,是节点间失效广播的基础设施。

缓存怎么放、怎么存已经定了。但「数据变了以后缓存怎么办」才是真正难的部分,也是下一节的全部主题。

阅读导航:上一节:8.1 Spring Cache 抽象 · 下一节:8.3 缓存一致性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计