嘎仔的Coding日记头像
关注
从零实现视频续播 + 学习进度统计:前端心跳、条件更新、GROUP BY 统计全链路拆解封面图

从零实现视频续播 + 学习进度统计:前端心跳、条件更新、GROUP BY 统计全链路拆解

上一篇博客写了"我的课表"模块的搭建过程。但课表里那几个学习进度字段(已学小节数、最近学习时间)其实都是空值——因为还没有实现学习记录功能。这篇来填这个坑:用户看视频时前端每 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%
考试考试结束提交一次不记录进度提交即学完

整体处理流程:

视频

不存在

存在

考试

📝 前端提交学习记录

🎬 小节类型?

查询旧记录

旧记录存在?

新增记录
finished=false

之前未学完
且进度≥50%?

更新进度
finished=true
记录finish_time

仅更新进度

直接新增
finished=true

📊 更新课表

learned_sections + 1
latest_section_id 更新
判断是否全部学完

这张图说的是:提交学习记录后,先按小节类型分流。视频要先查旧记录判断是新增还是更新,更新时还要判断是否"首次学完"(之前未学完 + 当前进度 ≥ 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 == 0SET status = 1
状态 → 已学完allLearnedSET status = 2
更新最近小节!finishedSET latest_section_id = ?
小节数 +1finishedSET learned_sections = learned_sections + 1

四、学习计划进度统计:GROUP BY + 手动分页

查询学习计划进度是这个模块里 SQL 最复杂的部分。需要统计"本周每门课学完了几节"、“本周总共学完几节”、“本周计划学几节”。

📋 查询所有进行中的学习计划

📊 GROUP BY 统计本周每门课学完小节数

📊 SUM 统计本周总计划小节数

🔗 Feign 批量查课程信息

🧮 手动分页 + 组装 VO

这张图说的是:先查出用户所有进行中的学习计划,然后用 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一次全量查询
统计方式独立 SQLStream 内存统计
适用场景数据量大(>100 条)数据量小(<50 条)
代码复杂度

项目里最终选了方案二。原因很实际:一个用户同时在学的课程一般不会超过 10 门,全量查询的数据量很小,手动分页反而更简单。

面试时如果被追问"如果数据量大了怎么办",回答思路是:切换到方案一,用物理分页 + 独立统计 SQL。如果统计 SQL 本身也慢(比如学习记录表数据量很大),可以考虑加定时任务预计算统计结果,存到 Redis 里。

五、面试回答:视频续播 + 高频写数据库

文档末尾有一段面试问答,我觉得回答思路非常值得学习。

面试官问:“有没有觉得比较有挑战的功能?”

回答的切入点是视频续播。先说需求:续播误差 30 秒以内,支持跨设备续播。然后说方案:前端每 15 秒心跳提交进度到服务端,这样续播误差控制在 15 秒左右。

但面试官一定会追问:每 15 秒写一次数据库,并发量大了扛不住怎么办?

这个问题的回答思路文档里提到了下一节内容(应该是 Redis 缓存 + MQ 异步写),但根据我前面几篇博客学到的知识,可以提前梳理一下优化方向:

📱 前端心跳
每15秒提交

🔧 优化方案

方案1: Redis 缓存
进度写 Redis
定时批量落库

方案2: MQ 异步
进度发到 MQ
消费者批量写库

方案3: 合并写
多次进度更新
合并为一次写库

三种方案的共同思路是:前端心跳照常提交,但服务端不直接写数据库,而是先写到 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技术社区

原文链接:https://blog.csdn.net/xwhxy/article/details/165009066

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--