资讯详情

资讯详情

苍穹外卖项目深度复盘:业务链路、缓存一致性与部署实战

最近一段时间我把苍穹外卖这个项目从头到尾跟了一遍。说实话刚开始我并没有太当回事觉得它不过是一个比学生管理系统复杂一点的 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.activeprod指定使用哪套配置。这看起来只是文件拆分背后传递的是工程化意识。开发环境日志打印在控制台生产环境输出到文件并做切割开发环境数据库密码随意生产环境使用更复杂的凭据。如果所有配置揉在一个文件里很容易出现“本地能跑服务器上不行”的乌龙。我现在做项目时第一步就会先建多环境配置哪怕是个人项目也会成为肌肉记忆。一个小经验不要把生产环境的数据库密码提交到公共代码仓库。哪怕只是学习项目也要养成使用环境变量或者配置中心读取敏感信息的习惯。这是安全底线也是职业素养。4.2 服务器部署顺序、内存和日志把项目跑到 Linux 服务器上才算把“开发”和“生产”连接起来。我的部署顺序是先安装 JDK再启动 MySQL 和 Redis然后构建后端 jar 包最后用 Nginx 托管前端静态资源和反向代理接口。启动 jar 包时我最开始直接java -jar xxx.jar后来发现服务器内存吃紧就改成了java -Xms256m -Xmx512m -jar xxx.jar --spring.profiles.activeprod-Xms和-Xmx分别指定初始内存和最大内存。限制内存不是说越少越好而是防止默认参数把服务器内存占满。项目本身不大没必要让 JVM 无限制扩张。日志方面生产环境一定要区分业务日志和错误日志。出问题时先看错误日志已经是常识但如果只有控制台输出服务器重启后记录就没了。用 Logback 或 Log4j2 配置rolling日志文件按天切割保留最近 30 天这样排查问题时有据可查。4.3 部署中容易踩的时区和时间坑第一次部署后我发现数据库里创建时间比本地时间快了 8 小时。原因有三层服务器时区、JVM 默认时区、数据库连接串没指定时区。解决办法是在启动参数里加-Duser.timezoneAsia/Shanghai同时 MySQL 连接 URL 加上serverTimezoneAsia/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 加characterEncodingutf8保证源码文件 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 折磨我特别想说所有卡顿的时刻都是你真正长进最快的时刻。把苍穹外卖当成一面镜子照着它好好磨一遍你得到的不会只是一个可以写进简历的项目而是一整套分析问题和交付功能的做事方式。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →