天机学堂5-XxlJobRedis

news2025/1/21 16:07:52

文章目录

  • 梳理前面的实现:
    • Feign
    • 点赞改进
  • day07-积分系统
  • bitmap相关命令
  • 签到
    • 增加签到记录
    • 计算本月已连续签到的天数
    • 查询签到记录
  • 积分
    • 表设计
    • 签到-->发送RabbitMQ消息,保存积分
    • 对应的消费者:**消费消息 用于保存积分**
    • 增加积分
    • 查询个人今日积分情况
  • 排行榜
    • 分页查询当前赛季积分和排名列表-redis
    • 查询当前用户的当前赛季积分和排名
    • 查询积分榜
  • 榜单持久化
      • MybatisPlus实现动态表名
      • 定时任务持久化榜单到DB:
      • 分页查询指定历史赛季积分和排名
  • 海量数据存储策略
    • 历史榜单的存储策略
  • XXL-Job分布式任务
    • XXL-JOB任务分片
      • ~~xxl-job子任务--> 任务链~~

在这里插入图片描述
項目也用了Ribbon

梳理前面的实现:

Feign

在这里插入图片描述
Feign的拦截器 header里的key和网关是一样的

template.header(JwtConstants.USER_HEADER, userId.toString());

在这里插入图片描述

package com.tianji.authsdk.resource.interceptors;

import com.tianji.auth.common.constants.JwtConstants;
import com.tianji.common.utils.UserContext;
import feign.RequestInterceptor;
import feign.RequestTemplate;

// 服务之间的Feign调用没有用户信息,所以设置Feign拦截器获取用户信息,加到请求头,后面feign调用才有
public class FeignRelayUserInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        Long userId = UserContext.getUser();

        if (userId == null) {
            return;
        }
        // 将用户信息加到请求头里
        template.header(JwtConstants.USER_HEADER, userId.toString());
    }
}

点赞改进

day07-积分系统

  • 积分:用户在天机学堂网站的各种交互行为都可以产生积分,积分值与行为类型有关
  • 学霸天梯榜:按照每个学员的总积分排序得到的排行榜,称为学霸天梯榜。排名前三的有奖励。天梯榜每个自然月为一个赛季,月初清零。

积分获取规则

  1. 签到规则
    连续7天奖励10分 连续14天 奖励20 连续28天奖励40分, 每月签到进度当月第一天重置
  2. 学习规则
    每学习一小节,积分+10,每天获得上限50分
  3. 交互规则(有效交互数据参与积分规则,无效数据会被删除)
  • 写评价 积分+10
  • 写问答 积分+5 每日获得上限为20分
  • 写笔记 积分+3 每次被采集+2 每日获得上限为20分

bitmap相关命令

在这里插入图片描述
读取BitMap中的数据:

BITFIELD key GET encoding offset
  • GET:代表查询
  • encoding:返回结果的编码方式,BitMap中是二进制保存,而返回结果会转为10进制,但需要一个转换规则,也就是这里的编码方式
    • u:无符号整数,例如 u2,代表读2个bit位,转为无符号整数返回
    • i:又符号整数,例如 i2,代表读2个bit位,转为有符号整数返回
  • offset:从第几个bit位开始读取,例如0:代表从第一个bit位开始
    例如,我想查询从第1天到第3天的签到记录,可以这样:

BITFIELD key GET u3 0

可以看到,返回的结果是7. 为什么是7呢?
签到记录是 11100111,从0开始,取3个bit位,刚好是111,转无符号整数,刚好是7

Redis最基础的数据类型只有5种:String、List、Set、SortedSet、Hash,其它特殊数据结构大多都是基于以上5这种数据类型。
BitMap也不例外,它是基于String结构的。因为Redis的String类型底层是SDS,也会存在一个字节数组用来保存数据。而Redis就提供了几个按位操作这个数组中数据的命令,实现了BitMap效果。
由于String类型的最大空间是512MB,也就是2的31次幂个bit,因此可以保存的数据量级是十分恐怖的。

签到

增加签到记录

签到的key前缀,后面需要拼接用户id和年月,如sign:uid:111:202403

public static final String SIGN_RECORD_KEY_PREFIX = “sign:uid:”

public SignResultVO addSignRecords() {
    Long userId = UserContext.getUser();

    LocalDate now = LocalDate.now();
    int dayOfMonth = now.getDayOfMonth();   // 获取当前日是这个月的多少号
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMM");
    String yearMonth = formatter.format(now);   // 获取年月,如202403
    String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId.toString() + ":" + yearMonth;
    // 利用bitset,将签到记录保存到redis的bitmap结构中,需要校验是否已签到
    int offset = dayOfMonth - 1;    // 偏移量,因为下标从0开始所以减1
    // 校验是否已签到
    Boolean exists = redisTemplate.opsForValue().setBit(key, offset, true);
    if (exists) {
        throw new BadRequestException("不能重复签到");
    }
    // 计算连续签到的天数,以此计算是否有连续签到7天的额外奖励积分
    int signDays = countSignDays(key, dayOfMonth);
    // 计算连续签到的奖励积分,规则是连续签到7天加10积分,连续签到14天加20积分,连续签到28天加40积分
    int rewardPoints = 0;
    switch (signDays) {
        case 7:
            rewardPoints = 10;
            break;
        case 14:
            rewardPoints = 20;
            break;
        case 28:
            rewardPoints = 40;
            break;
    }

    // 发送RabbitMQ消息,保存积分
    rabbitMqHelper.send(MqConstants.Exchange.LEARNING_EXCHANGE,
            MqConstants.Key.SIGN_IN,
            // 积分数量 = 签到积分1+额外签到奖励积分
            SignInMessage.of(userId, rewardPoints + 1));
    // 封装结果并返回
    SignResultVO resultVO = SignResultVO.builder()
            .signDays(signDays)
            .signPoints(1)   // 签到分数,默认为1,不需要重复赋值
            .rewardPoints(rewardPoints)
            .build();

    return resultVO;
}

