外卖系统高并发库存一致性实战:SpringBoot+Vue落地要点
简介这是一套面向计算机专业本科生及Java初学者的完整外卖点餐系统实战项目适用于毕业设计、课程设计与全栈开发能力训练。系统采用Spring Boot构建后端微服务架构Vue.js实现响应式前端界面覆盖用户端下单、商家端接单、后台管理等核心业务流程具备完整的MVC分层结构与RESTful API设计实践价值。资源包共含项目源码、MySQL数据库脚本、部署操作视频、逐行代码讲解视频、开发说明文档及全套开发软件压缩包大小为20.1MB以Java类文件、Vue组件、SQL脚本、MP4视频和PDF文档为主各类文件分工明确便于分模块学习与调试复现。已有142人下载学习所有代码均经实机验证可直接运行配套视频涵盖环境搭建、数据库初始化、前后端联调及常见报错解决方案显著降低部署门槛与理解成本。1. 这不是又一个“SpringBootVue练手项目”外卖点餐系统的真实落地边界在哪你搜“SpringBoot Vue 外卖点餐系统”首页弹出的几乎全是带“源码数据库视频”的打包资源——但真正跑通、能改、能上线、能应对并发下单和库存扣减的不到三成。很多所谓“完整项目”连 Redis 缓存库存都没接入订单状态流转靠前端硬编码切换支付回调直接写死 success 页面。本篇不讲“如何下载源码”而是聚焦一个真实可交付的外卖点餐系统该有的技术纵深从 SpringBoot 的多数据源事务控制、Vue 路由守卫与权限动态加载到 MySQL 悲观锁防超卖、WebSocket 实时派单通知、Nginx 静态资源分离部署。适合 Java 后端已掌握 MyBatis 基础、Vue 已会vue-router和axios封装正卡在“本地能跑上线就崩”阶段的开发者。重点不是堆功能而是拆解每个模块的不可绕过的技术决策点——比如为什么用Transactional(timeout 5)而不是默认值为什么 Vue 中购物车数据必须用ref而非reactive这些细节才是源码包里不会写、但面试官会问、线上故障会爆的硬核部分。2. 后端核心SpringBoot 如何稳住高并发下单与库存一致性外卖场景下最脆弱的环节永远是“用户点击下单”那一秒——库存扣减、订单生成、优惠券核销必须原子性完成且不能因网络延迟或重试导致重复扣减。常见错误是把库存校验和扣减拆成两个 SQL中间插入其他业务逻辑或直接用UPDATE product SET stock stock - 1 WHERE id ? AND stock 0依赖 MySQL 行锁却忽略stock字段未加索引导致锁表。SpringBoot 的解决方案必须分层设计DAO 层用SELECT ... FOR UPDATE显式加锁Service 层用Transactional控制事务边界并强制设置超时避免长事务阻塞。2.1 数据库设计与关键约束从 ER 图到实际建表语句真实外卖系统中商品、规格、库存必须分离建模。例如同一菜品“宫保鸡丁”可能有“小份/大份”、“微辣/中辣”组合每种组合独立库存。ER 关系需明确product主品→product_sku规格→inventory库存其中inventory.sku_id是唯一外键且inventory.stock必须为BIGINT类型避免 INT 溢出并添加CHECK (stock 0)约束防止负库存。-- 创建库存表含乐观锁版本号和唯一索引 CREATE TABLE inventory ( id BIGINT NOT NULL AUTO_INCREMENT, sku_id BIGINT NOT NULL COMMENT 规格ID, stock BIGINT NOT NULL DEFAULT 0 CHECK (stock 0), version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_sku_id (sku_id), INDEX idx_updated_at (updated_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表;提示UNIQUE KEY uk_sku_id是强约束确保一个 SKU 只有一条库存记录INDEX idx_updated_at为后续按时间清理过期库存提供基础。若忽略此索引DELETE FROM inventory WHERE updated_at ?会全表扫描。2.2 库存扣减的三种实现对比悲观锁、乐观锁、Redis Lua 原子脚本方案适用场景SpringBoot 实现要点风险点MySQL 悲观锁中低并发QPS 500强一致性要求Transactional内执行SELECT * FROM inventory WHERE sku_id ? FOR UPDATE再UPDATE inventory SET stock stock - 1, version version 1 WHERE sku_id ? AND version ?锁等待超时需捕获LockAcquisitionException否则线程阻塞乐观锁重试中高并发QPS 500~2000容忍少量失败UPDATE语句带version条件if (updateCount 0) { retry() }重试上限 3 次重试逻辑必须在Transactional外否则嵌套事务失效Redis Lua 脚本高并发QPS 2000最终一致性可接受EVAL if redis.call(get, KEYS[1]) tonumber(ARGV[1]) then redis.call(decrby, KEYS[1], ARGV[1]) return 1 else return 0 end 1 inventory:123 1需双写 MySQLLua 脚本内不能调用redis.call(set)等阻塞命令实际项目中我一般采用悲观锁 降级开关正常流量走 MySQL 悲观锁大促前通过配置中心动态开启 Redis Lua 模式。关键代码如下// InventoryService.java Transactional(timeout 5) // 强制5秒超时避免长事务 public boolean deductStock(Long skuId, Integer quantity) { // 1. 加锁查询库存 Inventory inventory inventoryMapper.selectForUpdate(skuId); if (inventory null || inventory.getStock() quantity) { throw new BusinessException(库存不足); } // 2. 扣减并更新版本号 int updateCount inventoryMapper.updateStockAndVersion( skuId, quantity, inventory.getVersion() ); if (updateCount 0) { // 版本号不匹配说明已被其他事务修改抛异常触发事务回滚 throw new BusinessException(库存并发冲突请重试); } return true; }inventoryMapper.selectForUpdate()对应 XML!-- InventoryMapper.xml -- select idselectForUpdate resultTypeInventory SELECT * FROM inventory WHERE sku_id #{skuId} FOR UPDATE !-- 关键显式加行锁 -- /select2.2.1 为什么Transactional(timeout 5)不可省略MySQL 默认事务超时是innodb_lock_wait_timeout50秒但 SpringBoot 的Transactional默认无超时。若某次库存查询因慢 SQL 卡住后续所有请求都会排队等待锁释放导致线程池耗尽。设为 5 秒后超时自动回滚前端收到TransactionTimedOutException可降级返回“系统繁忙请稍后再试”。3. 前端协同Vue 如何保障购物车、订单页的状态可信与实时同步外卖系统前端最易被忽视的是状态一致性用户在购物车页添加商品切到首页再返回购物车数量是否还是最新下单成功后订单页的“待支付”状态能否秒级刷新很多 Vue 项目用localStorage存购物车但不同标签页间无法同步用vuex但未处理页面刷新后状态丢失。真正的方案是服务端兜底 客户端轻量同步。3.1 购物车数据流设计从ref到watchEffect的响应式链路Vue 3 中购物车列表必须用ref包裹数组而非reactive因为reactive对Array的响应式支持有缺陷如push不触发更新。同时需监听cartItems变化自动同步到服务端// composables/useCart.js import { ref, watchEffect } from vue import { api } from /utils/request const cartItems ref([]) // 初始化页面加载时拉取服务端购物车 export async function loadCart() { const res await api.get(/api/cart) cartItems.value res.data } // 监听变化500ms 防抖同步到服务端 watchEffect((onCleanup) { let timer onCleanup(() clearTimeout(timer)) timer setTimeout(() { if (cartItems.value.length 0) { api.post(/api/cart/sync, { items: cartItems.value }) } }, 500) }) // 导出可操作方法 export function addToCart(item) { const exist cartItems.value.find(i i.skuId item.skuId) if (exist) { exist.quantity item.quantity } else { cartItems.value.push({ ...item }) } }注意watchEffect的onCleanup清除定时器避免组件卸载后仍触发请求api.post使用POST而非PUT因购物车是集合资源符合 RESTful 规范。3.2 订单状态实时推送用 WebSocket 替代轮询的最小可行方案订单创建后用户需立即看到“待接单”状态骑手端需实时收到新订单。轮询setInterval浪费资源且延迟高。SpringBoot 内置spring-boot-starter-websocketVue 端用原生WebSocket即可无需额外库// WebSocketConfig.java Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new OrderWebSocketHandler(), /ws/order) .setAllowedOrigins(*); // 生产环境需限制域名 } }// orderDetail.vue const socket new WebSocket(ws://localhost:8080/ws/order) socket.onmessage (event) { const data JSON.parse(event.data) if (data.orderId orderId.value) { orderStatus.value data.status // 如 ACCEPTED, DELIVERING } } // 页面卸载时关闭连接 onBeforeUnmount(() { socket.close() })3.2.1 WebSocket 连接失败的降级策略网络不稳定时socket.readyState可能为0CONNECTING或3CLOSED。必须实现重连机制let reconnectTimer null function connectWebSocket() { socket new WebSocket(ws://.../ws/order) socket.onopen () { clearTimeout(reconnectTimer) } socket.onerror () { // 错误时不立即重连避免雪崩 if (reconnectTimer null) { reconnectTimer setTimeout(connectWebSocket, 3000) } } }4. 部署与验证Nginx Docker 一键部署及关键指标观测本地开发环境能跑通不等于生产环境可用。外卖系统部署必须解决三个问题静态资源分离、API 请求代理、健康检查探针。Nginx 不是可选而是必选项——它承担了 SSL 终结、负载均衡、缓存静态文件等职责让 SpringBoot 专注业务逻辑。4.1 Nginx 配置精准分离/static与/api流量Vue 打包后的dist目录需由 Nginx 直接服务而所有/api/**请求必须反向代理到 SpringBoot。错误配置如location / { proxy_pass http://backend; }会导致index.html被代理404。# /etc/nginx/conf.d/food-delivery.conf upstream backend { server 127.0.0.1:8080; # SpringBoot 默认端口 } server { listen 80; server_name food.example.com; # 静态资源Vue 构建产物 location /static/ { alias /var/www/food/dist/static/; expires 1y; add_header Cache-Control public, immutable; } # 根路径Vue history 模式 fallback location / { root /var/www/food/dist; try_files $uri $uri/ /index.html; } # API 接口全部代理到 SpringBoot location /api/ { proxy_pass http://backend/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }提示try_files $uri $uri/ /index.html是 Vue Router history 模式的必需配置否则刷新页面 404proxy_set_header系列确保 SpringBoot 能正确获取客户端真实 IP。4.2 Docker Compose 一键启停包含 MySQL、Redis、Nginx 三容器单机部署用 Docker 最简。docker-compose.yml必须声明各服务依赖关系确保 MySQL 启动后再启动 SpringBoot# docker-compose.yml version: 3.8 services: nginx: image: nginx:alpine ports: - 80:80 volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - ./dist:/var/www/food/dist depends_on: - app mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: food_db volumes: - mysql-data:/var/lib/mysql command: --default-authentication-pluginmysql_native_password redis: image: redis:7-alpine ports: - 6379:6379 app: build: . environment: SPRING_PROFILES_ACTIVE: prod SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/food_db?useSSLfalseserverTimezoneAsia/Shanghai SPRING_REDIS_HOST: redis depends_on: - mysql - redis volumes: mysql-data:4.2.1 验证部署成功的 3 个终端命令部署后不要只刷浏览器用命令行快速验证# 1. 检查容器状态应全为 Up docker-compose ps # 2. 查看 SpringBoot 日志确认连接 MySQL 和 Redis 成功 docker-compose logs app | grep -E (Started|Connected to|RedisConnectionFactory) # 3. 模拟下单请求验证接口返回 200 curl -X POST http://localhost/api/orders \ -H Content-Type: application/json \ -d {userId:1,items:[{skuId:1001,quantity:1}]} \ -w \nHTTP Status: %{http_code}\n5. 进阶技巧用 JMeter 压测库存接口并定位瓶颈点源码包里常缺的是压测方案。一个外卖系统能否扛住 1000 QPS 下单不能靠“感觉”要靠数据。JMeter 是免费开源工具比阿里云 PTS 更透明可控。重点不是跑出数字而是通过压测发现隐藏瓶颈——比如 MySQL 连接池耗尽、Redis 连接超时、或 JVM Full GC 频繁。5.1 JMeter 脚本编写模拟真实用户行为链路单接口压测如只压/api/orders意义有限。必须构建用户行为链路登录 → 查询商品 → 加入购物车 → 下单 → 支付。JMeter 中用Thread Group设置 100 线程模拟 100 用户Ramp-up period设为 60 秒每秒启动 1.67 个用户Loop Count设为 10每个用户循环 10 次。关键参数配置HTTP Header Manager添加Content-Type: application/json和Authorization: Bearer xxxJSON Extractor从登录响应中提取access_token供后续请求使用Constant Timer在“查询商品”和“加入购物车”间加 1~3 秒随机延迟模拟真实操作5.2 压测结果分析从Active Threads Over Time到Response Times Over Time运行 5 分钟后查看聚合报告Aggregate Report若90% Line90% 请求响应时间超过 2 秒说明接口慢若Error %突增先看View Results Tree中具体错误Connection refused指服务宕机Timeout指下游依赖超时若Active Threads Over Time曲线陡升后骤降说明线程池满需调大server.tomcat.max-threads。此时打开 SpringBoot Actuator 的/actuator/metrics/jvm.memory.used端点观察堆内存是否持续增长——若增长后不回收就是内存泄漏。常见泄漏点未关闭InputStream、静态 Map 缓存未清理、WebSocket Session 未注销。5.2.1 快速定位 MySQL 慢查询的三步法当压测中订单创建变慢优先查数据库开启 MySQL 慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0.1;执行压测然后查日志SELECT * FROM performance_schema.events_statements_summary_by_digest ORDER BY avg_timer_wait DESC LIMIT 5;找到digest_text含UPDATE inventory的记录复制其 SQL在EXPLAIN中分析EXPLAIN SELECT * FROM inventory WHERE sku_id 123 FOR UPDATE;若type为ALL全表扫描说明sku_id缺少索引——立刻补ALTER TABLE inventory ADD INDEX idx_sku_id (sku_id);本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →