简介:这是一份仿12306购票窗口的完整演示工程,采用Java语言配合关系型数据库MySQL实现,面向正在学习Java Web开发、数据库编程或并发控制的进阶学习者,也可直接作为课程设计参考。界面基于Swing或JavaFX构建,通过JDBC与MySQL交互,覆盖车次查询、座位选择、订单提交、支付同步更新座位状态等购票流程,并涉及连接池、多线程模拟并发、异常处理、事务管理与基础设计模式等关键知识点。压缩包共61个文件,主体为12个Java源文件与39个编译后的class文件,另有3个jar包提供数据库驱动与连接池支持,2个properties文件承载配置,还附带工程说明与每日一言数据,整体大小4.73MB,目录结构清晰,方便导入开发环境后对照运行。已有667人学习下载。通过该项目可系统梳理图形界面与关系型数据库的协同写法,并理解购票场景下如何用事务保证数据一致性、以索引优化查询效率,适合用于进阶实战练习、期末课程设计或求职作品展示。
1. 仿12306窗口购买火车票这个Java+MySQL Demo,到底在考察什么
标题里的“Java—Demo仿12306窗口实现购买火车票<MySQL>myticket.zip”看起来像是一个做界面的作业,可你一旦开始动手就会发现,真正卡住你的从来不是按钮和布局,而是“两张窗口同时卖最后一张票时,系统怎么保证不超卖”。这个 Demo 常出现在学完 JDBC 和 MySQL 之后的综合练习阶段,表面上是模拟车站售票窗口,内核对的是事务边界、余票字段设计和并发控制。适合刚学完 Java SE 正要做课程设计、或者想拿一个不做烂大街管理系统的人练手。下面按最常见的落地路径,把 myticket 这个压缩包里的数据模型、购票方法、窗口实现和并发坑完整拆开。
2. 建库建表与数据模型:把“窗口售票”拆成车次、余票、订单三张表
2.1 为什么不推荐一张 ticket 表一行一张票
第一次做仿 12306 的人常把表设计成“一张票一行记录”:有一张 ticket 表,每个座位一条数据,status 为 0 表示可售、1 表示已售。查余票时对着 status 做 count,买票时把某一行 status 改为 1。这个模型在数据量小的时候能跑,但它有三个问题:余票查询要走全表 count,车次越多越慢;扣票要改多行,InnoDB 对多行加锁容易产生死锁;最麻烦的是事务里很难保证“查到的可售行数”和“实际扣减行数”一致。实际上你把“库存”看成一个数字而不是一组行,问题会简单得多。
我的常用做法是拆三张表:train 只存车次基础信息,train_seat_stock 存每个车次每个席别的剩余票数,ticket_order 存每一笔购买订单。余票就是一行记录里的一个整数,扣票就是对这个整数做一次 UPDATE。这样锁粒度从“多行”变成“单行”,事务也短。窗口售票的典型事务就是:在事务里读这个整数,校验大于购买数,写订单,把这个整数减掉,提交。
这个模型还有一层好处:以后要加“席别价格”或者“按日期区分库存”,只需要在 train_seat_stock 上扩展字段,不用重新设计订单结构。很多人在课程设计里把价格塞在订单表里,表格冗余、逻辑也乱。正确做法是价格属于库存/票档,订单只记录成交金额。
2.2 myticket 的建表 SQL 脚本:数据底座一次建好
这里给一份可以直接执行的脚本。数据库名就用 myticket,和压缩包同名,方便你导入时不出错。
CREATE DATABASE IF NOT EXISTS myticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE myticket; CREATE TABLE train ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL COMMENT '车次号,如 G1001', origin VARCHAR(50) NOT NULL COMMENT '出发站', destination VARCHAR(50) NOT NULL COMMENT '到达站', depart_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, UNIQUE KEY uk_train_no (train_no) ) ENGINE = InnoDB COMMENT '车次基础信息'; CREATE TABLE train_seat_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, seat_type VARCHAR(20) NOT NULL COMMENT '硬座/硬卧/软卧', total_count INT NOT NULL, remain_count INT NOT NULL, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_train_seat (train_id, seat_type) ) ENGINE = InnoDB COMMENT '座席余票库存'; CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号,全局唯一', user_name VARCHAR(50) NOT NULL COMMENT '购票人姓名', train_id BIGINT NOT NULL, seat_type VARCHAR(20) NOT NULL, ticket_count INT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0取消', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINE = InnoDB COMMENT '购票订单';这份脚本的关键点有三个:train_seat_stock 对 (train_id, seat_type) 做了唯一键,保证同一车次同一席别只有一行库存,这正是后面“单行 UPDATE 防超卖”的前提;ticket_order 的 order_no 建了唯一索引,这是防止重复下单的最后一道闸门;utf8mb4 和 0900_ai_ci 是为了让中文站名不乱码。如果你用的 MySQL 版本较老,把 COLLATE 改成 utf8mb4_general_ci 也一样。
外键我这里故意没加。FOREIGN KEY 在演示项目里看着严谨,但会影响后续的删数据顺序,而且很多人在初始化时先插订单后插车次会报错。数据一致性由 Java 层的事务保证,外键这一层省略反而让 Demo 更好跑。真要做到生产级,再考虑外键或更严格的约束。
要跑通窗口界面,库里还得有几条演示数据。常见做法是手工 INSERT 两趟车,比如一趟上午出发、一趟下午出发,席别给硬座和硬卧:
INSERT INTO train (train_no, origin, destination, depart_time, arrive_time) VALUES ('G1001', '南市', '北城', '2025-04-10 08:00:00', '2025-04-10 10:30:00'), ('D2002', '东站', '西站', '2025-04-10 13:00:00', '2025-04-10 16:00:00'); INSERT INTO train_seat_stock (train_id, seat_type, total_count, remain_count, version) VALUES (1, '硬座', 100, 100, 0), (1, '硬卧', 50, 50, 0), (2, '硬座', 80, 80, 0);这里把城市名写成“南市、北城、东站、西站”是为了避免跟真实站点混淆,演示项目里你完全可以用自己所在地。DATETIME 字段直接填字符串,MySQL 会自动解析;如果你喜欢用 TIMESTAMP,要注意它受时区影响,后面第 5 章提到的 serverTimezone 参数会更敏感。DECIMAL(10,2) 用于金额,不要换成 DOUBLE,浮点误差在价格上很容易被挑刺。
2.3 remain_count 和 version 字段:为什么不只留 int 余票
有人会问:既然 remain_count 都能用,version 字段是不是多此一举?答案是:remain_count 解决的是“还有多少”,version 解决的是“别人改没改”。在并发场景里,两个事务都查到 remain_count = 5,然后各自根据旧值判断 5 >= 1 成立,接着都执行 UPDATE 减一,最终库存变成 3 而不是 4,这就是超卖。version 的作用是让“查到的版本”变成更新条件的一部分:
UPDATE train_seat_stock SET remain_count = remain_count - 1, version = version + 1 WHERE train_id = ? AND seat_type = ? AND version = ?;如果事务提交时 version 已经被别人改掉,UPDATE 的影响行数是 0,程序就知道这次操作失败、需要重试。这在第 5 章会用来处理并发。现在建表时先把这个字段留下,后面写代码就直接用,不用回头改表。
在 MySQL 里还有一种常见方案是不用 version,直接 UPDATE 语句带 WHERE remain_count >= ?,这种条件更新同样能防超卖。表里留 version 的好处是它更通用,换成 JPA、MyBatis-Plus 这类框架时,乐观锁插件照样认这个字段。数据模型到这里已经可以支撑窗口售票了,下一章开始写 Java 侧的事务方法,这也是整个 Demo 里最容易写错的部分。
3. Java 购票核心流程:JDBC 手动事务的边界到底划在哪
3.1 只依赖 JDBC 的 JdbcUtil:为什么先不引 ORM
课程设计阶段不建议直接上 MyBatis 或 Spring,原因不是那些框架不好,而是这个 Demo 的考点就是“你懂不懂 SQL 和事务”。JDBC 裸写能把每次 commit、rollback 都暴露在代码里,出错时定位更快。先写一个最简洁的工具类:
package myticket.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class JdbcUtil { private static final String URL = "jdbc:mysql://localhost:3306/myticket" + "?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL 驱动未找到", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }代码里已经点名两个最基础参数:characterEncoding=utf8 解决中文乱码,serverTimezone=Asia/Shanghai 解决日期差 8 小时。useSSL=false 是为了避免本机调试时 SSL 握手警告。USER 和 PASSWORD 写死是为了演示方便,真正交作业时建议放到 config.properties 里读,不要让别人一眼看到你的密码。
3.2 buyTicket 方法:查余票、写订单、扣库存必须同生共死
现在写最核心的购票方法。这个方法会接管一个 Connection,手动把自动提交关掉,然后在事务里依次做三件事:查余票、插入订单、扣减库存。注意我这里先不加锁,保留一段“朴素版本”,后面并发章节会解释它为什么翻车,你正好能对照着看。
package myticket.service; import myticket.util.JdbcUtil; import java.math.BigDecimal; import java.sql.*; public class TicketService { public boolean buyTicket(String orderNo, String userName, long trainId, String seatType, int ticketCount) { String querySql = "SELECT remain_count FROM train_seat_stock " + "WHERE train_id = ? AND seat_type = ?"; String insertSql = "INSERT INTO ticket_order " + "(order_no, user_name, train_id, seat_type, ticket_count, amount) " + "VALUES (?, ?, ?, ?, ?, ?)"; String updateSql = "UPDATE train_seat_stock " + "SET remain_count = remain_count - ? " + "WHERE train_id = ? AND seat_type = ?"; Connection conn = null; try { conn = JdbcUtil.getConnection(); conn.setAutoCommit(false); // 1. 查余票:当前是快照读,被并发钻空子的入口 int remain = 0; try (PreparedStatement ps = conn.prepareStatement(querySql)) { ps.setLong(1, trainId); ps.setString(2, seatType); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { conn.rollback(); return false; } remain = rs.getInt("remain_count"); } } if (remain < ticketCount) { conn.rollback(); return false; } // 2. 写订单:order_no 的唯一索引在重复下单时兜底 try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setString(1, orderNo); ps.setString(2, userName); ps.setLong(3, trainId); ps.setString(4, seatType); ps.setInt(5, ticketCount); ps.setBigDecimal(6, new BigDecimal("80") .multiply(BigDecimal.valueOf(ticketCount))); ps.executeUpdate(); } // 3. 扣库存:这里没有条件,只按车次席别扣 try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, ticketCount); ps.setLong(2, trainId); ps.setString(3, seatType); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); } catch (SQLException e) { e.printStackTrace(); } try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } } }几个容易忽略的点:每个 PreparedStatement 都用 try-with-resources 包裹,用完就关,但 Connection 是在外层 finally 里关闭,所以事务能跨多个 statement 存活。连接关闭前如果事务没有 commit,MySQL 默认会回滚,但不要依赖这个行为,显式 rollback 更安全。金额这里写死单价 80 元,实际可以从价格表里查;多席别、退票这些扩展见最后一章。
为什么“查余票、写订单、扣库存”必须在同一个 Connection 上做?因为事务是绑定连接的,如果三个操作各自开一个连接,那第一种查询看到的余票和最后扣减的数据之间没有任何隔离,超卖会变得更加无法控制。所有数据库操作都围绕同一个 conn,commit 才能把这组操作变成原子的。
3.3 listTrains 方法:窗口界面的数据入口
列表查询方法,返回车次和余票的列表。窗口不管控制台还是 Swing,都要先拿到这份数据。
public List<String> listTrains() { String sql = "SELECT t.id, t.train_no, t.origin, t.destination, " + "DATE_FORMAT(t.depart_time, '%H:%i') AS depart, " + "DATE_FORMAT(t.arrive_time, '%H:%i') AS arrive, " + "s.seat_type, s.remain_count " + "FROM train t " + "LEFT JOIN train_seat_stock s ON t.id = s.train_id " + "ORDER BY t.depart_time"; List<String> result = new ArrayList<>(); try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { result.add(String.format("%d %s %s→%s %s 席:%s 余票:%d", rs.getLong("id"), rs.getString("train_no"), rs.getString("origin"), rs.getString("destination"), rs.getString("depart"), rs.getString("seat_type"), rs.getInt("remain_count"))); } } catch (SQLException e) { e.printStackTrace(); } return result; }这里返回 String 是为了让控制台界面直接打印。如果你要做 Swing 的 JTable,最好返回 List<Object[]>,每一行放 id、车次、站点、余票。DATE_FORMAT 在结果集里已经把时间格式化成可读字符串,避免前端再转换;缺点是后续如果要对余票排序,尽量在 SQL 里 ORDER BY 做完,Java 侧不要二次排序。
注意 LEFT JOIN 而不是 INNER JOIN。因为某个车次如果还没初始化席别库存,INNER JOIN 会把车次信息整个丢掉,窗口里就看不见这趟车;LEFT JOIN 至少能显示车次,余票为 null 时在 Java 循环里转成 0 即可。
4. 窗口台的两种实现:控制台循环和 Swing 表格界面
4.1 控制台版:先让购票链路能跑通
很多人拿到 myticket.zip 的第一反应是直接做界面,我却建议先写控制台版。原因很简单:控制台能把“输入命令→调 service→看结果”这条链路压到最短,你不用处理按钮事件和表格刷新,就能把所有并发问题暴露出来。
package myticket.window; import myticket.service.TicketService; import java.util.Scanner; public class ConsoleWindow { private final TicketService service = new TicketService(); public void start() { Scanner scanner = new Scanner(System.in); while (true) { System.out.println("1. 查询车次 2. 购票 q. 退出"); String cmd = scanner.nextLine(); switch (cmd) { case "1" -> service.listTrains().forEach(System.out::println); case "2" -> buy(scanner); case "q" -> { return; } default -> System.out.println("命令无效"); } } } private void buy(Scanner scanner) { System.out.print("车次ID: "); long trainId = Long.parseLong(scanner.nextLine()); System.out.print("席别(硬座/硬卧/软卧): "); String seatType = scanner.nextLine(); System.out.print("购票张数: "); int count = Integer.parseInt(scanner.nextLine()); String orderNo = "W" + System.currentTimeMillis() + (int) (Math.random() * 900 + 100); boolean ok = service.buyTicket(orderNo, "窗口01", trainId, seatType, count); System.out.println(ok ? "出票成功,订单号" + orderNo : "余票不足或下单失败"); } }这段代码用了 Java 14 开始支持的 switch 箭头语法,如果你的环境是 JDK 8,就把 case 改成带 break 的写法。orderNo 用时间戳加随机数只是演示,多线程压测时要保证唯一,最简单的做法是用“窗口号+时间戳”,也能撑住课程设计场景。控制台版最大的价值是可以被脚本驱动,你在第 6 章做并发测试时,甚至不用打开界面。
控制台版也有个能踩的坑:Scanner 的 nextLine 会把上次输入残留的回车吃掉,导致第一次读到的车次 ID 是空字符串。最稳的写法是每次都用 nextLine 而不是 nextInt,然后手动 Integer.parseInt;或者把所有输入都封装成一个小工具方法,统一解析。这里我全用 nextLine,就是为了避开这个经典翻车点。
4.2 Swing 版:JTable 展示车次,一个按钮完成购票
课程设计的“窗口”大多数指 GUI。Swing 不用额外依赖,JDK 自带,交作业最省事。这里只给关键结构:一个 JTable 显示车次,一个 JComboBox 选席别,一个按钮触发购票。事件监听器里的业务代码全部转发给 TicketService。
JFrame frame = new JFrame("模拟12306售票窗口"); String[] cols = {"ID", "车次", "区间", "出发", "席别", "余票"}; DefaultTableModel model = new DefaultTableModel(cols, 0); JTable table = new JTable(model); JComboBox<String> seatBox = new JComboBox<>(new String[]{"硬座", "硬卧", "软卧"}); JButton buyBtn = new JButton("购买"); buyBtn.addActionListener(e -> { int row = table.getSelectedRow(); if (row < 0) { JOptionPane.showMessageDialog(frame, "请先选择车次"); return; } long trainId = (Long) model.getValueAt(row, 0); String seatType = (String) seatBox.getSelectedItem(); String orderNo = "S" + System.currentTimeMillis() + (int) (Math.random() * 900 + 100); boolean ok = service.buyTicket(orderNo, "窗口01", trainId, seatType, 1); JOptionPane.showMessageDialog(frame, ok ? "购票成功" : "购票失败"); refreshTable(); }); private void refreshTable() { model.setRowCount(0); service.listTrainRows().forEach(row -> model.addRow(row)); }这里 service.listTrainRows() 需要在 TicketService 里再加一个返回 List<Object[]> 的方法,SQL 逻辑和 listTrains 一样,区别是循环里改成 rows.add(new Object[]{ id, train_no, origin + "→" + destination, depart, seat_type, remain_count })。JTable 的选中行拿到的是库存表的 train_id,传给 buyTicket 之前先转成 Long,注意表格单元格里的数字默认是 Integer,超过 2^31 之前没问题,但为了保险建议在放入 Object[] 时就用 rs.getLong("id"),保证是 Long 类型。
Swing 的坑在于:所有界面更新必须回到事件分发线程,而 JDBC 查询会阻塞这个线程。这个 Demo 数据量小,阻塞几十毫秒感觉不到;如果你想把表做漂亮一点,就用 SwingWorker 在后台查余票,查完再在 done() 里刷新表格。这一条写到项目说明里,能明显加分。
另外可以在窗口右上角加一个“刷新余票”按钮,这样不用每次购票失败才看到数据过期。要做得更像 12306,还可以用 javax.swing.Timer 每 3 秒自动刷新一次表格,但要注意:如果上一次刷新还没结束,下一次刷新又触发,多个查询线程会竞争连接。简单 Demo 里我一般不加自动刷新,只在点击“查询”和“购票”后刷新,逻辑最可控。
5. 常见避坑:超卖、重复下单、事务失效这样排查
5.1 两张窗口同时买最后一张票:超卖是怎么发生的
现象:A、B 两个窗口同时操作,余票只剩 1 张,两边的购票返回结果都是成功,刷新库存发现余票变成了 -1。
原因:buyTicket 方法里“查余票→判断→扣减”不是原子操作。A 查到 remain_count=1,B 也查到 1,A 判断 1>=1 成立,B 判断 1>=1 也成立;A 先扣成 0,B 再扣成 -1。第 3 章代码里特意没加锁,就是要在这里对照。
解决:把扣减 SQL 加一个条件,让数据库在更新时再次校验余票。
String safeUpdateSql = "UPDATE train_seat_stock " + "SET remain_count = remain_count - ? " + "WHERE train_id = ? AND seat_type = ? AND remain_count >= ?"; try (PreparedStatement ps = conn.prepareStatement(safeUpdateSql)) { ps.setInt(1, ticketCount); ps.setLong(2, trainId); ps.setString(3, seatType); ps.setInt(4, ticketCount); int rows = ps.executeUpdate(); if (rows == 0) { conn.rollback(); return false; } }这样即使两个事务都通过了早前的余票判断,UPDATE 执行时 InnoDB 会给这行加锁,第二个事务必须等第一个提交后再执行条件判断,发现 remain_count=0 不满足 0>=1,影响行数为 0,事务回滚。这是处理超卖最朴素也最有效的手段。注意 UPDATE 的影响行数在 MySQL 默认是“实际改动的行数”,不是“匹配条件的行数”,所以 rows == 0 就是没买成。
5.2 双击一次购票按钮:重复订单从哪里来
现象:界面点击“购买”没有立刻禁用,手一抖点了两下,订单表出现两条记录,库存被扣了两次。后端看不出是哪一单出了问题。
原因:两次点击生成了两个不同的 order_no,数据库层面没有任何约束阻止“同一个用户在极短时间内买同一个车次”。真实系统会把按钮置灰、做幂等接口,但真正可靠的防线在数据表。
解决:建表时已经给 order_no 建了唯一索引。Java 侧在插入订单前先查一次有没有同 order_no 的记录,或者更简单的方法是捕获 DuplicateKeyException。
try { ps.executeUpdate(); } catch (SQLException e) { if (e.getErrorCode() == 1062) { conn.rollback(); return false; } throw e; }这里的关键是:错误码 1062 对 MySQL 是准的,但不同数据库不一样。更通用的写法是判断异常消息里是否包含 Duplicate 或唯一索引名 uk_order_no。项目里只要能跑通 MySQL,用错误码最直观。前端也可以把 buyBtn 在第一次点击后 setEnabled(false),成功或失败后再恢复,两道防线各管一段。
5.3 余票显示与库存对不上:快照读和当前读的差异
现象:窗口 A 在界面上显示余票 5,实际库里只有 0,点购票却提示成功(或者反过来提示失败)。用两个连接同时查,查出来的结果不一样。
原因:MySQL InnoDB 默认隔离级别是 REPEATABLE READ,普通 SELECT 是快照读,在事务第一次读时生成快照,后面再读都读这个快照。第 3 章的 buyTicket 先 SELECT 再 UPDATE,SELECT 看到的是事务开始时的旧余票,如果另一个窗口在那之后提交了扣减,当前事务的 SELECT 结果不会变,判断自然失真。UPDATE 是当前读,但此时已经按照旧快照做了判断。
解决:不要依赖“先 SELECT 后 UPDATE”的顺序。直接让 UPDATE 承担校验,见 5.1;如果必须在事务里先查后改,把 SELECT 改成 SELECT ... FOR UPDATE,这会把涉及的行锁住,直到事务提交,其他窗口的当前读会阻塞:
String lockSql = "SELECT remain_count FROM train_seat_stock " + "WHERE train_id = ? AND seat_type = ? FOR UPDATE";注意 SELECT ... FOR UPDATE 只能锁住已经存在的行,如果余票行不存在,它不会阻止别人插入新行;不过本 Demo 的库存行在初始化时已经固定,所以可以放心用。加了 FOR UPDATE 之后,务必保证事务尽快提交,否则其他窗口会一直等你。有些同学在事务里先查询车次列表再 FOR UPDATE 锁库存,中间隔了很长时间,这种长事务在真实系统里很容易造成锁等待超时。
5.4 用了连接池之后事务失效:连接归还时的状态问题
现象:本地 DriverManager 跑得好好的,换成 Druid 或 HikariCP 后,明明 commit 了,另一个查询线程就是看不到数据;偶尔还出现 rollback 无效的情况。
原因:连接池复用的是物理连接,如果某个连接在归还时 autoCommit 还是 false,下一次从池里借出它时,前一个事务的中间状态会残留。不同连接池对归还时重置行为的处理不一致,依赖默认行为本身就是一个大坑;更常见的是有人在 finally 里只写了 conn.close(),没写 setAutoCommit(true)。close 把连接还给池子,事务状态却没有恢复,后面的请求在同一连接上继续未提交的事务,提交时机完全错乱。
解决:事务代码的 finally 里,除了 close,必须先把 autoCommit 恢复为 true。第 3.2 节的 finally 已经写了,这是规范写法。连接池版本的正确姿势是把“事务代码块”和“连接借还”看作一对:
Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 业务操作 conn.commit(); } finally { conn.setAutoCommit(true); // 恢复默认状态再归还 conn.close(); // 归还连接池 }我现在做任何带连接池的项目,都会先写这一对 finally。而且连接不要跨线程共享,一个线程一个 Connection,否则两个线程同时在一个连接上执行事务,会把 autoCommit 状态彻底搞乱。
5.5 中文乱码和日期差 8 小时:连接串上的两个基础参数
现象:站名在 Java 里正常,写进 MySQL 变成“???”,或者查询出来的到达时间比实际少了 8 小时。
原因:连接 URL 没指定 characterEncoding,数据库连接用的编码跟服务端不一致;serverTimezone 没指定时,JDBC 驱动用 JVM 默认时区去解释 DATETIME,本地时区与 MySQL 会话时区不同就偏移。
解决:在 3.1 节的 URL 里同时加 characterEncoding=utf8 和 serverTimezone=Asia/Shanghai;建表阶段的 DEFAULT CHARACTER SET utf8mb4 也要保留。这两个参数同时出现才能同时解决乱码和时区。
jdbc:mysql://localhost:3306/myticket?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai如果你是 MySQL 8 及以上的驱动,Class.forName 用 com.mysql.cj.jdbc.Driver;如果是旧驱动,写成 com.mysql.jdbc.Driver。这个报错信息通常是“ClassNotFoundException”,看到它先确认驱动 jar 的版本再改类名。另外,建表时用了 utf8mb4,但如果你把 MySQL 服务端默认字符集也改成 utf8mb4,那 URL 里的 characterEncoding 还是要有,因为它是客户端连接参数,两者各管一段。
6. 让这个 Demo 从“能跑”变成“能讲”:三个并发验证和加分改动
6.1 把 5.1 的条件更新变成默认写法
课程设计交上去,老师很可能只看两个地方:数据库表设计是否合理、并发时会不会超卖。5.1 的 safeUpdateSql 应该直接写进 buyTicket 的默认实现,不要保留“朴素版”。你可以在注释里留两行说明“这是防超卖的关键”,这份注释比代码本身更让老师满意。我一般把 UPDATE 影响行数为 0 的情况单独封装成一个业务异常,而不是返回 false,这样在日志里能区分“余票不足”和“系统异常”。
6.2 用唯一索引和错误码给下单接口做幂等
订单唯一索引已经有了,再做一个小动作就能挡住双击产生的重复订单:插入订单前,用“窗口号 + 当前时间戳”生成 order_no,并让 service 捕获 1062。我在 5.2 里给过异常片段,真正写进项目时,把它跟“按钮点击后禁用”一起做,前端和后端各守一道。重复点击时第二次会收到“订单号重复,请勿重复提交”,而不是多出一条脏数据。
6.3 多线程压测脚本:用数据说话
最后一个加分项是给项目配一个小压测。测试车次初始余票 100,开 200 个线程同时买 1 张,断言最终订单数不超过 100 且余票不为负数。这个脚本可以用 main 方法跑,不需要引入 JUnit;跑完把输出贴到实验报告里,说服力远远大于截图。
public static void main(String[] args) throws Exception { int threadCount = 200; ExecutorService pool = Executors.newFixedThreadPool(threadCount); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); TicketService service = new TicketService(); for (int i = 0; i < threadCount; i++) { int index = i; pool.execute(() -> { try { start.await(); String orderNo = "T" + System.currentTimeMillis() + index; service.buyTicket(orderNo, "压力用户" + index, 1L, "硬座", 1); } catch (InterruptedException e) { e.printStackTrace(); } finally { done.countDown(); } }); } start.countDown(); done.await(); pool.shutdown(); // 这里再查一次 ticket_order 和 train_seat_stock,打印统计 }等所有线程结束后,查询 ticket_order 里 train_id=1 且 seat_type='硬座' 的记录数,再查 train_seat_stock 的 remain_count,两个数字应该满足:订单数 + 剩余余票 = 初始余票 100。跑一次你会发现这个结论很稳,条件更新后不会出现负库存。压测前最好把测试车次的 remain_count 重置回 100,否则第二次跑全是 0 余票,结果没有参考意义。
每次带项目我都习惯先写“最坏的那条路”——两张窗口抢最后一张票、用户双击提交、数据库连接半夜归还。这样回头再写界面心里才有底。这个 Demo 不算大,但把事务边界、行锁和唯一索引三个点讲通,它就不再是“又一个增删改查系统”。希望帮到你。
本文还有配套的精品资源,点击获取