Spring Boot自习室预约系统源码:从抢座乱象到高并发预约实现

发布时间:2026/10/7 14:07:45
Spring Boot自习室预约系统源码:从抢座乱象到高并发预约实现
简介这份源码资源面向计算机专业学生与Java Web开发者提供一套基于Spring Boot与MVC架构的自习室管理与预约系统完整实现可用于课程设计、毕业设计或全栈练手。系统分为前后台前台支持用户注册登录、自习室预约及座位与开放时段查询后台提供管理员登录、自习室信息增删改、用户管理以及预约记录的查看、修改与取消业务闭环较为完整。压缩包共828个文件约30.6MB包含121个Java后端源码、63个Vue组件、159个JavaScript脚本、47个HTML页面以及大量svg、gif、jpg、png等界面素材和css、scss样式文件另有sql建库脚本、yml配置、xml映射与bat启动脚本前端资源与后端分层结构清晰。目前已有52人学习下载适合希望理解预约类业务建模、权限划分与前后端联调流程的读者参考复用。1. 自习室预约系统从抢座乱象到一套能跑通的 Spring Boot 源码每到考试季图书馆门口排长队、座位被书本占着却没人来的场景几乎每个高校都上演过。自习室管理与预约系统要解决的就是这件事把座位状态、预约时段、违约记录这些原本靠人工登记的信息用一套后端服务管起来。这个标题指向的是一份基于 Spring Boot 框架的完整源码适合两类人——想拿一个真实业务练手 Spring Boot 全链路的开发者以及需要给学校或小型自习室快速搭一套预约后台的团队。它涉及的核心技术点包括 Spring Boot 的 REST 接口设计、MyBatis 数据访问、预约时段冲突校验、座位状态机以及定时任务处理超时未签到。下面按「先搞懂业务模型再动手跑通最后避开那些必踩的坑」的顺序展开中间会给到能直接抄的配置和代码片段。2. 预约业务建模座位、时段、订单三张表怎么设计才不返工2.1 先想清楚座位状态机再动手建表很多人拿到这类源码第一反应是打开 IDEA 直接跑结果发现预约逻辑对不上自己的场景。问题出在业务模型没对齐。自习室预约的本质是一个「有限资源 时间窗口 独占占用」的问题核心实体只有三个座位Seat、时段TimeSlot、预约订单Reservation。座位有物理属性所属自习室、座位编号、是否靠窗、有无电源时段定义了可预约的时间粒度常见是 1 小时或 2 小时一段订单则把用户、座位、时段三者绑定在一起。座位状态机是设计里最容易翻车的地方。一个座位在任意时刻只可能处于四种状态之一空闲、已预约未签到、使用中、维护中。状态流转必须由订单的生命周期驱动而不是让管理员手动改。常见做法是在 Reservation 表里存 status 字段0 待签到、1 已签到、2 已完成、3 已取消、4 违约座位本身的可用性通过查询「当前时段内是否存在有效订单」实时计算而不是在 Seat 表里冗余一个 is_occupied 字段。后者在并发场景下几乎必然出现状态不一致。提示如果你的场景允许跨时段连续预约时段表要设计成可拼接的否则用户约了 8:00-10:00 和 10:00-12:00 会被系统当成两个独立订单签到逻辑要额外处理。2.2 三张核心表的字段与索引下面给出我一般会用的建表结构字段命名贴近源码里常见的驼峰转下划线风格方便和 MyBatis 映射对上。-- 自习室表 CREATE TABLE study_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 自习室名称, location VARCHAR(128) COMMENT 位置描述, open_time TIME NOT NULL DEFAULT 08:00:00, close_time TIME NOT NULL DEFAULT 22:00:00, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放 0关闭 ); -- 座位表 CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, seat_no VARCHAR(16) NOT NULL COMMENT 座位编号如 A-01, has_power TINYINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可用 0维护, UNIQUE KEY uk_room_seat (room_id, seat_no) ); -- 预约订单表 CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, slot_date DATE NOT NULL COMMENT 预约日期, start_time TIME NOT NULL, end_time TIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待签到 1已签到 2已完成 3已取消 4违约, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, sign_in_time DATETIME DEFAULT NULL, KEY idx_seat_date (seat_id, slot_date, status), KEY idx_user_date (user_id, slot_date) );idx_seat_date这个联合索引是冲突校验的性能关键。判断某座位某天某时段是否已被占用时查询条件会同时命中 seat_id、slot_date 和 status走这个索引能把扫描行数压到个位数。idx_user_date则用于限制「同一用户同一天最多约几个时段」这类规则。注意 status 放在索引第三位是因为前两个字段的选择性已经足够高status 主要起过滤作用。2.3 时段冲突校验的 SQL 写法预约接口最核心的一步是判断目标时段是否和已有订单重叠。时间重叠的判定条件是「新开始 旧结束 且 新结束 旧开始」这个条件对开区间和闭区间都成立不需要额外处理边界。SELECT COUNT(*) FROM reservation WHERE seat_id #{seatId} AND slot_date #{slotDate} AND status IN (0, 1) -- 待签到和已签到都算占用 AND start_time #{endTime} AND end_time #{startTime};返回大于 0 就说明冲突直接抛业务异常。这里有个细节已取消3和违约4的订单不参与占用判断所以 status 的 IN 列表要写准。我见过有人图省事写成status ! 3结果违约订单也把座位锁死了用户投诉到怀疑人生。3. 用 Spring Boot MyBatis 把预约接口跑起来3.1 项目分层与依赖配置这类源码通常采用 Controller-Service-Mapper 三层结构配合 MyBatis 做数据访问。先看 pom.xml 里必须有的几个依赖版本按你本地仓库能拉到的稳定版填不要盲目追最新。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependenciesspring-boot-starter-validation容易被忽略但预约接口的入参校验日期不能是过去、时段必须在开放时间内靠它省很多手写 if。MyBatis starter 的版本要和 Spring Boot 主版本匹配2.3.x 对应 Spring Boot 3.x用错了启动直接报 NoSuchMethodError。application.yml 里除了数据源还要配 MyBatis 的映射文件位置和驼峰转换spring: datasource: url: jdbc:mysql://localhost:3306/study_room?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true default-fetch-size: 100map-underscore-to-camel-case: true让 seat_no 自动映射到 seatNo省掉大量 resultMap 配置。serverTimezone必须显式指定否则 MySQL 8 下时间字段会差 8 小时预约日期直接错位。3.2 预约创建接口的完整实现下面这段 Service 方法是整个系统的核心包含冲突校验、用户限额、订单落库三个步骤。Service public class ReservationService { Autowired private ReservationMapper reservationMapper; Autowired private SeatMapper seatMapper; private static final int MAX_DAILY_SLOTS 3; // 每人每天最多约3个时段 Transactional(rollbackFor Exception.class) public Long createReservation(Long userId, Long seatId, LocalDate date, LocalTime start, LocalTime end) { // 1. 校验座位是否可用 Seat seat seatMapper.selectById(seatId); if (seat null || seat.getStatus() 0) { throw new BizException(座位不存在或维护中); } // 2. 校验时段合法性 if (!start.isBefore(end)) { throw new BizException(结束时间必须晚于开始时间); } // 3. 冲突校验 int conflict reservationMapper.countConflict(seatId, date, start, end); if (conflict 0) { throw new BizException(该时段已被预约); } // 4. 用户当日限额 int userCount reservationMapper.countByUserAndDate(userId, date); if (userCount MAX_DAILY_SLOTS) { throw new BizException(当日预约次数已达上限); } // 5. 落库 Reservation r new Reservation(); r.setUserId(userId); r.setSeatId(seatId); r.setSlotDate(date); r.setStartTime(start); r.setEndTime(end); r.setStatus(0); reservationMapper.insert(r); return r.getId(); } }Transactional加rollbackFor Exception.class是必须的因为冲突校验和插入之间存在并发窗口。两个请求同时通过校验再同时插入就会产生重复预约。要彻底解决得靠数据库唯一约束或分布式锁但加事务至少能保证单次请求内的原子性。MAX_DAILY_SLOTS这类业务参数建议抽到配置中心或数据库硬编码在代码里改一次要重新打包。对应的 Mapper XML 里countConflict 就是前面那条重叠查询countByUserAndDate 则是按 user_id 和 slot_date 统计 status 为 0 或 1 的记录数。两个查询都很简单但索引必须建对否则并发一上来数据库 CPU 直接飙满。3.3 超时未签到的定时处理预约了不来是自习室管理最头疼的问题。常见做法是加一个定时任务把超过开始时间 30 分钟仍未签到的订单标记为违约释放座位。Component public class NoShowTask { Autowired private ReservationMapper reservationMapper; // 每10分钟执行一次 Scheduled(cron 0 0/10 * * * ?) public void markNoShow() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); int affected reservationMapper.markNoShow(deadline.toLocalDate(), deadline.toLocalTime()); if (affected 0) { // 记录日志便于排查 System.out.println(标记违约订单数: affected); } } }对应的 SQL 是UPDATE reservation SET status 4 WHERE status 0 AND CONCAT(slot_date, , start_time) #{deadline}。注意这里用 CONCAT 拼出完整时间做比较比分开比较日期和时间更直观。定时任务的 cron 表达式0 0/10 * * * ?表示每 10 分钟触发频率别设太高否则频繁扫表影响正常查询。启动类上要加EnableScheduling这个注解漏了任务不会报错只是静默不执行排查起来很费时间。4. 并发预约与状态一致性那些让座位「凭空消失」的坑4.1 冲突校验的并发漏洞与补救前面提到先查冲突再插入的写法在并发下会失效。假设两个用户同时预约 A-01 座位 14:00-16:00两个请求都查到 conflict0然后都执行 insert结果就是同一座位同一时段出现两条有效订单。用户到现场发现座位被占系统里却显示两个人都约成功了。补救方案有三个层次。最轻量的是在 reservation 表上加唯一索引把 seat_id、slot_date、start_time 组合成唯一键插入冲突时数据库直接报错Service 层捕获 DuplicateKeyException 转成友好提示。这个方案改动最小但只能防完全相同的时段对部分重叠无效。中等方案是用SELECT ... FOR UPDATE锁住座位行把并发串行化代价是吞吐下降。最重的是引入 Redis 分布式锁按 seat_id 加锁适合多实例部署。我一般先用唯一索引兜底业务量上来再考虑锁。4.2 签到接口的幂等处理签到接口被重复调用是常态用户手抖点两下、网络重试都会触发。如果签到逻辑写成「查到订单就更新状态并记录时间」重复调用会把 sign_in_time 覆盖成第二次的时间违约判定就乱了。正确做法是在 UPDATE 语句里带上状态条件UPDATE reservation SET status 1, sign_in_time NOW() WHERE id #{id} AND status 0。返回影响行数为 0 就说明订单不是待签到状态直接返回「请勿重复签到」。这种「条件更新 判断影响行数」的模式是幂等处理的通用套路比先查后改可靠得多。4.3 座位释放的时机订单取消或违约后座位什么时候重新可约如果靠定时任务批量释放会有延迟用户看到座位还是灰的。更好的做法是座位可用性完全由实时查询决定不存冗余状态。前端查可约座位时直接查「该时段内没有有效订单的座位」取消订单后下一次查询立刻就能看到。代价是查询稍复杂但避免了状态同步问题。如果非要缓存座位状态缓存过期时间要设短且取消订单时主动失效对应缓存。5. 部署与联调避坑从本地跑通到给别人用5.1 常见问题排查现象启动报错Failed to configure a DataSource。原因通常是 application.yml 没被加载或者数据源配置写在了错误的层级。检查 yml 缩进spring.datasource 必须是 spring 的直接子级。如果用了多环境配置确认spring.profiles.active指向的文件存在。现象预约时间存入数据库后差了 8 小时。原因是 JDBC URL 没加 serverTimezone或者加了但值写成了 UTC。MySQL 8 的驱动默认按服务器时区解析国内环境统一写serverTimezoneAsia/Shanghai。另外实体类里用 LocalDateTime 比 Date 更省心不受时区隐式转换影响。现象MyBatis 查询返回字段全是 null。先看map-underscore-to-camel-case是否开启再看 Mapper XML 里的 resultType 是否写成了实体类全限定名。如果 SQL 里用了别名别名要和实体属性名一致否则映射不上。现象定时任务不执行。检查启动类有没有EnableScheduling方法所在类有没有Component方法本身是不是 public。三者缺一任务都会静默失效没有任何报错。现象并发压测时出现重复预约。回到 4.1 的方案先加唯一索引再考虑锁。压测工具用 JMeter 或 wrk 都行重点看同一座位同一时段的并发请求是否只有一条成功。5.2 接口对外提供时的放置策略热搜里有人问 Spring Boot 对外接口该单独放一个服务还是放在对应模块里。对这个预约系统来说如果只是给学校内部的前端和管理后台用接口放在同一个应用里完全够按/api/reservation、/api/seat分路径就行。只有当第三方系统比如校园一卡通、门禁需要对接时才考虑把对外接口抽成独立模块用不同的鉴权方式和限流策略避免内部接口的权限模型被外部调用污染。抽离的时机是「外部调用方超过两个且鉴权需求不同」过早拆分只会增加部署复杂度。6. 把预约系统用起来几个让代码更耐用的技巧源码跑通只是起点真正投入使用还要处理一些边界。第一个技巧是给预约接口加防重放。用户快速点击提交按钮会产生多个相同请求除了前端置灰按钮后端可以用「用户 ID 座位 ID 时段」做短时缓存键60 秒内相同请求直接返回上一次的结果。这个用 Redis 的 setIfAbsent 一行就能实现比数据库唯一索引更早拦截。第二个技巧是把时段配置做成可调的。不同自习室的开放时间、时段粒度可能不一样硬编码在代码里后期维护很痛苦。建一张 time_slot_config 表存 room_id、start_time、end_time、slot_minutes预约时按配置生成可选时段。这样新增一个自习室只需要插数据不用改代码重新发版。第三个技巧是违约记录的软处理。直接标记违约会让用户反感可以做成「违约累计 3 次才限制预约一周」给一次申诉机会。实现上在 user 表加 violation_count 字段定时任务标记违约时累加预约接口校验时判断阈值。规则要能配置别写死。最后一个习惯每次改预约相关的逻辑先在本地用两条并发请求打同一个座位确认只有一条成功再提交。这个动作花不了两分钟但能挡住大部分状态一致性问题。我早期跳过这一步上线后被用户投诉座位冲突回头查日志才发现是并发插入血泪经验。希望帮到你。本文还有配套的精品资源点击获取