航空订票系统实战:3个避坑点搞定面试必问 航空订票系统实战:3个避坑点搞定面试必问 刚把报错日志贴到群里,那满屏的 NullPointerException 和 StackOverflowError 看得人头皮发麻。别慌,这种“报错一堆看不懂 StackTrace”的情况,在写【航空订票】这类高并发业务时太常见了。很多初学者一看到红色报错就懵圈,其实只要理清调用链,问题往往出在状态管理或资源释放上。 在【面试必问】的场景里,面试官最爱问的就是:“你的订票系统如何处理超卖?”或者“高并发下如何保证库存一致性?”如果你连基本的异常堆栈都读不懂,更别提深入讨论分布式锁或消息队列了。今天我们就从零搭建一个轻量级的航空订票核心模块,不整那些虚头巴脑的微服务,就用最扎实的 Java 代码,把坑填平,把原理讲透。 项目目标与场景还原 咱们先明确目标:实现一个单机的【航空订票】核心逻辑,支持“查询余票”、“锁定座位”、“确认支付”三个步骤。 为什么选这个场景?因为它是典型的有状态资源竞争问题。机票不是无限库存,卖完就没了。在【面试必问】中,这考察的是对“临界区”和“事务边界”的理解。 核心痛点复盘: 超卖问题:两个用户同时买最后一张票,都成功了。 死锁风险:用户锁了票没付款,资源一直占用,其他用户买不到。 数据不一致:数据库扣减了,但本地缓存没更新,导致查询结果不准。 我们要解决的,就是让这套逻辑在单机环境下跑通,且逻辑严密,能经得起【面试必问】的追问。 目录结构设计 别一上来就写代码,先搭好架子。清晰的目录结构是工程化的第一步。 flight-booking-demo ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ └── demo │ │ │ ├── FlightBookingSystem │ │ │ ├── model │ │ │ │ ├── Flight.java │ │ │ │ └── TicketStatus.java │ │ │ ├── service │ │ │ │ └── BookingService.java │ │ │ └── util │ │ │ └── InventoryLock.java │ │ └── resources │ │ └── logback.xml │ └── test │ └── java │ └── com │ └── demo │ └── BookingConcurrencyTest.java └── pom.xml 设计思路: model:存放领域模型,Flight 包含航班号、余票列表;TicketStatus 是枚举,定义 AVAILABLE, LOCKED, SOLD 三种状态。 service:核心业务逻辑层,所有状态变更都在这里发生。 util:工具类,这里我们放一个简易的库存锁实现,模拟并发控制。 test:并发测试用例,这是验证是否超卖的关键。 核心代码实现 这是重头戏。我们将分步实现,每段代码都附带详细注释,帮你理解每一行在干什么。 1. 定义数据模型 // model/Flight.java public class Flight { private String flightNo; private MapString, TicketStatus seatMap; // 座位号 - 状态 public Flight(String flightNo, int seatCount) { this.flightNo = flightNo; this.seatMap = new ConcurrentHashMap(); // 初始化所有座位为 AVAILABLE for (int i = 1; i = seatCount; i++) { String seatId = String.format(A%d, i); seatMap.put(seatId, TicketStatus.AVAILABLE); } } public boolean tryLockSeat(String seatId) { // 关键:原子性地检查并更新状态 // 使用 compute 方法保证线程安全 return seatMap.compute(seatId, (key, oldStatus) - { if (oldStatus == TicketStatus.AVAILABLE) { return TicketStatus.LOCKED; } return oldStatus; // 状态不变,返回原状态 }) == TicketStatus.LOCKED; } public boolean confirmPayment(String seatId) { return seatMap.computeIfPresent(seatId, (key, oldStatus) - { if (oldStatus == TicketStatus.LOCKED) { return TicketStatus.SOLD; } return oldStatus; }) == TicketStatus.SOLD; } public void unlockSeat(String seatId) { seatMap.computeIfPresent(seatId, (key, oldStatus) - { if (oldStatus == TicketStatus.LOCKED) { return TicketStatus.AVAILABLE; } return oldStatus; }); } } 逐行解析: ConcurrentHashMap:多线程环境下,普通 HashMap 会丢数据,必须用并发容器。 compute 方法:这是 JDK 8 提供的高阶函数。它接收一个 key 和一个 BiFunction,原子性地读取旧值、计算新值、写回新值。这是避免“检查-执行”竞态条件的关键。 tryLockSeat:只有当前状态是 AVAILABLE 时,才允许变为 LOCKED。如果已经是 LOCKED 或 SOLD,则直接返回旧状态,compute 返回 LOCKED 为 false,表示锁定失败。 2. 业务逻辑封装 // service/BookingService.java public class BookingService { private final MapString, Flight flightRepository = new ConcurrentHashMap(); public void initFlights() { // 初始化两个航班,各10个座位 flightRepository.put(CA1234, new Flight(CA1234, 10)); flightRepository.put(MU5678, new Flight(MU5678, 10)); } public boolean bookTicket(String flightNo, String seatId, String userId) { Flight flight = flightRepository.get(flightNo); if (flight == null) { throw new IllegalArgumentException(航班不存在: + flightNo); } // 第一步:锁定座位 boolean locked = flight.tryLockSeat(seatId); if (!locked) { System.out.println([ + userId + ] 座位 + seatId + 已被他人锁定或已售出); return false; } try { // 模拟支付耗时 (这是导致超卖的常见原因) Thread.sleep(100); // 第二步:确认支付 boolean paid = flight.confirmPayment(seatId); if (paid) { System.out.println([ + userId + ] 成功购买 + flightNo + + seatId); return true; } else { // 支付失败,释放锁 flight.unlockSeat(seatId); return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 中断时也要释放锁,防止死锁 flight.unlockSeat(seatId); return false; } } } 避坑点详解: Thread.sleep(100):模拟网络延迟。如果没有这行,测试时很难复现并发冲突。 异常处理:catch 块中必须调用 unlockSeat。如果这里不释放,一旦线程被中断,座位就永久锁死了。这是【面试必问】中关于“资源清理”的经典考点。 日志输出:在关键节点打印日志,方便后续排查 StackTrace。 运行与测试:复现并解决报错 光看代码不够,必须跑起来。我们用 JUnit 写一个并发测试,模拟 50 个用户抢 10 张票。 // test/BookingConcurrencyTest.java import org.junit.jupiter.api.Test; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class BookingConcurrencyTest { @Test public void testConcurrentBooking() throws InterruptedException { BookingService service = new BookingService(); service.initFlights(); int totalUsers = 50; int availableSeats = 10; AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); ExecutorService executor = Executors.newFixedThreadPool(20); CountDownLatch latch = new CountDownLatch(totalUsers); for (int i = 0; i totalUsers; i++) { final int userId = i; executor.submit(() - { try { // 随机选择座位和航班,增加冲突概率 String seatId = A + (userId % availableSeats + 1); String flightNo = CA1234; boolean success = service.bookTicket(flightNo, seatId, User + userId); if (success) { successCount.incrementAndGet(); } else { failCount.incrementAndGet(); } } finally { latch.countDown(); } }); } latch.await(); // 等待所有任务完成 executor.shutdown(); System.out.println(总用户数: + totalUsers); System.out.println(购买成功: + successCount.get()); System.out.println(购买失败: + failCount.get()); // 断言:成功数不能超过余票数 if (successCount.get() availableSeats) { throw new AssertionError(超卖!成功数 + successCount.get() + 超过余票 + availableSeats); } } } 如何看懂报错 StackTrace? 如果在测试中抛出 AssertionError: 超卖,别慌。按照以下步骤排查: 看最外层异常:java.lang.AssertionError: 超卖。这说明业务逻辑错了,不是代码语法错误。 看堆栈顶部:找到 at com.demo.BookingConcurrencyTest.testConcurrentBooking。定位到测试方法。 分析逻辑:回顾 bookTicket 方法。检查 tryLockSeat 是否真的原子操作? 如果用了 if (status == AVAILABLE) { status = LOCKED; } 这种非原子写法,就会超卖。 我们用了 compute,所以逻辑上应该是安全的。 检查资源释放:看 catch 块是否执行了 unlockSeat。如果没执行,可能导致某些座位一直 LOCKED,但这不会导致“超卖”,只会导致“少卖”。所以如果报“超卖”,重点查 tryLockSeat 的实现。 真实案例参考: 在【掘金技术社区】上,很多开发者分享过类似的高并发库存扣减方案。大家普遍发现,原子性操作是解决超卖的基石。JDK 的 ConcurrentHashMap 或 AtomicInteger 提供了很好的底层支持。如果你用的框架不支持原子更新,就要考虑引入 Redis 的 SETNX 或数据库的 UPDATE ... WHERE status = 'AVAILABLE' 乐观锁。 优化扩展:从单机到分布式 现在的代码在单机跑没问题,但如果部署到两台服务器呢? 问题: 服务器 A 和服务器 B 各自维护一份 Flight 对象。用户在 A 买了票,A 的内存状态变了,但 B 不知道。用户再去 B 买同一张票,B 的内存里还是 AVAILABLE,于是超卖。 解决方案: 集中式库存: 将 Flight 的余票信息存到 Redis。tryLockSeat 改为执行 Redis 命令: -- Redis Lua 脚本,保证原子性 local status = redis.call('GET', 'flight:CA1234:seat:A1') if status == 'AVAILABLE' then redis.call('SET', 'flight:CA1234:seat:A1', 'LOCKED', 'EX', 300) -- 5分钟过期 return 1 else return 0 end 使用 Lua 脚本确保“检查+更新”在 Redis 服务端原子执行。 消息队列异步扣减: 先锁定本地缓存(快速响应),然后发送 MQ 消息到 Kafka。消费者统一从数据库扣减库存。如果扣减失败,发送“取消”消息回滚本地缓存。 【面试必问】深度解析: 面试官问:“为什么用 Redis 而不是直接操作数据库?” 答:数据库 I/O 慢,高并发下会成为瓶颈。Redis 在内存中操作,速度是微秒级。但 Redis 不持久化,所以关键数据(如已售出)最终要落库。这是一个“最终一致性”的权衡。 小结与互动 我们从零搭建了一个【航空订票】核心模块,通过 ConcurrentHashMap 的 compute 方法解决了单机超卖问题,并通过并发测试验证了逻辑的正确性。 关键收获: 原子性是核心:任何涉及“检查-修改”的操作,必须保证原子性,否则必现并发 Bug。 异常必须清理:资源锁定后,无论成功失败,都要有释放机制,防止死锁。 读懂 StackTrace:从外到内,从业务到代码,逐层定位,别被满屏红字吓倒。 这个案例虽然简单,但涵盖了【面试必问】中关于并发、状态机、资源管理的核心考点。在实际项目中,你可能还要考虑数据库事务、分布式锁、限流熔断等,但底层逻辑是一致的。 还有什么不懂的?评论区留言挨个回。 比如: 如果用 Go 语言实现,sync.Map 和 Channel 怎么选? Redis 宕机了,库存怎么办? 如何设计一个通用的“资源锁定”框架? 把你的问题抛出来,咱们一起拆解。