车站售票管理系统数据库课设:事务并发防超卖与验收骨架

简介:车站售票管理系统数据库课程设计完整方案面向广东工业大学等高校本科生,适合正在准备数据库课设、需要参考选题落地方式的学习者。系统覆盖车票购买与退票、车次与时刻表查询统计,并延伸至数据备份恢复、操作员管理与权限设置等维护环节,可对照理解从需求分析到功能实现的完整链路。压缩包共100个文件、约4.65MB,以72个class与21个java源码为主体,配套sql建库脚本、doc安装说明书、txt说明及jar依赖,源码与文档对应,便于导入数据库、还原环境并逐模块阅读实现逻辑。已有1159人学习,说明该选题参考度较高。读者可获得的是一套可直接运行和拆解的课设范例:既能参考表结构设计与SQL脚本,也能借助安装说明快速部署,还能从售票、退票、查询统计及权限维护等模块中梳理功能划分与交互流程,为撰写报告、答辩演示和二次改造提供较完整的素材。

1. 从「能跑」到「能答辩」:一份车站售票管理系统数据库课设该有的骨架

验收现场最尴尬的一幕,不是界面丑,而是两个售票窗口同时点“出票”,系统把同一张硬座卖了两遍。这份车站售票管理系统数据库课程设计资源,核心价值不在界面,而在把余票、订单、退票三件事用表结构、约束和事务串起来。它适合正在做数据库课设、需要一套能讲清 E-R 图、能跑通并发测试、能扛住答辩追问的同学,也适合想补数据库落地经验的初中级开发者。下面按建表、事务、避坑、验收的顺序拆,尽量把每一步都落到可执行的 SQL 上。

2. 需求到 E-R 图:把售票窗口拆成五张主表和三张流水表

很多数据库课设一上来就画二十多张表,最后自己都说不清哪张表负责什么。车站售票管理系统的业务边界其实不宽:查班次、看余票、下单、出票、退票,最多再加一个操作员管理。真正容易讲乱的是“余票”到底放在班次表里,还是单独建一张库存表;“订单”和“车票”是不是一回事;“退票”要不要单独留痕。先把这几个问题定下来,E-R 图才不会变成摆设。

2.1 先定业务边界:一张票的生命周期

我一般会把一张票的生命周期拆成四段:班次发布、库存初始化、乘客下单、退票回滚库存。班次发布对应train_schedule,库存初始化对应seat_stock,下单对应orders和order_item,退票对应refund_log。乘客信息落在passenger,操作员落在operator,车站落在station。这样拆分的好处是,每一段都有明确的表承接,答辩时被问到“余票变化记在哪”,可以直接指到seat_stock.remaining和refund_log,而不是含糊地说“在代码里减一”。

这里有个常见误区:把余票直接塞进train_schedule表,做成remaining_seat一个字段。演示阶段看起来省事,但只要出现席别差异就撑不住。同一趟车有硬座、硬卧、二等座,价格和库存都不一样,一个字段根本表达不了。所以库存表至少要细到“班次 + 席别”这一层,seat_stock的主键可以用自增 ID,业务唯一键用(schedule_id, seat_type)约束住。

另一个边界是改签。课设周期紧的话,改签可以不做,但要在文档里写清楚“本系统暂不包含改签,退票按原订单整单退”。不要嘴上说支持改签,表里却没有改签流水,验收时一追问就露馅。把不做的功能明确写出来,比含糊承诺更加分。

2.2 实体与联系:五张主表加三张流水表

下面这张表是我在做这类课设时常用的最小闭环,字段不是越多越好,关键是每个字段都能在业务里找到落点。

表名作用关键字段关系
station车站基础信息station_id、station_name、city被班次引用
train_schedule班次schedule_id、train_no、from_station_id、to_station_id、depart_time多对一引用车站
seat_stock班次席别库存stock_id、schedule_id、seat_type、price、total_seat、remaining多对一引用班次
passenger乘客passenger_id、id_card、name、phone被订单引用
operator售票操作员operator_id、login_name、role被订单引用
orders订单主表order_id、passenger_id、operator_id、total_amount、status、create_time一对多订单明细
order_item订单明细item_id、order_id、schedule_id、seat_type、qty、unit_price多对一引用订单和库存
refund_log退票流水refund_id、order_id、refund_amount、refund_time、reason多对一引用订单

实体联系上,车站和班次是 1:N,班次和库存是 1:N,乘客和订单是 1:N,订单和明细是 1:N。订单和班次之间看起来是多对多,但被order_item拆开之后,每一行明细都指向一个具体班次和席别,关系就清楚了。退票流水单独一张表,是为了保留“谁在什么时候退了多少钱”,而不是直接改订单状态了事。课设答辩里,老师很爱问“退票之后原订单还在不在”,有这张流水表就能答得干净。

2.3 从 E-R 到关系模式:主外键怎么落

画 E-R 图时可以把“下单”画成联系,但落到关系模式就要变成外键。我的习惯是先把主表建出来,再建流水表,最后建约束。顺序大致是station→train_schedule→seat_stock→passenger→operator→orders→order_item→refund_log。如果顺序反了,外键会一直报“被引用表不存在”,这不是玄学,就是建表顺序问题。

关系模式里要特别留意两个点。第一,order_item不要只存schedule_id,还要存seat_type和unit_price。价格会变,订单明细必须保留成交时的价格,否则后面调价会把历史订单金额算错。第二,orders.total_amount可以用冗余字段,但它的值必须由明细汇总得出,不能手填。课设里可以用存储过程或触发器维护,也可以在插入明细后统一更新,关键是文档里要说明一致性怎么保证。

还有一个小细节:身份证号、手机号这类字段不要用INT,前导零会丢,长度也不够。常见做法是用CHAR(18)存身份证,用VARCHAR(20)存手机号。班次时间用DATETIME,不要用字符串拼2025-05-01 08:00,否则按时间排序和比较时全是坑。把这些字段类型定下来,后面的 SQL 才不容易翻车。

3. 建表与约束:把「余票不为负」写进数据库而不是写进嘴

课设里最容易拿分、也最容易被追问的,是约束。很多同学把“余票不能小于零”写在 Java 或 Python 的if里,数据库里一点防护都没有。演示时只走正常流程看不出问题,一旦用两个会话同时下单,或者手动改一条库存数据,问题就暴露了。数据库课设的落脚点,应该是让数据库自己守住底线。

3.1 建表顺序与外键

先看基础表的写法。下面这段 SQL 以 MySQL 的 InnoDB 引擎为例,字符集统一用utf8mb4,避免站名里的生僻字或特殊符号出现乱码。

