Spring Boot集成Redis缓存实战:从序列化到分布式锁
2026/9/16 3:56:07 网站建设 项目流程

做后端开发这些年,只要聊到缓存,Redis基本是绕不开的选项。Spring Boot 作为 Java 生态里最主流的开发框架,和 Redis 的组合几乎成了企业级项目的标准配置。这篇文章我想把 Spring Boot 集成 Redis 做缓存的完整链路梳理一遍,从环境安装、依赖配置、序列化方案、注解缓存,到缓存一致性、分布式锁、常见坑排查,都给你过一遍。不管你是刚接触 Spring Boot 的新手,还是已经在用 Redis 但老是遇到缓存不生效、key 乱码这类问题的老手,这篇文章应该都能给你一些可以落地的参考。

我不会从零开始讲什么是 Redis,因为这类文章太多了。我重点想说的是:在实际项目里,怎么把 Redis 缓存用好、用稳、用得不踩坑。毕竟 Redis 本身不难,难的是缓存和数据库之间的数据一致性,难的是缓存穿透、击穿、雪崩这些线上故障怎么防,难的是你写出来的缓存代码过两个月再看还能不能看懂。

1. 缓存设计思路:先想清楚什么该缓存,再动手写代码

1.1 缓存解决的核心问题与适用场景

在写任何一行缓存代码之前,先问自己一个问题:这个数据真的需要缓存吗?很多新手一上来就把所有查询结果往 Redis 里塞,结果内存暴涨、数据不一致,最后还得连夜删缓存。我见过不止一次这样的线上事故。

适合缓存的数据通常有三个特征:读多写少、实时性要求不高、数据量可控。典型的例子就是用户信息、商品详情、配置项、热点新闻列表。这些数据的特点是:用户请求量大,每次查数据库都是同样的 SQL、同样的结果,查一百次和查一次结果几乎一样,那为什么不把第一次查出来的结果放到内存里,后面直接取呢?

反过来,不适合缓存的数据也很明显:频繁更新的数据(比如库存数量)、强一致要求的数据(比如账户余额)、一次性使用且无法复用的数据(比如动态生成的验证码虽然也放 Redis,但那是另一种用途)。把这些硬要塞进缓存,只会给自己找麻烦。

生活里有个类比很贴切:缓存就像是你办公桌上常放的几本工具书。你天天要翻的那几本,放在手边能省不少事;但你一整年都用不上一回的厚字典,放到手边只会占地方,还不如放回书柜里。这就是缓存的"局部性原理"——程序访问数据的规律也类似,90% 的流量往往集中在 10% 的数据上。

1.2 Spring Boot 里做缓存的主流方案选型

现在 Java 生态里做缓存,主流方向就两个:本地缓存(Caffeine、Guava Cache)和分布式缓存(Redis、Memcached)。两者没有谁绝对好,要看场景。

本地缓存的优势是快,因为它就在应用进程内,没有网络开销,微秒级别就能拿到数据。但问题也很明显:应用多实例部署时,每个实例的缓存是独立的,A 实例更新了缓存,B 实例还是旧数据,这就导致数据不一致。而且本地缓存占用的是 JVM 堆内存,堆内存就那么大,缓存多了会影响应用本身的性能。

Redis 作为分布式缓存,正好解决本地缓存的痛点。它是独立部署的服务,所有应用实例共享同一份缓存数据,天然就是一致的。而且 Redis 支持持久化、过期策略、丰富的数据结构和分布式锁,功能上完全碾压本地缓存。当然代价就是多了一次网络 IO,一般也就 1ms 左右,对于绝大多数业务场景完全可接受。

在 Spring Boot 里接入 Redis 缓存,核心引入的依赖是这个:

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

这个 starter 会帮你把 RedisTemplate、StringRedisTemplate、RedisConnectionFactory 这些核心组件全部自动配置好。如果你还需要使用 Spring Cache 抽象(就是下面要讲的 @Cacheable 那一套注解),还需要加上:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>

所以整个方案选型就是:Redis 存数据,Spring Cache 管流程,RedisTemplate 干细活。前者解决"数据放哪里"的问题,后两者解决"怎么放、怎么取、怎么维护"的问题。这套组合在企业项目里经得起实践检验,网上能搜到的成熟方案也基本都是这个套路。

