简介:这是一套基于Java Web技术栈开发的会议室预约系统实战项目,面向Java初学者与Web开发入门者,解决企业内部会议室资源调度混乱、预约冲突频发等管理痛点,适用于课程设计、毕业设计或中小团队轻量级协同场景。资源包共744个文件,涵盖20个JSP页面(核心业务逻辑与交互入口)、212个JS脚本(表单验证、日历控件与前端交互)、74个HTML静态页(辅助说明与跳转)、31个CSS样式文件(界面美化)、26个CSS样式文件及229个GIF图标资源(UI组件与状态提示),整体压缩包仅5.01MB,结构清晰、模块完整。已有1659人学习下载,资源包含可直接部署运行的完整工程,含部门/员工/会议室/公告四大核心模块的数据库SQL脚本、Servlet控制层代码、JavaBean数据模型及FCKeditor富文本集成示例(如config.ascx、io.asp等),便于理解MVC分层实践与传统Java Web开发全流程。
1. 为什么一个“会议室预约”系统,反而最容易在毕设答辩里被问住?
不是功能太少,而是太容易想当然。某高校课程设计里,A同学用JSP+Servlet+MySQL搭了个能填时间、选房间、点提交的页面,自测跑通就交了——结果答辩时被连问三轮:“用户重复预约怎么拦?同一时段两个部门同时点提交,谁的请求生效?管理员删掉一条预约后,所有依赖它的会议通知是否自动失效?”——当场卡壳。这暴露了一个现实:会议室预约表面是CRUD,内核却是并发控制、状态流转、业务闭环的微型调度系统。它不追求炫技,但必须经得起“如果……会怎样”的连续追问。本方案面向需要交付完整可运行系统的本科生与初级开发者,覆盖从需求建模(含冲突场景预判)、三层架构落地(JSP做视图层不写Java逻辑)、MySQL事务隔离级实测配置、到6类典型并发/异常场景的防御式编码。所有代码模块可独立编译,数据库脚本带初始化数据,部署即验;文末附答辩高频问题清单与逐条应答话术。你不需要从零造轮子,但得知道每个轮子为什么这么造。
2. 需求解构与核心约束:把“预约”拆成可验证的原子动作
会议室预约不是“用户填表→存库→完事”,而是一组强约束的原子操作链。我们先剥离业务表象,聚焦系统必须守住的底线。
2.1 业务规则显性化:从模糊描述到可执行条款
很多同学直接写“用户可以预约会议室”,但这句话在代码里毫无意义。必须翻译成机器可校验的条款:
- 时间不可重叠:同一会议室在任一时刻只能被一个有效预约占用(注意:“有效”指状态为
confirmed,不包括pending或cancelled); - 资源强绑定:预约记录必须关联唯一会议室ID、唯一申请人ID、唯一时间段(start_time + duration),三者缺一不可;
- 状态机驱动:预约生命周期严格遵循
created → pending → confirmed/cancelled → archived,禁止跨状态跳转(如created直跳archived); - 权限隔离:普通用户仅能操作自己发起的
pending/confirmed预约;管理员可操作全部,但删除操作需记录操作人与时间戳; - 冲突熔断:当检测到时间/资源冲突时,系统必须拒绝提交并返回具体冲突对象(如“该时段已被【市场部周例会】占用”),而非仅提示“预约失败”。
提示:这些条款不是写在文档里就完事,它们将直接映射到数据库约束(如唯一索引)、Service层校验逻辑、甚至前端禁用策略。例如“时间不可重叠”在MySQL中需用
INSERT ... SELECT配合NOT EXISTS子查询实现,而非靠应用层SELECT COUNT(*)再INSERT——后者在高并发下必然漏判。
2.2 数据模型设计:用范式平衡与业务直觉
本系统采用3NF设计,但对高频查询做了适度冗余。核心表结构如下(关键字段加粗标注):
| 表名 | 字段(关键) | 说明 |
|---|---|---|
meeting_room | room_id(PK),room_name,capacity,status(enum: 'available','maintenance') | 会议室基础信息,status用于物理停用,不影响预约逻辑 |
user_info | user_id(PK),username,department,role(enum: 'user','admin') | 用户角色决定操作权限边界 |
reservation | res_id(PK),room_id(FK),user_id(FK),start_time(DATETIME),duration_min(INT),status(enum: 'created','pending','confirmed','cancelled','archived')**,create_time,update_time | 预约主表,start_time+duration_min定义时段,避免存储end_time引发计算不一致 |
reservation_log | log_id(PK),res_id(FK),operator_id,action(enum: 'create','confirm','cancel','delete'),action_time,remark | 操作审计日志,答辩时可演示“谁在何时做了什么” |
关键设计点说明:
reservation表未设end_time字段:因duration_min可能动态调整(如用户延长会议),若存end_time则每次修改需同步更新,易出错。所有时间计算均基于start_time + INTERVAL duration_min MINUTE;status字段用ENUM而非INT:避免非法状态写入,MySQL层面强制校验,减少Service层if-else;reservation_log独立建表:不与reservation耦合,便于按需归档,且审计日志需永久保留,与业务数据生命周期分离。
2.3 并发场景预演:画出你的系统“压力测试地图”
不要等答辩被问才想并发问题。我们提前模拟6类真实压力点,每类对应代码层防御策略:
| 场景 | 触发条件 | 系统风险 | 防御位置 | 验证方式 |
|---|---|---|---|---|
| 双写冲突 | 用户A/B同时预约同一会议室同一时段 | 两条confirmed记录入库,违反时间不可重叠 | MySQL唯一索引+Service层乐观锁 | 手动开两个浏览器Tab并发提交,查数据库是否仅1条成功 |
| 状态越权 | 普通用户调用/admin/delete?id=100接口 | 删除他人预约 | Controller层@PreAuthorize注解+Service层二次校验 | Postman构造带他人ID的DELETE请求,检查返回403 |
| 时间漂移 | 用户提交start_time=2024-05-20 09:00:00,但服务器时区为UTC | 预约时段错位8小时 | 全局统一使用Asia/Shanghai时区,JDBC连接串加serverTimezone=Asia/Shanghai | 在Linux服务器用date命令确认时区,插入后查数据库时间是否匹配 |
| 幻读漏洞 | 管理员查看“今日待确认预约”列表时,另一线程刚确认了一条 | 列表显示已确认的预约,导致重复操作 | 查询列表时用SELECT ... FOR UPDATE锁定行 | 用两个线程交替执行“查列表”和“确认预约”,观察列表是否实时刷新 |
| 脏读风险 | Service方法内部分事务回滚,但已发送邮件通知 | 收到通知却预约失败 | 事务边界包裹整个业务流(含邮件发送),失败则全部回滚 | 故意让邮件发送抛异常,检查数据库预约记录是否未创建 |
| 长事务阻塞 | confirmReservation()方法中嵌套调用耗时API(如钉钉通知) | 数据库连接池耗尽,其他请求超时 | 将非核心操作(通知)拆出事务,用异步队列(如内存队列)解耦 | 压测时监控show processlist,确认无长时间Sleep连接 |
这些不是理论假设,而是某次课程设计部署后真实发生的翻车点。接下来章节,我们将逐个击破。
3. 总体架构设计:JSP+Servlet+MySQL三层结构的务实落地
本方案放弃Spring Boot等框架,回归Servlet原生开发——不是为了复古,而是确保你能清晰看到每一层的数据流向与责任边界。三层结构不是摆设,而是调试时的救命索引。
3.1 技术栈选型依据:为什么坚持用原生Servlet?
- 教学穿透性:JSP本质是Servlet的语法糖,用原生Servlet能让你看清
HttpServletRequest如何解析表单、HttpServletResponse如何写回JSON、Filter如何拦截请求。某高校导师反馈,用Spring Boot的学生在答辩时说不清“@RequestBody是怎么把JSON转成对象的”,而用Servlet的同学能画出ObjectMapper调用链; - 轻量可控:无需管理
application.properties多环境配置、Starter依赖冲突、自动装配原理。一个web.xml文件定义所有Servlet映射,pom.xml仅含javax.servlet-api和mysql-connector-java两个核心依赖; - 调试友好:断点可直接打在
doPost()方法内,观察request.getParameter("room_id")值、session.getAttribute("user_id")状态、SQL执行前后的connection对象。没有代理、反射、AOP的黑匣子干扰。
注意:本方案中的JSP仅作纯视图渲染,禁止在JSP中写Java代码(
<% %>)、调用Service层方法。所有业务逻辑必须在Servlet或Service类中处理,JSP只通过request.setAttribute()接收数据,用JSTL标签(<c:forEach>)遍历展示。这是答辩时老师重点检查的规范项。
3.2 包结构与职责划分:让代码目录成为你的设计说明书
src/ ├── main/ │ ├── java/ │ │ └── com.example.meeting/ │ │ ├── controller/ // Servlet入口,只做参数接收、调用Service、转发/重定向 │ │ │ ├── ReservationServlet.java // 处理预约增删改查 │ │ │ └── AdminServlet.java // 管理员专用操作 │ │ ├── service/ // 业务逻辑核心,含事务管理、状态校验、并发控制 │ │ │ ├── ReservationService.java // 主服务类,@Transactional注解在此 │ │ │ └── NotificationService.java // 通知发送(邮件/站内信),异步解耦 │ │ ├── dao/ // 数据访问,纯SQL操作,不处理业务规则 │ │ │ ├── ReservationDao.java // CRUD方法,返回POJO │ │ │ └── LogDao.java // 审计日志写入 │ │ ├── model/ // POJO实体,与数据库表一一对应 │ │ │ ├── Reservation.java │ │ │ └── User.java │ │ └── util/ // 工具类,如DBUtil(获取Connection)、DateUtil │ └── webapp/ │ ├── WEB-INF/ │ │ ├── web.xml // Servlet注册、Filter配置、欢迎页 │ │ └── jsp/ // JSP页面,按功能分目录 │ │ ├── user/ // 普通用户页面 │ │ │ ├── index.jsp // 首页(可预约列表) │ │ │ └── myReservations.jsp // 我的预约 │ │ └── admin/ // 管理员页面 │ │ └── confirmList.jsp // 待确认列表 │ └── static/ // CSS/JS/图片,静态资源 └── resources/ └── db.properties // 数据库连接配置(driver,url,user,pass)关键约定:
controller层方法名与HTTP路径强对应:ReservationServlet.doPost()处理/reservation所有POST请求,内部用request.getParameter("action")区分create/cancel/confirm;service层方法必须以业务动词开头:createReservation()、confirmReservation()、cancelReservation(),禁止process()、handle()等模糊命名;dao层方法名体现SQL意图:insertReservation()、selectReservationById()、updateReservationStatus(),不出现get()、find()等泛化词。
3.3 数据库连接与事务管理:手写JDBC的硬核细节
不用Hibernate,意味着你要亲手管理Connection、PreparedStatement、Transaction。这不是倒退,而是掌握底层的关键。
// src/main/java/com/example/meeting/util/DBUtil.java public class DBUtil { private static DataSource dataSource; static { try { Properties props = new Properties(); props.load(DBUtil.class.getClassLoader().getResourceAsStream("db.properties")); // 关键:启用MySQL的可重复读隔离级别,并设置时区 String url = props.getProperty("url") + "?useSSL=false&serverTimezone=Asia/Shanghai&transactionIsolation=TRANSACTION_REPEATABLE_READ"; dataSource = new BasicDataSource(); dataSource.setUrl(url); dataSource.setUsername(props.getProperty("user")); dataSource.setPassword(props.getProperty("pass")); dataSource.setInitialSize(5); dataSource.setMaxIdle(10); dataSource.setMinIdle(5); } catch (Exception e) { throw new RuntimeException("DBUtil init failed", e); } } public static Connection getConnection() throws SQLException { Connection conn = dataSource.getConnection(); // 关键:所有业务操作默认开启事务,避免自动提交 conn.setAutoCommit(false); return conn; } }为什么选TRANSACTION_REPEATABLE_READ?
这是MySQL默认隔离级别,能防止脏读、不可重复读,且比SERIALIZABLE性能更好。对于会议室预约,“幻读”风险可通过应用层唯一索引+业务校验兜底,无需牺牲并发性能。
事务边界在哪?
在ReservationService的每个公共方法上:
// src/main/java/com/example/meeting/service/ReservationService.java public class ReservationService { private final ReservationDao reservationDao; private final LogDao logDao; public ReservationService() { this.reservationDao = new ReservationDao(); this.logDao = new LogDao(); } @Transactional // 此注解为示意,实际用try-catch手动commit/rollback public boolean createReservation(Reservation reservation) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); // 1. 校验时间冲突(关键:用SELECT FOR UPDATE锁定相关行) if (hasTimeConflict(conn, reservation.getRoomId(), reservation.getStartTime(), reservation.getDurationMin())) { throw new BusinessException("该时段已被占用"); } // 2. 插入预约记录 reservationDao.insertReservation(conn, reservation); // 3. 记录操作日志 logDao.insertLog(conn, reservation.getResId(), reservation.getUserId(), "create", "预约创建"); conn.commit(); // 显式提交 return true; } catch (BusinessException e) { if (conn != null) conn.rollback(); // 业务异常回滚 throw e; } catch (Exception e) { if (conn != null) conn.rollback(); // 系统异常回滚 throw new RuntimeException("预约创建失败", e); } finally { if (conn != null) conn.close(); } } }避坑 / 常见问题 / 排查 / 注意
3.4 并发与事务避坑指南:那些让你深夜改bug的细节
现象:本地Tomcat测试正常,部署到Linux服务器后频繁出现“预约失败:该时段已被占用”,但数据库查不到冲突记录。
原因:服务器JVM时区与MySQL时区不一致。Java默认用系统时区,若Linuxdate显示UTC,而MySQLSELECT @@time_zone返回SYSTEM(即UTC),但Javanew Date()生成的时间戳被当作CST(东八区)处理,导致时间计算偏移8小时。
解决:在db.properties的JDBC URL中强制指定serverTimezone=Asia/Shanghai,并在Java代码中所有时间操作前加TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));。现象:用户点击“取消预约”后,页面跳转到首页,但数据库中状态仍是
confirmed。
原因:Servlet中调用了cancelReservation()方法,但未检查其返回值,也未处理SQLException。异常被吞掉,conn.rollback()未执行,事务自动提交。
解决:所有Service方法调用后必须判断返回值(如if (!service.cancelReservation(id)) { request.setAttribute("error", "取消失败"); }),且catch块中必须显式rollback()并重新抛出。现象:管理员在“待确认列表”点击确认后,该预约消失,但其他用户刷新列表仍能看到。
原因:查询列表的SQL未加FOR UPDATE,导致读取的是快照数据。当A线程确认预约(更新status)时,B线程的查询结果还未刷新。
解决:在AdminServlet查询待确认列表时,DAO层使用SELECT * FROM reservation WHERE status='pending' FOR UPDATE,确保读取最新数据并锁定行。现象:高并发预约时,MySQL报错
Lock wait timeout exceeded。
原因:SELECT ... FOR UPDATE锁定了过多行(如查询所有可用会议室),或锁持有时间过长(如在事务中调用外部API)。
解决:缩小锁范围——只对目标会议室ID加锁;将耗时操作(如邮件发送)移出事务,用ExecutorService异步执行。现象:用户修改预约时间后,原时段未释放,新时段又无法预约(因原记录status未变)。
原因:updateReservation()方法只更新了start_time和duration_min,未校验新时段是否与自身旧时段冲突,也未将旧状态置为cancelled。
解决:修改逻辑为“先取消原预约(update status to cancelled),再创建新预约(insert new record)”,确保状态机流转正确。
4. 核心模块实现:从预约创建到状态确认的完整链路
现在进入最硬核的部分:把需求条款翻译成可运行的代码。我们聚焦三个高频操作——创建预约、确认预约、取消预约,每个都包含数据库交互、并发防护、错误反馈的完整闭环。
4.1 预约创建:用唯一索引+业务校验双保险防冲突
创建预约是并发冲突最高发的环节。单纯依赖应用层SELECT COUNT(*)校验,在高并发下必然失败。本方案采用“数据库唯一约束兜底 + 应用层精准提示”组合拳。
第一步:建唯一索引,让MySQL替你挡枪
-- 在reservation表上创建复合唯一索引,覆盖"会议室+时段"维度 CREATE UNIQUE INDEX uk_room_time ON reservation (room_id, start_time, duration_min);但此索引有缺陷:start_time和duration_min组合不能精确表达“时段重叠”。例如A预约09:00-10:00(60分钟),B预约09:30-10:30(60分钟),两者start_time不同、duration_min相同,索引不触发冲突。因此必须辅以应用层校验。
第二步:Service层精准冲突检测(带行锁)
// src/main/java/com/example/meeting/service/ReservationService.java private boolean hasTimeConflict(Connection conn, int roomId, Timestamp startTime, int durationMin) throws SQLException { // 计算时段边界:start_time 和 end_time Timestamp endTime = new Timestamp(startTime.getTime() + durationMin * 60 * 1000L); // 关键:用SELECT FOR UPDATE锁定所有可能冲突的记录 String sql = "SELECT res_id FROM reservation " + "WHERE room_id = ? AND status = 'confirmed' " + "AND ( " + " (? BETWEEN start_time AND DATE_ADD(start_time, INTERVAL duration_min MINUTE)) OR " + " (? BETWEEN start_time AND DATE_ADD(start_time, INTERVAL duration_min MINUTE)) OR " + " (start_time BETWEEN ? AND ?) " + ") " + "FOR UPDATE"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, roomId); ps.setTimestamp(2, startTime); // 新预约start ps.setTimestamp(3, endTime); // 新预约end ps.setTimestamp(4, startTime); // 新预约start ps.setTimestamp(5, endTime); // 新预约end try (ResultSet rs = ps.executeQuery()) { return rs.next(); // 有记录即冲突 } } }第三步:Controller层参数校验与错误透传
// src/main/java/com/example/meeting/controller/ReservationServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("create".equals(action)) { try { // 1. 参数校验(非空、数字格式) int roomId = getIntParam(request, "room_id"); String startStr = request.getParameter("start_time"); int duration = getIntParam(request, "duration_min"); // 2. 转换时间(强制指定时区) SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); sdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); Timestamp startTime = new Timestamp(sdf.parse(startStr).getTime()); // 3. 构建预约对象 Reservation reservation = new Reservation(); reservation.setRoomId(roomId); reservation.setUserId((Integer) request.getSession().getAttribute("user_id")); reservation.setStartTime(startTime); reservation.setDurationMin(duration); reservation.setStatus("pending"); // 初始状态为pending,待管理员确认 // 4. 调用Service boolean success = reservationService.createReservation(reservation); if (success) { request.setAttribute("message", "预约已提交,等待管理员确认"); } else { request.setAttribute("error", "预约失败,请重试"); } } catch (ParseException e) { request.setAttribute("error", "时间格式错误,请按 yyyy-MM-dd HH:mm:ss 输入"); } catch (BusinessException e) { // 业务异常(如冲突),透传给前端 request.setAttribute("error", e.getMessage()); } catch (Exception e) { request.setAttribute("error", "系统繁忙,请稍后重试"); e.printStackTrace(); // 开发期打印,上线改为日志 } } request.getRequestDispatcher("/WEB-INF/jsp/user/index.jsp").forward(request, response); }设计说明:
getIntParam()是工具方法,封装Integer.parseInt()并捕获NumberFormatException,避免空指针;- 时间转换强制
TimeZone.getTimeZone("Asia/Shanghai"),确保与数据库时区一致; status设为pending而非confirmed,体现“预约需审批”的业务规则;BusinessException是自定义异常,专用于携带用户友好的错误消息,区别于SQLException等系统异常。
4.2 管理员确认:状态机驱动的原子操作
确认预约不是简单UPDATE status='confirmed',而是状态机的一次跃迁,需满足前置条件、记录审计、触发通知。
// src/main/java/com/example/meeting/service/ReservationService.java public boolean confirmReservation(int resId, int operatorId) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); // 1. 检查当前状态是否为'pending'(状态机约束) Reservation current = reservationDao.selectReservationById(conn, resId); if (!"pending".equals(current.getStatus())) { throw new BusinessException("只能确认待确认状态的预约"); } // 2. 再次校验时间冲突(防止在pending期间被其他pending预约抢占) if (hasTimeConflict(conn, current.getRoomId(), current.getStartTime(), current.getDurationMin())) { throw new BusinessException("该时段已被其他预约占用,请刷新列表"); } // 3. 更新状态 reservationDao.updateStatus(conn, resId, "confirmed"); // 4. 记录审计日志 logDao.insertLog(conn, resId, operatorId, "confirm", "管理员确认预约"); // 5. 异步发送通知(移出事务) notificationService.sendConfirmationEmailAsync(current, operatorId); conn.commit(); return true; } catch (Exception e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) conn.close(); } }关键点:
- 双重校验:先查状态,再校冲突,确保即使
pending状态被并发修改,也能及时发现; - 异步通知:
sendConfirmationEmailAsync()内部使用Executors.newSingleThreadExecutor(),避免邮件发送超时拖垮事务; - 审计必留:
logDao.insertLog()在事务内执行,确保操作与状态变更原子性。
4.3 用户取消:软删除与状态回滚的边界
用户取消预约需区分场景:pending状态可直接取消;confirmed状态需管理员二次审核(本方案简化为允许用户取消,但记录cancelled_by_user标记)。
// src/main/java/com/example/meeting/service/ReservationService.java public boolean cancelReservation(int resId, int userId) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); Reservation reservation = reservationDao.selectReservationById(conn, resId); // 1. 权限校验:只能取消自己的预约 if (reservation.getUserId() != userId) { throw new BusinessException("无权操作他人预约"); } // 2. 状态校验:confirmed状态需特殊处理 if ("confirmed".equals(reservation.getStatus())) { // 允许用户取消,但状态变为'cancelled_by_user',便于管理员追溯 reservationDao.updateStatusAndRemark(conn, resId, "cancelled_by_user", "用户主动取消"); } else if ("pending".equals(reservation.getStatus())) { reservationDao.updateStatus(conn, resId, "cancelled"); } else { throw new BusinessException("当前状态不允许取消"); } logDao.insertLog(conn, resId, userId, "cancel", "用户取消预约"); conn.commit(); return true; } catch (Exception e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) conn.close(); } }设计说明:
updateStatusAndRemark()是DAO层新增方法,更新status和remark字段,remark用于记录取消原因;- 不提供
delete from reservation物理删除,所有操作均为状态变更,符合数据审计要求; cancelled_by_user状态在统计报表中可单独过滤,不影响confirmed总数。
5. 验收与答辩:用可演示的证据链说服评委
验收不是“跑起来就行”,而是构建一条从需求条款到代码实现、再到运行证据的完整证据链。评委要的不是你“知道”,而是你“做到并验证过”。以下是我带过的多个项目总结出的硬核验收法——不讲虚的,只列你能立刻执行的动作。
5.1 功能验收清单:对照需求条款逐条演示
拿出一张白纸,按2.1节的5条业务规则,每条对应一个可截图的操作步骤。不要口头描述,要让评委看到屏幕上的真实证据:
| 需求条款 | 演示步骤 | 期望结果(截图证据) | 评委关注点 |
|---|---|---|---|
| 时间不可重叠 | 1. A用户预约“会议室A” 09:00-10:00 2. B用户在同一秒提交相同预约 | B用户页面显示红色提示:“该时段已被【A用户】占用”;数据库reservation表仅有1条confirmed记录 | 是否精准定位冲突对象(显示A用户),而非笼统提示“已占用” |
| 状态机驱动 | 1. 查看数据库reservation表,找到一条status='created'的记录2. 手动执行 UPDATE reservation SET status='archived' WHERE res_id=xxx | 页面刷新后,该预约在“我的预约”列表中消失;但reservation_log表中无对应archived操作日志 | 是否阻止非法状态跳转(created直跳archived) |
| 权限隔离 | 1. 普通用户登录,打开浏览器开发者工具 2. 修改URL为 /admin/confirmList.jsp | 页面显示404或重定向至登录页;Network面板可见302跳转 | 是否在Controller层拦截,而非仅靠前端隐藏链接 |
| 冲突熔断 | 1. 管理员在“待确认列表”选中一条预约 2. 在另一个Tab中,用户取消该预约 3. 管理员点击“确认”按钮 | 页面弹出提示:“该预约已被取消,无法确认”;数据库状态仍为cancelled | 是否在确认前二次校验状态,而非仅依赖列表缓存 |
提示:答辩前用手机录屏,把上述4个场景录成1分钟短视频。评委没耐心看你现场操作,但会认真看你的证据。视频命名为
验收证据_时间冲突.mp4,答辩时直接播放。
5.2 并发压力验证:用最简工具证明你的系统可靠
别信“理论上没问题”。用ab(Apache Bench)这种命令行工具,3分钟就能证明你的并发防护是否真有效。
步骤:
- 启动Tomcat,确保数据库连接池足够(
db.properties中maxIdle=10); - 打开终端,执行:
其中# 模拟10个用户,各发起5次预约请求(共50次),-k表示keep-alive复用连接 ab -n 50 -c 10 -p reservation_data.txt -T "application/x-www-form-urlencoded" http://localhost:8080/reservationreservation_data.txt内容为:action=create&room_id=1&start_time=2024-05-20+09%3A00%3A00&duration_min=60 - 查看
ab输出的Failed requests: 0(失败请求数为0)和Concurrency Level: 10(并发级别10); - 登录MySQL,执行:
SELECT COUNT(*) FROM reservation WHERE room_id=1 AND status='confirmed'; -- 结果应为1(因唯一索引限制),而非10 SELECT COUNT(*) FROM reservation_log WHERE action='create'; -- 结果应为50(所有请求都记录了日志,证明无丢失)
评委想看到的:你不仅知道SELECT FOR UPDATE,还能用标准工具量化它的效果。如果ab报告Failed requests: 12,说明你的锁机制或事务边界有漏洞,必须现场修复。
5.3 答辩高频问题应答话术:把“不会”变成“我这样设计”
评委最爱问“如果……怎么办?”,这不是刁难,而是考察你的设计深度。以下是6个必问题及应答逻辑(不说“我没想到”,而说“我这样设计是因为……”):
| 问题 | 应答话术(核心逻辑) | 证据支撑点 |
|---|---|---|
| Q:如果管理员误点了两次“确认”,会不会重复发送邮件? | “不会。confirmReservation()方法在更新状态前,会先SELECT当前状态。第二次点击时,状态已是confirmed,直接抛出BusinessException,事务回滚,邮件服务根本不会触发。” | 指向ReservationService.java第XX行if (!"pending".equals(current.getStatus()))判断 |
| Q:用户取消预约后,已发的通知怎么撤回? | “技术上无法撤回已发送的邮件,但业务上我们规避了这个问题:取消操作发生在通知发送之后,且通知内容明确标注‘此预约尚未最终确认’。用户取消后,系统不再发送新通知,历史通知自然失效。” | 展示notificationService.sendConfirmationEmailAsync()方法中邮件模板的措辞 |
Q:为什么用pending状态,而不是直接confirmed? | “因为会议室预约本质是资源协调,需人工干预。pending状态让我们能:1)在确认前拦截冲突;2)给管理员留出审核窗口;3)支持批量确认。如果直接confirmed,冲突只能靠事后撤销,用户体验更差。” | 对比演示:pending流程(用户提交→管理员列表→确认)vs 直接confirmed(用户提交即占用) |
Q:MySQL的REPEATABLE READ能防止幻读吗?你们怎么处理? | “REPEATABLE READ在MySQL中通过MVCC避免幻读,但我们的SELECT ... FOR UPDATE进一步加锁,确保查询结果集稳定。更重要的是,我们用唯一索引+业务校验双保险,幻读即使发生,也会被后续的INSERT唯一约束拦截。” | 指向数据库SHOW CREATE TABLE reservation输出中的uk_room_time索引 |
| Q:如果服务器宕机,事务中的预约数据会丢失吗? | “不会。MySQL的InnoDB引擎有redo log,事务提交前日志已刷盘。只要innodb_flush_log_at_trx_commit=1(默认值),宕机后重启能通过日志恢复未完成事务。” | 展示my.cnf中innodb_flush_log_at_trx_commit=1配置 |
| Q:这个系统能支撑1000人同时预约吗? | “单机Tomcat+MySQL在合理配置下,QPS 50-100是可靠的。如果需要更高并发,我会:1)加Redis缓存热门会议室列表;2)用消息队列削峰;3)读写分离。但课程设计的目标是理解核心逻辑,而非架构扩展。” | 承认局限,但给出可落地的演进路径,而非画大饼 |
从那以后我每次带学生做毕设,都会强制他们在答辩前完成三件事:1)录好4个核心验收视频;2)用ab跑通并发测试并截图;3)把这6个问题的答案写在便利贴上,贴在显示器边框。不是为了背答案,而是让设计逻辑刻进肌肉记忆——当评委问“为什么”,你脱口而出的不是“因为老师说要这样”,而是“因为这里有个时间窗口,必须用行锁堵住”。希望帮到你。
本文还有配套的精品资源,点击获取