做视频播放器的同学对“记录用户上次看视频的进度并且从记录的时间继续观看”这个需求肯定不陌生。它看起来就是一行需求描述但真正落地的时候你会碰到存储选型、上报时机、多端同步、续播准确性、甚至播放器事件竞态这一堆问题。这篇文章我把自己在这类功能上的完整设计思路和实操代码整理出来包含了从需求拆解、技术选型、核心实现到线上踩坑的整个链路希望能帮你少走一些弯路。无论你是在做网页播放器、小程序视频模块还是基于原生播放器做客户端都可以参考这里的方案。核心思路是相通的无非就是“什么时候存、存哪里、怎么恢复、怎么避免恢复出错”。我会结合前端和服务端的常见实践来展开保证你能直接拿去用。1. 功能需求拆解与整体设计思路1.1 这个功能到底在解决什么问题先说一个很直观的场景用户晚上用手机看一部两个小时的电影看到一半切出去回微信再回来已经凌晨顺手把 App 杀了。第二天早上打开 App如果直接从头开始播用户的内心是崩溃的。做了进度记忆之后打开视频能直接回到昨天看的位置这个体验上的提升是肉眼可见的。放到产品视角来看这个功能本质上是在降低用户的内容找回成本。内容消费类的产品尤其是长视频、知识付费课程、播客、有声书用户的观看行为是跨会话的。如果一个会话结束之后用户每次都要手动拖动进度条找位置那流失率一定会增加。短视频产品对这个需求不强因为单条视频太短但对单集内容超过10分钟、需要分多次看完的产品来说这是刚需。而“继续观看”这个功能一旦做出来它还会衍生出更多价值。比如首页的“最近观看列表”、课程列表里的“学至第几节”、多端之间的进度同步这些产品功能全部依赖同一个底层能力可靠的播放进度存储。所以说这不仅仅是一个播放器的小功能它其实是一个内容平台的基础设施。1.2 一条完整的续播链路是怎么设计的我习惯把这个功能拆成四个环节采集、存储、下发、恢复。四条链路做好整个功能就立住了。采集环节要回答的问题是进度从哪来什么时候算一个有效的进度点。这里的核心不是“一直记录”而是“记录可靠的位置”。播放器在播放过程中会高频触发时间更新事件但我们不能每次都往存储里写东西。一方面存得太频繁对性能有影响另一方面很多中间状态没有意义比如用户刚播到第3秒你就存了下次打开几乎等于从头播。存储环节要回答的是进度放的哪里。是放在用户本地的浏览器 localStorage还是放在服务端数据库这是一个很关键的取舍。放本地简单但换了设备就丢放服务端复杂但能多端同步。这个我在后面会专门对比。下发环节要回答的是用户再次打开这个视频时怎么把上次的进度取回来。这里又涉及到取回来的进度是否可信比如用户上次看到80%这次打开要不要直接从80%开始不一定。很多产品会在进度超过一定比例比如95%时自动把进度归零否则下次打开用户一脸懵还得自己拖回开头。恢复环节是最后一公里也是最容易露馅的地方。用户在点击播放的那一刻播放器需要快速定位到指定时间点。但视频资源可能已经更新了总时长变了码率变了导致上次记录的时间点已经不存在需要做边界处理。详情页上显示“上次看到 33:20”点进去实际却没有精确跳到那个时间这个体验问题往往就是因为恢复环节没做好。1.3 先想清楚三个决策再动手开工之前有三个决策必须跟产品和技术负责人达成一致否则后面会反复返工。第一个决策进度归属于用户还是设备。如果产品允许用户登录那进度应该归属于用户并且要支持跨端同步。如果不需要登录比如纯粹的网页工具型视频那用本地存储就够了无非损失一点体验但省了很多服务端工作。第二个决策记录的粒度。是按视频维度记录一个 progress 数值还是按剧集维度记录多个进度。电视剧、课程这种天然有分P场景如果只按“视频ID”存一个进度用户看到第二集了记录一次再回来看第一集进度直接乱了。所以数据结构上至少要有 contentId 和 episodeId 的区分。再细一点还要区分暂停进度和播放完成状态。第三个决策用户手动结束观看和异常中断怎么区分。这里需要一套“会话”机制。比如用户点了暂停、点了退出、切后台这些是正常结束进度可以直接存但如果是 App 被系统杀掉、网页直接被关闭进度丢失就难免了。所以不能只依赖“用户主动离开”时保存更多的是依赖周期性的自动保存和心跳上报。2. 技术方案选型本地存储与服务端该选谁2.1 三种存储方案对比做这个功能常见的存储方案有三类浏览器缓存、服务端数据库、混合方案。我直接给一张对比表格方案优点缺点适用场景localStorage实现简单、零接口成本、读盘快只能存字符串、容量5MB左右、换设备就丢、用户清缓存就没了单端小工具、游客用户、无登录体系IndexedDB容量大、支持结构化数据、异步不阻塞API 繁琐、多标签页数据同步要额外处理纯前端离线视频、大型本地媒体库服务端存储多端同步、用户换设备不丢、可做数据分析需要接口设计、需要处理并发覆盖、依赖网络正式互联网产品、登录用户、多端场景混合方案体验好、容错强实现复杂度翻倍、本地和服务端数据要合并主流视频平台标配方案我实际做过的一个方案是混合式的用户未登录时写 localStorage登录之后以服务端数据为准同时用 localStorage 做本地兜底。原因是移动端网络不稳定用户在地铁里看视频突然断网服务端全挂但进度还是得能存下来等网络恢复之后再补上传。2.2 Storage 数据结构设计不管用哪种存储数据结构都要提前设计。一个标准的进度记录字段大致是这样{ userId: u_123456, deviceId: web_abc, contentId: movie_0001, episodeId: ep_002, progressSeconds: 965, durationSeconds: 5350, playbackRate: 1.5, status: playing, sessionId: sess_998811, updatedAt: 1721116773000 }字段看起来多但每一个都有用。contentId 和 episodeId 是业务定位progressSeconds 是进度durationSeconds 是当时的媒体总时长status 表示用户离开时的播放状态sessionId 用来识别播放会话updatedAt 是更新时间戳。playbackRate 可存可不存但如果是倍速观看场景存倍速可以提升恢复后的体验。有一点特别容易被忽略progressSeconds 和 durationSeconds 必须成对存。因为视频内容可能改版比如某节课原本60分钟后来被剪辑成50分钟如果只存了 progress3200 秒新时长变短了播放器直接跳到3200秒就超出了范围这时候需要用 durationSeconds 做归一化校验。2.3 上报时机timeupdate 节流、暂停、页面卸载接下来是采集端的核心逻辑什么时候上报。timeupdate事件是播放器的基础事件会在播放过程中高频触发不同浏览器和播放器 SDK 的触发频率不一样一般一秒触发好几次。如果每次触发都上报后端接口压力会非常大。所以必须做节流。我常用的策略是正常情况下每 5 秒上报一次同时记录下最后一次进度到本地变量用户点击暂停时立即上报一次用户切后台或页面进入隐藏状态时立即上报一次。暂停和页面隐藏时的上报非常关键因为这往往是用户离开视频的最后一个确定状态能抓住这个时机续播的准确率会提升很多。let lastReportTime 0; const REPORT_INTERVAL 5000; video.addEventListener(timeupdate, () { const currentTime video.currentTime; const duration video.duration; const now Date.now(); // 线性节流5秒上报一次 if (now - lastReportTime REPORT_INTERVAL) { lastReportTime now; reportProgress(currentTime, duration, timeupdate); } // 本地始终维护最新进度 latestProgress { currentTime, duration }; }); video.addEventListener(pause, () { reportProgress(video.currentTime, video.duration, pause); }); document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { reportProgress(latestProgress.currentTime, latestProgress.duration, hidden); } });这里要解释一下为什么用visibilitychange而不是beforeunload。beforeunload在移动端的兼容性很差很多浏览器根本不会触发这个事件尤其在移动端 Safari 上。而visibilitychange在切换到后台时会可靠触发拿这个事件做最后的请求发送成功率要高不少。但在用户完全关闭浏览器标签页时即便是visibilitychange也可能会丢掉最后的异步请求。真正的保底方案是用即将废弃的navigator.sendBeacon或者退而求其次请求里夹带Keep-Alive头。sendBeacon专门设计用于这种页面卸载场景数据会由浏览器在后台可靠送达页面关闭也不影响。document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { const data JSON.stringify({ videoId: currentVideoId, progressSeconds: Math.floor(video.currentTime), durationSeconds: Math.floor(video.duration), status: video.paused ? paused : playing }); navigator.sendBeacon(/api/playback/progress, data); } });3. 核心功能实现从播放器事件到进度恢复3.1 服务端接口设计正式项目里上报和获取进度应该分成两个接口上报进度接口和查询进度接口。上报接口我用 PUT 而不是 POST因为同一个用户的同一个视频进度在服务端应该是唯一的PUT 语意更准确天然幂等。接口路径可以设计成/api/v1/users/{userId}/playback/{contentId}/{episodeId}。服务端在收到上报时不能无脑覆写旧记录要带上一个乐观锁判断。UPDATE playback_progress SET progress_seconds :newProgress, duration_seconds :newDuration, status :status, session_id :sessionId, updated_at :now WHERE user_id :userId AND content_id :contentId AND episode_id :episodeId AND ( session_id :sessionId OR updated_at :now - INTERVAL 5 SECOND );这条 SQL 的逻辑是只有当本次写入来自同一会话或者上一次写入超过5秒之前才允许覆盖。也就是说如果同一用户的两个页面在同时播放一个页面刚上报完另一个页面立刻上报后上报的不会覆盖前一个因为时间差太短大概率是并发冲突而不是真正的进度推进。这能有效避免多标签页互踢的问题。查询接口就简单了按照 userId、contentId、episodeId 组合查出一条记录返回即可。正常会返回 progressSeconds、durationSeconds、status、updatedAt 这几个字段。查询时不需要做任何计算原样返回让前端自己处理边界逻辑。3.2 前端恢复进度的逻辑与策略拿到进度之后不能直接跳到那个时间点。恢复逻辑里要处理几个分支。第一个分支判断进度是否接近片尾。如果上次看到的位置已经过了总时长的95%我认为这次不是“继续看视频”而是“再看一次”所以会把进度重置为0让用户从头播放。否则用户会莫名其妙地在一个只剩两三分钟的结尾处打开视频体验非常差。这个阈值产品上可以调长视频可以设成95%短视频甚至可以设成90%核心就是别让“继续观看”变成“继续看剧尾彩蛋”。第二个分支判断视频资源是否变化。这里用 durationSeconds 做校验。如果服务端存的时长和当前媒体的时长差异超过30秒说明视频内容大概率已经更新直接重置到0。如果差异在30秒以内就直接跳到记录点因为编码参数变化可能导致时长有几秒的误差这是正常的。第三个分支判断记录点是否越界。也就是进度是否超过当前视频总时长。如果超过了就定位到总时长前10秒的位置。这个逻辑尤其重要因为很多视频平台会更新片头片尾导致时长缩短。合并成一个函数大致是这样function resolveStartTime(savedProgress, currentDuration) { // 无记录从头播 if (!savedProgress) { return 0; } const { progressSeconds, durationSeconds } savedProgress; // 上次看到的时长和当前媒体时长差距过大判定内容变更 if (currentDuration 0 Math.abs(durationSeconds - currentDuration) 30) { return 0; } // 如果上次已经看到片尾重新开始 if (durationSeconds 0 progressSeconds / durationSeconds 0.95) { return 0; } // 越界处理 if (currentDuration 0 progressSeconds currentDuration) { return Math.max(currentDuration - 10, 0); } return progressSeconds; }3.3 从点开页面到定位播放的完整流程把上面的逻辑串起来一个典型的续播流程是这样的第一步用户点开视频详情页前端同时请求视频元信息和播放进度。进度接口可以跟视频详情接口并行请求不拖慢首屏展示。第二步拿到进度后初始化播放器设置currentTime为计算后的恢复点然后调play()。第三步如果这次恢复点不是0播放器画面可以短暂显示一个“上次看到 xx:xx已为您续播”的提示。这个提示建议加否则用户有时会发现自己莫名其妙从某个时间点开始播怀疑是不是播放器坏了。还有一个细节在设置 currentTime 之前一定要等待播放器的loadedmetadata事件触发后再设置。否则在 iOS Safari 上播放器还处于加载中直接设置 currentTime 会不生效。我踩过这个坑当时排查了半天最后发现就是设置时机不对。let player new Player({ container: #app }); Promise.all([ fetchVideoInfo(videoId), fetchPlaybackProgress(videoId) ]).then(([videoInfo, progress]) { player.on(loadedmetadata, () { const startTime resolveStartTime(progress.data, player.getDuration()); player.seek(startTime); if (startTime 0) { showResumeTip(formatTime(startTime)); } player.play(); }); });4. 常见问题与排查技巧实录4.1 Safari 页面关闭时上报丢失这是移动端最典型的问题也是很多人第一次踩坑的地方。Safari 在页面进入后台几秒后就会冻结 JavaScript 执行异步请求可能根本没发出去进度就丢了。常规处理办法我前面提到了组合拳一是用visibilitychange事件这个事件在 Safari 上触发可靠二是上报请求优先用sendBeacon它的语义就是“尽力送达”不关心响应三是兜底策略每次timeupdate都在 localStorage 写一个轻量记录打开页面时先读本地再用服务端数据覆盖两者取最新。我线上实际的数据是加了 localStorage 兜底之后移动端续播成功率从76%提升到了93%。剩下的7%基本都是用户首次访问、完全没有记录的情况这个就没办法了。4.2 用户拖拽进度条导致误报正常情况下进度上报是平缓增长的。但用户有一次手动拖动了进度条从10分钟直接拖到50分钟播放器的timeupdate事件会立刻吐出新的 currentTime如果这时候触发了5秒节流的边界就会把50分钟当成新进度上报。按产品逻辑来说用户拖到50分钟下次从50分钟继续这本身没有错。但问题在于拖动过程中如果用户在进度条上拖来拖去最终停在30分钟但中间有一次上报停在了50分钟服务端就存了50分钟下次用户打开直接跳到50分钟这就错了。解决办法是拖拽过程中只更新本地状态不触发上报。监听seeking事件将节流计时器重置并设置一个标记位监听seeked事件后标记位解除等下一次节流窗口到来时再上报。或者在拖拽结束后延迟2秒再上报。核心原则是进度存储必须记录的是用户最终确定观看的位置而不是过程中的瞬时值。4.3 多标签页同时播放导致进度互相覆盖一个用户在同一台电脑上开两个标签页同时播放同一部剧的不同集数。如果没有会话隔离A页面上报了第5集的进度B页面却上报了第6集的进度两个请求的时间差可能不到几毫秒但顺序上后到的覆盖先到的数据库里第5集的进度可能被写成了第6集的内容。这听起来是巧合但实际上很常见。比如用户在页面A看到一半又用新标签页打开了页面B。页面A最后上报一次进度时把当前视频ID写成了已更新的 sessionId而页面B则在初始化时拉取了错误的进度。我的做法是给每个播放页面分配一个独立的 sessionId。服务端更新时校验会话ID和内容ID必须匹配同时使用前面提到的乐观锁时间控制把更新频率限制在5秒内一次。这样即便两个页面同时上报服务端也会丢弃明显不合理的写入避免数据互相污染。4.4 用户断网或服务端响应慢时进度丢失弱网环境下进度上报请求可能会失败。如果是纯服务端存储方案用户在电梯里看完一段视频断网导致进度没存上下次打开还得重新找位置那体验就崩了。我用的方案是本地缓存 补提交机制。每次上报接口失败时把当前进度写入 localStorage 的待同步队列同时设置一个标志位。等网络恢复监听online事件或下次打开应用时将队列里的上报请求重新提交服务端。提交成功之后再把本地队列清掉。这里有个经验补提交时不要逐条请求最好在接口上加一个批量上报的能力比如/api/playback/progress/batch一次把队列里的所有记录提交上去减少弱网环境下的请求次数。4.5 视频时长变化导致定位不准视频资源会被平台方不定期更新。可能上一版视频是720P时长53分钟更新成1080P之后时长因为片头片尾调整变成了52分35秒。如果用户上次看到50分钟更新后这个时间点仍然存在但多对时长进行判断就会发现差异超过30秒一刀切重置不合理。我的建议是把时长差异阈值放宽到60秒大于60秒才认为是“内容变更”并重置。同时重置不要静默进行要在播放器里做Toast提示“视频内容已更新已为您重置播放进度”否则用户会质疑自己的历史记录。还有一种极端情况视频被下架后重新上传contentId 没变但内容完全变了。这种场景下单靠时长经常不行因为总时长可能恰好相同。最可靠的办法还是在视频表里保存一个媒体指纹或版本号进度记录里保存当时的视频版本号恢复时发现版本对不上就重置这在正式产品里非常值得做。4.6 用户看完视频后进度应该如何收尾最后一个常见问题用户把视频看到最后一秒进度存下来了第二次打开时总不能直接从结尾开始吧。按业务惯例至少要判断“看完”的状态标记已完成继续观看入口不再展示。所以在进度接口里status 字段不要只记录 playing 和 paused还要记录 completed 状态。当播放器触发ended事件时上报 statuscompleted。服务端可以做两件事一是清除该条进度记录二是把用户维度的历史观看记录标记为已完成。下次用户再打开查询接口返回空前端自然从头播放。关于什么时候存 “completed”也有一个细节。用户可能拖到最后一秒就退出没有完整看完。是否标记完成要看业务需求。比较严格的产品会要求播放器上报ended事件才标记完成普通产品则只要进度超过95%就认为看完了。后者实现简单但可能带来误判比如用户只是拉进度条拉到片尾瞅了一眼就永久标记为“看过”用户下次想二刷时记录消失又得手动找。我在实际项目里最终选了更稳妥的组合逻辑播放进度达到95%以上但未触发 ended 时标记为“接近完成”不展示“继续观看”入口下次打开自动从头播放但保留历史记录“播放完成”必须等待 ended 事件用于完成状态的下发和用户勋章之类的业务逻辑。这样既兼顾了体验也不会破坏二刷场景。5. 细节打磨与体验优化5.1 恢复点提示的文案与交互续播提示这个细节容易被忽视但我强烈建议做。用户打开一个视频没有任何提示的情况下直接从30分钟开始播可能第一反应是“播放器怎么了”而不是“平台帮我记住了进度”。一个提示文案能解决所有误解。提示文案可以做成轻量浮层显示“上次看到 30:15已为您续播”两三秒后自动消失。也可以在详情页的播放按钮位置上直接显示“继续观看 30:15”。前者适合点击后直接进播放器沉浸式观看的场景后者适合内容消费平台有列表页的场景。提示出现的位置不要遮挡视频重要内容最好放在视频画面下方或右上角同时提供“从头播放”的按钮。这里有一个观点续播不是强制动作用户可能不想从断点看想重新看一遍你直接跳过去了反而逼用户手动拖回来所以“从头播放”这个逃生口必须有尤其是知识付费和教程类产品。5.2 多端同步的优先级策略如果产品是 Web、iOS、Android 三端都有那用户可能会在手机上看一半回到家用电脑继续看。这个场景下进度同步的策略需要一致。我的建议是以“服务端记录”为准上报时带上设备来源。用户在 B 端打开时直接拉取服务端最新记录。这里不做“本地记录”和“服务端记录”的合并因为视频进度不是文档协作取最新一次有效上报即可。用一个updatedAt时间戳比较谁新谁生效。还要注意一个问题进度上报的时钟偏差。用户手机的系统时间可能不准导致上报的 updatedAt 是未来时间或过去时间。因此服务端在写入时要使用服务器时间前端时间戳只作为参考。高并发场景下甚至可以把客户端时间误差超过5分钟的上报请求直接丢弃或降级处理避免脏数据干扰。5.3 性能开销与节流调优进度上报如果做不好会成为播放器的性能拖累。timeupdate事件在低端Android机上触发频率高如果在事件回调里直接做 DOM 操作、JSON 序列化、发送请求很容易造成卡顿。我的优化思路是把上报逻辑拆成三层第一层播放器回调层。去掉不必要的逻辑只更新一个内存对象不做任何存储和网络操作。第二层节流调度层。用一个5秒的定时器检查内存中的进度是否有更新如果有就触发上报。同时支持立即上报模式比如暂停时。第三层传输层。统一处理请求、重试、失败队列、本地缓存。这样可以做到不管播放器触发多少次timeupdate真正产生的网络请求最多每5秒一次而且上报的数据都是最新值。代码结构上更清晰也方便后续扩展。class ProgressReporter { constructor(video, options {}) { this.video video; this.interval options.interval || 5000; this.latest null; this.dirty false; this.timer null; this.batchQueue []; this._bindEvents(); this._startTick(); } _bindEvents() { this.video.addEventListener(timeupdate, () { this.latest { progressSeconds: Math.floor(this.video.currentTime), durationSeconds: Math.floor(this.video.duration || 0) }; this.dirty true; }); this.video.addEventListener(pause, () this.flush(true)); this.video.addEventListener(ended, () { this.flush(true); this.report({ ...this.latest, status: completed }); }); } _startTick() { this.timer setInterval(() this.flush(false), this.interval); } flush(force false) { if (!this.dirty !force) return; if (!this.latest) return; this.report(this.latest); this.dirty false; } report(data) { // 实际发送逻辑 } }5.4 后端存储与查询的性能考量前面代码里我用了一张 playback_progress 表来做演示。这张表的记录量会上涨得很快因为每个用户看过的每个视频都会产生一条记录。查询接口通常需要根据 userId contentId 加 episodeId 来查所以索引设计要提前做好。我建议建立联合索引(user_id, content_id, episode_id)。同时如果内容列表页要展示“最近观看”就还要按updated_at倒序查询那索引可以考虑(user_id, updated_at DESC)。这两个索引可以覆盖绝大多数场景。不要直接在没有任何索引的宽表上执行 where 查询千万级数据一进来接口必慢。定期清理也可以做。很多用户看了一半就再也不看了这些陈年进度记录一直占空间。可以按月定期清理三个月之前且 status 为 completed 或进度小于1%的记录。但要注意别把正在追剧的用户进度清了清理条件要加上“最近更新时间超过 N 天”的限制。数据量更大时可以考虑把播放进度从关系型数据库迁到 Redis 之类的缓存里用 Hash 结构按 userId 存储再异步刷到数据库。不过对于绝大多数中小型产品一张带索引的 MySQL 表完全够用了没必要过早引入额外组件。6. 小结之外的踩坑经验最后说几个我在实际操作中印象比较深的点都是一些容易被忽略的小细节。第一个是进度恢复的“闪烁感”。播放器初始化时如果从服务端拉进度比较慢前端可能已经播放了几秒然后进度返回了再强制跳转画面上就会一闪一闪。解决办法是把播放器初始化改成“等进度回来后再调用 play”而不是边加载边播。这需要接受一次额外的网络等待但对体验是正向的。第二个是 Android WebView 的特殊兼容问题。某些 Android 播放器的currentTime在视频未加载完成时读取的是 NaN如果用 NaN 做进度上报存储里就写入了一个非数字。所以上报之前必须做一次安全校验Number.isFinite(currentTime)不满足就直接丢弃。这个校验能挡掉一大批脏数据。第三个是“用户连续观看同一集”的情况。比如用户看到第9集然后整个视频列表下滑不小心又点进第9集此时配合详情页自动定位到第9集的断点是合理的。但有些产品会在用户已经主动选择了第10集之后由于接口返回的是“最近观看第9集”又把播放器拉回第9集。这个问题的根因是进度恢复逻辑不应该在用户已经主动发起新的播放行为时覆盖用户的选择。实现时要注意进入播放器时先等用户的播放意图如果用户已经明确点击了某一集就不应再应用恢复逻辑。这个功能本身的代码量不算大难的是把各种边界场景想清楚。尤其是多端同步、弱网容错、视频内容变更这些看似低频的问题一旦在你的产品里爆发排查难度远高于实现难度。所以一开始就把数据结构和服务端的乐观锁设计好后面的返工成本会小很多。如果你正准备开始做这个功能我建议从最简单的单机 localStorage 版本起步先把播放器采集和恢复链路跑通再加入服务端上报和同步。这样的节奏可以让你快速上线验证也能在推动服务端资源时给出足够有说服力的数据。我能想到的坑基本都在上面了照着一套做下来你的“继续观看”应该会比市面上一大半产品都稳。