☰
苍穹外卖深度复盘:Java后端核心技术与设计逻辑全解析
2026/10/13 11:52:49 网站建设 项目流程

做Java后端的朋友,对苍穹外卖应该都不会陌生。它算是我见过的教学实战项目里覆盖范围比较全的一个,用户端小程序、商家管理后台、平台运营端三个角色串起来,整个业务链条足够长,几乎把后端日常开发里会碰到的技术点都串了一遍。这一期复盘,我会带着大家从项目整体开始,把登录鉴权、缓存设计、下单支付、消息推送这些核心知识点重新过一遍,重点讲每个地方为什么要这么设计。适合刚开始做这个项目、项目准备接近尾声,或者面试前需要梳理知识点的朋友。如果你只是想找一篇“照着敲一遍”的教程,这篇可能不太合适;但如果你想理解代码背后的设计逻辑,这篇应该能帮你把零散的知识串成体系。

1. 项目整体认知:它到底在模拟什么

1.1 三个端,一条完整业务线

先把定位搞清楚。苍穹外卖不是单纯做一个C端用户点餐的demo,它是把“用户、商家、平台”三方角色都拉进来的一套餐饮外卖模拟系统。用户端就是小程序里的点餐页面,浏览分类、加购、下单、支付;商家端负责菜品管理、接单、送餐状态维护;平台端管分类、门店、套餐的全局数据。三个角色共享同一套服务端接口,不同的权限看到不同的数据范围。

正因为业务线完整,它才能承载足够多的技术点。只做简单的增删改查,根本没必要上Redis、WebSocket这些东西。很多学习者犯的错误是拿到项目就急着敲代码,不看整体业务,结果做到订单模块就懵了,因为用户表、菜品表、购物车表、订单表这些数据之间环环相扣。复盘之前先把业务主线刻在脑子里:登录→浏览菜品→加入购物车→下单→支付→商家接单→派送→完成。后面所有模块都是这条主线上长出来的分支。

1.2 技术栈选型背后的真实逻辑

主流版本的技术栈大概是:Spring Boot做服务端底座,MyBatis-Plus操作MySQL,Redis做缓存和热点数据存储,Spring Cache封装缓存操作,JWT做登录态,WebSocket做消息推送,对象存储存图片,Swagger/Knife4j出接口文档。有的版本还会引入RabbitMQ做消息解耦和延迟关单。

为什么这套组合被那么多人当成学习项目?因为它基本对应了Java后端就业市场上最常被问到的技术栈清单。Spring Boot是基本功,MySQL是绝大多数中小项目的存储首选,Redis只要有点并发压力的系统都用得上,JWT在前后端分离项目里是标准答案,WebSocket则是低成本实现推送的手段。这套技术不需要多先进,但足够贴近真实工作场景。

选择MyBatis-Plus而不是原生MyBatis,主要是生产力和可读性。写业务时,单表查询用LambdaQueryWrapper一行搞定,不用手写XML,开发效率高很多。有同学担心过度封装会让自己的SQL能力退化,这种担心有道理,但作为项目落地阶段,快速实现是第一诉求。分页这块也提一下:MyBatis-Plus分页插件本质是用拦截器帮我们拼接了limit语句,传Page对象进去就能拿到total和records,底层方言处理已经被封装好了。多表关联、复杂报表这种场景你还是得老老实实写SQL,这也是为什么项目里经常既有Wrapper又有自定义Mapper方法。

1.3 表结构从哪里看起

做这类项目复盘,建议不要一上来就啃代码,先把数据库表设计看明白。用户表、地址簿表、分类表、菜品表、套餐表、购物车表、订单表、订单明细表,每张表的主外键关系一旦看清,整个项目的业务逻辑就通了七成。

订单和订单明细为什么要拆成两张表,新人常常想不明白。一张订单只记录订单编号、总金额、状态这些汇总信息,明细表才记录每个菜品的具体名称、数量、价格。拆开的原因很直接:避免一张表字段冗余;商家端按订单查看菜品明细时查询效率更高;后续做数据分析时可以对明细单独统计。很多设计不是一开始就有的,而是业务发展到一定程度后被迫拆出来的,复盘时要体会这种演进过程。

2. 用户端核心链路:从登录到支付的知识点密集区

2.1 登录鉴权:JWT 取代 Session 的逻辑

C端用户在第三方小程序里拉起授权,服务端拿到授权code后向第三方开放平台换取用户唯一标识。首次登录的用户自动注册一条记录,之后不管是否注册,服务端最终都生成一个JWT字符串返回给前端。前端后续每次请求都在请求头里带上这个Token,服务端通过拦截器校验。

