简介:这份资源是广东工业大学数据库系统课程设计的个人选题方案——车站售票管理系统,面向正在准备数据库课设的本科生及需要Java+数据库综合练习的开发者。系统围绕售票与退票核心业务展开,涵盖车次查询、时刻表查询、售票情况统计等常用功能,并实现数据备份与恢复、操作员管理、权限设置等维护模块,可作为课设参考或二次开发基础。压缩包共100个文件,约4.65MB,其中72个class与21个java构成完整可运行的Java工程,另含1个sql建库脚本、1个jar依赖及doc安装说明书、txt说明等,源码与文档配套齐全。目前已有1154人学习下载,说明该方案在同类课设中具有较高参考价值。读者可据此快速理解售票系统的表结构设计、功能划分与实现思路,对照安装说明完成环境部署,并在此基础上调整需求或扩展统计查询模块,节省从零搭建的时间成本。
1. 车站售票管理系统:广工数据库课设到底在考什么
广工数据库课设选「车站售票管理系统」这个题,表面上是让你做一个卖票的小软件,实际上老师想看的是你能不能把数据库这一套东西真正用起来——建库建表、写约束、做增删改查、处理并发、写存储过程,最后再套一个能跑起来的前端。很多同学一上来就急着写界面,结果表结构设计得一塌糊涂,后面改到崩溃。我带过几届做这个题的人,血泪经验就一句话:先把 ER 图和表结构定死,再动手写代码。这个系统典型角色有三类:乘客查票买票退票、售票员开窗卖票、管理员维护车次和站点。核心业务是「一趟车次在某个日期某个区间还剩几张票」,难点全在余票的并发扣减和订单状态流转上。适合正在做课设、想拿高分又不想返工的同学,也适合想借这个题把 MySQL 增删改查和事务真正练一遍的人。下面我按「设计 → 建库 → 核心逻辑 → 避坑 → 进阶」的顺序,把能直接抄作业的东西讲清楚。
2. 表结构怎么设计才不会被老师打回
2.1 先画 ER 图,再定五张核心表
这个题最容易翻车的地方就是表设计。很多人把「车次」和「余票」混在一张表里,结果一改车次就要动一堆数据。正确的拆法是按实体拆:车站、车次、车次经停站、订单、乘客。车次和车站是多对多关系,中间用「经停站表」拆开,同时把区间票价和到达时间挂在这张中间表上。订单表关联乘客和具体车次,余票不单独存一张表,而是通过「车次总座位数 - 已售订单数」实时算,或者用一张余票表加乐观锁。我一般推荐后者,因为课设答辩时老师爱问并发,有张余票表好讲。
下面是我常用的建表顺序,先建被引用的表,再建引用别人的表,避免外键报错:
-- 车站表:所有站点的基础信息 CREATE TABLE station ( station_id INT PRIMARY KEY AUTO_INCREMENT, station_name VARCHAR(50) NOT NULL UNIQUE, city VARCHAR(50) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 车次表:一趟车的整体信息 CREATE TABLE train ( train_id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL UNIQUE, -- 如 G1234 start_station_id INT NOT NULL, end_station_id INT NOT NULL, depart_time TIME NOT NULL, total_seats INT NOT NULL DEFAULT 500, FOREIGN KEY (start_station_id) REFERENCES station(station_id), FOREIGN KEY (end_station_id) REFERENCES station(station_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:station表用station_name做唯一约束,防止同一个站被录两次。train表里total_seats是这趟车的总定员,后面算余票要用。参数上train_no设成唯一,是因为现实里车次号不会重复,老师也爱拿这个考你唯一约束。ENGINE=InnoDB必须写,MyISAM 不支持事务,后面讲并发扣票直接没法做。
2.2 经停站表和订单表是重头戏
经停站表决定了区间怎么算。一趟车从 A 到 D,中间经停 B、C,那 A→B、A→C、B→D 都是合法区间。这张表要存站序,靠站序判断谁在前谁在后:
-- 车次经停站表:记录每趟车经过哪些站、第几站、从起点算的累计里程 CREATE TABLE train_stop ( stop_id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, station_id INT NOT NULL, stop_order INT NOT NULL, -- 站序,从 1 开始 arrive_time TIME, depart_time TIME, mileage INT DEFAULT 0, -- 累计里程,用来算票价 UNIQUE KEY uk_train_order (train_id, stop_order), FOREIGN KEY (train_id) REFERENCES train(train_id), FOREIGN KEY (station_id) REFERENCES station(station_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:一张票一条记录 CREATE TABLE ticket_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, passenger_id INT NOT NULL, from_stop INT NOT NULL, -- 上车站序 to_stop INT NOT NULL, -- 下车站序 seat_no VARCHAR(10), price DECIMAL(8,2) NOT NULL, status TINYINT DEFAULT 1, -- 1已支付 2已退票 3已改签 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (train_id) REFERENCES train(train_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:train_stop上的uk_train_order唯一键保证同一趟车不会有两个「第 3 站」,这是数据一致性的关键。ticket_order里用from_stop和to_stop存站序而不是站 ID,是为了后面判断区间重叠时直接比大小,省一次关联查询。status用 TINYINT 而不是字符串,省空间也方便加索引。参数上price用DECIMAL(8,2),别用 FLOAT,金额算着算着就出现 0.0000001 的误差,答辩被问到很尴尬。
2.3 余票表加唯一约束防超卖
余票如果每次现算,高并发下会超卖。我一般单独建一张按「车次 + 日期 + 区间」粒度的余票表,并加唯一约束:
CREATE TABLE seat_inventory ( inv_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, travel_date DATE NOT NULL, from_stop INT NOT NULL, to_stop INT NOT NULL, remain INT NOT NULL, version INT DEFAULT 0, -- 乐观锁版本号 UNIQUE KEY uk_inv (train_id, travel_date, from_stop, to_stop) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:uk_inv保证同一趟车同一天同一区间只有一条余票记录,扣票时用UPDATE ... WHERE remain >= 1或版本号乐观锁。version字段是给乐观锁用的,后面第 4 章会讲怎么用。这张表的数据可以在生成车次时批量初始化,也可以懒加载——第一次卖某区间票时再插入。
3. 增删改查和事务:把卖票逻辑写对
3.1 查询余票和区间判断的 SQL 怎么写
乘客查票的本质是:给定出发站、到达站、日期,找出所有经停这两站且站序满足 from < to 的车次,再关联余票。核心 SQL 如下:
-- 查询 2025-06-01 从广州到武汉的所有车次及余票 SELECT t.train_no, s1.station_name AS from_station, s2.station_name AS to_station, ts1.depart_time, ts2.arrive_time, (ts2.mileage - ts1.mileage) * 0.5 AS price, inv.remain FROM train_stop ts1 JOIN train_stop ts2 ON ts1.train_id = ts2.train_id AND ts1.stop_order < ts2.stop_order JOIN train t ON t.train_id = ts1.train_id JOIN station s1 ON s1.station_id = ts1.station_id JOIN station s2 ON s2.station_id = ts2.station_id LEFT JOIN seat_inventory inv ON inv.train_id = t.train_id AND inv.travel_date = '2025-06-01' AND inv.from_stop = ts1.stop_order AND inv.to_stop = ts2.stop_order WHERE s1.station_name = '广州' AND s2.station_name = '武汉';逻辑说明:ts1和ts2是同一张经停站表的两次自连接,ts1.stop_order < ts2.stop_order保证出发站在前。票价这里用里程差乘 0.5 简单模拟,真实项目会有分段计价表。LEFT JOIN余票表是因为可能还没人买过这个区间,余票记录不存在,用 LEFT JOIN 让它显示 NULL,前端再显示「有票」。参数上travel_date是查询条件,实际项目里要加索引(train_id, travel_date),不然数据一多全表扫。
3.2 下单扣票必须放在一个事务里
卖票最怕的就是「扣了票没生成订单」或者「生成了订单没扣票」。这两步必须在一个事务里,要么都成功,要么都回滚:
START TRANSACTION; -- 1. 扣减余票,remain >= 1 才允许扣,防止扣成负数 UPDATE seat_inventory SET remain = remain - 1, version = version + 1 WHERE train_id = 1001 AND travel_date = '2025-06-01' AND from_stop = 1 AND to_stop = 3 AND remain >= 1; -- 2. 检查上一步是否真的扣到了(受影响行数) -- 如果 affected_rows = 0,说明没票了,回滚 -- 3. 插入订单 INSERT INTO ticket_order (train_id, passenger_id, from_stop, to_stop, price, status) VALUES (1001, 2001, 1, 3, 120.00, 1); COMMIT;逻辑说明:UPDATE ... WHERE remain >= 1是防超卖的第一道防线,数据库行锁保证同一行不会被两个事务同时扣。第二步在应用层判断affected_rows,如果是 0 就ROLLBACK并提示「余票不足」。参数上version自增是为了配合乐观锁,如果不用乐观锁可以去掉。注意START TRANSACTION和COMMIT之间不要做网络请求或耗时操作,否则锁持有时间太长,并发一高就死锁。
3.3 退票和改签的状态流转
退票不是删订单,是把status改成 2,同时把余票加回去。改签更复杂,要先退旧票再占新票,两个操作也要在一个事务里:
START TRANSACTION; -- 退票:状态改为已退票,余票加回 UPDATE ticket_order SET status = 2 WHERE order_id = 5001 AND status = 1; UPDATE seat_inventory SET remain = remain + 1 WHERE train_id = 1001 AND travel_date = '2025-06-01' AND from_stop = 1 AND to_stop = 3; COMMIT;逻辑说明:WHERE status = 1保证只有已支付的票能退,重复退票第二次affected_rows为 0,应用层据此提示「该票已退」。余票加回时不用判断remain >= 1,因为加不会超。参数上退票和改签建议加一个「退票记录表」留痕,答辩时老师问「退票历史怎么查」你能答上来。
4. 并发扣票和常见坑:课设答辩最爱问的地方
4.1 超卖是怎么发生的,怎么复现
超卖的经典场景:两个乘客同时买最后一张票。如果代码写成「先 SELECT 查余票,再 UPDATE 扣减」,两个事务都查到 remain=1,都以为有票,结果扣成 -1。复现方法很简单,开两个 MySQL 客户端,手动模拟:
-- 会话 A START TRANSACTION; SELECT remain FROM seat_inventory WHERE inv_id = 1; -- 查到 1 -- 先不提交 -- 会话 B START TRANSACTION; SELECT remain FROM seat_inventory WHERE inv_id = 1; -- 也查到 1 UPDATE seat_inventory SET remain = remain - 1 WHERE inv_id = 1; COMMIT; -- 回到会话 A UPDATE seat_inventory SET remain = remain - 1 WHERE inv_id = 1; COMMIT; -- 结果 remain = -1,超卖原因就是「查」和「改」之间没有锁住。解决办法有两个:一是把扣减写成UPDATE ... WHERE remain >= 1原子操作,二是用SELECT ... FOR UPDATE悲观锁。课设里推荐第一种,简单且够用。
4.2 避坑清单:五个我踩过的坑
坑一:外键顺序建反导致建表失败。现象是ERROR 1215: Cannot add foreign key constraint。原因是先建了ticket_order再建train,引用了一张还不存在的表。解决是按依赖顺序建表,或者先建所有表再统一ALTER TABLE ADD FOREIGN KEY。
坑二:用 FLOAT 存票价出现精度误差。现象是两张票加起来 119.99999。原因是 FLOAT 是二进制浮点,存不了精确小数。解决是金额一律用DECIMAL(8,2),Java 侧用BigDecimal接收。
坑三:事务里做了耗时操作导致锁等待超时。现象是并发测试时报Lock wait timeout exceeded。原因是在START TRANSACTION和COMMIT之间调了外部接口或打印了大量日志。解决是把非数据库操作挪到事务外,事务里只留 SQL。
坑四:余票表没加唯一约束,同一区间出现多条记录。现象是查余票时remain对不上。原因是初始化脚本跑了两次。解决是加uk_inv唯一键,重复插入直接报错,逼你写INSERT ... ON DUPLICATE KEY UPDATE。
坑五:日期字段用 VARCHAR 存。现象是WHERE travel_date = '2025-6-1'查不到'2025-06-01'的数据。原因是字符串比较不做日期归一化。解决是老老实实用DATE类型,前端传参统一格式。
4.3 用 EXPLAIN 看你的查询有没有走索引
课设数据量小的时候查什么都快,但老师会问「数据量大了怎么办」。这时候你要能掏出EXPLAIN:
EXPLAIN SELECT * FROM ticket_order WHERE train_id = 1001 AND status = 1;如果type是ALL,说明全表扫,要加索引(train_id, status)。如果key显示你用上了索引,rows估算值很小,就说明没问题。参数上possible_keys列出候选索引,key是实际用的,Extra里出现Using filesort或Using temporary就要警惕,通常意味着排序或分组没走索引。
5. 存储过程和触发器:让课设多拿几分
5.1 写一个自动算票价的存储过程
老师喜欢看存储过程,因为能体现你会写 PL/SQL。下面这个按里程算票价,里程差每公里 0.5 元,最低 5 元:
DELIMITER // CREATE PROCEDURE calc_price( IN p_train_id INT, IN p_from_stop INT, IN p_to_stop INT, OUT p_price DECIMAL(8,2) ) BEGIN DECLARE v_mileage INT; SELECT (ts2.mileage - ts1.mileage) INTO v_mileage FROM train_stop ts1 JOIN train_stop ts2 ON ts1.train_id = ts2.train_id WHERE ts1.train_id = p_train_id AND ts1.stop_order = p_from_stop AND ts2.stop_order = p_to_stop; SET p_price = GREATEST(v_mileage * 0.5, 5.00); END // DELIMITER ;逻辑说明:DELIMITER //是为了让 MySQL 把整个存储过程当成一条语句,不然遇到分号就截断了。INTO v_mileage把查询结果赋给变量,GREATEST保证最低票价 5 元。调用方式是CALL calc_price(1001, 1, 3, @price); SELECT @price;。参数上OUT类型的参数用来返回结果,Java 侧用CallableStatement注册Types.DECIMAL接收。
5.2 触发器记录余票变更日志
答辩时如果老师问「怎么追踪余票变化」,触发器是加分项:
CREATE TABLE inventory_log ( log_id BIGINT PRIMARY KEY AUTO_INCREMENT, inv_id BIGINT, old_remain INT, new_remain INT, change_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; DELIMITER // CREATE TRIGGER trg_inventory_update AFTER UPDATE ON seat_inventory FOR EACH ROW BEGIN IF OLD.remain <> NEW.remain THEN INSERT INTO inventory_log (inv_id, old_remain, new_remain) VALUES (OLD.inv_id, OLD.remain, NEW.remain); END IF; END // DELIMITER ;逻辑说明:AFTER UPDATE表示更新之后触发,OLD和NEW分别代表改前和改后的行。IF判断只有余票真变了才记日志,避免无关更新刷屏。参数上触发器会增加写开销,课设数据量小无所谓,生产环境要谨慎,一般用应用层记日志替代。
5.3 用视图简化前端查询
前端不想写复杂 JOIN,可以建视图:
CREATE VIEW v_train_schedule AS SELECT t.train_no, s1.station_name AS from_station, s2.station_name AS to_station, ts1.depart_time, ts2.arrive_time, ts1.stop_order AS from_order, ts2.stop_order AS to_order FROM train_stop ts1 JOIN train_stop ts2 ON ts1.train_id = ts2.train_id AND ts1.stop_order < ts2.stop_order JOIN train t ON t.train_id = ts1.train_id JOIN station s1 ON s1.station_id = ts1.station_id JOIN station s2 ON s2.station_id = ts2.station_id;逻辑说明:视图把自连接逻辑封装起来,前端直接SELECT * FROM v_train_schedule WHERE from_station='广州'。参数上视图不存数据,每次查都执行底层 SQL,性能取决于底层索引。课设里用视图能让代码更干净,答辩也好讲。
6. 从课设到能跑的系统:连接池和压测小技巧
课设最后要交一个能演示的系统,很多人卡在「本地跑得好好的,一演示就卡」。问题多半出在数据库连接上。JDBC 每次DriverManager.getConnection都新建物理连接,几十个并发就顶不住。加一个连接池是性价比最高的优化,HikariCP 配置如下:
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/ticket_db?useSSL=false&serverTimezone=Asia/Shanghai"); config.setUsername("root"); config.setPassword("your_password"); config.setMaximumPoolSize(10); // 最大连接数,课设 10 够用 config.setMinimumIdle(2); // 最小空闲连接 config.setConnectionTimeout(3000); // 获取连接超时 3 秒 config.setIdleTimeout(60000); // 空闲连接 60 秒回收 HikariDataSource ds = new HikariDataSource(config);逻辑说明:maximumPoolSize不是越大越好,MySQL 默认最大连接 151,设太大反而拖慢。connectionTimeout设 3 秒,拿不到连接快速失败,别让请求堆着。参数上serverTimezone必须写,不然 MySQL 8 会报时区错误,这是新手最常见的翻车点。
压测不用上 JMeter,写个简单的多线程脚本就能验证扣票逻辑:
import threading, pymysql def buy_ticket(): conn = pymysql.connect(host='localhost', user='root', password='pwd', database='ticket_db') cur = conn.cursor() cur.execute("UPDATE seat_inventory SET remain = remain - 1 WHERE inv_id = 1 AND remain >= 1") if cur.rowcount == 1: cur.execute("INSERT INTO ticket_order (train_id, passenger_id, from_stop, to_stop, price) VALUES (1001, 1, 1, 3, 120)") conn.commit() conn.close() threads = [threading.Thread(target=buy_ticket) for _ in range(50)] for t in threads: t.start() for t in threads: t.join()逻辑说明:开 50 个线程同时抢同一张余票,跑完查remain和订单数,如果remain没变负、订单数等于初始余票数,说明扣票逻辑正确。参数上remain >= 1是防超卖的关键,去掉它再跑一次就能看到负数,这个对比演示在答辩时特别有说服力。
我自己的习惯是:每次改完扣票逻辑,先跑一遍这个 50 线程脚本,确认没超卖再往下做界面。课设这东西,界面丑一点老师能忍,数据错了直接挂。把表结构、事务、并发这三块啃下来,广工这个题拿高分不难。希望帮到你。
本文还有配套的精品资源,点击获取