简介:本资源是一套面向计算机专业本科生及Spring Boot初学者的毕业设计级实战项目,聚焦景区民宿在线预约场景,解决传统旅游服务中信息不对称、预约流程低效等实际问题。压缩包共883个文件,涵盖157个Java后端核心代码、70个Vue与156个JS前端交互逻辑、49个CSS样式文件、19个XML配置及2个YML配置文件,辅以MySQL建表SQL、启动脚本(bat)和响应式页面资源,整体大小28.93MB。项目采用标准MVC架构,完整实现用户管理、民宿展示、在线预约、订单处理与Spring Security权限控制,并提供配套数据库设计说明与源码级注释。学习者可直接导入运行,深入理解Spring Boot自动配置、MyBatis数据层集成、前后端分离开发模式及响应式界面适配实践,是兼具教学性、工程性与可扩展性的典型Web应用范例。 做景区民宿预约系统这类题目的人,每年都不少。SpringBoot 已经是 Java 后端开发里绕不开的框架,民宿预约又是典型的管理信息系统场景,两者结合起来,就成了很多毕业设计和课程设计的热门选题。但说实话,我见过太多人拿到题目之后,第一反应是去下载一份现成源码,改个数据库连接、跑起来就以为万事大吉。这种作品拿到答辩现场,老师随口问一句“并发下单会不会重复预订”“订单状态是怎么流转的”“房态冲突你是用什么 SQL 判定的”,十有八九答不上来。
这篇文章,我想把做《基于SpringBoot框架开发的景区民宿预约系统的设计与实现》时,从需求分析、数据库设计、核心代码实现、并发问题处理到论文整理的全过程,按我的真实做法捋一遍。里面会直接给出核心表结构、关键代码和判定逻辑,也会把我踩过的坑和答辩时容易被追问的点都标出来。适合正在做同类选题、打算自己动手写源码而不是纯靠下载的朋友参考。
1. 景区民宿预约系统,真正要解决的核心问题
很多人做这个系统之前,其实没有真正住过景区民宿,也不知道民宿老板每天是怎么处理预订的。我简单还原一下线下场景:客人打电话或者到店问“X月X日到X月X日的房还有没有”,老板翻一个纸质登记本,或者翻微信群里的聊天记录,手动查一下,有空房就口头答应,然后记个时间。等客人到店发现房间已经被另一个渠道订走了,纠纷就来了。这个场景里最核心的矛盾,是房态信息不透明、预订决策依赖人工记忆。
所以这个系统的本质不是给民宿做个展示页面,也不是单纯地把线下流程搬到线上,而是要解决两件事:一是房态实时可查,二是预订过程有据可依。系统要处理的业务对象,说到底就是“一个客人在某段时间内占用某个房源”这件事。把这个占用关系用数据模型表达清楚、用代码保护起来,系统的价值就出来了。
1.1 需求来源和角色划分
这类系统的需求来源一般有两类。第一类是课程设计或毕业设计题目,需求文档里已经明确了要做什么;第二类是景区周边民宿经营者想要一个轻量管理工具,需求相对零散。无论哪种来源,角色基本都可以划分为用户端和管理员端两块。
用户端的核心需求包括:注册登录、浏览民宿信息、按日期搜索可预订房源、提交预订、取消订单、查看自己的订单记录。管理端的核心需求包括:维护民宿和房源信息、上下架房源、查看所有订单、确认订单、办理入住和退房、查看简单的统计报表。
功能不需要多,但每一条都要能说清楚它支持了什么业务操作。我不建议一上来就堆优惠券、积分、会员等级这类功能,那会让系统范围完全失控。我在做的时候就把功能列表控制得非常克制,核心就围绕“房源”和“订单”两个字,其余都是辅助。
1.2 容易被忽略的业务规则
需求文档里通常不会直接写业务规则,但这些东西躲不开,而且是整个设计的重点:
- 一个房源在同一时间段内只能被一个有效订单占用;
- 如果客人预订了5月1日到5月3日的房间,5月3日当天是可以被下一位客人预订的,也就是说离店日当天不占用房源;
- 订单未支付时不能永久占用房源,超过一定时间要自动释放,否则用户随便下单会把房态堵死;
- 已经入住的订单不能随意取消;
- 取消订单之后,房源要立刻恢复可预订状态。
这些规则决定了表结构怎么设计、状态字段怎么定义、冲突检测 SQL 怎么写。如果一开始不把业务规则想清楚,后面返工成本非常高。我最初做的版本就没有考虑“超时未支付自动关单”,结果测试时自己下了好几单,全部卡在待支付状态,管理员只能手动改状态,非常尴尬。
2. 技术选型要能讲出理由:为什么是SpringBoot加这套组合
技术选型这部分,不是简单写个技术清单就完了。答辩的时候老师一定会问“为什么选这个不选那个”,你得能说出个一二三来。我先把我最后用的方案列出来,再一个个解释理由。
2.1 后端框架:SpringBoot到底解决了什么问题
后端我选的是 SpringBoot 2.x,搭配 MyBatis Plus 做持久层框架。先说 SpringBoot 本身。早年的 SSM(Spring + SpringMVC + MyBatis)项目,光配置文件就有七八个:web.xml、spring-mvc.xml、spring-mybatis.xml、mybatis-config.xml,每个里面还得配一堆 bean。刚接触项目的同学,经常卡在环境搭建这一步,连一个 HelloWorld 都跑不起来。SpringBoot 的核心价值就是“自动配置 + 起步依赖”,把大量常规配置内置了,你只需要关注业务代码。
具体到开发体验上,三个点最明显。第一,内嵌 Tomcat,不用单独装容器,main 方法里直接启动。第二,pom.xml 里引入 spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java 这几个依赖,项目就能跑起来。第三,application.yml 里配置好数据源、端口号,开发环境就绪。省下来的时间全部可以投入到业务逻辑里。
这里我建议大家选 SpringBoot 2.7.x 这个版本,而不是最新的 SpringBoot 3.x。原因很简单:3.x 基于 Jakarta EE,很多旧教程里的 javax 包名都要改成 jakarta,网上大量现成的代码示例直接用会报错。对于课程设计、毕业设计这种交付场景,稳定、资料多、好排查问题才是最优先的。如果你在 IDEA 里新建项目时发现“Spring Initializr 默认拉不到 2.7.x”的选项,直接在 pom.xml 里手动改版本号就行,别在创建向导上死磕。
提示:pom.xml 里注意把 <java.version> 和 SpringBoot parent 版本对应起来。SpringBoot 2.7.x 用 Java 8 或 Java 11 都没问题,如果本机装的是 JDK 17,代码里要注意不要用太高版本的语法特性。
2.2 持久层选型:MyBatis Plus比纯MyBatis和JPA强在哪
持久层我用的是 MyBatis Plus。如果有人单纯用原生 MyBatis,我觉得性价比不高。MyBatis 虽然灵活,但单表增删改查都要写 xml 文件和一个接口方法,重复劳动非常多。Spring Data JPA 上手快,但复杂的多表关联查询和动态条件筛选反而不直观,尤其是在订单这种查询条件很多的表上,JPA 的 Specification 写出来的代码可读性很差。
MyBatis Plus 在我的理解里就是“MyBatis 的增强版”,它内置了 BaseMapper,单表 CRUD 不需要自己写 SQL,直接用selectById、selectList、insert、updateById就能搞定。更关键的是它提供了一套强力的条件构造器 QueryWrapper,可以用 Java 链式调用的方式构造动态 SQL,特别适合订单列表这种“状态、日期、用户ID等多种条件组合筛选”的场景。
举个实际例子:我要查“某用户在某个时间段内、状态为已确认的订单”,原生 MyBatis 要在 xml 里写一堆<if>标签做动态条件,MyBatis Plus 只需要:
QueryWrapper<ReserveOrder> wrapper = new QueryWrapper<>(); wrapper.eq("user_id", userId) .eq("status", 1) .between("start_date", startDate, endDate); List<ReserveOrder> list = reserveOrderMapper.selectList(wrapper);简洁、直观、不容易出错。对毕设项目来说,这个优势能让代码量减少三分之一以上。我整理文档时对比过,如果用纯 MyBatis,光 Mapper xml 可能就要写两三百行,而 MyBatis Plus 几乎不需要写 xml。
2.3 前端、数据库和部署的搭配
前端我比较推荐两种方案,你可以根据自己前端基础选。
第一种是服务端渲染方案,用 SpringBoot 集成 Thymeleaf 模板引擎,再配一套现成的后台管理模板(比如 AdminLTE、Layui)。这种方案的特点是代码都在同一个工程里,不用额外起一个前端服务,部署简单,写完直接打包成 jar 就能跑。适合前端功底一般、想快速把系统搞完整的同学。
第二种是前后端分离方案,后端提供 RESTful API,前端用 Vue 2 + Element UI 或 Vue 3 + Element Plus。这种方案更贴近企业实际开发,视觉效果好,但是工作量会明显增加,而且要处理跨域、Token 认证、接口联调这些问题。如果你时间充裕、前端也还算熟练,可以用分离方案;如果时间紧,我建议走 Thymeleaf 方案。
数据库选型就很简单了,MySQL 8.x 几乎是标配,成本低、资料多、功能稳定。存储引擎必须用 InnoDB,因为它支持事务和行级锁,这两个特性在订单业务里至关重要,后面讲并发控制的时候你就能感受到。字符集统一用 utf8mb4,别用 utf8,不然碰到 emoji 表情或者生僻字会出问题。Redis 在这个项目里不是必须的,我后面聊高并发方案时会讲到什么场景才需要引入,普通毕设项目用数据库事务就够了。
3. 数据库设计才是整个系统的灵魂:表结构和房态冲突判定
很多同学做数据库设计,喜欢先把表建出来,然后在建表语句里东加一个字段西加一个字段。这个习惯不好。景区民宿预约系统最核心的表是订单表,订单表的设计决定了整个系统的上限。我建议先把核心业务关系画出来:用户要预订某个房源的一个时间段,系统要记录这个预订的状态变化。围绕这个关系,核心表其实只需要四张:用户表、民宿表、房源表、订单表。再加上评论表和轮播图表作为功能延伸。
我直接给出我最后落地的核心表结构,并解释每个字段为什么存在。
3.1 核心表的建表逻辑
用户表:
CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT '加密密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `role` tinyint(4) NOT NULL DEFAULT '0' COMMENT '角色:0-用户 1-管理员', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';密码字段我用的是 BCrypt 加密后的密文,不是明文。这个问题答辩的时候挺容易被问到的,答案是“即使数据库泄露,攻击者拿到也不是原文”。Spring Security 的BCryptPasswordEncoder可以直接用,不需要引入整个安全框架,单独引入 spring-security-crypto 依赖就够。
民宿表,我理解为景区内的民宿实体,包含名称、地址、介绍、封面图等信息:
CREATE TABLE `homestay` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '民宿名称', `address` varchar(200) DEFAULT NULL COMMENT '地址', `description` text COMMENT '民宿介绍', `cover` varchar(255) DEFAULT NULL COMMENT '封面图片URL', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:0-下架 1-上架', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='民宿表';房源表,记录民宿下具体的某个房间或房型:
CREATE TABLE `homestay_room` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `homestay_id` bigint(20) NOT NULL COMMENT '所属民宿ID', `name` varchar(100) NOT NULL COMMENT '房型名称,如湖景大床房', `price` decimal(10,2) NOT NULL COMMENT '每晚价格(元)', `max_people` int(11) NOT NULL DEFAULT '2' COMMENT '可住人数', `area` varchar(20) DEFAULT NULL COMMENT '房间面积', `images` varchar(1000) DEFAULT NULL COMMENT '房间图片,多个用逗号分隔', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:0-停售 1-可预订', PRIMARY KEY (`id`), KEY `idx_homestay_id` (`homestay_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房源表';订单表是核心中的核心:
CREATE TABLE `reserve_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint(20) NOT NULL COMMENT '下单用户ID', `homestay_id` bigint(20) NOT NULL COMMENT '民宿ID', `room_id` bigint(20) NOT NULL COMMENT '房间ID', `start_date` date NOT NULL COMMENT '入住日期', `end_date` date NOT NULL COMMENT '离店日期', `nights` int(11) NOT NULL COMMENT '入住晚数', `total_price` decimal(10,2) NOT NULL COMMENT '订单总价', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-待支付 1-已确认 2-已入住 3-已完成 4-已取消 5-已关闭', `remark` varchar(255) DEFAULT NULL 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_room_time` (`room_id`, `start_date`, `end_date`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预订订单表';注意订单表里我加了idx_room_time组合索引,这是为房态冲突查询服务的。数据库里查“某个房间在某段时间内有没有被占”的时候,这个索引能让查询快很多,表数据量一大差异就很明显。
3.2 房态冲突判定:核心SQL怎么写才对
民宿预约和普通商品秒杀不一样的地方在于:秒杀抢的是一个确定库存量的商品,而民宿预约要检查的是一个“时间段”里有没有重叠订单。比如现有订单占了 5月1日到5月3日,那新订单如果是 4月30日到5月1日,能不能订?能,因为 5月1日中午就退房了。那 5月2日到5月4日呢?不能,因为和现有订单重叠了。
这个区间的重叠判断,标准做法是用开闭区间的思路:假设一个订单的入住日期是 start_date,离店日期是 end_date,那么它实际占用的时间是[start_date, end_date),也就是离店日当天不占用。两个时间段[A, B)和[C, D)重叠的条件是:
- A < D 并且 C < B
翻译成 SQL 就是:
SELECT COUNT(*) FROM reserve_order WHERE room_id = #{roomId} AND status IN (0, 1, 2) AND start_date < #{endDate} AND end_date > #{startDate}如果查询结果大于0,说明该房间在这个时间段里已经有有效订单了,不能再接受新订单。这里的status IN (0, 1, 2)指的是状态为待支付、已确认、已入住的订单,因为这些状态都占用房源。已取消、已关闭、已完成的订单不占用,不需要考虑。
没有杀器的正确逻辑,很容易出现一个隐蔽的 bug:现有订单是 5月1日到5月3日,如果你想订 5月3日到5月5日,有的人会写成end_date >= #{startDate},也就是 5月3日 >= 5月3日,判定为冲突。这就错了,因为离店日中午退房,中午之后完全可以接待新客人。用end_date > #{startDate}就正确了。
注意:这个 SQL 逻辑是我在项目里实际验证过的。如果你用 MyBatis Plus 的 QueryWrapper,写法是
.lt("start_date", endDate).gt("end_date", startDate),别搞反了。
3.3 订单状态机:让订单流转有据可依
订单不是建完就完事了,它的生命周期一直在变化。我定义了五个有效状态和一个终态之外的取消态:
| 状态值 | 状态名 | 含义与触发条件 |
|---|---|---|
| 0 | 待支付 | 用户提交订单成功后,系统生成订单,等待用户支付或管理员确认 |
| 1 | 已确认 | 用户支付(或管理员线下确认)后,订单正式生效,房源锁定 |
| 2 | 已入住 | 客人到店,管理员办理入住 |
| 3 | 已完成 | 客人退房,订单完成,房源释放 |
| 4 | 已取消 | 用户支付前主动取消,或管理员取消异常订单 |
| 5 | 已关闭 | 待支付订单超时未支付,系统自动关闭 |
状态流转的关系是这样的:0 可以到 1、4、5;1 可以到 2、4;2 只能到 3。3 是终态,5 是终态,4 也是终态。这些流转关系要在 Service 层做校验,不能用一条任意的 update 语句直接改,否则用户把自己订单改成“已完成”也不是没可能。
我实现的时候,在每个状态变更的 Service 方法里先查一遍当前状态,再用条件更新UPDATE ... SET status = #{newStatus} WHERE id = #{id} AND status = #{oldStatus},后面并发那一节会讲到,这种写法其实是防止状态被并发覆盖的一种基本手段。
4. 预约核心链路:从搜索房源到订单落库的代码实现
有了数据库表和状态定义,接下来就是核心功能链路的实现了。预约流程里最关键的三个环节:搜索可预订房源、提交预订、订单状态变更。我一个个拆开讲,都是可以直接抄走的写法和思路。
4.1 可预订房源搜索:先过滤停售,再排除冲突订单
用户搜索房源时,会传入入住日期、离店日期、人数、关键词。查询逻辑分两步:第一步,从homestay_room表里筛出状态为可预订、可住人数大于入住人数的房间;第二步,排除掉那些和目标时间段冲突的订单占用的房间。
用子查询实现最直观:
<select id="selectAvailableRooms" resultType="com.example.homestay.entity.vo.RoomVO"> SELECT r.*, h.name AS homestay_name, h.address FROM homestay_room r LEFT JOIN homestay h ON r.homestay_id = h.id WHERE r.status = 1 AND h.status = 1 AND r.max_people >= #{people} <if test="keyword != null and keyword != ''"> AND (r.name LIKE CONCAT('%', #{keyword}, '%') OR h.name LIKE CONCAT('%', #{keyword}, '%')) </if> AND r.id NOT IN ( SELECT o.room_id FROM reserve_order o WHERE o.status IN (0, 1, 2) AND o.start_date < #{endDate} AND o.end_date > #{startDate} ) </select>这里有一个细节值得说明:我用了<![CDATA[]]>或者<、>来转义小于号和大于号。很多新手第一次写这个查询的时候,直接写<和>,MyBatis 会把它当成 XML 标签开头,直接报错。遇到这种情况不要慌,在 xml 里转义或者改成<=的写法就可以了。
另外一种不用子查询的写法是 LEFT JOIN + IS NULL,性能在某些情况下更好,但可读性差一些。对这种规模的系统,子查询完全够用,重点是要把业务逻辑表达清楚。
4.2 提交预订:三层校验缺一不可
用户选定房源、填好日期点“提交预订”的瞬间,后端要做的事情远不止 insert 一条订单记录。我在 Service 层做了三层校验:
第一层,参数合法性校验。入住日期不能早于当前日期,离店日期不能早于或等于入住日期。这层校验放在 Controller 层用参数注解或者手动判断都行,目的是拦截明显不合法请求。
第二层,房源状态校验。查一下homestay_room里该房间是否存在、状态是否为 1(可预订)。如果用户点了一个已经下架的房源,后端必须兜住这个异常。
第三层,房态冲突校验。就是前面那个重叠区间 SQL。这一层必须放在事务里执行,而且要注意顺序:先查有没有冲突,再插入订单。
我把核心代码写出来,你直接就能用:
@Service @Transactional(rollbackFor = Exception.class) public class ReserveOrderServiceImpl implements ReserveOrderService { @Resource private ReserveOrderMapper reserveOrderMapper; @Resource private HomestayRoomMapper homestayRoomMapper; @Override public String createOrder(CreateOrderDTO dto) { // 1. 校验日期合法性 if (dto.getStartDate().isBefore(LocalDate.now())) { throw new ServiceException("入住日期不能早于当前日期"); } if (!dto.getEndDate().isAfter(dto.getStartDate())) { throw new ServiceException("离店日期必须晚于入住日期"); } // 2. 校验房源存在且可预订 HomestayRoom room = homestayRoomMapper.selectById(dto.getRoomId()); if (room == null || room.getStatus() != 1) { throw new ServiceException("该房源不存在或已停售"); } // 3. 校验房态冲突 long conflictCount = reserveOrderMapper.selectCount(new QueryWrapper<ReserveOrder>() .eq("room_id", dto.getRoomId()) .in("status", Arrays.asList(0, 1, 2)) .lt("start_date", dto.getEndDate()) .gt("end_date", dto.getStartDate())); if (conflictCount > 0) { throw new ServiceException("该时间段房源已被预订,请更换日期或房源"); } // 4. 计算晚数和总价 long nights = ChronoUnit.DAYS.between(dto.getStartDate(), dto.getEndDate()); BigDecimal totalPrice = room.getPrice().multiply(BigDecimal.valueOf(nights)); // 5. 生成订单号并落库 ReserveOrder order = new ReserveOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setHomestayId(room.getHomestayId()); order.setRoomId(room.getId()); order.setStartDate(dto.getStartDate()); order.setEndDate(dto.getEndDate()); order.setNights((int) nights); order.setTotalPrice(totalPrice); order.setStatus(0); reserveOrderMapper.insert(order); return order.getOrderNo(); } }订单号生成规则我是这样设计的:前缀字母 TS 加时间戳再加四位随机数,比如TS202506041253460001。虽然 MySQL 的 bigint 自增主键本身就能做唯一编号,但订单号要暴露给用户,而且格式上自己定义会更清晰,所以我在业务层单独生成了订单号,并加了唯一索引。
4.3 订单状态变更:用条件更新避免超范围操作
订单确认、入住、退房这些操作,管理员的处理入口不一样,但核心代码套路一致。我以“确认订单”为例说一个通用写法:
@Override public void confirmOrder(Long orderId) { int rows = reserveOrderMapper.update(null, new LambdaUpdateWrapper<ReserveOrder>() .eq(ReserveOrder::getId, orderId) .eq(ReserveOrder::getStatus, 0) .set(ReserveOrder::getStatus, 1)); if (rows == 0) { throw new ServiceException("订单状态已变化,无法确认,请刷新后重试"); } }这里最有价值的点是UPDATE ... WHERE id = ? AND status = 0这种条件更新写法。它保证了只有订单当前确实处于“待支付”状态时,才能被置为“已确认”。如果两个管理员同时在后台操作同一个订单,一个点确认、一个点取消,只有一个能成功,另一个会拿到影响行数为 0 的结果,从而提示“状态已变化”。这个写法不需要加锁,代码简单,而且还天然防止了并发状态覆盖。
5. 并发防超卖:实训中必须跨过的坎
前面已经能跑通完整的预订流程了,但如果你的系统要接受“多人同时抢同一个房源”的考验,就必须认真处理并发。这一块是我在实际测试里真正踩过坑的地方,也是答辩时老师最喜欢追问的点。
5.1 并发下为什么会出现重复预订
我先说问题现象。假设某个景区民宿在五一期间只剩最后一间湖景房,两个游客几乎同时下单。在单线程下,第一个请求进来先查冲突,发现没有冲突,插入订单;第二个请求进来查到冲突,被拦住。但如果在高并发下,两个请求同时通过了“查询房态”那一步,都判断为“没有冲突”,然后各自插入订单,这个房间就被订出去了两次。
很多同学不理解为什么会这样。核心原因在于:你前面的“查冲突”和“插入订单”是两个独立的操作,在数据库层面不是原子的。两个连接同时查询,互不可见对方的数据,查完都觉得自己是安全的,然后各插各的,就超卖了。
我在本地用 JMeter 模拟 100 个并发请求去订同一个房源,结果真的有 3 个订单成功插入,问题真实存在。如果你做完系统只在页面上点两下,根本发现不了这个 bug,但答辩现场老师可能会拿这个问题测试你对系统的理解。
5.2 正确的防重方案:事务加条件更新,而不是傻傻加锁
很多人一提到并发就想到加锁,其实得分场景。悲观锁SELECT ... FOR UPDATE是一种可行方案,但代价是并发性能差,而且代码里事务范围要控制得严格,否则容易死锁。在高并发场景下更推荐的做法,是把“防止重复”的压力转嫁给数据库的约束和条件更新。
我的方案分两步。
第一步,给订单表增加一个唯一约束或者唯一索引,让“同一个房间的同一个时间段”在数据库层面无法出现两条记录。但日期是区间,MySQL 普通唯一索引没法直接对区间生效,所以这一步可以改成在表里增加一个“时间槽位”字段:把入住日期到离店日期的每一天展开成字符串,比如“2025-05-01,2025-05-02”,对这个字段做唯一索引。插入时如果同房间同槽位已经存在记录,数据库直接报唯一冲突。这个方案叫做“时间槽位唯一键”,用代码生成槽位字符串,实现起来并不复杂,但对业务侵入比较大。
第二步,也是我实际采用的方案:把状态更新做成条件更新。因为订单状态从 0 变成 1、从 1 变成 2 这类操作本来就带条件,再加上“防重复”的核心思路是在创建订单时先占位置。我可以把订单创建和房态占用设计成一条 UPDATE 语句完成,而不是先查再插。
但标准的民宿订单没法直接套用“库存扣减”那种写法,所以我最后的落地方案是:保留前面“先查冲突再插入订单”的逻辑,同时给“查询冲突”加一个小锁。再简化一点,就是把冲突查询所在的整个 Service 方法声明为synchronized,用 JVM 的锁来保证单个应用实例内请求串行化。这样做对毕设项目完全够用,但你要知道它的局限:应用如果部署多个实例,synchronized在不同实例之间是无效的。
@Override @Transactional(rollbackFor = Exception.class) public synchronized String createOrderWithLock(CreateOrderDTO dto) { // 先查冲突 long conflictCount = reserveOrderMapper.selectCount(...); if (conflictCount > 0) { throw new ServiceException("该时间段房源已被预订"); } // 插入订单 reserveOrderMapper.insert(order); return order.getOrderNo(); }synchronized让同一个 JVM 内的请求串行化了,这样“查冲突+插入订单”这个复合操作就不会被并发拆开。配合@Transactional,事务会在方法体执行完后统一提交,能有效防止超卖。我还额外加了一个防御性校验:同一个用户重复提交同一个订单时,后台会做幂等处理,避免用户手抖点了两次提交按钮生成两条重复订单。
5.3 更好的方案:悲观锁、Redis分布式锁,什么时候才值得用
如果你想让代码更“高级”一点,可以在冲突查询的 SQL 后面加上FOR UPDATE:
SELECT COUNT(*) FROM reserve_order WHERE room_id = #{roomId} AND status IN (0, 1, 2) AND start_date < #{endDate} AND end_date > #{startDate} FOR UPDATE;但这里要注意,FOR UPDATE要生效,查询条件必须命中索引,而且它锁的是满足条件的索引记录。如果查询结果为空,那它锁的就是一个间隙,需要事务的隔离级别配合才行。很多同学只是在 SQL 后面加了句FOR UPDATE就以为万事大吉,实际没命中索引的话锁不住数据,这是我在调优时踩过的坑。
如果项目引入 Redis,可以用SETNX做一个分布式锁,锁的 key 设计成room:lock:{roomId}:{startDate}:{endDate},抢到锁的请求才能执行查询和插入,执行完释放锁。这是分布式部署时真正能用的方案,但注意要做好锁超时和可重入设计,否则锁没释放会导致大面积超时。
我的建议是:毕设项目优先用synchronized加事务,简单可靠,讲得清楚。如果老师追问更高阶的方案,你回答出悲观锁和 Redis 分布式锁的原理和适用场景就可以了,不需要真去实现一套分布式锁。能把“为什么需要”和“边界是什么”讲明白,已经能体现水平。
6. 把源码、数据库脚本和论文整理成一套完整交付物
题目里说了要有源码、数据库和论文。很多人代码写完了,但交付物的组织杂乱无章,要么数据库脚本和代码里不一致,要么论文和代码逻辑对不上。这一节讲讲我是怎么整理的,这些细节也直接影响答辩评分。
6.1 源码目录结构怎么组织才算合格
项目的包名和目录结构最好一开始就规划好,不要把所有类都堆在默认包下。我最终使用的结构是这样的:
com.example.homestay ├── HomestayApplication.java ├── common │ ├── Result.java // 统一返回结果 │ ├── ResultCode.java // 状态码枚举 │ └── exception │ ├── ServiceException.java │ └── GlobalExceptionHandler.java ├── config │ ├── MybatisPlusConfig.java │ └── WebMvcConfig.java ├── controller │ ├── UserController.java │ ├── HomestayController.java │ ├── RoomController.java │ └── OrderController.java ├── service │ ├── UserService.java │ ├── HomestayService.java │ ├── RoomService.java │ └── OrderService.java ├── service.impl │ └── ... ├── mapper │ ├── UserMapper.java │ ├── HomestayMapper.java │ ├── RoomMapper.java │ └── OrderMapper.java ├── entity │ ├── SysUser.java │ ├── Homestay.java │ ├── HomestayRoom.java │ └── ReserveOrder.java └── dto ├── CreateOrderDTO.java └── QueryRoomDTO.java统一返回结果类Result很值得写。它的结构包括 code、message、data 三个字段,所有接口都返回这个对象,前端处理起来统一,调试也方便。全局异常处理器把 ServiceException 统一转成 HTTP 200 加业务错误码的响应体,这样不用每个接口都写 try-catch,代码会干净很多。
6.2 数据库脚本和初始化数据
数据库脚本我放在sql目录下,分成两个文件:schema.sql是建库建表语句,data.sql是初始化数据。初始化数据里必须包含一个管理员账号和一些基础民宿房型数据,不然别人拿到源码后跑起来是一张空表,体验很差。
管理员密码我用 BCrypt 预先生成一个密文写进 data.sql,而不是明文。很多开源项目也是这么做的。这样你放进代码里的默认密码是明文,但数据库里存的是密文,登录逻辑里用 BCrypt 匹配,既安全又方便。
数据库脚本里的表名、字段名必须和代码里的实体类字段映射一致。这个问题特别容易出:实体类里写了驼峰字段名startDate,数据库里建的是start_date,如果你的application.yml没有开启map-underscore-to-camel-case: true,查询结果就全是 null。MyBatis Plus 默认开启了下划线转驼峰,但如果你改动过配置,一定要检查这个开关。
6.3 毕业设计论文的写作思路与框架
论文部分,我想说一个核心观点:论文不是给用户看的说明书,而是给老师看的技术论证报告。它要回答三个问题:你解决了什么问题、怎么解决的、解决得怎么样。
我用的论文框架是典型的六章结构:
第一章绪论,写景区的背景、民宿预订管理的现状、选题的意义、国内外研究现状。这部分不用写太长,但要写出为什么需要线上预订系统,重点是找出现有方式的痛点。
第二章关键技术,写 SpringBoot、MyBatis Plus、MySQL 这些技术的简介和选型理由。每个技术两三百字就够,不要大段抄书,重点是写清楚这个技术在你的系统里承担了什么职责。
第三章系统分析,写可行性分析和需求分析。需求分析里要画用例图。虽然我不能在这里画图,但你论文里最好用建模工具画一下,用户用例和管理员用例分开。还要写清楚业务规则,包括状态流转规则和时间段重叠判定规则。
第四章系统设计,写总体架构、功能模块设计、数据库设计。数据库设计这章要写出 E-R 图和数据库表结构,每张表的字段、类型、约束都要列清楚。这是论文里最容易被老师细看的部分。
第五章系统实现,展示核心功能的截图和关键代码片段。截图要真实,不要用网上的图。代码片段选最核心的,比如房态冲突检测 SQL、订单创建 Service、并发控制方案,不要贴大段低价值代码。
第六章系统测试,写测试环境、功能测试用例表和结果、并发测试结果。可以用一个表格列出测试编号、测试项、预期结果、实际结果、是否通过。并发测试那里如果能把“压测前超卖3个订单、加锁后全部通过”的数据对比放进去,会非常真实。
摘要的写法有个通用公式:背景一句话、系统用了什么技术一句话、系统有哪些功能模块一句话、主要解决的核心问题一句话、测试结果一句话。不要超过 300 字,关键词选 4 到 5 个,比如 SpringBoot、MyBatis Plus、民宿预订、数据库设计、并发控制。
提示:论文里的代码和数据库表,必须和你实际交付的源码保持完全一致。老师拿到论文后经常做的一件事是照着论文里的功能清单去跑你的系统,如果论文写了“支持用户取消订单”,但代码里没有取消方法,那就是明显的缺陷。宁可砍掉做不完的功能,也不要往论文里堆假功能。
我在整理交付物的时候,还会额外写一个README.md,包含项目介绍、环境要求(JDK 版本、Maven 版本、MySQL 版本)、启动步骤(导入数据库脚本、修改 application.yml、运行 main 方法)、默认账号信息、项目结构和功能列表。这个文件本身不加分,但它能让老师更容易跑起来你的项目,减少沟通成本,间接影响评分。
最后再分享一个小技巧:答辩前,把“房态冲突检测 SQL”“订单状态机”“并发防超卖”这三个你自己亲手实现的核心点,照着代码各写一段伪代码放在手边,老师一追问,你就能非常从容地展开。这几个点既是整个系统的骨架,也是你从一堆同类项目中跳出来的关键。真正写过一个系统的和只下载过一个仓库的,在这些问题上开口就能分辨出来。
本文还有配套的精品资源,点击获取