拉巴力的纸皮箱


  • 首页

  • 标签

  • 归档

  • 关于

  • 搜索

理解状态机原理及实践

发表于 2022-02-23

基本概念

  • 有限状态机FSM

  • 描述事物的有限状态机模型的元素由以下组成:

    1. 状态(State):事物的状态,包括初始状态和所有事件触发后的状态
    2. 事件(Event):触发状态变化或者保持原状态的事件
    3. 行为或转换(Action/Transition):执行状态转换的过程
    4. 检测器(Guard):检测某种状态要转换成另一种状态的条件是否满足
  • https://www.jianshu.com/p/37281543f506

  • 深入浅出理解有限状态机

  • 状态机的要素

  • 状态机可归纳为4个要素,即现态、条件、动作、次态。“现态”和“条件”是因,“动作”和“次态”是果。详解如下:
    1. 现态:是指当前所处的状态。
    2. 条件:又称为“事件”。当一个条件被满足,将会触发一个动作,或者执行一次状态的迁移。
    3. 动作:条件满足后执行的动作。动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。动作不是必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。
    4. 次态:条件满足后要迁往的新状态。“次态”是相对于“现态”而言的,“次态”一旦被激活,就转变成新的“现态”了。

代码实现

  1. 定义状态

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public enum ProcessState {

    INIT(0, "未开始"),
    READY_ONE_SIDE(1, "一方准备完成"),
    READY_ALL(2, "双方准备完成"),
    CHAT(3, "发言完成"),
    END(4, "结束"),
    ;

    private int val;
    private String desc;

    ProcessState(int val, String desc) {
    this.val = val;
    this.desc = desc;
    }

    public int getVal() {
    return val;
    }
    }
  2. 定义事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public enum ProcessEvent {

    READY_ONE_SIDE(1, "一方准备"),
    READY_ALL(2, "另一方准备"),
    CHAT(3, "发言"),
    FORBID_ALL(4, "全员禁言"),
    ;

    private Integer code;
    private String desc;

    ProcessEvent(Integer code, String desc) {
    this.code = code;
    this.desc = desc;
    }

    public Integer getCode() {
    return code;
    }
    }
  3. 定义状态机

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class ProcessEventConfig {

    private ProcessEvent event;
    private ProcessState fromState;
    private ProcessState toState;

    public String desc(){
    return fromState.getDesc() + "_" + event.getDesc() + "_" + toState.getDesc();
    }
    }


    List<ProcessEventConfig> configList = new ArrayList<>(16);
    configList.add(new ProcessEventConfig(ProcessEvent.READY_ONE_SIDE, ProcessState.INIT, ProcessState.READY_ONE_SIDE));
    configList.add(new ProcessEventConfig(ProcessEvent.READY_ALL, ProcessState.READY_ONE_SIDE, ProcessState.READY_ALL));
    configList.add(new ProcessEventConfig(ProcessEvent.CHAT, ProcessState.READY_ALL, ProcessState.CHAT));
    configList.add(new ProcessEventConfig(ProcessEvent.FORBID_ALL, ProcessState.CHAT, ProcessState.END));

    Map<ProcessEvent, ProcessEventConfig> eventResultStateConfigMap = new EnumMap<>(ProcessEvent.class);

    configList.forEach(eventConfig -> eventResultStateConfigMap.put(eventConfig.getEvent(), eventConfig));

  4. 触发状态变化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public boolean fire(ProcessEvent event, Process process) {

    ProcessEventConfig config = Optional.ofNullable(eventResultStateConfigMap.get(event)).orElseThrow(() -> ExceptionCodeEnum.AGRS_INVALID.newException("不存在该事件"));

    if (process.getStatus() != config.getFromState().getVal()) {
    return false;
    }
    process.setRemark(config.desc());
    process.setStatus(config.getToState().getVal());

    //DB改变状态
    /*update table set status=${status} WHERE id={id} AND status=${beforeStatus}*/
    boolean suc = processManager.updateStatus(process, config.getFromState().getVal());
    if (suc) {
    //变更成功,业务逻辑
    }
    return suc;
    }

nba选秀抽签具体是如何操作的?

发表于 2022-02-20

nba选秀抽签具体是如何操作的?

  • 14个乒乓球分别贴上1-14数字,随机滚出4个,加起来是1001可能,其中11、12、13、14这个组合不算,剩下1000种可能。

  • 闲来无事,使用Go粗暴模拟了一下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
package main

import (
"fmt"
"math/rand"
"sort"
"time"
)

/**
https://www.zhihu.com/question/52895544
*/
func main() {
n := 14
m := 4
nn := 1
mm := 1
nm := 1
for i := 1; i <= n; i++ {
nn = nn * i
}
for i := 1; i <= m; i++ {
mm = mm * i
}
for i := 1; i <= (n - m); i++ {
nm = nm * i
}

all_result := make([]string, 1000)
all_result_map := make(map[string]int)

count := 0
for i := 1; i <= 11; i++ {
for j := i + 1; j <= 12; j++ {
for k := j + 1; k <= 13; k++ {
for l := k + 1; l <= 14; l++ {
//fmt.Printf("%d -> %d -> %d -> %d\n", i, j, k, l)
if i == 11 && j == 12 && k == 13 && l == 14 {
continue
}
all_result[count] = fmt.Sprintf("%d-%d-%d-%d", i, j, k, l)
count++
all_result_map[fmt.Sprintf("%d-%d-%d-%d", i, j, k, l)] = count
}
}
}
}

weight_arr := [14]int{250, 199, 156, 119, 88, 63, 43, 28, 17, 11, 8, 7, 6, 5}
all_weight := 0
source_map := make(map[int]int)

for num, weight := range weight_arr {

for i := 0; i < weight; i++ {
source_map[i+1+all_weight] = num + 1
}
all_weight = all_weight + weight
}

/**
模拟前三顺位
*/
all_hit_result := []int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
all_hit_result_second_round := []int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
all_hit_result_third_round := []int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
r := rand.New(rand.NewSource(time.Now().Unix()))
for xx := 0; xx < 1000000; xx++ {

hit_map := make(map[int]int)
for round := 0; round < 3; round++ {
all_ball := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}

chosen_ball := make([]int, 4)
for i := 0; i < 4; i++ {

hit := r.Intn(14 - i)
chosen_ball[i] = all_ball[hit]
index := hit
all_ball = append(all_ball[:index], all_ball[index+1:]...)

}

sort.Ints(chosen_ball)
result := fmt.Sprintf("%d-%d-%d-%d", chosen_ball[0], chosen_ball[1], chosen_ball[2], chosen_ball[3])

if result == "11-12-13-14" {
round--
continue
}

result_rank := source_map[all_result_map[result]]

value := hit_map[result_rank]
if value == 1 {
round--
continue
}

if round == 0 {
all_hit_result[result_rank-1]++
}
if round == 1 {
all_hit_result_second_round[result_rank-1]++
}
if round == 2 {
all_hit_result_third_round[result_rank-1]++
}

hit_map[result_rank] = 1
}

}
fmt.Println("第一轮")
for i, resut_hit := range all_hit_result {
fmt.Printf("%d -> %d\n", i+1, resut_hit)
}
fmt.Println("第二轮")
for i, resut_hit := range all_hit_result_second_round {
fmt.Printf("%d -> %d\n", i+1, resut_hit)
}
fmt.Println("第三轮")
for i, resut_hit := range all_hit_result_third_round {
fmt.Printf("%d -> %d\n", i+1, resut_hit)
}

/**
模拟100万次
第一轮
1 -> 249512
2 -> 198836
3 -> 155714
4 -> 118849
5 -> 88395
6 -> 63352
7 -> 43285
8 -> 28016
9 -> 16892
10 -> 11093
11 -> 7894
12 -> 7004
13 -> 6100
14 -> 5058
第二轮
1 -> 215647
2 -> 188267
3 -> 157704
4 -> 125474
5 -> 96543
6 -> 71033
7 -> 49195
8 -> 32360
9 -> 19803
10 -> 13185
11 -> 9521
12 -> 8252
13 -> 7031
14 -> 5985
第三轮
1 -> 177455
2 -> 171603
3 -> 155736
4 -> 133323
5 -> 106458
6 -> 81193
7 -> 58185
8 -> 38863
9 -> 24146
10 -> 15550
11 -> 11508
12 -> 10064
13 -> 8634
14 -> 7282
*/

}

Java11基础原理和实践

发表于 2022-02-08

一、为什么选择java11

Java 11 是 Java 8 之后的首个 LTS 版本(Long-term support 长期支持版本),所以有不少开发者表示会选择升级至 Java 11。

1、目前相对来说,Java 8 太旧,Java 17 太新,Java11 刚刚好;
2、支持以类路径方式运行,适合过渡阶段升级;
3、其他原因(下文会说明)

二、模块系统

模块系统是Java 9的旗舰功能,使用java9,有必要了解一下。

java9之前存在哪些痛点

1、没有封装

类一旦公开,意味着没有封装。

如果将一个类设置为protected,那么就可以防止其他类访问该类,除非这些类与该类位于相同的包中。但这样做会产生一个有趣的问题:如果想从组件的另一个包中访问该类,同时仍然防止其他类使用该类,那么应该怎么做呢?事实是无法做到。

让类公开,意味着对系统中的所有类型都是公开的,也就意味着没有封装。

Java在过去经历过相当多的安全漏洞。这些漏洞都有一个共同的特点:不知何故,攻击者可以绕过JVM的安全沙盒并访问JDK中的敏感类。从安全的角度来看,在JDK中对危险的内部类进行强封装是一个很大的改进。同时,减少运行时中可用类的数量会降低攻击面。在应用程序运行时中保留大量暂时不使用的类是一种不恰当的做法。而通过使用模块化JDK,可以确定应用程序所需的模块。

2、编译时无法感知依赖缺失或类重复

Java确实使用了显式的import语句。但不幸的是,从严格意义上讲,这些导入是编译时结构,一旦将代码打包到JAR中,就无法确定哪些JAR包含当前JAR运行所需的类型。

在Java 9出现之前,JAR文件似乎是最接近模块的,它们拥有名称、对相关代码进行了分组并且提供了定义良好的公共接口。

(1)依赖缺失
所有类按照-classpath参数定义的顺序加载类。由于类会延迟加载,JVM无法在应用程序启动时有效地验证类路径的完整性,即无法预先知道类路径是否是完整的,或者是否应该添加另一个JAR。

(2)类重复
当类路径上有重复类时,则会出现更为隐蔽的问题。出现相同库的两个版本(如Guava 19和Guava 18)是非常常见的,这两个库JAR以一种未定义的顺序压缩到类路径中。库类的任一版本都可能会被首先加载。此外,有些类还可能会使用来自(可能不兼容的)其他版本的类。此时就会导致运行时异常。

关于类重复

目前存在以下方法缓解或解决

1、通过插件检查类重复

在编译时,检测类是否重复,比如使用插件 maven enforcer plugin,但本质上是通过解压全部jar包,检查所有文件是否存在重复类来判断的,效率可想而知。

2、通过构建工具保证只存在一个版本的jar包

构建工具冲突失败策略设置如gradle中

1
2
3
configurations.all {
    resolutionStrategy {  failOnVersionConflict() }
}

这种设置仅仅是针对jar包冲突,不能保证不同名的jar包是否存在相同的类。

处理jar包冲突有两种策略:

1)强制指定版本:版本冲突太多,指定版本的情况,后续有新的版本间接依赖也无法直接发现。
2)取最高版本:同样有可能引起高版本导致不兼容的问题。

要彻底解决,只有项目依赖完全模块化。

构建工具

Maven、Gradle等构建工具,在运行时没有任何作用。Maven构建了想要运行的工件,但是最后仍然需要配置Java运行时,以便运行正确的模块路径和类路径。虽然手动配置模块路径比类路径要容易得多,但它仍然是重复的工作,因为相关信息已经在pom.xml文件中了。

模块化的好处

模块化 可以 在编译时和运行时获得所有这些信息所带来的优势。这可以防止对来自其他非引用模块的代码的意外依赖。通过检查(传递)依赖关系,工具链可以知道运行模块需要哪些附加模块并进行优化。

Java平台模块系统带来了如下最重要的好处:

1.可靠的配置在编译或运行代码之前,模块系统会检查给定的模块组合是否满足所有依赖关系,从而导致更少的运行时错误。
2.强封装型模块显式地选择了向其他模块公开的内容,从而防止对内部实现细节的意外依赖。
3.可扩展开发显式边界能够让开发团队并行工作,同时可创建可维护的代码库。只有显式导出的公共类型是共享的,这创建了由模块系统自动执行的边界。
4.安全性在JVM的最深层次上执行强封装,从而减少Java运行时的攻击面,同时无法获得对敏感内部类的反射访问。
5.优化由于模块系统知道哪些模块是在一起的,包括平台模块,因此在JVM启动期间不需要考虑其他代码。同时,其也为创建模块分发的最小配置提供了可能性。此外,还可以在一组模块上应用整个程序的优化。

在模块出现之前,这样做是非常困难的,因为没有可用的显式依赖信息,一个类可以引用类路径中任何其他类。

此外还有:
(1)JDK9的模块化可以减少Java程序打包的体积,同时拥有更好的隔离线与封装性。
在JDK9之前,JVM的基础类以前都是在rt.jar这个包里,这个包也是JRE运行的基石。这不仅是违反了单一职责原则,同样程序在编译的时候会将很多无用的类也一并打包,造成臃肿。在JDK9中,整个JDK都基于模块化进行构建,以前的rt.jar, tool.jar被拆分成数十个模块,编译的时候只编译实际用到的模块,同时各个类加载器各司其职,只加载自己负责的模块。
(2)经过破坏后的双亲委派模型更加高效,减少了很多类加载器之间不必要的委派操作

模块系统的基础

本质上就是一个jar包切成多模块(更细的jar包)来引用

1、module-info.java描述文件 关键字说明

requires代表依赖的模块,只有依赖的模块存在才能通过编译并运行.需要注意的是,所有模块均自动隐式依赖java.base模块,不需要显示声明
exports指出需要暴露的包,如果某个包没有被exports,那么其他模块是无法访问的。

Readability:指的是必须exports的包才可被其他模块访问
Accessibility:指的是即使是exports的包,其中的类的可访问下也要基于java的访问修饰符,仅有public修饰的才可被其他模块访问

Implied Readability(隐式Readability, requires transitive):
Readability默认情况下是不会被传递的
requires transitive,传递性依赖生效

由于requires transitive的存在,就可以支持聚合模块。有些聚合模块可以没有任何代码,就一个module-info.java描述文件,比如java.se, java.se.ee模块
不建议直接引用java.se模块,因为它就相当于java9以前版本的rt.jar的内容。

Qualified Exports(有限制的exports)
比如我只想exports某个包给部分模块,而不是所有模块

2、模块化基础

(1)模块拥有一个名称,并对相关的代码以及可能的其他资源进行分组,同时使用一个模块描述符进行描述。模块描述符保存在一个名为module-info.java的文件中。
(2)模块描述符还可以包含exports语句。强封装性是模块的默认特性。只有当显式地导出一个包时(比如示例中的java.util.prefs),才可以从其他模块中访问该包
可访问性和可读性的结合可以确保在模块系统中实现强封装性。
(3)其他模块无法使用未导出包中的任何类型——即使包中的类型是公共的。这是对Java语言可访问性规则的根本变化。
(4)Java 9出现之后,public意味着仅对模块中的其他包公开。只有当导出包包含了公开类型时,其他模块才可以使用这些类型。这就是强封装的意义所在。

在模块出现之前,强封装实现类的唯一方法是将这些类放置到单个包中,并标记为私有。这种做法使得包变得非常笨重,实际上,将类公开只是为了实现不同包之间的访问。通过使用模块,可以以任何方式构建包,并仅导出模块使用者真正必须访问的包。如果愿意的话,还可以将导出的包构成模块的API。

(5)模块提供了导出包的显式信息,从而能够高效地对模块路径进行索引。当从给定的包中查找类型时,Java运行时和编译器可以准确地知道从模块路径中解析哪个模块。而在以前,对整个类路径进行扫描是找到任意类型的唯一方法。

在解析过程中还会完成一些额外的检查。例如,具有相同名称的两个模块在启动时(而不是在运行过程出现类加载失败时)会产生错误。此外,还会检查导出包的唯一性。

模块解析过程以及额外的检查确保了应用程序在一个可靠的环境中运行,降低了运行时失败的可能性。

(6)通过使用未命名模块,尚未模块化的代码可以继续在JDK 9上运行。
当将代码放在类路径上时,会自动使用未命名模块。这也意味着需要构建一个正确的类路径。可一旦使用了未命名模块,前面讨论的模块系统所带来的保障和好处也就没有了。

当在Java 9中使用类路径时,还需要注意两件事情。首先,由于平台是模块化的,因此对内部实现类进行了强封装。

(7)模块系统执行的另一个检查是循环依赖。
在编译时,模块之间的可读性关系必须是非循环的。而在模块中,仍然可以在类之间创建循环关系

(8)通过使用ServiceLoader API可在模块描述符和代码中表示服务
使用这个功能可以对代码的实现进行良好的封装

3、以非模块化方式开发和运行应用

在java9中也是允许你以非模块化方式开发和运行应用的(也就是说,模块化开发是可选的),如果你的应用中没有module-info.java,那么这就是一个unnamed module. java9对于unnamed module的处理方式就是所有的jdk模块均直接可用(模块图中是以java.se模块作为root模块的,也意味着单独处于java.se.ee下的一些包,比如JAXB API是无法访问到的)。

但是需要注意的是,在java8以及之前的版本中,我们可以访问jdk中的一些不推荐访问的内部类,比如com.sun.image.codec.jpeg,但在java9模块化之后被强封装了,所以在java9中无法使用这些内部类,也就是说无法通过编译,但是java9为了保持兼容性,允许之前引用这些内部类的已有的jar或已编译的类正确运行。换言之,就是java9不允许源码中引用这些类,无法通过编译,但是之前版本中引用这些类的已编译class文件是允许正常运行的。

三、迁移

(1)为了便于将基于类路径的应用程序迁移到Java 9,在对平台模块中的类应用深度反射时,或者使用反射来访问非导出包中的类型时,JVM默认显示警告

(2)那些在JDK 8和更早的版本上运行没有任何问题的代码现在会在控制台上显示一个醒目的警告——即使是在生产环境中也是如此。这表明严重破坏了强封装。除了这个警告之外,应用程序仍然照常运行。如警告消息所示,在下一个Java版本中行为将发生变化。将来,即使是类路径上的代码,JDK也会强制执行平台模块的强封装。

(3)可以使用–add-opens标志授予对模块中特定包的类路径深度反射访问。同样,当类路径上的代码尝试访问非导出包中的类型时,可以使用–add-exports来强制导出包。

(4)为了使逐步迁移成为可能,可以混合使用类路径和模块路径。但这不是一种理想的情况,因为只能部分受益于Java模块系统的优点。但是,“小步”迁移是非常有帮助的。

自动模块

Java模块系统提供了一个有用的功能来处理非模块的代码:自动模块。只需将现有的JAR文件从类路径移动到模块路径,而不改变其内容,就可以创建一个自动模块。这样一来,JAR就转换为一个模块,同时模块系统动态生成模块描述符。相比之下,显式模块始终有一个用户自定义的模块描述符。

自动模块的行为不同于显式模块。自动模块具有以下特征:

(1)不包含module-info.class;
(2)它有一个在META-INF/MANIFEST.MF中指定或者来自其文件名的模块名称。
(3)通过requires transitive请求所有其他已解析模块。
(4)导出所有包。
(5)读取路径(或者更准确地讲,读取前面所讨论的未命名模块)。
(6)它不能与其他模块拆分包。

自动模块并不是一个设计良好的模块。虽然请求所有的模块并导出所有的包听起来不像是正确的模块化,但至少是可用的。

自动模块中仍然没有明确的信息来告诉模块系统真正需要哪些模块,这意味着JVM在启动时不会警告自动模块的依赖项丢失。

作为开发人员,负责确保模块路径(或类路径)包含所有必需的依赖项。这与使用类路径没有太大区别。

模块图中的所有模块都需要通过自动模块传递。这实际上意味着,如果请求一个自动模块,那么就可以“免费”获得所有其他模块的隐式可读性。

当使用自动模块时,也会遇到拆分包。在大型应用程序中,由于依赖关系管理不善,通常会发现拆分包。拆分包始终是一个错误,因为它们在类路径上不能可靠地工作。

未命名模块

模块路径上的非模块化JAR变成了自动模块。而类路径变成了未命名模块。

类路径上的所有代码都是未命名模块的一部分。

存在一个很大的限制:未命名模块本身只能通过自动模块读取(只有自动模块可以读取类路径)

当读取未命名模块时自动模块和显式模块之间的区别。显式模块只能读取其他显式模块和自动模块。而自动模块可读取所有模块,包括未命名模块。

未命名模块的可读性只是一种在混合类路径/模块路径迁移方案中有助于自动模块的机制。

JVM为什么没有那么“聪明”呢?它有权访问自动模块中的所有代码,那么为什么不分析依赖关系呢?如果想要分析这些代码是否调用其他模块,JVM需要对所有代码执行字节码分析。虽然这不难实现,但却是一个昂贵的操作,可能会大量增加大型应用程序的启动时间。而且,这样的分析不会发现通过反射产生的依赖关系。由于存在这些限制,JVM不可能也永远不会这样做。相反,JDK附带了另一个工具jdeps,它可以执行字节码分析。

现代构建工具通常有一个“在重复依赖项上失败”的设置,这使得依赖关系管理问题变得更加清晰,从而迫使尽早解决这些问题。强烈建议使用这个设置。关于该问题,Java模块系统比类路径要严格得多。当它检测到一个包从模块路径上的两个模块导出时,就会拒绝启动。相比于以前使用类路径时所遇到的不可靠情况,这种快速失败(fail-fast)机制要好得多。在开发过程中失败好过在生产过程中失败,尤其是当一些不幸的用户碰到一个由于模糊的类路径问题而被破坏的代码路径时。但这也意味着我们必须处理这些问题。盲目地将所有JAR从类路径移至模块路径可能导致在生成的自动模块之间出现拆分包。而这些拆分包将被模块系统所拒绝。

为了使迁移变得容易一些,当涉及自动模块和未命名模块时,上述规则存在一个例外,即承认很多类路径是不正确的,并且包含拆分包。当(自动)模块和未命名模块都包含相同的包时,将使用来自(自动)模块的包,而未命名模块中的包被忽略。

如果在迁移到Java 9时遇到了拆分包问题,那么是无法绕过的。即使从用户角度来看基于类路径的应用程序可以正确工作,你也必须处理这些问题。

拆分包会导致无法以模块系统的方式启动服务

未命名模块(unnamed module)和自动模块(automatic module)总结

(1)一个未经模块化改造的 jar 文件是转为未命名模块还是自动模块,取决于这个 jar 文件出现的路径,如果是类路径,那么就会转为未命名模块,如果是模块路径,那么就会转为自动模块。注意,自动模块也属于命名模块的范畴,其名称是模块系统基于 jar 文件名自动推导得出的
(2)两者还有一个关键区别,分裂包规则适用于自动模块,但对未命名模块无效,也即多个未命名模块可以导出同一个包,但自动模块不允许。
(3) 未命名模块和自动模块存在的意义在于,无论传入的 jar 文件是否一个合法的模块(包含 module descriptor),Java 内部都可以统一的以模块的方式进行处理,这也是 Java 9 兼容老版本应用的架构原理。运行老版本应用时,所有 jar 文件都出现在类路径下,也就是转为未命名模块,对于未命名模块而言,默认导出所有包并且依赖所有模块,因此应用可以正常运行。
(4)基于未命名模块和自动模块,相应的就产生了两种老版本应用的迁移策略,或者说模块化策略。
(5)等所有 jar 包都完成模块化改造,应用改为 -m 方式启动,这也标志着应用已经迁移为真正的 Java 9 应用
(6)-cp 和 -m 可以同时存在 并运行??可以(类路径和模块系统可以混合运行)

