☰
数据库课设实战:车站售票管理系统表结构设计与事务处理完整拆解
2026/10/14 6:54:14 网站建设 项目流程

简介:这份资源是广东工业大学数据库系统课程设计的个人选题方案——车站售票管理系统,面向正在准备数据库课设的本科生及需要Java+数据库综合练习的开发者。系统围绕售票与退票、车次与时刻表查询、售票统计以及数据备份恢复、操作员权限管理等维护功能展开,可作为课设选题参考或二次开发基础。压缩包共100个文件,约4.65MB,以72个class与21个java源码为主体,另含1个sql建库脚本、jar依赖、classpath与project工程配置,以及doc安装说明书和txt说明,覆盖从建库到运行的完整链路。目前已有1157人学习下载,说明该方案在同类课设中具备一定参考价值。读者可据此快速理解售票系统的功能划分、数据库表结构设计与Java实现思路,并借助安装说明书完成环境部署与调试,适合作为课设起步模板或功能扩展的对照案例。

1. 车站售票管理系统:一份数据库课设的完整拆解与落地复盘

每年到了数据库系统课程设计那几周,后台总有人问同一个问题:有没有那种能跑通、逻辑闭环、表结构设计说得清楚的车站售票管理系统参考?不是那种只有几张截图、代码跑起来一堆报错的“半成品”。这次拿到的这份广工数据库课设资源,恰好就是一个完整度比较高的版本。它解决的核心问题很明确——用一套关系型数据库把“车次查询、余票扣减、订单生成、退票回滚”这条链路串起来,同时配套可执行的建表脚本、后端接口和一份能讲清楚设计思路的文档。适合正在做数据库课设、需要参考表结构设计和事务处理写法的同学,也适合已经工作但想回头补一补数据库实战细节的开发者。下面我按“先看懂它是什么、再动手跑起来、最后说清楚坑在哪”的顺序,把这份资源拆开讲一遍。

2. 先搞清楚数据模型:车站售票系统的表结构到底怎么设计

2.1 核心实体识别与关系推导

拿到任何一个管理系统类课设,第一步不是急着写代码,而是把实体和关系理清楚。车站售票这个场景,核心实体其实就那么几个:车站、列车车次、车厢座位、乘客、订单、支付记录。但真正决定系统能不能用的,是这些实体之间的约束关系。

常见做法是先画一张简化的 ER 图,然后转成关系模式。我一般会按这个顺序推:一个车站有多个车次经过,一个车次有多个座位,一个乘客可以下多个订单,一个订单对应一个车次和一个座位。这里有个容易翻车的地方——很多人会把“座位”直接做成车次表里的一个字段,比如“余票数量”,然后扣减的时候直接UPDATE 车次 SET 余票 = 余票 - 1。这种写法在单用户测试时没问题,一旦并发上来就会出现超卖。正确的做法是把座位单独建表,每个座位一行记录,用状态字段标记是否已售。

这份资源里的表结构设计基本遵循了这个思路,核心表包括:

表名作用关键字段
station车站信息station_id, station_name, city
train车次信息train_id, train_no, start_station, end_station, depart_time
seat座位库存seat_id, train_id, carriage_no, seat_no, status
passenger乘客信息passenger_id, name, id_card, phone
orders订单主表order_id, passenger_id, train_id, seat_id, status, create_time
payment支付流水payment_id, order_id, amount, pay_time, pay_status

这个设计里,seat 表的 status 字段是整条链路的命门。它的取值一般用枚举:0 表示可售,1 表示锁定,2 表示已售,3 表示退票中。订单创建时先把 seat 从 0 改成 1,支付成功后再改成 2,退票时改回 0。每一步都要放在事务里,否则就会出现“订单生成了但座位没锁住”这种玄学问题。

2.2 建表脚本与索引策略

资源里附带的 SQL 脚本可以直接在 MySQL 5.7 或 8.0 上执行。我建议先建库再建表,字符集统一用 utf8mb4,避免中文站名出现乱码。下面这段是核心表的建表语句,我按自己的习惯加了注释和索引:

