news 2026/9/21 19:35:57

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
航空订票系统实战:3个避坑点搞定面试必问

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

刚把报错日志贴到群里,那满屏的 NullPointerExceptionStackOverflowError 看得人头皮发麻。别慌,这种“报错一堆看不懂 StackTrace”的情况,在写【航空订票】这类高并发业务时太常见了。很多初学者一看到红色报错就懵圈,其实只要理清调用链,问题往往出在状态管理或资源释放上。

在【面试必问】的场景里,面试官最爱问的就是:“你的订票系统如何处理超卖?”或者“高并发下如何保证库存一致性?”如果你连基本的异常堆栈都读不懂,更别提深入讨论分布式锁或消息队列了。今天我们就从零搭建一个轻量级的航空订票核心模块,不整那些虚头巴脑的微服务,就用最扎实的 Java 代码,把坑填平,把原理讲透。

项目目标与场景还原

咱们先明确目标:实现一个单机的【航空订票】核心逻辑,支持“查询余票”、“锁定座位”、“确认支付”三个步骤。

为什么选这个场景?因为它是典型的有状态资源竞争问题。机票不是无限库存,卖完就没了。在【面试必问】中,这考察的是对“临界区”和“事务边界”的理解。

核心痛点复盘:

  1. 超卖问题:两个用户同时买最后一张票,都成功了。
  2. 死锁风险:用户锁了票没付款,资源一直占用,其他用户买不到。
  3. 数据不一致:数据库扣减了,但本地缓存没更新,导致查询结果不准。

我们要解决的,就是让这套逻辑在单机环境下跑通,且逻辑严密,能经得起【面试必问】的追问。

目录结构设计

别一上来就写代码,先搭好架子。清晰的目录结构是工程化的第一步。

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 Map<String, 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。如果已经是 LOCKEDSOLD,则直接返回旧状态,compute 返回 LOCKED 为 false,表示锁定失败。

2. 业务逻辑封装

// service/BookingService.java
public class BookingService {private final Map<String, 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: 超卖,别慌。按照以下步骤排查:

  1. 看最外层异常java.lang.AssertionError: 超卖。这说明业务逻辑错了,不是代码语法错误。
  2. 看堆栈顶部:找到 at com.demo.BookingConcurrencyTest.testConcurrentBooking。定位到测试方法。
  3. 分析逻辑:回顾 bookTicket 方法。检查 tryLockSeat 是否真的原子操作?
    • 如果用了 if (status == AVAILABLE) { status = LOCKED; } 这种非原子写法,就会超卖。
    • 我们用了 compute,所以逻辑上应该是安全的。
  4. 检查资源释放:看 catch 块是否执行了 unlockSeat。如果没执行,可能导致某些座位一直 LOCKED,但这不会导致“超卖”,只会导致“少卖”。所以如果报“超卖”,重点查 tryLockSeat 的实现。

真实案例参考: 在【掘金技术社区】上,很多开发者分享过类似的高并发库存扣减方案。大家普遍发现,原子性操作是解决超卖的基石。JDK 的 ConcurrentHashMapAtomicInteger 提供了很好的底层支持。如果你用的框架不支持原子更新,就要考虑引入 Redis 的 SETNX 或数据库的 UPDATE ... WHERE status = 'AVAILABLE' 乐观锁。

优化扩展:从单机到分布式

现在的代码在单机跑没问题,但如果部署到两台服务器呢?

问题: 服务器 A 和服务器 B 各自维护一份 Flight 对象。用户在 A 买了票,A 的内存状态变了,但 B 不知道。用户再去 B 买同一张票,B 的内存里还是 AVAILABLE,于是超卖。

解决方案:

  1. 集中式库存: 将 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 服务端原子执行。

  2. 消息队列异步扣减: 先锁定本地缓存(快速响应),然后发送 MQ 消息到 Kafka。消费者统一从数据库扣减库存。如果扣减失败,发送“取消”消息回滚本地缓存。

【面试必问】深度解析: 面试官问:“为什么用 Redis 而不是直接操作数据库?” :数据库 I/O 慢,高并发下会成为瓶颈。Redis 在内存中操作,速度是微秒级。但 Redis 不持久化,所以关键数据(如已售出)最终要落库。这是一个“最终一致性”的权衡。

小结与互动

我们从零搭建了一个【航空订票】核心模块,通过 ConcurrentHashMapcompute 方法解决了单机超卖问题,并通过并发测试验证了逻辑的正确性。

关键收获:

  1. 原子性是核心:任何涉及“检查-修改”的操作,必须保证原子性,否则必现并发 Bug。
  2. 异常必须清理:资源锁定后,无论成功失败,都要有释放机制,防止死锁。
  3. 读懂 StackTrace:从外到内,从业务到代码,逐层定位,别被满屏红字吓倒。

这个案例虽然简单,但涵盖了【面试必问】中关于并发、状态机、资源管理的核心考点。在实际项目中,你可能还要考虑数据库事务、分布式锁、限流熔断等,但底层逻辑是一致的。

还有什么不懂的?评论区留言挨个回。 比如:

  • 如果用 Go 语言实现,sync.MapChannel 怎么选?
  • Redis 宕机了,库存怎么办?
  • 如何设计一个通用的“资源锁定”框架?

把你的问题抛出来,咱们一起拆解。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 19:35:39

VR虚拟现实加盟源码跑不通?3个调试技巧带你入门到精通

VR虚拟现实加盟源码跑不通?3个调试技巧带你入门到精通 刚把VR虚拟现实加盟项目的源码复制过来,一运行直接报错?别慌,这种“看着能跑,实际全崩”的坑,我踩过的比吃过的盐都多。很多新手卡在入门到精通的第一步,不是代码写错了,而是环境、依赖和配置没对齐。今天不讲虚的,直接上硬核调试技巧,帮你把那些藏在代…

作者头像 李华
网站建设 2026/9/21 19:35:38

苹果双系统切换踩坑实录 一文搞懂底层逻辑与修复方案

苹果双系统切换踩坑实录 一文搞懂底层逻辑与修复方案 刚学完 Swift 语法,对着文档里的 import Foundation 和 @main 属性点头如捣蒜,一上手想搭个跨平台项目,电脑直接蓝屏或者卡在苹果 Logo…

作者头像 李华
网站建设 2026/9/21 19:35:21

11点11分源码深扒:解决复制代码跑不通的性能优化实战

11点11分源码深扒:解决复制代码跑不通的性能优化实战 刚把CSDN上那篇“11点11分”高精度计时Demo复制到本地,双击运行直接报 ImportError ,或者跑起来CPU飙升到100%却只输出了一个乱码时间?别急着怀疑自己手残,这大概率不是代码逻辑错了,而是 环境依赖版本冲突 加上…

作者头像 李华
网站建设 2026/9/21 19:35:18

2026最新合并单元格快捷键实战避坑指南

2026最新合并单元格快捷键实战避坑指南 复制来的代码跑不通,报错信息满屏飞,你是不是也卡在“合并单元格快捷键”这个看似简单实则深坑的地方?别急,2026最新的开发环境里,这块的逻辑早就变了。很多人还在用老版本Excel或WPS的肌肉记忆去套新框架,结果就是数据错位、样式丢失,甚至前端渲染直接崩盘。…

作者头像 李华
网站建设 2026/9/21 19:35:13

3天搞定二维码转换器:从入门到精通的实战源码解析

3天搞定二维码转换器:从入门到精通的实战源码解析 看了一堆教程还是不会写项目?别慌,这不是你的错,是大多数教程只讲“怎么用”,不讲“为什么这么写”。今天咱们不聊虚的,直接拆解一个真实项目里的 二维码转换器 核心代码。目标很明确:带你从 入门到精通…

作者头像 李华