接手海洋馆门票线上销售平台的第一周,我几乎每天都在问自己一个问题:一个听起来只是"卖票"的后端系统,为什么会同时牵涉库存、场次、支付、退改、限购这么多规则?后来我才想明白,票务系统的本质不是电商交易,而是"一张入场凭证在固定时间窗口内的分配与回收"。这篇博文围绕我用 Spring Boot + Java 搭建这一套 springboot-java 海洋馆门票线上销售平台的过程展开,会讲清楚数据模型怎么设计、购票链路怎么做防超卖、支付回调怎么保证幂等,以及上线前最容易踩的坑在哪。适合有 Java 基础、想完整做一个真实业务系统的开发者,也适合正被景区票务需求困住的后端同行。
1. 门票不是"商品"是"时段资源":从业务规则到数据建模
做系统之前,我花了不少时间听场馆运营方讲业务规则。听完就一个感受:如果直接照搬普通电商的"商品-订单-支付"模型,这个项目后面一定会返工。因为门票这类商品,天然带着时间维度和容量约束。
1.1 海洋馆门票与普通电商商品到底差在哪
普通电商卖手机,库存模型很简单:总数进库,卖一单减一,直到为0。但海洋馆门票卖的是"某一天某一个场次的进场名额"。这里至少有三个维度要同时成立:
- 日期维度:今天是5月1日的上午场票,和5月2日的上午场票,是两种完全不同的库存资源。
- 场次维度:上午场、下午场、夜场,各自的承载上限和销售节奏都不一样,上午场卖光了不代表下午场也卖光。
- 票种维度:成人票、儿童票、老人票、家庭套票,价格不同,退改政策也可能不同。
更麻烦的是,海洋馆是一个有安全承载量的实体场馆。系统里的可售票数并不是"我想放多少放多少",而是要和场馆瞬时承载量、表演场馆座位数、排队动线容量对齐。这意味着系统必须把"硬上限"落到数据约束里,而不是靠运营口头控制。
再叠加退改规则,复杂度就起来了。常见的规则包括:某类票在入园前可免费退、某类特惠票一经售出不可退、同一订单只能整单退不能部分退、改期只能在限定天数内操作。这些规则如果不在数据模型里想清楚,最后都会变成业务代码里一层又一层的 if-else,维护起来非常痛苦。
我接手时,旧系统就是一张大表塞了所有字段,结果每逢节假日,运营改一个票种规则,开发就得跟着改一堆判断分支。这次重做的时候,我们把"规则"尽量下沉到数据配置层,而不是散落在代码里。
1.2 把运营规则翻译成表结构
我最终的设计里,核心表并没有只有一张"票表",而是拆成了场次、库存日表、订单、订单明细、支付流水、退款流水这几个部分。这不是为了表多显得专业,而是因为它们的更新频率和生命周期完全不同。
场次表存的是相对静态的信息:场次名称、开始时间、结束时间、最大承载量。库存日表则是动态高频更新的:某一天某个场次总共放了多少票、已经卖了多少。两者拆开,目的是让热数据和冷数据不要互相干扰。
下面是简化后的表结构,实际字段会多一些,但骨架是这样:
-- 场次表 CREATE TABLE t_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_name VARCHAR(32) NOT NULL, -- 上午场/下午场/夜场 start_time TIME NOT NULL, end_time TIME NOT NULL, max_capacity INT NOT NULL, -- 该场次最大承载量 status TINYINT NOT NULL DEFAULT 1 -- 1可用 0停用 ); -- 库存日表 CREATE TABLE t_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_date DATE NOT NULL, -- 营业日期 session_id BIGINT NOT NULL, total_quantity INT NOT NULL, -- 该日该场次总票量 sold_quantity INT NOT NULL DEFAULT 0, -- 已售数量 status TINYINT NOT NULL DEFAULT 1, -- 1可售 0锁场 UNIQUE KEY uk_date_session (biz_date, session_id) ); -- 订单表 CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL, user_id BIGINT NOT NULL, biz_date DATE NOT NULL, session_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL, -- 状态机见正文 create_time DATETIME NOT NULL, pay_time DATETIME NULL, expire_time DATETIME NOT NULL, -- 待支付超时时间 UNIQUE KEY uk_order_no (order_no) ); -- 订单明细表 CREATE TABLE t_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL, ticket_type_id BIGINT NOT NULL, -- 票种ID ticket_name VARCHAR(32) NOT NULL, unit_price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL ); -- 支付流水表 CREATE TABLE t_payment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, payment_no VARCHAR(40) NOT NULL, order_no VARCHAR(40) NOT NULL, channel VARCHAR(20) NOT NULL, amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL, -- SUCCESS/FAILED/PENDING callback_time DATETIME NULL, UNIQUE KEY uk_payment_no (payment_no) );这个设计里有一个容易被忽略的点:t_order和t_stock都冗余了biz_date + session_id。为什么?因为绝大部分查询、报表、对账都围绕"某天某场次"展开,冗余这两个字段能大幅简化查询逻辑。代价是冗余需要靠定时对账来兜底,这一点在第6章会专门讲。
2. 技术栈与工程骨架:为什么这套 springboot-java 组合能落地
技术选型阶段,其实也有人建议用 Node.js 或者 Python 快速出活。但综合团队情况、生态成熟度和交付节奏,最后还是落在 Spring Boot + Java 上。这个选择不是因为它是最酷的,而是因为它对这个场景足够"稳"。
2.1 技术选型时对比过的几条路
我把当时对比的几个主流方案放在一起看:
| 方案 | 优势 | 顾虑 |
|---|---|---|
| Spring Boot + Java | 事务管理成熟、支付对接资料多、排查问题生态全 | 启动略重、初期配置多 |
| Node.js + Express | 上手快、原型开发快 | 事务与定时任务生态相对分散 |
| Python + Flask/FastAPI | 代码量少、适合小流量 | 高并发场景需要更多外围组件 |
票务系统绕不开"强一致"这个词:下单扣库存要在一个事务里,支付回调要防重复入账,超时关单要回补库存。这些场景下,Spring 的声明式事务 @Transactional 和成熟的数据库连接池方案,能让我把精力集中在业务规则上,而不是去组装一堆外围库。另外,和第三方支付渠道对接时,Java 这边的 SDK 和示例代码最全,踩坑的帖子也多。
有的读者可能会问,为什么不直接上微服务?我的答案是:业务规模还没到那一步。这个项目一开始就是单体应用,但在包结构上留好了拆分边界。等哪天某个模块(比如票务库存)访问量明显超出其他模块,再单独把它拆出去也不迟。
2.2 工程结构与模块边界
工程结构我按业务域划分包,而不是按技术层划分。区别在于:按 controller/service/mapper 分层容易让代码"越权",比如 controller 里写复杂业务逻辑、service 里直接拼 SQL。按业务域划分,能让人一眼看出某个功能该去哪改。
最终大致是这样的包结构:
com.example.aquarium ├── controller # 接口层:参数校验、返回封装 ├── service # 业务层:事务边界在这里 ├── repository # 数据访问层 ├── domain # 领域对象 ├── common # 统一返回体、异常、工具类 ├── config # Redis、线程池、支付等配置 ├── job # 定时任务:超时关单、库存对账接口层我用了一个统一的返回体Result<T>,包含 code、message、data 三个字段。所有接口成功返回 code=0,业务异常通过自定义异常抛出,由全局异常处理器统一转成响应。这样做的好处是前端对接时只需要处理一种结构,不用每个接口单独约定。
2.3 做减法的勇气
我见过不少项目一上来就规划了分库分表、分布式事务、消息中间件,最后发现大部分组件根本没被用上,反而增加了运维负担。这个海洋馆票务系统的真实访问量,即使在节假日抢票高峰,核心下单接口的 QPS 也很难破千,因为场馆容量决定了可售票数上限。与其盲目上重型架构,不如先把单库单表、MySQL 事务、Redis 缓存这套最基础的组合用扎实。
我的结论是:"能不能扛住"比"听着高不高级"重要得多。这套组合在真实投入运营后,顶住了数次节假日高峰,没有出现过一次库存错乱,已经说明问题。当然,这不代表可以不做并发控制——只是并发控制不需要分布式事务,用数据库行锁和条件更新就足够了。
3. 核心模型与状态流转:订单、库存、支付的三角关系
整个系统里最怕出问题的,就是库存、订单、支付三方状态不一致。我把它们的流转规则一条一条列清楚,再在代码里落实成条件更新,而不是"先查再改"。
3.1 库存扣减:把它写成一条 SQL 而不是一段逻辑
刚开始写代码时,最容易出现的错误是这样的流程:
Stock stock = stockMapper.findByDateAndSession(bizDate, sessionId); if (stock.getSoldQuantity() + buyCount <= stock.getTotalQuantity()) { stock.setSoldQuantity(stock.getSoldQuantity() + buyCount); stockMapper.updateById(stock); // 生成订单 }这个写法在低并发下没问题,但一旦两个人同时读到同一个 soldQuantity,判断都通过,就超卖了。正确做法是让数据库来决定是否扣减成功:
UPDATE t_stock SET sold_quantity = sold_quantity + ? WHERE biz_date = ? AND session_id = ? AND sold_quantity + ? <= total_quantity这个 UPDATE 的受影响行数如果是 1,说明扣减成功;如果是 0,说明库存不足或场次不可售。整个过程不需要在 Java 层加锁,性能和安全都兼顾了。
为了进一步降低数据库压力,我在下单链路里还加了一层 Redis 预扣。用户提交订单时,先用一个 Lua 脚本在 Redis 上原子扣减余票,扣成功才走数据库下单;支付超时或用户取消时再回补。这样热点场次的库存扣减不会全部打到 MySQL 的同一行上。
需要注意,Redis 预扣和数据库扣减之间天然存在不一致窗口。比如 Redis 扣了但数据库事务失败,或者 Redis 宕机丢了数据。所以必须有一个定期对账任务把真实库存"校准"回缓存,这个在第6章展开。
3.2 订单状态机的严格流向
订单状态我用了字符串枚举,因为可读性好,日志里排查也直观:
- PENDING_PAYMENT:待支付
- PAID:已支付
- USED:已使用(闸机核销后)
- CANCELLED:已取消
- REFUNDED:已退款
- EXPIRED:已过期
状态流转只有几条路径:
PENDING_PAYMENT -> PAID (支付成功) PENDING_PAYMENT -> CANCELLED (超时或主动取消) PAID -> USED (入园核销) PAID -> REFUNDED (售后退款) PAID -> EXPIRED (过了有效期未使用)所有状态更新都应该是"带条件的 UPDATE",绝不要"先 select 状态,if 判断,再 update"。比如支付成功就是这样:
UPDATE t_order SET status = 'PAID', pay_time = NOW() WHERE order_no = ? AND status = 'PENDING_PAYMENT'受影响行数为 0,说明订单已经不是待支付状态,可能是已取消,也可能是已退款。这时候支付回调绝不能把订单强改成已支付,而是要进入异常处理分支。
3.3 支付流水:幂等与对账的钩子
订单状态能准确流转,离不开支付流水表。我把支付流水表的主键约束设计成payment_no唯一,相当于为支付回调的幂等性加了一把锁。
支付回调的处理流程,我总结为一个固定模板:
- 根据 payment_no 查支付流水。
- 流水已经是 SUCCESS,直接返回成功给渠道方,不重复处理业务。
- 流水不存在或不是 SUCCESS,再查订单状态。
- 订单是 PENDING_PAYMENT,则更新订单为 PAID,同时更新流水为 SUCCESS。
- 订单不是 PENDING_PAYMENT,则记录异常回调,转人工或自动退款。
有人问,为什么先查流水再查订单,顺序不能反过来?因为支付流水是渠道方直接回调的唯一凭证,用它做入口最不容易漏。而订单状态可能因为超时任务被改成 CANCELLED,如果先查订单会干扰判断。以流水为锚点,逻辑最清晰。
4. 购票链路逐段拆解:从刷余票到结算完成的每一步
这一章是系统的主业务链路,我按用户实际操作顺序拆开讲,中间藏着不少只靠查文档发现不了的细节。
4.1 余票查询:缓存长什么样
用户打开海洋馆购票页,第一件事就是看"今天还有没有余票"。这个接口是典型的读多写少,必须走缓存。我的 key 设计是:
stock:{bizDate}:{sessionId}value 就是该场次当前剩余票数。同时把"某日是否可售"这种低频状态放在另一个 key 里,避免把所有信息堆在一个 key 上导致频繁失效。
这里要特别提醒:不要只缓存"有余票"的场次,也要缓存"已售罄"的场次。否则售罄场次一旦被大量用户刷新,所有请求都穿透到数据库,非常难受。我在 Redis 里对已售罄场次也设了 key,value 为 0,并设置较短的过期时间,让库存回补后能尽快恢复显示。
4.2 提交订单:预占库存的事务边界
用户填好票种和数量,点击提交订单。这一步的核心是:在同一个事务里完成库存扣减、订单生成、支付单生成三个动作。伪代码如下:
@Transactional(rollbackFor = Exception.class) public OrderResult submitOrder(SubmitRequest req) { // 1. Redis 预扣余票,失败直接返回库存不足 boolean locked = stockCache.tryDecr(req.getBizDate(), req.getSessionId(), req.getQuantity()); if (!locked) { return OrderResult.fail("该场次余票不足"); } try { // 2. 数据库条件扣减库存 int rows = stockMapper.decrStock(req.getBizDate(), req.getSessionId(), req.getQuantity()); if (rows == 0) { throw new BizException("该场次余票不足"); } // 3. 生成订单,状态 PENDING_PAYMENT,过期时间 15 分钟后 Order order = createOrder(req); // 4. 生成支付流水,状态 PENDING createPayment(order); return OrderResult.success(order); } catch (Exception e) { // 事务回滚时,回补 Redis 预扣 stockCache.incr(req.getBizDate(), req.getSessionId(), req.getQuantity()); throw e; } }这里有一个容易被忽略的点:Redis 预扣和数据库事务不在同一个事务里,所以数据库操作失败时,必须手动回补 Redis。回补操作本身也要设计成可重复执行的,因为异常重试时可能会回补两次。我的做法是记录一条"库存操作流水",用唯一键避免重复回补。
4.3 支付回调:重复通知与订单已取消的并发处理
支付回调是渠道方在用户完成支付后,主动通知我们服务器的请求。这个请求有三个特点:可能重复、可能乱序、可能延迟到订单超时取消之后才到。
处理回调时我最担心的一种情况是:用户下单后一直没支付,15分钟后定时任务把订单取消了。就在取消的同一瞬间,用户完成了支付,渠道方的回调也到了。这时候如果回调处理逻辑不够严谨,就会出现"订单状态已取消,但钱已经扣了"的尴尬局面。
我的处理原则是:支付成功以回调为准,订单状态作为辅助校验。只要支付流水没有被成功处理过,且订单仍处于待支付状态,就允许把订单改成已支付。如果订单已经被取消,则立即走"自动退款 + 标记异常支付"的流程。这个分支虽然少见,但一定得写。
4.4 超时关单与库存回补
超时关单我用定时任务实现,默认每 60 秒跑一次,扫描所有待支付超过 15 分钟的订单。为了避免一次性处理太多导致数据库抖动,每次只取 100 条,处理完再取下一批。
关键点是关单和回补库存的逻辑要"可重复执行"。定时任务扫描到一个待支付订单,先执行:
UPDATE t_order SET status = 'CANCELLED' WHERE order_no = ? AND status = 'PENDING_PAYMENT'只有这一句的受影响行数为 1,才继续回补库存。如果受影响行数为 0,说明订单状态已经被别的流程改掉了,定时任务直接跳过。这样即使任务重复调度,也不会重复关单、重复补库存。
5. 应对热场抢购的三板斧:缓存、限流、异步削峰
海洋馆平时的在线人数不算夸张,但遇到节假日、特惠票开售,流量会在几分钟内冲上来。这一章讲的就是在这种突增场景下,我实际采用的三个手段。
5.1 热点 key 的缓存维护
抢购开始前,运营会在后台设置好未来几天的场次和库存。这时候我会有一个预热任务,把未来 3 天所有可售场次的余票提前写入 Redis,key 就是前面说的stock:{bizDate}:{sessionId}。
缓存维护有一个关键问题:什么时候更新缓存?我的方案是:下单事务完成库存扣减后,同步更新 Redi s。如果更新失败,则不阻塞主流程,后续由对账任务校准。这样做的好处是查询接口延迟极低,写接口的性能也不会被缓存拖累。
这里踩过一个坑:如果只在数据库扣减后同步更新缓存,一旦 Redis 更新失败,缓存里的余票数就比数据库多。用户看到有票,提交订单却提示库存不足,体验很差。所以哪怕同步更新失败了,也要有补偿机制,把这条记录标记为"待校准",交给定时任务处理。
5.2 接口限流与用户排队
接口限流我用了两层:
- 第一层是全局限流,比如整个下单接口每秒最多处理 300 个请求,超出的直接返回"系统繁忙"。
- 第二层是用户维度限流,比如同一个用户 30 秒内只能提交一次订单,防止脚本疯狂刷单。
限流算法我用的其实是最简单的令牌桶,并没有上特别复杂的框架。对于单机部署,甚至可以在本地内存里维护令牌;多机部署时需要把计数器放到 Redis。考虑到这个项目的部署规模,单机 + Redis 计数器已经完全够用。
遇到特惠票抢购,还会出现用户集中刷新页面的情况。前面的余票查询接口如果被打穿,会比下单接口更早出问题。所以我在查询接口上也加了限流,同时在限流触发时返回一个"排队中"的提示,让用户稍等自动重试。这个方案不复杂,但能保护核心链路不被打挂。
5.3 写请求的异步化边界
抢购场景下,真正危险的不是查询,而是每分钟几千笔写请求同时打到数据库。下单接口本身要写订单表、订单明细表、支付流水表、库存表,一个事务里有好几张表的写操作。如果瞬时并发太高,数据库连接池和处理线程都会被打满。
我的做法是把"提交订单"这个动作拆成两段:第一段只做参数校验 + Redis 预扣库存,成功就返回"订单受理中"给前端;真正生成订单、写库的事务放到异步队列里执行。前端拿到受理成功的结果后,轮询订单状态接口,等订单真正生成并拿到支付链接。
这个"先占坑、后落库"的模式牺牲了一点实时性,但换来了极高的吞吐量。注意异步环节是天然的失败点,所以必须配合失败重试和超时告警。如果队列积压太多,就算 Redis 预扣成功,订单也迟迟生成不出来,用户就会以为自己买到了票,却一直没有收到支付链接。我在线上设置了一个阈值:队列积压超过 500 条就触发告警,并临时关闭下单入口,保护用户体验。
6. 上线前后最容易翻车的几个细节
技术方案说得再漂亮,最后拼的还是运维细节。这一章不讲架构,只讲我在这个项目里实际碰到、并且差点在线上酿成事故的几个问题。
6.1 跨天场次与时间存储
海洋馆一般没有跨天场次,但活动期间会有"跨年场""夜宿场"这种从晚上持续到凌晨的场次。如果表里只存一个"日期 + 场次名称",跨天场次就会出现很尴尬的情况:同一场演出,门票销售日期属于前一天,但入场日期已经是后一天。
我的处理是:把"营业日期"(biz_date)和"场次开始时间"(start_time)彻底分开存储。跨天场次在配置时,允许 start_time 晚于 end_time,业务层判断"是否跨天"用start_time > end_time来识别。查询用户订单时,统一展示"营业日期 + 场次名称 + 具体入场时间",避免歧义。
更基本的一条:所有时间字段在数据库里统一用 DATETIME 存储,不用字符串,也不要在不同服务器之间传字符串时间。曾经有一次因为服务器时区不一致,导致第三方支付回调的验签时间对不上,排查了好几个小时,最后才意识到是系统时区问题。
6.2 库存对账任务怎么设计
我坚持每天凌晨跑一次对账任务,逻辑非常简单:
1. 按日期和场次,从 t_stock 读 sold_quantity; 2. 统计 t_order 中该日期该场次所有非取消订单的明细数量之和; 3. 两者如果不相等,发告警并输出差异明细。这个对账任务救了我两次。一次是支付回调的重复处理,导致订单流水出现了重复计数;另一次是超时关单在异常分支里没有回补库存。两次都不是大并发下才出现的问题,而是某些极端条件下偶发,没有对账任务几乎不可能及时发现。
对账任务本身不要写得太复杂,越简单越不容易出问题。我甚至没有引入专门的计算引擎,就是一个定时任务 + 几条 SQL 汇总。任务跑完如果有差异,把所有相关信息打印成日志,人工核对。
6.3 日志埋点与告警粒度
最后想聊一下日志和监控。这个项目上线初期,我们没有上重量级监控平台,就是靠日志埋点和简单的告警。
我要求所有核心链路的入口和出口都必须打印关键参数:
- 下单接口:打印 orderNo、userId、日期、场次、数量
- 支付回调:打印 paymentNo、orderNo、处理结果
- 定时任务:打印每次扫描数量、耗时、异常原因
告警粒度上,我只对三类问题做告警:
- 下单接口失败率超过阈值
- 支付回调处理出现"订单已取消但仍收到支付成功"
- 每日库存对账不平
其他细枝末节的 error 日志先不告警,避免告警风暴把真正的问题淹没。后来发现,这个项目的绝大多数故障都能靠这三类告警提前暴露。反而是那些一开始就搭了一堆监控大屏、告警规则上百条的系统,运营同学没多久就把告警当噪音了。
做这个 springboot-java 海洋馆门票线上销售平台最大的收获,是理解了"业务规则就是系统设计的一部分"。锁库存、幂等回调、最终对账这些方案,都不是为了炫技,而是为了守住两条底线:同一张入场券不能被卖两次,同一笔支付不能被记两次。很多并发问题,其实不是靠更复杂的中间件解决的,而是靠数据约束和状态机设计提前拦掉的。
最后分享一个我一直保留的习惯:每次上线前,把核心流程在脑子里完整走一遍——用户下单、卡在支付、超时关单、主动退款、闸机核销、后台改期,每一种状态转换都至少写一个针对性的 test case。门票业务的状态一旦乱了,线上排查成本远高于开发时多花的那点时间。如果你也在做类似系统,建议先把这个思路理清楚,再动手写代码。