在线教育里"学到哪了"看似只是存个播放位置,真做多端同步才发现坑很多:手机看到一半换电脑进度没跟上、弱网下进度上报乱序导致新进度被旧数据覆盖、离线看完联网后进度反而回退。本文案例来自我们为一款在线教育产品做学习进度模块的改造实践,进度合并与一致性逻辑均为自研。下面把数据模型、乱序合并、断点续学和边界防护这条链路讲清楚,并复盘五个踩坑点。
学习进度是一个会被多端、多次、乱序写入的数据,难点不在存,而在多个来源并发写同一条记录时如何收敛成正确值:
难点 | 典型场景 | 错误后果 |
|---|---|---|
多端并发 | 手机、平板、电脑交替学习 | 后写入的旧进度覆盖新进度 |
乱序上报 | 弱网重试,旧请求比新请求晚到 | 进度回退,用户"被回到前面" |
离线补报 | 断网看完,联网后批量上报 | 本地与云端冲突,进度错乱 |
异常数据 | 改本地参数、倍速刷课 | 进度超过视频时长,数据失真 |
核心原则先立住:学习进度对同一节课是单调不减的,合并时永远不能让一个更旧、更小的进度覆盖更新、更大的进度。所有设计都围绕这条原则展开。
不要只存一个裸的播放秒数,至少要带上能判断新旧、能幂等合并的字段:
position:播放位置(秒),核心进度值epoch / version:单调递增的版本号,每次本地推进都自增,用于判断谁更新client_ts:客户端产生该进度的时间戳,辅助排序updated_at:服务端最后写入时间req_id:上报请求唯一号,用于幂等去重前端在播放过程中按固定节奏(如每 5 秒或章节切换时)产生一条进度,并先写入本地,保证断网也不丢:
class ProgressTracker {
constructor(courseId, lessonId, storage) {
this.key = `progress:${courseId}:${lessonId}`;
this.storage = storage;
this.local = storage.get(this.key) || { position: 0, epoch: 0 };
}
// 只允许本地进度向前推进
report(position, duration) {
if (position < 0 || position > duration + 1) return; // 边界:不允许超出时长
if (position <= this.local.position) return; // 单调不减,回退拖拽不降低最高进度
this.local = { position: Math.floor(position), epoch: this.local.epoch + 1, ts: Date.now() };
this.storage.set(this.key, this.local);
this.scheduleSync(); // 防抖批量上报
}
}服务端是进度正确性的最终裁决者。绝不能用"收到就覆盖"的裸 update,而要在 SQL 条件里判断:只有上报进度比当前记录更"新"(位置更大、或位置相同时版本更大)才允许写入:
-- 单调推进:只有新位置严格大于已存位置时才更新,从根上杜绝乱序回退
UPDATE lesson_progress
SET position = #{position},
epoch = #{epoch},
client_ts= #{clientTs},
updated_at = NOW()
WHERE user_id = #{userId}
AND lesson_id = #{lessonId}
AND (
#{position} > position
OR (#{position} = position AND #{epoch} > epoch)
);
-- affected rows=0 说明到达的是更旧的进度,直接丢弃,不覆盖这条条件更新是整个同步的核心:无论请求以什么顺序到达、重试多少遍,最终留下的一定是最大的有效进度,乱序和重复上报被同一条 SQL 挡掉。首次没有记录时用 insert ... on duplicate key update 并带上同样的比较条件,保证"插入"和"更新"路径一致。
这里要特别说明为什么排序依据选"位置加版本",而不是直接比客户端时间戳。客户端时间并不可靠:设备时钟可能不准、用户可能手动改时间、不同设备之间存在分钟级偏差,如果简单按 client_ts 做"最后写入获胜",一台时钟偏快的旧设备反而会用旧进度盖掉新进度。而播放位置是业务语义上的客观进度,配合本地单调自增的 epoch,不依赖设备时钟也能判断新旧;client_ts 只作为排查问题时的辅助信息,不参与谁覆盖谁的裁决。这个选择和分布式系统里"不能用本地时间定因果顺序"是同一个道理,只是落在了学习进度这个具体场景。
断网学习时进度只存在本地,联网后需要和云端合并。合并不能简单地"以本地为准"或"以云端为准",而要按同一套单调规则取两者的更优值:
def merge_progress(user_id, lesson_id, local, db):
remote = db.get_progress(user_id, lesson_id)
if remote is None:
db.upsert_progress(user_id, lesson_id, local)
return local
# 比较位置,位置相同比版本,取更新的一方
if (local["position"], local["epoch"]) > (remote["position"], remote["epoch"]):
db.advance_progress(user_id, lesson_id, local) # 走条件更新
winner = local
else:
winner = remote # 云端更新,回写到本地
return winner断点续学的体验细节是:用户进入课程时先用本地进度秒开定位(不等网络),后台拉取云端进度做一次合并,若云端更新则平滑跳转到更靠后的位置并提示"已为你同步到最新学习位置",既保证响应快又保证最终一致。
问题:弱网重试时旧请求晚到,把新进度盖掉。
解决:条件更新带"位置/版本更大才写入",服务端裁决新旧。
问题:位置相同或上报时间接近时无法排序,也无法幂等。
解决:进度带 epoch 版本、client_ts 和 req_id,幂等去重。
问题:另一台设备学得更靠后,被本地旧进度覆盖。
解决:联网后按单调规则双向合并,取更优值并回写本地。
问题:改本地参数让进度瞬间超过视频时长。
解决:前后端都校验 position 不超过时长,异常进度直接拒绝。
问题:用户拖动进度条复习,本地逻辑误拦或写入更小值。
解决:区分"最高进度"和"当前播放点",最高进度单调不减,播放点可自由回退。
多端学习进度同步的本质是一个最终一致的数据收敛问题:数据模型带上位置、版本和请求号,服务端用条件更新保证进度单调不减、乱序不回退,离线场景按同一套规则做双向合并,再用边界校验挡住异常数据。判断实现是否可靠,就看三句话:乱序到达会不会回退、多端交替会不会互相覆盖、断网学习会不会丢失。这三点闭环,用户在任何设备上都能稳稳地"接着上次继续学"。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。