XXL-Job分布式任务调度

1.提出场景

目前,我们的定时任务都是基于SpringTask来实现的。但是SpringTask存在一些问题:

  • 当微服务多实例部署时,定时任务会被执行多次。而事实上我们只需要这个任务被执行一次即可。
  • 我们除了要定时创建表,还要定时持久化Redis数据到数据库,我们希望这多个定时任务能够按照顺序依次执行,SpringTask无法控制任务顺序(×)

不仅仅是SpringTask,其它单机使用的定时任务工具,都无法实现像这种任务执行者的调度、任务执行顺序的编排、任务监控等功能。这些功能必须要用到分布式任务调度组件。

2.原理[统一管理]

我们先来看看普通定时任务的实现原理,一般定时任务中会有两个组件:

  • 任务:要执行的代码
  • 任务触发器:基于定义好的规则触发任务

因此在多实例部署的时候,每个启动的服务实例都会有自己的任务触发器,这样就会导致各个实例各自运行,无法统一控制:

image-20240830171522168

如果我们想统一控制各服务实例的任务执行和调度—>统一控制[统一出发、统一调度]

事实上,大多数的分布式任务调度组件都是这样做的:

image-20240830172305858

这样一来,具体哪个任务该执行,什么时候执行,交给哪个应用实例来执行,全部都有统一的任务调度服务来统一控制。并且执行过程中的任务结果还可以通过回调接口返回,让我们方便的查看任务执行状态、执行日志。这样的服务就是分布式调度服务

3.技术对比

能够实现分布式任务调度的技术有很多,常见的有:【越往右越牛逼】

Quartz XXL-Job SchedulerX PowerJob
定时类型 CRON 频率、间隔、CRON 频率、间隔、CRON、OpenAPI 频率、间隔、CRON、OpenAPI
任务类型 Java 多语言脚本 多语言脚本 多语言脚本
任务调度方式 随机 单机、分片 单机、广播、Map、MapReduce 单机、广播、分片、Map、MapReduce
管理控制台 支持 支持 支持
日志白屏 支持 支持 支持
报警监控 支持 支持 支持
工作流 有限 支持 支持

其中:

  • Quartz由于功能相对比较落后,现在已经很少被使用了。
  • SchedulerX是阿里巴巴的云产品,收费。
  • PowerJob是阿里员工自己开源的一个组件,功能非常强大,不过目前市值占比还不高,还需要等待市场检验。
  • XXL-JOB:开源免费,功能虽然不如PowerJob,不过目前市场占比最高,稳定性有保证。

扩展:多语言脚本–通过xxl-job平台,新增调度任务时候可以选择任务的运行模式【使用不同脚本语言编写任务】

==———–XXL-Job———–==

1.XXL-Job介绍

image-20240829165652242

XXL-JOB分为两部分:

  • 执行器:我们的服务引入一个XXL-JOB的依赖,就可以通过配置创建一个执行器。负责与XXL-JOB调度中心交互,执行本地任务。
  • 调度中心:一个独立服务,负责管理执行器、管理任务、任务执行的调度、任务结果和日志收集。

其中,我们可以打开xxl-job页面:

  • 页面:
image-20240829165442027

2.XXL-Job部署[调度中心]

自己部署,分为两步:

  • 运行初始化SQL,创建数据库表
  • 利用Docker命令,创建并运行容器

2.1 创建数据库表

sql语句在对应github文件夹下:

image-20240830173817398

2.2 Docker部署

docker命令:

1
2
3
4
5
6
7
8
9
10
docker run \
-e PARAMS="--spring.datasource.url=jdbc:mysql://192.168.150.101:3306/xxl_job?Unicode=true&characterEncoding=UTF-8 \
--spring.datasource.username=root \
--spring.datasource.password=123" \
--restart=always \
-p 28080:8080 \
-v xxl-job-admin-applogs:/data/applogs \
--name xxl-job-admin \
-d \
xuxueli/xxl-job-admin:2.3.0

3.XXL-Job实战

3.1 需求

每10s打印一次hello…

3.2 实现步骤

image-20240829170153373

3.2.1 引入xxl-job依赖(微服务)

image-20240829170654201

1
2
3
4
5
<!--xxl-job-->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
</dependency>

3.2.2 yml配置xxl-job(微服务)

  • 配置文件内容【nacos共享文件地址】

image-20240829170954454

  • 配置文件【因为我们使用nacos配置,所以需要bootstrap.yml配置nacos共享文件地址】
image-20240829171353854
  • 配置类
image-20240829172008280

3.2.3 配置执行器类–XxlJobConfig(微服务)

image-20240829172105476
1
2
3
4
5
6
- adminAddress:调度中心地址,天机学堂中就是填虚拟机地址
- appname:微服务名称
- ip和port:当前执行器的ip和端口,无需配置,自动获取
- accessToken:访问令牌,在调度中心中配置令牌,所有执行器访问时都必须携带该令牌,否则无法访问【修改虚拟机的/usr/local/src/xxl-job/application.properties文件中,修改xxl.job.accessToken属性,然后重启XXL-JOB即可】
- logPath:任务运行日志的保存目录
- logRetentionDays:日志最长保留时长

3.2.4 配置执行器和任务(页面)

  • 新建执行器【微服务名称和yml配置一致】

image-20240829172534332

  • 新建任务【JobHandler一定要和方法@XXlJob内容一致】
image-20240829173240249

