最近一段时间,我把苍穹外卖这个项目从头到尾跟了一遍。说实话,刚开始我并没有太当回事,觉得它不过是一个比学生管理系统复杂一点的 CRUD Demo,但真正跟到订单、支付回调、WebSocket 来单提醒那一块时,我才意识到这个项目最值钱的地方不是功能多,而是它把一个真实业务系统该有的“完整链路”呈现出来了:用户端、商户端、管理后台同时在线,订单在不同角色之间流转,每一步都要有状态、有记录、有异常兜底。如果只是图个“跑通”,会很浪费,必须带着“为什么这样设计”的疑问去翻代码、改代码,才能把它变成真正属于自己的项目经验。
如果你也准备刷这个项目,或者刷完之后不知道怎么复盘,这篇内容应该能帮你少走不少弯路。我会从业务主线、核心中间件、工程化部署、踩坑排障、面试表达五个维度聊一聊,最后附上我自己跟完一遍之后的一些扩展想法。整篇内容不追求把每个接口都列出来,而是把你最容易忽视、但又最能提升技术认知的点拆开讲。
1. 项目整体认知:先想清楚它到底在练什么
1.1 以订单为主线看业务闭环
外卖业务天然适合做实战项目,因为它的业务链路足够长,而且每个环节都有明确的操作者。用户端负责下单、支付、催单;商户端负责接单、制作、出餐;管理后台负责分类、菜品、套餐的上下架和运营统计。三个角色看着各干各的,其实都被订单串在同一条线上。
我建议你第一步不是去看代码怎么分层,而是把订单状态画出来:待支付、已支付待接单、已接单制作中、派送中、已完成、已取消、退款中。状态很多,但本质上就是一个状态机。以后不管写什么业务,比如审批流、售后流程、库存流转,都是同样的思路。状态流转只能由服务端统一驱动,不能在各个 Controller 里随手写update order set status = ...,否则业务会乱成一锅粥。之前项目里就有人把所有状态都交给前端选择,后来出现了“未支付订单被直接改成已完成”这种严重问题。
这个项目让我第一次把“状态机”这个抽象概念落到具体业务上。你不需要用复杂的状态机框架,但必须做到:每个状态变更都有触发条件,每种非法操作都会被拦截。同样的思想,放在任何有流程的业务系统里都是面试能聊下去的亮点。
1.2 这个项目适合哪类学习者
我的结论是:适合已经能独立写 Spring Boot 增删改查接口的人,但不适合刚学完 Java 语法就来硬啃。因为项目里涉及 JWT 登录、Redis 验证码、WebSocket 推送、AOP 公共字段填充、定时任务、Linux 部署,如果连@RestController和@Service的职责都分不清,很容易变成照着抄代码,抄完仍然什么都不剩。
建议准备这些基础:Maven 依赖管理、MyBatis 或 MyBatis-Plus 基本用法、简单的 SQL 和表连接、Redis 五种基本数据类型,再懂一点 Nginx 反向代理就够用。你不一定要背出所有原理,但至少要知道“这个组件解决什么问题”和“我为什么要引入它”。这样刷下来才是在“做项目”,而不是在“看视频”。
1.3 跟完一遍后最值得养成的三个习惯
第一,先看数据库表再写代码。很多功能想不明白,往往是因为没有理解字段之间的关系。订单和订单明细为什么拆两张表?购物车为什么要有口味字段?带着表结构去读代码,效率会翻倍。
第二,接口统一返回结构。项目里的 Response 包装类是精髓。不管是成功还是异常,都给前端一个固定格式,前端只处理一种数据形态。以后你进入任何团队,这种约定都是基础中的基础。
第三,每完成一个功能,用真实场景串一遍流程。比如从“用户选择分类 -> 看菜品列表 -> 加购物车 -> 提交订单 -> 支付回调 -> 商户端收到新订单”,一口气跑一遍。看起来只是测试,实际上能倒逼你把上下文连接起来,很多隐藏 bug 都是在这样串流程的时候暴露的。
我一直觉得,实战项目的收获不是“我调通了多少个接口”,而是“我有没有建立一套从设计到排障的做事方法”。苍穹外卖这种项目最合适的一点,是它的复杂度刚好超出你的舒适区一点点。
2. 核心技术模块拆解:不只是会用,还要知道为什么这么用
2.1 验证码登录:Redis 负责“状态”,JWT 负责“身份”
这个项目第一处精彩的设计在登录环节。用户拿手机号换验证码,并不是直接查数据库验证,而是先生成随机验证码存到 Redis,设置一个过期时间,一般是 5 分钟。用户提交验证码后,服务端从 Redis 取出来比对。比对成功的用户,系统签发一个 JWT 字符串,后续每次请求都带着它。
这个过程中,Redis 相当于是“临时寄存处”,保存的是验证码和有效期;JWT 相当于“通行证”,保存的是用户身份和过期时间。Redis 的好处在于可以主动控制失效时间,JWT 的好处在于服务端不需要维护 Session,符合前后端分离的场景。
这里有一个非常容易被忽略的坑,也是我当时反复排查很久才发现的:使用 ThreadLocal 保存登录用户信息时,请求结束一定要移除。前后端分离项目往往配合线程池使用,如果线程被复用,上一次请求的用户信息可能被下一次请求读到,出现用户 A 的订单展示给用户 B 的诡异问题。你可以在拦截器的afterCompletion方法里调用ThreadLocal.remove(),养成这个习惯后,这类问题基本不会再遇到。
还有一点:JWT 本身并不提供“主动失效”能力,如果用户被禁用,但手里的 JWT 还没过期,服务端无法立刻拦截。所以生产环境通常会有用户状态缓存或者 Token 黑名单机制。这个项目里可以不去实现,但如果面试被追问“JWT 有什么缺点”,这一类思考就是加分点。
2.2 公共字段自动填充:少写一半重复代码
订单、菜品、分类、购物车几乎所有表都有创建时间、更新时间、创建人、更新人。如果每写一个新增方法都手动set一次,既繁琐又容易漏。苍穹外卖项目里用 MyBatis 的 MetaObjectHandler 做公共字段自动填充,只要实体类字段上标注对应注解,插入和更新的时候自动赋值。
你要关注的不是那几行配置代码,而是“统一写入”的思维。在很多团队里,公共字段和时间戳都要求由框架层解决,不允许业务代码自己写,原因很简单:防止人为遗忘,也方便日后做数据库审计和全链路追踪。我在这个项目里最大的体会是,能用约定解决的问题,就不要给程序员留自由发挥的空间。
写的时候有两点经验。第一,填充创建人时,要从当前登录上下文中取用户 ID,而不是写死模拟数据。很多初学者为省事直接填 1,等做真实鉴权时会发现所有数据都变成“管理员创建”,排查起来非常痛苦。第二,更新操作里如果被更新的字段为空,不要盲目覆盖,可以判断原值再填充,避免把已有数据置空。
2.3 Redis 缓存:项目里的缓存不是摆设
很多人学 Redis 只知道它能做缓存,但不清楚到底该缓存什么。这个项目里给出了很典型的答案:手机验证码、用户登录状态、菜品分类列表、购物车数据。这些数据有一个共同特点:读多写少,或者时效要求不高。比如首页展示的分类列表,用户刷一次查一次数据库完全没意义,缓存到 Redis 后响应速度会明显提升。
缓存设计里最容易踩的是 key 的规范。比较合理的格式是业务前缀:功能:标识,比如dish:list:categoryId:1。一看到 key 就知道它属于哪个业务模块。后面排查数据时,用redis-cli按前缀批量查询也方便。
还有缓存一致性。项目里更新菜品后,不是去更新缓存,而是直接删除缓存。核心逻辑是“读的时候再回填,写的时候只删不更”。因为直接修改缓存很容易和数据库产生不一致,而删除缓存最多造成下一次查询多查一次数据库,相比不一致来说代价小得多。
小技巧:给所有缓存设一个过期时间,即使某次删除缓存失败,最终也会因为过期而被淘汰,不会永远脏下去。这个“过期时间兜底”的思路,值得记一辈子。
2.4 WebSocket:把“看不到的更新”推到屏幕前
外卖项目里有个很接地气的功能:用户下单后,商户端要实时收到来单提醒。如果只用 HTTP 轮询,商户端得非常频繁地请求接口,既浪费流量又有延迟。项目里采用 WebSocket,维持一条长连接,服务端主动把“新订单来了”的消息推给商户端。
我刚开始写 WebSocket 时犯过一个低级错误:把所有连接都放在一个静态 Map 里,还以为这就完事了。后来了解到,WebSocket 连接是跟具体客户端绑定的,如果你从某个连接收到了 A 商户消息,却想发给 B 商户的频道,就必须先通过某种方式找到 B 商户对应的 Session,而不是在一个 Map 里随便发。
更往深走一步:传统项目部署在一台服务器时,用一个本地 Map 存连接没问题,但如果将来部署多台机器,A 商户连接在机器 1,订单消息却可能被机器 2 消费,这时候本地 Map 就失效了。生产环境会用 Redis 发布订阅或者消息队列做 WebSocket 集群消息广播。学习项目用本地 Map 可以理解,但面试时如果能主动说出这个扩展点,会显得你对分布式场景有意识。
3. 核心业务落地:从建表到订单完成的完整流程
3.1 表结构设计:为什么订单要拆成主表和明细表
刚接触订单模块时,很容易想到“一个订单放一个字段,把菜品名和价格都拼成字符串”。这样看起来简单,但后面对账、统计销量、修改菜品名称时都会崩溃。苍穹外卖把订单主表和订单明细表拆开:主表存订单号、用户 ID、总金额、状态、收货地址等核心信息;明细表存每个菜品 ID、菜品名称、数量、单价、小计。
这背后的逻辑是“一主多明细”在关系型数据库里的经典设计。订单主表对应一次交易,订单明细表对应交易中的每一行商品。以后写商城、售后、报销单,都会复用这个模型。金额字段切记不要用double,会有精度问题。项目里统一用decimal,前端显示和算钱都会更安全。
还有一层经验:订单号不建议直接用数据库自增主键,因为会暴露日单量,也容易被遍历。常见做法是生成一个带有时间戳和随机数的业务订单号,比如日期加随机序列。订单号是支付、对账、客服定位问题的抓手,一定要唯一且不可猜测。
3.2 购物车的幂等:别让“多按一次”变成“多买一份”
购物车的实现看起来简单,其实是幂等问题的第一课。用户连续点击“加入购物车”,服务端如果每次都新增一条记录,购物车会重复出现多个同样的菜品。正确逻辑应该是:先根据用户 ID、菜品 ID 和口味查询购物车表中是否已存在相同记录;如果已存在,就把数量加一,否则才新增一条。
这个操作本身就是“查了再改”,存在并发间隙。如果两个请求同时查到不存在,都会新增,结果还是重复。所以更稳妥的做法是在数据库层面为user_id + dish_id + dish_flavor建唯一索引,插入时使用ON DUPLICATE KEY UPDATE或者先查后插加事务锁。学习项目里通常靠 Java 代码判重就够用,但理解深层问题对以后处理秒杀、抢购非常重要。
另外,购物车里菜品价格不应该从购物车记录里读取,而应该在下单时再从菜品表重新读取一遍。为什么?因为商家可能修改过价格,如果用购物车里的旧价格,金额会出错。记住一条原则:一切涉及金额的数据,在关键节点都要回到源头重新确认。
3.3 订单状态机与状态一致性
订单模块是整个项目业务复杂度最高的地方。状态包括待支付、待接单、已接单、派送中、已完成、已取消。每个状态转换都有一套规则,比如“待支付才能取消”“已接单才能派送”“派送中才能完成”。如果没有状态校验,用户取消一个“已完成”的订单,系统就会进入不可控状态。
我比较推荐的做法是定义状态常量或者枚举,再用一个统一的状态更新方法,里面传入当前状态和目标状态,更新 SQL 时带上where status = 当前状态,这样即使两条请求同时进来,也只有一个能更新成功。这本质上是乐观锁的思想。
支付回调那里是重点。第三方支付成功通知不一定是按顺序到达的,甚至可能重复通知。处理回调时千万不能无条件把订单改成“已完成”,要先查订单状态,只有待支付订单才允许变成待接单,同时用一个事务把“修改订单状态”“通知商户端接单”“记录支付流水”三件事一起做掉。项目里我在这个位置反复改了好几版,最后理解了什么叫“接口幂等,核心是状态变更的原子性”。
3.4 下单事务:一次操作,多个表,必须同生共死
下单流程表面上只是“插入订单表”,实际上要同时做四件事:生成订单主表数据、生成订单明细表数据、清空购物车、扣减菜品库存。这四件事只要有一件失败,其他都应该回滚。项目里通过@Transactional把它们包在同一个事务里。
真正写代码时你会发现,事务范围不是越大越好。如果事务里包含远程调用,比如调用支付接口或者推送 WebSocket,一定不要把网络等待放在事务内部,否则数据库连接会被长时间占用,并发高时连接池会耗尽。学习项目可以简单处理,但你要有这个意识:事务只管理本地的数据变更,外部通信放在事务提交后再执行。
这个理解让我后来排查慢接口时有了一个明确方向。如果你发现某个下单接口偶尔很慢,第一步就是看事务里面是不是塞了太多无关操作。把“事务边界”这个概念彻底搞懂,很多并发问题都能迎刃而解。
4. 从开发到部署:环境、配置与运维
4.1 多环境配置:开发、测试、生产必须分离
项目一开始只有本地配置,数据库、Redis 都是 localhost,后来部署到服务器时才发现,每次打包都要改一堆地址,非常麻烦。苍穹外卖项目里采用多分支环境配置:application-dev.yml、application-prod.yml,启动时通过spring.profiles.active=prod指定使用哪套配置。
这看起来只是文件拆分,背后传递的是工程化意识。开发环境日志打印在控制台,生产环境输出到文件并做切割;开发环境数据库密码随意,生产环境使用更复杂的凭据。如果所有配置揉在一个文件里,很容易出现“本地能跑,服务器上不行”的乌龙。我现在做项目时,第一步就会先建多环境配置,哪怕是个人项目,也会成为肌肉记忆。
一个小经验:不要把生产环境的数据库密码提交到公共代码仓库。哪怕只是学习项目,也要养成使用环境变量或者配置中心读取敏感信息的习惯。这是安全底线,也是职业素养。
4.2 服务器部署:顺序、内存和日志
把项目跑到 Linux 服务器上,才算把“开发”和“生产”连接起来。我的部署顺序是:先安装 JDK,再启动 MySQL 和 Redis,然后构建后端 jar 包,最后用 Nginx 托管前端静态资源和反向代理接口。
启动 jar 包时,我最开始直接java -jar xxx.jar,后来发现服务器内存吃紧,就改成了:
java -Xms256m -Xmx512m -jar xxx.jar --spring.profiles.active=prod-Xms和-Xmx分别指定初始内存和最大内存。限制内存不是说越少越好,而是防止默认参数把服务器内存占满。项目本身不大,没必要让 JVM 无限制扩张。
日志方面,生产环境一定要区分业务日志和错误日志。出问题时先看错误日志已经是常识,但如果只有控制台输出,服务器重启后记录就没了。用 Logback 或 Log4j2 配置rolling日志文件,按天切割,保留最近 30 天,这样排查问题时有据可查。
4.3 部署中容易踩的时区和时间坑
第一次部署后,我发现数据库里创建时间比本地时间快了 8 小时。原因有三层:服务器时区、JVM 默认时区、数据库连接串没指定时区。解决办法是在启动参数里加-Duser.timezone=Asia/Shanghai,同时 MySQL 连接 URL 加上serverTimezone=Asia/Shanghai,并且把数据库会话时区设置为东八区。
这类问题很隐蔽,因为页面看到的往往是“变成乱码的日期”,第一反应会怀疑转换代码,很少会想到是底层连接串的问题。以后遇到时间不准,先SELECT NOW()看数据库时间,再date看系统时间,基本上能定位。
4.4 定时任务:超时订单自动取消
外卖项目里有“超时未支付自动取消”的需求。简单实现是用 Spring Task 的@Scheduled(cron = "0 0/1 * * * ?")每分钟扫描一次待支付订单,如果创建时间超过 15 分钟就改为取消状态。
这个功能很容易被看轻,但实际上有两个坑。第一,扫描条件一定要带上索引字段,比如where status = 待支付 and create_time < ?,不然等到订单量大了,这条 SQL 会拖垮数据库。第二,如果将来服务部署了多个实例,定时任务会重复执行,可能对同一笔订单做两次取消失败。学习项目可以不加分布式锁,但面试时主动提一下“生产环境应该引入分布式锁或 XXL-Job 这类分布式调度平台”,会显得你想过扩展性。
5. 踩坑记录与排查思路:很多问题不是你代码写得不对
5.1 高频问题速查表
我在跟项目过程中整理了一份高频问题表,基本覆盖了新手最容易卡住的几个点:
| 问题现象 | 根本原因 | 排查思路与解决办法 |
|---|---|---|
| 接口返回中文乱码 | 数据库连接串或文件编码不对 | MySQL URL 加characterEncoding=utf8,保证源码文件 UTF-8 |
| 页面数据时间晚 8 小时 | 时区配置不一致 | 检查服务器、Spring、MySQL 三层时区 |
Redis 中 key 显示\xac\xed前缀 | 默认 JDK 序列化导致 | 配置 Jackson 序列化器,用 JSON 格式存储 |
| 登录后每次请求还是“未登录” | 前端没带 Token 或拦截器放行路径不对 | 检查请求头 Authorization,拦截器白名单是否错误 |
| 商户端收不到新订单提醒 | WebSocket 连接没建立成功 | 查看握手是否成功,前端地址是否加了项目上下文路径 |
| 更新菜品后前端仍显示旧数据 | Redis 缓存没有删除 | 检查更新方法是否删除了对应 key,缓存过期时间是否太长 |
| 数据库偶尔“连接池耗尽” | 事务范围过大或连接没释放 | 用SHOW PROCESSLIST查看连接,缩减事务或优化连接池配置 |
表格里的内容看一遍很容易,但真正排查的时候,最难的是冷静下来按“连接 -> 依赖 -> 缓存 -> 代码”的顺序排查。很多时候问题不在最后一层,而在最容易被忽略的环境配置上。
5.2 缓存一致性问题详解
这个项目里,菜品和分类的缓存设计能让你把“缓存一致性”这个面试高频题聊明白。我第一次实现时,走了“先更新缓存,再更新数据库”的弯路,数据经常出现旧值覆盖新值的现象。后来改成“先更新数据库,再删除缓存”,问题消失了大半。
为什么不是“先删缓存,再更新数据库”?因为删了缓存之后、更新数据库之前,如果来了一个查询请求,会把旧数据重新加载进缓存,依然出现脏数据。而“先更新数据库,再删缓存”的间隙里,也可能出现读到旧数据,但这段时间极短,并且有缓存过期兜底。对于外卖这种一致性要求没那么极端的场景,这是更简单的方案。
当然,这只是一个权衡,而不是银弹。生产环境中还会有删缓存失败、缓存击穿、缓存穿透等问题。学习项目里你至少应该说出:缓存为什么不是万能方案,以及为什么需要“过期时间兜底”。这些思考比背一个理论强得多。
5.3 重复提交与恶意操作
外卖项目里有一个典型场景:用户手速很快,连续点了两次“提交订单”,如果不做防护,会出现两个相同订单。前端可以设置按钮 loading 状态,但真正可靠的是后端防重。更常见的做法是在订单表里加唯一业务单号,提交订单前先生成订单号,重复请求如果携带同一个订单号,数据库唯一索引会直接拦截。
支付重复回调也类似。处理第三方回调时,要按订单号查状态,只有状态是待支付时才更新,否则直接忽略。这样即使回调发三次,也只有第一次生效。我现在做任何需要“只成功一次”的操作时,都会条件反射地去想:这个动作的幂等键是什么?整个苍穹外卖项目最核心的一句话,其实就是“把所有状态变更都想成带条件的更新,数据库唯一约束和乐观锁是你的最后一道防线”。
6. 复盘与扩展:项目结束之后还能带走什么
6.1 哪些代码值得反复看,哪些只是搬砖
如果只看业务代码,里面很多 Service 方法确实很像“搬砖”,比如按分类查询、按 ID 修改、分页查询。这类代码不需要死记硬背,熟练使用 MyBatis 之后,它们会被抽象成通用模板。真正值得反复咀嚼的是:统一异常处理、公共字段自动填充、JWT 拦截器、WebSocket 工具类、状态机校验方式。
我刷第二次的时候,会把通用部分抽出来,想象如果自己从零搭一个新项目,需要保留哪些骨架。这个项目不是让你记住某张表叫什么,而是让你积累一套“脚手架思维”:接口如何统一返回,异常如何处理,权限如何拦截,数据如何自动填充。换一个业务场景,这套骨架照样能用。
6.2 如果重构一遍,我会从哪些地方改
第一,把订单号生成、支付流水号生成抽成独立服务。统一用雪花算法或者 Redis 自增生成,避免在多个 Service 里各自 new 订单号,保证全局唯一且可排序。
第二,引入消息队列,比如 RabbitMQ。下单成功后发送消息,由异步消费者去扣库存、推送商户端通知。这样接口响应更快,也方便做流量削峰。当然,引入 MQ 会带来消息丢失和重复消费的复杂度,我需要配套做好失败重试和幂等处理。
第三,把业务校验的部分用策略模式重写。比如订单状态可以定义一组处理器,每种状态下对应一套 allowedActions,避免一个大方法里全是if/else if。这些设计不是为炫技,而是为了让代码在需求频繁变化时更容易维护。面试时能说出“我会如何重构”,其实比描述项目功能更让面试官提神。
6.3 面试时如何讲这个项目
很多人在面试里只会说“我做了外卖项目,用了 Spring Boot、MyBatis、Redis”,这种表达几乎没有任何区分度。真正有效的讲法是抓一个具体问题,讲清背景、方案、结果。比如我当时面试时讲过一个真实故事:商户端本地缓存导致菜品更新不及时,我通过分析发现是只更新了数据库,没有删除缓存,后来统一在 Service 层封装“更新+删缓存”方法,并在缓存 key 中带上分类 ID,问题解决。
这个表达方式强调的不是功能列表,而是“遇到了问题 -> 怎么定位 -> 怎么解决 -> 后续怎么避免”。苍穹外卖这种项目虽然不算商业系统,但足够支撑起一套完整的技术叙事。不要夸大自己没做过的东西,把项目里每一个“为什么”想明白,比背一百道八股文有用。
跟完这个项目,最明显的变化是我看报错不再慌了。之前出现异常,第一反应是去群里问“这是怎么回事”,现在会先看堆栈、查日志、确认是不是环境配置,再回到代码。这种排障的淡定,不是天生的,是踩坑踩出来的。如果你现在也正在这条路上被各种 bug 折磨,我特别想说:所有卡顿的时刻,都是你真正长进最快的时刻。把苍穹外卖当成一面镜子,照着它好好磨一遍,你得到的不会只是一个可以写进简历的项目,而是一整套分析问题和交付功能的做事方式。