10 万人抢 2000 个座位,电影票选座系统怎么防超卖?一文讲透大厂高频场景题
这道题是大厂后端面试的「照妖镜」:看起来只是个库存扣减问题,但真正拉开差距的地方在于——电影票选座不是简单的「数字减一」,而是一个「1 个座位同时只能被 1 个人锁定的空间资源分配问题」。10 万人抢 2000 个座位,本质是 10 万个并发请求要去争抢 2000 个互斥的离散资源,任何一层的疏忽都会导致「同一个座位卖给两个人」的线上事故。
下面按「为什么会超卖 → 六层防护 → 选座特有难题」的逻辑彻底讲透。
🎯 一、先搞清楚:超卖到底是怎么发生的
超卖的根因只有一个:「检查库存」和「扣减库存」这两个动作不是原子的,中间存在时间窗口。
text
线程A:查询座位G12 → 空闲 ✓
线程B:查询座位G12 → 空闲 ✓ ← 两个线程都查到了"空闲"
线程A:锁定座位G12 → 成功
线程B:锁定座位G12 → 成功 ← 超卖!同一个座位卖给了两个人
真正的难点有三层叠:
并发量极大:10 万请求同时涌入,瞬间打满数据库连接池。
资源是离散的:库存减 1 是同一个计数器,而选座是 2000 个独立的座位,每个座位都是一个独立的临界资源。
有锁座时效:用户选了座但不一定付款,需要「锁座 15 分钟」,超时自动释放,这又引入了定时任务的复杂度。
所以防超卖绝不是加一把锁就完事,而是要分层过滤、层层兜底。
🛡️ 二、六层防护体系(从外到内)
第 1 层:前端防重 + 交互限流
这是成本最低、但能拦掉大部分无效流量的第一道关。
按钮置灰:用户点击「确认选座」后立即禁用按钮,防止手抖连点产生重复请求。
本地防抖节流:前端对提交接口做 500ms 节流,减少瞬时重复提交。
选座画布实时同步:通过 WebSocket/SSE 实时把别人已锁的座位置灰,减少用户选到「已占座」的无效请求。
这一层不解决根本问题(绕过前端照样能刷),但能砍掉 30%~50% 的无效流量。
第 2 层:网关层限流防刷
请求到达业务前,先把「非人类流量」和「超量流量」挡在门外。
IP / 用户维度限流:基于令牌桶算法,限制单用户每秒最多 3 次选座请求,超过直接返回 429。
验证码 / 风控:抢票高峰触发滑块验证,人机识别拦截黄牛脚本,大麦、猫眼这类系统风控能拦掉七八成的机器流量。
排队令牌(Token Bucket + 排队):10 万请求不直接打到后端,先进入排队系统发号,只有拿到「通行令牌」的请求才被放行到业务层。
第 3 层:应用层幂等 + 分布式锁
即使流量被过滤到可控范围,同一用户/同一座位的并发请求仍需处理。
幂等控制:
为每次选座请求生成唯一 requestId,用 Redis SETNX 做幂等,同一请求重复提交只处理一次。
用户维度加分布式锁(如 lock:user:{userId}),防止同一用户并发提交不同座位导致的多扣。
分布式锁保护热点座位:
对座位维度加锁 lock:seat:{seatId},抢到锁的线程才能操作该座位。但单纯用 Redis 分布式锁性能很差,因为 2000 个座位就是 2000 把锁的竞争,锁粒度太细反而开销巨大(后面第 4 层会用更好的方式替代)。
第 4 层:缓存层预扣减(Redis + Lua,核心中的核心)
这是高并发防超卖的主战场,思路是「把 2000 个座位库存全量预热到 Redis,用 Lua 脚本保证原子性」。
为什么用 Lua?
因为 Redis 单线程执行 Lua 脚本,脚本内的「查库存 → 扣库存 → 记录占用」是一气呵成的原子操作,不存在任何时间窗口,从根源上杜绝了并发超卖。
核心逻辑(伪代码):
lua
-- KEYS[1]: 座位哈希key ARGV[1]: 座位号 ARGV[2]: 用户ID
-- 1. 判断座位是否已被占用
if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then
return 0 -- 已被占用,返回失败
end
-- 2. 占用座位:记录占用者 + 设置15分钟过期(锁座时效)
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
return 1 -- 锁定成功
配合给整个座位 Map 或每个座位设置 TTL(如 15 分钟),就天然实现了「超时未付款自动释放座位」,无需额外的定时任务扫描。
这一层的价值:
Redis 单机可扛 10 万+ QPS,轻松承接峰值流量。
Lua 的原子性从机制上消灭了超卖可能。
大量无效请求(座位已占)在这一层被快速拒绝,根本不落库。
第 5 层:数据库最终兜底(唯一索引 + 乐观锁)
缓存毕竟可能丢数据(如 Redis 故障),所以数据库必须有一道绝对可靠的兜底防线,宁可少卖也不能超卖。
杀手锏:联合唯一索引
sql
-- 订单表建立 场次ID + 座位ID 的联合唯一索引
ALTER TABLE seat_order ADD UNIQUE KEY uk_session_seat (session_id, seat_id);
即使缓存层出现极端异常放行了重复请求,数据库的唯一索引约束会在写入时直接报错回滚。这是防超卖的「最后一道保险栓」,物理上不可能出现同场次同一座位的两条订单记录。
乐观锁扣减:
sql
UPDATE seat_stock
SET status = 1, user_id = #{userId}, version = version + 1
WHERE seat_id = #{seatId} AND status = 0 AND version = #{version};
-- 影响行数为 0 说明被抢占,扣减失败
通过 status = 0 的条件更新 + version 版本号,保证「只有空闲座位才能被占用」,并用影响行数判断是否成功。
悲观锁(不推荐但可作对比):SELECT ... FOR UPDATE 行锁虽然能防超卖,但 FOR UPDATE 会锁住整行,高并发下大量线程阻塞,极易造成数据库连接池耗尽、系统雪崩,所以核心抢购场景几乎不会用。
第 6 层:MQ 异步削峰
把「同步扣减」改为「异步处理」,是应对瞬时洪峰的经典手段。
用户选座请求通过 Redis Lua 快速锁定成功后,立即返回「锁定中」,把「生成订单、扣减 DB 库存」的动作丢进消息队列(RocketMQ / Kafka)。
后端消费者以稳定速率消费消息,写入数据库落库。
这样 10 万瞬时请求被 MQ 平滑成后端能承受的匀速流量,保护数据库不被打垮。
代价:从「强一致」变成「最终一致」,用户可能短暂看到「锁定中」状态。电影票场景完全可以接受,因为用户本来就要等待付款。
🪑 三、电影选座特有的两个坑
上面讲的是通用防超卖,但电影选座还有两个「特色难题」,也是面试官最爱追问的点:
坑 1:相邻座位的「连坐」问题
用户选的不只是一个座位,可能是「G12、G13、G14 三个连座」。这三个座位必须要么全锁定成功,要么全部失败,不能出现「锁了 G12 和 G14,中间的 G13 被别人抢了」的尴尬情况。
解法:把「多座位」作为一个原子操作。在 Lua 脚本里先遍历检查所有座位是否全空闲,全部满足才一次性批量 HSET;或者用 Redis 的 MULTI/EXEC 事务包住。数据库层则用事务 BEGIN...COMMIT 保证一起成功或一起回滚。
坑 2:座位图的「一致性视图」
10 万人在看同一张座位图。如果一个人锁定了 G12,但别人页面上 G12 还显示为空闲,用户点下去必然失败,体验极差。
解法:通过 WebSocket / 长轮询推送座位状态变更,让所有在线用户实时看到最新座位图;同时在缓存里维护一份「场次座位位图(Bitmap)」,用 Redis Bitmap 存 2000 个座位的占用状态(2000 bit 才 250 字节,极其省内存),查询座位图毫秒级返回。
📊 四、完整架构串联(一次选座请求的旅程)
text
用户点击选座
↓
[1] 前端防重(按钮置灰 + 节流)
↓
[2] 网关限流(IP/用户限流 + 风控验证码)
↓
[3] 应用层幂等(requestId 去重)
↓
[4] Redis Lua 原子预扣(查占用→锁定→设TTL)★核心拦截
↓ 成功
[5] 投递 MQ 异步落库
↓
[6] DB 唯一索引 + 乐观锁兜底 ★绝对防线
↓
返回"锁定成功,请在15分钟内付款"
↓
超时未付款 → Redis TTL 到期自动释放 → 座位重新可售
✅ 五、面试答题的加分表达
如果面试被问这道题,按这个结构答基本能拿高分:
先定义问题本质:不是简单库存减一,而是离散互斥资源的并发分配,核心是「查与扣的原子性」。
分层作答:前端 → 网关 → 应用 → 缓存 → 数据库 → MQ,逐层说清各自职责,体现体系化思维。
强调「缓存预扣 + Lua 原子性」是主战场,数据库唯一索引是最后兜底——这句话是区分「知道」和「懂了」的关键。
主动提选座特有难点:连座原子性、座位图实时一致性,展现你真正思考过业务场景。
点出权衡:乐观锁 vs 悲观锁的性能差异、强一致 vs 最终一致的取舍,体现工程 trade-off 意识。
一句话总结:防超卖的核心不是「加锁」,而是用「Redis + Lua 原子预扣」扛住并发洪峰,用「数据库唯一索引」守住绝对底线,用「MQ 异步 + TTL 超时释放」提升吞吐和体验——三层配合,才能既扛住 10 万人抢座,又保证一个座位绝不卖给两个人。
需要我为你补充一份Redis Lua 防超卖脚本的完整可运行代码(含连座检测)吗?可以直接用于项目落地。
0 评论