☰
Java校园图书馆座位预约系统:并发控制与状态机落地
2026/10/30 5:07:42 网站建设 项目流程

简介:这是一份面向计算机相关专业毕业设计场景的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 是因为自动装配加上内嵌 Tomcat,java -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空闲,可预约→ BOOKED
1BOOKED已预约待签到→ CHECKED_IN / CANCELLED / NO_SHOW
2CHECKED_IN使用中→ TEMP_AWAY / RELEASED
3TEMP_AWAY暂离中→ CHECKED_IN / RELEASED
4RELEASED已释放终态
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 的SETNX,Key 设计成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 且已过签到截止时间 List<Reservation> 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 且暂离开始时间超过配置时长,直接释放 List<Reservation> 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 小时。三个地方各有一套时区,任何一个没对齐就会错位。

解决:三处统一。数据库连接串加上serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8;启动参数加上-Duser.timezone=Asia/Shanghai;容器部署时挂载TZ=Asia/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 打满。

原因:前端每秒轮询一次,每次请求都对整个阅览室的座位做全量查询,还带了 N+1——先查座位列表,再逐个查每个座位的当前预约状态。

解决:三步。座位列表本身很少变,加 Redis 缓存,缓存 key 是room:seats:{roomId},座位增删改时主动删缓存;当前占用状态按「日期 + 时段」维度缓存,key 是room:occupy:{roomId}:{date}:{slot},TTL 设 30 秒,接受一点点延迟;前端轮询间隔从 1 秒改成 5 到 10 秒,或者在座位图页用 WebSocket 推送变更,避免无效刷新。这三步做完,同样的硬件能扛的并发量会上一个台阶。

6. 验收与答辩:用一套可复现的演示脚本把方案讲透

代码写完不等于项目完成,验收环节才是把「我做了个系统」变成「我做了个能证明有效的系统」的地方。我的建议是提前准备一份可复现的验收清单,每条都能现场跑出来。

验收项操作方式通过标准
并发不超卖JMeter 或 ab 对同一座位发 50 个并发预约成功 1 条,其余返回「已被预约」,无重复记录
超时自动释放造一条 20 分钟前的待签到记录,等定时任务跑一轮状态变 5,active_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_id=101 AND reserve_date='2025-06-10' AND slot_id=8 AND active_flag=0的结果是不是也是 1。两个数字对得上,这条就过了。如果 200 出现了多个,先去看唯一索引在不在,再看 Redis 的finally有没有写漏。

答辩讲述的顺序也有讲究。不要一上来讲技术栈,先讲那个抢座场景——开馆前三千人同时点,然后说你的方案用 Redis 预占挡在前面、用唯一索引兜底、用状态机管流转。评委听到「唯一索引的第四个字段active_flag在失效时写入自身 id」这种细节,基本就能判断你是真做过而不是套模板。接着演示验收清单里的三条:并发、超时释放、违约冻结,每条都现场跑,跑完再说设计取舍。

我自己的习惯是,每套涉及状态流转的系统,答辩前一定强制走一遍「把服务器时间调快 8 小时 + 两台实例同时起 + 手动造 3 条超时数据」这三件事,一次没漏过。因为这三件事各自对应一类最难在演示现场自圆其说的问题,提前跑一遍,心里就有底了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询