生選課管理系統(tǒng)實(shí)戰(zhàn):從分層架構(gòu)到并發(fā)控制)
簡介這是一個(gè)基于 SSMSpringMVC MyBatis框架、搭配 JSP 與 MySQL 實(shí)現(xiàn)的學(xué)生信息管理與選課系統(tǒng)適合 Java Web 初學(xué)者、畢業(yè)設(shè)計(jì)或課程設(shè)計(jì)參考。項(xiàng)目前端采用 Bootstrap 與 particles.js 插件后臺使用 PageHelper 完成分頁查詢并對添加、修改操作通過 Ajax 校驗(yàn)主鍵是否可用同時(shí)已配置登錄攔截代碼分層清晰、輸入框約束較強(qiáng)便于二次開發(fā)。整個(gè)資源包共 266 個(gè)文件以 Java 源碼、JSP 頁面、XML 配置文件為主輔以 SQL 腳本、JavaScript 與樣式文件壓縮包大小僅 2.64MB結(jié)構(gòu)緊湊能快速導(dǎo)入 IDE 運(yùn)行學(xué)習(xí)。已有 806 人瀏覽學(xué)習(xí)適合希望掌握 SSM 整合、前后端交互及基礎(chǔ)權(quán)限控制的學(xué)生對照實(shí)踐從中可獲取完整可運(yùn)行的項(xiàng)目源碼、數(shù)據(jù)庫腳本和目錄結(jié)構(gòu)參考用于理解選課業(yè)務(wù)模塊與框架配置細(xì)節(jié)。1. 為什么“基于SSMjspmysql的學(xué)生信息管理系統(tǒng)(選課)”依然是重復(fù)使用率最高的教學(xué)項(xiàng)目骨架把“學(xué)生信息管理”和“選課”放進(jìn)同一個(gè)標(biāo)題意味著你已經(jīng)選中了 Java Web 開發(fā)里最經(jīng)典的一條教學(xué)路線JSP 負(fù)責(zé)頁面渲染SpringMVC 負(fù)責(zé)請求分發(fā)Spring 管理業(yè)務(wù)對象MyBatis 做 SQL 映射MySQL 存全部業(yè)務(wù)數(shù)據(jù)。這套組合在高校實(shí)訓(xùn)、畢業(yè)設(shè)計(jì)、企業(yè)內(nèi)部培訓(xùn)中出現(xiàn)的頻率遠(yuǎn)超微服務(wù)方案原因是它能在不出局域網(wǎng)的前提下用一臺 Tomcat 把登錄、CRUD、多表關(guān)聯(lián)、事務(wù)控制和權(quán)限攔截完整串起來恰好是初學(xué)者建立 Java Web 全鏈路認(rèn)知的爬坡路徑。對工作五年以上的工程師這個(gè)項(xiàng)目也有其價(jià)值很多公司里仍跑著以 JSP 為核心的遺留系統(tǒng)改造為前后端分離之前你需要一份對 JSP 生命周期、MyBatis 代理機(jī)制、Spring 事務(wù)邊界都有掌控力的基線版本。選課是最值得動(dòng)手的模塊超容量限制、重復(fù)選課校驗(yàn)、退選釋放名額、并發(fā)情況下防止超賣每一行代碼都直接對著數(shù)據(jù)庫的鎖和事務(wù)。2. SSMJSPMySQL的分工邏輯哪一層決定選課業(yè)務(wù)哪一層只做展示2.1 Spring、SpringMVC、MyBatis 三個(gè)框架在選課場景中的職責(zé)邊界很多初學(xué)者把 SSM 當(dāng)成一個(gè)整體來背但排錯(cuò)和改造時(shí)最忌諱的就是分不清每一層的邊界。Spring 在這里是 IoC 容器負(fù)責(zé)管理CourseService、SelectionService、CourseMapper這些 Bean 的生命周期事務(wù)由它統(tǒng)一控制SpringMVC 只做兩件事把 HTTP 請求解析成控制器方法參數(shù)再把方法返回值渲染到 JSP 頁面MyBatis 則把 DAO 接口和 Mapper XML 里的 SQL 綁定起來返還的結(jié)果直接映射成 JavaBean。放到選課業(yè)務(wù)里看這三層各管一段Controller 不寫if (selectedCount capacity)這種判斷它只負(fù)責(zé)接收courseId和studentId然后調(diào)用 Service判斷課程是否選滿、學(xué)生是否重復(fù)選課、事務(wù)邊界在哪里是 Service 的職責(zé)而SELECT ... FOR UPDATE、UPDATE course SET selected_count selected_count 1這類真正和數(shù)據(jù)打交道的 SQL只出現(xiàn)在 Mapper XML 里。我把這種分層原則叫做“Controller 越薄越好Service 越厚越穩(wěn)”因?yàn)橐坏┌褬I(yè)務(wù)判斷寫進(jìn) Controller將來新增一個(gè)選課入口比如管理員代選、批量選課你就得把判斷邏輯復(fù)制一遍這是事務(wù)失效和 bug 重復(fù)出現(xiàn)的根源。2.2 JSP 在后端渲染為主的后臺管理系統(tǒng)里為什么還管用前端界面上這個(gè)項(xiàng)目用 JSP 而不是 Vue、React常被當(dāng)成“技術(shù)陳舊”的典型。但放在選課管理系統(tǒng)這種場景里JSP 的服務(wù)器端渲染模式其實(shí)有不可替代的優(yōu)勢頁面數(shù)據(jù)量小、交互簡單主要操作是“查課表、點(diǎn)擊選課、看結(jié)果”服務(wù)端一次把課程列表和已選狀態(tài)渲染完瀏覽器幾乎不需要維護(hù)狀態(tài)。對比前后端分離方案JSP 省掉了一整套前端構(gòu)建鏈不需要 Node.js 環(huán)境也不需要考慮跨域一個(gè) Tomcat 部署完即可訪問。JSP 文件的另一個(gè)實(shí)際好處是 JavaBean JSTL 的配合。在course_list.jsp里用c:forEach遍歷ListCourse直接用${course.courseName}輸出屬性這套語法對剛接觸 Java Web 的新手非常直觀對一個(gè)上線的后臺管理系統(tǒng)也足夠穩(wěn)定。等系統(tǒng)真的復(fù)雜到需要圖表聯(lián)動(dòng)、實(shí)時(shí)刷新選課人數(shù)時(shí)再逐步在個(gè)別頁面引入 Ajax 接口也不遲這比一開始就上前后端分離的改造成本低得多。2.3 一次選課請求在 SSM 鏈路中的完整數(shù)據(jù)流把一次選課請求在整套框架里的流動(dòng)畫出來故障排查就成功了一半。學(xué)生在 JSP 頁面點(diǎn)擊“選課”按鈕表單 POST 提交到student/selectCourseDispatcherServlet 根據(jù)RequestMapping找到CourseController的對應(yīng)方法Controller 從 HttpSession 拿到當(dāng)前學(xué)生 ID把課程 ID 傳給SelectionServiceService 開啟事務(wù)先通過課程 ID 查詢課程行并加鎖檢查容量和重復(fù)選課插入選課記錄更新selected_count事務(wù)提交后返回成功提示Controller 把提示消息寫進(jìn) Model轉(zhuǎn)發(fā)回課程列表頁面JSP 用${msg}顯示給用戶。鏈路環(huán)節(jié)所在層典型問題排查手段表單提交JSP / 瀏覽器URL 地址或參數(shù)名寫錯(cuò)F12 看 Network 的實(shí)際請求請求分發(fā)SpringMVC返回 404 或 405檢查RequestMapping與 JSP form action業(yè)務(wù)判斷Spring Service事務(wù)未回滾選課數(shù)據(jù)不一致查看 rollbackFor 配置SQL 執(zhí)行MyBatis / MySQL鎖等待超時(shí)、參數(shù)綁定失敗打開 MyBatis 日志輸出實(shí)際 SQL這一層看在項(xiàng)目排錯(cuò)時(shí)最有價(jià)值的是最后一行把 MyBatis 的日志級別調(diào)到 DEBUGSpring 的applicationContext.xml里加上日志配置立刻能看到框架實(shí)際執(zhí)行的 SQL 語句和綁定參數(shù)。很多“頁面報(bào)錯(cuò)但不知道哪里錯(cuò)”的問題從這里開始查比逐行讀代碼快得多。3. MySQL建表與 Maven 工程骨架選課系統(tǒng)的地基怎么搭3.1 學(xué)生表、課程表、選課中間表的建表 SQL 與設(shè)計(jì)理由選課系統(tǒng)最少需要三張表學(xué)生表、課程表、選課中間表。學(xué)生表存基礎(chǔ)信息和登錄密碼課程表設(shè)計(jì)時(shí)必須包含capacity容量字段和selected_count當(dāng)前已選人數(shù)選課中間表是學(xué)生與課程的多對多關(guān)系的載體同時(shí)也承擔(dān)了“防止重復(fù)選課”的職責(zé)。建表 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;說三個(gè)關(guān)鍵參數(shù)。第一個(gè)是ENGINEInnoDB選課會用到行鎖和事務(wù)MyISAM 不支持行鎖并發(fā)選課時(shí)會出現(xiàn)數(shù)據(jù)錯(cuò)亂這一點(diǎn)必須明確。第二個(gè)是UNIQUE KEY uk_student_course (student_id, course_id)這是防止重復(fù)選課的最后一道物理防線哪怕業(yè)務(wù)代碼里漏了校驗(yàn)數(shù)據(jù)庫層也會拒絕第二次插入。第三個(gè)是selected_count字段它屬于冗余字段為的是避免每次展示課表時(shí)都COUNT(*)一張巨大的中間表但冗余字段必須在更新選課時(shí)同步維護(hù)這也就是后面事務(wù)中兩條 UPDATE 不能脫節(jié)的原因。3.2 pom.xml 依賴清單與版本兼容關(guān)系SSM 項(xiàng)目早期的坑大多集中在版本沖突上。Spring 4 和 Spring 5 之間跨越了 Java 版本基線MyBatis 3.4 以前與 Spring 集成時(shí)還需要單獨(dú)配org.mybatis:mybatis-spring的兼容版本。下面這份pom.xml依賴清單是我常用的組合對應(yīng) JDK 8 Tomcat 8.5 環(huán)境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 版本強(qiáng)制綁定JDK 8 用 5.2.x 最省心升到 Spring 6 就必須搭配 JDK 17。mysql-connector-java選 8.0.x 時(shí)JDBC 驅(qū)動(dòng)類名是com.mysql.cj.jdbc.Driver跟 5.x 版本的com.mysql.jdbc.Driver不一樣這也是新手最常遇到的ClassNotFoundException來源。javax.servlet-api用provided作用域目的是編譯時(shí)可用、打包時(shí)不重復(fù)打進(jìn)去否則部署到 Tomcat 會與容器自帶的 Servlet 類沖突。3.3 spring、springmvc、jdbc 三個(gè)配置文件的落地寫法SSM 的配置可以拆成三份jdbc.properties放數(shù)據(jù)庫連接參數(shù)spring-context.xml放 Spring 容器與 MyBatis 集成springmvc.xml放控制器掃描與視圖解析器。先看數(shù)據(jù)庫連接參數(shù)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 連接參數(shù)里最容易被忽略但直接影響業(yè)務(wù)的一環(huán)。不指定時(shí)區(qū)JDBC 把 Java 的Date寫入數(shù)據(jù)庫會按服務(wù)器默認(rèn)時(shí)區(qū)轉(zhuǎn)換經(jīng)常出現(xiàn)“插入的時(shí)間和數(shù)據(jù)庫看到的時(shí)間差 8 小時(shí)”。useSSLfalse避免本地開發(fā)時(shí)因?yàn)樽C書問題報(bào)一堆 warning生產(chǎn)環(huán)境如果開啟 SSL 則去掉這個(gè)參數(shù)。spring-context.xml的核心是把數(shù)據(jù)源、SqlSessionFactory 和事務(wù)管理器串到一起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時(shí)能自動(dòng)映射到實(shí)體類不用寫全路徑MapperScannerConfigurer則是把 DAO 接口動(dòng)態(tài)代理成 Bean。事務(wù)管理器選中DataSourceTransactionManagertx:annotation-driven開啟Transactional注解生效。springmvc.xml要單獨(dú)掃描Controller避免把業(yè)務(wù) Service 重復(fù)掃描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設(shè)置成/WEB-INF/jsp/而不是/jsp/是為了讓 JSP 文件放在 WEB-INF 目錄下瀏覽器無法直接通過 URL 訪問只能經(jīng) Controller 轉(zhuǎn)發(fā)這對后臺管理系統(tǒng)的訪問控制是重要的一層保護(hù)。4. 選課核心實(shí)現(xiàn)Controller、Service、MyBatis 映射怎么寫4.1 Service 層處理超容量與重復(fù)選課的事務(wù)代碼選課業(yè)務(wù)最核心的一條規(guī)則是課程已選人數(shù)達(dá)到capacity后不能再選同一個(gè)學(xué)生不能重復(fù)選同一門課。這兩條規(guī)則在并發(fā)場景下會放大成“超容量選課”和“重復(fù)插入”。只靠單機(jī)代碼里的if (selectedCount capacity)判斷做不到絕對安全因?yàn)閮蓚€(gè)請求可能同時(shí)讀到同一個(gè)舊值。所以 Service 層要同時(shí)依賴事務(wù)和數(shù)據(jù)庫行鎖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(請勿重復(fù)選課); } selectionMapper.insertSelection(studentId, courseId); int rows courseMapper.increaseSelectedCount(courseId); if (rows 0) { throw new BizException(選課失敗請重試); } return true; } }代碼邏輯不復(fù)雜但要注意三處。第一Transactional(rollbackFor Exception.class)必須顯式聲明rollbackFor因?yàn)?Spring 默認(rèn)只在運(yùn)行時(shí)異常時(shí)回滾如果業(yè)務(wù)代碼拋的是受檢異常比如Exception的直接子類事務(wù)不會回滾選課記錄插入成功但課程人數(shù)沒增加數(shù)據(jù)就錯(cuò)亂了。第二查詢課程用的selectByIdForUpdate不是普通查詢它會在 MySQL InnoDB 引擎里對這條課程記錄加排他鎖鎖會一直持有到事務(wù)提交或回滾這是防止并發(fā)超選的關(guān)鍵。第三先查checkExists再插入的做法能擋住大部分重復(fù)請求但極端并發(fā)下仍可能出現(xiàn)兩個(gè)事務(wù)同時(shí)通過檢查所以數(shù)據(jù)庫中uk_student_course唯一索引是最后的兜底。4.2 MyBatis XML 中的加鎖 SQL 與兩個(gè)參數(shù)綁定差異對應(yīng)CourseMapper接口的三個(gè)方法在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重點(diǎn)說FOR UPDATE。這里FOR UPDATE鎖的是課程表里id #{courseId}這一行不是表鎖因此在多門課并發(fā)選課時(shí)互不阻塞。但鎖的生效前提是查詢必須在事務(wù)內(nèi)執(zhí)行如果 Service 方法沒加TransactionalMySQL 會自動(dòng)提交每條語句查詢返回后鎖立刻釋放后面的insert和update就又回到無鎖狀態(tài)了。排查這類問題時(shí)可以打開 MySQL 命令行執(zhí)行SHOW ENGINE INNODB STATUS;查看TRANSACTIONS段能直接看到當(dāng)前事務(wù)持有的行鎖與等待關(guān)系。#{}的作用是把參數(shù)預(yù)編譯成?占位符由 JDBC 的PreparedStatement綁定值SQL 注入對#{courseId}無效這是 Mapper XML 里必須無條件使用#{}的原因。${}則是字符串替換直接把參數(shù)拼進(jìn) SQL 字符串比如排序字段ORDER BY ${sort}這種場景才用得到且傳入值必須白名單校驗(yàn)絕不能用用戶輸入直接拼。4.3 SpringMVC 控制器接收參數(shù)后如何回傳 JSPController 層把頁面上的表單參數(shù)接進(jìn)來調(diào)用 Service再把結(jié)果交給 JSP 渲染。這里用forward而非redirect的原因要講清楚forward是服務(wù)端內(nèi)部轉(zhuǎn)發(fā)瀏覽器 URL 不變請求參數(shù)和 Model 數(shù)據(jù)可以原樣傳遞redirect是一次 302 跳轉(zhuǎn)瀏覽器重新發(fā)起一次 GET 請求Model 數(shù)據(jù)會丟失只能用RedirectAttributes傳值。選課失敗后要保留學(xué)生的課表搜索條件我通常選擇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)的值對應(yīng) JSP 表單里input namecourseId或按鈕的namecourseId名字對不上會直接報(bào) 400。把返回字符串寫成course_list后視圖解析器自動(dòng)拼接成/WEB-INF/jsp/course_list.jsp。這里每做一次選課都重新查詢一次課程列表能保證頁面上已選名額是準(zhǔn)的如果課程量大也可以把courseList放到 session 上一段時(shí)間內(nèi)復(fù)用減少每一次選課請求都打數(shù)據(jù)庫的壓力。JSP 頁面里接收消息并展示同時(shí)把課程遍歷出來% 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}會自動(dòng)帶上應(yīng)用部署路徑例如/student_selection這樣即使把 WAR 包改名部署表單提交的 URL 也不會錯(cuò)。hidden輸入框用來帶課程 IDJSTL 的forEach遍歷的對象必須是 Controller 里通過model.addAttribute放入的ListCourse如果 LAZY 加載打開且 Session 關(guān)閉訪問關(guān)聯(lián)屬性時(shí)會拋LazyInitializationException這與 MyBatis 的延遲加載配置有關(guān)。4.4 并發(fā)選課常犯的三個(gè)錯(cuò)誤第一個(gè)是用同步關(guān)鍵字解決并發(fā)。給selectCourse方法加synchronized只能在單機(jī)單實(shí)例內(nèi)生效一旦應(yīng)用部署多個(gè) Tomcat 實(shí)例或進(jìn)入集群鎖就完全失效而且會讓本可并行處理的課程選課請求全部串行化。第二個(gè)是把容量判斷放在 INSERT INTO selection 之后先插入中間表再校驗(yàn)人數(shù)結(jié)果學(xué)生課表里出現(xiàn)了記錄但課程人數(shù)沒增加事務(wù)回滾時(shí)因?yàn)楫惓1?catch 后吞掉導(dǎo)致臟數(shù)據(jù)。正確的順序是加鎖查詢、業(yè)務(wù)校驗(yàn)、插入中間表、更新人數(shù)且所有操作在同一個(gè)事務(wù)里。第三個(gè)是不處理數(shù)據(jù)庫唯一索引沖突。依靠checkExists完成后覺得萬事大吉但當(dāng)兩條 SELECT 同時(shí)返回 0兩個(gè) INSERT 都會被數(shù)據(jù)庫執(zhí)行第二個(gè)會撞上uk_student_course拋出DuplicateKeyException。Service 里應(yīng)當(dāng)捕獲這個(gè)異常并轉(zhuǎn)成業(yè)務(wù)提示“你已選過該課程”否則前端會看到滿屏 500 異常棧。5. 部署驗(yàn)證、選課導(dǎo)出與兩個(gè)常用優(yōu)化5.1 把項(xiàng)目跑起來的部署順序與常見報(bào)錯(cuò)排查開發(fā)環(huán)境里 IDE 啟動(dòng) Tomcat 是一回事打成 WAR 包部署到獨(dú)立 Tomcat 又是另一回事。我先在項(xiàng)目根目錄執(zhí)行mvn clean package -DskipTests跳過單測減少構(gòu)建時(shí)間打包成功后到target/下找到.war文件拷貝到 Tomcat 的webapps/目錄啟動(dòng)腳本bin/startup.sh后查看logs/catalina.out。這里有個(gè)關(guān)鍵點(diǎn)WAR 包名稱就是應(yīng)用上下文路徑把target里的 WAR 重命名成ssm-select-course.war訪問地址就變成http://localhost:8080/ssm-select-course/JSP 表單里的${pageContext.request.contextPath}會自動(dòng)適配不會因?yàn)楦拿?404。部署階段報(bào)錯(cuò)現(xiàn)象優(yōu)先檢查項(xiàng)數(shù)據(jù)庫連接Cannot create PoolableConnectionFactoryjdbc.properties 的端口、用戶名、密碼MySQL 是否啟動(dòng)驅(qū)動(dòng)加載ClassNotFoundException: com.mysql.cj.jdbc.Driverpom.xml 依賴是否正確包是否打入 WEB-INF/libSpring 容器啟動(dòng)NoSuchBeanDefinitionExceptionMapperScannerConfigurer 的 basePackage 是否掃描到 DAO頁面訪問404 找不到 /student/loginweb.xml 中 DispatcherServlet 的 url-pattern 是否覆蓋JSP 渲染EL 表達(dá)式原樣輸出${msg}JSP 里isELIgnoredfalse或容器版本是否過高補(bǔ)一個(gè)高頻問題JSP 編譯報(bào)錯(cuò)時(shí)Tomcat 日志里通常只能看到Unable to compile class for JSP這時(shí)要單獨(dú)把 JSP 里的語法剝離出來最常見的原因是 JSTL 標(biāo)簽庫的 URI 寫錯(cuò)http://java.sun.com/jsp/jstl/core這個(gè)地址必須原樣保留不要改成 https 或加多余路徑。5.2 把選課名單導(dǎo)出為 Excel 的最小實(shí)現(xiàn)項(xiàng)目里常有“把選課名單導(dǎo)出成 Excel”的需求不用上 Apache POI 也能快速做出一版可用結(jié)果。POI 在 SSM 里會引入五六個(gè)別名包如果只是導(dǎo)出表格直接用 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(學(xué)號,姓名,班級,選課時(shí)間); for (SelectionVo vo : list) { writer.println(vo.getStudentNo() , vo.getName() , vo.getClassName() , vo.getSelectTime()); } writer.flush(); }setContentType告訴瀏覽器這是一個(gè) CSV 文件Content-Disposition里的attachment觸發(fā)下載而不是直接在瀏覽器打開\ufeff是 UTF-8 BOM不加它Excel 打開中文 CSV 文件時(shí)會出現(xiàn)“學(xué)號、姓名”亂碼。這層實(shí)現(xiàn)適合幾百行的選課名單超過五千行則建議用 POI 的 SXSSF 方式寫真實(shí).xlsx避免 CSV 受列數(shù)限制。5.3 選課列表分頁的 MySQL 寫法與索引落點(diǎn)課程數(shù)量和選課記錄增長后SELECT * FROM course全量查詢會拖慢首頁加載。分頁用 MySQL 的LIMIT #{offset}, #{pageSize}配合ORDER BY id DESC保證連續(xù)翻頁時(shí)數(shù)據(jù)不重復(fù)。翻頁越深LIMIT 100000, 20這種寫法越慢MySQL 需要掃描前面 10 萬條再丟棄所以超過幾十萬行時(shí)改成游標(biāo)分頁記住上一頁最后一條記錄的id下一頁直接用WHERE id #{lastId} ORDER BY id DESC LIMIT 20讓索引直接定位。分頁查詢的offset和pageSize參數(shù)傳入 Mapper 時(shí)用#{}綁定不會注入但offset一定要在 Service 里做page 0 時(shí)重置為 0、pageSize 100 時(shí)截?cái)酁?100的限制防止惡意用超大頁碼拖垮數(shù)據(jù)庫。本文還有配套的精品資源點(diǎn)擊獲取