演唱会售票系统设计:百万级用户虚拟队列

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) ──> 票务/邮件服务

主要流程如下:

  1. 客户端调用 POST /events/{id}/queue,得到 queueId。
  2. 客户端每隔几秒轮询 GET /events/{id}/queue/{queueId} 查看位置。轮到时,客户端获得 accessToken。
  3. 客户端使用令牌调用 POST /events/{id}/holds,传递座位列表。
  4. 客户端进行支付。支付服务商调用 webhook,系统调用 POST /orders/{id}/confirm 并传递幂等密钥。
  5. 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 用户优先队列或并发处理多个事件。

system-designredisspring-bootconcurrencykafka