尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
SpringBoot体育馆预约系统:并发锁、状态机与订单释放实战
简介基于SpringBoot的体育馆预约管理系统完整源码项目面向计算机科学与技术、电子信息工程等专业学生可用于毕业设计、课程项目或期末作业。系统采用浏览器-服务器模式与MVC设计模式集成SpringBoot、MyBatis、Vue、Ajax异步交互等技术覆盖场馆信息展示、预约下单、订单管理、用户管理等典型业务模块能帮助学习者快速掌握前后端分离项目的开发流程与实现思路。压缩包共900个文件以Java源码、Vue组件、JS逻辑、SVG图标、CSS样式为主另含GIF演示、构建辅助脚本及PDF、Word等格式文档整体大小17.61MB目录结构清晰便于按模块查阅和二次开发。目前已有58人学习浏览。项目代码经过充分测试功能稳定可直接运行部署附带的build/run批处理脚本能帮助快速启动环境适合作为毕业设计模板或SpringBoot实践参考尤其适合需要综合运用SpringBoot、MyBatis与Vue的开发者。1. 基于SpringBoot的体育馆预约管理系统难点并不在 CRUD体育馆预约管理系统开发实现第一次接触时容易把它当成普通的增删改查项目场地表、订单表、用户表往里一塞页面上能选时间能提交就算完成。但实际上线后问题会集中在几个地方同一场次被多人同时入库、用户取消后时段没有释放、管理端改时间导致历史订单对不上账。整个业务的骨架是时段模型、订单状态和并发控制SpringBoot只负责把这套骨架快速搭起来。按一线工程师的常规做法从表结构、项目启动、并发锁再到定时释放这里把完整方案讲透适合要独立完成可上线预约系统或准备答辩、交付相关功能的开发者。2. SpringBoot项目的预约核心模型场馆、时段与订单2.1 为什么预约要先做“场次”而不是“时间段”如果用户在界面上自由输入开始和结束时间冲突检测会变成区间重叠判断数据库压上再多的索引也不容易写得优雅而且界面很难排。常见做法是把场馆运营时间切成固定时段比如每个时段 2 小时当晚 22:00 前生成后 7 天的场次记录。这样“预约”这个动作退化为“对某场次下订单”买票式模型冲突检测变成对场次状态的原子扣减。2.2 表结构场地、场次与订单实际落地时我一般会拆四张核心表venue场地、time_slot时段字典、schedule某天某时段的具体场次、appointment_order订单。下面是一份可以建表后直接对接接口的SQL删掉了外键和审计字段留下主流程。CREATE TABLE venue ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, type TINYINT NOT NULL DEFAULT 0 COMMENT 1篮球 2羽毛球 3网球, max_people INT NOT NULL DEFAULT 10 COMMENT 单个场次可预约人数, status TINYINT NOT NULL DEFAULT 1 COMMENT 0停用 1启用 ) COMMENT 场地表; CREATE TABLE time_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, start_time TIME NOT NULL, end_time TIME NOT NULL, slot_minutes INT NOT NULL DEFAULT 120 COMMENT 时段长度用于生成场次 ) COMMENT 时段字典; CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, venue_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, match_date DATE NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, total_count INT NOT NULL COMMENT 可预约名额来自venue.max_people, remaining_count INT NOT NULL COMMENT 剩余名额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可约 1已约满 2锁定 3取消, UNIQUE KEY uk_venue_date_slot (venue_id, match_date, slot_id) ) COMMENT 场次表; CREATE TABLE appointment_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已完成, expire_time DATETIME NULL COMMENT 订单过期时间用于释放库存, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_user_schedule (user_id, schedule_id), KEY idx_schedule_status (schedule_id, status) ) COMMENT 预约订单表;锁仓思路下单时不是直接生成不可逆的订单而是把订单状态设为“待支付”同时给schedule.remaining_count做减一expire_time设为下单后 15 分钟。超过时间没支付定时任务把remaining_count加回来并把订单置为取消。这样库存和订单之间的事务边界清晰未支付订单不会永久占用名额。表里的uk_user_schedule保证同一个用户不会对同一个场次重复下单重复请求会被数据库直接拒绝。需要注意的坑schedule的唯一键是(venue_id, match_date, slot_id)含义是“同一个场地同一天同一个时段只能有一条场次记录”。如果业务里存在节假日分组、夜场加价的复杂规则需要再加一层price_policy之类的配置不要动这两张表的结构。批量生成场次时用INSERT ... ON DUPLICATE KEY UPDATE id id跳过重复日期避免每天定时任务报错。2.3 订单状态机与权限边界订单字段里的status是业务流转的核心常见状态如下。状态值含义驱动方式0待支付用户提交订单后1已支付支付回调后2已取消用户取消或超时释放3已完成场次结束后权限边界按user_id判断用户只能查看和取消自己的订单管理端才有权强制改期、关闭场次。取消接口只允许从 0 或 1 流转到 2如果订单已经完成直接抛业务异常。状态机判断放在 Service 层字段用TINYINT而不是VARCHAR查询走索引更快代码里再用枚举封装起来。2.4 业务约束放哪层规则“用户不能预约过去时间”“订单只能取消一次”“场次已约满不能再下单”这些是业务约束放在 Service 方法里先判断再写库。数据库唯一索引是兜底而不是主链路。两份约束放到一起既保证接口反馈友好又避免并发下数据库产生脏数据。常见误用是只靠前端按钮禁用或者只建索引不写业务提示导致用户看到报错也不知道发生了什么。把错误码设计成“CODE20001, 场次已约满”前端直接弹提示比回滚 500 再查日志友好得多。3. 用SpringBoot把预约接口落地实体、Mapper与Service3.1 SpringBoot自动装配与工程起步SpringBoot 能撑起项目主线靠的是自动装配而不是“配置更少”。启动时根据 classpath 里的依赖决定创建哪些 Bean比如引入了 spring-boot-starter-data-redis就会自动创建 RedisConnectionFactory 和 RedisTemplate。选型时使用当前稳定版本不追太高的 minor 版本避免第三方 starter 兼容性跟不上。工程里需要的最小依赖如下SpringBoot 3 项目为例MyBatis-Plus 要引入mybatis-plus-spring-boot3-starterSpringBoot 2 项目换成mybatis-plus-boot-starter。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyMyBatis-Plus 的 starter 需要自己声明版本号建议查看其官方 release 里的 SpringBoot 兼容矩阵不写进示例里是为了避免版本串号。接着是application.yml的核心配置。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/gym_reservation?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD} hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 data: redis: host: 127.0.0.1 port: 6379 password: ${REDIS_PASSWORD} timeout: 2s jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true${DB_PASSWORD}是环境变量引用密码这类敏感信息不要写死在配置文件里。maximum-pool-size20和connection-timeout3000是 HikariCP 连接池的关键参数连接不够时排队超过 3 秒直接降级避免预约高峰把请求全部卡在拿连接上。serverTimezone指定Asia/Shanghai否则 MySQL 的DATETIME和 Java 的LocalDateTime会有 8 小时偏差。3.2 实体与Mapper用MyBatis-Plus压缩样板代码用 MyBatis-Plus 而不是原生 MyBatis是因为预约系统没有复杂动态 SQL大部分 CRUD 用 BaseMapper 就能覆盖省掉一堆 XML。实体与表字段靠注解映射。TableName(appointment_order) public class AppointmentOrder { TableId(type IdType.ASSIGN_ID) private Long id; TableField(order_no) private String orderNo; TableField(user_id) private Long userId; TableField(schedule_id) private Long scheduleId; private Integer status; TableField(expire_time) private LocalDateTime expireTime; TableField(create_time) private LocalDateTime createTime; }TableId用ASSIGN_ID生成雪花ID避免数据库主键自增在分布式场景撞号也避免前端拿到 JSON 长整型丢失精度。TableField只在字段名和列名不一致的时候出现status这种同名的可以不加。Mapper 接口只需要继承BaseMapperAppointmentOrder插入、条件查询都来自框架不需要为每个方法写 XML。3.3 Service层创建订单事务、校验和库存扣减创建订单是一条完整的写链路查询场次、检查状态、扣减库存、插入订单。四个步骤必须在同一个事务里否则会出现“订单插上了库存没减”这种运维查半天的问题。Service RequiredArgsConstructor public class AppointmentService { private final ScheduleMapper scheduleMapper; private final AppointmentOrderMapper orderMapper; Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, Long scheduleId) { Schedule schedule scheduleMapper .selectById(scheduleId); if (schedule null) { throw new BizException(20000, 场次不存在); } if (schedule.getStatus() ! 0) { throw new BizException(20001, 场次已约满或锁定); } if (schedule.getRemainingCount() 0) { throw new BizException(20001, 场次已约满); } int updated scheduleMapper.decreaseRemaining(scheduleId, 1); if (updated 0) { throw new BizException(20001, 场次已约满请更换时段); } AppointmentOrder order new AppointmentOrder(); order.setUserId(userId); order.setScheduleId(scheduleId); order.setStatus(0); order.setExpireTime(LocalDateTime.now().plusMinutes(15)); orderMapper.insert(order); return order.getId(); } }到这里并发风险还没结束两个线程同时读到remainingCount1再各自执行decreaseRemaining前一个线程成功后一个更新条数为 0抛出“已约满”库存不会被扣成负数。decreaseRemaining的 SQL 里必须带条件remaining_count 0如果 SQL 只写id?不判断余量就会出现余量越界。Transactional(rollbackFor Exception.class)很关键默认只回滚 RuntimeException自定义的BizException继承 RuntimeException 才能让整个事务回滚。3.4 Controller层与统一返回Controller 层不要写复杂逻辑参数用 DTO 接收校验返回统一结构体。RestController RequestMapping(/api/order) RequiredArgsConstructor public class AppointmentController { private final AppointmentService appointmentService; PostMapping public ApiResultLong create(RequestBody Valid OrderCreateRequest request) { Long orderId appointmentService.createOrder( SecurityUtils.getUserId(), request.getScheduleId()); return ApiResult.success(orderId); } }订单跟用户端权限体系绑定不要直接把userId放在请求体里让前端传而是从当前登录状态取。如果项目还没接权限框架就用拦截器在请求头里解析登录态之后放进ThreadLocal后面 Service 层统一调SecurityUtils.getUserId()。OrderCreateRequest里的scheduleId加NotNull避免空参数走到数据库层才报错。前后端分离场景下ApiResult固定带code/message/data三个字段前端拦截器统一处理业务码。4. 体育馆预约的并发冲突控制锁、事务与幂等兜底4.1 并发问题从哪里来预约系统的并发冲突不在插入订单时而集中在扣减库存和状态更新上。第 3 章的decreaseRemaining能保证不超卖但用户取消、改期和支付回调的状态更新如果没加锁会出现把已取消订单改成已支付、两个请求同时取消同一订单。解决冲突的思路有三层数据库悲观锁做串行化业务里用 Redis 分布式锁做跨实例防重唯一索引做最终兜底。三层作用域不一样不能互相替代。4.2 乐观锁、悲观锁与分布式锁的取舍方案实现成本并发表现优缺点适合场景乐观锁 version低加一列即可冲突多时失败率高无锁等待重试成本高取消订单、更新资料悲观锁 SELECT FOR UPDATE中注意锁顺序串行吞吐下降等待阻塞事务内释放支付回调、状态反转Redis 分布式锁中高引入额外组件依赖锁粒度设计需处理过期和误删多实例部署时防重库存扣减用带余量判断的条件更新就足够真正的状态反转待支付→已支付、待支付→已取消才更容易出现丢失更新这部分用悲观锁或 Redis 锁保护。新人常犯的错误是拿到哪种锁都在 Controller 层加一遍结果锁没跨事务线程 A 在事务提交前已经释放锁线程 B 进来读到旧数据。锁一定要和事务边界对齐。4.3 用 SELECT FOR UPDATE 保护状态更新支付回调要同时更新订单和场次两个操作不在一个事务里就会出现“支付成功但场次状态没确认”。在事务方法的第一步锁住场次行可以防止两个回调同时处理同一张订单。SELECT id, remaining_count, status, start_time FROM schedule WHERE id #{scheduleId} FOR UPDATE这段 SQL 放在事务方法内第一步执行FOR UPDATE会让该行被当前事务持锁直到事务结束。两个线程并发进入时第二个线程会在 SELECT 处等待事务 A 提交后它读到的是更新后的数据。代价是这一段变成串行对预约这种短事务来说吞吐足够。注意FOR UPDATE只在 SELECT 查出记录时生效场次不存在会锁不上任何行需要先判空再往下走。4.4 Redis分布式锁解决跨实例防重当多个服务实例共用一台 MySQL数据库的行锁能保证单行正确但支付回调重复通知这类场景需要更大的锁粒度。用 SpringBoot 整合 Redis 实现一个足够简单的锁public T T runWithLock(String lockKey, Duration expire, SupplierT action) { String uuid UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, uuid, expire); if (!Boolean.TRUE.equals(locked)) { throw new BizException(20002, 系统繁忙请稍后重试); } try { return action.get(); } finally { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), List.of(lockKey), uuid); } }setIfAbsent是原子操作锁的 key 拼上业务对象 ID比如lock:order_pay:{orderId}过期时间建议 3 到 10 秒锁内部的实际操作必须远小于这个阈值。释放锁时带上 UUID 判断只有持有锁的线程能删除避免线程 A 超时后线程 B 获取锁A 再误释放 B 的锁。释放用 Lua 脚本保证 get 和 del 两步原子不要写成先 get 再 delete 的两段 Java 代码。使用案例放在支付回调里先获取锁执行状态从 0 到 1 的更新再释放锁。锁内部只放短促的数据库单行更新发短信、消息通知这类耗时操作不要塞进来否则锁过期时间要开很大吞吐下降。4.4.1 唯一索引的兜底与取消恢复无论加了什么锁网络超时、定时任务重试都可能导致同一条消息被执行两次。表结构里的uk_user_schedule就是对“同一用户下单同一场次”的最后一道防线。但取消订单以后用户要能重新预约这个唯一索引会成为绊脚石。常见做法不是物理删除订单而是给订单表增加deleted BIGINT字段默认 0唯一索引改成(user_id, schedule_id, deleted)取消时把deleted更新成当前时间戳。因为时间戳永不重复取消后的记录不会占住唯一键重新预约也就不会撞索引。查询订单时过滤deleted 0历史记录保留对账也有依据。4.4.2 死锁与隔离级别FOR UPDATE使用时要注意不同接口的调用顺序一致。取消接口先锁场次再更新订单支付接口也先锁场次再更新订单两个事务并发时才不会互相等待。事务隔离级别用默认的READ_COMMITTED不要开到SERIALIZABLE后者会让区间锁范围放大把简单预约接口拖成慢查询。死锁发生时 MySQL 返回 1213 错误代码里对DeadlockLoserDataAccessException做重试最多两次重试之间加随机退避。5. 预约系统进阶未支付订单自动释放、压测与验证5.1 用SpringBoot定时任务释放未支付订单创建订单时已经把expire_time写进数据库最简单可靠的做法是每分钟扫描一次超时订单而不是依赖 Redis 过期事件。Redis 的过期事件需要客户端订阅notify-keyspace-events默认关闭生产环境里并不保险。Scheduled(cron 0 * * * * ?) Transactional(rollbackFor Exception.class) public void releaseExpiredOrders() { ListAppointmentOrder orders orderMapper.selectExpired(0); for (AppointmentOrder order : orders) { int updated orderMapper.cancelIfPending(order.getId()); if (updated 1) { scheduleMapper.increaseRemaining(order.getScheduleId(), 1); } } }selectExpired查询status 0 AND expire_time now()cancelIfPending更新时带上status 0保证两个定时任务节点并发跑时只有一条更新成功另一个更新数为 0不会重复释放库存。订单取消和库存回补放在同一个事务里任何一步失败都会回滚。cron 0 * * * * ?表示每分钟的第 0 秒执行订单量大时可以改成每 10 秒一次但单次扫描数据量会上升SQL 里避免嵌套子查询。5.2 用并发脚本打一轮预约接口用 bash 和 xargs 模拟 100 个并发请求可以快速验证锁和事务是否生效seq 1 100 | xargs -P 20 -I {} curl -s -X POST \ -H Content-Type: application/json \ -d {scheduleId:10086} \ http://127.0.0.1:8080/api/order \ -w %{http_code} %{time_total}s\n-P 20控制同时 20 个进程避免测试机自己先成为瓶颈。正确的结果应该是约 8 个成功对应剩余名额其余全部是“场次已约满”的业务码HTTP 状态码依然 200。如果出现 500 或主键冲突优先排查事务边界是不是把FOR UPDATE放在了事务外以及 SQL 是否带了余量条件。5.3 长整型精度与缓存预热订单 ID 是雪花 ID 生成的 19 位 Long返回到前端后 JS 的 Number 会丢失精度在字段上增加JsonSerialize(using ToStringSerializer.class)或者全局配置把 Long 序列化成字符串否则用户取消请求传回的 ID 可能对不上。另一个是热门场次列表的缓存问题每天生成场次后把当天全部场次缓存到 Redis按场地维度加版本号管理端修改场次时立即删除对应缓存查询时用SETNX做空值缓存防击穿。如果引入了spring-boot-actuator记得关闭或限制 heapdump 端点避免 SpringBoot 进程的敏感信息被直接下载。压测时优先盯remaining_count变化与订单数是否一致对不齐基本就是释放任务或回补逻辑写错比看接口耗时更能定位问题。本文还有配套的精品资源点击获取
RELATED

