演唱会售票系统设计:百万级用户虚拟队列
1. 概述
一场大型演唱会在 10:00 时开放售票。短短几秒内,数百万人同时点击“购票”按钮,争抢仅有的 5 万个座位。如果把所有请求直接发到数据库,系统就会崩溃。如果拦截方式不当,两个人可能购到同一个座位。
本文逐步讲解系统设计:需求、数据模型、API 和处理流程。重点是三个难点:流量突发、座位竞争和支付时的数据一致性。读完后,你将学会用 Redis 构建虚拟队列、用数据库原子操作防止座位超售、以及实现幂等的订单确认流程。
2. 需求分析
2.1. 功能需求
- 用户进入事件页面时被加入虚拟队列,在开放售票前排队。
- 系统按先来先服务的原则分批次放用户进入选座区。
- 用户最多选 4 个座位。座位被保留 (hold) 5 分钟供用户完成支付。
- 支付成功后座位变为已售出,并生成电子票。
- 保留期限过期且未支付,座位返回库存。
2.2. 规模和约束
| 指标 | 估计值 |
|---|---|
| 开放售票时并发用户 | 2,000,000 |
| 座位总数 | 50,000 |
| 进入事件页面的峰值 RPS | ~500,000 |
| 同时在选座区的用户数 | 5,000 |
最关键的约束是永远不能超售同一个座位。这里的一致性必须是强一致性。相比之下,队列中的位置只需近似准确:“前面还有大约 1.2 万人”即可接受。
从上面两个数字可以得出核心见解:仅有 2.5% 的用户能买到票。因此系统必须在尽可能廉价的层面拒绝或延迟大部分请求,避免它们到达数据库。
3. 架构和处理流程
Client ──> CDN (静态页面、等待页面)
│
├─> Queue Service ──> Redis (ZSET 队列、访问令牌)
│ ▲
│ └── Admitter (定时任务,每秒放行 N 人)
│
└─> Booking Service (需要访问令牌)
├─> PostgreSQL (座位、订单、outbox)
└─> Kafka (ticket-issued) ──> 票务/邮件服务
主要流程如下:
- 客户端调用
POST /events/{id}/queue,得到queueId。 - 客户端每隔几秒轮询
GET /events/{id}/queue/{queueId}查看位置。轮到时,客户端获得accessToken。 - 客户端使用令牌调用
POST /events/{id}/holds,传递座位列表。 - 客户端进行支付。支付服务商调用 webhook,系统调用
POST /orders/{id}/confirm并传递幂等密钥。 - Booking Service 在单个事务中写入订单和 outbox 事件。事件随后被推送到 Kafka 生成电子票。
等待页面通过 CDN 作为静态文件提供。只有 join 和 poll API 会进入后端,且两者都只访问 Redis。
4. 数据模型
CREATE TABLE seat (
event_id BIGINT NOT NULL,
seat_id VARCHAR(16) NOT NULL,
section VARCHAR(16) NOT NULL,
price NUMERIC(12,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | SOLD
held_by BIGINT,
hold_expires_at TIMESTAMP,
PRIMARY KEY (event_id, seat_id)
);
CREATE TABLE ticket_order (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING | PAID | EXPIRED
idempotency_key VARCHAR(64) NOT NULL UNIQUE
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
座位的状态直接存储在 seat 表中。这样保留座位只需一条有条件的 UPDATE 语句。idempotency_key 上的 UNIQUE 约束是防止订单重复写入的最后一道防线。
5. 用 Redis 实现虚拟队列
添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. 加入队列
队列是一个 Redis 有序集合。分数是递增的序号,从 INCR 获取:
@Service
public class QueueService {
private final StringRedisTemplate redis;
public QueueService(StringRedisTemplate redis) {
this.redis = redis;
}
public long join(long eventId, long customerId) {
String queueKey = "queue:" + eventId;
String member = String.valueOf(customerId);
Long existing = redis.opsForZSet().rank(queueKey, member);
if (existing != null) {
return existing;
}
Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
redis.opsForZSet().addIfAbsent(queueKey, member, seq);
return redis.opsForZSet().rank(queueKey, member);
}
public Long position(long eventId, long customerId) {
return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
}
}
说明:
increment返回全局序号。不使用时间戳作为分数,因为许多请求可能在同一毫秒内到达。addIfAbsent对应ZADD NX。用户反复按 F5 刷新也不会被移到队列末尾。rank(ZRANK)时间复杂度为 O(log N)。对于 200 万个元素,该命令仍只需几微秒。
三个人依次进入队列,结果如下:
join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0 (再次进入,位置不变)
注意: 如果需要防止机器人,应在加入队列之前进行用户验证(登录、验证码等)。一旦进入 ZSET,机器人和真实用户一样占据一个位置。
5.2. Admitter:分批放行用户
一个定时任务每秒运行。它检查选座区还有多少空位,然后从队列前面取出相应数量的用户:
@Component
public class Admitter {
private static final int MAX_ACTIVE = 5_000;
private static final Duration TOKEN_TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
public Admitter(StringRedisTemplate redis) {
this.redis = redis;
}
@Scheduled(fixedRate = 1000)
public void admit() {
long eventId = 1L;
String activeKey = "active:" + eventId;
long now = System.currentTimeMillis();
redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
long slots = MAX_ACTIVE - active;
if (slots <= 0) return;
var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
if (batch == null) return;
for (var tuple : batch) {
String customerId = tuple.getValue();
String token = UUID.randomUUID().toString();
redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
}
}
}
说明:
active:{eventId}是一个 ZSET,分数是过期时间戳。removeRangeByScore清理已过期的会话,然后zCard返回当前选座区的用户数。popMin(ZPOPMIN)取出并删除队列前面的 N 个用户。- 轮询时,客户端读取
token-of:...。如果键存在,说明已轮到,客户端获得令牌。
需要确保这个定时任务仅在一个实例上运行,可使用 ShedLock 或 leader election。如果两个实例都运行,选座区的活跃用户可能超过 MAX_ACTIVE。
你不需要运行多个 Admitter 来处理 200 万人。放行用户的速度由 Booking Service 的容量决定,而非 Redis。
5.3. 拦截没有令牌的请求
@Component
public class AccessTokenFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
public AccessTokenFilter(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().contains("/holds");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String token = req.getHeader("X-Queue-Token");
String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
if (customerId == null) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Not admitted");
return;
}
req.setAttribute("customerId", Long.valueOf(customerId));
chain.doFilter(req, res);
}
}
过滤器使用令牌作为查询键,不信任客户端发送的 customerId。用户无法通过直接调用 /holds API 来插队。
6. 座位保留:处理竞争
多个用户可能选择同一个座位(如 A-12)。最简单和正确的方式是让数据库用有条件的 UPDATE 语句自己决定:
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(5);
private final JdbcTemplate jdbc;
public SeatHoldService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void hold(long eventId, long customerId, List<String> seatIds) {
if (seatIds.size() > 4) throw new IllegalArgumentException("Max 4 seats");
Timestamp now = Timestamp.from(Instant.now());
Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));
for (String seatId : seatIds.stream().sorted().toList()) {
int updated = jdbc.update("""
UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
WHERE event_id = ? AND seat_id = ?
AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
""", customerId, expires, eventId, seatId, now);
if (updated == 0) {
throw new SeatUnavailableException(seatId);
}
}
}
}
class SeatUnavailableException extends RuntimeException {
SeatUnavailableException(String seatId) { super("Seat taken: " + seatId); }
}
说明:
WHERE子句在单个原子操作中既检查又更新。数据库锁定该行。后续请求会等待,然后重新评估条件,发现status = 'HELD',返回updated = 0。hold_expires_at < ?条件允许在没有清理任务的情况下回收已过期的保留。- 如果一个座位失败,异常会导致整个事务回滚。用户永远不会被锁定“半个组”的座位。
- 在更新前对
seatIds排序确保所有事务按相同顺序锁定座位,防止交叉选择(如 {A-1, A-2} 和 {A-2, A-1})导致的死锁。
注意: 不要采用“先 SELECT 检查状态再 UPDATE”而不加锁的方式。这正是导致座位超售的经典竞争条件。
6.1. 测试防超售行为
让 50 个线程争夺同一个座位。预期结果是仅一个线程成功。
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired SeatHoldService service;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
jdbc.update("DELETE FROM seat");
jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long customer = 1; customer <= 50; customer++) {
long id = customer;
executor.submit(() -> {
try {
service.hold(1L, id, List.of("A-12"));
success.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
});
}
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
}
}
seat 表从 src/test/resources/schema.sql 创建。使用上面第 4 节的完整 CREATE TABLE seat 语句,必要时将 BIGSERIAL 改为 BIGINT AUTO_INCREMENT。@Transactional(NOT_SUPPORTED) 禁用围绕测试的事务,使数据被提交且其他线程可见。
7. 支付和数据一致性
支付服务商的 webhook 可能被多次调用。因此订单确认步骤必须是幂等的:
@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
int inserted = jdbc.update("""
INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
""", orderId, customerId, eventId, idempotencyKey);
if (inserted == 0) return; // 之前已处理
int sold = jdbc.update("""
UPDATE seat SET status = 'SOLD'
WHERE event_id = ? AND held_by = ? AND status = 'HELD'
""", eventId, customerId);
if (sold == 0) throw new IllegalStateException("Hold expired, refund required");
jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
"{\"orderId\":" + orderId + "}");
}
这段代码处理三件事:
ON CONFLICT DO NOTHING(PostgreSQL 语法)保证 webhook 重复调用不会创建两个订单。- 座位只在仍由该客户保留时才变为
SOLD。如果保留已过期且座位属于其他人,事务回滚。此时订单需进入退款流程。 - 事务性 outbox:事件与订单在同一事务中写入。一个单独的进程(或 Debezium)读取
outbox表并推送到 Kafka。这样不会出现订单已保存但票未生成的情况,反之亦然。
注意: 将保留 TTL 设置为略长于支付页面超时时间,例如保留 5 分钟,支付页面 4 分钟。这个缓冲区减少了客户已支付但保留刚过期的情况。
8. 常见难点和错误
- 初期的流量突发:等待页面必须是 CDN 上的静态文件。轮询 API 应在响应头中提示轮询周期,例如 5–15 秒(取决于队列位置)。如果客户端每秒轮询,200 万人会对 Redis 产生 200 万 RPS。
- Redis 单点故障:使用 Redis Cluster 或 Sentinel。如果只有一个大事件,整个 ZSET 在一个键上,会在一个分片上。单个 Redis 节点仍能处理数十万 ops/s,但需要提前测试。
- “还有座位”显示不准确:UI 上的座位剩余数可从缓存读取,容许几秒偏差。只有第 6 节的 UPDATE 语句才是真实数据源。
- 座位垃圾数据:用户保留座位后关闭标签页。UPDATE 中的过期条件已处理这种情况。只需添加一个定期任务将过期的保留恢复为
AVAILABLE状态,以便 UI 正确显示。 - 何时不需要虚拟队列:对于只有几百个座位和流量适中的事件,速率限制加上有条件 UPDATE 已足够。虚拟队列增加运维复杂度,使用户体验变慢。仅当购票意愿远超座位供应时才使用。
9. 结论
高流量售票系统之所以能处理好,是因为分离了两个关切点。虚拟队列用 Redis 处理流量突发并维持公平性。数据库仅处理少数已放行的用户,通过有条件 UPDATE 保证不会超售。
你已学到了如何用 ZADD NX 和 ZPOPMIN 实现队列和分批放行、如何原子地保留座位和避免死锁、以及如何结合事务性 outbox 实现幂等订单确认和 Kafka 事件推送。基于这个框架,你还可以扩展防机器人、VIP 用户优先队列或并发处理多个事件。