开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Spring Boot+Vue实现汽车租赁系统(毕设)

发表于 2021-11-05

一、前言

汽车租赁系统,很常见的一个系统,但是网上还是以前的老框架实现的,于是我打算从设计到开发都用现在比较流行的新框架。想学习或者做毕设的可以私信联系哦!!

二、技术栈

- 后端技术

  • Spring
  • SpringBoot
  • Mybatis

- 前端技术

  • vue
  • Element UI
  • axios
  • node
  • echarts

- 数据库

  • MySQL
  • Redis

三、管理员界面展示

管理员登录界面:

管理员页面

管理员修改密码界面:

在这里插入图片描述

添加车系界面:

在这里插入图片描述

添加车辆界面:

在这里插入图片描述

用户管理界面:

在这里插入图片描述

订单管理界面:

在这里插入图片描述

评价管理界面:

在这里插入图片描述

报表分析界面:

在这里插入图片描述
在这里插入图片描述

礼物管理界面:

在这里插入图片描述

添加修改礼物界面:

在这里插入图片描述

用户兑换礼物账单界面:

在这里插入图片描述

兑换礼物报表界面:

在这里插入图片描述

四、用户界面

用户注册界面:

在这里插入图片描述

用户登录界面:

在这里插入图片描述

登录后首页展示界面:

在这里插入图片描述

车辆出租展示界面:

在这里插入图片描述

车辆详情界面:

在这里插入图片描述

订单页面页面:

在这里插入图片描述

订单导出Excel界面:

在这里插入图片描述

Excel查看:

在这里插入图片描述

礼物兑换展示页面:

在这里插入图片描述

五、总结

总来的说项目的功能点大同小异,创新点在于报表分析和导出订单Excel。对于以前的汽车租赁系统来说还是有很大的改进,特别是框架方面。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

常考的 21 条 Linux 命令

发表于 2021-11-05
  • 一、文件和目录
  • cd命令(它用于切换当前目录,它的参数是要切换到的目录的路径,可以是绝对路径,也可以是相对路径)
  • cd /home 进入 ‘/ home’ 目录
  • cd .. 返回上一级目录
  • cd ../.. 返回上两级目录
  • cd 进入个人的主目录
  • cd ~user1 进入个人的主目录
  • cd - 返回上次所在的目录
  • pwd命令

pwd 显示工作路径

  • ls命令

(查看文件与目录的命令,list之意)

  • ls 查看目录中的文件
  • ls -l 显示文件和目录的详细资料
  • ls -a 列出全部文件,包含隐藏文件
  • ls -R 连同子目录的内容一起列出(递归列出),等于该目录下的所有文件都会显示出来
  • ls [0-9] 显示包含数字的文件名和目录名
  • cp 命令

(用于复制文件,copy之意,它还可以把多个文件一次性地复制到一个目录下)

  • -a :将文件的特性一起复制
  • -p :连同文件的属性一起复制,而非使用默认方式,与-a相似,常用于备份
  • -i :若目标文件已经存在时,在覆盖时会先询问操作的进行
  • -r :递归持续复制,用于目录的复制行为
  • -u :目标文件与源文件有差异时才会复制
  • mv命令

(用于移动文件、目录或更名,move之意)

  • -f :force强制的意思,如果目标文件已经存在,不会询问而直接覆盖
  • -i :若目标文件已经存在,就会询问是否覆盖
  • -u :若目标文件已经存在,且比目标文件新,才会更新
  • rm 命令

(用于删除文件或目录,remove之意)

  • -f :就是force的意思,忽略不存在的文件,不会出现警告消息
  • -i :互动模式,在删除前会询问用户是否操作
  • -r :递归删除,最常用于目录删除,它是一个非常危险的参数
  • 二、查看文件内容
  • cat命令

(用于查看文本文件的内容,后接要查看的文件名,通常可用管道与more和less一起使用)

  • cat file1 从第一个字节开始正向查看文件的内容
  • tac file1 从最后一行开始反向查看一个文件的内容
  • cat -n file1 标示文件的行数
  • more file1 查看一个长文件的内容
  • head -n 2 file1 查看一个文件的前两行
  • tail -n 2 file1 查看一个文件的最后两行
  • tail -n +1000 file1 从1000行开始显示,显示1000行以后的
  • cat filename | head -n 3000 | tail -n +1000 显示1000行到3000行
  • cat filename | tail -n +3000 | head -n 1000 从第3000行开始,显示1000(即显示3000~3999行)
  • 三、文件搜索
  • find命令()

find / -name file1 从 ‘/‘ 开始进入根文件系统搜索文件和目录

find / -user user1 搜索属于用户 ‘user1’ 的文件和目录

find /usr/bin -type f -atime +100 搜索在过去100天内未被使用过的执行文件

find /usr/bin -type f -mtime -10 搜索在10天内被创建或者修改过的文件

whereis halt 显示一个二进制文件、源码或man的位置

which halt 显示一个二进制文件或可执行文件的完整路径

删除大于50M的文件:

  • find /var/mail/ -size +50M -exec rm {} \;
  • 四、文件的权限 - 使用 “+” 设置权限,使用 “-“ 用于取消
  • chmod 命令

ls -lh 显示权限

chmod ugo+rwx directory1 设置目录的所有人(u)、群组(g)以及其他人(o)以读(r,4 )、写(w,2)和执行(x,1)的权限

chmod go-rwx directory1 删除群组(g)与其他人(o)对目录的读写执行权限

  • chown 命令

(改变文件的所有者)

  • chown user1 file1 改变一个文件的所有人属性
  • chown -R user1 directory1 改变一个目录的所有人属性并同时改变改目录下所有文件的属性
  • chown user1:group1 file1 改变一个文件的所有人和群组属性
  • chgrp 命令

(改变文件所属用户组)

  • chgrp group1 file1 改变文件的群组
  • 五、文本处理
  • grep 命令

(分析一行的信息,若当中有我们所需要的信息,就将该行显示出来,该命令通常与管道命令一起使用,用于对一些命令的输出进行筛选加工等等)

  • grep Aug /var/log/messages 在文件 ‘/var/log/messages’中查找关键词”Aug”
  • grep ^Aug /var/log/messages 在文件 ‘/var/log/messages’中查找以”Aug”开始的词汇
  • grep [0-9] /var/log/messages 选择 ‘/var/log/messages’ 文件中所有包含数字的行
  • grep Aug -R /var/log/* 在目录 ‘/var/log’ 及随后的目录中搜索字符串”Aug”
  • sed ‘s/stringa1/stringa2/g’ example.txt 将example.txt文件中的 “string1” 替换成 “string2”
  • sed ‘/^$/d’ example.txt 从example.txt文件中删除所有空白行
  • paste 命令

paste file1 file2 合并两个文件或两栏的内容

paste -d ‘+’ file1 file2 合并两个文件或两栏的内容,中间用”+”区分

  • sort 命令

sort file1 file2 排序两个文件的内容

sort file1 file2 | uniq 取出两个文件的并集(重复的行只保留一份)

sort file1 file2 | uniq -u 删除交集,留下其他的行

sort file1 file2 | uniq -d 取出两个文件的交集(只留下同时存在于两个文件中的文件)

  • comm 命令

comm -1 file1 file2 比较两个文件的内容只删除 ‘file1’ 所包含的内容

comm -2 file1 file2 比较两个文件的内容只删除 ‘file2’ 所包含的内容

comm -3 file1 file2 比较两个文件的内容只删除两个文件共有的部分

  • 六、打包和压缩文件
  • tar 命令

(对文件进行打包,默认情况并不会压缩,如果指定了相应的参数,它还会调用相应的压缩程序(如gzip和bzip等)进行压缩和解压)

  • -c :新建打包文件
  • -t :查看打包文件的内容含有哪些文件名
  • -x :解打包或解压缩的功能,可以搭配-C(大写)指定解压的目录,注意-c,-t,-x不能同时出现在同一条命令中
  • -j :通过bzip2的支持进行压缩/解压缩
  • -z :通过gzip的支持进行压缩/解压缩
  • -v :在压缩/解压缩过程中,将正在处理的文件名显示出来
  • -f filename :filename为要处理的文件
  • -C dir :指定压缩/解压缩的目录dir
  • 压缩:tar -jcv -f filename.tar.bz2 要被处理的文件或目录名称
  • 查询:tar -jtv -f filename.tar.bz2
  • 解压:tar -jxv -f filename.tar.bz2 -C 欲解压缩的目录
  • bunzip2 file1.bz2 解压一个叫做 ‘file1.bz2’的文件
  • bzip2 file1 压缩一个叫做 ‘file1’ 的文件
  • gunzip file1.gz 解压一个叫做 ‘file1.gz’的文件
  • gzip file1 压缩一个叫做 ‘file1’的文件
  • gzip -9 file1 最大程度压缩
  • rar a file1.rar test_file 创建一个叫做 ‘file1.rar’ 的包
  • rar a file1.rar file1 file2 dir1 同时压缩 ‘file1’, ‘file2’ 以及目录 ‘dir1’
  • rar x file1.rar 解压rar包
  • zip file1.zip file1 创建一个zip格式的压缩包
  • unzip file1.zip 解压一个zip格式压缩包
  • zip -r file1.zip file1 file2 dir1 将几个文件和目录同时压缩成一个zip格式的压缩包
  • 七、系统和关机 (系统的关机、重启以及登出 )
  • shutdown -h now 关闭系统(1)
  • init 0 关闭系统(2)
  • telinit 0 关闭系统(3)
  • shutdown -h hours:minutes & 按预定时间关闭系统
  • shutdown -c 取消按预定时间关闭系统
  • shutdown -r now 重启(1)
  • reboot 重启(2)
  • logout 注销
  • time 测算一个命令(即程序)的执行时间
  • 八、进程相关的命令
  • 17 jps命令
  • (显示当前系统的java进程情况,及其id号)
  • jps(Java Virtual Machine Process Status Tool)是JDK 1.5提供的一个显示当前所有java进程pid的命令,简单实用,非常适合在linux/unix平台上简单察看当前java进程的一些简单情况。
  • 18 ps命令
  • (用于将某个时间点的进程运行情况选取下来并输出,process之意)
  • -A :所有的进程均显示出来
  • -a :不与terminal有关的所有进程
  • -u :有效用户的相关进程
  • -x :一般与a参数一起使用,可列出较完整的信息
  • -l :较长,较详细地将PID的信息列出
  • ps aux # 查看系统所有的进程数据
  • ps ax # 查看不与terminal有关的所有进程
  • ps -lA # 查看系统所有的进程数据
  • ps axjf # 查看连同一部分进程树状态
  • 19 kill命令
  • (用于向某个工作(%jobnumber)或者是某个PID(数字)传送一个信号,它通常与ps和jobs命令一起使用)
  • 20 killall命令
  • (向一个命令启动的进程发送一个信号)
  • 21 top命令
  • 是Linux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况,类似于Windows的任务管理器。
  • 如何杀死进程:
  • 图形化界面的方式
  • kill -9 pid (-9表示强制关闭)
  • killall -9 程序的名字
  • pkill 程序的名字
  • 查看进程端口号:
  • netstat -tunlp|grep 端口号

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

设计模式-访问者模式及应用

发表于 2021-11-05

在软件开发的过程中,经常会对一个数据结构(对象数据)进行不同的业务操作,被访问的方法也不同。例如我们对一个权限管理系统某一个用户对象做操作,用户的用户名查询、更改,用户的积分查询,用户的权限查询等。为满足不同的业务,且单一职责的原则。都是对一个对象,有不同的操作。

现实生活中,比如我们使用某应用软件观看一部电影,对本部电影发表自己的见解评论及评分。每个用户的评价肯定都是不同的,但是评价的对象都是这部电影。 这种被处理的数据相对稳定且唯一,而访问的方式有多种,使用今天所要介绍的设计模式(访问者模式),处理此类问题就很方便。

访问者模式,能把处理方法从数据结构中分离出来,并可以根据需要增加新的处理方法,且不用修改原来的程序代码与数据结构,这提高了程序的扩展性和灵活性。

定义及结构特点

访问者(Visitor)模式的定义:将作用于某种数据结构中的各元素的操作分离出来封装成独立的类,使其在不改变数据结构的前提下可以添加作用于这些元素的新的操作,为数据结构中的每个元素提供多种访问方式,它将对数据的操作与数据结构进行分离。

访问者模式的结构与实现

从前面的定义不难看出,访问者的结构就是将元素(操作资源)与操作分离,封装为独立的类。

  1. 模式的结构
    访问者模式包含以下主要角色。
  • 抽象访问者(Visitor)角色:定义一个访问具体元素的接口,为每个具体元素类对应一个访问操作 visit() ,该操作中的参数类型标识了被访问的具体元素。
  • 具体访问者(ConcreteVisitor)角色:实现抽象访问者角色中声明的各个访问操作,确定访问者访问一个元素时该做什么。
  • 抽象元素(Element)角色:声明一个包含接受操作 accept() 的接口,被接受的访问者对象作为 accept() 方法的参数。
  • 具体元素(ConcreteElement)角色:实现抽象元素角色提供的 accept() 操作,其方法体通常都是 visitor.visit(this) ,另外具体元素中可能还包含本身业务逻辑的相关操作。
  • 对象结构(Object Structure)角色:是一个包含元素角色的容器,提供让访问者对象遍历容器中的所有元素的方法,通常由 List、Set、Map 等集合类实现。

2.访问者模式结构图
在这里插入图片描述

代码案例
1.案例1
  • 抽象访问者
1
2
3
4
5
java复制代码public interface Visitor {
// 提供不同访问元素接口
void visit(ConcreteElementA element);
void visit(ConcreteElementB element);
}
  • 具体访问者A
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class ConcreteVisitorA implements Visitor {

//实现不同访问元素实现
@Override
public void visit(ConcreteElementA element) {
System.out.println("访问者A,访问" + element.operation());
}

@Override
public void visit(ConcreteElementB element) {
System.out.println("访问者A,访问" + element.operation());
}
}
  • 具体访问者B
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class ConcreteVisitorB implements Visitor{

@Override
public void visit(ConcreteElementA element) {
System.out.println("访问者B,访问" + element.operation());
}

@Override
public void visit(ConcreteElementB element) {
System.out.println("访问者B,访问" + element.operation());
}
}
  • 抽象元素
1
2
3
4
5
6
java复制代码//抽象元素
public interface Element {

//一般提供一个接受访问者接口
void accept(Visitor visitor);
}
  • 具体元素A
1
2
3
4
5
6
7
8
9
10
11
java复制代码public class ConcreteElementA implements Element {

@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}

public String operation(){
return "具体元素A";
}
}
  • 具体元素B
1
2
3
4
5
6
7
8
9
10
java复制代码public class ConcreteElementB implements Element{

@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
public String operation(){
return "具体元素B";
}
}
  • 对象结构
    这个角色比较特殊,这里特殊说明,对象结构这里是维护多个元素,执行时通过遍历元素的方式传递访问者。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public class ObjectStructure {

private List<Element> list = new ArrayList<Element>();

public void accept(Visitor visitor) {
Iterator<Element> i = list.iterator();
while (i.hasNext()) {
((Element) i.next()).accept(visitor);
}
}

public void add(Element element) {
list.add(element);
}
public void remove(Element element) {
list.remove(element);
}
}
  • client使用方
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class Client {

public static void main(String[] args) {
ObjectStructure objectStructure = new ObjectStructure();
//添加两个访问元素 A\B
objectStructure.add(new ConcreteElementA());
objectStructure.add(new ConcreteElementB());

ConcreteVisitorA concreteVisitorA = new ConcreteVisitorA();
objectStructure.accept(concreteVisitorA);
}
}

访问者A,访问具体元素A
访问者A,访问具体元素B

2.案例2

案例2,这里写一个项目实战场景中的运用。首先我们的(风控)项目中,规则引擎中的规则元素对象,包含很多规则的元素(属性),决策跑规则中的条件,判定是否满足规则,来做命中后的前后操作。比如说触发规则的条件,规则配置的条件信息,规则命中后的后置操作等等。这里简单通过一个类图展示一下规则元素的属性。
在这里插入图片描述

在开发的过程中,我们需要解析规则中配置了哪些字段,规则操作中(命中后)配置的详情,规则触发的配置等等,这些属性都属于规则元素的属性,业务直接又需要解析规则中的各属性,如果写不同的接口方法,来操作当前的规则元素,耦合性很差。且如果再对规则元素做相关的操作,还要增加接口方法,数据访问与具体的元素未隔离,不易拓展。

通过代码的方式,看下项目中的最佳实践。首先规则,作为具体元素,继承抽象接口。

  • 抽象元素
1
2
3
java复制代码public interface Visitable {
void accept(Visitor var1);
}
  • 具体元素 (规则)
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
java复制代码public class Rule Visitable {
// ...
/**
* 规则条件配置
*
* @see RuleCondition
*/
private RuleCondition condition;


/**
* 规则操作配置
*
* @see RuleAction
*/
private List<RuleAction> actions;

/**
* 规则触发操作
*/
private String triggers;

// ....略

// accept 接受访问者接口
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
  • 抽象访问者
1
2
3
java复制代码public interface Visitor<T> {
void visit(T var1);
}
  • 具体访问者

代码这里不详贴,看下几个实现类。
在这里插入图片描述

  • 使用方

直接通过具体元素(规则),调用accept()方接受访问者,具体对元素的操作在访问者中。
在这里插入图片描述

然后获取访问者对元素的操作结果
在这里插入图片描述

以上是项目实战中的场景应用,如果后续我们增加了新的需求,直接拓展访问者即可,无需更改影响已有的代码逻辑。

访问者模式的优缺点及应用场景

1.优点:

  • 扩展性好。能够在不修改对象结构中的元素的情况下,为对象结构中的元素添加新的功能。
  • 复用性好。可以通过访问者来定义整个对象结构通用的功能,从而提高系统的复用程度。
  • 灵活性好。访问者模式将数据结构与作用于结构上的操作解耦,使得操作集合可相对自由地演化而不影响系统的数据结构。
  • 符合单一职责原则。访问者模式把相关的行为封装在一起,构成一个访问者,使每一个访问者的功能都比较单一。

2.缺点:

  • 增加新的元素类很困难。在访问者模式中,每增加一个新的元素类,都要在每一个具体访问者类中增加相应的具体操作,这违背了“开闭原则”。
  • 破坏封装。访问者模式中具体元素对访问者公布细节,这破坏了对象的封装性。
  • 违反了依赖倒置原则。访问者模式依赖了具体类,而没有依赖抽象类。
访问者模式的应用场景

当对一个元素有不同的多种操作时,使用访问者模式。

通常在以下情况可以考虑使用访问者(Visitor)模式。

  • 对象结构相对稳定,但其操作算法经常变化的程序。
  • 对象结构中的对象需要提供多种不同且不相关的操作,而且要避免让这些操作的变化影响对象的结构。
  • 对象结构包含很多类型的对象,希望对这些对象实施一些依赖于其具体类型的操作。

访问者模式在源码中的使用

1.spring源码中的使用
spring容器中,BeanDefinition接口用于存储,spring bean的基础信息。包括bean的name,bean的class、scope,是否懒加载 isLazyInit等等。实现类常用的主要有 RootBeanDefinition、ChildBeanDefinition、GenericBeanDefinition等。

在spring beans中有个BeanDefinitionVisitor类中,有对BeanDefinition对象的一系列信息补充,如源码:

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
java复制代码public class BeanDefinitionVisitor {
@Nullable
private StringValueResolver valueResolver;

public BeanDefinitionVisitor(StringValueResolver valueResolver) {
Assert.notNull(valueResolver, "StringValueResolver must not be null");
this.valueResolver = valueResolver;
}

protected BeanDefinitionVisitor() {
}

public void visitBeanDefinition(BeanDefinition beanDefinition) {
// 一些列的访问者方法
this.visitParentName(beanDefinition);
this.visitBeanClassName(beanDefinition);
this.visitFactoryBeanName(beanDefinition);
this.visitFactoryMethodName(beanDefinition);
this.visitScope(beanDefinition);
if (beanDefinition.hasPropertyValues()) {
this.visitPropertyValues(beanDefinition.getPropertyValues());
}

if (beanDefinition.hasConstructorArgumentValues()) {
ConstructorArgumentValues cas = beanDefinition.getConstructorArgumentValues();
this.visitIndexedArgumentValues(cas.getIndexedArgumentValues());
this.visitGenericArgumentValues(cas.getGenericArgumentValues());
}

}

protected void visitBeanClassName(BeanDefinition beanDefinition) {
String beanClassName = beanDefinition.getBeanClassName();
if (beanClassName != null) {
String resolvedName = this.resolveStringValue(beanClassName);
if (!beanClassName.equals(resolvedName)) {
beanDefinition.setBeanClassName(resolvedName);
}
}
}
protected void visitScope(BeanDefinition beanDefinition) {
String scope = beanDefinition.getScope();
if (scope != null) {
String resolvedScope = this.resolveStringValue(scope);
if (!scope.equals(resolvedScope)) {
beanDefinition.setScope(resolvedScope);
}
}
}
// 略...

这里的访问者模式使用的比较简单,BeanDefinition对象作为元素,访问者中不同的方法,就是对BeanDefinition对象的补充。也无做元素抽象,访问者抽象等设计。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Redis分布式锁使用不当,酿成一个重大事故,超卖了100瓶

发表于 2021-11-05

基于Redis使用分布式锁在当今已经不是什么新鲜事了。本篇文章主要是基于我们实际项目中因为Redis分布式锁造成的事故分析及解决方案。

背景:我们项目中的抢购订单采用的是分布式锁来解决的。有一次,运营做了一个飞天茅台的抢购活动,库存100瓶,但是却超卖了!要知道,这个地球上飞天茅台的稀缺性啊!事故定为P0级重大事故……只能坦然接受。整个项目组被扣绩效了(事故发生后,CTO指名点姓让我带头冲锋来处理,好吧,冲~)。

事故现场

经过一番了解后,得知这个抢购活动接口以前从来没有出现过这种情况,但是这次为什么会超卖呢?原因在于:之前的抢购商品都不是什么稀缺性商品,而这次活动居然是飞天茅台,通过埋点数据分析,各项数据基本都是成倍增长,活动热烈程度可想而知!话不多说,直接上核心代码,机密部分做了伪代码处理。

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
typescript复制代码public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
String key = "key:" + request.getSeckillId;
try {
Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, "val", 10, TimeUnit.SECONDS);
if (lockFlag) {
// HTTP请求用户服务进行用户相关的校验
// 用户活动校验

// 库存校验
Object stock = redisTemplate.opsForHash().get(key+":info", "stock");
assert stock != null;
if (Integer.parseInt(stock.toString()) <= 0) {
// 业务异常
} else {
redisTemplate.opsForHash().increment(key+":info", "stock", -1);
// 生成订单
// 发布订单创建成功事件
// 构建响应VO
}
}
} finally {
// 释放锁
stringRedisTemplate.delete("key");
// 构建响应VO
}
return response;
}

以上代码,通过分布式锁过期时间有效期10s来保障业务逻辑有足够的执行时间;采用try-finally语句块保证锁一定会及时释放。业务代码内部也对库存进行了校验。看起来很安全啊~ 别急,继续分析。

事故原因

飞天茅台抢购活动吸引了大量新用户下载注册我们的APP,其中,不乏很多羊毛党,采用专业的手段来注册新用户来薅羊毛和刷单。当然我们的用户系统提前做好了防备,接入阿里云人机验证、三要素认证以及自研的风控系统等各种十八般武艺,挡住了大量的非法用户。此处不禁点个赞~

但也正因如此,让用户服务一直处于较高的运行负载中。

抢购活动开始的一瞬间,大量的用户校验请求打到了用户服务。导致用户服务网关出现了短暂的响应延迟,有些请求的响应时长超过了10s,但由于HTTP请求的响应超时我们设置的是30s,这就导致接口一直阻塞在用户校验那里,10s后,分布式锁已经失效了,此时有新的请求进来是可以拿到锁的,也就是说锁被覆盖了。这些阻塞的接口执行完之后,又会执行释放锁的逻辑,这就把其他线程的锁释放了,导致新的请求也可以竞争到锁~这真是一个极其恶劣的循环。

这个时候只能依赖库存校验,但是偏偏库存校验不是非原子性的,采用的是get and compare的方式,超卖的悲剧就这样发生了~

事故分析

仔细分析下来,可以发现,这个抢购接口在高并发场景下,是有严重的安全隐患的,主要集中在三个地方:

没有其他系统风险容错处理 ,由于用户服务吃紧,网关响应延迟,但没有任何应对方式,这是超卖的导火索。

看似安全的分布式锁其实一点都不安全,虽然采用了set key value [EX seconds] [PX milliseconds] [NX|XX]的方式,但是如果线程A执行的时间较长没有来得及释放,锁就过期了,此时线程B是可以获取到锁的。当线程A执行完成之后,释放锁,实际上就把线程B的锁释放掉了。这个时候,线程C又是可以获取到锁的,而此时如果线程B执行完释放锁实际上就是释放的线程C设置的锁。这是超卖的直接原因。

非原子性的库存校验,非原子性的库存校验导致在并发场景下,库存校验的结果不准确。这是超卖的根本原因。

通过以上分析,问题的根本原因在于库存校验严重依赖了分布式锁。因为在分布式锁正常set、del的情况下,库存校验是没有问题的。但是,当分布式锁不安全可靠的时候,库存校验就没有用了。

解决方案

知道了原因之后,我们就可以对症下药了。

实现相对安全的分布式锁

相对安全的定义:set、del是一一映射的,不会出现把其他现成的锁del的情况。从实际情况的角度来看,即使能做到set、del一一映射,也无法保障业务的绝对安全。因为锁的过期时间始终是有界的,除非不设置过期时间或者把过期时间设置的很长,但这样做也会带来其他问题。故没有意义。

要想实现相对安全的分布式锁,必须依赖key的value值。在释放锁的时候,通过value值的唯一性来保证不会勿删。我们基于LUA脚本实现原子性的get and compare,如下:

1
2
3
4
5
scss复制代码public void safedUnLock(String key, String val) {
String luaScript = "local in = ARGV[1] local curr=redis.call('get', KEYS[1]) if in==curr then redis.call('del', KEYS[1]) end return 'OK'"";
RedisScript<String> redisScript = RedisScript.of(luaScript);
redisTemplate.execute(redisScript, Collections.singletonList(key), Collections.singleton(val));
}

我们通过LUA脚本来实现安全地解锁。

实现安全的库存校验

如果我们对于并发有比较深入的了解的话,会发现想get and compare/ read and save等操作,都是非原子性的。如果要实现原子性,我们也可以借助LUA脚本来实现。但就我们这个例子中,由于抢购活动一单只能下1瓶,因此可以不用基于LUA脚本实现而是基于redis本身的原子性。原因在于:

1
2
ini复制代码// Redis会返回操作之后的结果,这个过程是原子性的
Long currStock = redisTemplate.opsForHash().increment("key", "stock", -1);

发现没有,代码中的库存校验完全是“画蛇添足”。

改进之后的代码

经过以上的分析之后,我们决定新建一个DistributedLocker类专门用于处理分布式锁。

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
kotlin复制代码public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
String key = "key:" + request.getSeckillId();
String val = UUID.randomUUID().toString();
try {
Boolean lockFlag = distributedLocker.lock(key, val, 10, TimeUnit.SECONDS);
if (!lockFlag) {
// 业务异常
}

// 用户活动校验
// 库存校验,基于Redis本身的原子性来保证
Long currStock = stringRedisTemplate.opsForHash().increment(key + ":info", "stock", -1);
if (currStock < 0) { // 说明库存已经扣减完了。
// 业务异常。
log.error("[抢购下单] 无库存");
} else {
// 生成订单
// 发布订单创建成功事件
// 构建响应
}
} finally {
distributedLocker.safedUnLock(key, val);
// 构建响应
}
return response;
}

