侧边栏壁纸
博主头像
Haenu的Blog 博主等级

坚持学习,慢慢进步!

  • 累计撰写 35 篇文章
  • 累计创建 10 个标签
  • 累计收到 2 条评论

目 录CONTENT

文章目录

点赞系统

Haenu
2024-08-15 / 0 评论 / 0 点赞 / 81 阅读 / 0 字

点赞系统

image-maq3.png

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

第一版实现思路

如果避免重复点赞那么我们需要有一个点赞记录,因为只有这样下次我们点赞的时候才能查询到数据,判断是否重复点赞。同时,因为业务需要,可能根据点赞数量进行排序,所以我们还需要把点赞数量记录下来。

综上所述 点赞的基本思路如下:

image-7vl7.png

但是有一个问题 就是我们的点赞是单独服务, 那么问题来了,点赞的数量是由每个业务方来维护的,点赞系统作为一个低耦合的单独服务,无法修改其他业务服务的数据库,否则就造成了业务耦合

点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。

于是,实现思路变成了这样:

image-szw2.png

数据结构

点赞的数据结构分为两部分,第一部分是点赞记录, 第二部分是点赞数量。

点赞记录 包括 哪一个用户给哪个业务数据在哪个时间点了赞

还需要记录一下对象类型 为了通用性

image-ofhz.png

点赞或者取消点赞

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

从后台实现来看,点赞就是新增一条点赞记录,取消就是删除这条记录。为了方便前端交互,这两个合并为一个接口即可。

因此,请求参数首先要包含点赞有关的数据,并且要标记是点赞还是取消:

  • 点赞的目标业务id:bizId
  • 谁在点赞(就是登陆用户,可以不用提交)
  • 点赞还是取消

除此以外,我们之前说过,在很多功能中都会出现点赞功能,所以点赞必须具备通用性。因此还需要在提交一个参数标记点赞的类型:

  • 点赞目标的类型

返回值有两种设计:

  • 方案一:无返回值,200就是成功,页面直接把点赞数+1展示给用户即可
  • 方案二:返回点赞数量,页面渲染

这里推荐使用方案一,因为每次统计点赞数量也有很大的性能消耗。

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

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

image-xaph.png

第二版实现思路

虽然第一版实现了点赞功能,不过有一个非常严重的问题那就是多次数据库的读写操作

image-dzeo.png

按照之前我们讲的,高并发写操作常见的优化手段有:

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

虽然异步写减少了业务执行时间,降低了数据库写频率。不过此处更重要的是利用MQ来解耦。而且数据库的写次数没有减少,压力依然很大。

所以,我们应该像之前播放记录业务一样,采用合并写请求的方案。当然,现在的异步处理也保留,这样就兼顾了异步写合并写的优势。

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

无论用户中间执行点赞、取消、再点赞、再取消多少次,点赞次数发生了多少次变化,业务方只关注最终的点赞结果即可:

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

因此,点赞功能可以使用合并写方案。最终我们的点赞业务流程变成这样:

image-1ou3.png

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

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

点赞数据的缓存

点赞记录中最两个关键信息:

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

用户是否点赞

要知道某个用户是否点赞某个业务,就必须记录业务id以及给业务点赞的所有用户id . 由于一个业务可以被很多用户点赞,显然是需要一个集合来记录。而Redis中的集合类型包含四种:

  • List
  • Set
  • SortedSet
  • Hash

而要判断用户是否点赞,就是判断存在且唯一。显然,Set集合是最合适的。我们可以用业务id为Key,创建Set集合,将点赞的所有用户保存其中,格式如下:

KEY(bizId) VALUE(userId)
bizId:1 userId:1
bizId:1 userId:2
bizId:1 userId:3

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

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

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

点赞次数

由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。

由于需要记录业务id、业务类型、点赞数三个信息:

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

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

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

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

格式如下:

image-xjri.png

当用户对某个业务点赞时,我们统计点赞总数,并将其缓存在Redis中。这样一来在一段时间内,不管有多少用户对该业务点赞(热点业务数据,比如某个微博大V),都只在Redis中修改点赞总数,无需修改数据库。

