上一篇博客写了"我的课表"模块的搭建过程。但课表里那几个学习进度字段(已学小节数、最近学习时间)其实都是空值------因为还没有实现学习记录功能。这篇来填这个坑:用户看视频时前端每 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 异步落库来解决高频写数据库的问题。