☰
Java校园图书馆座位预约系统:预约签到违约闭环与并发避坑
2026/10/30 2:51:22 网站建设 项目流程

简介:面向计算机专业毕业设计与课程实践场景的Java校园图书馆座位预约系统,提供一套可运行的管理系统源码及配套数据库,重点解决座位预约、信息管理与后台维护等业务功能的实现与演示需求。压缩包约12.16MB,包含程序源代码与配套数据库文件,并附配置环境说明;上游暂未标注具体文件数量与类型明细。已有3244人学习,具备一定参考热度。读者可据此梳理需求分析、总体设计、数据库访问、主要功能模块实现与测试验证的完整开发脉络,了解预约管理系统的数据结构与关键代码组织方式,为同类课题设计、功能扩展和二次开发提供可复用的参考。

1. 早上七点半的图书馆门口:java校园图书馆座位预约系统要解决的真问题

某高校图书馆开馆前二十分钟,门口排起长队,门一开人群涌向三楼自习区,书包、水杯、一本单词书往桌上一放,座位就算“占住了”,人却到中午才回来。这套 java校园图书馆座位预约系统要干的事很直接:把“抢座”搬到线上,学生按日期和时段选座,到馆后签到,超时未签到自动释放座位并记一次违约,管理员在后台看座位使用率和违约名单。它适合三类人:正在做毕业设计、需要一套能跑通并且讲得清链路的完整工程的同学;手上有现成后台、想按自己学校的规则改预约策略的后端开发者;以及想拿一个包含事务、并发、定时任务的真实场景练手的初学者。功能不复杂,但预约、签到、违约这三条状态线一旦设计歪了,后面每加一个功能都要返工。

2. 工程骨架与技术选型:Spring Boot 分层目录、依赖和启动配置

这套系统的体量不大,但该有的分层一个都不能少。常见的做法是后端一个 Spring Boot 工程,前端用 Vue 加 Element UI 单独跑一个 dev server,两边通过/api前缀通信;如果不想折腾前后端分离,也可以把页面放在resources/static下用 Thymeleaf 渲染,毕业设计答辩时反而更省事,因为只需要启动一个进程。

技术选型上我一般这么配:JDK 8 或 11、Spring Boot 2.7.x、MyBatis-Plus 3.5.x、MySQL 8.0、Maven 构建,登录态用 JWT,定时任务用 Spring 自带的@Scheduled。Redis 是可选项,如果要做座位地图缓存或者分布式锁可以加,但单机部署的毕业设计场景,数据库唯一索引已经足够挡住重复预约,先别把复杂度堆上去。

2.1 目录结构:后端四层 + 一个独立前端

一个能直接跑的工程目录大概长这样,重点是service和mapper要分开放,不要把 SQL 写在 Controller 里:

seat-reservation ├── seat-server # 后端主工程 │ ├── pom.xml │ └── src/main │ ├── java/com/example/seat │ │ ├── SeatApplication.java # 启动类,@EnableScheduling 开定时任务 │ │ ├── config # 跨域、拦截器注册、MyBatis 配置 │ │ ├── controller # 只做参数接收和响应包装 │ │ ├── service # 业务规则都在这一层 │ │ │ └── impl │ │ ├── mapper # 接口 + XML │ │ ├── entity # 和表一一对应 │ │ ├── dto # 入参对象 │ │ ├── vo # 出参对象,比如座位地图 │ │ ├── task # 定时任务 │ │ └── common # 统一响应、业务异常、枚举、JWT 工具 │ └── resources │ ├── application.yml │ ├── mapper # 所有 XML 放这里 │ └── static # 用 Thymeleaf 时放页面 └── seat-web # Vue 前端,可选 ├── package.json └── src ├── api # axios 封装 ├── views # 选座、我的预约、后台管理 └── router

entity和vo一定要分开。座位地图接口返回的既有座位基础信息,又有“今天第 3 时段是否被占”这种拼装字段,直接拿entity返给前端,后面加字段会越来越乱。

2.2 依赖与 application.yml:数据源、时区、Jackson 一次配齐