计算本月已连续签到的天数

计算本月已连续签到的天数: return 本月已连续签到的天数

  1. bitfield key get u本月当前天数 0: 获取从0到本月当前天数(后面的不要)
  2. 与1进行&运算,得到数据num的最后一位 ; num右移一位
 private int countSignDays(String key, int dayOfMonth) {
    // 获取本月直到当前天的签到数据,结果为十进制,返回结果为list,在第0个元素
    // 相当于 bitfield key get u本月当前天数 0
    List<Long> signList = redisTemplate.opsForValue().bitField(key,
            BitFieldSubCommands.create()
                    .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
                    .valueAt(0));
    // 判空
    if (CollUtil.isEmpty(signList)) {
        return 0;
    }
    int signDays = 0; // 计数器,统计连续签到天数
    Long num = signList.get(0);
    log.debug("num {}", num);
    // num转二进制,累加统计连续签到天数
    // 通过与1进行&运算,获取当前签到数据最后一位
    while ((num & 1) == 1) {
        signDays++;
        // 注:>>和>>>分别表示带符号右移和无符号右移,这里去的是无符号数,所以用>>>
        num = num >>> 1;    // 右移1位,更新num,
    }
    return signDays;
}

统计的是 当前天签到,到当前天为止连续签到的天数(并不一定是最大连续签到的天数)

查询签到记录

积分

为什么要为不同的

表设计

签到记录:用Bitmap

积分记录:

CREATE TABLE IF NOT EXISTS `points_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '积分记录表id',
  `user_id` bigint NOT NULL COMMENT '用户id',
  `type` tinyint NOT NULL COMMENT '积分方式:1-课程学习,2-每日签到,3-课程问答, 4-课程笔记,5-课程评价',
  `points` tinyint NOT NULL COMMENT '积分值',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `idx_user_id` (`user_id`,`type`) USING BTREE,
  KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学习积分记录,每个月底清零';

排行耪:

CREATE TABLE IF NOT EXISTS `points_board` (
  `id` bigint NOT NULL COMMENT '榜单id',
  `user_id` bigint NOT NULL COMMENT '学生id',
  `points` int NOT NULL COMMENT '积分值',
  `rank` tinyint NOT NULL COMMENT '名次,只记录赛季前100',
  `season` smallint NOT NULL COMMENT '赛季,例如 1,就是第一赛季,2-就是第二赛季',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `idx_season_user` (`season`,`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学霸天梯榜';

tinyint: -128~127

签到–>发送RabbitMQ消息,保存积分

 // 签到--发送RabbitMQ消息,保存积分
 rabbitMqHelper.send(MqConstants.Exchange.LEARNING_EXCHANGE,
         MqConstants.Key.SIGN_IN,
         // 积分数量 = 签到积分1+额外签到奖励积分
         SignInMessage.of(userId, rewardPoints + 1));

对应的消费者:消费消息 用于保存积分

public class LearningPointListener {

    private final IPointsRecordService pointsRecordService;