点赞次数入库

image-0zot.png

@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;
    }
}

批量查询点赞状态统计

KEY(bizId) VALUE(userId)
bizId:1 userId:1
bizId:1 userId:2
bizId:1 userId:3

当我们判断某用户是否点赞时,需要使用下面命令:

# 判断用户是否点赞
SISMEMBER bizId userId

需要注意的是,这个命令只能判断一个用户对某一个业务的点赞状态。而我们的接口是要查询当前用户对多个业务的点赞状态。

因此,我们就需要多次调用 SISMEMBER命令,也就需要向Redis多次发起网络请求,给网络带宽带来非常大的压力,影响业务性能。

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());// 收集
}


定时任务

@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);
    }

监听点赞数变更

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来保存点赞记录、点赞数量信息,并且基于Redis的持久化机制来保证数据安全。然后利用定时任务定期的将点赞数量同步给业务方,持久化到数据库中。

Redis中具体使用了哪种数据结构

我们使用了两种数据结构,set和zset

首先保存点赞记录,使用了set结构,key是业务类型+业务id,值是点赞过的用户id。当用户点赞时就 SADD用户id进去,当用户取消点赞时就 SREM删除用户id。当判断是否点赞时使用 SISMEMBER即可。当要统计点赞数量时,只需要 SCARD就行,而Redis的SET结构会在头信息中保存元素数量,因此SCARD直接读取该值,时间复杂度为O(1),性能非常好。

为什么不用用户id为key,业务id为值呢?如果用户量很大,可能出现BigKey?

考虑到我们的项目数据量并不会很大,我们不会有大V,因此点赞数量通常不会超过1000,因此不会出现BigKey。并且,由于我们采用了业务id为KEY,当我们要统计点赞数量时,可以直接使用SCARD来获取元素数量,无需额外保存,这是一个很大的优势。但如果是考虑到有大V的场景,有两种选择,一种还是应该选择这种方案,另一种则是对用户id做hash分片,将大V的key拆分到多个KEY中,结构为 [bizType:bizId:userId高8位]

不过这里存在一个问题,就是页面需要判断当前用户有没有对某些业务点赞。这个时候会传来多个业务id的集合,而SISMEMBER只能一次判断一个业务的点赞状态,要判断多个业务的点赞状态,就必须多次调用SISMEMBER命令,与Redis多次交互,这显然是不合适的。所以就采用了Pipeline管道方式,这样就可以一次请求实现多个业务点赞状态的判断了。

ZSET干什么用的?

严格来说ZSET并不是用来实现点赞业务的,因为点赞只靠SET就能实现了。但是这里有一个问题,我们要定期将业务方的点赞总数通过MQ同步给业务方,并持久化到数据库。但是如果只有SET,我没办法知道哪些业务的点赞数发生了变化,需要同步到业务方。

因此,我们又添加了一个ZSET结构,用来记录点赞数变化的业务及对应的点赞总数。可以理解为一个待持久化的点赞任务队列。

每当业务被点赞,除了要缓存点赞记录,还要把业务id及点赞总数写入ZSET。这样定时任务开启时,只需要从ZSET中获取并移除数据,然后发送MQ给业务方,并持久化到数据库即可。

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

扔到List结构中虽然也能实现,但是存在一些问题:

首先,假设定时任务每隔2分钟执行一次,一个业务如果在2分钟内多次被点赞,那就会多次向List中添加同一个业务及对应的点赞总数,数据库也要持久化多次。这显然是多余的,因为只有最后一次才是有效的。而使用ZSET则因为member的唯一性,多次添加会覆盖旧的点赞数量,最终也只会持久化一次。

不过,并不是说ZSET方案就是完全没问题的,毕竟ZSET底层是哈希结构+跳表,对内存会有额外的占用。但是考虑到我们的定时任务每次会查询并删除ZSET数据,ZSET中的数据量始终会维持在一个较低级别,内存占用也是可以接受的。

0

评论区