天机学堂复习总结(day03-day04)

带你速通天机学堂准备面试

目录

[day03 - 学习计划和进度](#day03 - 学习计划和进度)

1查询学习记录

2提交学习记录

3学习进度统计?

4该项目的延迟队列是怎么配置实现的?

5创建学习计划

6查询学习计划进度

7时间工具类DateUtils的代码实现?

8课程过期

9方案思考

10面试题:

[day04 - 高并发优化](#day04 - 高并发优化)

1并发优化方案有哪些?

2面试


day03 - 学习计划和进度

1查询学习记录

思路:根据课程id查课程信息->然后根据课程信息里面的小节id去查小节信息 ->最后封装即可

java 复制代码
    //查询指定课程的学习记录
    public LearningLessonDTO queryLearningRecordByCourse(Long courseId) {
        //1.获取当前用户
        Long user = UserContext.getUser();
        //2.查询lesson信息
        LearningLesson learningLesson = lessonService.queryByUserAndCourseId(user, courseId);
        if(learningLesson == null){
            throw new BizIllegalException("该用户未拥有该课程");
        }
        //3.查询record表的小节学习进度
        List<LearningRecord> records = lambdaQuery()
                .eq(LearningRecord::getLessonId, learningLesson.getId())
                .list();
        // 没有得到学习记录不能抛异常,别问我怎么知道的
        // if(CollUtils.isEmpty(records)){
        //     return null;
        // }
        //4.设置LearningLessonDTO的信息
        LearningLessonDTO learningLessonDTO = new LearningLessonDTO();
        learningLessonDTO.setId(learningLesson.getId());
        learningLessonDTO.setLatestSectionId(learningLesson.getLatestSectionId());
        //5.复制小节记录集合到LearningRecordDTO
        List<LearningRecordDTO> dtos = BeanUtils.copyList(records, LearningRecordDTO.class);
        learningLessonDTO.setRecords(dtos);
        return learningLessonDTO;
    }

2提交学习记录

3学习进度统计?

java 复制代码
    //提交学习记录
    @Transactional
    public void addLearningRecord(LearningRecordFormDTO recordDTO) {
        Long user = UserContext.getUser();
        SectionType type = recordDTO.getSectionType();
        //判断是否已经考完试或者小节已经全部学完
        boolean finished = false;
        if(type == SectionType.EXAM){
            //考试流程
            finished = handleExamRecord(user, recordDTO);
        }else{
            //看视频流程
            finished = handleVideoRecordWithRedis(user, recordDTO);
        }
        /**
         * 本来没有这个判断,但是使用了延迟队列的版本后就需要判断一下
         * + 如果是第一次看完视频,taskHandler的延时队列里就没有该记录
         *   没有该记录就无法更新成最新的课表信息(最新学习小节id、最新学习时间等)
         *   所以需要这里手动更新一下课表
         * + 否则就不需要更新,因为并不是第一次看完视频的情况下
         *   延时队列出元素后会更新课表信息,如果再次更新会造成重复更新
         */
        if(!finished){
            return;
        }
        //更新课表
        handleLearningLessonsChanges(recordDTO);
    }

    //添加考试记录
    private boolean handleExamRecord(Long userId, LearningRecordFormDTO recordDTO) {
        LearningRecord learningRecord = new LearningRecord();
        learningRecord.setFinished(true);
        learningRecord.setFinishTime(recordDTO.getCommitTime());
        learningRecord.setSectionId(recordDTO.getSectionId());
        learningRecord.setLessonId(recordDTO.getLessonId());
        learningRecord.setUserId(userId);
        boolean save = save(learningRecord);
        if(!save){
            throw new DbException("新增考试记录失败");
        }
        return true;
    }

    /**
     * 异步延时阻塞队列版本(大致思路:优先查Redis记录->修改Redis记录->最后用户离开再把Redis记录持久化到数据库)
     * 减少了对数据库的操作,避免高并发
     * @param userId
     * @param recordDTO
     * @return
     */
    private boolean handleVideoRecordWithRedis(Long userId, LearningRecordFormDTO recordDTO) {
        //查询旧的学习记录
        LearningRecord oldRecord = queryOldRecord(recordDTO.getLessonId(), recordDTO.getSectionId());
        //1.没有旧的学习记录
        if(oldRecord == null){
            LearningRecord learningRecord = BeanUtils.copyBean(recordDTO, LearningRecord.class);
            learningRecord.setUserId(userId);
            //数据库新增学习记录
            boolean save = save(learningRecord);
            if(!save){
                throw new DbException("新增学习记录失败");
            }
            return false;
        }
        //2.有学习记录,修改学习记录
        //2.1如果该小节视频长度完成50%及以上且小节旧的状态为未完成,才让当前课程表的小节完成数量+1
        boolean finished = false;
        //todo getDuration() 视频总时长  和getMoment() 当前观看进度?
        if(!oldRecord.getFinished() && recordDTO.getDuration() <= recordDTO.getMoment()*2){
            finished = true;
        }
        //当前小节没有学完就将该小节信息添加到redis、延时队列
        if(!finished){
            LearningRecord nowRecord = new LearningRecord();
            nowRecord.setLessonId(recordDTO.getLessonId());
            nowRecord.setSectionId(recordDTO.getSectionId());
            nowRecord.setMoment(recordDTO.getMoment());
            nowRecord.setFinished(finished);
            nowRecord.setId(oldRecord.getId());

            //添加到redis和延时队列
            taskHandler.addLearningRecordTask(nowRecord);
            //如果不返回会执行另一个分支流程->重复更新小节学习记录
            return finished;
        }
        //2.2更新小节学习,如果符合完成条件,则更新小节记录的完成时间和状态
        boolean update = lambdaUpdate()
                .set(LearningRecord::getMoment, recordDTO.getMoment())
                .set(finished, LearningRecord::getFinished, true)
                .set(finished, LearningRecord::getFinishTime, recordDTO.getCommitTime())
                .eq(LearningRecord::getId, oldRecord.getId())
                .update();
        if(!update){
            throw new DbException("更新学习记录失败");
        }
        //3.如果是第一次学完,则清除redis中的学习记录 todo 为什么要删除?
        taskHandler.cleanRecordCache(recordDTO.getLessonId(), recordDTO.getSectionId());
        return finished;
    }

4该项目的延迟队列是怎么配置实现的?

  1. 核心组件构成

DelayTask.java

实现了 Java 的 Delayed 接口,作为延迟任务的基本单元

包含业务数据和截止时间(纳秒时间戳)

通过 compareTo() 方法实现任务优先级排序(按截止时间)

通过 getDelay() 方法计算剩余延迟时间

java 复制代码
package com.tianji.learning.utils;

import lombok.Data;

import java.time.Duration;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/**
 * 延迟任务封装类
 * <p>
 * 实现了Java的Delayed接口,用于在DelayQueue中存储延迟执行的任务。
 * 每个任务包含业务数据和截止时间,当到达截止时间后任务才会被取出执行。
 * </p>
 *
 * @param <D> 任务携带的业务数据类型
 */
@Data
public class DelayTask<D> implements Delayed {
    
    /** 任务携带的业务数据 */
    private D data;
    
    /** 任务截止时间的纳秒时间戳(绝对时间) */
    private long deadlineNanos;

    /**
     * 构造延迟任务
     *
     * @param data 任务携带的业务数据
     * @param delayTime 延迟时间,从当前时刻开始计算todo Duration delayTime?
     */
    public DelayTask(D data, Duration delayTime) {
        this.data = data;
        // 计算任务的截止时间: 当前系统纳秒时间 + 延迟时间的纳秒数
        this.deadlineNanos = System.nanoTime() + delayTime.toNanos();
    }



    /**
     * 比较两个延迟任务的优先级
     * <p>
     * DelayQueue是优先队列,会根据此方法的返回值对任务排序:
     * - 返回值 < 0: 当前任务优先级更高(更早到期)
     * - 返回值 > 0: 当前任务优先级更低(更晚到期)
     * - 返回值 = 0: 两个任务优先级相同
     * </p>
     * <p>
     * 排序规则: 截止时间越早的任务优先级越高,会先被取出执行
     * </p>
     *
     * @param o 要比较的另一个延迟任务
     * @return 比较结果: -1(当前任务更早)、0(同时到期)、1(当前任务更晚)
     */
    @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;      // 两个任务延迟时间相同
        }
    }
    /**
     * 获取任务的剩余延迟时间
     * <p>
     * DelayQueue会根据此方法的返回值判断任务是否到期:
     * - 返回值 <= 0: 任务已到期,可以被取出执行
     * - 返回值 > 0: 任务未到期,继续等待
     * </p>
     *
     * @param unit 时间单位,调用者指定返回值的单位
     * @return 剩余延迟时间,如果已到期则返回0或负数
     */
    @Override
    public long getDelay(TimeUnit unit) {
        // 计算剩余时间: 截止时间 - 当前时间
        // 使用Math.max确保不会返回负数todo convert?
        return unit.convert(Math.max(0, deadlineNanos - System.nanoTime()), TimeUnit.NANOSECONDS);
    }
}

