QPS

1.高并发定义

高并发:系统在单位时间内承受大量用户请求的能力【一般衡量高并发的指标就有QPS】

QPS:【Queries Per Second每秒查询次数】用来判断并发量的高低。

2.QPS范围

image-20241116143448846

实习git体会

1.分支命名

不同公司中对Git的使用分支命名规范也略有差异,不过整体都会分为;上线预发开发测试,这样几个分支。如图是一种比较简单使用的拉取分支方式。

image-20241116121910335

  • master/main 作为主分支,不可直接修改代码代码,只能从分支合并到主分支进行进行提交。同时,master 分支的合并需要进行审批,审批后才能合并。
  • 开发前,先从 master 分支,拉一个开发分支。2024/10/11/xfg-xxx 使用带有斜线的分支命名会自动创建文件夹,对于多人开发的项目,可以直接归档。
  • 后开发,也就是研发已经完成了本地的验证。进行测试时,可以把研发的开发分支合并到 test 分支,提交、部署、测试。遇到测试bug,需要回到可发分支修改代码,之后合并到 test 分支部署验证。
  • pre/release 预发分支,用于测试完成后,把研发的开发分支合并到预发分支进行预发上线,上线后测试人员进行验证。最终完成验证后,把开发分支合并到 master 分支,并需要由架构师对合并代码审批通过。最后进行上线开量验证。
  • 如果是修复bug的,可以添加一个 fix-用户名缩写-具体功能

2.提交规范

保持一个标准的统一的规范提交代码,在后续的评审、检查、合并,都会非常容易处理。

image-20241116141614067

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 主要type
feat: 增加新功能
fix: 修复bug

# 特殊type
docs: 只改动了文档相关的内容
style: 不影响代码含义的改动,例如去掉空格、改变缩进、增删分号
build: 构造工具的或者外部依赖的改动,例如webpack,npm
refactor: 代码重构时使用
revert: 执行git revert打印的message

# 暂不使用type
test: 添加测试或者修改现有测试
perf: 提高性能的改动
ci: 与CI(持续集成服务)有关的改动
chore: 不修改src或者test的其余修改,例如构建过程或辅助工具的变动

3.合并分支

在公司中很多时候是大家一起在一个工程开发代码,那么这个时候就会涉及合并代码的。如果有多人共同开发一个接口方法,就会在合并的时候产生冲突。所以要特别注意。

image-20241116141659803

  1. 选择,你要从哪个分支合并到 test 分支。右键选择 Merge into test
  2. 如果你合并到test分支的代码,有其他人也在同一行做了改变或者格式化了代码,就会弹出一个合并冲突。这个时候你需要点 Merge 进行合并。
  3. 在点击 Merge 后,你会看到具体冲突的代码是什么,你可以有选择的从左右合并到中,最后点击 Apply。这个时候要注意不要把让别人的代码合并丢喽。
  4. 合并完的代码,不要直接 push,你要先本地 install 看是否可以打包。以及如果可以运行的话,可以本地先跑一下。最后 push 提交合并代码即可。

4.回滚代码

如果出现了合并代码冲突后,丢失了代码,那么这个时候一般要进行回滚操作,重新合并。

虽然 Git 提供了回滚代码的功能,但一定要谨慎使用。怎么谨慎?第一个谨慎就是 push 的代码一定确保可以构建和运行,否则不要 push!第二个谨慎是要回滚代码,需要和团队中对应的伙伴打招呼,避免影响别人测试或者上线。

4.1 全量回滚

image-20241116141751877

  1. 先选择要在哪个分支的哪次提交上进行回滚。这里选择的是 test 分支上的提交进行回滚。
  2. 这里选择 Hard 回滚。因为我们所有的都是合并到 test 分支,所以 test 分支丢失也没问题。可以重新合并。但要和同组伙伴提前说明。
  3. 回滚后,你会看到代码只剩下从回滚往下的提交内容了。
  4. 回滚后,你不能直接 push 提交了,这个之后会报错;fast-forward 因为此时本地分支落后于远程分支。
  5. 所以要通过 git push origin HEAD --force 进行强制提交。或者你可以把 test 的远程分支删掉,之后在提交。

4.2 cherry-pick

只把我修改的部分合并上去,使用cheery pick

image-20241116141916797

PageHelper

0.前提

在开发Web应用时,我们经常需要处理大量的数据展示,而分页功能几乎成了标配。它不仅提升了用户体验,还减轻了服务器的负担。今天,咱们就来聊聊一个在Java圈里非常流行的分页插件——PageHelper,看看它是如何在不动声色间帮我们搞定分页难题的。

1.定义

