基于JAVA Web的车票预订系统:JSP+Servlet+MySQL课设实现与避坑指南 简介这是一套基于WEB的车票预订系统毕业设计/课程设计源码项目采用Java、Servlet、JSP与MySQL构建覆盖车次查询、用户注册登录、在线订票、订单管理等核心模块适合想通过完整实战掌握Java Web开发全流程的学习者。资源包约55.82MB内含1337个文件主体包括Java与JSP源码、编译后的class文件以及JS、CSS、HTML等前端资源png、gif、jpg图片用于界面美化jar包提供第三方库依赖sql脚本可直接初始化数据库表结构与样例数据。包中还附带UML设计文档wd3、myumldata、配置文件与mp4演示视频配合editor上传组件等痕迹能帮助理解从需求分析到代码实现的映射关系。当前已有1105人学习这份资料适合作为毕业设计修改基础或进阶Web开发的实践参考。1. 基于WEB的车票预订信息系统从课程设计到可跑通的全套JAVA实现车票预订管理系统是个被做烂了但始终逃不开的课设题目难点从来不在于“能不能登录、能不能下单”而在于一套代码拿下来之后能不能在别人的电脑上五分钟跑起来、能不能应付答辩时导师随手点开的一个边界操作。这套基于JAVA Web的车票预订信息系统采用JSP Servlet MySQL的经典组合覆盖了用户注册登录、车次查询、在线订票、订单管理、后台车次维护这几条主链路前后台分离、权限区分明确非常适合做毕业设计或课程设计的底子。我拆过不少同类资源大部分翻车点都出在环境版本不匹配和数据库初始化脚本不完整这两件事上这套项目在这两个地方处理得比较干净值得照着源码走一遍。2. 技术选型与项目结构为什么JSPServlet这套组合在课设里仍然能打2.1 技术栈选择JSP负责页面、Servlet负责控制、JavaBean负责数据这套系统的核心是Java Web最传统也最稳妥的三层结构JSP做视图层、Servlet做控制层、JavaBean做模型层数据库用MySQL服务器用Tomcat。现在很多新项目已经转向Spring Boot MyBatis但课设场景下JSPServlet仍是答辩通过率最高的方案原因是它的调用链足够短导师问“你点一下查询后台到底发生了什么”你可以从浏览器到Servlet到DAO到数据库一路讲清楚没有任何框架封装的黑匣子。项目里控制层的设计遵循了Servlet 3.0之后的注解方式不再需要去web.xml里一条条配Servlet映射。这一点对新手友好很多减少了配置错误的概率。数据访问层没有使用任何ORM框架手写了JDBC工具类来封装数据库连接与释放虽然代码量稍大但正好能展示你对JDBC原理的理解。2.2 项目目录结构与关键文件定位拿到 .rar 解压之后先别急着导入IDE先把目录结构摸清楚。典型的JSP课设项目结构是标准的Web应用布局几个关键位置的物什决定了你后续改代码的效率web车票预订/ ├── src/ # Java源码目录 │ ├── com.xxxx.entity/ # 实体类User、Ticket、Order等 │ ├── com.xxxx.dao/ # 数据访问层JDBC操作封装 │ ├── com.xxxx.servlet/ # 控制层登录、注册、订票等Servlet │ └── com.xxxx.util/ # 工具类DBUtil、字符串处理等 ├── WebContent/ # Web根目录 │ ├── index.jsp # 系统入口页面 │ ├── css/ js/ images/ # 静态资源 │ ├── WEB-INF/ │ │ ├── web.xml # Web部署描述文件 │ │ └── lib/ # 依赖的jar包 │ └── admin/ # 后台管理页面 └── database/ └── ticket.sql # 数据库初始化脚本导入Eclipse或IDEA时注意把 WebContent 目录识别为Web资源根目录否则JSP页面会找不到这是新手最常见的导入错误。如果你用的是IDEA导入时需要选择“New Project from Existing Sources”然后在Project Structure里把Web资源目录和lib目录都配置好。2.3 数据库连接参数的统一管理数据库连接信息集中在 DBUtil.java 里这是整套系统能否跨机器跑起来的关键位置。我看到不少同类项目把数据库账号密码硬编码在各个DAO类里改起来极为痛苦。这个项目统一管理的方式值得借鉴// DBUtil.java 核心代码 public class DBUtil { // 数据库连接参数统一维护换环境只需改这里 private static final String DRIVER com.mysql.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/ticket_db?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneAsia/Shanghai; private static final String USERNAME root; private static final String PASSWORD 123456; public static Connection getConnection() throws Exception { Class.forName(DRIVER); return DriverManager.getConnection(URL, USERNAME, PASSWORD); } public static void close(ResultSet rs, Statement stmt, Connection conn) { // 依次关闭资源释放数据库连接池占用的连接 if (rs ! null) try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } if (stmt ! null) try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } if (conn ! null) try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }这里有几个参数值得注意。URL中带上了 useSSLfalse 和 serverTimezoneAsia/Shanghai前者避免MySQL 8.x连接时报SSL警告后者解决时区导致的日期查询错乱问题。如果你的本机MySQL是5.7及以下版本serverTimezone可以不需要但保留也无害。密码默认是 123456如果你的MySQL设置了别的密码这里必须同步修改否则连第一步数据库连接测试都过不去。3. 数据库设计与初始化脚本车次、用户、订单三张核心表的关系拆解3.1 数据库表结构设计思路这套系统的数据库设计是三张核心表加两张辅助表的组合用户表(user)、车次表(train)、订单表(orders)以及车次站点明细和订单明细表。关系上用户表与订单表是一对多车次表与订单表也是一对多订单明细表则是订单与车次之间的关联拆解。车次表的设计特意没有把“始发站→终点站→途经站”拆成一张独立的线路表而是直接冗余存储在车次表里。这种设计在范式上不算最优但对于课设系统来说查询时少了一次关联代码写起来简单很多而且数据量有限冗余不会造成实际问题。答辩时如果导师问“为什么不拆表”你就说“考虑到车次信息变更频率低、查询频率高冗余存储可以减少联表查询开销”这是个能站住脚的理由。3.2 核心建表语句解读-- 用户表存储系统注册用户信息 CREATE TABLE user ( uid INT(11) NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 登录用户名, password VARCHAR(50) NOT NULL COMMENT 登录密码(MD5加密存储), real_name VARCHAR(20) DEFAULT NULL COMMENT 真实姓名, id_card VARCHAR(18) DEFAULT NULL COMMENT 身份证号, phone VARCHAR(11) DEFAULT NULL COMMENT 手机号码, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (uid), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8 COMMENT用户信息表; -- 车次表存储列车班次、日期与余票 CREATE TABLE train ( tid INT(11) NOT NULL AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL COMMENT 车次编号如G1024, start_station VARCHAR(50) NOT NULL COMMENT 始发站, end_station VARCHAR(50) NOT NULL COMMENT 终点站, start_time TIME NOT NULL COMMENT 发车时间, end_time TIME NOT NULL COMMENT 到达时间, ticket_price DECIMAL(10,2) NOT NULL COMMENT 票价, ticket_count INT(11) NOT NULL DEFAULT 0 COMMENT 总票数, remain_tickets INT(11) NOT NULL DEFAULT 0 COMMENT 余票数量, train_date DATE NOT NULL COMMENT 发车日期, PRIMARY KEY (tid), KEY idx_train_no (train_no) ) ENGINEInnoDB DEFAULT CHARSETutf8 COMMENT车次信息表;密码字段预留了MD5加密的说明位置。实际在代码里如果你的DAO层用的是明文存储答辩时最好把加密逻辑补上至少要对密码做个MD5或SHA-256哈希否则导师一眼就能抓到安全隐患。余票字段 real_name 用 DEFAULT NULL 是因为部分用户注册后不补充实名信息也能下单系统不强校验这是课设系统的常见取舍。3.3 初始化脚本执行的三个注意点导入 ticket.sql 时有几个顺序和兼容性的问题需要处理第一步确保MySQL服务已经启动。Windows下可以在服务管理器里确认 MySQL 服务状态Mac/Linux下执行service mysql status或systemctl status mysql。第二步登录MySQL后先创建数据库再导入表。用命令行导入时会让新手卡住的情况是字符集问题建议在执行前先声明使用UTF-8# 进入MySQL命令行创建数据库并指定字符集 mysql -u root -p CREATE DATABASE IF NOT EXISTS ticket_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE ticket_db; SOURCE C:/path/to/ticket.sql;这里用 utf8mb4 而不是 utf8原因在于UTF-8在MySQL里只能存3字节的字符遇到特殊符号或emoji会报错。虽然车票系统的数据不太可能出现emoji但真实姓名字段如果包含生僻字4字节存储更保险。SOURCE 后面带的是SQL脚本的绝对路径Windows下注意斜杠方向用反斜杠会被当成转义符处理。第三步导入完成后验证表数量。执行SHOW TABLES;应该能看到五张表同时用SELECT COUNT(*) FROM train;确认车次表里有初始化好的模拟数据。如果这个查询结果是0说明脚本没灌入测试数据后续页面打开后所有下拉框都是空的看起来像系统坏了。4. 核心功能模块实现从登录校验到余票扣减的完整调用链4.1 登录模块的Session处理与权限区分登录逻辑放在 LoginServlet 里用户提交用户名密码后Servlet调用DAO层查询用户表比对成功后将用户对象放入Session然后根据用户类型跳转到不同首页。前后台用户共用一张用户表通过 user_type 字段区分——这是课设里常见的省事设计但也是答辩时容易讲不清楚的地方。建议回答时明确说系统支持管理员和普通用户两种角色管理员由初始化SQL直接写入注册接口默认创建普通用户避免开放注册出管理员权限漏洞。4.2 车次查询功能的SQL拼接与防注入车次查询是这套系统的门面功能。查询页按“出发城市、到达城市、发车日期”三个条件筛选Servlet层接收参数后拼SQL。这里要警惕的是字符串拼接写法引入的SQL注入风险。有些课设代码直接写SELECT * FROM train WHERE start_station start 如果用户输入北京 OR 11整个表都会被拖出来答辩时这是致命伤。这个项目在查询Servlet里用了预处理语句PreparedStatement是值得保留的优点。它的核心逻辑是先定义带问号的SQL骨架再用setString注入真实参数MySQL驱动会对参数做转义从机制上切断注入路径。// TrainDao.java 查询车次 public ListTrain findByCondition(String start, String end, String date) { ListTrain list new ArrayListTrain(); String sql SELECT * FROM train WHERE start_station LIKE ? AND end_station LIKE ? AND train_date ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { // LIKE模糊查询配合 % 通配符实现站点部分匹配 ps.setString(1, % start %); ps.setString(2, % end %); ps.setString(3, date); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Train t new Train(); t.setTid(rs.getInt(tid)); t.setTrainNo(rs.getString(train_no)); t.setStartStation(rs.getString(start_station)); t.setEndStation(rs.getString(end_station)); t.setStartTime(rs.getTime(start_time)); t.setEndTime(rs.getTime(end_time)); t.setTicketPrice(rs.getBigDecimal(ticket_price)); t.setRemainTickets(rs.getInt(remain_tickets)); list.add(t); } } } catch (Exception e) { e.printStackTrace(); } return list; }注意这里用的是 try-with-resources 语法Java 7 的特性可以自动关闭实现了 AutoCloseable 接口的资源省去了 finally 块里手动关闭连接繁琐代码。如果你本机的JDK版本低于7这段代码会编译不过但一般现在大家的JDK至少是8问题不大。查询结果返回 List JSP页面再用 JSTL 的 c:forEach 循环渲染到表格里。这里有一个小的性能点车次数据量只有几十条时无所谓但如果导入了大量历史数据建议在车次表 train_date 字段上加索引否则日期过滤会全表扫描。4.3 订票与余票更新的事务边界订票是整系统最容易出并发问题的模块也是答辩时“压轴提问”出现频率最高的环节。这个项目的做法是用户点击订票后OrderServlet 先检查数据库里该车次的 remain_tickets 是否大于0如果为0则返回“票已售罄”否则插入一条订单记录同时把车次表的余票数减1。这个逻辑在单用户情况下没有问题但放在真实的高并发场景下会暴露经典问题两个用户同时查到余票还剩1张同时提交订单都通过了“余票大于0”的检查结果卖出了两张票。课设答辩不会真的上并发测试但导师很可能会口头问“你怎么保证不超卖”。如果你想把这块做稳妥可以用两种方式改进。第一种是行级锁查询余票时加FOR UPDATE让事务在检查余票那一刻就把这行锁住直到事务提交或回滚才释放。第二种是乐观锁思路在车次表加一个版本号字段更新余票时同时校验版本号一致才执行 UPDATE。对课设来说第一种简单直接代码改动最小。-- 改进方案在事务内部对车次行加锁防止并发超卖 START TRANSACTION; SELECT remain_tickets FROM train WHERE tid ? FOR UPDATE; -- 应用层判断 remain_tickets 0 后 UPDATE train SET remain_tickets remain_tickets - 1 WHERE tid ?; INSERT INTO orders (uid, tid, order_no, status) VALUES (?, ?, ?, 已支付); COMMIT;需要强调的是这段SQL里的 SELECT FOR UPDATE 必须放在事务里且 FOR UPDATE 配合 COMMIT 才会释放行锁。如果你用 JDBC 单独执行这几个语句默认 autocommit 是开启的每条语句独立提交行锁在 SELECT 结束就释放了后面的 UPDATE 和 INSERT 不再有锁保护。订单号生成是另一个可以拿到答辩场上讲的点。系统中订单号用时间戳加随机数拼接比如String.valueOf(System.currentTimeMillis()) (int)(Math.random() * 1000)。这个方案在并发低时够用但同一毫秒内生成两个订单号会重复更规范的做法是使用数据库自增ID加日期前缀或者引入 Snowflake 算法思想拼接时间戳、机器号、序列号三段。课设里时间戳加随机数的方案可以接受但你得知道它的局限在哪。4.4 后台车次管理的增删改查后台管理模块的代码结构跟前台类似TrainManageServlet 接收 action 参数区分是 add、update、delete 还是 list然后用 switch 分支调用不同的DAO方法。这种用一个Servlet多个方法的写法省去了大量Servlet类文件但代码可读性会下降参数枚举值一多就会绕晕。如果后续扩展建议拆成多个Servlet或者引入一个简单的BaseServlet封装反射调用。删除车次这里有个细节如果该车次已经存在关联订单直接 DELETE 会报外键约束错误。初始化SQL里如果没设置外键的 ON DELETE CASCADE那么删除前需要先处理订单表里的关联数据。更稳妥的做法是逻辑删除——给车次表加一个 status 字段删除时置为0查询时默认过滤 status1。这个方案保留了历史订单的数据完整性也是实际生产系统里常见做法。5. 部署与运行避坑JDBC驱动版本、乱码、端口占用等七个高频问题排查5.1 避坑一JDBC驱动版本与MySQL版本不匹配导致连接失败现象Tomcat启动时日志能看到报错Cannot create PoolableConnectionFactory或ClassNotFoundException: com.mysql.jdbc.Driver网页打开直接500。原因项目 lib 目录里带的 mysql-connector-java 版本和本机 MySQL 版本不匹配。MySQL 5.x 用 5.1.x 的驱动没问题MySQL 8.x 需要 8.0.x 驱动依赖的 JAR 包必须放进WEB-INF/lib目录Eclipse 的 Build Path 里配了单独引用也不行Tomcat 运行时只认 lib 下的JAR。解决确认本机MySQL版本后去Maven中央仓库下载对应版本驱动。MySQL 8.x 项目的驱动类名是com.mysql.cj.jdbc.Driver旧的是com.mysql.jdbc.Driver这两者不能混用代码里 DBUtil 的 DRIVER 常量要同步改。5.2 避坑二JSP页面中文乱码插入数据库再查出来变成问号现象页面初始打开中文显示正常提交表单存库后查询回来中文字段全部变成???。原因三层链路有任一处字符集不对都会乱码。第一层是数据库连接URL没带 characterEncodingutf-8 参数第二层是JSP页面没有声明pageEncodingUTF-8第三层是MySQL表本身用的不是utf8字符集。这个项目初始化脚本里表结构统一用了 utf8所以错误集中在第一层和第二层。解决DBUtil里的连接URL必须带上useUnicodetruecharacterEncodingutf-8JSP页面头部加上% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%同时检查Tomcat的 server.xml 里Connector是否配置了URIEncodingUTF-8这一步解决GET请求参数中文乱码的问题。5.3 避坑三浏览器打开 index.jsp 报404但Tomcat明明启动成功了现象Tomcat启动日志没有报错访问http://localhost:8080/项目名/却404。原因项目没有成功部署到Tomcat的webapps目录。Eclipse里用“Add and Remove”把项目加入Server之后需要确认部署路径里的Deploy Path是webapps。另外检查访问路径是否带上了项目上下文名http://localhost:8080/访问的是Tomcat默认页不是你的项目。解决打开Eclipse的Servers视图双击Tomcat服务器查看 Server Locations 里的Use Tomcat installation是否选中并且 Deploy Path 是 webapps。项目右键 Run As - Run on Server 启动之后在浏览器地址栏输入http://localhost:8080/项目名/index.jsp。如果项目名带下划线或空格先改成纯小写字母组合减少路径类低级错误。5.4 避坑四端口被占用Tomcat启动红叉报错现象Tomcat启动中途弹窗显示Port 8080 required by Tomcat v9.0 Server at localhost is already in use。原因之前启动过的Tomcat实例没有正常关闭或者别的程序占用了8080端口。解决命令行执行netstat -ano | findstr 8080查PID然后taskkill /PID 对应PID /F强制结束进程。如果本机多个项目共用8080不现实可以直接在 server.xml 里把8080改成8088或8081注意同时改HTTP连接器端口Shutdown端口也要改避免与其他Tomcat实例冲突。5.5 避坑五Session登录失效刷新一下又回到登录页现象登录成功跳转到首页点击任意链接后再跳回登录页好像Session没存住。原因Tomcat默认Session超时时间为30分钟。但很多情况下不是超时问题而是项目配置里设置了拦截器每次都验证Session而登录时没把用户对象正确塞进Session。也可能是部署后的访问域名带不带www导致Cookie作用域不一致本机访问一般不会触发。解决打开 web.xml 检查是否配置了session-configsession-timeout30/session-timeout/session-config把超时调大。同时确认登录Servlet里写的是session.setAttribute(user, user)后续所有页面取值用相同的key。如果用了拦截器Filter检查 Filter 是否放行了 index.jsp 和静态资源。5.6 避坑六数据库连接用MySQL 8时报 Public Key Retrieval 不合法错误现象连接MySQL 8.x时报Public Key Retrieval is not allowed错误连接直接失败。原因MySQL 8默认使用 caching_sha2_password 认证插件客户端第一次连接时服务端要求客户端获取RSA公钥来加密密码传输而JDBC URL里没有配置允许这个行为。解决在数据库连接URL末尾追加allowPublicKeyRetrievaltrueuseSSLfalse。注意这个参数只建议在开发环境开启生产环境应该配置服务端SSL证书后去掉。5.7 避坑七导出项目时候漏掉WebContent或src源码现象用Eclipse File - Export - WAR file 导出的WAR包部署到另一台机器后页面能访问但所有请求都404。原因导出的WAR文件没有包含编译后的 .class 文件常见原因是项目勾选了“Export source files”但没有勾选“Export class files”或者项目在Eclipse里根本没有编译过。解决导出前先 Project - Clean 强制重新编译确认 build/classes 目录下有 .class 文件。WAR包导出向导里勾选“Export source files”和“Export class files”两项。部署到新机器时把WAR直接丢进Tomcat的webapps目录Tomcat会自动解压并部署不要在部署中途手动解压容易产生权限和路径问题。6. 数据库安全加固与订单号生成的进阶优化让课设从“能跑”到“经得起问”项目答辩时老师问得最多的问题基本集中在安全性和并发一致性上。与其回避不如主动把代码升级到可以正面回答这些提问的程度这里分享几个我实际改动过的小技巧。密码存储升级是第一优先级。源码里注册功能如果是明文保存密码必须改成哈希存储。MD5本身已经被证明可碰撞建议至少使用 SHA-256 加盐// PasswordUtil.java 密码哈希工具 public class PasswordUtil { public static String sha256(String rawPassword, String salt) { String result ; try { MessageDigest md MessageDigest.getInstance(SHA-256); byte[] salted (rawPassword salt).getBytes(UTF-8); byte[] hash md.digest(salted); StringBuilder sb new StringBuilder(); for (byte b : hash) { sb.append(String.format(%02x, b)); } result sb.toString(); } catch (Exception e) { e.printStackTrace(); } return result; } // 生成随机盐值每个用户独立避免彩虹表攻击 public static String generateSalt() { SecureRandom random new SecureRandom(); byte[] salt new byte[16]; random.nextBytes(salt); return Base64.getEncoder().encodeToString(salt); } }Salt 要存到用户表里。登录时从数据库取出该用户的salt用相同算法哈希后比较而不是直接哈希后查表。这样即使用户表泄露攻击者也无法用预计算好的彩虹表反查出明文密码。订单号生成优化是第二个值得动手的模块。时间戳加随机数能过基本测试但如果老师问“同一毫秒下两个订单号会怎样”你得有能接得住的方案。可以在订单表设计上加入一个序列概念简化版思路是使用全局计数器配合时间戳// 生成订单号yyyyMMddHHmmss 日期内自增序列 public synchronized String generateOrderNo() { SimpleDateFormat sdf new SimpleDateFormat(yyyyMMddHHmmss); String timePart sdf.format(new Date()); // 缓存上一次的时间若与当前时间相同则递增序列号否则重置 if (lastTime.equals(timePart)) { seq; } else { lastTime timePart; seq 1; } return timePart String.format(%04d, seq); }用 synchronized 保证多线程同时调用时只有一个线程能进入方法配合内存变量 seq 做到同一秒内订单号有序递增。这个方案的生产可用性不如Redis或分布式发号器但在单机Tomcat部署的课设场景下足够回答并发生成重复订单号的问题。最后建议把购票流程整体走完一遍日志输出。在线程安全不能完全保证的情况下“所有操作留痕”是开发者最后的后悔药。AutomaticTicketServlet 里打印每一步时间点和结果答辩现场演示时能让导师直观看到事务边界和数据库变化System.setProperty(user.timezone, Asia/Shanghai); // 简单控制台日志观察订票链路 System.out.println([订票] 开始处理订单车次ID tid 时间 new Date()); // 事务内第一步查询余票 System.out.println([订票] 余票查询结果 remainTickets); // 事务内第二步更新余票 System.out.println([订票] 余票扣减结果 updateCount); // 事务内第三步插入订单 System.out.println([订票] 订单插入结果 insertCount);那一次帮某开发者排查线上订票系统超卖问题查到最后果然不是SQL写得不对而是事务被自动提交切碎了每个语句单独成一个事务锁的范围根本没有覆盖整个业务流程。从那以后我每次在课设里指导JSP项目都会强制走一遍“三个语句是否在同一个事务连接里提交”的检查。基于WEB的车票预订信息系统这套代码本身不复杂花一个晚上把数据库脚本导入、把连接参数改好、把避坑列表对着过一遍它就能成为一个能从导数据库一路演示到后台管理的完整答辩项目希望帮到你。本文还有配套的精品资源点击获取