LearningRecordDelayTaskHandler.java

主要的延迟队列处理器,使用 Spring 的 @Component 注解

内部维护一个 DelayQueue<DelayTask<RecordTaskData>> 队列

使用线程池处理延迟任务执行

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.*;

/**
 * todo SpringTask 定时任务 和延时队列有什么区别?
 * todo .高并发优化方案? day04
 * 延迟阻塞队列工具
 * 主要包含方法:
 *  + 读取Redis中学习记录
 *  + 添加播放记录到Redis,并添加一个延迟检测任务到DelayQueue
 *  + 删除Redis缓存中的指定小节的播放记录
 *  + 异步执行DelayQueue中的延迟检测任务,检测播放进度是否变化,如果无变化则写入数据库
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class LearningRecordDelayTaskHandler {

    private final StringRedisTemplate redisTemplate;
    //todo ?
    private final DelayQueue<DelayTask<RecordTaskData>> queue = new DelayQueue<>();
    private final static String RECORD_KEY_TEMPLATE = "learning:record:{}";
    private final LearningRecordMapper recordMapper;
    private final ILearningLessonService lessonService;
    private static volatile boolean begin = true;
    //创建线程池todo  创建线程池都需要什么参数?
    ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(12, 12,
            60, TimeUnit.SECONDS, new LinkedBlockingDeque<>(10));

    //PostConstruct:在此类初始化后,并在属性被输入之后执行
    //todo @PostConstruct 执行时机?
    @PostConstruct
    //初始化时会创建一个新线程,来执行handleDelayTask方法
    public void init(){
        //todo ?与new Thread () 核心区别?
            CompletableFuture.runAsync(this::handleDelayTask);
    }

    //PreDestroy:在此类实例销毁前设置destroy为false,使其延时队列任务监测方法停止,否则用户断开连接后,在新线程还会一直执行handleDelayTask
    @PreDestroy
    public void destroy(){
        log.debug("关闭学习记录处理的延迟任务");
        begin = false;
    }

    //由新线程开启的延时队列任务监测
    private void handleDelayTask(){
        while (begin){
            try {
                // 1.尝试获取任务todo take?
                DelayTask<RecordTaskData> task = queue.take();
                log.debug("获取到要处理的播放记录任务");
                //
                poolExecutor.submit(new Runnable() {
                    @Override
                    public void run() {
                        RecordTaskData data = task.getData(); //由于是阻塞队列(不会返回null),拿不到元素就会阻塞在这里一直等
                        // 2.读取Redis缓存
                        LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId());
                        log.info("从延迟队列获取到学习记录数据:{}", data);
                        log.info("从Redis缓存中获取到学习记录数据:{}", record);
                        if (record == null) {
                            return;
                        }
                        // 3.比较数据
                        if(!Objects.equals(data.getMoment(), record.getMoment())){
                            // 4.如果不一致,播放进度在变化,无需持久化
                            return;
                        }
                        // 5.如果一致,证明用户离开了视频,需要持久化
                        // 5.1.更新学习记录
                        record.setFinished(null);
                        recordMapper.updateById(record);
                        // 5.2.更新课表
                        LearningLesson lesson = new LearningLesson();
                        lesson.setId(data.getLessonId());
                        lesson.setLatestSectionId(data.getSectionId());
                        lesson.setLatestLearnTime(LocalDateTime.now());
                        lessonService.updateById(lesson);
                        log.debug("准备持久化学习记录信息");
                    }
                });
            } catch (Exception e) {
                log.error("处理播放记录任务发生异常", e);
            }
        }
    }

    /**
     * 添加学习记录到Redis并提交到延时队列(异步)
     * @param record 学习记录
     */
    public void addLearningRecordTask(LearningRecord record){
        // 1.添加数据到Redis缓存
        writeRecordCache(record);
        // 2.提交延迟任务到延迟队列 DelayQueue
        queue.add(new DelayTask<>(new RecordTaskData(record), Duration.ofSeconds(20)));
    }

    /**
     * 将学习记录存储到Redis
     * @param record 学习记录对象{课表id, 小节id, 播放时长}
     */
    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.添加Redis的缓存过期时间
            redisTemplate.expire(key, Duration.ofMinutes(1));
        } catch (Exception e) {
            log.error("更新学习记录缓存异常", e);
        }
    }

    /**
     * 读取Redis中学习记录
     * @param lessonId 课表id
     * @param sectionId 小节id
     * @return LearningRecord 学习记录
     */
    public LearningRecord readRecordCache(Long lessonId, Long sectionId){
        try {
            // 1.读取Redis数据todo ?
            String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId);
            Object cacheData = redisTemplate.opsForHash().get(key, sectionId.toString());
            if (cacheData == null) {
                return null;
            }
            // 2.数据检查和转换todo 为什么要有这个数据转换?
            //  Redis 里存的是 JSON 字符串
            return JsonUtils.toBean(cacheData.toString(), LearningRecord.class);
        } catch (Exception e) {
            log.error("缓存读取异常", e);
            return null;
        }
    }

    /**
     * 删除Redis中已完成的学习记录缓存
     * @param lessonId 课表id
     * @param sectionId 小节id
     */
    public void cleanRecordCache(Long lessonId, Long sectionId){
        // 删除数据
        String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId);
        redisTemplate.opsForHash().delete(key, sectionId.toString());
    }

    /**
     * Redis中保存的学习记录对象
     * 该对象与Redis中存储的学习记录属性保持一致
     */
    @Data
    @NoArgsConstructor
    private static class RecordCacheData{
        private Long id; //小节id,不需要lessonId,lessonId是作为key保存在redis中
        private Integer moment;
        private Boolean finished;

        public RecordCacheData(LearningRecord record) {
            this.id = record.getId();
            this.moment = record.getMoment();
            this.finished = record.getFinished();
        }
    }

    /**
     * 延迟阻塞队列任务对象中保存的学习记录对象
     * 该对象和上面的RecordCacheData有何不同?
     * + 该任务对象需要有lessonId方便查找再次redis的记录进行对比
     * + 没有finished字段,此对象只是拿来和redis中的记录比较moment值
     */
    @Data
    @NoArgsConstructor
    private static class RecordTaskData{
        /** 课表ID,用于定位Redis中的学习记录缓存 */
        private Long lessonId;
        /** 小节ID,用于定位Redis中的学习记录缓存 */
        private Long sectionId;
        /** 视频播放进度(秒),用于与Redis中的最新进度对比,判断用户是否仍在观看 */
        private Integer moment;

        public RecordTaskData(LearningRecord record) {
            this.lessonId = record.getLessonId();
            this.sectionId = record.getSectionId();
            this.moment = record.getMoment();
        }
    }
}

