引言:学习进度为什么总是最容易“看起来简单,做起来翻车”
在大多数在线教育项目里,学习进度往往是最容易被低估的模块之一。产品经理一句“记录用户学到第几分钟”,听起来像是半天就能做完的需求;但真正进入上线场景后,研发团队很快会发现,这根本不是一个播放器时间字段的问题,而是一套横跨播放器、缓存、数据库、状态机、反作弊和多端同步的系统工程。
真正麻烦的,从来都不是“当前看到第几秒”,而是这些高频又棘手的真实场景:
- 学员在 PC 端看了 30 分钟,切到 App 继续学习,进度如何无缝接续?
- 页面异常关闭、浏览器崩溃、移动端切后台时,最近一次学习位置如何尽量不丢?
- 用户拖动进度条、倍速播放、挂机不动时,平台如何识别真实学习时长?
- 多端同时在线学习时,总时长如何去重,避免把并发观看误算成双倍学习?
- 合规培训、企业培训场景下,平台如何证明“这个人是真的学完了,而不是把视频拖完了”?
如果这些问题处理不好,学习进度模块很快就会从一个体验问题,演变成统计不准、培训考核不可信、合规审计无法追溯的系统性问题。
织码在线教育系统在这一块采用的是 前端心跳上报 + Redis 实时聚合 + MySQL 批量落库 + 章节级断点续学 + 防快进拖拽校验 + 多端进度合并 的组合方案。它的设计目标不是简单记录一个播放时间,而是把“学习行为”抽象成一套可追踪、可校验、可恢复、可审计的状态同步链路。
本文不会停留在“功能介绍”层面,而会直接从数据建模、心跳机制、断点续学、防作弊、多端一致性和性能治理几个层面,系统拆解织码在线教育系统学习追踪引擎的技术实现。
一、为什么“学习进度”绝对不能只存一个字段

