Java实战:用JDBC+MySQL+Swing手写仿12306窗口售票系统
简介这是一套基于Java与MySQL模拟12306购票流程的Demo项目面向正在学习Java Web开发、数据库交互及并发场景的初学者或中级开发者适合用于课设、实训或毕设参考。压缩包共61个文件以39个class、12个java源码为主辅以3个jar依赖、2个properties配置及2个每日一言文件整体大小4.73MB结构清晰便于直接导入并理解分层逻辑。已有665人学习下载。项目完整覆盖用户登录、车次查询、选座下单等核心业务演示了JDBC连接、连接池、事务处理与多线程购票等关键知识并能通过Swing窗口直观呈现界面与数据库的联动效果。既有源码、字节码与运行依赖也有配置文件可帮助读者快速跑通并进一步改造。 这段时间不少初学Java的朋友问我同一个问题学完了集合、IO、JDBC基础却不知道该怎么把这些东西串起来做点“像样”的项目。这个仿12306窗口售票的Demo恰好就是一条非常合适的练手路径——用Java做窗口界面用MySQL存车次和余票把一次完整的购票流程从头到尾跑通。源码包myticket.zip里包含完整的工程和建表脚本我结合自己做这类项目的经验把项目拆开讲一遍。1. 一张表撑不起售票系统数据库模型该拆成几张表很多新手拿到“购票系统”第一反应是建一张大表把所有信息塞进去。这个思路在Demo阶段也能跑但稍微加一个条件就崩同一天同一趟车有不同座位类型、不同余票数一张表根本没法干净表达。我们先把业务捋一遍。1.1 购票核心流程先走通无论界面怎么做后台逻辑永远是这条链路用户看到车次列表选择一趟车。查看该车次余票情况如二等座、一等座还剩多少张。输入乘车人信息和席别点击购票。系统校验余票是否充足。扣减余票数同时生成一条订单记录。购票成功界面显示订单号。这个流程有个关键点余票扣减和订单生成必须是一件事不能拆成两步各自提交。否则就会出现典型的“超卖”问题——两个人同时看到剩1张票都下单成功最后余票变成-1。这就是数据库层面需要设计两件事表结构要能支撑数据一致性事务要能把多步操作合并为一个原子操作。1.2 核心表结构设计整个项目拆成三张核心表就够了额外加一张乘客表是可选优化Demo阶段不必强求。-- 车次表 CREATE TABLE train ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, train_no VARCHAR(20) NOT NULL UNIQUE COMMENT 车次号如 G1234, start_station VARCHAR(50) NOT NULL COMMENT 出发站, end_station VARCHAR(50) NOT NULL COMMENT 到达站, start_time DATETIME NOT NULL COMMENT 发车时间, end_time DATETIME NOT NULL COMMENT 到达时间, train_type VARCHAR(10) DEFAULT G COMMENT 车型G/D/C/K等 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 余票表 CREATE TABLE train_stock ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL COMMENT 关联车次表, seat_type VARCHAR(20) NOT NULL COMMENT 二等座/一等座/商务座, stock INT NOT NULL DEFAULT 0 COMMENT 当前余票数, price DECIMAL(10,2) NOT NULL COMMENT 票价, UNIQUE KEY uk_train_seat (train_id, seat_type), CONSTRAINT fk_stock_train FOREIGN KEY (train_id) REFERENCES train(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE ticket_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单号, train_id INT NOT NULL COMMENT 车次ID, passenger_name VARCHAR(50) NOT NULL COMMENT 乘车人, seat_type VARCHAR(20) NOT NULL COMMENT 席别, price DECIMAL(10,2) NOT NULL COMMENT 票价快照, create_time DATETIME NOT NULL COMMENT 下单时间, status TINYINT DEFAULT 1 COMMENT 1已支付 0已取消, CONSTRAINT fk_order_train FOREIGN KEY (train_id) REFERENCES train(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;余票表为什么要把车次和座位类型做联合唯一键因为业务上“一趟车 一种席别”只有一条记录这样后续做UPDATE ... WHERE stock 0的原子扣减才有确定的目标行。订单表里存price这个字段很多人不理解觉得票价查车次表就行。这是快照思维——订单下的那一刻价格是什么就是什么以后车次表价格改了历史订单不能被影响。这个设计同样适用于乘客姓名、车次号等字段。1.3 字段类型选择的理由车次号用VARCHAR(20)而不是INT是因为车次号含有字母开头比如 G1234、K5678没有数值意义。时间字段用DATETIME而不是字符串是为了后续能直接做时间比较和排序。余票数用INT不要用VARCHAR存数字否则“余票大于0”这个判断会变成字符串比较逻辑上出问题。价格用DECIMAL(10,2)浮点类型FLOAT/DOUBLE算钱会有精度误差这是常识但Demo里确实很容易随手写成double。建表脚本在 myticket.zip 里的sql/init.sql中可以找到直接执行就能生成三张表和一批测试车次数据。2. JDBC这层写明白后面所有查询都清爽表设计好之后最耗耐心的其实是JDBC连接这块。我见过太多人把连接数据库的代码直接写在业务方法里每个方法里都写一遍DriverManager.getConnection最后改个密码要全局搜索替换。这里提供一个更合理的组织方式。2.1 数据库连接工具类工具类的核心职责是统一管理连接创建和关闭。推荐把数据库配置放jdbc.properties文件里而不是硬编码在Java代码中jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/my_ticket?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.passwordyourpassword这里几个参数必须说清楚为什么这么写。useUnicodetruecharacterEncodingutf8mb4是解决中文乱码的如果不加存进去的中文可能变成问号。serverTimezoneAsia/Shanghai是MySQL 8.x驱动必须加的不加会报时区错误。useSSLfalse是为了本地开发省去SSL握手线上再讨论是否打开。工具类写法public class DBUtil { private static String url; private static String username; private static String password; static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(jdbc.properties)) { Properties props new Properties(); props.load(in); Class.forName(props.getProperty(jdbc.driver)); url props.getProperty(jdbc.url); username props.getProperty(jdbc.username); password props.getProperty(jdbc.password); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt ! null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } }2.2 预编译语句的几点经验DAO层所有的增删改查统一用PreparedStatement而不是Statement。这不仅仅是防SQL注入更现实的原因是参数拼接的转义问题乘车人姓名里如果含有单引号用字符串拼接SQL会直接报错或变成注入点。JDBC查询结果映射对象这段Demo里可以手写ResultSet → Train的转换。项目规模不大手写比引入MyBatis等框架更合适——这个阶段的目的是理解底层映射的过程而不是学会调用框架方法跳过过程。一个容易踩的点是ResultSet的列索引从1开始不是从0开始。rs.getInt(id)用列名更不容易出错。另外查询和更新操作要分开方法写。更新操作不产生结果集直接判断executeUpdate()返回值即可返回0说明影响行数为0。2.3 DAO层的抽象维度按实体拆DAO是Java项目的朴素习惯这个Demo可以拆成TrainDAO、StockDAO、OrderDAO三个类。StockDAO里有一个方法是这个项目的核心专门用于原子扣减余票public int decreaseStock(int trainId, String seatType, int count) throws SQLException { String sql UPDATE train_stock SET stock stock - ? WHERE train_id ? AND seat_type ? AND stock ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, count); ps.setInt(2, trainId); ps.setString(3, seatType); ps.setInt(4, count); return ps.executeUpdate(); } }这条SQL的意义在于stock ?条件直接放在UPDATE语句里数据库会自己判断余票是否足够。如果影响行数为1说明扣减成功如果为0说明余票不足。这比先SELECT查一次余票、再UPDATE扣减要安全得多因为两条语句之间存在时间窗口并发时会出现两个请求都查到余票为1然后都执行扣减把余票扣成负数。这个写法是该Demo与“真实售票系统”之间最关键的安全屏障。3. Swing窗口这样搭操作起来才像真正的售票窗口“窗口”这个标题词在Demo里指的是Swing图形界面。Swing虽然古老但用来做课程设计和Demo展示仍然非常直接——不需要额外的前后端分离一个main方法启动整个应用。3.1 界面布局的规划整个窗口按业务操作划分成三个功能区域上方表格区JTable展示所有车次每一行是一次完整的车次信息。中间操作区选择车次后显示该车次的余票信息同时提供乘车人姓名输入框、席别下拉框、购票按钮。下方日志区JTextArea显示操作日志比如“购票成功订单号xxxxx”或“余票不足”。界面是次要的关键是表格数据和事件传递的逻辑。JTable本身不直接绑定数据库而是通过DefaultTableModel填充数据。查询按钮触发事件后调用TrainDAO.listAll()获取车次列表遍历转换成表格行数据设置到model里最后table.setModel(model)完成刷新。3.2 事件监听里的一个典型错误Swing的事件处理是单线程模型。所有界面更新必须在事件分发线程EDT中执行否则可能出现界面卡顿或刷新异常。新手最常见的错误是在查询按钮的事件里写了一个Thread.sleep模拟网络延迟或数据库加载结果整个窗口卡住不能点。正确的做法是耗时操作放在后台线程然后通过SwingUtilities.invokeLater回到EDT更新界面。Demo阶段数据量小直接在主线程执行查询问题不大但建议从一开始就养成习惯。为了避免界面卡死数据库操作时间其实没有想象中那么夸张但如果你在ActionListener里执行一条慢SQL窗口会僵住几百毫秒体验一下就明白了。3.3 余票显示与查询的联动选车次看余票这个交互可以设计成表格行选中事件。JTable的ListSelectionListener可以监听当前选中行拿到车次ID后调用StockDAO.listByTrainId(trainId)再把余票结果展示在中间区域的标签或下拉框里。这里的细节是下拉框选项。不要让用户手输席别而是从数据库查询结果中动态填充。这样既保证了选项一定合法又展示了DAO层结果如何回传到界面层。界面代码量在这个项目里会占大头。我的建议是界面代码只处理事件和展示不写任何SQL。一旦在界面层出现Connection或Statement说明分层已经坏了。保持这个底线项目看起来会更像生产级代码而非玩具。4. 购票业务核心从点击下单到扣数量的完整链路购票按钮的ActionListener是整条业务链路的入口。很多同学在这一步写代码时路径容易变得混乱先检查这个、再检查那个穿插着更新界面最后还处理异常一个方法里塞上百行代码。要控制住这种复杂度思路是让Service层承担业务编排界面只做调用和回显。4.1 Service层的方法设计在entity/DAO之外增加一个TicketService类对外暴露一个买票的方法public PurchaseResult buyTicket(int trainId, String seatType, String passengerName) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); StockDAO stockDAO new StockDAO(); OrderDAO orderDAO new OrderDAO(); // 1. 原子扣减余票返回影响行数 int rows stockDAO.decreaseStock(conn, trainId, seatType, 1); if (rows 0) { conn.rollback(); return PurchaseResult.fail(余票不足); } // 2. 查询车次信息和票价用于生成订单快照 Train train trainDAO.findById(conn, trainId); Stock stock stockDAO.findStock(conn, trainId, seatType); String orderNo generateOrderNo(); // 3. 生成订单 int orderId orderDAO.insert(conn, orderNo, trainId, passengerName, seatType, stock.getPrice()); conn.commit(); return PurchaseResult.success(orderNo, train, stock.getPrice()); } catch (SQLException e) { rollbackQuietly(conn); return PurchaseResult.fail(系统异常请稍后重试); } finally { DBUtil.close(conn, null, null); } }方法内部最重要的是事务边界的控制。setAutoCommit(false)之后余票扣减和订单插入被绑定在同一个事务中。任何一个步骤失败都会调用rollback()保证不会出现“票扣了但订单没生成”或者“订单生成了但余票没扣”的脏数据。4.2 为什么要手动控制事务很多人知道事务概念但实际写代码时容易忽略两个问题。第一个问题MySQL默认autocommit1也就是说每一条SQL执行完自动提交。如果不手动开启事务decreaseStock扣减了余票后立刻生效下一步insert订单如果失败余票就白白少了。第二个问题连接工具类每次getConnection()都会创建一个新连接。在同一请求内如果StockDAO.decreaseStock内部自己拿了连接、执行完就关闭然后OrderDAO.insert再拿新连接两个连接各干各的事事务根本没法跨方法生效。所以Service层必须把同一个Connection对象通过参数或者ThreadLocal传递到每个DAO方法内部。这是手动事务代码里最容易踩的坑——表面看已经setAutoCommit(false)实际开启事务的连接和提交事务的连接根本不是同一个。4.3 订单号生成策略订单号字段设计为唯一键生成的规则不需要太复杂。常见做法是“时间戳 随机数/自增序号”private String generateOrderNo() { return T LocalDateTime.now() .format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)) String.format(%04d, (int)(Math.random() * 10000)); }这种生成方式在Demo并发量下基本够用。如果想更可靠可以从数据库获取自增ID配合日期拼接。订单号的意义在于它是用户和系统确认一笔订单的唯一标识界面购票成功后要明确显示给用户。4.4 事务的隔离级别要不要调这个Demo默认的MySQL隔离级别是REPEATABLE READ在这个业务场景下完全够用。关于并发扣减余票的安全性真正起作用的是UPDATE语句自身的行锁而不是隔离级别。UPDATE执行时会对命中行加锁第二个事务的相同UPDATE会等待第一个提交或回滚后才继续天然串行化。但要注意一个前提decreaseStock必须是以唯一主键或唯一索引作为条件去更新才能准确锁住目标行。如果更新条件是一个普通索引锁的粒度可能会扩大如果条件没有索引InnoDB会锁住全表扫描的所有行性能会受影响但安全没问题。这个Demo的数据量不用担心性能但不妨借此理解一下索引对锁粒度的影响。5. 踩坑实录驱动、编码、时区、连接泄漏这个项目有几个高频问题几乎每个在我这跑过这套代码的人都遇到过。我把它们直接列出来免得大家再浪费一个晚上。5.1 mysql-connector-java版本引发的ClassNotFound下载的jar包过旧或者驱动类写错都会在启动时抛出ClassNotFoundException。5.x版本的驱动类名是com.mysql.jdbc.Driver8.x版本改成了com.mysql.cj.jdbc.Driver。解决方案很简单记住MySQL服务端大版本号选择的驱动jar包大版本保持一致。如果你本地装的是MySQL 8.x就引入mysql-connector-java-8.0.x.jarURL里加上serverTimezoneAsia/Shanghai。如果不确定驱动版本在IDEA右侧的Maven面板或项目lib目录里检查一下jar包。5.2 中文乱码的三层问题乱码问题出现时逐层排查数据库表字符集、JDBC连接参数、控制台编码。建表时已经指定utf8mb4连接URL也带了characterEncodingutf8mb4但控制台输出的购票成功提示还是乱码。这其实是Windows控制台默认编码GBK导致的显示乱码跟程序本身没关系。解决办法是在启动参数中加-Dfile.encodingUTF-8或者在代码里不打印中文日志。如果是从Swing界面输入中文后存库变成问号优先查连接URL有没有带characterEncoding。这种问题最隐蔽因为程序不报错只是数据坏掉了。5.3 连接没有被关闭资源泄漏这是所有JDBC项目里最常见也最致命的问题。每次getConnection()都是一次TCP连接。如果执行完不关闭MySQL默认的wait_timeout通常是8小时短期看不出问题跑一段时间后连接数达到上限程序就会报Too many connections。我的建议是强制自己遵循一个模式在任何try块里拿连接在finally里关闭。用try-with-resources也可以但要注意事务场景下不能太早关闭连接。工具类里封装的DBUtil.close方法就是为此准备的。写代码时多问一句我这次拿的连接一定会关上吗5.4 时区错误导致的启动直接失败MySQL 8.x 驱动加载连接时如果URL没带时区参数会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。这个乱码是服务端系统时区返回的编码问题。URL里带上serverTimezoneAsia/Shanghai就能解决。如果是连接云数据库可能需要按实际情况调整时区值。5.5 批量插入测试数据时小心外键顺序init.sql里如果同时包含车次表和余票表的数据插入顺序必须先train后train_stock。外键约束要求关联的记录必须存在。反过来删除数据时要先删ticket_order和train_stock再删train。这几个顺序问题在实际执行建表脚本时很容易被忽略如果第一次报外键错误看下语句顺序。6. 源码结构与扩展方向Demo竣工之后还能怎么玩myticket.zip 里的代码目录结构大致如下拿到压缩包后对照着阅读会更快myticket/ ├── sql/init.sql # 建表和测试数据 ├── src/ │ ├── com/myticket/ │ │ ├── entity/ # 实体类Train, TrainStock, TicketOrder │ │ ├── dao/ # 数据访问层TrainDAO, StockDAO, OrderDAO │ │ ├── service/TicketService.java # 业务层购票事务 │ │ ├── ui/ # Swing界面MainFrame, BuyPanel │ │ └── util/DBUtil.java # 连接工具类 │ └── jdbc.properties └── README.md实体类与数据库表字段一一对应DAO层负责SQL语句和结果映射Service层负责任务编排UI层只和Service交互。这个分层方式看似简单其实对应了Java Web开发中MVC的雏形。做完了这个Demo后续学Spring的时候会发现很多概念是相通的——Spring的Transactional就是在解决本项目中手动开启事务的这份繁琐。几个值得继续扩展的方向增加退票功能。本质是新开一个事务把订单状态改成已取消同时把余票加回去需要注意一个订单不能退两次必须在UPDATE语句里带上WHERE status 1这样的条件。增加用户和登录。可以加一张 user 表订单表里外键关联用户ID界面加登录窗口。余票查询增加“日期”维度。现在train_stock只有一列余票真实系统中每一天的车次都有一份独立余票表结构需要增加train_date字段。界面加一个定时刷新按钮。通过一个5000ms的定时任务定时重新查询数据库并刷新表格数据会更接近真实售票大厅滚动显示屏的效果。Demo做到这里已经算是一个完整闭环数据库设计、JDBC连接、事务控制、GUI交互、并发安全都覆盖到了。我个人的建议是不要急着引入Spring之类的重量级框架先把这个项目里每一行代码都吃透尤其是事务和并发部分——这是从纯语法学习迈向真实业务开发的分水岭。本文还有配套的精品资源点击获取