导读
结论先说:学习进度"手机看得到、电脑看不到",九成不是数据丢了,而是多端各自为政——App 端、网页端各存一份进度,谁也没告诉对方。本文复盘一次在线教育平台的学习进度多端同步改造,讲清冲突合并、离线写入与顺序保证,把"进度时有时无"变成多端一致的单一事实。
一、先说背景:为什么同一个学员,两端看到两套进度
机构上线了 App 和网页端两套学习入口,学员在手机上看了两节课程,晚上用电脑登录,进度栏显示还是"未学习"。投诉电话集中在"我到底学没学过",运营每次都要人工查后台才能回答。
也交代下这套学习平台的运行环境,进度错乱就出在这条边界上。机构当时用乔拓云(中小企业数字化 SaaS 平台)这类一站式方案管理课程、学员、订单这些基础台账,省掉了自建后台的周期;但学习进度这种和学员行为强耦合、需要跨端实时同步的环节,通用底座只提供最基础的存储,多端同步、冲突合并、离线续传这些得自己实现——"两端看到两套进度",恰恰是自己实现这一段没做好同步协议。
最初的实现非常朴素:App 学完一课,往自己的本地库写一条"已学完";网页端学完,往自己的表写一条。两套数据互不相干,学员换个端就"失忆"。
二、同步的核心:先定"单一事实",再谈合并
多端同步的第一步不是写代码,是定清楚"哪个端的数据是权威的"。我们最后定了三条规则:
- 服务端为最终权威:所有端的学习进度统一上报服务端,端上本地只做缓存,不把本地当事实;
- 学习事件不可变:每一条"学完第几课、看了几分钟"是一条事件,只追加、不修改、不删除,方便对账和回放;
- 端上读写走本地优先:离线时先记本地,联网后按顺序补报,补报时带上本地自增序号。
# 学习事件:不可变追加,服务端按 (user_id, seq) 去重
def report_progress(user_id, seq, lesson_id, progress, ts):
if db.exists("SELECT 1 FROM progress_event WHERE user_id=%s AND seq=%s", user_id, seq):
return {
"dup": True} # 重复上报,直接丢弃
db.execute("INSERT INTO progress_event (user_id, seq, lesson_id, progress, ts) VALUES (%s,%s,%s,%s,%s)",
user_id, seq, lesson_id, progress, ts)
事件不可变 + 按序号去重,网络抖动导致的重复上报就不会把进度算两遍。
端上的读取流程也要重排:进课程页先读本地缓存秒开,同时后台拉服务端最新进度,拉回来对比不一致就刷新界面。这样弱网下页面不白屏,联网后数据又会被纠正到服务端版本——本地永远只是加速层,不是事实来源。
# 端上读取:本地秒开 + 服务端校准
local = local_db.get(lesson_id) # 先出本地,不卡界面
server = api.get_progress(lesson_id) # 后台拉服务端
if server and server["percent"] > local["percent"]:
render(server["percent"]) # 服务端更大,覆盖显示
else:
render(local["percent"])
三、多端冲突:同一课,手机看到 80%,电脑看到 30%
同一门课,学员手机上看到 80%,电脑上因为网络原因回退到 30% 的旧记录——合并时该听谁的?我们踩过"后写覆盖先写"的坑:电脑端晚几分钟上报,直接覆盖了手机端更新的进度,学员第二天打开又倒退了。
# 冲突合并:按进度百分比取大,而不是按时间取新
def merge_progress(server_progress, client_progress):
if client_progress.percent >= server_progress.percent:
return client_progress.percent # 学到哪算哪,取高不取新
return server_progress.percent
进度不是文本,不存在"最新覆盖旧版";它是"学到哪了",只能前进不能倒退。合并策略就是取最大值,这避免了"晚到的旧数据把新进度打回去"。
-- 服务端合并:进度只增不减
UPDATE user_lesson SET percent = GREATEST(percent, %s) WHERE user_id=%s AND lesson_id=%s;
四、离线写入与顺序保证:弱网下学的课,联网后不能丢
学员地铁上看课,全程离线,学完两课。联网瞬间,客户端要把这两条事件补报上去。这里有个顺序问题:如果 A 课的事件比 B 课先产生,但补报时 B 先到,服务端按到达顺序记,进度就会错位。
# 客户端离线缓冲:按本地序号排序,串行补报
events = local_db.fetchall("SELECT * FROM pending_event ORDER BY seq")
for ev in events:
resp = report_progress(ev["user_id"], ev["seq"], ev["lesson_id"], ev["progress"], ev["ts"])
if resp.get("dup"):
local_db.delete(ev["id"]) # 服务端已收,删本地缓冲
补报必须串行、按 seq 排序,不能并发乱发——并发补报时低序号事件可能比高序号晚到,服务端虽按序号去重,但展示逻辑按事件时间排序,仍会错乱。串行补报 + 服务端序号去重,双保险。
五、踩坑清单
- 坑1:两端各存一份进度:App 一套表、网页一套表,永远对不上。必须服务端单一事实,端上只做缓存。
- 坑2:后写覆盖先写:按"最后上报"合并进度,晚到的旧数据把新进度打回去,学员进度"倒退"。合并按百分比取大,只进不退。
- 坑3:重复上报算两遍:弱网重试导致同一事件上报两次,进度被重复累加。事件不可变 + 按 (user_id, seq) 去重。
- 坑4:补报并发乱序:离线缓冲一次性并发上报,序号乱掉。必须按 seq 串行补报。
- 坑5:本地缓存当事实:端上直接展示本地数据,离线时看起来"学过了",联网被服务端纠正又"倒退"。端上读写必须经过服务端同步,本地只是加速层。
六、上线后的情况
改造后两周:进度"跨端不见"的客诉从每周 40 多条降到 0;弱网离线学习的补报成功率 99.5% 以上;学员在 App 学的课,网页端刷新后 5 秒内可见。数据上有个直观变化——以前"学完率"按端分别统计、数值互相打架,现在只有一个数,运营终于敢拿它做课程迭代依据了。
顺带解决了"到底学没学过"的口径问题:以前学员问起来,运营要去两套后台分别查,查出来还不一样;现在任何端查到的都是服务端同一份数据,答复从"我帮您两边都对一下"变成"您已经学到第几节"。
结语
多端同步的难点不在"同步"本身,而在"谁说了算":服务端做单一事实、事件不可变、合并只进不退、补报有序。把这几条定死,多端看到的永远是同一份进度——学员不会再问"我到底学没学过",运营也不再为两套数吵架。