1. 为什么我选择Spring Boot来做酒店预订系统
这个项目其实是我在服务某小型连锁酒店时接到的需求。他们原来的预订方式还是电话和表格登记,前台每天要花大量时间核对房态、手写订单、月底对账更是灾难。最开始我想过要不要用现成的SaaS平台,但对方有几个硬性要求——数据要存在自己服务器上、要能和已有的会员系统打通、后期还可能对接门禁和发票系统,这几条下来基本就排除了第三方方案。最终敲定用Spring Boot自己开发一套酒店预订系统,顺便也把完整的源码、数据库脚本和文档整理出来,方便后续二次开发和维护。
先说结论:选Spring Boot不是因为它最潮,而是因为它最适合这类业务系统的落地节奏。酒店预订这种领域,核心不是算法多牛逼,而是业务状态多、流程链条长、并发场景集中,比如节假日抢房、支付回调、订单状态流转,这些东西需要一套成熟稳定的框架来兜底。Spring Boot自带自动配置和起步依赖,能让我把精力放在业务代码而不是XML配置上;再加上Spring全家桶的生态,MyBatis、Redis、Spring Security这些都是现成的,团队接手成本也低。
这个系统当时规划的功能面不算小,前台可以管理房型和房态,用户端能注册登录、搜索房间、提交订单、在线支付(模拟)、查看订单历史,后台还能统计入住率和营收报表。整个项目做下来,源码加数据库脚本加文档一共不到几万行,但覆盖了一个真实预订系统应该有的绝大部分核心节点。我觉得这个项目的价值不只是“能跑”,而是它把酒店业务的特殊逻辑——比如房态冲突、连住拆单、超卖防护——都落到了代码里,这部分恰恰是很多CRUD Demo缺失的。
如果你正在学Spring Boot,或者准备做毕业设计,又或者公司需要一套轻量级的酒店管理原型,这套东西都很适合拿来解剖。读代码的时候不要只看Controller写了啥,重点看订单状态怎么流转、库存怎么扣减、事务怎么控制,这些才是真正决定系统能不能扛住真实业务的地方。
2. 数据库设计:酒店业务的核心在“房态”和“状态机”
2.1 表结构设计上的几个关键决策
酒店预订系统和普通电商比,数据库设计上有几个不太一样的点。普通商品库存是一个数字,但酒店的“库存”是日期+房型+房间号的三维组合。同一个房间,今天可售明天可能就订出去了,所以不能简单在一张表里维护一个库存字段。我最终设计了这么几张核心表:
- 酒店表(hotel):管理分店信息,虽然系统里一般只有一家店,但预留多店扩展能力
- 房型表(room_type):定义大床房、双床房、套房等分类,记录门市价、床型、面积、可住人数
- 房间表(room):关联房型,具体到房间号,比如“501大床房”
- 客户表(customer):注册用户信息,包括手机号、密码摘要、会员等级
- 订单表(orders):订单主表,记录用户、房间、入住离店日期、订单状态、支付状态、金额
- 订单明细表(order_item):订单里每间房每晚的价格快照,方便处理连住多晚分拆计价
- 房态计划表(room_status_plan):核心表,记录每个房间在某个日期段的状态(可售/锁定/已入住/脏房)
这个设计有一个细节值得展开:为什么不直接用订单反查房态,而要多建一张房态计划表?因为订单是业务操作的日志结果,不适合做高并发的可用性判断。房态计划表相当于一个预占状态模型,用户在搜索房间时,系统只查这张表就能快速算出某房型在某日期段剩几间,不需要join订单表做一堆日期重叠判断。虽然多了一张表维护成本高了,但查询速度和逻辑清晰度都提升了一个档次。
2.2 状态管理:订单和房态必须有独立的状态机
做酒店系统最容易翻车的地方,是把订单状态和房态混在一起管理。我的做法是拆成两条状态线:
订单状态(order_status):
- 待支付(pending):下单但没付款,房态处于锁定状态,一般锁15分钟
- 已支付(paid):付款成功,房态从锁定转为已预订
- 已入住(checked_in):用户到店办理入住,房态变为占用
- 已退房(checked_out):退房后房间变成脏房,等待打扫
- 已取消(cancelled):用户取消或超时取消,释放房态
房态状态(room_status):
- 可售(available)
- 锁定(locked):订单待支付期间临时占住
- 预占(booked):订单已支付但还没入住
- 占用(occupied):实际入住
- 脏房(dirty):退房未打扫
- 维修(maintenance):人工设置的房间维修状态
这两条线靠订单号和房间号关联,但状态各自演进,互不干扰。我用一个枚举类把它们统一管起来,并在代码里禁止跨状态直接跳转,比如订单状态不能从“待支付”直接跳到“已入住”,必须经过“已支付”或后台强制操作。这种约束让系统在多人协作时不容易产生脏数据。
2.3 索引与查询性能的实测调优
初期跑测试数据的时候,发现按日期范围搜索房间特别慢。explain一看,问题出在房态计划表的查询没法有效利用索引。原因是我把“开始日期”和“结束日期”分开存,搜索“1月10日到1月12日可用的房型”时,要写类似start_date <= end AND end_date >= start的重叠判断,这个条件天然不适合B+树索引。
后来我调整了策略:加了一个冗余字段date_single,把每晚的房态拆成独立的行记录,每行就是“某房间在某一天的状态”。查可用房间时就变成了WHERE date_single BETWEEN ? AND ? AND status = 'available' GROUP BY room_id HAVING COUNT(*) = 天数,这样索引就能正常走了。这套“拆分日期明细”的方案是以空间换时间,一张表行数变多了,但单次查询从几百毫秒降到十几毫秒。如果你的预订系统也遇到日期搜索慢的问题,可以考虑这个思路。
3. 核心业务模块实现:不只是增删改查
3.1 搜索可用房间的完整逻辑
搜索模块是整个预订流程的入口,也是最容易让用户放弃的环节。一个小白用户进来,输入城市、入住日期、离店日期、人数,系统必须快速返回有哪些房型可选。这个接口的实现我拆成了几步:
- 校验日期参数,入住不能早于今天,离店必须晚于入住,连住不能超过30天(这是业务规则)
- 根据城市关联酒店,再关联房型
- 查询房态计划表,统计每个房型在日期区间内“每天都可售”的房间数量
- 排除维修状态的房间,排除已经被锁定或预占的房间
- 返回每个房型的可用房间数、门市价、会员折扣价
这里有一个比较隐蔽的业务点:可用房间数是“每天都有”的数量,而不是“区间内合计出现次数”。比如某房型一共5间,区间内每天能查出5间,不代表全程可用,因为中间某天可能被订走。所以SQL里必须有COUNT(DISTINCT date_single) = 区间天数的限定,这个条件写不对,查出来的库存就是虚高的。我见过不少初版系统栽在这个细节上,用户下完单才发现根本没房,只能手工退款。
前端页面上,搜索结果的卡片需要展示房型图片、价格、床型、面积、剩余房数。剩余房数我做了分类展示:大于3间显示“充足”,1-3间显示“仅剩x间”,0间直接置灰。这种细节对提升预订转化率挺管用的,用户会产生紧迫感。
3.2 下单、支付与超时释放的并发控制
下单接口是并发压力最大的地方。想象一下周五晚上,某网红酒店放出少量特价房,几十个人同时点“预订”,这时候绝对不能出现两个人都下单成功的情况。
我的方案是乐观锁+数据库唯一约束双保险。在房态计划表上维护一个version字段,下单时先执行条件更新:
UPDATE room_status_plan SET status = 'locked', version = version + 1 WHERE room_id = ? AND date_single = ? AND status = 'available' AND version = ?如果受影响行数为0,说明这间房已经被别人抢先锁定了,直接返回“房源紧张”。如果全部日期都更新成功,再创建订单。这种方式比直接用SELECT FOR UPDATE的悲观锁性能好,也避免长事务持锁导致连接池被耗尽。
支付回调的幂等处理也值得重点说说。我在订单表上加了transaction_no唯一索引,支付回调每一次都先检查这个流水号有没有处理过。如果处理过直接返回成功,不再重复修改订单状态。不然万一回调重试了两次,订单金额或者状态就可能被改错。超时释放用了一个简单的定时任务,每两分钟扫一次“待支付且锁定时间超过15分钟”的订单,把对应房态计划恢复到可售,同时把订单标记为超时取消。
3.3 订单状态扭转与事务边界划分
订单从“待支付”到“已支付”再到“已入住”,每个环节都涉及多张表的联动修改。比如确认支付后,需要做三件事:
- 修改订单状态为已支付
- 把对应日期段的房态计划从“锁定”改为“预占”
- 给会员累计积分
这三件事必须在一个事务里完成,任何一步失败都要回滚。我在Service层用@Transactional注解控制,传播级别用默认的 REQUIRED,内部方法之间调用要特别注意自调用的问题——同一个类里A方法调B方法,B的@Transactional是不生效的,因为this调用不走代理对象。我踩过一次这个坑,排查了半天才想起来是自调用问题,后来把需要事务的方法拆到不同的Bean里才解决。
还有一个事务边界的设计心得:不要把长时间的外部操作放进事务里,比如调用第三方支付接口、发送短信验证码。这些网络请求的耗时可能是几百毫秒甚至几秒,如果放在数据库事务里,连接会被一直占用,并发一高整个系统的连接池就告急了。我的做法是事务内只做数据库状态变更,支付请求在事务外发,支付结果通过回调来驱动状态流转。
4. 开发中踩过的四个坑与完整排查链路
4.1 日期区间查询的索引失效
这是数据库层最烦人的一个问题。最开始我写的按日期范围选房的SQL,查询条件是WHERE start_date <= ? AND end_date >= ?,这是标准的区间重叠判断。测试数据量小的时候没觉得慢,等导入了两年的房态数据后,接口直接超时。
排查步骤是这样的:
- 先打印SQL执行计划,
type=ALL,全表扫描 - 尝试给start_date和end_date分别加索引,发现MySQL优化器还是放弃了索引,因为两个条件都是范围查询,选择性不高
- 验证了索引合并也没啥效果后,决定改表结构,引入单日明细表方案
这个经历给我的教训是:数据库索引不是万能药,设计时就要考虑你的查询形态是否能利用到B+树的有序性。区间重叠类的条件本质上就不适合关系型数据库优化,要么用明细行拆开,要么引入时间段维度表,别硬扛。
4.2 事务自调用导致的失效
有一次测试支付流程时发现:订单状态更新了,但房态没改过来,数据对不上。单独调用房态更新的方法明明没问题,放在一个Service里就失效。
后来翻代码发现,Controller调的是Service的A方法,A方法内部调用了同一个类的B方法,而B方法上标了@Transactional。因为Spring的事务代理机制是基于AOP的,同类内部调用走的是this,不经过代理对象,所以B方法的事务注解根本不会被解析。这个问题有个俗名叫“自调用陷阱”。
我最后把一个内部方法移到了独立的Bean里,通过注入新Bean来调用,事务就生效了。这个坑特别隐蔽,因为代码编译和运行都不报错,只有数据异常时才暴露。建议所有给方法加@Transactional时的第一反应就是看看这个方法是给谁调的,跨Bean调用才靠谱。
4.3 并发下单时的超卖风险
压测时开了100个并发线程同时抢同一个房型,结果生成了十几个订单,但房间只有8间。问题出在我最初校验库存用的是先查询再更新的模式——先select看有没有房,再update扣库存,这中间有巨大的并发窗口,多个线程都能查到“还有房”。
这也让我认清了乐观锁的真实使用边界:不是所有表都适合加version做乐观控制,因为并发高的时候乐观锁的失败重试会浪费大量数据库操作。针对房态预占这个场景,我最后换成了条件更新(CAS式的update语句),单条SQL保证状态判断和修改原子进行,校验和占用在数据库层面完成了,不再依赖应用层的先查后改。改了之后压测数据就稳定了,同一个房间的预占成功率符合预期。
4.4 前后端联调时的跨域问题
前端跑在8081端口,后端Spring Boot跑在8080端口,联调时所有请求都被浏览器拦成了跨域错误。这不是Spring Boot特有的问题,但Spring Boot的解决方式很简单。
我在配置类里写了一个全局CORS配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里有个细节需要留意:生产环境不要用allowedOriginPatterns("*")加allowCredentials(true)的组合,这对所有域名都放了cookie权限,属于安全漏洞。合理做法是维护一个允许跨域的域名白名单,只对列表里的域名放开。
5. 项目结构与部署:这套代码怎么组织才算合格
5.1 分包结构与包名命名规范
分包我采用的是经典的分层结构,但比很多教程里的写法多了一层领域划分:
- controller:接收请求,只做参数校验和结果封装
- service:业务逻辑层,事务边界在这里
- mapper:持久层接口
- entity:数据库映射实体
- dto:数据传输对象,主要是接口的入参出参
- common:统一返回结构、全局异常拦截、枚举定义
- config:配置类
- task:定时任务
- util:工具类
这里有一件事我比较坚持:Controller层不允许直接返回entity。数据库实体里面可能包含密码字段、冗余字段,直接暴露给前端不合适。我专门定义了VO(View Object)来承载需要展示的字段,通过BeanUtils复制属性。虽然多写了一点代码,但接口文档和前端的数据契约就清晰了。
5.2 配置文件的区分管理
我把配置拆分成了三个文件:
- application.yml:通用配置,包括端口、应用名、日志级别
- application-dev.yml:开发环境,本地数据库地址,日志输出到控制台
- application-prod.yml:生产环境,独立的数据库地址,日志输出到文件并做滚动切割
启动时通过spring.profiles.active环境变量来指定用哪套配置。这个习惯帮我避免过好多次把测试环境配置发到生产的事故。配置里的敏感信息,比如数据库密码,我没有硬编码在文件里,而是通过环境变量注入,部署时在服务器上配置好。有人说密码放配置文件里图省事,但一旦数据库被脱库,连带一堆项目的密码都泄露,得不偿失。
5.3 部署流程与一键启动脚本
因为项目规模不大,我用了相对传统的部署方式:打jar包 + 服务器上systemd管理进程。打包的时候有一个细节:排除测试代码,用mvn package -DskipTests,避免测试类里连不上测试数据库导致打包失败。
服务的启动脚本大概长这样:
nohup java -Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError \ -jar hotel-reservation-system.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > logs/app.log 2>&1 &-XX:+HeapDumpOnOutOfMemoryError这个参数平时用不上,但系统OOM时能自动生成堆转储文件,排查内存问题的时候就靠它了。我希望这个项目不要一直在宿舍楼或本地环境里待着,而是真正地被部署到一台Linux机器上,开启生产配置跑起来,这样你才能体会到从代码到服务的完整链路是什么样,也才能真正暴露出开发环境从来没出现过的资源问题。
数据库初始化脚本放在db/目录下,包含建表语句、初始房价数据、测试账号。首次部署时导入即可,不用手工去库里敲SQL。初始化数据里我放了十几间房的示例数据,覆盖了大床、双床、套房三个房型,日期范围也做了前两个月的数据,方便直接体验完整的预订流程。
6. 交付文档怎么组织:让接手的人不看代码也能跑起来
很多人做完项目不爱写文档,觉得代码就是文档。但这种偏业务型系统,如果不把业务规则写清楚,后面的人接手就是灾难。我这次的文档分成三本,分别给不同角色看:
- 系统部署文档:给运维或自己看的,包括环境要求(JDK版本、MySQL版本、Maven配置)、初始化数据库步骤、打包命令、启动方式、常见报错处理
- 系统设计文档:给二次开发的人看的,包含架构图(我用简单文本画的,没有用太复杂的工具)、数据库表结构说明、每个字段含义、核心接口的调用时序、业务状态机说明
- 用户操作手册:给前台运营看的,包括怎么开房型、怎么手动调整房态、怎么查报表
写设计文档时有一个心得:别贴大段代码,要贴关键逻辑的说明和接口的定义。代码会变,但接口契约和业务规则相对稳定。像订单状态机那张图,我用纯文本的方式把每个状态能执行什么操作、会跳转到哪个状态列清楚了,谁来看都能快速理解这套预订流程的设计意图。
另外在数据库脚本上我做了版本管理。第一版是v1.0_init.sql,后面调整表结构就新增增量脚本v1.1_add_room_status_plan.sql,不直接改初始脚本。这种习惯在正式项目里可以保证老库可以平滑升级,不会因为重新执行初始化脚本而丢数据。对于学习用途的项目来说,这种方式还能让你对比出“表结构演进”的过程,这比看最终的建表语句要有价值得多。
最后再分享一个自己长期使用的小习惯:每写完一个模块,就手动测一遍完整的业务链路,包括异常路径。比如测试支付时,我会先故意不支付让它超时,确认房态能正确释放;再测试支付成功后取消订单,看看积分会不会正确扣回。这些边界行为不亲手验证一遍,上线后出了问题都没地方查。这套系统做完,最大的感受是:酒店预订系统的复杂度不在功能多,而在状态流转和并发控制要闭环,能把这套闭环跑通,你就已经超过了绝大多数只会写CRUD的Spring Boot学习者。