news 2026/10/1 5:16:05

Spring Boot实战:高校实验室预约系统的设计与并发避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot实战:高校实验室预约系统的设计与并发避坑

简介:基于Spring Boot的高校实验室预约系统是一套计算机毕业设计项目,面向正在做毕设的计算机专业学生及Java学习者,用于解决传统实验室资源预约不便、管理效率低等现实问题。资源包总计623个文件、21.93MB,主要包含171个Java后端源码、59个Vue前端组件、38个JS脚本、21个XML配置及1个SQL数据库脚本,同时提供安装、构建、运行等批处理文件,便于快速启动与调试。系统采用Spring Boot框架整合Spring Web与Spring Data JPA,结合Vue实现前后端分离,覆盖用户管理、实验室管理、预约管理、身份认证与数据持久化等核心功能,并通过合理的数据库表结构设计保障数据一致性与查询效率,可帮助学习者理解完整业务开发链路。目前已有70人学习,压缩包内附带运行说明和数据库文档,既能直接支撑毕业设计答辩,也适合作为课程设计或期末大作业的实战参考。

1. 高校实验室预约系统到底在解决什么问题

一个几百人的学院,实验室预约还在靠微信群接龙和 Excel 排班。学生反复问“某时段空了没”,管理员一遍遍复制粘贴,最终排出来的表还是撞车。高校实验室预约系统就是把这件事线上化:学生登录查空档、提交预约,管理员审核,使用情况留痕,月底还能直接导出使用率。用 Spring Boot 做这类系统是最顺手的选型,生态成熟、部署简单,单体架构能覆盖绝大多数高校的并发量。这篇文章写给两类人:一是正在做相关毕设的学生,需要一份能跑通全流程的实现路径;二是实验室或学院的信息化负责人,想低成本把预约管理从微信接龙里解放出来。

2. 系统架构拆解:为什么单体应用是这个题目的正解

2.1 先划清业务边界:三个角色与三条核心流程

动手写代码之前,一定要先把边界划清楚。这个系统里只有三类人:学生或教师是预约人,负责提交预约、取消预约、确认使用完成;实验室管理员是审核人,负责处理预约申请、维护实验室基本信息;系统管理员负责账号、基础数据这类运维操作。角色再多,比如加一个“院系超管”,也只是在角色字段上再加一个值,不必在架构上动刀。

核心流程也就三条。第一条是预约流程:预约人查实验室空档,提交一条带日期、时间段、用途、人数的申请,状态为待审核;管理员在列表里看到这条申请后通过或驳回;通过后到时间就来使用,结束后确认完成。第二条是取消与爽约流程:待审核的单子用户可以自己取消,已通过的单子管理员可以取消,超过开始时间没签到可以标记爽约。第三条是统计流程:按实验室、按月份统计预约次数、使用率、爽约率,这是月底管理员最需要的数据。

把流程理成这样之后,你就能回答一个关键问题:这个系统需要多复杂的架构?我见过不少毕设一上来就拆微服务,注册中心、网关、配置中心全上,最后连本地启动都要开五个进程。高校实验室预约的用户规模是几千人,峰值 QPS 很难过百,数据量在一个学期里也就是几万条预约记录。单体的 Spring Boot 应用加一个 MySQL 完全够用,部署也简单,一台服务器一个java -jar就起来了。很多人纠结 Spring Boot 和微服务的区别,这个题目里不用纠结,Spring Boot 单体本身就是微服务架构里单个服务最标准的姿势,先把单体做好,以后真要拆也拆得动。

2.2 技术栈怎么选:Spring Boot 3.2 与常用配套

技术栈的选择原则是“自己熟练,且社区资料够多”。我建议用 Spring Boot 3.2 加 JDK 17,这是目前最稳的组合。如果你所在学校的实验环境强制 JDK 8,那就退回 Spring Boot 2.7,写法上差别不大。如果你用的是 Java 21 加 Spring Boot 3.5,可以打开spring.threads.virtual.enabled=true启用虚拟线程,对预约这种 IO 密集型接口是加分项;但升级的同时 Spring Security 这类组件的配置方式也有迁移成本,不是非追不可。