-- 创建数据库,字符集用 utf8mb4 兼容中文和特殊符号 CREATE DATABASE IF NOT EXISTS ticket_system DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE ticket_system; -- 车站表:站名和城市都建索引,方便按城市筛选 CREATE TABLE station ( station_id INT PRIMARY KEY AUTO_INCREMENT, station_name VARCHAR(64) NOT NULL, city VARCHAR(32) NOT NULL, INDEX idx_city (city) ) ENGINE=InnoDB; -- 车次表:出发站和到达站建联合索引,查询线路时走索引 CREATE TABLE train ( train_id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL UNIQUE, start_station_id INT NOT NULL, end_station_id INT NOT NULL, depart_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, INDEX idx_route (start_station_id, end_station_id), INDEX idx_depart (depart_time) ) ENGINE=InnoDB; -- 座位表:train_id + carriage_no + seat_no 唯一,防止重复插入 CREATE TABLE seat ( seat_id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, carriage_no INT NOT NULL, seat_no VARCHAR(8) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0可售 1锁定 2已售 3退票中', UNIQUE KEY uk_seat (train_id, carriage_no, seat_no), INDEX idx_status (status) ) ENGINE=InnoDB; -- 订单表:订单号唯一,乘客和车次建索引 CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, passenger_id INT NOT NULL, train_id INT NOT NULL, seat_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退票', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_passenger (passenger_id), INDEX idx_train (train_id) ) ENGINE=InnoDB;

这段脚本里有两个参数值得注意。一个是status字段用TINYINT而不是ENUM,原因是 ENUM 在后续加状态值时需要改表结构,TINYINT 更灵活。另一个是seat表的唯一索引uk_seat,它保证了同一车次同一车厢同一座位不会重复插入,这是防止超卖的最后一道防线。索引方面,idx_route和idx_depart是为了车次查询准备的,如果你们的课设要求支持按日期和线路组合查询,这两个索引能明显减少全表扫描。

2.3 事务与锁:余票扣减的正确姿势

余票扣减是这类系统里最容易出问题的地方。我见过不少课设代码直接写成先查再改,中间没有任何锁,测试时一个人点没问题,两个人同时点就出事了。正确的做法是把“查余票”和“扣余票”放在同一个事务里,并且用SELECT ... FOR UPDATE锁住目标行。

-- 开启事务 START TRANSACTION; -- 锁定一个可售座位,FOR UPDATE 防止其他事务同时读到同一行 SELECT seat_id FROM seat WHERE train_id = 1001 AND status = 0 LIMIT 1 FOR UPDATE; -- 假设上一步返回 seat_id = 5001,更新状态为锁定 UPDATE seat SET status = 1 WHERE seat_id = 5001; -- 生成订单记录 INSERT INTO orders (order_no, passenger_id, train_id, seat_id, status) VALUES ('T20250101001', 2001, 1001, 5001, 0); -- 提交事务 COMMIT;

这里的关键是FOR UPDATE,它会在 seat 表上对满足条件的行加排他锁,其他事务再执行同样的查询时会阻塞,直到第一个事务提交。这样就能保证同一个座位不会被两个订单同时锁定。参数上要注意LIMIT 1的位置,它必须在FOR UPDATE之前,否则锁的范围会变大。另外,如果你们的 MySQL 版本是 8.0 以上,也可以考虑用SKIP LOCKED来跳过已被锁定的行,提升并发吞吐,但课设场景下用默认行为就够了。

3. 后端接口与业务逻辑:从查询车次到下单的完整链路

3.1 车次查询接口的实现与分页处理

车次查询看起来简单,但要做到“按出发站、到达站、日期三个条件组合筛选”,SQL 写不好就会变成全表扫描。资源里的后端用的是 Spring Boot + MyBatis,我按自己的习惯把查询逻辑拆成了动态 SQL。下面这段是 Mapper XML 里的核心片段:

<select id="searchTrains" resultType="TrainVO"> SELECT t.train_id, t.train_no, s1.station_name AS startName, s2.station_name AS endName, t.depart_time, t.arrive_time, COUNT(CASE WHEN seat.status = 0 THEN 1 END) AS availableSeats FROM train t JOIN station s1 ON t.start_station_id = s1.station_id JOIN station s2 ON t.end_station_id = s2.station_id LEFT JOIN seat ON seat.train_id = t.train_id <where> <if test="startStationId != null"> AND t.start_station_id = #{startStationId} </if> <if test="endStationId != null"> AND t.end_station_id = #{endStationId} </if> <if test="departDate != null"> AND DATE(t.depart_time) = #{departDate} </if> </where> GROUP BY t.train_id ORDER BY t.depart_time LIMIT #{offset}, #{pageSize} </select>

