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

上一篇博客写了"我的课表"模块的搭建过程。但课表里那几个学习进度字段(已学小节数、最近学习时间)其实都是空值------因为还没有实现学习记录功能。这篇来填这个坑:用户看视频时前端每 15 秒心跳提交播放进度,服务端记录进度、判断是否学完、更新课表统计。整个链路涉及学习记录表设计、视频/考试两种提交逻辑、MyBatis Plus 条件更新、GROUP BY 周统计,以及面试时被追问"高频写数据库扛不住怎么办"的回答思路。

一、学习记录表:记录每一小节的播放进度

课表(learning_lesson)记录的是"谁在学哪门课、学了几个小节"这种粗粒度信息。但视频续播需要知道"这个视频播放到了第几秒",学习计划进度需要知道"本周学完了哪几节"。这些信息靠课表一张表搞不定,需要一张更细粒度的学习记录表。

设计思路是这样的:用户每开始学一个小节(不管是视频还是考试),就产生一条学习记录。视频类型需要记录播放到了第几秒(moment)、是否学完(finished)。考试类型提交即学完,不需要记录播放进度。

sql 复制代码
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%
考试 考试结束提交一次 不记录进度 提交即学完

整体处理流程:
#mermaid-svg-X8U64r7tYyOvsanV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-X8U64r7tYyOvsanV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-X8U64r7tYyOvsanV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-X8U64r7tYyOvsanV .error-icon{fill:#552222;}#mermaid-svg-X8U64r7tYyOvsanV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-X8U64r7tYyOvsanV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-X8U64r7tYyOvsanV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-X8U64r7tYyOvsanV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-X8U64r7tYyOvsanV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-X8U64r7tYyOvsanV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-X8U64r7tYyOvsanV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-X8U64r7tYyOvsanV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-X8U64r7tYyOvsanV .marker.cross{stroke:#333333;}#mermaid-svg-X8U64r7tYyOvsanV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-X8U64r7tYyOvsanV p{margin:0;}#mermaid-svg-X8U64r7tYyOvsanV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-X8U64r7tYyOvsanV .cluster-label text{fill:#333;}#mermaid-svg-X8U64r7tYyOvsanV .cluster-label span{color:#333;}#mermaid-svg-X8U64r7tYyOvsanV .cluster-label span p{background-color:transparent;}#mermaid-svg-X8U64r7tYyOvsanV .label text,#mermaid-svg-X8U64r7tYyOvsanV span{fill:#333;color:#333;}#mermaid-svg-X8U64r7tYyOvsanV .node rect,#mermaid-svg-X8U64r7tYyOvsanV .node circle,#mermaid-svg-X8U64r7tYyOvsanV .node ellipse,#mermaid-svg-X8U64r7tYyOvsanV .node polygon,#mermaid-svg-X8U64r7tYyOvsanV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-X8U64r7tYyOvsanV .rough-node .label text,#mermaid-svg-X8U64r7tYyOvsanV .node .label text,#mermaid-svg-X8U64r7tYyOvsanV .image-shape .label,#mermaid-svg-X8U64r7tYyOvsanV .icon-shape .label{text-anchor:middle;}#mermaid-svg-X8U64r7tYyOvsanV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-X8U64r7tYyOvsanV .rough-node .label,#mermaid-svg-X8U64r7tYyOvsanV .node .label,#mermaid-svg-X8U64r7tYyOvsanV .image-shape .label,#mermaid-svg-X8U64r7tYyOvsanV .icon-shape .label{text-align:center;}#mermaid-svg-X8U64r7tYyOvsanV .node.clickable{cursor:pointer;}#mermaid-svg-X8U64r7tYyOvsanV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-X8U64r7tYyOvsanV .arrowheadPath{fill:#333333;}#mermaid-svg-X8U64r7tYyOvsanV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-X8U64r7tYyOvsanV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-X8U64r7tYyOvsanV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-X8U64r7tYyOvsanV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-X8U64r7tYyOvsanV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-X8U64r7tYyOvsanV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-X8U64r7tYyOvsanV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-X8U64r7tYyOvsanV .cluster text{fill:#333;}#mermaid-svg-X8U64r7tYyOvsanV .cluster span{color:#333;}#mermaid-svg-X8U64r7tYyOvsanV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-X8U64r7tYyOvsanV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-X8U64r7tYyOvsanV rect.text{fill:none;stroke-width:0;}#mermaid-svg-X8U64r7tYyOvsanV .icon-shape,#mermaid-svg-X8U64r7tYyOvsanV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-X8U64r7tYyOvsanV .icon-shape p,#mermaid-svg-X8U64r7tYyOvsanV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-X8U64r7tYyOvsanV .icon-shape .label rect,#mermaid-svg-X8U64r7tYyOvsanV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-X8U64r7tYyOvsanV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-X8U64r7tYyOvsanV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-X8U64r7tYyOvsanV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 视频
不存在
存在


考试
📝 前端提交学习记录
🎬 小节类型?
查询旧记录
旧记录存在?
新增记录

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。

核心代码:

java 复制代码
@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);
}

