东林在线微课堂-点赞相关

1.互动问答相关

准备阶段—分析业务流程

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

  • 通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能【数据库多一个字段描述点赞的业务类型】【单独微服务】
  • 独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性【单独】
  • 并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发【可以在同一时间点内承受住多次点赞】
  • 安全:要做好并发安全控制,避免重复点赞【防止重复点赞】

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

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

image-20240824210015490

点赞服务必须独立,因此必须抽取为一个独立服务。点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。

image-20240824211454190

准备阶段—字段分析

点赞的数据结构分两部分,一是点赞记录,二是与业务关联的点赞数【基本每个具体业务都预留了一个点赞数量的字段liked_times】

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

  • 点赞目标id —-给谁点赞了
  • 点赞人id —-我是谁,我点赞了
  • 点赞时间 —-我啥时候点赞的

不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:

  • 点赞对象类型(为了通用性) —-知道是给啥类型点赞了,是内容还是回复还是笔记

准备阶段—ER图

image-20240824212133197

准备阶段—表结构

1
2
3
4
5
6
7
8
9
10
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 AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点赞记录表';

image-20240824212552680

准备阶段—Mybatis-Plus代码生成

image-20240825143744780

准备阶段–接口统计

从表面来看,点赞功能要实现的接口就是一个点赞接口。不过仔细观察所有的点赞页面,你会发现点赞按钮有灰色和点亮两种状态。

也就是说我们还需要实现查询用户点赞状态的接口,这样前端才能根据点赞状态渲染不同效果。因此我们要实现的接口包括:

  • 点赞/取消点赞
  • 根据多个业务id批量查询用户是否点赞多个业务

==———具体实现———==

1.用户进行点赞/取消点赞[MQ发送]

1.原型图

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

image-20240824213704561

2.设计数据库

3.业务逻辑图

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

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

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

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

  • 点赞目标的类型

返回值有两种设计:

  • 方案一:无返回值,200就是成功,页面直接把点赞数+1展示给用户即可
  • 方案二:返回点赞数量,页面渲染【还需要回查一次数据库,太消耗性能】

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

我们先梳理一下点赞业务的几点需求:

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

由于业务方的类型很多,比如互动问答、笔记、课程等。所以通知方式必须是低耦合的,这里建议使用MQ来实现。

当点赞或取消点赞后,点赞数发生变化,我们就发送MQ通知。整体业务流程如图:

暂时无法在飞书文档外展示此内容

image-20240825150033695

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

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

image-20240825150444161

4.接口分析

综上,按照Restful风格设计,接口信息如下:

image-20240824214034557

5.具体实现

  • 1.controller层

image-20240826093012122

  • 2.service层

image-20240826093019118

  • 3.serviceimpl层

image-20240826093521619

  • 4.mapper层

6.具体难点和亮点

  • 问题一:如何点赞和取消点赞?【只能点赞评论/回复,不能点赞问题啊】

点赞【新增一行】:

image-20240825160058339

取消点赞【删除一行】:

image-20240825160127169

  • 问题二:怎么发送mq,发送者消费者怎么设定的?

image-20240826093204596

  • 问题三:怎么统计点赞数

    只需要点赞业务id和点赞业务类型,因为这两条就可以确定某一个类型的哪个评论/回复/笔记总共点赞数【不需要分用户】

2.批量查询点赞状态[给其他人用]

1.原型图

前端根据不同状态显示不同样式:

image-20240826100503248

2.设计数据库

3.业务逻辑图

由于这个接口是供其它微服务调用,实现完成接口后,还需要定义对应的FeignClient:

1.实现查询点赞情况

2.实现对应FeignClient:提供给其他微服务调用

4.接口分析

image-20240826100400127

5.具体实现

5.1 接口实现

  • 1.controller层

image-20240826103531221

  • 2.service层

image-20240826103537664

  • 3.serviceimpl层

image-20240826103638825

  • 4.mapper层

5.2 提供Feign接口

整体思路:

image-20240826104106520

  • RemarkClient和RemarkClientFallback

image-20240826104724350

  • 配置bean和自动装配

image-20240826104824380

6.具体难点和亮点

是否点赞:就是我传入多个bizid(业务id),你看看哪些业务(一条评论/一条回复)是被点赞过;我就去查询点赞数据库,如果有那就返回这个id【前端根据传回来id进行处理】

点赞多少:根据bizType去判断是QA还是note,然后查询对应表id的对应点赞数然后返回

3.监听点赞数-更新点赞数[消费者]

1.原型图

既然点赞后会发送MQ消息通知业务服务,那么每一个有关的业务服务都应该监听点赞数变更的消息,更新本地的点赞数量。

2.设计数据库

3.业务逻辑图

点赞/取消点赞业务[点赞微服务]添加:发送消息,发送点赞数和点赞id

