简介:基于Java+SSM的体育场地预约使用系统毕业设计资源包,内含完整项目源码、SQL数据库脚本与配套使用文档,整体方案可直接用于毕业设计或课程设计。资源面向软件工程、计算机科学与技术等专业的在校学生和开发者,适合学习SSM框架整合、用户管理、场地预约与后台管理等典型业务逻辑,基础较好者还可二次开发实现个性化功能。压缩包共包含1157个文件,主要涉及JSP与Java后端代码、HTML/CSS/JS前端页面、jar依赖库、数据库脚本以及大量图片与图标素材,整体大小约19MB,目录结构清晰便于部署调试。代码已在Windows和macOS多环境运行成功,项目获导师认可并通过答辩,评分达95分,属于高分优秀样例。目前已有68人浏览学习,下载后可参考使用文档、数据库说明与源码注释,系统掌握从环境搭建到功能实现的全过程,适合作为毕业设计模板或SSM入门进阶实践。
1. 为什么说SSM体育场地预约系统是毕业设计里的“硬通货”
一到毕业季,Java方向的学生翻来覆去就在几个经典题目里打转:图书管理系统、宿舍管理系统、商城。问题是这些题目做了十年,评委老师看第一眼就知道你有没有用心。基于Java+SSM的体育场地预约系统属于另一类——业务上真的有人用,技术上正好把SSM三大框架的活全干了一遍,数据库设计也有东西可讲,拿去做毕业设计,答辩时不愁没话说。
这系统解决的是个很具体的痛点:学校的篮球场、羽毛球场、乒乓球台,以前靠现场登记或者微信群接龙,场地利用率低、纠纷多。预约系统把它变成了“在线查场地、选时间段、下单支付或免费锁定、后台排期管理”的标准流程。SSM框架在这里不是炫技,而是恰好匹配这种中小型管理系统的全部需求:Spring管对象和事务,SpringMVC收请求还数据,MyBatis把场地和订单映射进MySQL。
适合谁?两类人。第一类是正在做毕业设计的Java学生,需要一套完整、能跑、能讲解的SSM落地样例;第二类是刚入职的初级开发,想看看一个典型SSM单体项目是怎么分层、怎么写SQL、怎么处理预约冲突这类业务细节的。下面我按自己做过的方案,把这个系统从表结构到部署踩坑拆开讲一遍。
2. 预约系统的业务模型与SSM分工:先想清楚再动手
2.1 三个角色、六张表:预约系统的核心领域模型
做毕业设计最忌讳一上来就建表。体育场地预约系统,角色只有三种:管理员、普通用户(学生/教职工)、场地。业务动作听起来复杂,拆开就是四个词:查场地、提交预约、审核/支付、改状态。
围绕这四个动作,我一般建六张表。第一张user用户表,字段没什么特别的,但要注意区分角色用role字段而不是再建一张角色表——毕业设计里权限逻辑简单,一张表加个字段够用了。第二张venue场地信息表,存场地名称、类型(篮球/羽毛球/网球)、位置、开放时间段、价格(如果是收费场)、图片路径、状态(启用/停用)。第三张venue_type场地类型表,别小看这张表,答辩时评委问你“第三范式怎么体现的”,就靠它撑场面——因为场地名称和场地类型是两个维度,拆开才能避免冗余。
第四张booking_order预约订单表,这是全系统最核心的一张表。字段要有:订单号、用户ID、场地ID、预约日期、开始时段、结束时段、总价(如果计费)、状态。状态用int还是String?我的建议是int,因为预约状态至少经历“待审核→已确认→已完成/已取消”四种流转,int 写死在代码常量里,查询和判等都比字符串稳妥。
第五张booking_record或者叫预约历史表——如果你做了“同一场地同一时段只能被预约一次”的约束,那么历史的预约记录就是冲突检测的基础数据。第六张admin_operation_log操作日志表。这张表很多毕业设计不做,但我强烈建议加,因为答辩时评委常问“如何证明系统有可追溯性”,有日志表你直接演示给评委看,从后台把某条场地状态变更记录调出来。
六张表之间的关系:venue_type是venue的一方,venue和user是多对一(一个场地可以被多个用户预约),booking_order是中间的那张关联表,把用户、场地、时间段关联起来。表建对了,后面的代码写起来就顺。
2.2 一场预约的生命周期:从查表到状态机流转
写代码之前,先按状态机思路把“预约”这个动作在脑子里过一遍,我拿羽毛球场的预约举例:
用户登录后进入场地列表,看到的是所有status=1(启用)的场地。点进详情页,前端会请求这个场地在某个日期下的所有已占用时段——这是个SELECT查询,SQL 大概是SELECT start_slot, end_slot FROM booking_order WHERE venue_id=? AND booking_date=? AND status IN (1,2),其中 status 1 是待确认,2 是已确认,因为待确认虽然没付钱但也应该锁住时段。
用户选好时段点“提交预约”,后端生成一条booking_order,状态置为待审核(status=1)。这时候要不要立刻扣减场地库存?大部分场地系统有“审核”环节,所以默认不扣库存,等管理员确认后才算占住时段。
管理员登录后台,看到待审核列表,点“通过”,这条订单的状态变成已确认(status=2)。此时要做的并发控制是——再次检查同一时段是否已经被别人先确认了,如果被占了,这条订单要自动流转成“已取消”并提示换时段。
用户实际到场使用后,管理员或系统定时任务把状态置为“已完成”(status=3)。如果用户主动取消,状态置为“已取消”(status=4)。这里一个容易出错的细节:用户取消的只能是待审核或已确认状态,已完成的是不能退的。
这个状态流转听起来简单,真正写代码时容易漏的是并发场景。比如两个用户同时抢同一个场地同一个时间段,后端代码如果用“先查有没有冲突,没有再插入订单”的写法,在高并发下必然翻车。解决办法有两个层面:第一,表层加约束,数据库唯一索引建(venue_id, booking_date, start_slot);第二,MyBatis 的插入语句里带上条件AND NOT EXISTS (SELECT 1 FROM booking_order WHERE ...),让数据库帮你挡住第二次插入。具体 SQL 后面第4章会给。
提示:我见过很多学生把“已确认”和“已完成”混用一个状态,导致后续统计“场地使用率”时算不清。建议从建表那一刻就把状态机定死,代码里用常量类OrderStatus定义,不要散落在各个 Service 里。
3. SSM三大框架在这个系统里各自扛什么活
3.1 SpringMVC 层:URL设计决定前端好写不好写
SpringMVC 在这套系统里是门面,所有请求先进它。URL 设计得合理,前端和联调就省一半力。我一般这么分:/user/login、/venue/list、/venue/detail、/booking/submit、/booking/myList、/admin/booking/audit、/admin/venue/edit。注意这里 Controller 里的/admin/前缀不是摆设——配合拦截器做权限控制,admin前缀下的所有请求都被拦截器校验session.getAttribute("role")是否为管理员,是说明这个系统的权限是写在 URL 路由层面的。
一个典型的 Controller 方法,比如提交预约,长这样:
@Controller @RequestMapping("/booking") public class BookingController { @Autowired private BookingService bookingService; @RequestMapping(value = "/submit", method = RequestMethod.POST) @ResponseBody public Result submit(@RequestBody BookingSubmitVO vo, HttpSession session) { // 从session里拿到登录用户ID,不用前端传,防止越权 User user = (User) session.getAttribute("loginUser"); if (user == null) { return Result.error(401, "请先登录"); } try { bookingService.submitBooking(vo, user.getId()); return Result.success("预约提交成功,等待管理员确认"); } catch (BookingConflictException e) { // 冲突是业务异常,由Service层抛出 return Result.error(400, e.getMessage()); } } }逻辑说明:这个 Controller 方法做了三件事——校验登录态、把前端传过来的 VO 转成业务参数、调用 Service 并统一封装返回。@ResponseBody表示直接返 JSON,这里用的是Result统一返回体,包含code/message/data三要素。前端拿到code==200跳转,拿到code==400直接弹错误文案,减少了联调时对返回格式的争论。
参数说明:BookingSubmitVO是一个接收前端 JSON 的实体类,包含三个字段——venueId、bookingDate(字符串 yyyy-MM-dd)、startSlot(int 型,比如 8 表示 8:00-9:00)。为什么用startSlot而不是直接用startTime?因为在场地预约里,时段的粒度是固定的,用 int 的下标比用字符串时间更好排序、更好做冲突判断。
3.2 Service 层:事务边界和业务异常一个都不能少
Service 层是这套系统的业务核心,也是我写代码时最花心思的地方。它的职责是把 Controller 里的业务逻辑细分出来,并且用@Transactional把“读-判-写”的原子性保住。
拿“提交预约”这个动作举例,Service 层至少要做三件事:第一,校验场地是否存在且处于启用状态;第二,校验用户选择的日期是否在过去、是否超过可提前预约的天数;第三,做冲突检测并插入订单。这三件事应该放在同一个事务方法里,否则第一步校验通过了,第二步插入时数据库报唯一索引冲突,事务不会回滚干净。
Service 的接口和实现我习惯分两层,接口定义方法签名,实现类@Service标注:
@Service public class BookingServiceImpl implements BookingService { @Autowired private BookingOrderMapper bookingOrderMapper; @Autowired private VenueMapper venueMapper; @Override @Transactional(rollbackFor = Exception.class) public void submitBooking(BookingSubmitVO vo, Integer userId) { Venue venue = venueMapper.selectById(vo.getVenueId()); if (venue == null || venue.getStatus() != 1) { throw new BookingConflictException("场地不存在或已停用"); } // 核心:冲突检测的SQL,在Mapper里写 int count = bookingOrderMapper.countConflict(vo.getVenueId(), vo.getBookingDate(), vo.getStartSlot(), null); if (count > 0) { throw new BookingConflictException("该时段已被预约,请选择其他时间段"); } // 构造订单并插入 BookingOrder order = new BookingOrder(); order.setOrderNo(generateOrderNo()); // 订单号生成规则:yyyyMMddHHmmss+随机4位 order.setUserId(userId); order.setVenueId(vo.getVenueId()); order.setBookingDate(DateUtils.parse(vo.getBookingDate())); order.setStartSlot(vo.getStartSlot()); order.setEndSlot(vo.getStartSlot() + 1); // 默认一次预约1小时 order.setStatus(OrderStatus.PENDING_AUDIT); bookingOrderMapper.insertSelective(order); } }逻辑说明:注意@Transactional(rollbackFor = Exception.class)这个写法,它比默认的@Transactional多了一个参数,意思是无论遇到受检异常还是非受检异常都回滚。说白了这个注解是给事务里的三层操作兜底的——如果插入订单时报错,前面的校验逻辑虽然不涉及写库不存在回滚问题,但这种方法统一了行为:事务中任何一步失败,整个方法不留残余数据。
参数说明:countConflict这个查询在 Mapper 里拼的 SQL 是关键,第四行传的第四个参数是null,代表“排除某个订单ID”。为什么需要这个参数?因为当管理员在后台修改预约时间时,要把当前正在编辑的这条订单自己排除掉,否则它会和自己冲突。
3.3 MyBatis 层:XML里的SQL才是这个系统的生死线
很多人写 SSM 项目,觉得 MyBatis 就是自动生成一堆XXXMapper.xml,CRUD 就完事了。说句实在话,这套系统的业务复杂度恰恰集中在 MyBatis 的 XML 里。
字段映射不是最麻烦的,麻烦的是动态 SQL。比如场地的列表查询,用户在前端可能选类型筛选、按状态筛选、按价格区间筛选,你不能为每个条件写一个方法,要用<where>+<if>动态拼接:
<select id="selectByCondition" resultType="com.example.entity.Venue"> SELECT * FROM venue <where> <if test="typeId != null"> AND type_id = #{typeId} </if> <if test="status != null"> AND status = #{status} </if> <if test="venueName != null and venueName != ''"> AND venue_name LIKE CONCAT('%', #{venueName}, '%') </if> </where> ORDER BY id DESC </select>逻辑说明:<where>标签会自动去掉第一个条件前面的AND,这个细节很重要——如果你手写WHERE 1=1也能跑,但会被有经验的面试官扣分。用<where>是 MyBatis 的推荐写法,因为WHERE 1=1虽然结果一样,但 MySQL 的优化器对这种情况的处理不如标准写法干净。
参数说明:CONCAT('%', #{venueName}, '%')是 MySQL 的模糊匹配写法,注意这里不能用%${venueName}%——后一种是字符串拼接,存在SQL注入风险。用#{}是预编译,MyBatis 底层是PreparedStatement的参数占位,这一点在答辩时被问到“如何防SQL注入”时可以直接答:全项目没有一处${}拼接用户输入。
提示:MyBatis 还有一个经常被新手踩的地方——resultType和resultMap的选择。如果数据库字段是下划线命名(venue_name),实体类属性是驼峰命名(venueName),建议在applicationContext.xml里配置mapUnderscoreToCamelCase=true,然后全项目统一用resultType,省得为每张表写一个resultMap。
4. 把系统跑起来:本地搭建的最小命令与核心配置
4.1 开发环境清单与项目骨架:照着搭不出错
这是整套系统最容易被忽视但最重要的环节——环境不一致会导致项目在你电脑上跑起来、在老师电脑上跑不起来。以下是我实测多次的组合,注意版本之间是配套的,不能乱换:
| 组件 | 版本建议 | 备注 |
|---|---|---|
| JDK | 1.8(8u202) | SSM 经典组合推荐 Java 8,稳定且 Tomcat 支持好 |
| Maven | 3.6.3 | 3.6.3 与 JDK8 兼容性最好,3.8+ 会因中央仓库 HTTPS 问题报错 |
| Tomcat | 8.5.x | 必须用 8.5,不要用 9(Servlet 规范变了) |
| MySQL | 5.7 | 8.0 也可以,但 5.7 的ONLY_FULL_GROUP_BY问题更少 |
| IDE | IDEA 2020+ | 装 Lombok 插件,否则编译报错看不懂 |
项目骨架用 Maven 的标准结构,pom.xml里依赖组:spring-context、spring-webmvc、spring-jdbc、mybatis-spring、mysql-connector-java、druid连接池、jackson-databind(JSON序列化)。中间层还要加javax.servlet-api(provided 范围)和jstl。这里注意mybatis-spring的版本不要高于 2.0.6,因为更高版本适配的是 Spring 5 的 Java Config,和 SpringMVC 的 XML 配置风格不匹配。
4.2 核心配置:数据源、MyBatis、SpringMVC三份XML
SSM 是 XML 配置驱动的框架,三份核心配置放resources目录下。第一份是spring-datasource.xml,管数据库连接池;第二份是spring-mybatis.xml,管 SqlSessionFactory 和 Mapper 扫描;第三份是spring-mvc.xml,管请求映射、注解驱动、视图解析器。
数据源配置我推荐用 Druid 连接池,因为它的监控页面在毕业设计答辩时特别好用——把 Druid 的StatViewServlet配好,浏览器直接访问/druid/index.html,能看到当前连接数、SQL执行次数、最慢SQL排名。这比说“我用了连接池”有说服力得多。
spring-datasource.xml关键片段:
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/stadium_booking?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> <property name="initialSize" value="5"/> <property name="minIdle" value="5"/> <property name="maxActive" value="20"/> <property name="maxWait" value="60000"/> </bean>参数说明:serverTimezone=Asia/Shanghai必须有,MySQL 8.0 连接不加这个会报时区错误;useUnicode=true&characterEncoding=utf-8是中文不乱码的关键。连接池参数里,maxActive=20对毕业设计是足够的,如果并发量真的超过20,Druid 会把多余的等待放到maxWait=60000毫秒的队列里。
MyBatis 配置里有一段容易踩坑的组合,就是mapper-locations和type-aliases-package的配合:
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.entity"/> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> <property name="logImpl" value="org.apache.ibatis.logging.stdout.StdOutImpl"/> </bean> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.mapper"/> </bean>逻辑说明:typeAliasesPackage配置后,XML 里写resultType="Venue"就能自动匹配到com.example.entity.Venue,少写全限定名。logImpl配置成StdOutImpl会在控制台打印执行的 SQL 和参数值——这是排查问题的第一助手,建议开发期打开,答辩演示时关掉,因为控制台打印太多显得不专业。
4.3 数据库初始化:六张表与测试数据一条龙
数据库脚本要一次性执行成功。我用 MySQL 5.7,建库stadium_booking,字符集全部utf8mb4。以下是核心建表脚本——订单表必须带上唯一索引来兜底并发冲突:
DROP DATABASE IF EXISTS stadium_booking; CREATE DATABASE stadium_booking DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE stadium_booking; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(128) NOT NULL, -- 明文只是演示,正规要做MD5+盐 real_name VARCHAR(50), phone VARCHAR(20), role TINYINT DEFAULT 2 COMMENT '1=管理员 2=普通用户', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE venue_type ( id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(50) UNIQUE NOT NULL ); CREATE TABLE venue ( id INT PRIMARY KEY AUTO_INCREMENT, venue_name VARCHAR(100) NOT NULL, type_id INT NOT NULL, location_desc VARCHAR(200), open_time VARCHAR(50) NOT NULL DEFAULT '08:00-22:00', price_hour DECIMAL(6,2) DEFAULT 0.00, status TINYINT DEFAULT 1 COMMENT '1=启用 0=停用', FOREIGN KEY (type_id) REFERENCES venue_type(id) ); CREATE TABLE booking_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(30) UNIQUE NOT NULL, user_id INT NOT NULL, venue_id INT NOT NULL, booking_date DATE NOT NULL, start_slot INT NOT NULL COMMENT '小时下标,8表示8点到9点', end_slot INT NOT NULL, total_price DECIMAL(6,2) DEFAULT 0.00, status TINYINT DEFAULT 1 COMMENT '1待审核 2已确认 3已完成 4已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_venue_time (venue_id, booking_date, start_slot), FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (venue_id) REFERENCES venue(id) );参数说明:uk_venue_time (venue_id, booking_date, start_slot)是防止重复预约的最后一道墙。即使 Service 层因为并发出现两次读取都没查到冲突,第二次INSERT也会被数据库拒绝并抛DuplicateKeyException。start_slot用INT是因为槽位计算远比字符串比较高效,而且前端可以用0-23的 int 数组渲染,不需要解析时间字符串。
逻辑说明:外键在毕业设计里建议保留,虽然很多人说互联网公司不用外键,但学术型项目里有外键可以展示你懂参照完整性。注意booking_order里不能只存venue_id而不冗余场地名称,因为如果管理员改了场地名称,历史订单里也要显示当时的名称——答辩时这属于“数据冗余与查询效率的权衡”,主动讲出来反而是加分项。
4.4 启动顺序与联调测试:Tomcat里的三道关卡
项目配置完,启动是有顺序的。第一步先确保 MySQL 服务启动,mysql -uroot -p能进客户端;第二步mvn clean install看编译是否通过,如果报错优先看pom.xml的依赖版本冲突;第三步在 IDEA 里配置 Tomcat,Deployment里加 artifact,Application context填/,然后启动。
启动常见的第一道关卡是 Maven 依赖没下载全,IDEA 报ClassNotFoundException: org.springframework.web.context.ContextLoaderListener,这个多半是spring-web依赖没引或者 Tomcat 的lib目录干扰。解决方案是打开项目结构检查Artifacts里有没有把 Maven 依赖的 jar 包放进WEB-INF/lib。
第二道关卡是启动时报Failed to configure a DataSource,但配置明明有——这种大多是application.properties写到了resources根目录,但 Spring 的配置扫描路径不对。SSM 项目的配置文件名和位置要严格核对:contextConfigLocation指向的必须是classpath:spring-datasource.xml等三份 XML,不要自动生成一个空白的application.properties在 classpath 下干扰它。
第三道关卡是404或者请求路径 404——这是 SSM 新手最容易遇到且最气人的错误。绝大多数不是因为代码错,而是因为 Web 应用没有把请求正确交给 DispatcherServlet。检查web.xml,必须要配置DispatcherServlet并映射/,还要配置CharacterEncodingFilter保证中文参数不乱码。这层配置忘了,页面能打开但所有 POST 请求全部 404。
数据库脚本执行、三份XML、web.xml、Tomcat运行,这四个环节按顺序过一遍,系统能正常打开登录页,后面就是在页面里点几个按钮验证主流程了。
5. SSM体育场地预约系统避坑指南:5个让人抓狂的经典坑
5.1 坑一:页面中文乱码,INSERT进去的数据在数据库里是“???”
现象:前端输入“篮球场”,保存到 MySQL 里变成“???”,控制台打印 SQL 里显示也是问号。
原因:三层乱码叠加。第一层是 JSP/HTML 页面编码不是 UTF-8;第二层是 POST 请求没有经过CharacterEncodingFilter转码;第三层是 MySQL 连接串没加characterEncoding=utf-8,而数据库表默认字符集又是latin1。
解决:第一步,JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>,HTML 文件里<meta charset="UTF-8">;第二步,web.xml里加 Spring 的CharacterEncodingFilter,forceEncoding设为true,注意这个 Filter 必须放在所有 Filter 的最前面(<filter-mapping>顺序);第三步,确认jdbcUrl里有characterEncoding=utf-8,第四步,建库时带上DEFAULT CHARSET utf8mb4,这是最省事的方案。
5.2 坑二:MyBatis 提示Invalid bound statement (not found)
现象:Mapper 接口的方法在调用时抛org.apache.ibatis.binding.BindingException,XML 文件明明写了对应的<select>。
原因:最常见的是 XML 文件的 namespace 写错,或者 XML 文件没有被打进 target 目录。IDEA 里默认resources目录下才有 xml 会被识别,如果你把 Mapper XML 放在src/main/java目录下,Maven 默认只编译.java不拷贝.xml,运行时就找不到。
解决:把 Mapper XML 统一放到src/main/resources/mapper/,并且检查pom.xml里build/resources/resources是否把src/main/java目录也加进去了(如果按理放好了就不用加)。另外,检查MapperScannerConfigurer的basePackage是否和 Mapper 接口实际包路径一致,一句话——XML 的 namespace 必须等于 Mapper 接口的全限定名,方法 ID 必须等于接口方法名。这个坑熟练后一分钟定位,新手能卡一整天。
5.3 坑三:预约查询能查出已取消的订单,导致场地看起来被占了
现象:用户在前端看到某场地某个时段不能用,但管理员在后台查订单,发现那条订单状态是“已取消”,前端没有正确过滤。
原因:前端场地状态查询的 SQL 只连接了venue和booking_order两张表,没有加status过滤条件,把已取消的订单也算进了占用时段。
解决:统一所有“查占用时段”的 SQL,统一加AND status IN (1, 2)——待审核和已确认的才算占用,已取消和已完成都不占用时段(已完成是历史数据,当前日期不会出现已完成,但为了代码严谨都写清楚)。建议这个过滤条件在 Mapper XML 里写两次,不,写一次,抽成一个公共 SQL 片段<sql id="occupiedFilter">,然后在多个<select>里<include refid="occupiedFilter"/>。这既避免了重复代码,也防止以后有人漏改一处。
5.4 坑四:并发预约时两个用户都提示“预约成功”,但库里只有一条
现象:两个用户同时提交同场地同时段的预约,两个浏览器都返回“成功”,刷新后看列表,只有一条订单在。
原因:典型的并发竞争。A 和 B 的事务几乎同时执行countConflict查询,都发现 0 条冲突,然后都执行INSERT,最终数据库唯一索引拦住了一条,但被拦住的那个事务在提交时抛异常,如果这个异常没被 Controller 捕获,A 拿到了“成功”的假响应。
解决:双重防护。第一道防线是数据库唯一索引,它保证数据层绝不重复;第二道防线是 Service 层把countConflict和INSERT合并在一个@Transactional方法内,并对DuplicateKeyException做捕获,转换成业务异常返回“该时段刚刚被预约,请重新选择”。注意捕获时要e.getCause()判断是不是DuplicateKeyException,但更简单的做法是直接捕获Exception然后看消息里有没有Duplicate entry。有经验的开发不会把底层异常直接抛给前端,而是统一返回业务码 400。
5.5 坑五:Druid连接池警告discard long time none received connection
现象:Tomcat 跑了两天,系统偶尔出现卡顿,控制台里有大量discard long time none received connection的日志,接着偶发Communications link failure。
原因:MySQL 默认wait_timeout是 8 小时,如果连接池里一个连接空闲超过 8 小时,MySQL 主动断开了它,但 Druid 不知道,继续拿着这条失效连接给业务用,于是报通信故障。
解决:在 Druid 配置里加三个参数——testWhileIdle=true(连接空闲时检测)、timeBetweenEvictionRunsMillis=60000(每60秒检测空闲连接)、validationQuery=SELECT 1(检测SQL)。这三个组合起来,Druid 会在给业务使用连接之前先验证是否可用,不可用的就丢弃重建,就不会拿着死连接干活了。这个坑属于“必看但非必踩”的,因为只有系统运行超过8小时才会暴露,很多毕业设计演示一两个小时根本看不到。
6. 答辩与扩展:把预约冲突检测从代码挪进SQL,再演示性能
系统能跑通只是及格,答辩时能讲出“为什么这么设计”才是高分的关键。最后给一个我在类似项目里反复使用的进阶改造,也是被评委问过几次“你怎么保证并发下不错乱”之后的升级方案——把预约冲突检测完全下沉到SQL层,让数据库做唯一裁决者。
改造点在BookingOrderMapper里新增一条插入语句,核心思路是把NOT EXISTS子查询放进INSERT语句中:
INSERT INTO booking_order (order_no, user_id, venue_id, booking_date, start_slot, end_slot, status) SELECT #{orderNo}, #{userId}, #{venueId}, #{bookingDate}, #{startSlot}, #{endSlot}, 1 FROM dual WHERE NOT EXISTS ( SELECT 1 FROM booking_order WHERE venue_id = #{venueId} AND booking_date = #{bookingDate} AND start_slot = #{startSlot} AND status IN (1, 2) )逻辑说明:这条 SQL 的巧妙之处在于INSERT INTO ... SELECT ... WHERE NOT EXISTS——如果存在冲突记录,SELECT结果为空集,INSERT 就什么都没做,返回影响行数为 0。在 Service 层判断insertCount == 0,就知道是冲突了,业务层连预查询都不需要了。
参数说明:FROM dual是 MySQL 里为了凑足SELECT ... FROM的语法糖,Oracle 风格,MySQL 从 5.7 也支持。如果看着别扭,也可以直接SELECT #{orderNo}, ...(MySQL 允许不含 FROM 的 SELECT),但在讲课场景里用dual更容易被评委认出来路。
升级后,Service 层代码瞬间瘦身,原来countConflict + insert的两步走变成了一个insertIfNotConflict,而且天然是原子操作,不用事务也能避免并发问题。我把这个方法称做“让数据库替你思考”。
答辩时的演示建议是三步走:第一步,打开两个浏览器窗口,用两个不同账号同时提交同一个场地的同个时段预约,能稳定地只有一个成功;第二步,打开 Druid 的 SQL 监控页,指着一条完成时间极短的 insert 语句说“这就是防冲突的关键,数据库帮我们挡住了并发的第二条”;第三步,如果评委追问“你了解秒杀系统的库存扣减是怎么做的吗”,可以说“原理类似,只是我的场景是场地 slot 的独占锁,秒杀是把字段做原子自减”。把这三个点讲完,整场答辩的核心技术水平基本就展示到位了。
最后说一个我在这个项目上最有体感的习惯:先写SQL,再写Service,最后写Controller。这个顺序看着反直觉,但实际做下来,表结构设计时就把所有查询的可执行性验证了一遍,后面写代码只是把SQL填到Mapper里,翻车率非常低。如果你做毕设或练手项目,试着从建表和SQL开始,不要在Controller里纠结三层怎么拆,SSM这套技术栈的天花板不在代码,在设计。希望帮到你。
本文还有配套的精品资源,点击获取