☰
多窗口抢票并发控制:MySQL乐观锁与JDBC事务实战
2026/10/13 7:51:51 网站建设 项目流程

简介:这是一个基于Java与MySQL的模拟购票练习项目,按照12306窗口购票流程设计,实现用户登录、车次查询、座位选择、下单支付等核心功能。压缩包共有61个文件,包含12个Java源码、39个编译后的class文件,以及3个jar依赖库(数据库连接池、MySQL驱动等)、若干配置文件和工程说明,整体仅4.73MB,结构紧凑,便于直接导入IDE运行调试。源码重点展示了Swing图形界面开发、JDBC数据库操作、多线程并发购票与异常处理,并体现单例模式连接池、事务控制、索引优化等MySQL应用技巧,项目还附带每日一言模块,可作为功能扩展参考。目前已有667人学习浏览,适合正在学习JavaWeb基础或数据库应用的开发人员,尤其适合理解桌面应用如何与MySQL高效交互,实际阅读时,可参照源码中的查询、下单和支付逻辑,掌握保证数据一致性、防止超卖的设计思路,对后续搭建完整业务系统具有直接借鉴意义。

1. 先看这个Demo在解决什么问题:两个窗口抢最后一张票

本地起一个MySQL,main方法里拉起来两个Swing窗口,就是一套仿12306式的多窗口卖票Demo。这类项目在课程设计和实训里很常见,核心难点不在界面,而是多个窗口同时卖同一趟车次时,余票不能被“卖超”。我第一次跑这个Demo时,两个窗口同时开卖同一趟车的最后一张票,一个窗口提示成功,另一个窗口点击购票也提示成功,可再查余票居然还是1,最后去翻数据库才发现已经成了负数。问题出在Java侧的并发控制和MySQL那条UPDATE语句上。myticket.zip把购票主流程、Swing窗口和数据库初始化都配齐了,适合拿Java练JDBC事务,也适合把MySQL并发控制彻底弄明白。

2. 数据库层:三张表把余票设计成共享资源

2.1 业务拆解:余票、车次、订单各管各的状态

先拆业务。一个买票动作牵涉三类数据:车次基本信息、某车次某天的余票、一张订单。很多同学拿到这种需求,第一步就把“余票”字段塞进车次表,表是少了,但一个车次每天的余票都不一样,日期一多直接没法扩展。

我的习惯是先按状态拆表。train表存静态信息,inventory表存动态库存,orders表存流水。这样拆的第一个好处是:train表只被读取,不会被并发写,锁竞争不会集中在静态数据上;第二个好处是,余票表里每一行代表“某趟车某一天的一个票池”,天然适合做行级锁和乐观锁控制。

表结构参考如下,字段是Demo核心口径,不是生产级设计,但足够支撑多窗口卖票跑通:

表名关键字段定位
trainid, code, start_station, end_station, depart_time, arrive_time车次静态信息
inventoryid, train_id, depart_date, stock, version每日票池,可售余票
ordersid, order_no, train_id, depart_date, passenger_name, status, create_time售票流水

为什么inventory里要单独带一个version字段?这是给乐观锁留的。多窗口并发扣减时,用UPDATE ... WHERE version = ? 来保证只有拿到当前版本号的窗口能成功改库存,其他窗口更新0行后重试或失败。没有这个字段,乐观锁无从谈起。

2.2 建表SQL:索引、唯一约束和乐观锁字段一次配齐

