千万级播放上报,每秒上百次更新 MySQL?高并发写数据库压垮 DB,这套合并写方案直接封神 📅 发布时间:2026/8/28 19:02:14 👁 浏览次数: 为了更精确的记录用户上一次播放的进度我们采用的方案是前端每隔15秒就发起一次请求将播放记录写入数据库。但问题是提交播放记录的业务太复杂了其中涉及到大量的数据库操作在并发较高的情况下会给数据库带来非常大的压力。该怎么解决呢今天我们就来分析一下当碰到高并发的数据库写业务时该如何优化。通过今天的学习大家可以掌握下面的技能理解高并发优化的常见方案掌握Redis合并写请求的方案掌握DelayQueue的使用特别是其中的高并发优化方案在很多的业务场景下都可以用到。1.高并发优化方案解决高并发问题从宏观角度来说有3个方向其中水平扩展和服务保护侧重的是运维层面的处理。而提高单机并发能力侧重的则是业务层面的处理也就是我们程序员在开发时可以做到的。因此我们本章重点讨论如何通过编码来提供业务的单机并发能力。1.1.单机并发能力在机器性能一定的情况下提高单机并发能力就是要尽可能缩短业务的响应时间ResponseTime而对响应时间影响最大的往往是对数据库的操作。而从数据库角度来说我们的业务无非就是读或写两种类型。对于读多写少的业务其优化手段大家都比较熟悉了主要包括两方面优化代码和SQL添加缓存对于写多读少的业务大家可能较少碰到优化的手段可能也不太熟悉这也是我们要讲解的重点。对于高并发写的优化方案有优化代码及SQL变同步写为异步写合并写请求代码和SQL优化与读优化类似我们就不再赘述了接下来我们着重分析一下变同步为异步、合并写请求两种优化方案。1.2.变同步为异步假如一个业务比较复杂需要有多次数据库的写业务如图所示由于各个业务之间是同步串行执行因此整个业务的响应时间就是每一次数据库写业务的响应时间之和并发能力肯定不会太好。优化的思路很简单我们之前讲解MQ的时候就说过利用MQ可以把同步业务变成异步从而提高效率。当我们接收到用户请求后可以先不处理业务而是发送MQ消息并返回给用户结果。而后通过消息监听器监听MQ消息处理后续业务。如图这样一来用户请求处理和后续数据库写就从同步变为异步用户无需等待后续的数据库写操作响应时间自然会大大缩短。并发能力自然大大提高。优点无需等待复杂业务处理大大减少响应时间利用MQ暂存消息起到流量削峰整形作用降低写数据库频率减轻数据库并发压力缺点依赖于MQ的可靠性降低了些频率但是没有减少数据库写次数应用场景比较适合应用于业务复杂 业务链较长有多次数据库写操作的业务。1.3.合并写请求合并写请求方案其实是参考高并发读的优化思路当读数据库并发较高时我们可以把数据缓存到Redis这样就无需访问数据库大大减少数据库压力减少响应时间。既然读数据可以建立缓存那么写数据可以不可以也缓存到Redis呢答案是肯定的合并写请求就是指当写数据库并发较高时不再直接写到数据库。而是先将数据缓存到Redis然后定期将缓存中的数据批量写入数据库。如图由于Redis是内存操作写的效率也非常高这样每次请求的处理速度大大提高响应时间大大缩短并发能力肯定有很大的提升。而且由于数据都缓存到Redis了积累一些数据后再批量写入数据库这样数据库的写频率、写次数都大大减少对数据库压力小了非常多优点写缓存速度快响应时间大大减少降低数据库的写频率和写次数大大减轻数据库压力缺点实现相对复杂依赖Redis可靠性不支持事务和复杂业务场景写频率较高、写业务相对简单的场景2.播放进度记录方案改进播放进度统计包含大量的数据库读、写操作。不过保存播放记录还是以写数据库为主。因此优化的方向还是以高并发写优化为主。大家思考一下针对播放进度记录业务来说应该采用哪种优化方案呢变同步为异步合并写2.1.优化方案选择虽然播放进度记录业务较为复杂但是我们认真思考一下整个业务分支考试每章只能考一次还不能重复考试。因此属于低频行为可以忽略视频进度前端每隔15秒就提交一次请求。在一个视频播放的过程中可能有数十次请求但完播进度超50%的请求只会有一次。因此多数情况下都是更新一下播放进度即可。也就是说95%的请求都是在更新learning_record表中的moment字段以及learning_lesson表中的正在学习的小节id和时间。而播放进度信息不管更新多少次下一次续播肯定是从最后的一次播放进度开始续播。也就是说我们只需要记住最后一次即可。因此可以采用合并写方案来降低数据库写的次数和频率而异步写做不到。综上提交播放进度业务虽然看起来复杂但大多数请求的处理很简单就是更新播放进度。并且播放进度数据是可以合并的覆盖之前旧数据。我们建议采用合并写请求方案2.2.Redis数据结构设计我们先讨论下Redis缓存中需要记录哪些数据。我们的优化方案要处理的不是所有的提交学习记录请求。仅仅是视频播放时的高频更新播放进度的请求对应的业务分支如图这条业务支线的流程如下查询播放记录判断是否存在如果不存在新增一条记录如果存在则更新学习记录判断当前进度是否是第一次学完播放进度要超过50%原本的记录状态是未学完更新课表中最近学习小节id、学习时间这里有多次数据库操作例如查询播放记录需要知道播放记录是否存在、播放记录当前的完成状态更新播放记录更新播放进度更新最近学习小节id、时间一方面我们要缓存写数据减少写数据库频率另一方面我们要缓存播放记录减少查询数据库。因此缓存中至少要包含3个字段记录idid用于根据id更新数据库播放进度moment用于缓存播放进度播放状态是否学完finished用于判断是否是第一次学完既然一个小节要保存多个字段是不是可以考虑使用Hash结构来保存这些数据如图不过这样设计有一个问题。课程有很多每个课程的小节也非常多。每个小节都是一个独立的KEY需要创建的KEY也会非常多浪费大量内存。而且用户学习视频的过程中可能会在多个视频之间来回跳转这就会导致频繁的创建缓存、缓存过期影响到最终的业务性能。该如何解决呢既然一个课程包含多个小节我们完全可以把一个课程的多个小节作为一个KEY来缓存如图KEYHashKeyHashValuelessonIdsectionId:1{ id: 1, moment: 242, finished: true }sectionId:2{ id: 2, moment: 20, finished: false }sectionId:3{ id: 3, moment: 121, finished: false }这样做有两个好处可以大大减少需要创建的KEY的数量减少内存占用。一个课程创建一个缓存当用户在多个视频间跳转时整个缓存的有效期都会被延续不会频繁的创建和销毁缓存数据添加缓存以后学习记录提交的业务流程就需要发生一些变化了如图变化最大的有两点提交播放进度后如果是更新播放进度则不写数据库而是写缓存需要一个定时任务定期将缓存数据写入数据库变化后的业务具体流程为1.提交学习记录2.判断是否是考试是新增学习记录并标记有小节被学完。走步骤8否走视频流程步骤33.查询播放记录缓存如果缓存不存在则查询数据库并建立缓存4.判断记录是否存在4.1.否新增一条学习记录4.2.是走更新学习记录流程步骤55.判断是否是第一次学完进度超50%旧的状态是未学完5.1.否仅仅是要更新播放进度因此直接写入Redis并结束5.2.是代表小节学完走步骤66.更新学习记录状态为已学完7.清理Redis缓存因为学习状态变为已学完与缓存不一致因此这里清理掉缓存这样下次查询时自然会更新缓存保证数据一致。8.更新课表中已学习小节的数量19.判断课程的小节是否全部学完是更新课表状态为已学完否结束2.3.持久化思路对于合并写请求方案一定有一个步骤就是持久化缓存数据到数据库。一般采用的是定时任务持久化但是定时任务的持久化方式在播放进度记录业务中存在一些问题主要就是时效性问题。我们的产品要求视频续播的时间误差不能超过30秒。假如定时任务间隔较短例如20秒一次对数据库的更新频率太高压力太大假如定时任务间隔较长例如2分钟一次更新频率较低续播误差可能超过2分钟不满足需求注意如果产品对于时间误差要求不高定时任务处理是最简单最可靠的一种方案推荐大家使用。那么问题来了有什么办法能够在不增加数据库压力的情况下保证时间误差较低吗假如一个视频时长为20分钟我们从头播放至15分钟关闭每隔15秒提交一次播放进度大概需要提交60次请求。但是下一次我们再次打开该视频续播的时候肯定是从最后一次提交的播放进度来续播。也就是说续播进度之前的N次播放进度都是没有意义的都会被覆盖。既然如此我们完全没有必要定期把这些播放进度写到数据库只需要将用户最后一次提交的播放进度写入数据库即可。但问题来了我们怎么知道哪一次提交是最后一次提交呢只要用户一直在提交记录Redis中的播放进度就会一直变化。如果Redis中的播放进度不变肯定是停止了播放是最后一次提交。因此我们只要能判断Redis中的播放进度是否变化即可。怎么判断呢每当前端提交播放记录时我们可以设置一个延迟任务并保存这次提交的进度。等待20秒后因为前端每15秒提交一次20秒就是等待下一次提交检查Redis中的缓存的进度与任务中的进度是否一致。不一致说明持续在提交无需处理一致说明是最后一次提交更新学习记录、更新课表最近学习小节和时间到数据库中流程如下3.延迟任务为了确定用户提交的播放记录是否变化我们需要将播放记录保存为一个延迟任务等待超过一个提交周期20s后检查播放进度。那么延迟任务该如何实现呢3.1.延迟任务方案延迟任务的实现方案有很多常见的有四类DelayQueueRedissonMQ时间轮原理JDK自带延迟队列基于阻塞队列实现。基于Redis数据结构模拟JDK的DelayQueue实现利用MQ的特性。例如RabbitMQ的死信队列时间轮算法优点不依赖第三方服务分布式系统下可用不占用JVM内存分布式系统下可以不占用JVM内存不依赖第三方服务性能优异缺点占用JVM内存只能单机使用依赖第三方服务依赖第三方服务只能单机使用以上四种方案都可以解决问题不过本例中我们会使用DelayQueue方案。因为这种方案使用成本最低而且不依赖任何第三方服务减少了网络交互。但缺点也很明显就是需要占用JVM内存在数据量非常大的情况下可能会有问题。但考虑到任务存储时间比较短只有20秒因此也可以接收。如果你们的数据量非常大DelayQueue不能满足业务需求大家也可以替换为其它延迟队列方式例如Redisson、MQ等3.2.DelayQueue的原理首先来看一下DelayQueue的源码public class DelayQueueE extends Delayed extends AbstractQueueE implements BlockingQueueE { private final transient ReentrantLock lock new ReentrantLock(); private final PriorityQueueE q new PriorityQueueE(); // ... 略 }可以看到DelayQueue实现了BlockingQueue接口是一个阻塞队列。队列就是容器用来存储东西的。DelayQueue叫做延迟队列其中存储的就是延迟执行的任务。我们可以看到DelayQueue的泛型定义DelayQueueE extends Delayed这说明存入DelayQueue内部的元素必须是Delayed类型这其实就是一个延迟任务的规范接口。来看一下public interface Delayed extends ComparableDelayed { /** * 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类型的延迟任务类要能保持任务数据。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 DelayTaskD 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; } } }接下来就可以创建延迟任务交给延迟队列保存package com.tianji.learning.utils; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import java.time.Duration; import java.util.concurrent.DelayQueue; Slf4j class DelayTaskTest { Test void testDelayQueue() throws InterruptedException { // 1.初始化延迟队列 DelayQueueDelayTaskString queue new DelayQueue(); // 2.向队列中添加延迟执行的任务 log.info(开始初始化延迟任务。。。。); queue.add(new DelayTask(延迟任务3, Duration.ofSeconds(3))); queue.add(new DelayTask(延迟任务1, Duration.ofSeconds(1))); queue.add(new DelayTask(延迟任务2, Duration.ofSeconds(2))); // TODO 3.尝试执行任务 } }最后补上执行任务的代码package com.tianji.learning.utils; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import java.time.Duration; import java.util.concurrent.DelayQueue; Slf4j class DelayTaskTest { Test void testDelayQueue() throws InterruptedException { // 1.初始化延迟队列 DelayQueueDelayTaskString queue new DelayQueue(); // 2.向队列中添加延迟执行的任务 log.info(开始初始化延迟任务。。。。); 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) { DelayTaskString task queue.take(); log.info(开始执行延迟任务{}, task.getData()); } } }注意这里我们是直接同一个线程来执行任务了。当没有任务的时候线程会被阻塞。而在实际开发中我们会准备线程池开启多个线程来执行队列中的任务。4.代码改造接下来我们就可以按照之前分析的方案来改造代码了。4.1.定义延迟任务工具类首先我们要定义一个工具类帮助我们改造整个业务。在提交学习记录业务中需要用到异步任务和缓存的地方有以下几处因此我们的工具类就应该具备上述4个方法① 添加播放记录到Redis并添加一个延迟检测任务到DelayQueue② 查询Redis缓存中的指定小节的播放记录③ 删除Redis缓存中的指定小节的播放记录④ 异步执行DelayQueue中的延迟检测任务检测播放进度是否变化如果无变化则写入数据库工具类代码如下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 DelayQueueDelayTaskRecordTaskData 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.获取到期的延迟任务 DelayTaskRecordTaskData 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(); } } }4.2.改造提交学习记录功能接下来改造提交学习记录的功能package com.tianji.learning.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.tianji.api.client.course.CourseClient; import com.tianji.api.dto.course.CourseFullInfoDTO; import com.tianji.api.dto.leanring.LearningLessonDTO; import com.tianji.api.dto.leanring.LearningRecordDTO; import com.tianji.common.exceptions.BizIllegalException; import com.tianji.common.exceptions.DbException; import com.tianji.common.utils.BeanUtils; import com.tianji.common.utils.UserContext; import com.tianji.learning.domain.dto.LearningRecordFormDTO; 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.LearningRecordDelayTaskHandler; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; /** * p * 学习记录表 服务实现类 * /p * * author 虎哥 * since 2022-12-10 */ Service RequiredArgsConstructor public class LearningRecordServiceImpl extends ServiceImplLearningRecordMapper, LearningRecord implements ILearningRecordService { private final ILearningLessonService lessonService; private final CourseClient courseClient; private final LearningRecordDelayTaskHandler taskHandler; Override public LearningLessonDTO queryLearningRecordByCourse(Long courseId) { // 1.获取登录用户 Long userId UserContext.getUser(); // 2.查询课表 LearningLesson lesson lessonService.queryByUserAndCourseId(userId, courseId); // 3.查询学习记录 // select * from xx where lesson_id #{lessonId} ListLearningRecord records lambdaQuery().eq(LearningRecord::getLessonId, lesson.getId()).list(); // 4.封装结果 LearningLessonDTO dto new LearningLessonDTO(); dto.setId(lesson.getId()); dto.setLatestSectionId(lesson.getLatestSectionId()); dto.setRecords(BeanUtils.copyList(records, LearningRecordDTO.class)); return dto; } Override Transactional public void addLearningRecord(LearningRecordFormDTO recordDTO) { // 1.获取登录用户 Long userId UserContext.getUser(); // 2.处理学习记录 boolean finished false; if (recordDTO.getSectionType() SectionType.VIDEO) { // 2.1.处理视频 finished handleVideoRecord(userId, recordDTO); } else { // 2.2.处理考试 finished handleExamRecord(userId, recordDTO); } if (!finished) { // 没有新学完的小节无需更新课表中的学习进度 return; } // 3.处理课表数据 handleLearningLessonsChanges(recordDTO); } private void handleLearningLessonsChanges(LearningRecordFormDTO recordDTO) { // 1.查询课表 LearningLesson lesson lessonService.getById(recordDTO.getLessonId()); if (lesson null) { throw new BizIllegalException(课程不存在无法更新数据); } // 2.判断是否有新的完成小节 boolean allLearned false; // 3.如果有新完成的小节则需要查询课程数据 CourseFullInfoDTO cInfo courseClient.getCourseInfoById(lesson.getCourseId(), false, false); if (cInfo null) { throw new BizIllegalException(课程不存在无法更新数据); } // 4.比较课程是否全部学完已学习小节 课程总小节 allLearned lesson.getLearnedSections() 1 cInfo.getSectionNum(); // 5.更新课表 lessonService.lambdaUpdate() .set(lesson.getLearnedSections() 0, LearningLesson::getStatus, LessonStatus.LEARNING.getValue()) .set(allLearned, LearningLesson::getStatus, LessonStatus.FINISHED.getValue()) .set(allLearned, LearningLesson::getFinishTime, LocalDateTime.now()) .setSql(learned_sections learned_sections 1) .eq(LearningLesson::getId, lesson.getId()) .update(); } private boolean handleVideoRecord(Long userId, LearningRecordFormDTO recordDTO) { // 1.查询旧的学习记录 LearningRecord old queryOldRecord(recordDTO.getLessonId(), recordDTO.getSectionId()); // 2.判断是否存在 if (old null) { // 3.不存在则新增 // 3.1.转换PO LearningRecord record BeanUtils.copyBean(recordDTO, LearningRecord.class); // 3.2.填充数据 record.setUserId(userId); // 3.3.写入数据库 boolean success save(record); if (!success) { throw new DbException(新增学习记录失败); } return false; } // 4.存在则更新 // 4.1.判断是否是第一次完成 boolean finished !old.getFinished() recordDTO.getMoment() * 2 recordDTO.getDuration(); if (!finished) { LearningRecord record new LearningRecord(); record.setLessonId(recordDTO.getLessonId()); record.setSectionId(recordDTO.getSectionId()); record.setMoment(recordDTO.getMoment()); record.setId(old.getId()); record.setFinished(old.getFinished()); taskHandler.addLearningRecordTask(record); return false; } // 4.2.更新数据 boolean success lambdaUpdate() .set(LearningRecord::getMoment, recordDTO.getMoment()) .set(LearningRecord::getFinished, true) .set(LearningRecord::getFinishTime, recordDTO.getCommitTime()) .eq(LearningRecord::getId, old.getId()) .update(); if (!success) { throw new DbException(更新学习记录失败); } // 4.3.清理缓存 taskHandler.cleanRecordCache(recordDTO.getLessonId(), recordDTO.getSectionId()); return true; } private LearningRecord queryOldRecord(Long lessonId, Long sectionId) { // 1.查询缓存 LearningRecord record taskHandler.readRecordCache(lessonId, sectionId); // 2.如果命中直接返回 if (record ! null) { return record; } // 3.未命中查询数据库 record lambdaQuery() .eq(LearningRecord::getLessonId, lessonId) .eq(LearningRecord::getSectionId, sectionId) .one(); // 4.写入缓存 taskHandler.writeRecordCache(record); return record; } private boolean handleExamRecord(Long userId, LearningRecordFormDTO recordDTO) { // 1.转换DTO为PO LearningRecord record BeanUtils.copyBean(recordDTO, LearningRecord.class); // 2.填充数据 record.setUserId(userId); record.setFinished(true); record.setFinishTime(recordDTO.getCommitTime()); // 3.写入数据库 boolean success save(record); if (!success) { throw new DbException(新增考试记录失败); } return true; } }