首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >演出票务小程序:高并发抢票、选座与防超卖的工程方案

演出票务小程序:高并发抢票、选座与防超卖的工程方案

原创
作者头像
用户3066938
发布于 2026-10-23 15:17:18
发布于 2026-10-23 15:17:18
140
举报

演出票务是小程序里对并发最敏感的类型之一。一场本地热门演出,开票瞬间的请求可能达到平日的百倍,而票的总数是确定的、座位是唯一的。本文围绕抢票链路的三个核心问题展开:库存怎么扣才不超卖、座位怎么锁才不冲突、黄牛怎么挡。示例基于 MySQL 8 + Redis,部署形态可平移到云托管或自建服务。

一、库存模型:场次库存与会话库存分开

票务和普通电商最大的区别是库存天然按场次隔离,且有"可售/锁定/已售/退回"多种状态:

代码语言:txt
复制
CREATE TABLE show_session (
 id            BIGINT PRIMARY KEY AUTO_INCREMENT,
 show_id       BIGINT NOT NULL,
 session_date  DATETIME NOT NULL,
 total_tickets INT NOT NULL,
 status        TINYINT NOT NULL DEFAULT 1 COMMENT '1预售 2开售 3售罄 4已结束',
 UNIQUE KEY uk_show_date (show_id, session_date)
);

CREATE TABLE ticket_order (
 id           BIGINT PRIMARY KEY AUTO_INCREMENT,
 order_no     VARCHAR(32) NOT NULL,
 session_id   BIGINT NOT NULL,
 seat_id      BIGINT NULL COMMENT '对号入座场次才有值',
 zone         VARCHAR(20) NULL COMMENT '区域,不对号入座用',
 uid          BIGINT NOT NULL,
 price        INT NOT NULL COMMENT '单位分',
 status       TINYINT NOT NULL DEFAULT 1
 COMMENT '1待支付 2已支付 3已出票 4已取消 5已退票',
 expire_at    DATETIME NOT NULL COMMENT '待支付截止时间',
 created_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 UNIQUE KEY uk_order_no (order_no),
 KEY idx_session_status (session_id, status),
 KEY idx_uid_session (uid, session_id)
);

二、防超卖:Redis 预扣 + 数据库条件更新双层

开票瞬间,绝不能让每个请求都打到数据库行锁上。标准做法是 Redis 预扣占位,数据库条件更新兜底:

代码语言:txt
复制
async function createOrder(sessionId, uid, qty, price) {
 // 1. 限购校验:同一用户同一场次限购 N 张
 const bought = await redis.get(`bought:${sessionId}:${uid}`);
 if (bought && +bought + qty > 4) throw new Error('超出限购数量');

 // 2. Redis 预扣(DECR 原子操作,天然不超卖)
 const remain = await redis.decr(`stock:${sessionId}`);
 if (remain < 0) {
 await redis.incr(`stock:${sessionId}`);   // 回补
 throw new Error('本场次已售罄');
 }

 try {
 // 3. 数据库条件更新兜底:库存充足才创建订单
 const r = await db.query(
 `UPDATE show_session SET total_tickets = total_tickets - ?
 WHERE id = ? AND total_tickets >= ? AND status = 2`,
 [qty, sessionId, qty]);
 if (r.affectedRows === 0) throw new Error('售罄');

 // 4. 限购计数,TTL 到场次结束
 await redis.incr(`bought:${sessionId}:${uid}`);
 await redis.expire(`bought:${sessionId}:${uid}`, 86400);

 const orderNo = genOrderNo();
 await db.query(
 `INSERT INTO ticket_order (order_no, session_id, uid, price, qty, expire_at)
 VALUES (?,?,?,?,?, NOW() + INTERVAL 10 MINUTE)`,
 [orderNo, sessionId, uid, price, qty]);

 return orderNo;
 } catch (e) {
 await redis.incr(`stock:${sessionId}`);   // 失败回补
 throw e;
 }
}

要点有三:DECR 后判断负数并回补,这比先 GET 再判断安全,因为 GET 和 DECR 之间可以插进任意请求;数据库层的 total_tickets >= ? 条件更新是最终防线,Redis 和数据库之间可能因回补失败出现少量偏差,条件更新保证结果正确;限购计数用 TTL 自动过期,不需要显式清理。

三、选座:座位锁必须带过期时间

对号入座的演出(剧场、Livehouse 座位区)比不对号入座多一个难题:两个人不能锁到同一个座位。座位锁的实现:

代码语言:txt
复制
async function lockSeats(sessionId, seatIds, uid) {
 const key = `seats:${sessionId}`;
 // 原子方式把用户想买的座位加入已锁集合
 // 用 Lua 保证"检查 + 锁定"原子性
 const lua = `
 local locked = redis.call('SMEMBERS', KEYS[1])
 for i, s in ipairs(ARGV) do
 if locked[s] then return s end
 end
 for i, s in ipairs(ARGV) do redis.call('SADD', KEYS[1], s) end
 return 'OK'`;
 const conflict = await redis.eval(lua, 1, key, ...seatIds);
 if (conflict !== 'OK') throw new Error(`座位 ${conflict} 已被锁定`);

 await redis.expire(key, 300);   // 锁 5 分钟
 await redis.hset(`seat_owner:${sessionId}`,
 seatIds.join(','), uid);
}

锁一定要有过期时间。 用户锁了座位不支付,五分钟后锁自动释放,座位回到可售池。配合订单层面的 expire_at,超时任务把待支付订单置为已取消并回补库存:

代码语言:txt
复制
-- 定时任务:取消过期未支付订单
UPDATE ticket_order SET status = 4
 WHERE status = 1 AND expire_at < NOW();
-- 回补对应场次库存(按取消量)
UPDATE show_session s JOIN (
 SELECT session_id, SUM(qty) q FROM ticket_order
 WHERE status = 1 AND expire_at < NOW() GROUP BY session_id
) t ON s.id = t.session_id
 SET s.total_tickets = s.total_tickets + t.q;

座位图渲染还有一个容易踩的坑:大场馆几千个座位,如果每个座位单独一个请求查询状态,开票瞬间服务器会被打穿。正确做法是座位状态用位图整体下发:每个座位一个 bit,接口一次返回整个场座的 bitmap,前端本地渲染,选中后再提交锁定请求。

票档设计也影响库存压力。同样一场演出,做成三四个价位档,比单一票价更抗风险:便宜档先售罄能带动气氛,贵的档留住预算高的客人,开票压力也被分散到多个库存维度上。

四、防黄牛:能做的和做不了的

完全防住黄牛不现实,但几层措施能显著提高成本:限购(同一实名证件同一场次一张);实名入场(购票绑定证件号,入场时核验证件与票面一致,转让即失去价值);风控(同 IP 高频下单、同设备多账号、下单到支付的异常行为特征)。其中实名入场是性价比相对高的一层——黄牛的核心诉求是"能转手",实名把转手的成本抬高,比任何算法风控都直接。

对账同样不能省。票务的钱流涉及自有渠道、合作渠道多方结算,日终把票款和各渠道分成对平:出票量、退票量、实收金额、应付渠道金额,四条线各自和渠道对账单核对。差额要能定位到单,这就要求订单表里把渠道来源、分成比例落库,而不是事后翻记录补。票务项目的对账差错,多半出在"渠道单导进来时没带场次维度",建模时多想一步,后面对账省十倍力气。

五、开票瞬间的架构预案

抢票流量是脉冲式的:开票前一秒没人,开票后十秒内洪峰。上线前要做的准备:

- 前端排队:开票页面进入虚拟排队,按序放行,比全量冲击后端体验好得多;- 接口限流:网关层按 UID 限频,每秒一次下单请求足够;- 库存预热:开票前把场次库存加载进 Redis,避免首次 DECR 击穿到数据库;- 降级预案:选座接口压力大时,临时切成"按区域随机派座",放弃座位选择保住成交。

六、电子票与核销:出票之后才算结束

很多票务系统把"支付成功"当终点,其实出票和核销才是体验的关键。支付完成后生成取票凭证,常见形态是取票码加动态二维码:取票码放在订单页,动态二维码每隔几十秒刷新,防止截图转发。入场核销时扫动态码,服务端校验场次、订单状态、是否已核销,核销记录带时间和闸机编号。

转让场景要提前定好规则:允许整单转让的,走系统内的受赠流程,原票作废、新票生成;不允许转让的实名场次,核销时比对证件。最怕的是没有规则,现场验票时和持票人扯皮。

退票建模也别偷懒:退票不是把订单状态改一下就完,票要回到库存、座位锁要释放、退款要走支付渠道原路退回,三件事要么都成功要么都不算退。建议把退票建为独立单据,和购票单关联,状态分开流转,对账时两条线各自对得上。

七、小结

票务系统的工程要点可以归纳为:Redis 预扣 + 数据库条件更新双保险、座位锁带过期、订单超时自动回补、实名限购抬高黄牛成本。青海青帝信息科技有限公司在做本地演出类项目时,会在上线前组织一次"内部抢票压测":全员同时开抢百张测试票,验证不超卖、不重座、回补准确,这三项通过才敢对外开票。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、库存模型:场次库存与会话库存分开
  • 二、防超卖:Redis 预扣 + 数据库条件更新双层
  • 三、选座:座位锁必须带过期时间
  • 四、防黄牛:能做的和做不了的
  • 五、开票瞬间的架构预案
  • 六、电子票与核销:出票之后才算结束
  • 七、小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档