1. 先搞清楚排课到底在解决什么问题
很多人一听到"教师排课系统",第一反应就是"这不就是个增删改查的CRUD项目吗"。刚开始接这个题目的时候我也这么想,直到真正把需求摸了一遍才发现,排课系统的核心根本不是数据管理,而是约束满足。你面对的不是"怎么存课表",而是"怎么在时间、教师、班级、教室、课程性质这么多维度互相打架的情况下,生成一张没有冲突的课表"。
先说个具体的场景。一个中等规模的学校,假设有60个教学班、200名教师、每学期需要排4000多节次课。排课员拿着Excel表人工排,往往要折腾两三个星期,期间要反复核对"张老师周二上午有没有课""这个教室下午是不是被占用""这个班周四第五节能不能安排体育课"。这种工作重复性极高,而且特别容易出错——漏掉一个冲突,可能到开学第一周上课了才暴露出来。
排课问题的约束,往细了说有这几类:
- 硬约束:必须满足、违反就会出教学事故的条件。比如同一个时间片(周几+第几节)下,一个教师不能同时上两节课;一个教学班不能同时在两个教室上课;一个教室不能被两个班级占用。这属于排课系统的底线,任何自动排课算法和人工调整都必须保证硬约束不被破坏。
- 软约束:尽量满足、但偶尔可以让步的条件。比如每个教师每天的课时量尽量均匀分布,不要出现周一连上六节、周三一整天没课的情况;同一个班尽量不把同一门课连续排两节;教师的课尽量集中在一个上午或一个下午,方便通勤。软约束做不到绝对满足,但能体现排课系统的"智能"程度。
- 显性需求:某些课程对时间、场地有硬性指定。比如体育课必须安排在下午(避开正午高温)、实验课必须使用特定实验室、某些教师只愿意在某个时间段上课。这些需要在排课参数里显式配置进来。
人工排课之所以难,就是因为在几十个班、几百个教师、上千节次的规模下,人脑很难同时追踪多维度冲突。计算机的优势恰恰在这里——把冲突判断逻辑写成代码,遍历上万个组合也就是几秒钟的事。这也是我为什么最终把系统的核心克制在"冲突检测引擎"上,而不是去做花哨的统计报表。统计只是辅助,排课引擎才是灵魂。
这个系统最终的价值在哪里?对排课员来说,是把两周的人工工作量压缩到几小时;对教务管理者来说,是能提前预知冲突、在开学前把所有问题暴露出来;对教师来说,是拿到一张符合自己时间意愿的课表。想明白这些,项目就已经成功了一半——你不是在做一个软件,你是在做一个能替代重复脑力劳动的决策引擎。
2. 从零搭建Springboot排课系统的技术选型与整体架构
2.1 为什么选择Springboot + MyBatis-Plus
技术选型这件事,我不喜欢追新,更看重稳定性和生态成熟度。这套系统最终选了Springboot 2.7.x + MyBatis-Plus + MySQL 8.0 + Redis + Vue 3的组合,理由很简单:
| 组件 | 选择理由 |
|---|---|
| Springboot 2.7.x | 生态成熟,社区资料多,遇到问题基本都能搜到现成答案;自动装配机制让项目搭建成本极低,一个Controller从零到能跑起来只需要几分钟 |
| MyBatis-Plus | 单表CRUD不用写SQL,内置的分页插件和条件构造器能省掉大量样板代码;排课系统查询条件复杂(按教师查、按班级查、按时间片查、组合条件查),MyBatis-Plus的LambdaQueryWrapper非常合适 |
| MySQL 8.0 | 关系型数据模型和排课系统的结构化数据高度匹配;唯一索引、联合索引能直接在数据库层面兜底约束冲突 |
| Redis | 缓存排课结果、分布式锁控制排课任务并发,后面细说 |
| Vue 3 + Element Plus | 展示周课表这类网格化数据非常顺手,组件复用度高 |
很多人纠结"要不要用微服务""要不要上Spring Cloud",我的建议是——排课系统这种体量,单体应用完全足够。微服务的拆分成本、部署成本、运维成本在项目初期都是纯负担。Springboot单体配合合理的模块分包,代码清晰度和维护性已经能打满分。真到了需要拆的那一天(比如学校扩展到几十个校区),再按领域拆也不迟。
2.2 项目结构怎么分,才能让排课逻辑不被CRUD淹没
我见过很多毕设或实际项目的代码,把所有业务逻辑堆在Service层,一个方法几百行,维护起来想骂人。排课系统的核心是算法逻辑,如果跟CRUD混在一起,后期改一个冲突规则会牵连一片。
所以我采用了按业务域分包 + 经典三层结构相结合的方式:
com.school.scheduler ├── controller # 接收HTTP请求,参数校验 ├── service # 业务逻辑层,事务边界在层 ├── mapper # MyBatis-Plus的Mapper接口 ├── entity # 数据库实体 ├── dto # 前端交互对象,避免直接暴露实体 ├── vo # 视图对象,课表展示专用 ├── core │ ├── conflict # 冲突检测核心算法 │ ├── engine # 自动排课引擎 │ ├── rule # 排课规则配置类 │ └── enums # 时间片、课程类型等枚举 ├── common # 统一返回结果、异常处理、工具类 └── config # 配置类(Redis、MyBatis-Plus、跨域等)core包是整个系统的里子,controller和service只是壳子。把排课引擎、冲突检测从业务层抽离出来,好处是算法可以独立测试——不启动Web容器,直接new一个Engine对象就能跑单元测试。后期如果要优化算法,也不会影响排课员在页面上的正常操作。
2.3 核心依赖配置和启动骨架
pom.xml里除了Spring Web和MyBatis-Plus,我还加了几个关键依赖:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>application.yml里有几个值得注意的点。第一个是MyBatis-Plus的逻辑删除配置,排课数据不建议物理删除——比如某个学期的课表排完了,你想重新排,不是把旧数据删了,而是把旧数据标记为作废,保留历史版本很重要:
mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0第二个是时间片相关的表结构设计,后面专门讲。第三个是Redis的连接配置,默认localhost:6379就够了,生产环境如果你要部署到服务器,记得设置密码并开启持久化。
跑起来之后,项目能提供哪些接口?排课员登录后录入教师、班级、课程、教室的基础档案,维护学期校历(哪些周是教学周),配置排课规则(每门课每周多少学时、教师可用时间段),点击"自动排课"按钮,系统基于配置的规则自动生成草稿课表,最后排课员在可视化网格里手动微调,确认无误后发布。这套流程跑通,就是完整的闭环。
3. 数据库设计:用表结构把排课约束焊死
3.1 五张核心表的模型设计
排课系统的数据模型,我拆成了五个核心实体:教师、班级、课程、教室、排课记录。其中排课记录是中心,其他四个都是它的维度参照。
各表的核心设计如下:
teacher(教师表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| name | VARCHAR(32) | 姓名 |
| title | VARCHAR(32) | 职称(影响排课权重) |
| max_hours_per_day | INT | 每日最大课时量,默认6 |
| unavailable_slots | JSON | 不可用时间片ID列表,比如"周三下午去分校开会"这种需求 |
unavailable_slots用JSON存,我一开始纠结过要不要拆表,后来发现查询频率极低、修改频率也低,JSON用起来反而方便——反序列化成List ,直接交给冲突检测器用。
class_info(班级表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| grade | INT | 年级,如2024级 |
| class_name | VARCHAR(32) | 班级名称,如"高一(3)班" |
| student_count | INT | 人数(决定教室容量筛选) |
course(课程表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| course_name | VARCHAR(64) | 课程名称 |
| hours_per_week | INT | 每周总课时数 |
| course_type | TINYINT | 0=公共课,1=专业课,2=实验课 |
| requires_lab | TINYINT | 是否需要实验室 |
| preferred_slot | VARCHAR(64) | 偏好时间片配置,如"下午" |
classroom(教室表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| building | VARCHAR(32) | 教学楼 |
| room_no | VARCHAR(32) | 房间号 |
| capacity | INT | 容量 |
| is_lab | TINYINT | 是否实验室 |
schedule(排课记录表)——这张表是所有的核心,也是最容易出现并发问题的表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| semester | VARCHAR(32) | 学期标识,如"2024-2025-1" |
| teacher_id | BIGINT | 教师ID |
| course_id | BIGINT | 课程ID |
| class_id | BIGINT | 班级ID |
| classroom_id | BIGINT | 教室ID |
| weekday | TINYINT | 星期几,1-5 |
| period | TINYINT | 第几节课,如1-8 |
| status | TINYINT | 0=草稿,1=已确认,2=作废 |
| version | INT | 乐观锁版本号 |
weekday + period共同构成一个时间片,我用一个TimeSlot枚举或工具类来编码这个组合:
public class TimeSlotUtil { // 将星期几和节次编码为唯一整数,如周一第1节 -> 101 public static int encode(int weekday, int period) { return weekday * 100 + period; } }编码规则是个人选择,只要保证唯一性和可逆性就行。核心是在Java代码里,冲突检测只操作一串时间片整数,不反复拼接字符串。
3.2 唯一索引:数据库层面的最后一道防线
代码里的冲突检测再严谨,也防不住并发请求绕过正常逻辑直接写库(比如排课员手动调课时连续点击两次保存按钮)。所以数据库层面必须用唯一索引硬性兜底。
schema.sql里这样定义schedule表的三个唯一约束:
-- 同一时间片下,一个教师不能同时上两门课 UNIQUE KEY uk_teacher_slot (semester, teacher_id, weekday, period) -- 同一时间片下,一个班级不能同时上两门课 UNIQUE KEY uk_class_slot (semester, class_id, weekday, period) -- 同一时间片下,一个教室不能被两个班级同时占用 UNIQUE KEY uk_classroom_slot (semester, classroom_id, weekday, period)这三个唯一索引分别对应三大硬约束。数据库层面的兜底逻辑很简单——如果代码冲突检测有漏洞,INSERT时数据库会直接拒绝并抛出DuplicateKeyException,此时事务回滚,前端弹出"该时间片已被占用"的提示。双保险机制至关重要,这也是我在实际项目里特别强调的一点。
3.3 为什么用"学期+周次"维度而不是具体日期
有朋友问过我,排课表为什么不用具体日期(比如2025-03-10),而是用"第几周+星期几+第几节"这种结构?
原因是教学排课具有周模板复用的特征。一个学期的课表,不是每天单独排的,而是"第1周周一的课表"复用到了"第2周周一、第3周周一……"。基础课表的粒度只需要到"星期几+第几节",具体的"第几周"由学期校历(开学日期、节假日、考试周)在运行时动态计算出来。这样设计有几个好处:
- 数据存储量小。一学期基础课表只有几百条排课记录,而不是按周展开的上万条。
- 课程调整灵活。调课只需要改基础模板,全校各周的课表自动同步。
- 节假日处理简单。某个周一放假,只需要在校历中标记这一天停课,不需要删掉基础课表中的周一数据。
缺点是需要额外封装一层"学期日历"服务,负责把"第X周周Y第Z节"翻译成具体的"2025年4月14日 第3节",但这层翻译逻辑非常简单,拿一个HashMap就能搞定。
4. 排课系统的算法核心:冲突检测与自动排课
4.1 冲突检测器:三重校验的逻辑闭环
自动排课算法的心脏是冲突检测器。它的职责很简单:给定一个课程、一个班级、一个时间片,判断这个安排是否合法。
我把冲突检测封装成一个独立的ConflictChecker类:
@Component public class ConflictChecker { @Autowired private ScheduleMapper scheduleMapper; /** * 检查某个排课安排是否存在硬冲突 * @param semester 学期 * @param teacherId 教师ID * @param classId 班级ID * @param classroomId 教室ID * @param weekday 星期几 * @param period 第几节 * @param excludeId 正在编辑的排课ID(修改场景排除自身) * @return 冲突信息,为空表示无冲突 */ public ConflictResult checkHardConflict(String semester, Long teacherId, Long classId, Long classroomId, int weekday, int period, Long excludeId) { // 1. 教师时间冲突 Long teacherConflict = scheduleMapper.selectCount(new LambdaQueryWrapper<Schedule>() .eq(Schedule::getSemester, semester) .eq(Schedule::getTeacherId, teacherId) .eq(Schedule::getWeekday, weekday) .eq(Schedule::getPeriod, period) .eq(Schedule::getStatus, 1) .ne(excludeId != null, Schedule::getId, excludeId)); if (teacherConflict > 0) { return ConflictResult.conflict("教师在该时间片已有课"); } // 2. 班级时间冲突 Long classConflict = scheduleMapper.selectCount(new LambdaQueryWrapper<Schedule>() .eq(Schedule::getSemester, semester) .eq(Schedule::getClassId, classId) .eq(Schedule::getWeekday, weekday) .eq(Schedule::getPeriod, period) .eq(Schedule::getStatus, 1) .ne(excludeId != null, Schedule::getId, excludeId)); if (classConflict > 0) { return ConflictResult.conflict("该班级在此时间片已有课"); } // 3. 教室时间冲突 Long roomConflict = scheduleMapper.selectCount(new LambdaQueryWrapper<Schedule>() .eq(Schedule::getSemester, semester) .eq(Schedule::getClassroomId, classroomId) .eq(Schedule::getWeekday, weekday) .eq(Schedule::getPeriod, period) .eq(Schedule::getStatus, 1) .ne(excludeId != null, Schedule::getId, excludeId)); if (roomConflict > 0) { return ConflictResult.conflict("该教室在此时间片已被占用"); } return ConflictResult.ok(); } }这段逻辑的一点优化之处在于,用排除自身ID的参数支持了"编辑已有排课记录"的场景——排课员在页面上把某节课从周一第2节挪到周三第3节时,系统检查的是"新时间片有没有冲突",不会因为旧时间片里那节课自身的存在而误报。
三个selectCount之所以分开写而不是用一条复杂SQL,是为了能分别返回具体的冲突提示。排课员看到"张老师周三第3节已有课",比笼统的"该时间片不可用"更有实际意义,他能直接去处理具体冲突。
4.2 自动排课引擎:贪心算法+优先级排序
自动排课算法,我采用的是带优先级的贪心策略,而不是更复杂但实现成本高的回溯算法或遗传算法。后者适合求解全局最优解,但在真实场景里,第一,全局最优解的定义很难量化(每个学校对"最优"的理解不同);第二,回溯算法在大规模数据下可能超时;第三,贪心算法配合良好的优先级策略,产出的课表质量完全能通过"可用"的及格线。
算法的执行流程如下:
public List<Schedule> autoSchedule(SemesterConfig config) { // 1. 准备排课任务队列 List<SchedulingTask> tasks = buildTaskQueue(config); // 2. 按优先级排序:专业课 > 公共课 > 实验课,每门课按课时数降序 tasks.sort(Comparator.comparing(SchedulingTask::getPriority) .thenComparing(Comparator.comparing(SchedulingTask::getWeeklyHours).reversed())); // 3. 逐个任务寻找可用时间片 List<Schedule> result = new ArrayList<>(); for (SchedulingTask task : tasks) { List<Integer> availableSlots = findAvailableSlots(task, config); if (availableSlots.size() < task.getWeeklyHours()) { throw new SchedulingException("课程排不满课时: " + task.getCourseName()); } // 取前N个可用时间片,优先分散分布 for (int i = 0; i < task.getWeeklyHours(); i++) { int slot = availableSlots.get(i); result.add(buildSchedule(task, slot)); } } return result; }findAvailableSlots是核心方法,它遍历标准学期的所有时间片(比如周一第1节到周五第8节,共40个候选),逐一切入到ConflictChecker里做三重校验,顺便把软约束打分也算了。打分维度包括:
- 教师连续上课数不超过3节,超出惩罚;
- 班级当天总课时不超过6节,超出惩罚;
- 同一课程优先分散在不同天(避免一天把一周课全上完);
- 体育课、实验课优先安排到下午时间段。
每个时间片根据违反软约束的程度得到一个分数值,排序后取最优的N个。这里有个值得说的小细节——我在打分时把"教师偏好时间"的权重设得比"课程连续性"更高,因为从真实组织的角度看,教师的通勤满意度直接影响工作配合度,而课程连续性的微小瑕疵并不会造成实际教学问题。权重的设计,本质上是对现实需求的重新排序,这个配置我放在了application.yml里,方便排课员按学校实际情况调整。
4.3 排不满课时怎么办:冲突的"弹性释放"策略
贪心算法最怕的场景是——轮到某个任务时,可用时间片不够了。比如某位名气很大的特级教师每周有12节课,但只愿意在周二和周四上午来学校,这8个时间片全被他之外的基础课占满了。
我设计了两种弹性释放策略,按优先级生效:
- 优先级抢占:如果当前任务是高优先级(专业课),允许抢占低优先级任务已占用的时间片。被抢占的任务重新放回任务队列等待下一轮分配,用Redis记录抢占次数,超过3次的直接抛异常提醒人工介入。
- 跨天扩容:如果默认排课时间不够,允许向"非偏好"时间段扩展。这个策略放在抢占之后——毕竟抢占会引起连锁反应,而扩展某个教师的上课时间边界,是更温和的处理手段。
这两种策略的配合,能让自动排课的完成率维持在95%以上。剩余的5%是真的无解场景——比如一个物理实验课必须要用到某间实验室,而这间实验室整个学期的空闲时间只有周三下午一节。这种场景就该交给人工判断,自动引擎负责提供"差哪节课排不下"的明确提示,而不是无限循环去尝试。
5. 排课结果的手动微调与课表可视化
5.1 拖动交换时间片的手动调课机制
自动排课跑出来的课表,大概率不会让所有人满意。真实的排课流程里,总有几个教师会找教务说"周三下午我要去接孩子""周五第一节能不能别排我的课"。这时候就需要一套灵活的手动调整机制。
我实现的手动调课做了两个方式:拖动调整和互换操作。
拖动调整很好理解——在前端周课表网格里,把某节课从格子A拖到格子B。后端接收到调整请求后,先走一遍ConflictChecker的检查逻辑,不通过就返回报错;通过则直接UPDATE记录的时间片字段。注意这里的检查要带上excludeId,不然会把自己当成冲突。
互换操作更实际——排课员在"教师A周一第1节"和"教师B周三第4节"的两条记录上点"互换",系统直接交换两个时间片。互换的好处是,两张课表的总量不变,教师A拿到自己想要的周三第4节,教师B接手周一第1节,只要双方都确认,教务零压力。
我额外做了个"调课记录"的日志功能,谁在几点几分把哪节课从哪调到了哪,全部留痕。这在真实场景里太重要了——开学两周后有人不认账:"我没调过我的课啊,怎么周四第5节变成我上了?"拉出调课记录一目了然。
5.2 周课表的两种查询方式与性能优化
课表的展示,本质上是把schedule表里的记录按照"行=教师/班级、列=周一到周五+节次"的二维结构渲染出来。查询接口我做了两个:
GET /schedule/teacher/{teacherId}:返回某位教师的周课表;GET /schedule/class/{classId}:返回某个班级的周课表。
这里比较容易踩的坑是N+1查询。如果一次性查出排课记录后遍历每条记录去查教师名、课程名、教室名,40条排课记录就会触发40多次SQL查询。我直接用一条JOIN把维度信息全部带出来:
@Select("SELECT s.*, t.name AS teacher_name, c.course_name AS course_name, " + "ci.class_name AS class_name, cr.building AS building, cr.room_no AS room_no " + "FROM schedule s " + "LEFT JOIN teacher t ON s.teacher_id = t.id " + "LEFT JOIN course c ON s.course_id = c.id " + "LEFT JOIN class_info ci ON s.class_id = ci.id " + "LEFT JOIN classroom cr ON s.classroom_id = cr.id " + "WHERE s.semester = #{semester} AND s.teacher_id = #{teacherId} " + "AND s.status = 1") List<ScheduleVO> getTeacherSchedule(@Param("semester") String semester, @Param("teacherId") Long teacherId);查出来的结果集在前端组建成二维数组,正好填进Element Plus的el-table里。除了初次加载会扫一次表,后续的教师课表查询都走Redis缓存,Redis的value直接存JSON字符串,过期时间设为当天24点。因为校历在学期内基本不变,这个缓存策略能让课表查询接口的响应时间稳定在10ms以内。
5.3 前端网格渲染的关键细节
前端我用的是Vue 3 + Element Plus。课表的网格结构是一个二维数组,行是节次,列是星期一到星期五。
<el-table :data="tableData"> <el-table-column label="节次" width="80" prop="periodLabel" fixed /> <el-table-column v-for="day in days" :key="day" :label="day"> <template #default="{ row }"> <div v-for="item in getCell(row.index, day)" :key="item.id" class="course-cell" :class="'type-' + item.courseType"> <div>{{ item.courseName }}</div> <div>{{ item.teacherName }}</div> <div>{{ item.building }}{{ item.roomNo }}</div> </div> </template> </el-table-column> </el-table>每个格子里的课程块用不同颜色标识课程类型(公共课橙色、专业课蓝色、实验课绿色),教师能一眼看到自己一周的时间分布。这里有个提高体验的小细节——课程块要支持拖拽,HTML5的draggable属性搭配@drop事件就能实现,不需要额外引拖拽库。我自己实测过,拖拽的交互反馈比点击选择"源节次-目标节次"要直觉得多,排课员上手几乎零学习成本。
6. 上线前最容易踩的坑:并发、事务与性能瓶颈
6.1 同一类方法内部调用导致的事务失效
第一个坑我就踩得特别实在。手动调课的swapSlots方法里需要做三步操作:更新记录A的时间片、更新记录B的时间片、写入调课日志。我把这三步放在同一个事务里,测试的时候单线程跑完全没问题,直到开了并发测试才发现——某些调课操作只执行了一步就结束了,另一半丢在了半路。
排查后发现是典型的Spring AOP事务失效场景:调课方法在Service内部又被另一个方法调用,内部调用绕过了代理对象,@Transactional注解根本没生效。
@Service public class ScheduleService { // 正确的做法:通过代理对象调用,事务才生效 @Transactional(rollbackFor = Exception.class) public void swapSlots(Long scheduleAId, Long scheduleBId, String operator) { scheduleMapper.updateSlot(scheduleAId, slotB); scheduleMapper.updateSlot(scheduleBId, slotA); scheduleLogMapper.insertLog(scheduleAId, scheduleBId, operator); } // 错误示范:内部直接调用this.swapSlots(),事务失效 public void batchSwap(List<Long[]> pairs, String operator) { for (Long[] pair : pairs) { this.swapSlots(pair[0], pair[1], operator); } } }解决办法有两种,要么把swapSlots方法拆到独立的Service类里,要么注入自身代理对象(@Autowired+@Lazy)。我最终选择了后者——把批处理循环里的N次互换也包进一个外层事务,这样任何一个交换失败,整批回滚,不会出现换了前两节、后三节没换的"半成品课表"。
6.2 排课并发请求下的数据一致性问题
排课员可能同时打开两个浏览器标签页,一个在排语文课、一个在排数学课,后台同时收到两个排课请求,都往schedule表里插数据。单靠唯一索引兜底,能挡住重复插入,但代价是报错那一刻,事务里的其他合法操作也全部回滚了,体验很差。
我的做法是给schedule表加上乐观锁字段version,MyBatis-Plus提供了现成的@Version注解支持:
@Version private Integer version;更新操作变成:
UPDATE schedule SET weekday = #{newWeekday}, period = #{newPeriod}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion}如果两个请求同时读到version=1,第一个请求更新成功后version变成2,第二个请求拿旧版本号去更新,影响行数为0,MyBatis-Plus会抛出OptimisticLockerException。在全局异常处理器里捕获这个异常后,向前端返回"课表数据已被其他操作修改,请刷新后再试"的提示。
自动排课这种大任务里,我额外加了一把Redis分布式锁——jedis.setIfAbsent("schedule:auto:" + semester, "locked", Duration.ofMinutes(10))。锁住之后,同一学期只能有一个排课任务在跑,防止两套课表互相覆盖。跑完任务后手动释放锁,并在finally里兜底删除锁,避免任务异常导致锁一直占着。
6.3 4000节课的自动排课跑不完怎么办
性能问题是我做压测时才发现的。自动排课引擎处理4000节课的任务,在本地开发环境跑要将近半分钟,放到线上虚拟机(2核4G)可能要超过一分钟。师生在这期间如果刷新页面,会看到接口一直pending,体验很差。
我做了三个维度的优化,叠加效果显著:
- 批量查询代替循环查询。冲突检测里原来的逻辑是每检查一个时间片就发3条SQL,4000节课 × 每个任务尝试20个时间片 = 理论最多24万次SQL查询,数据库直接被打爆。我改成提前把所有已排课记录加载到一个
Set<String>集合中,内存里判冲突,只有最终的INSERT才走数据库:
// 预加载所有已占用时间片的编码:"teacherId:weekday:period" Set<String> occupiedTeacherSlots = new HashSet<>(批量查询结果); // 判冲突:Set.contains(teacherId + ":" + weekday + ":" + period)这个优化把SQL从几十万次降到几千次,耗时从30秒降到3秒左右。 2.多线程分配时间片。排课任务之间是独立的(不同教师的课互不干扰),我用Spring的ThreadPoolTaskExecutor按任务优先级分4线程并行处理,边界条件是确保一个班级的任务不并行——因为同班级的课必须互斥分配。 3.异步执行+进度轮询。前端点"自动排课"后立即返回"任务已提交",后端用@Async跑引擎,把进度百分比写进Redis。前端每2秒轮询一次进度,显示"正在排课:已完成 1567/4000",跑完再拉取结果。体感完全不一样。
优化完压测,4000节课的自动排课稳定在4~6秒出结果,完全在可接受范围内。
6.4 部署阶段我留意到的几个细节
系统最终以单个可执行JAR包的形式部署在服务器上。除了标准的nohup java -jar scheduler.jar启动方式之外,还有几个细节值得单独提一下:
- MySQL的表结构初始化用Springboot的
schema.sql+data.sql,配置spring.sql.init.mode=always,方便在全新环境一键建库。 - 生产环境必须设置
spring.profiles.active=prod,并在prod配置里把SQL日志关掉——debug级别的日志在高峰期会产生大量IO开销,课表查询接口的RT会肉眼可见地涨。 - Redis的过期策略和内存上限要提前规划好。我把课表缓存设置为24小时过期,代码里设置
@Cacheable配合@CacheEvict——学期发布新课表的操作执行后,旧缓存必须立即清掉,不然师生看到的还是旧课表。
7. 从开发到交付:这个项目还能往哪些方向延伸
系统上线试运行了一个月,排课员给出的反馈和我自己复盘得出的结论一致——最难的不是课程如何自动排完,而是数据维护的规范和排课规则的清晰度。
举个例子,如果教师的可用时间偏好没有维护准确,自动排课引擎产出了张老师周三晚上的课,老师们自然会不满;如果教室容量数据缺失,引擎可能把60人的班级分到40人的教室。这些问题的根源都在基础数据质量,不在算法本身。所以我在系统里加了"数据完整性检查"功能——开排前先跑一遍校检,把缺教师可用时间、教室容量为空、课程没设置课时等异常全部列出来,排课员先修数据再点自动排课。
延伸方向上我也说几句实话。如果你只是想完成毕业设计,做到自动排课+冲突检测+课表展示已经绰绰有余;如果你想让它真正在真实学校环境里长期服役,还有两块值得投入:
- 基于Excel的批量导入导出。真实学校的教务系统往往有历史数据沉淀在Excel里,换个系统最怕数据搬不进来。我做了教师、班级、课程三个模块的批量导入模板,排课结果也能一键导出成标准Excel格式,下发到各年级组。
- 调课审批流程。教师提交调课申请 → 教务在线审批 → 通过后系统自动改课表并通知相关人员。这个功能加进去之后,系统的使用场景从"排课员专用"扩到了"全校教师日常使用"——虽然增加了开发量,但系统的价值和活跃度会明显提升。
做这类的系统,我一直坚持一个原则:与其堆功能,不如把主流程做到真正可用。排课引擎的核心——冲突检测的准确性和自动排课的高完成率——才是这个项目的立身之本。功能再多,排出来的课表有冲突,所有人都不会用;反之,核心流程稳定可靠,附加功能慢慢加就是了。
最后分享一个我自己的实操体会:开发这类业务逻辑复杂的系统,千万不要急着写代码。先找一位真实的排课员聊半小时,把排课流程里那些"只可意会不可言传"的规则问清楚——比如"为什么同一位老师不连排三节""为什么实训课不能排在周五下午",这些潜规则才是系统设计的灵魂。把规则翻译成代码中的约束和权重,这个系统的排课结果才算真正地被同事们接受。