    /***
     * 接收签到增加的积分的消息,并增加积分
     * @param message 接受的参数类型为SignInMessage
     */
    @RabbitListener(bindings = @QueueBinding(value = @Queue(value = "signs.points.queue", durable = "true"),
            exchange = @Exchange(value = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = MqConstants.Key.SIGN_IN))
    public void signInListener(SignInMessage message) {
        log.debug("LearningPointListener接收签到消息,用户{},积分数量{}", message.getUserId(),message.getPoints());
        if (message.getUserId() == null
                || message.getPoints() == null) {
            // 这里是接受MQ消息,中断即可,若抛异常,则会开启重试
            return;
        }
        // 保存积分
        pointsRecordService.addPointRecord(message, PointsRecordType.SIGN);
    }

增加积分

@Data
@NoArgsConstructor
@AllArgsConstructor(staticName = "of")
public class SignInMessage {
    private Long userId;
    // 积分数量
    private Integer points;
}

// incrementScore,如果key存在,则给对应的value(用户id)的score加上增量detal(新增积分数量),没有则新创建并赋值
redisTemplate.opsForZSet().incrementScore(key, userId.toString(), savePoints)

public void addPointRecord(SignInMessage message, PointsRecordType recordType) {
    // 判断积分是否有上限,recordType.getMaxPoints()是否大于0
    boolean hasMaxPoints = recordType.getMaxPoints() > 0;
    Long userId = message.getUserId();  // 获取当前登录用户信息
    // 如果有上限制,查询该用户今日该积分类型已获得的积分数量
    LocalDateTime now = LocalDateTime.now();
    int currentPoints = 0;
    if (hasMaxPoints) {
        LocalDateTime dayStartTime = DateUtils.getDayStartTime(now);    // 当前开始时间
        LocalDateTime dayEndTime = DateUtils.getDayEndTime(now);    // 当天结束时间
        QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
        
        wrapper.select("sum(points) as currentPoints")
                .eq("user_id", userId) 
                .eq("type", recordType)   
                .between("create_time", dayStartTime, dayEndTime); 
        Map<String, Object> map = this.getMap(wrapper);
        if (map != null) {
            BigDecimal bigDecimal = (BigDecimal) map.get("currentPoints");
            currentPoints = bigDecimal.intValue();
        }
        // 判断积分是否超过上限
        if (currentPoints >= recordType.getMaxPoints()) {
            return;
        }
    }
    // 计算实际应保存的积分数量
    int savePoints = hasMaxPoints ? Math.min(recordType.getMaxPoints() - currentPoints, message.getPoints()) : message.getPoints();
    // 保存积分明细
    PointsRecord pointsRecord = PointsRecord.builder().userId(userId)
            .type(recordType)
            .points(savePoints)
            .build();
    this.save(pointsRecord);
    // 累加并保存该用户总积分到redis的zset,用于生成当前赛季的排行榜
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMM");
    String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + formatter.format(now);
    // incrementScore,如果key存在,则给对应的value(用户id)的score加上增量detal(新增积分数量),没有则新创建并赋值
    redisTemplate.opsForZSet().incrementScore(key, userId.toString(), savePoints);
}

查询个人今日积分情况

在这里插入图片描述
在这里插入图片描述

@ApiModel(value="PointsBoard对象", description="学霸天梯榜")
public class PointsBoard implements Serializable {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "榜单id")
    @TableId(value = "id", type = IdType.ASSIGN_ID)
    private Long id;

    @ApiModelProperty(value = "学生id")
    @TableField("user_id")
    private Long userId;

    @ApiModelProperty(value = "积分值")
    @TableField("points")
    private Integer points;

    @ApiModelProperty(value = "名次,只记录赛季前100")
    @TableField("rank")
    private Integer rank;

    @ApiModelProperty(value = "赛季,例如 1,就是第一赛季,2-就是第二赛季")
    @TableField("season")
    private Integer season;


}

排行榜

实时排行榜–>Redis
历史排行榜–>DB

// 查询当前用户排名,根据query.season区分当前赛季(redis)和历史赛季(db)
PointsBoardVO boardVO = isCurrentBoard ? queryMyCurrentBoardRank(key) : queryMyHistoryBoardRank(season);
// 分类查询赛季列表,根据query.season区分当前赛季(redis)和历史赛季(db)
List<PointsBoard> boards = isCurrentBoard ? queryCurrentBoardRankList(query, key) : queryHistoryBoardRankList(season, query);

要想形成排行榜,我们在查询数据库时,需要先对用户分组,再对积分求和,最终按照积分和排序,Sql语句是这样:
SELECT user_id, SUM(points) FROM points_record GROUP BY user_id ORDER BY SUM(points)
但是效率不高 每次都要分组

zset实现:
在这里插入图片描述
积分榜单汇总信息的VO:

public class PointsBoardVO {
    @ApiModelProperty("我的榜单排名")
    private Integer rank;
    @ApiModelProperty("我的积分值")
    private Integer points;
    @ApiModelProperty("前100名上榜人信息")
    private List<PointsBoardItemVO> boardList;
}

每个赛季结束定时任务持久化到DB。Redis存当月当赛季的实时排行榜
保存积分addPointRecord里的:key是“”+赛季年月

// 累加并保存该用户总积分到redis的zset,用于生成当前赛季的排行榜
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMM");
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + formatter.format(now);
// incrementScore,如果key存在,则给对应的value(用户id)的score加上增量detal(新增积分数量),没有则新创建并赋值
redisTemplate.opsForZSet().incrementScore(key, userId.toString(), savePoints);

在这里插入图片描述

分页查询当前赛季积分和排名列表-redis

在这里插入图片描述

Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
int randIndex = start + 1;  // 排名计数器
List<PointsBoard> boards = new ArrayList<>();

for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
    
    Double score = typedTuple.getScore();   // 积分数
    String value = typedTuple.getValue();   // 用户id
    if (StrUtil.isBlank(value) || score == null) {   // 判空防止空指针
        continue;
    }
    PointsBoard board = PointsBoard.builder().rank(randIndex++)
            .points(score.intValue())
            .userId(Long.parseLong(value))
            .build();
    boards.add(board);
}

ZSetOperations.TypedTuple<String> 是针对 Redis 有序集合(ZSet) 的元素及其分数的封装。这个 TypedTuple<String> 实际上代表了一个带有值和分数的 有序集合元素。
在 Redis 中,有序集合是基于 分数(score) 对 元素(value) 排序的。这个 TypedTuple 中有两个部分:
值:这是存储在有序集合中的元素本身(可以是任何类型的值,例如 String、Integer 等)。
分数:每个元素有一个关联的分数(score),它是一个 double 类型,用来排序有序集合中的元素。