5创建学习计划

java 复制代码
    //创建学习计划
    public void createLearningPlan(Long courseId, Integer freq) {
        Long user = UserContext.getUser();
        LearningLesson lesson = queryByUserAndCourseId(user, courseId);
        if(lesson == null){
            throw new BizIllegalException("课表中没有该课程");
        }
        boolean update = this.lambdaUpdate()
                .set(LearningLesson::getWeekFreq, freq)
                .set(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING)
                .eq(LearningLesson::getId, lesson.getId())
                .update();
        if(!update){
            throw new DbException("课表更新失败");
        }
    }

6查询学习计划进度

思路: 先查询积分->先查询已完成的小节总数量->所有计划小节总数量->所有计划课程集合->每个课程的相关信息(本周已学习小节/本周计划学习小节/课程已学习小节/课程总小节数量)->封装VO并返回

java 复制代码
    //todo ?
    /*查询学习计划
    * 这个方法最终返回给前端一个页面:
    顶部统计区域
    本周已学:10 节
    本周计划:21 节
    本周积分:0
    下方课程列表
    Java 课程(本周学 3 节 / 总共 50 节)
    MySQL 课程(本周学 5 节 / 总共 30 节)
    Vue 课程(本周学 2 节 / 总共 42 节)
    * */
    public LearningPlanPageVO queryMyPlans(PageQuery query) {
        LearningPlanPageVO planPageVO = new LearningPlanPageVO();
        //1.获取用户
        Long user = UserContext.getUser();
        
        //2.获取本周时间范围(复用)
        LocalDate now = LocalDate.now();
        LocalDateTime weekBegin = DateUtils.getWeekBeginTime(now);
        LocalDateTime weekEnd = DateUtils.getWeekEndTime(now);
        
        //3.查询本周积分
        QueryWrapper<PointsRecord> pointsWrapper = new QueryWrapper<>();
        pointsWrapper.select("SUM(points) AS totalPoints");
        pointsWrapper.eq("user_id", user);
        pointsWrapper.between("create_time", weekBegin, weekEnd);
        Map<String, Object> pointsMap = pointsRecordMapper.selectMaps(pointsWrapper).stream()
                .findFirst()
                .orElse(null);
        
        if (pointsMap != null && pointsMap.get("totalPoints") != null) {
            Object totalPoints = pointsMap.get("totalPoints");
            // SUM返回的是BigDecimal类型,需要转换为Integer
            planPageVO.setWeekPoints(Integer.valueOf(totalPoints.toString()));
        } else {
            planPageVO.setWeekPoints(0);
        }
        
        //4.查询课程表信息
        //4.1获取本周已完成小节总数
        Integer weekFinished = recordMapper.selectCount(new LambdaQueryWrapper<LearningRecord>()
                .eq(LearningRecord::getUserId, user)
                .eq(LearningRecord::getFinished, true)
                .gt(LearningRecord::getFinishTime, weekBegin)
                .lt(LearningRecord::getFinishTime, weekEnd)
        );
        planPageVO.setWeekFinished(weekFinished);

        //3.3获取用户所有计划小节数量
        QueryWrapper<LearningLesson> wrapper = new QueryWrapper<>();
        wrapper.select("SUM(week_freq) AS plansTotal");
        wrapper.eq("user_id", user);
        wrapper.in("status", LessonStatus.NOT_BEGIN, LessonStatus.LEARNING);
        wrapper.eq("plan_status", PlanStatus.PLAN_RUNNING);
        Map<String, Object> map = getMap(wrapper);
        if(map != null && map.get("plansTotal") != null){
            //得到结果
            Object plansTotal = map.get("plansTotal");
            //SUM类型默认为BigDecimal,所以需要转换为int
            Integer plans = Integer.valueOf(plansTotal.toString());
            planPageVO.setWeekTotalPlan(plans);
        }else{
            planPageVO.setWeekTotalPlan(0);
        }

        //3.4查询所有有计划的课程集合
        Page<LearningLesson> lessonPage = lambdaQuery()
                .eq(LearningLesson::getUserId, user)
                .eq(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING)
                .in(LearningLesson::getStatus, LessonStatus.NOT_BEGIN, LessonStatus.LEARNING)
                .page(query.toMpPage("latest_learn_time", false));
        //如果没有计划的课程返回一个空分页
        List<LearningLesson> records = lessonPage.getRecords();
        if(CollUtils.isEmpty(records)){
            planPageVO.setTotal(0L);
            planPageVO.setPages(0L);
            planPageVO.setList(CollUtils.emptyList());
            return planPageVO;
        }


        //4.查询课程相关信息
        //查询每一门课程中的:本周已学小节、本周计划小节、小节已学数量、小节总数量、课程名字
        //4.1远程批量查询课程集合
        Map<Long, CourseSimpleInfoDTO> cinfos = queryCourseSimpleInfoList(records);
        //4.2远程批量查询各门课程本周已学小节
        //这里我使用先查出已学小节的集合,然后遍历集合输出map<课表id, 已学小节数量>
        //也可以在mapper写sql查出,做法在在线文档就已经实现
        Map<Long, Long> weekLearnedSections = recordMapper.selectList(
                new LambdaQueryWrapper<LearningRecord>()
                .eq(LearningRecord::getUserId, user)
                .eq(LearningRecord::getFinished,true)
                .gt(LearningRecord::getFinishTime, weekBegin)
                .lt(LearningRecord::getFinishTime, weekEnd)
                //对课表分组就是对课程分组,groupingBy分组时调用counting函数即可对分组中的数量进行统计,统计的结果就是已学小节数
        ).stream().collect(Collectors.groupingBy(LearningRecord::getLessonId, Collectors.counting()));


        //4.2封装每个课程
        List<LearningPlanVO> planVOS = records.stream().map(lesson -> {
            LearningPlanVO learningPlanVO = BeanUtils.copyBean(lesson, LearningPlanVO.class);
            //4.3设置该课程的信息(本来这里写的是远程获取,但是已经有了批量获取课程的方法了,为了提高效率所以这里使用map来直接获取)
            CourseSimpleInfoDTO cinfo = cinfos.get(lesson.getCourseId());
            //小节总数量
            learningPlanVO.setSections(cinfo.getSectionNum());
            //课程名字
            learningPlanVO.setCourseName(cinfo.getName());
            //4.4设置该课程本周已学小节
            Long sections = weekLearnedSections.get(lesson.getId());
            //这里一定要判断一下,因为如果该课程用户本周并没有学习小节时,map就没有该记录
            //所以要给一个默认值0
            if(sections == null){
                learningPlanVO.setWeekLearnedSections(0);
            }else{
                learningPlanVO.setWeekLearnedSections(Integer.valueOf(sections.toString()));
            }
            return learningPlanVO;
        }).collect(Collectors.toList());

        //5.封装pageVo
        return planPageVO.pageInfo(lessonPage.getTotal(), lessonPage.getPages(), planVOS);
    }

