
简介面向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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 ListSeatInfo 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; ListSeatInfo 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 丢失更新的发生路径两个线程都读到了 status1没有并发控制时超卖是这样发生的线程 A 执行 SELECT id FROM seat_info WHERE id 100得到 status 1线程 B 也执行同样查询也得到 status 1然后 A 把状态更新为 0B 也更新为 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?useSSLfalseserverTimezoneAsia/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 次全部指向购票接口请求参数里固定车次、随机选座位。跑完后执行两条校验 SQLSELECT 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][orderNoxxx][trainIdxxx][seatIdxxx] 开始锁行。排查时按 order_no 用 grep 一拉整条链路的耗时和在哪一步失败就清楚了。这个习惯要从 Demo 阶段养成等代码量大了再补日志成本翻倍不说还容易漏掉关键节点的上下文。我最早做这个 Demo 的时候以为能跑通就结束了结果第一次压测就同时撞上负库存和死锁才意识到“能用”和“扛得住”之间隔着一整套并发控制的功课。后来我把每个坑都按现象、原因、解决记进笔记遇到新问题先查旧笔记排查效率高很多。这个方向值得投入的地方恰恰是这些踩坑过程——事务边界怎么划、锁加在哪一层、幂等键用什么生成这些判断比代码本身值钱也是面试时真正能讲出深度的部分。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。