SpringBoot + Elasticsearch + Redis:八年 Java 开发手把手教你做 "不崩、不卡、不冲突" 的大学排课系统
做 Java 开发八年,接过不少教育类系统的活,但大学排课系统绝对是 "看着简单,做着崩溃" 的典型 ------ 早期用纯 SpringBoot+MySQL 做,排一门课要关联教师、教室、时间三张表查冲突,慢到管理员拍桌子;选课高峰期学生查课表,数据库 CPU 直接飙到 99%,页面转圈半分钟;期末统计 "计算机学院本学期课程分布",一条 Group By 语句跑了 12 秒,领导催得急,我手心全是汗。
直到后来用SpringBoot+Elasticsearch(ES)+Redis+MySQL 重构,才彻底解决这些痛点。今天就从八年开发的视角,带大家拆解这套 "不崩、不卡、不冲突" 的排课系统,从痛点分析到代码实现,再到踩过的坑,全是实战干货,拒绝空谈理论。
一、先聊排课系统的 "痛点暴击":为什么不能只靠 SpringBoot+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 的组合,正好精准解决这些痛点:
- SpringBoot:作为基础框架,快速整合其他组件,减少 boilerplate 代码(比如用 SpringBoot Starter 整合 ES、Redis);
- MySQL:存基础数据(学生 / 教师 / 教室 / 课程的核心信息),保证数据持久化和事务性;
- Elasticsearch:负责多维度检索(比如冲突检测、复杂课表查询)和聚合统计(比如院系课程分布),比 MySQL 快 5-10 倍;
- Redis:缓存热门数据(学生个人课表、班级课表、公共选修课表),应对高并发查询,减轻 MySQL 压力。
可能有人会问:"为什么不用 Redis 做冲突检测?"------Redis 的 Sorted Set 或 Hash 适合单维度查询(比如查某教师的所有课),但冲突检测是 "教师 + 时间 + 教室" 三重维度,ES 的 bool 查询天生适合这种多条件组合检索,效率比 Redis 高得多。
二、整体架构设计:从 "排课" 到 "查课" 的全链路
markdown
用户端(学生/教师/管理员)
↓(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(课表索引)
整个链路的核心逻辑是 "数据分层处理":
- 写链路(排课 / 调课) :先通过 ES 做冲突检测→检测通过后写 MySQL 存最终结果→同步数据到 ES(供查询和统计)→删除 Redis 旧缓存(避免脏数据);
- 读链路(查课表) :先查 Redis→未命中查 ES→查后回填 Redis;
- 统计链路:直接查 ES(用聚合功能),避免查 MySQL 拖慢性能。
这种设计的好处很明显:写操作保证 "不冲突",读操作保证 "不卡顿",统计操作保证 "不耗时"。
三、核心功能实现:代码 + 八年经验技巧
接下来分模块讲核心功能的实现,每个模块都贴关键代码,并穿插我总结的避坑技巧。
3.1 基础准备:组件整合(SpringBoot+ES+Redis+MySQL)
先搞定组件依赖和配置,这是后续开发的基础。
3.1.1 引入依赖(pom.xml)
xml
<!-- 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>
3.1.2 核心配置(application.yml)
yaml
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操作日志
八年经验技巧:
- Redis 的
max-active
要根据选课高峰期的并发量调整(比如 3 万学生,按 10% 并发查课表,设 20-30 足够); - ES 的
connection-timeout
别设太短(3 秒足够),避免网络波动导致连接失败; - 开启 MyBatis-Plus 和 ES 的 debug 日志,排错时能快速定位问题(比如 SQL 写错、ES 查询语句不对)。
3.2 核心功能 1:三重冲突检测(教师 / 教室 / 时间)
这是排课系统的 "生命线"------ 一旦出现冲突,比如 "教师 A 周一 1-2 节同时在两个教室上课",后果很严重。用 ES 做冲突检测,比 MySQL 多表联查快太多。
3.2.1 ES 索引设计(课表索引)
先在 ES 中创建 "课表索引",字段要覆盖冲突检测的所有维度:
kotlin
/**
* 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;
}
索引设计技巧:
- 分片数(shards):按院系分,比如 3 个分片对应 3 个院系,查询时指定分片,减少扫描范围;
- 只有用于查询和过滤的字段才设
index = true
(比如 createTime 不用查,设index = false
),减少索引体积; - 周次、星期、节次用 Integer 类型(比如 "周一 1-2 节" 存为 weekday=1,section=1),比字符串("周一 1-2 节")查询快。
3.2.2 冲突检测实现(用 ES bool 查询)
typescript
/**
* 排课服务(核心:冲突检测)
*/
@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);
}
}
}
八年经验技巧:
- ES 查询时用
withSize(1)
:只要查到 1 条数据就说明冲突,不用查全量,效率提升 50%; - 冲突检测的
minimumShouldMatch(1)
:必须满足 "教师冲突" 或 "教室冲突" 中的一个,逻辑别写错; - 排课后同步 ES 时,用
esTemplate.save()
而不是update()
:确保 ES 数据与 MySQL 一致。
3.3 核心功能 2:高并发课表查询(学生 / 教师端)
选课高峰期,上千学生同时查课表,靠 Redis 缓存扛住压力,避免 MySQL 崩溃。
3.3.1 学生个人课表查询(Redis+ES)
java
/**
* 课表查询服务
*/
@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;
}
}
八年经验技巧:
- 缓存过期时间设 2 小时:课表不会频繁变,2 小时足够,太长会导致调课后缓存脏读;
- 查 ES 前先从 MySQL 查 "学生选的课程 ID":因为 "学生 - 课程" 关联数据量小(每个学生选 5-10 门课),MySQL 查更快,避免在 ES 中查大量数据;
- 课表排序:按 "星期→节次" 升序,符合学生看课表的习惯(比如周一 1-2 节、周一 3-4 节......)。
3.4 核心功能 3:多维度统计分析(院系 / 教师)
领导经常需要 "统计计算机学院本学期各专业课程数""统计教师 A 近 3 个月授课时长",用 ES 的聚合功能比 MySQL 的 Group By 快 10 倍以上。
3.4.1 院系课程分布统计(ES 聚合)
scss
/**
* 统计分析服务
*/
@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)导致统计不全;- 统计结果可以缓存到 Redis(比如设 1 小时过期):因为统计数据不会实时变,减少 ES 压力。
四、踩坑实录:这些坑我替你踩过了
做排课系统的八年里,踩过不少 "血坑",分享 3 个最典型的,帮你少走弯路:
4.1 坑 1:ES 索引没分片,数据量大了查询慢
问题:早期把 ES 索引设为 1 个分片,当课表数据超过 10 万条后,冲突检测查询从 50ms 涨到 500ms,排课时明显卡顿。
原因:1 个分片只能用 1 个 ES 节点的 CPU,数据量大时处理不过来。
解决方案:
- 重建索引,设 3-5 个分片(根据数据量和节点数调整,比如 3 个节点设 3 个分片);
- 按 "院系 ID" 做路由分片:查询时指定分片,比如 "计算机学院(deptId=1)" 的数据只存在分片 1,减少扫描范围。
scss
// 按院系ID路由分片(新增课表时指定)
IndexQuery indexQuery = new IndexQueryBuilder()
.withObject(esEntity)
.withRouting(String.valueOf(esEntity.getDeptId())) // 路由键=院系ID
.build();
esTemplate.index(indexQuery);
4.2 坑 2:调课后 Redis 缓存没更新,学生看到旧课表
问题:管理员给学生 A 调课后,学生 A 查课表还是旧的,需要刷新页面才显示新的,投诉 "课表不对"。
原因:调课后只更新了 MySQL 和 ES,没删除 Redis 中的旧缓存,导致缓存脏读。
解决方案:
- 调课时,主动删除对应学生 / 教师的 Redis 缓存;
- 用 Redis 的发布订阅机制:调课服务发 "调课消息",缓存服务收到消息后删除对应缓存。
scss
// 调课时删除缓存(关键代码)
public void updateTimetable(TimetableDTO dto) {
// 1. 更新MySQL
// ...(省略更新逻辑)
// 2. 更新ES
// ...(省略更新逻辑)
// 3. 删除相关缓存(学生+教师)
Long studentId = dto.getStudentId();
Long teacherId = dto.getTeacherId();
deleteRelatedCache(teacherId, studentId); // 调用之前写的删除缓存方法
}
4.3 坑 3:纯 MySQL 做统计,期末时查询超时
问题 :期末统计 "全校各院系课程总数",用 MySQL 的GROUP BY dept_id
查询,数据量 50 万条时,耗时 12 秒,领导催得急,我只能重启数据库临时缓解。
原因:MySQL 的 Group By 需要扫描全表,数据量大时性能极差。
解决方案:
- 把统计功能迁移到 ES,用聚合查询替代 MySQL 的 Group By;
- 定时(比如每天凌晨)预计算统计结果,存到 Redis,领导查的时候直接取缓存,不用实时计算。
五、总结:排课系统的 "设计原则"
做了八年排课系统,总结出 3 个核心设计原则,适用于所有教育类系统:
- 数据分层,各司其职:MySQL 存基础数据(保证持久化),ES 做检索和统计(保证快),Redis 做缓存(保证高并发),不要让一个组件干所有活;
- 冲突前置,避免补救:排课前必须做冲突检测,不要等排完课再发现冲突(事后补救成本太高,比如要通知学生 / 教师调课);
- 高可用兜底:ES 挂了降级查 MySQL,Redis 挂了降级查 ES,避免单点故障导致系统崩溃(比如用 Spring Cloud CircuitBreaker 做熔断)。
最后给同行一个建议:做排课系统不要一开始就堆复杂组件 ------ 如果学校规模小(学生 < 1 万),用 SpringBoot+MySQL 足够;等规模大了再加 ES 和 Redis,循序渐进,避免过度设计。
如果你的项目也在做排课系统,希望这篇文章能帮你少走弯路。有其他问题的话,欢迎在评论区交流 ------ 教育类系统的坑,我们一起踩,一起填!