
今天来更新苍穹外卖项目的Day7笔记。这个节点在整条学习路线里算得上一个分水岭前面六天一直在跟分类、菜品、套餐这类基础CRUD打交道管理端接口居多数据量不大数据库基本无压力。但从第七天开始用户端正式进入开发重心菜品查询、套餐查询会从低频管理操作变成高频用户访问如果还用SQL直查数据库压力立刻上来了。所以Day7的主题很明确给菜品和套餐的查询加Redis缓存把购物车这种临时性强的数据用Redis来存。整天的内容说白了就是读多写少用缓存临时数据进Redis理解和做完这一层后面的订单、用户、报表模块会顺很多。1. 整体设计与缓存方案选型1.1 为什么菜品/套餐查询必须上缓存菜品和套餐查询有一个非常典型的特征读多写少。用户在饭点打开App首页刷分类、点进店铺看菜品、翻套餐一个用户一天可能产生几十次查询但商家改菜价、下架菜品这种写操作一天可能只有三五次。这种20比1甚至50比1的读写比天然适合加缓存。我见过很多新手上来就问我的表才几十条数据加缓存有意义吗有意义但重点不在于表小而在于接口的QPS上来了以后MySQL的连接数会被打满。不需要去预测很大的并发哪怕只有几百个用户同时刷首页每个用户刷一次首页要查店铺状态、分类、菜品、套餐、购物车数量一个首页请求背后可能是五六次数据库查询瞬间就能把数据库连接池拖垮。把菜品、套餐这类经常读、很少变的数据丢到Redis里一次查询从七八毫秒降到一毫秒以内数据库的QPS立竿见影地降下来。另外我在实际项目里还有一个体会缓存不只是用来扛QPS它还能显著降低数据库慢查询。菜品表如果关联了口味表、分类表、图片表一个接口可能要join三张表数据一多MySQL的索引和join成本都会上升。把这些组装好的VO对象整体缓存起来等于把join的耗时直接从链路里摘除这才是缓存降慢查询的价值。1.2 购物车为什么放进Redis而不落库购物车这个功能很多人第一反应是建一张cart表和cart_item表。其实做是能做但先问自己几个问题购物车生命周期有多长用户会不会在PC、小程序、App三个端同时打开一个账号购物车里的数据要不要跟订单一样严格存证答案很清楚购物车是强临时性的数据可能随时被清空、被修改而且用户在不同端登录期望看到同一份购物车。用MySQL表存储不是不行而是每次加购都要插入、更新、查询还要考虑脏数据和事务更麻烦的是购物车表的数据量会随着用户量线性上涨里面全是可能永远不下单的临时数据长期占用数据库空间。用Redis就舒服很多天然分布式多个端共用一个key读写都是内存操作毫秒级响应。而且Redis的hash结构非常适合购物车一个用户一个keyfield就是菜品ID口味value存购物车条目详情加购同一个菜品时直接把number字段加一不需要额外的update语句。这里其实牵扯到一个架构意识一个数据该放MySQL还是放Redis不是看它是不是业务数据而是看它的生命周期和访问特征。需要永久保存、有事务要求、要统计对账的放MySQL临时性强、访问频繁、需要跨端共享的可以考虑Redis。购物车就是典型。1.3 Spring Cache 和 RedisTemplate怎么分工做菜品和套餐缓存的时候我在Spring Cache和RedisTemplate之间纠结过。Spring Cache的好处是侵入性低一个Cacheable注解加在查询方法上框架帮你完成查缓存、未命中查库、回填缓存的整套逻辑代码非常干净。RedisTemplate的好处是灵活一切都可以手动控制对缓存结构、过期时间、序列化方式有完全的可控性。我的结论是读多写少、结构简单的查询缓存用Spring Cache需要精细操作、数据结构和业务强相关的用RedisTemplate。这个项目里菜品和套餐查询用Spring Cache做因为查询逻辑本身是一个分类ID作为key返回结果要么是一个菜品列表要么是一个套餐列表非常适合注解缓存。而购物车牵扯到hash结构、数量增减、字段拼接用RedisTemplate手动操作更直观也更容易排查问题。另外提一个坑Spring Cache默认的序列化器是JDK序列化直接把对象序列化成二进制存进Redis。这样有几个问题一是肉眼没法看二是内存消耗大三是前端有时会拿到乱码。所以用Spring Cache之前一定要自定义RedisCacheManager配置Jackson或者Fastjson序列化器。我建议改成GenericJackson2JsonRedisSerializerRedis里存的是可读的JSON字符串排查问题的时候可以直接用redis-cli查看。2. 缓存的几个关键细节必须想清楚再动手2.1 key设计与过期策略key设计其实很简单但容易乱。我的原则是业务前缀:对象类型:id。用户端的菜品缓存key是 dish:分类ID比如 dish:5 就是分类ID为5下的所有菜品列表。套餐缓存key是 setmeal:分类ID。为什么要把分类ID放进key而不是把所有菜都塞进一个key因为用户端是按照分类去刷菜品的按分类拆key缓存命中率最高也方便清缓存时按分类精准操作。购物车的key也一样cart:用户ID一个用户一个key。为什么不把多个用户的购物车塞进一个key因为hash的field数量如果过大单个key会变成热点key而且清缓存、设置过期时间都不方便。一个用户一个key每个key内部不超过几十个field无论从内存使用还是操作粒度都是合理的。过期时间看一个标准业务能容忍多久的数据延迟。菜品和套餐的过期时间我通常设置成30分钟左右然后在此基础上加一个5到10分钟的随机偏移量。为什么要加随机因为如果所有key在同一时刻过期缓存重建请求会同时打到数据库这就是缓存雪崩最典型的触发方式。加上随机偏移量等于把过期时间打散数据库压力就平缓了。购物车不建议设置过期时间因为购物车是用户主动添加的数据不应该因为过期而被悄悄清掉。这里给一张key设计速查表场景key类型过期策略菜品列表缓存dish:分类IDString30分钟随机偏移套餐列表缓存setmeal:分类IDString30分钟随机偏移购物车cart:用户IDHash不设置过期定期清理闲置2.2 缓存穿透、击穿、雪崩的应对这三个问题我建议在动手写代码之前就想明白不然压测会教做人。缓存穿透查询一个根本不存在的数据缓存里没有数据库里也没有每次请求都穿透到数据库。最典型的场景是用户传一个不存在的分类ID去查菜品。解决思路有两个一个是在查询前做参数校验、ID范围校验另一个是缓存空值也就是查询结果为空时在缓存里写一个空列表或空对象过期时间设置得短一点比如两三分钟。我倾向于两个都做。缓存击穿某个热点key刚好过期突然来了大量请求全部打到数据库。最典型的是首页某个爆款分类的菜品列表。解决思路是加互斥锁让只有一个线程去数据库重建缓存其他线程等待或直接返回旧结果。这个项目规模不大可以用JDK的synchronized按分类ID加锁生产环境我会用分布式锁。但说实话单纯靠锁复杂度高一个更省事的办法是把热点key的过期时间调长配合定时任务主动刷新。缓存雪崩大批量key在同一时间失效导致数据库被集中打爆。上面说的随机过期时间就能解决。如果用了凌晨批量刷新之类的定时任务也要注意尽量分散执行时间。可以用生活类比来记缓存穿透像有人拿着一张不存在的票反复去验票口每次工作人员都认真查一遍后台缓存击穿是演唱会最热门的歌手出场时安检口突然排队雪崩是整个系统所有安检口同时断电。理解了场景你就知道应该在哪一层加防护。2.3 缓存一致性更新与删除的顺序这个是缓存系统里最容易出事的地方。我的核心原则是更新数据库之后删缓存而不是更新缓存。为什么因为更新缓存要先把新的数据算出来如果中途有其他线程又改了数据库缓存就可能和数据库不一致而删缓存只需要一次Redis删除下次查询自然会重新回填简单可靠。顺序上一定要先操作数据库后删除缓存。如果反了先删缓存再更新数据库在删缓存和更新数据库之间的时间窗口里请求会把旧数据重新写回缓存造成缓存永远是旧值。更稳妥的办法是延迟双删先删缓存再更新数据库隔几百毫秒再删一次缓存。之所以要延迟再删一次是因为并发环境下前面删完缓存后可能马上有一个线程读到了旧数据库数据并回填到缓存延迟删除可以把这个问题兜住。这个项目里我用的是简单方案更新数据库后删除缓存。因为菜品和套餐的并发写并不多在用户端和管理端分离的场景下管理端改数据后清一次缓存用户端在下一个请求时自然重建就够了。还有一点必须提醒多级缓存场景下比如同时用了本地缓存和Redis清缓存时要两级一起清否则本地缓存里还留着旧数据Redis清得再干净也白搭。2.4 容易混淆的三个缓存写缓存这几天我发现很多人会被缓存这个词误导因为Spring、MyBatis、Redis都有缓存但它们解决的事情完全不一样。Spring的三级缓存是为了解决单例Bean的循环依赖问题它存在于Spring容器内部存的是Bean实例跟业务数据没有任何关系。很多人刷面试题会看到三级缓存原理如果把它当成业务缓存来理解思路就跑偏了。一句话总结三级缓存是Spring造对象的工程缓存不是给业务用的。MyBatis一级缓存是SqlSession级别的同一个SqlSession里执行两次相同的查询第二次会直接走缓存二级缓存是Mapper级别的跨SqlSession共享。听起来好像能提升性能但在实际业务里MyBatis的二级缓存很容易造成数据不一致而且并发控制也不够精细大多数项目都默认关闭。苍穹外卖这样的项目重点还是放在Redis这一层。日常答疑时我经常跟新人说看到一个技术名词先问自身定位再谈用法。搞混这三个缓存面试和开发都会很尴尬。3. 实操过程把缓存加进去3.1 菜品缓存改造全流程这块是整个Day7的实操核心我按实际落地步骤写。第一步配置Redis。在pom.xml里引入spring-boot-starter-data-redis和spring-boot-starter-cache配置Redis连接参数。我用的是spring-boot-starter-data-redis 2.7.18版本配合Spring Cache注解使用。第二步自定义RedisCacheManager。默认的JDK序列化一定要改掉配一个支持JSON序列化的CacheManager。核心代码大致如下Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); }这里有个细节如果模板方法里用了RedisTemplate建议单独配一下StringRedisTemplate或设置序列化器否则中文会乱码。我图省事在项目里直接注入StringRedisTemplate它的key和value都是String序列化对缓存场景足够友好。第三步在用户端查询菜品的Service方法上加注解。以根据分类ID查询菜品列表为例Override Cacheable(cacheNames dish, key #categoryId, unless #result null || #result.size() 0) public ListDishVO listByCategoryId(Long categoryId) { // 在这里执行数据库查询查不到或者查出来为空就不缓存 }Cacheable的意思是先去dish缓存里找key为分类ID的值找到了直接返回找不到就执行方法体并把返回值回填到缓存。unless就是结果为空时不缓存这也是一种防穿透策略防止大量不存在的分类ID把空结果也塞进缓存造成垃圾key堆积。第四步管理端改数据时清缓存。在新增菜品、修改菜品、删除菜品的接口上加CacheEvict注解把dish缓存整个清掉CacheEvict(cacheNames dish, allEntries true) public void add(DishDTO dishDTO) { // 插入菜品表 }这里用了allEntries true而不是指定key是为了省事管理端改动一次菜品把所有分类的菜品缓存都清掉。优点是简单缺点是多分类场景下其他分类的缓存也被清掉下次请求要重新回填。这个项目菜品分类数量有限完全可以接受如果分类特别多可以按分类ID精准清除更新菜品时知道菜品所属分类用 key #dishDTO.categoryId。改完之后我在Redis里执行redis-cli keys dish:*看看缓存有没有被正确回填和清除。3.2 套餐缓存与套餐内菜品组装套餐的缓存逻辑和菜品类似但有一个不一样的地方套餐不是一个简单对象套餐下面还有一份套餐菜品关系表setmeal_dish每个套餐关联着若干菜品。所以用户端查到的不只是套餐本身还要把套餐包含的菜品名称、图片、单价组装修饰到套餐对象里。我的做法是把组装好的套餐列表整体缓存起来而不是只缓存setmeal表本身。缓存的key还是setmeal:分类IDvalue是一个List每个元素是套餐基本信息套餐内菜品列表。这样做的原因是用户端展示套餐时需要一次性看到套餐里的菜品明细如果把套餐和套餐菜品分开缓存查询时要先去缓存拿套餐列表再遍历每个套餐去查菜品不仅Redis的交互次数多还容易造成缓存内数据互相不一致。代码上还是老一套Override Cacheable(cacheNames setmeal, key #categoryId, unless #result null || #result.size() 0) public ListSetmealVO listByCategoryId(Long categoryId) { // 查询套餐列表遍历套餐id查询套餐菜品组装VO返回 }清缓存时也是allEntries true。需要注意一个业务点套餐新增、修改、删除、起售停售都会影响套餐展示状态所以这些操作对应的管理端方法都要加上CacheEvict。我见过有人只在新增和修改时清缓存忘了起售停售接口结果套餐停售后用户端还是能看到实测时很容易暴露。补充一个经验不要把数据库表结构和缓存的value直接绑定。缓存的value应该是接口要返回什么缓存就存什么也就是VO对象。这样数据库加字段、改表名时只要VO结构不变缓存基本不用动反过来如果缓存里存的是一堆实体类的字段前端一改展示要的字段缓存结构就要跟着动麻烦得很。3.3 购物车在Redis里的完整实现购物车我使用的是hash结构这是我在这个项目里觉得最舒服的数据结构。key是cart:用户IDfield是商品维度标识为了能区分同一个菜品加不同口味的条目我拼成 dish:菜品ID:口味 或者 setmeal:套餐ID:口味value是购物车条目的JSON串。为什么要用商品维度标识而不是直接用菜品ID因为同一道菜加微辣和加辣本质上是两个购物车条目数量互相独立只用菜品ID会串味。这个细节如果忽略做减购的时候会出现加辣减掉了微辣的bug我踩过真的很冤枉。加购的逻辑核心是存在则数量加一不存在则新增用Redis的hash原语非常顺String key cart: userId; String field type : dishOrSetmealId : flavor; Object hit stringRedisTemplate.opsForHash().get(key, field); if (hit ! null) { // 已存在数量加一 CartVO item JSON.parseObject(hit.toString(), CartVO.class); item.setNumber(item.getNumber() 1); stringRedisTemplate.opsForHash().put(key, field, JSON.toJSONString(item)); } else { // 新增条目数量为1 CartVO item buildCartItem(request, userId); stringRedisTemplate.opsForHash().put(key, field, JSON.toJSONString(item)); }减购的逻辑反过来数量大于1则减一等于1则直接把这个field删掉。清空购物车更简单直接删除整个keystringRedisTemplate.delete(cart: userId);查看购物车就是把hash里的所有value取出来反序列化成对象列表再返回。定义CartVO包含id、名称、图片、单价、数量、口味字段。这里有个坑要专门说购物车里的数据是内存数据如果Redis发生持久化丢失购物车就没了。所以生产环境Redis一定要配置好持久化用RDB和AOF结合同时购物车这种不设置过期的key要防止内存无限增长可以给每个用户的key记录最后操作时间超过30天未活跃的定期清理。3.4 开发时本地上传图片与缓存的关系这里顺便说一句本地上传图片和缓存的关系开发阶段很容易踩坑。配置了本地上传图片后菜品图片通常存在本地的某个目录比如/usr/local/uploads或者项目里的upload目录。因为本地路径没有域名前端拿到的图片URL通常是一个相对路径或者带本机IP的路径。我在加缓存时是直接把包含图片URL的完整VO对象序列化进Redis。这样有一个问题如果图片URL是动态拼接的比如端口号变了、IP变了缓存里存的还是旧URL页面就会展示裂图。我的建议是开发阶段把图片URL在VO对象里存成相对路径由前端或者一个统一配置去拼完整地址或者干脆在上传接口里把图片URL的生成规则固化保证URL可预测。缓存里存的字段尽量稳定不要混入环境相关信息。如果实在需要存完整URL本地环境改动后要记得清空一批缓存我一般用 cli 执行 del dish:* setmeal:* 这种通配删除来处理。4. 常见问题与排查技巧实录4.1 经典案例后台改价格用户端看到的还是旧价格这是缓存不一致最经典的现象。排查步骤我会固定走一遍先在管理端把菜品价格改掉然后看数据库里的值是不是新的再看Redis里dish缓存是不是旧的。如果Redis里还是旧值那就是缓存没有清掉检查管理端的更新接口上有没有加CacheEvict或者加的位置对不对如果缓存已经清了用户端还是旧值那就要考虑是不是前端页面本身有静态缓存或者浏览器/CDN缓存没清。我当时排查到最隐蔽的一个原因是修改菜品的方法里先调用了更新数据库的方法但CacheEvict注解加在了Controller层而调用链里更新数据库的service方法内部又有一个自身调用把注解拦住了。Spring Cache的注解是基于代理的同一个类内部调用会绕过代理这个知识点能坑掉很多人。建议把CacheEvict和Cacheable都加在对外暴露的接口方法上或者在类内部调用时通过代理调用自己。4.2 压测时缓存穿透把数据库打挂我做压测时发现直接用JMeter模拟500个并发去请求一个分类ID99999的菜品列表接口Redis里没有数据库里也没有这一层穿透直接把数据库的连接池打满。后来我在代码里做了两道防护第一道在Controller入参阶段校验分类ID是否合法不合法直接返回空第二道service方法上加unless条件空结果不缓存还是会有穿透所以我又加了空结果也缓存2分钟的方案。方案的选择取决于穿透的量级如果只是正常用户偶尔传错ID第一道就够了如果可能被恶意刷接口必须做空值缓存。另外一个防护是限流但对接口滥用来说参数校验才是性价比最高的方案。4.3 购物车数据异常的表现购物车常见的异常有三类。第一类是重复购物车条目用户加了同一道菜两次购物车里有两条记录而不是数量叠加原因多半是field拼接逻辑不一致比如一次用了dish:1:微辣另一次拼成了dish:1:微辣 中间多了空格或者口味字段没统一看起来一样实际在Redis里是两个不同的field。修复方法是一定要定义一个工具方法来拼接field全项目只认这一种拼法。第二类是购物车清空失败用户下单成功之后购物车还残留数据。排查点在于下单接口里是否真的调用了delete整个key的方法以及下单后用户如果紧接着继续加购是否会因为Redis删key和写key之间的并发时序导致旧数据重新被写入。我建议下单清购物车和后续加购操作之间通过前端跳转和状态机来控制时序后端再做一次幂等校验比如清空前校验订单是否创建成功。第三类是购物车数据丢失Redis重启后购物车直接没了。这就回到我前面说的持久化问题生产环境一定要开AOF并且最好对购物车数据做定期备份毕竟用户加了半天的购物车不能因为服务重启就直接消失。4.4 排查缓存问题的手段和命令我做缓存治理总结了一套很顺手的排查手段日常最常用的几招列在下面。第一招redis-cli里用keys匹配前缀看缓存是否存在。比如keys dish:、keys cart:。生产上keys会阻塞Redis但开发环境没问题生产环境建议用scan。第二招看value内容。get dish:5 如果返回的是一堆二进制乱码说明序列化没配好如果返回的JSON很工整说明用的是Jackson或者Fastjson序列化。第三招看TTL。ttl dish:5 能看出key的过期时间还剩多少用来判断是该被缓存但没缓存还是刚过期还没重建。第四招用monitor实时监控Redis收到的命令看看到底有哪些请求在打Redis。这个命令生产环境慎用它会输出所有命令压力大时对Redis有性能影响。配合Spring Boot的actuator和日志我一般还会在Service方法里打一条DEBUG日志记录缓存命中还是缓存未命中查询数据库压测时对着日志数QPS比靠猜要快得多。我用这个方式定位过一个隐藏问题缓存失效后大量请求同时回源数据库日志里能看到同一时刻出现大量缓存未命中再配合Redis的客户端连接数很快能确认缓存击穿。4.5 购物车与缓存问题速查表症状可能原因处理建议后台改价前台不变缓存未清除 / 内部调用绕过代理检查CacheEvict位置清缓存后验证TTL空分类ID打满数据库参数校验缺失增加ID合法性校验必要时空值缓存同一菜品出现多个条目field拼接不一致统一field生成工具方法购物车重启后消失Redis未持久化开启AOF和RDB定期备份缓存内容是乱码默认JDK序列化未改用GenericJackson2JsonRedisSerializer配置CacheManager套餐停售后仍展示停售接口未清缓存所有影响展示的写操作都加CacheEvict5. 最后再分享一点实操体会Day7做完之后我的体会是缓存的难点从来不在加注解那一下而在缓存什么时候失效、失效后怎么办、数据不一致了怎么兜底。菜品和套餐缓存看起来简单但把过期时间、空值处理、清除时机、序列化这些细节全部捋顺才算是真正会写缓存。最后分享一个小技巧开发阶段我习惯把Redis里的缓存过期时间设得非常短比如30秒。这样我改完数据库之后不用手动清缓存30秒后自动失效前端刷新就能看到最新数据调试效率高很多。等到功能稳定了再把过期时间调回30分钟。这个小习惯帮我省了大量清缓存、等缓存重建的时间你可以试试。这个内容后续如果要扩展可以往缓存预热和分布式锁防缓存击穿这两个方向继续深入也可以把购物车拆成独立的服务。但那是后面的故事了先把Day7这一层打扎实。