SpringBoot体育馆预约系统实战:从表设计到并发控制
简介这套基于SpringBoot框架实现的体育馆预约管理系统是一份面向计算机科学与技术、电子信息工程等专业学生的完整项目参考方案适用于毕业设计、课程项目或期末作业等场景。系统采用浏览器与服务器B/S结构综合运用Java语言、SpringBoot框架、MyBatis持久化技术、Ajax异步交互和Vue前端框架并配合MySQL数据库与Tomcat应用服务器完整覆盖场馆信息展示、预约时间选择、订单生成与管理等典型业务场景还内置了一键安装、启动脚本方便本地快速部署运行。资源压缩包共包含900个文件以SVG图标、JavaScript脚本、Java源码、Vue组件、CSS样式、HTML页面及数据库备份文件为主压缩后整体约17.61MB目录结构清晰下载后即可对照学习。目前已有58人学习代码经过充分测试功能稳定不仅适合学生直接参考、二次开发或用于答辩准备也能帮助开发者快速掌握SpringBoot前后端分离项目的实际落地方式。1. 从排队登记到手机订场SpringBoot把体育馆预约做成闭环大多数体育馆还在用微信群接龙或前台Excel表排场地高峰期漏记、重复订、退款对不上账是常态。基于SpringBoot的体育馆预约管理系统核心是让用户在线选定日期和时段、提交订单、支付或锁场同时管理员能在后台看每个场地的空闲情况。这套系统不复杂但很能检验工程能力实体关系设计、接口参数校验、定时任务、并发控制都得依次落地。适合拿来做毕设、中小型场馆的数字化管理或者作为SpringBoot全家桶的练习项目。本文按我通常做这类系统的思路从表结构搭起到预约接口、防超卖、最后上线前要处理的边界问题都会给出能直接抄的代码和参数。2. 预约系统的数据模型与SpringBoot工程骨架搭建2.1 场地、时段与预约单先拆清楚三张核心表体育馆预约的领域模型不复杂但很多新手一上来就把所有信息塞进一张预约表导致后面查询时段是否可订非常别扭。常见做法是分成三张主表场地表venue、时段表time_slot、预约订单表booking_order。表名关键字段作用venueid、name、location、type、status记录羽毛球馆、篮球馆、健身房等场地基础信息time_slotid、start_time、end_time、price预定义固定时段例如 10:00-12:00booking_orderid、venue_id、time_slot_id、booking_date、user_id、status、create_time记录某天某时段某场地的预约情况这里最值得注意的关系是一个场地在同一个日期和同一个时段只能被一个有效订单占用。也就是订单表需要唯一约束(venue_id, time_slot_id, booking_date)而且这个约束要尽量包含状态判断。状态字段一般用PENDING待支付、PAID已支付、CANCELLED已取消、COMPLETED已完成。只有待支付和已支付算占用已取消的订单不锁时段。我的设计习惯是加一个status字段后用数据库唯一性去防重复但唯一约束不能作用于部分状态所以通常另加occupied_flag或干脆用一个独立的时间段状态表。如果项目不大可以直接在查询里以status IN (PENDING,PAID)判断配合分布式锁后面第4章会讲。2.2 SpringBoot版本怎么选JDK版本直接决定依赖兼容性版本选择是这类项目第一个坑。现在搜索引擎里大量 “SpringBoot版本太高”“用的是JDK21想回退到1.8” 的提问本质都是没弄明白 SpringBoot 与 JDK 的对应关系。SpringBoot 3.x 要求 JDK 17 起跳并且javax.*改成jakarta.*很多网上抄来的老资料里的import javax.servlet在 3.x 里直接编译失败。如果是毕业设计或者中小型项目我通常建议直接选 SpringBoot 2.7.x例如 2.7.18JDK 用 8 或 11 都行。原因不是 3.x 不行而是 2.7 的资料量大、和 MyBatis-Plus、Redis 客户端等中间件兼容问题最少。如果本机已经装了 JDK 21也可以用 SpringBoot 3.2.x后面代码里注意用jakarta.*包即可。技术栈SpringBoot 2.7.xSpringBoot 3.2.xJDK 最低版本817命名空间javax.*jakarta.*推荐搭配MyBatis-Plus 3.5.xMyBatis-Plus 3.5.5适合场景毕设、快速开发、老服务器部署新项目、团队已熟悉 JDK172.3 IDEA创建SpringBoot项目后pom里必须出现的依赖在 IDEA 中新建项目时选 Spring Initializr地域选阿里云镜像通常更快。如果是在 start.spring.io 网页生成生成的工程默认就是 SpringBoot 3.x想用 2.7 需要手动切换版本。下面是我做体育馆预约项目常用的pom.xml片段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这段配置里要说明的是MyBatis-Plus 的mybatis-plus-boot-starter版本不是随便加的。使用 2.7.x 时3.5.3.1 及以下的兼容性更稳如果用了 SpringBoot 3.x请在 3.5.5 及以上版本中选择否则启动时会报ClassNotFoundException: javax.sql.DataSource或 MyBatis 相关类找不到。Lombok 加optionaltrue/optional是因为它只在编译期生效不需要进入最终 jar。2.4 用 MyBatis-Plus 一分钟写出场地查询接口业务还没做之前先用一个场地列表接口验证工程能跑通。实体类Venue直接用注解映射字段Mapper 接口继承BaseMapper就行。下面是典型的 Controller、Service 和 Mapper 写法。RestController RequestMapping(/api/venue) public class VenueController { Resource private VenueService venueService; GetMapping(/list) public RListVenue list() { return R.ok(venueService.listUsefulVenue()); } }Service public class VenueServiceImpl implements VenueService { Resource private VenueMapper venueMapper; Override public ListVenue listUsefulVenue() { LambdaQueryWrapperVenue wrapper new LambdaQueryWrapper(); wrapper.eq(Venue::getStatus, 1) .orderByAsc(Venue::getSortOrder); return venueMapper.selectList(wrapper); } }LambdaQueryWrapper的好处是不用拼字符串列名编译期就能发现字段名写错的问题。.eq(Venue::getStatus, 1)表示只查状态为1的场地状态的含义我约定为0禁用1启用。.orderByAsc可以让球馆排序不乱跳。前端是 Vue 项目时也可以直接约定这个接口返回的 JSON后端不掺和页面渲染。3. 预约业务闭环参数校验、库存扣减与定时回滚3.1 提交预约业务上先查后插还不够预约接口是整个系统中最容易出错的入口。最基础的实现是先查该场地该时段该日期是否存在有效订单没有则插入新订单。代码如下Transactional(rollbackFor Exception.class) public BookingOrder createOrder(BookingRequest request) { LambdaQueryWrapperBookingOrder query new LambdaQueryWrapper(); query.eq(BookingOrder::getVenueId, request.getVenueId()) .eq(BookingOrder::getTimeSlotId, request.getTimeSlotId()) .eq(BookingOrder::getBookingDate, request.getBookingDate()) .in(BookingOrder::getStatus, Arrays.asList(PENDING, PAID)); Long count bookingOrderMapper.selectCount(query); if (count 0) { throw new BizException(该时段已被预约); } BookingOrder order new BookingOrder(); order.setVenueId(request.getVenueId()); order.setTimeSlotId(request.getTimeSlotId()); order.setBookingDate(request.getBookingDate()); order.setUserId(request.getUserId()); order.setStatus(PENDING); order.setCreateTime(new Date()); bookingOrderMapper.insert(order); return order; }这段代码看起来没有错但并发场景下会出大问题两个人同时读到 count 为 0随后都能插入成功预约超卖。真正的兜底是数据库层面的唯一约束不能只靠应用层查询。解决思路是给booking_order表加唯一索引但唯一索引不能建在普通 status 上。我一般单独加一个字段表示占用标记occupy_key值组合为venueId timeSlotId bookingDate只有当订单处于有效状态时才写入取消时置空。这个方案在 MySQL 下可行如果你用 PostgreSQL可以改用部分唯一索引一行语句搞定。3.2 用 EnableScheduling 自动清理超时未支付订单用户提交了预约单但迟迟不付款这种待支付记录会一直占着时段让其他用户无法预约。常见的做法是设置超时时间比如 15 分钟未支付自动取消。SpringBoot 里可以用自带的Scheduled定时任务解决不需要额外引入 Quartz。Component public class OrderTimeoutTask { Resource private BookingOrderMapper bookingOrderMapper; Scheduled(fixedDelay 60000) public void cancelExpiredOrders() { Date deadline new Date(System.currentTimeMillis() - 15 * 60 * 1000); LambdaUpdateWrapperBookingOrder update new LambdaUpdateWrapper(); update.eq(BookingOrder::getStatus, PENDING) .lt(BookingOrder::getCreateTime, deadline) .set(BookingOrder::getStatus, CANCELLED); bookingOrderMapper.update(null, update); } }启动类上需要加上EnableScheduling否则任务不生效。fixedDelay 60000表示上一次任务执行完成后再等 60 秒继续执行适合这种低频清理任务。注意.lt(BookingOrder::getCreateTime, deadline)的含义是找出创建时间早于截止时间的数据也就是已经超时的单子。这里有一个容易踩的坑如果服务器与数据库处于不同时区new Date()在 JDBC 传参会按服务器时区转换应用和 MySQL 连接参数必须显式设置serverTimezoneAsia/Shanghai否则清理时间可能偏差数小时。3.3 用 Swagger 把接口文档直接交给前端预约系统通常要对接小程序或 Vue 页面接口列表不能靠手写文档。在 SpringBoot 2.7 中使用springfox的 2.9.2 版本已经是过去式那个版本存在 swagger-ui 未授权访问的问题而且和 SpringBoot 2.6 的请求匹配策略冲突需要额外改配置。我更推荐使用 springdoc-openapidependency groupIdorg.springdoc/groupId artifactIdspringdoc-openapi-ui/artifactId version1.7.0/version /dependency启动项目后访问/swagger-ui/index.html就能看到可用接口列表。为了不把接口文档暴露在生产环境我一般在配置里加springdoc: api-docs: enabled: true swagger-ui: enabled: true生产环境则手动把这两个值改成false或者在网关层面限制/swagger-ui/**和/v3/api-docs/**的访问。停车场、体育馆这类系统通常在内网使用但仍要防止外网直接访问文档泄露接口字段结构。4. 热门时段被秒抢并发一致性与SpringBoot兜底方案4.1 超卖问题到底是怎么发生的同一个热门时段比如周五晚上 7 点到 9 点的羽毛球场一旦开放预约QPS 不会特别高但并发足够触发经典的超卖。原因不是代码写得差而是“检查-插入”这个组合操作不是原子的。应用层经过了网络、线程调度两个请求可能在同一个数据库快照上做检查然后各自插入成功。要彻底解决必须先想明白锁粒度。synchronized 和 ReentrantLock 只对单实例进程内的线程有效一旦系统以后部署了多个实例做负载均衡本地锁就形同虚设。所以分布式环境下的常见做法是 Redis 分布式锁数据库乐观锁作为最后一道防线。4.2 用 Redis 分布式锁给预约时段加锁这里用 Redisson 封装的锁因为它自带看门狗不用手动处理锁过期和续期问题。先引入依赖再在预约逻辑外层加锁。dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.20.0/version /dependency业务代码中构建锁的 key 时要把场地、时段、日期都拼进去锁粒度越小并发能力越高。不要只锁 venueId那样会导致同一个场馆的所有时段全部串行化Resource private RedissonClient redissonClient; public BookingOrder createOrderWithLock(BookingRequest request) { String lockKey BOOKING_LOCK: request.getVenueId() : request.getTimeSlotId() : request.getBookingDate(); RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(2, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(当前预约人数较多请稍后重试); } try { return createOrder(request); } finally { lock.unlock(); } }tryLock(2, 30, TimeUnit.SECONDS)的意思是最多等待 2 秒获取锁获取成功后自动释放时间为 30 秒。体育馆预约操作非常轻量30 秒完全够用但也要注意如果业务里出现慢 SQL超时释放有可能在事务结束前发生导致其他线程拿到锁但读到旧状态。所以更稳的做法是事务提交后再释放锁或者把锁放到 Controller 层Service 只用事务。4.3 数据库乐观锁兜底宁可失败也不重复分布式锁可以挡住绝大多数并发请求但不能完全依赖外部中间件。最后的防线是给订单或时段表增加乐观锁字段。以时段占用为例给venue_time_slot表加一个version字段更新时检查版本号Update(UPDATE venue_time_slot SET occupied 1, version version 1 WHERE id #{slotId} AND version #{version}) int tryOccupySlot(Param(slotId) Long slotId, Param(version) Integer version);这里的关键是 SQL 的WHERE version #{version}如果期间有其他事务修改过这行数据的 version本次更新会匹配不到任何记录影响行数为 0然后业务层抛出“该时段刚刚被抢”的提示顺带触发用户重新选择时段。乐观锁不能避免无意义的并发请求占用数据库连接但它能保证最多只有一个请求真正写入成功。实际使用时我建议把乐观锁和分布式锁叠加优先拿分布式锁挡流量乐观锁防止极端情况或锁失效后的脏写。5. 验证与上线压测、日志与接口安全细节5.1 用 JMeter 快速压出预约接口的吞吐量系统上线前至少要知道接口的承受能力。JMeter 创建一个线程组设置 100 个线程、Ramp-Up 10 秒、循环 5 次就能模拟 100 个用户同时点击预约。重点关注聚合报告中的吞吐量和 p90 响应时间。体育馆预约场景正常规模下单机 300 并发足够如果压测发现大量请求卡在等待分布式锁要调整的是锁等待时间和 Redis 连接池配置而不是盲目加机器。吞吐量低于预期时先看数据库连接池是否成为瓶颈。5.2 上线前关掉 Actuator 暴露的 HeapDump 和 Swagger 文档SpringBoot 项目依赖spring-boot-starter-actuator后默认暴露的 endpoint 有限但一旦配置被错误放开/actuator/heapdump可能让攻击者下载应用的对内存快照从日志、数据库密码到业务 ID 全部泄露。安全配置如下management: endpoints: web: exposure: include: health,info这样生产环境只暴露健康检查接口。与此同时Swagger 文档在生产环境应当关闭。注意不能只隐藏入口页面要直接禁用/v3/api-docs这个数据接口否则别人拼接 URL 仍能拿到 JSON 格式的完整接口文档。5.3 HikariCP 三处连接池参数调整SpringBoot 默认使用 HikariCP体育馆预约系统的特点是并发峰值集中、闲时几乎无流量。针对这种场景我一般做三处配置调整spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000maximum-pool-size: 20是对应数据库实例规格的合理值开太大反而增加 MySQL 的上下文切换minimum-idle: 5保证闲时保留少量连接避免冷启动慢connection-timeout: 3000让业务方在数据库连接被占满时快速失败而不是无限等下去。预约业务本身单次只做一次插入和一次 update这个配置在 8 核 16G 的机器上能支撑常规场馆的使用量。上线后如果日志出现connection is not available优先看慢 SQL再看是不是连接池最大值被业务长事务占满。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →