0%

网络安全是当前收到关注的一个问题,大部分站点都是https来实现自己的数据安全的。那么怎么样才能把自己的站点变成https站点呢?我们需要了解SSL协议。

SSL的全称是(secure sockets layer),而现在我们很多时候在使用TLS(transport layer security)。我们简单看一下它的发展过程。

SSL协议是由网景公司在1995年推出的,那么在3.0获得了一个非常大的一个发展,但是接下来,微软把它的ie浏览器捆绑windows,一起卖出以后呢。导致网景遇到了很大的一个困境,网景在把SSL协议交给IETF组织以后呢,在1999年,应微软的要求,IETF把SSL改名为TLS1.0。后面在06年、08年到18年,TLS分别发展了1.1,1.2,1.3协议。那么TLS协议,究竟是怎样保证HTTP的明文消息被加密的呢?我们可以看一下TLS的通用模型,在OSI七层模型中,应用层是HTTP协议,那么HTTP协议之下,我们的表示层,也就是SSL协议所发挥作用的这一层,它通过握手、交换密钥、告警、对称加密的方式,使HTTP层没有感知的情况下,做到了数据的安全加密。那么TLS究竟是怎样做到了数据的安全加密的呢?

我们可以看一下TLS的安全密码套件。当我们抓包,或者观察服务器端的配置时,我们可以看到类似于这张图中的这样的一个配置。

1
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256

这个安全密码的配置呢,它决定了我们的TLS协议是怎样保证明文被加密的。

这里大概有四个组成部分,第一个组成部分叫密钥交换,也就是ECDHE,这实际上是一个椭圆曲线加密算法的表达,密钥交换是为了解决浏览器和服务器之间是怎样各自独立的生成密钥,而最后生成的密钥,是相同的。接下来它们会用这个密钥去加密数据,那么在密钥交换这个过程中呢,我们需要去让各自双方去验证对方的身份,而验证身份,是需要一个算法,而这个算法叫RSA。它用于身份验证。接下来我们执行数据加密解密这样的通讯的时候呢,我们需要用到对称加密算法,而对称加密算法AES_128_GCM就是表达这样的一个对称加密算法,其中第一个部分AES它表达了是怎样一种算法,第二个部分128它表示了AES算法里支持了三种加密强度,那么我们使用了128位这样的一个加密强度。那么AES中,它有很多分组模式,其中GCM是比较新的一种分组模式,它可以提高多核CPU情况下加密和解密的一个性能。SSA256是一个摘要算法,它用来把不定长度的字符串,生成固定长度的更短的一个摘要。

那么,以上是TLS算法的一个概要介绍。之后我们再来看一下对称加密算法和非对称加密算法有什么不同。

在对称加密这样的一个场景中,是两个想要通信的人bob和alice共同持有同一把密钥,那么bob可以把原始明文的文档通过这一把密钥加密生成一个密文文档,而alice拿到这个密文文档以后呢,他用这把密钥还原,转化为原始的明文文档,而中间的任何人,如果没有持有这把密钥,即使他知道了对称加密的算法,他也没有办法把密文还原成原始文档,对称加密究竟是怎么实现的呢?

我们可以以RC4这样一个对称加密的序列算法,来看一下。

1010 密钥序列(共同持有密钥)

异或

0110 明文

等于

1100 密文

异或有一个对称的特性。

对称加密,性能非常的好,加密解密都只需要遍历一次。

而非对称加密性能就会差很多。

我们看一下非对称加密的算法,它根据一个数学原理啊,它会生成一对密钥。这一对密钥中,如果我们称其中的一个叫做公钥,那么另一个就叫做私钥,那么公钥和私钥有什么特性呢,就是同一份明文文档,如果用公钥加密,如果用公钥加密,那么只有用私钥才能解密,同样的道理,如果文档用私钥加密了,用公钥才能解密,可能大家有些疑惑。那么我们来说一下具体的场景。

比如说alice他有一对公钥和私钥,那么他就可以把他的公钥发布给大家,比如bob是其中一个人,他拿到了alice的公钥,那么这个时候的加密操作是怎么做的呢?比如bob如果想传递一份原始文档给alice,那么bob就拿到alice的公钥,对于原始文档进行加密,把密文再发送给alice,alice拿他的私钥才能进行解密,其他人用了公钥以后都没有办法解密。那么公钥和私钥还有第二种用法,就是身份验证,比如现在有一段信息,alice用他的私钥,进行了加密,然后把密文发给了bob或者任何人,只要bob拿到了alice的公钥,因为公钥本身就是公开的,那么用公钥能成功的解开这段密文,就证明这段密文确实是由alice发出的,这为我们接下来TLS的密钥交换算法提供了基本的签名保障。

以上就是对称加密和非对称加密的基本原理。

SSL证书的公信力

在之前的加密过程中,我们谈到了Alice和bob中间进行通信,但其实它们有一个前提条件,alice必须知道,bob就是bob,也就是他收到的信息,必须是bob发来的。那么这样的一个新的问题,在多方通信的过程中,必须有一个公信机构,这样的一个机构就是CA机构。接下里我们就来看看CA是怎么样颁发证书以及怎么样让证书过期的。

那么,CA就是CA机构,我们作为一个站点的维护者,我们就是一个证书的订阅人,首先我们必须去申请一个证书,为了申请这个证书,我们可能必要登记,我是谁,我属于什么组织,我想做什么。到了登记机构,通过CSR(request certificate issuance, CSR)发给CA,那么CA中心通过以后呢,它会去生成一对公钥和私钥,那么公钥会在CA的证书之内保存着呢,私钥证书订阅人拿到以后,就会把它部署到自己的web服务器中,比如说nginx。当浏览器通过第一步,访问我们的https站点的时候,那么它会去请求我们的证书,而nginx这样的web服务器,会把我们的公钥证书发给我们的浏览器,而浏览器需要去验证我们的证书是不是合法有效的,但是如果我们用lets encrypt我们会发现,这个证书只有三个月的有效期,如果通过大部分其他SSL CA这样的颁发证书,一般是一年的有效期,那么这个有效期是怎样体现的呢?那么CA中心会把过期的证书放到CRL服务器,这个服务器会把所有过期的证书形成一条链条,所以它的性能非常的差。所以它又推出了一个OCSP程序,OCSP可以就一个证书去查询它是否过期。所以浏览器是可以去直接去查询OCSP响应程序的。但OCSP性能还不是很高,所以往往,比如我们的nginx,会有一个OCSP的开关,当我们打开开关以后,会由nginx主动的去OCSP去查询,这样大量的客户端就可以直接从nginx获取到证书是否有效。

那么证书究竟是怎样组成的呢?接下来,我们看一下证书究竟有哪几种类型。第一种证书叫做域名验证DV,也就是说这个整数,它只会去验证我们域名的归属是否正确。比如我们在lets encrypt申请证书的时候会发现。只要你的域名指向的服务器是你正在申请证书的服务器,那你就可以成功的申请到证书。如果你使用其他的一些CA机构颁发的一些证书,可能会去验证你注册的那个邮箱是否正确。第二种证书叫做组织验证,OV验证证书,组织验证呢就是我们在申请证书的时候会去验证我们填写的机构企业名称是否是正确的,所以OV证书的申请往往需要几天的时间,不像DV证书,基本上实时就可以获取到了。而OV证书他的价格,也要远远高于DV证书。DV证书很多都是免费的。那么,比OV证书做了更严格的验证,就是扩展验证EV证书,因为EV证书做了更严格的验证,所以大部分浏览器会对EV证书的显示非常的友好,它会把我们申请证书时所填写的机构名称,在浏览器的地址栏中的,最左侧显示出来。而对DV或者OV证书,浏览器是没有这个优待的。

那么,我们获取到这样的证书之后,究竟是怎样生效的呢?浏览器实际上从安全角度来说对DVOV或者EV证书,它的效果是一样的。

阻塞与非阻塞

  1. 主要是指,操作系统,或者底层的c库,提供的方法,或者是一个系统调用。也就是说我们调用这个方法的时候,这个方法可能会导致我的进程进入sleep状态,为什么会进入sleep状态呢?就是当前的条件不满足的情况下,操作系统主动的把我的进程切换为另外一个进程了,在使用当前的CPU。那么这样就是一个阻塞方法。
  2. 而非阻塞方法就是我们调用该方法,永远不会因为,当我们时间片未用完时,把我们的进程主动切换掉。

同步与异步

而同步和异步则是从我们调用的方式而言,就是我们编码中写我们的业务逻辑这样的一个角度。我们可以从nginx的发展历史趋势上可以看出这一点。那么nginx目前除了官方在提供的javascript利用这样的同步写代码的方式实现非阻塞编码的效果,以及openresty基于lua语言用同步写代码的方式实现非阻塞高并发的一个效果。

