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

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.互动问答相关

准备阶段—分析业务流程

  • 主流程

image-20240823153033439

整体来说,流程是这样的:

  • 学员在学习的过程中可以随时提问问题
  • 老师、其他学员都可以回答问题
  • 老师、学员也都可以对回答多次回复
  • 老师、学员也都可以对评论多次回复
  • 老师可以在管理端管理问题、回答、评论的状态

准备阶段—字段分析

根据原型图可以得到对应字段

image-20240824150214835

准备阶段—ER图

基本上根据页面原型图得到的字段:

image-20240823153635633

准备阶段—表结构

  • 问题表

image-20240823154007026

  • 回答/评论表

image-20240824150648355

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

可以生成对应的文件和实体类等信息

image-20240823145646440

准备阶段–类型枚举

image-20240823151906796

准备阶段—接口统计

理论上我们应该先设计所有接口,再继续设计接口对应的表结构。不过由于接口较多,这里我们先对接口做简单统计。然后直接设计数据库,最后边设计接口,边实现接口。

image-20240823152213586

1.用户端

问题页面:

image-20240823155633183

结合原型设计图我们可以看到这里包含4个接口:

  • 带条件过滤的分页查询
  • 新增提问
  • 修改提问
  • 删除提问

问题的回答和评论页面:

image-20240823155743166

可以看到页面中包含5个接口:

  • 根据id查询问题详情
  • 分页查询问题下的所有回答
  • 分页查询回答下的评论
  • 点赞/取消点赞某个回答或评论
  • 回答某个提问、评论他人回答

2.管理端

刚才分析的都是用户端的相关接口,这些接口部分可以与管理端共用,但管理端也有自己的特有需求。

管理端也可以分页查询问题列表,而且过滤条件、查询结果会有很大不同:

image-20240823155824265

比较明显的有两个接口:

  • 管理端分页查询问题列表:与用户端分页查询不通用,功能更复杂,查询条件更多
  • 隐藏或显示指定问题

除此以外,这里有一个问题状态字段,表示管理员是否查看了该问题以及问题中的回答。默认是未查看状态;当管理员点击查看后,状态会变化为已查看;当学员再次回答或评论,状态会再次变为未查看。

因此,需要注意的是:

  • 每当用户点击查看按钮,需要根据根据id查询问题详情,此时应标记问题状态为已查看
  • 每当学员回答或评论时,需要将问题标记为未查看

管理端也会有回答列表、评论列表。另外,回答和评论同样有隐藏功能。

问题详情和回答列表:

image-20240823155848228

还有评论列表:

image-20240823155908368

总结一下,回答和评论包含的接口有:

  • 管理端根据id查询问题详情
  • 分页查询问题下的回答
  • 分页查询回答下的评论
  • 点赞/取消点赞某个回答或评论
  • 隐藏/显示指定回答或评论
  • 回答某个提问、评论他人回答、评论(与用户端共用)

==—–用户端–问题相关接口—–==

1.新增互动问题(用户端)

1.原型图

image-20240823161022682

2.设计数据库

3.业务逻辑图

比较简单,通过前端传递给我{课程id,章id,小节id,问题标题,问题具体描述,问题是否匿名}

4.接口分析

image-20240823161128538

通过新增的问题的表单即可分析出接口的请求参数信息了,然后按照Restful的风格设计即可:

image-20240823161245433

5.具体实现

  • 1.controller层

image-20240823165642847

  • 2.service层

image-20240823165750814

  • 3.serviceimpl层

image-20240823165804291

  • 4.mapper层

6.具体难点和亮点

2.修改问题(用户端)

1.原型图

2.设计数据库

3.业务逻辑图

4.接口分析

修改与新增表单基本类似,此处不再分析。我们可以参考新增的接口,然后按照Restful的风格设计为更新即可:

image-20240823170039855

5.具体实现

  • 1.controller层

image-20240823173145619

  • 2.service层

image-20240823173150262

  • 3.serviceimpl层

image-20240823173243825

  • 4.mapper层

6.具体难点和亮点

要注意校验问题是否是自己的,校验是否有这条问题

3.分页查询问题(用户端)

1.原型图

image-20240823203525426

2.设计数据库

3.业务逻辑图

这就是一个典型的分页查询。主要分析请求参数和返回值就行了。

请求参数就是过滤条件,页面可以看到的条件有:

  • 分页条件
  • 全部回答/我的回答:也就是要不要基于用户id过滤
  • 课程id:隐含条件,因为问题列表是在某课程详情页面查看的,所以一定要以课程id为条件
  • 章节id:可选条件,当用户点击小节时传递

返回值格式,从页面可以看到属性有:

  • 是否匿名:如果提交问题是选择了匿名,则页面不能展示用户信息
  • 用户id:匿名则不显示
  • 用户头像:匿名则不显示
  • 用户名称:匿名则不显示
  • 问题标题
  • 提问时间
  • 回答数量
  • 最近一次回答的信息:
    • 回答人名称
    • 回答内容

image-20240823204307508

4.接口分析

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

image-20240823203613606

5.具体实现

  • 1.controller层

image-20240823212331980

  • 2.service层

image-20240823212338832

  • 3.serviceimpl层

image-20240823213318136

[for循环遍历组装数据]

image-20240823212921387

  • 4.mapper层

6.具体难点和亮点

主要就是根据问题表和问答表查询对应信息:

image-20240823204407682

测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"total": "2",
"pages": "1",
"list": [
{
"id": "1552212554946768897",
"title": "redis安装的时候有问题,一直报错是怎么回事?",
"answerTimes": 1,
"createTime": "2022-07-27 16:41:27",
"userId": "2",
"userName": "jack",
"userIcon": "/img-tx/0abcd1d7a66a4fad98b80c1bb48ec6ec.png",
"latestReplyContent": "是不是Redis的依赖没有安装呢?",
"latestReplyUser": "13500010005"
},
//如果问题的提问者是匿名的就不显示userName userId userIcon属性
{
"id": "1585589766919852033",
"title": "Java的IO是阻塞IO吗?",
"answerTimes": 1,
"createTime": "2022-10-27 12:31:44",
"latestReplyContent": "阻塞IO和非阻塞IO都有。java.io包下的都是阻塞IO,java.nio下的是非阻塞IO",
"latestReplyUser": "admin"
}
]
}

封装过程:

4.根据id查询问题详情(用户端)

1.原型图

image-20240823175553623

由此可以看出详情页所需要的信息相比分页时,主要多了问题详情,主要字段有:

  • 是否匿名
  • 用户id:匿名则不显示
  • 用户头像:匿名则不显示
  • 用户名称:匿名则不显示
  • 问题标题
  • 提问时间
  • 回答数量
  • 问题描述详情

2.设计数据库

3.业务逻辑图

1
2
1.根据问题id获取一条问题
2.根据1步骤信息判断是否匿名情况:不匿名才赋用户信息

4.接口分析

而请求参数则更加简单了,就是问题的id

然后,再按照Restful风格设计,接口就出来了:

image-20240823175701421

5.具体实现

  • 1.controller层

image-20240823202537651

  • 2.service层

image-20240823202543455

  • 3.serviceimpl层

image-20240823202608324

  • 4.mapper层

6.具体难点和亮点

要注意只有不匿名的情况下才能获取用户信息

5.删除问题(用户端)

1.原型图

image-20240823173457552

2.设计数据库

image-20240823175401802

3.业务逻辑图

需要注意的是,当用户删除某个问题时,也需要删除问题下的回答、评论。

整体业务流程如下:

  • 查询问题是否存在
  • 判断是否是当前用户提问的
  • 如果不是则报错
  • 如果是则删除问题
  • 然后删除问题下的回答及评论 【两个表根据问题表的主键id和评论问答表的question_id对应(1对多)】

4.接口分析

image-20240823173517366

5.具体实现

  • 1.controller层

image-20240823175150666

  • 2.service层

image-20240823175156775

  • 3.serviceimpl层

image-20240823175228561

  • 4.mapper层

6.具体难点和亮点

需要注意的是,当用户删除某个问题时,也需要删除问题下的回答、评论。

整体业务流程如下:

  • 查询问题是否存在
  • 判断是否是当前用户提问的
  • 如果不是则报错
  • 如果是则删除问题
  • 然后删除问题下的回答及评论 【两个表根据问题表的主键id和评论问答表的question_id对应(1对多)】

==—–用户端–评论相关接口—–==

1.新增回答/评论(用户端)

1.原型图

image-20240824113228150

针对回答和评论的区别:

image-20240824114211785

2.设计数据库

3.业务逻辑图

image-20240824113914047

4.接口分析

综上,按照Restful的规范设计,接口信息如下:

image-20240824113518775

5.具体实现

  • 1.controller层

image-20240825141841133

  • 2.service层

image-20240825141848556

  • 3.serviceimpl层

  • 4.mapper层

无,使用mq即可

6.具体难点和亮点

  • 问题一:回复和评论的区别

    image-20240824114211785

回复:回答哪个问题,回复什么内容,要不要被看到

评论:回答哪个问题,回复什么内容,要不要被看到 + 【上一级】是哪个回答下面,评论哪个回答,针对谁

  • 问题二:新增回答记得更新question的最近一次回答id
  • 问题三:如果评论的用户是学生(前端传递是否是学生提交),标记问题为未查看【管理端根据id查看问题详情会更改为已查看】

2.分页查询回答/评论列表(用户端)

1.原型图

在问题详情页,除了展示问题详情外,最重要的就是回答列表了,原型图如下:

image-20240825104731667

我们先来分析回答列表,需要展示的内容包括:

  • 回答id
  • 回答内容
  • 是否匿名
  • 回答人信息(如果是匿名,则无需返回)
    • id
    • 昵称
    • 头像
  • 回答时间
  • 评论数量
  • 点赞数量

请求参数就是问题的id。不过需要注意的是,一个问题下的回答比较多,所以一次只能展示一部分,更多数据会采用滚动懒加载模式。简单来说说就是分页查询,所以也要带上分页参数。

再来看一下回答下的评论列表:

image-20240825104818133

仔细观察后可以发现,需要展示的数据与回答及其相似,都包括:

  • 评论id
  • 评论内容
  • 是否匿名
  • 评论人信息(如果是匿名,则无需返回)
    • id
    • 昵称
    • 头像
  • 回答时间
  • 评论数量(无)
  • 点赞数量
  • 目标用户昵称(评论特有)

从返回结果来看:相比回答列表,评论无需展示评论下的评论数量,但是需要展示目标用户的昵称,因为评论是针对某个目标的。

从查询参数来看:查询评论需要知道回答的id,这点与查询回答列表不太一样。

2.设计数据库

3.业务逻辑图

image-20240825094145845

4.接口分析

综上,按照Restful的规范设计,接口信息如下:

image-20240825105158382

5.具体实现

  • 1.controller层

image-20240825103843615

  • 2.service层

image-20240825104011588

  • 3.serviceimpl层

image-20240825104004139

  • 4.mapper层

6.具体难点和亮点

就是拼接数据,没啥难度

==—–管理端-问题相关接口—–==

1.根据条件分页查询问题(管理端)—引入ES

1.原型图

在管理端后台存在问答管理列表页,与用户端类似都是分页查询,但是请求参数和返回值有较大差别:因此需要引入ES处理

image-20240824094210826

从请求参数来看,除了分页参数,还包含3个:

  • 问题的查看状态
  • 课程名称
  • 提问时间

从返回值来看,比用户端多了一些字段:

  • 是否匿名: 管理端不关心,全都展示
  • 提问者信息:
    • 用户id
    • 用户头像:匿名则不显示
    • 用户 名称:匿名则不显示
  • 问题标题
  • 提问时间
  • 回答数量
  • 最近一次回答的信息:
    • 回答人名称
    • 回答内容
  • 问题关联的课程名称
  • 问题关联的章、节名称
  • 问题关联课程的分类名称

2.设计数据库

3.业务逻辑图

image-20240824163306170

4.接口分析

由于请求入参和返回值与用户端有较大差异,因此我们需要设计一个新的接口:

image-20240824094304095

5.具体实现

  • 1.controller层

image-20240824094857852

  • 2.service层

image-20240824094931532

  • 3.serviceimpl层

第一部分:

image-20240824174828966

第二部分:

image-20240824174932621

第三部分:

image-20240824175054481

第四部分:

image-20240824175209034

  • 4.mapper层

6.具体难点和亮点

image-20240824112643164

  • 问题二:查询条件是课程名称,数据是课程id,怎么实现模糊查询?

    所有上线的课程数据都会存储到Elasticsearch中,方便用户检索课程。并且在tj-search模块中提供了相关的查询接口

  • 问题三:那怎么保证ES和Mysql数据一致性?

    1
    2
    3
    4
    5
    方法一:同步双写,课程上架的时候数据写入Mysql,同步也写入ES
    方法二:异步双写,课程上架的时候数据写入Mysql,发送消息给MQ,MQ通知ES更新 【项目使用】
    方法三:定时同步,对于数据库新增的时候,定时批量/全量同步到ES
    方法四:基于Logstash输入输出插件
    方法五:基于cancal数据库增量日志解析工具,伪装主从数据库进行同步
策略 优点 缺点
同步双写 - 简单易实现
- 实时性高
- 代码侵入性强
- 存在不一致的风险
- 可能影响系统性能
异步双写(MQ方式) - 解耦数据写入操作
- 通过消息队列提升性能和扩展性
- 系统复杂度增加
- 可能存在消息丢失的风险
- 引入了消息中间件的依赖
定期同步 - 实现简单
- 无需改变现有业务逻辑
- 实时性差
- 可能给数据库带来额外压力
基于Binlog实时同步 - 无代码侵入
- 实时性较好
- 业务逻辑与数据同步解耦
- 构建Binlog系统复杂
- 可能存在MQ延时风险
使用Canal监听Binlog同步数据到ES - 基于MySQL的Binlog,实现数据的实时同步
- 减少系统耦合
- 需要维护额外的Canal服务
  • 问题四:CaffeineCache本地缓存怎么做的?

2.隐藏/显示问题(管理端)

1.原型图

在管理端的互动问题列表中,管理员可以隐藏某个问题,这样就不会在用户端页面展示了:

image-20240824095044589

2.设计数据库

3.业务逻辑图

由于interaction_question表中有一个hidden字段来表示是否隐藏:

image-20240824095143762

因此,本质来说,这个接口是一个修改某字段值的接口,并不复杂。

4.接口分析

我们按照Restful的风格来设定,接口信息如下:

  • 接口地址:/admin/questions/{id}/hidden/{hidden}
  • 请求方式:PUT
  • 请求参数: 路径占位符参数
    • id:问题id
    • hidden:是否隐藏

5.具体实现

  • 1.controller层

image-20240824100132775

  • 2.service层

image-20240824100138151

  • 3.serviceimpl层

image-20240824100229780

  • 4.mapper层

6.具体难点和亮点

就是简单修改字段

3.根据id查询问题详情(管理端)

1.原型图

在管理端的问题管理页面,点击查看按钮就会进入问题详情页:

image-20240824100328128

问题详情页如下:

image-20240824100430677

2.设计数据库

3.业务逻辑图

可以看到,这里需要查询的数据还是比较多的,包含:

  • 问题标题
  • 问题描述
  • 提问者信息
    • id
    • 昵称
    • 头像
  • 课程三级分类
  • 课程名称
  • 课程负责老师
  • 课程所属章节
  • 回答数量
  • 用户端是否显示

返回值与管理端分页查询基本一致,多了一个课程负责老师信息。所以我们沿用之前的QuestionAdminVO即可。但是需要添加一个课程负责老师的字段:

4.接口分析

虽然用户端也有根据id查询问题,但是返回值与用户端存在较大差异,所以我们需要另外设计一个接口。

按照Restful风格,接口信息如下:

  • 接口地址: /admin/questions/{id}
  • 请求方式: GET
  • 请求参数: 路径占位符格式
  • 返回值:与分页查询共享VO,这里不再赘述

5.具体实现

  • 1.controller层

image-20240824110945921

  • 2.service层

image-20240824110950955

  • 3.serviceimpl层

image-20240824111243999

  • 4.mapper层

6.具体难点和亮点

问题表中有一个status字段,标记管理员是否已经查看过该问题。因此每当调用根据id查询问题接口,我们可以认为管理员查看了该问题,应该将问题status标记为已查看

image-20240824105707505

==—–管理端–评论相关接口—–==

1.分页查询问答/评论列表(管理端)

1.原型图

image-20240825105504177

可以看到,返回的数据格式包含:

  • 评论id
  • 评论内容
  • 评论人信息
    • id
    • 昵称
    • 头像
    • 类型
  • 回答时间
  • 评论数量(回答时有)
  • 点赞数量
  • 目标用户昵称(评论特有)
  • 是否被隐藏(管理端特有)

与用户端查询几乎完全一致。

2.设计数据库

3.业务逻辑图

与用户端查询几乎完全一致,为什么不使用同一个接口?

原因有两点:

  • 管理端在统计评论数量的时候,被隐藏的评论也要统计(用户端不统计隐藏回答)
  • 管理端无视匿名,所有评论都要返回用户信息;用户端匿名评论不返回用户信息。

所以在实现的时候,基本逻辑可以与用户端分页一致,但统计评论数量、处理用户信息时,需要区别对待。

4.接口分析

为了减少代码重复,大家可以对代码做改造抽取,不要重复copy代码

image-20240825105639401

5.具体实现

在用户端的代码添加一个属性判断是否是真:用户端为false,管理端为true【区别:统计数量和用户信息字段】

6.具体难点和亮点

  • 问题一:为什么不复用接口?

原因有两点:

​ ①管理端在统计评论数量的时候,被隐藏的评论也要统计(用户端不统计隐藏回答)

​ ②管理端无视匿名,所有评论都要返回用户信息;用户端匿名评论不返回用户信息。

2.隐藏/显示评论(管理端)

1.原型图

与问题类似,管理员也可以显示或隐藏某个评论或评论:

image-20240824140709151

与隐藏问题类似,同样是修改hidden字段。

2.设计数据库

3.业务逻辑图

与隐藏问题类似,同样是修改hidden字段

注意:如果隐藏的是回答,则回答下的评论也要隐藏】

4.接口分析

  • 接口地址:/admin/replies/{id}/hidden/{hidden}
  • 请求方式:PUT
  • 请求参数:路径占位符参数
    • id:回答或评论id
    • hidden:是否被隐藏

5.具体实现

  • 1.controller层

image-20240824161407864

  • 2.service层

image-20240824161414397

  • 3.serviceimpl层

image-20240824161530474

  • 4.mapper层

image-20240824161535756

6.具体难点和亮点

东林在线微课堂-我的课表相关

1.我的课表

准备阶段—业务流程

image-20240817204359442

准备阶段—字段分析

【主要涉及主键id,学员id和课程id也要记录[要考虑是谁学了什么课程]】

课表要记录的是用户的学习状态,所谓学习状态就是记录在学习哪个课程学习的进度如何。

  • 其中,谁在学习哪个课程,就是一种关系。也就是说课表就是用户和课程的中间关系表。因此一定要包含三个字段:

    • userId:用户id,也就是
    • courseId:课程id,也就是学的课程
    • id:唯一主键
  • 而学习进度,则是一些附加的功能字段,页面需要哪些功能就添加哪些字段即可:

  • status:课程学习状态。0-未学习,1-学习中,2-已学完,3-已过期

  • planStatus:学习计划状态,0-没有计划,1-计划进行中

  • weekFreq:计划的学习频率

  • learnedSections:已学习小节数量,【注意:课程总小节数、课程名称、封面等可由课程id查询得出,无需重复记录】

  • latestSectionId:最近一次学习的小节id,方便根据id查询最近学习的课程正在学第几节

  • latestLearnTime:最近一次学习时间,用于分页查询的排序:

  • createTime和expireTime,也就是课程加入时间和过期时间

准备阶段—ER图

image-20240817212711903

准备阶段—表结构

image-20240817205247815

image-20240817205028215

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

参考我的Mybatis-plus笔记-代码生成步骤:

image-20240814151956565

准备阶段—状态枚举

image-20240814152152815

准备阶段—所有接口

image-20240813213435263

==具体实现==

1.支付/报名课程后添加课表

1.原型图

2.设计数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
create table learning_lesson
(
id bigint not null comment '主键'
primary key,
user_id bigint not null comment '学员id',
course_id bigint not null comment '课程id',
status tinyint default 0 null comment '课程状态,0-未学习,1-学习中,2-已学完,3-已失效',
week_freq tinyint null comment '每周学习频率,例如每周学习6小节,则频率为6',
plan_status tinyint default 0 not null comment '学习计划状态,0-没有计划,1-计划进行中',
learned_sections int default 0 not null comment '已学习小节数量',
latest_section_id bigint null comment '最近一次学习的小节id',
latest_learn_time datetime null comment '最近一次学习的时间',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
expire_time datetime not null comment '过期时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
constraint idx_user_id
unique (user_id, course_id)
)
comment '学生课程表' row_format = DYNAMIC;

image-20240815165857129

3.业务逻辑图

接下来,我们来分析一下添加课表逻辑的业务流程。首先来对比一下请求参数和数据库字段:

参数:

  • Long userId
  • List courseIds

数据表:

img

一个userId和一个courseId是learning_lesson表中的一条数据。而订单中一个用户可能购买多个课程。因此请求参数中的courseId集合就需要逐个处理,将来会有多条课表数据。

另外,可以发现参数中只有userId和courseId,表中的其它字段都需要我们想办法来组织:

  • status:课程状态,可以默认为0,代表未学习
  • week_freq:学习计划频率,可以为空,代表没有设置学习计划
  • plan_status:学习计划状态,默认为0,代表没有设置学习计划
  • learned_sections:已学习小节数,默认0,代表没有学习
  • latest_section_id:最近学习小节id,可以为空,代表最近没有学习任何小节
  • latest_learn_time:最近学习时间,可以为空,代表最近没有学习
  • create_time:创建时间,也就是当前时间
  • expire_time:过期时间,这个要结合课程来计算。每个课程都有自己的有效期(valid_duration),因此过期时间就是create_time加上课程的有效期
  • update_time:更新时间,默认当前时间,有数据库实时更新,不用管

可见在整张表中,需要我们在新增时处理的字段就剩下过期时间expire_time了。而要知道这个就必须根据courseId查询课程的信息,找到其中的课程有效期(valid_duration)。课程表结构如图:

image-20240817213419509

因此,我们要做的事情就是根据courseId集合查询课程信息,然后分别计算每个课程的有效期,组织多个LearingLesson的数据,形成集合。最终批量新增到数据库即可。

流程如图:

image-20240815212456046

其中消息发送者信息:

image-20240817211108395

4.接口分析

image-20240815212239468

5.具体实现

  • 1.数据库设计:

image-20240814145734802

  • 2.准备PO和枚举类:

image-20240814152152815

1
2
3
4
5
6
7
8
9
使用枚举类的优点:
1.类型安全:枚举提供了一种类型安全的方式来处理一组固定的常量。使用枚举可以确保status字段的值只限于预定义的四个选项,防止出现无效的值。【更安全】
2.代码可读性:枚举使得代码更易读,更易于维护。开发者可以很容易地理解每个枚举值的含义,而不需要去查看数据库字段的注释或文档。【易读】
3.减少错误:使用枚举可以减少因拼写错误或使用错误的整数值而导致的bug。【防止写错】
4.便于比较:枚举类型之间的比较可以直接使用==操作符,而不需要使用equals方法。【直接使用==对比】
5.可扩展性:如果未来需要添加更多的状态,枚举类可以很容易地进行扩展。【更容易扩展】
6.方法和属性:枚举类型可以包含字段、方法和构造函数,这使得你可以在枚举值上添加更多的行为和数据。
7.序列化:枚举类型默认实现了Serializable接口,这使得它们可以很容易地被序列化和反序列化。【搭配@JsonValue//序列化的时候转为响应值 @EnumValue //和数据库打交道时转为相对应值】
8.switch语句支持:枚举类型可以作为switch语句的合法变量类型,使得代码更加清晰。
  • 3.使用MybatisPlus生成其余基础代码:

image-20240814151956565

  • 4.设计MQ消费者信息

【我和课程下单统一OrderBasicDTO,主要传递orderId和courseID和userID以及完成时间】

image-20240815162011652

  • 5.具体逻辑

image-20240815162411566

6.具体难点和亮点

  • 问题一:课程过期时间怎么算?

    课程过期时间=课程加入课程时间(当前)+课程有效期(通过传入的courseId课程id远程调用课程微服务获取media_duration有效时间)

  • 问题二:如果这个人网络不好,重复下单怎么保证幂等性?

    1.我给(courseId,userId)创建唯一索引,保证幂等性

    image-20240815165221302

    2.我使用redis:进来的时候判断OrderId订单id是否有,有的话就重复,没有的话就存在redis[设置60s]

image-20240815165141761

  • 问题三:Id如何设计?

    分为分库和不分库情况:我考虑并发就分库,然后使用雪花算法【还有其他方法,在tk实习时候考虑的那个笔记里面】
    image-20240815170139617

参考本文:https://mp.weixin.qq.com/s/zQNfcpCbPoo4yQFJR7FpqQ

2.分页查询我的课表

1.原型图

image-20240817210643678

2.设计数据库

3.业务逻辑图

image-20240817210943970

肉眼可见的字段就包含:

  • 课程名称
  • 课程加入课表时间
  • 课程有效期结束时间
  • 课程状态
  • 课程已学习小节数
  • 课程总小节数
  • 课程是否创建了学习计划

还有一些字段是页面中没有的,但是可以从功能需要中推测出来,例如:

  • 课程id:因为我们点击卡片,需要跳转到对应课程页面,必须知道课程id
  • 课程封面:页面渲染时为了美观,一定会展示一个课程的封面图片
  • 学习计划频率:当用户点击修改学习计划时,需要回显目前的学习计划频率
  • 课表id,当前课程在课表中的对应id,当用户点击继续学习,或创建集合,需要根据课表来操作

4.接口分析

image-20240815211955503

5.具体实现

  • 1.controller层

image-20240815195224271

  • 2.service层

image-20240815195238771

  • 3.serviceImp层

image-20240815195504036

6.具体难点和亮点

  • 问题一:如何查询避免封装时候两次for循环

​ 使用courseList.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c))转换为map,在后续直接取出来就行

3. 查看最近学习的课程(一门)

1.原型图

image-20240817204641999

2.设计数据库

learning_lesson

course

course_catalogue

image-20240815200109908

3.业务逻辑图

主要分为四个部分数据:

image-20240815210724100

4.接口分析

image-20240815200138868

5.具体实现

  • 1.controller层

image-20240815211536963

  • 2.service层

image-20240815211548167

  • 3.serviceimpl层

    image-20240815211458287

  • 4.mapper层

image-20240816150635247

6.具体难点和亮点

  • 问题一:什么是最近学习的一门课程【基本上围绕learning-lesson和course以及course-catalogue三个数据库表获取数据】

    可以在学习中心位置查看最近学习的一门课程,主要是通过userId用户id查询一条课程表信息;通过课程表信息的courseId课程id查询课程的具体信息;通过课程表信息的latest_section_id最近一次学习的小节名称远程调用课程学习微服务获取(通过latest_section_id查询course-catalogue表数据);通过userId用户id来count(*)获得数据

4.根据id查询某个课程学习状态

1.原型图

在课程详情页,课程展示有两种不同形式:

  • 对于未购买的课程:展示为立刻购买或加入购物车

img

  • 对于已经购买的课程:展示为马上学习,并且显示学习的进度、有效期

img

2.设计数据库

3.业务逻辑图

image-20240816144647955

4.接口分析

image-20240816144722047

5.具体实现

  • 1.controller层

image-20240816151331705

  • 2.service层

image-20240816151336910

  • 3.serviceimpl层

image-20240816151452627

  • 4.mapper层

image-20240816151350430

6.具体难点和亮点

  • 问题一:查询课表的课程还是课程表的信息?

​ ①根据courseId和UserId(两者是唯一索引,能保证只有得到一条数据)查询课表得到公共数据,针对课程具体信息要传入courseId课程id远程调用查询

5.删除课表中的课程

1.原型图

image-20240816164422835

2.设计数据库

3.业务逻辑图

删除课表中的课程有两种场景:

  • ①用户直接删除已失效的课程【比较简单】
  • ②用户退款后触发课表自动删除【涉及发送MQ消息给learning-service服务】