这段 SQL 里有两个点值得说。一个是COUNT(CASE WHEN seat.status = 0 THEN 1 END),它统计的是每个车次当前可售座位数,比单独再查一次 seat 表要高效。另一个是LIMIT #{offset}, #{pageSize},分页参数由前端传入,一般 pageSize 设 10 到 20 比较合适,太大容易拖慢响应。如果你们的课设要求支持模糊查询站名,可以把startStationId换成stationName LIKE CONCAT('%', #{name}, '%'),但这样索引就用不上了,数据量大的时候要谨慎。

3.2 下单与支付状态流转

下单接口是整个系统里事务最重的地方。它要完成三件事:锁定座位、生成订单、返回订单号。支付接口则负责把订单状态从“待支付”改成“已支付”,同时把座位状态从“锁定”改成“已售”。这两个接口必须分开,因为用户下单后可能不付款,座位不能一直锁着。

// 下单服务层核心逻辑 @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long passengerId, Long trainId) { // 1. 锁定一个可售座位 Seat seat = seatMapper.lockOneSeat(trainId); if (seat == null) { throw new BusinessException("该车次已无余票"); } // 2. 更新座位状态为锁定 seatMapper.updateStatus(seat.getSeatId(), 1); // 3. 生成订单 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setPassengerId(passengerId); order.setTrainId(trainId); order.setSeatId(seat.getSeatId()); order.setStatus(0); orderMapper.insert(order); // 4. 返回订单信息 return convertToVO(order); }

这段代码里@Transactional注解是关键,它保证了三步操作要么全成功要么全回滚。lockOneSeat方法对应的 SQL 就是上一章说的SELECT ... FOR UPDATE。参数上要注意rollbackFor = Exception.class,默认情况下 Spring 只对运行时异常回滚,加上这个配置后受检异常也会触发回滚,避免出现“座位锁了但订单没生成”的情况。

支付接口的逻辑类似,但多了一步校验:先查订单状态是否为待支付,再更新订单和座位。如果订单已经支付过,直接返回成功,不要重复扣款。退票接口则要把座位状态改回可售,订单状态改成已退票,同时记录退票时间。这三个接口的状态流转可以用一张简单的状态机表来管理:

当前状态操作目标状态座位状态变化
待支付支付已支付锁定→已售
待支付取消已取消锁定→可售
已支付退票已退票已售→可售

3.3 接口联调与参数校验

后端写完之后,联调阶段最容易出问题的是参数格式。比如日期参数,前端传的是2025-01-01,后端如果直接用Date类型接收,可能会因为时区问题差一天。我一般会在 Controller 层用@DateTimeFormat(pattern = "yyyy-MM-dd")显式指定格式。另外,乘客的身份证号要做格式校验,订单号要保证全局唯一,这些校验逻辑放在 Service 层比放在 Controller 层更合适,因为 Controller 只负责接收参数和返回结果。

资源里的接口文档写得比较清楚,每个接口的请求方法、参数名、参数类型、是否必填都有说明。我建议在联调之前先把这份文档过一遍,把参数名和前端对齐,不然后面改起来很麻烦。常见做法是用 Postman 先把每个接口单独跑通,再让前端接进来。如果你们课设要求做压力测试,可以用 JMeter 模拟 50 个并发下单,观察有没有超卖或者死锁。

4. 避坑与排查:这份课设跑起来最容易翻车的五个地方

4.1 现象:下单时提示“无余票”,但数据库里明明有可售座位

原因:SELECT ... FOR UPDATE的查询条件里带了status = 0,但事务隔离级别是 REPEATABLE READ,导致其他事务看不到已提交的状态变更。解决:把隔离级别改成 READ COMMITTED,或者在查询时加上READ COMMITTED提示。更稳妥的做法是检查 seat 表的 status 字段是否有索引,没有索引时FOR UPDATE会锁全表,并发一高就互相阻塞。

4.2 现象:支付成功后座位状态没变,订单还是待支付

原因:支付接口的事务没有覆盖座位更新操作,或者更新座位时用的seat_id和订单里的不一致。解决:在支付接口里先根据order_id查出seat_id,再用这个seat_id去更新座位状态,不要从前端传座位 ID。同时确认@Transactional注解加在了 public 方法上,并且没有被同类内部调用绕过代理。

4.3 现象:退票后座位没有释放,余票数量对不上

原因:退票逻辑只改了订单状态,忘了把 seat 表的 status 改回 0。解决:退票接口里必须同时更新 orders 和 seat 两张表,并且放在同一个事务里。建议在数据库层面加一个触发器或者用定时任务扫描“已退票但座位未释放”的异常记录,作为兜底。

4.4 现象:车次查询接口返回重复数据

原因:LEFT JOIN seat之后没有正确分组,导致一个车次有多少个座位就返回多少行。解决:在 SQL 里加GROUP BY t.train_id,并且把非聚合字段都放到 GROUP BY 里,或者用ANY_VALUE()函数包一下。如果用的是 MyBatis,注意resultType对应的 VO 类字段名要和 SQL 里的别名一致。

4.5 现象:项目在本地跑得好好的,换台电脑就报数据库连接失败

原因:配置文件里的数据库地址、端口、用户名、密码是写死的,换环境后没改。解决:把application.yml里的数据库配置抽成环境变量,或者至少留一份application-dev.yml和application-prod.yml。另外,MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver,5.7 是com.mysql.jdbc.Driver,版本不匹配也会报错。

5. 进阶技巧:用存储过程和定时任务把系统做得更“像真的”

课设做到基本功能跑通只是及格线,如果想拿高分或者让系统看起来更完整,可以加两个东西:一个是存储过程,用来批量生成测试数据;另一个是定时任务,用来处理超时未支付的订单。

先看存储过程。手动往 seat 表里插几千条数据很痛苦,用存储过程几秒钟就能搞定:

DELIMITER $$ CREATE PROCEDURE batch_insert_seat(IN trainId INT, IN carriageCount INT, IN seatPerCarriage INT) BEGIN DECLARE i INT DEFAULT 1; DECLARE j INT DEFAULT 1; WHILE i <= carriageCount DO SET j = 1; WHILE j <= seatPerCarriage DO INSERT INTO seat (train_id, carriage_no, seat_no, status) VALUES (trainId, i, CONCAT(i, 'A', j), 0); SET j = j + 1; END WHILE; SET i = i + 1; END WHILE; END$$ DELIMITER ; -- 调用:给车次 1001 生成 8 节车厢,每节 60 个座位 CALL batch_insert_seat(1001, 8, 60);

这段存储过程里,DELIMITER是为了让 MySQL 把整个块当成一个语句执行,不然遇到分号就断了。参数carriageCount和seatPerCarriage可以根据实际车型调整,一般高铁 8 节车厢、每节 60 到 80 个座位比较常见。生成完之后记得查一下总数,确认没有重复插入。

定时任务用 Spring 的@Scheduled注解就能实现。比如每 5 分钟扫描一次超过 15 分钟未支付的订单,把它们取消掉并释放座位:

@Scheduled(fixedRate = 300000) // 每5分钟执行一次 @Transactional(rollbackFor = Exception.class) public void cancelTimeoutOrders() { // 查出15分钟前创建且状态为待支付的订单 List<Orders> timeoutOrders = orderMapper.findTimeoutOrders(15); for (Orders order : timeoutOrders) { // 取消订单 orderMapper.updateStatus(order.getOrderId(), 2); // 释放座位 seatMapper.updateStatus(order.getSeatId(), 0); } }

这里fixedRate = 300000表示每 5 分钟执行一次,单位是毫秒。findTimeoutOrders(15)里的 15 是分钟数,可以根据业务调整。注意这个方法也要加@Transactional,否则取消订单和释放座位可能只成功一半。另外,定时任务在集群环境下会重复执行,课设单机跑没问题,但如果要模拟分布式场景,需要加分布式锁。

最后说一个验证方法:把整个系统跑起来之后,用两个浏览器窗口同时登录不同乘客账号,对同一个车次的最后一张票同时下单。正常情况下应该只有一个成功,另一个提示无余票。如果两个都成功了,说明事务或者锁有问题,回头检查FOR UPDATE有没有生效。从那以后我每次做完这类售票系统,都会强制走一遍并发测试,不然心里没底。希望帮到你。

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

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

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

立即咨询