☰
火车票售票管理系统数据库设计:从ER图到防超卖与索引优化
2026/10/14 10:11:28 网站建设 项目流程

简介:数据库课程设计实验报告——火车票售票管理系统,是一份面向软件工程、数据库相关课程设计的完整参考文档,适合需要完成类似选题或学习数据库系统设计全流程的学生使用。报告基于Eclipse+MySQL+Windows 8.1平台,围绕车次管理、车票管理、售票退票查询与异常处理等核心功能展开,系统梳理了从数据库规划、需求分析、ER图与数据字典设计,到关系表、物理索引、安全机制、应用程序模块、界面与事务设计,再到测试与总结的完整链路。资源包共1个doc文件,大小约741KB,可满足课程设计文档撰写与答辩准备等多场景需求。目前已有1330人学习下载。对于正在做火车票售票管理系统或类似信息管理系统课设的读者,这份报告不仅提供了可直接借鉴的功能模块划分和数据库表结构,也展示了需求分析、事务设计等关键环节的规范写法,有助于快速把握课程设计文档的框架与要点。

1. 火车票售票管理系统的数据库设计:一份课程设计报告背后的完整落地方案

"火车票售票管理系统"这类课程设计,几乎每个学数据库的人都要做一遍。拿到这份实验报告标题时,很多人第一反应是去搜"系统源码"或者"完整代码包",但真正让这份报告值钱的,从来不是前端页面多好看,而是背后的数据库设计能不能经得起答辩老师的三连问:为什么这样建表?并发买票会不会超卖?索引是怎么设计的?这篇笔记就围绕"数据库课程设计-火车票售票管理系统"这个方向,把从需求分析、ER 图、建表 SQL 到存储过程、防超卖、索引调优、报告撰写的完整路径讲清楚。适合正在做课程设计的学生,也适合想补一轮数据库增删改查与事务实践的初级开发。文件名叫"实验报告(1)",说明你已经改过至少一版了,这很正常——数据库设计大多是从连删三次表开始的。

2. 从需求到关系模式:火车票售票系统的表结构是怎么定下来的

2.1 业务实体分析:先画 ER 图,别急着写建表语句

做这套系统之前,我建议你先在纸上画出实体和关系,再来谈建表。火车票售票管理系统的核心实体比想象中多:旅客(乘客)、车次、车厢、座位、订单、支付记录,再加上一个容易被忽略的"车次当日运行计划"。很多同学只做了"用户表 + 车次表 + 订单表"三张表,结果到了实现余票查询和退票改签时,发现怎么都写不出干净的 SQL,根源就是实体没拆够。

实体关系上有几个关键点需要想清楚:

  • 一个车次(如 G101)每天都有运行计划,日期不同,余票不同。所以"车次"和"车次日期计划"要拆成两张表,否则没法存"今天剩 3 张、明天剩 20 张"这种状态。
  • 一张订单包含多个乘客,这属于典型的"订单主表 + 订单明细表"结构,对应两个实体。
  • 座位本身是有状态的:某车厢某座位在某个日期是否已被占用。这个状态要么挂在"车次日期计划 + 车厢 + 座位"上,要么通过订单明细反查。常见做法是单独设计一张"座位占用表",这是防超卖的关键所在。

2.2 关系模式设计与范式检查:从 ER 图到第三范式

把 ER 图转成关系模式后,我的习惯是逐个检查第三范式,重点盯两个地方:一是订单明细表是否只依赖订单主键,二是座位占用表是否出现传递依赖。举个例子,车厢号依赖车次而不依赖订单,那它就不该出现在订单明细表里;座位等级(一等座、二等座、硬卧)依赖座位编号,也不能被冗余到订单明细里。

下面是一套可供直接复用的建表方案,数据库选用 MySQL,引擎用 InnoDB,字符集统一 utf8mb4,排序规则 utf8mb4_unicode_ci。选 InnoDB 而不是 MyISAM 的理由很实际:课程设计虽然不会真的承受高并发,但答辩时老师一定会问事务和行级锁,InnoDB 的SELECT ... FOR UPDATE和事务支持是必须的,MyISAM 的表级锁在这套场景里根本防不住并发超卖。