现在那边退款成功之后增加步骤4[发送消息,我需要负责接受消息]

image-20240816162139654

4.接口分析

这里我们可以按照Restful的规范来定义这个删除接口:

  • 请求方式:删除业务的请求方式都是DELETE
  • 请求路径:一般是资源名 + 标示,这里删除的是课表中的课程,因此:/ls/lessons/{courseId}
  • 请求参数:自然是路径中传递的课程id
  • 返回值:无

5.具体实现

  • 1.controller层

image-20240816160647290

  • 2.service层

    image-20240816163813941

  • 3.serviceimpl层

image-20240816160731775

image-20240816163833033

  • 4.mapper层

image-20240816160747311

  • 5.learning-service微服务接收MQ消息

image-20240816164326102

6.具体难点和亮点

  • 问题一:删除的有哪几种情况?

    ①根据用户下单,然后取消报名的时候发送MQ消息给learning-service微服务告知删除

    ②已经学习了很久,课程失效了就直接根据情况删除

6.检查课程是否有效

1.原型图

2.设计数据库

3.业务逻辑图

这是一个微服务内部接口,当用户学习课程时,可能需要播放课程视频。此时提供视频播放功能的媒资系统就需要校验用户是否有播放视频的资格。所以,开发媒资服务(tj-media)的同事就请你提供这样一个接口。

用户要想有播放视频的资格,那就必须满足两个条件:

  • 用户课表中是否有该课程
  • 课程状态是否是有效的状态(未过期)

image-20240816165647909

4.接口分析

image-20240816164643768

5.具体实现

  • 1.controller层

image-20240816165802260

  • 2.service层

image-20240816165837661

  • 3.serviceimpl层

image-20240816165901892

  • 4.mapper层

image-20240816165912431

6.具体难点和亮点

  • 问题一:如何判断课程是否有效

    就是①判断课表是否有这个课程,②这个课程的expire过期时间是否失效了,没办法学了

7.统计课程的学习人数

1.原型图

课程微服务中需要统计每个课程的报名人数,同样是一个内部调用接口,在tj-api模块中已经定义好了:

1
2
3
4
5
6
7
/**
* 统计课程学习人数
* @param courseId 课程id
* @return 学习人数
*/
@GetMapping("/lessons/{courseId}/count")
Integer countLearningLessonByCourse(@PathVariable("courseId") Long courseId);

2.设计数据库

3.业务逻辑图

4.接口分析

这里我们可以按照Restful的规范来定义这个统计接口:

  • 请求方式:删除业务的请求方式都是GET
  • 请求路径:一般是资源名 + 标示,这里删除的是课表中的课程,因此:/lessons/{courseId}
  • 请求参数:自然是路径中传递的课程id
  • 返回值:Integer学习人数

5.具体实现

  • 1.controller层

image-20240816173242686

  • 2.service层

image-20240816173248999

  • 3.serviceimpl层

image-20240816173314799

  • 4.mapper层

image-20240816173300519

6.具体难点和亮点

  • 问题一:sql怎么写?怎么统计

    1
    2
    3
    select count(user_id) 
    from learning_lesson
    where course_id=xx 【根据课程id分类】

东林在线微课堂-学习计划和进度

0.前提回顾

在东林在线微课堂-我的课表相关:已经可以实现课表的增删改查接口,但是在查看已学习课程时候有两个字段没有返回:
image-20240818165409837

我们需要在查询结果中返回已学习课时数、正在学习的章节名称。虽然我们在learning_lesson表中设计了两个字段:

  • learned_sections:已学习章节数
  • latest_learn_time:最近学习时间

image-20240818165508732

以上的问题归纳下来,就是一个学习进度统计问题,这在在线教育、视频播放领域是一个非常常见的问题。

  • 提出原因:

大部分人的学习自律性是比较差的,属于“买了就算会了”的状态。如果学员学习积极性下降,学习结果也会不尽人意,从而产生挫败感。导致购买课程的欲望也会随之下降,形成恶性循环,不利于我们卖课。

所以,我们推出学习计划的功能,让学员制定一套学习计划,每周要学几节课。系统会做数据统计,每一周计划是否达标,达标后给予奖励,未达标则提醒用户,达到督促用户持续学习的目的。

用户学习效果好了,产生了好的结果,就会有继续学习、购买课程的欲望,形成良性循环。

因此,学习计划、学习进度统计其实是学习辅助中必不可少的环节。

1.学习计划相关

准备阶段—分析业务流程

1.==学习计划==

在我的课程页面,可以对有效的课程添加学习计划:

img

学习计划就是简单设置一下用户每周计划学习几节课:

有了计划以后,我们就可以在我的课程页面展示用户计划的完成情况,提醒用户尽快学习:

image-20240818170916834

可以看到,在学习计划中是需要统计用户“已经学习的课时数量”。

2.==学习进度统计==

在原型图《课程学习页-录播课-课程学习页-目录》中,可以看到学习课程的原型图:

image-20240818171318018

一个课程往往包含很多个章(chapter),每一章下又包含了很多小节(section)。章本身没有课程内容,只是划分课程的一个概念。小节分两种,一种是视频;一种是每章最后的阶段考试 —-> 用户学完一个视频/参加了最终的考试都算学完了一个小节。

==统计学习进度:====用户学了多少小节[①视频:完播率超过75%②考试:考试提交]==

因而引出几个问题:

因此,用户在播放视频的过程中,需要不断地提交视频的播放进度,当我们发现视频进度超过75%的时候就标记这一小节为已完成
因此,我们需要记录视频是否完成,也需要记录用户具体播放到第几秒视频[这样下次播放就可以实现视频自动续播]

也就是说,要记录用户学习进度,需要记录下列核心信息:

  • 小节的基础信息(id、关联的课程id等)
  • 当前的播放进度(第几秒)
  • 当前小节是否已学完(播放进度是否超75%)

用户每学习一个小节,就会新增一条学习记录,当该课程的全部小节学习完毕,则该课程就从学习中进入已学完状态了。整体流程如图:

image-20240818173039394

准备阶段—字段分析

数据表的设计要满足学习计划[learning_lesson表在我的课表需求完成设计]学习进度[目前需要]的功能需求:

按照之前的分析,用户学习的课程包含多个小节,小节的类型包含两种:

  • 视频:视频播放进度超过50%就算当节学完
  • 考试:考完就算一节学完

学习进度除了要记录哪些小节学完,还要记录学过的小节、每小节的播放的进度(方便续播)。因此,需要记录的数据就包含以下部分:

  • 学过的小节的基础信息
    • 小节id
    • 小节对应的lessonId课表id
    • 用户id:学习课程的人
  • 小节的播放进度信息
    • 视频播放进度:也就是播放到了第几秒
    • 是否已经学完:播放进度有没有超过50%
    • 第一次学完的时间:用户可能重复学习,第一次从未学完到学完的时间要记录下来

