Java课程设计:火车票管理系统如何用乐观锁解决余票并发超卖
发布时间:2026/9/28 13:01:06来源:尧图网络
简介这是一份基于Java技术与SQL数据库设计的火车票售票管理系统源码包主要面向Java课程设计、数据库原理实验及毕业设计选题的学生也可作为小型票务信息系统开发的入门参考。系统完整覆盖车站票务核心流程用户注册登录、车次信息维护、座位类型与余票管理涵盖硬座、软座、卧铺等席位、在线售票、退票回收、余票查询和销售统计报表通过关系型数据库完成数据的持久化与增删改查项目结构完整功能逻辑清晰。压缩包内共89个文件包含14个Java源文件、43个编译后的Class字节码文件、15个HTML帮助与运行结果页面、11张GIF演示截图以及Access数据库、JBuilder工程文件jpx/jbx等打包后仅323KB目录简洁清晰便于按源码、文档、数据库和截图分类查阅。配套的数据库文件与运行截图可帮助读者快速还原项目环境理解从界面操作到JDBC数据访问层的完整调用链路并为课程设计报告、答辩展示提供可直接参考的素材。该资源已有3472人学习下载对正在完成同类课题的读者具有较好的借鉴与复用价值。1. 火车票管理系统 Java 数据库这门课设真正在考的是余票并发火车票管理系统Java 数据库是 java 课程设计里的常青树看起来只是对车次、订单、用户做增删改查但真正考验人的是并发下余票会不会卖超。等你用两个线程同时买最后一张票超卖问题立刻现形这也是为什么它比一般的管理系统课设更有含金量。这篇笔记按实际做课设的路径来走先讲表怎么建才不返工再讲 JDBC 事务怎么写才能保证数据一致性然后把超卖、乱码、连接池耗尽这些高频翻车点逐个拆开最后给出一套答辩前能直接照做的验证清单。适合正在赶课程设计、想把项目写进 java 面试经历里的初级开发者也适合想快速复现一个能演示、能讲解的售票系统的同学。2. 先建表再写代码火车票系统的数据模型与乐观锁设计写课设最忌讳一上来就写 Java 代码然后查余票时发现不知道从哪张表取数。我一般建议用四张表起步用户表、车次表、车次日期明细表、订单表。很多教程只给三张表把余票数字直接挂在车次表上这在单机演示时看不出问题但只要你稍微深入一点就会卡住同一趟车每天发一遍每天的余票是独立的一个 remaining 字段根本表达不了这个维度。2.1 用户、车次、日期明细、订单四张表字段怎么定车次表只存静态信息车次编号、始发站、终点站、发车时间、到达时间、票价。凡是会随日期变化的数据比如余票全部放到train_schedule明细表里用train_id travel_date作为唯一维度。这样设计的好处是退票、改签、查某一天的票都只需要操作明细表不用去动车次的静态信息逻辑边界清晰答辩时也容易讲。用户表按最小可用来id、username、password、phone。密码不要明文存统一存 SHA-256 的十六进制串。答辩时老师经常随手问一句用户密码怎么处理的你能答出哈希而不是明文这个印象分比多做两个按钮值钱。订单表要记录 user_id、schedule_id、座位类型、下单时间、订单状态。状态字段用 tinyint 就够了0 出票、1 退票、2 改签不要用字符串。字符串状态在统计和权限判断时会有大小写、空格这类零碎问题纯属给自己挖坑。2.2 一套可以直接抄的建表 SQL 与参数说明下面这份 SQL 按 MySQL 8.0 的 InnoDB 引擎编写字符集用 utf8mb4MySQL 5.7 同样兼容。CREATE DATABASE IF NOT EXISTS train_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE train_ticket; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash CHAR(64) NOT NULL COMMENT SHA-256哈希不存明文, phone VARCHAR(20) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE train ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(10) NOT NULL UNIQUE COMMENT 如 G1024, origin VARCHAR(50) NOT NULL, destination VARCHAR(50) NOT NULL, depart_time TIME NOT NULL, arrive_time TIME NOT NULL, price DECIMAL(10,2) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE train_schedule ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, travel_date DATE NOT NULL, remaining INT NOT NULL DEFAULT 100 COMMENT 当前余票, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_train_date (train_id, travel_date), CONSTRAINT fk_schedule_train FOREIGN KEY (train_id) REFERENCES train(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, schedule_id INT NOT NULL, seat_type VARCHAR(10) DEFAULT 二等座, status TINYINT NOT NULL DEFAULT 0 COMMENT 0出票 1退票 2改签, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_schedule (schedule_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user(id), CONSTRAINT fk_order_schedule FOREIGN KEY (schedule_id) REFERENCES train_schedule(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逐个说明关键参数。train_schedule里的UNIQUE KEY uk_train_date (train_id, travel_date)是整个设计的地基它保证同一趟车同一天在数据库层面只有一条记录不会因为并发插入生成两行导致余票数据分裂。remaining用 INT 而不是 SMALLINT课设阶段没人会真的塞几万张票但 INT 给你留了做批量插入测试的余地不用中途再改表结构。version列是乐观锁的载体出票时靠它做条件更新这一列删掉的话下面所有并发控制都无从谈起。orders的idx_user支撑按用户查订单idx_schedule支撑按车次查售票记录。没有索引时数据量一大就是全表扫描老师在机器上演示时查一次卡顿一次体验非常差。外键在课设里建议保留虽然很多人说生产环境不用外键但课设场景下外键能帮你挡住一部分删了车次却留下孤儿订单的低级错误。2.3 为什么单独讲余票设计快照隔离与数据一致性数据库默认隔离级别下两个事务先后读到remaining 1各自在内存里判断有票然后各自写入这就是超卖。解决思路是让读余票和扣余票之间不允许别人插队。悲观锁的做法是SELECT ... FOR UPDATE把该行锁到事务结束适合高冲突场景乐观锁的做法是 UPDATE 时把旧版本号放到 WHERE 条件里影响行数为 0 就说明被抢了需要重试。课设场景我推荐乐观锁代码不需要考虑锁超时和死锁而且用版本号控制并发这句话本身就是 java 面试里数据一致性问题的标准解法你做完这个项目再去翻 java 八股文会发现这一块几乎是送分题。这里有一个经常被忽视的点如果你的 dao 层先SELECT出 remaining在 Java 里减一再UPDATE写回新值乐观锁是失效的。因为减一发生在两个事务各自的内存空间最后写入的人会覆盖前一个人的结果。正确写法是把减法放进 SQL 里用SET remaining remaining - 1做原子更新这是数据库自身的能力和 Java 里读出来再算是两码事。3. 用 JDBC 跑通售票流程事务边界与三条核心 SQL课程设计的一般要求是用 Java 实现没说必须用 Spring。纯 JDBC 反而更贴近教学范围答辩时每一步都讲得清楚代码量也小。你用一个能跑的 JDBC 工程去讲连接怎么来、事务怎么开、SQL 怎么执行比背一套 Spring 注解更有说服力因为老师能顺着你的代码一路问到底。3.1 不引框架的最小工程与连接配置我一般建一个 Maven 单模块工程包结构固定四层entity放和表对应的 JavaBeandao只负责 SQL 执行service负责业务判断和事务边界ui是控制台菜单或 Swing 窗口。这样分层不是形式主义排错时能直接定位SQL 报错看 dao逻辑错看 service界面错看 ui。很多同学把 SQL 写在 main 方法里一旦报错就要通读整个文件这是最耗时间的写法。依赖只需要一个 MySQL 驱动dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency连接串里有两个参数几乎必配useSSLfalse和serverTimezoneAsia/Shanghai。前者避免本地开发时 SSL 握手报错后者解决数据库时区和 JVM 时区不一致导致的时间字段偏移。这两个配置坑在课设里出现频率极高代码本身没毛病就是连不上或者时间差八小时属于典型的配置型翻车。加载驱动用Class.forName(com.mysql.cj.jdbc.Driver)8.x 的驱动类名和 5.x 不一样老教程里写com.mysql.jdbc.Driver在 8.0 下虽然兼容但会打一段过时警告对强迫症不友好。3.2 查余票、出票、退票的三段核心逻辑先看查车次。查某天某两个城市之间的车次SQL 要 join 两张表public ListTrainSchedule queryByRoute(String origin, String destination, LocalDate date) { String sql SELECT s.id, s.travel_date, s.remaining, s.version, t.train_no, t.origin, t.destination, t.depart_time, t.arrive_time, t.price FROM train_schedule s JOIN train t ON s.train_id t.id WHERE t.origin ? AND t.destination ? AND s.travel_date ? AND s.remaining 0; // 用 PreparedStatement 填充三个问号执行查询后逐行封装成 TrainSchedule 对象 }这里必须用 PreparedStatement不要字符串拼接。字符串拼接的问题不只是注入漏洞还有 SQL 引号转义日期和字符串里一旦出现单引号整条 SQL 就废了排错时你会怀疑人生。remaining 0直接写进查询条件是业务层的第一次过滤让用户根本看不到已售罄的车次比查出来再在 Java 里 if 判断要干净。出票是整个系统的核心动作service 层代码骨架这样写public boolean buyTicket(int userId, int scheduleId) { Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); // 1. 查出当前版本号作为乐观锁条件 int version scheduleDao.getVersion(conn, scheduleId); // 2. 原子扣减余票带上 version 条件 int rows scheduleDao.decreaseRemaining(conn, scheduleId, version); if (rows 0) { conn.rollback(); // 余票不足或已被抢直接回滚 return false; } // 3. 插入订单 orderDao.insert(conn, userId, scheduleId, 二等座); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw new RuntimeException(出票失败, e); } finally { conn.setAutoCommit(true); conn.close(); } }注意三个关键点。第一Connection 必须由 service 层创建并向下传给 dao如果每个 dao 方法内部自己DriverManager.getConnection那就成了三个独立连接commit 和 rollback 全都不生效表现就是订单插进去了但余票没扣。第二decreaseRemaining的 SQL 长这样UPDATE train_schedule SET remaining remaining - 1, version version 1 WHERE id ? AND remaining 0 AND version ?;remaining 0是数据库层的最后防线哪怕业务代码漏判也不会把余票扣成负数version ?填的是查询阶段拿到的旧版本号影响行数为 0 就说明这期间有人抢了票。第三finally 里先把 autocommit 改回来再 close否则连接池中的下一个使用者会继承这个未提交的事务状态。退票是镜像逻辑UPDATE train_schedule SET remaining remaining 1再把订单状态改成 1同样要包事务。改签则是退旧票 买新票两个事务串起来中间任何一步失败都要全部回滚。3.3 连接池参数课设给多大合适不引连接池也能跑但建议至少用静态 LinkedList 做一个最简单的连接复用这能在答辩时展示你对数据库连接池原理的理解。如果直接引 HikariCP最关键的是maximumPoolSize本地课设给 10 足够不要照抄网上的 50。机房机器内存小50 个连接对应 MySQL 端 50 个线程一启动就可能把数据库压垮。另一个参数connectionTimeout建议设 3000 毫秒一旦连接获取超时立刻报错而不是让用户长时间傻等。4. 控制台、Swing 还是 JSP界面层选型与出票演示界面层是这个课设最容易被低估的部分很多人最后翻车就翻在选错了路线。我见过三种主流方案纯控制台、Swing 桌面窗口、JSP Tomcat 的 Web 页面。选择标准不是哪个好看而是你打算在答辩时讲什么。4.1 三条路线怎么选才不吃亏方案代码量环境依赖演示效果适合人群控制台最小无一般想把事务和 SQL 讲透Swing中等无直观想兼顾界面和后端讲解JSP Tomcat较大需要配置 Web 环境最接近真实系统老师明确要求 Web控制台版最稳妥所有精力都能放在事务和 SQL 上适合 java 基础还不够扎实、想把后端讲清楚的人但演示时老师看着黑底白字输命令很难提起兴趣。Swing 版是折中方案窗口表格能直观展示车次和余票代码量比控制台多三分之一但不需要装 Tomcat单机双击就能跑对机房环境最友好。JSP 版最接近真实系统但要配置 Tomcat、servlet 和 session课设时间不够很容易陷进环境配置里出不来所以我一般建议除非老师明确要求 Web否则优先 Swing。选 Swing 还有一个隐性好处事件监听器的写法能顺便展示你对面向对象编程这个概念的理解。把购票按钮和退票按钮各写成一个监听器类老师问起来你就能讲出事件源、监听器、匿名内部类这几个词这些都是 java 基础面试里高频出现的概念一次课设全部覆盖。4.2 Swing 界面与 service 层怎么对接Swing 界面只做两件事收集输入、展示结果。千万不要在按钮的 actionPerformed 里写 SQL那会让界面卡顿也让核心逻辑没法单独测试。我习惯的做法是界面层只调 service 层的queryTrains、buyTicket、refundTicket三个方法按钮点击后同步调用返回结果再刷新表格模型buyButton.addActionListener(e - { int selectedRow trainTable.getSelectedRow(); if (selectedRow 0) { JOptionPane.showMessageDialog(frame, 请先选中一个车次); return; } int scheduleId (int) trainTable.getValueAt(selectedRow, 0); boolean ok ticketService.buyTicket(currentUserId, scheduleId); if (ok) { JOptionPane.showMessageDialog(frame, 出票成功); refreshTable(); // 重新查一次余票刷新表格 } else { JOptionPane.showMessageDialog(frame, 余票不足或已被抢); } });参数说明getValueAt(selectedRow, 0)取的是表格第一列所以 JTable 的列顺序要把 scheduleId 放在第一位并且最好把这一列隐藏或设成不可编辑否则用户能顺手改掉主键出票时传错 id 会直接打出一堆莫名其妙的 SQL 异常。出票成功之后不是手动改表格里那行的数字而是重新调用查询方法刷新整个表这个查询-更新-重查的闭环能直观展示余票变化也让老师相信数据是真实落库的而不是前端自己减的。4.3 菜单顺序与演示细节界面菜单按登录 - 查票 - 买票 - 我的订单 - 退票的顺序排这是业务自然的流转顺序也方便答辩演示时不乱。登录那里要区分账号不存在和密码错误两种提示但不要对未登录用户暴露用户是否存在这类信息直接统一提示用户名或密码错误。查票页面留一个只看有余票的复选框默认勾选演示时你就不需要专门挑有余票的车次来买能少一次尴尬。用 JTable 展示时日期列用yyyy-MM-dd格式化时间列用HH:mm价格列保留两位小数。这些格式化代码很小但很影响演示观感老师看到一串2025-06-01 00:00:00的默认时间格式会觉得你没处理过数据展示。5. 车票系统避坑指南超卖、乱码、连接耗尽与事务失效这个课题的坑很集中翻来覆去就是那么几个。下面按现象 - 原因 - 解决把我在评审和辅导中见到最多的问题列出来每条都值得在写完代码后主动测一遍。5.1 余票被超卖现象用两个线程同时调 buyTicket 买同一趟车的最后一张票两个线程都返回成功数据库里出现两张订单余票变成 -1。原因有二一是 dao 层先 SELECT 再在 Java 里减一后 UPDATE两个线程读到同一个旧值互相覆盖二是 UPDATE 没带remaining 0条件数据库层面放任负数产生。解决把扣减操作收敛成一条原子 SQLSET remaining remaining - 1写进 UPDATEWHERE 里同时带remaining 0和version ?影响行数为 0 就回滚。如果你用了悲观锁SELECT ... FOR UPDATE仍然超卖一般是事务没提交就关了连接锁被提前释放检查 finally 里是不是过早 commit 或 close。5.2 中文乱码现象往数据库插入北京南查出来是北京å—或者控制台显示一排问号。原因三个环节字符集不一致。建库没指定 utf8mb4连接串没带characterEncodingutf8JVM 默认编码不是 UTF-8三选一或者三个都中。解决建库时显式写DEFAULT CHARACTER SET utf8mb4连接串追加characterEncodingutf8控制台程序启动参数加-Dfile.encodingUTF-8。如果用的是 IDEASettings 里 File Encodings 三处全改 UTF-8。这个坑查起来很快但我见过不少同学在乱码上耗掉半天只因为没看连接串。5.3 数据库连接耗尽现象程序跑了一会儿开始抛Too many connections或者Connection is not available然后界面卡死。原因每次操作都 new 一个连接用完没 close连接泄漏或者连接池配得过大MySQL 默认max_connections是 151你把连接池maximumPoolSize配到 200 就必然报错。解决第一步查代码里所有getConnection是否都配套 finally 里的 close第二步把连接池maximumPoolSize降到 10第三步用SHOW PROCESSLIST看当前连接数确认是不是有连接一直 Sleep 不释放。这里有个血泪经验Swing 程序在windowClosing事件里一定要把数据源 close 掉否则关掉窗口后连接还挂在数据库上第二次启动就会撞上限。5.4 订单插入成功但余票没扣现象买票返回成功订单表有记录但 train_schedule 的 remaining 没变化。原因几乎锁定在事务边界错误service 层没有把两个 dao 操作包在同一个 Connection 里或者setAutoCommit(false)写在 dao 层而 service 层根本不知道。排查方法是把 dao 层方法签名改成接受外部传入的 Connection不要在 dao 内部自建连接。另一个低级原因是扣减 SQL 的 WHERE 没匹配上比如 scheduleId 传错导致 UPDATE 影响行数为 0但代码没判断返回值仍然继续插入订单。解决在 buyTicket 里把rows 0直接回滚并返回失败不要吞掉这个信号。很多同学只判断了异常没判断影响行数这个细节在答辩时经常被老师一句话点穿。6. 从能跑到能答辩并发验证、演示清单与日志习惯写一个多线程并发测试是验证这个系统有没有真正解决超卖最直接的方式。用线程池起 5 个线程同时买一张只剩 1 张票的车次然后查订单表和余票表期望结果是恰好 1 个成功、4 个失败、余票为 0。这份测试代码二十多行但在答辩时说我做过并发压测比口头讲我写了乐观锁有说服力得多。ExecutorService pool Executors.newFixedThreadPool(5); CountDownLatch latch new CountDownLatch(1); AtomicInteger success new AtomicInteger(); for (int i 0; i 5; i) { pool.submit(() - { latch.await(); // 所有线程同时放行制造并发 if (ticketService.buyTicket(userId, scheduleId)) { success.incrementAndGet(); } return null; }); } latch.countDown(); pool.shutdown(); System.out.println(并发成功数: success.get()); // 期望输出 1跑这个测试前要确认 scheduleId 对应的余票是 1并且数据库里没有脏数据。多跑几次每次成功数都必须是 1如果出现 2 或者 3说明乐观锁没生效回到 5.1 查 UPDATE 语句。演示前我习惯过一遍这个清单每项都实际跑一次而不是脑子里过一遍检查项验证方法通过标准数据初始化执行建库脚本和测试数据四张表有数据余票不为 0中文显示查询一次含中文站名的车次界面与数据库均正常显示并发卖票5 线程抢 1 张票成功 1失败 4余票为 0退票回滚退票后查余票与订单余票 1订单状态变为 1连接释放关程序后查 SHOW PROCESSLIST无残留 Sleep 连接最后说一个我养成的习惯给 service 层的每个方法加一行System.out.println或者用 java.util.logging 记一条日志格式统一成方法名 参数 结果。比如buyTicket(userId3, scheduleId12) - successtrue。课设阶段不需要日志框架这行输出在出问题时就是你排错的第一现场也是答辩时老师问你怎么定位问题时能拿出来的真实依据。它不复杂但能把一个看起来能跑的项目变成一个出了问题知道往哪查的项目。我每次做这类带数据库的课设都会先花半小时把并发测试和关键日志写上再去做界面美化因为这两样东西决定的是项目能不能站得住。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网