接下来,我们来看一看非阻塞和阻塞以及同步异步,到底有一些什么样具体的区别。

那么,在阻塞调用中,我们以accept为例。因为绝大多数程序,在调用accept的时候,它都是在使用阻塞socket的。使用阻塞socket的时候呢,当我们调用accept方法的时候,如果说我们监听的端口所对应的accept队列,就是操作系统已经为我们做好了几个三次握手建立成功的socket,那么阻塞方法可能会立刻得到返回,而不会被阻塞。但是,如果accept队列是空的,那么操作系统就会去等待新的三次握手的连接,到达我们的内核中,我们才会去唤醒这个accept调用,这个时间往往是可控的。我们去设置这个阻塞socket最长的超时时间,那么如果没有达到的话,也可以唤醒我们的这样一个调用。所以,这里的流程中,就是会产生进程间的主动切换。而我们之前谈过,像nginx是不能容忍这样的进程间切换的。因为它并发的连接实在是太多了。

那么非阻塞调用,有什么差别呢?我们先看accept如果你使用了非阻塞套接字,使用accept调用去执行的时候呢,如果accept队列为空,它是不等待立刻返回的。但是它返回的是什么呢?其实是EAGAIN,是一个错误码。所以这个时候呢,我们的代码回收一个错误码,但这个错误码是一个特殊的错误码。需要我们的代码去处理它。如果我们再次调用accept是非阻塞的,如果accept队列不为空的话,则把成功的那一个socket建立好的套接字返回给我们的代码。所以,这里有一个很大的问题,就是由我们的代码决定当accept收到一个EAGAIN这样的错误码时,我们究竟是应该等一会,继续处理这个连接,别不sleep一下,还是先切换到其他的任务,再处理。

我这里举的是一个非常简单的accept的例子。如果涉及到我们的业务特性,比如http的复杂的子请求、主请求,等等,实际上是会导致我们的代码非常复杂。因此,非阻塞调用呢,是我们的底层实现,如果我们用异步方法去使用非阻塞调用是非常自然而然的。我们可以看一下,是怎样用异步的方式去处理非阻塞连接。

那么我这里举了一个例子。是一个反向代理的例子,那么nginx做反向代理的时候有一个特点,他会去考虑到上游服务的处理能力相对是不足的,所以,如果是一个有body的http请求,那么,nginx会先把body接收完,再去向上游服务器发起连接,那么我们看右边这段代码。我们可以看到,当我们收完header的时候,我们已经知道,接下来向谁,哪一台服务器去发起反向代理,建立连接了。但是我需要先读取body,所以它调用了这样一个方法,那么这个方法就是一个标准的异步方法。它表达当我执行完read request body之后,再去调用post_handler方法,也就是upstream_init,是我们对上游服务器建立连接的方法,所以当我们调用这样的一个异步调用的时候它其实意味着先把body收完,再调这个方法。非常的复杂。

这是标准的异步调用

1
2
3
4
rc = ngx_http_read_request_body(r, ngx_http_upsream_init);
if (rc >= NGX_HTTP_SRECIAL_RESPONSE) {
return rc;
}

这个方法执行完时调用post_handler异步方法

1
2
ngx_init_t
ngx_http_read_client_request_body(ngx_http_request_t * r, ngx_http_client_body_handler_pt post_handler)

最终读取完body后调用ngx_http_upstream_init方法

1
void ngx_http_upstream_init(ngx_http_request_t *r) {}

我们再来看一看,与此相反的同步调用的方法。比如说openresty写一段lua代码,比如说我现在要对redis建立连接,因为我们建立连接使用的也是TCP,那么TCP一样有三次握手,三次握手基于这种报文,那么我们在基于nginx openresty上也是不能使用阻塞方法的。但是如果你用异步的方式,非常复杂。同步方式呢,可以看到。

1
2
3
4
5
6
7
local cliend = redis:new()
client:set_tmeout(30000)
local ok, err = client:connect(ip, port)
if not ok then
ngx.say("failed: ", err)
return
end

我们new一个client redis以后,设置好这个连接的超时时间,我们就可以调用connect,这个connect就是一个同步调用,但是它里面走的是非阻塞代码。所以我们在写lua代码的时候,完全可以不必考虑像刚刚我们举的这个例子一样,考虑connect完成以后,再去回调另外一个方法,在另外一个方法中决定,connect是成功了还是失败了。如果成功了,我应该发消息,完全不需要这么做。我们只需要简单的connect,我收到响应值了,那么ok,如果是没有收到ok,那么我们打印一个ngx.say('failed')就可以了。因为在connect方法执行的过程中,当connect没有被满足,也就是我没有收到redis发来的ACK响应,就是成功建立连接时呢。这个connect方法不会返回,但是也不会阻塞nginx的代码,这就叫做同步调用代码。那么它使用了非阻塞的一个方式。

谈完同步异步阻塞与非阻塞以后我,我相信大家对于如何兼顾开发效率与我们的运行效率应该有了一个很好的认识。实际上,如果我们不是在极端场景下,都会去使用如openresty或者nginx的javascript模块,来使用同步编程的方式,达成我们的目的,这样的开发效率非常的高。

参考

  • GeeksforGeeks

如果所有的顶点对之间都有一条路径,那么一个有向图就是强连通的。有向图的强连通分量(strongly connected component,SCC)是一个最大强连通子图。例如,下面的图中有3个SCC。

我们已经讨论过Kosaraju的强连通分量的算法。前面讨论的算法需要对一个Graph进行两次DFS遍历。在这篇文章中,我们将讨论Tarjan算法,它只需要一次DFS遍历。

Tarjan算法基于以下事实:

  1. DFS搜索会产生一个DFS树/森林;
  2. 强连通分量构成DFS树的子树;
  3. 如果我们能找到这种子树的头,就可以打印/存储该子树的所有结点(包括头),这将是一个SCC;
  4. 从一个SCC到另一个SCC没有回溯边(back edge)(可以有交叉边,但在处理图的时候不会用到交叉边)。

为了找到一个SCC的头部,我们要计算disc和low数组(就像处理衔接点、桥、双连接组件一样)。如前几篇文章所讨论的,low[u]表示从以u为根的子树中可以到达的最早被访问的顶点(发现时间最短的顶点),如果disc[u]=low[u],则节点u为头部。

下图是该方法的说明:

强连通分量只与有向图有关,但Disc和Low值与有向图和无向图都有关。

在上图中,我们展示了一个图形和它的一颗DFS树(在同一个图形上可以有不同的DFS树,取决于边被遍历的顺序)。在DFS树中,连续的箭头是树的边,虚线的箭头是back edge

图中每个节点的disc和low的值显示为disc/low

Disc: 是指在DFS遍历时,节点第1次被访问的时间。这是在DFS遍历时,一个节点第一次被访问的时间。对于DFS树中的节点A,B,C,...,J,Disc值为1,2,3,…,10。

Low: 在DFS树中,树边带我们向前走,从祖先节点到它的一个子孙节点。例如,从节点C开始,树边可以带我们到节点G、节点I等。回溯边(back edge)带我们往后走,从一个子孙节点到它的一个祖先节点,例如,从节点G出发,back edge带我们到E或C。如果我们把树边和回溯边(back edge)一起看,那么我们可以看到,如果我们从一个节点开始遍历,我们可能会通过树边向下走,然后通过回溯边向上走。例如,从节点E开始,我们可以向下走到I或J,然后向上走到F。一个节点的Low值告诉了我们通过该节点的子树所能到达的最顶层祖先(最小可能的disc值)。所以对于任何一个节点,无论如何Low值都等于它的Disc值(一个节点是它自己的祖先)。然后我们查看它的子树,看看是否有任何节点可以带我们找到它的任何一个祖先。如果在子树中有多条回溯边可以带我们到不同的祖先,那么我们就选取Disc值最小的那条(即最上面的那条)。如果我们看一下节点F,它有两个子树。有节点G的子树,把我们带到E和C,另一个子树只把我们带回F。这里最顶层的祖先是F可以到达的C,所以F的Low值是3(C的Disc值)。

根据上面的讨论,应该很清楚,B、C、D的Low值是1(因为A是B、C、D可以到达的最上面的节点)。同理,E、F、G的Low值为3,H、I、J的Low值为6。

对于任何节点u,当DFS开始时,Low将会被设置为其Disc 1st。

然后对它的每一个子节点v依次进行DFS,u的Low值可以在两种情况下改变:

  • Case1(Tree Edge):如果节点v还没有被访问,那么在v的DFS完成后,low[u] = min(low[u], low[v])
  • Case2(Back Edge):当后代v已经被访问,那么low[u] = min(low[u], disc[v]);

