在在线教育平台的视频播放场景中,播放进度记录是一个看似简单但实际复杂的功能。天机学堂最初采用的方案是前端每隔15秒向服务端发送一次心跳请求,记录当前播放进度。这种设计在用户量较小时表现尚可,但随着用户规模增长,系统开始暴露出严重的性能问题。
原方案的核心流程如下:
这个方案的主要问题在于:
通过压力测试和性能监控,我们发现系统瓶颈主要集中在:
特别是在热门课程上新时,大量用户同时观看视频会导致数据库响应时间从平时的10ms飙升到500ms以上,严重时甚至引发级联故障。
面对高并发写入场景,我们主要考虑三个优化方向:
针对播放进度这种典型的写多读少场景,我们评估了三种优化手段:
| 优化手段 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| SQL优化 | 精简查询、添加索引 | 实施简单 | 提升有限 |
| 异步写入 | 通过MQ异步处理 | 降低响应时间 | 写入总量不变 |
| 合并写入 | 缓存+批量写入 | 大幅减少写入次数 | 实现复杂度高 |
基于业务特点,我们最终选择了合并写入方案,因为:
具体实现上,我们设计了两种技术方案:
该方案的核心思想是将高频的数据库写入转为先写Redis缓存,再通过定时任务批量同步到数据库。架构分为三层:
我们采用Hash结构存储播放进度数据,设计如下:
java复制// Key设计
String key = "learning:record:hash:" + lessonId;
// Field设计
String field = sectionId.toString();
// Value设计
{
"id": recordId, // 记录ID
"moment": 125, // 播放进度(秒)
"finished": false, // 是否完成
"finishTime": null // 完成时间
}
这种设计的好处是:
java复制@Slf4j
@Component
@RequiredArgsConstructor
public class LearningRecordCacheHandler {
private final StringRedisTemplate redisTemplate;
private static final String CACHE_KEY_TEMPLATE = "learning:record:hash:{}";
private static final Duration CACHE_EXPIRE_TIME = Duration.ofMinutes(30);
// 写入缓存
public void writeCache(Long lessonId, Long sectionId, LearningRecord record) {
try {
String key = buildCacheKey(lessonId);
String hashValue = JsonUtils.toJsonStr(new CacheData(record));
redisTemplate.opsForHash().put(key, sectionId.toString(), hashValue);
redisTemplate.expire(key, CACHE_EXPIRE_TIME);
} catch (Exception e) {
log.error("写入学习记录缓存失败", e);
}
}
// 读取缓存
public CacheData readCache(Long lessonId, Long sectionId) {
try {
String key = buildCacheKey(lessonId);
Object value = redisTemplate.opsForHash().get(key, sectionId.toString());
if (value == null) return null;
return parseCacheData(value.toString());
} catch (Exception e) {
log.error("读取学习记录缓存失败", e);
return null;
}
}
}
java复制@Slf4j
@Service
public class RedisLearningRecordServiceImpl implements ILearningRecordService {
public void addLearningRecord(LearningRecordFormDTO dto) {
// 1. 优先查询Redis缓存
CacheData cacheData = cacheHandler.readCache(dto.getLessonId(), dto.getSectionId());
// 2. 判断是否首次学完
boolean isFirstFinished = isFirstFinished(dto, cacheData);
// 3. 获取或创建学习记录
LearningRecord record = getRecordFromCacheOrDb(dto, cacheData);
// 4. 写入Redis缓存
cacheHandler.writeCache(dto.getLessonId(), dto.getSectionId(), record);
}
}
java复制@Scheduled(fixedRate = 60000) // 每分钟执行一次
public void syncRecordsToDatabase() {
// 1. 扫描所有课程Key
Set<String> keys = redisTemplate.keys("learning:record:hash:*");
// 2. 批量处理每个课程的记录
for (String key : keys) {
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
List<LearningRecord> records = convertToRecords(entries);
// 3. 批量更新数据库
batchUpdate(records);
// 4. 清理已处理的数据
redisTemplate.delete(key);
}
}
优点:
缺点:
实际测试数据显示:在1000并发用户场景下,数据库QPS从2000+降到200左右,CPU利用率从80%降到30%
该方案的核心创新点是"惰性持久化"策略:
这种设计完美契合播放进度业务的特性:
我们评估了四种延迟任务实现方式:
| 方案 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| DelayQueue | JDK内置优先级队列 | 单机短延迟任务 | 不支持分布式 |
| Redisson | Redis ZSet实现 | 分布式环境 | 网络开销大 |
| RabbitMQ | 死信队列+TTL | 高可靠场景 | 配置复杂 |
| 时间轮 | 环形队列算法 | 超高精度延迟 | 实现复杂 |
最终选择DelayQueue因为:
java复制@Data
public class DelayTask<D> implements Delayed {
private D data;
private long deadlineNanos; // 到期时间戳
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(deadlineNanos - System.nanoTime(), NANOSECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(getDelay(NANOSECONDS), o.getDelay(NANOSECONDS));
}
}
java复制@Slf4j
@Component
public class LearningRecordDelayTaskHandler {
private final DelayQueue<DelayTask<RecordTaskData>> queue = new DelayQueue<>();
@PostConstruct
public void init() {
CompletableFuture.runAsync(this::handleDelayTask);
}
private void handleDelayTask() {
while (true) {
try {
DelayTask<RecordTaskData> task = queue.take();
RecordTaskData data = task.getData();
// 检查Redis中的当前进度
LearningRecord current = readRecordCache(data.getLessonId(), data.getSectionId());
if (current == null || !data.getMoment().equals(current.getMoment())) {
continue; // 进度已更新,放弃本次任务
}
// 持久化到数据库
persistRecord(current);
} catch (Exception e) {
log.error("处理延迟任务异常", e);
}
}
}
}
java复制public void addLearningRecord(LearningRecordFormDTO dto) {
// 1. 更新Redis缓存
LearningRecord record = updateCache(dto);
// 2. 提交延迟任务
delayTaskHandler.addTask(new RecordTaskData(
record.getLessonId(),
record.getSectionId(),
record.getMoment()
));
// 3. 处理学完状态
if (isFirstFinished(dto)) {
markAsFinished(record);
}
}
性能提升:
资源节省:
| 维度 | Redis+定时任务 | Redis+延迟任务 |
|---|---|---|
| 写入次数 | 每分钟1次 | 每次观看1次 |
| 数据实时性 | 1分钟延迟 | 20秒延迟 |
| 实现复杂度 | 简单 | 中等 |
| 可靠性 | 可能丢失1分钟数据 | 可能丢失20秒数据 |
| 适用场景 | 对实时性要求不高 | 需要较高实时性 |
教育类长视频平台:推荐延迟任务方案
短视频学习平台:推荐定时任务方案
混合型平台:可以两种方案结合
必须配置Redis哨兵或集群模式,避免单点故障导致数据丢失:
yaml复制spring:
redis:
sentinel:
master: mymaster
nodes: 192.168.1.1:26379,192.168.1.2:26379
需要完善以下异常处理场景:
关键监控指标包括:
对于超大规模用户,可以引入本地缓存+Redis的多级缓存:
根据用户行为动态调整延迟时间:
通过离线作业补偿丢失的数据:
在实际项目中,我们最终采用了Redis+延迟任务的方案,经过3个月的生产环境验证,系统在日均100万用户访问量下保持稳定,数据库负载降低80%以上。这个方案特别适合对数据实时性要求较高且用户观看时长较长的在线教育场景。