java9 自动模块 的意义

(1)个人理解,只是为了让开发者能像模块一样使用起来,引入。实际上封装性方面和类路径没任何区别。
(2)未命名模块和自动模块存在的意义在于,无论传入的 jar 文件是否一个合法的模块(包含 module descriptor),Java 内部都可以统一的以模块的方式进行处理,这也是 Java 9 兼容老版本应用的架构原理。

其他

(1)如何用最快的速度判别它是不是一个模块?它又是如何定义的?
试试看 jar -d -f 。
(2)通过JDK11内置jdeps工具查找过期以及废弃API以及对应的替换
./jdk-11.0.10.jdk/Contents/Home/bin/jdeps --jdk-internals ./build/libs/service.jar
./jdk-11.0.10.jdk/Contents/Home/bin/jdeps --jdk-internals -R --class-path ./build/deploy/* ./build/libs/*
jdeps --jdk-internals -R --class-path 'libs/*' $project
libs是你的所有依赖的目录,$project是你的项目jar包
(3)我们可以在线上使用OpenJDK,开发时,使用任意的JDK。
https://zhuanlan.zhihu.com/p/87157172

库迁移

迁移库和迁移应用程序之间最大的区别在于库被许多应用程序使用。这些应用程序可能运行在不同版本的Java上,所以库通常需要在各种Java版本上工作。期望库的用户在你迁移库的同时切换到Java 9是不现实的。

库的迁移过程由以下步骤组成:

1)确保库可以作为自动模块在Java 9上运行。
2)使用Java 9编译器编译库(主要使用满足需求的最低Java版本),而不使用新的Java 9功能。
3)添加一个模块描述符,并将库转换为显式模块。
4)重构库的结构,以便增加封装性,识别API,并尽可能分割成多个模块(可选)。
5)开始使用库中的Java 9功能,同时保持向后兼容Java 9的早期版本。

库管理需要使用java8编译,模块描述需要用java9编译,且不能使用新特性,否则java8编译不过

项目模块化后,很多第三库(基于反射的),深度反射项目代码,或jdk,可能不能用,要进行特殊处理

历史解决jar包冲突问题得改造成模块化

四、模块系统实践例子

1、相同模块不同版本,新版废弃方法

结论:运行时才会报错

module_2-1.0  依赖 module_1-1.0 printInfo.print2()方法,项目依赖 module_1-2.0 printInfo.print3();
build的时候并不能识别到错误,只有运行时才会发现错误 java.lang.NoSuchMethodError: com.kxw.module1.info.PrintInfo.print2()V

2、不同模块含重复类

结论:不同模块可以存在相同类,在引入时指定模块,可以正确加载到对应类

(1)如果同时引入两个不同模块(不管是直接还是间接),编译时会检测引入的模块是否包含可读的相同路径
编译报错:错误: 未命名的模块同时从 com.kxw.module.four 和 com.kxw.module.three 读取程序包 com.kxw.module.config

(2)如果模块存在相同类,但是却没有通过export声明,仅内部使用,那么编译时将无法检测,但运行时报 (两个模块都没有export的情况,结果一样)
Error occurred during initialization of boot layer
java.lang.LayerInstantiationException: Package com.kxw.module.config in both module com.kxw.module.three and

没声明(export)的情况下,运行时怎么检测有相同的package?
报:Checks for split packages between modules defined to the built-in class loaders.
 ModuleBootstrap#checkSplitPackages 会检查所有module的package是否重复,通过String other = packageToModule.putIfAbsent(p, name);来实现

结论:只要是一个模块,且被引入,有相同包名都可以在启动时被检测出来并报错 (为什么编译时不做的呢?main方法不明确?还是要指定主模块?)

注意:类路径加载的模块都是未命名模块,且不会因为包冲突而报错 

五、classloader的变化

1、类加载器变化概览

双亲委派机制

(1)任意一个 ClassLoader 在尝试加载一个类的时候,都会先尝试调用其父类的相关方法去加载类,如果其父类不能加载该类,则交由子类去完成。 这样的好处:对于任意使用者自定义的 ClassLoader,都会先去尝试让 jvm 的 Bootstrap ClassLoader 去尝试加载(自定义的 ClassLoader 都继承了它们)。那么就能保证 jvm 的类会被优先加载,限制了使用者对 jvm 系统的影响。

(2)jdk9中的双亲委派机制仍然存在,只是在委派之前会优先寻找模块所属的加载器进行加载。

jdk9之前

Boostrap -> Extension -> Application

(1)bootstrap classloader加载rt.jar,jre/lib/endorsed (用来加载 jvm 自身需要的类,c++ 实现,用来加载 rt.jar)
(2)ext classloader加载jre/lib/ext ( 在 jdk9 中已经被移除。)
(3)application classloader加载-cp指定的类 (负责加载ClassPath路径下的类)

jdk9及之后

Application -> Platform -> Boostrap

(1)bootstrap classloader加载lib/modules 中的核心模块 (主要用来加载 java.base 中的核心系统类)
(2)ext classloader被废弃,新增platform classloader,加载lib/modules的非核心模块 (用来加载 jdk 中的非核心模块类)
(3)application classloader加载-cp,-mp指定的类 (用来加载一般的应用类)

注意

JDK9开始,AppClassLoader父类不再是 URLClassLoader;而是BuiltinClassLoader

之前对于动态加载的类,我们总是通过将这个类通过反射调用URLClassLoader加到classpath里面进行加载。这么加载在JDK11中已经无法实现,并且这样加载的类不能卸载。 对于动态加载的类,我们在OpenJDK11中只能自定义类加载器去加载,而不是通过获取APPClassLoader去加载。同时,这么做也有助于你随时能将动态加载的类卸载,因为并没有加载到APPClassLoader。

建议使用自定义的类加载器继承SecureClassLoader去加载类:java.security.SecureClassLoader

BuiltinClassLoader

BuiltinClassLoader 是 jdk9 中代替 URLClassLoader 的加载器,是 PlatformClassLoader 与 AppClassLoader 的父类。其继承了 SecureClassLoader,其核心的方法主要是 loadClassOrNull(…) 方法

如何自定义加载器

(1)java9之前

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
public class ArthasClassloader extends URLClassLoader {
public ArthasClassloader(URL[] urls) {
super(urls, ClassLoader.getSystemClassLoader().getParent());
}

@Override
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
final Class<?> loadedClass = findLoadedClass(name);
if (loadedClass != null) {
return loadedClass;
}

// 优先从parent(SystemClassLoader)里加载系统类,避免抛出ClassNotFoundException
if (name != null && (name.startsWith("sun.") || name.startsWith("java."))) {
return super.loadClass(name, resolve);
}
try {
Class<?> aClass = findClass(name);
if (resolve) {
resolveClass(aClass);
}
return aClass;
} catch (Exception e) {
// ignore
}
return super.loadClass(name, resolve);
}
}

(2)java9之后

TODO

2、扩展机制

(1)版本9之前的Java SE允许扩展机制,可以通过将JAR放置在系统属性java.ext.dirs指定的目录中来扩展运行时映像。 如果未设置此系统属性,则使用jre\lib\ext目录作为其默认值。 该机制通过扩展类加载器(这是引导类加载器的子类)和系统类加载器的父级加载了该目录中的所有JAR。 它加载所有应用程序类。 这些JAR的内容对于在此运行时映像上编译或运行的所有应用程序都可见。

(2)Java SE 9不支持扩展机制。 如果需要类似的功能,可以将这些JAR放在类路径的前面。 使用名为JAVA_HOME\lib\ext的目录或设置名为java.ext.dirs的系统属性会导致JDK 9中的错误。

(3)在JDK 9之前,扩展类加载器和应用程序类加载器是java.net.URLClassLoader类的一个实例。 在JDK 9中,平台类加载器(以前的扩展类加载器)和应用程序类加载器是内部JDK类的实例。 如果你的代码依赖于URLClassLoader类的特定方法,代码可能会在JDK 9中崩溃。

3、类加载器的加载流程

在JDK 9之前

a. JDK使用三个类加载器来加载类
b. JDK类加载器以分层方式工作 —— 引导类加载器位于层次结构的顶部。 类加载器将类加载请求委托给上层类加载器。

例如,如果应用程序类加载器需要加载一个类,它将请求委托给扩展类加载器,扩展类加载器又将请求委托给引导类加载器。 如果引导类加载器无法加载类,扩展类加载器将尝试加载它。 如果扩展类加载器无法加载类,则应用程序类加载器尝试加载它。 如果应用程序类加载器无法加载它,则抛出ClassNotFoundException异常。

c. 引导类加载器是扩展类加载器的父类。 扩展类加载器是应用程序类加载器的父类。 引导类加载器没有父类。 默认情况下,应用程序类加载器将是你创建的其他类加载器的父类。

在JDK 9之前的类加载机制

(1)【Boostrap】引导类加载器加载由Java平台组成的引导类,包括JAVA_HOME\lib\rt.jar中的类和其他几个运行时JAR。 它完全在虚拟机中实现。 可以使用-Xbootclasspath/p和-Xbootclasspath/a命令行选项来附加引导目录。 可以使用-Xbootclasspath选项指定引导类路径,该选项将替换默认的引导类路径。 在运行时,sun.boot.class.path系统属性包含引导类路径的只读值。 JDK通过null表示这个类加载器。 也就是说,你不能得到它的引用。 例如,Object类由引导类加载器加载,并且Object.class.getClassLoader()表达式将返回null。

(2)【Extension】扩展类加载器用于通过java.ext.dirs系统属性指定的目录中的位于JAR中的扩展机制加载可用的类。要获得扩展类加载器的引用,需要获取应用程序类加载器的引用,并在该引用上使用getParent()方法。

(3)【Application】应用程序类加载器从由CLASSPATH环境变量指定的应用程序类路径或命令行选项-cp或-classpath加载类。应用程序类加载器也称为系统类加载器,这是一种误称,它暗示它加载系统类。可以使用ClassLoader类的静态方法getSystemClassLoader()获取对应用程序类加载器的引用。

在JDK 9及之后

a. JDK9保持三级分层类加载器架构以实现向后兼容。但是,从模块系统加载类的方式有一些变化。
b. 在JDK 9中,应用程序类加载器【Application】可以委托给平台类加载器【Platform】以及引导类加载器【Boostrap】;平台类加载器【Platform】可以委托给引导类加载器【Boostrap】和应用程序类加载器【Application】。

(1)在JDK 9中,引导类加载器是由类库和代码在虚拟机中实现的(不再是c++实现)。 为了向后兼容,它在程序中仍然由null表示。 例如,Object.class.getClassLoader()仍然返回null。 但是,并不是所有的Java SE平台和JDK模块都由引导类加载器加载。 举几个例子,引导类加载器加载的模块是java.base,java.logging,java.prefs和java.desktop。 其他Java SE平台和JDK模块由平台类加载器和应用程序类加载器加载,这在下面介绍。 JDK 9中不再支持用于指定引导类路径,-Xbootclasspath和-Xbootclasspath/p选项以及系统属性sun.boot.class.path。-Xbootclasspath/a选项仍然受支持,其值存储在jdk.boot.class.path.append的系统属性中。

(2)JDK 9不再支持扩展机制。 但是,它将扩展类加载器保留在名为平台类加载器的新名称下。 ClassLoader类包含一个名为getPlatformClassLoader()的静态方法,该方法返回对平台类加载器的引用。 下表包含平台类加载器加载的模块列表。 平台类加载器用于另一目的。 默认情况下,由引导类加载器加载的类将被授予所有权限。 但是,几个类不需要所有权限。 这些类在JDK 9中已经被取消了特权,并且它们被平台类加载器加载以提高安全性。
下面是JDK 9中由平台加载器加载的模块列表。

(3)应用程序类加载器加载在模块路径上找到的应用程序模块和一些提供工具或导出工具API的JDK模块,如下表所示。 仍然可以使用ClassLoader类的getSystemClassLoader()的静态方法来获取应用程序类加载器的引用。

在JDK 9及之后的的类加载机制

三个内置的类加载器一起协作来加载类。

(1)当应用程序类加载器需要加载类时,它将搜索定义到所有类加载器的模块。 如果有合适的模块定义在这些类加载器中,则该类加载器将加载类,这意味着应用程序类加载器现在可以委托给引导类加载器和平台类加载器。 如果在为这些类加载器定义的命名模块中找不到类,则应用程序类加载器将委托给其父类,即平台类加载器。 如果类尚未加载,则应用程序类加载器将搜索类路径。 如果它在类路径中找到类,它将作为其未命名模块的成员加载该类。 如果在类路径中找不到类,则抛出ClassNotFoundException异常。

(2)当平台类加载器需要加载类时,它将搜索定义到所有类加载器的模块。 如果一个合适的模块被定义为这些类加载器中,则该类加载器加载该类。 这意味着平台类加载器可以委托给引导类加载器以及应用程序类加载器。 如果在为这些类加载器定义的命名模块中找不到一个类,那么平台类加载器将委托给它的父类,即引导类加载器。

(3)当引导类加载器需要加载一个类时,它会搜索自己的命名模块列表。 如果找不到类,它将通过命令行选项-Xbootclasspath/a指定的文件和目录列表进行搜索。 如果它在引导类路径上找到一个类,它将作为其未命名模块的成员加载该类。

JDK 9包含一个名为-Xlog::modules的选项,用于在虚拟机加载时记录调试或跟踪消息,可以看到类加载器及其加载的模块和类。其格式如下:

-Xlog:modules=<debug|trace>
java -Xlog:modules=trace --module-path lib --module com.jdojo.prime.client/com.jdojo.prime.client.Main > test.txt

JDK 9各类加载器对应加载的模块

(1)bootstrap classloader加载lib/modules

1
2
3
4
5
6
7
8
9
10
java.base java.security.sasl
java.datatransfer java.xml
java.desktop jdk.httpserver
java.instrument jdk.internal.vm.ci
java.logging jdk.management
java.management jdk.management.agent
java.management.rmi jdk.naming.rmi
java.naming jdk.net
java.prefs jdk.sctp
java.rmi jdk.unsupported

(2)platform classloader,加载lib/modules

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java.activation* jdk.accessibility
java.compiler* jdk.charsets
java.corba* jdk.crypto.cryptoki
java.scripting jdk.crypto.ec
java.se jdk.dynalink
java.se.ee jdk.incubator.httpclient
java.security.jgss jdk.internal.vm.compiler*
java.smartcardio jdk.jsobject
java.sql jdk.localedata
java.sql.rowset jdk.naming.dns
java.transaction* jdk.scripting.nashorn
java.xml.bind* jdk.security.auth
java.xml.crypto jdk.security.jgss
java.xml.ws* jdk.xml.dom
java.xml.ws.annotation* jdk.zipfs

(3)application classloader加载-cp,-mp指定的类

1
2
3
4
5
6
7
8
9
10
11
12
13
jdk.aot jdk.jdeps
jdk.attach jdk.jdi
jdk.compiler jdk.jdwp.agent
jdk.editpad jdk.jlink
jdk.hotspot.agent jdk.jshell
jdk.internal.ed jdk.jstatd
jdk.internal.jvmstat jdk.pack
jdk.internal.le jdk.policytool
jdk.internal.opt jdk.rmic
jdk.jartool jdk.scripting.nashorn.shell
jdk.javadoc jdk.xml.bind*
jdk.jcmd jdk.xml.ws*
jdk.jconsole

4、其他

(1)如果你想访问classpath下的内容,你可以读取环境变量:

1
2
3
4
5
String pathSeparator = System
.getProperty("path.separator");
String[] classPathEntries = System
.getProperty("java.class.path")
.split(pathSeparator);

(2)设置虚拟机参数为”-XX:+TraceClassLoading”来获取类加载信息
(3)如何判断类是有未命名模块加载,还是正常模块加载的 :clazz.getModule()

六、垃圾回收器

JDK 11 默认使用G1

G1会比CMS有更多的内存消耗,多了两个内存结构:

Remembered Sets:每个区块Region都有一个与之对应的Remembered Set来记录不同区块间对象引用(其他收集器只是记录新生代、老年代间互相引用)。用来避免全堆扫描
Collection Sets:将要被回收区块对象的集合,GC时收集器将这些区块对象复制到其他区块中

ZGC

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

停顿时间不超过10ms;
停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
支持8MB~4TB级别的堆(未来支持16TB)。

从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。

对吞吐量优先的场景,ZGC可能并不适合。
ZGC作为下一代垃圾回收器,性能非常优秀。ZGC垃圾回收过程几乎全部是并发,实际STW停顿时间极短,不到10ms。这得益于其采用的着色指针和读屏障技术。

后续考虑在生产环境使用ZGC。

七、新特性

1、HttpClient API

1
2
3
4
5
6
7
8
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://openjdk.java.net/"))
.build();
client.sendAsync(request, BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println)
.join();

2、使用Stream dropWhile 简化代码

获取列表从lastId开始到结尾的子集

java8

1
2
3
4
5
6
7
8
9
10
11
int index = 0;
for (UserDynamicSortSetMemberWithScoreBO bo : sortSetKeyList) {
if (bo.getId() == lastId) {
break;
}
index++;
}
return sortSetKeyList.stream()
.skip(index + 1)
.limit(DynamicConfig.feedListPageSize())
.collect(Collectors.toList());

java9

1
2
3
4
5
return sortSetKeyList.stream()
.dropWhile(v -> !v.getId().equals(lastId))
.skip(1)
.limit(DynamicConfig.feedListPageSize())
.collect(Collectors.toList());

3、Optional.ifPresentOrElse

1
2
optional.ifPresentOrElse( x -> System.out.println("Value: " + x),() ->
System.out.println("Not Present."));

八、迁移实践

目标1:可运行

由于项目历史沉重(代码不兼容或依赖未模块化),先使用旧方式运行(类路径而不是模块系统)

1、目标:用最简单快速的方式使用java11编译和运行代码
2、收益:使用属于Java 9-11 新的API、工具和性能改进。
3、未使用Java 9的旗舰功能——模块系统,仍使用类路径方式运行服务

目标2:使用模块系统运行

模块化改造,在现有的项目中如何发挥模块化的作用

自动模块会exports所有包,导致分包问题,不同模块包含相同包

解决的方法:
(1)改代码,去除这种不规范(难,第三方包很多这种)
(2)把这种包放在未命名模块

由于完全模块化分包的问题无法解决,所以要混合两种方式运行项目

gradle对混合两种(类路径和模块系统)的支持不足
直接命令行使用混合两种运行是可以的:

1
./jdk-11.0.10.jdk/Contents/Home/bin/java --add-opens=java.base/jdk.internal.loader=ALL-UNNAMED -cp "build/deploy/*" -p "build/module/*" --add-modules "com.xx.xx.xx" com.xx.xx.xx.Application

目标3:使用java17

1、Gradle 7.3 is the first version fully supporting Java 17

2、Spring Boot 2.5.5是Spring Boot 第一个支持Java 17的版本

其他

  • 新版本jdk现在基本都是规定时间内免费,比如半年或三年,一般情况下直接用openjdk就足够了。
  • OpenJDK和Oracle JDK有什么区别和联系
  • openjdk与Oraclejdk的区别
    • 在2006年11月13日的JavaOne大会上,Sun公司(当时还没被收购)宣布计划要把Java开源,在随后的一年多时间内,它陆续地将JDK的各个部分在GPL v2(GNU General Public License v2)协议下公开了源码,并建立了OpenJDK组织对这些源码进行独立管理。除了极少量的产权代码(Encumbered Code,这部分代码所有权不属于Sun公司,Sun本身也无权进行开源处理)外,OpenJDK几乎拥有了当时SunJDK 的全部代码。
    • 但是随着JDK版本的不断发布,Oracle失去了维护OpenJDK的耐心,因为不赚钱啊。RedHat从Oracle手上接过OpenJDK的管理权利和维护职责。
  • https://www.zhihu.com/question/19646618
  • https://www.zhihu.com/question/353325963
    • OpenJDK 实际上不适合拿来和 OracleJDK 进行对比,OpenJDK 不提供 LTS 服务,而 OracleJDK 每三年都会推出一个 LTS 版进行长期支持。
  • 从 11+ 版本开始 -XX:+UseContainerSupport 已经自动开启, 可以自适应内存pod的内存限制,不用通过-Xms -Xmx 来设置

Reference

  • [Java 9模块化开发_核心原则与实践]
  • 模块化加载_Java9模块化的类加载机制实现剖析
  • 【JDK 11】关于 Java 模块系统,看这一篇就够了
  • Java 9 揭秘(8. JDK 9重大改变)
  • 从JDK8升级到JDK11,看这篇就足够了
  • Load class from exploded module using custom classloader
  • Class Loaders in Java
  • How to load JAR files dynamically at Runtime?
  • Is it possible to load and unload jdk and custom modules dynamically in Java 9?
  • 聊聊java9的classloader
  • JVM_类加载机制详解
  • 新一代垃圾回收器ZGC的探索与实践

NIO总结笔记

发表于 2022-01-17

Java I/O

I/O NIO
面向流 面向缓冲
阻塞IO 非阻塞IO
无 选择器
  • I/O 与 NIO 一个比较重要的区别是我们使用 I/O 的时候往往会引入多线程,每个连接使用一个单独的线程,而 NIO 则是使用单线程或者只使用少量的多线程,每个连接共用一个线程。而由于 NIO 的非阻塞需要一直轮询,比较消耗系统资源,所以异步非阻塞模式 AIO 就诞生了。

5 种 I/O模型

  1. blocking I/O
  2. nonblocking I/O
  3. I/O multiplexing (select and poll)
  4. signal driven I/O (SIGIO)
  5. asynchronous I/O (the POSIX aio_functions)。
  • 不同的操作系统对上述模型支持不同,UNIX 支持 IO 多路复用。不同系统叫法不同,freebsd 里面叫 kqueue,Linux 叫 epoll。而 Windows2000 的时候就诞生了 IOCP 用以支持 asynchronous I/O。
  • Java 是一种跨平台语言,为了支持异步 I/O,诞生了 NIO,Java1.4 引入的 NIO1.0 是基于 I/O 复用的,它在各个平台上会选择不同的复用方式。Linux 用的 epoll,BSD 上用 kqueue,Windows 上是重叠 I/O。
  • IO多路复用,Java NIO的核心类库多路复用器Selector就是基于epoll的多路复用技术实现。
  • IO复用的系统调用方式:select,pselect,poll,epoll(IO复用属于同步IO)
  • 同步阻塞BIO,同步非阻塞NIO,异步非阻塞AIO
    同步IO和异步IO的区别就在于:数据拷贝的时候进程是否阻塞!
    阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回!

Java I/O 的相关方法

  1. 同步并阻塞 (I/O 方法):服务器实现模式为一个连接启动一个线程,每个线程亲自处理 I/O 并且一直等待 I/O 直到完成,即客户端有连接请求时服务器端就需要启动一个线程进行处理。但是如果这个连接不做任何事情就会造成不必要的线程开销,当然可以通过线程池机制改善这个缺点。I/O 的局限是它是面向流的、阻塞式的、串行的一个过程。对每一个客户端的 Socket 连接 I/O 都需要一个线程来处理,而且在此期间,这个线程一直被占用,直到 Socket 关闭。在这期间,TCP 的连接、数据的读取、数据的返回都是被阻塞的。也就是说这期间大量浪费了 CPU 的时间片和线程占用的内存资源。此外,每建立一个 Socket 连接时,同时创建一个新线程对该 Socket 进行单独通信 (采用阻塞的方式通信)。这种方式具有很快的响应速度,并且控制起来也很简单。在连接数较少的时候非常有效,但是如果对每一个连接都产生一个线程无疑是对系统资源的一种浪费,如果连接数较多将会出现资源不足的情况;
  2. 同步非阻塞 (NIO 方法):服务器实现模式为一个请求启动一个线程,每个线程亲自处理 I/O,但是另外的线程轮询检查是否 I/O 准备完毕,不必等待 I/O 完成,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有 I/O 请求时才启动一个线程进行处理。NIO 则是面向缓冲区,非阻塞式的,基于选择器的,用一个线程来轮询监控多个数据传输通道,哪个通道准备好了 (即有一组可以处理的数据) 就处理哪个通道。服务器端保存一个 Socket 连接列表,然后对这个列表进行轮询,如果发现某个 Socket 端口上有数据可读时,则调用该 Socket 连接的相应读操作;如果发现某个 Socket 端口上有数据可写时,则调用该 Socket 连接的相应写操作;如果某个端口的 Socket 连接已经中断,则调用相应的析构方法关闭该端口。这样能充分利用服务器资源,效率得到大幅度提高;
  3. 异步非阻塞 (AIO 方法,JDK7 发布):服务器实现模式为一个有效请求启动一个线程,客户端的 I/O 请求都是由操作系统先完成了再通知服务器应用去启动线程进行处理,每个线程不必亲自处理 I/O,而是委派操作系统来处理,并且也不需要等待 I/O 完成,如果完成了操作系统会另行通知的。该模式采用了 Linux 的 epoll 模型。
  • 在连接数不多的情况下,传统 I/O 模式编写较为容易,使用上也较为简单。但是随着连接数的不断增多,传统 I/O 处理每个连接都需要消耗一个线程,而程序的效率,当线程数不多时是随着线程数的增加而增加,但是到一定的数量之后,是随着线程数的增加而减少的。所以传统阻塞式 I/O 的瓶颈在于不能处理过多的连接。非阻塞式 I/O 出现的目的就是为了解决这个瓶颈。非阻塞 IO 处理连接的线程数和连接数没有联系,例如系统处理 10000 个连接,非阻塞 I/O 不需要启动 10000 个线程,你可以用 1000 个,也可以用 2000 个线程来处理。因为非阻塞 IO 处理连接是异步的,当某个连接发送请求到服务器,服务器把这个连接请求当作一个请求“事件”,并把这个“事件”分配给相应的函数处理。我们可以把这个处理函数放到线程中去执行,执行完就把线程归还,这样一个线程就可以异步的处理多个事件。而阻塞式 I/O 的线程的大部分时间都被浪费在等待请求上了。

AIO 相关的类和接口

  • java.nio.channels.AsynchronousChannel:标记一个 Channel 支持异步 IO 操作;
  • java.nio.channels.AsynchronousServerSocketChannel:ServerSocket 的 AIO 版本,创建 TCP 服务端,绑定地址,监听端口等;
  • java.nio.channels.AsynchronousSocketChannel:面向流的异步 Socket Channel,表示一个连接;
  • java.nio.channels.AsynchronousChannelGroup:异步 Channel 的分组管理,目的是为了资源共享。一个 AsynchronousChannelGroup 绑定一个线程池,这个线程池执行两个任务:处理 IO 事件和派发 CompletionHandler。AsynchronousServerSocketChannel 创建的时候可以传入一个 AsynchronousChannelGroup,那么通过 AsynchronousServerSocketChannel 创建的 AsynchronousSocketChannel 将同属于一个组,共享资源;
  • java.nio.channels.CompletionHandler:异步 IO 操作结果的回调接口,用于定义在 IO 操作完成后所作的回调工作。AIO 的 API 允许两种方式来处理异步操作的结果:返回的 Future 模式或者注册 CompletionHandler,推荐用 CompletionHandler 的方式,这些 handler 的调用是由 AsynchronousChannelGroup 的线程池派发的。这里线程池的大小是性能的关键因素。

Reactor线程模型

常用的Reactor线程模型有三种,分别如下:

  1. Reactor单线程模型;
  2. Reactor多线程模型;
  3. 主从Reactor多线程模型
    • Netty的线程模型并非固定不变,通过在启动辅助类中创建不同的EventLoopGroup实例并通过适当的参数配置,就可以支持上述三种Reactor线程模型。

Netty线程模型

  • http://www.infoq.com/cn/articles/netty-threading-model
  • 主从Reactor线程模型
  • Netty线程开发最佳实践
    • 2.4.1. 时间可控的简单业务直接在IO线程上处理
      如果业务非常简单,执行时间非常短,不需要与外部网元交互、访问数据库和磁盘,不需要等待其它资源,则建议直接在业务ChannelHandler中执行,不需要再启业务的线程或者线程池。避免线程上下文切换,也不存在线程并发问题。
    • 2.4.2. 复杂和时间不可控业务建议投递到后端业务线程池统一处理
      对于此类业务,不建议直接在业务ChannelHandler中启动线程或者线程池处理,建议将不同的业务统一封装成Task,统一投递到后端的业务线程池中进行处理。
      过多的业务ChannelHandler会带来开发效率和可维护性问题,不要把Netty当作业务容器,对于大多数复杂的业务产品,仍然需要集成或者开发自己的业务容器,做好和Netty的架构分层。
    • 2.4.3. 业务线程避免直接操作ChannelHandler
      对于ChannelHandler,IO线程和业务线程都可能会操作,因为业务通常是多线程模型,这样就会存在多线程操作ChannelHandler。为了尽量避免多线程并发问题,建议按照Netty自身的做法,通过将操作封装成独立的Task由NioEventLoop统一执行,而不是业务线程直接操作
      ctx.executor().execute(new Runnable(){})
      如果你确认并发访问的数据或者并发操作是安全的,则无需多此一举,这个需要根据具体的业务场景进行判断,灵活处理。

Netty的“零拷贝”

  • Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。
  • Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer。
  • Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。

Netty - 灵活的TCP参数配置能力

  • SO_RCVBUF和SO_SNDBUF:通常建议值为128K或者256K;
  • SO_TCPNODELAY:NAGLE算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提高网络应用效率。但是对于时延敏感的应用场景需要关闭该优化算法;
  • 软中断:如果Linux内核版本支持RPS(2.6.35以上版本),开启RPS后可以实现软中断,提升网络吞吐量。RPS根据数据包的源地址,目的地址以及目的和源端口,计算出一个hash值,然后根据这个hash值来选择软中断运行的cpu,从上层来看,也就是说将每个连接和cpu绑定,并通过这个hash值,来均衡软中断在多个cpu上,提升网络并行处理性能。

心跳实现

  • 使用TCP协议层的Keeplive机制,但是该机制默认的心跳时间是2小时,依赖操作系统实现不够灵活
  • 应用层实现自定义心跳机制,比如Netty实现心跳机制
    • 服务端添加IdleStateHandler心跳检测处理器,并添加自定义处理Handler类实现userEventTriggered()方法作为超时事件的逻辑处理

Netty中比较常用的帧解码器

  1. 固定长度帧解码器 - FixedLengthFrameDecoder
    • 适用场景:每个上层数据包的长度,都是固定的,比如 100。在这种场景下,只需要把这个解码器加到 pipeline 中,Netty 会把底层帧,拆分成一个个长度为 100 的数据包 (ByteBuf),发送到下一个 channelHandler入站处理器。
  2. 行分割帧解码器 - LineBasedFrameDecoder
    • 适用场景:每个上层数据包,使用换行符或者回车换行符做为边界分割符。发送端发送的时候,每个数据包之间以换行符/回车换行符作为分隔。在这种场景下,只需要把这个解码器加到 pipeline 中,Netty 会使用换行分隔符,把底层帧分割成一个一个完整的应用层数据包,发送到下一站。前面的例子,已经对这个解码器进行了演示。
  3. 自定义分隔符帧解码器 - DelimiterBasedFrameDecoder
    • DelimiterBasedFrameDecoder 是LineBasedFrameDecoder的通用版本。不同之处在于,这个解码器,可以自定义分隔符,而不是局限于换行符。如果使用这个解码器,在发送的时候,末尾必须带上对应的分隔符。
  4. 自定义长度帧解码器 - LengthFieldBasedFrameDecoder
    • 这是一种基于灵活长度的解码器。在数据包中,加了一个长度字段(长度域),保存上层包的长度。解码的时候,会按照这个长度,进行上层ByteBuf应用包的提取。
    • LengthFieldPrepender(编码):如果协议中的第一个字段为长度字段,netty提供了LengthFieldPrepender编码器,它可以计算当前待发送消息的二进制字节长度,将该长度添加到ByteBuf的缓冲区头中

Netty API

JDK ByteBuffer VS Netty ByteBuf

  • https://blog.csdn.net/u013828625/article/details/79845512
  • Netty中的ByteBuf则完全对JDK中的ByteBuffer的缺点进行了改进
  • 网络数据的基本单位永远是 byte(字节)。Java NIO 提供 ByteBuffer 作为字节的容器,但该类过于复杂,有点难用。ByteBuf是Netty当中的最重要的工具类,它与JDK的ByteBuffer原理基本上相同,也分为堆内与堆外俩种类型,但是ByteBuf做了极大的优化,具有更简单的API,更多的工具方法和优秀的内存池设计。
  • ByteBuf 维护俩不同索引:一个用于读取,一个用于写入:从 ByteBuf 读取时,其 readerIndex 将会被递增已经被读取的字节数;当写入 ByteBuf 时,writerIndex 也会被递增

SimpleChannelInboundHandler

  • SimpleChannelInboundHandler与ChannelInboundHandlerAdapter
  • Netty随记之ChannelInboundHandlerAdapter、SimpleChannelInboundHandler
  • Netty——高级发送和接收数据handler处理器
  • 每一个Handler都一定会处理出站或者入站(也可能两者都处理)数据,例如对于入站的Handler可能会继承SimpleChannelInboundHandler或者ChannelInboundHandlerAdapter,而SimpleChannelInboundHandler又是继承于ChannelInboundHandlerAdapter,最大的区别在于SimpleChannelInboundHandler会对没有外界引用的资源进行一定的清理,并且入站的消息可以通过泛型来规定。
  • 对于两者关系:
    public abstract class SimpleChannelInboundHandler<I> extends ChannelInboundHandlerAdapter
  • public void channelRead(ChannelHandlerContext ctx, Object msg) , msg 是ByteBuf类型(未decode转化类型的情况),使用 SimpleChannelInboundHandler 会被自动释放
  1. ChannelInboundHandlerAdapter
    • ChannelInboundHandlerAdapter是ChannelInboundHandler的一个简单实现,默认情况下不会做任何处理,只是简单的将操作通过fire*方法传递到ChannelPipeline中的下一个ChannelHandler中让链中的下一个ChannelHandler去处理。
    • 需要注意的是信息经过channelRead方法处理之后不会自动释放(因为信息不会被自动释放所以能将消息传递给下一个ChannelHandler处理)。
  2. SimpleChannelInboundHandler
    • SimpleChannelInboundHandler支持泛型的消息处理,默认情况下消息处理完将会被自动释放,无法提供fire*方法传递给ChannelPipeline中的下一个ChannelHandler,如果想要传递给下一个ChannelHandler需要调用ReferenceCountUtil#retain方法。
    • channelRead0方法在将来将会重命名为messageReceived

channelRead()和channelReadComplete()

  • channelRead()和channelReadComplete() 方法的区别是什么?
  • channelRead表示接收消息,可以看到msg转换成了ByteBuf,然后打印,也就是把Client传过来的消息打印了一下,你会发现每次打印完后,channelReadComplete也会调用,如果你试着传一个超长的字符串过来,超过1024个字母长度,你会发现channelRead会调用多次,而channelReadComplete只调用一次。

ctx.write() vd ctx.channel().write()

  • Any difference between ctx.write() and ctx.channel().write() in netty?:https://stackoverflow.com/questions/20366418/any-difference-between-ctx-write-and-ctx-channel-write-in-netty
  • Yes there is… Channel.write(..) always start from the tail of the ChannelPipeline and so pass through all the ChannelOutboundHandlers. ChannelHandlerContext.write(…) starts from the current position of the ChannelHandler which is bound to the ChannelHandlerContext and so only pass those ChannelOutboundHandlers that are in front of it.

自定义消息协议

  • len : 表示消息的长度,通常用4个字节保存

  • head : 消息头部

  • body : 消息内容

  • 在实际的项目中,消息格式可能会增加一些标志,例如,开始标记,结束标志,消息序列号,消息的协议类型(json或者二进制等)

Netty 没用JDK1.7的AIO

  • 为什么Netty不用AIO而用NIO?
According to the book the main reasons were:
1. Not faster than NIO (epoll) on unix systems (which is true)
There is no daragram suppport
2. Unnecessary threading model (too much abstraction without usage)
3. I agree that AIO will not easily replace NIO, but it is useful for windows developers nonetheless.

We obviously did not consider Windows as a serious platform so far, and that's why we were neglecting NIO.2 AIO API which was implemented using IOCP on Windows. (On Linux, it wasn't any faster because it was using the same OS facility - epoll.)

Netty-Epoll

  • Selector 实现原理:http://www.jianshu.com/p/2b71ea919d49
  • epoll的两种工作模式:
    • LT:level-trigger,水平触发模式,只要某个socket处于readable/writable状态,无论什么时候进行epoll_wait都会返回该socket。
      当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
    • ET:edge-trigger,边缘触发模式,只有某个socket从unreadable变为readable或从unwritable变为writable时,epoll_wait才会返回该socket。
      当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
      ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
  • 在Linux系统中JDK NIO使用的是 LT ,而Netty epoll使用的是 ET。

其他

  • JDK中有自带的ByteBuffer类,但是netty中的 ByteBuf 算是对Byte Buffer的重新实现。他们没有关联关系。
  • netty推荐使用io.netty.buffer.Unpooled来进行Buff的创建工作。Unpooled是一个工具类,可以为ByteBuf分配空间、拷贝或者封装操作
  • DirectBuffer:使用 DirectBuffer 是一种更加接近系统底层的方法,所以,它的速度比普通的 ByteBuffer 更快。DirectBuffer 相对于 ByteBuffer 而言,读写访问速度快很多,但是创建和销毁 DirectBuffer 的花费却比 ByteBuffer 高。
  • Netty的并发处理能力主要体现在两个方面:
    1. 利用Java语言自身的多线程机制实现消息的并行处理;
    2. 利用Java NIO类库的Selector实现多路复用,一个NIO线程可以同时并发处理成百上千个通信链路,实现海量客户端的并发接入和处理。
  • netty.pipeline执行顺序:在给定的示例配置中,当事件进入到入站时,处理程序计算顺序为(代码行:从上到下)。当一个事件出站时,顺序是(代码行:从下到上)。

Reference

  • Netty系列之Netty高性能之道
  • Netty实战-自定义解码器处理半包消息
  • TODO nio-demo/docs
  • TODO nio_demo/netty-definitive-guide/netty-definitive-guide-notes.md

如何做数据库迁移

发表于 2022-01-14

先抛出以下问题:
把MHA-1集群的Database-C 迁到 MHA-2 集群中,有没有简单高效的方案?


  • 方案1、在两个MHA集群,在建一套MHA3。MHA2的主作为MHA1的从?这样MH3切换的时候,应用服务就可以直接感知切到新库了;
  • 方案2、先把C同步到2集群,然后做otter同步

  • 方案1: 存在的问题:
    • 中间再搞一套MHA,不是运维麻烦,而是多了一套中间库,可能会导致MHA的切换逻辑紊乱。
  • 方案2:
    • 业务方的服务要自行选择数据源进行切换 (流量低且数据一致性要求不高,考虑通过开关切换;否则需考虑停服等其他方案)
    • otter双向同步,业务最好同时只写一边(要注意旧库是否有自增主键;同步冲突策略设置,默认会冲突会同步中断)

raft学习笔记

发表于 2021-10-27
  • Raft将共识问题分解三个子问题:

    1. Leader election 领导选举:有且仅有一个leader节点,如果leader宕机,通过选举机制选出新的leader;
    2. Log replication 日志复制:leader从客户端接收数据更新/删除请求,然后日志复制到follower节点,从而保证集群数据的一致性;
    3. Safety 安全性:通过安全性原则来处理一些特殊case,保证Raft算法的完备性;
  • 所以,Raft算法核心流程可以归纳为:

    • 首先选出leader,leader节点负责接收外部的数据更新/删除请求;
    • 然后日志复制到其他follower节点,同时通过安全性的准则来保证整个日志复制的一致性;
    • 如果遇到leader故障,followers会重新发起选举出新的leader;
  • Raft规定:只有拥有最新提交日志的follower节点才有资格成为leader节点。具体做法:candidate竞选投票时会携带最新提交日志,follower会用自己的日志和candidate做比较。

  • 因为日志提交需要超过半数的节点同意,所以针对日志同步落后的follower(还未同步完全部日志,导致落后于其他节点)在竞选leader的时候,肯定拿不到超过半数的票,也只有那些完成同步的才有可能获取超过半数的票成为leader。

  • 日志更新判断方式是比较日志项的term和index:

    • 如果TermId不同,选择TermId最大的;
    • 如果TermId相同,选择Index最大的;
  • Raft对日志提交有额外安全机制:leader只能提交当前任期Term的日志,旧任期Term(以前的数据)只能通过当前任期Term的数据提交来间接完成提交。简单的说,日志提交有两个条件需要满足:

    • 当前任期;
    • 复制结点超过半数;
  • Raft在日志项提交上增加了限制:只有当前任期且复制超过半数的日志才可以提交。

Reference

  • 分布式一致性算法Raft
  • Raft 一致性算法论文中文译文

CQRS笔记

发表于 2021-10-12
  • 后端开发实践系列——简单可用的CQRS编码实践

个人认为:

  1. 清除消息业务:写时只记录一个”清除时间”,读时只读取”清除时间”之后的数据,这是一种CQRS (避免写时操作太多数据,QPS不高,但单个请求需要操作多条数据的情况)
  2. 消息通知业务:写时只写在redis,定时任务从redis批量获取数据逐步写入的数据库(QPS高,但单个请求却不多,可以批量操作的情况)

分布式理论相关整理

发表于 2021-10-12

其他

  • 分布式存储系统通常通过维护多个副本来进行容错,提高系统的可用性。要实现此目标,就必须要解决分布式存储系统的最核心问题:维护多个副本的一致性。
  • 一致性(consensus),它是构建具有容错性(fault-tolerant)的分布式系统的基础。 在一个具有一致性的性质的集群里面,同一时刻所有的结点对存储在其中的某个值都有相同的结果,即对其共享的存储保持一致。集群具有自动恢复的性质,当少数结点失效的时候不影响集群的正常工作,当大多数集群中的结点失效的时候,集群则会停止服务(不会返回一个错误的结果)。
  • 一致性协议就是用来保证即使在部分(确切地说是小部分)副本宕机的情况下,系统仍然能正常对外提供服务。一致性协议通常基于replicated state machines,即所有结点都从同一个state出发,都经过同样的一些操作序列(log),最后到达同样的state。

分布式架构

  • [分布式架构的套路](<https://mp.weixin.qq.com/s/vJJWpIZ-bTzVl9E3wPLlEw)
    • 1、纯负载均衡形式。
      硬件层面的 F5、软件层面的 nginx
    • 2、领导选举型
      整个集群的消息都会转发到集群的领导这里,是一种 master-slavers,区别只是这个 master 是被临时选举出来的,一旦 master 宕机,集群会立刻选举出一个新的领导,继续对外提供服务。
      ElasticSearch,zookeeper、Raft
    • 3、区块链型
      整个集群的每一个节点都可以进行记录,但是记录的内容要得到整个集群 N 个机器的认可才是合法的。典型的应用有 Bit Coin,以及 Hyperledger。
    • 4、master-slaver型
      整个集群以某台 master 为中枢,进行集群的调度。交互是这样,一般会把所有的管理类型的数据放到 master 上,而把具体的数据放到 slaver 上,实际进行调用的时候,client 先调用 master 获取数据所存放的 server 的 信息,再自行跟 slave 进行交互。典型的系统有 Hadoop。集群,HBase 集群,Redis 集群等。
    • 5、规则型一致性Hash
      这种架构类型一般出现在数据库分库分表的设计中。按照规则进行分库分表,在查询之前使用规则引擎进行库和表的确认,再对具体的应用进行访问。为什么要用一致性 Hash ?其实用什么都可以,只是对于这类应用来说一致性 Hash 比较常见而已。

副本一致性

  1. 强一致性(strong consistency)
  2. 单调一致性(monotonic consistency):任何时刻,任何用户一旦读到某个数据在某次更新后的值, 这个用户不会再读到比这个值更旧的值。
  3. 会话一致性(session consistency):在同一个会话内,系统保证读己所写的一致性。
  4. 最终一致性(eventual consistency):如果没有更新,最终系统会返回最后更新的值。换句话说,如果系统在持续更新,则永远无法达到一致性。
  5. 弱一致性(week consistency):系统并不保证后续读操作获得更新值的时间点;弱一致性系统 一般很难在实际中使用,使用弱一致性系统需要应用方做更多的工作从而使得系统可用。
  6. 因果一致性:和写进程具有因果关系的进程将会读取到更新的数据,写进程保证取代上次更更新。
  7. 读己所写一致性:进程永远读取自己上次更新写入的最新值,而不可能读取到任何历史数据。这是传统操作系统默认的一致性行为。

分布式系统中的一致性模型

  • 分布式系统中的一致性模型
  • 一致性模型是所有被允许的操作记录的集合。当我们运行一个程序,经过一系列集合中允许的操作,特定的执行结果总是一致的。如果程序意外地执行了非集合中的操作,我们就称执行记录是非一致的。如果任意可能的执行操作都在这个被允许的操作集合内,那么系统就满足一致性模型。
  • 现实往往没有那么理想化:在几乎每个实际的系统中,进程之间都有一定的距离。一个没有被缓存的值(指没有被CPU的local cache缓存),通常在距离CPU30厘米的DIMM内存条上。光需要整整一个纳秒来传播这么长的距离,实际的内存访问会比光速慢得多。位于不同数据中心某台计算机上的值可以相距几千公里——意味着需要几百毫秒的传播时间。我们没有更快传播数据的方法,否则就违反了物理定律。(物理定律都违反了,就更别谈什么现代计算机体系了。)
  • 这意味着我们的操作不再是瞬时的。某些操作也许快到可以被近乎认为是瞬时的,但是通常来说,操作是耗时的。我们调用对一个变量的写操作;写操作传播到内存,或其他计算机,或月球;内存改变状态;一个确认信息回传;这样我们才知道这个操作真实的发生了。
  • 在分布式系统中,操作的耗时被放大了,我们必须使一致性模型更宽松:允许这些有歧义的顺序发生。
  • 我们该如何确定宽松的程度?我们必须允许所有可能的顺序吗?或许我们还是应该强加一些合理性约束?
  1. 线性一致性(Linearizability)
    • 线性一致性模型提供了这样的保证:1.对于观察者来说,所有的读和写都在一个单调递增的时间线上串行地向前推进。2.所有的读总能返回最近的写操作的值。
  2. 顺序一致性(Sequential consistency)
    • 如果我们允许进程在时间维度发生偏移,从而它们的操作可能会在调用之前或是完成之后生效,但仍然保证一个约束——任意进程中的操作必须按照进程中定义的顺序(即编程的定义的逻辑顺序)发生。这样我们就得到了一个稍弱的一致性模型:顺序一致性。
    • 顺序一致性放松了对一致性的要求:1. 不要求操作按照真实的时间序发生。2. 不同进程间的操作执行先后顺序也没有强制要求,但必须是原子的。3. 单个进程内的操作顺序必须和编码时的顺序一致。
    • 如果我在Twitter上写了一条推文,或是在Facebook发布了一篇帖子,都会耗费一定的时间渗透进一层层的缓存系统。不同的用户将在不同的时间看到我的信息,但每个用户都以同一个顺序看到我的操作。一旦看到,这篇帖子便不会消失。如果我写了多条评论,其他人也会按顺序的看见,而非乱序。
  3. 因果一致性(Casual consistency)
    • 我们不必对一个进程中的每个操作都施加顺序约束。只有因果相关的操作必须按顺序发生。同样拿帖子举例子:一篇帖子下的所有评论必须以同样的顺序展示给所有人,并且只有帖子可见后,帖子下的回复才可见(也就是说帖子和帖子下的评论有因果关系)。如果我们将这些因果关系编码成类似“我依赖于操作X”的形式,作为每个操作明确的一部分,数据库就可以将这些操作延迟直到它们的依赖都就绪后才可见。
    • 因果一致性比同一进程下对每个操作严格排序的一致性(即顺序一致性)来的更宽松——属于同一进程但不同因果关系链的操作能以相对的顺序执行(也就是说按因果关系隔离,无因果关系的操作可以并发执行),这能防止许多不直观的行为发生。
  4. 串行一致性(Serializable consistency)
    • 如果我们说操作记录的发生等效于某些单一的原子序,但和调用时间与完成时间无关,那么我们就得到了名为串行一致性的一致性模型。这一模型比你想象的更强大同时也更脆弱。
    • 因为串行一致性允许对操作顺序执行任意的重排(只要操作顺序是原子序的), 它在实际的场景中并不是十分有用。大多数宣称提供了串行一致性的数据库实际上提供的是强串行一致性,它有着和线性一致性一样的时间边界。让事情更复杂的是,大多数SQL数据库宣称的串行一致性等级比实际的更弱,比如可重复读,游标稳定性,或是快照隔离性。
    • 关于线性一致性和串行一致性,看似十分相似,其实不然。串行一致性是数据库领域的概念,是针对事务而言的,描述对一组事务的执行效果等同于某种串行的执行,没有ordering的概念,而线性一致性来自并行计算领域,描述了针对某种数据结构的操作所表现出的顺序特征。串行一致性是对多操作,多对象的保证,对总体的操作顺序无要求;线性一致性是对单操作,单对象的保证,所有操作遵循真实时间序
  5. FIFO 一致性(FIFO consistency, 又称 PRAM consistency, pipelined RAM consistency)。
    • FIFO 一致性不会考虑多个进程之间的操作排序。对任意一个进程的写操作 1 与写操作 2,若写操作 1 先于写操作 2 完成,那么任何进程不可以先读到写操作 2 的值,再读到写操作 1 的值。
  • 强一致(strict consistency),通常是指线性一致性或顺序一致性。线性一致性与顺序一致性之间的区别,也可以被理解为系统模型的区别,即系统中是否存在绝对时间。弱于顺序一致性的一致性级别都可被称为弱一致,而最终一致性是弱一致性的一种形式。

衡量分布式系统的指标

  • 性能
  • 可用性
  • 可扩展性
  • 一致性

基本副本协议

  • 副本控制协议分为两大类:“中心化(centralized)副本控制协议”和“去中心化(decentralized) 副本控制协议”。
  1. 中心化副本控制协议
    • primary-secondary 协议
  2. 去中心化副本控制协议(去中心化协议没有因为中心化节点异常而带来的停服务等问题。)

NWR 机制

  • 首先看看这三个字母在分布式系统中的含义:
    • N:有多少份数据副本;
    • W:一次成功的写操作至少有w份数据写入成功;
    • R:一次成功的读操作至少有R份数据读取成功。
  • NWR值的不同组合会产生不同的一致性效果,当W+R>N的时候,读取操作和写入操作成功的数据一定会有交集,这样就可以保证一定能够读取到最新版本的更新数据,数据的强一致性得到了保证,如果R+W<=N,则无法保证数据的强一致性,因为成功写和成功读集合可能不存在交集,这样读操作无法读取到最新的更新数值,也就无法保证数据的强一致性。
  • 版本的新旧需要版本控制算法来判别,比如向量时钟。
  • 当然R或者W不能太大,因为越大需要操作的副本越多,耗时越长。

Quorum 机制

  • Quorum机制其实就是NWR机制。
  1. Write-all-read-one(简称 WARO).
    • WARO 读服务的可用性较高,但更新服务的可用性不高,甚至虽然使用了 副本,但更新服务的可用性等效于没有副本。WARO 牺牲了更新服务的可用性,最大程度的增强读服务的可用性。
  2. Quorum 机制
  • 将 WARO 的条件进行松弛,从而使得可以在读写服务可用性之间做折中,得出 Quorum 机制。
  • 在 Quorum 机制下,当某次更新操作 wi 一旦在所有 N 个副本中的 W 个副本上都成功,则就称 该更新操作为“成功提交的更新操作”,称对应的数据为“成功提交的数据”。
  • 仅仅依赖 quorum 机制是无法保证强一致性的。因为仅有 quorum 机制时无法确 定最新已成功提交的版本号,除非将最新已提交的版本号作为元数据由特定的元数据服务器或元数 据集群管理,否则很难确定最新成功提交的版本号。
  • Quorum 机制的三个系统参数 N、W、R 控制了系统的可用性,也是系统对用户的服务承诺:数 据最多有 N 个副本,但数据更新成功 W 个副本即返回用户成功。对于一致性要求较高的 Quorum 系 统,系统还应该承诺任何时候不读取未成功提交的数据,即读取到的数据都是曾经在 W 个副本上成 功的数据。

  • 分布式系统理论之Quorum机制

  • 在分布式系统中有个CAP理论,对于P(分区容忍性)而言,是实际存在 从而无法避免的。因为,分布系统中的处理不是在本机,而是网络中的许多机器相互通信,故网络分区、网络通信故障问题无法避免。
    因此,只能尽量地在C 和 A 之间寻求平衡。对于数据存储而言,为了提高可用性(Availability),采用了副本备份,比如对于HDFS,默认每块数据存三份。某数据块所在的机器宕机了,就去该数据块副本所在的机器上读取(从这可以看出,数据分布方式是按“数据块”为单位分布的)

  • 但是,问题来了,当需要修改数据时,就需要更新所有的副本数据,这样才能保证数据的一致性(Consistency)。因此,就需要在 C(Consistency) 和 A(Availability) 之间权衡。

  • Quorum机制,就是这样的一种权衡机制,一种将“读写转化”的模型。在介绍Quorum之前,先看一个极端的情况:WARO机制。
    WARO(Write All Read one)是一种简单的副本控制协议,当Client请求向某副本写数据时(更新数据),只有当所有的副本都更新成功之后,这次写操作才算成功,否则视为失败。

    • ①写操作很脆弱,因为只要有一个副本更新失败,此次写操作就视为失败了。②读操作很简单,因为,所有的副本更新成功,才视为更新成功,从而保证所有的副本一致。
      这样,只需要读任何一个副本上的数据即可。假设有N个副本,N-1个都宕机了,剩下的那个副本仍能提供读服务;但是只要有一个副本宕机了,写服务就不会成功。
    • WARO牺牲了更新服务的可用性,最大程度地增强了读服务的可用性。而Quorum就是更新服务和读服务之间进行一个折衷。
    • Quorum机制是“抽屉原理”的一个应用。定义如下:假设有N个副本,更新操作wi 在W个副本中更新成功之后,才认为此次更新操作wi 成功。称成功提交的更新操作对应的数据为:“成功提交的数据”。对于读操作而言,至少需要读R个副本才能读到此次更新的数据。其中,W+R>N ,即W和R有重叠。一般,W+R=N+1
    • 5(3+3,5+1);7(4+4,5+3,7+1);9(5+5,7+3,9+1)
  • 1)如何读取最新的数据?—在已经知道最近成功提交的数据版本号的前提下,最多读R个副本就可以读到最新的数据了。
    2)如何确定 最高版本号 的数据是一个成功提交的数据?—继续读其他的副本,直到读到的 最高版本号副本 出现了W次。

  • 一般一个Quorum的节点数目不大于9个,故无法简单地将一致性系统节点直接部署在多个地域,系统需要能持续地水平拓展,来满足服务、资源的拓展需求

CAP 理论

  • Consistency (一致性):CAP 理论中的副本一致性特指强一致性(1.3.4 );
  • Availiablity(可用性):指系统在出现异常时已经可以提供服务;
  • Tolerance to the partition of network (分区容忍):指系统可以对网络分区(1.1.4.2 )这种异常情 况进行容错处理;
  • 协议分析
    1. Lease 机制牺牲了部分异常情况下的 A,从而获得了完全的 C 与很好的 P。
    2. Quorum 机制,在 CAP 三大因素中都各做了折中,有一定的 C,有较好 的 A,也有较好的 P,是一种较为平衡的分布式协议。
    3. 两阶段提交系统具有完全的 C,很糟糕的 A,很糟糕的 P。
    4. Paxos 协议 ,在 CAP 三方面较之两阶段提交协议要优秀得多。Paxos 协议具有 完全的 C,较好的 A,较好的 P。Paxos 的 A 与 P 的属性与 Quorum 机制类似,因为 Paxos 的协议本 身就具有 Quorum 机制的因素。
  • CAP中的三个因素并不对等,P是基础,CA之间需要tradeoff。系统设计不是三选二的取舍。
  • 延迟作为可用性的指标和体现,系统设计通常需要在C和延迟之间tradeoff。
  • 总结:P是一个自然的事实,CA是强需求。三者并不对等。
  • 在数据库领域,CAP也正是ACID和BASE长期博弈(tradeoff)的结果。
  • ACID伴随数据库的诞生定义了系统基本设计思路,所谓先入为主。2000年左右,随着互联网的发展,高可用的话题被摆上桌面,所以提出了BASE。从此C和A的取舍消长此起彼伏,其结晶就是CAP理论。
  • 从ACID和BASE来说,ACID是为了保证一致性而诞生,因而侧重一致性;BASE是为了高可用系统的设计而诞生,因而侧重可用性。在分解C和A的情况时,肯定要涉及P,所以CAP理论统一了这一切。如果非要说酸碱,或者说酸碱平衡,那就是平衡于CAP理论。
  • CAP并不与ACID中的A(原子性)冲突,值得讨论的是ACID中的C(一致性)和I(隔离性)。ACID的C指的是事务不能破坏任何数据库规则,如键的唯一性。与之相比,CAP的C仅指单一副本这个意义上的一致性,因此只是ACID一致性约束的一个严格的子集。如果系统要求ACID中的I(隔离性),那么它在分区期间最多可以在分区一侧维持操作。事务的可串行性(serializability)要求全局的通信,因此在分区的情况下不能成立。
  • CA系统才是真正的难点。宣称是CA系统的,目前有两家:一家是Google的Spanner,一家是Alibaba的OceanBase。
  • 对P的分解需要从网络开始。网络包含了基础设施,光速限制以及软件配置与升级等。Google通过建设自己广域网获得高可靠的基础设施支撑,对于Google Spanner的CA系统,CAP之父曾总结说网络才是根本。
  • CAP理论:一致性与性能之间的trade-off

Consistency

  • 请不要再称数据库是CP或者AP
  • 一致性(Consistency)在CAP中是可线性化的意思(linearizability)。而这个是非常特殊(而且非常强)的一致性。尤其是虽然ACID中的C也是一致性(Consistency),但是和这里的一致性没有任何关系。
  • Alice还有Bob,他们在同一个房间,都在看他们的手机查2014年世界杯的决赛结果。就在最终结果刚发布之后,Alice刷新了页面,看到了宣布冠军,而且很兴奋地告诉了Bob。Bob马上也重新加载了他手机上的页面,但是他的请求被送到了一个数据库的拷贝,还没有拿到最新的数据,结果他的手机上显示决赛还正在进行。
  • 如果Alice和Bob同时刷新,拿到了不一样的结果,并不会太让人意外。因为他们不知道具体服务器到底是先处理了他们中哪一个请求。但是Bob知道他刷新页面是在Alice告诉了他最终结果_之后_的。所以他预期他查询的结果一定比Alice的更新。事实是,他却拿到了旧的结果。这就违反了可线性化。
  • ZooKeeper默认设置既不是一致的(CP)也不是可用的(AP),只是“P”。但是你有选择通过用sync命令来让它成为CP。并且在正确的设置下,读操作(不包括写)其实是CAP可用的。

Lease 机制

  • Lease 是由颁发者授予的在某一有效期内的承诺。颁发者一旦发 出 lease,则无论接受方是否收到,也无论后续接收方处于何种状态,只要 lease 不过期,颁发者一 定严守承诺;另一方面,接收方在 lease 的有效期内可以使用颁发者的承诺,但一旦 lease 过期,接 收方一定不能继续使用颁发者的承诺。
  • Lease 机制依赖于有效期,这就要求颁发者和接收者的时钟是同步的。对于这种时钟不同步,实践中的通常做法是 将颁发者的有效期设置得比接收者的略大,只需大过时钟误差就可以避免对 lease 的有效性的影响。

  • master给各个slave分配不同的数据,每个节点的数据都具有有效时间比如1小时,在lease时间内,客户端可以直接向slave请求数据,如果超过时间客户端就去master请求数据。一般而言,slave可以定时主动向master要求续租并更新数据,master在数据发生变化时也可以主动通知slave,不同方式的选择也在于可用性与一致性之间进行权衡。
  • 租约机制也可以解决主备之间网络不通导致的双主脑裂问题,亦即:主备之间本来心跳连线的,但是突然之间网络不通或者暂停又恢复了或者太繁忙无法回复,这时备机开始接管服务,但是主机依然存活能对外服务,这是就发生争夺与分区,但是引入lease的话,老主机颁发给具体server的lease必然较旧,请求就失效了,老主机自动退出对外服务,备机完全接管服务。

Split Brain

  • 如何避免“Split Brain”(脑裂)问题?
    • Split Brain 是指在同一时刻有两个认为自己处于 Active 状态的 NameNode。
  • Raft是一种一致性算法, gossip是广播协议
  • 为 Raft 引入 leader lease 机制解决集群脑裂时的 stale read 问题:https://www.jianshu.com/p/072380e12657
    • 这种方法牺牲了一定的可用性(在脑裂时部分客户端的可用性)换取了一致性的保证。
    • 多数派的网络分区挂了,岂不是直接不可写?

拜占庭将军问题

  • 拜占庭将军问题提供了对分布式共识问题的一种情景化描述,是分布式系统领域最复杂的模型。此外, 它也为我们理解和分类现有的众多分布式一致性协议和算法提供了框架。现有的分布式一致性协议和算法主要可分为两类:
    1. 一类是故障容错算法(Crash Fault Tolerance, CFT), 即非拜占庭容错算法,解决的是分布式系统中存在故障,但不存在恶意攻击的场景下的共识问题。也就是说,在该场景下可能存在消息丢失,消息重复,但不存在消息被篡改或伪造的场景。一般用于局域网场景下的分布式系统,如分布式数据库。属于此类的常见算法有Paxos算法、Raft算法、ZAB协议等。
    2. 一类是拜占庭容错算法,可以解决分布式系统中既存在故障,又存在恶意攻击场景下的共识问题。一般用于互联网场景下的分布式系统,如在数字货币的区块链技术中。属于此类的常见算法有PBFT算法、PoW算法。

CAP软件分类

  • CP: MongoDB、HBase、Zookeeper; (paxos、raft、zab、2PC协议)
  • AP: Eureka、Couch DB、Cassandra、Amazon Dynamo
  • Raft (etcd)、ZAB(Zookeeper)

故障处理如何做?有以下模型可以考虑

  • Fail-Fast:从字面含义看就是“快速失败”,尽可能的发现系统中的错误,使系统能够按照事先设定好的错误的流程执行,对应的方式是“fault-tolerant(容错)”。只发起一次调用,失败立即报错,通常用于非幂等性的写操作。 如果有机器正在重启,可能会出现调用失败 。

  • Fail-Over:含义为“失效转移”,是一种备份操作模式,当主要组件异常时,其功能转移到备份组件。其要点在于有主有备,且主故障时备可启用,并设置为主。如Mysql的双Master模式,当正在使用的Master出现故障时,可以拿备Master做主使用。阿里同学认为这里可以指失败自动切换。当出现失败,重试其它服务器,通常用于读操作(推荐使用)。 重试会带来更长延迟。

  • Fail-Safe:含义为“失效安全”,即使在故障的情况下也不会造成伤害或者尽量减少伤害。维基百科上一个形象的例子是红绿灯的“冲突监测模块”当监测到错误或者冲突的信号时会将十字路口的红绿灯变为闪烁错误模式,而不是全部显示为绿灯。有时候来指代“自动功能降级” (Auto-Degrade)。阿里的同学认为失败安全,出现异常时,直接忽略,通常用于写入审计日志等操作。调用信息丢失 可用于生产环境Monitor。

  • Fail-Back:Fail-over之后的自动恢复,在簇网络系统(有两台或多台服务器互联的网络)中,由于要某台服务器进行维修,需要网络资源和服务暂时重定向到备用系统。在此之后将网络资源和服务器恢复为由原始主机提供的过程,称为自动恢复。阿里的同学认为失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作 不可靠,重启丢失。可用于生产环境 Registry。

  • Forking 并行调用多个服务器,只要一个成功即返回,通常用于实时性要求较高的读操作。 需要浪费更多服务资源 。

  • Broadcast广播调用,所有提供逐个调用,任意一台报错则报错。通常用于更新提供方本地状态速度慢,任意一台报错则报错。

  • 上述故障模型是从系统设计的角度出发的,根据不同的需要设计不同故障处理方案。现在看来,系统的外延已经扩大。系统的容错性,或者分区容错能力,不能仅仅使用事先和事中的方案解决,系统的容错性还包括事后处理。

  • 分布式系统(Distributed System)资料:https://github.com/ty4z2008/Qix/blob/master/ds.md

Reference

  • 学习笔记:The Log(我所读过的最好的一篇分布式技术文章)
  • 《分布式系统原理介绍刘杰》
  • 分布式系统理论之Quorum机制
  • 一文读懂拜占庭将军问题
  • 详解分布式一致性机制
  • CAP理论与分布式系统设计
  • 深度介绍分布式系统原理与设计!!
  • 跨地域场景下,如何解决分布式系统的一致性?
  • 左耳朵耗子:分布式系统架构经典资料
  • 如何系统性的学习分布式系统?

通过判断订单状态是否可以避免并发导致的问题?

发表于 2021-09-17
  • 首先重温一个问题:RPC可以和事务绑定吗?

  • 有以下业务场景:

    • 用户扣钱成功之后,可以玩一局游戏;如果用户没成功玩游戏,需要将已扣的钱退回给用户。
  • 服务架构:服务A(游戏服务),服务B(用户资产服务)。

  • 游戏订单状态:1(初始状态);2(扣费成功);3(退费成功);4(不存在)

  • 实现基本流程如下:

  • 流程1:

    玩游戏请求----------> 服务A
               [无事务] 
               [1.插入游戏订单(订单状态:1)]
               [2.RPC进行扣费]------------------------------------------------>服务B
               [3.更新游戏订单状态(1-> 2)(DB操作)] 
               (订单状态为2的可以参与游戏)

  • 流程2 (针对扣费成功但超时返回的情况):
    定时任务补偿----------> 服务A
               [无事务] 
               [1.查询订单状态为1的订单]
               [2.RPC查询扣费订单是否存在]------------------------------->服务B
               [3_1.不存在 -- 更新游戏订单状态(1-> 4)(DB操作)] 
               [3_2.存在 -- RPC取消扣费] ---------------------------------------->服务B
               [4.更新游戏订单状态(1-> 3)(DB操作)]

  • 流程1 和 流程2 在并发执行的时候会存在 用户退费成功但仍然可以玩游戏的问题:
    • 执行流程1中步骤2
    • 执行流程2中步骤3_2
    • 执行流程1中步骤3

解决方案1

  • 加入订单时间校验:超过n分钟后的订单才允许退款(可以很大避免以上问题的发生,因为一个请求基本不可能执行几分钟还没结束)
定时任务补偿----------> 服务A
             [无事务] 
             [0.查询订单状态为1的订单]
    新增判断 -   [1.查询订单创建时间是否超过n分钟,不满足暂不退款,中断执行] 
             [2.RPC查询扣费订单是否存在]------------------------------->服务B
             [3_1.不存在 -- 更新游戏订单状态(1-> 4)(DB操作)] 
             [3_2.存在 -- RPC取消扣费] ---------------------------------------->服务B
             [4.更新游戏订单状态(1-> 3)(DB操作)]
  • 该方案的缺点
    • 如果设置的订单退款超时时间太长,会导致用户被误扣的钱长时间未退款,引起投诉
    • 设置足够合理的超时时间就一定能避免这个问题的发生了吗?不会存在极端情况,请求执行时间过长?

解决方案2

  • 回到本文的标题。
  • 通过和RPC绑事务的方式,也可以解决这个问题(不用通过配置时间差),本质是通过数据库锁的方式来解决。
定时任务补偿----------> 服务A
             [1.查询订单状态为1的订单]
             [2.RPC查询扣费订单是否存在]------------------------------->服务B
             [3_1.不存在 -- 更新游戏订单状态(1-> 4)(DB操作)] 
             [新建事务] 
             [3_2.存在 -- 更新订单状态(1-> 3)(DB操作)]
             [ if (row = UPDATE order SET status = 3 WHERE status = 1) > 0 ]
             [4. RPC取消扣费] ---------------------------------------->服务B
             [5_1. 调用成功 - 提交事务]
             [5_1. 调用失败 - 异常回滚事务]
  1. 扣费的时候,update order set status = ’成功‘ where status = ’初始状态‘ ;update raw == 1 时, 执行RPC 冻结, 否则抛异常回滚事务
  2. 定时任务补偿退款的时候,update order set status = ’取消冻结成功‘ where status = ’初始状态‘ ;update row == 1 时, 执行RPC 取消冻结, 否则抛异常回滚事务
    • 实际情况会比描述的复杂,因为status的最终值设置是根据RPC的结果来的,而不是一开始就能确定的;解决方案
      1. 新加字段,统一 update 未执行 到 已执行, 通过新字段来判断是否执行db成功
    1. select for update, 查的时候锁住该行数据
    2. 其他?

解决方案3

  • 方案1和方案2的结合
  1. 1分钟后才执行流程2退费
  2. 流程2中【3_1】 和【4】绑事务,【4】变成update order set status=3 where status=1
  3. 流程1中【3】,变成 update order set status=2 where status=1

多IDC下微服务数据如何同步

发表于 2021-09-17
  • 最近公司出现跨机房调用redis超时导致的故障,借此简单记录一下微服务依赖的相关组件使用规范。

Redis

  1. 每个服务的每个IDC对应一套Redis集群,原则上:
    • 不同服务不共用一套redis:避免业务相互影响,key定义冲突等问题;
    • 不跨机房读写redis:避免不必要的时延等
  2. 同个服务不同IDC的redis数据怎么同步?
    • 通过消息队列异地通知
    • 如果一定要双写,异地IDC的同步尽量异步
    • 如果一定要跨IDC读Redis,要控制好超时时间和降级

MySQL

  • 看具体业务选择合适的部署方式
  • 数据不要求强一致,可覆盖的业务(比如用户基础信息),可以采用双活架构,MySQL使用双主架构,通过otter 同步数据
  • 要求强一致,不可覆盖的业务(比如用户资产),可以采用主备架构,MySQL跨IDC搭建主从。
<1…91011…17>

163 日志
180 标签
RSS
© 2025 Kingson Wu
由 Hexo 强力驱动
|
主题 — NexT.Pisces v5.1.4