持久层我推荐 MyBatis-Plus,因为预约系统里有大量条件查询:按实验室查、按日期查、按状态查、按用户查,MyBatis-Plus 的 LambdaQueryWrapper 写起来比 JPA 直观,复杂 SQL 也可以手写 XML 控制。数据库用 MySQL 8.x,字符集 utf8mb4,建表时注意时间字段统一。缓存和分布式锁看条件:有 Redis 环境就引入spring-boot-starter-data-redis,用来做验证码存储和预约并发锁;没有 Redis,也可以用 Caffeine 做本地缓存,但跨实例的锁就做不了了,所以锁这块我还是建议用数据库悲观锁兜底。

权限这块,我一般建议用 JWT 加拦截器,而不是直接上 Spring Security。不是 Spring Security 不好,而是它的过滤器链配置对新手是个坎,一旦配错,接口 401 能排查一整天。用 JWT 拦截器的方式,代码量少、逻辑透明,答辩时也能讲得清楚。如果导师点名要求 Spring Security,那你需要在SecurityFilterChain里放行登录接口和静态资源,其他路径全部走 JWT 过滤器,这个方案在第四章会讲到。

2.3 项目骨架落地:依赖、配置与包结构

跑通第一个 Spring Boot 程序不难,Spring Initializr 选好依赖,写一个带@RestController的类就能看到 Hello World。这个题目的难度从来不在启动,而在预约业务里那些看不见的约束:时间重叠、并发冲突、状态流转。所以骨架搭好后,重心要放在数据模型上,但先把基础打对。

pom.xml里的核心依赖是这样的:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>

逻辑说明:Spring Boot 3.x 走的是jakarta命名空间,所以 MyBatis-Plus 必须选mybatis-plus-spring-boot3-starter,选成 2.x 的 starter 会直接启动失败。MySQL 驱动在 8.x 里坐标是mysql-connector-j,老写法mysql-connector-java已经被替代。Redis starter 先引进来,用不用分布式锁是一回事,编译环境里得有。

参数说明:版本号能交给 Spring Boot parent 管理的就不要手写,避免依赖冲突。jjwt这类第三方库版本比较老,如果你要用,注意选择兼容 JDK 17 的版本。

application.yml里最关键的配置是时区和日志:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/lab_reserve?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

逻辑说明:serverTimezone=Asia/Shanghai和spring.jackson.time-zone=GMT+8必须一致,否则前端传来的2024-06-01 14:00会被 Jackson 按 UTC 解析成2024-06-01 06:00,入库就错了 8 小时。这个坑在第五章会专门说。

参数说明:log-impl配成StdOutImpl只是开发期方便看 SQL,生产环境一定要改成 Logback 文件输出,否则日志刷屏不说,还拖慢接口。

包结构按下面这样分,接口层要瘦,业务都放 service:

src/main/java/com/example/labreserve/ ├── controller/ # HTTP 接口层,只做参数接收和响应封装 ├── service/ # 预约、审核、统计等业务逻辑 ├── mapper/ # MyBatis-Plus 数据访问层 ├── entity/ # 数据库实体对象 ├── dto/ # 前后端交互的请求和响应对象 ├── common/ # 统一返回体、异常处理、枚举、工具类 └── config/ # WebMvc、Redis、定时任务配置

参数说明:entity里的类字段尽量和数据库列一一对应,DTO 则按页面需要来设计,不要在 controller 里直接返回实体,否则改一个页面要连带改表结构,后面会很被动。

3. 表结构与预约状态机:数据模型定生死

3.1 五张核心表的字段设计与表关系

这个系统的数据模型不是越多表越好,够用就行。我一般建五张表:用户表、实验室表、预约单表、操作日志表,再加一张预约设备关联表,设备表看需求。预约单表是最核心的,字段设计直接决定了冲突检测怎么写。

