☰
Spring Boot体育馆场地预约系统:并发库存扣减与订单状态机设计要点
2026/10/10 3:57:26 网站建设 项目流程

如果你接过体育馆这类预约系统的需求,第一个感觉多半是:不就是“场地加时间段,用户下单付钱”嘛。等真正把Spring Boot项目搭起来、接口写完、压测一跑,才发现最麻烦的根本不是 CRUD,而是同一块羽毛球场地在同一时间段被三个人同时抢到时,系统凭什么只让一个人成功。

这篇内容我会围绕“Spring Boot体育馆场内设施场地预约系统设计”这条主线,从需求边界、技术选型、数据库建模、并发库存扣减、订单状态机、接口协议,一直聊到上线前最容易踩的坑。适合正在做同类项目的开发同学,也适合产品经理看完之后发现:原来预约系统要设计得这么细。

1. 先搞清楚业务模型:场地预约不是简单的“下单卖票”

1.1 体育馆预约的三种典型资源形态

很多需求文档会把所有场地混在一起说“预约”,但落到数据库里其实是完全不同的建模方式。我在实际项目中习惯把资源分成三类:

资源类型典型例子库存模型
按时段租赁的场地羽毛球馆、网球半场、篮球全场一个场次就是一个可售库存,数量为1
按名额预约的场地游泳馆泳道、健身操课、培训教室一个场次有多个名额,库存为剩余人数
附属设施租赁球拍、储物柜、运动器材按天或按小时租赁,库存为设施数量

这三类资源看起来都是“时间段 + 场地”的二元组合,但并发处理逻辑差别很大。按时段租赁的场地是典型的“一单一库存”,谁先锁定谁赢;按名额预约则要考虑超订风险,比如游泳馆一个泳道时段最多放6个人,如果并发下单时不做原子扣减,很容易出现第7个人也下单成功。

所以在需求评审阶段,我会先让运营把场馆内的所有可预约对象按这三类列一张清单,再决定哪张表放什么字段。如果一开始就只建一张venue表和一张order表,后面扩展设施租赁时会非常痛苦。

1.2 核心用户角色与权限边界

预约系统至少要考虑四类角色:

  • 普通用户:查场次、下单、支付、取消、查看历史订单;
  • 前台运营:手工代下单、手动确认到场、处理退款、维护场地和场次;
  • 财务人员:核对订单金额、处理异常退款、导出对账报表;
  • 系统超管:管理角色权限、查看操作日志、配置系统参数。

这里有个容易被忽略的设计点:运营人员代下单不能走和用户完全一样的流程。前台需要在电话里帮客户下单,但用户可能还没注册、没绑定手机号,所以后台下单接口要支持“用户ID为空时创建一个临时客户档案”,或者强制前台先录入手机号。这个细节不做,保准上线第二天就被前台同事吐槽。

1.3 MVP阶段先砍掉哪些东西

场馆类预约系统最容易现场失控的地方,是运营方想把会员卡、优惠券、积分、次卡、押金、教练约课全塞进去。我建议第一版只保留以下核心链路:

  • 按日期查询可预约场次;
  • 锁定场次并创建待支付订单;
  • 在线支付或线下支付确认;
  • 超时未支付自动释放;
  • 开场前限时取消;
  • 后台手动改价和退款。

会员卡和优惠券放第二期。因为这类营销逻辑会和数据库字段强耦合,一旦促销规则改一次,订单金额计算就得跟着改一次。第一版把金额计算固定为“单价 × 数量”,反而能最快上线,后面再做独立的计价模块也更容易重构。

2. 技术选型:为什么是 Spring Boot + MySQL + Redis + 延迟任务

2.1 后端框架与项目结构

这类系统的并发量不会像电商秒杀那么夸张,一个中型体育馆高峰期也就几分钟内几十个订单,但依然要走正经工程化的路子。

我推荐的基础组合是:

  • JDK 17 + Spring Boot 3.x;
  • MyBatis-Plus,原因是团队对SQL的可控性要求高,复杂报表场景可以直接写XML;
  • MySQL 8.0,InnoDB引擎,事务安全;
  • Redis,用于缓存热点场次和做原子扣减;
  • 定时任务框架先用Spring自带的@Scheduled,等真需要延迟队列再引入RabbitMQ。

