SeatFlow 选座页:座位按可选、锁定中、已售、已选四种状态着色,底部是已选座位和合计金额

影院选座与票务

SeatFlow

面向影院的在线选座票务系统,提供场次浏览、实时选座、锁座下单与模拟支付。

  • Java 21
  • Spring Boot
  • Redis
  • RocketMQ
02 / 05 · DOCUMENTATION

并发锁座与限流

一次请求购买多个座位时,原子性必须覆盖整批库存和限购计数。

先校验,再争抢

设想两名用户同时提交包含同一个座位的订单。前端刚刚看到“空闲”,只能说明上一次查询时可售;查询返回与用户提交之间总会有时间差。正确性必须由服务端在提交时重新判定,不能依赖前端禁用按钮或轮询频率。

OrderService.createOrder 依次校验座位数量、场次售卖状态、座位与影厅的归属,随后执行按用户及按场次的令牌桶限流。限流解决请求频率问题,限购解决同一用户在一场电影能持有多少座位的问题,两者不是同一份计数。

Redis 为每场电影维护一个座位 Hash,字段是座位 ID,值 0 / 1 / 2 分别表示空闲、锁定、已售;用户在该场次的限购额度单独保存。

检查作用失败后是否应写库存
数量与座位归属拒绝空列表、超量、重复或不属于该影厅的座位否
场次售卖状态阻止未开售或已结束场次下单否
用户与场次令牌桶限制个人频率与热门场次入口负载否
整批座位与限购在共享状态里判断是否仍可购买只有全部通过才写入
数据库唯一约束防止异常漂移后的重复持久化占用冲突时回滚数据库并尝试释放预占

座位归属检查通过影厅 ID 和提交的座位 ID 集合查询实际数量,再与请求数量对比。它同时避免用户把另一个影厅的座位拼进订单,也会拒绝重复 ID 导致的数量不一致。

一批座位的原子检查

不能在循环中“检查一个、锁一个”:第三个座位冲突时,前两个可能已经锁住。Lua 脚本先检查全部座位,再检查用户限购,最后一次性写入所有状态与计数。

seatflow/seatflow-service/src/main/resources/lua/lock_seats.luaL3-16 @472bd43
local seatsKey, countKey = KEYS[1], KEYS[2]
local n, maxPerUser, ttl = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local failed = {}
for i = 4, 3 + n do
  local v = redis.call('HGET', seatsKey, ARGV[i])
  if v == false or v ~= '0' then failed[#failed + 1] = ARGV[i] end
end
if #failed > 0 then return {0, table.concat(failed, ',')} end
local cnt = tonumber(redis.call('GET', countKey) or '0')
if cnt + n > maxPerUser then return {-1, ''} end
for i = 4, 3 + n do redis.call('HSET', seatsKey, ARGV[i], '1') end
redis.call('INCRBY', countKey, n)
if redis.call('TTL', countKey) < 0 then redis.call('EXPIRE', countKey, ttl) end
return {1, ''}
返回 0 表示座位冲突,-1 表示超过限购,1 表示整批预占成功。

脚本在 Redis 单次执行中完成检查和写入,其他请求不会插入这段过程。多座位成功不需要应用端逐个确认,减少往返并避免部分成功。

例如用户已持有该场次两个座位,再请求三个,即使三个座位都空闲,也会因 2 + 3 超过默认四张限购而整体失败;请求三个座位中有一个已锁定,则不会先消耗另外两个的库存或限购额度。

这里的“锁座”是业务状态 0 → 1,不是为每个座位创建一个长时间持有的分布式锁。Lua 保证单次脚本内的检查与更新原子;用户等待支付的十五分钟由订单状态、截止时间和取消链路管理。限购计数的 TTL 也不能代替每笔订单的到期处理。

下单时序与事务边界

sequenceDiagram
  participant U as 用户
  participant S as OrderService
  participant R as Redis
  participant D as MySQL
  participant M as RocketMQ
  U->>S: 提交场次与整批座位
  S->>S: 校验、限流
  S->>R: Lua 预占及限购计数
  R-->>S: 整批成功
  S->>D: 写订单与有效座位占用
  alt 写入失败
    S->>R: 尝试释放预占
    S-->>U: 返回业务失败
  else 数据库事务提交
    S->>M: 提交后发送超时消息
    S-->>U: 订单号、金额与截止时间
  end

应用在事务提交回调中发送超时消息,避免消费者查询尚未提交的订单。这个提交回调不是消息事务或 outbox:数据库提交与消息发送之间仍存在失败窗口,由后续超时对账兜底。

数据库的最后一道约束

Redis 锁座成功后,还要在 MySQL 本地事务里写入 order_info 与 order_seat。order_seat 上的 (show_id, seat_id) 联合唯一约束,保证同一场次座位只有一条有效占用记录。

seatflow/seatflow-service/src/main/java/com/seatflow/service/order/OrderService.javaL96-122 @472bd43
@Transactional
public CreateOrderResult createOrder(Long userId, Long showId, List<Long> seatIds) {
    validateSeatCount(seatIds);
    ShowInfo show = requireOnSaleShow(showId);
    validateSeatsInHall(show.getHallId(), seatIds);
    rateLimitOrThrow(userId, showId);
    LockResult lockResult = seatStateService.lockSeats(showId, userId, seatIds, maxPerUser);
    if (!lockResult.ok()) {
        if (lockResult.limitExceeded()) {
            throw new BizException(ErrorCode.SEAT_LIMIT_EXCEEDED);
        }
        throw new BizException(ErrorCode.SEAT_TAKEN, "座位已被占用: " + formatSeatIds(lockResult.failedSeatIds()));
    }
    try {
        OrderInfo order = insertPending(userId, show, seatIds);
        insertOrderSeats(order.getId(), showId, seatIds);
        registerTimeoutMessage(order.getOrderNo());
        meterRegistry.counter("seatflow.order.created").increment();
        return new CreateOrderResult(order.getOrderNo(), order.getTotalAmount(), order.getExpireTime());
    } catch (DuplicateKeyException e) {
        seatStateService.releaseSeats(showId, userId, seatIds);
        throw new BizException(ErrorCode.SEAT_TAKEN, "座位已被抢,请重新选择");
    } catch (RuntimeException e) {
        seatStateService.releaseSeats(showId, userId, seatIds);
        throw e;
    }
}
方法内部捕获唯一冲突和其他运行时异常后会尝试释放 Redis 预占;订单和座位记录受同一本地事务约束。

唯一约束并不能让 Redis 与数据库组成原子事务。它限制的是最终持久化的重复占用;如果锁座后进程崩溃,仍会留下“Redis 已锁、数据库没有订单”的差异,需要后续对账释放。

取消订单会删除对应 order_seat 记录,因此这里表示有效占用,而不是永远保留的座位购买历史。订单主体继续保留取消原因与状态;已经支付的占用不应被普通释放脚本当成待支付预占清理。

应用捕获数据库写入阶段的异常后会尝试释放 Redis,但这也有边界:补偿调用自身可能失败,进程可能在执行补偿之前退出,数据库提交阶段的异常也不一定落在方法内部的捕获范围。对账因此是恢复链路的一部分,而不是只有理论上才会用到的备选。

限流与失败语义

默认用户令牌桶容量为十,补充速度约每分钟十个;场次桶容量五百,补充速度每秒五百。两桶都允许才进入锁座。它们为系统限制入口负载,不提供排队购票或公平性保证。

遇到座位冲突应重新拉取座位图,让用户重新选择。遇到限流应减小重试频率;反复原样重试同一批冲突座位不能提升成功率。演示保留座位冲突与限购语义,不模拟 Redis 限流吞吐或跨进程竞争。

用户桶与场次桶分别执行,不是一个联合原子事务;其中一桶放行而另一桶拒绝时,已消耗的令牌不会在这段实现中回补。它影响短时间内的可用额度,不改变座位唯一性。令牌桶允许容量范围内的突发流量,因此“每分钟约十个”也不表示任意滑动一分钟内严格只能有十次请求。