ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

滑雪场售票系统实战:Spring Boot+Redis解决高并发库存与支付难题

滑雪场售票系统实战:Spring Boot+Redis解决高并发库存与支付难题

最近刚交付完一套滑雪场的售票系统,从需求梳理到上线跑数据,前后折腾了小两个月。滑雪场售票这件事,听起来就是个普通电商,真做起来坑比想象的多——高峰期抢票、临时退改、场次库存、雪具租赁关联,每一项都有可能把系统打穿。这篇文章把整个项目的设计思路和核心实现完整梳理一遍,重点说说技术选型、数据模型、库存防超卖、退票状态机和部署优化,全是实际踩过坑之后沉淀下来的结论,希望能给要做类似业务系统的朋友省点时间。

先说结论:这套系统用 Spring Boot 做主体框架,配合 MyBatis-Plus 操作 MySQL,Redis 扛热点和分布式锁,前端管理端走 Vue,部署用单机 Docker Compose。整体没有用微服务,单体应用足够支撑一个中型雪场全天的售票压力。具体为什么这么选,下面从业务需求开始讲。

1. 业务需求梳理与技术选型

1.1 滑雪场售票业务到底特殊在哪里

做系统之前先别急着建表,得搞清楚滑雪场售票和普通景区售票的本质区别。我接触到的这家雪场,业务上有几个非常鲜明的特点。

第一是波峰波谷差距极大。平时工作日一天也就两三百个订单,一到周末和寒假春节假期,单日订单量能冲到两三千单,瞬时并发远远超过普通景区。也就是说,系统平时负载很低,但高峰期的前几分钟如果扛不住,整个雪季的口碑就毁了。这决定了架构设计必须简单可靠,不能为了炫技搞太复杂的分布式方案。

第二是票种结构复杂。除了常见的单日票、季卡,还有时段票——比如上午场、下午场、夜场,每个场次的库存是独立的。再加上教练课、雪具租赁、储物柜、保险这些增值服务,订单的结构就不是单一商品那么简单,而是可能出现“一张滑雪票 + 两套雪具 + 一节教练课”这种组合订单。但组合逻辑又不能做得太重,否则下单链路会变得非常慢。

第三是业务规则变化频繁。雪场经常会因为天气、造雪情况临时调整开放场次,也会在节假日推出早鸟票、联票,价格策略需要灵活配置。这就要求票品和价格不能写死在代码里,要能通过后台快速调整,但不能影响已经生成的订单价格。

第四是对账和退改要求高。雪场门票的退改规则相对复杂,明确开滑前2小时可以免费退,开滑后只能改期不能退,季卡则有独立的冻结规则。这直接决定了订单状态机不能简单套用电商那些“待付款-已付款-已完成-已取消”的四态模型,要向更细的状态拆分。

把这些业务特点理清楚之后,系统的核心需求就很明确了:库存要准、下单要快、价格要活、退改要稳、数据要能对账。所有的技术选型都围绕这五个点展开。

1.2 为什么是 Spring Boot 这套组合

技术选型这块,我直接给结论,附上对比理由。因为这套系统本质上是业务管理系统,不存在海量数据、实时数仓、高并发消息队列这些极端场景,所以选型原则是“成熟、够用、好招人维护”。

Spring Boot 作为主框架是完全没有争议的,生态成熟、社区活跃、第三方库丰富。关键是持久层框架的选择,我在 MyBatis-Plus 和 JPA 之间犹豫过一阵。最后选 MyBatis-Plus,核心原因是这家系统的查询场景比较复杂,多条件动态查询特别多——按场次查订单、按手机号查用户、按时间段查核销记录,MyBatis-Plus 的 LambdaQueryWrapper 写起来非常顺手,对嵌套标签等细节的控制也比较稳妥。JPA 在复杂查询场景下反而容易生成低效 SQL,排查问题也更费劲。