3.2.5 创建方法–添加@XxlJob注解(微服务)

JobHandler一定要和方法@XXlJob内容一致

image-20240829173755449

3.2.6 启动测试(页面)

  • 启动本地微服务,会发现网页端执行器变化

image-20240829173441228

  • 启动任务

【想要测试的话也可以手动执行一次任务,但是要设置好调度策略】

image-20240829173930400

也可以在页面看到日志:

image-20240829174046001

4.XXL-Job任务分片[不同部署处理不同数据]

3.1 原理

刚才定义的定时持久化任务,通过while死循环,不停的查询数据,直到把所有数据都持久化为止。这样如果数据量达到数百万,交给一个任务执行器来处理会耗费非常多时间—->实例多个部署,这样就会有多个执行器并行执行(但是多个执行器执行相同代码,都从第一页开始也会重复处理)—->任务分片分片查询

举例[类似于发牌]:

image-20240830164322338

最终,每个执行器处理的数据页情况:

  • 执行器1:处理第1、4、7、10、13、…页数据
  • 执行器2:处理第2、5、8、11、14、…页数据
  • 执行器3:处理第3、6、9、12、15、…页数据

要想知道每一个执行器执行哪些页数据,只要弄清楚两个关键参数即可:

  • 起始页码(1,2,3):pageNo【执行器编号是多少,起始页码就是多少】
  • 下一页的跨度(步长3):step【执行器有几个,跨度就是多少。也就是说你要跳过别人读取过的页码,类似于分布式ID的步长】

因此,现在的关键就是获取两个数据:

  • 执行器编号
  • 执行器数量

这两个参数XXL-JOB作为任务调度中心,肯定是知道的,而且也提供了API帮助我们获取:

image-20240830164735039

这里的分片序号其实就是执行器序号,不过是从0开始,那我们只要对序号+1,就可以作为起始页码了

3.2 业务优化

根据实际情况,分成多个机器[这个用例,分片1,2,3;步长为1]

image-20240830164953405

3.3 引发问题解决方案

使用xxl-job定时每月初进行持久化:

①根据计算上个月时间创建上赛季mysql表

②根据查询出来上赛季redis数据,数据库新表名通过mp动态表名插件(本质是一个拦截器,在与mapper数据库接触过程中通过threadlocal更改数据库名)】然后查询数据
③根据非阻塞语句del删除redis上赛季数据—但是我考虑使用分片,这样导致分片1执行完异步执行删除,但是分片2执行完数据好像又回来了【针对②查询结果分页用xxlJob分片,log查日志没解决,我就打断点发现是分片次数问题,我就redis添加一个总数,一个分片执行次数,然后将删除逻辑放在一个新的定时任务,判断总数==分片执行次数,符合的情况才删除】

海量数据存储策略

1.海量数据存储策略[四种]

image-20240829104320737

1.1 分区

表分区(Partition)是一种数据存储方案,可以解决单表数据较多的问题【MySQL5.1开始支持表分区功能】

image-20240829144303567

如果表数据过多 —> 文件体积非常大 —> 文件跨越多个磁盘分区 —> 数据检索时的速度就会非常慢 —>【Mysql5.1引入表分区】按照某种规则,把表数据对应的ibd文件拆分成多个文件来存储。

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

  • 从逻辑上来看,他们对外表现是一张表【一张表】 — CRUD不会变化,只是底层MySQL处理上会有变更,检索时可以只检索某个文件就可以

例如,我们的历史榜单数据,可以按照赛季切分:

image-20240829144756927

此时,赛季榜单表的磁盘文件就被分成了两个文件,但逻辑上还是一张表。CRUD不会变化,只是底层MySQL处理上会有变更,检索时可以只检索某个文件就可以

  • 表分区的好处:

    • 1.可以存储更多的数据,突破单表上限。甚至可以存储到不同磁盘,突破磁盘上限

    • 2.查询时可以根据规则只检索某一个文件,提高查询效率

    • 3.数据统计时,可以多文件并行统计,最后汇总结果,提高统计效率【分而治之,各自统计】

    • 4.对于一些历史数据,如果不需要时,可以直接删除分区文件,提高删除效率

  • 表分区的方式:【对数据做水平拆分

    • Range分区:按照指定字段的取值范围分区 –保单表,根据1-10,11-20这样10个为一组区分
    • List分区:按照指定字段的枚举值分区【必须提前制定所有分区值,否则会因为找不到报错】–保单表,根据保单是车险财/非车进行区分
    • Hash分区:按照字段做hash运算后分区【字段一般是对数值类型】 –保单表,根据保单表%hash运算进行区分
    • Key分区:按照指定字段的值做运算结果分区【不限定字段类型】 –保单表,根据保单号%9进行区分

1.2 分表

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

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

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

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

  • 分表的好处:

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

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

  • 分表的坏处:

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

1.2.1 水平分表

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

image-20240829150144571

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

1.2.2 垂直分表

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

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

image-20240829150258884

1.3 分库[垂直分库]

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

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

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

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

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

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

    • 3.避免了单点故障

  • 缺点:

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

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

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

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

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

image-20240829151509972

1.4 集群[主写从读]

[保证单节点的高可用性]给数据库建立主从集群,主节点向从节点同步数据,两者结构一样

image-20240829151628463

东林在线微课堂-积分系统

==—————-签到功能—————-==

为了激励东林学子学习,可以设定一个学习积分的排行榜。优秀的学子可以给予优惠券

image-20240827112718773

这个页面信息比较密集,从上往下来看分三部分:

  • 顶部:当前用户在榜单中的信息
  • 中部:签到表
  • 下部:分为左右两侧
    • 左侧:用户当天获取的积分明细
    • 右侧:榜单

准备阶段—分析业务流程

准备阶段—字段分析

签到最核心的包含两个要素:

  • 谁签到:用户id
  • 什么时候签的:签到日期

同时要考虑一些功能要素,比如:

  • 补签功能,所以要有补签标示
  • 按照年、月统计的功能:所以签到日期可以按照年、月、日分离保存

准备阶段—ER图

image-20240827113203866

准备阶段—表结构

1
2
3
4
5
6
7
8
9
 CREATE TABLE `sign_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint NOT NULL COMMENT '用户id',
`year` year NOT NULL COMMENT '签到年份',
`month` tinyint NOT NULL COMMENT '签到月份',
`date` date NOT NULL COMMENT '签到日期',
`is_backup` bit(1) NOT NULL COMMENT '是否补签',
PRIMARY KEY (`id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到记录表';

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

无【基于redis做的,没有mysql数据库表】

准备阶段–类型枚举

准备阶段–接口统计

回到个人积分页面,在页面中部有一个签到表:

image-20240827112902867

可以看到这就是一个日历,对应了每一天的签到情况。日历中当天的日期会高亮显示为《打卡》状态,点击即可完成当日打卡,服务端自然要记录打卡情况。

因此这里就有一个接口需要实现:①签到接口

除此以外,可以看到本月第一天到今天为止的所有打卡日期也都高亮显示标记出来了。也就是说页面还需要知道本月到今天为止每一天的打卡情况。这样对于了一个接口:②查询本月签到记录

image-20240827113045503

准备阶段–redis的bitMap

1.基础知识

Redis 的 Bitmap(位图)是一种特殊的字符串数据类型,它利用字符串类型键(key)来存储一系列连续的二进制位(bits),每个位可以独立地表示一个布尔值(0 或 1)。这种数据结构非常适合用于存储和操作大量二值状态的数据,尤其在需要高效空间利用率和特定位操作场景中表现出色。

2.常用命令

我们可以使用setbit getbit bitcount bitfield四个指令:

image-20240827100228847

1
2
3
4
5
6
7
8
# 签到/取消签到【给某人的某某年某某月为一个位图】 0就是偏移量【第一天】 1【1就是签到/0是不签到】
setbit YYYYMM:userId 0 1
# 获取某一天【0是第一天】的签到情况
getbit YYYYMM:userId 0
# 获取某时间段【第一天-第二十九天】内签到的总天数 ---是1的总数
bitcount YYYYMM:userId 0 30
# 获取某时间段【第一天-第二十天】签到的所有情况 u表示无符号/i表示有符号 ---得到的是一个十进制数
bitfield YYYYMM:userId get u21 0

3.bitMap扩展

基础类型:Redis最基础的数据类型只有5种:String、List、Set、SortedSet、Hash【其它特殊数据结构大多都是基于以上5这种数据类型】

BitMap基于String结构【String类型底层是SDS,会有一个字节数组用来保存数据。而Redis就提供了几个按位操作这个数组中数据的命令,实现了BitMap效果】

由于String类型的最大空间是512MB=2的31次幂个bit,因此可以存储的数据量级很大!!!【一个月才是31bit,四个字节】–> ==bitMap扩容是8个字节一组==

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

1.签到

1.原型图

在个人中心的积分页面,用户每天都可以签到一次,连续签到则有积分奖励,请实现签到接口,记录用户每天签到信息,方便做签到统计。

在个人中心的积分页面,用户每天都可以签到一次:

image-20240827115636260

而在后台,要做的事情就是把BitMap中的与签到日期对应的bit位,设置为1

2.设计数据库

3.业务逻辑图

  • mysql设计:【占用空间大】

    我们设计了签到功能对应的数据库表:sign_record[主键id,用户id,签到年月日,是否可以补签]。这张表的一条记录就是一个用户一次的登录记录。如果一个用户一年签到100次,那就是100条记录,如果有100w用户,就会产生一亿条记录。—->占用空间会越来越大

  • Redis设计:【只需要存储一个用户是否签到,0未签到,1签到】—>使用bitMap

​ 如果我们按月来统计用户签到信息,签到记为1,未签到记为0,就可以用一个长度为31位的二进制数来表示一个用户一个月的签到情况。最终效果如下:

image-20240827114010026

我们知道二进制是计算机底层最基础的存储方式了,其中的每一位数字就是计算机信息量的最小单位了,称之为bit,一个月最多也就 31 天,因此一个月的签到记录最多也就使用 31 bit 就能保存了,还不到 4 个字节【mysql数据库就要使用数百字节】

4.接口分析

image-20240827115840411

5.具体实现

  • 1.controller层

image-20240827115942060

  • 2.service层

image-20240827115949449

  • 3.serviceimpl层
image-20240827130422747
  • 4.mapper层

6.具体难点和亮点

  • 问题一:如何涉及签到的数据类型?为什么选redis的bitMap?怎么扩容的?一次扩充8位