深度思考

分布式锁有必要么,改进之后,其实可以发现,我们借助于redis本身的原子性扣减库存,也是可以保证不会超卖的。对的。但是如果没有这一层锁的话,那么所有请求进来都会走一遍业务逻辑,由于依赖了其他系统,此时就会造成对其他系统的压力增大。这会增加的性能损耗和服务不稳定性,得不偿失。基于分布式锁可以在一定程度上拦截一些流量。

分布式锁的选型,有人提出用RedLock来实现分布式锁。RedLock的可靠性更高,但其代价是牺牲一定的性能。在本场景,这点可靠性的提升远不如性能的提升带来的性价比高。如果对于可靠性极高要求的场景,则可以采用RedLock来实现。

再次思考分布式锁有必要么,由于bug需要紧急修复上线,因此我们将其优化并在测试环境进行了压测之后,就立马热部署上线了。实际证明,这个优化是成功的,性能方面略微提升了一些,并在分布式锁失效的情况下,没有出现超卖的情况。

然而,还有没有优化空间呢?有的!

由于服务是集群部署,我们可以将库存均摊到集群中的每个服务器上,通过广播通知到集群的各个服务器。网关层基于用户ID做hash算法来决定请求到哪一台服务器。这样就可以基于应用缓存来实现库存的扣减和判断。性能又进一步提升了!

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
scss复制代码// 通过消息提前初始化好,借助ConcurrentHashMap实现高效线程安全
private static ConcurrentHashMap<Long, Boolean> SECKILL_FLAG_MAP = new ConcurrentHashMap<>();
// 通过消息提前设置好。由于AtomicInteger本身具备原子性,因此这里可以直接使用HashMap
private static Map<Long, AtomicInteger> SECKILL_STOCK_MAP = new HashMap<>();

...

public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;

Long seckillId = request.getSeckillId();
if(!SECKILL_FLAG_MAP.get(requestseckillId)) {
// 业务异常
}
// 用户活动校验
// 库存校验
if(SECKILL_STOCK_MAP.get(seckillId).decrementAndGet() < 0) {
SECKILL_FLAG_MAP.put(seckillId, false);
// 业务异常
}
// 生成订单
// 发布订单创建成功事件
// 构建响应
return response;
}

通过以上的改造,我们就完全不需要依赖Redis了。性能和安全性两方面都能进一步得到提升!

当然,此方案没有考虑到机器的动态扩容、缩容等复杂场景,如果还要考虑这些话,则不如直接考虑分布式锁的解决方案。

总结

稀缺商品超卖绝对是重大事故。如果超卖数量多的话,甚至会给平台带来非常严重的经营影响和社会影响。经过本次事故,让我意识到对于项目中的任何一行代码都不能掉以轻心,否则在某些场景下,这些正常工作的代码就会变成致命杀手!对于一个开发者而言,则设计开发方案时,一定要将方案考虑周全。怎样才能将方案考虑周全?唯有持续不断地学习!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

关于Java中的 JSP 你了解多少?

发表于 2021-11-05

「这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战」

🌊 作者主页:海拥

🌊 作者简介:🥇HDZ核心组成员、🏆全栈领域优质创作者、🥈蝉联C站周榜前十

🌊 粉丝福利:进粉丝群每周送四本书(每位都有),每月抽送各种小礼品(掘金搪瓷杯、抱枕、鼠标垫、马克杯等)

JSP 代表 Java 服务器页面。它是一种在应用服务器端使用的编程工具。JSP 基本上用于支持平台 – 独立和动态的方法来构建 Web 依赖的应用程序。JSP 页面类似于 ASP 页面,因为它们是在服务器上编译的,而不是在用户的 Web 浏览器上进行编译。

JSP 是由 Sun Microsystems 公司于 1999 年开发的。JSP 的开发使用语言,其中内置的所有功能都是用 Java 编程语言创建的。

JSP的特点:

  • JSP 是 Servlet 技术的扩展版本。
  • JSP 技术类似于 Servlet 应用程序接口(API)。
  • 它提供了一些附加功能,例如表达式语言和自定义标签等。
  • JSP 文件更容易部署,因为 JSP 引擎会自动执行 Java 代码的重新编译。

JSP的优势:

JSP 有很多优点。

  • 对 Servlet 的扩展:

Servlet 的 JSP 扩展。我们可以在 JSP 中使用 Servlet 的所有功能。我们可以轻松使用 JSP 开发的隐式对象、预定义标签、自定义标签和表达式语言。

  • 易于维护:

它易于管理,因为我们可以轻松地分离我们的业务逻辑,在 Servlet 技术中,我们可以将我们的业务逻辑与 Presentation 逻辑混合。

  • 快速发展:

无需重新编译和重新部署。如果 JSP 页面被修改。我们不需要重新编译和重新部署项目。如果我们想改变应用程序的外观和感觉,则需要重新编译和更新 Servlet 代码。

  • 比 Servlet 更少的代码:

在 JSP 中,我们可以使用很多标签,例如 action 标签、jstt、Custom 标签等,以减少代码。我们可以使用 EL 和隐式对象。

  • JSP 页面代码在客户端上不可见,只有生成的 HTML 可见。

JSP的缺点:

  • 由于 JSP 页面在编译过程之前首先被转换为 servlet,因此很难调试或跟踪错误。
  • 由于 JSP 页面被转换为 Servlets 并被编译,因此很难跟踪 JSP 页面中发生的错误。
  • 数据库连接并不容易。
  • JSP 页面需要更多的磁盘空间来保存 JSP 页面。
  • 第一次访问 JSP 页面时需要更多时间,因为它们要在服务器上编译。

JSP的用途:

  • JSP 有很多优点。首先,动态部分是用 Java 编写的,而不是 Visual Basic 或其他 MS 特定的语言,因此它更强大,更易于使用。
  • 它是独立于非 Microsoft Web 服务器和其他操作系统的平台
  • JSP 帮助开发人员使用特殊的 JSP 标签在 HTML 页面中插入 Java 代码
  • JSP 也可用于访问 JavaBeans 对象。JSP 允许使用请求和响应对象跨页面共享信息。
  • 它可以用于将视图层与 Web 应用程序中的业务逻辑分离

写在最后的

作者立志打造一个拥有100个小游戏的摸鱼网站,更新进度:40/100

我已经写了很长一段时间的技术博客,并且主要通过掘金发表,这是我的一篇关于Java中的 JSP 。我喜欢通过文章分享技术与快乐。你可以访问我的博客: juejin.cn/user/204034… 以了解更多信息。希望你们会喜欢!😊

💌 欢迎大家在评论区提出意见和建议!💌

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

管理系统必备技(11) 对接公众号发送定期提醒通知

发表于 2021-11-05

这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战

前言

现在需要通过公众号对用户进行发送值班提醒和巡检提醒的通知。

提醒的内容分为三种:

  • 值班提醒
  • 厂区巡检提醒
  • 高压区巡检提醒

一、提醒模板设计

1.1 添加模板

添加模板的前提是必须是认证过的公众号。目前在广告与服务->模板消息 中可以获取到。下面是我从模板库中添加的。

image-20211104134154910

然后发送模板消息即可。

模板消息仅用于公众号向用户发送重要的服务通知,只能用于符合其要求的服务场景中,如信用卡刷卡通知,商品购买成功通知等。不支持广告等营销类消息以及其它所有可能对用户造成骚扰的消息。

关于使用规则,请注意:

  1. 所有服务号都可以在功能->添加功能插件处看到申请模板消息功能的入口,但只有认证后的服务号才可以申请模板消息的使用权限并获得该权限;
  2. 需要选择公众账号服务所处的2个行业,每月可更改1次所选行业;
  3. 在所选择行业的模板库中选用已有的模板进行调用;
  4. 每个账号可以同时使用25个模板。
  5. 当前每个账号的模板消息的日调用上限为10万次,单个模板没有特殊限制。【2014年11月18日将接口调用频率从默认的日1万次提升为日10万次,可在MP登录后的开发者中心查看】。当账号粉丝数超过10W/100W/1000W时,模板消息的日调用上限会相应提升,以公众号MP后台开发者中心页面中标明的数字为准。

找到适合自己的模板,然后传入模板id即可。以该模板为例测试:

image-20211104135008707

1.2 发送模板消息

发送模板消息据我了解到可以有两种方式:

  • 公众号的 openId 传入发送模板消息
1
perl复制代码TEMPLATE_ENUM_SEND("发送模板消息", "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=%s"),
  • 小程序的 openId 传入发送模板消息。(前提是要通过微信开放平台绑定公众号和小程序)
1
perl复制代码UNIFORM_ENUM_SEND("发送统一模板消息", "https://api.weixin.qq.com/cgi-bin/message/wxopen/template/uniform_send?access_token=%s"),

区别就是 公众号的 openid 和小程序的 openid 是不一致的,如果公众号仅仅用作小程序的辅助通知的话,可以只维护 小程序的 openid 到数据库中,通过它发送统一服务消息即可;如果更细致点,想维护公众号openid 的话,就可以采用 采用默认的发送方式。在两种都试过后,为了程序的可扩展性,选择了第二种做法。

