景区票务小程序和普通电商最大的区别是:它卖的不是商品,是一段时间内某个空间的使用权。这个特性决定了三件事必须做对——分时段库存怎么算、实名票怎么绑、核销怎么做才能不重不漏。
本文以青海湖、茶卡这类高原景区为背景,给出可落地的数据模型与关键实现。示例用 Node.js + MySQL 8,核销部分给出 Redis 方案。
一、分时段库存:为什么不能按"总库存"卖
一个景区一天接 8000 人,如果只设一个总数 8000,会出现什么问题?
上午九点到十一点,三千人同时到门口,检票队伍排出五百米,观光车排队一小时;下午三点,园区空了一半。总收入没变,体验崩了。
分时段的意义是把"总量控制"变成"流量曲线控制"。但一分时段,库存模型就复杂了:总库存 8000 要拆成若干时段,每个时段有自己的容量,还要处理"某时段卖完了能不能调剂到相邻时段"。
先看模型:
-- 场次(一个景区一天可以有多个时段) CREATE TABLE ticket_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scenic_id BIGINT NOT NULL, biz_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, capacity INT NOT NULL COMMENT '该时段容量', sold INT NOT NULL DEFAULT 0, locked INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1可售 0关闭', UNIQUE KEY uk_scenic_date_start (scenic_id, biz_date, start_time), KEY idx_date (biz_date) ); -- 票种(成人票、学生票、老人票),价格挂在这里 CREATE TABLE ticket_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scenic_id BIGINT NOT NULL, name VARCHAR(60) NOT NULL, price_cents INT NOT NULL, need_realname TINYINT NOT NULL DEFAULT 1, stock_mode TINYINT NOT NULL DEFAULT 1 COMMENT '1按场次 2全场通用', KEY idx_scenic (scenic_id) );
stock_mode 这个字段很重要。有的票种必须绑场次(比如限流的博物馆),有的票种全场通用(比如年卡)。两种模式的扣减逻辑不同,混在一起写会在后面出大问题。
二、下单扣减:条件更新 + 事务边界
抢票的并发比普通电商高一个量级,尤其是在节假日开售那一分钟。核心只有一条原则:把判断写进 SQL 的 WHERE 里。
async function lockSlot(conn, slotId, qty) { const [r] = await conn.query( `UPDATE ticket_slot SET locked = locked + ? WHERE id = ? AND status = 1 AND capacity - sold - locked >= ?`, [qty, slotId, qty] ); // 影响行数为 0,说明库存不足或场次已关闭 if (r.affectedRows === 0) { throw new Error('SLOT_SOLD_OUT'); } }
下单流程要在同一个事务里完成四步:
1. 锁场次库存(上面这条 SQL)2. 创建订单3. 创建票明细(每张票一条记录,实名票要带游客信息)4. 写库存流水(用于事后对账)
任何一步失败,整单回滚。不要在下单时调外部接口(比如给游客发短信),否则事务会变长,锁持有时间上升,高并发下直接拖垮数据库。短信放到支付成功之后异步发。
三、实名票:一人一票与证件校验
景区实名制有两个层次:实名购买(下单时填身份证)和实名入园(入园时校验本人)。前者是管理要求,后者是防黄牛手段。
数据设计上,票明细表要独立于订单表:
CREATE TABLE ticket_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, ticket_type_id BIGINT NOT NULL, visitor_name VARCHAR(40) NOT NULL, id_type TINYINT NOT NULL DEFAULT 1 COMMENT '1身份证 2护照 3港澳台通行证', id_no_enc VARBINARY(255) NOT NULL COMMENT '证件号加密存储', id_no_hash CHAR(64) NOT NULL COMMENT '证件号哈希,用于唯一性校验', status TINYINT NOT NULL DEFAULT 1 COMMENT '1待使用 2已核销 3已退款', qr_token CHAR(32) NOT NULL COMMENT '核销二维码令牌', used_at DATETIME NULL, KEY idx_order (order_id), KEY idx_qr (qr_token), KEY idx_idhash_slot (id_no_hash, slot_id) );
三个设计要点:
证件号必须加密存储,同时保留哈希。 明文存证件号是合规红线,一旦泄露后果严重。但业务上又需要"同一个证件在同一场次只能买一张",这个校验用哈希做:
const crypto = require('crypto'); // 加密用 AES-256-GCM,密钥放 KMS function encIdNo(plain, key, iv) { const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); return Buffer.concat([iv, cipher.getAuthTag(), enc]); } // 哈希用于唯一校验,加盐防止彩虹表 function hashIdNo(plain, salt) { return crypto.createHmac('sha256', salt).update(plain).digest('hex'); } 同一场次一人一票的约束,落在应用层查 (id_no_hash, slot_id) 是否存在,再加一个唯一索引兜底: ALTER TABLE ticket_item ADD UNIQUE KEY uk_idhash_slot (id_no_hash, slot_id); 注意这个唯一索引会把"已退款的票"也算进去,导致退款后无法重购。解决办法是把已退款的记录迁移到历史表,或者用 (id_no_hash, slot_id, status) 做部分索引(MySQL 8 可以用生成列实现): ALTER TABLE ticket_item ADD COLUMN uniq_key VARCHAR(80) GENERATED ALWAYS AS (IF(status = 1, CONCAT(id_no_hash, '-', slot_id), NULL)) STORED, ADD UNIQUE KEY uk_uniq (uniq_key);
status 变成 2 或 3 时 uniq_key 为 NULL,MySQL 的唯一索引允许多个 NULL,冲突就解开了。这个技巧在票务场景里很常用。
四、核销:闸机场景下的一致性
核销是整个票务系统最容易出事的地方。一个二维码,如果被两个闸机同时扫到(游客举着手机在两个通道之间晃),一张票可能被核销两次;如果闸机断网,核销记录可能丢失。
核心原则:核销必须是幂等的条件更新。
UPDATE ticket_item
SET status = 2, used_at = NOW(), used_gate = ?
WHERE qr_token = ?
AND status = 1;
影响行数为 1 才是有效核销;为 0 说明票已经被用过、已退款或不存在。闸机根据这个结果决定放行还是拦截,判断逻辑只有一处,不会有二义性。
高并发下的性能问题:节假日入园高峰,每秒几百次核销,每次都打 MySQL 会有压力。常见做法是用 Redis 做前置状态:
// 核销前置校验:Redis 记录"已核销"标记 async function verify(redis, pool, qrToken, gateId) { // SETNX 成功表示之前没核销过 const key = `ticket:used:${qrToken}`; const ok = await redis.set(key, gateId, 'NX', 'EX', 86400); if (!ok) { // Redis 说已核销,但可能是缓存不一致,回查数据库确认 const [rows] = await pool.query( 'SELECT status, used_at FROM ticket_item WHERE qr_token = ?', [qrToken] ); if (!rows.length) return { pass: false, code: 'NOT_FOUND' }; if (rows[0].status === 2) return { pass: false, code: 'ALREADY_USED', at: rows[0].used_at }; // 数据库仍是待使用,说明是脏标记,放行并修复缓存 await redis.del(key); } const [r] = await pool.query( 'UPDATE ticket_item SET status = 2, used_at = NOW(), used_gate = ? WHERE qr_token = ? AND status = 1', [gateId, qrToken] ); if (r.affectedRows === 0) { await redis.del(key); return { pass: false, code: 'ALREADY_USED' }; } return { pass: true }; }
要点:Redis 只做加速,不作为权威。任何缓存判断不一致时,一律以数据库的条件更新结果为准。这样即使 Redis 挂了、数据丢了,也不会出现重复核销。
断网怎么办:闸机要支持离线核销——本地缓存当天的有效票令牌列表(只缓存不敏感的 token 和场次),断网时本地判断,联网后把离线核销记录补传。补传时同样走条件更新,冲突的以先到为准。这是一致性的必要妥协:宁可少数场景下允许重复入园,也不能让游客在门口干等。
五、退改签与库存回补
景区票的退改规则通常和日期强绑:当天可退、提前一天免费改、当天改期收手续费。规则本身是业务配置,实现上要注意两件事。
一是回补库存要防止超卖。 退票时把 sold 减回去,但如果这个场次已经过了,减回去没有意义(票已经不能卖了)。所以回补前要判断场次是否还在可售状态:
UPDATE ticket_slot SET sold = sold - ? WHERE id = ? AND status = 1 AND CONCAT(biz_date, ' ', start_time) > NOW();
二是退款要和核销状态互斥。 已经核销的票不能退。这条约束要在退款接口里用条件更新保证:
UPDATE ticket_item SET status = 3
WHERE id = ? AND status = 1;
影响行数为 0 就拒绝退款,并返回具体原因(已核销 / 已退款 / 不存在),不要笼统地报"退款失败"。
六、运营侧需要的三个报表
系统上线后,景区运营最先要的是三张表:分时段售罄率(哪个时段卖完了、哪个时段空着)、核销率(卖了多少、实际来了多少)、渠道分布(小程序、OTA、窗口各占多少)。
前两张直接决定第二天的容量配置:某个时段连续售罄,可以考虑加开;核销率长期低于 70%,说明容量定高了或者存在大量退票。第三张决定渠道投入的优先级。
这三张表的数据都来自前面几张表,不需要额外埋点——前提是下单、核销、退款三条链路都写全了流水。青海青帝科技在本地文旅项目中,会把"流水是否完整"作为验收项之一,因为报表不准的时候,运营就失去了调整依据。