资讯动态

苍穹外卖深度复盘:Java后端核心技术与设计逻辑全解析

发布时间:2026/10/13 11:52:55 来源:尧图企业网站定制
做Java后端的朋友对苍穹外卖应该都不会陌生。它算是我见过的教学实战项目里覆盖范围比较全的一个用户端小程序、商家管理后台、平台运营端三个角色串起来整个业务链条足够长几乎把后端日常开发里会碰到的技术点都串了一遍。这一期复盘我会带着大家从项目整体开始把登录鉴权、缓存设计、下单支付、消息推送这些核心知识点重新过一遍重点讲每个地方为什么要这么设计。适合刚开始做这个项目、项目准备接近尾声或者面试前需要梳理知识点的朋友。如果你只是想找一篇“照着敲一遍”的教程这篇可能不太合适但如果你想理解代码背后的设计逻辑这篇应该能帮你把零散的知识串成体系。1. 项目整体认知它到底在模拟什么1.1 三个端一条完整业务线先把定位搞清楚。苍穹外卖不是单纯做一个C端用户点餐的demo它是把“用户、商家、平台”三方角色都拉进来的一套餐饮外卖模拟系统。用户端就是小程序里的点餐页面浏览分类、加购、下单、支付商家端负责菜品管理、接单、送餐状态维护平台端管分类、门店、套餐的全局数据。三个角色共享同一套服务端接口不同的权限看到不同的数据范围。正因为业务线完整它才能承载足够多的技术点。只做简单的增删改查根本没必要上Redis、WebSocket这些东西。很多学习者犯的错误是拿到项目就急着敲代码不看整体业务结果做到订单模块就懵了因为用户表、菜品表、购物车表、订单表这些数据之间环环相扣。复盘之前先把业务主线刻在脑子里登录→浏览菜品→加入购物车→下单→支付→商家接单→派送→完成。后面所有模块都是这条主线上长出来的分支。1.2 技术栈选型背后的真实逻辑主流版本的技术栈大概是Spring Boot做服务端底座MyBatis-Plus操作MySQLRedis做缓存和热点数据存储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 键解决的高频问题商家端有个开关叫店铺营业状态用户端点餐前也要判断门店是否营业。这个状态读写频率高、数据量小放数据库就太重了。直接存Rediskey固定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、POSTOPTIONS预检请求被拦下来前端就报跨域错误。有同学排查半天最后发现是拦截器把OPTIONS请求给拦了放行OPTIONS之后一切正常。这个知识点在真实项目里几乎必遇列表查询、支付回调都容易触发复盘时值得记牢。4.4 定时关单一个可以继续深挖的设计用户下单后一直不支付订单会一直占着资源。真实外卖平台会设置支付超时时间超时自动取消。教学版本未必完整实现但复盘时值得把这个点当成扩展练习。方案一Spring Task定时扫描待支付订单创建时间超出N分钟就改成已取消实现简单但扫描有延迟频繁扫库有压力。方案二RabbitMQ延迟队列下单后发延迟消息到时间消费检查订单状态再关单延时更精准但引入消息中间件成本高。方案三Redis过期键监听但Redis过期事件默认不精确集群模式下还可能丢事件工业级使用要谨慎评估。面试被问“如果让你设计超时关单”不需要答出完美方案把三个方案的取舍讲清楚就比背一种方案强得多。这也提醒我们项目复盘不要只看已有代码还要看哪些地方存在可优化空间这种主动思考才是面试官最看重的。5. 面试官真正会深挖的几个问题5.1 缓存一致性这个老问题怎么答才有深度回答思路分三层缓存适合读多写少的数据写数据时删除缓存而不是更新缓存更简单可靠极端情况下允许短暂不一致靠过期时间兜底。如果面试官继续追问就补充可以引入binlog异步刷新缓存、分布式锁保证并发一致性但这会带来系统复杂度小项目没必要一上来就上重方案。要学会判断什么时候需要强一致什么时候最终一致就够。5.2 JWT和Session别只说一个“JWT更好”正确答法是场景化对比。JWT适合分布式、前后端分离、无状态扩展场景SessionRedis适合需要主动控制会话、强制下线、防止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 byMyBatis-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在订单推送里的细节配置、定时关单三种方案的手把手实现这些都能拆成新主题慢慢展开。有同学在项目里踩了新坑欢迎随时来吐槽我踩过的坑不少说不定能给你指一条近路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