1.3 发送代码测试

1、首先获取公众号的 accesstoken。公众号的服务是必须要在公网下才能获取,所以比较坑,得先部署个服务到公网环境下。

2、组织好数据,例如下面的这种JSON 体发送模板消息即可(私密信息已隐去)。

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
json复制代码{
"token": "token",
"data":{
"touser": "touser",
"template_id": "template_id",
"miniprogram":{
"appid":"appid",
"pagepath": "pages/me/index"
},
"data":{
"first":{
"value":"明日巡检排班计划",
"color":"#130c0e"
},
"keyword1":{
"value": "平顶山电站",
"color": "#130c0e"
},
"keyword2":{
"value": "值班提醒",
"color": "#130c0e"
},
"keyword3":{
"value": "潇雷",
"color": "#130c0e"
},
"remark":{
"value": "亲,明天上午轮到您值班了,别忘记噢,辛苦了,么么哒!",
"color": "#130c0e"
}
}

}
}

最后效果如下:

image-20211104140728173

二、提醒计划设计

根据通知模板,需要制定提醒计划,例如通知时间和通知次数。

现在值班提醒初步设计是每天晚上8点提醒第一次,每天早上8点提醒第二次。同样的消息如果一天超过两次的话,个人觉得就会很反感。

为了可扩展性,应该设计个公众号通知表,在里面发送通知,添加两次巡检时间,然后在代码里面每隔一个小时去数据库中找一次,是否获取的值在这个时间端内,例如7.00-8.00 分这个时间端内有没有通知任务;如果有就发送,然后判断时刻2 的开关是否开启,如果开启了,取它的时刻2也做个判断是否在当前时间段内,再发送。时刻1 和时刻2 都必须是整点。当然可能也只需要发送一次,那就把时刻2 的开关关掉即可。

时刻1到了,就查询明天要值班的用户发消息。

时刻2到了,就查询今天要值班的用户发消息。

数据库表的设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sql复制代码CREATE TABLE `mp_notification_time` (
`id` bigint NOT NULL AUTO_INCREMENT,
`type` varchar(60) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '模板类型。例如:101代表值班提醒;201代表厂区巡检提醒;202代表高压区巡检提醒',
`sys_id` bigint DEFAULT NULL COMMENT '电站id',
`temp_id` varchar(120) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '模板id',
`page` varchar(120) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '小程序跳转页面路径',
`time_one` time DEFAULT NULL COMMENT '时刻1',
`time_two` time DEFAULT NULL COMMENT '时刻2',
`two_on` tinyint DEFAULT NULL COMMENT '时刻2是否开启。0:开启;1关闭',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`delete_flag` tinyint DEFAULT NULL COMMENT '0正常 1删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

定时任务每一小时去执行一次,如果判断在这小时内有巡检任务就发送,判断时间方法可以用下面这:

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
vbscript复制代码    /**
* 判断当前时间是否在[startTime, endTime]区间,注意时间格式要一致
*
* @param nowTime 当前时间
* @param startTime 开始时间
* @param endTime 结束时间
* @return
* @author jqlin
*/
public static boolean isEffectiveDate(Date nowTime, Date startTime, Date endTime) {
if (nowTime.getTime() == startTime.getTime()
|| nowTime.getTime() == endTime.getTime()) {
return true;
}
Calendar date = Calendar.getInstance();
date.setTime(nowTime);

Calendar begin = Calendar.getInstance();
begin.setTime(startTime);

Calendar end = Calendar.getInstance();
end.setTime(endTime);

if (date.after(begin) && date.before(end)) {
return true;
} else {
return false;
}
}

最终效果如下:

image-20211105130111418

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

实践出真知!Http接口联调实战

发表于 2021-11-05

场景

与供应商进行一次接口的对接,自己在postman中进行调试,发现可以正常调用,但是联调时对方却报请求方式错误,导致花费了较多时间。

  • 请求类型

POST

  • 请求的url
    https://xxxxx/vehicleOperate/openapi/gateMachine/inRecord/518021_0054/parkcarin
  • 请求参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bash复制代码 {
"carinlist": "[{
"id": "197",
"entrance": "入口",
"cardId": "BDF2413131313131",
"cardNo": "津A11111",
"carNo": "津A11111",
"ownerName": "临时用户",
"cardTypeId": "31",
"cardTypeName": "临时卡A",
"entranceTime": "2017-06-14 08:38:29",
"entranceUserName": "系统管理员",
"entranceWayId": "0",
"entranceWayName": "正常入场",
"small": "0",
"entryPic": "",
"MachNo":"1"
}]",
"signature":"SJDKLSJKLDJSKLSHJKSHDJKHJKHJK",
"t":"1520416876"
}

接口代码

1
less复制代码

@PostMapping(“/inRecord/{parkId}/parkcarin”)
public DoorRecordResult createInRecord(@RequestBody DoorGateMachineRecordInfoInVO doorGateMachineRecordInfoVO,
@PathVariable(“parkId”) String parkId) {
DoorRecordResult doorRecordResult = new DoorRecordResult();
SSOUser ssoUser = getCurrentUser();
log.info(“parkId : {}, 入场消息消费:{}”, parkId, JSONObject.toJSONString(doorGateMachineRecordInfoVO));
List carinlist = doorGateMachineRecordInfoVO.getCarinlist();
carinlist.stream().forEach(g -> {
g.setCreatorId(ssoUser.getId());
g.setCreatorName(ssoUser.getUsername() + “/“ + ssoUser.getRealname());
g.setCreateTime(new Date());
});
doorRecordResult = gateMachineService.handleInRecord(carinlist, parkId);
log.info(“入场消息消费处理结束 parkId : {}, 处理结果:{}”, parkId, JSONObject.toJSONString(doorRecordResult));
return doorRecordResult;
}

1
2


通过postman调用接口可以调通返回数据,但是通过对方应用调用就是不行,其中试了几种方式都是不行,最终发现遗漏了一项,就是content-type,对方应用调用我们接口推送数据的content-type是application/x-www-form-urlencoded,而我这边接口支持的content-type实际上是application/json,导致接口无法调用,接口文档中content-type类型一定要标明,否则会严重影响接口的开发。

application/x-www-form-urlencoded和application/json有什么区别呢?首先要了解下一共有几种content-type

  1. application/x-www-form-urlencoded
    application/x-www-form-urlencoded是最常见的 POST 提交数据的方式了。浏览器的原生

    表单,如果不设置 enctype 属性,那么最终就会以 application/x-www-form-urlencoded 方式提交数据,在springMVC里面接收此类型传递的数据要通过@RequestParam,如上面接口就可以修改成

1
2
3
4
5
6
7
8
less复制代码@PostMapping(value = "/inRecord/{parkId}/parkcarin",consumes = "application/x-www-form-urlencoded")
public DoorRecordResult createInRecord(@RequestParam (value = "carinlist") String carinlistStr,
@PathVariable("parkId") String parkId) {

-----
carinlistStr = parameterMap.get("carinlist")[0];

}

也可以不用springMVC的注解,直接从HttpServerlet中取值,如

