爆款云主机2核4G限时秒杀,88元/年起!
查看详情

活动

天翼云最新优惠活动,涵盖免费试用,产品折扣等,助您降本增效!
热门活动
  • 618智算钜惠季 爆款云主机2核4G限时秒杀,88元/年起!
  • 免费体验DeepSeek,上天翼云息壤 NEW 新老用户均可免费体验2500万Tokens,限时两周
  • 云上钜惠 HOT 爆款云主机全场特惠,更有万元锦鲤券等你来领!
  • 算力套餐 HOT 让算力触手可及
  • 天翼云脑AOne NEW 连接、保护、办公,All-in-One!
  • 中小企业应用上云专场 产品组合下单即享折上9折起,助力企业快速上云
  • 息壤高校钜惠活动 NEW 天翼云息壤杯高校AI大赛,数款产品享受线上订购超值特惠
  • 天翼云电脑专场 HOT 移动办公新选择,爆款4核8G畅享1年3.5折起,快来抢购!
  • 天翼云奖励推广计划 加入成为云推官,推荐新用户注册下单得现金奖励
免费活动
  • 免费试用中心 HOT 多款云产品免费试用,快来开启云上之旅
  • 天翼云用户体验官 NEW 您的洞察,重塑科技边界

智算服务

打造统一的产品能力,实现算网调度、训练推理、技术架构、资源管理一体化智算服务
智算云(DeepSeek专区)
科研助手
  • 算力商城
  • 应用商城
  • 开发机
  • 并行计算
算力互联调度平台
  • 应用市场
  • 算力市场
  • 算力调度推荐
一站式智算服务平台
  • 模型广场
  • 体验中心
  • 服务接入
智算一体机
  • 智算一体机
大模型
  • DeepSeek-R1-昇腾版(671B)
  • DeepSeek-R1-英伟达版(671B)
  • DeepSeek-V3-昇腾版(671B)
  • DeepSeek-R1-Distill-Llama-70B
  • DeepSeek-R1-Distill-Qwen-32B
  • Qwen2-72B-Instruct
  • StableDiffusion-V2.1
  • TeleChat-12B

应用商城

天翼云精选行业优秀合作伙伴及千余款商品,提供一站式云上应用服务
进入甄选商城进入云市场创新解决方案
办公协同
  • WPS云文档
  • 安全邮箱
  • EMM手机管家
  • 智能商业平台
财务管理
  • 工资条
  • 税务风控云
企业应用
  • 翼信息化运维服务
  • 翼视频云归档解决方案
工业能源
  • 智慧工厂_生产流程管理解决方案
  • 智慧工地
建站工具
  • SSL证书
  • 新域名服务
网络工具
  • 翼云加速
灾备迁移
  • 云管家2.0
  • 翼备份
资源管理
  • 全栈混合云敏捷版(软件)
  • 全栈混合云敏捷版(一体机)
行业应用
  • 翼电子教室
  • 翼智慧显示一体化解决方案

合作伙伴

天翼云携手合作伙伴,共创云上生态,合作共赢
天翼云生态合作中心
  • 天翼云生态合作中心
天翼云渠道合作伙伴
  • 天翼云代理渠道合作伙伴
天翼云服务合作伙伴
  • 天翼云集成商交付能力认证
天翼云应用合作伙伴
  • 天翼云云市场合作伙伴
  • 天翼云甄选商城合作伙伴
天翼云技术合作伙伴
  • 天翼云OpenAPI中心
  • 天翼云EasyCoding平台
天翼云培训认证
  • 天翼云学堂
  • 天翼云市场商学院
天翼云合作计划
  • 云汇计划
天翼云东升计划
  • 适配中心
  • 东升计划
  • 适配互认证

开发者

开发者相关功能入口汇聚
技术社区
  • 专栏文章
  • 互动问答
  • 技术视频
资源与工具
  • OpenAPI中心
开放能力
  • EasyCoding敏捷开发平台
培训与认证
  • 天翼云学堂
  • 天翼云认证
魔乐社区
  • 魔乐社区

支持与服务

为您提供全方位支持与服务,全流程技术保障,助您轻松上云,安全无忧
文档与工具
  • 文档中心
  • 新手上云
  • 自助服务
  • OpenAPI中心
定价
  • 价格计算器
  • 定价策略
基础服务
  • 售前咨询
  • 在线支持
  • 在线支持
  • 工单服务
  • 建议与反馈
  • 用户体验官
  • 服务保障
  • 客户公告
  • 会员中心
增值服务
  • 红心服务
  • 首保服务
  • 客户支持计划
  • 专家技术服务
  • 备案管家

了解天翼云

天翼云秉承央企使命,致力于成为数字经济主力军,投身科技强国伟大事业,为用户提供安全、普惠云服务
品牌介绍
  • 关于天翼云
  • 智算云
  • 天翼云4.0
  • 新闻资讯
  • 天翼云APP
基础设施
  • 全球基础设施
  • 信任中心
最佳实践
  • 精选案例
  • 超级探访
  • 云杂志
  • 分析师和白皮书
  • 天翼云·创新直播间
市场活动
  • 2025智能云生态大会
  • 2024智算云生态大会
  • 2023云生态大会
  • 2022云生态大会
  • 天翼云中国行
天翼云
  • 活动
  • 智算服务
  • 产品
  • 解决方案
  • 应用商城
  • 合作伙伴
  • 开发者
  • 支持与服务
  • 了解天翼云
      • 文档
      • 控制中心
      • 备案
      • 管理中心

      SpringCloud天机学堂:高并发优化(五)

      首页 知识中心 软件开发 文章详情页

      SpringCloud天机学堂:高并发优化(五)

      2024-12-05 08:49:34 阅读次数:20

      Redis,延迟,数据库,缓存,记录

      1、高并发优化方案

      解决高并发问题从宏观角度来说有3个方向:

      SpringCloud天机学堂:高并发优化(五)

      其中,水平扩展和服务保护侧重的是运维层面的处理。而提高单机并发能力侧重的则是业务层面的处理,也就是我们程序员在开发时可以做到的。

      服务保护:常见的流量控制、降级保护、服务熔断等措施。

      1.1、单机并发能力

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

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

      • 优化代码和SQL
      • 添加缓存

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

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

      • 优化代码及SQL
      • 变同步写为异步写
      • 合并写请求
      1.2、变同步为异步

      假如一个业务比较复杂,需要有多次数据库的写业务,如图所示:

      SpringCloud天机学堂:高并发优化(五)

      由于各个业务之间是同步串行执行,因此整个业务的响应时间就是每一次数据库写业务的响应时间之和,并发能力肯定不会太好。

      优化的思路很简单,我们之前讲解MQ的时候就说过,利用MQ可以把同步业务变成异步,从而提高效率。

      • 当我们接收到用户请求后,可以先不处理业务,而是发送MQ消息并返回给用户结果。
      • 而后通过消息监听器监听MQ消息,处理后续业务。

      如图:

      SpringCloud天机学堂:高并发优化(五)

      这样一来,用户请求处理和后续数据库写就从同步变为异步,用户无需等待后续的数据库写操作,响应时间自然会大大缩短。并发能力自然大大提高。

      优点:

      • 无需等待复杂业务处理,大大减少响应时间
      • 利用MQ暂存消息,起到流量削峰整形作用
      • 降低写数据库频率,减轻数据库并发压力

      缺点:

      • 依赖于MQ的可靠性
      • 降低了些频率,但是没有减少数据库写次数

      应用场景:

      • 比较适合应用于业务复杂, 业务链较长,有多次数据库写操作的业务。
      1.3、合并写请求

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

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

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

      如图:

      SpringCloud天机学堂:高并发优化(五)

      由于Redis是内存操作,写的效率也非常高,这样每次请求的处理速度大大提高,响应时间大大缩短,并发能力肯定有很大的提升。

      而且由于数据都缓存到Redis了,积累一些数据后再批量写入数据库,这样数据库的写频率、写次数都大大减少,对数据库压力小了非常多!

      优点:

      • 写缓存速度快,响应时间大大减少
      • 降低数据库的写频率和写次数,大大减轻数据库压力

      缺点:

      • 实现相对复杂
      • 依赖Redis可靠性
      • 不支持事务和复杂业务

      场景:

      • 写频率较高、写业务相对简单的场景
      2、播放进度记录方案改进

      播放进度统计包含大量的数据库读、写操作。不过保存播放记录还是以写数据库为主。因此优化的方向还是以高并发写优化为主。

      大家思考一下,针对播放进度记录业务来说,应该采用哪种优化方案呢?

      • 变同步为异步?
      • 合并写?
      2.1、优化方案选择

      虽然播放进度记录业务较为复杂,但是我们认真思考一下整个业务分支:

      SpringCloud天机学堂:高并发优化(五)

      • 考试:每章只能考一次,还不能重复考试。因此属于低频行为,可以忽略
      • 视频进度:前端每隔15秒就提交一次请求。在一个视频播放的过程中,可能有数十次请求,但完播(进度超50%)的请求只会有一次。因此多数情况下都是更新一下播放进度即可。

      也就是说,95%的请求都是在更新learning_record表中的moment字段,以及learning_lesson表中的正在学习的小节id和时间。

      SpringCloud天机学堂:高并发优化(五)

      而播放进度信息,不管更新多少次,下一次续播肯定是从最后的一次播放进度开始续播。也就是说我们只需要记住最后一次即可。因此可以采用合并写方案来降低数据库写的次数和频率,而异步写做不到。

      综上,提交播放进度业务虽然看起来复杂,但大多数请求的处理很简单,就是更新播放进度。并且播放进度数据是可以合并的(覆盖之前旧数据)。我们建议采用合并写请求方案:

      SpringCloud天机学堂:高并发优化(五)

      2.2、Redis数据结构设计

      我们先讨论下Redis缓存中需要记录哪些数据。

      我们的优化方案要处理的不是所有的提交学习记录请求。仅仅是视频播放时的高频更新播放进度的请求,对应的业务分支如图:

      SpringCloud天机学堂:高并发优化(五)

      这条业务支线的流程如下:

      • 查询播放记录,判断是否存在
        • 如果不存在,新增一条记录
        • 如果存在,则更新学习记录
      • 判断当前进度是否是第一次学完
        • 播放进度要超过50%
        • 原本的记录状态是未学完
      • 更新课表中最近学习小节id、学习时间

      这里有多次数据库操作,例如:

      • 查询播放记录:需要知道播放记录是否存在、播放记录当前的完成状态
      • 更新播放记录:更新播放进度
      • 更新最近学习小节id、时间

      一方面我们要缓存写数据,减少写数据库频率;另一方面我们要缓存播放记录,减少查询数据库。因此,缓存中至少要包含3个字段:

      • 记录id:id,用于根据id更新数据库
      • 播放进度:moment,用于缓存播放进度
      • 播放状态(是否学完):finished,用于判断是否是第一次学完

      既然一个小节要保存多个字段,是不是可以考虑使用Hash结构来保存这些数据,如图:

      SpringCloud天机学堂:高并发优化(五)

      不过,这样设计有一个问题。课程有很多,每个课程的小节也非常多。每个小节都是一个独立的KEY,需要创建的KEY也会非常多,浪费大量内存。

      而且,用户学习视频的过程中,可能会在多个视频之间来回跳转,这就会导致频繁的创建缓存、缓存过期,影响到最终的业务性能。该如何解决呢?

      既然一个课程包含多个小节,我们完全可以把一个课程的多个小节作为一个KEY来缓存,如图:

      SpringCloud天机学堂:高并发优化(五)

      这样做有两个好处:

      • 可以大大减少需要创建的KEY的数量,减少内存占用。
      • 一个课程创建一个缓存,当用户在多个视频间跳转时,整个缓存的有效期都会被延续,不会频繁的创建和销毁缓存数据

      添加缓存以后,学习记录提交的业务流程就需要发生一些变化了,如图:

      SpringCloud天机学堂:高并发优化(五)

      变化最大的有两点:

      • 提交播放进度后,如果是更新播放进度则不写数据库,而是写缓存
      • 需要一个定时任务,定期将缓存数据写入数据库

      变化后的业务具体流程为:

      • 1.提交学习记录
      • 2.判断是否是考试
        • 是:新增学习记录,并标记有小节被学完。走步骤8
        • 否:走视频流程,步骤3
      • 3.查询播放记录缓存,如果缓存不存在则查询数据库并建立缓存
      • 4.判断记录是否存在
        • 4.1.否:新增一条学习记录
        • 4.2.是:走更新学习记录流程,步骤5
      • 5.判断是否是第一次学完(进度超50%,旧的状态是未学完)
        • 5.1.否:仅仅是要更新播放进度,因此直接写入Redis并结束
        • 5.2.是:代表小节学完,走步骤6
      • 6.更新学习记录状态为已学完
      • 7.清理Redis缓存:因为学习状态变为已学完,与缓存不一致,因此这里清理掉缓存,这样下次查询时自然会更新缓存,保证数据一致。
      • 8.更新课表中已学习小节的数量+1
      • 9.判断课程的小节是否全部学完
        • 是:更新课表状态为已学完
        • 否:结束
      2.3、持久化思路

      对于合并写请求方案,一定有一个步骤就是持久化缓存数据到数据库。一般采用的是定时任务持久化:

      SpringCloud天机学堂:高并发优化(五)

      但是定时任务的持久化方式在播放进度记录业务中存在一些问题,主要就是时效性问题。我们的产品要求视频续播的时间误差不能超过30秒。

      • 假如定时任务间隔较短,例如20秒一次,对数据库的更新频率太高,压力太大
      • 假如定时任务间隔较长,例如2分钟一次,更新频率较低,续播误差可能超过2分钟,不满足需求

      注意:

      如果产品对于时间误差要求不高,定时任务处理是最简单,最可靠的一种方案,推荐大家使用。

      那么问题来了,有什么办法能够在不增加数据库压力的情况下,保证时间误差较低吗?

      假如一个视频时长为20分钟,我们从头播放至15分钟关闭,每隔15秒提交一次播放进度,大概需要提交60次请求。

      但是下一次我们再次打开该视频续播的时候,肯定是从最后一次提交的播放进度来续播。也就是说续播进度之前的N次播放进度都是没有意义的,都会被覆盖。

      既然如此,我们完全没有必要定期把这些播放进度写到数据库,只需要将用户最后一次提交的播放进度写入数据库即可。

      但问题来了,我们怎么知道哪一次提交是最后一次提交呢?

      只要用户一直在提交记录,Redis中的播放进度就会一直变化。如果Redis中的播放进度不变,肯定是停止了播放,是最后一次提交。

      因此,我们只要能判断Redis中的播放进度是否变化即可。怎么判断呢?

      每当前端提交播放记录时,我们可以设置一个延迟任务并保存这次提交的进度。等待20秒后(因为前端每15秒提交一次,20秒就是等待下一次提交),检查Redis中的缓存的进度与任务中的进度是否一致。

      • 不一致:说明持续在提交,无需处理
      • 一致:说明是最后一次提交,更新学习记录、更新课表最近学习小节和时间到数据库中

      流程如下:

      SpringCloud天机学堂:高并发优化(五)

      3、延迟任务

      为了确定用户提交的播放记录是否变化,我们需要将播放记录保存为一个延迟任务,等待超过一个提交周期(20s)后检查播放进度。

      那么延迟任务该如何实现呢?

      3.1、延迟任务方案

      延迟任务的实现方案有很多,常见的有四类:

        DelayQueue Redisson MQ 时间轮
      原理 JDK自带延迟队列,基于阻塞队列实现。 基于Redis数据结构模拟JDK的DelayQueue实现 利用MQ的特性。例如RabbitMQ的死信队列 时间轮算法
      优点 不依赖第三方服务 分布式系统下可用不占用JVM内存 分布式系统下可以不占用JVM内存 不依赖第三方服务性能优异
      缺点 占用JVM内存只能单机使用 依赖第三方服务 依赖第三方服务 只能单机使用

      以上四种方案都可以解决问题,不过本例中我们会使用DelayQueue方案。因为这种方案使用成本最低,而且不依赖任何第三方服务,减少了网络交互。

      但缺点也很明显,就是需要占用JVM内存,在数据量非常大的情况下可能会有问题。但考虑到任务存储时间比较短(只有20秒),因此也可以接收。

      如果你们的数据量非常大,DelayQueue不能满足业务需求,大家也可以替换为其它延迟队列方式,例如Redisson、MQ等

      3.2、DelayQueue的原理

      首先来看一下DelayQueue的源码:

      public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
          implements BlockingQueue<E> {
      
          private final transient ReentrantLock lock = new ReentrantLock();
          private final PriorityQueue<E> q = new PriorityQueue<E>();
          
          // ... 略
      }
      

      可以看到DelayQueue实现了BlockingQueue接口,是一个阻塞队列。队列就是容器,用来存储东西的。DelayQueue叫做延迟队列,其中存储的就是延迟执行的任务。

      我们可以看到DelayQueue的泛型定义:

      DelayQueue<E extends Delayed>
      

      这说明存入DelayQueue内部的元素必须是Delayed类型,这其实就是一个延迟任务的规范接口。来看一下:

      public interface Delayed extends Comparable<Delayed> {
      
          /**
           * Returns the remaining delay associated with this object, in the
           * given time unit.
           *
           * @param unit the time unit
           * @return the remaining delay; zero or negative values indicate
           * that the delay has already elapsed
           */
          long getDelay(TimeUnit unit);
      }
      

      从源码中可以看出,Delayed类型必须具备两个方法:

      • getDelay():获取延迟任务的剩余延迟时间
      • compareTo(T t):比较两个延迟任务的延迟时间,判断执行顺序

      可见,Delayed类型的延迟任务具备两个功能:获取剩余延迟时间、比较执行顺序。当然,我们可以对Delayed做实现和功能扩展,比如添加延迟任务的数据。

      将来每一次提交播放记录,就可以将播放记录保存在这样的一个Delayed类型的延迟任务里并设定20秒的延迟时间。然后交给DelayQueue队列。DelayQueue会调用compareTo方法,根据剩余延迟时间对任务排序。剩余延迟时间越短的越靠近队首,这样就会被优先执行。

      3.3、DelayQueue的用法

      首先定义一个Delayed类型的延迟任务类,要能保持任务数据。

      @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;
              }
          }
      }
      

      接下来就可以创建延迟任务,交给延迟队列保存:

      @Slf4j
      class DelayTaskTest {
          @Test
          void testDelayQueue() throws InterruptedException {
              // 1.初始化延迟队列
              DelayQueue<DelayTask<String>> queue = new DelayQueue<>();
              // 2.向队列中添加延迟执行的任务
              ("开始初始化延迟任务。。。。");
              queue.add(new DelayTask<>("延迟任务3", Duration.ofSeconds(6)));
              queue.add(new DelayTask<>("延迟任务1", Duration.ofSeconds(2)));
              queue.add(new DelayTask<>("延迟任务2", Duration.ofSeconds(4)));
              // 3.尝试执行任务
              while (!queue.isEmpty()) {
                  // poll:非阻塞方法,take非阻塞方法
                  DelayTask<String> task = queue.take();
                  ("开始执行延迟任务:{}", task.getData());
              }
          }
      }
      

      最后,补上执行任务的代码:

      @Slf4j
      class DelayTaskTest {
          @Test
          void testDelayQueue() throws InterruptedException {
              // 1.初始化延迟队列
              DelayQueue<DelayTask<String>> queue = new DelayQueue<>();
              // 2.向队列中添加延迟执行的任务
              ("开始初始化延迟任务。。。。");
              queue.add(new DelayTask<>("延迟任务3", Duration.ofSeconds(3)));
              queue.add(new DelayTask<>("延迟任务1", Duration.ofSeconds(1)));
              queue.add(new DelayTask<>("延迟任务2", Duration.ofSeconds(2)));
              // 3.尝试执行任务
              while (true) {
                  DelayTask<String> task = queue.take();
                  ("开始执行延迟任务:{}", task.getData());
              }
          }
      }
      

      注意:

      这里我们是直接同一个线程来执行任务了。当没有任务的时候线程会被阻塞。而在实际开发中,我们会准备线程池,开启多个线程来执行队列中的任务。

      4、代码改造

      接下来,我们就可以按照之前分析的方案来改造代码了。

      4.1、定义延迟任务工具类

      首先,我们要定义一个工具类,帮助我们改造整个业务。在提交学习记录业务中,需要用到异步任务和缓存的地方有以下几处:

      SpringCloud天机学堂:高并发优化(五)

      因此,我们的工具类就应该具备上述4个方法:

      • ① 添加播放记录到Redis,并添加一个延迟检测任务到DelayQueue
      • ② 查询Redis缓存中的指定小节的播放记录
      • ③ 删除Redis缓存中的指定小节的播放记录
      • ④ 异步执行DelayQueue中的延迟检测任务,检测播放进度是否变化,如果无变化则写入数据库

      工具类代码如下:

      @Slf4j
      @RequiredArgsConstructor
      @Component
      public class LearningRecordDelayTaskHandler {
      
          private final StringRedisTemplate redisTemplate;
          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;
          // volatile关键字:在多线程环境中,当一个线程修改了这个变量的值,其他线程能够立即看到最新的值。
          private static volatile boolean begin = true;
      
          // 项目启动后,当前类实例化 属性注入值后 该方法会运行,一般用于初始化工作
          @PostConstruct
          public void init(){
              ("init方法执行了");
              CompletableFuture.runAsync(this::handleDelayTask);
          }
      
          // 项目销毁前后,关闭延迟队列
          @PreDestroy
          public void destroy(){
              log.debug("关闭学习记录处理的延迟任务");
              begin = false;
          }
      
          /**
           * 处理延时任务
           */
          private void handleDelayTask(){
              while (begin){
                  try {
                      // 1.尝试获取任务,poll:非阻塞方法,take非阻塞方法
                      DelayTask<RecordTaskData> task = queue.take();
                      RecordTaskData data = task.getData();
                      // 2.读取Redis缓存
                      LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId());
                      log.debug("获取到要处理的播放记录任务,任务数据:{},缓存数据:{}",task.getData(),record);
                      if (record == null) {
                          continue;
                      }
                      // 3.比较新提交的延迟任务的视频播放进度数值和redis缓存中的是否一致
                      if(!Objects.equals(data.getMoment(), record.getMoment())){
                          // 4.如果不一致,播放进度在变化,无需持久化
                          continue;
                      }
                      // 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,并提交延迟任务到延迟队列DelayQueue
           * @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    学习记录信息
           */
          public void writeRecordCache(LearningRecord record) {
              log.debug("更新学习记录的缓存数据");
              try {
                  // 1.数据转换
                  String json = JsonUtils.toJsonStr(new RecordCacheData(record));
                  // 2.拼装Hash的key
                  String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId());
                  // 写入redis
                  redisTemplate.opsForHash().put(key, record.getSectionId().toString(), json);
                  // 3.添加缓存过期时间:1分钟
                  redisTemplate.expire(key, Duration.ofMinutes(1));
              } catch (Exception e) {
                  log.error("更新学习记录缓存异常", e);
              }
          }
      
          /**
           * 查询redis指定学习记录
           */
          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.数据检查和转换
                  // 补充传入lessonId和sectionId
                  return JsonUtils.toBean(cacheData.toString(), LearningRecord.class)
                          .setLessonId(lessonId)
                          .setSectionId(sectionId);
              } 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);
              // hash的key保留,删除value
              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();
              }
          }
      }
      
      
      4.2、改造提交学习记录功能

      接下来,改造提交学习记录的功能:

      @Slf4j
      @Service
      @RequiredArgsConstructor
      public class LearningRecordServiceImpl extends ServiceImpl<LearningRecordMapper, LearningRecord> implements ILearningRecordService {
      
          private final ILearningLessonService learningLessonService;
      
          private final CourseClient courseClient;
      
          private final LearningRecordDelayTaskHandler taskHandler;
      
          /**
           * 查询当前用户指定课程的学习进度
           *
           * @param courseId 课程id
           * @return 课表信息、学习记录及进度信息
           */
          @Override
          public LearningLessonDTO queryLearningRecordByCourse(Long courseId) {
              // 获取当前登录用户
              Long userId = UserContext.getUser();
              // 根据用户userId和课程courseId获取最近学习的小节id和课表id
              LearningLesson learningLesson = learningLessonService.lambdaQuery()
                      .eq(LearningLesson::getCourseId, courseId)
                      .eq(LearningLesson::getUserId, userId).one();
              // 判NULL防止NPE
              if (Objects.isNull(learningLesson)) {
                  throw new BizIllegalException("该课程未加入课表");
              }
              // 根据课表id获取学习记录
              List<LearningRecord> learningRecordList = this.lambdaQuery()
                      .eq(LearningRecord::getLessonId, learningLesson.getId()).list();
              // copyToList有判空校验,不再赘余
              List<LearningRecordDTO> learningRecordDTOList = BeanUtil.copyToList(learningRecordList, LearningRecordDTO.class);
              // 封装结果到DTO
              LearningLessonDTO learningLessonDTO = new LearningLessonDTO();
              learningLessonDTO.setId(learningLesson.getId());
              learningLessonDTO.setLatestSectionId(learningLesson.getLatestSectionId());
              learningLessonDTO.setRecords(learningRecordDTOList);
              return learningLessonDTO;
      
          }
      
      
          /**
           * 提交学习记录
           *
           * @param dto 学习记录表单
           */
          @Override
          public void submitLearningRecord(LearningRecordFormDTO dto) {
              // 获取当前登录用户
              Long userId = UserContext.getUser();
              // 处理学习记录
              boolean finished = false;
              if (dto.getSectionType().equals(SectionType.EXAM)) {
                  // 提交考试记录
                  finished = handleExamRecord(userId, dto);
              } else {
                  // 提交视频播放记录
                  finished = handleVideoRecord(userId, dto);
              }
              // 如果本小节不是首次学完,由于使用了异步延迟任务,不需要往下执行
              if (!finished) {
                  return;
              }
              // 处理课表数据
              handleLessonData(dto);
          }
      
          /**
           * 是否已完成该小节
           * 处理课表数据
           */
          private void handleLessonData(LearningRecordFormDTO dto) {
              // 根据lessonId查询课表记录
              LearningLesson learningLesson = learningLessonService.getById(dto.getLessonId());
              if (learningLesson == null) {
                  throw new BizIllegalException("未查询到课表记录");
              }
              // boolean allFinished = false;
              // Integer allSections = 0;
              // 由finished字段可知是否是否为第一次完成小节
              // 使用异步延迟延误后不需要判断了
              // feign远程调用课程服务,查询课程信息的小节综述
              CourseFullInfoDTO courseInfo = courseClient.getCourseInfoById(learningLesson.getCourseId(), false, false);
              if (courseInfo == null) {
                  throw new BizIllegalException("未查询到课程记录");
              }
              Integer allSections = courseInfo.getSectionNum();   // 该课程所有小节数
              // 判断该课程所有小节是否已学完
              boolean allFinished = learningLesson.getLearnedSections() + 1 >= allSections;
              // 更新课表信息:课表状态,已学小节数,最近学习小节id,最近学习时间
              learningLessonService.lambdaUpdate()
                      // 如果当前小节未学完,更新最近学习小节id和最近学习时间
                      .set(LearningLesson::getLatestSectionId, learningLesson.getLatestSectionId() + 1)
                      .set(LearningLesson::getLatestLearnTime, dto.getCommitTime())
                      // 如果当前小姐已学完,更新已学小节数
                      .set(LearningLesson::getLearnedSections, learningLesson.getLearnedSections())
                      // 如果该课表所以小节已学完,则更新课表状态为已学完
                      .set(allFinished, LearningLesson::getStatus, LessonStatus.FINISHED)
                      // 首次学习需要将状态由未开始更新为学习中
                      // .set(learningLesson.getLearnedSections() == 0 , LearningLesson::getStatus, LessonStatus.LEARNING)
                      .set(learningLesson.getStatus() == LessonStatus.NOT_BEGIN, LearningLesson::getStatus, LessonStatus.LEARNING)
                      .eq(LearningLesson::getId, learningLesson.getId())
                      .update();
      
          }
      
          /**
           * 处理该小节视频播放记录
           *
           * @param userId 用户id
           * @param dto    学习记录DTO
           * @return 是否已完成该小节
           */
          private boolean handleVideoRecord(Long userId, LearningRecordFormDTO dto) {
              // 查询该小节视频进度记录是否已存在,根据lessonId和sectionId进行匹配
              LearningRecord oldRecord = queryOldRecord(dto.getLessonId(), dto.getSectionId());
              // 根据查询结果来判断是新增还是删除
              if (oldRecord == null) {
                  // po转dto
                  LearningRecord learningRecord = BeanUtil.toBean(dto, LearningRecord.class);
                  // 视频播放小节是否已完成根据
                  learningRecord.setUserId(userId);
                  // 保存到Learning-record表
                  // 由于前段每15秒发送提交学习记录请求,所以新增时默认未完成
                  boolean result = this.save(learningRecord);
                  if (!result) {
                      throw new DbException("新增视频播放记录失败");
                  }
                  // 返回false是因为新增
                  return false;
              }
              // 判断本小节是否是首次完成:之前未完成且视频播放进度大于50%
              boolean isFinished = !oldRecord.getFinished() && dto.getMoment() * 2 >= dto.getDuration();
              // 更新视频播放进度,根据主键id进行匹配
              if (!isFinished) {
                  LearningRecord record = LearningRecord.builder()
                          .id(oldRecord.getId())
                          .lessonId(dto.getLessonId())
                          .sectionId(dto.getSectionId())
                          .finished(oldRecord.getFinished())
                          .moment(dto.getMoment())
                          .build();
                  // 添加指定学习记录到redis,并提交延迟任务到延迟队列DelayQueue
                  taskHandler.addLearningRecordTask(record);
                  // 返回,本小节未完成
                  return false;
              }
              boolean result = this.lambdaUpdate()
                      .set(LearningRecord::getMoment, dto.getMoment())
                      // 只有首次完成视频播放才更新finished字段和finish_time字段
                      .set(LearningRecord::getFinished, true)
                      .set(LearningRecord::getFinishTime, dto.getCommitTime())
                      .eq(LearningRecord::getId, oldRecord.getId())
                      .update();
              if (!result) {
                  throw new DbException("更新视频播放记录失败");
              }
              // 清理redis相应record
              taskHandler.cleanRecordCache(dto.getLessonId(), dto.getSectionId());
              return true;
          }
      
          /**
           * 查询指定学习记录是否已存在,
           */
          private LearningRecord queryOldRecord(Long lessonId, Long sectionId) {
              // 查询redis缓存
              LearningRecord cacheRecord = taskHandler.readRecordCache(lessonId, sectionId);
              // redis缓存命中
              if (cacheRecord != null) {
                  return cacheRecord;
              }
              // redis缓存未命中,查询数据库
              LearningRecord dbRecord = this.lambdaQuery().eq(LearningRecord::getLessonId, lessonId)
                      .eq(LearningRecord::getSectionId, sectionId).one();
              // 数据库查询结果为null,表示记录不存在,需要新增学习记录,返回null即可
              if (dbRecord == null) {
                  return null;
              }
              // 数据库查询结果写入redis缓存
              taskHandler.writeRecordCache(dbRecord);
              return dbRecord;
          }
      
          /**
           * 处理该小节考试记录
           *
           * @param userId 用户id
           * @param dto    学习记录DTO
           * @return 是否已完成该小节
           */
          private boolean handleExamRecord(Long userId, LearningRecordFormDTO dto) {
              // po转dto
              LearningRecord learningRecord = BeanUtil.toBean(dto, LearningRecord.class);
              // 考试小节提交后默认已完成
              learningRecord.setUserId(userId)
                      .setFinished(true)
                      .setFinishTime(dto.getCommitTime());
              // 保存到Learning-record表
              boolean result = this.save(learningRecord);
              if (!result) {
                  throw new DbException("新增考试记录失败");
              }
              return true;
          }
      }
      
      
      版权声明:本文内容来自第三方投稿或授权转载,原文地址:https://xlxbc.blog.csdn.net/article/details/141174728,作者:小林学习编程,版权归原作者所有。本网站转在其作品的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如因作品内容、版权等问题需要同本网站联系,请发邮件至ctyunbbs@chinatelecom.cn沟通。

      上一篇:static关键字在Java中的作用与用法

      下一篇:用户态协议栈04-定时arp-table的实现

      相关文章

      2025-05-19 09:05:01

      项目更新到公网服务器的操作步骤

      项目更新到公网服务器的操作步骤

      2025-05-19 09:05:01
      公网 , 数据库 , 文件 , 更新 , 服务器
      2025-05-19 09:04:53

      Django rest froamwork-ModelSerializer

      Django rest froamwork-ModelSerializer

      2025-05-19 09:04:53
      django , sqlite , 数据库
      2025-05-19 09:04:38

      mysql只有在任务处于完成状态才能运行

      mysql只有在任务处于完成状态才能运行

      2025-05-19 09:04:38
      MySQL , 任务 , 数据库 , 查询 , 状态
      2025-05-19 09:04:30

      设置28401事件后启动数据库时报错ORA-49100

      设置28401事件后启动数据库时报错ORA-49100

      2025-05-19 09:04:30
      ORA , 数据库 , 时报
      2025-05-16 09:15:24

      Redis Hash哈希

      Redis Hash哈希

      2025-05-16 09:15:24
      field , hash , Redis , value , 哈希
      2025-05-14 10:03:13

      MySQL 索引优化以及慢查询优化

      MySQL 是一种广泛使用的关系型数据库管理系统,因其性能优异和使用便捷而备受欢迎。然而,随着数据量的增长和查询复杂度的增加,性能瓶颈也变得越来越明显。

      2025-05-14 10:03:13
      MySQL , 优化 , 使用 , 性能 , 数据库 , 查询 , 索引
      2025-05-14 10:03:13

      【Mybatis】-防止SQL注入

      【Mybatis】-防止SQL注入

      2025-05-14 10:03:13
      SQL , 执行 , 日志 , 注入 , 缓存 , 编译 , 语句
      2025-05-14 10:03:05

      Oracle数据库用户权限分析

      Oracle数据库用户权限分析

      2025-05-14 10:03:05
      Oracle , 分析 , 数据库 , 权限 , 用户
      2025-05-14 10:02:48

      互斥锁解决redis缓存击穿

      在高并发系统中,Redis 缓存是一种常见的性能优化方式。然而,缓存击穿问题也伴随着高并发访问而来。

      2025-05-14 10:02:48
      Redis , 互斥 , 数据库 , 线程 , 缓存 , 请求
      2025-05-14 10:02:48

      SQL Server 事务日志体系结构1--基本术语

      事务包括对数据库的一次更改或一系列更改。它有一个明确开始和明确结束。开始时使用BEGIN TRANSACTION语句,或者SQL Server会自动为您开始一个事务。

      2025-05-14 10:02:48
      Server , SQL , 事务 , 数据库 , 日志 , 磁盘
      查看更多
      推荐标签

      作者介绍

      天翼云小翼
      天翼云用户

      文章

      33561

      阅读量

      5251857

      查看更多

      最新文章

      互斥锁解决redis缓存击穿

      2025-05-14 10:02:48

      springboot实战学习(1)(开发模式与环境)

      2025-05-09 08:50:35

      java Swing学生成绩管理系统【项目源码+数据库脚本】

      2025-05-08 09:03:21

      springboot酒店管理系统分前后端【源码+数据库】

      2025-05-08 09:03:21

      基础—SQL—DCL(数据控制语言)之权限控制

      2025-05-07 09:10:01

      基础—SQL—DCL(数据控制语言)之用户管理

      2025-05-07 09:09:52

      查看更多

      热门文章

      Python数据库测试实战教程

      2023-06-07 07:31:52

      Hibernate注解开发关于Id的若干问题

      2022-12-29 09:29:46

      Python基础教程(第3版)中文版 第13章 数据库支持(笔记)

      2023-02-13 07:55:59

      2023爬虫学习笔记 -- Python链接Mysql数据库

      2023-05-04 09:43:57

      【Redis】 GEO基本用法、实现查找附近商户功能

      2023-06-12 09:39:03

      SpringBoot-技术专区-Redis同数据源动态切换db

      2023-06-12 09:25:54

      查看更多

      热门标签

      java Java python 编程开发 代码 开发语言 算法 线程 Python html 数组 C++ 元素 javascript c++
      查看更多

      相关产品

      弹性云主机

      随时自助获取、弹性伸缩的云服务器资源

      天翼云电脑(公众版)

      便捷、安全、高效的云电脑服务

      对象存储

      高品质、低成本的云上存储服务

      云硬盘

      为云上计算资源提供持久性块存储

      查看更多

      随机文章

      springboot校园宿舍管理系统前后端分离

      缓存击穿和缓存雪崩的区别是什么?

      Redis 新特性篇:多线程模型解读

      vue中使用分页组件、将从数据库中查询出来的数据分页展示(前后端分离SpringBoot+Vue)

      PHP 简单案例[1]

      使用多线程Callable查询oracle数据库

      • 7*24小时售后
      • 无忧退款
      • 免费备案
      • 专家服务
      售前咨询热线
      400-810-9889转1
      关注天翼云
      • 旗舰店
      • 天翼云APP
      • 天翼云微信公众号
      服务与支持
      • 备案中心
      • 售前咨询
      • 智能客服
      • 自助服务
      • 工单管理
      • 客户公告
      • 涉诈举报
      账户管理
      • 管理中心
      • 订单管理
      • 余额管理
      • 发票管理
      • 充值汇款
      • 续费管理
      快速入口
      • 天翼云旗舰店
      • 文档中心
      • 最新活动
      • 免费试用
      • 信任中心
      • 天翼云学堂
      云网生态
      • 甄选商城
      • 渠道合作
      • 云市场合作
      了解天翼云
      • 关于天翼云
      • 天翼云APP
      • 服务案例
      • 新闻资讯
      • 联系我们
      热门产品
      • 云电脑
      • 弹性云主机
      • 云电脑政企版
      • 天翼云手机
      • 云数据库
      • 对象存储
      • 云硬盘
      • Web应用防火墙
      • 服务器安全卫士
      • CDN加速
      热门推荐
      • 云服务备份
      • 边缘安全加速平台
      • 全站加速
      • 安全加速
      • 云服务器
      • 云主机
      • 智能边缘云
      • 应用编排服务
      • 微服务引擎
      • 共享流量包
      更多推荐
      • web应用防火墙
      • 密钥管理
      • 等保咨询
      • 安全专区
      • 应用运维管理
      • 云日志服务
      • 文档数据库服务
      • 云搜索服务
      • 数据湖探索
      • 数据仓库服务
      友情链接
      • 中国电信集团
      • 189邮箱
      • 天翼企业云盘
      • 天翼云盘
      ©2025 天翼云科技有限公司版权所有 增值电信业务经营许可证A2.B1.B2-20090001
      公司地址:北京市东城区青龙胡同甲1号、3号2幢2层205-32室
      • 用户协议
      • 隐私政策
      • 个人信息保护
      • 法律声明
      备案 京公网安备11010802043424号 京ICP备 2021034386号