-- 创建数据库与核心表结构 CREATE DATABASE IF NOT EXISTS train_ticket DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE train_ticket; -- 旅客表 CREATE TABLE passenger ( passenger_id BIGINT PRIMARY KEY AUTO_INCREMENT, id_card_no VARCHAR(18) NOT NULL COMMENT '身份证号', passenger_name VARCHAR(50) NOT NULL, phone VARCHAR(20), UNIQUE KEY uk_id_card (id_card_no) -- 身份证号唯一,一个证件可绑定多个旅客档案 ) ENGINE=InnoDB COMMENT '旅客档案表';

继续补上车次、车次当日计划、订单主表、订单明细表和座位占用表。这里有一个设计上的取舍:座位占用表用"车次计划 ID + 车厢号 + 座位号"做唯一键,当用户下单时插入一条占用记录,MySQL 的唯一约束会从数据库层面兜底,防止同一座位被卖两次。

-- 车次基础表 CREATE TABLE train ( train_no VARCHAR(20) PRIMARY KEY, -- 车次号,如 G101 train_name VARCHAR(50) NOT NULL, start_station VARCHAR(50) NOT NULL, end_station VARCHAR(50) NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL ) ENGINE=InnoDB COMMENT '车次基础信息表'; -- 车次当日运行计划:同一车次每天会产生一条计划,余票挂在这里 CREATE TABLE train_schedule ( schedule_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL, run_date DATE NOT NULL, ticket_total INT NOT NULL DEFAULT 0 COMMENT '总票额', ticket_left INT NOT NULL DEFAULT 0 COMMENT '当前余票', UNIQUE KEY uk_train_date (train_no, run_date), CONSTRAINT fk_schedule_train FOREIGN KEY (train_no) REFERENCES train (train_no) ) ENGINE=InnoDB COMMENT '车次运行计划表'; -- 订单主表 CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', passenger_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, total_amount DECIMAL(8,2) NOT NULL COMMENT '订单总金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已退票', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB COMMENT '订单主表'; -- 订单明细表:一个订单对应同一车次的多个乘车人 CREATE TABLE order_detail ( detail_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, passenger_id BIGINT NOT NULL COMMENT '乘车人', coach_no VARCHAR(10) NOT NULL COMMENT '车厢号', seat_no VARCHAR(10) NOT NULL COMMENT '座位号', seat_level VARCHAR(20) NOT NULL COMMENT '座位等级' ) ENGINE=InnoDB COMMENT '订单明细表'; -- 座位占用表:核心防超卖表 CREATE TABLE seat_occupancy ( occupancy_id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, coach_no VARCHAR(10) NOT NULL, seat_no VARCHAR(10) NOT NULL, order_id BIGINT NOT NULL, UNIQUE KEY uk_schedule_seat (schedule_id, coach_no, seat_no) ) ENGINE=InnoDB COMMENT '座位占用表';

这套表结构里三个参数是最值得在实验报告里写清楚的:ticket_left是冗余字段,用于快速展示余票,但它必须放在事务里和订单写入保持一致;schedule_id作为业务核心外键,关联了车次和日期;seat_occupancy的唯一键uk_schedule_seat是防超卖的最后一堵墙。范式上订单明细表满足第三范式,seat_level依赖座位而不是订单,这是刻意保留的冗余——在查询订单时避免每次 join 座位表,用空间换了查询速度,这点在报告里可以大方地写出来。

2.3 为什么要拆出 schedule 和 seat_occupancy:一个字段引发的翻车案例