查询当前用户的当前赛季积分和排名

在这里插入图片描述

// 获取当前赛季积分
Double score = redisTemplate.opsForZSet().score(key, userId.toString());
// 获取当前赛季排名,zset默认是按score升序排名,rank升序排名,reverseRank即按照score降序排名
Long rank = redisTemplate.opsForZSet().reverseRank(key, userId.toString()) + 1;

查询积分榜

要从DB中查,因为Redis只有当月的。同时持久化到DB还没没做
在这里插入图片描述
积分赛季表 服务实现类

public class PointsBoardSeasonServiceImpl extends ServiceImpl<PointsBoardSeasonMapper, PointsBoardSeason> implements IPointsBoardSeasonService {

    private final PointsBoardSeasonMapper pointsBoardSeasonMapper;

    /**
     * 查询历史赛季列表
     */
    @Override
    public List<PointsBoardSeason> getHistorySeasonList() {
        List<PointsBoardSeason> seasonList = this.lambdaQuery().list();
        if (CollUtil.isEmpty(seasonList)) {  // 判空
            throw new BizIllegalException("查询历史赛季列表失败");
        }
        return seasonList;
    }

    /**
     * 创建上赛季表
     *
     * @param tableName 上赛季表名
     */
    @Override
    public void createPointsBoardTableOfLastSeason(String tableName) {
        pointsBoardSeasonMapper.createPointsBoardTableOfLastSeason(tableName);
    }
}
<insert id="createPointsBoardTableOfLastSeason" parameterType="java.lang.String">
    CREATE TABLE `${tableName}`
    (
        `id`      BIGINT NOT NULL AUTO_INCREMENT COMMENT '榜单id',
        `user_id` BIGINT NOT NULL COMMENT '学生id',
        `points`  INT    NOT NULL COMMENT '积分值',
        PRIMARY KEY (`id`) USING BTREE,
        INDEX `idx_user_id` (`user_id`) USING BTREE
    )
        COMMENT ='学霸天梯榜分表'
        COLLATE = 'utf8mb4_0900_ai_ci'
        ENGINE = InnoDB
        ROW_FORMAT = DYNAMIC
</insert>

${tableName}传的是public static final String POINTS_BOARD_TABLE_PREFIX = “points_board_”+赛季ID,下面是积分赛季表
在这里插入图片描述

榜单持久化

在这里插入图片描述

MybatisPlus实现动态表名

动态表名是:"point_board" + 赛季ID
如何获取:1. ThreadLocal存储动态表名
按照赛季ID分表,保存的表是没有rank字段的
在这里插入图片描述

public class TableInfoContext {
    private static final ThreadLocal<String> TL = new ThreadLocal<>();

    public static void setInfo(String info) {
        TL.set(info);
    }

    public static String getInfo() {
        return TL.get();
    }

    public static void remove() {
        TL.remove();
    }
}
  1. 声明动态表名拦截器,使用拦截器插件
package com.tianji.learning.config;

import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import com.tianji.learning.util.TableInfoContext;

@Configuration//配置类
public class MybatisConfiguration {

    // 声明动态表名拦截器,使用拦截器插件
    @Bean
    public DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor() {
        // 准备一个Map,用于存储TableNameHandler
        Map<String, TableNameHandler> map = new HashMap<>(1);
        // 存入一个TableNameHandler,用来替换points_board表名称
        // 替换方式,就是从TableInfoContext中读取保存好的动态表名,判空是提高代码健壮性
        map.put("points_board", (sql, tableName) -> TableInfoContext.getInfo() == null ? tableName : TableInfoContext.getInfo());
        map.put("points_record", (sql, tableName) -> TableInfoContext.getInfo() == null ? tableName : TableInfoContext.getInfo());
        return new DynamicTableNameInnerInterceptor(map);
    }
}

在执行SQL语句时,根据TableInfoContext中存储的动态表名信息,决定是否替换原始表名。如果TableInfoContext中有动态表名,则使用动态表名;如果没有,则使用原始表名
通过@Bean注解将dynamicTableNameInnerInterceptor方法返回的DynamicTableNameInnerInterceptor实例注册为一个Spring Bean。这个拦截器会被MyBatis自动识别并加入到拦截器链中

在这里插入图片描述
4. 用的时候set get设置
在这里插入图片描述

定时任务持久化榜单到DB:

/**
     * 持久化上赛季排行榜数据到DB
     * XxlJob注解内容要和任务名称一致
     * 使用XxlJob实现任务分片
     */
    @XxlJob("savePointsBoard2DB")
    public void savePointsBoard2DB() {
        log.debug("开始持久化上赛季排行榜数据到DB...");
        // 查询上赛季信息
        PointsBoardSeason boardSeason = getLastPointsBoardSeason();
        // 拼接表名并存入ThreadLocal
        String tableName = LearningConstants.POINTS_BOARD_TABLE_PREFIX + boardSeason.getId();
        TableInfoContext.setInfo(tableName);//存入ThreadLocal

        // 分页从redis查询上赛季榜单数据
        LocalDate time = LocalDate.now().minusMonths(1);
        DateTimeFormatter yearMonth = DateTimeFormatter.ofPattern("yyyyMM");
        String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + time.format(yearMonth);
        // xxl-job分片广播
        int sharedIndex = XxlJobHelper.getShardIndex(); // 当前分片索引
        int shardTotal = XxlJobHelper.getShardTotal();  // 总分片数
        // 构建分页参数
        PointsBoardQuery pageQuery = new PointsBoardQuery();
        pageQuery.setPageNo(sharedIndex + 1); // 页码
        pageQuery.setPageSize(1000);    // 页面记录数

        while (true) {
            List<PointsBoard> boards = pointsBoardService.queryCurrentBoardRankList(pageQuery, key);
            if (CollUtil.isEmpty(boards)) {  // 结束循环
                break;
            }
            // 翻页,跳过N个页,N就是分片数量
            pageQuery.setPageNo(pageQuery.getPageNo() + shardTotal);   // 页码+total,跳过N页
            // 字段处理,rank赋值给id并清空
            for (PointsBoard board : boards){
                board.setId(board.getRank().longValue());   // 历史赛季排行榜中id存了rank排名
                board.setRank(null);    // 清空rank
            }
            // 持久化到db相应的赛季表中,批量新增
            pointsBoardService.saveBatch(boards);
        }
        // 清空ThreadLocal中的数据
        TableInfoContext.remove();
        log.debug("完成持久化上赛季排行榜数据到DB...");

    }
public class PointsBoardPersistentHandler {

    private final IPointsBoardSeasonService pointsBoardSeasonService;
    private final IPointsBoardService pointsBoardService;//注入都要写接口注入,不能拿impl实现类
    private final StringRedisTemplate redisTemplate;

    /**
     * 定时任务创建上赛季榜单表
     */
    // @Scheduled(cron = "0 0 3 1 * ?")    // 一月一个赛季,每月1号的凌晨3点
    // @Scheduled(cron = "0 43 15 27 3 ?")    // 一月一个赛季,每月1号的凌晨3点
    @XxlJob("createPointsBoardTableJob")
    private void createPointsBoardTableOfLastSeason() {
        log.debug("创建上赛季榜单表开始执行....");
        PointsBoardSeason boardSeason = getLastPointsBoardSeason();
        // 创建上赛季榜单表,表名示例:points_board_7, 7为赛季id
        pointsBoardSeasonService.createPointsBoardTableOfLastSeason(LearningConstants.POINTS_BOARD_TABLE_PREFIX + boardSeason.getId());
        log.debug("创建上赛季榜单表成功 ....");
    }

    /**
     * 查询上赛季信息
     */
    private PointsBoardSeason getLastPointsBoardSeason() {
        // 获取上个月的当前时间点
        LocalDate time = LocalDate.now().minusMonths(1);
        // 从赛季表查询对应赛季信息
        PointsBoardSeason boardSeason = pointsBoardSeasonService.lambdaQuery()
                .le(PointsBoardSeason::getBeginTime, time)
                .ge(PointsBoardSeason::getEndTime, time)
                .one();
        return boardSeason == null ? new PointsBoardSeason() : boardSeason;
    }

    /**
     * 清除redis的历史榜单
     */
    @XxlJob("clearPointsBoardFromRedis")
    public void clearPointsBoardFromRedis(){
        // 拼接key
        LocalDate time = LocalDate.now().minusMonths(1);
        DateTimeFormatter yearMonth = DateTimeFormatter.ofPattern("yyyyMM");
        String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + time.format(yearMonth);
        // 删除键
        redisTemplate.unlink(key);
    }
}

分页查询指定历史赛季积分和排名

private List<PointsBoard> queryHistoryBoardRankList(Long seasonId, PointsBoardQuery query) {
        String tableName = LearningConstants.POINTS_BOARD_TABLE_PREFIX + seasonId;
        TableInfoContext.setInfo(tableName);    // 设置动态表名
        // 根据排名降序分页查询
        Page<PointsBoard> boardPage = this.lambdaQuery()
                .select(PointsBoard::getId, PointsBoard::getPoints, PointsBoard::getUserId)   // 设置查询字段,id,user_id和points
                .page(query.toMpPage("id", false));
        if (CollUtil.isEmpty(boardPage.getRecords())) {
            throw new BizIllegalException("查询历史赛季排行榜信息异常");
        }
        List<PointsBoard> boardList = boardPage.getRecords().stream().map(board -> {
            // 排名rank暂存在了id字段,
            board.setRank(board.getId() == null ? 0 : board.getId().intValue());
            board.setId(null);
            return board;
        }).collect(Collectors.toList());
        TableInfoContext.remove();  // 移除动态表名
        return boardList;
    }

假如有数百万用户,这就意味着每个赛季榜单都有数百万数据。随着时间推移,历史赛季越来越多,如果全部保存到一张表中,数据量会非常恐怖!
该怎么办呢?

海量数据存储策略