7时间工具类DateUtils的代码实现?

不会写也没事,工具类以后可以直接提取到代码中进行使用

java 复制代码
package com.tianji.common.utils;

import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.LocalDateTimeUtil;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;

/**
 * 时间工具类,用于本地时间操作,包含LocalDateTimeUtil的所有方法和自定义的LocalDateTime的操作方法及常量
 *
 * @author wusongsong
 * @version 1.0.0 1.0
 * @see 1.0
 * @since 从哪个版本开始支持该类的功能todo LocalDateTimeUtil?
 */
public class DateUtils extends LocalDateTimeUtil {

    public static final String DEFAULT_YEAR_FORMAT = "yyyy";
    public static final String DEFAULT_MONTH_FORMAT = "yyyy-MM";
    public static final String DEFAULT_MONTH_FORMAT_SLASH = "yyyy/MM";
    public static final String DEFAULT_MONTH_FORMAT_EN = "yyyy年MM月";
    public static final String DEFAULT_MONTH_FORMAT_COMPACT = "yyyyMM";
    public static final String DEFAULT_WEEK_FORMAT = "yyyy-ww";
    public static final String DEFAULT_WEEK_FORMAT_EN = "yyyy年ww周";
    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    public static final String DEFAULT_DATE_FORMAT_EN = "yyyy年MM月dd日";
    public static final String DEFAULT_DATE_FORMAT_COMPACT = "yyyyMMdd";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_DATE_TIME_COMPACT = "yyyyMMddHHmmss";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
    public static final String DAY = "DAY";
    public static final String MONTH = "MONTH";
    public static final String WEEK = "WEEK";
    public static final long MAX_MONTH_DAY = 30L;
    public static final long MAX_3_MONTH_DAY = 90L;
    public static final long MAX_YEAR_DAY = 365L;

