3️⃣
主要技术点:动态表名分表,XXL-JOB分区任务与避免重复执行,Redis的Bitmaps与Zset使用,业务点:积分记录定时分表,榜单持久化,点赞记录持久化。
Redis客户端选择
IDEA
- 使用中发现IDEA自带的Redis连接功能有限
- 比如不能对zset里面的数据按字段选择排序

- 无法查看bitmap的数据

- 只能靠BITFIELD返回的结果估计(此命令Redis以10进制返回结果)



Another Redis Desktop Manager

- 可以根据字段做排序

- 以二进制显示bitmap数据


Tiny RDM
- 界面更好看的客户端,但是通过GUI直接更改bitmap数据的时候存在问题,比如将某一偏移位置值改为1,往后的所有数据也都全部设置为1了。

点赞记录的持久化方案
核心思路
- 基本方法:通过分离Redis里存放更新数据的集合与全量数据的集合来进行增量更新。
- 数据结构选择:由于需要批量取出数据并保证数据唯一不重复,采用Zset存放更新部分数据,出于方便,将新增的数据放至NEW中,删除的数据放至DEL中。
流程图

- 部分环节trick:1.点赞/取消中,同时发送ZSCORE与ZINCRBY,通过ZSCORE返回值(数据或nil)可以区分点赞数是否已在Redis中,如果返回不为null,那么数据在Redis中,业务已经结束,反之则查询数据库,将数据库结果ZINCRBY上去即可。2.查询已点赞时,通过Redis中ALL判断是否有数据,同时可以通过DEL检查用户是否先前取消了点赞,减少二次检查的查询量。3.定时同步操作里,由于业务id没有办法给定,需要自己构造key模板,交给ScanOptions,通过 executeWithStickyConnection在长连接中不断scan各个键将数据加入集合,用于数据库一次批量处理(防止数据过多限定了一次读取的数量上限)。
新增/取消