配置文件是最容易埋雷的地方。时区、日期格式、驼峰映射这三项没配好,前端拿到的数据会莫名其妙差一天或者字段全是小写。

server: port: 8080 servlet: context-path: / spring: datasource: url: jdbc:mysql://127.0.0.1:3306/seat_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 connection-timeout: 30000 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai default-property-inclusion: non_null # 空字段不返回,前端少判空 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true # seat_no -> seatNo log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打印 SQL seat: reservation: open-days: 3 # 允许提前预约的天数 max-per-user: 1 # 同一时段同一用户最多有效预约数 sign-in: expire-minutes: 30 # 时段开始后 30 分钟未签到算违约

几个参数值得单独说。serverTimezone=Asia/Shanghai必须写,MySQL 8 的驱动不写这个会按 UTC 解析,LocalDate存进去再读出来就是前一天。map-underscore-to-camel-case打开之后,reserve_date才能自动映射到reserveDate,不然查出来的字段全是 null,排查半天以为是 SQL 写错了。seat.reservation.open-days这种自定义配置用@ConfigurationProperties绑到一个SeatProperties类上,别在代码里写魔法数字 3。

启动类上加两个注解就够了:

@SpringBootApplication @MapperScan("com.example.seat.mapper") @EnableScheduling // 超时违约任务依赖它 public class SeatApplication { public static void main(String[] args) { SpringApplication.run(SeatApplication.class, args); } }

2.3 登录态与全局异常:拦截器 + ThreadLocal + @RestControllerAdvice

登录态用 JWT,拦截器解析完把用户信息塞进ThreadLocal,Service 层直接取当前用户,不用每个方法都传userId。这里有个必须做的收尾动作:afterCompletion里清ThreadLocal,Tomcat 线程池会复用线程,不清就会出现 A 同学看到 B 的预约这种玄学问题。