在第二种情况下,我们是否可以用low[v]代替DIsc[v]?答案是NO。如果你能想到为什么答案是NO,那么你大概明白Low和Disc的概念。(因为是有向图,low[v]是v能访问到的最小节点,u不一定能访问到,u能访问到v,说明disc[v]一定是可以取到的)

相同的Low和Disc值有助于解决其他图的问题,如衔接点、桥和双连接分量。

为了跟踪以头部为根的子树,我们可以使用栈(在访问时不断推送节点)。当发现一个头部节点时,从堆栈中弹出所有节点,直到从栈中得到头部。

为了确保不考虑交叉边,当我们到达一个已经被访问过的节点时,只有当栈中存在被访问过的节点时,我们才应该对该节点进行处理,否则忽略该节点。

实现:写一个Tarjan算法找到有向图的所有连通分量并打印出来

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
class Graph:
def __init__(self, vertices):
self.V = vertices
self.neigh = defaultdict(list)
self.Time = 0

def addEdge(self, x, y):
self.neigh[x].append(y)

def scc(self):
disc = [-1] * self.V
low = [-1] * self.V
visited = [False] * self.V
stack = []
for i in range(self.V):
if disc[i] == -1:
self.scc_util(i, low, disc, visited, stack)

def scc_util(self, u, low, disc, visited, stack):
disc[u] = self.Time
low[u] = self.Time
self.Time += 1
visited[u] = True
stack.append(u)
for v in self.neigh[u]:
if disc[v] == -1:
self.scc_util(v, low, disc, visited, stack)
low[u] = min(low[u], low[v])
elif visited[v]:
low[u] = min(low[u], disc[v])
w = -1
if disc[u] == low[u]:
while w != u:
w = stack.pop()
print(w, end=' ')
visited[w] = False
print()


def main():
g1 = Graph(5)
g1.addEdge(1, 0)
g1.addEdge(0, 2)
g1.addEdge(2, 1)
g1.addEdge(0, 3)
g1.addEdge(3, 4)
print("SCC in first graph")
g1.scc()

g2 = Graph(4)
g2.addEdge(0, 1)
g2.addEdge(1, 2)
g2.addEdge(2, 3)
print("SCC in second graph")
g2.scc()

g3 = Graph(7)
g3.addEdge(0, 1)
g3.addEdge(1, 2)
g3.addEdge(2, 0)
g3.addEdge(1, 3)
g3.addEdge(1, 4)
g3.addEdge(1, 6)
g3.addEdge(3, 5)
g3.addEdge(4, 5)
print("SCC in third graph")
g3.scc()

g4 = Graph(11)
g4.addEdge(0, 1)
g4.addEdge(0, 3)
g4.addEdge(1, 2)
g4.addEdge(1, 4)
g4.addEdge(2, 0)
g4.addEdge(2, 6)
g4.addEdge(3, 2)
g4.addEdge(4, 5)
g4.addEdge(4, 6)
g4.addEdge(5, 6)
g4.addEdge(5, 7)
g4.addEdge(5, 7)
g4.addEdge(5, 8)
g4.addEdge(5, 9)
g4.addEdge(6, 4)
g4.addEdge(7, 9)
g4.addEdge(8, 9)
g4.addEdge(9, 8)
print("SCC in forth graph")
g4.scc()

g5 = Graph(5)
g5.addEdge(0, 1)
g5.addEdge(1, 2)
g5.addEdge(2, 3)
g5.addEdge(2, 4)
g5.addEdge(3, 0)
g5.addEdge(4, 2)
print("SCC in fifth graph")
g5.scc()


if __name__ == '__main__':
main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Output
SCC in first graph
4
3
1 2 0
SCC in second graph
3
2
1
0
SCC in third graph
5
3
4
6
2 1 0
SCC in forth graph
8 9
7
5 4 6
3 2 1 0
10
SCC in fifth graph
4 3 2 1 0

时间复杂度:算法主要使用DFS,邻接表表示的图的DFS算法是O(V+E)复杂度的。

1933年,富兰克林·罗斯福首次总统就职演说

  • 因此,首先,请允许我表明自己的坚定信念:我们唯一值得恐惧的就是恐惧本身——这是一种难以名状、盲目冲动、毫无缘由的恐惧,可以使人们转退为进所需的努力全都丧失效力。

名言:

  • 我们必须成为民主制度的伟大兵工厂。对我们来讲,这是如同战争本身一样严重的紧急情况。我们必须以同样的决心,同样的紧迫感,同样的爱国主义和牺牲精神来致力于我们的任务,就好像我们处在战争中表现的那样。
  • 生活好比橄榄球比赛,原则就是:奋力冲向底线。
  • 把法西斯瘟疫隔离在自由世界之外。
  • 人生就像打橄榄球一样,不能犯规,也不要闪避球,而应向底线冲过去。
  • 人的仁慈从未削弱自由者的毅力或软化他们。一个国家不必变得残酷就可以坚强。
  • 不能以牺牲他人自由为代价来获得持久和平。

今天我要跟你聊聊MySQL的锁。数据库锁设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。

根据加锁的范围,MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类。今天这篇文章,我会和你分享全局锁和表级锁。而关于行锁的内容,我会留着在下一篇文章中再和你详细介绍。

这里需要说明的是,锁的设计比较复杂,这两篇文章不会涉及锁的具体实现细节,主要介绍的是碰到锁时的现象和其背后的原理。

全局锁