    public static final DateTimeFormatter SIGN_DATE_SUFFIX_FORMATTER =
            DateTimeFormatter.ofPattern(":yyyyMM");

    public static final String TIME_ZONE_8 = "GMT+8";

    /**
     * 获取utc时间
     *
     * @param localDateTime 转化时间
     * @return utc时间
     */
    public static LocalDateTime getUTCTime(LocalDateTime localDateTime) {
        ZoneId australia = ZoneId.of("Asia/Shanghai");
        ZonedDateTime dateAndTimeInSydney = ZonedDateTime.of(localDateTime, australia);
        ZonedDateTime utcDate = dateAndTimeInSydney.withZoneSameInstant(ZoneOffset.UTC);
        return utcDate.toLocalDateTime();
    }

    /**
     * 获取Asia时间
     *
     * @param localDateTime 转化时间
     * @return Asia时间
     */
    public static LocalDateTime getAsiaTime(LocalDateTime localDateTime) {
        ZoneId australia = ZoneId.of("Asia/Shanghai");
        ZonedDateTime dateAndTimeInSydney = ZonedDateTime.of(localDateTime, ZoneOffset.UTC);
        ZonedDateTime utcDate = dateAndTimeInSydney.withZoneSameInstant(australia);
        return utcDate.toLocalDateTime();
    }

