电影售票系统设计:座位临时锁定与预留过期
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. 与支付的一致性
来自支付网关的回调是最容易出错的地方。你需要处理三种情况:
- 回调到达两次。 保存
payment_id并加UNIQUE约束。第二次看到交易已处理时,则返回200且不做任何其他操作。 - 座位过期后回调到达。
confirm返回false。此时系统已收款但无法交付座位。我们标记booking为FAILED并发出自动退款命令。不要试图“夺回”座位,因为其他人可能已经购买了。 - 回调永远不到达。 一个定期对账任务查询超过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和虚拟等候室。