課設(shè)實戰(zhàn):從數(shù)據(jù)庫設(shè)計到高并發(fā)防超賣)
簡介這是一套基于JavaWeb實現(xiàn)的火車售票系統(tǒng)完整項目資料面向計算機相關(guān)專業(yè)正在準(zhǔn)備期末大作業(yè)、課程設(shè)計的學(xué)生以及需要項目實戰(zhàn)練習(xí)的學(xué)習(xí)者。項目經(jīng)導(dǎo)師指導(dǎo)并認(rèn)可通過評審分達(dá)99分可作為課程設(shè)計或畢業(yè)設(shè)計的參考方案。壓縮包共1290個文件約33.99MB涵蓋92個Java源文件、27個JSP頁面、358個HTML頁面、59個CSS樣式、196個JavaScript腳本以及數(shù)據(jù)庫SQL腳本、jar依賴包和圖片資源等前后端結(jié)構(gòu)完整便于直接部署運行與二次開發(fā)。資源中附有數(shù)據(jù)庫文件與文檔說明可幫助讀者理解購票、車次查詢、訂單管理等核心業(yè)務(wù)模塊的實現(xiàn)思路掌握Servlet、DAO分層與數(shù)據(jù)庫連接等JavaWeb關(guān)鍵技術(shù)。目前已有238人學(xué)習(xí)下載適合需要快速搭建項目框架、對照調(diào)試與查漏補缺的初學(xué)者和進(jìn)階學(xué)習(xí)者參考使用。1. 火車售票系統(tǒng)到底在考什么從選座到訂單一套 JavaWeb 項目怎么把課設(shè)分?jǐn)?shù)拉開很多人做期末大作業(yè)第一反應(yīng)是去搜「javaweb項目完整案例mysql」找到一個能跑的 demo 就交差。但火車售票系統(tǒng)這個題目老師一眼就能看出你是真做了還是套模板——因為它天然帶三個硬骨頭車次與余票的并發(fā)扣減、訂單狀態(tài)機、以及座位與車廂的映射關(guān)系。這三個點只要有一個沒想清楚答辯時被追問兩句就露餡。這套「基于 JavaWeb 的火車售票系統(tǒng)」本質(zhì)是一個典型的 B/S 架構(gòu) CRUD 加強版前端 JSP 或 HTML 渲染車次列表和下單頁后端 Servlet 或 Spring Boot 處理查詢、下單、退票MySQL 存車次、座位、訂單、用戶四類核心數(shù)據(jù)。它適合兩類人一是課程設(shè)計想拿良以上的本科生二是轉(zhuǎn)行練手需要一份能講清楚業(yè)務(wù)的項目。下面我按「先立模型、再搭環(huán)境、后填業(yè)務(wù)、最后避坑」的順序把能直接抄作業(yè)的路徑拆開講。2. 先把數(shù)據(jù)模型立住車次、座位、訂單三張表怎么設(shè)計才不返工2.1 為什么表設(shè)計錯了后面全是補丁火車售票和普通電商最大的區(qū)別是「庫存」不是簡單一個數(shù)字。同一趟車北京到上海和濟(jì)南到南京賣的是同一批座位但區(qū)間不同。如果你只在車次表里放一個remain_ticket字段那濟(jì)南上車的乘客會把北京到濟(jì)南的座位也占掉超賣是必然的。常見做法是把庫存拆到「車次 座位 區(qū)間」粒度但課設(shè)規(guī)模沒必要上那么復(fù)雜我一般用「車次 日期 座位類型」做庫存行再在訂單里記錄上車站和下車站下單時用 SQL 條件判斷區(qū)間是否沖突。這樣既不會超賣代碼量也可控。核心四張表train車次基礎(chǔ)信息、train_schedule車次每日開行與余票、seat座位物理信息可選、orders訂單。下面是我實際用過的建表語句字段名和類型可以直接抄。-- 車次基礎(chǔ)表一趟車固定不變的信息 CREATE TABLE train ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL COMMENT 車次號如G1024, start_station VARCHAR(32) NOT NULL, end_station VARCHAR(32) NOT NULL, depart_time TIME NOT NULL, arrive_time TIME NOT NULL, UNIQUE KEY uk_train_no (train_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 車次每日開行表余票按日期和座位類型拆 CREATE TABLE train_schedule ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, run_date DATE NOT NULL, seat_type TINYINT NOT NULL COMMENT 1商務(wù) 2一等 3二等, price DECIMAL(10,2) NOT NULL, total_seat INT NOT NULL, sold_seat INT NOT NULL DEFAULT 0, UNIQUE KEY uk_train_date_type (train_id, run_date, seat_type), KEY idx_run_date (run_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 訂單表記錄區(qū)間退票和改簽都靠它 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, schedule_id INT NOT NULL, from_station VARCHAR(32) NOT NULL, to_station VARCHAR(32) NOT NULL, seat_type TINYINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已退票 3已改簽, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_schedule (schedule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;邏輯說明train_schedule用(train_id, run_date, seat_type)做唯一鍵保證同一天同一車次同一座位類型只有一行庫存扣減時直接UPDATE ... SET sold_seat sold_seat 1 WHERE sold_seat total_seat靠數(shù)據(jù)庫行鎖保證不超賣。orders里存from_station和to_station是為了退票時能精確釋放對應(yīng)區(qū)間的庫存——如果你的課設(shè)不做區(qū)間庫存這兩個字段也建議留著答辯時能講出「為后續(xù)擴展預(yù)留」。參數(shù)說明seat_type用 TINYINT 而不是 VARCHAR省空間且方便做枚舉映射price用 DECIMAL 不用 FLOAT金額計算不會出現(xiàn) 0.10.2 的玄學(xué)問題status用數(shù)字而不是字符串索引效率更高。字符集統(tǒng)一 utf8mb4避免車站名里有生僻字存不進(jìn)去。2.2 用 IDEA 跑通第一個查詢接口環(huán)境這塊熱搜里「idea運行javaweb項目配置」是高頻問題。我一般用 Maven 建項目Tomcat 9 JDK 8 或 11MySQL 5.7 或 8.0 都行。關(guān)鍵是把數(shù)據(jù)庫連接池配好別用 DriverManager 每次新建連接課設(shè)答辯時老師問「連接池用的什么」你答不上來就尷尬。常見做法是 Druid 或 HikariCP下面給一個 Druid 的最小配置。!-- pom.xml 關(guān)鍵依賴 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency# druid.properties driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/train_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai usernameroot password你的密碼 initialSize5 maxActive20 maxWait3000邏輯說明serverTimezoneAsia/Shanghai不加的話 MySQL 8 會報時區(qū)錯誤這是新手最常見的翻車點。maxActive20對課設(shè)足夠太大反而浪費。連接池初始化放在ServletContextListener里項目啟動就加載別每次請求都讀配置文件。參數(shù)說明initialSize是啟動時創(chuàng)建的連接數(shù)maxActive是最大連接數(shù)maxWait是獲取連接的超時毫秒數(shù)。這三個值根據(jù)你機器配置調(diào)課設(shè)演示并發(fā)不高5/20/3000 是穩(wěn)妥值。3. 下單和退票的業(yè)務(wù)閉環(huán)Servlet 里怎么寫才不會被追問到卡殼3.1 下單接口的完整鏈路與事務(wù)邊界下單是整個系統(tǒng)最容易出 bug 的地方。用戶點「提交訂單」后端要做四件事校驗余票、扣減庫存、生成訂單、返回結(jié)果。這四步必須在一個事務(wù)里否則扣了庫存沒生成訂單或者生成了訂單沒扣庫存都是臟數(shù)據(jù)。我一般用 JDBC 手動控制事務(wù)因為課設(shè)里用 Spring 聲明式事務(wù)反而讓老師覺得你沒理解原理。public String createOrder(int userId, int scheduleId, String from, String to, int seatType) throws SQLException { Connection conn null; try { conn DruidUtil.getConnection(); conn.setAutoCommit(false); // 開啟事務(wù) // 1. 扣減庫存條件里帶 sold_seat total_seat 防止超賣 String deductSql UPDATE train_schedule SET sold_seat sold_seat 1 WHERE id ? AND sold_seat total_seat; PreparedStatement ps conn.prepareStatement(deductSql); ps.setInt(1, scheduleId); int affected ps.executeUpdate(); if (affected 0) { conn.rollback(); return 余票不足; } // 2. 生成訂單 String orderNo T System.currentTimeMillis() userId; String insertSql INSERT INTO orders(order_no,user_id,schedule_id,from_station,to_station,seat_type,amount,status) VALUES(?,?,?,?,?,?,?,0); PreparedStatement ps2 conn.prepareStatement(insertSql); ps2.setString(1, orderNo); ps2.setInt(2, userId); ps2.setInt(3, scheduleId); ps2.setString(4, from); ps2.setString(5, to); ps2.setInt(6, seatType); ps2.setBigDecimal(7, new BigDecimal(553.00)); // 實際應(yīng)從 schedule 查價格 ps2.executeUpdate(); conn.commit(); return orderNo; } catch (Exception e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }邏輯說明UPDATE ... WHERE sold_seat total_seat這一句是防超賣的核心數(shù)據(jù)庫會對這一行加排他鎖并發(fā)請求會串行執(zhí)行第二個請求發(fā)現(xiàn)sold_seat已經(jīng)等于total_seat就更新 0 行直接回滾。這比先SELECT再UPDATE可靠得多后者在并發(fā)下必然超賣。訂單號用時間戳加用戶 ID課設(shè)夠用生產(chǎn)環(huán)境要用雪花算法。參數(shù)說明setAutoCommit(false)開啟事務(wù)后所有操作要么全成功要么全回滾。conn.rollback()在庫存不足和異常時都要調(diào)用。finally里恢復(fù)自動提交并關(guān)閉連接否則連接池里的連接會一直掛著事務(wù)。3.2 退票與狀態(tài)流轉(zhuǎn)別讓訂單狀態(tài)變成黑匣子退票不是簡單把status改成 2還要把庫存加回去。而且只有「已支付」的訂單能退「待支付」的應(yīng)該走取消而不是退票。我一般把狀態(tài)流轉(zhuǎn)寫成枚舉避免代碼里到處是魔法數(shù)字。public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), REFUNDED(2, 已退票), CHANGED(3, 已改簽); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }退票的 SQL 要同時更新訂單和庫存同樣放在一個事務(wù)里-- 退票先查訂單狀態(tài)再更新 UPDATE orders SET status 2 WHERE id ? AND status 1; -- 如果 affected 0說明退票成功再釋放庫存 UPDATE train_schedule SET sold_seat sold_seat - 1 WHERE id ? AND sold_seat 0;邏輯說明WHERE status 1保證只有已支付訂單能退重復(fù)退票第二次會更新 0 行天然冪等。庫存釋放加sold_seat 0防止減成負(fù)數(shù)。這兩句必須在同一事務(wù)否則退票成功但庫存沒釋放余票就少了。參數(shù)說明訂單 ID 和 schedule ID 從訂單記錄里取不要相信前端傳的 schedule ID否則用戶改個參數(shù)就能退別人訂單的庫存。4. 避坑與排查課設(shè)答辯前一定要自己走一遍的五個坑4.1 中文亂碼從 JSP 到數(shù)據(jù)庫全鏈路排查現(xiàn)象車次列表里車站名顯示成問號或亂碼。原因JSP 頁面、Servlet 響應(yīng)、數(shù)據(jù)庫連接、表字符集四處只要有一處不是 UTF-8 就會亂。解決JSP 頂部加% page contentTypetext/html;charsetUTF-8 %Servlet 里response.setContentType(text/html;charsetUTF-8)連接 URL 帶characterEncodingutf8建表用utf8mb4。四處對齊后基本不會再亂。4.2 余票顯示不對sold_seat 和實際訂單數(shù)對不上現(xiàn)象頁面顯示余票 10但實際下單 8 次就提示沒票了。原因測試時手動改過數(shù)據(jù)庫或者退票時庫存沒加回去。解決寫一個對賬 SQLSELECT schedule_id, COUNT(*) FROM orders WHERE status 1 GROUP BY schedule_id和sold_seat比對不一致就說明有臟數(shù)據(jù)。課設(shè)演示前跑一遍心里有底。4.3 連接池耗盡頁面卡死最后報 500現(xiàn)象連續(xù)點幾次下單后整個系統(tǒng)無響應(yīng)。原因Connection 沒關(guān)閉或者事務(wù)沒提交導(dǎo)致連接被占用。解決所有數(shù)據(jù)庫操作放 try-finallyfinally里關(guān)閉連接事務(wù)里異常必須 rollback。用 Druid 的話可以在監(jiān)控頁面看活躍連接數(shù)超過 maxActive 就是泄漏了。4.4 日期類型轉(zhuǎn)換報錯String 和 Date 互轉(zhuǎn)翻車現(xiàn)象查詢某天車次時后臺拋java.text.ParseException。原因前端傳的是2024-01-01字符串后端直接往 SQL 里拼或者 SimpleDateFormat 格式不匹配。解決統(tǒng)一用java.sql.Date.valueOf(2024-01-01)轉(zhuǎn)換或者用 PreparedStatement 的setDate方法。別用字符串拼接 SQL既有時區(qū)問題又有注入風(fēng)險。4.5 答辯被問「并發(fā)怎么處理」答不上來現(xiàn)象老師問「兩個人同時買最后一張票會怎樣」。原因只寫了業(yè)務(wù)代碼沒想過并發(fā)。解決把 3.1 里那句UPDATE ... WHERE sold_seat total_seat講清楚說明數(shù)據(jù)庫行鎖保證了原子性再補一句「如果壓力大可以加 Redis 預(yù)扣減但課設(shè)規(guī)模數(shù)據(jù)庫足夠」。這樣答既展示了原理又體現(xiàn)了邊界意識。5. 讓課設(shè)多拿五分的兩個進(jìn)階技巧區(qū)間庫存與訂單超時區(qū)間庫存是火車售票和普通商城最大的區(qū)別也是答辯時最能體現(xiàn)你思考深度的點。前面表設(shè)計里orders存了from_station和to_station但庫存還是按整個車次扣的。如果你想再進(jìn)一步可以把train_schedule拆成「車次 日期 區(qū)間」的庫存行下單時只扣對應(yīng)區(qū)間的庫存。實現(xiàn)方式是在查詢余票時用WHERE from_station ? AND to_station ?扣減時同樣帶區(qū)間條件。代價是數(shù)據(jù)量變大但課設(shè)車次少完全撐得住。我一般會在答辯時主動提這個擴展點老師會覺得你想過真實場景。訂單超時取消是另一個加分項。待支付訂單如果一直不支付庫存就被占著。常見做法是下單時記create_time起一個定時任務(wù)每分鐘掃一次把超過 15 分鐘且status 0的訂單改成取消并釋放庫存。用ScheduledExecutorService就能實現(xiàn)不需要引入 Quartz 這種重框架。// 簡易超時取消任務(wù)項目啟動時調(diào)用 ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { String sql UPDATE orders SET status 4 WHERE status 0 AND create_time DATE_SUB(NOW(), INTERVAL 15 MINUTE); // 執(zhí)行更新后再批量釋放對應(yīng)庫存此處省略具體 JDBC 代碼 }, 0, 1, TimeUnit.MINUTES);邏輯說明status 4表示已取消和退票區(qū)分開。定時任務(wù)每分鐘跑一次對課設(shè)來說頻率足夠。釋放庫存時要根據(jù)訂單里的schedule_id逐條減回去注意加sold_seat 0保護(hù)。這個功能代碼量不大但演示時能講出「庫存時效性」這個概念比單純 CRUD 高一個層次。參數(shù)說明INTERVAL 15 MINUTE是超時時間可以按需改成 30 分鐘。scheduleAtFixedRate的第二個參數(shù)是首次延遲第三個是間隔。注意定時任務(wù)里的數(shù)據(jù)庫操作也要關(guān)連接否則跑一晚上連接池就滿了。最后說個血淚經(jīng)驗課設(shè)文檔說明別最后一天才寫。我一般邊寫代碼邊把關(guān)鍵 SQL 和接口說明記在 Markdown 里最后直接導(dǎo)出成 Word。文檔里重點寫「為什么這么設(shè)計」而不是「這個類有什么方法」老師看的是你的思路不是 API 手冊。數(shù)據(jù)庫腳本、源碼、文檔三樣對齊演示前自己從注冊到下單到退票完整走一遍基本就穩(wěn)了。希望幫到你。本文還有配套的精品資源點擊獲取