2. 环境准备与基础集成:先把 Redis 跑起来

2.1 Redis 安装的三种方式与推荐

要开发调试 Redis,你本地总得先有一个能跑的 Redis 实例。安装方式主要有三种:

第一种:本机直接安装。Windows 用户可以去 Redis 官网下载 MSI 安装包(不过 Redis 官方其实对 Windows 支持不积极,目前 Windows 版本的维护是由别的团队在跟进,下载时注意看版本);Linux 用户可以直接apt install redis-server(Ubuntu/Debian)或者yum install redis(CentOS)。装完之后redis-cli ping返回 PONG 就说明启动成功了。

第二种:Docker 方式。这是我个人最推荐的开发方式,好处是环境隔离、版本切换方便、不会污染本机。一条命令就搞定:

docker run -d --name redis \ -p 6379:6379 \ -v /data/redis:/data \ redis:7.0 \ --requirepass yourpassword --appendonly yes

这里解释一下几个参数:-p 6379:6379把容器内的 6379 端口映射到宿主机,这样本机应用能直接访问;-v挂载数据目录,防止容器删了数据全丢;--requirepass设置访问密码;--appendonly yes开启 AOF 持久化。如果是本地开发,密码可以先用简单的;生产环境密码必须复杂,而且建议关掉一些危险命令。

想看 Redis 里到底存了什么 key、key 的过期时间还剩多久,我习惯配一个可视化工具。网上搜 Redis Desktop Manager(现在改名叫 Another Redis Desktop Manager)的版本很多,选一个开源免费的下下来,填上 host、port、password 就能连上。它对排查序列化乱码这类问题特别有用,你可以直观地看到 key 是什么样的、value 是怎样的格式,比在命令行里敲 keys 命令直观得多。

第三种:云服务。如果你的项目已经部署在云上,直接用云厂商提供的 Redis 服务最省事,主从、持久化、监控都帮你做好了,就是得花钱。开发环境还是本地起一个吧。

2.2 Spring Boot 接入 Redis 的基础配置

依赖引好了,Redis 服务也跑起来了,接下来就是在 Spring Boot 里做配置。下面是 application.yml 里的基础配置,我加了注释说明每个参数的作用:

spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword # 没有密码就留空 database: 0 # 默认连 0 号库,多业务可隔离 timeout: 3000ms # 连接超时时间 lettuce: pool: max-active: 16 # 连接池最大连接数 max-idle: 8 # 连接池最大空闲连接 min-idle: 2 # 连接池最小空闲连接 max-wait: 3000ms # 获取连接超时时间

注意这里的缩进是spring.data.redis(Spring Boot 3.x 的写法),不是spring.redis。如果你用的还是 Spring Boot 2.x,那前缀是spring.redis。这是个很隐蔽的坑,版本升级之后配置文件没改,结果连接不上 Redis,报错你都不一定反应得过来。

关于连接池,Spring Boot 2.x 之后默认用的是 Lettuce 而不是 Jedis。为什么换?因为 Jedis 是阻塞式 IO,在多线程环境下性能不够好;Lettuce 基于 Netty,底层是异步非阻塞 IO,虽然你用的时候感知不到,但高并发下性能差距是实打实的。这个选型是 Spring 官方做的,你不用自己纠结,直接用默认的就行。唯一要留意的就是连接池参数,默认的 8 个连接在并发高的时候可能不够,压测的时候注意观察。

配置完成后,怎么验证连通性?最直接的办法是写个测试用例跑一下:

@SpringBootTest class RedisConnectionTest { @Autowired private StringRedisTemplate stringRedisTemplate; @Test void testConnection() { stringRedisTemplate.opsForValue().set("test:conn", "hello-redis"); String value = stringRedisTemplate.opsForValue().get("test:conn"); System.out.println("获取到的值为: " + value); Assertions.assertEquals("hello-redis", value); } }

能正常打印出来,说明你的 Spring Boot 和 Redis 已经打通了。这里我用的是 StringRedisTemplate 而不是 RedisTemplate,原因下一节详细讲。