预约单表reservation的关键字段是这样的:

字段名类型说明
idBIGINT主键,自增
user_idBIGINT预约人 ID
lab_idBIGINT实验室 ID
reserve_dateDATE预约日期
start_timeTIME开始时间
end_timeTIME结束时间
statusVARCHAR(20)状态:PENDING / APPROVED / REJECTED / CANCELED / COMPLETED / ABSENT
purposeVARCHAR(255)预约用途
people_countINT人数
audit_user_idBIGINT审核人 ID
audit_remarkVARCHAR(255)审核备注
audit_timeDATETIME审核时间
create_timeDATETIME创建时间
update_timeDATETIME更新时间

把预约时间拆成reserve_date、start_time、end_time三个字段,而不是用一个start_timeDATETIME 加一个duration,是因为冲突检测要频繁比较“某个时间段是否重叠”,拆开后 SQL 可以用start_time < ? AND end_time > ?直接判断,走索引也方便。status用 VARCHAR 而不是 TINYINT,调试的时候在数据库里看到的是APPROVED而不是2,省得猜,代码里再维护一个枚举常量。

用户表和实验室表不要设计得太复杂。用户表有id、username、password(BCrypt 加密后的密文)、real_name、role、email、phone就够;实验室表有id、name、location、capacity、equipment_desc、open_time_start、open_time_end、status就够。open_time_start和open_time_end表示这个实验室的开放时段,预约的时间段不能超出这个范围。

3.2 预约状态机:为什么不能用删除代替状态

预约单的状态必须用状态字段管理,而不是用户取消就直接删记录。我见过有人把取消做成 DELETE,结果月底统计数据少了一大截,管理员想查历史也查不到。合理的做法是定义六个状态,并且维护一张清晰的状态流转表:

  • PENDING:提交预约,等待审核
  • APPROVED:管理员审核通过
  • REJECTED:管理员驳回
  • CANCELED:用户自行取消或管理员取消
  • COMPLETED:使用完成,正常结束
  • ABSENT:超过开始时间未签到,标记为爽约

流转规则是:PENDING 可以转 APPROVED、REJECTED、CANCELED;APPROVED 可以转 COMPLETED、ABSENT、CANCELED;REJECTED 和 CANCELED、COMPLETED、ABSENT 都是终态,不能再改。代码里最好写一个校验方法,避免谁绕过 service 直接 update:

public enum ReserveStatus { PENDING, APPROVED, REJECTED, CANCELED, COMPLETED, ABSENT; public static boolean canTransition(ReserveStatus from, ReserveStatus to) { if (from == PENDING) { return to == APPROVED || to == REJECTED || to == CANCELED; } if (from == APPROVED) { return to == COMPLETED || to == ABSENT || to == CANCELED; } return false; } }

逻辑说明:这个枚举在 service 层做审核、取消、完成操作时统一调用,比如管理员想驳回一个已经被取消的单子,canTransition直接返回 false,接口报错,数据就不会被改坏。

如果你后面想加状态,比如“使用中”,记得同步改枚举和流转方法,不要只改数据库注释,否则代码里判断会漏。

3.3 建表 SQL 与索引设计:把冲突检测做成数据库能撑住的样子

建表 SQL 建议直接写进项目里的schema.sql,方便别人 clone 下来就能初始化。核心两张表的建表语句如下:

CREATE TABLE lab ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, location VARCHAR(128) NOT NULL, capacity INT NOT NULL DEFAULT 0, equipment_desc VARCHAR(255) COMMENT '实验室自带设备说明', open_time_start TIME NOT NULL, open_time_end TIME NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, lab_id BIGINT NOT NULL, reserve_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'PENDING', purpose VARCHAR(255), people_count INT NOT NULL DEFAULT 1, audit_user_id BIGINT, audit_remark VARCHAR(255), audit_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_lab_date (lab_id, reserve_date), KEY idx_user_date (user_id, reserve_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:idx_lab_date用来支撑“某个实验室某一天有哪些预约”的查询,这是系统里最频繁的查询;idx_user_date用来支撑“我的预约记录”。注意时间重叠的冲突无法用唯一索引直接挡住,因为区间是动态的,所以索引只能加速查询,真正的兜底要靠应用层锁和状态约束,这在第四章会细讲。

参数说明:status字段建议预留索引,如果预约单量大了,按状态筛选管理列表会变慢。people_count默认 1,防止前端不传导致空值。

4. 核心接口实现:把预约、审核、通知串起来

4.1 登录鉴权:用 JWT 拦截器还是引入 Spring Security

我建议用 JWT 加拦截器实现,代码量小且逻辑直观。用户登录成功后,后端生成一个 token,里面放 userId 和 role,过期时间设为 24 小时;前端每次请求在 Header 里带Authorization: Bearer <token>;拦截器校验 token,并把用户信息放到UserContext里,controller 里直接取。

一个精简的拦截器核心逻辑如下:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException(401, "未登录"); } Claims claims = JwtUtil.parse(token.substring(7)); if (claims == null) { throw new BizException(401, "登录已过期"); } UserContext.set(claims.get("userId", Long.class), claims.get("role", String.class)); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }

逻辑说明:UserContext是一个 ThreadLocal,存放当前请求的用户信息,请求结束时在afterCompletion里清理,防止线程池复用导致串数据。业务代码里需要“当前登录用户”时直接UserContext.getUserId(),不用每个接口都从 token 里解析一遍。

参数说明:claims.get("role", String.class)拿到的角色在后面做权限判断用,比如只有ADMIN角色可以调审核接口。登录接口、注册接口和静态资源要在注册拦截器时放行,否则会死循环。

4.2 提交预约:时间重叠检测与并发控制

提交预约是系统里最容易出 bug 的接口,因为它涉及两个问题:时间重叠检测怎么做,以及并发时怎么保证不超卖。先看时间重叠的判断逻辑:

public void checkConflict(ReservationCreateDTO dto) { LocalDate date = dto.getReserveDate(); LocalTime start = dto.getStartTime(); LocalTime end = dto.getEndTime(); LambdaQueryWrapper<Reservation> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Reservation::getLabId, dto.getLabId()) .eq(Reservation::getReserveDate, date) .ne(Reservation::getStatus, "REJECTED") .ne(Reservation::getStatus, "CANCELED") .apply("start_time < {0} AND end_time > {1}", end, start); Long count = reservationMapper.selectCount(wrapper); if (count > 0) { throw new BizException("该时间段已被预约"); } }

逻辑说明:重叠的判断条件是“已有的开始时间小于新结束时间,且已有的结束时间大于新开始时间”。这个条件能同时覆盖四种情况:新时间段完全包含在已有时间段内、已有时间段完全包含在新时间段内、两者交叉、两者首尾相等(如10:00-12:00和12:00-14:00不算冲突,因为旧结束时间12:00不大于新开始时间12:00)。

但这段代码在并发下是有问题的。两个请求同时执行到这里,如果都查出count=0,就都会往下走插入预约单,结果就是同一天同一时段出现两条成功预约。要解决这个问题,常见做法是加数据库悲观锁。在事务里先锁住实验室这一行,让同一实验室的预约请求串行执行:

@Transactional public void createReservation(ReservationCreateDTO dto) { Lab lab = labMapper.selectByIdForUpdate(dto.getLabId()); if (lab == null) { throw new BizException("实验室不存在"); } // 校验预约时间在实验室开放时间内 ... checkConflict(dto); Reservation reservation = new Reservation(); reservation.setUserId(UserContext.getUserId()); reservation.setLabId(dto.getLabId()); reservation.setReserveDate(dto.getReserveDate()); reservation.setStartTime(dto.getStartTime()); reservation.setEndTime(dto.getEndTime()); reservation.setStatus(ReserveStatus.PENDING.name()); reservation.setPurpose(dto.getPurpose()); reservation.setPeopleCount(dto.getPeopleCount()); reservationMapper.insert(reservation); }

对应的 Mapper 写法:

@Select("SELECT * FROM lab WHERE id = #{id} FOR UPDATE") Lab selectByIdForUpdate(@Param("id") Long id);

逻辑说明:SELECT ... FOR UPDATE会锁住实验室表里这一行,直到事务提交才释放。A 请求锁住实验室后,B 请求再执行这条 SQL 就会阻塞等待,A 的事务提交后 B 才能继续,这时 B 再跑checkConflict就能看到 A 插入的记录,从而抛出“该时间段已被预约”。

参数说明:FOR UPDATE必须放在事务里才会生效,如果 service 方法没有加@Transactional,锁在单条 SQL 执行完就释放了,等于没锁。另外,锁的粒度是实验室这一行,不是预约表,所以同一实验室不同时段的预约也会串行等待,但考虑到高校实验室的预约频率,这个吞吐量完全够用。

注意:如果项目里已经引入了 Redis,也可以改用setIfAbsent做分布式锁,锁的 key 设计成lock:lab:{labId}:{reserveDate},并设置 30 秒过期时间作为兜底,防止锁没有释放导致死锁。Redis 锁的方案在第五章的并发避坑里会展开。

4.3 管理员审核:带条件更新与消息通知

审核接口的核心不是简单的 update,而是“只能审核待审核状态的单子,且不能被并发重复处理”。用带条件的 UPDATE 能一举解决这两个问题:

@Transactional public void audit(Long reservationId, Long auditorId, String action, String remark) { Reservation reservation = reservationMapper.selectById(reservationId); if (reservation == null) { throw new BizException("预约单不存在"); } ReserveStatus target = "APPROVED".equals(action) ? ReserveStatus.APPROVED : ReserveStatus.REJECTED; if (!ReserveStatus.canTransition(ReserveStatus.valueOf(reservation.getStatus()), target)) { throw new BizException("当前状态不能执行该审核操作"); } int rows = reservationMapper.updateByCondition(reservationId, target.name(), auditorId, remark); if (rows == 0) { throw new BizException("该预约已被其他管理员处理"); } // 审核通过或驳回后,通过 WebSocket 推送结果给预约人 notifyService.sendReservationResult(reservationId); }

Mapper 里的更新语句:

@Update("UPDATE reservation SET status = #{status}, audit_user_id = #{auditorId}, " + "audit_remark = #{remark}, audit_time = NOW() " + "WHERE id = #{id} AND status = 'PENDING'") int updateByCondition(@Param("id") Long id, @Param("status") String status, @Param("auditorId") Long auditorId, @Param("remark") String remark);

逻辑说明:WHERE条件里带上status = 'PENDING',就是说只有当前状态是待审核的单子才会被更新。两个管理员同时点审核,数据库层面只有一个 UPDATE 能成功,另一个返回 0 行,接口会提示“已被其他管理员处理”。这个思路和乐观锁本质是一样的,用状态字段当版本号。

参数说明:action参数建议在接口层做枚举校验,只允许传APPROVED或REJECTED,否则容易构造出非法状态。remark在驳回时是必填的,前端应该做强校验,让用户知道为什么被拒。

审核后的通知,我一般用 WebSocket 推给前端,学生页面不用刷新就能看到状态变化。Spring Boot 集成 WebSocket 并不需要在 yml 里写spring.websocket这类的配置,核心是引入spring-boot-starter-websocket依赖,再写一个@ServerEndpoint端点和一个配置类注册ServerEndpointExporter即可。

5. 避坑记录:预约系统最容易翻车的五个场景

5.1 时区玄学:前端传 14:00,后端收到 06:00

现象:前端选的是2024-06-01 14:00,后端接口收到的对象里变成了2024-06-01 06:00,存进数据库也是 6 点。

原因:Jackson 在把 JSON 字符串反序列化成LocalDateTime时,用的是 JVM 默认时区。如果服务器运行在 UTC 时区,而前端传的是东八区时间,就会差 8 个小时。这不是一个偶发问题,而是 Spring Boot 全局配置缺失导致的必然结果。

解决:在application.yml里显式指定 Jackson 时区,同时数据库连接串里的serverTimezone要保持一致:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 datasource: url: jdbc:mysql://localhost:3306/lab_reserve?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

参数说明:date-format指定了前端传递日期字符串的格式,前端也必须按yyyy-MM-dd HH:mm:ss传,不要传时间戳。如果你用LocalDate接收2024-06-01,那date-format要改成yyyy-MM-dd,或者单独写@JsonFormat注解。

5.2 LocalDate 与 LocalDateTime 混用导致查询边界多一天

现象:管理员查“6 月 1 日所有预约”,结果列表里混进了 6 月 2 日的记录,或者 6 月 1 日当天的记录查不全。

原因:前端只传了一个日期字符串2024-06-01,后端接口用LocalDate接收,但在查询时新手容易把日期往时间列上套,写成ge(startTime, date)或者between(startTime, date.atStartOfDay(), date.atTime(LocalTime.MAX))。预约表里的时间是分开的reserve_date和start_time,如果你把日期条件写到start_time列上,等于是拿date转换后的2024-06-01T00:00:00去和14:00这种 TIME 类型的值比较,边界完全错乱。

解决:日期条件就老老实实查日期列,用eq精确匹配:

LambdaQueryWrapper<Reservation> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Reservation::getReserveDate, dto.getReserveDate());

逻辑说明:reserve_date是 DATE 类型,LocalDate和它直接比较不会有时区问题,也不用拼一天的开始和结束时间。只有当你的表设计是start_time一个 DATETIME 字段时,才需要ge(date.atStartOfDay())和lt(date.plusDays(1))的组合。所以建表时的字段拆分,到了查询阶段就会体现优势。

5.3 并发预约超卖:同一时间段出现两条成功预约

现象:用压测工具模拟 50 个人同时抢同一实验室的同一时段,结果数据库里出现了两条APPROVED状态的预约记录。

原因:checkConflict是先查询再插入,两个并发请求同时查到count = 0,然后都往下执行插入。单机部署时有人想用synchronized锁住方法,但@Transactional加synchronized有一个经典问题:锁在事务提交之前就释放了,A 线程释放锁后事务可能还没提交,B 线程进方法后依然查不到 A 的数据。集群部署时synchronized更是完全无效。

解决:用数据库悲观锁串行化同一实验室的预约请求:

@Transactional public void createReservation(ReservationCreateDTO dto) { Lab lab = labMapper.selectByIdForUpdate(dto.getLabId()); ... }

如果你有 Redis,也可以用分布式锁:

String lockKey = "lock:lab:" + dto.getLabId() + ":" + dto.getReserveDate(); Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(locked)) { throw new BizException("当前有其他人正在预约该时段,请稍后再试"); } try { checkConflict(dto); reservationMapper.insert(reservation); } finally { redisTemplate.delete(lockKey); }

