云计算百科
云计算领域专业知识百科平台

天机学堂——点赞功能

目录

1.原始的点赞业务

1.1页面原型

1.2点赞业务

1.2.1数据库表

1.2.2业务流程

1.2.3业务代码

1.3 原始方案的性能瓶颈

2.高并发优化思路:Redis + 合并写请求

2.1 优化方向分析

2.2修改之后的业务分析

2.2.1核心业务流程

2.2.2Redis 数据结构选型

2.3代码实现

2.3.1代码实现流程图

2.3.2核心接口改造(点赞 / 取消)

2.3.3Pipeline 优化批量查询性能

2.3.4 定时任务批量同步点赞数

3.进一步优化:定期持久化到数据库,采用LRU移除最近最少访问

3.1定义新的Redis结构用于实现LRU和持久化

3.1.1LRU 访问策略表

3.1.2业务类型映射表 (Hash)

3.2业务流程

3.2.1实时交互流程

3.2.2异步持久化流程

3.3代码实现


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

1.原始的点赞业务

1.1页面原型

当用户点击点赞按钮的时候,第一次点击是点赞,按钮会高亮;第二次点击是取消,点赞按钮变灰:

1.2点赞业务

1.2.1数据库表

表只有一张,很简单,就是谁给哪个业务点了赞的一个记录;点赞数就是统计这条数据有多少人点赞了。liked_record 用于记录点赞行为,通过 biz_type 区分不同业务、biz_id 关联具体业务 ID,结合用户 ID 做唯一索引防止重复点赞:

CREATE TABLE IF NOT EXISTS `liked_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
`user_id` bigint NOT NULL COMMENT '用户id',
`biz_id` bigint NOT NULL COMMENT '点赞的业务id',
`biz_type` VARCHAR(16) NOT NULL COMMENT '点赞的业务类型',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_biz_user` (`biz_id`,`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞记录表';

业务方(如互动问答)则在自身表中维护 liked_times 字段,记录点赞总数。

1.2.2业务流程

  • 点赞就新增一条点赞记录,取消点赞就删除记录

  • 用户不能重复点赞

  • 点赞数由具体的业务方保存,需要通知业务方更新点赞数

需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可。

在RabbitMQ中,利用TOPIC类型的交换机,结合不同的RoutingKey,可以实现通知对象的变化。我们需要让不同的业务方监听不同的RoutingKey,然后发送通知时根据点赞类型不同,发送不同RoutingKey:

1.2.3业务代码

①点赞相关代码

MQ常量

package com.tianji.common.constants;

public interface MqConstants {
interface Exchange{
/*点赞记录有关的交换机*/
String LIKE_RECORD_EXCHANGE = "like.record.topic";
}
interface Queue {
String ERROR_QUEUE_TEMPLATE = "error.{}.queue";
}
interface Key{
/*点赞的RoutingKey*/
String LIKED_TIMES_KEY_TEMPLATE = "{}.times.changed";
/*问答*/
String QA_LIKED_TIMES_KEY = "QA.times.changed";
/*笔记*/
String NOTE_LIKED_TIMES_KEY = "NOTE.times.changed";

}
}

DTO

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LikedTimesDTO {
/**
* 点赞的业务id
*/
private Long bizId;
/**
* 总的点赞次数
*/
private Integer likedTimes;
}

service完整业务代码

package com.tianji.remark.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.tianji.common.autoconfigure.mq.RabbitMqHelper;
import com.tianji.common.utils.StringUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.remark.domain.dto.LikeRecordFormDTO;
import com.tianji.remark.domain.po.LikedRecord;
import com.tianji.remark.mapper.LikedRecordMapper;
import com.tianji.remark.service.ILikedRecordService;
import lombok.RequiredArgsConstructor;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import static com.tianji.common.constants.MqConstants.Exchange.LIKE_RECORD_EXCHANGE;
import static com.tianji.common.constants.MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE;

/**
* <p>
* 点赞记录表 服务实现类
* </p>
*/
@Service
@RequiredArgsConstructor
public class LikedRecordServiceImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {

private final RabbitMqHelper mqHelper;

@Override
public void addLikeRecord(LikeRecordFormDTO recordDTO) {
// 1.基于前端的参数,判断是执行点赞还是取消点赞
boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);
// 2.判断是否执行成功,如果失败,则直接结束
if (!success) {
return;
}
// 3.如果执行成功,统计点赞总数
Integer likedTimes = lambdaQuery()
.eq(LikedRecord::getBizId, recordDTO.getBizId())
.count();
// 4.发送MQ通知
mqHelper.send(
LIKE_RECORD_EXCHANGE,
StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, recordDTO.getBizType()),
LikedTimesDTO.of(recordDTO.getBizId(), likedTimes));
}

private boolean unlike(LikeRecordFormDTO recordDTO) {
return remove(new QueryWrapper<LikedRecord>().lambda()
.eq(LikedRecord::getUserId, UserContext.getUser())
.eq(LikedRecord::getBizId, recordDTO.getBizId()));
}

private boolean like(LikeRecordFormDTO recordDTO) {
Long userId = UserContext.getUser();
// 1.查询点赞记录
Integer count = lambdaQuery()
.eq(LikedRecord::getUserId, userId)
.eq(LikedRecord::getBizId, recordDTO.getBizId())
.count();
// 2.判断是否存在,如果已经存在,直接结束
if (count > 0) {
return false;
}
// 3.如果不存在,直接新增
LikedRecord r = new LikedRecord();
r.setUserId(userId);
r.setBizId(recordDTO.getBizId());
r.setBizType(recordDTO.getBizType());
save(r);
return true;
}
}

②批量查询点赞状态(其实就相当于查询用户为哪些消息进行了点赞),供业务方查询当前用户对指定业务的点赞状态,返回点赞过的业务 ID 集合。

业务代码:

/**
* 判断业务是否被该用户点赞
*
* @param bizIds 业务id列表
* @return 业务是否被点赞
*/
@Override
public Set<Long> isBizLiked(List<Long> bizIds) {
// 1.获取登录用户id
Long userId = UserContext.getUser();
// 2.查询点赞状态
List<LikedRecord> list = lambdaQuery()
.in(LikedRecord::getBizId, bizIds)
.eq(LikedRecord::getUserId, userId)
.list();
// 3.返回结果
return list.stream().map(LikedRecord::getBizId).collect(Collectors.toSet());
}

1.3 原始方案的性能瓶颈

原始方案能满足功能需求,但在高并发场景下暴露出致命问题:

  • 数据库压力过大:每一次点赞 / 取消操作都涉及数据库的 insert/delete + count,高频操作会耗尽数据库连接池,响应延迟飙升;
  • 无效写操作多:用户反复点赞、取消点赞时,数据库会执行多次无效写,且每次都要发送 MQ 通知,浪费资源;
  • 网络交互成本高:批量查询点赞状态时,需多次查询数据库,网络往返次数多,性能低下。

2.高并发优化思路:Redis + 合并写请求

2.1 优化方向分析

针对高并发写场景,常见优化手段有三种:

  • 优化 SQL / 代码:基础操作,对高频写场景提升有限;
  • 同步改异步:通过 MQ 降低响应时间,但未减少总写库次数;
  • 合并写请求:将高频写操作缓存到 Redis,积累到一定量后批量写入数据库,从根源减少写库次数。
  • 点赞业务的核心特性是「最终结果有效,中间过程可忽略」—— 用户反复点赞 / 取消,最终只需要保留「是否点赞」和「最终点赞数」,完全适配「合并写请求」方案。因此我们选择「Redis 缓存 + 定时批量同步」的优化路径,兼顾高性能与数据一致性。

    2.2修改之后的业务分析

    2.2.1核心业务流程

    优化后的点赞流程核心是「先写缓存,后批量同步」:

    • 用户点赞 / 取消:仅操作 Redis Set,不直接写数据库;
    • 同步更新 Redis ZSet:记录业务 ID 与最新点赞数;
    • 定时任务:每隔 20 秒从 ZSet 中取出待同步数据,批量发送 MQ 通知业务方;
    • 业务方:监听 MQ 批量更新本地点赞数,完成最终持久化。

    这里给两幅图,一份黑马的,一份按我自己的理解画(其实感觉黑马的有点抽象)

    2.2.2Redis 数据结构选型

    基于点赞业务的两个核心数据(用户点赞记录、业务点赞数),我们选择两种 Redis 数据结构:

    数据类型数据结构Key 设计核心作用
    用户点赞记录 Set likes:set:biz:{bizId} 存储给某业务点赞的所有用户 ID,利用 Set 的唯一性防重复点赞,SCARD 快速统计总数
    待同步点赞数 ZSet likes:times:type:{bizType} 存储业务 ID 与最新点赞数,利用 ZSet 的 member 唯一性避免重复同步

    选型深层思考:

    • Set 存点赞记录:SADD/SREM 原子性操作保证点赞 / 取消的安全性,SISMEMBER 判断点赞状态,SCARD O (1) 时间复杂度统计总数,性能远超数据库 count;
    • ZSet 存待同步数:其实这里Hash和ZSet都可以,而且如果是从节省内存角度来考虑,Hash结构无疑是最佳的选择;但是考虑到将来我们要从Redis读取点赞数,然后移除(避免重复处理)。为了保证线程安全,查询、移除操作必须具备原子性。而SortedSet则提供了几个移除并获取的功能,天生具备原子性。并且我们每隔一段时间就会将数据从Redis移除,并不会占用太多内存。因此,这里我们计划使用SortedSet结构

    两种Redis结构示例如下:

    点赞记录(判断用户是否点赞)

    KEY(bizId)

    VALUE(userId)

    bizId:1

    userId:1

    userId:2

    userId:3

    点赞数量

    KEY(bizType)

    Member(bizId)

    Score(likedTimes)

    likes:qa

    bizId:1001

    10

    bizId:1002

    5

    likes:note

    bizId:2001

    9

    bizId:2002

    21

    2.3代码实现

    2.3.1代码实现流程图

    2.3.2核心接口改造(点赞 / 取消)

    替换原有的数据库操作,改为 Redis 操作,核心代码如下:

    @Service
    public class LikedRecordServiceRedisImpl implements ILikedRecordService {
    private final StringRedisTemplate redisTemplate;

    @Override
    public void addLikeRecord(LikeRecordFormDTO recordDTO) {
    // 1. 点赞/取消点赞(操作Redis Set)
    boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);
    if (!success) return;
    // 2. 统计Redis中点赞总数
    Long likedTimes = redisTemplate.opsForSet()
    .size(RedisConstants.LIKE_BIZ_KEY_PREFIX + recordDTO.getBizId());
    if (likedTimes == null) return;
    // 3. 更新ZSet待同步数据
    redisTemplate.opsForZSet().add(
    RedisConstants.LIKES_TIMES_KEY_PREFIX + recordDTO.getBizType(),
    recordDTO.getBizId().toString(),
    likedTimes
    );
    }

    // 点赞:SADD返回1则成功(未点赞过),0则失败(重复点赞)
    private boolean like(LikeRecordFormDTO recordDTO) {
    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + recordDTO.getBizId();
    Long result = redisTemplate.opsForSet().add(key, UserContext.getUser().toString());
    return result != null && result > 0;
    }

    // 取消点赞:SREM返回1则成功(已点赞),0则失败
    private boolean unlike(LikeRecordFormDTO recordDTO) {
    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + recordDTO.getBizId();
    Long result = redisTemplate.opsForSet().remove(key, UserContext.getUser().toString());
    return result != null && result > 0;
    }
    }

    2.3.3Pipeline 优化批量查询性能

    批量查询点赞状态时,若逐个调用 SISMEMBER,会产生多次 Redis 网络交互。我们使用 Redis Pipeline 将多个命令打包发送,一次网络往返完成所有查询:

    @Override
    public Set<Long> isBizLiked(List<Long> bizIds) {
    Long userId = UserContext.getUser();
    // Pipeline批量执行SISMEMBER
    List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    StringRedisConnection src = (StringRedisConnection) connection;
    for (Long bizId : bizIds) {
    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + bizId;
    src.sIsMember(key, userId.toString());
    }
    return null;
    });
    // 过滤出点赞过的业务ID
    return IntStream.range(0, results.size())
    .filter(i -> (boolean) results.get(i))
    .mapToObj(bizIds::get)
    .collect(Collectors.toSet());
    }

    2.3.4 定时任务批量同步点赞数

    使用 SpringTask 实现定时任务,每隔 20 秒从 ZSet 中取出待同步数据,批量发送 MQ:

    @Component
    public class LikedTimesCheckTask {
    // 支持的业务类型(可配置化)
    private static final List<String> BIZ_TYPES = List.of("QA", "NOTE");
    private static final int MAX_BIZ_SIZE = 30; // 单次最大同步数量

    @Scheduled(fixedDelay = 20000) // 20秒执行一次
    public void checkLikedTimes(){
    for (String bizType : BIZ_TYPES) {
    recordService.readLikedTimesAndSendMessage(bizType, MAX_BIZ_SIZE);
    }
    }
    }

    // 核心同步逻辑
    @Override
    public void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {
    String key = RedisConstants.LIKES_TIMES_KEY_PREFIX + bizType;
    // 从ZSet中取出并删除前N条数据(原子操作)
    Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().popMin(key, maxBizSize);
    if (CollUtils.isEmpty(tuples)) return;
    // 转换为MQ消息体
    List<LikedTimesDTO> list = tuples.stream()
    .filter(t -> t.getValue() != null && t.getScore() != null)
    .map(t -> LikedTimesDTO.of(Long.valueOf(t.getValue()), t.getScore().intValue()))
    .collect(Collectors.toList());
    // 批量发送MQ
    mqHelper.send(LIKE_RECORD_EXCHANGE,
    StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, bizType), list);
    }

    3.进一步优化:定期持久化到数据库,采用LRU移除最近最少访问

    3.1定义新的Redis结构用于实现LRU和持久化

    3.1.1LRU 访问策略表

    用于存储 每个业务最后一次被访问(点赞/查询)的时间戳 。Score 越小代表越久没访问(冷数据)。

    KEY(likes:access:strategy)MEMBER(bizId)SCORE(timestamp)说明
    likes:access:strategy 102 1735689600000 业务 102 最后访问于 08:00(最冷)
    101 1735691400000 业务 101 最后访问于 08:30
    103 1735693200000 业务 103 最后访问于 09:00(最热)

    3.1.2业务类型映射表 (Hash)

    用于存储 业务ID对应的业务类型 ,确保持久化时能知道每个 ID 是什么类型的业务。

    KEY(likes:biz:type)FIELD(bizId)VALUE(bizType)说明
    likes:biz:type 101 "QA" 业务 101 是问答类型
    102 "NOTE" 业务 102 是笔记类型
    103 "QA" 业务 103 是问答类型

    3.2业务流程

    3.2.1实时交互流程

    ① 点赞 / 取消点赞

    • – 操作 Redis 集合 : 用户请求到达后,直接在 Redis 的 Set 集合中添加 ( SADD ) 或移除 ( SREM ) 用户ID,保证极速响应。
    • – 更新 LRU 热度 : 将该业务ID 在 likes:access:strategy (ZSet) 中的分数更新为当前时间戳,标记为“最近活跃”。
    • – 记录元数据 : 在 likes:biz:type (Hash) 中记录该业务ID对应的业务类型,防止持久化时丢失类型信息。
    • – 统计总数 : 更新该业务类型的点赞排行榜数据。

    ②查询点赞状态

    • – 缓存检查 : 系统首先检查请求的业务ID是否在 Redis 中存在。
    • – 缓存回填 (Cache Warming) :
      •   – 如果 Redis 中 没有 数据(说明是冷数据或已被淘汰),则触发“回填机制”。
      •   – 系统自动去 MySQL 查询该业务的所有点赞记录,重建 Redis Set,并补全元数据。
    • – 刷新热度 : 无论数据来自缓存还是数据库,本次查询涉及的所有业务ID,其 LRU 时间戳都会被更新为最新时间,防止被立刻淘汰。
    • – 返回结果 : 最终统一从 Redis Set 中判断并返回状态。

    3.2.2异步持久化流程

    定时任务 (每 3分钟执行一次,这里根据实际情况进行调整)

    1. 容量检查 : 检查 LRU ZSet 记录的数量是否超过最大阈值 (如 10000)。 2. 筛选冷数据 : 如果超限,按分数从小到大 (时间从旧到新) 抓取最久未访问的一批业务ID。 3. 数据同步 (Diff & Sync) :    – 对于每个冷业务ID,取出 Redis 中的用户列表与 DB 中的用户列表。    – 计算差异 : 找出 Redis 中新增的用户 (需插入 DB) 和 Redis 中已移除的用户 (需从 DB 删除)。    – 批量落库 : 执行数据库的插入和删除操作,确保数据一致。 4. 内存释放 : 同步成功后,彻底删除 Redis 中的相关 Key (Set, Hash, ZSet),释放内存空间。

    3.3代码实现

    package com.tianji.remark.service.impl;

    import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
    import com.tianji.api.dto.remark.LikedTimesDTO;
    import com.tianji.common.autoconfigure.mq.RabbitMqHelper;
    import com.tianji.common.utils.CollUtils;
    import com.tianji.common.utils.StringUtils;
    import com.tianji.common.utils.UserContext;
    import com.tianji.remark.constants.RedisConstants;
    import com.tianji.remark.domain.dto.LikeRecordFormDTO;
    import com.tianji.remark.domain.po.LikedRecord;
    import com.tianji.remark.mapper.LikedRecordMapper;
    import com.tianji.remark.service.ILikedRecordService;
    import lombok.RequiredArgsConstructor;
    import org.springframework.data.redis.connection.StringRedisConnection;
    import org.springframework.data.redis.core.RedisCallback;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.data.redis.core.ZSetOperations;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;

    import java.util.*;
    import java.util.stream.Collectors;
    import java.util.stream.IntStream;

    import static com.tianji.common.constants.MqConstants.Exchange.LIKE_RECORD_EXCHANGE;
    import static com.tianji.common.constants.MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE;

    /**
    * <p>
    * 点赞记录表 服务实现类
    * </p>
    */
    @Service
    @RequiredArgsConstructor
    public class LikedRecordServiceRedisImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {

    private final RabbitMqHelper mqHelper;
    private final StringRedisTemplate redisTemplate;

    @Override
    public void addLikeRecord(LikeRecordFormDTO recordDTO) {
    // 1.基于前端的参数,判断是执行点赞还是取消点赞
    boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);
    // 2.判断是否执行成功,如果失败,则直接结束
    if (!success) {
    return;
    }

    // 更新访问时间(LRU) 和 业务类型映射
    String bizIdStr = recordDTO.getBizId().toString();
    redisTemplate.opsForZSet().add(RedisConstants.LIKES_ACCESS_STRATEGY_KEY, bizIdStr, System.currentTimeMillis());
    redisTemplate.opsForHash().put(RedisConstants.LIKES_BIZ_TYPE_MAP_KEY, bizIdStr, recordDTO.getBizType());

    // 3.如果执行成功,统计点赞总数
    Long likedTimes = redisTemplate.opsForSet()
    .size(RedisConstants.LIKE_BIZ_KEY_PREFIX + recordDTO.getBizId());
    if (likedTimes == null) {
    return;
    }
    // 4.缓存点总数到Redis
    redisTemplate.opsForZSet().add(
    RedisConstants.LIKES_TIMES_KEY_PREFIX + recordDTO.getBizType(),
    recordDTO.getBizId().toString(),
    likedTimes
    );
    }

    @Override
    public Set<Long> isBizLiked(List<Long> bizIds) {
    // 1.获取登录用户id
    Long userId = UserContext.getUser();

    // 2.检查Redis中是否存在 key,不存在则尝试从数据库加载
    List<Long> missingBizIds = new ArrayList<>();
    // 使用pipeline批量判断key是否存在
    List<Object> existsResults = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    StringRedisConnection src = (StringRedisConnection) connection;
    for (Long bizId : bizIds) {
    src.exists(RedisConstants.LIKE_BIZ_KEY_PREFIX + bizId);
    }
    return null;
    });

    for (int i = 0; i < bizIds.size(); i++) {
    Boolean exists = (Boolean) existsResults.get(i);
    if (exists == null || !exists) {
    missingBizIds.add(bizIds.get(i));
    }
    }

    if (!missingBizIds.isEmpty()) {
    loadBizLikesFromDb(missingBizIds);
    }

    // 3.更新LRU访问时间
    long now = System.currentTimeMillis();
    redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    StringRedisConnection src = (StringRedisConnection) connection;
    for (Long bizId : bizIds) {
    src.zAdd(RedisConstants.LIKES_ACCESS_STRATEGY_KEY, now, bizId.toString());
    }
    return null;
    });

    // 4.查询点赞状态
    List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    StringRedisConnection src = (StringRedisConnection) connection;
    for (Long bizId : bizIds) {
    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + bizId;
    src.sIsMember(key, userId.toString());
    }
    return null;
    });
    // 5.返回结果
    return IntStream.range(0, objects.size()) // 创建从0到集合size的流
    .filter(i -> (boolean) objects.get(i)) // 遍历每个元素,保留结果为true的角标i
    .mapToObj(bizIds::get)// 用角标i取bizIds中的对应数据,就是点赞过的id
    .collect(Collectors.toSet());// 收集
    }

    private void loadBizLikesFromDb(List<Long> bizIds) {
    // 查询DB
    List<LikedRecord> list = lambdaQuery().in(LikedRecord::getBizId, bizIds).list();
    if (CollUtils.isEmpty(list)) {
    return;
    }

    // 分组
    Map<Long, List<LikedRecord>> map = list.stream().collect(Collectors.groupingBy(LikedRecord::getBizId));

    // 写入Redis
    redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    StringRedisConnection src = (StringRedisConnection) connection;
    for (Map.Entry<Long, List<LikedRecord>> entry : map.entrySet()) {
    Long bizId = entry.getKey();
    List<LikedRecord> records = entry.getValue();
    if (CollUtils.isEmpty(records)) continue;

    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + bizId;
    String[] userIds = records.stream().map(r -> r.getUserId().toString()).toArray(String[]::new);
    src.sAdd(key, userIds);

    // 缓存业务类型
    String bizType = records.get(0).getBizType();
    src.hSet(RedisConstants.LIKES_BIZ_TYPE_MAP_KEY, bizId.toString(), bizType);
    }
    return null;
    });
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void persistOldRecords(int maxCapacity) {
    // 1.检查容量是否超标
    Long size = redisTemplate.opsForZSet().zCard(RedisConstants.LIKES_ACCESS_STRATEGY_KEY);
    if (size == null || size <= maxCapacity) {
    return;
    }
    long removeCount = size – maxCapacity;

    // 2.获取最旧的记录
    Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet()
    .rangeWithScores(RedisConstants.LIKES_ACCESS_STRATEGY_KEY, 0, removeCount – 1);

    if (CollUtils.isEmpty(tuples)) {
    return;
    }

    for (ZSetOperations.TypedTuple<String> tuple : tuples) {
    String bizIdStr = tuple.getValue();
    Long bizId = Long.valueOf(bizIdStr);

    // 3.获取Redis中的数据
    Set<String> userIdsInRedis = redisTemplate.opsForSet().members(RedisConstants.LIKE_BIZ_KEY_PREFIX + bizIdStr);
    if (userIdsInRedis == null) {
    userIdsInRedis = new HashSet<>();
    }

    // 4.获取DB中的数据
    List<LikedRecord> dbRecords = lambdaQuery().eq(LikedRecord::getBizId, bizId).list();
    Set<String> userIdsInDb = dbRecords.stream().map(r -> r.getUserId().toString()).collect(Collectors.toSet());

    // 5.计算差异
    Set<String> toAdd = new HashSet<>(userIdsInRedis);
    toAdd.removeAll(userIdsInDb);

    Set<String> toDelete = new HashSet<>(userIdsInDb);
    toDelete.removeAll(userIdsInRedis);

    // 6.同步到DB
    if (!toAdd.isEmpty()) {
    // 获取bizType
    Object bizTypeObj = redisTemplate.opsForHash().get(RedisConstants.LIKES_BIZ_TYPE_MAP_KEY, bizIdStr);
    String bizType = (bizTypeObj != null) ? bizTypeObj.toString() :
    (!dbRecords.isEmpty() ? dbRecords.get(0).getBizType() : "UNKNOWN");

    List<LikedRecord> newRecords = toAdd.stream().map(uid -> {
    LikedRecord r = new LikedRecord();
    r.setBizId(bizId);
    r.setUserId(Long.valueOf(uid));
    r.setBizType(bizType);
    return r;
    }).collect(Collectors.toList());
    saveBatch(newRecords);
    }

    if (!toDelete.isEmpty()) {
    List<Long> userIdsToDelete = toDelete.stream().map(Long::valueOf).collect(Collectors.toList());
    lambdaUpdate().eq(LikedRecord::getBizId, bizId).in(LikedRecord::getUserId, userIdsToDelete).remove();
    }

    // 7.清理Redis
    redisTemplate.delete(RedisConstants.LIKE_BIZ_KEY_PREFIX + bizIdStr);
    redisTemplate.opsForHash().delete(RedisConstants.LIKES_BIZ_TYPE_MAP_KEY, bizIdStr);
    redisTemplate.opsForZSet().remove(RedisConstants.LIKES_ACCESS_STRATEGY_KEY, bizIdStr);
    }
    }

    @Override
    public void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {
    // 1.读取并移除Redis中缓存的点赞总数
    String key = RedisConstants.LIKES_TIMES_KEY_PREFIX + bizType;
    Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().popMin(key, maxBizSize);
    if (CollUtils.isEmpty(tuples)) {
    return;
    }
    // 2.数据转换
    List<LikedTimesDTO> list = new ArrayList<>(tuples.size());
    for (ZSetOperations.TypedTuple<String> tuple : tuples) {
    String bizId = tuple.getValue();
    Double likedTimes = tuple.getScore();
    if (bizId == null || likedTimes == null) {
    continue;
    }
    list.add(new LikedTimesDTO(Long.valueOf(bizId), likedTimes.intValue()));
    }
    // 3.发送MQ消息
    mqHelper.send(
    LIKE_RECORD_EXCHANGE,
    StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, bizType),
    list);
    }

    private boolean unlike(LikeRecordFormDTO recordDTO) {
    // 1.获取用户id
    Long userId = UserContext.getUser();
    // 2.获取Key
    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + recordDTO.getBizId();
    // 3.执行SREM命令
    Long result = redisTemplate.opsForSet().remove(key, userId.toString());
    return result != null && result > 0;
    }

    private boolean like(LikeRecordFormDTO recordDTO) {
    // 1.获取用户id
    Long userId = UserContext.getUser();
    // 2.获取Key
    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + recordDTO.getBizId();
    // 3.执行SADD命令
    Long result = redisTemplate.opsForSet().add(key, userId.toString());
    return result != null && result > 0;
    }
    }
    package com.tianji.remark.constants;

    public interface RedisConstants {
    /*给业务点赞的用户集合的KEY前缀,后缀是业务id*/
    String LIKE_BIZ_KEY_PREFIX = "likes:set:biz:";
    /*业务点赞数统计的KEY前缀,后缀是业务类型*/
    String LIKES_TIMES_KEY_PREFIX = "likes:times:type:";
    /*业务点赞业务类型映射*/
    String LIKES_BIZ_TYPE_MAP_KEY = "likes:biz:type";
    /*业务点赞访问策略集合(用于LFU/LRU)*/
    String LIKES_ACCESS_STRATEGY_KEY = "likes:access:strategy";
    }
    package com.tianji.remark.task;

    import com.tianji.remark.service.ILikedRecordService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.cloud.context.config.annotation.RefreshScope;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;

    import java.util.List;

    @Slf4j
    @Component
    @RequiredArgsConstructor
    @RefreshScope // 支持Nacos动态刷新配置
    public class LikedTimesCheckTask {

    @Value("${tj.remark.task.biz-types:QA,NOTE}")
    private List<String> bizTypes;

    @Value("${tj.remark.task.max-biz-size:30}")
    private int maxBizSize;

    private final ILikedRecordService recordService;

    @Scheduled(fixedDelay = 2000)
    public void checkLikedTimes(){
    for (String bizType : bizTypes) {
    recordService.readLikedTimesAndSendMessage(bizType, maxBizSize);
    }
    }

    @Scheduled(fixedDelay = 10000)
    public void checkOldRecords(){
    int maxCapacity = 10000;
    recordService.persistOldRecords(maxCapacity);
    }
    }

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

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 天机学堂——点赞功能
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!