☰
酒店管理系统数据库设计:表结构、事务与并发控制实践
2026/9/25 13:25:11 网站建设 项目流程

简介:一份面向数据库课程设计的酒店管理系统(客房管理)Java 项目,适合正在完成数据库实训作业的学生或需要快速验证客房业务数据建模的开发者参考。资源围绕客房、客户、订单、入住与退房等核心数据实体,完整演示了从关系型数据库(如 MySQL 或 SQL Server)表结构设计、主外键约束、数据类型规划到 JDBC 增删改查的建模思路,可帮助理解数据库原理在真实酒店业务中的落地方式。压缩包共 30 个文件,包含 18 个 class 编译文件、10 个 Java 源码和 2 张 JPG 图片,整体仅 404KB,轻量易用;源码覆盖预订、入住、退房、结账、客户查询与登记等模块,class 文件便于直接运行验证,Java 源码适合阅读与二次修改,图片可配合理解界面设计。已有 1874 人学习下载。借助本项目可掌握酒店客房场景下的关系表设计、Java 图形界面与数据库的交互编码套路,同时理解订单状态随入住、退房流程更新的处理逻辑,以及测试数据准备、超期未退房等异常情况的排错思路,是一份能快速上手并完成课程设计报告的实用参考资料。

1. 酒店管理系统:数据库课程设计里最像“真实业务”的一道题

如果你的课程设计题目落到“酒店管理系统(客房管理)”上,恭喜,你拿到了一个最接近真实业务的数据库课设题目。这个题目拼的从来不是界面有多漂亮,而是房间状态从“空闲→已预订→入住中→已退房”这一整条链路能不能在数据库层面闭环。很多人写完增删改查就去交差了,结果答辩时被一句“两个客人同时订最后一间房怎么办”问翻车。这篇文章直接给可落地的表结构、连接配置和事务写法,也把常见踩坑列出来。适合正在做数据库课程设计或毕业设计的学生,也适合想快速搭一套客房管理原型做二次开发的从业者。

2. 从 ER 图到关系模式:把酒店客房业务拆成可落地的表结构

2.1 客房、房型、房态:分表还是合表?先想清楚状态流转

很多同学一开始就把“房间号、房型、价格、状态”全塞进一张 room 表,这是民宿小账本的做法。真正常见的酒店管理系统实践,至少会把“房型”和“房间”拆成两张表:房型表存的是可复制的基础属性,比如大床房、双床房、门市价;房间表存的是物理存在的一间房,它属于哪个房型、在哪一层、现在是什么状态。

为什么必须拆?因为一间“豪华双床房”可以有很多间,而“房间状态”属于每一间独立房间的动态数据。如果不拆,改一次房型门市价要更新十几行,而且很容易出现同一栋楼里两个房间价格不一致的脏数据。两张表的职责分别是“房型定义”和“房间实例”,这也是你在答辩里解释 ER 图时最容易讲清楚的一点。

房间的状态字段,常见做法是用一个 TINYINT 表示:0 空闲、1 已预订、2 入住中、3 维修、4 打扫中。有人喜欢用 VARCHAR 存“已入住”“空闲”,看起来直观,但代码判断、索引效率、排序都不如数字类型。更重要的是,这个字段只代表“当前状态”,它没有历史。真正常见的酒店管理系统,无论连锁还是单体酒店,都会配套一张状态流水表,记录 RoomNo、旧状态、新状态、操作人、操作时间。

这套设计放到“智慧酒店管理系统”方向尤其有价值。房间状态每变一次就插入一条流水,一旦出现价格争议、清洁纠纷、门锁联动,你能直接查出“这间房昨天 17:30 从入住中变成了打扫中,操作人是谁”。课程设计阶段把这个日志表建出来,比任何花哨页面都加分。下面是一个最小字段的房态流转日志表参考。

字段类型说明
log_idINT UNSIGNED AUTO_INCREMENT主键
room_idINT UNSIGNED关联房间表
old_statusTINYINT变更前状态
new_statusTINYINT变更后状态
operatorVARCHAR(50)操作人
remarkVARCHAR(255)备注,换房、维修等信息
create_timeDATETIME变更时间

2.2 客户表与订单表:外键怎么设,才能避免删不掉、改不动