逻辑说明:setIfAbsent只有在 key 不存在时才能设置成功,所以同一实验室同一日期同时只有一个请求能拿到锁。Duration.ofSeconds(30)是兜底过期时间,防止线程在finally之前崩溃导致锁永久占用。

参数说明:30 秒这个值要明显大于一次预约事务的耗时,但不能设置太长,否则会阻塞后续预约。事务里如果还有别的远程调用,要控制在合理范围内。

5.4 懒加载报错:LazyInitializationException

现象:查询预约列表时,接口返回 JSON 报could not initialize proxy - no Session,或者前端看到某个关联字段是 null。

原因:如果你用的是 Spring Data JPA,关联的User、Lab对象默认是懒加载,Service 层事务结束后 Session 关闭,再取关联属性就报错。如果你用 MyBatis-Plus,不会报这个错,但会出现另一个形态:预约列表里只有user_id,没有用户姓名和实验室名称,前端展示很不友好。

解决:写一个联表查询的 Mapper,用 DTO 直接接收结果:

<select id="selectReservationVO" resultType="com.example.labreserve.dto.ReservationVO"> SELECT r.id, r.reserve_date, r.start_time, r.end_time, r.status, r.purpose, r.people_count, u.real_name AS userName, l.name AS labName FROM reservation r LEFT JOIN user u ON r.user_id = u.id LEFT JOIN lab l ON r.lab_id = l.id WHERE r.id = #{id} </select>

