做滑雪场售票系统这个项目,最早是因为一个做滑雪场运营的朋友吐槽:旺季大厅里挤满了人排队买票,雪具租赁还得另外开单子,账目经常对不上。聊了几次之后,我就用Spring Boot给他搭了一套售票系统。做完之后发现,这类“场地票务+资源预约”系统,套路非常固定,但坑也不少,而且和普通电商下单相比,滑雪场卖票有几个很特殊的点值得拆开讲。
这样一个系统能做什么?简单说就是四件事:线上选场次购票、支付后自动出电子码、进场扫码核销、后台管票价和场次库存。如果再算上雪具租赁,就是一套完整的场地运营工具。适合谁参考?首先是做毕业设计选了这个题目的同学,其次是那些在小团队里需要快速交付一个“能跑、能用、不出大错”的订单类系统的人。
我第一次设计的时候,心里其实没什么底。后来把核心流程理清楚,发现无非就是“库存-订单-支付-核销”这条主线,外加一堆管理功能。下面我把整个设计和实现过程,包括数据库怎么设计、库存怎么扣、回调怎么处理、踩了哪些坑,全部整理出来。
1. 需求拆解与整体设计思路
1.1 先搞清滑雪售票和普通电商卖货的差异
滑雪场售票和卖一件T恤最大的区别,在于库存维度和使用时间绑在一起。
普通商品卖的是“数量”,卖完就没了;滑雪场卖的是“某一天某个场次的某个票种的剩余名额”。比如周日上午场,全天票还剩80张,亲子票还剩20张,这俩互不冲突,因为同一个时段里,一个人只能占一个名额,但票种不同、库存却是共享同一片雪场的容量。设计之初如果没想通这一点,后面做库存扣减一定出问题。
第二个差异是票有强时效性。买一张电影票,你关心的是几号几点;滑雪票也一样,订单必须绑定具体场次,过期作废还是可以改签,这是业务规则上的决策点,系统里必须留出状态位来支撑。
第三个差异是核销场景。电商订单送到家就结束,滑雪票买完还要到现场验票。验票环节要么靠人工看手机上的码,要么闸机扫二维码。所以订单的支付状态和核销状态必须分开,我见过不少人把这两个状态混用一个字段,结果一到退票环节就说不清楚到底退没退入场资格。
1.2 功能模块怎么切分
从一个完整可交付的角度,我最终把系统拆成四个大模块,这也是比较稳妥的做法:
- 用户端模块:注册登录、场次查询、购票下单、支付对接、订单查询、电子票查看。
- 管理端模块:票价配置、场次管理、库存调整、订单管理、退票审核。
- 核销模块:扫码验票、状态标记、核销记录查询。
- 数据统计模块:按日/周/月的售票量、营收、场次售出率。
这种拆分方式的好处是前后端职责清楚,管理端和核销端即使不在同一个移动设备上也能独立运行。我在管理端用了Bootstrap搭配Thymeleaf,用户端用的是轻量H5页面,服务端完全提供RESTful接口,两端共用一套Spring Boot服务。
1.3 为什么是Spring Boot
选择Spring Boot没有太多纠结。首先团队里大家最熟的就是Java,生态里无论是MyBatis还是Spring Data JPA,资料都足够多。其次是Spring Boot的自动配置和内置容器让部署变得很简单,打一个jar包往服务器一扔就能跑,这对没有专职运维的滑雪场来说很重要。第三点是接支付渠道时,Java版本的SDK和示例代码最全,出问题好查。
如果说有犹豫的地方,那就是要不要上微服务。后来想明白:一个滑雪场的业务体量,单机Spring Boot完全扛得住,再大的吞吐也只是峰值期某个场次几百人同时抢票,加个Redis做库存预扣就够。微服务纯粹是给自己找麻烦。
2. 数据库设计与核心表结构
2.1 表的整体关系
数据库设计是整个系统的地基。我最终设计了八张表,核心关系可以用一句话概括:用户下单产生订单,订单关联商品(票种),票种绑定场次,支付成功后有支付记录,核销后有核销记录,雪具租赁挂在订单下面作为可选项。
具体表如下:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| user | 用户信息 | id, nickname, phone, password |
| session | 场次表 | id, date, start_time, end_time, capacity |
| ticket_type | 票种表 | id, name, price, limit_per_order |
| ticket_inventory | 场次票种库存表 | id, session_id, ticket_type_id, stock, version |
| orders | 订单主表 | id, order_no, user_id, session_id, total_amount, status, created_at |
| order_item | 订单明细表 | id, order_id, ticket_type_id, quantity, unit_price |
| payment_record | 支付记录表 | id, order_id, trade_no, amount, status, notify_time |
| rental_item | 雪具租赁表 | id, order_id, item_name, quantity, price, status |
2.2 两张容易被忽略的设计细节
第一,库存表我单独建了一张,而不是直接在票种表里写一个stock字段。原因是同一张票种可能在不同场次有不同的库存数量,单人票在周日上午场可能卖完了,下午场还剩很多。库存必须挂在场次和票种的组合维度下,这个组合就是ticket_inventory表的唯一键。
第二,订单状态字段我用了多个独立状态,而不是一两个大而全的状态。订单主表的status分为:待支付、已支付、已出票、已核销、已退票、已关闭。其中已支付和已出票在大多数实现里可以合并,但我还是分开存,因为从支付成功到二维码生成之间有一个异步过程,一旦二维码生成失败,我还需要能区分出“钱收了票没出”的中间态,方便做补偿处理。
创建表的时候还有一个容易踩的坑:金额字段一定要用decimal,不要用float。浮点数在涉及钱的时候会出现0.1+0.2不等于0.3这种问题,我当时因为这一点在退款对账时吃了一次亏,后来全部改成decimal(10,2)。
2.3 索引怎么建
订单表最终会以两个维度查询:用户查自己的订单(user_id + status),管理端查全部订单(created_at、status)。所以orders表建了两个联合索引:(user_id, status)和(created_at, status)。payment_record表以order_id为普通索引,同时trade_no单独建唯一索引,从而保证回调幂等时查得够快。
ticket_inventory表的唯一索引是(session_id, ticket_type_id),同时version字段是专门给乐观锁用的。这个version字段你不能省,它是后面解决库存超卖的关键,我在第4节会专门讲。
3. 核心功能与业务流程实现
3.1 购票下单主流程
用户从选场次到拿到电子票,完整链路大概是:
- 用户进入场次列表,选择日期和场次。
- 系统加载该场次下的票种和剩余库存。
- 用户选择票种、数量,提交订单。
- 服务端校验库存和用户登录态,创建待支付订单。
- 用户发起支付,服务端调起支付渠道,生成支付二维码。
- 支付回调处理成功后,订单状态改为已支付,生成电子票二维码。
- 用户到现场出示二维码,工作人员扫码核销。
这条链路里,第4步和第6步是最容易出问题的。下单时如果库存已经不够,直接返回“余票不足”,并且前端要实时刷新剩余票数。第6步生成电子票,我用了ZXing,把订单号和场次信息加密后拼成一个字符串,生成二维码图片存到本地磁盘,同时在数据库里只存二维码内容字符串,读取时再动态生成图片,避免存图片文件导致磁盘碎片化和备份困难。
3.2 创建订单的核心代码逻辑
创建订单接口是整个下单链路的核心。我用MyBatis,订单和明细在事务里一起插入。这里有一个关键点:先查库存,再尝试扣减,整个操作必须在事务里完成。
一个伪代码级别的实现思路如下:
@Transactional public OrderResult createOrder(CreateOrderRequest req) { // 1. 校验场次和票种是否存在且可售 TicketInventory inventory = inventoryMapper.selectForUpdate( req.getSessionId(), req.getTicketTypeId()); if (inventory == null || inventory.getStock() < req.getQuantity()) { throw new BusinessException("余票不足"); } // 2. 扣减库存 int updated = inventoryMapper.decreaseStock( req.getSessionId(), req.getTicketTypeId(), req.getQuantity(), inventory.getVersion()); if (updated == 0) { throw new BusinessException("余票已被抢空"); } // 3. 插入订单和明细 Order order = buildOrder(req); orderMapper.insert(order); orderItemMapper.insert(buildOrderItem(order.getId(), req)); return OrderResult.of(order); }这里selectForUpdate用的是悲观锁,也就是select ... for update,把这一行库存锁住再做扣减。在项目初期并发量不高时完全够用,逻辑也简单。后来我引入Redis做预扣减优化后,数据库层的悲观锁就退化成兜底方案,这样即便Redis宕机丢数据,数据库依然能保证不超卖。
3.3 支付回调与幂等处理
对接支付时,最怕的就是回调重复。支付渠道为了保证通知送达,会多次推送同一个支付结果。如果回调处理逻辑没有做幂等,订单状态可能被覆盖,甚至同一笔订单被二次处理生成两张电子票。
我的做法是在payment_record表里以trade_no做唯一索引,回调进来先尝试插入一条支付记录,如果插入报主键冲突,说明这条回调已经处理过了,直接返回成功。只有插入成功的那一次,才继续更新订单状态、生成电子票。
伪代码如下:
public PayResult handleNotify(PayNotifyRequest notify) { String tradeNo = notify.getTradeNo(); try { paymentRecordMapper.insert(buildRecord(notify)); } catch (DuplicateKeyException e) { // 已经处理过,幂等返回 return PayResult.success(); } orderService.markPaid(notify.getOrderNo()); ticketService.genTicket(notify.getOrderNo()); return PayResult.success(); }这一步是整个系统几乎最重要的防线。我测试的时候就试过模拟连续推送三次回调,如果不用幂等,第三次会把订单状态搞错,用户端显示已支付还好,但核销记录会出现三条,对账直接乱掉。
3.4 超时未支付订单怎么关
用户下了单不付款是常态,如果订单一直占用库存,场次的票就会被占死。我最后用了定时任务定期扫描,把超过30分钟未支付的待支付订单关掉,同时把占用的库存释放回ticket_inventory表。
定时任务写法不复杂,但有一个注意点:任务执行频率要设置合理。我最初5秒扫一次,数据库压力有点大。后来改成每30秒扫一次,每次只扫最近2小时内创建的待支付订单,并且用limit分批处理,避免一次update太多行导致锁竞争和主从延迟。
还有一种做法是用RabbitMQ延迟队列,下单时发一条延迟消息,30分钟后消费,检查订单状态,如果是待支付就关闭。如果项目里已经有消息中间件,这种方案更优雅,也不依赖数据库轮询。我当时考虑团队对中间件不熟,最终选择数据库定时任务方案,胜在简单可靠。
4. 库存并发的问题与优化
4.1 为什么纯数据库扣减会不够快
高峰期同一个场次的票,可能会有几百人同时抢购。如果每人都走select for update,数据库的行锁会让后面的请求排队。对于滑雪场这种量级,其实排队也就几百毫秒,但用户体验就是“页面一直在转圈”。
为了让用户点击购票后能快速得到结果,我在库存扣减前加了一层Redis预扣。流程变成:
- 下单请求进入,先从Redis里用incr扣减对应场次票种的预扣库存。
- 如果扣减后数量大于等于0,说明还有票,继续走数据库逻辑。
- 如果扣减后是负数,说明库存不够,返回余票不足。
- 创建订单成功、支付成功后,Redis里的预扣转成正式扣减;订单超时关闭时,再把扣掉的库存回补到Redis。
这套流程的核心好处是:把并发请求的压力从MySQL行锁转移到了Redis单线程命令上,incr命令天然原子,不存在超卖。Redis即使挂了,数据库悲观锁那个兜底逻辑依然能挡住超卖,最多就是系统性能短暂下降。
4.2 乐观锁和悲观锁怎么取舍
数据库层面,悲观锁实现简单,select for update直接把行锁住,业务代码里不用关心版本号。但并发高时锁等待比乐观锁明显。
乐观锁的做法是update时带上version条件:
UPDATE ticket_inventory SET stock = stock - #{quantity}, version = version + 1 WHERE session_id = #{sessionId} AND ticket_type_id = #{ticketTypeId} AND stock >= #{quantity} AND version = #{version}如果update的影响行数为0,说明version不匹配或者库存不够,重新查询后再重试或返回失败。但我个人最终采用的是“悲观锁兜底+Redis预扣”的组合方案,原因很简单:滑雪场系统的并发规模还不至于让行锁成为瓶颈,而Redis预扣能极大改善用户体验,兜底锁又能保证最终正确性,整体复杂性可控。
4.3 退款和退票时的库存回补
退款和退票是库存最容易出错的地方。退款表示钱退给用户,但票可能已经核销了一半,这时要不要回补库存?
我给系统定的规则是:
- 待支付订单直接关闭:库存立即回补。
- 已支付未核销,用户申请退款:库存回补,订单状态变为已退票。
- 已核销的票,走特殊退款审批:线下确认后由管理员手工回补库存,系统不自动处理。
回补库存时同样要加version乐观锁,因为回补操作可能和其他人下单的扣减同时发生。很多系统在这一步只做了“库存加回去”,没做并发控制,结果就是加回去的量和其他扣减相互覆盖,库存越算越错。
4.4 库存预扣数量的准确性
还有一个容易被忽略的细节:Redis预扣和数据库实际扣减之间有可能不一致。比如Redis扣了5张,但数据库插入订单失败,这5张就“飞”了。
我的解决思路是每次创建订单前,先从数据库读取一次库存,在创建订单的同一个事务里进行数据库扣减,如果扣减失败,同时回补Redis。这套流程说白了就是Redis负责“快速拒绝”,数据库负责“最终裁决”,两者结合才能既快又准。
5. 核销模块与雪具租赁
5.1 电子票设计与核销流程
电子票我采用“订单号+场次日期+场次时间段+票种编码”的拼接字符串,经过AES加密后生成二维码内容。好处是验票时不需要查数据库就能先解析出内容,快速判断这个二维码归属哪个订单。
核销接口的动作就两步:先根据二维码内容查出订单,再判断状态。只有已出票状态才能标记为已核销。已核销、已退票、已关闭的订单,都会返回具体提示语。
我还做了核销保护:核销请求必须带核销员ID,同一个订单同一时间只能有一个核销请求在处理,用Redis分布式锁保证不重复核销。这个在节假日高峰期特别有用,两个工作人员拿两个手持机同时扫同一个码,如果没有锁,就可能出现两次核销记录。
5.2 雪具租赁怎么和订单绑定
雪具租赁我设计成订单的附属项,用户下单购票时可以同时选择是否租赁雪板、雪鞋、头盔,租赁价格和押金单独计算。租赁表和订单表是一对一关系,在用户端下单时作为可选清单传入,管理端需要有归还状态维护。
这里特别提醒押金处理:押金不能混在门票总金额里一起支付,否则退款时要拆分,非常麻烦。我单独用一个字段记录押金金额,支付渠道里押金作为独立子订单支付,退款时单独退押金,门票部分按票状态退。很多第一版系统都是把押金和票价揉在一起,后面做对账时全是泪。
6. 常见问题排查与经验分享
6.1 我在实操中踩过的坑
第一个坑是时区问题。服务器时间默认是UTC,数据库连接串没配serverTimezone时,如果MySQL用的中国时区,存进去的订单创建时间会差8小时。用户看到“下单时间2030年”这种离谱数据,排查半天才发现是时区没对齐。这个问题诊断思路很简单:所有涉及时间的字段统一存时间戳,或者统一数据库、应用服务器时区,Java的时间类型用LocalDateTime,不要用Date。
第二个坑是定时任务重复执行。单机部署还好,但如果后期做成多实例部署,Spring自带的@Scheduled任务会在每个实例上都执行一遍,超时关单和库存回补就会重复。我后来引入了分布式调度锁,用一个数据库表记录任务执行状态,每次执行先抢锁,抢到才真正执行。
第三个坑是二维码图片缓存问题。我早期把二维码文件生成后直接返回图片路径,用户第二次访问时图片没变,但订单状态已经变了。后来我改成在二维码内容里加上随机数,每次请求动态生成,从根上避免缓存串单。
6.2 支付测试的注意事项
没有正式支付渠道时,我用的是沙箱环境或者本地模拟支付。模拟支付有个陷阱:人工触发“支付成功”回调时,如果直接调生产环境接口,要先把回调签名校验逻辑关掉,否则回调永远验签失败。
我建议刚开始开发时先走一个最简单的Mock接口,在后台写一个仅供测试的接口,手动触发订单支付成功,把这个流程跑通后,再替换成真实支付渠道的SDK和回调。
6.3 数据统计怎么做简单又实用
统计功能没必要一开始就上各种分析工具。我直接用SQL聚合,按天、按场次、按票种三个维度,把订单表的已支付金额和订单数汇总展示。MySQL的SUM和COUNT在这种量级下完全够用,跑一次也就几十毫秒。只有等数据量真的大了,再考虑用定时任务把统计结果预聚合到一张汇总表里,避免每次查询都全表扫描。
7. 项目扩展方向
系统做完之后,如果你想继续往深了做,有几个方向我个人觉得性价比很高:
一是对接真实支付渠道。把模拟支付的开关做成配置项,上线时切换到正式渠道,同时补充完整的退款、对账、差错处理流程。
二是做小程序端。用户端从H5迁移到小程序,扫码核销和购票体验会好很多,还方便做会员体系。
三是加短信或者模板消息通知。支付成功、出票成功、场次即将开始时各发一条通知,这个对用户体感提升非常明显,实现成本也不高。
四是做运营大屏。把当日售出率、实时营收、各场次热度投到一块大屏上,滑雪场管理方很喜欢这种功能,甚至可以作为演示亮点。
我个人在实际项目中体会最深的一点是:这类系统核心难点从来不在某一个技术点有多难,而在各种状态之间的一致性。订单状态、库存、支付记录、核销记录,四者必须始终对得上。只要把“库存怎么扣、回调怎么处理、状态怎么流转”这三件事想清楚,系统基本就稳了。
最后再分享一个小技巧:所有对外接口的返回值里加一个requestId,每笔请求都带上,日志里也打印出来。排查线上问题的时候,拿着这个ID一搜日志,整个请求链路就出来了,省掉大量猜疑时间。
如果你正准备做或正在做类似的场地预约售票系统,希望这篇文章能帮你把弯路走直。这套设计思路不光滑雪场用得上,换到羽毛球馆预约、景区门票、温泉票务这些场景,改改票种和场次字段,一样能跑起来。