项目结构按模块拆,不需要微服务,单应用加模块分层足够:

com.xxx.gym ├── controller ├── service │ ├── order │ ├── schedule │ ├── payment │ └── member ├── mapper ├── entity ├── dto ├── config └── common

很多项目毁在把所有逻辑塞controller里。哪怕只是预约系统,也要把订单状态机流转、库存扣减、支付回调这些核心逻辑放到独立的service层,避免后面测试和二次开发寸步难行。

2.2 Redis 和 MySQL 的分工

有人一看预约就想着用Redis存所有场地数据,这其实没必要。场地和场次数据量很小,MySQL完全扛得住。Redis在这个系统里真正有价值的是两件事:一是缓存热门场次的剩余库存,二是用Lua脚本做原子扣减。

MySQL则作为最终数据一致性的兜底。所有订单、支付流水、场次库存的最终结果必须以数据库记录为准。Redis的库存可以丢,但数据库不能错。

2.3 延迟任务方案怎么选

用户创建订单后如果一直不支付,这个场次就必须被释放。释放动作在预约系统里属于“延迟任务”。

小规模方案是启动一个定时任务,每分钟扫一次reserve_order表,把超过支付时限且状态还是PENDING的订单改成CANCELLED,同时把对应场次的remain_count加回去。这个方案简单可靠,缺点是会有最多一分钟的延迟,而且随着订单量增大,扫表频率会变高。

如果不想让用户等太久,可以在订单创建时发一条延迟消息到RabbitMQ死信队列,或者用Redis的过期键通知。但这两者都需要额外维护中间件,对中小型场馆项目来说有点重。我个人建议第一版先用定时任务扫表,等运营反馈“超时释放太慢影响用户体验”了,再升级到延迟队列。

提示:无论用哪种方式释放超时订单,都要保证释放动作和订单状态变更在同一个事务语义里,并且要用乐观锁或状态判断兜底,防止用户刚支付成功,定时任务又把订单取消了。

3. 数据库建模:能从ERD落到建表语句才算设计完成

3.1 场地、场次、设施的表结构

先建最核心的场地表。这里不要把“羽毛球馆18:00-19:00”直接设计成一行记录,因为一个场地可以有很多天、很多时段的场次。

CREATE TABLE `venue` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL COMMENT '场地名称,如1号羽毛球馆', `sport_type` VARCHAR(32) NOT NULL COMMENT '运动类型:badminton/basketball/swimming', `address` VARCHAR(255) DEFAULT '' COMMENT '位置描述', `max_people` INT NOT NULL DEFAULT 1 COMMENT '最大可容纳人数/名额数', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_sport_type` (`sport_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地基础表';

场次表是预约系统的核心,一个场次代表“某场地某日某时段的可售库存”。

CREATE TABLE `venue_schedule` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `venue_id` BIGINT NOT NULL COMMENT '场地ID', `schedule_date` DATE NOT NULL COMMENT '可约日期', `start_time` TIME NOT NULL COMMENT '开始时间', `end_time` TIME NOT NULL COMMENT '结束时间', `total_count` INT NOT NULL DEFAULT 1 COMMENT '总库存/总名额', `remain_count` INT NOT NULL DEFAULT 1 COMMENT '剩余库存/剩余名额', `price` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '该场次单价', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1可约 0已关闭', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_venue_date_time` (`venue_id`, `schedule_date`, `start_time`, `end_time`), KEY `idx_date_status` (`schedule_date`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场次库存表';

这里有个新手容易忽略的点:venue_schedule表一定要加版本号version字段。虽然我们可以在SQL里用remain_count > 0做条件更新,但加版本号会让并发控制和排查问题时多一个抓手,尤其是涉及“释放库存回补”的时候。

3.2 订单表与支付流水

订单表不要所有信息都塞进一个字段,我的习惯是把下单时的价格快照、数量、总金额都存到订单里。因为场地价格后期会调整,如果订单只存venue_id,财务对账时根本说不清用户当时付了多少钱。

