航空订票系统实战:3个避坑点搞定面试必问

📅 发布时间:2026/9/21 19:36:52
航空订票系统实战: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();// 初始化所有座位为 AVAILABLEfor (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 {@Testpublic 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' thenredis.call('SET', 'flight:CA1234:seat:A1', 'LOCKED', 'EX', 300) -- 5分钟过期return 1 elsereturn 0 end使用 Lua 脚本确保“检查+更新”在 Redis 服务端原子执行。消息队列异步扣减: 先锁定本地缓存(快速响应),然后发送 MQ 消息到 Kafka。消费者统一从数据库扣减库存。如果扣减失败,发送“取消”消息回滚本地缓存。【面试必问】深度解析: 面试官问:“为什么用 Redis 而不是直接操作数据库?” 答:数据库 I/O 慢,高并发下会成为瓶颈。Redis 在内存中操作,速度是微秒级。但 Redis 不持久化,所以关键数据(如已售出)最终要落库。这是一个“最终一致性”的权衡。 小结与互动 我们从零搭建了一个【航空订票】核心模块,通过 ConcurrentHashMap 的 compute 方法解决了单机超卖问题,并通过并发测试验证了逻辑的正确性。 关键收获:原子性是核心:任何涉及“检查-修改”的操作,必须保证原子性,否则必现并发 Bug。 异常必须清理:资源锁定后,无论成功失败,都要有释放机制,防止死锁。 读懂 StackTrace:从外到内,从业务到代码,逐层定位,别被满屏红字吓倒。这个案例虽然简单,但涵盖了【面试必问】中关于并发、状态机、资源管理的核心考点。在实际项目中,你可能还要考虑数据库事务、分布式锁、限流熔断等,但底层逻辑是一致的。 还有什么不懂的?评论区留言挨个回。 比如:如果用 Go 语言实现,sync.Map 和 Channel 怎么选? Redis 宕机了,库存怎么办? 如何设计一个通用的“资源锁定”框架?把你的问题抛出来,咱们一起拆解。