视频处理逻辑:

java 复制代码
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 清爽得多:

java 复制代码
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();

如果不用条件更新,得写成:

java 复制代码
// 传统写法
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 片段做字段自增:

java 复制代码
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 最复杂的部分。需要统计"本周每门课学完了几节"、"本周总共学完几节"、"本周计划学几节"。
#mermaid-svg-yKK5GcLCYbequb6d{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yKK5GcLCYbequb6d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yKK5GcLCYbequb6d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yKK5GcLCYbequb6d .error-icon{fill:#552222;}#mermaid-svg-yKK5GcLCYbequb6d .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yKK5GcLCYbequb6d .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yKK5GcLCYbequb6d .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yKK5GcLCYbequb6d .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yKK5GcLCYbequb6d .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yKK5GcLCYbequb6d .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yKK5GcLCYbequb6d .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yKK5GcLCYbequb6d .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yKK5GcLCYbequb6d .marker.cross{stroke:#333333;}#mermaid-svg-yKK5GcLCYbequb6d svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yKK5GcLCYbequb6d p{margin:0;}#mermaid-svg-yKK5GcLCYbequb6d .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yKK5GcLCYbequb6d .cluster-label text{fill:#333;}#mermaid-svg-yKK5GcLCYbequb6d .cluster-label span{color:#333;}#mermaid-svg-yKK5GcLCYbequb6d .cluster-label span p{background-color:transparent;}#mermaid-svg-yKK5GcLCYbequb6d .label text,#mermaid-svg-yKK5GcLCYbequb6d span{fill:#333;color:#333;}#mermaid-svg-yKK5GcLCYbequb6d .node rect,#mermaid-svg-yKK5GcLCYbequb6d .node circle,#mermaid-svg-yKK5GcLCYbequb6d .node ellipse,#mermaid-svg-yKK5GcLCYbequb6d .node polygon,#mermaid-svg-yKK5GcLCYbequb6d .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yKK5GcLCYbequb6d .rough-node .label text,#mermaid-svg-yKK5GcLCYbequb6d .node .label text,#mermaid-svg-yKK5GcLCYbequb6d .image-shape .label,#mermaid-svg-yKK5GcLCYbequb6d .icon-shape .label{text-anchor:middle;}#mermaid-svg-yKK5GcLCYbequb6d .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yKK5GcLCYbequb6d .rough-node .label,#mermaid-svg-yKK5GcLCYbequb6d .node .label,#mermaid-svg-yKK5GcLCYbequb6d .image-shape .label,#mermaid-svg-yKK5GcLCYbequb6d .icon-shape .label{text-align:center;}#mermaid-svg-yKK5GcLCYbequb6d .node.clickable{cursor:pointer;}#mermaid-svg-yKK5GcLCYbequb6d .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yKK5GcLCYbequb6d .arrowheadPath{fill:#333333;}#mermaid-svg-yKK5GcLCYbequb6d .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yKK5GcLCYbequb6d .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yKK5GcLCYbequb6d .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yKK5GcLCYbequb6d .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yKK5GcLCYbequb6d .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yKK5GcLCYbequb6d .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yKK5GcLCYbequb6d .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yKK5GcLCYbequb6d .cluster text{fill:#333;}#mermaid-svg-yKK5GcLCYbequb6d .cluster span{color:#333;}#mermaid-svg-yKK5GcLCYbequb6d div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yKK5GcLCYbequb6d .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yKK5GcLCYbequb6d rect.text{fill:none;stroke-width:0;}#mermaid-svg-yKK5GcLCYbequb6d .icon-shape,#mermaid-svg-yKK5GcLCYbequb6d .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yKK5GcLCYbequb6d .icon-shape p,#mermaid-svg-yKK5GcLCYbequb6d .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yKK5GcLCYbequb6d .icon-shape .label rect,#mermaid-svg-yKK5GcLCYbequb6d .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yKK5GcLCYbequb6d .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yKK5GcLCYbequb6d .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yKK5GcLCYbequb6d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 📋 查询所有进行中的学习计划
📊 GROUP BY 统计本周每门课学完小节数
📊 SUM 统计本周总计划小节数
🔗 Feign 批量查课程信息
🧮 手动分页 + 组装 VO

这张图说的是:先查出用户所有进行中的学习计划,然后用 GROUP BY 统计每门课本周学完的小节数,用 SUM 统计总计划小节数,再 Feign 查课程名称等信息,最后手动分页组装 VO。

核心 SQL------按课表分组统计本周学完小节数:

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 统计总数。统计数据不受分页影响。

java 复制代码
// 单独统计本周总学完小节数
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。

方案二:全量查询,手动分页

一次查出所有进行中的学习计划,在内存里做统计和分页。

