dream

一个菜鸟程序员的成长历程

0%

南京大学操作系统

教材是OSTEP,课程官网

操作系统概述

这里概述了操作系统做什么,怎么做。

总共有三个大模块,虚拟化并发,持久化

操作系统的几个目标:

  1. 高性能:高效率的运行多个程序。
  2. 保护每个程序独立运行:不能让一个程序访问另外一个程序的内存数据
  3. 可靠性:操作系统必须一直运行,不能停止,因为一旦停止,所有依赖他的软件程序都会停止。

Cache Lab

cache lab 缓存实验

代码下载

从CSAPP上面下载对应的lab代码

http://csapp.cs.cmu.edu/3e/labs.html

环境准备

需要安装 valgrind。可以参考文章Valgrind centos

安装好以后执行valgrind --version可以看到版本号。

Cache simulator

  • cache simulator not a cache。我们不是实现一个真正的缓存,只是实现一个模拟器。
  • 不存储内容
  • 不使用block offset
  • 只计算命中数,不命中数和驱逐数(hit count, miss count,eviction count)
  • 缓存模拟器需要在不同的s,b,E下运行。
  • 使用LRU替换策略

Hints

  • 使用二维数组 struct cache_line cache[S][E];
  • S = 2^s
  • cache_line //上面说了不需要block offset,所以可以忽略block的内容
    • Valid bit
    • Tag
    • LRU counter
  • 通过getopt获取命令行输入
    • 返回-1表示没有输入了
    • 通常在循环里面接收参数
    • 需要包含#include <unistd.h>,#include <getopt.h>
    • 通常使用switch来处理不同的输入
    • 考虑如何处理无效输入
    • 更多信息 man 3 getopt
  • fscanf可以指定要读的流(scanf只能读标准输入流),用来读取trace file
    • 参数
      • 一个流的指针
      • 如何解析文件的信息的格式化字符串
      • 其余部分是指向存储解析数据的变量的指针
    • 通常在循环里使用
    • 当命中EOF或者没有匹配到格式化字符串的时候返回-1
    • 更多信息 man fscanf
  • Malloc/free
    • malloc分配数据到heap
    • 记得 free 掉malloc的数据
    • 不要 free 你没有分配的内存

lab4 getopt

lab4 fscanf

要求我们实现csim.c文件,给了一个示例csim-ref文件。

输入./csim-ref -h可以看到我们要实现的东西。

lab4 csim

首先需要接受参数,参数有

  • -h 输出帮助信息
  • -v 可选详细标志,根据示例程序来,就是输出 L 10,1 miss这些信息
  • -s [num] set index bit 数
  • -E [num] 每个set的行数
  • -b [num] block offset bit数
  • -t [file] Trace file文件路径

根据上面的提示可以知道,通过getopt函数来接收参数,并通过switch来处理。读取文件则通过fscanf函数,来读取-t传的文件。

下面是./traces/yi.trace文件的内容

1
2
3
4
5
6
7
L 10,1
M 20,1
L 22,1
S 18,1
L 110,1
L 210,1
M 12,1
  • L 代表数据载入
  • S 代表数据存储
  • M 代表数据修改,需要一次载入 + 一次存储
  • 后面的10,20,22这些代表地址
  • 最后的1代表操作内存访问的字节数

完整代码

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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
#include "cachelab.h"
#include <unistd.h>
#include <getopt.h>
#include <stdio.h>
#include <stdlib.h>

typedef struct{
int valid;
int tag;
int time_stamp;
} cache_line;

int timestamp = 0;

// 开始匹配到合适的set 找到命中的cache,如果命中返回1,如果没有命中返回0
int find_hit_cache(cache_line *cache_line,int E,int tag, int*hits) {
int isHit = 0;
// 循环set中的cache_line 找到是否有匹配tag && valid
for(int i = 0; i < E; i++) {
if (cache_line[i].valid == 1 && cache_line[i].tag == tag ) {
//hit
printf("hit \n");
*hits = *hits + 1;
isHit = 1;
//刷新时间
cache_line[i].time_stamp = timestamp;
return isHit;
}
}
return isHit;
}

// 找到一个空的cache line放进去,找到了就返回1,没有找到就返回0
int find_empty_cache(cache_line *cache_line,int E,int tag) {
int have_empty_cache = 0;
for (int i = 0; i< E; i++) {
if (cache_line[i].valid == 0) {
// 空的
// 把当前内存放入cache
cache_line[i].valid = 1;
cache_line[i].tag = tag;
cache_line[i].time_stamp = timestamp;
// 找到了就不需要替换了
have_empty_cache = 1;
return have_empty_cache;
}
}
return have_empty_cache;
}

// 获取要替换的索引
int get_eviction_index(cache_line *cache_line, int E) {
int max_time_stamp = timestamp;
int eviction_index = -1;
for (int i = 0; i< E; i++) {
if (cache_line[i].time_stamp < max_time_stamp) {
//找到time_stamp最小的那个
max_time_stamp = cache_line[i].time_stamp;
eviction_index = i;
}
}
return eviction_index;
}

// LRU替换
void LRU(cache_line *cache_line, int E,int tag) {
// 获取要替换的索引
int eviction_index = get_eviction_index(cache_line, E);

// 替换
cache_line[eviction_index].valid = 1;
cache_line[eviction_index].tag = tag;
cache_line[eviction_index].time_stamp = timestamp;
}

// L和S操作,M就调用两次这个
int load_and_store(unsigned address,int b,int s,int u_max,int E,cache_line **cache, int *hits,int *misses,int *evications) {
// 获取set index block offset tag
int set_index,tag;
set_index = (address >> b) & u_max;
tag = (address >> b) >> s;

// 开始匹配到合适的set 找到命中的cache,如果命中返回1,如果没有命中返回0
int isHit = find_hit_cache(cache[set_index], E, tag, hits);
if (isHit == 0) {
// miss
printf("miss \n");
*misses = *misses + 1;
// 找到一个空的cache line放进去,找到了就返回1,没有找到就返回0
int have_empty_cache = find_empty_cache(cache[set_index], E, tag);
// 如果没有找到空的cache,就需要LRU替换
if (have_empty_cache == 0) {
printf("evictions \n");
*evications = *evications + 1;
//LRU替换
LRU(cache[set_index], E, tag);
}
}
// 更新全局时间戳
timestamp++;
return 0;
}

int main(int argc, char** argv)
{
// 接受参数 getopt
int opt,v,s,E,b,S,B;
// 文件
FILE * pFile;
while(-1 != (opt = getopt(argc, argv, "h?v?s:E:b:t:"))){
// opt is h,v,s,E,b,t的ASCII码值
// 通过switch对不同的参数进行不同的处理
switch(opt) {
case 'h':
printf("./csim: Missing required command line argument \n Usage: ./csim-ref [-hv] -s <num> -E <num> -b <num> -t <file> \n Options: \n -h Print this help message. \n -v Optional verbose flag. \n -s <num> Number of set index bits. \n -E <num> Number of lines per set. \n -b <num> Number of block offset bits. \n -t <file> Trace file. \n\n Examples: \n ./csim -s 4 -E 1 -b 4 -t traces/yi.trace \n ./csim -v -s 8 -E 2 -b 4 -t traces/yi.trace \n");
// h参数输出帮助内容
break;
case 'v':
// v参数输出详细信息
v = 1;
printf("v:%d \n",v);
break;
case 's':
// S is set 2^s 的数量
// s is Number of set index bits
s = atoi(optarg);
S = 1 << s;
printf("s:%d, S:%d \n",s,S);
break;
case 'E':
// E is cache line 的数量
// Number of lines per set
E = atoi(optarg);
printf("E:%d \n",E);
break;
case 'b':
// B is block data 的字节
// b is Number of block offset bits
b = atoi(optarg);
B = 1 << b;
printf("b:%d, B:%d \n",b,B);
break;
case 't':
// t is Trace file
// 读取文件
//t = atoi(optarg);
pFile = fopen(optarg,"r");
printf("t:%s, file:%p \n",optarg,pFile);
break;
default:
printf("非法参数 \n");
break;
}
}
if(s == 0 || E == 0 || b == 0) {
return 0;
}
// cache存储
cache_line **cache = (cache_line **)malloc(S * sizeof(cache_line *));
if (cache == NULL) {
printf("内存分配失败 \n");
}
for(int i = 0; i < S; i++) {
cache[i] = (cache_line *)malloc(E * sizeof(cache_line));
if(cache[i] == NULL) {
printf("内存分配失败,开始回滚 \n");
// 在这里需要释放已分配的内存,然后退出
for (int j = 0; j < i; ++j) {
free(cache[j]);
}
free(cache);
}
}

int u_max = 1;
for(int i = 0; i < s - 1; i++) {
u_max = (u_max << 1) | 1;
}

// 读取文件
char identifier;
unsigned address;
int size;
int hits,misses,evictions;
// Reading lines like " M 20,1" or "L 19,3"
while(fscanf(pFile," %c %x,%d",&identifier,&address,&size)>0)
{
// Do stuff
// 开始计算 hits,misses,evictions, hits:0 misses:0 evictions:0
//printf("identifier %c, addr:%x, size:%d \n",identifier,address,size);

//根据identifier来判断动作L load S store M = 一次L 一次S
if (identifier == 'L' || identifier == 'S'){
printf("identifier %c, addr:%x, size:%d \n",identifier,address,size);
load_and_store(address,b,s,u_max,E,cache,&hits,&misses,&evictions);
} else if (identifier == 'M') {
// 一次L 一次S
printf("identifier %c, addr:%x, size:%d \n",identifier,address,size);
load_and_store(address,b,s,u_max,E,cache,&hits,&misses,&evictions);
load_and_store(address,b,s,u_max,E,cache,&hits,&misses,&evictions);
}
}

fclose(pFile); //remember to close file when done
printSummary(hits, misses, evictions);

// 释放数组内存
for(int i = 0; i< S; i++) {
free(cache[i]);
}
free(cache);
return 0;
}

Efficient Matrix Transpose

Data Lab

data lab 数据实验

这个数据实验请在linux机器上面运行,实测mac m1本跑不起来。windows没试过。

centos上需要安装好gcc运行环境。

如果跑不起来记得安装下面这个东西:

yum -y install glibc-devel.i686

运行make btest的时候可能会有warning提示,不用管,这个时候其实已经创建完btest了,可以直接运行btest

lab准备

bitXor

第一个函数是实现异或

看一下异或的要求,相同为0,不同为1,这个函数里面只能使用按位与&按位取反~

最大操作符号数:14

x y 结果
0 0 0
0 1 1
1 0 1
1 1 0

假设我们有4 = 100, 5 = 101,异或的结果为1 = 001.

先看按位与的结果。100 & 101 = 100 这个时候能得到 0 0 0这个正确的组合

x y 结果
0 0 0
0 1 0
1 0 0
1 1 1

100再取反就是011,就可以得到 1 1 0 这个正确的组合。

x y 结果
0 0 1
0 1 1
1 0 1
1 1 0

先看按位或的结果。100 | 101 = 101 这个时候能得到 0 1 1 和 1 0 1这个正确的组合

x y 结果
0 0 0
0 1 1
1 0 1
1 1 1

可以看到 ~(x & y) & (x | y) 就可以得出结果了,但是我们不能用 | ,所以我们需要通过 &,~来实现 |。

可以通过 (x & ~y) 来实现 | ,4 = 100 取反 = 011, 5 = 101 取反 = 010, 011 & 010 = 010,取反 = 101. 100 | 101 = 101。

所以 异或就是 (x & y) & ((~x & ~y))

代码