顾名思义,全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法,flush tables with read lock(FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景,是做全局逻辑备份。也就是把整库每个表都select出来存成文本。

以前有一种做法,是通过FTWRL确保不会有其他线程对数据库做更新,然后对整个库做备份。注意,在备份过程中整个库完全处于只读状态。

但是让整库只读,听上去就很危险:

  • 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
  • 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。

看来加全局锁不大好。但是细想一下,备份为什么要加锁呢?我们来看一下不加锁会有什么问题。

假设你现在要维护“极客时间”的购买系统,关注的是用户账户余额表和用户课程表。

现在发起一个逻辑备份。假设逻辑备份期间,有一个用户,他购买了一个课程,业务逻辑里就要扣掉他的余额,然后往已购课程里面加上一门课。

如果时间顺序上是先备份账户余额表(u_account),然后哦用户购买,然后备份用户课程表(u_course),会怎么样呢?你可以看一下这个图:

可以看到,这个备份结果里,用户A的数据状态是“账户余额没扣,但是用户课程表里面已经多了一门课”。如果后面用这个备份来恢复数据的话,用户A就发现,自己赚了。

作为用户可别觉得这样可真好啊,你可以试想一下:如果备份表的顺序反过来,先备份用户课程表再备份账户余额表,有可能会出现什么结果?

也就是说,不加锁的话,备份系统备份得到的库不是一个逻辑时间点,这个视图是逻辑不一致的。

说到视图你肯定想起来了,我们在前面讲事务隔离的时候,其实是有一个方法能够拿到一致性视图的。

是的,就是在可重复读隔离级别下开启一个事务。

官方自带的逻辑备份工具是mysqldump。当mysqldump使用参数-single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。

你一定在疑惑,有了这个功能,为什么还需要FTWRL呢?一致性读是好,但前提是引擎要支持这个隔离级别。比如,对于MyISAM这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用FTWRL命令了。

所以,single-transaction方法只适用于所有的表使用事务引擎的库。如果有的表使用了不支持事务的引擎,那么备份就只能通过FTWRL方法。这往往是DBA要求业务开发人员使用InnoDB替代MyISAM的原因之一。

你也许会问,既然要全表只读,为什么不使用set global readonly=true的方式呢?确实readonly方式也可以让全库进入只读状态,但我还是建议你用FTWRL方式,主要有两个原因:

  • 一是,在有些系统中,readonly的值会被用来做其他逻辑,比如判断一个库是主库还是备库。因此,修改global变量的影响面更大,我不建议你使用。
  • 二是,在异常处理机制上有差异。如果执行FTWRL命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为readonly之后,如果客户端发生异常,则数据库就会一直保持readonly,这样会导致整个库长时间处于不可写状态,风险较高。

业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论是哪种方法,一个库被全局锁上以后,你要对里面的任何一个表做加字段操作,都是会被锁住的。

但是,即使没有被全局锁住,加字段也不是就能一帆风顺的,因为你还会碰到接下来我们要介绍的表级锁。

表级锁

MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁的语法是lock tables .. read/write。与FTWRL类似,可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables语法除了会限制别的线程读写外,也限定了本线程接下来的操作对象。

举个例子,如果在某个线程A中执行lock tables t1 read, t2 write;这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。连写t1都不允许,自然也不能访问其他表。(可能还会本线程还是可以对表t1写。)

在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于InnoDB这种支持行锁的引擎,一般不使用lock tables命令来控制并发,毕竟锁住整个表的影响面还是太大。

另一类表级的锁是MDL(metadata lock)。MDL不需要显示使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

因此,在MySQL 5.5 版本中引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表结构做变更操作的时候,加MDL写锁。

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

虽然MDL锁是系统默认会加的,但却是你不能忽略的一个机制。比如下面这个例子,我经常看到有人掉到这个坑里:给一个小表加个字段,导致整个库挂了。

你肯定知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,你肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题。我们来看一下下面的操作序列,假设表t是一个小表。

备注:这里的实验环境是MySQL 5.6

我们可以看到session A先启动,这时候会对表t加一个MDL读锁。由于session B需要的也是MDL读锁,因此可以正常执行。

之后session C会被blocked,是因为session A的MDL读锁还没有释放,而session C需要MDL写锁,因此只能被阻塞。

如果只有session C自己被阻塞还没什么关系,但是之后所有要在表t上新申请MDL读锁的请求也会被session C阻塞。前面我们说了,所有对表的增删改查操作都需要先申请MDL读锁,就都被锁住,等于这个表现在完全不可读写了。

如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新的session再请求的话,这个库的变成很快就会爆满。

你现在应该知道了,事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。

基于上面的分析,我们来讨论一个问题,如何安全地给小表加字段?

首先我们要解决长事务,事务不提交,就会一直占着MDL锁。在MySQL的information_schema库的innodb_trx表中,你可以查到当前执行中的事务。如果你要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。

但考虑一下这个场景。如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你改怎么做呢?

这时候kill可能未必管用,因为新的请求马上就来了。比较理想的机制是,在alter table语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员或者DBA在通过重试命令重复这个过程。

MariaDB已经合并了AliSQL的这个功能,所以这两个开源分支目前都支持DDL NOWAIT/WAIT n 这个语法。

1
2
ALTER TABLE tbl_name NOWAIT add column ... 
ALTER TABLE tbl_name WAIT N add column ...

小结

今天,我跟你介绍了MySQL的全局锁和表级锁。

全局锁主要用在逻辑备份过程中。对于全部是InnoDB引擎的库,我建议你选择使用mysqldump配合上 -single-transaction参数,利用InnoDB提供的一致性视图,对应用会更友好。如果没有InnoDB引擎,就只能FTWRL了。

表锁一般是在数据库引擎不支持行锁的时候才会被用到。如果你发现你的应用程序里有lock tables这样的语句,你需要追查一下,比较可能的情况是:

  • 要么是你的系统现在还在用MyISAM这类不支持事务的引擎,那要安排升级换引擎;
  • 要么是你的引擎升级了,但是代码还没升级。我见过这样的情况,最后业务开发就是把lock tables和unlock tables改成begin和commit,问题就解决了。

MDL会直到事务提交才释放,在做表结构变更的时候,你一定要小心不要导致锁住线上查询和更新。

最后,我给你留一个问题吧。备份一般都会在备库上执行,你再用--single-tramnaction方法给备库做逻辑备份的过程中,如果主库上的一个小表做了一个DDL,比如给一个表加了一个列。这时候,从备库上会看到什么现象呢?

假设这个DDL是针对表t1的,这里我把备份过程中几个关键的语句列出来:

1
2
3
4
5
6
7
8
9
10
11
12
Q1:  SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Q2: START TRANSACTION WITH CONSISTENT SNAPSHOT;
/* other tables */
Q3: SAVEPOINT SP;
/* 时刻1 */
Q4: show create table `t1`;
/* 时刻2 */
Q5: SELECT * FROM `t1`;
/* 时刻3 */
Q6: ROLLBACK TO SAVEDPOINT sp;
/* 时刻4 */
/* other tables */

在备份开始的时候,为了确保RR(可重复读)隔离级别,再设置一次RR隔离级别(Q1);

启动事务,这里用WITH CONSISTENT SNAPSHOT确保这个语句执行完就可以得到一个一致性视图(Q2);

设置一个保存点,这个很重要(Q3);

show create 是为了拿到表结构(Q4),然后正式导数据(Q5),回滚到SAVEPOINT sp,在这里的作用是释放t1的MDL锁(Q6)。当然这部分属于"超纲"。

DDL从主库传过来的时间按照效果不同,我打了四个时刻。题目设定为小表,我们假定到达后,如果开始执行,则很快就能完成。

参考答案如下:

  1. 如果在Q4语句执行之前到达,现象:没有影响,备份拿到的是DDL后的表结构。
  2. 如果在”时刻2“到达,则表结构被改过,Q5执行的时候,报Table definition has changed, please retry transaction,现象:mysqldump终止;
  3. 如果在”时刻2“和”时刻3“之间到达,mysqldump占着t1的MDL读锁,binlog被阻塞,现象:主从延迟,直到Q6执行完成。
  4. 从”时刻4“开始,mysqldump释放了MDL读锁,现象:没有影响,备份拿到的是DDL前的表结构。

覆盖索引

如果执行的语句是select ID from T where k between 3 and 5,这时只需要查ID的值,而ID的值已经在k索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引k已经“覆盖了”我们的查询需求,我们称为覆盖索引。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

需要注意的是,在引擎内部使用覆盖索引在索引k上其实读了三个记录,R3~R5(对应的索引k上的记录项),但是对于MySQL的Server层来说,它就是找引擎拿到了两条记录,因此MySQL认为扫描行数是2。

备注:关于如何查看扫描行数的问题,我将会在第16篇文章《如何正确地显示随机消息?》中,和你详细讨论。

基于上面覆盖索引的说明,我们来讨论一个问题:在一个市民信息表上,是否有必要将身份证号和名字建立联合索引?

假设这个市民表的定义是这样的:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `tuser` (
`id` int(11) NOT NULL,
`id_card` varchar(32) DEFAULT NULL,
`name` varchar(32) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
`ismale` tinyint(1) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `id_card` (`id_card`),
KEY `name_age` (`name`, `age`)
) ENGINE= InnoDB

我们知道,身份证号是市民的唯一标识。也就是说,如果有根据身份证号查询市民信息的需求,我们只要在身份证号字段上建立索引就够了。而再建立一个(身份证号、姓名)的联合索引,是不是浪费空间?

如果现在有一个高频请求是,要根据市民的身份证号查询他的姓名,这个联合索引就有意义了。它可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。

当然,索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。这正是业务DBA,或者称为业务数据架构师的工作。

最左前缀原则

看到这里你一定有一个疑问,如果为每一种查询都设计一个索引,索引是不是太多了。如果我现在要按照市民的身份证号去查他的家庭地址呢?虽然这个查询需求在业务中的出现的概率不高,但总不能让它走全表扫描吧?反过来说,单独为一个不频繁的请求创建一个(身份证号、地址)的索引又感觉有点浪费。应该怎么做呢?

这里,我先和你说结论吧。B+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。

为了直观地说明这个概念,我们用(name,age)这个联合索引来分析。

可以看到,索引项式按照索引定义里面出现的字段顺序排序的。

当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到ID4,然后向后遍历得到所有需要的结果。

如果你要查的是所有名字第一个字是“张”的人,你的SQL语句的条件是“where name like ‘张%'”。这时,你也能够用上这个索引,查找到第一个符合条件的记录是ID3,然后向后遍历,直到不满足条件为止。

可以看到,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。

基于上面对最左索引的说明,我们来讨论一个问题:在建立联合索引的时候,如何安排索引内的字段顺序。

这里我们评估的标准是,索引的复用能力。因为可以支持最左前缀,所以当已经有了(a, b)这个联合索引之后,一般就不需要单独在a上建立索引了。因此,第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑的

所以现在你知道了,这段开头的问题里,我们要为高频请求创建(身份证号,姓名)这个联合索引,并用这个索引支持“根据身份证号查询地址”的需求。

那么,如果既有联合查询,又有基于a、b各自的查询呢?查询条件里面只有b的语句,是无法使用(a,b)这个联合索引的,这时候你不得不维护另外一个索引,也就是说你需要同时维护(a, b)、(b)两个索引。

这时候,我们需要考虑的原则就是空间了。比如上面这个市民表的情况,name字段是比age字段大的,那我就建议你创建一个(name,age)的联合索引和一个(age)的单字段索引。

索引下推

上一段我们说到满足最左前缀规则的时候,最左前缀可以用于在索引中定位记录。这是,你可能要问,那些不符合最左前缀的部分,会怎么样呢?

我们还是以市民表的联合索引(name,age)为例。如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是10岁的所有男孩”。那么,SQL语句是这么写的:

1
mysql>select * from tuser where name like '张%' and age=10 and ismale=1;

你已经知道了前缀索引规则,所以这个语句在搜索索引树的时候,只能用”张“,找到第一个满足条件的记录ID3。当然,这还不错,总比全表扫描好。

然后呢?

当然是判断其他条件是否满足。

在MySQL 5.6之前,只能从ID3开始一个个回表。到主键索引上找出数据行,再对比字段值。

而MySQL 5.6引入的索引下推优化(index condition pushdown),可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

图3和图4,是这两个过程的执行流程图。

在图3和图4这两个图里面,每个虚线箭头表示回表一次。

图3中,在(name, age)索引里面我特意去掉了age的值,这个过程InnoDB并不会去看age的值,只是按顺序把”name第一个字是’张‘“的记录一条条取出来回表。因此,需要回表4次。

图4和图3的区别是,InnoDB在(name,age)索引内部就判断了age是否等于10,对于不等于10的记录,直接判断并跳过。在我们这个例子中,只需要对ID4、ID5这两条记录回表取数据判断,就只需要回表2次。

小结

今天这篇文章,我和你继续讨论了数据库索引的概念,包括了覆盖索引、前缀索引、索引下推。你可以看到,在满足语句需求的情况下,尽量少地访问资源是数据库设计的重要原则之一。我们在使用数据库的时候,尤其是在设计表结构时,也要以减少资源消耗作为目标。

接下来我们给你留一个问题吧。

实际上主键索引也是可以使用多个字段的。DBA小吕在入职新公司的时候,就发现自己接手维护的库里面,有这么一个表,表结构定义类似这样的:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `geek` (
`a` int(11) NOT NULL,
`b` int(11) NOT NULL,
`c` int(11) NOT NULL,
`d` int(11) NOT NULL,
PRIMARY KEY (`a`, `b`),
KEY `c` (`c`),
KEY `ca` (`c`, `a`),
KEY `cb` (`c`, `b`)
) ENGINE=InnoDB;

公司的同事告诉他说,由于历史原因,这个表需要a、b做联合主键,这个小吕理解了。

但是,学过本章内容的小吕纳闷了,既然主键包含了a、b两个字段,那意味着单独在字段c上创建一个索引,就已经包含了三个字段了呀,为什么要创建"ca", "cb"这两个索引呢?

同事告诉他,是因为他们的业务里面有这样的两种语句:

1
2
select * from geek where c=N order by a limit 1;
select * from geek where c=N order by b limit 1;

我给你的问题是,这位同事的解释对吗,为了这两个查询模式,这两个索引是否都是必须的?为什么呢?

"cb"是必须的,"ca"不是必须的。因为主键就是"ab",在"c"这个索引上,已经是按照a的顺序排序的了。其实在MySQL的实现中,"ca"的主键部分只有b,所以和c索引是一模一样的,而"cb"要保留,因为"c"这个索引上没有按照"b"排序。

nginx是一个多进程程序,那么不同的worker进程之间,如果需要共享数据,那么只能通过共享内存。那么下面我们来看一看,nginx中的共享内存是怎么使用的。

nginx的进程间的通讯方式,主要有两种,第一种是信号,那么之前我们在说如何管理nginx的过程中,已经比较详细的介绍过了,那么如果需要做数据的同步呢?那么只能通过共享内存,所谓共享内存,也就是说我们打开了一块内存,比如说10M,那么一整块0-10M之间,多个worker进程之间,可以同时的访问它,包括读取和写入。那么为了使用好这样一块共享内存,就会引入另外两个问题。第一个问题呢,就是锁,因为多个worker进程同时操作一块内存,一定会出现竞争关系,所以我们需要加锁,在nginx的锁中,在早期它还有基于信号量的锁,信号量是一种linux里比较久远的进程同步方式,它会导致你的进程进入了休眠状态,也就是发生了主动切换,而现在大多数操作系统版本中,nginx所使用的锁,都是自旋锁,而不会基于信号量,自旋锁,也就是说,当这个锁的条件没有满足,比如说这块内存现在被1号worker进程所使用,那么2号worker进程需要去获取锁的时候,只要1号进程没有释放锁,2号进程会一直在不停地去请求这个把锁,就好像,如果是基于信号量的早期的nginx锁,那么假设这把锁锁住了一扇门,如果worker进程1已经拿到了这把锁进到了屋里,那么worker进程2试图去拿锁,敲门,发现里面已经有人了,那么worker进程2就会就地休息,等待worker进程1从门里出来以后,通知它。而自选锁不一样,worker2进程2发现门里已经有worker进程1了,它就一直的持续的在敲门,所以,使用自旋锁,要求所有的nginx模块必须快速的使用共享内存,也就是快速的取得锁以后,快速的释放锁。一旦出现有第三方模块不遵守这样的规则,就可能导致出现死锁,或者说性能下降的问题。那么,有了这么一块共享内存,会引入第2个问题,因为一整块共享内存,往往是给许多对象同时使用的,如果我们在模块中手动去编写分配,把这些内存给到不同的对象,是非常繁琐的,所以,这个时候,我们使用了slab内存管理器。这个接下来我们会再说,那么nginx哪些模块使用了共享内存呢?我对官方的常用的nginx模块使用共享内存做了一个总结。那么使用共享内存,主要使用这两种数据结构,第一个是红黑树,比如我们想做限速或者流控等等场景时,我们是不同容忍在内存中做的,否则一个worker进程,对某一个用户,触发了流控,而其他worker进程还不知道,所以我们只能在共享内存中做,比如说,limit connection,比如说limit request(ngx_stream_limit_req_module),还有所有的http cache,做反向代理时用的,还有ssl,那么红黑树有一个特点,就是它的增删改查特别的快,当然也可以做遍历,所以这些模块都有一个特点,我需要做快速的插入和删除,比如我现在发现了一个客户端,我对它限速,那么限速如果达到了,我需要把这个客户端从我的限速数据结构容器中移出,都需要非常的快速。第二个常用的数据结构是单链表,也就是说我只要这些需要共享的元素都串起来就可以了。Ngx_http_upstream_zone_module或者Ngx_stream_upstream_zone_module,然后我们再来一个稍微复杂的例子,也就是ngx_http_lua_api,这个模块,实际上是openresty的核心模块,那么openresty在这个模块中定义了一个sdk,这个sdk叫lua_shared_dict,当这个指令出现的时候,它会分配一块共享内存,比如这个我们指定了10m,m就是我们的空间大小。那么这块共享内存会有一个名称,叫做dogs。接下来,我们在lua代码中,比如这一段content_by_lua_blocks,这对应这我们nginx收到了set这个url的时候需要做一些什么样的事情,我们首先从dogs共享内存中取出,然后设置了一个key value,然后向客户端返回,我已存储。然后在get请求中,我们把刚才存下的值取出来,返回给用户。那么在这一段代码中呢,我们同时使用了刚刚我们介绍中红黑树,以及链表。那么,这个lua_shared_dict dogs10m了。中间呢,使用红黑树来保存每一个key value,红黑树中的每一个节点就是它的key,它的value就是8,那么为什么我还需要一个链表呢,是因为这个10m是有限的,但我们的lua代码,涉及到了我们的应用业务代码,应用业务代码,很容易就超过了10m的限制,当我们超过10m的限制了呢,我们有很多种处理办法,比如让它写入失败,但是lua_shared_dict采用了另外一种处理方式,就是它用lru淘汰。也就是最早不用的节点,会被最早淘汰,当已经达到10m的最大值时。所以,这个lua_shared_dict中,它对共享内存的使用,同时满足了红黑树和链表。

共享内存是nginx跨多个worker通讯的最有效的手段,只要我们需要让一段业务逻辑,在多个worker进程中,同时生效,比如许多在集群的流控上,那么必须使用共享内存,而不能在每一个worker内存中去操作。

参考

  • 链接

对于支持继承的编程语言来说,其方法(属性)可能定义在当前类,也可能来自于基类,所以在方法调用时就需要对当前类和基类进行搜索以确定方法所在的位置。而搜索的顺序就是所谓的「方法解析顺序」(Method Resolution Order,或MRO)。对于只支持单继承的语言来说,MRO 一般比较简单;而对于 Python 这种支持多继承的语言来说,MRO 就复杂很多。

先看一个「菱形继承」的例子:

MySQL里有很多自增的id,每个自增id都是定义了初始值,然后不停地往上加步长。虽然自然数是没有上限的,但是在计算机里,只要定义了表示这个数的字节长度,那它就有上限。比如,无符号整型(unsigned int)是4个字节,上限就是2^32-1。

既然自增id有上限,我们就来看看MySQL里面的几种自增id,一起分析一下它们的值达到上限之后,会出现什么情况。

表定义自增值id

说到自增id,你第一个想到的应该就是表结构定义里的自增字段,也就是我在第39篇文章《自增主键为什么不是连续的?》和你介绍过的自增主键id。

表定义的自增值达到上限后的逻辑是:再申请下一个id时,得到的值保持不变。

我们可以通过下面这个语句序列验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
create table t(id int unsigned auto_increment primary key) auto_increment=4294967295;
insert into t values(null);
//成功插入一行 4294967295
show create table t;
/* CREATE TABLE `t` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4294967295;
*/

insert into t values(null);
//Duplicate entry '4294967295' for key 'PRIMARY'

可以看到,第一个insert语句插入数据成功后,这个表的AUTO_INCREMENT没有改变(还是4294967295),就导致了第二个insert语句又拿到相同的自增id值,再试图执行插入语句,报主键冲突错误。

2^32-1(4294967295)不是一个特别大的数,对于一个频繁插入删除数据的表来说,是可能会被用完的。因此在建表的时候你需要考察你的表是否有可能达到这个上限,如果有可能,就应该创建成8个字节的bigint unsigned。

InnoDB系统自增row_id

如果你创建的InnoDB表没有指定主键,那么InnoDB会给你创建一个不可见的,长度为6个字节的row_id。InnoDB维护了一个全局的dict_sys.row_id值,所有无主键的InnoDB表,每插入一行数据,都将当前的dict_sys.row_id值作为要插入数据的row_id,然后把dict_sys.row_id的值加1。

实际上,在代码实现时row_id是一个长度为8字节的无符号长整型(bigint unsigned)。但是,InnoDB在设计时,给row_id留的只是6个字节的长度,这样写到数据表中时只放了最后6个字节,所以row_id能写到数据表中的值,就有两个特征:

  1. row_id写入表中额值范围,是从0到2^48-1;
  2. 当dict_sys.row_id=2^48时,如果再有插入数据的行为要来申请row_id,拿到以后再取最后6个字节的话就是0。

也就是说,写入表的row_id是从0开始到2^48-1。达到上限后,下一个值就是0,然后继续循环。

当然,2^48-1这个值本身已经很大了,但是如果一个MySQL实例跑得足够久的话,还是可能达到这个上限的。在InnoDB逻辑里,申请到row_id=N后,就将这行数据写入表中;如果表中已经存在row_id=N的行,新写入的行就会覆盖原有的行。

要验证这个结论的话,你可以通过gdb修改系统的自增row_id来实现。注意,用gdb改变量这个操作是为了便于我们复现问题,只能在测试环境使用。

可以看到,在我用gdb将dict_sys.row_id设置为2^48之后,再插入的a=2的行会出现在表t的第一行,因为这个值的row_id=0。之后再插入的a=3的行,由于row_id=1,就覆盖了之前a=1的行,因为a=1这一行的row_id也是1。

从这个角度看,我们还是应该在InnoDB表中主动创建自增主键。因为,表自增id到达上限后,再插入数据时报主键冲突错误,是更能被接受的。

毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。

Xid

在第15篇文章《答疑文章(一):日志和索引相关问题》中,我和你介绍redo log和binlog相配合的时候,提到了它们有一个共同的字段叫做Xid。它在MySQL中是用来对应事务的。

那么,Xid在MySQL内部是怎么生成的呢?

MySQL内部维护了一个全局变量global_query_id,每次执行语句的时候将它赋值给Query_id,然后给这个变量加1。如果当前语句是这个事务执行的第一条语句,那么MySQL还会同时把Query_id赋值给这个事务的Xid。

而global_query_id是一个纯内存变量,重启之后就清零了。所以你就知道了,在同一个数据库实例中,不同事务的Xid也是有可能相同的。

但是MySQL重启之后会重新生成新的binlog文件,这就保证了,同一个binlog文件里,Xid一定是唯一的。

虽然MySQL重启不会导致同一份binlog里面出现两个相同的Xid,但是如果global_query_id达到上限后,就会继续从0开始计数。从理论上讲,还是会出现同一个binlog里面出现相同Xid的场景。

因为global_query_id定义的长度是8个字节,这个自增值的上限是2^64-1。要出现这种情况,必须是下面这样的过程:

  1. 执行一个事务,假设Xid是A;
  2. 接下来执行2^64次查询语句,让global_query_Id回到A;
  3. 再启动一个事务,这个事务的Xid也是A。

不过,2^64这个值太大了,大到你可以认为这个可能性只会存在于理论上。

Innodb trx_id

Xid和InnoDB的trx_Id是两个容易混淆的概念。

Xid是由server层维护的。InnoDB内部使用Xid,就是为了能够在InnoDB事务和server之间做关联。但是,InnoDB自己的trx_id,是另外维护的。

其实,你应该非常熟悉这个trx_id。它就是我们在第8篇文章《事务到底是隔离的还是不隔离的?》讲事务可见性时,用到的事务id(transaction id)。

InnoDB内部维护了一个max_trx_id全局变量,每次需要申请一个新的trx_id时,就获得max_trx_id的当前值,然后将max_trx_id加1。

InnoDB数据可见性的核心思想是:每一行数据都记录了更新它的trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的trx_id做对比。

对于正在执行的事务,你可以从information_schema.innodb_trx表中看到事务的trx_id。

我在上一篇文章的末尾留给你的思考题,是关于从innodb_trx表里面查到的trx_id的。现在,我们一起来看看一个事务现场:

session B里,我从innodb_trx表里查出的这两个字段,trx_id和trx_mysql_thread_id,第二个字段trx_mysql_thread_id就是线程id。显示线程id,是为了说明这两次查询看到的事务对应的线程id都是5,也就是session A所在的线程。

可以看到,T2时刻显示的trx_id是一个很大的数;T4时刻显示的trx_id是1289,看上去是一个比较正常的数字。这是什么原因呢?

实际上,在T1时刻,session A还没有涉及到更新,是一个只读事务。而对于只读事务,InnoDB并不会分配trx_id。也就是说:

  1. 在T1时刻,trx_id的值其实就是0。而这个很大的数,只是显示用的。一会儿我会再和你说说这个数据的生成逻辑。
  2. 直到session A在T3时刻执行insert语句的时候,InnoDB才真正分配了trx_id。所以,T4时刻,session B查到的这个trx_id的值就是1289。

需要注意的是,除了显而易见的修改类语句外,如果select语句后面加上for update,这个事务也不是只读事务。

在上一篇文章的评论区,有同学提出,实验的时候发现不止加1。这是因为:

  1. update和delete语句除了事务本身,还涉及到标记删除旧数据,也就是要把数据放到purge队列里等待后续的物理删除,这个操作也会把max_trx_id+1,因此在一个事务中至少加2;
  2. 而InnoDB的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx_id值并不是按照加1递增的。

那么,T2时刻查到的这个很大的数字是怎么来的呢?

其实,这个数字是每次查询的时候由系统临时计算出来的。它的算法是:把当前事务的trx变量的指针地址转成整数,再加上2^48。使用这个算法,就可以保证以下两点:

  1. 因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在innodb_trx还是在innodb_locks表里,同一个只读事务查出来的trx_id就会是一样的。
  2. 如果有并行的多个只读事务,每个事务的trx变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的trx_id就是不同的。

那么,为什么还要再加上2^48呢?

在显示值里面加上2^48,目的是保证只读事务显示的trx_id值比较大,正常情况下就会区别于读写事务的id。但是,trx_id跟row_id的逻辑类似,定义长度也是8个字节。因此,在理论上还是可能出现一个读写事务与一个只读事务显示的trx_id相同的情况。不过,这个概率很低,并且也没有什么实质的危害,可以不管它。

另一个问题是,只读事务不分配trx_id,有什么好处呢?

  • 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小。因为正在运行的只读事务,是不影响数据的可见性判断的,所以在创建事务的一致性视图时,InnoDB就只需要拷贝读写事务的trx_id;
  • 另一个好处是,可以减少trx_id的申请次数。在InnoDB里,即使你只是执行一个普通的select语句,在执行过程中,也是要对应一个只读事务的。所以只读事务优化后,普通的查询语句不需要申请trx_id,就大大减少了并发事务申请trx_id时的锁冲突。

由于只读事务不分配trx_id,一个自然而然的结果就是trx_id的增加速度变慢了。

但是,max_trx_id会持久化存储,重启也不会重置为0,那么从理论上讲,只要一个MySQL服务跑得足够久,就可能出现max_trx_id达到2^48-1的上限,然后从0开始的情况。

当达到这个状态后,MySQL就会持续出现一个脏读的bug,我们来复现一下这个bug。

首先我们需要把当前max_trx_id先修改成2^48-1。注意:这个case里使用的是可重复读隔离级别。具体的操作流程如下:

由于我们已经把系统的max_trx_id设置成了2^48,所以在session A启动的事务TA的低水位就是2^48-1。

在T2时刻,session B执行第一条update语句的事务id就是2^48-1,而第二条update语句的事务id就是0了,这条update语句执行后生成的数据版本上的trx_id就是0。

在T3时刻,session A执行select语句的时候,判断可见性发现,c=3这个数据版本的trx_id,小于事务TA的低水位,因此认为这个数据可见。

但,这是个脏读。

由于低水位值会持续增加,而事务id从0开始计数,就导致了系统在这个时刻之后,所有的查询都会出现脏读的。

并且,MySQL重启时max_trx_id也不会清0,也就是说重启MySQL,这个bug仍然存在。

那么,这个bug也是只存在于理论上吗?

假设一个MySQL实例的TPS是每秒50万,持续这个压力的话,在17.8年以后,就会出现这个情况。如果TPS更高,这个年限自然也就更短了。但是,从MySQL真正开始流行到现在,恐怕都还没有实例跑到过这个上限。不过,这个bug是只要MySQL实例的服务时间够长,就会必然出现的。

当然,这个例子更现实的意义是,可以加深我们对低水位和数据可见性的理解。你也可以借此机会,再回顾一下第8篇文章中《事务到底是隔离的还是不隔离的?》中相关的内容。

thread_id

接下来,我们再看看线程id(thread_id)。其实,线程id才是MySQL中最常见的一种自增id。平时我们在查各种现象的时候,show processlist里面的第一列,就是thread_id。

thread_id的逻辑很好理解:系统保存了一个全局变量thread_id_counter,每新建一个连接,就将thread_id_counter赋值给这个新链接的线程变量。

thread_id_counter定义的大小是4个字节,因此达到2^32-1后,它就会重置为0,然后继续增加。但是,你不会再show processlist里看到两个相同的thread_id。

这是因为,MySQL设计了一个唯一数组的逻辑,给新线程分配thread_id的时候,逻辑代码是这样的:

1
2
3
do {
new_id = thread_id_counter++;
} while (!thread_ids.insert_unique(new_id).second);

这个代码逻辑简单而且实现优雅,相信你一看就能明白。(唯一数组中存在,就加1,直到不存在。)

小结

今天这篇文章,我给你介绍了MySQL不同的自增id达到上限以后的行为。数据库系统作为一个可能需要7*24小时全年无休的服务,考虑这些边界是非常有必要的。

每种自增id有各自的应用场景,在达到上限后的表现也不同:

  1. 表的自增id达到上限后,在申请时它的值就不改变,进而导致继续插入数据时报主键冲突的错误。
  2. row_id达到上限后,会归0再重新递增,如果出现相同的row_id,后写的数据会覆盖之前的数据。
  3. Xid只需要不在同一个binlog文件中出现重复值即可,虽然理论上会出现重复值,但是概率极小,可以忽略不计。
  4. InnoDB的max_trx_id递增值每次MySQL重启都会被保存起来,所以我们在文章中提到的脏读的例子就是一个必现的bug,好在留给我们的时间还很充裕。
  5. thread_id是我们使用中最常见的,而且也是处理得最好的一个自增id逻辑了。

当然,在MySQL里还有别的自增id,比如table_id、binlog文件序号等,就留给你去验证和探索了。

不同的自增id有不同的上限值,上限值的大小取决于声明的类型长度。而我们的专栏声明的上限id就是45,所以今天这篇文章也是我们最后一篇技术文章了。

既然没有下一个id了,课后也就没有思考题了

问题:

  1. 能不能使用join?
  2. 两个大小不同的表,应该用哪个表做驱动表?

为了便于量化分析,我建两个表t1和t2来和你说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
CREATE TABLE `t2` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`)
) ENGINE=InnoDB;

drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t2 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();

create table t1 like t2;
insert into t1 (select * from t2 where id<=100)

可以看到,这两个表都有一个主键索引id和一个索引a,字段b上无索引。存储过程idata()往表t2里插入了1000行数据,在表t1里插入的是100行数据。

Index Nested-Loop Join

我们来看一下这个语句:

1
select * from t1 straight_join t2 on (t1.a=t2.a);

如果直接使用join语句,MySQL优化器可能会选择表t1或t2作为驱动表,这样会影响我们分析SQL语句的执行过程。所以,为了便于分析执行过程中的性能问题,我改用strainght_join让MySQL使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去join。在这个语句里,t1是驱动表,t2是被驱动表。

现在,我们来看一下这条语句的explain结果。

可以看到,在这条语句里,被驱动表t2的字段a上有索引,join过程用上了这个索引,因此这个语句的执行流程是这样的:

  1. 从表t1中读入一行数据R;
  2. 从数据行R中,取出a字段到表t2里查找;
  3. 取出表t2中满足条件的行,跟R组成一行,作为结果集的一部分;
  4. 重复执行步骤1到3,直到表t1的末尾循环结束。

这个过程是先遍历表t1,然后根据从表t1中取出的每行数据中的a值,去表t2中查找满足条件的记录。在形式上,这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为"Index Nested-Loop Join",简称NLJ。

它对应的流程图如下所示:

在这个流程里:

  1. 对驱动表t1做了全表扫描,这个过程需要扫描100行;
  2. 而对于每一行R,根据a字段去表t2查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次搜索过程都只扫描一行,也是总共扫描100行;
  3. 所以,整个执行流程,总扫描行数是200。

现在我们知道了这个过程,再试着回答一下文章开头的两个问题。

先看第一个问题,能不能使用join?

假设不使用join,那我们就只能单表查询,我们看看上面这条语句的需求,用单表查询怎么实现。

  1. 执行select * from t1,查出表t1的所有数据,这里有100行;
  2. 循环遍历这100行数据:
    • 从每一行R取出字段a的值$R.a;
    • 执行select * from t2 where a=$R.a;
    • 把返回的结果和R构成结果集的一行。

可以看到,在这个查询过程,也是扫描了200行,但是总共执行了101条语句,比直接join多了100次交互。除此之外,客户端还要自己拼接SQL语句和结果。

显然,这么做还不如直接join好。

我们再来看看第二个问题:怎么选择驱动表

在这个join语句执行过程中,驱动表是走全表扫描,而被驱动表是走数搜索。

假设被驱动表的行数是M。每次在被驱动表查一行数据,要先搜索索引a,再搜索主键索引。每次搜索一棵树近似复杂度是以2为底的M的对数,记为log2M,所以在被驱动表上查一行的时间复杂度是2*log2M。

假设驱动表的行数是N,执行过程就要扫描驱动表N行,然后对于每一行,到被驱动表上匹配一次。

因此整个执行过程,近似复杂度是N+N2log2M。

显然,N对扫描行数的影响更大,因此应该让小表来做驱动表。

到这里小结一下,通过上面的分析我们得到了两个结论:

  1. 使用join语句,性能比强拆成多个单表执行SQL语句的性能要好;
  2. 如果使用join语句的话,需要让小表做驱动表。

但是,你需要注意,这个结论的前提是“可以使用被驱动表的索引”。

接下来,我们再看看被驱动表用不上索引的情况。

Simple Nested-Loop Join

现在,我们把SQL语句改成这样:

1
select * from t1 straight_join t2 on (t1.a=t2.b);

由于表t2的字段b上没有索引,因此再用图2的执行流程时,每次到t2去匹配的时候,就要做一次全表扫描。

你可以设想一下这个问题,继续使用图2的算法,是不是可以得到正确的结果呢?如果只看结果的话,这个算法是正确的,而且这个算法也有一个名字,叫做“Simple Nested-Loop Join”。

但是,这样算来,这个SQL请求就要扫描表t2多达100次,总共扫描100*1000=10万行。

这还只是两个小表,如果t1和t2都是10万行的表(当然了,这也还是属于小表的范围,在现在的硬件条件下,1千万行一下的表都可以认为是小表),就要扫描100亿行,这个算法看上去太“笨重”了。

当然,MySQL也没有使用这个Simple Nested-Loop Join算法,而是使用了另一个叫做“Block Nested-Loop Join"的算法,简称BNL。

Block Nested-Loop Join

这时候,被驱动表上没有可用的索引,算法的流程是这样的:

  1. 把表t1的数据读入线程内存join_buffer中,由于我们这个语句中写的是select *,因此是把整个表t1放入了内存;
  2. 扫描表t2,把表t2中的每一行取出来,跟join_buffer中的数据作对比,满足join条件的,作为结果集的一部分返回。

这个过程的流程图如下:

对应地,这条SQL语句的explain结果如下所示:

可以看到,在这个过程中,对表t1和t2都做了一次全表扫描,因此总的扫描行数是1100。由于join_buffer是以无序数组的方式组织的,因此对表t2中的每一行,都要做100次判断,总共需要在内存中做得判断次数是:100*1000=10万次。

前面我们说过,如果使用Simple Nested-Loop Join算法进行查询,扫描行数也是10万行。因此,从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop 算法的这10万次判断是内存操作,速度上会快很多,性能也更好。(原因在这里,我们把小表的数据放到了内存中。而对于Simple Nested-Loop Join算法,是对表t2做了全表扫面,每次拿出一个数据页到内存中,再做判断,这样增加了磁盘IO次数,就比较慢)

接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。

假设小表的行数是N,大表的行数是M,那么在这个算法里:

  1. 两个表都做一次全表扫描,所以总的扫描行数是M+N;
  2. 内存中判断的次数时M*N。

可以看到,调换这两个算式中M和N没差别,因此这时候选择大表还是小表做驱动表,执行耗时是一样的。

然后,你可能马上就会问了,这个例子里表t1才100行,要是t1是一个大表,join_buffer放不下怎么办呢?

join_buffer的大小是由参数join_buffer_size设定的,默认值是256k。如果放不下表t1的所有数据的话,策略很简单,就是分段放。我把join_buffer_size改成1200,再执行:

1
select * from t1 straight_join t2 on (t1.a=t2.b);

执行过程就变成了:

  1. 扫描表t1,顺序读取数据行放入join_buffer中,放完第88行join_buffer满了,继续第2步;
  2. 扫描表t2,把t2中的每一行取出来,跟join_buffer中的数据作对比,满足join条件的,作为结果集的一部分返回;
  3. 清空join_buffer;
  4. 继续扫描表t1,顺序读取最后的12行数据放入join_buffer中,继续执行第2步。

执行流程图也就变成这样:

图中的步骤4和5,表示清空join_buffer再复用。

这个流程才体现出了这个算法名字中"Block"的由来,表示“分块去join"。

可以看到,这时候由于表t1被分成了两次放入join_buffer中,导致表t2会被扫描两次。虽然分成两次放入join_buffer,但是判断等之条件的次数还是不变的,依然是(88+12)*1000=10万次。

我们再来看下,在这种情况下驱动表的选择问题。

假设,驱动表的数据行数是N,需要分K段才能完成算法流程,被驱动表的数据行数是M。

注意,这里的K不是常数,N越大K就会越大,因此把K表示为λ*N,显然λ的取值范围是(0, 1)。

所以,在这个算法的执行过程中:

  1. 扫描行数是N+λ*N*M;
  2. 内存判断N*M次。

显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在M和N大小确定的情况下,N小一些,整个算式的结果会更小。

所以结论是,应该让小表当驱动表。

当然,你会发现,在N+λ*N*M这个式子里,λ才是影响扫描行数的关键因素,这个值越小越好。

刚刚我们说了N越大,分段数K越大。那么,N固定的时候,什么参数会影响K的大小呢?(也就是λ的大小)Join_buffer_size。join_buffer_size越大,一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少。

这就是为什么,你可能看到一些建议说,如果你的join语句很慢,就把join_buffer_size改大。

理解了MySQL执行join的两种算法,现在我们再来试着回答文章开头的两个问题。

第一个问题:能不能使用join'语句?

  1. 如果可以使用Index Nested-Loop Join算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
  2. 如果使用Block Nested-Loop Join算法,扫描行数就会过多。尤其是在大表上的join操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种join尽量不要用。

所以你在判断要不要使用join语句时,就是看explain结果里面,Extra字段里面有没有出现“Block Nested Loop"字样。

第二个问题:如果要使用join,应该选择大表做驱动表还是选择小表做驱动表?

  1. 如果是Index Nested-Loop Join算法,应该选择小表做驱动表;
  2. 如果是Block Nested-Loop Join算法:
    • 在join_buffer_size足够大的时候,是一样的;
    • 在join_buffer_size不够大的时候(这种情况才是更为常见的),应该选择小表做驱动表。

所以,这个问题的结论就是,总是应该选择小表做驱动表。

当然,这里我需要说明下,什么叫做"小表"?

我们前面的例子是没有加条件的。如果我在语句的where条件加上t2.id<=50这个限定条件,再来看下这两条语句:

1
2
select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50;
select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50;

注意,为了让两条语句的被驱动表都用不上索引,所以join字段都使用了没有索引的字段b。

但如果是用第二个语句的话,join_buffer只需要放入t2的前50行,显然是更好的。所以这里,"t2的前50行"是那个相对小的表,也就是"小表"。

我们再来看另外一组例子:

1
2
select t1.b,t2.* from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=100;
select t1.b,t2.* from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=100;

这个例子里,表t1和t2都是只有100行参加join。但是,这两条语句每次查询放入join_buffer中的数据是不一样的:

  1. 表t1只查字段b,因此如果把t1放到join_buffer中,则join_buffer中只需要放入b的值;
  2. 表t2需要查所有的字段,因此如果把表t2放到join_buffer中的话,就需要放入三个字段为id、a、b。

这里,我们应该选择表t1作为驱动表。也就是说在这个例子里,"只需要一列参与join的表t1"是那个相对小的表。

所以,更准确的说,在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个子弹的总数据量,数据量小的那个表,就是"小表",应该作为驱动表。

小结

今天,我和你介绍了MySQL执行join语句的两种可能算法,这两种算法,是由能否使用被驱动表的索引决定的。而能否用上被驱动表的索引,对join语句的性能影响很大。

通过对Index Nested-Loop Join和Block Nested-Loop Join两个算法执行过程的分析,我们也得到了文章开头两个问题的答案:

  1. 如果可以使用被驱动表的索引,join语句还是有其优势的;
  2. 不能使用被驱动表的索引,只能使用Block Nested-Loop Join算法,这样的语句尽量不要使用;
  3. 在使用join的时候,应该让小表做驱动表。

最后,又到了今天的问题时间。

我们在上文中说到,使用Block Nested-Loop Join算法,可能会因为join_buffer不够大,需要对被驱动表做多次全表扫描。

我的问题是,如果被驱动表是一个大表,并且是一个冷数据表,除了查询过程中农可能会导致IO压力大以外,你觉得对这个MySQL服务还有什么更严重的影响吗?(这个问题需要结合上一篇文章的知识点)

解答:

我在上一篇文章末尾,给你留下的思考题是,使用 Block Nested-Loop Join(BNL) 算法时,可能会对被驱动表做多次扫描。如果这个被驱动表是一个大的冷数据表,除了会导致 IO 压力大以外,还会对系统有什么影响呢?

33篇文章中,我们说到InnoDB的LRU算法的时候提到,由于InnoDB对Buffer Pool的LRU算法做了优化,即:第一次从磁盘读入内存的数据页,会先放在old区域(很像jvm GC分成了Young和Old,但是相反)。如果1秒之后这个数据页不再被访问了,就不会被移动到LRU链表头部,这样对Buffer Pool的命中率影响就不大。

但是,如果一个使用BNL算法的join语句,多次扫描一个冷表,而且这个语句执行时间超过1秒,就会在再次扫描冷表的时候,把冷表的数据页移到LRU链表的头部。

这种情况对应的,是冷表的数据量小于整个Buffer Pool的3/8,能够完全放入old区域的情况。

如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入young区域。

由于优化机制的存在,一个正常访问的数据页,要进入young区域,需要隔1秒后再次被访问到。但是,由于我们的join语句在循环读磁盘和淘汰内存页,进入old区域的数据页,很可能在1秒之内就被淘汰了。这样,就会导致这个MySQL实例的Buffer Pool在这段时间内,young区域的数据页没有被合理地淘汰。

也就是说,这两种情况都会影响Buffer Pool的正常运作。

大表join操作虽然对IO有影响,但是在语句执行结束后,对IO的影响也就结束了。但是,对Buffer Pool的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。

为了减少这种影响,你可以考虑增大join_buffer_size的值,减少对被驱动表的扫描次数。

也就是说,BNL算法对系统的影响主要包括三个方面:

  1. 可能会多次扫描被驱动表,占用磁盘IO资源;
  2. 判断join条件需要执行M*N次对比(M、N分别是两张表的行数),如果是大表就会占用非常多的CPU资源;
  3. 可能会导致Buffer Pool的热数据被淘汰,影响内存命中率。

我们执行语句之前,需要通过理论分析和查看explain结果的方式,确认是否要使用BNL算法。如果确认优化器会使用BNL算法,就需要做优化。优化的常见做法是,给被驱动表的join字段加上索引,把BNL算法转成BKA算法。

关于BKA算法的详细内容,请看下一篇文章35