
如果你接过体育馆这类预约系统的需求第一个感觉多半是不就是“场地加时间段用户下单付钱”嘛。等真正把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.xMyBatis-Plus原因是团队对SQL的可控性要求高复杂报表场景可以直接写XMLMySQL 8.0InnoDB引擎事务安全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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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_count1它们都会执行减库存导致同一个场次被卖两次。这在单体应用、多线程环境下就已经可能发生更不用说有多个实例部署时。所以库存扣减必须是一个原子操作。整个预约系统的核心就是把“查库存、减库存、建订单”这个过程用事务加并发控制串起来。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 方案BRedis 原子扣减 数据库兜底当运营预计高峰期订单量很大或者希望在用户查询时直接展示实时剩余库存可以引入Redis做前置扣减。先把场次库存预热到RedisSET 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待支付已锁定场次用户支付成功PAIDPENDING_PAY待支付超时未支付CANCELLEDPAID已支付场次已确认用户取消REFUNDINGPAID已支付用户核销到场COMPLETEDREFUNDING退款中支付渠道退款成功REFUNDEDREFUNDING退款中退款失败PAIDCANCELLED已取消无无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()的订单。释放库存时有三个关键步骤把订单状态从PENDING_PAY改成CANCELLED把对应venue_schedule的remain_count加回订单数量如果这个场次在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就完了。更安全的做法是加一个REFUNDING中间状态调用支付渠道退款接口等支付回调结果后再把订单置为REFUNDED如果回调失败定时补偿重试最多重试N次。这里要注意退款成功后必须把场次库存回补。很多项目上线初期只做了“取消回补库存”没做“退款回补库存”导致用户已经退款成功场次库存却永久少了。6. 接口设计给前端和APP一个稳定的协议6.1 场次查询接口场次查询是高频只读接口可以在Redis里做缓存但缓存过期策略要设计好。最简单的方案是Redis存venue_schedule的剩余库存和场次基本信息过期时间设置为30秒到1分钟。这样既能扛住高峰期查询又不会因为缓存太久导致前端展示的库存和数据库严重不一致。接口返回结构大致如下GET /api/v1/schedules?date2025-06-01sportTypebadminton { 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.urljdbc:mysql://localhost:3306/gym_db?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8mb4同时后端统一用Asia/Shanghai时区处理日期时间不要在代码里到处用new Date()去拼日期字符串。7.2 并发压测后发现数据库连接池被打满第一版上线前压测很可能出现连接池默认最大连接数为10用户一多所有请求都卡在拿连接上。建议压测时观察数据库连接池的活跃连接数再根据业务量调整配置spring.datasource.hikari.maximum-pool-size30 spring.datasource.hikari.minimum-idle5 spring.datasource.hikari.connection-timeout3000不要把maximum-pool-size调得过大因为每个连接都会占用MySQL内存超过数据库上限反而会拖垮整个服务。30到50对一个中小型体育馆系统基本够用。7.3 用户疯狂点击“提交订单”导致大量脏数据即使前端做了按钮防抖也不能完全信任前端。后端必须靠唯一约束和幂等键拦住重复请求。我见过一个项目因为没做幂等用户连续点了5次提交生成了5笔待支付订单把同一个场次的5个名额全部锁死了。用户没有支付但订单不释放其他人就永远约不上这个场次。解决方式就是前面提到的idempotency_key唯一索引加上订单表里针对user_id schedule_id的唯一约束。两重保障都加上才能彻底杜绝这种问题。7.4 支付回调幂等没做好支付渠道的回调可能会重复推送多次。如果每次回调都执行订单状态改为已支付同时没有校验原本状态就可能出现两个请求一个在改状态另一个在发起退款最后把已经退款的订单又改成已支付。处理支付回调的正确思路是先查payment_record表用order_no channel唯一约束插入流水插入冲突说明已处理过回调直接返回成功插入成功后用带状态条件的UPDATE把订单从PENDING_PAY改成PAID如果UPDATE影响行数为0说明订单已经不在待支付状态不做任何后续入账操作。7.5 定时任务释放订单和用户支付产生了竞态这个坑非常隐蔽。用户创建订单后在支付截止时间的最后一秒完成了支付与此同时定时任务扫描到了这笔待支付订单执行了“取消订单并回补库存”。两个操作并发执行最终可能出现用户钱付了订单却显示已取消的严重后果。因为支付回调在绝大多数情况下是异步的所以释放订单时一定要“先状态UPDATE再回补库存”而且状态UPDATE要带status PENDING_PAY和expire_time NOW()两个条件。如果UPDATE影响行数为1才允许进行后续回补。如果影响行数为0就说明订单已经不在待支付状态可能是刚支付成功定时任务应该跳过这笔订单等用户状态变成PAID后一切照常。这个竞态更稳妥的解法是在释放订单的临界区加一个分布式锁或者使用SELECT ... FOR UPDATE锁住订单行但我见过不少项目因为没注意这个细节上线后出现过好多次用户投诉。最后再分享一点我的实操习惯这类预约系统真正投入运营之后你会发现最值钱的不是代码写得多花哨而是能不能快速回答运营的三个问题某个场次为什么锁不了某笔订单为什么状态不对某天到底卖了多少场所以我会在系统里给关键操作都埋上操作日志。比如创建订单时记录请求参数和扣减库存前后的场次数据释放超时订单时记录任务执行信息支付回调时记录完整报文。这些日志平时看着没什么用一旦线上出了数据不一致问题就是排查的唯一线索。如果你正在设计Spring Boot版本的体育馆场内设施场地预约系统我建议第一版优先保证两条链路正确一条是“用户下单扣库存”另一条是“超时/取消回补库存”。只要这两条链路状态流转清晰、唯一约束到位后面再增加支付渠道、会员体系、次卡核销都会轻松很多。