1.互动问答相关
准备阶段—分析业务流程
一个通用点赞系统需要满足下列特性:
- 通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能【数据库多一个字段描述点赞的业务类型】【单独微服务】
- 独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性【单独】
- 并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发【可以在同一时间点内承受住多次点赞】
- 安全:要做好并发安全控制,避免重复点赞【防止重复点赞】
而要保证安全,避免重复点赞,我们就必须保存每一次点赞记录。只有这样在下次用户点赞时我们才能查询数据,判断是否是重复点赞。同时,因为业务方经常需要根据点赞数量排序,因此每个业务的点赞数量也需要记录下来。
综上,点赞的基本思路如下:
点赞服务必须独立,因此必须抽取为一个独立服务。点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。
准备阶段—字段分析
点赞的数据结构分两部分,一是点赞记录,二是与业务关联的点赞数【基本每个具体业务都预留了一个点赞数量的字段liked_times】
点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:
- 点赞目标id —-给谁点赞了
- 点赞人id —-我是谁,我点赞了
- 点赞时间 —-我啥时候点赞的
不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:
- 点赞对象类型(为了通用性) —-知道是给啥类型点赞了,是内容还是回复还是笔记
准备阶段—ER图
准备阶段—表结构
1 | CREATE TABLE IF NOT EXISTS `liked_record` ( |
准备阶段—Mybatis-Plus代码生成
准备阶段–接口统计
从表面来看,点赞功能要实现的接口就是一个点赞接口。不过仔细观察所有的点赞页面,你会发现点赞按钮有灰色和点亮两种状态。
也就是说我们还需要实现查询用户点赞状态的接口,这样前端才能根据点赞状态渲染不同效果。因此我们要实现的接口包括:
- 点赞/取消点赞
- 根据多个业务id批量查询用户是否点赞多个业务
==———具体实现———==
1.用户进行点赞/取消点赞[MQ发送]
1.原型图
当用户点击点赞按钮的时候,第一次点击是点赞,按钮会高亮;第二次点击是取消,点赞按钮变灰:
2.设计数据库
3.业务逻辑图
从后台实现来看,点赞就是新增(insert)一条点赞记录,取消就是删除(delete)这条点赞记录。——为了方便前端交互——->个合并为一个接口即可。
因此,请求参数首先要包含点赞有关的数据,并且要标记是点赞还是取消:
- 点赞给谁:点赞的目标业务id:bizId
- 谁在点赞(就是登陆用户,可以不用提交)
- 是取消还是点赞
除此以外,我们之前说过,在问答、笔记等功能中都会出现点赞功能,所以点赞必须具备通用性。因此还需要在提交一个参数标记点赞的类型:
- 点赞目标的类型
返回值有两种设计:
- 方案一:无返回值,200就是成功,页面直接把点赞数+1展示给用户即可
- 方案二:返回点赞数量,页面渲染【还需要回查一次数据库,太消耗性能】
这里推荐使用方案一,因为每次统计点赞数量也有很大的性能消耗。
我们先梳理一下点赞业务的几点需求:
- 点赞就新增一条点赞记录,取消点赞就删除记录
- 用户不能重复点赞
- 点赞数由具体的业务方保存,需要通知业务方更新点赞数
由于业务方的类型很多,比如互动问答、笔记、课程等。所以通知方式必须是低耦合的,这里建议使用MQ来实现。
当点赞或取消点赞后,点赞数发生变化,我们就发送MQ通知。整体业务流程如图:
暂时无法在飞书文档外展示此内容
需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可。
在RabbitMQ中,利用TOPIC类型的交换机,结合不同的RoutingKey,可以实现通知对象的变化。我们需要让不同的业务方监听不同的RoutingKey,然后发送通知时根据点赞类型不同,发送不同RoutingKey:
4.接口分析
综上,按照Restful风格设计,接口信息如下:
5.具体实现
- 1.controller层
- 2.service层
- 3.serviceimpl层
- 4.mapper层
无
6.具体难点和亮点
- 问题一:如何点赞和取消点赞?【只能点赞评论/回复,不能点赞问题啊】
点赞【新增一行】:
取消点赞【删除一行】:
- 问题二:怎么发送mq,发送者消费者怎么设定的?
问题三:怎么统计点赞数
只需要点赞业务id和点赞业务类型,因为这两条就可以确定某一个类型的哪个评论/回复/笔记总共点赞数【不需要分用户】
2.批量查询点赞状态[给其他人用]
1.原型图
前端根据不同状态显示不同样式:
2.设计数据库
3.业务逻辑图
由于这个接口是供其它微服务调用,实现完成接口后,还需要定义对应的FeignClient:
1.实现查询点赞情况
2.实现对应FeignClient:提供给其他微服务调用
4.接口分析
5.具体实现
5.1 接口实现
- 1.controller层
- 2.service层
- 3.serviceimpl层
- 4.mapper层
无
5.2 提供Feign接口
整体思路:
- RemarkClient和RemarkClientFallback
- 配置bean和自动装配
6.具体难点和亮点
是否点赞:就是我传入多个bizid(业务id),你看看哪些业务(一条评论/一条回复)是被点赞过;我就去查询点赞数据库,如果有那就返回这个id【前端根据传回来id进行处理】
点赞多少:根据bizType去判断是QA还是note,然后查询对应表id的对应点赞数然后返回
3.监听点赞数-更新点赞数[消费者]
1.原型图
既然点赞后会发送MQ消息通知业务服务,那么每一个有关的业务服务都应该监听点赞数变更的消息,更新本地的点赞数量。
2.设计数据库
3.业务逻辑图
点赞/取消点赞业务[点赞微服务]添加:发送消息,发送点赞数和点赞id
回复/评论业务[其他微服务]添加:接受消息,更新点赞信息
4.接口分析
5.具体实现
- 点赞微服务
- 其他微服务[以回复/评论微服务为例]
6.具体难点和亮点
传递消息[业务id和点赞数],这样通过业务id(主键)能获取到一行数据,然后根据id更新业务点赞数
==———点赞优化———==
1.目前现状
- 目前情况:
1.点赞/取消点赞—–>统计点赞总数(只根据点赞业务和点赞id就可以确定是问答/笔记表的一行数据) —–>发送MQ通知[点赞业务,点赞数]
2.传入多个业务id,判断是否有点赞【直接根据业务id,业务类型,用户id查询点赞表是否有数据就行】
3.监听点赞数【其他微服务通过1获取消息,然后更新对应的数据库一行数据】
- 存在问题:
1.点赞/取消点赞,一次就要发送MQ进行更新点赞【太频繁】 —> 定时任务【定时去批量更新】
2.点赞、取消、再点赞、再取消多少次【读写太频繁】 —> 合并写【反正业务方只关注最终点赞结果】
优化图:
【从原来一次性的从头到尾—>redis处理,缓存,定时异步】
2.优化思路
因此将①点赞/取消点赞 / ②点赞数据分别放入redis缓存!!!
点赞记录中最两个关键信息:
用户是否点赞【需要业务id,业务类型,用户id】—> 一个数据结构
某业务的点赞总次数【需要业务id,业务类型】—>一个数据结构
- ①用户是否点赞【需要业务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】
3.2 点赞总数[zset-读写原子性]
由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。
当用户对某个业务点赞时,我们统计点赞总数,并将其缓存在Redis中。这样一来在一段时间内,不管有多少用户对该业务点赞(热点业务数据,比如某个微博大V),都只在Redis中修改点赞总数,无需修改数据库。
4.总流程图
原来:新增点赞是直接插入数据库,统计是根据条件查询数据库,点赞总数是根据条件查询数据库,然后直接发送消息给MQ
现在:新增点赞和取消点赞以及统计点赞数量用redis的set
点赞总数用zset缓存
不着急送MQ,而是通过定时任务去[定期批量]发送bizId和点赞数
5.具体实现
5.1 点赞和取消点赞
==之前串行化,通过redis的set来存储点赞或者取消点赞,然后使用zset存储点赞总数,通过定时任务来发送mq异步接收消息==
从原来直接查数据库获取点赞总数(改为set统计行数就是点赞数),然后zset缓存业务的点赞数
从原来的直接发送一个MQ(改为定时20s一次性扫描处理30个Zset的多个业务的多个业务id下面的点赞数,批量发送MQ),然后批量处理更新点赞
5.2 批量查询点赞状态
==使用Redis的管道批量处理,解决只能isMember判断单个bizId是否有用户点赞==
redis管道:
管道就可以优化,只需要1次往返的网络传输耗时即可
具体代码:
就是使用redisTemplate下面的executePipelined()方法批量处理,它会将多个要判断的条件一次性打包给redis,然后redis判断是否存在(存在是true,不存在是false),然后将结果给方法返回值resultList(他的顺序就是和id顺序一致),最后遍历resultList,如果是true说明对应的业务id被这个用户点赞过,需要返回
5.3 监听点赞数-更新点赞数
==消息接收者:更改为批量获取,批量处理==
6.测试
点赞和取消点赞的set:
统计业务的点赞数:
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中的数据量始终会维持在一个较低级别,内存占用也是可以接受的。
- 怎么优化的思路图