简介:一份基于Java与MySQL的仿12306购票窗口Demo,定位于正在学习Java GUI编程、JDBC数据库操作及并发控制的开发者。项目完整模拟用户登录、车次查询、选座下单、支付回写等购票核心流程,将Swing/JFX界面与MySQL数据存储贯通,重点覆盖数据库表设计、事务原子性、多线程并发购票等实战知识点,非常适合作为课程设计或毕业设计参考。压缩包共61个文件,包含12个Java源码、39个编译class、3个Jar依赖包(Druid、MySQL Connector等)以及properties配置等,包体仅4.73MB,src、lib、bin目录明确,便于快速导入Eclipse/IDEA运行调试。已有665人学习下载。通过该项目可掌握连接池配置、DBUtils封装、异常处理与并发控制技巧,对构建完整业务系统很有帮助。 从简历里翻出一个早年写的 Demo 项目,正好是“Java + MySQL 仿 12306 窗口购票”这套组合,myticket.zip 这个文件名我到现在还记得。今天把这个项目的完整思路、表结构设计、核心代码逻辑、还有当初踩过的坑一次性拆开讲清楚。不管你是刚学完 Java 基础想找个综合练手项目,还是在准备面试需要拿得出手的实战案例,这篇内容都能直接照着做。
先说这个 Demo 能干什么:用 Java Swing 写一个桌面窗口程序,模拟 12306 的购票流程——注册登录、查询车次、查看余票、购买车票、生成订单,所有数据存到 MySQL 里。麻雀虽小五脏俱全,涵盖了 Java 面向对象、JDBC 操作、SQL 编写、GUI 事件处理这些 Java 学习阶段最重要的知识点,比单纯刷题或者照着教程敲代码有用得多。
1. 项目整体拆解与设计思路
1.1 这个 Demo 到底在做什么
很多初学者做完图书管理系统、学生管理系统之后,总觉得还差点意思——那些项目太“平”了,无非是增删改查。仿 12306 购票系统不一样,它有真实的业务逻辑:余票是动态变化的,买票要扣库存,订单要记录状态,一个用户不能重复买同一趟车。这些规则撑起来之后,项目就有了“业务感”。
整个系统我拆成了四个功能块:
- 用户模块:注册、登录、个人信息查看
- 车次模块:查询车次、展示余票数量
- 购票模块:选择车次、选择乘客、提交订单
- 订单模块:订单列表、退票操作
每个模块对应一到两个窗口界面,界面之间通过登录后的用户 ID 串联数据。这样设计的好处是边界清楚,写代码的时候不会东一榔头西一棒子。
1.2 技术选型:为什么是 Java Swing + MySQL
当年选 Swing 而不是 JavaFX,主要原因是 Swing 足够经典,网上资料多,遇到问题随便一搜就有答案。JavaFX 虽然界面更现代,但配置繁琐,对刚接触 GUI 编程的人不太友好。Swing 的 JFrame、JTable、JButton 这几个组件足够完成 90% 的功能,而且事件监听机制是 Java 面试常问的点,做一遍印象非常深。
数据库用 MySQL 也是综合考虑:MySQL 安装简单、社区版免费、SQL 语法标准,而且大部分公司生产环境就是 MySQL,学这个性价比最高。数据访问层用 JDBC 原生 API 来写,刻意没有引入 MyBatis 这类框架——因为这是学习项目,JDBC 能让你把 Connection、PreparedStatement、ResultSet 这些底层的流程吃透,之后再学框架就很轻松。
1.3 分层架构:别把代码全塞在窗口里
这是我想重点强调的一点。很多新手写 GUI 程序,喜欢把数据库查询写在按钮的点击事件里,窗口类几百行起步,改一个功能要翻半天。我写这个项目的时候强制自己分层:
view包:存放所有窗口类,只负责界面展示和事件转发dao包:数据访问层,负责所有 SQL 操作entity包:实体类,对应数据库表结构util包:工具类,比如数据库连接管理
窗口类里绝不出现DriverManager.getConnection()这种代码,按钮点击后调用的是 dao 层的方法,拿到结果再刷新界面。这样每个类的职责都单一,出了问题定位也快。分层带来的额外好处是,如果以后想换成控制台界面或者 Web 界面,dao 层完全不用动。
2. 数据库设计与核心表结构
2.1 三张核心表:用户表、车次表、订单表
数据库我建了myticket库,里面三张表,设计的时候参考了真实售票系统的思路,但做了简化。
用户表t_user:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT 主键自增 | 用户唯一标识 |
| username | VARCHAR(50) UNIQUE | 登录用户名 |
| password | VARCHAR(100) | 登录密码(MD5 加密后存储) |
| real_name | VARCHAR(50) | 真实姓名,购票时用 |
| id_card | VARCHAR(18) | 身份证号,购票时用 |
车次表t_train:
| 字段 | 类型 | 说明 |
|---|---|---|
| train_no | VARCHAR(20) 主键 | 车次号,如 G101、D305 |
| start_station | VARCHAR(50) | 出发站 |
| end_station | VARCHAR(50) | 到达站 |
| start_time | VARCHAR(10) | 发车时间,如 08:30 |
| end_time | VARCHAR(10) | 到达时间 |
| ticket_price | DECIMAL(10,2) | 票价 |
| remaining_tickets | INT | 余票数 |
订单表t_order:
| 字段 | 类型 | 说明 |
|---|---|---|
| order_id | VARCHAR(32) 主键 | 订单号,UUID 生成 |
| user_id | INT | 下单用户 |
| train_no | VARCHAR(20) | 购买的车次 |
| order_time | TIMESTAMP | 下单时间 |
| status | INT | 订单状态,1 已支付,0 已退票 |
这里有个取舍:真实 12306 的订单表会拆分出乘车人子表,因为我这个 Demo 每单只买一张票,就把乘车人信息冗余到订单表里了。如果以后要扩展成一次买多张票,再单独建一张t_order_item表就行。
2.2 关键 SQL:余票查询、扣减库存、订单生成
查询车次是最基础的功能,支持按出发站和到达站模糊查询:
SELECT * FROM t_train WHERE start_station LIKE ? AND end_station LIKE ?购票是整个项目最核心的 SQL 逻辑,设计成了事务操作,分三步:
-- 第一步:查询余票,确认有票 SELECT remaining_tickets FROM t_train WHERE train_no = ? FOR UPDATE; -- 第二步:扣减余票 UPDATE t_train SET remaining_tickets = remaining_tickets - 1 WHERE train_no = ?; -- 第三步:插入订单 INSERT INTO t_order (order_id, user_id, train_no, order_time, status) VALUES (?, ?, ?, NOW(), 1);FOR UPDATE是行级锁,加上事务就能保证并发情况下不会超卖。这个知识点放到后面第 4 部分详聊。
退票的逻辑刚好相反,先把订单状态改成 0,再把余票加回去,同样用事务包起来。
2.3 JDBC 连接配置:DriverManager 的老实写法
数据库连接我写了一个DBUtil工具类,最原始的 JDBC 方式:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/myticket?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }有几个点必须注意。serverTimezone=Asia/Shanghai这段不能少,MySQL 8.x 驱动不指定时区会报错;characterEncoding=utf8是中文不乱码的关键;com.mysql.cj.jdbc.Driver是 MySQL 8.x 的驱动类名,如果是 5.x 版本要写成com.mysql.jdbc.Driver。
3. 核心功能实现细节
3.1 注册登录:密码加密与登录状态
注册功能用的是 MD5 加密存储密码。为什么要加密不存明文?因为明文密码一旦数据库泄露就是灾难,虽然 Demo 里没有安全问题,但从第一天就该养成好习惯。加密代码很简单:
public static String md5(String source) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] bytes = md.digest(source.getBytes("utf-8")); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (Exception e) { e.printStackTrace(); return null; } }虽然现在生产环境推荐 BCrypt 这类加盐算法,MD5 其实不够安全了,但作为学习项目,理解“密码不能明文存储”这个思路才是重点。
登录验证就是拿用户名去库里查,比对 MD5 后的密码是否一致。登录成功后把用户 ID 和姓名存到一个全局变量或者传给下一个窗口的构造函数里,后续购票、查订单都要用到。
3.2 车次查询:JTable 展示结果
查询窗口的交互流程是:输入出发站、到达站,点查询按钮,下方 JTable 显示所有符合条件的车次。
JTable 的数据填充需要用到DefaultTableModel,我封装了一个方法,从 ResultSet 遍历数据,逐行添加:
private void refreshTable(List<Train> trainList) { DefaultTableModel model = (DefaultTableModel) table.getModel(); model.setRowCount(0); for (Train t : trainList) { model.addRow(new Object[]{ t.getTrainNo(), t.getStartStation(), t.getEndStation(), t.getStartTime(), t.getEndTime(), t.getTicketPrice(), t.getRemainingTickets() }); } }这里有一个很常见的坑:JTable 默认不可编辑,但如果直接往 JTable 里加数据,会发现单元格居然能双击编辑。需要重写isCellEditable返回 false:
table = new JTable(model) { @Override public boolean isCellEditable(int row, int column) { return false; } };另外做还加了一个双击获取选中行数据的功能,购票按钮读取选中行,避免用户选错车次。
3.3 购票流程:事务与状态流转
用户点击购买后,系统取出选中的车次信息,弹出一个确认窗口,显示车次、发车时间、票价,用户填乘车人身份证号,确认提交。
这里我用了一条主干流程来展示事务处理:
Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 查余票并加锁 String querySql = "SELECT remaining_tickets FROM t_train WHERE train_no = ? FOR UPDATE"; PreparedStatement ps1 = conn.prepareStatement(querySql); ps1.setString(1, trainNo); ResultSet rs = ps1.executeQuery(); if (rs.next() && rs.getInt("remaining_tickets") <= 0) { JOptionPane.showMessageDialog(null, "余票不足,购票失败"); conn.rollback(); return; } // 扣减余票 String updateSql = "UPDATE t_train SET remaining_tickets = remaining_tickets - 1 WHERE train_no = ?"; PreparedStatement ps2 = conn.prepareStatement(updateSql); ps2.setString(1, trainNo); ps2.executeUpdate(); // 生成订单 String orderSql = "INSERT INTO t_order (order_id, user_id, train_no, order_time, status) VALUES (?, ?, ?, NOW(), 1)"; PreparedStatement ps3 = conn.prepareStatement(orderSql); ps3.setString(1, UUID.randomUUID().toString().replace("-", "")); ps3.setInt(2, currentUserId); ps3.setString(3, trainNo); ps3.executeUpdate(); conn.commit(); JOptionPane.showMessageDialog(null, "购票成功,订单号已生成"); } catch (Exception e) { if (conn != null) conn.rollback(); e.printStackTrace(); } finally { if (conn != null) conn.close(); }这段代码是整个项目的精华。setAutoCommit(false)之后的三个 SQL 要么全部成功,要么全部回滚,不会出现“票扣了但订单没生成”这种脏数据。
3.4 窗口布局:JFrame 的常用套路
Swing 布局没有想象中那么难。我每个窗口基本遵循同一个套路:主容器用BorderLayout,顶部放查询条件JPanel(用FlowLayout排组件),中间放JTable的滚动面板,底部放操作按钮。
有一点想提醒:Swing 是单线程模型,所有界面刷新必须在事件分发线程里做。虽然这个 Demo 的数据量小基本不会卡顿,但如果在查询按钮里执行很耗时的 SQL,界面会假死。标准的做法是用SwingWorker做异步处理,不过作为入门项目,理解这个原理就够了。
4. 实操踩坑记录与常见问题排查
4.1 数据库连接失败:最常见的五个原因
这个项目我最开始跑不起来,问题全出在连接数据库上。我把踩过的坑整理成了一张速查表:
| 异常信息 | 原因 | 解决方案 |
|---|---|---|
ClassNotFoundException: com.mysql.cj.jdbc.Driver | 没有引入 MySQL 驱动 jar 包 | 下载 mysql-connector-java 放入项目并 Add to Build Path |
Access denied for user 'root'@'localhost' | 用户名或密码错误 | 检查 DBUtil 里的账号密码 |
Unknown database 'myticket' | 数据库没创建 | 先执行CREATE DATABASE myticket |
The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized | JDBC URL 缺时区参数 | URL 末尾加&serverTimezone=Asia/Shanghai |
Public Key Retrieval is not allowed | MySQL 8.x 安全机制 | URL 加&allowPublicKeyRetrieval=true |
第三条那个乱码看着很吓人,其实就是时区问题,千万别去改系统时区。
4.2 中文乱码:从数据库到界面的全链路排查
购票系统里全是中文站名,乱码问题几乎避不开。乱码的根源只有一个:字符集不一致。我自己遇到过三种情况:
第一种,数据库表是 latin1 编码。建表的时候没指定,MySQL 默认用了不支持中文的字符集。解决:建表语句加DEFAULT CHARSET=utf8mb4,已经建好的表用ALTER TABLE t_train CONVERT TO CHARACTER SET utf8mb4。
第二种,JDBC 连接字符集没指定。URL 里的characterEncoding=utf8一定不能少,这是 Java 程序和 MySQL 之间的“翻译官”。
第三种,Swing 窗口里输入框的乱码。这种情况基本都是编辑器编码问题,把 Eclipse 或 IDEA 的项目编码统一改成 UTF-8 就好。Windows 系统下如果只改单个文件,运行起来很容易因为编译字节码和源码编码不一致出现乱码。
4.3 余票扣减的并发问题:为什么必须用事务加锁
这是面试官最爱追问的点。假设余票只剩 1 张,两个用户同时点购买,如果不加处理,可能两个人都查询到有余票,然后都执行了扣减操作,最终余票变成 -1,但两个人都收到了购票成功提示——这就是典型的超卖。
我在 2.2 部分写的SELECT ... FOR UPDATE就是解决这个问题。这条 SQL 会锁住选中那一行,第二个事务必须等第一个事务提交或回滚之后才能读到这行数据。配合事务的原子性,就能保证余票扣减和订单生成要么一起成功,要么一起失败。
当然这只是单机场景的解决方案,真实的高并发系统会用 Redis 分布式锁、消息队列等手段,但 Demo 阶段理解行锁和事务就足够了。你可以做一个实验加深印象:在购票代码里加个Thread.sleep(5000),然后开两个窗口同时买最后一趟车的票,看看第二个窗口是不是要等第一个事务结束才返回结果。
4.4 其他典型报错速查
java.sql.SQLException: No suitable driver found:驱动类没有加载,检查Class.forName是否执行,jar 包路径是否正确。OutOfMemoryError: insufficient memory:JVM 内存不足,Eclipse/IDEA 里调大启动参数-Xmx512m,或者可能是循环构造大数据集合导致内存泄漏。Column 'xxx' not found:SQL 里字段名写错了。建议 SQL 语句里的字段一律和表结构保持一致,别用驼峰映射的思维去写,因为 JDBC 不会帮你自动转换。- 窗口打开后中文按钮全部变成“口口”:字体问题。Windows 下指定
new Font("微软雅黑", Font.PLAIN, 14)就能解决。
写在最后:这个项目的后续扩展思路
项目做到能跑通、能正常买票退票,已经算完成了,但如果你想拿它去面试或者在简历上多写两行,可以试试这几个扩展方向:
第一,把 JDBC 换成 MyBatis 或 Spring JDBC,对比一下开发效率的差异,这个经历能体现你的学习能力。第二,增加一个简单的管理端,让管理员能添加车次、修改票价,这就多了一层角色权限的设计。第三,把单线程的 Swing 重构成 JavaFX,界面美观度会提升一大截。
我个人当时做这个项目最大的收获不是学会了 Swing 组件怎么用,而是第一次理解了“一个功能从点击按钮到数据库返回结果”的完整链路。中间遇到的每一个报错,本质上都是对某个知识点理解不到位,排查的过程比写代码本身更有学习价值。你把这个项目从头到尾自己敲一遍,再试着改几个功能,Java 基础和 MySQL 操作基本就稳了。
本文还有配套的精品资源,点击获取