对于数据库的海量数据存储,方案有很多,常见的有:

  1. 分区(Partition)是一种数据存储方案,可以解决单表数据较多的问题
    在这里插入图片描述
    按照某种规则,把表数据对应的ibd文件拆分成多个文件来存储。从物理上来看,一张表的数据被拆到多个表文件存储了;从逻辑上来看,他们对外表现是一张表。但逻辑上还是一张表。增删改查的方式不会有什么变化,只不过底层MySQL底层的处理上会有变更。例如检索时可以只检索某个文件,而不是全部。
    在这里插入图片描述

在这里插入图片描述
3. 分库
在这里插入图片描述
4.集群
在这里插入图片描述
在这里插入图片描述

历史榜单的存储策略

天机学堂项目是一个教育类项目,用户规模并不会很高,一般在十多万到百万级别。因此最终的数据规模也并不会非常庞大。
综合之前的分析,结合天机学堂的项目情况,我们可以对榜单数据做分表,但是暂时不需要做分库和集群。

由于我们要解决的是数据过多问题,因此分表的方式选择水平分表。具体来说,就是按照赛季拆分,每一个赛季是一个独立的表
在这里插入图片描述

XXL-Job分布式任务

目前,我们的定时任务都是基于SpringTask来实现的。但是SpringTask存在一些问题:

  • 当微服务多实例部署时,定时任务会被执行多次。而事实上我们只需要这个任务被执行一次即可。
  • 我们除了要定时创建表,还要定时持久化Redis数据到数据库,我们希望这多个定时任务能够按照顺序依次执行,SpringTask无法控制任务顺序

不仅仅是SpringTask,其它单机使用的定时任务工具,都无法实现像这种任务执行者的调度、任务执行顺序的编排、任务监控等功能。这些功能必须要用到分布式任务调度组件。
单机使用的定时任务在多实例部署的时候,每个启动的服务实例都会有自己的任务触发器,这样就会导致各个实例各自运行,无法统一控制。
把任务触发器提取到各个服务实例之外,去做统一的触发、统一的调度。
事实上,大多数的分布式任务调度组件都是这样做的:
在这里插入图片描述
XXL-JOB的运行原理和架构如图:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

@ConfigurationProperties(prefix = "tj.xxl-job")
public class XxlJobProperties {

    private String accessToken;
    private Admin admin;
    private Executor executor;

    @Data
    public static class Admin {
        private String address;
    }

    @Data
    public static class Executor {
        private String appName;
        private String address;
        private String ip;
        private Integer port;
        private String logPath;
        private Integer logRetentionDays;

    }
}
@Configuration
@ConditionalOnClass(XxlJobSpringExecutor.class)
@EnableConfigurationProperties(XxlJobProperties.class)
public class XxlJobConfig {

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor(XxlJobProperties prop) {
        log.info(">>>>>>>>>>> xxl-job config init.");
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        XxlJobProperties.Admin admin = prop.getAdmin();
        if (admin != null && StringUtils.isNotEmpty(admin.getAddress())) {
            xxlJobSpringExecutor.setAdminAddresses(admin.getAddress());
        }
        XxlJobProperties.Executor executor = prop.getExecutor();
        if (executor != null) {
            if (executor.getAppName() != null)
                xxlJobSpringExecutor.setAppname(executor.getAppName());
            if (executor.getIp() != null)
                xxlJobSpringExecutor.setIp(executor.getIp());
            if (executor.getPort() != null)
                xxlJobSpringExecutor.setPort(executor.getPort());
            if (executor.getLogPath() != null)
                xxlJobSpringExecutor.setLogPath(executor.getLogPath());
            if (executor.getLogRetentionDays() != null)
                xxlJobSpringExecutor.setLogRetentionDays(executor.getLogRetentionDays());
        }
        if (prop.getAccessToken() != null)
            xxlJobSpringExecutor.setAccessToken(prop.getAccessToken());
        log.info(">>>>>>>>>>> xxl-job config end.");
        return xxlJobSpringExecutor;
    }
}

在这里插入图片描述
appname: 和服务名称一致
在这里插入图片描述
handler那个和注解里的一致
路由策略:就是指如果有多个任务执行器,该由谁执行?

路由策略说明:
ROUND(轮询):在线的执行器按照轮询策略选择一个执行【轮着执行】
SHARDING_BROADCAST(分片广播):广播触发对应集群中所有执行器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务

在这里插入图片描述

XXL-JOB任务分片

通过while死循环,不停的查询数据,直到把所有数据都持久化为止。这样如果数据量达到数百万,交给一个任务执行器来处理会耗费非常多时间。
因此,将来肯定会将学习服务多实例部署,这样就会有多个执行器并行执行。但是,如果交给多个任务执行器,大家执行相同代码,都从第1页逐页处理数据,又会出现重复处理的情况。
怎么办?
这就要用到任务分片的方案了。

怎样才能确保任务不重复呢?我们可以参考扑克牌发牌的原理:

  • 逐一给每个人发牌
  • 发完一圈后,再回头给第一个人发
  • 重复上述动作,直到牌发完为止
    在这里插入图片描述
    要想知道每一个执行器执行哪些页数据,只要弄清楚两个关键参数即可:
  • 起始页码:pageNo
  • 下一页的跨度:step

而这两个参数是有规律的:

  • 起始页码:执行器编号是多少,起始页码就是多少
  • 页跨度:执行器有几个,跨度就是多少。也就是说你要跳过别人读取过的页码