3. 缓存核心实操:从序列化方案到注解缓存

3.1 序列化方案对比:为什么你的 key 全是乱码

很多初次在 Spring Boot 里用 Redis 的开发者都会遇到一个怪问题:用 RedisTemplate 存了个 key,用 Redis Desktop Manager 一看,key 变成了类似\xac\xed\x00\x05t\x00\x04name这样的一堆看不懂的东西,value 也完全没法读。这就是序列化问题。

Redis 本身只认字节,Java 对象要存进去,得先序列化成字节数组;从 Redis 取出来,得反序列化成 Java 对象。这个过程用什么序列化器,直接决定了你存进去的东西长什么样。

Spring Boot 默认提供的 RedisTemplate,用的序列化器是JdkSerializationRedisSerializer,也就是 JDK 原生的序列化方式。这种方式的优点是实现简单、不用引入额外的库,但缺点也很致命:序列化后的数据里带着类名、包名这些元信息,体积大、可读性差,而且跨语言、跨版本都不友好。这就是你看到一堆乱码的根本原因。

解决方案是自定义序列化器,把 key 序列化成字符串,把 value 序列化成 JSON。主流做法是 key 用StringRedisSerializer,value 用GenericJackson2JsonRedisSerializer。key 用字符串是为了可读性,你可以在 Redis Desktop Manager 里直接看到user:info:1001这样的 key,一眼就知道它是干嘛的;value 用 JSON 是为了体积可控、跨语言通用。

下面是配置类的完整代码:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key 使用字符串序列化 StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); // value 使用 JSON 序列化 GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer(); // 设置 key 和 hash key 的序列化器 template.setKeySerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); // 设置 value 和 hash value 的序列化器 template.setValueSerializer(jackson2JsonRedisSerializer); template.setHashValueSerializer(jackson2JsonRedisSerializer); template.afterPropertiesSet(); return template; } }

注意两个细节。第一,hashKeyhashValue的序列化器也要单独设置,如果你用 Redis 的 Hash 结构存数据(比如存一个对象的多个字段),不设置的话还是会走默认的 JDK 序列化,key 照样乱。第二,GenericJackson2JsonRedisSerializer序列化后的 value 会额外存一个@class字段,里面是类的全限定名,这是为了反序列化时能知道目标类型。这也带来一个小问题:类路径变了或者类名改了,老数据就反序列化不了了。

GenericJackson2JsonRedisSerializerJackson2JsonRedisSerializer的区别也值得说一句。前者在序列化时会带上类型信息,支持多态:同一个 key 存进去的是 User 对象,读出来还能正确转成 User。后者你需要手动指定类型,比如new Jackson2JsonRedisSerializer<>(User.class),性能和紧凑性更好,但只能处理指定类型的对象。实际项目中如果你只缓存固定类型的对象,用后者更稳;缓存类型不固定,就用前者。

3.2 用 Spring Cache 注解实现声明式缓存

上面说了 RedisTemplate 的用法,但实际项目里,我更推荐用 Spring Cache 抽象层来写缓存代码。为什么?因为 RedisTemplate 是命令式的,你要自己判断缓存有没有命中、手动往 Redis 里塞数据、手动删数据,代码侵入性很强。而 Spring Cache 是声明式的,你只需要在方法上标注注解,缓存逻辑框架自动帮你完成,业务代码干净很多。

用之前先开启缓存功能,在启动类或者配置类上加@EnableCaching

@EnableCaching @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }

然后就可以在 Service 层方法上写注解了。最常用的三个注解是@Cacheable@CachePut@CacheEvict,区别我整理成了表格:

注解执行时机适用场景
@Cacheable方法执行前先查缓存,命中直接返回,不执行方法;未命中才执行方法并缓存结果查询操作
@CachePut方法执行后,把返回值放到缓存里,不干扰方法本身执行新增/更新操作后刷新缓存
@CacheEvict方法执行后,从缓存中移除指定 key删除操作后移除缓存

一个典型的示例是这样的:

@Service public class UserService { @Autowired private UserMapper userMapper; @Cacheable(cacheNames = "user:info", key = "#id") public User getUserById(Long id) { // 第一次查询会进到这里,走数据库 return userMapper.selectById(id); } @CachePut(cacheNames = "user:info", key = "#user.id") public User updateUser(User user) { userMapper.updateById(user); // 方法执行后,返回值自动写入缓存,保证缓存是最新的 return user; } @CacheEvict(cacheNames = "user:info", key = "#id") public void deleteUser(Long id) { userMapper.deleteById(id); } }

这里几个参数要解释一下。cacheNames是缓存分区的名字,相当于给缓存 key 一个命名空间,最终 Redis 里的 key 会是user:info::1001这种形式,两个冒号是默认的分隔符。key用 SpEL 表达式从参数中取值,#id就表示参数列表里的 id 变量。为什么要加 cacheNames?因为不同业务的缓存 key 可能会有交集,用 cacheNames 区分开就不会互相覆盖,后续排查也好查。

还有一些高级用法值得掌握。比如conditionunless,两个都能做条件过滤,但机制不同:

// 只有当 id 大于 0 时才走缓存逻辑 @Cacheable(cacheNames = "user:info", key = "#id", condition = "#id > 0") // 只有当返回值不为 null 时才放入缓存 @Cacheable(cacheNames = "user:info", key = "#id", unless = "#result == null")

一个常见误区是把conditionunless搞混。condition是在方法执行前判断,决定了要不要执行缓存逻辑;unless是在方法执行后判断,决定了返回值要不要放进缓存。如果查询结果是 null,你却把它缓存了,后面频繁查这个不存在的 id,缓存里就一直存着 null,就相当于变相放大了缓存穿透问题。所以除非你刻意要做"空值缓存",否则建议习惯性加上unless = "#result == null"

3.3 缓存的过期时间策略与主动失效

用注解缓存的时候,一个容易忽略的问题是:过期时间怎么配置?@Cacheable 注解本身没有直接设置 TTL 的参数,默认情况下缓存是不过期的。这就很危险,如果你缓存的数据发生了变化而你又忘了调用 @CacheEvict 删缓存,那用户拿到的就是永久过期的脏数据。

正确的做法是通过RedisCacheManager统一配置过期时间。代码是这样的:

@Configuration public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) // 默认过期时间 30 分钟 .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .computePrefixWith(name -> name + "::"); // key 前缀分隔符 return RedisCacheManager.builder(factory) .cacheDefaults(config) .withInitialCacheConfigurations(customTtlMap()) // 为特定缓存单独设置过期时间 .build(); } private Map<String, RedisCacheConfiguration> customTtlMap() { Map<String, RedisCacheConfiguration> map = new HashMap<>(); map.put("user:info", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(2))); map.put("hot:list", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(5))); return map; } }

这样配置之后,user:info里的缓存 2 小时过期,hot:list里的缓存 5 分钟过期,其他缓存默认 30 分钟过期。不同的数据有不同的时效性,分开设置更合理。

除了过期,缓存还需要主动失效的场景。比如用户修改了头像,你希望立即删掉旧缓存,下次请求再加载新的。这时候用@CacheEvict就能实现。它的beforeInvocation参数值得注意:默认是 false,也就是方法执行成功后才删缓存;如果设为 true,会在方法执行前就删除缓存。什么时候用 true?当方法可能抛异常,但你又想在异常时也把缓存清掉,或者你想先删缓存再执行数据库操作以避免并发下读到旧数据,就可以用 true。

还有一种比较重的用法是多条缓存一起清:

@Caching(evict = { @CacheEvict(cacheNames = "user:info", key = "#id"), @CacheEvict(cacheNames = "user:list", allEntries = true) }) public void deleteUserAllCache(Long id) { userMapper.deleteById(id); }

allEntries = true表示清空整个user:list分区下的所有缓存。这种操作要慎用,因为如果分区下的 key 很多,Redis 内部要遍历删除,在高并发下会造成短暂的卡顿。不过对于数据量不大、变更不频繁的业务,这样的"粗暴"反而省心。

关于缓存预热,如果你的项目启动后有一批热点数据马上会被大量访问,可以在启动时预先把数据加载到 Redis 里,避免第一个用户请求时全部打到数据库。实现方式很简单,用ApplicationRunner或者CommandLineRunner在 Spring Boot 启动完成后执行预加载逻辑,再从数据库查出来写入 Redis。如果数据会周期性变化,还可以配合@Scheduled定时刷新。

4. 缓存一致性、分布式锁与线上故障排查

4.1 缓存一致性问题的本质与常用方案

缓存一致性是整个缓存体系里最让人头疼的问题。简单说就是:数据库里的数据和 Redis 里的数据不一致了,用户读到的可能是旧的、甚至错的数据。

为什么会出现不一致?根本原因是同一个数据有两份存储(数据库一份、缓存一份),而更新这两份的操作不可能做到原子。比如你先更新数据库,再删缓存,这两步之间如果有一个请求正好读缓存,读到的就是旧值。反过来说,你先删缓存,再更新数据库,中间这一步如果有请求进来,发现缓存没有,就去数据库读数据然后写回缓存,写回的却是旧数据,等数据库更新完,缓存还留着旧的。

业界常用的数据一致性方案有几种,我按实践中的使用频率排序:

方案一:Cache Aside Pattern(旁路缓存)。这是最主流、最推荐的做法。读的时候先读缓存,读不到再读数据库,然后回填缓存;写的时候先更新数据库,再删除缓存,而不是更新缓存。为什么删除而不是更新?因为删除的代价远小于更新,而且可以避免并发写时缓存里存了旧值的情况。这个方案简单可靠,缺点是有短暂的不一致窗口,但对大多数业务完全够用。

方案二:延迟双删。在 Cache Aside 的基础上,更新数据库后先删一次缓存,然后延迟几百毫秒再删一次。这个"双删"就是为了处理高并发下的竞态条件:第一次删除后,一个旧请求读到旧数据并写回缓存,第二次删除把这个脏数据清掉。这个方案能进一步降低不一致的概率,但它是一个"概率性"方案,不是百分之百可靠,而且延迟时间怎么定是个玄学,需要根据业务压测来调。

方案三:订阅 Binlog 异步更新缓存。用 Canal 这类工具监听 MySQL 的 Binlog,数据变更后异步把最新的数据推送到 Redis。这个方案解耦性最好,应用层完全不用写缓存更新逻辑,但引入 Cannel 增加了系统复杂度,一般中大型项目才值得做。

实践中我的建议是:中小项目直接用 Cache Aside + 合适的过期时间。过期时间就是兜底方案,即使中间某一次缓存没删干净,到了过期时间也会自动失效,不会永远不一致。线上出问题后,优先检查是不是有缓存忘记删了,再考虑要不要上更复杂的方案。

4.2 缓存穿透、击穿、雪崩的防御与分布式锁实现

这三个是缓存领域最经典的故障模式,我在文章里把它们放一起讲,因为它们的本质都是缓存失效或未命中导致请求打到数据库,但原因和应对手段完全不同。

缓存穿透:查询一个根本不存在的数据。由于数据不存在,缓存永远不可能命中,每次请求都打到数据库。如果有人恶意用一个不存在的 id 反复刷接口,数据库压力会非常大。防御手段有两个:一是把空值也缓存起来,设置较短的过期时间(比如 30 秒),代码里就是unless = "#result == null"不生效,改成判断 null 后手动写入一个空对象;二是用布隆过滤器,先判断 id 是否存在,不存在直接返回,但布隆过滤器有误判率且不支持删除,实现成本略高。我建议先用空值缓存,简单有效,大部分场景够用。

缓存击穿:某个热点 key 刚好过期,此时大量并发请求同时发现缓存未命中,全部打到数据库。和穿透的区别是:穿透是数据本来就不存在,击穿是数据存在但缓存恰好失效了。防御的核心思想是互斥锁:只让一个请求去数据库加载数据并回填缓存,其他请求等缓存回填后再读缓存。用 Redis 实现一个分布式锁就派上用场了。

Redis 分布式锁的经典实现是SETNX+ 过期时间。简单说就是利用 Redis 的原子操作,只有一个客户端能成功设置某个 key 的值,设置成功的人拿到锁,其他人拿不到。Spring Boot 里用 RedisTemplate 可以这样写:

public boolean tryLock(String lockKey, String requestId, long expireSeconds) { // 利用 Redis SET NX EX 原子操作,避免并发问题 // 设置成功返回 true,说明拿到锁 return redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds)); } public void unlock(String lockKey, String requestId) { // 这里要注意:只能释放自己持有的锁,通过 requestId 校验 String value = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(value)) { redisTemplate.delete(lockKey); } }

这个实现还有几个细节要注意:key 必须加过期时间,防止拿到锁的线程崩溃导致死锁;释放锁时必须比较 value 是不是自己的(用 requestId 标识),防止误删了别人后来获取的锁。如果你不想自己封装这些细节,直接引入 Redisson,它把可重入锁、自动续期这些都实现了,开箱即用,这也是当前企业项目里最常用的分布式锁方案。

缓存雪崩:大量 key 在同一时间过期,或者 Redis 服务整体挂了,导致所有请求全部打到数据库,数据库扛不住就也挂了。防御思路:一是给过期时间加随机值,比如entryTtl(Duration.ofMinutes(30 + RandomUtil.randomInt(10))),让 key 的过期时间错开,避免同时失效;二是做多级缓存,本地缓存 + Redis 兜底,Redis 挂了本地还能挡一下;三是 Redis 做高可用部署,主从加哨兵,不用裸奔的单机 Redis。

4.3 缓存监控与排查:线上出问题别慌,按这个顺序查

最后聊聊线上问题的排查思路。缓存相关的线上故障,最常见的无非这么几类:连接超时、缓存不生效、key 乱码、缓存穿透压垮数据库、Redis 内存暴涨。遇到问题先别慌,按下面的顺序排查。

第一步:确认 Redis 连接状态和健康情况。Spring Boot Actuator 默认提供了 Redis 的健康检查指标,配置一下就能从/actuator/health看到 Redis 状态:

management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: always

访问这个接口,如果 Redis 挂了你会直接看到"status": "DOWN"。这是最快判断 Redis 是否活着的方式。

第二步:确认缓存有没有命中。如果你用了 Redis Desktop Manager,直接看缓存 key 是否存在。如果 key 存在但值不对,那是数据一致性问题;如果 key 不存在,说明缓存没生效或者已经被清掉了,要去查代码逻辑。

第三步:确认序列化是否正常。如果你发现 Redis Desktop Manager 里 key 是乱码,那就是序列化器配错了。解决办法就是前面说的,自定义序列化器,key 用 StringRedisSerializer。

这里再分享一个特别常见的问题:使用 @Cacheable 缓存了对象,但取出来转型报ClassCastException。原因通常是序列化器配置不一致:存的时候用的是 JSON 序列化,读的时候因为类型信息丢了或者类路径变了,反序列化出的类型不对。排查方法就是看代码里 RedisTemplate 和 RedisCacheManager 的序列化器是不是一致的,检查有没有地方用的是默认的 JdkSerializationRedisSerializer。

我把常见问题整理成了一张速查表,方便你遇到问题直接对照:

问题现象可能原因排查与解决
key 显示为乱码RedisTemplate 默认 JDK 序列化自定义序列化器,key 用 String 序列化
@Cacheable 不生效没加 @EnableCaching检查启动类或配置类是否开启缓存
缓存数据一直不变没配过期时间或没主动清缓存配置 TTL 结合 @CacheEvict 清理
数据库压力大可能存在缓存穿透或击穿空值缓存/布隆过滤/分布式锁
连接池报错连接池太小或没释放连接调大 max-active、max-wait 参数
Redis 内存暴涨大量 key 未设置过期时间统一使用 setex / 检查 TTL 配置
ClassCastException序列化类型不匹配统一序列化器,确认类型信息

排查缓存问题有一个通用原则:先看缓存层有没有数据,再看数据对不对,最后看代码逻辑通不通。不要一上来就翻代码,先用工具把缓存层的情况摸清楚,大部分问题就已经能找到方向了。

5. 进阶实践:Redis 数据类型、主从与扩展思路

5.1 Redis 数据类型选型:别只会用 String 存一切

聊到 Redis 缓存,大部分开发者第一反应就是 String 类型。但实际上 Redis 提供了丰富的数据结构,选对了类型能给业务带来很大的便利。

String 是用的最多的类型,适合存简单的键值对,比如用户信息 JSON、配置项、验证码等。Hash 类型适合存对象的多个字段,比如你需要单独修改用户头像字段而不用把整个对象序列化再读出来改一遍再序列化回去,Hash 就高效得多,hset user:info:1001 avatar "new.jpg"只改一个字段,不用动整个对象。

List 类型适合做消息队列或者简单的顺序列表,比如秒杀场景的请求排队。Set 类型天然去重,适合做标签系统、好友关系这类需要交集并集运算的场景。ZSet(有序集合)带 score 排序,适合排行榜这类高频业务,ZADD 加分数、ZREVRANGE 查前 N 名,都是 O(logN) 的复杂度,非常高效。

实际项目中一个对象该用 String 存 JSON 还是 Hash 存字段,要看你的访问模式。如果读取时总是全字段查询,String 存 JSON 更合适;如果经常要修改局部字段,Hash 更合适。没有绝对的对错,关键看你的业务访问模式。

5.2 Redis 主从复制与生产环境部署

如果你的项目正式上线了,我强烈不建议再使用单机 Redis。单机意味着单点故障:Redis 挂了,你的缓存全部失效,数据库直接裸奔。生产环境至少要做主从复制,一台主节点负责写,一台或多台从节点负责读,主节点挂了从节点可以顶上来。

Docker 部署 Redis 主从也很方便,大致思路是启动两台 Redis 容器,在从节点的配置文件里指定主节点的地址和密码:

# 启动主节点 docker run -d --name redis-master -p 6379:6379 redis:7.0 # 启动从节点,并指定主节点地址 docker run -d --name redis-slave -p 6380:6379 redis:7.0 \ redis-server --slaveof 127.0.0.1 6379

这只是个演示命令,实际配置里主从都需要设置密码,从节点的masterauth要配上。更高级的高可用方案是 Redis 哨兵(Sentinel)或者 Redis Cluster,哨兵负责监控和自动故障转移,Cluster 则解决了大数据量分片存储的问题。这些属于运维层面的进阶内容,有兴趣可以再深入研究,但了解思路对理解 Redis 的定位很有帮助。

5.3 从缓存到分布式锁:Redis 的更多应用场景

文章到了这里,Spring Boot 集成 Redis 实现缓存的主线已经讲完了。但 Redis 在实际项目里的价值远不止缓存。分布式锁就是一个最常见的高级应用,我在前面讲缓存击穿时已经提过,很多业务场景都需要它:多个服务实例处理同一个订单、分布式定时任务防止重复执行、秒杀扣减库存避免超卖。Redis 分布式锁之所以受欢迎,是因为它实现简洁、性能高,比基于数据库的乐观锁更适合同一时刻大量请求的场景。

还有限流,利用 Redis 的 INCR 加过期时间,可以方便地实现固定窗口限流;用 ZSet 可以实现滑动窗口限流;配合 Lua 脚本还能实现更复杂的令牌桶算法。Redis 的原子性保证了这些计数的准确性,又因为单线程模型天然避免并发竞争,在限流场景下非常可靠。

Redis 作为缓存组件在企业级 Java 项目中的地位,几乎和 Spring Boot 本身一样重要。从 Spring Boot 2.x 到 3.x,官方对 Redis 的支持越来越完善,lettuce 客户端 + Spring Cache 抽象 + 自定义序列化方案的组合,已经是我在项目里最顺手的搭配。缓存设计的核心不是 Redis 怎么配,而是想清楚什么数据该缓存、缓存多久、怎么保证一致,把这些想明白了,后面的代码水到渠成。

最后分享一个我踩过不少坑之后的经验:缓存代码一定要能快速人工干预。比如提供一个管理接口,可以手动查缓存、删缓存、刷新缓存,出问题的时候你不用重启应用就有救火手段。这个接口平时不起眼,但关键时刻能救命。另外一个建议是日志里把缓存命中和未命中打出来,加个开关控制日志级别,线上排查的时候你会感谢当初多写了这两行日志。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询