    /**
     * 获取某一天的开始:0点0分
     *
     * @param localDateTime 指定日期
     * @return 转换后的时间
     */
    public static LocalDateTime getDayStartTime(LocalDateTime localDateTime) {
        if (localDateTime == null) {
            return null;
        }
        return localDateTime.toLocalDate().atStartOfDay();
    }

    /**
     * 获取某一天的结束:23点 59分 59秒的时间
     *
     * @param localDateTime 指定日期
     * @return 转换后的时间
     */
    public static LocalDateTime getDayEndTime(LocalDateTime localDateTime) {
        if (localDateTime == null) {
            return null;
        }
        //todo 为什么这个可以获取某一天的结束?
        return LocalDateTime.of(localDateTime.toLocalDate(), LocalTime.MAX);
    }

    public static Date addDays(int i) {
        Calendar c = Calendar.getInstance(TimeZone.getTimeZone(TIME_ZONE_8));
        c.add(Calendar.DAY_OF_MONTH, i);
        return c.getTime();
    }



    public static LocalDate getMonthBegin(DateTime date) {
        return LocalDate.of(date.getYear(), date.getMonth(), 1);
    }

    public static LocalDate getMonthEnd(LocalDate date) {
        return LocalDate.of(date.getYear(), date.getMonthValue() + 1, 1).minusDays(1);
    }