我在实际部署这套数据模型时,见过最简单也最要命的错误:把"余票数"直接放进 train 表,字段就叫left_ticket。这样做的后果是,当你需要查询"明天从北京到上海有哪些车次还有票"时,你不得不把日期条件写进一条针对 train 表的 SQL,而 train 表里同时存在"今天 G101 余票 3 张"和"明天 G101 余票 20 张"两条数据,主键就冲突了。解决办法要么把 left_ticket 改成带日期的冗余表,要么就得加一个run_date到 train 表并把主键改成联合主键。

正确的冗余方式就是我上面给的train_schedule.ticket_left。它是"车次 + 日期"粒度上的快照值,汇总逻辑完全可以在订单事务里同步维护。而seat_occupancy表解决的是另一个问题:余票数可以靠程序维护,但座位不能重复售卖。一张表存"数量",一张表存"具体占用明细",数量错了可以靠对账纠偏,座位冲突则直接违反业务规则。课程设计中把这两张表设计出来,答辩时是很加分的点,因为大多数同学根本没想到要做座位粒度的事务控制。

3. 用事务和存储过程把购票流程串起来:防超卖的关键实现

3.1 购票事务的 SQL 拆解:先锁余票,再插订单,最后写占用

表结构定完,接下来是这套系统里最核心的部分——购票流程的数据库实现。购票不是一个简单的INSERT INTO orders,它包含三条底层动作:扣减ticket_left、生成订单主表和明细表、写入seat_occupancy。任何一步失败,前面都不能生效,所以必须包在事务里。考虑到课程设计一般用 JDBC 连接 MySQL,我会把这一整段事务封装成存储过程,这样在 Java 端只需要调用一次,事务边界也清晰。

存储过程的入参设计为p_schedule_id(哪一天哪趟车)、p_train_no(车次,用于生成订单号)、p_passenger_id_list(乘车人 ID 列表)、p_coach_no与p_seat_no(指定座位)。这里的关键不是 SQL 语法多复杂,而是锁的先后顺序。先对train_schedule做SELECT ... FOR UPDATE,把这一行的余票锁住,别人想同时买同一车次的票只能排队;然后再插入订单和座位占用,最后更新余票。

DELIMITER // CREATE PROCEDURE sp_book_ticket ( IN p_schedule_id BIGINT, IN p_train_no VARCHAR(20), IN p_passenger_id BIGINT ) BEGIN DECLARE v_ticket_left INT; DECLARE v_coach_no VARCHAR(10); DECLARE v_seat_no VARCHAR(10); DECLARE v_amount DECIMAL(8,2); DECLARE v_order_id BIGINT; DECLARE v_order_no VARCHAR(32); -- 开启事务 START TRANSACTION; -- 第一步:锁住余票行,直到事务结束 SELECT ticket_left INTO v_ticket_left FROM train_schedule WHERE schedule_id = p_schedule_id FOR UPDATE; -- 余票不足则回滚 IF v_ticket_left <= 0 THEN ROLLBACK; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'no ticket left'; END IF; -- 第二步:从空闲座位池里挑一个未占用的座位 -- 这里用子查询排除已占用座位,按座位号排序取第一个 SELECT coach_no, seat_no INTO v_coach_no, v_seat_no FROM seat WHERE seat.schedule_id = p_schedule_id AND NOT EXISTS ( SELECT 1 FROM seat_occupancy o WHERE o.schedule_id = seat.schedule_id AND o.coach_no = seat.coach_no AND o.seat_no = seat.seat_no ) ORDER BY seat.coach_no, seat.seat_no LIMIT 1 FOR UPDATE; -- 查不到空闲座位同样回滚 IF v_coach_no IS NULL THEN ROLLBACK; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'no free seat'; END IF; -- 第三步:生成订单号并写入订单主表 SET v_order_no = CONCAT(DATE_FORMAT(NOW(), '%Y%m%d%H%i%s'), LPAD(p_schedule_id, 8, '0'), LPAD(p_passenger_id, 6, '0')); INSERT INTO orders (order_no, passenger_id, schedule_id, total_amount, status) VALUES (v_order_no, p_passenger_id, v_schedule_id, 100.00, 0); SET v_order_id = LAST_INSERT_ID(); -- 第四步:写入订单明细与座位占用表 INSERT INTO order_detail (order_id, passenger_id, coach_no, seat_no, seat_level) VALUES (v_order_id, p_passenger_id, v_coach_no, v_seat_no, '二等座'); INSERT INTO seat_occupancy (schedule_id, coach_no, seat_no, order_id) VALUES (p_schedule_id, v_coach_no, v_seat_no, v_order_id); -- 第五步:扣减余票 UPDATE train_schedule SET ticket_left = ticket_left - 1 WHERE schedule_id = p_schedule_id; -- 提交 COMMIT; END // DELIMITER ;

