外卖项目Day07实战:Redis缓存设计与购物车订单核心逻辑

发布时间:2026/10/7 12:49:42
外卖项目Day07实战:Redis缓存设计与购物车订单核心逻辑
项目刚开始第七天正好是前后端联调最频繁的阶段。这篇文章我把苍穹外卖开发到 Day07 遇到的完整链路、缓存设计思路、购物车流程和外卖订单前置逻辑串一遍包含可以直接抄的代码片段、参数计算过程和排坑记录。无论你是跟着课程走还是自己从零搭一个外卖后端这天的内容都值得多看两遍——很多坑是业务场景逼出来的不在真实需求里根本碰不到。1. 整体设计与技术选型复盘第七天的工作量集中在用户端的核心链路缓存菜品数据、维护购物车、跑通下单前的地址和配送逻辑。技术栈依然是熟悉的 Spring Boot MyBatis Plus Redis但今天的切入点不再是 CRUD 增删改查而是偏向“高并发只读场景”和“会话级临时数据”这两种典型问题的工程化处理。1.1 为什么今天必须引入缓存外卖项目的菜品展示属于典型的读多写少场景。用户在首页、分类页、搜索页反复浏览菜品同一道菜一天可能被请求上万次。如果每次请求都打到 MySQL既浪费数据库连接资源又会在高峰期拖慢响应速度。我的做法是给“用户端菜品浏览”这整条链路加 Redis 缓存缓存粒度细分到分类维度。一个分类下的菜品列表设置一个 key过期时间统一设 60 分钟。这样用户切换分类时命中率高后台管理员修改菜品后也能在可接受的时间内自动失效。需要注意这里不能直接缓存整个菜单 JSON否则菜品上下架状态变化时会出现脏数据。实际开发中我只缓存“已上架”状态的菜品集合后台任何上下架操作都同步删除对应分类的缓存 key这样逻辑就闭环了。1.2 购物车为什么用 Redis 不用 MySQL购物车是今天另一个核心模块。它最大的特点是数据属于用户会话、变化频繁、不需要持久化到业务数据库。如果每次加购、改数量、清空都写 MySQL会产生大量毫无价值的冗余记录——用户可能选了几样菜又全删了这些操作过程根本不值得入库。所以第七天我把购物车直接放在 Redis 里以用户 ID 为维度存储购物车列表。存储结构我选了最稳妥的 String 类型value 放 JSON 数组。每个用户的购物车就是一个独立 key“cart:用户ID”。加购时先查 Redis存在则追加或合并相同菜品不存在则新建数组写入。为什么不用 Hash因为购物车项本身是一个包含多个字段的对象菜品 ID、名称、图片、数量、口味嵌套结构在 Hash 里处理起来很别扭。String JSON 序列化虽然每次操作要序列化和反序列化但购物车单用户数据量极小性能损耗完全可以忽略。1.3 下单前地址和配送逻辑的边界下订单功能今天只做到“前置逻辑”也就是确认地址合法、店铺营业状态、订单金额计算并没有把支付完整接进来。这里要特别注意业务边界外卖系统的订单状态机非常复杂如果在第七天就急着写完整下单流程很容易把支付回调、超时取消、配送状态这些后续逻辑全部揉在一起导致后期返工。我建议的下单模块拆分方式是这样的今天只做“提交订单 订单明细落库”把订单状态统一初始化为“待付款”。支付回调逻辑留给后几天单独做。这样每一阶段都能独立测试接口边界清晰也为后续的定时任务比如超时未支付自动取消留下了干净的扩展点。2. 缓存设计与菜品查询的深度融合菜品浏览是用户端所有页面里访问频率最高的接口。今天我把缓存处理和业务查询做了深度整合核心思路是优先查缓存缓存未命中再查数据库回填缓存的同时设置随机过期时间防止雪崩。2.1 缓存 Key 的设计原则缓存 Key 的命名直接影响后续维护成本。我第七天定的规范是“业务前缀:维度:ID”。比如// 分类对应的菜品缓存 key String key dish:category: categoryId; // 购物车 key String cartKey cart: userId;业务前缀必须语义清晰不能省。项目后期可能还要加“套餐缓存”“店铺信息缓存”等如果 key 命名随意排查问题时根本不知道哪个 key 对应哪块业务。而且不同的业务 prefix 天然隔离了数据空间避免类型混乱。过期时间的设置我强烈建议不要写死固定值而是在基础过期时间上加上一个随机数。所有菜品缓存在同一时间过期会导致缓存雪崩——大量请求同一瞬间落到数据库数据库压力瞬间飙升。我习惯用 60 Random.nextInt(30) 作为过期时间分散失效窗口。2.2 缓存穿透与空值处理缓存穿透是外卖项目里很容易踩的坑。所谓穿透就是缓存和数据库都没有这条数据但请求还是源源不断打进来。比如用户恶意传入一个不存在的菜品 ID每次都会直接查数据库。如果并发足够大数据库会被这种无效请求拖垮。解决穿透我采用“缓存空值”策略查询数据库结果为 null 时也往 Redis 写入一个空字符串过期时间设置短一些比如 5 分钟。这样同一 ID 的重复请求会在缓存层直接短路不会反复穿透到数据库。// 查询数据库为空时缓存空值 redisTemplate.opsForValue().set(key, , 5, TimeUnit.MINUTES);但注意空值判断要区分“真的不存在”和“暂时不可用”。我的判断逻辑是Redis 中获取到空字符串时返回空列表给前端只有 Redis 完全不存在 key 时才去数据库查询并且要在查询前做一次互斥锁保护防止同一时刻多个线程同时回填缓存。2.3 缓存一致性的双删策略后台修改菜品后前台缓存什么时候失效最直接的做法是修改菜品时同步删除对应缓存。但删除缓存和更新数据库之间有时间差如果并发请求在中间读到了旧数据就会造成短暂的不一致。我在项目中采用“先更新数据库再删除缓存”的顺序。逻辑是这样的假如先删缓存再更新数据库那么更新过程中来的请求会发现缓存没有数据直接去数据库读——这时候可能读到旧值——它会把这个旧值回填到 Redis造成长时间脏数据。而先更新数据库再删缓存即使有并发问题影响窗口也极短。为了保险我在更新完数据库后延迟 1 秒再删一次缓存。这个双删策略听起来多余实则在真实场景很有价值。多机部署时请求分发有时间差双删可以确保最终一致性。代价仅仅是多一次 Redis 删除操作完全可以接受。// 更新数据库 updateById(dish); // 立即删除缓存 deleteCache(dish.getCategoryId()); // 延迟 1 秒后再次删除 timer.schedule(() - deleteCache(dish.getCategoryId()), 1000);3. 购物车模块的完整实现细节购物车是用户操作最频繁的模块接口设计必须同时考虑简单性和可扩展性。我今天把购物车拆成了四个核心操作加入购物车、查看购物车、修改数量、清空购物车。每一块都有值得记录的细节。3.1 加购接口的幂等与合并逻辑加购最容易出的问题是重复点击导致同一菜品多次添加。前端可以防抖但后端必须做兜底。我的设计是用户点击加购时先查询 Redis 中当前用户的购物车数据。如果同一菜品 ID 和同一口味已经存在则只增加数量不新增记录。这里有一个隐藏细节口味。外卖场景下同一道菜可能有不同口味规格比如辣度、甜度。不同口味的同一菜品必须视为不同购物车项否则会把用户的个性化选择弄丢。所以我的合并判断条件是两个dishId 相同、dishFlavor 相同。// 合并判断 private boolean canMerge(CartItem existed, CartItem newItem) { return existed.getDishId().equals(newItem.getDishId()) existed.getDishFlavor().equals(newItem.getDishFlavor()); }加购完成后立即更新 Redis并且把购物车数量返回给前端。前端用这个数量更新角标不需要再单独调用查询接口减少一次往返。3.2 购物车读写 Redis 的序列化坑用 RedisTemplate 操作购物车时序列化方式是个大坑。默认的 JdkSerializationRedisSerializer 会把数据序列化成可读性极差的二进制格式排查问题时用可视化工具都看不懂记录内容更麻烦的是跨语言、跨版本升级时可能出现反序列化异常。第七天我统一换成了 GenericJackson2JsonRedisSerializervalue 直接以 JSON 字符串形式存储。这样不仅可用 Redis Desktop Manager 直接查看数据还能让 Java 对象和 JSON 之间的转换更透明。序列化配置代码如下Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 使用 Jackson 序列化 GenericJackson2JsonRedisSerializer jsonRedisSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(redisSerializer); template.setHashKeySerializer(redisSerializer); template.setValueSerializer(jsonRedisSerializer); template.setHashValueSerializer(jsonRedisSerializer); return template; }但注意用 Jackson 序列化时必须给 POJO 提供无参构造器否则反序列化会直接报错。购物车实体类我专门写了默认无参构造器避免这个坑。3.3 购物车数量变更与前端联动修改购物车数量是日常操作中最基础的交互。前端每次点击加减按钮实际上是一次完整接口请求。我这里的做法是接口接收 dishId、userId、数量增减方向加或减。服务端拿到请求后先读 Redis 购物车找到对应菜品项判断增减后的数量是否合法。数量不能小于 1也不能超过 99——这是业务侧给出的上限防止恶意刷取数据导致缓存膨胀。if (newNumber 1) { throw new RuntimeException(菜品数量不能小于1); } if (newNumber 99) { throw new RuntimeException(菜品数量不能超过99); }数量减到 0 时购物车项直接移除而不是保留一个数量为 0 的脏数据。Clear 操作接口则直接删除整个 user 维度的 key。这里我遇到过一个问题前端在清空后马上又要展示购物车结果因为 key 还没删除完成返回了旧数据。处理方式是清空操作同步执行调用 Redis delete 方法后立即返回——Redis 单体删除是毫秒级操作不涉及异步补偿不会出现一致性问题。4. 订单前置逻辑与核心表结构设计今天虽然没有把支付做完但订单相关的表结构和业务逻辑已经定型了。这个阶段把表设计思考清楚能避免后面改表带来的地狱级工作量。我梳理了两张核心表订单表和订单明细表同时明确了它们之间的数据关系。4.1 订单表与订单明细表的主从关系订单表和订单明细表是典型的“一主多从”结构。订单表记录一次下单的整体信息订单号、金额、地址、状态、用户 ID、下单时间。订单明细表则记录订单中包含的每道菜菜品 ID、名称、数量、单价、口味、小计金额。为什么要拆两张表因为业务上需要独立查询。用户在我的订单页只需要看订单列表不需要每次把全部菜品都带出来。数据量大了以后订单表和明细表的归档策略、查询策略都可能不同拆分开更灵活。订单号的设计是第七天的一个重点。我用了时间戳 用户 ID 后四位 随机数拼接的形式保证唯一性的同时兼具可读性。String orderNumber System.currentTimeMillis() String.format(%04d, userId % 10000) String.format(%03d, (int)(Math.random() * 1000));4.2 订单金额计算与精度处理外卖订单金额计算很容易出现浮点数精度问题。系统里所有金额字段我都使用了 BigDecimal而不是 double。double 在二进制表示中无法精确表达十进制小数0.1 0.2 会等于 0.30000000000000004。金额计算一旦出现这种误差财务对不上账就是大事故。下单时的金额计算顺序有讲究先算单品小计再累加结算总价。我额外做了金额校验——服务端重新计算一次总价如果和前端传入的总价不一致直接拒绝下单。这个校验能挡掉绝大多数价格篡改或传输错误。品类数量越多每项金额的舍入误差叠加越明显。我的处理基准是所有金额计算使用 ROUND_HALF_UP 保留两位小数并且只在最终小计和总计两个节点做舍入中间过程尽量保持原始精度。BigDecimal totalAmount BigDecimal.ZERO; for (CartItem item : cartList) { BigDecimal itemAmount item.getPrice() .multiply(new BigDecimal(item.getNumber())); totalAmount totalAmount.add(itemAmount); } totalAmount totalAmount.setScale(2, RoundingMode.HALF_UP);4.3 地址簿与配送范围的联动下单前必须校验配送地址是否在门店配送范围内。这里我引入了地址簿表用户下单选地址时从地址簿获取。地址簿表保存收货人姓名、手机号、详细地址以及经纬度。派送范围判断我用了简单的坐标范围匹配。门店预设一个中心经纬度和配送半径通过 Haversine 公式计算用户地址到门店的距离控制在半径内才允许下单。这个算法虽然简单但对外卖这种固定门店半径的场景已经够用。如果后续要支持多边形配送范围可以把坐标点预存成 GeoJSON 或多边形顶点改用射线法校验现在暂时不需要。距离计算代码示例private double distance(double lat1, double lng1, double lat2, double lng2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double a radLat1 - radLat2; double b Math.toRadians(lng1) - Math.toRadians(lng2); double s 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2) )); return s * 6371.0; // 地球半径单位公里 }距离计算的结果在提交订单时填入订单表同时作为派送费计算依据。超过基础配送距离但仍在范围内的地址派送费按照每公里递增的方式追加。这个逻辑虽然简单但足够应付需求文档中的佣金和配送费模式后边如果接第三方配送平台才会替换成平台返回的费用。5. 代码结构与接口设计的心得从 Day01 到 Day07我体会最深的不是某个技术点怎么用而是如何组织代码结构让项目在快速迭代中保持清晰度。今天我把 controller、service、mapper 三层结构的边界重新整理了一遍下面几个心得值得记录。5.1 Controller 层只做参数接收和结果封装我见过很多人把业务逻辑直接写在 Controller 里一眼看去接口是跑通了但后边加一个功能就要动 Controller改一处牵一发而动全身。第七天我坚持的原则是Controller 层方法只做三件事——接收参数、调用 Service、返回统一结果对象。即使是一个只有两行逻辑的查询我也要求自己必须经过 Service。这样的好处是后续做事务管理、缓存注解、权限校验都有统一入口不会出现一个接口有事务另一个没有的混乱状态。统一返回结果我用了 Result 封装类包含 code、msg、data 三个字段。code 为 1 表示成功为 0 表示失败msg 存放错误信息data 是具体返回数据。前端拿到 Result 后先判断 code再解析 data逻辑非常统一。5.2 业务异常与全局异常处理器外卖项目里大量业务校验不通过的情况比如菜品已下架、地址超出配送范围、购物车为空等。这些不是系统级异常而是业务规则不满足必须返回给前端明确提示。我实现了一个自定义业务异常类public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } }然后在全局异常处理器中统一捕获。这样 Service 层只需在规则不满足时抛出 BusinessException所有异常信息都会以 Result 的形式返回给前端不会暴露堆栈信息也不会有莫名其妙的 500 错误。5.3 为什么 Service 层要拆接口和实现类项目初期也许会嫌接口实现类麻烦但到了第七天业务逻辑增多我发现拆开之后收益很大。接口定义了能力边界实现类隐藏了细节。后续如果要加缓存、加消息队列、替换存储引擎只需要替换实现类调用方完全无感知。更重要的是测试时可以用 Mockito 直接 Mock Service 接口不需要加载整个 Spring 容器。这在写单元测试时优势巨大。外卖项目业务多接口和实现分离带来的可测试性提升会让后边的开发质量明显提高。6. 实战踩坑与排错经验第七天开发中我遇到不少问题这里挑出四个最有代表性的把排查过程和根因分析完整记录下来。这些问题如果你不亲自动手看十遍文档也记不住。6.1 Redis 中购物车数据变成乱码现象用 Redis 客户端查看购物车 key发现 value 是类似\xAC\xED\x00\x05t...的乱码序列。原因项目中某处代码遗漏了 RedisTemplate 的 JSON 序列化配置走的是默认 JDK 序列化。排查全局搜索所有 RedisTemplate 的创建和注入方式发现有一个工具类直接 new 了默认配置绕过了我自定义的配置类。解决统一收口 RedisTemplate 的实例化不允许任何地方自己 new。项目中有且只有一个 RedisConfig 配置类所有注入点都从这里获取。这一个改动解决了后续所有乱码问题。6.2 菜品修改后缓存不生效现象后台把一道菜设为下架前端菜品列表仍然显示该菜品。原因修改菜品时只更新了数据库删除缓存的代码只在特定分支执行下架状态变更的分支没有走到删除逻辑。排查打开 DEBUG 日志观察下架请求最终的缓存操作记录发现根本没有执行 Redis delete。解决所有可能改变菜品展示状态的入口上下架、修改信息、删除都必须调用同一个清理缓存方法。我把清理逻辑封装到一个独立方法中强制每个入口都调用不再有分支遗漏。6.3 下单时金额对不上账现象用户提交订单前端展示的总金额和服务端重新计算的总金额不一致偶发差一分钱。原因前端展示金额时用 double 运算服务端用 BigDecimal 计算换算过程中出现精度丢失。另外购物车内多个菜品的小计金额各自舍入后再相加和服务端一次性累加原始价格再舍入结果不一致。解决前后端统一金额类型约定前端展示仅用于展示后端以原始价格累加计算为准。服务端完善校验逻辑两边误差超过 0.01 元直接拒绝下单。6.4 高并发下缓存击穿导致数据库压力飙升现象某个热销菜品的缓存刚好过期大量用户在同一时刻请求这个菜品数据库瞬间被打满。原因缓存失效的一瞬间没有互斥控制所有请求都去查询数据库并尝试回填缓存。解决使用 Redis 分布式锁在查询数据库之前尝试加锁获取锁的线程负责查询和回填缓存未获取锁的线程短暂自旋后从缓存重新获取。这样同一时刻只有一个请求会穿透到数据库。// 加锁代码简化版 String lockKey lock:dish: dishId; boolean lock redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (lock) { try { ListDish dishes queryFromDB(dishId); cacheDishList(dishes); return dishes; } finally { redisTemplate.delete(lockKey); } } else { Thread.sleep(50); return getDishListFromCache(dishId); }锁的过期时间要大于查询和回填的总耗时否则锁提前释放后其他请求会再次穿透。同时用 try-finally 保证删除锁一定执行防止锁残留造成死锁。7. 第七天实操后的效率提升技巧开发到第七天我对这套技术栈的熟练度已经提升不少。下面几个技巧是今天实践下来效率提升最明显的分享给同样在肝项目的你。7.1 用注解简化缓存操作我尝试在 Service 方法上使用 Spring Cache 注解来管理缓存大幅减少了显式调用 RedisTemplate 的样板代码。虽然项目里最终仍保留了手写缓存实现来应对复杂逻辑但简单的“查缓存-回填缓存”场景用注解简洁得多。Cacheable(value dish, key #categoryId) public ListDish listByCategory(Long categoryId) { return dishMapper.selectList(...); }但注解方式在缓存穿透防护和过期时间随机化上做不了太细的控制所以只适合基础场景使用。复杂的菜品查询仍然需要手写缓存逻辑这个取舍要心里有数。7.2 统一异常信息和错误码外卖项目后期要对接前端、小程序、管理后台错误信息的统一非常重要。我在项目里维护了一个错误码常量类所有业务异常都引用常量而不是直接写死字符串。这样前端可以根据错误码做国际化翻译后端也方便统计各种错误的出现频率。public class ErrorCodes { public static final int CART_EMPTY 1001; public static final int ADDRESS_OUT_OF_RANGE 1002; public static final int DISH_NOT_AVAILABLE 1003; }今天排查问题最舒服的时刻就是看日志里直接出现语义化的错误码不用翻代码猜是哪块业务抛出的异常。7.3 利用日志定位缓存问题缓存相关的问题最难查因为数据在 Redis 里看不到应用的调用链。我今天的做法是在缓存读写点都打上精简日志。日志内容包括操作类型命中/未命中/回填、key 名称、耗时毫秒数。这样一来当用户反馈数据不对时我直接查日志看缓存命中链路就能快速定位是缓存没删还是回填了脏数据。日志格式我保持统一cache hit | keydish:category:123 | cost3ms cache miss | keydish:category:123 | cost28ms | query1用这个方式今天处理三个缓存相关问题平均只花了十分钟定位。日志的开销在可接受范围内换来的是成倍的排障效率。8. 我个人的实操体会从第七天这个节点往回看外卖项目最大的难点不是任何一个单一技术点而是如何把缓存、购物车、订单、地址这些模块有条理地串起来。如果一开始只盯着代码能不能跑通到后边一定会因为模块之间的耦合吃亏。我在实际操作中最受益的一个习惯是动手写代码前先把当天的核心流程画一遍理清调用关系和数据结构。画图不一定要多正式在草稿纸上标出 Controller、Service、Mapper、Redis 之间的调用链就够用。这个习惯在第七天的缓存一致性处理中起到了决定性作用——我知道哪些入口会修改菜品数据自然就知道从哪里删除缓存。第七天这个进度正好是项目难度爬坡的开始。前面的增删改查是基础功从缓存、购物车、订单流程开始业务逻辑已经不再线性需要你在动手之前多想一步这个操作会影响哪些数据会不会有并发问题旧数据什么时候失效。后面的开发大概率还会遇到支付回调、超时任务、催单推送这些更复杂的功能。但只要你把 Day07 这些基础模块的逻辑边界和数据结构想清楚后面接新的功能模块就像搭积木而不是补窟窿。最后再分享一个小技巧每次开发完一个功能点先不要急着测下一个需求花五分钟把自己改动的代码走一遍调用链看看有没有入口遗漏了缓存清理。这五分钟的检查往往能帮你省下第二天排查问题的两小时。