排课、约课、排班类系统最隐蔽的 bug,几乎都出在“时间冲突”上:课表上看着没问题,直到一位老师被排进同时开的两门课、一间教室在同一时段被两个班占用。这篇把冲突判断从直觉写法讲到正确的区间条件,再到多资源、数据库兜底和整表批量校验,文末附一张可直接照抄的排查清单。
很多人第一反应是枚举“不冲突”的情况:A 在 B 前面、或 A 在 B 后面,于是写出一堆大于小于。这种写法不仅绕,还很容易在“首尾相接算不算冲突”上出错。
更稳的思路是反过来:先求“什么情况下一定冲突”,剩下的都不冲突。 两个半开区间 [s1,e1)、[s2,e2) 发生重叠,当且仅当:
区间重叠条件(半开区间):s1 小于 e2,且 s2 小于 e1。
注意是“开始早于对方结束”,用半开区间(结束时刻不含)后,09:00-10:00 和 10:00-11:00 首尾相接不算冲突,正好符合排课习惯。
function isOverlap(s1, e1, s2, e2) {
// 入参统一为分钟数或时间戳,避免字符串比较
return s1 < e2 && s2 < e1;
}只记这一条就够了:“两个开始都早于对方结束”即重叠,其余情况天然不冲突,不需要再枚举。
课程安排落库后,新增或拖拽一节课时,最可靠的是直接让数据库判断“这个资源在这个时段是否已有课”。以“同一间教室”为例:
SELECT lesson_id, start_min, end_min
FROM schedule
WHERE room_id = :room_id
AND start_min < :new_end
AND end_min > :new_start
LIMIT 1;查到任意一行就是冲突,返回给前端标红即可。把时间统一换算成“从 0 点起的分钟数”(或时间戳)再比较,能彻底避开 '09:00' 字符串比较、跨天、12 小时制这些坑。
应用层封装时把资源做成参数,老师、教室、班级各查一遍:
async function findConflicts(db, {teacherId, roomId, classId, start, end, excludeId}) {
const sql = `
SELECT lesson_id, teacher_id, room_id, class_id FROM schedule
WHERE lesson_id <> :excludeId
AND start_min < :end AND end_min > :start
AND (teacher_id = :teacherId OR room_id = :roomId OR class_id = :classId)
LIMIT 20`;
return db.query(sql, {teacherId, roomId, classId, start, end, excludeId: excludeId || 0});
}excludeId 用来排除“正在编辑的那节课自己”,否则改时间时会和自己判重,这是很容易漏掉的一点。
一节排课记录同时占用三类资源,任何一类撞了都算冲突,且冲突原因要分别告诉用户:
资源 | 冲突含义 | 提示文案应区分 |
|---|---|---|
教师 | 同一老师同时段已有课 | “张老师该时段还有另一节课” |
教室 | 同一教室同时段被占 | “301 教室该时段已被占用” |
班级 | 同一班级同时段另有安排 | “该班该时段已有课程” |
把三类资源拆开判定、分别返回冲突类型,前端才能在对应行精确提示,而不是笼统报一句“时间冲突”。有些场景还有第四类约束,比如“特定课程只能用特定类型教室(机房/琴房)”,这类是资源匹配约束,和时间重叠是正交的两层,建议分开校验、分开报错。
应用层“先查再插”在并发下并不可靠:两个排课请求同时查到“无冲突”,随后都插入,冲突课就这么落库了。和库存超卖同理,需要数据库这一层独立兜底。
做法一:事务加锁。 在同一事务里对相关资源的排课记录加锁再判断:
START TRANSACTION;
SELECT lesson_id FROM schedule
WHERE room_id = :room_id AND start_min < :new_end AND end_min > :new_start
FOR UPDATE;
-- 应用层确认无冲突后再 INSERT
INSERT INTO schedule(teacher_id, room_id, class_id, start_min, end_min)
VALUES (:teacherId, :roomId, :classId, :start, :end);
COMMIT;做法二:用排他约束表。 如果数据库支持范围类型(如 PostgreSQL 的 tstzrange),可以对“资源 + 时间段”建排他约束,从根上拒绝重叠插入:
ALTER TABLE schedule ADD CONSTRAINT no_room_overlap
EXCLUDE USING gist (
room_id WITH =,
tstzrange(start_at, end_at, '[)') WITH &&
);应用层校验负责“体验好、提示准”,数据库约束负责“绝不出错”,两层都要有,不能互相替代。
课表视图里常常是连续拖拽多节课,逐节查库既慢又容易闪烁。可以一次性把相关资源一周的课拉到内存,用排序扫描在线性时间内判完全部冲突:
function findAllOverlaps(lessons, resourceKey) {
const sorted = [...lessons].sort((a, b) => a.start - b.start);
const conflicts = [];
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1], cur = sorted[i];
if (prev[resourceKey] === cur[resourceKey] && prev.end > cur.start) {
conflicts.push([prev.lesson_id, cur.lesson_id]);
}
}
return conflicts; // 同资源按开始排序后,只需比较相邻两条
}同一资源按开始时间排序后,重叠只可能发生在相邻记录之间,复杂度从两两比较的 O(n²) 降到排序的 O(n log n)。老师、教室、班级各跑一遍即可。
[s,e)。lesson_id <> 当前id。冲突校验建议沉淀成一个独立的领域服务,对外只暴露“校验排课意图、返回结构化冲突列表”一个接口,前端拖拽、批量导入、复制课表三种入口都复用它,避免每个入口各写一套判断。时间表示在系统内统一为 UTC 时间戳或分钟整数、只在展示层转本地时区;资源维度用枚举固定下来,后续新增“设备”“监考老师”等资源时只需扩展维度、不改判定算法。批量导入场景先在内存里做一次全量预检、把所有冲突一次性返回,比逐条报错、用户改一条再导一次体验好得多。
s1<e2 AND s2<e1 替代了枚举式判断?排课冲突的难点从来不是“判断两个时间段”,而是把多资源、并发、批量、跨天这些边界一次性兜住。把区间条件写对、把约束分层、把提示做细,这类问题就能从“线上偶发”变成“提交前拦截”。本文为工程实践经验分享,具体实现以所用数据库与业务规则为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。