Java+Spring Boot教练培训排课系统:从表设计到并发冲突实战
2026/9/9 23:04:55 网站建设 项目流程

开场先聊个实际场景。我自己搞过几年驾校和健身机构的软件项目,排课系统这类东西,乍一看不就是“把教练的时间和学员预约对上”嘛,真做起来一堆细节。时间冲突、教练排班、临时调课、请假、场地占用、高峰期并发,随便拎一个出来都能把你折腾够呛。这也是为什么凡是带“排课”两个字的需求,报价从来都不低。

这篇博文就围绕一套基于Java技术栈的教练培训排课系统源码来拆,讲清楚核心表结构怎么设计、排课流程怎么走、冲突检测怎么实现、并发场景怎么防超约,再带上实际调试过程中容易踩的坑。适合刚接触业务系统的Java开发、准备做驾校/健身/培训类项目的团队,以及那些想看明白“排课系统到底在排什么”的产品和测试同学。

1. 排课系统的业务核心:排的到底是什么

1.1 先理清教练培训场景下的三个核心实体

排课系统听起来复杂,业务模型抽出来就是三个核心实体:教练、学员、课程(或者说时段)。这三者之间不是简单的多对多关系,每一个“排课动作”实际上是在建立一条“教练在某段时间内带某个学员上课”的强约束记录。

先说教练。教练有归属校区、有授课科目(比如驾校里的科目二、科目三,健身房的私教课、团课),还有自己的工作时间段和可预约时段。这些信息不是写死的,每周可能都在变。所以表设计里教练信息、教练可预约时段、教练休假记录,通常要拆成多张表,而不是在教练表里加一个“工作时间”字段就完了。

再说学员。学员有报名的课程包、剩余课时、有效期、当前学习进度。这决定了他在某一天能不能约课、能约哪种课、能约多长时间。如果一个学员剩3节课了,系统还允许他预约10节,那就是严重的业务漏洞。

最后是课时时段。这是整个系统的核心资源。每个时段包含开始时间、结束时间、所属课程、教练、学员、状态。注意,一个时段一定不能出现两个学员同时占用同一个教练的情况,这是刚性约束。基于这个核心约束,才能往上叠加场地、车辆、教室等资源。

我见过不少初学开发的同学,一上来就画一张大表——“排课表”,把所有信息塞进去,结果后面做冲突检测、做统计报表的时候发现怎么都绕不过去,最后只能推倒重来。正确的思路是先拆实体、再定关系、最后才谈流程。

1.2 排课系统里最常见的两类冲突场景

排课系统里“冲突”这个词指的是资源被重复占用。最常见的有两类:时间冲突和人车/场地冲突。

时间冲突很好理解。同一个教练,10:00-10:45已经排了学员A的课,现在又要排学员B的课,时间一旦重叠,直接冲突。同理,学员A在同一时间段内预约了两门课,也是时间冲突。代码落地的时候,本质就是SQL查重叠区间:start_time < 新结束时间 AND end_time > 新开始时间。这个公式看着简单,实际排查问题的时候最容易出错,因为很多人写成了“包含了等于”,导致正好卡在边界上的预约被误判。

人车/场地冲突稍微复杂一点。比如某驾校有10台教练车,科目二的训练必须用指定车辆,那排课时除了查教练是否空闲,还要查车辆在那个时间段是否被占用。这里有一个常见的坑:如果你在代码里把“查教练空闲”和“查车辆空闲”分成两条SQL来执行,在高并发预约的瞬间,两条SQL各自返回“空闲”,但组合起来就撞车了。这就是典型的并发问题,后面会专门讲怎么用事务和锁来解决。

1.3 为什么要用Java + Spring Boot这套技术栈

排课系统用Java写,在实际交付和面试场景里都有很现实的理由。一是Java生态对复杂业务系统的支撑足够成熟,从MyBatis/MyBatis Plus的数据访问,到Spring的声明式事务,到Redis做分布式锁,每层都有现成方案,开发效率有保障;二是这类系统通常要接微信小程序、App、后台管理端多个入口,Java在后端API这块的稳定性和社区方案积累是经过大量生产环境验证的;三是排课系统本身就是Java面试里的高频项目案例,不管是数据库索引、事务隔离、并发控制还是缓存穿透,都能在这套系统里找到实际的落脚点。

技术栈选型上,我给的是一套最稳妥的组合:

  • JDK 1.8+,Spring Boot 2.7.x,这是目前存量项目里最普及的版本组合
  • MyBatis Plus 做数据访问,代码生成能力强,联表查询也很顺手
  • MySQL 8.0 做业务存储,配合InnoDB事务
  • Redis 做缓存和分布式锁
  • Vue + Element UI 做后台管理界面,便于运营人员手动调整排课结果

这套组合并不追求新奇,但胜在稳定、资料多、招人好招。真要上生产环境,这套方案是完全撑得住的。

2. 数据库表设计:一张一张拆给你看

2.1 教练表、学员表的结构细节

先看教练表。我见过很多人的教练表写着写着就膨胀起来,恨不得把教练的身份证号、驾驶证档案编号、带教通过率全部塞进去。我的建议是:教练表只放稳定的基础属性,变化频繁的业务属性全部单独建表

具体来说,教练表coach的核心字段包括:

CREATE TABLE `coach` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `coach_no` varchar(32) NOT NULL COMMENT '教练编号', `name` varchar(64) NOT NULL COMMENT '教练姓名', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1正常 0停用', `hire_date` date DEFAULT NULL COMMENT '入职日期', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_coach_no` (`coach_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教练信息表';

这里有两个容易忽略的点。第一,coach_no一定要建唯一索引。教练编号在业务层是给学员查看、给教务统计用的,一旦重复,后面所有关联查询都会出问题。第二,status字段建议用tinyint,不要用varchar存“正常”“停用”。原因无他,用数字做状态判断写起来干净,而且加新状态时不用改表结构。

学员表student的结构类似,但要多加两个关键字段:

`remain_lesson_count` int(11) NOT NULL DEFAULT '0' COMMENT '剩余课时数', `lesson_expire_date` date DEFAULT NULL COMMENT '课时有效期'

这些字段的存在,意味着每次成功预约课件后都要对剩余课时做扣减,并且每次提交预约时要校验有效期。如果不做校验,会出现学员课程包已经过期还在约课的尴尬局面。

2.2 课程表与排课表:一对多关系怎么建模

课程表course相对简单,就是定义“科目二”“科目三”“私教体验课”这类基础课程信息。但真正核心的排课表,也就是schedule,设计上要投入更多心思。

下面是我在项目里实际用过的核心结构(精简掉跟业务强相关的冗余字段):

CREATE TABLE `schedule` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `course_id` bigint(20) NOT NULL COMMENT '课程ID', `coach_id` bigint(20) NOT NULL COMMENT '教练ID', `student_id` bigint(20) DEFAULT NULL COMMENT '学员ID,为空表示未预约', `car_id` bigint(20) DEFAULT NULL COMMENT '车辆ID,如果启用车辆约束则必填', `start_time` datetime NOT NULL COMMENT '上课开始时间', `end_time` datetime NOT NULL COMMENT '上课结束时间', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0待预约 1已预约 2已完成 3已取消 4已过期', `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_coach_time` (`coach_id`, `start_time`, `end_time`), KEY `idx_student_time` (`student_id`, `start_time`, `end_time`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='排课表';

status字段是整个表的灵魂。待预约的时段表示教练开放了这段时间但还没学员约;已预约表示学员占住了;已完成表示课已经上完;已取消和已过期是兜底状态。很多系统把所有状态的排课记录混在一起,查询时逻辑一团乱,这里用状态机来管理就清晰得多。

索引设计是这个表的重中之重。idx_coach_timeidx_student_time这两个联合索引直接服务于冲突检测。查询“教练X在某个时间段是否有排课”时,走的就是coach_id + start_time + end_time这个索引。没有这两个索引,数据量上到几万条以后,每次排课都要全表扫,系统基本就卡死了。

2.3 教练可预约时段表:把“开放时间”和“实际排课”分开管理

这个设计是容易被忽略但非常实用的一点:建议单独建一张coach_available_time表,存教练每周可开放的上课时间段。

