说实话,做到苍穹外卖Day08这个节点,很多人会突然卡一下。前面几天还在吭哧吭哧写增删改查,到了这一天,主题一下子变成了性能优化,核心就两个字:缓存。你把手写的SQL、循环查库、界面卡顿这些问题过一遍之后,会发现一个残酷的现实——功能全都能跑,但用户如果真的拿手机点开看,那个加载速度根本不达标。Day08就是来解决这个问题的,而且解法非常工程项目化:引入Redis缓存,把高频查询的数据从数据库里搬到内存里,再用Spring Cache把重复代码收掉。这天的笔记我会按自己的理解重新整理一遍,重点不是抄代码,而是把为什么要这么设计、缓存什么时候失效、菜品改了怎么同步这些逻辑讲透。
如果你正在跟这个项目,或者你是一个Java后端初学者,想搞懂企业里到底怎么用Redis和Spring Cache,这篇笔记都适合你。内容会按实战顺序展开,最后附上我个人踩过的一些坑,希望你能绕开。
1. 从业务场景看缓存:到底在优化什么
1.1 首页查菜品的性能瓶颈在哪
苍穹外卖的业务链路并不复杂:用户打开小程序,进入首页,看到分类,再看到每个分类下的菜品。C端查询菜品的接口,背后是关联了分类表、菜品表、口味表,甚至还要拼装口味列表。不用有压力就能想象,这个接口被同时点击的时候,数据库要扛多少查询量。
一个关键点在于,这些菜品数据本身变化频率极低。菜品上架、下架、改价格,这些操作都是管理端偶尔做一次的事情。但用户端的高频读取,却每次都要走一遍完整SQL,甚至多次循环查询口味。这是典型的“读多写少”场景。数据库最怕的就是不必要的重复查询,而缓存最擅长的就是拦住重复查询。
Day08做的第一件事,就是识别出“这个查询值得缓存”。判断标准很简单:数据量小、读取频繁、更新频率低、一致性要求不那么极致。菜品缓存完美符合这四条。比如套餐、分类,其实也都符合条件,但当天笔记里项目是先拿菜品开刀,因为菜品的查询链路最重,优化效果最直观。
1.2 为什么选Redis而不是本地缓存
可能有人会想,既然要缓存,我用一个Map放在内存里不也行吗?放在单体应用里,短期内确实可以跑。但你要面对两个问题:第一,Java应用重启,缓存就丢了,冷启动时数据库还是得扛一波压力;第二,如果你以后部署多实例,每个实例各存各的缓存,数据就不一致了。
Redis的方案把缓存独立出来,作为一个共享的存储层。多实例部署时,所有实例访问同一个Redis,天然避免本地缓存不一致的问题。而且Redis的过期策略、持久化、淘汰机制都非常成熟,企业里基本是标配。特别是Spring Boot项目,用一个spring-boot-starter-data-redis依赖,配置一下连接地址,后面就是写代码的事。
另一个好处是,Redis除了能当缓存,还能用在后面的登录token存储、购物车、订单超时等场景。你花一天时间把Redis环境搞熟,后面很多天都会受益。Day08在这个阶段引入Redis,其实是在给后续课程铺路,不只是为了解决当下这个查询慢的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring Cache:把缓存代码从业务里解放出来
2.1 原生Redis代码的问题
如果有人上来直接用StringRedisTemplate写缓存,代码大概长这样:先查缓存,判断有没有,没有再查数据库,然后手动序列化塞回Redis。一个查询接口写下来,缓存判断的代码反而比业务代码还多。最要命的是,每个需要缓存的方法都要重复这一套,一旦要改缓存逻辑,就要改动所有方法。
Spring Cache就是来解决这个痛点的。它的思路是把“查缓存、写缓存、删缓存”这些动作抽象成注解,你只需要在方法上标注意图,由Spring在方法调用时自动拦截处理。业务代码里你看不到任何一行缓存逻辑,但它就是生效了。
Day08笔记里最核心的一个转变就是:从手写RedisTemplate,转到用Spring Cache注解。这个转变不是说后者一定比前者高级,而是说在“简单查询缓存”这个场景下,注解模式代码量更少、更不容易出错。等到你后面要缓存复杂对象、要手动控制序列化方式的时候,再退回手写RedisTemplate也来得及。
2.2 三个核心注解的用法和坑
Spring Cache里最常用的注解就是@Cacheable、@CachePut、@CacheEvict。我一个个说。
@Cacheable是加在查询方法上的,意思是我这个方法的返回值值得缓存。第一次调用时,方法正常执行,返回值被存进Redis;第二次再调用时,Spring发现Redis里已经有这个key了,就直接反序列化返回,不再执行方法体。value属性代表缓存组名,最终会拼在key前缀里;key属性是用SpEL表达式写的动态key。比如key = "#categoryId"就是拿方法参数做key。
这里有个特别要留神的坑: @Cacheable默认key不允许为null。如果你查询分类ID为null,Spring的KeyGenerator会报错,所以通常你需要在外层做好非空判断,或者用#root.methodName、#root.targetClass这些内置属性做兜底。项目里最常见的就是加个if (categoryId == null) return;在前置检查。
@CacheEvict是加在更新、删除方法上的,作用是把指定的缓存清掉。这个非常好理解:数据变了,缓存里旧数据就不能再用了,必须清理掉,让下一次查询重新走数据库并刷新缓存。属性里有个allEntries = true,表示清空整个缓存组下的所有key。比如菜品的分类和状态一变,所有分类下的菜品缓存可能都不准确了,与其精准匹配去删,不如整组清掉,简单粗暴但有效。
@CachePut是更新缓存而不是删除缓存,它会在方法执行后,把返回值写进缓存。这个注解在苍穹外卖里用得相对少,因为大多数数据处理都走删除缓存+下次重建的策略。这背后的权衡后面专门提。
2.3 为什么不选择缓存更新而是删除
我刚接触缓存的时候有一个疑惑:数据改了,我这里顺便把缓存也改掉不就行了?为什么非要删掉重新查?后来在实际开发中想明白了。
如果走缓存更新,你得保证新值和数据库一致。然而真实的更新操作往往涉及商品状态、口味、分类信息,可能影响好几条缓存数据。你算不清这个更新会影响到哪些key,就只能把相关的缓存全捞出来,挨个拼装新值再写回去。这个复杂度会随着业务逻辑膨胀,很容易出bug。
而缓存删除的思路非常朴素:我承认缓存可能已经不对了,那我就让它失效,下一次查询发现没有缓存,自然就会去查数据库,然后用最新的数据重建缓存。这是当前行业里很主流的缓存一致性方案,叫做Cache Aside Pattern。它的核心就两步:读的时候先读缓存,读不到就读数据库再回填;写的时候先更新数据库,再删除缓存。
这里有一个隐藏的先后顺序问题:先删缓存再更新数据库,和先更新数据库再删缓存,效果是不一样的。正确做法应该是先更新数据库,然后再删除缓存。因为在并发场景下,如果先删了缓存但数据库还没改完,另一个请求会查不到缓存,跑到数据库里读到旧数据回填,缓存里又变成旧值了。而先更新数据库再删缓存,即使删缓存这个动作稍微慢一点,至少瞬间的脏数据窗口很小。
Day08笔记里,@CacheEvict用的正是“更新数据库后删除缓存”这个套路。这也是后面面试时很常被追问的一个点,值得记牢。
3. 菜品缓存实战:从配置到代码一步步落地
3.1 环境准备和Redis配置
第一步肯定是把Redis依赖加进pom.xml。如果你之前没有接触过Redis,可以直接用这个:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
然后去application.yml里配置连接信息:
yaml复制spring:
redis:
host: localhost
port: 6379
password:
database: 0
如果你的Redis是docker起的,记得把6379端口映射到宿主机。这里说的database: 0就是使用第0号逻辑库,项目里一般单库就够用了。如果你有多个业务模块,可以按数字分库,但注意Spring Cache默认用的key不带库名前缀,如果你分库了,一定要确认你的cacheNames组名不会跟其他模块冲突。
紧接着是配置缓存管理器。这一步几乎每次都会被新手忽略。Spring Cache默认的RedisCacheManager,如果没有自定义序列化器,存进Redis的值会走JDK序列化,结果就是你在Redis里看到一串\xAC\xED\x00\x05t...这样的二进制垃圾数据。排查起来很痛苦,而且可读性极差。我自己第一次看到那个东西的时候,还以为代码写错了。
解决方法是配置一个RedisCacheConfiguration,设置JSON系列的序列化器:
java复制@Configuration
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
.entryTtl(Duration.ofMinutes(30));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
这个配置同时设置了缓存过期时间为30分钟,太短的过期时间会导致缓存命中率低,太长的又怕数据长期不更新,30分钟对菜品这种业务来说是个合适的折中值。配置完之后,你再往Redis里写缓存,看到的就都是可读的JSON了。
3.2 实现用户端查询菜品缓存
按正常流程,你应该先看原本的代码长什么样。苍穹外卖里C端查询菜品的入口是DishController,里面调用DishService,然后DishService会去查菜品表、分类表、口味表。我们的目标就是把这段查询缓存起来。
首先在启动类上加上@EnableCaching,这一步不做,后面所有的缓存注解都不会生效。很多人漏掉这一步,一直以为自己的注解写错了,排查半天发现只是一个激活开关的问题。
然后在DishService的实现类里,找到用户端查询菜品的方法,加上@Cacheable:
java复制@Override
@Cacheable(cacheNames = "dishCache", key = "#categoryId")
public List<DishVO> listWithFlavor(Long categoryId) {
// 断言 categoryId 不为空
if (categoryId == null) {
return new ArrayList<>();
}
// 这里是原本的查询逻辑,从数据库查菜品、口味再拼装 DishVO
}
这里有一个细节特别想提醒:如果你的categoryId可能为null,@Cacheable的key会出问题。所以要么你在Controller层拦截掉,要么在方法内先判断。我见过不少人直接在注解上写unless = "#result.size() == 0",意思是为空结果不缓存,但没处理key为null的情况,结果一上线就报错。这个顺序建议是:先判断参数再执行后续逻辑。
另一个容易被忽略的问题是,cacheNames要起得有意义。你叫dishCache,那所有菜品相关的缓存就都在这个组下。后面如果要清理菜品缓存,直接用同一个组名就可以整组清掉。你要是突发奇想给这个方法起名dishCache,另一个接口起名dishListCache,清理的时候就会很麻烦,因为得记得两组都要删。
3.3 菜品的增删改如何保持缓存同步
把查询缓存好之后,别忘了后面还有管理端操作。管理端修改菜品、删除菜品、起售停售,这些操作都会让数据库中的数据发生变化。如果不清缓存,用户端下一次查询还是走旧的缓存数据,菜品改了个价格,用户看到的还是老价格。
解决办法就是在修改菜品的Service方法上加@CacheEvict注解。
比如新增菜品:
java复制@Override
@CacheEvict(cacheNames = "dishCache", allEntries = true)
public void saveWithFlavor(DishDTO dishDTO) {
// 插入菜品表
// 批量插入口味表
}
修改菜品同理:
java复制@Override
@CacheEvict(cacheNames = "dishCache", allEntries = true)
public void updateWithFlavor(DishDTO dishDTO) {
// 更新菜品表
// 更新口味表
}
起售停售菜品:
java复制@Override
@CacheEvict(cacheNames = "dishCache", allEntries = true)
public void startOrStop(Integer status, Long id) {
// 更新状态
}
注意这里用的是allEntries = true,代表删除dishCache组下的所有缓存。原因很简单:一个菜品改了分类或者状态,受影响的不只是它自己,而是该分类下所有菜品的展示状态。精确删一条太费劲,整组清理是效率最高、最不易出错的做法。
这里还有个一直会被问到的点:为什么@CacheEvict没有指定key也能删对?因为你配合了allEntries = true,Spring会忽略key,直接把整个缓存组的所有条目全部清空。这个机制一定要和“只删指定key”区分开。
删除菜品:
java复制@Override
@CacheEvict(cacheNames = "dishCache", allEntries = true)
public void deleteBatch(List<Long> ids) {
// 删除菜品记录
// 删除菜品口味数据
}
同样的道理。删除操作的deleteBatch里,你不但要删数据库里的菜品,还要把菜品相关的口味表数据一并删掉。这时候如果你只清了缓存,却忘记删口味表,用户端下次查缓存时,缓存里还是旧的口味数据,这个问题排查起来非常隐蔽。
3.4 查看效果:缓存到底生效没有
代码写完,启动项目,用管理端给某个分类添加一个菜品,然后用用户端去查询。第一次查询时会走数据库,Redis里还没有缓存,所以会比较慢;第二次再查询时,日志里如果看不到SQL执行,说明缓存已经命中了。
如果MySQL开了慢查询日志,你可以在第一次查询后看到数据库有SQL执行,第二次查询没SQL,这个对比就是缓存生效的直接证据。
具体去看Redis里的数据,可以打开命令行敲:
bash复制redis-cli
keys dishCache*
你会看到一个类似dishCache::1的key,后面跟的是JSON格式的菜品列表数据。这个dishCache::1里的1,就是我们加在注解上的categoryId。这个命名格式是Spring Cache默认的“缓存组名::key”拼接规则。
如果你在Redis里看到的是类似\xAC\xED开头的乱码,说明你的序列化器没配置好。回到前面说的RedisCacheConfiguration,把序列化器换掉再重启项目。
4. 缓存穿透、击穿、雪崩与解决方案
4.1 缓存穿透:查一个不存在的东西怎么办
缓存穿透,指的是一个请求去查询一个数据库中根本不存在的数据。比如菜品分类ID传了一个999999,Redis里没有,数据库里也没有,每次请求都会直接打到数据库。如果这种请求量很大(可能是恶意刷接口,也可能是用户手滑传了一个非法ID),数据库就会承受无谓的压力。
这是一个缓存方案里非常经典的命题,Day08虽然不要求你一定实现,但作为笔记我必须把思路整理出来,因为这是面试高频题。
第一个解决办法是缓存空值。也就是即使数据库查出来是空,也往Redis里写一个占位符,比如空字符串,设置一个很短的过期时间,比如3到5分钟。这样后续同样ID的请求会在Redis这一层拿到空值,直接返回,不会打到数据库。但要注意,如果有大量不同ID的非法请求,每个ID都缓存空值,也会造成内存浪费,所以空值的过期时间要短。
第二个办法是布隆过滤器。这个东西的思路是:在做缓存查询之前,先用一个布隆过滤器判断这个数据“一定不存在,还是可能存在”。布隆过滤器是一个很节省空间的二进制向量,可以快速判断某个值是否在集合里。如果过滤器说不存在,直接返回,压根不查任何存储;如果过滤器说可能存在,才去走后面的查询链路。这种方案适合数据量非常大、而且key非常离散的场景,比缓存空值更省内存。
4.2 缓存击穿:热点key突然失效
缓存击穿和穿透不太一样。击穿是指一个热点key,在一个瞬间刚好过期了,然后大量请求同时打进来,发现缓存没有了,就全跑去数据库查询。这个瞬间数据库的压力会突然暴涨,严重时可能直接被打挂。
菜品分类里的“套餐”或者“热销榜”,是典型的可能产生击穿的场景。比如某个分类下的菜品在午间高峰期正好缓存过期,一瞬间几百个查询同时涌进来。
常规解法有两种。第一种是加互斥锁:在缓存没有命中的时候,不是所有人立刻去查数据库,而是先去抢一个分布式锁,抢到锁的人去查数据库并回填缓存,其他人在锁外面等一下,直到缓存重建完成。这种方式会增加一些等待时间,但能有效挡住峰值流量。
第二种是逻辑过期:在缓存的数据里额外加一个过期时间字段,业务上不依赖Redis自带的过期,而是自己判断逻辑过期后,异步去刷新缓存。比如缓存里存一个“逻辑过期时间=12:00”,到了12:00,请求发现逻辑过期了,就马上触发一个异步任务去更新缓存,同时当前线程先返回旧数据。这样用户感知不到延迟,但实现会复杂一些,不太适合新手当天就做出来。
4.3 缓存雪崩:大量key同时过期
雪崩比击穿更严重,指的是大量key在同一时间一起过期,导致数据库瞬间被海量请求压垮。造成这种情况的原因基本就是两个:一个是设置的过期时间都一样,比如你统一配置30分钟,那所有缓存同一波数据写入后,会在同一个时间点集体失效;另一个是Redis服务本身挂了,所有缓存请求直接穿透到数据库。
预防手段首先是给过期时间加随机值。比如你在RedisCacheConfiguration里固定的30分钟过期时间,可以改成“30分钟 + Random(0~10分钟)”,让每个key的过期时间点错开,避免一批缓存一起失效。这个小改动成本极低,效果却很实在。
其次是做多级缓存,比如本地缓存一级、Redis一级,即便Redis挂了,本地缓存还能扛一部分。不过多级缓存在苍穹外卖这个项目里有点超纲,大家知道思路就行。
最后是给Redis做好高可用,比如主从复制加哨兵,或者集群模式。这个是部署层面的内容,Day08一般不会深入到这儿,但你可以自己查一查,扩展知识面。
4.4 项目里到底需要做到哪一步
我在整理Day08笔记的时候发现一个现象:很多人学完缓存概念,总想立刻把所有高级方案都塞进项目里。但实际上,苍穹外卖这个阶段要求你做的,就是把菜品的缓存和删除机制跑通,做到能说清楚为什么这么设计。
能防御穿透、击穿、雪崩,这些是加分项,不是必选项。你可以先把基本的Cache Aside Pattern吃透,然后深入了解布隆过滤器、互斥锁的思路,让自己能说出来。真要在项目里都实现,反而会把代码搞复杂,在课程进度压力下得不偿失。
我的建议是:代码层面只做缓存和删除,概念层面能展开讲清楚三种问题以及对应的标准解法。这样既有实操能力,也满足面试表达需求。
5. 疑难杂症排查:我踩过的和你想踩的
5.1 缓存没生效?先检查启动类和配置类
缓存注解不生效,是最常见的问题。我那次排查了半天,最后发现@EnableCaching没有加在启动类上。这个注解是Spring Boot开启缓存机制的开关,没有它,你加在方法上的所有注解都会被Spring当成普通注解忽略掉,完全不干活。
第二个排查点是RedisCacheManager这个Bean有没有被Spring正确装配。如果项目里你自定义了一个RedisTemplate的Bean,但是没配置RedisCacheManager,Spring会使用默认的缓存管理器,序列化方式跟你预期的不一样,甚至可能因为Redis连接配置缺失直接报错。
稳妥的验证方法:启动项目后,手动调用一次查询接口,然后打开redis-cli看一眼有没有key生成。有key说明配置生效了,没有key才需要排查注解或配置。
5.2 缓存数据和数据库不一致
这里说的不一致,一般是你改了数据库的数据,但用户端查询还是旧数据。原因大概率是:你改了新增/修改代码,却忘了加@CacheEvict;或者你设置了@CacheEvict的key,但key算出来的值和@Cacheable的key对不上。
当你用allEntries = true时,没有key对不上的问题,因为你清空了整组。但如果你在某个方法上用了key = "#id",然后你查询的时候用categoryId,这就会导致缓存清理只删了id这个key,而实际缓存存在categoryId这个key下,结果就是缓存永远清不到。
排查办法很简单,去Redis里执行keys dishCache*,看看实际存的key长什么样,然后对比你清理时配置的key,一眼就能看出来哪里对不上。
另外还有一个容易被忽略的坑:当修改菜品的代码里执行了新增口味表数据的操作,但是缓存删除是在整个事务提交后才执行的,如果事务回滚了,但缓存已经删了,那也不会出现大问题,因为下次查询会重新加载数据库数据。怕就怕顺序反了,事务还没提交,缓存就删了,然后另一个线程在缓存失效期间读到了数据库旧数据回填,这就麻烦了。
5.3 JSON反序列化报错
用GenericJackson2JsonRedisSerializer序列化对象时,会往Redis里写入一个带@class属性的JSON字符串。这个@class里记录了完整的类名。反序列化的时候,Spring会根据它来还原成原来的类型。这个方法有一个明显的坑:如果你改了类的包名或类名,或者字段类型不匹配,旧的缓存数据反序列化时会直接报错,而且这种报错很难从日志里一眼定位。
我建议在项目开发阶段,改过实体类之后,顺手把Redis里相关缓存清掉,否则你经常会遇到“明明改了代码,但接口死活不生效”的诡异问题。这其实是序列化器的特性导致的,不是你的业务逻辑错了。
也可以换用Jackson2JsonRedisSerializer,但它不会存@class,反序列化时可能会丢失对象的实际类型,返回的是一个List of LinkedHashMap,类型转换容易出问题。具体取舍要看你的业务复杂度。
6. 缓存设计的临场发挥与后续方向
缓存相关的操作流程,到这里已经完整跑通了:加依赖、配置缓存管理器、注解缓存、注解清理、验证效果、排查常见问题。思路清晰之后你会发现,这套机制不只能用在菜品上,套餐、分类、用户Token、购物车,凡是“读多写少”的数据都能照葫芦画瓢套进去。
接下来值得自己动手去扩展的方向,第一个是套餐缓存,做法和菜品几乎一模一样,但你要注意套餐可能包含菜品列表,缓存的结构要设计成嵌套的,查询时不能只缓存套餐本身,还要包含关联的菜品信息,否则用户点进套餐详情还是会查库。
第二个是尝试给过期时间加随机值,把前面4.3里说的雪崩防护落到代码里。这个改动很简单,无非是Duration.ofMinutes(30 + new Random().nextInt(10)),一行代码就能做完,但能让你真正体会到配置层面的灵活性,而不是停留在笔记里。
第三个是用缓存记录用户登录状态。Day08之后,你可能会接触JWT或者Redis存token。原来Session模式换成无状态Token之后,token的有效期、刷新、主动失效,都需要靠Redis来存。这时候你会发现,前面学到的Redis序列化、过期策略、缓存命名,全部又派上了用场。
我个人在整理完这天笔记之后最大的感受是:缓存并不复杂,复杂的是你要在正确的地方决定“哪些数据值得缓存”,以及“数据变了之后如何处理旧缓存”。如果这两点想明白了,什么@Cacheable、@CacheEvict都只是顺手的工具而已。
最后再分享一个小经验,以后写代码之前先画一张很小的逻辑图:哪些接口读数据库、哪些接口写数据库、写入后需要清哪些缓存,画清楚再动手。你会在写代码时少走很多弯路,排查问题也有据可依。Day08不是让你成为缓存专家,而是让你养成“先考虑性能边界,再写实现代码”的工程思维。带着这个思路往下学,后续的支付、订单、定时任务这些复杂模块,你会顺手很多。