数据库选 MySQL 8.0,InnoDB 引擎,这个没什么好说的。Redis 是必须上的,前面说过高峰期的瞬时流量很猛,场次库存、雪具余量这些热点数据必须走缓存,同时用 Redis 做分布式锁,避免并发下超卖。定时任务用的 Spring 自带的 @Scheduled,配合 Redis 延迟队列处理超时未支付订单。文件存储用的本地磁盘加 Nginx 做静态映射,没有额外接 OSS,因为雪场的图片就那几张,没必要为这点量引入额外依赖。

有一个点需要特别说明:考虑过微服务方案,用 Nacos 注册中心加网关那一套,但后来否掉了。原因很简单,整个系统的业务域就那么大,强行拆成订单服务、库存服务、用户服务,带来的分布式事务问题比收益大得多。单体应用的优点是逻辑内聚、事务好控制、部署简单,只要做好接口分层和模块划分,后续真要拆分也有清晰的边界。很多人一上来就套微服务,这其实是把简单问题复杂化了。

技术栈最终定为:Spring Boot 2.7 + MyBatis-Plus 3.5 + MySQL 8.0 + Redis 6 + Vue 2 + Element UI + Nginx + Docker Compose。JDK 用的 1.8,虽然 17 出了很久,但考虑到部署环境兼容性,1.8 还是最稳的选择。

1.3 功能模块的划分与用户场景

功能模块拆成两条主线:用户端和管理端。

用户端的场景是:游客在小程序里浏览票种、选择场次日期、下单支付、收到电子票二维码、到雪场闸机扫码核销、如果要退票则在线发起申请。这套链路涉及的核心模块是票品浏览、订单创建、在线支付、订单状态查询、退票申请。

管理端的场景更复杂一些:运营人员要维护雪道信息、场次库存、价格策略、优惠活动;客服要处理退票审核和改期操作;财务要看每日销售报表和退款流水;闸机管理员要用核销客户端扫描游客二维码并完成核销。管理端还包含一个容易被忽略但极其重要的模块——数据统计,要能按日、按周、按月查看售票量、核销量、退票量和营收金额,这些数据直接决定雪场第二天的开放策略。

用户角色方面,系统里设计了四种:普通游客、注册会员、内部员工、系统管理员。会员体系带来了一个额外的需求——储值余额支付,游客可以预先充值,下单时用余额抵扣。储值账户涉及资金安全,支付和退款必须走独立的流水表记录,后面会在订单状态机里详细展开。

2. 数据模型设计:给业务打好地基

2.1 核心表结构和字段设计

数据库设计是这套系统的重中之重,我直接分享经过实际业务验证的表结构设计思路。核心表包含:用户表、票品表、场次表、票种场次关联表、订单主表、订单明细表、支付流水表、核销记录表、退票申请表、储值账户表。

先说票品和场次的关系。票品是商品定义,例如“平日全天滑雪票”“周末夜场票”;场次是具体的时间段,例如“2025年1月18日 夜场 18:00-22:00”。两者是多对多关系,由中间表关联。为什么这么设计?因为同一个票种在不同日期可能是不同场次,同一个场次也可能对应多个票种,中间表天然能表达这种灵活性。库存字段放在中间表上,因为库存一定是针对“某天某场次某票种”这个组合的,放在票品表和场次表都不合适。

订单主表和明细表是典型的一主多明细结构。订单主表记录订单编号、用户ID、总金额、支付状态、订单状态、过期时间等;明细表记录每一行商品对应的票品ID、场次ID、票种名称、单价、数量、小计金额。这里有一个关键设计:订单明细里必须冗余场次日期、时间、票种名称原样拷贝一份,不能通过外键去关联查询。

订单主表会增加一个“游客信息”字段,用JSON格式存储体验人的姓名、身份证号、手机号。这样设计的考虑是:滑雪票常常是多人一起购买,但具体到每个人的实名信息在购票时就要确定,尤其是购买保险的场景。如果把游客信息拆成一张子表,订单查询时要多一次join,退票时也要连带操作,徒增复杂度。JSON字段在MySQL 8.0下已经被支持得很好,只要数据量没有大到需要大规模分析统计,这个设计是最实用的。

2.2 价格快照与历史订单不可变原则

价格设计上有一个非常重要的原则:订单一旦生成,单价就必须以快照形式写入订单明细表,后续价格调整绝不能影响已生成订单。

实际开发中确实踩过这个坑。最初版本的价格字段在订单明细里只保存了票种ID,查询订单详情时实时去票品表读取当前价格。后来运营在春节期间调了一次票价,结果所有历史订单的详情页显示的都是新价格,财务对账时发现营收金额对不上,排查了好久才发现是这个原因。后来把所有历史订单的价格改成了快照存储,也就是说,订单生成时把商品名称、单价原样复制到明细表里,之后无论票品表怎么改,订单数据都不会变。

这就类比到电商场景:你在买东西时看到的价格是下单那一刻确定的,之后商家调价不应该影响你已下的订单。放到系统设计里,就是“订单不可变”原则。除了价格,商品名称、场次时间、优惠政策信息也要同步做快照,这样既能保证订单展示的稳定性,也为后续退票时计算退款金额提供了精确依据。

2.3 库存模型与状态机设计

库存模型是整个系统最容易出问题的地方。先说设计:库存表放在场次票种中间表上,字段是总库存、已售数量、剩余库存,同时用一个版本号字段做乐观锁控制。高峰期售票时,库存扣减操作直接用受影响行数来判断是否成功,不额外加锁。

订单状态机最开始只设计了“待支付、已支付、已完成、已取消”四个状态,但业务方提出退改需求后明显不够用,最终扩展为七个状态:待支付、已支付、已核销、退票申请中、已退票、退款失败、已关闭。状态流转是严格单向的:待支付可以变成已支付或者已关闭(超时),已支付可以变成已核销或者退票申请中,退票申请中只能变成已退票或者退款失败。系统里所有状态变更都记录操作日志,这样财务在对账时有据可查,客服在处理纠纷时也能看到完整的流转轨迹。

3. 关键功能实现:从下单到核销的完整链路

3.1 库存扣减与超卖防护方案

库存扣减是售票系统的核心中的核心。超卖意味着雪场已经卖出去了 500 张票,但实际到场的游客有 520 人,这在真实运营中是绝对不能接受的事故。

防超卖方案我对比过三种:数据库乐观锁、数据库悲观锁、Redis 预扣库存。先说结论:最终采用的是“Redis 预扣库存 + 数据库乐观锁兜底”的组合方案。

单独用数据库乐观锁的问题是:在高并发下,一个票种同时有几十个请求抢库存,乐观锁会不断触发重试,数据库压力很大,而且每个请求都要走一次完整的下单链路,响应时间不可控。单独用 Redis 扣减库存的问题是:Redis 库存和数据库库存是两份数据,一旦出现不一致(比如 Redis 宕机、订单超时回滚逻辑有 bug),会造成实际库存和显示库存对不上,对账时会非常麻烦。

组合方案的核心流程是这样:

用户点击购买时,先走 Redis 接口检查并预扣库存。Redis 里存储的数据结构是场次票种组合的剩余量,预扣时用 DECR 原子操作,操作结果小于 0 则说明库存不足,直接返回“已售罄”。预扣成功后开始创建本地订单、调用支付接口。支付成功的回调里,再对数据库执行库存扣减,用乐观锁 UPDATE 语句带版本号,如果更新影响行数为 0 说明版本冲突,则执行补偿回滚。如果用户下单后一直不支付,系统会在 15 分钟后通过定时任务扫描 Redis 延迟队列,把预扣的库存加回去,同时将订单状态改为已关闭。

这里有一个容易忽略的细节:Redis 预扣的库存要和数据库的剩余库存保持逻辑一致。每次后台调整库存时,要同时刷新 Redis 缓存,否则会出现后台调库存但前台不生效的问题。我在项目里做了一个库存同步工具类,每次后台改库存后自动重建该场次票种的 Redis 缓存,从根源上避免两套数据不一致。

3.2 支付回调与幂等性处理

支付回调是对接微信支付时最让人头疼的部分,因为回调通知可能会重复推送。微信支付官方文档明确说明:支付结果通知可能会在网络异常时重试发送,最多会推送多次。如果回调处理逻辑不做幂等,一个订单被重复执行两次支付成功更新,就会出现资金流水多记、库存重复扣减的问题。

