做Java后端这些年身边总有朋友让我推荐适合练手的项目。如果是零基础刚学完SSM或者Spring Boot我通常不会让他们去啃那些几百行的Demo而是会建议直接上手一个有完整业务闭环的项目。苍穹外卖这个题目恰恰是这类项目中很典型的一个——表面上看是个外卖订单系统实际上把后端日常开发里最常遇到的典型场景全部串在了一起用户授权登录、菜品分类查询、购物车管理、订单状态流转、数据统计报表一个都没落下。这篇文章我从做过的角度出发把它的架构选型、核心模块实现、以及你在学习过程中大概率踩过的坑逐层拆开讲清楚。1. 项目整体设计与技术架构拆解1.1 为什么选外卖场景业务复杂度与学习价值的平衡先说个很多人忽略的问题选学习项目不是越复杂越好而是复杂度和你的目标要匹配。太简单的项目比如图书管理系统说难听点就是几张表的增删改查写三轮你就没得写了状态机、缓存一致性、事务边界、复杂SQL这些问题一概碰不到。太复杂的项目比如电商秒杀、海量日志分析光环境搭建就能劝退一大半人还没跑通主流程就被各种中间件绕晕了。外卖系统卡在两者中间很巧妙。它的业务规则足够丰富菜品分类有两层、口味规格可以做组合、购物车要支持增删改、订单必须走状态流转、后台还要出统计报表。每个模块单独拆开都不算难但组合在一起就需要你认真思考数据库设计和接口划分。这种跳一跳够得着的难度恰恰是最适合用来建立工程思维的。另外一点你会反复用到真实项目里的设计模式。比如套餐和菜品是一对多的关系购物车要区分不同口味订单状态有六种流转路径这些都不是教科书上那种生硬的例子而是你以后工作里真的会碰到的业务模型。做完这个项目你再去看别的Spring Boot项目会发现大部分套路都是通的。1.2 整体分层架构从Controller到Mapper的四层结构苍穹外卖是标准的前后端分离架构服务端对外提供RESTful接口管理后台和C端小程序共用同一套接口服务。从代码结构看它是非常标准的Spring Boot分层应用Controller层负责参数接收、请求转发、统一响应包装。Service层业务核心逻辑层事务边界从这里开始。Mapper层数据访问层项目使用MyBatis的XML方式写SQL。Entity/DTO/VO数据载体层分别对应数据库表、接口入参、接口出参。这里最值得学习的其实是响应包装的规范。项目里几乎每个接口都返回统一的结果对象包含code、msg和data三个字段。前端拿到响应后先判断code是不是200再决定是渲染数据还是弹出异常提示。这个习惯看似简单但能坚持做到的项目并不多。我见过太多半路出家的开发接口返回格式一会儿是Map一会儿是裸对象联调时前端叫苦连天。宁可开始多写一点包装代码后面维护省下的时间远比你想象的要多。分层的另一个要点是Entity、DTO、VO绝对不能混用。很多新手图省事直接把数据库实体类返回给前端结果把密码哈希、逻辑删除标记这些不该暴露的字段全泄露出去了。正确的做法是每个层用自己的模型对象层与层之间手动拷贝属性。这个项目里用到了对象拷贝工具来做这件事既保留了结构清晰又没增加太多样板代码。1.3 技术栈选型对比学习项目的最优解先列出这个项目涉及到的完整技术清单技术类别具体选型使用场景基础框架Spring Boot 2.x整体应用骨架持久层MyBatis PageHelper数据访问与分页查询数据库MySQL核心业务数据存储缓存Redis购物车、菜品缓存、验证码鉴权JWT登录状态校验接口文档Knife4jOpenAPI前后端联调文档实时通信WebSocket管理端来单提醒文件存储本地文件 / 云OSS菜品图片、用户头像上传这套技术组合的选型逻辑有几个值得琢磨的地方。第一为什么持久层用MyBatis而不是Spring Data JPA。从学习价值来看MyBatis需要你手写SQL对SQL基本功的提升帮助很大这一点在后面的数据统计模块体现得尤为明显。而且从国内就业环境看MyBatis以及MyBatis-Plus的使用率相当高用这个项目练手面试时的项目经验描述也更接底气。第二Redis在整个项目里的定位是能用但不过度用。购物车数据临时性强适合放Redis菜品列表读多写少适合缓存加速但订单数据必须落在MySQL因为订单涉及事务和统计。这个区分很重要。很多新手喜欢把能缓存的东西全塞进Redis结果数据一致性处理不好反而越做越乱。分布式技术是为了解决特定问题才引入的不是为了撑场面。第三项目用JWT做无状态认证而不是传统的Session方案。前后端分离架构下Session在跨域和横向扩展时都比较麻烦JWT把用户信息编码进Token里服务端不保存会话状态。这个选择更贴近目前主流公司的做法学完以后面对真实项目也不会觉得陌生。2. 核心业务模块与数据库设计的实操逻辑2.1 数据模型设计从用户表到订单明细表先看数据库设计。这个项目的数据模型不算复杂但表结构设计很规范核心表可以分成三个维度来理解。第一类是基础数据表员工表、分类表、菜品表、套餐表。菜品和套餐通过关联字段对应菜品表里通过状态字段控制上下架。这里有个常见的设计细节分类表用type字段区分菜品分类和套餐分类这样两张分类其实可以共用一张表省掉了一张表的冗余。你在做类似业务时也可以考虑这种一表多用的建模方式。第二类是用户相关表用户表、地址簿表。用户通过微信授权登录后落地到用户表每个用户可以有多个配送地址地址簿和用户是一对多的关系。第三类是交易数据表购物车表、订单表、订单明细表。其中订单表和订单明细表是主表明细表的经典结构。为什么订单明细要单独拆一张表因为一个订单包含多个菜品如果直接把菜品列表塞进订单表的一个字段里后续做销量统计、退款计算、对账都会非常痛苦。订单明细表每行对应一个菜品通过订单ID关联回主表。在设计阶段重点应该放在订单表的状态字段上。从待付款、待接单、待派送、派送中到已完成、已取消整条状态的流转路径是一个不折不扣的有限状态机。业务规则要求状态只能按固定路径迁移绝对不能从待付款直接跳已完成。这个状态机设计才是订单模块真正的核心。2.2 认证与授权机制的实现细节用户端的授权登录是项目里比较早要做的模块。原理是前端小程序调用微信登录能力拿到临时凭证后端拿这个凭证去微信接口换取openid然后根据openid查数据库看用户是否已存在不存在就自动注册一个新用户。理解这层逻辑之后你就明白了openid是用户在微信体系内的唯一身份标识后端再基于这个标识生成自己的登录凭证。后端生成的登录凭证用的是JWT。JWT本质上是一串由三部分组成的字符串Header声明签名算法Payload存放用户ID、角色、过期时间等数据Signature用来防止数据被篡改。项目里的做法是写一个拦截器每次请求进来先从请求头取出Token验签通过后解析出用户ID放到本地线程变量里后续业务代码直接从线程变量取当前用户不用每个方法都去查数据库。这个机制有三个必须注意的坑。第一JWT是无状态的签发之后在过期之前没办法主动吊销。如果用户修改密码旧Token在到期前仍然是有效的。解决方案是引入Token版本号比如把密码变更时间戳存进JWT的Payload服务端也存一份比对不一致就拒绝。第二Token过期时间必须合理。用户端场景一般设置两个小时左右管理端建议更短并配合刷新机制。过期时间设置太长账号泄露后风险窗口太大设置太短用户体验又差这个平衡要在真实业务里反复调。第三不要往Payload里放敏感信息。JWT的Payload只是Base64编码不是加密任何人都能解码看到内容。明文放手机号、密码这些字段等于把隐私信息直接暴露到了Token里。2.3 菜品与分类模块的完整实现分类管理是管理端的基础功能之一。分类分两种菜品分类和套餐分类通过type字段区分。增删改查本身不难真正值得学习的是删除时的关联检查——如果某个分类下已经关联了菜品就不能直接删。这个逻辑的背后是数据完整性约束在业务层的体现。真实商业项目里很少启用数据库外键约束性能和维护成本太高所以是否允许删除这种规则要靠开发者在Service层自己控制。推荐做法是删除前先查一下菜品表里有多少记录的分类ID指向待删除分类数量大于零就直接抛业务异常。注意这里不要相信前端传来的数量参数删除前必须后端实时去数据库查。菜品模块比分类复杂原因在于一个菜品可能有多个口味。所以除了菜品主表还要设计一张口味表通过菜品ID关联回去。保存菜品时第一步插入菜品基本信息第二步把口味列表循环插入口味表。很多新手在这里踩过一个隐蔽的坑两步操作不是原子的如果口味插入到一半失败了菜品就变成了有头无身的脏数据。解决办法很简单在Service层的保存方法上加上事务注解让整个保存过程同生共死。起售和停售菜品也要注意联动逻辑。如果菜品已经停售那么包含该菜品的套餐也应该同步停售不然用户在小程序端还能正常看到并下单后面履约环节就出麻烦了。这个联动逻辑是初学者最容易漏掉的我记得当时带过的一位同学把所有注意点都放在CRUD上结果在套餐中包含已停售菜品这个边界场景里暴露了问题。3. 高价值功能的落地过程与踩坑记录3.1 购物车模块Redis与数据库的取舍购物车是这个项目里教学价值很高的模块之一。外卖场景的购物车有两个特点一是临时性强用户可能加了一堆菜最后没下单第二天再来就清空了二是读写频繁每点一次加购都要更新状态。基于这两个特点项目把购物车数据放在Redis里而不是MySQL表里。具体做法是以用户ID拼出一个Redis的key购物车明细用Hash结构存储field是菜品IDvalue是购物车条目对象。每次加购就执行一次哈希写入操作如果菜品已经存在就在原数量上做累加。这套模型非常适合临时性数据的读写场景比数据库表反复增删的效率高得多。但这个设计会带来两个必须处理的问题。第一个是数据过期问题。Redis缓存要设置过期时间购物车key一般给一个较长的期限比如7天同时每次用户操作时刷新过期时间。这里不设过期时间会让Redis内存无限膨胀设太短又会误删用户还没提交的数据。第二个问题更隐蔽是购物车数据与下单流程的一致性。用户在购物车界面看到的价格必须和实际下单时计算出来的价格一致。我的建议是下单时不要信任前端传来的金额字段而是根据菜品ID重新查询最新的价格来计算订单金额前端金额只作为展示参考。这一步看着多查了几次数据库但能避免用户手速过快导致的价格偏差问题。3.2 订单流程从下单到派单的状态机设计订单模块是整个项目里状态流转最复杂的部分。我建议学习的时候做一件事把六种订单状态和每个状态的迁移条件画成一张流转图贴在代码文件旁边写代码时随时对着看。用户提交订单时后端要完成的主线操作包括保存订单主表、批量保存订单明细表、清空用户购物车。如果设计里还涉及库存还要扣减库存。这里事务粒度很重要保存订单、清理购物车、扣库存应该放在同一个事务方法里任何一个环节失败整体回滚。如果把这几步拆到Controller里分步调用中间任何一步出异常数据库里就会出现只有订单没有明细或者购物车没清干净的脏数据。商家在管理端的操作主要是接单和派单。接单把订单从待接单改为待派送派单则把状态改为派送中并记录配送信息。这两个操作背后都有一个并发问题需要处理如果两个管理员同时操作同一个订单怎么办解决方案是在更新订单状态的SQL里加上前置状态条件只有当前状态等于预期状态时才允许更新。比如更新一个待接单的订单SQL里必须带当前状态等于待接单这个条件只要有一个请求更新成功了另一个请求就只能更新0行从而自然失败。WebSocket来单提醒是订单流程里比较有趣的功能。后端与前端建立WebSocket长连接当新订单创建的消息发布时后端通过WebSocket会话把消息推送到管理端页面实现叮咚一声弹出来的效果。很多人学到这里会卡住因为没分清WebSocket和HTTP的区别——HTTP是请求之后服务端给一次响应就断开WebSocket是建立连接后双方随时都能往对方发数据。弄清楚这个基本区别再看WebSocket相关配置就顺了。3.3 数据统计模块SQL聚合的艺术数据统计模块是整个项目里最能体现SQL水平的部分。它要完成的功能包括统计每日营业额、统计每日新增用户、统计每日订单量、统计销量Top10。以营业额统计为例核心SQL是查询订单表中某个时间段内状态为已完成的订单金额总和。SQL本身不复杂难点在日期的边界处理。统计今日营业额正确做法是用数据库的日期函数动态生成今天零点到当前时刻的时间区间而不是在前端把起止时间算好传过来。为什么因为前端和后端如果存在时钟偏差统计结果就对不上报表场景必须以服务端时间为准。销量Top10排名则更考验SQL组织能力。得把订单明细表按菜品ID分组用汇总函数算出每个菜品的总销量然后排序取前10条再关联菜品表查出名称和图片。这里有个容易被忽略的细节订单明细表里其实存了一份菜品名称快照可能和菜品表当前名称不一致。因为菜品改名之后历史订单明细不会跟着改。所以统计展示应该以订单明细表里的快照为准而不是去关联菜品表。这个细节自学项目基本不会教但真实业务里一定会遇到。统计模块也让我体会到为什么说MyBatis在复杂报表场景里更实用。数据统计SQL往往要跨多张表做聚合如果强行用JPA的流式API拼装可读性和调试性都会大幅下降。MyBatis里把SQL写在XML文件里改起来方便出现问题还能拿出来直接丢进数据库客户端跑一遍定位。这个优势在数据统计场景里被放得很大。4. 常见问题与排查技巧实录4.1 金额计算精度BigDecimal的陷阱外卖项目里到处是金额字段单价、金额、营业额。新手最容易犯的错误是用Double或者Float来定义金额变量。二进制浮点数表达十进制小数本来就有误差做加减乘除会出现莫名其妙的尾数。这在金钱场景里是完全不能接受的。正确做法是统一使用BigDecimal并且在涉及乘法、除法的地方显式指定小数位保留规则。例如订单明细行的金额等于单价乘以数量结果需要调用四舍五入方法保留两位小数。加法时如果小数点后位数不一致也要通过设置精度保证最终结果的正确性。还有一个细节很多人会忽略BigDecimal的equals方法和compareTo方法行为不同。equals会比较数值和精度也就是说0.00和0.0在equals眼里是两个不同的对象。但compareTo只比较数值大小所以当你要判断两个金额是否相等时必须用compareTo而不是equals否则会出现两个金额明明数值都是0却判定不相等的问题。这种bug光看代码非常难发现很容易让人怀疑是数据库算错了。4.2 文件上传路径与回显问题菜品图片上传是文件存储功能的经典练习场景。项目里把图片保存到服务器本地磁盘目录数据库里只存一个访问路径。这个存储与访问分离的设计是图片能不能正常回显的关键。新手最常见的问题是把本机绝对路径存进数据库比如C:\upload\xxx.jpg。本地测试看着一切正常换一台电脑或者部署到服务器上图片全部崩掉。正确做法是数据库只存相对路径或者URL路径比如/upload/xxx.jpg再通过配置把静态资源目录暴露给外部访问。这样部署时切换环境只需要调整公共配置里的磁盘路径数据库数据完全不用动。另一个高频问题是图片上传成功后端也能访问但前端页面上图片不显示。排查思路一般是打开浏览器开发者工具的Network面板看图片请求返回了什么状态码。常见的坑包括路径拼错、端口不一致、代理服务器没映射静态目录、文件扩展名大小写不匹配。记住一个原则先把图片URL原样复制到浏览器地址栏里访问如果能打开就是前端拼接问题打不开就是后端映射问题一测就定位了。4.3 Redis缓存与数据库的一致性问题菜品列表查询会缓存到Redis提升接口性能。但菜品名称、价格修改之后缓存里的旧数据怎么办最简单的通用方案是先更新数据库再删除Redis缓存。为什么是删除而不是同步更新因为同步更新要考虑的并发场景太复杂更新过程中有新的读请求进来很可能会把旧数据又写回去。而菜品这种数据不是高频变化的删除缓存之后下一次查询时发现没有缓存再从数据库加载并写回Redis成本很低。操作顺序上要特别注意。如果先删缓存再更新数据库在高并发下会有一个窗口期别的请求在删缓存后、更新前的间隙里把旧数据重新写回缓存脏数据就会一直在Redis里待着。所以顺序必须是先改数据库再删缓存。如果删缓存这一步失败可以考虑用延迟双删策略或者消息队列做补偿。单体学习项目里做到先更新再删除并记录删除失败日志已经能覆盖绝大多数场景了。4.4 事务失效的几个典型场景凡是写过这个项目的人基本都在事务上栽过跟头。我整理三个最容易中招的场景。第一个事务注解加在非public方法上。Spring事务默认通过动态代理实现只有通过代理对象调用public方法时事务拦截器才会生效。同一个类内部的方法调用比如A方法调同类里的B方法B方法上的事务注解是不会生效的因为这次调用绕过了代理对象。解决办法是把需要事务的方法单独放到另一个Service类里或者注入自身代理来调用。第二个数据库表用了不支持事务的存储引擎。MySQL在InnoDB引擎下才支持事务如果建表的时候误用了MyISAM事务根本不会生效。排查方式很简单执行语句查看表的存储引擎如果是MyISAM就改成InnoDB。这个坑在本地开发环境很少遇到但如果不小心用了历史遗留的建表脚本就很容易踩中。第三个异常被吞掉了。Spring事务默认只回滚运行时异常如果你在业务代码里用catch把异常接住并且没有重新抛出来事务层完全感知不到异常自然也就不会回滚。这种bug最坑的地方是程序不会崩溃但数据库里会留下一堆半成品数据。所以在事务方法里尽量不要随意捕捉异常如果确实需要catch要么在catch块里把异常重新抛出要么通过事务参数显式声明需要回滚的异常类型。坦白说苍穹外卖这个项目在技术上不算有天花板的复杂度它的价值在于让学习者用一条完整业务线把后端开发里最重要的东西串了一遍从表设计到接口开发从缓存使用到事务控制从状态机设计到SQL聚合统计。如果你正在学Java建议把这个项目从头到尾做完并且不要停留在跑通的阶段试着去理解每个模块背后的为什么。我陪着不少人走过这条路一个很深的体会是能把这里面的坑一个个填平的人后面接触任何Spring Boot项目上手速度都会比没做过完整项目的人快得多。做完以后不妨再试着改改需求比如增加搜索功能、接入第三方物流回调、做简单的库存预扣你会发现一个项目越改越像真实系统你的收获也会远超预期。
阅读完成 · 觉得有帮助?