数据库其它调优策略
调优的目标
- 尽可能
节省系统资源
,以便系统可以提供更大负荷的服务。(吞吐量更大)。 - 合理的结构设计和参数调整,以提高用户操作
响应的速度
。(响应速度更快) - 减少系统的瓶颈,提高MySQL数据库整体的性能。
如何定位调优问题
不过随着用户量的不断增加,以及应用程序复杂度的提升,我们很难用“更快
”去定义数据库调优的目标,因为用户在不同时间段访问服务器遇到的瓶颈不同,比如双十一促销的时候会带来大规模的并发访问
;还有用户在进行不同业务操作的时候,数据库的事务处理
和SQL查询
都会有所不同。因此我们还需要更加精细的定位,去确定调优的目标。
如何确定呢?一般情况下,有如下几种方式:
-
用户的反馈(主要)
用户是我们的服务对象,因此他们的反馈是最直接的。虽然他们不会直接提出技术建议,但是有些问题往往是用户第一时间发现的。我们要重视用户的反馈,找到和数据相关的问题。
-
日志分析(主要) 我们可以通过查看数据库日志和操作系统日志等方式找出异常情况,通过它们来定位遇到的问题。
-
服务器资源使用监控
通过监控服务器的CPU、内存、I/o等使用情况,可以实时了解服务器的性能使用,与历史情况进行对比。
-
数据库内部状况监控
在数据库的监控中,
活动会话(Active Session)监控
是一个重要的指标。通过它,你可以清楚地了解数据库当前是否处于非常繁忙的状态,是否存在SQL堆积等。 -
其它 除了活动会话监控以外,我们也可以对
事务、锁等待
等进行监控,这些都可以帮助我们对数据库的运行状态有更全面的认识。
调优的维度和步骤
我们需要调优的对象是整个数据库管理系统,它不仅包括SQL查询,还包括数据库的部署配置、架构等。从这个 /角度来说,我们思考的维度就不仅仅局限在SQL优化上了。通过如下的步骤我们进行梳理:
第1步:选择适合的DBMS
如果对事务性处理
以及安全性要求高
的话,可以选择商业的数据库产品。
也可以采用开源的MySQL进行存储,它有很多存储引擎可以选择,如果进行事务处理的话可以选择InnoDB,非事务处理可以选择MylSAM。
NoSQL阵营包括键值型数据库
、文档型数据库
、搜索引擎
、列式存储
和图形数据库
。
第2步:优化表设计
1.表结构要尽量遵循三范式的原则
。这样可以让数据结构更加清晰规范减少冗余字段,同时也减少了在更新,插入和删除数据时等异常情况的发生。
2.如果查询
应用比较多,尤其是需要进行多表联查
的时候,可以采用反范式
进行优化。反范式采用空间换时间
的方式,通过增加冗余字段提高查询的效率。
3.表字段的数据类型选择
,关系到了查询效率的高低以及存储空间的大小。可以用数值类型就不要采用字符类型;字符长度要尽可能设计得短一些。当长度不固定时,通常采用VARCHAR类型。
第3步:优化逻辑查询
SQL查询优化,可以分为逻辑查询优化
和物理查询优化
。逻辑查询优化就是通过改变SQL语句的内容让SQL执行效率更高效,采用的方式是对SQL语句进行等价变换,对查询进行重写。
第4步:优化物理查询
物理查询优化是在确定了逻辑查询优化之后,采用物理优化技术(比如索引等),通过计算代价模型对各种可能的访问路径进行估算,从而找到执行方式中代价最小的作为执行计划。在这个部分中,我们需要掌握的重点是对索引的创建和使用。
第5步:使用Redis或 Memcached作为缓存
从可靠性来说,Redis支持持久化
,可以让我们的数据保存在硬盘上,不过这样一来性能消耗也会比较大。而Memcached仅仅是内存存储,不支持持久化。
从支持的数据类型来说,Redis 比Memcached要多,它不仅支持 key-value类型的数据,还支持List,Set,Hash等数据结构。当我们有持久化需求或者是更高级的数据处理需求的时候,就可以使用Redis。如果是简单的key-value存储,则可以使用Memcached。
第6步:库级优化
库级优化是站在数据库的维度上进行的优化策略,比如控制一个库中的数据表数量。另外,单一的数据库总会遇到各种限制,不如取长补短,利用"外援"的方式。通过主从架构
优化我们的读写策略,通过对数据库进行垂直或者水平切分,突破单一数据库或数据表的访问限制,提升查询的性能。
1、读写分离
2、数据分片
对数据库分库分表。当数据量级达到干万级以上时,有时候我们需要把一个数据库切成多份,放到不同的数据库服务器上,减少对单一数据库服务器的访问压力。如果你使用的是MySQL,就可以使用MySQL自带的分区表功能,当然你也可以考虑自己做垂直拆分(分库)
、水平拆分
(分表)、垂直+水平拆分(分库分表
)。
但需要注意的是,分拆在提升数据库性能的同时,也会增加维护和使用成本
优化MySQL服务器
优化服务器硬件
服务器的硬件性能直接决定着MySQL数据库的性能。
(1)配置较大的内存
。
(2)配置高速磁盘系统
,以减少读盘的等待时间,提高响应速度。
(3)合理分布磁盘I/O
,把磁盘I/o分散在多个设备上,以减少资源竞争提高并行操作能力。 (4)配置多处理器
,MySQL是多线程的数据库,多处理器可同时执行多个线程。
优化MySQL的参数
过优化MySQL的参数可以提高资源利用率,从而达到提高MySQL服务器性能的目的。
MySQL服务的配置参数都在my.cnf
或者my. ini文
件的[mysqld]组中
-
innodb_buffer_pool_size
:这个参数是Mysql数据库最重要的参数之一,表示InnoDB类型的表和索引的最大缓存
。它不仅仅缓存索引数据,还会缓存表的数据。这个值越大,查询的速度就会越快。但是这个值太大会影响操作系统的性能。 -
key_buffer_size
:表示索引缓冲区的大小
。索引缓冲区是所有的线程共享
。增加索引缓冲区可以得到更好处理的索引(对所有读和多重写)。当然,这个值不是越大越好,它的大小取决于内存的大小。如果这个值太大,就会导致操作系统频繁换页,也会降低系统性能。对于内存在4GB
左右的服务器该参数可设置为256M
或384M
。 -
table_cache
:表示同时打开的表的个数。这个值越大,能够同时打开的表的个数越多。物理内存越大,设置就越大。默认为2402,调到512-1024最佳。这个值不是越大越好,因为同时打开的表太多会影响操作系统的性能。 -
query_cache_size
:表示查询缓冲区的大小。可以通过在MySQL控制台观察,如果Qcache_lowmem_prunes的值非常大,则表明经常出现缓冲不够的情况,就要增加Query_cache_size的值;如果Qcache_hits的值非常大,则表明查询缓冲使用非常频繁,如果该值较小反而会影响效率,那么可以考虑不用查询缓存; Qcache_free_blocks,如果该值非常大,则表明缓冲区中碎片很多。MySQL8.0之后失效。该参数需要和query_cache_type配合使用。 -
query_cache_type
的值是0时,所有的查询都不使用查询缓存区。但是query_cache_type=0并不会导致MySQL释放query_cache_size所配置的缓存区内存。- 当
query_cache_type
=1时,所有的查询都将使用查询缓存区,除非在查询语句中指定SQL_NO_CACHE
,SELECT SQL_NO_CACHE* FROM tbl_name。 - 当query_cache_type=2时,只有在查询语句中使用
SQL_CACHE
关键字,查询才会使用查询缓存区。使用查询缓存区可以提高查询的速度,这种方式只适用于修改操作少且经常执行相同的查询操作的情况。
- 当
-
sort_buffer_size
:表示每个需要进行排序的线程分配的缓冲区的大小
。增加这个参数的值可以提高ORDER BY
或GROUP BY
操作的速度。默认数值是2097144字节(约2MB)。对于内存在4GB左右的服务器推荐设置为6-8M,如果有100个连接,那么实际分配的总共排序缓冲区大小为100 ×6 = 600MB。 -
join_buffer_size = 8M
:表示联合查询操作所能使用的缓冲区大小
,和sort_buffer_size—样,该参数对应 的分配内存也是每个连接独享。 -
read_buffer_size
:表示每个线程连续扫描时为扫描的每个表分配的缓冲区的大小(字节)
。当线程从表中连续读取记录时需要用到这个缓冲区。SET SESSION read_buffer_size=n可以临时设置该参数的值。默认为64K,可以设置为4M。 -
innodb_flush_log_at_trx_commit
:表示何时将缓冲区的数据写入日志文件
,并且将日志文件写入磁盘中。该参数对干innoDB引擎非常重要。该参数有3个值,分别为0、1和2。该参数的默认值为1.- 值为0时,表示
每秒1次
的频率将数据写入日志文件并将日志文件写入磁盘。每个事务的commit并不会触发前面的任何操作。该模式速度最快,但不太安全,mysqld进程的崩溃会导致上一秒钟所有事务数据的丢失。 - 值为1时,表示
每次提交事务时
将数据写入日志文件并将日志文件写入磁盘进行同步。该模式是最安全的,但也是最慢的一种方式。因为每次事务提交或事务外的指令都需要把日志写入(flush)硬盘。 - 值为2时,表示
每次提交事务时
将数据写入日志文件,每隔1秒将日志文件写入磁盘。该模式速度较快,也比0安全,只有在操作系统崩溃或者系统断电的情况下,上一秒钟所有事务数据才可能丢失。
- 值为0时,表示
-
innodb_log_buffer_size
:这是InnoDB存储引擎的事务日志所使用的缓冲区
。为了提高性能,也是先将信息写入Innodb Log Buffer中,当满足innodb_flush_log_trx_commit参数所设置的相应条件(或者日志缓冲区写满)之后,才会将日志写到文件(或者同步到磁盘)中。 -
max_connections
:表示允许连接到MySQL数据库的最大数量
,默认值是151
。如果状态变量 connection_errors_max_connections 不为零,并且一直增长,则说明不断有连接请求因数据库连接数已达到允许最大值而失败,这是可以考虑增大max_connections的值。在Linux平台下,性能好的服务器,支持500-1000个连接不是难事,需要根据服务器性能进行评估设定。这个连接数不是越大越好
,因为这些连接会浪费内存的资源。过多的连接可能会导致MySQL服务器僵死。 -
back_log
:用于控制MySQL监听TCP端口时设置的积压请求栈大小
。如果MySql的连接数达到max_connections时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即back_log,如果等待连接的数量超过back_log,将不被授予连接资源,将会报错。5.6.6版本之前默认值为50,之后的版本默认为50+(max_connections / 5),对于Linux系统推荐设置为小于512的整数,但最大不超过900。 如果需要数据库在较短的时间内处理大量连接请求,可以考虑适当增大back_log的值。 -
thread_cache_size
:线程池缓存线程数量的大小,当客户端断开连接后将当前线程缓存起来,当在接到新的连接请求时快速响应无需创建新的线程。这尤其对那些使用短连接的应用程序来说可以极大的提高创建连接的效率。那么为了提高性能可以增大该参数的值。默认为60,可以设置为120。 -
wait_timeout
:指定一个请求的最大连接时间
,对于4GB左右内存的服务器可以设置为5-10。. -
interactive_timeout
:表示服务器在关闭连接前等待行动的秒数。
文里给出一份my.cnf的参考配置:
[mysqld]port = 3306serverid = 1
socket = /tmp/mysql.sockskip-locking
#避免MySQL的外部锁定,减少出错几率增强稳定性。
skip-name-resolve
#禁止MysQL对外部连接进行DNS解析,使用这一选项可以消除MySQL进行DNS解析的时间。但需要注意,如果开启该选项,则所有远程主机连接授权都要使用IP地址方式,否则MySQL将无法正常处理连接请求!
back_log = 384
key_buffer_size = 256M
max_allowed_packet = 4M
thread_stack = 256K
table_cache = 128K
sort_buffer_size = 6M
read_buffer_size =4M
read_rnd_buffer_size=16M
join_buffer_size = 8M
myisam_sort_buffer_size = 64M
table_cache = 512
thread_cache_size = 64
query_cache_size = 64M
tmp_table_size = 256M
max_connections = 768
max_connecterrors = 10000000
wait_timeout= 10
thread_concurrency = 8
#该参数取值为服务器逻辑CPU数量*2,在本例中,服务器有2颗物理CPU,而每颗物理CPU又支持H.T超线程,所以实际取值为4*2=8
skip-networking
#开启该选项可以彻底关闭MySQL的TCP/IP连接方式,如果WEB服务器是以远程连接的方式访问MysQL数据库服务器则不要开启该选项!否则将无法正常连接!
table_cache=1024
innodb_additional_mem_pool_size=4M
#默认为2M
innodb_flush_log_at_trx_commit=1
innodb_log_buffer_size=2M
#默认为1M
innodb_thread_concurrency=8
#你的服务器CPU有几个就设置为几。建议用默认一般为8
tmp_table_size=64M
#默认为16M,调到64-256最挂
thread_cache_size=120
query_cache_size=32M
举例:
下面是一个电商平台,类似京东或天猫这样的平台。商家购买服务,入住平台,开通之后,商家可以在系统中架各种商品,客户通过手机App、微信小程序等渠道购买商品,商家接到订单以后安排快递送货。
刚刚上线
的时候,系统运行状态良好。但是,随着入住的商家不断增多
,使用系统的用户量越来越多
,每天的 单数据达到了5万条以上。这个时候,系统开始出现问题,CPU使用率不断飙升
。终于,双十一或者618活动高的时候,CPU使用率达到99%
,这实际上就意味着,系统的计算资源已经耗尽,再也无法处理任何新的订单了。换句话说,系统已经崩溃了。
这个时候,我们想到了对系统参数进行调整,因为参数的值决定了资源配置的方式和投放的程度。
为了解决这个问题,一共调整3个系统参数,分别是
- InnoDB_flush_log_at_trx_commit
- lnnoDB_buffer_pool_size
- InnoDB_buffer_pool_instances
下面我们就说—说调整这三个参数的原因是什么。
(1)调整系统参数InnoDB_flush_log_at_trx_commit
这个参数适用于InnoDB存储引擎,电商平台系统中的表用的存储引擎都是InnoDB。默认的值是1,意思是每次提交事务的时候,都把数据写入日志,并把日志写入磁盘。这样做的好处是数据安全性最佳
,不足之处在于每次提交事务,都要进行磁盘写入的操作。在大并发的场景下
,过于频繁的磁盘读写会导致CPU资源浪费,系统效率变低。
这个参数的值还有2个可能的选项,分别是0和2。我们把这个参数的值改成了2。这样就不用每次提交事务的时候都启动磁盘读写了,在大并发的场景下,可以改善系统效率,降低CPU使用率。即便出现故障,损失的数据也比较小。
(2)调整系统参数InnoDB_buffer_pool_size
这个参数的意思是,InnoDB存储引擎使用缓存来存储索引和数据
。这个值越大,可以加载到缓存区的索引和数据量就越多,需要的磁盘读写就越少
。
因为我们的MySQL服务器是数据库专属服务器,只用来运行MysQL数据库服务,没有其他应用了,而我们的计算机是64位机器,内存也有128G。于是我们把这个参数的值调整为64G
。这样一来,磁盘读写次数可以大幅降低,我们就可以充分利用内存,释放出一些CPU的资源。
(3)调整系统参数InnoDB_buffer_pool_instances
这个参数可以将InnoDB的缓存区分成几个部分,这样可以提高系统的并行处理能力
,因为可以允许多个进程同时处理不同部分的缓存区。
我们把InnoDB_buffer_pool_instances 的值修改为64,意思就是把 InnoDB的缓存区分战64个分区,这样就可以同时有多个进程
进行数据操作,CPU的效率就高多了。修改好了系统参数的值,要重启MysQL数据库服务器。
总结一下就是遇到CPU资源不足的问题,可以从下面2个思路去解决。
- 疏通拥堵路段,消除瓶颈,让等待的时间更短;
- 开拓新的通道,增加并行处理能力。
事务
事务是数据库区别于文件系统的重要特性之一,当我们有了事务就会让数据库始终保持一致性
,同时我们还能通过事务的机制恢复到某个时间点
,这样可以保证已提交到数据库的修改不会因为系统崩溃而丢失。
事务: 一组逻辑操作单元,使数据从一种状态变换到另一种状态。
事务处理的原则:保证所有事务都作为 一个工作单元
来执行,即使出现了故障,都不能改变这种执行方式。当在一个事务中执行多个操作时,要么所有的事务都被提交( commit
),那么这些修改就 永久
地保存下来;要么数据库管理系统将 放弃
所作的所有 修改
,整个事务回滚( rollback
)到最初状态。
事务的ACID特性
-
原子性(atomicity):
原子性是指事务是一个不可分割的工作单位,要么全部提交,要么全部失败回滚。即要么转账成功,要么转账失败,是不存在中间的状态。
-
一致性(consistency):
(国内很多网站上对一致性的阐述有误,具体你可以参考 Wikipedia 对Consistency的阐述)
根据定义,一致性是指事务执行前后,数据从一个
合法性状态
变换到另外一个合法性状态
。这种状态是语义上
的而不是语法上的,跟具体的业务有关。那什么是合法的数据状态呢?满足
预定的约束
的状态就叫做合法的状态。通俗一点,这状态是由你自己来定义的(比如满足现实世界中的约束)。满足这个状态,数据就是一致的,不满足这个状态,数据就是不一致的!如果事务中的某个操作失败了,系统就会自动撤销当前正在执行的事务,返回到事务操作之前的状态。举例1: A账户有200元,转账300元出去,此时A账户余额为-100元。你自然就发现了此时数据是不一致的,为什么呢?因为你定义了一个状态,余额这列必须>=0。
举例2∶A账户20o元,转账50元给B账户,A账户的钱扣了,但是B账户因为各种意外,余额并没有增加。你也知道此时数据是不一致的,为什么呢?因为你定义了一个状态,要求A+B的总余额必须不变。
举例3∶在数据表中我们将
姓名
字段设置为唯一性约束
,这时当事务进行提交或者事务发生回滚的时候,如果数据表中的姓名不唯一,就破坏了事务的一致性要求。 -
隔离型(isolation):
事务的隔离性是指一个事务的执行
不能被其他事务干扰
,即一个事务内部的操作及使用的数据对并发
的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。如果无法保证隔离性会怎么样?
-
持久性(durability):
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是
永久性的
,接下来的其他操作和数据库故障不应该对其有任何影响。持久性是通过
事务日志
来保证的。日志包括了重做日志
和回滚日志
。当我们通过事务对数据进行修改的时候,首先会将数据库的变化信息记录到重做日志
中,然后再对数据库中对应的行进行修改。这样做的好处是,即使数据库系统崩溃,数据库重启后也能找到没有更新到数据库系统中的重做日志,重新执行,从而使事务具有持久性。总结
ACID是事务的四大特性,在这四个特性中,原子性是基础,隔离性是手段,一致性是约束条件,而持久性是我们的目的。
数据库事务,其实就是数据库设计者为了方便起见,把需要保证
原子性
、隔离性
、一致性
和持久性
的一个或多个数据库操作称为一个事务。
1.4 事务的状态
我们现在知道 事务
是一个抽象的概念,它其实对应着一个或多个数据库操作,MySQL根据这些操作所执行的不同阶段把 事务
大致划分成几个状态:
-
活动的(active)
事务对应的数据库操作正在执行过程中时,我们就说该事务处在
活动的
状态。 -
部分提交的(partially committed)
当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并
没有刷新到磁盘
时,我们就说该事务处在部分提交的
状态。 -
失败的(failed)
当事务处在
活动的
或者部分提交的
状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的
状态。 -
中止的(aborted)
如果事务执行了一部分而变为
失败的
状态,那么就需要把已经修改的事务中的操作还原到事务执行前的状态。换句话说,就是要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称之为回滚
。当回滚
操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的
状态。 -
提交的(committed)
当一个处在
部分提交的
状态的事务将修改过的数据都同步到磁盘
上之后,我们就可以说该事务处在了提交的
状态。
一个基本的状态转换图如下所示
图中可见,只有当事务处于提交的
或者中止的
状态时,一个事务的生命周期才算是结束了。对于已经提交的事务来说,该事务对数据库所做的修改将永久生效,对于处于中止状态的事务,该事务对数据库所做的所有修改都会被回滚到没执行该事务之前的状态。
显式事务和隐式事务
- 显式的的使用 START TRANSACTION 或者 BEGIN 语句开启一个事务。这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。
- 把系统变量 autocommit 的值设置为 OFF
隐式提交数据的情况
-
数据定义语言(
Data definition language
,缩写为:DDL
) -
隐式使用或修改
mysql
数据库中的表事务控制或关于锁定的语句① 当我们在一个事务还没提交或者回滚时就又使用
START TRANSACTION
或者BEGIN
语句开启了另一个事务时,会 隐式的提交 上一个事务。即: ② 当前的autocommit
系统变量的值为OFF
,我们手动把它调为ON
时,也会 隐式的提交 前边语句所属的事务。 ③ 使用LOCK TABLES
、UNLOCK TABLES
等关于锁定的语句也会 隐式的提交 前边语句所属的事务。 -
加载数据的语句
-
关于
MySQL
复制的一些语句 -
其它的一些语句
当我们设置
autocommit=0
时,不论是否采用START TRANSACTION
或者BEGIN
的方式来开启事务,都需要用COMMIT
进行提交,让事务生效,使用ROLLBACK
对事务进行回滚。
当我们设置
autocommit=1
时,每条 SQL 语句都会自动进行提交。 不过这时,如果你采用START TRANSACTION
或者 BEGIN 的方式来显式地开启事务,那么这个事务只有在COMMIT
时才会生效,在ROLLBACK
时才会回滚
事务隔离级别
MySQL是一个 客户端/服务器 架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称为一个会话( Session
)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。事务有 隔离性 的特性,理论上在某个事务 对某个数据进行访问 时,其他事务应该进行 排队 ,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样对 性能影响太大 ,我们既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时 性能尽量高些 ,那就看二者如何权衡取舍了。
数据并发问题
1. 脏写( Dirty Write )
对于两个事务 Session A
、Session B
,如果事务Session A
修改了 另一个 未提交 事务Session B
修改过 的数据,那就意味着发生了 脏写
2. 脏读( Dirty Read )
对于两个事务 Session A、Session B,Session A 读取 了已经被 Session B 更新 但还 没有被提交 的字段。 之后若 Session B 回滚 ,Session A 读取 的内容就是 临时且无效 的。
3. 不可重复读( Non-Repeatable Read )
对于两个事务Session A、Session B,Session A 读取 了一个字段,然后 Session B 更新 了该字段。 之后 Session A 再次读取 同一个字段, 值就不同 了。那就意味着发生了不可重复读。 我们在Session B中提交了几个 隐式事务 (注意是隐式事务,意味着语句结束事务就提交了),这些事务都修改了studentno列为1的记录的列name的值,每次事务提交之后,如果Session A中的事务都可以查看到最新的值,这种现象也被称之为 不可重复读 。
4. 幻读( Phantom )
对于两个事务Session A、Session B, Session A 从一个表中 读取 了一个字段, 然后 Session B 在该表中 插入 了一些新的行。 之后, 如果 Session A 再次读取 同一个表, 就会多出几行。那就意味着发生了幻读。
SQL中的四种隔离级别
上面介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题有轻重缓急之分,我们给这些问题 按照严重性来排一下序:
脏写 > 脏读 > 不可重复读 > 幻读
我们愿意舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,并发问题发生的就越多。 SQL标准 中设立了4个 隔离级别 : READ UNCOMMITTED
:读未提交,在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。不能避免脏读、不可重复读、幻读。 READ COMMITTED
:读已提交,它满足了隔离的简单定义:一个事务只能看见已经提交事务所做 的改变。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。可以避免脏读,但不可重复读、幻读问题仍然存在。 REPEATABLE READ
:可重复读,事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。可以避免脏读、不可重复读,但幻读问题仍然存在。这是MySQL的默认隔离级别。 SERIALIZABLE
:可串行化,确保事务可以从一个表中读取相同的行。在这个事务持续期间,禁止其他事务对该表执行插入、更新和删除操作。所有的并发问题都可以避免,但性能十分低下。能避免脏读、不可重复读和幻读。
脏写怎么没涉及到?
因为脏写这个问题太严重了,不论是哪种隔离级别,都不允许脏写的情况发生。
MySQL支持的四种隔离级别
MySQL的默认隔离级别为REPEATABLE READ,我们可以手动修改一下事务的隔离级别。
# 查看隔离级别,MySQL 5.7.20的版本之前:
mysql> SHOW VARIABLES LIKE 'tx_isolation';
# MySQL 5.7.20版本之后,引入transaction_isolation来替换tx_isolation
# 查看隔离级别,MySQL 5.7.20的版本及之后:
mysql> SHOW VARIABLES LIKE 'transaction_isolation';
#或者不同MySQL版本中都可以使用的:
SELECT @@transaction_isolation;
如何设置事务的隔离级别
通过下面的语句修改事务的隔离级别:
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL 隔离级别;
#其中,隔离级别格式:
> READ UNCOMMITTED
> READ COMMITTED
> REPEATABLE READ
> SERIALIZABLE
或者:
SET [GLOBAL|SESSION] TRANSACTION_ISOLATION = '隔离级别'
#其中,隔离级别格式:
> READ-UNCOMMITTED
> READ-COMMITTED
> REPEATABLE-READ
> SERIALIZABLE
关于设置时使用GLOBAL
或SESSION
的影响: 当前已经存在的会话无效,只对执行完该语句之后产生的会话起作用 使用 SESSION 关键字(在会话范围影响): 对当前会话的所有后续的事务有效 如果在事务之间执行,则对后续的事务有效 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务
小结:
数据库规定了多种事务隔离级别,不同隔离级别对应不同的干扰程度,隔离级别越高,数据一致性就越好,但并发性越弱
不同隔离级别举例
4. 事务的常见分类
从事务理论的角度来看,可以把事务分为以下几种类型:
- 扁平事务(
Flat Transactions
) - 带有保存点的扁平事务(
Flat Transactions with Savepoints
) - 链事务(
Chained Transactions
) - 嵌套事务(
Nested Transactions
) - 分布式事务(
Distributed Transactions
)
MySQL事务日志
事务有4种特性:原子性、一致性、隔离性和持久性。那么事务的四种特性到底是基于什么机制实现呢?
-
事务的隔离性由
锁机制
实现。 -
而事务的原子性、一致性和持久性由事务的 redo 日志和undo 日志来保证。
- REDO LOG 称为
重做日志
,提供再写入操作,恢复提交事务修改的页操作,用来保证事务的持久性。 - UNDO LOG 称为
回滚日志
,回滚行记录到某个特定版本,用来保证事务的原子性、一致性。
- REDO LOG 称为
有的DBA或许会认为 UNDO 是 REDO 的逆过程,其实不然。其实不然。REDO和UNDO都可以视为是一种恢厦操作
-
redo log:是存储引擎层(innodb)生成的日志,记录的是"
物理级别
"上的页修改操作,比如页号xx、偏移量ywy写入了'zzz'数据。主要为了保证数据的可靠性;提交,由redo log来保证事务的持久化。
-
undo log:是存储引擎层(innodb)生成的日志,记录的是
逻辑操作
日志,比如对某一行数据进行了INSERT语句操作,那么undo log就记录一条与之相反的DELETE操作。主要用于事务的回滚
(undo log 记录的是每个修改操作的逆操作
)和一致性非锁定读
(undo log回滚行记录到某种特定的版本---MVCC,即多版本并发控制)。
1. redo日志
InnoDB存储引擎是以页为单位
来管理存储空间的。在真正访问页面之前需要把在磁盘上
的页缓存到内存中的 Buffer Pool
之后才可以访问。所有的变更都必须先更新缓冲池中
的数据,然后缓冲池中的脏页
会以一定的频率被刷入磁盘( checkPoint
机制),通过缓冲池来优化CPU和磁盘之间的鸿沟,这样就可以保证整体的性能不会下降太快。
1.1 为什么需要REDO日志
一方面,缓冲池可以帮助我们消除CPU和磁盘之间的鸿沟,checkpoint机制可以保证数据的最终落盘,然而由于checkpoint 并不是每次变更的时候就触发
的,而是master线程隔一段时间去处理的。所以最坏的情况就是事务提交后,刚写完缓冲池,数据库宕机了,那么这段数据就是丢失的,无法恢复。
另一方面,事务包含 持久性
的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。
那么如何保证这个持久性呢? 一个简单的做法
:在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:
-
修改量与刷新磁盘工作量严重不成比例
有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在InnoDB中是以页为单位来进行磁盘lo的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太小题大做了。
-
随机lO刷新较慢
一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,假如该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的Buffer Pool中的页面
刷新到磁盘
时需要进行很多的随机IO
,随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。
另一个解决的思路
:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把 修改
了哪些东西 记录一下
就好。比如,某个事务将系统表空间中 第10号
页面中偏移量为 100
处的那个字节的值 1
改成 2
。我们只需要记录一下:将第0号表空间的10号页面的偏移量为100处的值更新为 2 。
InnoDB引擎的事务采用了WAL技术(Write-Ahead Logging
),这种技术的思想就是先写日志,再写磁盘,只有日志写入成功,才算事务提交成功,这里的日志就是redo log
。当发生宕机且数据未刷到磁盘的时候,可以通过redo log来恢复,保证ACID中的D,这就是redo log的作用。
1.2 REDO日志的好处、特点
1. 好处
- redo日志降低了刷盘频率
- 存储表空间ID、页号、偏移量以及需要更新的值,所需的存储空间是很小的,刷盘快。
2. 特点
-
redo日志是顺序写入磁盘的
在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的
顺序写入磁盘
的,也就是使用顺序IO,效率比随机IO快。 -
事务执行过程中,redo log不断记录
redo log跟bin log的区别,redo log是
存储引擎层
产生的,而bin log是数据层
产生的。假设一个事务,对表做10万行的记录插入,在这个过程中,一直不断的往redo log顺序记录,而bin log不会记录,直到这个事务提交,才会一次写入到bin log文件中。
1.3 redo的组成
Redo log可以简单分为以下两个部分:
-
重做日志的缓冲 (redo log buffer)
,保存在内存中,是易失的。在服务器启动时就向操作系统申请了一大片称之为
redo log buffer
的连续内存
空间,翻译成中文就是redo日志缓冲区。这片内存空间被划分成若干个连续的redo log block
。一个redo log block
占用512字节
大小。
**参数设置:innodb_log_buffer_size:**
redo log buffer 大小,默认 `16M` ,最大值是4096M,最小值为1M。
-
重做日志文件(redo log file)
,保存在硬盘中,是持久的。REDO
日志文件,其中的ib_logfile0
和ib_logfile1
即为redo log日志。
以一个更新事务为例,redo log 流转过程,如下图所示:
第1步:先将原始数据从磁盘中读入内存中来,修改数据的内存拷贝
第2步:生成一条重做日志并写入redo log buffer,记录的是数据被修改后的值
第3步:当事务commit时,将redo log buffer中的内容刷新到 redo log file,对 redo log file采用追加写的方式
第4步:定期将内存中修改的数据刷新到磁盘中
体会:
Write-Ahead Log(预先日志持久化):在持久化一个数据页之前,先将内存中相应的日志页持久化。
1.5 redo log的刷盘策略
redo log的写入并不是直接写入磁盘的,InnoDB引擎会在写redo log的时候先写redo log buffer,之后以 一定的频率刷
入到真正的redo log file 中。这里的一定频率怎么看待呢?这就是我们要说的刷盘策略。
注意,redo log buffer刷盘到redo log file的过程并不是真正的刷到磁盘中去,只是刷入到 文件系统缓存
(page cache)中去(这是现代操作系统为了提高文件写入效率做的一个优化),真正的写入会交给系统自己来决定(比如page cache足够大了)。那么对于InnoDB来说就存在一个问题,如果交给系统来同步,同样如果系统宕机,那么数据也丢失了(虽然整个系统宕机的概率还是比较小的)。
针对这种情况,InnoDB给出 innodb_flush_log_at_trx_commit
参数,该参数控制 commit
提交事务时,如何将 redo log buffer
中的日志刷新到 redo log file
中。它支持三种策略:
-
设置为0
:表示每次事务提交时不进行刷盘操作。(系统默认master thread每隔1s进行一次重做日志的同步) -
设置为1
:表示每次事务提交时都将进行同步,刷盘操作(默认值
) -
设置为2
:表示每次事务提交时都只把 redo log buffer 内容写入page cache
,不进行同步。由os自己决定什么时候同步到磁盘文件。show variables like 'innodb_flush_log_at_trx_commit';
另外,InnoDB存储引擎有一个后台线程,每隔1秒,就会把redo log buffer中
的内容写到文件系统缓存( page cache
),然后调用刷盘操作。
也就是说,一个没有提交事务的redo log
记录,也可能会刷盘。因为在事务执行过程redo log记录是会写入redo log buffer
中,这些redo log记录会被后台线程
刷盘。
除了后台线程每秒1次
的轮询操作,还有一种情况,当redo log buffer
占用的空间即将达到innodb_log_buffer_size
(这个参数默认是16M)的一半的时候,后台线程会主动刷盘。
1.6 不同刷盘策略演示
1.流程图
除了1秒刷盘,提交了也刷盘。效率差一些。
小结: innodb_flush_log_at_trx_commit=1
为1时,只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失。
如果事务执行期间MySQL挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。可以保证ACID的D,数据绝对不会丢失,但是效率最差的。
建议使用默认值,虽然操作系统宕机的概率理论小于数据库宕机的概率,但是一般既然使用了事务,那么数据的安全相对来说更重要些。|
除了1s 强制刷盘,page cache 由系统决定啥时候刷盘
小结: innodb_flush_log_at_trx_commit=2
为2时,只要事务提交成功,
redo log buffer
中的内容只写入文件系统缓存( page cache ) 。如果仅仅只是
MySQL
挂了不会有任何数据丢失,但是操作系统宕机可能会有1
秒数据的丢失,这种情况下无法满足ACID中的D。但是数值2肯定是效率最高的。
- 1最慢 但最安全
- 0 最快最不安全
- 2 折中。
1.7 写入redo log buffer 过程
1.补充概念:Mini-Transaction
MySQL把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction
,简称mtr
,比如,向某个索引对应的B+树中插入一条记录的过程就是一个Mini-Transaction
。一个所谓的mtr
可以包含一组redo
日志,在进行崩溃恢复时这一组redo
日志作为一个不可分割的整体。
一个事务可以包含若干条语句,每一条语句其实是由若干个 mtr
组成,每一个 mtr
又可以包含若干条redo
日志,画个图表示它们的关系就是这样:
2.redo 日志写入log buffer
向log buffer
中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer
中写入redo日志时,第一个遇到的问题就是应该写在哪个block
的哪个偏移量处,所以InnoDB
的设计者特意提供了一个称之为buf_free
的全局变量,该变量指明后续写入的redo日志应该写入到log buffer
中的哪个位置,如图所示:
一个mtr执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组
,所以其实并不是每生成一条redo日志,就将其插入到log buffer中,而是每个mtr运行过程中产生的日志先暂时存到一个地方,当该mtr结束的时候,将过程中产生的一组redo日志再全部复制到log buffer
中。我们现在假设有两个名为T1
、T2
的事务,每个事务都包含2个mtr,我们给这几个mtr
命名一下:
- 事务
T1
的两个mtr
分别称为mtr_T1_1
和mtr_T1_2
。 - 事务
T2
的两个mtr
分别称为mtr_T2_1
和mtr_T2_2
。
每个mtr都会产生一组redo日志。
不同的事务可能是 并发
执行的,所以 T1
、 T2
之间的 mtr
可能是 交替执行
的。每当一个mtr执行完成时,伴随该mtr生成的一组redo日志就需要被复制到log buffer中,也就是说不同事务的mtr可能是交替写入log buffer的,我们画个示意图(为了美观,我们把一个mtr中产生的所有的redo日志当作一个整体来画):
3. redo log block的结构图
一个redo log block是由日志头
、日志体
、日志尾
组成。日志头占用12字节,日志尾占用8字节,所以一个block真正能存储的数据就是512-12-8=492字节。
为什么一个block设计成512字节?
这个和磁盘的扇区有关,机械磁盘默认的扇区就是512字节,如果你要写入的数据大于512字节,那么要写入的扇区肯定不止一个,这时就要涉及到盘片的转动,找到下一个扇区,假设现在需要写入两个扇区A和B,如果扇区A写入成功,而扇区B写入失败,那么就会出现
非原子性
的写入,而如果每次只写入和扇区的大小一样的512字节,那么每次的写入都是原子性的。
真正的redo日志都是存储到占用496
字节大小的log block body
中,图中的log block header
和logblock trailer
存储的是一些管理信息。我们来看看这些所谓的管理信息
都有什么。
1.8 redo log file
1. 相关参数设置
innodb_log_group_home_dir
:指定 redo log 文件组所在的路径,默认值为./
,表示在数据库的数据目录下。MySQL的默认数据目录(var/lib/mysql
)下默认有两个名为ib_logfile0
和ib_logfile1
的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。此redo日志文件位置还可以修改。innodb_log_files_in_group
:指明redo log file的个数,命名方式如:ib_logfile0,iblogfile1...iblogfilen。默认2个,最大100个。
-
innodb_flush_log_at_trx_commit:控制 redo log 刷新到磁盘的策略,默认为1。
-
innodb_log_file_size:单个 redo log 文件设置大小,默认值为
48M
。最大值为512G,注意最大值指的是整个 redo log 系列文件之和,即(innodb_log_files_in_group * innodb_log_file_size )不能大于最大值512G。根据业务修改其大小,以便容纳较大的事务。编辑my.cnf文件并重启数据库生效,如下所示
[root@localhost ~]# vim /etc/my.cnf innodb_log_file_size=200M
在数据库实例更新比较频繁的情况下,可以适当加大 redo log组数和大小。但也不推荐redo log 设置过大,在MySQL崩溃恢复时会重新执行REDO日志中的记录。
2. 日志文件组
从上边的描述中可以看到,磁盘上的redo
日志文件不只一个,而是以一个日志文件组
的形式出现的。这些文件以ib_logfile[数字]
(数字
可以是0、1、2...)的形式进行命名,每个的redo日志文件大小都是一样的。
在将redo日志写入日志文件组时,是从ib_logfile0
开始写,如果ib_logfile0
写满了,就接着ib_logfile1
写。同理,ib_logfile1
.写满了就去写ib_logfile2
,依此类推。如果写到最后一个文件该咋办?那就重新转到ib_logfile0
继续写,所以整个过程如下图所示:
总共的redo日志文件大小其实就是: innodb_log_file_size × innodb_log_files_in_group
。
采用循环使用的方式向redo日志文件组里写数据的话,会导致后写入的redo日志覆盖掉前边写的redo日志?当然!所以InnoDB的设计者提出了checkpoint的概念。
3. checkpoint
在整个日志文件组中还有两个重要的属性,分别是write pos
、checkpoint
write pos
是当前记录的位置,一边写一边后移checkpoint
是当前要擦除的位置,也是往后推移
每次刷盘redo log
记录到日志文件组中,write pos
位置就会后移更新。每次MySQL
加载日志文件组恢复数据时,会清空加载过的redo log
记录,并把 checkpoint
后移更新。write pos
和checkpoint
之间的还空着的部分可以用来写入新的redo log
记录。
如果 write pos
追上 checkpoint
,表示日志文件组满了,这时候不能再写入新的 redo log记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。
1.9 redo log小结
相信大家都知道redo log的作用和它的刷盘时机、存储形式:
InnoDB的更新操作采用的是Write Ahead Log(预先日志持久化)策略,即先写日志,再写入磁盘。
2. Undo日志
redo log
是事务持久性的保证,undo log
是事务原子性的保证。在事务中 更新数据
的 前置操作
其实是要先写入一个 undo log
。
2.1 如何理解Undo日志
事务需要保证 原子性
,也就是事务中的操作要么全部完成,要么什么也不做。但有时候事务执行到一半会出现一些情况,比如:
- 情况一:事务执行过程中可能遇到各种错误,比如
服务器本身的错误
,操作系统错误
,甚至是突然断电
导的错误。 - 情况二:程序员可以在事务执行过程中手动输入
ROLLBACK
语句结束当前事务的执行。
以上情况出现,我们需要把数据改回原先的样子,这个过程称之为 回滚
,这样就可以造成一个假象:这个事务看起来什么都没做,所以符合 原子性
要求。
每当我们要对一条记录做改动时(这里的改动
可以指INSERT
、DELETE
、UPDATE
),都需要"留一手"——把回滚时所需的东西记下来。比如:
- 你
插入一条记录时
,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉
就好了。(对于每个INSERT, InnoDB存储引擎会完成一个DELETE) - 你
删除了一条记录
,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。(对于每个DELETE,InnoDB存储引擎会执行一个INSERT) - 你
修改了一条记录
,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值
就好了。(对于每个UPDATE,InnoDB存储引擎会执行一个相反的UPDATE,将修改前的行放回去)
MySQL把这些为了回滚而记录的这些内容称之为撤销日志
或者回滚日志(
即undo log
)。
注意,由于查询操作(
SELECT
)并不会修改任何用户记录,所以在杳询操作行时,并不需要记录相应的undo日志
此外,undo log
会产生redo log
,也就是undo log
的产生会伴随着redo log
的产生,这是因为undo log
也需要持久性的保护
2.2 Undo日志的作用
-
作用1:回滚数据
用户对
undo
日志可能有误解
:undo
用于将数据库物理地恢复到执行语句或事务之前的样子。但事实并非如此。undo是逻辑日志
,因此只是将数据库逻辑地恢复到原来的样子。所有修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同。这是因为在多用户并发系统中,可能会有数十、数百甚至数千个并发事务。数据库的主要任务就是协调对数据记录的并发访问。比如,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中另几条记录进行修改。因此,不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作。
-
作用2:MVCC
undo
的另一个作用是MVCC
,即在InnoDB
存储引擎中MVCC
的实现是通过undo
来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo
读取之前的行版本信息,以此实现非锁定读取。
2.3 undo的存储结构
1. 回滚段与undo页
InnoDB对undo log的管理采用段的方式,也就是 回滚段(rollback segment)
。每个回滚段记录了1024
个 undo log segment
,而在每个undo log segment段中进行 undo页
的申请。
- 在
InnoDB1.1版本之前
(不包括1.1版本),只有一个rollback segment,因此支持同时在线的事务限制为1024
。虽然对绝大多数的应用来说都已经够用。 - 从1.1版本开始InnoDB支持最大
128个rollback segment
,故其支持同时在线的事务限制提高到了 128*1024 。
虽然InnoDB1.1版本支持了128个rollback segment,但是这些rollback segment都存储于共享表空间ibdata中。从lnnoDB1.2版本开始,可通过参数对rollback segment做进一步的设置。这些参数包括:
innodb_undo_directory
:设置rollback segment文件所在的路径。这意味着rollback segment可以存放在共享表空间以外的位置,即可以设置为独立表空间。该参数的默认值为“”,表示当前InnoDB存储引擎的目录。oinnodb_undo_logs
:设置rollback segment的个数,默认值为128。在InnoDB1.2版本中,该参数用来替换之前版本的参数innodb_rollback_segments。innodb_undo_tablespaces
: 设置构成rollback segment文件的数量,这样rollback segment可以较为平均地分布在多个文件中。设置该参数后,会在路径innodb_undo_directory看到undo为前缀的文件,该文件就代表rollback segment文件。
undo页的重用
当我们开启一个事务需要写undo log
的时候,就得先去undo log segment
中去找到一个空闲的位置,当有空位的时候,就去申请undo页,在这个申请到的undo页中进行undo log的写入。我们知道mysql默认一页的大小是16k。
为每一个事务分配一个页,是非常浪费的(除非你的事务非常长),假设你的应用的TPS(每秒处理的事务数目)为1000,那么1s就需要1000个页,大概需要16M的存储,1分钟大概需要1G的存储。如果照这样下去除非MySQL清理的非常勤快,否则随着时间的推移,磁盘空间会增长的非常快,而且很多空间都是浪费的。
于是undo
页就被设计的可以重用
了,当事务提交时,并不会立刻删除undo
页。因为重用,所以这个undo
页可能混杂着其他事务的undo log
。undo log
在commit
后,会被放到一个链表
中,然后判断undo
页的使用空间是否小于3/4
,如果小于3/4的话,则表示当前的undo
页可以被重用,那么它就不会被回收,其他事务的undo log
可以记录在当前undo页的后面。由于undo log是离散的
,所以清理对应的磁盘空间时,效率不高。
2. 回滚段与事务
-
每个事务只会使用一个
回滚段(rollback segment)
,一个回滚段在同一时刻可能会服务于多个事务。 -
当一个事务开始的时候,会制定一个回滚段,在事务进行的过程中,当数据被修改时,原始的数据会被复制到回滚段。
-
在回滚段中,事务会不断填充盘区,直到事务结束或所有的空间被用完。如果当前的盘区不够用,事务会在段中请求扩展下一个盘区,如果所有已分配的盘区都被用完,事务会覆盖最初的盘区或者在回滚段允许的情况下扩展新的盘区来使用。
-
回滚段存在于undo表空间中,在数据库中可以存在多个undo表空间,但同一时刻只能使用一个undo表空间。
mysql> show variables like 'innodb_undo_tablespaces'; +-------------------------+-------+ | Variable_name | Value | +-------------------------+-------+ | innodb_undo_tablespaces | 2 | +-------------------------+-------+ #undo log的数量,最少为2,undo log的truncate操作有purge协调线程发起。在truncate某个undo log表空间的过程中,保证有一个可用的undo log可用。
-
当事务提交时,
InnoDB
存储引擎会做以下两件事情:- 将
undo log
放入列表中,以供之后的purge
操作 - 判断
undo log
所在的页是否可以重用(低于3/4可以重用),若可以分配给下个事务使用
- 将
3. 回滚段中的数据分类
-
未提交的回滚数据(uncommitted undo information)
:该数据所关联的事务并未提交,用于实现读一致性,所以该数据不能被其他事务的数据覆盖。
-
已经提交但未过期的回滚数据(committed undo information)
:该数据关联的事务已经提交,但是仍受到
undo retention
参数的保持时间的影响。 -
事务已经提交并过期的数据(expired undo information)
:事务已经提交,而且数据保存时间已经超过
undo retention
参数指定的时间,属于已经过期的数据。当回滚段满了之后,会优先覆盖"事务已经提交并过期的数据"。
事务提交后并不能马上删除undo log
及undo log
所在的页。这是因为可能还有其他事务需要通过undo log
来得到行记录之前的版本。故事务提交时将undo log
放入一个链表中,是否可以最终删除undo log
及undo log
所在页由purge
线程来判断。
2.4 undo的类型
在InnoDB
存储引擎中,undo log
分为:
-
insert undo log
insert undo log
是指在insert
操作中产生的undo log
。因为insert
操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该undo log可以在事务提交后直接删除。不需要进行purge操作。 -
update undo log
update undo log
记录的是对delete
和update
操作产生的undo log
。该undo log
可能需要提供MVCC
机制,因此不能在事务提交时就进行删除。提交时放入undo log
链表,等待purge
线程进行最后的删除。
2.5 undo log的生命周期
1. 简要生成过程
以下是undo
+redo
事务的简化过程
假设有2个数值,分别为A=1和B=2,然后将A修改为3,B修改为4
1. start transaction;
2.记录A=1到undo log;
3. update A = 3;
4.记录A=3 到redo log;
5.记录B=2到undo loq;
6. update B = 4;
7.记录B = 4到redo log;
8.将redo log刷新到磁盘;
9. commit
-
在1-8步骤的任意一步系统宕机,事务未提交,该事务就不会对磁盘上的数据做任何影响。
-
如果在8-9之间宕机。
- redo log 进行恢复
- undo log 发现有事务没完成进行回滚。
-
若在9之后系统宕机,内存映射中变更的数据还来不及刷回磁盘,那么系统恢复之后,可以根据redo log把数据刷回磁盘。
只有Buffer Pool的流程:
有了Redo Log和Undo Log之后 :
在更新Buffer Pool
中的数据之前,我们需要先将该数据事务开始之前的状态写入Undo Log
中。假设更新到一半出错了,我们就可以通过Undo Log来回滚到事务开始前。
2. 详细生成过程
对于InnoDB引擎来说,每个行记录除了记录本身的数据之外,还有几个隐藏的列:
-
DB_ROW_ID
: 如果没有为表显式的定义主键,并且表中也没有定义唯一索引,那么InnoDB会自动为表添加一个row_id的隐藏列作为主键。 -
DB_TRX_ID
︰每个事务都会分配一个事务ID,当对某条记录发生变更时,就会将这个事务的事务ID写入trx_id中。疑问, 就一个字段,如果有两个事务怎么办。两个事务会不会有锁呢?
-
DB_ROLL_PTR
:回滚指针,本质上就是指向undo log的指针。
当我们执行INSERT时:
begin;
INSERT INTO user (name) VALUES ("tom");
插入的数据都会生成一条insert undo log
,并且数据的回滚指针会指向它。undo log
会记录undo log
的序号、插入主键的列和值...,那么在进行rollback的时候,通过主键直接把对应的数据删除即可。
当我们执行UPDATE时:
对于更新的操作会产生update undo log,并且会分更新主键的和不更新主键的,假设现在执行:
UPDATE user SET name= "Sun" WHERE id=1;
这时会把老的记录写入新的undo log,让回滚指针指向新的undo log,它的undo no是1,并且新的undo log会指向老的undo log (undo no=0)。
假设现在执行:
UPDATE user SET id=2 WHERE id=1;
对于更新主键的操作,会先把原来的数据deletemark
标识打开,这时并没有真正的删除数据,真正的删除会交给清理线程去判断,然后在后面插入一条新的数据,新的数据也会产生undo log
,并且undo log
的序号会递增。
可以发现每次对数据的变更都会产生一个undo log,当一条记录被变更多次时,那么就会产生多条undo log,undo log记录的是变更前的日志,并且每个undo log的序号是递增的,那么当要回滚的时候,按照序号依次向前推
,就可以找到我们的原始数据了。
3. undo log是如何回滚的
以上面的例子来说,假设执行rollback
,那么对应的流程应该是这样:
- 通过undo no=3的日志把id=2的数据删除
- 通过undo no=2的日志把id=1的数据的deletemark还原成0
- 通过undo no=1的日志把id=1的数据的name还原成Tom
- 通过undo no=0的日志把id=1的数据删除
4.undo log的删除
-
针对于
insert undo log
因为
insert
操作的记录,只对事务本身可见,对其他事务不可见。故该undo log
可以在事务提交后直接删除,不需要进行purge
操作。 -
针对于
update undo log
该
undo log
可能需要提供MVCC
机制,因此不能在事务提交时就进行删除。提交时放入undo log
链表,等待purge
线程进行最后的删除。
补充:
purge
线程两个主要作用是:清理undo页
和清除page里面带有Delete_Bit标识的数据
行。在InnoDB
中,事分中的Delete
操作实际上并不是真正的删除掉数据行,而是一种Delete Mark
操作,在记录上标识Delete_Bit
,而不删除记录。是一种"假删除"只是做了个标记,真正的删除工作需要后台purge
线程去完成。
2.6 小结
undo log是逻辑日志,对事务回滚时,只是将数据库逻辑地恢复到原来的样子。
redo log是物理日志,记录的是数据页的物理变化,undo log不是redo log的逆过程。
锁
锁
是计算机协调多个进程或线程并发访问某一资源
的机制。在程序开发中会存在多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程
在访问,保证数据的完整性
和一致性
。在开发过程中加锁是为了保证数据的一致性,这个思想在数据库领域中同样很重要。
在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对并发操作进行控制
,因此产生了锁
。同时锁机制
也为实现MySQL的各个隔离级别提供了保证。锁冲突
也是影响数据库并发访问性能
的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。
MySQL并发事务访问相同记录
并发事务访问相同记录的情况大致可以划分为 3 种
2. 1 读-读情况
读-读
情况,即并发事务相继读取相同的记录
。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。
2. 2 写-写情况
写-写
情况,即并发事务相继对相同的记录做出改动。
在这种情况下会发生脏写
的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行
,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构
,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构
和记录进行关联的,如图所示:
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构
,当没有的时候就会在内存中生成一个锁结构
与之关联。比如,事务T1
要对这条记录做改动,就需要生成一个锁结构
与之关联:
在锁结构里有很多信息,为了简化理解,只把两个比较重要的属性拿了出来:
trx信息
:代表这个锁结构是哪个事务生成的。is_waiting
:代表当前事务是否在等待。
当事务T1
改动了这条记录后,就生成了一个锁结构
与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting
属性就是false
,我们把这个场景就称之为获取锁成功
,或者加锁成功
,然后就可以继续执行操作了。
在事务T1
提交之前,另一个事务T2
也想对该记录做改动,那么先看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting
属性值为true
,表示当前事务需要等待,我们把这个场景就称之为获取锁失
败,或者加锁失败
,图示:
在事务T1提交之后,就会把该事务生成的锁结构释放
掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting
属性设置为false
,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。效果图就是这样:
小结几种说法:
-
不加锁
意思就是不需要在内存中生成对应的
锁结构
,可以直接执行操作。 -
获取锁成功,或者加锁成功
意思就是在内存中生成了对应的
锁结构
,而且锁结构的is_waiting
属性为false
,也就是事务可以继续执行操作。 -
获取锁失败,或者加锁失败,或者没有获取到锁
意思就是在内存中生成了对应的锁结构,不过锁结构的
is_waiting
属性为true
,也就是事务需要等待,不可以继续执行操作。
2. 3 读-写或写-读情况
读-写
或写-读
,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读
、不可重复读
、幻读
的问题。
各个数据库厂商对SQL标准的
支持都可能不一样。比如MySQL在REPEATABLE READ
隔离级别上就已经解决了幻读问题。
2. 4 并发问题的解决方案
怎么解决脏读
、不可重复读
、幻读
这些问题呢?其实有两种可选的解决方案:
- 方案一:读操作利用多版本并发控制(MVCC,下章讲解),写操作进行加锁。
所谓的MVCC
,就是生成一个ReadView
,通过ReadView找到符合条件的记录版本(历史版本由undo日志
构建)。查询语句只能读
到在生成ReadView之前已提交事务所做的更改
,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作
肯定针对的是最新版本的记
录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写
操作并不冲突。
普通的
SELECT
语句在READ COMMITTED
和REPEATABLE READ
隔离级别下会使用到MVCC
读取记录。
- 在
READ COMMITTED
隔离级别下,一个事务在执行过程中每次执行SELECT
操作时都会生成一个ReadView
,ReadView
的存在本身就保证了事务不可以读取到未提交的事务所做的更改
,也就是避免了脏读现象;- 在
REPEATABLE READ
隔离级别下,一个事务在执行过程中只有第一次执行SELECT
操作才会生成一个ReadView
,之后的SELECT
操作都复用
这个ReadView
,这样也就避免了不可重复读和幻读的问题。
-
方案二:读、写操作都采用
加锁
的方式。如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去
读取记录的最新版本
。脏读
的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。不可重复读
的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。幻读
问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录。采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候加锁就有点尴尬(因为你并不知道给谁加锁)。 -
小结对比发现:
- 采用
MVCC
方式的话,读-写
操作彼此并不冲突,性能更高
。 - 采用
加锁
方式的话,读-写
操作彼此需要排队执行
,影响性能。
一般情况下我们当然愿意采用
MVCC
来解决读-写
操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁
的方式执行。下面就讲解下MySQL中不同类别的锁。 - 采用
锁的不同角度分类
3. 1 从数据操作的类型划分:读锁、写锁
对于数据库中并发事务的读-读
情况并不会引起什么问题。对于写-写
、读-写
或写-读
这些情况可能会引起一些问题,需要使用MVCC
或者加锁
的方式来解决它们。在使用加锁
的方式解决问题时,由于既要允许读-读
情况不受影响,又要使写-写
、读-写
或写-读情况中的操作相互阻塞,所以MySQL实现一个由两种类型的锁组成的锁系统来解决。这两种类型的锁通常被称为共享锁(Shared Lock,SLock) 和排他锁(Exclusive Lock,XLock), 也叫读锁(readlock)和写锁(write lock)。
读锁
:也称为共享锁
、英文用S
表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。写锁
:也称为排他锁
、英文用X
表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。
需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上。
举例(行级读写锁)∶如果一个事务T1已经获得了某个行r的读锁,那么此时另外的一个事务T2是可以去获得这个行r的读锁的,因为读取操作并没有改变行r的数据;但是,如果某个事务T3想获得行r的写锁,则它必须等待事务T1、T2释放掉行r上的读锁才行。
总结:这里的兼容是指对同一张表或记录的锁的兼容性情况。
X锁 | S锁 | |
---|---|---|
X锁 | 不兼容 | 不兼容 |
S锁 | 不兼容 | 兼容 |
1. 锁定读
在采用加锁
方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取该记录的S锁,其实是不严谨的,有时候需要在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此MySQL提出了两种比较特殊的SELECT语句格式:
- 对读取的记录加S锁:
SELECT ... LOCK IN SHARE MODE;
#或
SELECT ... FOR SHARE;#(8.0新增语法)
在普通的SELECT语句后边加LOCK IN SHARE MODE
,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务也使用SELECT ... LOCK IN SHAREMODE
语句来读取这些记录),但是不能获取这些记录的X锁(比如使用SELECT ... FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的x锁
,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁
释放掉。
- 对读取的记录加X锁:
SELECT ... FOR UPDATE;
在普通的SELECT语句后边加FOR UPDATE
,如果当前事务执行了该语句,那么它会为读取到的记录加X锁
,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT ... LOCK IN SHARE MODE
语句来读取这些记录),也不允许获取这些记录的X锁(比如使用SELECT ... FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的s锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。
MySQL8.0新特性:
在5.7及之前的版本,SELECT ..FOR UPDATE,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout
超时。在8.0版本中,SELECT. FOR UPDATE,SELECT ...FOR SHARE添加NOWAIT
、SKIP LOCKED
语法,跳过锁等待,或者跳过锁定。
-
通过添加NOWAIT、SKIP LOCKED语法,能够立即返回。如果查询的行已经加锁:
- 那么NOWAIT会立即报错返回(等不到锁立即返回)
- 而SKIP LOCKED也会立即返回,只是返回的结果中不包含被锁定的行。()
SELECT. FOR UPDATE NOWAIT
2.写操作
平常所用到的写操作
无非是DELETE
、UPDATE
、INSERT
这三种:
-
DELETE
: 对一条记录做DELETE
操作的过程其实是先在B+
树中定位到这条记录的位置,然后获取这条记录的X锁
,再执行delete mark
.操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁
的锁定读
。 -
UPDATE
∶在对一条记录做UPDATE
操作时分为三种情况:-
情况1: 未修改该记录的
键值
,并且被更新的列占用的存储空间在修改前后未发生变化
。则先在
B+
树中定位到这条记录的位置,然后再获取一下记录的X锁
,最后在原记录的位置进行修改操作。我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁
的锁定读
。 -
情况2∶未修改该记录的
键值
,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取
×锁
的锁定读
,新插入的记录由INSERT
操作提供的隐式锁
进行保护。 -
情况3∶修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照
DELETE
和INSERT
的规则进行了。
-
-
INSERT :
一般情况下,新插入一条记录的操作并不加锁,通过一种称之为
隐式锁
的结构来保护这条新插入的记录在本事务提交前不被别的事务访问
。
3.2 从数据操作的粒度划分:表级锁、页级锁、行锁
为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源
的事情(涉及获取、检查、释放锁等动作)(越小消耗越大)。因此数据库系统需要在高并响应
和系统性能
两方面进行平衡,这样就产生了“锁粒度(Lock granularity)
”的概念。
对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别
进行加锁,自然就被称之为表级锁
或者表锁
,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。锁的粒度主要分为表级锁、页级锁和行锁。
1.表锁(Table Lock)
该锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎
(不管你是MySQL的什么存储引擎,对于表锁的策略都是一样的),并且表锁是开销最小
的策略(因为粒度比较大)。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁
问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣
。
① 表级别的S锁、X锁
在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁
或者X锁
的。在对某个表执行一些诸如ALTER TABLE
、DROP TABLE
这类的DDL
语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞。同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行DDL
语句也会发生阻塞。这个过程其实是通过在server
层使用一种称之为元数据锁
(英文名:Metadata Locks
,简称MDL
)结构来实现的。
一般情况下,不会使用InnoDB存储引擎提供的表级别
的S锁
和X锁
。只会在一些特殊情况下,比方说崩溃恢复
过程中用到。比如,在系统变量autocommit=0,innodb_table_locks = 1
时,手动获取InnoDB存储引擎提供的表t 的S锁或者X锁可以这么写:
LOCK TABLES t READ
:InnoDB存储引擎会对表t加表级别的S锁。LOCK TABLES t WRITE
:InnoDB存储引擎会对表t加表级别的X锁。
不过尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES
这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB的厉害之处还是实现了更细粒度的行锁,关于InnoDB表级别的S锁和X锁大家了解一下就可以了。
# 查看innodb表锁是否打开
mysql> show variables like '%innodb_table%';
show open tables where in_use > 0; # 查看哪些表被锁了
lock tables student read # 加写锁
lock tables student write;
unlock tables; # 表解锁
总结:
MyISAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB存储引擎是不会为这个表添加表级别的读锁
或者写锁
的。(有行锁,谁TM用表锁啊)
MySQL的表级锁有两种模式:(以MyISAM表进行操作的演示)
- 表共享读锁(Table Read Lock)
- 表独占写锁(Table Write Lock)
锁类型 | 自己可读 | 自己可写 | 自己可操作其他表 | 他人可读 | 他人可写 |
---|---|---|---|---|---|
读锁 | 是 | 否 | 否 | 是 | 否,等 |
写锁 | 是 | 是 | 否 | 否,等 | 否,等 |
② 意向锁 (intention lock)
InnoDB 支持多粒度锁(multiple granularity locking)
,它允许行级锁与表级锁共存,而 意向锁 就是其中的一种表锁
。
1、意向锁的存在是为了协调行锁和表锁
的关系,支持多粒度(表锁与行锁)的锁并存。
2、意向锁是一种不与行级锁冲突表级锁
3、表明“某个事务正在某些行持有了锁或该事务准备去持有锁”
意向锁分为两种:
-
意向共享锁 (intention shared lock, IS) :事务有意向对表中的某些行加 共享锁 (S锁)
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。 -- 会自动加,不用管 SELECT column FROM table ... LOCK IN SHARE MODE;
-
意向排他锁 (intention exclusive lock, IX) :事务有意向对表中的某些行加 排他锁 (X锁)
-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。 -- 会自动加,不用管 SELECT column FROM table ... FOR UPDATE;
即:意向锁是由存储引擎自己维护的
,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在数据表的对应意向锁
。
1.意向锁要解决的问题
现在有两个事务,分别是T1和T2,其中T2试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么T2就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞
。T2在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级别的空间示意里面是否已经上过锁。
在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了(不这么做的话,想上表锁的那个程序,还要遍历有没有航所),这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。
- 如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上
添加意向共享锁
。 - 如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上
添加意向排他锁
。
这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录。
举例:创建表teacher,插入6条数据,事务的隔离级别默认为Repeatable-Read
假设事务A获取了某一行的排他锁,并未提交,语句如下所示。
begin ;
SELECT * FROM teacher WHERE id = 6 FOR UPDATE;
事务B想要获取teacher 表的表读锁,语句如下。
begin;
LOCK TABLES teacher READ;
因为共享锁与排他锁互斥,所以事务B在试图对teacher表加共享锁的时候,必须保证两个条件。
(1)当前没有其他事务持有teacher 表的排他锁
(2)当前没有其他事务持有teacher表中任意一行的排他锁。
为了检测是否满足第二个条件,事务B必须在确保teacher表不存在任何排他锁的前提下,去检测表中的每一行是否存在排他锁。很明显这是一个效率很差的做法,但是有了意向锁之后,情况就不一样了。
意向锁是怎么解决这个问题的呢?首先,我们需要知道意向锁之间的兼容互斥性,如下所示。
意向共享锁(lS) | 意向排他锁(IX) | |
---|---|---|
意向共享锁(IS) | 兼容 | 兼容 |
意向排他锁(IX) | 兼容 | 兼容 |
即意向锁之间是互相兼容的,虽然意向锁和自家兄弟互相兼容,但是它会与普通的排他/共享锁互斥。
意向共享锁(lS) | 意向排他锁(IX) | |
---|---|---|
共享锁(S)表 | 兼容 | 互斥 |
排他锁(X)表 | 互斥 | 互斥 |
注意这里的排他/共享锁指的都是表锁,意向锁不会与行级的共享/排他锁互斥。回到刚才teacher表的例子。
事务A获取了某一行的排他锁,并未提交:
# 事务A
BEGIN;
SELECT *FROM teacher WHERE id = 6 FOR UPDATE;
此时teacher表存在两把锁: teacher表上的意向排他锁与id为6的数据行上的排他锁。事务B想要获取teacher表的共享锁。
# 事务B
BEGIN;
LOCK TABLES teacher READ;
此时事务B检测事务A持有teacher表的意向排他锁,就可以得知事务A必然持有该表中某些数据行的排他锁,那么事务B对teacher表的加锁请求就会被排斥(阻塞),而无需去检测表中的每一行数据是否存在排他锁。
意向锁的并发性
意向锁不会与行级的共享 / 排他锁互斥!正因为如此,意向锁并不会影响到多个事务对不同数据行加排他锁时的并发性。(不然我们直接用普通的表锁就行了)
我们扩展一下上面 teacher表的例子来概括一下意向锁的作用(一条数据从被锁定到被释放的过程中,可能存在多种不同锁,但是这里我们只着重表现意向锁)。
从上面的案例可以得到如下结论:
- InnoDB 支持
多粒度锁
,特定场景下,行级锁可以与表级锁共存。 - 意向锁之间互不排斥,但除了 IS 与 S 兼容外,
意向锁会与 共享锁 / 排他锁 互斥
。 - IX,IS是表级锁,不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突。
- 意向锁在保证并发性的前提下,实现了
行锁和表锁共存
且满足事务隔离性
的要求。
③ 自增锁(AUTO-INC锁)
在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT
属性。举例:
这个表的id字段声明了AUTO_INCREMENT
,意味着在书写插入语句时不需要为其赋值,系统会自动为它赋上递增的值,结果如下所示。
现在我们看到的上面插入数据只是一种简单的插入模式,所有插入数据的方式总共分为三类,分别是“Simple inserts
”,“Bulk inserts
”和“Mixed-mode inserts
”。
1. “Simple inserts” (简单插入)
可以预先确定要插入的行数
(当语句被初始处理时)的语句。包括没有嵌套子查询的单行和多行INSERT...VALUES()
和REPLACE
语句。比如我们上面举的例子就属于该类插入,已经确定要插入的行数。
2. “Bulk inserts” (批量插入)
事先不知道要插入的行数
(和所需自动递增值的数量)的语句。比如INSERT ... SELECT,REPLACE... SELECT和LOAD DATA语句,但不包括纯INSERT。 InnoDB在每处理一行,为AUTO_INCREMENT列分配一个新值。
3. “Mixed-mode inserts” (混合模式插入)
这些是“Simple inserts”语句但是指定部分新行的自动递增值。例如INSERT INTO teacher (id,name) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d');只是指定了部分id的值。另一种类型的“混合模式插入”是 INSERT ... ON DUPLICATE KEY UPDATE
。
对于上面数据插入的案例,MySQL中采用了自增锁
的方式来实现,AUTO-INC锁是当向使用含有AUTO_INCREMENT列的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句
中分配的递增值是连续
的。也正因为此,其并发性显然并不高,当我们向一个有AUTO_INCREMENT关键字的主键插入值的时候,每条语句都要对这个表锁进行竞争,这样的并发潜力其实是很低下的,所以innodb通过innodb_autoinc_lock_mode
的不同取值来提供不同的锁定机制,来显著提高SQL语句的可伸缩性和性能。
innodb_autoinc_lock_mode有三种取值,分别对应与不同锁定模式:
(1)innodb_autoinc_lock_mode = 0(“传统”锁定模式)
在此锁定模式下,所有类型的insert
语句都会获得一个特殊的表级AUTO-INC
锁,用于插入具有AUTO_INCREMENT
列的表。这种模式其实就如我们上面的例子,即每当执行insert
的时候,都会得到一个表级锁(AUTO-INC锁),使得语句中生成的auto_increment
为顺序,且在bin log
中重放的时候,可以保证master
与slave
中数据的auto_increment是相同的。因为是表级锁,当在同一时间多个事务中执行insert
的时候,对于AUTO-INC锁的争夺会限制并发能力
。
(2)innodb_autoinc_lock_mode = 1(“连续”锁定模式)
在 MySQL 8.0 之前,连续锁定模式是默认
的。
在这个模式下,“bulk inserts”仍然使用AUTO-INC表级锁,并保持到语句结束。这适用于所有INSERT ...SELECT,REPLACE ... SELECT和LOAD DATA语句。同一时刻只有一个语句可以持有AUTO-INC锁。
对于“Simple inserts”(要插入的行数事先已知),则通过在mutex(轻量锁)
的控制下获得所需数量的自动递增值来避免表级AUTO-INC锁, 它只在分配过程的持续时间内保持,而不是直到语句完成。不使用表级AUTO-INC锁,除非AUTO-INC锁由另一个事务保持。如果另一个事务保持AUTO-INC
锁,则“Simple inserts
”等待AUTO-INC锁,如同它是一个“bulk inserts
”。
( 3 )innodb_autoinc_lock_mode = 2(“交错”锁定模式)
从 MySQL 8.0 开始,交错锁模式是默认
设置。
在这种锁定模式下,所有类INSERT
语句都不会使用表级AUTO-INC锁,并且可以同时执行多个语句。这是最快和最可扩展的锁定模式,但是当使用基于语句的复制或恢复方案时,从二进制日志重播SQL语句时,这是不安全的。(主从复制id可能不一致)
在此锁定模式下,自动递增值保证
在所有并发执行的所有类型的insert语句中是唯一且单调递增的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号), 为任何给定语句插入的行生成的值可能不是连续的。
如果执行的语句是“simple inserts
”,其中要插入的行数已提前知道,除了“Mixed-mode inserts
"之外,为单个语句生成的数字不会有间隙。然而,当执行“bulk inserts
"时,在由任何给定语句分配的自动递增值中可能存在间隙。
④ 元数据锁(MDL锁)
MySQL5.5
引入了meta data lock
,简称MDL
锁,属于表锁范畴。MDL 的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更
,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。
因此, 当对一个表做增删改查操作的时候,加 MDL读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。不需要显式使用
,在访问一个表的时候会被自动加上。
2. InnoDB中的行锁
行锁(Row Lock
)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要的注意的是,MySQL
服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。
优点: 锁定力度小,发生锁冲突概率低
,可以实现的并发度高
。
缺点: 对于锁的开销比大
,加锁会比较慢,容易出现死锁
情况。
InnoDB
与MylSAM
的最大不同有两点:一是支持事务(TRANSACTION
);二是采用了行级锁。
这里把B+树的索引结构做了一个超级简化,只把索引中的记录给拿了出来,下面看看都有哪些常用的行锁类型。
① 记录锁(Record Locks)
记录锁也就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP
。比如我们把id
值为 8 的那条记录加一个记录锁的示意图如图所示。仅仅是锁住了id值为 8
的记录,对周围的数据没有影响。
举例如下:
记录锁是有S锁和X锁之分的,称之为S型记录锁
和X型记录锁
。
- 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;
- 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。
② 间隙锁(Gap Locks)
MySQL在REPEATABLE READ
隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC
方案解决,也可以采用加锁方案解决。但是在使用加锁
方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录
加上记录锁
。InnoDB提出了一种称之为Gap Locks
的锁,官方的类型名称为:LOCK_GAP
,我们可以简称为gap锁
。比如,把id
值为 8 的那条记录加一个gap锁的示意图如下。
图中id值为 8 的记录加了gap锁,意味着不允许别的事务在id值为 8 的记录前边的间隙插入新记录
,其实就是id列的值( 3 , 8 )这个区间的新记录是不允许立即插入的。比如,有另外一个事务再想插入一条id值为 4 的新记录,它定位到该条新记录的下一条记录的id值为 8 ,而这条记录上又有一个gap
锁,所以就会阻塞插入操作,直到拥有这个gap
锁的事务提交了之后,id列的值在区间( 3 , 8 )中的新记录才可以被插入。
gap锁的提出仅仅是为了防止插入幻影记录而提出的 。虽然有共享gap锁
和独占gap锁
这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap
锁还是独占gap
锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。
③ 临键锁(Next-Key Locks)
有时候我们既想锁住某条记录
,又想阻止
其他事务在该记录前边的间隙插入新记录
,所以InnoDB就提出了一种称之为Next-Key Locks的锁
,官方的类型名称为:LOCK_ORDINARY
,我们也可以简称为next-key锁。Next-Key Locks
是在存储引擎innodb、事务级别在可重复读
的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks。
begin;
select * from student where id <= 8 and id > 3 for update;
④ 插入意向锁(Insert Intention Locks)
我们说一个事务在插入
一条记录时需要判断一下插入位置是不是被别的事务加了gap锁
(next-key锁
也包含gap锁
),如果有的话,插入操作需要等待,直到拥有gap锁
的那个事务提交。但是 InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构 ,表明有事务想在某个间隙
中插入
新记录,但是现在在等待。InnoDB
就把这种类型的锁命名为Insert Intention Locks
,官方的类型名称为:LOCK_INSERT_INTENTION
,我们称为插入意向锁。插入意向锁
是一种Gap锁
,不是意向锁,在insert操作时产生。
插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁。
插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
3. 页锁
页锁就是在页的粒度
上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。 页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的
。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级
。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如InnoDB
中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。
3. 3 从对待锁的态度划分:乐观锁、悲观锁
从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式
。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想
。
1. 悲观锁(Pessimistic Locking)
悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞
直到它拿到锁( 共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程 )。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
2. 乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是 不采用数据库自身的锁机制,而是通过程序来实现 。在程序上,我们可以采用版本号机制
或者CAS机制
实现。 乐观锁适用于多读的应用类型,这样可以提高吞吐量 。在Java
中java.util.concurrent.atomic
包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。
1.乐观锁的版本号机制
在表中设计一个版本字段 version
,第一次读的时候,会获取 version
字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version
。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
2. 乐观锁的时间戳机制
时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。
你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。
3. 两种锁的适用场景
从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:
乐观锁
适合读操作多
的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。悲观锁
适合写操作多
的场景,因为写的操作具有排它性
。采用悲观锁的方式,可以在数据库层
面阻止其他事务对该数据的操作权限,防止读 - 写
和写 - 写
的冲突。
3. 4 按加锁的方式划分:显式锁、隐式锁
1. 隐式锁
- 情景一: 对于聚簇索引记录来说,有一个
trx_id
隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id
隐藏列代表的的就是当前事务的事务id
,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id
隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting
属性是false
),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting
属性是true
)。 - 情景二: 对于二级索引记录来说,本身并没有
trx_id
隐藏列,但是在二级索引页面的PageHeader
部分有一个PAGE_MAX_TRX_ID
属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID
属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。
隐式锁的逻辑过程如下:
A. InnoDB
的每条记录中都一个隐含的trx_id
字段,这个字段存在于聚簇索引的B+Tree中。
B. 在操作一条记录前,首先根据记录中的trx_id
检查该事务是否是活动的事务(未提交或回滚)。如果是活动的事务,首先将隐式锁转换为显式锁(就是为该事务添加一个锁)。
C. 检查是否有锁冲突,如果有冲突,创建锁,并设置为waiting
状态。如果没有冲突不加锁,跳到E。
D. 等待加锁成功,被唤醒,或者超时。
E. 写数据,并将自己的trx_id
写入trx_id
字段。
2. 显式锁
通过特定的语句进行加锁,我们一般称之为显示加锁,例如:
显示加共享锁:
select .... lock in share mode
显示加排它锁:
select .... for update
3. 5 其它锁之:全局锁
全局锁就是对整个数据库实例
加锁。当你需要让整个库处于只读状态
的时候,可以使用这个命令,之后
其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景
是:做全库逻辑备份
。
全局锁的命令:
Flush tables with read lock
3. 6 其它锁之:死锁
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。死锁示例:
事务1 | 事务12 | |
---|---|---|
1 | start transaction; update account set money=10 where id=1; | start transaction; |
2 | update account set money=10 where id=2; | |
3 | update account set money=20 where id=2; | |
4 | update account set money=20 where id=1; |
这时候,事务 1 在等待事务 2 释放id=2
的行锁,而事务 2 在等待事务 1 释放id=1
的行锁。 事务 1 和事务 2 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略
:
- 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数
innodb_lock_wait_timeout
来设置。 - 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务(将持有最少行级排他锁的事务进行回滚),让其他事务得以继续执行。将参数
innodb_deadlock_detect
设置为on
,表示开启这个逻辑。
第二种策略的成本分析
方法 1 :如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。 但是这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的
。而关掉死锁检测意味着可能会出现大量的超时,这是业务有损
的。
方法 2 :控制并发度。 如果并发能够控制住,比如同一行同时最多只有 10 个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。
这个并发控制要做在数据库服务端
。如果你有中间件,可以考虑在中间件实现
;甚至有能力修改MySQL
源码的人,也可以做在MySQL
里面。基本思路就是,对于相同行的更新,在进入引擎之前排队,这样在InnoDB
内部就不会有大量的死锁检测工作了。
4. 锁的内存结构
InnoDB存储引擎中的锁结构如下:
结构解析:
-
锁所在的事务信息:
不论是
表锁
还是行锁
,都是在事务执行过程中生成的,哪个事务生成了这个锁结构
,这里就记录这个事务的信息。此
锁所在的事务信息
在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比方说事务id等。 -
索引信息:
对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。这里也是一个指针。
-
表锁/行锁信息:
表锁结构
和行锁结构
在这个位置的内容是不同的:-
表锁:
记载着是对哪个表加的锁,还有其他的一些信息。
-
行锁:
记载了三个重要的信息:
-
Space ID:记录所在表空间。
-
Page Number:记录所在页号。
-
n_bits:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。
n_bits的值一般都比页面中记录条数多一些。主要是为了之后在页面中插入了新记录后 也不至于重新分配锁结构
-
-
-
type_mode:
这是一个 32 位的数,被分成了lock_mode、lock_type和rec_lock_type三个部分,如图所示:
-
锁的模式(lock_mode),占用低 4 位,可选的值如下:
- LOCK_IS(十进制的 0 ):表示共享意向锁,也就是IS锁。
- LOCK_IX(十进制的 1 ):表示独占意向锁,也就是IX锁。
- LOCK_S(十进制的 2 ):表示共享锁,也就是S锁。
- LOCK_X(十进制的 3 ):表示独占锁,也就是X锁。
- LOCK_AUTO_INC(十进制的 4 ):表示AUTO-INC锁。
在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。
-
锁的类型(lock_type),占用第 5 ~ 8 位,不过现阶段只有第 5 位和第 6 位被使用:
- LOCK_TABLE(十进制的 16 ),也就是当第 5 个比特位置为 1 时,表示表级锁。
- LOCK_REC(十进制的 32 ),也就是当第 6 个比特位置为 1 时,表示行级锁。
-
行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在lock_type的值为
- LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:
- LOCK_ORDINARY(十进制的 0 ):表示next-key锁。
- LOCK_GAP(十进制的 512 ):也就是当第 10 个比特位置为 1 时,表示gap锁。
- LOCK_REC_NOT_GAP(十进制的 1024 ):也就是当第 11 个比特位置为 1 时,表示正经记录锁。
- LOCK_INSERT_INTENTION(十进制的 2048 ):也就是当第 12 个比特位置为 1 时,表示插入意向锁。其他的类型:还有一些不常用的类型我们就不多说了。
-
is_waiting属性呢?基于内存空间的节省,所以把is_waiting属性放到了type_mode这个 32位的数字中:
- LOCK_WAIT(十进制的 256 ) :当第 9 个比特位置为 1 时,表示is_waiting为true,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为 0 时,表示is_waiting为false,也就是当前事务获取锁成功。
-
其他信息:
为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。
-
一堆比特位:
如果是
行锁结构
的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的n_bits属性 表示的。InnoDB数据页中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimum的 heap_no值为 0 ,Supremum的heap_no值为 1 ,之后每插入一条记录,heap_no值就增 1 。锁结 构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,即一个比特位映射 到页内的一条记录。
5. 锁监控
关于MySQL锁的监控,我们一般可以通过检查InnoDB_row_lock
等状态变量来分析系统上的行锁的争夺情况
mysql> show status like 'innodb_row_lock%';
对各个状态量的说明如下:
- Innodb_row_lock_current_waits:当前正在等待锁定的数量;
- Innodb_row_lock_time:从系统启动到现在锁定总时间长度;(等待总时长)
- Innodb_row_lock_time_avg:每次等待所花平均时间;(等待平均时长)
- Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;
- Innodb_row_lock_waits:系统启动后到现在总共等待的次数;(等待总次数)
其他监控方法:
MySQL把事务和锁的信息记录在了information_schema
库中,涉及到的三张表分别是INNODB_TRX
、INNODB_LOCKS
和INNODB_LOCK_WAITS
。
MySQL5.7
及之前,可以通过information_schema.INNODB_LOCKS查看事务的锁情况,但只能看到阻塞事 务的锁;如果事务并未被阻塞,则在该表中看不到该事务的锁情况。
MySQL8.0删除了information_schema.INNODB_LOCKS,添加了performance_schema.data_locks
,可以通过performance_schema.data_locks查看事务的锁情况,和MySQL5.7及之前不同,performance_schema.data_locks
不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁。
同时,information_schema.INNODB_LOCK_WAITS也被performance_schema.data_lock_waits
所代替。
我们模拟一个锁等待的场景,以下是从这三张表收集的信息
锁等待场景,我们依然使用记录锁中的案例,当事务 2 进行等待时,查询情况如下:
( 1 )查询正在被锁阻塞的sql语句。
SELECT * FROM information_schema.INNODB_TRX\G;
重要属性代表含义已在上述中标注。
( 2 )查询锁等待情况
SELECT * FROM data_lock_waits\G;
( 3 )查询锁的情况
mysql > SELECT * from performance_schema.data_locks\G;
多版本并发控制
什么是MVCC
MVCC (Multiversion Concurrency Control)
,多版本并发控制。顾名思义,MVCC
是通过数据行的多个版本管理来实现数据库的并发控制
。这项技术使得在InnoDB
的事务隔离级别下执行一致性读
操作有了保证。换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值,这样在做查询的时候就不用等待另一个事务释放锁。
2. 快照读与当前读
MVCC
在MySQL InnoDB
中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突
,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读
,而这个读指的就是快照读
, 而非当前读
。当前读实际上是一种加锁的操作,是悲观锁的实现。而MVCC本质是采用乐观锁思想
的一种方式。
2. 1 快照读
快照读又叫一致性读,读取的是快照数据。 不加锁的简单的 SELECT 都属于快照读 ,即不加锁的非阻塞读;比如这样:
SELECT * FROM player WHERE ...
之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于MVCC
,它在很多情况下,避免了加锁操作,降低了开销。
既然是基于多版本,那么快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。
2. 2 当前读
当前读读取的是记录的最新版本(最新数据,而不是历史版本的数据),读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。加锁的 SELECT,或者对数据进行增删改都会进行当前读。比如:
SELECT * FROM student LOCK IN SHARE MODE; # 共享锁
SELECT * FROM student FOR UPDATE; # 排他锁
INSERT INTO student values ... # 排他锁
DELETE FROM student WHERE ... # 排他锁
UPDATE student SET ... # 排他锁
隐藏字段、Undo Log版本链
回顾一下undo
日志的版本链,对于使用InnoDB
存储引擎的表来说,它的聚簇索引记录中都包含两个必 要的隐藏列(字段)。
trx_id
:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id
隐藏列。roll_pointer
:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志
中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
设插入该记录的事务id
为8
,那么此刻该条记录的示意图如下所示:
insert undo
只在事务回滚时起作用,当事务提交后,该类型的undo
日志就没用了,它占用的UndoLog Segment
也会被系统回收(也就是该undo
日志占用的Undo
页面链表要么被重用,要么被释放)。
假设之后两个事务id分别为 10 、 20 的事务对这条记录进行UPDATE操作,操作流程如下:
发生时间 顺序 | 事务10 | 事务20 |
---|---|---|
1 | BEGIN; | |
2 | BEGIN; | |
3 | UPDATE student SET name="李四" WHERE id=1; | |
4 | UPDATE student SET name="王五" WHERE id=1; | |
5 | COMMIT; | |
6 | UPDATE student SET name="钱七" WHERE id=1; | |
7 | UPDATE student SET name="宋八" WHERE id=1; | |
8 | COMMIT; |
能不能在两个事务中交叉更新同一条记录呢?不能!这不就是一个事务修改了另一个未提交事务修改过的数据,脏写。
InnoDB使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新了某条记录后,就会给这条记录加锁,另一个事务再次更新时就需要等待第一个事务提交了,把锁释放之后才可以继续更新。
每次对记录进行改动,都会记录一条undo
日志,每条undo日志也都有一个roll_pointer
属性(INSERT
操作对应的undo
日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志
都连起来,串成一个链表:
对该记录每次更新后,都会将旧值放到一条undo日志
中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer
属性连接成一个链表,我们把这个链表称之为版本链
,版本链的头节点就是当前记录最新的值。
每个版本中还包含生成该版本时对应的事务id
。
4. MVCC实现原理之ReadView
MVCC 的实现依赖于: 隐藏字段、Undo Log、Read View 。
4.1 什么是ReadView
在MVCC机制中,多个事务对同一个行记录进行更新会产生多个历史快照,这些历史快照保存在Undo Log
里。如果一个事务想要查询这个行记录,需要读取哪个版本的行记录呢?这时就需要用到ReadView
了,它帮我们解决了行的可见性问题。
ReadView
就是事务在使用MVCC
机制进行快照读操作时产生的读视图。当事务启动时,会生成数据库系统当前的一个快照,InnoDB
为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID
(“活跃"指的就是,启动了但还没提交)。
4.2 设计思路
使用READ UNCOMMITTED
隔离级别的事务,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。
使用SERIALIZABLE
隔离级别的事务,InnoDB规定使用加锁的方式来访问记录。
使用READ COMMITTED
和REPEATABLE READ
隔离级别的事务,都必须保证读到已经提交了的事务修改过的记录。假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是需要判断一下版本链中的哪个版本是当前事务可见的,这是ReadView
要解决的主要问题。
这个ReadView
中主要包含 4 个比较重要的内容,分别如下:
-
creator_trx_id
,创建这个Read View
的事务 ID。说明:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为 0 。
-
trx_ids
,表示在生成ReadView
时当前系统中活跃的读写事务的事务id列表
。 -
up_limit_id
,活跃的事务中最小的事务ID
。 -
low_limit_id
,表示生成ReadView时系统中应该分配给下一个事务的id
值。low_limit_id
是系统最大的事务id值,这里要注意是系统中的事务id,需要区别于正在活跃的事务ID。
注意:
low_limit_id
并不是trx_ids
中的最大值,事务id是递增分配的。比如,现在有id为 1 ,2 , 3 这三个事务,之后id为 3 的事务提交了。那么一个新的读事务在生成ReadView时,trx_ids
就包括 1 和 2 ,up_limit_id
的值就是 1 ,low_limit_id
的值就是 4 。
4.3 ReadView的规则
有了这个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见。
-
如果被访问版本的
trx_id
属性值与ReadView
中的creator_trx_id
值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。 -
如果被访问版本的
trx_id
属性值小于ReadView
中的up_limit_id
值,表明生成该版本的事务在当前事务生成ReadView
前已经提交,所以该版本可以被当前事务访问。 -
如果被访问版本的
trx_id
属性值大于或等于ReadView
中的low_limit_id
值,表明生成该版本的事务在当前事务生成ReadView
后才开启,所以该版本不可以被当前事务访问。 -
如果被访问版本的
trx_id
属性值在ReadView
的up_limit_id
和low_limit_id
之间,那就需要判断一下trx_id属性值是不是在trx_ids列表中。- 如果在,说明创建
ReadView
时生成该版本的事务还是活跃的,该版本不可以被访问。 - 如果不在,说明创建
ReadView
时生成该版本的事务已经被提交,该版本可以被访问。
- 如果在,说明创建
4. 4 MVCC整体操作流程
了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过MVCC
找到它:
- 首先获取事务自己的版本号,也就是事务 ID;
- 生成
ReadView
; - 查询得到的数据,然后与
ReadView
中的事务版本号进行比较; - 如果不符合
ReadView
规则,就需要从Undo Log
中获取历史快照; - 最后返回符合规则的数据。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
lnnoDB
中,MVCC
是通过Undo Log + Read View
进行数据读取,Undo Log
保存了历史快照,而Read View
规则帮我们判断当前版本的数据是否可见。
在隔离级别为读已提交(Read Committed
)时,一个事务中的每一次 SELECT
查询都会重新获取一次Read View
。
如表所示:
事务 | 说明 |
---|---|
begin; | |
select * from student where id >2; | 获取一次Read View |
......... | |
select * from student where id >2; | 获取一次Read View |
commit; |
注意,此时同样的查询语句都会重新获取一次 Read View,这时如果 Read View 不同,就可能产生不可重复读或者幻读的情况。
当隔离级别为可重复读的时候,就避免了不可重复读,这是因为一个事务只在第一次 SELECT 的时候会获取一次 Read View,而后面所有的 SELECT 都会复用这个 Read View,如下表所示:
MVCC只能在READ COMMITTED和REPEATABLE READ两个隔离级别下工作。接下来看一下READ COMMITTED
和REPEATABLE READ
所谓的生成ReadView的时机不同到底不同在哪里。
5. 1 READ COMMITTED隔离级别下
READ COMMITTED :每次读取数据前都生成一个ReadView 。
现在有两个事务id分别为 10
、 20
的事务在执行:
# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id= 1 ;
UPDATE student SET name="王五" WHERE id= 1 ;
# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...
说明:事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在事务2中更新些别的表的记录,目的是让它分配事务id。
此刻,表student 中id为 1 的记录得到的版本链表如下所示:
假设现在有一个使用READ COMMITTED隔离级别的事务开始执行:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 10、 20 未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'张三'
这个SELECT1的执行过程如下:
步骤1: 在执行SELECT
语句时会先生成一个ReadView
, ReadView
的 trx_ids
列表的内容就是[10,20]
,up_limit_id
为10, low_limit_id
为21, creator_trx_id
为0。
步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name
的内容是'王五
',该版本的trx_id
值为10
,在trx_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。
步骤3:下一个版本的列name
的内容是'李四
',该版本的trx_id
值也为10
,也在trx_ids
列表内,所以也不符合要求,继续跳到下一个版本。
步骤4:下一个版本的列name
的内容是'张三
',该版本的trx_id
值为8,小于ReadView中的up_limit_id
值10
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为‘张三
'的记录。
之后,我们把事务id
为 10
的事务提交一下:
# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id= 1 ;
UPDATE student SET name="王五" WHERE id= 1 ;
COMMIT;
然后再到事务id为 20 的事务中更新一下表student中id为 1 的记录:
# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...
UPDATE student SET name="钱七" WHERE id= 1 ;
UPDATE student SET name="宋八" WHERE id= 1 ;
此刻,表student中id为 1 的记录的版本链就长这样:
然后再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个id为 1 的记录,如下:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 10、 20 均未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'张三'
# SELECT2:Transaction 10提交,Transaction 20未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'王五'
这个SELECT2
的执行过程如下:
步骤1:在执行SELECT
语句时会又会单独生成一个ReadView
,该ReadView的trx_ids列表的内容就是[20
],up_limitid
为.20,low_limit_id
为21, creator_trx_id
为0
。
步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name
的内容是‘宋八
‘,该版本的trx_id
值为20,在trx_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。
步骤3:下一个版本的列name
的内容是‘钱七
',该版本的trx_id
值为28
,也在trx_ids
列表内,所以也不符合要求,继续跳到下一个版本。
步骤4:下一个版本的列name
的内容是'王五
',该版本的trx_id
值为10,小于ReadView
中的up_limit_id
值20,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为‘王五
‘的记录。
以此类推,如果之后事务id为20的记录也提交了,再次在使用READ COMMITTED隔离级别的事务中查询表student中id值为1的记录时,得到的结果就是‘宋八'了,具体流程我们就不分析了。
强调: 使用
READ COMMITTED
隔离级别的事务在每次查询开始时都会生成一个独立的ReadView
。
5. 2 REPEATABLE READ隔离级别下
使用REPEATABLE READ
隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView
,之后的查询就不会重复生成了。
比如,系统里有两个事务id分别为 10 、 20 的事务在执行:
# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id= 1 ;
UPDATE student SET name="王五" WHERE id= 1 ;
# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...
此刻,表student 中id为 1 的记录得到的版本链表如下所示:
假设现在有一个使用
REPEATABLE READ
隔离级别的事务开始执行:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 10、 20 未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'张三'
这个SELECT1的执行过程如下(第一个ReadView和读已提交是一样的)
:
步骤1: 在执行SELECT语句时会先生成一个ReadView
, ReadView的 trx_ids
列表的内容就是[10,20]
,up_limit_id
为10, low_limit_id
为21, creator_trx_id
为0。
步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name
的内容是'王五
',该版本的trx_id
值为10
,在trx_ids
列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
步骤3:下一个版本的列name
的内容是'李四
',该版本的trx_id
值也为10
,也在trx_ids
列表内,所以也不
符合要求,继续跳到下一个版本。
步骤4:下一个版本的列name
的内容是'张三
',该版本的trx_id
值为8,小于ReadView中的up_limit_id
值10
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为‘张三
'的记录。
之后,我们把事务id
为 10
的事务提交一下:
# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id= 1 ;
UPDATE student SET name="王五" WHERE id= 1 ;
COMMIT;
然后再到事务id为 20 的事务中更新一下表student中id为 1 的记录:
# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...
UPDATE student SET name="钱七" WHERE id= 1 ;
UPDATE student SET name="宋八" WHERE id= 1 ;
此刻,表student 中id为 1 的记录的版本链长这样:
然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为 1 的记录,如下:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 10、 20 均未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'张三'
# SELECT2:Transaction 10提交,Transaction 20未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值仍为'张三'
这个SELECT2
的执行过程如下:
步骤1:在执行SELECT
语句时会继续使用之前的ReadView
,该ReadView的trx_ids列表的内容就是[10,20],
up_limit_id为10,
low_limit_id为21, ``creator_trx_id
为0。
步骤2:然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'宋八',该版本的trx_id值为20,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
步骤3:下一个版本的列name的内容是'钱七',该版本的trx_id值为20,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。
步骤4∶下一个版本的列name的内容是'王五',该版本的trx_id值为10,而trx_ids列表中是包含值为10的事务id的,所以该版本也不符合要求,同理下一个列name的内容是'李四’的版本也不符合要求。继续跳到下一个版本。
步骤5∶下一个版本的列name的内容是‘张三',该版本的trx_id值为80,小于ReadView中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为‘张三'的记录。
两次SELECT查询得到的结果是重复的,记录的列c值都是‘张三',这就是可重复读的含义。如果我们之后再把事务id为20的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,得到的结果还是‘张三',具体执行过程大家可以自己分析一下。
5. 3 如何解决幻读
接下来说明InnoDB
是如何解决幻读的。
假设现在表 student
中只有一条数据,数据内容中,主键 id=1
,隐藏的 trx_id=10
,它的 undo log
如下图所示。
假设现在有事务 A 和事务 B 并发执行,事务 A 的事务 id
为 20 ,事务 B 的事务 id
为 30 。
步骤 1 :事务 A 开始第一次查询数据,查询的 SQL 语句如下。
select * from student where id >= 1 ;
在开始查询之前,MySQL 会为事务 A 产生一个 ReadView,此时 ReadView 的内容如下:trx_ids=[20,30],up_limit_id=20,low_limit_id=31,creator_trx_id=20
。
由于此时表 student 中只有一条数据,且符合 where id>=1 条件,因此会查询出来。然后根据 ReadView机制,发现该行数据的trx_id=10,小于事务 A 的 ReadView 里 up_limit_id,这表示这条数据是事务 A 开启之前,其他事务就已经提交了的数据,因此事务 A 可以读取到。
结论:事务 A 的第一次查询,能读取到一条数据,id=1。
步骤 2 :接着事务 B(trx_id=30),往表 student 中新插入两条数据,并提交事务。
insert into student(id,name) values( 2 ,'李四');
insert into student(id,name) values( 3 ,'王五');
此时表student 中就有三条数据了,对应的 undo 如下图所示:
步骤 3 :接着事务 A 开启第二次查询,根据可重复读隔离级别的规则,此时事务 A 并不会再重新生成ReadView。此时表 student 中的 3 条数据都满足 where id>=1 的条件,因此会先查出来。然后根据ReadView 机制,判断每条数据是不是都可以被事务 A 看到。
1 )首先 id=1 的这条数据,前面已经说过了,可以被事务 A 看到。
2 )然后是 id=2 的数据,它的 trx_id=30,此时事务 A 发现,这个值处于 up_limit_id 和 low_limit_id 之 间,因此还需要再判断 30 是否处于 trx_ids 数组内。由于事务 A 的 trx_ids=[20,30],因此在数组内,这表 示 id=2 的这条数据是与事务 A 在同一时刻启动的其他事务提交的,所以这条数据不能让事务 A 看到。
3 )同理,id=3 的这条数据,trx_id 也为 30 ,因此也不能被事务 A 看见。
结论:最终事务 A 的第二次查询,只能查询出 id=1 的这条数据。这和事务 A 的第一次查询的结果是一样 的,因此没有出现幻读现象,所以说在 MySQL 的可重复读隔离级别下,不存在幻读问题。
6. 总结
这里介绍了MVCC
在READ COMMITTD
、REPEATABLE READ
这两种隔离级别的事务在执行快照读操作时访问记录的版本链的过程。这样使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
核心点在于 ReadView 的原理,READ COMMITTD
、REPEATABLE READ
这两个隔离级别的一个很大不同就是生成ReadView的时机不同:
READ COMMITTD
在每一次进行普通SELECT操作前都会生成一个ReadViewREPEATABLE READ
只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。
说明: 我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCc服务的。
通过MVCC我们可以解决:
读写之间阻塞的问题
。通过MVCC可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事 务并发处理能力。降低了死锁的概率
。这是因为MVCC采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁 定必要的行。解决快照读的问题
。当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结 果,而不能看到这个时间点之后事务提交的更新结果。
其他数据库日志
对于线上数据库应用系统,突然遭遇数据库宕机
怎么办?在这种情况下,定位宕机的原因就非常关键。我们可以查看数据库的错误日志
。因为日志中记录了数据库运行中的诊断信息,包括了错误、警告和注释等信息。比如:从日志中发现某个连接中的SQL操作发生了死循环,导致内存不足,被系统强行终止了。明确了原因,处理起来也就轻松了,系统很快就恢复了运行。
除了发现错误,日志在数据复制、数据恢复、操作审计,以及确保数据的永久性和一致性等方面,都有着不可替代的作用。
MySQL支持的日志
1. 1 日志类型
MySQL有不同类型的日志文件,用来存储不同类型的日志,分为二进制日志
、错误日志
、通用查询日志
和慢查询日志
,这也是常用的 4 种。MySQL 8又新增两种支持的日志:中继日志和数据定义语句日志。
这 6 类日志分别为:
- 慢查询日志: 记录所有执行时间超过
long_query_time
的所有查询,方便我们对查询进行优化。 - 通用查询日志: 记录所有连接的起始时间和终止时间,以及连接发送给数据库服务器的所有指令,对我们复原操作的实际场景、发现问题,甚至是对数据库操作的审计都有很大的帮助。
- 错误日志: 记录
MySQL
服务的启动、运行或停止MySQL服务时出现的问题,方便我们了解服务器的状态,从而对服务器进行维护。 - 二进制日志: 记录所有更改数据的语句,可以用于主从服务器之间的数据同步,以及服务器遇到故障时数据的无损失恢复。
- 中继日志: 用于主从服务器架构中,从服务器用来存放主服务器二进制日志内容的一个中间文件。从服务器通过读取中继日志的内容,来同步主服务器上的操作。
- 数据定义语句日志: 记录数据定义语句执行的元数据操作。
除二进制日志外,其他日志都是文本文件
。默认情况下,所有日志创建于MySQL数据目录
中。
1. 2 日志的弊端
- 日志功能会
降低MySQL数据库的性能
。例如,在查询非常频繁的MysQL数据库系统中,如果开启了通用查询日志和慢查询日志,MySQL数据库会花费很多时间记录日志。 - 日志会占用
大量的磁盘空间
。对于用户量非常大、操作非常频繁的数据库,日志文件需要的存储空间设置比数据库文件需要的存储空间还要大
2. 慢查询日志(slow query log)
3. 通用查询日志(general query log)
通用查询日志用来记录用户的所有操作
,包括启动和关闭MySQL服务、所有用户的连接开始时间和截止时间、发给 MySQL 数据库服务器的所有 SQL 指令等。当我们的数据发生异常时, 查看通用查询日志,还原操作时的具体场景 ,可以帮助我们准确定位问题。
3. 1 问题场景
在电商系统中,购买商品并且使用微信支付完成以后,却发现支付中心的记录并没有新增,此时用户再次使用支付宝支付,就会出现重复支付
的问题。但是当去数据库中查询数据的时候,会发现只有一条记录存在。那么此时给到的现象就是只有一条支付记录,但是用户却支付了两次。
我们对系统进行了仔细检查,没有发现数据问题,因为用户编号和订单编号以及第三方流水号都是对的。可是用户确实支付了两次,这个时候,我们想到了检查通用查询日志,看看当天到底发生了什么。
查看之后,发现: 1月1日下午2点,用户使用微信支付完以后,但是由于网络故障,支付中心没有及时收到微信支付的回调通知,导致当时没有写入数据。1月1日下午2点30,用户又使用支付宝支付,此时记录更新到支付中心。1月1日晚上9点,微信的回调通知过来了,但是支付中心已经存在了支付宝的记录,所以只能覆盖记录了。
3.2 查看当前状态
SHOW VARIABLES LIKE '%general%';
3. 3 启动日志
方式 1 :永久性方式
修改my.cnf或者my.ini配置文件来设置。在[mysqld]组下加入log选项,并重启MySQL服务。格式如下:
[mysqld]
general_log=ON
general_log_file=[path[filename]] #日志文件所在目录路径,filename为日志文件名
如果不指定目录和文件名,通用查询日志将默认存储在MySQL数据目录中的hostname.log文件中,hostname表示主机名。
方式 2 :临时性方式
SET GLOBAL general_log=on; # 开启通用查询日志
SET GLOBAL general_log_file=’path/filename’; # 设置日志文件保存位置
对应的,关闭操作SQL命令如下:
SET GLOBAL general_log=off; # 关闭通用查询日志
查看设置后情况:
SHOW VARIABLES LIKE 'general_log%';
3. 4 查看日志
通用查询日志是以文本文件的形式存储在文件系统中的,可以使用文本编辑器直接打开日志文件。每台MySQL服务器的通用查询日志内容是不同的。
- 在Windows操作系统中,使用文本文件查看器;
- 在Linux系统中,可以使用vi工具或者gedit工具查看;
- 在Mac OSX系统中,可以使用文本文件查看器或者vi等工具查看
3. 5 停止日志
方式 1 :永久性
方式 2 :临时性方式
3.6 删除\刷新日志
如果数据的使用非常频繁,那么通用查询日志会占用服务器非常大的磁盘空间。数据管理员可以删除很长时间之前的查询日志,以保证MySQL服务器上的硬盘空间。
手动删除文件
错误日志(error log)
启动日志
在MySQL数据库中,错误日志功能是默认开启的。而且,错误日志无法被禁止。默认情况下,错误日志存储在MySQL数据库的数据文件夹下,名称默认为mysqld.log(Linux系统)或hostname.err(mac系统)。如果需要制定文件名,则需要在my.cnf或者my.ini中做如下配置:
log-error=[path/[filename]] #path为日志文件所在的目录路径,filename为日志文件名
查看日志
MySQL错误日志是以文本文件形式存储的,可以使用文本编辑器直接查看。
查询错误日志的存储路径:
SHOW VARIABLES LIKE 'log_err%';
二进制日志(bin log)
bin log
可以说是MySQL
中比较重要
的日志了,在日常开发及运维过程中,经常会遇到。
bin log
即binary log
,二进制日志文件,也叫作变更日志(update log
)。它记录了数据库所有执行的DDL和DML等数据库更新事件的语句,但是不包含没有修改任何数据的语句(如数据查询语句select、show等)。
它以事件形式
记录并保存在二进制文件
中。通过这些信息,我们可以再现数据更新操作的全过程。
如果想要记录所有语句(例如,为了识别有问题的查询),需要使用通用查询日志。
bin log
主要应用场景:
- 一是用于
数据恢复
,如果MySQL数据库意外停止,可以通过二进制日志文件来查看用户执行了哪些操作,对数据库服务器文件做了哪些修改,然后根据二进制日志文件中的记录来恢复数据库服务器。 - 二是用于
数据复制
,由于日志的延续性和时效性,master
把它的二进制日志传递给slaves
来达到master-slave
数据一致的目的。
可以说MySQL数据库的数据备份、主备、主主、主从都离不开bin log
,需要依靠bin log
来同步数据,保证数据一致性。
查看默认情况
查看记录二进制日志是否开启:在MySQL8中默认情况下,二进制文件是开启的。
mysql> show variables like '%log_bin%';
log_bin_trust_function_creators
防止主机和从机函数产生不一样。例如now()
日志参数设置
提示:
log-bin=mysql-bin
#打开日志(主机需要打开),这个mysql-bin
也可以自定义,这里也可以加上路径,- binlog_expire_logs_seconds:此参数控制二进制日志文件保留的时长单位是秒,默认2592000 30天 --14400 4小时;86400 1天; 259200 3天;
- max_binlog_size:控制单个二进制日志大小,当前日志文件大小超过此变量时,执行切换动作。此参数的
最大和默认值是1GB
,该设置并不能严格控制Binlog的大小
,尤其是Binlog比较靠近最大值而又遇到一个比较大事务时,为了保证事务的完整性,可能不做切换日志的动作只能将该事务的所有SQL都记录进当前日志,直到事务结束。一般情况下可采取默认值。
查看日志
当MySQL创建二进制日志文件时,先创建一个以“filename
”为名称、以“.index”
为后缀的文件,再创建一个以“filename
”为名称、以“.000001
”为后缀的文件。
MySQL服务重新启动一次,以“.000001”
为后缀的文件就会增加一个,并且后缀名按 1 递增。即日志文件的数与MySQL服务启动的次数相同;如果日志长度超过了max_binlog_size的上限(默认是1GB),就会创建一个新的日志文件。
使用日志恢复数据
mysqlbinlog
恢复数据的语法如下:
mysqlbinlog [option] filename|mysql –uuser -ppass;
这个命令可以这样理解:使用mysqlbinlog
命令来读取filename
中的内容,然后使用mysql
命令将这些内容 恢复到数据库中。 filename
:是日志文件名。 option
:可选项,比较重要的两对option
参数是--start-date、--stop-date
和 --start-position、-- stop-position
。
删除二进制日志
MySQL的二进制文件可以配置自动删除,同时MySQL也提供了安全的手动删除二进制文件的方法。 PURGE MASTER LOGS 只删除指定部分的二进制日志文件, RESET MASTER 删除所有的二进制日志文 件。
写入机制
binlog
的写入时机也非常简单,事务执行过程中,先把日志写到 binlog cache ,事务提交的时候,再把binlog cache
写到binlog
文件中。因为一个事务的binlog
不能被拆开,无论这个事务多大,也要确保一 次性写入,所以系统会给每个线程分配一个块内存作为binlog cache
。
binlog与redolog对比
redo log
它是 物理日志 ,记录内容是“在某个数据页上做了什么修改”,属于 InnoDB
存储引擎层产生 的。 而 binlog
是 逻辑日志 ,记录内容是语句的原始逻辑,类似于“给 ID=2
这一行的 c 字段加 1”,属于 MySQL Server
层。
两阶段提交
在执行更新语句过程,会记录redo log
与binlog
两块日志,以基本的事务为单位,redo log
在事务执行过程 中可以不断写入,而binlog只有在提交事务时才写入,所以redo log
与binlog
的 写入时机 不一样。
中继日志(relay log)
中继日志只在主从服务器架构的从服务器上存在。从服务器为了与主服务器保持一致,要从主服务器读取二进制日志的内容,并且把读取到的信息写入 本地的日志文件 中,这个从服务器本地的日志文件就叫 中继日志 。然后,从服务器读取中继日志,并根据中继日志的内容对从服务器的数据进行更新,完成主从服务器的 数据同步 。
搭建好主从服务器之后,中继日志默认会保存在从服务器的数据目录下。
文件名的格式是: 从服务器名 -relay-bin.序号 。中继日志还有一个索引文件: 从服务器名 -relaybin.index ,用来定位当前正在使用的中继日志。
主从复制
一般应用对数据库而言都是“ 读多写少 ”,也就说对数据库读取数据的压力比较大,有一个思路就 是采用数据库集群的方案,做 主从架构 、进行 读写分离 ,这样同样可以提升数据库的并发处理能力。但 并不是所有的应用都需要对数据库进行主从架构的设置,毕竟设置架构本身是有成本的。
如果我们的目的在于提升数据库高并发访问的效率,那么首先考虑的是如何 优化SQL和索引 ,这种方式 简单有效;其次才是采用 缓存的策略 ,比如使用 Redis
将热点数据保存在内存数据库中,提升读取的效率;最后才是对数据库采用 主从架构 ,进行读写分离。
主从复制的作用
读写分离
数据备份
具有高可用性
主从复制的原理
三个线程 实际上主从同步的原理就是基于 binlog
进行数据同步的。在主从复制过程中,会基于 3 个线程来操作,一个主库线程,两个从库线程。
二进制日志转储线程 (Binlog dump thread
)是一个主库线程。当从库线程连接的时候, 主库可以将二进制日志发送给从库,当主库读取事件(Event
)的时候,会在 Binlog
上加锁 ,读取完成之后,再将锁释放掉。
从库 I/O线程会连接到主库,向主库发送请求更新 Binlog
。这时从库的 I/O
线程就可以读取到主库的二进制日志转储线程发送的 Binlog 更新部分,并且拷贝到本地的中继日志 (Relay log) 。
从库SQL线程会读取从库中的中继日志,并且执行日志中的事件,将从库中的数据与主库保持同步。
复制
步骤1: Master
将写操作记录到二进制日志( binlog
)。
步骤2: Slave
将 Master
的binary log events
拷贝到它的中继日志( relay log
);
步骤3: Slave
重做中继日志中的事件,将改变应用到自己的数据库中。 MySQL
复制是异步的且串行化 的,而且重启后从 接入点 开始复制。
复制的最大问题: 延时
复制的基本原则
- 每个 Slave 只有一个 Master
- 每个 Slave 只能有一个唯一的服务器ID
- 每个 Master 可以有多个 Slave