CREATE TABLE `reserve_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL COMMENT '用户ID', `venue_id` BIGINT NOT NULL, `schedule_id` BIGINT NOT NULL, `schedule_date` DATE NOT NULL COMMENT '场次日期冗余', `start_time` TIME NOT NULL COMMENT '开始时间冗余', `end_time` TIME NOT NULL COMMENT '结束时间冗余', `quantity` INT NOT NULL DEFAULT 1 COMMENT '预约数量/名额数', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `status` VARCHAR(20) NOT NULL DEFAULT 'PENDING_PAY' COMMENT '订单状态', `expire_time` DATETIME NOT NULL COMMENT '支付截止时间', `paid_time` DATETIME NULL COMMENT '支付完成时间', `cancel_time` DATETIME NULL COMMENT '取消时间', `cancel_reason` VARCHAR(255) DEFAULT '' 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_order_no` (`order_no`), KEY `idx_user_id_status` (`user_id`, `status`), KEY `idx_schedule_id` (`schedule_id`), KEY `idx_status_expire` (`status`, `expire_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约订单表';

支付流水单独建表,用来对接支付回调。它的作用不只是记录流水,更重要的是通过“支付回调幂等性”来防止订单状态被重复更新。

CREATE TABLE `payment_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `payment_no` VARCHAR(64) NOT NULL COMMENT '第三方支付单号', `channel` VARCHAR(32) NOT NULL COMMENT '支付渠道', `amount` DECIMAL(10,2) NOT NULL, `status` VARCHAR(20) NOT NULL DEFAULT 'CREATED' COMMENT 'CREATED/SUCCESS/FAILED', `callback_time` DATETIME NULL, `callback_payload` TEXT NULL COMMENT '回调原始报文', PRIMARY KEY (`id`), UNIQUE KEY `uk_payment_no` (`payment_no`), UNIQUE KEY `uk_order_no_channel` (`order_no`, `channel`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表';

3.3 唯一约束是防止脏数据的最后防线

业务代码写得再小心,也一定要从数据库层面兜底。

reserve_order里已经用uk_order_no保证订单号不重复,这没问题。但还有一个“不可见”的并发问题:用户在两台设备上同时点提交,前端生成了两个不同的订单号,实际重复预约了同一个场次。为了防这个,订单表里建议再加一个唯一约束:

ALTER TABLE `reserve_order` ADD UNIQUE KEY `uk_user_schedule_unique` (`user_id`, `schedule_id`);

加了这个约束后,同一个用户对同一个场次最多只能有一个订单。如果业务允许用户同时预约同一个场次多次,那就去掉这个约束,改由代码判断。但从我接触过的场馆需求来看,绝大多数时候是不允许一个人重复锁定同一个场次的。

4. 并发控制:同一块场地同一时间被抢的解决方案

4.1 先看一个必踩的坑

“最简单的实现”是:先查询remain_count,判断大于0,然后减库存,插入订单。问题是,如果两个请求在同一时刻都查到了remain_count=1,它们都会执行减库存,导致同一个场次被卖两次。这在单体应用、多线程环境下就已经可能发生,更不用说有多个实例部署时。

所以库存扣减必须是一个原子操作。整个预约系统的核心,就是把“查库存、减库存、建订单”这个过程用事务加并发控制串起来。

4.2 方案A:数据库乐观锁,简单可靠

最适合第一版上线的方案是用一条带条件的UPDATE语句扣库存。

UPDATE venue_schedule SET remain_count = remain_count - #{quantity}, version = version + 1 WHERE id = #{scheduleId} AND remain_count >= #{quantity} AND status = 1 AND schedule_date >= CURDATE();

这条SQL的执行过程是原子性的,数据库InnoDB会对满足条件的行加锁。如果UPDATE影响行数为1,说明扣减成功,接着创建订单;如果影响行数为0,说明库存不足或者场次已关闭,直接返回“无库存”。

这套方案的好处是简单,不需要额外引入Redis也能保证并发安全。缺点是所有扣库存动作都在MySQL行锁上串行执行,如果系统访问量很大,数据库连接和行锁等待会成为瓶颈。但对绝大多数体育馆应用来说,这个瓶颈远远没到。

配合事务的写法长这样:

@Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderCommand command) { int updated = venueScheduleMapper.deductStock( command.getScheduleId(), command.getQuantity()); if (updated == 0) { throw new BizException("该场次库存不足或已关闭"); } ReserveOrder order = buildOrder(command); reserveOrderMapper.insert(order); return order.getId(); }

注意:这里的deductStock和insert必须在同一个事务内。如果插入订单失败,事务回滚,库存扣减也会回滚,不会出现“库存扣了但订单不存在”的情况。

4.3 方案B:Redis 原子扣减 + 数据库兜底

当运营预计高峰期订单量很大,或者希望在用户查询时直接展示实时剩余库存,可以引入Redis做前置扣减。

先把场次库存预热到Redis:

SET venue_schedule:1024:remain 3

然后使用Lua脚本原子扣减:

-- KEYS[1]: 库存key -- ARGV[1]: 扣减数量 local remain = tonumber(redis.call('GET', KEYS[1])) if not remain or remain < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1

调用Redis扣减成功后,进入Spring事务去创建订单、异步同步库存到MySQL。这里最大的坑是:如果Redis扣减成功、数据库订单创建失败,就要把Redis的库存回补回去。这个回补动作不能用简单的INCRBY,因为可能连带其他请求已经扣减过库存,直接回补会造成库存虚高。稳妥的办法是记录一条“Redis扣减流水”,回补时只针对当前请求扣减的数量做补偿,并且要加分布式锁防止并发补偿。

方案B不适合第一版直接上,因为它把简单的库存问题变成了两个存储之间的数据一致性问题。我的建议是先用方案A跑起来,等压测发现数据库扣库存确实撑不住时再演进到方案B。

4.4 方案C:分布式锁锁定场景

还有一种是使用Redisson的分布式锁,把“某个场次”作为锁key。

RLock lock = redisson.getLock("lock:scene:" + command.getScheduleId()); if (lock.tryLock(2, 30, TimeUnit.SECONDS)) { try { // 校验场次状态 // 扣减库存 // 创建订单 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }

用分布式锁的好处是代码读起来直接,把并发问题简化为“同一时间只有一个人能操作这个场次”。坏处是一旦锁的服务没配置好,或者锁的超时时间设置不合理,就会直接影响正常下单流程。

我在实际项目中很少单独用分布式锁,更多是把方案A当作必选,再在需要缓存库存时叠加方案B。分布式锁一般只用于管理端修改场次价格、批量关闭场次这类低频操作,避免和用户下单产生冲突。

方案优点缺点适用场景
数据库乐观锁简单可靠,事务保证高峰期行锁等待绝大多数中小型场馆
Redis原子扣减并发能力强,抗压好双存储一致性复杂热门抢购类场馆
Redisson分布式锁逻辑直观依赖Redis高可用,锁超时风险管理端操作、低频排他场景

5. 预约状态机与超时释放:订单不能只有已支付和未支付

5.1 订单状态定义

预约系统的订单状态比普通电商更细。我一般定义这些状态:

状态含义可流转事件目标状态
PENDING_PAY待支付,已锁定场次用户支付成功PAID
PENDING_PAY待支付超时未支付CANCELLED
PAID已支付,场次已确认用户取消REFUNDING
PAID已支付用户核销到场COMPLETED
REFUNDING退款中支付渠道退款成功REFUNDED
REFUNDING退款中退款失败PAID
CANCELLED已取消无无
COMPLETED已完成无无
NO_SHOW已支付但未到场无无

这个状态机看起来简单,但实现时最容易踩的坑是:任何状态变更都不能靠“先查一下当前状态,再update状态”来判断,因为两步之间有并发窗口。正确做法是带状态条件的UPDATE,例如取消订单时:

UPDATE reserve_order SET status = 'CANCELLED', cancel_time = NOW() WHERE id = #{orderId} AND status = 'PENDING_PAY';

如果SQL影响行数为1,说明取消成功;为0,说明订单已经不再是待支付状态,此时必须重新加载订单,根据实际状态决定是跳转支付还是提示用户。

5.2 超时未支付释放库存的完整闭环

订单创建后,我们需要一个“支付截止时间”。常见做法是创建订单时设置expire_time = NOW() + 15分钟,15分钟内未支付就自动取消。

定时任务扫表时,会扫所有status = 'PENDING_PAY'且expire_time < NOW()的订单。释放库存时有三个关键步骤:

  1. 把订单状态从PENDING_PAY改成CANCELLED;
  2. 把对应venue_schedule的remain_count加回订单数量;
  3. 如果这个场次在Redis里有缓存库存,也要同步回补。

这三步必须放在一个事务里,并且最好先修改订单状态,再回补库存。因为回补库存的SQL本身要加“当前可约状态才可增加”的条件,避免场次已经被运营手动关闭之后被定时任务重新打开。

UPDATE venue_schedule SET remain_count = remain_count + #{quantity} WHERE id = #{scheduleId} AND remain_count + #{quantity} <= total_count;

最后这个条件很重要,它能防止因为重复释放、重复回补导致剩余库存超过总库存。

5.3 用户取消与退款的状态流转

场馆预约的取消规则一般由运营配置,例如“开场前2小时可免费取消”“开场前2小时内不可取消”。代码上只需要在接口层判断当前时间和场次开始时间的关系,再决定是否进入退款流程。

退款不是直接把订单状态改成REFUNDED就完了。更安全的做法是:

  1. 加一个REFUNDING中间状态;
  2. 调用支付渠道退款接口;
  3. 等支付回调结果后,再把订单置为REFUNDED;
  4. 如果回调失败,定时补偿重试,最多重试N次。

这里要注意,退款成功后必须把场次库存回补。很多项目上线初期只做了“取消回补库存”,没做“退款回补库存”,导致用户已经退款成功,场次库存却永久少了。

6. 接口设计:给前端和APP一个稳定的协议

6.1 场次查询接口

场次查询是高频只读接口,可以在Redis里做缓存,但缓存过期策略要设计好。最简单的方案是Redis存venue_schedule的剩余库存和场次基本信息,过期时间设置为30秒到1分钟。这样既能扛住高峰期查询,又不会因为缓存太久导致前端展示的库存和数据库严重不一致。

接口返回结构大致如下:

GET /api/v1/schedules?date=2025-06-01&sportType=badminton { "code": 0, "msg": "success", "data": [ { "scheduleId": 1024, "venueId": 1, "venueName": "1号羽毛球馆", "startTime": "18:00", "endTime": "19:00", "price": 120.00, "remainCount": 1, "status": 1 } ] }

6.2 创建预约接口与幂等设计

创建预约不能只传scheduleId,前端生成一个业务幂等键更稳。这样即使用户连点两次提交,服务端也能识别是同一个请求。

POST /api/v1/orders { "scheduleId": 1024, "quantity": 1, "idempotencyKey": "e3b0c442-98fc-4c7b-9e2f-1234567890ab" }

服务端在事务里先查idempotency_key是否已经存在,存在就直接返回已有订单,不存在则创建订单并记录幂等键。幂等键可以放在订单表里:

ALTER TABLE `reserve_order` ADD COLUMN `idempotency_key` VARCHAR(64) NULL COMMENT '前端幂等键', ADD UNIQUE KEY `uk_idempotency_key` (`idempotency_key`) USING HASH;

6.3 管理端接口

管理端至少要提供这几个能力:

  • 批量生成场次:运营选择场地和日期范围后,系统按场馆规则自动生成所有时段的场次;
  • 关闭/开启场次:临时闭馆时能把场次改成不可约;
  • 查看订单:按日期、用户手机号、订单状态筛选;
  • 手动退款:线下支付订单要支持前台走线下退款流程;
  • 财务对账报表:按天汇总支付金额、退款金额、订单量。

管理端和用户端接口建议分开,不要复用同一个Controller。因为权限校验逻辑不同,管理端还会有操作日志审计需求。分开之后,用户端接口可以走JWT认证,管理端走独立的OAuth2或简单的后台Token认证。

7. 上线前后最容易踩的坑

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

这是Spring Boot项目里非常经典的坑。如果MySQL连接串没指定时区,JDBC驱动默认会读服务器时区,而很多部署环境是UTC,用户在白天下的单,数据库里记录的schedule_date却变成了前一天。

连接串里一定要显式指定:

spring.datasource.url=jdbc:mysql://localhost:3306/gym_db?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4

同时后端统一用Asia/Shanghai时区处理日期时间,不要在代码里到处用new Date()去拼日期字符串。

7.2 并发压测后发现数据库连接池被打满

第一版上线前压测,很可能出现:连接池默认最大连接数为10,用户一多,所有请求都卡在拿连接上。

建议压测时观察数据库连接池的活跃连接数,再根据业务量调整配置:

spring.datasource.hikari.maximum-pool-size=30 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=3000

不要把maximum-pool-size调得过大,因为每个连接都会占用MySQL内存,超过数据库上限反而会拖垮整个服务。30到50对一个中小型体育馆系统基本够用。

7.3 用户疯狂点击“提交订单”导致大量脏数据

即使前端做了按钮防抖,也不能完全信任前端。后端必须靠唯一约束和幂等键拦住重复请求。我见过一个项目因为没做幂等,用户连续点了5次提交,生成了5笔待支付订单,把同一个场次的5个名额全部锁死了。用户没有支付但订单不释放,其他人就永远约不上这个场次。

解决方式就是前面提到的idempotency_key唯一索引,加上订单表里针对user_id + schedule_id的唯一约束。两重保障都加上,才能彻底杜绝这种问题。

7.4 支付回调幂等没做好

支付渠道的回调可能会重复推送多次。如果每次回调都执行订单状态改为已支付,同时没有校验原本状态,就可能出现两个请求:一个在改状态,另一个在发起退款,最后把已经退款的订单又改成已支付。

处理支付回调的正确思路是:

  1. 先查payment_record表,用order_no + channel唯一约束插入流水;
  2. 插入冲突说明已处理过回调,直接返回成功;
  3. 插入成功后,用带状态条件的UPDATE把订单从PENDING_PAY改成PAID;
  4. 如果UPDATE影响行数为0,说明订单已经不在待支付状态,不做任何后续入账操作。

7.5 定时任务释放订单和用户支付产生了竞态

这个坑非常隐蔽。用户创建订单后,在支付截止时间的最后一秒完成了支付;与此同时,定时任务扫描到了这笔待支付订单,执行了“取消订单并回补库存”。两个操作并发执行,最终可能出现用户钱付了,订单却显示已取消的严重后果。

因为支付回调在绝大多数情况下是异步的,所以释放订单时一定要“先状态UPDATE再回补库存”,而且状态UPDATE要带status = 'PENDING_PAY'和expire_time < NOW()两个条件。如果UPDATE影响行数为1,才允许进行后续回补。如果影响行数为0,就说明订单已经不在待支付状态,可能是刚支付成功,定时任务应该跳过这笔订单,等用户状态变成PAID后一切照常。

这个竞态更稳妥的解法,是在释放订单的临界区加一个分布式锁或者使用SELECT ... FOR UPDATE锁住订单行,但我见过不少项目因为没注意这个细节,上线后出现过好多次用户投诉。

最后再分享一点我的实操习惯

这类预约系统真正投入运营之后,你会发现最值钱的不是代码写得多花哨,而是能不能快速回答运营的三个问题:某个场次为什么锁不了?某笔订单为什么状态不对?某天到底卖了多少场?

所以我会在系统里给关键操作都埋上操作日志。比如创建订单时记录请求参数和扣减库存前后的场次数据,释放超时订单时记录任务执行信息,支付回调时记录完整报文。这些日志平时看着没什么用,一旦线上出了数据不一致问题,就是排查的唯一线索。

如果你正在设计Spring Boot版本的体育馆场内设施场地预约系统,我建议第一版优先保证两条链路正确:一条是“用户下单扣库存”,另一条是“超时/取消回补库存”。只要这两条链路状态流转清晰、唯一约束到位,后面再增加支付渠道、会员体系、次卡核销都会轻松很多。

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

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

立即咨询