java 复制代码
// 一次查出所有进行中的学习计划
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 异步写),但根据我前面几篇博客学到的知识,可以提前梳理一下优化方向:
#mermaid-svg-byJdf1CCHwhuzkdn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-byJdf1CCHwhuzkdn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-byJdf1CCHwhuzkdn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-byJdf1CCHwhuzkdn .error-icon{fill:#552222;}#mermaid-svg-byJdf1CCHwhuzkdn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-byJdf1CCHwhuzkdn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-byJdf1CCHwhuzkdn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-byJdf1CCHwhuzkdn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-byJdf1CCHwhuzkdn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-byJdf1CCHwhuzkdn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-byJdf1CCHwhuzkdn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-byJdf1CCHwhuzkdn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-byJdf1CCHwhuzkdn .marker.cross{stroke:#333333;}#mermaid-svg-byJdf1CCHwhuzkdn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-byJdf1CCHwhuzkdn p{margin:0;}#mermaid-svg-byJdf1CCHwhuzkdn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-byJdf1CCHwhuzkdn .cluster-label text{fill:#333;}#mermaid-svg-byJdf1CCHwhuzkdn .cluster-label span{color:#333;}#mermaid-svg-byJdf1CCHwhuzkdn .cluster-label span p{background-color:transparent;}#mermaid-svg-byJdf1CCHwhuzkdn .label text,#mermaid-svg-byJdf1CCHwhuzkdn span{fill:#333;color:#333;}#mermaid-svg-byJdf1CCHwhuzkdn .node rect,#mermaid-svg-byJdf1CCHwhuzkdn .node circle,#mermaid-svg-byJdf1CCHwhuzkdn .node ellipse,#mermaid-svg-byJdf1CCHwhuzkdn .node polygon,#mermaid-svg-byJdf1CCHwhuzkdn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-byJdf1CCHwhuzkdn .rough-node .label text,#mermaid-svg-byJdf1CCHwhuzkdn .node .label text,#mermaid-svg-byJdf1CCHwhuzkdn .image-shape .label,#mermaid-svg-byJdf1CCHwhuzkdn .icon-shape .label{text-anchor:middle;}#mermaid-svg-byJdf1CCHwhuzkdn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-byJdf1CCHwhuzkdn .rough-node .label,#mermaid-svg-byJdf1CCHwhuzkdn .node .label,#mermaid-svg-byJdf1CCHwhuzkdn .image-shape .label,#mermaid-svg-byJdf1CCHwhuzkdn .icon-shape .label{text-align:center;}#mermaid-svg-byJdf1CCHwhuzkdn .node.clickable{cursor:pointer;}#mermaid-svg-byJdf1CCHwhuzkdn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-byJdf1CCHwhuzkdn .arrowheadPath{fill:#333333;}#mermaid-svg-byJdf1CCHwhuzkdn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-byJdf1CCHwhuzkdn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-byJdf1CCHwhuzkdn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-byJdf1CCHwhuzkdn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-byJdf1CCHwhuzkdn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-byJdf1CCHwhuzkdn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-byJdf1CCHwhuzkdn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-byJdf1CCHwhuzkdn .cluster text{fill:#333;}#mermaid-svg-byJdf1CCHwhuzkdn .cluster span{color:#333;}#mermaid-svg-byJdf1CCHwhuzkdn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-byJdf1CCHwhuzkdn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-byJdf1CCHwhuzkdn rect.text{fill:none;stroke-width:0;}#mermaid-svg-byJdf1CCHwhuzkdn .icon-shape,#mermaid-svg-byJdf1CCHwhuzkdn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-byJdf1CCHwhuzkdn .icon-shape p,#mermaid-svg-byJdf1CCHwhuzkdn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-byJdf1CCHwhuzkdn .icon-shape .label rect,#mermaid-svg-byJdf1CCHwhuzkdn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-byJdf1CCHwhuzkdn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-byJdf1CCHwhuzkdn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-byJdf1CCHwhuzkdn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 📱 前端心跳

每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 异步落库来解决高频写数据库的问题。

相关推荐
做萤石二次开发的哈哈1 小时前
海康移动交通四款设备技能接入实战:布控球+取证终端+测速仪+出入口终端,Web/App/小程序移动交通应用快速生成
前端·物联网·萤石开放平台·蓝海aiot一站式工作台·aiot开发·移动交通
一个有温度的技术博主1 小时前
深入理解 Spring Boot 自动装配
java·spring boot·后端
草莓熊Lotso1 小时前
【Redis 初阶】C++ 客户端实战:从 RESP 协议到 redis-plus-plus 工程化用法
linux·开发语言·网络·数据库·c++·redis·缓存
TT哇1 小时前
我把两个 Java 项目和一个 Python AI 服务部署到 2C2G:一次低成本 Docker 容器化实践
java·人工智能·python
星子yu1 小时前
【Java数据结构】二叉树 好像就这样?
java·数据结构
IT_陈寒1 小时前
我TM竟然被Java的空指针坑了第三次!
前端·人工智能·后端
Irene19911 小时前
Flink 学习需要具备的 Java 基础知识总结
java·flink
宸津-代码粉碎机1 小时前
Spring AI企业级工程化落地手册|从Demo改造为生产级商用项目
java·人工智能·python·spring
xcLeigh1 小时前
AI 编程学习路线图:一份覆盖前端、后端、全栈的系统学习计划
前端·人工智能·学习