客房业务的核心人物是“客户”,核心单据是“订单”和“入住登记”。我一般建议至少做三张表:客户表 customer、预订订单表 booking_order、入住登记表 check_in_record。客户表存客户基本信息,订单表存一次预订行为的计划日期和状态,入住登记表存每一次实际住店的流水。这三张表的关系是:一个客户可以有多张订单,一张订单可以关联一次入住登记。

外键设计是课程设计里最纠结的地方。物理外键能让数据库层面保证引用完整性,但代价是删除和更新路径非常受限。比如你做了“客户表→订单表”的物理外键,测试阶段想清空订单重新造数据,就得先删订单再删客户,顺序反了就报错;如果删了一个有过历史订单的客户,数据库会直接拒绝,因为历史订单还引用着他。

更现实的做法是:主外键关联字段照建,但把“业务里唯一且有意义的字段”单独做唯一索引,而不是把外键当作唯一身份。比如房间号 room_no 建唯一索引,但订单表不直接引用 room_no,而是引用 room_id 自增主键;客户身份证号建唯一索引,但订单表引用 customer_id。这样既保证数据可追踪,又不会因为业务键修改导致外键链断裂。课程设计答辩时,老师问到外键设计,你可以明确说:核心表之间保留逻辑外键,应用层用事务保证一致性,物理外键只用于强制不能丢失引用关系的场景,比如入住登记与订单。

订单表里有一个容易被忽略的字段:下单时的房价快照。后文会专门讲它为什么必须存在。这里先记住一条原则:凡是订单级别的信息,存订单表;凡是客户资料级别的信息,存客户表;凡是房间物理属性的信息,存房间表。不要为了省一次联表查询而把客户手机号、房型名称全冗余进订单表。

2.3 范式与反范式:为什么订单表里要冗余“房价快照”

讲范式不是为了背概念,是为了让别人问不出你的毛病。第三范式说,非主属性不能传递依赖于主键。房型的门市价,本质上是房型表的属性,它通过 RoomID→TypeID→BasePrice 这条链路和订单发生关系。如果订单表里也存了“当前房价”,订单的最终金额就和房型当前的挂牌价建立了传递依赖,这违反了第三范式。

但酒店业务里有个现实需求:客人下单时的价格必须锁定。客人 3 月 1 日以 388 元订了 3 月 10 日的房,3 月 8 日酒店把门市价调到了 488 元,你不能让客人退房时按 488 结账,否则就是价格欺诈。所以订单表里必须冗余一个“下单时房价快照”。这个冗余是业务必须,属于反范式设计。

反范式有一个边界:你冗余的必须是“下单那一刻就不再变化”的快照数据,而不是“会跟着源表变化”的关联数据。把房价快照放进订单表没问题,把客户手机号放进订单表就有隐患——客户改手机号后,所有历史订单里的旧号码不会同步更新,查询时就只能拿到过期信息。真正常见的做法是,订单表只存 customer_id,需要显示手机号时联表查客户表;但订单表一定要存 room_price_snapshot,因为成交价已经成为这笔订单的既定事实。

把一张错误的订单表结构写成反例,你会更容易理解插入、更新、删除异常是怎么发生的:如果订单表直接存了客户姓名和客户手机号,那么一个新客户只要还没下单,就无法被记录在系统里,这是插入异常;一个客户改了手机号,必须去更新所有历史订单,否则旧订单上的联系方式是错的,这是更新异常;删除一笔订单时,客户信息也跟着被删掉了,这是删除异常。你的关系模式拆开后,这三类异常自然消失,数据库课程设计的分值也就在这里拉开。

3. 建库建表与连接配置:MySQL 与连接池的常见做法

3.1 一张完整的建表 DDL:字符集、引擎与索引一次配齐

数据库选型上,课程设计最常用的是 MySQL,免费、资料多、Navicat 和命令行都能连。下面这套 DDL 是我平时搭客房管理原型时习惯使用的结构,足够支撑后续所有业务。字符集直接选 utf8mb4,不要选 utf8,因为 MySQL 的 utf8 实际不是完整的四字节 UTF-8,遇到生僻字或 emoji 会报乱码和截断错误。