我的处理方式是三步:

第一步,回调接口收到通知后,先去订单表查询当前订单的支付状态。如果状态已经是“已支付”,直接返回成功给微信支付服务器,不做任何后续逻辑。这是最基础的幂等保障。

第二步,状态变更为“已支付”的过程用数据库 UPDATE 语句带条件:UPDATE order SET status = 'paid' WHERE order_no = ? AND status = 'pending'。这条语句在数据库层面保证了只有一个请求能把待支付改成已支付,即使两个回调同时到达,也只有一个能成功更新行数。

第三步,订单状态更新成功后,把支付成功的消息发送到 Redis 延迟队列,由订阅者异步处理后续任务——比如扣减库存、发放电子票、发送短信通知。这一步不能放在回调接口同步执行,因为如果短信服务响应慢,会导致回调接口超时,微信支付会误判为未收到通知而反复重试。把耗时操作异步化之后,回调接口的响应时间能稳定控制在 200 毫秒以内,实测几乎不会触发重试。

支付对账方面,每天凌晨跑一次定时任务,把微信支付账单和本地支付流水表做比对,金额不一致的记录单独标记,财务人员上班后只需处理对不上的那几笔,而不是全部流水。上线第一个月就靠这个任务发现了一次回调丢失,金额精确对上了,省去了大量人工排查时间。

3.3 退票、改期与订单状态流转

退票是售后环节里最容易产生客诉的功能,设计不好就会出现“用户申请退款了,但票还能核销入场”的漏洞。

退票流程设计为两步:用户提交退票申请之后,订单状态变成“退票申请中”,同时立即锁定该订单对应的所有票码,此时就算用户拿着二维码去闸机扫码,也会被提示“该票已申请退票,无法入场”。这一步非常关键,很多系统的漏洞就在于退票申请已提交但票码没有及时失效。锁定票码之后,系统自动触发退款到原支付渠道,退款成功则状态变为“已退票”,退款失败进入“退款失败”状态,由客服介入处理。

改期逻辑比退票简单,但有个硬性条件:只能改到未来场次,且目标场次必须有足够库存。实现方式是生成一张新票码、作废旧票码,原订单的金额不变,只是关联的场次信息和票码变了。改期必须限制次数,否则会出现一个人反复改期占住多个场次的库存,最终只来一次的情况。系统限制每张票只能改期一次,改期后的票不能再改,只能退,这个规则在代码和产品文档中都要明确。

整个状态流转的代码实现里,我写了一个订单状态机工具类,所有状态变更都走独立的 changeStatus 方法。这个方法接收订单ID、目标状态、操作人、操作备注,内部先查当前状态是否允许流转到目标状态,允许则更新并记录操作日志。这个设计保证了状态流转逻辑全部集中在一个地方,不会出现某个功能悄悄绕过状态机直接改状态的情况。

3.4 核销码与防重复扫码机制

核销码是游客入场凭证,本质是一串唯一下发的码,线上订单生成后由后台算法算出,关联订单明细和场次,同时以二维码形式展示给用户。核销码生成规则我用的是“随机字符串 + 校验位”的组合。随机字符串保证不可猜测,校验位用于在断网等特殊情况下人工输入核销码时也能快速验证格式是否正确。

核销端的核心是防重复扫码。闸机管理员用核销客户端扫描游客二维码后,系统先检查核销码是否存在,再检查该核销码对应的订单状态是否为已支付,最后检查该核销码是否已经被核销过。当多次扫描同一核销码时,第二次操作要根据核销状态给出明确的错误信息。实际开发中发现,单纯靠代码判断状态是不够的,还要在数据库层面加唯一约束,防止并发请求下两个闸机同时扫到同一个码造成重复核销。核销操作会记录核销员ID、核销时间、核销设备信息,后续运营统计各时段入园人数时直接按核销记录分组查询即可。

4. 部署上线与性能调优

4.1 单机 Docker Compose 部署方案

