简介:车站售票管理系统(广工数据库系统课程设计个人方案)面向广东工业大学数据库课设及同类课程设计学习者,围绕售票、退票、车次与时刻表查询统计、系统维护等业务展开,帮助读者理解从需求分析到数据库应用实现的完整流程。压缩包共100个文件,约4.65MB,以72个class与21个java源码为主体,另含1个sql建库脚本、doc安装说明书及项目配置与依赖文件,便于还原工程结构、查看功能模块和对照数据库设计。源码模块涉及购票退票、车次信息维护、用户注册与权限管理、查询统计及数据备份恢复,配合建库脚本和部署说明,可作为课设选题参考、源码研读与二次开发基础。已有1159人学习,适合需要完整案例、可运行工程与数据库设计参照的课程设计者。
1. 车站售票管理系统:一份数据库课设真正卡人的地方
答辩前一周,一个同学把程序发给我看:车站售票管理系统,Java 写的桌面端,点两下就出票,界面上车次、余票、订单列表都有,看着挺像回事。我让他开两个客户端,同时抢同一趟车最后一张二等座,结果两边都弹出“购票成功”,订单表里出现了两个一模一样的座位号。这就是数据库课设最容易出问题的地方:表建了、外键写了、增删改查都能跑,但事务边界和并发控制没人管,一压就露馅。
这套资源就是一份完整的车站售票管理系统数据库系统课程设计,Swing 做界面、JDBC 直连 MySQL,功能覆盖车次查询、余票展示、购票下单、订单管理、退票改签和后台车次维护,带建库脚本和初始化测试数据。适合正在赶数据库课设、想交一份能跑通也能讲清 ER 图和事务的同学;也适合想拿它当练手底子,往接口版方向改造的人。
2. 技术选型与表结构:Swing + JDBC + MySQL 这套组合为什么够用
2.1 为什么是这套技术栈
课设项目的技术选型标准跟生产项目不一样。生产上我会先看团队栈、部署环境和后续维护成本;课设阶段,我一般只看两条:能不能在一台没装过开发环境的笔记本上一次跑起来,以及代码量能不能在两周内写完还能讲明白。
Swing 就是为这两条服务的。它不用起 Web 容器,不用配 Tomcat,Main方法一跑窗口就出来,答辩时老师插上 U 盘双击就能演示,不会有“服务没起来”这种意外。JDBC 直连 MySQL 的好处是 SQL 全写在明面上,SELECT、UPDATE、事务提交回滚都在代码里看得见,答辩被问到“你的事务是怎么控制的”,翻到BookingService那一页就能指给老师看。换成 MyBatis 或者 JPA,SQL 被框架藏起来,反而不好讲。
代价也很清楚:JDBC 样板代码多,一个查询要写PreparedStatement、ResultSet、手动映射,DAO 层会写得很啰嗦;Swing 的表格模型和事件监听也不够现代。但这些属于“写起来累”,不属于“跑不起来”,对课设场景来说是划算的交换。常见做法是把 JDBC 的重复部分收进一个BaseDao,把ResultSet到实体的映射抽成一个方法,DAO 里只留 SQL 和参数。
2.2 目录结构:分层不用多,四层足够
这份资源的工程结构大致是这样组织的,包名我做过去标识化处理:
ticket-system/ ├── pom.xml # Maven 依赖:mysql-connector、lombok 可选 ├── sql/ │ ├── schema.sql # 建库建表 + 索引 + 唯一约束 │ └── init_data.sql # 车站、车次、余票、测试用户 └── src/main/ ├── java/com/demo/ticket/ │ ├── Main.java # 启动入口,拉起登录窗 │ ├── ui/ # Swing 窗体:查询、下单、订单、后台 │ ├── service/ # 业务与事务边界,事务只在这一层开 │ ├── dao/ # 纯 SQL,不写业务判断 │ ├── entity/ # Train、SeatStock、TicketOrder 等 POJO │ └── util/ # DBUtil、日期工具、订单号生成 └── resources/ └── db.properties # 连接串、账号密码,跟 jar 一起打包关键约束只有一条:事务只能在 service 层开,dao 层不许改autoCommit。我见过不少课设代码在 DAO 里conn.commit(),结果退票时先提交了订单状态、再扣余票,中间报错就出现“票退了但座位没回来”。分层不是为了好看,是为了让事务有唯一入口。
2.3 三张核心表:把并发问题在表结构上先解决一半
余票表和订单表是整个系统的关键,其余的车站表、用户表都是陪衬。下面这段 DDL 是能直接跑的版本,注释里写了每个字段为什么存在:
-- 车次表:一条记录代表一趟车的固定信息,不含日期 CREATE TABLE train ( train_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(10) NOT NULL, -- 车次号,如 G1234 start_station VARCHAR(32) NOT NULL, end_station VARCHAR(32) NOT NULL, depart_time TIME NOT NULL, arrive_time TIME NOT NULL, run_days TINYINT NOT NULL DEFAULT 127, -- 位图,bit0=周一开行 UNIQUE KEY uk_train_no (train_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 余票表:真正被并发争抢的那张表,一天一车次一席别一行 CREATE TABLE seat_stock ( stock_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, travel_date DATE NOT NULL, seat_type CHAR(1) NOT NULL, -- 1 硬座 2 硬卧 3 二等座 4 一等座 price DECIMAL(8,2) NOT NULL, total INT NOT NULL DEFAULT 0, sold INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_train_date_type (train_id, travel_date, seat_type), KEY idx_train_date (train_id, travel_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:uk_seat 是防重复售卖的兜底,别省 CREATE TABLE ticket_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, stock_id BIGINT NOT NULL, seat_no VARCHAR(8) NOT NULL, -- 08车12A amount DECIMAL(8,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已出票 2已退票 3已改签 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_seat (stock_id, seat_no), KEY idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个设计点值得单独说。余票不存“每一张票一行”,而是“一个车次+日期+席别一行,记总数和已售数”,这样查询余票就是一次单行读,比COUNT(*)快一个量级,缺点是座位号要靠订单表反查,属于典型的时间和空间换位。
uk_seat (stock_id, seat_no)这条唯一索引是最后一道防线。即使业务代码写错、事务漏了、并发没控住,数据库也会在插入时直接报Duplicate entry把脏数据挡在门外。做课设时我强烈建议保留它,答辩时老师问“怎么保证同一个座位不卖两次”,这条索引比任何口头解释都有说服力。
version字段是为乐观锁留的口子,课设里用不上也没关系,后面改造成接口版、加缓存的时候会用到。留着不占地方,删了以后补字段要改一堆 SQL。
3. 核心链路拆解:一次购票请求从点按钮到落订单
3.1 余票扣减:别先查再改
新手写购票最常见的写法是两步:先SELECT sold, total FROM seat_stock WHERE stock_id = ?,在 Java 里判断sold < total,再UPDATE seat_stock SET sold = sold + 1。这两条 SQL 分开执行,中间那段空隙足够另一个客户端把同一张票买走,超卖就是这么来的。我把这类写法叫“先查后改”,血泪经验是:只要判断条件和修改动作不在同一条 SQL 里,它就一定会出问题。
正确做法是把判断塞进WHERE,让数据库在一次原子操作里完成“判断 + 扣减”:
// dao/SeatStockDao.java public class SeatStockDao { /** * 扣减余票:sold < total 写在 WHERE 里,由 InnoDB 行锁保证原子性 * @return 影响行数,0 表示余票不足 */ public int deduct(Connection conn, long stockId) throws SQLException { String sql = "UPDATE seat_stock SET sold = sold + 1 " + "WHERE stock_id = ? AND sold < total"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, stockId); // 参数顺序别写反,这里只有 1 个 return ps.executeUpdate(); } } }这里conn是从外面传进来的,DAO 自己不开连接也不提交,事务由 service 层掌控。sold < total命中时会锁住这一行,第二个事务进来只能等,等第一个提交后重新判断条件,发现sold已经等于total,影响行数就是 0,直接返回“余票不足”。
返回值和异常要分开处理:0是业务上的“卖完了”,属于正常分支,不该抛异常打日志;只有SQLException才是真出错了。这一点在答辩时被问到的概率不低。
3.2 订单落库:事务边界画在 service 层
扣余票和写订单必须同生共死。只扣了余票没写订单,座位就凭空消失;只写了订单没扣余票,就超卖。所以事务的开启点必须在 service 层的入口方法上,setAutoCommit(false)之后所有操作共用一个Connection:
// service/BookingService.java public class BookingService { private final SeatStockDao stockDao = new SeatStockDao(); private final OrderDao orderDao = new OrderDao(); /** * 购票下单 * @param userId 当前登录用户 * @param stockId 余票行主键(车次 + 日期 + 席别唯一确定) * @param seatNo 选中的座位号,如 "08车12A" * @return 订单号 */ public String book(long userId, long stockId, String seatNo) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 事务从这里开始,dao 层不许再改 if (stockDao.deduct(conn, stockId) == 0) { conn.rollback(); throw new SoldOutException("该席别余票不足,请换一趟车"); } String orderNo = OrderNoGen.next(); // 时间戳 + 用户尾号 + 随机位 orderDao.insert(conn, orderNo, userId, stockId, seatNo); conn.commit(); return orderNo; } catch (SQLException | SoldOutException e) { if (conn != null) conn.rollback(); // 任何一步失败整单回滚 throw e; } finally { if (conn != null) { conn.setAutoCommit(true); // 归还连接前恢复默认状态 conn.close(); } } } }seatNo是界面上先查已占座位、再挑一个空位算出来的。查已占座位的 SQL 很短:
SELECT seat_no FROM ticket_order WHERE stock_id = ? AND status IN (0, 1) -- 待支付和已出票都算占用这套“先查空位再插入”的做法在并发下仍然可能撞车,两个客户端同时挑中 12A。不用怕,uk_seat唯一索引会拒绝第二条插入,业务层捕获DuplicateKeyException后提示“座位已被占用,请重选”即可。这就是前面说的兜底:业务逻辑负责体验,数据库约束负责正确性,两层都要有。
finally里恢复autoCommit这一步很多人会漏。连接如果是复用的连接池,带着autoCommit=false还回去,下一次借到它的线程所有 SQL 都不会自动提交,事后排查起来极难,属于典型的黑匣子故障。
3.3 退票改签:状态机 + 幂等
退票比购票更容易写错,错在“重复退”。用户手快连点两次,或者网络超时重发,第二次退票如果还执行一遍“余票 +1”,库存就多出来了。解决办法是把订单状态当成状态机来用,更新时用条件判断,看影响行数决定后续动作:
// service/RefundService.java public boolean refund(long orderId) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 只有已出票(status=1)的订单能退,条件写在 WHERE 里保证幂等 String sql = "UPDATE ticket_order SET status = 2 WHERE order_id = ? AND status = 1"; int rows; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, orderId); rows = ps.executeUpdate(); } if (rows == 0) { // 已经退过或状态不对,直接返回 conn.rollback(); return false; } // 余票归还,sold > 0 兜住异常数据,避免扣成负数 String back = "UPDATE seat_stock s JOIN ticket_order o ON s.stock_id = o.stock_id " + "SET s.sold = s.sold - 1 WHERE o.order_id = ? AND s.sold > 0"; try (PreparedStatement ps = conn.prepareStatement(back)) { ps.setLong(1, orderId); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }改签本质上是一次“退票 + 重新购票”,但必须放进同一个事务。中途失败要能回到原状态,否则用户会出现“旧票退了、新票没买上”的尴尬局面。课设里的简化做法是:先把原订单置为status=3,再调用购票流程;如果购票抛异常,整个事务回滚,原订单状态恢复。测试这个分支的方法是临时把余票表的total改小,制造必然失败,看原订单有没有被改回去。
退款金额和手续费规则在课设里通常按固定比例(开车前 24 小时以上退 5% 之类),写在service层的一个规则类里,别塞进 SQL。规则会变,SQL 不擅长表达分支。
4. 从零跑起来:建库、灌数据、改连接串、启动客户端
4.1 环境准备:版本对齐是第一道关
这套代码依赖三样东西:JDK 8 或以上、Maven 3.6+、MySQL 5.7 或 8.0。三个都装好后,先确认版本,尤其是 MySQL 驱动版本和数据库版本要能对上:
java -version mvn -v mysql --versionMySQL 8.0 必须用mysql-connector-java 8.x(新坐标是com.mysql:mysql-connector-j),驱动类是com.mysql.cj.jdbc.Driver;5.7 可以用 5.1.x,驱动类是com.mysql.jdbc.Driver。驱动和数据库版本不匹配时,报错信息往往跟网络、权限毫无关系,却让人往那个方向排查半天,这是课设里踩得最多的一类坑。
4.2 建库灌数据:三条命令加一次验证
建库语句里字符集必须显式写utf8mb4,不写会走服务器默认值,某些安装包默认还是latin1,车站名写进去就变问号:
# 1. 建库,显式指定字符集,别依赖服务端默认值 mysql -uroot -p -e "CREATE DATABASE ticket_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 建表:包含唯一索引和字符集设置 mysql -uroot -p ticket_db < sql/schema.sql # 3. 灌测试数据:车站、30 天内车次、余票、两个测试账号 mysql -uroot -p ticket_db < sql/init_data.sql # 4. 验证:能查到数据说明前面三步都成了 mysql -uroot -p ticket_db -e "SELECT train_no,start_station,end_station,depart_time FROM train LIMIT 5;"第 3 步的初始化脚本里,余票数据要覆盖“最近 30 天 × 若干车次 × 若干席别”,否则演示时选个远一点的日期全是零,看起来像程序坏了。用一个存储过程或者一段循环INSERT批量生成最省事:
-- 生成未来 30 天的余票,从第 1 天开始,每趟车每个席别一行 INSERT INTO seat_stock (train_id, travel_date, seat_type, price, total, sold) SELECT t.train_id, DATE_ADD(CURDATE(), INTERVAL d.n DAY), s.seat_type, s.base_price, s.capacity, 0 FROM train t CROSS JOIN (SELECT '3' AS seat_type, 99.00 AS base_price, 120 AS capacity UNION ALL SELECT '4', 158.00, 40) s CROSS JOIN ( SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 -- 实际脚本里展开到 29,这里省略重复项 ) d;CROSS JOIN就是笛卡尔积,车次数 × 席别数 × 天数一次性铺满,比手写几十条INSERT靠谱。sold全部置 0,演示时先卖几张再看余票变化,效果直观。
4.3 连接配置与启动
连接串集中放在resources/db.properties,账号密码不要硬编码进 Java 代码,不然换台机器就要重新编译:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/ticket_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.user=root jdbc.password=你的密码serverTimezone=Asia/Shanghai在 MySQL 8 上几乎是必填项,漏了会抛The server time zone value '...' is unrecognized,这类报错看着吓人,其实是配置漏了一项。allowPublicKeyRetrieval=true是 8.0 默认加密插件下免密登录可能遇到的坑,本地开发开着无妨。
DBUtil用静态块加载配置,只在类第一次被使用时执行一次:
// util/DBUtil.java public final class DBUtil { private static final Properties P = new Properties(); static { try (InputStream in = DBUtil.class.getClassLoader().getResourceAsStream("db.properties")) { if (in == null) throw new IllegalStateException("classpath 下找不到 db.properties"); P.load(in); // 跟着 jar 走,不依赖工作目录 Class.forName(P.getProperty("jdbc.driver")); // 显式注册驱动,避免 SPI 失效 } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置加载失败: " + e.getMessage()); } } private DBUtil() {} public static Connection getConnection() throws SQLException { return DriverManager.getConnection( P.getProperty("jdbc.url"), P.getProperty("jdbc.user"), P.getProperty("jdbc.password")); } }这里用getResourceAsStream而不是new FileInputStream("db.properties"),是因为后者依赖启动时的工作目录,换个 shell 或者打成 jar 就找不到文件。课设演示经常是“在我电脑上好好的,拷到老师机器上就报错”,问题多半出在这。
打包运行:
mvn clean package java -jar target/ticket-system-1.0.jar启动后先登录测试账号,查一趟今天的车,买一张票,进订单页看到记录,再退掉,回余票页确认数字变化。这一圈走通,才算真的跑起来了。
5. 避坑排查:课设跑不起来、演示翻车的五个现场
5.1 连接与驱动类问题
现象:启动就抛No suitable driver found for jdbc:mysql://127.0.0.1:3306/ticket_db。原因:驱动类名和驱动包版本对不上,或者依赖没打进最终 jar,mvn clean package出来的包里没有mysql-connector。解决:先看pom.xml里是 5.1.x 还是 8.x,5.1 用com.mysql.jdbc.Driver,8.x 用com.mysql.cj.jdbc.Driver;再用maven-shade-plugin或者maven-assembly-plugin把依赖打进去,别用默认的jar目标,它不管依赖。
现象:报The server time zone value is unrecognized。原因:MySQL 8 要求连接串显式声明时区。解决:在 URL 后面补serverTimezone=Asia/Shanghai,同时加上useSSL=false消掉一堆安全警告。
5.2 字符集与数据问题
现象:车次表里查出来的车站名是????,或者显示成乱码方块。原因:三个地方的字符集没对齐——库、表、连接。任何一层是latin1,中文就过不去。解决:建库时写死DEFAULT CHARSET utf8mb4,建表时每张表也写上,连接串里带useUnicode=true&characterEncoding=utf8。三处都改完重启客户端才生效,只改一处白忙。
现象:查询“今天出发”的车次,明明有数据却查不出来。原因:travel_date是DATE类型,界面上传的是带时分秒的Timestamp,WHERE travel_date = '2024-05-01 10:00:00'匹配不上任何行。解决:Java 侧统一用LocalDate,ps.setObject(i, date);SQL 侧要兼容的话写成WHERE travel_date = DATE(?)。日期类型不一致这类问题不报错,只是默默返回空列表,最耗时间。
5.3 演示现场最容易翻的两处
现象:两台机器同时买同一张票,都成功,订单表出现重复座位。原因:先SELECT判断余票、再UPDATE扣减,两条语句中间没锁。解决:改成UPDATE ... WHERE sold < total看影响行数,再加上uk_seat唯一索引兜底,两个手段一起上。
现象:退票后余票数字没变,或者退一次数字减了两次。原因:退票没做状态条件更新,重复执行时余票被反复归还;或者余票归还和订单状态更新不在同一个事务里。解决:UPDATE ticket_order SET status = 2 WHERE order_id = ? AND status = 1,靠影响行数判断是否首次退票,并把归还余票放进同一个Connection的事务里。
提示:这五条里,前四条是环境问题,第五条是逻辑问题。环境问题半小时能解决,逻辑问题往往要演示当场才暴露,答辩前至少用两台机器同时点一遍购票按钮。
6. 二次开发:把课设改造成能写进简历的版本
课设交完不该就扔。这套代码的骨架是对的——分层清楚、事务集中、有唯一约束兜底——从它出发做三处改造,就能变成一个能在面试里讲二十分钟的项目。
第一处是 DAO 层重写。现在是手写 JDBC,ResultSet到实体的映射代码占了一大半。换成 MyBatis 或者 Spring JDBC 的JdbcTemplate,SQL 保留在 XML 或注解里,业务代码能瘦一半。改造时注意一点:事务注解和 JDBC 手动提交不能混用。如果你已经引入 Spring 的@Transactional,就必须把conn.setAutoCommit(false)那些代码删干净,否则两个事务管理器打架,会出现“方法抛出异常但数据已提交”的情况。我一般会先把BookingService单独改成注解式事务,跑通购票和退票两条链路,再批量改其它 service。
第二处是余票查询加速。车次查询是典型的读多写少,每次打开界面都去JOIN三张表,几十条数据看不出来,加到几千条就明显卡。常见做法是在 service 层加一层本地缓存(ConcurrentHashMap+ 过期时间),或者接 Redis 存“车次 + 日期”的余票快照,扣减时同步失效。这里有个必须注意的点:缓存只能用于展示,扣减余票一定要打到数据库。我吃过这个亏——缓存里的余票数当作判断依据,结果缓存和库里的值不同步,超卖了三十多张。验证方法是写一个并发测试:用CountDownLatch起 50 个线程抢 10 张票,看最终成功数和sold是否都等于 10。
// 并发验证:50 个线程抢同一批余票,结果必须恰好等于 total int threads = 50; CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threads); AtomicInteger success = new AtomicInteger(); for (int i = 0; i < threads; i++) { new Thread(() -> { try { start.await(); // 一起放行,制造真实争抢 service.book(userId, stockId, nextSeatNo()); success.incrementAndGet(); } catch (SoldOutException e) { // 余票不足属于预期分支,不计入成功数 } catch (Exception e) { e.printStackTrace(); } finally { done.countDown(); } }).start(); } start.countDown(); done.await(); System.out.println("成功 " + success.get() + " 张,总数应为 " + total);跑出来的success必须严格等于total,多一张说明超卖,少一张说明有票没卖出去(通常是锁等待超时被吞掉了异常)。这个测试跑通,再讲并发控制就有底气了。
第三处是加一层 HTTP 接口。Swing 桌面端在简历上说服力有限,把 service 层套一个 Spring Boot 的@RestController,暴露GET /trains、POST /orders、DELETE /orders/{id}三个接口,用 Postman 能调通,项目性质就变了——从“课设”变成“带并发控制的售票服务”。改造时把BookingService原样注入进来就行,界面层可以留着,也可以删掉。接口化之后,前面那个 Redis 缓存和并发测试才有地方施展,否则都是纸上谈兵。
最后说个习惯问题。我最早做这类项目时,总喜欢在演示前一天集中改代码,改完只点两下界面觉得没问题就睡了,第二天现场翻车。后来强制自己定规矩:任何涉及事务和唯一约束的改动,必须先跑一遍并发测试,再用两台机器手动点一遍,两个都过才算改完。从那以后我每次动BookingService和seat_stock相关的 SQL,都强制走一遍这个流程,宁可少改一个功能,也不在演示现场赌运气。这个习惯比任何技术点都值钱,希望帮到你。
本文还有配套的精品资源,点击获取