这里值得琢磨的问题是:为什么不用Session?外卖场景是典型的分布式部署,服务端有多台实例时,Session默认存在单台机器内存里,用户请求被负载均衡到另一台机器就找不到登录态了。要么做Session复制,要么用Redis统一存Session,但都比直接用JWT复杂。JWT把用户信息放进Token本身,服务端校验签名后就能信任,天然无状态,适合横向扩展。

JWT的组成要会讲:Header标明签名算法和令牌类型,Payload放业务数据(用户ID、过期时间),Signature是对前两部分做签名。有一个局限性必须意识到,JWT一旦签发,过期之前无法主动作废,所以过期时间不能设太长。真需要强制下线,就要引入黑名单机制,或者改用Redis保存会话控制。这个追问点面试中经常出现,能主动说出来会显得你考虑过工程落地。

2.2 缓存设计:菜品为什么要进 Redis

用户打开首页,系统要加载分类列表以及每个分类下的菜品或套餐,这个动作是所有用户都会执行的热点查询。如果每次都直接访问MySQL,数据库压力会非常大。苍穹外卖的做法是把分类和菜品缓存到Redis,后续请求命中缓存直接返回,数据库压力明显降下来。

数据被修改了怎么办?项目里通常用Spring Cache的@Cacheable和@CacheEvict配合使用。查询方法上加@Cacheable指定缓存名称;增删改分类或菜品时,在对应方法上加@CacheEvict把缓存清除。下一次请求发现没有缓存,就回源数据库重新加载并写入缓存。为什么不直接更新缓存?因为更新缓存需要知道新值,修改菜品时传入的是DTO,可能需要重新查一遍数据库再组装缓存结构,容易出错。清空缓存成本远低于更新缓存,还能避免并发下更新到错的值。这是一个非常关键的工程取舍:删缓存往往比更缓存简单可靠。

缓存穿透、击穿、雪崩这三个概念也得能答清楚。穿透是查一个必然不存在的数据,缓存没有,数据库也没有,请求直接打到数据库;击穿是某个热点数据恰好过期,大量请求一起打到数据库;雪崩是大量key同一时间失效。应对方案分别是空值缓存、互斥锁重建缓存、随机过期时间。项目里不会全部实现,但面试能答上来才有深度。

2.3 购物车与下单:从临时数据到核心事务

购物车数据高度动态,单个用户读写频繁,但总数据量不大。苍穹外卖通常把购物车存在Redis里,key设计成类似user:购物车:用户ID的结构,value是购物车条目列表。为什么不用数据库表?购物车只是用户操作过程中的临时容器,订单一旦生成购物车就要清空,没必要持久化。放在Redis还天然支持过期时间,用户长时间不操作也不会堆积垃圾数据。

从购物车生成订单是项目里事务最集中的环节。大致流程:获取当前用户购物车数据,计算总金额;组装订单主表数据,生成订单号;遍历购物车条目逐条生成订单明细;保存订单主表和明细表;最后清空购物车。这个流程必须保证原子性,方法上要标注事务注解。Spring里事务的本质是AOP,遇到运行时异常就回滚,正常返回才提交。这里有个很常见的坑:如果在事务方法内部自己catch住异常没往外抛,事务感知不到异常,不会回滚。有的同学写订单业务时喜欢在方法最外层包try-catch返回统一提示,结果数据出问题不回溯,这个习惯一定要改。

2.4 支付模拟与消息推送:WebSocket 是重点

真实接入第三方支付需要商户资质、证书、回调验签等流程,教学项目通常用模拟支付替代。点击支付按钮时直接把订单状态从待支付改成已支付,跑通业务闭环。但面试沟通时要把真实逻辑讲清楚:真实支付中,支付成功是由第三方服务器异步回调通知我们的,不能只靠前端轮询支付结果。面试官追问可靠性,就要答回调幂等、定时主动查单这些思路。

支付成功之后,商家端需要第一时间感知新订单。如果让前端每两秒轮询一次“有没有新订单”,实现简单但很浪费资源,延迟也不可控。WebSocket更合适:商家端管理页面与服务端建立长连接,服务端有订单消息时主动推给商家端。HTTP是“你问我答”,WebSocket是“全双工长连接”,这是最核心的区别。项目里来单提示、用户催单基本都走这条通道。

