简介:围绕酒店客房管理展开的数据库课程设计资料包,面向高校计算机及相关专业学生与课程设计指导教师,聚焦客房预订、入住、退房、订单流转和房间状态更新等业务的数据建模与数据库实现。压缩包共30个文件,以10个Java源码和18个class编译文件为主体,另附2张jpg运行截图,整体约404KB,便于阅读代码结构、对照界面效果并复现实验。内容涉及客房、客户、订单、入住及退房记录等实体关系设计,主外键约束与数据类型分配,SQL增删改查、JDBC连接和图形界面交互,也包含存储过程封装业务逻辑、正常预订与提前退房等测试场景,以及SQL注入防护和客户隐私保护思路。已有1877人学习,适合需要完成数据库课程设计、梳理酒店客房管理流程或查找Java与关系型数据库结合实现参考的读者。
1. 一间房卖了两次,问题出在数据库
凌晨两点,前台把同一间空房开给了第二位客人,系统里两条入住记录都合法。这类事故在酒店管理系统里不少见,而在「数据库课程设计——酒店管理系统(客房管理)」这个题目里,它恰好是最值得展开的部分:客房管理并不是把房间信息录进去就完事,它要处理房态流转、预订冲突、价格快照、账单归集四件事,每一件都落在表结构和事务边界上。这套设计面向两类读者:要交数据库课程设计的学生,和需要给中小酒店搭一套能跑的客房模块的开发者。下面从需求、概念模型、物理设计、并发控制一路写到验收,每一步都给可执行的 SQL 和判断标准。
2. 需求与概念建模:三条业务主线与九张核心表
客房管理最容易犯的错,是把它当成客房表的增删改查。真实场景里,一间房在一天之内可能经历「可售 → 预订 → 在住 → 待清洁 → 可售」的完整循环,每个环节都要留下记录,而这些记录之间又互相约束。做需求分析时我一般不看界面上有哪些按钮,而是先找出「哪些动作会产生新记录、哪些动作会修改已有记录」这个核心问题。
2.1 客房业务的三条主线与房态状态机
把客房相关的操作归归类,其实只有三条主线。
第一条是预订线:客人查询房型 → 锁定具体房间 → 生成预订单 → 到店或取消。预订会产生一条 reservation 记录,同时把对应房间的房态从「空闲可用」改成「已预订」。取消则是反向操作,但取消记录不能删除,只能改状态,否则对账时查不到历史。
第二条是在住线:登记入住 → 生成入住单 → 追加消费 → 续住或换房 → 退房结算。入住单是这一条线的主记录,消费明细挂在入住单下,账单在退房时归集。换房是个高频需求,处理方式是关掉旧的入住单、新开一条,并在新单上记录来源单号,而不是直接把房间号改掉——改掉的话,前一晚的房费就无处可查了。
第三条是维护线:退房后派清洁任务 → 清洁完成 → 质检通过 → 恢复可售;期间如果发现设施故障,转维修停用。这条线最容易被课程设计忽略,但它恰恰是房态字段必须存在的原因。
三条线最终都指向同一个字段:房间的当前状态。所以房态的状态机要在设计文档里画清楚,我通常用一张迁移表来表达:
| 当前状态 | 触发动作 | 目标状态 | 谁改了它 |
|---|---|---|---|
| 空闲可用 | 生成预订 | 已预订 | 预订事务 |
| 已预订 | 办理入住 | 在住 | 入住存储过程 |
| 已预订 | 取消预订 | 空闲可用 | 取消事务 |
| 在住 | 退房结算 | 待清洁 | 退房存储过程 |
| 待清洁 | 清洁完成 | 空闲可用 | 清洁任务更新 |
| 任意状态 | 报修 | 维修停用 | 维修单 |
| 维修停用 | 修复验收 | 待清洁 | 维修单关闭 |
这张表看着简单,但它是后面所有约束的依据。比如「在住」的房间不能生成预订,「维修停用」的房间不能出现在可售列表里,这些校验如果只写在界面层,一旦有人直接改数据库就会全线崩掉。我在方案里会把它们写成 CHECK 约束或者集中放进存储过程,两者选其一,不要一半在库里一半在代码里。
另外要提醒一句,房态字段是典型的「冗余但必要」。严格按范式来说,房间是否被占用完全可以由入住单表推导出来,不需要在 room 表上再存一份。但每次查可售房都要做一次区间判断加子查询,前台列表刷新的性能会很难看。所以这里我选择用空间换时间:room 表存当前状态,入住单表存历史事实,再用事务保证两者同步。这是课程设计里可以大方写进文档的一个取舍点,老师通常会追问你「为什么不满足 3NF」,你要答得出来。
2.2 ER 图里的实体、联系与基数
概念设计阶段,我建议把实体控制在九张表以内,多了在答辩时讲不清,少了又撑不起客房管理的业务复杂度。这九张表分别是:
客房类型表 room_type 存房型名称、床型、面积、门市价、可住人数,它是价格和超售策略的源头。客房表 room 存房号、楼层、房型外键、房态、备注,房号做唯一约束。客人表 guest 存证件类型、证件号、姓名、手机号,证件号唯一。预订表 reservation 存预订号、客人外键、房间外键、预计到店日期、预计离店日期、价格快照、状态。入住单表 stay_order 存入住单号、预订外键(散客可空)、房间外键、实际入住时间、预计离店、实际离店时间、房价快照、押金。消费明细表 charge_item 存入住单外键、项目类型、数量、单价、金额、发生时间。账单表 bill 存入住单外键、应收金额、已收金额、结算方式、结算时间。员工表 staff 存工号、姓名、岗位。清洁任务表 housekeeping_task 存房间外键、任务类型、状态、派单人、完成时间。
联系上,房型与客房是 1:N,客房与预订单是 1:N,客人与预订单是 1:N,预订与入住单是 1:1(可空,散客直接入住),入住单与消费明细是 1:N,入住单与账单是 1:1,客房与清洁任务是 1:N。
这里有个设计选择值得在文档里写一段:预订到底绑房型还是绑具体房间。绑房型更贴近真实酒店(客人订的是「大床房」而不是「508 房」),但要在入住时才分配房间,冲突判断就变成对房型库存的计数。绑具体房间则冲突判断简单直接,用房间号加日期区间就能判断,课程设计里更好演示事务和锁。我这套方案选的是绑具体房间,同时保留房型字段用于统计和价格来源。如果老师问你为什么不做超售,你就答「课程设计范围限定为不超售,避免库存计数引入额外复杂度」,这是个站得住脚的回答。
2.3 从 ER 到关系模式:范式取舍与冗余字段
把 ER 图转成关系模式时,主键我统一用自增整数代理键,业务单号(预订号、入住单号)单独做唯一索引。这样做的好处是外键关联时不用传长字符串,索引也更紧凑。代价是每次插入后要回查一次自增 ID,在存储过程里用 LAST_INSERT_ID() 就能解决。
范式方面,主体按第三范式落地:客人的手机号只存在 guest 表,房型价格只存在 room_type 表,消费项目只存在 charge_item 表。但有三个地方我故意留了冗余,理由要写清楚。
第一个是价格快照。预订表和入住单表都存了下单当时的房价,哪怕 room_type 表后来调价,历史订单金额也不受影响。这是酒店行业的硬性要求,账单不能因为改价而变。第二个是房态字段,前面说过,用冗余换查询性能。第三个是账单里的应收金额,它本可以由消费明细加总算出,但结算后明细可能被补录(比如迷你吧后来才发现),所以结算那一刻的金额要落一份快照。
这三个冗余点,在课程设计文档里最好单独列一节叫「反范式设计说明」,把「冗余了什么、为什么冗余、靠什么保证一致」三句话写全。答辩时这是加分项,因为它证明你理解范式是工具而不是教条。
3. 物理设计与完整性约束:建表脚本里最容易翻车的地方
概念模型再漂亮,落不到建表脚本上都是空的。这一章给的是可以直接跑的 MySQL 8.0 脚本,字段类型、约束、索引都在里面。选择 MySQL 是因为课程设计环境普遍好装,InnoDB 支持事务和外键,够用。
3.1 表结构与字段类型选型
先看房型和客房两张基础表:
-- 客房类型表:价格与房型属性的唯一来源 CREATE TABLE room_type ( type_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, type_name VARCHAR(32) NOT NULL COMMENT '如:标准大床房', bed_type VARCHAR(16) NOT NULL COMMENT '床型', area DECIMAL(5,1) NOT NULL COMMENT '面积,平方米', max_guests TINYINT UNSIGNED NOT NULL DEFAULT 2, list_price DECIMAL(10,2) NOT NULL COMMENT '门市价,禁止用FLOAT', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', UNIQUE KEY uk_type_name (type_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客房类型'; -- 客房表:房态字段是冗余,靠事务与存储过程维护 CREATE TABLE room ( room_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, room_no VARCHAR(8) NOT NULL COMMENT '房号,如1208', floor_no TINYINT NOT NULL, type_id INT UNSIGNED NOT NULL, room_status VARCHAR(8) NOT NULL DEFAULT 'AVL' COMMENT 'AVL空闲 DTY待清洁 RSV已预订 OCC在住 OOO维修', remark VARCHAR(128) DEFAULT NULL, UNIQUE KEY uk_room_no (room_no), KEY idx_status_type (room_status, type_id), CONSTRAINT fk_room_type FOREIGN KEY (type_id) REFERENCES room_type(type_id), CONSTRAINT ck_room_status CHECK (room_status IN ('AVL','DTY','RSV','OCC','OOO')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客房';两个参数值得单独说。金额一律用 DECIMAL(10,2),不用 FLOAT 也不用 DOUBLE,浮点数在累加几十条消费后会出现 0.01 级别的误差,退房时对不上账,这是血泪经验。房态用 VARCHAR(8) 存英文缩写而不是 TINYINT,是为了导出报表和排查时肉眼可读,代价是占用几个字节,完全可以接受。CHECK 约束在 MySQL 8.0.16 之后才真正生效,如果环境是 5.7,这条约束会被忽略,要改成触发器或在应用层兜底,答辩前先确认版本。
再看预订表和入住单表,这两张是业务核心:
CREATE TABLE reservation ( res_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, res_no VARCHAR(20) NOT NULL COMMENT '预订号,业务唯一', guest_id INT UNSIGNED NOT NULL, room_id INT UNSIGNED NOT NULL, checkin_date DATE NOT NULL, checkout_date DATE NOT NULL, room_price DECIMAL(10,2) NOT NULL COMMENT '价格快照', res_status VARCHAR(8) NOT NULL DEFAULT 'BOOKED' COMMENT 'BOOKED已订 CHECKED已入住 CANCEL取消 NOSHOW未到', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_res_no (res_no), KEY idx_room_date (room_id, checkin_date, checkout_date), CONSTRAINT fk_res_guest FOREIGN KEY (guest_id) REFERENCES guest(guest_id), CONSTRAINT fk_res_room FOREIGN KEY (room_id) REFERENCES room(room_id), CONSTRAINT ck_res_date CHECK (checkout_date > checkin_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预订单';ck_res_date这条 CHECK 看起来不起眼,但它是防止「离店日期早于到店日期」这种脏数据的最后一道门。很多同学把日期校验只放在前端日期控件里,结果用 Postman 直接调接口就能插进去一条负晚数的订单,对账时金额算出来是负的。
3.2 主键、外键与唯一约束的落点
主键统一代理键这件事前面说过,这里补充唯一约束的用法。业务单号必须有唯一索引,不只是为了防重,更是为了让「重复提交」这种常见线上问题在数据库层被拦住。前台网络卡顿,用户连点两次提交,如果单号是应用生成的 UUID,插入第二条就会报唯一键冲突,接口捕获后返回「请勿重复提交」即可。
外键的删除行为要逐个想清楚,默认的 RESTRICT 未必都对。客户表被预订表引用,客人信息不允许物理删除,只能打标记,所以 RESTRICT 是对的。消费明细被账单引用,也不该级联删除。但清洁任务表引用客房,如果客房被删了,任务记录留着就是孤儿数据,这里可以设成 ON DELETE CASCADE。我一般会在设计文档里画一张表,把每一条外键的更新、删除行为列出来,这比在答辩时被临时问住要强。
还有一个细节:外键列上必须手动建索引。InnoDB 在为外键建约束时会自动创建一个索引,但如果你已经有以该列打头的组合索引,它就不会重复建。所以像 reservation 表的 room_id,通过idx_room_date组合索引的最左前缀就已经覆盖,不必再单独建。这个点答辩时被问到「为什么外键列没建索引」,你能答上来就算过关。
3.3 索引设计:哪些查询真正需要索引
索引不是越多越好,每建一个都会拖慢写入。判断标准很简单:把客房管理模块的 SQL 全列出来,看哪几条会扫大表。
第一条是前台的可售房查询:按房型和房态筛选。对应idx_status_type (room_status, type_id),注意字段顺序,等值条件在前、区分度高的在前,这里房态的选择性其实不高(大部分房间是 AVL 或 OCC),但它是必填条件,所以放最左。第二条是预订区间冲突检查:给定房间和日期区间,查有没有重叠的有效预订。对应idx_room_date (room_id, checkin_date, checkout_date),room_id 等值匹配后用日期做范围扫描,效率很好。
第三条是账单查询:按结算时间做月度统计。这类报表查询建idx_bill_settle (settle_time)就够了,但如果数据量不大(课程设计通常几千条),不建也行,讲清楚「为什么不建」同样是加分项。
这里有个常见误用:给每个字段都建单列索引。比如给 room 表的 room_status 单独建一个索引,查询WHERE room_status='AVL'时优化器可能根本不用它,因为回表成本高于全表扫描。组合索引的前缀能覆盖的场景,就不要再建单列索引。
3.4 视图:把高频联表查询固定下来
客房管理里有几个查询会被反复调用,与其在每个接口里重写 JOIN,不如封成视图。视图还有个好处:字段变更时只改视图定义,上层 SQL 不动。
-- 客房全貌视图:前台列表和报表共用 CREATE VIEW v_room_detail AS SELECT r.room_id, r.room_no, r.floor_no, t.type_name, t.list_price, r.room_status, CASE r.room_status WHEN 'AVL' THEN '可售' WHEN 'DTY' THEN '待清洁' WHEN 'RSV' THEN '已预订' WHEN 'OCC' THEN '在住' WHEN 'OOO' THEN '维修停用' END AS status_text, (SELECT s.stay_id FROM stay_order s WHERE s.room_id = r.room_id AND s.actual_checkout IS NULL LIMIT 1) AS current_stay_id FROM room r JOIN room_type t ON t.type_id = r.type_id; -- 今日预抵视图:前台交接班必看 CREATE VIEW v_today_arrival AS SELECT res_no, guest_name, room_no, checkin_date, room_price FROM reservation r JOIN guest g ON g.guest_id = r.guest_id JOIN room m ON m.room_id = r.room_id WHERE r.res_status = 'BOOKED' AND r.checkin_date = CURDATE();第一个视图里那个标量子查询要慎用,房间数量在几百间时没问题,上千间时每次刷新都会变慢,真到那一步就改成 LEFT JOIN 加 GROUP BY。第二个视图依赖 CURDATE(),好处是永远查当天,坏处是不能用来查历史某天,如果要做「查询指定日期的预抵」,就把日期改成参数化的存储过程。视图在课程设计里常被当成「可选内容」,其实它是展示你懂分层的好材料,答辩时主动讲一句「视图承担了读模型的职责,写操作一律走基表」,印象分会高不少。
4. 事务与并发控制:同一间房被订两次是怎么发生的
前面所有的表结构和约束,都只能保证单条 SQL 正确。真正的麻烦在并发:两个前台同时给同一个房间下单,两边都先查「这间房在 6 月 10 日到 12 日有没有预订」,都查到没有,然后都插入成功。这不是玄学,是典型的检查后写入竞态,光靠唯一约束也拦不住,因为两条预订的日期区间不同,唯一键不冲突。
4.1 预订冲突的四种做法与选型
我把常见方案列成一张对比表,这张表建议直接搬进设计文档:
| 方案 | 实现方式 | 优点 | 缺陷 |
|---|---|---|---|
| 先查后插 | 应用层查重再插入 | 简单 | 并发下必然失效 |
| 唯一约束 | 房间+日期建唯一键 | 数据库兜底 | 只能防同日,防不住区间重叠 |
| 悲观锁 | SELECT ... FOR UPDATE | 逻辑直观,强一致 | 锁持有时间长,需注意死锁 |
| 乐观锁 | 版本号或时间戳比对 | 无锁等待 | 冲突多时重试成本高 |
课程设计里我推荐悲观锁方案,因为它把「锁住哪一行、锁多久」讲得清楚,代码量也小。具体做法是在事务开始后先对 room 表的对应行加排他锁,把并发请求串行化,然后再做区间冲突检查、再插入。由于同一房间的所有预订都会争抢同一行,冲突检查就不会出现两个会话同时通过的窗口。
这里有个细节要注意:锁的必须是 room 表的行,而不是 reservation 表。锁 reservation 表没有意义,因为你要防的是「还不存在的记录」,不存在的行锁不住。这就是所谓的间隙锁场景,靠 InnoDB 在 REPEATABLE READ 下的临键锁能覆盖一部分,但依赖它不如显式锁房间行来得可控。
4.2 事务边界与隔离级别选择
事务边界的原则是「尽量短,但必须包住所有校验和写入」。把校验放在事务外、插入放在事务内,等于没做。下面是一段完整的事务脚本:
START TRANSACTION; -- 1. 锁住目标房间,后续同房间的预订请求会在此排队 SELECT room_id, room_status FROM room WHERE room_id = 508 FOR UPDATE; -- 2. 区间重叠检查:新订区间与已有有效预订是否相交 SELECT COUNT(*) INTO @conflict FROM reservation WHERE room_id = 508 AND res_status IN ('BOOKED','CHECKED') AND checkin_date < '2025-06-12' -- 新单离店日 AND checkout_date > '2025-06-10'; -- 新单到店日 -- 3. 无冲突则插入,并把房态改为已预订 -- 实际执行时由应用判断 @conflict 是否为 0 INSERT INTO reservation (res_no, guest_id, room_id, checkin_date, checkout_date, room_price, res_status) VALUES ('R20250610001', 1001, 508, '2025-06-10', '2025-06-12', 388.00, 'BOOKED'); UPDATE room SET room_status = 'RSV' WHERE room_id = 508 AND room_status = 'AVL'; COMMIT;区间重叠的判断条件值得拆开讲:两个闭开区间 [a,b) 和 [c,d) 相交,等价于a < d AND c < b。这个式子比「大于等于且小于等于」的写法更准确,因为它天然处理了「上一单 12 日退房、下一单 12 日入住」这种合法的接续情况。很多同学写成checkin_date <= '2025-06-12',结果把接续订单判成冲突,前台天天投诉。
隔离级别用 MySQL 默认的 REPEATABLE READ 即可,配合FOR UPDATE已经能保证强一致。如果换成 READ COMMITTED,FOR UPDATE依然有效,但范围查询的间隙锁会减少,理论上并发更高。课程设计里不必纠结,把「为什么选 RR」写清楚——默认值、支持间隙锁、可重复读便于对账——就够。
死锁要提一句。如果以后扩展到「一次订多间房」,两个事务分别锁 508 和 512,顺序相反就会死锁。解决办法是固定加锁顺序,按 room_id 升序逐个锁。这条经验放到答辩里说,基本能镇住场。
4.3 存储过程与触发器:房态联动怎么写才可控
房态和业务单据的联动,有两种落点:存储过程或触发器。我的习惯是核心流程走存储过程,触发器只做轻量的审计日志,原因是触发器藏在暗处,出问题时最难排查,堪称黑匣子。
入住登记就适合做成存储过程,因为它要连续改多张表:
DELIMITER $$ CREATE PROCEDURE sp_check_in( IN p_res_no VARCHAR(20), IN p_room_id INT UNSIGNED, IN p_deposit DECIMAL(10,2) ) BEGIN DECLARE v_guest_id INT UNSIGNED; DECLARE v_price DECIMAL(10,2); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 取预订信息并加锁,防止重复办理入住 SELECT guest_id, room_price INTO v_guest_id, v_price FROM reservation WHERE res_no = p_res_no AND res_status = 'BOOKED' FOR UPDATE; -- 写入住单,房价用预订时的快照价 INSERT INTO stay_order (order_no, res_id, guest_id, room_id, checkin_time, room_price, deposit) SELECT CONCAT('S', DATE_FORMAT(NOW(),'%Y%m%d%H%i%s')), res_id, guest_id, p_room_id, NOW(), v_price, p_deposit FROM reservation WHERE res_no = p_res_no; -- 联动更新:预订已入住,房态转为在住 UPDATE reservation SET res_status = 'CHECKED' WHERE res_no = p_res_no; UPDATE room SET room_status = 'OCC' WHERE room_id = p_room_id; COMMIT; END$$ DELIMITER ;参数三个:预订号用于定位单据,房间号用于联动房态,押金金额由前台录入。整个过程在一个事务里,任何一步失败都整体回滚,不会出现「入住单写了但房态没改」的中间态。EXIT HANDLER那段是兜底,捕获异常后回滚并重新抛出,让上层能拿到错误信息。
退房流程同理,只是方向相反:写入离店时间 → 汇总消费明细生成账单 → 预订状态改 CHECKED 或完成 → 房态改为待清洁 → 插入一条清洁任务。这一串如果拆成五条独立 SQL 由应用依次调用,中间任何一次网络抖动都会留下不一致数据,所以必须收进一个存储过程。
至于触发器,我只建议用在审计上,比如 room 表的房态变化记一条日志到 room_status_log。这种场景即使触发器失败也不影响主流程,风险可控。把核心业务逻辑写进触发器,后期改需求时你会非常难受,因为触发器不能像存储过程那样单独调试。
5. 常见问题排查:五个最容易在答辩现场翻车的点
课程设计做完能跑,和做得对,中间隔着一段距离。下面这五个问题,是我见过出现频率最高的,每一个都按「现象 → 原因 → 解决」拆开写,答辩前逐条自查一遍,能省掉不少尴尬。
5.1 房态和入住记录对不上
现象是查 room 表显示 508 在住,但 stay_order 表里 508 的入住单已经退房了,或者反过来,房间空着但入住单还挂着。
原因基本是两个:一是房态更新和入住单写入不在同一个事务里,应用先写了入住单,再调接口改房态,中间失败了;二是多处代码都在改 room_status,比如退房逻辑改了,清洁任务完成时又覆盖了一次,后写的把先写的盖掉。
解决办法是把房态变更收敛到唯一入口。所有改房态的动作都走存储过程,应用层不直接 UPDATE room。另外加一条对账 SQL,放在后台每天跑一次:
-- 找出房态与在住记录不一致的房间 SELECT r.room_no, r.room_status, s.stay_id FROM room r LEFT JOIN stay_order s ON s.room_id = r.room_id AND s.actual_checkout IS NULL WHERE (r.room_status = 'OCC' AND s.stay_id IS NULL) OR (r.room_status <> 'OCC' AND s.stay_id IS NOT NULL);这条 SQL 返回空集才算过。我一般会把它写进验收清单,答辩时现场跑一次,比口头解释有说服力得多。
5.2 退房后房间一直显示待清洁
现象是客人退房了,房态停在 DTY,清洁员打扫完在系统里点了完成,房态却没变回 AVL。
原因通常是清洁任务完成时的更新条件写错了,比如WHERE room_status = 'DTY' OR room_status = 'AVL',把本不该动的房间也改了;或者任务表里存的房间号和 room 表对不上,更新影响了 0 行但代码没检查返回值。
解决方法是更新语句必须带状态判断并检查影响行数:
UPDATE room SET room_status = 'AVL' WHERE room_id = 508 AND room_status = 'DTY'; -- 只有待清洁才能恢复可售 -- 应用层判断 affected_rows,为 0 说明状态已被别人改过,记录日志而不是静默成功这条经验很朴素:凡是带状态机的更新,WHERE 里必须带当前状态。这样即使两个操作并发,也只有一个能成功,另一个影响 0 行被记录下来,问题可追溯。
5.3 账单金额差几分钱
现象是退房结算时,系统算出的应收和手工加总差 0.01 或 0.02。
原因几乎都是金额字段用了 FLOAT 或 DOUBLE。浮点数存 0.1 本身就存不准,几十条消费累加后误差被放大。另一个可能是四舍五入的时机不一致,有的地方每条明细先 round,有的地方汇总后 round。
解决办法是把所有金额字段改成 DECIMAL(10,2),累加用 SUM 后统一 round 一次,明细表里不要提前取整。如果涉及折扣和税率,把折扣率也用 DECIMAL(5,4) 存,别用浮点。改完字段类型后,历史数据要用CAST重新导一遍,否则老数据里的误差还在。
5.4 删不掉的客房
现象是测试时想删一间测试客房,报外键约束错误,删不掉。
原因是 room 被 reservation、stay_order、housekeeping_task 三张表引用,默认 RESTRICT 行为会阻止删除。这其实是正确行为,不该绕过。但很多同学图省事,把外键改成 ON DELETE CASCADE,结果一删房间,历史订单全没了,对账数据直接消失。
正确的做法是给 room 表加一个逻辑删除标记is_active,停用的房间不出现在可售列表里,但记录保留。外键保持 RESTRICT。答辩时如果被问「为什么不做物理删除」,答「客房是历史账单的关联对象,物理删除会破坏审计链」,这个理由足够硬。
5.5 报表查询跑了八秒
现象是月度入住率报表一跑就转圈,数据量才几千条。
原因往往是隐式类型转换导致索引失效。比如 room_no 定义成 VARCHAR,查询写成WHERE room_no = 1208(没加引号),MySQL 会把字符列转成数字比较,索引直接废掉,走全表扫描。
排查方法是加 EXPLAIN 看执行计划:
EXPLAIN SELECT r.room_no, COUNT(*) AS stay_cnt FROM stay_order s JOIN room r ON r.room_id = s.room_id WHERE s.checkin_time >= '2025-06-01' AND s.checkin_time < '2025-07-01' GROUP BY r.room_no;重点看 type 是不是 ALL、key 是不是 NULL、rows 是不是接近全表行数。如果是,先检查字段类型和参数类型是否一致,再看有没有合适的组合索引。入住时间统计这类查询,建一个idx_stay_checkin (checkin_time, room_id)就能覆盖。EXPLAIN 这个工具在课程设计里很少被要求,但只要你主动用一次并把结果讲清楚,评委会立刻觉得你是真跑过数据的人。
6. 验收与答辩:用二十分钟证明这套库是对的
验收不是把界面点一遍,而是用可复现的用例证明每条约束都生效。我一般准备一张验收清单,每条都对应一句 SQL 和一个预期结果,现场逐条执行,比讲十分钟设计思路都管用。
| 验收项 | 操作 | 判定标准 |
|---|---|---|
| 房号唯一 | 插入重复房号 | 报唯一键冲突 |
| 日期合法性 | 插入离店早于到店的预订 | 报 CHECK 约束失败 |
| 预订冲突 | 两会话并发下单同房同日 | 仅一条成功,另一条冲突 |
| 房态联动 | 执行入住存储过程 | room 状态变 OCC,预订变 CHECKED |
| 对账一致 | 跑 5.1 的对账 SQL | 返回空集 |
| 金额精度 | 累加十条 0.1 元消费 | 结果为 1.00,无浮点误差 |
| 索引生效 | 对可售房查询跑 EXPLAIN | key 命中 idx_status_type |
并发那条最值得现场演示,写个二十行的脚本就够:
import threading, pymysql def book(room_id, tag): conn = pymysql.connect(host='localhost', user='root', password='***', database='hotel', autocommit=False) cur = conn.cursor() try: cur.execute("SELECT room_id FROM room WHERE room_id=%s FOR UPDATE", (room_id,)) cur.execute("""SELECT COUNT(*) FROM reservation WHERE room_id=%s AND res_status IN ('BOOKED','CHECKED') AND checkin_date < '2025-06-12' AND checkout_date > '2025-06-10'""", (room_id,)) if cur.fetchone()[0] == 0: cur.execute("""INSERT INTO reservation (res_no, guest_id, room_id, checkin_date, checkout_date, room_price, res_status) VALUES (%s, 1001, %s, '2025-06-10', '2025-06-12', 388, 'BOOKED')""", (f'R{tag}', room_id)) conn.commit() print(f'{tag} 预订成功') else: conn.rollback() print(f'{tag} 冲突,已回滚') finally: cur.close(); conn.close() threads = [threading.Thread(target=book, args=(508, i)) for i in range(5)] [t.start() for t in threads] [t.join() for t in threads]脚本开五个线程抢同一间房,正确结果是只有一条「预订成功」,其余四条打印冲突。如果五条全成功,说明FOR UPDATE那句没生效,或者连接没关自动提交。这个演示的说服力在于它是不可伪造的,老师看着终端输出就知道你的锁是真加了。
答辩环节通常有三个必问题,提前把答案打磨好。第一问是「你的设计满足第几范式」,答主体三范式、三处有意冗余并说明理由。第二问是「为什么这么建索引」,答按查询模式建、组合索引最左前缀、避免给低区分度字段单独建。第三问是「并发怎么处理的」,答锁房间行、区间判断用a<d AND c<b、事务内完成校验与写入。这三问答顺了,剩下的都是细节。
还有一个容易被忽略的点:把建表脚本、初始化数据、存储过程、视图、测试用例分文件整理好,放在项目目录里。现场演示时直接按序号执行,别在现场敲命令,紧张时最容易打错字。我吃过这个亏,后来每次交设计前,都会从头到尾把脚本在干净的数据库上跑一遍,确认零报错再出门。从那以后我每次交方案前,都会把并发脚本和对账 SQL 强制走一遍,因为它们是最容易在最后一刻出问题的地方。希望帮到你。
本文还有配套的精品资源,点击获取