
簡介這是一個基于 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的限制防止惡意用超大頁碼拖垮數據庫。本文還有配套的精品資源點擊獲取