做苍穹外卖项目的人,大概率都在赶一个“进度”。Day01到Day07把管理端基础功能搭完,到了Day08这个节点,最典型的安排就是用户端核心流程起步,具体来说主要就是两件事:购物车模块 和 缓存优化。如果你卡在这一天不知道该先做什么,或者代码写完但缓存怎么都加不进去,这篇文章把我自己当时踩过的坑、整理过的思路全部摊开讲。
这篇内容适合正在跟苍穹外卖课程、或者自己仿写外卖项目的人看。已经跑通管理端的同学会很顺畅,哪怕数据库表还没建完,只要Redis和Spring Boot基础过关,也能直接跟着把购物车和Spring Cache打通。整个Day08做完,你的项目就从“管理后台”真正摸到了“用户下单”的边,这个节点对后续订单模块特别重要。
1. 先理清Day08的模块边界:购物车与缓存为什么放一起
很多人在第8天会迷路,原因很简单:课程表上既出现了购物车,又出现了Spring Cache,看起来像是两个并列的知识点,实际上它们是一条线上前后咬合的两环。购物车是用户端的第一个高频率写操作,缓存是解决读操作压力的通用手段,放到同一天学,是因为它们都依赖同一个基础设施——Redis。
1.1 用户端购物车到底要解决什么问题
购物车本质上是一个“临时订单”:用户在菜品详情页加菜,可能在结算前反复修改数量、清空重选。这个场景有两个显著特点,决定了它不能直接照搬管理端的普通数据库表设计:
- 写入频繁但单条数据量小。加购、减购、清空、勾选,一次请求可能只动一行记录。如果每次都走MySQL的行锁和事务,连接池压力会非常大,特别是高峰期用户集中加购时。
- 生命周期短且实时性要求高。购物车数据通常只在一次会话周期内有意义,用户结账后就应该清空或转为订单明细。对时效性要求高,但对持久化要求没那么严格。
所以Docker部署方案里,Redis作为购物车的存储介质几乎是顺理成章的:用Hash或者List结构存购物车,每条数据带userId作为key的维度,天然天然比MySQL快一个数量级。这也解释了为什么Day08要把购物车和缓存放在一起讲——你学会用Redis存购物车,也就学会了用Redis做缓存,反过来也一样。
1.2 缓存选型:Redis如何接入Spring Cache
Spring Cache是Spring框架对缓存功能的抽象,它本身不提供存储,而是对接各种缓存实现。苍穹外卖项目里接的是Redis,但这个“接”有两种思路,很多人一开始搞混:
- 直接用RedisTemplate操作Redis。这时候你写的代码是
redisTemplate.opsForValue().set(key, value),所有的Key过期时间、序列化策略都要自己管理。优点是灵活,缺点是代码侵入性太强,每个查询方法里都塞一段缓存逻辑。 - 用Spring Cache的注解驱动。在Service方法上加
@Cacheable、@CacheEvict,底层自动帮你做Redis读写。优点是代码干净,与业务逻辑解耦,缺点是注解魔法多,遇到Key设计不当或过期时间设置不合理时,排查起来比手写代码更费劲。
Day08的目标是让你两种都能掌握:购物车用RedisTemplate手动管理,因为购物车的数据结构相对复杂,涉及List更新、数量累加,天然适合手动控制;而菜品、套餐这类读多写少的查询用Spring Cache注解,因为逻辑简单,用注解最省事。
我当时自己先手动实现了一遍购物车,再去给菜品查询加缓存注解,这两种模式切换着用,对Redis的理解会扎实很多。如果你直接跳过RedisTemplate学注解,后面遇到数据一致性问题时会非常被动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 购物车模块从零实现:从数据库设计到接口联调
购物车模块听起来不大,但拆开来看涉及前端页面、后端接口、Redis结构、用户身份识别四个部分。这里我按后端主线来讲,前端接口联调的部分会提一下需要注意的地方。
2.1 购物车的表结构设计与Redis结构映射
如果你的项目要求购物车数据持久化到MySQL,那表结构大概长这样:
sql复制CREATE TABLE `shopping_cart` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(32) DEFAULT NULL COMMENT '商品名称',
`image` varchar(255) DEFAULT NULL COMMENT '商品图片',
`user_id` bigint NOT NULL COMMENT '用户ID',
`dish_id` bigint DEFAULT NULL COMMENT '菜品ID',
`setmeal_id` bigint DEFAULT NULL COMMENT '套餐ID',
`dish_flavor` varchar(50) DEFAULT NULL COMMENT '口味',
`number` int NOT NULL DEFAULT '1' COMMENT '数量',
`amount` decimal(10,2) NOT NULL COMMENT '金额',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这个表里有个细节很容易被忽略:dish_id和setmeal_id是互斥的。一个购物车条目要么是一个菜品,要么是一个套餐,不可能同时是两者。所以查询的时候一定要带上这个条件,否则同一个菜品会被查出两条记录。
如果按课程方案用Redis存购物车,常见的存储结构是:
code复制key: cart:{userId}
value: List<ShoppingCart>
也就是一个用户的购物车对应一个Redis List,每个元素是购物车条目。每次操作时,先取整个List出来,增删改后再写回去。这样做的好处是逻辑简单,坏处是并发更新时会互相覆盖,所以需要配合乐观锁或加锁来处理。对于学习项目来说,直接使用这个结构没有问题,只要你在接口上加个简单的同步锁或者用Redis的WATCH机制,就能避免大部分并发问题。
2.2 添加购物车的完整逻辑:数量累加与幂等处理
添加购物车这个接口是购物车模块最核心的逻辑,也是面试时最常被追问的地方。它的核心不是“插入一条数据”,而是“先查再决定插入还是累加”。我拆成四个步骤:
- 获取当前登录用户ID。这个值通常是拦截器往ThreadLocal里塞的,你从ThreadLocal里拿到userId,而不是从请求参数里拿。这点非常重要,直接拿前端传的userId会带来越权风险。
- 判断当前添加的是菜品还是套餐。依据传入的
dishId或setmealId哪个不为空来判断,同时要处理口味不同的情况。比如同一个菜品,“微辣”和“中辣”要作为两条不同的购物车记录。 - 查询购物车中是否已存在同款商品。查询条件包括userId、dishId(或setmealId)、dishFlavor。如果存在,数量加1;如果不存在,new一条记录,数量初始化为1。
- 保存回Redis或数据库。如果使用MySQL,直接update或insert;如果使用Redis的List结构,取出来改完再放回去。
我把Service层核心代码的逻辑写出来,方便你对照自己的实现:
java复制public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
// 1. 获取当前用户ID
Long userId = BaseContext.getCurrentId();
// 2. 组装查询条件
ShoppingCart cart = new ShoppingCart();
cart.setUserId(userId);
cart.setDishId(shoppingCartDTO.getDishId());
cart.setSetmealId(shoppingCartDTO.getSetmealId());
cart.setDishFlavor(shoppingCartDTO.getDishFlavor());
// 3. 查询是否已存在
ShoppingCart existingCart = shoppingCartMapper.selectByCondition(cart);
if (existingCart != null) {
// 已存在,数量+1
existingCart.setNumber(existingCart.getNumber() + 1);
shoppingCartMapper.updateNumberById(existingCart);
} else {
// 不存在,新增
cart.setNumber(1);
cart.setCreateTime(LocalDateTime.now());
// 注意:这里要查菜品或套餐的名称、图片、金额回填
shoppingCartMapper.insert(cart);
}
}
这里有一个关键操作:新增时要把菜品的名称、图片、单价回填到购物车记录里。很多初学者只存了dishId,到展示购物车列表的时候才发现查不到名称和图片,又要再去join菜品表,徒增复杂度。记住,购物车表里冗余了菜品的基本信息,这是有意设计的,不是数据冗余错误。
2.3 查看与清空购物车:注意结果排序
查看购物车列表相对简单,根据userId查询所有条目,按create_time倒序排列返回即可。但有一个细节:如果用户在购物车页面删除了某个条目,然后又重新添加同款,你希望它排在最前面还是回到原来的位置?这个需求不同项目不一样,但如果你的表有create_time并且删除时没有物理删除而是软删除,那么重新添加的时候会生成新的create_time,自然就排到最前面了。
清空购物车的逻辑更直接:按userId删除所有记录。但这里有个边界情况,用户结算完成后调用清空接口,此时购物车可能已经被支付回调里清过一次了,所以清空接口需要是幂等的——用户连续调用两次,第二次也不应该报错。用DELETE语句天然幂等,但如果你用Redis,清空的时候要检查key是否存在,不存在就直接返回成功,不要抛异常。
3. Spring Cache注解式缓存:给菜品查询加上“加速引擎”
购物车搞定后,Day08的另一半重头戏是缓存优化。菜品和套餐的查询是用户端最高频的读操作,每次打开小程序首页都会触发多次数据库查询。如果所有请求都打到MySQL上,虽然前期数据量小看不出问题,但一旦并发上来,数据库连接池很快就会被打满。
3.1 为什么需要Spring Cache而不是手写RedisTemplate
刚接触缓存的人最容易犯的错,就是每个查询方法里手写一套“先查缓存、缓存没有查数据库、查到后写回缓存”的逻辑。比如这样:
java复制public List<Dish> list(Long categoryId) {
String key = "dish_" + categoryId;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseArray(json, Dish.class);
}
List<Dish> list = dishMapper.selectByCategoryId(categoryId);
redisTemplate.opsForValue().set(key, JSON.toJSONString(list), 30, TimeUnit.MINUTES);
return list;
}
这个写法没有什么原则性错误,但问题在于可维护性差。项目里这样的查询方法可能有几十个,每个方法里的缓存逻辑都是复制粘贴,一旦要调整过期策略或者Key的格式,你得把所有方法都改一遍。
Spring Cache的注解方式就是来解决这个问题的。你只需要在方法上标注注解,Spring AOP会在方法执行前后自动完成缓存的查询和写入,业务代码保持纯净。比如:
java复制@Cacheable(cacheNames = "dish", key = "#categoryId")
public List<Dish> list(Long categoryId) {
return dishMapper.selectByCategoryId(categoryId);
}
这就是Day08让你体验注解式缓存的原因:用最少的代码,给所有查询方法加上缓存能力。实际项目中,大部分查询场景用注解就够了,手写RedisTemplate主要用于购物车这类结构复杂的会话数据。
3.2 三个核心注解的用法与失效场景
Spring Cache里最常用的是三个注解,它们各自对应不同的场景,用错一个就会出现缓存不生效或者缓存数据不更新的问题。
@Cacheable:加在查询方法上。执行前先查缓存,缓存有就直接返回,不再执行方法体;缓存没有才执行方法,并把返回值写入缓存。它最适合“读多写少、数据变化不频繁”的查询。
这里有一个隐藏的坑:cacheNames和key组合成真正的Redis键。比如cacheNames = "dish",key = "#categoryId",那么实际Redis键是dish::1(中间有两个冒号)。这个是Spring Cache拼接出来的,你在Redis可视化工具里看到dish::1不要觉得奇怪。
@CachePut:加在更新方法上。执行方法体后,把返回值写入缓存,不会先查缓存。这个注解适合“更新数据库后同步更新缓存”的场景。要注意它始终会执行方法体,所以性能上不占优势,但能保证缓存和数据库尽量一致。
@CacheEvict:加在删除或更新方法上。执行方法体后,把指定缓存从Redis中删除。它适合“更新数据库后让缓存失效”的场景,等下一次查询时再回源数据库加载新数据。
三者的关系可以理解为:@Cacheable负责读缓存,@CacheEvict负责删缓存,@CachePut负责重建缓存。最容易出问题的场景是,管理员在管理端修改了菜品信息,用户端的菜品缓存还是旧数据。解决办法就是在管理端的修改、删除方法上加@CacheEvict,把对应的旧缓存清掉。
3.3 缓存Key的设计:避免被忽略的坑
Key设计是Spring Cache最容易翻车的地方,也是最容易被忽略的地方。我的建议是遵循三条原则:
- 同一类数据用同一个cacheNames前缀。菜品缓存统一用
dish,套餐缓存统一用setmeal,这样后续排查问题的时候,用scan命令扫描某个前缀的key,能快速找到所有相关数据。 - key中必须包含所有影响结果的参数。如果菜品查询同时受categoryId和status影响,那key就要设计成
#categoryId + '_' + #status,否则两个不同条件的查询会相互覆盖。 - 避免使用对象作为key的Hash值。如果直接把一个对象作为key,Spring会调用它的
toString()或hashCode(),而你往往不确定这些方法返回什么,线上环境会出难以排查的怪问题。安全的做法是提取核心ID作为key。
另外还要注意,@Cacheable是基于Spring AOP实现的,它只对通过代理对象调用的方法生效。如果你在同一个类里写了一个方法A,内部直接调用同类中带@Cacheable注解的方法B,那么B的缓存注解不会生效。这是因为AOP切面只在外层拦截,内部调用绕过了代理。这个坑非常隐蔽,通常表现是“缓存没生效但代码看起来完全正确”。如果你遇到这种情况,可以把方法拆到不同的Service类里,或者使用AopContext.currentProxy()强行走代理。
4. 联调、排错与性能验证:自己当测试的过程
代码写完之后,真正花时间的是联调和排错。Day08的模块涉及Redis、MySQL、前端小程序三端联动,任何一个环节出问题,表象都可能是“接口报错”或者“页面数据不对”,但原因五花八门。这里我把自己遇到过的问题整理成一份速查表,方便你对照排查。
4.1 接口自测的完整流程
写完购物车接口后,我建议你按下面的顺序手动测试一遍:
- 先测登录态是否正常。用Swagger或者Postman调用需要登录的接口前,先调一次用户端的登录接口,拿到token。因为购物车接口依赖用户身份,没有token会直接403。
- 再测加购接口。传一个dishId和数量,看Redis或数据库里是否新增了一条记录,然后连续加两次同样的菜品,确认数量是累加而不是新增两条记录。
- 测购物车列表。确认返回的数据包含菜品名称、图片、口味、数量、金额,并且字段名与前端页面对应。
- 测删除和清空。删除单条记录时,确认只删了对应的一条,不影响其他条目;清空后再次查询,确认返回空列表而不是报错。
- 最后测缓存。第一次查询菜品列表时,看日志确认走了数据库查询;第二次查询时,看日志确认没有SQL输出,说明命中了缓存。
4.2 经典问题复盘:缓存穿透与数据一致性
我在做Day08的过程中遇到最典型的问题,可以归纳为三个,全部都是项目里真实出现过的:
第一个问题是Redis出现空值穿透。用户请求一个不存在的菜品ID时,@Cacheable会正常执行方法,查不到数据,返回null。但Spring Cache默认不会缓存null值,于是每次请求都会打到数据库,这就是缓存穿透。在高并发场景下,恶意用户或无效ID请求会把数据库打爆。解决方式有两种:一是业务层在查到null时主动创建一个空对象缓存到Redis,设置短暂过期时间;二是使用@Cacheable的unless属性,但更推荐前者,因为空对象缓存可以复用。
第二个问题是管理端修改菜品后,用户端缓存不更新。这个问题的根因是缓存失效的时机不对。管理员保存菜品时,你把@CacheEvict加在了service里,但查询菜品用的key和服务端更新菜品用的key不一致,结果删掉的缓存和实际使用的缓存不是一个。这个问题在开发环境很难发现,因为本地缓存刷得快,一旦部署到测试环境多台机器,就会出现“有的机器有缓存,有的机器没有”的诡异现象。我的排查经验是,先到Redis里查一下实际的key是什么,再去看代码里注解上的key对不对,百分之八九十的问题出在这里。
第三个问题是并发加购时数量丢失。我当时用Redis的List结构存购物车,测试时用两个线程同时给同一个用户加购,结果购物车里的数量变成了1而不是2。原因是两个线程同时取出List,各自修改后再写回,后面的写覆盖了前面的结果。解决方式很简单,给加购接口加一个Synchronized锁,或者用Redis的分布式锁。后者更健壮,但对学习项目来说,单机部署用Synchronized就够了,面试时能解释清楚两种方案的适用场景即可。
4.3 小技巧:日志、Redis可视化工具与缓存验证
排查缓存问题时,我特别推荐几个习惯,能让你的效率翻倍:
- 打开MyBatis的SQL日志。在application.yml里配置
mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,这样每次执行的SQL都会打印在控制台。第二次查询菜品列表时,如果SQL没有再次打印,说明缓存命中成功。 - 用Redis Desktop Manager或者Another Redis Desktop Manager查看Redis数据。比起一个个敲命令查,可视化工具能直接看到key、value和过期时间,特别适合排查Key格式错乱的问题。
- 给缓存设置合理的过期时间。过期时间不是越长越好。菜品和套餐这类基础数据,可以设置30分钟;购物车数据不要设过期时间,或者设置24小时,因为用户的临时选择不应该无故消失。
- 清理缓存时用
flushdb而不是del全部。开发环境测试时,如果发现缓存数据很乱,可以直接在Redis客户端执行flushdb清空当前库。但生产环境千万不要这么干,要精准地按业务前缀删除。
5. 从Day08到后续模块:这些细节趁早留好扩展位
既然做完了Day08,顺手把后面几天可能要用的思路提前埋进去,后面会省很多事。
5.1 购物车向订单转换的数据形态
购物车数据最终的归宿是订单明细。在苍穹外卖的后续章节里,用户点击“去结算”时,后端要做三件事:读取购物车列表、把每一条数据转为订单明细、清空购物车。如果你在购物车设计时预留了amount字段和dishFlavor字段,这个转换过程会非常顺畅。如果当时偷懒没存这些冗余字段,到做订单模块时就得回头再join查询菜品表,多走一步没必要的弯路。
5.2 缓存数据预热与双写一致性思路
菜品和套餐的缓存,在做完Day08后基本都是“懒加载”模式——第一次有人查询时才写入缓存。但如果你追求更好的用户体验,可以考虑在服务启动时主动加载热门菜品到Redis,这就是缓存预热。它有两个好处:一是避免上线后第一批请求全部穿透到数据库,二是可以让用户端首屏加载速度更快。
缓存预热的做法很简单,用一个ApplicationRunner或者@PostConstruct注解的方法,在Spring容器启动后自动执行一次查询并写入缓存:
java复制@Component
public class CachePreloadRunner implements ApplicationRunner {
@Autowired
private DishMapper dishMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void run(ApplicationArguments args) throws Exception {
List<Dish> dishList = dishMapper.selectAll();
for (Dish dish : dishList) {
String key = "dish:" + dish.getId();
redisTemplate.opsForValue().set(key, JSON.toJSONString(dish), 30, TimeUnit.MINUTES);
}
}
}
这里要注意,预热的数据量不要太大,如果有几万条菜品全部预热,启动时间会很长,反而得不偿失。预热只做高频数据,低频数据还是靠懒加载。
5.3 分布式锁与缓存一致性的取舍
很多人在面试时会被问到“缓存和数据库双写一致性问题”。Day08这个阶段,你只需要理解最简单可信的方案:先更新数据库,再删除缓存。为什么是删除而不是更新?因为更新缓存的代价高,而且多线程并发下容易产生脏数据。删除缓存后,下一次查询会把新数据加载进来,虽然中间有一个很小的缓存空窗期,但整体上是安全的。
如果你追求更严格的一致性,可以把删除缓存和更新数据库放在同一个事务里,或者用消息队列异步删除。但这是后续优化的内容,现在不需要过度设计。先把最简单的方案做对,理解它的优缺点,面试时能讲清楚就行。
6. 我做完Day08后最有用的几个习惯
最后写几条个人感受,也是我反复跟带的学生强调的点:
第一,拿到任务先理清数据流,再写代码。购物车和缓存两个模块都是数据流驱动的,先画清楚用户请求从Controller到Service到Mapper再到Redis的完整路径,再开始写代码。大脑里没有数据流图的时候写的代码,必然是一堆零散方法的堆砌,后期重构成本极高。特别是购物车这个模块,我在带人的时候发现90%的学员第一次加购都会写错一个地方——他们会在service里重复写好几遍Redis读写逻辑,完全没意识到要用一个统一的方法来管理购物车数据。
第二,动手前先看Redis里实际存了什么。缓存相关的问题,不要靠猜,直接看Redis客户端。很多时候你以为缓存没生效,其实缓存生效了只是Key不是你预期的那样。看一眼实际存储,所有猜测都会烟消云散。
第三,写接口时顺手把参数校验做完整。购物车加购接口,如果前端传入负数数量或者空菜品ID,后端要有防御,否则脏数据进了Redis,排查起来非常痛苦。Spring Boot里用@Validated注解加几个校验规则,成本很低,收益很高。
第四,管好ThreadLocal的值。购物车和后续所有用户端接口都依赖用户身份,很多人会在某个方法里忘记从ThreadLocal取值,直接去请求参数里找userId,结果调试半天。我给自己定的规矩是:所有用户端Controller方法的入参中绝不出现userId,一律从ThreadLocal获取。这个约束虽然简单,但能避免一整类越权问题。
如果你已经看到这里,说明Day08的坑基本躲过去了。把这个模块做扎实,后面做订单、支付、工作台报表的时候,你会有一种“怎么这么顺”的感觉——这正是前面每一步都踩实的回报。缓存和数据结构的健壮性,会在越往后的模块里越明显地体现出来,希望这篇内容能让你少走几段弯路。
