苍穹外卖项目实战:Spring Boot + Redis 构建完整O2O订单系统 做这个项目之前我在本地跑过不少零散的接口demo但真正动手把“苍穹外卖”这套完整项目从头到尾捋下来之后才感受到一个线上级业务系统涉及的东西远不止CRUD那么简单。这套外卖系统很像一个浓缩版的商业中台用户端小程序、商家端后台、管理端控制台外加一整套订单流转、购物车、支付回调、配送提醒、数据统计的链路。无论你是准备毕业设计、想写进简历的练手项目还是想搞明白“一个真实业务系统怎么从零搭起来”这个项目都能给你很完整的参照系。主后端用的是Spring Boot持久层用了MyBatis也有一版是MyBatis Plus缓存交给Redis登录鉴权走JWT前后端分离管理端用Vue构建。整个开发过程里最难的部分不是某个算法而是“多个模块之间怎么把状态同步好”——订单状态怎么变、库存什么时候扣、超时单怎么处理、WebSocket怎么把配送状态推给用户。这篇文章我会把你需要关注的模块设计、关键实现、部署细节以及我实际调试过程中踩过的坑全部拆开讲尽量做到你照着往下走就能复现。1. 项目全貌与技术选型拆解1.1 为什么叫“苍穹外卖”它在解决什么问题“苍穹外卖”是一个典型的O2O外卖平台项目名字本身是代号你可以把它理解成一套“小美外卖”或“某外卖系统”。它模拟真实外卖平台的核心闭环用户浏览菜品、加入购物车、下单支付模拟、商家接单、骑手配送、用户确认收货同时管理端还承担分类管理、菜品管理、套餐管理、员工管理、订单查询和数据统计等功能。这个项目最大的价值在于覆盖面广它不是简单的“增删改查”而是把真实的业务状态机、并发控制、缓存策略、权限模型都融入进来了。比如用户端需要处理购物车勾选的一堆菜品下单时要同时考虑起送价、配送费、订单总金额和结算明细商家端要能看到新订单并执行接单/拒单管理端要能按时间段查询营业额和用户下单趋势。任何一个环节如果只写表面接口都会在联调时出问题。对于开发者来说它解决了“只会写CRUD不知道业务怎么串联”的痛点。你会真实地感受到一个订单从创建到完成需要哪些表需要哪些状态字段什么时候用Redis存购物车什么时候必须落到MySQL什么时候用定时任务去处理超时未支付订单。这些经验在面试和实际工作中都非常关键。1.2 技术栈清单与选型理由先列一份我在项目中实际使用的技术栈集合方便你对照环境层次技术选型说明后端Spring Boot 2.x主框架快速搭建RESTful服务持久层MyBatis含MyBatis Plus封装灵活编写SQL应对复杂报表缓存Redis缓存店铺营业状态、购物车数据存储JWT黑名单鉴权JWT Spring Interceptor无状态登录校验数据库MySQL 5.7/8.0存储持久化业务数据前端用户端微信小程序原生或uni-app贴近真实C端场景前端管理端Vue Element UI管理后台界面接口文档Knife4j / Swagger前后端联调必备部署Linux Nginx Docker可选模拟线上环境选型背后的逻辑我需要多说几句。Spring Boot在Java生态里就是“约定优于配置”的代言人起步快并且社区资料极其丰富。MyBatis和JPA的争论一直存在但这个项目里我选MyBatis是因为很多查询涉及多表关联、动态条件、金额统计MyBatis的SQL可控性更强出现慢查询时可以直接在XML里优化。Redis在这里不是装饰品而是真实承担了高频读写业务外卖系统里的店铺营业状态每天被看好几次购物车数据用户改来改去写在MySQL里不仅要频繁建会话表压力也大放在Redis里天然带过期时间数据结构又支持Hash和List非常契合。JWT则解决了分布式场景下的会话共享问题。虽然单体项目用Session也可以但JWT更接近现在的主流架构习惯登录后服务端不保存用户会话只需要在拦截器里校验Token的签名和有效期这样后续如果要把服务拆成多实例也无须额外同步Session。1.3 系统角色与模块划分我从实际开发的角度把整个项目拆成三大块用户端小程序/移动H5登录、浏览分类与菜品、购物车、下单、支付结算模拟、查看订单状态、催单。商家端管理后台一部分主要是商家或店员视角管理菜品、接收新订单、订单状态流转比如接单、配送中、完成。管理端平台运营视角员工管理、分类与菜品管理、套餐管理、订单综合查询、营业数据统计、工作台运营概览。为什么要把商家端和管理端分开真实外卖平台中商家只能管理自己的店铺平台运营可以查看全量数据。这个项目里Model划分虽然没有搞微服务那么重但Controller层的包结构、权限拦截、角色判断还是要提前设计好。我一开始就是把所有接口都堆到同一个Controller里后面发现要么是权限混乱要么是字段输出到前端时互相干扰所以项目里凡是标识了admin的路径都统一管理凡是/user路径都走用户身份判断形成约定后代码好维护很多。2. 数据库设计与核心表结构解析2.1 业务实体拆解做任何项目数据库设计都是地基。我最初设计这张表清单花了两天时间来回调整了三版。最终的核心表大概包括员工表employee管理端登录账号包含账号、密码加盐加密、状态。分类表category类型区分菜品分类和套餐分类名称、排序、是否删除。菜品表dish菜名、图片、价格、描述、所属分类、状态启售/停售。口味表dish_flavor很多菜品需要选辣度、规格一对多。套餐表setmeal包含套餐价格、分类、状态。套餐与菜品关联表setmeal_dish多对多关系。用户表user微信端用户信息openid模拟、昵称、手机号。地址簿表address_book收货人、手机号、详细地址、标签家/公司。购物车表shopping_cart其实可以用Redis但落库版本能把购物车持久化到MySQL表里存用户ID、菜品ID、口味JSON、数量、金额。订单表orders订单号、用户ID、地址快照、金额明细、状态、下单时间、支付时间、预计送达时间。订单明细表order_detail一个订单对应多个菜品记录菜品快照信息。工作台相关统计表一般实时计算不单独建表。这里想强调两个容易被忽略的设计点。第一订单表里一定要存地址快照和菜品快照而不是通过外键去关联用户地址表和菜品表。否则用户以后改了地址或者菜品下架改名历史订单的数据就全部乱掉了。第二金额字段要用decimal(10,2)不能用float或double这个面试里踩中概率极高——二进制浮点数在累加时会出现0.10.2不等于0.3的问题订单金额错了可没法交代。2.2 订单状态流转与关联设计订单是这个系统的核心我们把状态机设计为状态值含义对应的用户操作1待付款用户下单后未支付2待接单已支付等待商家确认3已接单商家已接受制作中4配送中骑手正在配送5已完成用户确认或自动完成6已取消用户取消或超时取消7退款售后状态模拟状态之间绝不能乱跳比如待付款状态下是不能直接改成配送中的。我代码里在Service层写了一个统一的状态校验方法不允许非法状态跃迁。在订单表里还设置了一个字段区分是用户取消、商家拒单还是超时取消便于后续统计取消原因。和订单关联最紧密的就是明细表。明细表不需要和菜品表做强关联因为菜品表字段可以随便改但明细要保留当时的价格。如果你在管理端改了菜品价格然后回头查历史订单发现金额变了那就是关联设计出了问题。这是我实际开发中遇到的真实问题所以快照思路要严格执行。2.3 数据库设计的几个坑第一个坑是时间字段的时区问题。MySQL里如果连接参数没有设置serverTimezoneAsia/Shanghai入库的时间会和本地时间差8小时。订单的统计是按天来的时区一乱营业额统计就大概率偏移。第二个坑是逻辑删除。很多表都加了deleted字段但查询时稍不注意就把已删除的分类也查出来了。我采取的方案是MyBatis配置全局的逻辑删除字段并在业务层通过构造查询条件强制过滤。第三个坑是索引设计。订单表数据量一旦上来如果没对user_id和status建联合索引按用户查历史订单、按状态列订单都会走全表扫描。我的建议是order_no做唯一索引user_idstatus做联合索引订单创建时间的范围查询也要考虑到。3. 核心功能实现与实操要点3.1 用户登录与JWT鉴权流程外卖项目的用户端登录一般走微信登录真实场景需要调用微信的code2session接口换取openid。做模拟项目的时候不需要真实的AppID所以我在项目里封装了一个类似的登录服务前端把模拟的code传过来后端通过固定的配置类生成一个openid真实场景就是请求微信接口获取再根据openid查用户表不存在则自动注册新用户最后生成JWT返回给前端。代码层面JWT我用的是jjwt库核心操作就是三块生成Token、解析Token、校验过期时间。实际项目中我会自定义一个拦截器或者HandlerInterceptor对所有除登录接口之外的路径进行校验。拦截器里从Header中取token解析成功就把用户ID放到ThreadLocal或RequestContext里后续Controller直接取。这里有个关键点ThreadLocal用完后一定要remove否则线程池复用时会带来数据串台的隐患。我踩过的坑是第一次设计Header字段时叫“Authorization”但管理端和用户端用了同一个拦截器导致用户Token被当成管理员Token用。后来我给两个端分别定义了不同的鉴权拦截器且路径前缀区分开/admin/**走管理端校验/user/**走用户端校验。这个设计影响到后面所有模块的联调提前理清很有必要。3.2 购物车与下单链路购物车是这个系统中“高频写”的代表。用户加购时前端把菜品ID和口味组合传给后端后端先判断Redis里有没有对应数据如果有数量加一如果没有初始化一个新的购物车条目。用Hash结构存储时key可以用userIdfield用“菜品id_口味hash”这样同一个菜加不同辣度可以分开计数也方便前端回显购物车列表时聚合。下单链路我更想强调“一致性”。下单涉及的操作包括校验购物车非空、计算总金额、校验余额或模拟支付结果、生成订单主表和明细表、清空购物车、扣减库存如果做了库存。这些操作必须放在同一个事务中否则一旦中途异常订单表和库存表就出现不一致。我在做库存扣减时一开始用的是先查库存再更新结果并发下单时出现超卖问题。后来改了SQL直接通过UPDATE语句“库存购买数量”这个条件来扣减update dish set stock stock - #{num} where id #{id} and stock #{num}。受影响行数为0说明库存不足需要回滚。这是我在实际项目中记忆很深刻的一个教训虽然苍穹这套项目有些版本没有做库存字段但加上这个点会让项目含金量更高。3.3 订单状态变更与消息推送订单状态变化之后用户端需要实时看到。这里我没有引入MQ做异步推送而是用WebSocket建立一个长连接商户或管理端修改订单状态后服务端主动把最新状态推送给对应的用户端连接。实现步骤大致是后端引入WebSocket依赖编写一个WebSocketServer的单例管理所有在线连接当用户登录成功后前端通过握手请求把token传上来服务端解析出userId后把Session映射到userId的Map中订单状态变更时根据订单里的userId找到对应连接并推送JSON消息比如推送订单号、最新状态、预计送达时间。如果不想引入WebSocket也可以前端定时轮询订单接口但这样实时性差且压力大。WebSocket的难度主要在于连接管理和心跳保活。我在本地测试时发现浏览器和服务器之间存在网络代理时会自动断开连接所以写了Ping/Pong心跳机制每30秒发一次心跳包维持连接。这个机制如果你不做线上环境很容易出现用户那边已经断开了服务端还不知道的情况。3.4 管理端数据统计与报表管理端有一个比较亮眼的功能是数据统计按时间区间展示营业额、订单量、平均客单价、新增用户数同时画出订单趋势曲线。这里不需要额外引入复杂的BI工具用SQL聚合即可。我以营业额为例说明思路筛选条件包括订单状态已完成的订单才计算营业额不能把待付款和已取消算进去然后根据下单时间或支付时间所在日期进行分组按天求出订单金额总额。注意MySQL的DATE_FORMAT函数可以把datetime转成yyyy-MM-dd的字符串再配合GROUP BY就能得到每日的营业额列表。类似的订单趋势统计是按天统计订单数新增用户数是按用户表create_time日期分组访问趋势可能涉及UV/PV统计这时候Redis的HyperLogLog就派上用场了PFADD和PFCOUNT可以非常省内存地去重计数。当然如果只是为了演示效果用SQL直接做也行但你能说出HyperLogLog的原理面试官对你的工程认知会明显加分。4. 部署与环境搭建实录4.1 本地开发环境准备我先说本地跑起来的必要环境配置。JDK建议用Java 8或11Maven用3.6以上MySQL用5.7或8.0都可Redis用5.0以上。如果你的电脑还没装这些我建议用Docker一键跑MySQL和Redis可以省去很多环境变量的烦恼。比如执行docker run -p 3306:3306 --name mysql -e MYSQL_ROOT_PASSWORD123456 -d mysql:5.7Windows上记得检查端口占用本地开发时3306端口很容易被另一个残留MySQL进程占着连接不上时先netstat查一下。项目配置文件中需要修改的核心项是数据源和Redis地址。Spring Boot里我倾向于用application.yml统一管理。需要注意的一点是密码和敏感信息不要写死在git里至少应该通过环境变量引用spring.redis.host${REDIS_HOST:localhost}这样既方便本地跑也方便上线时用环境变量注入。4.2 前后端联调中的协议约定这个项目涉及到小程序端和管理端前后端联调是重头戏。联调之前必须先统一数据格式。我用的统一响应结构是{ code: 1, msg: success, data: {} }code为1表示成功0表示失败。有人喜欢用200/500这种HTTP状态码来代表业务成功与否但我不太建议因为HTTP状态码应该表达传输层状态而业务层状态需要code字段来细分。前端拿到res.code后判断是否等于1再决定拉取data还是弹出错误信息。另外一个非常容易踩雷的是日期格式。后端返回的LocalDateTime默认是一长串带T的格式小程序端直接展示很难看。我在全局配置里加了Jackson的日期格式化统一为“yyyy-MM-dd HH:mm:ss”。还有金额字段返回时要小心精度丢失不能转成double再传给前端而是要保留字符串或整数形式比如以元为单位保留两位小数或者前端用整数分单位解析避免浮点数精度问题。4.3 打包部署的常见问题本地运行成功后要把后端打成jar包部署到Linux服务器。执行mvn clean package后在target目录下生成xxx.jar。部署时我用的是systemd服务或者直接nohup java -jar xxx.jar logs/log.log 21 。最常见的三个问题第一打包时test报错导致打包失败可以在pom中跳过测试spring-boot-maven-plugin配置maven.test.skiptrue或者命令行加-Dmaven.test.skiptrue。第二端口没开服务器安全组或防火墙挡住了8080端口导致前端请求连接超时检查firewalld或者iptables规则。第三静态资源路径问题。管理端打包后的dist文件一般放在Nginx里Nginx配置api反向代理到后端的8080端口前端和后端之间不会跨域但如果你前端直接用devServer代理本地跑时也要确认proxy表配置正确。5. 问题排查与避坑指南5.1 常见异常速查表我在做这个项目时整理过一张异常排错表现在直接分享出来异常表现常见原因解决方案登录接口返回401JWT过期或签名不匹配检查token是否带Bearer前缀再确认拦截器排除路径是否配错MySQL连接报CommunicationsException数据库地址/端口/账号错误或连接池耗尽检查yml配置注意时区参数重启时确认服务已启动Redis连接失败Redis未启动或密码错误本地执行ping检查生产环境用redis-cli -a 密码 ping验证中文乱码数据库连接字符集未配置UTF-8URL加useUnicodetruecharacterEncodingutf-8金额对不上使用了float/double全面改为BigDecimal并配置保留两位小数前端报跨域后端未允许跨域或代理配置错误开发环境用async的proxy线上用Nginx同源解决定时任务没执行未启用EnableScheduling或cron表达式错误检查启动类注解和Cron表达式注意6位还是7位最隐蔽的问题是数据库连接池连接泄露。如果有长连接查询特别慢或者偶发无法获取连接多半是事务没提交或者连接未归还。我会在yml里配置连接池监控参数例如maximum-pool-size20minimum-idle5并开启日志排查的时候能看清谁拿了连接不还。5.2 我踩过的几个坑第一个坑是事务失效。在同一个类里调用带Transactional的public方法时事务不会生效因为Spring的代理对象调用的是this方法而不是代理方法。解决办法是把需要事务的逻辑拆到另一个Service类中或者通过注入自身的代理对象调用。为了排查这种问题我会在配置里加上事务日志输出到控制台观察异常后是否有rollback记录。第二个坑是JSON序列化循环引用。菜品类里包含分类对象分类对象又包含菜品列表结果返回JSON时直接栈溢出。后来把关联属性用JsonIgnore或设置DTO只返回需要的字段。经验就是实体类不要直接作为API响应体一定要转VO/DTO这既安全又灵活。第三个坑是MyBatis的动态SQL字段里出现了下划线映射问题。表字段是create_time实体属性是createTime如果没开启mapUnderscoreToCamelCase查询字段时为null。在配置文件里加上mybatis.configuration.map-underscore-to-camel-casetrue即可这个点能帮你省半小时。5.3 性能优化与后续扩展方向项目基本跑通后我又做了几个方向上的优化让整个项目的完成度更高。缓存优化方面店铺营业状态、分类列表这些读多写少的数据可以在每次查询后写入Redis并设置合理的过期时间例如5到10分钟。新增或修改时主动删除对应缓存保证缓存与数据库最终一致。页面端会出现数据短暂延迟但通常可以接受。接口优化方面菜品分页查询时如果数据量大可以考虑用PageHelper分页或者在SQL里做limit分页。管理端订单列表查询使用了比较多的时间范围筛选我建立了复合索引避免临时文件排序。扩展方向上可以给项目加入MQ来处理订单超时消息比如用延迟队列实现下单后15分钟未支付自动取消。还可以引入Elasticsearch做菜品搜索提高分类和菜品的查询速度。现在很多微服务架构把苍穹项目拆分成多个模块比如认证服务、订单服务、商品服务这其实也是很好的学习路径但先把单体项目吃透更重要。一点私人总结做完这个项目之后我最大的收获是建立了对完整业务系统的认知闭环。以前写CRUD只是照着接口文档填方法体而苍穹外卖让我真正思考了订单在什么时机生成、购物车数据放在哪里、什么时候要加锁、什么时候要用快照。从数据库设计到鉴权拦截再到WebSocket推送链路完整每个环节都不是孤立的。如果你正在动手做这个项目我建议你不要只满足于“能跑起来”。把每一个状态变更的日志打出来把每一次调用链路梳理清楚甚至可以自己画一张时序图。只要你把订单主流程的所有细节都吃透了下次再去接触分布式事务、消息队列、微服务拆分这些主题会发现基础已经打好了。最后再分享一个小经验这个项目做完记得用真实体验的心态去测试每一个按钮你自己就是最好的测试人员。