考虑签到只需要1/0,那就使用bitMap;然后YYMM:Userid就是一个bitMap【代表某人某年某月的登录】,一共设计31个bit位就可以代表一个月的签到数据;扩容的话是8位一组,一般一个月就是4组32位(最后一位暂时没有用)

  • 问题二:连续签到天数怎么计算?怎么从bitMap获取?怎么从后往前遍历?

连续登录天数:从当前天从后往前算连续1的个数【一定是从后往前】;从后往前就用算出来的十进制数&1做与运算【只关心最后一位结果】,然后右移十进制得到前面的一位

1
2
3
4
5
int count = 0; // 定义一个计数器
for(/*从后向前遍历签到记录中的每一个bit位*/){
// 判断是否是1
// 如果是,则count++
}

image-20240827125513753

  • 问题三:怎么判断重复签到?

    利用setbit返回值的特性

image-20240827125629493

  • 问题四:bitmap用哪些指令了?

    image-20240827130515836

2.查询我的本月签到记录

1.原型图

在签到日历中,需要把本月(第一天-今天)的所有签到过的日期高亮显示。

因此我们必须把签到记录返回,具体来说就是每一天是否签到的数据。是否签到,就是0或1,刚好在前端0和1代表false和true,也就是签到或没签到。

因此,每一天的签到结果就是一个0或1的数字,我们最终返回的结果是一个0或1组成的数组,对应从本月第1天到今天为止每一天的签到情况。

2.设计数据库

3.业务逻辑图

4.接口分析

综上,最终的接口如下:

image-20240827130734059

5.具体实现

  • 1.controller层

image-20240827130758330

  • 2.service层

image-20240827130814871

  • 3.serviceimpl层

image-20240828144807606

  • 4.mapper层

6.具体难点和亮点

  • 问题一:如何获取本月的登录记录?

    根据bitfield指令可以获得本月等登录记录(0001…0111001)的十进制数字

  • 问题二:如何转为二进制,并且统计转为byte数组?

    第一种办法,十进制转为字符串二进制,然后二进制char的for循环遍历得到byte[①必须-‘0’才是数字1,不然是ascii码的48;②因为十进制不是32位,转出来也不是32位!!!]

    第二种办法,按照统计连续天数的思路(10进制与1进行与运算,可以依次倒序取出所有的0/1,然后逆序一下就是结果)

image-20240828144627571

==——————————————==

==—————-积分功能—————-==

  • 积分:用户在天机学堂网站的各种交互行为都可以产生积分,积分值与行为类型有关
  • 学霸天梯榜:按照每个学员的总积分排序得到的排行榜,称为学霸天梯榜。排名前三的有奖励。天梯榜每个自然月为一个赛季,月初清零

准备阶段—分析业务流程

具体的积分获取细则如下:

1
2
3
4
5
6
7
8
9
10
1. 签到规则
连续7天奖励10分 连续14天 奖励20 连续28天奖励40分, 每月签到进度当月第一天重置【本篇文章的签到!!!!!!!】

2. 学习规则
每学习一小节,积分+10,每天获得上限50分

3. 交互规则(有效交互数据参与积分规则,无效数据会被删除)
- 写评价 积分+10
- 写问答 积分+5 每日获得上限为20分
- 写笔记 积分+3 每次被采集+2 每日获得上限为20分

用户获取积分的途径有5种:

  • 签到:在个人积分页面可以每日签到,每次签到得1分,连续签到有额外积分奖励。
  • 学习:也就是看视频
  • 写回答:就是给其他学员提问的问题回答,给回答做评论是没有积分的。
  • 写笔记:就是学习的过程中记录公开的学习笔记,分享给所有人看。或者你的笔记被人点赞。
  • 写评价:对你学习过的课程评价,可以获取积分。但课程只能评价一次。

image-20240827112705701

这个页面信息比较密集,从上往下来看分三部分:

  • 顶部:当前用户在榜单中的信息
  • 中部:签到表
  • 下部:分为左右两侧
    • 左侧:用户当天获取的积分明细
    • 右侧:榜单

准备阶段—字段分析

积分记录的目的有两个:一个是统计用户当日某一种方式获取的积分是否达到上限;一个是统计积分排行榜。

要达成上述目的我们至少要记录下列信息:

  • 本次得到积分值
  • 积分方式
  • 获取积分时间
  • 获取积分的人

准备阶段—ER图

image-20240828160621560

准备阶段—表结构

