1.出现原因
我直接关闭了我提交博客的git窗口,导致下次hexo d的时候提示:
2.解决方案
- 1.根据git命令行进入hexo博客所在目录:
- 2.根据git status命令查看当前仓库的状态:
- 3.根据git reset –hard命令重置仓库状态,但是有可能会丢失未提交的更改[谨慎使用]
- 4.重新hexo g和hexo d查看:
我直接关闭了我提交博客的git窗口,导致下次hexo d的时候提示:
关于创建xx数据库表,主键id的取值问题:如果自增可能会出现分库分表的麻烦,但是分库分表如果使用分布式id也有对应的缺点。因此,本文从①不分库分表②分库分表两个方面考虑
==每个数据库设置①不同的初始值和②相同的自增步长==
如图所示,这样可以保证DB123生成的ID是不冲突的,但是如果扩容,DB4数据库的话就没有初始值。
因此解决方案:
①根据扩容考虑决定步长,可以让多个数据库之间有空隙数字,可以扩容
②在其他未标记去扩容
==其实就是给数据库一批ID,不管多个DB之间的是否联系和连续,可能会出现多个数据库内连续,外不连续==
方案一步长的问题不好考虑,那我干脆一台机器分配,我分配的话肯定不会出现没法扩充,只是没办法保证多个数据库之间的ID是连续的。我DB4数据库来了,我可能忘了我就给他500-599的。
形式:32个十六进制数字一共是128位【8-4-4-4-12】
优点:不是有序的,安全性更高
缺点:
①不是有序的,所以做主键的话innodb聚集索引内存消耗大,读写效率低;
②32个数字长度大,导致innodb叶子节点存储过大;
③因为无序,查找效率低下
第1个bit位:保留位,无实际作用
第2-42的bit位:这41位表示时间戳,精确到毫秒级别
第43-52的bit位:这10位表示专门负责生产ID的工作机器的id
第53-64的bit位:这12位表示序列号,也就是1毫秒内可以生成2 12 2^{12}2
优点:
①整体上按照时间趋势增加,后续插入索引树的性能较好;
②整个分布式系统不会发生ID碰撞;
③本地生成,且不依赖数据库,没有网络消耗
缺点:
①强依赖时间容易发生时种回拨【Map存储<机器ID,max_id>服务器出故障就从max_id重新生成】
目前,我们的定时任务都是基于SpringTask来实现的。但是SpringTask存在一些问题:
不仅仅是SpringTask,其它单机使用的定时任务工具,都无法实现像这种任务执行者的调度、任务执行顺序的编排、任务监控等功能。这些功能必须要用到分布式任务调度组件。
我们先来看看普通定时任务的实现原理,一般定时任务中会有两个组件:
因此在多实例部署的时候,每个启动的服务实例都会有自己的任务触发器,这样就会导致各个实例各自运行,无法统一控制:
如果我们想统一控制各服务实例的任务执行和调度—>统一控制[统一出发、统一调度]
事实上,大多数的分布式任务调度组件都是这样做的:
这样一来,具体哪个任务该执行,什么时候执行,交给哪个应用实例来执行,全部都有统一的任务调度服务来统一控制。并且执行过程中的任务结果还可以通过回调接口返回,让我们方便的查看任务执行状态、执行日志。这样的服务就是分布式调度服务
能够实现分布式任务调度的技术有很多,常见的有:【越往右越牛逼】
Quartz | XXL-Job | SchedulerX | PowerJob | |
---|---|---|---|---|
定时类型 | CRON | 频率、间隔、CRON | 频率、间隔、CRON、OpenAPI | 频率、间隔、CRON、OpenAPI |
任务类型 | Java | 多语言脚本 | 多语言脚本 | 多语言脚本 |
任务调度方式 | 随机 | 单机、分片 | 单机、广播、Map、MapReduce | 单机、广播、分片、Map、MapReduce |
管理控制台 | 无 | 支持 | 支持 | 支持 |
日志白屏 | 无 | 支持 | 支持 | 支持 |
报警监控 | 无 | 支持 | 支持 | 支持 |
工作流 | 无 | 有限 | 支持 | 支持 |
其中:
扩展:多语言脚本–通过xxl-job平台,新增调度任务时候可以选择任务的运行模式【使用不同脚本语言编写任务】
XXL-JOB的运行原理和架构如图:
XXL-JOB分为两部分:
其中,我们可以打开xxl-job页面:
自己部署,分为两步:
sql语句在对应github文件夹下:
docker命令:
1 | docker run \ |
每10s打印一次hello…
1 | <!--xxl-job--> |
1 | - adminAddress:调度中心地址,天机学堂中就是填虚拟机地址 |
JobHandler一定要和方法@XXlJob内容一致
【想要测试的话也可以手动执行一次任务,但是要设置好调度策略】
也可以在页面看到日志:
刚才定义的定时持久化任务,通过while死循环,不停的查询数据,直到把所有数据都持久化为止。这样如果数据量达到数百万,交给一个任务执行器来处理会耗费非常多时间—->实例多个部署,这样就会有多个执行器并行执行(但是多个执行器执行相同代码,都从第一页开始也会重复处理)—->任务分片【分片查询】
举例[类似于发牌]:
最终,每个执行器处理的数据页情况:
要想知道每一个执行器执行哪些页数据,只要弄清楚两个关键参数即可:
因此,现在的关键就是获取两个数据:
这两个参数XXL-JOB作为任务调度中心,肯定是知道的,而且也提供了API帮助我们获取:
这里的分片序号其实就是执行器序号,不过是从0开始,那我们只要对序号+1,就可以作为起始页码了
根据实际情况,分成多个机器[这个用例,分片1,2,3;步长为1]
使用xxl-job定时每月初进行持久化:
①根据计算上个月时间创建上赛季mysql表
②根据查询出来上赛季redis数据,数据库新表名通过mp动态表名插件(本质是一个拦截器,在与mapper数据库接触过程中通过threadlocal更改数据库名)】然后查询数据
③根据非阻塞语句del删除redis上赛季数据—但是我考虑使用分片,这样导致分片1执行完异步执行删除,但是分片2执行完数据好像又回来了【针对②查询结果分页用xxlJob分片,log查日志没解决,我就打断点发现是分片次数问题,我就redis添加一个总数,一个分片执行次数,然后将删除逻辑放在一个新的定时任务,判断总数==分片执行次数,符合的情况才删除】
表分区(Partition)是一种数据存储方案,可以解决单表数据较多的问题【MySQL5.1开始支持表分区功能】
如果表数据过多 —> 文件体积非常大 —> 文件跨越多个磁盘分区 —> 数据检索时的速度就会非常慢 —>【Mysql5.1引入表分区】按照某种规则,把表数据对应的ibd文件拆分成多个文件来存储。
从物理上来看,一张表的数据被拆到多个表文件存储了【多张表】
从逻辑上来看,他们对外表现是一张表【一张表】 — CRUD不会变化,只是底层MySQL处理上会有变更,检索时可以只检索某个文件就可以
例如,我们的历史榜单数据,可以按照赛季切分:
此时,赛季榜单表的磁盘文件就被分成了两个文件,但逻辑上还是一张表。CRUD不会变化,只是底层MySQL处理上会有变更,检索时可以只检索某个文件就可以
表分区的好处:
1.可以存储更多的数据,突破单表上限。甚至可以存储到不同磁盘,突破磁盘上限
2.查询时可以根据规则只检索某一个文件,提高查询效率
3.数据统计时,可以多文件并行统计,最后汇总结果,提高统计效率【分而治之,各自统计】
4.对于一些历史数据,如果不需要时,可以直接删除分区文件,提高删除效率
表分区的方式:【对数据做水平拆分】
开发者自己对表的处理,与数据库无关
从物理上来看,一张表的数据被拆到多个表文件存储了【多张表】
从逻辑上来看,【多张表】 — CRUD会变化,需要考虑取哪张表做数据处理
在开发中我们很多情况下业务需求复杂,更看重分表的灵活性。因此,我们大多数情况下都会选择分表方案。
分表的好处:
1.拆分方式更加灵活【可以水平也可以垂直】
2.可以解决单表字段过多问题【垂直分表,分在多个表】
分表的坏处:
例如,对于赛季榜单,我们可以按照赛季拆分为多张表,每一个赛季一张新的表。如图:
这种方式就是水平分表,表结构不变,仅仅是每张表数据不同。查询赛季1,就找第一张表。查询赛季2,就找第二张表。
如果一张表的字段非常多(比如达到30个以上,这样的表我们称为宽表)。宽表由于字段太多,单行数据体积就会非常大,虽然数据不多,但可能表体积也会非常大!从而影响查询效率。
例如一个用户信息表,除了用户基本信息,还包含很多其它功能信息:
无论是分区,还是分表,我们刚才的分析都是建立在单个数据库的基础上。但是单个数据库也存在一些问题:
综上,在大型系统中,我们除了要做①分表、还需要对数据做②分库—>建立综合集群。
优点:【解决了单个数据库的三大问题】
1.解决了海量数据存储问题,突破了单机存储瓶颈
2.提高了并发能力,突破了单机性能瓶颈
3.避免了单点故障
缺点:
1.成本非常高【要多个服务器,多个数据库】
2.数据聚合统计比较麻烦【因为牵扯多个数据库,有些语句会很麻烦】
3.主从同步的一致性问题【主数据库往从数据库更新,会有不可取消的延误时间,只能通过提高主从数据库网络带宽,机器性能等操作(↓)延误时间】
4.分布式事务问题【因为涉及多个数据库多个表,使用seata分布式事务可以解决】
微服务项目中,我们会按照项目模块,每个微服务使用独立的数据库,因此每个库的表是不同的
[保证单节点的高可用性]给数据库建立主从集群,主节点向从节点同步数据,两者结构一样
为了激励东林学子学习,可以设定一个学习积分的排行榜。优秀的学子可以给予优惠券
这个页面信息比较密集,从上往下来看分三部分:
签到最核心的包含两个要素:
同时要考虑一些功能要素,比如:
1 | CREATE TABLE `sign_record` ( |
无【基于redis做的,没有mysql数据库表】
回到个人积分页面,在页面中部有一个签到表:
可以看到这就是一个日历,对应了每一天的签到情况。日历中当天的日期会高亮显示为《打卡》状态,点击即可完成当日打卡,服务端自然要记录打卡情况。
因此这里就有一个接口需要实现:①签到接口
除此以外,可以看到本月第一天到今天为止的所有打卡日期也都高亮显示标记出来了。也就是说页面还需要知道本月到今天为止每一天的打卡情况。这样对于了一个接口:②查询本月签到记录
Redis 的 Bitmap(位图)是一种特殊的字符串数据类型,它利用字符串类型键(key)来存储一系列连续的二进制位(bits),每个位可以独立地表示一个布尔值(0 或 1)。这种数据结构非常适合用于存储和操作大量二值状态的数据,尤其在需要高效空间利用率和特定位操作场景中表现出色。
我们可以使用setbit getbit bitcount bitfield四个指令:
1 | # 签到/取消签到【给某人的某某年某某月为一个位图】 0就是偏移量【第一天】 1【1就是签到/0是不签到】 |
基础类型:Redis最基础的数据类型只有5种:String、List、Set、SortedSet、Hash【其它特殊数据结构大多都是基于以上5这种数据类型】
BitMap基于String结构【String类型底层是SDS,会有一个字节数组用来保存数据。而Redis就提供了几个按位操作这个数组中数据的命令,实现了BitMap效果】
由于String类型的最大空间是512MB=2的31次幂个bit,因此可以存储的数据量级很大!!!【一个月才是31bit,四个字节】–> ==bitMap扩容是8个字节一组==
在个人中心的积分页面,用户每天都可以签到一次,连续签到则有积分奖励,请实现签到接口,记录用户每天签到信息,方便做签到统计。
在个人中心的积分页面,用户每天都可以签到一次:
而在后台,要做的事情就是把BitMap中的与签到日期对应的bit位,设置为1
mysql设计:【占用空间大】
我们设计了签到功能对应的数据库表:sign_record[主键id,用户id,签到年月日,是否可以补签]。这张表的一条记录就是一个用户一次的登录记录。如果一个用户一年签到100次,那就是100条记录,如果有100w用户,就会产生一亿条记录。—->占用空间会越来越大
Redis设计:【只需要存储一个用户是否签到,0未签到,1签到】—>使用bitMap
如果我们按月来统计用户签到信息,签到记为1,未签到记为0,就可以用一个长度为31位的二进制数来表示一个用户一个月的签到情况。最终效果如下:
我们知道二进制是计算机底层最基础的存储方式了,其中的每一位数字就是计算机信息量的最小单位了,称之为bit,一个月最多也就 31 天,因此一个月的签到记录最多也就使用 31 bit 就能保存了,还不到 4 个字节【mysql数据库就要使用数百字节】
无
考虑签到只需要1/0,那就使用bitMap;然后YYMM:Userid就是一个bitMap【代表某人某年某月的登录】,一共设计31个bit位就可以代表一个月的签到数据;扩容的话是8位一组,一般一个月就是4组32位(最后一位暂时没有用)
连续登录天数:从当前天从后往前算连续1的个数【一定是从后往前】;从后往前就用算出来的十进制数&1做与运算【只关心最后一位结果】,然后右移十进制得到前面的一位
1 | int count = 0; // 定义一个计数器 |
问题三:怎么判断重复签到?
利用setbit返回值的特性
问题四:bitmap用哪些指令了?
在签到日历中,需要把本月(第一天-今天)的所有签到过的日期高亮显示。
因此我们必须把签到记录返回,具体来说就是每一天是否签到的数据。是否签到,就是0或1,刚好在前端0和1代表false和true,也就是签到或没签到。
因此,每一天的签到结果就是一个0或1的数字,我们最终返回的结果是一个0或1组成的数组,对应从本月第1天到今天为止每一天的签到情况。
综上,最终的接口如下:
无
问题一:如何获取本月的登录记录?
根据bitfield指令可以获得本月等登录记录(0001…0111001)的十进制数字
问题二:如何转为二进制,并且统计转为byte数组?
第一种办法,十进制转为字符串二进制,然后二进制char的for循环遍历得到byte[①必须-‘0’才是数字1,不然是ascii码的48;②因为十进制不是32位,转出来也不是32位!!!]
第二种办法,按照统计连续天数的思路(10进制与1进行与运算,可以依次倒序取出所有的0/1,然后逆序一下就是结果)
具体的积分获取细则如下:
1 | 1. 签到规则 |
用户获取积分的途径有5种:
这个页面信息比较密集,从上往下来看分三部分:
积分记录的目的有两个:一个是统计用户当日某一种方式获取的积分是否达到上限;一个是统计积分排行榜。
要达成上述目的我们至少要记录下列信息:
1 | CREATE TABLE IF NOT EXISTS `points_record` ( |
针对数据库的积分类型字段:设计成枚举类型
由积分规则可知,获取积分的行为多种多样,而且每一种行为都有自己的独立业务。而这些行为产生的时候需要保存一条积分明细到数据库。
我们显然不能要求其它业务的开发者在开发时帮我们新增一条积分记录,这样会导致原有业务与积分业务耦合。因此必须采用异步方式,将原有业务与积分业务解耦。如果有必要,甚至可以将积分业务抽离,作为独立微服务。
因此,我们需要为每一种积分行为定义一个不同的RoutingKey【用来分辨不同的业务,从而进行不同的业务处理】
==获取到积分,发送MQ给积分微服务就行【加不加积分微服务自己负责】==
==其他微服务学习获得积分(用户id,学习到的积分)—》积分微服务【内部判断是否上限,未上限的情况下加入到积分表】==
MQ接受消息
积分微服务处理业务
问题一:MQ发送什么消息?怎么判断是啥业务?
签到,评论,点赞等操作都可以获得积分,然后可以通过MQ异步进行更新;只需要用户id和获得积分数就可以【加不加的上是积分微服务负责,不同交换机代表不同获取积分的业务】
问题二:怎么判断今日积分是否超标?–使用sum函数统计
问题三:积分怎么计算的?
在个人中心,用户可以查看当天各种不同类型的已获得的积分和积分上限:
可以看到,页面需要的数据:
而且积分类型不止一个,所以结果应该是集合。
就是根据数据group by type分别取出类型和对应的sum(points)
另外,这个请求是查询当前用户的积分信息,所以只需要知道当前用户即可, 无需传参。
综上,接口信息如下:
顶部展示的当前用户在榜单中的信息,其实也属于排行榜信息的一部分。因为排行榜查出来了,当前用户是第几名,积了多少分也就知道了。
当我们点击更多时,会进入历史榜单页面:
排行榜是分赛季的,而且页面也需要查询到历史赛季的列表。因此赛季也是一个实体,用来记录每一个赛季的信息。当然赛季信息非常简单:
排行榜也不复杂,核心要素包括:
当然,由于要区分赛季,还应该关联赛季信息:
1 | CREATE TABLE IF NOT EXISTS `points_board_season` ( |
1 | CREATE TABLE IF NOT EXISTS `points_board` ( |
在历史赛季榜单中,有一个下拉选框,可以选择历史赛季信息:
其实就是获取赛季表的信息【多条信息】
因此,我们需要实现一个接口,把历史赛季全部查询出来
无
查询赛季列表—>必须是当前赛季【开始时间小于等于当前时间】
既然要使用Redis的SortedSet来实现排行榜,就需要在用户每次积分变更时,累加积分到Redis的SortedSet中。因此,我们要对之前的新增积分功能做简单改造,如图中绿色部分:
在Redis中,使用SortedSet结构,以赛季的日期为key,以用户id为member,以积分和为score. 每当用户新增积分,就累加到score中,SortedSet排名就会实时更新。这样一个实时的当前赛季榜单就出现了
一旦积分微服务获取到积分,然后将积分新增到积分明细表之后,我就可以发送积分【累加】到redis!!!!
问题一:如何做排行榜?
redis的Zset数据结构【key=赛季日期,member=用户id,score=积分和】—如果有用户新增积分,那就累加到对应score上,zset就可以实时更新
问题二:积分怎么新增还是累加?
使用Zset的incrementScore方法
在个人中心,学生可以查看指定赛季积分排行榜(只显示前100 ),还可以查看自己总积分和排名。而且排行榜分为本赛季榜单和历史赛季榜单。
我们可以在一个接口中同时实现这两类榜单的查询
首先,我们来看一下页面原型(这里我给出的是原型对应的设计稿,也就是最终前端设计的页面效果):
首先我们分析一下请求参数:
然后是返回值,无论是历史榜单还是当前榜单,结构都一样。分为两部分:
综上,接口信息如下:
分为整体:
其中查询我的积分和排名:
其中查询榜单列表:
无
获得我的积分 zscore boards:202408 userId
获得我的排名 zrevrank boards:202408 userId
问题二:如何分页获取排行榜(用户,积分,排名)?
获取我的排行榜 zrevRangeWithScore start stop[start和stop要根据pageSize和pageNo推断]
积分排行榜是分赛季的,每一个月是一个赛季。因此每到每个月的月初,就会进入一个新的赛季。所有用户的积分应该清零,重新累积。
如果直接删除Redis数据,那就丢失了一个赛季 —-==持久化==—-> Mysql
假如有数百万用户,每个赛季榜单都有数百万数据。随着时间推移,历史赛季越来越多,如果全部保存到一张表中,数据量会非常恐怖!–>==海量数据存储策略==
表分区(Partition)是一种数据存储方案,可以解决单表数据较多的问题【MySQL5.1开始支持表分区功能】
如果表数据过多 —> 文件体积非常大 —> 文件跨越多个磁盘分区 —> 数据检索时的速度就会非常慢 —>【Mysql5.1引入表分区】按照某种规则,把表数据对应的ibd文件拆分成多个文件来存储。
从物理上来看,一张表的数据被拆到多个表文件存储了【多张表】
从逻辑上来看,他们对外表现是一张表【一张表】 — CRUD不会变化,只是底层MySQL处理上会有变更,检索时可以只检索某个文件就可以
例如,我们的历史榜单数据,可以按照赛季切分:
此时,赛季榜单表的磁盘文件就被分成了两个文件,但逻辑上还是一张表。CRUD不会变化,只是底层MySQL处理上会有变更,检索时可以只检索某个文件就可以
表分区的好处:
1.可以存储更多的数据,突破单表上限。甚至可以存储到不同磁盘,突破磁盘上限
2.查询时可以根据规则只检索某一个文件,提高查询效率
3.数据统计时,可以多文件并行统计,最后汇总结果,提高统计效率【分而治之,各自统计】
4.对于一些历史数据,如果不需要时,可以直接删除分区文件,提高删除效率
表分区的方式:【对数据做水平拆分】
开发者自己对表的处理,与数据库无关
从物理上来看,一张表的数据被拆到多个表文件存储了【多张表】
从逻辑上来看,【多张表】 — CRUD会变化,需要考虑取哪张表做数据处理
在开发中我们很多情况下业务需求复杂,更看重分表的灵活性。因此,我们大多数情况下都会选择分表方案。
分表的好处:
1.拆分方式更加灵活【可以水平也可以垂直】
2.可以解决单表字段过多问题【垂直分表,分在多个表】
分表的坏处:
例如,对于赛季榜单,我们可以按照赛季拆分为多张表,每一个赛季一张新的表。如图:
这种方式就是水平分表,表结构不变,仅仅是每张表数据不同。查询赛季1,就找第一张表。查询赛季2,就找第二张表。
如果一张表的字段非常多(比如达到30个以上,这样的表我们称为宽表)。宽表由于字段太多,单行数据体积就会非常大,虽然数据不多,但可能表体积也会非常大!从而影响查询效率。
例如一个用户信息表,除了用户基本信息,还包含很多其它功能信息:
无论是分区,还是分表,我们刚才的分析都是建立在单个数据库的基础上。但是单个数据库也存在一些问题:
综上,在大型系统中,我们除了要做①分表、还需要对数据做②分库—>建立综合集群。
优点:【解决了单个数据库的三大问题】
1.解决了海量数据存储问题,突破了单机存储瓶颈
2.提高了并发能力,突破了单机性能瓶颈
3.避免了单点故障
缺点:
1.成本非常高【要多个服务器,多个数据库】
2.数据聚合统计比较麻烦【因为牵扯多个数据库,有些语句会很麻烦】
3.主从同步的一致性问题【主数据库往从数据库更新,会有不可取消的延误时间,只能通过提高主从数据库网络带宽,机器性能等操作(↓)延误时间】
4.分布式事务问题【因为涉及多个数据库多个表,使用seata分布式事务可以解决】
微服务项目中,我们会按照项目模块,每个微服务使用独立的数据库,因此每个库的表是不同的
[保证单节点的高可用性]给数据库建立主从集群,主节点向从节点同步数据,两者结构一样
东林微课堂是一个教育类项目,用户规模并不会很高,一般在十多万到百万级别。因此最终的数据规模也并不会非常庞大。综合之前的分析,结合天机学堂的项目情况,我们可以对榜单数据做分表,但是暂时不需要做分库和集群。
由于我们要解决的是数据过多问题,因此分表的方式选择水平分表。具体来说,就是按照赛季拆分,每一个赛季是一个独立的表,如图:
但是,考虑我们只需要排名,积分,用户id即可—>可以删除掉season,rank两个字段【也可以减少单表存储】
1 | CREATE TABLE IF NOT EXISTS `points_board_X` |
每个赛季刚开始的时候(月初)来创建新的赛季榜单表。每个月的月初执行一个创建表的任务,我们可以利用定时任务来实现。
【由于表的名称中包含赛季id,因此在定时任务中我们还要先查询赛季信息,获取赛季id,拼接得到表名,最后创建表】
大概流程如图:
①生成上赛季表:
通过xxl-job设定定时任务[每月初]:查询赛季表上个月对应的赛季id。通过传递(表名+赛季id)在mapper层创建历史赛季表
②redis数据进入mysql表:
根据(key,pageNo,pageSize)分页查询redis数据[id(改为input自己输入,按照rank属性设置),user_id,points],然后通过saveBatch分批插入新建的数据库内[数据库名根据mybatisplus动态插件底层通过threadlocal存储表名,本质是一个拦截器,在数据到mapper和数据库打交道的时候更改数据库名],插入结束记得remove删除
使用unlike指令删除【非阻塞式】
①生成上赛季表:
通过xxl-job设定定时任务[每月初]:查询赛季表上个月对应的赛季id。通过传递(表名+赛季id)在mapper层创建历史赛季表
②redis数据进入mysql表:
根据(key,pageNo,pageSize)分页查询redis数据[id(改为input自己输入,按照rank属性设置),user_id,points],然后通过saveBatch分批插入新建的数据库内[数据库名根据mybatisplus动态插件底层通过threadlocal存储表名,本质是一个拦截器,在数据到mapper和数据库打交道的时候更改数据库名],插入结束记得remove删除
使用unlike指令删除【非阻塞式】
流程中,我们会先计算表名,然后去执行持久化,而动态表名插件就会生效,去替换表名。
因此,一旦我们计算完表名,以某种方式传递给插件中的TableNameHandler,那么就无需重复计算表名了。都是MybatisPlus内部调用的,我们无法传递参数。—> 但是可以在一个线程中实现数据共享
刚才定义的定时持久化任务,通过while死循环,不停的查询数据,直到把所有数据都持久化为止。这样如果数据量达到数百万,交给一个任务执行器来处理会耗费非常多时间—->实例多个部署,这样就会有多个执行器并行执行(但是多个执行器执行相同代码,都从第一页开始也会重复处理)—->任务分片
举例[类似于发牌]:
最终,每个执行器处理的数据页情况:
要想知道每一个执行器执行哪些页数据,只要弄清楚两个关键参数即可:
因此,现在的关键就是获取两个数据:
这两个参数XXL-JOB作为任务调度中心,肯定是知道的,而且也提供了API帮助我们获取:
这里的分片序号其实就是执行器序号,不过是从0开始,那我们只要对序号+1,就可以作为起始页码了
使用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、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触发时,就会依次执行这三个任务了。
一个通用点赞系统需要满足下列特性:
而要保证安全,避免重复点赞,我们就必须保存每一次点赞记录。只有这样在下次用户点赞时我们才能查询数据,判断是否是重复点赞。同时,因为业务方经常需要根据点赞数量排序,因此每个业务的点赞数量也需要记录下来。
综上,点赞的基本思路如下:
点赞服务必须独立,因此必须抽取为一个独立服务。点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。
点赞的数据结构分两部分,一是点赞记录,二是与业务关联的点赞数【基本每个具体业务都预留了一个点赞数量的字段liked_times】
点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:
不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:
1 | CREATE TABLE IF NOT EXISTS `liked_record` ( |
从表面来看,点赞功能要实现的接口就是一个点赞接口。不过仔细观察所有的点赞页面,你会发现点赞按钮有灰色和点亮两种状态。
也就是说我们还需要实现查询用户点赞状态的接口,这样前端才能根据点赞状态渲染不同效果。因此我们要实现的接口包括:
当用户点击点赞按钮的时候,第一次点击是点赞,按钮会高亮;第二次点击是取消,点赞按钮变灰:
从后台实现来看,点赞就是新增(insert)一条点赞记录,取消就是删除(delete)这条点赞记录。——为了方便前端交互——->个合并为一个接口即可。
因此,请求参数首先要包含点赞有关的数据,并且要标记是点赞还是取消:
除此以外,我们之前说过,在问答、笔记等功能中都会出现点赞功能,所以点赞必须具备通用性。因此还需要在提交一个参数标记点赞的类型:
返回值有两种设计:
这里推荐使用方案一,因为每次统计点赞数量也有很大的性能消耗。
我们先梳理一下点赞业务的几点需求:
由于业务方的类型很多,比如互动问答、笔记、课程等。所以通知方式必须是低耦合的,这里建议使用MQ来实现。
当点赞或取消点赞后,点赞数发生变化,我们就发送MQ通知。整体业务流程如图:
暂时无法在飞书文档外展示此内容
需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可。
在RabbitMQ中,利用TOPIC类型的交换机,结合不同的RoutingKey,可以实现通知对象的变化。我们需要让不同的业务方监听不同的RoutingKey,然后发送通知时根据点赞类型不同,发送不同RoutingKey:
综上,按照Restful风格设计,接口信息如下:
无
点赞【新增一行】:
取消点赞【删除一行】:
问题三:怎么统计点赞数
只需要点赞业务id和点赞业务类型,因为这两条就可以确定某一个类型的哪个评论/回复/笔记总共点赞数【不需要分用户】
前端根据不同状态显示不同样式:
由于这个接口是供其它微服务调用,实现完成接口后,还需要定义对应的FeignClient:
1.实现查询点赞情况
2.实现对应FeignClient:提供给其他微服务调用
无
整体思路:
是否点赞:就是我传入多个bizid(业务id),你看看哪些业务(一条评论/一条回复)是被点赞过;我就去查询点赞数据库,如果有那就返回这个id【前端根据传回来id进行处理】
点赞多少:根据bizType去判断是QA还是note,然后查询对应表id的对应点赞数然后返回
既然点赞后会发送MQ消息通知业务服务,那么每一个有关的业务服务都应该监听点赞数变更的消息,更新本地的点赞数量。
点赞/取消点赞业务[点赞微服务]添加:发送消息,发送点赞数和点赞id
回复/评论业务[其他微服务]添加:接受消息,更新点赞信息
传递消息[业务id和点赞数],这样通过业务id(主键)能获取到一行数据,然后根据id更新业务点赞数
1.点赞/取消点赞—–>统计点赞总数(只根据点赞业务和点赞id就可以确定是问答/笔记表的一行数据) —–>发送MQ通知[点赞业务,点赞数]
2.传入多个业务id,判断是否有点赞【直接根据业务id,业务类型,用户id查询点赞表是否有数据就行】
3.监听点赞数【其他微服务通过1获取消息,然后更新对应的数据库一行数据】
1.点赞/取消点赞,一次就要发送MQ进行更新点赞【太频繁】 —> 定时任务【定时去批量更新】
2.点赞、取消、再点赞、再取消多少次【读写太频繁】 —> 合并写【反正业务方只关注最终点赞结果】
优化图:
【从原来一次性的从头到尾—>redis处理,缓存,定时异步】
因此将①点赞/取消点赞 / ②点赞数据分别放入redis缓存!!!
点赞记录中最两个关键信息:
用户是否点赞【需要业务id,业务类型,用户id】—> 一个数据结构
某业务的点赞总次数【需要业务id,业务类型】—>一个数据结构
因为要知道某个用户是否点赞某个业务,就必须记录业务id以及给业务点赞的所有用户id . 由于一个业务可以被多个用户点赞,那就需要一个集合存储[哪个类型的哪个业务—-对应user_Id谁点赞],并且要判断用户是否点赞这个操作具有存在且唯一的特性 —> set最符合
点赞,那就sadd方法—–取消点赞,那就srem方法—–判断是否点赞过,那就sismember方法—–统计点赞总数,那就scard方法
因为只需要业务id和业务类型去判断对应的点赞数,因此我们可以将业务类型作为key,业务id作为键,点赞数作为值。这样键值对集合 —> hash或者sortedSet都符合。
Hash:传统键值对集合,无序
SortedSet:基于Hash结构,[+跳表]。因此可排序,但更占用内存
从节省内存方面hash更好,但是考虑将来要从redis获取点赞数,然后移除[避免重复处理]。为了保证线程安全,查询和移除的操作具备原子性,刚好zset就有几个移除并且获取的功能,天生具备原子性。并且我们每隔一段时间就将数据从redis移除,并不会占用太多内存。
也就是说,用户的一切点赞行为,以及将来查询点赞状态我们可以都走Redis,不再使用数据库查询。
我们现在点赞的时候(业务idbizId,业务类型bizType,用户id)这三个参数可以确定是谁点赞的,点赞的是什么类型的,点赞的是哪一个
这样可以考虑【key=业务类型+业务id,value=用户id】
由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。
当用户对某个业务点赞时,我们统计点赞总数,并将其缓存在Redis中。这样一来在一段时间内,不管有多少用户对该业务点赞(热点业务数据,比如某个微博大V),都只在Redis中修改点赞总数,无需修改数据库。
原来:新增点赞是直接插入数据库,统计是根据条件查询数据库,点赞总数是根据条件查询数据库,然后直接发送消息给MQ
现在:新增点赞和取消点赞以及统计点赞数量用redis的set
点赞总数用zset缓存
不着急送MQ,而是通过定时任务去[定期批量]发送bizId和点赞数
==之前串行化,通过redis的set来存储点赞或者取消点赞,然后使用zset存储点赞总数,通过定时任务来发送mq异步接收消息==
从原来直接查数据库获取点赞总数(改为set统计行数就是点赞数),然后zset缓存业务的点赞数
从原来的直接发送一个MQ(改为定时20s一次性扫描处理30个Zset的多个业务的多个业务id下面的点赞数,批量发送MQ),然后批量处理更新点赞
==使用Redis的管道批量处理,解决只能isMember判断单个bizId是否有用户点赞==
redis管道:
管道就可以优化,只需要1次往返的网络传输耗时即可
具体代码:
就是使用redisTemplate下面的executePipelined()方法批量处理,它会将多个要判断的条件一次性打包给redis,然后redis判断是否存在(存在是true,不存在是false),然后将结果给方法返回值resultList(他的顺序就是和id顺序一致),最后遍历resultList,如果是true说明对应的业务id被这个用户点赞过,需要返回
==消息接收者:更改为批量获取,批量处理==
点赞和取消点赞的set:
统计业务的点赞数:
先在设计之初我们分析了一下点赞业务可能需要的一些要求。
例如,在我们项目中需要用到点赞的业务不止一个,因此点赞系统必须具备通用性,独立性,不能跟具体业务耦合。
再比如,点赞业务可能会有较高的并发,我们要考虑到高并发写库的压力问题。
所以呢,我们在设计的时候,就将点赞功能抽离出来作为独立服务。当然这个服务中除了点赞功能以外,还有与之关联的评价功能,不过这部分我就没有参与了。在数据层面也会用业务类型对不同点赞数据做隔离,隔离的手段就是在数据库表中设置了业务类型字段,目前是一张表中记录,将来我们如果数据量过大,还可以考虑基于业务类型对数据库做分表。
从具体实现上来说,为了减少数据库压力,我们会利用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并不是用来实现点赞业务的,因为点赞只靠SET就能实现了。但是这里有一个问题,我们要定期将业务方的点赞总数通过MQ同步给业务方,并持久化到数据库。但是如果只有SET,我没办法知道哪些业务的点赞数发生了变化,需要同步到业务方。
因此,我们又添加了一个ZSET结构,用来记录点赞数变化的业务及对应的点赞总数。可以理解为一个待持久化的点赞任务队列。
每当业务被点赞,除了要缓存点赞记录,还要把业务id及点赞总数写入ZSET。这样定时任务开启时,只需要从ZSET中获取并移除数据,然后发送MQ给业务方,并持久化到数据库即可。
首先,假设定时任务每隔2分钟执行一次,一个业务如果在2分钟内多次被点赞,那就会多次向List中添加同一个业务及对应的点赞总数,数据库也要持久化多次。这显然是多余的,因为只有最后一次才是有效的。而使用ZSET则因为member的唯一性,多次添加会覆盖旧的点赞数量,最终也只会持久化一次。
(面试官可能说:“那就改为SET结构,SET中只放业务id,业务方收到MQ通知后再次查询不就行了。”如果没问就自己往下说)
当然要解决这个问题,也可以用SET结构代替List,然后当业务被点赞时,只存业务id到SET并通知业务方。业务方接收到MQ通知后,根据id再次查询点赞总数从而避免多次更新的问题。但是这种做法会导致多次网络通信,增加系统网络负担。而ZSET则可以同时保存业务id及最新点赞数量,避免多次网络查询。
不过,并不是说ZSET方案就是完全没问题的,毕竟ZSET底层是哈希结构+跳表,对内存会有额外的占用。但是考虑到我们的定时任务每次会查询并删除ZSET数据,ZSET中的数据量始终会维持在一个较低级别,内存占用也是可以接受的。
整体来说,流程是这样的:
根据原型图可以得到对应字段
基本上根据页面原型图得到的字段:
可以生成对应的文件和实体类等信息
理论上我们应该先设计所有接口,再继续设计接口对应的表结构。不过由于接口较多,这里我们先对接口做简单统计。然后直接设计数据库,最后边设计接口,边实现接口。
问题页面:
结合原型设计图我们可以看到这里包含4个接口:
问题的回答和评论页面:
可以看到页面中包含5个接口:
刚才分析的都是用户端的相关接口,这些接口部分可以与管理端共用,但管理端也有自己的特有需求。
管理端也可以分页查询问题列表,而且过滤条件、查询结果会有很大不同:
比较明显的有两个接口:
除此以外,这里有一个问题状态字段,表示管理员是否查看了该问题以及问题中的回答。默认是未查看状态;当管理员点击查看后,状态会变化为已查看;当学员再次回答或评论,状态会再次变为未查看。
因此,需要注意的是:
管理端也会有回答列表、评论列表。另外,回答和评论同样有隐藏功能。
问题详情和回答列表:
还有评论列表:
总结一下,回答和评论包含的接口有:
比较简单,通过前端传递给我{课程id,章id,小节id,问题标题,问题具体描述,问题是否匿名}
通过新增的问题的表单即可分析出接口的请求参数信息了,然后按照Restful的风格设计即可:
无
无
修改与新增表单基本类似,此处不再分析。我们可以参考新增的接口,然后按照Restful的风格设计为更新即可:
无
要注意校验问题是否是自己的,校验是否有这条问题
这就是一个典型的分页查询。主要分析请求参数和返回值就行了。
请求参数就是过滤条件,页面可以看到的条件有:
返回值格式,从页面可以看到属性有:
综上,按照Restful来设计接口,信息如下:
[for循环遍历组装数据]
无
主要就是根据问题表和问答表查询对应信息:
测试结果:
1 | { |
封装过程:
由此可以看出详情页所需要的信息相比分页时,主要多了问题详情,主要字段有:
1 | 1.根据问题id获取一条问题 |
而请求参数则更加简单了,就是问题的id
然后,再按照Restful风格设计,接口就出来了:
无
要注意只有不匿名的情况下才能获取用户信息
需要注意的是,当用户删除某个问题时,也需要删除问题下的回答、评论。
整体业务流程如下:
无
需要注意的是,当用户删除某个问题时,也需要删除问题下的回答、评论。
整体业务流程如下:
针对回答和评论的区别:
综上,按照Restful的规范设计,接口信息如下:
无,使用mq即可
问题一:回复和评论的区别
回复:回答哪个问题,回复什么内容,要不要被看到
评论:回答哪个问题,回复什么内容,要不要被看到 + 【上一级】是哪个回答下面,评论哪个回答,针对谁
在问题详情页,除了展示问题详情外,最重要的就是回答列表了,原型图如下:
我们先来分析回答列表,需要展示的内容包括:
请求参数就是问题的id。不过需要注意的是,一个问题下的回答比较多,所以一次只能展示一部分,更多数据会采用滚动懒加载模式。简单来说说就是分页查询,所以也要带上分页参数。
再来看一下回答下的评论列表:
仔细观察后可以发现,需要展示的数据与回答及其相似,都包括:
从返回结果来看:相比回答列表,评论无需展示评论下的评论数量,但是需要展示目标用户的昵称,因为评论是针对某个目标的。
从查询参数来看:查询评论需要知道回答的id,这点与查询回答列表不太一样。
综上,按照Restful的规范设计,接口信息如下:
无
就是拼接数据,没啥难度
在管理端后台存在问答管理列表页,与用户端类似都是分页查询,但是请求参数和返回值有较大差别:因此需要引入ES处理
从请求参数来看,除了分页参数,还包含3个:
从返回值来看,比用户端多了一些字段:
由于请求入参和返回值与用户端有较大差异,因此我们需要设计一个新的接口:
第一部分:
第二部分:
第三部分:
第四部分:
无
问题二:查询条件是课程名称,数据是课程id,怎么实现模糊查询?
所有上线的课程数据都会存储到Elasticsearch
中,方便用户检索课程。并且在tj-search
模块中提供了相关的查询接口
问题三:那怎么保证ES和Mysql数据一致性?
1 | 方法一:同步双写,课程上架的时候数据写入Mysql,同步也写入ES |
策略 | 优点 | 缺点 |
---|---|---|
同步双写 | - 简单易实现 - 实时性高 |
- 代码侵入性强 - 存在不一致的风险 - 可能影响系统性能 |
异步双写(MQ方式) | - 解耦数据写入操作 - 通过消息队列提升性能和扩展性 |
- 系统复杂度增加 - 可能存在消息丢失的风险 - 引入了消息中间件的依赖 |
定期同步 | - 实现简单 - 无需改变现有业务逻辑 |
- 实时性差 - 可能给数据库带来额外压力 |
基于Binlog实时同步 | - 无代码侵入 - 实时性较好 - 业务逻辑与数据同步解耦 |
- 构建Binlog系统复杂 - 可能存在MQ延时风险 |
使用Canal监听Binlog同步数据到ES | - 基于MySQL的Binlog,实现数据的实时同步 - 减少系统耦合 |
- 需要维护额外的Canal服务 |
在管理端的互动问题列表中,管理员可以隐藏某个问题,这样就不会在用户端页面展示了:
由于interaction_question
表中有一个hidden
字段来表示是否隐藏:
因此,本质来说,这个接口是一个修改某字段值的接口,并不复杂。
我们按照Restful的风格来设定,接口信息如下:
/admin/questions/{id}/hidden/{hidden}
PUT
无
就是简单修改字段
在管理端的问题管理页面,点击查看按钮就会进入问题详情页:
问题详情页如下:
可以看到,这里需要查询的数据还是比较多的,包含:
返回值与管理端分页查询基本一致,多了一个课程负责老师信息。所以我们沿用之前的QuestionAdminVO
即可。但是需要添加一个课程负责老师的字段:
虽然用户端也有根据id查询问题,但是返回值与用户端存在较大差异,所以我们需要另外设计一个接口。
按照Restful风格,接口信息如下:
/admin/questions/{id}
GET
无
问题表中有一个status字段,标记管理员是否已经查看过该问题。因此每当调用根据id查询问题接口,我们可以认为管理员查看了该问题,应该将问题status标记为已查看。
可以看到,返回的数据格式包含:
与用户端查询几乎完全一致。
与用户端查询几乎完全一致,为什么不使用同一个接口?
原因有两点:
所以在实现的时候,基本逻辑可以与用户端分页一致,但统计评论数量、处理用户信息时,需要区别对待。
为了减少代码重复,大家可以对代码做改造抽取,不要重复copy代码
在用户端的代码添加一个属性判断是否是真:用户端为false,管理端为true【区别:统计数量和用户信息字段】
原因有两点:
①管理端在统计评论数量的时候,被隐藏的评论也要统计(用户端不统计隐藏回答)
②管理端无视匿名,所有评论都要返回用户信息;用户端匿名评论不返回用户信息。
与问题类似,管理员也可以显示或隐藏某个评论或评论:
与隐藏问题类似,同样是修改hidden字段。
与隐藏问题类似,同样是修改hidden字段
【注意:如果隐藏的是回答,则回答下的评论也要隐藏】
/admin/replies/{id}/hidden/{hidden}
PUT
【主要涉及主键id,学员id和课程id也要记录[要考虑是谁学了什么课程]】
课表要记录的是用户的学习状态,所谓学习状态就是记录谁在学习哪个课程,学习的进度如何。
其中,谁在学习哪个课程,就是一种关系。也就是说课表就是用户和课程的中间关系表。因此一定要包含三个字段:
而学习进度,则是一些附加的功能字段,页面需要哪些功能就添加哪些字段即可:
status:课程学习状态。0-未学习,1-学习中,2-已学完,3-已过期
planStatus:学习计划状态,0-没有计划,1-计划进行中
weekFreq:计划的学习频率
learnedSections:已学习小节数量,【注意:课程总小节数、课程名称、封面等可由课程id查询得出,无需重复记录】
latestSectionId:最近一次学习的小节id,方便根据id查询最近学习的课程正在学第几节
latestLearnTime:最近一次学习时间,用于分页查询的排序:
createTime和expireTime,也就是课程加入时间和过期时间
参考我的Mybatis-plus笔记-代码生成步骤:
1 | create table learning_lesson |
接下来,我们来分析一下添加课表逻辑的业务流程。首先来对比一下请求参数和数据库字段:
参数:
数据表:
一个userId和一个courseId是learning_lesson表中的一条数据。而订单中一个用户可能购买多个课程。因此请求参数中的courseId集合就需要逐个处理,将来会有多条课表数据。
另外,可以发现参数中只有userId和courseId,表中的其它字段都需要我们想办法来组织:
可见在整张表中,需要我们在新增时处理的字段就剩下过期时间expire_time
了。而要知道这个就必须根据courseId查询课程的信息,找到其中的课程有效期(valid_duration
)。课程表结构如图:
因此,我们要做的事情就是根据courseId集合查询课程信息,然后分别计算每个课程的有效期,组织多个LearingLesson的数据,形成集合。最终批量新增到数据库即可。
流程如图:
其中消息发送者信息:
1 | 使用枚举类的优点: |
【我和课程下单统一OrderBasicDTO,主要传递orderId和courseID和userID以及完成时间】
问题一:课程过期时间怎么算?
课程过期时间=课程加入课程时间(当前)+课程有效期(通过传入的courseId课程id远程调用课程微服务获取media_duration有效时间)
问题二:如果这个人网络不好,重复下单怎么保证幂等性?
1.我给(courseId,userId)创建唯一索引,保证幂等性
2.我使用redis:进来的时候判断OrderId订单id是否有,有的话就重复,没有的话就存在redis[设置60s]
问题三:Id如何设计?
分为分库和不分库情况:我考虑并发就分库,然后使用雪花算法【还有其他方法,在tk实习时候考虑的那个笔记里面】
参考本文:https://mp.weixin.qq.com/s/zQNfcpCbPoo4yQFJR7FpqQ
肉眼可见的字段就包含:
还有一些字段是页面中没有的,但是可以从功能需要中推测出来,例如:
使用courseList.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c))转换为map,在后续直接取出来就行
learning_lesson
course
course_catalogue
主要分为四个部分数据:
3.serviceimpl层
4.mapper层
问题一:什么是最近学习的一门课程【基本上围绕learning-lesson和course以及course-catalogue三个数据库表获取数据】
可以在学习中心位置查看最近学习的一门课程,主要是通过userId用户id查询一条课程表信息;通过课程表信息的courseId课程id查询课程的具体信息;通过课程表信息的latest_section_id最近一次学习的小节名称远程调用课程学习微服务获取(通过latest_section_id查询course-catalogue表数据);通过userId用户id来count(*)获得数据
在课程详情页,课程展示有两种不同形式:
①根据courseId和UserId(两者是唯一索引,能保证只有得到一条数据)查询课表得到公共数据,针对课程具体信息要传入courseId课程id远程调用查询
删除课表中的课程有两种场景:
现在那边退款成功之后增加步骤4[发送消息,我需要负责接受消息]
这里我们可以按照Restful的规范来定义这个删除接口:
/ls/lessons/{courseId}
2.service层
3.serviceimpl层
问题一:删除的有哪几种情况?
①根据用户下单,然后取消报名的时候发送MQ消息给learning-service微服务告知删除
②已经学习了很久,课程失效了就直接根据情况删除
这是一个微服务内部接口,当用户学习课程时,可能需要播放课程视频。此时提供视频播放功能的媒资系统就需要校验用户是否有播放视频的资格
。所以,开发媒资服务(tj-media
)的同事就请你提供这样一个接口。
用户要想有播放视频的资格,那就必须满足两个条件:
问题一:如何判断课程是否有效
就是①判断课表是否有这个课程,②这个课程的expire过期时间是否失效了,没办法学了
课程微服务中需要统计每个课程的报名人数,同样是一个内部调用接口,在tj-api模块中已经定义好了:
1 | /** |
这里我们可以按照Restful的规范来定义这个统计接口:
/lessons/{courseId}
问题一:sql怎么写?怎么统计
1 | select count(user_id) |
在东林在线微课堂-我的课表相关:已经可以实现课表的增删改查接口,但是在查看已学习课程时候有两个字段没有返回:
我们需要在查询结果中返回已学习课时数、正在学习的章节名称。虽然我们在learning_lesson表中设计了两个字段:
以上的问题归纳下来,就是一个学习进度统计问题,这在在线教育、视频播放领域是一个非常常见的问题。
大部分人的学习自律性是比较差的,属于“买了就算会了”的状态。如果学员学习积极性下降,学习结果也会不尽人意,从而产生挫败感。导致购买课程的欲望也会随之下降,形成恶性循环,不利于我们卖课。
所以,我们推出学习计划的功能,让学员制定一套学习计划,每周要学几节课。系统会做数据统计,每一周计划是否达标,达标后给予奖励,未达标则提醒用户,达到督促用户持续学习的目的。
用户学习效果好了,产生了好的结果,就会有继续学习、购买课程的欲望,形成良性循环。
因此,学习计划、学习进度统计其实是学习辅助中必不可少的环节。
在我的课程页面,可以对有效的课程添加学习计划:
学习计划就是简单设置一下用户每周计划学习几节课:
有了计划以后,我们就可以在我的课程页面展示用户计划的完成情况,提醒用户尽快学习:
可以看到,在学习计划中是需要统计用户“已经学习的课时数量”。
在原型图《课程学习页-录播课-课程学习页-目录》中,可以看到学习课程的原型图:
一个课程往往包含很多个章(chapter),每一章下又包含了很多小节(section)。章本身没有课程内容,只是划分课程的一个概念。小节分两种,一种是视频;一种是每章最后的阶段考试 —-> 用户学完一个视频/参加了最终的考试都算学完了一个小节。
==统计学习进度:====用户学了多少小节[①视频:完播率超过75%②考试:考试提交]==
因而引出几个问题:
因此,用户在播放视频的过程中,需要不断地提交视频的播放进度,当我们发现视频进度超过75%的时候就标记这一小节为已完成
因此,我们需要记录视频是否完成,也需要记录用户具体播放到第几秒视频[这样下次播放就可以实现视频自动续播]
也就是说,要记录用户学习进度,需要记录下列核心信息:
用户每学习一个小节,就会新增一条学习记录,当该课程的全部小节学习完毕,则该课程就从学习中进入已学完状态了。整体流程如图:
数据表的设计要满足学习计划[learning_lesson
表在我的课表需求完成设计]、学习进度[目前需要]的功能需求:
按照之前的分析,用户学习的课程包含多个小节,小节的类型包含两种:
学习进度除了要记录哪些小节学完,还要记录学过的小节、每小节的播放的进度(方便续播)。因此,需要记录的数据就包含以下部分:
再加上一些表基础字段,整张表结构就出来了:
1 | CREATE TABLE IF NOT EXISTS `learning_record` ( |
我们需要准备一些VO和DTO等
按照用户的学习顺序,依次有下面几个接口:
在个人中心的我的课表列表中,没有学习计划的课程都会有一个创建学习计划的按钮,在原型图就能看到:
创建学习计划,本质就是让用户设定自己每周的学习频率:
当我们创建学习计划时,就是根据课程id和用户id去更新learning_lesson
表,写入week_freq
并更新plan_status
为计划进行中即可。
而学习频率我们在设计learning_lesson表的时候已经有两个字段来表示了:
当我们创建学习计划时,就是根据课程id和用户id去更新learning_lesson
表,写入week_freq
并更新plan_status
为计划进行中即可。
因此请求参数就是课程的id、每周学习频率。再按照Restful风格,最终接口如下:
无
就是简单的创建学习计划【根据userId和courseId课程id更新一行数据的weekFreq和status字段】
用户创建完计划自然要开始学习课程,在用户学习视频的页面,首先要展示课程的一些基础信息。例如课程信息、章节目录以及每个小节的学习进度:
其中:
①课程、章节、目录信息等数据都在课程微服务。【课程信息是必备的】
②学习进度肯定是在学习微服务。【学习进度却不一定存在】
①课程、章节、目录信息等数据都在课程微服务。【课程信息是必备的】
②学习进度肯定是在学习微服务。【学习进度却不一定存在】
因此,查询这个接口的请求———>课程微服务【查询课程、章节信息】,再由课程微服务———>学习微服务【查询学习进度】,合并后一起返回给前端即可。
所以,学习中心要提供一个查询章节学习进度的Feign接口,事实上这个接口已经在tj-api模块的LearningClient中定义好了:
根据courseId和userId获取课表id和最近学习的小节id,然后根据课表id获取多条学习记录。【小节id,小节视频播放进度,小节是否学习完】
对应的DTO也都在tj-api模块定义好了,因此整个接口规范如下:
无[就是查询而已]
之前分析业务流程的时候已经聊过,学习记录==用户当前学了哪些小节,以及学习到该小节的进度如何。而小节类型分为考试、视频两种。
只要记录了用户学过的每一个小节,以及小节对应的学习进度、是否学完。无论是视频续播、还是统计学习计划进度,都可以轻松实现了。
因此,提交学习记录就是提交小节的信息和小节的学习进度信息。考试提交一次即可,视频则是播放中频繁提交。提交的信息包括两大部分:
小节的基本信息
播放进度信息
具体业务思路:
综上,提交学习记录的接口信息如下:
serviceimpl层代码整体逻辑:
其中处理课表:
其中处理视频:
其中处理考试:
无【全用的mybatisplus完成】
问题一:学习记录服务有必要提交到服务端?在客户端不就可以保存
我设置的是课程学习页面播放视频时/考试后,需要提交学习记录信息到服务端保存。每隔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>课程总小节数【课程微服务查询出】
在个人中心的我的课程页面,会展示用户的学习计划及本周的学习进度,原型如图:
需要注意的是这个查询其实是一个分页查询,因为页面最多展示10行,而学员同时在学的课程可能会超过10个,这个时候就会分页展示,当然这个分页可能是滚动分页,所以没有进度条。另外,查询的是我的学习计划,隐含的查询条件就是当前登录用户,这个无需传递,通过请求头即可获得。
因此查询参数只需要分页参数即可。
查询结果中有很多对于已经学习的小节数量的统计,因此将来我们一定要保存用户对于每一个课程的学习记录,哪些小节已经学习了,哪些已经学完了。只有这样才能统计出学习进度。
查询的结果如页面所示,分上下两部分。:
①总的统计信息:
②正在学习的N个课程信息的集合,其中每个课程包含下列字段:
综上,查询学习计划进度的接口信息如下:
输出结果分为两个模块:
①本周计划和积分奖励
②课程信息:
【本质就是,学习记录表一行就是学了一个小节;表内每个课程都有一个week_freq,计算总和就是本周计划总数】
1 | #统计用户本周已学习小节总数 |
【本质就是,分页查询就是加个limit;某个课程要根据group by lesson_id课程id,因为一个课程在record记录表每个小节id都有一行数据,一个课程id在lesson课表中每个课程有一个】
1 | #分页查询当前用户的课表 |
1 | public static LocalDateTime getWeekBeginTime(LocalDate now) { |
定期检查learning_lesson表中的课程是否过期,如果过期则将课程状态修改为已过期。
①SpringTask定时任务使用@Scheduled注解+@Async异步调用+@Retryable重试机制 —》保证既定时执行又异步且具备重试功能的健壮任务
②实现SchedulingConfigurer接口
③Quartz框架
④MQ延迟队列 【在定时任务方法里面发送消息给MQ,让MQ进行业务修改】
答:我参与了整个学习中心的功能开发,其中有很多的学习辅助功能都很有特色。比如视频播放的进度记录。我们网站的课程是以录播视频为主,为了提高用户的学习体验,需要实现视频续播功能。这个功能本身并不复杂,只不过我们产品提出的要求比较高:
首先续播时间误差要控制在30秒以内[每隔15s发起一次心跳请求,请求最新的播放进度,存储在服务器]
而且要做到用户突然断开,甚至切换设备后,都可以继续上一次播放[播放记录必须保存在服务端,而不是客户端(传统的只能保证一个设备)]
要达成这个目的,使用传统的手段显然是不行的。
首先,要做到切换设备后还能续播,用户的播放进度必须保存在服务端,而不是客户端。
其次,用户突然断开或者切换设备,续播的时间误差不能超过30秒,那播放进度的记录频率就需要比较高。我们会在前端每隔15秒就发起一次心跳请求,提交最新的播放进度,记录到服务端[写在数据库内,可能会导致数据库压力过大问题]。这样用户下一次续播时直接读取服务端的播放进度,就可以将时间误差控制在15秒左右。
其中,②水平扩展和③服务保护侧重的是运维层面的处理。而①提高单机并发能力侧重的则是业务层面的处理,也就是我们程序员在开发时可以做到的。
在机器性能一定的情况下,提高单机并发能力就是要尽可能缩短业务的响应时间(ResponseTime),而对响应时间影响最大的往往是对数据库的操作。而从数据库角度来说,我们的业务无非就是读/写两种类型。
对于==读>写==的业务,其优化手段大家都比较熟悉了,主要包括两方面:
对于==读<写==的业务,大家可能较少碰到,优化的手段可能也不太熟悉,这也是我们要讲解的重点。
对于高并发写的优化方案有:
由于各个业务之间是同步串行执行,因此整个业务的响应时间就是每一次数据库写业务的响应时间之和,并发能力肯定不会太好。
优化的思路很简单,利用MQ可以把同步业务变成异步,从而提高效率。
这样一来,用户请求处理和后续数据库写就从同步变为异步,用户无需等待后续的数据库写操作,响应时间自然会大大缩短。并发能力自然大大提高。
①无需等待复杂业务处理,大大减少了响应时间 ②利用MQ暂存消息,起到流量削峰整形 ③降低写数据库频率,减轻数据库并发压力
①依赖于MQ的可靠性 ②只是降低一些频率,但是没有减少数据库写次数
业务复杂, 业务链较长,有多次数据库写操作的业务
合并写请求方案其实是参考高并发读的优化思路:当读数据库并发较高时,我们可以把数据缓存到Redis,这样就无需访问数据库,大大减少数据库压力,减少响应时间。
合并写请求就是指当写数据库并发较高时,不再直接写到数据库。而是先将数据缓存到Redis,然后定期将缓存中的数据批量写入数据库。
由于Redis是内存操作,写的效率也非常高,这样每次请求的处理速度大大提高,响应时间大大缩短(↓),并发能力肯定有很大的提升。
而且由于数据都缓存到Redis了,积累一些数据后再批量写入数据库,这样数据库的写频率(↓)、写次数(↓)都大大减少,对数据库压力小了非常多!
①写缓存速度快,响应时间大大缩短(↓) ②降低数据库的写频率(↓)和写次数(↓)
①实现相对复杂 ②依赖Redis可靠性 ③不支持事务和复杂业务
写频率高,写业务相对简单的业务
提交进度统计包含大量的数据库读、写操作。不过提交播放记录还是以写数据库为主。因此优化的方向还是以高并发写优化为主。
也就是说,95%的请求都是在更新learning_record
表中的moment
视频播放秒数字段,以及learning_lesson
表中的最近正在学习的小节id和最近学习时间两个字段上。
而播放进度信息,不管更新多少次,下一次续播肯定是从最后的一次播放进度开始续播。也就是说我们只需要记住最后一次即可。因此可以采用合并写方案来降低数据库写的次数和频率,而异步写做不到。
综上,提交播放进度业务虽然看起来复杂,但大多数请求的处理很简单,就是==更新播放进度==。并且播放进度数据是可以合并的(覆盖之前旧数据)。我们建议采用合并写请求方案:
我们的优化方案要处理的不是所有的提交学习记录请求。仅仅是视频播放时的高频更新播放进度的请求,对应的业务分支如图:
这条业务支线的流程如下:
这里有多次数据库操作,例如:
一方面我们要缓存写数据,减少写数据库频率;另一方面我们要缓存播放记录,减少查询数据库。因此,缓存中至少要包含3个字段:
记录id:id,用于根据id更新数据库
播放进度:moment,用于缓存播放进度
播放状态(是否学完):finished,用于判断是否是第一次学完
既然一个课程包含多个小节,我们完全可以把一个课程的多个小节作为一个KEY来缓存,==Redis最终数据结构如图==:
这样做有两个好处:
添加缓存之后,业务逻辑更改为:
变化后的业务具体流程为:
但是定时任务的持久化方式在播放进度记录业务中存在一些问题,主要就是时效性问题。我们的产品要求视频续播的时间误差不能超过30秒。
因此,我们考虑将用户==最后一次提交==的播放进度写入数据库
==【只要用户一直在提交记录,Redis中的播放进度就会一直变化。如果Redis中的播放进度不变,肯定是停止了播放,是最后一次提交】==
因此,我们只要能判断Redis中的播放进度是否变化即可—–>每当前端提交(15s)播放记录时,我们可以设置一个延迟任务并保存这次提交的进度。等待20秒后(因为前端每15秒提交一次,20秒就是等待下一次提交),检查Redis中的缓存的进度与任务中的进度是否一致。
流程如下:
针对2.4提出用户提交的播放记录是否变化,我们需要将更新播放记录做一个延迟任务,等待超过一个提交周期(20s)后检查播放进度
延迟任务的实现方案有很多,常见的有四类:
DelayQueue | Redisson | MQ | 时间轮 | |
---|---|---|---|---|
原理 | JDK自带延迟队列,基于阻塞队列实现。 | 基于Redis数据结构模拟JDK的DelayQueue实现 | 利用MQ的特性。例如RabbitMQ的死信队列 | 时间轮算法 |
优点 | 不依赖第三方服务 | 分布式系统下可用不占用JVM内存 | 分布式系统下可以不占用JVM内存 | 不依赖第三方服务性能优异 |
缺点 | 占用JVM内存只能单机使用 | 依赖第三方服务 | 依赖第三方服务 | 只能单机使用 |
以上四种方案都可以解决问题,不过本例中我们会使用DelayQueue方案。因为这种方案使用成本最低,而且不依赖任何第三方服务,减少了网络交互。
但缺点也很明显,就是需要占用JVM内存,在数据量非常大的情况下可能会有问题。但考虑到任务存储时间比较短(只有20秒),因此也可以接收。
【如果数据量非常大,DelayQueue不能满足业务需求,大家也可以替换为其它延迟队列方式,例如Redisson、MQ等】
1 | //实现了BlockingQueue接口【是一个阻塞队列】 |
其中
从源码中可以看出,Delayed类型必须具备两个方法:
可见,Delayed类型的延迟任务具备两个功能:①获取剩余延迟时间、②比较执行顺序
将来每一次提交播放记录,就可以将播放记录保存在这样的一个Delayed
类型的延迟任务里并设定20秒的延迟时间。然后交给DelayQueue
队列。DelayQueue
会调用compareTo
方法,根据剩余延迟时间对任务排序。剩余延迟时间越短的越靠近队首,这样就会被优先执行。
首先定义一个Delayed类型的延迟任务类,要能保持任务数据。
1 | public class DelayTask<T> implements Delayed { //实现Delayed接口【实现两个方法】 |
接下来就可以创建延迟任务,交给延迟队列保存:
1 | @Slf4j |
注意:本用例直接同一个线程来执行任务了。当没有任务的时候线程会被阻塞。而在实际开发中,我们会准备线程池,开启多个线程来执行队列中的任务。
具体改造之后的业务逻辑图:
是第一次学习,更新学习记录,删除redis
1 | package com.tianji.learning.task; |
插入到redis,直接返回false这样后续4的更新学习记录就不会执行
不是第一次学完,多次提交的情况: