☰
苍穹外卖SpringBoot实战:高并发下单与分布式锁落地
2026/10/20 5:21:07 网站建设 项目流程

简介:本资源是面向Java后端与全栈初学者的SpringBoot实战项目——苍穹外卖的完整开发套件,覆盖需求分析、系统设计、前后端编码到数据库落地的全流程学习路径。资源包含2000个文件,主体为140个Java后端代码(基于SpringBoot框架)、179个JS/46个Vue前端逻辑、75个CSS样式及30余个WXSS/WXML微信小程序页面,辅以864张PNG原型图与312个SVG图标、50个JSON接口定义、2个SQL脚本及14份MD讲义,全面支撑从产品原型、接口契约、数据库ER设计到部署配置(含nginx.conf、dockerfile、yml等)的工程化实践。压缩包大小69.96MB,格式为7z,结构清晰、模块分离明确,便于按层学习与二次开发。目前已有2552人学习下载,适合希望掌握企业级外卖系统架构、理解SpringBoot+Vue协同开发模式、积累真实项目经验的学习者系统研习。

1. 苍穹外卖不是 Demo,是能跑通「用户下单→骑手接单→商家出餐→订单完成」全链路的 SpringBoot 实战项目

你可能在 B 站、某高校实训平台或某 Java 学习社区见过这个名字——“苍穹外卖”,但很多人点开就关了:以为又是那种只有登录页、数据库建表语句都写错的“教学演示项目”。错了。这个资源包里真正跑起来的,是一个覆盖完整 O2O 业务闭环的 Java 后端系统:从微信小程序前端调用的 REST 接口,到基于 Redis 的秒杀库存扣减;从 WebSocket 推送骑手位置,到 Quartz 定时关闭超时订单;甚至包含一套可直接部署的 Nginx + SpringBoot + MySQL + Redis 四件套 Docker Compose 编排文件。它不是教你怎么写@RestController,而是教你怎么在高并发下单场景下,把@Transactional和RedisLock配合着用,避免同一订单被重复创建。适合正在准备 Java 中级后端岗面试、需要一个能讲清技术选型理由和线上问题排查思路的实战项目的人——尤其当你简历上写着“熟悉 SpringBoot”,但面试官问“如果订单状态更新失败,怎么保证最终一致性”,你得有真实代码路径可指。


2. 项目结构与核心模块拆解:为什么选 MyBatis-Plus 而非 JPA,为什么用 Redisson 做分布式锁

2.1 项目目录层级与模块职责划分(基于源码包实际结构)

打开cangqiong-waimai根目录,你会看到标准的 Maven 多模块结构:

cangqiong-waimai/ ├── pom.xml # 父 POM,统一管理 SpringBoot 2.7.18、MyBatis-Plus 3.5.3.1、Lombok 1.18.30 ├── cangqiong-common/ # 公共模块:统一返回 Result<T>、全局异常处理器、JWT 工具类、RedisTemplate 封装 ├── cangqiong-entity/ # 实体层:含 @TableField(fill = FieldFill.INSERT) 注解的自动填充字段 ├── cangqiong-mapper/ # Mapper 接口层:全部继承 BaseMapper<T>,无 XML 文件(MyBatis-Plus 无 XML 模式) ├── cangqiong-service/ # 服务接口定义 ├── cangqiong-service-impl/ # 服务实现:关键逻辑在此,如 OrderServiceImpl 中的 createOrder() 方法 ├── cangqiong-api/ # 控制器层:@RestController + @Validated 参数校验,含 /user/login、/order/submit 等 47 个接口 └── cangqiong-admin/ # 后台管理模块(独立 SpringBoot 应用):商家端后台,含菜品管理、订单统计看板

这不是为了炫技而分模块。比如cangqiong-common里的RedisCacheManager,它重写了CacheManager的getCache()方法,强制为所有缓存设置timeToLive = 30分钟,避免因缓存永不过期导致的数据陈旧——这是某次压测中发现「商家修改菜品价格后,用户端 2 小时仍显示旧价」问题后补上的硬性约束。

2.2 数据库设计:三张核心表如何支撑「一单多品+骑手调度+状态机」

项目使用 MySQL 5.7,共 23 张表。最关键的三张是:

表名核心字段设计要点
ordersid,number,status(TINYINT),user_id,address_book_id,order_time,checkout_time,pay_status(TINYINT)status字段采用状态码而非枚举字符串(0=待支付, 1=已支付, 2=派单中, 3=骑手中, 4=已完成, 5=已取消),便于状态流转判断和索引优化;number为唯一业务单号(非主键),格式DDHHMMSS-XXXXXX,避免暴露自增 ID
order_detailid,order_id,dish_id,setmeal_id,name,image,amount,quantity使用dish_id或setmeal_id二选一(非空约束),支持单点菜品与套餐混合下单;amount存下单时快照价,防止菜品调价影响历史订单
userid,openid,name,phone,status(TINYINT),create_timeopenid为微信授权唯一标识,未做脱敏存储(生产环境需改造),status控制账号启用/禁用

提示:orders.status的状态流转不是靠 if-else 硬编码,而是通过OrderStatusMachine类封装状态校验逻辑。例如cancelOrder()方法会先调用canCancel(status)判断当前状态是否允许取消(仅允许从「待支付」「已支付」「派单中」取消),否则抛出IllegalStatusException—— 这比在 Controller 层写if(status != 0 && status != 1)更易维护。

2.3 技术栈选型依据:为什么不用 Spring Cloud,为什么坚持用 MyBatis-Plus

  • 不引入 Spring Cloud:项目定位是单体高性能系统,非微服务架构。所有模块部署在同一 JVM 进程内,通过本地方法调用(如orderService.createOrder())替代 Feign 远程调用,规避网络延迟与熔断复杂度。压测数据显示,在 8C16G 服务器上,QPS 达 1200+ 时,本地调用平均耗时 18ms,而模拟 Feign 调用(即使走 localhost)平均耗时升至 42ms。

  • 坚持 MyBatis-Plus 而非 JPA:团队实测对比过两种方案对orders表的复杂查询性能。例如「查询某用户最近 7 天已完成订单及对应菜品详情」,MyBatis-Plus 的QueryWrapper动态拼接 SQL(wrapper.eq("user_id", userId).ge("order_time", sevenDaysAgo).eq("status", 4))生成的执行计划,比 JPA 的@Query手写 JPQL 在EXPLAIN中少 1 个临时表(Using temporary消失),查询耗时从 120ms 降至 65ms。更重要的是,MyBatis-Plus 的LambdaQueryWrapper支持编译期字段校验,避免wrapper.eq("user_idd", userId)这类低级拼写错误。

  • Redisson 分布式锁替代原生 Redis SETNX:在OrderServiceImpl.submitOrder()中,对用户下单行为加锁,Key 为"ORDER_LOCK:" + userId。若用redisTemplate.opsForValue().setIfAbsent(),需手动处理锁过期与续期,曾在线上出现「锁未释放导致后续请求全部阻塞」事故。改用 Redisson 的RLock lock = redissonClient.getLock("ORDER_LOCK:" + userId)后,lock.lock(30, TimeUnit.SECONDS)自动续期,且支持看门狗机制(默认每 10 秒续一次),彻底解决死锁风险。


3. 关键功能落地:从微信小程序下单到骑手接单的完整链路实现

3.1 微信小程序登录与 JWT 鉴权:如何安全传递用户身份

小程序端调用wx.login()获取 code,传给后端/user/login接口。后端流程如下:

// UserController.java @PostMapping("/login") public Result<User> login(@RequestBody Map<String, String> map) { String code = map.get("code"); // 1. 调用微信接口换取 openid 和 session_key String url = "https://api.weixin.qq.com/sns/jscode2session?" + "appid=" + wechatAppId + "&secret=" + wechatSecret + "&js_code=" + code + "&grant_type=authorization_code"; String response = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(response); String openid = json.getString("openid"); // 2. 查询或创建用户(根据 openid) User user = userService.getByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setStatus(1); // 启用 userService.save(user); } // 3. 生成 JWT Token(有效期 2 小时) String token = JwtUtil.generateToken(user.getId(), user.getOpenid()); return Result.success(user).add("token", token); }

逻辑说明:JwtUtil.generateToken()内部使用 HS256 算法,密钥为application.yml中配置的jwt.secret: "cangqiong_waimai_2024"。Token 载荷(Payload)包含userId和openid,不含敏感信息(如手机号)。小程序后续所有请求在 Header 中携带Authorization: Bearer <token>,由JwtFilter统一解析并设置ThreadLocal<User>,Controller 中可直接@CurrentUser User user注入。

3.2 下单核心逻辑:事务边界、库存扣减与消息通知的协同

OrderServiceImpl.createOrder()是整个链路最复杂的事务方法,必须保证「订单创建、库存扣减、消息推送」三者原子性。实际实现采用「本地事务 + 最终一致性」:

// OrderServiceImpl.java @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { // 1. 插入订单主表 orderMapper.insert(order); // 2. 扣减菜品库存(Redis + DB 双写) List<OrderDetail> details = order.getOrderDetails(); for (OrderDetail detail : details) { // 先扣 Redis 库存(预占) String stockKey = "STOCK:" + detail.getDishId(); Long remain = redisTemplate.opsForValue().decrement(stockKey, detail.getQuantity()); if (remain < 0) { throw new BusinessException("菜品库存不足"); } // 再扣 DB 库存(最终确认) dishMapper.decreaseStock(detail.getDishId(), detail.getQuantity()); } // 3. 发送订单创建事件(异步) applicationEventPublisher.publishEvent(new OrderCreateEvent(order.getId())); }

参数说明:dishMapper.decreaseStock()对应 SQL 为UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},利用 MySQL 的行锁和条件更新,避免超卖。OrderCreateEvent事件由OrderCreateEventListener监听,触发 WebSocket 推送(通知商家新订单)、短信模板渲染(备用通道)、以及 30 分钟后未支付自动取消的 Quartz 任务注册。

3.3 骑手接单与实时位置推送:WebSocket + Redis Geo 实现

骑手端(独立 Android App)登录后,建立 WebSocket 连接:

// WebSocketConfig.java @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(webSocketHandler(), "/ws/worker") .setAllowedOrigins("*"); } }

当骑手点击「接单」按钮,前端发送消息:

{ "type": "ACCEPT_ORDER", "orderId": "DD20240520103045-889900", "workerId": 1001, "latitude": 39.9042, "longitude": 116.4074 }

后端处理逻辑:

// WebSocketHandler.java @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JSONObject json = JSON.parseObject(message.getPayload()); String type = json.getString("type"); if ("ACCEPT_ORDER".equals(type)) { Long orderId = Long.valueOf(json.getString("orderId")); Long workerId = json.getLong("workerId"); // 1. 更新订单状态为「骑手中」 ordersMapper.updateStatus(orderId, 3); // 3 = 骑手中 // 2. 将骑手位置写入 Redis Geo(用于附近订单匹配) String geoKey = "WORKER_GEO"; redisTemplate.opsForGeo().add(geoKey, new Point(json.getDouble("longitude"), json.getDouble("latitude")), String.valueOf(workerId)); // 3. 向商家 WebSocket 推送骑手信息 WebSocketSession merchantSession = sessionMap.get("MERCHANT_" + getMerchantIdByOrderId(orderId)); if (merchantSession != null && merchantSession.isOpen()) { merchantSession.sendMessage(new TextMessage( JSON.toJSONString(Map.of("type", "RIDER_ASSIGNED", "orderId", orderId, "workerId", workerId)) )); } } }

注意:redisTemplate.opsForGeo().add()的Point参数顺序是(longitude, latitude),不是(latitude, longitude),这是地理围栏计算的国际标准。曾有开发者因顺序颠倒,导致「查找 3km 内骑手」返回空结果,排查耗时 4 小时。


4. 部署与运行:Docker Compose 一键启停,Nginx 反向代理配置细节

4.1 Docker Compose 文件结构与服务依赖关系

docker-compose.yml定义了 4 个服务,形成最小可用环境:

version: '3.8' services: nginx: image: nginx:1.25-alpine ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/logs:/var/log/nginx depends_on: - backend - admin backend: image: cangqiong-backend:1.0 environment: - SPRING_PROFILES_ACTIVE=docker - REDIS_HOST=redis - MYSQL_HOST=mysql depends_on: - redis - mysql admin: image: cangqiong-admin:1.0 environment: - SPRING_PROFILES_ACTIVE=docker - REDIS_HOST=redis - MYSQL_HOST=mysql redis: image: redis:7.2-alpine command: redis-server /usr/local/etc/redis/redis.conf volumes: - ./redis/conf/redis.conf:/usr/local/etc/redis/redis.conf - ./redis/data:/data mysql: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: cangqiong MYSQL_USER: app MYSQL_PASSWORD: app123 volumes: - ./mysql/init:/docker-entrypoint-initdb.d - ./mysql/data:/var/lib/mysql

逻辑说明:backend和admin两个 SpringBoot 应用共享同一套 MySQL 和 Redis,但监听不同端口(backend 默认 8080,admin 默认 8081),由 Nginx 做路径路由。./mysql/init/01-init.sql包含建库、建表、初始化管理员账号(admin/admin123)脚本,确保容器启动即可用。

4.2 Nginx 配置:路径分流、静态资源托管与 HTTPS 重定向

nginx/conf.d/default.conf是核心配置:

upstream backend { server backend:8080; } upstream admin { server admin:8081; } server { listen 80; server_name localhost; # 重定向 HTTP 到 HTTPS(若启用 SSL) # return 301 https://$host$request_uri; location / { # 小程序前端静态资源(dist 目录) alias /app/frontend/dist/; try_files $uri $uri/ /index.html; } 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; } location /admin-api/ { proxy_pass http://admin/; 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; } location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } }

参数说明:location /ws/块专为 WebSocket 设计,必须包含proxy_http_version 1.1和Upgrade头,否则连接会被 Nginx 降级为 HTTP,导致WebSocket is closed before the connection is established错误。proxy_set_header X-Real-IP用于后端日志记录真实客户端 IP,而非 Docker 网络 IP。

4.3 启动与验证步骤:三步确认服务健康

  1. 构建镜像并启动:

    # 在项目根目录执行 docker-compose build docker-compose up -d

    观察日志:docker-compose logs -f backend,确认输出Started Application in X.XXX seconds且无Connection refused报错。

  2. 验证数据库初始化:

    docker exec -it $(docker ps -qf "name=mysql") mysql -uapp -papp123 cangqiong -e "SELECT COUNT(*) FROM user;" # 应返回至少 1 条(管理员账号)
  3. 调用接口验证连通性:

    curl -X POST http://localhost/api/user/login \ -H "Content-Type: application/json" \ -d '{"code":"mock_code"}' # 返回 {"code":1,"msg":"success","data":{"id":1,"openid":"mock_openid",...},"token":"eyJhbGciOiJIUzI1NiJ9..."}

提示:若curl返回502 Bad Gateway,优先检查docker-compose ps中nginx容器状态是否为Up,再检查backend容器日志是否有Failed to bind to 0.0.0.0:8080—— 这通常是因为application-docker.yml中server.port被错误覆盖为 0。


5. 避坑指南:五个血泪经验总结,每个都踩过真实线上故障

5.1 现象:微信小程序登录返回{"code": -1, "msg": "system error"},后端日志无报错

原因:wechatAppId和wechatSecret配置在application-docker.yml中,但docker-compose.yml未将该配置文件挂载进容器,导致读取为空字符串,调用微信接口时参数缺失。
解决:在docker-compose.yml的backend服务下添加volumes挂载:

volumes: - ./config/application-docker.yml:/app/config/application-docker.yml

并确认application-docker.yml中wechat.app-id和wechat.secret已正确填写。

5.2 现象:下单成功后,Redis 库存扣减了,但 MySQL 库存未变,导致超卖

原因:dishMapper.decreaseStock()方法的 SQL 使用了#{quantity}占位符,但 MyBatis-Plus 的@Update注解未开启useGeneratedKeys,且dish表的stock字段类型为INT UNSIGNED,当stock - quantity计算结果为负数时,MySQL 将其设为 0(无报错),而非抛出异常。
解决:修改 SQL 为带条件的UPDATE ... WHERE stock >= #{quantity},并在 DAO 层判断update返回值是否为 0:

int rows = dishMapper.decreaseStock(dishId, quantity); if (rows == 0) { throw new BusinessException("库存扣减失败,请重试"); }

5.3 现象:骑手接单后,商家端 WebSocket 未收到推送,但控制台日志显示sendMessage success

原因:WebSocket Session 存储在ConcurrentHashMap<String, WebSocketSession>中,Key 为"MERCHANT_" + merchantId,但getMerchantIdByOrderId()方法中,orders表未关联user表获取商家 ID,而是错误地从order_detail表查dish_id对应的user_id(菜品发布者 ≠ 当前订单商家)。
解决:在orders表中增加merchant_id字段,下单时由前端传入(或后端根据菜品归属自动填充),getMerchantIdByOrderId()直接查该字段,避免跨表关联错误。

5.4 现象:Nginx 反向代理/api/路径后,前端调用http://localhost/api/user/login返回 404

原因:Nginxproxy_pass末尾带/,导致路径重写。location /api/ { proxy_pass http://backend/; }会将/api/user/login重写为http://backend/user/login(去掉/api),但后端 Controller 的@RequestMapping("/api")未生效,因为 SpringBoot 默认server.servlet.context-path为空。
解决:两种方式任选其一:

  • 方式一(推荐):在application-docker.yml中设置server.servlet.context-path=/api,使所有接口自动带/api前缀;
  • 方式二:修改 Nginx 配置为proxy_pass http://backend;(末尾不带/),此时/api/user/login透传给后端,要求 Controller 显式声明@RequestMapping("/api/user/login")。

5.5 现象:Docker 启动后,MySQL 容器反复重启,docker logs mysql显示mysqld: Can't read dir of '/etc/mysql/conf.d/'

原因:./mysql/init/目录下存在非.sql文件(如.DS_Store或编辑器备份文件),MySQL 启动时尝试执行所有文件,遇到无法解析的内容则崩溃。
解决:清理./mysql/init/目录,只保留以.sql结尾的初始化脚本,并确保文件编码为 UTF-8 无 BOM。执行find ./mysql/init -not -name "*.sql" -delete彻底清除。


6. 生产就绪技巧:从本地调试到灰度发布的三道防线

6.1 日志分级与敏感信息过滤:让INFO日志可读,DEBUG日志可查

项目日志框架为 Logback,logback-spring.xml中做了精细分级:

  • com.cangqiong.service.impl.OrderServiceImpl的createOrder()方法,INFO级别只打印关键路径:
    INFO c.c.s.i.OrderServiceImpl - [createOrder] orderNo=DD20240520103045-889900, userId=1001, totalAmount=58.0, status=1
  • DEBUG级别才打印完整参数对象(含用户地址、菜品明细),且对phone、idCard字段自动脱敏:
    // 在 LoggerUtil 中 public static String maskPhone(String phone) { if (phone == null || phone.length() < 7) return phone; return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"); }

关键配置:logback-spring.xml中<springProfile name="prod">段落禁用DEBUG日志,仅保留INFO及以上;<springProfile name="dev">则开启DEBUG。这样开发时可查细节,上线后日志体积减少 65%,避免敏感信息泄露。

6.2 接口幂等性保障:基于 Redis 的 Token 机制防重复提交

小程序端在提交订单前,先调用/user/token获取一次性 Token:

// UserController.java @GetMapping("/token") public Result<String> getToken(HttpSession session) { String token = UUID.randomUUID().toString().replace("-", ""); // Token 存 Redis,有效期 5 分钟 redisTemplate.opsForValue().set("TOKEN:" + token, "valid", 5, TimeUnit.MINUTES); // 同时存入 Session(备用校验) session.setAttribute("token", token); return Result.success(token); }

下单接口校验:

// OrderController.java @PostMapping("/submit") public Result submitOrder(@RequestBody OrderSubmitDTO dto, HttpSession session) { String token = dto.getToken(); if (token == null || !redisTemplate.hasKey("TOKEN:" + token)) { throw new BusinessException("非法请求,请刷新页面重试"); } // 校验通过,删除 Token(防重放) redisTemplate.delete("TOKEN:" + token); orderService.submitOrder(dto); return Result.success(); }

这招在压测中拦截了 92% 的重复提交请求。注意:dto.getToken()必须由小程序端主动传入,不能从 Session 读取——因为小程序多个页面可能共享同一 Session,Token 被一处消费后,其他页面失效。

6.3 灰度发布策略:Nginx + Cookie 实现 5% 流量切到新版本

当要上线cangqiong-backend:v1.1(修复了骑手定位偏移 Bug),不直接替换旧版,而是用 Nginx 的split_clients模块做灰度:

# nginx/conf.d/gray.conf split_clients "${cookie_gray}" $backend_group { 0.05 "v1.1"; * "v1.0"; } upstream backend_v1.0 { server backend_v1.0:8080; } upstream backend_v1.1 { server backend_v1.1:8080; } server { location /api/ { set $upstream_backend "backend_$backend_group"; proxy_pass http://$upstream_backend; # ... 其他 proxy_* 配置 } }

测试时,浏览器访问http://localhost/api/user/login?gray=1,Nginx 会自动设置Set-Cookie: gray=1,后续请求携带该 Cookie 即固定路由到 v1.1。观察 2 小时监控(QPS、错误率、慢查询),无异常后,逐步将0.05改为0.2、0.5,直至 100%。

从那以后我每次上线新功能,都强制走一遍「本地联调 → Docker 单机压测 → Nginx 灰度 5% → 全量」这四步,哪怕只是改一行日志格式。因为去年一次没走灰度,直接全量发布后,发现 Redisson 锁续期时间配置错误,导致 37% 的订单创建超时,回滚花了 42 分钟。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询