java
/** * 新增或取消点赞(支持数据库持久化)分离存储的Set与更新Set以实现增量更新 */ @Override public void addOrDeleteLikeRecordPersistent(LikeRecordFormDTO dto) { String bizType = dto.getBizType(); String bizIdString = dto.getBizId() .toString(); // * 拼装Key定位Set,使用new与del实现增量更新,避免每次必须全量更新 String newKey = RedisConstants.LIKES_NEW_KEY_PREFIX + bizType + ":" + bizIdString; String delKey = RedisConstants.LIKES_DEL_KEY_PREFIX + bizType + ":" + bizIdString; String allKey = RedisConstants.LIKES_ALL_KEY_PREFIX + bizType + ":" + bizIdString; boolean success = false; Long userId = UserContext.getUser(); String user = userId.toString(); // * 通过Set返回值进行后续业务 List<Object> results; if (dto.getLiked()) { // * 点赞 // * 移出DEL;加入Redis NEW与ALL;NEW作数据库更新,ALL作全数据;避免NEW与DEL冲突(用户点完就取消) results = redisTemplate.executePipelined(new SessionCallback<Object>() { @SuppressWarnings({"unchecked", "NullableProblems"}) @Override public Object execute(RedisOperations operations) throws DataAccessException { operations.opsForZSet() .remove(delKey, user); operations.opsForZSet() .add(newKey, user, 0); operations.opsForSet() .add(allKey, user); return null; } }); } else { // * 取消 // * 移出NEW;加入Redis DEL与移出ALL;DEL作数据库更新,ALL作全数据 results = redisTemplate.executePipelined(new SessionCallback<Object>() { @SuppressWarnings({"unchecked", "NullableProblems"}) @Override public Object execute(RedisOperations operations) throws DataAccessException { operations.opsForZSet() .remove(newKey, user); operations.opsForZSet() .add(delKey, user, 0); operations.opsForSet() .remove(allKey, user); return null; } }); } success = CollUtils.isNotEmpty(results) && (Long) results.get(results.size() - 1) > 0; // * 是否成功(避免操作重复) if (success) { String timesKey = RedisConstants.LIKES_TIMES_KEY_PREFIX + dto.getBizType(); // * 成功操作,修改当前点赞数 results = redisTemplate.executePipelined(new SessionCallback<Object>() { @SuppressWarnings({"unchecked", "NullableProblems"}) @Override public Object execute(RedisOperations operations) throws DataAccessException { operations.opsForZSet() .score(timesKey, bizIdString); operations.opsForZSet() .incrementScore(timesKey, bizIdString, dto.getLiked() ? 1 : -1); return null; } }); // * Redis操作失败 if (CollUtils.isEmpty(results)) { throw new RedisException("Redis修改点赞计数失败"); } // * 已有原始计数数据,业务结束 if (results.get(0) != null) { return; } // * 不存在原始计数数据,查询数据库,将原始数据加上 Integer count = lambdaQuery() .eq(LikedRecord::getBizType, bizType) .eq(LikedRecord::getBizId, dto.getBizId()) .count(); // * 数据库不存在数据,结束业务 if (count == null || count == 0) { return; } // * 加入原始数据 redisTemplate.opsForZSet() .incrementScore(timesKey, bizIdString, count); } }
Java
查询点赞情况



java
/** * 查询点赞情况,先查Redis,没有就查数据库 */ @Override public Set<Long> queryLikedListByUserIdsAndBizIdsPersistent(String bizType, List<Long> bizIds) { Long userId = UserContext.getUser(); String user = userId.toString(); // * 批量查询对应业务id项用户点赞记录 List<Object> objects = redisTemplate.executePipelined(new RedisCallback<Object>() { @Override public Object doInRedis(RedisConnection connection) throws DataAccessException { for (Long bizId : bizIds) { String allKey = RedisConstants.LIKES_ALL_KEY_PREFIX + bizType + ":" + bizId; String delKey = RedisConstants.LIKES_DEL_KEY_PREFIX + bizType + ":" + bizId; // * Redis中是否有 connection.sIsMember(allKey.getBytes(), user.getBytes()); // * 防止删除没同步时数据库有记录 connection.zScore(delKey.getBytes(), user.getBytes()); } return null; } }); // * 如果在ALL set则已点赞,如果在DEL zset存在则已取消 Set<Long> likedBizIds = new HashSet<>(); Set<Long> secondCheckBizIds = new HashSet<>(); // * 一次两条命令返回结果 for (int i = 0; i < objects.size(); i += 2) { Boolean isLiked = (Boolean) objects.get(i); Double isDel = (Double) objects.get(i + 1); // * 确认已点赞 if (isLiked) { likedBizIds.add(bizIds.get(i)); continue; } // * 已经删除,跳过 if (isDel != null) { continue; } // * Redis结束,但点赞状态仍不确定,需要二次检查数据库 secondCheckBizIds.add(bizIds.get(i)); } // * 检查数据库 List<LikedRecord> records = lambdaQuery() .eq(LikedRecord::getUserId, userId) .eq(LikedRecord::getBizType, bizType) .in(LikedRecord::getBizId, secondCheckBizIds) .list(); // * 二次检查列表里不存在点赞过的 if (CollUtils.isEmpty(records)) { return likedBizIds; } // * 转id返回 Set<Long> likedBizIdDbSet = records.stream() .map(LikedRecord::getBizId) .collect(Collectors.toSet()); // * 写入Redis避免下次查数据库 redisTemplate.executePipelined(new RedisCallback<Object>() { @Override public Object doInRedis(RedisConnection connection) throws DataAccessException { for (Long bizId : likedBizIdDbSet) { String allKey = RedisConstants.LIKES_ALL_KEY_PREFIX + bizType + ":" + bizId; connection.sAdd(allKey.getBytes(), user.getBytes()); } return null; } }); // * 补充进结果集,返回最终点赞数据 likedBizIds.addAll(likedBizIdDbSet); return likedBizIds; }
Java
定时同步(限制单个Key的取出数据量与总共的取出数据量)



java
@Override public void syncLikeRecordsToDb(String bizType, int maxKeyScanSingle, int maxAllUpdate) { // * 构造Redis key匹配模板 String newKeyAsterisk = RedisConstants.LIKES_NEW_KEY_PREFIX + bizType + ":*"; String delKeyAsterisk = RedisConstants.LIKES_DEL_KEY_PREFIX + bizType + ":*"; // * 构造Redis Scan参数,限定单个更新队列Key数量上限 ScanOptions newScanOptions = ScanOptions.scanOptions() .match(newKeyAsterisk) .count(maxKeyScanSingle) .build(); ScanOptions delScanOptions = ScanOptions.scanOptions() .match(delKeyAsterisk) .count(maxKeyScanSingle) .build(); // * 构造bizId 为 key,userId全体为集合 作 value Map<Long, Set<ZSetOperations.TypedTuple<String>>> newRecords = new HashMap<>(); Map<Long, Set<ZSetOperations.TypedTuple<String>>> delRecords = new HashMap<>(); // * 取出新增数据 fetchRecordsFromRedisByScan(newScanOptions, newRecords, maxAllUpdate); // * 剩余更新配额至少还剩 1 / 8 int remaining = maxAllUpdate - newRecords.size(); // * 取出删除数据 if (remaining > maxAllUpdate / 8) { fetchRecordsFromRedisByScan(delScanOptions, delRecords, remaining); } // * 非空时操作 List<LikedRecord> recordsToUpdate = buildRecordsToUpdateList(bizType, newRecords); if (CollUtils.isNotEmpty(recordsToUpdate)) { try { saveBatch(recordsToUpdate); } catch (DataIntegrityViolationException e) { log.debug("重复插入数据"); } } // * 非空时操作 recordsToUpdate = buildRecordsToUpdateList(bizType, delRecords); if (CollUtils.isNotEmpty(recordsToUpdate)) { likedRecordMapper.batchDeleteByUniqueKey(recordsToUpdate); } } private List<LikedRecord> buildRecordsToUpdateList(String bizType, Map<Long, Set<ZSetOperations.TypedTuple<String>>> recordsMap) { List<LikedRecord> recordsToUpdate = new ArrayList<>(); if (CollUtils.isNotEmpty(recordsMap)) { for (Long bizId : recordsMap.keySet()) { Set<ZSetOperations.TypedTuple<String>> typedTuples = recordsMap.get(bizId); for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { String userIdString = tuple.getValue(); Long userId = Long.valueOf(userIdString); LikedRecord record = new LikedRecord(); record.setBizId(bizId) .setUserId(userId) .setBizType(bizType); recordsToUpdate.add(record); } } } return recordsToUpdate; } private void fetchRecordsFromRedisByScan(ScanOptions scanOptions, Map<Long, Set<ZSetOperations.TypedTuple<String>>> recordsMap, int remaining) { // * 打开Redis cursor 扫描各个key,取出总计不超过总更新配额的数据用于更新 // * 由于已经有了全局异常处理,这里不做异常处理 try (Cursor<byte[]> cursor = redisTemplate.executeWithStickyConnection(connection -> connection.scan(scanOptions))) { while (cursor != null && cursor.hasNext() && recordsMap.size() < remaining) { String key = new String(cursor.next()); String[] split = key.split(":"); Long bizId = Long.valueOf(split[split.length - 1]); int quota = remaining - recordsMap.size(); Set<ZSetOperations.TypedTuple<String>> records = redisTemplate.opsForZSet() .popMin(key, quota); if (CollUtils.isNotEmpty(records)) { Set<ZSetOperations.TypedTuple<String>> tupleSet = recordsMap.putIfAbsent(bizId, records); if (tupleSet != null) { tupleSet.addAll(records); } } } } }
Java
MP动态表名
插件顺序
- 根据官方文档的说明,需要确保插件的注册顺序。

- 修改tj-common下MybatisPlus配置类,先注册动态表名,再注册分页,最后自动填充是自定义拦截器,补全用户信息(如果遗漏)。
- 在传参的部分@Autowired自动注入,由于目前只有tj-learning一个服务用到,使用required = false参数,兼容其他微服务。
展开

java
package com.tianji.common.autoconfigure.mybatis; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @ConditionalOnClass({MybatisPlusInterceptor.class, BaseMapper.class}) public class MybatisConfig { /** * @see MyBatisAutoFillInterceptor 通过自定义拦截器来实现自动注入creater和updater * @deprecated 存在任务更新数据导致updater写入0或null的问题,暂时废弃 */ // @Bean // @ConditionalOnMissingBean public BaseMetaObjectHandler baseMetaObjectHandler() { return new BaseMetaObjectHandler(); } @Bean @ConditionalOnMissingBean public MybatisPlusInterceptor mybatisPlusInterceptor(@Autowired(required = false) DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor) { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // * 插件有要求顺序 // * 动态表名插件 if (dynamicTableNameInnerInterceptor != null) { interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor); } // * 分页插件 PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInnerInterceptor.setMaxLimit(200L); interceptor.addInnerInterceptor(paginationInnerInterceptor); // * 自动填充 interceptor.addInnerInterceptor(new MyBatisAutoFillInterceptor()); return interceptor; } }
Java
拦截器中获取数据
- 在tj-learning学习服务里,添加MybatisPlus动态表名拦截器配置类,对points_board积分榜单表注册对应表名处理函数,加入表名映射map,传参构造拦截器返回。
- 拦截器不能直接被调用,但是由于流程中一直是同一线程,所以可以通过ThreadLocal暂存和获取表名数据。

展开

java
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.utils.TableInfoContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.HashMap; /** * @author CamelliaV * @since 2024/11/21 / 19:18 */ @Configuration public class MybatisPlusConfiguration { @Bean public DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor() { // * key为需要替换的表明,value为对应的handler HashMap<String, TableNameHandler> tableMap = new HashMap<>(); tableMap.put("points_board", new TableNameHandler() { @Override public String dynamicTableName(String sql, String tableName) { return TableInfoContext.getInfo(); } }); return new DynamicTableNameInnerInterceptor(tableMap); } }
Java
海量数据存储
分区(Partition)
- 在数据库层面按照规则对表做水平拆分。
- 以InnoDB为例,一张表的数据在磁盘上对应一个ibd文件,如果表数据过多,就会导致文件体积非常大。文件就会跨越多个磁盘分区,数据检索时的速度就会非常慢。
- 按照某种规则,把表数据对应的ibd文件拆分成多个文件来存储。从物理上来看,一张表的数据被拆到多个表文件存储了;从逻辑上来看,他们对外表现是一张表。
- 逻辑操作不变,MySQL底层处理上有所变更。
优势
- 可存储超过单表上限的数据,也可存储到不同磁盘。
- 查询时可以按规则只检索一个文件,提高效率。
- 可多文件并行统计,提高效率。
- 可以删除分区文件,直接清除一部分数据,提高效率。
分区方式
- Range:按指定字段范围。
- List:按字段枚举,需提前指定所有可能值。
- Hash:字段hash后结果分区,数值类型。
- Key:字段值运算结果分区,不限定字段类型。
分表
- 在业务层面按照规则对表做水平拆分。
- 逻辑上与物理存储上都变成多张表,需要更改CRUD语句。
优势
- 拆分灵活,可水平,可垂直。
- 解决数据量大或字段多的问题。
劣势
- CRUD中需要添加判断访问哪张表。
- 聚合操作数据合并需要额外处理。
- 单表变多表带来的事务问题与数据关联问题。
集群与分库
- 微服务项目模块划分,每个微服务有独立的不同业务的数据库,进行了垂直分库。
- 对数据库做主从集群,主从数据同步,保证高可用,进行了水平扩展。
优势
- 突破单机存储瓶颈。
- 提高并发能力,突破单机性能瓶颈。
- 避免单点故障。
劣势
- 系统复杂度与成本高。
- 主从数据一致性问题。
- 分布式事务问题。
总结
- 单表数据多可以先库内分表,再分库。
- 读写压力大可以垂直分表,再读写分离集群。
- 索引→分表/ES→分库/集群
榜单持久化
Cron表达式详细


每月初创建历史榜单分表
展开


持久化上一赛季Redis榜单数据
展开


清理Redis中的历史榜单
展开

代码
代码
java
package com.tianji.learning.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.tianji.learning.domain.po.PointsBoard; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; /** * <p> * 学霸天梯榜 Mapper 接口 * </p> * * @author CamelliaV * @since 2024-11-21 */ public interface PointsBoardMapper extends BaseMapper<PointsBoard> { @Insert("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") void createPointsBoardTableBySeason(@Param("tableName") String tableName); }
Java
java
package com.tianji.learning.job; import com.tianji.common.utils.DateUtils; import com.tianji.learning.constants.LearningConstants; import com.tianji.learning.constants.RedisConstants; import com.tianji.learning.domain.po.PointsBoard; import com.tianji.learning.domain.po.PointsBoardSeason; import com.tianji.learning.service.IPointsBoardSeasonService; import com.tianji.learning.service.IPointsBoardService; import com.tianji.learning.utils.TableInfoContext; import com.xxl.job.core.context.XxlJobHelper; import com.xxl.job.core.handler.annotation.XxlJob; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Set; /** * @author CamelliaV * @since 2024/11/21 / 21:46 */ // @Component @RequiredArgsConstructor public class PointsBoardPersistHandler { private final IPointsBoardService boardService; private final IPointsBoardSeasonService seasonService; private final StringRedisTemplate redisTemplate; // * 拆分成多个XXLJOB然后以子任务关联执行(方便对其中一部分同步Redis数据到数据库做任务分片) @XxlJob("createHistoryBoardInDb") public void createHistoryBoardInDb() { // * 获取上月时间(每次同步上月排行榜数据) LocalDate time = LocalDate.now() .minusMonths(1); LocalDate beginDate = DateUtils.getMonthBegin(time); LocalDate endDate = beginDate.plusDays(beginDate.lengthOfMonth() - 1); // * 查询对应时间的赛季数据 PointsBoardSeason season = seasonService.lambdaQuery() .le(PointsBoardSeason::getEndTime, endDate) .ge(PointsBoardSeason::getBeginTime, beginDate) .one(); // * 无数据不同步 if (season == null) { return; } // * 获取id构造表名在数据库创建表 Integer seasonId = season.getId(); String tableName = LearningConstants.POINTS_BOARD_TABLE_PREFIX + seasonId; boardService.createPointsBoardTableBySeason(tableName); // * 存入ThreadLocal用于动态表名插件 TableInfoContext.setInfo(tableName); } @XxlJob("syncHistoryBoardToDb") public void syncHistoryBoardToDb() { // * 获取上月时间(每次同步上月排行榜数据) LocalDate time = LocalDate.now() .minusMonths(1); // * Redis查询上一赛季排行榜 String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + time.format(DateTimeFormatter.ofPattern("yyyyMM")); // * XXLJOB分片 int shardIndex = XxlJobHelper.getShardIndex(); int shardTotal = XxlJobHelper.getShardTotal(); int pageNo = shardIndex + 1; int pageSize = LearningConstants.SYNC_POINTS_BOARD_PAGE_SIZE; while (true) { List<PointsBoard> boardList = new ArrayList<>(); int start = (pageNo - 1) * pageSize; int end = start + pageSize - 1; Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet() .reverseRangeWithScores(key, start, end); // * Redis已无排行数据 if (typedTuples == null) { break; } // * 封装结果 int rank = start + 1; for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { String user = tuple.getValue(); Double points = tuple.getScore(); PointsBoard item = new PointsBoard(); // * 数据残缺 if (points == null || user == null) { continue; } // * rank作为id item.setId((long) rank); rank++; item.setPoints(points.intValue()); item.setUserId(Long.valueOf(user)); boardList.add(item); } pageNo += shardTotal; // * 持久化到数据库 boardService.saveBatch(boardList); } // * 移除ThreadLocal TableInfoContext.remove(); // * 存入XXL_JOB计数,防止多次调用unlink(可能导致删除在于同步数据前,致使数据丢失) redisTemplate.opsForValue() .increment(RedisConstants.SYNC_BOARD_XXL_JOB_TIMES); redisTemplate.opsForValue() .set(RedisConstants.SYNC_BOARD_XXL_SHARD_TOTAL, String.valueOf(shardTotal)); } @XxlJob("removeHistoryBoardInRedis") public void removeHistoryBoardInRedis() { String timesString = redisTemplate.opsForValue() .get(RedisConstants.SYNC_BOARD_XXL_JOB_TIMES); String totalString = redisTemplate.opsForValue() .get(RedisConstants.SYNC_BOARD_XXL_SHARD_TOTAL); if (timesString != null && timesString.equals(totalString)) { LocalDate time = LocalDate.now() .minusMonths(1); String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + time.format(DateTimeFormatter.ofPattern("yyyyMM")); // * 异步清除Redis数据 redisTemplate.unlink(key); redisTemplate.delete(RedisConstants.SYNC_BOARD_XXL_JOB_TIMES); redisTemplate.delete(RedisConstants.SYNC_BOARD_XXL_SHARD_TOTAL); } } }
Java
- 注意清理的XXL-JOB分片任务只应有一次业务执行。
积分记录的定时分表方案
- 重命名方案:简单快捷
- 延时删除:保证服务从始至终一直可用
流程图

重命名方案
展开

java
/** * 分表存档积分记录数据简易版(冷启动下测试百万数据总和不超过1s) * tj_learning> ALTER TABLE points_record RENAME points_record_xx * [2024-11-23 13:42:09] completed in 601 ms * tj_learning> CREATE TABLE points_record LIKE points_record_xx * [2024-11-23 13:42:09] completed in 357 ms */ public void pointsRecordArchiveSimple() { // * 上一赛季时间 LocalDate time = LocalDate.now() .minusMonths(1); PointsBoardSeason season = seasonService.lambdaQuery() .ge(PointsBoardSeason::getEndTime, time) .le(PointsBoardSeason::getBeginTime, time) .one(); if (season == null) { return; } String seasonId = String.valueOf(season.getId()); // * 重命名原表为分片表,再用LIKE创建新表(采用原表名) getBaseMapper().renamePointsRecordTableToSharding(seasonId); getBaseMapper().copyPointsRecordShardingTableDefinition(seasonId); }
Java

java
// * 重命名方案 @Update("RENAME TABLE tj_learning.points_record TO tj_learning.points_record_${seasonId}") void renamePointsRecordTableToSharding(@Param("seasonId") String seasonId); @Insert("CREATE TABLE IF NOT EXISTS tj_learning.points_record LIKE tj_learning.points_record_${seasonId}") void copyPointsRecordShardingTableDefinition(@Param("seasonId") String seasonId);
Java
延迟任务方案
展开


java
/** * 分表存档积分记录数据(实现,不采用) */ @Override @Transactional public void pointsRecordArchive() { LocalDate time = LocalDate.now() .minusMonths(1); PointsBoardSeason season = seasonService.lambdaQuery() .ge(PointsBoardSeason::getEndTime, time) .le(PointsBoardSeason::getBeginTime, time) .one(); if (season == null) { return; } // * 传入分表key赛季id String seasonId = String.valueOf(season.getId()); // * 创建分表 getBaseMapper().createPointsRecordShardingTable(seasonId); // * 复制数据插入,只要读锁 Integer inserted = getBaseMapper().insertAllToShardingTable(seasonId); if (inserted == null) { throw new DbException("复制插入points_record记录分表失败"); } // * 查询删除id范围 List<Long> results = getBaseMapper().queryMaxMinId(); if (CollUtils.isEmpty(results) || results.size() != POINTS_RECORD_SHARDING_RESULT_NUM) { throw new DbException("数据库points_record数据获取id范围失败"); } // * 获取待删除id范围 Long max_id = results.get(MAX_ID_INDEX); Long min_id = results.get(MIN_ID_INDEX); if (max_id == null || min_id == null) { throw new DbException("数据库points_record分表操作待删除范围获取失败"); } // * 添加所有任务到延时队列 // * 每给定一段时间(常量定义)范围获取任务结果删除(指定不变id范围内固定limit删除) for (long id = min_id; id <= max_id; id += LearningConstants.SHARDING_POINTS_RECORD_DELETE_LIMIT) { delayTaskHandler.addPointsRecordShardingTask(min_id, max_id); } }
Java

java
/** * 范围删除积分记录(延时任务用) */ @Override public void deletePointsRecordWithRange(Long minId, Long maxId, int limit) { getBaseMapper().deletePointsRecordWithRange(minId, maxId, limit); }
Java

java
// * 积分记录月初分表存档相关 // * 分段删除方案 @Insert("CREATE TABLE IF NOT EXISTS tj_learning.points_record_${seasonId} LIKE tj_learning.points_record") void createPointsRecordShardingTable(@Param("seasonId") String seasonId); @Insert("INSERT INTO tj_learning.points_record_${seasonId} SELECT * FROM tj_learning.points_record") Integer insertAllToShardingTable(@Param("seasonId") String seasonId); @Select("SELECT MAX(id), MIN(id) FROM tj_learning.points_record") List<Long> queryMaxMinId(); @Delete("DELETE FROM tj_learning.points_record WHERE id >= #{minId} AND id <= #{maxId} LIMIT #{limit}") Integer deletePointsRecordWithRange(@Param("minId") Long minId, @Param("maxId") Long maxId, @Param("limit") int limit);
Java


java
package com.tianji.learning.task; import com.tianji.learning.constants.LearningConstants; import com.tianji.learning.service.IPointsRecordService; import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.time.Duration; import java.util.concurrent.CompletableFuture; import java.util.concurrent.DelayQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @Slf4j @RequiredArgsConstructor @Component public class PointsRecordDelayTaskHandler { // * 实际核心数,非逻辑处理器数 // private final static int CPU_ACTUAL_CORES = 8; private final static int CPU_ACTUAL_CORES = 1; private static volatile boolean begin = true; private final DelayQueue<DelayTask<RecordTaskData>> queue = new DelayQueue<>(); @Autowired private IPointsRecordService pointsRecordService; @PostConstruct public void init() { ExecutorService threadPool = Executors.newFixedThreadPool(CPU_ACTUAL_CORES); CompletableFuture.runAsync(this::handleDelayTask, threadPool); } @PreDestroy public void destroy() { log.debug("关闭积分记录处理的延迟任务"); begin = false; } private void handleDelayTask() { while (begin) { try { // 1.尝试获取任务 DelayTask<RecordTaskData> task = queue.take(); RecordTaskData data = task.getData(); pointsRecordService.deletePointsRecordWithRange(data.getMinId(), data.getMaxId(), LearningConstants.SHARDING_POINTS_RECORD_DELETE_LIMIT); } catch (Exception e) { log.error("处理分片积分记录延迟任务发生异常", e); } } } public void addPointsRecordShardingTask(Long minId, Long maxId) { queue.add(new DelayTask<>(new RecordTaskData(minId, maxId), Duration.ofSeconds(LearningConstants.SHARDING_POINTS_RECORD_DELETE_DELAY))); } @Data @AllArgsConstructor private static class RecordTaskData { private Long minId; private Long maxId; } }
Java
Bitmaps类型使用
常用命令
- 主用setbit和bitfield,相关使用(Spring Data Redis)参见代码模板。

业务相关
- 获取本月至今的签到详情:BITFIELD key GET u[dayOfMonth] 0
- 获取连续签到天数:通过从最后一次签到向前统计,直至第一次未签到,计算总的签到次数,代码实现上从后向前遍历每个bit:循环&1,得最后一bit,循环无符号>>>右移动1位,重复进行。
Zset类型使用
常用命令

业务场景
- 实时排行榜
代码模板
Spring Data Redis Pipeline模板

java
// * 批量查询对应业务id项用户点赞记录 List<Object> objects = redisTemplate.executePipelined(new RedisCallback<Object>() { @Override public Object doInRedis(RedisConnection connection) throws DataAccessException { for (Long bizId : bizIds) { String allKey = RedisConstants.LIKES_ALL_KEY_PREFIX + bizType + ":" + bizId; String delKey = RedisConstants.LIKES_DEL_KEY_PREFIX + bizType + ":" + bizId; // * Redis中是否有 connection.sIsMember(allKey.getBytes(), user.getBytes()); // * 防止删除没同步时数据库有记录 connection.zScore(delKey.getBytes(), user.getBytes()); } return null; } });
Java
Spring Data Redis BitMap操作模板



java
package com.tianji.learning.service.impl; import com.tianji.common.autoconfigure.mq.RabbitMqHelper; import com.tianji.common.constants.MqConstants; import com.tianji.common.exceptions.BizIllegalException; import com.tianji.common.utils.BooleanUtils; import com.tianji.common.utils.CollUtils; import com.tianji.common.utils.UserContext; import com.tianji.learning.constants.RedisConstants; import com.tianji.learning.domain.vo.SignResultVO; import com.tianji.learning.mq.message.PointsMessage; import com.tianji.learning.service.ISignRecordService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.connection.BitFieldSubCommands; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; /** * @author CamelliaV * @since 2024/11/18 / 15:48 */ @Service @RequiredArgsConstructor public class ISignRecordServiceImpl implements ISignRecordService { private final StringRedisTemplate redisTemplate; private final RabbitMqHelper mqHelper; /** * 签到 */ @Override public SignResultVO addSignRecord() { // * 使用bitmap实现签到,拼接用户id与年月作key,日计算offset,存入bitmap Long userId = UserContext.getUser(); LocalDate now = LocalDate.now(); String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId + ":" + now.format(DateTimeFormatter.ofPattern("yyyyMM")); int offset = now.getDayOfMonth() - 1; // * bitmap属于字符串 // * setBit返回之前的bit值 Boolean result = redisTemplate.opsForValue() .setBit(key, offset, true); if (BooleanUtils.isTrue(result)) { throw new BizIllegalException("不能重复签到"); } // * 统计连续签到天数 // * 获取到当天的本月签到详情 // * BITFIELD key GET u[dayOfMonth] 0 int signDays = 0; List<Long> results = redisTemplate.opsForValue() .bitField(key, BitFieldSubCommands.create() .get(BitFieldSubCommands.BitFieldType.unsigned(offset + 1)) .valueAt(0)); // * 返回为10进制数据,转二进制&处理 // * 计算连续签到天数 if (CollUtils.isNotEmpty(results)) { int num = results.get(0) .intValue(); while ((num & 1) == 1) { signDays++; num >>>= 1; } } // * 封装vo // * 填充连续签到天数与奖励积分 SignResultVO vo = new SignResultVO(); vo.setSignDays(signDays); int rewardPoints = 0; if (signDays == 7) { rewardPoints = 10; } else if (signDays == 14) { rewardPoints = 20; } else if (signDays == 28) { rewardPoints = 40; } vo.setRewardPoints(rewardPoints); // * 积分推送至mq mqHelper.send(MqConstants.Exchange.LEARNING_EXCHANGE, MqConstants.Key.SIGN_IN, PointsMessage.of(userId, vo.totalPoints())); return vo; } /** * 查询签到记录 */ @Override public Byte[] querySignRecords() { // * bitField查询签到详情 Long userId = UserContext.getUser(); LocalDate now = LocalDate.now(); int dayOfMonth = now.getDayOfMonth(); String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId + ":" + now.format(DateTimeFormatter.ofPattern("yyyyMM")); List<Long> results = redisTemplate.opsForValue() .bitField(key, BitFieldSubCommands.create() .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)) .valueAt(0)); if (CollUtils.isEmpty(results)) { return new Byte[0]; } int num = results.get(0) .intValue(); // * 从最末尾(日期最大)开始填充 Byte[] bytes = new Byte[dayOfMonth]; int pos = dayOfMonth; while (--pos >= 0) { bytes[pos] = (byte) (num & 1); num >>>= 1; } return bytes; } }
Java
Mybatis注解中多值SQL模板

java
/** * <p> * 点赞记录表 Mapper 接口 * </p> * * @author CamelliaV * @since 2024-11-13 */ public interface LikedRecordMapper extends BaseMapper<LikedRecord> { @Delete("<script>" + "DELETE FROM tj_remark.liked_record WHERE (biz_id, user_id, biz_type) IN " + "<foreach collection='records' item='record' open='(' separator=',' close=')'>" + "(#{record.bizId}, #{record.userId}, #{record.bizType})" + "</foreach>" + "</script>") int batchDeleteByUniqueKey(@Param("records") List<LikedRecord> recordsToUpdate); }
Java
其他注意事项
消息队列传递数据的类型
- 消息队列API需要生产者和消费者数据类型一致,否则可能出现消息失败,送到error去重试。当时传递数据写成了Integer,类型错误导致消息错误。
案发现场
java
// * 首次完成,提交mq奖励积分 mqHelper.send(MqConstants.Exchange.LEARNING_EXCHANGE, MqConstants.Key.LEARN_SECTION, PointsMessage.of(userId, PointsRecordType.LEARNING.getRewardPoints()));
Java
- 😅因为API里不限定消息类型,这里消息失败后断点调试问题又多发了几次,消息一直重试给电脑整蓝屏了,强制重启之后虚拟机MySQL Docker容器开始无限重启(启动就是restarting状态),整的数据库用不了,直接重装虚拟机了。
- 善用VMware快照,多备份虚拟机状态用于回退。

数据库查询返回可为null值的类型
- MySQL SUM函数在不满足条件的情况下返回不为0,为null,需要用Integer包装类接收结果。


- 采用int接收出现了数据库操作正常(日志正常打印)下的业务失败。
案发现场
java
@Select("SELECT SUM(points) FROM tj_learning.points_record WHERE user_id = #{userId} AND type = #{type} AND create_time >= #{start} AND create_time <= #{end}") Integer queryUserTodayPoints(@Param("userId") Long userId, @Param("type") PointsRecordType type, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
Java
注:上方枚举类型传递mapper中,放入sql语句自动转为value。
修改points_board表中类型
- 在Redis查询到以前赛季中有的数据实际上已经达到200以上,但数据库对应表的points_board在积分值字段仍采用的tinyint,考虑到之前的积分值一天有几十分的上限设置,十分不合理,将其更改为int类型,而且本身在PO类代码中映射也是Integer。
点击查看points_board定义图

点击查看Redis旧赛季数据

点击查看points_board PO定义代码
java
package com.tianji.learning.domain.po; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; import java.io.Serializable; /** * <p> * 学霸天梯榜 * </p> * * @author CamelliaV * @since 2024-11-21 */ @Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("points_board") public class PointsBoard implements Serializable { private static final long serialVersionUID = 1L; /** * 榜单id */ @TableId(value = "id", type = IdType.INPUT) private Long id; /** * 学生id */ private Long userId; /** * 积分值 */ private Integer points; /** * 名次,只记录赛季前100 */ @TableField(exist = false) private Integer rank; /** * 赛季id */ @TableField(exist = false) private Integer season; }
Java
测试数据生成
- 原季度数据只到2023年9月,测试同步上一赛季的数据时业务需要获取查询季度id,此处添加测试数据至2030年。
展开

java
package com.tianji.learning; import com.tianji.common.utils.DateUtils; import com.tianji.learning.domain.po.PointsBoardSeason; import com.tianji.learning.service.IPointsBoardSeasonService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; /** * @author CamelliaV * @since 2024/11/22 / 12:38 */ @SpringBootTest(classes = LearningApplication.class) @Slf4j public class PointsBoardSeasonAddTillNow { @Autowired private IPointsBoardSeasonService seasonService; @Test public void addAllSeasonTillNow() { List<PointsBoardSeason> seasons = seasonService.list(); if (seasons == null) { log.info("数据库没有赛季数据"); return; } PointsBoardSeason lastSeason = seasons.get(seasons.size() - 1); LocalDate begin = lastSeason.getBeginTime(); Integer seasonId = lastSeason.getId(); LocalDate target = LocalDate.of(2030, 9, 10); LocalDate targetBegin = DateUtils.getMonthBegin(target); List<PointsBoardSeason> newSeasonList = new ArrayList<>(); while (begin.isBefore(targetBegin)) { PointsBoardSeason newSeason = new PointsBoardSeason(); begin = begin.plusMonths(1); LocalDate end = begin.plusDays(begin.lengthOfMonth() - 1); newSeason.setName("第" + (++seasonId) + "赛季"); newSeason.setBeginTime(begin); newSeason.setEndTime(end); newSeasonList.add(newSeason); } seasonService.saveBatch(newSeasonList); } }
Java
- 测试重命名方案实现积分记录分表存档的开销时间是否可以接受,插入一百万随机数据。
展开

java
package com.tianji.learning; import com.tianji.learning.domain.po.PointsRecord; import com.tianji.learning.enums.PointsRecordType; import com.tianji.learning.service.IPointsRecordService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.ArrayList; import java.util.List; /** * @author CamelliaV * @since 2024/11/23 / 0:36 */ @SpringBootTest(classes = LearningApplication.class) @Slf4j public class PointsRecordAddMillionData { // * 必须Autowired @Autowired private IPointsRecordService pointsRecordService; @Test public void addMillionPointsRecord() { // * 表应该为int积分类型 for (int i = 0; i < 1_000_000; i += 1000) { List<PointsRecord> recordList = new ArrayList<>(); for (int j = 0; j < 1000; j++) { long userId = (int) (Math.random() * 10001); int type = (int) (Math.random() * 5) + 1; PointsRecordType recordType = PointsRecordType.of(type); int points = (int) (Math.random() * 1145140721) + 1; PointsRecord record = new PointsRecord(); record.setPoints(points); record.setType(recordType); record.setUserId(userId); recordList.add(record); } pointsRecordService.saveBatch(recordList); } } }
Java
Mybatis DDL语句使用事项
- Mybatis注解里一次性写多条DDL会失效。
- 个人理解Mybatis一个语句应该是在一次事务提交里面,但是DDL需要每次直接提交,所以放一起多条会不生效,日志反馈就是没有第三行的返回值(单个执行就有)。
- 总结下来DDL单句一个函数,然后DDL和DML分开不同的函数写(参照上方写法)。

Day06练习参考实现
完善互动问答功能
- 前文复盘已经实现
点赞业务类型的动态配置
展开
在Nacos自行新添配置,后缀.yml,.yaml,或者没有也可以

参考版本

在bootstrap文件里添加配置

可以添加到常量里使用

用${}获取配置,:为设置默认值,注意层级与文件内容保持一致

点赞记录持久化
- 参见上方对应标题部分
XXL-JOB定时任务
- 可以在Day08 20p视频里快速入门,以下提供参考实现
展开
Java代码部分

java
/** * @author CamelliaV * @since 2024/11/15 / 20:49 */ @Component @RequiredArgsConstructor public class LikeRecordsSaveDbTask { // * 读取常量服务名 private static final List<String> BIZ_TYPES = List.of(Constant.CONFIG_BIZTYPE_QA); private static final int MAX_ALL_UPDATE = 5000; private static final int MAX_KEY_SCAN_SINGLE = 30; private final ILikedRecordService likeService; // * SpringTask方案 // @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS) // public void syncLikeRecordsToDb() { // for (String bizType : BIZ_TYPES) { // likeService.syncLikeRecordsToDb(bizType, MAX_KEY_SCAN_SINGLE, MAX_ALL_UPDATE); // } // } @XxlJob("syncLikeRecordsToDb") public void syncLikeRecordsToDb() { for (String bizType : BIZ_TYPES) { likeService.syncLikeRecordsToDb(bizType, MAX_KEY_SCAN_SINGLE, MAX_ALL_UPDATE); } } }
Java

java
/** * @author CamelliaV * @since 2024/11/14 / 13:30 */ @Component @RequiredArgsConstructor public class LikeTimesCheckTask { private static final List<String> BIZ_TYPES = List.of(Constant.CONFIG_BIZTYPE_QA); private static final int MAX_BIZ_SIZE = 30; private final ILikedRecordService likeService; // @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS) @XxlJob("readLikeTimesAndSendMq") public void readLikeTimesAndSendMq() { for (String bizType : BIZ_TYPES) { likeService.readLikeTimesAndSendMq(bizType, MAX_BIZ_SIZE); } } }
Java
XXL-JOB控制台


Day07练习参考实现
查询签到记录
展开

java
/** * 查询签到记录 */ @Override public Byte[] querySignRecords() { // * bitField查询签到详情 Long userId = UserContext.getUser(); LocalDate now = LocalDate.now(); int dayOfMonth = now.getDayOfMonth(); String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId + ":" + now.format(DateTimeFormatter.ofPattern("yyyyMM")); List<Long> results = redisTemplate.opsForValue() .bitField(key, BitFieldSubCommands.create() .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)) .valueAt(0)); if (CollUtils.isEmpty(results)) { return new Byte[0]; } int num = results.get(0) .intValue(); // * 从最末尾(日期最大)开始填充 Byte[] bytes = new Byte[dayOfMonth]; int pos = dayOfMonth; while (--pos >= 0) { bytes[pos] = (byte) (num & 1); num >>>= 1; } return bytes; }
Java
完善积分功能
展开


查询赛季列表功能
展开

java
/** * <p> * 服务实现类 * </p> * * @author CamelliaV * @since 2024-11-18 */ @Service public class PointsBoardSeasonServiceImpl extends ServiceImpl<PointsBoardSeasonMapper, PointsBoardSeason> implements IPointsBoardSeasonService { /** * 赛季列表查询 */ @Override public List<PointsBoardSeasonVO> queryPointsBoardSeasons() { LocalDate now = LocalDate.now(); // * 切为本月第一天 LocalDate monthBegin = DateUtils.getMonthBegin(now); // * 查询所有第一天小于本月第一天的,即所有过往赛季 List<PointsBoardSeason> records = lambdaQuery() .le(PointsBoardSeason::getBeginTime, monthBegin) .list(); if (CollUtils.isEmpty(records)) { return CollUtils.emptyList(); } // * 封装返回 return BeanUtils.copyToList(records, PointsBoardSeasonVO.class); } }
Java
Day08练习参考实现
查询积分榜
展开




java
/** * 根据赛季id查询用户数据与榜单数据 */ @Override public PointsBoardVO queryPointsBoardBySeason(PointsBoardQuery query) { Long seasonId = query.getSeason(); Long userId = UserContext.getUser(); String userIdString = userId.toString(); LocalDate now = LocalDate.now(); PointsBoardVO vo = new PointsBoardVO(); List<PointsBoard> boardList = new ArrayList<>(); String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + now.format(DateTimeFormatter.ofPattern("yyyyMM")); // * 无赛季id传参或为0,查询当前赛季 if (seasonId == null || seasonId == 0) { // * Redis查询当前用户积分与排名 Long userRank = redisTemplate.opsForZSet() .reverseRank(key, userIdString); Double userPoints = redisTemplate.opsForZSet() .score(key, userIdString); vo.setRank(userRank != null ? userRank.intValue() + 1 : 0); vo.setPoints(userPoints != null ? userPoints.intValue() : 0); // * Redis查询当前赛季排行榜 // * 校验过的分页字段 int pageNo = query.getPageNo(); int pageSize = query.getPageSize(); int start = (pageNo - 1) * pageSize; int end = start + pageSize - 1; Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet() .reverseRangeWithScores(key, start, end); // * Redis无排行数据 if (typedTuples == null) { return vo; } int rank = start + 1; // * 暂存结果 for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { String user = tuple.getValue(); Double points = tuple.getScore(); PointsBoard item = new PointsBoard(); // * 数据残缺 if (points == null || user == null) { continue; } item.setRank(rank++); item.setPoints(points.intValue()); item.setUserId(Long.valueOf(user)); boardList.add(item); } // * 构造userIdList查询用户信息 List<Long> userIdList = boardList.stream() .map(PointsBoard::getUserId) .collect(Collectors.toList()); // * 查询用户名并填补itemvo List<PointsBoardItemVO> itemVOList = new ArrayList<>(); if (CollUtils.isNotEmpty(userIdList)) { List<UserDTO> userDTOS = userClient.queryUserByIds(userIdList); if (CollUtils.isNotEmpty(userDTOS)) { Map<Long, String> userMap = userDTOS.stream() .collect(Collectors.toMap(UserDTO::getId, UserDTO::getUsername)); for (PointsBoard item : boardList) { PointsBoardItemVO itemVO = new PointsBoardItemVO(); itemVO.setPoints(item.getPoints()); itemVO.setRank(item.getRank()); String userName = userMap.get(item.getUserId()); if (userName != null) { itemVO.setName(userName); } itemVOList.add(itemVO); } } } // * 补全vo vo.setBoardList(itemVOList); } else { // * 查询历史赛季 // * 拼接历史赛季表名,放入ThreadLocal用于拦截器获取表名查询 String tableName = LearningConstants.POINTS_BOARD_TABLE_PREFIX + seasonId; TableInfoContext.setInfo(tableName); // * 查询用户自己的数据 PointsBoard userBoardItem = lambdaQuery() .eq(PointsBoard::getUserId, userId) .one(); // * 设置自己排行与积分 vo.setRank(userBoardItem != null ? userBoardItem.getId() .intValue() : 0); vo.setPoints(userBoardItem != null ? userBoardItem.getPoints() : 0); // * 分页查询数据库 Page<PointsBoard> boardPage = lambdaQuery() .page(query.toMpPage(new OrderItem("id", true))); List<PointsBoardItemVO> itemVOList = new ArrayList<>(); List<PointsBoard> records = boardPage.getRecords(); // * 数据库记录不为空 if (CollUtils.isNotEmpty(records)) { // * 构造userId列表查询用户名 List<Long> userIdList = records.stream() .map(PointsBoard::getUserId) .collect(Collectors.toList()); List<UserDTO> userDTOS = userClient.queryUserByIds(userIdList); Map<Long, String> userMap = new HashMap<>(); // * 封装映射关系到map if (CollUtils.isNotEmpty(userDTOS)) { userMap = userDTOS.stream() .collect(Collectors.toMap(UserDTO::getId, UserDTO::getUsername)); } // * 组装voList for (PointsBoard record : records) { PointsBoardItemVO itemVO = new PointsBoardItemVO(); itemVO.setRank(record.getId() .intValue()); itemVO.setPoints(record.getPoints()); String userName = userMap.get(record.getUserId()); if (userName != null) { itemVO.setName(userName); } itemVOList.add(itemVO); } } // * 组装结果vo vo.setBoardList(itemVOList); // * 删去TL TableInfoContext.remove(); } // * 返回vo return vo; }
Java
清理积分明细
- 见上方积分记录定时分表方案
了解更多
技术重点在三个方面:幂等性保障|延迟任务|合并写,除去这一部分,其他主要为基本业务,简单且量大,对初学友好,如果不是第一次做微服务项目,选看这三个方面就可以跳了。文中图片与代码块较多,善用展开懒加载。
- 作者:CamelliaV
- 链接:https://camelliav.netlify.app/article/tjxt-day06-08
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章