天机学堂——多级缓存

目录

1.使用场景

1.1业务说明

1.2业务痛点

[2. 多级缓存设计思路](#2. 多级缓存设计思路)

[2.1 缓存方案选型思考](#2.1 缓存方案选型思考)

[2.2 多级缓存的核心架构](#2.2 多级缓存的核心架构)

各层级缓存的作用:

[本地缓存 vs Redis 缓存对比:](#本地缓存 vs Redis 缓存对比:)

[3. 本地缓存核心:Caffeine 详解](#3. 本地缓存核心:Caffeine 详解)

[3.1 为什么选择 Caffeine?](#3.1 为什么选择 Caffeine?)

[3.2 Caffeine 核心 API 使用](#3.2 Caffeine 核心 API 使用)

[3.2.1 基础操作示例](#3.2.1 基础操作示例)

[3.2.2 三种缓存驱逐策略](#3.2.2 三种缓存驱逐策略)

[4. 课程分类本地缓存实现](#4. 课程分类本地缓存实现)

[4.1 缓存配置类(CategoryCacheConfig)](#4.1 缓存配置类(CategoryCacheConfig))

[4.2 缓存工具类(CategoryCache)](#4.2 缓存工具类(CategoryCache))

[4.3 多级缓存扩展:增加 Redis 二级缓存](#4.3 多级缓存扩展:增加 Redis 二级缓存)

4.3.1课程微服务分类查询接口改造:

[5. 适用场景扩展](#5. 适用场景扩展)


本文主要是我个人学习天机学堂这个项目自己的一些理解和优化部分,主要是摘出项目中一些比较通用的部分,方便大家以及自己之后如果遇到了类似的业务可以进行参考使用

1.使用场景

1.1业务说明

在天机学堂的管理端功能中,查询互动问答数据时,不仅需要返回问题本身的信息,还需关联展示一系列附属数据,例如:

  • 课程名称(关联course_id
  • 章节 / 小节名称(关联chapter_idsection_id
  • 课程分类名称(关联一级、二级、三级分类 ID)
  • 提问者名称(关联user_id

其中,课程、章节、提问者信息的查询在以往业务中已有成熟方案,但课程分类数据 的查询存在性能优化空间 ------ 这类数据在首页展示、课程筛选、问答管理 等多个场景高频复用,并且这类数据有着数据量小更新不频繁的特点

这些数据对应到interaction_question表中,只包含一些id字段:

1.2业务痛点

由于互动问答表(interaction_question)中仅存储分类 ID,未存储分类名称,需通过以下流程获取完整分类信息:

  • 根据问题的**course_id**查询课程微服务,获取课程关联的一级、二级、三级分类 ID;
  • 根据分类 ID 查询分类数据,获取分类名称;
  • 组装数据返回给前端。

若直接每次查询都调用课程微服务或数据库,会存在两个核心问题:

  • 高频查询导致网络开销大、响应延迟高;
  • 分类数据变更频率极低,重复查询数据库造成资源浪费。

因此,我们需要一套高效的缓存方案来优化分类数据的查询性能。

2. 多级缓存设计思路

2.1 缓存方案选型思考

针对分类数据的特性(数据量小、更新频率低、查询频率高 ),单一缓存方案已无法满足性能极致需求。我们需要设计多级缓存架构,结合不同缓存的优势,实现 "本地快速访问 + 分布式缓存兜底" 的效果。

2.2 多级缓存的核心架构

多级缓存的查询优先级为:

本地缓存(JVM内存) → Redis缓存(分布式) → 数据库/微服务查询

各层级缓存的作用:
  • 本地缓存:存储在应用进程内,无网络开销,查询速度最快,适用于高频访问的静态数据;

本地缓存简单来说就是JVM内存的缓存,比如你建立一个HashMap,把数据库查询的数据存入进去。以后优先从这个HashMap查询,一个本地缓存就建立好了

  • Redis 缓存:分布式缓存,解决本地缓存无法共享的问题,同时作为本地缓存的兜底;
  • 数据库 / 微服务:数据源头,仅在缓存未命中时查询,保证数据一致性。
本地缓存 vs Redis 缓存对比:
特性 本地缓存(JVM) Redis 缓存(分布式)
访问速度 极快(内存直接访问,无网络开销) 较快(网络 IO + 内存访问)
数据共享 不支持(进程内隔离) 支持(分布式部署共享)
数据同步 困难(需手动刷新或过期淘汰) 较易(支持发布订阅、过期淘汰)
存储容量 有限(受 JVM 内存限制) 较大(独立 Redis 集群)
可靠性 低(应用重启数据丢失) 高(支持持久化、主从复制)

课程分类数据完全匹配本地缓存的适用场景,因此我们选择本地缓存作为一级缓存,Redis 作为二级缓存的多级架构。

3. 本地缓存核心:Caffeine 详解

3.1 为什么选择 Caffeine?

直接使用**HashMap**作为本地缓存存在明显缺陷:

  • 无自动过期机制,内存会持续膨胀;
  • 无缓存淘汰策略,无法应对数据量增长;
  • 线程安全问题需手动处理。

Caffeine 是基于 Java8 开发的高性能本地缓存库,具备以下优势:

  • 性能领先:官方测试显示,Caffeine 的命中率和查询速度远超 Guava Cache、EHCache 等同类框架;
  • 功能强大:支持多种缓存驱逐策略、异步加载、统计监控等;
  • 原生集成:Spring Boot 2.0 + 默认使用 Caffeine 作为本地缓存实现,集成成本低。

下图是官方给出的几种常见的本地缓存实现方案的性能对比:

(可以看到Caffeine的性能遥遥领先!)

3.2 Caffeine 核心 API 使用

3.2.1 基础操作示例

java 复制代码
@Test
void testCaffeineBasicOps() {
    // 1. 构建缓存对象(无配置,默认无过期时间)
    Cache<String, String> cache = Caffeine.newBuilder().build();

    // 2. 存入数据
    cache.put("category:1", "Java编程");

    // 3. 读取数据(无则返回null)
    String categoryName = cache.getIfPresent("category:1");
    System.out.println("直接读取:" + categoryName); // 输出:Java编程

    // 4. 读取数据(缓存未命中时执行Lambda查询逻辑)
    String defaultCategory = cache.get("category:999", key -> {
        // 模拟从数据库/微服务查询数据
        System.out.println("缓存未命中,执行查询逻辑");
        return "默认分类";
    });
    System.out.println("带查询逻辑的读取:" + defaultCategory); // 输出:默认分类
}

运行结果:

3.2.2 三种缓存驱逐策略

Caffeine 提供灵活的缓存淘汰机制,避免内存溢出:

①基于容量限制:设置缓存最大存储数量,超出后按 LRU(最近最少使用)策略淘汰;

java 复制代码
Cache<String, String> sizeLimitedCache = Caffeine.newBuilder()
        .maximumSize(1000) // 最大缓存1000条数据
        .build();

②基于时间过期:设置数据的有效时间,支持 "最后写入过期" 和 "最后访问过期";

java 复制代码
// 最后一次写入后30分钟过期
Cache<String, String> timeLimitedCache = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofMinutes(30))
        .build();

// 最后一次访问后10分钟过期(适用于热点数据)
Cache<String, String> accessTimeCache = Caffeine.newBuilder()
        .expireAfterAccess(Duration.ofMinutes(10))
        .build();

基于引用淘汰 :设置数据为软引用(softValues())或弱引用(weakValues()),依赖 GC 回收,性能较差,不推荐使用。

注意:Caffeine 默认不会主动清理过期数据,而是在下次访问或空闲时触发清理,避免占用额外 CPU 资源。

4. 课程分类本地缓存实现

在实际项目中,我们已将课程分类缓存封装到tj-api模块,供所有微服务复用,核心实现如下:

4.1 缓存配置类(CategoryCacheConfig

通过 Spring Bean 配置 Caffeine 缓存参数,指定初始容量、最大容量和过期时间:

java 复制代码
package com.tianji.api.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.tianji.api.cache.CategoryCache;
import com.tianji.api.client.course.CategoryClient;
import com.tianji.api.dto.course.CategoryBasicDTO;
import org.springframework.context.annotation.Bean;

import java.time.Duration;
import java.util.Map;

public class CategoryCacheConfig {
    /**
     * 配置课程分类Caffeine缓存
     */
    @Bean
    public Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches() {
        return Caffeine.newBuilder()
                .initialCapacity(1) // 初始容量(分类数据量小,初始1即可)
                .maximumSize(10_000) // 最大容量(支持1万条分类数据,足够使用)
                .expireAfterWrite(Duration.ofMinutes(30)) // 30分钟过期,平衡一致性和性能
                .build();
    }

    /**
     * 缓存工具类Bean,供业务代码调用
     */
    @Bean
    public CategoryCache categoryCache(
            Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches, 
            CategoryClient categoryClient) {
        return new CategoryCache(categoryCaches, categoryClient);
    }
}

4.2 缓存工具类(CategoryCache

封装缓存的查询、刷新逻辑,对外提供简洁的 API,避免重复代码:

java 复制代码
package com.tianji.api.cache;

import com.github.benmanes.caffeine.cache.Cache;
import com.tianji.api.client.course.CategoryClient;
import com.tianji.api.dto.course.CategoryBasicDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class CategoryCache {
    private final Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches;
    private final CategoryClient categoryClient;
    private static final String CATEGORY_KEY = "all_categories"; // 缓存统一Key

    /**
     * 根据分类ID查询分类名称
     */
    public String getCategoryNameById(Long categoryId) {
        if (categoryId == null) {
            return "";
        }
        // 1. 从本地缓存查询所有分类
        Map<Long, CategoryBasicDTO> categoryMap = getCategoryMap();
        // 2. 根据ID获取分类信息
        CategoryBasicDTO dto = categoryMap.get(categoryId);
        return dto != null ? dto.getName() : "";
    }

    /**
     * 批量查询分类名称
     */
    public Map<Long, String> getCategoryNamesByIds(List<Long> categoryIds) {
        if (categoryIds == null || categoryIds.isEmpty()) {
            return Map.of();
        }
        Map<Long, CategoryBasicDTO> categoryMap = getCategoryMap();
        return categoryIds.stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toMap(
                        id -> id,
                        id -> categoryMap.getOrDefault(id, new CategoryBasicDTO()).getName(),
                        (k1, k2) -> k1
                ));
    }

    /**
     * 核心方法:获取分类映射(缓存未命中则查询微服务)
     */
    private Map<Long, CategoryBasicDTO> getCategoryMap() {
        // 1. 优先查询本地缓存
        Map<Long, CategoryBasicDTO> categoryMap = categoryCaches.getIfPresent(CATEGORY_KEY);
        if (categoryMap != null && !categoryMap.isEmpty()) {
            return categoryMap;
        }
        // 2. 缓存未命中,调用课程微服务查询所有分类
        List<CategoryBasicDTO> categoryList = categoryClient.queryAllCategories();
        if (categoryList == null || categoryList.isEmpty()) {
            return Map.of();
        }
        // 3. 转换为ID->分类的映射,存入本地缓存
        categoryMap = categoryList.stream()
                .collect(Collectors.toMap(
                        CategoryBasicDTO::getId,
                        dto -> dto,
                        (d1, d2) -> d1
                ));
        categoryCaches.put(CATEGORY_KEY, categoryMap);
        return categoryMap;
    }

    /**
     * 手动刷新缓存(分类数据变更时调用)
     */
    public void refreshCache() {
        categoryCaches.invalidate(CATEGORY_KEY);
        getCategoryMap(); // 主动加载最新数据到缓存
    }
}

4.3 多级缓存扩展:增加 Redis 二级缓存

当前实现仅使用了 Caffeine 本地缓存,若需支持分布式场景(如多实例部署时分类数据同步),可在课程微服务的**CategoryClient** 接口中增加Redis 缓存,形成完整的多级缓存:

扩展思路:

  • 在课程微服务中,查询分类数据时先查 Redis,未命中再查数据库;
  • 分类数据更新时(如新增、修改分类),同步更新 Redis 缓存并发送刷新通知;
  • 各微服务的本地缓存(Caffeine)过期后,查询课程微服务时,优先命中 Redis,进一步降低数据库压力。

4.3.1课程微服务分类查询接口改造:

java 复制代码
package com.tianji.course.service.impl;

import cn.hutool.core.collection.CollectionUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.tianji.common.constants.Constant;
import com.tianji.common.constants.ErrorInfo;
import com.tianji.common.enums.CommonStatus;
import com.tianji.common.exceptions.BizIllegalException;
import com.tianji.common.exceptions.DbException;
import com.tianji.common.utils.*;
import com.tianji.course.constants.CourseConstants;
import com.tianji.course.constants.CourseErrorInfo;
import com.tianji.course.constants.CourseStatus;
import com.tianji.course.constants.RedisConstants;
import com.tianji.course.domain.dto.CategoryAddDTO;
import com.tianji.course.domain.dto.CategoryDisableOrEnableDTO;
import com.tianji.course.domain.dto.CategoryListDTO;
import com.tianji.course.domain.dto.CategoryUpdateDTO;
import com.tianji.course.domain.po.Category;
import com.tianji.course.domain.po.Course;
import com.tianji.course.domain.vo.CategoryInfoVO;
import com.tianji.course.domain.vo.CategoryVO;
import com.tianji.course.domain.vo.SimpleCategoryVO;
import com.tianji.course.mapper.CategoryMapper;
import com.tianji.course.mapper.SubjectCategoryMapper;
import com.tianji.course.service.ICategoryService;
import com.tianji.course.service.ICourseDraftService;
import com.tianji.course.service.ICourseService;
import com.tianji.course.utils.CategoryDataWrapper;
import com.tianji.course.utils.CategoryDataWrapper2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.util.*;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;

/**
 * <p>
 * 课程分类 服务实现类
 * </p>
 *
 * @author wusongsong
 * @since 2022-07-14
 */
@Service
@Slf4j
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements ICategoryService {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private SubjectCategoryMapper subjectCategoryMapper;

    @Autowired
    private ICourseService courseService;

    @Autowired
    private ICourseDraftService courseDraftService;

    @Resource(name = "taskExecutor")
    private Executor taskExecutor;

    @Override
    public List<CategoryVO> list(CategoryListDTO categoryListDTO) {

        //1.搜索条件根据priority正序排序,id逆序排序
        LambdaQueryWrapper<Category> queryWrapper =
                Wrappers.lambdaQuery(Category.class)
                        .orderByAsc(Category::getPriority)
                        .orderByDesc(Category::getUpdateTime);
        //2.查询数据
        List<Category> list = super.list(queryWrapper);
        if (CollUtils.isEmpty(list)) {
            return new ArrayList<>();
        }

        //3.获取课程分类拥有的课程数量
        Map<Long, Long> thirdCategoryNumMap = this.statisticThirdCategory();

        Map<Long, Integer> cateIdAndNumMap = courseService
                .countCourseNumOfCategory();
        //4.通过TreeDataUtils组装数据
        List<CategoryVO> categoryVOS = TreeDataUtils.parseToTree(list, CategoryVO.class,
                //4.1设置转换
                (category, categoryVO) -> {
                    //4.2设置三级分类数量、课程数量、状态描述、排序
                    categoryVO.setThirdCategoryNum(NumberUtils.null2Zero(thirdCategoryNumMap.get(category.getId())).intValue());
                    categoryVO.setCourseNum(NumberUtils.null2Zero(cateIdAndNumMap.get(category.getId())));
                    categoryVO.setStatusDesc(CommonStatus.desc(category.getStatus()));
                    categoryVO.setIndex(category.getPriority());
                }, new CategoryDataWrapper2());
        //5.根据条件过滤
        if (CollUtils.isNotEmpty(categoryVOS)) {
            return fiter(categoryVOS, categoryListDTO);
        } else {
            return new ArrayList<>();
        }
    }

    @Override
    @Transactional(rollbackFor = {DbException.class, Exception.class})
    public void add(CategoryAddDTO categoryAddDTO) {

        //校验名称是否重复
        checkSameName(categoryAddDTO.getParentId(), categoryAddDTO.getName(), null);
        int level = 1; //默认一级分类
        if (CourseConstants.CATEGORY_ROOT != categoryAddDTO.getParentId()) {
            //校验父分类是否存在
            Category parentCategory = this.baseMapper.selectById(categoryAddDTO.getParentId());
            if (parentCategory == null) {
                throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_PARENT_NOT_FOUND);
            }
            //三级课程分类下不能在创建子分类
            if (parentCategory.getLevel() == 3) {
                throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_CREATE_ON_THIRD);
            }
            //分类级别,父分类+1
            level = parentCategory.getLevel() + 1;
        }
        if (level > 3) {
            throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_ADD_OVER_THIRD_LEVEL);
        }

        //将请求参数转换成PO
        Category category = BeanUtils.copyBean(categoryAddDTO, Category.class, (dto, po) -> {
            po.setPriority(dto.getIndex());
            po.setStatus(CommonStatus.DISABLE.getValue());
        });
        //设置分类级别
        category.setLevel(level);
        if (this.baseMapper.insert(category) <= 0) {
            throw new DbException(null);
        }
        
        // 清除Redis缓存
        clearCategoryCache();
    }

    @Override
    public CategoryInfoVO get(Long id) {
        //1.查询数据
        Category category = this.baseMapper.selectById(id);
        //1.1判空
        if (category == null) {
            return new CategoryInfoVO();
        }
        //2.数据组装
        CategoryInfoVO categoryInfoVO = BeanUtils.toBean(category, CategoryInfoVO.class);
        //2.1.课程分类级别
        categoryInfoVO.setCategoryLevel(category.getLevel());
        //2.2.课程分类状态描述
        categoryInfoVO.setStatusDesc(CommonStatus.desc(category.getStatus()));
        //2.3课程分类序号
        categoryInfoVO.setIndex(category.getPriority());
        Long firstCategoryId = null;
        if (category.getLevel() == 3) {
            //2.4.查询二级课程分类
            Category secondCategory = this.baseMapper.selectById(category.getParentId()); //所在二级目录
            //2.5.设置二级课程分类名称
            categoryInfoVO.setSecondCategoryName(secondCategory.getName());
            //2.6.设置一级课程分类id
            firstCategoryId = secondCategory.getParentId();
        } else if (category.getLevel() == 2) {
            //2.7.设置一级课程分类id
            firstCategoryId = category.getParentId();
        }

        if (firstCategoryId != null) {
            //2.8.查询一级课程分类信息
            Category firstCategory = this.baseMapper.selectById(firstCategoryId);
            //2.9设置一级课程分类名称
            categoryInfoVO.setFirstCategoryName(firstCategory.getName());
        }
        return categoryInfoVO;
    }

    @Override
    public void delete(Long id) {
        //1.子分类查询条件
        LambdaQueryWrapper<Category> queryWrapper =
                Wrappers.lambdaQuery(Category.class)
                        .eq(Category::getParentId, id);
        //1.1查询子分类信息
        List<Category> categories = this.baseMapper.selectList(queryWrapper);
        //1.2.子分类判空
        if (CollectionUtil.isNotEmpty(categories)) { //分类有子分类
            throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_HAVE_CHILD);
        }
        //2.查询分类信息
        Category category = this.baseMapper.selectById(id);
        //2.1.判空
        if (category == null) {
            throw new DbException(ErrorInfo.Msg.DB_DELETE_EXCEPTION);
        }
        //3.统计分类拥有的课程数量
        Integer courseNum = courseService.countCourseNumOfCategory(id);
        //3.1.分类拥有课程数据量判空
        if (courseNum > 0) {
            throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_DELETE_HAVE_COURSE);
        }
        //4.统计分类拥有的题目数量
        int subjectNum = subjectCategoryMapper.countSubjectNum(category.getId(), category.getLevel());
        //4.1.分类拥有的题目数量判空
        if (subjectNum > 0) { //课程含有题目
            throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_DELETE_HAVE_SUBJECT);
        }
        //5.删除课程
        int result = this.baseMapper.deleteById(id);
        if (result <= 0) {
            throw new DbException(CourseErrorInfo.Msg.CATEGORY_DELETE_FAILD);
        }
        
        // 清除Redis缓存
        clearCategoryCache();
    }

    /**
     * 功能点:
     * 1.启用或禁用课程,下一级或下一级的课程同时启用或禁用,
     * 联动启用或禁用
     *
     * @param categoryDisableOrEnableDTO
     */
    @Override
    @Transactional(rollbackFor = {DbException.class, Exception.class})
    public void disableOrEnable(CategoryDisableOrEnableDTO categoryDisableOrEnableDTO) {

        //1.获取禁用/启用的课程分类
        Category category = baseMapper.selectById(categoryDisableOrEnableDTO.getId());
        if (category == null) {
            throw new BizIllegalException(CourseErrorInfo.Msg.COURSE_CATEGORY_NOT_FOUND);
        }
        //2.校验
        if (category.getParentId() != 0) { //校验父级分类
            //2.1启用校验
            if (categoryDisableOrEnableDTO.getStatus() == CommonStatus.ENABLE.getValue()) {
                //2.2获取父分类
                Category parentCategory = baseMapper.selectById(category.getParentId());
                if (parentCategory == null) {
                    log.error("操作异常,根据父类id查询课程分类未查询到,parentId : {}", category.getParentId());
                    throw new BizIllegalException(ErrorInfo.Msg.OPERATE_FAILED);
                }
                //2.3父分类禁用下不能启用当前分类
                if (CommonStatus.DISABLE.getValue() == parentCategory.getStatus()) {
                    throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_ENABLE_CANNOT);
                }
            }
        }

        //3.获取启用/禁用时联动启用的分类
        List<Long> childCategoryIds = new ArrayList<>();
        LambdaQueryWrapper<Category> directQueryWrapper = new LambdaQueryWrapper<>();
        directQueryWrapper.eq(Category::getParentId, categoryDisableOrEnableDTO.getId());
        //3.1获取启用分类的子分类列表
        List<Category> categories = baseMapper.selectList(directQueryWrapper);
        if (CollUtils.isNotEmpty(categories)) { //直接子分类
            //3.2将子类id写入到childCategoryIds中
            childCategoryIds.addAll(categories.stream().map(Category::getId).collect(Collectors.toList()));
        }
        //3.3获取启用分类子分类的子分类列表
        if (CollUtils.isNotEmpty(childCategoryIds)) {
            LambdaQueryWrapper<Category> inDirectQueryWrapper = new LambdaQueryWrapper<>();
            inDirectQueryWrapper.in(Category::getParentId, childCategoryIds);
            List<Category> inDirectCategorys = baseMapper.selectList(inDirectQueryWrapper);
            if (CollUtils.isNotEmpty(inDirectCategorys)) {
                //3.4将子分类的子分类id列表写入childCategoryIds中
                childCategoryIds.addAll(inDirectCategorys.stream()
                        .map(Category::getId).collect(Collectors.toList()));
            }
        }

        //4.更新当前分类,启用/禁用
        int result = this.baseMapper.updateById(BeanUtils.toBean(categoryDisableOrEnableDTO, Category.class));
        if (result <= 0) {
            throw new BizIllegalException(ErrorInfo.Msg.DB_UPDATE_EXCEPTION);
        }
        //5.启用或禁用关联课程分类
        if (CollUtils.isNotEmpty(childCategoryIds)) {
            //5.1更新条件
            LambdaUpdateWrapper<Category> updateWrapper = new LambdaUpdateWrapper();
            updateWrapper.in(Category::getId, childCategoryIds);
            Category updateCategory = new Category();
            updateCategory.setStatus(categoryDisableOrEnableDTO.getStatus());
            //5.2更新关联分类状态
            baseMapper.update(updateCategory, updateWrapper);
        }
        
        // 清除Redis缓存
        clearCategoryCache();
        
        //6.课程分类禁用触发课程批量下架
        if (categoryDisableOrEnableDTO.getStatus() == CommonStatus.DISABLE.getValue()) {
            Long userId = UserContext.getUser();
            taskExecutor.execute(() -> {
                batchDownShelfCourse(category.getId(), category.getLevel(), userId);
            });

        }
    }

    @Override
    @Transactional(rollbackFor = {DbException.class, Exception.class})
    public void update(CategoryUpdateDTO categoryUpdateDTO) {
        //1.查询更新数据
        Category category = this.baseMapper.selectById(categoryUpdateDTO.getId());
        if (category == null) {
            throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_NOT_FOUND);
        }
        //2.校验名称是否可以更新
        checkSameName(category.getParentId(), categoryUpdateDTO.getName(), categoryUpdateDTO.getId());
        //3.设置更新字段
        Category updateCategory = new Category();
        updateCategory.setId(categoryUpdateDTO.getId()); //修改课程分类id
        updateCategory.setPriority(categoryUpdateDTO.getIndex());
        updateCategory.setName(categoryUpdateDTO.getName());
        //4.更新
        int result = this.baseMapper.updateById(updateCategory);
        if (result <= 0) {
            throw new BizIllegalException(ErrorInfo.Msg.DB_UPDATE_EXCEPTION);
        }
        
        // 清除Redis缓存
        clearCategoryCache();
    }

    @Override
    public List<SimpleCategoryVO> all(Boolean admin) {
        // 1.查询有课程的课程分类id列表
        List<Long> categoryIdList = admin ?
                null :courseService.getCategoryIdListWithCourse();
        // 1.1.判空
        if(!admin && CollUtils.isEmpty(categoryIdList)){
            return new ArrayList<>();
        }

        // 2.升序查询所有未禁用的课程分类
        LambdaQueryWrapper<Category> queryWrapper = Wrappers.lambdaQuery(Category.class)
                .eq(!admin, Category::getStatus, CommonStatus.ENABLE.getValue())
                .in(CollectionUtil.isNotEmpty(categoryIdList), Category::getId, categoryIdList)
                .orderByAsc(Category::getPriority)
                .orderByDesc(Category::getId);
        List<Category> categories = this.baseMapper.selectList(queryWrapper);

        // 3.将课程分类转换成树状结构
        List<SimpleCategoryVO> simpleCategoryVOS = TreeDataUtils.parseToTree(categories,
                SimpleCategoryVO.class, new CategoryDataWrapper());
        // 4.过滤掉没有三级子课程分类的课程分类
        filter(simpleCategoryVOS);
        return simpleCategoryVOS;

    }

    @Override
    public Map<Long, String> getCateIdAndName() {
        List<Category> categories = this.baseMapper.selectList(null);
        return categories.stream()
                .collect(Collectors.toMap(Category::getId, Category::getName));
    }

    @Override
    public List<CategoryVO> allOfOneLevel() {
        // 1.尝试从Redis缓存中获取数据(二级缓存)
        List<CategoryVO> categoryVOList = (List<CategoryVO>) redisTemplate.opsForValue()
                .get(RedisConstants.REDIS_KEY_CATEGORY_ALL);
        
        // 2.如果Redis缓存命中,直接返回(无需回写,Redis已经是二级缓存)
        if (CollUtils.isNotEmpty(categoryVOList)) {
            log.debug("命中Redis缓存,获取课程分类数据");
            return categoryVOList;
        }
        
        // 3.Redis缓存未命中,查询数据库(三级缓存)
        log.debug("Redis缓存未命中,从数据库查询课程分类数据");
        List<Category> list = super.list();
        if (CollUtils.isEmpty(list)) {
            // 即使是空数据也缓存,防止缓存穿透
            categoryVOList = new ArrayList<>();
            redisTemplate.opsForValue().set(
                    RedisConstants.REDIS_KEY_CATEGORY_ALL, 
                    categoryVOList, 
                    5, 
                    java.util.concurrent.TimeUnit.MINUTES
            );
            return categoryVOList;
        }

        // 4.统计一级二级目录对应的三级目录的数量
        Map<Long, Long> thirdCategoryNumMap = this.statisticThirdCategory();
        categoryVOList = BeanUtils.copyList(list, CategoryVO.class, (category, categoryVO) -> {
            categoryVO.setThirdCategoryNum(thirdCategoryNumMap.getOrDefault(category.getId(), 0L).intValue());
        });
        
        // 5.将数据库查询结果回写到Redis缓存(回写到上一级缓存)
        redisTemplate.opsForValue().set(
                RedisConstants.REDIS_KEY_CATEGORY_ALL, 
                categoryVOList, 
                30, 
                java.util.concurrent.TimeUnit.MINUTES
        );
        log.debug("数据库查询结果已回写到Redis缓存");
        
        return categoryVOList;
    }

    @Override
    public List<Category> queryByIds(List<Long> ids) {
        if (CollUtils.isEmpty(ids)) {
            return new ArrayList<>();
        }
        LambdaQueryWrapper<Category> queryWrapper =
                Wrappers.lambdaQuery(Category.class)
                        .in(Category::getId, ids);
        return baseMapper.selectList(queryWrapper);
    }

    @Override
    public Map<Long, String> queryByThirdCateIds(List<Long> thirdCateIdList) {
        Map<Long, String> resultMap = new HashMap<>();
        //1.校验
        // 1.1判断参数是否为空
        if (CollUtils.isEmpty(thirdCateIdList)) {
            return resultMap;
        }
        // 1.2校验分类id都是三级分类id
        LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Category::getLevel, 3)
                .in(Category::getId, thirdCateIdList);
        int thirdCateNum = baseMapper.selectCount(queryWrapper);
        if (!NumberUtils.equals(thirdCateNum, thirdCateIdList.size())) {
            throw new BizIllegalException(ErrorInfo.Msg.REQUEST_PARAM_ILLEGAL);
        }
        //2.查询所有分类,并将分类转化成map
        List<Category> categories = baseMapper.selectList(null);
        Map<Long, Category> categoryMap = categories.stream()
                .collect(Collectors.toMap(Category::getId, p -> p));
        //3.遍历三级分类id
        for (Long thirdCateId : thirdCateIdList) {
            //3.1三级分类
            Category thirdCategory = categoryMap.get(thirdCateId);

            //3.2二级分类
            Category secondCategory = categoryMap.get(thirdCategory.getParentId());
            //3.3一级分类
            Category firstCategory = categoryMap.get(thirdCateId);
            resultMap.put(thirdCateId, StringUtils.format("{}/{}/{}",
                    firstCategory.getName(), secondCategory.getName(), thirdCategory.getName()));
        }
        return resultMap;
    }

    @Override
    public List<String> queryCourseCategorys(Course course) {
        //1.查询课程分类
        List<Category> categories = baseMapper.selectBatchIds(
                Arrays.asList(course.getFirstCateId(),
                        course.getSecondCateId(),
                        course.getThirdCateId()));
        if (CollUtils.isNotEmpty(categories)) {
            return new ArrayList<>();
        }
        Map<Long, String> categoryIdAndNameMap = categories.stream()
                .collect(Collectors.toMap(Category::getId, Category::getName));
        //2.按照分类层级关系组装成列表
        return Arrays.asList(categoryIdAndNameMap.get(course.getFirstCateId()),
                categoryIdAndNameMap.get(course.getSecondCateId()),
                categoryIdAndNameMap.get(course.getThirdCateId()));
    }

    @Override
    public List<Long> checkCategory(Long thirdCateId) {
        //1.查询三级课程分类
        Category thirdCategory = baseMapper.selectById(thirdCateId);
        //1.1判断三级课程分类状态
        if (thirdCategory.getStatus() != CommonStatus.ENABLE.getValue()) {
            throw new BizIllegalException(CourseErrorInfo.Msg.COURSE_CATEGORY_NOT_FOUND);
        }
        //2.查询二级课程分类
        Category secondCategory = baseMapper.selectById(thirdCategory.getParentId());
        //2.1判断三级课程分类状态
        if (secondCategory.getStatus() != CommonStatus.ENABLE.getValue()) {
            throw new BizIllegalException(CourseErrorInfo.Msg.COURSE_CATEGORY_NOT_FOUND);
        }
        //3.返回数据
        return Arrays.asList(secondCategory.getParentId(), secondCategory.getId(), thirdCateId);
    }

    /**
     * 获取一级二级没有下一级分类的分类id列表
     * @return
     */
    private List<Long> getCateIdsWithoutChildCateId(){
        // 1.查询数据
        List<Category> list = list();
        // 1.1.判空
        if(CollUtils.isEmpty(list)){
            return new ArrayList<>();
        }
        // 2.list转map
        Map<Long, List<Category>> idAndParentIdMap = list.stream()
                .collect(Collectors.groupingBy(Category::getParentId));
        // 3.遍历
        return list.stream()
                .filter(category -> category.getLevel() < 3 && !idAndParentIdMap.containsKey(category.getId()))
                .map(Category::getId)
                .collect(Collectors.toList());
    }


    /**
     * 新增或更新时校验是否有同名的分类
     *
     * @param parentId  父id
     * @param name
     * @param currentId
     */
    private void checkSameName(Long parentId, String name, Long currentId) {
        //1.统计同一个父分类的子分类列表中有同名分类,或和父分类同名查询条件
        LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.or().eq(true, Category::getParentId, parentId)
                .eq(Category::getName, name)
                .eq(Category::getDeleted, Constant.DATA_NOT_DELETE);
        queryWrapper.or().eq(Category::getId, parentId)
                .eq(Category::getName, name);
        //2.统计符合上述条件的分类列表
        List<Category> categories = this.baseMapper.selectList(queryWrapper);
        //3.新增情况下,有同名的分类
        if (currentId == null && CollectionUtil.isNotEmpty(categories)) {
            throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_SAME_NAME);
        }
        //4.更新情况下出现同名,需要判断是否是当前分类的名称
        if (CollectionUtil.isNotEmpty(categories) && categories.get(0).getId() != currentId.longValue()) {
            throw new BizIllegalException(CourseErrorInfo.Msg.CATEGORY_SAME_NAME);
        }
    }

    private void setThirdCategoryNum(List<CategoryVO> categoryVOList){
        // 1.判空
        if(CollUtils.isEmpty(categoryVOList)){
            return;
        }
        // 2.
    }

    /**
     * 统计每个一级目录二级目录有多少个三级目录
     *
     * @return
     */
    private Map<Long, Long> statisticThirdCategory() {
        Map<Long, Long> result = new HashMap<>();
        // 1.查询所有数据
        List<Category> categories = baseMapper.selectList(null);
        // 1.1.判空
        if(CollUtils.isEmpty(categories)){
            return result;
        }

        // 2.二级分类的拥有的三级课程分类数量
        Map<Long, Long> collect = categories.stream()
                .filter(category -> category.getLevel() == 3)
                .collect(Collectors.groupingBy(Category::getParentId, Collectors.counting()));
        result.putAll(collect);
        // 3.一级分类拥有的三级课程分类数量
        Map<Long, List<Category>> category2Map = categories.stream()
                .filter(category -> category.getLevel() == 2)
                .collect(Collectors.groupingBy(Category::getParentId));
        // 3.1.遍历category2Map
        for (Map.Entry<Long, List<Category>> entry : category2Map.entrySet()){
            long sum = entry.getValue()
                    .stream()
                    .map(category -> NumberUtils.null2Zero(result.get(category.getId())))
                    .collect(Collectors.summarizingLong(num -> num))
                    .getSum();
            result.put(entry.getKey(), sum);
        }
        // 4.返回结果
        return result;
    }

    /**
     * 根据条件过滤课程分类
     *
     * @param categoryVOList
     * @param categoryListDTO
     * @return
     */
    private List<CategoryVO> fiter(List<CategoryVO> categoryVOList, CategoryListDTO categoryListDTO) {
        if (CollUtils.isEmpty(categoryVOList)) {
            return new ArrayList<>();
        }
        return categoryVOList.stream().
                filter(categoryVO -> filter(categoryVO, categoryListDTO))
                .collect(Collectors.toList());
    }

    /**
     * 递归过滤出查询的数据,当前分类是否符合条件 = 当前分类信息符合条件 OR 有符合条件的子分类
     * 1.校验信息状态和名称符合dto要求
     * 2.循环遍历子分类,子分类不符合条件将从子分类列表中删除
     * 3.步骤1中通过 + 是否还有子分类(步骤2删除了不符合条件的)
     *
     * @param categoryVO
     * @param categoryListDTO
     * @return 当前分类是否符合条件
     */
    private boolean filter(CategoryVO categoryVO, CategoryListDTO categoryListDTO) {

        //当前分类通过,或者子分类有一个通过则都通过
        //不需要过滤
        if (StringUtils.isEmpty(categoryListDTO.getName()) && categoryListDTO.getStatus() == null) {
            return true;
        }
        boolean pass = true;
        // 状态校验
        if (categoryListDTO.getStatus() != null) { //和查询状态一致pass
            pass = (categoryVO.getStatus() == categoryListDTO.getStatus());
        }
        //名称校验
        if (pass && StringUtils.isNotEmpty(categoryListDTO.getName())) {//状态pass通过后校验名称,包含名称关键字 通过
            pass = StringUtils.isNotEmpty(categoryVO.getName()) && categoryVO.getName().contains(categoryListDTO.getName());
        }
        //分类信息校验未通过,并且没有子分类,当前分类不符合条件
        if (!pass && CollUtils.isEmpty(categoryVO.getChildren())) { //告诉上一级没通过
            return false;
        }
        //遍历子分类是否符合条件
        for (int count = categoryVO.getChildren().size() - 1; count >= 0; count--) {
            CategoryVO child = categoryVO.getChildren().get(count);
            //子分类校验
            boolean childPass = filter(child, categoryListDTO);
            if (!childPass) { //子分类不符合条件,从子分类列表中删除
                categoryVO.getChildren().remove(count);
            }
        }
        return pass || CollUtils.isNotEmpty(categoryVO.getChildren());
    }

    private void batchDownShelfCourse(Long categoryId, Integer level, Long userId) {
        //1.多线程下设置操作用户id
        UserContext.setUser(userId);
        //2.查询需要下架的课程
        List<Course> courses = courseService.queryByCategoryIdAndLevel(categoryId, level);
        if (CollUtils.isEmpty(courses)) {
            return;
        }
        //3.遍历下架课程
        for (Course course : courses) {
            //4.判断状态是否可以下架
            if (!CourseStatus.SHELF.equals(course.getStatus())) {
                continue;
            }
            try {
                //5.课程下架
                courseDraftService.downShelf(course.getId());
            } catch (Exception e) {
                log.error("课程下架异常");
            }
        }
    }

    /**
     * 过滤掉没有三级课程分类的
     * @param simpleCategoryVOS
     */
    private void filter(List<SimpleCategoryVO> simpleCategoryVOS){
        // 1.判空
        if(CollUtils.isEmpty(simpleCategoryVOS)){
            return;
        }
        // 2.遍历分类列表
        for (int count = simpleCategoryVOS.size() -1; count >= 0; count--) {
            SimpleCategoryVO simpleCategoryVO = simpleCategoryVOS.get(count);
            if(simpleCategoryVO.getLevel() == 3){
                continue;
            }
            filter(simpleCategoryVO.getChildren());
            if(CollUtils.isEmpty(simpleCategoryVO.getChildren())){
                simpleCategoryVOS.remove(count);
            }
        }
    }
    
    /**
     * 清除课程分类Redis缓存
     */
    private void clearCategoryCache() {
        try {
            redisTemplate.delete(RedisConstants.REDIS_KEY_CATEGORY_ALL);
            log.debug("课程分类Redis缓存已清除");
        } catch (Exception e) {
            log.error("清除课程分类Redis缓存失败", e);
        }
    }
}

4.3.2Redis常量类增加:

java 复制代码
package com.tianji.course.constants;

/**
 * @author wusongsong
 * @since 2022/7/17 17:20
 * @version 1.0.0
 **/
public class RedisConstants {

    //一级二级分类拥有的三级分类的数量
    public static final String REDIS_KEY_CATEGORY_THIRD_NUMBER = "CATEGORY:THIRD_NUMBER";
    
    //所有课程分类缓存(不分层)
    public static final String REDIS_KEY_CATEGORY_ALL = "CATEGORY:ALL";

    public static class Formatter {
        public static final String STATISTICS_EXAMINFO = "COURSE:SUBJECT:ANSWER_PROCESS_#{examDetailInfoDTO.recordId}";
        public static final String STATISTICS_COURSE_NUM_CATE = "COURSE:COURSE_NUM_CATEGORY";
        public static final String CATEGORY_ID_LIST_HAVE_COURSE = "COURSE:CATEGORY_ID_WITH_COURSE";
    }
}

5. 适用场景扩展

Caffeine 本地缓存不仅适用于课程分类,还可用于以下场景:

  • 系统配置(如限流阈值、开关配置);
  • 字典数据(如订单状态、用户角色);
  • 热点数据(如首页推荐课程、高频访问的商品信息)。

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

相关推荐
heartbeat..2 小时前
Redis 常用命令全解析:基础、进阶与场景化实战
java·数据库·redis·缓存
Yvonne爱编码2 小时前
Java 接口学习核心难点深度解析
java·开发语言·python
带刺的坐椅2 小时前
Solon AI Remote Skills:开启分布式技能的“感知”时代
java·llm·solon·mcp·skills
这周也會开心2 小时前
SSM 配置 index 页面的实现方式
java·tomcat·springmvc
黎雁·泠崖2 小时前
Java继承入门:概念+特点+核心继承规则
java·开发语言
sheji34162 小时前
【开题答辩全过程】以 小区物业管理APP为例,包含答辩的问题和答案
java
星辰徐哥3 小时前
Java程序的编译与运行机制
java·开发语言·编译·运行机制
老毛肚3 小时前
Spring 6.0基于JDB手写定制自己的ROM框架
java·数据库·spring
Sylvia-girl3 小时前
线程安全问题
java·开发语言·安全