1
2
3
4
5
6
7
8
9
ini复制代码@PostMapping(value = "/inRecord/{parkId}/parkcarin",consumes = "application/x-www-form-urlencoded")
public DoorRecordResult createInRecord(HttpServletRequest request,
@PathVariable("parkId") String parkId) {
List<DoorGateMachineRecordInVO> carinlist = null;
Map<String, String[]> parameterMap = request.getParameterMap();
try {
String carinlistStr = parameterMap.get("carinlist")[0];
log.info("入场消息消费处理开始 parkId : {}, 收到数据:{}", parkId,carinlistStr);
carinlist = JacksonUtil.toList(carinlistStr, DoorGateMachineRecordInVO.class);

之前对方系统报请求方式错误,就是因为代码中使用@RequestBody来接收参数,那什么时候要用@RequestBody来接收参数呢,这就要说第二种content-type
2. application/json

这个 Content-Type 作为响应头大家肯定不陌生。实际上,现在越来越多的人把它作为请求头,用来告诉服务端消息主体是序列化后的 JSON 字符串。由于 JSON 规范的流行,除了低版本 IE 之外的各大浏览器都原生支持 JSON.stringify,服务端语言也都有处理 JSON 的函数,使用 JSON 不会遇上什么麻烦。
这种类型是现在很常见的响应头,而接收她的参数就是@RequestBody,之前接口不通的原因也是我这边使用的响应头其实是application/json方式
3. multipart/form-data

这又是一个常见的 POST 数据提交的方式。我们使用表单上传文件时,必须让 表单的 enctype 等于 multipart/form-data
4. text/xml

它是一种使用 HTTP 作为传输协议,XML 作为编码方式的远程调用规范。典型的 XML-RPC 请求是这样的:

POST www.example.com HTTP/1.1 Content-Type: text/xml examples.getStateName 41

除了content-type类型外,还要注意的就是如何在postman上测试application/x-www-form-urlencoded请求。由于入参是复杂的数组,所以要考虑postman如何传递数组。

起初以为是这样传递

image.png

一直调不通,后来才发现是这样的

image.png

由此可见postman默认是一行对应一个key-value,当时联调时由于对postman的这种调用不熟悉,所以使用通过java代码的调用形式,这两种也是异曲同工的。了解这些后我也对http的请求头进行了一些了解

Accept:客户端能接受的内容类型 text/plain, text/html

User-Agent :发起请求方的信息

结语

Http接口算是诸多接口中最简单的接口,但是如果不熟悉他的请求头,请求体,及相应框架的接收处理,也会产生一系列的问题,只有再实践中发掘其中问题,才能再下次处理接口时更好的处理功能的开发,更快的处理相关问题,提升开发的效率。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

深入理解JVM

发表于 2021-11-05

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。 JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

baike.baidu.com/item/JVM/29…

一、类加载器

1.类加载

在Java代码中,类型(是一个class)的加载,连接与初始化过程都是在程序运行期间完成(runtime)的

类型加载最常见的:将已经存在的类的class文件字节码文件,从硬盘加载到内存

初始化过程:是将一些静态变量在初始化时进行赋值

2.类加载器深入剖析

  • Java虚拟机与程序的生命周期,在如下几种情况下,Java虚拟机将结束生命周期
0. 执行了System.exit()方法
1. 程序正常执行结束
2. 程序在执行过程中遇到了异常或错误而异常终止
3. 由于操作系统出现错误而导致Java虚拟机进程终止

3.类的加载,连接与初始化

  • 加载:查找并加载类的二进制数据
  • 连接
0. 验证:确保被加载的类的正确性
1. 准备:为类的**静态变量**分配内存,并将其初始化为**默认值**(每一个类型具有的值)
2. 解析:**把类中的符号引用转换为直接引用**
  • 初始化:为类的静态变量赋予真正正确的初始化值
1
2
3
4
arduino复制代码class Test{
   public static int a=1;
}
//a的值在准备阶段时是0默认值,而在解析完成后,到初始化时,a才被赋值为1,并不是一下子赋值将1赋值给a的

基本数据类型的值就是一个数字,一个字符或一个布尔值。

数据类型 byte short int long float double char boolean
大小(bit) 8 16 32 64 32 64 16 1
范围 -128 ~ 127 -32768 ~ 32767 -2147483648 ~ 2147483647 -9233372036854477808 ~ 9233372036854477807 -3.40292347E+38 ~ 3.40292347E+38 -1.79769313486231570E+308 ~ 1.79769313486231570E+308 ‘\u0000’ ~ ‘\uffff ‘ true / false
包装类 Character
默认值 0 0 0 0 0.0f 0.0d ‘\u0000’ false

注:

a.基本类型中,int永远占4个字节(1Byte = 8bit);

b.boolean类型长度与平台有关,其它数据类型长度都是与平台无关;

c.基本数据类型默认值仅在作为类中属性时生效。

4.类的使用和卸载

不像类加载,Java中没有提供显式进行类卸载的API,但是如果加载类的ClassLoader对象被垃圾回收器回收的话,这个类就会被卸载。所以我们可以自己实现ClassLoader,自己加载类,然后对ClassLoader对象的引用赋值为null,等ClassLoader对象剩下的引用数量为0时会被回收,这样就达到卸载类的目的了。

5.类的加载连接与初始化过程详解

  • 类的加载

类的加载值的是将类的.class文件中的二进制数据读取到内存中,将其放在运行时数据区的方法区内,然后在内存中创建一个java.Lang.Class对象(规范并未说明Class对象位于哪里,HotSpot虚拟机将其放在了方法区)用来封装类在方法区的数据结构。

加载.class文件的方式

0. 从本地系统中直接加载
1. 通过网络下载.class文件
2. 从zip,jar等归档文件中加载.class文件
3. 从专有的数据库中提取.class文件
4. **将Java源文件动态编译为.class文件**
  • Java程序对类的使用方式可分为两种
0. 主动使用(七种)


    + 创建类的实例(创建一个对象new)
    + 访问某个类或接口的静态变量,或者对该静态变量赋值
    + 调用类的静态方法
    + 反射(如:Class.forName("com,test.Test"))
    + 初始化一个类的子类(父类也会被初始化)
    + Java虚拟机启动时被标明为启动类的类(包含了main方法,程序入口的(java Test))
    + JDK1.7开始提供的动态语言支持java.lang.invoke.MethodHandle实例的解析结果REF\_getStatic,REF\_putStatic,REF\_invokeStatic句柄对应的类没有初始化,则初始化
1. 被动使用


除了主动使用的7种,其他使用java类的方式都被看做是对类的被动使用,都不会导致类**初始化**


被动使用不会初始化类,但是有可能会加载类
  • 所有的Java虚拟机实现必须在每个类或接口被Java程序”首次主动使用“时才初始化他们,初始化只会执行一次,只有主动使用才会初始化
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
csharp复制代码/**对于静态字段来说,只有直接定义了该字段的类才会被初始化;
当一个类在初始化时,要求其父类全部都已经初始化完毕了
虽然str2是Child1的属性,但Child1继承了Parent1,所以就会主动使用
因为Child.str的str属性是Parent1的,也就是从Parent上继承下来的,与Child无关,使用了谁的属性谁就会执行主动使用
*/
public class MyTest1{
   public static void main(String[] args) {
       System.out.println(MyChild1.str2);
       System.out.println(MyChild1.str);
  }
}
​
class MyParent1{
   public static String str ="hello word!";
   static {
       System.out.println("MyParent1 static block");
  }
}
class MyChild1 extends MyParent1{
   public static String str2="welcome";
   static {
       System.out.println("MyChild1 static block");
  }
}
​

##持续更新中

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

HTTP状态码:3XX

发表于 2021-11-05

「这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战」

3XX状态码

含义:表明浏览器需要执行某些特殊的处理以正确的处理请求,大部分都需要进行重定向。

这类状态码代表需要客户端采取进一步的操作才能完成请求。通常,这些状态码用来重定向,后续的请求地址(重定向目标)在本次响应的 Location 域中指明。

当且仅当后续的请求所使用的方法是 GET 或者 HEAD 时,用户浏览器才可以在没有用户介入的情况下自动提交所需要的后续请求。客户端应当自动监测无限循环重定向(例如:A->A,或者A->B->C->A),因为这会导致服务器和客户端大量不必要的资源消耗。按照 HTTP/1.0 版规范的建议,浏览器不应自动访问超过5次的重定向。

(使用gin框架来实践测试)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
go复制代码package main

import "github.com/gin-gonic/gin"

func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong"})
})

r.GET("/redirect/301", redirect301)
r.GET("/redirect/302", redirect302)
r.Run()
}

func redirect302(c *gin.Context) {
c.Redirect(302, "https://www.shanbay.com")
}

func redirect301(c *gin.Context) {
c.Redirect(301, "https://www.shanbay.com")
}

301 Moved Permanently(永久重定向)

含义:客户请求的文档在其他地方,新的URL在Location头中给出,浏览器应该自动地访问新的URL。

被请求的资源已永久移动到新位置,并且将来任何对此资源的引用都应该使用本响应返回的若干个 URI 之一。如果可能,拥有链接编辑功能的客户端应当自动把请求的地址修改为从服务器反馈回来的地址。

除非额外指定,否则这个响应也是可缓存的(也就是说浏览器会保存urlAf返回的301响应中Location的urlB,当下一次再去请求urlA时,会从缓存中直接得到urlB去请求,即便服务端已经把urlA的重定向改成了urlC,浏览器也不会知道。

其实我此时已经将301的重定向链接改为 www.shanbay.com/wordsweb/

1
2
3
go复制代码func redirect301(c *gin.Context) {
c.Redirect(301, "https://www.shanbay.com/wordsweb/")
}

302 Found (Previously “Moved temporarily”) (临时重定向)

含义:类似于301,但新的URL应该被视为临时性的替代,而不是永久性的。

请求的资源临时从不同的URI响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。

注意:虽然RFC 1945和RFC 2068规范不允许客户端在重定向时改变请求的方法,但是很多现存的浏览器将302响应视作为303响应,并且使用GET方式访问在Location中规定的URI,而无视原先请求的方法。状态码303和307被添加了进来,用以明确服务器期待客户端进行何种反应。

许多浏览器会错误地响应302应答进行重定向,由于这个原因,HTTP 1.1新增了307,并期望用303和307两个细分的状态码来替代含糊不清的302。

303 See Other

含义:类似于301/302,不同之处在于,如果原来的请求是POST,Location头指定的重定向目标文档应该通过GET提取(HTTP 1.1新)。

303与302不同之处在于,302是不会改变请求的方法,如果请求方法是POST的话,重定向的请求也应该是POST。而对于303,使用POST请求的话,重定向的请求应该是GET请求。

307 Temporary Redirect (since HTTP/1.1)

含义:和302(Found)相同(HTTP 1.1新)。

如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。对于307,使用POST请求的话,重定向的请求应该是POST请求。

304 Not Modified(未修改)

含义:客户端有缓冲的文档并发出了一个条件性的请求(一般是提供If-Modified-Since头表示客户只想比指定日期更新的文档)。服务器告诉客户,原来缓冲的文档还可以继续使用。

如果客户端发送了一个带条件的 GET 请求且该请求已被允许,而文档的内容(自上次访问以来或者根据请求的条件)并没有改变,则服务器应当返回这个状态码。304响应禁止包含消息体(因此304请求可以借助浏览器缓存来节省资源请求) 。

参考文章

浅谈之-http的状态码以及使用场景

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Elastic-Job的执行原理及优化实践

发表于 2021-11-05
  1. Quartz

Quartz是由OpenSymphony提供的强大的开源任务调度框架,用来执行定时任务。比如每天凌晨三点钟需要从数据库导出数据,这时候就需要一个任务调度框架,帮我们自动去执行这些程序。那Quartz是怎样实现的呢?

1)首先我们需要定义一个运行业务逻辑的接口,即Job,我们的类继承这个接口来实现业务逻辑,比如凌晨三点读取数据库并且导出数据。

