上一篇博客写了"我的课表"模块的搭建过程。但课表里那几个学习进度字段(已学小节数、最近学习时间)其实都是空值——因为还没有实现学习记录功能。这篇来填这个坑:用户看视频时前端每 15 秒心跳提交播放进度,服务端记录进度、判断是否学完、更新课表统计。整个链路涉及学习记录表设计、视频/考试两种提交逻辑、MyBatis Plus 条件更新、GROUP BY 周统计,以及面试时被追问"高频写数据库扛不住怎么办"的回答思路。
一、学习记录表:记录每一小节的播放进度
课表(learning_lesson)记录的是"谁在学哪门课、学了几个小节"这种粗粒度信息。但视频续播需要知道"这个视频播放到了第几秒",学习计划进度需要知道"本周学完了哪几节"。这些信息靠课表一张表搞不定,需要一张更细粒度的学习记录表。
设计思路是这样的:用户每开始学一个小节(不管是视频还是考试),就产生一条学习记录。视频类型需要记录播放到了第几秒(moment)、是否学完(finished)。考试类型提交即学完,不需要记录播放进度。
CREATE TABLE learning_record (
id bigint NOT NULL COMMENT '学习记录id',
lesson_id bigint NOT NULL COMMENT '课表id',
section_id bigint NOT NULL COMMENT '小节id',
user_id bigint NOT NULL COMMENT '用户id',
moment int DEFAULT 0 COMMENT '视频播放进度(秒)',
finished bit(1) NOT NULL DEFAULT b'0' COMMENT '是否学完',
finish_time datetime DEFAULT NULL COMMENT '第一次学完的时间',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '第一次观看时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_lesson_id (lesson_id, section_id),
KEY idx_user_id (user_id),
KEY idx_update_time (update_time)
);
几个设计细节值得说一下:
lesson_id + section_id 建了联合索引,因为查询学习记录时总是按课表 + 小节来查,这个索引同时保证了唯一性(同一小节只有一条记录)。
finish_time 单独记录"第一次学完的时间",和 update_time 分开。因为用户可能反复看同一个视频,update_time 会不断更新,但 finish_time 记录的是从未学完到学完的那个时间点,后面统计"本周学完了几节"要用这个字段。
二、提交学习记录:视频和考试的分支处理
提交学习记录是整个学习进度模块的核心。视频和考试的处理逻辑完全不同:
| 类型 | 提交频率 | 是否记录进度 | 学完条件 |
|---|---|---|---|
| 视频 | 每 15 秒心跳提交 | 记录播放秒数 | 播放进度 ≥ 50% |
| 考试 | 考试结束提交一次 | 不记录进度 | 提交即学完 |
整体处理流程:
这张图说的是:提交学习记录后,先按小节类型分流。视频要先查旧记录判断是新增还是更新,更新时还要判断是否"首次学完"(之前未学完 + 当前进度 ≥ 50%)。考试直接新增并标记学完。最后无论哪种类型,都要更新课表的统计数据。
"首次学完"的判断条件是两个条件同时满足:之前 finished 为 false,且当前 moment * 2 >= duration(播放进度超过 50%)。这样设计是因为用户可能反复看同一个视频,只有第一次从未学完变成学完时,才需要给课表的 learned_sections 加 1。
核心代码:
@Transactional
public void addLearningRecord(LearningRecordFormDTO recordDTO) {
Long userId = UserContext.getUser();
boolean finished = false;
if (recordDTO.getSectionType() == SectionType.VIDEO) {
finished = handleVideoRecord(userId, recordDTO);
} else {
finished = handleExamRecord(userId, recordDTO);
}
// 更新课表统计数据
handleLearningLessonsChanges(recordDTO, finished);
}
视频处理逻辑:
private boolean handleVideoRecord(Long userId, LearningRecordFormDTO recordDTO) {
// 查旧记录
LearningRecord old = queryOldRecord(recordDTO.getLessonId(), recordDTO.getSectionId());
if (old == null) {
// 第一次看这个视频,新增记录
LearningRecord record = BeanUtils.copyBean(recordDTO, LearningRecord.class);
record.setUserId(userId);
save(record);
return false; // 第一次提交,不算学完
}
// 已有记录,判断是否首次学完
boolean finished = !old.getFinished()
&& recordDTO.getMoment() * 2 >= recordDTO.getDuration();
lambdaUpdate()
.set(LearningRecord::getMoment, recordDTO.getMoment())
.set(finished, LearningRecord::getFinished, true)
.set(finished, LearningRecord::getFinishTime, recordDTO.getCommitTime())
.eq(LearningRecord::getId, old.getId())
.update();
return finished;
}
recordDTO.getMoment() * 2 >= recordDTO.getDuration() 这个写法比 moment >= duration * 0.5 更巧妙——用整数乘法代替浮点除法,避免了精度问题。
三、MyBatis Plus 条件更新:.set(condition, …) 的妙用
上面代码里有个值得单独拿出来说的技巧:lambdaUpdate().set(condition, column, value)。
第一个参数是 boolean 条件,为 true 时才执行这个 set。这比写 if-else 清爽得多:
lambdaUpdate()
// 只有首次学完时才更新 finished 和 finish_time
.set(finished, LearningRecord::getFinished, true)
.set(finished, LearningRecord::getFinishTime, recordDTO.getCommitTime())
// 无条件更新播放进度
.set(LearningRecord::getMoment, recordDTO.getMoment())
.eq(LearningRecord::getId, old.getId())
.update();
如果不用条件更新,得写成:
// 传统写法
LambdaUpdateWrapper<LearningRecord> wrapper = lambdaUpdate()
.set(LearningRecord::getMoment, recordDTO.getMoment())
.eq(LearningRecord::getId, old.getId());
if (finished) {
wrapper.set(LearningRecord::getFinished, true);
wrapper.set(LearningRecord::getFinishTime, recordDTO.getCommitTime());
}
wrapper.update();
条件更新把分支逻辑内联到了链式调用里,代码更紧凑。特别是在更新多个字段、每个字段的更新条件不同时,优势更明显。
更新课表时还有更复杂的用法——用 .setSql() 直接写 SQL 片段做字段自增:
lessonService.lambdaUpdate()
// 第一次从"未学习"变成"学习中"
.set(lesson.getLearnedSections() == 0,
LearningLesson::getStatus, LessonStatus.LEARNING.getValue())
// 全部学完变成"已学完"
.set(allLearned,
LearningLesson::getStatus, LessonStatus.FINISHED.getValue())
// 没学完新小节时,更新最近学习信息
.set(!finished, LearningLesson::getLatestSectionId, recordDTO.getSectionId())
.set(!finished, LearningLesson::getLatestLearnTime, recordDTO.getCommitTime())
// 学完新小节时,learned_sections 自增 1
.setSql(finished, "learned_sections = learned_sections + 1")
.eq(LearningLesson::getId, lesson.getId())
.update();
一次 UPDATE 语句同时处理了四种场景:状态变更、进度更新、小节计数自增、最近学习信息刷新。如果拆成多条 SQL,不仅代码冗长,还增加了数据库交互次数。
| 操作 | 条件 | 对应 SQL |
|---|---|---|
| 状态 → 学习中 | learned_sections == 0 | SET status = 1 |
| 状态 → 已学完 | allLearned | SET status = 2 |
| 更新最近小节 | !finished | SET latest_section_id = ? |
| 小节数 +1 | finished | SET learned_sections = learned_sections + 1 |
四、学习计划进度统计:GROUP BY + 手动分页
查询学习计划进度是这个模块里 SQL 最复杂的部分。需要统计"本周每门课学完了几节"、“本周总共学完几节”、“本周计划学几节”。
这张图说的是:先查出用户所有进行中的学习计划,然后用 GROUP BY 统计每门课本周学完的小节数,用 SUM 统计总计划小节数,再 Feign 查课程名称等信息,最后手动分页组装 VO。
核心 SQL——按课表分组统计本周学完小节数:
SELECT lesson_id AS id, COUNT(1) AS num
FROM learning_record
WHERE user_id = #{userId}
AND finished = 1
AND finish_time > #{begin} AND finish_time < #{end}
GROUP BY lesson_id
这条 SQL 返回的是每门课(lesson_id)本周学完了几节(num)。拿到结果后转成 Map,key 是 lessonId,value 是学完数量,后面组装 VO 时直接 get 就行。
4.1 两种分页策略
这个接口有个有意思的决策点:怎么分页?
常规做法是数据库层面 LIMIT 分页,但这里有个问题——"本周总学完小节数"和"本周总计划小节数"需要统计所有课程的数据,如果只查一页,总数就不准了。
项目里实现了两种方案:
方案一:物理分页,分别统计
数据库 LIMIT 分页查课表,同时单独发 SQL 统计总数。统计数据不受分页影响。
// 单独统计本周总学完小节数
Integer weekFinished = recordMapper.selectCount(
new LambdaQueryWrapper<LearningRecord>()
.eq(LearningRecord::getUserId, userId)
.eq(LearningRecord::getFinished, true)
.gt(LearningRecord::getFinishTime, begin)
.lt(LearningRecord::getFinishTime, end));
// 分页查课表
Page<LearningLesson> p = lambdaQuery()
.eq(LearningLesson::getUserId, userId)
.eq(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING)
.page(query.toMpPage("latest_learn_time", false));
优点:数据库压力小,只查一页数据。缺点:需要额外写统计 SQL。
方案二:全量查询,手动分页
一次查出所有进行中的学习计划,在内存里做统计和分页。
// 一次查出所有进行中的学习计划
List<LearningLesson> lessons = lambdaQuery()
.eq(LearningLesson::getUserId, userId)
.eq(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING)
.list();
// 统计总计划小节数(Stream 累加)
int weekTotalPlan = lessons.stream()
.mapToInt(LearningLesson::getWeekFreq).sum();
// 手动分页
List<LearningLesson> records = CollUtils.sub(
lessons, query.from(), query.from() + query.getPageSize());
优点:统计逻辑简单,不需要额外 SQL。缺点:数据量大时全量查询有压力。
| 对比 | 方案一(物理分页) | 方案二(手动分页) |
|---|---|---|
| 数据库查询 | 分页 + 统计 SQL | 一次全量查询 |
| 统计方式 | 独立 SQL | Stream 内存统计 |
| 适用场景 | 数据量大(>100 条) | 数据量小(<50 条) |
| 代码复杂度 | 中 | 低 |
项目里最终选了方案二。原因很实际:一个用户同时在学的课程一般不会超过 10 门,全量查询的数据量很小,手动分页反而更简单。
面试时如果被追问"如果数据量大了怎么办",回答思路是:切换到方案一,用物理分页 + 独立统计 SQL。如果统计 SQL 本身也慢(比如学习记录表数据量很大),可以考虑加定时任务预计算统计结果,存到 Redis 里。
五、面试回答:视频续播 + 高频写数据库
文档末尾有一段面试问答,我觉得回答思路非常值得学习。
面试官问:“有没有觉得比较有挑战的功能?”
回答的切入点是视频续播。先说需求:续播误差 30 秒以内,支持跨设备续播。然后说方案:前端每 15 秒心跳提交进度到服务端,这样续播误差控制在 15 秒左右。
但面试官一定会追问:每 15 秒写一次数据库,并发量大了扛不住怎么办?
这个问题的回答思路文档里提到了下一节内容(应该是 Redis 缓存 + MQ 异步写),但根据我前面几篇博客学到的知识,可以提前梳理一下优化方向:
三种方案的共同思路是:前端心跳照常提交,但服务端不直接写数据库,而是先写到 Redis 或者 MQ,再由定时任务或消费者批量落库。这样数据库的写入压力从"每 15 秒一次 × 在线用户数"降到"定时批量写入",压力可以降低一到两个数量级。
面试时这个问题不需要给出完整代码,但要把"为什么直接写库不行 → 优化方向是什么 → 为什么这样能降低压力"的逻辑链讲清楚。
六、整体感受
这篇是整个学习中心模块里业务逻辑最细的一段。从学习记录表设计,到视频/考试两种提交分支,到条件更新的链式写法,到 GROUP BY 周统计,到两种分页策略的选择——每一个点都不算特别难,但串在一起就是一个完整的"学习进度系统"。
最大的收获是 MyBatis Plus 的条件更新。之前写更新逻辑都是 if-else 套 wrapper,看到 .set(condition, column, value) 这种写法才发现链式 API 还能这么用。.setSql(finished, "learned_sections = learned_sections + 1") 更是把 Java 条件和原生 SQL 混在一起,一条 UPDATE 搞定所有更新。
另一个收获是"分页不一定要用 LIMIT"。之前以为分页就是 Page<xxx> + lambdaQuery().page(),这次看到全量查询 + 手动分页的方案,意识到技术方案要根据业务场景选。用户同时在学的课程不超过 10 门,全量查出来在内存里分页完全没问题,反而省了统计 SQL。
面试那块的视频续播回答思路也让我意识到:项目里的"小功能"往往是最好的面试素材。视频续播听起来很简单,但往深了挖就是"前端心跳频率设计 → 服务端存储选型 → 高并发写优化"这条链路,每一层都有东西可以聊。
下一篇打算把这个模块的 Redis 优化方案补上,看看怎么用 Redis 缓存学习进度、用 MQ 异步落库来解决高频写数据库的问题。
转载自 CSDN-专业IT技术社区




