ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSM+JSP+MySQL学生选课管理系统实战:从分层架构到并发控制

SSM+JSP+MySQL学生选课管理系统实战:从分层架构到并发控制 简介这是一个基于 SSMSpringMVC MyBatis框架、搭配 JSP 与 MySQL 实现的学生信息管理与选课系统适合 Java Web 初学者、毕业设计或课程设计参考。项目前端采用 Bootstrap 与 particles.js 插件后台使用 PageHelper 完成分页查询并对添加、修改操作通过 Ajax 校验主键是否可用同时已配置登录拦截代码分层清晰、输入框约束较强便于二次开发。整个资源包共 266 个文件以 Java 源码、JSP 页面、XML 配置文件为主辅以 SQL 脚本、JavaScript 与样式文件压缩包大小仅 2.64MB结构紧凑能快速导入 IDE 运行学习。已有 806 人浏览学习适合希望掌握 SSM 整合、前后端交互及基础权限控制的学生对照实践从中可获取完整可运行的项目源码、数据库脚本和目录结构参考用于理解选课业务模块与框架配置细节。1. 为什么“基于SSMjspmysql的学生信息管理系统(选课)”依然是重复使用率最高的教学项目骨架把“学生信息管理”和“选课”放进同一个标题意味着你已经选中了 Java Web 开发里最经典的一条教学路线JSP 负责页面渲染SpringMVC 负责请求分发Spring 管理业务对象MyBatis 做 SQL 映射MySQL 存全部业务数据。这套组合在高校实训、毕业设计、企业内部培训中出现的频率远超微服务方案原因是它能在不出局域网的前提下用一台 Tomcat 把登录、CRUD、多表关联、事务控制和权限拦截完整串起来恰好是初学者建立 Java Web 全链路认知的爬坡路径。对工作五年以上的工程师这个项目也有其价值很多公司里仍跑着以 JSP 为核心的遗留系统改造为前后端分离之前你需要一份对 JSP 生命周期、MyBatis 代理机制、Spring 事务边界都有掌控力的基线版本。选课是最值得动手的模块超容量限制、重复选课校验、退选释放名额、并发情况下防止超卖每一行代码都直接对着数据库的锁和事务。2. SSMJSPMySQL的分工逻辑哪一层决定选课业务哪一层只做展示2.1 Spring、SpringMVC、MyBatis 三个框架在选课场景中的职责边界很多初学者把 SSM 当成一个整体来背但排错和改造时最忌讳的就是分不清每一层的边界。Spring 在这里是 IoC 容器负责管理CourseService、SelectionService、CourseMapper这些 Bean 的生命周期事务由它统一控制SpringMVC 只做两件事把 HTTP 请求解析成控制器方法参数再把方法返回值渲染到 JSP 页面MyBatis 则把 DAO 接口和 Mapper XML 里的 SQL 绑定起来返还的结果直接映射成 JavaBean。放到选课业务里看这三层各管一段Controller 不写if (selectedCount capacity)这种判断它只负责接收courseId和studentId然后调用 Service判断课程是否选满、学生是否重复选课、事务边界在哪里是 Service 的职责而SELECT ... FOR UPDATE、UPDATE course SET selected_count selected_count 1这类真正和数据打交道的 SQL只出现在 Mapper XML 里。我把这种分层原则叫做“Controller 越薄越好Service 越厚越稳”因为一旦把业务判断写进 Controller将来新增一个选课入口比如管理员代选、批量选课你就得把判断逻辑复制一遍这是事务失效和 bug 重复出现的根源。2.2 JSP 在后端渲染为主的后台管理系统里为什么还管用前端界面上这个项目用 JSP 而不是 Vue、React常被当成“技术陈旧”的典型。但放在选课管理系统这种场景里JSP 的服务器端渲染模式其实有不可替代的优势页面数据量小、交互简单主要操作是“查课表、点击选课、看结果”服务端一次把课程列表和已选状态渲染完浏览器几乎不需要维护状态。对比前后端分离方案JSP 省掉了一整套前端构建链不需要 Node.js 环境也不需要考虑跨域一个 Tomcat 部署完即可访问。JSP 文件的另一个实际好处是 JavaBean JSTL 的配合。在course_list.jsp里用c:forEach遍历ListCourse直接用${course.courseName}输出属性这套语法对刚接触 Java Web 的新手非常直观对一个上线的后台管理系统也足够稳定。等系统真的复杂到需要图表联动、实时刷新选课人数时再逐步在个别页面引入 Ajax 接口也不迟这比一开始就上前后端分离的改造成本低得多。2.3 一次选课请求在 SSM 链路中的完整数据流把一次选课请求在整套框架里的流动画出来故障排查就成功了一半。学生在 JSP 页面点击“选课”按钮表单 POST 提交到student/selectCourseDispatcherServlet 根据RequestMapping找到CourseController的对应方法Controller 从 HttpSession 拿到当前学生 ID把课程 ID 传给SelectionServiceService 开启事务先通过课程 ID 查询课程行并加锁检查容量和重复选课插入选课记录更新selected_count事务提交后返回成功提示Controller 把提示消息写进 Model转发回课程列表页面JSP 用${msg}显示给用户。链路环节所在层典型问题排查手段表单提交JSP / 浏览器URL 地址或参数名写错F12 看 Network 的实际请求请求分发SpringMVC返回 404 或 405检查RequestMapping与 JSP form action业务判断Spring Service事务未回滚选课数据不一致查看 rollbackFor 配置SQL 执行MyBatis / MySQL锁等待超时、参数绑定失败打开 MyBatis 日志输出实际 SQL这一层看在项目排错时最有价值的是最后一行把 MyBatis 的日志级别调到 DEBUGSpring 的applicationContext.xml里加上日志配置立刻能看到框架实际执行的 SQL 语句和绑定参数。很多“页面报错但不知道哪里错”的问题从这里开始查比逐行读代码快得多。3. MySQL建表与 Maven 工程骨架选课系统的地基怎么搭3.1 学生表、课程表、选课中间表的建表 SQL 与设计理由选课系统最少需要三张表学生表、课程表、选课中间表。学生表存基础信息和登录密码课程表设计时必须包含capacity容量字段和selected_count当前已选人数选课中间表是学生与课程的多对多关系的载体同时也承担了“防止重复选课”的职责。建表 SQL 我一般这样写CREATE DATABASE IF NOT EXISTS student_selection DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE student_selection; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, class_name VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(100) NOT NULL, teacher VARCHAR(50), credit DECIMAL(3,1), capacity INT NOT NULL DEFAULT 60, selected_count INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已选0退选, UNIQUE KEY uk_student_course (student_id, course_id), KEY idx_course_id (course_id), CONSTRAINT fk_selection_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_selection_course FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;说三个关键参数。第一个是ENGINEInnoDB选课会用到行锁和事务MyISAM 不支持行锁并发选课时会出现数据错乱这一点必须明确。第二个是UNIQUE KEY uk_student_course (student_id, course_id)这是防止重复选课的最后一道物理防线哪怕业务代码里漏了校验数据库层也会拒绝第二次插入。第三个是selected_count字段它属于冗余字段为的是避免每次展示课表时都COUNT(*)一张巨大的中间表但冗余字段必须在更新选课时同步维护这也就是后面事务中两条 UPDATE 不能脱节的原因。3.2 pom.xml 依赖清单与版本兼容关系SSM 项目早期的坑大多集中在版本冲突上。Spring 4 和 Spring 5 之间跨越了 Java 版本基线MyBatis 3.4 以前与 Spring 集成时还需要单独配org.mybatis:mybatis-spring的兼容版本。下面这份pom.xml依赖清单是我常用的组合对应 JDK 8 Tomcat 8.5 环境properties spring.version5.2.22.RELEASE/spring.version mybatis.version3.5.6/mybatis.version /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version scoperuntime/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.8/version /dependency /dependencies依赖说明Spring 版本与 JDK 版本强制绑定JDK 8 用 5.2.x 最省心升到 Spring 6 就必须搭配 JDK 17。mysql-connector-java选 8.0.x 时JDBC 驱动类名是com.mysql.cj.jdbc.Driver跟 5.x 版本的com.mysql.jdbc.Driver不一样这也是新手最常遇到的ClassNotFoundException来源。javax.servlet-api用provided作用域目的是编译时可用、打包时不重复打进去否则部署到 Tomcat 会与容器自带的 Servlet 类冲突。3.3 spring、springmvc、jdbc 三个配置文件的落地写法SSM 的配置可以拆成三份jdbc.properties放数据库连接参数spring-context.xml放 Spring 容器与 MyBatis 集成springmvc.xml放控制器扫描与视图解析器。先看数据库连接参数jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/student_selection?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.passwordroot jdbc.initialSize5 jdbc.maxActive20serverTimezoneAsia/Shanghai是 MySQL 8 连接参数里最容易被忽略但直接影响业务的一环。不指定时区JDBC 把 Java 的Date写入数据库会按服务器默认时区转换经常出现“插入的时间和数据库看到的时间差 8 小时”。useSSLfalse避免本地开发时因为证书问题报一堆 warning生产环境如果开启 SSL 则去掉这个参数。spring-context.xml的核心是把数据源、SqlSessionFactory 和事务管理器串到一起context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.example.entity/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.dao/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/mapperLocations告诉 MyBatis 去哪里找 XML 映射文件typeAliasesPackage让 Mapper XML 里写resultTypeCourse时能自动映射到实体类不用写全路径MapperScannerConfigurer则是把 DAO 接口动态代理成 Bean。事务管理器选中DataSourceTransactionManagertx:annotation-driven开启Transactional注解生效。springmvc.xml要单独扫描Controller避免把业务 Service 重复扫描context:component-scan base-packagecom.example.controller/ mvc:annotation-driven/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp// property namesuffix value.jsp/ /bean视图解析器的prefix设置成/WEB-INF/jsp/而不是/jsp/是为了让 JSP 文件放在 WEB-INF 目录下浏览器无法直接通过 URL 访问只能经 Controller 转发这对后台管理系统的访问控制是重要的一层保护。4. 选课核心实现Controller、Service、MyBatis 映射怎么写4.1 Service 层处理超容量与重复选课的事务代码选课业务最核心的一条规则是课程已选人数达到capacity后不能再选同一个学生不能重复选同一门课。这两条规则在并发场景下会放大成“超容量选课”和“重复插入”。只靠单机代码里的if (selectedCount capacity)判断做不到绝对安全因为两个请求可能同时读到同一个旧值。所以 Service 层要同时依赖事务和数据库行锁Service Transactional(rollbackFor Exception.class) public class SelectionServiceImpl implements SelectionService { Resource private CourseMapper courseMapper; Resource private SelectionMapper selectionMapper; Override public boolean selectCourse(Integer studentId, Integer courseId) { Course course courseMapper.selectByIdForUpdate(courseId); if (course null) { throw new BizException(课程不存在); } if (course.getSelectedCount() course.getCapacity()) { throw new BizException(该课程已选满); } Integer exists selectionMapper.checkExists(studentId, courseId); if (exists ! null exists 0) { throw new BizException(请勿重复选课); } selectionMapper.insertSelection(studentId, courseId); int rows courseMapper.increaseSelectedCount(courseId); if (rows 0) { throw new BizException(选课失败请重试); } return true; } }代码逻辑不复杂但要注意三处。第一Transactional(rollbackFor Exception.class)必须显式声明rollbackFor因为 Spring 默认只在运行时异常时回滚如果业务代码抛的是受检异常比如Exception的直接子类事务不会回滚选课记录插入成功但课程人数没增加数据就错乱了。第二查询课程用的selectByIdForUpdate不是普通查询它会在 MySQL InnoDB 引擎里对这条课程记录加排他锁锁会一直持有到事务提交或回滚这是防止并发超选的关键。第三先查checkExists再插入的做法能挡住大部分重复请求但极端并发下仍可能出现两个事务同时通过检查所以数据库中uk_student_course唯一索引是最后的兜底。4.2 MyBatis XML 中的加锁 SQL 与两个参数绑定差异对应CourseMapper接口的三个方法在CourseMapper.xml里这样写mapper namespacecom.example.dao.CourseMapper select idselectByIdForUpdate resultTypeCourse SELECT id, course_no, course_name, teacher, credit, capacity, selected_count, version FROM course WHERE id #{courseId} FOR UPDATE /select select idcheckExists resultTypejava.lang.Integer SELECT COUNT(*) FROM selection WHERE student_id #{studentId} AND course_id #{courseId} AND status 1 /select insert idinsertSelection INSERT INTO selection (student_id, course_id, status) VALUES (#{studentId}, #{courseId}, 1) /insert update idincreaseSelectedCount UPDATE course SET selected_count selected_count 1, version version 1 WHERE id #{courseId} /update /mapper重点说FOR UPDATE。这里FOR UPDATE锁的是课程表里id #{courseId}这一行不是表锁因此在多门课并发选课时互不阻塞。但锁的生效前提是查询必须在事务内执行如果 Service 方法没加TransactionalMySQL 会自动提交每条语句查询返回后锁立刻释放后面的insert和update就又回到无锁状态了。排查这类问题时可以打开 MySQL 命令行执行SHOW ENGINE INNODB STATUS;查看TRANSACTIONS段能直接看到当前事务持有的行锁与等待关系。#{}的作用是把参数预编译成?占位符由 JDBC 的PreparedStatement绑定值SQL 注入对#{courseId}无效这是 Mapper XML 里必须无条件使用#{}的原因。${}则是字符串替换直接把参数拼进 SQL 字符串比如排序字段ORDER BY ${sort}这种场景才用得到且传入值必须白名单校验绝不能用用户输入直接拼。4.3 SpringMVC 控制器接收参数后如何回传 JSPController 层把页面上的表单参数接进来调用 Service再把结果交给 JSP 渲染。这里用forward而非redirect的原因要讲清楚forward是服务端内部转发浏览器 URL 不变请求参数和 Model 数据可以原样传递redirect是一次 302 跳转浏览器重新发起一次 GET 请求Model 数据会丢失只能用RedirectAttributes传值。选课失败后要保留学生的课表搜索条件我通常选择forwardController RequestMapping(/student) public class StudentCourseController { Resource private SelectionService selectionService; RequestMapping(value /selectCourse, method RequestMethod.POST) public String selectCourse(RequestParam(courseId) Integer courseId, HttpSession session, Model model) { Integer studentId (Integer) session.getAttribute(studentId); try { selectionService.selectCourse(studentId, courseId); model.addAttribute(msg, 选课成功); } catch (BizException e) { model.addAttribute(msg, e.getMessage()); } model.addAttribute(courseList, courseService.listAllCourses()); return course_list; } }RequestParam(courseId)的值对应 JSP 表单里input namecourseId或按钮的namecourseId名字对不上会直接报 400。把返回字符串写成course_list后视图解析器自动拼接成/WEB-INF/jsp/course_list.jsp。这里每做一次选课都重新查询一次课程列表能保证页面上已选名额是准的如果课程量大也可以把courseList放到 session 上一段时间内复用减少每一次选课请求都打数据库的压力。JSP 页面里接收消息并展示同时把课程遍历出来% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html body p${msg}/p c:forEach items${courseList} varcourse form action${pageContext.request.contextPath}/student/selectCourse methodpost 课程名${course.courseName} 教师${course.teacher} 名额${course.selectedCount}/${course.capacity} input typehidden namecourseId value${course.id} button typesubmit选课/button /form /c:forEach /body /html${pageContext.request.contextPath}会自动带上应用部署路径例如/student_selection这样即使把 WAR 包改名部署表单提交的 URL 也不会错。hidden输入框用来带课程 IDJSTL 的forEach遍历的对象必须是 Controller 里通过model.addAttribute放入的ListCourse如果 LAZY 加载打开且 Session 关闭访问关联属性时会抛LazyInitializationException这与 MyBatis 的延迟加载配置有关。4.4 并发选课常犯的三个错误第一个是用同步关键字解决并发。给selectCourse方法加synchronized只能在单机单实例内生效一旦应用部署多个 Tomcat 实例或进入集群锁就完全失效而且会让本可并行处理的课程选课请求全部串行化。第二个是把容量判断放在 INSERT INTO selection 之后先插入中间表再校验人数结果学生课表里出现了记录但课程人数没增加事务回滚时因为异常被 catch 后吞掉导致脏数据。正确的顺序是加锁查询、业务校验、插入中间表、更新人数且所有操作在同一个事务里。第三个是不处理数据库唯一索引冲突。依靠checkExists完成后觉得万事大吉但当两条 SELECT 同时返回 0两个 INSERT 都会被数据库执行第二个会撞上uk_student_course抛出DuplicateKeyException。Service 里应当捕获这个异常并转成业务提示“你已选过该课程”否则前端会看到满屏 500 异常栈。5. 部署验证、选课导出与两个常用优化5.1 把项目跑起来的部署顺序与常见报错排查开发环境里 IDE 启动 Tomcat 是一回事打成 WAR 包部署到独立 Tomcat 又是另一回事。我先在项目根目录执行mvn clean package -DskipTests跳过单测减少构建时间打包成功后到target/下找到.war文件拷贝到 Tomcat 的webapps/目录启动脚本bin/startup.sh后查看logs/catalina.out。这里有个关键点WAR 包名称就是应用上下文路径把target里的 WAR 重命名成ssm-select-course.war访问地址就变成http://localhost:8080/ssm-select-course/JSP 表单里的${pageContext.request.contextPath}会自动适配不会因为改名而 404。部署阶段报错现象优先检查项数据库连接Cannot create PoolableConnectionFactoryjdbc.properties 的端口、用户名、密码MySQL 是否启动驱动加载ClassNotFoundException: com.mysql.cj.jdbc.Driverpom.xml 依赖是否正确包是否打入 WEB-INF/libSpring 容器启动NoSuchBeanDefinitionExceptionMapperScannerConfigurer 的 basePackage 是否扫描到 DAO页面访问404 找不到 /student/loginweb.xml 中 DispatcherServlet 的 url-pattern 是否覆盖JSP 渲染EL 表达式原样输出${msg}JSP 里isELIgnoredfalse或容器版本是否过高补一个高频问题JSP 编译报错时Tomcat 日志里通常只能看到Unable to compile class for JSP这时要单独把 JSP 里的语法剥离出来最常见的原因是 JSTL 标签库的 URI 写错http://java.sun.com/jsp/jstl/core这个地址必须原样保留不要改成 https 或加多余路径。5.2 把选课名单导出为 Excel 的最小实现项目里常有“把选课名单导出成 Excel”的需求不用上 Apache POI 也能快速做出一版可用结果。POI 在 SSM 里会引入五六个别名包如果只是导出表格直接用 JSP 或 Controller 输出带 BOM 的 CSV 文件Excel 打开后仍然正常分列。Controller 里这样写RequestMapping(/student/exportSelection) public void exportSelection(Integer courseId, HttpServletResponse response) throws IOException { ListSelectionVo list selectionService.listByCourseId(courseId); response.setContentType(text/csv;charsetUTF-8); response.setHeader(Content-Disposition, attachment;filenameselection_ courseId .csv); PrintWriter writer response.getWriter(); writer.write(\ufeff); writer.println(学号,姓名,班级,选课时间); for (SelectionVo vo : list) { writer.println(vo.getStudentNo() , vo.getName() , vo.getClassName() , vo.getSelectTime()); } writer.flush(); }setContentType告诉浏览器这是一个 CSV 文件Content-Disposition里的attachment触发下载而不是直接在浏览器打开\ufeff是 UTF-8 BOM不加它Excel 打开中文 CSV 文件时会出现“学号、姓名”乱码。这层实现适合几百行的选课名单超过五千行则建议用 POI 的 SXSSF 方式写真实.xlsx避免 CSV 受列数限制。5.3 选课列表分页的 MySQL 写法与索引落点课程数量和选课记录增长后SELECT * FROM course全量查询会拖慢首页加载。分页用 MySQL 的LIMIT #{offset}, #{pageSize}配合ORDER BY id DESC保证连续翻页时数据不重复。翻页越深LIMIT 100000, 20这种写法越慢MySQL 需要扫描前面 10 万条再丢弃所以超过几十万行时改成游标分页记住上一页最后一条记录的id下一页直接用WHERE id #{lastId} ORDER BY id DESC LIMIT 20让索引直接定位。分页查询的offset和pageSize参数传入 Mapper 时用#{}绑定不会注入但offset一定要在 Service 里做page 0 时重置为 0、pageSize 100 时截断为 100的限制防止恶意用超大页码拖垮数据库。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进