资讯动态

从单体到微服务:外卖点餐系统全栈开发实战与踩坑记录

发布时间:2026/10/3 3:47:12 来源:尧图企业网站定制
说到外卖点餐配送系统很多人第一反应是“这不就是电商再加个地图嘛”。但真把这个项目落地的过程中你会发现它几乎涵盖了一整套微服务架构会遇到的典型问题服务怎么拆、分布式环境下用户状态怎么管理、订单和库存的一致性怎么保证、验证码和密码找回这种小功能在微服务链路里怎么设计才安全。我这次用 SpringBoot Vue SpringCloud 把这套系统完整做了一遍包括员工登录、忘记密码、点餐、配送调度这些模块过程中踩了不少坑也沉淀了一些可复用的设计思路这篇就按我的实现顺序把这些内容整理出来。如果你正在做类似的外卖、电商、本地生活类系统或者准备用微服务架构做毕业设计、项目实训这篇应该对你有帮助。我尽量不写教科书式的架构图讲解而是直接告诉你哪些地方容易翻车、为什么翻车、我当时是怎么处理的。1. 外卖系统的微服务拆分逻辑不是套壳是业务边界的重新划分1.1 单体架构跑到第几行开始难受我最初图省事把整个外卖系统写成了一个 SpringBoot 单体应用所有模块共用一个数据库。业务刚跑通时其实挺爽的代码提交、本地启动、断点调试都很方便。但越往后越难受主要有几个信号员工管理、用户点餐、商家接单、骑手配送、支付回调全部堆在一个工程里一次改动可能要重启整个服务。订单模块是流量压力最大的但单体架构下没办法单独把订单服务扩容只能整站复制部署资源浪费非常严重。数据表耦合严重订单表直接关联用户表、商家表、骑手表一个表的字段变更会引发连锁改 SQL。多人协作时大家都在同一个代码仓库里改同一个模块冲突不断合代码的时间比写代码还长。当这几个问题开始反复出现时就该动手拆了。我的判断标准很简单能不能在不影响其他业务的前提下单独把一个业务模块部署、扩容、迭代。如果能这个模块就有独立的理由。1.2 我最终落地的服务划分方案拆微服务不能对着网上的电商案例照搬得结合外卖业务的真实流程来划分。我的做法是先梳理业务链路用户点餐 → 下单减库存 → 支付 → 商家接单 → 骑手取餐配送 → 订单完成评价。围绕这条链路我把服务拆成了这样服务名称核心职责关键数据表用户服务C端用户注册登录、地址管理用户表、地址表员工服务B端员工账号管理、角色权限、验证码、忘记密码员工表、角色表、验证码记录表商家服务商家信息、店铺菜单、菜品库存商家表、菜品表、库存表订单服务下单流程、订单状态流转、订单查询订单表、订单明细表配送服务骑手信息、订单配送状态、抢单/派单逻辑骑手表、配送记录表支付服务支付单创建、支付回调处理、退款支付流水表网关服务统一入口、路由转发、登录鉴权无认证授权服务JWT令牌签发校验、Feign内部调用鉴权令牌表、刷新令牌表这个划分思路是按业务领域而非按技术层拆的。比如“文件上传”这种通用能力我没有单独拆一个文件服务而是垂直放在各自业务服务内部处理避免过度拆分导致服务数量失控。另外员工服务和认证授权服务我刻意分开员工服务管业务身份认证服务管令牌签发这样后续如果接入用户端登录也可以复用同一套授权逻辑。1.3 技术选型为什么是 SpringBoot SpringCloud Vue技术栈这一层很多人在 SpringCloud 和 Dubbo 之间纠结过我为什么选 SpringCloud核心原因是它的生态和当前主流技术栈更贴合。SpringCloud 全家桶里我重点用了这几个组件Nacos既做服务注册中心也做配置中心比 Eureka Spring Cloud Config 的组合少维护一个组件控制台还能直接管理配置和权重。Spring Cloud Gateway统一入口做路由转发内置断言和过滤器可以很方便地实现跨域处理、JWT 校验、灰度发布等逻辑。OpenFeign服务间远程调用配合 Nacos 的服务发现代码写起来像调用本地方法一样。Sentinel做限流和熔断。外卖系统有典型的秒杀式峰值流量比如午高峰大量用户同时下单Sentinel 可以在网关层做全局限流。前端选 Vue 2 Element UI 的原因很简单生态成熟、简历和毕设认可度高、组件库齐全。管理后台、骑手端的页面用 Element UI 做后台表格、表单非常高效C 端用户页面则用原生 Vue 配合 CSS 做移动端适配。前端工程的划分我第 3 部分会细说。这一层选型还有一个考量团队里其他人对 Java 技术栈更熟微服务如果上 Go 或者 Node.js协作成本高学习曲线也陡。技术选型不能光看技术热度还得看团队能力和项目周期能不能兜底。2. 员工登录与忘记密码功能的完整链路设计2.1 员工表设计不只是账号密码两个字段标题里专门提到“员工 忘记密码”说明这个模块是项目的难点之一。我最早设计员工表时也走过弯路只放了username和password两个字段结果后面做角色权限、密码找回时又反复改表。最终我落地的表结构是这样的CREATE TABLE employee ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 员工ID, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, phone varchar(20) DEFAULT NULL COMMENT 手机号用于验证码找回密码, email varchar(100) DEFAULT NULL COMMENT 邮箱用于备用验证, status tinyint(4) DEFAULT 1 COMMENT 状态1启用0禁用, department varchar(100) DEFAULT NULL COMMENT 所属部门或门店, role_id bigint(20) DEFAULT NULL COMMENT 角色ID关联角色表, last_login_time datetime DEFAULT NULL COMMENT 最近登录时间, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工表;有几个设计点在开发中很关键手机号和邮箱是找回密码的身份凭证。员工忘记密码时系统不能直接重置密码或明文返回原密码正确做法是通过手机/邮箱验证码校验身份再允许设置新密码。密码字段用 BCrypt 加密不用 MD5。这一点容易被忽略后面单独说。状态字段和角色字段分开。禁用员工账号和分配角色权限是两个维度的操作混在一个字段里后面会很痛苦。2.2 忘记密码流程拆解从“发送验证码”到“重置成功”忘记密码这个功能乍一看简单但在微服务架构里涉及网关、员工服务、Redis、短信/邮件服务等多个环节。我把完整流程拆成了六个步骤用户提交找回请求前端表单提交员工账号或手机号 图形验证码防止机器人刷接口。员工服务校验账号存在性调用员工服务接口查询账号是否存在、状态是否启用。这里不能直接提示“账号不存在”否则等于暴露了系统注册员工信息统一返回“如果账号存在验证码已发送”。生成并发送验证码生成 6 位随机数字存入 Rediskey 设计为sms:forgot:员工ID有效期 5 分钟。同时通过邮件或短信渠道发送验证码。用户输入短信/邮件验证码前端提交验证码 新密码 确认密码。校验验证码并重置密码员工服务从 Redis 取出验证码比对校验通过后更新数据库中的 BCrypt 密码。记录操作日志并通知用户记录一条“密码修改成功”的操作日志并通过站内信或短信通知用户避免账号被盗时用户毫不知情。代码实现上发送验证码的接口要做防刷限制我加了两层// 限制单账号5分钟内只能请求1次验证码 ValueOperationsString, String ops redisTemplate.opsForValue(); String sendCountKey sms:forgot:count: phone; Long count ops.increment(sendCountKey); if (count ! null count 1) { throw new BizException(验证码已发送请稍后再试); } redisTemplate.expire(sendCountKey, 5, TimeUnit.MINUTES);同时验证码校验也做了最大错误次数限制连续输错 5 次就删除验证码并要求重新获取。别小看这个细节如果不做限制暴力破解可以遍历 6 位数字验证码。2.3 密码存储的安全细节MD5 加密早已不合格密码存储这块我要多说一句。很多人图省事用 MD5 加盐但在我个人看来MD5 的算力消耗太小GPU 撞库太容易了已经不适合现代应用。Spring Boot 自带的spring-security-crypto模块里就有 BCrypt 实现用起来也不复杂// 加密 String encodedPassword new BCryptPasswordEncoder().encode(rawPassword); // 校验 boolean matches new BCryptPasswordEncoder().matches(rawPassword, encodedPassword);BCrypt 每次加密生成的盐是随机的所以同一个密码两次加密结果不同但校验都能通过。这个特性让彩虹表攻击直接失效而且 BCrypt 设计上就故意放慢加密速度让暴力破解的成本大幅上升。另外还要注意一个细节微服务架构下密码校验尽量放在员工服务内部不要把数据库中的 BCrypt 密文通过接口传给前端或网关。我之前看到过某些项目为了前端做“记住密码”把密文返回给前端存 Cookie这是非常危险的做法。我的方案是认证授权服务只负责校验 JWT 令牌员工服务负责校验账号密码两个服务完全隔离。3. 前端 Vue 的工程组织与多角色视图落地方案3.1 管理端、骑手端、用户端我最终选择了一个工程三个模块外卖系统天然存在三类用户C 端消费者、B 端商家/员工、骑手配送员。前端如果拆成三个独立工程工程开发时要同时跑三个脚手架部署和维护成本都很大我最后选择了一个 Vue 工程内部按模块拆分路由和组件的方式。具体目录结构是这样的src/ ├── api/ # 所有接口请求 │ ├── user.js # C端用户接口 │ ├── employee.js # 员工端接口 │ ├── order.js # 订单接口 │ └── delivery.js # 配送接口 ├── router/ │ ├── index.js # 路由入口 │ ├── user.routes.js # C端路由 │ ├── admin.routes.js # 管理后台路由 │ └── rider.routes.js # 骑手端路由 ├── views/ │ ├── user/ # 用户端页面 │ ├── admin/ # 管理后台页面 │ └── rider/ # 骑手端页面 ├── store/ # Vuex状态管理 │ ├── modules/ │ │ ├── user.js │ │ ├── employee.js │ │ └── app.js ├── utils/ │ └── request.js # axios封装一个工程三个模块核心收益是公共组件比如订单卡片、地图选点、支付弹窗可以全局复用而且用户端和管理端共用同一套 axios 封装和后端接口维护一份代码就够。3.2 Axios 请求封装与 Token 失效自动跳转前端绕不开的问题就是 token 管理和接口鉴权。我的做法是统一封装request.js所有请求实例统一带上令牌并统一处理异常状态码。// utils/request.js import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 15000 }) // 请求拦截器自动带token service.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers[Authorization] Bearer ${token} } return config }, error Promise.reject(error)) // 响应拦截器统一处理错误 service.interceptors.response.use(response { const res response.data if (res.code ! 0) { Message.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error { const status error.response?.status if (status 401) { // token过期跳转登录页并清理本地状态 localStorage.removeItem(access_token) localStorage.removeItem(refresh_token) router.push(/login) Message.warning(登录已过期请重新登录) } else if (status 403) { Message.error(没有权限访问该资源) } else if (status 500) { Message.error(服务器繁忙请稍后重试) } return Promise.reject(error) })这里有个很关键的细节401 处理不能只依赖前端跳转后端网关也必须做同样的校验。如果前端拦截器漏掉了某个请求后端网关的全局 JWT 过滤器会把请求拦截下来双重保障防止静态资源或绕过前端的非法请求进入业务服务。3.3 动态路由与按钮级权限员工角色不同看到的功能不同外卖管理后台里超级管理员、门店店长、普通员工看到的菜单和按钮应该不同。我在 Vue 路由里实现了动态路由权限控制核心逻辑分三步登录成功后获取当前员工的角色和权限码后端返回角色编码roleCode和权限标识数组permissions如employee:add、order:export。前端通过路由守卫决定是否放行// 路由守卫逻辑 router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) if (!token to.path ! /login) { next(/login) return } if (token to.path /login) { next(/) return } // 动态路由按角色生成 if (token !store.state.permission.routesLoaded) { store.dispatch(permission/generateRoutes, roleCode).then(accessRoutes { router.addRoutes(accessRoutes) next({ ...to, replace: true }) }) return } next() })按钮级权限用自定义指令控制Vue.directive(permission, { inserted(el, binding) { const requiredPermissions store.state.permission.permissions const value binding.value if (value !requiredPermissions.includes(value)) { el.parentNode.removeChild(el) } } })按钮上使用方式就是el-button v-permissionemployee:add 新增员工/el-button。这一套做下来前后端的权限点是能对上的。后端接口也要在网关或员工服务里做同样的权限码校验否则前端隐藏了按钮但懂接口的人直接绕过前端调用后端接口权限就是摆设。4. 分布式架构下最容易翻车的三个问题与处理方案4.1 Redis 分布式锁点餐高峰并发扣库存的正确姿势外卖系统的菜品库存和普通电商库存不一样很多菜品是“当日限量”高并发下多个用户同时抢最后一个菜品库存容易扣成负数。我早期在订单服务里直接写了update stock set count count - 1 where id ?本地单库单表没问题但拆了微服务后订单服务是多实例部署的两个实例同时读到库存 1各自执行减一结果库存变成 -1。解决方案自然是分布式锁但这里也有坑。我第一次用 Redis 的setnx手动实现代码是这样的// 错误示范没有设置过期时间 Boolean flag stringRedisTemplate.opsForValue().setIfAbsent(lockKey, 1); if (flag) { // 执行业务逻辑 stringRedisTemplate.delete(lockKey); }这个实现有两个经典问题如果业务逻辑抛异常锁永远不释放如果锁没有过期时间服务宕机后锁也永远不释放。后来我改成了带过期时间的写法但又有新问题——如果一个线程业务执行时间超过了锁过期时间锁自动释放后另一个线程拿到了新锁此时第一个线程业务做完删锁把第二个线程的锁误删了。最终的解决方式是引入 RedissonAutowired private RedissonClient redissonClient; public boolean deductStock(Long skuId) { RLock lock redissonClient.getLock(stock-lock: skuId); boolean locked false; try { // 尝试加锁最多等待5秒锁有效期默认30秒 locked lock.tryLock(5, TimeUnit.SECONDS); if (!locked) { throw new BizException(当前操作人数过多请稍后再试); } // 扣减库存逻辑基于乐观锁或条件更新 int result stockMapper.deductCount(skuId); return result 0; } finally { // 只有获取到锁的线程才释放锁 if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }Redisson 底层用的是 Lua 脚本保证加锁和设置过期时间的原子性并且有看门狗机制自动续期。实际经验是分布式锁不要自己造轮子用成熟框架省心得多。4.2 订单掉单与状态不一致退出分布式事务后如何保证数据一致外卖系统里最常见的严重故障就是用户支付成功了但订单状态没更新或者库存扣了但订单没创建。分布式事务的教科书方案是 Seata AT 模式但我实际项目中没有优先引入 Seata原因有三一是为几个关键接口引入全局事务开销太大二是 Seata 的部署和调试复杂度对项目来说过重三是外卖业务本身允许一定程度的“最终一致”不需要强一致。我的替代方案是本地消息表 定时任务对账下单时订单服务和库存扣减在同一个本地事务里完成订单服务里冗余一份菜品库存扣减逻辑然后写入一条“订单创建成功”的消息记录表。有一个后台定时任务每 30 秒扫描一次把未确认的消息推送到配送服务、商家服务。如果推送失败消息表里记录重试次数超过重试上限就告警人工介入。这个方案的核心理念是与其在多个服务间强行做全局锁不如设计好“如果这一步失败最终怎么对上账”。外卖场景里用户支付后系统返回“订单已支付”但商家侧如果晚 30 秒收到消息用户是感知不到的最终一致完全够用。4.3 跨服务调用链路日志不通怎么排查微服务拆了之后排查问题最痛苦的一点就是一个订单异常从头到尾经过了网关、认证服务、订单服务、库存、支付日志散落在多个服务里没有任何一个入口能看到全链路。我一开始没做链路追踪结果一次支付回调排查到凌晨最后发现是 Feign 调用超时导致数据不一致但没有日志支持只能靠猜。后来我给项目接入了 Spring Cloud Sleuth 和 Zipkin。Sleuth 会自动给每个请求生成 Trace ID 和 Span ID通过 Feign 传递到下游服务Zipkin 收集并展示调用链。你可能觉得这个对新手项目太重但我的体会是至少要接入 Sleuth哪怕不接 Zipkin 界面把 Trace ID 打到日志里也能在排查问题时把散落的日志串起来。比如日志统一输出格式里加一个字段[订单服务] TraceId: 34e5f2a1, SpanId: b3d9c8a2, 订单创建成功: {orderId: 12345}有了这个 TraceId出问题时直接 grep 一个 ID就能从网关日志一直追到数据库操作记录效率翻倍。5. 部署联调实录Nacos、网关、前端打包的常见坑5.1 本地联调Nacos 配置加载不生效与端口规划多服务联调第一步是端口规划。我本地的端口分配建议如下服务端口网关服务8080员工服务8081用户服务8082订单服务8083配送服务8084支付服务8085Nacos8848MySQL3306Redis6379Vue 前端开发服务器3000端口规划是小事但做好了能少很多排查时间。我记得第一次联调时两个服务都默认用了 8080 端口结果启动报错找了好久才发现是端口冲突。Nacos 的另一个常见坑是配置文件加载不生效。我在bootstrap.yml里配置了 Nacos 的扩展配置文件但改了配置后服务一直不重启就加载不到新值后来发现是本地环境把spring.cloud.nacos.config.import-check.enabled设成了 false导致配置没有被拉取。这里的关键是Nacos 作为配置中心时服务启动先去 Nacos 拉配置再去启动应用。如果你发现改了 Nacos 上的配置服务没收到优先检查服务是否成功连上了 Nacos以及配置列表的 Data ID 和 Group 是否和服务里写的一致。5.2 Vue 前端打包部署的两个注意点前端开发时用的代理配置和生产环境完全不同。我在本地开发用 Vite 的 proxy 代理了/api到网关地址但打包部署后 Nginx 也需要一套转发。很多新手在服务器上踩过这个坑前端打包后能打开页面但一调接口就 404。我的 Nginx 配置是一个通用模板server { listen 80; server_name your.domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # Vue Router history模式必须加 } location /api/ { proxy_pass http://127.0.0.1:8080; # 转发到网关服务 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }注意try_files $uri $uri/ /index.html这行Vue Router 如果用的是 history 模式不带 #刷新/admin/employee这种子页面时Nginx 会直接 404必须靠这行配置把路由重定向回index.html。这是我第一次部署时掉进去的典型坑。还有一个细节是refresh_token的有效期处理。前端在 Axios 响应拦截器里发现 token 快过期时应该用 refreshToken 静默换新 token而不是直接踢用户下线。我在项目里加了 token 刷新逻辑// 响应拦截器里判断 401尝试刷新 token const response await service.post(/auth/refresh, { refreshToken: localStorage.getItem(refresh_token) }) if (response.data.code 0) { localStorage.setItem(access_token, response.data.data.accessToken) // 重放原请求 }这样用户中午点外卖点一半token 到点过期了也不会被强制退出登录体验会好很多。5.3 服务注册不上和 Feign 调用超时的排查思路排查 Nacos 服务注册不上的问题我的经验是按住这 4 个点查spring.cloud.nacos.discovery.server-addr是否指向正确的 Nacos 地址。Nacos 控制台的服务列表里是否出现了服务名。如果是部署在服务器上Nacos 的 8848 端口是否在防火墙或安全组里放行。各服务之间网络是否能互通比如用telnet 192.168.x.x 8848测一下。Feign 调用超时是我另一个主要排查点。OpenFeign 默认的超时时间是 1 秒而订单服务查询库存、加锁、扣减库存的链路很容易超过 1 秒所以配置里要合理调整超时时间feign: client: config: default: connectTimeout: 3000 readTimeout: 5000超时设置太短会误伤正常请求太长又会让线程长时间挂起拖垮服务。我实践中一般readTimeout设置成 3~5 秒再配合 Sentinel 的熔断降级比单纯拉长超时更稳妥。服务完成后整个系统从单体演进成微服务再到前后端分离部署最让我感慨的一点是微服务本身不是目的解决单体架构的问题才是目的。外卖这个项目因为业务链路长、角色多、峰值流量高非常适合拿来拆解微服务的核心问题。如果你正在做类似项目我的建议是从一个能跑通全流程的单体版本出发先理清业务边界的痛点再动手拆分这样每一步都有明确的动机而不是为了“微服务”三个字硬生生造一座天梯。另外再分享一个小技巧拆微服务后本地联调时我会把每个服务的启动配置-Dspring.profiles.activedev固定下来配合 IDEA 的 compound configuration 一键启动全部服务。虽然冷启动要等一小会儿但能省下手工逐个启动的重复操作也更接近线上分布式的真实运行状态。项目做完之后这个启动方式也会让演示和答辩顺滑很多。

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

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

免费获取报价 →
↑