演出票务是小程序里对并发最敏感的类型之一。一场本地热门演出,开票瞬间的请求可能达到平日的百倍,而票的总数是确定的、座位是唯一的。本文围绕抢票链路的三个核心问题展开:库存怎么扣才不超卖、座位怎么锁才不冲突、黄牛怎么挡。示例基于 MySQL 8 + Redis,部署形态可平移到云托管或自建服务。
票务和普通电商最大的区别是库存天然按场次隔离,且有"可售/锁定/已售/退回"多种状态:
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 预扣占位,数据库条件更新兜底:
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 座位区)比不对号入座多一个难题:两个人不能锁到同一个座位。座位锁的实现:
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,超时任务把待支付订单置为已取消并回补库存:
-- 定时任务:取消过期未支付订单
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 删除。