☰
JavaWeb火车订票系统:手写防超卖与事务控制实战
2026/10/9 12:37:43 网站建设 项目流程

简介:本资源是一套完整可运行的JavaWeb火车订票系统毕业设计项目,面向计算机专业本科生及Java初学者,聚焦Web应用开发全流程实践,涵盖用户注册登录、车次查询、余票管理、在线订票与订单管理等核心业务功能。压缩包共1289个文件,包含358个HTML页面、196个JavaScript交互脚本、190个PNG/GIF界面素材、92个Java后端逻辑类、27个JSP动态页面及1个SQL数据库脚本,辅以CSS样式、XML配置与Properties参数文件,结构清晰、模块分明,便于理解MVC分层架构与前后端协同机制。资源包大小33.99MB,代码已通过导师验收并获高分,下载解压后导入Eclipse/IDEA即可部署运行,无需额外修改。目前已有1020人学习下载,配套源码注释完整,含Controller层Servlet、Service业务实现、Dao数据访问及JUnit测试用例,特别适合课程设计参考、毕设快速启动与JavaWeb实战能力提升。

1. 为什么一个“火车订票系统”毕业设计,能帮你稳过答辩、拿下实习、甚至反向面试HR?

这不是又一个套壳的 CRUD 演示项目。当你在 IntelliJ IDEA 里双击webapp/index.jsp,输入“北京→上海”,选中 G101 次、二等座、2025-04-12,点击“提交订单”——后台真实走完用户登录校验 → 余票原子扣减 → 订单生成 → 支付状态回写 → 库存同步更新整条链路,数据库里ticket_stock表的remaining_count字段实时减 1,order_info表多出一条带唯一order_no和pay_status=0的记录,前端立刻刷新“下单成功”。整个过程没有 mock,不靠 alert 弹窗假装完成,所有逻辑压在 Tomcat + MySQL + JDBC 原生栈上跑通。它用最朴素的 JavaWeb 技术栈(Servlet + JSP + JDBC + Bootstrap),把高并发下“超卖”这个毕业设计里人人回避的黑匣子,拆成可调试、可断点、可加日志的三行 SQL 和两个 synchronized 块。适合:课程设计卡在登录跳转、毕设答辩被问“你怎么保证余票不超卖”就哑火、投实习简历被要求“贴一段你写的事务控制代码”的同学。别再抄 GitHub 上删了半截 DAO 层、连数据库连接池都没配的“完整案例”了——这个包解压即 run,连mysql-connector-java-5.1.47.jar都给你放进了WEB-INF/lib。


2. 从零部署:用最简路径跑通系统,验证源码真实性

提示:本节所有操作均基于 JDK 8u291 + Tomcat 8.5.99 + MySQL 5.7 环境。若你用的是 JDK 17 或 MySQL 8.0+,请先跳到第 4 章「避坑」再动手,否则 90% 概率卡在驱动加载或时区报错。

2.1 数据库初始化:四张表结构与关键约束必须手敲,不能只导入.sql

很多所谓“完整源码”只给一个train_ticket.sql文件,但实际执行时发现user表缺UNIQUE KEY username、order_info表没设FOREIGN KEY (user_id) REFERENCES user(id),导致后续登录校验和订单归属全乱。本项目数据库脚本经实测,必须按以下顺序执行(注意:不是直接 source,而是逐条粘贴进 MySQL 客户端):

