简介:这份资源是广东工业大学数据库系统课程设计的个人选题方案——车站售票管理系统,面向正在准备数据库课设的本科生及需要参考完整项目实现的学习者。系统围绕售票与退票、车次与时刻表查询、售票统计以及数据备份恢复、操作员权限管理等维护功能展开,覆盖数据库设计到界面交互的完整链路。压缩包共100个文件,约4.65MB,以72个class与21个java源码为主体,另含1个sql建库脚本、jar依赖、classpath与project工程配置,以及doc安装说明书和txt说明,便于直接导入运行与二次修改。目前已有1157人学习下载。读者可据此获得一套可运行的课设参考实现、数据库建表脚本与安装文档,理解售票、查询、统计和权限模块的组织方式,为课程设计选题、答辩准备和功能扩展提供实用借鉴。
1. 车站售票管理系统:广工数据库课设到底在考什么
很多人拿到“车站售票管理系统”这个课设题目,第一反应是打开 IDE 开始画界面,结果做到一半发现数据库表都建不明白,查询语句写得像意大利面,最后答辩被老师问一句“你这个余票字段怎么保证不超卖”就当场卡壳。这个课设真正考的不是你会不会写 Java 或 Python,而是你能不能把现实世界里“买票、退票、改签、查余票”这套业务,翻译成一组干净、一致、能扛住并发的关系模型。它适合正在上数据库课程、需要交课设的学生,也适合工作几年但没系统梳理过事务与约束的开发者拿来补课。核心就三件事:表怎么设计、SQL 怎么写、并发怎么控。把这三件事做扎实,课设拿高分是顺带的,真正值钱的是你以后做任何交易类系统都能直接复用的建模直觉。
2. 从业务到表结构:车站售票管理系统的实体关系怎么拆
2.1 先画业务流,再谈范式
我一般会先拿一张纸,把乘客从“查车次”到“拿到票”的完整路径写下来:查余票 → 选车次 → 提交订单 → 支付 → 出票 → 可能退票或改签。每一步背后都对应一个数据动作,把这些动作里的名词圈出来,就是候选实体:乘客、车次、座位、订单、车票、支付记录。动词圈出来就是关系:乘客下单、订单包含车票、车票绑定座位、车次拥有座位。
这里有个新手最容易翻车的地方:把“车票”和“座位”合并成一张表。一旦合并,你就没法表达“同一个座位在不同日期卖给不同人”这件事,因为座位是物理资源,车票是某一天某一段行程的销售记录。正确做法是座位表只描述物理布局(车厢、排号、座号),车票表描述某次车次某天某座位的占用情况。这个拆分想清楚了,后面余票查询和防超卖都会顺很多。
2.2 三张核心表的字段与约束
下面是我在课设里最常用的最小可用表结构,直接给建表语句,字段含义和约束都写在注释里。
-- 车次表:描述一趟固定的列车班次 CREATE TABLE train ( train_id VARCHAR(10) PRIMARY KEY, -- 车次号,如 G1234 start_station VARCHAR(50) NOT NULL, -- 始发站 end_station VARCHAR(50) NOT NULL, -- 终到站 depart_time TIME NOT NULL, -- 发车时刻 arrive_time TIME NOT NULL, -- 到达时刻 total_seats INT NOT NULL CHECK (total_seats > 0) -- 定员 ); -- 座位表:描述某车次下的物理座位,与日期无关 CREATE TABLE seat ( seat_id INT PRIMARY KEY AUTO_INCREMENT, train_id VARCHAR(10) NOT NULL, carriage VARCHAR(5) NOT NULL, -- 车厢号 seat_no VARCHAR(5) NOT NULL, -- 座号 seat_type VARCHAR(10) NOT NULL, -- 商务/一等/二等 UNIQUE KEY uk_train_seat (train_id, carriage, seat_no), FOREIGN KEY (train_id) REFERENCES train(train_id) ); -- 车票表:某天某车次某座位的销售记录,核心业务表 CREATE TABLE ticket ( ticket_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id VARCHAR(10) NOT NULL, seat_id INT NOT NULL, travel_date DATE NOT NULL, -- 乘车日期 passenger_id VARCHAR(18) NOT NULL, -- 乘客证件号 status TINYINT NOT NULL DEFAULT 0, -- 0已售 1已退 2已改签 order_id BIGINT NOT NULL, UNIQUE KEY uk_seat_date (train_id, seat_id, travel_date), -- 防同一座位同日重复售出 FOREIGN KEY (seat_id) REFERENCES seat(seat_id) );逻辑说明:train和seat是一对多,seat和ticket通过seat_id关联,但真正决定“这个座位这天能不能卖”的是ticket上的uk_seat_date唯一索引。参数上,status用 TINYINT 而不是字符串,是为了索引效率和状态机判断方便;travel_date单独成列而不是塞进时间戳,是因为余票查询几乎永远按“日期 + 车次”过滤,拆出来能直接命中联合索引。
2.3 订单表与乘客表:别把证件号当主键
订单表负责把钱和票串起来,乘客表负责存常用联系人。这里有个血泪经验:不要拿身份证号当乘客表主键。证件号会变(比如升位、更正),而且长度不固定,做主键会让所有外键跟着遭殃。正确做法是给乘客一个自增或雪花 ID,证件号加唯一索引即可。
CREATE TABLE passenger ( passenger_id BIGINT PRIMARY KEY AUTO_INCREMENT, id_card VARCHAR(18) NOT NULL, name VARCHAR(30) NOT NULL, phone VARCHAR(15), UNIQUE KEY uk_id_card (id_card) ); CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, passenger_id BIGINT NOT NULL, train_id VARCHAR(10) NOT NULL, travel_date DATE NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_status TINYINT NOT NULL DEFAULT 0, -- 0未支付 1已支付 2已退款 create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (passenger_id) REFERENCES passenger(passenger_id) );amount用 DECIMAL 而不是 FLOAT,是因为金额做等值比较和求和时浮点误差会让你对不上账,这个坑在课设答辩时被问到会非常尴尬。
3. 余票查询与防超卖:车站售票管理系统最核心的两条 SQL
3.1 余票查询:用聚合还是用计数表
余票查询有两种常见做法。第一种是实时聚合:SELECT total_seats - COUNT(*) FROM ticket WHERE train_id=? AND travel_date=? AND status=0。第二种是维护一张余票计数表,下单时扣减。课设阶段我建议先用实时聚合,因为逻辑简单、不会出现计数表和真实票数不一致的玄学问题。等你要模拟高并发压测时,再引入计数表或 Redis 预扣。
-- 查询某车次某天的余票,按座位类型分组 SELECT s.seat_type, COUNT(*) AS total, SUM(CASE WHEN t.ticket_id IS NULL THEN 1 ELSE 0 END) AS remain FROM seat s LEFT JOIN ticket t ON t.seat_id = s.seat_id AND t.train_id = s.train_id AND t.travel_date = '2025-06-01' AND t.status = 0 WHERE s.train_id = 'G1234' GROUP BY s.seat_type;逻辑说明:用 LEFT JOIN 保证没卖出去的座位也参与统计,CASE WHEN把已售的标记出来,remain就是剩余。参数上,travel_date和train_id必须都能命中索引,否则数据量一上来查询会慢到你以为数据库挂了。建议在ticket上建联合索引(train_id, travel_date, status)。
3.2 防超卖:唯一索引兜底 + 事务包裹
防超卖是课设里最能体现水平的地方。很多人只写一个SELECT查余票,然后INSERT,这在并发下必然超卖。正确姿势是两层防护:第一层用唯一索引uk_seat_date保证同一个座位同一天不可能插入两条有效记录;第二层用事务把“查余票 + 插订单 + 插车票”包起来,任何一步失败就回滚。
START TRANSACTION; -- 1. 锁定目标座位,防止其他事务同时选中 SELECT seat_id FROM seat WHERE train_id = 'G1234' AND seat_type = '二等' AND seat_id NOT IN ( SELECT seat_id FROM ticket WHERE train_id = 'G1234' AND travel_date = '2025-06-01' AND status = 0 ) LIMIT 1 FOR UPDATE; -- 2. 插入订单 INSERT INTO orders (passenger_id, train_id, travel_date, amount, pay_status) VALUES (1001, 'G1234', '2025-06-01', 553.00, 1); -- 3. 插入车票,若唯一索引冲突则整个事务回滚 INSERT INTO ticket (train_id, seat_id, travel_date, passenger_id, status, order_id) VALUES ('G1234', 88, '2025-06-01', '110101199001011234', 0, LAST_INSERT_ID()); COMMIT;逻辑说明:FOR UPDATE会在选中座位时加行锁,避免两个事务同时拿到同一个seat_id。LAST_INSERT_ID()拿到刚插入的订单号,保证车票和订单关联正确。参数上,LIMIT 1只取一个座位,是因为一次下单通常只买一张票;如果要买多张,把LIMIT调大并在应用层循环插入即可。失败时看什么:如果报Duplicate entry,说明唯一索引生效了,这是好事,捕获异常后提示用户“该座位已被抢,请重选”即可。
3.3 退票与改签:状态机比删除记录更可靠
退票不要DELETE,改签不要UPDATE座位号了事。正确做法是用status字段做状态流转:退票把status从 0 改成 1,改签把原票改成 2 并插入一张新票。这样历史可追溯,余票统计也不会因为物理删除而算错。
-- 退票:只改状态,不删记录 UPDATE ticket SET status = 1 WHERE ticket_id = 5001 AND status = 0; -- 改签:原票置为已改签,新票插入 UPDATE ticket SET status = 2 WHERE ticket_id = 5001 AND status = 0; INSERT INTO ticket (train_id, seat_id, travel_date, passenger_id, status, order_id) VALUES ('G1234', 99, '2025-06-02', '110101199001011234', 0, 7001);注意:这两步必须在同一个事务里,否则可能出现原票已改签、新票没插入的“票凭空消失”事故。
4. 车站售票管理系统避坑:5 个课设答辩高频翻车点
4.1 余票数对不上:计数表与真实票数不一致
现象:压测或手动改数据后,余票显示还有 3 张,实际一张都插不进去。原因:维护了计数表但退票时忘了加回去,或者改签时只扣了新票没还原旧票。解决:课设阶段优先用实时聚合查询,别引入计数表;如果非要用,把扣减和回补写在同一个事务里,并加一个定时对账任务。
4.2 同一座位卖两次:唯一索引没建或建错列
现象:并发插入时同一个seat_id同一天出现两条status=0的记录。原因:唯一索引只建在seat_id上,没带travel_date,或者根本没建。解决:建UNIQUE KEY uk_seat_date (train_id, seat_id, travel_date),并且把status纳入业务判断,退票后允许重新售卖时用“部分唯一索引”思路(MySQL 不支持,可用触发器或应用层保证)。
4.3 查询慢到超时:索引没命中,全表扫描
现象:车次数据刚到几万条,余票查询就要好几秒。原因:ticket表只在主键上有索引,WHERE train_id=? AND travel_date=?走的是全表扫描。解决:建联合索引(train_id, travel_date, status),用EXPLAIN确认type不是ALL。参数上,联合索引的列顺序要把选择性最高的放前面,但这里train_id和travel_date通常一起出现,顺序影响不大。
4.4 事务没回滚:部分成功导致数据脏了
现象:下单时订单插入了,车票插入失败,用户看到订单但没票。原因:没开事务,或者开了但异常时没ROLLBACK。解决:用START TRANSACTION包住所有写操作,在应用层用 try-catch 捕获异常后显式回滚。注意:MySQL 默认 autocommit 是开的,不开事务等于每条 SQL 独立提交。
4.5 时间字段类型选错:DATE 和 DATETIME 混用
现象:按日期查余票时,跨天数据查不出来或查多了。原因:travel_date用了 DATETIME,插入时带了时分秒,查询时用= '2025-06-01'匹配不上。解决:乘车日期用 DATE,创建时间用 DATETIME,查询时用travel_date = '2025-06-01'而不是BETWEEN。
5. 课设加分项:用存储过程和触发器把业务逻辑下沉到数据库
5.1 用存储过程封装“下单”全流程
答辩时如果老师问“你的业务逻辑写在哪”,你说“全在 Java 里”,分数不会太高。把核心的下单流程写成存储过程,能体现你对数据库能力的掌握。下面是一个简化版。
DELIMITER // CREATE PROCEDURE buy_ticket( IN p_train_id VARCHAR(10), IN p_travel_date DATE, IN p_passenger_id BIGINT, IN p_seat_type VARCHAR(10), OUT p_result VARCHAR(50) ) BEGIN DECLARE v_seat_id INT; DECLARE v_order_id BIGINT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_result = 'FAILED'; END; START TRANSACTION; SELECT seat_id INTO v_seat_id FROM seat WHERE train_id = p_train_id AND seat_type = p_seat_type AND seat_id NOT IN ( SELECT seat_id FROM ticket WHERE train_id = p_train_id AND travel_date = p_travel_date AND status = 0 ) LIMIT 1 FOR UPDATE; IF v_seat_id IS NULL THEN SET p_result = 'NO_SEAT'; ROLLBACK; ELSE INSERT INTO orders (passenger_id, train_id, travel_date, amount, pay_status) VALUES (p_passenger_id, p_train_id, p_travel_date, 553.00, 1); SET v_order_id = LAST_INSERT_ID(); INSERT INTO ticket (train_id, seat_id, travel_date, passenger_id, status, order_id) VALUES (p_train_id, v_seat_id, p_travel_date, '110101199001011234', 0, v_order_id); COMMIT; SET p_result = 'SUCCESS'; END IF; END // DELIMITER ;逻辑说明:EXIT HANDLER捕获任何 SQL 异常后回滚并返回失败,避免脏数据。OUT参数把结果带回应用层,应用层只需要调用CALL buy_ticket(...)并检查p_result。参数上,p_seat_type决定买哪种座位,p_travel_date决定乘车日期。这个存储过程把并发控制、事务、业务判断都收在数据库里,应用层会变得很薄。
5.2 用触发器自动维护余票缓存
如果你已经引入了余票计数表,可以用触发器在ticket插入和更新时自动扣减或回补。但注意:触发器里不要写复杂查询,否则会拖慢写入。下面是一个简单示例。
CREATE TRIGGER trg_ticket_insert AFTER INSERT ON ticket FOR EACH ROW BEGIN IF NEW.status = 0 THEN UPDATE seat_stock SET remain = remain - 1 WHERE train_id = NEW.train_id AND travel_date = NEW.travel_date; END IF; END;逻辑说明:AFTER INSERT保证车票已经落库再扣库存,NEW.status = 0只处理有效票。参数上,seat_stock表需要提前按(train_id, travel_date)建好记录,否则UPDATE会影响 0 行。这个方案适合读多写少的场景,如果写入非常频繁,触发器会成为瓶颈,那时候就该考虑用消息队列异步处理了。
5.3 验证方法:用 EXPLAIN 和压测脚本自证
课设答辩前,我习惯做两件事。第一,对每条核心 SQL 跑EXPLAIN,确认key列不是NULL,rows列在可接受范围。第二,写一个简单的并发脚本,用多线程同时调用下单接口,看会不会出现超卖。下面是一个用 Python 模拟并发的骨架。
import threading import pymysql def buy(): conn = pymysql.connect(host='localhost', user='root', password='', db='ticket_db') cur = conn.cursor() try: cur.execute("CALL buy_ticket('G1234', '2025-06-01', 1001, '二等', @res)") cur.execute("SELECT @res") print(cur.fetchone()) finally: conn.close() threads = [threading.Thread(target=buy) for _ in range(50)] for t in threads: t.start() for t in threads: t.join()逻辑说明:50 个线程同时抢同一车次同一天的票,如果最终成功数等于座位数,说明防超卖生效;如果成功数大于座位数,说明事务或唯一索引有问题。参数上,线程数可以按座位总数的 2 到 3 倍设置,压力更真实。
我自己的习惯是:课设代码可以写得糙,但表结构和核心 SQL 一定要经得起EXPLAIN和并发测试。当年我第一次做这个课设,余票字段用 FLOAT 存,结果退票时出现 0.9999999 的余票,被老师当反面案例讲了一学期。后来我养成了一个规矩:凡是涉及钱和库存的字段,一律 DECIMAL 加唯一索引加事务,三件套缺一不可。希望帮到你。
本文还有配套的精品资源,点击获取