
航空订票系统实战: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 宕机了,库存怎么办?
如何设计一个通用的“资源锁定”框架?
把你的问题抛出来,咱们一起拆解。