640.png

2)有了Job之后需要按时执行这个Job,这就需要一个触发器Trigger,触发器Trigger就是按照我们的要求在每天凌晨三点执行我们定义的Job。

640 (1).png

3)有了任务Job和触发器Trigger后,就需要把它们结合起来,让触发器Trigger在规定的时间调用Job,这时需要一个Schedule来实现这个功能。所以,Quartz主要有三个部分组成:

调度器:Scheduler

任务:JobDetail

触发器:Trigger,包括SimpleTrigger和CronTrigger

创建一个Quartz任务的流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码//定义一个作业类,实现用户的业务逻辑
public class HelloJob implements Job {
......
实现业务逻辑
}
//根据作业类得到JobDetail
JobDetail jobDetail = JobBuilder.newJob(HelloJob.class)
//定义一个触发器,按照规定的时间调度作业
Trigger trigger = TriggerBuilder.newTrigger("每隔1分钟执行一次")
//根据作业类和触发器创建调度器
Scheduler scheduler = scheduler.scheduleJob(jobDetail,trigger);
//启动调度器,开始执行任务
scheduler .start()
  1. Elastic-Job的基本原理

2.1 分片

Elastic-Job为了提高任务的并发能力,引入了分片的概念,即将一个任务划分成多个分片,然后由多个执行的机器分别领取这些分片来执行。比如一个数据库中有1亿条数据,需要将这些数据读取出来并计算,然后再写入到数据库中。就可以将这1亿条数据划分成10个分片,每一个分片读取其中的1千万条数据,然后计算后写入数据库。这10个分片编号为0,1,2…9,如果有三台机器执行,A机器分到分片(0,1,2,9),B机器分到分片(3,4,5),C机器分到分片(6,7,8) 。

2.2 作业调度与执行

Elastic-Job是去中心化的任务调度框架,当多个节点运行时,会先选择一个主节点,当到达执行时间后,每个实例开始执行任务,主节点负责分片的划分,其它节点等待划分完成,主节点将划分后的结果存放到zookeeper中,然后每个节点再从zookeeper中获取划分好的分片项,将分片信息作为参数,传入到本地的任务函数中,从而执行任务。

2.3 作业的类型

elastic-job支持三种类型的作业任务处理!

Simple 类型作业:Simple 类型用于一般任务的处理,只需实现SimpleJob接口。该接口仅提供单一方法用于覆盖,此方法将定时执行,与Quartz原生接口相似。

Dataflow 类型作业:Dataflow 类型用于处理数据流,需实现DataflowJob接口。该接口提供2个方法可供覆盖,分别用于抓取(fetchData)和处理(processData)数据。

Script类型作业:Script 类型作业意为脚本类型作业,支持 shell,python,perl等所有类型脚本。只需通过控制台或代码配置 scriptCommandLine 即可,无需编码。执行脚本路径可包含参数,参数传递完毕后,作业框架会自动追加最后一个参数为作业运行时信息。

  1. Elastic-Job的执行原理

3.1 Elastic-Job的启动流程

下面以一个SimpleJob类型的任务来说明elastic-job的启动流程

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
typescript复制代码public class MyElasticJob implements SimpleJob {
public void execute(ShardingContext context) {
//实现业务逻辑
......
}

// 对zookeeper进行设置,作为分布式任务的注册中心
private static CoordinatorRegistryCenter createRegistryCenter() {
CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(new ZookeeperConfiguration("xxxx"));
regCenter.init();
return regCenter;
}

//设置任务的执行频率、执行的类
private static LiteJobConfiguration createJobConfiguration() { JobCoreConfiguration simpleCoreConfig = JobCoreConfiguration.newBuilder("demoSimpleJob", "0/15 * * * * ?", 10).build();
// 定义SIMPLE类型配置
SimpleJobConfiguration simpleJobConfig = new SimpleJobConfiguration(simpleCoreConfig, MyElasticJob.class.getCanonicalName());
// 定义Lite作业根配置
LiteJobConfiguration simpleJobRootConfig = LiteJobConfiguration.newBuilder(simpleJobConfig).build();
return simpleJobRootConfig;
}
//主函数
public static void main(String[] args) {
new JobScheduler(createRegistryCenter(), createJobConfiguration()).init();
}
}

创建一个Elastic-Job的任务并执行,步骤如下:

1)需要先设置zookeeper的基本信息,Elastic-Job使用zookeeper来进行分布式管理,如选主、元数据存储与读取、分布式监听机制等。

2)创建一个执行任务的Job类,以Simple 类型作业为例,创建一个继承SimpleJob的类,在这个类中实现execute函数。

3)设置作业的基本信息,在JobCoreConfiguration 中设置作业的名称(jobName),作业执行的时间表达式(cron),总的分片数(shardingTotalCount);然后在SimpleJobConfiguration 中设置执行作业的Job类,最后定义Lite作业根配置。

4)创建JobScheduler(作业调度器)实例,然后JobScheduler的init()方法中执行作业的初始化,这样作业就开始运行了。

Elastic-Job的作业调度在JobScheduler中完成,下面详细介绍JobScheduler方法。JobScheduler的定义如下:

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
arduino复制代码public class JobScheduler {

public static final String ELASTIC_JOB_DATA_MAP_KEY = "elasticJob";

private static final String JOB_FACADE_DATA_MAP_KEY = "jobFacade";

//作业配置
private final LiteJobConfiguration liteJobConfig;

//注册中心
private final CoordinatorRegistryCenter regCenter;

//调度器门面
private final SchedulerFacade schedulerFacade;

//作业门面
private final JobFacade jobFacade;

private JobScheduler(final CoordinatorRegistryCenter regCenter, final LiteJobConfiguration liteJobConfig, final JobEventBus jobEventBus, final ElasticJobListener... elasticJobListeners) {
JobRegistry.getInstance().addJobInstance(liteJobConfig.getJobName(), new JobInstance());

this.liteJobConfig = liteJobConfig;

this.regCenter = regCenter;

List<ElasticJobListener> elasticJobListenerList = Arrays.asList(elasticJobListeners);

setGuaranteeServiceForElasticJobListeners(regCenter, elasticJobListenerList);

schedulerFacade = new SchedulerFacade(regCenter, liteJobConfig.getJobName(), elasticJobListenerList);

jobFacade = new LiteJobFacade(regCenter, liteJobConfig.getJobName(), Arrays.asList(elasticJobListeners), jobEventBus);

}

如上,在JobScheduler的构造方法中,设置好作业配置信息liteJobConfig、注册中心regCenter、一系列监听器elasticJobListenerList ,调度器门面,作业门面。

在创建好JobScheduler实例后,就进行作业的初始化操作,如下:

1
2
3
4
5
6
7
8
9
10
scss复制代码/**     
* 初始化作业.
*/
public void init() {
JobRegistry.getInstance().setCurrentShardingTotalCount(liteJobConfig.getJobName(), liteJobConfig.getTypeConfig().getCoreConfig().getShardingTotalCount());
JobScheduleController jobScheduleController = new JobScheduleController(createScheduler(), createJobDetail(liteJobConfig.getTypeConfig().getJobClass()), liteJobConfig.getJobName());
JobRegistry.getInstance().registerJob(liteJobConfig.getJobName(), jobScheduleController, regCenter);
schedulerFacade.registerStartUpInfo(liteJobConfig);
jobScheduleController.scheduleJob(liteJobConfig.getTypeConfig().getCoreConfig().getCron());
}

如上,

1)JobRegistry是作业注册表,以单例的形式存储作业的元数据,在JobRegistry中设置好分片总数等信息。

2)jobScheduleController是作业调度控制器,在jobScheduleController中可以执行:调度作业、重新调度作业、暂停作业、恢复作业、立刻恢复作业。所以作业的开始、暂停、恢复都是在jobScheduleController中执行的。

3)在作业注册表JobRegistry中设置作业名称、作业调度器、注册中心。

4)执行调度器门面schedulerFacade的registerStartUpInfo方法,在这个方法中注册作业启动信息,代码如下:

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
scss复制代码/**
* 注册作业启动信息.
*
* @param liteJobConfig 作业配置
*/
public void registerStartUpInfo(final LiteJobConfiguration liteJobConfig) {
regCenter.addCacheData("/" + liteJobConfig.getJobName());
// 开启所有监听器
listenerManager.startAllListeners();
// 选举主节点
leaderService.electLeader();
//持久化job的配置信息
configService.persist(liteJobConfig);
LiteJobConfiguration liteJobConfigFromZk = configService.load(false);
// 持久化作业服务器上线信息
serverService.persistOnline(!liteJobConfigFromZk.isDisabled());
// 持久化作业运行实例上线相关信息,将服务实例注册到zk
instanceService.persistOnline();
// 设置 需要重新分片的标记
shardingService.setReshardingFlag();
// 初始化 作业监听服务
monitorService.listen();
// 初始化 调解作业不一致状态服务
if (!reconcileService.isRunning()) {
reconcileService.startAsync();
}
}

如上,