-- 车站表:站名唯一,避免重复录入 CREATE TABLE station ( station_id INT PRIMARY KEY AUTO_INCREMENT, station_name VARCHAR(64) NOT NULL, city VARCHAR(64) NOT NULL, UNIQUE KEY uk_station_name (station_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 班次表:始发站和到达站都引用车站表 CREATE TABLE train_schedule ( schedule_id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL, from_station_id INT NOT NULL, to_station_id INT NOT NULL, depart_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, CONSTRAINT fk_schedule_from FOREIGN KEY (from_station_id) REFERENCES station(station_id), CONSTRAINT fk_schedule_to FOREIGN KEY (to_station_id) REFERENCES station(station_id), CONSTRAINT ck_schedule_time CHECK (arrive_time > depart_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段代码里,AUTO_INCREMENT负责代理主键,UNIQUE KEY负责业务唯一性,CHECK负责时间先后。外键名fk_schedule_from和fk_schedule_to要分开写,因为同一个班次表两次引用车站表,不分开命名后面排查会很痛苦。DATETIME比字符串更适合做时间比较,ck_schedule_time能挡住“到达时间早于出发时间”的脏数据。

接着建库存表。这张表是后面事务章节的主角,字段不多,但每一个都关键。

-- 席别库存表:同一班次同一席别只能有一条记录 CREATE TABLE seat_stock ( stock_id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id INT NOT NULL, seat_type VARCHAR(16) NOT NULL, price DECIMAL(10,2) NOT NULL, total_seat INT NOT NULL, remaining INT NOT NULL, CONSTRAINT fk_stock_schedule FOREIGN KEY (schedule_id) REFERENCES train_schedule(schedule_id), CONSTRAINT ck_stock_remaining CHECK (remaining >= 0 AND remaining <= total_seat), UNIQUE KEY uk_schedule_seat (schedule_id, seat_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

金额用DECIMAL(10,2),不要用FLOAT或DOUBLE。浮点数在做金额汇总时会出现0.01的误差,课设演示可能看不出来,但答辩时被问到“为什么总金额和明细差一分钱”,很难圆。uk_schedule_seat保证一个班次一个席别只有一条库存记录,避免重复插入导致余票翻倍。

注意:较新的 MySQL 版本才会真正执行CHECK约束,老版本只解析不生效。如果实验室环境版本较旧,不要只依赖CHECK,要补触发器或在存储过程里做判断。

3.2 余票不为负:CHECK 与触发器双保险

CHECK能挡住直接UPDATE成负数的情况,但它挡不住“先读后写”造成的超卖。为了在课设里讲清楚“数据库层防护”,我通常再补一个BEFORE UPDATE触发器。触发器的写法要注意,不能在触发器里再SELECT同一张表,否则容易报错。直接用NEW.remaining判断即可。

-- 更新库存前拦截负数余票 DELIMITER $$ CREATE TRIGGER trg_stock_before_update BEFORE UPDATE ON seat_stock FOR EACH ROW BEGIN IF NEW.remaining < 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余票不足,禁止超卖'; END IF; IF NEW.remaining > NEW.total_seat THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余票不能超过总座位数'; END IF; END$$ DELIMITER ;

SIGNAL SQLSTATE '45000'是用户自定义异常,MESSAGE_TEXT会直接返回给调用方。这样即使应用层漏了判断,数据库也会把非法更新拦下来。触发器里只做判断,不写业务插入,保持职责单一。答辩时如果被问“触发器和存储过程有什么区别”,可以答:触发器由事件自动触发,适合做约束和审计;存储过程由调用方显式执行,适合封装完整业务事务。

3.3 初始化数据与可重复执行

课设包如果没有初始化脚本,换一台机器就要手动插半天数据。我的做法是写一段可重复执行的种子 SQL,用INSERT ... ON DUPLICATE KEY UPDATE避免反复插入报唯一键冲突。

-- 初始化车站 INSERT INTO station (station_name, city) VALUES ('模拟东站', '模拟市'), ('模拟西站', '模拟市') ON DUPLICATE KEY UPDATE city = VALUES(city); -- 初始化班次 INSERT INTO train_schedule (train_no, from_station_id, to_station_id, depart_time, arrive_time) VALUES ('G1001', 1, 2, '2025-06-01 08:00:00', '2025-06-01 10:30:00') ON DUPLICATE KEY UPDATE arrive_time = VALUES(arrive_time); -- 初始化库存:二等座 100 张,一等座 20 张 INSERT INTO seat_stock (schedule_id, seat_type, price, total_seat, remaining) VALUES (1, '二等座', 56.50, 100, 100), (1, '一等座', 89.00, 20, 20) ON DUPLICATE KEY UPDATE price = VALUES(price), total_seat = VALUES(total_seat), remaining = VALUES(remaining);

这段脚本适合在演示前重置数据。ON DUPLICATE KEY UPDATE后面的VALUES()取的是本次准备插入的值,不是表里旧值。把remaining一起重置,能避免上一轮测试把余票扣到 0 后演示无法继续。实际课设里可以把这段放到init.sql,每次验收前跑一遍,比手动改表稳。

4. 事务与并发:同一张票被两个窗口同时卖出的复现与处理

数据库课设如果只做增删改查,很难拉开差距。真正能体现“数据库系统课程设计”含量的,是并发场景。两个售票窗口同时卖最后一张票,系统到底会不会超卖,这个问题既能做实验,又能讲清楚隔离级别、行锁和事务边界。下面这套复现步骤,我在实验室里见过很多次翻车,也见过同学用一句原子UPDATE就解决。

4.1 复现超卖:两个会话的手动实验

先手动制造一次超卖。打开两个数据库会话,假设seat_stock里schedule_id = 1、seat_type = '二等座'的remaining是 1。两个会话都先读再写,但不加锁。

-- 会话 A START TRANSACTION; SELECT remaining FROM seat_stock WHERE schedule_id = 1 AND seat_type = '二等座'; -- 读到 1,先不提交 -- 会话 B START TRANSACTION; SELECT remaining FROM seat_stock WHERE schedule_id = 1 AND seat_type = '二等座'; -- 也读到 1 -- 会话 A 继续 UPDATE seat_stock SET remaining = 0 WHERE schedule_id = 1 AND seat_type = '二等座'; INSERT INTO orders (passenger_id, operator_id, total_amount, status, create_time) VALUES (1, 1, 56.50, 'PAID', NOW()); COMMIT; -- 会话 B 继续 UPDATE seat_stock SET remaining = 0 WHERE schedule_id = 1 AND seat_type = '二等座'; INSERT INTO orders (passenger_id, operator_id, total_amount, status, create_time) VALUES (2, 1, 56.50, 'PAID', NOW()); COMMIT;

最后查orders会发现两条订单都成功了,但库存只扣了一次。这就是典型的丢失更新:两个事务都基于“读到 1”的旧快照做判断,后提交的更新覆盖了前一个。REPEATABLE READ隔离级别下,普通SELECT是快照读,不会阻塞,也不会自动帮你把后一个事务挡住。

要复现得稳定,可以把两个会话的SELECT和UPDATE分开执行,中间留几秒。演示时不要用图形化工具自动提交,否则事务边界会乱。命令行客户端或者把自动提交关掉,效果更直观。

4.2 原子更新与行锁:把判断和扣减合成一句

解决超卖最直接的办法,是把“判断余票”和“扣减余票”合成一条UPDATE,让数据库在行锁里完成。

-- 原子扣减:只有 remaining >= 1 才会更新成功 UPDATE seat_stock SET remaining = remaining - 1 WHERE schedule_id = 1 AND seat_type = '二等座' AND remaining >= 1; -- 应用层检查受影响行数 -- 如果返回 0,说明余票不足,直接回滚或提示用户

这条语句的关键在WHERE remaining >= 1。InnoDB 执行UPDATE时会对匹配的行加排他锁,两个会话同时进来,后一个会等前一个提交,然后基于最新值重新判断。受影响行数为 1 表示抢到了,为 0 表示没抢到。应用层不要只判断异常,还要判断affected rows,这是很多人漏掉的一步。

另一种写法是SELECT ... FOR UPDATE,先锁行再判断。

START TRANSACTION; SELECT remaining INTO @remain FROM seat_stock WHERE schedule_id = 1 AND seat_type = '二等座' FOR UPDATE; -- 如果 @remain < 1,回滚并提示 -- 否则继续扣减和插入订单 UPDATE seat_stock SET remaining = remaining - 1 WHERE schedule_id = 1 AND seat_type = '二等座'; COMMIT;

FOR UPDATE是当前读,会加行锁。它的好处是逻辑直观,适合在存储过程里做多步判断;缺点是锁持有时间更长,如果后面还要调用外部接口,容易把锁拖长。课设演示里两种写法都可以,但文档里要写清楚:原子UPDATE更轻,FOR UPDATE更适合复杂事务。

4.3 存储过程封装下单:把事务边界收在数据库侧

如果每次下单都靠应用层拼 SQL,事务边界容易散。把下单封装成存储过程,课设答辩时也更好讲。下面这个版本包含库存检查、订单插入、明细插入和异常回滚。

DELIMITER $$ CREATE PROCEDURE sp_create_order( IN p_passenger_id INT, IN p_operator_id INT, IN p_schedule_id INT, IN p_seat_type VARCHAR(16), IN p_qty INT, OUT p_order_id BIGINT ) BEGIN DECLARE v_remaining INT; DECLARE v_price DECIMAL(10,2); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 锁定库存行,防止并发超卖 SELECT remaining, price INTO v_remaining, v_price FROM seat_stock WHERE schedule_id = p_schedule_id AND seat_type = p_seat_type FOR UPDATE; IF v_remaining IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存记录不存在'; END IF; IF v_remaining < p_qty THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余票不足'; END IF; UPDATE seat_stock SET remaining = remaining - p_qty WHERE schedule_id = p_schedule_id AND seat_type = p_seat_type; INSERT INTO orders (passenger_id, operator_id, total_amount, status, create_time) VALUES (p_passenger_id, p_operator_id, v_price * p_qty, 'PAID', NOW()); SET p_order_id = LAST_INSERT_ID(); INSERT INTO order_item (order_id, schedule_id, seat_type, qty, unit_price) VALUES (p_order_id, p_schedule_id, p_seat_type, p_qty, v_price); COMMIT; END$$ DELIMITER ;

参数里p_qty是购买张数,p_order_id是出参,方便调用方拿到订单号。EXIT HANDLER FOR SQLEXCEPTION负责异常回滚,RESIGNAL把原始错误继续抛给调用方,避免错误被吞掉。SELECT ... FOR UPDATE锁住库存行,LAST_INSERT_ID()取刚插入的订单主键。调用方式如下:

CALL sp_create_order(1, 1, 1, '二等座', 1, @order_id); SELECT @order_id;

如果并发下出现死锁,数据库会返回 1213 错误。常见做法是在应用层捕获这个错误后重试一次,重试次数不要太多,两到三次即可。存储过程里不要写WHILE无限重试,否则演示时容易卡死。

5. 避坑与排查:课设验收现场最容易翻车的五件事

数据库课设的坑,大多不是语法不会,而是环境、顺序和演示流程没控住。下面这五条是我在实验室里反复见到的翻车现场,每一条都按“现象 → 原因 → 解决”拆开写,照着排查能省不少时间。

5.1 建表与初始化阶段的坑

现象:建order_item时报“Cannot add or update a child row”。
原因:先建了子表,后建父表,或者父表里没有对应的schedule_id。外键要求被引用列必须有匹配值,建表顺序和插入顺序都要先父后子。
解决:按station→train_schedule→seat_stock→passenger→operator→orders→order_item的顺序建表和插数据;排查时先查父表有没有那条记录,再查子表的外键值。

现象:中文站名插进去变成问号。
原因:数据库、表、连接三层的字符集不一致。常见是表用了utf8,连接用了latin1,或者客户端没有设置字符集。
解决:建库建表统一utf8mb4,连接串里显式指定字符集;排查时执行SHOW VARIABLES LIKE 'character_set%';,看character_set_client、character_set_connection、character_set_results是否一致。

5.2 事务与演示阶段的坑

现象:触发器里查询同一张表,报 1442 错误。
原因:MySQL 的触发器不能修改或查询正在被触发的表,容易形成递归或锁冲突。
解决:触发器里只使用NEW和OLD的值做判断,不要写SELECT ... FROM seat_stock;需要跨表校验时,把逻辑放到存储过程里。

现象:演示时余票扣成负数,但代码里明明有if。
原因:应用层判断和数据库更新之间有时间窗口,两个请求都读到了旧值;或者演示时用了自动提交,事务没包住。
解决:把扣减改成UPDATE ... WHERE remaining >= ?,判断受影响行数;或者用SELECT ... FOR UPDATE锁行;演示前关掉自动提交,确认事务边界。

现象:验收前跑了一遍测试,数据乱了,重新演示时余票全是 0。
原因:测试脚本没有重置数据,或者remaining被手动改过没有恢复。
解决:准备一份可重复执行的init.sql,每次演示前重置库存和订单;不要直接改生产表,先用SELECT确认当前值,再决定是重置还是补数据。

提示:课设演示前,至少留一遍“干净数据”的备份。用mysqldump导出结构加数据,出了问题先恢复,再排查,比现场改表稳。

6. 从能演示到能讲清:视图、索引与验收顺序的收尾技巧

课设做到能跑通下单,只是及格线。验收时老师往往会追问:“余票查询为什么慢?”“订单列表走没走索引?”“视图和表有什么区别?”这些问题不需要把系统做得多复杂,但要在关键查询上有准备。我的习惯是先把最常用的三条查询跑一遍EXPLAIN,再决定加什么索引,而不是一上来给每个字段都建索引。

6.1 索引与执行计划:别给每个字段都戴帽子

下面这张表是我在车站售票场景里常用的索引取舍,核心原则是“按查询路径建复合索引,别按字段数量堆索引”。

查询场景建议索引理由
按班次和席别查余票seat_stock(schedule_id, seat_type)已经由唯一键覆盖,等值查询直接命中
按乘客查订单orders(passenger_id, create_time)先过滤乘客,再按时间排序,避免额外排序
按订单查明细order_item(order_id)明细表按订单号回查,频率高
按班次查订单明细order_item(schedule_id, seat_type)统计某班次某席别销量时使用

加完索引后,用EXPLAIN看执行计划。重点看type是不是ALL,key是不是走了预期索引,rows扫描行数是不是过大,Extra里有没有Using filesort或Using temporary。

-- 查看余票查询是否走唯一键 EXPLAIN SELECT remaining, price FROM seat_stock WHERE schedule_id = 1 AND seat_type = '二等座'; -- 查看乘客订单列表是否走复合索引 EXPLAIN SELECT order_id, total_amount, create_time FROM orders WHERE passenger_id = 1 ORDER BY create_time DESC;

第一条查询理想情况下key是uk_schedule_seat,type是const或ref。第二条如果key是idx_passenger_time,并且Extra里没有Using filesort,说明复合索引的顺序是对的。如果type是ALL,先别急着加索引,看看是不是条件字段类型不匹配,比如passenger_id是字符串却传了数字,隐式转换会让索引失效。

视图方面,可以建一个v_schedule_stock把班次、车站、库存拼在一起,方便前台查询。视图不要嵌套太深,课设里一层就够了。存储过程适合放下单和退票,退票时要注意先更新orders.status,再回补seat_stock.remaining,最后写refund_log,三步放在同一个事务里。退票的回补语句同样要带条件,避免把库存回补到超过total_seat。

6.2 验收演示的顺序:先讲约束,再跑并发

验收时我一般按这个顺序走:先打开 E-R 图讲实体和联系,再跑建表脚本展示外键和CHECK,然后插一条订单展示事务,最后开两个会话演示并发下单。演示并发时不要一上来就讲隔离级别,先让老师看到“两个会话同时买最后一张票,只有一个成功”,再解释UPDATE ... WHERE remaining >= 1和FOR UPDATE的区别。这样比背概念更有说服力。

从那以后我每次交付数据库课设,都强制自己先跑一遍“重置数据 → 单线程下单 → 双会话并发 → 退票回补 → 查执行计划”这条链路,确认每一步都能复现,再谈界面和文档。希望帮到你。

本文还有配套的精品资源,点击获取