简介车站售票管理系统广工数据库系统课程设计个人方案面向广东工业大学数据库课设及同类课程设计学习者围绕售票、退票、车次与时刻表查询统计、系统维护等业务展开帮助读者理解从需求分析到数据库应用实现的完整流程。压缩包共100个文件约4.65MB以72个class与21个java源码为主体另含1个sql建库脚本、doc安装说明书及项目配置与依赖文件便于还原工程结构、查看功能模块和对照数据库设计。源码模块涉及购票退票、车次信息维护、用户注册与权限管理、查询统计及数据备份恢复配合建库脚本和部署说明可作为课设选题参考、源码研读与二次开发基础。已有1159人学习适合需要完整案例、可运行工程与数据库设计参照的课程设计者。1. 车站售票管理系统一份数据库课设真正卡人的地方答辩前一周一个同学把程序发给我看车站售票管理系统Java 写的桌面端点两下就出票界面上车次、余票、订单列表都有看着挺像回事。我让他开两个客户端同时抢同一趟车最后一张二等座结果两边都弹出“购票成功”订单表里出现了两个一模一样的座位号。这就是数据库课设最容易出问题的地方表建了、外键写了、增删改查都能跑但事务边界和并发控制没人管一压就露馅。这套资源就是一份完整的车站售票管理系统数据库系统课程设计Swing 做界面、JDBC 直连 MySQL功能覆盖车次查询、余票展示、购票下单、订单管理、退票改签和后台车次维护带建库脚本和初始化测试数据。适合正在赶数据库课设、想交一份能跑通也能讲清 ER 图和事务的同学也适合想拿它当练手底子往接口版方向改造的人。2. 技术选型与表结构Swing JDBC MySQL 这套组合为什么够用2.1 为什么是这套技术栈课设项目的技术选型标准跟生产项目不一样。生产上我会先看团队栈、部署环境和后续维护成本课设阶段我一般只看两条能不能在一台没装过开发环境的笔记本上一次跑起来以及代码量能不能在两周内写完还能讲明白。Swing 就是为这两条服务的。它不用起 Web 容器不用配 TomcatMain方法一跑窗口就出来答辩时老师插上 U 盘双击就能演示不会有“服务没起来”这种意外。JDBC 直连 MySQL 的好处是 SQL 全写在明面上SELECT、UPDATE、事务提交回滚都在代码里看得见答辩被问到“你的事务是怎么控制的”翻到BookingService那一页就能指给老师看。换成 MyBatis 或者 JPASQL 被框架藏起来反而不好讲。代价也很清楚JDBC 样板代码多一个查询要写PreparedStatement、ResultSet、手动映射DAO 层会写得很啰嗦Swing 的表格模型和事件监听也不够现代。但这些属于“写起来累”不属于“跑不起来”对课设场景来说是划算的交换。常见做法是把 JDBC 的重复部分收进一个BaseDao把ResultSet到实体的映射抽成一个方法DAO 里只留 SQL 和参数。2.2 目录结构分层不用多四层足够这份资源的工程结构大致是这样组织的包名我做过去标识化处理ticket-system/ ├── pom.xml # Maven 依赖mysql-connector、lombok 可选 ├── sql/ │ ├── schema.sql # 建库建表 索引 唯一约束 │ └── init_data.sql # 车站、车次、余票、测试用户 └── src/main/ ├── java/com/demo/ticket/ │ ├── Main.java # 启动入口拉起登录窗 │ ├── ui/ # Swing 窗体查询、下单、订单、后台 │ ├── service/ # 业务与事务边界事务只在这一层开 │ ├── dao/ # 纯 SQL不写业务判断 │ ├── entity/ # Train、SeatStock、TicketOrder 等 POJO │ └── util/ # DBUtil、日期工具、订单号生成 └── resources/ └── db.properties # 连接串、账号密码跟 jar 一起打包关键约束只有一条事务只能在 service 层开dao 层不许改autoCommit。我见过不少课设代码在 DAO 里conn.commit()结果退票时先提交了订单状态、再扣余票中间报错就出现“票退了但座位没回来”。分层不是为了好看是为了让事务有唯一入口。2.3 三张核心表把并发问题在表结构上先解决一半余票表和订单表是整个系统的关键其余的车站表、用户表都是陪衬。下面这段 DDL 是能直接跑的版本注释里写了每个字段为什么存在-- 车次表一条记录代表一趟车的固定信息不含日期 CREATE TABLE train ( train_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(10) NOT NULL, -- 车次号如 G1234 start_station VARCHAR(32) NOT NULL, end_station VARCHAR(32) NOT NULL, depart_time TIME NOT NULL, arrive_time TIME NOT NULL, run_days TINYINT NOT NULL DEFAULT 127, -- 位图bit0周一开行 UNIQUE KEY uk_train_no (train_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 余票表真正被并发争抢的那张表一天一车次一席别一行 CREATE TABLE seat_stock ( stock_id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, travel_date DATE NOT NULL, seat_type CHAR(1) NOT NULL, -- 1 硬座 2 硬卧 3 二等座 4 一等座 price DECIMAL(8,2) NOT NULL, total INT NOT NULL DEFAULT 0, sold INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_train_date_type (train_id, travel_date, seat_type), KEY idx_train_date (train_id, travel_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表uk_seat 是防重复售卖的兜底别省 CREATE TABLE ticket_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, stock_id BIGINT NOT NULL, seat_no VARCHAR(8) NOT NULL, -- 08车12A amount DECIMAL(8,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已出票 2已退票 3已改签 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_seat (stock_id, seat_no), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个设计点值得单独说。余票不存“每一张票一行”而是“一个车次日期席别一行记总数和已售数”这样查询余票就是一次单行读比COUNT(*)快一个量级缺点是座位号要靠订单表反查属于典型的时间和空间换位。uk_seat (stock_id, seat_no)这条唯一索引是最后一道防线。即使业务代码写错、事务漏了、并发没控住数据库也会在插入时直接报Duplicate entry把脏数据挡在门外。做课设时我强烈建议保留它答辩时老师问“怎么保证同一个座位不卖两次”这条索引比任何口头解释都有说服力。version字段是为乐观锁留的口子课设里用不上也没关系后面改造成接口版、加缓存的时候会用到。留着不占地方删了以后补字段要改一堆 SQL。3. 核心链路拆解一次购票请求从点按钮到落订单3.1 余票扣减别先查再改新手写购票最常见的写法是两步先SELECT sold, total FROM seat_stock WHERE stock_id ?在 Java 里判断sold total再UPDATE seat_stock SET sold sold 1。这两条 SQL 分开执行中间那段空隙足够另一个客户端把同一张票买走超卖就是这么来的。我把这类写法叫“先查后改”血泪经验是只要判断条件和修改动作不在同一条 SQL 里它就一定会出问题。正确做法是把判断塞进WHERE让数据库在一次原子操作里完成“判断 扣减”// dao/SeatStockDao.java public class SeatStockDao { /** * 扣减余票sold total 写在 WHERE 里由 InnoDB 行锁保证原子性 * return 影响行数0 表示余票不足 */ public int deduct(Connection conn, long stockId) throws SQLException { String sql UPDATE seat_stock SET sold sold 1 WHERE stock_id ? AND sold total; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, stockId); // 参数顺序别写反这里只有 1 个 return ps.executeUpdate(); } } }这里conn是从外面传进来的DAO 自己不开连接也不提交事务由 service 层掌控。sold total命中时会锁住这一行第二个事务进来只能等等第一个提交后重新判断条件发现sold已经等于total影响行数就是 0直接返回“余票不足”。返回值和异常要分开处理0是业务上的“卖完了”属于正常分支不该抛异常打日志只有SQLException才是真出错了。这一点在答辩时被问到的概率不低。3.2 订单落库事务边界画在 service 层扣余票和写订单必须同生共死。只扣了余票没写订单座位就凭空消失只写了订单没扣余票就超卖。所以事务的开启点必须在 service 层的入口方法上setAutoCommit(false)之后所有操作共用一个Connection// service/BookingService.java public class BookingService { private final SeatStockDao stockDao new SeatStockDao(); private final OrderDao orderDao new OrderDao(); /** * 购票下单 * param userId 当前登录用户 * param stockId 余票行主键车次 日期 席别唯一确定 * param seatNo 选中的座位号如 08车12A * return 订单号 */ public String book(long userId, long stockId, String seatNo) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 事务从这里开始dao 层不许再改 if (stockDao.deduct(conn, stockId) 0) { conn.rollback(); throw new SoldOutException(该席别余票不足请换一趟车); } String orderNo OrderNoGen.next(); // 时间戳 用户尾号 随机位 orderDao.insert(conn, orderNo, userId, stockId, seatNo); conn.commit(); return orderNo; } catch (SQLException | SoldOutException e) { if (conn ! null) conn.rollback(); // 任何一步失败整单回滚 throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); // 归还连接前恢复默认状态 conn.close(); } } } }seatNo是界面上先查已占座位、再挑一个空位算出来的。查已占座位的 SQL 很短SELECT seat_no FROM ticket_order WHERE stock_id ? AND status IN (0, 1) -- 待支付和已出票都算占用这套“先查空位再插入”的做法在并发下仍然可能撞车两个客户端同时挑中 12A。不用怕uk_seat唯一索引会拒绝第二条插入业务层捕获DuplicateKeyException后提示“座位已被占用请重选”即可。这就是前面说的兜底业务逻辑负责体验数据库约束负责正确性两层都要有。finally里恢复autoCommit这一步很多人会漏。连接如果是复用的连接池带着autoCommitfalse还回去下一次借到它的线程所有 SQL 都不会自动提交事后排查起来极难属于典型的黑匣子故障。3.3 退票改签状态机 幂等退票比购票更容易写错错在“重复退”。用户手快连点两次或者网络超时重发第二次退票如果还执行一遍“余票 1”库存就多出来了。解决办法是把订单状态当成状态机来用更新时用条件判断看影响行数决定后续动作// service/RefundService.java public boolean refund(long orderId) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 只有已出票(status1)的订单能退条件写在 WHERE 里保证幂等 String sql UPDATE ticket_order SET status 2 WHERE order_id ? AND status 1; int rows; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, orderId); rows ps.executeUpdate(); } if (rows 0) { // 已经退过或状态不对直接返回 conn.rollback(); return false; } // 余票归还sold 0 兜住异常数据避免扣成负数 String back UPDATE seat_stock s JOIN ticket_order o ON s.stock_id o.stock_id SET s.sold s.sold - 1 WHERE o.order_id ? AND s.sold 0; try (PreparedStatement ps conn.prepareStatement(back)) { ps.setLong(1, orderId); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }改签本质上是一次“退票 重新购票”但必须放进同一个事务。中途失败要能回到原状态否则用户会出现“旧票退了、新票没买上”的尴尬局面。课设里的简化做法是先把原订单置为status3再调用购票流程如果购票抛异常整个事务回滚原订单状态恢复。测试这个分支的方法是临时把余票表的total改小制造必然失败看原订单有没有被改回去。退款金额和手续费规则在课设里通常按固定比例开车前 24 小时以上退 5% 之类写在service层的一个规则类里别塞进 SQL。规则会变SQL 不擅长表达分支。4. 从零跑起来建库、灌数据、改连接串、启动客户端4.1 环境准备版本对齐是第一道关这套代码依赖三样东西JDK 8 或以上、Maven 3.6、MySQL 5.7 或 8.0。三个都装好后先确认版本尤其是 MySQL 驱动版本和数据库版本要能对上java -version mvn -v mysql --versionMySQL 8.0 必须用mysql-connector-java 8.x新坐标是com.mysql:mysql-connector-j驱动类是com.mysql.cj.jdbc.Driver5.7 可以用 5.1.x驱动类是com.mysql.jdbc.Driver。驱动和数据库版本不匹配时报错信息往往跟网络、权限毫无关系却让人往那个方向排查半天这是课设里踩得最多的一类坑。4.2 建库灌数据三条命令加一次验证建库语句里字符集必须显式写utf8mb4不写会走服务器默认值某些安装包默认还是latin1车站名写进去就变问号# 1. 建库显式指定字符集别依赖服务端默认值 mysql -uroot -p -e CREATE DATABASE ticket_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 建表包含唯一索引和字符集设置 mysql -uroot -p ticket_db sql/schema.sql # 3. 灌测试数据车站、30 天内车次、余票、两个测试账号 mysql -uroot -p ticket_db sql/init_data.sql # 4. 验证能查到数据说明前面三步都成了 mysql -uroot -p ticket_db -e SELECT train_no,start_station,end_station,depart_time FROM train LIMIT 5;第 3 步的初始化脚本里余票数据要覆盖“最近 30 天 × 若干车次 × 若干席别”否则演示时选个远一点的日期全是零看起来像程序坏了。用一个存储过程或者一段循环INSERT批量生成最省事-- 生成未来 30 天的余票从第 1 天开始每趟车每个席别一行 INSERT INTO seat_stock (train_id, travel_date, seat_type, price, total, sold) SELECT t.train_id, DATE_ADD(CURDATE(), INTERVAL d.n DAY), s.seat_type, s.base_price, s.capacity, 0 FROM train t CROSS JOIN (SELECT 3 AS seat_type, 99.00 AS base_price, 120 AS capacity UNION ALL SELECT 4, 158.00, 40) s CROSS JOIN ( SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 -- 实际脚本里展开到 29这里省略重复项 ) d;CROSS JOIN就是笛卡尔积车次数 × 席别数 × 天数一次性铺满比手写几十条INSERT靠谱。sold全部置 0演示时先卖几张再看余票变化效果直观。4.3 连接配置与启动连接串集中放在resources/db.properties账号密码不要硬编码进 Java 代码不然换台机器就要重新编译jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/ticket_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue jdbc.userroot jdbc.password你的密码serverTimezoneAsia/Shanghai在 MySQL 8 上几乎是必填项漏了会抛The server time zone value ... is unrecognized这类报错看着吓人其实是配置漏了一项。allowPublicKeyRetrievaltrue是 8.0 默认加密插件下免密登录可能遇到的坑本地开发开着无妨。DBUtil用静态块加载配置只在类第一次被使用时执行一次// util/DBUtil.java public final class DBUtil { private static final Properties P new Properties(); static { try (InputStream in DBUtil.class.getClassLoader().getResourceAsStream(db.properties)) { if (in null) throw new IllegalStateException(classpath 下找不到 db.properties); P.load(in); // 跟着 jar 走不依赖工作目录 Class.forName(P.getProperty(jdbc.driver)); // 显式注册驱动避免 SPI 失效 } catch (Exception e) { throw new ExceptionInInitializerError(数据库配置加载失败: e.getMessage()); } } private DBUtil() {} public static Connection getConnection() throws SQLException { return DriverManager.getConnection( P.getProperty(jdbc.url), P.getProperty(jdbc.user), P.getProperty(jdbc.password)); } }这里用getResourceAsStream而不是new FileInputStream(db.properties)是因为后者依赖启动时的工作目录换个 shell 或者打成 jar 就找不到文件。课设演示经常是“在我电脑上好好的拷到老师机器上就报错”问题多半出在这。打包运行mvn clean package java -jar target/ticket-system-1.0.jar启动后先登录测试账号查一趟今天的车买一张票进订单页看到记录再退掉回余票页确认数字变化。这一圈走通才算真的跑起来了。5. 避坑排查课设跑不起来、演示翻车的五个现场5.1 连接与驱动类问题现象启动就抛No suitable driver found for jdbc:mysql://127.0.0.1:3306/ticket_db。原因驱动类名和驱动包版本对不上或者依赖没打进最终 jarmvn clean package出来的包里没有mysql-connector。解决先看pom.xml里是 5.1.x 还是 8.x5.1 用com.mysql.jdbc.Driver8.x 用com.mysql.cj.jdbc.Driver再用maven-shade-plugin或者maven-assembly-plugin把依赖打进去别用默认的jar目标它不管依赖。现象报The server time zone value is unrecognized。原因MySQL 8 要求连接串显式声明时区。解决在 URL 后面补serverTimezoneAsia/Shanghai同时加上useSSLfalse消掉一堆安全警告。5.2 字符集与数据问题现象车次表里查出来的车站名是????或者显示成乱码方块。原因三个地方的字符集没对齐——库、表、连接。任何一层是latin1中文就过不去。解决建库时写死DEFAULT CHARSET utf8mb4建表时每张表也写上连接串里带useUnicodetruecharacterEncodingutf8。三处都改完重启客户端才生效只改一处白忙。现象查询“今天出发”的车次明明有数据却查不出来。原因travel_date是DATE类型界面上传的是带时分秒的TimestampWHERE travel_date 2024-05-01 10:00:00匹配不上任何行。解决Java 侧统一用LocalDateps.setObject(i, date)SQL 侧要兼容的话写成WHERE travel_date DATE(?)。日期类型不一致这类问题不报错只是默默返回空列表最耗时间。5.3 演示现场最容易翻的两处现象两台机器同时买同一张票都成功订单表出现重复座位。原因先SELECT判断余票、再UPDATE扣减两条语句中间没锁。解决改成UPDATE ... WHERE sold total看影响行数再加上uk_seat唯一索引兜底两个手段一起上。现象退票后余票数字没变或者退一次数字减了两次。原因退票没做状态条件更新重复执行时余票被反复归还或者余票归还和订单状态更新不在同一个事务里。解决UPDATE ticket_order SET status 2 WHERE order_id ? AND status 1靠影响行数判断是否首次退票并把归还余票放进同一个Connection的事务里。提示这五条里前四条是环境问题第五条是逻辑问题。环境问题半小时能解决逻辑问题往往要演示当场才暴露答辩前至少用两台机器同时点一遍购票按钮。6. 二次开发把课设改造成能写进简历的版本课设交完不该就扔。这套代码的骨架是对的——分层清楚、事务集中、有唯一约束兜底——从它出发做三处改造就能变成一个能在面试里讲二十分钟的项目。第一处是 DAO 层重写。现在是手写 JDBCResultSet到实体的映射代码占了一大半。换成 MyBatis 或者 Spring JDBC 的JdbcTemplateSQL 保留在 XML 或注解里业务代码能瘦一半。改造时注意一点事务注解和 JDBC 手动提交不能混用。如果你已经引入 Spring 的Transactional就必须把conn.setAutoCommit(false)那些代码删干净否则两个事务管理器打架会出现“方法抛出异常但数据已提交”的情况。我一般会先把BookingService单独改成注解式事务跑通购票和退票两条链路再批量改其它 service。第二处是余票查询加速。车次查询是典型的读多写少每次打开界面都去JOIN三张表几十条数据看不出来加到几千条就明显卡。常见做法是在 service 层加一层本地缓存ConcurrentHashMap 过期时间或者接 Redis 存“车次 日期”的余票快照扣减时同步失效。这里有个必须注意的点缓存只能用于展示扣减余票一定要打到数据库。我吃过这个亏——缓存里的余票数当作判断依据结果缓存和库里的值不同步超卖了三十多张。验证方法是写一个并发测试用CountDownLatch起 50 个线程抢 10 张票看最终成功数和sold是否都等于 10。// 并发验证50 个线程抢同一批余票结果必须恰好等于 total int threads 50; CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threads); AtomicInteger success new AtomicInteger(); for (int i 0; i threads; i) { new Thread(() - { try { start.await(); // 一起放行制造真实争抢 service.book(userId, stockId, nextSeatNo()); success.incrementAndGet(); } catch (SoldOutException e) { // 余票不足属于预期分支不计入成功数 } catch (Exception e) { e.printStackTrace(); } finally { done.countDown(); } }).start(); } start.countDown(); done.await(); System.out.println(成功 success.get() 张总数应为 total);跑出来的success必须严格等于total多一张说明超卖少一张说明有票没卖出去通常是锁等待超时被吞掉了异常。这个测试跑通再讲并发控制就有底气了。第三处是加一层 HTTP 接口。Swing 桌面端在简历上说服力有限把 service 层套一个 Spring Boot 的RestController暴露GET /trains、POST /orders、DELETE /orders/{id}三个接口用 Postman 能调通项目性质就变了——从“课设”变成“带并发控制的售票服务”。改造时把BookingService原样注入进来就行界面层可以留着也可以删掉。接口化之后前面那个 Redis 缓存和并发测试才有地方施展否则都是纸上谈兵。最后说个习惯问题。我最早做这类项目时总喜欢在演示前一天集中改代码改完只点两下界面觉得没问题就睡了第二天现场翻车。后来强制自己定规矩任何涉及事务和唯一约束的改动必须先跑一遍并发测试再用两台机器手动点一遍两个都过才算改完。从那以后我每次动BookingService和seat_stock相关的 SQL都强制走一遍这个流程宁可少改一个功能也不在演示现场赌运气。这个习惯比任何技术点都值钱希望帮到你。本文还有配套的精品资源点击获取