小蔡学Java

项目一总结:(六)点赞中台的实现

2024-01-23 22:36 1700 0 项目 Redis优化合并写请求Redis数据结构定时任务高并发持久化

前言

通用点赞系统需要满足下列特性:

  • 通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能
  • 独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性。
  • 并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发
  • 安全:要做好并发安全控制,避免重复点赞

实现思路

要保证安全,避免重复点赞,我们就必须 保存每一次点赞记录 。只有这样在下次用户点赞时我们 才能查询数据,判断是否是重复点赞 。同时,因为业务方经常需要根据点赞数量 排序 ,因此每个业务的 点赞数量也需要记录下来 。 综上,点赞的基本思路如下:

问题

点赞服务必须独立,因此必须 抽取为一个独立服务

如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段。但是点赞系统无法修改其它业务服务的数据库,否则就出现了 业务耦合 。该怎么办呢?

解决:点赞数变更时,通过 MQ通知业务方,避免了点赞系统与业务方的耦合

数据结构设计

一是 点赞记录 ,二是与 业务关联的点赞数

点赞数是与具体业务表关联在一起记录 例如回答或者评论:

其他亦是如此

点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:

  • 点赞目标id
  • 点赞人id 不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:
  • 点赞对象类型(为了通用性) 当然还有点赞时间,综上对应的数据库ER图如下:

设计出表结构如下

优化前的方案实现

  • 点赞就 新增 一条点赞记录,取消点赞就 删除 记录
  • 用户不能重复点赞
  • 点赞数由具体的业务方保存,需要 通知业务方更新点赞数

由于业务方的类型很多,比如互动问答笔记课程等。所以通知方式必须是低耦合的,这里建议使用MQ来实现。 当点赞或取消点赞后,点赞数发生变化,我们就发送MQ通知。整体业务流程如图:

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

利用TOPIC类型的交换机,结合不同的RoutingKey,不同的业务方监听不同的RoutingKey,互不干扰。

我在MqConstants定义了如下的用于点赞的常量

		/*点赞的RoutingKey*/
        String LIKED_TIMES_KEY_TEMPLATE = "{}.times.changed";
        /*问答*/
        String QA_LIKED_TIMES_KEY = "QA.times.changed";
        /*笔记*/
        String NOTE_LIKED_TIMES_KEY = "NOTE.times.changed";
		/*点赞记录有关的交换机*/
        String LIKE_RECORD_EXCHANGE = "like.record.topic";

优化前的代码实现

/**
 * <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;
    }
}
  • 点赞数变更的消息监听器
@Slf4j
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {

    private final IInteractionReplyService replyService;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "qa.liked.times.queue", durable = "true"),
            exchange = @Exchange(name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = QA_LIKED_TIMES_KEY
    ))
    public void listenReplyLikedTimesChange(LikedTimesDTO dto){
        log.debug("监听到回答或评论{}的点赞数变更:{}", dto.getBizId(), dto.getLikedTimes());
        InteractionReply r = new InteractionReply();
        r.setId(dto.getBizId());
        r.setLikedTimes(dto.getLikedTimes());
        replyService.updateById(r);
    }
}

老方案存在的问题

  • 点赞业务包含 多次数据库读写操作
  • 点赞操作波动较大,有可能会在短时间内访问量激增。例如 有人非常频繁的点赞、取消点赞。这样就会给数据库带来非常大的压力

改进思路

点赞业务与提交播放记录类似,都是高并发写操作

参考我们之前视频续播的功能实现:续播功能之合并写请求提高并发量 有如下三个手段

  • 优化SQL和代码
  • 变同步写为异步写
  • 合并写请求

虽然利用MQ异步写来实现点赞数的变更(仅仅是解耦),但是数据库的写次数没有减少,压力依然很大;我们可以借鉴之前视频续播的合并写吗?

合并写是有使用场景的,必须是对中间的N次写操作不敏感的情况下。点赞业务是否符合这一需求呢?

无论用户中间执行点赞、取消、再点赞、再取消多少次,业务方只关注最终的点赞次数结果,所以是符合的!

改善后如下:

合并写请求有两个关键点要考虑:

  • 数据如何缓存
  • 缓存何时写入数据库

分析

点赞数据缓存 赞记录中最两个关键信息:

  • 用户是否点赞
  • 某业务的点赞总次数

用户是否点赞

判断用户是否点赞,就是判断存在且唯一。显然,Set集合 是最合适的 业务id为Key set集合保存点赞过的用户ID

redis也提供了丰富的指令

# 判断用户是否点赞
SISMEMBER bizId userId
# 点赞,如果返回1则代表点赞成功,返回0则代表点赞失败
SADD bizId userId
# 取消点赞,就是删除一个元素
SREM bizId userId
# 统计点赞总数
SCARD bizId
  • 由于Redis本身具备持久化机制,AOF提供的数据可靠性已经能够满足点赞业务的安全需求,因此我们完全可以用Redis存储来代替数据库的点赞记录。
  • 也就是说,用户的一切点赞行为,以及将来查询点赞状态我们可以都走Redis,不再使用数据库查询

如果点赞数据非常庞大,达到数百亿,那么该怎办呢?

大多数企业根本达不到这样的规模,如果真的达到也没有关系。这个时候我们可以将Redis与数据库结合。

  • 先利用Redis来记录点赞状态
  • 并且 定期的将Redis中的点赞状态持久化到数据库
  • 对于历史点赞记录,比如下架的课程、或者超过2年以上的访问量较低的数据都可以从redis移除,只保留在数据库中
  • 当某个记录点赞时,优先去Redis查询并判断,如果Redis中不存在,再去查询数据库数据并缓存到Redis

某业务的点赞总次数

由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。 由于需要记录业务id业务类型点赞数三个信息:

  • 一个业务类型下包含多个业务id
  • 每个业务id对应一个点赞数

因此,我们可以把每一个业务类型作为一组,使用Redis的一个key,然后业务id作为键,点赞数作为值。这样的键值对集合,有两种结构都可以满足:

  • Hash:传统键值对集合,无序
  • SortedSet:基于Hash结构,并且增加了跳表。因此可排序,但更占用内存

从节省 内存角度 来考虑,Hash结构 无疑是最佳的选择 考虑到将来我们要从 Redis读取点赞数,然后移除(避免重复处理) 为了保证线程安全, 查询、移除操作必须具备原子性 。而SortedSet则提供了几个移除并获取的功能,天生具备原子性 并且我们 每隔一段时间就会将数据从Redis移除 ,并不会占用太多内存。因此,这里我们计划使用 SortedSet 结构。

优化后的代码实现

Redis的常量定义

public interface RedisConstants {
    String LIKES_BIZ_KEY_PREFIX = "likes:frowning:biz:";
    String LIKES_TIMES_KEY_PREFIX = "likes:frowning:type:";
}

点赞取消点赞的实现

@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;
        }
        // 3.如果执行成功,统计点赞总数
        Long likedTimes = redisTemplate.opsForSet()
                .size(RedisConstants.LIKES_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
        );
    }

    private boolean unlike(LikeRecordFormDTO recordDTO) {
        // 1.获取用户id
        Long userId = UserContext.getUser();
        // 2.获取Key
        String key = RedisConstants.LIKES_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.LIKES_BIZ_KEY_PREFIX + recordDTO.getBizId();
        // 3.执行SADD命令
        Long result = redisTemplate.opsForSet().add(key, userId.toString());
        return result != null && result > 0;
    }
}

批量判断用户对某一业务的点赞情况,可以看我之前发过的另外一篇文章:Redis 管道技术之Pipeline

@Override
public Set<Long> isBizLiked(List<Long> bizIds) {
    // 1.获取登录用户id
    Long userId = UserContext.getUser();
    // 2.查询点赞状态
    List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        StringRedisConnection src = (StringRedisConnection) connection;
        for (Long bizId : bizIds) {
            String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + bizId;
            src.sIsMember(key, userId.toString());
        }
        return null;
    });
    // 3.返回结果
    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());// 收集
}

入库问题

何时把缓存的点赞数,通过MQ通知到业务方,持久化到业务方的数据库呢?

点赞频率如何完全不确定。因此无法采用延迟检测这样的手段。怎么办?

解决方案:

  • 通过定时任务,定期将缓存的数据持久化到数据库中。

我们这里先使用SpringTask来进行点赞次数的持久化,稍后我们使用xxl-job来实现

启动类加上注解@EnableScheduling

定时任务

@Component
@RequiredArgsConstructor
public class LikedTimesCheckTask {

    private static final List<String> BIZ_TYPES = List.of("QA", "NOTE");
    private static final int MAX_BIZ_SIZE = 30;

    private final ILikedRecordService recordService;

    @Scheduled(fixedDelay = 20000)
    public void checkLikedTimes(){
        for (String bizType : BIZ_TYPES) {
            recordService.readLikedTimesAndSendMessage(bizType, MAX_BIZ_SIZE);
        }
    }
}

具体逻辑

@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(LikedTimesDTO.of(Long.valueOf(bizId), likedTimes.intValue()));
    }
    // 3.发送MQ消息
    mqHelper.send(
            LIKE_RECORD_EXCHANGE,
            StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, bizType),
            list);
}

监听点赞次数的变更并且入库

@Slf4j
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {

    private final IInteractionReplyService replyService;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "qa.liked.times.queue", durable = "true"),
            exchange = @Exchange(name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = QA_LIKED_TIMES_KEY
    ))
    public void listenReplyLikedTimesChange(List<LikedTimesDTO> likedTimesDTOs){
        log.debug("监听到回答或评论的点赞数变更");

        List<InteractionReply> list = new ArrayList<>(likedTimesDTOs.size());
        for (LikedTimesDTO dto : likedTimesDTOs) {
            InteractionReply r = new InteractionReply();
            r.setId(dto.getBizId());
            r.setLikedTimes(dto.getLikedTimes());
            list.add(r);
        }
        replyService.updateBatchById(list);
    }
}

总结(稍后回答)

那你能不能讲讲你们的点赞系统是如何设计的?

那你们Redis中具体使用了哪种数据结构呢?

那你ZSET干什么用的?

那为什么一定要用ZSET结构,把更新过的业务扔到一个List中不行吗?

评论( 0 )

  • 博主 Mr Cai
  • 坐标 河南 信阳
  • 标签 Java、SpringBoot、消息中间件、Web、Code爱好者

文章目录