部署方案没有上什么高端的容器编排平台,一台 4核8G 的云服务器加 Docker Compose 就足够了。项目分为四个容器:MySQL、Redis、后端应用、Nginx。四个服务统一由 docker-compose.yml 管理,一条命令全部启动。

MySQL 容器挂载宿主机数据目录,保证容器重建后数据不丢。Redis 开启持久化,使用 RDB 快照加 AOF 日志的双重策略。后端应用打包成 jar 后构建镜像,启动参数里通过环境变量注入数据库地址、Redis 地址、支付秘钥等敏感配置,不写死在代码和镜像里。Nginx 负责三件事:托管前端静态文件、反向代理后端 API、配置 HTTPS 证书。

这里有一个部署细节需要注意:Docker Compose 里的服务名可以直接作为应用配置文件里的主机名,比如数据库地址写成 mysql:3306 而不是 127.0.0.1:3306,这样容器重启后 IP 变化也不会影响应用连接。第一次部署时踩过这个坑,当时图省事直接写了公网IP和端口,结果 MySQL 容器重启后应用就连不上了。

4.2 热点数据缓存与连接池调优

数据库连接池用的是 HikariCP,Spring Boot 默认集成,关键参数做了调优:maximum-pool-size 设置为 20,minimum-idle 设置为 5,connection-timeout 设置为 30000 毫秒。这个参数的设定参考了压测结果,下面会详细讲。

缓存策略上主要缓存三类数据:场次票种库存量、票品详情、首页运营配置。库存量缓存到 Redis,缓存时间设置为 60 秒,后台变动库存时主动删除缓存键,让下次请求重新从数据库加载。票品详情是读多写少的数据,缓存 5 分钟,运营通过后台修改后主动清除。这里要特别说一个缓存穿透问题:如果一个场次的票种在前端被频繁查询但实际不存在,每次都会穿透到数据库。解决方案是缓存空值,把不存在的票种 ID 也缓存起来,过期时间设置短一些(比如 5 分钟),流量高峰期实测效果好不少。

4.3 压测数据与容量规划

上线前进行了 JMeter 压测,核心场景是模拟并发购买同一场次的滑雪票。压测结果放在这里供参考:4核8G 配置下单机Spring Boot应用,HikariCP 连接池 20 个连接,Redis 单节点,压测 100 个并发线程循环下单,接口成功率保持 99.9% 以上,平均响应时间 420 毫秒,P95 响应时间 780 毫秒。200 个并发时开始出现少量超时,响应时间上升到 1.2 秒左右,但无错误请求。这个数据对一个小型雪场完全够用——就算春节期间瞬时涌入 1000 人同时抢票,通过 Nginx 层限流和队列削峰,实际打到后端的并发也不会超过 200。

容量规划的结论是:单机应用不需要上微服务,但有几个基础保障必须做好。数据库开启慢查询日志,阈值 1 秒;Redis 设置合理的淘汰策略(allkeys-lru);后端应用配置有序的内置线程池处理异步任务;所有对外接口设置统一的响应体格式和错误码。做到这几点,单机应用撑起一个中型雪场的售票业务没有压力。

5. 踩坑记录与问题排查实录

5.1 时区问题导致场次日期错乱

生产环境第一次有点真实流量之后,运营反馈说用户购买的“夜场票”显示日期差了一天,比如买的是 1 月 18 日夜场,系统显示核销日期是 1 月 19 日。排查后发现是时区配置出了问题。

服务器系统时区设置为 UTC,MySQL 连接串里没有指定 serverTimezone,Spring Boot 序列化日期时又默认按东八区处理。三重叠加导致日期时间在存储和读取时发生了偏移。解决方法是同时做三件事:服务器时区统一改为 Asia/Shanghai,MySQL 连接串增加 serverTimezone=Asia/Shanghai,Jackson 序列化配置指定日期格式和时区。这三步缺一不可,否则总会有一个环节在某个时刻偷偷把时间改掉。

5.2 并发超卖:一个凌晨的告警电话