1)开启所有的监听器,利用zookeeper的watch机制来监听系统中各种元数据的变化,从而执行相应的操作

2)选举主节点,利用zookeeper的分布式锁来选择一个主节点,主节点主要进行分片的划分。

3)持久化各种元数据到zookeeper,如作业的配置信息,每个服务实例的信息等

4)设置需要分片的标志,在第一次执行任务或者系统中服务实例增减时都需要重新分片。

在作业启动信息注册好以后,就调用jobScheduleController的scheduleJob方法,进行作业的调度,这样作业就开始执行了。scheduleJob方法的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arduino复制代码/**
* 调度作业.
*
* @param cron CRON表达式
*/
public void scheduleJob(final String cron) {
try {
if (!scheduler.checkExists(jobDetail.getKey())) {
scheduler.scheduleJob(jobDetail, createTrigger(cron));
}
scheduler.start();
} catch (final SchedulerException ex) {
throw new JobSystemException(ex);
}
}

通过前面Quartz的讲解可知,scheduler通过将jobDetail和触发器Trigger结合,再调用scheduler.start(),这样就开始了作业调用。

通过上面的代码分析可知。作业的启动流程如下:

640 (2).png

3.2 Elastic-Job的执行流程

通过前面Quartz的讲解可知,任务的执行实际是运行JobDetail中定义的业务逻辑,我们只需要看jobDetail里面的内容,就能知道作业执行的过程

1
2
3
4
arduino复制代码private JobDetail createJobDetail(final String jobClass) 
JobDetail result = JobBuilder.newJob(LiteJob.class).withIdentity(liteJobConfig.getJobName()).build();
//忽略其它代码
}

通过上面的代码可知,执行的任务就是LiteJob这个类的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public final class LiteJob implements Job {

@Setter
private ElasticJob elasticJob;

@Setter
private JobFacade jobFacade;

@Override
public void execute(final JobExecutionContext context) throws JobExecutionException {
JobExecutorFactory.getJobExecutor(elasticJob, jobFacade).execute();
}
}

LiteJob 通过 JobExecutorFactory 获得到作业执行器( AbstractElasticJobExecutor ),并进行执行:

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
java复制代码public final class JobExecutorFactory {

/**
* 获取作业执行器.
*
* @param elasticJob 分布式弹性作业
* @param jobFacade 作业内部服务门面服务
* @return 作业执行器
*/
@SuppressWarnings("unchecked")
public static AbstractElasticJobExecutor getJobExecutor(final ElasticJob elasticJob, final JobFacade jobFacade) {
// ScriptJob
if (null == elasticJob) {
return new ScriptJobExecutor(jobFacade);
}
// SimpleJob
if (elasticJob instanceof SimpleJob) {
return new SimpleJobExecutor((SimpleJob) elasticJob, jobFacade);
}
// DataflowJob
if (elasticJob instanceof DataflowJob) {
return new DataflowJobExecutor((DataflowJob) elasticJob, jobFacade);
}
throw new JobConfigurationException("Cannot support job type '%s'", elasticJob.getClass().getCanonicalName());
}
}

可见,作业执行器工厂JobExecutorFactory ,根据不同的作业类型,返回对应的作业执行器,然后执行对应作业执行器的execute()函数。下面看一下execute函数

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
scss复制代码// AbstractElasticJobExecutor.java
public final void execute() {
// 检查作业执行环境
try {
jobFacade.checkJobExecutionEnvironment();
} catch (final JobExecutionEnvironmentException cause) {
jobExceptionHandler.handleException(jobName, cause);
}
// 获取当前作业服务器的分片上下文
ShardingContexts shardingContexts = jobFacade.getShardingContexts();
// 发布作业状态追踪事件(State.TASK_STAGING)
if (shardingContexts.isAllowSendJobEvent()) {
jobFacade.postJobStatusTraceEvent(shardingContexts.getTaskId(), State.TASK_STAGING, String.format("Job '%s' execute begin.", jobName));
}
// 跳过存在运行中的被错过作业
if(jobFacade.misfireIfRunning(shardingContexts.getShardingItemParameters().keySet())) {
// 发布作业状态追踪事件(State.TASK_FINISHED)
if (shardingContexts.isAllowSendJobEvent()) {
jobFacade.postJobStatusTraceEvent(shardingContexts.getTaskId(), State.TASK_FINISHED, String.format(
"Previous job '%s' - shardingItems '%s' is still running, misfired job will start after previous job completed.", jobName,
shardingContexts.getShardingItemParameters().keySet()));
}
return;
}
// 执行作业执行前的方法
try {
jobFacade.beforeJobExecuted(shardingContexts);
//CHECKSTYLE:OFF
} catch (final Throwable cause) {
//CHECKSTYLE:ON
jobExceptionHandler.handleException(jobName, cause);
}
// 执行普通触发的作业
execute(shardingContexts, JobExecutionEvent.ExecutionSource.NORMAL_TRIGGER);
// 执行被跳过触发的作业
while (jobFacade.isExecuteMisfired(shardingContexts.getShardingItemParameters().keySet())) {
jobFacade.clearMisfire(shardingContexts.getShardingItemParameters().keySet());
execute(shardingContexts, JobExecutionEvent.ExecutionSource.MISFIRE);
}
// 执行作业失效转移
jobFacade.failoverIfNecessary();
// 执行作业执行后的方法
try {
jobFacade.afterJobExecuted(shardingContexts);
//CHECKSTYLE:OFF
} catch (final Throwable cause) {
//CHECKSTYLE:ON
jobExceptionHandler.handleException(jobName, cause);
}
}

execute函数的主要流程:

  1. 检查作业执行环境
  2. 获取当前作业服务器的分片上下文。即通过函数jobFacade.getShardingContexts()获取当前的分片信息,由主节点根据相应的分片策略来进行分片项的划分,划分好之后将划分结果存入到zookeeper中,其它节点再从zookeeper中获取划分结果。
  3. 发布作业状态追踪事件
  4. 跳过正在运行中的被错过执行的作业
  5. 执行作业执行前的方法
  6. 执行普通触发的作业最后,会调用MyElasticJob中的execute方法,从而达到执行用户业务逻辑的目的。整个Elastic-Job的执行流程如下:

640 (3).png

  1. Elastic-Job的优化实践

4.1 空转问题

Elastic-Job的作业按照是否有实现类可以分为两种:有实现类的作业和没有实现类的作业。如Simple类型和DataFlow类型的作业需要用户自己定义实现类,继承SimpleJob或者DataFlowJob类;另一种是不需要实现类的作业,如Script类型作业和Http类型作业,对应这种不需要实现类的作业,用户只需要在配置平台填写好相应的配置,我们后台再定时的从配置平台拉取最新注册的任务,然后就可以执行用户最新注册的script或者Http类型的作业。

在生产环境中,执行作业的集群的机器数量很多,但是用户注册的每个作业的分片却很少(大部分只有1个分片),根据前面的分析可知,对应只有一个分片的任务,集群中的所有机器都会参与运行,但是由于只有得到那个分片的机器才会真正运行,其余的都会因为没有分片而空转,这无疑是对计算资源的浪费。

4.2 解决方案

为了解决分片数量少、执行服务器多而出现的空转问题,我们这边的解决方案是用户在配置平台注册任务时,指定好对应的执行服务器,执行服务器的数量M=分片数+1(多出来的机器作为冗余备份)。如用户的作业分片为2, 后台根据每天机器当前的负载排序,选择3台负载最轻的机器作为执行服务器。这样当这些机器定时从配置平台拉取任务时,如果发现自己不属于这个任务的执行服务器,就不运行这个作业,只有属于当前任务的执行服务器才运行。这样既保证了可靠性,又避免了过多机器的空转,提高了效率。

  1. OPPO海量作业调度方案

Elastic-Job通过zookeeper来实现弹性分布式的功能,这在任务量很小的时候可以满足用户需求,但是也有以下缺点:

  1. Elastic-Job的弹性分布式功能强依赖zookeeper,zookeeper容易成为性能瓶颈。
  2. 任务划分的分片数可能小于执行任务的实例数,导致一些机器空转。

基于Elastic-Job的上述缺点,OPPO中间件团队在处理海量任务调度时,采用了集中式的调度方案,用户的作业不需要通过Quartz来定时触发,而是通过接收服务器的消息来触发本地任务。用户先在注册平台注册任务,服务器定时从注册平台的数据库中扫描最近一个周期(30秒)内需要执行的任务,再根据任务的实际执行时间生成延时消息并写入具有延时功能的消息队列,用户再从消息队列中拉取数据并触发作业的执行。这种集中式的调度方式由中心服务器来触发消息执行,既克服了zookeeper的性能瓶颈,又避免了任务服务器的空转,能够满足海量任务的执行要求。

总结

Elastic-Job使用quartz来进行作业的调度,同时引入zookeeper来实现分布式管理的功能,在高可用方案的基础上增加了弹性扩容和数据分片的思路,以便于更大限度的利用分布式服务器的资源从而实现了分布式任务调度的功能。同时由于分片的思路,也会导致没有得到分片的服务器处于空转的状态,这在实际的生产中可以设法规避。

作者简介

Xinchun OPPO高级后端工程师

目前负责分布式作业调度的研发,关注消息队列、redis数据库、ElasticSearch等中间件技术。

获取更多精彩内容,扫码关注[OPPO数智技术]公众号

二维码.jfif

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…415416417…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%