电影售票系统设计:座位临时锁定与预留过期

1. 总体概览

当上映大片的首场放映时,数千人同时点击座位图,而放映厅只有几百个座位。系统必须保证每个座位只属于一个人。座位在客户支付时被临时保留,如果客户离开则需要自动释放。

本文完整介绍了系统设计:需求、数据模型、API、处理流程以及难点。代码示例使用Java 21、Spring Boot 3.x、SQL和Redis。读完本文后,你可以自己实现一个能处理高并发请求的安全座位保留机制。你还将学到如何在不依赖清理任务的情况下处理座位过期。

2. 需求

2.1. 功能需求

  • 客户可查看某一放映场次的座位图,显示座位状态:空闲、已保留、已售出。
  • 客户最多选择8个座位并保留10分钟。
  • 座位保留是全或无的:如果某个座位无法保留,则一个都不保留。
  • 客户在保留期间支付,座位转换为已售出。
  • 10分钟后未支付,座位自动恢复为空闲。

2.2. 规模和约束

  • 约500家影院,每家影院10个放映厅,每个放映厅100–300个座位。
  • 正常情况下每秒数百个请求。发售热门电影时可能猛增至每秒5万个请求,集中在某几场放映。
  • 不能重复销售座位。这是硬性约束。
  • 读写比例严重不均衡:每次座位保留伴随数十次座位图查询。
  • 支付通过外部网关处理。回调可能延迟到达、到达两次或永不到达。

3. 数据模型

核心表是show_seat。每一行对应某场放映中的一个座位。我们不会单独分离“保留”表,因为座位状态和保留信息需在同一原子操作中更新。

CREATE TABLE show_seat (
    showtime_id     BIGINT       NOT NULL,
    seat_code       VARCHAR(8)   NOT NULL,
    status          VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
    hold_id         UUID,
    customer_id     BIGINT,
    hold_expires_at TIMESTAMP,
    booking_id      BIGINT,
    PRIMARY KEY (showtime_id, seat_code)
);

CREATE INDEX idx_show_seat_hold ON show_seat (hold_id);
CREATE INDEX idx_show_seat_expired ON show_seat (status, hold_expires_at);

需要注意的几点:

  • 主键(showtime_id, seat_code)允许快速更新同一放映场次的座位组。
  • hold_id汇总同一次保留的所有座位。支付步骤将通过此hold_id确认整个组。
  • 索引(status, hold_expires_at)用于清理过期座位的任务,详见第7部分。

showtime、booking和payment表是常规CRUD表,本文不详细介绍。

4. API

GET    /showtimes/{showtimeId}/seats          -> 座位图+状态
POST   /showtimes/{showtimeId}/holds          -> 保留座位,body: {"seats":["F5","F6"]}
                                                 201 {"holdId":"...","expiresAt":"..."} | 409 座位被占用
POST   /holds/{holdId}/checkout               -> 创建支付会话(header Idempotency-Key)
DELETE /holds/{holdId}                        -> 客户取消,立即释放座位
POST   /payments/callback                     -> 支付网关回调

座位已被其他人保留时返回409 Conflict。客户端根据这个状态码刷新座位图,提示用户选择其他座位。

5. 处理流程

客户          API                 DB / Redis              支付网关
  | 选择座位    |                       |                          |
  |------------>| 条件UPDATE            |                          |
  |             |---------------------->| 已保留,10分钟后过期      |
  |<------------| 201 holdId            |                          |
  | 结账        |                       |                          |
  |------------>| 创建支付(8分钟)       |------------------------->|
  |             |                       |        回调成功           |
  |             |<-------------------------------------------------|
  |             | 如果未过期则            |                          |
  |             | 已保留 -> 已售出       |                          |

支付会话配置为8分钟后过期,短于座位保留时间。2分钟的缓冲帮助大多数回调在座位释放前及时到达。

6. 座位临时锁定

需要的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

6.1. 条件UPDATE是事实之源

常见的错误方式是先读后写:用SELECT检查空座位,然后UPDATE。两条语句之间,另一个请求可能插入并抢走座位。结果是两个人都以为自己保留了座位。

正确的做法是将检查并入WHERE条件,然后计算受影响的行数:

@Service
public class SeatHoldService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);

    private final NamedParameterJdbcTemplate jdbc;
    private final Clock clock;

    public SeatHoldService(NamedParameterJdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Transactional
    public SeatHold hold(long showtimeId, List<String> seatCodes, long customerId) {
        UUID holdId = UUID.randomUUID();
        Instant now = clock.instant();
        Instant expiresAt = now.plus(HOLD_TTL);

        var params = new MapSqlParameterSource()
                .addValue("holdId", holdId)
                .addValue("customerId", customerId)
                .addValue("showtimeId", showtimeId)
                .addValue("seatCodes", seatCodes.stream().sorted().toList())
                .addValue("now", Timestamp.from(now))
                .addValue("expiresAt", Timestamp.from(expiresAt));

        int updated = jdbc.update("""
                UPDATE show_seat
                   SET status = 'HELD', hold_id = :holdId,
                       customer_id = :customerId, hold_expires_at = :expiresAt
                 WHERE showtime_id = :showtimeId
                   AND seat_code IN (:seatCodes)
                   AND (status = 'AVAILABLE'
                        OR (status = 'HELD' AND hold_expires_at < :now))
                """, params);

        if (updated != seatCodes.size()) {
            throw new SeatUnavailableException(showtimeId, seatCodes);
        }
        return new SeatHold(holdId, expiresAt);
    }
}

public record SeatHold(UUID holdId, Instant expiresAt) {}

public class SeatUnavailableException extends RuntimeException {
    public SeatUnavailableException(long showtimeId, List<String> seats) {
        super("Seats " + seats + " of showtime " + showtimeId + " are not available");
    }
}

代码如下所示:

  • UPDATE语句对每一行座位获取行锁。两个事务都针对座位F5时必须排队。后到达的事务重新评估WHERE条件,看到status = 'HELD',因此无法更新那一行。
  • 条件status = 'HELD' AND hold_expires_at < :now是延迟过期。已过期的保留座位被视为空闲,即使还没有任务清理它。
  • 如果更新的行数少于请求的座位数,我们抛出异常。@Transactional会全部回滚,从而我们获得全或无的特性。
  • Clock被注入到service中,以便测试可以控制时间。

假设客户A保留座位F5、F6,然后客户B保留座位F6、F7。此时B收到:

HTTP 409 Conflict
{"error":"Seats [F6, F7] of showtime 42 are not available"}

B的座位F7仍然空闲,因为B的事务已被回滚。

注意: 更新前请对seatCodes进行排序。如果A按F5 → F6的顺序锁定,而B按F6 → F5的顺序锁定,两个事务可能死锁。对于PostgreSQL,锁定顺序取决于行扫描顺序。为了确保安全,你可以运行SELECT ... ORDER BY seat_code FOR UPDATE在更新前。

6.2. Redis作为快速屏障层

当流量突增时,5万个座位保留请求同时冲击几百行,数据库必须排列很长的锁队列。我们在前面放置Redis,以提前排除肯定会失败的请求。下面的Lua脚本帮助对多个键的操作变成原子的:

@Component
public class RedisSeatGate {

    private static final RedisScript<Long> TRY_HOLD = new DefaultRedisScript<>("""
            for _, key in ipairs(KEYS) do
              if redis.call('EXISTS', key) == 1 then return 0 end
            end
            for _, key in ipairs(KEYS) do
              redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
            end
            return 1
            """, Long.class);

    private final StringRedisTemplate redis;

    public RedisSeatGate(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryHold(long showtimeId, List<String> seats, UUID holdId, Duration ttl) {
        List<String> keys = seats.stream()
                .map(s -> "seat:{" + showtimeId + "}:" + s)
                .toList();
        Long ok = redis.execute(TRY_HOLD, keys, holdId.toString(),
                String.valueOf(ttl.toMillis()));
        return ok != null && ok == 1L;
    }
}

用法如下:

  • Redis在单个线程上运行Lua脚本。没有请求可以在检查EXISTS和SET之间插入。
  • PX为键设置TTL,所以Redis在过期时自动删除键。这是缓存层的自然过期机制。
  • 部分{showtimeId}是哈希标签。在Redis Cluster中,同一放映场次的所有键都在同一个槽中。如果缺少哈希标签,多键脚本会报告CROSSSLOT错误。

完整流程如下:先在Redis上调用tryHold。如果失败则立即返回409。如果成功则运行第6.1部分的UPDATE语句。如果UPDATE失败则删除刚刚设置的Redis键。

注意: Redis不是事实之源。Redis可能会在故障转移时丢失数据,键可能会与数据库相差几毫秒过期。最终决定总是条件UPDATE。如果你的系统没有突发流量,就完全放弃Redis层,以减少需要运维的组件。

7. 过期预留处理

过期处理的正确性已由第6.1部分中的条件hold_expires_at < :now保证。但是,座位图仍然显示HELD直到有人覆盖它。因此我们需要添加一个清理任务以使界面显示正确:

@Component
public class ExpiredHoldSweeper {

    private final JdbcTemplate jdbc;
    private final Clock clock;

    public ExpiredHoldSweeper(JdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Scheduled(fixedDelay = 15_000)
    public void releaseExpired() {
        int released = jdbc.update("""
                UPDATE show_seat
                   SET status = 'AVAILABLE', hold_id = NULL,
                       customer_id = NULL, hold_expires_at = NULL
                 WHERE status = 'HELD' AND hold_expires_at < ?
                """, Timestamp.from(clock.instant()));
        if (released > 0) {
            // 发布SeatsReleased以更新座位图缓存
        }
    }
}

此任务每15秒运行一次。它可能运行延迟或在多个实例上同时运行也没关系,因为该语句是幂等的。由于延迟过期,即使任务死亡几分钟也不会导致座位重复销售。唯一的后果是界面显示过时数据。记住在配置中启用@EnableScheduling。

支付确认端必须在同一语句中检查期限:

@Transactional
public boolean confirm(UUID holdId, long bookingId) {
    int updated = jdbc.update("""
            UPDATE show_seat
               SET status = 'BOOKED', booking_id = :bookingId, hold_expires_at = NULL
             WHERE hold_id = :holdId AND status = 'HELD'
               AND hold_expires_at >= :now
            """, new MapSqlParameterSource()
                    .addValue("holdId", holdId)
                    .addValue("bookingId", bookingId)
                    .addValue("now", Timestamp.from(clock.instant())));
    return updated > 0;
}

此函数在两种情况下返回false:hold已过期,或座位已被他人保留。两种情况都需要在支付步骤处理。

8. 与支付的一致性

来自支付网关的回调是最容易出错的地方。你需要处理三种情况:

  1. 回调到达两次。 保存payment_id并加UNIQUE约束。第二次看到交易已处理时,则返回200且不做任何其他操作。
  2. 座位过期后回调到达。 confirm返回false。此时系统已收款但无法交付座位。我们标记booking为FAILED并发出自动退款命令。不要试图“夺回”座位,因为其他人可能已经购买了。
  3. 回调永远不到达。 一个定期对账任务查询超过15分钟的PENDING支付状态(从支付网关),然后应用如上两种情况的逻辑。

成功确认后,发送电子邮件、生成二维码票证、增加会员积分等步骤不应在同一事务中运行。应将一行BookingConfirmed写入该事务中的outbox表。一个单独的进程会将事件推送到Kafka,主题为booking-events,键为bookingId。这种方式确保事件仅在数据库已提交时发出,且提交时事件肯定被发送。

9. 防止突发流量

第6.2部分的Redis层只解决了写操作。当开始销售热门电影时,你需要采取以下措施:

  • 虚拟等候室。 每场热门放映同时仅允许N人进入座位选择页面。其他人收到排队令牌,存储在Redis有序集中,分数为到达时间。几百个座位不需要5万人同时选择。
  • 缓存座位图。 座位图从Redis或CDN读取,并通过SeatsHeld/SeatsReleased事件刷新。座位图延迟1–2秒仍然可以接受,因为座位保留步骤会重新精确检查。
  • 按客户进行速率限制。 例如每分钟最多5次座位保留,以阻止座位爬虫机器人。
  • 按showtime_id分区。 所有竞争都在一个放映场次内。你可以按showtime_id对数据库分片,无需分布式事务。

10. 并发测试

最重要的行为需要验证:多个客户同时保留一个座位则仅有一人成功。

@SpringBootTest
class SeatHoldServiceTest {

    @Autowired SeatHoldService service;
    @Autowired JdbcTemplate jdbc;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM show_seat");
        jdbc.update("INSERT INTO show_seat (showtime_id, seat_code) VALUES (42, 'F5'), (42, 'F6')");
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int customers = 20;
        var start = new CountDownLatch(1);
        var successes = new AtomicInteger();

        try (var pool = Executors.newFixedThreadPool(customers)) {
            for (int i = 0; i < customers; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        service.hold(42, List.of("F5", "F6"), customerId);
                        successes.incrementAndGet();
                    } catch (SeatUnavailableException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(successes.get()).isEqualTo(1);
        assertThat(jdbc.queryForObject(
                "SELECT COUNT(DISTINCT hold_id) FROM show_seat WHERE status = 'HELD'",
                Integer.class)).isEqualTo(1);
    }
}

测试说明:

  • CountDownLatch在起点保持20个线程,然后同时释放,以最大化竞争的可能性。
  • try-with-resources在ExecutorService上(Java 21)等待所有任务完成,在运行断言部分之前。
  • 第二个断言检查两个座位都属于同一hold_id,即没有“每人一个座位”的情况。

测试运行在H2上,使用第3部分的schema.sql文件(将UUID类型改为H2的UUID,已经被支持)。你需要声明一个Clock.systemUTC() bean。为了测试过期,用一个mock Clock替换该bean,可以快进时间。

注意: H2不能精确模拟PostgreSQL或MySQL的锁定行为。对于涉及并发的代码,应该运行额外的测试在实际生产数据库上,例如通过Testcontainers。

11. 常见错误

  • 检查然后写入, 即先SELECT然后UPDATE,而没有锁。这是导致座位重复销售的原因#1。
  • 完全依赖清理任务来释放座位。 如果任务死亡则座位卡住。WHERE中的延迟过期消除这种风险。
  • 使用Redis lock作为事实之源。 Lock可能在故障转移时丢失,导致两个人都保留一个座位。
  • 使用SELECT ... FOR UPDATE保持10分钟的锁 等待客户支付。连接池会很快耗尽。数据库锁应该只存在几毫秒内。长的座位保留时间必须用数据表示,具体是hold_expires_at列。
  • 从多个不同的机器的时钟获取时间。 应该统一一个时间源:或者数据库的now(),或者应用的Clock与紧密同步的NTP。

12. 结论

系统的核心是一条条件UPDATE。该语句既原子地锁定座位,又将过期的hold视为空座位,所以永远不会重复销售,也不需要清理任务才正确。其他组件围绕这个核心构建:

  • Redis Lua脚本提前阻止流量。
  • Sweeper任务帮助界面显示正确的状态。
  • 幂等性和对账处理异常的支付回调。
  • 结合Kafka的Outbox可靠发送事件。
  • 虚拟等候室吸收突发流量。

应用时,从SQL核心部分开始,和并发测试。仅在实际流量数据显示你需要它们时才添加Redis和虚拟等候室。

system-designspring-bootredisconcurrencycase-study