Java校园图书馆座位预约系统:并发控制与状态机落地

发布时间:2026/10/30 5:08:00
Java校园图书馆座位预约系统:并发控制与状态机落地 简介这是一份面向计算机相关专业毕业设计场景的Java校园图书馆座位预约系统管理程序适合正在选题、开发或准备答辩的学生参考。资源以zip压缩包交付整体约12.16MB包含程序源代码与数据库文件并附环境配置说明便于对照理解座位预约、用户管理、信息维护等模块的实现思路。内容围绕需求分析、系统功能设计、总体结构设计、数据结构设计、数据库访问、主要功能模块实现与功能测试展开可帮助读者建立较完整的管理系统开发认知。目前已有3244人学习说明该选题在同类毕业设计中具有一定参考价值。读者可用于梳理系统结构、学习Java管理系统开发方法并在此基础上进行功能扩展、二次开发或答辩准备。1. 需求切入校园图书馆座位预约系统为什么值得做成一套完整毕设做 java校园图书馆座位预约系统 的毕设答辩时最容易被问倒的一句话往往不是「代码写了多少行」而是「早上七点半开馆前三千人同时点预约你凭什么保证同一个座位不会被两个人占到」。这个问题一旦答不上来前面做的界面再漂亮也会显得单薄。校园图书馆座位预约系统的价值恰恰在这里它看起来是个增删改查的管理系统实际上把并发控制、状态机、定时任务、权限隔离这几件事全部挤在了一个小场景里做透一套等于把后端工程里最常见的几类问题各练了一遍。这套方案面向两类人正在准备毕设或课程设计、需要一个能跑起来又能讲清楚的项目的人以及已经在做类似预约类系统、想把并发和状态管理补扎实的从业者。下文按「需求边界 → 架构与数据模型 → 核心模块实现 → 避坑 → 验收答辩」的顺序推进每一章都落到可以直接抄改的结构和代码上。2. 需求梳理与技术选型从抢座冲突倒推功能边界需求这一步最容易走过场。很多人直接打开 IDE 开始建表写到一半发现「暂离」这个状态没设计前面写的预约逻辑全部要返工。我的习惯是先把「谁在什么时间点做什么操作」列成一句一句话再从中提炼出实体和状态。2.1 三类角色的真实诉求与功能边界学生端的动作链条其实很短选阅览室 → 看座位图 → 选时段 → 提交预约 → 到馆扫码签到 → 中途暂离 → 离馆释放。短归短每一步都有隐含约束。选时段要能看到「这个座位今天已经被谁占了哪几段」提交预约要能拦住「今天已经违约三次的人」签到要能判断「是不是超过 15 分钟没来」暂离要能限制「一次暂离最长 30 分钟超时自动释放」。管理端的诉求更偏运维维护阅览室和座位新增、停用、标记维修、查看实时占座情况、处理学生申诉、导出某段时间的使用率统计。辅导员或管理员还需要一个「黑名单」入口能手动解冻被违约规则冻结的账号。边界也要说清楚不然毕设会失控。这套系统通常不做在线支付、不做跨校区通借通还、不做人脸识别闸机联动可以留接口但不必实现。把范围收在「预约—签到—释放—违约」这条主线上工作量刚好够一个完整毕设也够讲出深度。功能清单落到模块上大致是七块用户与权限、阅览室与座位、预约、签到核销、违约与冻结、统计分析、系统配置。后面第三章的架构划分就按这七块来。2.2 技术选型Spring Boot MySQL Redis 的组合为什么够用技术栈不用堆。这套系统的体量JDK 8 或 11 加 Spring Boot 2.7、MyBatis-Plus、MySQL 8、Redis、JWT 就完全撑得住前端用 Vue 3 加 Element Plus 或者干脆 Thymeleaf 服务端渲染都行——毕设答辩重点在后端逻辑前端能演示清楚就够。逐个说理由。选 Spring Boot 是因为自动装配加上内嵌 Tomcatjava -jar一条命令就能起答辩现场不用折腾环境选 MyBatis-Plus 是因为座位图那种「按阅览室 时段批量查座位状态」的查询手写 SQL 比 JPA 的链式调用更直观也更方便你在答辩时解释索引怎么走。MySQL 负责持久化预约记录、违约记录这些数据必须落盘不能只放内存。真正需要解释的是 Redis 的位置。很多人第一反应是「毕设用不上 Redis」但座位预约的峰值场景恰恰是它的典型用法开馆前那一分钟热门阅览室的座位会被反复点击如果每次都直接打到数据库做「查一下有没有人占 — 没有就插入」中间那段间隙就是超卖的窗口。用 Redis 做一次SETNX预占把绝大部分并发挡在数据库之前数据库再用唯一索引兜底这套组合既讲得出原理代码量也不大。定时任务用 Spring 自带的Scheduled就够不必上 Quartz除非你想在论文里多写一章调度框架对比。版本上给个可参考的组合实际以你本机能跑通为准组件参考版本作用JDK8 或 11运行环境8 兼容性最好Spring Boot2.7.x应用框架与内嵌容器MyBatis-Plus3.5.xORM 与分页插件MySQL8.0主数据存储需支持窗口函数Redis6.x 及以上座位预占、登录态、计数缓存JWTjjwt 0.11.x无状态登录凭证2.3 非功能性需求并发量、时段粒度与数据留存非功能性需求是毕设里最容易被忽略、却最能体现思考深度的部分。三个数字建议提前定下来写进论文的需求分析章节。并发量。按一个中型高校估算单个阅览室 200 个座位开馆瞬间可能有 2000 到 3000 人同时刷新座位图。这意味列表接口要扛住读多写少预约接口要扛住写冲突。读接口靠 Redis 缓存座位图 分页写接口靠预占 唯一索引两条路分开设计。时段粒度。座位不是按天预约的是按段。常见做法是把开馆时间切成 30 分钟一格一天 24 格学生可以连续约多格。粒度太细比如 10 分钟会让座位图变得难以操作太粗比如半天又会浪费座位资源。30 分钟是个比较稳妥的默认值做成sys_config表里的可配置项答辩时能顺带讲一句「参数外置不改代码就能调整运营策略」。数据留存。预约记录、签到记录、违约记录建议保留一整个学期统计报表按周和按月聚合。历史数据不清空的好处是答辩时能拿出一张「本学期各阅览室使用率趋势」的图比任何文字描述都有说服力。还有一条隐性需求所有涉及「当前时间」的判断必须统一时区。这条放到第五章展开因为它是这套系统里翻车率最高的地方。3. 总体架构与数据模型四层结构与七张核心表的职责划分架构图不用画得很花。四层、七个模块、七张表把职责边界划清楚答辩时被追问任何一个功能你都能立刻说出「这个逻辑在哪一层、动哪张表」。这一章的重点不是图好不好看而是每个设计决定背后的取舍。3.1 四层架构与七个业务模块的职责划分分层沿用最朴素的 Controller → Service → Mapper → 基础设施。Controller 只做参数校验和权限注解不写业务判断Service 承担事务边界一个方法对应一个完整业务动作Mapper 只管 SQL不做逻辑拼装基础设施层放 Redis 工具、定时任务、全局异常处理、JWT 拦截器。七个模块与层的对应关系是用户与权限横跨 Controller 和拦截器阅览室与座位、预约、签到、违约、统计、系统配置都在 Service 层各有独立的 service 类。之所以不把预约和签到合并成一个类是因为它们的并发特征完全不同——预约是高频写冲突签到是低频状态流转混在一起会让事务范围失控。有几个约定值得在项目初期就定死。第一所有 Service 的写方法统一加Transactional(rollbackFor Exception.class)不要让受检异常悄悄吞掉回滚。第二所有对外接口统一返回包装对象包含code、message、data前端只认code方便后续加错误码表。第三所有涉及状态的修改都走「带条件的 UPDATE」比如UPDATE ... WHERE id ? AND status ?靠影响行数判断是否成功而不是先查后改。第三条是防并发最省钱的办法第五章会具体讲。模块之间的调用尽量单向预约模块可以调座位模块查可用性座位模块不反向依赖预约模块。需要反向数据比如「这个座位今天被约了几次」时用统计模块定时聚合不要在座位查询里现算。3.2 七张核心表与索引设计表不用多七张足够撑起全部功能。字段只列关键的具体类型按你实际需要定。-- 阅览室 CREATE TABLE reading_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 阅览室名称, floor VARCHAR(16) COMMENT 楼层, open_time TIME NOT NULL COMMENT 开馆时间, close_time TIME NOT NULL COMMENT 闭馆时间, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放 0关闭, deleted TINYINT NOT NULL DEFAULT 0 ) COMMENT 阅览室表; -- 座位 CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, seat_no VARCHAR(16) NOT NULL COMMENT 座位编号如 A-032, has_socket TINYINT DEFAULT 0 COMMENT 是否有电源, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可用 0维修 2停用, KEY idx_room (room_id, status) ) COMMENT 座位表; -- 预约记录核心表 CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, room_id BIGINT NOT NULL, reserve_date DATE NOT NULL, slot_id INT NOT NULL COMMENT 时段序号0~23, status TINYINT NOT NULL DEFAULT 1 COMMENT 1待签到 2使用中 3已释放 4已取消 5违约, active_flag BIGINT NOT NULL DEFAULT 0 COMMENT 有效为0失效时写入本行id, create_time DATETIME NOT NULL, expire_time DATETIME NOT NULL COMMENT 签到截止时间, UNIQUE KEY uk_seat_slot (seat_id, reserve_date, slot_id, active_flag), KEY idx_user_date (user_id, reserve_date), KEY idx_status_expire (status, expire_time) ) COMMENT 预约记录表;剩下四张是sys_user用户含frozen_until冻结截止时间、checkin_record签到流水记签到时间、签到方式、IP、violation_record违约流水记类型、发生时间、关联预约 id、sys_config键值配置存签到时限、暂离时长、违约阈值、冻结天数。重点讲uk_seat_slot这个唯一索引因为它是整套方案的关键。四个字段组合座位 日期 时段 active_flag。active_flag在记录有效时恒为 0一旦取消或释放就更新成本行的id。为什么要这么做因为如果只用(seat_id, reserve_date, slot_id)做唯一索引取消后的记录会一直占着这个位置同一座位同一时段就再也约不上了而如果只用status参与索引同一个人反复取消再预约会产生多条失效记录互相冲突。用「失效时写入自身 id」这种写法既保证了有效记录唯一又允许任意多条历史失效记录共存这是个很小的技巧但在答辩时讲出来效果比背概念好得多。3.3 座位状态机六个状态与四条合法迁移路径状态机是这个项目的骨架。状态不定义清楚代码里就会到处if (status 1 || status 2)最后没人说得清什么情况下该释放座位。状态码状态名含义出口0FREE空闲可预约→ BOOKED1BOOKED已预约待签到→ CHECKED_IN / CANCELLED / NO_SHOW2CHECKED_IN使用中→ TEMP_AWAY / RELEASED3TEMP_AWAY暂离中→ CHECKED_IN / RELEASED4RELEASED已释放终态5NO_SHOW违约未签到终态合法迁移只有四条主线FREE→BOOKED预约成功、BOOKED→CHECKED_IN扫码签到、BOOKED→NO_SHOW超时未签到由定时任务触发、CHECKED_IN→RELEASED主动离馆或闭馆释放。暂离是 CHECKED_IN 和 TEMP_AWAY 之间的来回本质上不改变座位占用权只是暂停计时。把这张表贴在需求文档里好处是后面写任何一段逻辑都能对号入座。比如「学生中途走了没点离馆怎么办」答案落在 BOOKED→NO_SHOW 和 TEMP_AWAY→RELEASED 两条迁移上而不是临场发挥。状态机还决定了数据库里status字段的取值千万不要一会儿用 1 表示待签到、一会儿又用 0这种不一致是后期最难查的 bug 之一。4. 核心模块实现并发控制、定时释放与违约冻结的落地代码前三章定的是边界和结构这一章开始写能跑的代码。三段核心逻辑预约接口怎么不超卖、超时座位怎么自动回收、违约次数怎么算并冻结账号。这三段写扎实项目的主体就立住了。4.1 预约接口的并发控制唯一索引兜底加 Redis 预扣减先明确要防的是什么。两个学生同时点了同一个座位的同一个时段如果代码是「先 select 查有没有冲突没有再 insert」那么两个线程都可能查到「没有冲突」然后都插入成功。这就是典型的超卖。两道防线。第一道是 Redis 的SETNXKey 设计成seat:lock:{seatId}:{date}:{slotId}抢到锁的线程继续走业务流程没抢到的直接返回「座位已被占」。第二道是数据库唯一索引万一 Redis 挂了或者锁过期了插入时会被uk_seat_slot挡住抛DuplicateKeyException捕获后转成友好提示。Service public class ReservationService { Autowired private StringRedisTemplate redisTemplate; Autowired private ReservationMapper reservationMapper; Autowired private UserService userService; private static final long LOCK_TTL_SECONDS 10L; /** * 提交预约 * param userId 当前登录用户 * param seatId 座位 id * param date 预约日期 * param slotId 时段序号 */ Transactional(rollbackFor Exception.class) public Long reserve(Long userId, Long seatId, LocalDate date, Integer slotId) { // 1. 违约冻结校验冻结期内的用户直接拒绝 if (userService.isFrozen(userId)) { throw new BizException(账号处于冻结期暂不能预约); } // 2. 同一用户同一时段只能有一个有效预约避免一人占多座 int existing reservationMapper.countActiveByUser(userId, date, slotId); if (existing 0) { throw new BizException(该时段你已有预约); } // 3. Redis 预占key 唯一标识一个座位的一个时段 String lockKey String.format(seat:lock:%d:%s:%d, seatId, date, slotId); Boolean ok redisTemplate.opsForValue() .setIfAbsent(lockKey, String.valueOf(userId), LOCK_TTL_SECONDS, TimeUnit.SECONDS); if (Boolean.FALSE.equals(ok)) { throw new BizException(手慢了该座位刚被预约); } try { // 4. 落库唯一索引作为最后一道防线 Reservation r new Reservation(); r.setUserId(userId); r.setSeatId(seatId); r.setReserveDate(date); r.setSlotId(slotId); r.setStatus(1); r.setActiveFlag(0L); r.setCreateTime(LocalDateTime.now()); // 签到截止时间 时段开始时间 配置的签到宽限分钟数 r.setExpireTime(calcExpireTime(date, slotId)); reservationMapper.insert(r); return r.getId(); } catch (DuplicateKeyException e) { // 唯一索引冲突说明并发下已被别人抢先 throw new BizException(该座位已被预约); } finally { // 5. 无论成功失败都要释放预占。成功时释放是为了让后续时段复用 // 真正的占位由数据库记录承担失败时释放是为了不让座位被锁死。 redisTemplate.delete(lockKey); } } }几个参数值得说清楚。LOCK_TTL_SECONDS设 10 秒是因为一次预约的事务正常在几百毫秒内完成10 秒足够覆盖 GC 停顿等极端情况又不至于让异常退出时座位被锁太久。finally里无条件删除 Key 这一点很关键——很多人只在成功分支删结果异常路径上 Key 一直留到过期短时间内该座位对其他人不可约用户看到的就是「明明没人坐却约不上」这种问题排查起来非常费劲属于典型的知识盲区。calcExpireTime的实现要注意时段序号slotId要换算成具体时间点通常用open_time slotId * 30min再减去一个签到宽限。宽限值从sys_config读默认 15 分钟。还有一个容易漏的点Redis 预占的粒度是「座位 日期 时段」粒度必须和数据库唯一索引完全一致。如果 Redis 用的是「座位 日期」而数据库是「座位 日期 时段」两道防线的语义就对不上会出现 Redis 说被占、数据库其实可约的假冲突。4.2 签到核销与超时释放定时任务怎么写才不重复执行签到本身很简单前端展示二维码或座位码学生扫码后调签到接口服务端校验「这个预约是不是本人的、是不是在有效期内、是不是还没签到」通过后把状态从 1 改成 2写一条checkin_record。Transactional(rollbackFor Exception.class) public void checkIn(Long userId, Long reservationId) { // 带条件的更新靠影响行数判断天然防重复签到 int rows reservationMapper.updateStatus(reservationId, userId, 1, 2); if (rows 0) { throw new BizException(预约不存在、已失效或已签到); } CheckinRecord record new CheckinRecord(); record.setReservationId(reservationId); record.setUserId(userId); record.setCheckinTime(LocalDateTime.now()); checkinRecordMapper.insert(record); }updateStatus对应的 SQL 是UPDATE reservation SET status #{to} WHERE id #{id} AND user_id #{userId} AND status #{from}。这一句同时解决了三个问题身份校验、状态校验、并发幂等。两个请求同时签到只有一个能把rows拿到 1另一个拿到 0 直接报错不需要加任何锁。这种写法在任何状态流转场景下都适用建议养成习惯。真正麻烦的是超时释放。每天有大量预约没人签到如果不定时清理座位会被长期占着。做法是每分钟跑一次扫描Component public class ReleaseTask { Autowired private ReservationMapper reservationMapper; Autowired private ViolationService violationService; /** 每分钟执行处理超时未签到与暂离超时 */ Scheduled(cron 0 */1 * * * ?) public void releaseTimeout() { LocalDateTime now LocalDateTime.now(); // 1. 待签到超时状态 1 且已过签到截止时间 ListReservation noShowList reservationMapper.selectTimeout(1, now, 200); for (Reservation r : noShowList) { // 逐条更新保证状态迁移可追溯同时记一次违约 int rows reservationMapper.updateStatus(r.getId(), r.getUserId(), 1, 5); if (rows 0) { // 失效标记写入自身 id让唯一索引位让出来 reservationMapper.releaseActiveFlag(r.getId()); violationService.addViolation(r.getUserId(), r.getId(), NO_SHOW); } } // 2. 暂离超时状态 3 且暂离开始时间超过配置时长直接释放 ListReservation awayList reservationMapper.selectAwayTimeout(3, now.minusMinutes(30), 200); for (Reservation r : awayList) { reservationMapper.updateStatus(r.getId(), r.getUserId(), 3, 4); reservationMapper.releaseActiveFlag(r.getId()); } } }releaseActiveFlag的 SQL 是UPDATE reservation SET active_flag id WHERE id #{id}把这一行的唯一索引位让出来座位在其他人的视图里立刻恢复可约。分批取 200 条是个细节。一次性select出几千条再逐条更新会把事务拉得很长锁住大量行影响正常预约。分批加限流是这套写法在小体量系统里最实用的折中。如果并发量真的上来了再考虑用UPDATE ... WHERE一条语句批量改状态但那样就没法逐条记违约流水了各有取舍。4.3 违约计数与冻结阈值配置化别写死在代码里违约规则通常是「一个学期内累计 3 次未签到冻结 7 天不能预约」。这条规则里有两个数字会变阈值和冻结天数都不该硬编码。ViolationService.addViolation的逻辑分三步写违约流水、累加计数、判断是否触顶并冻结。Service public class ViolationService { Autowired private ViolationRecordMapper violationRecordMapper; Autowired private UserMapper userMapper; Autowired private SysConfigService configService; Transactional(rollbackFor Exception.class) public void addViolation(Long userId, Long reservationId, String type) { ViolationRecord vr new ViolationRecord(); vr.setUserId(userId); vr.setReservationId(reservationId); vr.setType(type); vr.setCreateTime(LocalDateTime.now()); violationRecordMapper.insert(vr); // 统计本学期内有效违约次数 int count violationRecordMapper.countByUserInTerm(userId); int threshold configService.getInt(violation.threshold, 3); if (count threshold) { int frozenDays configService.getInt(violation.frozen.days, 7); userMapper.freeze(userId, LocalDateTime.now().plusDays(frozenDays)); } } }freeze的 SQL 是UPDATE sys_user SET frozen_until #{until} WHERE id #{id}。isFrozen判断就是比较frozen_until和当前时间一句话的事不需要额外的状态字段避免两处状态不同步。这里有一个设计取舍值得在论文里写一段违约次数是「实时统计」还是「冗余计数」实时统计就是每次count(*)简单但频繁查询冗余计数是在用户表加一个violation_count字段快但可能出现数据不一致。这套方案的体量实时统计完全够用原因是有idx_user_date这样的索引单用户一学期的违约记录最多几十条count的代价忽略不计。冗余计数属于过早优化反而会引入「计数对不上流水」的坑。统计模块也顺便在这一层解决。使用率报表按天聚合用一条GROUP BY查询就够了不需要建设数据仓库-- 统计某阅览室近 7 天各时段的占座率 SELECT reserve_date AS d, slot_id, COUNT(*) AS used, COUNT(*) / (SELECT COUNT(*) FROM seat WHERE room_id #{roomId} AND status 1) AS rate FROM reservation WHERE room_id #{roomId} AND reserve_date DATE_SUB(CURDATE(), INTERVAL 7 DAY) AND status IN (1, 2, 3, 5) GROUP BY reserve_date, slot_id ORDER BY d DESC, slot_id;注意status IN (1,2,3,5)这里把违约也算进了「被占用」的口径里——座位确实被占用了只是占的人没来。这个口径要和答辩时讲的定义对上别一个地方说使用率、另一个地方说签到率。5. 避坑与排查座位预约系统上线前最容易翻车的五个点这套系统的代码量不大但坑很集中。下面五个点是我在做类似预约类项目时踩过或者看着别人踩过的每一个都能在答辩现场把你问住。5.1 时间与时区最容易在答辩现场翻车的一类问题现象本地跑得好好的部署到服务器后签到接口一律提示「预约已过期」或者定时任务把还有 8 小时才到期的预约提前释放了。原因JVM 默认时区是 UTC数据库连接没指定时区LocalDateTime.now()拿到的时间和数据库里存的时间差了 8 小时。三个地方各有一套时区任何一个没对齐就会错位。解决三处统一。数据库连接串加上serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8启动参数加上-Duser.timezoneAsia/Shanghai容器部署时挂载TZAsia/Shanghai环境变量。业务代码统一用LocalDateTime/LocalDate不要用java.util.Date混着来——Date在序列化时会隐式转时区是最容易出问题的地方。提示写完定时任务后先把服务器时间调快 8 小时跑一遍看释放逻辑是否符合预期这个动作花不了五分钟能省掉答辩前一晚上的排查。5.2 并发与座位锁Redis 预占没释放导致座位永久占死现象某个座位在座位图上一直显示「已占用」但查数据库没有任何有效预约记录重启服务也没用等十几分钟又自己好了。原因setIfAbsent成功后业务代码在中途抛了异常而释放 Key 的delete只写在成功分支里没放进finally。Key 一直留到 TTL 到期这段时间里座位对所有人不可约。解决预占 Key 的删除必须放在finally里无条件执行。同时把 TTL 设成一个合理的短值10 秒左右作为兜底。如果业务确实需要长期持有比如支付类场景那就不能只靠 TTL得加一个对账任务定期清理孤儿 Key。座位预约这种短事务finally加 TTL 双保险就够了。另一类相关问题Redis 和数据库的粒度不一致。前面提过Key 是「座位 日期 时段」唯一索引也必须是这三个字段加active_flag粒度错一位两道防线就形同虚设。5.3 状态与数据一致性唯一索引被软删除和状态字段废掉现象一学生取消预约后再想约同一个座位同一时段提示「已被预约」但座位图上明明是空的。原因取消时只把status改成了 4没动active_flag那条记录仍然占着uk_seat_slot的位置。解决所有让预约失效的操作取消、超时释放、违约、闭馆释放都必须同时执行active_flag id。最稳妥的做法是把「改状态」和「让位」封装成一个releaseReservation(id, fromStatus, toStatus)方法谁都不许绕过它直接改状态。现象二明明有唯一索引还是出现了同一座位同一时段的重复有效记录。原因索引建在了(seat_id, reserve_date, slot_id)上但漏了active_flag或者建表时用了MyISAM之类的非事务引擎或者线上库是手工改过的、索引根本没建。解决上线前执行一次SHOW INDEX FROM reservation确认索引存在且字段顺序正确。字段顺序有讲究把区分度最高的seat_id放前面active_flag放最后这样等值查询能完整走索引。5.4 定时任务在多实例下重复执行现象本地单机跑没问题一部署两台机器违约记录翻倍一个学生一次未签到被记了两次违约直接被冻结。原因Scheduled是进程内的调度两台机器各跑各的同一个预约被处理两次。解决三个选择。一是加分布式锁任务开始前setIfAbsent(task:release, ...)抢锁抢不到直接返回二是把定时任务拆成独立部署的一个实例应用实例不跑调度三是用数据库的UPDATE ... WHERE status 1做幂等即使重复执行第二次影响行数为 0 也不会重复记违约。毕设阶段最省事的是第三种配合前两种任意一种更稳。5.5 座位图接口把数据库打满现象开馆前座位图刷新很慢接口响应两三秒数据库 CPU 打满。原因前端每秒轮询一次每次请求都对整个阅览室的座位做全量查询还带了 N1——先查座位列表再逐个查每个座位的当前预约状态。解决三步。座位列表本身很少变加 Redis 缓存缓存 key 是room:seats:{roomId}座位增删改时主动删缓存当前占用状态按「日期 时段」维度缓存key 是room:occupy:{roomId}:{date}:{slot}TTL 设 30 秒接受一点点延迟前端轮询间隔从 1 秒改成 5 到 10 秒或者在座位图页用 WebSocket 推送变更避免无效刷新。这三步做完同样的硬件能扛的并发量会上一个台阶。6. 验收与答辩用一套可复现的演示脚本把方案讲透代码写完不等于项目完成验收环节才是把「我做了个系统」变成「我做了个能证明有效的系统」的地方。我的建议是提前准备一份可复现的验收清单每条都能现场跑出来。验收项操作方式通过标准并发不超卖JMeter 或 ab 对同一座位发 50 个并发预约成功 1 条其余返回「已被预约」无重复记录超时自动释放造一条 20 分钟前的待签到记录等定时任务跑一轮状态变 5active_flag等于自身 id座位可再约违约冻结连续造 3 次未签到第 3 次后frozen_until有值预约接口返回冻结提示重复签到幂等同一预约连点两次签到第二次返回失败提示checkin_record只有一条取消后可重约取消后立刻再约同一座位同一时段预约成功唯一索引不冲突并发那条要用命令行工具跑不能靠手点。示例# 50 个并发请求抢同一个座位观察返回码分布 seq 1 50 | xargs -P 50 -I{} curl -s -o /dev/null -w %{http_code}\n \ -X POST http://localhost:8080/api/reservation \ -H Authorization: Bearer ${TOKEN} \ -H Content-Type: application/json \ -d {seatId:101,date:2025-06-10,slotId:8} | sort | uniq -c跑完看两件事HTTP 200 的数量是不是只有 1 个数据库里SELECT COUNT(*) FROM reservation WHERE seat_id101 AND reserve_date2025-06-10 AND slot_id8 AND active_flag0的结果是不是也是 1。两个数字对得上这条就过了。如果 200 出现了多个先去看唯一索引在不在再看 Redis 的finally有没有写漏。答辩讲述的顺序也有讲究。不要一上来讲技术栈先讲那个抢座场景——开馆前三千人同时点然后说你的方案用 Redis 预占挡在前面、用唯一索引兜底、用状态机管流转。评委听到「唯一索引的第四个字段active_flag在失效时写入自身 id」这种细节基本就能判断你是真做过而不是套模板。接着演示验收清单里的三条并发、超时释放、违约冻结每条都现场跑跑完再说设计取舍。我自己的习惯是每套涉及状态流转的系统答辩前一定强制走一遍「把服务器时间调快 8 小时 两台实例同时起 手动造 3 条超时数据」这三件事一次没漏过。因为这三件事各自对应一类最难在演示现场自圆其说的问题提前跑一遍心里就有底了。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询