简介:低价限时票是文旅行业常见的引流手段,也是对票务系统的一次全链路压测。本文以“1元门票抢购”场景为例,完整拆解活动建模、动静分离架构、库存原子扣减、防超卖与防刷、订单状态机、库存回流及监控压测等关键设计,并给出可直接落地的伪代码与检查清单,帮助开发者构建稳定、公平、可运营的高并发抢票系统。
低价限时票是文旅行业的经典引流打法:史前文化主题园区、自然山水景区、博物馆等在淡季或新项目开业窗口,常推出“1元门票”“一票两园”之类的极低门槛产品。对运营来说,它要的是话题热度与实际到访;对技术来说,它是“极小库存 + 极低价格 + 固定时间窗 + 极高预期流量”叠加而成的人为洪峰,等于对票务系统做了一次不打招呼的全链路压测。
如果没有针对性设计,几类故障几乎必然出现:开闸瞬间详情页被刷新流量打垮;库存扣了却没生成订单,形成超卖;同一用户重复下单、重复扣款;脚本与黄牛抢走大部分名额;关单任务与支付回调并发,把订单状态写乱。本文按照“活动建模 → 总体架构 → 库存扣减 → 下单链路 → 防刷风控 → 运营协同 → 监控压测”的顺序,给出一套通用且可落地的方案。
1. 基础实体。 一次抢购活动至少包含四类实体:活动(时间窗、参与票种、规则说明)、票种(单园票、组合票、成人票/儿童票等)、库存(总量、预扣量、已售量,数据库持久化、缓存做镜像)、规则(限购数、渠道开关、退改策略、使用限制)。活动时间窗(例如“10月21日至11月22日”这类日期区间)必须是配置项而非代码常量,并支持“提前预热、到点自动开闸、到期自动关闸”。运营中途调整库存与规则是常态,硬编码等于埋雷。
2. 组合票建模。 “一张门票、两处游玩”(如门票同时包含主园区与动物乐园)有两种方案:
核销环节要拆成两条子核销记录,支持部分核销状态展示(“主园区已核销、动物乐园未核销”),这直接决定订单详情页的信息结构。
3. 规则透明化。 价目表、适用日期、限购与退改规则要结构化存储,在下单页与订单详情页完整呈现,并留存用户确认记录。多数“抢完发现不能用”的纠纷,本质是规则没有被当作数据来管理。规则透明既是产品要求,也是减少客诉与资损的技术设计。
三条设计原则:
1. 预热。 开抢前把库存从数据库加载进缓存,双侧校验一致后冻结活动配置;开闸后所有扣减都发生在缓存侧,数据库只承担异步落库与兜底。
2. 原子脚本扣减。 扣减必须把“限购判断 + 库存判断 + 扣减 + 记录已购”放进同一个原子操作,任何拆分都会给并发留下缝隙:
-- 缓存端原子扣减脚本(伪代码示意,适用于支持原子脚本的主流缓存中间件)-- KEYS[1]: 活动库存键 KEYS[2]: 已购用户集合键-- ARGV[1]: 购买数量 ARGV[2]: 用户标识if SISMEMBER(KEYS[2], ARGV[2]) == 1 thenreturn -1 -- 重复购买,直接拒绝endlocal stock = tonumber(GET(KEYS[1]) or "0")if stock < tonumber(ARGV[1]) thenreturn 0 -- 库存不足endDECRBY(KEYS[1], ARGV[1])SADD(KEYS[2], ARGV[2])return 1 -- 预扣成功
返回值语义要事先约定:1为预扣成功、0为售罄、-1为重复购买。抢购服务据此走不同分支,避免让用户在“卖完了”和“你已买过”之间猜测。
3. 数据库兜底。 缓存解决性能,数据库负责正确性。订单落库时执行条件更新:
-- 影响行数为 0 即表示并发下库存不足,整个订单创建流程回滚UPDATE activity_stockSET reserved = reserved + 1WHERE activity_id = ? AND reserved < total_quota;
有了这层兜底,即使缓存被击穿,超卖在数据库层依然被禁止。
4. 售罄标记。 库存清零后写入售罄标记并同步到应用层本地缓存,后续请求直接快速返回售罄,不再触碰缓存与数据库——既保护后端,也减少用户无效等待。
5. 对账。 定时任务周期性比对三个数字:缓存累计扣减量、订单创建量、支付成功量。三者应满足“扣减 ≥ 订单 ≥ 支付”且差异可被超时窗口解释;一旦超出阈值立即告警,必要时暂停活动。宁可暂停销售,也不能带着不一致继续跑。
6. 少卖治理。 预扣后未支付的库存要能回流:订单超时关闭后把数量加回可售池、刷新售罄标记,避免“页面显示售罄、后台其实有票”的少卖。
1. 幂等控制。 入口以“用户 + 票种 + 场次”构造幂等键,先占位再执行,失败时释放占位允许重试:
// 伪代码:下单入口幂等控制String idemKey = "order:create:" + userId + ":" + skuId + ":" + roundId;if (!cache.setIfAbsent(idemKey, "PROCESSING", 60s)) {return Result.reject("处理中,请勿重复提交");}try {long ret = stockClient.atomicDeduct(skuId, 1, userId); // 调用原子脚本if (ret <= 0) return Result.reject("已售罄或超出限购");mq.send("order-create-topic", OrderMsg.of(userId, skuId, roundId));return Result.ok("抢购成功,请在15分钟内完成支付");} catch (Exception e) {cache.delete(idemKey); // 失败释放幂等键,允许用户重试throw e;}
消息消费端同样要做幂等(按业务键去重),防止队列重投造成重复建单。
2. 订单状态机。 清晰定义五态:待支付(CREATED)、已支付(PAID)、已核销(USED)、已关闭(CLOSED)、已退款(REFUNDED)。合法迁移只有:CREATED→PAID→USED;CREATED→CLOSED(超时或取消,库存回流);PAID→REFUNDED。所有迁移用条件更新(UPDATE ... SET status = 新值 WHERE id = ? AND status = 期望旧值)实现,天然防止并发写入乱序。
3. 关单与支付的竞态。 这是抢票系统最经典也最易遗漏的场景:延迟消息到点触发关单前,必须先以条件更新确认订单仍是待支付;若与支付回调并发,谁先更新成功谁生效,另一方按“状态已变更”走补偿或原路退回。没有这层保护,会出现“已支付订单被关闭、钱货两空”的严重资损。
上线前逐项核对:
技术方案是骨架,抢购活动的最终口碑取决于三件事:规则透明、过程公平、票能兑现。把价目与限制当作结构化数据管理并留痕,把天气等外部因素的处理流程产品化,把每一次活动当作一次压测来复盘,“1元抢票”才能真正从一次性的流量脉冲,沉淀为可持续的系统能力与用户资产。