车站售票管理系统数据库课设:余票扣减与不超卖实战

发布时间:2026/10/27 10:55:06
车站售票管理系统数据库课设:余票扣减与不超卖实战 简介面向广东工业大学数据库系统课程设计的学生与自学者这份车站售票管理系统课设方案围绕售票、退票、车次与时刻表查询、售票统计等核心业务展开并覆盖操作员管理、权限设置及数据备份恢复等系统维护环节可用于课程设计参考、项目复现与答辩准备。压缩包共100个文件约4.65MB以72个class与21个java源文件为主体另含1个sql数据库脚本、项目配置文件和安装说明文档便于在本地导入工程、建库运行并对照理解模块划分。目前已有1159人学习。读者可获得可运行的Java实现、数据库建表脚本和安装说明书据此梳理售票、退票、查询统计与权限管理的实现思路也能借鉴课设项目的目录组织与功能拆分方式适合需要完成同类数据库课程设计或进行Java桌面应用练习的读者参考。1. 拆开一份车站售票管理系统数据库系统课程设计到底该交出什么每年课设季被问得最多的一类问题不是“选什么题目”而是“表建完了、界面也跑起来了答辩老师一追问余票怎么扣就哑了”。这份车站售票管理系统的数据库系统课程设计资源解决的正是这个断层它不是只挂个界面的演示程序而是把车次、经停、席位库存、订单、退票、改签这几条真实业务链完整翻译成了表结构、约束、事务和查询语句。适合刚学完 SQL 语法、要独立完成一次库设计的人也适合工作两三年想回头补关系建模基本功的从业者。余票为什么不能只存一个数字、退票时座位和钱谁先动、并发下怎么不超卖这些问题在库里都能落到可运行的语句上。2. 从需求到 ER 图把售票业务拆成 8 张表并过一遍 3NF2.1 先划边界卖票系统管到哪一步为止动手画图之前先把“这个系统管什么”钉死。车站售票管理系统的核心用例其实就七条按日期和起终站查车次、查某车次某席别的余票、下单锁座、支付出票、退票、改签、按订单号或证件号查历史。边界之外的东西要主动砍掉比如车站大屏调度、列车运行图排班、财务对账、积分营销这些都属于另外的系统硬塞进来只会让 ER 图变成一团毛线。我一般会把边界写成一张两列的表左边是“本系统负责”右边是“本系统只读或不管”。比如“车次基础信息”只读自调度系统、“席位总数”由车型定员决定本系统只负责“卖了多少”。这么写的好处是答辩时被问到“为什么没有排班表”你能一句话答上来而不是现场编。另外提前定一个规则一条订单可以含多张车票同行人一起买一张车票对应一个乘客一个座位。这个一对多关系后面会直接决定订单表和车票表要不要拆开很多人在这里图省事合成一张表改签的时候就只能整单改逻辑立刻别扭。2.2 实体识别从需求句子里抠名词把上面七条用例逐句拆开名词基本就是候选实体。查车次涉及车站、车次、经停站查余票涉及车次、日期、席别下单涉及乘客、订单出票涉及车票退票涉及退票记录。合起来正好八张核心表多一张嫌胖少一张就有人要存冗余字段。表名职责关键字段station车站字典station_code、station_nametrain车次主档train_no、start_station、end_stationtrain_stop经停站与时刻train_no、stop_seq、arrive_time、leave_timeseat_inventory按日期的席位库存train_no、travel_date、seat_type、total_qty、sold_qtypassenger乘客id_card、passenger_name、phoneticket_order订单主表order_no、passenger_id、total_amount、order_statusticket车票明细ticket_no、order_no、seat_no、ticket_statusrefund_record退票流水refund_no、ticket_no、refund_fee、refund_time这里有个容易走偏的地方很多人会把“余票”直接做成 train 表的一个字段。乍看没问题但同一趟车今天卖 300 张、明天卖 280 张余票天然带日期维度塞进车次主档就等于把两个不同粒度的事实揉在一起第一范式都过不了。所以 seat_inventory 必须独立主键是train_no, travel_date, seat_type三元组。2.3 关系模式与范式3NF 是推出来的不是背出来的把 ER 图转关系模式的时候判断标准是函数依赖而不是口诀。以 seat_inventory 为例{train_no, travel_date, seat_type} 决定 total_qty 和 sold_qty反过来任何非主属性都不能决定主属性的一部分这就是标准 3NF。可如果你把 train_no 对应的始发站也塞进这张表始发站只依赖 train_no不依赖整个三元组传递依赖出现表就掉到 2NF。再看 train_stop 这张表为什么要单拆。车次的经停信息是一对多一个车次有十几站每站有站序、到点、发点、里程。如果把它塞进 train 表做成“站1、站2、站3”这样的多列一是站数不固定二是没法按站查“经过某站的所有车次”三是插入一个新经停站要改表结构。拆成子表后主键train_no, stop_seq查询“从 A 站到 B 站有哪些车”就变成一次自连接这才是关系库该有的写法。订单和车票的拆分同理。订单存“谁买的、多少钱、什么状态”车票存“哪张票、哪个座位、什么状态”。一张订单退其中一张票只动车票和库存订单总额再汇总回写。这个结构在改签场景下尤其省事改签本质是“原票置为已改签 生成新票 库存一退一占”三张表各干各的不会互相打结。2.4 数据字典答辩现场被翻得最勤的一页ER 图画得再漂亮落地还得靠数据字典。我的习惯是每张表至少写清五列字段名、类型、是否可空、约束、业务含义。以一个精简版 seat_inventory 为例字段类型可空约束说明train_noVARCHAR(12)否联合主键车次号travel_dateDATE否联合主键乘车日期seat_typeVARCHAR(8)否联合主键席别编码total_qtyINT否CHECK ≥ 0定员sold_qtyINT否CHECK ≥ 0已售versionINT否默认 0乐观锁版本写字典的时候顺便把 CHECK 约束也想清楚sold_qty 必须大于等于 0 且不超过 total_qty这条约束在数据库层兜住比在代码里 if 判断靠谱得多。答辩时老师翻到这一页问“已售超过定员怎么办”你能指着约束说“插不进去”比解释十分钟业务逻辑都管用。3. 建表与 DDL 落地MySQL 8 上的字段、约束和索引三件事3.1 主键、外键和字符集三个最容易返工的决定主键选择上车次号这类业务主键可以直用 VARCHAR但订单号我建议用应用层生成的定长字符串而不是自增 BIGINT。原因很实际订单号会出现在用户界面上自增 ID 会暴露下单量而且分库分表时自增主键不好扩展。课设阶段不必真分库但这个习惯值得早养。外键是个有争议的点。课设的评分点里通常有“完整性约束”所以建议保留外键让 ticket.order_no 指向 ticket_order.order_no。但要知道代价每次插入车票都会去订单表加共享锁高并发下容易变成瓶颈。真实生产系统里外键往往被去掉改由应用层保证课设里保留是为了让设计意图一目了然。字符集在 MySQL 8 上没什么好纠结的直接 utf8mb4排序规则用默认的 utf8mb4_0900_ai_ci。别用 utf8那是 utf8mb3 的别名存不了四字节字符也别在不同表上混用排序规则否则关联查询会报“Illegal mix of collations”这个错我第一次遇到时查了整整一个下午。CREATE DATABASE railway_ticket DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_0900_ai_ci;上面这条语句把库级默认编码钉死后面所有表继承省得每张表重复写。参数上只需要改库名字符集和排序规则在 MySQL 8 环境里照抄即可如果目标环境是 5.7把排序规则换成 utf8mb4_general_ci。3.2 核心表建表脚本约束写在 DDL 里先把主档表建好再建库存和订单顺序不能反否则外键找不到父表。CREATE TABLE train ( train_no VARCHAR(12) NOT NULL COMMENT 车次号, start_station VARCHAR(32) NOT NULL COMMENT 始发站, end_station VARCHAR(32) NOT NULL COMMENT 终到站, depart_time TIME NOT NULL COMMENT 始发时刻, train_type VARCHAR(8) NOT NULL COMMENT 车型G/D/K, PRIMARY KEY (train_no) ) ENGINEInnoDB COMMENT车次主档; CREATE TABLE seat_inventory ( train_no VARCHAR(12) NOT NULL, travel_date DATE NOT NULL, seat_type VARCHAR(8) NOT NULL COMMENT 席别二等座/一等座/卧铺, total_qty INT NOT NULL COMMENT 定员, sold_qty INT NOT NULL DEFAULT 0 COMMENT 已售, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, PRIMARY KEY (train_no, travel_date, seat_type), CONSTRAINT ck_sold CHECK (sold_qty 0 AND sold_qty total_qty) ) ENGINEInnoDB COMMENT按日期与席别的库存; CREATE TABLE ticket_order ( order_no VARCHAR(32) NOT NULL COMMENT 订单号, passenger_id BIGINT NOT NULL COMMENT 下单人, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总额, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退票, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (order_no), KEY idx_passenger (passenger_id, created_at) ) ENGINEInnoDB COMMENT订单主表;第一段建车次主档只放跟日期无关的静态属性第二段建库存表主键三元组保证同车次同日期同席别只有一行CHECK 约束把“超卖”这件事在存储层堵死第三段建订单表金额用 DECIMAL 而不是 DOUBLE因为浮点数做金额累加会出现 0.10.2 这类经典误差退票手续费一算就对不上账。索引 idx_passenger 是给“我的订单”列表页准备的按乘客加时间倒序覆盖最常见的查询路径。参数上要注意两点ENGINE 必须显式写 InnoDBMyISAM 不支持事务课设里做并发扣减直接没法玩created_at 用 DEFAULT CURRENT_TIMESTAMP 让数据库填时间比应用层传时间更不容易因时区出错。3.3 索引按查询路径倒推别凭感觉加索引不是越多越好每多一个索引写入就多一次 B 树维护。正确做法是先把高频查询列出来再倒推索引。车站售票系统里跑得最频繁的三条 SQL 大概是按车次日期席别查余票、按乘客查订单、按订单查车票明细。-- 余票查询走主键无需额外索引 SELECT total_qty - sold_qty AS remain FROM seat_inventory WHERE train_no G1001 AND travel_date 2025-06-01 AND seat_type 二等座; -- 车票明细按订单号查建普通索引 CREATE INDEX idx_ticket_order ON ticket (order_no); -- 按证件号查历史订单乘客表上建唯一索引 CREATE UNIQUE INDEX uk_id_card ON passenger (id_card);第一条查询直接命中联合主键的最左前缀这是把 train_no 放在主键第一位的回报。第二条给 ticket.order_no 建索引因为订单详情页要按订单捞票。第三条用唯一索引而不是普通索引既加速查询又顺带保证一个证件号只注册一次一举两得。这里有个常见误用给 seat_type 单独建索引。席别取值就三五种区分度极低优化器八成不会走它白白占空间。判断一个字段该不该建索引看区分度——不同值数量除以总行数低于 0.1 的基本可以放弃除非要拿它做覆盖索引。3.4 金额、时间、状态三个字段类型的血泪经验金额用 DECIMAL(10,2)这条前面提过但还有个坑退票手续费按比例算出来是小数四舍五入的规则要统一。我一般规定“手续费向上取整到分”并在应用层用同一套工具函数避免数据库里存 12.34、界面上显示 12.35。时间字段分三种粒度depart_time 存 TIME每天固定发车时刻travel_date 存 DATE乘车日created_at 存 DATETIME精确到秒的下单时刻。千万别用一个 DATETIME 通吃否则查“明天所有车次”要写一堆函数索引也用不上。状态字段用 TINYINT 加注释别用中文 ENUM。ENUM 改值要 ALTER TABLE锁表不说历史数据迁移还容易出错。状态机建议在文档里画清楚0 待支付 → 1 已支付 → 3 已退票以及 0 → 2 已取消允许的迁移路径写死后面写触发器或应用层校验都有依据。4. 余票扣减不超卖事务隔离级别与悲观锁、乐观锁两套写法4.1 超卖是怎么发生的一个读-改-写的竞态先看一段看起来毫无问题的代码# 错误示范查与改分离中间没有锁 cur.execute(SELECT sold_qty, total_qty FROM seat_inventory WHERE train_no%s AND travel_date%s AND seat_type%s, (train_no, travel_date, seat_type)) sold, total cur.fetchone() if sold total: cur.execute(UPDATE seat_inventory SET sold_qty sold 1 WHERE ...) conn.commit()单线程跑一万遍都不会出错一上并发就翻车。两个请求同时读到 sold99、total100都判断“还有票”都执行 UPDATE 写成 100结果卖出 101 张票座位只有一个。这就是经典的读-改-写竞态问题的根子在于“读”和“写”之间没有原子性。MySQL 默认隔离级别是 REPEATABLE READ普通 SELECT 是快照读读的是事务开始时的版本根本看不到别人刚提交的修改所以连“读到最新值”都做不到。要解决要么让读变成当前读并加锁要么把判断条件塞进 UPDATE 的 WHERE 里让数据库自己保证原子。4.2 悲观锁SELECT ... FOR UPDATE 的正确姿势悲观锁的思路是“先占住这行别人排队”。START TRANSACTION; -- 当前读 行锁锁住这一行库存 SELECT sold_qty, total_qty FROM seat_inventory WHERE train_no G1001 AND travel_date 2025-06-01 AND seat_type 二等座 FOR UPDATE; -- 应用层判断 sold_qty total_qty 后执行 UPDATE seat_inventory SET sold_qty sold_qty 1 WHERE train_no G1001 AND travel_date 2025-06-01 AND seat_type 二等座; INSERT INTO ticket (ticket_no, order_no, seat_no, ticket_status) VALUES (T20250601001, O20250601001, 08车12A, 1); COMMIT;这条链路的关键在 FOR UPDATE它把快照读升级成当前读并加排他行锁第二个事务进来会阻塞直到第一个提交。三个语句放在同一个事务里要么全成要么全滚不会出现“票扣了但车票没生成”的脏数据。参数上有两点要留意。一是锁定粒度WHERE 条件必须命中索引否则 InnoDB 会退化成锁全表并发直接归零这里命中的是联合主键锁的是一行没问题。二是锁的持有时间事务里别夹带网络调用或用户交互比如“锁住库存等用户付款”那是给自己挖坑正确做法是下单时就扣减超时未支付再由定时任务回补。悲观锁的优点是逻辑直白、不会重试缺点是并发高时排队明显且一旦事务里再锁别的表很容易凑出死锁。4.3 乐观锁版本号 有限重试乐观锁反过来假设冲突不常发生先改改不动再重试。def book_seat(conn, train_no, travel_date, seat_type, retry3): for attempt in range(retry): with conn.cursor() as cur: # 先拿当前版本号 cur.execute(SELECT version FROM seat_inventory WHERE train_no%s AND travel_date%s AND seat_type%s, (train_no, travel_date, seat_type)) row cur.fetchone() if not row: return False, 库存记录不存在 version row[0] # 带版本号和余量双重条件的原子更新 cur.execute(UPDATE seat_inventory SET sold_qty sold_qty 1, version version 1 WHERE train_no%s AND travel_date%s AND seat_type%s AND version%s AND sold_qty 1 total_qty, (train_no, travel_date, seat_type, version)) if cur.rowcount 1: conn.commit() return True, 占座成功 conn.rollback() # 版本被别人改过或余票不足重来 return False, 系统繁忙请重试核心在 UPDATE 的 WHERE 里同时塞了 version 和 sold_qty 1 total_qty 两个条件。数据库执行单行更新时本身持有行锁条件判断和赋值在一条语句内完成天然原子。rowcount 为 1 说明抢到了为 0 说明要么版本过期要么票卖完了回滚重试。参数说明retry 设 3 次是经验值再多说明热点冲突严重该换悲观锁或排队方案了version 每次成功更新加 1不能复用条件里的 sold_qty 1 total_qty 不能省只靠版本号挡不住“票已售罄但版本刚好匹配”的情况。乐观锁适合读多写少、冲突率低的场景比如查询余票用无锁快照读只有下单才走乐观更新。真要抢热门车次的票冲突率高重试次数会飙升这时候悲观锁反而更稳。4.4 退票与改签事务边界和加锁顺序退票的事务边界要划清楚锁库存行 → sold_qty 减一 → 车票状态置为已退 → 写退票流水 → 更新订单状态。五步一个事务任何一步失败全滚。START TRANSACTION; UPDATE seat_inventory SET sold_qty sold_qty - 1 WHERE train_no G1001 AND travel_date 2025-06-01 AND seat_type 二等座; UPDATE ticket SET ticket_status 3 WHERE ticket_no T20250601001; INSERT INTO refund_record (refund_no, ticket_no, refund_fee, refund_time) VALUES (R20250602001, T20250601001, 12.50, NOW()); COMMIT;改签更复杂本质是两次库存变更加两次车票状态变更必须放在同一个事务里并且严格按“先锁旧车次、再锁新车次”的固定顺序否则 A 改签到 B、B 改签到 A 同时发生就会死锁。加锁顺序这件事没有玄学全靠约定所有涉及多行库存的操作一律按train_no, travel_date, seat_type的字典序加锁全系统统一死锁率立刻降下来。5. 避坑实录五个把课设从“能跑”拖成“跑不起来”的现场问题5.1 明明显示有票点下单却提示余票不足现象列表页显示还剩 5 张点进去下单报“票已售完”。原因列表页读的是从库或者缓存里的快照数据下单读的是主库当前值中间隔了几百毫秒票被别人抢走了。这不是 bug是展示与扣减的数据源不一致。解决把“剩余张数”的展示口径改成“仅供参考”下单页刷新时才做一次实时校验如果一定要精确列表页的余票也走当前读代价是并发下降。课设里我通常选前者并在文档里写清楚这个取舍反而成了加分项。5.2 插入车票时报 1452外键约束失败现象Cannot add or update a child row: a foreign key constraint fails。原因ticket.order_no 指向 ticket_order.order_no但订单还没插入就先插了车票或者订单号拼错、大小写不一致。解决按父表到子表的顺序插入订单先落库再落车票订单号统一由应用层生成不要一处用 UUID 一处用时间戳。排查时先SELECT * FROM ticket_order WHERE order_no ...确认父记录存在再看两边的字符集和排序规则是否一致混用排序规则同样会报 1452而且错误信息完全不提排序规则特别容易误判。5.3 中文站名入库变成一串问号现象插入“北京南”变成“???”。原因连接串没指定字符集客户端用 latin1 连进来服务端按 utf8mb4 存转换时丢失。解决连接串显式加charsetutf8mb4JDBC 的话在 URL 后面挂?characterEncodingutf8useUnicodetrue同时确认库、表、列三级字符集一致。已经写坏的问号数据救不回来只能删了重插所以建库那一步就要把默认字符集钉死别指望事后补救。5.4 并发压测跑几十个线程就报 1213 死锁现象Deadlock found when trying to get lock; try restarting transaction。原因两个事务以相反顺序锁两行库存比如一个先锁 G1001 再锁 G1002另一个反过来互相等对方释放。解决全系统统一加锁顺序按主键字典序从小到大锁退票、改签这类涉及多行的操作尤其要注意。另外把事务里的非数据库操作日志、HTTP 调用、文件写入全部挪到事务外缩短持锁时间。压测时打开SHOW ENGINE INNODB STATUS看 LATEST DETECTED DEADLOCK 那一段能直接看到是哪两条 SQL 在互等比猜快得多。5.5 报表查询越跑越慢最后超时现象统计某日各车次上座率的 SQL 一开始 200ms数据量到十万行后要十几秒。原因在 WHERE 里对日期列做函数运算比如DATE(created_at) 2025-06-01索引直接失效全表扫描。解决改成范围查询created_at 2025-06-01 AND created_at 2025-06-02让索引能用上统计类查询单独建汇总表按日预聚合别每次都扫明细。验证方法很简单在 SQL 前面加 EXPLAIN看 type 是不是 ALL是就说明没走索引回去改条件写法。6. 验收与进阶用执行计划和统计视图把设计说圆6.1 拿 EXPLAIN 当体检报告设计做完别急着交挑五条最核心的 SQL 逐条过 EXPLAIN。重点看三列type 最好是 const、eq_ref、range出现 ALL 就是全表扫rows 是预估扫描行数跟实际结果集差太多说明统计信息过期跑一次ANALYZE TABLEExtra 里出现 Using filesort 或 Using temporary 都值得停下来想想能不能靠索引消掉。EXPLAIN ANALYZE SELECT s.train_no, s.total_qty, s.sold_qty, ROUND(s.sold_qty / s.total_qty * 100, 2) AS load_rate FROM seat_inventory s WHERE s.travel_date 2025-06-01 ORDER BY load_rate DESC LIMIT 10;EXPLAIN ANALYZE 会真跑一遍并给出实际耗时比单纯 EXPLAIN 的预估值靠谱。这条查询用 travel_date 做过滤因为 travel_date 是联合主键的第二列不满足最左前缀走不了主键索引。如果想让它快就得单独给 travel_date 建索引或者干脆建一张按日期预聚合的统计表——这正是我要说的进阶做法。6.2 建一张日汇总表把报表从明细里解放出来明细表数据量一大任何实时聚合都难受。可行的做法是每天凌晨跑一次汇总把上座率、退票率、热门车次固化下来。CREATE TABLE daily_stats ( stat_date DATE NOT NULL, train_no VARCHAR(12) NOT NULL, seat_type VARCHAR(8) NOT NULL, total_qty INT NOT NULL, sold_qty INT NOT NULL, refund_cnt INT NOT NULL DEFAULT 0, PRIMARY KEY (stat_date, train_no, seat_type) ) ENGINEInnoDB COMMENT按日预聚合统计; INSERT INTO daily_stats (stat_date, train_no, seat_type, total_qty, sold_qty, refund_cnt) SELECT 2025-06-01, s.train_no, s.seat_type, s.total_qty, s.sold_qty, (SELECT COUNT(*) FROM refund_record r JOIN ticket t ON t.ticket_no r.ticket_no WHERE t.train_no s.train_no AND DATE(r.refund_time) 2025-06-01) FROM seat_inventory s WHERE s.travel_date 2025-06-01 ON DUPLICATE KEY UPDATE sold_qty VALUES(sold_qty), refund_cnt VALUES(refund_cnt);ON DUPLICATE KEY UPDATE 让这段脚本可以重复执行跑第二遍不会报主键冲突重跑补数据时特别省心。退票数用关联子查询算数据量小的时候够用真上规模就拆成两条语句分别聚合再 JOIN。6.3 压测脚本不测并发等于没做设计课设里最容易被忽略的一环是验证。写个几十行的脚本开 50 个线程对同一车次抢 100 张票最后核对 sold_qty 是否恰好等于 100。我一般用 Python 的线程池每个线程独立连接、独立事务跑完把成功次数和库存增量比对。第一次跑大概率会对不上那说明锁用错了地方回去看第 4 章。那次压测我印象很深单测全绿一上并发就多卖了 3 张票查了两个小时才发现是某个分支漏了事务包裹。从那以后我每次改完扣减逻辑都强制走一遍“50 线程抢 100 张票”的核对数字对不上就不算完。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询