心跳机制是容易忽略的细节。网络环境复杂,连接可能被中间网络设备断开,但两端都不一定立刻知道,所以客户端要定期发心跳,服务端超时没收到就判定连接失效并清理。很多人在学习阶段不会注意,等部署到线上才发现连接丢了一堆。复盘时把这个点记下来,面试提到WebSocket时主动说心跳,加分。

3. 商家端与管理端:业务管理模块里的实战细节

3.1 店铺营业状态:一个 Redis 键解决的高频问题

商家端有个开关叫店铺营业状态,用户端点餐前也要判断门店是否营业。这个状态读写频率高、数据量小,放数据库就太重了。直接存Redis,key固定,value是0或1。点餐时读这个key,商家修改时也写这个key。这个例子特别能体现Redis的典型用法:不是所有数据都适合放缓存,但高频读、低频写、允许小延迟的业务状态非常适合。

往深一层看,这其实是一种配置开关的思想。真实系统里很多功能开关、灰度策略都是类似的:把业务开关放到集中式缓存或配置中心,管理方修改后立即生效,不用重启服务。面试时能把一个营业状态案例讲出这种抽象,会显得你有架构意识。

3.2 图片上传与对象存储

菜品需要图片展示,商家上传图片是基本操作。正规做法是管理端把图片文件传到服务端,服务端转存到对象存储,返回可访问的文件URL保存到数据库。为什么不自建文件服务器?本地磁盘扩展难度大,没有CDN加速,还要处理备份容灾。对象存储按量付费,可以配CDN,支持自定义域名,对中小团队来说是成本最低的方案。

实操里有几个细节值得注意。上传接口要限制文件类型和大小,不能让用户传一个1GB的视频上去;调用存储API时要设置超时时间,避免存储服务抖动把请求线程拖死;有些系统还会做前端直传,减少服务端带宽压力。教学项目未必都实现,但复盘时要知道这些补齐点。还有一个容易被忽视的问题:删除菜品时,对象存储里的图片文件也要清理,否则云存储会产生费用,很多项目只会删数据库记录,留下大量孤儿文件。

3.3 分类、菜品与套餐:约束关系怎么设计

分类模块主要面向管理端,功能是新增、删除、修改、分页查询。删除分类时的业务规则是核心:如果分类下已经存在菜品或套餐,就不能直接删,要抛出友好提示。这个校验逻辑很直白,但体现了业务完整性约束的思想。套餐里关联了菜品,菜品或套餐被修改后,依赖关系必须保持有效。

落到代码上,就是用MyBatis-Plus条件构造器统计分类下的菜品数量,大于0就抛业务异常。你可能觉得太简单,但它引出一个面试点:逻辑删除和物理删除的区别。如果历史订单已经引用了分类名称,物理删除后历史数据展示时拿不到分类名。所以很多系统用逻辑删除字段,查询时统一过滤。复盘时注意看你手里的版本用的是哪种,别把物理删除和逻辑删除混为一谈。

3.4 订单状态流转与分页查询

商家端订单管理涉及接单、拒单、派送、完成等动作,每个动作都对应订单状态从一个值变到另一个值。把订单状态定义成枚举类统一管理是比较好的实践:待支付、已支付、已接单、派送中、已完成、已取消、已拒单。枚举的好处是业务代码里不出现魔法数字,可读性明显提升;状态流转的合法性检查也有集中位置,已完成的订单不能再接单,已取消的订单不能再次支付,这类约束都应该放在枚举或Service层统一判断。

订单列表查询必须分页。订单数据增长快,不能一次查全表。MyBatis-Plus分页插件让分页很简单:new Page(页码, 每页条数),配合分页插件拦截器就能拿到total。但如果查询条件用了动态拼SQL,要注意分页拦截器生成的count语句是否正确,复杂SQL可能导致count结果异常。订单列表还要支持状态筛选、时间范围和模糊搜索,多表联查就需要自定义SQL,这时候SQL编写能力就派上用场了。很多同学用多了MyBatis-Plus反而忘了怎么写多表关联,这是退步,要警惕。

4. 藏在代码里的关键设计:那些让项目不显得糙的细节

4.1 统一异常处理与友好提示

体面的后端项目,Controller层不应该到处try-catch。苍穹外卖里通常有一个全局异常处理器,类上标注@RestControllerAdvice,方法上用@ExceptionHandler。异常被分为几类:业务异常(库存不足、菜品停售),系统异常(空指针、SQL错误),未知异常。业务异常直接返回给前端对应提示;系统异常记录日志并返回“服务异常”的通用提示。这样Controller只负责业务编排,异常处理逻辑收敛到一处。