@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { // 预检请求直接放行,否则浏览器跨域会先收到 401 if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { return true; } String token = req.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } Claims claims = jwtUtil.parse(token.substring(7)); UserContext.set(claims.get("userId", Long.class), claims.get("role", String.class)); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { // 线程复用,必须清理,否则用户身份会串 UserContext.clear(); } }

参数说明:Authorization头格式固定为Bearer <token>,substring(7)是去掉前缀;UserContext内部就是一个ThreadLocal<LoginUser>,role用来在后台接口上判断是不是管理员。全局异常处理器把BizException统一转成{code, msg},前端只认这一种结构,比在每个 Controller 里 try-catch 干净得多。

3. 数据建模:六张表撑起“选座—签到—违约”的闭环

表设计决定了后面能加多少功能。座位预约看着简单,实际上是“用户 × 座位 × 日期 × 时段”四维占用,任何一维没约束住,都会出现一个座位被两个人同时约到的情况。

3.1 核心表 DDL:座位、时段、预约三张主表

用户表、自习室表都是常规结构,重点看座位、时段、预约这三张。

CREATE TABLE `seat` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `room_id` BIGINT NOT NULL COMMENT '所属自习室', `seat_no` VARCHAR(16) NOT NULL COMMENT '座位编号,如 A-032', `row_num` INT DEFAULT NULL COMMENT '所在行,前端画地图用', `col_num` INT DEFAULT NULL COMMENT '所在列', `has_power` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否带电源', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 可预约 0 停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_room_seat` (`room_id`, `seat_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='座位表'; CREATE TABLE `time_slot` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `slot_name` VARCHAR(32) NOT NULL COMMENT '如 08:00-10:00', `start_time` TIME NOT NULL, `end_time` TIME NOT NULL, `sort` INT NOT NULL DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约时段表';

时段表建议初始化成固定的几段,比如 08:00-10:00、10:00-12:00、14:00-16:00、16:00-18:00、18:00-21:00。不要把时段做成“任意起止时间”,否则冲突判断会从等值匹配退化成区间重叠,SQL 复杂度直接上一个台阶。

预约表是核心,字段里我特意加了active_seat_id:

CREATE TABLE `reservation` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `seat_id` BIGINT NOT NULL, `active_seat_id` BIGINT DEFAULT NULL COMMENT '有效占用时的座位ID,取消/完成/违约后置 NULL', `reserve_date` DATE NOT NULL, `slot_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 待签到 1 已签到 2 已完成 3 已取消 4 违约', `sign_in_time` DATETIME DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_active_slot` (`active_seat_id`, `reserve_date`, `slot_id`), KEY `idx_user_date` (`user_id`, `reserve_date`), KEY `idx_date_slot` (`reserve_date`, `slot_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约记录表';

违约表violation记录user_id、reservation_id、reason、create_time,它是从预约表状态变更时异步或同步写一条,用来做信用分统计。

3.2 唯一索引怎么设计:为什么不能把 status 放进唯一键

很多人第一版会写UNIQUE KEY (seat_id, reserve_date, slot_id, status),想着“不同状态可以共存”。这个设计有个致命问题:用户预约后取消,status从 0 变成 3,唯一键组合变了,同一座位同一时段又能插一条新记录,看起来没问题;但如果是“已签到”变成“已完成”,同样能再插,历史记录会越滚越多,而且“当前这个座位到底被谁占着”要靠status IN (0,1)去过滤,一旦有人写错条件就出重复占用。

active_seat_id方案解决的就是这件事:有效占用时它等于seat_id,取消、完成、违约时置为NULL。MySQL 的唯一索引对NULL不做唯一性校验,多个NULL可以共存,所以同一个座位同一时段可以有多条历史记录,但同一时刻最多只有一条active_seat_id非空的有效记录。业务上判断“有没有被占”也简单,直接看active_seat_id是否为 null。

提示:这个方案依赖 MySQL 对 NULL 的处理方式,换数据库要重新验证。用 PostgreSQL 的话部分唯一索引更直接,CREATE UNIQUE INDEX ... WHERE active_seat_id IS NOT NULL。

3.3 状态机与字段约定:0/1/2/3/4 分别代表什么

状态值不要用魔法数字散落在代码里,定义成枚举,并且明确每个状态能往哪些状态走:

状态值含义可流转到触发动作
0待签到1、3、4签到 / 取消 / 超时
1已签到2、3时段结束 / 提前离开
2已完成无终态
3已取消无终态
4违约无终态

所有状态变更一律用“带原状态条件的 UPDATE”,不要先select再update。比如签到必须是UPDATE ... SET status = 1 WHERE id = ? AND status = 0,影响行数为 0 就说明状态已经被别人改过,直接抛业务异常。这一点在定时任务和用户操作并发时能救命,后面第 5 章会展开。

4. 预约主链路拆解:从点击选座到落库的五道校验

从用户点“确认预约”到数据库里出现一条记录,中间要过五道关:日期窗口、座位可用、时段合法、用户自身冲突、座位并发占用。前四道是业务校验,第五道靠数据库唯一索引兜底。

4.1 预约接口:参数校验、用户冲突检测、唯一索引兜底

@Service public class ReservationServiceImpl implements ReservationService { @Autowired private ReservationMapper reservationMapper; @Autowired private SeatMapper seatMapper; @Autowired private TimeSlotMapper timeSlotMapper; @Autowired private SeatProperties seatProperties; @Override @Transactional(rollbackFor = Exception.class) public Long reserve(Long userId, Long seatId, LocalDate date, Long slotId) { // 1. 日期窗口:只能约今天起 N 天内 LocalDate today = LocalDate.now(); if (date.isBefore(today) || date.isAfter(today.plusDays(seatProperties.getOpenDays()))) { throw new BizException("只能预约 " + seatProperties.getOpenDays() + " 天内的座位"); } // 2. 座位必须存在且可预约 Seat seat = seatMapper.selectById(seatId); if (seat == null || seat.getStatus() != 1) { throw new BizException("座位不存在或已停用"); } // 3. 时段必须存在 TimeSlot slot = timeSlotMapper.selectById(slotId); if (slot == null) { throw new BizException("时段不存在"); } // 4. 同一用户同一时段只允许一个有效预约 int userActive = reservationMapper.countUserActive(userId, date, slotId); if (userActive >= seatProperties.getMaxPerUser()) { throw new BizException("该时段你已有预约,请先取消"); } // 5. 插入,并发冲突交给唯一索引 Reservation r = new Reservation(); r.setUserId(userId); r.setSeatId(seatId); r.setActiveSeatId(seatId); // 关键:有效占用标记 r.setReserveDate(date); r.setSlotId(slotId); r.setStatus(ReservationStatus.WAIT_SIGN_IN.getCode()); try { reservationMapper.insert(r); } catch (DuplicateKeyException e) { // uk_active_slot 冲突,说明这一秒被别人抢走了 throw new BizException("手慢了,该座位刚被预约"); } return r.getId(); } }

逻辑说明:第 4 步的countUserActive对应的 SQL 是SELECT COUNT(1) FROM reservation WHERE user_id = ? AND reserve_date = ? AND slot_id = ? AND status IN (0,1),只统计仍然有效的预约。第 5 步是最关键的,很多人会在这里先select一次看座位有没有被占,再insert,这个“先查后插”在并发下必然出问题,两个线程同时查到“没占”,然后都插成功。正确做法是让数据库的唯一索引当裁判,代码只需要捕获DuplicateKeyException并翻译成友好提示。

参数说明:seatProperties.getMaxPerUser()默认 1,如果以后要放开“一次约两个座位”,改成 2 即可,不用动 SQL。date用LocalDate而不是Date,避免时区偏移。

4.2 签到与取消:条件更新保证状态不被覆盖

签到接口要防两件事:重复签到,以及和定时任务的“超时违约”抢同一条记录。

@Override @Transactional(rollbackFor = Exception.class) public void signIn(Long userId, Long reservationId) { Reservation r = reservationMapper.selectById(reservationId); if (r == null || !r.getUserId().equals(userId)) { throw new BizException("预约记录不存在"); } // 带原状态条件的更新,影响行数为 0 说明状态已变 int rows = reservationMapper.updateStatus(reservationId, ReservationStatus.WAIT_SIGN_IN.getCode(), ReservationStatus.SIGNED_IN.getCode(), LocalDateTime.now()); if (rows == 0) { throw new BizException("签到失败,该预约可能已超时或已取消"); } }

对应的 XML:

<update id="updateStatus"> UPDATE reservation SET status = #{toStatus}, sign_in_time = #{time} WHERE id = #{id} AND status = #{fromStatus} </update>

取消同理,条件里带user_id防止越权取消别人的预约:

<update id="cancel"> UPDATE reservation SET status = 3, active_seat_id = NULL <!-- 释放座位,唯一索引不再拦新预约 --> WHERE id = #{id} AND user_id = #{userId} AND status IN (0, 1) </update>

这里的active_seat_id = NULL是整个方案的核心动作,取消的一瞬间座位就对别人可见了。如果漏了这句,用户会发现“取消之后这个座位再也约不上”,第 5 章会详细说这个坑。

4.3 超时违约定时任务:幂等 SQL 的写法

定时任务每 5 分钟跑一次,把“今天、已过开始时间 30 分钟、仍待签到”的记录置为违约:

@Component public class ReservationTask { private static final Logger log = LoggerFactory.getLogger(ReservationTask.class); @Autowired private ReservationMapper reservationMapper; @Scheduled(cron = "0 */5 * * * ?") public void expireUnSigned() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(30); int rows = reservationMapper.expireUnSigned(deadline); if (rows > 0) { log.info("自动违约处理 {} 条,截止时间 {}", rows, deadline); } } }
<update id="expireUnSigned"> UPDATE reservation r JOIN time_slot t ON r.slot_id = t.id SET r.status = 4, r.active_seat_id = NULL WHERE r.status = 0 AND r.reserve_date = CURDATE() AND CONCAT(r.reserve_date, ' ', t.start_time) &lt; #{deadline} </update>

逻辑说明:WHERE r.status = 0保证了幂等,任务重复执行、服务重启后补跑,都不会把已经签到的记录误改成违约。CONCAT(reserve_date, ' ', start_time)把日期和时段拼成完整时间点和截止时间比较,避免只比较TIME类型导致跨天判断出错。如果数据量上去之后这个 JOIN 变慢,可以把“时段开始时间”冗余成预约表的一个slot_start字段,加索引直接扫。

参数说明:cron = "0 */5 * * * ?"表示每 5 分钟的第 0 秒执行,?是 Quartz 语法里“不指定”的意思。deadline是当前时间减 30 分钟,意味着一个 08:00 开始的时段,08:30 之后还没签到就会被处理。

4.4 座位地图查询:一次请求把“哪些座位已被占”取回来

前端选座页面要画一张座位图,每个格子的颜色代表可约、已占、停用。千万别在循环里对每个座位查一次预约表,30 个座位就是 30 次查询,页面会明显卡。

@Override public List<SeatMapVO> seatMap(Long roomId, LocalDate date, Long slotId) { // 一次查出该自习室所有座位 List<Seat> seats = seatMapper.selectByRoom(roomId); // 一次查出该时段所有有效占用 Set<Long> occupied = reservationMapper.selectOccupiedSeatIds(date, slotId) .stream().collect(Collectors.toSet()); return seats.stream().map(s -> { SeatMapVO vo = new SeatMapVO(); vo.setSeatId(s.getId()); vo.setSeatNo(s.getSeatNo()); vo.setRow(s.getRowNum()); vo.setCol(s.getColNum()); vo.setHasPower(s.getHasPower()); vo.setStatus(s.getStatus() == 0 ? "DISABLED" : occupied.contains(s.getId()) ? "OCCUPIED" : "FREE"); return vo; }).collect(Collectors.toList()); }
<select id="selectOccupiedSeatIds" resultType="java.lang.Long"> SELECT active_seat_id FROM reservation WHERE reserve_date = #{date} AND slot_id = #{slotId} AND active_seat_id IS NOT NULL </select>

两次查询,内存里拼装,接口响应能压到几十毫秒。selectOccupiedSeatIds走的是idx_date_slot索引,active_seat_id IS NOT NULL过滤掉历史记录。前端拿row和col直接用 CSS Grid 铺格子,不需要后端返回二维数组。

5. 避坑与排查:联调阶段最容易翻车的五件事

5.1 取消之后再约同一个座位,提示“已被占用”

现象:用户取消预约成功,列表里也看不到了,但回头再约同一个座位同一时段,接口一直报“手慢了”。

原因:取消时只改了status = 3,没有把active_seat_id置空,唯一索引uk_active_slot仍然认为这个座位被占着。

解决:所有会让预约失效的动作——取消、完成、违约——统一在 UPDATE 里带上active_seat_id = NULL。可以把这几条 SQL 抽到一个 Mapper 方法里,用状态枚举控制,避免以后加“管理员强制取消”时又漏掉。

5.2 定时任务把刚签到的记录改成了违约

现象:用户 08:29 签到成功,08:30 定时任务跑完,记录变成违约,用户来找你理论。

原因:定时任务的 UPDATE 没带status = 0条件,或者先select出列表再逐条update,中间隔了几百毫秒,签到刚好插进来。

解决:UPDATE 必须带原状态条件,靠影响行数判断是否真的改了。不要在 Java 里 for 循环逐条更新,一条 SQL 批量处理,数据库的行锁会保证同一个时刻只有一个事务在改这行。

5.3 日期整体偏一天,今天约的显示成昨天

现象:前端传2024-05-20,数据库存进去变成2024-05-19,或者查出来少一天。

原因:JDBC URL 没写serverTimezone,MySQL 驱动按 UTC 解析;或者 Jackson 没配time-zone,序列化时又转了一次。

解决:三处时区统一——JDBC URL 加serverTimezone=Asia/Shanghai,spring.jackson.time-zone配Asia/Shanghai,服务器TZ环境变量也设成东八区。实体里的日期字段统一用LocalDate/LocalDateTime,不要混用java.util.Date。

5.4 A 同学登录后看到 B 同学的预约列表

现象:压测或者多人同时在线时,偶尔出现用户信息错乱,刷新一下又好了。

原因:ThreadLocal存了用户信息,但拦截器只写了preHandle,没写afterCompletion清理。Tomcat 线程复用,下一个请求进来拿到的是上一个用户的数据。

解决:afterCompletion里无条件UserContext.clear(),最好把它写进拦截器的基类,所有新加的拦截器都继承。这个坑很隐蔽,单机单人测试永远发现不了。

5.5 前端一直报跨域,浏览器 Network 里显示 OPTIONS 请求 401

现象:Vue 本地 8081 端口调后端 8080,登录接口能通,其他接口全部失败,控制台报 CORS 错误。

原因:跨域预检的 OPTIONS 请求不携带Authorization头,被登录拦截器拦下来返回 401,浏览器认为预检失败,真实请求根本发不出去。

解决:拦截器第一行判断OPTIONS直接放行;同时在WebMvcConfigurer里配置allowedHeaders("*")、allowCredentials(true),并且allowedOrigins不能写*,要写具体的前端地址。生产环境用 Nginx 反向代理把前后端放同一个域名下,这类问题直接消失。

6. 二次开发:把“能演示”改成“能上线”的三个改造点

毕业设计能跑通只是起点,真正拿去给一个学院用,至少有三个地方值得动手。

第一个是候补队列。现在座位被约满之后用户只能干瞪眼,加一张waitlist表,字段是user_id、seat_id、reserve_date、slot_id、create_time,有人取消时在取消逻辑里查候补队列,按时间顺序取第一个人发通知。通知在毕业设计里可以简化为写一条站内消息,不用真接短信。这里要注意取消和候补要在同一个事务里,否则会出现“座位释放了但没人收到通知”。

第二个是信用分。违约表violation已经有数据了,加一个credit_score字段到用户表,初始 100 分,每次违约扣 5 分,低于 60 分禁止预约三天。判断逻辑放在预约接口的第 4 步之前,一行查询的事。这个功能答辩时很加分,因为它把“违约”从一条记录变成了有实际约束力的规则。

第三个是并发验证。很多人改完代码自己点两下觉得没问题,上线第一天就被现实教育。写个 JUnit 测试,用CountDownLatch让 50 个线程同时抢同一个座位,断言只有一个成功:

@Test public void testConcurrentReserve() throws Exception { int threads = 50; CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threads); AtomicInteger success = new AtomicInteger(); ExecutorService pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { final long userId = 1000 + i; pool.submit(() -> { try { start.await(); // 所有线程在这里对齐,一起冲 reservationService.reserve(userId, 1L, LocalDate.now(), 1L); success.incrementAndGet(); } catch (BizException e) { // 预期内的“已被占用”,忽略 } catch (Exception e) { fail("非预期异常: " + e.getMessage()); } finally { done.countDown(); } }); } start.countDown(); done.await(10, TimeUnit.SECONDS); pool.shutdown(); assertEquals(1, success.get()); // 只能有一个人成功 }

这个测试跑在真实数据库上,不要用 H2 内存库,因为唯一索引的行为要一致。跑通之后再去看reservation表,确认只有一条active_seat_id = 1的记录,其余全是异常回滚或者唯一索引冲突。

改造时的取舍也很重要。候补队列会引入新的状态和通知逻辑,如果时间有限,优先做信用分,因为它复用现有的违约表,改动最小、效果最直观。座位地图如果座位数超过 200,前端 Grid 渲染会开始卡,这时候再考虑分页或者 SVG 绘制,不要一上来就上 Canvas。

我第一次把类似系统交给别人试用时,没做并发测试,结果开放预约的第一分钟就出现了两条同一座位的有效记录,排查到凌晨才发现是“先查后插”的老问题。从那以后我每次改预约相关的 SQL,都强制先跑一遍 50 线程的并发用例再提交,宁可多花十分钟,也不想再被这种问题按在地上摩擦。希望这些内容帮到你,少走几个弯路。

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

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

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

立即咨询