CREATE TABLE train ( id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(10) NOT NULL COMMENT '车次号', start_station VARCHAR(20) NOT NULL COMMENT '出发站', end_station VARCHAR(20) NOT NULL COMMENT '到达站', depart_time VARCHAR(5) NOT NULL COMMENT '出发时间', arrive_time VARCHAR(5) NOT NULL COMMENT '到达时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE inventory ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, depart_date DATE NOT NULL, stock INT NOT NULL DEFAULT 0 COMMENT '可售余票', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_train_date (train_id, depart_date), CONSTRAINT fk_inv_train FOREIGN KEY (train_id) REFERENCES train(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号', train_id INT NOT NULL, depart_date DATE NOT NULL, passenger_name VARCHAR(20) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0已出票,1已退票', create_time DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逐段说明一下。表引擎统一用InnoDB,目的是让UPDATE和SELECT ... FOR UPDATE真正走行锁,MyISAM在这种卖票场景下会直接锁表,并发度没法看。inventory表的 (train_id, depart_date) 组合唯一键是关键,它保证同一个车次同一天在表里只有一行,业务上不会出现重复票池。orders表里 order_no 加唯一约束,是为了防止订单重复插入,后面做并发验证时,这个约束能挡住“一条订单被两个窗口同时写进来”的脏数据。

version字段默认从0开始,每次成功扣减都加1。它不是自增主键,也不需要程序主动读出来,全靠UPDATE语句里带条件。

提示:订单表不要叫order,order在MySQL里是保留字,不用反引号包起来的话SQL一执行就报语法错误。拆这类Demo时经常遇到,顺手规避。

2.3 初始数据:让Demo打开就能看到两个车次可卖

INSERT INTO train (code, start_station, end_station, depart_time, arrive_time) VALUES ('G101', '滨江北', '春城西', '08:00', '13:00'), ('G102', '春城西', '滨江北', '14:00', '19:00'); INSERT INTO inventory (train_id, depart_date, stock, version) VALUES (1, '2025-08-01', 10, 0), (2, '2025-08-01', 10, 0);

初始数据有两个细节要注意。车次的起终点站、发车时间都取了贴近真实风格的值,方便后面做界面展示和余票查询。stock先给10,这是故意留的小库存,方便做并发验证——10张票20个线程抢,比1000张票半天看不出问题要直观得多。

depart_date建议改成你当前测试的日期。常见做法是把日期做成可输入的文本框,由用户填,更贴近购票软件的操作习惯。后续所有接口都围绕这三张表展开:查询余票读inventory,下单先扣inventory再插orders,两个操作必须在同一个事务里。

3. 购票主流程:从JDBC事务到并发锁实现

3.1 单人购票流程:查库存再扣库存的完整代码

把核心卖票逻辑抽成一个TicketService,每个售票窗口都调用同一个方法。先看不做并发控制的版本,这部分逻辑是后面所有讨论的起点。

public class TicketService { private DataSource dataSource; public boolean buyTicket(int trainId, String departDate, String passengerName) { String selectSql = "SELECT stock FROM inventory WHERE train_id = ? AND depart_date = ?"; String updateSql = "UPDATE inventory SET stock = stock - 1 WHERE train_id = ? AND depart_date = ?"; String insertSql = "INSERT INTO orders (order_no, train_id, depart_date, passenger_name, status, create_time) " + "VALUES (?, ?, ?, ?, 0, NOW())"; try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); int stock = 0; try (PreparedStatement ps = conn.prepareStatement(selectSql)) { ps.setInt(1, trainId); ps.setString(2, departDate); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { stock = rs.getInt("stock"); } } } if (stock <= 0) { conn.rollback(); return false; } try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, trainId); ps.setString(2, departDate); ps.executeUpdate(); } try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setString(1, "T" + System.currentTimeMillis() + Thread.currentThread().getName()); ps.setInt(2, trainId); ps.setString(3, departDate); ps.setString(4, passengerName); ps.executeUpdate(); } conn.commit(); return true; } catch (Exception e) { return false; } } }

逻辑说明:这段代码完整走完“查余票→判断库存→扣库存→写订单”四个步骤,事务包裹保证要么全成功要么全回滚。数据源用连接池还是单连接都行,但必须保证每个线程拿到的连接是独立的——如果所有窗口共用一个Connection,事务会互相干扰,后面并发验证时非常明显。

参数说明:trainId和departDate唯一确定一个票池,passengerName是业务入参;order_no用时间戳加线程名拼出来,满足唯一约束即可,生产环境建议换成更严谨的ID生成方案。

3.2 并发下一定会翻车的check-then-act