有个坑要特别注意:全局异常处理器兜住的是Controller层抛出的异常,但拦截器里抛出的异常往往接不住。拦截器在进入Controller之前执行,JWT解析失败时如果直接在preHandle抛了异常,可能不会走进全局异常处理。所以Token过期这类错误,通常要在拦截器内直接写出JSON响应,或者在异常处理器里单独配对应处理。很多新人项目配置了全局异常却依然看到奇怪的报错,排查方向就应该往拦截器、过滤器这层找。

4.2 公共字段填充与 AOP 思想

订单表有创建时间、更新时间、创建人、更新人,菜品表也有。每个INSERT和UPDATE都手动set这些字段,代码会非常啰嗦。MyBatis-Plus提供了字段自动填充机制:在实体字段上标@TableField(fill = FieldFill.INSERT)或FieldFill.UPDATE,再写一个MetaObjectHandler实现类,在insertFill和updateFill里统一给字段赋值。

赋值时当前登录用户ID从哪里拿?前面JWT校验时已经把用户ID放进了ThreadLocal。请求先过拦截器,拦截器把ID放进ThreadLocal,然后进Controller、Service,到MyBatis执行SQL时自动填充,ThreadLocal里正好有值。整个请求结束,拦截器afterCompletion里要把ThreadLocal remove。不remove的后果很严重:服务端线程池会复用线程,上一个请求的用户ID留在ThreadLocal里,下一个请求读同一个线程可能拿到上一个用户的数据,造成串号。这个bug在并发下才会出现,开发环境往往复现不了,但真实生产环境是实实在在的安全隐患。

关键习惯:用了ThreadLocal,务必在finally或afterCompletion中清理。不要依赖框架,自己代码自己负责。

4.3 JWT 拦截器与放行路径的细节

JWT校验用拦截器实现:实现HandlerInterceptor,在preHandle里从请求头拿Token,验签,把用户ID放入ThreadLocal然后放行;没有Token或校验失败返回401。拦截器注册时要注意排除路径,匿名可访问的接口都要放行:登录接口、首页浏览接口、店铺营业状态接口等。

这里有个特别常见的坑:CORS跨域。前后端分离后,前端发非简单请求时会先发一个OPTIONS预检请求。如果拦截器只放行了GET、POST,OPTIONS预检请求被拦下来,前端就报跨域错误。有同学排查半天,最后发现是拦截器把OPTIONS请求给拦了,放行OPTIONS之后一切正常。这个知识点在真实项目里几乎必遇,列表查询、支付回调都容易触发,复盘时值得记牢。

4.4 定时关单:一个可以继续深挖的设计

用户下单后一直不支付,订单会一直占着资源。真实外卖平台会设置支付超时时间,超时自动取消。教学版本未必完整实现,但复盘时值得把这个点当成扩展练习。方案一:Spring Task定时扫描待支付订单,创建时间超出N分钟就改成已取消,实现简单但扫描有延迟,频繁扫库有压力。方案二:RabbitMQ延迟队列,下单后发延迟消息,到时间消费检查订单状态再关单,延时更精准但引入消息中间件成本高。方案三:Redis过期键监听,但Redis过期事件默认不精确,集群模式下还可能丢事件,工业级使用要谨慎评估。

面试被问“如果让你设计超时关单”,不需要答出完美方案,把三个方案的取舍讲清楚就比背一种方案强得多。这也提醒我们,项目复盘不要只看已有代码,还要看哪些地方存在可优化空间,这种主动思考才是面试官最看重的。

5. 面试官真正会深挖的几个问题

5.1 缓存一致性这个老问题,怎么答才有深度

回答思路分三层:缓存适合读多写少的数据;写数据时删除缓存而不是更新缓存,更简单可靠;极端情况下允许短暂不一致,靠过期时间兜底。如果面试官继续追问,就补充:可以引入binlog异步刷新缓存、分布式锁保证并发一致性,但这会带来系统复杂度,小项目没必要一上来就上重方案。要学会判断什么时候需要强一致,什么时候最终一致就够。

5.2 JWT和Session,别只说一个“JWT更好”

正确答法是场景化对比。JWT适合分布式、前后端分离、无状态扩展场景;Session+Redis适合需要主动控制会话、强制下线、防止Token泄漏后无法撤销的场景。没有绝对优劣,只有适不适合。能主动说出JWT的短板,比一味吹捧它更显成熟。顺带可以提一嘴,引入Spring Security这类框架时,Token方案和会话方案都支持,关键看业务诉求。

5.3 项目里的AOP,能说出几个场景才算过关