    public static LocalDateTime getMonthBeginTime(LocalDate date) {
        return LocalDate.of(date.getYear(), date.getMonth(), 1).atStartOfDay();
    }

    public static LocalDateTime getMonthEndTime(LocalDate date) {
        return LocalDate.of(date.getYear(), date.getMonthValue() + 1, 1)
                .minusDays(1).atTime(LocalTime.MAX);
    }

    public static LocalDateTime getWeekBeginTime(LocalDate now) {
        return now.minusDays(now.getDayOfWeek().getValue() - 1).atStartOfDay();
    }

    public static LocalDateTime getWeekEndTime(LocalDate now) {
        return LocalDateTime.of(now.plusDays(8 - now.getDayOfWeek().getValue()), LocalTime.MAX);
    }

    /**
     * 获取最近15天日期(不包含当天),格式MM.dd
     *
     * @return
     */
    public static List<String> last15Day(){
        // 1.定义日期列表
        List<String> days = new ArrayList<>();
        // 2.获取15天前的时间
        LocalDateTime time = now().minusDays(15);
        // 3.for循环遍历
        for (int count = 0; count < 15; count++){
            // 3.1.格式化时间
            days.add(String.format("%s.%s",
                    NumberUtils.repair0(time.getMonthValue(),2), NumberUtils.repair0(time.getDayOfMonth(), 2)));
            // 3.2.日期加1天
            time = time.plusDays(1);
        }
        // 4.返回结果
        return days;
    }
}

8课程过期

编写一个SpringTask定时任务,定期检查learning_lesson表中的课程是否过期,如果过期则将课程状态修改为已过期。

java 复制代码
package com.tianji.learning.task;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.enums.LessonStatus;
import com.tianji.learning.service.ILearningLessonService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

/**
 * 学习课表过期检查定时任务
 * <p>
 * 功能:定期检查 learning_lesson 表中的课程是否过期,如果过期则将课程状态修改为已过期
 * </p>
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class LearningLessonExpireCheckTask {

    private final ILearningLessonService lessonService;

    /**
     * 每小时执行一次,检查并更新过期的学习课表
     * cron表达式:0 0 * * * ? 表示每小时的第0分钟执行
     */
    @Scheduled(cron = "0 0 * * * ?")
    public void checkExpiredLessons() {
        log.info("开始执行学习课表过期检查任务...");
        
        try {
            // 1. 查询所有已过期的学习课表
            // 条件:expire_time 不为空 且 expire_time <= 当前时间 且 状态不是已过期
            LambdaQueryWrapper<LearningLesson> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.isNotNull(LearningLesson::getExpireTime)
                    .le(LearningLesson::getExpireTime, LocalDateTime.now())
                    .ne(LearningLesson::getStatus, LessonStatus.EXPIRED);
            
            List<LearningLesson> expiredLessons = lessonService.list(queryWrapper);
            
            if (expiredLessons == null || expiredLessons.isEmpty()) {
                log.info("没有发现过期的学习课表");
                return;
            }
            
            log.info("发现 {} 个过期的学习课表,开始更新状态...", expiredLessons.size());
            
            // 2. 批量更新状态为已过期
            int updateCount = 0;
            for (LearningLesson lesson : expiredLessons) {
                boolean updated = lessonService.lambdaUpdate()
                        .set(LearningLesson::getStatus, LessonStatus.EXPIRED)
                        .eq(LearningLesson::getId, lesson.getId())
                        .update();
                
                if (updated) {
                    updateCount++;
                    log.debug("课表 ID: {} 已更新为过期状态", lesson.getId());
                }
            }
            
            log.info("学习课表过期检查任务完成,共更新 {} 个课表状态", updateCount);
            
        } catch (Exception e) {
            log.error("学习课表过期检查任务执行失败", e);
        }
    }
}
  1. 确保启用定时任务

在启动类或配置类上添加 @EnableScheduling 注解:

java 复制代码
@SpringBootApplication
@EnableScheduling  // ← 启用 Spring 定时任务
public class LearningApplication {
    public static void main(String[] args) {
        SpringApplication.run(LearningApplication.class, args);
    }
}

9方案思考

思考题:思考一下目前提交学习记录功能可能存在哪些问题?有哪些可以改进的方向?

多次查询数据库 → 性能差