1.1 进度、时长、完成状态,本质上是三类不同数据
很多系统第一版实现学习进度时,喜欢在课程表里直接挂一个 progress_percent 或 current_position。这种方式开发快,但在业务一复杂之后很容易失真,因为下面三类数据并不是一回事:
- 播放位置:用户当前看到视频第几秒,用于断点续学。
- 有效学习时长:真正计入培训时长、积分、学分、合规统计的学习时长。
- 完成状态:某章节或课程是否达到平台定义的完成条件。
这三者不总是线性关系:
- 用户把视频拖到最后,播放位置可能已经到结尾,但有效学习时长并不足。
- 用户反复回看重点内容,有效学习时长会增加,但当前位置未必持续向前。
- 某些课程的完成判定,可能还依赖考试通过、问卷提交、章节解锁等业务规则,并不只看播放时长。
因此,进度系统如果要稳定,必须把“位置、时长、状态”拆开建模,而不是塞进一个字段里硬算。
1.2 推荐的数据分层:事实层、章节层、课程聚合层
织码在线教育系统里,学习进度至少拆成三层:
- 学习会话事实层:记录每次学习行为的原始轨迹。
- 章节级进度层:记录章节当前位置、章节累计时长、章节完成状态。
- 课程级聚合层:对外提供课程总进度、最近学习章节、最近学习时间等高频查询结果。
这种拆法的核心优势是:
- 事实层保留最原始数据,便于追溯、排障、审计。
- 章节层支撑断点续学、已完成跳转、章节解锁等播放器能力。
- 聚合层保证高频页面查询不需要每次实时扫全量明细。
1.3 数据模型设计
下面是更适合长期演进的表结构设计。相比单表方案,这种设计更适合后续扩展倍速校验、多端冲突合并和反作弊分析。
-- 课程级聚合进度:一个用户一门课程一条记录
CREATE TABLE `user_course_progress` (
`id` bigint NOT NULL COMMENT '主键',
`user_id` bigint NOT NULL COMMENT '用户ID',
`course_id` bigint NOT NULL COMMENT '课程ID',
`current_chapter_id` bigint DEFAULT NULL COMMENT '当前学习章节ID',
`current_position` int DEFAULT 0 COMMENT '当前播放位置(秒)',
`valid_duration` int DEFAULT 0 COMMENT '累计有效学习时长(秒)',
`progress_percent` int DEFAULT 0 COMMENT '课程进度百分比',
`last_device_type` tinyint DEFAULT NULL COMMENT '最近学习设备',
`last_study_time` datetime DEFAULT NULL COMMENT '最后学习时间',
`status` tinyint DEFAULT 0 COMMENT '0未开始 1学习中 2已完成',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_course` (`user_id`, `course_id`),
KEY `idx_last_study` (`user_id`, `last_study_time`)
) ENGINE=InnoDB COMMENT='用户课程聚合进度表';
-- 章节级进度:断点续学和章节完成判定的核心
CREATE TABLE `user_chapter_progress` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '用户ID',
`course_id` bigint NOT NULL COMMENT '课程ID',
`chapter_id` bigint NOT NULL COMMENT '章节ID',
`position` int DEFAULT 0 COMMENT '章节当前位置(秒)',
`valid_duration` int DEFAULT 0 COMMENT '章节累计有效学习时长(秒)',
`complete_status` tinyint DEFAULT 0 COMMENT '0未完成 1正常完成 2异常完成',
`completed_time` datetime DEFAULT NULL COMMENT '完成时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_chapter` (`user_id`, `chapter_id`)
) ENGINE=InnoDB COMMENT='用户章节进度表';
-- 学习会话明细:学习行为的事实表
CREATE TABLE `study_session_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '用户ID',
`course_id` bigint NOT NULL COMMENT '课程ID',
`chapter_id` bigint NOT NULL COMMENT '章节ID',
`start_position` int NOT NULL COMMENT '开始位置(秒)',
`end_position` int DEFAULT NULL COMMENT '结束位置(秒)',
`start_time` datetime NOT NULL COMMENT '开始时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
`raw_duration` int DEFAULT 0 COMMENT '自然流逝时长(秒)',
`valid_duration` int DEFAULT 0 COMMENT '有效学习时长(秒)',
`device_type` tinyint NOT NULL COMMENT '设备: 1PC 2App 3小程序 4H5',
`playback_rate` decimal(4,2) DEFAULT 1.00 COMMENT '播放倍速',
`is_valid` tinyint DEFAULT 1 COMMENT '是否有效: 0无效 1有效',
PRIMARY KEY (`id`),
KEY `idx_user_course` (`user_id`, `course_id`),
KEY `idx_user_time` (`user_id`, `start_time`)
) ENGINE=InnoDB COMMENT='学习会话明细表';
这个模型解决了两个常见问题:
- 既能对外快速查询当前进度,也保留了足够的底层事实数据。
- 后续无论做拖拽识别、挂机识别、多端冲突分析,都会有原始数据可回放。
二、为什么学习进度不能每秒写库,而要走“心跳 + 缓存聚合”
2.1 前端心跳协议设计
学习进度最常见的反模式,是播放器每秒直接调一次接口写数据库。这样实现简单,但一旦并发起来,数据库会被大量小更新压垮,而且网络抖动会直接放大数据不一致问题。
织码在线教育系统采用的是 15 秒心跳 + 关键事件补报 的策略:
start:开始学习会话heartbeat:定时上报位置、倍速、设备类型等end:离开页面、切章节、播放器关闭时主动结束会话
前端实现示例如下:
/**
* 学习进度心跳上报
*/
class StudyHeartbeat {
constructor(courseId, chapterId, deviceType) {
this.courseId = courseId
this.chapterId = chapterId
this.deviceType = deviceType
this.lastPosition = 0
this.playbackRate = 1
this.timer = null
this.heartbeatInterval = 15000
}
onTimeUpdate(currentTime) {
this.lastPosition = Math.floor(currentTime)
}
onRateChange(rate) {
this.playbackRate = rate
}
start() {
this.report('start')
this.timer = setInterval(() => {
this.report('heartbeat')
}, this.heartbeatInterval)
}
async report(type) {
const payload = {
courseId: this.courseId,
chapterId: this.chapterId,
position: this.lastPosition,
deviceType: this.deviceType,
playbackRate: this.playbackRate,
clientTime: Date.now(),
type
}
try {
await api.post('/api/study/heartbeat', payload)
} catch (e) {
// 上报失败先落本地缓存,等待后续补发
this.cacheFailedReport(payload)
}
}
async stop() {
clearInterval(this.timer)
await this.report('end')
}
}
这里有两个设计细节非常关键:
- 不是只有心跳才更新进度。开始播放、结束播放、切章节、切后台这些关键动作,都应该作为事件单独上报。
- 客户端时间要一并上送。后端在做迟到请求处理、重放排序和幂等控制时,需要用到客户端时间做辅助判断。
2.2 后端实时聚合:Redis 负责吃掉高频写
后端收到心跳后,不会每次都更新 MySQL,而是先写 Redis,利用 Redis 做实时进度聚合。典型键设计如下:
study:session:{userId}:{courseId} -> 当前学习会话上下文
study:position:{userId}:{courseId} -> 最近一次播放位置
study:duration:{userId}:{courseId} -> 当前累计有效学习时长
study:chapter:{userId}:{chapterId} -> 当前章节实时进度
study:dedup:{requestId} -> 幂等去重标记
其中最关键的是:心跳不是简单 set position,而是根据本次位置和上次位置之间的差值,决定是否累计有效时长。
/**
* 学习心跳处理服务
*/
@Service
public class StudyHeartbeatService {
private static final String STUDY_POSITION_KEY = "study:position:";
private static final String STUDY_DURATION_KEY = "study:duration:";
private static final String STUDY_SESSION_KEY = "study:session:";
private static final String STUDY_DEDUP_KEY = "study:dedup:";
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void handleHeartbeat(HeartbeatDTO dto, Long userId) {
// 1. 幂等去重,避免客户端重试导致重复累计
String dedupKey = STUDY_DEDUP_KEY + dto.getRequestId();
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 10, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(first)) {
return;
}
String positionKey = STUDY_POSITION_KEY + userId + ":" + dto.getCourseId();
String durationKey = STUDY_DURATION_KEY + userId + ":" + dto.getCourseId();
String sessionKey = STUDY_SESSION_KEY + userId + ":" + dto.getCourseId();
if ("start".equals(dto.getType())) {
initSession(sessionKey, dto, userId);
return;
}
int lastPos = Optional.ofNullable(redisTemplate.opsForValue().get(positionKey))
.map(Integer::parseInt)
.orElse(dto.getPosition());
if ("heartbeat".equals(dto.getType())) {
int delta = dto.getPosition() - lastPos;
// 正向推进且不超过心跳窗口,视为有效学习
if (delta > 0 && delta <= 15) {
redisTemplate.opsForValue().increment(durationKey, delta);
}
redisTemplate.opsForValue().set(positionKey,
String.valueOf(dto.getPosition()), 2, TimeUnit.HOURS);
syncChapterProgress(dto, userId, delta);
return;
}
if ("end".equals(dto.getType())) {
endSession(userId, dto);
}
}
}
这段逻辑背后的核心思想是:
- 高频请求先落缓存,避免数据库承担细粒度更新。
delta合理时才累加有效学习时长,不让明显异常的数据直接进入统计。- 幂等键保护能避免客户端重试导致同一心跳被重复累计。
2.3 批量落库:把实时性和成本做平衡
课程聚合进度最终还是要进入 MySQL,但不需要每次心跳都写。织码在线教育系统采用定时任务按固定频率批量刷盘:
/**
* 每60秒将 Redis 聚合结果批量刷入 MySQL
*/
@Scheduled(fixedRate = 60000)
public void batchFlushToMySQL() {
Set<String> sessionKeys = redisTemplate.keys(STUDY_SESSION_KEY + "*");
if (CollUtil.isEmpty(sessionKeys)) {
return;
}
List<UserCourseProgress> batchUpdate = new ArrayList<>();
for (String key : sessionKeys) {
StudySession session = loadSession(key);
int duration = loadValidDuration(session.getUserId(), session.getCourseId());
int totalVideoDuration = courseService.getCourseTotalDuration(session.getCourseId());
int percent = totalVideoDuration > 0
? Math.min(100, duration * 100 / totalVideoDuration)
: 0;
UserCourseProgress progress = new UserCourseProgress();
progress.setUserId(session.getUserId());
progress.setCourseId(session.getCourseId());
progress.setCurrentChapterId(session.getChapterId());
progress.setCurrentPosition(session.getCurrentPosition());
progress.setValidDuration(duration);
progress.setProgressPercent(percent);
progress.setLastStudyTime(new Date());
progress.setStatus(percent >= 100 ? 2 : 1);
batchUpdate.add(progress);
}
if (!batchUpdate.isEmpty()) {
progressMapper.batchUpsert(batchUpdate);
}
}
这样设计的收益很直接:
- 页面查进度时可以优先读 Redis,实时性更好。
- 数据库只接收批量写,避免海量小事务。
- 异常退出场景下,仍然能依赖最近一次心跳保住大部分学习进度。
—
三、断点续学怎么做,才能既不断点又防刷进度
3.1 断点续学真正依赖的是章节级位置,而不是课程百分比
如果系统只记录课程级 progress_percent,用户重新打开课程时,播放器并不知道应该跳到哪一章、哪一秒。因此断点续学最小可恢复单元一定是:
- 当前章节
chapter_id - 当前章节位置
position - 当前章节完成状态
complete_status
典型恢复逻辑如下:
/**
* 获取断点续学位置
*/
public ResumePositionDTO getResumePosition(Long userId, Long courseId) {
// 1. 先查课程级聚合进度,定位最近学习章节
UserCourseProgress progress = progressMapper.selectByUserAndCourse(userId, courseId);
if (progress == null) {
CourseChapter firstChapter = chapterMapper.selectFirstChapter(courseId);
return ResumePositionDTO.builder()
.chapterId(firstChapter.getId())
.position(0)
.build();
}
// 2. 再查章节级进度,定位章节内断点
UserChapterProgress chapterProgress = chapterProgressMapper
.selectByUserAndChapter(userId, progress.getCurrentChapterId());
int position = chapterProgress != null ? chapterProgress.getPosition() : 0;
// 3. 如果当前章节已完成,则自动跳到下一个未完成章节
if (chapterProgress != null && chapterProgress.getCompleteStatus() == 1) {
CourseChapter next = chapterMapper.selectNextUncompleted(
courseId, progress.getCurrentChapterId(), userId);
if (next != null) {
return ResumePositionDTO.builder()
.chapterId(next.getId())
.position(0)
.build();
}
}
return ResumePositionDTO.builder()
.chapterId(progress.getCurrentChapterId())
.position(position)
.build();
}
这里的关键不是简单返回最后位置,而是让播放器恢复到业务上真正合理的下一个学习点。
3.2 防快进拖拽:为什么必须做位置变化合理性校验
在线教育最典型的作弊方式之一,就是拖拽进度条快速“刷完”视频。如果系统只根据当前位置判定进度,用户拖到视频末尾就能瞬间完成章节,这样的进度数据没有任何可信度。
织码在线教育系统在这一块采用的是 位置变化合理性校验:
- 正常播放时,两次心跳之间的位置变化应接近心跳周期。
- 回看视频允许位置回退。
- 如果短时间内位置跳跃过大,则判定为疑似拖拽,不计入有效时长。
/**
* 防快进检测:校验进度变化是否合理
*/
@Service
public class ProgressValidationService {
private static final int HEARTBEAT_INTERVAL_SECONDS = 15;
private static final int MAX_ACCEPTABLE_DELTA = 20;
public boolean validateProgressForSpeedDrag(Long userId, Long courseId,
int currentPosition, int lastPosition) {
int delta = currentPosition - lastPosition;
// 正常播放
if (delta > 0 && delta <= MAX_ACCEPTABLE_DELTA) {
return true;
}
// 回看允许
if (delta < 0) {
return true;
}
// 异常快进
if (delta > MAX_ACCEPTABLE_DELTA) {
log.warn("用户{}课程{}疑似快进拖拽: 上次位置{}秒→当前位置{}秒, 变化{}秒",
userId, courseId, lastPosition, currentPosition, delta);
return false;
}
return true;
}
}
更进一步,实际线上通常不会只靠一个 delta 判定,还会联动以下信号:
- 当前页面是否前台可见
- 是否处于静音或切后台状态
- 当前倍速是否超出平台允许阈值
- 是否连续多个心跳都出现不合理跳变
也就是说,平台真正要防的不是“拖动行为本身”,而是“借拖动行为伪造有效学习时长”。
3.3 原始时长和有效时长要分开记
为了让进度系统更可解释,建议会话表同时记录两种时长:
raw_duration:自然流逝的会话时长valid_duration:通过校验后真正计入学习结果的时长
这样做的价值很大:
- 便于后续做反作弊复盘
- 能区分“在线时长”和“有效学习时长”
- 对合规培训和考核场景尤其重要,因为最终考核通常只认有效时长
四、多端切换时,进度为什么最容易算乱
4.1 多端切换下,位置和时长不能用同一套合并规则
用户在 PC、App、小程序之间切换学习几乎是常态,这时候最容易犯的两个错误是:
- 后一次写入直接覆盖前一次进度,导致位置回退。
- 不区分时间重叠,直接把多个端的时长相加,导致总学习时长虚高。
织码在线教育系统的策略是:
- 当前位置以最新时间戳优先
- 有效学习时长按时间段合并去重
位置是一个时间点状态,时长是一段时间区间,这两者本来就不该共用同一种 merge 规则。
4.2 当前位置合并:以最新学习会话为准
/**
* 多端进度合并:当前位置以最新学习会话为准
*/
public UserCourseProgress mergeMultiDeviceProgress(Long userId, Long courseId) {
List<StudySessionLog> sessions = sessionLogMapper.selectByUserAndCourse(userId, courseId);
if (CollUtil.isEmpty(sessions)) {
return null;
}
sessions.sort(Comparator.comparing(StudySessionLog::getEndTime).reversed());
StudySessionLog latest = sessions.get(0);
UserCourseProgress merged = new UserCourseProgress();
merged.setUserId(userId);
merged.setCourseId(courseId);
merged.setCurrentChapterId(latest.getChapterId());
merged.setCurrentPosition(latest.getEndPosition());
merged.setLastStudyTime(latest.getEndTime());
int totalDuration = calculateEffectiveDuration(sessions);
merged.setValidDuration(totalDuration);
int totalVideoDuration = courseService.getCourseTotalDuration(courseId);
merged.setProgressPercent(
totalVideoDuration > 0 ? Math.min(100, totalDuration * 100 / totalVideoDuration) : 0
);
progressMapper.upsert(merged);
return merged;
}
4.3 有效时长合并:重叠区间只算一份
当同一用户在多个端同时学习时,会话之间很可能出现时间重叠。如果直接 sum(valid_duration),结果一定会偏大。因此需要做区间合并:
/**
* 合并有效学习时长:重叠时间段只算一份
*/
private int calculateEffectiveDuration(List<StudySessionLog> sessions) {
List<StudySessionLog> valid = sessions.stream()
.filter(s -> s.getIsValid() == 1)
.sorted(Comparator.comparing(StudySessionLog::getStartTime))
.collect(Collectors.toList());
int totalDuration = 0;
long lastEnd = 0;
for (StudySessionLog session : valid) {
long start = session.getStartTime().getTime() / 1000;
long end = session.getEndTime() != null
? session.getEndTime().getTime() / 1000
: start + session.getValidDuration();
if (start >= lastEnd) {
totalDuration += (end - start);
lastEnd = end;
} else if (end > lastEnd) {
totalDuration += (end - lastEnd);
lastEnd = end;
}
}
return totalDuration;
}
这个算法的核心意义是:
- 防止用户双端同时在线时,时长被重复累计
- 保证最终时长更接近真实有效学习时长
- 为后续学习行为分析保留更稳定的统计口径
—
五、一个真正能上线的学习追踪系统,还缺不了哪些底层能力
5.1 幂等、补偿和过期会话回收
学习追踪系统不是一次成功就算完的接口,而是一条长链路,任何一段都可能因为网络、客户端退出、服务重试而丢数据或重复数据。因此除了主流程,至少还要补上三类能力:
- 幂等控制:避免重复心跳重复累计时长
- 补偿机制:客户端缓存失败心跳,下次重放
- 过期会话回收:长时间没有心跳的会话,需要自动收口并落库
否则,最终最常见的线上问题会是:
- 用户说我明明学了,怎么进度没变
- 运营说为什么学习时长偶尔会翻倍
- 开发排查时发现会话既没正确结束,也没被系统补偿
5.2 可观测性:没有指标,排障几乎不可能
学习追踪系统要稳定运行,必须有足够的监控指标。至少要覆盖:
- 每分钟心跳请求量
- 心跳处理成功率、失败率
- Redis 活跃会话数
- 批量落库耗时和失败次数
- 进度回退次数
- 快进拖拽命中次数
- 多端冲突合并次数
只有把这些指标建起来,系统出现进度丢失、统计不准、状态异常时,才能快速定位问题是在前端播放器、心跳接口、缓存聚合还是落库任务。
5.3 可扩展性:后续还能接哪些能力
如果学习进度引擎设计得足够清晰,后续其实还能自然延伸出很多能力:
- 学习活跃度分析
- 学习卡顿与播放失败率统计
- 章节热力图和回看热点分析
- 课程完成率与流失节点分析
- 培训考核、学分结算、合规审计联动
换句话说,学习追踪系统不仅是播放器的一个功能模块,它其实还是课程数据分析和培训运营体系的数据底座。
六、总结:学习进度做不好,后面的培训统计和考核都会失真
学习进度追踪真正的难点,从来不在于把播放器时间写进数据库,而在于如何把学习行为建模成一套稳定可信的状态同步系统。织码在线教育系统在这一块的核心设计可以概括为:
- 分层建模:会话事实层、章节进度层、课程聚合层分离,避免单字段承载全部语义。
- 缓存聚合:前端 15 秒心跳,后端 Redis 实时聚合,MySQL 批量落库,兼顾实时性与成本。
- 有效时长校验:通过位置变化合理性、防拖拽、防挂机等机制,保证统计的是“真实学习”。
- 多端一致性:当前位置以最新时间戳为准,有效时长按时间段去重,确保跨端学习结果稳定可信。
- 可恢复可审计:通过断点续学、会话明细、补偿机制和监控指标,让系统既服务用户体验,也服务培训统计与合规分析。
如果你正在做企业培训平台、在线课堂、合规学习平台或考试系统,那么学习进度模块一定要按“引擎级能力”来设计,而不是把它当成一个普通字段存储问题处理。只有把这套底层机制打稳,后续的学习分析、培训考核和运营决策才有可信的数据基础。
如需私有化部署报价、远程产品演示,可访问官网https://www.weavecodes.com/,私信作者领取企业落地案例。

431

被折叠的 条评论
为什么被折叠?



