目录
[2. 优化方案概述](#2. 优化方案概述)
[2.1 高并发优化的三大宏观方向](#2.1 高并发优化的三大宏观方向)
[2.2 写多读少场景的核心优化手段](#2.2 写多读少场景的核心优化手段)
[2.3 播放进度业务的方案选型小结](#2.3 播放进度业务的方案选型小结)
[3. Redis 加定时任务](#3. Redis 加定时任务)
[3.1 核心设计思路](#3.1 核心设计思路)
[3.2 方案优缺点分析](#3.2 方案优缺点分析)
[4. Redis 加延迟任务](#4. Redis 加延迟任务)
[4.1 延迟任务方案对比与选型](#4.1 延迟任务方案对比与选型)
[4.2 DelayQueue 的原理与基础用法](#4.2 DelayQueue 的原理与基础用法)
[4.2.1 核心原理](#4.2.1 核心原理)
[4.2.2 基础用法实现](#4.2.2 基础用法实现)
[4.3 核心业务改造](#4.3 核心业务改造)
[4.3.1 延迟任务工具类设计](#4.3.1 延迟任务工具类设计)
[4.3.2 关键业务流程分析(核心步骤)](#4.3.2 关键业务流程分析(核心步骤))
[4.4.3Redisson 实现的关键设计原理](#4.4.3Redisson 实现的关键设计原理)
本文主要是我个人学习天机学堂这个项目自己的一些理解和优化部分,主要是摘出项目中一些比较通用的部分,方便大家以及自己之后如果遇到了类似的业务可以进行参考使用
1.原有的播放进度记录方式
1.1业务描述
前端在用户播放视频时,每隔 15 秒发起一次心跳请求,将当前播放进度(moment)提交到服务端
服务端接收到请求后,执行一系列数据库操作,完成播放记录的更新或新增,业务流程涉及:
① 查询用户该小节的播放记录是否存在
② 不存在则新增记录;存在则更新播放进度
③ 判断进度是否超过 50%,若首次达到则标记小节为 "已学完"
④ 更新课程表中最近学习小节 ID、学习时间,以及已学小节数量
⑤ 判断课程是否全部学完,更新课程状态
1.2问题
我们采用的方案是:前端每隔15秒就发起一次请求,将播放记录写入数据库。
但问题是,提交播放记录的业务太复杂了,其中涉及到大量的数据库操作:

2. 优化方案概述
2.1 高并发优化的三大宏观方向
在系统架构层面,解决高并发问题有三个核心思路:
- 水平扩展:通过增加服务器节点分担压力,比如数据库读写分离、分库分表,属于运维部署层面的方案
- 服务保护:通过限流、熔断、降级等手段,避免系统被峰值流量打垮,依赖 Sentinel、Hystrix 等中间件
- 提高单机并发能力:通过代码和架构优化,提升单节点的处理效率,是开发人员可以主导的核心优化方向

对于我们的播放进度记录业务,核心优化方向是提高单机并发能力 ,聚焦写多读少场景的优化手段。
2.2 写多读少场景的核心优化手段
针对数据库高频写操作的优化,主流手段有三种:
① 优化代码及 SQL:精简查询语句、添加索引、减少无效字段更新,是基础优化手段,但对高频写场景的性能提升有限
② 变同步写为异步写 :利用 MQ 将同步的数据库操作转为异步执行,用户请求无需等待写库完成即可返回。优点是降低响应时间、削峰填谷;缺点是依赖 MQ 可靠性,且未减少总写库次数

③ 合并写请求 :将多次高频的写操作缓存到内存或中间件中,积累到一定量或满足特定条件后,批量写入数据库。该方案能同时减少写库次数和频率,是本次优化的核心选择、

2.3 播放进度业务的方案选型小结
结合播放进度的业务特性,我们分析三种手段的适配性:
- 同步改异步:虽能降低响应时间,但未解决 "多次无效写入" 的问题,优化力度不足
- 合并写请求:完美匹配业务特性 ------ 用户的播放进度是覆盖式更新,只需要保留最后一次的进度值。通过缓存中间进度,最终只写入一次有效数据,可大幅降低数据库压力
因此,我们选择合并写请求作为核心优化方案,基于 Redis 作为缓存载体,结合两种不同的持久化策略(定时任务、延迟任务)来落地。
3. Redis 加定时任务
合并写请求的核心思路是 "先写缓存,再批量写库",定时任务是最基础的持久化方式,我们先从该方案的设计与实现讲起。

因为95%的请求都是在更新learning_record表中的moment字段,以及learning_lesson表中的正在学习的小节id和完成时间。总结起来其实就是只需要根据id更新两个字段即可,那么我们就可以来根据这个业务设计Redis的数据结构
3.1 核心设计思路
3.1.1缓存载体选型:采用 Redis 的 Hash 结构存储播放进度数据
Key 设计:
learning:record:{lessonId},以课程 ID 为 Key,减少 Key 的数量,降低内存占用Field 设计:
{sectionId},以小节 ID 为 FieldValue 设计:存储播放记录的核心字段(
id、moment、finished、finishTime)的 JSON 串
|----------|-------------|-----------------------------------------------------------|
| KEY | HashKey | HashValue |
| lessonId | sectionId:1 | { "id": 1, "moment": 242, "finished": true,"finshTime": } |
| lessonId | sectionId:2 | { "id": 2, "moment": 20, "finished": false,"finshTime": } |
| lessonId | sectionId:3 | { "id": 3, "moment": 121, "finished": false,"finshTime":} |
业务流程改造

-
前端提交播放进度时,服务端优先查询 Redis 缓存
-
缓存未命中则查询数据库,并将结果同步到 Redis
-
统一将播放进度和完成状态写入 Redis 缓存(包括moment、finished、finishTime)
-
不直接更新数据库,由定时任务定期将 Redis 中的缓存数据批量写入数据库
-
启动定时任务,定期将 Redis 中的缓存数据批量写入数据库
3.1.2核心代码
缓存工具类
java
package com.tianji.learning.utils;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.tianji.common.utils.JsonUtils;
import com.tianji.common.utils.StringUtils;
import com.tianji.learning.domain.po.LearningRecord;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
/**
* 学习记录Redis缓存处理器(Hash结构)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LearningRecordCacheHandler {
private final StringRedisTemplate redisTemplate;
/**
* Redis Hash key模板: learning:record:hash:{lessonId}
*/
private static final String CACHE_KEY_TEMPLATE = "learning:record:hash:{}";
/**
* 缓存过期时间:30分钟
*/
private static final Duration CACHE_EXPIRE_TIME = Duration.ofMinutes(30);
/**
* 缓存数据结构
*/
@Data
public static class CacheData {
private Long id;
private Integer moment;
private Boolean finished;
private LocalDateTime finishTime;
// 无参构造函数,用于JSON反序列化
public CacheData() {
}
public CacheData(LearningRecord record) {
this.id = record.getId();
this.moment = record.getMoment();
this.finished = record.getFinished();
this.finishTime = record.getFinishTime();
}
}
/**
* 写入Redis Hash缓存
* @param lessonId 课程id
* @param sectionId 小节id
* @param record 学习记录
*/
public void writeCache(Long lessonId, Long sectionId, LearningRecord record) {
try {
String key = buildCacheKey(lessonId);
String hashKey = sectionId.toString();
String hashValue = JsonUtils.toJsonStr(new CacheData(record));
redisTemplate.opsForHash().put(key, hashKey, hashValue);
redisTemplate.expire(key, CACHE_EXPIRE_TIME);
log.debug("写入学习记录缓存 - lessonId:{}, sectionId:{}, moment:{}", lessonId, sectionId, record.getMoment());
} catch (Exception e) {
log.error("写入学习记录缓存失败", e);
}
}
/**
* 从Redis Hash缓存读取指定小节的学习记录
* @param lessonId 课程id
* @param sectionId 小节id
* @return 学习记录缓存数据
*/
public CacheData readCache(Long lessonId, Long sectionId) {
try {
String key = buildCacheKey(lessonId);
String hashKey = sectionId.toString();
Object value = redisTemplate.opsForHash().get(key, hashKey);
if (value == null) {
log.debug("缓存未命中 - lessonId:{}, sectionId:{}", lessonId, sectionId);
return null;
}
String jsonStr = value.toString();
log.debug("从Redis读取到缓存数据 - lessonId:{}, sectionId:{}, json:{}", lessonId, sectionId, jsonStr);
// 使用JSONObject手动解析,避免toBean方法对null LocalDateTime字段的NPE问题
JSONObject jsonObject = JSONUtil.parseObj(jsonStr);
CacheData cacheData = new CacheData();
cacheData.setId(jsonObject.getLong("id"));
cacheData.setMoment(jsonObject.getInt("moment"));
cacheData.setFinished(jsonObject.getBool("finished"));
// 手动处理finishTime,可能为null
String finishTimeStr = jsonObject.getStr("finishTime");
if (finishTimeStr != null && !finishTimeStr.isEmpty()) {
cacheData.setFinishTime(jsonObject.get("finishTime", LocalDateTime.class));
}
log.debug("缓存数据解析成功 - lessonId:{}, sectionId:{}, id:{}, moment:{}, finished:{}, finishTime:{}",
lessonId, sectionId, cacheData.getId(), cacheData.getMoment(),
cacheData.getFinished(), cacheData.getFinishTime());
return cacheData;
} catch (Exception e) {
log.error("读取学习记录缓存失败 - lessonId:{}, sectionId:{}", lessonId, sectionId, e);
return null;
}
}
/**
* 删除Redis Hash缓存中指定小节的学习记录
* @param lessonId 课程id
* @param sectionId 小节id
*/
public void deleteCache(Long lessonId, Long sectionId) {
try {
String key = buildCacheKey(lessonId);
String hashKey = sectionId.toString();
redisTemplate.opsForHash().delete(key, hashKey);
log.debug("删除学习记录缓存 - lessonId:{}, sectionId:{}", lessonId, sectionId);
} catch (Exception e) {
log.error("删除学习记录缓存失败", e);
}
}
/**
* 批量获取课程的所有小节缓存数据
* @param lessonId 课程id
* @return Map<sectionId, CacheData>
*/
public Map<Object, Object> getAllCache(Long lessonId) {
try {
String key = buildCacheKey(lessonId);
return redisTemplate.opsForHash().entries(key);
} catch (Exception e) {
log.error("获取课程所有小节缓存数据失败", e);
return Map.of();
}
}
/**
* 批量获取课程的所有小节sectionId
* @param lessonId 课程id
* @return sectionId列表
*/
public Set<Object> getAllSectionIds(Long lessonId) {
try {
String key = buildCacheKey(lessonId);
return redisTemplate.opsForHash().keys(key);
} catch (Exception e) {
log.error("获取课程所有小节sectionId失败", e);
return Set.of();
}
}
/**
* 构建缓存key
* @param lessonId 课程id
* @return 完整的Redis key
*/
private String buildCacheKey(Long lessonId) {
return StringUtils.format(CACHE_KEY_TEMPLATE, lessonId);
}
}
业务代码:
java
package com.tianji.learning.service.impl;
import com.tianji.api.client.course.CourseClient;
import com.tianji.api.dto.course.CourseSearchDTO;
import com.tianji.api.dto.leanring.LearningLessonDTO;
import com.tianji.api.dto.leanring.LearningRecordDTO;
import com.tianji.api.dto.leanring.LearningRecordFormDTO;
import com.tianji.common.autoconfigure.mq.RabbitMqHelper;
import com.tianji.common.constants.MqConstants;
import com.tianji.common.utils.BeanUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.domain.po.LearningRecord;
import com.tianji.learning.enums.LessonStatus;
import com.tianji.learning.enums.SectionType;
import com.tianji.learning.mapper.LearningRecordMapper;
import com.tianji.learning.service.ILearningLessonService;
import com.tianji.learning.service.ILearningRecordService;
import com.tianji.learning.utils.LearningRecordCacheHandler;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* 学习记录服务实现类(基于Redis Hash缓存)
* 业务流程:
* 1. 前端提交播放进度时,服务端优先查询 Redis 缓存
* 2. 缓存未命中则查询数据库,并将结果同步到 Redis
* 3. 统一将播放进度和完成状态写入 Redis 缓存(包括moment、finished、finishTime)
* 4. 不直接更新数据库,由定时任务定期将 Redis 中的缓存数据批量写入数据库
* 5. 启动定时任务,定期将 Redis 中的缓存数据批量写入数据库
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisLearningRecordServiceImpl extends ServiceImpl<LearningRecordMapper, LearningRecord> implements ILearningRecordService {
private final ILearningLessonService learningLessonService;
private final CourseClient courseClient;
private final LearningRecordCacheHandler cacheHandler;
private final RabbitMqHelper mqHelper;
@Override
public LearningLessonDTO queryLearningRecord(Long courseId) {
// 1.获取用户id
Long userId = UserContext.getUser();
// 2.查询用户课程信息
LearningLesson learningLesson = learningLessonService.queryLearningLessons(courseId, userId);
LearningLessonDTO learningLessonDTO = new LearningLessonDTO();
learningLessonDTO.setId(learningLesson.getId());
learningLessonDTO.setLatestSectionId(learningLesson.getLatestSectionId());
// 3.查询学习记录
List<LearningRecord> learningRecords = lambdaQuery()
.eq(LearningRecord::getUserId, userId)
.eq(LearningRecord::getLessonId, learningLesson.getId())
.list();
// 4.如果Redis中有数据,覆盖数据库中的记录(确保返回最新的播放进度)
if (learningRecords != null && !learningRecords.isEmpty()) {
Long lessonId = learningLesson.getId();
for (LearningRecord record : learningRecords) {
Long sectionId = record.getSectionId();
LearningRecordCacheHandler.CacheData cacheData = cacheHandler.readCache(lessonId, sectionId);
if (cacheData != null) {
// 用Redis中的moment、finished和finishTime覆盖数据库中的数据
record.setFinished(cacheData.getFinished());
record.setMoment(cacheData.getMoment());
if (cacheData.getFinishTime() != null) {
record.setFinishTime(cacheData.getFinishTime());
}
log.debug("使用Redis缓存数据覆盖数据库记录 - lessonId:{}, sectionId:{}, redisMoment:{}, dbMoment:{}",
lessonId, sectionId, cacheData.getMoment(), record.getMoment());
}
}
}
learningLessonDTO.setRecords(BeanUtils.copyList(learningRecords, LearningRecordDTO.class));
return learningLessonDTO;
}
@Override
public void addLearningRecord(LearningRecordFormDTO dto) {
// 1.获取用户id
Long userId = UserContext.getUser();
// 是否需要更新学习课表
boolean shouldUpdateLesson = false;
// 2.判断小节类型
if (dto.getSectionType().equals(SectionType.EXAM.getValue())) {
// 考试类型,直接添加记录
shouldUpdateLesson = handleExamRecord(dto, userId);
} else {
// 视频类型,需要判断是否学完
shouldUpdateLesson = handleVideoRecord(dto, userId);
}
// 3.判断是否需要更新学习课表
if (shouldUpdateLesson) {
updateLearningLesson(dto);
}
}
/**
* 处理考试类型的学习记录
*/
private boolean handleExamRecord(LearningRecordFormDTO dto, Long userId) {
LearningRecord record = new LearningRecord();
BeanUtils.copyProperties(dto, record);
record.setUserId(userId);
record.setFinished(true);
record.setFinishTime(LocalDateTime.now());
save(record);
return true;
}
/**
* 处理视频类型的学习记录(基于Redis Hash缓存)
*/
private boolean handleVideoRecord(LearningRecordFormDTO dto, Long userId) {
Long lessonId = dto.getLessonId();
Long sectionId = dto.getSectionId();
// 1.优先查询Redis缓存
LearningRecordCacheHandler.CacheData cacheData = cacheHandler.readCache(lessonId, sectionId);
// 2.判断是否是首次学完(播放进度超过50%)
boolean isFirstFinished = isFirstFinished(dto,cacheData);
// 3.获取或创建学习记录对象
LearningRecord record = getRecordFromCacheOrDb(dto, userId, cacheData);
// 4.设置finished标记和finishTime
record.setFinished(isFirstFinished);
if (isFirstFinished) {
record.setFinishTime(dto.getCommitTime() != null ? dto.getCommitTime() : LocalDateTime.now());
}
// 5.写入Redis缓存(无论缓存有无,都重新写入,确保数据最新)
cacheHandler.writeCache(lessonId, sectionId, record);
log.debug("写入学习记录缓存 - lessonId:{}, sectionId:{}, moment:{}, finished:{}, finishTime:{}",
lessonId, sectionId, dto.getMoment(), isFirstFinished, record.getFinishTime());
return isFirstFinished;
}
/**
* 判断是否是首次学完(播放进度超过50%)
*/
private boolean isFirstFinished(LearningRecordFormDTO dto,LearningRecordCacheHandler.CacheData cacheData) {
if (dto.getDuration() == null || dto.getDuration() <= 0) {
return false;
}
if (dto.getMoment() == null) {
return false;
}
// 当播放进度大于50%时并且之前是未学完的状态,判定为学完
boolean isNowFinished = dto.getMoment() * 2 > dto.getDuration();
boolean wasFinished = cacheData != null && Boolean.TRUE.equals(cacheData.getFinished());
return isNowFinished && !wasFinished;
}
/**
* 获取学习记录对象
* 优先从缓存获取,缓存未命中则查询数据库
* 数据库中不存在则创建新记录并保存到数据库
*/
private LearningRecord getRecordFromCacheOrDb(LearningRecordFormDTO dto, Long userId, LearningRecordCacheHandler.CacheData cacheData) {
Long lessonId = dto.getLessonId();
Long sectionId = dto.getSectionId();
// 1.缓存中有记录,直接构建对象(不需要查数据库)
if (cacheData != null && cacheData.getId() != null) {
LearningRecord record = new LearningRecord();
record.setId(cacheData.getId());
record.setLessonId(lessonId);
record.setSectionId(sectionId);
record.setMoment(dto.getMoment());
record.setUserId(userId);
record.setFinished(cacheData.getFinished());
if (cacheData.getFinishTime() != null) {
record.setFinishTime(cacheData.getFinishTime());
}
log.debug("从缓存构建学习记录 - lessonId:{}, sectionId:{}, id:{}",
lessonId, sectionId, cacheData.getId());
return record;
}
// 2.缓存中没有,从数据库查询
LearningRecord record = lambdaQuery()
.eq(LearningRecord::getLessonId, lessonId)
.eq(LearningRecord::getSectionId, sectionId)
.one();
// 3.数据库中也没有,创建新记录并保存到数据库
if (record == null) {
record = new LearningRecord();
BeanUtils.copyProperties(dto, record);
record.setUserId(userId);
record.setFinished(false);
save(record);
log.debug("创建新的学习记录并保存到数据库 - lessonId:{}, sectionId:{}, id:{}",
lessonId, sectionId, record.getId());
} else {
// 数据库中已有记录,直接返回(更新moment和finished由外部统一处理)
log.debug("从数据库获取学习记录 - lessonId:{}, sectionId:{}, id:{}",
lessonId, sectionId, record.getId());
}
return record;
}
/**
* 更新学习课表信息
*/
private void updateLearningLesson(LearningRecordFormDTO dto) {
// 1.查询学习课表
LearningLesson learningLesson = learningLessonService.queryLearningByLessonId(dto.getLessonId());
if (learningLesson == null) {
return;
}
// 2.课程已学习小节数加1
learningLesson.setLearnedSections(learningLesson.getLearnedSections() + 1);
// 3.查询课程小结总数量
CourseSearchDTO courseSearchDTO = courseClient.getSearchInfo(learningLesson.getCourseId());
if (courseSearchDTO != null && learningLesson.getLearnedSections() >= courseSearchDTO.getSections()) {
// 4.课程已学完
learningLesson.setStatus(LessonStatus.FINISHED);
}
// 5.更新课表
learningLessonService.updateById(learningLesson);
// 6.发送MQ消息,添加积分记录
Long userId = UserContext.getUser();
mqHelper.send(
MqConstants.Exchange.LEARNING_EXCHANGE,
MqConstants.Key.LEARN_SECTION,
userId
);
log.debug("更新学习课表 - lessonId:{}, learnedSections:{}", dto.getLessonId(), learningLesson.getLearnedSections());
}
}
3.1.3定时任务的配置考量
其实这里我觉得多少都可以,你像几分钟这样合并写一次都可以,因为课中所讲的有误差问题主要源于,查询课程进度是从数据库中进行查询的,而我改成了从数据库中查询播放记录,再在redis中查一遍覆盖掉一些还没同步的播放进度数据,这样就保证了所有的数据一定是最新的
综合来看,其实这个方案也是可以的,主要还是看大家的业务需求
3.2 方案优缺点分析
| 优点 | 缺点 |
|---|---|
| 批量写库有效减少数据库 IO | 存在数据丢失风险(Redis 宕机且未持久化) |
| 缓存降低了数据库查询频率 | 中间进度数据未持久化,若 Redis 故障会导致续播误差 |
4. Redis 加延迟任务
核心思想是**"只持久化最后一次有效进度"**,从根源上解决无效写入的问题。
播放进度的核心特性是:只有最后一次提交的进度是有效数据。用户持续播放时,中间的进度更新都是临时的,无需持久化。
因此,我们只需要判断 "用户是否停止播放"------ 若停止,则将最后一次进度写入数据库。延迟任务就是实现该判断的关键技术。
4.1 延迟任务方案对比与选型
常见的延迟任务实现方案有四种,我们结合业务需求做选型:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| DelayQueue | JDK 自带,基于阻塞队列 + 优先级队列实现 | 无第三方依赖、轻量、性能高 | 单机模式,占用 JVM 内存 |
| Redisson | 基于 Redis 的 ZSet 模拟延迟队列 | 分布式支持、可靠性高 | 依赖 Redis,有网络开销 |
| MQ 死信队列 | 利用 MQ 的 TTL + 死信交换机特性 | 分布式支持、削峰填谷 | 依赖 MQ,配置复杂 |
| 时间轮算法 | 基于环形结构实现任务调度 | 高性能、低延迟 | 实现复杂,需要自研或引入框架 |
最终我们选择JDK DelayQueue作为延迟任务载体,原因是:任务生命周期短(仅 20 秒)、无分布式需求、轻量无依赖。
4.2 DelayQueue 的原理与基础用法
4.2.1 核心原理
DelayQueue 是一个支持延迟获取元素的阻塞队列 ,队列中的元素必须实现Delayed接口,该接口定义了两个核心方法:
long getDelay(TimeUnit unit):获取任务的剩余延迟时间int compareTo(Delayed o):比较两个任务的延迟时间,用于队列排序(延迟时间短的优先执行)
DelayQueue 的底层是优先级队列(PriorityQueue)+ 重入锁(ReentrantLock),队列会根据任务的延迟时间排序,只有当任务的延迟时间≤0 时,才能被取出执行。
4.2.2 基础用法实现
① 定义延迟任务实体类
java
package com.tianji.learning.utils;
import lombok.Data;
import java.time.Duration;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
@Data
public class DelayTask<D> implements Delayed {
private D data;
private long deadlineNanos;
public DelayTask(D data, Duration delayTime) {
this.data = data;
this.deadlineNanos = System.nanoTime() + delayTime.toNanos();
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(Math.max(0, deadlineNanos - System.nanoTime()), TimeUnit.NANOSECONDS);
}
@Override
public int compareTo(Delayed o) {
long l = getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
if(l > 0){
return 1;
}else if(l < 0){
return -1;
}else {
return 0;
}
}
}
② DelayQueue 的使用示例
java
@Test
void testDelayQueue() throws InterruptedException {
DelayQueue<DelayTask<String>> queue = new DelayQueue<>();
// 添加延迟任务,延迟时间分别为1s、2s、3s
queue.add(new DelayTask<>("任务1", Duration.ofSeconds(1)));
queue.add(new DelayTask<>("任务2", Duration.ofSeconds(2)));
queue.add(new DelayTask<>("任务3", Duration.ofSeconds(3)));
// 阻塞获取并执行到期任务
while (true) {
DelayTask<String> task = queue.take();
System.out.println("执行任务:" + task.getData());
}
}
运行结果:

4.3 核心业务改造
4.3.1 延迟任务工具类设计
我们封装LearningRecordDelayTaskHandler工具类,核心功能如下:
① addLearningRecordTask:将播放进度写入 Redis,并添加 20 秒的延迟任务到 DelayQueue
② readRecordCache:查询 Redis 中指定小节的播放进度
③ cleanRecordCache:小节学完后清理对应缓存
④ handleDelayTask:异步执行延迟任务,核心逻辑为:
- 20 秒后取出延迟任务,获取任务中的进度值
- 查询 Redis 中当前的进度值,与任务中的值对比
- 若两者一致:说明用户已停止播放,将该进度写入数据库
- 若两者不一致:说明用户持续播放,放弃本次任务
java
package com.tianji.learning.utils;
import com.tianji.common.utils.JsonUtils;
import com.tianji.common.utils.StringUtils;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.domain.po.LearningRecord;
import com.tianji.learning.mapper.LearningRecordMapper;
import com.tianji.learning.service.ILearningLessonService;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.DelayQueue;
@Slf4j
@Component
@RequiredArgsConstructor
public class LearningRecordDelayTaskHandler {
private final StringRedisTemplate redisTemplate;
private final LearningRecordMapper recordMapper;
private final ILearningLessonService lessonService;
private final DelayQueue<DelayTask<RecordTaskData>> queue = new DelayQueue<>();
private final static String RECORD_KEY_TEMPLATE = "learning:record:{}";
private static volatile boolean begin = true;
@PostConstruct
public void init(){
CompletableFuture.runAsync(this::handleDelayTask);
}
@PreDestroy
public void destroy(){
begin = false;
log.debug("延迟任务停止执行!");
}
public void handleDelayTask(){
while (begin) {
try {
// 1.获取到期的延迟任务
DelayTask<RecordTaskData> task = queue.take();
RecordTaskData data = task.getData();
// 2.查询Redis缓存
LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId());
if (record == null) {
continue;
}
// 3.比较数据,moment值
if(!Objects.equals(data.getMoment(), record.getMoment())) {
// 不一致,说明用户还在持续提交播放进度,放弃旧数据
continue;
}
// 4.一致,持久化播放进度数据到数据库
// 4.1.更新学习记录的moment
record.setFinished(null);
recordMapper.updateById(record);
// 4.2.更新课表最近学习信息
LearningLesson lesson = new LearningLesson();
lesson.setId(data.getLessonId());
lesson.setLatestSectionId(data.getSectionId());
lesson.setLatestLearnTime(LocalDateTime.now());
lessonService.updateById(lesson);
} catch (Exception e) {
log.error("处理延迟任务发生异常", e);
}
}
}
public void addLearningRecordTask(LearningRecord record){
// 1.添加数据到Redis缓存
writeRecordCache(record);
// 2.提交延迟任务到延迟队列 DelayQueue
queue.add(new DelayTask<>(new RecordTaskData(record), Duration.ofSeconds(20)));
}
public void writeRecordCache(LearningRecord record) {
log.debug("更新学习记录的缓存数据");
try {
// 1.数据转换
String json = JsonUtils.toJsonStr(new RecordCacheData(record));
// 2.写入Redis
String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId());
redisTemplate.opsForHash().put(key, record.getSectionId().toString(), json);
// 3.添加缓存过期时间
redisTemplate.expire(key, Duration.ofMinutes(1));
} catch (Exception e) {
log.error("更新学习记录缓存异常", e);
}
}
public LearningRecord readRecordCache(Long lessonId, Long sectionId){
try {
// 1.读取Redis数据
String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId);
Object cacheData = redisTemplate.opsForHash().get(key, sectionId.toString());
if (cacheData == null) {
return null;
}
// 2.数据检查和转换
return JsonUtils.toBean(cacheData.toString(), LearningRecord.class);
} catch (Exception e) {
log.error("缓存读取异常", e);
return null;
}
}
public void cleanRecordCache(Long lessonId, Long sectionId){
// 删除数据
String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId);
redisTemplate.opsForHash().delete(key, sectionId.toString());
}
@Data
@NoArgsConstructor
private static class RecordCacheData{
private Long id;
private Integer moment;
private Boolean finished;
public RecordCacheData(LearningRecord record) {
this.id = record.getId();
this.moment = record.getMoment();
this.finished = record.getFinished();
}
}
@Data
@NoArgsConstructor
private static class RecordTaskData{
private Long lessonId;
private Long sectionId;
private Integer moment;
public RecordTaskData(LearningRecord record) {
this.lessonId = record.getLessonId();
this.sectionId = record.getSectionId();
this.moment = record.getMoment();
}
}
}
使用这个工具类改造我们的业务流程:
java
package com.tianji.learning.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tianji.api.client.course.CourseClient;
import com.tianji.api.dto.course.CourseSearchDTO;
import com.tianji.api.dto.leanring.LearningLessonDTO;
import com.tianji.api.dto.leanring.LearningRecordDTO;
import com.tianji.api.dto.leanring.LearningRecordFormDTO;
import com.tianji.common.autoconfigure.mq.RabbitMqHelper;
import com.tianji.common.constants.MqConstants;
import com.tianji.common.exceptions.DbException;
import com.tianji.common.utils.BeanUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.domain.po.LearningRecord;
import com.tianji.learning.enums.LessonStatus;
import com.tianji.learning.enums.SectionType;
import com.tianji.learning.mapper.LearningLessonMapper;
import com.tianji.learning.mapper.LearningRecordMapper;
import com.tianji.learning.mq.message.SignInMessage;
import com.tianji.learning.service.ILearningLessonService;
import com.tianji.learning.service.ILearningRecordService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.tianji.learning.utils.LearningRecordDelayTaskHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* <p>
* 学习记录表 服务实现类
* </p>
*
* @author jxl
* @since 2025-12-04
*/
@Service
@RequiredArgsConstructor
public class LearningRecordServiceImpl extends ServiceImpl<LearningRecordMapper, LearningRecord> implements ILearningRecordService {
private final ILearningLessonService learningLessonService;
private final CourseClient courseClient;
private final LearningRecordDelayTaskHandler delayTaskHandler;
private final RabbitMqHelper mqHelper;
/**
* 查询学习记录
*
* @param courseId 课程id
*/
@Override
public LearningLessonDTO queryLearningRecord(Long courseId) {
// 1.获取用户id
Long userId = UserContext.getUser();
// 2.查询用户课程信息
LearningLesson learningLesson = learningLessonService.queryLearningLessons(courseId,userId);
LearningLessonDTO learningLessonDTO = new LearningLessonDTO();
learningLessonDTO.setId(learningLesson.getId());
learningLessonDTO.setLatestSectionId(learningLesson.getLatestSectionId());
// 3.查询学习记录
List<LearningRecord> learningRecords = lambdaQuery()
.eq(LearningRecord::getUserId, userId)
.eq(LearningRecord::getLessonId, learningLesson.getId())
.list();
learningLessonDTO.setRecords(BeanUtils.copyList(learningRecords, LearningRecordDTO.class));
return learningLessonDTO;
}
/**
* 添加学习记录
*
* @param learningLessonDTO 学习记录信息
*/
@Override
public void addLearningRecord(LearningRecordFormDTO learningLessonDTO) {
// 1.获取用户id
Long userId = UserContext.getUser();
// 创建学习记录
LearningRecord learningRecord = new LearningRecord();
// 是否需要更新学习记录
boolean IsUpdateLearningLesson = false;
// 2.判断用户是哪种状态
if(learningLessonDTO.getSectionType().equals(SectionType.EXAM.getValue())){
// 考试
// 直接添加课程记录
BeanUtils.copyProperties(learningLessonDTO, learningRecord);
learningRecord.setFinished(true);
learningRecord.setFinishTime(LocalDateTime.now());
save(learningRecord);
IsUpdateLearningLesson = true;
}else {
// 视频
IsUpdateLearningLesson = handleVideoLearningRecord(learningLessonDTO, userId);
}
// 3.判断课程是否已学完
if (IsUpdateLearningLesson){
// 课程已学习小节数加1
LearningLesson learningLesson = learningLessonService.queryLearningByLessonId(learningLessonDTO.getLessonId());
learningLesson.setLearnedSections(learningLesson.getLearnedSections() + 1);
// 查询课程小结总数量
CourseSearchDTO courseSearchDTO = courseClient.getSearchInfo(learningLesson.getCourseId());
if(learningLesson.getLearnedSections()>=courseSearchDTO.getSections()){
// 课程已学完
learningLesson.setStatus(LessonStatus.FINISHED);
}
learningLessonService.updateById(learningLesson);
// 添加积分记录
mqHelper.send(
MqConstants.Exchange.LEARNING_EXCHANGE,
MqConstants.Key.LEARN_SECTION,
userId
);
}
}
/**
* 处理视频学习记录
*
* @param learningLessonDTO 学习记录信息
* @param userId 用户id
*/
private boolean handleVideoLearningRecord(LearningRecordFormDTO learningLessonDTO, Long userId) {
// 查询记录是否已经存在
LearningRecord record = queryOldRecord(learningLessonDTO.getLessonId(), learningLessonDTO.getSectionId());
if(record==null){
// 新增
record = new LearningRecord();
BeanUtils.copyProperties(learningLessonDTO, record);
record.setUserId(userId);
save(record);
return false;
}
// 修改
// 判断是否是第一次学完
boolean finished = !record.getFinished()&&learningLessonDTO.getMoment()*2>learningLessonDTO.getDuration();
// 不是
if(!finished){
LearningRecord learningRecord = new LearningRecord();
learningRecord.setId(record.getId());
learningRecord.setLessonId(learningLessonDTO.getLessonId());
learningRecord.setSectionId(learningLessonDTO.getSectionId());
learningRecord.setMoment(learningLessonDTO.getMoment());
learningRecord.setFinished(record.getFinished());
// 添加延迟任务
delayTaskHandler.addLearningRecordTask(learningRecord);
return false;
}
// 是
boolean success=lambdaUpdate()
.set(LearningRecord::getMoment,learningLessonDTO.getMoment())
.set(LearningRecord::getFinished,true)
.set(LearningRecord::getFinishTime,learningLessonDTO.getCommitTime())
.eq(LearningRecord::getId,record.getId())
.update();
if(!success){
throw new DbException("更新学习记录失败");
}
// 删除缓存
delayTaskHandler.clearLearningRecordCache(learningLessonDTO.getLessonId(),learningLessonDTO.getSectionId());
return true;
}
/**
* 查询旧学习记录
*
* @param lessonId 课程id
* @param sectionId 小节id
*/
private LearningRecord queryOldRecord(Long lessonId, Long sectionId) {
// 1.查询缓存
LearningRecord record = delayTaskHandler.readRecordCache(lessonId, sectionId);
// 2.缓存中有直接返回
if(record!=null){
return record;
}
log.debug("缓存中没有指定小节的播放记录");
// 3.缓存中没有,查询数据库
record = lambdaQuery()
.eq(LearningRecord::getLessonId, lessonId)
.eq(LearningRecord::getSectionId, sectionId)
.one();
// 4.写入缓存
delayTaskHandler.writeRecordCache(record);
return record;
}
}
4.3.2 关键业务流程分析(核心步骤)
① 用户提交视频播放进度 → 服务端查询 Redis 缓存
② 缓存未命中 → 查询数据库并同步到 Redis
③ 判断是否首次学完(进度 > 50% 且未标记完成)
- 是:更新数据库,清理缓存,结束流程
- 否:调用工具类写入 Redis,并添加 20 秒延迟任务
④ 延迟任务到期执行对比逻辑 → 一致则持久化到数据库(这里就相当于把20秒前的进度与当前进度进行对比,进度没动说明用户不看视频了,就可以进行持久化了 )

4.4基于Redisson的延迟任务实现
4.4.1延迟队列里的数据
java
package com.tianji.learning.utils;
import lombok.Data;
import java.io.Serializable;
import java.time.Duration;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
@Data
public class DelayTask<D extends Serializable> implements Delayed, Serializable {
private static final long serialVersionUID = 1L;
/**
* 延迟任务携带的业务数据(泛型类型,可存储任意类型的业务数据,比如播放进度、用户ID等)
*/
private D data;
/**
* 任务的截止执行时间(纳秒级)
* 基于System.nanoTime()计算,代表任务需要执行的时间点(当前系统纳秒时间 + 延迟时间)
*/
private long deadlineNanos;
/**
* 创建一个延迟任务
* @param data 延迟任务携带的业务数据
* @param delayTime 延迟时间
*/
public DelayTask(D data, Duration delayTime) {
this.data = data;
// 计算任务的截止时间:当前系统纳秒时间 + 延迟时间
this.deadlineNanos = System.nanoTime() + delayTime.toNanos();
}
/**
* 【Delayed接口必须实现】获取当前时间到任务截止时间的剩余延迟时间
* @param unit 时间单位(比如TimeUnit.SECONDS、TimeUnit.MILLISECONDS)
* @return 剩余延迟时间(转换为指定单位后的值,若已到截止时间,返回0)
*/
@Override
public long getDelay(TimeUnit unit) {
// 计算剩余纳秒数:截止时间-当前系统纳秒时间,取最大值0(避免出现负数)
long remainingNanos = Math.max(0,deadlineNanos - System.nanoTime());
// 返回剩余延迟时间(转换为指定单位后的值)
return unit.convert(remainingNanos,TimeUnit.NANOSECONDS);
}
/**
* 【Delayed接口继承自Comparable接口,必须实现】任务排序规则
* DelayQueue会根据这个方法对任务进行排序,保证延迟时间最短的任务在队首(最先被取出)
* @param o 另一个延迟任务
* @return 比较结果:负数(当前任务延迟更短)、0(延迟相同)、正数(当前任务延迟更长)
*/
@Override
public int compareTo(Delayed o) {
long delayDiff=getDelay(TimeUnit.NANOSECONDS)-o.getDelay(TimeUnit.NANOSECONDS);
if(delayDiff>0){
// 当前任务延迟更长
return 1;
} else if (delayDiff < 0) {
// 当前任务延迟更短
return -1;
}else {
// 延迟相同
return 0;
}
}
}
4.4.2Redisson实现延迟队列
java
package com.tianji.learning.utils;
import com.tianji.common.utils.JsonUtils;
import com.tianji.common.utils.StringUtils;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.domain.po.LearningRecord;
import com.tianji.learning.mapper.LearningRecordMapper;
import com.tianji.learning.service.ILearningLessonService;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.Serializable;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonDelayTaskHandler {
private final StringRedisTemplate redisTemplate;
private final RedissonClient redissonClient;
/**
* 延迟任务Redis key模板: learning:record:delay:{lessonId}
* 用于旧的延迟队列实现,与Hash缓存key(learning:record:hash:{lessonId})分离
*/
private final static String RECORD_KEY_TEMPLATE = "learning:record:delay:{}";
private RBlockingQueue<DelayTask<RecordTaskData>> blockingQueue;
private RDelayedQueue<DelayTask<RecordTaskData>> delayedQueue;
private final LearningRecordMapper recordMapper;
private final ILearningLessonService lessonService;
private static volatile boolean begin = true;
@PostConstruct
public void init(){
// 初始化 Redisson 队列
this.blockingQueue = redissonClient.getBlockingQueue("learning:record:delay:queue");
this.delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
CompletableFuture.runAsync(this::handleDelayTask);
}
@PreDestroy
public void destroy(){
begin = false;
log.debug("销毁延迟任务线程 RedissonDelayTaskHandler");
}
/**
* 缓存数据的结构
*/
@Data
@NoArgsConstructor
private static class RecordCacheData{
// 学习记录的id
private Long id;
// 视频的当前观看时间点
private Integer moment;
// 是否完成学习
private Boolean finished;
public RecordCacheData(LearningRecord record) {
this.id = record.getId();
this.moment = record.getMoment();
this.finished = record.getFinished();
}
}
/**
* 延迟任务数据结构
*/
@Data
@NoArgsConstructor
private static final class RecordTaskData implements Serializable {
private static final long serialVersionUID = 1L;
// 课表id
private Long lessonId;
// 小节id
private Long sectionId;
// 视频的当前观看时间点
private Integer moment;
public RecordTaskData(LearningRecord record) {
this.lessonId = record.getLessonId();
this.sectionId = record.getSectionId();
this.moment = record.getMoment();
}
}
/**
* 添加播放记录到Redis,并添加一个延迟检测任务到Redisson延迟队列
* @param record 播放记录
*/
public void addLearningRecordTask(LearningRecord record){
// 添加播放记录到redis中
writeRecordCache(record);
// 添加延迟任务-延迟20秒
DelayTask<RecordTaskData> delayTask = new DelayTask<>(new RecordTaskData(record), Duration.ofSeconds(20));
delayedQueue.offer(delayTask, 20, TimeUnit.SECONDS);
}
/**
* 更新学习记录数据到Redis
* @param record 学习记录
*/
public void writeRecordCache(LearningRecord record){
log.debug("更新学习记录的数据");
try {
// 1.数据转换
String json = JsonUtils.toJsonStr(new RecordCacheData(record));
// 2.写入redis:业务key + 业务数据id
String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId());
redisTemplate.opsForHash().put(key, record.getSectionId().toString(), json);
// 3.添加缓存过期时间
redisTemplate.expire(key, Duration.ofMinutes(1));
} catch (Exception e) {
log.error("更新学习记录数据失败", e);
}
}
/**
* 查询Redis缓存中的指定小节的播放记录
* @param lessonId 课表id
* @param sectionId 小节id
* @return 播放记录
*/
public LearningRecord readRecordCache(Long lessonId,Long sectionId){
log.debug("查询Redis缓存中的指定小节的播放记录");
try {
String key=StringUtils.format(RECORD_KEY_TEMPLATE, lessonId);
Object cacheData = redisTemplate.opsForHash().get(key, sectionId.toString());
if(cacheData==null){
return null;
}
return JsonUtils.toBean(cacheData.toString(), LearningRecord.class);
} catch (Exception e) {
log.error("查询Redis缓存中的指定小节的播放记录失败", e);
return null;
}
}
/**
* 清除Redis缓存中的指定小节的播放记录
* @param lessonId 课表id
* @param sectionId 小节id
*/
public void clearLearningRecordCache(Long lessonId,Long sectionId){
log.debug("清除Redis缓存中的指定小节的播放记录");
try {
String key=StringUtils.format(RECORD_KEY_TEMPLATE, lessonId);
redisTemplate.opsForHash().delete(key, sectionId.toString());
} catch (Exception e) {
log.error("清除Redis缓存中的指定小节的播放记录失败", e);
}
}
public void handleDelayTask(){
while(begin){
try {
// 1.获取到期的延迟任务(阻塞等待)
DelayTask<RecordTaskData> task = blockingQueue.take();
RecordTaskData recordTaskData = task.getData();
// 2.查询Redis缓存中的指定小节的播放记录
LearningRecord learningRecord = readRecordCache(recordTaskData.getLessonId(), recordTaskData.getSectionId());
if(learningRecord==null){
continue;
}
// 3.比较moment值
if (!Objects.equals(recordTaskData.getMoment(), learningRecord.getMoment())){
// 不一致,说明用户还在观看
continue;
}
// 4.一致,持久化播放进度数据到数据库
// 4.1.更新学习记录的moment
learningRecord.setFinished(null);
recordMapper.updateById(learningRecord);
// 4.2.更新课表最近学习信息
LearningLesson learningLesson = new LearningLesson();
learningLesson.setId(recordTaskData.getLessonId());
learningLesson.setLatestSectionId(recordTaskData.getSectionId());
learningLesson.setLatestLearnTime(LocalDateTime.now());
lessonService.updateById(learningLesson);
} catch (Exception e) {
log.error("处理延迟任务失败", e);
}
}
}
}
4.4.3Redisson 实现的关键设计原理
双队列架构:RDelayedQueue + RBlockingQueue
Redisson 延迟队列的实现采用了 双队列模式:
①RDelayedQueue :用于 添加 延迟任务,调用 offer(task, delay, unit) 时,Redisson 内部会:
- 计算任务的到期时间戳。
- 将任务序列化后存入 Redis 的 有序集合(ZSET),以到期时间戳作为分数。
- 启动一个后台线程定期扫描 ZSET,将到期的任务转移到阻塞队列。
②RBlockingQueue :用于 消费 到期任务,调用 take() 时:
- 从 Redis 的 列表(List) 中阻塞弹出任务。
- 如果列表为空,则等待直到有任务被转移进来。
补充:
序列化要求
java
private static final class RecordTaskData implements Serializable {
private static final long serialVersionUID = 1L;
// 字段...
}
必须实现 Serializable 的原因:
- Redisson 需要将任务对象序列化为字节数组存储到 Redis。
- 如果不实现,会抛出
NotSerializableException。 serialVersionUID用于确保序列化兼容性。
4.4.4Redisson实现的业务流程图

感兴趣的宝子可以关注一波,后续会更新更多有用的知识!!!
