Spring Boot+Vue外卖系统:高并发订单状态机与幂等性实战

Spring Boot+Vue外卖系统:高并发订单状态机与幂等性实战 简介这是一套面向计算机专业本科生及Java初学者的完整外卖点餐系统实战项目适用于毕业设计、课程设计与全栈开发能力训练。项目采用Spring Boot后端Vue前端的主流技术栈覆盖用户端下单、商家端接单、后台管理等核心业务模块具备真实业务逻辑与可部署运行能力。资源包共含项目源码、MySQL数据库脚本、开发说明文档、环境部署视频、逐行代码讲解视频及全套开发软件文件类型以Java源文件、Vue组件、SQL脚本、MP4视频和PDF文档为主压缩包大小为20.1MB结构清晰、开箱即用。已有142人学习下载所有代码均经严格调试确保在JDK8、Node.js 14、MySQL 5.7环境下一键启动配套视频详细演示从环境配置到功能验证的全流程显著降低学习门槛与排错成本。1. 外卖点餐系统不是“增删改查练习册”而是 Spring Boot 与 Vue 协同处理高并发订单状态流转的真实战场很多刚跑通“用户下单→商家接单→骑手配送”流程的开发者会误以为只要把 Spring Boot 当后端 API 服务器、Vue 做个表单提交页面再连上 MySQL 就算完成了外卖点餐系统。但真实业务中一个订单在 30 秒内可能经历「待支付→已支付→商家确认→备餐中→已出餐→骑手取餐→配送中→已完成→已评价」7 种状态且每种状态变更都需触发库存扣减、消息通知、超时自动关单、异常回滚等原子操作。Spring Boot 提供的Transactional和Async是基础但真正决定系统是否扛得住午高峰 2000 订单/分钟的关键在于状态机设计、WebSocket 实时推送、Redis 缓存穿透防护和 Vue 端对异步状态变更的响应式兜底。本项目不是教学 Demo而是面向中小型本地生活服务商可直接部署的最小可用闭环——它用 Spring Boot 2.7.x兼容 JDK 8/11构建领域驱动的订单服务用 Vue 3 Composition API 实现状态驱动 UI所有源码、MySQL 5.7 兼容脚本、Docker Compose 一键部署方案、Nginx 反向代理配置及全流程讲解视频全部开源。适合 Java 后端想补全全栈链路、Vue 前端需理解真实 RESTful 接口契约、以及准备 Java 面试题中“分布式事务如何保证最终一致性”的工程师深度复现。2. 用 Spring Boot 搭建具备幂等性与状态校验的订单核心服务外卖系统最易被低估的复杂度来自订单状态的不可逆性与并发冲突。例如用户重复点击“支付”按钮若后端未做幂等控制可能生成多笔相同金额的支付记录骑手端同时点击“已取餐”和“取消配送”若状态校验缺失会导致订单卡在中间态。Spring Boot 并非开箱即用就能解决这些问题必须通过组合策略实现业务级健壮性。2.1 基于数据库唯一索引 业务代码双重校验的幂等设计单纯依赖 Redis 分布式锁会增加运维成本而纯内存校验无法应对服务重启。本项目采用“数据库唯一约束兜底 业务层预判拦截”的双保险模式。以支付接口为例// OrderService.java Transactional(rollbackFor Exception.class) public Result payOrder(Long orderId, String payChannel) { // 1. 查询订单并校验当前状态必须是已支付才能继续 Order order orderMapper.selectById(orderId); if (!WAIT_PAY.equals(order.getStatus())) { return Result.fail(订单状态非法当前为 order.getStatus()); } // 2. 生成幂等 keypay_order_{orderId}_{payChannel} String idempotentKey pay_order_ orderId _ payChannel; // 3. 尝试插入幂等记录表idempotent_log IdempotentLog log new IdempotentLog(); log.setKey(idempotentKey); log.setCreateTime(new Date()); try { idempotentLogMapper.insert(log); // 依赖数据库唯一索引 uk_key } catch (DuplicateKeyException e) { return Result.success(重复请求已忽略); } // 4. 执行真实支付逻辑调用支付网关、更新订单状态等 order.setStatus(PAID); order.setPayTime(new Date()); orderMapper.updateById(order); // 5. 发送状态变更事件用于后续库存、通知等解耦处理 applicationEventPublisher.publishEvent(new OrderPaidEvent(orderId)); return Result.success(); }提示idempotent_log表必须在 MySQL 中创建唯一索引UNIQUE KEY uk_key (key)。该设计比纯 Redis SETNX 更可靠——即使 Redis 故障数据库层仍能拦截重复请求比纯数据库 SELECTINSERT 更高效——避免了“读-判-写”三步竞争。2.2 使用状态机引擎管理订单生命周期硬编码if (WAIT_PAY.equals(status)) { status PAID; }在状态超过 5 种后极易出错。本项目集成spring-statemachine版本 3.0.1定义清晰的状态迁移规则# application.yml spring: statemachine: # 启用状态机自动配置 enabled: true # 状态持久化到数据库避免重启丢失 persistence: enabled: true repository: jpa// OrderStateMachineConfig.java Configuration EnableStateMachineFactory public class OrderStateMachineConfig extends StateMachineConfigurerAdapterString, String { Override public void configure(StateMachineConfigurationConfigurerString, String config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(stateMachineListener()); // 注册监听器记录每次状态变更 } Override public void configure(StateMachineTransitionConfigurerString, String transitions) throws Exception { transitions .withExternal() .source(WAIT_PAY).target(PAID).event(PAY_SUCCESS) // 支付成功 .and() .withExternal() .source(PAID).target(PREPARING).event(MERCHANT_CONFIRM) // 商家接单 .and() .withExternal() .source(PREPARING).target(READY_FOR_PICKUP).event(COOKING_COMPLETE) // 出餐 .and() .withExternal() .source(READY_FOR_PICKUP).target(DELIVERING).event(RIDER_PICKUP) // 骑手取餐 .and() .withExternal() .source(DELIVERING).target(COMPLETED).event(RIDER_DELIVERED); // 配送完成 } }注意状态机事件触发需配合StateRepository持久化当前状态。每次调用stateMachine.send(MessageBuilder.withPayload(PAY_SUCCESS).setHeader(orderId, 1001L).build())时框架自动校验迁移合法性——若当前状态不是WAIT_PAY则直接抛出InvalidTransitionException无需业务代码判断。2.3 关键参数表订单服务核心配置项说明参数名默认值说明修改建议spring.statemachine.jpa.enabledtrue启用 JPA 持久化状态机状态生产环境必须开启否则重启后状态丢失spring.redis.timeout2000msRedis 连接超时若网络延迟高建议调至5000msmybatis-plus.global-config.db-config.id-typeASSIGN_ID主键生成策略使用雪花算法避免分库分表时主键冲突server.tomcat.max-connections8192Tomcat 最大连接数午高峰前调至16384配合 Nginxworker_connections调整spring.servlet.session.timeout30mSession 过期时间用户登录态建议设为12h避免频繁重登3. 用 Vue 3 Composition API 构建响应式订单状态流与 WebSocket 实时同步Vue 端不能只做静态表单渲染。外卖场景下用户下单后需实时看到“商家已接单”“骑手距离 500 米”“预计送达 12:30”等动态信息这要求前端建立与后端的状态同步通道并对状态变更做出精准 UI 响应。Vue 3 的ref、computed与watch组合配合 WebSocket能以极简代码实现高保真状态映射。3.1 基于 WebSocket 的订单状态实时推送架构Spring Boot 后端使用EnableWebSocketMessageBroker搭建 STOMP 消息代理Vue 前端通过stomp/stompjs库订阅专属主题// websocket.js import { Client } from stomp/stompjs; export const stompClient new Client({ brokerURL: ws://localhost:8080/ws, // Spring Boot WebSocket 端点 connectHeaders: { X-AUTH-TOKEN: localStorage.getItem(token) // 传递认证 token }, debug: (str) { console.log(STOMP Debug:, str); } }); // 初始化连接 export function initWebSocket() { stompClient.onConnect () { console.log(WebSocket connected); // 订阅用户专属订单状态主题 stompClient.subscribe(/user/queue/order-status, (message) { const payload JSON.parse(message.body); // 触发全局状态更新 updateOrderStatus(payload.orderId, payload.newStatus); }); }; stompClient.activate(); }逻辑说明后端在订单状态变更时如OrderPaidEvent监听器中调用simpMessagingTemplate.convertAndSendToUser(userId, /queue/order-status, payload)向指定用户推送消息。/user/queue/前缀确保消息仅推送给该用户避免广播风暴。3.2 使用 Composition API 实现状态驱动 UI 渲染订单详情页不再用v-if/v-else罗列所有状态分支而是将状态映射为计算属性由computed自动派生 UI 行为!-- OrderDetail.vue -- script setup import { ref, computed, onMounted, watch } from vue; import { stompClient, initWebSocket } from /utils/websocket.js; const props defineProps({ orderId: { type: Number, required: true } }); // 响应式订单数据 const order ref(null); // 从后端拉取初始订单数据 const fetchOrder async () { const res await axios.get(/api/orders/${props.orderId}); order.value res.data; }; // 根据订单状态派生 UI 行为 const statusConfig computed(() { const status order.value?.status; switch (status) { case WAIT_PAY: return { title: 等待支付, action: 立即支付, buttonType: primary, showPayButton: true, progress: 20 }; case PAID: return { title: 商家处理中, action: 查看商家, buttonType: default, showPayButton: false, progress: 40 }; case PREPARING: return { title: 备餐中, action: 联系商家, buttonType: warning, showPayButton: false, progress: 60 }; case READY_FOR_PICKUP: return { title: 骑手已接单, action: 查看骑手, buttonType: success, showPayButton: false, progress: 80 }; case DELIVERING: return { title: 配送中, action: 查看位置, buttonType: info, showPayButton: false, progress: 90 }; case COMPLETED: return { title: 订单已完成, action: 去评价, buttonType: success, showPayButton: false, progress: 100 }; default: return { title: 未知状态, progress: 0 }; } }); // WebSocket 状态更新函数 const updateOrderStatus (id, newStatus) { if (id props.orderId order.value) { order.value.status newStatus; } }; onMounted(() { fetchOrder(); initWebSocket(); }); // 监听订单状态变化触发 UI 重绘 watch(() order.value?.status, (newVal) { console.log(订单状态更新为, newVal); }); /script template div classorder-detail div classstatus-bar el-progress :percentagestatusConfig.progress / h3{{ statusConfig.title }}/h3 /div el-button v-ifstatusConfig.showPayButton typeprimary clickhandlePay {{ statusConfig.action }} /el-button /div /template参数说明statusConfig是一个computed属性其返回值随order.value.status变化自动重新计算。当 WebSocket 推送新状态时order.value.status更新statusConfig立即响应UI 无需手动this.$forceUpdate()即可刷新。这种声明式写法大幅降低状态分支维护成本。3.3 Vue 端关键依赖与环境配置验证命令确保 Vue 项目能正确连接后端并渲染状态需验证以下三项# 1. 检查 Node.js 与 npm 版本Vue 3 要求 Node 14.15.0 node -v npm -v # 2. 安装项目依赖含 WebSocket 客户端 npm install stomp/stompjs axios element-plus # 3. 启动开发服务器并检查控制台无跨域错误 npm run serve # 此时浏览器访问 http://localhost:8080打开开发者工具 Network 标签页 # 查看 ws://localhost:8080/ws 连接状态是否为 101 Switching Protocols注意若出现WebSocket connection to ws://localhost:8080/ws failed请检查 Spring Boot 的WebSocketConfig是否已启用 CORSOverride public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws) .setAllowedOrigins(http://localhost:8080) // 前端地址 .withSockJS(); }4. 数据库脚本与 Docker Compose 一键部署绕过环境配置陷阱“源码能跑通但部署到服务器就报错”是外卖系统落地的最大拦路虎。常见问题包括MySQL 字符集不匹配导致中文乱码、Redis 密码未配置引发连接拒绝、Nginx 未代理 WebSocket 导致连接中断。本项目提供完整docker-compose.yml与init.sql屏蔽 90% 的环境差异。4.1 MySQL 初始化脚本关键字段与约束说明init.sql不仅建表更通过 DDL 强制规范数据质量。以下是核心表片段-- 创建订单主表utf8mb4 支持 emoji如商家名称含 CREATE TABLE t_order ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_no varchar(32) NOT NULL UNIQUE COMMENT 订单号全局唯一, user_id bigint NOT NULL COMMENT 用户ID, merchant_id bigint NOT NULL COMMENT 商家ID, status varchar(20) NOT NULL DEFAULT WAIT_PAY COMMENT 订单状态WAIT_PAY/PAID/PREPARING/..., total_amount decimal(10,2) NOT NULL COMMENT 总金额, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_merchant_id (merchant_id), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci COMMENT订单主表; -- 幂等日志表支撑支付幂等性 CREATE TABLE t_idempotent_log ( id bigint NOT NULL AUTO_INCREMENT, key varchar(128) NOT NULL COMMENT 幂等key如 pay_order_1001_alipay, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_key (key) COMMENT 唯一索引防止重复 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;提示utf8mb4_0900_ai_ci是 MySQL 8.0 推荐字符集比旧版utf8mb4_general_ci排序更准确ON UPDATE CURRENT_TIMESTAMP确保update_time自动更新避免手动维护。4.2 Docker Compose 配置文件详解docker-compose.yml将 MySQL、Redis、Nginx、Spring Boot、Vue 五组件统一编排关键配置如下version: 3.8 services: mysql: image: mysql:5.7 container_name: food-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: food_db MYSQL_USER: food_user MYSQL_PASSWORD: food_pass ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql redis: image: redis:7-alpine container_name: food-redis command: redis-server --requirepass redis123 ports: - 6379:6379 nginx: image: nginx:alpine container_name: food-nginx ports: - 80:80 - 443:443 volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./dist:/usr/share/nginx/html depends_on: - springboot-app springboot-app: build: ./backend container_name: food-springboot ports: - 8080:8080 environment: SPRING_PROFILES_ACTIVE: prod SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/food_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai SPRING_REDIS_HOST: redis SPRING_REDIS_PASSWORD: redis123 SPRING_REDIS_PORT: 6379 depends_on: - mysql - redis vue-app: build: ./frontend container_name: food-vue ports: - 8081:80 volumes: - ./dist:/usr/share/nginx/html逻辑说明nginx.conf中必须包含 WebSocket 支持配置location /ws { proxy_pass http://springboot-app:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; }否则 Vue 端ws://localhost/ws会被 Nginx 拦截为 HTTP返回 400 错误。4.3 一键部署执行步骤与失败排查表步骤命令预期输出常见失败原因与修复1. 构建前端静态资源cd frontend npm install npm run build生成dist/目录npm run build报错检查vue.config.js中publicPath是否为/2. 构建后端镜像cd backend docker build -t food-springboot .Successfully built xxxpom.xml中spring-boot-maven-plugin版本与 Spring Boot 2.7.x 不匹配3. 启动全栈服务docker-compose up -dCreating food-mysql ... doneMySQL 容器启动失败检查init.sql路径是否正确挂载或mysql-data目录权限4. 验证服务健康curl http://localhost/actuator/health{status:UP}返回DOWN检查springboot-app日志docker logs food-springboot常见为 Redis 密码错误5. 面试高频考点Spring Boot Vue 外卖系统中的分布式事务与状态一致性验证技巧Java 面试官常问“如果支付成功但库存扣减失败如何保证订单与库存数据一致” 这并非考察理论而是检验你能否在 Spring Boot Vue 架构中落地可验证的解决方案。本项目不依赖 Seata 等重型中间件而是用“本地消息表 定时补偿”实现最终一致性并提供快速验证方法。5.1 本地消息表机制解耦支付与库存服务当用户支付成功订单状态变为PAID但库存服务可能因网络抖动未收到通知。此时将消息落库由独立线程定时扫描并重试// PaymentService.java Transactional(rollbackFor Exception.class) public void handlePaymentSuccess(Long orderId) { // 1. 更新订单状态 Order order orderMapper.selectById(orderId); order.setStatus(PAID); orderMapper.updateById(order); // 2. 写入本地消息表t_local_message LocalMessage message new LocalMessage(); message.setTopic(inventory-deduct); message.setPayload(JSON.toJSONString(new InventoryDeductDTO(orderId, order.getItems()))); message.setTryCount(0); message.setStatus(SENDING); // 初始状态 localMessageMapper.insert(message); // 3. 发送消息异步失败不影响主流程 try { rabbitTemplate.convertAndSend(inventory.exchange, inventory.deduct, message); } catch (Exception e) { log.error(发送库存扣减消息失败将由补偿任务处理, e); } } // 补偿任务每 30 秒扫描一次 Scheduled(fixedDelay 30000) public void compensateInventoryMessages() { ListLocalMessage sendingMessages localMessageMapper.selectList( new QueryWrapperLocalMessage().eq(status, SENDING).last(limit 100) ); for (LocalMessage msg : sendingMessages) { try { // 重试发送 rabbitTemplate.convertAndSend(inventory.exchange, inventory.deduct, msg); msg.setStatus(SENT); localMessageMapper.updateById(msg); } catch (Exception e) { msg.setTryCount(msg.getTryCount() 1); if (msg.getTryCount() 3) { msg.setStatus(FAILED); // 超过 3 次标记失败人工介入 } localMessageMapper.updateById(msg); } } }验证技巧面试时可现场演示该机制。在compensateInventoryMessages方法中加断点手动将某条消息status改为SENDING观察 30 秒后是否被扫描并重试——这比背诵 CAP 理论更能证明你懂落地。5.2 Vue 端状态一致性自检脚本捕获 UI 与后端状态偏差用户看到的 UI 状态必须与数据库一致。本项目提供status-checker.js在订单页加载后自动发起状态比对// status-checker.js export async function checkOrderStatusConsistency(orderId) { try { // 1. 获取前端当前渲染状态 const frontendStatus document.querySelector(.status-bar h3)?.innerText || ; // 2. 调用后端状态查询接口不走缓存 const res await axios.get(/api/orders/${orderId}/status?_t${Date.now()}); const backendStatus res.data.status; // 3. 比对并上报偏差 if (frontendStatus ! backendStatus) { console.warn([状态不一致] 前端: ${frontendStatus}, 后端: ${backendStatus}); // 上报监控系统此处简化为 console await axios.post(/api/monitor/status-mismatch, { orderId, frontendStatus, backendStatus, timestamp: new Date().toISOString() }); } } catch (e) { console.error(状态一致性检查失败, e); } } // 在订单页 mounted 钩子中调用 onMounted(() { checkOrderStatusConsistency(props.orderId); // 每 60 秒轮询一次持续监控 setInterval(() checkOrderStatusConsistency(props.orderId), 60000); });参数说明?_t${Date.now()}是强制不走浏览器缓存的技巧/api/orders/{id}/status接口应直连数据库查询绕过 Redis 缓存确保比对基准真实。5.3 面试必答Spring Boot 版本选择与 MyBatis Plus 配置要点Spring Boot 2.7.x 是当前生产环境最稳妥的选择它兼容 JDK 8/11且spring-boot-starter-webflux已足够支撑外卖系统的响应式需求。而 MyBatis Plus 的关键配置直接影响 SQL 安全与性能# application-prod.yml mybatis-plus: configuration: # 禁用全局 mapUnderscoreToCamelCase显式指定 resultType map-underscore-to-camel-case: false global-config: db-config: # 主键策略雪花算法避免分库分表冲突 id-type: assign_id # 逻辑删除字段名 logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 # 开启 SQL 日志仅开发环境 configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意map-underscore-to-camel-case: false是硬性要求。若开启MyBatis Plus 会将user_name自动映射为userName但 Vue 端传参习惯用userName后端接收对象却定义为private String user_name;导致参数绑定为空——这是 Java 面试中高频踩坑点。本文还有配套的精品资源点击获取