☰
JavaWeb教务管理系统:选课并发控制与事务一致性设计
2026/11/2 13:43:06 网站建设 项目流程

简介:面向JavaWeb初学者、课程设计学习者与毕业设计参考者的教务管理系统实战项目,基于MVC设计模式与DAO模式组织分层代码,使用Servlet、JSP、Filter与MySQL数据库实现,可在Windows环境下配合Tomcat 7与JDK1.8运行。系统以登录和权限控制为主线,划分学生、教师、教务员、系统管理员四类角色,覆盖找回密码、修改密码、注销、成绩查询、选修与考级报名、学籍信息查看修改、考级成绩查询、教师个人信息维护、教务员成绩管理、报名学生名单管理及管理员用户管理等功能。压缩包共223个文件,约2.6MB,含82个Java源码、82个class、38个JSP页面以及若干jar、xml、sql等配置与数据脚本,便于按控制层、业务层、数据访问层阅读调试。已有11659人学习,适合借鉴其目录组织、过滤器拦截、数据库访问和角色权限设计,并依据README说明快速启动,在待完善功能上继续扩展。

1. 选课第三周的那次并发,决定了这套教务系统该怎么设计

每年春季学期第三周,选课系统都要承受一次真实的压力测试:几百个学生在同一分钟里抢同一门课的几十个名额。多数教务管理系统项目就栽在这里——先select查余量、再update扣减,中间隔着几毫秒窗口,超卖就发生了。这套基于 JavaWeb 的教务管理系统方案,就围绕这个场景把需求、架构、模块和验收完整走一遍:三类角色、八张核心表、四个功能模块,用 Servlet + JSP + JDBC 这套组合实现,跑在 Tomcat 上。适合正在做毕设或课程设计、需要一份既讲得清原理又跑得起来的人。

2. 需求与角色边界:三类角色、八张表撑起的最小闭环

动手建表之前,先想清楚一件事:教务系统最容易翻车的地方,往往不在功能多少,而在权限边界没有划分清楚。我见过不少课程设计,学生登录后能在地址栏里把studentId一改,看到别人的成绩。功能是齐的,但这种方案拿出去答辩,导师随便点两下,整个方案的可信度就没了。所以第 2 章先把角色、主流程、技术选型这三件事讲清楚,后面所有代码才有依据。

2.1 权限边界:三个角色、一张矩阵,别做第四种

教务系统的角色划分通常就是这么三类:学生、教师、教务管理员。有些方案会再加“院系管理员”“督导”“系主任”,听起来完整,实际上每个多出来的角色都要配一套菜单、一套数据范围、一套测试用例,工作量翻倍,而答辩时并不会因此加分。我一般的建议是:只做三类角色,把院系维度做成教师表上的一个字段,需要按院系统计时用where dept_id = ?筛选即可。

权限的落地方式有两种。一种是每个 JSP 页面顶部写if (role != "admin") return;,这种写法在功能少的时候没问题,一旦页面过了二十个,你根本记不住哪个页面漏了判断。另一种是把权限判断前置到统一的入口,用 URL 前缀映射角色,页面里只负责展示。后者是通常的做法,因为它让“有没有权限”变成一个可枚举、可测试的问题,而不是靠人肉巡检。

权限矩阵按“资源 + 动作”来列,比按菜单列更细,也更方便写测试用例:

资源学生教师管理员
个人课表读自己读自己所教读全部
选课记录增删自己读自己所教课程增删改全部
成绩读已发布录入、修改未发布审核、发布、导出
课程与开课班只读只读增删改
用户与班级无无增删改

这张表的价值在于,它直接对应后面 Service 层的方法命名。看到“学生只能读已发布成绩”,ScoreService.listForStudent()里就必然带一个status = 'PUBLISHED'的条件,不允许由前端传参决定。把权限写进 SQL 条件而不是写进 if 判断,是这套方案里最重要的一条设计约束。

还有一条容易被忽略:数据范围权限和功能权限是两回事。教师能“录入成绩”是功能权限,但只能录“自己教的课”是数据范围权限。后者必须在 DAO 层用teacher_id作为参数参与查询,否则教师 A 改一下表单里的classId,就能改教师 B 的课。

2.2 三条主流程:选课、成绩、课表,其余都是配套

功能模块再多,核心就是三条流程。把这三条跑通,剩下的用户管理、公告管理、日志查询都是配套的增删改查,实现上高度同构,写起来很快。

选课流程的判定顺序很关键,顺序错了会出现“名额扣了但选课失败”的脏数据。通常的顺序是:判断是否已选过(唯一索引兜底)→ 判断学期是否在选课窗口内 → 判断学分上限 → 判断时间冲突 → 扣减名额 → 写入选课记录。前四步都是只读校验,可以放在事务外快速失败;后两步必须在同一个事务里,任何一步失败整体回滚。

成绩流程要处理“未发布”和“已发布”两个状态。教师录入后是DRAFT,可以反复修改;提交后是SUBMITTED,教师不能再改;管理员审核通过后是PUBLISHED,学生可见。很多课程设计把成绩录入做成直接可见,学生端立刻刷新就能看到,看着方便,但一旦老师录错一位数字,没有回退余地,这在真实现场是要出事故的。

课表流程本质是一个查询:按学期查这个学生所有已选的开课班,每个班有固定的星期、节次、周次范围,前端按“星期 × 节次”渲染成二维网格。这里不需要复杂的排课算法,教务系统里的排课是管理员线下协调好的结果,系统只负责记录和展示。有些同学非要写遗传算法自动排课,结果算法跑十分钟出一个仍有冲突的方案,反而成了减分项。

提示:选课窗口、学分上限、单门课容量这三个参数不要硬编码在 Java 里,放进sys_config表或常量类,验收演示时改一个数字就能说明系统支持配置,比讲十句设计模式管用。

2.3 技术选型:为什么坚持 Servlet + JSP + JDBC

技术栈的选择要看目的。如果这套系统的目的是“练完整工程能力并能在答辩时讲清楚每一层”,那 JDK 8 + Servlet 3.1 + JSP 2.3 + JDBC + MySQL 8 + Tomcat 9 依然是最合适的组合。它没有中间件黑匣子,请求从*.do进来,经过 Filter、Servlet、Service、DAO,最后到一条 SQL,每一环你都能在代码里指出来。

如果换成 Spring Boot + MyBatis,开发效率确实高,但答辩时被问“这个请求是怎么到你的方法上的”,多数人只能答“注解自动映射的”,再往深处就答不上来。这不是说框架不能用,而是说在需要讲原理的场景下,自己组装一遍的价值更大。

层面选型理由
运行环境JDK 8 / Tomcat 9兼容性最好,教程与排错资料最多
表现层JSP + JSTL + EL无需额外构建工具,页面直接部署
控制层Servlet 3.1 注解映射免 web.xml,URL 一眼看清
数据访问原生 JDBC + PreparedStatement能讲清连接、事务、结果集映射
数据库MySQL 8 + InnoDB支持行锁与事务,选课并发的基础
前端原生 HTML/CSS/JS + 少量 jQuery不引入构建链,改完刷新即可

需要提醒的是,原生 JDBC 不等于满地try-catch-finally。DAO 层一定要抽一个BaseDao出来,把连接获取、参数绑定、结果集映射、资源关闭收口到一处,业务 DAO 只写 SQL 和映射。否则二十个 DAO 类里会有二十份重复的关闭逻辑,其中至少有三份忘了关ResultSet。

3. 总体架构设计:三层结构落地成四个包和八张表

分层这件事,讲起来人人都会说“三层架构”,但真正落到目录上,很多项目是这样的:Servlet 里拿了HttpServletRequest,顺手就写了一段 SQL。这样的代码在演示时跑得通,在改动时就是灾难——想加一个“导出成绩”的功能,你会发现查询逻辑和请求参数、Session 取值搅在一起,没法复用。架构设计的价值不在于画得好看,而在于让“改动”这个动作有明确的落点。

3.1 分层怎么切:表现层、业务层、DAO 层各管什么

表现层(Servlet + JSP)只做三件事:接收参数并做格式校验、调用 Service、根据结果选择跳转或转发。它不应该出现 SQL,也不应该出现跨多个表的业务规则。判断标准很朴素:如果把这套系统从网页换成手机接口,Servlet 全部重写,Service 和 DAO 一行不改,那分层就是对的。

业务层(Service)承载所有业务规则:选课能不能选、成绩能不能改、名额够不够、学分超没超。它接收的是 Java 基本类型和实体对象,不接收HttpServletRequest。这条约束能挡住绝大多数“业务逻辑泄漏到 Servlet”的情况。Service 方法内部负责开启和提交事务,一个 Service 方法对应一个事务边界。

DAO 层(Repository)只负责一件事:把 SQL 执行掉,把结果映射成实体。它不做判断,不做校验,不开启事务。DAO 方法名建议直接对应 SQL 语义,findById、countByClassIdAndStatus、updateSelectedIfAvailable,名字读出来就知道它干了什么。

实体层(Entity)是数据库表的 Java 映射,字段与列名一一对应,只放 getter/setter 和一个无参构造。不要在实体里加业务方法,也不要把实体直接当 VO 返回给 JSP——需要组合字段时(比如课表里要同时显示课程名、教师名、教室)单独建一个 VO,实体保持纯净。

3.2 目录与包结构:按层分还是按功能分

小项目按层分就够了,包名统一前缀,避免和第三方库冲突:

src/main/java/com/example/edu/ ├── common/ # 通用能力 │ ├── BaseDao.java # 连接获取、参数绑定、资源关闭 │ ├── PageBean.java # 分页结果封装 │ ├── DBUtil.java # 数据源初始化(Druid 或内置连接池) │ └── BusinessException.java # 业务异常,带错误码与提示语 ├── entity/ # 实体,与表一一对应 │ ├── Student.java Teacher.java Course.java │ ├── CourseClass.java CourseSelection.java Score.java ├── dao/ # 数据访问,只写 SQL │ ├── StudentDao.java CourseClassDao.java ScoreDao.java ├── service/ # 业务规则与事务边界 │ ├── AuthService.java SelectionService.java ScoreService.java ├── web/ # Servlet 控制器 │ ├── LoginServlet.java SelectionServlet.java ScoreServlet.java └── filter/ └── AuthFilter.java # 统一鉴权与编码 webapp/ ├── WEB-INF/views/ # JSP 页面,禁止直接 URL 访问 └── static/ # css / js / 图片

这里有两个细节值得说明。第一,JSP 全部放进WEB-INF/views/,这样浏览器无法直接访问student/list.jsp,只能经过 Servlet 转发;否则用户绕过 Servlet 直接打开页面,鉴权和数据准备全部失效。第二,common包里放BusinessException,Service 抛出它,Servlet 统一捕获后转发到错误页并带上提示语,避免每个 Servlet 都写一套try-catch去拼错误字符串。

3.3 数据库表设计:八张表,两个唯一索引

表设计决定了后面代码的复杂度。八张表的分工是:sys_user存登录凭证,student和teacher存业务属性,course是课程字典,course_class是“某学期某教师开的某门课”,course_selection是选课关系,score是成绩,semester是学期。学生选的是course_class而不是course,这一点在建模时就必须分清,否则同一个课程号在不同学期会冲突。

-- 开课班:选课的真正目标对象,capacity 与 selected 是并发核心 CREATE TABLE course_class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT '课程字典ID', teacher_id BIGINT NOT NULL COMMENT '授课教师', semester_id BIGINT NOT NULL COMMENT '学期', classroom VARCHAR(32) NOT NULL COMMENT '上课地点', week_day TINYINT NOT NULL COMMENT '1-7', start_section TINYINT NOT NULL COMMENT '起始节次', end_section TINYINT NOT NULL COMMENT '结束节次', capacity INT NOT NULL DEFAULT 60 COMMENT '容量', selected INT NOT NULL DEFAULT 0 COMMENT '已选人数', status TINYINT NOT NULL DEFAULT 1 COMMENT '1开放 0停开', KEY idx_semester (semester_id), KEY idx_teacher (teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课记录:唯一索引是防重复选的最后一道防线 CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_class_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_class (student_id, course_class_id), KEY idx_class (course_class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 成绩:一门课一个学生一条记录,状态机由 status 控制 CREATE TABLE score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, selection_id BIGINT NOT NULL COMMENT '关联选课记录', usual_score DECIMAL(5,2) COMMENT '平时分', exam_score DECIMAL(5,2) COMMENT '考试分', total_score DECIMAL(5,2) COMMENT '总评', grade_point DECIMAL(3,2) COMMENT '绩点', status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/SUBMITTED/PUBLISHED', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_selection (selection_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

参数说明与设计取舍:course_class.selected是个冗余计数,理论上可以用count(course_selection)实时算,但每次选课都要统计一遍再判断,读放大严重,所以用冗余字段 + 条件更新来保证一致性。uk_student_class唯一索引不是可选项——即使 Service 层已经判断过“是否已选”,两个并发请求仍可能同时通过这个判断,唯一索引会让第二个插入直接失败,Service 捕获SQLIntegrityConstraintViolationException后转成友好提示。

字符集统一utf8mb4,不要用utf8,否则学生姓名里生僻字或表情符号会插入失败,这类问题在验收现场很难解释。所有涉及金额、分数的字段用DECIMAL不用FLOAT,避免 59.999 显示成 59.99 的尴尬。

4. 核心模块实现:登录鉴权、抢名额、成绩录入

设计讲完,落到代码。这一章的四个模块是这套系统里最值得写进论文和答辩 PPT 的部分,因为它们各自解决了一个具体问题,而不是简单的增删改查。

4.1 登录鉴权:一个 Filter 拦住所有越权请求

登录本身很简单,难点在“登录之后怎么保证每个请求都是合法的”。做法是登录成功时把用户信息写进 Session,再用一个 Filter 拦截所有请求,检查 Session 和 URL 是否匹配当前角色。

@WebFilter(urlPatterns = "/*") public class AuthFilter implements Filter { // 白名单:登录页、登录接口、静态资源不需要登录 private static final Set<String> WHITE_LIST = new HashSet<>(Arrays.asList("/login", "/login.jsp", "/static")); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 统一编码放这里,避免每个 Servlet 都写一遍 request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); String uri = request.getRequestURI().substring(request.getContextPath().length()); if (isWhite(uri)) { chain.doFilter(req, resp); return; } HttpSession session = request.getSession(false); LoginUser user = session == null ? null : (LoginUser) session.getAttribute("LOGIN_USER"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 按 URL 前缀判定角色,前缀与权限矩阵一一对应 if (uri.startsWith("/admin/") && !"ADMIN".equals(user.getRole()) || uri.startsWith("/teacher/") && !"TEACHER".equals(user.getRole()) || uri.startsWith("/student/") && !"STUDENT".equals(user.getRole())) { response.sendError(403, "无权访问"); return; } chain.doFilter(req, resp); } private boolean isWhite(String uri) { return WHITE_LIST.stream().anyMatch(uri::startsWith); } }

这段代码的逻辑是:先放行白名单,再检查 Session 里有没有登录用户,最后按 URL 前缀校验角色。参数上,request.getSession(false)表示“没有会话就不要新建”,避免未登录用户每访问一次就创建一个空 Session 撑爆内存。sendError(403)而不是重定向到首页,是因为重定向会掩盖越权行为,排查问题时看不到痕迹。

还有一种更细的做法:把“角色 → 允许的 URL 正则”配置化,写在一个 Map 里,Filter 只负责匹配。这样做的好处是新增角色时不用改 Filter 代码。但要注意,URL 前缀鉴权只能挡住“能不能进这个入口”,挡不住“能不能看这条数据”,后者必须在 SQL 里带student_id、teacher_id条件,这一点在 2.1 节已经强调过,代码里不要偷懒。

4.2 选课容量控制:用条件更新代替先查后改

这是全文最该讲清楚的一段。直觉写法是:

1. select selected, capacity from course_class where id = ? 2. if (selected < capacity) { 3. update course_class set selected = selected + 1 where id = ? 4. insert into course_selection ... 5. }

问题出在第 2 行和第 3 行之间。两个学生同时执行到第 2 行,都看到selected = 59, capacity = 60,都判定“还有名额”,然后都执行第 3 行,selected变成 61,超卖。这就是典型的检查与使用之间存在的竞态。

正确做法是把判断和更新合并成一条 SQL,让数据库的锁来保证原子性:

public void selectCourse(long studentId, long classId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 事务边界在 Service,DAO 不自己管 // 第一步:条件更新,affected = 0 说明名额已满或课程停开 String sql = "UPDATE course_class SET selected = selected + 1 " + "WHERE id = ? AND status = 1 AND selected < capacity"; int affected = courseClassDao.update(conn, sql, classId); if (affected == 0) { throw new BusinessException("SOLD_OUT", "该课程名额已满"); } // 第二步:写入选课记录,唯一索引兜底防重复 selectionDao.insert(conn, studentId, classId); conn.commit(); } catch (SQLIntegrityConstraintViolationException e) { DBUtil.rollback(conn); throw new BusinessException("REPEATED", "你已经选过这门课了"); } catch (BusinessException e) { DBUtil.rollback(conn); throw e; } catch (Exception e) { DBUtil.rollback(conn); throw new BusinessException("SYSTEM_ERROR", "选课失败,请稍后重试"); } finally { DBUtil.close(conn); } }

关键在于WHERE ... AND selected < capacity。MySQL 执行这条 UPDATE 时会对命中行加排他锁,第二个事务必须等第一个提交后才能继续,此时selected已经变成 60,条件不成立,affected返回 0,业务层直接抛“名额已满”。不需要在 Java 里加任何 synchronized,也不需要分布式锁。

参数与边界说明:status = 1参与条件,意味着停开的课程即使有余量也选不上,这个判断放在 SQL 里比放在 Java 里更省一次查询。selected < capacity而不是selected + 1 <= capacity,是因为 MySQL 会先算selected + 1,在selected = capacity时读起来容易看错,前者更直观。

还要注意事务边界。conn.setAutoCommit(false)之后,update和insert必须在同一个连接上执行,所以 DAO 方法要接收Connection参数,而不是自己从连接池取。这是很多项目写错的地方——两个 DAO 各自getConnection(),结果根本不在一个事务里,回滚时只回滚了其中一半。

提示:把上面这段在本地启动两个线程并发跑一百次,观察course_selection的条数和course_class.selected是否一致,这是答辩时最有说服力的演示,比任何描述都直接。

4.3 成绩录入与绩点计算

成绩模块的重点不在录入界面,而在状态机和计算规则。录入时教师提交的是一个班级几十条记录,必须批量处理并在一个事务里完成,否则中途失败会出现“一半学生有成绩、一半没有”的状态。

public void saveScores(long teacherId, long classId, List<ScoreInput> inputs) { // 数据范围校验:这门开课班必须是当前教师所授 CourseClass cc = courseClassDao.findById(classId); if (cc == null || cc.getTeacherId() != teacherId) { throw new BusinessException("FORBIDDEN", "无权录入该课程成绩"); } // 状态校验:已提交或已发布的成绩不允许再改 if (scoreDao.existsStatusNotDraftByClass(classId)) { throw new BusinessException("LOCKED", "成绩已提交,无法修改"); } scoreDao.batchUpsert(classId, inputs); // 内部用 addBatch + executeBatch }

绩点计算通常用五分制或四分制,规则必须写死在 Service 里并由参数表驱动,不要在前端算。常见规则是:总评 = 平时分 × 0.3 + 考试分 × 0.7,60 分以下绩点 0,60 分绩点 1.0,每高 1 分增加 0.1,上限 5.0。把这条规则抽成一个静态方法GradePointCalculator.of(double total),单元测试覆盖 59.5、60、60.5、100 四个边界值,论文里附上这张边界测试表,比写十行“本模块实现了绩点计算”有分量得多。

4.4 分页与组合查询:别在 JSP 里拼 SQL

列表页是最容易被写烂的部分。学生列表、课程列表、成绩列表都要分页,都要支持按姓名或课程名模糊查。做法是抽一个PageBean<T>,字段是pageNo、pageSize、total、rows,Service 里查两次——一次count(*),一次limit offset, size。offset = (pageNo - 1) * pageSize,pageNo从请求参数取,必须做上下界钳制,否则用户可以传pageNo = -1触发 SQL 语法错误。

模糊查询的%keyword%要用PreparedStatement拼接,参数里带通配符,SQL 文本本身不变:String sql = "SELECT * FROM student WHERE name LIKE ?"; ps.setString(1, "%" + kw + "%");。这样做的好处是预编译语句能被复用,同时天然防注入。千万不要写成"... LIKE '%" + kw + "%'",那样只要有人在搜索框里输入一个单引号,页面就直接报 500。

5. 避坑与排查:五个最容易在验收现场翻车的点

下面这五个问题,几乎每个用 JavaWeb 写教务系统的项目都会碰到至少三个。它们的共同特点是:本地开发时不一定复现,一到演示环节就冒出来。提前处理掉,能省掉答辩当天一大半的慌乱。

5.1 中文乱码:三处都要设,缺一处就白搭

现象:表单提交的课程名在数据库里是乱码,或者查询出来的学生姓名显示成问号。

原因:编码涉及三个环节——浏览器提交、容器解析请求体、JDBC 写入数据库。任何一处字符集不是 UTF-8,链路就断了。常见的是 Filter 里设了request.setCharacterEncoding("UTF-8"),但数据库连接串没写characterEncoding=utf8,结果前两步正常,最后一步写库时转成了默认字符集。

解决:固定三处配置。Filter 里设请求和响应编码(见 4.1 代码);JDBC URL 带上?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai;建库建表统一DEFAULT CHARSET=utf8mb4。改完之后清一次 Tomcat 的work目录,因为编译过的 JSP 会缓存旧编码。

5.2 连接泄漏:跑半小时就卡死

现象:系统刚启动一切正常,持续点几分钟后页面转圈,最后报Communications link failure或连接池耗尽。

原因:DAO 里Connection、PreparedStatement、ResultSet没有在finally里关闭,或者关闭顺序写反了、只关了ResultSet忘了关Statement。每次查询泄漏一个连接,池子里的连接几十次就被吃干净。

解决:所有资源关闭收口到BaseDao.close(ResultSet, Statement, Connection)一个方法,内部逐个判空、按 ResultSet → Statement → Connection 的顺序反向关闭并各自 try-catch。业务代码统一调用,不允许自己写 close。排查时打开连接池的监控页面,或者临时把池大小调到 2,跑一遍全流程,如果第二次点就卡住,说明泄漏点在前面的方法里。

5.3 时间字段对不上:差 8 小时

现象:选课记录里的时间比实际时间少 8 小时,或者查询某天的数据查不到。

原因:MySQL 8 的驱动默认按 JVM 时区解释DATETIME,服务器时区如果是 UTC,而业务代码按东八区理解,就会整体偏移。另一个来源是java.util.Date与java.sql.Timestamp混用,转换时又丢了一次偏移。

解决:JDBC URL 显式指定serverTimezone=Asia/Shanghai,实体类的时间字段统一用java.time.LocalDateTime,JDBC 4.2 之后setObject和getObject原生支持,不需要再手动转。跨时区部署时才考虑存 UTC,校内系统一般不涉及。

5.4 事务边界写错:选课人数和记录对不上

现象:course_class.selected显示 45,course_selection里只有 43 条记录。

原因:两个 DAO 各自从连接池取连接,update和insert落在两个不同的物理连接上,事务只覆盖了其中一个。异常发生时,另一个已经提交,数据永久不一致。

解决:Service 层取一次连接,设为手动提交,把同一个Connection一路传给 DAO 方法;提交和回滚都在 Service 的try-catch里做。写完之后做一次验证:故意在insert之后抛一个运行时异常,看selected有没有回滚,如果没回滚,就是连接传错了。

5.5 Session 与浏览器后退:退出去还能看到数据

现象:学生点“退出登录”回到登录页,按浏览器后退键,还能看到自己的成绩页面。

原因:退出时只是session.invalidate()或重定向到登录页,但浏览器把上一个页面放在本地缓存里,后退时直接从缓存渲染,根本没发请求到服务器。

解决:两层处理。一是退出时除了 invalidate,还对所有页面响应加Cache-Control: no-store,在 Filter 里统一设置,让浏览器不缓存受保护页面。二是在每个需要登录的 Servlet 里再次确认 Session 中的用户存在(Filter 已经做了,但页面转发前再查一次成本极低),避免依赖前端行为。

6. 验收与答辩:一份能自证的测试清单和三个必答问题

方案写完只是完成了一半,另一半是让别人相信它能跑。我见过不少项目,功能其实做得不错,但讲的时候东一榔头西一棒子,导师问一句“选课并发怎么处理的”就答不上来,最后分数被压得很低。验收这件事是可以提前设计的:你不需要临场发挥,只要手里有一张清单和三个准备好的答案。

6.1 验收清单:十六个用例跑完才算交付

下面这张表按模块列了必须自己跑一遍的用例,每条都标了预期结果。建议在答辩前一天完整执行一次,把结果截图存档。表里的用例覆盖了正常路径、边界路径和异常路径三类,导师挑着问的时候你至少有一半是提前验证过的。

编号模块操作预期结果
T01登录正确账号密码进入对应角色首页
T02登录密码错误三次提示错误,不泄露账号是否存在
T03权限学生直接访问/admin/user/list返回 403
T04权限教师修改他人课程成绩提示无权操作
T05选课正常选一门有余量的课记录生成,selected +1
T06选课选一门已满的课提示名额已满,selected 不变
T07选课重复选同一门课提示已选过,不产生第二条记录
T08选课选与已选课程时间冲突的课提示时间冲突,不写库
T09选课并发两个请求抢最后一个名额只有一条成功,selected 恰好等于 capacity
T10成绩教师录入并保存草稿状态为 DRAFT,学生不可见
T11成绩提交后再次修改提示已锁定
T12成绩管理员发布后学生查询可见,绩点按规则计算
T13成绩总评为 60 / 59.5 边界绩点分别为 1.0 / 0
T14查询分页第 1 页与最后一页条数正确,无重复无遗漏
T15查询搜索框输入单引号不报错,按普通字符处理
T16稳定性连续操作 5 分钟连接不耗尽,响应时间无明显劣化

T09 是这张表的核心,也是最容易在答辩现场被要求演示的一条。演示方法很简单:开两个浏览器窗口,都停留在选课确认页,手动点“确认选课”,中间间隔不到一秒。如果两次都成功,而你只放了一个名额,说明条件更新没写对。反过来,如果第二次提示“名额已满”,并且数据库里selected与course_selection条数一致,这条就算过了。

T15 用来证明 SQL 注入防护到位,T16 用来证明连接没有泄漏。这两条不需要讲原理,跑一遍给导师看即可。

6.2 答辩现场三个必答问题

第一个问题通常是“你这个系统的难点在哪”。不要答“功能比较多”或者“页面比较复杂”,那等于没答。标准答案是选课并发:描述检查与使用之间的竞态窗口,说明你用条件更新把判断和扣减合并成一条 SQL,并用唯一索引做第二道防线,最后说清事务边界在 Service 层。这三句话讲完,基本就够分了。

第二个问题是“如果选课人数继续增长,这套方案还撑得住吗”。这时候可以坦诚说明当前方案的边界:单库单表下,条件更新依赖行锁,热门课程的热点行会成为瓶颈;再往上要么按课程拆分、要么加排队机制。承认边界比硬说“完全没问题”可信得多,导师也更愿意给这种回答加分。

第三个问题往往落到数据一致性上:“如果扣了名额但写记录失败了怎么办”。答案就是事务回滚,同时指出selected是冗余计数字段,所以必须有定期校对逻辑——写一个管理端的“人数校准”功能,用count(*)重算所有开课班的selected,这个功能实现只要十几行 SQL,但在答辩时是很实的加分项。

驾轻就熟之前我也翻过车。第一次做这套系统,我把conn.setAutoCommit(false)写在了 DAO 里,以为设了就是事务,结果两个 DAO 用的不是同一个连接,回滚根本不管用,演示时人数对不上,当场被问住。从那以后我每次写完涉及多表的写操作,都强制走一遍“故意抛异常看有没有回滚”的流程,再顺手跑一次人数校准 SQL 对账。这个习惯帮我省掉了至少三次返工。希望这套结构和清单能帮到你,少走一遍我当年那条弯路。

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

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

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

立即咨询