1. 项目背景与问题定位
在在线教育平台的视频播放场景中,播放进度记录是个看似简单却暗藏玄机的功能。去年我们重构天机学堂时,发现旧系统的进度同步存在三个致命问题:
第一是"进度丢失"现象。当用户快速拖动进度条时,约有12%的请求因服务端并发处理丢失,导致下次播放时出现"时间跳跃"。我们用JMeter模拟测试发现,当并发量超过200TPS时,MySQL的UPDATE操作开始出现锁等待超时。
第二是"进度风暴"问题。移动端APP在息屏后仍会周期性发送进度,这些无效请求占用了30%的带宽资源。抓包分析显示,某Android机型甚至每2秒就发送一次进度数据,而用户实际观看间隔平均为8分钟。
第三是"最终一致性"困境。由于采用MySQL直接存储,当用户跨设备观看时,新设备读取到的可能是数秒前的旧数据。抽样统计显示,跨设备进度偏差超过15秒的比例高达17%。
2. 技术选型与架构设计
2.1 存储层方案对比
我们对比了三种主流方案:
| 方案 | 写入性能 | 读取延迟 | 数据持久性 | 实现复杂度 |
|---|---|---|---|---|
| MySQL直接更新 | 低 | 低 | 高 | 低 |
| Redis+定时落库 | 极高 | 极低 | 中 | 中 |
| Kafka+消费入库 | 高 | 高 | 高 | 高 |
最终选择Redis作为一级缓存,配合Redisson的RDelayedQueue实现延迟批量写入。这个组合在测试环境中实现了:
- 写入QPS从原来的150提升到4200
- 95%的读取延迟从120ms降至8ms
- 数据库写入量减少82%
2.2 关键数据结构设计
在Redis中使用两层存储结构:
// 实时进度缓存(String类型) key: progress:{userId}:{courseId}:{videoId} value: {"time": 125.6, "updatedAt": 1634567890} // 待持久化队列(ZSET类型) key: delay_queue:progress member: {userId}:{courseId}:{videoId} score: 当前时间戳 + 延迟时间(默认5分钟)这种设计带来两个优势:
- 高频更新的String类型只用20字节左右内存
- ZSET的自动排序特性避免重复处理
3. 核心实现细节
3.1 进度更新流程优化
改造后的写入流程如下:
public void updateProgress(Long userId, Long videoId, Double seconds) { // 1. 内存锁防抖(同一用户1秒内只处理最后一次) String lockKey = "lock:progress:" + userId; if (!redisson.getLock(lockKey).tryLock(100, TimeUnit.MILLISECONDS)) { return; } try { // 2. 更新Redis缓存 String key = String.format("progress:%d:%d", userId, videoId); redisTemplate.opsForValue().set(key, new Progress(seconds, System.currentTimeMillis())); // 3. 加入延迟队列(自动去重) String member = String.format("%d:%d", userId, videoId); redisTemplate.opsForZSet().add( "delay_queue:progress", member, System.currentTimeMillis() + DELAY_MS ); } finally { redisson.getLock(lockKey).unlock(); } }3.2 延迟任务处理机制
使用Redisson的RDelayedQueue实现优雅的延迟处理:
@Scheduled(fixedDelay = 30000) public void processDelayQueue() { RDelayedQueue<String> queue = redisson.getDelayedQueue( redisson.getScoredSortedSet("delay_queue:progress")); // 每次处理最多100条,避免长事务 List<String> members = queue.poll(100); if (!members.isEmpty()) { // 批量查询Redis最新进度 List<String> keys = members.stream() .map(m -> "progress:" + m) .collect(Collectors.toList()); List<Progress> progressList = redisTemplate.opsForValue().multiGet(keys); // 批量更新MySQL batchUpdateToDatabase(members, progressList); } }这里有个关键细节:每次从ZSET获取元素时,会同时用ZREMRANGEBYSCORE删除已处理项,保证不会重复消费。
4. 性能优化实践
4.1 热点数据处理
针对热门课程视频,我们增加了本地缓存层:
// 使用Caffeine做二级缓存 LoadingCache<String, Progress> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(1, TimeUnit.MINUTES) .build(key -> { String redisKey = "progress:" + key; return redisTemplate.opsForValue().get(redisKey); });实测表明,这个改动使得热门视频的读取QPS从3500提升到12000+,同时Redis的CPU负载下降40%。
4.2 智能延迟调整算法
根据系统负载动态调整延迟时间:
private long calculateDynamicDelay() { // 基础延迟5分钟 long baseDelay = 5 * 60 * 1000; // Redis内存使用率超过70%时缩短延迟 if (redisMemoryUsage > 0.7) { return baseDelay / 2; } // MySQL活跃连接数超过阈值时延长延迟 if (mysqlActiveConnections > 50) { return baseDelay * 2; } return baseDelay; }这个算法使得系统在高负载时自动降低数据库压力,在闲时又能保证数据及时性。
5. 异常处理与监控
5.1 补偿机制设计
我们建立了三级补偿体系:
- 定时全量同步:每天凌晨扫描ZSET剩余项
- 异常重试队列:对数据库写入失败的数据进入重试队列
- 人工修复接口:提供按时间范围修复的Admin API
5.2 监控指标埋点
在Prometheus中配置了关键指标:
metrics: - name: progress_update_total type: counter labels: [source] - name: progress_delay_seconds type: histogram buckets: [5, 30, 60, 300] - name: redis_progress_size type: gauge配合Grafana看板,可以实时监控:
- 进度更新成功率
- 平均延迟时间分布
- Redis内存增长趋势
6. 效果验证与数据对比
上线后关键指标变化:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 进度同步成功率 | 88% | 99.97% | +11.97% |
| 数据库写入QPS | 1200 | 220 | -81.67% |
| 端到端延迟(P99) | 450ms | 35ms | -92.22% |
| 服务器成本 | $3200 | $1800 | -43.75% |
用户调研显示:
- 跨设备进度偏差>15秒的比例从17%降至0.3%
- 视频续播准确率从82%提升到98%
- 用户投诉量减少64%
7. 踩坑经验分享
坑1:ZSET的内存增长问题初期没有及时清理已处理项,导致ZSET在高峰期每小时增长2GB。解决方案是:
// 在处理完成后立即清理 redisTemplate.opsForZSet().removeRangeByScore( "delay_queue:progress", 0, System.currentTimeMillis());坑2:Redisson的序列化兼容性发现Jackson序列化与Redisson默认编码冲突,最终统一使用:
config.setCodec(new JsonJacksonCodec());坑3:MySQL批量写入瓶颈当批量插入超过500条时出现锁等待,最终采用:
INSERT INTO progress(user_id, video_id, time) VALUES (?,?,?), (?,?,?) ON DUPLICATE KEY UPDATE time=VALUES(time)配合100条/批次的拆分策略。