
Java在线视频点播中的断点续播与进度记录实践
断点续播看起来只是“保存一个秒数,下次再跳到那里”,但真正上线时,至少涉及三个不同问题:播放器如何恢复播放位置、服务端怎样按用户和视频保存进度、视频文件如何支持拖动与分段传输。把它们混为一谈,容易出现进度丢失、用户之间串档,或者“进度能保存但拖动不了”的情况。
本文用一个 Spring Boot 示例实现用户级播放进度接口,并配合浏览器播放器代码演示保存与恢复。示例使用内存存储,便于运行和验证;生产环境应替换为数据库或其他持久化存储。视频文件的静态分发、鉴权和 HTTP Range 处理则单独说明,因为它们与进度记录不是同一项功能。
一、适用场景与数据模型
进度记录适合有账号体系、用户可能跨设备观看,或者需要继续学习、继续观看的点播业务,例如课程、培训视频和影视点播。若视频只在当前页面短暂播放,使用浏览器本地存储也许足够;但本地存储无法天然跨设备同步,也不适合承担可信的服务端学习记录。
最基本的进度记录通常包含:
- 用户标识:由服务端身份认证上下文取得,而不是相信请求正文里的 userId。
- 视频标识:明确对应哪一条视频资源。
- 播放位置:以秒为单位的非负数,表示下次恢复时的候选位置。
- 更新时间:用于展示最近观看时间,或处理并发写入。
总时长可以由客户端播放器报告,但不能把它当成可信数据。服务端可使用视频资产元数据校验播放位置,并计算完成状态。本文的简化接口不把总时长写入数据库,因此不会因客户端报错就永久污染视频时长。
二、先区分“记进度”和“视频可拖动”
进度接口只保存诸如 125.5 秒的位置;它并不负责返回视频字节,也不会让播放器自动支持任意位置播放。浏览器要从文件中间开始取数据,通常需要视频源支持 HTTP Range 请求:客户端发送类似 Range: bytes=... 的请求,服务器或对象存储返回 206 Partial Content 及相应的 Content-Range。如果媒体服务器不支持范围请求,播放器可能无法可靠地跳转,或者只能从头下载。
Range 的单位是字节,进度接口的单位通常是秒。两者不能直接互换:视频容器的索引、关键帧、编码码率等因素都会影响字节位置与时间位置的关系。一般由浏览器播放器与媒体服务器处理时间定位和字节请求。
生产环境常见方案包括:由支持 Range 的 Web 服务器提供 MP4 文件;使用对象存储或 CDN 的范围读取能力;或者采用 HLS、MPEG-DASH 等分片流媒体方案。应检查实际响应头和播放行为,而不是仅凭“服务器能返回文件”就认为支持拖动。
三、接口设计与边界约定
示例采用两个接口:
GET /api/videos/{videoId}/progress:读取当前登录用户的进度。PUT /api/videos/{videoId}/progress:覆盖保存播放位置。
选择 PUT 是因为示例把请求理解为“将这条进度更新为指定位置”,重复提交相同位置不会产生额外副作用。请求体只接收播放位置,不接收用户 ID。接口还限制了位置必须是有限的非负数;生产环境应进一步根据服务端视频时长拒绝明显越界的位置,或按业务规则裁剪。
客户端的 video.duration 在媒体元数据尚未加载时可能是 NaN,直播或未知时长媒体还可能是 Infinity。不要把这些值当作有效时长。本文保存时只发送有限的播放位置,恢复时则等待元数据加载后再设置 currentTime。
四、可运行的 Spring Boot 示例
下面示例使用 Java 17、Spring Boot 3 和 Spring Security 的 HTTP Basic 认证。内存 Map 只用于演示接口行为:应用重启后数据会丢失,且多实例部署时各实例数据不共享。运行示例时,接口通过认证用户名区分用户;生产系统应接入实际的登录体系,并从服务端认证主体取得用户标识。
项目结构:
resume-demo/
pom.xml
src/main/java/com/example/resume/ResumeApplication.java
pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>resume-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugins>
</plugins>
</build>
</project>
ResumeApplication.java:
package com.example.resume;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@SpringBootApplication
@RestController
@RequestMapping("/api/videos")
public class ResumeApplication {
private final ConcurrentMap<Key, Progress> store =
new ConcurrentHashMap<>();
public static void main(String[] args) {
SpringApplication.run(ResumeApplication.class, args);
}
@GetMapping("/{videoId}/progress")
public Progress getProgress(
@PathVariable String videoId,
java.security.Principal principal) {
validateVideoId(videoId);
Key key = new Key(principal.getName(), videoId);
return store.getOrDefault(key, new Progress(0, null));
}
@PutMapping("/{videoId}/progress")
public Progress saveProgress(
@PathVariable String videoId,
@RequestBody SaveRequest request,
java.security.Principal principal) {
validateVideoId(videoId);
if (request == null || request.positionSeconds() == null) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "positionSeconds is required");
}
double position = request.positionSeconds();
if (!Double.isFinite(position) || position < 0) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "positionSeconds must be finite and non-negative");
}
Key key = new Key(principal.getName(), videoId);
Progress progress = new Progress(position, Instant.now());
store.put(key, progress);
return progress;
}
private static void validateVideoId(String videoId) {
if (videoId == null || !videoId.matches("[A-Za-z0-9_-]{1,80}")) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "invalid videoId");
}
}
public record SaveRequest(Double positionSeconds) {}
public record Progress(double positionSeconds, Instant updatedAt) {}
private record Key(String username, String videoId) {}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
// 仅为便于演示用 curl 调用。浏览器生产环境不要随意关闭 CSRF 防护。
.csrf(csrf -> csrf.disable())
.build();
}
@Bean
UserDetailsService users() {
PasswordEncoder encoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
return new InMemoryUserDetailsManager(
User.withUsername("demo")
.password(encoder.encode("change-this-password"))
.roles("USER")
.build());
}
}
启动应用:
mvn spring-boot:run
保存一条进度并读取:
curl -u demo:change-this-password \
-H "Content-Type: application/json" \
-X PUT \
-d '{"positionSeconds":125.5}' \
http://localhost:8080/api/videos/lesson-01/progress
curl -u demo:change-this-password \
http://localhost:8080/api/videos/lesson-01/progress
示例用户名和密码仅用于本地验证,不能直接作为生产凭据。HTTP Basic 应通过 HTTPS 使用;生产环境不应把长期密码硬编码在代码或前端中。
五、浏览器端保存与恢复
下面代码假设页面中存在一个 <video> 元素,且该元素加载的视频与 videoId 对应。示例用同源接口和浏览器的 HTTP Basic 凭据进行演示;在真实产品中,应配合既有登录会话、令牌策略及相应的 CSRF 防护。
<video id="player" controls preload="metadata"
src="/media/lesson-01.mp4"></video>
<script>
const videoId = "lesson-01";
const player = document.getElementById("player");
const endpoint =
`/api/videos/${encodeURIComponent(videoId)}/progress`;
let lastSentAt = 0;
let lastSentPosition = -1;
let restored = false;
let saveInFlight = false;
async function loadProgress() {
const response = await fetch(endpoint, {
credentials: "same-origin"
});
if (!response.ok) {
throw new Error(`读取进度失败:${response.status}`);
}
return response.json();
}
async function restoreProgress() {
try {
const progress = await loadProgress();
player.addEventListener("loadedmetadata", () => {
if (restored) return;
restored = true;
const duration = player.duration;
const position = Number(progress.positionSeconds);
if (!Number.isFinite(duration) ||
!Number.isFinite(position) ||
position <= 0) {
return;
}
// 接近结尾时从头播放,避免恢复到最后几秒后体验不佳。
const safeEnd = Math.max(0, duration - 2);
player.currentTime = Math.min(position, safeEnd);
}, { once: true });
} catch (error) {
console.error(error);
}
}
async function saveProgress(force = false) {
const position = player.currentTime;
if (!Number.isFinite(position) || position < 0) return;
if (saveInFlight) return;
const now = Date.now();
if (!force && now - lastSentAt < 10000) return;
if (!force && Math.abs(position - lastSentPosition) < 3) return;
lastSentAt = now;
lastSentPosition = position;
saveInFlight = true;
try {
const response = await fetch(endpoint, {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ positionSeconds: position })
});
if (!response.ok) {
throw new Error(`保存进度失败:${response.status}`);
}
} catch (error) {
console.error(error);
} finally {
saveInFlight = false;
}
}
player.addEventListener("loadedmetadata", restoreProgress, { once: true });
player.addEventListener("timeupdate", () => saveProgress(false));
player.addEventListener("pause", () => saveProgress(true));
player.addEventListener("ended", () => {
// 已看完时可按产品约定保存为 0,或由后端另记完成状态。
saveProgress(true);
});
window.addEventListener("pagehide", () => saveProgress(true));
</script>
播放器的 timeupdate 事件会频繁触发,不能每次都请求服务端。示例至少间隔 10 秒、且位置变化达到 3 秒才常规保存,并在暂停时尝试立即保存。间隔和阈值应按业务调整:课程需要更细的观看记录,影视点播通常不必每秒写一次。
页面关闭时的异步请求不保证一定完成。浏览器可能冻结或终止页面,因此 pagehide 只能作为补充,不能作为唯一保存时机。若使用 navigator.sendBeacon,需要注意它主要适用于小型 POST 请求,不能直接替代任意带 JSON 请求体的 PUT;跨域、凭据、CSRF 和服务端路由也都要单独验证。即便采用 beacon,也不应把它视为可靠事务机制。
六、生产环境需要补齐的服务端能力
1. 持久化与索引
示例中的 Map 会在进程退出后清空。关系型数据库可建立以 (user_id, video_id) 为唯一键的进度表,并为用户查询最近观看记录建立合适索引。写入时使用数据库的 upsert 或事务性更新,避免先查后插在并发请求下产生重复记录。
CREATE TABLE video_progress (
user_id VARCHAR(128) NOT NULL,
video_id VARCHAR(80) NOT NULL,
position_sec DOUBLE PRECISION NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, video_id)
);
具体字段类型应依据数据库选择。若系统需要审计或学习合规记录,仅保留“最后位置”通常不够,还要设计不可变的播放事件或服务端学习记录,并明确哪些数据具有业务证明效力。
2. 防止旧请求覆盖新进度
播放器可能同时发出多个保存请求,网络延迟会导致较早的位置最后到达。示例按请求到达顺序覆盖,因而不保证跨请求顺序。生产系统可以增加客户端递增版本号、服务端更新时间条件,或在客户端串行化保存请求。单纯取较大的 position 也不是通用方案:用户主动回看时,位置变小是合理行为。
3. 合理校验位置
有限且非负只是基础校验。服务端掌握视频时长时,可按产品规则拒绝明显大于时长的位置,或者裁剪到合法范围。对未知时长、直播回放和可变媒体时长,应定义各自的语义。浮点秒数用于播放器定位通常足够;如果业务要求严格的课程章节或帧级定位,应采用明确的整数单位或结构化进度模型。
4. 认证、授权与隐私
进度属于用户数据。保存和读取都必须绑定已认证身份,并检查该用户是否有权访问对应视频。不能因为客户端传入了别人的 userId 就允许读写其进度;更不能把身份标识放在可任意修改的查询参数中并直接信任。
接口还应遵循最小暴露原则:校验视频 ID 格式、限制请求体大小、对异常请求限流,并避免在日志中记录密码、令牌或不必要的个人信息。上述正则只是示例格式约束,不是身份认证、访问授权或防注入的替代品。使用 JPA、参数化 SQL 等方式访问数据库,仍需正确处理权限和输入。
本文为方便 curl 演示关闭了 CSRF 防护。若生产系统使用 Cookie 会话认证,跨站请求伪造风险需要通过 Spring Security 的 CSRF 机制及前端令牌传递等方式处理;HTTPS 保护传输机密性,但不能替代 CSRF 防护。若采用无状态令牌认证,也应按实际认证架构配置,而不是照搬示例配置。
七、常见错误排查
- 读取接口返回零,但视频能拖动。 媒体定位与进度保存是两条链路,检查进度接口是否成功写入、用户身份是否一致。
- 设置 currentTime 报错或没有效果。 通常是元数据尚未加载、时长未知、媒体不可定位,或目标位置不在有效范围。等待
loadedmetadata后再恢复,并检查媒体服务的 Range 支持。 - 每次刷新都从头开始。 检查请求是否被认证拦截、视频 ID 是否变化、保存请求是否失败,以及读写是否落在同一存储中。
- 一个账号的多个页面互相覆盖。 这是最后写入胜出策略的自然结果。若产品需要跨设备冲突提示或精确播放会话管理,应增加版本、设备或会话维度。
- 进度偶尔少几秒。 浏览器关闭时最后一次请求可能未到达;缩短保存间隔能减少损失,但会增加请求量。可在暂停、切换视频、定期播放时保存,并结合可靠的前端重试策略。
- 把客户端 duration 当成完成证明。 客户端上报的数据可被修改,不适合作为可信考核依据。需要完成判定时,应由服务端结合播放事件、业务规则和必要的反作弊策略处理。
八、实现时的检查清单
- 身份来自认证主体,数据键至少包含用户和视频。
- 播放位置做非负、有限值校验,并按服务端视频元数据处理越界值。
- 前端等待媒体元数据后恢复,处理未知时长与接近结尾的情况。
- 进度请求做节流或去抖,不把每次 timeupdate 都写入数据库。
- 存储具备持久化和并发更新策略,多实例部署不依赖进程内 Map。
- 视频源独立验证 Range、鉴权和 CDN 行为;秒数进度不是字节范围。
- 生产环境启用 HTTPS,并根据认证方式正确配置 CSRF、授权、限流和日志脱敏。
可靠的断点续播不是单独的一次数据库写入,而是播放器事件、进度接口、用户身份、持久化策略和媒体定位能力之间的协作。把这些边界拆开设计,再依据产品的精度、并发和审计要求选择实现方式,通常比简单地“保存 currentTime”更容易得到稳定、可维护的结果。




