☰
滑雪场售票系统库存防超卖设计与Spring Boot实践
2026/10/19 13:29:31 网站建设 项目流程

滑雪场的售票系统和普通电商比起来,有个特别折磨人的点:库存不是简单的"总数",而是"每天限量"——雪场容量有限,节假日必须限流,票种又分日场、夜场、季卡、教学套餐,价格体系还随淡旺季浮动。我去年给一家中型雪场做过一套基于Spring Boot的售票系统,从需求梳理到部署上线差不多用了两个月,中间踩了不少坑,今天把整个设计思路和核心实现完整盘一遍,希望能给正在做同类项目的朋友一些参考。

1. 滑雪票务的业务特殊性:为什么不能照搬电商秒杀方案

先说一个反直觉的结论:滑雪售票系统最核心的技术难点不是高并发秒杀,而是"时间维度上的库存管理和履约闭环"。雪场单日接待量有上限,缆车运力、雪道容量、租赁装备数量都决定了每天能卖多少票,所以库存模型天然是"票种+日期"的二维结构,这和电商里一个SKU对应一个总库存的模型有本质区别。

1.1 票种维度和价格策略拆解

滑雪场的票务体系通常比想象中复杂,我梳理了某雪场实际运营时的票种分类,大致如下:

票种大类典型子类型定价逻辑库存策略
入场票日场票、夜场票、全日票按时间段+淡旺季浮动按日期限量
季卡单人季卡、家庭季卡固定价,预售为主总量控制,不分日期
教学产品私教课、团体课按教练时段定价按教练+时间段分配
租赁套餐雪具租赁、储物柜附加服务,随门票组合关联门票库存

这里有个容易被忽略的需求:夜场票和日场票在跨天时段(比如夜场从18:00到22:00,跨了营业日期)的场次归属问题。很多系统在早期设计时把日期当成简单的"售卖日期",结果核销时发现夜场票的"使用日期"和"售卖日期"不是同一天,导致对账混乱。正确的做法是给票种增加一个"有效使用日期"字段,和下单日期解耦。

1.2 库存模型的三个核心约束

我在设计阶段和雪场运营负责人反复确认后,总结出库存模型必须满足的三个约束,这也是后续所有技术决策的出发点:

  • 日期容量约束:每个票种每天有独立的最大售卖量,比如日场票每天限售800张,夜场限售500张。这个数字由雪场承载力决定,不是拍脑袋定的。
  • 超卖保护约束:用户下单后到支付完成前,库存需要被"预占",超过支付时限要自动释放。这里不能简单用数据库行锁,否则高峰期会锁等待严重。
  • 核销闭环约束:票售出后必须能验证真伪,且一张票只能核销一次。线上的订单状态和线下的入园记录必须打通。

这三个约束决定了系统的技术选型:数据库需要支持精准的库存扣减,Redis要承担预占和热点数据缓存,订单状态机要覆盖从创建到核销的完整生命周期。

2. 技术选型与整体架构:单体应用的分层实践

很多同学一上来就想着微服务、消息队列、分布式事务,实际上对于单雪场规模的售票系统来说,Spring Boot单体应用加良好分层完全够用,而且维护成本低得多。我最终的选型是:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis 6.x,JDK用17。

2.1 为什么不用微服务

滑雪场售票系统高峰期也就节假日那几天,QPS撑死几百,真正要命的是业务逻辑的复杂性,而不是并发量。如果拆成订单服务、库存服务、支付服务三个微服务,光是分布式事务就够喝一壶的——库存扣减和订单创建需要强一致,用Seata之类的方案引入额外的运维负担,对一个小型项目来说是得不偿失的。

单体应用配合合理的模块划分,效果完全一样。我的项目结构是这样的:

ski-ticketing/ ├── controller/ # 接口层,只做参数校验和结果封装 ├── service/ # 业务逻辑层,事务边界在这里 │ ├── order/ # 订单相关 │ ├── inventory/ # 库存相关 │ ├── payment/ # 支付回调 │ └── ticket/ # 出票、核销 ├── mapper/ # 数据访问层 ├── entity/ # 数据库实体 ├── dto/ # 请求响应对象 ├── config/ # Redis、线程池、拦截器配置 └── common/ # 统一返回、异常处理、工具类