回复/评论业务[其他微服务]添加:接受消息,更新点赞信息

4.接口分析

5.具体实现

  • 点赞微服务

image-20240826093805663

  • 其他微服务[以回复/评论微服务为例]

image-20240826093901752

6.具体难点和亮点

传递消息[业务id和点赞数],这样通过业务id(主键)能获取到一行数据,然后根据id更新业务点赞数

==———点赞优化———==

1.目前现状

  • 目前情况:

1.点赞/取消点赞—–>统计点赞总数(只根据点赞业务和点赞id就可以确定是问答/笔记表的一行数据) —–>发送MQ通知[点赞业务,点赞数]

2.传入多个业务id,判断是否有点赞【直接根据业务id,业务类型,用户id查询点赞表是否有数据就行】

3.监听点赞数【其他微服务通过1获取消息,然后更新对应的数据库一行数据】

  • 存在问题:

1.点赞/取消点赞,一次就要发送MQ进行更新点赞【太频繁】 —> 定时任务【定时去批量更新】

2.点赞、取消、再点赞、再取消多少次【读写太频繁】 —> 合并写【反正业务方只关注最终点赞结果】

优化图:

【从原来一次性的从头到尾—>redis处理,缓存,定时异步】

image-20240826164921602

2.优化思路

因此将①点赞/取消点赞 / ②点赞数据分别放入redis缓存!!!

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

  • 用户是否点赞【需要业务id,业务类型,用户id】—> 一个数据结构

  • 某业务的点赞总次数【需要业务id,业务类型】—>一个数据结构

image-20240826115404019

  • ①用户是否点赞【需要业务id,业务类型,用户id】

​ 因为要知道某个用户是否点赞某个业务,就必须记录业务id以及给业务点赞的所有用户id . 由于一个业务可以被多个用户点赞,那就需要一个集合存储[哪个类型的哪个业务—-对应user_Id谁点赞],并且要判断用户是否点赞这个操作具有存在且唯一的特性 —> set最符合

​ 点赞,那就sadd方法—–取消点赞,那就srem方法—–判断是否点赞过,那就sismember方法—–统计点赞总数,那就scard方法

  • ②某业务的点赞总次数【需要业务id,业务类型】

​ 因为只需要业务id和业务类型去判断对应的点赞数,因此我们可以将业务类型作为key,业务id作为键,点赞数作为值。这样键值对集合 —> hash或者sortedSet都符合。

​ Hash:传统键值对集合,无序

​ SortedSet:基于Hash结构,[+跳表]。因此可排序,但更占用内存

​ 从节省内存方面hash更好,但是考虑将来要从redis获取点赞数,然后移除[避免重复处理]。为了保证线程安全,查询和移除的操作具备原子性,刚好zset就有几个移除并且获取的功能,天生具备原子性。并且我们每隔一段时间就将数据从redis移除,并不会占用太多内存。

3.Redis数据结构

3.0 Redis持久化机制

  • 由于Redis本身具备持久化机制,AOF提供的数据可靠性已经能够满足点赞业务的安全需求,因此我们完全可以用Redis存储来代替数据库的点赞记录。

也就是说,用户的一切点赞行为,以及将来查询点赞状态我们可以都走Redis,不再使用数据库查询。

  • 大多数企业无法达到数百亿级—>如果真的达到—>可以将redis与数据库结合
    • 1.先利用Redis来记录点赞状态
    • 2.【定期】将Redis中的点赞状态持久化到数据库
    • 3.对于历史点赞记录,比如下架的课程、或者超过2年以上的访问量较低的数据都可以从redis移除,只保留在数据库中
    • 4.当某个记录点赞时,优先去Redis查询并判断,如果Redis中不存在,再去查询数据库数据并缓存到Redis

3.1 点赞[set-要求唯一性]

我们现在点赞的时候(业务idbizId,业务类型bizType,用户id)这三个参数可以确定是谁点赞的,点赞的是什么类型的,点赞的是哪一个

这样可以考虑【key=业务类型+业务id,value=用户id】

image-20240826142805100

3.2 点赞总数[zset-读写原子性]

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

image-20240826144600691

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

4.总流程图

原来:新增点赞是直接插入数据库,统计是根据条件查询数据库,点赞总数是根据条件查询数据库,然后直接发送消息给MQ

现在:新增点赞和取消点赞以及统计点赞数量用redis的set

​ 点赞总数用zset缓存

​ 不着急送MQ,而是通过定时任务去[定期批量]发送bizId和点赞数

image-20240826145808533

5.具体实现

5.1 点赞和取消点赞

==之前串行化,通过redis的set来存储点赞或者取消点赞,然后使用zset存储点赞总数,通过定时任务来发送mq异步接收消息==

image-20240826172351804

