简介一份面向广东工业大学数据库系统课程设计的车站售票管理系统完整项目以Java实现功能覆盖车票购买与退票、车次及时刻表查询、售票情况统计并包含数据备份恢复、操作员管理与权限设置等系统维护模块可作为课设选题、数据库设计和编码实现的直接参考。资源压缩包共100个文件其中含21个Java源码、72个编译后的class文件、1个SQL建库脚本另附jar包及安装说明书类doc/txt文档整体约4.65MB便于快速部署和阅读源码。目前已有1155人学习下载。借助源码与SQL脚本可直观理解从数据表设计到业务逻辑、界面交互的完整链路适合需要完成数据库课设、梳理应用开发流程或在此基础上做功能扩展的同学使用。1. 车站售票管理系统在课设里考什么别把余票当成一张表的字段车站售票管理系统是某高校数据库课设里出现频率最高的题目之一看起来像普通的增删改查实际考点集中在三件事关系模式设计、事务边界、并发与索引。最容易翻车的认知是把余票当成车次表里的一个整数字段卖一张减一。真实业务里一张票的完整标识至少是「车次 发车日期 出发站区间 座位类型」的组合库存必须按区间拆开扣库存和生成订单必须落在同一个事务里。这篇文章按我平时带课设的流程展开先建表再写事务最后排坑新手可以直接照抄熟手重点看第 4 章和第 5 章的边界。2. 数据模型先行车站、经停、库存、订单四组表的建表 SQL2.1 实体和关系为什么车次和车站之间要多一张经停表拆解业务前先把实体理清楚。一个最简单的车站售票系统至少要覆盖这几个实体车站、车次、经停信息、座位类型、区间库存、订单、订单明细。实体作用关键字段station车站基本信息station_id、station_nametrain车次基本信息train_id、车型、基准票价train_stop车次经过哪些站顺序和里程train_id、stop_seq、station_id、mileageseat_type座位类型及票价倍率seat_type_id、seat_type_name、price_rateinventory某车次某天某区间的余票train_id、run_date、from_seq、to_seq、stockorders一次购票行为的主表order_id、user_id、statusorder_item订单里的每一张票order_id、train_id、run_date、区间、价格其中最容易想不明白的是 train_stop 这张表。车次是一条物理线路比如某车次从 A 站出发经过 B 站终点 C 站。它不是一个简单的一对一关系而是一辆车连着多个车站车站又会被多个车次经过天然是多对多必须用中间表拆分。经停表里每个站带一个自增的 stop_seq从 1 开始以及一个从始发站累计的 mileage 字段里程差就是后面算票价的依据。有了经停表很多设计就顺了。区间库存里的 from_seq 和 to_seq 指的就是经停表里的站序余票查询本质上是把 inventory 和 train_stop 做两次连接一次查出发站一次查到达站再关联 station 表拿到站名。整个系统的查询热度集中在这几张表的连接上所以索引设计比业务代码更重要。2.2 建表 SQL范式、外键与唯一约束一次到位直接给一份能跑通的建表 SQL数据库选 MySQL 8.x存储引擎用 InnoDB字符集用 utf8mb4。这是我在课设里推荐的默认组合原因是 InnoDB 支持事务和行锁utf8mb4 能避免中文乱码。CREATE DATABASE IF NOT EXISTS railway DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE railway; CREATE TABLE station ( station_id INT PRIMARY KEY AUTO_INCREMENT, station_name VARCHAR(50) NOT NULL, city VARCHAR(50) DEFAULT , UNIQUE KEY uk_station_name (station_name) ); CREATE TABLE train ( train_id CHAR(5) PRIMARY KEY, train_type VARCHAR(10) NOT NULL, start_station_id INT NOT NULL, end_station_id INT NOT NULL, base_price DECIMAL(6,2) NOT NULL COMMENT 每公里基准票价, FOREIGN KEY (start_station_id) REFERENCES station(station_id), FOREIGN KEY (end_station_id) REFERENCES station(station_id) ); CREATE TABLE train_stop ( id INT PRIMARY KEY AUTO_INCREMENT, train_id CHAR(5) NOT NULL, stop_seq INT NOT NULL COMMENT 从1开始, station_id INT NOT NULL, arrive_time TIME, depart_time TIME, mileage DECIMAL(8,2) NOT NULL COMMENT 距始发站公里数, UNIQUE KEY uk_train_seq (train_id, stop_seq), FOREIGN KEY (train_id) REFERENCES train(train_id), FOREIGN KEY (station_id) REFERENCES station(station_id) ); CREATE TABLE seat_type ( seat_type_id INT PRIMARY KEY, seat_type_name VARCHAR(10) NOT NULL, price_rate DECIMAL(3,2) NOT NULL COMMENT 票价倍率, UNIQUE KEY uk_seat_name (seat_type_name) ); CREATE TABLE inventory ( id INT PRIMARY KEY AUTO_INCREMENT, train_id CHAR(5) NOT NULL, run_date DATE NOT NULL, from_seq INT NOT NULL, to_seq INT NOT NULL, seat_type_id INT NOT NULL, stock INT NOT NULL, UNIQUE KEY uk_inv (train_id, run_date, from_seq, to_seq, seat_type_id), FOREIGN KEY (train_id) REFERENCES train(train_id), FOREIGN KEY (seat_type_id) REFERENCES seat_type(seat_type_id), CONSTRAINT chk_range CHECK (from_seq to_seq) ); CREATE TABLE orders ( order_id BIGINT PRIMARY KEY, user_id VARCHAR(20) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已退款, create_time DATETIME NOT NULL ); CREATE TABLE order_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, passenger_name VARCHAR(20) NOT NULL, train_id CHAR(5) NOT NULL, run_date DATE NOT NULL, from_seq INT NOT NULL, to_seq INT NOT NULL, seat_type_id INT NOT NULL, price DECIMAL(8,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1有效 0已退, FOREIGN KEY (order_id) REFERENCES orders(order_id), FOREIGN KEY (train_id) REFERENCES train(train_id), FOREIGN KEY (seat_type_id) REFERENCES seat_type(seat_type_id), KEY idx_item_train_date (train_id, run_date) );说明几个设计判断。第一inventory 上必须建联合唯一键 uk_inv否则初始化脚本跑两次就会生成重复库存后面所有查询的余票数都会翻倍。第二orders 和 order_item 拆成两张表是为了体现订单和车票的 1 对 N 关系一张订单可以买多张票其中某张票单独退掉时不影响订单里其他票。第三inventory 里没有把 from_seq、to_seq 设成指向 train_stop 的复合外键。严格设计可以建但 MySQL 的复合外键要求子表先有对应索引课设阶段反而增加删数据的麻烦靠初始化脚本保证合法数据就够答辩时能讲清楚这个取舍即可。2.3 余票的正确建模区间库存而不是单字段我见过不少同学把余票设计成 train 表里的一个 total_tickets 字段这是这个题目最大的坑。假设某车次从青州站经临江站到东岭站如果全车只有一个总余票数卖了一张青州到临江的短途票就要把青州到东岭的全程票库存也减一这不符合实际。真实的售票系统里不同区间的可售席位是独立核算的车型的物理座位可以按区间分配所以库存必须按「车次 日期 出发站序 到达站序 座位类型」拆分。inventory 表里的 stock 值是初始化时手动生成的不是通过计算得到的。初始化时一般写一段循环为每个车次生成未来三十天、每两个站之间、每种座位类型各一条记录初始库存按车型座位数填比如硬座 200、硬卧 80。这里用 date 类型存 run_date而不是 datetime因为同一车次每天一条库存记录日期精度足够还能省索引空间。票价的计算也依赖表和表之间的关系。常见的计费方式是票价 到达站里程 - 出发站里程× 每公里基准价 × 座位类型倍率最后四舍五入到元。里程差直接查 train_stop 表里两个 stop_seq 对应的 mileage 相减所以 train 表里的 base_price 是每公里单价不是整车票总价。把这个公式在 service 层算清楚比在数据库里写存储过程更容易调试也方便答辩时现场改参数。3. 技术选型和项目骨架JDBC Swing 是课设最稳的组合3.1 为什么不用 MyBatis 和 Spring Boot每次带课设都有人问现在企业都用 Spring Boot MyBatis课设还用 JDBC 是不是过时了我的回答是课设和工程项目的目标不一样。课设的评分核心是数据库设计、事务处理和对 SQL 的理解JDBC 把所有环节都暴露在你面前连接怎么开、事务怎么提交、SQL 怎么执行每一步都看得见。Spring Boot 会自动管理连接池和事务反而把这些细节包装成了黑匣子答辩证的时候很容易被问倒。技术选型对比看这张表。对比项JDBC SwingSpring Boot MyBatis环境要求JDK 自带无需额外依赖需要 Maven 拉依赖网络要求高事务控制手写 setAutoCommit原理清晰注解声明式看不到底层SQL 可追踪性全部在手写清晰可见XML 或注解多一层映射答辩展示点能讲清楚每一步更多在讲框架界面方案Swing 内嵌单机演示稳定一般做 Web还要配前端如果学校明确要求用 JavaSwing JDBC 是最不挑环境的一条路。如果学校允许自由选语言用 Python 加 pymysql 也可以事务控制思路完全一样只是把 DriverManager 换成了 pymysql.connect。但考虑到课设环境里 Java 的兼容性最稳我一般建议用 JDBC把时间花在数据库和业务逻辑上不值得跟开发环境较劲。3.2 最小目录结构与连接层三层结构让答辩更好讲代码别全写在 Main 里至少拆成三层界面层、服务层、数据访问层。目录是这样railway-ticketing/ ├── Main.java 入口 ├── db/DbUtil.java 连接管理 ├── dao/InventoryDao.java ├── dao/TrainDao.java ├── dao/OrderDao.java ├── service/TicketService.java ├── ui/MainFrame.java └── resources/schema.sqldao 层只写 SQLservice 层写业务逻辑和事务边界ui 层只处理按钮事件和输入解析。这样答辩时老师问你「这笔购票业务在哪些文件里走了一圈」你能清晰地指出来比在一大坨代码里找逻辑强得多。连接管理用一个 DbUtil 就够了不需要连接池课设的并发量远没到需要连接池的地步。package db; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/railway ?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/ShanghaiuseSSLfalse; private static final String USER root; private static final String PASSWORD 你的密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL驱动加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这里三个参数别省。useUnicodetrue 和 characterEncodingutf8 保证中文字符集和数据库一致serverTimezoneAsia/Shanghai 是因为 MySQL 8.x 驱动默认要求指定时区不写会在连接时直接报错useSSLfalse 是为了避免本地连接做无意义的 SSL 握手不是安全漏洞。Class.forName 里的是 com.mysql.cj.jdbc.Driver注意是 cj这是 MySQL 8.x 的驱动类名MySQL 5.x 时代是 com.mysql.jdbc.Driver写错会抛 ClassNotFoundException。3.3 界面层该有的样子查询、购票、退票三个按钮就够Swing 界面不用做复杂一个主窗口上面放车次号和日期输入框、余票表格、购票按钮、退票按钮就足够覆盖课设要求。界面代码的核心不在布局而在按钮事件里调用了什么。看购票按钮的写法btnBuy.addActionListener(e - { String trainId txtTrainId.getText(); LocalDate runDate LocalDate.parse(txtDate.getText()); int fromSeq Integer.parseInt(txtFromSeq.getText()); int toSeq Integer.parseInt(txtToSeq.getText()); int seatTypeId Integer.parseInt(txtSeatType.getText()); try { TicketService service new TicketService(); service.buy(new TicketBuyRequest(trainId, runDate, fromSeq, toSeq, seatTypeId, txtPassenger.getText())); JOptionPane.showMessageDialog(frame, 购票成功); refreshStockTable(); } catch (BusinessException ex) { JOptionPane.showMessageDialog(frame, ex.getMessage()); } });这段代码 interface 上只做三件事从输入框取参数调用 service弹窗提示结果。余票不足的提示由 BusinessException 从 service 层抛出来界面不关心具体业务规则。refreshStockTable() 是查询余票并刷新表格属于界面层和 dao 层的联动。这样拆开之后你可以在不打开界面代码的情况下单独测试 service 的购票逻辑这也是课设加分的地方。4. 售票与退票的核心事务先扣库存再插订单4.1 售票事务的完整写法UPDATE 带 stock 0 条件售出一张票的完整动作是扣减 inventory 里的库存然后插入一条订单明细。这两步必须在一个事务里否则会出现库存扣了订单没生成或者订单有了库存没扣的情况。事务边界放在 service 层用同一个 Connection 执行多条 SQL。public void buy(TicketBuyRequest req) throws Exception { try (Connection conn DbUtil.getConnection()) { conn.setAutoCommit(false); try { String deductSql UPDATE inventory SET stock stock - 1 WHERE train_id ? AND run_date ? AND from_seq ? AND to_seq ? AND seat_type_id ? AND stock 0; int rows; try (PreparedStatement ps conn.prepareStatement(deductSql)) { ps.setString(1, req.getTrainId()); ps.setDate(2, Date.valueOf(req.getRunDate())); ps.setInt(3, req.getFromSeq()); ps.setInt(4, req.getToSeq()); ps.setInt(5, req.getSeatTypeId()); rows ps.executeUpdate(); } if (rows 0) { conn.rollback(); throw new BusinessException(该区间余票不足请改签或换乘); } long orderId ORDER_ID.incrementAndGet(); try (PreparedStatement ps conn.prepareStatement( INSERT INTO orders(order_id, user_id, status, create_time) VALUES (?, ?, 1, NOW()))) { ps.setLong(1, orderId); ps.setString(2, req.getUserId()); ps.executeUpdate(); } BigDecimal price calcPrice(conn, req); try (PreparedStatement ps conn.prepareStatement( INSERT INTO order_item(order_id, passenger_name, train_id, run_date, from_seq, to_seq, seat_type_id, price, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1))) { ps.setLong(1, orderId); ps.setString(2, req.getPassengerName()); ps.setString(3, req.getTrainId()); ps.setDate(4, Date.valueOf(req.getRunDate())); ps.setInt(5, req.getFromSeq()); ps.setInt(6, req.getToSeq()); ps.setInt(7, req.getSeatTypeId()); ps.setBigDecimal(8, price); ps.executeUpdate(); } conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } } }最关键的是 UPDATE 语句里带了 AND stock 0 这个条件。它的作用是让「检查余票」和「扣减库存」变成一条原子 SQL数据库在 InnoDB 行锁的保护下执行这行语句时同一时间只有一个事务能更新这条库存记录。如果先 SELECT 查出来余票大于 0再执行 UPDATE两个并发请求可能同时读到库存还有 1然后都执行扣减库存变成 -1这就是超卖。用了带条件的 UPDATE 后第二个事务执行时发现 stock 已经不满足大于 0executeUpdate 返回 0事务回滚从根上避免了超卖。注意几个细节。第一扣库存的 SQL 必须放在插入订单之前因为这是整个事务里最可能失败的操作先让它执行失败就回滚不会产生任何中间数据。第二PreparedStatement 的参数全部用 ? 占位符不要拼接字符串防止 SQL 注入也避免日期格式踩坑。第三order_id 我用了 AtomicLong 从当前时间戳开始自增并发下不会重复比手写随机数靠谱也不需要引入雪花算法这类外部依赖。价格计算在 calcPrice 里完成SQL 是查两个经停站的 mileage 差值乘以 train 的 base_price再乘以 seat_type 的 price_rate。这个计算放在事务里执行保证读到的票价和库存属于同一个一致性视图。计算完成后向上取整或四舍五入到元入库时用 DECIMAL 类型保存。4.2 退票与改签回补库存的顺序和状态管理退票是购票的逆操作看起来只是把库存加回去但同样有顺序问题。先回补库存还是先改订单状态直接影响并发下的正确性。我的做法是先锁住订单明细再回补库存最后改状态。public void refund(long orderId, int itemId) throws Exception { try (Connection conn DbUtil.getConnection()) { conn.setAutoCommit(false); try { int ticketStatus; try (PreparedStatement ps conn.prepareStatement( SELECT status FROM order_item WHERE item_id ? AND order_id ? FOR UPDATE)) { ps.setInt(1, itemId); ps.setLong(2, orderId); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) { throw new BusinessException(订单明细不存在); } ticketStatus rs.getInt(status); } } if (ticketStatus 0) { throw new BusinessException(该车票已退过请勿重复操作); } try (PreparedStatement ps conn.prepareStatement( SELECT train_id, run_date, from_seq, to_seq, seat_type_id FROM order_item WHERE item_id ? FOR UPDATE)) { ps.setInt(1, itemId); try (ResultSet rs ps.executeQuery()) { rs.next(); // 这里把取出来的字段用于下面的库存回补 } } try (PreparedStatement ps conn.prepareStatement( UPDATE inventory SET stock stock 1 WHERE train_id ? AND run_date ? AND from_seq ? AND to_seq ? AND seat_type_id ?)) { // 绑定上面查出的参数 ps.executeUpdate(); } try (PreparedStatement ps conn.prepareStatement( UPDATE order_item SET status 0 WHERE item_id ?)) { ps.setInt(1, itemId); ps.executeUpdate(); } // 如果该订单所有明细都已退订单状态置为 2 conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } } }这里用 SELECT ... FOR UPDATE 把明细行锁住防止两个窗口同时退同一张票。FOR UPDATE 是当前读会一直持锁到事务提交第二个请求只能等待不会出现一张票退两次、库存加两次的情况。回补库存用 UPDATE ... SET stock stock 1不需要额外的判断条件因为订单明细是我们系统里生成的合法记录只要它存在且 status1补库存就是安全的。改签不建议做成单独的表结构常见做法就是先退旧票再买新票两步各自走事务新票走 buy 流程生成新的订单旧票走 refund 流程退掉。这个方案胜在状态清晰任何一步失败都不会留下「半改签」的脏数据。如果老师问改签要不要保留原票价你回答「改签后新票按当时价格重新计算差额多退少补」即可课设层面不必实现复杂的原价保留。4.3 余票查询连接经停表与索引命中余票查询是系统里最频繁的操作界面刷新、购票前确认、退票后查看都会用到。它的 SQL 核心是把 inventory 和 train_stop 连两次分别取出发站和到达站的名称。SELECT i.train_id, i.run_date, s1.station_name AS from_station, s2.station_name AS to_station, st.seat_type_name, i.stock FROM inventory i JOIN train_stop ts1 ON ts1.train_id i.train_id AND ts1.stop_seq i.from_seq JOIN train_stop ts2 ON ts2.train_id i.train_id AND ts2.stop_seq i.to_seq JOIN station s1 ON s1.station_id ts1.station_id JOIN station s2 ON s2.station_id ts2.station_id JOIN seat_type st ON st.seat_type_id i.seat_type_id WHERE i.train_id ? AND i.run_date ? ORDER BY i.from_seq, i.to_seq;这条 SQL 的顺序是通过 inventory 的联合索引快速定位某个车次某天的所有区间库存然后按 from_seq 和 to_seq 去 train_stop 里找对应的站再关联 station 拿站名。i.train_id 和 i.run_date 正好命中 uk_inv 联合索引的最左前缀查询效率很高。需要提醒的是联合索引 (train_id, run_date, from_seq, to_seq, seat_type_id) 有一个最左前缀原则查询条件不能跳过中间的列。如果你要按 seat_type_id 查但没带 from_seq 和 to_seq这个索引就用不上会退化成全表扫描。课设的数据量不大全表扫描也能出结果但答辩时能主动说出这条索引规则是明显的加分项。5. 课设高频踩坑与排查乱码、超卖、驱动、外键、重复数据5.1 插入中文显示问号字符集三处不统一现象往 station 表插入「青州站」查询出来是「???」。原因三处字符集不一致。第一处是建库时没有指定 utf8mb4数据库用了默认的 latin1第二处是 JDBC 连接串没带 characterEncodingutf8客户端写入时按系统默认字符集转码第三处是表结构本身是旧的ALTER DATABASE 只能改数据库默认值改不了已经建好的表。这三处只要有一处不一致中文就可能在某个环节变成问号。解决把库删了重建注意是 DROP 再 CREATE不是 ALTER DATABASE。建库语句显式加上 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci连接串里带上 useUnicodetruecharacterEncodingutf8已存在的表用 ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4 逐张转。我的习惯是建库脚本从头到尾完整跑一遍而不是在出问题后打补丁这样能保证环境从头就是一致的。5.2 并发扣票超卖select 后 update 的经典翻车现象库存剩 1两个窗口同时点购票两个都提示成功库存变成 -1。原因代码写成了先 SELECT stock判断大于 0再 UPDATE stock stock - 1。两个事务同时读到 stock 1都认为可以卖于是都执行了扣减。MySQL 默认隔离级别是 REPEATABLE READ普通 SELECT 是快照读不会看到别的事务尚未提交的修改所以后读到的一侧也认为库存是 1。解决把检查改成 UPDATE 语句的一部分即 4.1 里的写法。UPDATE 的 WHERE 条件里带 stock 0数据库对这行记录加排他锁两个事务被强制串行化第一个事务提交后第二个再执行时发现条件不满足影响行数为 0直接回滚。判断 executeUpdate 的返回值等于 0 就抛余票不足这才是正确的 check-and-set 方式。错误写法示例这是反面代码不要照着写int stock queryStock(conn, req); if (stock 0) { updateStock(conn, req); // 超卖点 }正确方式是把两步合并成一条 UPDATE利用行锁保证原子性。这个坑在课设里最容易踩也最容易被老师用来出追问想清楚 REPEATABLE READ 和当前读的区别答辩就稳了。5.3 MySQL 8 驱动与时区类名和 serverTimezone 缺一不可现象程序启动时 Class.forName 抛 ClassNotFoundException或者在 getConnection 时抛 SQLException提示 The server time zone value 无法识别。原因MySQL 8.x 的官方驱动把类名从 com.mysql.jdbc.Driver 改成了 com.mysql.cj.jdbc.Driver写了旧类名自然找不到。时区报错是因为 8.x 驱动默认要求连接时明确时区不写 serverTimezone 参数驱动拿系统默认时区去解析容易解析失败。解决Class.forName 里写 com.mysql.cj.jdbc.Driver连接串加上 serverTimezoneAsia/Shanghai。如果你用的 MySQL 是 5.x驱动要用 5.x 的包类名也保持旧的不要把两边混着用。判断驱动版本最简单的方法是看 jar 包名字里的版本号8.x 开头的用新类名5.x 的用旧类名。这个坑不是技术难度问题纯粹是版本差异踩过一次记住就行。5.4 删除车次报外键失败先删子表还是逻辑删除现象执行 DELETE FROM train WHERE train_id T1001报错 Cannot delete or update a parent row: a foreign key constraint fails。原因train 表是父表train_stop、inventory、order_item 三张表都有外键指向它。只要任何一张子表里还有该车次的记录父表就不能被物理删除这是 InnoDB 外键约束的默认行为。解决先删干净子表数据再删父表。删除顺序必须是 order_item → inventory → train_stop → train。但这里有个更重要的业务问题订单表里的记录是购票凭证不应该被物理删除。所以对已产生订单的车次正确做法不是删记录而是在 train 表加一个 status 字段用逻辑删除标记车次下线。物理删除只用在初始化数据阶段业务运行期间一律用逻辑删除这个思路答辩时一定要说清楚。5.5 初始化脚本跑两次数据翻倍唯一约束的后悔药现象初始化数据脚本执行两遍inventory 表里同一个车次、同一天、同一区间的库存记录出现两条余票查询结果直接翻倍。原因初始化脚本里用 INSERT 无条件插入脚本本身没有做幂等处理跑一次插一次。解决inventory 表已经建了 uk_inv 联合唯一键重复插入会直接报错而不是静默翻倍这是最后的防线。更稳妥的做法是在初始化脚本开头用 TRUNCATE 清空表或者在 INSERT 语句里加 ON DUPLICATE KEY UPDATE让重复执行时更新库存而不是新增记录。两种方式选一种就行我的习惯是脚本开头 TRUNCATE inventory保证每次初始化都是干净状态也方便答辩前反复演示。6. 答辩前最后过一遍演示数据、并发验证和 EXPLAIN答辩翻车通常不是功能没做出来而是演示不可复现。最常见的情况是演示前两天手工往数据库里插了各种数据到答辩现场一操作余票数对不上界面弹出一堆乱码。我的习惯是答辩前把整个初始化流程完整跑一遍先执行 schema.sql 建库建表再执行 init_data.sql 生成基础数据确认每一步输出正常。这个流程在宿舍跑通了去答辩教室换一台电脑也能跑通因为它是从头构建的不依赖任何手工状态。并发验证是值得在现场演示的亮点尤其当你做了 4.1 的带条件 UPDATE 之后。写一个简单的测试类开 50 个线程同时买同一个区间的票初始库存设为 1最终只有 1 个线程成功其余全部提示余票不足。这个演示直接证明你的事务边界是稳定的比口头解释有说服力得多。ExecutorService pool Executors.newFixedThreadPool(50); CountDownLatch latch new CountDownLatch(50); AtomicInteger success new AtomicInteger(); TicketBuyRequest req new TicketBuyRequest(T1001, LocalDate.parse(2025-08-01), 1, 3, 1, 测试乘客); for (int i 0; i 50; i) { pool.submit(() - { try { new TicketService().buy(req); success.incrementAndGet(); } catch (Exception ignored) { } finally { latch.countDown(); } }); } latch.await(); System.out.println(成功购票数: success.get());这段程序结束之后成功购票数永远小于等于初始库存数这就是数据库事务加行锁的效果。注意测试时你的 MySQL 连接数要够50 个线程同时 getConnection 会瞬时占用 50 个连接默认 max_connections 是 151没问题。最后把核心查询拿去做一次 EXPLAIN看执行计划里 key 列是否命中了 uk_invtype 列是不是从 ALL 变成了 refrows 列有没有明显下降。这一步不是走过场它能帮你提前发现索引设计的问题。我自己带课设时见过太多学生功能能跑一问索引就卡壳。你如果能在答辩前打开 EXPLAIN指着 key 列说「这条查询命中了联合索引的最左前缀」评委的追问基本就到此为止了。这套方案做下来数据库设计、事务处理、并发控制、索引优化四个方向都有落点覆盖了课设评分的大头。剩下就是把这些内容按 ER 图、关系模式、事务边界、索引分析四段写进报告图和代码都来自你已经跑通的东西不用额外编造。车站售票管理系统这套题值得认真做一遍投入产出比很高。希望帮到你。本文还有配套的精品资源点击获取