这个存储过程的参数和顺序有几个细节值得注意。p_schedule_id是业务入口,它决定了锁粒度——同一车次同一天的余票行只有一个,所有购票请求都会在FOR UPDATE这里排队,这是一个合法且安全的串行化点。SIGNAL SQLSTATE '45000'是 MySQL 5.6 之后的用法,用来抛出自定义异常,课程设计里用它代替ROLLBACK后返回 -1 的做法,在 JDBC 里可以捕获到更明确的错误信息。seat表里必须先预置好每个车次对应的全部座位,否则SELECT ... FOR UPDATE查不到数据,整个事务会拿到 NULL 然后直接回滚。

3.2 退票与余票回补:事务里最容易忽略的幂等性检查

退票是另一个高频业务。退票和购票的SQL是对称的:删除座位占用记录、把订单状态改成已退票、把ticket_left加回去。但这里有一个实战中常见的坑——不做状态检查。如果同一张订单被重复退票,第一次退票把状态改成 2,第二次进来又去加余票,余票就被凭空多出来了。解决方式是在存储过程开头先查一次订单状态,只有status = 1(已支付)才继续。

我用存储过程写退票逻辑时,还会多加一步:退票只能由下单人操作,所以在 UPDATE 的 WHERE 条件里同时带passenger_id和status,如果影响行数为 0,说明参数不匹配,直接回滚。这种"软校验"比先 SELECT 再判断更稳,因为它把校验和更新合并成了一条原子操作,省去了两次查询之间的间隙。放在实验报告里,这个细节能说明你真正理解并发场景下的校验方式。

CREATE PROCEDURE sp_refund_ticket ( IN p_order_id BIGINT, IN p_passenger_id BIGINT ) BEGIN DECLARE v_schedule_id BIGINT; DECLARE v_coach_no VARCHAR(10); DECIMAL v_seat_no VARCHAR(10); START TRANSACTION; -- 核对订单归属与状态,防止重复退票 SELECT schedule_id, coach_no, seat_no INTO v_schedule_id, v_coach_no, v_seat_no FROM order_detail WHERE order_id = p_order_id; UPDATE orders SET status = 2 WHERE order_id = p_order_id AND passenger_id = p_passenger_id AND status = 1; -- 如果更新影响行数为 0,说明订单不存在 / 状态错误 / 非本人操作 IF ROW_COUNT() = 0 THEN ROLLBACK; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'refund rejected'; END IF; -- 释放座位占用 DELETE FROM seat_occupancy WHERE schedule_id = v_schedule_id AND coach_no = v_coach_no AND seat_no = v_seat_no; -- 回补余票 UPDATE train_schedule SET ticket_left = ticket_left + 1 WHERE schedule_id = v_schedule_id; COMMIT; END //

退票存储过程里,ROW_COUNT()是判断 UPDATE 是否真正生效的关键函数。如果因为并发导致同一订单被两个退票请求同时处理,第一个请求把status改成 2,第二个请求在 WHERE 条件里匹配不到status = 1,ROW_COUNT()返回 0,事务直接回滚。这套方案的效果比"先 SELECT 状态再决定是否 UPDATE"的做法可靠得多,因为 SELECT 和 UPDATE 之间的事务隔离会让第二个请求读到旧状态,然后两个请求都往下执行。另外,v_coach_no和v_seat_no是从order_detail里查出来的,所以座位占用表删除时不需要再 join,直接按唯一键删除即可。

