ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Java的培训机构排课系统设计与核心算法实践

基于Java的培训机构排课系统设计与核心算法实践 1. 为什么培训机构真的需要一套Java写的排课系统1.1 从一张Excel表聊起人工排课到底痛在哪里去年我接手一个线下教练培训机构的项目业务很简单教练开课、学员约课、教室排期、月底算课时费。但就是这个看似简单的业务把教务老师逼得天天加班。她们的排课工具是什么一张巨大的Excel表横轴是时间段纵轴是教练和教室格子里面填学员名字。听起来还行对吧但真实场景远比想象中混乱。教练临时请假要改期学员想从A班换到B班教室的设备临时维修旺季一天要排几十节课……每一次变动都要人工检查是否冲突。而人手一忙漏查就出现了同一个教练被安排在同一时间上两节课一位学员的两个预约时间重叠教室被重复预定。等学员到了现场发现上不了课投诉电话就打到了校长办公室。这个项目给我的直接冲击是排课不是一个填表问题而是一个约束满足问题。约束至少有四层——教练时间、学员时间、教室占用、课程周期。四层约束叠在一起靠人脑或者Excel的条件格式去扛迟早出错。所以我当时给机构的建议很明确与其继续在Excel上打补丁不如用Java从零搭一套培训排课的一站式解决方案把约束交给程序去算让人只做决策。1.2 技术选型为什么是Java而不是Python或Node.js机构负责人一开始也问过我排课系统这么简单为什么不用Python写个脚本或者直接用现成的SaaS系统我的回答分两层。第一层业务需要的不只是排课还需要后续的学员管理、订单核销、教练课时费统计、课表推送。这个复杂度已经超过脚本能驾驭的范畴它需要一个完整的Web系统。第二层团队的技术栈和生态。虽然项目组里有人熟悉Python但机构后续还想做小程序端、做数据分析大屏Java在服务端的稳定性和人才供给上更稳妥。最终选型如下模块选型理由后端框架Spring Boot 2.7生态成熟快速开发社区资料多ORMMyBatis-Plus单表CRUD不用写SQL复杂查询手写SQL兜底数据库MySQL 8.0关系型数据天然适合排课这种强约束场景权限认证Sa-Token轻量比Shiro配置简单比Spring Security容易上手前端Vue 3 Element Plus课表视图用日历组件交互友好定时任务Quartz处理每周课程自动生成这套组合说实话没什么新花样但胜在稳。排课系统最怕的不是框架不够新而是业务约束没想清楚就急着写代码。技术选型只要贴合团队能力就是好选型。2. 核心业务模型教练、学员与时间段到底怎么建表2.1 五张核心表搭起整个排课底子排课系统的数据模型我建议从业务动作反推。业务上有五个核心对象教练、学员、课程、排课记录、预约记录。对应到数据库就是五张表。教练表和学员表比较常规重点是课程表与排课记录表。课程表表示某种课比如瑜伽基础班它定义了课程名称、单节课时长、总课时数、适合人数上限。排课记录表表示某个教练在某个时间于某个教室上某节课这是整个系统最核心的一张表。我画一下排课记录表的简化结构CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT 课程ID, coach_id BIGINT NOT NULL COMMENT 教练ID, classroom_id BIGINT NOT NULL COMMENT 教室ID, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, max_students INT NOT NULL DEFAULT 10 COMMENT 约课上限, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未开始 1进行中 2已结束 3已取消, creator BIGINT COMMENT 创建人ID, create_time DATETIME, update_time DATETIME, KEY idx_coach_time (coach_id, start_time, end_time), KEY idx_classroom_time (classroom_id, start_time, end_time), KEY idx_course_time (course_id, start_time, end_time) );建表时我特意加了三个联合索引分别覆盖教练、教室、课程在时间维度上的查询。为什么要建这三个索引因为冲突检测的核心查询就是给定某个资源教练/教室和某个时间区间查有没有重叠记录。没有索引数据量一上来这条查询会把数据库打满。2.2 时间段设计用原子时段而不是自由时间输入这块是我最想强调的经验。早期版本我让教务自己输入09:00-10:30这样的自由时间结果数据库里存的数据五花八门有的写9:00有的写09:00还有的结束时间早于开始时间。后来我统一改成时段模板设计。具体做法是建一张时间段字典表把一天切成固定的原子时段public enum TimeSlotEnum { SLOT_1(08:00, 09:30), SLOT_2(10:00, 11:30), SLOT_3(14:00, 15:30), SLOT_4(16:00, 17:30), SLOT_5(19:00, 20:30); }排课操作从输入起止时间变成选时段系统根据枚举自动算出start_time和end_time。这个设计有三个好处第一天生避免格式混乱和跨时段重叠第二课表视图非常规整前端渲染简单第三时段本身就是业务规则的载体比如午休时段不开放、晚间时段对上班族更重要。当然固定时段也有代价——特殊课程可能需要超出常规时段的安排。我的处理方式是枚举里加了一个CUSTOM类型允许管理员填写自定义时间但提交时后台仍会做严格的区间重叠校验。3. 冲突检测让排课不再撞车的核心算法3.1 区间重叠判断一个公式解决90%的问题冲突检测是整个系统最核心的逻辑本质上是一个区间重叠问题给定资源R教练或教室已有排课区间[A, B)新排课区间[C, D)如何判断两者是否冲突我见过很多新手在这里写出一长串if-else实际上一个公式就够// 两个区间不重叠的条件一个的结束时间 另一个的开始时间 // 所以重叠的条件取反 private boolean isOverlap(LocalDateTime aStart, LocalDateTime aEnd, LocalDateTime bStart, LocalDateTime bEnd) { return aStart.isBefore(bEnd) bStart.isBefore(aEnd); }这个公式的推导逻辑很简单A在B结束之前开始且B在A结束之前开始两个区间必然有交集。注意我用的是isBefore因为课程是左闭右开的——上一节课结束的瞬间下一节课可以开始不存在冲突。实际排课时我只需要查一次数据库把教练在目标日期范围内的所有已排课程捞出来然后逐一和新课程做区间比较public boolean checkConflict(ScheduleRequest request) { LocalDateTime start request.getStartTime(); LocalDateTime end request.getEndTime(); // 查询该教练在同一天的已有排课 ListSchedule coachSchedules scheduleMapper.selectByCoachAndDate( request.getCoachId(), start.toLocalDate()); for (Schedule s : coachSchedules) { if (isOverlap(start, end, s.getStartTime(), s.getEndTime())) { return true; // 有冲突 } } // 同样逻辑校验教室 ListSchedule classroomSchedules scheduleMapper.selectByClassroomAndDate( request.getClassroomId(), start.toLocalDate()); for (Schedule s : classroomSchedules) { if (isOverlap(start, end, s.getStartTime(), s.getEndTime())) { return true; } } return false; }教练和教室是两套独立的资源维度必须分别校验。因为存在这种情况教练A和教练B在同一个教室上课时间不同两者对教室维度都不冲突但如果教练A自己的两节课时间重叠那就是教练维度冲突。3.2 学员维度冲突很多人会漏掉的第三重校验教练和教室的冲突好理解学员维度的冲突最容易漏。一个学员可能在同一天预约了两个不同教练的课程时间重叠了。学员预约的校验逻辑和排课类似但是数据源不同——它查的是预约记录表。我把预约和排课设计成两张表排课是官方开的班次预约是学员选班的行为。一个学员要预约某节课系统先查学员当天已有的预约记录再做区间重叠判断。这里有一个业务取舍到底允许学员一天内在同一个场地连续上两节课吗如果允许那紧挨着的时段比如10:00结束、10:00开始不算冲突如果不允许中间要留缓冲。我的方案是做一个开关配置默认允许紧邻但中间的换场时间是否足够由教务人工把控。这个开关在配置中心里改起来不用发版。4. Spring Boot核心接口实现从请求到落库的完整链路4.1 排课接口参数校验永远比业务逻辑先跑核心排课接口我设计为POST /api/schedule/create。请求参数用一个DTO接收Data public class ScheduleCreateRequest { NotNull(message 课程ID不能为空) private Long courseId; NotNull(message 教练ID不能为空) private Long coachId; NotNull(message 教室ID不能为空) private Long classroomId; NotNull(message 开始时间不能为空) JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime startTime; NotNull(message 结束时间不能为空) JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime endTime; Min(value 1, message 约课上限至少为1) private Integer maxStudents; }控制层做参数校验Service层做业务校验这个分层习惯一定要坚持。有人觉得参数校验在Controller用Valid注解就行了Service层不需要重复校验——我的观点是Controller层校验是防外部恶意请求Service层校验是防内部调用逻辑漏传两层各管各的不能互相替代。Service层的核心方法我写成这样Transactional(rollbackFor Exception.class) public Long createSchedule(ScheduleCreateRequest request) { // 1. 校验课程是否存在且处于上架状态 Course course courseMapper.selectById(request.getCourseId()); if (course null || course.getStatus() ! CourseStatus.ON_SALE.getCode()) { throw new BizException(课程不存在或已下架); } // 2. 校验教练是否处于可排课状态 Coach coach coachMapper.selectById(request.getCoachId()); if (coach null || coach.getStatus() ! CoachStatus.ACTIVE.getCode()) { throw new BizException(教练不存在或不可排课); } // 3. 校验教练和教室时间冲突 if (scheduleService.checkConflict(request)) { throw new BizException(该时间段与已有排课冲突); } // 4. 组装排课记录并落库 Schedule schedule new Schedule(); BeanUtils.copyProperties(request, schedule); schedule.setStatus(ScheduleStatus.NOT_STARTED.getCode()); scheduleMapper.insert(schedule); return schedule.getId(); }4.2 事务边界与防重复提交排课创建方法上我加了Transactional为什么因为落库之前有多个校验步骤万一并发下两个请求同时通过了校验同时插入就可能产生冲突数据。虽然数据库层面的唯一索引能兜底但业务校验和落库之间的事务一致性必须靠事务保证。防重复提交也是一大坑。教务人员双击提交按钮、前端网络重试都可能导致同一排课记录被创建两次。我的解决方案是两张牌一起打第一张牌数据库层面对(coach_id, start_time, end_time)建唯一索引从根上防止同一教练同一时间被插入两条记录第二张牌Redis分布式锁以schedule:create:{coachId}:{startTime}为key加锁锁过期时间10秒防止同一秒内重复提交。public Long createScheduleWithLock(ScheduleCreateRequest request) { String lockKey String.format(schedule:create:%d:%s, request.getCoachId(), request.getStartTime()); boolean locked redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException(操作太频繁请稍后重试); } try { return createSchedule(request); } finally { redisLock.unlock(lockKey); } }这里有一个细节Redis锁必须在事务提交之后再释放否则会出现锁释放了但事务还没提交另一个请求进来查询时读不到数据又创建了一条。所以我用AOP方式处理锁的释放时机放在事务方法返回之后而不是在方法内部finally里直接解锁。5. 批量排课与调课自动化真正省时间的重头戏5.1 场景驱动设计每周固定课表的批量生成系统上线初期教务告诉我一个新需求某某课程每周一、三、五晚上7点上课要连续排8周能不能一次搞定如果一张张手动排8周就是24次操作太累了。于是我做了批量排课功能。输入参数包括课程ID、教练ID、教室ID、开始日期、结束日期、星期集合、时段ID。系统按规则生成所有排课日期对每个日期逐条做冲突检测然后把成功的排课一次性批量插入。批量插入我放弃了循环单条insert改用MyBatis的batch insert一次提交一整批数据耗时从秒级降到毫秒级。数据量大时性能差距非常明显。public BatchCreateResult batchCreate(BatchScheduleRequest request) { ListLocalDate dates generateDates( request.getStartDate(), request.getEndDate(), request.getWeekdays() ); ListSchedule scheduleList new ArrayList(); for (LocalDate date : dates) { LocalDateTime startTime LocalDateTime.of(date, request.getStartTime()); LocalDateTime endTime LocalDateTime.of(date, request.getEndTime()); // 校验每个时间点是否冲突 if (checkConflict(request.getCoachId(), request.getClassroomId(), startTime, endTime)) { continue; // 冲突的跳过不中断整个批量任务 } Schedule schedule new Schedule(); schedule.setCourseId(request.getCourseId()); // ... 省略属性设置 scheduleList.add(schedule); } // 批量插入 if (scheduleList.size() 0) { scheduleMapper.batchInsert(scheduleList); } // 返回结果成功多少条、跳过多少条 BatchCreateResult result new BatchCreateResult(); result.setSuccessCount(scheduleList.size()); result.setSkippedCount(dates.size() - scheduleList.size()); return result; }批量排课的返回结果很重要。我特意设计了批次结果对象告诉教务哪些日期冲突被跳过了。有一次教务批量排课发现周一、周三全跳过只剩周五排上了。一查原因是教练周一、周三已经排了另一门课。如果没有跳过提示教务会以为是系统吞数据。冲突的跳过策略是有意为之——批量任务不能因为某一天冲突就整体回滚但冲突明细必须暴露给用户。5.2 调课与消息通知改期不是改一个时间那么简单排课系统的另一个高频操作是调课。比如教练周六临时有事要把周六的课改到周日。这里有个隐藏问题周六这节课可能已经有学员预约了。如果只改时间不通知学员周日就会有一堆学员缺席或者学员到了现场才发现课没了。我设计的调课流程分三步校验教练和教室在新时间段是否有冲突更新排课记录的时间查询该排课下所有有效预约记录给学员发送调课通知。第三步是异步的用Spring的Async加上消息队列解耦。简单场景下我直接用线程池加CountDownLatch等待所有通知发送完成保证接口返回时通知已经发出去而不是稍后发送这种业务上不可控的状态。public void reschedule(Long scheduleId, LocalDateTime newStart, LocalDateTime newEnd) { // 1. 查询原排课 Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule null || schedule.getStatus() ! ScheduleStatus.NOT_STARTED.getCode()) { throw new BizException(该排课不存在或已结束); } // 2. 构建新时间段并校验冲突 ScheduleCreateRequest tempRequest new ScheduleCreateRequest(); tempRequest.setCoachId(schedule.getCoachId()); tempRequest.setClassroomId(schedule.getClassroomId()); tempRequest.setStartTime(newStart); tempRequest.setEndTime(newEnd); if (checkConflict(tempRequest)) { throw new BizException(新时间段与已有排课冲突); } // 3. 更新排课时间 schedule.setStartTime(newStart); schedule.setEndTime(newEnd); scheduleMapper.updateById(schedule); // 4. 异步通知已预约学员 notifyStudentsAsync(schedule); }这里还有一个业务判断值得说调课的时候如果旧时间段的学员已经满员而新时间段原本已经有一节满员的课那这两个班的学员合在一起就超出教室容量了。所以调课不仅要查教练和教室的时间冲突还要判断新时间是否会影响原班级的容量。我把它做成可配置项默认允许超员一定比例比如10%超出部分人工确认。6. 部署上线后的稳定性优化与避坑记录6.1 线上环境排查实录一次隐秘的索引失效系统上线一个月后运营反馈某天的课表打开很慢接口响应从平均300ms膨胀到3秒以上。我首先查看慢查询日志发现一条SQL走了全表扫描SELECT * FROM schedule WHERE coach_id ? AND start_time ? AND start_time ? ORDER BY start_time;奇怪明明建了联合索引idx_coach_time (coach_id, start_time, end_time)。我拿真实参数去EXPLAIN发现possible_keys显示有索引但key是空的。问题出在coach_id字段的类型上——表设计时coach_id是BIGINT但实体类里为了兼容老系统写成了Long这没问题可MyBatis的XML里不小心把参数传成了String类型MySQL在比较BIGINT和VARCHAR时会做隐式类型转换索引就失效了。排查过程花了一个多小时最终修复就是一行确保传入的参数类型与数据库字段类型完全一致。很多这类问题不是SQL写错而是类型不匹配导致的索引失效。这个案例我写进了团队的代码规范里所有关联字段和查询条件的Java类型必须与数据库字段严格对应禁止用字符串拼接SQL。6.2 并发预约场景的乐观锁与超卖问题排课系统上线后另一个大问题是学员抢课。一个热门教练的热门时段放出来100个名额瞬间被抢光。一开始我用UPDATE schedule SET booked_count booked_count 1 WHERE id ? AND booked_count max_students这样的原子SQL来控制报名人数这能防止超卖但带来的副作用是同时抢同一个排课的多个学员只有一个能成功其他都拿到名额已满的报错。产品经理不满意的点在于学员看到的排队体验太差了。于是我把秒杀式的强锁改成了预约申请确认机制。学员提交预约申请后状态是待确认系统异步检查实时名额如果有空位就自动确认没空位就进入候补队列。这个改动让业务上可以灵活处理有人取消后候补顶上的需求也让并发压力小了很多。具体实现上预约表增加了一个version字段用于乐观锁控制// Mapper层 Update(UPDATE booking SET status 1, version version 1 WHERE id #{id} AND version #{version} AND status 0) int confirmBooking(Param(id) Long id, Param(version) Integer version);如果confirmBooking返回0说明version不匹配代表这条预约已经被其他线程处理了当前线程只需要重新查询最新状态再决定下一步。乐观锁比较适合这种读多写少、冲突概率相对可控的场景。6.3 MyBatis动态SQL里最容易踩的空指针最后一个坑是MyBatis的if标签判断空值。我早期写过一个条件查询传入一个包含多个状态的列表当列表为空时期望的是不按状态筛选但因为没加foreach的空集合判断生成的SQL变成了WHERE status IN ()直接报语法错误。我的经验是凡是涉及集合参数的动态SQL一定要先在Service层处理空集合要么直接返回空结果要么给集合赋一个不可能的默认值。不要指望MyBatis的if标签能聪明到自动处理所有边界情况。踩过这个坑后我给团队定了一条规矩复杂动态SQL必须配套单元测试至少覆盖空集合、单元素集合、正常多元素集合三种情况。6.4 数据归档与统计报表的延时问题系统运行大半年后排课数据和预约数据到了几百万条。月底统计教练课时费时聚合查询开始变慢。我做了两个优化第一冷热数据分离。把三个月之前的已结课排课记录迁移到归档表schedule_history业务查询默认只查热表。迁移任务用Quartz每天凌晨执行一次迁移前校验归档表和热表的数据完整性。第二报表查询改造。课时费统计的核心是每个教练在一个时间段内实际上课多少节我建了一张日汇总表coach_daily_summary每天定时任务把前一天每个教练的实际上课数、预约数、取消数聚合好报表直接查汇总表而不是每次现算。查询时间从分钟级降到毫秒级。这套方案的数据一致性靠定时任务的重跑机制兜底某天任务失败或数据异常时修复数据后手动触发重跑那一天汇总表会先删除再重建当天的数据保证最终一致。7. 这套方案的可复制经验给同行的一些实在建议项目交付后我复盘了整个开发过程有几条经验值得分享。第一排课系统的复杂度不在功能多少而在约束是否梳理清楚。动手写代码之前一定先把所有业务约束列出来写进需求文档。包括教练一周最多排多少节课、教室是否有设备要求、学员能否跨店预约、同一天最多预约几节。每一条约束都是代码里的一个校验分支前期少列一条后期就要多改一堆代码。第二时间段枚举化是我觉得这套系统里性价比最高的设计。它看起来只是把时间输入变成了下拉选择但实际上消灭了一整类数据质量问题也简化了前端课表渲染。如果业务灵活到必须支持任意时间也建议在枚举基础上扩展而不是放弃约束。第三批量操作必须返回明细。无论是批量排课、批量调课还是批量取消接口都应该告诉调用方成功了多少、失败了多少、每条失败的原因是什么。这在运营场景的价值极高能避免很多系统没反应的困惑。第四在做预约并发控制的时候优先考虑乐观锁而不是悲观锁。排课系统的并发峰值虽然可能短期很高但单条记录上的争抢通常是可控的乐观锁简单、性能好出问题也好排查。第五如果团队没有专职DBA建议对每一条涉及到联合索引的查询都养成EXPLAIN的习惯。线上的索引失效问题90%是因为参数类型不匹配和函数包裹字段造成的这两类问题通过EXPLAIN基本都能发现。这个项目前后开发周期约两个月真正写业务代码的时间大概四周其余时间都在跟教务老师确认需求细节、处理历史数据、调课表页面的交互。系统上线后教务排课时间从每天几个小时降到十几分钟教练和学员的冲突投诉几乎归零。我给机构的最终建议是后续可以在这个基础上加一个学员考勤自动核销和教练教学质量评分把排课系统升级成完整的教务运营平台。
返回列表