飞机订票系统设计:占位、支付、防止超售
1. 概述
卖飞机票听起来很简单:客户选座位、付钱、领票。难点在于成千上万的人争夺有限的座位。而且支付还要经过第三方,可能会很慢或返回多次结果。
在这篇文章中,你将设计一个从需求到代码的订票系统。系统包括有期限的占位、幂等支付、防止重复售票和应对流量突增。读完之后,你会有一个通用的设计框架,可以用于演唱会售票、酒店预订或闪购等类似问题。
2. 需求分析
2.1. 功能需求
- 查找航班并查看座位图的空座。
- 占位(hold) 一个或多个座位 10 分钟。
- 支付。成功则出票,失败或过期则释放座位。
- 取消订单并按政策退款。
2.2. 规模与约束
- 每天约 5,000 个航班,每个航班 180 个座位。
- 读取量(查询、查看座位)大约是写入量的 100 倍。
- 在廉价票开售时,可能有 50,000 请求/秒涌向数十个航班。
- 硬性约束: 一个座位永远不能卖给两个人。按舱位等级售出的机票数不能超过航空公司允许的上限。
注意: 航空公司会故意按比例超售,比如 180 个经济舱座位卖 183 张票。原因是总会有旅客无法成行。我们要防止的是无意中由于竞态条件导致的超售。故意超售的比例应该是一个可控的参数。
上述两个约束决定了架构的选择。座位库存数据需要强一致性。而搜索部分则接受最终一致性(eventual consistency)并进行积极缓存。
3. 数据模型
我们用 PostgreSQL 作为库存的真实来源:
CREATE TABLE flight (
id BIGINT PRIMARY KEY,
code VARCHAR(10) NOT NULL,
departure_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE fare_inventory (
flight_id BIGINT NOT NULL REFERENCES flight(id),
fare_class VARCHAR(10) NOT NULL, -- ECONOMY, BUSINESS
capacity INT NOT NULL,
overbook_limit INT NOT NULL DEFAULT 0,
sold INT NOT NULL DEFAULT 0,
held INT NOT NULL DEFAULT 0,
PRIMARY KEY (flight_id, fare_class),
CHECK (sold + held <= capacity + overbook_limit)
);
CREATE TABLE seat (
flight_id BIGINT NOT NULL REFERENCES flight(id),
seat_no VARCHAR(4) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, SOLD
booking_id BIGINT,
held_until TIMESTAMPTZ,
PRIMARY KEY (flight_id, seat_no)
);
CREATE TABLE booking (
id BIGINT PRIMARY KEY,
flight_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
status VARCHAR(12) NOT NULL, -- HELD, CONFIRMED, EXPIRED, CANCELLED
amount NUMERIC(12,2) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE payment (
id BIGINT PRIMARY KEY,
booking_id BIGINT NOT NULL,
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(10) NOT NULL
);
设计有两层库存控制:
fare_inventory按舱位等级计数。CHECK约束是最后的安全网:即使代码有 bug,数据库也会拒绝超过上限的超售。seat管理每个具体座位。主键(flight_id, seat_no)确保每个座位只有一行状态。
4. API
GET /flights?from=SGN&to=HAN&date=2026-12-20 -> 航班列表(缓存)
GET /flights/{id}/seats -> 座位图(缓存 2-5 秒)
POST /bookings -> 占位,返回 bookingId + expiresAt
POST /bookings/{id}/payments -> 需要 Idempotency-Key header
POST /payments/webhook -> 支付网关回调
DELETE /bookings/{id} -> 取消
占位请求示例:
{ "flightId": 1001, "customerId": 42, "fareClass": "ECONOMY", "seats": ["12A", "12B"] }
座位图允许有几秒的延迟。用户可能看到座位“空着”却在占位时被拒绝。这是可接受的权衡,只要占位步骤始终重新检查数据库。
5. 占位:防止重复售票
最常见的错误方法是“先读后写”:SELECT status,看到 AVAILABLE 就 UPDATE。两个事务可能都读到座位空着,然后都写入。结果是一个座位被卖给了两个人。
解决方案是条件更新:把检查条件合并到 UPDATE 语句中,并计算受影响的行数。
public interface SeatRepository extends JpaRepository<Seat, SeatId> {
@Modifying
@Query(value = """
UPDATE seat
SET status = 'HELD', booking_id = :bookingId, held_until = :until
WHERE flight_id = :flightId
AND seat_no IN (:seatNos)
AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < now()))
""", nativeQuery = true)
int holdSeats(Long flightId, List<String> seatNos, Long bookingId, Instant until);
@Modifying
@Query(value = """
UPDATE seat SET status = 'SOLD', held_until = NULL
WHERE booking_id = :bookingId AND status = 'HELD'
""", nativeQuery = true)
int markSold(Long bookingId);
}
public interface FareInventoryRepository extends JpaRepository<FareInventory, FareInventoryId> {
@Modifying
@Query(value = """
UPDATE fare_inventory SET held = held + :n
WHERE flight_id = :flightId AND fare_class = :fareClass
AND sold + held + :n <= capacity + overbook_limit
""", nativeQuery = true)
int reserve(Long flightId, String fareClass, int n);
}
PostgreSQL 在执行 UPDATE 时会锁定行。第二个事务必须等待,然后根据新数据重新评估 WHERE 条件。这时条件为假,语句返回 0 行。条件 held_until < now() 允许“夺回”已过期的占位座位,无需等待清理任务。
Service 在一个事务中结合两步:
@Service
@RequiredArgsConstructor
public class BookingService {
private static final Duration HOLD_TTL = Duration.ofMinutes(10);
private final SeatRepository seatRepository;
private final FareInventoryRepository inventoryRepository;
private final BookingRepository bookingRepository;
private final IdGenerator idGenerator;
@Transactional
public Booking hold(HoldRequest req) {
int n = req.seats().size();
if (inventoryRepository.reserve(req.flightId(), req.fareClass(), n) != 1) {
throw new SoldOutException(req.flightId(), req.fareClass());
}
Instant until = Instant.now().plus(HOLD_TTL);
long bookingId = idGenerator.next();
int held = seatRepository.holdSeats(req.flightId(), req.seats(), bookingId, until);
if (held != n) {
// 回滚 reserve 和已获得的座位
throw new SeatUnavailableException(req.seats());
}
return bookingRepository.save(new Booking(bookingId, req.flightId(),
req.customerId(), BookingStatus.HELD, req.amount(), until));
}
}
SeatUnavailableException 是 RuntimeException,所以 Spring 会回滚整个事务。客户要么成功占有所有选中的座位,要么没有座位。不会出现占到 12A 而失去 12B 的情况。
注意: 为了避免两个客户同时选择 {12A, 12B} 和 {12B, 12A} 时的死锁,请在发送前对
seatNos排序。Postgres 不保证按IN列表顺序加锁。对于大座位组,应该在更新前使用SELECT … ORDER BY seat_no FOR UPDATE。
如果座位刚过期就被别人夺走,旧订单的 held 计数器仍然存在。第 7 节的清理任务会将其扣除。
6. 幂等支付
支付网关可能多次调用 webhook。用户可能点两次“支付”。所有金融操作必须是幂等的。
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
private final BookingRepository bookingRepository;
private final SeatRepository seatRepository;
private final FareInventoryRepository inventoryRepository;
private final OutboxRepository outboxRepository;
@Transactional
public PaymentResult confirm(long bookingId, String idempotencyKey) {
Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return PaymentResult.of(existing.get()); // 重复调用:返回旧结果
}
// HELD -> CONFIRMED,仅当订单未过期时才成功
int updated = bookingRepository.transition(bookingId, "HELD", "CONFIRMED");
if (updated == 0) {
paymentRepository.save(Payment.refundRequired(bookingId, idempotencyKey));
return PaymentResult.REFUND;
}
Booking booking = bookingRepository.findById(bookingId).orElseThrow();
int seats = seatRepository.markSold(bookingId);
inventoryRepository.moveHeldToSold(booking.getFlightId(), booking.getFareClass(), seats);
paymentRepository.save(Payment.success(bookingId, idempotencyKey));
outboxRepository.save(OutboxEvent.bookingConfirmed(bookingId));
return PaymentResult.SUCCESS;
}
}
transition 语句只是另一种条件更新:
UPDATE booking SET status = :to WHERE id = :id AND status = :from
关键之处在于“钱到账”和“占位过期”之间的竞态。支付流程和过期任务都从 HELD 状态转换。只有一方会赢。如果付款来晚了,客户会自动获得退款,而不是拿到已卖给别人的座位。
列 idempotency_key UNIQUE 阻止两个相同 key 的请求并行运行。两个请求都看不到旧记录,但第二个请求在插入时会得到 DataIntegrityViolationException,其事务回滚。
6.1. Outbox 和 Kafka
出票、发邮件和同步到值机系统都是异步的。不要在事务内直接发 Kafka。如果 DB 提交但发送失败(或反之),数据会不一致。应该在同一个事务中写入 outbox 表,然后由一个 relay 推送到 Kafka:
@Component
@RequiredArgsConstructor
public class OutboxRelay {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafka;
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
for (OutboxEvent e : outboxRepository.findTop100ByPublishedFalseOrderById()) {
kafka.send("booking-events", String.valueOf(e.getBookingId()), e.getPayload()).join();
e.markPublished();
}
}
}
Relay 保证至少一次传递,所以出票端的消费者也必须幂等,例如通过保存已处理的 bookingId。使用 bookingId 作为 key 会将同一订单的所有事件发往同一分区并保持顺序。
7. 清理过期占位
@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
for (Booking b : bookingRepository.findTop500ByStatusAndExpiresAtBefore(
BookingStatus.HELD, Instant.now())) {
if (bookingRepository.transition(b.getId(), "HELD", "EXPIRED") == 1) {
int released = seatRepository.releaseByBooking(b.getId()); // HELD -> AVAILABLE
inventoryRepository.release(b.getFlightId(), b.getFareClass(), b.getSeatCount());
}
}
}
任务是按订单的 seatCount 扣除 held,而不是按刚释放的座位数。原因是有些座位可能已被别人夺走,但计数器仍然为旧订单计算。在多实例运行时,使用 SELECT … FOR UPDATE SKIP LOCKED 或 ShedLock 以确保节点不会重复处理。
8. 应对流量突增
廉价票开售时,50,000 请求/秒同时打到几行 fare_inventory。这是热行。每个事务都排队等待同一行锁。数据库不是因为 CPU 而死,而是因为连接池耗尽。
策略是在到达 DB 之前过滤:
- 虚拟等待室(waiting room): 每秒只放 N 个人进入订票页面,其余的得到排队令牌。
- Redis 快速过滤器: 用
SET NX锁定座位,提前拒绝注定失败的请求。 - DB 仍然是最终仲裁者。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
@RequiredArgsConstructor
public class SeatGate {
private final StringRedisTemplate redis;
public boolean tryEnter(long flightId, String seatNo, long customerId) {
String key = "seat:%d:%s".formatted(flightId, seatNo);
Boolean ok = redis.opsForValue()
.setIfAbsent(key, String.valueOf(customerId), Duration.ofSeconds(15));
return Boolean.TRUE.equals(ok);
}
}
如果 tryEnter 返回 false,API 立即返回 409 Conflict 而无需开启任何事务。15 秒的 TTL 足以覆盖 DB 上的占位步骤,如果实例崩溃,key 会自动过期。
注意: 不要让 Redis 成为库存的真实来源。Redis 故障转移时,你可能会丢失 key,导致两个人通过。这没关系,因为 DB 上的条件更新仍会阻止重复。相反,如果只信任 Redis,你会重复售票。
对于按舱位等级计数的热行,你可以将 fare_inventory 分成多个“桶”(例如 10 行,每行 18 个座位)并随机选择一个桶来减少锁争用。
9. 防重复售票测试
最重要的测试:50 个线程同时占一个座位,只有一个线程会赢。用真实 PostgreSQL 通过 Testcontainers 运行,因为 H2 的锁定行为与 Postgres 不同。
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired BookingService bookingService;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
int threads = 50;
AtomicInteger success = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < threads; i++) {
long customerId = i;
pool.submit(() -> {
start.await();
try {
bookingService.hold(new HoldRequest(1001L, customerId, "ECONOMY",
List.of("12A"), new BigDecimal("1500000")));
success.incrementAndGet();
} catch (SeatUnavailableException | SoldOutException ignored) { }
return null;
});
}
start.countDown();
}
assertThat(success.get()).isEqualTo(1);
}
}
测试假设样本数据(航班 1001、座位 12A 空着)通过 Flyway 或 data.sql 加载。CountDownLatch 同时释放所有线程以最大化竞争。ExecutorService 的 try-with-resources 块(Java 21)等待所有任务完成后才关闭。预期结果:恰好一次成功,49 次捕获 SeatUnavailableException。
10. 常见错误
- 在 JVM 中使用
synchronized或锁: 仅在单实例运行时正确。扩展到两个 pod 就会出问题。 - 为热行使用 Optimistic locking 和
@Version: 逻辑上正确,但在流量突增时重试率会很高。条件更新更高效,因为无需提前读取。 - 支付调用期间保持事务打开: 行锁会被持有数秒。占位、提交、调用支付网关、然后在新事务中确认。
- 忘记在占位过期后处理到账: 这是最大的投诉来源。始终有自动退款的分支。
- 座位图缓存时间过长: 用户不断选择已丢失的座位。2–5 秒足够。
11. 总结
飞机订票系统围绕一个原则运作:将需要强一致性的部分(座位库存、订单状态)与可以容忍延迟的部分(搜索、通知、出票)分离。数据库上的条件更新不需要分布式锁就能解决竞争。带有条件转换的状态机 HELD → CONFIRMED/EXPIRED 处理支付和过期之间的竞态。幂等性 key 加上 outbox 防止金钱和事件重复。Redis 和虚拟等待室在流量突增时保护数据库。
你可以将这个通用框架应用于任何“稀缺资源、多人争夺”的问题:演唱会票、酒店房间、医疗号源或闪购。