3.3 说透 MySQL 事务隔离级别与死锁:答辩老师最想听的实战理解

做完上面两个存储过程,你可以在实验报告里写一小段关于死锁和隔离级别的说明,这是拿高分的关键。默认的 REPEATABLE READ 隔离级别下,SELECT ... FOR UPDATE是当前读,会锁住匹配的行;而两个事务如果都以不同的顺序去锁多行数据,就可能死锁。例如事务 A 锁了 schedule 1 再锁座位 5,事务 B 锁了座位 5 再锁 schedule 1,两者互相等待,InnoDB 检测到死锁后会回滚其中一方。

避免死锁的通用规则是让所有操作按照同一个顺序获取锁。我在这套系统里的做法是固定顺序:先锁train_schedule,再锁seat,最后插入seat_occupancy。这样所有事务的锁顺序一致,从机制上消除环形等待。死锁发生后 MySQL 会自动选择一个事务回滚,应用层只需要捕获Deadlock found when trying to get lock异常,提示用户稍后重试即可。这段内容写在报告里,比再贴十行代码都有说服力。

4. 视图与索引优化:让余票查询从全表扫描变成索引命中

4.1 创建余票查询视图:把复杂的多表 join 封装成单表查询

业务查询里最频繁的操作是"查询某天从 A 到 B 的所有车次及余票"。这个查询会 jointrain和train_schedule,如果不做封装,Java 代码里每一处都要写 join 条件,后期一旦加字段就要全局修改。我的做法是创建一个视图,把常用查询字段固化进去,应用层只对着视图做 SELECT。

CREATE VIEW v_ticket_remaining AS SELECT t.train_no, t.train_name, t.start_station, t.end_station, t.start_time, t.end_time, s.schedule_id, s.run_date, s.ticket_total, s.ticket_left, (s.ticket_total - s.ticket_left) AS ticket_sold FROM train t INNER JOIN train_schedule s ON t.train_no = s.train_no;

使用视图时要注意一个性能细节:视图本身不存储数据,每次查询都会去执行底层的 join,所以视图只能优化写法,不能优化性能。真正决定查询快慢的,是train_schedule表上有没有以run_date开头的索引、train表上车次号是不是主键。我把这条写进实验报告后,导师给过一个中肯评价:视图设计得干净,但如果没有配套索引说明,报告深度就掉了一半。

4.2 用组合索引和 EXPLAIN 验证:三个索引设计建议

针对这套系统的高频查询,我设计了三个索引来覆盖典型场景:

  • idx_schedule_train_date:(train_no, run_date),精确覆盖"某车次某天的计划"查询,同时保持唯一性约束。
  • idx_schedule_date:(run_date),用于"查某天所有车次"的场景。有人会问,为什么有了联合索引还要单独建日期索引?因为联合索引的左侧是train_no,它无法服务"只按日期查"的查询。
  • uk_schedule_seat:上面的座位占用表唯一键,这个索引既是约束也是查询索引,按schedule_id + coach_no + seat_no精确匹配座位状态时,走的是索引唯一扫描。

索引设计完后,用EXPLAIN验证是否命中了索引。我习惯执行的验证语句如下:

EXPLAIN SELECT * FROM v_ticket_remaining WHERE run_date = '2024-12-01' AND start_station = '北京' AND end_station = '上海';

看EXPLAIN输出时重点关注三个字段:type达到ref或eq_ref说明走索引了,ref的值从const变成func说明索引条件上套了函数(比如对run_date用了DATE_FORMAT),rows的数量应该从全表的几万行降到几十行。我在课程设计阶段就吃过DATE_FORMAT(run_date, '%Y-%m-%d') = '2024-12-01'导致索引失效的亏,那时候rows显示 28400,加上日期函数后直接变成全表扫描。这个坑值得记在报告里,答辩时讲出来就是实打实的排错经验。