CREATE TABLE `coach_available_time` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `coach_id` bigint(20) NOT NULL COMMENT '教练ID', `week_day` tinyint(4) NOT NULL COMMENT '星期几:1-7', `start_time` time NOT NULL COMMENT '开始时间,如09:00', `end_time` time NOT NULL COMMENT '结束时间,如10:00', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_coach_week` (`coach_id`, `week_day`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教练可预约时段表';

为什么要把可预约时段单独拆出来?因为实际业务里,教练不是每天都能上课。周一可能全天带训,周二下午要去开安全例会,这就是“可预约时段”和“实际排课”分离的原因。教务人员在系统里配好了教练的周可用时段,学员在App端看到的就是教练真正能约的空档,系统内部再把已排的课从空档里剔除,剩下的就是可预约时间。

这套设计的另外一个好处是,调课逻辑变得很简单。教练某天临时有事,只需要删除当天的coach_available_time记录,或者在那张表上加一个“是否可用”的标记,学员端立刻感知,不用去跟已经生成的排课记录玩“乾坤大挪移”。

3. 核心功能模块的代码级实现

3.1 排课主流程:校验、加锁、插入三步走

排课的核心动作就是生成一条新的schedule记录。但这个动作背后有非常多的前置校验:课程是否存在、教练是否正常状态、学员剩余课时是否大于0、时间范围是否合法、教练在目标时间段是否空闲、学员在目标时间段是否空闲。

在校验逻辑之前,先要处理一个并发问题。假设两个学员同时抢同一个时间段,都通过了“教练空闲”校验,然后同时插入,就出现了重复预约。解决方式不复杂,最稳妥的办法是利用数据库层面的互斥:

  1. 把整段排课业务放在一个事务里;
  2. coach_available_time表里对应的记录执行SELECT ... FOR UPDATE加锁;
  3. 事务结束后释放锁。

示例代码如下(基于Spring的@Transactional注解):

@Transactional(rollbackFor = Exception.class) public Long createSchedule(ScheduleCreateDTO dto) { // 1. 校验基础信息 Course course = courseMapper.selectById(dto.getCourseId()); Coach coach = coachMapper.selectById(dto.getCoachId()); Student student = studentMapper.selectById(dto.getStudentId()); if (course == null || coach == null || student == null) { throw new BizException("课程、教练或学员不存在"); } if (student.getRemainLessonCount() <= 0) { throw new BizException("学员剩余课时不足"); } if (student.getLessonExpireDate() != null && student.getLessonExpireDate().isBefore(LocalDate.now())) { throw new BizException("学员课时包已过期"); } // 2. 锁定教练的可预约时段记录,防止并发重复预约 List<CoachAvailableTime> lockedTimes = coachAvailableTimeMapper .lockCoachAvailableTime(dto.getCoachId(), dto.getStartTime(), dto.getEndTime()); if (CollectionUtils.isEmpty(lockedTimes)) { throw new BizException("教练在该时间段未开放预约"); } // 3. 冲突检测:教练维度 Integer coachConflict = scheduleMapper .countConflictByCoachId(dto.getCoachId(), dto.getStartTime(), dto.getEndTime()); if (coachConflict > 0) { throw new BizException("教练在当前时间段已有排课"); } // 4. 冲突检测:学员维度 Integer studentConflict = scheduleMapper .countConflictByStudentId(dto.getStudentId(), dto.getStartTime(), dto.getEndTime()); if (studentConflict > 0) { throw new BizException("学员在当前时间段已有排课"); } // 5. 扣减剩余课时 studentMapper.decreaseRemainLessonCount(dto.getStudentId(), 1); // 6. 插入排课记录 Schedule schedule = new Schedule(); schedule.setCourseId(dto.getCourseId()); schedule.setCoachId(dto.getCoachId()); schedule.setStudentId(dto.getStudentId()); schedule.setStartTime(dto.getStartTime()); schedule.setEndTime(dto.getEndTime()); schedule.setStatus(ScheduleStatus.BOOKED.getCode()); scheduleMapper.insert(schedule); return schedule.getId(); }

这里有一点要特别强调:第2步用SELECT ... FOR UPDATE锁的是coach_available_time表里的记录,目的是让同时发起预约请求的多个事务,在锁上串行执行。如果一个教练开放了多个可预约时段,事务只会锁住目标时间范围内命中的那条记录,不会影响教练在其他时段的预约,并发粒度很合理。

3.2 冲突检测SQL到底怎么写才不会漏

冲突检测就是要查出“目标时间段”与“已存在的排课时间”是否有重叠。标准写法如下:

@Select("SELECT COUNT(*) FROM schedule " + "WHERE coach_id = #{coachId} " + "AND status IN (1, 2) " + "AND start_time < #{endTime} AND end_time > #{startTime}") Integer countConflictByCoachId(@Param("coachId") Long coachId, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);

注意两个条件:

  • start_time < #{endTime}表示已有的课开始时间早于新课的结束时间;
  • end_time > #{startTime}表示已有的课结束时间晚于新课的开始时间。

这俩条件同时成立,说明两个时间段有交集。举个例子,已有排课是 10:00-11:00,新排课想插入 10:30-11:30,那么10:00 < 11:30成立,11:00 > 10:30也成立,判定为冲突,正确。已有排课是 09:00-10:00,新排课是 10:00-11:00,那么09:00 < 11:00成立,但10:00 > 10:00不成立,最终不冲突,这正好符合“上一节课刚结束,下一节课马上开始”的业务需求。

status IN (1, 2)这个条件的用意是只把“已预约”和“已完成”的排课视为占用时间。已完成状态之所以也算占用,是为了防止教练在历史排课的时间段上重复排课,同时也方便统计教练的带教时长。至于“已取消”的记录,它们不占用任何资源,所以不参与冲突检测,这也是很多初学者容易漏掉的一点:如果忘了过滤状态,学员取消过的课就会一直占着时间坑,影响后续预约。

3.3 查询某教练的可用时间段:从源头上杜绝冲突

做了一个简单的“生成下一周排课空档”的逻辑。这个逻辑用起来非常顺手:

public List<TimeSlotVO> getAvailableSlots(Long coachId, LocalDate targetDate) { // 1. 取教练当天可预约时段 List<CoachAvailableTime> availableTimes = coachAvailableTimeMapper .selectByCoachAndWeekDay(coachId, targetDate.getDayOfWeek().getValue()); // 2. 查出当天已经预约/完成的排课 List<Schedule> scheduledList = scheduleMapper .selectByCoachAndDate(coachId, targetDate); List<TimeSlotVO> result = new ArrayList<>(); for (CoachAvailableTime at : availableTimes) { // 把可预约时段按已排课拆分成剩余空档 result.addAll(splitAvailableTime(at, scheduledList)); } return result; }

splitAvailableTime方法的思路并不复杂:把教练当天可预约的时间区间[start, end]看作一条线段,把已经排出去的课看作线段上的“障碍”。从开始时间往后遍历,每遇到一段没有障碍的空隙,就生成一个可预约时间段。这种“线段切割”的方式,比每次预约都去查全表判断“这段时间能不能约”要高效得多,而且在展示给学员的界面里,直接就把不可约的时间过滤掉了。

4. 从源码到可运行:实操部署与验证

4.1 本地环境准备

在动手跑源码之前,先把环境准备好。JDK 1.8 或者 JDK 11 都行,我自己用 JDK 1.8 跑这套代码跑得很稳。Maven 3.6+ 用来拉依赖、打包,这里不展开 Maven 安装步骤了,但建议配阿里云镜像,否则首次拉取 Spring Boot 全家桶依赖会等得让人怀疑人生。

数据库这块,生产环境用 MySQL 8.0,本地调试用 Docker 起一个 MySQL 8.0 容器就够了。注意字符集一定要指定utf8mb4,否则存中文姓名或备注信息可能出现乱码,这是老生常谈的问题了。

Redis 在这套系统里不是必须的,但如果你要跑“高并发预约”演示,就必须起一个 Redis,用来做分布式锁。本地用 Docker 起 Redis 很省事:

docker run -d --name redis-local -p 6379:6379 redis:7

4.2 初始化数据库与项目配置

把源码里的sql/init.sql导进 MySQL,里面包含表结构、基础数据和几个必要的存储过程。运行成功后,确认以下几张核心表存在:coachstudentcourseschedulecoach_available_time

然后打开application.yml,检查数据源配置:

spring: datasource: url: jdbc:mysql://localhost:3306/coach_schedule?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver

启动类直接运行,等控制台打印出 Spring Boot 启动成功的日志,再用 Postman 打一个查询接口验证:

GET http://localhost:8080/api/schedule/available?coachId=1&date=2025-03-10

如果接口正常返回教练的可预约时间段列表,说明项目已经跑通了,接下来可以上手改业务逻辑。

4.3 用Postman完整走一遍排课流程

我建议新手按这个顺序逐一验证:

第一步,查询教练可预约时段。接口返回timeSlots数组,里面是某个教练在某个日期下生成的可用起止时间。 第二步,调用创建排课接口。把courseIdcoachIdstudentIdstartTimeendTime填好,发起POST /api/schedule请求。正常情况下返回新排课的id。 第三步,立刻查询教练在该时间段的排课列表。如果看到刚创建的记录,说明插入成功。 第四步,再调用一次同一个时间段的创建排课接口。这次应该被拦截下来,返回“教练在当前时间段已有排课”的错误提示。 第五步,把已创建的排课取消掉,再重复第四步。此时请求应该能成功通过,因为“已取消”的记录不再参与冲突检测。

这一套流程走下来,排课系统的核心链路基本就验证完整了。

5. 并发、缓存与性能:进阶避坑指南

5.1 高并发预约下的超卖问题

排课系统最容易出事故的场景就是:学员同时抢教练的热门时间段。比如驾考前夕,科目二的教练晚上8点开放预约,几十个学员同时点“预约”按钮。如果代码里没有加锁,就可能出现两个学员同时预约同一个教练同一个时间段的情况。

前面已经提到了用SELECT ... FOR UPDATE解决这个问题,但需要注意两个坑。

第一个坑是FOR UPDATE一定要走索引。如果lockCoachAvailableTime这条SQL的WHERE条件没有命中任何索引,InnoDB会锁住整张表的所有记录,等于把所有教练的预约都串行化了。这在开发环境看不出问题,生产环境数据量一大直接雪崩。所以查询条件里coach_id一定要用上联合索引,比如(coach_id, week_day)

第二个坑是事务超时。默认的Spring事务超时时间通常足够,但如果冲突检测里还包含大范围的报表统计查询,事务时间可能超过数据库的innodb_lock_wait_timeout,导致锁等待超时报错。建议把锁范围控制到最小,冲突检测SQL只查schedule表上用得到索引的关键字段,不要把无关查询塞进同一个事务。

顺便说一句,如果你打算用 Redis 分布式锁,建议把锁的粒度设置为“教练ID + 时间段”,而不是“全局一把锁”。全局锁简单,但会把系统吞吐量拉得非常低,表现就是高峰时段预约请求排队特别久,用户疯狂点按钮体验极差。

5.2 缓存穿透、击穿、雪崩:排课场景有哪些实际案例

排课系统里最常见的性能问题有三个。第一个是缓存穿透,查询一个不存在的coachIdscheduleId,缓存里没有,数据库里也没有,请求每次都打到数据库。解决办法除了参数校验,还可以把空结果也缓存起来,缓存时间设置短一点,比如60秒。

第二个是缓存击穿。某个明星教练的时间段是“热点key”,大量学员同时查询他的可预约时段,缓存一旦过期,所有请求瞬间打到数据库。解决办法是在“重建缓存”这块做互斥:只允许一个线程去查数据库并重建缓存,其他线程等待或直接返回旧缓存。

第三个是缓存雪崩。大量缓存在同一时刻过期,数据库压力突然飙升。解决办法是给缓存过期时间加一个随机值,比如5到15分钟随机分布,避免同时失效。这三个问题在Java面试里也是常客,在排课系统源码里亲手调一遍,比单纯背八股文有用得多。

5.3 定时任务:自动释放未支付的预约名额

预约流程里经常有“占位但未支付”的状态。比如学员提交了预约请求,但没付钱,系统要给15分钟的支付等待期,超时自动释放该时间段。这种功能用定时任务最合适。

推荐用 Spring Boot 自带的@Scheduled注解实现,扫描schedule表中状态为“待支付”且创建时间早于15分钟前的记录,将其状态改为“已取消”,同时给学员退回课时。这里有一个细节:定时任务修改状态用一条UPDATE语句加条件判断,比如UPDATE schedule SET status = 3 WHERE id = ? AND status = 0,利用数据库的行锁与原子性,防止刚好用户在支付时被任务误杀。

如果你要处理更多复杂的定时调度,比如按教练维度自动生成下一周的可用时段,建议引入xxl-job这类分布式任务调度框架。不过小项目用@Scheduled完全够用,别过度设计。

6. 这套源码在Java面试里的价值

6.1 排课系统对应试八股文的实际印证

排课系统是典型的“业务复杂度集中”项目,特别适合用来检验Java基础。面试官问“事务失效的场景有哪些”,你直接把排课里的@Transactional拿来讲:同类内部调用导致事务失效、rollbackFor没写异常类型、数据库表引擎不是InnoDB,这些在排课系统里全都有实际对应。

问“MySQL索引为什么能加速查询”,你可以拿idx_coach_time联合索引举例,解释最左前缀原则和作用在WHERE条件上的索引如何减少扫描行数。问“缓存穿透怎么解决”,你直接把排课系统里的缓存空值方案说一遍。这些都不是死记硬背,而是有真实业务背景的实战经验,面试官一听就知道你是真写过代码的。

6.2 从排课系统延伸出去:能扩展的功能方向

这套系统源码的价值不止于跑通现有功能。基于现有的表结构和业务模型,可以低成本扩展出很多能力:

  • 微信小程序端学员自助预约,多端共用一套后端API
  • 教练端日历视图,用Vue或React实现周历、月历,拖拽调整排课
  • 多校区资源隔离,在coach表里增加school_id字段
  • 收费与订单模块,将待支付排课记录接入支付回调
  • 消息通知,预约成功、开课提醒、教练临时取消时通过短信或微信模板消息触达

这些扩展方向做下来,一套排课系统源码就能变成完整的中型业务系统。这也是为什么我一直建议做Java项目的开发者,不要只做增删改查的“管理后台”,而是要挑一到两个有业务深度的系统,比如排课、订单、审批流,把它们彻底吃透。业务复杂度上来了,技术深度自然跟着上来。

7. 实际操作中遇到的几个问题与排查记录

7.1 一个字符集引发的“幽灵预约”

有一回我给一家健身工作室部署这套排课系统,学员反馈“明明看到教练有空,预约却一直失败”。排查了半天,最后发现是接口传参里学员姓名带了特殊空格字符,存到MySQL后被替换成了另一个不可见字符,导致学员维度的冲突检测永远命中不了已有记录。这类问题只要统一在服务层做入参清洗就能规避。

7.2 “刚刚取消的课又出现了?”——缓存与数据库不一致

处理预约取消时,如果先更新了数据库,但缓存里的可预约时间段没有同步删除,学员端就会看到“已取消的时段仍不可约”的诡异现象。解决思路很直接:在取消接口里加缓存删除操作,并且给可预约时间段的缓存设置一个合理的过期时间,比如5分钟,即便漏删也能自动校正。

7.3 定时任务重复执行导致课时重复扣减

分布式部署下,多个实例同时跑@Scheduled定时任务,会自动释放预约名额,导致学员课时被重复扣除。最直接的解决办法是为定时任务加一个分布式锁,用 Redis 的SETNX实现。这样做之后,即使有多实例在跑,同一时刻也只有一个实例能执行任务,彻底解决了重复执行问题。

8. 我从这套源码里学到的事

做排课系统这类业务系统,最核心的从来不是某个高深的技术点,而是把业务规则梳理清楚,然后用最合适的技术手段去落地。一张schedule表加一个时间段重叠判断,就能解决教练培训里最头疼的排课冲突问题;一条FOR UPDATE加上正确的事务边界,就能守住并发预约的底线;一套“可预约时段”与“实际排课记录”分离的设计,就能让调课和改期变得异常灵活。

我实际动手改这套源码时,感受最深的一点是:不要把业务状态散落在各种零散的字段里,而是统一用status状态机去管理,查询和判断都会清爽很多。另外,调试并发问题时,千万别靠肉眼观察日志来推理,用SHOW ENGINE INNODB STATUS看锁等待记录,比什么都直观。

如果你正在做类似的培训、教务、预约类系统,这套排课源码的设计思路和代码片段可以直接搬过去用。真踩到坑了,欢迎在评论区留言,一起讨论怎么优化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询