上面的代码在单窗口单线程下完全正常,两个窗口同时点购票就有问题。假设库存只剩1,窗口A执行SELECT看到stock=1,还没执行UPDATE,窗口B也执行SELECT看到stock=1,两个窗口都认为“还有票”,然后都执行UPDATE,最终stock变成-1,订单多了一张,这就是超卖。

问题本质是check-then-act模式:先检查再操作,检查和操作之间不是原子的。数据库在并发下不是一个普通变量,它是一个所有窗口共享的内存区域,读到的值只能代表某个时刻的快照。我在调这个Demo时最深的感受是:不要假设“SELECT完别人不会动”,只要没锁,别人就一定会动。

解决思路有两个方向:一是把检查动作和扣减动作合并成一个原子操作,二是用锁让检查期间其他窗口无法操作。这就是下一小节的两种实现。

3.3 两种锁的选择:悲观锁FOR UPDATE和乐观锁version

先看乐观锁,我最终在Demo里用的就是这个方案。把“查余票”和“扣库存”合并成一条UPDATE。

public boolean buyTicketWithOptimisticLock(int trainId, String departDate, String passengerName) { String updateSql = "UPDATE inventory SET stock = stock - 1, version = version + 1 " + "WHERE train_id = ? AND depart_date = ? AND stock > 0"; String insertSql = "INSERT INTO orders (order_no, train_id, depart_date, passenger_name, status, create_time) " + "VALUES (?, ?, ?, ?, 0, NOW())"; try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, trainId); ps.setString(2, departDate); int rows = ps.executeUpdate(); if (rows == 0) { conn.rollback(); return false; } } try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setString(1, "T" + System.currentTimeMillis() + Thread.currentThread().getName()); ps.setInt(2, trainId); ps.setString(3, departDate); ps.setString(4, passengerName); ps.executeUpdate(); } conn.commit(); return true; } catch (Exception e) { return false; } }

逻辑说明:把“余票大于0”放进了UPDATE的WHERE条件,让数据库在行锁保护下完成判断和扣减。version字段顺手+1,之后如果要做更严格的“版本号必须匹配”校验,把WHERE条件改成AND version = ?即可。Demo里只做条件扣减已经够用。

悲观锁版本则是先锁定这一行:

SELECT stock FROM inventory WHERE train_id = ? AND depart_date = ? FOR UPDATE

FOR UPDATE会在事务结束前锁住inventory表的这行记录,窗口B执行同样SELECT时必须等窗口A提交或回滚,相当于把“检查库存”串行化。实现简单,但并发窗口多时等待时间长。

两种方案的选择不必纠结。这个Demo的核心是演示多窗口卖票,用乐观锁的理由有三:一是库存扣减就一行UPDATE,事务短、锁持有时间短;二是窗口端是用户点击触发,实时等待可接受,重试成本低;三是FOR UPDATE在Swing窗口里如果事务开得久,会拖着一堆窗口互相等,调试体验差。

方案并发度实现成本适用场景
乐观锁UPDATE条件高低短事务、高并发扣减
悲观锁FOR UPDATE低低强一致、事务时间短

4. 避坑:多窗口卖票的五个经典翻车点

4.1 超卖:两个线程都通过了“余票大于0”检查

现象:库存剩1,两个窗口同时购票,两个都返回成功,订单表插了两条记录,库存变成-1。

原因:上面3.1节的check-then-act模式,SELECT和UPDATE之间没有原子性,两个线程都读到旧值再各自执行扣减。

解决:改用条件UPDATE,把stock > 0写进WHERE,并检查executeUpdate返回值。rows == 0就回滚,不写入订单。这样即使两个窗口同时执行,数据库行锁也只会让一条UPDATE生效,另一条影响0行直接失败。注意加锁后不要再提前查一次stock,否则又把原子操作拆散了。

4.2 事务没提交:库存扣了订单没了

现象:窗口提示购票失败,但库存少了一张,订单表里又查不到这条记录。

原因:随手在catch块里打印异常就返回false,没执行conn.rollback()。自动提交模式下,前面那条UPDATE已经生效,后面INSERT抛异常时,库存已经被扣掉了。

