并发锁座与限流
一次请求购买多个座位时,原子性必须覆盖整批库存和限购计数。
先校验,再争抢
设想两名用户同时提交包含同一个座位的订单。前端刚刚看到“空闲”,只能说明上一次查询时可售;查询返回与用户提交之间总会有时间差。正确性必须由服务端在提交时重新判定,不能依赖前端禁用按钮或轮询频率。
OrderService.createOrder 依次校验座位数量、场次售卖状态、座位与影厅的归属,随后执行按用户及按场次的令牌桶限流。限流解决请求频率问题,限购解决同一用户在一场电影能持有多少座位的问题,两者不是同一份计数。
Redis 为每场电影维护一个座位 Hash,字段是座位 ID,值 0 / 1 / 2 分别表示空闲、锁定、已售;用户在该场次的限购额度单独保存。
| 检查 | 作用 | 失败后是否应写库存 |
|---|---|---|
| 数量与座位归属 | 拒绝空列表、超量、重复或不属于该影厅的座位 | 否 |
| 场次售卖状态 | 阻止未开售或已结束场次下单 | 否 |
| 用户与场次令牌桶 | 限制个人频率与热门场次入口负载 | 否 |
| 整批座位与限购 | 在共享状态里判断是否仍可购买 | 只有全部通过才写入 |
| 数据库唯一约束 | 防止异常漂移后的重复持久化占用 | 冲突时回滚数据库并尝试释放预占 |
座位归属检查通过影厅 ID 和提交的座位 ID 集合查询实际数量,再与请求数量对比。它同时避免用户把另一个影厅的座位拼进订单,也会拒绝重复 ID 导致的数量不一致。
一批座位的原子检查
不能在循环中“检查一个、锁一个”:第三个座位冲突时,前两个可能已经锁住。Lua 脚本先检查全部座位,再检查用户限购,最后一次性写入所有状态与计数。
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, ''}
脚本在 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) 联合唯一约束,保证同一场次座位只有一条有效占用记录。
@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 已锁、数据库没有订单”的差异,需要后续对账释放。
取消订单会删除对应 order_seat 记录,因此这里表示有效占用,而不是永远保留的座位购买历史。订单主体继续保留取消原因与状态;已经支付的占用不应被普通释放脚本当成待支付预占清理。
应用捕获数据库写入阶段的异常后会尝试释放 Redis,但这也有边界:补偿调用自身可能失败,进程可能在执行补偿之前退出,数据库提交阶段的异常也不一定落在方法内部的捕获范围。对账因此是恢复链路的一部分,而不是只有理论上才会用到的备选。
限流与失败语义
默认用户令牌桶容量为十,补充速度约每分钟十个;场次桶容量五百,补充速度每秒五百。两桶都允许才进入锁座。它们为系统限制入口负载,不提供排队购票或公平性保证。
遇到座位冲突应重新拉取座位图,让用户重新选择。遇到限流应减小重试频率;反复原样重试同一批冲突座位不能提升成功率。演示保留座位冲突与限购语义,不模拟 Redis 限流吞吐或跨进程竞争。
用户桶与场次桶分别执行,不是一个联合原子事务;其中一桶放行而另一桶拒绝时,已消耗的令牌不会在这段实现中回补。它影响短时间内的可用额度,不改变座位唯一性。令牌桶允许容量范围内的突发流量,因此“每分钟约十个”也不表示任意滑动一分钟内严格只能有十次请求。