没有做缓存(

  • 学习计划页面是高频访问
  • 每次都查 DB
  • 没有缓存,压力大

  • 手动内存分页,性能差,数据量大容易 OOM
  • 查询全量学习记录再统计,占用内存大
  • 多次数据库查询,IO 次数多
  • 没有使用缓存,频繁访问压力大
  • 手写分页逻辑容易出错

10面试题:

面试官:你在开发中参与了哪些功能开发让你觉得比较有挑战性?

答:我参与了整个学习中心的功能开发,其中有很多的学习辅助功能都很有特色。比如视频播放的进度记录。我们网站的课程是以录播视频为主,为了提高用户的学习体验,需要实现视频续播功能。这个功能本身并不复杂,只不过我们产品提出的要求比较高:

  • 首先续播时间误差要控制在30秒以内。

  • 而且要做到用户突然断开,甚至切换设备后,都可以继续上一次播放

要达成这个目的,使用传统的手段显然是不行的。

首先,要做到切换设备后还能续播,用户的播放进度必须保存在服务端,而不是客户端。

其次,用户突然断开或者切换设备,续播的时间误差不能超过30秒,那播放进度的记录频率就需要比较高。我们会在前端每隔15秒就发起一次心跳请求,提交最新的播放进度,记录到服务端。这样用户下一次续播时直接读取服务端的播放进度,就可以将时间误差控制在15秒左右。

注:此时面试官会追问:播放进度写到服务端保存在哪里?如果写在数据库,那写数据库的压力是不是太大了?等一系列问题,这个会在下一节内容中讲解。

day04 - 高并发优化

1并发优化方案有哪些?

1.1.单机并发能力

在机器性能一定的情况下,提高单机并发能力就是要尽可能缩短业务的响应时间(R esponseT ime),而对响应时间影响最大的往往是对数据库的操作。而从数据库角度来说,我们的业务无非就是两种类型。

对于读多写少的业务,其优化手段大家都比较熟悉了,主要包括两方面:

  • 优化代码和SQL

  • 添加缓存

对于写多读少的业务,大家可能较少碰到,优化的手段可能也不太熟悉,这也是我们要讲解的重点。

对于高并发写的优化方案有:

  • 优化代码及SQL

  • 变同步写为异步写

  • 合并写请求

1.3.合并写请求

合并写请求方案其实是参考高并发读的优化思路:当读数据库并发较高时,我们可以把数据缓存到Redis,这样就无需访问数据库,大大减少数据库压力,减少响应时间。

既然读数据可以建立缓存,那么写数据可以不可以也缓存到Redis呢?

答案是肯定的,合并写请求就是指当写数据库并发较高时,不再直接写到数据库。而是先将数据缓存到Redis,然后定期将缓存中的数据批量写入数据库。

2面试

面试官:你在开发中参与了哪些功能开发让你觉得比较有挑战性?

答:我参与了整个学习中心的功能开发,其中有很多的学习辅助功能都很有特色。比如视频播放的进度记录。我们网站的课程是以录播视频为主,为了提高用户的学习体验,需要实现视频续播功能。这个功能本身并不复杂,只不过我们产品提出的要求比较高:

  • 首先续播时间误差要控制在30秒以内。

  • 而且要做到用户突然断开,甚至切换设备后,都可以继续上一次播放

要达成这个目的,使用传统的手段显然是不行的。

首先,要做到切换设备后还能续播,用户的播放进度必须保存在服务端,而不是客户端。

其次,用户突然断开或者切换设备,续播的时间误差不能超过30秒,那播放进度的记录频率就需要比较高。我们会在前端每隔15秒就发起一次心跳请求,提交最新的播放进度,记录到服务端。这样用户下一次续播时直接读取服务端的播放进度,就可以将时间误差控制在15秒左右。

相关推荐
x***r1511 小时前
jdk-11.0.16.1_windows使用步骤详解(附JDK 11环境变量配置与验证教程)
java·开发语言·windows
弹简特2 小时前
【Java项目-轻聊】01-项目演示+项目介绍+准备工作+项目源码
java
luck_bor2 小时前
File类&递归作业
java·开发语言
武子康3 小时前
Java-07 深入浅出 MyBatis数据库一对多关系模型实战:表结构设计与查询实现
java·后端
REDcker5 小时前
Linux OverlayFS详解
java·linux·运维
Royzst5 小时前
xml知识点
java·服务器·前端
橙子圆1235 小时前
Redis知识9之集群
数据库·redis·缓存
鱼鳞_5 小时前
苍穹外卖-Day08(缓存套餐)
java·redis·缓存
过期动态6 小时前
【LeetCode 热题 100】移动零
java·数据结构·算法·leetcode·职场和发展·rabbitmq