简介:这是一份基于 Java MVC 架构的学生选课系统项目压缩包,面向正在学习 Java Web 开发的初学者,以及需要完成课程设计或想快速搭建选课功能原型的人群。系统采用经典的 Model-View-Controller 三层结构,前端使用 JSP 动态页面,后端包含 Servlet、JavaBean、工具类等,覆盖学生登录、课程浏览、选课、退课及后台管理等功能。压缩包为 rar 格式,约 320KB,共 82 个文件,其中 JSP 页面 29 个、Java 源文件 19 个、编译后的 class 文件 19 个,另有 XML 配置、数据文件及项目工程文件等,目录结构清晰,可直接导入 IDE 并部署到 Tomcat 运行。目前已有 190 人学习下载。借助这份资源,读者可以对照源码理解 MVC 各层如何协作,学习 JSP+Servlet+JavaBean 的典型开发方式,也能根据实际需求修改功能模块,是巩固 Java Web 基础与实战能力的不错参考。
1. Java MVC 选课系统:为什么每个模块都是“看着简单,一做就错”
一个 Java MVC 选课系统,可能是你下载下来最快、跑起来也最快,但真正讲明白却最费劲的课程设计项目。选课系统表面只有登录、查课、选课、退课四个功能,本质上是标准增删改查,可一旦几十个学生同时点热门课程,容量校验失效、事务回滚白做、分页数据错乱这些事会一起冒出来。这篇实战笔记从建表开始,把 MVC 分层、JDBC 连接池、选课事务和并发控制完整过一遍,最后给一套能现场演示的验证方法,适合正在做课程设计、准备毕业答辩,或者拿到源码想改成自己项目的读者。你只要有一台能跑 JDK、Tomcat 和 MySQL 的电脑,就可以跟着复现。
2. 选课系统的数据模型:先设计四张表,再谈 MVC 分层和 JavaBean
拿到压缩包时别急着解压导入 Tomcat,第一步先把表结构看明白。选课系统的数据模型很典型:学生、课程、选课记录、管理员,四张表把多对多关系拆干净,MVC 里的 Model 层也顺势有了落点。表结构定了,后面写 javabean、dao、servlet 的边界就非常清楚;反过来如果一上来就写页面,每加一个字段要改三五个文件,那种翻车我经历过太多次。
2.1 四张核心表的 DDL:学生、课程、选课、管理员
我用的是 MySQL 5.7,建表语句如下。注意四张表全部用 InnoDB,因为后面的选课和退课要在事务里更新多行,InnoDB 才支持事务和行级锁。
CREATE TABLE student ( sno VARCHAR(20) PRIMARY KEY COMMENT '学号,主键', sname VARCHAR(50) NOT NULL COMMENT '姓名', spassword VARCHAR(64) NOT NULL COMMENT '密码', sclass VARCHAR(50) COMMENT '班级' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE course ( cno VARCHAR(20) PRIMARY KEY COMMENT '课程编号', cname VARCHAR(100) NOT NULL COMMENT '课程名', teacher VARCHAR(50) COMMENT '任课教师', credit DECIMAL(3,1) COMMENT '学分', capacity INT NOT NULL DEFAULT 60 COMMENT '容量上限', selected INT NOT NULL DEFAULT 0 COMMENT '已选人数,冗余计数', begin_time DATETIME COMMENT '选课开始时间', end_time DATETIME COMMENT '选课结束时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE elective ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键', sno VARCHAR(20) NOT NULL COMMENT '学生号', cno VARCHAR(20) NOT NULL COMMENT '课程号', select_time DATETIME NOT NULL COMMENT '选课时间', UNIQUE KEY uk_sno_cno (sno, cno), CONSTRAINT fk_ele_sno FOREIGN KEY (sno) REFERENCES student(sno), CONSTRAINT fk_ele_cno FOREIGN KEY (cno) REFERENCES course(cno) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课关系表'; CREATE TABLE admin ( ano VARCHAR(20) PRIMARY KEY, aname VARCHAR(50), apassword VARCHAR(64) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有三处是故意这么设计的。第一,学号和课程号都用业务编号当主键,而不是自增 int,因为选课系统里学号、课号天然唯一,用自增反而要额外做唯一索引。第二,选课表虽然加了自增 id,但真正的防重靠的是uk_sno_cno这个联合唯一键,数据库层面拦住同一个人选同一门课两次。第三,course 表里的selected是冗余字段,正常范式可以左边COUNT(*)统计,但选课场景下高频查询剩余容量,每次 count 全表代价高,用冗余计数配合事务更新更实在。
2.2 一个请求在 MVC 里的真实走向
很多教程讲 MVC 喜欢画三个圆,看得懂但落不了地。选课系统里一次“选课”请求的真实走向是:JSP 表单把学号和课号 POST 给 Servlet,Servlet 拿到参数后调用 Service,Service 开启事务再调 DAO,DAO 把结果映射成 JavaBean 返回,Service 提交事务,Servlet 再把结果放进 request,最后 forward 到 JSP 渲染。谁做什么、哪里容易写错,我用表格列出来。
| 层次 | 典型位置 | 选课系统里负责的事 | 最常见的坑 |
|---|---|---|---|
| View | webapp 下的 JSP | JSTL 渲染课程列表、回显错误消息 | 在 JSP 里直接写 JDBC |
| Controller | controller 包下的 Servlet | 收参数、调服务、控制跳转 | 一个 Servlet 堆几百行业务 |
| Service | service 包 | 事务边界和业务规则校验 | 事务写了但根本没生效 |
| DAO | dao 包 | SQL 执行与结果集映射 | 把容量判断写死在 SQL 里 |
| Model | model 包 | 每张表对应一个 JavaBean | 字段和表列对不上 |
这个分层不是应付检查的摆设。以“课程列表”为例,CourseDao 只负责执行 SQL 返回 List,CourseService 负责算分页总数和校验页码,CourseServlet 只做转发逻辑,jsp 页面用<c:forEach>遍历。任何一层出问题,日志里看异常堆栈就能定位到具体类,而不是在 jsp 里翻 Java 片段。Spring MVC 的分层思想与此同源,区别只是把 Servlet 换成了 DispatcherServlet,把 DAO 换成了 MyBatis 接口,理解这套原始 MVC,再看 Spring 相关框架会顺很多。
2.3 目录与依赖:一个能编译能部署的最小骨架
常见课程设计是 Eclipse 加 Maven 的 war 工程,目录结构我一般按这个来,包名按自己习惯改。
src/main/java/com/example/selectcourse/ controller/ LoginServlet.java CourseServlet.java ElectiveServlet.java service/ CourseService.java StudentService.java dao/ StudentDao.java CourseDao.java ElectiveDao.java model/ Student.java Course.java Elective.java Admin.java util/ DBUtil.java src/main/webapp/ WEB-INF/web.xml login.jsp listCourse.jsp courseDetail.jsppom.xml 里最关键的三件事是 servlet-api 用 provided、加上 JSTL、加上数据库驱动。下面这组是在 Tomcat 8.5 加 JDK 1.8 环境下常用的版本组合。
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.6</version> </dependency> </dependencies>我特别说下 servlet-api 为什么用 provided。Tomcat 自己带了 servlet-api 实现,如果 war 包里再打一份,启动时两个同名类互相干扰,轻则报警告,重则 NoSuchMethodException。所以编译期用它,打包期排除它。配置完目录后先跑mvn clean package,确认能打出 war 再继续写业务,这是最小可运行骨架的验收标准。
3. 把登录、查课、选课、退课串起来:三段核心 Java 代码与调用链
数据模型稳定之后,核心流程就是四条线:学生登录、课程分页列表、选课、退课。选课系统毕竟是个教学项目,页面我用 JSP 服务端渲染,不引入前后端分离,原因是这个规模下多一层 REST 联调纯属给自己加工作量。真正需要下功夫的是请求链路和事务边界。
3.1 登录、Session 写入和过滤器放行规则
登录逻辑不复杂,但有两个细节直接影响后面所有功能:密码校验不能走 SQL 拼接,登录成功后必须把用户对象放 Session。先看 LoginServlet 的核心部分。
@WebServlet("/login") public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String sno = req.getParameter("sno"); String pwd = req.getParameter("pwd"); // 参数校验略:sno 和 pwd 为空时直接转发回登录页 StudentDao dao = new StudentDao(); Student student = dao.findBySnoAndPassword(sno, pwd); if (student != null) { HttpSession session = req.getSession(); session.setAttribute("student", student); resp.sendRedirect(req.getContextPath() + "/listCourse"); } else { req.setAttribute("msg", "学号或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }对应 DAO 里的方法是SELECT * FROM student WHERE sno=? AND spassword=?,用 PreparedStatement 绑定参数,而不是把 sno 和密码直接拼进 SQL。这一步同时防住了 SQL 注入和中文编码问题,后面第 4 章展开细讲。
登录写了 Session,就必须配一个过滤器统一拦截未登录请求,不然每个页面都要自己判断 Session 是否为空。拦截器用@WebFilter("/*")拦截所有路径,但要放行登录接口和静态资源。
@WebFilter("/*") public class AuthFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); // 登录页、登录接口、CSS/JS/图片都放行,避免循环重定向 if (uri.endsWith("/login.jsp") || uri.endsWith("/login") || uri.endsWith(".css") || uri.endsWith(".js")) { chain.doFilter(request, response); return; } HttpSession session = request.getSession(false); if (session == null || session.getAttribute("student") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }注意这里的getSession(false),它不会新建 Session,而是只取已有 Session。很多人用getSession()导致未登录用户也被创建了一个空 Session,过滤器判断时就失真了。参数上的差别看似小,实际影响整个认证逻辑。
3.2 课程列表与分页:LIMIT、count 和一个不能省的排序字段
课程列表页是选课系统的门面,学生得先看到有哪些课、还剩多少名额,然后才点选课。列表用分页实现,每页 6 条,Controller 里只处理页码参数。
@WebServlet("/listCourse") public class CourseServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); int pageNo = 1; String pageNoStr = req.getParameter("pageNo"); if (pageNoStr != null && !pageNoStr.isEmpty()) { pageNo = Integer.parseInt(pageNoStr); } int pageSize = 6; CourseService service = new CourseService(); // 分页查询结果和总页数在 Service 里组装 PageResult<Course> page = service.findPage(pageNo, pageSize); req.setAttribute("page", page); req.getRequestDispatcher("/listCourse.jsp").forward(req, resp); } }DAO 里的分页 SQL 是核心,查询当前页数据用LIMIT ?, ?,统计总数另发一条 SQL。
SELECT cno, cname, teacher, credit, capacity, selected FROM course ORDER BY selected DESC, cno ASC LIMIT ?, ?; SELECT COUNT(*) FROM course;分页看起来只有两行 SQL,但排序字段必须选对。很多人图方便只写ORDER BY selected DESC,当多门课已选人数相同时,MySQL 返回顺序不固定,翻页时就会出现同一行出现在第一页和第二页。课程编号cno是主键,加入排序后每行的相对位置就锁死了,后面第 5.4 会专门讲这个坑。
3.3 选课与退课:事务里先查容量再插入,判断写在服务层而不是 SQL 里
选课是整个系统最值得讲清楚的地方。一个正常的选课流程要做三件事:查课程是否存在、查剩余容量是否够、查这个人是否已经选过。这三步必须在一个事务里完成,而且 DAO 里不能自己拿到 Connection 去执行,否则事务就失效了。
public boolean selectCourse(Connection conn, String sno, String cno) throws Exception { // 由 Service 层传入已开启事务的 Connection CourseDao courseDao = new CourseDao(); ElectiveDao electiveDao = new ElectiveDao(); Course course = courseDao.getByNo(conn, cno); if (course == null) { throw new BizException("课程不存在"); } // 业务规则校验:剩余名额 if (course.getSelected() >= course.getCapacity()) { throw new BizException("该课程已满员"); } // 业务规则校验:不能重复选 if (electiveDao.exists(conn, sno, cno)) { throw new BizException("你已经选过这门课"); } electiveDao.insert(conn, sno, cno); courseDao.increaseSelected(conn, cno); return true; }对应的 CourseDao 里有两个方法很关键。
-- 已选人数加一,注意 WHERE 条件里带了 selected < capacity UPDATE course SET selected = selected + 1 WHERE cno = ? AND selected < capacity;这个 UPDATE 的返回值是受影响行数,如果等于 0,说明课程已经满员,Service 层直接抛业务异常。它把容量判断放进了数据库原子操作里,防止两个请求同时读到同一个“最后一个名额”。但我要说明白:这里的 SQL 条件只是最后一道保险,容量校验的业务逻辑仍然写在 Service 层,因为异常消息要能区分“课程不存在”“已满员”“已选过”,SQL 只知道成功或失败,区分不了具体原因。
退课的逻辑是反向操作,先删选课记录,再让已选人数减一。
public boolean cancelCourse(Connection conn, String sno, String cno) throws Exception { ElectiveDao electiveDao = new ElectiveDao(); CourseDao courseDao = new CourseDao(); electiveDao.deleteBySnoAndCno(conn, sno, cno); courseDao.decreaseSelected(conn, cno); return true; }事务、异常处理和连接关闭这层,我习惯放在 Service 方法里统一管,而不是散在 DAO 中。创作一个假事务的典型写法:Service 调conn.setAutoCommit(false),但 DAO 内部每执行一条 SQL 都自己DBUtil.getConnection(),等于每个 DAO 方法各开一个事务,Service 的回滚什么也管不着。这个问题几乎每个模仿 MVC 的作业里都有,排查方法也简单:把事务相关代码集中到 Service,DAO 所有方法都接收 Connection 参数,而不是自己取连接。
4. JDBC 与连接池参数:事务、预编译和连接的 3 个关键选择
选了 Servlet 加 JSP 这套老牌 MVC,数据库访问层大概率直接用 JDBC。JDBC 本身不难,但有一个终极问题:怎么管理 Connection。课堂示例代码喜欢每次调用DriverManager.getConnection(),用完全部扔掉,这在几十人访问的课程设计里勉强能跑,一旦有并发请求,数据库连接瞬间耗尽。
4.1 用 Druid 连接池替换 DriverManager:五个初始化参数
我的做法是接入阿里巴巴的 Druid 连接池。它对 JDBC 标准接口没有侵入,DBUtil 里初始化一个 DataSource,之后getConnection()的调用方式不变。
public class DBUtil { private static DruidDataSource dataSource; static { dataSource = new DruidDataSource(); dataSource.setDriverClassName("com.mysql.jdbc.Driver"); dataSource.setUrl("jdbc:mysql://localhost:3306/select_course" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai"); dataSource.setUsername("root"); dataSource.setPassword("root"); dataSource.setInitialSize(5); dataSource.setMaxActive(20); dataSource.setMaxWait(60000); dataSource.setValidationQuery("SELECT 1"); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这里五个参数是血泪经验换来的,逐个说。
initialSize是启动时预建的连接数,设为 5,避免第一个请求打进来时才临时建连接。maxActive是最大活跃连接数,课程设计 20 足够了,调太大反而让 MySQL 默认的连接数吃紧。maxWait是拿不到连接时的最大等待毫秒数,设为 60000,超过就抛异常,防止请求无限阻塞。validationQuery是借用连接前的探活 SQL,SELECT 1是 MySQL 最小的健康检查。
最容易被忽视的是 URL 里的characterEncoding=utf8,它保证中文从 JDBC 进入 MySQL 时按 UTF-8 编码,少这一行,页面里正常的数据进库就会变问号。这个坑我会在第 5 章再展开。
有一个概念要特别讲清楚:连接池里的close()不是真断开数据库,而是把连接归还池子。如果你在finally里忘了 close,池里的连接会被借光,程序卡在 getConnection 等 60 秒然后报连接超时。所以 DAO 和 Service 的每个方法,finally 块都要把 Connection、Statement、ResultSet 逐个关掉,顺序是从里往外关。
4.2 PreparedStatement 参数绑定:写好 SQL 的三个命令
JDBC 里执行一条带参数的 SQL,正确写法永远是先 prepare 再 bind 再 execute,三步缺一不可。
String sql = "SELECT * FROM student WHERE sno = ? AND spassword = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, sno); ps.setString(2, pwd); ResultSet rs = ps.executeQuery();为什么不用字符串拼接?原因有两个。第一是 SQL 注入,比如密码框里输' OR '1'='1,拼出来的 where 条件恒成立,绕过登录验证,这在公开的选课系统源码里非常常见。第二是转义问题,如果学生姓名里有单引号,拼接 SQL 轻则查不出数据,重则语法报错。PreparedStatement 的占位符由驱动负责转义,这两类问题从根上消失。
实际写 DAO 时,我一般把 SQL、参数绑定、结果集映射分成三个明确步骤,一条查询语句对应一个方法。尤其要注意的是ps.setXxx的类型:学号是字符串就 setString,课号也是 setString,别因为业务上像数字就转成 setInt,类型不一致会导致 MySQL 走不到索引,数据量大时查询慢得明显。
4.3 事务隔离级别与回滚边界:选课失败别把无关数据一起回滚
事务隔离级别是面试里经常被拷打的问题,落到选课系统里反而好理解。MySQL 默认隔离级别是 REPEATABLE_READ,但对于选课这个场景,我习惯在 Service 里显式降到 READ_COMMITTED。
conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); try { // 业务操作 course = courseDao.getByNo(conn, cno); if (course.getSelected() >= course.getCapacity()) { throw new BizException("已满员"); } electiveDao.insert(conn, sno, cno); courseDao.updateSelected(conn, cno); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.close(); }为什么不用 REPEATABLE_READ?选课核心是读剩余容量和更新已选人数,我们只关心读到的是不是已提交的数据。READ_COMMITTED 读到的都是其他事务已提交的结果,在这个场景足够了。换成 Serializable 确实最安全,但数据库并发能力断崖式下降,一个五六十人的选课系统完全没必要。这里要记住:隔离级别越低,并发越大,但脏读、不可重复读风险越高;选什么级别取决于你能不能容忍对应的异常。
事务的回滚边界同样重要。回滚的是数据库里这一条选课业务涉及的行,而不是 Servlet 里的 request 和 Session。很多新手把conn.rollback()写在了 Servlet 层,以为能让页面也恢复原样,实际上 web 对象根本不受数据库事务管理。正确做法是事务只在 Service 方法里控制,Servlet 捕获业务异常后往 request 里放错误消息,再 forward 回列表页,让用户看到“该课程已满员”而不是一整页 500 错误。
5. 选课系统避坑实录:并发超选、中文乱码、分页错乱的 5 个严重坑点
选课系统跑通容易,稳定很难。下面这五条是我筛过的真实踩坑记录,每一条都按“现象、原因、解决”来复盘,都是网上代码包里最常见的雷。
5.1 热门课程容量瞬间变成负数:并发选课缺少原子容量校验
现象:课程明明只剩 1 个名额,最后却选进去 5 个人,日志里selected值变成负数,页面显示剩余名额 -1。
原因:Service 层先查selected=59,判断小于 capacity 后执行 UPDATE 加一。两个请求同时到达,都读到 59,都通过校验,于是两次 UPDATE 把 59 改成 61,超卖。
解决:把容量判断挪进 UPDATE 的 WHERE 条件里,变成一条原子 SQL。
UPDATE course SET selected = selected + 1 WHERE cno = ? AND selected < capacity;执行后判断受影响行数,返回 0 就说明这条 update 没有匹配到可更新的行,课程已经满员。这种方法不依赖数据库悲观锁,也不需要额外等待队列,是课程设计阶段性价比最高的修复。另外,select_time 字段要按学生实际选课那一刻来填,不要在 Servlet 里塞一个固定值,避免多个请求共用同一时间。
5.2 页面中文全部变成问号:三处编码不一致
现象:登录后学生姓名、课程名在 JSP 页面上显示为“???”或者一片乱码,刷新也没用。
原因:字符集问题几乎都是三处不一致造成的,JSP 页面编码、JDBC URL 参数、MySQL 表字段编码,只要有一处是 latin1 或默认编码,显示就乱。
解决:JSP 页面头部统一写这两行,JDBC URL 按第 4.1 节的写法带上characterEncoding=utf8,建表时用utf8mb4。已经乱掉的表可以修正数据库默认字符集,但旧数据建议手动验证后再保留。
ALTER DATABASE select_course CHARACTER SET utf8mb4; ALTER TABLE course CONVERT TO CHARACTER SET utf8mb4;第三个隐蔽点:Servlet 里接收 POST 参数前必须设req.setCharacterEncoding("UTF-8"),否则中文参数从表单进入 Java 时就已经乱码了,后面全白搭。
5.3 同一个学生把同一门课选了两遍:双重提交压过了业务校验
现象:选课按钮连点两次,配了一套 AJAX 后请求发了两遍,查 elective 表出现了两条相同 sno 加 cno 的记录。
原因:Service 里的exists校验是先用 SELECT 判断,再执行 INSERT,两次请求都通过判断后插入。业务层校验在这个时间窗口内形同虚设,这是典型的并发竞态。
解决:靠第 2.1 节建表时的uk_sno_cno联合唯一索引兜底。第二次 INSERT 在数据库层直接报 DuplicateKeyException,Service 捕获后转为业务消息返回给页面,而不是让异常一路抛到 500 页面。业务校验能拦下大多数情况,唯一索引负责最后一道防线,两者不冲突,缺一个都不稳。
5.4 分页翻到第二页,数据还是第一页那几条
现象:总共有 20 门课,每页 6 条,点第 2 页后页面刷新,内容跟第 1 页几乎一样,翻到第 3 页才看到新数据。
原因:ORDER BY selected DESC排序字段不唯一。当多门课程已选数相同时,MySQL 认为这些行顺序等价,翻页时行偏移的计算结果就不稳定,出现重复或漏行。
解决:ORDER BY 里补一个主键列。主键唯一,排序就稳定。
SELECT cno, cname, teacher, credit, capacity, selected FROM course ORDER BY selected DESC, cno ASC LIMIT ?, ?;这是一条很能体现基本功的修复。面试问分页细节时,能把这条讲出来,比背一堆分页插件 API 有用得多。顺带提一句:LIMIT的 offset 要用 int 类型传入,不要直接在 SQL 里拼接pageNo字符串,否则又回到 SQL 注入那一类问题。
5.5 Tomcat 启动失败或页面 404:端口、jar 和部署路径三连查
现象:双击 startup.bat 或从 IDE 启动 Tomcat,控制台报端口被占用;或者部署后访问http://localhost:8080/xxx/login.jsp直接 404。
原因:8080 端口被其他进程占了,或者 war 包里的 servlet-api 与 Tomcat 自带类冲突,部署上下文路径和访问路径不一致。
解决:按顺序排查。先看端口,netstat -ano | findstr :8080,有 PID 就杀掉或改 Tomcat 的server.xml里 Connector 端口。把 8080 改成 8081、8082 都行,但要记住改完之后所有访问路径跟着变。再看 war 包解压后的目录,webapps 下如果是select-course.war,访问路径就是http://localhost:8080/select-course/。最后确认 pom.xml 里 servlet-api 是 provided 作用域,很多 Tomcat 启动秒崩就是它打进了 lib 目录和容器冲突。
6. 现场答辩怎么验证并发修复:一个可复现的压测脚本与配套 SQL
代码写完了,别人问你“你怎么证明并发下不超卖”,你不能只说加了事务。我建议准备一个十秒钟能跑完的验证脚本,先往 student 表插入 20 个测试学号,然后把某门课的 capacity 改成 1、selected 改成 0,再模拟 20 个学生同时点选课。
for i in $(seq 10 29); do ( curl -s -c /tmp/s$i.txt -d "sno=2024$i&pwd=123456" \ http://localhost:8080/select-course/login > /dev/null curl -s -b /tmp/s$i.txt -d "cno=CS101" \ http://localhost:8080/select-course/elect > /dev/null ) & done wait这个脚本里每个循环体都被小括号包住并放到后台执行,20 个请求近乎同时发出。第一次执行后,course 表里如果 selected 大于 1,说明容量校验失败;加上第 5.1 节的原子 UPDATE 后再跑,selected 只会等于 1。
脚本跑完,用三条 SQL 验收结果。
SELECT COUNT(*) FROM elective WHERE cno = 'CS101'; SELECT cno, cname, capacity, selected FROM course WHERE cno = 'CS101'; SELECT * FROM course WHERE selected > capacity;第一条看选课记录条数,第二条看课程容量和已选数是否平衡,第三条专门捞异常数据。任何一条不合理,都说明事务或并发控制还有破绽。
做完这个系统,我给自己留了个习惯:所有涉及写库的接口,旁边必须配一条能查出结果的 SQL。这比自信地拍胸口说“没问题”可靠得多,也能在答辩现场把“什么时候回滚、怎么防超选、为什么排序要带主键”这些话题稳稳接住。希望帮到你。
本文还有配套的精品资源,点击获取