飞机订票系统设计:占位、支付、防止超售

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 之前过滤:

  1. 虚拟等待室(waiting room): 每秒只放 N 个人进入订票页面,其余的得到排队令牌。
  2. Redis 快速过滤器: 用 SET NX 锁定座位,提前拒绝注定失败的请求。
  3. 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 和虚拟等待室在流量突增时保护数据库。

你可以将这个通用框架应用于任何“稀缺资源、多人争夺”的问题:演唱会票、酒店房间、医疗号源或闪购。

system-designspring-bootconcurrencyrediskafka