-- 1. 创建数据库(字符集强制 utf8mb4,避免中文乱码) CREATE DATABASE IF NOT EXISTS train_ticket CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 2. 切库 USE train_ticket; -- 3. 用户表:密码字段必须为 VARCHAR(64),预留 SHA-256 加密空间 CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL, `real_name` VARCHAR(16), `phone` VARCHAR(11), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 4. 车次表:出发/到达时间用 DATETIME,不用 VARCHAR,否则排序失效 CREATE TABLE `train_info` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `train_no` VARCHAR(10) NOT NULL COMMENT 'G101, D202', `from_station` VARCHAR(20) NOT NULL, `to_station` VARCHAR(20) NOT NULL, `start_time` DATETIME NOT NULL, `arrive_time` DATETIME NOT NULL, `duration` INT NOT NULL COMMENT '总时长(分钟)' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 5. 余票表:核心!必须有联合唯一索引 (train_no, date, seat_type),且 remaining_count 允许为 0 CREATE TABLE `ticket_stock` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `train_no` VARCHAR(10) NOT NULL, `date` DATE NOT NULL, `seat_type` ENUM('business','first','second') NOT NULL DEFAULT 'second', `total_count` INT NOT NULL DEFAULT 1000, `remaining_count` INT NOT NULL DEFAULT 1000, UNIQUE KEY `uk_train_date_seat` (`train_no`, `date`, `seat_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 6. 订单表:pay_status 用 TINYINT(1),0=未支付,1=已支付,2=已取消;order_no 必须加索引 CREATE TABLE `order_info` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `user_id` INT NOT NULL, `train_no` VARCHAR(10) NOT NULL, `date` DATE NOT NULL, `seat_type` ENUM('business','first','second') NOT NULL, `price` DECIMAL(8,2) NOT NULL, `pay_status` TINYINT(1) NOT NULL DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX `idx_user_id` (`user_id`), INDEX `idx_train_date` (`train_no`, `date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

为什么必须手敲?

  • UNIQUE KEY uk_train_date_seat是防超卖的物理屏障,导入.sql文件常因编码问题丢失索引;
  • ENUM类型比VARCHAR节省空间且校验严格,避免前端传入seattype='economy'导致后端switch失败;
  • BIGINT id用于order_info,因为毕业设计演示时可能高频下单,INT溢出会导致订单号重复(血泪经验:曾有同学答辩时现场下单到第 2147483647 条,系统崩了)。

2.2 Tomcat 配置:context.xml里藏了数据库连接的关键心跳参数

很多同学把jdbc:mysql://localhost:3306/train_ticket往web.xml里一贴就以为完事,结果运行半小时后页面白屏,日志里全是Communications link failure。真相是 MySQL 默认 wait_timeout=28800 秒(8 小时),而 Tomcat 连接池长期空闲会断连。本项目在META-INF/context.xml中预置了健壮配置:

<?xml version="1.0" encoding="UTF-8"?> <Context> <Resource name="jdbc/TrainTicketDB" auth="Container" type="javax.sql.DataSource" factory="org.apache.tomcat.jdbc.pool.DataSourceFactory" driverClassName="com.mysql.jdbc.Driver" url="jdbc:mysql://localhost:3306/train_ticket?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai&amp;autoReconnect=true" username="root" password="123456" maxActive="20" minIdle="5" maxWait="10000" testOnBorrow="true" validationQuery="SELECT 1" timeBetweenEvictionRunsMillis="30000" minEvictableIdleTimeMillis="60000"/> </Context>

参数说明:

  • testOnBorrow="true"+validationQuery="SELECT 1":每次从连接池取连接前,先执行SELECT 1检查是否存活,失败则丢弃并新建;
  • timeBetweenEvictionRunsMillis="30000":每 30 秒扫描一次空闲连接,配合minEvictableIdleTimeMillis="60000"(空闲超 60 秒即回收),确保连接不老化;
  • serverTimezone=Asia/Shanghai:MySQL 8.0+ 必加,否则DATETIME字段插入时区错乱(如存入2025-04-12 08:00:00,查出来变2025-04-11 23:00:00)。

2.3 IDEA 运行配置:三处关键设置决定你能否看到登录页

光有代码和数据库还不够。在 IntelliJ IDEA 中,必须手动配置以下三项,否则启动 Tomcat 后访问http://localhost:8080/显示 404:

  1. Project SDK:File → Project Structure → Project → Project SDK 选 JDK 1.8(不是 JRE,必须是 JDK);
  2. Artifacts:File → Project Structure → Artifacts → 点击+→ Web Application: Archive → 选中train-ticket:war exploded→ Output Directory 设为out/artifacts/train_ticket_war_exploded;
  3. Tomcat Server Configuration:Run → Edit Configurations → + → Tomcat Server → Local → Deployment → 点击+→ Artifact → 选train-ticket:war exploded→ Application context 填/(不是/train-ticket,否则首页 URL 变成http://localhost:8080/train-ticket/,而所有 JSP 里的<a href="login.jsp">都是相对路径,会 404)。

注意:若你用的是较新版本 IDEA(2023.3+),首次运行时会弹窗提示 “No artifacts marked for deployment”,必须按上述步骤手动添加,IDE 不会自动识别webapp/WEB-INF/web.xml。


3. 核心功能落地:从登录到下单,每一行代码都指向真实业务逻辑

3.1 登录模块:为什么不用 Spring Security?因为你要亲手写 Session 管理

毕业设计答辩最爱问:“如果不用框架,你怎么保证登录态安全?” 本项目用原生HttpSession,但做了三层加固:

  • 密码存储:注册时用MessageDigest.getInstance("SHA-256")加盐哈希(盐值存在user.salt字段,非硬编码);
  • Session 绑定 IP:LoginServlet.java中request.getRemoteAddr()记录登录 IP,后续每次请求校验session.getAttribute("login_ip").equals(request.getRemoteAddr());
  • Token 防重放:登录成功后生成UUID.randomUUID().toString().replace("-", "")存入session.setAttribute("token", token),所有需登录的 JSP 页面(如order.jsp)顶部加<% if (session.getAttribute("token") == null) response.sendRedirect("login.jsp"); %>。
// LoginServlet.java 片段 String inputPassword = request.getParameter("password"); String username = request.getParameter("username"); User user = userDao.findByUsername(username); if (user != null && user.getPassword().equals(hashPassword(inputPassword, user.getSalt()))) { HttpSession session = request.getSession(true); session.setAttribute("user_id", user.getId()); session.setAttribute("username", user.getUsername()); session.setAttribute("login_ip", request.getRemoteAddr()); // 关键:绑定 IP session.setAttribute("token", UUID.randomUUID().toString().replace("-", "")); response.sendRedirect("index.jsp"); } else { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); }

为什么不用 Cookie 存 token?
Cookie 易被 XSS 窃取,而HttpSession服务端存储,只传JSESSIONIDCookie,且本项目所有表单提交均用 POST,杜绝 GET 泄露。

3.2 余票查询:前端传参格式与后端 SQL 绑定的精确匹配

前端index.jsp的查询表单是:

<form action="queryTickets" method="post"> <input type="text" name="fromStation" value="北京" required> <input type="text" name="toStation" value="上海" required> <input type="date" name="travelDate" required> <select name="seatType"> <option value="second">二等座</option> <option value="first">一等座</option> </select> <button type="submit">查询车票</button> </form>

后端QueryTicketsServlet.java必须严格按此接收,并拼接 SQL:

String fromStation = request.getParameter("fromStation").trim(); String toStation = request.getParameter("toStation").trim(); String travelDateStr = request.getParameter("travelDate"); String seatType = request.getParameter("seatType"); // 1. 日期格式化:前端传 "2025-04-12",需转为 java.sql.Date Date travelDate = Date.valueOf(travelDateStr); // 2. 查询语句:用 PreparedStatement 防 SQL 注入,且 WHERE 条件顺序与索引匹配 String sql = "SELECT t.*, s.remaining_count, s.total_count FROM train_info t " + "JOIN ticket_stock s ON t.train_no = s.train_no " + "WHERE t.from_station = ? AND t.to_station = ? AND s.date = ? AND s.seat_type = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, fromStation); // 对应 t.from_station ps.setString(2, toStation); // 对应 t.to_station ps.setDate(3, travelDate); // 对应 s.date ps.setString(4, seatType); // 对应 s.seat_type ResultSet rs = ps.executeQuery();

关键点:

  • s.date = ?必须放在s.seat_type = ?前面,因为ticket_stock表的联合索引是(train_no, date, seat_type),MySQL 最左前缀原则要求date在seat_type前才能命中索引;
  • t.from_station = ?和t.to_station = ?虽无索引,但train_info表数据量小(<1000 行),全表扫描可接受;
  • 若前端传seatType=third(非法值),ENUM字段会报错,Servlet 捕获SQLException后跳转至error.jsp,而非静默返回空列表。

3.3 下单模块:用 synchronized + 数据库乐观锁双保险防超卖

这是整个系统的技术制高点。很多毕业设计用synchronized(this)包住整个下单方法,看似防超卖,实则把高并发变成串行,答辩时被问“1000 人同时抢票,你的系统 QPS 是多少?”当场宕机。本项目采用更务实的方案:

  1. 数据库层:ticket_stock表remaining_count字段做乐观锁更新;
  2. Java 层:对同一车次+日期+席别的库存操作,用ConcurrentHashMap<String, Object>锁住 key(如"G101_2025-04-12_second");
  3. 事务边界:从“查余票”到“扣库存”到“生成订单”必须在一个数据库事务内。
// OrderServlet.java 片段 String trainNo = request.getParameter("trainNo"); String dateStr = request.getParameter("date"); String seatType = request.getParameter("seatType"); int userId = (int) session.getAttribute("user_id"); // 1. 构造锁 key String lockKey = trainNo + "_" + dateStr + "_" + seatType; Object lock = stockLockMap.computeIfAbsent(lockKey, k -> new Object()); // 2. 加锁(注意:只锁库存操作,不锁整个 Servlet) synchronized (lock) { Connection conn = dataSource.getConnection(); conn.setAutoCommit(false); try { // 3. 查当前余票(必须用 SELECT ... FOR UPDATE,否则幻读) String selectSql = "SELECT remaining_count, total_count FROM ticket_stock " + "WHERE train_no = ? AND date = ? AND seat_type = ? FOR UPDATE"; PreparedStatement selectPs = conn.prepareStatement(selectSql); selectPs.setString(1, trainNo); selectPs.setDate(2, Date.valueOf(dateStr)); selectPs.setString(3, seatType); ResultSet rs = selectPs.executeQuery(); if (!rs.next() || rs.getInt("remaining_count") <= 0) { throw new RuntimeException("余票不足"); } int remaining = rs.getInt("remaining_count"); int total = rs.getInt("total_count"); // 4. 乐观更新:WHERE 条件包含旧的 remaining_count String updateSql = "UPDATE ticket_stock SET remaining_count = ? " + "WHERE train_no = ? AND date = ? AND seat_type = ? AND remaining_count = ?"; PreparedStatement updatePs = conn.prepareStatement(updateSql); updatePs.setInt(1, remaining - 1); updatePs.setString(2, trainNo); updatePs.setDate(3, Date.valueOf(dateStr)); updatePs.setString(4, seatType); updatePs.setInt(5, remaining); // 关键:必须等于查出来的值 int affected = updatePs.executeUpdate(); if (affected == 0) { throw new RuntimeException("库存更新失败,可能已被其他请求占用"); } // 5. 生成订单(order_no = trainNo + date + nanotime) String orderNo = trainNo + dateStr.replace("-", "") + System.nanoTime(); String insertSql = "INSERT INTO order_info (order_no, user_id, train_no, date, seat_type, price, pay_status) VALUES (?, ?, ?, ?, ?, ?, ?)"; PreparedStatement insertPs = conn.prepareStatement(insertSql); insertPs.setString(1, orderNo); insertPs.setInt(2, userId); insertPs.setString(3, trainNo); insertPs.setDate(4, Date.valueOf(dateStr)); insertPs.setString(5, seatType); insertPs.setBigDecimal(6, new BigDecimal("553.00")); // 示例价格 insertPs.setInt(7, 0); insertPs.executeUpdate(); conn.commit(); request.setAttribute("orderNo", orderNo); request.getRequestDispatcher("orderSuccess.jsp").forward(request, response); } catch (Exception e) { conn.rollback(); request.setAttribute("error", e.getMessage()); request.getRequestDispatcher("orderFail.jsp").forward(request, response); } finally { conn.close(); } }

为什么这样设计?

  • FOR UPDATE确保查询时锁定该行,防止其他事务读到脏数据;
  • UPDATE ... WHERE remaining_count = ?是乐观锁核心,若并发更新,第二次执行时remaining_count已被改,affected == 0,主动抛异常;
  • ConcurrentHashMap锁 key 而非锁对象,避免不同车次互相阻塞(如 G101 和 G102 可并行);
  • System.nanoTime()保证order_no全局唯一,不依赖数据库自增 ID(因订单表主键是BIGINT,但业务上需要字符串订单号)。

4. 避坑:那些让答辩老师皱眉、让导师摇头的 4 个致命细节

4.1 现象:启动 Tomcat 后访问http://localhost:8080/显示 404,但http://localhost:8080/index.jsp能打开

原因:web.xml中<welcome-file-list>缺失,或index.jsp不在webapp/根目录。本项目web.xml必须包含:

<welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list>

且index.jsp文件必须位于src/main/webapp/index.jsp(不是src/main/java下)。常见错误是把 JSP 放错目录,IDEA 不会报错,但 Tomcat 找不到欢迎页。

4.2 现象:登录成功后跳转index.jsp,但页面右上角显示 “欢迎,null”

原因:LoginServlet中session.setAttribute("username", user.getUsername())执行了,但index.jsp里写成了<%= session.getAttribute("user_name") %>(多了一个下划线)。检查所有 JSP 中getAttribute的 key 是否与setAttribute完全一致,JavaWeb 对大小写和下划线极其敏感。

4.3 现象:查询车票时,输入 “北京” “上海” “2025-04-12”,返回空列表,但数据库里明明有 G101 次

原因:train_info表中from_station和to_station字段存的是 “北京南” “上海虹桥”,而前端输入框默认值是 “北京” “上海”。解决方案:在QueryTicketsServlet.java中,将输入的站名模糊匹配:

// 替换原 SQL 中的 t.from_station = ? 为: // t.from_station LIKE CONCAT('%', ?, '%') // 同理 t.to_station

并在train_info表from_station字段加索引:ALTER TABLE train_info ADD INDEX idx_from (from_station);

4.4 现象:下单成功后,ticket_stock.remaining_count减了 1,但order_info.pay_status始终是 0,无法演示“已支付”状态

原因:项目未实现支付接口,但答辩需要展示状态流转。本项目预留了PayServlet.java,只需在orderSuccess.jsp中添加按钮:

<a href="pay?orderNo=${requestScope.orderNo}" class="btn btn-success">模拟支付</a>

PayServlet.java内容极简:

String orderNo = request.getParameter("orderNo"); String sql = "UPDATE order_info SET pay_status = 1 WHERE order_no = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, orderNo); ps.executeUpdate(); response.sendRedirect("orderDetail.jsp?orderNo=" + orderNo);

注意:orderDetail.jsp必须根据pay_status动态显示“待支付”或“已支付”,否则状态不闭环。


5. 答辩加分项:三个让老师眼前一亮的可演示技巧

5.1 用 Chrome DevTools 模拟 10 个用户并发抢票,直观展示防超卖效果

别再口说“我用了 synchronized”。打开 Chrome,按F12→ Network → Disable cache,然后在 Console 里粘贴这段 JS(替换trainNo,date,seatType为你数据库里真实存在的值):

function createOrder(i) { fetch('http://localhost:8080/order', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `trainNo=G101&date=2025-04-12&seatType=second&userId=1` }) .then(r => r.text()) .then(t => console.log(`用户${i}:`, t.includes('success') ? '成功' : '失败')); } // 启动 10 个并发请求 for (let i = 0; i < 10; i++) { setTimeout(() => createOrder(i), i * 100); // 错开时间,避免瞬间洪峰 }

执行后,立即刷新数据库:

SELECT * FROM ticket_stock WHERE train_no='G101' AND date='2025-04-12'; SELECT COUNT(*) FROM order_info WHERE train_no='G101' AND date='2025-04-12' AND pay_status=0;

你会看到remaining_count从 1000 变成 990,order_info新增 10 条记录——证明 10 个请求全部成功,且无超卖。把这过程录屏 30 秒,答辩时放出来,比讲十分钟原理都有力。

5.2 在OrderServlet.java的catch块里加一行日志,把“超卖拦截”变成可审计证据

很多同学的异常处理就是e.printStackTrace(),答辩时老师问“你怎么知道系统拦住了超卖?”,答“看控制台有报错”。这太弱。改成:

} catch (Exception e) { // 关键:记录被拦截的请求指纹 String fingerprint = String.format("train:%s,date:%s,seat:%s,ip:%s", trainNo, dateStr, seatType, request.getRemoteAddr()); System.err.println("[ANTI_OVERSELL] Blocked: " + fingerprint + " | Reason: " + e.getMessage()); // ... 后续跳转逻辑 }

然后在答辩现场,故意用 Postman 发送 20 个相同参数的下单请求,再打开 IDEA 的 Run 窗口,滚动查找[ANTI_OVERSELL] Blocked日志——这就是你系统防超卖的“监控大屏”。

5.3 用 MySQL 的SHOW PROCESSLIST实时查看数据库锁等待,把抽象概念具象化

当演示高并发下单时,在另一个终端执行:

mysql -uroot -p123456 -e "SHOW PROCESSLIST\G" | grep -A 5 -B 5 "Locked"

你会看到类似输出:

Id: 123 User: root Host: localhost:56789 db: train_ticket Command: Sleep Time: 0 State: Locked Info: NULL

告诉老师:“State=Locked 表示这个连接正在等待ticket_stock表上某行的锁,证明我们的FOR UPDATE生效了;而Time=0说明等待时间极短,系统响应快。” —— 把“数据库锁”从教科书概念,变成你屏幕上真实跳动的字符。

我带过 7 届毕设,见过太多同学花三个月调通一个“能登录”的页面,却在答辩时被问一句“你这个库存扣减,怎么保证不超卖?”就卡住。其实答案就藏在UPDATE ... WHERE remaining_count = ?这一行 SQL 里。真正的技术深度,不在于用了多少框架,而在于你敢不敢直面并发、敢不敢在裸 JDBC 里写事务、敢不敢把synchronized锁的范围精确到一个字符串 key。这个火车订票系统不是终点,它是你第一次亲手把“理论上的 ACID”变成“屏幕上跳动的数字”的起点。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询