广工数据库课设实战:车站售票管理系统防超卖与并发控制
简介这份资源是广东工业大学数据库系统课程设计的个人选题方案——车站售票管理系统面向正在准备数据库课设的本科生及需要参考完整项目实现的学习者。系统围绕售票与退票、车次与时刻表查询、售票统计以及数据备份恢复、操作员权限管理等维护功能展开覆盖数据库设计到界面交互的完整链路。压缩包共100个文件约4.65MB以72个class与21个java源码为主体另含1个sql建库脚本、1个jar依赖及doc安装说明书、txt说明等源码与文档配套便于对照理解表结构设计与功能实现。目前已有1154人学习下载可作为课设选题、功能拆解与代码复用的直接参考帮助读者快速理清售票管理系统的模块划分与数据库操作思路。1. 车站售票管理系统广工数据库课设到底在考什么广工数据库课设选“车站售票管理系统”的人不少但真正动手才发现题目表面是做一个卖票窗口底层考的是你对数据库的建模能力、事务控制和并发处理的理解。我带过几届学弟学妹做这个题最常见的翻车现场是表建好了增删改查也能跑一到“两个人同时买最后一张票”就出问题——超卖、死锁、数据对不上。这个课设的核心不是界面多漂亮而是你能不能把数据库的约束、索引、事务隔离级别用对。适合谁看正在做广工数据库课设、选了车站售票方向或者想拿一个完整案例把数据库课程设计从建表到并发跑通的人。下面按我实际做过的路径从需求拆解到 SQL 落地再到并发压测一步步讲清楚。2. 需求拆解与表结构设计先想清楚“票”到底怎么存2.1 从售票窗口反推实体和关系车站售票管理系统的业务链条其实很短查车次、选座、下单、支付、出票、退票。但短链条里藏着几个容易建模错的点。我一般先画一张手写草图把名词圈出来车站、车次、车厢、座位、乘客、订单、支付记录。然后问自己三个问题座位是跟着车次走还是跟着车厢走一张票能不能改签退票后座位什么时候释放常见做法是把“车次”和“座位”拆成两张表中间用“车次座位库存”关联。不要直接把座位号写死在车次表里否则一趟车几百个座位字段爆炸。我见过有人用逗号分隔的座位字符串存查余票时用LIKE匹配结果并发一上来全表扫描课设演示直接卡死。正确的思路是车次表只存车次基本信息座位表存物理座位库存表存“某车次某座位某日期”的售卖状态。这样查余票就是COUNT库存表退票就是改状态逻辑清晰。2.2 建表 SQL 与约束设计下面是我在 MySQL 8.0 上跑通的建表脚本字段类型和约束都按课设答辩能讲清楚的标准来。注意train_schedule和seat_inventory的联合唯一索引这是防超卖的第一道防线。-- 车站表 CREATE TABLE station ( station_id INT PRIMARY KEY AUTO_INCREMENT, station_name VARCHAR(50) NOT NULL UNIQUE, city VARCHAR(30) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 车次表 CREATE TABLE train ( train_no VARCHAR(10) PRIMARY KEY, start_station_id INT NOT NULL, end_station_id INT NOT NULL, depart_time TIME NOT NULL, arrive_time TIME NOT NULL, FOREIGN KEY (start_station_id) REFERENCES station(station_id), FOREIGN KEY (end_station_id) REFERENCES station(station_id) ) ENGINEInnoDB; -- 车次日程按日期实例化 CREATE TABLE train_schedule ( schedule_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(10) NOT NULL, run_date DATE NOT NULL, base_price DECIMAL(8,2) NOT NULL, UNIQUE KEY uk_train_date (train_no, run_date), FOREIGN KEY (train_no) REFERENCES train(train_no) ) ENGINEInnoDB; -- 座位库存核心表 CREATE TABLE seat_inventory ( inventory_id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, carriage_no INT NOT NULL, seat_no VARCHAR(5) NOT NULL, seat_type TINYINT NOT NULL COMMENT 0-硬座 1-软座 2-卧铺, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-可售 1-锁定 2-已售, order_id BIGINT DEFAULT NULL, UNIQUE KEY uk_schedule_seat (schedule_id, carriage_no, seat_no), KEY idx_schedule_status (schedule_id, status), FOREIGN KEY (schedule_id) REFERENCES train_schedule(schedule_id) ) ENGINEInnoDB;逻辑说明seat_inventory上的uk_schedule_seat唯一索引保证同一车次同一天同一座位不会出现两条记录这是物理层面的防重。idx_schedule_status是给查余票用的WHERE schedule_id? AND status0能走索引。status用 TINYINT 而不是 ENUM是为了后续扩展状态时不用改表结构。参数说明DECIMAL(8,2)存票价别用 FLOAT课设答辩时老师问“为什么不用浮点”你要能答出精度问题。BIGINT给schedule_id和order_id因为车次日程和订单量可能很大INT 在演示阶段够用但扩展性差。2.3 订单表与乘客表怎么关联订单表我一般设计成“订单主表 订单明细”的结构但课设规模不大可以简化成一张订单表用seat_inventory_id关联座位。乘客信息单独一张表订单表里存乘客 ID 快照。CREATE TABLE passenger ( passenger_id BIGINT PRIMARY KEY AUTO_INCREMENT, id_card VARCHAR(18) NOT NULL UNIQUE, name VARCHAR(30) NOT NULL, phone VARCHAR(15) ) ENGINEInnoDB; CREATE TABLE ticket_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, passenger_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, inventory_id BIGINT NOT NULL, amount DECIMAL(8,2) NOT NULL, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已退票 3-已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, FOREIGN KEY (passenger_id) REFERENCES passenger(passenger_id), FOREIGN KEY (schedule_id) REFERENCES train_schedule(schedule_id), FOREIGN KEY (inventory_id) REFERENCES seat_inventory(inventory_id) ) ENGINEInnoDB;这里有个坑inventory_id加外键后退票时不能直接删库存记录只能改status。我见过有人退票时DELETE库存行结果订单表外键约束报错演示当场翻车。正确做法是UPDATE seat_inventory SET status0, order_idNULL WHERE inventory_id?。3. 购票与退票的核心 SQL事务和锁到底怎么加3.1 购票流程的原子操作购票的本质是“查库存 → 锁座位 → 写订单 → 改库存状态”四步。这四步必须在一个事务里否则并发时会出现两个人查到同一张票都以为能买。下面是我用的购票存储过程核心是SELECT ... FOR UPDATE锁住库存行。DELIMITER // CREATE PROCEDURE buy_ticket( IN p_passenger_id BIGINT, IN p_schedule_id BIGINT, IN p_carriage_no INT, IN p_seat_no VARCHAR(5), OUT p_order_no VARCHAR(32), OUT p_result INT ) BEGIN DECLARE v_inventory_id BIGINT; DECLARE v_status TINYINT; DECLARE v_price DECIMAL(8,2); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_result -1; END; START TRANSACTION; -- 锁定目标座位行防止并发修改 SELECT inventory_id, status INTO v_inventory_id, v_status FROM seat_inventory WHERE schedule_id p_schedule_id AND carriage_no p_carriage_no AND seat_no p_seat_no FOR UPDATE; IF v_status ! 0 THEN SET p_result 1; -- 座位已被占用 ROLLBACK; ELSE SELECT base_price INTO v_price FROM train_schedule WHERE schedule_id p_schedule_id; SET p_order_no CONCAT(T, UNIX_TIMESTAMP(), LPAD(FLOOR(RAND()*1000), 3, 0)); INSERT INTO ticket_order(order_no, passenger_id, schedule_id, inventory_id, amount, order_status) VALUES(p_order_no, p_passenger_id, p_schedule_id, v_inventory_id, v_price, 0); UPDATE seat_inventory SET status 1, order_id LAST_INSERT_ID() WHERE inventory_id v_inventory_id; COMMIT; SET p_result 0; END IF; END // DELIMITER ;逻辑说明FOR UPDATE在 InnoDB 里加的是排他锁锁住的是uk_schedule_seat索引命中的那一行。如果两个事务同时执行到SELECT ... FOR UPDATE后到的会阻塞直到前一个提交或回滚。这样就能保证同一座位不会被两个人同时锁定。参数说明p_result输出参数用来区分“成功”“座位被占”“系统异常”三种情况前端根据这个值给提示。order_no用时间戳加随机数课设够用生产环境要用雪花算法或号段模式。注意FOR UPDATE必须在事务里才有意义 autocommit 模式下单独执行会立即释放锁。另外如果WHERE条件没走索引FOR UPDATE可能锁表而不是锁行所以uk_schedule_seat索引必须建对。3.2 退票与库存释放退票逻辑比购票简单但要注意状态流转和金额处理。课设一般不做真实退款只改订单状态和库存状态。DELIMITER // CREATE PROCEDURE refund_ticket( IN p_order_no VARCHAR(32), OUT p_result INT ) BEGIN DECLARE v_inventory_id BIGINT; DECLARE v_order_status TINYINT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_result -1; END; START TRANSACTION; SELECT inventory_id, order_status INTO v_inventory_id, v_order_status FROM ticket_order WHERE order_no p_order_no FOR UPDATE; IF v_order_status ! 1 THEN SET p_result 1; -- 订单状态不允许退票 ROLLBACK; ELSE UPDATE ticket_order SET order_status 2 WHERE order_no p_order_no; UPDATE seat_inventory SET status 0, order_id NULL WHERE inventory_id v_inventory_id; COMMIT; SET p_result 0; END IF; END // DELIMITER ;逻辑说明先锁订单行判断状态是否为“已支付”只有已支付的才能退。然后改订单状态为“已退票”同时把库存状态改回“可售”。两步在同一个事务里保证要么都成功要么都回滚。参数说明p_result返回 0 表示退票成功1 表示订单状态不对-1 表示异常。实际课设里可以再加一个退票时间字段记录退票操作的时间戳。3.3 查余票的 SQL 怎么写才不走全表查余票是高频操作SQL 写不好演示时页面加载慢老师一眼就能看出问题。我一般用覆盖索引 条件过滤。-- 查某车次某日期的余票数量按座位类型分组 SELECT seat_type, COUNT(*) AS available_count FROM seat_inventory WHERE schedule_id ? AND status 0 GROUP BY seat_type;这个查询走idx_schedule_status索引schedule_id和status都在索引里不需要回表。如果还要查具体座位号再加carriage_no和seat_no字段但那样会回表数据量大时考虑加覆盖索引(schedule_id, status, carriage_no, seat_no)。常见误用有人用SELECT COUNT(*) FROM seat_inventory WHERE schedule_id? AND status0然后前端循环减库存这是典型的“查完再改”模式并发下必超卖。正确做法是把判断和修改放在一个事务里用FOR UPDATE锁行。4. 并发场景避坑超卖、死锁和隔离级别怎么选4.1 超卖是怎么发生的超卖的根本原因是“检查”和“修改”之间存在时间窗口。比如两个线程同时执行SELECT status FROM seat_inventory WHERE ...都查到status0然后都执行UPDATE ... SET status1结果同一座位被卖了两次。解决超卖只有两条路要么在 SQL 层面用原子操作要么在应用层加锁。原子操作的写法UPDATE seat_inventory SET status 1, order_id ? WHERE inventory_id ? AND status 0;然后检查ROW_COUNT()是否等于 1。如果等于 1说明抢到了如果等于 0说明被别人抢先了。这种写法不需要显式SELECT ... FOR UPDATE依赖 InnoDB 的行锁和WHERE条件里的status0保证原子性。我一般推荐课设用这种“乐观锁”思路代码简单不容易死锁。但要注意UPDATE的WHERE条件必须走索引否则会锁表。4.2 死锁的常见触发场景死锁在车站售票系统里最容易出现在“批量购票”场景。比如一个事务先锁座位 A 再锁座位 B另一个事务先锁 B 再锁 A互相等待。避免死锁的方法很简单所有事务按相同的顺序锁资源。比如按inventory_id升序锁定。另一个死锁来源是间隙锁。InnoDB 在REPEATABLE READ隔离级别下SELECT ... FOR UPDATE如果走的是非唯一索引可能锁住间隙。课设里如果seat_inventory的schedule_id索引不是唯一的WHERE schedule_id? FOR UPDATE会锁住整个范围。解决办法是尽量用唯一索引定位或者把隔离级别降到READ COMMITTED。-- 查看当前隔离级别 SELECT transaction_isolation; -- 会话级设置为 READ COMMITTED SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;READ COMMITTED下没有间隙锁死锁概率低但可能出现不可重复读。课设演示场景下不可重复读的影响远小于死锁导致的事务失败所以我一般建议课设用READ COMMITTED。4.3 隔离级别选哪个课设的务实选择MySQL 默认是REPEATABLE READ很多课设直接用它也能跑但并发一高就出死锁。我的血泪经验是如果购票逻辑用UPDATE ... WHERE status0的原子写法隔离级别用READ COMMITTED更稳。如果非要用SELECT ... FOR UPDATE那就保持REPEATABLE READ但必须保证WHERE条件走唯一索引。下面这张表是我在课设答辩时被问到的隔离级别对比直接背下来能加分隔离级别脏读不可重复读幻读课设适用场景READ UNCOMMITTED可能可能可能不用READ COMMITTED不可能可能可能推荐死锁少REPEATABLE READ不可能不可能InnoDB 下基本不可能默认需注意间隙锁SERIALIZABLE不可能不可能不可能性能差不用注意InnoDB 在REPEATABLE READ下通过 MVCC 和间隙锁基本解决了幻读但间隙锁是死锁的主要来源。课设数据量小READ COMMITTED足够。5. 课设答辩前必查的 5 个翻车点5.1 现象演示时购票成功但库存没变原因购票存储过程里UPDATE seat_inventory的WHERE条件写错比如用了schedule_id而不是inventory_id导致更新了多行或者零行。解决在UPDATE后加SELECT ROW_COUNT()检查或者在存储过程里用IF ROW_COUNT() 0 THEN ROLLBACK兜底。5.2 现象并发测试时出现“死锁 found”原因两个事务以不同顺序锁定座位行。解决在应用层对要购买的多个座位按inventory_id排序后再依次锁定。如果课设只买一张票检查是否在事务里混用了SELECT ... FOR UPDATE和UPDATE且WHERE条件不一致。5.3 现象退票后余票数量没增加原因退票只改了ticket_order状态忘了改seat_inventory状态。解决退票存储过程里必须同时更新两张表且放在同一事务。检查seat_inventory的status是否从 1 改回 0order_id是否置空。5.4 现象查余票页面加载超过 3 秒原因seat_inventory表没建索引或者WHERE条件用了LIKE %...%。解决确认idx_schedule_status索引存在用EXPLAIN看执行计划确保type不是ALL。如果数据量超过 10 万行考虑分表或加缓存。5.5 现象外键约束报错导致插入失败原因插入ticket_order时passenger_id或schedule_id在父表里不存在。解决先插乘客和车次日程再插订单。课设演示前用SET FOREIGN_KEY_CHECKS0临时关闭外键检查可以救急但答辩时别这么干老师会问。6. 用 Python 写一个并发压测脚本验证你的系统课设答辩时老师经常问“你怎么证明没有超卖”。光说没用跑一个并发脚本把结果甩出来最直观。下面是我常用的 Python 压测脚本用threading模拟 50 个人抢同一张票。import threading import pymysql import time # 数据库连接配置 DB_CONFIG { host: localhost, user: root, password: your_password, database: ticket_system, charset: utf8mb4 } success_count 0 fail_count 0 lock threading.Lock() def buy_one_ticket(thread_id): global success_count, fail_count conn pymysql.connect(**DB_CONFIG) try: with conn.cursor() as cursor: # 调用购票存储过程 cursor.execute( CALL buy_ticket(%s, %s, %s, %s, order_no, result), (1, 1, 1, A01) # 乘客1车次日程11车厢A01座 ) cursor.execute(SELECT order_no, result) order_no, result cursor.fetchone() with lock: if result 0: success_count 1 else: fail_count 1 except Exception as e: with lock: fail_count 1 print(f线程{thread_id}异常: {e}) finally: conn.close() # 重置库存状态 conn pymysql.connect(**DB_CONFIG) with conn.cursor() as cursor: cursor.execute(UPDATE seat_inventory SET status0, order_idNULL WHERE inventory_id1) cursor.execute(DELETE FROM ticket_order WHERE inventory_id1) conn.commit() conn.close() # 启动 50 个线程同时抢 threads [] for i in range(50): t threading.Thread(targetbuy_one_ticket, args(i,)) threads.append(t) start time.time() for t in threads: t.start() for t in threads: t.join() elapsed time.time() - start print(f耗时: {elapsed:.2f}秒) print(f成功: {success_count}, 失败: {fail_count}) print(f预期: 成功1失败49)逻辑说明50 个线程同时调用buy_ticket存储过程抢同一个座位。如果系统正确应该只有 1 个成功49 个失败。如果成功数大于 1说明超卖了。参数说明DB_CONFIG里的密码改成你自己的。buy_ticket的参数按存储过程定义传这里固定乘客 1、车次日程 1、1 车厢 A01 座。压测前先重置库存状态避免上次测试残留影响结果。跑完这个脚本如果输出“成功: 1, 失败: 49”说明你的并发控制是对的。如果成功数大于 1回去检查FOR UPDATE或UPDATE ... WHERE status0的写法。我一般还会把elapsed时间记下来答辩时可以说“50 并发下响应时间 0.3 秒”比空口说性能好更有说服力。最后说个习惯每次改完存储过程我都会先跑一遍这个脚本再演示。课设翻车最惨的不是功能少而是演示时数据对不上。希望帮到你。本文还有配套的精品资源点击获取