这个分层的核心原则是:事务必须在service层控制,controller层不允许直接操作数据库。后面排查库存扣减问题时会发现,如果事务边界乱放,会出现各种诡异的并发问题。

2.2 Redis在系统里的三个角色

Redis在这个系统里不只是缓存,它承担了三个职责,这也是库存不超卖的关键:

  • 库存预占计数器:用Redis的DECR和INCR命令实现下单前的原子扣减,扣减成功的订单才允许落库。这里用的是Redis的原子性,而不是分布式锁。
  • 分布式锁:在真正操作数据库扣减库存时,用Redisson的tryLock保证同一天同一票种的并发扣减是串行的。锁的粒度是"票种+日期",不是全局锁,否则性能会退化得很厉害。
  • 热点缓存:票种列表、价格表、库存余量这些读多写少的数据缓存到Redis,减少数据库压力。特别是首页的余量展示,如果每次都查数据库,高峰期数据库连接会被打满。

这里有个关键的时序问题:Redis预占和数据库扣减不是同时发生的。用户下单时先Redis预占,然后创建订单(状态为待支付),最后再异步扣减数据库库存。为什么这么设计?因为从下单到支付之间有很长的时间窗口,数据库库存如果提前扣了,用户超时未支付还得做反向补偿,很容易出现数据库和Redis数据不一致的问题。

3. 数据库设计:库存日历和订单状态机的建模

数据库是整个系统的地基,建表阶段多花一天思考,后面能少加一个星期的班。我重点讲三张核心表的设计思路。

3.1 库存日历表:以"天"为粒度的库存核心表

库存表不能只存总数,必须按日期展开。我的设计如下:

CREATE TABLE `ticket_inventory` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `ticket_type_id` BIGINT NOT NULL COMMENT '票种ID', `sale_date` DATE NOT NULL COMMENT '售卖日期(使用日期)', `total_stock` INT NOT NULL DEFAULT 0 COMMENT '总库存', `sold_stock` INT NOT NULL DEFAULT 0 COMMENT '已售库存', `locked_stock` INT NOT NULL DEFAULT 0 COMMENT '锁定库存(待支付)', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_ticket_date` (`ticket_type_id`, `sale_date`), KEY `idx_sale_date` (`sale_date`) ) ENGINE=InnoDB COMMENT '票种库存日历表';

这张表的核心是三个库存字段:total_stock是当天该票种的总量,sold_stock是已支付出票的数量,locked_stock是预占但未支付的数量。剩余可卖库存 = total_stock - sold_stock - locked_stock。

为什么单独拆一个locked_stock字段?因为对账时需要知道"哪些票被锁了但没付款",这直接关系到超卖风险的评估。如果只要sold_stock一个字段,释放锁定时就要做减法,逻辑容易混乱。

3.2 订单表和状态机的完整生命周期

订单表是交易的核心载体,我用的是扩展状态机设计:

CREATE TABLE `ticket_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号,全局唯一', `user_id` BIGINT NOT NULL COMMENT '用户ID', `ticket_type_id` BIGINT NOT NULL, `sale_date` DATE NOT NULL COMMENT '使用日期', `quantity` INT NOT NULL DEFAULT 1 COMMENT '购买数量', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1已支付 2已出票 3已核销 4已取消 5已退款', `pay_expire_time` DATETIME NOT NULL COMMENT '支付截止时间', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status_expire` (`status`, `pay_expire_time`) ) ENGINE=InnoDB COMMENT '订单表';

订单状态流转路径为:待支付 → 已支付 → 已出票 → 已核销,待支付超时后走已取消。这里我建议用一个TinyInt类型存状态码而不是字符串,数据库索引效率更高。状态流转的代码必须放在一个独立的方法里,用UPDATE ... WHERE status = ?做条件更新,这是后面实现幂等的基础。

3.3 核销记录表:一张票只能进一次雪场

核销是滑雪系统特有的闭环动作,我单独设计了一张核销记录表:

CREATE TABLE `ticket_verification` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `ticket_code` VARCHAR(64) NOT NULL COMMENT '电子票唯一码', `order_id` BIGINT NOT NULL, `verify_time` DATETIME NOT NULL COMMENT '核销时间', `verify_channel` TINYINT NOT NULL COMMENT '核销渠道:1闸机 2人工 3小程序', `operator_id` BIGINT DEFAULT NULL COMMENT '操作员ID', PRIMARY KEY (`id`), UNIQUE KEY `uk_ticket_code` (`ticket_code`) ) ENGINE=InnoDB COMMENT '核销记录表';

uk_ticket_code唯一索引是核心防重复核销手段——同一个人拿着同一个票码在闸机上刷两次,第二次插入会因为唯一索引冲突而失败。这种约束用应用层判断是挡不住并发请求的,只有数据库约束最可靠。

4. 核心交易链路实现:库存扣减就是一场防超卖的攻防战

整个系统技术含量最高的部分就是下单接口的库存扣减逻辑。我前前后后重构了三版,这里把最终版本的完整思路和代码贴出来。

4.1 下单接口的整体流程

下单接口不是直接扣库存,而是走一个"预占 + 落单 + 异步确认"的流程:

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, Long ticketTypeId, LocalDate saleDate, Integer quantity) { // 1. 前置校验:票种是否存在、日期是否在售卖范围内 TicketType ticketType = ticketTypeService.getById(ticketTypeId); Assert.notNull(ticketType, "票种不存在"); Assert.isTrue(!saleDate.isBefore(LocalDate.now()), "不能购买过去的日期"); // 2. Redis预占库存:原子扣减,防止超卖的第一道防线 String stockKey = buildStockKey(ticketTypeId, saleDate); Long remaining = redisTemplate.opsForValue().decrement(stockKey, quantity); if (remaining == null || remaining < 0) { // 扣减失败,需要回补 redisTemplate.opsForValue().increment(stockKey, quantity); throw new BizException("当日库存不足"); } try { // 3. 生成订单号,落库订单数据 String orderNo = generateOrderNo(); TicketOrder order = new TicketOrder(); order.setOrderNo(orderNo); order.setUserId(userId); // ... 设置票种、日期、金额等字段 order.setStatus(0); order.setPayExpireTime(LocalDateTime.now().plusMinutes(15)); orderMapper.insert(order); // 4. 异步扣减数据库库存(乐观锁) inventoryService.deductStock(ticketTypeId, saleDate, quantity); return buildOrderVO(order); } catch (Exception e) { // 5. 回补Redis库存,保证最终一致性 redisTemplate.opsForValue().increment(stockKey, quantity); throw e; } }

这段代码的关键在Redis预占和数据库扣减的配合。Redis的DECR是原子操作,不会出现两个线程同时扣减同一份库存的问题,这是第一道防线。数据库的UPDATE带乐观锁,这是第二道防线。两道防线都通过,才算是真正锁定了库存。

4.2 乐观锁扣减数据库库存的具体实现

数据库层的库存扣减不能写成"先查再改",必须用一条SQL完成条件更新:

public boolean deductStock(Long ticketTypeId, LocalDate saleDate, Integer quantity) { // 只有当剩余可卖库存大于等于购买数量时,才允许扣减 int rows = inventoryMapper.deductStock(ticketTypeId, saleDate, quantity); return rows > 0; }

对应的Mapper SQL:

<update id="deductStock"> UPDATE ticket_inventory SET sold_stock = sold_stock + #{quantity}, version = version + 1 WHERE ticket_type_id = #{ticketTypeId} AND sale_date = #{saleDate} AND (total_stock - sold_stock - locked_stock) >= #{quantity} AND version = #{version} </update>

这个SQL的巧妙之处在于:WHERE条件里直接计算剩余库存,只有满足条件才更新,否则影响行数为0。配合version字段做乐观锁,即使两个事务同时执行,也只有一个能成功更新。

这里我必须强调一个实际踩过的坑:如果version是每次查询时带上、更新时校验的,那么在高并发场景下,两个线程可能拿到相同的version,然后一个成功、一个失败,失败的请求直接报"库存不足"给用户。但实际上库存是够的,只是并发冲突。后来我的处理方式是在WHERE里不加version条件,只依赖"剩余库存>=数量"这个业务条件来判断。数据库的行锁会保证同一行数据的更新是串行的,后到的请求会阻塞在前一个事务提交后,然后重新评估条件。

4.3 支付回调的幂等处理

支付回调是另一个容易出问题的环节。第三方支付平台为了保证通知送达,会多次回调同一个订单,如果处理逻辑不幂等,订单状态会被覆盖,甚至重复出票。

我的解决方式是:状态更新用条件更新,只有"当前状态=待支付"时才允许改成"已支付":

@Transactional(rollbackFor = Exception.class) public void handlePayCallback(String orderNo, String transactionId) { // 幂等关键:只有待支付状态才能流转到已支付 int rows = orderMapper.updateStatusByCondition(orderNo, 0, 1, transactionId); if (rows == 0) { // 已经处理过回调或订单状态不允许流转 log.warn("支付回调重复处理或状态异常,orderNo={}", orderNo); return; } // 支付成功后的后续操作:扣减locked_stock、生成电子票 ticketService.issueTicket(orderNo); }

这里updateStatusByCondition的SQL是核心:

UPDATE ticket_order SET status = 1, pay_transaction_id = #{transactionId}, pay_time = NOW() WHERE order_no = #{orderNo} AND status = 0

多条并发回调执行这条SQL时,数据库行锁会保证只有一个请求能成功更新,其他的影响行数为0,直接返回。这样无论回调多少次,出票逻辑只执行一次。

5. 超时未支付与库存回补:定时任务的补偿机制

用户下单后如果15分钟内不支付,订单要自动取消,锁定的库存要释放。这个场景如果用定时任务全局扫描,效率太低,而且会有几分钟的延迟。我采用的是"延迟队列 + 定时任务补偿"的双保险方案。

5.1 基于Redis延迟队列的准实时释放

下单成功后,把订单号丢进一个带过期时间的Redis键里,利用Redis的键过期事件来触发取消逻辑。不过Redis的过期事件有延迟,而且默认不保证可靠性,所以只作为辅助手段。

我的做法是先把订单号写入一个ZSet,score设为过期时间戳,然后用一个后台线程每隔10秒扫描一次:

public void setupOrderExpireListener() { ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleWithFixedDelay(() -> { long now = System.currentTimeMillis(); // 取出所有已过期的订单号 Set<String> expiredOrders = redisTemplate.opsForZSet() .rangeByScore(ORDER_EXPIRE_QUEUE, 0, now); for (String orderNo : expiredOrders) { cancelExpiredOrder(orderNo); // 处理完从ZSet移除 redisTemplate.opsForZSet().remove(ORDER_EXPIRE_QUEUE, orderNo); } }, 0, 10, TimeUnit.SECONDS); }

这里线程池的线程数不需要多,一个就够了,因为取消操作的频率不会很高。真正要注意的是:cancelExpiredOrder的方法必须加分布式锁,防止和支付回调同时操作同一个订单。

5.2 取消订单时的库存回补逻辑

取消订单时,Redis预占的库存和数据库的locked_stock都要释放,顺序不能反:

@Transactional(rollbackFor = Exception.class) public void cancelExpiredOrder(String orderNo) { // 用分布式锁锁住订单,防止和支付回调并发冲突 RLock lock = redissonClient.getLock("order:lock:" + orderNo); if (!lock.tryLock(0, 3, TimeUnit.SECONDS)) { return; // 没拿到锁说明有其他线程在处理,跳过 } try { TicketOrder order = orderMapper.selectByOrderNo(orderNo); if (order == null || order.getStatus() != 0) { return; // 订单不存在或已支付,不能取消 } // 1. 先把订单置为已取消 int rows = orderMapper.updateStatusByCondition(orderNo, 0, 4); if (rows == 0) { return; } // 2. 释放数据库锁定库存 inventoryMapper.releaseLockedStock(order.getTicketTypeId(), order.getSaleDate(), order.getQuantity()); // 3. 回补Redis预占库存 redisTemplate.opsForValue().increment(buildStockKey(order.getTicketTypeId(), order.getSaleDate()), order.getQuantity()); } finally { lock.unlock(); } }

这个流程里最关键的细节是:先更新订单状态,再释放库存。如果反过来,先释放库存再取消订单,极端情况下会出现用户支付成功了,但因为订单被取消而无法出票,还占用着已经释放的库存,对账时彻底崩溃。

6. 线上踩过的坑:库存超卖、脏读和日期时区问题

最后的章节必须分享我在联调和上线阶段遇到的真实问题,很多是文档里查不到的经验。

6.1 压测发现超卖:问题出在事务没有提交就读取

系统上线前用JMeter做了并发压测,结果发现加了Redis预占和数据库乐观锁之后,依然出现了超卖。排查了很久,最后定位到问题不在扣减逻辑,而在库存余量查询接口。

余量查询的逻辑是:先查Redis预占数量,再查数据库已售数量,然后用总量相减。但在并发场景下,一个订单的数据库扣减还没提交事务,另一个线程就能读到扣减前的数据,导致余量查询结果虚高,用户看到有票,下单时却报库存不足。

这个问题最终通过调整事务隔离级别和查询顺序解决:余量查询直接查数据库的total_stock - sold_stock - locked_stock,不再依赖Redis的预占数量。Redis的预占数只在下单接口内部使用,不作为对外展示的数据源。

6.2 夜场票的"跨天"日期问题

夜场票的营业时间是18:00到次日凌晨1点,如果按自然日设计库存表,会出现"12月31日的夜场票"和"1月1日凌晨的场次"归属不清的问题。

我的解决方案是引入一个"营业日"概念:每天晚上20点之前,夜场票的sale_date填的是当天;晚上20点之后购买的夜场票,sale_date填的是次日。这样虽然用户看到的日期是"12月31日夜场",数据库中实际存的可能是"1月1日夜场",但完全不影响核销,因为夜场票的有效期是"购买当晚"。

这里唯一的坑是报表统计时,如果按sale_date分组统计,会把"12月31日晚上买的夜场票"统计到"1月1日"的数据里。需要在报表SQL里做一层映射,把20点以后购买的票归属到购买当天。

6.3 核销并发请求的串行化

闸机扫码核销的并发量虽然不大,但存在"同一张票在手机端和闸机端同时核销"的可能性。我通过一个分布式锁加数据库唯一索引的双保险解决:

public void verifyTicket(String ticketCode) { RLock lock = redissonClient.getLock("verify:lock:" + ticketCode); lock.lock(); try { // 先查核销记录,已核销的直接拒绝 TicketVerification existing = verificationMapper.selectByTicketCode(ticketCode); if (existing != null) { throw new BizException("该票已核销,请勿重复使用"); } // 插入核销记录,依赖唯一索引兜底 TicketVerification verification = new TicketVerification(); verification.setTicketCode(ticketCode); verification.setVerifyTime(LocalDateTime.now()); verificationMapper.insert(verification); // 同步更新订单状态为已核销 orderMapper.updateStatusByCondition(orderNo, 2, 3); } finally { lock.unlock(); } }

这个方案里,分布式锁保证了业务层面的串行,唯一索引保证了极端情况下的数据完整性。实际生产环境里,分布式锁可能因为Redis超时等原因失效,这时候唯一索引就是最后一道防线,这也是为什么我一直强调数据库约束比应用层判断更可靠。

7. 总结之外:这套设计的扩展可能

整个系统的核心已经讲完了,最后分享几个我觉得可以继续优化的方向。

如果雪场后续要做会员体系,可以在订单表上增加member_id和points_amount字段,在下单接口里加积分抵扣逻辑,对现有架构完全无损。如果要对接小程序扫码入园,只需要在核销接口上加一个开放API层,用AppId和Secret做签名认证就行。如果要接大屏实时数据看板,可以从数据库的binlog里同步销售数据到统计库,现有的事务逻辑不用动。

我个人最深的体会是:业务系统的设计不在于用了多牛的技术框架,而在于把业务约束理解透,然后用最简单可靠的方式去满足这些约束。Spring Boot本身不复杂,复杂的是"库存不能超卖""订单不能重复出票""支付回调必须幂等"这些看似朴素却暗藏玄机的业务规则。希望这篇文章能帮你少走一些弯路,特别是库存扣减那段,真的是拿线上事故换来的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询