并发与一致性

Article / 并发与一致性

座位是怎么锁住的

从 Lua 原子预占到数据库唯一索引,拆解高并发选座的正确性来源、失败回滚与并发验证。

SeatFlow RedisLua并发控制MySQL压测

一场热门电影开票时,同一个座位会在同一秒收到几十上百个请求。选座系统最不能犯的错误是超卖——两个人买到同一把椅子,到影院只能请一个人改签。SeatFlow 把这个问题的答案拆成三层:Redis 里的 Lua 原子预占负责「同一瞬间谁能买」,数据库的唯一索引负责「万一判断错了也不会重复记名」,对账任务负责「判断造成的漂移最终会被收敛」。这篇讲前两层,以及一个更实际的问题:怎么证明它真的没有超卖。

为什么把座位状态放进 Redis

最直接的方案是用数据库行锁:开事务,SELECT ... FOR UPDATE 锁住座位行,检查状态,写订单,提交。语义正确,但在热点座位上会把吞吐交给锁竞争:同一把椅子上的等待队列里,除了赢家全是白等;而座位占用的生命周期长达十五分钟,也完全不需要数据库事务来替它守这么久。

SeatFlow 把「座位当前可不可售」这份判断权交给 Redis,用一个 Hash 存每场次每个座位的状态:

  • seatflow:show:{showId}:seats:字段是座位 ID,取值 0 可售、1 锁定(有订单待支付)、2 已售;
  • seatflow:user:{userId}:show:{showId}:seatcount:同一用户在同一场次的有效座位数,用来执行限购。

座位模板本身在 MySQL 里(影厅的物理布局),场次开售时从模板初始化 Redis,键的 TTL 设为场次结束加一天。之后所有「能不能买」的判断都发生在 Redis;MySQL 只记录「谁买了」,不参与每次校验。这个分工让选座路径完全避开了行锁,代价是引入了「Redis 与 MySQL 可能不一致」这个新问题——那是对账要解决的事(见《当 Redis 和 MySQL 各执一词》)。

Hash 不是唯一选择。用 Bitmap 存状态更省内存——十万个座位只要大约 12KB,位运算也能一次性完成整场判断;但 Hash 的字段语义直观、按座位读写简单、排查问题时肉眼可读。当前选择 Hash,Bitmap 留作容量优化项,这是一笔明确的取舍。

检查、写入、计数放进同一个脚本

「检查状态 → 写入锁定」是典型的 check-then-act。两个请求各自读到「可售」,然后各自写入「锁定」,就产生了超卖。要消除这个窗口,检查和写入必须在同一个原子步骤里完成。Redis 的单线程执行模型恰好提供了这种原子性:一段 Lua 脚本在执行期间不会被其他命令打断。

lock_seats.lua 做四件事,缺一不可:

-- KEYS[1]=seats hash KEYS[2]=count key
-- ARGV[1]=n ARGV[2]=maxPerUser ARGV[3]=ttlSeconds ARGV[4..]=seatIds
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, ''}

几个设计点值得展开。第一,先全量检查、再统一写入:脚本把所有不可售的座位收集进 failed 列表后一次性返回,而不是遇到第一个冲突就退出。用户提交三个座位,其中两个被占,响应里直接告诉他哪两个被占、哪个还能选——这比「失败了,请重试」有用得多。第二,限购计数也在这段脚本里:限购是「用户 × 场次」维度上的另一个并发不变量,如果拆成独立命令执行,两个并发的下单请求就可能同时通过限购检查;把它并入同一个脚本,两个不变量共享同一次原子执行。第三,返回值是一个三态协议:{1, ''} 成功、{0, '失败座位 CSV'} 座位冲突、{-1, ''} 超限购。Java 侧按第一个元素分派,不把冲突和超限混成一个错误。

计数键的 TTL 有一个小细节:它应该与座位状态同生命周期,但计数是脚本第一次执行时才创建的,脚本里读不到剩余 TTL 就直接取座位 Hash 的剩余 TTL,读不到再退到两天。这避免了「座位状态因场次结束过期、计数键却永久残留」的泄漏。

Java 侧的调用保持在很薄的一层:校验座位确实属于该场次的影厅、把参数拼给脚本、解析三态。一个容易忽略的分支是脚本返回 null 的情况——它不是冲突,而是系统异常,直接抛 SYSTEM_ERROR,绝不能当成「座位被占」误导用户重选。

数据库唯一索引:最后一道防线

Redis 是外部系统:可能被误清、可能在容器重建后丢数据(当前 compose 未挂数据卷)、应用回滚路径也可能有 bug。应用层再小心,也需要一个「就算前面全错了也不会重复售出」的结构保证。这个保证落在 order_seat 表上:

UNIQUE KEY uk_order_seat_show_seat (show_id, seat_id)

order_seat 只保留有效占用:座位被锁定或已支付时存在一行,取消或超时释放时物理删除。因此这个唯一索引约束的是「此时此刻同一场次同一座位只能有一个有效订单」,而不是历史记录——座位被释放后可以再次售卖,不会被旧行挡住。历史追踪交给订单表和日志。

于是完整的下单路径是:Lua 预占成功 → 事务内写入 order_infoorder_seat → 提交。任何一层在数据库层面重复占用,都会撞上这个唯一索引。冲突发生时,处理顺序很关键:

sequenceDiagram
    participant S as OrderService
    participant R as Redis(lock_seats.lua)
    participant D as MySQL
    S->>R: 检查+预占+计数(原子执行)
    alt 预占成功
        S->>D: INSERT order_info + order_seat
        alt 唯一索引冲突
            D-->>S: DuplicateKeyException
            S->>R: release_seats 回滚锁定
            S->>S: 抛出 3002 座位被抢
        else 提交成功
            D-->>S: OK
        end
    else 冲突或超限
        R-->>S: {0, 失败座位} 或 {-1}
    end
try {
    OrderInfo order = insertPending(userId, show, seatIds);
    insertOrderSeats(order.getId(), showId, seatIds);
    registerTimeoutMessage(order.getOrderNo());
    // ...
} 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 里的「锁定」不会自己恢复。顺序反了,座位就会带着锁进入十五分钟的 TTL,在订单不存在的情况下无法售出。

第二个 catch 是代码审查时补上的。最初的实现只捕获 DuplicateKeyException,但回滚路径不止这一种:数据库连接抖动、死锁、其他完整性错误同样会让事务回滚,此时座位已经被 Lua 锁住,却没有释放逻辑,也没有订单让超时消息来兜底——座位被锁死到 TTL 结束。修正很直接,为所有运行时异常兜底释放;测试也补了对应的用例:注入一个让订单插入抛异常的故障,断言 Redis 座位状态回到 0。修复前这条断言失败,暴露的正是「异常种类没穷举」这个想当然。

还有一个提交边界问题:超时消息通过 afterCommit 回调注册,数据库提交成功之前不会发出。如果事务最终回滚,消息不会发出,也就不会有「消息先到、订单还没提交」的竞态。

状态怎么流转

三个 Lua 脚本对应三条状态边,除此之外没有任何路径能改座位状态:

stateDiagram-v2
    s0 : 可售(0)
    s1 : 锁定(1)
    s2 : 已售(2)
    [*] --> s0
    s0 --> s1 : lock_seats 原子预占
    s1 --> s2 : mark_sold(支付成功)
    s1 --> s0 : release(超时/取消/失败回滚)
    s2 --> [*] : 终态(退款为 P2)

release_seats.lua 只处理 1 → 0,不会把 2 退回可售;mark_sold.lua 只处理 1 → 2,且天然幂等——重复执行不会把已售改成其他状态。这两条约束让支付回调、超时释放、用户取消三条并发链路各自可以安全重试(见《回调为什么必须幂等》与《订单超时之后》)。

一个已知取舍记在这里:释放脚本的限购计数是无条件递减的:即使座位状态不是 1(比如已售),计数也会减。重复释放或释放已售座位会让计数低估在途占用,进而放宽限购。当前靠对账校准兜底。

怎么证明它没有超卖

并发正确性不能靠「代码看起来对」来证明,要给出可复现的证据。SeatFlow 的验证分三层,逐层放大:

第一层,Redis 层的原子性。 直接对 SeatStateService 发起并发调用:两百个调用者抢同一个座位,断言恰好一个成功、其余全部拿到冲突。这一层测的是脚本本身的原子性。

第二层,全链路并发。 走完整的下单路径(限流 → 锁座 → 事务写订单),用一千个调用者做两组断言:

@Test
void thousandThreadsOneSeatExactlyOneOrder() throws Exception {
    // ... 1000 个并发调用 orderService.createOrder(uid, showId, List.of(seatId))
    assertEquals(1, outcome.successes());                 // 恰好一单
    assertEquals(CALLERS - 1, outcome.bizFailures());     // 其余全部业务失败
    assertEquals(before + 1, orderService.countOrders(showId));
    assertEquals("1", redis.opsForHash().get(RedisKeys.showSeats(showId), String.valueOf(seatId)));
}

另一个用例是一个 10×10 的影厅:一千个调用者各自抢一百个座位中的一个,恰好一百单成功,order_seat 中恰好一百个不同座位,重复座位查询返回零行。注意断言的写法——精确计数,而不是「不超过」:若把失败数写成「小于等于」,任何悄悄多卖或少卖的回归都能蒙混过关。

这组测试有两个容易翻车的配置细节。其一,必须用 @TestPropertySource 把限流容量抬到十万:默认配置是「每用户每分钟 10 次、每场次每秒 500 次」,在一千个并发调用下,测到的会是限流器而不是锁座系统。其二,连接池要放大到 32,否则一千个并发请求会排队等数据库连接,测试超时的时间都花在池上。

第三层,压测与对账。 JMeter 混合场景(450 浏览线程 + 50 下单线程,共 5505 个请求,351.7 QPS,下单接口 P99 43ms)跑完后,用一条 SQL 检验库存真相:

SELECT COUNT(*) FROM (
  SELECT show_id, seat_id FROM order_seat GROUP BY show_id, seat_id HAVING COUNT(*) > 1
) t;  -- 期望 0

这条查询依赖唯一索引本身——如果唯一索引失效了,它也查不出问题,所以它验证的是「在数据库允许的范围内,没有任何重复占用存在」。三层证据合在一起:脚本原子、链路精确、数据干净。

代价与边界

热点座位上失败是常态,系统把它变成带失败座位列表的业务错误(3002),前端刷新后重选;Redis 故障时锁座直接报错——宁可暂时不可用,不可超卖。对账与在途订单之间仍有极小概率的竞态窗口,它会把「刚下单成功的座位」短暂显示为可售,数据库唯一索引保证这不会变成真正的重复售出;完整分析与兜底链路放在对账一篇里。

座位锁的复杂度不在锁本身,而在于它连接的三方——Redis、MySQL、消息队列——各自有各自的失败方式。正确性来自机制叠加:原子脚本挡住并发,唯一索引兜住错误,对账收敛漂移。 而锁住之后的十五分钟里,还藏着另一个问题:超时之后,谁把座位还回来。