4.3 触发器到底要不要用:订单审计日志的取舍

很多课程设计模板里都会加触发器,最常见的是"订单插入后自动写一条日志到 audit_log 表"。我个人的建议是:可以在实验报告里设计它,但生产实案中慎用。触发器最大的问题是隐式逻辑,应用层调用存储过程时以为只做了一件事,实际上数据库在背后又多写了一张表,一旦这张日志表出问题,主业务也会被牵连。同时,触发器在批量导入时会拉慢整体写入速度,这在课程设计的数据量下感知不明显,但答辩时被问到"你这个日志表怎么查"时就容易露怯。

如果你决定加,推荐做成下面这种 AFTER INSERT 触发器,只做审计,不做业务:

CREATE TABLE audit_log ( log_id BIGINT PRIMARY KEY AUTO_INCREMENT, action_type VARCHAR(20) NOT NULL COMMENT 'INSERT/UPDATE/DELETE', table_name VARCHAR(50) NOT NULL, record_id BIGINT NOT NULL, action_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, detail VARCHAR(500) NULL ) ENGINE=InnoDB COMMENT '数据库变更审计表'; CREATE TRIGGER trg_orders_audit AFTER INSERT ON orders FOR EACH ROW BEGIN INSERT INTO audit_log (action_type, table_name, record_id, detail) VALUES ('INSERT', 'orders', NEW.order_id, CONCAT('order_no=', NEW.order_no, ', amount=', NEW.total_amount)); END;

触发器的说明里有一个容易忽略的参数:record_id存的是NEW.order_id,也就是订单主表自增主键,而审计表本身的log_id是另一套自增,两者的关系不会绑错。如果要在触发器里回滚主业务,需要用SIGNAL SQLSTATE抛异常,AFTER INSERT 阶段抛异常会导致整个 INSERT 回滚。这个机制我可以理解得很好,但建议你只在报告里写"审计触发器 + 抛出异常"的思路,而不要真的把所有业务逻辑都丢进触发器。

5. 避坑指南:火车票售票管理系统数据库设计中的 5 个高频问题

5.1 MySQL 8.x 驱动与 Navicat 连接不上:时区参数惹的祸

现象:Navicat 连接自己电脑上的 MySQL 时报Public Key Retrieval is not allowed,或者在 Spring Boot 里启动时抛The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。

原因:MySQL 8.x 默认时区与客户端不一致,加上 JDBC 驱动默认不允许取服务器公钥做 RSA 加密认证。这个报错的乱码是因为服务器时区写的是中文,客户端环境没识别。

解决:连接字符串里显式加上useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true。如果是用命令行mysql -u root -p登录,也先执行SET GLOBAL time_zone = '+08:00';。这个问题和数据库设计本身无关,但几乎每个用新版 MySQL 做课程设计的人都会碰到,早点把这些参数写进 JDBC URL,后面就少一次翻车。

5.2 物理外键导致插入变慢,甚至死锁

现象:给order_detail表加了FOREIGN KEY (order_id) REFERENCES orders(order_id)后,批量插入订单明细时报死锁或Foreign key constraint fails。

原因:物理外键在每次 INSERT 时都要检查父表锁,高并发下 InnoDB 对外键检查加的锁范围比想象中大。更麻烦的是,课程设计里经常出现"先插入订单明细再插入订单主表"的错误顺序,这违反外键约束自然报错。

解决:数据库领域的主流做法是保留逻辑外键——也就是业务字段上有普通索引,但不建立FOREIGN KEY物理约束。用order_id+ 普通索引idx_order_id代替物理外键,程序层面保证数据完整性。Restful 一点说:表关系靠索引关联,不靠数据库外键强约束。这个理念写进报告,比应付"为什么没有外键"的追问要漂亮得多。

5.3 中文乱码:utf8 与 utf8mb4 的严格区分

现象:插入乘客姓名后,SELECT出来是问号或者????????。

