Java+MySQL仿12306购票Demo:JDBC事务与并发控制实战
简介面向具备Java与MySQL基础的学习者这份仿12306购票系统项目包展示了桌面端购票程序的完整实现思路。系统模拟了用户登录、车次查询、座位选择、订单提交与支付等主流功能并围绕数据持久化、连接池管理、事务一致性与并发控制展开设计代码中体现了JDBC操作数据库、多线程模拟多用户抢票、异常处理、单例与工厂等实用技巧同时涉及数据库表结构规划、索引优化与权限安全等知识点能帮助学习者将课堂知识落到真实业务场景中也可作为课程设计或毕业设计的参考底稿。资源包共含六十一个文件以十二个Java源码和三十九个编译后的class文件为主体另附三个第三方依赖库包括Druid连接池、Apache Commons DbUtils工具与MySQL驱动同时包含属性配置、工程描述以及每日一言等辅助文件整体体积约四点七三MB目录结构较清晰可导入Eclipse或IDEA直接运行学习。目前已有六百六十五人学习下载适合希望提升Java窗口应用与MySQL整合开发能力的初中级开发者参考实践。 我不是那种喜欢把仿12306挂在嘴边的标题党但这个Java MySQL的购票Demo确实值得认真写一写。它看起来只是个教学项目实际拆开看里面塞了Java基础语法、面向对象设计、JDBC操作、MySQL表结构设计、事务与并发控制、Swing窗口编程这些知识点几乎把Java后端入门的核心技能全串起来了。如果你正在学Java想找一个能写进简历、又能真正跑起来的练手项目这个仿12306窗口购票的Demo是一个非常好的起点。这篇文章我就以这个项目为蓝本把它从零到一的完整实现思路、数据库设计、关键技术点、踩坑实录都拆开讲清楚。1. 项目整体设计与业务逻辑拆解1.1 这个Demo到底在模拟什么先把这个项目的本质说透。所谓仿12306窗口购票并不是真的要复刻一个铁路售票系统而是用Java模拟出一个具备核心购票业务逻辑的桌面程序。它要解决的问题是用户在窗口界面选择车次输入乘车人信息系统完成余票检查、座位分配、订单生成这一整套流程。整个Demo可以拆成三个核心模块用户交互层通过Java Swing构建的窗口界面负责展示车次列表、接收购票操作业务逻辑层购票流程控制、余票判断、订单状态管理数据存储层MySQL数据库存放车次、余票、订单等结构化数据这三层之间的关系非常清晰界面层调用业务层业务层通过JDBC访问数据库。这种分层结构其实是企业级Java项目的缩影哪怕是一个Demo分层思想从一开始就要建立起来。1.2 业务流程选型的几个关键决定在设计这个Demo的业务流程时我做过几个关键决策这些决策直接影响项目复杂度和学习价值。第一个决定是要不要做用户登录。我最终选择了简化处理把用户信息直接放在购票表单里填。为什么因为Demo的核心价值在购票链路上如果加登录模块要额外做用户表、密码校验、会话管理这些内容会分散精力。但你必须在设计文档里说明这一点不然面试官问起来会显得你考虑不周。第二个决定是余票库存怎么扣减。这是整个项目最核心的业务逻辑。我的方案是车次表里维护一个剩余票数字段每次购票时先检查余票是否大于0满足条件则执行余票-1和新增订单两个操作。这里最关键的问题是这两个操作必须放在同一个数据库事务里否则会出现票扣了但订单没生成的数据不一致问题。第三个决定是窗口界面做到什么程度。我没有做特别复杂的页面跳转而是用一个主窗口左侧车次列表、右侧选票表单、底部确认按钮。这个布局对新手来说足够友好代码量也不会失控。2. 数据库设计与核心表结构2.1 表结构设计这个项目涉及的数据实体主要是车次和订单所以MySQL这边我建了两张核心表。先看建表SQL-- 车次表 CREATE TABLE train ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL COMMENT 车次编号, start_station VARCHAR(50) NOT NULL COMMENT 始发站, end_station VARCHAR(50) NOT NULL COMMENT 终点站, start_time TIME NOT NULL COMMENT 发车时间, end_time TIME NOT NULL COMMENT 到达时间, ticket_price DECIMAL(10,2) NOT NULL COMMENT 票价, remaining_tickets INT NOT NULL COMMENT 余票数量 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE ticket_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号, train_id INT NOT NULL COMMENT 车次ID, passenger_name VARCHAR(50) NOT NULL COMMENT 乘车人姓名, id_card VARCHAR(18) NOT NULL COMMENT 身份证号, order_time DATETIME NOT NULL COMMENT 下单时间, status TINYINT NOT NULL DEFAULT 1 COMMENT 订单状态 1-已支付 0-已取消, FOREIGN KEY (train_id) REFERENCES train(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这两张表的设计有几个值得注意的点。订单号我没有用自增ID直接当订单号而是用时间戳加随机数拼接了一个唯一字符串。原因很简单真实业务场景里订单号不能暴露自增规律否则容易被人遍历抓数据。虽然Demo不需要考虑安全性但养成这个习惯没坏处。余票数量直接冗余在车次表里而不是通过总票数减去已售订单数来实时计算。这是典型的空间换时间思路。如果每次购票都去SELECT COUNT(*)统计已售订单在数据量大的时候性能会很差而且统计出来的数据在并发场景下还会出现偏差。DECIMAL(10,2)用来存票价而不是FLOAT或DOUBLE这是Java项目里必须遵守的规范。浮点数存储货币会有精度丢失问题比如0.1 0.2 0.30000000000000004这在涉及金额的业务里是不可接受的。2.2 事务与并发控制MySQL表设计完成后第二个关键点是事务。刚才提到的余票扣减 订单插入两个操作一定得包在一个事务里。START TRANSACTION; -- 检查余票并锁住这行记录 SELECT remaining_tickets FROM train WHERE id 1 FOR UPDATE; -- 余票减1 UPDATE train SET remaining_tickets remaining_tickets - 1 WHERE id 1; -- 插入订单 INSERT INTO ticket_order (order_no, train_id, passenger_name, id_card, order_time, status) VALUES (202406151030001234, 1, 张三, 110101199001011234, NOW(), 1); COMMIT;这里我用了SELECT ... FOR UPDATE这是InnoDB引擎提供的行级锁。当多个用户同时抢同一趟车次的票时这个语句会把这条车次记录锁住其他事务必须等当前事务提交后才能继续操作这行数据。这个机制有效避免了超卖问题——也就是两个用户同时看到余票还剩1张结果两个人都买成功的情况。FOR UPDATE为什么要放在UPDATE前面单独执行因为你要先做检查再做修改如果检查时不加锁检查完了锁还没拿到别的线程可能已经把票改掉了。用一个事务把检查和修改变成一个原子操作这才是正确姿势。新手最容易踩的坑就是先查询后更新两步之间没有事务保护。事务的隔离级别在MySQL默认是REPEATABLE READ可重复读配合行锁在这个场景下够用。如果你想深入一点可以在代码里设置setTransactionIsolation为READ_COMMITTED读已提交能减少锁等待时间但Demo阶段用默认值就行。3. 购票核心流程的Java实现3.1 JDBC连接与数据访问层Java这边访问MySQL最基础的方式就是JDBC无论你以后用MyBatis还是Spring Data JPA底层都是JDBC封装。所以我建议这个Demo老老实实用原生JDBC写把底层原理吃透。工具类封装一个标准的数据库连接工具大致长这样import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/myticket?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 你的密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, java.sql.Statement stmt, java.sql.ResultSet rs) { try { if (rs ! null) rs.close(); if (stmt ! null) stmt.close(); if (conn ! null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }这个工具类的三个细节我给你逐个解释。URL里的serverTimezoneAsia/Shanghai是必须的不设置的话新版MySQL驱动会报时区错误。characterEncodingutf8解决中文乱码问题。useSSLfalse是因为本地开发没必要走SSL加密减少握手开销。Class.forName(com.mysql.cj.jdbc.Driver)这一行在JDBC 4.0之后其实可以省略驱动会自动注册。但保留它对理解JDBC运行机制有帮助而且能让你一眼看出你用的是哪个版本的驱动。mysql-connector-java8.x版本对应的驱动类是com.mysql.cj.jdbc.Driver5.x版本是com.mysql.jdbc.Driver这两个别搞混了。3.2 余票扣减与座位分配购票核心方法我建议放在一个TicketService类里这样职责清晰。核心代码逻辑如下public boolean buyTicket(int trainId, String passengerName, String idCard) { Connection conn null; PreparedStatement ps1 null; PreparedStatement ps2 null; ResultSet rs null; try { conn DBUtil.getConnection(); // 开启事务默认自动提交要关掉 conn.setAutoCommit(false); // 1. 锁定车次行记录 String lockSql SELECT remaining_tickets FROM train WHERE id ? FOR UPDATE; ps1 conn.prepareStatement(lockSql); ps1.setInt(1, trainId); rs ps1.executeQuery(); int remaining 0; if (rs.next()) { remaining rs.getInt(remaining_tickets); } else { conn.rollback(); return false; } // 2. 余票不足则回滚 if (remaining 0) { conn.rollback(); return false; } // 3. 扣减余票 String updateSql UPDATE train SET remaining_tickets remaining_tickets - 1 WHERE id ?; ps2 conn.prepareStatement(updateSql); ps2.setInt(1, trainId); ps2.executeUpdate(); // 4. 生成订单 String orderNo generateOrderNo(); String insertSql INSERT INTO ticket_order (order_no, train_id, passenger_name, id_card, order_time, status) VALUES (?, ?, ?, ?, NOW(), 1); ps2 conn.prepareStatement(insertSql); ps2.setString(1, orderNo); ps2.setInt(2, trainId); ps2.setString(3, passengerName); ps2.setString(4, idCard); ps2.executeUpdate(); // 5. 提交事务 conn.commit(); return true; } catch (SQLException e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { DBUtil.close(conn, ps1, rs); DBUtil.close(null, ps2, null); } }这套代码是整篇博文的核心。第2步判断余票这一步看上去是先查再用但因为第一步已经用FOR UPDATE把这一行锁住了所以这里查出来的余票就是当前时刻的准确值别的客户端不可能在这个事务提交前改掉这行数据。这就是检查-修改原子化的关键实现。订单号生成方法generateOrderNo()我用的是System.currentTimeMillis()加一个三位随机数。如果担心并发下重复可以加一个内存中的计数器或者直接用UUID.randomUUID().toString().replace(-, ).substring(0, 20)。Demo阶段不追求极致的唯一性但最好在数据库给订单号加唯一索引从源头挡住重复数据。3.3 并发下单的锁机制很多人在做完单线程Demo后觉得完事了但一旦你用多线程模拟多个用户同时抢票就会发现事情没那么简单。我在测试时专门写了一个并发模拟的小工具创建20个线程同时调用buyTicket方法购买同一趟车的最后10张票结果发现超卖问题确实可以被事务和行锁挡住——最终数据库里只有10个订单余票归零另外10次购票全部失败。但这个结论建立在一个前提上JDBC连接池没有把多个线程分配到同一条连接。如果用最原始的DriverManager.getConnection()每次调用都新建连接那么每个线程各用自己的数据库连接那行锁的隔离效果才能体现。如果你在代码里临时改成了共享同一个Connection对象那事务的隔离性就会被打破因为多个线程混在一个事务里操作提交或回滚都会互相干扰。如果你发现实测出现了超卖排查步骤是确认conn.setAutoCommit(false)是否真正执行可以在提交前打印一行日志验证确认SELECT语句后是否紧跟了FOR UPDATE确认两个操作是否用的是同一个Connection实例还有一个容易被忽略的细节事务必须在finally块里确保Connection被关闭。数据库连接不关闭连接数耗尽后整个程序会卡死在getConnection()那一步。你可以把MySQL默认的连接数调大验证一下这种现象实际操作中见过不少新手在这个地方吃亏。4. Swing窗口界面设计与交互4.1 界面布局Java Swing虽然现在用的人不多了但它作为Java桌面程序的基础理解它的布局管理和事件机制仍然有价值。这个Demo的主界面我用的是BorderLayout北面放功能标题西面放车次列表面板(JScrollPane包JTable)中间放购票表单南面放操作按钮。车次列表展示通过DefaultTableModel来封装数据从数据库查出车次集合后遍历填充到表格模型里。这里有个小建议不要在JTable里直接显示所有字段比如数据库里的自增主键id对用户没有意义可以隐藏或者只存到内存里不显示。我是把trainId存在了一个List里表格行的序号对应List的下标选中的时候反查车次ID这样既不影响展示又方便取数。当用户在表格里选中某一行时触发ListSelectionListener事件把该车次的发车时间、到达时间、票价、余票信息自动填充到右侧的表单中减少用户手动输入。这一处交互细节能让Demo用起来更像真实产品。4.2 事件驱动模型Swing是典型的事件驱动模型。用户点击购票按钮触发ActionListener在actionPerformed方法里做以下事情btnBuy.addActionListener(e - { String passengerName nameField.getText().trim(); String idCard idCardField.getText().trim(); // 基本的非空校验 if (passengerName.isEmpty() || idCard.isEmpty()) { JOptionPane.showMessageDialog(frame, 请填写完整的乘车人信息); return; } // 从表格选中行反查trainId int selectedRow trainTable.getSelectedRow(); if (selectedRow 0) { JOptionPane.showMessageDialog(frame, 请先选择车次); return; } int trainId trainIdList.get(selectedRow); // 调用业务层 boolean success ticketService.buyTicket(trainId, passengerName, idCard); if (success) { JOptionPane.showMessageDialog(frame, 购票成功); refreshTrainList(); // 刷新余票数据 } else { JOptionPane.showMessageDialog(frame, 购票失败余票不足或系统异常); } });刷新车次列表这个操作很重要不然会出现界面上的余票数和数据库里的实际余票不一致的情况。真实业务场景里你还需要考虑别人其他客户端也买了票所以每次操作后刷新列表是必须的。你可以在刷新的时候加上仅刷新选中行的优化但Demo阶段全量刷新就行。事件模型这一块我用了一个小技巧把ActionListener写成Lambda表达式而不是匿名内部类代码看起来更干净。Java 8之后的项目建议都这么做面试时候提一句Lambda也能加分。5. 常见问题与排查技巧实录5.1 JDBC连接MySQL的常见报错这个Demo做完之后我整理了一份自己实际踩过的坑新手可以直接照单排查。错误一java.sql.SQLException: Access denied for user rootlocalhost用户或密码不对。检查DBUtil里的用户名密码是否和本地MySQL一致。这里提醒一句别把root的密码硬编码在代码里虽然是Demo但养成好习惯把配置抽出来放在.properties文件里。错误二java.sql.SQLException: The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这就是URL里缺了serverTimezoneAsia/Shanghai。这个报错在MySQL 8.x连接时非常典型。错误三java.sql.SQLException: Could not create connection to database server连接被拒。先检查MySQL服务是不是真的启动了。Windows下可以打开服务管理器看MySQL80或MySQL服务状态Linux下用systemctl status mysql。另外确认端口是不是默认的3306如果改过端口URL里的3306要同步改。5.2 中文乱码问题中文乱码在Swing MySQL的项目里几乎必然遇到我从两个方向排查一是数据库的字符集二是JDBC连接参数。数据库表创建时我指定了DEFAULT CHARSETutf8mb4。如果你用的是utf8而不是utf8mb4虽然大多数汉字没问题但遇到特殊字符比如Emoji就会报错或乱码。MySQL 8.x默认就是utf8mb4MySQL 5.7需要手动指定。JDBC连接URL里的characterEncodingutf8也必须加上。只改数据库不改连接参数照样乱码。Swing界面本身的中文乱码一般在Windows下不会出现但如果你用的是某些Linux发行版缺少中文字体会导致界面全是方框。这个问题通常不是代码问题而是系统缺少字体安装fonts-wqy-microhei这类中文字体包就能解决。5.3 并发模拟问题再深挖一个并发细节我测试的时候如果20个线程同时请求最后10张票用FOR UPDATE实现的正确结果是成功数量严格等于10。但有一次实测出现了15个成功排查了很久才发现问题不在业务代码而在我的测试代码使用了同一个Connection实例。这个案例值得单独拿出来说。共享Connection导致多个线程的事务互相交错A线程还没提交B线程就能看到A线程未提交的修改这直接破坏了事务的隔离性。正确的做法是每个线程获取独立连接或者用一个简单的连接池比如HikariCP来管理连接。HikariCP配置也不复杂HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/myticket); config.setUsername(root); config.setPassword(你的密码); config.setMaximumPoolSize(10); HikariDataSource dataSource new HikariDataSource(config);用连接池还有一个好处getConnection()不再每次新建物理连接而是从池里复用性能提升非常大。你在面试时提到连接池的配置参数maximumPoolSize、minimumIdle会显得很有实战经验。常见问题速查表现象可能原因解决办法中文乱码数据库或JDBC字符集不一致URL加characterEncodingutf8表用utf8mb4时区报错MySQL 8.x驱动要求指定时区URL加serverTimezoneAsia/Shanghai连接被拒MySQL服务未启动或端口不对检查服务和端口确认URL端口一致超卖问题事务未开启或未用行锁setAutoCommit(false)FOR UPDATE多线程下单相互干扰共享同一个Connection改用连接池或每线程独立连接界面刷新后余票不变没有重新查询数据库购票成功后重新加载车次列表5.4 一个特别容易忽略的细节PreparedStatement的复用如果你注意看了上面的代码你会发现我用的是PreparedStatement而不是Statement。这不是随便选的两个原因第一PreparedStatement能防止SQL注入用Statement拼接SQL字符串时如果有人输入; DROP TABLE train;--这样的内容后果不堪设想第二PreparedStatement有预编译机制同一个SQL执行多次时性能更好。在buyTicket方法里我重新定义了ps2这个变量来执行两步SQL每个SQL语句都单独prepareStatement因为它们的SQL模板不同。但如果你要在一次循环里反复执行同一条SQL一定要共用同一个PreparedStatement实例否则预编译的优势就没了。这是我做批量插入的时候总结出来的经验用addBatch()和executeBatch()批量提交插入1000条数据能比单条循环快一个数量级。6. 这个Demo还能往哪些方向扩展项目做完之后如果想进一步提升我有几个建议的扩展方向按实现难度从低到高排列。第一个方向是用户模块。加一张user表把购票表单改成登录后自动带出用户信息。这个扩展能让你练习密码加密比如BCrypt、Session管理、登录状态保持这些真实的业务功能。第二个方向是改造成Spring Boot项目。用Spring Boot MyBatis框架重新实现同样的功能对比一下原生JDBC和框架的差异。这一步做完你对框架的底层原理会有更直观的理解——你会发现MyBatis帮你处理的Connection管理、ResultSet映射、异常处理在原生JDBC里都需要自己写。第三个方向是引入Redis做分布式锁。当你从单机扩展到多实例部署时FOR UPDATE这种数据库锁在分布式环境下就不能完全解决问题了这时候可以尝试用Redis的SETNX命令实现分布式锁对比一下数据库锁和分布式锁的适用场景。第四个方向是给项目加上单元测试。用JUnit写TicketServiceTest对购票成功、余票不足、重复购票这些场景做断言。这一步能让你建立起代码写完必须测的习惯。我个人在实际操作中的体会是这种仿某某系统的Demo最大的价值不在于它模仿得有多像而在于它强行把一个完整业务链路压缩到一个你能掌控的范围内逼着你去思考数据怎么存、流程怎么走、异常怎么办。把这些基础问题想通了以后学习Spring Boot、微服务这些上层建筑的时候你会觉得地基非常稳固。最后再分享一个小技巧这个项目的源码建议你放在GitHub或Gitee上托管README里写清楚项目简介、技术栈、运行方式、数据库初始化SQL。这一套完整的项目文档本身就是你求职时的一块敲门砖——面试官看过的Demo项目太多了但能把文档写清楚的候选人永远是少数。买票这个场景很经典把它做透你的Java基础能力会有一个质的提升。本文还有配套的精品资源点击获取