南京大学操作系统
操作系统概述
这里概述了操作系统做什么,怎么做。
总共有三个大模块,虚拟化
,并发
,持久化
。
操作系统的几个目标:
- 高性能:高效率的运行多个程序。
- 保护每个程序独立运行:不能让一个程序访问另外一个程序的内存数据
- 可靠性:操作系统必须一直运行,不能停止,因为一旦停止,所有依赖他的软件程序都会停止。
cache lab 缓存实验
从CSAPP上面下载对应的lab代码
需要安装 valgrind
。可以参考文章Valgrind centos。
安装好以后执行valgrind --version
可以看到版本号。
cache simulator not a cache
。我们不是实现一个真正的缓存,只是实现一个模拟器。Hints
要求我们实现csim.c
文件,给了一个示例csim-ref
文件。
输入./csim-ref -h
可以看到我们要实现的东西。
首先需要接受参数,参数有
L 10,1 miss
这些信息根据上面的提示可以知道,通过getopt
函数来接收参数,并通过switch来处理。读取文件则通过fscanf
函数,来读取-t传的文件。
下面是./traces/yi.trace
文件的内容
1 | L 10,1 |
完整代码
1 | #include "cachelab.h" |
data lab 数据实验
这个数据实验请在linux机器上面运行,实测mac m1本跑不起来。windows没试过。
centos上需要安装好gcc运行环境。
如果跑不起来记得安装下面这个东西:
yum -y install glibc-devel.i686
运行make btest
的时候可能会有warning
提示,不用管,这个时候其实已经创建完btest
了,可以直接运行btest
。
第一个函数是实现位
的异或
。
看一下异或的要求,相同为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 | int bitXor(int x, int y) { |
btest 结果:
dlc 结果:
bdd check 结果:
Tmin是1000 0000
,也就是最小的有符号数,那当然是符号位是1,剩下全0了。
可以使用操作符:! ~ & ^ | + << >>
最大操作符号数量:4
分数:1
返回 1000 0000就可以了。正常的int Tmin就是1后面31个0,也就是1左移动31位
代码:
1 | int tmin(void) { |
btest 结果:
dlc 结果:
bdd check 结果:
Tmax是0111
可以使用操作符: ! ~ & ^ | +
最大操作符号数量: 10
4位的话,Tmax就是7,看一下7的一些操作结果,可以发现,7+1 = ~7
1 | 7 = 0111 |
但是 -1 + 1 也等于 ~-1,所以我们需要排除-1
1 | -1 = 1111 |
可以看到4的话,4 + 1 不等于~4
1 | 4 = 100 |
怎么排除-1呢,观察发现-1+1 = 0,而0^0 = 0,但是tmax ^ 0 不等于0
所以tmax需要满足两个条件
可以用^
操作来实现==
。如果相等,那么x+1 ^ ~x 就会等于0,!0 == 1,所以第一个条件就是
!((x+1) ^ ~x)
第二个条件同样通过^
来实现。
!!((x+1) ^ 0)
只要这两个都满足就是Tmax了,都满足可以通过&
来实现,如果都是1,那么&
以后就是1,有一个不满足&
以后就是0.
代码:
1 | int isTmax(int x) { |
btest 结果:
dlc 结果:
bdd check 结果:
如果所有的奇数位都是1就返回1,否则返回0
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 12
分数: 2
比如 1010 1010
就是奇数位上全1.
所以只要和 1010 1010
做 &
操作,只要做完以后还是 1010 1010
的话,那么就返回1,不然就是0.
因为假设 x 奇数位上有一个是0,比如 1010 1000
,那么结果就会是 1010 1000
,所以只有奇数位上全1,&
以后一定是1010 1010
。
所以需要满足条件
代码:
1 | int allOddBits(int x) { |
btest 结果:
dlc 结果:
bdd check 结果:
返回-x
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 5
分数: 2
这里要分成三部
如果使用按位取反
取反以后的值 + 1就是对应的负数了,-8 + 1 = -7, -1 + 1 = 0, 0 + 1 = 1, 7 + 1 = 8
代码:
1 | int negate(int x) { |
btest 结果:
dlc 结果:
bdd check 结果:
如果 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 | int isAsciiDigit(int x) { |
btest 结果:
dlc 结果:
bdd check 结果:
实现三元运算 x ? y : z
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 16
分数: 3
x 为真代表 x & 1 == 1,x 为假代表 x & 1 == 0。
需要满足条件
!(x & 1) & z
就可以把z置为0,所以应该返回 (!(x & 1) & z) | y
x & 1 & y
就可以把y置为0。所以应该返回 (x & 1 & y) | z
把上面的2个条件合并起来。
(!(x & 1) & z) | (x & 1 & y)
但是发现这样并不行,所以重新思考,发现 x & 1 == 1时候是没错,但是我们应该让 x = 0xFF才行。
所以改进一下子
(0 & y) | z
(0 & z) | y
这里把 !x 在按位取反 + 1就可以得到当 x = 0时候,condition = 1111 1111。这个时候返回z。
代码:
1 | int conditional(int x, int y, int z) { |
btest 结果:
dlc 结果:
bdd check 结果:
如果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是1,y是0的话,就返回1,否则0
代码
1 | int isLessOrEqual(int x, int y) { |
btest 结果:
dlc 结果:
bdd check 结果:
对x取反,实现!操作
可以使用的操作符: ~ & ^ | + << >>
最大数量: 12
分数: 4
两种情况
0要返回1,1要返回0,可以异或1
代码
1 | int logicalNeg(int x) { |
btest 结果:
dlc 结果:
bdd check 结果:
输出最少需要的位数来表示int x
可以使用的操作符: ! ~ & ^ | + << >>
最大数量: 90
分数: 4
例子:
三种情况
有没有1,可以通过!!来判断,如果是!!0,就是0,如果是其他数!!x就是1了。
这道题的代码是从网上抄的。
代码
1 | int howManyBits(int x) { |
btest 结果:
dlc 结果:
bdd check 结果:
传入一个无符号数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的计算方式
M的计算方式
-1^s * 1.尾数 * 2^E
当阶码等于全0的时候,就表示非规格化的浮点数。
exp ^ 0是0就代表全0,非规格化
E的计算方式,他跟阶码没关系了,因为阶码永远是0
M的计算方式
-1^s * 0.尾数 * 2^E
当阶码等于全1的时候,就表示特殊的浮点数。
~exp ^ 0是0就代表全1,特殊浮点数 当尾数不为全0的时候,就是NaN,返回参数。
小数乘法
对于规格化的数,2,自然是e+1,因为2的E次方,E+1,那就等于多乘了个2
对于非规格化的数,E是固定的-126,没法改变,所以尾数2
1 | unsigned float_twice(unsigned uf) { |
btest 结果:
dlc 结果:
bdd check 结果:
计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。。虽然系统的 具体实现方式随着时间不断变化,但是系统内在的概念却没有改变。所有计算机系统都有 相似的硬件和软件组件,它们又执行着相似的功能。
一般第一个程序都是输出hello world
,这里我们使用c语言输出一个hello world
。后面在来讲这里面都发生了什么。
1 | #include <stdio.h> |
无符号整型和有符号整型的二进制表示
可以用bit来表示集合,并用位操作来表示集合的操作。
p && *p可以避免空指针
左移1位等于乘2
右移1位等于除2,且向下取整
逻辑右移:右移后补0,java中 -3 >> 1 = -2
算术右移:右移后补符号位,java中 -3 >>> 1 = 2147483646
可以通过下面的代码来测试自己的机器是大端还是小端的。
1 | #include <stdio.h> |
101.11表示5又3/4,也就是23/4。前面的101表示5,小数点后面的11表示3/4,小数点后面第1个1表示1/2,第二个1表示1/4,所以相加就是3/4
浮点数表示由IEEE
制定。
IEEE浮点标准用V = (-1)的s次方 * M * 2的E次方
来表示。
所以存储就有了3部分
当阶码不等于全0或不等于全1的时候,就表示规格化的浮点数。
E的计算方式
M的计算方式
假设符号为0表示正数,那么这个浮点数为 V = 1 * M * 2的E次方
= 1 * 1.00000000000000000000001 * 2的-126次方
= 1.00000000000000000000001 * 2的-126次方
= 0.000…00100000000000000000000001 (小数点左移126位)
这等于非常非常小的一个浮点数了
如何表示15213.0呢。首先符号位为0,然后计算尾数和阶码。
15213.0的浮点数表示为:
0 1000 1100 1101 1011 0110 1000 0000 000
当阶码等于全0的时候,就表示非规格化的浮点数。
E的计算方式,他跟阶码没关系了,因为阶码永远是0
M的计算方式
假设符号为0表示正数,那么这个浮点数为 V = 1 * M * 2的E次方
= 1 * 0.00000000000000000000001 * 2的-126次方
= 0.00000000000000000000001 * 2的-126次方
= 0.000…00000000000000000000000001 (小数点左移126位)
这等于非常非常小的一个浮点数了,比上一个规格化的更小
如果尾数也等于全0,那么就表示浮点数0
当阶码等于全1的时候,就表示特殊的浮点数。
用数轴表示如下:
习题2.54 假定变量x,f和d的类型分别是int、float 和cioubile。除了f和d都 不 能 等 于 十∞ 、 、 一 ∞ 或 者 N a N , 它 们 的 值 是 任 意 的 。 对 于 下 面 每 个 C 表 达 式, 证 明 它 总 是 为 真 ( 也 就 是 求 值 为 1 ), 或 者 给 出 一 个 使 表 达 式 不 为 真 的 值 ( 也 就 是 求 值 为 0 )。
类型中long > double > int > float
从最早的16位处理器8086到现在的64位处理器x86-64
要写出编译器友好的程序来提升程序性能,比如下面两个函数
1 | void test1(long *xp, long *yp) { |
1 | void test2(long *xp, long *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 | x = 1000;y = 3000; |
如果*q
和*p
是指向两个内存,那么t1就是3000,如果指向同一个内存,那么t1就是1000,因此编译器无法对这种代码进行优化。
可以把循环中的多次计算或者过程调用等重复执行的,结果确定的,提取出来,只执行一次。
比如下面的代码,这个时候strlen这个函数会执行n次,A-a也会执行n次,他们的结果每次都是一样的,可以都提取出来。A-a由于是常数减法,编译器应该会优化,但是strlen是一个函数,函数的结果是不确定的,编译器会保守的保留它不进行优化。
1 | void lower(char *s) |
优化后的代码
1 | void lower(char *s) |
有一些函数中会有一些不必要的操作,可以减少他们,比如数组函数,里面也许会有边界检查之类的,防止越界,但是如果我们的外部代码已经很明显的做了检查,或者说肯定不会越界,那么我们就可以省掉调用函数里面的这部分开销。
get_vec_element这个函数获取数组中的值,并且做了边界检查。这样会做n次边界检查,我们可以优化它。
1 | void combine2(vec_ptr v, data_t *dest) |
优化之后的代码,省了每次的边界检查。
1 | void combine3(vec_ptr v, data_t *dest) |
上面的代码还是有些问题,因为对于dest来说,每次都要经历load,计算,store的过程,这个过程要重复n次,可以通过增加局部变量来减少load和store的次数。
1 | void combine4(vec_ptr v, data_t *dest) |
for循环每次循环都会进行if条件判断,计算机执行条件判断的时候是比较费时的,现在有了分支预测
功能,但是预测失败的话,也会影响性能。可以通过循环展开来优化这里,来减少for循环的消耗。这样改造的程序,已经极为接近线性代码的功耗了。
1 | void combine5(vec_ptr v, data_t *dest) |
对于满足结合律的运算,可以通过结合律来优化。对于上面的combine5函数来说,他的执行流程是这样的。因为每次乘的时候都要等上一个的结果,所以是线性的。
使用结合律优化以后,执行路程是下面这样的,为啥它能并行呢,因为在执行左边acc * (i * i+1)
结果的时候,就已经可以计算i+2 * i+3
的结果了。这两个乘法并行了,所以缩短了执行时间。上面无法并行的原因是先计算了acc * i
,当计算下一个acc * i
的时候需要依赖上一次acc的结果。
1 | void combine6(vec_ptr v, data_t *dest) |
还可以通过先求出所有i为偶数的结果,和i为奇数的结果,在把两个结果结合到一起。这样的话先计算奇数和偶数,这两个是互不干涉的,也可以达到上面结合律的效果。
1 | void combine7(vec_ptr v, data_t *dest) |
存储技术分为易失存储
和非易失存储
。
最便宜的存储,存储容量也很大。由下面的部分组成
所以在次盘上读写的时候,需要移动磁头
,让磁头找到对应的盘面,在找到磁道和扇区,然后才能读写数据,所以顺序读写
比随机读写
快的多。因为顺序的话磁头只需要移动一点点距离,而随机需要移动很长的距离。
固态硬盘是基于闪存
的存储技术,价格比机械硬盘贵一些。因为是闪存,所以顺序读写和随机读写都比机械硬盘快。虽然固态的顺序读写和随机读写的差距缩小了,但是顺序读写依旧比随机读写快。
固态每次写入需要先擦除一个页里面原来的内容,在写入新的,因此,擦除次数多了以后,这个页就会坏掉。为了提升固态的使用寿命,一般也会控制每次擦除寿命更高的页。
DRAM 也就是电脑内存,由下面几部分组成,价格比固态和机械硬盘贵的多,容量也小的多,现在大多数是16G内存。
DRAM的发展历史,RAS:发送行号,CAS:发送列号
现在一般的DDR5,DDR4,其实就是DDR DRAM内存。
SRAM 也就是电脑CPU的L1,L2,L3三级缓存使用的,价格比DRAM更贵,容量更小。通常是多少KB或MB。
SRAM将每个位存储在一个双稳态的(bistable)存储器单元里。每个单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,它可以无限期地保持在两个不同的电压 配置(configuration )或状态(state) 之一。其他任何状态都是不稳定的一一从不稳定状态开始,电路会迅速地转移到两个稳定状态中的一个。
局部性分为空间局部性(spatial locality)
和时间局部性(temporal locality)
。
从上面也能看出,DRAM一次加载一行到行缓冲区,如果我们要的数据都在这一行,取的时候就会很快。这就是空间局部性
。
比如数组的顺序访问。这个时候一个个接着访问的,就具有空间局部性。
1 | a[100] = {0,0,0,...} |
二维数组是一样的,需要按照行来访问。而不能按照列。
1 | a[100][100] = {{0,0,0,...},{0,0,0,...},...} |
三维数组,也应该使用行访问。
{
{
{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 | a[100][100] = {{{0,0,0,...},{0,0,0,...},{0,0,0,...},...},{{0,0,0,...},{0,0,0,...},{0,0,0,...},...},...} |
使用最近使用过的数据,就是时间局部性
。这是因为缓存的原因,比如最近使用过的数据还在L1缓存中,就会比再去DRAM中取数据要快。
sum就有 时间局部性,一直在使用。
1 | a[100] = {0,0,0,...} |
通常,高层的可以作为低层的缓存,比如L1缓存L2的数据,L2缓存L3的,L3缓存内存的。数据以块为大小传输。
像L1,L2这种SRAM,就是高速缓存存储器
。
假设每个存储器地址有m位,形成$M = 2^m$个不同的地址。它由 $S = 2^s$ 个set组成。每个set里面有 $E = 2^e $ 个缓存行。每个行有 $B = 2^b$字节的数据块组成。高速缓存的大小$C = S * E * B$(不包括Tag和Valid)
每个数据块有
m个地址位的组成:
假设 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 | valid tag data |
缓存不命中的情况。总共4个set,找到第2个set,然后找到其中tag是0并且valid是1的,这个时候发现没有匹配的,那么触发miss,开始去下一层获取数据,获取到数据以后,通过替换算法替换掉一个行,将tag修改为0并且valid修改为1,然后把数据写入block data。再返回001位置的数据。
1 | valid tag data |
冲突不命中,比如计算两个数组的点积。这个时候a[0] * b[0],会先把数组a加载到L1缓存,然后再把b加载到L1缓存,这个时候b会把a覆盖,然后a[1] * b[1]的时候,a就会冲突不命中,然后用a把b覆盖,一直重复这个动作。这个时候可以增加E,比如一个set里面有多个block,这样就不会覆盖了。
1 | float dotProd(float a[100], float b[100]) { |
在写入的时候有两种情况,缓存命中和不命中,通常的搭配是write through + no write allocate
和write back + write allocate
c文件变成可执行文件的过程
为了构造可执行文件,链接器必须完成两个主要任务
目标文件有三种形式
ELF(x86-64的Linux系统的文件)可重定位目标文件
的格式
每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
C++ 和Java 都允许重载方法,这些方法在源代码中有相同的名字,却有不同的参数 列表。那么链接器是如何区别这些不同的重载函数之间的差异呢?C++ 和Java 中能使用 重载函数,是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一 的名字。这种编码过程叫做重整(mangling),而相反的过程叫做恢复(demangling)。 幸运的是,C++ 和Java使用兼容的重整策略。一个被重整的类名字是由名字中字符 的整数数量,后面跟原始名字组成的。比如,类Foo 被编码成3Foo。方法被编码为原始方法名, 后面加上 __ , 加上被重整的类名, 再加上每个参数的单字母编码。 比如,Foo::bar (int, long)被编码为bar__3Fooil。重整全局变量和模板名字的策略是相似的。
链接器对多个文件中同名变量或函数的处理。编译器会先分强符号
和弱符号
,强符号指函数和已初始化的全局变量,弱符号指未初始化的全局变量。
上面说的链接器接受一组可重定位文件
,并生成一个可执行文件
,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库
。它也可以用作链接器的输入。当构造可执行文件的时候,直接复制静态库里面被应用程序引用的目标模块。静态库是.a
文件。
可执行目标文件
的格式
段头部表
.init
。定义了一个函_init,程序的初始化代码会调用它动态库
也叫共享库
是致力于解决静态库缺陷的一个现代创新产物。共享库是一个 目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。在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 平台上。
Linux 系统为操作共享库还提供了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/
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
上下文切换
当Unix 系统级函数遇到错误时,它们通常会返回一1,并设置全局整数变量errno 来表示什么出错了.比如
1 | if ((pid = fork()) < 0) { |
一些进程控制的系统调用C函数
从程序员的角度,可以认为进程总是处于下面三个状态之一
当一个子进程终止时,内核并不会立即清除它,进程会被保持为一种已终止
的状态中,直到被父进程回收。这时候的子进程被称为僵尸进程
。当父进程回收子进程后,内核将子进程的退出状态传递给父进程,然后清除子进程,这个时候子进程就不存在了。
如果一个父进程终止。它的子进程就被称为孤儿进程
,内核会安排init进程
成为它的孤儿进程的养父,init进程的pid为1,是在系统启动时由内核创建的,它是所有进程的祖先进程。如果父进程没有回收僵尸进程就死了,init进程会回收僵尸进程,不过长时间运行的程序比如shell或者服务器,总是应该回收它们的僵尸子进程,即使僵尸子进程没有运行,他们仍然消耗系统的内存资源。
一个进程可以通过waitpid
函数来等待它的子进程终止或者停止。如果子进程已经终止,那么立即返回,如果子进程没有终止,挂起当前进程,等待子进程终止后返回,返回值为子进程的pid。此时,子进程已经被回收,内核会删除掉它的所有痕迹。
进程的执行顺序和回收顺序都是由操作系统内核通过异常控制流切换执行的,所以我们不能假设他们的执行顺序和回收顺序。
Linux信号
允许进程和内核中断其他进程。一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每个信号类型都对应一个系统事件,底层的硬件异常由内核异常处理程序处理的,正常情况下,对用户进程是不可见的。
下图是一些信号
传送信号分为两部分
信号处理程序
来处理信号。发送信号的几种方式
前台作业和后台作业,系统只能有一个前台作业,但是可以有多个后台作业,前台作业就是通过waitpid等待程序在前台完成的,后台作业是运行在后台的,一般在命令最后面加上&
就可以让程序后台运行。
接收信号的几种处理
可以通过signal
函数来捕获信号并处理,第一个参数是信号,第二个参数是处理函数,如果处理函数是SIG_IGN
,则忽略该信号,如果是SIG_DFL
,则采用信号的默认行为处理,如果是一个函数,则执行他进行处理。
待处理的信号只能有一个
。再过来一个同类型的信号就会被丢弃,实际上处理方式是有一个Pending
的位,如果有6个信号,Pending就有6位,一个信号对应一位,当有一个待处理信号的时候,相应的位就变成1,所以最多只有一个待处理,剩下的会被丢弃。所以我们不能假设所有的信号都能被接收并且处理。
为了解决上面的问题,就出现了阻塞信号
。通过阻塞信号可以暂时阻塞住信号,不接收它,等我们把待处理信号处理了再解除阻塞
然后接受它,这样的话就可以接收到每一个信号并处理了,同样的阻塞信号是通过Blocked
位来实现的,有6个信号那么Blocked就有6位,阻塞一个信号,对应的位就变成1.
可以通过sigprocmask
函数来设置Blocked位,他有三个参数,第一个参数决定了行为,第二个参数是一个set变量用来设置Blocked位,第三个参数是返回一个oldset变量来存储以前的Blocked位,当解除阻塞以后用来恢复Bloked位的。
第一个参数值
使用下面的函数可以操作set变量中的位。
信号处理程序的编写很麻烦,因为它和其他信号处理程序以及主程序都是并发执行的。为了保证安全,要尽可能的保守,以下是一些基本原则。
sig_atomic_t
声明标志。在常见的处理程序设计中,处理程序会写全局标 志来记录收到了信号。主程序周期性地读这个标志,响应信号,再清除该标志。对于通过这种方式来共享的标志 , C 提供一种整型数据类型 sig_atomic_t, 对它的读和写保证会是原子的(不可中断的),因为可以用一条指令来实现它们当程序返回的时候一般都是通过调用栈一层层返回,有的时候会很麻烦,为了避免这种情况,出现了非本地跳转
,它通过setjmp
和longjmp
来进行直接跳转,避免了一层层返回。
setjmp保存当前的运行环境,longjmp可以跳回setjmp的地方。
C++和Java 提供的异常机制是较高层次的,是C语言的setjmp和longjmp 函数的更加结构化的版本。你可以把try语句中的catch 子句看做类似于setjmp函数。相似地,throw语句就类似于longjmp 函数。
在Linux中,所有的一切都是文件
。所以文件读取可以控制一切,包括磁盘读写,网络编程都是通过文件IO来控制的。一个Linux文件就是一个m个字节的序列。
Unix IO接口提供了对文件的控制
文件描述符
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)
每个文件都有一个类型
Linux将所有文件都组织成一个目录结构,最上面是/
根目录,其他所有目录都是它的子级。
打开文件以后返回的文件描述符总是在进程中当前没有打开的最小描述符。通过系统的limit
命令可以看到当前系统可以同时打开的文件数量。
Unix对文件的读取和写入操作可能会遇到不足值(short count)
的问题。比如你要读取一个文件的前100个字节,但是这个文件每行50个字节,所以51个字节就是换行符EOL
,换行符在Linux/mac里面是LF
,在win里面是CRLF
。当遇到换行的时候只会读入换行前的50个字节,然后读取换行的时候会读取0个字节,再之后才能读取下一行。下面是几种会遇到不足值的情况。
RIO包解决了不足值的问题,它提供了两类函数
无缓冲输入输出
1 | ssize_t rio_readn(int fd, void *usbuf, size_t n) { |
1 | ssize_t rio_writen(int fd, void *usrbuf, size_t n) |
有缓冲输入输出
下面是缓冲区rio_t
结构体
1 | typedef struct { |
1 | void rio_readinitb(rio_t *rp, int fd) { |
读取最核心的是rio_read
函数
1 | static ssize_t rio_read(rio_t *rp , char *usrbuf, size_t n) { |
rio_readlineb和rio_readnb都是用了rio_read函数
1 | ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) { |
和上面的rio_readn唯一的区别就是read换成了rio_read,因此里面也不需要在判断是否被信号打断了,因为rio_read已经判断了。
1 | ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n) { |
有两个函数可以获取文件元数据
文件元数据结构体
内核用三个数据结构来表示打开的文件
描述符表
,里面每个表项是一个指针,指向一个打开文件表,每个进程一开始的描述符表都有三个指针,指向stdin,stdout,stderr打开文件表
,记录了所有打开的文件,记录了当前的文件位置,引用计数,指向v-node表的指针。调用close
关闭文件会减少引用计数
,当达到0,内核才会删除这个表项。v-node表
,记录了stat结构中的大部分信息1 | 注意:当fork的时候,子进程会继承父进程的描述符表,打开文件表的引用计数会+1,这个时候想彻底关闭文件,需要两个进程都close才行。 |
可以使用dup2
函数来实现IO重定向
,也就是Linux里面的>
功能,在linux中,cat a.c > b.c
可以把cat a.c的输出内容重定向输入到b.c文件中。
执行dup2(4,1)
的意思是复制描述符fd4的内容给fd1,覆盖fd1原来的内容,假设原来fd1指向标准输出,复制以后fd1的输出将不再输出到标准输出,而是输出到fd4指向的打开文件里面。也就是将fd1的输出重定向到了fd4
C程序还提供了标准IO库,和RIO一样对Unix IO进行了封装,不过标准IO不适合网络读写,所以网络读写应该使用RIO,其他情况都应该使用标准IO。
MMU(Memory Management Unit)
来进行虚拟地址到物理地址的转化。
虚拟内存也像磁盘一样,划分为一个个块,虚拟内存里面一个块叫做一页,虚拟页面,物理内存同样分成一个个页面,称为物理页面。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
因为主存不命中的话需要到磁盘去加载数据,这样会很慢,所以主页页面通常比较大,在4KB - 2MB,由于大的不命中处罚,所以主存是全相连
的。任何虚拟页都可以放在任何物理页中。因为对磁盘的访问时间很长,所以主存总是用写回法
,而不是直写法
。
页表(page table)
存储了虚拟页是否加载到物理页中,以及物理页的地址
,页表中的每一项叫做页表条目(Page Table Entry)
,简称PTE
。
下图展示了虚拟页VP1,VP2,VP4,VP7已经加载到了物理内存中,对应的PTE里面的有效位是1,PTE里面还记录了对应的物理页面地址。VP0和VP5则处于未分配
状态。剩下的VP3,VP6则在磁盘里面。
命中
,如果通过虚拟页找到对应的PTE,发现valid == 1,就可以直接取出物理地址,去物理内存中获取对应的信息。
不命中
,如果通过虚拟页找到对应的PTE,发现valid == 0,那么需要进入缺页异常
处理程序,先选择出一个victim page
,如果victim page
有修改,需要写回磁盘,然后把新的页面装入物理内存。当缺页异常处理完毕以后,返回程序继续处理,进入命中
流程。
如果程序有好的局部性
的话,虚拟内存将工作的很好,不命中会很少,因为都集中在局部性这几页中,如果程序局部性不好,可能就会发生抖动
,虚拟内存不断的换入换出。
你可以利用Linux的getrusage函数监测缺页的数量 (以及许多其他的信息)
页表中还有一些位来表示权限,SUP
位为1表示只有内核可以访问这个PTE,用户程序不可以,READ
则表示可以读取这个PTE的内容,WRITE
表示可以写入这个PTE的内容。如果违反了这些,会触发异常段错误(segmentation fault)
每个进程有自己的页表,当前进程页表的起始地址
存放在页表基址寄存器PTBR
中,n位的虚拟地址包含两个部分,一个p位的虚拟页面偏移VPO(Virtual Page Offset)
和一个n-p位的虚拟页号VPN(Virtual Page Number)
。
VPN
从页表
中获取到对应的PTE
命中
,就把PTE里面存储的PPN
和虚拟地址的VPO
拼接起来成为物理地址
,因为物理页面和虚拟页面都是p字节的,所以VPO等于PPO,可以把VPO直接拿来用。由于每次地址翻译都需要获取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
。
TLB的执行步骤
TLBI
从TLB
中取到对应的PTE
PTE
命中
,就把PTE里面存储的PPN
和虚拟地址的VPO
拼接起来成为物理地址
,因为物理页面和虚拟页面都是p字节的,所以VPO等于PPO,可以把VPO直接拿来用。一个页表
装载现在所有的内存地址,可能会很大,甚至比整个内存都要大,因此,可以使用多级页表
的方式来组织页表。一级页表
里面存放的是对应的二级页表的地址,以此类推。只有最后一个页表中包含的是物理页面号PPN
。
一级页表
的大小,虚拟地址空间大部分都是未分配的,而未分配的一级页表项则不存在对应的二级页表项。在内存的堆
中动态的分配内存,内核维护着一个变量brk
,指向堆的顶部。分配器将堆视为一组不同大小大的块的集合,每个块就是连续的虚拟内存片,要么是已分配的,要么是空闲的。
分配器有两种
显式分配器必须在一些相当严格的约束条件下工作
在这些限制条件下,试图实现吞吐率最大化和内存使用率最大化。
一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑以下几个问题:
将空闲块大小信息放入空闲块头部,返回指针的时候返回指向payload
的指针。如果是8字节对齐的,那么size的最低3位总是0,可以用来存放其他信息,比如最低位来存放是否分配,1已经分配,0还是空闲块
每个网络应用都是基于客户端 - 服务器模型的。采用这个模型,一个应用是由一个服务器进程和一个或者多个客户端进程组成。服务器管理某种资源,并且通过操作这种资源 来为它的客户端提供某种服务。
主机A和LAN1相连,它发送一段数据字节到主机B,主机B和LAN2相连
每个主机都运行TCP/IP
协议。因特网的客户端和服务器混合使用套接字接又函数和Unix I/O函数来进行通信。通常将套接字函数实现为系统调用,这些系统调用会陷入内核,并调用各种内核模式的 TCP/IP 函数。
IP协议提供基本的命名方法和递送机制,能从一台网络主机往其他主机发送包,也叫数据报
。IP协议是不可靠
的,如果数据报
套接字接口(socket interface)是一组函数,它们和Unix I /O函数结合起来,用以创建 网络应用。
从Linux内核的角度来看, 一个套接字就是通信的一个端点。从Linux程序的角度来看,套接字就是一个有相应描述符的打开文件。
现代操作系统提供了三种并发编程的方法
进程通过fork
和execve
来进行开发。
IO多路复用通过select
或epoll
函数来执行,它们会挂起当前进程,当文件描述符的状态改变的时候会触发对应的操作。
1 | #include <sys/select.h> |
IO多路复用的优缺点
除了多进程以外,还可以在一个进程里面运行多个线程,每个线程有线程上下文
。它们包括
多个线程共享进程的虚拟地址空间,代码,数据,共享库和打开的文件,线程的切换开销更小。
线程的一些函数
1 | #include <pthread.h> |
在任何一个时间点上,线程是可结合的
或可分离的
。可结合的线程可以被其他线程收回和杀死。在被其他线程回收前,它的内存资源是不被释放的。一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放。
默认情况下,线程都是可结合的,可以通过下面的函数变成可分离的。
1 | // 某个线程变成可分离的 |
初始化线程。once_control 变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT 。当你第一次用参数 once_control 调 用 pthread_.once 时,它调用 init_routine,这是一个没有输入参数、也不返回什么的函数。接下来的以once_control为参数 的pthread_once 调用不做任何事情。无论何时,当你需要动态初始化多个线程共享的全局变量时,Pthread_once 函数是很有用的
1 | pthread_once_t once_control = PTHREAD_ONCE_INIT; |
共享变量很方便,但也引入了同步错误
的可能性。
假设两个线程操作同一个变量进行计数。我们预期cnt应该是200,但结果却不一定。
1 | for (int i=0; i< 100; i++) cnt++; |
cnt++
虽然是一条指令,但是查看汇编可以发现,其实被分解成了三个指令,Load
将cnt从内存加载到寄存器,Update
更新cnt的值,Store
将cnt写入内存。
如果按照顺序执行,是没有问题的。比如
但是多线程是并发执行的,我们不能假设它们的执行顺序,所以有可能是以下顺序执行
进度图(progress graphy)
可以将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。每条轴k对应线程k的进度。每个点代表已经完成了Ik这个状态。
将两个线程的执行化成进度图,如下:从左下角开始,任意一条可以到达右上角的链接线都是可能的执行顺序。
对于这两个线程来说,Load
,Update
,Store
这三个指令构成了一个临界区
。只要确保每次只有一个线程在执行临界区的代码,就可以保证顺序。也就是对共享变量的互斥
访问。
两个临界区的交集形成的空间叫做不安全区(unsafe region)
。没有经过不安全区的路线叫做安全路线
,经过不安全区的叫做不安全路线
。所有的安全路线都可以得到正确的结果,而不安全路线将得到错误的结果。
通过信号量(semaphore)
可以阻止代码走到不安全路线上面。信号量s是具有非负整数值的全局变量。只能由两种特殊的操作来处理:
P操作和V操作都是不可分割的,不会被中断。
下面是操作信号量的函数
1 | #include <semaphore.h> |
通过P和V操作信号量将不安全区包裹起来,就可以阻止代码跑到不安全区里面。以这种方式来保护共享变量的信号量叫做二元信号量
,因为它的值总是0或1。以互斥为目的的二元信号量叫做互斥锁(mutex)
。P也叫做lock
,V也叫做unlock
。
代码
1 | sem_t s; |
包裹起来以后的进度图
信号量除了能解决同步问题,还可以调度对共享资源的访问
。一个线程可以用信号量操作来通知另一个线程,程序状态中
真子集
。将第2类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使之变为可重入的。获得互斥锁
并以相反的顺序释放
,那么这个程序就是无死锁
的大家好,我是大头,98年,职高毕业,上市公司架构师,大厂资深开发,管理过10人团队。
大家都知道,目前的AI方面可以说是GPT遥遥领先,大部分的国产大模型还是在追赶的路上的。
可是,现在!我国的国产大模型出现了一个巨大利好!那就是DeepSeek诞生了!
DeepSeek是由知名量化资管巨头幻方量化创立,目前最新发布的DeepSeek R1
模型,对标OpenAI o1
模型,已经可以免费体验了!
这可以说是国产大模型的巨大进步!
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的网址,大家可以打开自己感受一下效果!
我们可以看到DeepSeek目前登顶了中国区和美国区下载排行榜,更是超过了ChatGPT。
但是只有实际使用,才能知道大模型的能力。
大头这次问了大模型两个问题,来进行对比,大家一起看看吧!
首先是DeepSeek
。 可以看到基本没啥问题。
1 | import java.util.ArrayList; |
我们再看一下GPT的回答。
1 | import java.util.ArrayList; |
从这里就能看出来了,这两个模型代码能力差不多。
那么,我们再来看第二个问题,算是AI难题了。
聪明的你们都知道是9.9更大,但是AI能正确回答出来吗?
这一次,我们先看GPT的回答。
1 | 9.11 比 9.9 大。 |
可以看到,GPT回答错误了,真不愧是AI难题啊。
我们再看看DeepSeek
的回答。
1 | 比较 9.9 和 9.11 的大小: |
可以看到,也回答错了。好吧,这么看,两个模型不想上下。
但是,我们换一个问法呢?
比如问它们9.9-9.11等于多少
?
我们再看看GPT的回答。
1 | 9.9 - 9.11 = 0.79 |
这个回答没毛病!回答正确,可是我们问它9.9和9.11谁大
,它却回答不出来。
并且我们都知道,模型是有上下文
这个概念的,我们现在根据这个上下文再问一次。问题是那么9.9和9.11谁大
下面是GPT的回答。
1 | 9.9 比 9.11 小。 |
在拥有上下文的过程中,GPT依然回答错误!!
好了,接下来看看DeepSeek
的回答。
问题是9.9-9.11等于多少
?
回答是
1 | 计算 9.9 - 9.11 的步骤如下: |
可以发现,错误了,错的离谱,说明DeepSeek
还是和GPT有一些差距的。
有人说了,你这是不是没用DeepSeek
的R1模型啊。
确实是这样哈哈哈。
我们来看一下R1模型的效果。
问题9.9-9.11等于多少
?
可以看到,这个答案依然是错误的……
那么我们换回刚才的问题请问9.9和9.11谁大
?
见证奇迹的时候。
好吧,奇迹没有出现,依然错误。
不过能看出来大模型确实是在思考,看一下这次的回答,有详细步骤,有纠正,有问题根源,等等,但是依然回答错误了。
下面是具体的回答。
1 | 答案:9.11 更大 |
可以看到,根本原因是因为大模型认为9.9 = 9.90
。
所以,DeepSeek目前还是没办法替代GPT的,不过,国产大模型也很强大了,相信不久的将来是可以超越GPT的!
以上就是今天的内容了,大家有任何疑问可以打在评论区,一起交流~
关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
部分电子书如图所示。
数据库就是管理文件的一个程序。将文件管理抽象出来不同的结构,如关系数据库,文档数据库,图数据库等。方便管理,使用,并能进行复杂的操作,如事务等。更加通用使任何语言都可以使用。对于多个进程并发修改一个文件,那么数据库可以提供更好的性能和解决方案。
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元关系。
关键原则:
结构采用关系。确保数据库内容满足完整性约束。程序通过接口来访问和修改数据库内容。
关系是无序的,n元关系就是n个列的表。一个元组是一行记录。
PostegreSQL:由伯克利大学开发,是之前开发Ingres的人开发的。
IBM的DB2支持SQL,所以SQL成为了标准。
数据库支持SQL,最低要支持SQL-92标准。
下面的sql在postgreSQL中会报错,mysql中如果sql_mode
是ansi
也会报错,如果sql_mode
是traditional
就不会报错,而是会随机选一个cid展示出来。
1 | select avg(s.gpa), e.cid from enrolled as e,student as s |
名称 | 大小写 | 引号 | 字符串拼接 |
---|---|---|---|
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 | create table student2 ( |
下面的是错误做法,因为不知道id最大的name是谁,会报错,如果sql_mode=tranditional,会执行成功,但是name是随机的。
1 | select MAX(e.sid),s.name from enrolled as e,student as s |
下面的在postgresql和mysql都可以执行成功,并获取到id最大的name数据。
1 | select sid, name from student |
下面的SQL在postgresql中可以执行成功,结果和上面的一样,而在mysql8中报错This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'
1 | select sid, name from student |
下面的sql在postgresql 和 mysql 中都可以得到正确的结果
1 | select * from course |
ROW_NUMBER
和RANK
都需要和OVER
一起使用。
下面的SQL,在postgresql中执行成功,mysql8执行报错。
首先查询所有课程信息,并按照课程分组,按照分数排序。
1 | SELECT *, |
接着搜索上表中分数为1,也就是分数最高的学生。也就是每个课分数最高的学生信息。
1 | SELECT * FROM ( |
使用CTE
实现获取每个课程中分数最高的学生信息。
通过WITH
语句来声明一个临时表。表名cteSource
,表的内容就是最的sid,通过SELECT MAX(sid) FROM enrolled
查询出来的结果。字段名叫maxId
。
然后在查询语句里面就可以连接cteSource
表,然后通过sid = cteSource.maxId 来获取到sid最大的用户信息。
1 | WITH cteSource (maxId) AS ( |
还有一些其他的用法,比如:
1 | WITH cte1 (col1) AS ( |
mysql目前还不支持该功能,postgreSQL和Sqlserver等支持。
页的三个概念
磁盘和内存通信是一页一页的,如果数据都在一页里,后续的访问请求就可以走内存了,要不然还的从磁盘获取。内存中可以获取bit数据。
系统设计目标:给应用程序一个错觉,能提供足够的内存将整个数据库存入内存中。
实现:谨慎的最小化每次从磁盘读取内容或运行查询时所带来的影响。
流程:
buffer pool
请求查询内容。database file
请求。buffer pool
。上面的步骤操作系统本身就可以实现,比如使用mmap
,但是操作系统是统一的动作,遇到一些问题不知道该如何处理,而DBMS则可以根据不同的情况做不同的处理,进行优化。像主流的mysql
,SqlServer
,Oracle
都没有用mmap
。mongoDB
早期使用的mmap
,后面也是用WiredTiger
替换掉了mmap
。
DBMS自己实现的话,主要关心的两个问题:
数据库的数据最终以文件的形式放在磁盘中。通过文件读写将数据读写到文件中。文件有特定的格式,具体的内容有数据库进行解析然后展示在数据库中。这就是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对于文件在磁盘上的存储方式不一样,有下面几种
堆存储
page directory
方式来记录文件位置。page directory
page header
一般想法,直接存储,并在后面追加,但是对于可变数据长度很难管理。
所以,page内部,通常不使用上面那种,而使用的是slotted pages
record ID
表示一个tuple的物理位置,不同的DBMS有不同的名称,来表示数据的唯一位置,比如postgresql
的ctid
,oracle
的rowid
。ctid
由page id
和slot number
组成。
插入新的tuple的时候
更新tuple的时候
因此更新的时候有一些问题
所以有些DBMS不能更新数据,只能增加数据,比如HDFS等
比如HBase,ClickHouse,RocksDB,LevelDB都是这个方式。
这种方式的一些问题:
tuple
tuple就是一堆bit,DBMS解释他们的作用。里面包含
table foo
现代CPU是64位对齐,创建表以后,DBMS会自动的将数据进行对齐存储,不过,如果在创建表的时候考虑对齐,可以优化速度和存储空间。
可变长度的数据varchar
,varbinary
,text
,blob
,他们的长度存在header里面。
日期时间类型存储的是时间戳。
float/real/double: 是浮点数,cpu支持浮点数运算,优点是速度快,但是会精度缺失
decimal: 是定点数,运算速度慢,但是精度高。
large values,应该避免这样,因为维护overflow page很麻烦。
toast
,如果数据大于2KB,就会放到toast中,tuple中只存储指针。overflow page
,如果数据大于1/2的page大小,就会放进去,tuple中只存储指针。外部存储
NULL存储
catalogs 用来存储数据库元信息,大多数数据库将这些信息存到一张表里面
OLTP
OLAP
HTAP
N-ary 模型
优点
index-oriented
物理存储方式缺点
and/or
属性的子集value domains
Decomposition 模型
优点
cached data
重用缺点
列存储查询的时候处理where子句以后需要找到对应的其他列在其他page中的位置,有两个方法,通常使用第一个方法,第二个方法并不好
Partition Attributes Across(PAX) Storage 模型
PAX 物理数据组织
row groups
,即一些行数据的集合row groups
里面垂直划分为column chunks
,即列的集合,也就是列存储方式column chunks
下面可能还会有page目标1:必须产生固定长度的值
目标2:在查询期间尽可能推迟解压缩,你不希望先解压缩在查询,这样很占空间且影响速度
目标3:必须是无损方案
压缩粒度
innodb 在写入的时候可以不解压,但是读取的时候会先在buffer pool中解压在读取。因此Mysql innodb的压缩的好处是提升空间利用率,减少了磁盘IO,缺点是读取的时候需要解压,因此增加了这部分的时间和CPU功耗以及解压以后会占用更多的内存空间。
innodb 默认page 是 16KB,可以压缩到1/2/4/8KB。
将单个column中的相同值压缩成三元组,需要对列进行智能排序,以最大限度地提高压缩机会。
比如下面的数据,将压缩成右边的数据,(Y,0,3),代表值是Y,起始位置0,值的数量有3个。后面的压缩数据是一样的。这种压缩方法可以快速计算count的数量等。
如果你的值类型很少,且有序,那么将大大减少空间占用。
如果字段里面的值都比较小,但是column type很大,可以忽略掉不需要的bit,比如int是32 bit,但是里面的值都很小,用不了这么多,就可以忽略他们。
使用bit map来标识数据值,仅仅适用于值的类型比较少的。
找到一个基本的数据,以它为基础,进行压缩,+1,-1这种。再将其按照run length encoding
的方式压缩,可以再次节省空间。
按照字典将数据进行映射,并存储,这样可以节省空间,如果在字典映射的时候还能先排序,那么还可以完成将where like 'and%'
转成where between 10 and 20
。
时间管理
空间管理
frame
page table
page 里面记录一些元数据
lock and latch
buffer pool 使用 mmap的问题:
SIGBUS
,而整个DBMS都需要处理它。全局策略
局部策略
淘汰策略有几种算法
相当于K=2。有一个LRU List,但是有两个指针,分别表示old list
和young list
。当数据第一次被访问的时候放到old list
中,再次被访问的时候放到young list
中。
当访问 page1 的时候,需要淘汰掉old list
中的page8,其实也是整个LRU中的最后一个元素。然后将page1插入old list
。
当再次访问 page1 的时候,将page1 插入young list
。这个时候young list
最后的元素也就进入了old list
.
比如B+树的根节点具有最高的优先级,所以一直放在内存中。
多buffer pool
预取数据
扫描共享
buffer pool bypass
os page cache
两种写出方案需要做权衡,取舍
hash function
hash schema
墓碑
标记,这样就知道这里是有数据的不是空,查找的时候就会继续往下扫描而不会是没找到距离数
,表示插入的位置和应该插入的位置的距离。从0开始。劫富济贫
,如果你向下扫描到距离数为3的地方插入,而在距离数为2的地方的数据x,x的距离数比你小,比如是0,1.那么你就占据这里,你插入距离数为2的地方,而将x插入你下面,x的距离数会+1.hash table
来记录数据,对A进行两次hash,得出两个hash table中的插入位置,随机选择一个进行插入动态hash table
split point
,一开始指向0,然后每次overflow
需要拆分的时候就拆分split point指向的那个bucket,然后slot array只扩容一个,这个时候出现第二个hash函数并将split point+1b+ 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数据
b tree 和 b+ tree 的区别
latch
latch
b+ tree 插入
b+ tree 删除
latch
,因为不知道需不需要合并,操作以后才会释放M/2 - 1
,那么就会出发合并,因为不满足key数量啦b+ tree 标准填充容量大概是67% - 69%,对于一个大小是8kb的page来说,如果高度为4,大约能记录30 0000个键值对。
b+ tree的查找
b+ tree的节点大小,机械硬盘的大小最好在1M,ssd的大小在10KB
推荐书籍 Modern B-Tree Techniques
对于非唯一索引
节点内部的搜索
优化方法
b+ tree的重复key,通常使用增加record id
的方式,这种方式影响更小。
record id
,record id
是page id
+ offset
用来确定tuple的位置。部分索引
覆盖索引
函数索引
trie index(前缀树)
radix tree
Bloom filter
Counting Bloom filter
Cuckoo filter
Succinct Range Filter
并发控制
latch 模式
latch
latch crabbing/coupling
安全
的,就可以释放上层的所有latchs安全
:指操作的时候不会触发拆分
和合并
。通常read latch都是安全的,write latch 插入的时候如果有足够的空间就是安全的,删除的时候删除以后不会合并就是安全的乐观锁
合并
和拆分
。合并
和拆分
,再次从头获取write latch来一遍合并
和拆分
会再来一遍,而且如果连续的插入都需要合并,就会退化成每个都获取write latch。叶子节点扫描
死锁
,比如两个线程自杀
,然后重头再来,假如线程1自杀,然后再来一遍,这个时候线程2就可以获取到latch,然后执行下去了overflow处理
父结点
传播,因为修改父结点需要从头开始获取write latch。排序的好处
order by
分组的时候可以更快的分组distinct
去重的时候可以更快的去重排序算法
可以用内存的大小
,这样就知道该内存排序还是磁盘排序归并排序
更好,分成多个runs
,对每个run排序,然后在通过二路归并
生成总的排序,这可以减少随机IObuffer pool
,2个用来排序run,1个用来二路归并。 预取
来优化,当对page排序的时候,另外一个线程先取出下次要排序的page。聚簇索引
比如下面的sql
1 | select * from a |
那么首先创建一个大小为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} |
当数据太大,无法放在内存中的时候,需要借助外部的文件来进行排序
early materialization
late materialization
优化方法
两个实现方法
group by 和 distinct 本身执行的时候也是需要排序的
hash
排序的聚合实现,以distinct为例:
tuple
哈希的聚合实现,以distinct为例:
tuple
重新哈希的时候
join输出:数据
join输出:record id
vertica
列存储数据库,不过现在已经不用了如何判断两个join算法的好坏?
join算法
1 | for (Tuple r: R) { |
1 | // 这个看上去循环多了,不过因为预先读取了两个block才循环,所以循环是在内存中,IO次数少了 |
假设s.id有索引,那么就可以根据索引进行匹配,加快速度.
M + (m * C)
C是索引需要的时间1 | for (Tuple r: R) { |
1 | sort R,S on join keys |
1 | for (Tuple r: R) { |
Hash Join优化
布隆过滤器
来优化,这样的话在probe阶段,对右表的key, hash以后先查询布隆过滤器,如果false,就不需要在放入hash table去匹配了Grace Hash Join
hash 几乎总是好的。
排序是好的情况有两种
processing Method
Iterator Model
next
方法调用下层的方法并接收返回值Materalization Model
next
方法,使用了output
方法,一次输出所有数据给上层Vectorized Model
next
方法,但是一次性返回一堆 tuples, 数量取决于 Buffer pool 大小Access Method
顺序扫描的优化
多索引扫描
Zone Maps
late materialization
Expression Evaulate
=
,>
,<
,and
,or
等。子节点是两边的值Process Models
Process per DBMS Worker
共享内存
进行buffer pool的通信,要不然每个进程都会有一个buffer pool。Process Model
Thread per DBMS Worker
scheduling
Intra query parallelism
Intra operator(水平)
exchange operator
来进行合并,拆分也是通过它。Exchange operator
Inter operator(垂直)
Bushy
假设有以下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' |
数据库将构建以下关系代数的树
按照这个关系代数的树来执行的话,总共需要 2M的IO
接下来将笛卡尔积的代数换成join的代数,就算使用Nested Loop Join,也能获得54K的IO
如果将Join算法替换成Sort-Merge Join
,则可以将IO降低到7159
这个算法是基于Materialization Model
的,所以每次还要写入文件,再读取。如果优化成Veectorization Model
,减少重复的写入和读取,可以达到3151的IO
wraning:
查询结构
逻辑查询计划
物理执行计划
生成物理执行计划的时候,可能有多个执行路径,在短时间内可能无法从全部的路径中选出最佳的。
查询优化是很难的,有些数据库的查询优化做的很差,DB2曾引入机器学习做查询优化,效果并不好,被吐糟安装DB2要做的第一件事就是关掉这个功能
查询优化
关系代数等价
query rewriting
属于上面的 Tree rewrite
阶段predicate pushdown
projection pushdown
语句重写
mongoDB没有使用成本预测模型,而是执行所有的查询计划,哪个最先返回就用哪个
最初是IBM提出的。枚举不同的查询计划,并估算他们的成本,在检查完所有的计划或者超时后,选择其中成本最低的一个。
single relation
对于单表查询来说,一般会使用启发式规则
,他来判断哪些where条件能筛掉更多的数据,就先进行哪个where。
sargable (search argument able)
:他会比较不同的索引,比如这个索引合适,那么就会使用它,比如id=1的,那么就会使用主键索引
multiple relation
比如有下面的SQL,ALBUM.NAME 字段有索引
1 | SELECT ARTIST.NAME |
进行第一步,可以得到三个表的扫描方法
接下来组合不同的join算法,我们可以先连接ARTIST和APPEARS表,也可以先连接APPEARS和ALBUM表,或者ALBUM和ARTIST表,并且可以使用hash join 或者 merge join
最终为每个可能选择成本最低的join
接下来为每个可能去join其他表,来完成最终的三个表join,这个时候还是有hash join和merge join,选择最适合的join方法,就会产生三个路径
在从这三个中选择出成本最低的一个路径作为最终的路径
首先生成逻辑节点,最底下是三个表,最上面是三个表join并且order by,中间是两个表join
接下来先生成三个表的一个物理操作,因为需要order by,所以可以认为merge join更好。
接下来两个表的物理操作,可以选择hash join或这merge join,因为要排序,可以认为merge join更好
最后在探测其他的路径,比如最上层还可能有hash join,或者先hash join,在排序,但是这些都没有merge join的成本低,所以在探测到以后就可以Pass掉了
比如以下SQL
1 | SELECT name FROM sailors AS S |
可以被重写为
1 | SELECT name |
比如下面的SQL
1 | SELECT S.sid, MIN(R.day) |
可以将子查询提取出来变成
catalog会记录一些成本信息,不同的DBMS有不同的更新策略,也可以手动更新,这被叫做statistics
statistics: 维护着下面的信息
选择基数SC
是 counter / V(A,R) 的值选择率:有了上面的数据,就可以计算出要查询的数据的分布比例了。这里就是求概率。
汽车表
,有make
字段代表生产商,model
代表型号,我们知道model = “帕萨特”,make 一定是 大众
。按照我们的算法 假设make 有10个,选择率就是1/10, model 100个,选择率就是 1/100,总的选择率就是 1/1000,但是帕萨特一定是大众的,所以真实选择率其实是1/100。有些数据库可以设置字段关联来解决这个问题,比如oracle等,mysql和postgresql不行。直方图的存储,由于存储所有信息的直方图可能很占空间,可以选择稀疏存储,合并一些数据,这样会牺牲一些准确率,但是节省空间。
除了直方图以外,有些数据库还会使用抽样检查,花费一些时间进行抽样,然后根据样本来进行预测选择率。
原子性:事务的每个操作都是原子的,要么全成功,要么全失败,通过undo redo log实现
一致性:保证事务执行前和执行后是一致的,中间可以临时不一致,但最终要一致。通过raft等共识协议实现
隔离性:保证事务的隔离性,每个事务都是独立运行的,并发的时候通过并发控制协议
来保证交错执行,通过latch保证正确性。通过并发控制实现
持久性:事务提交后数据持久保存了,通过undo redo log实现
当转账的时候,事务被突然的中止,或者断电,该怎么做?
Logging
shadow paging
并发控制协议:
顺序执行:
交错执行:
假设a,b账户都有1000,那么经过t1事务和t2事务执行以后,总的结果应该不变,对于数据库来说,哪个事务先执行都可以,如果想控制事务执行顺序,应该由应用层控制。
如果交错执行的结果和顺序执行的结果不一样,就是错误的
总共会出现三种冲突
读写冲突(不可重复读):当读第一次的时候,值被其他事务改变了,再次读的时候,值就和第一次读的时候不一样了
写读冲突(读未提交或脏读):A事务读取后,修改了值,B事务读取了修改的值,然后又修改了值,B事务提交后,A事务中止,回滚。
写写冲突(覆盖数据):两个事务同时写入一个值,有一个值会被覆盖掉。
冲突可串行化,大多数数据库使用的,还有个视图可串行化,没数据库实现
假设事务t1写入了A数据,事务t2同时读取A数据,那么事务t2就依赖了事务t1
假设事务t2又写入了B,事务t1要读取B,那么事务t1就依赖了事务t2,这个时候就产生了循环依赖,就跟死锁一样,所以这个时候需要回滚一个事务
事务A先获取锁,事务B等待锁,事务A执行完成以后,释放锁,事务B才能拿到锁。
共享锁,读锁,S-LOCK
独享锁,写锁,X-LOCK
如上图所示,这样还是会出现不可重复读的问题,两阶段锁可以解决这个问题,两阶段锁是第一个正确的并发控制协议
两阶段锁,遵循这个方法,使得事务是冲突可串行化
的,但是会有级联事务中止(cascading aborts),可以通过强严格两阶段锁解决这个问题。
从生命周期来看,第一阶段是上升,第二阶段只会下降,不会再次上升,下图是正确的生命周期
下图是错误的生命周期
cascading aborts,如果事务t1中止回滚了,那么事务t2就发生了脏读
,所以也需要回滚重来
在提交事务的时候才释放锁。可以解决脏读的问题。
非两阶段锁执行如下:
两阶段锁执行如下:
严格两阶段锁执行如下:
死锁检测:通过使用wait-for
图来检测依赖关系,如果有环就是死锁
检测的频率可以通过参数调整,这个需要权衡
victim选择,选择出回滚哪个事务,这也是企业级系统和开源系统的区别
根据时间戳来选择
数据库锁层次
意向共享锁IS
意向排它锁IX
共享意向排它锁
假设t1事务要查询一个tuple,那么可以在table上一个IS锁,然后在具体的tuple上一个S锁,接下来t2事务要更新一个tuple,发现表上是IS锁,那么根据规则,可以在table上在加一个IX锁,然后在要更新的tuple上一个X锁,如果恰好是同一个tuple,那么就等待S锁释放,如果不是同一个,那么就并发执行成功
假设t1事务要扫描所有tuple来找到一个tuple去更新,那么table上一个SIX锁,然后扫描到一个tuple,就给一个tuple上X锁,扫描完就释放X锁,接下来t2事务要读取一个tuple,发现表上是SIX锁,那么根据规则,可以在table上一个IS锁,然后在要查询的tuple上S锁,如果刚巧tuple上有X锁,那么就等待t1事务释放。
两阶段锁是一种悲观
的协议,所有人都会上锁,会争抢,时间戳是一种不依赖锁的乐观
的协议。
Ti代表事务i得一个时间戳,Tj是j的,如果Ti < Tj,那么i得事务会在j之前提交。
时间戳的两个特性
每个tuple需要维护两个时间戳,一个读时间戳
,一个写时间戳
。
在读取的时候要保证,当前时间戳 > 写时间戳,也就是读取的是最新的值,未来不会被改变的值。
如果 当前时间戳 < 写时间戳,那么重启事务,分配一个新的时间戳,再试一次。
如果成功取到tuple,那么需要更新读时间戳
,使用自己的时间戳和原来的时间戳中大的那个去更新。
写入的时候要保证,当前时间戳 > 读时间戳 并且 > 写时间戳
成功写入的时候要更新写时间戳,使用自己的时间戳和原来的时间戳中大的那个去更新。
在写入的时候,如果当前时间戳 < 写入时间戳,本来应该中止
的事务,可以继续执行,但是写入操作不写入数据库,因为数据库的数据是更新的,而是写入本地副本,方便这个事务后面使用。
另外一个是乐观并发控制
。为每一个事务创建一个私有空间,他的所有操作都是先对私有空间的副本操作,最后执行到数据库里面的时候需要对比一下,是否能执行。如果不冲突,就可以执行。
三个阶段
假设事务t1读取tuple A,将从全局工作区复制A到私有工作区
接下来事务t2读取tuple A,也将从全局工作区复制A到私有工作区
接下来事务t2进入validation阶段,然后分配时间戳为1,接下来进入写入阶段,什么也不做
事务t1修改tuple A,然后再次读取tuple A,可以重复读取,因为读取私有工作区的tuple A
事务t1进入validation阶段,分配时间戳为2,因为1已经分配给t2事务了,写入阶段将私有工作区的tuple A,更新到全局工作区
数据库拥有全局视野,在validation phase阶段,是单线程比较事务是否可以执行,会有一个大的latch上锁
如果事务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才对
如果t2的validation阶段在前就没问题,因为t2分配W-TS=1,t1是2
如果事务t1 < t2 ,t1的validation阶段在t2的前面,但是t1已经写入全局工作区以后,t2在读取这个tuple,就没有问题
forward validation和没有提交的事务进行比较
backward 和已经提交的事务比较
按照时间戳水平分区,在同一个区里面,按照时间戳顺序执行,就不需要latch了。速度会很快。
在不同区执行的话,就很复杂了。
每个分区都是单线程执行的。这样不需要获取latch。
假设事务t1读取了table a的count()是99,事务t2插入了一条数据,事务t1再次读取count()就变成了100,因为t1只能lock已经存在的数据,要插入的数据没办法lock
解决幻读的三个方法
key-value locks只能锁定键值,需要一个virtual key来锁定不存在的key
间隙锁,锁定键值和下一个键值之间的间隙
key-Range locks,锁定键值以及和下一个键值之间的间隙
Hierarchical locks使用IX,IS等意向锁
不同隔离级别对应的问题,最受欢迎的隔离级别是读已提交,mysql默认是可重复读
MVCC(Multiple Version Concurrency Contronl)最早在1978年由一位MIT的Phd学生提出。在1980年被数据库实现。
firefox 最开始叫 phoenix, 但是因为和其他的重名了需要改名字,然后改成了firebird, firebird是个最早开源的数据库,它使用了MVCC,所以火狐还要改名,就成了firefox。
只读事务读取快照,不需要锁。MVCC天然支持快照隔离,如果没有gc,支持time travel query,就是可以查询很久以前的更改
版本维护有3个元数据
还要维护一个事务状态表
假设事务t1读取A0的数据,然后事务t2写入A1数据,然后更新A0的end-ts=2,事务t1再次读取的时候还能看到A0
假设事务t1读取A0的数据,然后写入A1的数据,事务t2读取A0的数据,写入A2的数据,这个时候t2会阻塞,因为t1持有A的锁,当t1事务commit后,释放锁,事务t2才能继续写入A2的数据
当事务启动的时候,会看到启动时数据库里的一致的快照
快照隔离收到写入偏差异常(Write Skew Anomaly)的影响。
写入偏差异常,假设当前有2个黑球,2个白球,事务t1要将白球更新成黑球,事务t2要将黑球更新成白球,这个时候事务t1读取到2个白球,只将这个两个白球更新成黑球了,而事务t2读取了2个黑球,只把这2个黑球更新成白球了,最终结果还是2黑2白
但是顺序执行的话结果应该是全白或全黑。
并发控制协议
版本存储:为每个逻辑tuple创建一个链表,每个事务通过指针遍历链表获取对应的版本。索引指针指向链表的头节点。
要么最新到最旧的连接,要么最旧到最新的连接,全都放在一个工作空间中,然后用指针连一起
这个是两个空间来存储,一个main table存储最新的,一个time travel table存储所有旧的,像Sql server这种最初没有设计MVCC的数据库,为了兼容使用了这种方法。
也是两个空间存储,不同的是旧空间只存储修改的列的值
garbage collection(垃圾回收)
后台线程的方式运行,每隔一段时间去扫描,还可以通过bit map来提升速度
假设现在正在运行的事务有两个,最低时间戳是12,那么所有end-ts低于12的都可以被gc,数据库基本都用的这个
当查询某个tuple的时候,进行扫描这个tuple的旧版本,从而gc,缺点是如果这个tuple一直不被访问,那么就一直不gc,很罕见
在事务提交的时候进行对比旧版本的数据是否可以删除
索引管理
所有的索引都记录指针,缺点是更新的时候所有索引的指针都要更新
二级索引都记录主键,只有主键索引记录指针,mysql就这样的,这种方式更好
如果索引不是唯一索引,可以有多个key的话,假设事务t1读取A1,没问题
假设事务t2更新了A1,又删除了A1,那么数据如图所示A1老版本指向新版本,但是新版本是被删除的
假设事务t3插入了一条A1数据,那么索引就指向两个A1数据
MySQL使用两阶段锁,版本存储使用Delta Storage,垃圾回收是tuple level Vacuum,索引管理是逻辑指针。Mysql更快。
PostgreSQL使用两阶段锁,版本存储使用append only storage, 垃圾回收使用tuple level Vacuum,索引管理是物理指针。
MVCC实现
在数据库运行时,还没有把数据写入磁盘的时候发生故障,这个时候需要恢复数据。
主要是两件事
故障类型
Undo:维护一些信息,可以恢复事务对数据库中某个对象所做的任何修改。
Redo: 维护一些信息,可以重新执行某个事务对数据库中的某个对象所做的修改。可以重新执行一个已经提交的事务的修改。
两个策略
not steal + force
shadow paging:
wirte ahead log
日志内容
当事务执行的时候,写入日志到内存中,比如事务t1,将A从1修改到8,当事务提交的时候,先把日志写入磁盘,然后告诉客户端事务成功,如果这个时候发生断电等,可以读取日志来恢复事务,如果写入日志之前断电,那么无需恢复,因为没有告诉客户端成功,事务没有成功如何办是客户端要考虑的。
group commit
满了的写入磁盘可以异步写入,增加速度
shadow paging 和 WAL 就是一种权衡,是看重运行时速度还是恢复速度,几乎所有的数据库都是用WAL,比如mysql的bin log, WAL运行时速度更快
logging schemes
WAL的缺点是无限增长,如果崩溃后,则需要重现所有的WAL,增加检查点以后,可以只重现检查点以后的日志。
日志中写入check point,check point之前的都是已经写入磁盘的,所以恢复的时候就不用管了。
假设事务t1在检查点前commit了,则不需要恢复,因为已经提交了,事务t2在检查点后commit,在检查点前开始,则需要redo恢复事务t2,事务t3在检查点前开始,但是还没有commit,所以不需要恢复,直接撤销即可。
三步
日志序列号(LSN)
如果pageLSN <= flushedLSN, 表明这个page的数据都已经写入磁盘了
事务提交的时候,往日志里面写入一个txn end
。
当事务commit后写入磁盘,然后更新flushedLSN,接下来添加txn end标识
CLR:abort算法
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标识
check point写入
ATT(Active Transation Table)
DPT(Dirty Page Table)
第一个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
fuzzy check point
因为所有事务都在运行中,所以增加了check point begin 和 end来标识check point的开始和结束,第一个check point end里面记录了事务t2和脏页P22,是因为这些发生在check point begin之前。
arise recovery
分析阶段:从master record的位置开始扫描到最后,找出这之间的所有active transation table和dirty page table信息。
redo: 根据分析出的信息,找到dirty page中最早的一个recLSN, 也就是最早的一个日志,然后从这里开始恢复数据,执行一遍所有的操作。来恢复buffer pool。
undo: 从最后开始往上面扫描,把需要撤销的数据进行撤销。
并行数据库
系统架构
shared noting example
首先通过catalog查询应该请求哪个节点
然后发送查询请求到相应的节点
如果数据跨节点存储,那么数据会节点间通信,比如获取100和200的数据,node p1会请求p2获取200的数据,然后p1将两个数据返回给客户端
shared disk example
客户端请求节点获取数据,节点请求disk获取数据,然后返回
但是更新的时候需要广播给其他节点
mongo属于shared noting
数据拆分
一致性hash
来解决增加分区的问题,而不是取余。SHARED-DISK PARTITIONING
SHARED-NOTHING PARTITIONING
一致性hash
节点分布在环上,数据也分布在环上,顺时针旋转,数据就属于第一个到达的节点。
增加节点的时候,只需要重新hash其中一个节点的数据就可以,比如增加p4,因为P4节点落在p3节点的范围里面,所以只需要重新hash节点p3的数据,其他节点无影响。
如果需要复制数据,将数据存储在多个节点上,假设replication=3,复制数据到三个节点,则顺时针旋转的3个节点都存储该数据。
分布式事务
spanner
HNSW
假设所有节点是友好的
replication:可以提高可用性
副本配置
primary,写入主节点,读取可以在从节点
multi-primary 任何节点都可以读写
K-safety: 通过监控对象来看有哪些replica是活跃的。至少要有k个replica,如果小于k个,就认为宕机了。
传播方案
传播时序
事务提交的顺序由数据库状态决定,原子提交协议也是分布式的共识协议
原子提交协议
两阶段提交
假设客户端发送提交事务的请求,有一个协调器和若干个参与者,协调器接收请求以后,向参与者发送第一阶段prepare请求。
如果所有参与者返回OK,表示全部同意提交,则可以提交。
接下来协调器发送第二阶段commit请求给所有参与者
所有参与者返回OK以后,协调器返回提交成功给客户端
假设有任何一个参与者在第一阶段返回了不同意,则终止事务提交
协调器向所有节点发送第二阶段abort请求
等待所有节点返回OK以后,协调器返回abort给客户端
崩溃恢复
优化
early Ack After Prepare:当第一阶段prepare返回成功以后,立即给客户端返回成功。
返回给客户端以后再发送第二阶段commit请求给其他参与者。
Paxos,来自分布式计算领域,也被称为共识协议
。两阶段提交是Paxos的一个子集。
multi-Paxos
两阶段提交 vs Paxos vs Raft
两阶段提交
Paxos
Raft
CAP理论
Nosql基本都是AP,事务性的基本都是CP
一致性代表从哪个节点获取的数据都是一样的
可用性代表当节点挂了以后系统还可以使用
分区容错性最难,容易出现脑裂
问题。当网络挂掉以后从节点以为主节点挂了,所以把自己选举为主节点,就产生了两个主节点
当网络恢复以后,两个节点的数据就不一样了。
解决方法1:停止系统
解决方案2:允许拆分,协调更改
2010年提议对CAP进行扩展
OLAP数据库也被称作数据仓库
,通过ETL,把数据存入数据仓库。
星形模型
雪花模型
查询执行
条件下推
那样push
pull
查询计划
大家好,我是大头,98年,职高毕业,上市公司架构师,大厂资深开发,管理过10人团队,我是如何做到的呢?
这离不开持续学习的能力,而其中最重要的当然是数据库技术了!
对于所有开发来说,都离不开数据库,因为所有的数据都是要存储的。
其他的技能可能会变,比如业务开发、算法开发、基础设施开发,也不管你是用Java、php、golang、C++等等。你都会用到数据库,因此,学好数据库对于我们来说就至关重要了。
接下来大头将分享自己学习数据库的路线以及心得。
后续也会根据这个路线分享所有的学习方法以及实操案例。关注我一起学习!文末有惊喜哦!
如何学好数据库?我相信这是一个老生常谈的问题了,如何学好XXX,这里我觉得最重要的是实践
。
相信大家都知道这句话。实践是检验真理的唯一标准
。因此,当分享结束以后,大头还会分享完整的实操应用,完全免费分享,放在外面至少价值大几百的实操应用训练营。
在现在这个AI发展迅速的时代,你不用点AI好像就被社会淘汰了一样。所以,我们来看一下AI给出的回答。
1 | 学好数据库需要系统的学习方法和实践相结合,以下是一些建议: |
可以看到AI给出的回答相当不错了。看起来也是那么回事。
对于学习一种新事物来说,我觉得要分为几个阶段吧,对于所有新事物都适用。
那么回到我们的话题上,如何学好数据库?
那么再详细一些呢?如何学好MySQL数据库?
首先,我们应该知道什么是数据库?很多人都会搞混一个概念,那就是数据库和数据库管理系统。
数据库的英文是DataBase
。它的概念是
1 | 数据库是一个长期存储在计算机内的、有组织的、可共享的数据集合,它具有以下特点 |
而数据库管理系统的英文是DataBase Management System
。它的概念是:
1 | 数据库管理软件(Database Management System,简称DBMS)是用于创建、管理、维护和操作数据库的软件系统。它在用户和数据库之间提供了一个接口,使得用户能够方便地存储、检索、更新和管理数据。 |
因此,我们要明白,MySQL
是一个数据库管理系统,而不是一个数据库。
虽然我们老说MySQL数据库
,但这个是因为大家已经习惯了,大家都明白MySQL是什么,因此省略了一些。
MySQL
是用来管理数据库的一个系统。
那么问题来了,SQL
又是什么呢?
这里给出基础篇的概念学习路线。大家可以根据这些去了解具体的概念。
这一个部分1-2小时就差不多了。
学会了概念以后,我们就要应用,进行实践。只有这样才能将知识转化成我们自己的。
对于实战来说,首先肯定要进行MySQL的安装,可以直接到官网进行安装,这里给出连接。
安装完成以后,根据上面学习的概念,首先执行一遍DCL、DML、DDL。
接下来需要学习ER图
。如何学习ER图呢?同意的,先了解一下概念,在进行实战应用。自己画一画ER图。
画ER图的工具,这里推荐几个
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学习路线了。
关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
部分电子书如图所示。
大家好,我是大头,98年,职高毕业,上市公司架构师,大厂资深开发,管理过10人团队,我是如何做到的呢?
这离不开持续学习的能力,而其中最重要的当然是数据库技术了!
对于所有开发来说,都离不开数据库,因为所有的数据都是要存储的。
关注我一起学习!文末有惊喜哦!
MySQL 索引失效是指尽管表中已经建立了索引,但在某些查询操作中,MySQL 的查询优化器选择不使用这些索引,而是采用全表扫描(Full Table Scan)或其他非索引扫描方式来执行查询。这种情况通常会导致查询性能下降,因为全表扫描需要扫描表中的每一行数据,而不是利用索引快速定位数据。
这里就要介绍一下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
优化器负责几个事情
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)
。等等。where a is null
这种条件进行优化,比如该字段设置了not null
,那么这个条件就会被删除。Top N
排序这里简单的介绍一些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; |
如果没有谓词下推优化的话,执行树如下。
其执行顺序如下:
table_a
表的10条数据,将10条数据传递给where过滤节点。a = 1
条件的1条数据,将这个数据传递给列选择节点。其内存中要存储10条数据。
而有了谓词下推优化以后,执行树如下。
执行顺序如下:
table_a
表的10条数据,过滤出符合a = 1
条件的这一个数据。将这个数据传给列选择节点。对于BTREE和HASH索引,当使用=、<=>、IN()、IS NULL或IS NOT NULL运算符时,键部分与常量值的比较是范围条件。此外,对于BTREE索引,当使用>,<,>=,<=,BETWEEN,!= 、或<>运算符,或者LIKE比较(如果LIKE的参数是不以小写字符开头的常量字符串)。对于所有索引类型,多个范围条件与OR或AND组合形成范围条件。
给定数据
1 | key_part1 key_part2 key_part3 |
执行where key_part1= 1,其扫描范围为 1,负无穷,负无穷到 1,正无穷,正无穷
1 | (1,-inf,-inf) <= (key_part1,key_part2,key_part3) < (1,+inf,+inf) |
即覆盖了这三行
1 | 1 1 '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就是多个索引并发扫描,再将扫描结果合并
索引合并不适用于全文索引。
索引合并访问方法检索具有多个范围扫描的行,并将其结果合并为一个。此访问方法只合并单个表的索引扫描,而不合并多个表的扫描。合并可以产生其底层扫描的并集、交集或交集的并集。
可以使用索引合并的查询示例:
1 | SELECT * FROM tbl_name WHERE key1 = 10 OR key2 = 20; |
如果你的查询有一个带有深度AND/OR嵌套的复杂WHERE子句,并且MySQL没有选择最佳计划,请尝试使用以下恒等转换来分发术语:
1 | (x AND y) OR z => (x OR z) AND (y OR z) |
在EXPLAIN输出中,Index Merge方法在type列中显示为index_merge。在本例中,key列包含使用的索引列表,key_len包含这些索引的最长键部分列表。
索引合并访问方法有几种算法,它们显示在EXPLAIN输出的Extra字段中:
索引合并的使用取决于optimizer_switch系统变量的index_merge、index_merge_intersection、index_merge_union和index_merge_sort_union标志的值。默认情况下,所有这些标志都是打开的。
默认情况下,MySQL尽可能使用哈希连接。可以使用BNL和NO_BNL优化器提示之一来控制是否使用散列连接。
hash join比嵌套join快的多,首先创建hash表,在循环另一个表进行hash,判断是否相等
可以使用join_buffer_size系统变量控制哈希连接的内存使用量;哈希连接使用的内存量不能超过此值。当哈希连接所需的内存超过可用量时,MySQL会使用磁盘上的文件来处理。如果发生这种情况,您应该注意,如果哈希连接无法容纳内存并且它创建的文件比为open_files_limit设置的文件多,则连接可能不会成功。要避免此类问题,请进行以下更改之一:
MySQL成本模型(Cost Model)是MySQL查询优化器(Query Optimizer)用来评估不同查询执行计划的成本(Cost)的一种机制。成本模型通过估算每种执行计划所需的资源(如CPU、I/O、内存等)来选择最优的执行计划。
MySQL的成本模型主要考虑以下几个方面:
其中大部分的成本都是固定的,比如CPU成本、IO成本、内存成本。这个是根据你服务器的配置决定的。
所以,主要关注的是数据分布。
MySQL的数据分布使用直方图
来记录。
column_statistics数据字典表
存储有关列值的直方图
统计信息,供优化器在构造查询执行计划时使用。要执行直方图管理,请使用ANALYZE TABLE
语句。
用户不能直接访问column_statistics
表,因为它是数据字典
的一部分。直方图信息可使用 INFORMATION_SCHEMA.COLUMN_STATISTICS
获得,它是作为数据字典表上的视图实现的。COLUMN_STATISTICS
包含以下列:
直方图实例
1 | { |
SQL NULL
值的列值的分数。如果为0,则该列不包含NULL值。YYYY-MM-DD hh:mm:ss.uuuuuu
格式的UTC值表示。ANALYZE TABLE
语句中指定的存储桶数量时,将创建此直方图类型。ANALYZE TABLE
语句中指定的存储桶数量时,将创建此直方图类型。ANALYZE TABLE
语句中指定的桶数
。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 | INSERT INTO test_a (a, b) VALUES |
接下来生成直方图信息。
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 | +-------------+------------+-------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |
我们来看一下直方图的信息。
1 | { |
not null
声明字段。a
列有10个值,都不重复,而桶数量我们用的是5,所以生成了这个类型。ANALYZE TABLE
语句中指定的桶数
。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 | INSERT INTO test_a (a, b) VALUES |
这样我们就知道了,方案1, 会直接过滤出1行数据,然后在过滤,这样显然比方案2更好。
因为方案2会先查出2条数据,再次过滤。
这就是不同的执行路线带来的性能区别。当然了,我们这里的例子只是打个比方,实际上谓词下推
优化以后,这两个条件都是和扫描表一起执行的。
这个例子只是让你明白不同的路线选择而已。
对于join
查询来说,会有更多的选择。
以上就是今天的内容了,大家有任何疑问可以打在评论区,一起交流~
关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
部分电子书如图所示。