有一个周末凌晨,运营打电话说系统显示“已售罄”的雪票还能下单成功。排查日志发现是预扣库存和数据库扣减库存之间出现了一个时间窗口漏洞。当时是这样一个问题:Redis 预扣用的是 DECR 操作,当 DECR 结果等于 0 时,说明这是最后一个库存,允许下单。但此时数据库的剩余库存可能已经是 0,因为之前的订单已经把数据库库存扣完了。也就是说,Redis 和数据库两边的库存扣减时序没有严格同步,Redis 里显示还有 1 个,数据库里实际是 0。

解决思路分两步:第一,在数据库库存扣减时,UPDATE 语句带剩余库存条件(UPDATE ... WHERE remaining_stock > 0),确保数据库层面不会扣成负数;第二,Redis 预扣成功后,在生成订单前查一次数据库剩余库存做二次确认,如果数据库剩余已经为 0,则回滚 Redis 预扣并返回售罄。双保险做完之后,再没有出现过超卖问题。

5.3 支付回调偶发丢失与对账发现

微信支付回调理论上很可靠,但实际运行中还是会偶发丢失回调通知的情况。一次对账任务发现某笔订单已经扣款成功,但本地订单状态还是“待支付”,用户因此没有收到电子票。

分析原因是:用户完成支付后,微信回调到服务器时恰好遇到后端服务发版重启,回调请求连接被重置。微信支付虽然会重试,但重试间隔是 15 秒、15 秒、30 秒这类递增策略,也确实会重试成功。可问题在于,服务重启后第一次回调成功处理了订单,本地状态正常更新,但用户那把票因为等待回调时被放进了一个补偿队列,补偿逻辑把订单状态从已支付误判为待支付给关掉了。

排查清楚后,加了一个规则:关闭订单的超时任务必须二次检查订单当前状态,只有状态仍然是“待支付”时才能关闭。另外在支付回调接口里加了一个兜底逻辑:如果收到微信的支付成功通知,但本地订单状态已经是已支付,则不改变状态,只更新支付流水信息。这套组合拳之后,类似问题再没有出现。

5.4 热点 Key 引发的缓存瞬时崩溃

春节假期前一天,运营发布了次日所有票种半价的促销活动,活动页面顶部有“热卖推荐”模块,倒计时按钮会每秒请求一次库存接口。结果这个热卖票种的库存 Key 在 Redis 里被每秒查询上百次,虽然 Redis 扛得住,但 Key 的过期时刻恰好是高峰期,导致瞬间大量请求同时穿透到数据库,数据库连接数被打满。

解决方式用到了缓存击穿的经典方案:互斥锁重建缓存。当某个 Key 过期时,只有一个请求能获得重建锁并去数据库查询,其他请求短暂自旋等待后读取新缓存。实测这个方案把数据库的连接峰值降低了大约 70%,整个促销期间没有再出现过数据库连接打满的情况。

写在最后的实战感受

做完整个项目,我最大的感悟是:业务系统的复杂度从来不在技术栈,而在业务规则本身的细致程度。这个系统技术上没有用到任何高深的东西,Spring Boot、MySQL、Redis 都是最基础的组件,但真正花时间的是理解滑雪场的运营逻辑——什么时候会有人退票,什么时候会有人反复改期,季卡用户的核销规则和普通散客有什么不同。这些业务理解最终都会落地到状态机、库存模型和接口设计上,决定了系统是真正能用起来,还是只是一个演示 Demo。

如果你准备做一个类似的售票或预约类系统,我的建议是先花两周时间整理业务规则。把订单状态、退款条件、库存扣减时机这三件事彻底想清楚再动手写代码。代码实现永远是最快的部分,业务流程理不顺才是后面返工和事故的根源。

最后分享一个小技巧:订单号要用带有业务含义的编码规则,比如时间戳加随机数加业务类型前缀,不要直接用数据库自增 ID 或者纯 UUID。自增 ID 太容易预测,可能被恶意遍历抓取订单数据;纯 UUID 对账时完全看不出业务含义。我用的规则是“日期 + 业务类型 + 随机数”,例如 2025011812300012001,既能保证唯一性,客服在电话里和用户确认订单号时也能快速识别是哪天的单子。这个小细节在后续运营和客服工作里省了不少事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进