PageHelper是MyBatis的一个分页插件,它能够在不修改原有查询语句的基础上,自动实现分页功能。
简单来说,就是你在查询数据库时,告诉PageHelper你想看第几页、每页多少条数据,它就会帮你把结果集“裁剪”好。

2.分页原理

  • 主要依赖于两个核心步骤:拦截器分页SQL的生成

2.1 拦截器–Sql守门人

它首先会作为一个拦截器注册到MyBatis的执行流程中。这个拦截器就像是个守门人,会在SQL语句执行前和执行后进行一些“小动作”。

  • 执行前:当你发起一个查询请求时,PageHelper会检查当前线程是否已经设置了分页参数(比如页码、每页数量)。如果设置了,它就会根据这些参数计算出需要跳过的记录数和要查询的记录数。
  • 执行后:查询完成后,PageHelper还会对结果进行二次加工,比如封装成分页对象,包含总记录数、当前页的数据列表等信息。

2.2 分页SQL的生成

拦截到SQL语句后,PageHelper并不会直接修改你的原始SQL,而是通过动态生成一段分页SQL来实现分页功能。这个过程大致如下:

  • 计算分页参数:根据你提供的页码和每页数量,计算出起始位置和结束位置。
  • 拼接分页SQL:在原始SQL的基础上,添加LIMITOFFSET(或者数据库特定的分页语法,比如MySQL的LIMIT,Oracle的ROWNUM等),从而实现对结果集的裁剪。【如果要查询第20-30行数据。 limit 19,10 或者 limit 10 offset 19】
  • 执行分页SQL:最终,这个经过“加工”的SQL会被提交给数据库执行,返回的就是你想要的那一页数据了。

3.使用操作

具体操作网页:如何使用分页插件

  • 1.引入依赖:在你的项目中添加PageHelper的依赖,无论是Maven还是Gradle,都有现成的配置。

image-20241116115450030

  • 2.配置PageHelper:在MyBatis配置文件中简单配置一下PageHelper插件。
  • 3.代码中分页:在需要分页的查询方法前,调用PageHelper.startPage(pageNum, pageSize),其中pageNum是页码,pageSize是每页数量。
  • 4.获取分页结果:执行查询后,你可以直接从返回的结果中获取分页信息,比如总记录数、当前页数据等。

微服务和分布式系统设计

后台分布式架构形形色色,特别是微服务和云原生的兴起,诞生了一批批经典的分布式架构,然而在公司内部,或者其他大型互联网企业,都是抛出自己的架构,从接入层Controller,逻辑层Service,数据层Mapper都各有特点,但这些系统设计中到底是出于何种考量,有没有一些参考的脉络呢,本文将从云原生和微服务,有状态服务,无状态服务以及分布式系统等维度探讨这些脉络。

1.分布式系统概论

  • 定义:《Designing Data-Intensive Application》指出分布式系统:通过网络进行通信的多台机器的系统

  • 好处:

    • 1.容错/高可用性:将应用程序部署在多台机器/网络/整个数据中心,保证任意宕机时仍可以继续工作。
    • 2.可扩容性:负载均衡到多台机器。
    • 3.低延迟:在全球各地设置服务器,保证每个用户都可以从最靠近他们地理位置的数据中心获取服务,避免用户等待网络包绕地球半圈响应请求。
    • 4.资源弹性:可以设置云部署根据需求来扩展/收缩,保证每个用户只需要为实际使用的资源付费。
    • 5.法律合规:有的数据符合的规则需要符合不同国家地区的政策,因此需要分布到多个位置的服务器上。

【DDIA这本书主要是基于有数据有状态来讨论分布式。】

但是,现实的实践中,分布式系统存在①有状态和②无状态:

  • ①有状态

image-20241116104740469

  • ②无状态

image-20241116104911980

2.实现分布式系统的模式

  • AO:【微服务的顶层】封装应用程序的业务逻辑和处理流程;负责处理用户请求,调用相关的原子服务来完成特定任务;与其他对象进行交互,协调不同的功能模块。

  • BO:【微服务中相关的原子服务】,负责业务原子化的服务[特定业务/数据打交道];通常被各种AO服务调用

实现有状态的分布式系统,通常有以下三种:

2.1 单体应用

应用程序作为一个整体进行开发,测试和部署:

image-20241116111726965

优点:

  • 简单性:测试和部署更为简单。
  • 性能较好:所有功能都在同一个进程中运行,有较好的性能和响应能力。
  • 易于维护:所有代码和数据库都在同一个代码库和mysql库,很容易维护。