相关推荐

MySQL学习路线图:从入门SQL到索引与事务原理的进阶指南

MySQL学习路线图:从入门SQL到索引与事务原理的进阶指南

说实话,我在带新人和做技术评审的这些年里,见过太多人在 MySQL 学习上栽跟头:SQL 能写出来,但一问索引为什么生效、事务隔离级别怎么选、一条慢查询怎么分析,就支支吾吾说不清楚。倒不是大家不努力,而是资料…

📅 2026/9/16 2:22:03
电脑变卡别急着花钱,手把手教你从U盘重装Windows 10/11系统

电脑变卡别急着花钱,手把手教你从U盘重装Windows 10/11系统

电脑用久了变卡、弹窗广告一堆、蓝屏死机频繁,很多人第一反应就是拿去电脑店花几十上百块重装系统。其实重装系统这件事,说难不难,说简单也不算简单,核心就三步:做一个启动U盘、进BIOS引导、按向导装完。只要搞清楚这三…

📅 2026/9/16 2:22:03
智能写作系统如何提升学术论文效率与质量

智能写作系统如何提升学术论文效率与质量

1. 项目背景与核心价值去年指导研究生论文时,我发现一个有趣现象:超过80%的学生在开题阶段要花费2-3周时间反复修改框架,而其中60%的修改都集中在文献综述和方法论部分。这正是PaperXie智能写作系统要解决的核心痛点——通过结构化拆解学术写…

📅 2026/9/16 2:17:02
MORE NEWS

更多资讯

📰

AI论文写作技巧与最新研究进展实用指南

作为研究生,我们的日常生活往往被繁重的文献查阅、数据分析和论文写作所占据。在这些任务中,最耗时且高效性难以保证的,莫过于论文写作了。幸运的是,随着技术的进步,许多学术工具的出现,极大地提升了我们写…

📰

用 Docker 搭建 Rocky Linux 基础镜像平台:CentOS 停更后的 RHEL 兼容方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

MySQL入门实操:基本概念与Workbench图形工具使用全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

轻度卒中预后预测:空间放射组学与可解释机器学习模型解析

我先把这篇文章读了好几遍才动笔。这几年经手过不少卒中相关项目,从影像组学到深度学习模型都摸过一轮,看到“轻度卒中”和“空间放射组学”组合在一起,还是觉得值得单独写一篇拆解。1. 轻度卒中的“预后悖论”:为什么传统评估经常…

📰

数据库表大小查询实战:MySQL、Oracle、SQL Server、PostgreSQL 命令汇总

屠龙刀法系列到这篇已经是第三十六篇了,本来想写点更冷门的东西,但后台好几个读者都在问同一个问题:怎么查看不同数据库的表格大小。这个问题表面上很基础,真遇到的时候才麻烦。换库排障要查,磁盘快满要查,…

📰

3个免费工具搞定wordpress富文本表单,让官网访客主动留资

3个免费工具搞定wordpress富文本表单,让官网访客主动留资 网站做好了没人访问,比没做还让人焦虑。你盯着后台那惨淡的UV数据,心里直打鼓:是不是SEO没做好?还是内容太干瘪?其实,很多时候问题出在“交互”上。访客来了,看了一眼,觉得填…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