做 Java 开发八年,接过不少教育类系统的活,但大学排课系统绝对是 “看着简单,做着崩溃” 的典型 —— 早期用纯 SpringBoot+MySQL 做,排一门课要关联教师、教室、时间三张表查冲突,慢到管理员拍桌子;选课高峰期学生查课表,数据库 CPU 直接飙到 99%,页面转圈半分钟;期末统计 “计算机学院本学期课程分布”,一条 Group By 语句跑了 12 秒,领导催得急,我手心全是汗。
直到后来用 SpringBoot+Elasticsearch(ES)+Redis+MySQL 重构,才彻底解决这些痛点。今天就从八年开发的视角,带大家拆解这套 “不崩、不卡、不冲突” 的排课系统,从痛点分析到代码实现,再到踩过的坑,全是实战干货,拒绝空谈理论。
很多新手会觉得 “排课不就是 CRUD + 时间匹配吗?用 SpringBoot+MySQL 足够了”—— 这话在学生数少于 1 万的小学校可能成立,但一旦到中大型高校(3 万 + 学生、上千教师、几百间教室),分分钟给你上一课。
先列几个我踩过的 “致命痛点”,看看纯 MySQL 方案为什么扛不住:
| 痛点 | 纯 MySQL 方案的坑 | 后果 |
|---|---|---|
| 三重冲突检测(教师 / 教室 / 时间) | 需关联
teacher_course(教师课表)、
classroom_course(教室课表)、
time_slot(时间段)三张表,用多表 JOIN+WHERE 判断,数据量大时查询耗时超 3 秒 |
管理员排课要等半天,还容易漏判冲突(比如跨院系的教师重复排课) |
| 选课高峰期查课表 | 学生查个人课表 / 班级课表,全靠 MySQL 单表查或关联查,1 万学生同时查,数据库连接池直接满了 | 页面转圈、超时,学生投诉量暴涨,教务处电话被打爆 |
| 多维度统计分析 | 比如 “统计某院系本学期各专业课程数”“某教师近 3 个月授课时长”,需用复杂 Group By + 子查询,数据量超 10 万条时,查询耗时超 10 秒 | 领导要数据时拿不出来,影响教学决策 |
| 动态调课同步 | 临时调课后,学生端课表要实时更新,纯 MySQL 需每次查最新数据,没缓存的话,旧课表会残留 | 学生按旧课表去上课,发现教室被占,场面混乱 |
而 SpringBoot+ES+Redis+MySQL 的组合,正好精准解决这些痛点:
可能有人会问:“为什么不用 Redis 做冲突检测?”——Redis 的 Sorted Set 或 Hash 适合单维度查询(比如查某教师的所有课),但冲突检测是 “教师 + 时间 + 教室” 三重维度,ES 的 bool 查询天生适合这种多条件组合检索,效率比 Redis 高得多。
用户端(学生/教师/管理员)
↓(1. 操作请求)
SpringBoot应用(排课系统核心)
↓(2. 身份校验)
Spring Security + JWT(权限控制)
↓(3. 校验通过)
SpringBoot应用(排课系统核心)
↓(4. 排课/调课)
业务模块{
冲突检测模块
课表查询模块
统计分析模块
调课同步模块
}
├─ 冲突检测模块 →(5. 多维度冲突检索)→ Elasticsearch(课表索引)
│ →(6. 最终排课结果存储)→ MySQL(基础数据表)
│ →(7. 同步排课数据到ES)→ Elasticsearch(课表索引)
├─ 课表查询模块 →(8. 优先查缓存)→ Redis(课表缓存)
│ →(9. 缓存未命中)→ Elasticsearch(课表索引)
│ →(10. 查后回填缓存)→ Redis(课表缓存)
├─ 统计分析模块 →(11. 多维度统计)→ Elasticsearch(课表索引)
│ →(12. 统计结果返回)→ 用户端
└─ 调课同步模块 →(13. 调课更新)→ MySQL(基础数据表)
→(14. 删除旧缓存)→ Redis(课表缓存)
→(15. 同步更新ES)→ Elasticsearch(课表索引)
整个链路的核心逻辑是 “ 数据分层处理”:
这种设计的好处很明显:写操作保证 “不冲突”,读操作保证 “不卡顿”,统计操作保证 “不耗时”。
接下来分模块讲核心功能的实现,每个模块都贴关键代码,并穿插我总结的避坑技巧。
先搞定组件依赖和配置,这是后续开发的基础。
<!-- SpringBoot核心 -->
<
dependency>
<
groupId>org.springframework.boot
</
groupId>
<
artifactId>spring-boot-starter-web
</
artifactId>
</
dependency>
<!-- MySQL + MyBatis-Plus(简化CRUD) -->
<
dependency>
<
groupId>com.baomidou
</
groupId>
<
artifactId>mybatis-plus-boot-starter
</
artifactId>
<
version>3.5.3.1
</
version>
</
dependency>
<
dependency>
<
groupId>mysql
</
groupId>
<
artifactId>mysql-connector-java
</
artifactId>
<
version>8.0.33
</
version>
</
dependency>
<!-- Elasticsearch(SpringBoot Starter) -->
<
dependency>
<
groupId>org.springframework.boot
</
groupId>
<
artifactId>spring-boot-starter-data-elasticsearch
</
artifactId>
</
dependency>
<!-- Redis -->
<
dependency>
<
groupId>org.springframework.boot
</
groupId>
<
artifactId>spring-boot-starter-data-redis
</
artifactId>
</
dependency>
<!-- 工具类(JSON/日期等) -->
<
dependency>
<
groupId>com.alibaba
</
groupId>
<
artifactId>fastjson2
</
artifactId>
<
version>2.0.32
</
version>
</
dependency>
<
dependency>
<
groupId>org.projectlombok
</
groupId>
<
artifactId>lombok
</
artifactId>
<
optional>true
</
optional>
</
dependency>
spring:
# 数据源配置(MySQL)
datasource:
url:
jdbc:mysql://localhost:3306/college_timetable?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
username:
root
password:
123456
driver-class-name:
com.mysql.cj.jdbc.Driver
# Redis配置
redis:
host:
localhost
port:
6379
password:
database:
0
timeout:
5000ms
lettuce:
pool:
max-active:
20
# 最大连接数(选课高峰期调大)
max-idle:
10
min-idle:
5
# Elasticsearch配置
elasticsearch:
uris:
http://localhost:9200
username:
elastic
password:
123456
rest:
connection-timeout:
3000ms
# MyBatis-Plus配置
mybatis-plus:
mapper-locations:
classpath:mapper/**/*.xml
type-aliases-package:
com.college.timetable.entity
configuration:
map-underscore-to-camel-case:
true
# 下划线转驼峰
# 日志配置(方便调试ES和MySQL)
logging:
level:
com.college.timetable.mapper:
debug
# MySQL SQL日志
org.springframework.data.elasticsearch:
debug
# ES操作日志
八年经验技巧:
max-active要根据选课高峰期的并发量调整(比如 3 万学生,按 10% 并发查课表,设 20-30 足够);connection-timeout别设太短(3 秒足够),避免网络波动导致连接失败;这是排课系统的 “生命线”—— 一旦出现冲突,比如 “教师 A 周一 1-2 节同时在两个教室上课”,后果很严重。用 ES 做冲突检测,比 MySQL 多表联查快太多。
先在 ES 中创建 “课表索引”,字段要覆盖冲突检测的所有维度:
/**
* ES课表索引实体(对应MySQL的timetable表)
*/
@Data
@Document(indexName =
"college_timetable", shards = 3, replicas = 1)
// 3分片1副本(根据数据量调整)
public
class
TimetableEsEntity {
@Id
// ES文档ID(与MySQL的timetable_id一致)
private
Long timetableId;
@Field(type = FieldType.Long, index = true)
// 教师ID(索引,用于冲突检测)
private
Long teacherId;
@Field(type = FieldType.Long, index = true)
// 教室ID(索引,用于冲突检测)
private
Long classroomId;
@Field(type = FieldType.Integer, index = true)
// 周次(比如第1-16周,索引)
private Integer week;
@Field(type = FieldType.Integer, index = true)
// 星期(1=周一,7=周日,索引)
private Integer weekday;
@Field(type = FieldType.Integer, index = true)
// 节次(1=1-2节,2=3-4节,索引)
private Integer section;
@Field(type = FieldType.Long, index = true)
// 课程ID(索引,用于查课表)
private
Long courseId;
@Field(type = FieldType.Long, index = true)
// 院系ID(索引,用于统计)
private
Long deptId;
@Field(type = FieldType.Date, format = DateFormat.basic_date_time)
// 创建时间(非索引)
private Date createTime;
}
索引设计技巧:
index = true(比如 createTime 不用查,设
index = false),减少索引体积;
/**
* 排课服务(核心:冲突检测)
*/
@Service
public
class
TimetableService {
@Autowired
private
ElasticsearchRestTemplate esTemplate;
// ES操作模板
@Autowired
private
TimetableMapper timetableMapper;
// MySQL Mapper
@Autowired
private
RedisTemplate<
String,
Object> redisTemplate;
// Redis操作
// Redis缓存Key前缀(学生个人课表)
private
static final
String
STUDENT_TIMETABLE_KEY =
"timetable:student:%s";
/**
* 排课前检测冲突
*
@param dto 排课DTO(含教师ID、教室ID、周次、星期、节次)
*
@return true=无冲突,false=有冲突
*/
public
boolean
checkConflict(
TimetableDTO dto) {
// 构建ES bool查询:同时匹配“教师+时间”或“教室+时间”→有匹配则冲突
NativeSearchQuery searchQuery =
new
NativeSearchQueryBuilder()
.
withQuery(
QueryBuilders.
boolQuery()
// 时间条件(周次、星期、节次必须完全匹配)
.
must(
QueryBuilders.
termQuery(
"week", dto.
getWeek()))
.
must(
QueryBuilders.
termQuery(
"weekday", dto.
getWeekday()))
.
must(
QueryBuilders.
termQuery(
"section", dto.
getSection()))
// 冲突条件:要么教师冲突,要么教室冲突
.
should(
QueryBuilders.
termQuery(
"teacherId", dto.
getTeacherId()))
.
should(
QueryBuilders.
termQuery(
"classroomId", dto.
getClassroomId()))
.
minimumShouldMatch(
1)
// 至少满足一个should条件
)
.
withSize(
1)
// 只需要判断是否有数据,不用查全量
.
build();
// 执行ES查询
SearchHits<
TimetableEsEntity> searchHits = esTemplate.
search(
searchQuery,
TimetableEsEntity.
class
);
// 有命中结果→冲突;无命中→无冲突
return searchHits.
getTotalHits() ==
0;
}
/**
* 排课(冲突检测通过后执行)
*/
public
void
addTimetable(
TimetableDTO dto) {
// 1. 先检测冲突
boolean noConflict =
checkConflict(dto);
if (!noConflict) {
throw
new
BusinessException(
"排课冲突!请检查教师、教室或时间");
}
// 2. 保存到MySQL(用MyBatis-Plus)
TimetableEntity entity =
new
TimetableEntity();
BeanUtils.
copyProperties(dto, entity);
entity.
setCreateTime(
new
Date());
timetableMapper.
insert(entity);
// 3. 同步数据到ES(供后续查询和统计)
TimetableEsEntity esEntity =
new
TimetableEsEntity();
BeanUtils.
copyProperties(entity, esEntity);
esTemplate.
save(esEntity);
// 4. (可选)删除相关缓存(比如教师课表缓存,避免旧数据)
deleteRelatedCache(dto.
getTeacherId(),
null);
log.
info(
"排课成功!课表ID:{}", entity.
getTimetableId());
}
/**
* 删除相关缓存(调课/删课时用)
*/
private
void
deleteRelatedCache(
Long teacherId, Long studentId) {
if (teacherId !=
null) {
String teacherKey =
"timetable:teacher:" + teacherId;
redisTemplate.
delete(teacherKey);
}
if (studentId !=
null) {
String studentKey =
STUDENT_TIMETABLE_KEY + studentId;
redisTemplate.
delete(studentKey);
}
}
}
八年经验技巧:
withSize(1):只要查到 1 条数据就说明冲突,不用查全量,效率提升 50%;minimumShouldMatch(1):必须满足 “教师冲突” 或 “教室冲突” 中的一个,逻辑别写错;esTemplate.save()而不是
update():确保 ES 数据与 MySQL 一致。选课高峰期,上千学生同时查课表,靠 Redis 缓存扛住压力,避免 MySQL 崩溃。
/**
* 课表查询服务
*/
@Service
public
class
TimetableQueryService {
@Autowired
private ElasticsearchRestTemplate esTemplate;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private StudentCourseMapper studentCourseMapper;
// 学生-课程关联表(查学生选的课程)
// Redis缓存Key:学生个人课表(过期时间2小时,课表不会频繁变)
private
static
final
String
STUDENT_TIMETABLE_KEY
=
"timetable:student:%s";
private
static
final
long
CACHE_EXPIRE
=
2 *
60 *
60;
// 2小时
/**
* 学生查询个人课表
*
@param studentId 学生ID
*
@param week 当前周次
*
@return 个人课表列表
*/
public List<TimetableVO>
getStudentTimetable
(Long studentId, Integer week) {
String
cacheKey
= STUDENT_TIMETABLE_KEY + studentId;
// 1. 先查Redis缓存
Object
cacheObj
= redisTemplate.opsForValue().get(cacheKey);
if (cacheObj !=
null) {
log.info(
"学生{}查课表,命中Redis缓存", studentId);
return JSON.parseObject(cacheObj.toString(),
new
TypeReference<List<TimetableVO>>() {});
}
// 2. 缓存未命中,查ES(先查学生选的课程ID,再查课表)
// 2.1 查学生选的课程ID(从MySQL查,数据量小)
List<Long> courseIds = studentCourseMapper.selectCourseIdByStudentId(studentId);
if (CollectionUtils.isEmpty(courseIds)) {
return Collections.emptyList();
}
// 2.2 查ES:匹配课程ID+周次
NativeSearchQuery
searchQuery
=
new
NativeSearchQueryBuilder()
.withQuery(QueryBuilders.boolQuery()
.must(QueryBuilders.termsQuery(
"courseId", courseIds))
// 学生选的课程
.must(QueryBuilders.termQuery(
"week", week))
// 当前周次
)
.withSort(SortBuilders.fieldSort(
"weekday").order(SortOrder.ASC))
// 按星期升序
.withSort(SortBuilders.fieldSort(
"section").order(SortOrder.ASC))
// 按节次升序
.build();
SearchHits<TimetableEsEntity> searchHits = esTemplate.search(
searchQuery, TimetableEsEntity.class
);
// 3. 转换为VO(给前端返回)
List<TimetableVO> result = searchHits.stream()
.map(hit -> {
TimetableEsEntity
esEntity
= hit.getContent();
TimetableVO
vo
=
new
TimetableVO();
BeanUtils.copyProperties(esEntity, vo);
// 补充课程名称、教师名称(从缓存或MySQL查,这里省略)
return vo;
})
.collect(Collectors.toList());
// 4. 回填Redis缓存
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), CACHE_EXPIRE, TimeUnit.SECONDS);
log.info(
"学生{}查课表,缓存未命中,查ES后回填缓存", studentId);
return result;
}
}
八年经验技巧:
领导经常需要 “统计计算机学院本学期各专业课程数”“统计教师 A 近 3 个月授课时长”,用 ES 的聚合功能比 MySQL 的 Group By 快 10 倍以上。
/**
* 统计分析服务
*/
@Service
public class TimetableStatService {
@Autowired
private ElasticsearchRestTemplate esTemplate;
/**
* 统计某院系本学期各专业的课程数量
* @param deptId 院系ID
* @param semester 学期(比如2024-2025-1)
* @return 统计结果(专业ID→课程数)
*/
public Map<Long, Integer>
statCourseByMajor(Long deptId, String semester) {
// 1. 构建ES聚合查询:按“专业ID”分组,统计每组的课程数
NativeSearchQuery searchQuery = new
NativeSearchQueryBuilder()
.withQuery(QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery("deptId", deptId))
// 指定院系
.must(QueryBuilders.termQuery("semester", semester))
// 指定学期
)
// 聚合:按majorId分组(专业ID),统计count
.addAggregation(AggregationBuilders.terms("major_course_count")
.field("majorId")
// 按专业ID分组
.size(
100)
// 最多返回100个专业(足够)
)
.withSize(
0)
// 只需要聚合结果,不需要原始数据
.build();
// 2. 执行查询
SearchHits<TimetableEsEntity> searchHits = esTemplate
.search(
searchQuery, TimetableEsEntity.class
);
// 3. 解析聚合结果
Aggregations aggregations = searchHits
.getAggregations();
Terms termsAgg = aggregations
.get("major_course_count");
Map<Long, Integer> result = new HashMap<>();
for (Terms.Bucket bucket : termsAgg.getBuckets()) {
Long majorId = Long
.parseLong(bucket.getKeyAsString());
// 专业ID
long count = bucket
.getDocCount();
// 课程数
result
.put(majorId, (int) count);
}
return result;
}
}
八年经验技巧:
withSize(0):不需要返回原始课表数据,只需要聚合结果,减少数据传输量;terms聚合的
size设足够大(比如 100):避免专业数量超过默认 size(10)导致统计不全;做排课系统的八年里,踩过不少 “血坑”,分享 3 个最典型的,帮你少走弯路:
问题:早期把 ES 索引设为 1 个分片,当课表数据超过 10 万条后,冲突检测查询从 50ms 涨到 500ms,排课时明显卡顿。
原因:1 个分片只能用 1 个 ES 节点的 CPU,数据量大时处理不过来。
解决方案:
// 按院系ID路由分片(新增课表时指定)
IndexQuery indexQuery = new
IndexQueryBuilder()
.withObject(esEntity)
.withRouting(String.valueOf(esEntity.getDeptId()))
// 路由键=院系ID
.build();
esTemplate
.index(indexQuery);
问题:管理员给学生 A 调课后,学生 A 查课表还是旧的,需要刷新页面才显示新的,投诉 “课表不对”。
原因:调课后只更新了 MySQL 和 ES,没删除 Redis 中的旧缓存,导致缓存脏读。
解决方案:
// 调课时删除缓存(关键代码)
public void
updateTimetable(TimetableDTO dto) {
// 1. 更新MySQL
// ...(省略更新逻辑)
// 2. 更新ES
// ...(省略更新逻辑)
// 3. 删除相关缓存(学生+教师)
Long studentId = dto
.getStudentId();
Long teacherId = dto
.getTeacherId();
deleteRelatedCache(teacherId, studentId);
// 调用之前写的删除缓存方法
}
问题:期末统计 “全校各院系课程总数”,用 MySQL 的
GROUP BY dept_id查询,数据量 50 万条时,耗时 12 秒,领导催得急,我只能重启数据库临时缓解。
原因:MySQL 的 Group By 需要扫描全表,数据量大时性能极差。
解决方案:
做了八年排课系统,总结出 3 个核心设计原则,适用于所有教育类系统:
最后给同行一个建议:做排课系统不要一开始就堆复杂组件 —— 如果学校规模小(学生 < 1 万),用 SpringBoot+MySQL 足够;等规模大了再加 ES 和 Redis,循序渐进,避免过度设计。
如果你的项目也在做排课系统,希望这篇文章能帮你少走弯路。有其他问题的话,欢迎在评论区交流 —— 教育类系统的坑,我们一起踩,一起填!
<"<image="zq.c5zb.cn"><image="sjb.c5zb.cn"><image="nba.c5zb.cn"><image="cba.c5zb.cn"><image="lq.c5zb.cn">">
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70048944/viewspace-3114764/ ,如需转载,请注明出处,否则将追究法律责任。