逻辑说明:把关联查询直接写在 SQL 里,一次性把页面需要的数据查出来,而不是在 Java 代码里循环查数据库。如果你的列表要分页,记得把这段 SQL 套到Page查询里,关联条件带上即可。

5.5 日志像黑匣子:SQL 和参数看不到

现象:接口 500,控制台只有异常栈,不知道实际执行的 SQL 是什么,也不知道 MyBatis 传进去的参数值,排错全靠猜。

原因:application.yml里没开 SQL 日志,或者 MyBatis 的日志级别是默认的 info。Spring Boot 默认只会打印一点启动日志,具体 SQL 不会输出。

解决:开发环境在application.yml里给 mapper 包单独开 debug 级别:

logging: level: com.example.labreserve.mapper: debug

逻辑说明:这样设置后,MyBatis 会把预编译 SQL 和绑定的参数值全部打到控制台。排查 5.3 的并发问题时,你可以直接看到两条请求的 SQL 执行顺序,判断锁是否生效。

参数说明:这里的包名要改成你自己的 mapper 接口所在包。生产环境千万别开 debug,否则一天能刷出几个 G 的日志,要用 Logback 把日志按天滚动,并且 SQL 日志只留warn和error级别。Spring Boot 日志配置这块,logback-spring.xml是标准做法,按INFO输出到一个文件、ERROR单独一个文件,保留 30 天就够了。

6. 进阶验证:把系统从“能跑”做到“抗造”

6.1 用 JMeter 模拟并发抢约

系统写完不是能启动就算完,预约系统的命门是并发。我的习惯是写完后用 JMeter 压一遍,模拟真实抢约场景。线程组设 50 个线程,Ramp-Up 设为 1 秒,也就是 50 个人在 1 秒内同时发起预约请求;HTTP 请求里带上登录后的 JWT token,可以用 CSV 文件做参数化,让每个线程用不同账号;断言里检查响应体是否包含success。

跑完后直接查数据库,同一个时间段最多只能有一条APPROVED记录,其余请求要么被锁阻塞后查到了冲突,要么状态还是PENDING。如果你的接口在 50 并发下出现了重复预约,回到第五章的 5.3 检查锁。这一步值得在答辩前多做几轮,我见过太多系统演示时翻车,都是因为没做并发验证。

6.2 补上定时取消与操作日志两个“后悔药”

系统上线后还会遇到一个真实问题:学生提交了预约,管理员忘了审核,预约单一直挂着,状态卡在PENDING。我的处理方式是用定时任务把超时未审核的预约单自动取消:

@Scheduled(cron = "0 */30 * * * ?") @Transactional public void autoCancelTimeoutReservations() { LocalDateTime deadline = LocalDateTime.now().minusHours(2); LambdaUpdateWrapper<Reservation> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Reservation::getStatus, "PENDING") .lt(Reservation::getCreateTime, deadline) .set(Reservation::getStatus, "CANCELED") .set(Reservation::getAuditRemark, "超时未审核自动取消"); int rows = reservationMapper.update(null, wrapper); if (rows > 0) { log.info("自动取消超时预约:{} 条,截止时间:{}", rows, deadline); } }

逻辑说明:cron = "0 */30 * * * ?"表示每 30 分钟执行一次,PENDING状态且创建时间超过 2 小时的单子会被置为CANCELED。更新条件里必须带上status = 'PENDING',防止定时任务把刚审核通过的单子覆盖掉。启动类上记得加@EnableScheduling。

参数说明:2 小时这个阈值按学校的实际情况调,比如要求管理员当天必须审核完,就改成 8 小时。定时任务类要单独放一个类里,不要和 service 写在同一个类中,否则内部自调用会导致@Transactional不生效。

