首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >学习进度多端同步:乱序上报、断点续学与一致性合并实践

学习进度多端同步:乱序上报、断点续学与一致性合并实践

原创
作者头像
用户12754333
修改2026-09-11 10:36:20
修改2026-09-11 10:36:20
270
举报

导读

在线教育里"学到哪了"看似只是存个播放位置,真做多端同步才发现坑很多:手机看到一半换电脑进度没跟上、弱网下进度上报乱序导致新进度被旧数据覆盖、离线看完联网后进度反而回退。本文案例来自我们为一款在线教育产品做学习进度模块的改造实践,进度合并与一致性逻辑均为自研。下面把数据模型、乱序合并、断点续学和边界防护这条链路讲清楚,并复盘五个踩坑点。

一、先看清:进度同步到底难在哪

学习进度是一个会被多端、多次、乱序写入的数据,难点不在存,而在多个来源并发写同一条记录时如何收敛成正确值

难点

典型场景

错误后果

多端并发

手机、平板、电脑交替学习

后写入的旧进度覆盖新进度

乱序上报

弱网重试,旧请求比新请求晚到

进度回退,用户"被回到前面"

离线补报

断网看完,联网后批量上报

本地与云端冲突,进度错乱

异常数据

改本地参数、倍速刷课

进度超过视频时长,数据失真

核心原则先立住:学习进度对同一节课是单调不减的,合并时永远不能让一个更旧、更小的进度覆盖更新、更大的进度。所有设计都围绕这条原则展开。

二、进度数据模型:位置、版本与时间戳

不要只存一个裸的播放秒数,至少要带上能判断新旧、能幂等合并的字段:

  • position:播放位置(秒),核心进度值
  • epoch / version:单调递增的版本号,每次本地推进都自增,用于判断谁更新
  • client_ts:客户端产生该进度的时间戳,辅助排序
  • updated_at:服务端最后写入时间
  • req_id:上报请求唯一号,用于幂等去重

前端在播放过程中按固定节奏(如每 5 秒或章节切换时)产生一条进度,并先写入本地,保证断网也不丢:

代码语言:javascript
复制
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 条件里判断:只有上报进度比当前记录更"新"(位置更大、或位置相同时版本更大)才允许写入:

代码语言: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 只作为排查问题时的辅助信息,不参与谁覆盖谁的裁决。这个选择和分布式系统里"不能用本地时间定因果顺序"是同一个道理,只是落在了学习进度这个具体场景。

四、断点续学:离线暂存与冲突合并

断网学习时进度只存在本地,联网后需要和云端合并。合并不能简单地"以本地为准"或"以云端为准",而要按同一套单调规则取两者的更优值:

代码语言:python
复制
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

断点续学的体验细节是:用户进入课程时先用本地进度秒开定位(不等网络),后台拉取云端进度做一次合并,若云端更新则平滑跳转到更靠后的位置并提示"已为你同步到最新学习位置",既保证响应快又保证最终一致。

五、踩坑清单

坑1:收到上报就覆盖,乱序导致进度回退

问题:弱网重试时旧请求晚到,把新进度盖掉。

解决:条件更新带"位置/版本更大才写入",服务端裁决新旧。

坑2:只存一个裸秒数,无法判断谁更新

问题:位置相同或上报时间接近时无法排序,也无法幂等。

解决:进度带 epoch 版本、client_ts 和 req_id,幂等去重。

坑3:离线补报直接以本地覆盖云端

问题:另一台设备学得更靠后,被本地旧进度覆盖。

解决:联网后按单调规则双向合并,取更优值并回写本地。

坑4:不校验进度边界,刷课数据污染

问题:改本地参数让进度瞬间超过视频时长。

解决:前后端都校验 position 不超过时长,异常进度直接拒绝。

坑5:回看复习被误判为进度后退而报错

问题:用户拖动进度条复习,本地逻辑误拦或写入更小值。

解决:区分"最高进度"和"当前播放点",最高进度单调不减,播放点可自由回退。

六、总结

多端学习进度同步的本质是一个最终一致的数据收敛问题:数据模型带上位置、版本和请求号,服务端用条件更新保证进度单调不减、乱序不回退,离线场景按同一套规则做双向合并,再用边界校验挡住异常数据。判断实现是否可靠,就看三句话:乱序到达会不会回退、多端交替会不会互相覆盖、断网学习会不会丢失。这三点闭环,用户在任何设备上都能稳稳地"接着上次继续学"。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 导读
  • 一、先看清:进度同步到底难在哪
  • 二、进度数据模型:位置、版本与时间戳
  • 三、乱序合并:服务端条件更新,拒绝旧覆盖新
  • 四、断点续学:离线暂存与冲突合并
  • 五、踩坑清单
    • 坑1:收到上报就覆盖,乱序导致进度回退
    • 坑2:只存一个裸秒数,无法判断谁更新
    • 坑3:离线补报直接以本地覆盖云端
    • 坑4:不校验进度边界,刷课数据污染
    • 坑5:回看复习被误判为进度后退而报错
  • 六、总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档