Spring Boot图书馆座位预约系统实战:并发控制与状态机设计
发布时间:2026/10/10 3:23:15 锦皓数字建站

只要在大学图书馆待过几年对“考研季一座难求、平时座位一堆空”这种反差多半不陌生。真正的问题从来不是座位不够而是信息不透明哪些座位被占着、哪些被贴了书就永远“有人”、预约了又不到场的人如何处理。我去年用一个典型的 Spring Boot 技术栈做了一套图书馆座位预约系统从需求梳理到部署上线前后折腾了一个多月中间踩了不少并发和状态管理的坑。这篇就按项目的完整开发链路来复盘从数据库设计、核心预约流程、管理后台可视化到常见问题排查把能直接抄作业的部分都写出来给正在做毕设或者想给自习室做预约工具的朋友做个参考。1. 从需求到模块座位预约系统到底在设计什么1.1 先搞清楚核心痛点与业务场景座位预约系统的本质不是一个 CRUD而是一套“资源在时间维度上的分配”系统。图书馆座位是典型的有限公共资源用户希望的是来之前能确认有空位、预约之后座位真实保留、到点不来会被释放管理员希望能看到实时占用情况并处理违约。我梳理需求时不急着建表而是把日常占座流程画成一条链路查询可预约座位→提交预约→到场签到→入座使用→离席释放中间还穿插取消预约、超时未签到自动释放、闭馆时统一清理。这条链路里的每一个节点都是一个状态转换而状态转换过程中最怕的就是两个用户同时在抢一个座位。1.2 功能清单拆解前台预约、后台管理、通用底账按使用角色拆分系统主要分三块。用户端要提供登录注册、按楼层/区域查看座位实时状态、选择时间段预约、扫码或手动签到、取消预约、查看个人预约记录与违约次数。管理端要提供座位与区域维护、预约记录查询、违约规则配置、基础统计看板。底层还必须有系统配置比如预约提前时间、签到时限、违约阈值这些不要写死在代码里。额外提一个容易被忽略的点有些人只是临时来自习不想预约所以系统还应该支持“现场选座”模式管理员可配置某区域是否允许现场直接入座并扫码登记。这个需求一开始没做后来被图书馆老师反复提好在表结构预留了 seat type 字段加一个状态流转就完成了。1.3 数据库模型怎么设计才不返工这套系统核心就四张表设计上我认为最值得花心思的是状态字段如何使用。先给出我当时落地的核心表结构。CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, nickname VARCHAR(64), role TINYINT DEFAULT 0 COMMENT 0-用户 1-管理员, credit_score INT DEFAULT 100 COMMENT 信用分用于违约惩罚, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE seat ( id BIGINT AUTO_INCREMENT PRIMARY KEY, floor TINYINT NOT NULL, area VARCHAR(64), seat_no VARCHAR(32), seat_type TINYINT DEFAULT 0 COMMENT 0-预约座位 1-现场座位, status TINYINT DEFAULT 0 COMMENT 0-空闲 1-已预约 2-使用中 3-锁定, x INT DEFAULT 0, y INT DEFAULT 0, version INT DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE reservation ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, reserve_date DATE NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-待签到 1-使用中 2-已完成 3-已取消 4-超时释放 5-违约, sign_time DATETIME, release_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );座位表里留了 x 和 y 两个坐标字段是为了管理后台画座位图。初期不做可视化时可以不加但座位号反而容易在楼层变化时改来改去坐标位置相对稳定所以从设计上我更推荐提前预留。很多同学喜欢把“开始时间、结束时间”存成字符串我建议直接用 datetime方便后面 SQL 做时段统计。另一个容易被忽略的索引问题是reservation 表查询经常按 user_id、seat_id、reserve_date 过滤这三个字段建议建联合索引否则预约记录一多后台查询就很慢。2. 技术选型背后的思考为什么是 Spring Boot 配这套组件2.1 Spring Boot 版本选择高版本迁移到底坑在哪现在做新项目我推荐直接上 Spring Boot 3.x但如果你是在课程要求或已有代码基础的情况下做毕设Spring Boot 2.7 也完全够用。这里必须聊一下“springboot 版本太高”这个热词产生的原因。Spring Boot 3.0 开始底层从 Java EE 规范迁移到了 Jakarta EE 9最直观的影响是包名变了javax.servlet 改成 jakarta.servletjavax.persistence 改成 jakarta.persistence。如果你从 2.x 项目升级以前的拦截器、过滤器、JPA 实体类全部要改 import。另外 3.x 强制要求 JDK 17很多老旧的第三方 starter 比如 springfoxSwagger 2 的那套直接不兼容我那时换成 springdoc-openapi 才解决接口文档问题。如果你刚从教程里复制了一个 Spring Boot 2.x 的代码又在本地装了 JDK 17 和最新版 IDEA直接把老项目跑起来通常没问题但一旦想加 springboot 官方推荐的新依赖就很容易遇到版本冲突。我的建议很简单新项目用 Spring Boot 3.2.x JDK 17老教程只作为思路参考依赖坐标以 Maven 仓库里实际能解析到的 latest stable 为准。2.2 数据访问层选型MyBatis-Plus 还是 JPA这个项目我做数据访问层时选了 MyBatis-Plus理由很实际系统里有大量“按条件分页查预约记录”“按状态批量更新座位”这类操作MyBatis-Plus 提供的 LambdaQueryWrapper 和分页插件能省非常多样板代码而且团队里大多数人对 XML 映射更熟悉出了问题好排查。我见过不少项目用 JPA 做这类系统实体关系映射确实写起来简洁但如果业务里出现了动态条件组合和复杂统计 SQLJPA 的 Query 方法名和 Specification 反而绕。座位预约系统本质上还是“状态流转 统计报表”对 SQL 的可控性要求高所以 MyBatis-Plus 更顺手。数据访问层有一个细节容易忽略逻辑删除。座位和预约记录不建议物理删除尤其是预约流水后面要算违约率和座位利用率。MyBatis-Plus 配置 TableLogic 后delete 操作会自动变成 update 置删除标记这在小项目里很实用但用的时候一定要记住所有查询得带上逻辑删除条件MP 默认会帮你加如果你手写 XML 反而要注意。2.3 不止是 CRUDRedis、WebSocket、定时任务各扛一块这个系统不只是把数据存进库里它有三块相对高并发或实时的能力需要专门组件支撑。Redis 主要干三件事缓存座位状态减少数据库压力用 setnx 做分布式锁防止预约时同一座位被并发抢把预约成功后的签到凭证短缓存方便签到接口快速校验。如果项目部署规模不大理论上不用 Redis 也能通过数据库乐观锁完成预约但每次查询都打数据库响应速度在高峰期会明显变慢。WebSocket 是为了让管理后台的座位图实时刷新。刚开始我用的是前端定时轮询每 5 秒拉一次座位状态接口区域小还好座位一多页面就经常闪动。换成本地 WebSocket 推送后管理员端能实时看到有人签到、释放体验好非常明显。定时任务这里我用了 Spring 自带的 Scheduled主要跑三个任务超时未签到释放座位、闭馆后批量清理当日预约记录、每日统计上座率。如果你的部署环境有多实例Scheduled 会重复执行这时候需要一个分布式锁来保证同一时刻只有一个节点在跑任务比较轻量的方案是集成 ShedLock或者用 Redis 的 setnx 自己做简单互斥。3. 核心功能与关键流程的实现3.1 预约接口座位状态如何保证不超卖座位预约系统最核心的接口就是预约它的难点不在于 SQL 多复杂而在于并发下不能出现“两个人都约成功了同一个座位”。我先说一个最容易想到但千万别用的方案查询座位状态为 0然后插入预约记录再更新座位状态。这个流程在单用户下没问题并发一上来两个请求同时查询到状态为 0都会继续执行后续逻辑结果超卖。要解决必须把“检查和更新”做成原子操作。我当时写的是直接在 SQL 层面用乐观锁做原子更新Transactional public ReservationResult reserve(ReserveRequest request) { // 1. 先查出符合用户预约时间段的可用座位列表 // 2. 用户选择一个座位后执行原子更新 int updated seatMapper.updateByVersion( request.getSeatId(), SeatStatus.RESERVED.getCode(), SeatStatus.FREE.getCode() ); // update seat set status 1, version version 1 // where id ? and status 0 and version ? if (updated 0) { throw new BizException(座位刚刚被预约请重新选择); } // 3. 写入预约记录 reservationMapper.insert(reservation); }这里有一个关键点SQL 更新条件里必须带“status 0”更新行数为 1 才说明抢座成功为 0 就说明座位状态已经变化。乐观锁字段 version 可以不加直接以 status 作为条件也能完成原子操作加 version 主要是为了更新时的冲突排查。事务上要注意update 操作和 insert 操作必须在同一个事务里否则可能出现座位状态已改为已预约但预约记录没写入的情况。我犯过的错是把 seat 更新和 reservation 插入放在两个方法里由于 Spring 事务默认只对 public 方法生效如果通过 this.xxx() 方式在类内部调用事务会失效。这个问题放到后面排查章节再细说。3.2 签到与离座状态机的流转设计座位不是只有“空闲”和“占用”两个状态。我建了一张状态流转图来辅助编码这里用文字描述一下空闲0→ 已预约1用户预约成功已预约1→ 使用中2用户在约定时间内签到已预约1→ 空闲0用户取消预约或超时未签到被释放使用中2→ 空闲0用户离座签退或管理员手动释放任何状态 → 锁定3管理员对座位进行维护签到接口同样存在并发问题用户可能在手机上点了两次签到如果不做防重就会生成两条签到流水。我的处理是给 reservation 表加唯一约束reservation_id sign_time 之类或者更简单一点签到 SQL 直接带状态条件Transactional public void signIn(Long reservationId, Long userId) { int updated reservationMapper.updateStatus( reservationId, userId, ReservationStatus.IN_USE.getCode(), ReservationStatus.PENDING_SIGN.getCode() ); // update reservation set status 2, sign_time now() // where id ? and user_id ? and status 1 if (updated 0) { throw new BizException(签到失败预约状态已发生变化); } // 远程调用座位状态更新这里也走原子更新 seatMapper.updateStatusBySeatId(seatId, SeatStatus.IN_USE.getCode()); }状态机设计最忌讳的是在业务代码里到处 if 判断后直接 update 任意状态。我一开始偷懒用户取消接口没做状态校验导致一个“已完成”的预约还能被用户再次取消数据库里就出现了逻辑矛盾。后来所有状态变更统一收敛到 service 层入口处先判断当前状态能不能流转到目标状态再执行 SQL。3.3 超时释放与定时任务别把所有鸡蛋放在一个调度器里预约系统一个特色场景是“预约了但人没来”这类座位如果不处理会长时间被无效占用。我当时定的规则是预约开始时间前 30 分钟必须签到否则系统自动释放座位并记录一次违约。这个判断如果只靠定时任务每分钟扫一次数据库高峰期会有少量延迟但对图书馆场景可以接受。代码也不复杂Scheduled(cron 0 */1 * * * ?) public void releaseExpiredReservation() { ListReservation expiredList reservationMapper.selectExpiredPendingSign( LocalDateTime.now().minusMinutes(30) ); for (Reservation r : expiredList) { try { releaseByTimeout(r.getId()); } catch (Exception e) { log.error(释放超时预约失败, reservationId{}, r.getId(), e); } } }这里有两个注意点。一是任务跑批一定要考虑失败重试我当时没加 try-catch某天数据库连接池短暂抖动一批预约该释放的没释放用户投诉后才意识到批处理任务必须有单条失败隔离。二是如果你用了 Redis 分布式锁超时释放和用户主动取消可能同时触发两层逻辑要共用同一个状态校验本质上还是“状态只有匹配才能流转”。4. 管理后台与实时可视化管理员看不到实时状态就白做4.1 座位图可视化与区域管理管理后台我最想做的事情是让管理员打开页面就能像看考场分布图一样看清每个座位的使用情况。实现上不复杂座位表的 x、y 坐标字段在前端渲染时非常有用。前端我用 Vue 3 的简单 grid 布局根据楼层和区域分组每个座位渲染成一个 div 方块颜色对应状态绿色空闲、黄色已预约、红色使用中、灰色锁定。点击座位弹窗显示详情管理员可以直接改状态或查看当前预约人。这里提一个数据结构的细节后端给前端的座位列表最好按“区域 座位号”排序同时包含楼层信息。一开始我把 floor 和 area 做成两个关联表后来发现座位数量不大反而是一张 seat 表里冗余 floor 和 area 字段更直接管理员筛选时一个接口就能返回所有数据前端少做几次联动请求。4.2 WebSocket 推送从轮询到主动通知实时状态这块我早期方案是前端每隔几秒轮询一次 /seat/status 接口实现简单但体验差而且接口压力不小。后来在 Spring Boot 里集成 WebSocket管理员端订阅座位状态通道每当预约、签到、释放发生服务端主动推送该区域的座位状态变化。核心配置可以这样写Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new SeatStatusHandler(), /ws/seatStatus) .setAllowedOrigins(*); } }后端在业务代码里比如用户签到成功后调一个 WebSocket 工具类把变更后的座位状态广播到指定区域频道。前端收到消息后局部更新对应座位块而不是整页刷新。需要注意的点是 WebSocket 长连接会占资源教室环境几十个并发没问题如果以后扩展到全校几千人同时在线建议网关层再设计一层消息推送比如集成消息中间件做广播。4.3 统计报表与违约记录把预约数据变成管理依据这套系统上线后最有价值的反馈来自统计报表。我做了三张简单的报表按日/周/月统计预约人次、按时间段统计座位利用率、按用户统计违约次数。统计 SQL 不需要太复杂例如按小时统计平均使用率SELECT HOUR(start_time) AS hour, COUNT(*) AS reserve_count, SUM(CASE WHEN status 2 THEN 1 ELSE 0 END) AS actually_used FROM reservation WHERE reserve_date BETWEEN #{begin} AND #{end} GROUP BY HOUR(start_time) ORDER BY hour;报表数据查询频率低不用做缓存直接查库即可。前面预留的 reserve_date 字段在这里发挥了作用按日期查询时索引效果很好。违约规则我也做成了系统配置表比如连续违约 3 次禁用预约 7 天这样一个简单的信用体系就跑起来了管理员不用每天人工盯名单。5. 从本地运行到部署常见问题与排查思路5.1 环境配置类问题数据库连不上、Redis 没启动开发中最多的报错反而来自环境。Spring Boot 项目启动时报 Failed to configure a DataSource最常见的两个原因是配置文件里数据库地址写错或者 MySQL 没启动。/he 还有一个小坑application.yml 里用了${MYSQL_HOST:localhost}这种占位符如果环境变量缺失会启动失败但控制台提示往往不直观需要看详细堆栈。Redis 连接失败同样常见。如果你是本地安装了 Redis先确认服务是否启动如果用的是云数据库 Redis还必须配置密码和正确的连接超时时间。我建议在开发环境把 Redis 序列化方式统一成 JSON避免在控制台里看到一串乱码排查半天。5.2 事务、锁与缓存一致性并发模块的经典三连坑高并发模块里最容易出现的三个现象我做一下速查分析。第一事务注解不生效。前面提到过在同一个类中方法自调用this.xxx()时Spring AOP 代理不会拦截Transactional 就变成普通方法。解决方法是把需要事务的方法放到另一个 Service 类里或者通过自注入代理对象调用。第二乐观锁更新失败后没有处理。如果你 update 返回 0 但业务代码没抛异常用户会看到“预约成功”但库里没有记录。所有返回更新行数的方法都必须判断结果这是最基本也最容易漏掉的。第三缓存与数据库不一致。因为预约、取消、释放都会改变座位状态如果只更新数据库没更新 Redis 缓存前端仍显示旧状态。我给自己的建议是写操作走数据库读多写少的座位状态走 Redis但任何写操作成功后同步刷新对应 key不搞复杂的双写一致性方案。5.3 高版本 Spring Boot 兼容性排查如果你用的 Spring Boot 版本比较高项目里又有老牌第三方库常常会遇到接口文档出不来、静态资源配置失效这类问题。我列一个自己的排查顺序先看启动日志有没有 Bean 创建失败的异常通常是依赖版本冲突用mvn dependency:tree检查冲突 jar 包如果涉及 Swagger/接口文档优先替换成 springdoc-openapi如果遇到 javax 包名报错说明依赖是从旧版迁来的改成 jakarta 包名再补一个热词相关的小点很多人关心 Spring Boot 能不能不内置 Tomcat。可以把 spring-boot-starter-web 里的 tomcat 依赖排除掉换成 undertow 或 jetty启动方式和接口写法完全不变。对低并发场景内置 Tomcat 完全够用不必折腾。5.4 部署与代码规范建议这个项目我最终是通过宝塔面板部署的前端打包成静态文件交给 Nginx后端用 Maven 打成 jar 包跑在系统服务里。如果你也想快速部署建议直接写一个简单的部署脚本包含打包、上传、重启三个步骤避免每次手动敲命令。代码规范方面我强烈建议哪怕是一个人写也分好包controller、service、mapper、entity、dto、common。不要图省事把所有类堆在同一个包下否则后面排查问题时找类都很痛苦。另一个建议是统一返回结果比如定义一个ResultT包装类前端处理起来会轻松很多。6. 如果再让我重写一次我会在哪些地方做优化做完这个系统后我反思过一阵有些设计当时觉得“够用就行”后面测试和真实使用中还是暴露了一些可优化空间。第一是预约时段设计。我最初做的是“可选开始时间和使用时长”用户操作自由度高但座位图上的状态会非常碎片化而且很难防止一个人连续预约多天。后来改成固定时段比如上午、下午、晚间三个大段规则更清晰数据库里也不会出现交叉重叠的预约记录校验冲突的 SQL 也简单不少。第二是消息通知。超时提醒、预约成功提醒目前只靠页面提示实际使用中很多人预约完就关页面根本看不到。后续最优先补齐的是邮件或企业微信通知在定时任务扫到“即将超时”时主动触达用户能显著减少违约率。第三是扫码签到。我之前用的是手动输入预约号管理起来总有输错风险。用 zxing 生成二维码把预约 ID 编码进去前端扫码后调签到接口整体体验会自然很多。这个改动不涉及表结构变更只是增加一个扫码解析入口属于性价比很高的优化。从技术积累的角度看这类“资源预约系统”的本质非常通用——会议室预约、实验设备预约、面试间预约甚至停车位预约核心逻辑都是同一套状态机加并发控制。你在图书馆座位这个场景里把事务、锁、定时任务、实时推送这些基本功练扎实了换到其他资源管理需求基本就是换皮换字段。对我个人来说这个项目最大的收获不是用了多少新技术而是明白了在业务里“一个状态字段的流转”要比“用什么数据库、什么 ORM”重要得多前者才是系统不出逻辑错误的底线。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。