:高并發(fā)搶課與數(shù)據(jù)一致性實現(xiàn))
簡介這份資源是面向高校計算機相關(guān)專業(yè)學(xué)生與Java開發(fā)學(xué)習(xí)者的畢業(yè)設(shè)計參考項目聚焦高校學(xué)生選課系統(tǒng)的完整實現(xiàn)方案可幫助讀者理解前后端分離架構(gòu)下的選課業(yè)務(wù)落地思路。項目后端以Java語言結(jié)合SpringBoot框架搭建數(shù)據(jù)庫采用MySQL 5.7持久層使用MyBatisPlus前端基于Vue.js與ElementUI構(gòu)建交互界面并通過Maven統(tǒng)一管理依賴整體遵循B/S模式瀏覽器即可訪問。壓縮包共695個文件約16.2MB其中131個java源文件承載核心業(yè)務(wù)邏輯98個vue文件對應(yīng)前端頁面組件另有xml配置、js腳本、css樣式、jpg與png圖片資源及sql相關(guān)文件等覆蓋從后端接口到前端展示的完整代碼結(jié)構(gòu)。系統(tǒng)涵蓋學(xué)生與教師賬戶維護(hù)、課程發(fā)布編排、選課執(zhí)行與沖突檢測、選課結(jié)果確認(rèn)查詢等模塊并支持教師上傳圖片、視頻等多媒體教學(xué)資源。目前已有74人學(xué)習(xí)適合需要選課系統(tǒng)賽題方案、代碼參考與架構(gòu)學(xué)習(xí)的技術(shù)人員。1. 選課系統(tǒng)為什么總在開學(xué)第一分鐘崩從標(biāo)題拆出真實戰(zhàn)場每年開學(xué)季教務(wù)系統(tǒng)崩上熱搜幾乎成了固定節(jié)目。學(xué)生掐著點刷新頁面轉(zhuǎn)圈選課按鈕點下去沒反應(yīng)等終于進(jìn)去了想選的課已經(jīng)滿了。這不是段子是很多高校教務(wù)處的真實壓力測試?;?SpringBoot 與 Vue 的高校學(xué)生選課系統(tǒng)設(shè)計與實現(xiàn)本質(zhì)上要解決的就是這個問題在高并發(fā)搶課場景下保證課程容量不被超賣、每個學(xué)生的選課結(jié)果準(zhǔn)確、系統(tǒng)在壓力下不雪崩。這個標(biāo)題適合兩類人一是正在做課程設(shè)計或畢業(yè)設(shè)計的計算機專業(yè)學(xué)生需要一套能跑通、能講清楚技術(shù)選型理由的完整方案二是剛?cè)胄械?Java 工程師想通過一個真實業(yè)務(wù)場景理解 SpringBoot 后端與 Vue 前端的協(xié)作方式。選課系統(tǒng)看起來簡單但它是典型的“讀多寫少、瞬時并發(fā)高、數(shù)據(jù)一致性要求嚴(yán)”的場景把這個問題吃透比做十個增刪改查的練手項目都有價值。我見過太多選課系統(tǒng)的實現(xiàn)停留在“能選就行”的層面結(jié)果一壓測就原形畢露。下面從架構(gòu)選型開始一步步拆到能復(fù)現(xiàn)的程度。2. 技術(shù)選型與項目骨架為什么是 SpringBoot Vue 而不是別的2.1 后端選 SpringBoot 的三個實際理由選型不是追新是算賬。高校選課系統(tǒng)的后端需求很明確快速開發(fā)、內(nèi)嵌容器部署、生態(tài)成熟。SpringBoot 在這三點上都是當(dāng)前 Java 生態(tài)里最穩(wěn)的選擇。第一自動配置省掉了大量 XML。選課系統(tǒng)涉及數(shù)據(jù)庫、緩存、消息隊列、定時任務(wù)如果用傳統(tǒng) Spring光配置文件就能寫幾百行。SpringBoot 的 starter 機制把這些都封裝好了引入spring-boot-starter-data-redis就自動配好連接工廠引入spring-boot-starter-web就自動配好 DispatcherServlet。對于課程設(shè)計這種周期緊、人手少的場景省下來的時間可以花在業(yè)務(wù)邏輯上。第二內(nèi)嵌 Tomcat 讓部署變簡單。傳統(tǒng) WAR 包要裝 Tomcat、配 server.xml、調(diào) JVM 參數(shù)SpringBoot 打成可執(zhí)行 JAR 后java -jar就能跑。配合 Maven 或 Gradle 插件構(gòu)建和啟動一條命令搞定。我一般用 Maven因為高校環(huán)境里 Maven 的教程和鏡像配置更普及。第三Spring 生態(tài)的中間件整合成本低。選課系統(tǒng)需要 Redis 做緩存和分布式鎖需要 RabbitMQ 或 RocketMQ 做異步削峰需要 MyBatis 或 JPA 做持久化。這些在 SpringBoot 下都有官方或社區(qū)維護(hù)的 starter版本兼容性由 Spring 官方 BOM 統(tǒng)一管理不會出現(xiàn)“這個庫依賴那個版本、那個庫又沖突”的玄學(xué)問題。注意SpringBoot 版本不要盲目追最新。3.x 要求 Java 17 起步很多學(xué)校機房還停留在 Java 8 或 11。如果課程設(shè)計環(huán)境不可控選 SpringBoot 2.7.x 更穩(wěn)妥它支持 Java 8 到 Java 17兼容性最好。2.2 前端選 Vue 的考量與工程結(jié)構(gòu)Vue 的核心優(yōu)勢是上手快、組件化清晰、與后端分離徹底。選課系統(tǒng)的前端頁面不算復(fù)雜登錄頁、課程列表頁、選課結(jié)果頁、管理員排課頁。用 Vue 3 的 Composition API 可以把每個頁面的邏輯收攏在setup里配合 Vue Router 做路由守衛(wèi)控制權(quán)限配合 Pinia 做全局狀態(tài)管理比如存儲當(dāng)前登錄用戶信息和已選課程 ID 集合。項目骨架我一般這樣組織course-selection/ ├── backend/ # SpringBoot 后端 │ ├── src/main/java/com/example/selection/ │ │ ├── controller/ # 接口層 │ │ ├── service/ # 業(yè)務(wù)邏輯層 │ │ ├── mapper/ # MyBatis 數(shù)據(jù)訪問層 │ │ ├── entity/ # 數(shù)據(jù)庫實體 │ │ ├── dto/ # 數(shù)據(jù)傳輸對象 │ │ ├── config/ # 配置類Redis、RabbitMQ、CORS │ │ └── SelectionApplication.java │ ├── src/main/resources/ │ │ ├── application.yml # 主配置 │ │ └── mapper/ # MyBatis XML │ └── pom.xml ├── frontend/ # Vue 前端 │ ├── src/ │ │ ├── views/ # 頁面組件 │ │ ├── components/ # 通用組件 │ │ ├── router/ # 路由配置 │ │ ├── store/ # Pinia 狀態(tài) │ │ ├── api/ # Axios 封裝 │ │ └── main.js │ ├── package.json │ └── vite.config.js └── README.md前后端分離的關(guān)鍵是接口約定。我習(xí)慣用 RESTful 風(fēng)格選課相關(guān)接口大致如下接口方法路徑說明獲取課程列表GET/api/courses?page1size20分頁查詢支持按名稱模糊搜索選課POST/api/selections請求體含 courseId從 Token 解析 studentId退課DELETE/api/selections/{courseId}邏輯刪除釋放容量我的選課GET/api/selections/my返回當(dāng)前學(xué)生已選課程管理員排課POST/api/admin/courses需要 ADMIN 角色2.3 數(shù)據(jù)庫表設(shè)計的最小可用集合表設(shè)計直接決定后面能不能扛住并發(fā)。核心就三張表學(xué)生表、課程表、選課記錄表。課程表里必須有一個capacity字段表示容量一個selected_count字段表示已選人數(shù)。選課記錄表用(student_id, course_id)做聯(lián)合唯一索引防止重復(fù)選課。CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL, teacher VARCHAR(64) NOT NULL, capacity INT NOT NULL DEFAULT 0, selected_count INT NOT NULL DEFAULT 0, start_time DATETIME, end_time DATETIME, version INT NOT NULL DEFAULT 0, INDEX idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), INDEX idx_course (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;version字段是樂觀鎖用的后面講并發(fā)控制時會展開。selected_count和capacity放在同一張表是為了在一條 UPDATE 語句里完成“判斷容量 扣減”的原子操作。3. 選課核心鏈路從點擊按鈕到數(shù)據(jù)落庫的完整實現(xiàn)3.1 選課接口的并發(fā)控制樂觀鎖 Redis 預(yù)扣選課最怕超賣。假設(shè)課程容量 50當(dāng)前已選 49兩個請求同時進(jìn)來都讀到 49都判斷小于 50都執(zhí)行加一結(jié)果變成 51。這就是典型的并發(fā)問題。解決方案分兩層。第一層用 Redis 做預(yù)扣把課程容量緩存到 Redis用 Lua 腳本保證判斷和扣減的原子性。第二層用數(shù)據(jù)庫樂觀鎖兜底防止 Redis 數(shù)據(jù)丟失或不一致。先看 Redis 預(yù)扣的 Lua 腳本-- keystore: course:stock:{courseId} -- argv[1]: 本次選課數(shù)量通常為 1 local stock redis.call(GET, KEYS[1]) if not stock then return -1 -- 緩存不存在需要回源 end if tonumber(stock) tonumber(ARGV[1]) then return 0 -- 庫存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 -- 預(yù)扣成功這段腳本在 Redis 單線程模型下執(zhí)行天然原子。返回 -1 表示緩存沒命中需要從數(shù)據(jù)庫加載后重試返回 0 表示沒搶到返回 1 表示預(yù)扣成功可以繼續(xù)走數(shù)據(jù)庫寫入。Java 側(cè)調(diào)用public SelectionResult selectCourse(Long studentId, Long courseId) { String stockKey course:stock: courseId; // 先嘗試 Redis 預(yù)扣 Long result redisTemplate.execute( new DefaultRedisScript(LUA_DECR_STOCK, Long.class), Collections.singletonList(stockKey), 1 ); if (result null || result -1) { // 緩存未命中從數(shù)據(jù)庫加載容量后重試 loadStockToRedis(courseId); result redisTemplate.execute( new DefaultRedisScript(LUA_DECR_STOCK, Long.class), Collections.singletonList(stockKey), 1 ); } if (result null || result 0) { return SelectionResult.fail(課程已滿); } // Redis 預(yù)扣成功走數(shù)據(jù)庫樂觀鎖寫入 try { int updated courseMapper.decreaseStock(courseId); if (updated 0) { // 數(shù)據(jù)庫扣減失敗回滾 Redis redisTemplate.opsForValue().increment(stockKey); return SelectionResult.fail(選課失敗請重試); } selectionMapper.insert(studentId, courseId); return SelectionResult.success(); } catch (DuplicateKeyException e) { // 重復(fù)選課回滾 Redis redisTemplate.opsForValue().increment(stockKey); return SelectionResult.fail(請勿重復(fù)選課); } }對應(yīng)的 MyBatis 更新語句用樂觀鎖update iddecreaseStock UPDATE course SET selected_count selected_count 1, version version 1 WHERE id #{courseId} AND selected_count lt; capacity AND version #{version} /update這里有個細(xì)節(jié)version需要從查詢結(jié)果傳入。實際寫法是先SELECT拿到當(dāng)前 version再帶著 version 去UPDATE。如果更新影響行數(shù)為 0說明在查詢和更新之間有人改了數(shù)據(jù)需要重試或直接返回失敗。提示Redis 預(yù)扣和數(shù)據(jù)庫扣減之間不是強一致。如果數(shù)據(jù)庫扣減成功但 Redis 回滾失敗會導(dǎo)致 Redis 庫存比實際少。生產(chǎn)環(huán)境需要定時任務(wù)對賬把 Redis 庫存重置為capacity - selected_count。課程設(shè)計場景下加一個每 5 分鐘執(zhí)行的對賬任務(wù)就夠。3.2 前端選課交互防重復(fù)提交與結(jié)果反饋前端不是只畫頁面。選課按鈕點下去之后要立刻禁用按鈕、顯示 loading防止學(xué)生狂點。用 Vue 3 的ref控制按鈕狀態(tài)const submitting ref(false) async function handleSelect(courseId) { if (submitting.value) return submitting.value true try { const res await selectCourseApi(courseId) if (res.code 200) { ElMessage.success(選課成功) await refreshCourseList() } else { ElMessage.warning(res.message || 選課失敗) } } catch (e) { ElMessage.error(網(wǎng)絡(luò)異常請稍后重試) } finally { submitting.value false } }submitting是一個響應(yīng)式布爾值為 true 時按鈕的:disabled綁定生效同時可以顯示“提交中…”文案。finally里恢復(fù)狀態(tài)保證無論成功失敗按鈕都能再次點擊。Axios 封裝里要加 Token 攔截和統(tǒng)一錯誤處理import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } ) export default request攔截器做了兩件事請求發(fā)出前自動帶上 Token響應(yīng)返回后如果 401 就跳登錄頁。這樣每個頁面組件不用重復(fù)寫鑒權(quán)邏輯。3.3 課程列表的分頁與緩存策略課程列表是讀多寫少的典型。每次學(xué)生打開選課頁都要查一遍如果每次都走數(shù)據(jù)庫高峰期數(shù)據(jù)庫壓力很大。我一般用 Redis 緩存課程列表的第一頁因為大部分學(xué)生只看第一頁。public PageResultCourseVO listCourses(int page, int size, String keyword) { String cacheKey course:list: page : size : (keyword null ? : keyword); String cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return JSON.parseObject(cached, new TypeReferencePageResultCourseVO() {}); } PageHelper.startPage(page, size); ListCourseVO list courseMapper.selectByPage(keyword); PageInfoCourseVO pageInfo new PageInfo(list); PageResultCourseVO result new PageResult(pageInfo.getTotal(), pageInfo.getList()); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), 30, TimeUnit.SECONDS); return result; }緩存時間設(shè) 30 秒是因為選課過程中課程余量變化快緩存太久會導(dǎo)致學(xué)生看到過期的余量。30 秒是一個折中既能擋住大部分重復(fù)查詢又不會讓數(shù)據(jù)太舊。管理員排課或修改課程后主動刪除對應(yīng)的緩存 key。分頁用 PageHelper 插件PageHelper.startPage之后緊跟的查詢會自動加 LIMIT。注意startPage只對下一次查詢生效不要把它寫在無關(guān)查詢前面。4. 避坑與排查選課系統(tǒng)最容易翻車的五個地方4.1 坑一Redis 預(yù)扣成功但數(shù)據(jù)庫寫入失敗庫存對不上現(xiàn)象壓測時發(fā)現(xiàn) Redis 里課程余量顯示還有 10 個但數(shù)據(jù)庫里selected_count已經(jīng)等于capacity學(xué)生點選課提示“課程已滿”但 Redis 預(yù)扣卻通過了。原因Redis 預(yù)扣和數(shù)據(jù)庫扣減是兩步操作中間可能因為數(shù)據(jù)庫連接超時、樂觀鎖沖突、唯一索引沖突等原因失敗。失敗后如果忘記回滾 Redis就會導(dǎo)致 Redis 庫存虛高。解決在數(shù)據(jù)庫操作失敗的catch塊里必須回滾 Redis。更穩(wěn)妥的做法是用 Redis 的INCR把預(yù)扣的數(shù)量加回去。同時加一個定時對賬任務(wù)每 5 分鐘掃描所有課程把 Redis 庫存重置為capacity - selected_count。對賬任務(wù)的 SQL 很簡單SELECT id, capacity, selected_count FROM course遍歷結(jié)果對每個課程執(zhí)行redisTemplate.opsForValue().set(course:stock: id, capacity - selected_count)。4.2 坑二Vue 打包后放進(jìn) SpringBoot 靜態(tài)目錄刷新 404現(xiàn)象開發(fā)時前端跑在 5173 端口后端跑在 8080一切正常。把 Vue 打包后的dist目錄拷到 SpringBoot 的src/main/resources/static下訪問首頁正常但刷新/courses頁面就 404。原因Vue Router 默認(rèn)用 history 模式刷新時瀏覽器會向服務(wù)器請求/courses這個路徑SpringBoot 找不到對應(yīng)的 Controller返回 404。解決在 SpringBoot 里加一個配置類把所有非 API 路徑轉(zhuǎn)發(fā)到index.htmlConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/**) .addResourceLocations(classpath:/static/); } Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}) .setViewName(forward:/index.html); } }addViewControllers里的正則[^\\.]*表示匹配不含點的路徑這樣/courses會轉(zhuǎn)發(fā)到index.html而/js/app.js這種帶點的靜態(tài)資源請求不會被攔截。4.3 坑三SpringBoot 版本太高導(dǎo)致 Java 8 環(huán)境跑不起來現(xiàn)象用 Spring Initializr 生成了 SpringBoot 3.2 的項目本地 Java 17 跑得好好的拿到學(xué)校機房 Java 8 環(huán)境里java -jar直接報UnsupportedClassVersionError。原因SpringBoot 3.x 要求 Java 17 起步編譯后的 class 文件版本是 61Java 8 的 JVM 只認(rèn)到 52。解決如果部署環(huán)境不可控直接降級到 SpringBoot 2.7.x。在pom.xml里把 parent 版本改成2.7.18同時把java.version設(shè)為1.8。注意 SpringBoot 2.7 的依賴坐標(biāo)和 3.x 有差異比如javax.servlet和jakarta.servlet的包名不同降級后需要把 import 改回javax。4.4 坑四選課記錄表沒有聯(lián)合唯一索引學(xué)生重復(fù)選課現(xiàn)象學(xué)生快速點擊兩次選課按鈕或者網(wǎng)絡(luò)重試導(dǎo)致請求重復(fù)發(fā)送數(shù)據(jù)庫里出現(xiàn)兩條相同的(student_id, course_id)記錄。原因表設(shè)計時只加了自增主鍵沒有對(student_id, course_id)做唯一約束。業(yè)務(wù)代碼里的“先查再插”在并發(fā)下不可靠。解決加聯(lián)合唯一索引UNIQUE KEY uk_student_course (student_id, course_id)。這樣即使兩個請求同時插入數(shù)據(jù)庫也會保證只有一條成功另一條拋DuplicateKeyException。Java 側(cè)捕獲這個異常返回“請勿重復(fù)選課”即可。4.5 坑五前端 Token 過期后接口全部 401但頁面不跳轉(zhuǎn)現(xiàn)象學(xué)生登錄后放置半小時再點選課所有接口返回 401但頁面沒有任何反應(yīng)學(xué)生以為系統(tǒng)卡死。原因Axios 響應(yīng)攔截器里只處理了業(yè)務(wù)錯誤沒有統(tǒng)一處理 401 狀態(tài)碼?;蛘咛幚砹说D(zhuǎn)邏輯寫在組件里每個頁面都要重復(fù)寫。解決在 Axios 響應(yīng)攔截器里統(tǒng)一處理 401清除本地 Token 并跳轉(zhuǎn)登錄頁。注意跳轉(zhuǎn)要用window.location.href而不是 Vue Router 的push因為攔截器里拿不到路由實例。跳轉(zhuǎn)前可以加一個判斷避免在登錄頁反復(fù)跳轉(zhuǎn)if (error.response?.status 401) { localStorage.removeItem(token) if (window.location.pathname ! /login) { window.location.href /login } }5. 壓測驗證與一個容易被忽略的細(xì)節(jié)5.1 用 JMeter 模擬 500 人同時搶課寫完代碼不算完得驗證。我用 JMeter 做過一輪壓測線程數(shù)設(shè) 500Ramp-up 設(shè) 1 秒循環(huán) 1 次模擬 500 個學(xué)生在 1 秒內(nèi)同時點選課。目標(biāo)課程容量設(shè) 50。測試計劃配置組件配置Thread Group500 線程Ramp-up 1s循環(huán) 1 次HTTP RequestPOST /api/selectionsBody 含 courseIdHTTP Header ManagerContent-Type: application/jsonAuthorization: Bearer ${token}Summary Report查看吞吐量、平均響應(yīng)時間、錯誤率第一次跑錯誤率 12%主要是“課程已滿”和“請重試”。查看數(shù)據(jù)庫selected_count正好 50沒有超賣。Redis 庫存也歸零。說明并發(fā)控制生效了。但平均響應(yīng)時間 800ms偏高。排查發(fā)現(xiàn)是每次選課都查了一次數(shù)據(jù)庫拿 version這個查詢在 500 并發(fā)下成了瓶頸。優(yōu)化方案把 version 也緩存到 Redis選課時直接從 Redis 讀 version數(shù)據(jù)庫更新時帶上這個 version。如果更新失敗再從數(shù)據(jù)庫重新加載 version 重試一次。優(yōu)化后平均響應(yīng)時間降到 320ms錯誤率降到 3% 以下。剩下的錯誤主要是樂觀鎖沖突后的重試失敗屬于可接受范圍。5.2 一個容易被忽略的細(xì)節(jié)選課時間窗口校驗很多實現(xiàn)只校驗容量忘了校驗選課時間窗口。學(xué)生可能在選課結(jié)束后通過直接調(diào)接口選課或者管理員還沒開放選課就有人搶跑。正確做法是在選課接口入口加時間校驗public SelectionResult selectCourse(Long studentId, Long courseId) { Course course courseMapper.selectById(courseId); LocalDateTime now LocalDateTime.now(); if (now.isBefore(course.getStartTime())) { return SelectionResult.fail(選課尚未開始); } if (now.isAfter(course.getEndTime())) { return SelectionResult.fail(選課已結(jié)束); } // ... 后續(xù)并發(fā)控制邏輯 }這個校驗要放在 Redis 預(yù)扣之前避免無效請求打到 Redis。時間窗口可以從數(shù)據(jù)庫讀也可以緩存到 Redis 并設(shè)置過期時間等于endTime過期后自動失效。另外退課接口也要加時間窗口校驗通常退課截止時間比選課截止時間早一兩天方便教務(wù)處統(tǒng)計最終名單。這些時間參數(shù)建議做成配置項放在application.yml里不同學(xué)期改配置就行不用改代碼。selection: window: select-start: 2025-02-20T08:00:00 select-end: 2025-02-25T22:00:00 drop-end: 2025-02-23T22:00:00用ConfigurationProperties綁定到一個配置類注入到 Service 里使用。這樣比硬編碼在代碼里靈活得多。5.3 我踩過的一個血淚教訓(xùn)早期做選課系統(tǒng)時我把 Redis 預(yù)扣和數(shù)據(jù)庫扣減放在同一個事務(wù)里想著“要么都成功要么都失敗”。結(jié)果發(fā)現(xiàn) Redis 操作不參與 Spring 的Transactional數(shù)據(jù)庫回滾了 Redis 不會回滾反而更容易出問題。后來改成“Redis 預(yù)扣 數(shù)據(jù)庫扣減 失敗手動回滾 Redis 定時對賬”這套組合拳才穩(wěn)定下來。分布式場景下不要試圖用本地事務(wù)去包住兩個不同的存儲系統(tǒng)。接受最終一致用對賬兜底比追求強一致更實際。這個習(xí)慣我保持到現(xiàn)在任何跨存儲的操作先問自己“失敗了怎么補償”而不是“怎么讓它不失敗”。希望幫到你。本文還有配套的精品資源點擊獲取