Java羽毛球馆管理系统:从单体架构到并发订场的实战设计 简介基于Java的羽毛球馆管理系统设计与实现文档定位是毕业设计/课程设计类参考资源适合计算机专业学生、初级开发人员及体育场馆信息化建设者阅读。内容围绕场地预约、资源分配、订单支付等业务场景梳理了从需求分析到系统实现的全流程思路并涉及用户管理、提醒通知、数据统计、安全控制等关键模块设计。压缩包内共1个docx文件大小约942KB包含中英文摘要、关键词及正文内容结构完整。目前已有446人浏览/学习。透过这份文档读者可以提炼出完整的系统设计方案、数据库表结构设计思路、前端交互逻辑与后端接口组织方式还能借鉴论文撰写结构与排版规范适合快速完成同类管理系统设计或相关学业文档。1. 羽毛球馆管理系统一个看起来不起眼的 Java 单体项目为什么我还要写一篇长文周末陪朋友去球馆订场地前台小姑娘还在用 Excel 手动记场次撞单了就给顾客打电话道歉。这个场景我相信很多人不陌生羽毛球馆这种中小型场馆场地少则四片、多则十来片按小时切时段运营账目复杂度和酒店不相上下但因为单价低、管理粗放多数老板还在靠手记账本和微信群接龙过日子。基于 Java 的羽毛球馆管理系统就是把这套并行的高并发订场、会员充值、计费结算搬到系统里让顾客能查场次、订场、办卡让老板能看报表、锁场地、控价格。这篇笔记按我实际做这类系统的思路来讲先立技术选型再讲数据库怎么拆表然后落到核心预订和计费代码最后一章讲部署和运维。适合准备做课程设计、毕设或者想给自家球馆做低成本管理系统的 Java 从业者。2. 技术选型与工程结构Spring Boot MyBatis-Plus 这套组合怎么搭才不臃肿2.1 为什么不选微服务单体项目怎么定位羽毛球馆管理系统本质是一个低并发、高事务一致性的业务系统用户量级撑死在几千个会员、几十个管理员。微服务那套注册中心、配置中心、网关在这里纯属负重训练。选 Spring Boot 单体应用就够了这是业界的共识做法也符合这类系统的真实落地成本。技术栈我用的是 Spring Boot MyBatis-Plus MySQL Redis。有人会问既然并发不高Redis 还需要吗需要。订场这事的并发峰值一般都出现在晚上八点整——会员卡着点抢周末黄金时段或者早上十点整开放新一天的预订。MySQL 行锁能扛但响应时间会波动加上 Redis 做一层预订资格预检和分布式锁能明显减少数据库锁等待。还有一个工程上的考虑MyBatis-Plus 可以根据实体类自动生成 CRUD省掉大量重复 Mapper XML。但我要先泼一盆冷水这类系统的核心是预约时间片校验后面细讲这种定制逻辑 MyBatis-Plus 帮不上忙必须手写 SQL。所以选型思路要清晰——简单的增删改查交给它复杂的业务查询自己控制 SQL。2.2 包结构划分按领域拆包不按层拆包很多同学拿到项目就按 controller / service / mapper 三层建包结果改一个预订流程要在五个包里来回跳。我常用的是按领域拆包每个领域内部再分三层对应代码如下。com.example.badminton ├── common // 统一响应体、全局异常、枚举 ├── config // Redis、MyBatis、跨域等配置 ├── member // 会员领域 │ ├── controller │ ├── service │ ├── mapper │ └── entity ├── venue // 场地领域 │ ├── controller │ ├── service │ ├── mapper │ └── entity ├── order // 订单领域 │ ├── controller │ ├── service │ ├── mapper │ └── entity └── report // 报表统计这个结构的好处是需求变更发生在领域内部比如给会员增加一个实名认证字段只动 member 包要调整订场规则只动 order 包隔离性比按层分包强很多。如果未来系统长大按领域拆包天然就是拆微服务的雏形。2.3 统一响应体和全局异常接口层不写 try-catch羽毛球馆的前端可能是小程序、H5 或者管理后台接口风格必须统一。我习惯在 common 包下定义一个 Result 类所有接口返回这个结构。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); return r; } }逻辑说明code 表示业务状态码200 是成功4001 是参数错误5002 是库存不足5003 是重复请求。message 给前端直接弹窗提示用data 才是真正的响应数据。这样设计的好处是前端拿到 Result 只需要判断 code 是否为 200错误提示不用后端写文字再拼接 HTML。Service 层不必每个方法都包 try-catch交给全局异常处理器统一兜底。我一般配合 RestControllerAdvice 使用捕获业务异常类 BizException、参数校验异常和兜底 Exception这样代码里只需要在业务失败的地方 throw new BizException(该时间段已被预订)可读性和维护性都提升一个档次。3. 数据库设计会员、场地、订单与场次时间片到底怎么拆表3.1 核心表结构六张表讲清楚羽毛球馆的账羽毛球馆的管理对象主要是会员、场地、订单和计费流水。我习惯拆成六张表会员表、场地表、场地时段表、预约订单表、充值记录表、消费流水表。会员表很简单关键字段是余额 balance 和等级 level等级决定折扣率。场地表记录场地编号和类型比如 1 号场、2 号场类型区分塑胶和木地板。时段表是最关键的表它把场地按小时切片比如 1 号场 18:00-19:00 是一行19:00-20:00 是另一行每行有价格和状态。订单表关联会员、时段。流水表记录每一笔充值或消费。这个设计对应到现实场景就是羽毛球馆按「片场 × 小时」卖时间不是卖场地本身。如果只建场地表和订单表每次去数据库用场地ID和时间段查冲突SQL 写起来既复杂又容易漏索引。把时间段做成独立表就等于提前把可售商品每个时段物化了预订行为变成对商品行的状态变更这是这个系统设计上最关键的一个决策。3.2 场次时间片表一个场地一天多少片怎么生成时段表字段设计如下。CREATE TABLE venue_slot ( id bigint NOT NULL AUTO_INCREMENT, venue_id bigint NOT NULL COMMENT 场地ID, start_time datetime NOT NULL COMMENT 开始时间, end_time datetime NOT NULL COMMENT 结束时间, price decimal(10,2) NOT NULL COMMENT 该时段价格, status tinyint NOT NULL DEFAULT 0 COMMENT 状态 0空闲 1锁定 2已售, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), KEY idx_venue_time (venue_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场地时段表;逻辑说明venue_id 加 start_time 建联合索引查询某个场地某天的可订时段直接走索引。status 三个状态0 表示还没被预订1 表示用户正在支付流程中暂时锁定2 表示支付完成。version 字段是给乐观锁用的后面说并发时会用到。价格放时段表而不是场地表因为黄金时段和闲时价格不一样这样运营调价只需改一行数据。每天凌晨要生成未来 N 天的时段数据我定义了一个定时任务。常见做法是用 Spring Scheduled 每天跑一次查出每个场地未来第 N1 天的日期按营业时间切成小时段批量 insert。生成时要注意跨天比如球馆营业到凌晨 1 点那么 23:00-24:00 和 00:00-01:00 两个时段日期标注不同但先后连续后面订场校验时不能只按日期过滤。3.3 会员余额与流水表为什么不做成一只表会员的余额更新和充值流水、消费流水必须分开。原因在于余额是一个可变状态流水是不可变记录混在一张表里每次查询都要统计所有历史记录数据量大之后性能堪忧而且对账困难。充值记录表保存充值金额和赠送金额消费流水表保存每次订场扣了多少钱、余额变动前后快照。CREATE TABLE member_account_flow ( id bigint NOT NULL AUTO_INCREMENT, member_id bigint NOT NULL, order_id bigint DEFAULT NULL COMMENT 关联订单充值为空, change_amount decimal(10,2) NOT NULL COMMENT 变动金额正负表示, balance_after decimal(10,2) NOT NULL COMMENT 变动后余额, type tinyint NOT NULL COMMENT 1充值 2消费 3退款, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_member_time (member_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员资金流水;逻辑说明balance_after 存变动后余额是给对账用的快照。这样哪怕某条流水被误删还可以从快照反推。订单表里面有余金额和实付金额退款时新增一条 type3 的流水并把钱加回余额。整套设计保证了账目可追溯不会出现余额越扣越乱的翻车事故。3.4 并发订场的两把锁Redis 预检 数据库乐观锁订场这类操作天然存在并发几十个人同时抢同一片场地同一时段。我在系统里做了两道防线。第一道是 Redis 分布式锁key 设计为 slot:lock:{slotId}抢锁成功的用户才能进入下单流程这样直接把并发请求串行化避免同时读到 status0。第二道是数据库乐观锁在更新时段表时带上 version 条件防止在业务时间窗过长时覆盖状态。这里有个细节值得注意分布式锁的 key 粒度一定要到具体的 slotId而不是整个场馆加锁否则一片场地被人锁住整个球馆的订场操作都得排队这完全不现实。Redis 锁要设置过期时间我一般设为 10 秒正常下单流程在局域网环境下 1 秒内就能完成10 秒足够兜底。过期时间太短会在慢查询时误杀正常请求太长会在服务宕机时拖住其他用户——10 秒是一个实战出来的平衡值。4. 核心业务实现从预订场地到会员扣费的完整代码链路4.1 预订接口从参数校验到锁库更新的完整逻辑订场是系统的心脏。我以一个「会员预订场地时段」的接口为例把代码按业务顺序拆开讲。这里刻意把事务处理和分布式锁分开先抢锁再开事务防止事务未提交时锁已释放。Transactional(rollbackFor Exception.class) public Order createOrder(Long memberId, Long slotId, Long venueId) { // 1. 分布式锁预检 String lockKey slot:lock: slotId; boolean locked redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙该时段正在被其他人预订); } try { // 2. 查询时段并校验状态 VenueSlot slot venueSlotMapper.selectById(slotId); if (slot null || slot.getStatus() ! 0) { throw new BizException(该时段已售出或已锁定); } // 3. 校验会员余额 Member member memberMapper.selectById(memberId); BigDecimal price slot.getPrice().multiply(discount(member.getLevel())); if (member.getBalance().compareTo(price) 0) { throw new BizException(余额不足请先充值); } // 4. 乐观锁更新时段 int rows venueSlotMapper.updateStatusByVersion(slotId, 2, slot.getVersion()); if (rows 0) { throw new BizException(该时段已被抢购请更换时间); } // 5. 创建订单 扣减余额 写流水 Order order buildOrder(memberId, slotId, price); orderMapper.insert(order); memberMapper.deductBalance(memberId, price); memberAccountFlowMapper.insert(buildFlow(memberId, order.getId(), price.negate())); return order; } finally { redisLock.unlock(lockKey); } }逻辑说明步骤 1 先抢 Redis 锁没抢到直接返回繁忙提示避免大量请求打到 MySQL。步骤 2 和 3 是前置校验此时还在锁内数据状态是安全的。步骤 4 是关键转折点——用 updateStatusByVersion 这个自定义 SQL 同时判断状态和版本号更新成功返回行数 1失败说明有其他请求先改了数据直接报错。步骤 5 的订单、扣余额、写流水在一个事务里任何一个失败全部回滚不会出现扣了钱没订单的尴尬。自定义 SQL 在 Mapper 里对应这么一段。Update(UPDATE venue_slot SET status #{status}, version version 1 WHERE id #{slotId} AND status 0 AND version #{version}) int updateStatusByVersion(Param(slotId) Long slotId, Param(status) Integer status, Param(version) Integer version);参数说明status 传 2 表示已售version 传查询时的旧值。更新时指定 status 0是为了让数据库层面再兜一道防止程序逻辑漏判导致重复售卖。version 1 让每次更新都改变版本号后续并发请求即使拿到旧版本也无法覆盖。4.2 会员充值与优惠折扣计算放在哪一层充值和折扣的逻辑要简单粗暴但不出错。充值接口我支持充 100 送 10、充 300 送 50 这种阶梯规则在代码里用一个规则表或枚举维护避免硬编码在业务代码中。折扣这里有一个容易踩的坑场地标价是 100 元黄金会员 8 折最终金额是 80 元。如果用 BigDecimal 的 multiply 方法乘 0.8得到 80.000数据库 decimal(10,2) 会四舍五入存储但如果在计算过程中不调用 setScale(2, RoundingMode.HALF_UP)可能出现 80.001 这种精度问题导致余额扣减后对不上账。我的统一做法是金额计算全部用 BigDecimal乘完折扣立刻 setScale(2, RoundingMode.HALF_UP)最后再比较余额。充值流水的另一个细节是赠送金额是否可退。很多球馆的规则是充值赠送部分不能退还现金只能在场内消费。这需要在流水表加一个 amount_type 字段区分本金和赠送金退款时优先退本金这类业务逻辑虽然小但漏了后面跟老板对账时哭都来不及。4.3 管理端报表按天按场馆统计营收老板最关心的报表无非三个数今天卖了多少场、充了多少钱、哪些时段卖得最差。写一个按天统计的查询接口把订单表按日期分组统计。Select(SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*) AS order_count, SUM(pay_amount) AS revenue FROM order WHERE create_time BETWEEN #{startTime} AND #{endTime} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY day DESC) ListDailyReport selectDailyReport(Param(startTime) String startTime, Param(endTime) String endTime);逻辑说明按天分组的报表在数据量几千行的规模下性能完全没问题。如果未来要统计每片场地利用率再加一个 GROUP BY venue_id 就行。统计口径上要注意 pay_amount 是实付金额不是订单原价因为会员有折扣对账一律以实付为准。这个规则要和财务说清楚否则月底对不上账就变成玄学问题。5. 避坑指南羽毛球馆系统最容易翻车的五个场景和解决方案5.1 并发超额预订状态检查与更新不是同一个事务导致卖重现象同一个时段被两个会员同时下单两个请求都查到 status 0然后都成功创建了订单场馆实际卖出了两倍。原因查询状态和更新状态之间有时间窗口如果程序里是「先 select 判断再 update」天然存在竞态条件。解决要么在事务里用 SELECT FOR UPDATE 锁行要么像第 4 章那样用乐观锁配合版本号更新两条路都能解决。我选乐观锁因为不用长时间占用数据库连接性能更好。血泪经验是不要相信单机应用的「低并发」而省略并发控制抢黄金时段这种场景并发量比你想的高得多。5.2 跨天时段只按日期查时段导致凌晨场次凭空消失现象球馆营业到凌晨 1 点用户在系统里看不到 00:00-01:00 的场次。原因定时生成时段时把凌晨的时段归属到了前一天还是后一天规则不统一查询时又只过滤了当天两个逻辑打架。解决统一规则——所有时段归属于「开场日」即 00:00-01:00 这个时段归属到开场那天的日期生成和查询都用 start_time 的日期做过滤条件。这是一个设计约定写进代码注释里后面接手的人才能看懂不然这就是一个查一次翻一次车的经典大坑。5.3 支付回调与本地事务不一致用户付了钱订单还是待支付现象用户在微信支付里扣款成功但系统订单状态还是「待支付」或者订场成功但回调没更新。原因支付回调是异步的如果回调处理中数据库操作失败本地事务回滚但支付平台那边钱已经扣了。解决回调处理不能直接在回调方法里写业务逻辑必须是先落一条支付回调记录再异步处理状态变更。常见做法是回调进来先幂等校验查询订单当前状态如果已经是已支付直接返回成功如果是待支付更新状态并执行后续扣款操作整个动作包在事务里失败则由定时任务轮询未完成订单进行补偿。这类问题一旦发生用户投诉力度是最大的因为牵扯到钱。5.4 金额精度浮点数计算让余额不翼而飞现象会员充值 100 元打完 8 折订了一场 80 元的场地余额显示 20 元但过几天再看变成了 19.99 元。原因用了 float 或 double 类型存储金额小数在二进制中无法精确表示叠加多次运算误差累积。解决数据库字段一律 decimal(10,2)Java 实体的成员变量用 BigDecimal禁止 float/double所有计算必须经过 BigDecimal 并在每个乘法后 setScale 指定精度。这不是一个要不要做的选项而是做这类计费系统的最低底线。5.5 事务注解的坑同类调用导致 Transactional 失效现象createOrder 方法里自己调了本类的另一个 Transactional 方法结果中途抛出异常前面的数据库操作没有回滚。原因Spring 的事务是基于代理实现的同类内部的 this 调用绕过了代理注解完全不生效。解决事务方法必须通过代理对象调用常见的做法是把需要事务的方法放到单独的 Service 类中或者注入自身代理。一个更稳的策略是事务边界尽量设置在 Controller 调用的最外层 Service 方法上内层方法不加事务注解只依赖外层统一回滚。这样结构清晰也不会出现内部调用失效的问题。6. 部署与配置把系统放到服务器上稳定跑起来的三个关键细节毫秒级订场响应、MySQL 连接池、备份策略、日志检查这几件事构成了系统上线后是否稳定的分水岭。我通常会先解决部署方式和连接池配置再谈备份和监控。部署用常见做法就行Maven 打成 jar 包放到一台 4 核 8G 的云服务器上nohup 启动。这里有一个容易忽略的点——生产环境的 JVM 参数。我一般会显式设置初始堆和最大堆。nohup java -Xms1024m -Xmx2048m -XX:UseG1GC \ -jar badminton-system.jar \ --spring.profiles.activeprod \ --server.port8080 \ /data/logs/app.log 21 启动参数说明-Xms 和 -Xmx 设置堆内存服务刚启动时默认按需扩张如果不显式设置有时会遇到高峰期 GC 频繁导致订场接口变慢这算是一种运行环境上的隐性问题。-XX:UseG1GC 是 JDK 8 之后比较稳妥的垃圾回收器选择。日志输出到 /data/logs/app.log配合 logback 按天滚动排查问题时不至于翻一个几 GB 的巨型文件。生产环境的数据库账号绝对不能复用 root我建一个名为 badminton 的账号只授予业务库的增删改查权限最大连接数配 50。Spring Boot 的 HikariCP 连接池参数中有两个值得单独调maximum-pool-size 配 20 就够了minimum-idle 配 5。这种业务规模下连接数配再大也只是空占数据库资源反而是连接池过小会在定时任务批量写时段时拖慢接口响应。最后要养成的检查习惯是每天看一眼 /data/logs/app.log 里的 WARN 和 ERROR 数量以及数据库里有没有大事务长时间锁表。备份用 mysqldump 加 crontab 每天凌晨全量备份保留 7 天。曾经有一回我发现生产库的 venue_slot 表被误删了未来三天的时段数据如果不是有备份整晚都别想睡了。从那之后我意识到这类系统的安全感和复杂度没有关系它就来自最土的备份机制。羽毛球馆管理系统做到这个程度已经可以支撑一家场馆的完整线上预订和计费业务了。用户在小程序里看场次、下单、扣费管理员在后台改价、锁场、看报表每天凌晨定时任务自动生成未来时段的库存。有时候也会想这套系统有没有可能加上 AI 预测未来时段价格但作为一线工程师我更倾向于先把基础的数据可靠性和并发订场做扎实——这些才是用户真正会感知的部分。希望这篇文章能帮你在做同类系统时少走几段弯路。本文还有配套的精品资源点击获取