缺点:

  • 系统复杂度高‌:随着功能的增加,代码库变得庞大和复杂,导致开发人员难以理解整个系统,进而影响代码的质量和维护性。
  • 开发速度慢‌:由于需要编译、构建和测试整个项目,每次更改代码都会消耗大量时间,增加了开发成本。
  • 难以扩展‌:单体应用难以根据不同模块的需求进行针对性的扩展,往往需要整体扩展,导致资源利用效率低下。
  • ‌难以维护‌:模块之间的耦合度较高,修改一个模块的需求往往会带来连锁反应,影响其他模块的稳定性。
  • 难以采用新技术‌:项目是一个庞大的整体,使得应用新技术的成本很高,因为必须对整个项目进行重构,这通常是不可能的。
  • 开发速度慢‌:应用太大,每启动一次都需要很长时间,因此从编辑到构建、运行再到测试这个周期花费的时间越来越长。
  • 部署困难‌:代码部署的周期很长,而且容易出问题。程序更改部署到生产环境的时间变得更长,代码库复杂,以至于一个更改可能引起的影响是未知的。
  • 系统故障隔离差‌:应用程序缺乏故障隔离,因为所有模块都运行在同一个进程当中,任何部分的故障都可能影响整个系统的稳定性,导致宕机。

2.2 SOA架构–面向服务的架构

SOA架构关注于改变IT服务在企业范围内的工作方式,定义一种可通过服务接口复用软件组件并实现其互操作的方法。

image-20241116112342547

优点:

  • 可扩展性和灵活性:SOA 架构将系统拆分成独立的服务,可以按需组合和重组这些服务,从而实现系统的快速扩展和灵活部署。
  • 提高系统的可重用性:每个服务都是独立的功能单元,可以在不同的系统中复用,提高了系统的开发效率和维护成本。
  • 降低系统的耦合性:SOA 架构通过服务之间的松耦合关系,降低了服务之间的依赖性,有利于系统的模块化和维护。
  • 提高系统的稳定性和可靠性:SOA 架构采用了服务注册与发现机制、负载均衡、故障恢复等机制,提高了系统的稳定性和可靠性。

缺点:

  • 系统复杂度高:SOA 架构中涉及多个服务之间的协作和通信,系统的复杂度较高,开发、测试和维护成本相对较高。
  • 性能问题:由于服务之间的通信需要通过网络进行,可能存在网络延迟和性能损失,对系统的性能造成影响。
  • 安全性难以保障:SOA 架构中涉及多个服务之间的通信,需要对数据传输进行加密和安全控制,保障系统的安全性比较困难。
  • 部署和运维难度大:SOA 架构中涉及多个服务的部署和管理,需要专门的运维团队进行管理,增加了系统的复杂性和运维成本。

2.3 微服务

【SOA架构的一种变体】微服务架构是一种云原生架构常用的实现方式—更强调基于云原生,独立部署,Devops,持续交付

image-20241116112700938

优点:

  • 可扩展性和灵活性:SOA 架构将系统拆分成独立的服务,可以按需组合和重组这些服务,从而实现系统的快速扩展和灵活部署。

  • 提高系统的可重用性:每个服务都是独立的功能单元,可以在不同的系统中复用,提高了系统的开发效率和维护成本。

  • 降低系统的耦合性:SOA 架构通过服务之间的松耦合关系,降低了服务之间的依赖性,有利于系统的模块化和维护。

  • 提高系统的稳定性和可靠性:SOA 架构采用了服务注册与发现机制、负载均衡、故障恢复等机制,提高了系统的稳定性和可靠性

    【基于SOA架构新增的优点】

  • 独立性‌:每个服务可以独立部署和更新,提高了系统的灵活性和可靠性。

  • ‌可扩展性‌:根据需求,可以独立扩展单个服务,而不是整个应用程序。

  • ‌容错性‌:单个服务的故障不会影响其他服务,提高了系统的稳定性‌。

缺点:

【基于SOA架构,主要在运维和部署上增加了难度!!!】

