资讯动态

SpringBoot篮球场馆预订系统:从数据库建模到并发防超卖实战详解

发布时间:2026/10/21 22:45:32 来源:尧图企业网站定制
这段时间帮一位朋友调试一套基于JavaSpringBoot的篮球场馆预订系统从最初跑通源码再到把调试文档、设计文档LW、讲解资料整套整理完前后花了挺长时间。说实话这类场地预约平台在课程设计和毕业设计里几乎成了“标配”选题但大多数同学拿到手的源码要么缺数据库表要么配置文件对不上要么遇到并发抢订时直接乱套。今天我不打算复述那种“抄一遍说明书”的内容而是完全按我实际调这个项目的过程把篮球馆订场系统的数据库建模、核心业务流程、并发防超卖、文档交付这些关键点逐一拆开希望能给正在做同类项目的读者一些真正能落地的参考。这个项目本身不复杂但麻雀虽小五脏俱全用户端要浏览场地、按日期选时段、创建订单、支付哪怕是模拟支付管理端要维护场地和时段、查看订单、处理退款中间还夹着预订冲突、超时未支付、重复提交等一堆“坑”。理解了这套系统怎么从零到一搭起来再去看别的预订类项目基本一通百通。1. 为什么选择SpringBoot做篮球场地预约平台从实际痛点说起1.1 这类系统解决的真实痛点在没有线上系统之前球馆管理员的日常是Excel记录预约微信群里口头确认打电话约场。一到周末晚高峰经常出现同一个场地同一时段被两个人同时记住的情况管理员自己都分不清谁先谁后。篮球场馆预订系统要解决的核心问题就是把“场地-时段-用户”三者的关系线上化用订单状态把每一个时段的“空闲/锁定/已订/使用完成”管理起来。所以你看需求本质上非常朴素用户打开页面看到今天还有哪些场地哪些时间段能订选好后下单支付完成后到点去打球。管理员在后台能看到每一天的排场情况、订单收入也能手动关闭某个时段的预订。听上去不难但真正实现的时候如何保证“同一个时段不会出现两份订单”就成了最考验设计能力的点。1.2 技术选型的取舍与理由为什么选JavaSpringBoot首先这是一个极其成熟的组合SpringBoot自动装配大大减少了配置工作量适合快速搭建单体应用。更重要的是这套组合的生态资料丰富遇到问题基本都能搜到答案对于需要交付源码和讲解项目的场景很友好。我这次使用的具体技术栈如下后端Java 8 Spring Boot 2.7.x MyBatis-Plus数据库MySQL 8.0缓存/锁Redis 5.x用于并发控制和未支付订单超时处理前端服务端渲染模板Thymeleaf AdminLTE后台模板工具Maven、Lombok、Hutool常用工具非必须这里不选前后端分离是因为课程设计和毕设场景下服务端渲染可以少写一套Vue/React接口调试成本也低展示的时候直接从浏览器进后台更直观。如果你的目标是学习Spring Boot接口开发也可以做成前后端分离本质逻辑是一样的。2. 从业务需求到数据库模型场地、时段、订单之间怎么关联2.1 先画清楚状态流转再建表很多同学一上来就建表结果字段越加越乱。正确顺序应该是先想清楚“一个订单从创建到结束有哪些状态这些状态之间允许怎样跳转”。我最终定义订单状态为0待支付1已支付2已取消3已使用核销4退款中/已退款时段排场状态更简单就三个0空闲可订1已被锁定或已下单待支付/已支付都算占用2已禁用管理员手动关闭状态为什么用int不用字符串因为状态流转时比较大小、做条件判断更方便而且枚举值含义在代码里定义清楚不担心拼写错误。当然可读性稍微差一点所以我会在枚举类上写注释。2.2 核心表结构与DDL这张图在脑子里过一遍用户表user、场地表venue、时段表time_slot、订单表booking_order。其中时段表是关键它把一个抽象的“篮球场”和具体的“某天某段时间”绑定起来。我的建表语句简化版去掉了多余字段CREATE TABLE venue ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 场地名称如A区1号场, location varchar(100) DEFAULT NULL COMMENT 位置描述, price_per_hour decimal(10,2) NOT NULL COMMENT 基准价/小时, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT篮球场地表; CREATE TABLE time_slot ( id bigint(20) NOT NULL AUTO_INCREMENT, venue_id bigint(20) NOT NULL COMMENT 场地ID, book_date date NOT NULL COMMENT 预约日期, start_time time NOT NULL COMMENT 开始时间, end_time time NOT NULL COMMENT 结束时间, price decimal(10,2) NOT NULL COMMENT 实际售价, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0空闲 1占用 2禁用, PRIMARY KEY (id), UNIQUE KEY uk_venue_date_time (venue_id,book_date,start_time,end_time), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场地时段表; CREATE TABLE booking_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL, slot_id bigint(20) NOT NULL, amount decimal(10,2) NOT NULL COMMENT 订单金额, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已使用 4已退款, pay_time datetime DEFAULT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_slot_id (slot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预订订单表;这里值得注意的点time_slot表的唯一索引uk_venue_date_time保证了同一场地同一日期同一时间段只能有一条记录。这就是防重的最底层屏障。为什么不把日期和时间直接写在订单表里因为时段要先被创建出来用户只能从已有的时段中选择。这样做的好处是天然避免了“两个订单时间段交叉重叠”的复杂判断。price字段在时段表里冗余了一份方便做活动价、早鸟价也避免场地调价后历史订单算错账。2.3 数据初始化给场地批量生成一天的时段启动项目后一般需要一个“生成排场”的功能管理员选择某个场地和日期系统自动按固定时长拆分成多个时段。这个逻辑很像电影院排片先把可售的“座位时间片”创建出来用户再买。最简单的拆分方法从早上10点到晚上22点按2小时一段生成。代码示意public void generateSlots(Long venueId, String date) { LocalDate bookDate LocalDate.parse(date); LocalTime start LocalTime.of(10, 0); LocalTime end LocalTime.of(22, 0); ListTimeSlot list new ArrayList(); while (start.isBefore(end)) { LocalTime slotStart start; LocalTime slotEnd start.plusHours(2); TimeSlot slot new TimeSlot(); slot.setVenueId(venueId); slot.setBookDate(bookDate); slot.setStartTime(slotStart); slot.setEndTime(slotEnd); slot.setPrice(venueService.getById(venueId).getPricePerHour() * 2); slot.setStatus(0); list.add(slot); start slotEnd; } timeSlotService.saveBatch(list); }这段代码看着简单但有几个容易错的地方一定要判断这个场地在指定日期是否已经生成过时段否则重复插入会撞唯一索引时段跨天问题也要考虑例如23:00到次日1:00的场次日期归属应该算开始日期。我这个项目没有做深夜场所以暂不处理。3. 用户端核心流程实现浏览、下单、支付、取消的编码要点3.1 接口先规划好不要边写边想我规划的主要接口如下功能请求方式接口路径说明场地列表GET/venue/list只返回启用状态的场地某场地某日时段GET/venue/slots?venueIddate返回空闲时段创建订单POST/order/create入参slotIduserId模拟支付POST/order/pay入参orderNo取消订单POST/order/cancel待支付或已支付均可取消管理员禁用时段POST/admin/slot/disable需要管理员权限如果你做了登录前端还要有注册登录接口。我这里为了突出预订流程假设已经通过拦截器把当前用户信息放进了ThreadLocal。3.2 创建订单的事务与行锁创建订单是整套系统最关键的一个方法。典型错误写法是先查询时段状态是否为0再插入订单再更新时段状态。这样写并发下必出问题。两个请求同时查询到状态为0然后都插入订单时段状态被更新两次但两个订单都关联了同一个时段。正确做法是把“检查状态更新状态”合成一条原子SQL利用受影响行数判断是否抢订成功Transactional(rollbackFor Exception.class) public Long createOrder(Long slotId, Long userId) { TimeSlot slot timeSlotMapper.selectById(slotId); if (slot null || slot.getStatus() ! 0) { throw new RuntimeException(该时段不可预订); } // 原子更新仅当状态为0时更新为1 int rows timeSlotMapper.updateStatusByIdAndStatus(slotId, 1, 0); if (rows 0) { throw new RuntimeException(手慢了该时段已被抢走); } // 创建订单 BookingOrder order new BookingOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setSlotId(slotId); order.setAmount(slot.getPrice()); order.setStatus(0); orderMapper.insert(order); return order.getId(); }对应的Mapper方法用注解实现Update(UPDATE time_slot SET status 1 WHERE id #{id} AND status 0) int updateStatusByIdAndStatus(Param(id) Long id, Param(newStatus) int newStatus, Param(oldStatus) int oldStatus);这里再强调一下为什么这条原子更新能防超卖因为它把“读状态”和“改状态”合并为一条数据库指令InnoDB会锁住这一行直到事务提交。两个事务同时执行时只有一个成功另一个更新行数为0直接抛出“手慢了”的提示。3.3 支付与取消的状态流转校验订单创建成功后用户跳转到支付页面。项目里没有接真实的微信/支付宝而是用“模拟支付”按钮代替。真正的支付流程应该有回调但作为学习项目模拟支付足够展示状态流转。支付方法同样需要注意原子性只有“待支付”状态才能变成“已支付”不能出现已取消的订单被支付成功的情况。Transactional public void pay(String orderNo) { int rows orderMapper.updateStatusByOrderNo(orderNo, 1, 0); if (rows 0) { throw new RuntimeException(订单状态异常无法支付); } // 扣减库存在这里其实已经在创建订单时完成了 orderMapper.updatePayTimeByOrderNo(orderNo, new Date()); }取消订单逻辑要区分是否已经支付待支付订单取消直接把时段状态改回0订单状态改为已取消。已支付订单取消需要走退款逻辑把订单变成“退款中/已退款”同时释放时段。这里一个小细节已支付订单取消时如果这个时段已经开始比如现在是晚上8点订单订的是19:00-21:00应该不允许取消。所以取消前要判断book_dateend_time与当前时间的关系。4. 并发抢订的防超卖设计从数据库锁到Redis分布式锁4.1 为什么synchronized不可靠很多同学第一步会想到加synchronized锁住createOrder方法。在单实例部署下它确实能解决同一个JVM内部的线程竞争但存在两个问题一是性能差整个方法被串行化高峰期所有用户挤在一把锁上二是只要以后部署多个实例synchronized就失效了因为不同Tomcat进程之间互相不感知。对于单体教学项目用数据库原子更新已经够用。但如果你想把项目写得更有亮点或者确实有多实例部署的需求可以考虑Redis分布式锁。4.2 Redis分布式锁实现方案用Redis实现“时段锁定”的典型做法直接在 set 命令上加锁释放时用Lua脚本保证原子性。网上有很多现成封装但要注意不能只依赖SETNX后DEL因为中间逻辑异常会导致锁永远不释放。我这里给一个更贴合场景的替代方案把“锁定”操作放到Redis的value里key为slot:lock:{slotId}value为订单号。创建订单前尝试执行SET slot:lock:123 5d0e...f9 NX EX 120NX只有当key不存在时才能设置成功相当于抢锁。EX 120锁120秒自动过期防止持有者崩溃后锁不释放。value存订单号可以在解锁时校验是不是自己的锁。拿到锁之后再去查数据库状态、创建订单、更新数据库状态最后释放锁。释放锁要先用Lua比对value再删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这种做法比数据库原子更新更“轻”吗其实没有更快因为仍然要查一次库、更新一次库但它把等待锁的冲突前置到了Redis避免了数据库行锁等待放大。对课程设计来说能在答辩时讲清楚这两种方案的区别已经是很不错的加分项。4.3 验证并发结果同一时段只放行一个请求我自己用JMeter做了个简单压测线程组设置100个并发全部请求同一个slotId创建订单。结果数据库里只有一条订单记录时段状态为1其余99个请求返回“该时段已被抢走”。这个结果就是防超卖最直观的证据。不过压测时有个容易忽略的坑如果createOrder方法没有加Transactional那条原子更新虽然成功后面插入订单失败时不会回滚时段被占用却没有对应订单。所以创建订单这类涉及多表写操作的方法务必加上事务并且让RuntimeException触发回滚。5. 交付前的文档准备调试文档、LW设计文档、讲解思路5.1 调试文档写的不是操作流程而是“环境真相”很多人觉得调试文档就是“安装JDK、导入数据库、运行Application”实际交付时才发现根本不够。我这次整理的调试文档包含这几块开发环境精确版本JDK 8u202、Maven 3.6.3、MySQL 8.0.27、Redis 6.2。数据库初始化提供schema.sql和data.sql并写清楚如果要重新生成排场数据需要在后台点哪个按钮或调用哪个接口。配置文件说明application.yml里的数据库账号、Redis密码、端口每一个都标注用途。启动步骤先启Redis再启MySQL最后运行main方法如果使用IDEA要说明Lombok插件必须安装否则编译直接报错。常见错误对照表例如Access denied for user、Unknown database、Unable to find a SpringBootConfiguration分别是什么原因。调试文档最大的意义是让接手者不必“猜”。你写的时候应该假设对方完全不了解你的项目结构把坑提前填平。5.2 LW设计文档的结构要领LW设计说明文档往往比源码本身更影响最终成绩。结构上我按这样的顺序写项目背景与意义说明球馆预约手工管理混乱线上化能提高效率。需求分析角色分析、功能需求、性能需求并发人数、响应时间。系统设计模块划分、技术架构图、数据库E-R图、表结构说明。详细设计每个模块的核心类、关键方法、时序逻辑。系统实现与测试每个功能截图附上核心代码片段写清测试用例和结果。这里要特别注意不要贴大段完整代码老师更愿意看到“为什么这么写”。比如并发控制部分可以画一个抢订时序的表格对比“不加锁”“数据库原子更新”“Redis锁”三种情况的结果这比堆代码有说服力得多。5.3 项目讲解时怎么讲“亮点”答辩或项目展示时不要平铺直叙地过功能。我的思路是准备一个“三分钟亮点”第一分钟讲项目价值直击球场管理痛点。第二分钟讲技术挑战重点讲并发抢订、状态机一致性。第三分钟现场演示一个“极端场景”比如开两个浏览器同时抢同一时段展示只有一个能成功。如果你能现场口头画出订单状态流转图待支付→已支付→已使用/退款并解释每一步怎么防止不一致基本就能证明你是真正做过这个项目而不是只跑了别人的源码。6. 部署到服务器上常见环境坑与排查路径6.1 打包启动时容易卡住的三个点如果你用Maven打包最先遇到的大概率是测试跑挂。可以跳过测试mvn clean package -DskipTests打包成功后把jar包丢到服务器。启动命令我一般用java -jar basketball-venue-system.jar --spring.profiles.activeprod但这里有个经典问题比如把打包后的jar放到/opt/app/下如果配置文件里有相对路径如文件上传目录./uploadWindows下和Linux下的行为会不一样。最好在application.yml里用绝对路径或通过启动参数传入。还有一个小坑Spring Boot 2.x默认内嵌Tomcat不需要额外安装Tomcat。如果你的服务器80端口被占用记得改配置文件里的server.port。6.2 数据库无外网连接、时区问题、Redis启动失败我在调试中碰到最典型的问题是服务器上MySQL端口没对外网开放或者防火墙限制导致本地连不上。排查命令netstat -tlnp | grep 3306发现3306监听仅供本地后需要确认安全组和防火墙规则。如果是学习环境可以暂时简单放行。另一个高频率问题是时区MySQL连接串必须加上serverTimezoneAsia/Shanghai否则会报The server time zone value的异常。同时保存日期时尽量用LocalDateTime避免 java.util.Date 在不同时区下出现8小时偏差。Redis启动失败也很常见尤其是redis.conf里设置daemonize yes后没有给日志文件权限。启动后立刻用redis-cli ping验证是否返回PONG。6.3 日志是唯一可信的现场很多人出了Bug第一步是问别人其实更高效的手段是看日志。Spring Boot默认日志级别是INFO如果怀疑某个SQL有问题可以在命令行加--logging.level.com.example.mapperdebug这样MyBatis映射的SQL和参数会直接打印出来。我之前排查一个“查询时段总是空”的问题就是靠日志发现book_date参数传成了字符串MySQL做了隐式转换后没走索引导致索引失效。汇总一下常见问题对照现象原因解决启动报Access denied for user数据库账号或密码错误核对application.yml测试mysql -u root -p中文乱码数据库字符集不是utf8mb4建库指定CHARACTER SET utf8mb4连接串加参数刷新页面没数据时段没有生成先调用排场生成接口确认time_slot表有数据点击支付失败订单状态已被取消检查是否设置超时自动取消看日志中状态值最后说点个人体会这类基于JavaSpringBoot的篮球场馆预订系统代码量并不大但真正把它调试到能稳定运行、文档写到可以让别人接手其实很考验细节。我最大的体会是不要跳过并发这一关。哪怕是课程设计只要你把数据库原子更新那行SQL的原理搞清楚整个项目的含金量立刻不一样。如果时间充裕你还可以继续做这些扩展把模拟支付换成实际接口、增加场地图片上传、加入用户信用积分、用Quartz定时生成每日排场、甚至把管理端拆成独立的后台。这套系统本质上就是一个“预约订单支付”的模型换到羽毛球馆、健身房、会议室预订完全能复用。如果你正在做类似项目建议先从数据库和状态机开始不要一上来就写控制层。把“时段状态”和“订单状态”这两条线的流转理清楚后面写代码会非常顺。遇到并发超卖问题优先用数据库原子更新不要迷信复杂分布式方案简单可靠最重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