导读
排课一保存就提示冲突,老师时间老是撞、教室被两个班同时占。问题不在教务粗心,是排课没做区间重叠校验和资源约束。本文用一次真实落地,把每周两三起的排课冲突,在排课阶段拦截到 0。
一、先说背景
前段时间帮一家有 3 个校区的少儿艺术培训做排课,课程以美术、舞蹈、口才为主,外加一部分一对一约课。机构有 1200 多名学员、40 多个老师、将近 30 间教室。排课分三类:学期初的固定班课表(每周固定时间,一学期 16 周)、一对一的临时约课、以及日常的调课和补课。
之前教务是在 Excel 里排好课表,再导入系统,开课后才发现问题:同一个老师在周六上午 10 点被排了两个班、同一间教室被一个美术班和一个书法班同时占用。开学头两周,平均每周都有两三起冲突,老师在教室门口等、家长在场,非常被动。
我们一开始以为是教务排得急,把两个学期的课表翻出来核对,发现问题全在系统:排课保存时只校验了"这个时间段有没有课",没有校验"老师、教室、学员这些资源在这个时间段是否已被占用"。 这也是这次复盘最想讲清的一点:排课的难点不在"把课存进表",而在时间区间判断、资源约束、排课事务这三件事。
二、第一层:时间区间重叠判断(基础中的基础)
冲突检测的第一步,是判断两节课的时间段是否重叠。这一步看起来简单,但写错的概率非常高。
正确的判断是把每节课看成半开区间 [start, end),两个区间重叠的充要条件是:start1 < end2 且 start2 < end1。
# 正确:半开区间,端点相接不算冲突
# 一节 10:00 下课,另一节 10:00 上课,是允许的
def overlap(s1, e1, s2, e2):
return s1 < e2 and s2 < e1
我们最早写的是另一个版本,只判断了第二节课的开始是否落在第一节课区间内:
# 错误:只覆盖了"s2 在 s1 区间内",漏掉了包含关系
# 当新课把已有课整个包住(s1 在 s2 区间内)时,检测不到
def bad_overlap(s1, e1, s2, e2):
return s1 <= s2 < e1
举个实际踩过的例子:教务给一个班临时加了一节 2 小时的连堂课,时间从 9:00 到 11:00,把这个时间段原本 9:30 的一节常规课整个包住了。错误的判断里,9:30 < 9:00 不成立,检测不到冲突;而正确写法 9:00 < 9:30 的下课时间 且 9:30 < 11:00,能立刻判出重叠。
为什么用半开区间(end 不包含):如果把下课时间也算在区间内,那么 10:00 下课、10:00 上课的两节连排课会被误判冲突。端点相接本就应该允许,这也和后面"课间缓冲"的处理是分开的两件事。
三、第二层:三类资源约束(老师、教室、学员)
时间重叠只是必要条件,真正决定"算不算冲突"的是资源。一节课占用的资源有三类,同一时间段内,只要有一类资源重合,就是冲突。
资源 1:老师。 同一个老师不可能同时上两个班。
资源 2:教室。 同一间教室不能被两个班同时占用。
资源 3:学员。 同一个学员不能同时出现在两节课里。班课比对的是班级(一个班的学员集合),一对一比对的是具体学员。
def check_conflict(lesson, existing):
# 时间不重叠,直接无冲突
if not overlap(lesson.start, lesson.end,
existing.start, existing.end):
return None
# 三类资源逐一比对,任一重合即冲突
if lesson.teacher_id == existing.teacher_id:
return ("老师", existing)
if lesson.room_id == existing.room_id:
return ("教室", existing)
students_new = set(lesson.student_ids)
students_old = set(existing.student_ids)
if students_new & students_old:
return ("学员", existing)
return None
这里有两个容易漏的点。
第一,班课不能只比对老师。 我们一开始只校验了老师和教室,结果两个由不同老师上、但有几个共同插班学员的班排在了同一时段,这几个学生的时间撞了。后来把学员集合也纳入比对,取交集非空就判冲突。
第二,学员比对要考虑班课的整班学员。 一对一课直接取 student_id,但班课要展开成班级里所有学员的集合,再和其他课的学员集合求交集。一个班几十个学员,逐个比对,我们用集合相交来做,性能和正确性都更可控。
四、第三层:周期课展开与排课事务
解决了"单节课怎么校验",接下来是教育机构特有的两个场景:周期课和调课。
场景 1:周期课要展开成课次。 班课是"每周六 10:00、共 16 次"这样的周期规则,保存时必须在整个学期范围内展开成 16 个具体课次,逐课次、逐资源校验,而不是只校验一个时间点。
from datetime import timedelta
def expand(recurring):
lessons = []
cur = recurring.start_date
for _ in range(recurring.times):
if not is_holiday(cur): # 跳过节假日、停课日
lessons.append(make_lesson(recurring, cur))
cur += timedelta(days=7) # 每周推进
return lessons
展开时必须跳过节假日和机构停课日,否则系统会在不开课的日子也判出一堆冲突。我们一次排秋季 16 周、约 120 个班,展开后是 1900 多个课次,全部校验一遍再整体保存。
场景 2:调课、补课必须放在一个事务里。 调课是"旧时段释放、新时段占用",补课是临时加一节。最危险的写法是先删旧课、再插新课:
# 危险:先删旧再插新,新课校验失败时,旧课已经没了
def bad_reschedule(old, new_slot):
delete(old)
if find_conflict(new_slot):
raise ConflictError() # 走到这里,旧课已丢失
insert(new_slot)
改成先校验新时段、确认无冲突,再在一个事务里完成删旧插新,并把要调的这节课本身排除在冲突校验之外(它和自己当然"重叠"):
def reschedule(old, new_slot):
with transaction():
conflict = find_conflict(new_slot, exclude_id=old.id)
if conflict:
raise ConflictError(conflict)
delete(old)
insert(new_slot)
五、并发与边界情况
并发:两个教务同时排同一资源。 两个教务在各自的页面上,同时给同一个老师排周六 10 点,两人的校验都通过、再各自写入,就会产生冲突。应用层的"先查后写"在并发下挡不住。我们的做法是在排课事务里,对该老师(或教室)这段时间涉及的课次加排他锁,SELECT ... FOR UPDATE,把同一资源的排课串行化;校验和写入在同一事务内完成。
课间缓冲:换教室要时间。 一个老师 10:00 在 A 教室下课、10:00 在 B 教室上课,时间不冲突,但人走不过去。这种"端点相接"前面说了不该判死,所以我们把缓冲做成可配置项,按老师、教室设置 5 到 10 分钟的换场时间,比对时给区间加上缓冲,而不是改半开区间的定义。
其他边界: 连堂课(两节连上、中间不休息)要按一整段处理;跨天、全天课要单独判断;一对一约课和班课共用一套资源校验,只是学员比对的粒度不同。
踩坑清单
- 区间判断漏掉包含关系:只判"开始是否落在区间内",新课包住旧课时检测不到,必须用
start1 < end2 and start2 < end1。 - 只校验老师和教室、不校验学员:插班学员同时出现在两个班,学员集合要取交集。
- 周期课只校验一个时间点:必须展开成整个学期的课次,逐次校验并跳过节假日。
- 调课先删后插:新课校验失败会把旧课弄丢,要先校验、再在一个事务里删旧插新。
- 并发只靠应用层查询:两人同时排同一资源会双双通过,要用事务加排他锁串行化。
- 把换场缓冲写进区间定义:端点相接本应允许,缓冲应单独做成可配置项。
结语
排课冲突看似是"排的时候没注意",根子是系统没把"时间区间重叠"和"资源占用"做成强校验。先用半开区间判断重叠,再对老师、教室、学员三类资源逐一比对,周期课展开成课次、调课放进一个事务,并发用锁串行化,排课阶段就能把冲突拦下。排查顺序建议:先看区间判断有没有漏掉包含关系,再确认三类资源是否都校验,最后查调课是不是先删后插、并发有没有加锁,这类问题基本可以一次根治。