事务、全局异常、拦截器、公共字段自动填充、日志切面,这些都是AOP思想的体现。面试官真正想听的,是你有没有面向切面的意识,而不是把横切逻辑散落到每个方法里。可以举一个具体的例子:日志审计切面统一记录操作人、操作时间、请求参数,业务代码零侵入。这种思路在苍穹外卖源码里可能没有完整实现,但你能说出来,就说明你理解AOP的价值超过了“会用注解”。

5.4 高并发改造从哪切入

外卖项目的瓶颈大概率在数据库。优先做缓存:菜品缓存、门店状态缓存、订单热点状态缓存。然后考虑读写分离,主库写从库读。再往上才是接口限流、消息队列削峰。下单高峰时支付回调可能瞬间涌入,用异步消息处理订单状态更新,也是一种思路。答这类问题,先定位瓶颈再设计方案,比直接抛出一堆中间件名字要靠谱得多。

6. 实战避坑与复盘手记

6.1 缓存删了但没生效,多半是key没对上

我第一次做的时候,改了菜品数据库里的价格,但用户端小程序还是旧价格。排查发现:管理员修改菜品的方法上有@CacheEvict,但和查询方法用的缓存key不一致,删缓存时删错了key。这个小坑提醒我:使用Spring Cache时,缓存名称和key一定要全局统一,最好抽成常量统一管理。

6.2 分页的count突然不对,先打印SQL

有一次列表接口拼接了动态查询条件,分页插件统计total一直不对。查了半天,发现是SQL里既有子查询又有group by,MyBatis-Plus自动生成的count语句没有正确套结构。后来手动拆了SQL才解决。遇到MyBatis-Plus分页问题,第一反应不是改代码,而是打印执行SQL,看自动生成的count到底查了什么。看SQL永远比猜逻辑快。

6.3 ThreadLocal不清理,用户数据会串门

本地并发压测时发现,模拟两个用户操作,A用户创建的订单创建人变成了B。原因就是登录拦截器里把用户ID放进了ThreadLocal,但请求结束后没有remove,线程被复用导致下个用户读取了错误身份。这个bug开发环境下基本不会出现,没有压测工具根本发现不了。后来统一在拦截器afterCompletion里清理,问题消失。这个经验写下来,希望你别等到线上出事故才重视。

6.4 本地Redis不设密码,部署时差点裸奔

本地开发Redis默认无密码,开发机直接当生产用,6379端口完全裸奔。虽然内网环境风险低,但养成习惯很重要:项目凡是引入Redis,学习阶段就把密码带上,连接配置里加上认证。表面看和学习无关,但这恰恰是真实工程最基础的底线。安全配置永远比业务代码更需要提前想一步。

6.5 跨域报错老是解决不了,看看拦截器

有时候前端报跨域,后端在配置类里加了很多跨域配置却依然无效。后来发现拦截器把OPTIONS预检请求拦截了,预检请求根本没到Controller,跨域配置形同虚设。解决方式就是注册拦截器时放行OPTIONS。这个坑建议收藏,前后端分离项目基本都会遇到一次。

复盘过程中踩过的问题远不止这些,我把最容易反复出现的几个整理成一个速查表,方便你直接对照自查:

现象可能原因排查方向
改了数据库,页面数据没变缓存key不一致或缓存未清除检查@CacheEvict的key与查询key是否一致
分页total不准count语句未适配复杂SQL打印MyBatis-Plus生成SQL,手动修正count
两个用户数据串号ThreadLocal未清理检查拦截器afterCompletion是否有remove
前端跨域一直报错OPTIONS预检被拦截放行OPTIONS请求
删除分类失败分类下仍有菜品先统计子表数量,做业务约束校验
商家端收不到新订单WebSocket连接断开未重连检查心跳机制和自动重连逻辑
部署后Redis连接被拒绝Redis未配置密码或防火墙未开设置认证,配置安全组规则

最后再分享一个复盘心得。做苍穹外卖这类实战项目,最怕的是把“能跑起来”当成了“学会了”。能跑起来只是第一步,真正的学习发生在你拆开每个模块、追问自己“为什么它是这么写”的时候。第一期先讲到这里,如果后面大家需要,我可以把每个模块单独拿出来深聊,比如Redis缓存一致性的完整落地、WebSocket在订单推送里的细节配置、定时关单三种方案的手把手实现,这些都能拆成新主题慢慢展开。有同学在项目里踩了新坑,欢迎随时来吐槽,我踩过的坑不少,说不定能给你指一条近路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询