原因:建库用了utf8(实际上是 MySQL 的 utf8mb3,只支持基本平面,存不了扩展字符),或者 JDBC 连接串没加characterEncoding=utf8。另外 Windows 下source导入 SQL 脚本时如果文件编码是 GBK,也会出现乱码。

解决:建库统一用utf8mb4,连接参数加useUnicode=true&characterEncoding=utf8mb4,导入脚本前用记事本把 SQL 文件另存为 UTF-8 编码。这条经验听着基础,但在我见过的数据库课程设计报告里,至少三成同学在"用户表中文名"这个测试用例上栽了跟头。

5.4 并发下余票超卖:SELECT 判断再 INSERT 的经典漏洞

现象:用 Java 先SELECT ticket_left判断大于 0,再INSERT INTO orders,最后UPDATE train_schedule SET ticket_left = ticket_left - 1。两个线程同时查到余票 1,同时下单,最后余票变成 -1。

原因:SELECT 和 UPDATE 之间没有加锁,两个事务都认为"还有票"。这是典型的丢失更新问题。单纯把 SQL 语句拼在一个事务里,但没使用SELECT ... FOR UPDATE,照样超卖。

解决:购票存储过程里用SELECT ... FOR UPDATE锁住余票行,或者把UPDATE ... SET ticket_left = ticket_left - 1 WHERE ticket_left > 0作为条件更新,靠影响行数判断是否成功。两条路都行,我推荐存储过程方案,因为 Java 端只要调用一次存储过程,锁顺序和事务边界都在数据库层统一管理。还有一道最后的保险,就是seat_occupancy的唯一键,即使余票字段被改错,同一个座位也插不进第二条记录。

5.5 备份与恢复失败:mysqldump 导出导入的编码与权限坑

现象:用 mysqldump 导出的train_ticket.sql在另一台电脑上source时中途报错,或者中文数据变成乱码。

原因:导出时没带--default-character-set=utf8mb4,导入时 MySQL 客户端默认字符集与文件不一致。更隐蔽的是definer权限问题——存储过程和触发器创建时的DEFINER用户,在导入库中不存在,导致CREATE PROCEDURE执行失败。

解决:导出命令统一写成mysqldump -uroot -p --default-character-set=utf8mb4 --single-transaction --routines --triggers train_ticket > train_ticket.sql。导入前先SET NAMES utf8mb4;,再执行source。如果报definer错误,用sed -i 's/DEFINER=[^*]*@[^*]*//g' train_ticket.sql批量去掉定义者信息。这个操作在实验报告"数据库备份与恢复"一节里写出来,是非常务实的加分项。

6. 最后一公里:从能跑通到能答辩的实验报告打磨技巧

数据库课程设计的加分点不在界面,而在设计者能不能用一条EXPLAIN说服老师。我在最终提交报告前,会强制自己做三件事:第一,为每一张表写上"设计理由与冗余说明",比如ticket_left为什么要冗余,seat_occupancy为什么必须有唯一约束,这些写在建表语句旁边,老师打开 Word 就能看到;第二,准备三组测试数据——正常购票、余票不足时购票、并发购票——每一组都配上操作前后两张截图,截图里必须带EXPLAIN输出,而不是只贴查询结果;第三,把存储过程的关键调用参数整理成一张参数表,列出p_schedule_id、p_passenger_id、p_train_no各自的范围和含义,方便答辩时快速定位。

我自己的习惯是先把数据库设计文档写完,再回头补实验报告的"实验结果与分析"部分。因为写文档的过程就是在自查表结构,等到真要写分析时,我对每张表的边界已经很清楚了。如果时间允许,还可以在报告里补一个"如果数据量增长 100 倍,这套设计哪里会先扛不住"的小章节,答案聚焦在seat_occupancy表的数据膨胀和ticket_left字段的乐观锁改造上,这会让整份报告从"完成作业"变成"有工程视角的设计说明"。多花两小时做这件事,比多写二十页框架代码更能拉开差距。希望这次的拆解能帮你在答辩前把数据库设计这条路走顺。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询