从原来直接查数据库获取点赞总数(改为set统计行数就是点赞数),然后zset缓存业务的点赞数

image-20240826172611424

从原来的直接发送一个MQ(改为定时20s一次性扫描处理30个Zset的多个业务的多个业务id下面的点赞数,批量发送MQ),然后批量处理更新点赞

image-20240826173831860

5.2 批量查询点赞状态

==使用Redis的管道批量处理,解决只能isMember判断单个bizId是否有用户点赞==

  • redis管道:

    管道就可以优化,只需要1次往返的网络传输耗时即可

image-20240826200119745

  • 具体代码:

    就是使用redisTemplate下面的executePipelined()方法批量处理,它会将多个要判断的条件一次性打包给redis,然后redis判断是否存在(存在是true,不存在是false),然后将结果给方法返回值resultList(他的顺序就是和id顺序一致),最后遍历resultList,如果是true说明对应的业务id被这个用户点赞过,需要返回

image-20240826175635360

5.3 监听点赞数-更新点赞数

==消息接收者:更改为批量获取,批量处理==

image-20240826174215532

6.测试

点赞和取消点赞的set:

image-20240826154305150

统计业务的点赞数:

image-20240826154415625

7.总结

  • 点赞系统是如何设计的?

先在设计之初我们分析了一下点赞业务可能需要的一些要求。

例如,在我们项目中需要用到点赞的业务不止一个,因此点赞系统必须具备通用性,独立性,不能跟具体业务耦合。

再比如,点赞业务可能会有较高的并发,我们要考虑到高并发写库的压力问题。

所以呢,我们在设计的时候,就将点赞功能抽离出来作为独立服务。当然这个服务中除了点赞功能以外,还有与之关联的评价功能,不过这部分我就没有参与了。在数据层面也会用业务类型对不同点赞数据做隔离,隔离的手段就是在数据库表中设置了业务类型字段,目前是一张表中记录,将来我们如果数据量过大,还可以考虑基于业务类型对数据库做分表。

从具体实现上来说,为了减少数据库压力,我们会利用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中不行吗?

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

(面试官可能说:“那就改为SET结构,SET中只放业务id,业务方收到MQ通知后再次查询不就行了。”如果没问就自己往下说)

当然要解决这个问题,也可以用SET结构代替List,然后当业务被点赞时,只存业务id到SET并通知业务方。业务方接收到MQ通知后,根据id再次查询点赞总数从而避免多次更新的问题。但是这种做法会导致多次网络通信,增加系统网络负担。而ZSET则可以同时保存业务id及最新点赞数量,避免多次网络查询。

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

  • 怎么优化的思路图

×

纯属好玩

扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦

文章目录
  1. 1. 1.互动问答相关
    1. 1.1. 准备阶段—分析业务流程
    2. 1.2. 准备阶段—字段分析
    3. 1.3. 准备阶段—ER图
    4. 1.4. 准备阶段—表结构
    5. 1.5. 准备阶段—Mybatis-Plus代码生成
    6. 1.6. 准备阶段–接口统计
  2. 2. ==———具体实现———==
  3. 3. 1.用户进行点赞/取消点赞[MQ发送]
    1. 3.1. 1.原型图
    2. 3.2. 2.设计数据库
    3. 3.3. 3.业务逻辑图
    4. 3.4. 4.接口分析
    5. 3.5. 5.具体实现
    6. 3.6. 6.具体难点和亮点
  4. 4. 2.批量查询点赞状态[给其他人用]
    1. 4.1. 1.原型图
    2. 4.2. 2.设计数据库
    3. 4.3. 3.业务逻辑图
    4. 4.4. 4.接口分析
    5. 4.5. 5.具体实现
      1. 4.5.1. 5.1 接口实现
      2. 4.5.2. 5.2 提供Feign接口
    6. 4.6. 6.具体难点和亮点
  5. 5. 3.监听点赞数-更新点赞数[消费者]
    1. 5.1. 1.原型图
    2. 5.2. 2.设计数据库
    3. 5.3. 3.业务逻辑图
    4. 5.4. 4.接口分析
    5. 5.5. 5.具体实现
    6. 5.6. 6.具体难点和亮点
  6. 6. ==———点赞优化———==
  7. 7. 1.目前现状
  8. 8. 2.优化思路
  9. 9. 3.Redis数据结构
    1. 9.1. 3.0 Redis持久化机制
    2. 9.2. 3.1 点赞[set-要求唯一性]
    3. 9.3. 3.2 点赞总数[zset-读写原子性]
  10. 10. 4.总流程图
  11. 11. 5.具体实现
    1. 11.1. 5.1 点赞和取消点赞
    2. 11.2. 5.2 批量查询点赞状态
    3. 11.3. 5.3 监听点赞数-更新点赞数
  12. 12. 6.测试
  13. 13. 7.总结
,