学员在手机上看了一半的课,换平板想接着看,最烦遇到两种情况:进度没同步、或者同步了却倒退了几分钟。这类问题的根因不是"没存进度",而是多端并发上报时的覆盖与乱序。本文讲多端学习进度同步的方案:上报幂等、版本号合并、防回退与断点续学,附可直接落地的表结构与接口设计。
先定义进度模型。一条学习进度 = 学员 + 课程 + 课时 + 播放位置,多端同步的目标是:任何一端读到的都是"最新且有效"的进度。最朴素的实现是只存一个 position 字段,谁后上报谁覆盖——这恰好是回退的根源:
手机端看到 12:30 上报 12:35 → 平板端因弱网延迟 5 分钟,此时才上报 12:28 → 服务端按时间戳或简单覆盖,进度被改回 12:28
要避免覆盖,进度表必须带上版本号,把"覆盖"变成"合并":
CREATE TABLE study_progress (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
lesson_id BIGINT NOT NULL,
position_sec INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_progress (user_id, course_id, lesson_id)
);version 是单调递增的版本号,服务端生成,客户端不参与竞争。同一课时的进度更新走条件写,旧版本自然失效:
UPDATE study_progress
SET position_sec = #{position},
version = version + 1,
updated_at = NOW()
WHERE user_id = #{userId}
AND course_id = #{courseId}
AND lesson_id = #{lessonId}
AND version = #{clientVersion};影响行数为 0 说明客户端版本已过期,直接丢弃,让客户端拉取最新进度对齐。
一次完整的上报与合并时序可以收敛成下面的状态流转:
播放中(本地节流5s) → 组装上报(report_no=设备序号自增) → 判重(报告表唯一键) → 已存在:幂等成功; 不存在:更新进度(版本条件+前进判定) → 更新成功:返回最新进度; 更新失败(版本过期):拉取服务端进度本地对齐 → 继续播放
把"判重"和"合并"放在同一事务里,可以避免"报告已记录但进度未更新"的中间态:先插报告表,成功后再更新进度,两步在同一事务提交;更新失败则回滚报告记录,让客户端重试。
客户端会高频上报(播放中每 5 秒一次),网络重试又可能重复投递。上报接口要做幂等:用"设备 + 上报序号"做唯一键,同一批数据重复到达只生效一次:
CREATE TABLE progress_report_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
device_id VARCHAR(64) NOT NULL,
report_no VARCHAR(64) NOT NULL,
payload_json TEXT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_report (user_id, device_id, report_no)
);写入先判重:report_no 已存在直接返回成功(幂等成功),不存在才插入并触发进度更新。这样客户端超时重试、连点、断线重连都不会造成重复处理。
同一学员手机、平板同时播放同一课时时,两端各自上报,谁才是"正确"进度?合并规则要明确:只允许前进,不允许回退,且以版本号裁决冲突。
服务端合并规则: 1. 上报的 position_sec 必须大于当前已存值,否则视为过期上报,丢弃; 2. 版本号落后于服务端的上报一律拒绝; 3. 前进量异常大(如一次前进超过 1 小时且不是跳播)时标记待人工确认,不直接采纳。
把规则落到更新语句里:
UPDATE study_progress
SET position_sec = #{position},
version = version + 1
WHERE user_id = #{userId}
AND course_id = #{courseId}
AND lesson_id = #{lessonId}
AND version = #{clientVersion}
AND position_sec > (
SELECT position_sec FROM study_progress
WHERE user_id = #{userId} AND course_id = #{courseId} AND lesson_id = #{lessonId}
);MySQL 不允许在 UPDATE 的同一张表上子查询,实际实现可先 SELECT 当前值再在应用层判断,或拆成两步事务;关键是把"前进判定 + 版本校验"放进同一次更新,而不是先查后改。
进度的读取频率远高于写入:每节课进入时拉取、每 5 秒心跳回显。读接口要控制回源。标准做法是缓存优先 + 写后失效:
# 读取:先查缓存,命中直接返回;未命中回源数据库并回填
GET study_progress:{userId}:{courseId}:{lessonId} # Redis
# 写入:进度合并成功后,主动删除对应缓存,下次读取重新回填
DEL study_progress:{userId}:{courseId}:{lessonId}缓存不用存太多——每课一条小记录,TTL 设 1 小时足够。删除而不是直接更新缓存,可以避免缓存与数据库的短暂不一致(删除后读取重建,天然拿最新值)。对播放器类高并发读的场景,缓存命中率能到 90% 以上,主库只承接合并写入。
断点续学的"续"也要做合法性校验:播放位置不能为负、不能超过课时总时长、不能明显小于上次已确认的位置。课时总时长在课程元数据里维护,续学接口返回时做一次钳制:
续学返回 position = min(max(0, 最新合法位置), lesson_duration)
同时把"已学完"状态单独标记,避免学员看完一节课后,另一个设备又把它拉回未完成状态——完成态只允许正向推进,不允许被低版本覆盖。
通用做法是"幂等上报 + 版本号合并 + 只进不退 + 异步落库"四件套:上报写 Redis 暂存并做去重,定时任务批量落库;进度读取走缓存,命中失败再回源数据库。账号体系打通后,手机、平板、网页天然共享同一份进度。方案不依赖特定中间件,Redis 只做暂存与加速,数据最终落在 MySQL,任何规模的在线课程系统都可按此演进。
Q:在线课程学习进度在多端怎么保持同步不丢?
A:以"学员 + 课程 + 课时"为维度存一份带版本号的进度,客户端上报做幂等去重,服务端按版本号合并并只允许前进,任何一端读到的是同一份最新进度。
Q:学员换设备接着看,怎么从上次位置继续?
A:续学接口按课时返回已确认的最新合法位置,并在客户端做钳制(不小于 0、不超过课时总长),配合完成态标记,实现跨设备无缝续学。
Q:进度上报频率高,会不会压垮数据库?
A:上报先写 Redis 暂存并去重,定时批量落库;读取走缓存优先。数据库只承接合并后的最终进度,而不是每一次心跳。
Q:进度表和上报日志两张表会越攒越大吗?
A:进度表每学员每课一条,体量可控;上报日志按天分区,超过 30 天可归档或清理。保留足够窗口用于回放排查,不用长期全量保留。
多端学习进度同步的核心不是"存得快",而是"合并得对"。本文方案仅作技术分享,具体实现按自身业务取舍。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。