微信三公机器人 10 万人抢 2000 个座位,电影票选座系统怎么防超卖?一文讲透大厂高频场景题

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 评论

发表评论