解决:每个购票方法都维护一个局部Connection,setAutoCommit(false)之后,所有异常路径都要rollback。我的习惯是把rollback放在catch块第一行,再打印日志,避免业务代码从异常中直接return导致rollback被跳过。

4.3 隔离级别设置不当:读到未提交的库存

现象:一个窗口下单后界面立刻查余票,发现库存没少,刷新几次才变化。

原因:JDBC连接默认隔离级别被改成了READ_UNCOMMITTED,能读到别的事务未提交的中间状态。如果业务里在事务内先减库存再写订单,中间那一刻库存已经变化但订单还没落盘,其他窗口可能读到“少了票”却不知是谁买的。

解决:用MySQL默认的REPEATABLE READ就够,不要去调低隔离级别。同时确认连接没有开启autocommit,否则每个SQL独立提交,事务边界全乱。排查时可以打印连接的TransactionIsolation值,一眼看出问题。

4.4 版本冲突误伤:有票却一直提示下单失败

现象:用严格version乐观锁版本时,明明库存还多,但购票频繁失败,成功率不到一半。

原因:UPDATE的WHERE条件带了AND version = ?,两个窗口同时操作时后到的窗口版本号已经变了,影响行数为0,直接返回失败。没有重试,用户感知就是“有票但买不了”。

解决:区分“没票失败”和“版本冲突失败”。版本冲突时做有限次重试,比如最多三次,每次重新查version并再扣减;库存不足则直接返回。我在Demo里建议用stock > 0条件扣减而不是version = ?,因为库存扣减是数值变化,不需要严格版本匹配,条件扣减的自然语义更贴近业务。

4.5 界面卡顿与重复提交:JDBC慢操作堵了EDT

现象:点击购票按钮后窗口卡住不动,鼠标转圈,等了好几秒才弹结果。连点两下按钮,订单表里出现两张同乘客人订单。

原因:Swing的按钮事件默认在EDT线程执行,直接在监听器里跑JDBC,网络延迟和数据库执行时间全部占用了UI线程,界面就卡死。点击事件没做防抖,同一个请求被触发了两次。

解决:用SwingWorker把数据库操作放到后台线程,UI更新放回done方法。按钮在请求未返回时setEnabled(false),防止重复下单。这两步在单窗口Demo里不明显,多窗口并发时差别会放大。

5. 窗口端落地:Swing窗口、余票刷新与扩展边界

5.1 窗口的职责边界:查询显示、下单提交、结果反馈

Demo主界面是一个Swing窗口,整体参照常见购票软件的简洁风格,左侧车次列表,中间日期和乘客人信息,右侧余票和购票按钮。窗口这个角色要克制,只做三件事:

  • 查询:从inventory表读当前余票,刷新到标签上
  • 下单:调用第3章的TicketService,拿到boolean结果
  • 反馈:成功和失败给出不同的对话框提示

不要在窗口里写SQL,也不要在窗口里开事务。窗口是消费方,数据库操作全部收敛到service层。我第一次拆这类Demo时看到把PreparedStatement写在ActionListener里,一改业务逻辑窗口和SQL就得一起动,非常痛苦。正确姿势是窗口只关心按钮被点击后返回true还是false。

public class TicketWindow extends JFrame { private JComboBox<String> trainBox; private JTextField dateField; private JTextField nameField; private JLabel stockLabel; private TicketService ticketService; private void buyAction() { int trainId = getSelectedTrainId(); String date = dateField.getText().trim(); String name = nameField.getText().trim(); new SwingWorker<Boolean, Void>() { @Override protected Boolean doInBackground() { return ticketService.buyTicketWithOptimisticLock(trainId, date, name); } @Override protected void done() { try { boolean success = get(); JOptionPane.showMessageDialog(TicketWindow.this, success ? "购票成功" : "余票不足或购票失败"); refreshStock(); } catch (Exception e) { JOptionPane.showMessageDialog(TicketWindow.this, "系统异常"); } } }.execute(); } }