再加上一些表基础字段,整张表结构就出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE IF NOT EXISTS `learning_record` (
`id` bigint NOT NULL COMMENT '学习记录的id',
`lesson_id` bigint NOT NULL COMMENT '对应课表的id',
`section_id` bigint NOT NULL COMMENT '对应小节的id',
`user_id` bigint NOT NULL COMMENT '用户id',
`moment` int DEFAULT '0' COMMENT '视频的当前观看时间点,单位秒',
`finished` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否完成学习,默认false',
`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`) USING BTREE,
KEY `idx_update_time` (`update_time`) USING BTREE,
KEY `idx_user_id` (`user_id`) USING BTREE,
KEY `idx_lesson_id` (`lesson_id`,`section_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学习记录表';

image-20240818212933490

准备阶段—ER图

准备阶段—表结构

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

image-20240819155350133

准备阶段—类型枚举

我们需要准备一些VO和DTO等

image-20240819155305895

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

按照用户的学习顺序,依次有下面几个接口:

  • 创建学习计划
  • 查询学习记录
  • 提交学习记录
  • 查询我的计划

1.创建学习计划

1.原型图

在个人中心的我的课表列表中,没有学习计划的课程都会有一个创建学习计划的按钮,在原型图就能看到:

image-20240818175247495

创建学习计划,本质就是让用户设定自己每周的学习频率:

image-20240818175308838

2.设计数据库

3.业务逻辑图

当我们创建学习计划时,就是根据课程id和用户id去更新learning_lesson表,写入week_freq并更新plan_status为计划进行中即可。

4.接口分析

而学习频率我们在设计learning_lesson表的时候已经有两个字段来表示了:

image-20240818175359777

当我们创建学习计划时,就是根据课程id和用户id去更新learning_lesson表,写入week_freq并更新plan_status为计划进行中即可。

因此请求参数就是课程的id、每周学习频率。再按照Restful风格,最终接口如下:

image-20240818175713570

5.具体实现

  • 1.controller层

image-20240820144641297

  • 2.service层

image-20240820144656535

  • 3.serviceimpl层

image-20240820144819334

  • 4.mapper层

6.具体难点和亮点

就是简单的创建学习计划【根据userId和courseId课程id更新一行数据的weekFreq和status字段】

2.查询学习记录

1.原型图

用户创建完计划自然要开始学习课程,在用户学习视频的页面,首先要展示课程的一些基础信息。例如课程信息、章节目录以及每个小节的学习进度:

image-20240818210943313

其中:

①课程、章节、目录信息等数据都在课程微服务。【课程信息是必备的】

②学习进度肯定是在学习微服务。【学习进度却不一定存在】

2.设计数据库

3.业务逻辑图

①课程、章节、目录信息等数据都在课程微服务。【课程信息是必备的】

②学习进度肯定是在学习微服务。【学习进度却不一定存在】

因此,查询这个接口的请求———>课程微服务【查询课程、章节信息】,再由课程微服务———>学习微服务【查询学习进度】,合并后一起返回给前端即可。

所以,学习中心要提供一个查询章节学习进度的Feign接口,事实上这个接口已经在tj-api模块的LearningClient中定义好了:

image-20240818211523413

根据courseId和userId获取课表id和最近学习的小节id,然后根据课表id获取多条学习记录。【小节id,小节视频播放进度,小节是否学习完】

4.接口分析

对应的DTO也都在tj-api模块定义好了,因此整个接口规范如下:

image-20240818211701755

5.具体实现

  • 1.controller层

image-20240819171234727

  • 2.service层

image-20240819171400064

  • 3.serviceimpl层

image-20240819171323344

  • 4.mapper层

image-20240819171336002

6.具体难点和亮点

无[就是查询而已]

3.提交学习记录(每15秒提交–很难懂!!!)

1.原型图

2.设计数据库

3.业务逻辑图

之前分析业务流程的时候已经聊过,学习记录==用户当前学了哪些小节,以及学习到该小节的进度如何。而小节类型分为考试、视频两种。

  • 考试比较简单,只要提交了就说明这一节学完了。
  • 视频比较麻烦,需要记录用户的播放进度,进度超过75%才算学完。因此视频播放的过程中需要不断提交播放进度到服务端,而服务端则需要保存学习记录到数据库。

只要记录了用户学过的每一个小节,以及小节对应的学习进度、是否学完。无论是视频续播、还是统计学习计划进度,都可以轻松实现了。

因此,提交学习记录就是提交小节的信息和小节的学习进度信息。考试提交一次即可,视频则是播放中频繁提交。提交的信息包括两大部分:

  • 小节的基本信息

    • 小节id
    • lessonId课程id
    • 小节类型:可能是视频,也可能是考试。考试无需提供播放进度信息
    • 提交时间
  • 播放进度信息

    • 视频时长:时长结合播放进度可以判断有没有超过50%
    • 视频播放进度:也就是第几秒

具体业务思路:

image-20240819175544333

4.接口分析

综上,提交学习记录的接口信息如下:

image-20240818211748452

5.具体实现

  • 1.controller层

image-20240819213932560

  • 2.service层

image-20240819213937233

  • 3.serviceimpl层

serviceimpl层代码整体逻辑:

image-20240821150157002

其中处理课表:

image-20240821151018860

其中处理视频:

image-20240821151504478

其中处理考试:

image-20240821151957029

  • 4.mapper层

无【全用的mybatisplus完成】

6.具体难点和亮点

  • 问题一:学习记录服务有必要提交到服务端?在客户端不就可以保存

    ​ 我设置的是课程学习页面播放视频时/考试后,需要提交学习记录信息到服务端保存。每隔15s提交一次。【保证换个设备还可以查看】

  • 问题二:实现思路是什么?

    ​ ①获取当前用户

    ​ ②处理学习记录 —>2.1判断提交类型,①处理视频[存在记录更新学习记录并且判断是否第一次学习,不存在就新增学习记录]②处理考试[只需要新增学习记录,返回true一定是已学习]

    ​ ③处理课表记录 —> 3.1查找课表,3.2判断是否全部学完,3.3放在一个更新课表[①本来就修改的字段②学习完全部小节,多修改一个字段③第一次学习,多修改一个字段]

  • 问题三:怎么判断的各个节点

判断是否是考试:通过前端传入的dto判断sectionType字段

判断记录已经存在:通过lessonid课程id和sectionId小节id确定一行record,如果有就是存在

判断是否第一次学习完:通过判断record的finished字段未完成&&前端传入的视频播放秒数moment*2>前端传入的视频总长duration

判断判断是否学习完全部课程:当前lesson的learnedsections已学习小节数+1>课程总小节数【课程微服务查询出】

4.查询我的计划(封装数据难)

1.原型图

在个人中心的我的课程页面,会展示用户的学习计划及本周的学习进度,原型如图:

image-20240818211809633

2.设计数据库

3.业务逻辑图

需要注意的是这个查询其实是一个分页查询,因为页面最多展示10行,而学员同时在学的课程可能会超过10个,这个时候就会分页展示,当然这个分页可能是滚动分页,所以没有进度条。另外,查询的是我的学习计划,隐含的查询条件就是当前登录用户,这个无需传递,通过请求头即可获得。

因此查询参数只需要分页参数即可。

查询结果中有很多对于已经学习的小节数量的统计,因此将来我们一定要保存用户对于每一个课程的学习记录,哪些小节已经学习了,哪些已经学完了。只有这样才能统计出学习进度。

查询的结果如页面所示,分上下两部分。:

①总的统计信息:

  • 本周已完成总章节数:需要对学习记录做统计
  • 课程总计划学习数量:累加课程的总计划学习频率即可
  • 本周学习积分:积分暂不实现

②正在学习的N个课程信息的集合,其中每个课程包含下列字段:

  • 该课程本周学了几节:统计学习记录
  • 计划学习频率:在learning_lesson表中有对应字段
  • 该课程总共学了几节:在learning_lesson表中有对应字段
  • 课程总章节数:查询课程微服务
  • 该课程最近一次学习时间:在learning_lesson表中有对应字段

image-20240820151930884

4.接口分析

综上,查询学习计划进度的接口信息如下:

image-20240818211836745

5.具体实现

  • 1.controller层

image-20240820203649725

  • 2.service层

image-20240820203656379

  • 3.serviceimpl层

image-20240820205151868

输出结果分为两个模块:
①本周计划和积分奖励

image-20240820205620992

②课程信息:

image-20240820210625381

  • 4.mapper层

image-20240820203706792

6.具体难点和亮点

  • 问题一:怎么统计用户本周已学习小节数和计划小节数

【本质就是,学习记录表一行就是学了一个小节;表内每个课程都有一个week_freq,计算总和就是本周计划总数】

1
2
3
4
5
6
7
8
9
#统计用户本周已学习小节总数
select count(*)
from learning_record
where user_id=2 and finished=true and finish_time between '2022-10-11 10:12:34' and '2022-10-20 10:12:34';

#统计用户本周计划学习小节总数
select sum(week_freq)
from learning_lesson
where user_id=129 and status in(0,1) and plan_status=1;
  • 问题二:怎么统计当前用户课表和某个课程本周的学习小节数

【本质就是,分页查询就是加个limit;某个课程要根据group by lesson_id课程id,因为一个课程在record记录表每个小节id都有一行数据,一个课程id在lesson课表中每个课程有一个】

1
2
3
4
5
6
7
8
9
10
11
#分页查询当前用户的课表
select *
from learning_lesson
where user_id=2 and status in(0,1) and plan_status=1
limit 0,2;

#查询某个课程本周的学习小节数
select lesson_id,count(*)
from learning_record
where user_id=2 and finished=true and finish_time between '2024-08-19 10:12:34' and '2024-08-26 10:12:34'
group by lesson_id;
  • 问题三:怎么设置的开始和结束时间
1
2
3
4
5
6
7
public static LocalDateTime getWeekBeginTime(LocalDate now) {
return now.minusDays(now.getDayOfWeek().getValue() - 1).atStartOfDay();
}

public static LocalDateTime getWeekEndTime(LocalDate now) {
return LocalDateTime.of(now.plusDays(8 - now.getDayOfWeek().getValue()), LocalTime.MAX);
}

5.定时监测—课程是否过期

1.原型图

定期检查learning_lesson表中的课程是否过期,如果过期则将课程状态修改为已过期。

2.设计数据库

3.业务逻辑图

4.接口分析

5.具体实现

  • 1.启动类添加注解

image-20240821174944291

  • 2.创建Task类

image-20240821174924986

  • 3.mapper层image-20240821173237955

6.具体难点和亮点

  • 使用什么技术实现?

①SpringTask定时任务使用@Scheduled注解+@Async异步调用+@Retryable重试机制 —》保证既定时执行又异步且具备重试功能的健壮任务

②实现SchedulingConfigurer接口

③Quartz框架

④MQ延迟队列 【在定时任务方法里面发送消息给MQ,让MQ进行业务修改】

==面试题==

  • 你在开发中参与了哪些功能开发让你觉得比较有挑战性?

答:我参与了整个学习中心的功能开发,其中有很多的学习辅助功能都很有特色。比如视频播放的进度记录。我们网站的课程是以录播视频为主,为了提高用户的学习体验,需要实现视频续播功能。这个功能本身并不复杂,只不过我们产品提出的要求比较高:

  • 首先续播时间误差要控制在30秒以内[每隔15s发起一次心跳请求,请求最新的播放进度,存储在服务器]

  • 而且要做到用户突然断开,甚至切换设备后,都可以继续上一次播放[播放记录必须保存在服务端,而不是客户端(传统的只能保证一个设备)]

要达成这个目的,使用传统的手段显然是不行的。

首先,要做到切换设备后还能续播,用户的播放进度必须保存在服务端,而不是客户端。

其次,用户突然断开或者切换设备,续播的时间误差不能超过30秒,那播放进度的记录频率就需要比较高。我们会在前端每隔15秒就发起一次心跳请求,提交最新的播放进度,记录到服务端[写在数据库内,可能会导致数据库压力过大问题]。这样用户下一次续播时直接读取服务端的播放进度,就可以将时间误差控制在15秒左右。

==———————-高并发优化——————–==

1.高并发方案[三个]

image-20240821213816592

其中,②水平扩展和③服务保护侧重的是运维层面的处理。而①提高单机并发能力侧重的则是业务层面的处理,也就是我们程序员在开发时可以做到的。

1.1方案一:提高单机并发[数据库方面-读写优化]

在机器性能一定的情况下,提高单机并发能力就是要尽可能缩短业务的响应时间(ResponseTime),而对响应时间影响最大的往往是对数据库的操作。而从数据库角度来说,我们的业务无非就是/两种类型。

对于==读>写==的业务,其优化手段大家都比较熟悉了,主要包括两方面:

  • 优化代码和SQL
  • 添加缓存

对于==读<写==的业务,大家可能较少碰到,优化的手段可能也不太熟悉,这也是我们要讲解的重点。

对于高并发写的优化方案有:

  • 优化代码和SQL
  • 同步写 —> 异步写
  • 合并写数据请求

1.1.1 同步写 –> 异步写

由于各个业务之间是同步串行执行,因此整个业务的响应时间就是每一次数据库写业务的响应时间之和,并发能力肯定不会太好。

image-20240822115831779

优化的思路很简单,利用MQ可以把同步业务变成异步,从而提高效率。

  • 当我们接收到用户请求后,可以先不处理业务,而是发送MQ消息并返回给用户结果。
  • 而后通过消息监听器监听MQ消息,处理后续业务。

image-20240822140833957

这样一来,用户请求处理和后续数据库写就从同步变为异步,用户无需等待后续的数据库写操作,响应时间自然会大大缩短。并发能力自然大大提高。

  • 优点:

​ ①无需等待复杂业务处理,大大减少了响应时间 ②利用MQ暂存消息,起到流量削峰整形 ③降低写数据库频率,减轻数据库并发压力

  • 缺点:

​ ①依赖于MQ的可靠性 ②只是降低一些频率,但是没有减少数据库写次数

  • 适应场景:

​ 业务复杂, 业务链较长,有多次数据库写操作的业务

1.1.2 合并写请求

合并写请求方案其实是参考高并发读的优化思路:当读数据库并发较高时,我们可以把数据缓存到Redis,这样就无需访问数据库,大大减少数据库压力,减少响应时间。

合并写请求就是指当写数据库并发较高时,不再直接写到数据库。而是先将数据缓存到Redis,然后定期将缓存中的数据批量写入数据库。

image-20240822141520757

由于Redis是内存操作,写的效率也非常高,这样每次请求的处理速度大大提高,响应时间大大缩短(↓),并发能力肯定有很大的提升。

而且由于数据都缓存到Redis了,积累一些数据后再批量写入数据库,这样数据库的写频率(↓)、写次数(↓)都大大减少,对数据库压力小了非常多!

  • 优点:

​ ①写缓存速度快,响应时间大大缩短(↓) ②降低数据库的写频率(↓)和写次数(↓)

  • 缺点:

​ ①实现相对复杂 ②依赖Redis可靠性 ③不支持事务和复杂业务

  • 适应场景:

​ 写频率高,写业务相对简单的业务

2.业务优化-提交学习记录

2.1 业务优化选型分析

提交进度统计包含大量的数据库读、写操作。不过提交播放记录还是以写数据库为主。因此优化的方向还是以高并发写优化为主。

image-20240822143119605

  • 考试:每章只能考一次,还不能重复考试。因此属于低频行为(×),可以忽略
  • 视频进度:前端每隔15秒就提交一次请求。在一个视频播放的过程中,可能有数十次请求,但完播(进度超50%)的请求只会有一次。因此多数情况下都是更新一下播放进度即可。

也就是说,95%的请求都是在更新learning_record表中的moment视频播放秒数字段,以及learning_lesson表中的最近正在学习的小节id和最近学习时间两个字段上。

image-20240822143815456

而播放进度信息,不管更新多少次,下一次续播肯定是从最后的一次播放进度开始续播。也就是说我们只需要记住最后一次即可。因此可以采用合并写方案来降低数据库写的次数和频率,而异步写做不到。

综上,提交播放进度业务虽然看起来复杂,但大多数请求的处理很简单,就是==更新播放进度==。并且播放进度数据是可以合并的(覆盖之前旧数据)。我们建议采用合并写请求方案:

image-20240822143954705

2.2 Redis数据结构[hash哈希]

我们的优化方案要处理的不是所有的提交学习记录请求。仅仅是视频播放时的高频更新播放进度的请求,对应的业务分支如图:

image-20240822143815456

这条业务支线的流程如下:

  • 查询播放记录,判断是否存在【存在就更新学习记录,不存在就新增学习记录】
  • 判断当前进度是否是第一次学完【播放进度要超过50% + 原本的记录状态是未学完】
  • 更新课表中最近学习小节id、学习时间【无论如何】

这里有多次数据库操作,例如:

  • 查询播放记录:需要知道播放记录是否存在、播放记录当前的完成状态
  • 更新record学习记录表的播放记录:更新播放进度
  • 更新课表lesson表最近学习小节id、时间

一方面我们要缓存写数据,减少写数据库频率;另一方面我们要缓存播放记录,减少查询数据库。因此,缓存中至少要包含3个字段:

  • 记录id:id,用于根据id更新数据库

  • 播放进度:moment,用于缓存播放进度

  • 播放状态(是否学完):finished,用于判断是否是第一次学完

既然一个课程包含多个小节,我们完全可以把一个课程的多个小节作为一个KEY来缓存,==Redis最终数据结构如图==:

image-20240822144903928

这样做有两个好处:

  • 可以大大减少需要创建的KEY的数量,减少内存占用。
  • 一个课程创建一个缓存,当用户在多个视频间跳转时,整个缓存的有效期都会被延续,不会频繁的创建和销毁缓存数据

2.3 业务逻辑修改–redis缓存

添加缓存之后,业务逻辑更改为:

  • 提交播放进度后,如果是更新播放进度则不写数据库,而是写缓存
  • 需要一个定时任务,定期将缓存数据写入数据库

image-20240822151218103

变化后的业务具体流程为:

  • 1.提交学习记录
  • 2.判断是否是考试
    • 是:新增学习记录,并标记有小节被学完。走步骤8
    • 否:走视频流程,步骤3
  • 3.查询播放记录缓存,如果缓存不存在则查询数据库并建立缓存
  • 4.判断记录是否存在
    • 4.1.否:新增一条学习记录
    • 4.2.是:走更新学习记录流程,步骤5
  • 5.判断是否是第一次学完(进度超50%,旧的状态是未学完)
    • 5.1.不是第一次学完:仅仅是要更新播放进度,因此直接写入Redis并结束
    • 5.2.是第一次学完:代表小节学完,走步骤6
  • 6.更新学习记录状态为已学完
  • 7.清理Redis缓存:因为学习状态变为已学完,与缓存不一致,因此这里清理掉缓存,这样下次查询时自然会更新缓存,保证数据一致。
  • 8.更新课表中已学习小节的数量+1
  • 9.判断课程的小节是否全部学完
    • 是:更新课表状态为已学完
    • 否:结束

2.4 业务逻辑修改–定时任务将redis缓存到数据库

image-20240822152304129

但是定时任务的持久化方式在播放进度记录业务中存在一些问题,主要就是时效性问题。我们的产品要求视频续播的时间误差不能超过30秒。

  • 假如定时任务间隔较短,例如20秒一次,对数据库的更新频率太高,压力太大
  • 假如定时任务间隔较长,例如2分钟一次,更新频率较低,续播误差可能超过2分钟,不满足需求

因此,我们考虑将用户==最后一次提交==的播放进度写入数据库

==【只要用户一直在提交记录,Redis中的播放进度就会一直变化。如果Redis中的播放进度不变,肯定是停止了播放,是最后一次提交】==

因此,我们只要能判断Redis中的播放进度是否变化即可—–>每当前端提交(15s)播放记录时,我们可以设置一个延迟任务保存这次提交的进度。等待20秒后(因为前端每15秒提交一次,20秒就是等待下一次提交),检查Redis中的缓存的进度与任务中的进度是否一致。

  • 不一致:说明持续在提交,无需处理
  • 一致:说明是最后一次提交,更新学习记录、更新课表最近学习小节和时间到数据库中

流程如下:

image-20240822152717163

3.延迟任务方案(定时任务)

针对2.4提出用户提交的播放记录是否变化,我们需要将更新播放记录做一个延迟任务,等待超过一个提交周期(20s)后检查播放进度

延迟任务的实现方案有很多,常见的有四类:

DelayQueue Redisson MQ 时间轮
原理 JDK自带延迟队列,基于阻塞队列实现。 基于Redis数据结构模拟JDK的DelayQueue实现 利用MQ的特性。例如RabbitMQ的死信队列 时间轮算法
优点 不依赖第三方服务 分布式系统下可用不占用JVM内存 分布式系统下可以不占用JVM内存 不依赖第三方服务性能优异
缺点 占用JVM内存只能单机使用 依赖第三方服务 依赖第三方服务 只能单机使用

以上四种方案都可以解决问题,不过本例中我们会使用DelayQueue方案。因为这种方案使用成本最低,而且不依赖任何第三方服务,减少了网络交互。

但缺点也很明显,就是需要占用JVM内存,在数据量非常大的情况下可能会有问题。但考虑到任务存储时间比较短(只有20秒),因此也可以接收。

【如果数据量非常大,DelayQueue不能满足业务需求,大家也可以替换为其它延迟队列方式,例如Redisson、MQ等】

3.1 DelayQueue实现原理

1
2
3
4
5
6
7
//实现了BlockingQueue接口【是一个阻塞队列】
public class DelayQueue<E extends Delayed> extends AbstractQueue<E> implements BlockingQueue<E> {
private final transient ReentrantLock lock = new ReentrantLock();
private final PriorityQueue<E> q = new PriorityQueue<E>();

// ... 略
}

其中:DelayQueue内部的元素必须是Delayed类型,这其实就是一个延迟任务的规范接口
从源码中可以看出,Delayed类型必须具备两个方法:

  • getDelay():获取延迟任务的剩余延迟时间
  • compareTo(T t):比较两个延迟任务的延迟时间,判断执行顺序

可见,Delayed类型的延迟任务具备两个功能:①获取剩余延迟时间、②比较执行顺序

将来每一次提交播放记录,就可以将播放记录保存在这样的一个Delayed类型的延迟任务里并设定20秒的延迟时间。然后交给DelayQueue队列。DelayQueue会调用compareTo方法,根据剩余延迟时间对任务排序。剩余延迟时间越短的越靠近队首,这样就会被优先执行。

3.2 DelayQueue具体用法

首先定义一个Delayed类型的延迟任务类,要能保持任务数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class DelayTask<T> implements Delayed {  //实现Delayed接口【实现两个方法】

//数据
private T data;

//执行时间(纳秒)
private long activeTime;

public DelayTask(T data, Duration delayTime) {
this.data = data;
this.activeTime = System.nanoTime() + delayTime.toNanos(); //当前时间+延迟时间
}

//返回任务剩余的时间
@Override
public long getDelay(TimeUnit unit) {
//设定时间-当前时间[和构造方法不一定是一个时间]
return unit.convert(Math.max(0,activeTime-System.nanoTime()), TimeUnit.NANOSECONDS);
}

//排序
@Override
public int compareTo(Delayed o) {
long l=this.getDelay(TimeUnit.NANOSECONDS)-o.getDelay(TimeUnit.NANOSECONDS);
if(l>0){
return 1;
}else if(l<0){
return -1;
}else{
return 0;
}
}
}

接下来就可以创建延迟任务,交给延迟队列保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
class DelayTaskTest {
@Test
void testDelayQueue() throws InterruptedException {
// 1.初始化延迟队列
DelayQueue<DelayTask<String>> queue = new DelayQueue<>();
// 2.向队列中添加延迟执行的任务
log.info("开始初始化延迟任务。。。。");
queue.add(new DelayTask<>("延迟任务3", Duration.ofSeconds(3)));
queue.add(new DelayTask<>("延迟任务1", Duration.ofSeconds(1)));
queue.add(new DelayTask<>("延迟任务2", Duration.ofSeconds(2)));
// 3.尝试执行任务
while (true) {
DelayTask<String> task = queue.take(); //take方法是阻塞式,如果没有延迟任务就会阻塞
log.info("开始执行延迟任务:{}", task.getData());
}
}
}

注意:本用例直接同一个线程来执行任务了。当没有任务的时候线程会被阻塞。而在实际开发中,我们会准备线程池,开启多个线程来执行队列中的任务。

4.具体改造[直接看这里!!!]

具体改造之后的业务逻辑图:

image-20240822210809060

4.1 定义延迟任务工具类

  • 读取redis数据【用于判断记录是否已经存在,先在redis查询】

image-20240822211557257

image-20240822211514592

  • 缓存到redis并且提交延迟检测任务

image-20240822211659588

image-20240822211856114

  • 异步任务处理

image-20240822211919420

image-20240822212516067

  • 是第一次学习,更新学习记录,删除redis

    image-20240822212711271

image-20240822212818101

  • 延迟任务传递的三个参数

image-20240822212910818

  • redis的value值三个参数

image-20240822213014775

  • 完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
package com.tianji.learning.task;
import com.tianji.common.utils.JsonUtils;
import com.tianji.common.utils.StringUtils;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.domain.po.LearningRecord;
import com.tianji.learning.mapper.LearningRecordMapper;
import com.tianji.learning.service.ILearningLessonService;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.DelayQueue;
@Slf4j
@Component
@RequiredArgsConstructor
public class LearningRecordDelayTaskHandler {

private final StringRedisTemplate redisTemplate;
private final LearningRecordMapper recordMapper;
private final ILearningLessonService lessonService;
private final DelayQueue<DelayTask<RecordTaskData>> queue = new DelayQueue<>();
private final static String RECORD_KEY_TEMPLATE = "learning:record:{}"; //业务前缀【防止只有一个1不知道是谁的课表1】
private static volatile boolean begin = true;

@PostConstruct //项目初始前执行
public void init(){
//异步
//1.自定义线程池【注册到spring容器内,注入线程池,线程池调用业务】
//2.使用CompletableFuture【内部也是多线程】
CompletableFuture.runAsync(this::handleDelayTask);
}
@PreDestroy
public void destroy(){
begin = false; //多线程共享数据begin,必须用volatile其他线程可见性!!!
log.debug("延迟任务停止执行!");
}

//处理延迟任务[判断数据是否一致]
public void handleDelayTask(){
while (begin) {
try {
// 1.[不断地取]获取到期的延迟任务
DelayTask<RecordTaskData> task = queue.take(); //take是阻塞式,没有任务就阻塞
RecordTaskData data = task.getData();

// 2.查询Redis缓存
LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId());
if (record == null) {
continue;
}
// 3.比较数据,moment值
if(!Objects.equals(data.getMoment(), record.getMoment())) { //redis中数据和任务中的数据
// 不一致,说明用户还在持续提交播放进度,放弃旧数据
continue;
}

// 4.一致,持久化播放进度数据到数据库
// 4.1.更新学习记录的moment时刻
record.setFinished(null);
recordMapper.updateById(record);
// 4.2.更新课表最近学习信息
LearningLesson lesson = new LearningLesson();
lesson.setId(data.getLessonId());
lesson.setLatestSectionId(data.getSectionId());
lesson.setLatestLearnTime(LocalDateTime.now());
lessonService.updateById(lesson);
} catch (Exception e) {
log.error("处理延迟任务发生异常", e);
}
}
}

//将数据添加到redis,并且添加一个延迟检查任务到DelayQueue
public void addLearningRecordTask(LearningRecord record){
// 1.添加数据到Redis缓存
writeRecordCache(record);
// 2.提交延迟任务到延迟队列 DelayQueue
queue.add(new DelayTask<>(new RecordTaskData(record), Duration.ofSeconds(20)));
}

//将更新学习记录的数据缓存起来
public void writeRecordCache(LearningRecord record) {
log.debug("更新学习记录的缓存数据");
try {
// 1.数据转换
String json = JsonUtils.toJsonStr(new RecordCacheData(record)); //转为json【id,moment,finished】
// 2.写入Redis
String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId()); //learning:record:{lessonId}
redisTemplate.opsForHash().put(key, record.getSectionId().toString(), json); //KEY[lessonid]-HashKey[sectionid]-HashValue[{id:xxx,moment:xxx,finished:xxx}]
// 3.添加缓存过期时间
redisTemplate.expire(key, Duration.ofMinutes(1)); //设置过期时间1分钟
} catch (Exception e) {
log.error("更新学习记录缓存异常", e);
}
}

//读取redis数据[检查记录是否已经存在]
public LearningRecord readRecordCache(Long lessonId, Long sectionId){
try {
// 1.读取Redis数据
String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId); //learning:record:{lessonId}
Object cacheData = redisTemplate.opsForHash().get(key, sectionId.toString()); //根据hash类型,根据key获取hashkey[sectionId]对应的一行数据value
if (cacheData == null) {
return null;
}
// 2.数据检查和转换
return JsonUtils.toBean(cacheData.toString(), LearningRecord.class); //转为json
} catch (Exception e) {
log.error("缓存读取异常", e);
return null;
}
}

//删除redis数据
public void cleanRecordCache(Long lessonId, Long sectionId){
// 删除数据---删除hashKey里面的一行数据[不能是redisTemplate.delete()这样是删除lessonId了,太大了]
String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId);
redisTemplate.opsForHash().delete(key, sectionId.toString());
}

@Data
@NoArgsConstructor
//redis的hash里面value的三个属性
private static class RecordCacheData{
private Long id;
private Integer moment;
private Boolean finished;

public RecordCacheData(LearningRecord record) {
this.id = record.getId();
this.moment = record.getMoment();
this.finished = record.getFinished();
}
}
@Data
@NoArgsConstructor
//延迟任务所需要的三个属性
private static class RecordTaskData{
private Long lessonId;
private Long sectionId;
private Integer moment;

public RecordTaskData(LearningRecord record) {
this.lessonId = record.getLessonId();
this.sectionId = record.getSectionId();
this.moment = record.getMoment();
}
}
}

4.2 改造提交学习记录

  • 修改整体逻辑

image-20240822213351745

  • 修改课表

image-20240822213659512

插入到redis,直接返回false这样后续4的更新学习记录就不会执行

image-20240822213850097

4.3 测试

不是第一次学完,多次提交的情况:

image-20240822210450880

持续集成

1.项目部署

  • 项目打成jar包
  • Docker部署(项目打成jar包 —>docker镜像文件—>docker容器)
  • K8S

2.持续集成CI

持续集成是一种软件开发实践,即团队开发成员经常集成他们的工作,通常每个成员每天至少集成一次,就意味着每天有多次集成。每次集成都通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽早发现集成错误。

2.1 优点

  • 1.自动构建,发布,测试
  • 2.降低风险

2.2 分类

  • Jenkins[tk旧项目使用]
  • 坎特[tk新项目使用]

3.Jenkins(老头)

Jenkins是一个开源的实现持续集成的软件工具:Jenkins

==原理图:每当我们push代码时候就触发项目完成自动编译和打包==

image-20240812101022218

4.项目部署

微服务部署比较麻烦,所以企业中都会采用持续集成的方式,快捷实现开发、部署一条龙服务。

为了模拟真实环境,我们在虚拟机中已经提供了一套持续集成的开发环境,代码一旦自测完成,push到Git私服后即可自动编译部署。

==原理图:每当我们push代码时候就触发项目完成自动编译和打包==

image-20240812101022218

而开发我们负责的微服务时,则需要在本地启动运行部分微服务。

4.1 虚拟机部署

4.2 本地部署

如果需要运行某个微服务时,只需要以下两步:

  • 第一步:访问Jenkins控制台
  • 第二步:点击对应微服务后面的绿色运行按钮

image-20240812211957065

构建过程中,可以在页面左侧看到构建进度,如果没有说明构建已经结束了(你的机器速度太快了!):

image-20240812212051332

Mysql分表分库-Sharding-Sphere

title: Mysql分表分库-Sharding-Sphere
date: 2024-07-22 09:39:47
tags: Mysql

1.分表分库概念

1.1 分表

开发者自己对表的处理,与数据库无关

  • 从物理上来看,一张表的数据被拆到多个表文件存储了【多张表

  • 从逻辑上来看,【多张表】 — CRUD会变化,需要考虑取哪张表做数据处理

在开发中我们很多情况下业务需求复杂,更看重分表的灵活性。因此,我们大多数情况下都会选择分表方案。

  • 分表的好处:

    • 1.拆分方式更加灵活【可以水平也可以垂直】

    • 2.可以解决单表字段过多问题【垂直分表,分在多个表】

  • 分表的坏处:

    • 1.CRUD需要自己判断访问哪张表
    • 2.垂直拆分还会导致事务问题及数据关联问题:【原本一张表的操作,变为多张表操作,要考虑长事务情况】

而分表有两种分法:

  • 水平分表[根据一些维度横向]

水平分表是将表中的数据行拆分到多个不同的表/数据库,通常是根据某种键值来拆分

  • 垂直分表[部分字段拆分到别的表]

垂直分表是将表中的列拆分到多个不同的表,通常是根据列的使用频率或者业务逻辑来拆分

  • 两者区别
垂直分表 水平分表
拆分依据 列的使用频率/业务逻辑 某种键值[用户ID,时间戳]
目的 优化表结构,提高访问速度 解决单表数据量过大导致性能问题,分散数据到多个表提高查询和更新性能
优点 1.可以减少页面加载时间,只查询必要的列2.降低数据冗余,提高存储效率 1.减少单个表数据量,提高单表管理能力和访问速度2.易于实现,大多数据库中间件都支持水平分表
缺点 1.增加了数据库复杂性,需要更多join连接合并数据2.分表策略要根据业务仔细设计 1.例如联表查询就需要额外的逻辑处理[有的符合行在A,有的符合行在B]2.键值需要谨慎设计
应用场景 表中有大量列,访问只有少数 几个列情况 处理具有明显数据划分界限情况
注意事项 1.分表操作可能会影响数据库的事务管理,需要考虑跨表操作的一致性2.分表策略应该基于实际的业务需求和数据访问模式来设计3.分表后的数据迁移和数据完整性保持是实施过程中最难的挑战

1.1.1 水平分表

例如,对于赛季榜单,我们可以按照赛季拆分为多张表,每一个赛季一张新的表。如图:

image-20240917203826700

这种方式就是水平分表,表结构不变,仅仅是每张表数据不同。查询赛季1,就找第一张表。查询赛季2,就找第二张表。

1.1.2 垂直分表

如果一张表的字段非常多(比如达到30个以上,这样的表我们称为宽表)。宽表由于字段太多,单行数据体积就会非常大,虽然数据不多,但可能表体积也会非常大!从而影响查询效率。

例如一个用户信息表,除了用户基本信息,还包含很多其它功能信息:

image-20240917203845323

1.2 分库[垂直分库]

image-20240722094437153

  • 1.考虑分库的目标:提高读写性能,写入性能还是两者兼顾
  • 2.考虑分片键的选择:通常是表中某个字段
  • 3.考虑分片策略:①范围分片(基于数值范围)②哈希分片(基于哈希算法)③列表分片(基于枚举值)

无论是分区,还是分表,我们刚才的分析都是建立在单个数据库的基础上。但是单个数据库也存在一些问题:

  • 单点故障问题:数据库发生故障,整个系统就会瘫痪【鸡蛋都在一个篮子里】
  • 单库的性能瓶颈问题:单库受服务器限制,其网络带宽、CPU、连接数都有瓶颈【性能有限制】
  • 单库的存储瓶颈问题:单库的磁盘空间有上限,如果磁盘过大,数据检索的速度又会变慢【存储有限制】

综上,在大型系统中,我们除了要做①分表、还需要对数据做②分库—>建立综合集群。

  • 优点:【解决了单个数据库的三大问题】

    • 1.解决了海量数据存储问题,突破了单机存储瓶颈

    • 2.提高了并发能力,突破了单机性能瓶颈

    • 3.避免了单点故障

  • 缺点:

    • 1.成本非常高【要多个服务器,多个数据库】

    • 2.数据聚合统计比较麻烦【因为牵扯多个数据库,有些语句会很麻烦】

    • 3.主从同步的一致性问题【主数据库往从数据库更新,会有不可取消的延误时间,只能通过提高主从数据库网络带宽,机器性能等操作(↓)延误时间】

    • 4.分布式事务问题【因为涉及多个数据库多个表,使用seata分布式事务可以解决】

微服务项目中,我们会按照项目模块,每个微服务使用独立的数据库,因此每个库的表是不同的

image-20240917203647097

2.Sharding-Sphere概念

2.1 Sharding-JDBC框架简介[配置麻烦]

Sharding-JDBC的定位是一款轻量级Java框架,它会以POM依赖的形式嵌入程序,运行期间会和Java应用共享资源,这款框架的本质可以理解成是JDBC的增强版,只不过Java原生的JDBC仅支持单数据源的连接,而Sharding-JDBC则支持多数据源的管理,部署形态如下:

image-20240722094656183

Java-ORM框架在执行SQL语句时,Sharding-JDBC会以切面的形式拦截发往数据库的语句,接着根据配置好的数据源、分片规则和路由键,为SQL选择一个目标数据源,然后再发往对应的数据库节点处理。

Sharding-JDBC在整个业务系统中对性能损耗极低,但为何后面又会推出Sharding-Proxy呢?因为Sharding-JDBC配置较为麻烦,比如在分布式系统中,任何使用分库分表的服务都需要单独配置多数据源地址、路由键、分片策略….等信息,同时它也仅支持Java语言,当一个系统是用多语言异构的,此时其他语言开发的子服务,则无法使用分库分表策略。

2.2 Sharding-Proxy中间件简介[成本过大]

也正是由于配置无法统一管理、不支持异构系统的原因,后面又引入Sharding-Proxy来解决这两个问题,Sharding-Proxy可以将其理解成一个伪数据库,对于应用程序而言是完全透明的,它会以中间件的形式独立部署在系统中,部署形态如下:

image-20240722095156667

使用Sharding-Proxy的子服务都会以连接数据库的形式,与其先建立数据库连接,然后将SQL发给它执行,Sharding-Proxy会根据分片规则和路由键,将SQL语句发给具体的数据库节点处理,数据库节点处理完成后,又会将结果集返回给Sharding-Proxy,最终再由它将结果集返回给具体的子服务。

Sharding-Proxy虽然可以实现分库分表配置的统一管理,以及支持异构的系统,但因为需要使用独立的机器部署,同时还会依赖Zookeeper作为注册中心,所以硬件成本会直线增高,至少需要多出3~4台服务器来部署。

同时SQL执行时,需要先发给Proxy,再由Proxy发给数据库节点,执行完成后又会从数据库返回到Proxy,再由Proxy返回给具体的应用,这个过程会经过四次网络传输的动作,因此相较于原本的Sharding-JDBC来说,性能、资源开销更大,响应速度也会变慢。

2.3 JDBC、Proxy混合部署模式[取长补短]

如果用驱动式分库分表,虽然能够让Java程序的性能最好,但无法支持多语言异构的系统,但如果纯用代理式分库分表,这显然会损害Java程序的性能,因此在Sharding-Sphere中也支持JDBC、Proxy做混合式部署,也就是Java程序用JDBC做分库分表,其他语言的子服务用Proxy做分库分表,部署形态如下:

image-20240722095231817

这种混合式的部署方案,所有的数据分片策略都会放到Zookeeper中统一管理,然后所有的子服务都去Zookeeper中拉取配置文件,这样就能很方便的根据业务情况,来灵活的搭建适用于各种场景的应用系统,这样也能够让数据源、分片策略、路由键….等配置信息灵活,可以在线上动态修改配置信息,修改后能够在线上环境中动态感知。

Sharding-Sphere还提供了一种单机模式,即直接将数据分片配置放在Proxy中,但这种方式仅适用于开发环境,因为无法将分片配置同步给多个实例使用,也就意味着会导致其他实例由于感知不到配置变化,从而造成配置信息不一致的错误。

3.Sharding-Sphere核心概念—路由键/分片算法

  • 路由键/分片键:作为数据分片的基准字段[可以是一个/多个字段组成]

  • 分片算法:基于路由键做一定逻辑处理,从而计算出一个最终节点位置的算法

举例:好比按user_id将用户表数据分片,每八百万条数据划分一张表。user_id就是路由键,而按user_id做范围判断则属于分片算法,一张表中的所有数据都会依据这两个基础,后续对所有的读写SQL进行改写,从而定位到具体的库、表位置。

4.Sharding-Sphere分表分库的工作流程

image-20240722101404319

  • 逻辑表:提供给应用程序操作的表名,程序可以像操作原本的单表一样,灵活的操作逻辑表(逻辑表并不是一种真实存在的表结构,而是提供给Sharding-Sphere使用的)

  • 真实表:在各个数据库节点上真实存在的物理表,但表名一般都会和逻辑表存在偏差。

  • 数据节点:主要是用于定位具体真实表的库表名称,如DB1.tb_user1、DB2.tb_user2.....

    • 均匀分布:指一张表的数量在每个数据源中都是一致的。
    • 自定义分布:指一张表在每个数据源中,具体的数量由自己来定义,上图就是一种自定义分布。

Java为例:

编写业务代码的SQL语句直接基于逻辑表操作;当Sharding-Sphere接收到一条操作某张逻辑表的SQL语句—–已配置好的路由键和分片算法—–>对相应的SQL语句进行解析,然后计算出SQL要落入的数据节点(是哪个真实表),最后再将语句发给具体的真实表上处理即可

JDBC和Proxy的主要区别就在于:解析SQL语句计算数据节点的时机不同

  • JDBC是在Java程序中就完成相应计算,从Java程序中发出SQL语句就已经是操作真实表的SQL
  • Proxy是在Java程序外做解析工作,它会接收程序操作逻辑表的SQL语句。然后再做解析得到具体要操作的真实表,然后再执行,同时Proxy还要作为应用程序和数据库之间,传输数据的中间人

5.Sharding-Sphere概念—表

5.1 绑定表[解决主外键数据落不同库产生跨库查询]

  • 现有问题:

多张表之间存在物理或逻辑上的主外键关系,如果无法保障同一主键值的外键数据落入同一节点,显然在查询时就会发生跨库查询,这无疑对性能影响是极大的。

image-20240722110038729

  • 解决方案:

比如:前面案例中的order_id、order_info_id可以配置一组绑定表关系,这样就能够让订单详情数据随着订单数据一同落库,简单的说就是:配置绑定表的关系后,外键的表数据会随着主键的表数据落入同一个库中,这样在做主外键关联查询时,就能有效避免跨库查询的情景出现。

5.2 广播表[解决跨库join问题]

  • 现有问题:

当有些表需要经常被用来做连表查询时,这种频繁关联查询的表,如果每次都走跨库Join,这显然又会造成一个令人头疼的性能问题。

image-20240722110446940

  • 解决方案:

对于一些经常用来做关联查询的表,就可以将其配置为广播表

image-20240722112901134

广播表是一种会在所有库中都创建的表,以系统字典表为例,将其配置为广播表之后,向其增、删、改一条或多条数据时,所有的写操作都会发给全部库执行,从而确保每个库中的表数据都一致,后续在需要做连表查询时,只需要关联自身库中的字典表即可,从而避免了跨库Join的问题出现。

5.3 单表[不分表分库]

单表的含义比较简单,并非所有的表都需要做分库分表操作,所以当一张表的数据无需分片到多个数据源中时,就可将其配置为单表,这样所有的读写操作最终都会落入这一张单表中处理。

5.4 动态表

动态表是指表会随着数据增长、或随着时间推移,不断的去创建新表,如下:

image-20240722134127808

Sharding-Sphere中可以直接支持配置,无需自己去从头搭建,因此实现起来尤为简单,配置好之后会按照时间或数据量动态创建表。

6.Sharding-Sphere数据分片策略

分库分表之后读写操作具体会落入哪个库中,这是根据路由键和分片算法来决定的

Sharding-Sphere中的数据分片策略又分为:

  • 1.内置的自动化分片算法:[取模分片、哈希分片、范围分片、时间分片等这积累常规算法]

  • 2.用户自定义的分片算法:[标准分片、复合分片、强制分片]

  • 2.1 标准分片算法:适合基于单一路由键进行=、in、between、>、<、>=、<=...进行查询的场景。

  • 2.2 复合分片算法:适用于多个字段组成路由键的场景,但路由算法需要自己继承接口重写实现。

  • 2.3 强制分片算法:适用于一些特殊SQL的强制执行,在这种模式中可以强制指定处理语句的节点。

综上所述,在Sharding-Sphere内部将这四种分片策略称为:Inline、Standard、Complex、Hint,分别与上述四种策略一一对应,但这四种仅代表四种策略,具体的数据分片算法,可以由使用者自身来定义。

7.Sharding-Sphere分库方式

Sharding-Sphere生态中,支持传统的主从集群分库,[如搭建出读写分离架构、双主双写架构],同时也支持按业务进行垂直分库,也支持对单个库进行横向拓展,做到水平分库。

但通常都是用它来实现水平分库和读写分离,因为分布式架构的系统默认都有独享库的概念,也就是分布式系统默认就会做垂直分库,因此无需引入Sharding-Sphere来做垂直分库。

==Sharding-Sphere实际操作==

之前提到过,Sharding-Sphere的所有产品对业务代码都是零侵入的,无论是Sharding-JDBC也好,Sharding-Proxy也罢,都不需要更改业务代码,这也就意味着大家在分库分表环境下做业务开发时,可以像传统的单库开发一样轻松。

  • Sharding-Sphere中最主要的是对配置文件的更改
  • Sharding-JDBC主要修改application.properties/yml文件
  • Sharding-Proxy主要修改自身的配置文件

1.配置yml文件[业务代码零侵入]

1
//后期补充

==Sharding-Sphere工作原理==

1.核心工作步骤

其核心工作步骤会分为如下几步:

  • • 配置加载:在程序启动时,会读取用户的配置好的数据源、数据节点、分片规则等信息。
  • SQL解析:SQL执行时,会先根据配置的数据源来调用对应的解析器,然后对语句进行拆解。
  • SQL路由:拆解SQL后会从中得到路由键的值,接着会根据分片算法选择单或多个数据节点。
  • SQL改写:选择了目标数据节点后,接着会改写、优化用户的逻辑SQL,指向真实的库、表。
  • SQL执行:对于要在多个数据节点上执行的语句,内部开启多线程执行器异步执行每条SQL
  • • 结果归并:持续收集每条线程执行完成后返回的结果集,最终将所有线程的结果集合并。
  • • 结果处理:如果SQL中使用了order by、max()、count()...等操作,对结果处理后再返回。

整个Sharding-Sphere大致工作步骤如上,这个过程相对来说也比较简单,但具体的实现会比较复杂,针对于不同的数据库,内部都会实现不同的解析器,如MySQLMySQL的解析器,PgSQL也会有对应的解析器,同时还会做SQL语句做优化。而SQL路由时,除开要考虑最基本的数据分片算法外,还需要考虑绑定表、广播表等配置,来对具体的SQL进行路由。

2.分库分表产品对比

对比项 Sharding-JDBC Sharding-Proxy MyCat
性能开销 较低 较高
异构支持 不支持 支持 支持
网络次数 最少一次 最少两次 最少两次
异构语言 仅支持Java 支持异构 支持异构
数据库支持 任意数据库 MySQL、PgSQL 任意数据库
配置管理 去中心化 中心化 中心化
部署方式 依赖工程 中间件 中间件
业务侵入性 较低
连接开销
事务支持 XA、Base、Local事务 同前者 XA事务
功能丰富度 一般
社区活跃性 活跃 活跃 一言难尽
版本迭代性 极低
多路由键支持 2 2 1
集群部署 支持 支持 支持
分布式序列 雪花算法 雪花算法 自增序列

Mysql排查

1.前言

在程序开发与运行过程中,出现Bug问题的几率无可避免,数据库出现问题一般会发生在下述几方面:

  • ①撰写的SQL语句执行出错,俗称为业务代码Bug

  • ②开发环境执行一切正常,线上偶发SQL执行缓慢的情况。

  • ③线上部署MySQL的机器故障,如磁盘、内存、CPU100%MySQL自身故障等。

1.1 线上排查和解决问题思路

相对而言,解决故障问题也好,处理性能瓶颈也罢,通常思路大致都是相同的,步骤如下:

  • ①分析问题:根据理论知识+经验分析问题,判断问题可能出现的位置或可能引起问题的原因,将目标缩小到一定范围。
  • ②排查问题:基于上一步的结果,从引发问题的“可疑性”角度出发,从高到低依次进行排查,进一步排除一些选项,将目标范围进一步缩小。
  • ③定位问题:通过相关的监控数据的辅助,以更“细粒度”的手段,将引发问题的原因定位到精准位置。
  • ④解决问题:判断到问题出现的具体位置以及引发的原因后,采取相关措施对问题加以解决。
  • ⑤尝试最优解(非必须):将原有的问题解决后,在能力范围内,且环境允许的情况下,应该适当考虑问题的最优解(可以从性能、拓展性、并发等角度出发)。

我的解决方案:

当然,上述过程是针对特殊问题以及经验老道的开发者而言的,作为“新时代的程序构建者”,那当然得学会合理使用工具来帮助我们快速解决问题:

  • ①摘取或复制问题的关键片段。
  • ②打开百度或谷歌后粘贴搜索。
  • ③观察返回结果中,选择标题与描述与自己问题较匹配的资料进入。
  • ④多看几个后,根据其解决方案尝试解决问题。
  • ⑤成功解决后皆大欢喜,尝试无果后“找人/问群”。
  • ⑥“外力”无法解决问题时自己动手,根据之前的步骤依次排查解决。

1.2 线上排查方向

==①发生问题的大体定位,②逐步推导出具体问题的位置==

  • 1.应用程序本身导致的问题

    • 程序内部频繁触发GC,造成系统出现长时间停顿,导致客户端堆积大量请求。
    • JVM参数配置不合理,导致线上运行失控,如堆内存、各内存区域太小等。【遇到启动项目OOM,在idea创建设置堆空间大小700到10000解决】
    • Java程序代码存在缺陷,导致线上运行出现Bug,如死锁/内存泄漏、溢出等。
    • 程序内部资源使用不合理,导致出现问题,如线程/DB连接/网络连接/堆外内存等。
  • 2.上下游内部系统导致的问题

    • 上游服务出现并发情况,导致当前程序请求量急剧增加,从而引发问题拖垮系统。
    • 下游服务出现问题,导致当前程序堆积大量请求拖垮系统,如Redis宕机/DB阻塞等。
  • 3.程序所部署的机器本身导致的问题

    • 服务器机房网络出现问题,导致网络出现阻塞、当前程序假死等故障。
    • 服务器中因其他程序原因、硬件问题、环境因素(如断电)等原因导致系统不可用。
    • 服务器因遭到入侵导致Java程序受到影响,如木马病毒/矿机、劫持脚本等。
  • 4.第三方的RPC远程调用导致的问题

    • 作为被调用者提供给第三方调用,第三方流量突增,导致当前程序负载过重出现问题。
    • 作为调用者调用第三方,但因第三方出现问题,引发雪崩问题而造成当前程序崩溃。

==——三大类错误排查——==

2.Sql语句执行出错—排查

作为一个程序员,对MySQL数据库而言,接触最多的就是SQL语句的撰写,和写业务代码时一样,写代码时会碰到异常、错误,而写SQL时同样如此,比如:

1
2
ERROR 1064 (42000):
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'xxxxxxx' at line 1

Mysql的错误信息会由三部分组成:

  • ErrorCode:错误码【1064这种】

  • SQLState:Sql状态【42000这种】

  • ErrorInfo:错误详情【在;之后跟一长串描述具体错误详情】

Mysql的错误类型:

  • 根据ErrorInfo位置根据错误类型定位,认真对准之后百度搜索
  • 没有定位,只能通过SQLState和网上办法解决

3.Mysql线上慢查询语句—排查

有些SQL可能在开发环境没有任何问题,但放到线上时就会出现偶发式执行耗时较长的情况,所以这类情况就只能真正在线上环境才能测出来,尤其是一些不支持灰度发布的中小企业,也只能放到线上测才能发现问题。

3.1 打开Mysql慢查询日志

一般在上线前,Mysql手动打开慢查询日志:

1
2
3
4
5
6
7
8
开启慢查询日志需要配置两个关键参数:
• slow_query_log:取值为on/off[默认]-----项目上线前需要手动开启。
• long_query_time:指定记录慢查询日志的阈值,[单位是秒,指定更细粒度用小数表示]-----阈值根据不同的业务系统取值也不同

①设置一个大概值,灰度发布时走正式运营场景效果更好
②开启查询日志,压测所有业务,紧接着分析查询日志中sql的平均耗时,再根据正常的sql执行时间,设置一个偏大的慢查询阈值即可

---公司内设置的是3s

3.2 查看Mysql慢查询日志

查看慢查询日志的方式:

  • 拥有完善的监控系统:【自动】读取磁盘中的慢查询日志,然后可以通过监控系统大屏观察
  • 未拥有完善的监控系统:linux系统通过cat类指令查看本地日志文件/windows记事本打开

image-20240710150212444

从上面日志中记录的查询信息来看,可以得知几个信息:

  • • 执行慢查询SQL的用户:root,登录IP为:localhost[127.0.0.1]
  • • 慢查询执行的具体耗时为:0.014960s,锁等待时间为0s
  • • 本次SQL执行后的结果集为4行数据,累计扫描6行数据。
  • • 本次慢查询发生在db_zhuzi这个库中,发生时间为1667466932(2022-11-03 17:15:32)
  • • 最后一行为具体的慢查询SQL语句。

3.3 排查sql执行缓慢问题

通过3.2步骤我们读取慢查询日志后,能够精准定位到发生慢查询Sql的用户、客户端机器、执行耗时、锁阻塞耗时、结果集行数、扫描行数、发生的库和事件、具体的慢查询sql语句。

得到这些信息之后,其实排查引起慢查询的原因就通过以下步骤就可以:

  • ①根据本地慢查询日志文件中的记录,得到具体慢查询sql执行的相关信息
  • ②查看lock_time的耗时,判断本次执行缓慢是否由于并发事务导致的长时间阻塞【多半原因】
    • 2.1 如果是,是由于并发事务导致的长时间阻塞【并发事务抢占锁,造成当前事务长时间无法获取锁资源】,看到大量由于锁阻塞导致执行超过阈值,那就执行查看mysql锁状态,如果值都比较大意味着当前这个mysql节点承担的并发压力过大,急需mysql架构优化
    • 2.2 如果不是,通过①explain索引分析工具,先判断索引使用情况,找到那些执行计划中扫描行数过多、type=index/allSQL语句,尝试优化掉即可;②人肉排查法解决

一般来说在开发环境中没有问题的SQL语句,放到线上环境出现执行缓慢的情况,多半原因是由于并发事务抢占锁,造成当前事务长时间无法获取锁资源,因此导致当前事务执行的SQL出现超时,这种情况下需要去定位操作相同行数据的大事务,一般长时间的阻塞是由于大事务持有锁导致的,找出对应的大事务并拆解或优化掉即可。【基本就是操作相同行数据的大事务持有锁

3.3.1 长时间锁阻塞的排查方法[查看lock_time时间]

通过show status like 'innodb_row_lock_%';命令可以查询MySQL整体的锁状态,如下:

image-20240710171033106

  • Innodb_row_lock_current_waits:当前正在阻塞等待锁的事务数量。
  • Innodb_row_lock_time:MySQL启动到现在,所有事务总共阻塞等待的总时长。
  • Innodb_row_lock_time_avg:平均每次事务阻塞等待锁时,其平均阻塞时长。
  • Innodb_row_lock_time_maxMySQL启动至今,最长的一次阻塞时间。
  • Innodb_row_lock_waitsMySQL启动到现在,所有事务总共阻塞等待的总次数。

3.3.2 非锁阻塞的排查方法[explain/拆分语句]

  • 方法一:explain解释方法:

找到那些执行计划中扫描行数过多、type=index/allSQL语句,尝试优化掉即可

image-20240710171706075

select_type字段:展示查询的类型(简单查询,联合查询,子查询等)—可以往join连接,避免子查询

partitions字段:展示查询涉及的分区(mysql5.1引出的,解决单表问题)—-可以往分区上优化

type字段:展示链接类型,反映mysql如何查找表的行—可以往system(系统表查询)/const(主键/唯一索引)

possible_keys和key字段:mysql认为可以使用的索引和实际使用的索引—可以往一致优化

filtered字段:mysql认为where符合(返回结果的行数/总行数)的百分比—可以往100%优化[where字段优化]

extra字段:一些额外操作—可以往①Using index索引覆盖;②Using where使用where子句过滤行

  • 方法二:人肉排查法:

【对于一些较为复杂或庞大的业务需求,可以采取拆分法去逐步实现,最后组装所有的子语句,最终推导出符合业务需求的SQL语句】

一条复杂的查询语句,拆解成一条条子语句,对每条子语句使用explain工具分析,精准定位到:复杂语句中导致耗时较长的具体子语句,最后将这条子语句优化后重新组装即可。

【拆解排除法有一个最大的好处是:有时组成复杂SQL的每条子语句都不存在问题,也就是每条子语句的执行效率都挺不错的,但是拼到一起之后就会出现执行缓慢的现象,这时拆解后就可以一步步的将每条子语句组装回去,每组装一条子语句都可以用explain工具分析一次,这样也能够精准定位到是由于那条子语句组合之后导致执行缓慢的,然后进行对应优化即可。】

4.Mysql线上机器故障排查

MySQL数据库线上的机器故障主要分为两方面,①是由于MySQL自身引起的问题,比如连接异常、死锁问题等,②是部署MySQL的服务器硬件文件,如磁盘、CPU100%等现象,对于不同的故障问题排查手段也不同,下面将展开聊一聊常见的线上故障及解决方案。

4.1 客户端连接异常

当数据库出现连接异常时,基本上就是因为四种原因导致:

【①②比较简单,设置两者参数就行】

  • ①数据库总体的现有连接数,超出了MySQL中的最大连接数,此时再出现新连接时会出异常。【遇到过,直接更新参数,加大核心线程数即可】
  • ②客户端数据库连接池与MySQL版本不匹配,或超时时间过小,也可能导致出现连接中断。

【③④比较特殊】

  • MySQL、Java程序所部署的机器不位于同一个网段,两台机器之间网络存在通信故障。
  • ④部署MySQL的机器资源被耗尽,如CPU、硬盘过高,导致MySQL没有资源分配给新连接。

其中,介绍一下③④情况:

MySQL、Java程序所部署的机器不位于同一个网段,两台机器之间网络存在通信故障

这种情况,问题一般都出在交换机上面,由于Java程序和数据库两者不在同一个网段,所以相互之间通信需要利用交换机来完成,但默认情况下,交换机和防火墙一般会认为时间超过3~5分钟的连接是不正常的,因此就会中断相应的连接,而有些低版本的数据库连接池,如Druid只会在获取连接时检测连接是否有效,此时就会出现一个问题:

交换机把两个网段之间的长连接嘎了,但是Druid因为只在最开始检测了一次,后续不会继续检测连接是否有效,所以会认为获取连接后是一直有效的,最终就导致了数据库连接出现异常(后续高版本的Druid修复了该问题,可以配置间隔一段时间检测一次连接

一般如果是由于网络导致出现连接异常,通常排查方向如下:

  • • 检测防火墙与安全组的端口是否开放,或与外网机器是否做了端口映射。
  • • 检查部署MySQL的服务器白名单,以及登录的用户IP限制,可能是IP不在白名单范围内。
  • • 如果整个系统各节点部署的网段不同,检查各网段之间交换机的连接超时时间是多少。
  • • 检查不同网段之间的网络带宽大小,以及具体的带宽使用情况,有时因带宽占满也会出现问题。
  • • 如果用了MyCat、MySQL-Proxy这类代理中间件,记得检查中间件的白名单、超时时间配置。

一般来说上述各方面都不存在问题,基本上连接异常应该不是由于网络导致的问题,要做更为细致的排查,可以在请求链路的各节点上,使用网络抓包工具,抓取对应的网络包,看看网络包是否能够抵达每个节点,如果每个节点的出入站都正常,此时就可以排除掉网络方面的原因。

④部署MySQL的机器资源被耗尽,如CPU、硬盘过高,导致MySQL没有资源分配给新连接。

这种情况更为特殊,网络正常、连接数未满、连接未超时、数据库和客户端连接池配置正常….,在一切正常的情况下,有时候照样出现连接不上MySQL的情况咋整呢?在这种情况下基本上会陷入僵局,这时你可以去查一下部署MySQL服务的机器,其硬件的使用情况,如CPU、内存、磁盘等,如果其中一项达到了100%,这时就能够确定问题了!

1
因为数据库连接的本质,在MySQL内部是一条条的工作线程,要牢记的一点是:操作系统在创建一条线程时,都需要为其分配相关的资源,如果一个客户端尝试与数据库建立新的连接时,此刻正好有一个数据库连接在执行某个操作,导致CPU被打满,这时就会由于没有资源来创建新的线程,因此会向客户端直接返回连接异常的信息。

先找到导致资源耗尽的连接/线程,然后找到它当时正在执行的SQL语句,最后需要优化相应的SQL语句后才能彻底根治问题。

4.2 Mysql死锁频发[查看innodb存储引擎运行状态日志]

MySQL内部其实会【默认】开启死锁检测算法,当运行期间出现死锁问题时,会主动介入并解除死锁,但要记住:虽然数据库能够主动介入解除死锁问题,但这种方法治标不治本因为死锁现象是由于业务不合理造成的,能出现一次死锁问题,自然后续也可能会多次出现,因此优化业务才是最好的选择,这样才能根治死锁问题。

从业务上解决死锁问题:①先定准定位到产生死锁的SQL语句,根据查看innodb存储引擎的运行状态日志【找到内部latest detected deadlock区域日志】

例如:
image-20240710183226640

在上面的日志中,基本上已经写的很清楚了,在2022-11-04 23:04:34这个时间点上,检测到了一个死锁出现,该死锁主要由两个事务产生,SQL如下:

  • (1):UPDATEzz_accountSET balance = balance + 888 WHERE user_name = "熊猫";
  • (2):UPDATEzz_accountSET balance = balance + 666 WHERE user_name = "竹子";

在事务信息除开列出了导致死锁的SQL语句外,还给出了两个事务对应的线程ID、登录的用户和IP、事务的存活时间与系统线程ID、持有的锁信息与等待的锁信息….

除开两个发生死锁的事务信息外,倒数第二段落还给出了两个事务在哪个锁上产生了冲突,以上述日志为例,发生死锁冲突的地点位于db_zhuzi库中zz_account表的主键上,两个事务都在尝试获取对方持有的X排他锁,后面还给出了具体的页位置、内存地址….。

最后一条信息中,给出了MySQL介入解除死锁的方案,也就是回滚了事务(2)的操作,强制结束了事务(2)并释放了其持有的锁资源,从而能够让事务(1)继续运行。

经过查看上述日志后,其实MySQL已经为我们记录了产生死锁的事务、线程、SQL、时间、地点等各类信息,因此想要彻底解决死锁问题的方案也很简单了,根据日志中给出的信息,去找到执行相应SQL的业务和库表,优化SQL语句的执行顺序,或SQL的执行逻辑,从而避免死锁产生即可。

最后要注意:如果是一些偶发类的死锁问题,也就是很少出现的死锁现象,其实不解决也行,毕竟只有在一些特殊场景下才有可能触发,重点是要关注死锁日志中那些频繁出现的死锁问题,也就是多次死锁时,每次死锁出现的库、表、字段都相同,这种情况时需要额外重视并着手解决。

4.3 服务器CPU100%[两种思路]

可能出现两种情况:

  • ①业务活动:突然大量流量进来活动后cpu占用率就会下降

  • ②cpu长期占用率过高:程序有那种循环次数超级多,甚至出现死循环

排查思路:

  • ①先找到CPU过高的服务器

  • ②然后在其中定位到具体的进程。【top指令】

  • ③再定位到进程中具体的线程。【top -Hp xxxx】 xxxx就是②查出来的PID进程号

  • ④再查看线程正在执行的代码逻辑–会显示线程是属于Java/Mysql

    • 4.1 Java层面:该线程的PID转换为16进制,然后进一步排查日志grep 查询接口信息
    • 4.2 Mysql层面:mysql5.7以下查找innodb运行状态日志的某个部分/mysql5.7以上通过threads表信息查找】
  • ⑤最后从代码层面着手优化掉即可。

②先使用top指令查看系统后台的进程状态:

image-20240710183927468

从如上结果中不难发现,PID76661MySQL进程对CPU的占用率达到99.9%,此时就可以确定,机器的CPU利用率飙升是由于该进程引起的。

③根据top -Hp [PID]指令查看进程中cpu占用率最高的线程:

image-20240710184121817

top -Hp 76661命令的执行结果中可以看出:其他线程均为休眠状态,并未持有CPU资源,而PID为77935的线程对CPU资源的占用率却高达99.9%

到此时,导致CPU利用率飙升的“罪魁祸首”已经浮现水面,但此时问题来了!在如果这里是Java程序,此时可以先将该线程的PID转换为16进制的值,然后进一步排查日志信息来确定具体线程执行的业务方法。但此时这里是MySQL程序,咱们得到了操作系统层面的线程ID后,如何根据这个IDMySQL中找到对应的线程呢?

④分为Mysql5.7以上和Mysql5.7以下两种情况:

  • MySQL5.7及以上的版本中,MySQL会自带一个名为performance_schema的库,在其中有一张名为threads的表,其中表中有一个thread_os_id字段,其中会保存每个连接/工作线程与操作系统线程之间的关系(在5.7以下的版本是隐式的,存在于MySQL内部无法查看)。

image-20240710185143259

可以通过查询threads表,输出所有已经创建的线程:【select查询–对应processlist_info就是对应的sql语句】

image-20240710185225772

从上述中可以明显看出MySQL线程和OS线程之间的关系,当通过前面的top指令拿到CPU利用率最高的线程ID后,在再这里找到与之对应的MySQL线程,同时也能够看到此线程正在执行的SQL语句,最后优化对应SQL语句的逻辑即可。

  • MySQL5.7以下的版本中,我们只能通过Innodb存储引擎状态表的transactions板块查看,

统计着所有存活事务的信息,此时也可以从中得到相应的OS线程、MySQL线程的映射关系

image-20240710185424434

是这种方式仅能够获取到OS线程、MySQL线程之间的映射关系,无法获取到对应线程/连接正在执行的SQL语句,此时如果线程还在运行,则可以通过show processlist;查询,如下:

image-20240710185448934

但这种方式只能看到正在执行的SQL语句,无法查询到最近执行过的语句,所以这种方式仅适用于:==线上SQL还在继续跑的情况==。

4.4 Mysql刷盘100%

指磁盘IO达到100%利用率,这种情况下一般会导致其他读写操作都被阻塞,因为操作系统中的IO总线会被占满,无法让给其他线程来读写数据,先来总结一下出现磁盘IO占用过高的原因:

  • • ①突然大批量变更库中数据,需要执行大量写入操作,如主从数据同步时就会出现这个问题。
  • • ②MySQL处理的整体并发过高,磁盘I/O频率跟不上,比如是机械硬盘材质,读写速率过慢。
  • • ③内存中的BufferPool缓冲池过小,大量读写操作需要落入磁盘处理,导致磁盘利用率过高。
  • • ④频繁创建和销毁临时表,导致内存无法存储临时表数据,因而转到磁盘存储,导致磁盘飙升。
  • • ⑤执行某些SQL时从磁盘加载海量数据,如超12张表的联查,并每张表数据较大,最终导致IO打满。
  • • ⑥日志刷盘频率过高,其实这条是①、②的附带情况,毕竟日志的刷盘频率,跟整体并发直接挂钩。

一般情况下,磁盘IO利用率居高不下,甚至超过100%,基本上是由于上述几个原因造成的,当需要排查磁盘IO占用率过高的问题时,可以先通过iotop工具找到磁盘IO开销最大的线程,然后利用pstack工具查看其堆栈信息,从堆栈信息来判断具体是啥原因导致的,如果是并发过高,则需要优化整体架构。如果是执行SQL加载数据过大,需要优化SQL语句……

磁盘利用率过高的问题其实也比较好解决,方案如下:

  • • ①如果磁盘不是SSD材质,请先将磁盘升级成固态硬盘,MySQLSSD硬盘做了特殊优化。
  • • ②在项目中记得引入Redis降低读压力,引入MQ对写操作做流量削峰。
  • • ③调大内存中BufferPool缓冲池的大小,最好设置成机器内存的70~75%左右。
  • • ④撰写SQL语句时尽量减少多张大表联查,不要频繁的使用和销毁临时表。

基本上把上述工作都做好后,线上也不会出现磁盘IO占用过高的问题,对于前面说到的:利用iotop、pstack工具排查的过程,就不再做实际演示了,其过程与前面排查CPU占用率过高的步骤类似,大家学习iotop、pstack两个工具的用法后,其实实操起来也十分简单。

Elasticsearch-黑马商城为例

1.启动ES

1.1 安装elasticsearch

通过下面的Docker命令即可安装单机版本的elasticsearch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#先在tar所在目录下打开cmd
docker load -i es.tar

#创建一个网络【不然kibana不能连接es,踩坑了!!】
docker network create elastic

#黑马安装:
docker run -d \
--name es \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ #配置jvm的内存
-e "discovery.type=single-node" \ #配置运行模式【单点模式/集群模式】
-v es-data:/usr/share/elasticsearch/data \ #挂载
-v es-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network hm-net \
-p 9200:9200 \ #访问http端口
-p 9300:9300 \ #集群使用
elasticsearch:7.12.1

#csdn安装:
docker run -d --name es -e ES_JAVA_OPTS="-Xms512m -Xmx512m" -e "discovery.type=single-node" --privileged --network elastic -p 9200:9200 -p 9300:9300 elasticsearch:7.12.1

启动之后访问http://localhost:9200/就可以看到elasticsearch信息:

image-20240507204417602

1.2 安装Kibana

通过下面的Docker命令,即可部署Kibana:

1
2
3
4
5
6
7
8
9
10
11
12
13
#先在tar所在目录下打开cmd
docker load -i kibana.tar

#黑马安装:
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \ #es的地址,这里的es要和es配置docker的时候--name一致
--network=hm-net \ #网络和es一个网络
-p 5601:5601 \
kibana:7.12.1 #要保证和es版本一致!!!

#csdn安装:
docker run -d --name kibana -e ELASTICSEARCH_HOSTS=http://es:9200 --network elastic -p 5601:5601 kibana:7.12.1

启动之后访问http://localhost:5601/就可以通过kibana数据化访问elasticsearch:

image-20240507204635028

可以点击右上角Dev tools,进入开发工具页面:

image-20240507204914788

点击之后:

image-20240507205135009

2.改造操作步骤

在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为RestHighLevelClient的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。

2.1 初始化RestClient

2.1.1 引入RestHighLevelClient依赖

在微服务模块中引入esRestHighLevelClient依赖:

1
2
3
4
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>

2.1.2 覆盖ES版本

因为SpringBoot默认的ES版本是7.17.10,所以我们需要覆盖默认的ES版本【黑马商城是在pom.xml中修改】:

1
2
3
4
5
6
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<!--覆盖成7.12.1-->
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>

2.1.3 初始化RestHighLevelClient

1
2
3
4
5
6
RestHighLevelClient client = new RestHighLevelClient(
//使用RestClient的builder方法创建
RestClient.builder(
HttpHost.create("http://192.168.xxx.xxx:9200")
)
);

2.2 分析Mysql设计ES实现

我们针对购物车数据库进行分析:

image-20240520172813812

我们可以对购物车的所有字段进行分析,判断哪些字段必须添加到ElasticSearch中,判断哪些字段必须添加搜索功能。从而进行新建索引库和映射:

image-20240520171754450

具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
PUT /items
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name":{
"type": "text",
"analyzer": "ik_max_word"
},
"price":{
"type": "integer"
},
"stock":{
"type": "integer"
},
"image":{
"type": "keyword",
"index": false
},
"category":{
"type": "keyword"
},
"brand":{
"type": "keyword"
},
"sold":{
"type": "integer"
},
"commentCount":{
"type": "integer",
"index": false
},
"isAD":{
"type": "boolean"
},
"updateTime":{
"type": "date"
}
}
}
}

2.3 索引库操作(client.indices.xxx)

JavaRestClient操作elasticsearch的流程基本类似。核心是client.indices()方法来获取索引库的操作对象。

索引库操作的基本步骤:

  • 1.初始化RestHighLevelClient类对象client【创建客户端】
  • 2.创建XxxIndexRequest对象request【XXX是CreateGetDelete
  • 3.准备请求参数request.source()方法【只有新增Create需要参数,其他情况不需要】
  • 4.发送请求client.indices().xxx()方法【xxx是createexistsdelete

2.3.1 创建索引库

image-20240520173351287

2.3.2 删除索引库

image-20240521135115905

2.3.2 查询索引库

2.4 文档操作(client.xxx)

文档操作的基本步骤:

  • 1.初始化RestHighLevelClient类对象client【创建客户端】
  • 2.创建XxxRequest对象request【Xxx是IndexUpdateDeleteBulk
  • 3.准备请求参数request.source()方法(IndexUpdateBulk时需要)
  • 4.发送请求client.Xxx()方法【Xxx是indexgetupdatedeletebulk
  • 5.解析结果(Get查询时需要,数据在_source内部)

2.4.1 新增文档

  • 1.创建Request对象,这里是IndexRequest,因为添加文档就是创建倒排索引的过程
  • 2.准备请求参数,本例中就是Json文档
  • 3.发送请求【client.index()方法就好了】

image-20240521142712455

2.4.2 查询文档

与之前的流程类似,代码大概分2步:

  • 创建Request对象
  • 准备请求参数,这里是无参,【直接省略】
  • 发送请求
  • 解析结果【因为结果在_source部分内】

image-20240521142844007

可以看到,响应结果是一个JSON,其中文档放在一个_source属性中,因此解析就是拿到_source,反序列化为Java对象即可

2.4.3 删除文档

与查询相比,仅仅是请求方式从DELETE变成GET,可以想象Java代码应该依然是2步走:

  • 1)准备Request对象,因为是删除,这次是DeleteRequest对象。要指定索引库名和id
  • 2)准备参数,无参,直接省略
  • 3)发送请求。因为是删除,所以是client.delete()方法

image-20240521143043972

2.4.4 修改文档

修改我们讲过两种方式:

  • 全量修改:本质是先根据id删除,再新增【与新增文档】
  • 局部修改:修改文档中的指定字段值

在RestClient的API中,全量修改与新增的API完全一致,判断依据是ID:

  • 如果新增时,ID已经存在,则修改
  • 如果新增时,ID不存在,则新增

这里不再赘述,我们主要关注局部修改的API即可

image-20240521143147541

2.4.5 批量导入文档

因此BulkRequest中提供了add方法,用以添加其它CRUD的请求:

image-20240521144140401

具体代码:

image-20240521143955532

2.5 高级查询

文档搜索的基本步骤是:

  1. 创建SearchRequest对象实例request
  2. 准备request.source(),也就是DSL语句【这个位置可以创建查询,分页,排序,聚合,高亮等操作】
    1. QueryBuilders来构建查询条件
    2. 传入request.source()query()方法
  3. 发送请求,得到结果
  4. 解析结果(参考DSL查询得到的JSON结果,从外到内,逐层解析)

2.5.1 查询数据

我们可以分三步拼凑DSL语句和发起请求获取相应结果:

image-20240522172046658

其中2.组织DSL参数的步骤中source()方法下面对应的查询/高亮/分页/排序/聚合:
image-20240522172832347

在查询方面我们直接可以通过QueryBuilders类调用对应的叶子查询/复杂查询

image-20240522172921305

2.5.2 解析数据

我们可以通过响应结果和Elasticsearch页面返回结果获取具体细节: 【可以扩展很多,但其实就是对照DSL查询结果写

image-20240522173851593

黑马的图:

image-20240522173920457

3.代码实现思路

==基础操作==

  • 1.引入RestHighLevelClient依赖

  • 2.初始化RestHighLevelClient

1
2
3
4
5
RestHighLevelClient client = new RestHighLevelClient(
RestClient.builder(
HttpHost.create("http://192.168.xxx.xxx:9200") //使用RestClient的builder方法创建
)
);
  • 3.针对索引库(数据库表)操作【创建,查询,修改,删除】
1
2
3
4
5
索引库操作的基本步骤:
- 1.初始化RestHighLevelClient类对象client【创建客户端】
- 2.创建XxxIndexRequest对象request【XXX是`Create`、`Get`、`Delete`】
- 3.准备请求参数request.source()方法【只有新增`Create`需要参数,其他情况不需要】
- 4.发送请求client.indices().xxx()方法【xxx是`create`、`exists`、`delete`】
  • 4.针对文档(每一行数据)操作【创建,查询,修改,删除】
1
2
3
4
5
6
文档操作的基本步骤:
- 1.初始化RestHighLevelClient类对象client【创建客户端】
- 2.创建XxxRequest对象request【Xxx是`Index`、`Update`、`Delete`、`Bulk`】
- 3.准备请求参数request.source()方法(`Index`、`Update`、`Bulk`时需要)
- 4.发送请求client.Xxx()方法【Xxx是`index`、`get`、`update`、`delete`、`bulk`】
- 5.解析结果(`Get`查询时需要,数据在_source内部)

==高级操作(复杂的DSL查询)==

5.在具体位置就可以进行复杂的DSL查询【可以进行查询,分页,排序,高亮,聚合等操作】

1
2
3
4
5
6
7
文档搜索的基本步骤是:
1. 创建`SearchRequest`对象实例request
2. 准备`request.source()`,也就是DSL语句【这个位置可以创建查询,分页,排序,聚合,高亮等操作】
1. `QueryBuilders`来构建查询条件
2. 传入`request.source()` 的` query() `方法
3. 发送请求,得到结果
4. 解析结果(参考DSL查询得到的JSON结果,从外到内,逐层解析)
,