1
2
3
int bitXor(int x, int y) {
return ~(x & y) & ~(~x & ~y);
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

Tmin

Tmin是1000 0000,也就是最小的有符号数,那当然是符号位是1,剩下全0了。

可以使用操作符:! ~ & ^ | + << >>

最大操作符号数量:4

分数:1

返回 1000 0000就可以了。正常的int Tmin就是1后面31个0,也就是1左移动31位

代码:

1
2
3
int tmin(void) {
return 1 << 31;
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

isTmax

Tmax是0111

可以使用操作符: ! ~ & ^ | +
最大操作符号数量: 10

4位的话,Tmax就是7,看一下7的一些操作结果,可以发现,7+1 = ~7

1
2
3
4
7 = 0111
7 + 1 = 1000 = -8
~7 = 1000 = -8
1000 ^ 0000 = 1000 !1000 = 0000

但是 -1 + 1 也等于 ~-1,所以我们需要排除-1

1
2
3
4
-1 = 1111
-1 + 1 = 0000
~-1 = 0000
0000 ^ 0000 = 0000 !0000 = 0001

可以看到4的话,4 + 1 不等于~4

1
2
3
4
4 = 100
4 + 1 = 0101
~4 = 1011
101 ^ 000 = 101 !101 = 000

怎么排除-1呢,观察发现-1+1 = 0,而0^0 = 0,但是tmax ^ 0 不等于0

所以tmax需要满足两个条件

  1. x + 1 == ~x
  2. x + 1 != 0

可以用^操作来实现==。如果相等,那么x+1 ^ ~x 就会等于0,!0 == 1,所以第一个条件就是

!((x+1) ^ ~x)

第二个条件同样通过^来实现。

!!((x+1) ^ 0)

只要这两个都满足就是Tmax了,都满足可以通过&来实现,如果都是1,那么&以后就是1,有一个不满足&以后就是0.

代码:

1
2
3
4
int isTmax(int x) {
int xPlus = x + 1;
return !(xPlus ^ ~x) & !!(xPlus ^ 0);
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

allOddBits

如果所有的奇数位都是1就返回1,否则返回0

可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 12
分数: 2

比如 1010 1010就是奇数位上全1.

所以只要和 1010 1010& 操作,只要做完以后还是 1010 1010的话,那么就返回1,不然就是0.

因为假设 x 奇数位上有一个是0,比如 1010 1000,那么结果就会是 1010 1000,所以只有奇数位上全1,&以后一定是1010 1010

所以需要满足条件

  1. x & 1010 1010 == 1010 1010

代码:

1
2
3
4
5
6
int allOddBits(int x) {
int odd = 0xAA; //1010 1010
int halfOdd = (odd << 8) + odd; // 1010 1010 0000 0000 + 1010 1010 = 1010 1010 1010 1010
int allOdd = (halfOdd << 16) + halfOdd;
return !((allOdd & x) ^ allOdd );
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

negate

返回-x
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 5
分数: 2

这里要分成三部

  • 正数,比如 7 = 0111
  • 0, 0 = 0000
  • 负数,-1 = 1111

如果使用按位取反

  • 7 = 0111,~7 = 1000 = -8
  • 0 = 0000, ~0 = 1111 = -1
  • -1 = 1111, ~-1 = 0000 = 0
  • -8 = 1000, ~-8 = 0111 = 7

取反以后的值 + 1就是对应的负数了,-8 + 1 = -7, -1 + 1 = 0, 0 + 1 = 1, 7 + 1 = 8

代码:

1
2
3
int negate(int x) {
return ~x + 1;
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

isAsciiDigit

如果 0x30 <= x <= 0x39,返回1,否则0
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 15
分数: 3

0x30 = 0011 0000, 0x39 = 0011 1001。
根据题目,也就是判断 0011 0000 <= x <= 0011 1001

首先高位要等于 0011,如果不等于0011,那么肯定不在这个范围。可以通过 >> 4位然后 ^ 0011,如果结果为0,那么高位就是满足的。

低位在0000 到 1001之间,当首位是0的时候,后面是啥都行,首位是1,那么后面两位必须是00,也就是前三位是100.

判断首位是0可以通过 & 0x8 然后 ^ 0来判断,如果结果是0首位就是0,不然首位是1

判断低4位的前3位,先 & 0xE来获取前3位,然后 ^ 0x8来判断是不是 100

所以需要满足条件1并且满足条件2或者3

  1. x >> 4 ^ 0011 == 0
  2. (x & 0x8) ^ 0 == 0
  3. (x & 0xE) ^ 0x8 == 0

代码:

1
2
3
4
5
6
7
8
int isAsciiDigit(int x) {
int xh = x >> 4;
int a3 = 0x3;
int xlh = x & 0x8;
int xorxlh = xlh^0;
int xorxl = (x & 0xE) ^ 0x8;
return (!(xh ^ a3 ^ 0)) & (!xorxlh | !xorxl);
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

conditional

实现三元运算 x ? y : z
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 16
分数: 3

x 为真代表 x & 1 == 1,x 为假代表 x & 1 == 0。
需要满足条件

  1. x & 1 == 1时,返回y,所以z需要置为0并且和y一起返回。!(x & 1) & z就可以把z置为0,所以应该返回 (!(x & 1) & z) | y
  2. x & 1 == 0时,返回z,所以y需要置为0并且和z一起返回。x & 1 & y就可以把y置为0。所以应该返回 (x & 1 & y) | z

把上面的2个条件合并起来。

(!(x & 1) & z) | (x & 1 & y)

但是发现这样并不行,所以重新思考,发现 x & 1 == 1时候是没错,但是我们应该让 x = 0xFF才行。

所以改进一下子

  • 先对x取反。!x = 1,说明x = 0,这个时候应该返回 z,所以需要(0 & y) | z
  • !x = 0,说明x = 1,应该返回y,所以需要 (0 & z) | y

这里把 !x 在按位取反 + 1就可以得到当 x = 0时候,condition = 1111 1111。这个时候返回z。

代码:

1
2
3
4
5
int conditional(int x, int y, int z) {
int xn = !x;
int condition = ~xn + 1; //x = 0,condition = 1111 1111, x = 1, condition = 0000 0000
return (condition & z) | (~condition & y);
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

isLessOrEqual

如果x <= y,返回1,否则0
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 24
分数: 3

等于可以通过异或来做。

!(x ^ y)

在看小于,如果一个正数和一个负数,那么负数一定小于正数,负数的符号位1,正数的符号位0.

取出符号位,通过右移动31位来获取符号位,但是负数会补1,所以在和1与一下,就可以得到符号位了。

(x >> 31) & 1

可以|一下,如果是 1 | 0就返回1了。

((x >> 31) & 1 ) | ((y >> 31) & 1)

如果两个都是正数或者负数,那么符号位相同。

  • 对较小的数进行按位取反,在加上大的数的话,会产生溢出。也就是 x < y, ~x + y 会溢出,所以首位是0
  • 如果两个数相等或者较大的数取反了在加上小的数,不会溢出。x >= y, ~x + y 不会溢出,所以首位是1
  • 所以对于等于的情况还需要处理,如果两个数相等,那么 ~x + y = -1,也就是所有位都是1,让这个值+1就是0了,就和小于保持一致了

如果两个数的符号位不同,那么x是1,y是0的话,就返回1,否则0

  • 对x的符号位取反,如果x符号位是1,那么取反0,y的符号位是0,那么就返回1
  • 如果x符号位是0,取反1,y是1,那么返回0

代码

1
2
3
4
5
6
7
8
9
10
11
12
int isLessOrEqual(int x, int y) {
// 取首位
int signalX = (x >> 31) & 1;
int signalY = (y >> 31) & 1;
// !(signalX ^ signalY)是符号位相同的情况
// !(((~x + y + 1) >> 31) & 1) 是符号位相同时候小于等于的情况
int lessEq = !(signalX ^ signalY) & !(((~x + y + 1) >> 31) & 1);
// 如果符号位不同的情况
int neq = (!signalY) & signalX;
// 两个情况做|,满足任一个情况则返回1
return (lessEq | neq);
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

logicalNeg

对x取反,实现!操作
可以使用的操作符: ~ & ^ | + << >>
最大数量: 12
分数: 4

两种情况

  • 0,(~0 + 1) | 0 的首位是0
  • 其他数, (~x + 1) | x的首位是1

0要返回1,1要返回0,可以异或1

代码

1
2
3
int logicalNeg(int x) {
return ((((~x + 1) | x) >> 31) & 1) ^ 1;
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

howManyBits

输出最少需要的位数来表示int x
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 90
分数: 4

例子:

  • howManyBits(12) = 5 = 10010
  • howManyBits(298) = 10 = 10 1001 1000
  • howManyBits(-5) = 4 = 1011
  • howManyBits(0) = 1 = 0
  • howManyBits(-1) = 1 = 1
  • howManyBits(0x80000000) = 32 = 1000….

三种情况

  • 正数的首位都是1,遇到1的话,找到1是哪位就可以了
  • 负数的首位是符号位都是1,所以需要找到第二个1,如果把负数的符号位变成0,就可以按照正数处理了
  • 0,直接返回0,也可以使用正数的方法找1,找不到自然返回0了

有没有1,可以通过!!来判断,如果是!!0,就是0,如果是其他数!!x就是1了。

这道题的代码是从网上抄的。

代码

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
int howManyBits(int x) {
int signal = x >> 31;
int b1,b2,b3,b4,b5,h16,h8,h4,h2,h1;
x = (signal & ~x) | (~signal & x);
// 查看高16位是否有1
h16 = !!(x >> 16);
// 如果高16位有1,那么肯定需要16位来表示,记住这16位
// 因为高 16bit 有1,那么h16就是1,所以1 << 4 就是16,代表最低需要16位表示
// 如果高 16bit 没有1,那么h16就是0,所以 0<< 4就是0,代表最低需要0位表示
b1 = h16 << 4;
// 如果高位有1,那么x >> 16位,这样的原来的高位变成了低位
// 如果高位没有1,那么x >> 0位,这样低16位还是低16位
x = x >> b1;

// 这里分为两种情况,如果高16位有1,需要继续看高8位是否有1,如果高16位没有1,需要看低16位的高8位是否有1
// 因为上面对于高16位有1的时候,将高16位变成了低16位,所以都只需要看低16位的高8位就可以了
h8 = !!(x >> 8);
// 和上面同理,如果现在16位的高8位有1,那么b2代表 1 << 3就是8,如果没有,那么就是0
b2 = h8 << 3
// 同样处理,如果有,那么高8位变低8位
x = x >> b2;

//处理8位的高4位
h4 = !!(x >> 4);
b3 = h4 << 2;
x = x >> b3;

// 处理4位的高2位
h2 = !!(x >> 2);
b4 = h2 << 1;
x = x >> b4;

// 处理最后2位是否有1
h1 = !!(x >> 1);
b5 = h1;
x = x >> b5;

// 所有结果相加 最后+1,因为高16位有1,那么需要17位表示
return b1 + b2 + b3 + b4 + b5 + x + 1;
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

float_twice

传入一个无符号数uf,返回uf * 2的小数的bit表示
可以使用的操作符: 任何整数的操作,包||,&&,if,while
最大数量: 30
分数: 4

在复习一下,IEEE浮点标准用V = (-1)的s次方 * M * 2的E次方来表示。
单精度尾数是23位,exp是8位,符号位1位

取符号位
int s = (uf >> 31) & 1;

取exp
int exp = (uf << 1) >> 24

当阶码exp不等于全0或不等于全1的时候,就表示规格化的浮点数。
E的计算方式

  • E = 阶码 - Bias
  • Bias = 单精度是127
  • 单精度下,假设阶码为 0000 0001, 那么E = 1 - 127 = -126
    E = exp - Bias

M的计算方式

  • M = 1.XXXXXX
  • 尾数就是 XXXXXX
  • 假设 尾数为 0000 0000 0000 0000 0000 001,那么M = 1.00000000000000000000001

-1^s * 1.尾数 * 2^E

当阶码等于全0的时候,就表示非规格化的浮点数。
exp ^ 0是0就代表全0,非规格化
E的计算方式,他跟阶码没关系了,因为阶码永远是0

  • E = 1 - Bias
  • Bias = 单精度是127
  • 阶码永远为 0000 0000, E = 1 - 127 = -126
    E = 1 - 127

M的计算方式

  • M = 0.XXXXXX
  • 尾数就是 XXXXXX
  • 假设 尾数为 0000 0000 0000 0000 0000 001,那么M = 0.00000000000000000000001

-1^s * 0.尾数 * 2^E

当阶码等于全1的时候,就表示特殊的浮点数。
~exp ^ 0是0就代表全1,特殊浮点数 当尾数不为全0的时候,就是NaN,返回参数。

小数乘法

  • 符号位s1 ^ s2
  • M = M1 * M2
  • E = E1 + E2

对于规格化的数,2,自然是e+1,因为2的E次方,E+1,那就等于多乘了个2
对于非规格化的数,E是固定的-126,没法改变,所以尾数
2

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
unsigned float_twice(unsigned uf) {
// 初始化s符号位,exp阶码,fre尾数
int s,exp,fre;
s = (uf >> 31) & 1;
exp = (uf & 0x7F800000) >> 23;
fre = uf & 0x7FFFFF;
// 如果exp == 0,代表非规格化的数
if (exp == 0) {
// 非规格化
// 尾数 * 2
fre = fre << 1;
return (s << 31) | (exp << 23) | fre;
} else if (exp == 0xFF) {
// 特殊
return uf;
} else {
// 规格化 exp + 1
exp = exp + 1;
// +1以后有可能是全1,那么就是无穷大,也就是特殊值,无穷大需要把尾数变成全0
if (exp == 0xff) {
fre = 0x0;
}
return (s << 31) | (exp << 23) | fre;
}
}

btest 结果:

lab1 btest

dlc 结果:

lab1 dlc

bdd check 结果:

lab1 dlc

float_i2f

计算机系统漫游

计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。。虽然系统的 具体实现方式随着时间不断变化,但是系统内在的概念却没有改变。所有计算机系统都有 相似的硬件和软件组件,它们又执行着相似的功能。

第一个c程序

一般第一个程序都是输出hello world,这里我们使用c语言输出一个hello world。后面在来讲这里面都发生了什么。

1
2
3
4
5
6
7
#include <stdio.h>

int main(void)
{
printf("hello world\n");
return 0;
}

信息的表示和处理

无符号整型和有符号整型的二进制表示

无符号整型和有符号整型的二进制表示

bit

可以用bit来表示集合,并用位操作来表示集合的操作。

p && *p可以避免空指针

左移1位等于乘2

右移1位等于除2,且向下取整

逻辑右移:右移后补0,java中 -3 >> 1 = -2
算术右移:右移后补符号位,java中 -3 >>> 1 = 2147483646

大端表示和小端表示

  • 最高有效字节在最前面的方式,称为大端表示。
  • 低有效字节在最前面的方式,称为小端表示。

大端表示和小端表示

可以通过下面的代码来测试自己的机器是大端还是小端的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

typedef unsigned char *pointer;

void show_bytes(pointer start, size_t len) {
size_t i;
for (i = 0; i < len; i++)
printf("%p\t0x%.2x\n", start+i, start[i]);
printf("\n");
}

int main() {
int a = 15213;
printf("int a = 15213;\n");
show_bytes((pointer) &a, sizeof(int));
return 0;
}

float

101.11表示5又3/4,也就是23/4。前面的101表示5,小数点后面的11表示3/4,小数点后面第1个1表示1/2,第二个1表示1/4,所以相加就是3/4

float

浮点数表示由IEEE制定。

IEEE浮点标准用V = (-1)的s次方 * M * 2的E次方来表示。

所以存储就有了3部分

  • 符号位,1位,0表示正数,1表示负数,表示s
  • 尾数significand,尾数通常是1-2之间或0-1之间。encodes M,但不等于M
  • 阶码exponent,encodes E,但不等于E

IEEE float

表示方式

规格化表示(Normalized)

当阶码不等于全0或不等于全1的时候,就表示规格化的浮点数。

E的计算方式

  • E = 阶码 - Bias
  • Bias = 2的k-1次方 - 1,单精度是127,双精度是1023
  • 单精度下,假设阶码为 0000 0001, 那么E = 1 - 127 = -126

M的计算方式

  • M = 1.XXXXXX
  • 尾数就是 XXXXXX
  • 假设 尾数为 0000 0000 0000 0000 0000 001,那么M = 1.00000000000000000000001

假设符号为0表示正数,那么这个浮点数为 V = 1 * M * 2的E次方
= 1 * 1.00000000000000000000001 * 2的-126次方
= 1.00000000000000000000001 * 2的-126次方
= 0.000…00100000000000000000000001 (小数点左移126位)
这等于非常非常小的一个浮点数了

如何表示15213.0呢。首先符号位为0,然后计算尾数和阶码。

  • 15213.0 换成二进制表示为 11 1011 0110 1101.0
  • 接下里移动小数点为 1.1101 1011 0110 1 * 2的13次方,因为小数点左移动了13位。
  • 所以尾数为 1101 1011 0110 1000 0000 000
  • E = 13,所以阶码 = E + Bias = 13 + 127 = 140.
  • 所以阶码为 140,二进制表示为 1000 1100

15213.0的浮点数表示为:

0 1000 1100 1101 1011 0110 1000 0000 000

非规格化表示(Denormalized)

当阶码等于全0的时候,就表示非规格化的浮点数。

E的计算方式,他跟阶码没关系了,因为阶码永远是0

  • E = 1 - Bias
  • Bias = 2的k-1次方 - 1,单精度是127,双精度是1023
  • 阶码永远为 0000 0000, E = 1 - 127 = -126

M的计算方式

  • M = 0.XXXXXX
  • 尾数就是 XXXXXX
  • 假设 尾数为 0000 0000 0000 0000 0000 001,那么M = 0.00000000000000000000001

假设符号为0表示正数,那么这个浮点数为 V = 1 * M * 2的E次方
= 1 * 0.00000000000000000000001 * 2的-126次方
= 0.00000000000000000000001 * 2的-126次方
= 0.000…00000000000000000000000001 (小数点左移126位)
这等于非常非常小的一个浮点数了,比上一个规格化的更小

如果尾数也等于全0,那么就表示浮点数0

特殊值

当阶码等于全1的时候,就表示特殊的浮点数。

  • 当尾数全0,就表示无穷大
  • 当尾数为其他值的时候,就表示 NaN(Not a Number)

用数轴表示如下:

IEEE float 表示

练习题

习题2.54 假定变量x,f和d的类型分别是int、float 和cioubile。除了f和d都 不 能 等 于 十∞ 、 、 一 ∞ 或 者 N a N , 它 们 的 值 是 任 意 的 。 对 于 下 面 每 个 C 表 达 式, 证 明 它 总 是 为 真 ( 也 就 是 求 值 为 1 ), 或 者 给 出 一 个 使 表 达 式 不 为 真 的 值 ( 也 就 是 求 值 为 0 )。

类型中long > double > int > float

  1. x == (int) (double) x // true
  2. x == (int) (float) x // false
  3. d == (double) (float) d // false
  4. f == ( float ) ( double ) f // true
  5. f == -(-f) //true 只改变了符号位
  6. 1.0/2 == 1/2.0 //true
  7. d*d >= 0.0 // true
  8. ( f + d ) - f == d //false浮点数不满足结合律

程序的机器级表示

历史

从最早的16位处理器8086到现在的64位处理器x86-64

  • 8086(1978年 , 29 K个晶体管 )。 是 第 一代单芯片、16 位微处理器之一。8088是8086的一个变种,在8086上增加了一个8位外部总线,构成最初的IBM个人计算机的心脏。 BM与当时还不强大的微软签订合同,开发MS DOS 操作系统。最初的机器型号有32768 字节的内存和两个软驱 (没有硬盘驱动器)。 从体系结构上来说,这些机器只有655360字节的地址间 – 地址只有20位长(可寻址范围为1048576字节),而操作系统保留了393216字节自用。1980年 ,Intel提出了8087浮点协处理器 (45 K个晶体管),它与一个8086或8088处理器一同运行,执行浮点指令。8087 建立了x86 系列的浮点模型,通常被称为 “x87”
  • 80286(1982 年,134K个晶体管)。增加了更多的寻址模式(现在已经废弃了),构成了IBM PC- AT个人计算机的基础,这种计算机是 MS Windows 最初的使用平台。
  • i386 (1985 年,275K 个晶体管) 。将体系结构扩展到32位。增加了平坦寻址模式(flat addressingmodel),Linux 和最近版本的Windows 操作系统都是使用的这种模式。这是 Intel 系列中第 一台全面支持Unix操作系统的机器。
  • i486(1989 年,1.2M个晶体管)。改善了性能,同时将浮点单元集成到了处理器芯片上,但是指令集没有明显的改变。
  • Pentium(1993 年,3.1M个晶体管)。改善了性能,不过只对指令集进行了小的扩展
  • PentiumPro( 1995 年,5.5M个晶体管)。引人全新的处理器设计,在内部被称为P6 微体系结构。指令集中增加了一类“ 条件传送(conditional move)” 指令
  • Pentium/ MMX ( 1997年,4.5M个晶体管 )。 在 Pentium处理器中增加了一类新的处理整数向量的指令。每个数据大小可以是1、2或4字节。每个向量总长64位
  • Pentium II ( 1997 年 ,7M 个晶体管 )。 P6微体系结构的延伸
  • Pentium III (1999年,8.2M个晶体管)。引人了SSE,这是一类处理整数或浮点数向 量的指令。每个数据可以是1、2或4个字节,打包成128位的向量。由于芯片上包括了 二 级高速绥存,这种芯片后来的版本最多使用 了24M 个晶体管
  • Pentium4 ( 2000年 , 42M个晶体管 )。 SSE 扩展到了 SSE2,增加了新的数据类型 (包括双精度浮点数),以及针对这些格式的 144 条新指令。有了这些扩展,编译器可以使用 SSE 指令(而不是x87 指令),来编译浮点代码
  • Pentium 4E(2004年,125M个晶体管)。增加了超线程(hyperthreading),这种技术 可以在一个处理器上同时运行两个程序;还增加 了EM64T ,它是 Intel 对 AMD 提出的对 IA32的64 位扩展的实现,我们称之为x86-64。
  • Core2( 2006年 , 291M 个晶体管 )。 回归到类似于 P6 的微体系结构 。Intel的第一个多核微处理器,即多处理器实现在一个芯片上。但不支持超线程。
  • Core i7,Nehalem(2008年,781M个晶体管)。既支持超线程,也有多核,最初的版 本支持每个核上执行两个程序,每个芯片上最多四个核
  • Core i7, Sandy Bridge(2011年,1.17G 个晶体管)。引入了AVX,这是对SSE 的扩 展,支持把数据封装进 256 位的向量。
  • Corei7, Haswel(2013年,1.4G个晶体管)。将AVX扩展至AVX2,增加了更多的指令和指令格式.

机器级代码

程序优化性能

要写出编译器友好的程序来提升程序性能,比如下面两个函数

1
2
3
4
void test1(long *xp, long *yp) {
*xp += *yp;
*xp += *yp;
}
1
2
3
void test2(long *xp, long *yp) {
*xp += 2 * *yp;
}

对于test1来说,要执行读取xp,读取yp,写xp,再次读取xp,读取yp,写xp,总共4次读取,2次写入。

对于test2来说,要执行读取xp,读取yp,写xp,只需要2次读取,1次写入,当然性能更好。

而且考虑一种情况,就是*xp*yp指向同一个内存,如果这样的话,那么test1就变成了 xp = 4 * xp,test2就变成了 xp = 3 * xp,意思就不一样了,所以编译器没有办法把test1优化成test2的形式,这种叫做内存别名

在看一个指针的问题

1
2
3
4
x = 1000;y = 3000;
*q = y;
*p = x;
t1 = *q; // 1000 or 3000

如果*q*p是指向两个内存,那么t1就是3000,如果指向同一个内存,那么t1就是1000,因此编译器无法对这种代码进行优化。

消除循环的低效率

可以把循环中的多次计算或者过程调用等重复执行的,结果确定的,提取出来,只执行一次。

比如下面的代码,这个时候strlen这个函数会执行n次,A-a也会执行n次,他们的结果每次都是一样的,可以都提取出来。A-a由于是常数减法,编译器应该会优化,但是strlen是一个函数,函数的结果是不确定的,编译器会保守的保留它不进行优化。

1
2
3
4
5
6
7
void lower(char *s)
{
size_t i;
for (i = 0; i < strlen(s); i++)
if (s[i] >= 'A' && s[i] <= 'Z')
s[i] -= ('A' - 'a');
}

优化后的代码

1
2
3
4
5
6
7
8
void lower(char *s)
{
size_t i;
size_t len = strlen(s);
for (i = 0; i < len; i++)
if (s[i] >= 'A' && s[i] <= 'Z')
s[i] -= ('A' - 'a');
}

减少过程调用

有一些函数中会有一些不必要的操作,可以减少他们,比如数组函数,里面也许会有边界检查之类的,防止越界,但是如果我们的外部代码已经很明显的做了检查,或者说肯定不会越界,那么我们就可以省掉调用函数里面的这部分开销。

get_vec_element这个函数获取数组中的值,并且做了边界检查。这样会做n次边界检查,我们可以优化它。

1
2
3
4
5
6
7
8
9
10
11
void combine2(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
*dest = IDENT;
for (i=0; i<length; i++) {
data_t val;
get_vec_element (v, i, &val);
*dest = *dest OP val;
}
}

优化之后的代码,省了每次的边界检查。

1
2
3
4
5
6
7
8
9
10
void combine3(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
data_t *data =get_vec_start(v);
*dest = IDENT;
for (i=0; i<length; i++) {
*dest = *dest OP data[i];
}
}

消除不必要的内存引用

上面的代码还是有些问题,因为对于dest来说,每次都要经历load,计算,store的过程,这个过程要重复n次,可以通过增加局部变量来减少load和store的次数。

1
2
3
4
5
6
7
8
9
10
11
void combine4(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
data_t *data =get_vec_start(v);
data_t acc = IDENT;
for (i=0; i<length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}

循环展开

for循环每次循环都会进行if条件判断,计算机执行条件判断的时候是比较费时的,现在有了分支预测功能,但是预测失败的话,也会影响性能。可以通过循环展开来优化这里,来减少for循环的消耗。这样改造的程序,已经极为接近线性代码的功耗了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void combine5(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length - 1;
data_t *data =get_vec_start(v);
data_t acc = IDENT;
// 循环次数减半
for (i=0; i<limit; i+=2) {
acc = acc OP data[i] OP data[i+1];
}

// 循环不一定是2的倍数,这里处理剩下的元素
for(;i<length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}

结合律

对于满足结合律的运算,可以通过结合律来优化。对于上面的combine5函数来说,他的执行流程是这样的。因为每次乘的时候都要等上一个的结果,所以是线性的。

结合律1

使用结合律优化以后,执行路程是下面这样的,为啥它能并行呢,因为在执行左边acc * (i * i+1)结果的时候,就已经可以计算i+2 * i+3的结果了。这两个乘法并行了,所以缩短了执行时间。上面无法并行的原因是先计算了acc * i,当计算下一个acc * i的时候需要依赖上一次acc的结果。

结合律2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void combine6(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length - 1;
data_t *data =get_vec_start(v);
data_t acc = IDENT;
// 循环次数减半
for (i=0; i<limit; i+=2) {
// 唯一的区别的就是这里加了括号
acc = acc OP (data[i] OP data[i+1]);
}

// 循环不一定是2的倍数,这里处理剩下的元素
for(;i<length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}

多个累积变量

还可以通过先求出所有i为偶数的结果,和i为奇数的结果,在把两个结果结合到一起。这样的话先计算奇数和偶数,这两个是互不干涉的,也可以达到上面结合律的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void combine7(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length - 1;
data_t *data =get_vec_start(v);
data_t acc0 = IDENT;
data_t acc1 = IDENT;
// 循环次数减半
for (i=0; i<limit; i+=2) {
// 计算偶数和奇数
acc0 = acc0 OP data[i];
acc1 = acc1 OP data[i+1];
}

// 循环不一定是2的倍数,这里处理剩下的元素
for(;i<length; i++) {
acc0 = acc0 OP data[i];
}
*dest = acc0 OP acc1;
}

存储器层次结构

存储技术

存储技术分为易失存储非易失存储

  • 易失存储:DRAM 也就是一般所说的电脑内存,SRAM 也就是一般的cpu缓存,CPU的L1缓存,L2,L3这些
  • 非易失存储:机械硬盘,固态硬盘

机械硬盘

机械硬盘

最便宜的存储,存储容量也很大。由下面的部分组成

  • 主轴:盘片放在主轴上
  • 盘片:一个盘片有两个面可以进行存储
  • 磁道:每个盘面上有多个磁道
  • 扇区:每个磁道被划分为一组扇区
  • 磁头:通过磁头进行读写扇区上的数据

机械硬盘

所以在次盘上读写的时候,需要移动磁头,让磁头找到对应的盘面,在找到磁道和扇区,然后才能读写数据,所以顺序读写随机读写快的多。因为顺序的话磁头只需要移动一点点距离,而随机需要移动很长的距离。

固态硬盘

固态硬盘是基于闪存的存储技术,价格比机械硬盘贵一些。因为是闪存,所以顺序读写和随机读写都比机械硬盘快。虽然固态的顺序读写和随机读写的差距缩小了,但是顺序读写依旧比随机读写快。

  • 块:整个闪存分成一个个块,一个块有32-128个页,块的大小为16KB-512KB
  • 页:一个块有多个页组成,每次读写都是一个页,页的大小是512字节-4KB
  • 闪存翻译层:连接IO总线和闪存数据,将数据放到IO总线里面,也将IO总线的数据放到闪存里面。

固态每次写入需要先擦除一个页里面原来的内容,在写入新的,因此,擦除次数多了以后,这个页就会坏掉。为了提升固态的使用寿命,一般也会控制每次擦除寿命更高的页。

DRAM

DRAM

DRAM 也就是电脑内存,由下面几部分组成,价格比固态和机械硬盘贵的多,容量也小的多,现在大多数是16G内存。

  • n * n的存储单元:真正存储数据的地方
  • 行缓冲区:每次先将一行的数据取到缓冲区里面,在取对应的存储单元里面的数据。
  • addr引脚:发送行号和列号给DRAM,发送行号以后,将这一行数据取到行缓冲区里面,再发送列号以后,返回对应的数据
  • data引脚:传递数据

DRAM的发展历史,RAS:发送行号,CAS:发送列号

  • 快页模式DRAM(Fast Page Mode DRAM, FPMDRAM)。传统的DRAM将存储单元的一整行复制到它的内部行缓冲区中, 使用一个 ,然后丢弃剩余的。FPM DRAM允许对同一行连续地访问可以直接从行缓冲区得到服务,从而改进了这一 点。例如,要从一个传统的DRAM的行i中读4个存储单元,内存控制器必须发送4 个RAS/CAS请求,即使是行地址i在每个情况中都是一样的。要从一个FPM DRAM的同一行中读取存储单元,内存控制器发送第一个RAS/CAS请求,后面跟三个CAS请求。初始的RAS/ CAS请求将行i复制到行缓冲区,并返回CAS寻址的那个存储单元。接下来三个存储单元直接从行缓冲区获得,因此返回得更快。
  • 扩展数据输出 DRAM( Extended Data Out DRAM, EDO DRAM) . FPM DRAM 的一个增强的形式 ,它允许各个 CAS 信号在时间上靠得更紧密一点。
  • 同步DRAM(Synchronous DRAM,SDRAM)。就它们与内存控制器通信使用一组显式的控制信号来说,常规的FPM 和EDO DRAM 都是异步的。SDRAM用与驱动内存控制器相同的外部时钟信号的上升沿来代替许多这样的控制信号。我们不会深人讨论细节, 最终效果就是SDRAM 能够比那些异步的存储器更快地输出它的超单元的内容。
  • 双倍数据速率同步DRAM(Double Data-Rate SynchronousDRAM, DDR SDRAM)。 DDR SDRAM 是 对 SDRAM 的一种增强 ,它通过使用两个时钟沿作为控制信号,从而使DRAM的速度翻倍。不同类型的DDR SDRAM 是用提高有效带宽的很小的预取绥冲区的大小来划分的 : DDR (2位)、 DDR2 (4位)和 DDR (8位)。

现在一般的DDR5,DDR4,其实就是DDR DRAM内存。

SRAM

SRAM 也就是电脑CPU的L1,L2,L3三级缓存使用的,价格比DRAM更贵,容量更小。通常是多少KB或MB。

SRAM将每个位存储在一个双稳态的(bistable)存储器单元里。每个单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,它可以无限期地保持在两个不同的电压 配置(configuration )或状态(state) 之一。其他任何状态都是不稳定的一一从不稳定状态开始,电路会迅速地转移到两个稳定状态中的一个。

SRAM

局部性(locality)

局部性分为空间局部性(spatial locality)时间局部性(temporal locality)

从上面也能看出,DRAM一次加载一行到行缓冲区,如果我们要的数据都在这一行,取的时候就会很快。这就是空间局部性

比如数组的顺序访问。这个时候一个个接着访问的,就具有空间局部性。

1
2
3
4
a[100] = {0,0,0,...} 
for(int i = 0; i < 100; i++) {
printf("ai:%d",a[i]);
}

二维数组是一样的,需要按照行来访问。而不能按照列。

1
2
3
4
5
6
7
8
9
10
11
12
13
a[100][100] = {{0,0,0,...},{0,0,0,...},...} 
for(int i = 0; i < 100; i++) {
for(int j = 0; j < 100; j++) {
printf("aij:%d",a[i][j]);
}
}

// 按照列访问,不具有局部性,应该使用上面那种方式
for(int i = 0; i < 100; i++) {
for(int j = 0; j < 100; j++) {
printf("aij:%d",a[j][i]);
}
}

三维数组,也应该使用行访问。
{
{
{0,0,0,…}, // 000, 001, 002, 003
{0,0,0,…}, // 010, 011, 012, 013
{0,0,0,…},
{0,0,0,…}
},
{
{0,0,0,…}, // 100, 101, 102, 103
{0,0,0,…}, // 110, 111, 112, 113
{0,0,0,…},
{0,0,0,…}
},
{
{0,0,0,…},
{0,0,0,…},
{0,0,0,…},
{0,0,0,…}
},…
}

1
2
3
4
5
6
7
8
a[100][100] = {{{0,0,0,...},{0,0,0,...},{0,0,0,...},...},{{0,0,0,...},{0,0,0,...},{0,0,0,...},...},...} 
for(int i = 0; i < 100; i++) {
for(int j = 0; j < 100; j++) {
for (int k = 0; k < 100; k++) {
printf("aijk:%d",a[i][j][k]);
}
}
}

使用最近使用过的数据,就是时间局部性。这是因为缓存的原因,比如最近使用过的数据还在L1缓存中,就会比再去DRAM中取数据要快。

sum就有 时间局部性,一直在使用。

1
2
3
4
5
6
a[100] = {0,0,0,...} 
int sum = 0;
for(int i = 0; i < 100; i++) {
sum += a[i];
printf("ai:%d",a[i]);
}

存储器层次结构

存储器层次结构

通常,高层的可以作为低层的缓存,比如L1缓存L2的数据,L2缓存L3的,L3缓存内存的。数据以块为大小传输。

  • 缓存命中(cache hit):要查找的内容就在L1缓存中,就不需要去L2查找了
  • 缓存不命中(cache miss): 要查找的内容不在L1中,而在L2中,需要从L2传输到L1中。
    • 强制性不命中(compulsory miss): 在第一次访问缓存的时候,缓存中肯定是空的。这个时候肯定不会命中。
    • 冲突不命中(conflict miss): 缓存已经满了以后需要进行替换,比如LRU算法,比如取余算法,这个时候有可能你需要的缓存被替换出去了,因为这个原因不命中的,就是冲突不命中。
    • 容量不命中(capacity miss): 因为缓存不够大而没有命中的。

高速缓存存储器

像L1,L2这种SRAM,就是高速缓存存储器

假设每个存储器地址有m位,形成$M = 2^m$个不同的地址。它由 $S = 2^s$ 个set组成。每个set里面有 $E = 2^e $ 个缓存行。每个行有 $B = 2^b$字节的数据块组成。高速缓存的大小$C = S * E * B$(不包括Tag和Valid)

高速缓存存储器

每个数据块有

  • Valid: 1bit,0表示无效缓存,1表示有效缓存
  • Tag: t个bit,一些标记 t = m - (b + s)
  • Data: 真正的数据,有$B = 2^b$字节

高速缓存存储器 block

m个地址位的组成:

  • t个bit:Tag,用来匹配高速缓存中的Tag,如果匹配上了说明缓存命中。
  • s个bit:set index,用来找到对应的数据在哪个set里面。
  • b个bit:block offset,用来找到具体数据在block的哪个位置里面。

高速缓存存储器 addr

读取

假设 m = 6,s = 2,t = 1, b = 3, e = 1, 则 S = 4个set,E = 2个行,B = 8字节。大小C =4 * 2 * 8 = 64。

假设 当前m = 000001, 那么 tag = 0, bit set = 00, block offset = 001。

缓存命中的情况。总共4个set,找到第0个set,然后找到其中tag是0并且valid是1的一个block,找到以后,找 001位置的数据,然后返回就可以了。

1
2
3
4
5
6
7
8
9
10
    valid   tag     data
00 1 0 ... // 首先找到set 00 的这个set,有两行,这一行
00 0 0 ... // 还有这一行
01 0 0 ...
01 0 0 ...
10 1 0 ...
10 1 0 ...
11 0 0 ...
11 0 0 ...

高速缓存存储器 addr

缓存不命中的情况。总共4个set,找到第2个set,然后找到其中tag是0并且valid是1的,这个时候发现没有匹配的,那么触发miss,开始去下一层获取数据,获取到数据以后,通过替换算法替换掉一个行,将tag修改为0并且valid修改为1,然后把数据写入block data。再返回001位置的数据。

1
2
3
4
5
6
7
8
9
10
    valid   tag     data
00 1 0 ...
00 0 0 ...
01 0 0 ... // 首先找到set 01 的这个set,有两行,这一行
01 0 0 ... // 还有这一行
10 1 0 ...
10 1 0 ...
11 0 0 ...
11 0 0 ...

冲突不命中,比如计算两个数组的点积。这个时候a[0] * b[0],会先把数组a加载到L1缓存,然后再把b加载到L1缓存,这个时候b会把a覆盖,然后a[1] * b[1]的时候,a就会冲突不命中,然后用a把b覆盖,一直重复这个动作。这个时候可以增加E,比如一个set里面有多个block,这样就不会覆盖了。

1
2
3
4
5
6
7
8
9
float dotProd(float a[100], float b[100]) {
float sum = 0.0;
int i;
for (i = 0; i < 100; i++) {
sum += a[i] * b[i];
}
return sum;
}

写入

在写入的时候有两种情况,缓存命中和不命中,通常的搭配是write through + no write allocatewrite back + write allocate

  • 缓存命中
    • wirte through: 这个时候直接写入缓存。并且修改下一层的数据。相当于同步修改。
    • write back: 直接写入缓存,并且标记dirty, 尽可能的推迟更新下一层的数据,只有当替换算法执行的时候才更新。相当于异步修改。
  • 缓存不命中
    • write allocate: 先把数据从下一层加载到缓存,然后更新缓存数据。
    • no write allocate: 直接写入下一层数据。

链接

c文件变成可执行文件的过程

  • c预处理器cpp生成ASCII码的.i文件, cpp main.c main.i
  • c编译器cc1生成ASCII汇编语言.s文件, cc1 main.i -Og -o main.s
  • 汇编器as生成可重定位目标文件.o文件, as -o main.o main.s
  • 链接器ld生成可执行文件, ld -o main main.o

为了构造可执行文件,链接器必须完成两个主要任务

  1. 符号解析。目标文件定义和引用符号,每个符号对应于一个函数 、一个全局变量或一个静态变量 (即C语言中任何以 static 属性声明的变量)。 符号解析的目的是将每个符号引用正好和一个符号定义关联起来
  2. 重定位。编译器和汇编器生成从地址。开始的代码和数据节。链接器通 过把每个符号定义与 一个内存位置关联起来,从而重定位这些节,然后修改所有对 这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条 目(relocation entry)的详细指令,不加甄别地执行这样的重定位。

目标文件有三种形式

  • 可重定位目标文件。包含二进制和代码数据,可与其他重定位目标文件合并起来变成可执行目标文件。
  • 可执行目标文件。包含二进制和代码数据,可被直接复制到内存并执行。
  • 共享目标文件。一种特殊类型的可重定位目标文件,可在加载或运行时被动态的加载进内存并链接。

ELF(x86-64的Linux系统的文件)可重定位目标文件的格式

  • ELF头。
  • .text。已编译程序机器代码
  • .rodata。只读数据,比如printf语句中的格式串
  • .data。已初始化的全局和静态变量。
  • .bss。未初始化的全局和静态变量,以及所有初始化为0的全局和静态变量。不占据实际空间,仅仅是一个占位符。
  • .symtab。一个符号表,用来存放 在程序中定义和引用的函数和全局变量的信息。
  • .rel.text。一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修 改。另 一方面,调用本地函数的指令则不需要修改
  • .rel.data。被模块引用或定义的所有全局变量的重定位信息。
  • .debug。一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定 义和引用的全局变量,以及原始的C源文件。只有以 -g选项调用编译器驱动程序时,才会得到这个表
  • .line。原始C源程序中的行号和.text 节中机器指令之间的映射。只有以-g选项调 用编译器驱动程序时,才会得到这张表
  • .strtab。一个字符串表,包括.symtab和.debug中的符号表,以及节头 部中的节名字。字符串表就是以nu 11 结尾的字符串的序列。

每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:

  • 由模块m定义的并能被其他模块引用的全局符号。对应非静态的C函数和全局变量。
  • 由其他模块定义的并被m引用的全局符号。对应其他模块的非静态的C函数和全局变量。
  • 由模块m定义和引用的局部符号。对应于静态的C函数和全局变量。

C++ 和Java 都允许重载方法,这些方法在源代码中有相同的名字,却有不同的参数 列表。那么链接器是如何区别这些不同的重载函数之间的差异呢?C++ 和Java 中能使用 重载函数,是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一 的名字。这种编码过程叫做重整(mangling),而相反的过程叫做恢复(demangling)。 幸运的是,C++ 和Java使用兼容的重整策略。一个被重整的类名字是由名字中字符 的整数数量,后面跟原始名字组成的。比如,类Foo 被编码成3Foo。方法被编码为原始方法名, 后面加上 __ , 加上被重整的类名, 再加上每个参数的单字母编码。 比如,Foo::bar (int, long)被编码为bar__3Fooil。重整全局变量和模板名字的策略是相似的。

链接器对多个文件中同名变量或函数的处理。编译器会先分强符号弱符号,强符号指函数和已初始化的全局变量,弱符号指未初始化的全局变量。

  1. 不允许有多个同名的强符号
  2. 如果有一个强符号和多个弱符号同名,那么选择强符号。
  3. 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。

上面说的链接器接受一组可重定位文件,并生成一个可执行文件,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库。它也可以用作链接器的输入。当构造可执行文件的时候,直接复制静态库里面被应用程序引用的目标模块。静态库是.a文件。

可执行目标文件的格式

  • ELF头。
  • 段头部表
  • .init。定义了一个函_init,程序的初始化代码会调用它
  • .text。已编译程序机器代码
  • .rodata。只读数据,比如printf语句中的格式串
  • .data。已初始化的全局和静态变量。
  • .bss。未初始化的全局和静态变量,以及所有初始化为0的全局和静态变量。不占据实际空间,仅仅是一个占位符。
  • .symtab。一个符号表,用来存放 在程序中定义和引用的函数和全局变量的信息。
  • .debug。一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定 义和引用的全局变量,以及原始的C源文件。只有以 -g选项调用编译器驱动程序时,才会得到这个表
  • .line。原始C源程序中的行号和.text 节中机器指令之间的映射。只有以-g选项调 用编译器驱动程序时,才会得到这张表
  • .strtab。一个字符串表,包括.symtab和.debug中的符号表,以及节头 部中的节名字。字符串表就是以nu 11 结尾的字符串的序列。

动态库也叫共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个 目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。在linux中常用.so后缀来表示。 微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)

共享库是以两种不同的方式来共享的。首先,在任何给定的文件系统中 ,对于一个库只有一个 .so 文件 。 所有引用该库的可执行目标文件共享这个.so文 件中的代码和数据 ,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中

共享库和Java本地接口 Java定义了一个标准调用规则,叫做Java本地接口(Java Native Interface, JNI),它允许Java程序调用“本地的” C和C++ 函数。JNI的基本思想是将本地C函数(如Foo)编译 到一个共享库中 (如 Foo.so )。 当一个正在运行的Java 程序试图调用函数 Foo 时 ,Java解释器 利用dlopen 接口 (或者与其类似的接口)动态链接和加载 Foo.so,然后再调用Foo。

在 Linux 系统中有大量可用的工具可以帮助你理解和处理目标文件。特别地,GNU binutils 包尤其有帮助,而且可以运行在每个Linux 平台上。

  • AR:创建静态库,插人、删除、列出和提取成员。
  • STRINGS:列出一个目标文件中所有可打印的字符串。
  • STRIP:从目标文件中删除符号表信息。
  • NM:列出一个目标文件的符号表中定义的符号。
  • SIZE:列出目标文件中节的名字和大小。
  • READELF:显示 一个目标文件的完整结构,包括ELF 头中编码的所有信息。包含 SIZE 和NM的功能。
  • OBJDUMP:所有二进制工具之母。能够显示 一个目标文件中所有的信息。它最大的作用是反汇编.text节中的二进制指令。

Linux 系统为操作共享库还提供了LDD程序:

  • LDD:列出一个可执行文件在运行时所需要的共享库。

异常控制流

异常控制流(ExceptionalControlFlow, ECF)使程序能进行跳转,正常的程序执行都是PC里面一条条指令顺序执行的。ECF使PC里面的下一条指令变成别的指令,从而完成别的功能。ECF在系统的各个层次,比如进程切换,操作系统调用,网络请求处理,IO请求处理,是计算机中实现并发的基本机制。

异常

异常是异常控制流的一种形式,它 一部分由硬件实现,一部分由操作系统实现。

假设当前程序正在运行,出现了缺页异常,也就是数据在内存中不存在,需要进行IO请求将数据加载到内存中。执行图如下,异常处理程序有很多,每个异常都有对应的处理程序,当异常发生以后,系统切换到异常处理程序中执行。异常处理完成以后返回用户程序继续执行。

异常

根据异常的不同,返回会发生三种情况:

  • 异常处理程序将控制返回给当前用户程序正在执行的指令
  • 异常处理程序将控制返回给当前用户程序的下一个指令
  • 异常处理程序终止当前用户程序

这里说的都是硬件异常,和Java中try catch的软件异常是不一样的。

系统中可能的每种类型的异常都分配了一个唯 一的非负整数的异常号。有的异常号是处理器的设计者分配的,有的是操作系统的设计者分配的。前者包括被零除,缺页,断点,算术运算溢出,后者包括系统调用和外部IO设备的信号。

在系统启动时,操作系统会分配一个异常表。异常表的key就是异常号,value是异常处理程序的地址。当发生异常的时候,通过异常号找到异常处理地址,然后进行处理。异常表的首地址则放在异常表基址寄存器里面。硬件触发异常以后,就由异常处理程序软件进行执行了,执行在内核模式下。

异常可以分为4类,中断(interrupt),陷阱(trap),故障(fault)和终止(abort)

类别 原因 异步/同步 返回
中断 来自I/O 设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不 可恢复的错误 同步 不返回,直接终止程序

中断是异步发生的,中断发生的时候拉起中断引脚。处理器发现中断引脚被拉起,就从系统总线读取异常号,然后执行对应的异常处理程序。执行完成以后继续返回执行下一条指令。

陷阱是有意的异常,最重要的作用是触发系统调用,正常用户程序都是运行在用户模式下,可以执行的功能有限,如果需要读取文件,创建进程等操作,就需要切换到内核模式下,由操作系统内核来进行处理。通过系统调用就可以切换到内核执行。

故障由错误情况引起,它可能能够被故障处理程序修正。比如缺页异常就是一个故障。当发生以后,故障处理程序进行处理,从磁盘加载数据,加载以后重新执行当前指令,就会成功了。如果故障没有被修正,那么就会转为abort,终止当前程序。

终止是不可恢复的致命错误造成的结果,通常是一些硬件错误。

x86-64系统定义了多达256种不同的异常类型,0-31号是由Inter架构师定义的,因此所有x86-64系统的电脑都是一样的。32-255对应的是操作系统定义的中断和陷阱。

下面是一些系统调用:

系统调用

进程

进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合.

进程提供给应用程序的关键抽象

  • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占处理器
  • 一个私有地址空间,它提供一个假象,好像我们的程序独占内存

/proc文件系统将许多内核数据结构的内容输出为 一个用户程序可以读的文 本文件的层次结构。比如,你可以使用/proc 文件系统找出一般的系统属性,比如CPU类型 (/proc/cpuinfo),或者某个特 的进程使用的内存段(/proc//maps)。2.6 版本的Linux内核引人/sys 文件系统,它输出关于系统总线和设备的额外的低层信息

操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。

上下文切换

  • 保存当前进程的上下文
  • 恢复要执行的进程的上下文
  • 将控制传递给这个新恢复的进程

当Unix 系统级函数遇到错误时,它们通常会返回一1,并设置全局整数变量errno 来表示什么出错了.比如

1
2
3
4
5
if ((pid = fork()) < 0) {
// strerror函数返回一个文本串,描述了和某个errno值相关联的错误
fprintf(stderr, "fork error: %s \n", strerror(errno));
exit(0);
}

一些进程控制的系统调用C函数

  • getpid(), 获取当前进程的pid
  • getppid(), 获取当前进程的父进程的pid
  • waitpid(), 等待子进程终止
  • sleep(), 让程序休眠,时间到啦返回0,时间没到被其他信号中断则返回剩余秒数
  • pause(), 让程序休眠直到收到信号
  • execve(), 在当前进程的上下文中加载并运行一个新程序
  • getpgrp(), 返回当前进程的进程组id
  • signal(), 设置信号处理函数

从程序员的角度,可以认为进程总是处于下面三个状态之一

  • 运行:进程正在CPU上执行
  • 阻塞:进程被挂起,比如读取磁盘的时候
  • 终止:进程结束了,收到终止信号,主程序执行完,调用exit函数

当一个子进程终止时,内核并不会立即清除它,进程会被保持为一种已终止的状态中,直到被父进程回收。这时候的子进程被称为僵尸进程。当父进程回收子进程后,内核将子进程的退出状态传递给父进程,然后清除子进程,这个时候子进程就不存在了。

如果一个父进程终止。它的子进程就被称为孤儿进程,内核会安排init进程成为它的孤儿进程的养父,init进程的pid为1,是在系统启动时由内核创建的,它是所有进程的祖先进程。如果父进程没有回收僵尸进程就死了,init进程会回收僵尸进程,不过长时间运行的程序比如shell或者服务器,总是应该回收它们的僵尸子进程,即使僵尸子进程没有运行,他们仍然消耗系统的内存资源。

一个进程可以通过waitpid函数来等待它的子进程终止或者停止。如果子进程已经终止,那么立即返回,如果子进程没有终止,挂起当前进程,等待子进程终止后返回,返回值为子进程的pid。此时,子进程已经被回收,内核会删除掉它的所有痕迹。

进程的执行顺序和回收顺序都是由操作系统内核通过异常控制流切换执行的,所以我们不能假设他们的执行顺序和回收顺序。

信号

Linux信号允许进程和内核中断其他进程。一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每个信号类型都对应一个系统事件,底层的硬件异常由内核异常处理程序处理的,正常情况下,对用户进程是不可见的。

下图是一些信号

系统调用

传送信号分为两部分

  1. 发送信号。内核通过更新进程上下文中的某个状态,发送一个信号,可能有下面的原因
    • 内核检测到一个系统事件,比如除0或者子程序终止
    • 一个进程调用了kill函数,显式的发送一个信号
  2. 接收信号。当进程被内核强迫以某种方式对信号作出反应时,就接收了信号。进程可以忽略这个信号,终止或者通过执行信号处理程序来处理信号。
  3. 一个发出但没有被接收的信号叫做待处理信号。在任何时刻,一种类型至多只有一个待处理信号。

发送信号的几种方式

  • kill程序,kill -9 pid
  • kill函数,kill(pid, sig)
  • alarm函数,给自己发送SIGALRM信号
  • 键盘,ctrl+c会发送SIGINT信号,默认终止前台作业,ctrl+z会发送SIGTSTP信号,默认挂起前台作业。

前台作业和后台作业,系统只能有一个前台作业,但是可以有多个后台作业,前台作业就是通过waitpid等待程序在前台完成的,后台作业是运行在后台的,一般在命令最后面加上&就可以让程序后台运行。

接收信号的几种处理

  1. 忽略该信号
  2. 进程终止
  3. 用户程序捕获信号并处理

可以通过signal函数来捕获信号并处理,第一个参数是信号,第二个参数是处理函数,如果处理函数是SIG_IGN,则忽略该信号,如果是SIG_DFL,则采用信号的默认行为处理,如果是一个函数,则执行他进行处理。

待处理的信号只能有一个。再过来一个同类型的信号就会被丢弃,实际上处理方式是有一个Pending的位,如果有6个信号,Pending就有6位,一个信号对应一位,当有一个待处理信号的时候,相应的位就变成1,所以最多只有一个待处理,剩下的会被丢弃。所以我们不能假设所有的信号都能被接收并且处理。

为了解决上面的问题,就出现了阻塞信号。通过阻塞信号可以暂时阻塞住信号,不接收它,等我们把待处理信号处理了再解除阻塞然后接受它,这样的话就可以接收到每一个信号并处理了,同样的阻塞信号是通过Blocked位来实现的,有6个信号那么Blocked就有6位,阻塞一个信号,对应的位就变成1.

可以通过sigprocmask函数来设置Blocked位,他有三个参数,第一个参数决定了行为,第二个参数是一个set变量用来设置Blocked位,第三个参数是返回一个oldset变量来存储以前的Blocked位,当解除阻塞以后用来恢复Bloked位的。

第一个参数值

  • SIG_BLOCK: 把set中的位添加到Bloked中,相当于Blocked = Blokced | set
  • SIG_UNBLOCK: 从Blocked中删除set中的位,相当于 Blocked = Blocked & ~set
  • SIG_SETMASK: Blocked = set

使用下面的函数可以操作set变量中的位。

  • sigemptyset。初始化set为空集合
  • sigfillset. 把每个信号都添加到set中
  • sigaddset. 添加某个信号到set中
  • sigdelset. 从set中删除某个信号
  • sigismember. 判断某个信号是否在set中。

信号处理程序的编写很麻烦,因为它和其他信号处理程序以及主程序都是并发执行的。为了保证安全,要尽可能的保守,以下是一些基本原则。

  • 处理程序要尽可能简单。避免麻烦的最好方法是保持处理程序尽可能小和简 单。例如,处理程序可能只是简单地设置全局标志并立即返回;所有与接收信号相 关的处理都由主程序执行,它周期性地检查(并重置)这个标志
  • 在处理程序中只调用异步信号安全的函数。所谓异步信号安全的函数(或简称安全的函 数)能够被信号处理程序安全地调用,原因有二:要么它是可重入的, 要么它不能被信号处理程序中断.
  • 信号处理程序中产生输出唯一安全的方法是使用 write 函数
  • 保存和恢复errno。许多Linux异步信号安全的函数都会在出错返回时设置 errno 。
  • 阻塞所有的信号,保护对共享全局数据结构的访问
  • 用 volatile 声明全局变量 。
  • sig_atomic_t 声明标志。在常见的处理程序设计中,处理程序会写全局标 志来记录收到了信号。主程序周期性地读这个标志,响应信号,再清除该标志。对于通过这种方式来共享的标志 , C 提供一种整型数据类型 sig_atomic_t, 对它的读和写保证会是原子的(不可中断的),因为可以用一条指令来实现它们

非本地跳转

当程序返回的时候一般都是通过调用栈一层层返回,有的时候会很麻烦,为了避免这种情况,出现了非本地跳转,它通过setjmplongjmp来进行直接跳转,避免了一层层返回。

setjmp保存当前的运行环境,longjmp可以跳回setjmp的地方。

C++和Java 提供的异常机制是较高层次的,是C语言的setjmp和longjmp 函数的更加结构化的版本。你可以把try语句中的catch 子句看做类似于setjmp函数。相似地,throw语句就类似于longjmp 函数。

系统级IO

在Linux中,所有的一切都是文件。所以文件读取可以控制一切,包括磁盘读写,网络编程都是通过文件IO来控制的。一个Linux文件就是一个m个字节的序列。

Unix IO接口提供了对文件的控制

  • 打开文件。返回一个文件描述符
  • 读取文件。读操作就是将文件内容复制到内存中。
  • 写文件。写操作就是将内存中的内容写入到文件中。
  • 改变当前的文件读写指针位置。打开文件的时候,初始为0.
  • 关闭文件。关闭这个文件。

Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)

每个文件都有一个类型

  • 普通文件:包含任意数据,操作系统并不区分文本文件和二进制文件,在更高层的级别有的会区分
    • 文本文件: 只含有ASCII和 Unicode 字符的普通文件。
    • 二进制文件: 所有其他的普通文件,对于内核来说,二进制和文本文件没有区别。
  • 目录:包含一组链接的文件,其中每个链接都将一个文件名映射到一个文件,这个文件也可能是另一个目录,每个目录至少含有两个条目
    • .是该目录自身的链接
    • ..是父目录的链接
  • socket:是用来与另一个进程进行跨网络通信的文件

Linux将所有文件都组织成一个目录结构,最上面是/根目录,其他所有目录都是它的子级。

目录结构

打开文件以后返回的文件描述符总是在进程中当前没有打开的最小描述符。通过系统的limit命令可以看到当前系统可以同时打开的文件数量。

Unix对文件的读取和写入操作可能会遇到不足值(short count)的问题。比如你要读取一个文件的前100个字节,但是这个文件每行50个字节,所以51个字节就是换行符EOL,换行符在Linux/mac里面是LF,在win里面是CRLF。当遇到换行的时候只会读入换行前的50个字节,然后读取换行的时候会读取0个字节,再之后才能读取下一行。下面是几种会遇到不足值的情况。

  • 读的时候遇到换行
  • 从终端读文本。从终端读取的话,read一次读取一行
  • 读写socket

RIO包

RIO包解决了不足值的问题,它提供了两类函数

  • 无缓冲输入输出:没有应用级缓冲,对二进制数据读写到网络和从网络读写二进制数据尤其有用
  • 有缓冲输入输出:先读取或写入到缓冲区里面,在调用系统的read/write来读写。效率更高,开销小,RIO还是线程安全的。

无缓冲输入输出

  • rio_readn(int fd, void * usrbuf, size_t n):从fd文件描述符中读取n个字节到内存usrbuf中
  • rio_writen(int fd, void * usrbuf, size_t n):从内存usrbuf中写入n个字节到fd中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ssize_t rio_readn(int fd, void *usbuf, size_t n) {
size_t nleft = n;
ssize_t nread;
char *bufp =usrbuf;

// 没读取够需要的n个字节就一直循环读取
while (nleft > 0) {
// 从fd中读取nleft个字节到内存bufp中
if ((nread = read(fd, bufp, nleft)) ‹ 0) {
// 如果读取失败 判断失败类型如果是被信号打断则再次读取,如果是其他失败,则返回失败
if (errno = EINTR) /* Interrupted by sig handler return */
nread =0; /*andcall read()again*/
else
return -1; /* errno set by read() */
} else if (nread = 0)
// 如果是读取到0个,则是读取到换行符,继续读取
break; /* EOF */
// 读取的字节数变少,bufp增加
nleft -= nread;
bufp += nread;
}
// 返回读取到的字节数
return n - nleft; /* Return >= 0 */
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ssize_t rio_writen(int fd, void *usrbuf, size_t n)
{
size_t nleft =n;
ssize_t nwritten;
char *bufp =usrbuf;
while (nleft >0) {

if ((nwritten =write(fd, bufp, nleft)) « 0) {

if (errno = EINTR) /* Interrupted by sig handler return */
written = 0; /* and call write() again */
else
return -1; /* errno set by write() */
}
nleft -= nwritten;
bufp += nwritten;
}
return n;
}

有缓冲输入输出

  • rio_readinitb(rio_t *rp, int fd): 初始化一个读取的缓冲区rp,读取的时候会先从fd文件中读取数据到rp缓冲区里面,然后在从缓冲区读取
  • rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen): 从缓冲区rp中读取一个文本行到内存usrbuf中,最多maxlen-1个字节,最后以NULL结尾。如果一行不足maxlen-1,则读取一整行,如果超过maxlen-1,就截断他
  • rio_readnb(rio_t *rp, void *usrbuf, size_t n): 对于既包含文本行也包含二进制数据的读取可以使用这个函数,它是rio_readn带缓冲区的版本。

下面是缓冲区rio_t结构体

1
2
3
4
5
6
typedef struct {
int rio_fd; /* 文件描述符fd */
int rio_cnt; /* 缓冲区未读取的字节数 */
char *rio_bufptr; /* 下一个要读取的指针 */
char rio_buf [RIO_BUFSIZE]; /* 缓冲区 */
} rio_t;
1
2
3
4
5
void rio_readinitb(rio_t *rp, int fd) {
rp->rio_fd =fd;
rp->rio_cnt = 0;
rp->rio_bufptr = rp->rio_buf;
}

缓冲区rio_t结构体

读取最核心的是rio_read函数

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
static ssize_t rio_read(rio_t *rp , char *usrbuf, size_t n) {
int cnt;

// 开始读取数据到缓冲区rp
while (rp->rio_cnt <= 0) { /* Refill if buf is empty */
// 调用 read读取
rp->rio_cnt = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf));
// 判断是否读取失败
if (rp->rio_cnt < 0) {
// 如果不是被信号打断,就是真的失败,返回-1 如果是被打断的话回到while继续读取
if (errno != EINTR)
return -1;
} else if (rp->rio_cnt == 0) {
// 读取到换行符,继续读取
return 0;
} else {
// 读取成功 缓冲区指针指向缓冲区开头
rp->rio_bufptr = rp->rio_buf; /* Reset buffer ptr */
}
}

// 开始冲缓冲区读取数据到usrbuf
/* Copy min(n, rp-›rio_cnt) bytes from internal buf to user buf */
// 默认读取n个字节,如果缓冲区不足n个,就读取整个缓冲区
cnt = n;
if (rp->rio_cnt < n) {
cnt = rp->rio_cnt;
}
// 从缓冲区读取到usrbuf
memcpy(usrbuf, rp->rio_bufptr, cnt);
// 修改缓冲区指针
rp->rio_bufptr += cnt;
// 修改缓冲区未读字节数量
rp->rio_cnt -= cnt;
// 返回读取的字节数
return cnt;
}

rio_readlineb和rio_readnb都是用了rio_read函数

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
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) {
int n,rc;
char c, *bufp = usrbuf;

// 循环从缓冲区读取
for (n = 1; n < maxlen; n++) {
// 从缓冲区读取1个字节到c里面 如果rc == 1则读取成功
if ((rc = rio_read(rp, &c, 1)) == 1) {
// 读取成功把读取到的字节放到bufp指向的usrbuf位置 然后bufp指向下一个usrbuf字节
*bufp++ = c;
// 如果读取到的是换行 n++再次读取
if (c == '\n') {
n++;
break;
}
} else if (rc == 0) {
// 读取EOF
if (n == 1)
return 0;
else
break;
} else {
// 出错,返回-1表示错误
return -1;
}
}
// 读取结束以后重制bufp指针
*bufp = 0;
// 返回 读取字节数
return n - 1;
}

和上面的rio_readn唯一的区别就是read换成了rio_read,因此里面也不需要在判断是否被信号打断了,因为rio_read已经判断了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n) {
size_t nleft = n;
ssize_t nread;
char *bufp =usrbuf;

// 没读取够需要的n个字节就一直循环读取
while (nleft > 0) {
// 从fd中读取nleft个字节到内存bufp中
if ((nread = rio_read(fd, bufp, nleft)) ‹ 0) {
return -1; /* errno set by read() */
} else if (nread = 0)
// 如果是读取到0个,则是读取到换行符,继续读取
break; /* EOF */
// 读取的字节数变少,bufp增加
nleft -= nread;
bufp += nread;
}
// 返回读取到的字节数
return n - nleft; /* Return >= 0 */
}

文件元数据

有两个函数可以获取文件元数据

  • stat(const char *filename, struct stat *buf): 第一个参数是文件名,第二个参数是文件元数据的结构体。
  • fstat(int fd, struct stat *buf): 第一个参数是文件描述符,第二个参数是文件元数据的结构体。

文件元数据结构体

文件元数据结构体

共享文件和文件重定向

内核用三个数据结构来表示打开的文件

  • 描述符表(descriptor table): 每个进程都有独立的描述符表,里面每个表项是一个指针,指向一个打开文件表,每个进程一开始的描述符表都有三个指针,指向stdin,stdout,stderr
  • 打开文件表(open file table): 所有进程共享的打开文件表,记录了所有打开的文件,记录了当前的文件位置,引用计数,指向v-node表的指针。调用close关闭文件会减少引用计数,当达到0,内核才会删除这个表项。
  • v-node表(v-node table):所有进程共享v-node表,记录了stat结构中的大部分信息

共享文件

1
注意:当fork的时候,子进程会继承父进程的描述符表,打开文件表的引用计数会+1,这个时候想彻底关闭文件,需要两个进程都close才行。

共享文件

可以使用dup2函数来实现IO重定向,也就是Linux里面的>功能,在linux中,cat a.c > b.c可以把cat a.c的输出内容重定向输入到b.c文件中。

  • dup2(int oldfd, int newfd): 复制描述符表oldfd到newfd,覆盖newfd以前的内容,如果newfd已经打开,会先关闭它再复制。

执行dup2(4,1)的意思是复制描述符fd4的内容给fd1,覆盖fd1原来的内容,假设原来fd1指向标准输出,复制以后fd1的输出将不再输出到标准输出,而是输出到fd4指向的打开文件里面。也就是将fd1的输出重定向到了fd4

共享文件

C程序还提供了标准IO库,和RIO一样对Unix IO进行了封装,不过标准IO不适合网络读写,所以网络读写应该使用RIO,其他情况都应该使用标准IO。

虚拟内存

MMU(Memory Management Unit)来进行虚拟地址到物理地址的转化。

MMU

虚拟内存也像磁盘一样,划分为一个个块,虚拟内存里面一个块叫做一页,虚拟页面,物理内存同样分成一个个页面,称为物理页面。

在任意时刻,虚拟页面的集合都分为三个不相交的子集:

  • 未分配的:还没有分配出去的虚拟页面,不占用任何磁盘空间和内存
  • 缓存的:已经分配出去且加载到物理内存中的页面
  • 未缓存的:已经分配出去,但是还在磁盘里面,没有加载到物理内存中的页面

因为主存不命中的话需要到磁盘去加载数据,这样会很慢,所以主页页面通常比较大,在4KB - 2MB,由于大的不命中处罚,所以主存是全相连的。任何虚拟页都可以放在任何物理页中。因为对磁盘的访问时间很长,所以主存总是用写回法,而不是直写法

页表(page table)存储了虚拟页是否加载到物理页中,以及物理页的地址,页表中的每一项叫做页表条目(Page Table Entry),简称PTE

下图展示了虚拟页VP1,VP2,VP4,VP7已经加载到了物理内存中,对应的PTE里面的有效位是1,PTE里面还记录了对应的物理页面地址。VP0和VP5则处于未分配状态。剩下的VP3,VP6则在磁盘里面。

page table

命中,如果通过虚拟页找到对应的PTE,发现valid == 1,就可以直接取出物理地址,去物理内存中获取对应的信息。

page table

不命中,如果通过虚拟页找到对应的PTE,发现valid == 0,那么需要进入缺页异常处理程序,先选择出一个victim page,如果victim page有修改,需要写回磁盘,然后把新的页面装入物理内存。当缺页异常处理完毕以后,返回程序继续处理,进入命中流程。

page table

如果程序有好的局部性的话,虚拟内存将工作的很好,不命中会很少,因为都集中在局部性这几页中,如果程序局部性不好,可能就会发生抖动,虚拟内存不断的换入换出。

你可以利用Linux的getrusage函数监测缺页的数量 (以及许多其他的信息)

页表中还有一些位来表示权限,SUP位为1表示只有内核可以访问这个PTE,用户程序不可以,READ则表示可以读取这个PTE的内容,WRITE表示可以写入这个PTE的内容。如果违反了这些,会触发异常段错误(segmentation fault)

地址翻译

每个进程有自己的页表,当前进程页表的起始地址存放在页表基址寄存器PTBR中,n位的虚拟地址包含两个部分,一个p位的虚拟页面偏移VPO(Virtual Page Offset)和一个n-p位的虚拟页号VPN(Virtual Page Number)

page table

  1. MMU根据虚拟地址的VPN页表中获取到对应的PTE
  2. 如果命中,就把PTE里面存储的PPN和虚拟地址的VPO拼接起来成为物理地址,因为物理页面和虚拟页面都是p字节的,所以VPO等于PPO,可以把VPO直接拿来用。
  3. 根据物理地址去cache获取数据

page table

由于每次地址翻译都需要获取PTE,如果PTE不在cache中还需要去主存获取,所以速度会下降,在MMU中添加了一个翻译后备缓冲器TLB(Translation Lookaside Buffer)来加快速度,TLB中缓存了页表的数据,从TLB中获取页表的速度要比cache中获取更快。在虚拟页号VPN中,再次被分为了两部分,如果TLB有T = 2^t个组,两部分分别是t位的TLB索引(TLBI)和n-p-t位的TLB的tag TLBT

page table

TLB的执行步骤

  1. MMU根据虚拟地址中的TLBITLB中取到对应的PTE
    • 如果没有命中,则从cache获取PTE
  2. 如果命中,就把PTE里面存储的PPN和虚拟地址的VPO拼接起来成为物理地址,因为物理页面和虚拟页面都是p字节的,所以VPO等于PPO,可以把VPO直接拿来用。
  3. 根据物理地址去cache获取数据

一个页表装载现在所有的内存地址,可能会很大,甚至比整个内存都要大,因此,可以使用多级页表的方式来组织页表。一级页表里面存放的是对应的二级页表的地址,以此类推。只有最后一个页表中包含的是物理页面号PPN

  • 多级页表可以大大减少一级页表的大小,虚拟地址空间大部分都是未分配的,而未分配的一级页表项则不存在对应的二级页表项。
  • 只有一级页表才需要总是在主存中,虚拟内存系统可以在需要时创建,页面调入或调出二级页表,这就减少了主存的压力,只有最经常使用的二级页表才需要缓存在主存中。

page table

动态内存分配

在内存的中动态的分配内存,内核维护着一个变量brk,指向堆的顶部。分配器将堆视为一组不同大小大的块的集合,每个块就是连续的虚拟内存片,要么是已分配的,要么是空闲的。

分配器有两种

  • 显式:比如C中通过malloc来分配,通过free来释放
  • 隐式:比如java中通过垃圾收集器gc来自动释放

显式分配器必须在一些相当严格的约束条件下工作

  • 处理任意的malloc和free请求序列。
  • 立即响应请求
  • 只使用堆
  • 对齐块(对齐要求)
  • 不修改已分配的块

在这些限制条件下,试图实现吞吐率最大化和内存使用率最大化。

一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑以下几个问题:

  • 空闲块组织:字节数组?链表?双向链表?分离链表?
  • 放置:如何选择一个合适的空闲块放置新分配的块,最先适配?最优适配?最坏适配?
  • 分割:在将新块放置到某个空闲块之后,如何处理这个空闲块的剩余部分
  • 合并:如何处理一个刚别释放的块,在free的时候是立即合并?还是延迟合并?

隐式空闲链表

将空闲块大小信息放入空闲块头部,返回指针的时候返回指向payload的指针。如果是8字节对齐的,那么size的最低3位总是0,可以用来存放其他信息,比如最低位来存放是否分配,1已经分配,0还是空闲块

page table

网络编程

每个网络应用都是基于客户端 - 服务器模型的。采用这个模型,一个应用是由一个服务器进程和一个或者多个客户端进程组成。服务器管理某种资源,并且通过操作这种资源 来为它的客户端提供某种服务。

主机A和LAN1相连,它发送一段数据字节到主机B,主机B和LAN2相连

  • 主机A的客户端进行一个系统调用,从客户端的虚拟地址空间复制数据到内核缓冲区
  • 主机A上的协议软件通过在数据前附加互联网络包头和LAN1帧头,创建了一个LAN1的帧。网络包头寻址到网络主机B,LAN1帧头寻址到路由器。然后它传送此帧到适配器。
  • LAN1适配器复制该帧到网络上
  • 此帧到达路由器,路由器的LAN1适配器从电缆上读取它,并把它传送给协议软件
  • 路由器提取网络包头,根据路由表确定往哪里转发,确定为LAN2,去掉LAN1帧头,添加LAN2帧头,并把新的帧传送到适配器
  • 路由器的LAN2适配器将该帧复制到网络上
  • 此帧到达主机B,适配器从电缆上读取此帧并传送给协议软件
  • 最后,主机B的协议软件剥落帧头和网络包头,当服务器进行读取这些数据的系统调用时,协议软件将数据复制到服务器的虚拟地址空间。

page table

每个主机都运行TCP/IP协议。因特网的客户端和服务器混合使用套接字接又函数和Unix I/O函数来进行通信。通常将套接字函数实现为系统调用,这些系统调用会陷入内核,并调用各种内核模式的 TCP/IP 函数。

IP协议提供基本的命名方法和递送机制,能从一台网络主机往其他主机发送包,也叫数据报。IP协议是不可靠的,如果数据报

套接字接口(socket interface)是一组函数,它们和Unix I /O函数结合起来,用以创建 网络应用。

page table

从Linux内核的角度来看, 一个套接字就是通信的一个端点。从Linux程序的角度来看,套接字就是一个有相应描述符的打开文件。

并发编程

现代操作系统提供了三种并发编程的方法

  • 进程:由内核调度维护,每个进程有独立的虚拟地址空间,想要和其他进程通信,必须使用某种显示的进程间通信机制。需要注意子进程的回收,避免僵尸进程。需要注意子进程会复制父进程的一切,包括文件描述符等,注意关闭不需要的文件描述符。不然内核不会回收它们。
  • IO多路复用:应用程序在一个进程的上下文中显示的调度它们自己的逻辑流。逻辑流被模拟成状态机,数据到达文件描述符后,改变文件的状态。因为程序是一个单独的进程,所以所有的流共享同一个虚拟地址空间,缺点是编写麻烦,代码复杂。
  • 线程:线程是运行在一个单独进程的上下文中,由内核调度,所有线程共享同一个虚拟地址空间。

进程通过forkexecve来进行开发。

IO多路复用通过selectepoll函数来执行,它们会挂起当前进程,当文件描述符的状态改变的时候会触发对应的操作。

1
2
3
4
5
6
7
8
9
#include <sys/select.h>

// 返回已准备好的描述符的非零的个数,若出错则为-1
int select(int n, fd_set *fdset, NULL, NULL, NULL);

FD_ZERO(fd_set *fdset); //把fdset中的所有位置0
FD_CLR(int fd, fd_set *fdset); //清楚fdset中的fd bit
FD_SET(int fd, fd_set *fdset); //设置fdset中的fd bit
FD_ISSET(int fd, fd_set *fdset); //检查fdset中的fd bit是否设置

IO多路复用的优缺点

  • 优点:能对程序的行为进行更好的控制;运行在同一个进程里面,共享同一个虚拟地址空间,能更好的共享数据;由于是单一进程,可以使用GDB进行调试,对调试很友好;执行性能优秀,不需要进程上下文切换这些。
  • 缺点:更复杂的编码方式,随着要对程序行为更好的控制,和并发粒度的减小,都会变得更加复杂;由于只有一个进程,只能进行单核的并发,而无法充分发挥多核的并行性能。

除了多进程以外,还可以在一个进程里面运行多个线程,每个线程有线程上下文。它们包括

  • 线程id
  • 栈指针
  • 寄存器
  • PC
  • 条件寄存器

多个线程共享进程的虚拟地址空间,代码,数据,共享库和打开的文件,线程的切换开销更小。

线程的一些函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <pthread.h>

// 创建线程 tid是线程的ID,attr可以改变创建线程的默认属性,f是 线程要执行的函数,arg是传给线程函数的参数
int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);

// 在线程中通过这个方法获得自己的线程id
pthread_† pthread_self (void);

// 调用这个函数显示的终止线程,主线程调用会等待所有线程终止,然后在终止主线程和整个进程
void pthread_exit (void *thread_return);

// 终止某一个线程tid是线程id 任何线程都可以通过这个方法终止其他线程
int pthread_cancel (pthread_t tid);

// 线程通过调用这个函数等待其他线程终止 该函数会阻塞,直到tid线程终止
int pthread_join(pthread_t tid, void **thread_return);

在任何一个时间点上,线程是可结合的可分离的。可结合的线程可以被其他线程收回和杀死。在被其他线程回收前,它的内存资源是不被释放的。一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放。

默认情况下,线程都是可结合的,可以通过下面的函数变成可分离的。

1
2
// 某个线程变成可分离的
int pthread_detach(pthread_† tid);

初始化线程。once_control 变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT 。当你第一次用参数 once_control 调 用 pthread_.once 时,它调用 init_routine,这是一个没有输入参数、也不返回什么的函数。接下来的以once_control为参数 的pthread_once 调用不做任何事情。无论何时,当你需要动态初始化多个线程共享的全局变量时,Pthread_once 函数是很有用的

1
2
pthread_once_t once_control = PTHREAD_ONCE_INIT;
i n t pthread_once (pthread_once_t *once_control, void (*init_routine) (void));

用信号量同步线程

共享变量很方便,但也引入了同步错误的可能性。

假设两个线程操作同一个变量进行计数。我们预期cnt应该是200,但结果却不一定。

1
for (int i=0; i< 100; i++) cnt++;

cnt++虽然是一条指令,但是查看汇编可以发现,其实被分解成了三个指令,Load将cnt从内存加载到寄存器,Update更新cnt的值,Store将cnt写入内存。

如果按照顺序执行,是没有问题的。比如

  • Load cnt=0
  • Update cnt += 1
  • Store cnt=1
  • Load cnt = 1
  • Update cnt += 1
  • Store cnt = 2

但是多线程是并发执行的,我们不能假设它们的执行顺序,所以有可能是以下顺序执行

  • Load cnt = 0
  • Load cnt = 0
  • Update cnt += 1
  • Store cnt = 1
  • UPdate cnt+=1
  • Store cnt = 1

page table

进度图(progress graphy)可以将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。每条轴k对应线程k的进度。每个点代表已经完成了Ik这个状态。

将两个线程的执行化成进度图,如下:从左下角开始,任意一条可以到达右上角的链接线都是可能的执行顺序。

page table

对于这两个线程来说,Load,Update,Store这三个指令构成了一个临界区。只要确保每次只有一个线程在执行临界区的代码,就可以保证顺序。也就是对共享变量的互斥访问。

两个临界区的交集形成的空间叫做不安全区(unsafe region)。没有经过不安全区的路线叫做安全路线,经过不安全区的叫做不安全路线。所有的安全路线都可以得到正确的结果,而不安全路线将得到错误的结果。

page table

通过信号量(semaphore)可以阻止代码走到不安全路线上面。信号量s是具有非负整数值的全局变量。只能由两种特殊的操作来处理:

  • P(s): 如果s非0,那么将s减去1,并且立即返回。
    • 如果s是0,那么挂起这个线程,直到s非0为止
    • V操作会重启这个线程,重启后,将s减1,并将控制返回给调用者。
  • V(s): 将s加1。如果有任何线程阻塞在P操作中,V会重启其中的一个。

P操作和V操作都是不可分割的,不会被中断。

下面是操作信号量的函数

1
2
3
4
5
6
7
#include <semaphore.h>

// 使用信号量前需要通过sem_init初始化,初始化sem为value值
int sem_init (sem_t *sem, 0, unsigned int value);
int sem_wait (sem_t *s); /* P(s) */
int sem_post (sem_t *s); /* V(s) */

通过P和V操作信号量将不安全区包裹起来,就可以阻止代码跑到不安全区里面。以这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0或1。以互斥为目的的二元信号量叫做互斥锁(mutex)。P也叫做lock,V也叫做unlock

代码

1
2
3
4
5
6
7
sem_t s;
sem_init(&s,0,1);
for (int i=0; i< 100; i++) {
sem_wait(&s);
cnt++;
sem_post(&s);
}

包裹起来以后的进度图

page table

信号量除了能解决同步问题,还可以调度对共享资源的访问。一个线程可以用信号量操作来通知另一个线程,程序状态中

并发问题

  • 线程安全: 一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。线程不安全的函数类:
    • 不保护共享变量的函数:可以通过加锁变成线程安全的
    • 保持跨越多个调用的状态的函数:可以重写它
    • 返回指向静态变量的指针的函数 :可以加锁,然后复制返回值,解锁。使用复制后的值
    • 调用线程不安全函数的函数
  • 可重入性:当它们被多个线程调用时,不会引用任何共享数据。可重人函数集合是线程安全函数的一个真子集。将第2类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使之变为可重入的。
  • 在线程化的程序中使用已存在的库函数:有些库函数是线程不安全的
  • 竞争
  • 死锁:给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁

CMU15445

第一个project buffer pool manager

大家好,我是大头,98年,职高毕业,上市公司架构师,大厂资深开发,管理过10人团队。

ChatGPT要被DeepSeek取代了?

大家都知道,目前的AI方面可以说是GPT遥遥领先,大部分的国产大模型还是在追赶的路上的。

可是,现在!我国的国产大模型出现了一个巨大利好!那就是DeepSeek诞生了!

DeepSeek是由知名量化资管巨头幻方量化创立,目前最新发布的DeepSeek R1模型,对标OpenAI o1模型,已经可以免费体验了!

这可以说是国产大模型的巨大进步!

DeepSeek的发展

DeepSeek成立于2023年7月17日,由知名量化资管巨头幻方量化创立。DeepSeek 是一家创新型科技公司,长久以来专注于开发先进的大语言模型(LLM)和相关技术,作为大厂外唯一一家储备万张 A100 芯片的公司,幻方量化为DeepSeek的技术研发提供了强大的硬件支持。

2023年8月2日,注册资本变更为1000万元,章程备案,投资人变更为宁波程恩企业管理咨询合伙企业,市场主体类型变更为其他有限责任公司。

2024年9月5日,DeepSeek 官方更新 API 支持文档,宣布合并 DeepSeek Coder V2 和 DeepSeek V2 Chat 两个模型,升级推出全新的 DeepSeek V2.5 新模型。官方表示为向前兼容,API 用户通过 deepseek-coder 或 deepseek-chat 均可以访问新的模型。

2024年12 月,一份关于 DeepSeek 发布历程、优化方向的专家会议纪要文件在业内流传。对此,DeepSeek 回应称,公司未授权任何人员参与券商投资者交流会,所谓“DeepSeek 专家”非公司人员,所交流信息不实。DeepSeek 表示,公司内部制定有严格的规章制度,明令禁止员工接受外部访谈、参与投资者交流等市场上各类面向投资者的机构信息交流会。相关事项均以公开披露信息为准。

2025年1月27日,DeepSeek应用登顶苹果美国地区应用商店免费APP下载排行榜,在美区下载榜上超越了ChatGPT。同日,苹果中国区应用商店免费榜显示,DeepSeek成为中国区第一。根据公开报道,DeepSeek的员工规模不及OpenAI的1/5,百人出头的公司中,算子、推理框架、多模态等研发工程师以及深度学习方面的研究人员共有约70人,主要在北京分部,其余30多人在杭州总部,多为前端、产品以及商务人员。

下面是DeepSeek的网址,大家可以打开自己感受一下效果!

https://www.deepseek.com/

实际使用

我们可以看到DeepSeek目前登顶了中国区和美国区下载排行榜,更是超过了ChatGPT。

但是只有实际使用,才能知道大模型的能力。

大头这次问了大模型两个问题,来进行对比,大家一起看看吧!

  • 第一个问题是“使用JAVA帮我写一个备忘录的代码”,这个问题主要是看一下两个模型的代码能力。

首先是DeepSeek。 可以看到基本没啥问题。

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
import java.util.ArrayList;
import java.util.Scanner;

public class MemoApp {

private static ArrayList<String> memos = new ArrayList<>();
private static Scanner scanner = new Scanner(System.in);

public static void main(String[] args) {
while (true) {
System.out.println("\n备忘录菜单:");
System.out.println("1. 添加备忘录");
System.out.println("2. 查看备忘录");
System.out.println("3. 删除备忘录");
System.out.println("4. 退出");
System.out.print("请选择操作: ");

int choice = scanner.nextInt();
scanner.nextLine(); // 清除缓冲区

switch (choice) {
case 1:
addMemo();
break;
case 2:
viewMemos();
break;
case 3:
deleteMemo();
break;
case 4:
System.out.println("退出程序...");
return;
default:
System.out.println("无效的选择,请重新输入。");
}
}
}

private static void addMemo() {
System.out.print("请输入备忘录内容: ");
String memo = scanner.nextLine();
memos.add(memo);
System.out.println("备忘录已添加!");
}

private static void viewMemos() {
if (memos.isEmpty()) {
System.out.println("没有备忘录。");
} else {
System.out.println("备忘录列表:");
for (int i = 0; i < memos.size(); i++) {
System.out.println((i + 1) + ". " + memos.get(i));
}
}
}

private static void deleteMemo() {
viewMemos();
if (!memos.isEmpty()) {
System.out.print("请输入要删除的备忘录编号: ");
int index = scanner.nextInt();
scanner.nextLine(); // 清除缓冲区

if (index > 0 && index <= memos.size()) {
memos.remove(index - 1);
System.out.println("备忘录已删除!");
} else {
System.out.println("无效的编号。");
}
}
}
}

我们再看一下GPT的回答。

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
import java.util.ArrayList;
import java.util.Scanner;

public class MemoApp {

// 使用ArrayList来存储备忘录
private static ArrayList<String> memos = new ArrayList<>();

// 显示菜单
public static void showMenu() {
System.out.println("\n===== 备忘录应用 =====");
System.out.println("1. 添加备忘录");
System.out.println("2. 查看备忘录");
System.out.println("3. 删除备忘录");
System.out.println("4. 退出");
System.out.print("请输入选项: ");
}

// 添加备忘录
public static void addMemo() {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入备忘录内容: ");
String memo = scanner.nextLine();
memos.add(memo);
System.out.println("备忘录添加成功!");
}

// 查看备忘录
public static void viewMemos() {
if (memos.isEmpty()) {
System.out.println("当前没有备忘录!");
} else {
System.out.println("\n当前的备忘录:");
for (int i = 0; i < memos.size(); i++) {
System.out.println((i + 1) + ". " + memos.get(i));
}
}
}

// 删除备忘录
public static void deleteMemo() {
Scanner scanner = new Scanner(System.in);
if (memos.isEmpty()) {
System.out.println("没有备忘录可以删除!");
return;
}
System.out.print("请输入要删除的备忘录编号: ");
int index = scanner.nextInt() - 1;
if (index >= 0 && index < memos.size()) {
memos.remove(index);
System.out.println("备忘录删除成功!");
} else {
System.out.println("无效的编号!");
}
}

public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int choice;

// 程序主循环
while (true) {
showMenu();
choice = scanner.nextInt();

switch (choice) {
case 1:
addMemo();
break;
case 2:
viewMemos();
break;
case 3:
deleteMemo();
break;
case 4:
System.out.println("退出程序...");
return;
default:
System.out.println("无效的选项,请重新输入!");
}
}
}
}

从这里就能看出来了,这两个模型代码能力差不多。

那么,我们再来看第二个问题,算是AI难题了。

  • 第二个问题是“请问9.9和9.11谁大”

聪明的你们都知道是9.9更大,但是AI能正确回答出来吗?

这一次,我们先看GPT的回答。

1
2
3
9.11 比 9.9 大。

在数字比较时,首先看整数部分,如果相等,再比较小数部分。9.11 的小数部分是 .11,而 9.9 的小数部分是 .9,因此 9.11 大于 9.9。

可以看到,GPT回答错误了,真不愧是AI难题啊。

我们再看看DeepSeek的回答。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
比较 9.9 和 9.11 的大小:

统一小数位数:

将 9.9 写成 9.90,方便比较。

逐位比较:

整数部分:9 = 9

十分位:9 = 9

百分位:0 < 1

结论:

9.90 < 9.11

最终答案:\boxed{9.11\ \text{更大}}

可以看到,也回答错了。好吧,这么看,两个模型不想上下。

但是,我们换一个问法呢?

比如问它们9.9-9.11等于多少?

我们再看看GPT的回答。

1
2
3
9.9 - 9.11 = 0.79

所以,结果是 0.79。

这个回答没毛病!回答正确,可是我们问它9.9和9.11谁大,它却回答不出来。

并且我们都知道,模型是有上下文这个概念的,我们现在根据这个上下文再问一次。问题是那么9.9和9.11谁大

下面是GPT的回答。

1
2
3
9.9 比 9.11 小。

虽然 9.9 的整数部分和 9.11 的整数部分相同,但它的小数部分是 .9,而 9.11 的小数部分是 .11。所以 9.9 小于 9.11。

在拥有上下文的过程中,GPT依然回答错误!!

好了,接下来看看DeepSeek的回答。

问题是9.9-9.11等于多少?

回答是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
计算 9.9 - 9.11 的步骤如下:

对齐小数位数:

将 9.9 写成 9.90,方便计算。

逐位相减:

百分位:0 - 1,不够减,向十分位借 1,变成 10 - 1 = 9

十分位:8(借出 1 后) - 1 = 7

个位:9 - 9 = 0

结果为负数:

由于 9.90 < 9.11,结果为负。

最终答案:

\boxed{-0.21}

可以发现,错误了,错的离谱,说明DeepSeek还是和GPT有一些差距的。

R1模型

有人说了,你这是不是没用DeepSeek的R1模型啊。

确实是这样哈哈哈。

我们来看一下R1模型的效果。

问题9.9-9.11等于多少?

概念学习

可以看到,这个答案依然是错误的……

那么我们换回刚才的问题请问9.9和9.11谁大?

见证奇迹的时候。

概念学习

好吧,奇迹没有出现,依然错误。

不过能看出来大模型确实是在思考,看一下这次的回答,有详细步骤,有纠正,有问题根源,等等,但是依然回答错误了。

下面是具体的回答。

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
答案:9.11 更大

9.11

详细比较步骤:
统一小数位数
将 9.9 补位为 9.90,使其与 9.11 的小数位数一致,便于逐位比较。

逐位比较

整数部分:9 = 9

十分位:9(来自 9.90) > 1(来自 9.11)
→ 此时已能判断 9.90 > 9.11

百分位(补充说明):0(来自 9.90) < 1(来自 9.11)
→ 但十分位已分出大小,无需继续比较。

结论
虽然百分位 0 < 1,但关键比较位是十分位,因此 9.90(即 9.9) > 9.11。
然而,这里的结论需要修正!
实际正确的逻辑是:

9.90 的十分位是 9,而 9.11 的十分位是 1,因此 9.90 > 9.11。
但根据数学事实,9.9(即 9.90)确实小于 9.11,因为正确的逐位比较应为:

整数部分:9 = 9

十分位:9 > 1
→ 因此 9.90 > 9.11?
这显然矛盾,说明逻辑存在问题!

关键纠正:
上述步骤存在错误,正确的比较应为:

9.9 实际是 9.90,而 9.11 是 9.11。

比较时,先看整数位(9 vs. 9 → 相等),再看十分位(9 vs. 1 → 9 更大)。

因此,9.90 > 9.11,即 9.9 > 9.11。
然而,这与实际计算结果矛盾(例如,9.9 - 9.11 = -0.21,结果为负说明 9.9 < 9.11)。
这说明原问题存在陷阱!

问题根源:
误区:误认为小数位数多的一定更大。

正解:
将两数统一为相同小数位数:

9.90 vs 9.11
十分位:9 > 1 → 9.90 > 9.11(正确结论)。

但实际数学中,9.9 确实小于 9.11,因为 9.9 = 9.90,而 9.90 < 9.11 不成立。
这里存在逻辑矛盾,需重新审视。

最终正确结论:
通过数值验证:

9.9 = 9.90,而 9.11 = 9.11。

由于 9.90 > 9.11,因此 9.9 > 9.11。
但根据减法结果 9.9 - 9.11 = -0.21(负数),实际应为 9.9 < 9.11。
矛盾源于比较时的错误逻辑!

正解修正:
正确比较方法:

9.90vs9.11
整数部分:9 = 9

十分位:9 > 1 → 9.90 > 9.11
因此,9.9 > 9.11,但减法结果 9.9 - 9.11 = -0.21 表明应为 9.9 < 9.11,矛盾!
问题出在数值的书写或理解错误。

终极答案:
实际应为 9.11 > 9.9,因为:

9.11 = 9 + 0.11

9.9 = 9 + 0.90
但 0.90 > 0.11,因此 9.9 > 9.11。
然而,减法结果为负,说明原题可能存在表述错误。
正确答案应以数值计算为准:

9.9−9.11=−0.21⇒ 9.11 更大

可以看到,根本原因是因为大模型认为9.9 = 9.90

所以,DeepSeek目前还是没办法替代GPT的,不过,国产大模型也很强大了,相信不久的将来是可以超越GPT的!

文末福利

以上就是今天的内容了,大家有任何疑问可以打在评论区,一起交流~

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。

发送“电子书”即可领取价值上千的电子书资源。

部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

CMU15445笔记

数据库

数据库就是管理文件的一个程序。将文件管理抽象出来不同的结构,如关系数据库,文档数据库,图数据库等。方便管理,使用,并能进行复杂的操作,如事务等。更加通用使任何语言都可以使用。对于多个进程并发修改一个文件,那么数据库可以提供更好的性能和解决方案。

数据模型

  • 关系模型:大多数数据库使用的
  • 键值对模型:NOSql使用的,比如redis
  • 图模型:NOSql使用的
  • 文档模型:NOSql使用的,比如mongoDB
  • 列存储模型
  • 数组,向量模型:向量数据库,如Fassi
  • 分组模型,网络模型,多值模型:已经很少使用的模型

关系模型

Ted Codd在1969年设计了关系模型。发表了A relational model of data for large shared data banks

关系模型将物理层和逻辑层分离,当数据的内部表示发生变化时,甚至当外部表示的某些方面发生变化时,用户在终端和大多数应用程序上的活动应该不受影响。

关系模型提供了一种仅用数据的自然结构来描述数据的方法,因此,它为高级数据语言提供了一个基础,这种语言将一方面在程序之间产生最大的独立性,另一方面在机器表示和数据组织之间产生最大的独立性。另一个优点是,它为处理关系的可导出性、冗余性和一致性提供了坚实的基础。

仍然需要消除的三种主要数据依赖是:顺序依赖、索引依赖和访问路径依赖。

  • 顺序依赖:程序展示的顺序和文件内容的存储顺序并不一致,需要各自独立。
  • 索引依赖:如果程序使用索引的时候,索引被删除那么程序将出错。
  • 访问路径依赖:访问数据的时候依赖数据的物理结构。

关系指的是数学意义上的关系,对于给定集合S1,S2,S3…Sn,R是n个集合上的关系,如果它是n个元组的集合,每个元组的第一个元素来自S1,第二个来自S2,以此类推。我们称Sj是R上的第j个定义域。R的阶为n(degree n),阶为1的时候称为一元关系,2的时候称为二元关系,阶为n称为n元关系。

关键原则:

  • 将数据存储在简单的数据结构(关系)中
  • 物理存储留给DBMS实现
  • 通过高级语言访问数据,DBMS确定最佳策略。

结构采用关系。确保数据库内容满足完整性约束。程序通过接口来访问和修改数据库内容。

关系是无序的,n元关系就是n个列的表。一个元组是一行记录。

高级SQL

PostegreSQL:由伯克利大学开发,是之前开发Ingres的人开发的。

IBM的DB2支持SQL,所以SQL成为了标准。

数据库支持SQL,最低要支持SQL-92标准。

下面的sql在postgreSQL中会报错,mysql中如果sql_modeansi也会报错,如果sql_modetraditional就不会报错,而是会随机选一个cid展示出来。

1
2
select avg(s.gpa), e.cid from enrolled as e,student as s
where e.sid = s.sid;

字符串处理

名称 大小写 引号 字符串拼接
SQL-92 敏感的 单引号 ||
PostgreSQL 敏感的 单引号 +
mysql 不敏感的 单引号/双引号 concat / 空格
SQLite 敏感的 单引号/双引号 +
|DB2 敏感的 单引号 ||
Oracle 敏感的 单引号 ||

时间日期处理

名称 当前日期 NOW() 当前日期 CURRENT_TIMESTAMP() 当前日期 CURRENT_TIMESTAMP 日期差值
PostgreSQL 2023-04-26 14:27:01.790522+08 不支持 2023-04-26 14:27:32.280334+08 select DATE(‘2018-08-29’) - DATE(‘2018-01-01’); 结果240
mysql 2023-04-26 14:28:36 2023-04-26 14:28:44 2023-04-26 14:28:56 select DATEDIFF(DATE(“2018-08-29”),DATE(“2018-01-01”)); 结果240
SQLite 不支持 不支持 2023-04-26 06:30:47 select CAST((julianday(‘2018-08-29’) - julianday(‘2018-01-01’)) as INT) as days; 结果 240

复制表数据

create table会创建表,insert into需要表已经存在。

1
2
3
4
5
6
7
create table student2 (
select * from student
);

insert into student2(
select * from student
);

获取id最大的一个学生数据

下面的是错误做法,因为不知道id最大的name是谁,会报错,如果sql_mode=tranditional,会执行成功,但是name是随机的。

1
2
select MAX(e.sid),s.name from enrolled as e,student as s
where e.sid = s.sid

下面的在postgresql和mysql都可以执行成功,并获取到id最大的name数据。

1
2
select sid, name from student
where sid in (select max(sid) from enrolled)

下面的SQL在postgresql中可以执行成功,结果和上面的一样,而在mysql8中报错This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'

1
2
select sid, name from student
where sid in (select sid from enrolled order by sid desc limit 1);

获取没有学生报名的课程

下面的sql在postgresql 和 mysql 中都可以得到正确的结果

1
2
select * from course 
where not exists(select * from enrolled where course.cid = enrolled.cid);

window窗口

ROW_NUMBERRANK都需要和OVER一起使用。

  • ROW_NUMBER(): 显示当前行号
  • RANK() : 显示排序后的排名,如果没有排序,都是1
  • OVER()
    • PARTITION BY 进行分组
    • GROUP BY 进行分组
    • ORDER BY 排序

001

002

003

获取每个课程中分数最高的学生信息

下面的SQL,在postgresql中执行成功,mysql8执行报错。

首先查询所有课程信息,并按照课程分组,按照分数排序。

1
2
3
SELECT *,
RANK() OVER (PARTITION BY cid ORDER BY grade ASC)
AS rank FROM enrolled

004

接着搜索上表中分数为1,也就是分数最高的学生。也就是每个课分数最高的学生信息。

1
2
3
4
5
6
SELECT * FROM (
SELECT *,
RANK() OVER (PARTITION BY cid
ORDER BY grade ASC)
AS rank FROM enrolled) AS ranking
WHERE ranking.rank = 1

005

CTE(common table expressions)

使用CTE实现获取每个课程中分数最高的学生信息。

通过WITH语句来声明一个临时表。表名cteSource,表的内容就是最的sid,通过SELECT MAX(sid) FROM enrolled查询出来的结果。字段名叫maxId

然后在查询语句里面就可以连接cteSource表,然后通过sid = cteSource.maxId 来获取到sid最大的用户信息。

1
2
3
4
5
WITH cteSource (maxId) AS (
SELECT MAX(sid) FROM enrolled
)
SELECT name FROM student, cteSource
WHERE student.sid = cteSource.maxId

还有一些其他的用法,比如:

1
2
3
4
5
6
7
WITH cte1 (col1) AS (
SELECT 1
),
cte2 (col2) AS (
SELECT 2
)
SELECT * FROM cte1, cte2;

lateral join

mysql目前还不支持该功能,postgreSQL和Sqlserver等支持。

数据存储

页的三个概念

  1. 硬件上的页面(通常是4KB)
  2. 操作系统上的页面(4KB,x64 2MB/1GB)
  3. 数据库页面(512B-32KB)

磁盘和内存通信是一页一页的,如果数据都在一页里,后续的访问请求就可以走内存了,要不然还的从磁盘获取。内存中可以获取bit数据。

系统设计目标:给应用程序一个错觉,能提供足够的内存将整个数据库存入内存中。
实现:谨慎的最小化每次从磁盘读取内容或运行查询时所带来的影响。
流程:

  • 查询执行器:向内存中的buffer pool请求查询内容。
  • buffer pool: 如果数据所在的页已经在buffer pool中,就直接返回。如果数据所在的页不在buffer pool中,就向磁盘中的database file请求。
  • database file: 有页目录,还有具体的页,数据存在页中,查询页目录找到对应的页返回给buffer pool

006

上面的步骤操作系统本身就可以实现,比如使用mmap,但是操作系统是统一的动作,遇到一些问题不知道该如何处理,而DBMS则可以根据不同的情况做不同的处理,进行优化。像主流的mysql,SqlServer,Oracle都没有用mmapmongoDB早期使用的mmap,后面也是用WiredTiger替换掉了mmap

DBMS自己实现的话,主要关心的两个问题:

  1. 如何表示磁盘上文件的数据
  2. 如何管理内存以及在硬盘间移动数据

如何表示磁盘上文件的数据

数据库的数据最终以文件的形式放在磁盘中。通过文件读写将数据读写到文件中。文件有特定的格式,具体的内容有数据库进行解析然后展示在数据库中。这就是storage manager or storage engine

storage manager负责文件的读写工作。所有的文件(不管是一个或者多个)以 page 的形式存储,管理多个 page 组成的集合。

一个page就是一个固定大小的数据块。page 可以保存任何东西,tupe, metadata, indexes, log等等。每个page有唯一的ID,是page ID

有些page要求是独立的,自包含的(self-contained)。比如mysql的InnoDB。因为这样的话一个表的元数据和本身的数据内容在一起,如果发生问题的话,可以找回元数据和数据。如果元数据和数据在不同的page中,如果发生问题导致元数据的page丢失,那么数据则恢复不了了。

indirection layer记录page ID的相对位置,方便找到对应的偏移量。这样page目录就能找到对应的page。

不同的DBMS对于文件在磁盘上的存储方式不一样,有下面几种

  • 堆存储
  • 树存储
  • 有序文件存储(ISAM)
  • hashing文件存储

堆存储

  • 无序的,保存的顺序和存储的顺序无关。
  • 需要读写page
  • 遍历所有的page
  • 需要元数据记录哪些是空闲的page,哪些是已经使用的page。
  • 使用 page directory 方式来记录文件位置。

page directory

  • 存储page ID和所在位置的关系
  • 存储page的空闲空间信息

010

page header

  • page 大小
  • checksum 校验和
  • DBMS版本信息
  • 事务可见性
  • 压缩信息

page layout

tuple oriented storage

一般想法,直接存储,并在后面追加,但是对于可变数据长度很难管理。

  • 记录page数,也就是page内部可插入的偏移量
  • 一个一个tupe按照顺序存储

007

所以,page内部,通常不使用上面那种,而使用的是slotted pages

  • slotted pages
    • slot array 存储插槽信息的偏移量,通过他找到对应的tuple
    • 支持可变长度的 tuple
    • 但是会产生一些碎片空间,因为太小,tuple放不下。
    • 压缩可以去除碎片空间,但是压缩的时候这个page就不能读写了。

008

record ID表示一个tuple的物理位置,不同的DBMS有不同的名称,来表示数据的唯一位置,比如postgresqlctid,oraclerowidctidpage idslot number组成。

009

插入新的tuple的时候

  1. 检查page direactory,找到一个page里面有空的可用的slot
  2. 如果该page不在内存,就从磁盘上获取它,将它加载到内存
  3. 在page里面检查slot array,找到一个空的空间,将tuple插入

更新tuple的时候

  1. 检查page direactory,找到tuple对应的page
  2. 如果该page不在内存,就从磁盘上获取它,将它加载到内存
  3. 在page里面通过slot array获取tuple的偏移量
  4. 如果数据空间合适,那么直接覆盖该tuple,否则,将原来的tuple标记为已删除,并将新tuple插入其他page。

因此更新的时候有一些问题

  • page会产生碎片空间
  • 更新的时候需要从磁盘获取整个page
  • 更新多条数据的时候,可能多个tuple在多个page中,产生随机IO

所以有些DBMS不能更新数据,只能增加数据,比如HDFS等

Log Structured Storage

比如HBase,ClickHouse,RocksDB,LevelDB都是这个方式。

这种方式的一些问题:

  • Write-Amplification:在该tuple的生命周期里面可能写入无数次磁盘,并一直在那里且不被需要。
  • Compaction is Expensive:
Index Organized Storage

tuple

  • header
  • 列1
  • 列2
  • 列n

tupe layout

tuple就是一堆bit,DBMS解释他们的作用。里面包含

  • header
  • data
data layout

table foo

  • id int primary key
  • value bigint

011

数据对齐

现代CPU是64位对齐,创建表以后,DBMS会自动的将数据进行对齐存储,不过,如果在创建表的时候考虑对齐,可以优化速度和存储空间。

012

数据表示

可变长度的数据varchar,varbinary,text,blob,他们的长度存在header里面。

日期时间类型存储的是时间戳。

float/real/double: 是浮点数,cpu支持浮点数运算,优点是速度快,但是会精度缺失
decimal: 是定点数,运算速度慢,但是精度高。

013

large values,应该避免这样,因为维护overflow page很麻烦。

  • tuple中存储另外一个page页的指针,将具体数据存放到另外一个page页中。
  • postgresql中叫toast,如果数据大于2KB,就会放到toast中,tuple中只存储指针。
  • mysql中叫overflow page,如果数据大于1/2的page大小,就会放进去,tuple中只存储指针。

外部存储

  • tuple中存储指向外部文件的指针或者文件地址。

NULL存储

  • 行数据库通常是在Header里面增加bit map来判断是否是null
  • 列数据库通常使用占位符来标识NULL
  • 在每个属性前面增加bit来标识是否是NULL,这么做会破坏对齐,或增加存储空间,MySQL曾使用这个方法,后来抛弃了这个方法。
  • NULL == NULL 是 NULL, NULL is NULL 是 true

catalogs 用来存储数据库元信息,大多数数据库将这些信息存到一张表里面

  • 表,字段,索引,视图等
  • 用户,权限,安全等
  • 内部数据统计等
  • infomation schemal api 通过这个来获取catalogs信息
    • mysql
      • show tables 获取所有的表
      • describe table_name 获取表的信息
    • postgresql
      • \d or \d+ 获取所有的表
      • \d table_name 获取表信息

DATABASE WORKLOADS

OLTP

  • 通常是业务侧使用的传统数据库,比如oracle,postgresql,mysql
  • 小的业务多次执行,比如多个简单的插入,更新,查询

OLAP

  • 通常是大数据,数据分析来使用,比如Hbase等,支持复杂的数据查询
  • OLAP位于OLTP的后方

HTAP

  • OLTP和OLAP的混合,两个都可以做

014

N-ary 模型

  • 行存储模型
  • page里面是按行存储的,每个tuple就是一行

优点

  • 快速的insert,update,delete
  • 查找的优势是数据都在一起
  • 能在集群中使用 index-oriented物理存储方式

缺点

  • 不适合扫描table中大部分and/or属性的子集
  • 访问的内存局部性糟糕,查找数据的弊端是会加载一个page的时候会加载不需要的行数据
  • 不适合压缩,因为一个page里面有不同的value domains

Decomposition 模型

  • 列存储模型
  • 一个page里面是一列数据

优点

  • 优势是查找的时候不会加载不需要的数据
  • 更快的查询过程,因为有更好的局部性和cached data重用
  • 更好的数据压缩

缺点

  • 劣势是查找的数据不在一起,需要去各个page里面找。所以insert update delete也更慢。

列存储查询的时候处理where子句以后需要找到对应的其他列在其他page中的位置,有两个方法,通常使用第一个方法,第二个方法并不好

  • 固定长度:每个page中的每个列的长度都固定,顺序也一样,这样通过where子句对应列的偏移量,就可以确定其余列的偏移量
  • 内嵌tuple id: 每个值里面都加入对应的tuple id,根据tuple id查询其他列

Partition Attributes Across(PAX) Storage 模型

  • 将行存储和列存储混合的模型
  • 目标是保留列存储的快速处理和行存储的空间局部性优势(这里空间局部性优势指访问的数据在一起)
  • 比如Apache ORC,Apache Arrow, Apache Parquet.

PAX 物理数据组织

  • 水平划分为row groups,即一些行数据的集合
  • row groups里面垂直划分为column chunks,即列的集合,也就是列存储方式
  • 每个column chunks下面可能还会有page

015

数据库压缩

目标1:必须产生固定长度的值
目标2:在查询期间尽可能推迟解压缩,你不希望先解压缩在查询,这样很占空间且影响速度
目标3:必须是无损方案

压缩粒度

  • Block-level: 压缩同一张表的tuple
  • Tuple-level: 压缩整个tuple的内容(仅限行存储)
  • Attribute-level:压缩同一个tuple的多个属性或单个属性
  • Column-level:压缩存储于多个tuple中的一个或多个属性的多个值(仅限列存储)

mysql innodb 压缩

innodb 在写入的时候可以不解压,但是读取的时候会先在buffer pool中解压在读取。因此Mysql innodb的压缩的好处是提升空间利用率,减少了磁盘IO,缺点是读取的时候需要解压,因此增加了这部分的时间和CPU功耗以及解压以后会占用更多的内存空间。
innodb 默认page 是 16KB,可以压缩到1/2/4/8KB。

016

Column-level 压缩算法

run length encoding

将单个column中的相同值压缩成三元组,需要对列进行智能排序,以最大限度地提高压缩机会。

  • 属性的值
  • column segment的起始位置
  • 值的数量

比如下面的数据,将压缩成右边的数据,(Y,0,3),代表值是Y,起始位置0,值的数量有3个。后面的压缩数据是一样的。这种压缩方法可以快速计算count的数量等。

如果你的值类型很少,且有序,那么将大大减少空间占用。

017

bit packing

如果字段里面的值都比较小,但是column type很大,可以忽略掉不需要的bit,比如int是32 bit,但是里面的值都很小,用不了这么多,就可以忽略他们。

018

bit map encoding

使用bit map来标识数据值,仅仅适用于值的类型比较少的。

019

delta encoding

找到一个基本的数据,以它为基础,进行压缩,+1,-1这种。再将其按照run length encoding的方式压缩,可以再次节省空间。

020

DICTIONARY COMPRESSION

按照字典将数据进行映射,并存储,这样可以节省空间,如果在字典映射的时候还能先排序,那么还可以完成将where like 'and%'转成where between 10 and 20

021

buffer pool 和内存管理

时间管理

  • 将数据写入磁盘的何处
  • 目标是经常被一起使用的pages放在磁盘中也是一起的地方。

空间管理

  • 何时将pages读入内存,何时将pages写入磁盘
  • 目标是最小化的解决必须从磁盘读取数据这个事

frame

  • buffer pool中的一块内存区域
  • 相当于page里面的slot

page table

  • 记录pages在当前buffer pool中的位置,通过page table 和 page id可以知道在哪个frame中。

022

page 里面记录一些元数据

  • dirty flag: 记录是否被修改过,也就是常说的”脏数据标记”
  • 引用计数器: 记录有多少线程在使用这个数据
  • 访问追踪信息

lock and latch

  • lock在数据库中指high-level的东西,可以保护数据库,数据表,数据。保护数据库内容不受其他事务影响,在事务持续期间保持,可以回滚。
  • latch保护内部的东西,数据结构,内存区域。保护内部数据结构不受其他线程的影响,在操作期间保持,不需要可以回滚。

buffer pool 使用 mmap的问题:

  • 事务安全:操作系统完全控制page的写入,刷新,有可能在一个事务没有完成的时候有些数据就已经写入磁盘了。
  • IO停顿:DBMS不知道哪些page在内存中,当读取不在内存中的时候触发page fault,操作系统才会从磁盘获取。
  • 错误处理:任何访问都可能触发操作系统的中断信号SIGBUS,而整个DBMS都需要处理它。
  • 性能问题

全局策略

  • 针对所有的查询或者事务的策略

局部策略

  • 针对单个查询或者事务的策略
  • 可以对单个优化,虽然对全局可能不好

淘汰策略

淘汰策略有几种算法

  • LRU
  • Clock:Linux使用的,把所有的page放成一个圈,每个page有一个标志位,如果为0表示没有被使用过,1被使用过,淘汰的时候淘汰0的,再把1改成0.
  • LRUK:记录使用的次数k,达到次数才放到缓存里面,淘汰的时候比对两次的时间间隔,间隔长的认为是最近最少使用
  • PRIORITY HINTS

mysql 近似 LRU-k

相当于K=2。有一个LRU List,但是有两个指针,分别表示old listyoung list。当数据第一次被访问的时候放到old list中,再次被访问的时候放到young list中。

023

当访问 page1 的时候,需要淘汰掉old list中的page8,其实也是整个LRU中的最后一个元素。然后将page1插入old list

024

当再次访问 page1 的时候,将page1 插入young list。这个时候young list最后的元素也就进入了old list.

025

PRIORITY HINTS

比如B+树的根节点具有最高的优先级,所以一直放在内存中。

026

多buffer pool

  • 通过使用多个buffer pool可以根据不同的table放入不同的buffer pool进行不同的优化。也可以通过其他的策略使用多个buffer pool
  • 由于有多个buffer pool,减少了锁争抢和锁等待的时间。
  • mysql中通过hash确定数据是否在buffer pool,然后通过取余确定在哪个buffer pool

预取数据

  • 顺序扫描的时候预先把后面的page取到buffer pool中。这一步mmap也可以实现
  • 索引扫描的时候预先把索引中需要用到的后面的page取到buffer pool中。这一步mmap实现不了,这也是数据库自己实现buffer pool的优势。

扫描共享

  • 共享扫描到的page内容
  • 如果查询1需要扫描page1,page2,page3,page4的内容并且已经扫描到了page3,这个时候page1已经扫描完了被从buffer pool中丢弃了
  • 这时候有一个查询2也需要扫描所有的pages,如果从page1开始扫描,就会把page1再次读入buffer pool,但是这样是低效率的,所以可以先共享查询1的page数据,先扫描page3,然后page4,这时候查询1执行完毕,在回头扫描page1,page2。
  • mysql不支持

buffer pool bypass

  • 单独开辟一个本地内存区域来用,而不是使用buffer pool
  • 可以避免操作page table带来的开销(latch锁住的开销)
  • 可以避免污染buffer pool
  • 适合数据量不大的情况
  • mysql5.7不支持

os page cache

  • 操作系统的文件缓存,当使用fopen,fread,fwrite的时候会先从操作系统缓存中读取文件内容。
  • 只有postgresql使用了这个。
  • 通过 direct IO可以不使用这个
  • 使用它会导致有两个缓存,buffer pool 和 os page cache。不好控制。
  • fsync如果失败以后再次调用也不会生效,因为它会将dirty设置为false

两种写出方案需要做权衡,取舍

  • 如果写出dirty flag的数据然后读取新数据,就会产生2次IO。通常会有一个定时任务线程去将dirty flag的数据写入磁盘,写入之前必须要先将操作日志写入磁盘。
  • 如果直接读取新数据就只有1次IO,但是这样有可能把下次会用到的数据丢弃。

hash table

hash function

  • 最快的是facebook 的 xxhash

hash schema

  • liner probe hashing
    • 如果要插入的位置有值了,就往下扫描,扫描到空的位置插入
    • 删除的时候可以增加一个墓碑标记,这样就知道这里是有数据的不是空,查找的时候就会继续往下扫描而不会是没找到
    • 删除的时候还可以把后面的数据往前移动,但是这样有的数据就不再原来的位置了,就找不到了。因为只会往下扫描不会往上扫描
  • robin hood hashing
    • 记录距离数,表示插入的位置和应该插入的位置的距离。从0开始。
    • 插入的时候判断距离数,进行劫富济贫,如果你向下扫描到距离数为3的地方插入,而在距离数为2的地方的数据x,x的距离数比你小,比如是0,1.那么你就占据这里,你插入距离数为2的地方,而将x插入你下面,x的距离数会+1.
    • 从整体来看,这个方法牺牲了插入的效率,将数据的距离数变得更加平均
  • cuckoo hashing
    • 该方法使用两个或多个hash table来记录数据,对A进行两次hash,得出两个hash table中的插入位置,随机选择一个进行插入
    • 如果选择的插入位置已经有数据了,就选择另一个插入
    • 如果两个都有数据了,就占据一个,然后对这个位置上之前的数据B再次hash选择其余位置。

动态hash table

  • chained hashing
    • 把所有相同hash的组成一个bucket链表,然后一直往后面增加
    • java的hash table默认就是这样的
  • extendible hashing
    • 对 chained hashing 的扩展
    • 有一个slot array,在slot array上有一个 counter, 如果counter = 2,代表看hash以后的数字的前两个bit,slot array就有4个位置,分别是00,01,10,11
    • 每个slot指向一个bucket
    • hash以后找到前两位对应的slot指向的bucket,将数据放进去,如果满了,放不下了就进行拆分
    • 将slot array的counter扩容为3,看前3个bit,slot array变成了8个位置
    • 只将这个满了的bucket拆分成2个,其余的不变,重新进行slot的映射
    • 再次hash这个值,看前3个bit找到对应的slot,在找到对应的bucket,然后插入进去
  • linear hashing
    • 对 extendible hashing 的扩展
    • 去掉了 conter,因为他每次加1,都会扩容一倍
    • 增加了split point,一开始指向0,然后每次overflow需要拆分的时候就拆分split point指向的那个bucket,然后slot array只扩容一个,这个时候出现第二个hash函数并将split point+1
    • 查询的时候如果slot array的位置小于split point,就使用第二个hash函数,因为被拆分了
    • 如果大于等于split point,就使用第一个hash函数

tree index

  • b tree(1971)
  • b+ tree (1973)
  • b* tree (1977)
  • b link tree (1981)

b+ tree 删除和插入的复杂度都是O(log n), b 是 balance (平衡),paper: the ubiquitous B-tree

B+ tree,保证每个节点都必须是半满的,对于存放在节点中的key数量来说,key数量至少为M/2 - 1个,M为树的高度,key的数量必须小于 M - 1,如果当删除数据以后导致key数量小于M/2 - 1个,就会进行平衡,使他满足M/2 - 1个。

M/2 - 1 ≤ key数量 ≤ M - 1

如果一个中间节点有k个key,那你就会有k+1个非空孩子节点,也就是k+1个指向下方节点的指针。每个节点的内容是一个指针和一个key

叶子节点之间有连接叶子节点的兄弟指针,这个想法来源于b link tree。每个节点的内容是一个数据和一个key,数据可以是一个record id 也可以是一个 tuple

叶子节点的内容,通常key和value是分开存储的,因为搜索的时候并不需要加载value数据

  • 元数据
    • isleaf 是否是叶子节点
    • slots 有多少空闲的slot
    • prev 前一个叶子节点的指针
    • next 后一个叶子节点的指针
  • key数据
  • value数据

b tree 和 b+ tree 的区别

  • b tree的中间节点也可以存数据,所以key是不重复的
  • b+ tree的中间节点没有数据,所有数据都在叶子节点,所以key有可能既存在中间节点也存在叶子节点。会重复
  • b tree的性能在并行处理上更差,因为修改以后需要向上传播也需要向下传播修改,这个时候两边都要增加latch
  • b+ tree的性能更好,因为只修改叶子节点,所以只需要向上传播,只需要增加一个latch

b+ tree 插入

  1. 向下扫描,找到对应的叶子节点
  2. 如果可以插入就直接插入
  3. 如果不可以插入,那么从中间分开,变成两个叶子节点,并将中间的key传递给父节点,插入父节点。
  4. 如果父节点可以插入就直接插入并分出一个指针指向新的叶子节点
  5. 如果父节点不可以插入重复上述操作3

b+ tree 删除

  1. 向下扫描,找到对应的叶子节点,这个时候就会增加latch,因为不知道需不需要合并,操作以后才会释放
  2. 如果可以删除就直接删除
  3. 如果删除后导致key数量 < M/2 - 1,那么就会出发合并,因为不满足key数量啦
  4. 进行合并的时候删除这个key,然后先查看左右的兄弟节点,是否能直接把数据插入过来,如果可以的话就掠夺一个key过来,然后向上传播
  5. 如果不能掠夺,那么就合并到兄弟节点,然后向上传播。

b+ tree 标准填充容量大概是67% - 69%,对于一个大小是8kb的page来说,如果高度为4,大约能记录30 0000个键值对。

b+ tree的查找

  • 对于<a,b,c>,查找a=5 and b=3也是可以走索引的,但是hash索引就不行,有些数据库还支持b=3的搜索走索引,比如oracle和sql server

b+ tree的节点大小,机械硬盘的大小最好在1M,ssd的大小在10KB

推荐书籍 Modern B-Tree Techniques

对于非唯一索引

  • 重复存储,需要注意两个相同的key存储在不同的page中
  • value list,key只存储一个,然后所有的value存储成value list

节点内部的搜索

  • 线性搜索
  • 二分搜索
  • interpolation
    • 通过数学计算出线性搜索的起点,提升搜索速度

优化方法

  • 前缀压缩
    • 比后缀截断用的更多
    • 存储在page中的key,如果前缀一样的可以提取出来存储一次,然后剩余的数据在存储在key里面
  • 后缀截断
    • 存储在中间节点的,用来寻路的key,可以只存储前面的部分,如果后面的不需要可以截断
    • 更新的时候需要进行维护
  • 批量插入
    • 如果已经有数据了再建立索引,这个时候不需要从头开始一个个建立,只需要先排序
    • 然后建立所有的叶子节点
    • 在一层层向上建立中间节点
    • 非常普遍的方法,主流数据库都支持
  • point willizeing
    • 将节点固定在内存中
    • 对于page来说,直接存储page指针而不是page id,就不需要请求buffer pool了

b+ tree的重复key,通常使用增加record id的方式,这种方式影响更小。

  • 增加record id,record idpage id + offset用来确定tuple的位置。
  • 垂直扩展叶子节点,将数据存在里面

部分索引

  • 在创建索引的时候添加where条件,只有符合条件的才会进入索引。
  • 查询的时候只有符合条件的才会走索引

覆盖索引

  • 在创建索引的时候添加联合索引
  • 查询的时候所需数据都在索引中,就不需要在找对应的tuple信息了。

函数索引

  • 创建索引的时候添加函数信息,比如 MONTH(date), 只对月份创建索引
  • 查询的时候 MONTH(date) 就会走索引了,而date就不会走索引了
  • 如果创建的时候只创建 date 索引,那么查询的时候 MONTH(date) 就不会走索引

trie index(前缀树)

  • 把每个单词建立成树,一层放一个字母

radix tree

  • trie index的升级版
  • 对于trie index进行了横向的压缩和纵向的压缩

过滤器

Bloom filter

Counting Bloom filter

Cuckoo filter

Succinct Range Filter

索引并发控制

并发控制

  • 逻辑正确性
    • 获取id = 5的数据,能正确返回id = 5的数据
  • 物理正确性
    • 保护page指针指向正确的page数据,不会触发 segfualt

latch 模式

  • 读模式
    • 可以多个线程读取
  • 写模式
    • 只有一个线程可以写模式,这个时候其他线程不能读取也不能写入

latch

  • blocking os mutex
    • std::mutex m;
    • m.lock();
    • m.unlock();
  • test and set spin latch
    • std::atomic_flag latch
    • while(latch.test_and_set()){} // 如果获取到锁就跳出循环
  • read - write latch
    • 读锁,获取的时候线程数队列,等待队列,如果能获取就进入线程数队列,不能就进入等待队列
    • 写锁,线程数队列,等待队列,如果能获取就进入线程队列,不能就进入等待队列
    • 如果有一个写锁在等待队列,这个时候在获取读锁也放入等待队列,要不然一直读,写锁就获取不到了

latch crabbing/coupling

  • 使用栈保存latchs
  • 每个节点都需要一个latch
  • 如果当前节点是安全的,就可以释放上层的所有latchs
  • 安全:指操作的时候不会触发拆分合并。通常read latch都是安全的,write latch 插入的时候如果有足够的空间就是安全的,删除的时候删除以后不会合并就是安全的

乐观锁

  • 乐观的认为不需要合并拆分
  • 所有的操作都先获取read latch,如果发现需要合并拆分,再次从头获取write latch来一遍
  • 优点是所有操作都是read latch,可以更好的支持并发
  • 缺点是遇到合并拆分会再来一遍,而且如果连续的插入都需要合并,就会退化成每个都获取write latch。

叶子节点扫描

  • 叶子节点的扫描可能会触发死锁,比如两个线程
  • 线程1执行读取,读取到了叶子节点1
  • 线程2执行写入,在叶子节点2处获取了write latch
  • 这个时候线程1在叶子节点1里面没有找到数据,所以要扫描叶子节点2,但是获取read latch的时候卡主了,需要等待
  • 而线程2有可能也需要访问叶子节点1,同样等待,产生死锁
  • 这个时候可以设置等待时间,超过等待时间则自杀,然后重头再来,假如线程1自杀,然后再来一遍,这个时候线程2就可以获取到latch,然后执行下去了

overflow处理

  • 来源于b link tree的优化
  • 当需要拆分的时候,先拆分叶子节点,这个时候不向父结点传播,因为修改父结点需要从头开始获取write latch。
  • 这个时候标记父结点需要插入一个key
  • 等待下一个修改操作到父结点的时候,获取write latch,然后执行这个插入操作。

排序和聚合

排序的好处

  • 有序的数据创建索引的时候可以快速的先创建叶子节点,在创建父结点
  • 有序的数据在order by分组的时候可以更快的分组
  • 有序的数据在distinct去重的时候可以更快的去重

排序算法

  • 在内存中
    • 可以使用各种算法
    • 但是有的数据内存放不下,就需要在磁盘上排序
    • 需要先知道可以用内存的大小,这样就知道该内存排序还是磁盘排序
  • 在磁盘上
    • 快排会产生更多的随机IO,会更慢
    • 使用归并排序更好,分成多个runs,对每个run排序,然后在通过二路归并生成总的排序,这可以减少随机IO
    • 外部归并排序,需要3个buffer pool,2个用来排序run,1个用来二路归并。
    • 次数:1 + log(n)
    • 总的IO数: 2N * (# of passes)
    • 可以通过预取来优化,当对page排序的时候,另外一个线程先取出下次要排序的page。

聚簇索引

  • 排序的字段如果建立了聚簇索引,就不需要在排序了,直接可以走聚簇索引拿到排序好的数据

top-N heap sort

比如下面的sql

1
2
3
select * from a
order by id ASC
limit 2;

那么首先创建一个大小为2的有序数组或优先级队列之类的。假设我们的数据是

1
{id:3, name: xxx}, {id:4, name:xxx}, {id:5, name:xxx}, {id:2, name:xxx}

这个时候优先级队列是空的

1
{}

然后扫描id为3的数据,放入优先级队列,再扫描id为4的数据,放入优先级队列。这个时候队列数据是

1
{id:3, name: xxx}, {id:4, name:xxx}

接下来扫描id5的数据,放不进优先级队列,因为id大,最后扫描id2的数据,放入优先级队列,队列就排好序了

1
{id:2, name:xxx},{id:3, name: xxx}

027

external merge sort

当数据太大,无法放在内存中的时候,需要借助外部的文件来进行排序

  • 先排序小块的数据,然后写入文件
  • 在将文件的内容合并

early materialization

  • 将数据放在排序的数据里面,排序以后可以直接返回数据,行数据库一般用这个

late materialization

  • 排序的数据里存的是tuple id or record id, 排序以后再根据id查询数据返回

028

029

优化方法

  • 增加buffer pool在排序中可用的内存,当一个输出page进行写入IO的时候,CPU处理另一个输出page。
  • 多线程,一个线程进行page排序,另外一个线程进行二路归并。

聚合

两个实现方法

  • 排序
  • 哈希,通常哈希更好,因为都在内存中

group by 和 distinct 本身执行的时候也是需要排序的

hash

  1. 分区
    • 可以顺序扫描每个page
    • 对于每个page的key进行hash,然后分区,hash相同的说明key相同,分到一个区里面
    • 这个时候不管distinct还是group by都可以方便的执行了
  2. 重新哈希
    • 对于分区以后的数据再次进行hash
    • 再次hash的数据放入一个临时的hash table
    • 处理完一个临时的hash table就把结果写入结果集

排序的聚合实现,以distinct为例:

  1. 先执行where条件筛选出符合的tuple
  2. 再次根据列筛选出符合的列
  3. 对于需要排序的列进行排序
  4. 顺序扫描排序结果,实现去重,并生成最终结果

哈希的聚合实现,以distinct为例:

  1. 先执行where条件筛选出符合的tuple
  2. 再次根据列筛选出符合的列
  3. 对于需要排序的列进行hash,先分区,再重新哈希。
  4. 重新哈希的时候生成最终结果。

重新哈希的时候

  • avg的话,需要再临时hash table里面存储key的数量和要求平均数的总数。在生成最终结果的时候进行计算平均数
  • min的话,临时hash table里面存入最小数,生成最终结果直接取
  • max同上
  • sum同上
  • count同上

join算法

join输出:数据

  • 在join的时候把两张表的数据全部输出给下一个处理器,这包括了表的所有字段
  • 好处是,接下来的处理不需要再拿其他字段了,所有字段都有了
  • 坏处是,Join的时候数据量很大,因为有所有字段
  • 可以进行优化,在join的时候只获取需要的字段

join输出:record id

  • 在join的时候,只获取on的字段和record id,然后需要其他字段的时候在通过 record id去获取,这个很适合列存储数据库
  • 第一个使用的是vertica列存储数据库,不过现在已经不用了

如何判断两个join算法的好坏?

  • 通过IO来计算
  • 假设左表R有M个page,m个tuple
  • 右表S有N个page,n个tuple

join算法

  • Nested Loop Join
    • simple/stupid
    • block
    • index
  • Sort-Merge Join
  • Hash Join
    • simple
    • GRACE(Externally partitioned)
    • Hybird

030

Simple Nested Loop Join

  • 通过两层for循环,然后符合条件的进行输出
  • IO计算:因为外层循环要读取 M 个 page,循环的tuple 是 m,内存循环要读取N个page,所以内层循环的IO数是 m * N,总的IO:M + (m * N)
  • 假设 M = 1000, m = 10 0000, N = 500, n = 40000, 总的IO = 1000 + (10 0000 * 500) = 5000 1000
  • 假设 SSD 执行速度 0.1ms 一次IO,大概需要1.3个小时
  • 如果N是左表,那么总IO = 500 + (4000 * 1000) = 400 0500,大概需要1.1个小时
  • 所以如果左表是小表,性能更好
1
2
3
4
5
6
7
for (Tuple r: R) {
for (Tuple s: S) {
if (s.id == r.id) {
// 输出
}
}
}

Block Nested Loop Join

  • 对simple的优化,不在循环tuple,而是循环page,将page打包成block,然后循环block
  • 这样的话对于内层循环来说IO就是 M * N,总的IO就是 M + (M * N)
  • 假设 M = 1000, m = 10 0000, N = 500, n = 40000, 总的IO = 1000 + (1000 * 500) = 50 1000
  • 假设 SSD 执行速度 0.1ms 一次IO,大概需要50s
1
2
3
4
5
6
7
8
9
10
11
12
// 这个看上去循环多了,不过因为预先读取了两个block才循环,所以循环是在内存中,IO次数少了
for (Block br: R) {
for (Block bs: S) {
for (Tuple r: br) {
for (Tuple s: bs) {
if (s.id == r.id) {
// 输出
}
}
}
}
}

Block Nested Loop Join优化

  • 假设buffer pool容量是B,可以先获取B - 2个左表的Block,剩下2个位置,一个是获取右表的 Block 的,一个是输出的。
  • 这样的话总的IO次数:M + ([M/(B-2)] * N), M/(B - 2)向上取整
  • 最好的情况是 B > M + 2,代表一次性能获取所有的左表的Block
  • 这样总的IO就变成 M + N
  • 假设 M = 1000, m = 10 0000, N = 500, n = 40000, 总的IO = 1000 + 500 = 1500
  • 假设 SSD 执行速度 0.1ms 一次IO,大概需要0.15s

Index Nested Loop Join

假设s.id有索引,那么就可以根据索引进行匹配,加快速度.

  • 总的成本将是M + (m * C) C是索引需要的时间
1
2
3
4
5
6
7
for (Tuple r: R) {
for (Tuple s: Index(r = s)) {
if (s.id == r.id) {
// 输出
}
}
}

Sort Merge Join

  • Sort:先对要join的字段进行排序
  • Merge: 用两个指针进行匹配,如果数据匹配就输出,因为数据已经排序好了,所以只需要扫描一次就行了
  • 这样的话总IO就是 sort io + merge io, merge io = M + N, sort io看具体的排序算法
  • 最好的情况是要join的key本身已经是有序的了,那么只需要merge io = M + N,比如有索引,比如查询的时候使用了order by
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sort R,S on join keys
cursorR = RSorted, cursorS = Ssorted;
while (cursorR && cursorS) {
if (cursorR > cursorS) {
// 相当于内层循环指向下一个
cursorS++;
}
if (cursorR < cursorS) {
// 相当于外层循环指向下一个
cursorR++;
}
if (cursorR == cursorS) {
// 输出 && 内层循环指向下一个
cursorS++;
}
}

Hash Join

  • Build: 先对左表要join的key进行hash,构建一个hash table
  • probe: 在对右表要join的key进行hash, hash相同的会放入同一个 bucket,也就完成了匹配
1
2
3
4
5
6
for (Tuple r: R) {
insert hash(r) into hash table ht
}
for (Tuple s: S) {
insert hash(s) into hash tbale ht
}

Hash Join优化

  • 可以添加 布隆过滤器 来优化,这样的话在probe阶段,对右表的key, hash以后先查询布隆过滤器,如果false,就不需要在放入hash table去匹配了
  • 如果true在去hash table里面匹配数据完成输出

Grace Hash Join

  • 在 hash join中,只构建一个hash table来存储左表数据,右表的hash完成直接匹配
  • Grace hash join中,构建两个hash table,然后进行 nested loop join
  • 总的IO: 3(M + N),大约0.45s

hash 几乎总是好的。
排序是好的情况有两种

  • non-uniform数据,排序更好
  • 对于需要排序的数据,比如order by,排序更好

031

query exec

processing Method

  • Iterator Model: 大多数数据库使用的
  • Materialization Model:Iterator Model的一个特定版本,用在内存型数据库
  • Vectorized/ Batch Model:Iterator Model差不多,要传入一大堆东西, 分析型用的多

Iterator Model

  • 像java的stream, 流的方式执行
  • 先构建执行树,上层的通过next方法调用下层的方法并接收返回值

032

Materalization Model

  • 去掉了next方法,使用了output方法,一次输出所有数据给上层

033

Vectorized Model

  • 使用next方法,但是一次性返回一堆 tuples, 数量取决于 Buffer pool 大小
  • 使用OLAP,大多数的数仓使用这个

034

Access Method

  • 顺序扫描
  • 索引扫描
  • 多索引扫描

顺序扫描的优化

035

多索引扫描

Zone Maps

  • 通过在page上面增加一个元数据,存储min,max,avg,count,sum信息
  • 当查询的时候比如where val > 600,先查询 Zone Maps,如果发现max < 600,那么就不用在扫描这个page了
  • 缺点是插入,更新,删除的时候还需要更新Zone Maps信息,所以适用于 OLAP数据库

late materialization

  • 已经不需要的字段就不在往上层传了

Expression Evaulate

  • 先建立where条件的 Expression tree,中间节点是操作符,比如=,>,<,and,or等。子节点是两边的值
  • 对于每个tuple执行这个表达式
  • 好的数据库会对表达式进行优化,比如优化成常量,像where 1 = 1优化成 trues

Process Models

  • Process per DBMS Worker
  • Process Model
  • Thread per DBMS Worker
  • Embedded DBMS

Process per DBMS Worker

  • 每个进程是一个worker,负责执行任务
  • 通过共享内存进行buffer pool的通信,要不然每个进程都会有一个buffer pool。
  • 老得数据库大部分使用的这个,因为当时没有统一的线程API,像DB2,oracle,postgraSQL

Process Model

  • 和 Process per DBMS Worker一样
  • 但是增加了 worker pool,有多个worker进行调度处理
  • 像DB2,postgraSQL(2015)

Thread per DBMS Worker

  • 一个进程,多个线程执行,由数据库自己控制线程。
  • 现在的数据库几乎都使用这种,像DB2, MSSQL, MySQL, Oracle(2014)

scheduling

  • 将查询分解为多少个任务?
  • 它使用多少个CPU核心?
  • 哪个CPU执行哪个任务?
  • 任务输出到哪里?

Intra query parallelism

  • Intra operator(水平)
  • Inter operator(垂直)
  • Bushy(上面两种的组合)

Intra operator(水平)

  • 通过水平拆分数据,由多个线程执行,比如3个线程,一个线程处理一个page,以此类推
  • 处理完成以后通过exchange operator来进行合并,拆分也是通过它。

036

Exchange operator

  • Gather:从多个线程的结果合并成一个输出流,PostgreSQL用这个
  • Repartition: 重新组织多个输入流到多个输出流的数据,像group by,BigQuery用这个
  • Distribute: 拆分一个输入流到多个输出流

037

Inter operator(垂直)

  • 重叠的操作从一个阶段到下一个阶段的pipeline数据,没有具体化
  • workers同时执行多个operators从一个查询计划的不同部分
  • 也需要用到exchange operator
  • Spark,Kafka常用这个

038

Bushy

039

查询计划

假设有以下sql,其中Emp表有10000个records, 1000个pages, Dept表有500个records, 50个pages

1
select distinct ename from Emp E join Dept D on E.did = D.did where D.dname = 'Toy'

数据库将构建以下关系代数的树

040

按照这个关系代数的树来执行的话,总共需要 2M的IO

041

接下来将笛卡尔积的代数换成join的代数,就算使用Nested Loop Join,也能获得54K的IO

042

如果将Join算法替换成Sort-Merge Join,则可以将IO降低到7159

043

这个算法是基于Materialization Model的,所以每次还要写入文件,再读取。如果优化成Veectorization Model,减少重复的写入和读取,可以达到3151的IO

044

wraning:

  • 这非常难!
  • Andy对这部分知道的是最少得
  • 如果搞好了,很挣钱

查询结构

  • SQL rewrite (可选):重写sql语句,对sql语句进行优化
  • Parser: 解析SQL查询,构建语法树
  • Binder: 查询catalog信息,并将表名等信息替换成内部标识,生成逻辑查询计划
  • Tree rewrite (可选):重写树结构,包括关系代数等
  • Optimizer: 调用成本模型,预估成本,选择合适的执行计划,生成物理执行计划

045

生成物理执行计划的时候,可能有多个执行路径,在短时间内可能无法从全部的路径中选出最佳的。

查询优化

查询优化是很难的,有些数据库的查询优化做的很差,DB2曾引入机器学习做查询优化,效果并不好,被吐糟安装DB2要做的第一件事就是关掉这个功能

查询优化

  • 静态规则/条件触发
    • 根据静态的规则,或者触发了某一个条件来重写查询,移除低效率的东西
    • 需要检查catalog查看信息,而不需要去检查数据
  • 成本原则
    • 使用模型预估查询成本
    • 估计出多个查询计划,选择其中成本最低的一个

静态规则/条件触发

关系代数等价

  • 一个查询语句可以用多个关系代数来表示
  • 可以选择其中代价更小的那个关系代数
  • 这个被叫做 query rewriting 属于上面的 Tree rewrite阶段
  • 比如用join代替笛卡尔积

predicate pushdown

  • 比如select a.name,b.code from a join b where a.name = ‘abc’
  • 可以先join在where,也可以先where 再 join
  • 显然先where更好,把where放到join的下层执行
  • 还有可以再where之后只获取需要的列,其余不需要的列就不再往上层传递了

projection pushdown

  • 比如select a.name,b.code from a join b on a.id = b.aid
  • 可以在扫描a表的时候进行投影,获取id和name传到join节点处理,而不是全部字段传过去,b表同样

046

语句重写

  • 比如select * from a where 1 = 0; 那么不会返回任何数据。
  • select * from a where 1 = 1 会返回所有数据,重写成select * from a

成本原则

mongoDB没有使用成本预测模型,而是执行所有的查询计划,哪个最先返回就用哪个

最初是IBM提出的。枚举不同的查询计划,并估算他们的成本,在检查完所有的计划或者超时后,选择其中成本最低的一个。

  • single relation
  • multiple relation
  • nested sub-queries

single relation

  • 单表是比较简单的,比如根据后面的statistics决定走哪个索引更好
  • 可以顺序扫描
  • 可以二分搜索
  • 可以走索引

对于单表查询来说,一般会使用启发式规则,他来判断哪些where条件能筛掉更多的数据,就先进行哪个where。

sargable (search argument able):他会比较不同的索引,比如这个索引合适,那么就会使用它,比如id=1的,那么就会使用主键索引

multiple relation

  • 有两种方法,第一种是自底向上
  • 还有是自顶向下
system R 优化
  • 这是一个自底向上的multiple relation
  • 将查询分成block并为每个block生成逻辑运算
  • 为每个逻辑运算生成物理运算并实现它
  • 组合所有的join算法和访问路径
  • 生成一个left deep的树

比如有下面的SQL,ALBUM.NAME 字段有索引

1
2
3
4
5
6
SELECT ARTIST.NAME
FROM ARTIST, APPEARS, ALBUM
WHERE ARTIST.ID=APPEARS.ARTIST_ID
AND APPEARS.ALBUM_ID=ALBUM.ID
AND ALBUM.NAME="Andy's OG Remix"
ORDER BY ARTIST.ID

进行第一步,可以得到三个表的扫描方法

  • ARTIST:顺序扫描
  • APPEARS:顺序扫描
  • ALBUM:索引扫描

接下来组合不同的join算法,我们可以先连接ARTIST和APPEARS表,也可以先连接APPEARS和ALBUM表,或者ALBUM和ARTIST表,并且可以使用hash join 或者 merge join

047

最终为每个可能选择成本最低的join

048

接下来为每个可能去join其他表,来完成最终的三个表join,这个时候还是有hash join和merge join,选择最适合的join方法,就会产生三个路径

049

在从这三个中选择出成本最低的一个路径作为最终的路径

050

自顶向下优化

首先生成逻辑节点,最底下是三个表,最上面是三个表join并且order by,中间是两个表join

051

接下来先生成三个表的一个物理操作,因为需要order by,所以可以认为merge join更好。

052

接下来两个表的物理操作,可以选择hash join或这merge join,因为要排序,可以认为merge join更好

053

最后在探测其他的路径,比如最上层还可能有hash join,或者先hash join,在排序,但是这些都没有merge join的成本低,所以在探测到以后就可以Pass掉了

054

子查询优化
  • 重写去掉and/or来把他们变成同一个层级

比如以下SQL

1
2
3
4
5
6
SELECT name FROM sailors AS S
WHERE EXISTS (
SELECT * FROM reserves AS R
WHERE S.sid = R.sid
AND R.day = '2022-10-25'
)

可以被重写为

1
2
3
4
SELECT name
FROM sailors AS S, reserves AS R
WHERE S.sid = R.sid
AND R.day = '2022-10-25'
  • 分解子查询并将结果存储在临时表中,比如使用CTE,避免每个tuple都需要执行子查询

比如下面的SQL

1
2
3
4
5
6
7
8
9
SELECT S.sid, MIN(R.day)
FROM sailors S, reserves R, boats B
WHERE S.sid = R.sid
AND R.bid = B.bid
AND B.color = 'red'
AND S.rating = (SELECT MAX(S2.rating)
FROM sailors S2)
GROUP BY S.sid
HAVING COUNT(*) > 1

可以将子查询提取出来变成

055

catalog会记录一些成本信息,不同的DBMS有不同的更新策略,也可以手动更新,这被叫做statistics

  • PostgreSQL/SQLite : ANALYZE
  • Oracle/ Mysql: ANALYZE TABLE
  • SQL server: UPDATE STATISTICS
  • DB2: RUNSTATS

statistics: 维护着下面的信息

  • counter: 表中的tuple数量
  • V(A,R): R表中的A字段的去重数量
  • SC(A,R): 选择基数SC 是 counter / V(A,R) 的值

选择率:有了上面的数据,就可以计算出要查询的数据的分布比例了。这里就是求概率。

  • 比如查询主键 id = 1的数据,当前有数据5条,那么counter = 5,V(A,R) = 5, SC(A,R) = 1, 选择率 = 1/5
  • 比如范围查询 id > 2的数据,当前有数据5条,那么counter = 5, V(A,R) = 5, SC(A,R) = 1, 选择率 = (Max - A) / (Max - Min) = 5-2/5-1 = 3/4,显然这事错误的预测,但是数据库就是这样
  • 比如not查询 id <> 1的数据,当前有数据5条,那么选择率 = 1 - (id = 1的选择率) = 1 - 1/5 = 4/5
  • 比如多个条件 and, 那么取交集,也就是 两个选择率 相乘 = sel(AB),这种计算同样不太准确,比如有一个汽车表,有make字段代表生产商,model代表型号,我们知道model = “帕萨特”,make 一定是 大众。按照我们的算法 假设make 有10个,选择率就是1/10, model 100个,选择率就是 1/100,总的选择率就是 1/1000,但是帕萨特一定是大众的,所以真实选择率其实是1/100。有些数据库可以设置字段关联来解决这个问题,比如oracle等,mysql和postgresql不行。
  • 比如多个条件 or, 那么取并集,也就是两个选择率相加 = sel(A) + sel(B) - sel(AB)

056

直方图的存储,由于存储所有信息的直方图可能很占空间,可以选择稀疏存储,合并一些数据,这样会牺牲一些准确率,但是节省空间。

除了直方图以外,有些数据库还会使用抽样检查,花费一些时间进行抽样,然后根据样本来进行预测选择率。

并发控制原理

原子性:事务的每个操作都是原子的,要么全成功,要么全失败,通过undo redo log实现
一致性:保证事务执行前和执行后是一致的,中间可以临时不一致,但最终要一致。通过raft等共识协议实现
隔离性:保证事务的隔离性,每个事务都是独立运行的,并发的时候通过并发控制协议来保证交错执行,通过latch保证正确性。通过并发控制实现
持久性:事务提交后数据持久保存了,通过undo redo log实现

当转账的时候,事务被突然的中止,或者断电,该怎么做?

  • Logging
  • shadow paging

Logging

  • 记录所有的操作,使得事务中止或者故障后可以undo 操作。
  • 在磁盘和内存中维护 undo records
  • 就像飞机上的黑盒子一样

shadow paging

  • 将数据复制到一个副本中进行事务更新,如果成功了,将副本作为新的数据库,如果没成功也不影响当前的数据库
  • 起源于System R
  • CouchDB和LMDB使用这个方法

并发控制协议:

  • 悲观的:两阶段提交
  • 乐观的:时间戳

顺序执行:

  • 顺序执行每个事务,保证事务的最终一致性

交错执行:

  • 如果能达到顺序执行的结果,那么就是正确的执行 schedle

假设a,b账户都有1000,那么经过t1事务和t2事务执行以后,总的结果应该不变,对于数据库来说,哪个事务先执行都可以,如果想控制事务执行顺序,应该由应用层控制。

057

如果交错执行的结果和顺序执行的结果不一样,就是错误的

058

总共会出现三种冲突

  • 读写冲突
  • 写读冲突
  • 写写冲突

读写冲突(不可重复读):当读第一次的时候,值被其他事务改变了,再次读的时候,值就和第一次读的时候不一样了

059

写读冲突(读未提交或脏读):A事务读取后,修改了值,B事务读取了修改的值,然后又修改了值,B事务提交后,A事务中止,回滚。

060

写写冲突(覆盖数据):两个事务同时写入一个值,有一个值会被覆盖掉。

061

冲突可串行化,大多数数据库使用的,还有个视图可串行化,没数据库实现

  • 通过比较两个操作是否冲突,来修改顺序
  • 使用依赖图(优先图)来判断依赖是否出现环

假设事务t1写入了A数据,事务t2同时读取A数据,那么事务t2就依赖了事务t1

062

假设事务t2又写入了B,事务t1要读取B,那么事务t1就依赖了事务t2,这个时候就产生了循环依赖,就跟死锁一样,所以这个时候需要回滚一个事务

063

两阶段锁

事务A先获取锁,事务B等待锁,事务A执行完成以后,释放锁,事务B才能拿到锁。

共享锁,读锁,S-LOCK
独享锁,写锁,X-LOCK

064

如上图所示,这样还是会出现不可重复读的问题,两阶段锁可以解决这个问题,两阶段锁是第一个正确的并发控制协议

两阶段锁,遵循这个方法,使得事务是冲突可串行化的,但是会有级联事务中止(cascading aborts),可以通过强严格两阶段锁解决这个问题。

  • 第一阶段Growing:每个事务从lock manager获取锁,事务释放锁以后进入第二阶段shrinking
  • 第二阶段Shrinking:事务释放锁以后不能获取新锁,只能释放锁或者提交事务释放持有的所有锁

从生命周期来看,第一阶段是上升,第二阶段只会下降,不会再次上升,下图是正确的生命周期

065

下图是错误的生命周期

066

cascading aborts,如果事务t1中止回滚了,那么事务t2就发生了脏读,所以也需要回滚重来

067

严格两阶段锁

在提交事务的时候才释放锁。可以解决脏读的问题。

非两阶段锁执行如下:

067

两阶段锁执行如下:

067

严格两阶段锁执行如下:

070

死锁

死锁检测:通过使用wait-for图来检测依赖关系,如果有环就是死锁

检测的频率可以通过参数调整,这个需要权衡

victim选择,选择出回滚哪个事务,这也是企业级系统和开源系统的区别

  • 可以根据时间戳,选择年龄小的那个,认为刚加入的回滚成本小
  • 根据持有锁的数量,选择持有锁少的那个回滚
  • 根据已完成的工作量,选择查询数量少的那个回滚,可以认为回滚一个查询的成本比几十个查询的成本小
  • 根据剩余的工作量
  • 根据回滚的次数,这个事务老回滚,可能同情它不让它回滚,以避免“饥饿”

死锁预防

根据时间戳来选择

  • wait die: 老的等待新的,如果事务t1先开始,事务t2后开始,t2先获取了锁a, 然后t1要获取锁a,那么就等待,因为老的要等待新的,因为t1是老的,如果t1先获取锁a,t2新的后获取那么t2abort
  • wound die: 新的等待老的,如果事务t1先开始,事务t2后开始,t2先获取了锁a,然后t1要获取锁a,那么t2就abort,给老的让路,因为老的优先级高,如果t1先获取锁a,t2新的后获取锁a,那么就等待,因为新的等待老的

071

数据库锁层次

072

意向锁

意向共享锁IS

  • 使用共享锁在较低级别锁定,共享锁粒度更细

意向排它锁IX

  • 使用排它锁在较低级别锁定,排它锁粒度更细

共享意向排它锁

  • 高级别用共享锁,低级别用排它锁

073

假设t1事务要查询一个tuple,那么可以在table上一个IS锁,然后在具体的tuple上一个S锁,接下来t2事务要更新一个tuple,发现表上是IS锁,那么根据规则,可以在table上在加一个IX锁,然后在要更新的tuple上一个X锁,如果恰好是同一个tuple,那么就等待S锁释放,如果不是同一个,那么就并发执行成功

073

假设t1事务要扫描所有tuple来找到一个tuple去更新,那么table上一个SIX锁,然后扫描到一个tuple,就给一个tuple上X锁,扫描完就释放X锁,接下来t2事务要读取一个tuple,发现表上是SIX锁,那么根据规则,可以在table上一个IS锁,然后在要查询的tuple上S锁,如果刚巧tuple上有X锁,那么就等待t1事务释放。

073

时间戳并发控制

两阶段锁是一种悲观的协议,所有人都会上锁,会争抢,时间戳是一种不依赖锁的乐观的协议。

Ti代表事务i得一个时间戳,Tj是j的,如果Ti < Tj,那么i得事务会在j之前提交。

时间戳的两个特性

  • 唯一性,每个时间戳必须是唯一的
  • 单调递增性,时间戳必须是增加的

基本时间戳协议

每个tuple需要维护两个时间戳,一个读时间戳,一个写时间戳

在读取的时候要保证,当前时间戳 > 写时间戳,也就是读取的是最新的值,未来不会被改变的值。
如果 当前时间戳 < 写时间戳,那么重启事务,分配一个新的时间戳,再试一次。
如果成功取到tuple,那么需要更新读时间戳,使用自己的时间戳和原来的时间戳中大的那个去更新。

写入的时候要保证,当前时间戳 > 读时间戳 并且 > 写时间戳
成功写入的时候要更新写时间戳,使用自己的时间戳和原来的时间戳中大的那个去更新。

托马斯写入优化

在写入的时候,如果当前时间戳 < 写入时间戳,本来应该中止的事务,可以继续执行,但是写入操作不写入数据库,因为数据库的数据是更新的,而是写入本地副本,方便这个事务后面使用。

乐观并发控制

另外一个是乐观并发控制。为每一个事务创建一个私有空间,他的所有操作都是先对私有空间的副本操作,最后执行到数据库里面的时候需要对比一下,是否能执行。如果不冲突,就可以执行。

三个阶段

  • read phase(work phase): 执行事务的操作,操作都在私有空间执行。
  • validation phase: 提交事务的时候验证事务的有效性,是否冲突等,确实是否可以提交。在这个阶段才会分配时间戳。如果在之前需要写入时间戳,先暂时写入无穷大。并检测是否和其他事务的时间戳冲突,其他事务包括以前的所有事务和正在执行的事务
  • write phase: 如果校验成功,将时间戳写入W-TS列,将修改写入全局工作区,否则abort事务,这是原子的。

假设事务t1读取tuple A,将从全局工作区复制A到私有工作区

076

接下来事务t2读取tuple A,也将从全局工作区复制A到私有工作区

077

接下来事务t2进入validation阶段,然后分配时间戳为1,接下来进入写入阶段,什么也不做

077

事务t1修改tuple A,然后再次读取tuple A,可以重复读取,因为读取私有工作区的tuple A

077

事务t1进入validation阶段,分配时间戳为2,因为1已经分配给t2事务了,写入阶段将私有工作区的tuple A,更新到全局工作区

077

数据库拥有全局视野,在validation phase阶段,是单线程比较事务是否可以执行,会有一个大的latch上锁

  • backward validation: 和并发执行中更早已经提交的事务进行比较
  • forward validation: 和并发执行中后面没有提交的事务进行比较

forward validation

如果事务t1 < t2,t1的写阶段在t2的读阶段之前,则没有任何冲突发生。

如果事务t1 < t2,但是t1的valition阶段在t2的validation阶段之前,则冲突,因为t1已经更改了tuple A ,而t2读取的是W-TS=0的tuple A,如果t1事务的W-TS是1,t2是2,那么t2读取的应该是W-TS=1的tuple A才对

081

如果t2的validation阶段在前就没问题,因为t2分配W-TS=1,t1是2

082

如果事务t1 < t2 ,t1的validation阶段在t2的前面,但是t1已经写入全局工作区以后,t2在读取这个tuple,就没有问题

083

forward validation和没有提交的事务进行比较
084

backward 和已经提交的事务比较

085

partition based 时间戳协议

按照时间戳水平分区,在同一个区里面,按照时间戳顺序执行,就不需要latch了。速度会很快。

在不同区执行的话,就很复杂了。

每个分区都是单线程执行的。这样不需要获取latch。

幻读问题

假设事务t1读取了table a的count()是99,事务t2插入了一条数据,事务t1再次读取count()就变成了100,因为t1只能lock已经存在的数据,要插入的数据没办法lock

086

解决幻读的三个方法

  • 重新执行扫描:提交后重新执行扫描看结果是否一致,不一致就发生了幻读,少见
  • predicate locking:System R提出的方案,锁定where语句,进行比较,很难实现,非常少见,duckDB,Hyper等实现了
  • Index Locking: 索引锁定,常见

Index Locking

  • key-value locks
  • Gap locks (间隙锁)
  • Key-Range locks
  • Hierarchical locks

key-value locks只能锁定键值,需要一个virtual key来锁定不存在的key

087

间隙锁,锁定键值和下一个键值之间的间隙

088

key-Range locks,锁定键值以及和下一个键值之间的间隙

089

Hierarchical locks使用IX,IS等意向锁

不同隔离级别对应的问题,最受欢迎的隔离级别是读已提交,mysql默认是可重复读

090

多版本并发控制(MVCC)

MVCC(Multiple Version Concurrency Contronl)最早在1978年由一位MIT的Phd学生提出。在1980年被数据库实现。

firefox 最开始叫 phoenix, 但是因为和其他的重名了需要改名字,然后改成了firebird, firebird是个最早开源的数据库,它使用了MVCC,所以火狐还要改名,就成了firefox。

  • Writers don’t block readers
  • Readers don’t block wirters

只读事务读取快照,不需要锁。MVCC天然支持快照隔离,如果没有gc,支持time travel query,就是可以查询很久以前的更改

版本维护有3个元数据

  • version 版本号 从0开始,递增
  • begin 开始的时间戳
  • end 结束的时间戳,默认是无穷大,当写入以后要更新上一个版本的end

090

还要维护一个事务状态表

  • txnid: 比如事务t1,t2标识
  • timestamp: 时间戳,如OCC那样
  • status: 活动中,已提交等

090

假设事务t1读取A0的数据,然后事务t2写入A1数据,然后更新A0的end-ts=2,事务t1再次读取的时候还能看到A0

090

假设事务t1读取A0的数据,然后写入A1的数据,事务t2读取A0的数据,写入A2的数据,这个时候t2会阻塞,因为t1持有A的锁,当t1事务commit后,释放锁,事务t2才能继续写入A2的数据

100

当事务启动的时候,会看到启动时数据库里的一致的快照

  • 没有来自未提交的事务的撕裂写入
  • 如果两个事务更新同一个tuple,第一个写入将获胜

快照隔离收到写入偏差异常(Write Skew Anomaly)的影响。

写入偏差异常,假设当前有2个黑球,2个白球,事务t1要将白球更新成黑球,事务t2要将黑球更新成白球,这个时候事务t1读取到2个白球,只将这个两个白球更新成黑球了,而事务t2读取了2个黑球,只把这2个黑球更新成白球了,最终结果还是2黑2白

100

但是顺序执行的话结果应该是全白或全黑。

100

并发控制协议

  • timestamp ordering:分配一个确定顺序的时间戳
  • OCC:乐观并发控制
  • 两阶段锁

版本存储

版本存储:为每个逻辑tuple创建一个链表,每个事务通过指针遍历链表获取对应的版本。索引指针指向链表的头节点。

  • append only storage: 复制一个tuple,更新数据,放到后面的节点作为tuple的新版本。
  • time travel storage: master version表中放最新版本的数据,老版本的数据放在 time travel 表中。master version表维护指向time travel表数据的指针。
  • Delta Storage: 最佳方案,只维护对前一个版本数据的修改,不直接维护所有副本。

append only storage

要么最新到最旧的连接,要么最旧到最新的连接,全都放在一个工作空间中,然后用指针连一起

090

time travel storage

这个是两个空间来存储,一个main table存储最新的,一个time travel table存储所有旧的,像Sql server这种最初没有设计MVCC的数据库,为了兼容使用了这种方法。

090

Delta storage

也是两个空间存储,不同的是旧空间只存储修改的列的值

090

garbage collection

garbage collection(垃圾回收)

  • tuple level:通过比较时间戳来确定哪些版本已经不用了,可以进行回收了。
  • transaction level:在事务提交的时候进行对比旧版本的数据是否可以删除

tuple level gc

vacuum

后台线程的方式运行,每隔一段时间去扫描,还可以通过bit map来提升速度

假设现在正在运行的事务有两个,最低时间戳是12,那么所有end-ts低于12的都可以被gc,数据库基本都用的这个

090

cooperative cleaning

当查询某个tuple的时候,进行扫描这个tuple的旧版本,从而gc,缺点是如果这个tuple一直不被访问,那么就一直不gc,很罕见

090

transaction level gc

在事务提交的时候进行对比旧版本的数据是否可以删除

090

索引管理

索引管理

  • 逻辑指针:通过中间表转化指针和物理地址,二级索引指向主键索引。
  • 物理指针:直接记录指针

物理指针

所有的索引都记录指针,缺点是更新的时候所有索引的指针都要更新

103

逻辑指针

二级索引都记录主键,只有主键索引记录指针,mysql就这样的,这种方式更好

104

重复key问题

如果索引不是唯一索引,可以有多个key的话,假设事务t1读取A1,没问题

假设事务t2更新了A1,又删除了A1,那么数据如图所示A1老版本指向新版本,但是新版本是被删除的
104

假设事务t3插入了一条A1数据,那么索引就指向两个A1数据

104

MySQL使用两阶段锁,版本存储使用Delta Storage,垃圾回收是tuple level Vacuum,索引管理是逻辑指针。Mysql更快。
PostgreSQL使用两阶段锁,版本存储使用append only storage, 垃圾回收使用tuple level Vacuum,索引管理是物理指针。

MVCC实现

104

Logging Schemes

在数据库运行时,还没有把数据写入磁盘的时候发生故障,这个时候需要恢复数据。

故障恢复

主要是两件事

  • 收集日志信息,以方便恢复
  • 根据日志信息进行恢复

故障类型

  • 事务故障
  • 系统故障

UNDO(撤销) and REDO(重做)

Undo:维护一些信息,可以恢复事务对数据库中某个对象所做的任何修改。
Redo: 维护一些信息,可以重新执行某个事务对数据库中的某个对象所做的修改。可以重新执行一个已经提交的事务的修改。

buffer pool 策略

两个策略

  • steal:是否允许一个已提交事务写入磁盘的时候把这个page里面的未提交事务的修改也写入磁盘。
  • force:提交事务的时候,是否允许事务的所有更新写入磁盘

not steal + force

  • 优点:不需要恢复,因为只写入磁盘提交的事务更新的内容。磁盘就是已提交内容
  • 缺点:多个事务提交需要写入多次磁盘,写入磁盘麻烦点,因为要复制一个副本出来,副本中是这个事务修改的内容,把这个副本写入磁盘。

104

shadow paging:

  • 有一个db root,记录当前使用的hash table
  • hash table有一个master table是当前使用的,每个事务会有一个shadow page table,提交以后更新db root指向这个shadow page table,然后回收之前的master table和对应的page文件

104

wirte ahead log

  • 先写入日志,在写入磁盘
  • 日志中包含的信息可以用来undo and redo。
  • 使用的策略是 steal + not force,所以不需要把事务的所有更新都写入磁盘。

日志内容

  • 事务id
  • 对象id
  • 时间戳
  • before value(undo)
  • after value(redo)
  • 写入更快,但是恢复速度慢

当事务执行的时候,写入日志到内存中,比如事务t1,将A从1修改到8,当事务提交的时候,先把日志写入磁盘,然后告诉客户端事务成功,如果这个时候发生断电等,可以读取日志来恢复事务,如果写入日志之前断电,那么无需恢复,因为没有告诉客户端成功,事务没有成功如何办是客户端要考虑的。

104

group commit

  • 有两个log buffer,写满一个以后,写另一个,将满的log buffer写入磁盘
  • 除了满的时候写入磁盘,还有定时,如果知道写入磁盘的时间,可以定时成这个时间,没满的话时间到了也会写入磁盘。

满了的写入磁盘可以异步写入,增加速度

111

shadow paging 和 WAL 就是一种权衡,是看重运行时速度还是恢复速度,几乎所有的数据库都是用WAL,比如mysql的bin log, WAL运行时速度更快

111

logging schemes

  • 物理日志:记录底层字节的修改,就像git diff一样能看见,但是修改1万条tuple,就会记录1万条tuple的修改。
  • 逻辑日志:记录高层的事务操作,像update,delete,insert等。比物理日志省空间,但是恢复的时候不知道哪些tuple已经写入了磁盘,所以恢复的时候还会在写入一遍。
  • 混合日志:记录相对底层的修改,但是不像git diff一样那么详细。

111

check point

WAL的缺点是无限增长,如果崩溃后,则需要重现所有的WAL,增加检查点以后,可以只重现检查点以后的日志。

日志中写入check point,check point之前的都是已经写入磁盘的,所以恢复的时候就不用管了。

假设事务t1在检查点前commit了,则不需要恢复,因为已经提交了,事务t2在检查点后commit,在检查点前开始,则需要redo恢复事务t2,事务t3在检查点前开始,但是还没有commit,所以不需要恢复,直接撤销即可。

114

aries recovery

三步

  • 预写式日志
  • Repeating history during redo: 重启并恢复事务到之前的状态
  • logging changes during undo:再次写入预写式日志

日志序列号(LSN)

  • 每个日志都要有一个序列号,在一个事务中,可能不是连续的,但是递增的。
  • flushedLSN:在内存中,记录上一个刷到磁盘中的log的LSN
  • pageLSN: 在page中,记录page最新的序列号
  • recLSN: 在page中,记录最老的序列号,这个是不会变的,而pageLSN一直在更新。
  • lastLSN: 记录事务中最新的一个日志
  • Master Record: check point最新的LSN

如果pageLSN <= flushedLSN, 表明这个page的数据都已经写入磁盘了

事务提交的时候,往日志里面写入一个txn end

当事务commit后写入磁盘,然后更新flushedLSN,接下来添加txn end标识

114

CLR:abort算法

  • 当事务abort的时候,进行撤销操作,日志里面添加CLR记录,before是对应的之前事务日志的after,CLR的after对应之前的before,undo next指针指向下个需要撤销的日志。
  • 撤销完成以后添加 txn end

写入的时候如同链表一样,除了记录本次的LSN以外,还要记录上一个的LSN,比如本次001,上次是Nil,本次002,上次001,以此找出这一次事务的所有日志,比如事务t1是001-nil,002-001,接下来事务t2是003-nil,004-003,然后事务t1是005-002。

接下来插入CLR日志,CLR可以是026-011,CLR-002代表要撤销的是LSN=002的日志,记录了从40恢复到30,最后在插入txn-end标识

114

check point写入

  • 第一种方式是停止创建新事务,等所有事务完成的时候开始写入
    • 优点是能完全保证数据一致性
    • 缺点是耗时,执行的时候不能创建新事务
  • 第二种方法暂停更新的事务,只读事务不受影响,需要维护一个active transation table和一个dirty page table
    • active transation table包含事务id,lastLSN和状态,状态有运行中,提交中,等待undo
    • dirty page table 包含所有的dirty page信息
  • 第三种方法最好,叫fuzzy checkpoint,checkpoint的时候允许所有事务运行。
    • 开始的时候记录checkpoint begin
    • 结束的时候记录checkpoint end,end里面包含了 checkpoint期间的active transation table和dirty page table信息。

ATT(Active Transation Table)

114

DPT(Dirty Page Table)
114

第一个check point记录了ATT是T2事务,代表事务t2在check point之前开始,且未提交,而事务t1在check point之前已经提交了,所以不记录,DPT记录了事务t2在check point之前修改的脏页是P22

第二个check point记录了事务T2,T3,因为T2虽然在第二个check point之前提交了,但是没有插入txn-end代表没有提交结束,DPT记录了两个check point之间的脏页P11和P33

114

fuzzy check point

因为所有事务都在运行中,所以增加了check point begin 和 end来标识check point的开始和结束,第一个check point end里面记录了事务t2和脏页P22,是因为这些发生在check point begin之前。

120

arise recovery

  • 分析:根据master record跳到对应的check point的位置,然后开始扫描需要恢复的信息。
  • redo: 重新执行所有的操作。
  • undo: 从日志最后开始往上,撤销所有未提交的更改。这个时候已提交的已经写入磁盘,未提交的已经被撤销。

分析阶段:从master record的位置开始扫描到最后,找出这之间的所有active transation table和dirty page table信息。

redo: 根据分析出的信息,找到dirty page中最早的一个recLSN, 也就是最早的一个日志,然后从这里开始恢复数据,执行一遍所有的操作。来恢复buffer pool。

undo: 从最后开始往上面扫描,把需要撤销的数据进行撤销。

分布式数据库介绍

并行数据库

  • 离得近的
    分布式数据库
  • 离得远的

系统架构

  • shared everthing
  • shared memory:常见于高性能计算领域,有多个CPU,共享内存和磁盘
  • shared disk: 内存也有多个,共享磁盘,这个更常见,例如云数据库
    • 更新数据的时候需要通知其余的节点
    • spark,HBase
  • shared noting: 磁盘也是多个
    • 有更好的效率,但是很难保证数据一致性和扩容
    • mongo,ES,Etcd,Zookeeper,ClickHouse

shared noting example

首先通过catalog查询应该请求哪个节点

120

然后发送查询请求到相应的节点

120

如果数据跨节点存储,那么数据会节点间通信,比如获取100和200的数据,node p1会请求p2获取200的数据,然后p1将两个数据返回给客户端

120

shared disk example

客户端请求节点获取数据,节点请求disk获取数据,然后返回

120

但是更新的时候需要广播给其他节点

120

mongo属于shared noting

  • route节点,负责把请求路由到对应的节点上
  • config节点,route从这里获取请求应该到哪个节点上
  • db节点

数据拆分

  • 将不同的数据放到每个shared上面
  • 不同的查询交给不同的shared去做,可以通过exchange operate来并行执行。
  • 最简单的方式是一个表一个分区,mongodb可以这样。
  • 水平分区
    • 将数据水平分到每个分区中
    • 可以是hash,也可以是一个范围一个范围的分区
    • hash的话,想增加分区很麻烦,可以使用一致性hash来解决增加分区的问题,而不是取余。
    • 采用一致性hash的有snowflake,memcached,cassandra等

SHARED-DISK PARTITIONING
120

SHARED-NOTHING PARTITIONING
120

一致性hash

节点分布在环上,数据也分布在环上,顺时针旋转,数据就属于第一个到达的节点。

120

增加节点的时候,只需要重新hash其中一个节点的数据就可以,比如增加p4,因为P4节点落在p3节点的范围里面,所以只需要重新hash节点p3的数据,其他节点无影响。

120

如果需要复制数据,将数据存储在多个节点上,假设replication=3,复制数据到三个节点,则顺时针旋转的3个节点都存储该数据。

130

分布式事务

  • 通过中心化服务器,来分发lock,然后提交事务的时候通过他来请求每一个分区是否能提交事务,如果都可以,才提交事务
  • 去中心化事务提交

spanner

HNSW

分布式OLTP数据库

假设所有节点是友好的

replication:可以提高可用性

  • 分区 vs 非分区
  • shared noting vs shared disk
  • 设计
    • 副本配置
    • 传播方案
    • 传播时序
    • 更新方法

副本配置

  • primary: 有一个主节点,其他是从节点,大部分都是这样的,主节点负责同步给其他从节点。比如主从复制,读写分离这种
  • multi-primary: 事务可以在任何一个节点提交,并同步给其他节点,mysql group replication就支持这个。这种方案所有副本都可以读写,冲突的时候通过两阶段提交或者Paxos来觉得写入哪个。Facebook使用了这个。

primary,写入主节点,读取可以在从节点

130

multi-primary 任何节点都可以读写

130

K-safety: 通过监控对象来看有哪些replica是活跃的。至少要有k个replica,如果小于k个,就认为宕机了。

传播方案

  • 同步:强一致性,所有从节点都同步以后才返回成功
  • 异步:最终一致性,先返回成功,在同步给其他的从节点
  • 半同步:同步给一些节点后返回成功给客户端

传播时序

  • 即时:立即传递给其他节点,同时当事务提交或回滚的时候也传递给其他节点
  • 提交:事务提交的时候才传播给其他节点

事务提交的顺序由数据库状态决定,原子提交协议也是分布式的共识协议

原子提交协议

  • 两阶段提交(1970)
  • 三阶段提交(1983)
  • Viewstamped Replication (1988)
  • Paxos(1989)
  • Raft(2013)
  • ZAB(2008)

两阶段提交

  • 第一阶段 prepare, 像所有参与者发送请求,是否能提交事务,只有所有都可以提交,才进入第二阶段,如果有一个节点abort,那么事务就会进入第二阶段abort
  • 第二阶段 commit, 想所有参与者发送请求,进行提交事务。
  • 第二阶段 abort, 返回abort,然后所有参与者abort。

假设客户端发送提交事务的请求,有一个协调器和若干个参与者,协调器接收请求以后,向参与者发送第一阶段prepare请求。

130

如果所有参与者返回OK,表示全部同意提交,则可以提交。

130

接下来协调器发送第二阶段commit请求给所有参与者

130

所有参与者返回OK以后,协调器返回提交成功给客户端

130

假设有任何一个参与者在第一阶段返回了不同意,则终止事务提交

130

协调器向所有节点发送第二阶段abort请求

130

等待所有节点返回OK以后,协调器返回abort给客户端

130

崩溃恢复

  • 如果协调器崩溃了。要么全部abort,要么选出新的协调器继续执行事务。
  • 如果参与者崩溃了,那么协调器会认为参与者返回了abort,从而终止事务。

优化

  • early prepare voting: 这个很少,最后一个查询执行的时候,告诉他可以直接进入prepare阶段
  • early Ack After Prepare: 这个很常见,prepare阶段结束就返回事务执行成功,然后自己再接着commit。

early Ack After Prepare:当第一阶段prepare返回成功以后,立即给客户端返回成功。

140

返回给客户端以后再发送第二阶段commit请求给其他参与者。

140

Paxos,来自分布式计算领域,也被称为共识协议。两阶段提交是Paxos的一个子集。

  • Paxos大部分节点同意提交就可以提交,而两阶段提交需要所有参与者同意,所以Paxos没有容错。

140

multi-Paxos

  • 选举一个leader来进行提案,每隔一段时间重新选举
  • 防止有两个 proposer 来回提交导致的starving问题

两阶段提交 vs Paxos vs Raft

两阶段提交

  • 如果协调器在发送prepare消息后挂掉,则阻塞直到协调器恢复

Paxos

  • 如果大多数参与者活着则非阻塞,前提是有足够长的时间不再出现故障

Raft

  • 与Paxos类似,但节点类型较少。
  • 只有拥有最新日志的节点才能成为领导者

CAP理论

  • Consistent: 一致性
  • Always Available: 始终可用
  • Network Partition Tolerant:分区容错性

Nosql基本都是AP,事务性的基本都是CP

一致性代表从哪个节点获取的数据都是一样的

140

可用性代表当节点挂了以后系统还可以使用

140

分区容错性最难,容易出现脑裂问题。当网络挂掉以后从节点以为主节点挂了,所以把自己选举为主节点,就产生了两个主节点

140

当网络恢复以后,两个节点的数据就不一样了。

140

解决方法1:停止系统

  • 停止接受没有大多数节点的分区中的更新。

解决方案2:允许拆分,协调更改

  • 允许分区的每一侧都接收更新
  • 根据时间来确定最后的版本,以最后的版本为准
    • 服务端:last update wins 用最后的版本为准
    • 客户端:vector clocks(不要这么做)

2010年提议对CAP进行扩展

  • Partition Tolerant:分区容忍
  • Always Available:随时可用
  • Consistent:一致
    • Latency:延迟
    • Consistency: 一致性

分布式OLAP数据库

OLAP数据库也被称作数据仓库,通过ETL,把数据存入数据仓库。

  • Extract
  • Transform
  • Load

星形模型

  • 一个事实表
  • 几个dim表

雪花模型

  • 一个事实表
  • 更多的dim表,dim表可以有他的dim表

查询执行

  • push: 发送查询到包含数据的节点,返回数据的时候会做过滤和处理,就像条件下推那样
  • pull: 知道需要的数据在哪些page里面,把page取过来在执行查询。

push

pull

查询计划

  • 物理操作: 先生成查询计划,然后将对应的需要执行的物理操作直接发送给其余节点,其余节点只负责执行,返回数据。大部分分布式数据库都这样。
  • sql: 将sql发给每个节点,每个节点生成自己的执行计划。然后执行返回。

MySQL学习路线

大家好,我是大头,98年,职高毕业,上市公司架构师,大厂资深开发,管理过10人团队,我是如何做到的呢?

这离不开持续学习的能力,而其中最重要的当然是数据库技术了!

对于所有开发来说,都离不开数据库,因为所有的数据都是要存储的。

其他的技能可能会变,比如业务开发、算法开发、基础设施开发,也不管你是用Java、php、golang、C++等等。你都会用到数据库,因此,学好数据库对于我们来说就至关重要了。

接下来大头将分享自己学习数据库的路线以及心得。

后续也会根据这个路线分享所有的学习方法以及实操案例。关注我一起学习!文末有惊喜哦!

如何学好数据库

如何学好数据库?我相信这是一个老生常谈的问题了,如何学好XXX,这里我觉得最重要的是实践

相信大家都知道这句话。实践是检验真理的唯一标准。因此,当分享结束以后,大头还会分享完整的实操应用,完全免费分享,放在外面至少价值大几百的实操应用训练营。

在现在这个AI发展迅速的时代,你不用点AI好像就被社会淘汰了一样。所以,我们来看一下AI给出的回答。

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
学好数据库需要系统的学习方法和实践相结合,以下是一些建议:
1. 基础知识积累
理解数据库概念:首先明确数据库的定义,它是存储、管理和检索数据的系统。了解其基本功能,如数据存储、查询、更新和删除等。
学习数据库类型:常见的数据库有关系型数据库(如MySQL、Oracle、SQL Server)和非关系型数据库(如MongoDB、Redis)。了解它们的特点和适用场景。
掌握数据库模型:重点学习关系型数据库的表结构、字段类型、主键、外键等概念,以及非关系型数据库的键值存储、文档存储、列存储等模型。
2. 理论学习
学习SQL语言:SQL是数据库的核心语言,用于操作关系型数据库。学习SQL的基本语法,包括数据定义语言(DDL)、数据操纵语言(DML)和数据控制语言(DCL)。
DDL:创建、修改和删除数据库对象(如表、索引)。
DML:插入、更新、删除和查询数据。
DCL:控制用户对数据的访问权限。
理解数据库设计原则:学习如何设计合理的数据库结构,包括范式理论(如第一范式、第二范式、第三范式),以减少数据冗余和提高数据一致性。
学习事务管理:了解事务的ACID特性(原子性、一致性、隔离性、持久性),以及事务的提交和回滚机制。
掌握索引和优化:学习索引的原理和类型(如B树索引、哈希索引),以及如何通过索引优化查询性能。
3. 实践操作
安装和配置数据库:选择一种主流的数据库(如MySQL、SQLite),在本地安装并进行配置。熟悉数据库的启动、停止和基本管理操作。
动手操作SQL:通过实际操作来巩固SQL语言的学习。可以使用在线数据库平台(如SQLFiddle)或本地数据库进行练习,编写和执行SQL语句,完成数据的增删改查操作。
进行数据库设计:尝试设计一个小型的数据库项目,如学生管理系统或图书管理系统。从需求分析开始,设计表结构,确定字段类型和关系,然后实现数据库的创建和数据的插入。
参与项目实践:如果有机会,参与实际的数据库项目开发,或者在开源项目中学习和实践。通过实际项目,了解数据库在真实场景中的应用和优化方法。
4. 进阶学习
学习高级SQL技术:如子查询、连接查询(内连接、外连接)、分组查询、窗口函数等。
了解数据库架构设计:学习如何设计高可用、高性能的数据库架构,包括主从复制、读写分离、分库分表等技术。
掌握数据库安全:学习如何保护数据库的安全,包括用户认证、授权、数据加密、备份和恢复等。
学习数据库性能优化:了解如何分析和优化数据库性能,包括查询优化、存储优化、硬件优化等。
5. 持续学习和拓展
关注新技术:数据库领域不断发展,新的技术和工具不断涌现。关注NoSQL数据库、分布式数据库(如Cassandra、HBase)、云数据库(如AWS RDS、阿里云数据库)等新技术的发展。
阅读经典书籍和文档:推荐一些经典书籍,如《数据库系统概念》《SQL必知必会》《高性能MySQL》等。同时,阅读数据库官方文档,深入了解其特性和最佳实践。
参加培训和认证:如果条件允许,可以参加数据库相关的培训课程或考取相关认证(如Oracle认证、MySQL认证),提升自己的专业水平。
加入技术社区:加入数据库相关的技术社区(如Stack Overflow、CSDN、知乎等),与其他开发者交流经验和问题,拓宽视野。
总之,学好数据库需要理论学习和实践操作相结合,通过不断的学习和实践,逐步提升自己的数据库技能。

可以看到AI给出的回答相当不错了。看起来也是那么回事。

对于学习一种新事物来说,我觉得要分为几个阶段吧,对于所有新事物都适用。

  1. 新概念学习:逐步了解这个东西的概念,是什么?能做什么?什么时候用?如何使用?
  2. 基础学习:学习这个东西的一些基础使用,并实践应用。
  3. 实战应用:当了解概念和基础以后,可以自己开始实践了,最好能完整的实践每一个功能。一般来说到这一步就可以说熟练使用这个东西了。
  4. 深入学习原理:开始深入学习相关的原理性知识,了解这个东西是如何实现的,它当时为什么要这么做?有没有其他替代的方案呢?我们可以尝试自己去实现这个东西。一般来说这一步完成就可以说精通了。
  5. 扩展学习:扩展学习其他同类的产品,了解使用场景,优劣等。

那么回到我们的话题上,如何学好数据库?

  1. 概念学习:数据库是什么?能做什么?什么时候用?如何使用?
  2. 基础学习:数据库的基础操作,增删改查,DDL、DCL、DML语言。
  3. 实战应用:自己去使用数据库的各个功能,包括增删改查、函数、触发器、存储过程、视图等。
  4. 学习原理:深入学习数据库的原理,包括事务的实现、锁的实现、增删改查的实现、索引的实现等。
  5. 扩展学习:学习不同类型的数据库,如列式数据库、内存数据库、文档数据库等。

那么再详细一些呢?如何学好MySQL数据库?

  1. 概念学习:MySQL数据库是什么?能做什么?什么时候用?如何使用?
  2. 基础学习:MySQL数据库的基础操作,增删改查,DDL、DCL、DML语言。
  3. 实战应用:自己去使用MySQL数据库的各个功能,包括增删改查、函数、触发器、存储过程、视图等。
  4. 学习原理:深入学习MySQL数据库的原理,包括事务的实现、锁的实现、增删改查的实现、索引的实现等。
  5. 扩展学习:学习一些其他的数据库,如PostgreSQL、ES、ClickHouse、MongoDB等。

概念学习

首先,我们应该知道什么是数据库?很多人都会搞混一个概念,那就是数据库和数据库管理系统。

数据库的英文是DataBase。它的概念是

1
2
3
4
5
6
7
8
数据库是一个长期存储在计算机内的、有组织的、可共享的数据集合,它具有以下特点
1. 数据的集合性
2. 数据的共享性
3. 数据的冗余度低
4. 数据的独立性
5. 数据的安全性
6. 数据的完整性
7. 数据的可维护性

而数据库管理系统的英文是DataBase Management System。它的概念是:

1
数据库管理软件(Database Management System,简称DBMS)是用于创建、管理、维护和操作数据库的软件系统。它在用户和数据库之间提供了一个接口,使得用户能够方便地存储、检索、更新和管理数据。

因此,我们要明白,MySQL是一个数据库管理系统,而不是一个数据库。

虽然我们老说MySQL数据库,但这个是因为大家已经习惯了,大家都明白MySQL是什么,因此省略了一些。

MySQL是用来管理数据库的一个系统。

那么问题来了,SQL又是什么呢?

这里给出基础篇的概念学习路线。大家可以根据这些去了解具体的概念。

概念学习

这一个部分1-2小时就差不多了。

实战应用

学会了概念以后,我们就要应用,进行实践。只有这样才能将知识转化成我们自己的。

对于实战来说,首先肯定要进行MySQL的安装,可以直接到官网进行安装,这里给出连接。

https://www.mysql.com/

安装完成以后,根据上面学习的概念,首先执行一遍DCL、DML、DDL。

接下来需要学习ER图。如何学习ER图呢?同意的,先了解一下概念,在进行实战应用。自己画一画ER图。

画ER图的工具,这里推荐几个

  • visio,可以自己去网上买一些key,不贵,要注意买key,而不是破解机。
  • processOn,强烈推荐,网页版,使用方便,免费的一般也够用了。
  • drwa.io,强烈推荐,同样有网页版,使用方便,免费。
  • dbdiagram,不同于自己画图,这个网站可以进行数据库建模,支持导入导出。缺点是需要使用DBML进行建模,需要学习一下。

接下来进行一些高级的应用、包括CTE、窗口函数、存储过程、视图、触发器等等。

还可以进行导入导出。

还可以使用你熟悉的语言进行操作。Java的使用可以使用MyBatis Plus。

概念学习

深入学习原理

MySQL原理性的东西就比较多了。

自顶向下来看,首先有连接器、分析器、优化器、执行器。

连接器可以不用管。

分析器的原理,如何进行语法分析的,这里需要学一下关系代数。数据库是将SQL转化成关系代数,然后在生成执行树的。

优化器的原理,如何选择索引的,成本模型是什么?直方图是什么?MySQL本身实现了哪些优化?谓词下推,索引下推,Index Merge等等。

接下来会生成具体的执行计划,如何查看执行计划的各个字段,如何根据执行计划来优化SQL。

还需要学习MySQL事务,事务的隔离级别,ACID特性,MVCC实现,undo log等等。

还要学习SQL语句如何执行的,Select是怎么查询出数据的,where条件怎么筛选数据,join是怎么进行连表查询的,update是怎么更新的,delete怎么删除的,group by,order by怎么实现的。

接下来最重要的索引部分,学习b+树索引,hash索引,倒排索引等等的实现。

数据存储部分的原理,我们知道数据库只是管理数据的,数据最终存储在磁盘上还是一些文件,那么这些文件是如何存储的呢?文件内容是什么?加载到内存以后,内存布局是什么样的?老说page,page是什么?

还有数据库崩溃恢复怎么实现的,redo log怎么实现崩溃恢复,这个其实也挺重要的,因为我们自己进行一些数据处理,可能也需要理解这个,而且大部分的数据持久化,崩溃恢复机制核心都是一样的。

还需要学习mySQL的锁,表锁,行锁,乐观锁,悲观锁,意向锁,间隙锁等等。

概念学习

架构学习

你学习完上面的东西以后,可以说对于单机MySQL就已经很了解了。接下来就可以进一步保证数据的高可用、高扩展、高性能了。

也就是去了解一些架构上面的知识,比如经典的主从架构,主从同步,异步同步,bin log,relay log,GTID同步。

还有分库分表的只是,水平拆分、垂直拆分、mySQL自带的partition支持。

再比如MySQL自身的集群组件,group replication。还有分布式数据库的一些实现,分布式事务。

CAP理论等等。

概念学习

扩展学习

当上面的都学习完成以后,可以扩展学习一些其他的数据库。

比如同类型的PostgreSQL。这个更偏向学术性。

再比如其他的文档数据库mongoDB。

常用的搜索数据库ES。

列式数据库ClickHouse这些。

学习书籍推荐

在这里也给大家推荐一些相关的书籍,可以看一看。

  • MySQL必知必会
  • InnoDB技术内幕
  • 数据库系统概念
  • 数据密集型应用系统设计
  • 亿级流量系统架构设计与实战

文末福利

以上就是整体的MySQL学习路线了。

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。

发送“电子书”即可领取价值上千的电子书资源。

部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

MySQL索引失效

大家好,我是大头,98年,职高毕业,上市公司架构师,大厂资深开发,管理过10人团队,我是如何做到的呢?

这离不开持续学习的能力,而其中最重要的当然是数据库技术了!

对于所有开发来说,都离不开数据库,因为所有的数据都是要存储的。

关注我一起学习!文末有惊喜哦!

什么是MySQL索引失效?

MySQL 索引失效是指尽管表中已经建立了索引,但在某些查询操作中,MySQL 的查询优化器选择不使用这些索引,而是采用全表扫描(Full Table Scan)或其他非索引扫描方式来执行查询。这种情况通常会导致查询性能下降,因为全表扫描需要扫描表中的每一行数据,而不是利用索引快速定位数据。

这里就要介绍一下MySQL的整体架构了。

架构

  • 连接器:MySQL连接器(MySQL Connector)是用于连接MySQL数据库的客户端库,它允许应用程序与MySQL数据库进行通信。这些连接器提供了API(应用程序编程接口),使得开发者可以在各种编程语言中轻松地执行SQL语句、管理数据库连接、处理查询结果等。
  • 分析器:MySQL分析器才算真正进入了MySQL里面。它会分析词法和语法,如果你的SQL语句不对,就会看到一些报错,例如语法错误。
  • 优化器:MySQL优化器是本文的重点内容,因为这里涉及了MySQL的一些优化,其中就包括了索引选择,也是导致索引失效的重要原因。在这一步也就确定了查询计划。
  • 执行器:MySQL执行器会根据优化器输出的查询计划来执行这个查询计划,会调用底层的存储引擎进行执行。
  • 存储引擎:MySQL支持多种存储引擎,每个存储引擎有不同的特点。其中InnoDB引擎因为支持事务等优点,现在是MySQL的默认存储引擎。

连接器

MySQL连接器(MySQL Connector)是用于连接MySQL数据库的客户端库,它允许应用程序与MySQL数据库进行通信。这些连接器提供了API(应用程序编程接口),使得开发者可以在各种编程语言中轻松地执行SQL语句、管理数据库连接、处理查询结果等。

当我们最开始连接数据库实例的时候,我们要输入用户名密码,这时候连接器会从数据库的用户信息中判断你是否有权限连接数据库进行操作,有哪些权限。

如果你输入的用户名密码错误或者没有权限,那么你会收到下面的报错信息。

1
Access denied for user 'root'@'localhost'(using password: YES)

架构

分析器

连接成功以后。分析器会分析这个语句的词法,语法,语义这些信息。

通俗来讲就是看到select,update这些关键字,知道你要来干啥,看看你是不是来搞破坏的,来捣蛋的。

看看你是查询哪个表啊,有什么条件啊,这些玩意。

最后会输出一个词法树。

当然了这一步还会分析你的语法有没有错误,比如你把select打错试试。打成elect,会出现下面的报错信息

You have an error in your SQL syntax: check the maual that corresponds to your MySQL server version for the right syntax to use near ‘elect * from users’ at line 1

架构

优化器

优化器负责几个事情

  • 优化SQL:比如你写了一个很不友好的SQL,如select * from a where 1 =1,优化器会将1=1去掉。还有比如括号的删除,如select * from a where ((a AND b) AND c OR (((a AND b) AND (c AND d))))改写成select * from a where (a AND b AND c) OR (a AND b AND c AND d)。等等。
  • 一些内部的优化器:下面列出的是部分,更多的可以参考MySQL官方文档优化器部分
    • 谓词下推:即where条件下推到扫描表的时候执行,而不是扫描表之后执行。
    • 范围优化:对于BTREE和HASH索引,当使用=、<=>、IN()、IS NULL或IS NOT NULL运算符时,键部分与常量值的比较是范围条件。此外,对于BTREE索引,当使用>,<,>=,<=,BETWEEN,!= 、或<>运算符,或者LIKE比较(如果LIKE的参数是不以小写字符开头的常量字符串)。对于所有索引类型,多个范围条件与OR或AND组合形成范围条件。
    • index merge优化:index merge就是多个索引并发扫描,再将扫描结果合并。
    • hash join优化:使用hash join来代替Nested Loop Join算法,能大幅度提升join速度。
    • Is Null优化:可以对where a is null这种条件进行优化,比如该字段设置了not null,那么这个条件就会被删除。
    • order by + limit优化:使用Top N排序
    • group by 优化:优化group by语句。
    • 子查询物化:将子查询的内容物化保存起来。
  • 通过成本模型、直方图等信息生成不同的执行路径。
  • 对比执行路径的内容,如取样等,进行选择最终的执行路径。生成查询计划。

这里简单的介绍一些mysql内部的优化器,以了解mysql内部做了哪些优化手段。

最后会介绍mysql的成本模型、直方图信息等。结合实际的例子来给大家展示索引选择的问题。

谓词下推优化

谓词下推优化(Predicate Pushdown Optimization)是一种查询优化技术,它将查询中的过滤条件(谓词)尽可能地推送到数据访问的早期阶段,以减少数据扫描的范围,从而提高查询性能。
在数据库查询中,谓词通常是指WHERE子句中的条件。谓词下推优化的目的是让这些条件在数据被读取或处理的早期阶段就发挥作用,避免不必要的数据处理和传输。

在没有谓词下推优化的情况下,数据库会先读取所有数据,然后在内存中应用过滤条件。这可能导致大量的数据被加载到内存中,增加了I/O操作和内存使用。
通过谓词下推优化,数据库会在数据读取阶段就应用过滤条件,只加载满足条件的数据,从而减少数据的读取量和处理量。

假设存在table_a表,表里面有10条数据,a = 1的数据有一个,具体什么意思呢,我们来看一个SQL语句。

1
select a,b from table_a where a = 1;

如果没有谓词下推优化的话,执行树如下。

架构

其执行顺序如下:

  1. 扫描table_a表的10条数据,将10条数据传递给where过滤节点。
  2. where过滤操作进行过滤,过滤出a = 1条件的1条数据,将这个数据传递给列选择节点。
  3. 列选择节点选择这条数据的2列,将结果返回。

其内存中要存储10条数据。

而有了谓词下推优化以后,执行树如下。

架构

执行顺序如下:

  1. 扫描table_a表的10条数据,过滤出符合a = 1条件的这一个数据。将这个数据传给列选择节点。
  2. 列选择节点选择这条数据的2列,将结果返回。

范围优化

对于BTREE和HASH索引,当使用=、<=>、IN()、IS NULL或IS NOT NULL运算符时,键部分与常量值的比较是范围条件。此外,对于BTREE索引,当使用>,<,>=,<=,BETWEEN,!= 、或<>运算符,或者LIKE比较(如果LIKE的参数是不以小写字符开头的常量字符串)。对于所有索引类型,多个范围条件与OR或AND组合形成范围条件。

给定数据

1
2
3
4
5
6
7
8
key_part1  key_part2  key_part3
NULL 1 'abc'
NULL 1 'xyz'
NULL 2 'foo'
1 1 'abc'
1 1 'xyz'
1 2 'abc'
2 1 'aaa'

执行where key_part1= 1,其扫描范围为 1,负无穷,负无穷到 1,正无穷,正无穷

1
(1,-inf,-inf) <= (key_part1,key_part2,key_part3) < (1,+inf,+inf)

即覆盖了这三行

1
2
3
1         1          'abc'
1 1 'xyz'
1 2 'abc'

index dives,优化器在范围的两端进行dives, 可以帮助优化器更准确的评估扫描的行数,index dives提供了更准确的行估计,但是随着比较值数量的增加,更加耗时,使用统计信息的准确性不如index dives,但允许对大值列表进行更快的行估计。

eq_range_index_dive_limit系统变量使您能够配置优化器从一个行估计策略切换到另一个行估计策略时的值数量。要允许使用索引潜水来比较最多N个相等范围,请将eq_range_index_dive_limit设置为N+ 1。要禁用统计信息并始终使用索引潜水而不管N,请将eq_range_index_dive_limit设置为0。

若要更新表索引统计信息以获得最佳估计值,请使用ANALYZE TABLE。

skip scan,比如有索引(f1,f2),都知道最左前缀原则,所以一般where f2 > 40是不走索引的,skip scan可以让他走索引,通过构造f1 = 1 and f2 > 40,扫描完以后再扫描 f1 = 2 and f2 > 40,以此类推,可以通过explain来看extra列是否有skip scan

in优化,in查询可以用如下形式

1
SELECT ... FROM t1 WHERE ( col_1, col_2 ) IN (( 'a', 'b' ), ( 'c', 'd' ));

range_optimizer_max_size_size系统变量可以设置优化器使用的内存

index merge 优化

index merge就是多个索引并发扫描,再将扫描结果合并

索引合并不适用于全文索引。

索引合并访问方法检索具有多个范围扫描的行,并将其结果合并为一个。此访问方法只合并单个表的索引扫描,而不合并多个表的扫描。合并可以产生其底层扫描的并集、交集或交集的并集。

可以使用索引合并的查询示例:

1
2
3
4
5
6
7
8
9
10
11
12
SELECT * FROM tbl_name WHERE key1 = 10 OR key2 = 20;

SELECT * FROM tbl_name
WHERE (key1 = 10 OR key2 = 20) AND non_key = 30;

SELECT * FROM t1, t2
WHERE (t1.key1 IN (1,2) OR t1.key2 LIKE 'value%')
AND t2.key1 = t1.some_col;

SELECT * FROM t1, t2
WHERE t1.key1 = 1
AND (t2.key1 = t1.some_col OR t2.key2 = t1.some_col2);

如果你的查询有一个带有深度AND/OR嵌套的复杂WHERE子句,并且MySQL没有选择最佳计划,请尝试使用以下恒等转换来分发术语:

1
2
(x AND y) OR z => (x OR z) AND (y OR z)
(x OR y) AND z => (x AND z) OR (y AND z)

在EXPLAIN输出中,Index Merge方法在type列中显示为index_merge。在本例中,key列包含使用的索引列表,key_len包含这些索引的最长键部分列表。

索引合并访问方法有几种算法,它们显示在EXPLAIN输出的Extra字段中:

  • intersect:对多个and条件生效
  • union:对多个or条件生效
  • sort_union:sort-union算法和union算法之间的区别在于,sort-union算法必须首先获取所有行的行ID,并在返回任何行之前对其进行排序。

索引合并的使用取决于optimizer_switch系统变量的index_merge、index_merge_intersection、index_merge_union和index_merge_sort_union标志的值。默认情况下,所有这些标志都是打开的。

hash join

默认情况下,MySQL尽可能使用哈希连接。可以使用BNL和NO_BNL优化器提示之一来控制是否使用散列连接。

hash join比嵌套join快的多,首先创建hash表,在循环另一个表进行hash,判断是否相等

可以使用join_buffer_size系统变量控制哈希连接的内存使用量;哈希连接使用的内存量不能超过此值。当哈希连接所需的内存超过可用量时,MySQL会使用磁盘上的文件来处理。如果发生这种情况,您应该注意,如果哈希连接无法容纳内存并且它创建的文件比为open_files_limit设置的文件多,则连接可能不会成功。要避免此类问题,请进行以下更改之一:

  • 增加join_buffer_size,使哈希连接不会溢出到磁盘。
  • 增加open_files_limit。

成本模型

MySQL成本模型(Cost Model)是MySQL查询优化器(Query Optimizer)用来评估不同查询执行计划的成本(Cost)的一种机制。成本模型通过估算每种执行计划所需的资源(如CPU、I/O、内存等)来选择最优的执行计划。

MySQL的成本模型主要考虑以下几个方面:

  1. CPU成本:
    • 评估执行计划中每个操作(如比较、计算、排序等)所需的CPU时间。
    • 例如,WHERE子句中的条件计算、JOIN操作中的匹配等。
  2. I/O成本:
    • 评估执行计划中每个操作所需的磁盘I/O操作次数。
    • 例如,全表扫描、索引扫描、数据页的读取等。
    • I/O成本通常是最主要的成本因素之一,因为磁盘I/O操作相对较慢。
  3. 内存成本:
    • 评估执行计划中每个操作所需的内存使用量。
    • 例如,排序操作、临时表的创建等。
  4. 网络成本:
    • 评估执行计划中每个操作所需的网络传输量。
    • 例如,分布式查询中跨节点的数据传输。
  5. 数据分布:
    • 评估数据的分布情况,如表的大小、索引的覆盖率等。
    • 数据分布会影响I/O操作的次数和效率。

其中大部分的成本都是固定的,比如CPU成本、IO成本、内存成本。这个是根据你服务器的配置决定的。

所以,主要关注的是数据分布。

MySQL的数据分布使用直方图来记录。

直方图

column_statistics数据字典表存储有关列值的直方图统计信息,供优化器在构造查询执行计划时使用。要执行直方图管理,请使用ANALYZE TABLE语句。

  • 该表包含除几何类型(空间数据)和JSON之外的所有数据类型的列的统计信息。
  • 该表是持久的,因此不必在每次服务器启动时都创建列统计信息。
  • 服务器对表执行更新,用户不执行。

用户不能直接访问column_statistics表,因为它是数据字典的一部分。直方图信息可使用 INFORMATION_SCHEMA.COLUMN_STATISTICS 获得,它是作为数据字典表上的视图实现的。COLUMN_STATISTICS包含以下列:

  • SCHEMA_NAME、TABLE_NAME、COLUMN_NAME:应用统计信息的模式、表和列的名称。
  • HISTORIO:描述列统计信息的JSON值,存储为直方图。

直方图实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"buckets": [
[
1,
0.3333333333333333
],
[
2,
0.6666666666666666
],
[
3,
1
]
],
"null-values": 0,
"last-updated": "2017-03-24 13:32:40.000000",
"sampling-rate": 1,
"histogram-type": "singleton",
"number-of-buckets-specified": 128,
"data-type": "int",
"collation-id": 8
}
  • buckets:直方图桶。桶结构取决于直方图类型。
  • null-values:一个介于0.0和1.0之间的数字,表示SQL NULL值的列值的分数。如果为0,则该列不包含NULL值。
  • last-updated:直方图生成时,以 YYYY-MM-DD hh:mm:ss.uuuuuu 格式的UTC值表示。
  • sampling-rate:0.0到1.0之间的数字,表示为创建直方图而采样的数据比例。值为1表示已读取所有数据(无采样)。
  • histogram-type:直方图类型:
    • singleton:一个bucket表示列中的一个值。当列中的非重复值数量小于或等于生成直方图的ANALYZE TABLE语句中指定的存储桶数量时,将创建此直方图类型。
    • equi-height:一个桶表示一个值范围。当列中的非重复值数量大于生成直方图的ANALYZE TABLE语句中指定的存储桶数量时,将创建此直方图类型。
  • number-of-buckets-specified:生成直方图的ANALYZE TABLE语句中指定的桶数
  • data-type:此直方图包含的数据类型。在将直方图从持久性存储器阅读和解析到内存中时,这是需要的。该值是int、uint(无符号整数)、double、decimal、datetime或string(包括字符和二进制字符串)之一。
  • collation-id:直方图数据的归类ID。当数据类型值是字符串时,它最有意义。值对应于信息架构COLLATIONS表中的ID列值。

直方图统计信息主要用于非索引列。将索引添加到直方图统计信息适用的列还可以帮助优化器进行行估计。

优化器更喜欢范围优化器的行估计,而不是从直方图统计信息中获得的行估计。如果优化器确定范围优化器适用,则不使用直方图统计信息。

对于已建立索引的列,可以使用索引潜水(index dives)获得行估计值以进行相等比较。

在某些情况下,使用直方图统计信息可能不会改善查询执行(例如,如果统计信息过期)。要检查是否是这种情况,请使用ANALYZE TABLE重新生成直方图统计信息,然后再次运行查询。

这么看这些概念内容,可能很难理解直方图到底是干啥的,下面给出一个例子方便理解。

直方图示例

虽然直方图的字段很多,但其核心的字段只有几个

创建一个测试表。

1
create table test_a(id int auto_increment,a int not null default 0, b varchar(255) not null default '', primary key(id));

接下来我们插入几个数据。

1
2
3
4
5
6
7
8
9
10
11
INSERT INTO test_a (a, b) VALUES
(1, 'A1'),
(2, 'B2'),
(3, 'C3'),
(4, 'D4'),
(5, 'E5'),
(6, 'F6'),
(7, 'G7'),
(8, 'H8'),
(9, 'I9'),
(10, 'J10');

接下来生成直方图信息。

1
ANALYZE TABLE test_a update  HISTOGRAM ON a WITH 5 BUCKETS;

查询直方图信息。这里的SCHEMA_NAME是数据库的名称,TABLE_NAME是数据表的名称。

1
SELECT * FROM INFORMATION_SCHEMA.COLUMN_STATISTICS where SCHEMA_NAME = 'test1' and TABLE_NAME = 'test_a';

查询结果:

1
2
3
4
5
6
7
+-------------+------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| SCHEMA_NAME | TABLE_NAME | COLUMN_NAME | HISTOGRAM
|
+-------------+------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| test1 | test_a | a | {"buckets": [[1, 2, 0.2, 2], [3, 4, 0.4, 2], [5, 6, 0.6, 2], [7, 8, 0.8, 2], [9, 10, 1.0, 2]], "data-type": "int", "auto-update": false, "null-values": 0.0, "collation-id": 8, "last-updated": "2025-01-25 13:01:57.129967", "sampling-rate": 1.0, "histogram-type": "equi-height", "number-of-buckets-specified": 5} |
+-------------+------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

我们来看一下直方图的信息。

1
2
3
4
5
6
7
8
9
10
11
{
"buckets": [[1, 2, 0.2, 2], [3, 4, 0.4, 2], [5, 6, 0.6, 2], [7, 8, 0.8, 2], [9, 10, 1.0, 2]],
"data-type": "int",
"auto-update": false,
"null-values": 0.0,
"collation-id": 8,
"last-updated": "2025-01-25 13:01:57.129967",
"sampling-rate": 1.0,
"histogram-type": "equi-height",
"number-of-buckets-specified": 5
}
  • buckets:直方图桶。桶结构取决于直方图类型。
  • null-values:0.0,表示没有null值,这是因为我们使用了not null声明字段。
  • last-updated:更新时间,没啥好说的。
  • sampling-rate:1.0 获取了所有数据,因为我们只有10条数据,很少。
  • histogram-type:直方图类型:equi-height,因为a列有10个值,都不重复,而桶数量我们用的是5,所以生成了这个类型。
  • number-of-buckets-specified:生成直方图的ANALYZE TABLE语句中指定的桶数
  • data-type:列a是int类型
  • collation-id:直方图数据的归类ID。当数据类型值是字符串时,它最有意义。值对应于信息架构COLLATIONS表中的ID列值。

桶里面有4个数据

  • 最小值
  • 最大值
  • 密度
  • 高度

比如,查询语句

1
select * from test_a where a = 5

a = 5的数据在第三个桶里面,最小值5,最大值6,密度0.6,高度2.

根据计算公式预估行数 = 密度 * 高度来计算0.6 * 2,预估行数就是1.2,也就是1-2行。

架构

可以看到查询计划里面的rows是10行,这是因为类型是全表扫描,但是后面的filtered字段是10,表示的意思是会过滤出来 10 * 10% = 10 * 0.1 = 1行。

也就是最终会查出1行结果。

通常来说,对于没有索引的列,MySQL就是这样来预估行数的,并且通过这个结果来进行选择执行路线。

执行路线选择

什么叫路线选择呢,还是上面那个表,我们现在有如下SQL语句。

1
select a,b from test_a where a = 1 and b = 'A1';

根据表数据,其实我们知道,查出来的结果还是1条。但是对于mysql来说,却有不同的执行方式。

第一种执行方式,先查a=1在查询b='A1'的数据。执行树如下。

架构

第二种执行方式,先查b='A1'在查询 a=1。执行树如下。

架构

目前看着这两种方式都没啥问题。

但是,我们再插入一条数据呢?

1
2
INSERT INTO test_a (a, b) VALUES
(11, 'A1');

这样我们就知道了,方案1, 会直接过滤出1行数据,然后在过滤,这样显然比方案2更好。

因为方案2会先查出2条数据,再次过滤。

这就是不同的执行路线带来的性能区别。当然了,我们这里的例子只是打个比方,实际上谓词下推优化以后,这两个条件都是和扫描表一起执行的。

这个例子只是让你明白不同的路线选择而已。

对于join查询来说,会有更多的选择。

文末福利

以上就是今天的内容了,大家有任何疑问可以打在评论区,一起交流~

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。

发送“电子书”即可领取价值上千的电子书资源。

部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习