逻辑说明:doInBackground运行在后台线程,不卡界面;done回到EDT做UI更新。按钮点击期间把自身setEnabled(false),防止用户连点导致重复下单,请求结束后再恢复。这是Swing多线程的标准姿势。

5.2 多窗口实例:两个JFrame模拟两台售票机

打开Demo的main方法,常见做法是直接new两个窗口实例模拟多个售票点。

public class Main { public static void main(String[] args) { SwingUtilities.invokeLater(() -> { new TicketWindow("售票窗口A").setVisible(true); new TicketWindow("售票窗口B").setVisible(true); }); } }

这里有一个值得多想一层的点:两个窗口实例共用一个TicketService,TicketService内部如果是同一个DataSource连接池,每个购票请求从池里拿独立连接,天然形成并发环境;如果TicketService内部是同一个Connection,两个窗口就会互相阻塞,并发效果出不来。我建议按连接池方式处理,才更接近真实售票窗口各自独立请求后端服务的场景。

虽然叫“多窗口”,但窗口只是壳,真正的并发发生在service层。实验时可以在两个窗口同时点购票按钮,观察库存变化和订单表记录。若要更可控地复现竞争,可以把inventory的stock改成小值,比如1或2,手动制造“最后一张票”的竞争场景。

5.3 订单状态扩展:退票、改签与可扩展边界

订单表里status字段,卖票时只用到0,但留这个字段是为了后续扩展。比如退票,本质是把扣减动作反过来,在同一个事务里先改订单状态再加回库存:

功能核心操作边界说明
正常售票扣库存 + 插订单必须同一事务
退票改订单状态 + 加库存防止重复退票,需判断status
改签退旧票 + 买新票中间存在时间窗口,新票可能被抢走

退票逻辑里要注意status判断:只有“已出票”的订单才能退,重复退票会给库存多加分,造成卖超。改签可以做成“退旧票+买新票”两个独立操作,产品层面接受中间那个小窗口即可。把这些边界想清楚,答辩被问到“还能扩展什么”的时候不会心虚。

6. 进阶验证:并发脚本怎么证明库存没卖超

6.1 验证思路

功能跑通不代表并发正确。我一般会在交付前做一次暴力验证:初始化10张票,开20个线程并发抢,最后统计成功订单数、剩余库存和订单总数,三项必须满足守恒。

public class ConcurrencyTest { public static void main(String[] args) throws Exception { int threadCount = 20; int trainId = 1; String date = "2025-08-01"; CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch endLatch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); for (int i = 0; i < threadCount; i++) { int index = i; new Thread(() -> { try { startLatch.await(); TicketService service = new TicketService(); if (service.buyTicketWithOptimisticLock(trainId, date, "旅客" + index)) { successCount.incrementAndGet(); } } catch (Exception e) { // 单线程失败不影响统计,最后看总数守恒 } finally { endLatch.countDown(); } }).start(); } startLatch.countDown(); endLatch.await(); TicketService service = new TicketService(); int stock = service.getStock(trainId, date); int orderCount = service.getOrderCount(trainId, date); System.out.println("成功返回: " + successCount.get() + ", 剩余库存: " + stock + ", 订单数: " + orderCount); } }

跑完后看结果是否满足成功返回 + 剩余库存 = 初始库存10,且订单数 = 成功返回数。每个线程独立new TicketService,模拟的是不同售票窗口各自发请求。

6.2 结果口径

我本地跑了几轮,稳定版本结果像这样:

初始库存并发线程成功订单剩余库存一致性
1020100一致
1002001000一致

如果出现成功订单小于10但库存不为0,说明条件判断过严;如果成功订单大于10,说明锁没生效;如果成功订单和库存总和不等于初始库存,说明订单插入和库存扣减不在同一事务,或回滚路径有漏洞。这套myticket.zip下载后按第2章的建表SQL初始化,再把第6章的验证脚本跑一遍,就能完整复现多窗口抢票的效果。这个验证脚本我每次改动购票逻辑后都会重跑一遍。从那以后我做任何涉及库存扣减的Demo,都会在交付前把并发脚本强制走一遍,确认总数守恒、订单不重复才松手。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询