1
2
3
4
5
6
7
8
9
10
CREATE TABLE IF NOT EXISTS `points_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '积分记录表id',
`user_id` bigint NOT NULL COMMENT '用户id',
`type` tinyint NOT NULL COMMENT '积分方式:1-课程学习,2-每日签到,3-课程问答, 4-课程笔记,5-课程评价',
`points` tinyint NOT NULL COMMENT '积分值',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_user_id` (`user_id`,`type`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学习积分记录,每个月底清零';

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

image-20240828155811966

准备阶段–类型枚举

针对数据库的积分类型字段:设计成枚举类型

image-20240828152847450

准备阶段–接口统计

image-20240828171205430

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

1.保存积分[MQ消费者]

1.原型图

由积分规则可知,获取积分的行为多种多样,而且每一种行为都有自己的独立业务。而这些行为产生的时候需要保存一条积分明细到数据库。

我们显然不能要求其它业务的开发者在开发时帮我们新增一条积分记录,这样会导致原有业务与积分业务耦合。因此必须采用异步方式,将原有业务与积分业务解耦。如果有必要,甚至可以将积分业务抽离,作为独立微服务。

2.设计数据库

3.业务逻辑图

  • 具体可以加分的业务发送MQ【用户id,获得的积分】
  • 积分微服务通过MQ获取,具体业务处理
  • 具体业务
    • 1.查看是哪个业务,是不是有积分上限
    • 2.如果有积分上限,要看看今日获得积分情况:如果满了拉倒,如果没有计算出还可以加多少积分
    • 3.加入积分

image-20240828160432982

4.接口分析

  • 使用MQ获得信息,然后添加

image-20240828160241699

因此,我们需要为每一种积分行为定义一个不同的RoutingKey【用来分辨不同的业务,从而进行不同的业务处理】

5.具体实现

5.1 MQ发送者[获得积分的微服务]

==获取到积分,发送MQ给积分微服务就行【加不加积分微服务自己负责】==

image-20240828151319644

5.2 MQ消费者[获取积分然后判断是否满足条件加入]

==其他微服务学习获得积分(用户id,学习到的积分)—》积分微服务【内部判断是否上限,未上限的情况下加入到积分表】==

  • MQ接受消息

    image-20240828155632016
  • 积分微服务处理业务

image-20240828155720138

  • 查询积分sql

image-20240828155747380

6.具体难点和亮点

  • 问题一:MQ发送什么消息?怎么判断是啥业务?

    签到,评论,点赞等操作都可以获得积分,然后可以通过MQ异步进行更新;只需要用户id和获得积分数就可以【加不加的上是积分微服务负责,不同交换机代表不同获取积分的业务】

  • 问题二:怎么判断今日积分是否超标?–使用sum函数统计

    image-20240828152104149

  • 问题三:积分怎么计算的?

    image-20240828155323570

2.查询今日积分情况

1.原型图

在个人中心,用户可以查看当天各种不同类型的已获得的积分和积分上限:

image-20240828160730051

可以看到,页面需要的数据:

  • 积分类型描述
  • 今日已获取积分值
  • 积分上限

而且积分类型不止一个,所以结果应该是集合。

2.设计数据库

3.业务逻辑图

就是根据数据group by type分别取出类型和对应的sum(points)

4.接口分析

另外,这个请求是查询当前用户的积分信息,所以只需要知道当前用户即可, 无需传参。

综上,接口信息如下:

image-20240828160755273

5.具体实现

  • 1.controller层

image-20240828170037116

  • 2.service层

image-20240828170044098

  • 3.serviceimpl层
image-20240828170239984
  • 4.mapper层

image-20240828170348013

6.具体难点和亮点

  • 问题一:怎么获取当前用户不同积分类型的签到积分

image-20240828164038259

==——————————————==

==—————排行榜功能—————==

准备阶段—分析业务流程

顶部展示的当前用户在榜单中的信息,其实也属于排行榜信息的一部分。因为排行榜查出来了,当前用户是第几名,积了多少分也就知道了。

当我们点击更多时,会进入历史榜单页面:

image-20240828171350690

准备阶段—字段分析

排行榜是分赛季的,而且页面也需要查询到历史赛季的列表。因此赛季也是一个实体,用来记录每一个赛季的信息。当然赛季信息非常简单:

  • 赛季名称
  • 赛季开始时间
  • 赛季结束时间

排行榜也不复杂,核心要素包括:

  • 用户id
  • 本赛季当前积分
  • 本赛季当前排名

当然,由于要区分赛季,还应该关联赛季信息:

  • 赛季id【关联赛季表】

准备阶段—ER图

image-20240828174731750

准备阶段—表结构

  • 赛季表

image-20240828171652429

1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS `points_board_season` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '自增长id,season标示',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '赛季名称,例如:第1赛季',
`begin_time` date NOT NULL COMMENT '赛季开始时间',
`end_time` date NOT NULL COMMENT '赛季结束时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC;
  • 排行表

image-20240828172027344

1
2
3
4
5
6
7
8
9
CREATE TABLE IF NOT EXISTS `points_board` (
`id` bigint NOT NULL COMMENT '榜单id',
`user_id` bigint NOT NULL COMMENT '学生id',
`points` int NOT NULL COMMENT '积分值',
`rank` tinyint NOT NULL COMMENT '名次,只记录赛季前100',
`season` smallint NOT NULL COMMENT '赛季,例如 1,就是第一赛季,2-就是第二赛季',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_season_user` (`season`,`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学霸天梯榜';

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

image-20240828172455269

准备阶段–类型枚举

准备阶段–接口统计

image-20240828171151039

1.查询赛季列表功能

1.原型图

在历史赛季榜单中,有一个下拉选框,可以选择历史赛季信息:

image-20240828171050509

2.设计数据库

3.业务逻辑图

其实就是获取赛季表的信息【多条信息】

4.接口分析

因此,我们需要实现一个接口,把历史赛季全部查询出来

image-20240828171107205

5.具体实现

  • 1.controller层

image-20240828173011382

  • 2.service层

image-20240828173016118

  • 3.serviceimpl层

image-20240828173530388

  • 4.mapper层

6.具体难点和亮点

  • 问题一:怎么获取?那这个开始和结束时间怎么确定?

查询赛季列表—>必须是当前赛季【开始时间小于等于当前时间】

==实时数据[Zset数据类型]==

0.业务分析

  • Mysql:要不停计算,不停添加数据【很繁琐】
  • Redis:既然考虑积分排名,就是用redis的Zset数据结构【key=赛季日期,member=用户id,score=积分和】—如果有用户新增积分,那就累加到对应score上,zset就可以实时更新

image-20240828192525979

既然要使用Redis的SortedSet来实现排行榜,就需要在用户每次积分变更时,累加积分到Redis的SortedSet中。因此,我们要对之前的新增积分功能做简单改造,如图中绿色部分:

image-20240828194705961

在Redis中,使用SortedSet结构,以赛季的日期为key,以用户id为member,以积分和为score. 每当用户新增积分,就累加到score中,SortedSet排名就会实时更新。这样一个实时的当前赛季榜单就出现了

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

1.生成本赛季榜单(实时数据)

1.原型图

2.设计数据库

3.业务逻辑图

4.接口分析

一旦积分微服务获取到积分,然后将积分新增到积分明细表之后,我就可以发送积分【累加】到redis!!!!

5.具体实现

  • 在原有的新增积分上:

6.具体难点和亮点

  • 问题一:如何做排行榜?

    redis的Zset数据结构【key=赛季日期,member=用户id,score=积分和】—如果有用户新增积分,那就累加到对应score上,zset就可以实时更新

  • 问题二:积分怎么新增还是累加?

    使用Zset的incrementScore方法

image-20240828194446571

2.查询积分榜(实时数据)

1.原型图

在个人中心,学生可以查看指定赛季积分排行榜(只显示前100 ),还可以查看自己总积分和排名。而且排行榜分为本赛季榜单和历史赛季榜单。

我们可以在一个接口中同时实现这两类榜单的查询

首先,我们来看一下页面原型(这里我给出的是原型对应的设计稿,也就是最终前端设计的页面效果):

image-20240828192147851

2.设计数据库

3.业务逻辑图

首先我们分析一下请求参数:

  • 榜单数据非常多,不可能一次性查询出来,因此这里一定是分页查询(滚动分页),需要分页参数。
  • 由于要查询历史榜单需要知道赛季,因此参数中需要指定赛季id。【当赛季id为空,我们认定是查询当前赛季。这样就可以把两个接口合二为一】

然后是返回值,无论是历史榜单还是当前榜单,结构都一样。分为两部分:

  • 当前用户的积分和排名。【当前用户不一定上榜,因此需要单独查询】
  • 榜单数据。就是N个用户的积分、排名形成的集合。

4.接口分析

综上,接口信息如下:

image-20240828195228359

5.具体实现

  • 1.controller层

image-20240829095637423

  • 2.service层

image-20240829095718181

  • 3.serviceimpl层

分为整体:

image-20240829095931498

其中查询我的积分和排名:

image-20240829100041040

其中查询榜单列表:

image-20240829100117423

  • 4.mapper层

6.具体难点和亮点

  • 问题一:如何获取用户(User_id)我的排名和积分?

​ 获得我的积分 zscore boards:202408 userId

​ 获得我的排名 zrevrank boards:202408 userId

  • 问题二:如何分页获取排行榜(用户,积分,排名)?

    获取我的排行榜 zrevRangeWithScore start stop[start和stop要根据pageSize和pageNo推断]

==——————————————==

==历史数据[Redis和Mysql数据持久化]==

0.业务分析

积分排行榜是分赛季的,每一个月是一个赛季。因此每到每个月的月初,就会进入一个新的赛季。所有用户的积分应该清零,重新累积。

如果直接删除Redis数据,那就丢失了一个赛季 —-==持久化==—-> Mysql

image-20240830195901104

假如有数百万用户,每个赛季榜单都有数百万数据。随着时间推移,历史赛季越来越多,如果全部保存到一张表中,数据量会非常恐怖!–>==海量数据存储策略==

1.海量数据存储策略[四种策略]

image-20240829104320737

1.1 分区

表分区(Partition)是一种数据存储方案,可以解决单表数据较多的问题【MySQL5.1开始支持表分区功能】

image-20240829144303567

如果表数据过多 —> 文件体积非常大 —> 文件跨越多个磁盘分区 —> 数据检索时的速度就会非常慢 —>【Mysql5.1引入表分区】按照某种规则,把表数据对应的ibd文件拆分成多个文件来存储。

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

  • 从逻辑上来看,他们对外表现是一张表【一张表】 — CRUD不会变化,只是底层MySQL处理上会有变更,检索时可以只检索某个文件就可以

例如,我们的历史榜单数据,可以按照赛季切分:

image-20240829144756927

此时,赛季榜单表的磁盘文件就被分成了两个文件,但逻辑上还是一张表。CRUD不会变化,只是底层MySQL处理上会有变更,检索时可以只检索某个文件就可以

  • 表分区的好处:

    • 1.可以存储更多的数据,突破单表上限。甚至可以存储到不同磁盘,突破磁盘上限

    • 2.查询时可以根据规则只检索某一个文件,提高查询效率

    • 3.数据统计时,可以多文件并行统计,最后汇总结果,提高统计效率【分而治之,各自统计】

    • 4.对于一些历史数据,如果不需要时,可以直接删除分区文件,提高删除效率

  • 表分区的方式:【对数据做水平拆分

    • Range分区:按照指定字段的取值范围分区 –保单表,根据1-10,11-20这样10个为一组区分
    • List分区:按照指定字段的枚举值分区【必须提前制定所有分区值,否则会因为找不到报错】–保单表,根据保单是车险财/非车进行区分
    • Hash分区:按照字段做hash运算后分区【字段一般是对数值类型】 –保单表,根据保单表%hash运算进行区分
    • Key分区:按照指定字段的值做运算结果分区【不限定字段类型】 –保单表,根据保单号%9进行区分

1.2 分表

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

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

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

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

  • 分表的好处:

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

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

  • 分表的坏处:

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

1.2.1 水平分表

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

image-20240829150144571

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

1.2.2 垂直分表

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

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

image-20240829150258884

1.3 分库[垂直分库]

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

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

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

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

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

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

    • 3.避免了单点故障

  • 缺点:

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

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

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

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

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

image-20240829151509972

1.4 集群[主写从读]

[保证单节点的高可用性]给数据库建立主从集群,主节点向从节点同步数据,两者结构一样

image-20240829151628463

2.[历史榜单]存储策略—水平分表

东林微课堂是一个教育类项目,用户规模并不会很高,一般在十多万到百万级别。因此最终的数据规模也并不会非常庞大。综合之前的分析,结合天机学堂的项目情况,我们可以对榜单数据做分表,但是暂时不需要做分库和集群。

由于我们要解决的是数据过多问题,因此分表的方式选择水平分表。具体来说,就是按照赛季拆分,每一个赛季是一个独立的表,如图:

image-20240829152437762

但是,考虑我们只需要排名,积分,用户id即可—>可以删除掉season,rank两个字段【也可以减少单表存储】

  • 表结构如下:
1
2
3
4
5
6
7
8
CREATE TABLE IF NOT EXISTS `points_board_X`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '榜单id',
`user_id` BIGINT NOT NULL COMMENT '学生id',
`points` INT NOT NULL COMMENT '积分值',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id` (`user_id`) USING BTREE
)COMMENT ='学霸天梯榜' COLLATE = 'utf8mb4_0900_ai_ci' ENGINE = InnoDB ROW_FORMAT = DYNAMIC;

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

1.[定时]生成历史榜单表

1.1 业务设计

每个赛季刚开始的时候(月初)来创建新的赛季榜单表。每个月的月初执行一个创建表的任务,我们可以利用定时任务来实现。

【由于表的名称中包含赛季id,因此在定时任务中我们还要先查询赛季信息,获取赛季id,拼接得到表名,最后创建表】

大概流程如图:

image-20240829164908368

1.2 实现思路

  • ①生成上赛季表:

    通过xxl-job设定定时任务[每月初]:查询赛季表上个月对应的赛季id。通过传递(表名+赛季id)在mapper层创建历史赛季表

  • ②redis数据进入mysql表:

​ 根据(key,pageNo,pageSize)分页查询redis数据[id(改为input自己输入,按照rank属性设置),user_id,points],然后通过saveBatch分批插入新建的数据库内[数据库名根据mybatisplus动态插件底层通过threadlocal存储表名,本质是一个拦截器,在数据到mapper和数据库打交道的时候更改数据库名],插入结束记得remove删除

  • ③清除redis数据:unlike命令会在另外一个线程中回收内存,非阻塞【del会阻塞】

​ 使用unlike指令删除【非阻塞式】

1.3 具体实现

  • 定时任务

image-20240830155440727

  • 具体执行业务
image-20240830112047591
  • (页面)创建任务
image-20240830104732769

1.4 具体难点和亮点

  • ①生成上赛季表:

    通过xxl-job设定定时任务[每月初]:查询赛季表上个月对应的赛季id。通过传递(表名+赛季id)在mapper层创建历史赛季表

  • ②redis数据进入mysql表:

​ 根据(key,pageNo,pageSize)分页查询redis数据[id(改为input自己输入,按照rank属性设置),user_id,points],然后通过saveBatch分批插入新建的数据库内[数据库名根据mybatisplus动态插件底层通过threadlocal存储表名,本质是一个拦截器,在数据到mapper和数据库打交道的时候更改数据库名],插入结束记得remove删除

  • ③清除redis数据:unlike命令会在另外一个线程中回收内存,非阻塞【del会阻塞】

​ 使用unlike指令删除【非阻塞式】

2.MybatisPlus动态表名插件

2.1 原始情况

image-20240830193434978

2.2 实现思路

流程中,我们会先计算表名,然后去执行持久化,而动态表名插件就会生效,去替换表名。

因此,一旦我们计算完表名,以某种方式传递给插件中的TableNameHandler,那么就无需重复计算表名了。都是MybatisPlus内部调用的,我们无法传递参数。—> 但是可以在一个线程中实现数据共享

image-20240830193650901

2.3 具体实现

  • 配置Mybatis动态表名拦截器

image-20240830162523297

  • TableInfoContext底层:

image-20240830162537173

  • MybatisConfig配置:

image-20240830162550841

3.XxlJob分片

3.1 原理

刚才定义的定时持久化任务,通过while死循环,不停的查询数据,直到把所有数据都持久化为止。这样如果数据量达到数百万,交给一个任务执行器来处理会耗费非常多时间—->实例多个部署,这样就会有多个执行器并行执行(但是多个执行器执行相同代码,都从第一页开始也会重复处理)—->任务分片

举例[类似于发牌]:

image-20240830164322338

最终,每个执行器处理的数据页情况:

  • 执行器1:处理第1、4、7、10、13、…页数据
  • 执行器2:处理第2、5、8、11、14、…页数据
  • 执行器3:处理第3、6、9、12、15、…页数据

要想知道每一个执行器执行哪些页数据,只要弄清楚两个关键参数即可:

  • 起始页码:pageNo【执行器编号是多少,起始页码就是多少】
  • 下一页的跨度:step【执行器有几个,跨度就是多少。也就是说你要跳过别人读取过的页码,类似于分布式ID的步长】

因此,现在的关键就是获取两个数据:

  • 执行器编号
  • 执行器数量

这两个参数XXL-JOB作为任务调度中心,肯定是知道的,而且也提供了API帮助我们获取:

image-20240830164735039

这里的分片序号其实就是执行器序号,不过是从0开始,那我们只要对序号+1,就可以作为起始页码了

3.2 业务优化

image-20240830164953405

3.3 引发问题解决方案

使用xxl-job定时每月初进行持久化:

①根据计算上个月时间创建上赛季mysql表

②根据查询出来上赛季redis数据,数据库新表名通过mp动态表名插件(本质是一个拦截器,在与mapper数据库接触过程中通过threadlocal更改数据库名)】然后查询数据
③根据非阻塞语句del删除redis上赛季数据—但是我考虑使用分片,这样导致分片1执行完异步执行删除,但是分片2执行完数据好像又回来了【针对②查询结果分页用xxlJob分片,log查日志没解决,我就打断点发现是分片次数问题,我就redis添加一个总数,一个分片执行次数,然后将删除逻辑放在一个新的定时任务,判断总数==分片执行次数,符合的情况才删除】

面试题

面试官:你在项目中负责积分排行榜功能,说说看你们排行榜怎么设计实现的?

​ 答:我们的排行榜功能分为两部分:一个是当前赛季排行榜,一个是历史排行榜。

因为我们的产品设计是每个月为一个赛季,月初清零积分记录,这样学员就有持续的动力去学习。这就有了赛季的概念,因此也就有了当前赛季榜单和历史榜单的区分,其实现思路也不一样。

首先说当前赛季榜单,我们采用了Redis的SortedSet来实现。member是用户id,score就是当月积分总值。每当用户产生积分行为的时候,获取积分时,就会更新score值。这样Redis就会自动形成榜单了。非常方便且高效。

然后再说历史榜单,历史榜单肯定是保存到数据库了。不过由于数据过多,所以需要对数据做水平拆分,我们目前的思路是按照赛季来拆分,也就是每一个赛季的榜单单独一张表。这样做有几个好处:

  • 拆分数据时比较自然,无需做额外处理
  • 查询数据时往往都是按照赛季来查询,这样一次只需要查一张表,不存在跨表查询问题

因此我们就不需要用到分库分表的插件了,直接在业务层利用MybatisPlus就可以实现动态表名,动态插入了。简单高效。

我们会利用一个定时任务在每月初生成上赛季的榜单表,然后再用一个定时任务读取Redis中的上赛季榜单数据,持久化到数据库中。最后再有一个定时任务清理Redis中的历史数据。

这里要说明一下,这里三个任务是有关联的,之所以让任务分开定义,是为了避免任务耦合。这样在部分任务失败时,可以单独重试,无需所有任务从头重试。

当然,最终我们肯定要确保这三个任务的执行顺序,一定是依次执行的[通过xxlJob分布式调度完成,弥补单体的springTask框架不能顺序执行的毛病]

面试官追问:你们使用Redis的SortedSet来保存榜单数据,如果用户量非常多怎么办?

​ 首先Redis的SortedSet底层利用了跳表机制,性能还是非常不错的。即便有百万级别的用户量,利用SortedSet也没什么问题,性能上也能得到保证。在我们的项目用户量下,完全足够。

当系统用户量规模达到数千万,乃至数亿时,我们可以采用分治的思想,将用户数据按照积分范围划分为多个桶。

然后为每个桶创建一个SortedSet类型的key,这样就可以将数据分散,减少单个KEY的数据规模了。

而要计算排名时,只需要按照范围查询出用户积分所在的桶,再累加分值范围比他高的桶的用户数量即可。依然非常简单、高效。

面试官追问:你们使用历史榜单采用的定时任务框架是哪个?处理数百万的榜单数据时任务是如何分片的?你们是如何确保多个任务依次执行的呢?

​ 答:我们采用的是XXL-JOB框架。

XXL-JOB自带任务分片广播机制,每一个任务执行器都能通过API得到自己的分片编号、总分片数量。在做榜单数据批处理时,我们是按照分页查询的方式:

  • 每个执行器的读取的起始页都是自己的分片编号+1,例如第一个执行器,其起始页就是1,第二个执行器,其起始页就是2,以此类推
  • 然后不是逐页查询,而是有一个页的跨度,跨度值就是分片总数量。例如分了3片,那么跨度就是3

此时,第一个分片处理的数据就是第1、4、7、10、13等几页数据,第二个分片处理的就是第2、5、8、11、14等页的数据,第三个分片处理的就是第3、6、9、12、15等页的数据。

这样就能确保所有数据都会被处理,而且每一个执行器都执行的是不同的数据了。

最后,要确保多个任务的执行顺序,可以利用XXL-JOB中的子任务功能。比如有任务A、B、C,要按照字母顺序依次执行,我们就可以将C设置为B的子任务,再将B设置为A的子任务。然后给A设置一个触发器。

这样,当A触发时,就会依次执行这三个任务了。

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

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
集群部署 支持 支持 支持
分布式序列 雪花算法 雪花算法 自增序列
,