int sharedIndex = XxlJobHelper.getShardIndex(); // 起始页码:当前分片索引
int shardTotal = XxlJobHelper.getShardTotal();  // 跨度:总分片数
// 构建分页参数
PointsBoardQuery pageQuery = new PointsBoardQuery();
pageQuery.setPageNo(sharedIndex + 1); // 页码(从1开始)
pageQuery.setPageSize(1000);    // 页面记录数

while (true) {
    List<PointsBoard> boards = pointsBoardService.queryCurrentBoardRankList(pageQuery, key);
    if (CollUtil.isEmpty(boards)) {  // 结束循环
        break;
    }
    // 翻页,跳过N个页,N就是分片数量
    pageQuery.setPageNo(pageQuery.getPageNo() + shardTotal);   // 页码+total,跳过N页
    // 字段处理,rank赋值给id并清空
    for (PointsBoard board : boards){
        board.setId(board.getRank().longValue());   // 历史赛季排行榜中id存了rank排名
        board.setRank(null);    // 清空rank
    }
    // 持久化到db相应的赛季表中,批量新增
    pointsBoardService.saveBatch(boards);
}

在这里插入图片描述
这里是 每个实例:端口不一样 实现的
在这里插入图片描述
分片广播:实现横向加机器 解决 如一亿个用户计算余额宝的余额太耗时的问题
面试 场景:用户特别大的情况,榜单特别大,

Redis del和unlink的区别
在这里插入图片描述

xxl-job子任务–> 任务链

和分片一起用,非常可能出问题【分片 其中一个实例执行完就会执行子任务,可能清除掉还未持久化的redis数据】所以还是别用了。每个Job设置执行时间,谁先谁后就好了
要想让任务A、B依次执行,其实就是配置任务B作为任务A的子任务。因此,我们按照下面方式配置:

  • 创建历史榜单表(10)的子任务是持久化榜单数据任务(12)
  • 持久化榜单数据任务(12)的子任务是清理Redis中的历史榜单(13)

也就是说:10的子任务是12, 12的子任务是13
在这里插入图片描述
在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2279891.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

2024 年度学习总结

目录 1. 前言 2. csdn 对于我的意义 3. 写博客的初衷 3.1 现在的想法 4. 写博客的意义 5. 关于生活和博客创作 5.1 写博客较于纸质笔记的优势 6. 致 2025 1. 前言 不知不觉, 来到 csdn 已经快一年了, 在这一年中, 我通过 csdn 学习到了很多知识, 结识了很多的良师益友…

使用Chrome和Selenium实现对Superset等私域网站的截图

最近遇到了一个问题&#xff0c;因为一些原因&#xff0c;我搭建的一个 Superset 的 Report 功能由于节假日期间不好控制邮件的发送&#xff0c;所以急需一个方案来替换掉 Superset 的 Report 功能 首先我们需要 Chrome 浏览器和 Chrome Driver&#xff0c;这是执行数据抓取的…

[操作系统] 进程的调度

进程切换概念 时间⽚&#xff1a;当代计算机都是分时操作系统&#xff0c;没有进程都有它合适的时间⽚(其实就是⼀个计数 器)。时间⽚到达&#xff0c;进程就被操作系统从CPU中剥离下来。 死循环是如何运行&#xff1f; 当一个进程代码为死循环&#xff0c;它并不会一直占据C…

Biotin sulfo-N-hydroxysuccinimide ester ;生物素磺基-N-羟基琥珀酰亚胺酯;生物素衍生物;190598-55-1

一、生物素及其衍生物的概述 生物素衍生物是指在生物素&#xff08;Vitamin H或B7&#xff09;分子基础上进行化学修饰得到的衍生化合物。这些衍生化合物在生物医学研究、临床诊断和药物开发等领域有着广泛的应用。 生物素&#xff08;Biotin&#xff09;是一种水溶性维生素&a…

Jenkins-Pipeline简述

一. 什么是Jenkins pipeline&#xff1a; pipeline在jenkins中是一套插件&#xff0c;主要功能在于&#xff0c;将原本独立运行于单个或者多个节点的任务连接起来&#xff0c;实现单个任务难以完成的复杂发布流程。Pipeline的实现方式是一套Groovy DSL&#xff0c;任何发布流程…

Linux系统下安装配置Nginx(保姆级教程)

目录 前言 安装配置Nginx 一.下载依赖 二.下载Nginx 1. 访问官网?&#xff0c;获取需要的Nginx版本 2. 将文件下载到Linux系统 3. 解压文件 4. 解压成功后&#xff0c;当前文件夹会出现一个nginx-1.26.1文件夹&#xff0c;进入到文件夹内 5. 配置nginx 6.?编译并安…

《Linux服务与安全管理》| 邮件服务器安装和配置

《Linux服务与安全管理》| 邮件服务器安装和配置 目录 《Linux服务与安全管理》| 邮件服务器安装和配置 1.在Server01上安装dns、postfix、dovecot和telnet&#xff0c;并启动 2&#xff0e;在Server01上配置DNS服务器&#xff0c;设置MX资源记录 3&#xff0e;在server1上…

WPS数据分析000001