操作日志我建议用@Aspect切面统一记录,审核、取消、修改实验室信息这些都写进operation_log表,出了问题能追溯到人。这个切面本身不复杂,难的是别漏掉那些“看起来不重要”的操作,比如管理员下架实验室,这种操作不改日志,后面排查“为什么约不了”会很痛苦。

现在再回头看这个项目:技术栈是常规的 Spring Boot,难点全在业务约束上——时间重叠怎么判定、并发怎么不超卖、状态怎么不跳变、时区怎么不串。我的个人习惯是每写完一个接口,先问自己三个问题:并发下会不会重复、状态会不会走错、日志能不能跟上。这三个问题挡住我绝大多数翻车现场。高校实验室预约这类系统,复杂度不高,但正因为不高,细节才决定它能不能真正被用起来。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 5:16:04

小米MiMo-V2.6双版本+MoE+SGLang部署实战:Pro与Flash选型及负载均衡调优

1. 小米 MiMo-V2.6 凭什么值得单独写一篇小米这次把 MiMo-V2.6 端出来的时候&#xff0c;我第一反应不是去看榜单&#xff0c;而是先翻它的版本策略。Pro 和 Flash 两个版本&#xff0c;价格一分没涨&#xff0c;这个动作在当前的开源模型圈子里其实挺少见的。大部分团队迭代到…

作者头像 李华
网站建设 2026/10/1 5:15:21

Spring Boot体育馆场地预约系统:源码实战与核心设计解析

每年到了毕设季或者Java学习者找练手项目的时候&#xff0c;我都能在各大平台刷到大量“某某管理系统”的开源仓库。其中有一类几乎年年霸榜——场地预约类系统。而“体育馆场地预约系统”又是这类项目里综合性价比最高的&#xff1a;它既覆盖了用户登录注册、场地信息展示、在…

作者头像 李华
网站建设 2026/10/1 5:15:12

secs4net实战:用老牌.NET库搞定SECS/GEM设备通信与MES对接

简介&#xff1a;secs4net-master 是一套基于 .NET 的 SECS/GEM 协议通信源码工程&#xff0c;面向半导体设备自动化领域开发者&#xff0c;可用于设备与上位机之间的报文交互测试与协议联调。包内共 320 个文件&#xff0c;以 191 个 C# 源码文件为核心&#xff0c;辅以项目工…

作者头像 李华
网站建设 2026/10/1 5:15:01

ZKFPModuleSDK Windows指纹开发实战指南

简介&#xff1a;本资源是面向Windows平台开发者的一站式ZKFPModule SLK20M指纹识别模块SDK开发套件&#xff0c;适用于需集成生物识别功能的C/C桌面应用或服务端系统开发。包内含90个文件&#xff0c;总大小43.44MB&#xff0c;涵盖核心动态库&#xff08;9个DLL&#xff09;、…

作者头像 李华
网站建设 2026/10/1 5:14:22

WeKnora本机部署与RAG调优:解析失败排查及检索命中率提升指南

1. 从热搜词里读懂 WeKnora 的真实定位先把结论摆在前面&#xff1a;WeKnora 不是一个"又一个 RAG 框架"&#xff0c;它更像是腾讯微信团队把内部做知识库问答时踩过的坑&#xff0c;打包成了一套可自部署的工程化方案。你从热搜词里能明显看出大家的关注点集中在几个…

作者头像 李华
网站建设 2026/10/1 5:14:00

Linux 下 jar 包 systemd 自启动与守护实践

1. 为什么要在 Linux 上给 jar 包做自启动与守护1.1 一个真实运维场景引发的思考我第一次遇到这个问题&#xff0c;是在给一家做仓储管理的小公司做部署的时候。服务器上跑着一个 Spring Boot 打包出来的 jar&#xff0c;白天业务在用&#xff0c;晚上我回家睡觉&#xff0c;结…

作者头像 李华