简介:面向Java学习者与课程设计者的仿12306购票窗口示例,基于Java与MySQL实现,覆盖用户登录、车次查询、选座、下单支付等购票链路,可用于理解前端界面与后端数据库的交互方式。项目结合图形界面与数据库操作,重点演示了多线程模拟并发购票、事务保证扣票与支付一致、异常处理等实战难点;同时涉及的数据库设计涵盖车次、座位、订单等表,适合作为课程设计或期末项目参考。压缩包共61个文件、约4.73MB,内含12个Java源文件、39个编译后的class文件、3个核心依赖库(数据库驱动、连接池、工具类)及配置文件和每日一言模块,源码与编译产物并存,导入开发环境即可运行。目前已有667人浏览学习。通过该项目可系统练习数据表结构设计、SQL查询、索引优化与连接池配置,并理解并发场景下数据一致性的处理思路,是一份能快速上手Java与MySQL整合开发的完整示例。
1. 仿官方售票窗口的Java购票Demo:为什么“买票”比“卖票”难十倍
“购买火车票”这个业务,放在 CRUD 项目里看是普通增删改查,放在并发场景里看就是一场对数据库锁和事务的基本功考试。标题指向的 Java Demo 配 MySQL 数据库,表面是模仿官方售票窗口做个能下单的系统,实际要解决的核心问题是:余票只剩一张时,两个请求同时进来,凭什么只让一个人买到?这个 Demo 的边界很明确——单机 Tomcat、单库 MySQL、纯 JDBC 或轻量框架,不涉及分布式,但足够把行锁、事务隔离、唯一索引这些高频考点变成能跑通的代码。它适合三类人:刚学完 Servlet 或 SSM、想找一个完整练手项目的新手;面试前需要一个并发案例来复盘的同学;以及想搞明白订单表为什么必须设计唯一索引的开发者。后面我会按建表、核心逻辑、并发控制、踩坑、进阶验证的顺序,把每个参数和边界讲透。
2. MySQL表结构与事务前提:myticket.zip的数据层怎么建模
2.1 四张核心表拆解:车次、车厢、座位、订单各管什么
常见做法是把业务拆成四张表,而不是一张大宽表。车次是静态班次信息,车厢和座位是物理资源,订单是业务流水,各自的生命周期和变更频率完全不同,混在一张表里会让 UPDATE 的锁范围失控,也让取消、改签这类后续操作无从下手。下面是这个 Demo 的表结构,注释里标了我对每个字段的设计理由。
CREATE TABLE train_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(10) NOT NULL COMMENT '车次号,如G1024', start_station VARCHAR(50) NOT NULL COMMENT '始发站', end_station VARCHAR(50) NOT NULL COMMENT '终到站', depart_time DATETIME NOT NULL COMMENT '发车时间', arrive_time DATETIME NOT NULL COMMENT '到达时间', UNIQUE KEY uk_train_no (train_no), KEY idx_start_end (start_station, end_station) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次表'; CREATE TABLE carriage_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL COMMENT '所属车次ID', carriage_no INT NOT NULL COMMENT '车厢号,从1开始', seat_type TINYINT NOT NULL COMMENT '1二等座 2一等座 3商务座', seat_count INT NOT NULL COMMENT '该车厢座位数', UNIQUE KEY uk_train_carriage (train_id, carriage_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车厢表'; CREATE TABLE seat_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL COMMENT '所属车次ID', carriage_id BIGINT NOT NULL COMMENT '所属车厢ID', seat_no VARCHAR(5) NOT NULL COMMENT '座位号,如001', seat_type TINYINT NOT NULL COMMENT '与车厢seat_type保持一致', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可售 0已售', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁备用字段', UNIQUE KEY uk_train_carriage_seat (train_id, carriage_id, seat_no), KEY idx_train_status_type (train_id, status, seat_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='座位表'; CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号,Java侧生成', train_id BIGINT NOT NULL COMMENT '车次ID', seat_id BIGINT NOT NULL COMMENT '座位ID,唯一约束防重售', passenger_name VARCHAR(20) NOT NULL, id_card_no VARCHAR(20) NOT NULL COMMENT '真实场景需加密存储', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退票', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_seat_id (seat_id), KEY idx_train_status (train_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';每张表的关键参数值得单独说。train_no 上的唯一索引保证同一车次不会重复录入;seat_info 的 uk_train_carriage_seat 是座位维度的唯一键,它保证“同一趟车、同一车厢、同一座位”只存在一行,这是后面行锁加锁的基础;ticket_order 的 uk_seat_id 是防重售的最后一道数据库防线,同一个座位只能出现一条有效订单。status 用 TINYINT 而不是字符串,既能节省空间,也方便索引和比较;version 字段现在不用,但保留它,以后想切乐观锁方案时不用改表。id_card_no 在真实系统里必须加密,Demo 先用明文,但字段长度要按真实证件号预留,别用 VARCHAR(11)。
提示:所有表都显式指定 InnoDB,这是行锁和事务的前提。MyISAM 不支持行锁,也不支持事务,在这个场景下没有讨论价值。
2.2 为什么余票不能只存一个数字:座位明细模型才是正确方向
不少初学版本会把余票设计成 train_info 表里的一个 remaining_count 字段,买票就是 UPDATE train_info SET remaining_count = remaining_count - 1 WHERE train_no = ? AND remaining_count > 0。单看这条 SQL 是对的,但它回答不了三个问题:卖掉的到底是哪个座位?取消订单时释放的是哪个座位?两个不同车厢的票怎么区分?也就是说,余票数字模型把“票”这种商品抽象成了一个计数器,而真实的票是绑定车厢和座位的物理资源。
所以这个 Demo 采用座位明细模型:seat_info 表的每一行代表一个具体座位,status 字段标记可售或已售。购买动作从“减一个数字”变成“把某一行的 status 从 1 改成 0”,取消动作则是反方向。两种模型的对比直接决定并发控制策略:
| 对比维度 | 余票数字模型 | 座位明细模型 |
|---|---|---|
| 表结构 | train_info 加一个计数列 | seat_info 一行一个座位 |
| 购票动作 | 计数器减一 | 单行 status 从 1 变 0 |
| 取消释放 | 计数器加一 | 单行 status 从 0 变 1 |
| 并发控制 | 只能靠 UPDATE 条件防超卖 | 行锁 + 条件更新 + 唯一索引 |
| 能否回答“卖了哪个座位” | 不能 | 能 |
座位明细模型的代价是行数变大,但一趟 8 节车厢的动车大概在 500 到 600 个座位之间,按车次累积并不是瓶颈。换成区间余票模型(为每个站段保存余票数)更贴近大型售票系统的真实策略,但表结构和购票逻辑会同时膨胀,不适合作为讲清事务和锁的练手 Demo。先做对座位明细模型,再往站段方向扩展,是更稳的路线。
2.3 初始化数据脚本与自增 ID 的三个注意点
建完表要写初始化脚本。常见做法是在应用启动时检测 seat_info 是否为空,为空则根据 carriage_info 的 seat_count 循环插入座位行,避免手工 SQL 重复执行。下面这段是初始化逻辑的骨架:
public void initSeatsIfEmpty(long trainId) { String countSql = "SELECT COUNT(*) FROM seat_info WHERE train_id = ?"; // 如果 count > 0 直接返回,保证初始化方法可重复执行 String carriagesSql = "SELECT id, carriage_no, seat_count FROM carriage_info WHERE train_id = ?"; String insertSql = "INSERT INTO seat_info (train_id, carriage_id, seat_no, seat_type) " + "VALUES (?, ?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement psCount = conn.prepareStatement(countSql)) { psCount.setLong(1, trainId); try (ResultSet rs = psCount.executeQuery()) { if (rs.next() && rs.getInt(1) > 0) { return; } } // 查询车厢后,按 seat_count 循环插入 try (PreparedStatement psCar = conn.prepareStatement(carriagesSql); PreparedStatement psIns = conn.prepareStatement(insertSql)) { psCar.setLong(1, trainId); try (ResultSet rsCar = psCar.executeQuery()) { while (rsCar.next()) { long carriageId = rsCar.getLong("id"); int seatCount = rsCar.getInt("seat_count"); int seatType = rsCar.getInt("seat_type"); for (int i = 1; i <= seatCount; i++) { psIns.setLong(1, trainId); psIns.setLong(2, carriageId); psIns.setString(3, String.format("%03d", i)); psIns.setInt(4, seatType); psIns.addBatch(); } } psIns.executeBatch(); } } } catch (SQLException e) { throw new RuntimeException("初始化座位失败", e); } }三个注意点:第一,座位号用 String.format("%03d", i) 补成三位,否则 10 号的字符串排序会排在 2 号前面,查询列表时出现“1、10、11、2”这种乱序;第二,插入 seat_type 必须从 carriage_info 里取,不能自己再写一遍,否则会出现“查到座位但车厢类型对不上”的脏数据;第三,自增 ID 在单机 Demo 里没问题,但订单表一旦要分库分表,自增 ID 就不适合做订单号,所以 trade_order 里保留了独立的 order_no 字段,由 Java 侧生成,这个习惯从这个 Demo 开始就要养成。初始化方法必须可重复执行,加最前面的 COUNT 判断就是为了一键启动时不重复插入。
3. Java购票核心流程:从查车次到扣座位的完整链路
3.1 查询可售座位:一条 SQL 加一个组合索引
查票是第一个被调用的接口,它决定用户能不能看到座位,也决定后续购票是否顺畅。Demo 阶段的查询条件一般是车次加座位类型加可售状态,所以建表时加了组合索引 idx_train_status_type,列顺序是 train_id、status、seat_type。列顺序不是随便写的:等值条件 train_id 放最前,过滤性强的 status 其次,最后是区分度较低的 seat_type。如果调换顺序,索引虽然可能被用到,但过滤效果会差很多。
public List<SeatInfo> queryAvailableSeats(long trainId, int seatType) { String sql = "SELECT id, carriage_id, seat_no FROM seat_info " + "WHERE train_id = ? AND status = 1 AND seat_type = ? " + "ORDER BY carriage_id, seat_no LIMIT 10"; List<SeatInfo> seats = new ArrayList<>(); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, trainId); ps.setInt(2, seatType); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { SeatInfo seat = new SeatInfo(); seat.setId(rs.getLong("id")); seat.setCarriageId(rs.getLong("carriage_id")); seat.setSeatNo(rs.getString("seat_no")); seats.add(seat); } } } catch (SQLException e) { throw new RuntimeException("查询可售座位失败", e); } return seats; }这里用 try-with-resources 保证连接、语句、结果集全部关闭,是 Java 7 之后的标准写法,比 finally 里手动 close 少犯空指针的错。参数用 PreparedStatement 占位符而不是字符串拼接,既防 SQL 注入也方便 MySQL 做语句缓存。LIMIT 10 必须有,真实线上系统会按起始站和目的站做区间过滤,Demo 先不做站段,但 LIMIT 限制了单次传输的行数,否则一趟车几百行一次全查出来,接口耗时会肉眼可见地变差。ORDER BY carriage_id, seat_no 保证多次查询返回顺序稳定,前端做展示时不会出现座位列表跳动。
3.2 购票下单三步走:锁行、改状态、插入订单
购票是整个 Demo 的心脏。我采用的顺序是:先锁定目标座位行,再把状态改为已售,最后插入订单。顺序有讲究——先锁行让其他事务对同一个座位的操作在第一步就排队;先插订单再改座位状态则会出现“订单插入成功但座位更新失败”的脏数据窗口。三步必须在同一个事务里,任何一步失败都要整体回滚。
@Transactional public PurchaseResult purchase(long trainId, long seatId, String passengerName, String idCardNo) { // 第1步:锁定座位行,阻止其他事务同时操作该座位 String lockSql = "SELECT id, status FROM seat_info WHERE id = ? AND train_id = ? FOR UPDATE"; SeatInfo seat; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(lockSql)) { ps.setLong(1, seatId); ps.setLong(2, trainId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { return PurchaseResult.fail("座位不存在"); } seat = new SeatInfo(); seat.setId(rs.getLong("id")); seat.setStatus(rs.getByte("status")); } } catch (SQLException e) { throw new RuntimeException("锁定座位失败", e); } if (seat.getStatus() != 1) { return PurchaseResult.fail("座位已被占用"); } // 第2步:条件更新,影响行数为0说明被并发修改,放弃 String updateSql = "UPDATE seat_info SET status = 0 WHERE id = ? AND status = 1"; int updated; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setLong(1, seatId); updated = ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("更新座位状态失败", e); } if (updated == 0) { return PurchaseResult.fail("手慢了,座位刚被买走"); } // 第3步:插入订单 String orderNo = generateOrderNo(); String insertSql = "INSERT INTO ticket_order " + "(order_no, train_id, seat_id, passenger_name, id_card_no, status) " + "VALUES (?, ?, ?, ?, ?, 0)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setString(1, orderNo); ps.setLong(2, trainId); ps.setLong(3, seatId); ps.setString(4, passengerName); ps.setString(5, idCardNo); ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("插入订单失败", e); } return PurchaseResult.success(orderNo); }这个方法的三个关键点对应三道防线:FOR UPDATE 必须在事务里执行,否则语句结束锁就释放了;UPDATE 的 WHERE 里带 status = 1,这让即使 FOR UPDATE 没拦住的后到更新影响行数为 0,方法直接返回失败;插入订单依赖 uk_seat_id 唯一索引兜底,万一前两道防线都失守,数据库会抛 DuplicateKeyException。参数上,锁查询只查 id 和 status 两个列,不把整行拉出来,减少事务内的数据传输时间,锁持有时间就越短。代码里三个 SQL 各拿一个连接,是纯 JDBC 的写法;如果换成 Spring 管理事务,这三个操作会共用同一个事务连接,事务边界必须理解成一体。
提示:@Transactional 只对 Spring 代理对象的方法调用生效。同类内部方法互相调用会绕过代理,事务不生效,这是“锁不住”的经典原因之一,排查时先看是不是这个问题。
3.3 支付回调与取消释放:订单状态机的两个出口
下单后订单是待支付状态,Demo 要给这个状态两个出口:支付成功和取消。支付成功对应 status 从 0 变 1,一步 UPDATE;取消则要把座位释放回去,而且释放动作必须和订单状态更新在同一个事务里,否则会出现订单已取消但座位仍是已售的空档。
@Transactional public boolean cancelOrder(String orderNo) { String updateOrderSql = "UPDATE ticket_order SET status = 2 " + "WHERE order_no = ? AND status = 0"; int updated = executeUpdate(updateOrderSql, orderNo); if (updated == 0) { return false; // 订单不存在或已不是待支付状态 } String releaseSql = "UPDATE seat_info s JOIN ticket_order o ON s.id = o.seat_id " + "SET s.status = 1 WHERE o.order_no = ? AND o.status = 2"; int released = executeUpdate(releaseSql, orderNo); return released > 0; }取消业务里最容易犯的错是直接 UPDATE seat_info SET status = 1 WHERE seat_id = ?——这句会把一个已支付订单的座位也释放掉,造成超卖。正确做法是关联订单表,并且只释放状态为“已取消”的订单对应的座位。第一步先更新订单状态为 2,第二步再释放座位,两步之间其他事务如果读到座位状态还是 0,会认为该座位不可售,逻辑上安全。JOIN 写法的锁范围比单表更新大,如果高并发下取消频繁,建议改成先查订单再更新座位的两步式,配合订单表主键可以缩小锁范围,减少死锁概率。
4. 并发控制与事务隔离:行锁、条件更新与唯一索引的三层防线
4.1 丢失更新的发生路径:两个线程都读到了 status=1
没有并发控制时,超卖是这样发生的:线程 A 执行 SELECT id FROM seat_info WHERE id = 100,得到 status = 1;线程 B 也执行同样查询,也得到 status = 1;然后 A 把状态更新为 0,B 也更新为 0,两个订单都插入成功,一个座位卖出去两次。这个问题的本质是“先查后改”中间存在时间窗,而 MySQL 默认的普通 SELECT 不加锁,两个线程可以同时读到同一个旧值,后一个更新把前一个更新覆盖了,这就是经典的丢失更新。
解决思路有两类。一类是让读变成写,也就是 SELECT ... FOR UPDATE 直接加行锁,让第二个线程在 SELECT 阶段就阻塞;另一类是依赖 UPDATE 的条件,把“状态等于 1”写进 WHERE,让后到的更新影响 0 行。两者可以单用,但在这个 Demo 里我会建议同时用:FOR UPDATE 能提前失败、快速返回提示,条件更新是最终兜底。只写条件更新不加锁也能防超卖,但用户要等 UPDATE 执行完毕才知道失败,而且无法先做业务校验。
4.2 行锁的正确姿势:FOR UPDATE 的四个前置条件
FOR UPDATE 不是写了就生效,它有几个前置条件,任何一个不满足就会从行锁退化成表锁,或者干脆没锁。第一,必须在事务内执行。MySQL 默认 autocommit = 1,不加事务时 FOR UPDATE 语句执行完锁立刻释放,等于白锁。第二,WHERE 条件必须命中主键、唯一索引或普通索引列,否则 InnoDB 找不到精确的记录,会退化成锁表,整个系统的并发能力直接废掉。第三,WHERE 条件要能唯一确定一行,如果用 train_id 加 seat_type 这种过滤性差的组合,会锁住几十个座位,后续请求全部排队。第四,被锁的行必须真实存在,SELECT FOR UPDATE 查不到行时,InnoDB 会加间隙锁,这在可重复读隔离级别下可能引发死锁。
在表结构里,uk_train_carriage_seat 唯一索引保证锁能命中精确行,实际加锁时 WHERE id = ? 走主键就够。还有一个容易忽略的性能点:FOR UPDATE 查出来的列如果包含大字段,事务内传输和对象构造时间都会变长,锁的持有时间被拉长。只查 id 和 status 两个列,是最经济的做法。
4.3 隔离级别选型:可重复读和读已提交怎么选
MySQL InnoDB 默认隔离级别是可重复读,它用间隙锁解决幻读,但在购票 Demo 里,间隙锁带来的死锁风险比幻读风险更现实。比如两个事务都执行 SELECT ... FROM seat_info WHERE train_id = 1 FOR UPDATE,一个锁住座位 1 到 5,另一个锁住座位 6 到 10,然后各自想锁对方范围内的行,就会死锁。把隔离级别降到读已提交后,间隙锁退化为记录锁,死锁概率明显下降。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 间隙锁行为 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 无 |
| 读已提交 | 不可能 | 可能 | 可能 | 基本无,用记录锁 |
| 可重复读(默认) | 不可能 | 不可能 | InnoDB 用间隙锁规避 | 有,死锁风险高 |
| 串行化 | 不可能 | 不可能 | 不可能 | 读也加锁,并发最低 |
改隔离级别有两种方式:SET GLOBAL transaction_isolation = 'READ-COMMITTED' 是全局生效,影响同库其他业务;按连接设置更安全。在 Demo 里建议在数据源连接上设置,setTransactionIsolation 的代价是每次建连接多一次执行,连接池复用后几乎无感。读已提交在这个场景足够,因为购票逻辑不依赖“同一事务内两次读结果一致”这种特性,每一条 SQL 都是独立的当前读,幻读影响面基本为零。
4.4 除了锁,唯一索引为什么是最后一道防线
就算 FOR UPDATE 和条件更新都出了问题,ticket_order 表上的 uk_seat_id 唯一索引还会拦住同一个座位的第二次订单插入。这是数据库层面的最后一道防线,它不需要应用层任何配合,只要数据存在就会生效。代价是插入订单时多一次索引检查,对 Demo 来说完全可接受。见过不少项目把这一层省掉,理由是“应用层已经加锁了”,但锁可能因为代码改动被绕过,比如有人接手后把 FOR UPDATE 删了,或者把条件更新里的 status = 1 漏了,唯一索引不会跟着改代码一起失效。
5. 避坑记录:myticket.zip从能跑到扛住的4个踩坑现场
这一章是血泪经验。每个坑都按现象、原因、解决三步写,照着排查能省大半天。这些坑不是某一个项目的专利,只要是“查余票、锁座位、下单”结构的系统都会遇到。
5.1 现象:并发一上来就报“连接获取超时”
第一次压测,50 个线程同时点购票,控制台很快刷出 SQLException: Connections could not be acquired from the underlying database。原因有两层:一是没用连接池,每个请求都新建物理连接,MySQL 的 max_connections 默认 151,几下就打满;二是即使有连接池,maximumPoolSize 设成 10,而线程池开了 50 个并发,剩下 40 个线程只能排队等连接,超过 connectionTimeout 就抛异常。
解决的常见做法是引入 HikariCP,参数按 Demo 的规模设置。maximumPoolSize 不是越大越好,一般取“CPU 核数 × 2 到核数 + 1”之间的值,50 并发用 20 到 30 足够。
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/myticket?useSSL=false&serverTimezone=Asia/Shanghai"); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(30); config.setMinimumIdle(30); config.setConnectionTimeout(3000); config.setIdleTimeout(600000);connectionTimeout 设 3000 毫秒,让请求快速失败而不是无限排队,用户体验比傻等好。minimumIdle 和 maximumPoolSize 设成一致,避免空闲连接频繁创建销毁;idleTimeout 不要低于 600000 毫秒,否则空闲连接会被 MySQL 的 wait_timeout 提前断开,出现“拿到连接但连接已死”的假死现象。还有一个隐藏坑:连接池只管理连接,不管线程池,如果你的 HTTP 线程本身只有 10 个,连接池设 30 也没有意义。
5.2 现象:余票扣成负数
在“余票数字模型”的写法里,这个坑几乎必现。现象是压测后查数据,remaining_count 变成 -3。原因很直接:UPDATE train_info SET remaining_count = remaining_count - 1 WHERE train_no = ? 没有加 remaining_count > 0 条件,两个并发请求同时减,就把库存减成负数。即使加了条件,如果在事务里先 SELECT 再 UPDATE,中间依然有时间窗。
解决方式就是全文强调的:座位明细模型加条件更新。UPDATE seat_info SET status = 0 WHERE id = ? AND status = 1,影响行数为 0 就说明座位已不可售,必须检查 executeUpdate 的返回值。很多开发者写完更新不看返回值,这在并发场景里是大忌——数据库已经告诉你“没有行被更新”,应用层却当作成功继续插入订单。
注意:executeUpdate 返回的是受影响行数,不是 0 就是大于 0。凡是条件更新(WHERE 里带状态判断),返回值必须作为业务成功与否的依据。
5.3 现象:报 Deadlock found when trying to get lock
压测时死锁几乎是必现的。常见路径是事务 A 锁定座位 1 后想锁座位 2,事务 B 锁定座位 2 后想锁座位 1,两个事务互相等对方释放锁。另一个更隐蔽的路径是 FOR UPDATE 的 WHERE 条件没走索引,InnoDB 先锁了大量行,两个事务的锁范围重叠;还有一个路径是插入订单时触发唯一索引检查,两个事务同时插入不同座位但同一车次的记录,间隙锁互斥。
解决分三步。第一,所有加锁查询统一用主键或唯一索引,并且多行加锁时用 ORDER BY id 排序,让所有事务按同一个顺序拿锁。第二,把隔离级别降到读已提交,减少间隙锁范围。第三,如果死锁仍然出现,在应用层做最多 3 次重试,但重试的前提是购票操作具备幂等性,也就是重复提交不会创建重复订单——这正好要落到第 6 章的 requestId 设计上。死锁日志里 Innodb 会打出两个事务的持锁和等待信息,先看是表锁还是行锁,再定位是哪两条 SQL 的锁顺序不对。
5.4 现象:查询变慢,接口耗时上百毫秒
数据量只有几千行时,查询慢几乎都是索引问题。跑 EXPLAIN 看执行计划,常见的坏情况是 type = ALL 全表扫描。原因通常是 WHERE 里的列没有索引,或者索引列顺序和 WHERE 条件顺序不匹配。比如 WHERE 里写了 seat_type = 1 AND status = 1 AND train_id = 1,但索引是 (train_id, status, seat_type),优化器虽然能命中索引,但前导列 train_id 的条件写在最后,过滤顺序不佳。
解决的常见做法是给 seat_info 建 (train_id, status, seat_type) 组合索引,并让 WHERE 条件从左到右覆盖索引前缀。还有一个隐藏点:WHERE 里出现 OR 时索引会失效,要拆成两条 SQL 或 UNION。订单表按 train_id 和 status 建索引,是因为后台要频繁查某趟车的订单状态。排查时先 EXPLAIN,再看 key 列有没有用上索引、type 是不是 ref 或 const,全表扫描加几万行数据,接口慢就是必然的。
6. 从Demo走向可用的三个进阶动作:压测、幂等与日志回溯
6.1 压测验证:50个并发抢10个座位,最终卖出必须等于10
写完核心逻辑,第一件事不是加功能,而是验证不会超卖。我常用的方式是用 JMeter 开 50 个线程,每个线程循环 1 次,全部指向购票接口,请求参数里固定车次、随机选座位。跑完后执行两条校验 SQL:
SELECT COUNT(*) FROM ticket_order WHERE train_id = 1 AND status IN (0, 1); SELECT COUNT(*) FROM seat_info WHERE train_id = 1 AND status = 0;两条 SQL 的结果必须相等,并且等于实际可售座位数。如果订单数大于已售座位数,说明有座位被重复销售,立即回查日志定位是哪一步放行。压测时要注意:JMeter 的并发线程数不等于数据库连接数,连接池参数要按第 5 章先调好,否则压测结果会失真——报连接超时不代表业务逻辑有问题,只是池子小了。
6.2 接口幂等:客户端重复提交时,订单不会创建两条
真实场景里,用户点击“购买”后网络超时,会本能地再点一次。如果不做幂等,同一个座位会被创建两条待支付订单,虽然唯一索引挡住了重复销售,但用户会看到两个待支付单。常见做法是前端生成一个 requestId,后端在订单表加一列 request_id 并建唯一索引,插入时遇到重复键就返回已有订单号。Demo 里最轻量的改法是把 order_no 当成幂等键,生成规则改成“用户ID + 时间戳 + 随机数”,同一个请求重复提交时 order_no 相同,第二次插入被 uk_order_no 拦下,接口捕获 DuplicateKeyException 后查询并返回原订单号。幂等做在数据库层永远比应用层可靠。
6.3 日志回溯:从一条订单号反查完整链路
出了线上问题,最怕日志里只有 Exception 堆栈没有上下文。我习惯在购票接口四个节点各打一条日志:入口、锁行成功、状态更新、订单插入,统一带上 order_no、train_id、seat_id。格式固定,比如 [PURCHASE][orderNo=xxx][trainId=xxx][seatId=xxx] 开始锁行。排查时按 order_no 用 grep 一拉,整条链路的耗时和在哪一步失败就清楚了。这个习惯要从 Demo 阶段养成,等代码量大了再补日志,成本翻倍不说,还容易漏掉关键节点的上下文。
我最早做这个 Demo 的时候,以为能跑通就结束了,结果第一次压测就同时撞上负库存和死锁,才意识到“能用”和“扛得住”之间隔着一整套并发控制的功课。后来我把每个坑都按现象、原因、解决记进笔记,遇到新问题先查旧笔记,排查效率高很多。这个方向值得投入的地方恰恰是这些踩坑过程——事务边界怎么划、锁加在哪一层、幂等键用什么生成,这些判断比代码本身值钱,也是面试时真正能讲出深度的部分。希望帮到你。
本文还有配套的精品资源,点击获取