目录 一、表格的新建、保存、协作和分享 新建 保存 协作 二、认识WPS表格界面 三、认识WPS表格选项卡 开始选项卡 插入选项卡 页面布局选项卡 公式选项卡 数据选项卡 审阅选项卡 视图选项卡 会员专享选项卡 一、表格的新建、保存、协作和分享 新建 ctrlN------…

2025年免费量化交易软件——PTrade(含开通攻略)

量化交易软件&#xff0c;为广大投资者提供了一个便捷、高效的投资工具。 本文重点为大家介绍一款2025年好用的免费量化交易软件&#xff1a;PTrade量化&#xff0c;并详解其功能、特点、开通方法等。 一、PTrade的概念 PTrade是恒生电子开发的一款交易终端软件&#xff0c;旨…

【数据结构篇】顺序表 超详细

目录 一.顺序表的定义 1.顺序表的概念及结构 1.1线性表 2.顺序表的分类 2.1静态顺序表 2.2动态顺序表 二.动态顺序表的实现 1.准备工作和注意事项 2.顺序表的基本接口&#xff1a; 2.0 创建一个顺序表 2.1 顺序表的初始化 2.2 顺序表的销毁 2.3 顺序表的打印 3.顺序…

mysql查缺补漏

auto increment&#xff1a;自增序列&#xff0c;在字段后作为约束使用 comment&#xff1a;备注信息&#xff0c;用于在创建字段后或创建表的语句最后. 数值类型&#xff1a; 字符串类型&#xff1a; 日期类型&#xff1a; desc table_name&#xff1a;查询表结构 sho…

C++ 面向对象(继承)

三、继承 3.1 继承的概念 基于一个已有的类 去重新定义一个新的类&#xff0c;这种方式我们叫做继承 关于继承的称呼 一个类B 继承来自 类 A 我们一般称呼 A类&#xff1a;父类 基类 B类: 子类 派生类 B继承自A A 派生了B 示例图的语法 class vehicle // 车类 {}class …

JAVA-IO模型的理解(BIO、NIO)

前言 &#xff08;本文是作者学习制作rpc框架时&#xff0c;一些自用的笔记&#xff0c;并不会完整详细的介绍某个模块&#xff0c;会写大概的流程及一些相关概念&#xff0c;供日后复习使用~&#xff09; IO模型 先理解基本的IO流程&#xff1a; 应用A把消息发送到 TCP发送缓…

【Spring】原型 Bean 被固定

问题描述 在定义 Bean 时&#xff0c;有时候我们会使用原型 Bean&#xff0c;例如定义如下&#xff1a; Service Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class ServiceImpl { }然后我们按照下面的方式去使用它&#xff1a; RestController public class Hello…

奉加微PHY6230兼容性:部分手机不兼容

从事嵌入式单片机的工作算是符合我个人兴趣爱好的,当面对一个新的芯片我即想把芯片尽快搞懂完成项目赚钱,也想着能够把自己遇到的坑和注意事项记录下来,即方便自己后面查阅也可以分享给大家,这是一种冲动,但是这个或许并不是原厂希望的,尽管这样有可能会牺牲一些时间也有哪天原…

Python脚本实现通过JLink烧录Hex文件

1 安装JLink驱动程序 驱动安装包下载路径&#xff1a;https://www.segger.com/downloads/jlink/ 选择对应的版本下载&#xff1a; 将下载的安装文件双击进行安装。 2 安装 pylink 包 pip install pylink3 查询 JLink 设备的 serial number 将JLink通过USB线插入电脑。 w…

【Qt】04-Lambda表达式

前言一、概念引入二、使用方法2.1 基本用法代码示例2.2 捕获外部变量2.3 参数列表 三、完整代码mywidget.cppsecondwidget.cppmywidget.hsecondwidget.h 总结 前言 一、概念引入 Lambda表达式&#xff08;Lambda Expressions&#xff09;是C11标准引入的一种匿名函数对象&…

[STM32 HAL库]串口中断编程思路

一、前言 最近在准备蓝桥杯比赛&#xff08;嵌入式赛道&#xff09;&#xff0c;研究了以下串口空闲中断DMA接收不定长的数据&#xff0c;感觉这个方法的接收效率很高&#xff0c;十分好用。方法配置都成功了&#xff0c;但是有一个点需要进行考虑&#xff0c;就是一般我们需要…

汇编与逆向(一)-汇编工具简介

RadASM是一款著名的WIN32汇编编辑器&#xff0c;支持MASM、TASM等多种汇编编译器&#xff0c;Windows界面&#xff0c;支持语法高亮&#xff0c;自带一个资源编辑器和一个调试器。 一、汇编IDE工具&#xff1a;RadASM RadASM有内置的语言包 下载地址&#xff1a;RadASM asse…

Langchain+FastApi+Vue前后端Ai对话(超详细)

一、引入 首先可以先看下作者的文章 FastApi相关文章&#xff1a;创建最简单FastApi的项目Vue相关文章&#xff1a;最简单的aixos二次封装Langchain相关文章&#xff1a;如何使用LangSmith跟踪deepseek模型 二、后端搭建 1 项目文件结构 routers&#xff1a;存放api接口se…