需要处理的问题:

  1. 必须有接入层:如上图,微服务化后,必然存在用户需要直接链接后端服务,那么这个时候就需要网关来解耦这块,也就是上面接入层讨论的好处。
  2. 服务容错:多个微服务部署在云上,不同母机,会带来通讯的复杂性,网络问题会成为常态,那么如何容灾,容错,降级,也是需要考虑的。
  3. 服务发现:当服务 A 发布或者扩缩容时,依赖服务 A 的服务 X 如何在保持运行的前提下自动感知到服务 A 的变化。这里需要引入第三方服务注册中心来实现服务的可发现性。比如北极星,stark,以及如何和容器,云原生结合。
  4. 服务部署:服务变成微服务之后,部署是分散,部署是独立的,就需要有一个可靠快速的部署,扩缩容方案,也包括 ci/cd,全链路、实时和多维度的可观测性等,如 tke,智妍等,k8s 就是解决这种问题的。
  5. 数据存储隔离:数据存储隔离(Data Storage Segregation, DSS) 原则,即数据是微服务的私有资产,必须通过当前微服务提供的 API 来访问数据,避免数据层产生耦合。对于有状态的微服务而言,通常使用计算与存储分类的方式,将数据下层到分布式存储方案中,从而一定程度上实现服务无状态化。
  6. 服务间调用:服务 A 采用什么方式才可以调用服务 X,由于服务自治的约束 ,服务之间的调用需要采用开发语言无关的远程调用协议。现在业界大部分的微服务架构通常采用基于 IDL (Interactive Data Language, 交互式数据语言)的二进制协议进行交互,如 pb。

而整体解决微服务问题的思路:

image-20241116113914493

Hexo博客关闭窗口下次提交报错

1.出现原因

我直接关闭了我提交博客的git窗口,导致下次hexo d的时候提示:
image-20241008212321429

2.解决方案

  • 1.根据git命令行进入hexo博客所在目录:

image-20241008212433009

  • 2.根据git status命令查看当前仓库的状态:

image-20241008212448913

  • 3.根据git reset –hard命令重置仓库状态,但是有可能会丢失未提交的更改[谨慎使用]

image-20241008212538938

  • 4.重新hexo ghexo d查看:

image-20241008212601285

关于创建表的主键ID问题

0.问题提出

关于创建xx数据库表,主键id的取值问题:如果自增可能会出现分库分表的麻烦,但是分库分表如果使用分布式id也有对应的缺点。因此,本文从①不分库分表②分库分表两个方面考虑

1.数据库自增ID

  • 形式:使用数据库的id自增策略(Mysql的auto_increment)
  • 优点:比较简单,天然有序
  • 缺点:存在数量泄露,并发性能较差,数据库一旦故障就无法使用
  • 解决方案:

1.1 数据库水平拆分

==每个数据库设置①不同的初始值和②相同的自增步长==

image-20240902162503205

img

如图所示,这样可以保证DB123生成的ID是不冲突的,但是如果扩容,DB4数据库的话就没有初始值。

因此解决方案:

①根据扩容考虑决定步长,可以让多个数据库之间有空隙数字,可以扩容

②在其他未标记去扩容

1.2 批量缓存自增ID

==其实就是给数据库一批ID,不管多个DB之间的是否联系和连续,可能会出现多个数据库内连续,外不连续==

image-20240902162544339

方案一步长的问题不好考虑,那我干脆一台机器分配,我分配的话肯定不会出现没法扩充,只是没办法保证多个数据库之间的ID是连续的。我DB4数据库来了,我可能忘了我就给他500-599的。

1.3 Redis生成ID

  • 核心思路:Redis所有命令操作都是单线程的[本身提供像incr/increby这样的自增原子命令,能够保证Redis生成的ID唯一且有序]
  • 优点:①不依赖数据库,灵活方便;②性能优于数据库;③数字天然有序
  • 缺点:需要引入新的组件,增加系统复杂度;—》可以搭建redis集群提高吞吐量
  • 适合场景:适合Redis生成每天从0开始的流水号。比如:订单号=日期+当前自增长号

2.UUID

  • 形式:32个十六进制数字一共是128位【8-4-4-4-12】

  • 优点:不是有序的,安全性更高

  • 缺点:

    ①不是有序的,所以做主键的话innodb聚集索引内存消耗大,读写效率低;

    ②32个数字长度大,导致innodb叶子节点存储过大;

    ③因为无序,查找效率低下

3.雪花算法

  • 形式:最多长度为19一共是64位【1个64bit字节的整数】

​ 第1个bit位:保留位,无实际作用

​ 第2-42的bit位:这41位表示时间戳,精确到毫秒级别

​ 第43-52的bit位:这10位表示专门负责生产ID的工作机器的id

​ 第53-64的bit位:这12位表示序列号,也就是1毫秒内可以生成2 12 2^{12}2

image-20240902162710723

  • 优点:

    ①整体上按照时间趋势增加,后续插入索引树的性能较好;

    ②整个分布式系统不会发生ID碰撞;

    ③本地生成,且不依赖数据库,没有网络消耗

  • 缺点:

    ①强依赖时间容易发生时种回拨【Map存储<机器ID,max_id>服务器出故障就从max_id重新生成】

img

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———–==

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触发时,就会依次执行这三个任务了。

,