CREATE DATABASE IF NOT EXISTS hotel_manage DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE hotel_manage; CREATE TABLE room_type ( type_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, type_name VARCHAR(50) NOT NULL, base_price DECIMAL(10,2) NOT NULL, bed_type VARCHAR(20) DEFAULT NULL, max_guests TINYINT NOT NULL DEFAULT 2, UNIQUE KEY uk_type_name (type_name) ) ENGINE=InnoDB; CREATE TABLE room ( room_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, room_no VARCHAR(10) NOT NULL, type_id INT UNSIGNED NOT NULL, floor TINYINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1预订 2入住 3维修 4打扫', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_room_no (room_no), KEY idx_type (type_id), KEY idx_status (status) ) ENGINE=InnoDB; CREATE TABLE booking_order ( order_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, customer_id INT UNSIGNED NOT NULL, room_id INT UNSIGNED NOT NULL, check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, order_status TINYINT NOT NULL DEFAULT 0 COMMENT '0预订 1入住 2完成 3取消', room_price_snapshot DECIMAL(10,2) NOT NULL COMMENT '下单时房价快照', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_customer (customer_id), KEY idx_status_date (order_status, check_in_date) ) ENGINE=InnoDB; CREATE TABLE check_in_record ( record_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id INT UNSIGNED NOT NULL, room_id INT UNSIGNED NOT NULL, customer_id INT UNSIGNED NOT NULL, actual_check_in DATETIME NOT NULL, actual_check_out DATETIME DEFAULT NULL, amount_paid DECIMAL(10,2) DEFAULT 0.00, operator VARCHAR(50) DEFAULT NULL, KEY idx_order (order_id), KEY idx_room_time (room_id, actual_check_in) ) ENGINE=InnoDB;

逻辑说明:这里把业务键和主键分开了。订单对外展示用 order_no,比如H20250301001,方便人工识别;内部关联用自增主键 order_id,避免订单号生成逻辑影响表关联。房间表也一样,room_no 加唯一索引保证不重号,但订单表引用 room_id,这样以后调整房间编号格式时不需要改历史订单。

参数说明:DECIMAL(10,2) 存金额,一共 10 位有效数字,小数点后 2 位,足够覆盖房费计算;状态字段用 TINYINT,比 VARCHAR 省空间且判断快;update_time 用 ON UPDATE CURRENT_TIMESTAMP,应用层不用手动维护。选 InnoDB 而不是 MyISAM 是最关键的决定:InnoDB 支持事务和行级锁,MyISAM 只支持表级锁,并发一上来会互相阻塞,这也是常见的数据库优化起点。如果你换达梦、PostgreSQL 等数据库,自增写法会略有差异,但表结构设计思路是通用的。

3.2 连接池参数:为什么并发一上来连接就崩

课设阶段最常见的连接错误,是每次操作都现场DriverManager.getConnection(),用完再 close。这在单人调试时没问题,一旦用 JMeter 或浏览器并发点几下,数据库就会报连接超时、Too Many Connections。原因是建立连接要握手、认证、建会话,开销远比执行一条 UPDATE 大。真正常见的做法是引入连接池,让连接创建一次后反复复用。

以 Druid 为例,给出一个常见配置。如果你用的是 HikariCP,参数名略有不同,但核心逻辑一致。

# druid 连接池配置 spring.datasource.url=jdbc:mysql://localhost:3306/hotel_manage?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=你的密码 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.type=com.alibaba.druid.pool.DruidDataSource spring.datasource.druid.initial-size=5 spring.datasource.druid.min-idle=5 spring.datasource.druid.max-active=20 spring.datasource.druid.max-wait=5000 spring.datasource.druid.validation-query=SELECT 1 spring.datasource.druid.test-while-idle=true

参数说明:initial-size 是启动时预创建的连接数,5 条足够课程设计场景;min-idle 是池里至少保留的空闲连接数;max-active 是上限,超过之后新请求要排队等待;max-wait 是排队等待时间,单位毫秒,5000 毫秒内拿不到连接就直接抛异常。max-active 不必调太大,20 已经很充裕,因为连接池会把同一连接复用到多个请求。validation-query 是保活检查语句,空闲连接被回收前先执行SELECT 1确认连接没断。

连接串里有三个参数必须说清楚。characterEncoding=utf8 保证中文不乱码;serverTimezone=Asia/Shanghai 解决 8 小时时差;useSSL=false 是本地开发环境没必要做 SSL 加密。allowPublicKeyRetrieval=true 是新版 MySQL 驱动在 caching_sha2_password 认证方式下连接时需要的参数,不加会报 Public Key Retrieval is not allowed。如果你用 Navicat 连接达梦数据库,驱动和 URL 格式完全不同,但连接池的复用思路不变,排查方向也一致:先从命令行确认数据库服务本身能不能连上,再看连接串参数。

3.3 三层结构与事务边界:SQL 该写在 DAO 层还是 Service 层

客房管理系统的代码结构,如果所有 SQL 都写在 Controller 里,事务会变得难以控制。常见做法是分三层:Controller 做参数校验和返回结果,Service 做业务规则和事务边界,DAO 只负责单表读写。以入住登记为例,业务规则是“先确认房间空闲,再插入入住记录”,这两步必须在同一个事务里,否则会出现房间状态已改但记录没写进去的脏数据。

Spring 事务用起来最简单,在 Service 方法上加@Transactional注解。下面这段代码模拟了入住登记的三个必要步骤。

@Service public class CheckInService { @Autowired private RoomMapper roomMapper; @Autowired private OrderMapper orderMapper; @Autowired private CheckInRecordMapper checkInRecordMapper; @Transactional(rollbackFor = Exception.class) public void checkIn(Integer orderId, Integer roomId, String operator) { // 第一步:用条件更新抢房间状态,受影响行数必须为 1 int updated = roomMapper.freeToChecked(roomId); if (updated != 1) { throw new BusinessException("房间已被占用,或当前状态不允许入住"); } // 第二步:把订单状态从“预订”改为“入住中” orderMapper.updateStatus(orderId, 1, 0); // 第三步:写入入住登记流水 checkInRecordMapper.insert(orderId, roomId, operator); } }

逻辑说明:rollbackFor = Exception.class的意思是,方法里任何一个步骤抛出异常,前面已执行的操作全部回滚。第一步的 freeToChecked 对应的 SQL 类似UPDATE room SET status=2 WHERE room_id=? AND status=0,它既是状态修改,也是并发控制。如果两个请求同时操作同一间房,只有先拿到行锁的那个事务能把这行从 0 改成 2,另一个请求受影响行数为 0,直接抛异常回滚。

参数说明:updateStatus 方法第二个参数 1 是新状态,第三个参数 0 是旧状态条件。更新订单时带上旧状态条件,是为了防止同一张订单被重复办理入住。三步操作的顺序是固定的:先锁房间行,再改订单,最后插入流水。所有事务里都保持这个顺序,能避免多个事务互相等待对方持有的锁而产生数据库死锁。如果你不做分层,把这些逻辑全塞进 JSP 或 Servlet,事务管理会让你极其痛苦,而且答辩时讲不清楚。

4. 客房管理的增删改查:从入住、退房到换房的事务实践

4.1 入住登记:用“条件更新”代替“先查后改”来防并发

数据库增删改查的“增”,在客房管理里不是简单 INSERT,而是 INSERT 之前必须验证房间状态。最直观也最容易翻车的写法是:先 SELECT 房间状态,如果等于 0,再 INSERT 订单,最后 UPDATE 房间状态。问题在于 SELECT 和 UPDATE 之间有两个时间窗口,两个连接都能读到“空闲”,然后都执行成功,房间被重复预订。

常见做法是把整个流程放进一个事务,用“条件更新”作为并发闸门。下面给出存储过程版本的完整流程,逻辑直观,也方便你在答辩时演示。

DELIMITER // CREATE PROCEDURE sp_check_in ( IN p_order_id INT, IN p_room_id INT, IN p_operator VARCHAR(50), OUT p_result INT ) BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_result = -1; END; START TRANSACTION; UPDATE room SET status = 2, update_time = NOW() WHERE room_id = p_room_id AND status = 0; IF ROW_COUNT() <> 1 THEN ROLLBACK; SET p_result = -2; ELSE UPDATE booking_order SET order_status = 1 WHERE order_id = p_order_id AND order_status = 0; INSERT INTO check_in_record(order_id, room_id, actual_check_in, operator) VALUES(p_order_id, p_room_id, NOW(), p_operator); COMMIT; SET p_result = 0; END IF; END // DELIMITER ;

逻辑说明:第一步的UPDATE room ... WHERE room_id=? AND status=0是控制并发的关键。InnoDB 会对满足条件的行加锁,两个事务同时执行时,后到的事务会被阻塞,等前一个事务提交后才能继续,这时它发现 status 已经不是 0,ROW_COUNT() 返回 0,整个事务回滚。第二步把订单从“预订”改成“入住中”,也带了AND order_status=0,防止同一订单被重复提交。第三步插入入住流水,如果插入失败,事务回滚,房间状态也会被还原。

参数说明:p_result 是输出参数,调用方通过它的值判断结果:0 表示成功,-1 表示数据库异常,-2 表示房间状态已被占用。存储过程里 START TRANSACTION 和 COMMIT/ROLLBACK 必须配套,注意 EXIT HANDLER 负责捕获 SQL 异常并回滚,避免部分提交。

4.2 退房结账:用 DECIMAL 计算费用,避免金额“差一分”

退房是“更新型”业务,核心问题是费用计算。房费按晚数计算:3 月 1 日入住、3 月 3 日退房,按两晚计算;如果订单里还有加床费、餐费、洗衣费等,应该走一张消费明细子表,这里为了讲清边界先简化成额外费用入参。

金额计算必须重视数据类型。数据库字段用 DECIMAL(10,2),应用层用 BigDecimal,不能用 double 或 float。这不是玄学,而是 IEEE 754 浮点数无法精确表示大部分十进制小数,0.1 加 0.2 在 float 里会变成 0.30000000000000004,账单一旦累计就会差一分钱。课程设计里用 float 算钱翻车的案例比比皆是。

@Transactional public BigDecimal checkout(Integer orderId, BigDecimal extraFee, String operator) { // 查订单:拿到房间号、房价快照、计划入住和退房日期 Order order = orderMapper.getById(orderId); if (order.getOrderStatus() != 1) { throw new BusinessException("订单状态不是入住中,不能退房"); } // 按晚数计算房费,不足一晚按一晚 long nights = ChronoUnit.DAYS.between( order.getCheckInDate(), order.getCheckOutDate()); if (nights < 1) { nights = 1; } BigDecimal roomFee = order.getRoomPriceSnapshot() .multiply(BigDecimal.valueOf(nights)); BigDecimal total = roomFee.add(extraFee); // 回写入住登记表的实际离店时间和结清金额 checkInRecordMapper.finish(orderId, LocalDateTime.now(), total, operator); // 订单置为已完成 orderMapper.updateStatus(orderId, 2, 1); // 房间置为“打扫中”,而不是直接回到空闲 roomMapper.updateStatus(order.getRoomId(), 4, 2); return total; }

逻辑说明:房费必须用订单冗余的 room_price_snapshot,而不能去查房型表当前的门市价。客人下单后酒店调价,是酒店经营行为,不能追溯到已成交的订单上。最后一步把房间置为“打扫中”,而不是直接置为“空闲”,是因为客房需要保洁检查后才能再次出售,这是酒店业务里的硬规矩。

参数说明:ChronoUnit.DAYS.between计算两个日期之间的天数差,适用于按整晚计费的场景。roomMapper.updateStatus(roomId, 4, 2)的第三个参数 2 是旧状态条件,确保只有“入住中”的房间才能被置为“打扫中”,避免误操作一个空闲房间。extraFee 如果有值,在真实系统里应该由消费明细子表汇总而来,而不是前端随便传一个数;课设阶段可以加上最小金额和最大金额的前端校验。

4.3 换房与预订取消:状态机里不能违背的几条铁律

换房本质上是“旧房间退房 + 新房间入住”的组合操作。它必须在一个事务里完成,否则会出现旧房已释放、新房未入住,或者新房已入住、旧房未释放的中间状态。常见做法是:事务开始,先改旧房间状态为“空闲”,再改新房状态为“入住中”,同时更新订单里的 room_id,最后插入一条换房流水。

预订取消是另一个容易写错的地方。一个订单只有处于“预订”状态时才能取消,已经“入住”的订单只能走退房流程,不能取消后把房间直接释放。对应到 SQL,取消操作必须带状态条件:

UPDATE booking_order SET order_status = 3 WHERE order_id = 5 AND order_status = 0; UPDATE room SET status = 0, update_time = NOW() WHERE room_id = 3 AND status = 1;

逻辑说明:第一条语句把订单从“预订”改成“取消”,条件是order_status = 0;第二条把房间从“已预订”改成“空闲”,条件是status = 1。两条都执行成功才能提交。如果其中一条的受影响行数为 0,说明在这个事务开始前,订单或房间的状态已经被其它事务改变,必须整体回滚,否则会出现“订单取消成功但房间仍被占用”或“房间已释放但订单依然是预订状态”的问题。

参数说明:这里的 5、3、3、0、0、1 都是示例参数,实际项目里参数从接口传入。建议把所有状态流转先画成一张简单的状态表:空闲可以到预订和入住,预订可以到入住和取消,入住只能到退房,退房只能到打扫。凡是状态表里没有的跳转,应用层一律拒绝。

5. 课设避坑指南:字符集、锁、外键与时区的 5 个翻车现场

5.1 乱码:插入的“入住人姓名”变成问号

现象:网页上输“张伟”,数据库里存的是“????”,或者英文正常、中文全变问号。

原因:字符集在三个位置不一致:数据库、客户端工具、JDBC 连接串。最常见的是建库时用了默认 latin1,或者连接串里漏了characterEncoding=utf8。

解决:建库时用 utf8mb4,连接串加useUnicode=true&characterEncoding=utf8。如果库已经建了,执行一条修改语句即可:

ALTER DATABASE hotel_manage CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

注意修改数据库字符集不会自动改已有表的字符集,已存在的表还要单独改。另外,配置文件里写characterEncoding=utf8而不是utf8mb4是正常的,因为 MySQL 驱动会把 utf8 映射为 utf8mb4;但建库语句里应写 utf8mb4。

5.2 删除失败:明明房间退房了,房间记录却删不掉

现象:管理员想删一间测试用的房间,执行 DELETE 时报错Cannot delete or update a parent row: a foreign key constraint fails。

原因:booking_order 或 check_in_record 表还有记录引用了这个 room_id。只要有历史入住流水,房间就是一个已经发生的事实,数据库外键不允许你直接删掉它。

解决:不要试图删除房间。正确做法是给房间增加一个“停用”状态,比如 status 设为 5,表示不再接受新预订,但历史流水仍然可查。如果只是测试阶段想清库,应该按依赖顺序清理:先删 check_in_record,再删 booking_order,最后才能删 room。课程设计答辩时,你能说出“房间是基础数据,业务上线后只停用不删除”,比硬删数据更显得懂行。物理外键不一定要去掉,但要理解它的限制。

5.3 时差:查询结果的日期总是差 8 小时

现象:入住时间是下午 14:00,查出来变成早上 06:00,或者反过来,所有日期时间整体偏移 8 小时。

原因:MySQL 驱动和数据库服务端时区不一致。最常见的是连接串里没有指定 serverTimezone,驱动用了 JVM 默认时区,而 MySQL 服务端配置的 time_zone 是 UTC。

解决:连接串加serverTimezone=Asia/Shanghai。如果改完还有问题,执行 SQL 看数据库侧时间:

SELECT NOW();

如果数据库返回的时间和系统当前时间差 8 小时,可能是服务器时区配置问题,用SET GLOBAL time_zone = '+08:00'修正。排查时先在命令行里直接执行SELECT NOW()确认数据库本身时间是否正确,再查连接参数,能快速缩小范围。

5.4 超卖:两个人同时抢到最后一件客房

现象:并发测试中,两个订单同时预订同一间房,系统都提示预订成功,最后房间里只有一个客人,另一个订单变成无效订单。

原因:代码逻辑是“先 SELECT 房间状态,再用 INSERT 生成订单,最后 UPDATE 房间状态”。两个连接都读到 status=0,于是都生成订单,房间状态被后执行的 UPDATE 覆盖为已预订,但两个订单都已插入,数据不一致。

解决:把“状态变更”作为并发控制点。用条件更新:

UPDATE room SET status = 1 WHERE room_id = 10 AND status = 0;

如果返回受影响行数为 1,说明抢锁成功,这个连接才能继续插入订单;如果返回 0,说明房间被别人抢先,直接拒绝。注意 InnoDB 下行级锁才能这样控制,MyISAM 虽然不会出现这个问题,但它用表锁,并发性能差,不建议用于酒店管理系统这类需要并发的场景。另外,多个事务的锁顺序要统一,建议都按“room → booking_order → check_in_record”的顺序拿锁,避免死锁。

5.5 结算误差:用 float 算钱,账单永远差一分

现象:房费、餐费、洗衣费单独算都对,加总后和应收金额差 0.01 元,或者单价 0.6 小时计算后出现 0.6000000000000001。

原因:float/double 是二进制浮点数,无法精确表示十进制小数。金额计算用 float 是课程设计的高频血泪错误。

解决:数据库字段全部用 DECIMAL(10,2),Java 代码里用 BigDecimal,接收前端金额参数时直接用 String 或 BigDecimal,不要先转 double 再转 BigDecimal。计算时先乘后除,中间不要四舍五入。比如按小时计费时,单价乘以时长后保留两位小数即可,不需要每一步都 round。

6. 验证与进阶:用一个模拟脚本证明你的系统撑得住七天运营

6.1 生成七天运营数据,验证房态流转的一致性

课程设计答辩时,老师最爱做的动作是打开数据库看你的表里有没有真实可信的数据。只插两条测试记录不够,最好用脚本生成连续七天的预订和入住数据。下面给出一种用 MySQL 8 的递归 CTE 和随机函数生成模拟订单的写法,适合在开发环境造数据。

WITH RECURSIVE dates AS ( SELECT '2025-03-01' AS d UNION ALL SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM dates WHERE d < '2025-03-07' ) INSERT INTO booking_order(order_no, customer_id, room_id, check_in_date, check_out_date, order_status, room_price_snapshot, create_time) SELECT CONCAT('T', DATE_FORMAT(d, '%Y%m%d'), LPAD(room.room_id, 3, '0')), customer.customer_id, room.room_id, d, DATE_ADD(d, INTERVAL 1 DAY), 2, room_type.base_price, NOW() FROM dates CROSS JOIN (SELECT room_id FROM room ORDER BY RAND() LIMIT 10) room CROSS JOIN (SELECT customer_id FROM customer ORDER BY RAND() LIMIT 1) customer;

逻辑说明:这个脚本生成 7 天内每天 10 间房各一条已完成订单,订单状态直接置为 2。它只为统计演示服务,没有模拟完整的入住和退房流水。在你自己的验证环境里,更完善的做法是写一个存储过程,循环生成订单、调用第四章的入住流程、再调用退房流程,让每一单都走完完整状态流转。

参数说明:ORDER BY RAND() LIMIT 10是随机取房间,课设造数没问题;数据量大了之后会全表扫描,生产系统不要这样用。DATE_ADD(d, INTERVAL 1 DAY)表示每人只住一晚,你可以改成 2 天、3 天来模拟不同房晚分布。

6.2 用统计 SQL 自测:入住率与收入能不能对得上

数据造完不代表系统正确,要用统计 SQL 对账。第一个验证是“订单状态已完成的订单,必须都有对应的退房记录”。如果两者数量不一致,说明退房服务的更新逻辑有遗漏:

SELECT (SELECT COUNT(*) FROM booking_order WHERE order_status = 2) AS finished_orders, (SELECT COUNT(*) FROM check_in_record WHERE actual_check_out IS NOT NULL) AS checked_out_records;

第二个验证是检查房间状态,运行一段时间后,所有空闲房间的 status 都应该是 0。如果出现 status 为 1 或 2 但没有对应未完成订单的房间,就是脏数据:

SELECT room_id, room_no, status FROM room WHERE status IN (1, 2) AND room_id NOT IN ( SELECT room_id FROM booking_order WHERE order_status IN (0, 1) );

这两个查询的思路是“数据自洽”:业务状态之间必须能对上账。把统计 SQL 放进答辩演示里,老师能直接看到你的系统经过七天模拟运营后数据仍然一致,这比口头说“我测试过”有说服力得多。

我当年做课设时吃过“先查再改”的亏,后来养成一个习惯:凡是涉及房态变化的代码,都要在 WHERE 里带上旧状态条件;每跑一次模拟数据,就用对账查询把所有状态数一遍。答辩前一周,我把每个状态流转的 SQL 打印出来贴在电脑边上,老师问到哪一步就能立刻讲出对应语句。这个习惯后来带进了工作,帮我挡掉不少线上问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询