MySQL InnoDB Cluster环境搭建和简单测试

InnoDB Cluster初印象

记得MySQL Group Replicatioin 刚开始的时候,MySQL界很是轰动,等待了多年,终于有了官方的这个高可用解决方案。你要说还有一些方案补充,比如MySQL Cluster,MySQL Proxy,这些的使用率个人感觉还是不高,也就是经受的考验还不够,原因有很多,就不赘述了。

不久,我和一个MySQL DBA有了下面的一个基本对话。

我: MySQL GR GA之后,里面的自动切换功能确实很赞,能够做到读写分离,原本MHA的方案现在MGR也可以做了。

MySQL DBA:如果数据库发生了故障,这个自动切换的过程,其实对于应用不是透明的,因为读写节点相当于漂移到了另外一台服务器上,除非再做个中间件。

我:单纯MGR目前还做不了这个,它目前只是保证数据库层面的这种切换和高可用。

MySQL DBA:所以说MGR的企业级应用还是需要一些辅助,这样才算是一个完整的解决方案。

不久,MySQL InnoDB Cluster推出,我觉得这个方案想比原来的MGR更进一步,说实话,我很看好这个方案,尽管目前愿意真正去用的用户确实不多。

如果你看一下官方的这个架构图,就会发现,MGR本身就是Innodb Cluster的一部分,还有两个组件,MySQL Shell,MySQL Router,这三板斧就是InnoDB Cluster的一个核心组件,而正如我之前所说,可以看到MySQL的一个格局和定位,他正在很努力去解决以前诟病的问题。

未分类

安装前先保证Python满足要求

要安装InnoDB Cluster,环境的一个基本要求就是Python,我看了下,很多默认的系统版本是2.6,而它的最低要求是2.7及以上,所以还是需要提前准备下这个部分。

如果你的系统是Python 2.6版本的,可以考虑升级到2.7,参考如下的方法。

下载安装包,部署

wget http://python.org/ftp/python/2.7/Python-2.7.tar.bz2     --no-check-certificate
./configure
make all
make install
make clean
make distclean

查看Python的版本

# /usr/local/bin/python2.7 -V
Python 2.7

做基本的环境设置,替换旧的Python

mv /usr/bin/python /usr/bin/python2.6
ln -s /usr/local/bin/python2.7 /usr/bin/python

sandbox安装部署InnoDB Cluster

搭建InnoDB Cluster显而易见需要多台服务器,而如果在一台服务器上练习测试,也是全然没有问题,如果想更快更方便的测试模拟,还可以使用sandbox来做,首先你得有sandbox,接着InnoDB Cluster的三大组件是MGR,MySQL Shell,MySQL Router,所以你可以从官网直接下载下来。

然后我们开启安装之旅。

使用MySQL Shell的命令mysqlsh开始部署,创建一个端口为3310的实例

mysql-js> dba.deploySandboxInstance(3310)
A new MySQL sandbox instance will be created on this host in
/root/mysql-sandboxes/3310

输入密码之后,一个3310端口的MySQL服务就启动了。

Please enter a MySQL root password for the new instance:
Deploying new MySQL instance...
Instance localhost:3310 successfully deployed and started.
Use shell.connect('root@localhost:3310'); to connect to the instance.

接着创建另外两个节点 3320,3330

dba.deploySandboxInstance(3320)
dba.deploySandboxInstance(3330)

我们切换到3310的MySQL实例,准备开始创建Cluster

mysql-js>  connect root@localhost:3310
Creating a Session to 'root@localhost:3310'
Enter password:
Closing old connection...
Classic Session successfully established. No default schema selected.

定义一个Cluster变量,节点1就开启了Cluster创建之旅,可以从下面的信息看出,至少需要3个节点

mysql-js>  var cluster = dba.createCluster('testCluster')
A new InnoDB cluster will be created on instance 'root@localhost:3310'.
Creating InnoDB cluster 'testCluster' on 'root@localhost:3310'...
Adding Seed Instance...
Cluster successfully created. Use Cluster.addInstance() to add MySQL instances.
At least 3 instances are needed for the cluster to be able to withstand up to  one server failure.

接着把另外两个节点加入进来,先加入端口为3320的节点

mysql-js> cluster.addInstance('root@localhost:3320')
A new instance will be added to the InnoDB cluster. Depending on the amount of
data on the cluster this might take from a few seconds to several hours.
Please provide the password for 'root@localhost:3320':
Adding instance to the cluster ...加入端口为3330的节点,日志和节点2相似。

mysql-js> cluster.addInstance('root@localhost:3330')

这个时候Cluster就创建好了。

这个时候,我们再配置一下MySQL Router,创建个软链接,保证能够正常调用。

# ln -s /home/innodb_cluster/mysql-router-2.1.3-linux-glibc2.12-x86-64bit/bin/mysqlrouter   /usr/bin/mysqlroute
# which mysqlroute
/usr/bin/mysqlroute

配置MySQL Router的启动节点为端口3310的实例

# mysqlrouter –bootstrap root@localhost:3310 –user=mysql

这个时候还是要输入密码,成功之后,这个绑定就打通了。

Please enter MySQL password for root:
Bootstrapping system MySQL Router instance...
MySQL Router  has now been configured for the InnoDB cluster 'testCluster'.
The following connection information can be used to connect to the cluster.
Classic MySQL protocol connections to cluster 'testCluster':
- Read/Write Connections: localhost:6446
- Read/Only Connections: localhost:6447
X protocol connections to cluster 'testCluster':
- Read/Write Connections: localhost:64460
- Read/Only Connections: localhost:64470

可以从上面的日志看出来,分配的读写端口是6446,只读端口是6447,还有x协议连接的端口为64460,64470

启动MySQL Router

# mysqlrouter &
[1] 2913

如果对MySQL Router还有些疑问,可以看看安装目录下,会生成下面的配置文件,我们就看里面的.conf文件,里面的一部分内容如下:

[routing:testCluster_default_rw]
bind_address=0.0.0.0
bind_port=6446
destinations=metadata-cache://testCluster/default?role=PRIMARY
mode=read-write
protocol=classic

验证测试

我们尝试使用6446来连接登录,这个时候就通过MySQL Shell开启了连接入口,MySQL Router做了转接,连接到了里面的读写节点3310

# mysqlsh --uri root@localhost:6446
Creating a Session to 'root@localhost:6446'
Enter password:
Classic Session successfully established. No default schema selected.
Welcome to MySQL Shell 1.0.9

切换到sql模式,查看端口就知道是哪个节点了。

mysql-js> sql
Switching to SQL mode... Commands end with ;
mysql-sql> select @@port;
+--------+
| @@port |
+--------+
|   3310 |
+--------+
1 row in set (0.00 sec)

如果切换为脚本模式查看实例的状态,可以使用里面定义的API来做,输出都是JSON串。

mysql-js> dba.configureLocalInstance('[email protected]:3310')
Please provide the password for '[email protected]:3310':
Detected as sandbox instance.
Validating MySQL configuration file at: /root/mysql-sandboxes/3310/my.cnf
Validating instance...
The instance '127.0.0.1:3310' is valid for Cluster usage
You can now use it in an InnoDB Cluster.
{
"status": "ok"
}如果查看Cluster的信息,可以看到下面的读写节点,只读节点的状态信息

mysql-js> dba.getCluster()
<Cluster:testCluster>

得到Cluster的信息

var cluster = dba.getCluster()
mysql-js> cluster.status()
{
"clusterName": "testCluster",
"defaultReplicaSet": {
"name": "default",
"primary": "localhost:3310",
"status": "OK",
"statusText": "Cluster is ONLINE and can tolerate up to ONE failure.",
"topology": {
"localhost:3310": {
"address": "localhost:3310",
"mode": "R/W",
"readReplicas": {},
"role": "HA",
"status": "ONLINE"
},
"localhost:3320": {
"address": "localhost:3320",
"mode": "R/O",
"readReplicas": {},
"role": "HA",
"status": "ONLINE"
},
"localhost:3330": {
"address": "localhost:3330",
"mode": "R/O",
"readReplicas": {},
"role": "HA",
"status": "ONLINE"
}
}
}
}

也可以使用describe得到一些基本的信息

mysql-js> cluster.describe();
{
"clusterName": "testCluster",
"defaultReplicaSet": {
"instances": [
{
"host": "localhost:3310",
"label": "localhost:3310",
"role": "HA"
},
{
"host": "localhost:3320",
"label": "localhost:3320",
"role": "HA"
},
{
"host": "localhost:3330",
"label": "localhost:3330",
"role": "HA"
}
],
"name": "default"
}
}

切换测试

当然光看不练还是假把式,我们切换一下,看看好使不?

模拟一个节点出现问题,可以使用killSandboxInstance方法。

mysql-js> dba.killSandboxInstance(3310)
The MySQL sandbox instance on this host in
/root/mysql-sandboxes/3310 will be killed
Killing MySQL instance...
Instance localhost:3310 successfully killed.

节点被清理了,没有任何进程存在。

# ps -ef|grep mysql|grep 3310
#

我们还是使用6446的端口来统一连接,这个时候就切换到了端口3320的MySQL服务

# mysqlsh --uri root@localhost:6446
mysql-js> sql
Switching to SQL mode... Commands end with ;
mysql-sql> select @@port;
+--------+
| @@port |
+--------+
|   3320 |
+--------+
1 row in set (0.00 sec)

所以切换的部分没有问题,我们再次把“迷失”的节点启动起来。

# mysqlsh --uri root@localhost:6446
mysql-js> dba.startSandboxInstance(3310)
The MySQL sandbox instance on this host in
/root/mysql-sandboxes/3310 will be started
Starting MySQL instance...
Instance localhost:3310 successfully started.

这个时候再次查看Cluster的状态,3320就是主了,3310就是只读节点了。

mysql-js> dba.getCluster()
<Cluster:testCluster>

把节点2纳入到Cluster中

mysql-js> cluster.rejoinInstance('root@localhost:3310')
Rejoining the instance to the InnoDB cluster. Depending on the original
problem that made the instance unavailable, the rejoin operation might not be
successful and further manual steps will be needed to fix the underlying
problem.
Please monitor the output of the rejoin operation and take necessary action if
the instance cannot rejoin.
Please provide the password for 'root@localhost:3310':
Rejoining instance to the cluster ...
The instance 'root@localhost:3310' was successfully rejoined on the cluster.
The instance 'localhost:3310' was successfully added to the MySQL Cluster.
mysql-js>

可以想象如果是一个生产系统,这么多的日志,这个过程真是让人纠结。
最后来一个切换后的Cluster状态

mysql-js> cluster.status()
{
"clusterName": "testCluster",
"defaultReplicaSet": {
"name": "default",
"primary": "localhost:3320",
"status": "OK",
"statusText": "Cluster is ONLINE and can tolerate up to ONE failure.",
"topology": {
"localhost:3310": {
"address": "localhost:3310",
"mode": "R/O",
"readReplicas": {},
"role": "HA",
"status": "ONLINE"
},
"localhost:3320": {
"address": "localhost:3320",
"mode": "R/W",
"readReplicas": {},
"role": "HA",
"status": "ONLINE"
},
"localhost:3330": {
"address": "localhost:3330",
"mode": "R/O",
"readReplicas": {},
"role": "HA",
"status": "ONLINE"
}
}
}
}

MySQL InnoDB存储引擎崩溃恢复原理介绍

前言

数据库系统与文件系统最大的区别在于数据库能保证操作的原子性,一个操作要么不做要么都做,即使在数据库宕机的情况下,也不会出现操作一半的情况,这个就需要数据库的日志和一套完善的崩溃恢复机制来保证。本文仔细剖析了InnoDB的崩溃恢复流程,代码基于5.6分支。

基础知识

  • lsn: 可以理解为数据库从创建以来产生的redo日志量,这个值越大,说明数据库的更新越多,也可以理解为更新的时刻。此外,每个数据页上也有一个lsn,表示最后被修改时的lsn,值越大表示越晚被修改。比如,数据页A的lsn为100,数据页B的lsn为200,checkpoint lsn为150,系统lsn为300,表示当前系统已经更新到300,小于150的数据页已经被刷到磁盘上,因此数据页A的最新数据一定在磁盘上,而数据页B则不一定,有可能还在内存中。

  • redo日志: 现代数据库都需要写redo日志,例如修改一条数据,首先写redo日志,然后再写数据。在写完redo日志后,就直接给客户端返回成功。这样虽然看过去多写了一次盘,但是由于把对磁盘的随机写入(写数据)转换成了顺序的写入(写redo日志),性能有很大幅度的提高。当数据库挂了之后,通过扫描redo日志,就能找出那些没有刷盘的数据页(在崩溃之前可能数据页仅仅在内存中修改了,但是还没来得及写盘),保证数据不丢。

  • undo日志: 数据库还提供类似撤销的功能,当你发现修改错一些数据时,可以使用rollback指令回滚之前的操作。这个功能需要undo日志来支持。此外,现代的关系型数据库为了提高并发(同一条记录,不同线程的读取不冲突,读写和写读不冲突,只有同时写才冲突),都实现了类似MVCC的机制,在InnoDB中,这个也依赖undo日志。为了实现统一的管理,与redo日志不同,undo日志在Buffer Pool中有对应的数据页,与普通的数据页一起管理,依据LRU规则也会被淘汰出内存,后续再从磁盘读取。与普通的数据页一样,对undo页的修改,也需要先写redo日志。

  • 检查点: 英文名为checkpoint。数据库为了提高性能,数据页在内存修改后并不是每次都会刷到磁盘上。checkpoint之前的数据页保证一定落盘了,这样之前的日志就没有用了(由于InnoDB redolog日志循环使用,这时这部分日志就可以被覆盖),checkpoint之后的数据页有可能落盘,也有可能没有落盘,所以checkpoint之后的日志在崩溃恢复的时候还是需要被使用的。InnoDB会依据脏页的刷新情况,定期推进checkpoint,从而减少数据库崩溃恢复的时间。检查点的信息在第一个日志文件的头部。

  • 崩溃恢复: 用户修改了数据,并且收到了成功的消息,然而对数据库来说,可能这个时候修改后的数据还没有落盘,如果这时候数据库挂了,重启后,数据库需要从日志中把这些修改后的数据给捞出来,重新写入磁盘,保证用户的数据不丢。这个从日志中捞数据的过程就是崩溃恢复的主要任务,也可以成为数据库前滚。当然,在崩溃恢复中还需要回滚没有提交的事务,提交没有提交成功的事务。由于回滚操作需要undo日志的支持,undo日志的完整性和可靠性需要redo日志来保证,所以崩溃恢复先做redo前滚,然后做undo回滚。

我们从源码角度仔细剖析一下数据库崩溃恢复过程。整个过程都在引擎初始化阶段完成(innobase_init),其中最主要的函数是innobase_start_or_create_for_mysql,innodb通过这个函数完成创建和初始化,包括崩溃恢复。首先来介绍一下数据库的前滚。

redo日志前滚数据库

前滚数据库,主要分为两阶段,首先是日志扫描阶段,扫描阶段按照数据页的space_id和page_no分发redo日志到hash_table中,保证同一个数据页的日志被分发到同一个哈希桶中,且按照lsn大小从小到大排序。扫描完后,再遍历整个哈希表,依次应用每个数据页的日志,应用完后,在数据页的状态上至少恢复到了崩溃之前的状态。我们来详细分析一下代码。

  • 首先,打开所有的ibdata文件(open_or_create_data_files)(ibdata可以有多个),每个ibdata文件有个flush_lsn在头部,计算出这些文件中的max_flush_lsn和min_flush_lsn,因为ibdata也有可能有数据没写完整,需要恢复,后续(recv_recovery_from_checkpoint_start_func)通过比较checkpont_lsn和这两个值来确定是否需要对ibdata前滚。

  • 接着,打开系统表空间和日志表空间的所有文件(fil_open_log_and_system_tablespace_files),防止出现文件句柄不足,清空buffer pool(buf_pool_invalidate)。接下来就进入最最核心的函数:recv_recovery_from_checkpoint_start_func,注意,即使数据库是正常关闭的,也会进入。

虽然recv_recovery_from_checkpoint_start_func看过去很冗长,但是很多代码都是为了LOG_ARCHIVE特性而编写的,真正数据崩溃恢复的代码其实不多。

  • 首先,初始化一些变量,查看srv_force_recovery这个变量,如果用户设置跳过前滚阶段,函数直接返回。

  • 接着,初始化recv_sys结构,分配hash_table的大小,同时初始化flush list rbtree。recv_sys结构主要在崩溃恢复前滚阶段使用。hash_table就是之前说的用来存不同数据页日志的哈希表,哈希表的大小被初始化为buffer_size_in_bytes/512, 这个是哈希表最大的长度,超过就存不下了,幸运的是,需要恢复的数据页的个数不会超过这个值,因为buffer poll最多(数据库崩溃之前脏页的上线)只能存放buffer_size_in_bytes/16KB个数据页,即使考虑压缩页,最多也只有buffer_size_in_bytes/1KB个,此外关于这个哈希表内存分配的大小,可以参考bug#53122。flush list rbtree这个主要是为了加入插入脏页列表,InnoDB的flush list必须按照数据页的最老修改lsn(oldest_modifcation)从小到大排序,在数据库正常运行时,可以通过log_sys->mutex和log_sys->log_flush_order_mutex保证顺序,在崩溃恢复则没有这种保证,应用数据的时候,是从第一个元素开始遍历哈希表,不能保证数据页按照最老修改lsn(oldest_modifcation)从小到大排序,这样就需要线性遍历flush_list来寻找插入位置,效率太低,因此引入红黑树,加快查找插入的位置。

  • 接着,从ib_logfile0的头中读取checkpoint信息,主要包括checkpoint_lsn和checkpoint_no。由于InnoDB日志是循环使用的,且最少要有2个,所以ib_logfile0一定存在,把checkpoint信息存在里面很安全,不用担心被删除。checkpoint信息其实会写在文件头的两个地方,两个checkpoint域轮流写。为什么要两个地方轮流写呢?假设只有一个checkpoint域,一直更新这个域,而checkpoint域有512字节(OS_FILE_LOG_BLOCK_SIZE),如果刚好在写这个512字节的时候,数据库挂了,服务器也挂了(先不考虑硬件的原子写特性,早期的硬件没有这个特性),这个512字节可能只写了一半,导致整个checkpoint域不可用。这样数据库将无法做崩溃恢复,从而无法启动。如果有两个checkpoint域,那么即使一个写坏了,还可以用另外一个尝试恢复,虽然有可能这个时候日志已经被覆盖,但是至少提高了恢复成功的概率。两个checkpoint域轮流写,也能减少磁盘扇区故障带来的影响。checkpoint_lsn之前的数据页都已经落盘,不需要前滚,之后的数据页可能还没落盘,需要重新恢复出来,即使已经落盘也没关系,因为redo日志时幂等的,应用一次和应用两次都一样(底层实现: 如果数据页上的lsn大于等于当前redo日志的lsn,就不应用,否则应用。

  • checkpoint_no可以理解为checkpoint域写盘的次数,每次刷盘递增1,同时这个值取模2可以用来实现checkpoint_no域的轮流写。正常逻辑下,选取checkpoint_no值大的作为最终的checkpoint信息,用来做后续崩溃恢复扫描的起始点。
    接着,使用checkpoint域的信息初始化recv_sys结构体的一些信息后,就进入日志解析的核心函数recv_group_scan_log_recs,这个函数后续我们再分析,主要作用就是解析redo日志,如果内存不够了,就直接调用应用(recv_apply_hashed_log_recs)日志,然后再接着解析。如果需要应用的日志很少,就仅仅解析分发日志,到recv_recovery_from_checkpoint_finish函数中在应用日志。

  • 接着,依据当前刷盘的数据页状态做一次checkpoint,因为在recv_group_scan_log_recs里可能已经应用部分日志了。至此recv_recovery_from_checkpoint_start_func函数结束。

  • 在recv_recovery_from_checkpoint_finish函数中,如果srv_force_recovery设置正确,就开始调用函数recv_apply_hashed_log_recs应用日志,然后等待刷脏的线程退出(线程是崩溃恢复时临时启动的),最后释放recv_sys的相关资源以及hash_table占用的内存。

  • 至此,数据库前滚结束。接下来,我们详细分析一下redo日志解析函数以及redo日志应用函数的实现细节。

redo日志解析函数

解析函数的最上层是recv_group_scan_log_recs,这个函数调用底层函数(log_group_read_log_seg),按照RECV_SCAN_SIZE(64KB)大小分批读取。读取出来后,首先通过block_no和lsn之间的关系以及日志checksum判断是否读到了日志最后(所以可以看出,并没一个标记在日志头标记日志的有效位置,完全是按照上述两个条件判断是否到达了日志尾部),如果读到最后则返回(之前说了,即使数据库是正常关闭的,也要走崩溃恢复逻辑,那么在这里就返回了,因为正常关闭的checkpoint值一定是指向日志最后),否则则把日志去头掐尾放到一个recv_sys->buf中,日志头里面存了一些控制信息和checksum值,只是用来校验和定位,在真正的应用中没有用。在放到recv_sys->buf之前,需要检验一下recv_sys->buf有没有满(RECV_PARSING_BUF_SIZE,2M),满了就报错(如果上一批解析有不完整的日志,日志解析函数不会分发,而是把这些不完整的日志留在recv_sys->buf中,直到解析到完整的日志)。接下的事情就是从recv_sys->buf中解析日志(recv_parse_log_recs)。日志分两种:single_rec和multi_rec,前者表示只对一个数据页进行一种操作,后者表示对一个或者多个数据页进行多种操作。日志中还包括对应数据页的space_id,page_no,操作的type以及操作的内容(recv_parse_log_rec)。解析出相应的日志后,按照space_id和page_no进行哈希(如果对应的表空间在内存中不存在,则表示表已经被删除了),放到hash_table里面(日志真正存放的位置依然在buffer pool)即可,等待后续应用。这里有几个点值得注意:

  • 如果是multi_rec类型,则只有遇到MLOG_MULTI_REC_END这个标记,日志才算完整,才会被分发到hash_table中。查看代码,我们可以发现multi_rec类型的日志被解析了两次,一次用来校验完整性(寻找MLOG_MULTI_REC_END),第二次才用来分发日志,感觉这是一个可以优化的点。

  • 目前日志的操作type有50多种,每种操作后面的内容都不一样,所以长度也不一样,目前日志的解析逻辑,需要依次解析出所有的内容,然后确定长度,从而定位下一条日志的开始位置。这种方法效率略低,其实可以在每种操作的头上加上一个字段,存储后面内容的长度,这样就不需要解析太多的内容,从而提高解析速度,进一步提高崩溃恢复速度,从结果看,可以提高一倍的速度(从38秒到14秒,详情可以参见bug#82937)。

  • 如果发现checkpoint之后还有日志,说明数据库之前没有正常关闭,需要做崩溃恢复,因此需要做一些额外的操作(recv_init_crash_recovery),比如在错误日志中打印我们常见的“Database was not shutdown normally!”和“Starting crash recovery.”,还要从double write buffer中检查是否发生了数据页半写,如果有需要恢复(buf_dblwr_process),还需要启动一个线程用来刷新应用日志产生的脏页(因为这个时候buf_flush_page_cleaner_thread还没有启动)。最后还需要打开所有的表空间。。注意是所有的表。。。我们在阿里云RDS MySQL的运维中,常常发现数据库hang在了崩溃恢复阶段,在错误日志中有类似“Reading tablespace information from the .ibd files…”字样,这就表示数据库正在打开所有的表,然后一看表的数量,发现有几十甚至上百万张表。。。数据库之所以要打开所有的表,是因为在分发日志的时候,需要确定space_id对应哪个ibd文件,通过打开所有的表,读取space_id信息来确定,另外一个原因是方便double write buffer检查半写数据页。针对这个表数量过多导致恢复过慢的问题,MySQL 5.7做了优化,WL#7142, 主要思想就是在每次checkpoint后,在第一次修改某个表时,先写一个新日志mlog_file_name(包括space_id和filename的映射),来表示对这个表进行了操作,后续对这个表的操作就不用写这个新日志了,当需要崩溃恢复时候,多一次扫描,通过搜集mlog_file_name来确定哪些表被修改过,这样就不需要打开所有的表来确定space_id了。

  • 最后一个值得注意的地方是内存。之前说过,如果有太多的日志已经被分发,占用了太多的内存,日志解析函数会在适当的时候应用日志,而不是等到最后才一起应用。那么问题来了,使用了多大的内存就会出发应用日志逻辑。答案是:buffer_pool_size_in_bytes – 512 * buffer_pool_instance_num * 16KB。由于buffer_pool_instance_num一般不会太大,所以可以任务,buffer pool的大部分内存都被用来存放日志。剩下的那些主要留给应用日志时读取的数据页,因为目前来说日志应用是单线程的,读取一个日志,把所有日志应用完,然后就可以刷回磁盘了,不需要太多的内存。

redo日志应用函数

应用日志的上层函数为recv_apply_hashed_log_recs(应用日志也可能在io_helper函数中进行),主要作用就是遍历hash_table,从磁盘读取对每个数据页,依次应用哈希桶中的日志。应用完所有的日志后,如果需要则把buffer_pool的页面都刷盘,毕竟空间有限。有以下几点值得注意:

  • 同一个数据页的日志必须按照lsn从小到大应用,否则数据会被覆盖。只应用redo日志lsn大于page_lsn的日志,只有这些日志需要重做,其余的忽略。应用完日志后,把脏页加入脏页列表,由于脏页列表是按照最老修改lsn(oldest_modification)来排序的,这里通过引入一颗红黑树来加速查找插入的位置,时间复杂度从之前的线性查找降为对数级别。

  • 当需要某个数据页的时候,如果发现其没有在Buffer Pool中,则会查看这个数据页周围32个数据页,是否也需要做恢复,如果需要则可以一起读取出来,相当于做了一次io合并,减少io操作(recv_read_in_area)。由于这个是异步读取,所以最终应用日志的活儿是由io_helper线程来做的(buf_page_io_complete),此外,为了防止短时间发起太多的io,在代码中加了流量控制的逻辑(buf_read_recv_pages)。如果发现某个数据页在内存中,则直接调用recv_recover_page应用日志。由此我们可以看出,InnoDB应用日志其实并不是单线程的来应用日志的,除了崩溃恢复的主线程外,io_helper线程也会参与恢复。并发线程数取决于io_helper中读取线程的个数。

执行完了redo前滚数据库,数据库的所有数据页已经处于一致的状态,undo回滚数据库就可以安全的执行了。数据库崩溃的时候可能有一些没有提交的事务或者已经提交的事务,这个时候就需要决定是否提交。主要分为三步,首先是扫描undo日志,重新建立起undo日志链表,接着是,依据上一步建立起的链表,重建崩溃前的事务,即恢复当时事务的状态。最后,就是依据事务的不同状态,进行回滚或者提交。

undo日志回滚数据库

在recv_recovery_from_checkpoint_start_func之后,recv_recovery_from_checkpoint_finish之前,调用了trx_sys_init_at_db_start,这个函数做了上述三步中的前两步。

  • 第一步在函数trx_rseg_array_init中处理,遍历整个undo日志空间(最多TRX_SYS_N_RSEGS(128)个segment),如果发现某个undo segment非空,就进行初始化(trx_rseg_create_instance)。整个每个undo segment,如果发现undo slot非空(最多TRX_RSEG_N_SLOTS(1024)个slot),也就行初始化(trx_undo_lists_init)。在初始化undo slot后,就把不同类型的undo日志放到不同链表中(trx_undo_mem_create_at_db_start)。undo日志主要分为两种:TRX_UNDO_INSERT和TRX_UNDO_UPDATE。前者主要是提供给insert操作用的,后者是给update和delete操作使用。之前说过,undo日志有两种作用,事务回滚时候用和MVCC快照读取时候用。由于insert的数据不需要提供给其他线程用,所以只要事务提交,就可以删除TRX_UNDO_INSERT类型的undo日志。TRX_UNDO_UPDATE在事务提交后还不能删除,需要保证没有快照使用它的时候,才能通过后台的purge线程清理。

  • 第二步在函数trx_lists_init_at_db_start中进行,由于第一步中,已经在内存中建立起了undo_insert_list和undo_update_list(链表每个undo segment独立),所以这一步只需要遍历所有链表,重建起事务的状态(trx_resurrect_insert和trx_resurrect_update)。简单的说,如果undo日志的状态是TRX_UNDO_ACTIVE,则事务的状态为TRX_ACTIVE,如果undo日志的状态是TRX_UNDO_PREPARED,则事务的状态为TRX_PREPARED。这里还要考虑变量srv_force_recovery的设置,如果这个变量值为非0,所有的事务都会回滚(即事务被设置为TRX_ACTIVE),即使事务的状态应该为TRX_STATE_PREPARED。重建起事务后,按照事务id加入到trx_sys->trx_list链表中。最后,在函数trx_sys_init_at_db_start中,会统计所有需要回滚的事务(事务状态为TRX_ACTIVE)一共需要回滚多少行数据,输出到错误日志中,类似:5 transaction(s) which must be rolled back or cleaned up。InnoDB: in total 342232 row operations to undo的字样。

  • 第三步的操作在两个地方被调用。一个是在recv_recovery_from_checkpoint_finish的最后,另外一个是在recv_recovery_rollback_active中。前者主要是回滚对数据字典的操作,也就是回滚DDL语句的操作,后者是回滚DML语句。前者是在数据库可提供服务之前必须完成,后者则可以在数据库提供服务(也即是崩溃恢复结束)之后继续进行(通过新开一个后台线程trx_rollback_or_clean_all_recovered来处理)。因为InnoDB认为数据字典是最重要的,必须要回滚到一致的状态才行,而用户表的数据可以稍微慢一点,对外提供服务后,慢慢恢复即可。因此我们常常在会发现数据库已经启动起来了,然后错误日志中还在不断的打印回滚事务的信息。事务回滚的核心函数是trx_rollback_or_clean_recovered,逻辑很简单,只需要遍历trx_sys->trx_list,按照事务不同的状态回滚或者提交即可(trx_rollback_resurrected)。这里要注意的是,如果事务是TRX_STATE_PREPARED状态,那么在InnoDB层,不做处理,需要在Server层依据binlog的情况来决定是否回滚事务,如果binlog已经写了,事务就提交,因为binlog写了就可能被传到备库,如果主库回滚会导致主备数据不一致,如果binlog没有写,就回滚事务。

崩溃恢复相关参数解析

  • innodb_fast_shutdown:
    innodb_fast_shutdown = 0。这个表示在MySQL关闭的时候,执行slow shutdown,不但包括日志的刷盘,数据页的刷盘,还包括数据的清理(purge),ibuf的合并,buffer pool dump以及lazy table drop操作(如果表上有未完成的操作,即使执行了drop table且返回成功了,表也不一定立刻被删除)。
    innodb_fast_shutdown = 1。这个是默认值,表示在MySQL关闭的时候,仅仅把日志和数据刷盘。
    innodb_fast_shutdown = 2。这个表示关闭的时候,仅仅日志刷盘,其他什么都不做,就好像MySQL crash了一样。
    这个参数值越大,MySQL关闭的速度越快,但是启动速度越慢,相当于把关闭时候需要做的工作挪到了崩溃恢复上。另外,如果MySQL要升级,建议使用第一种方式进行一次干净的shutdown。

  • innodb_force_recovery:
    这个参数主要用来控制InnoDB启动时候做哪些工作,数值越大,做的工作越少,启动也更加容易,但是数据不一致的风险也越大。当MySQL因为某些不可控的原因不能启动时,可以设置这个参数,从1开始逐步递增,知道MySQL启动,然后使用SELECT INTO OUTFILE把数据导出,尽最大的努力减少数据丢失。
    innodb_force_recovery = 0。这个是默认的参数,启动的时候会做所有的事情,包括redo日志应用,undo日志回滚,启动后台master和purge线程,ibuf合并。检测到了数据页损坏了,如果是系统表空间的,则会crash,用户表空间的,则打错误日志。
    innodb_force_recovery = 1。如果检测到数据页损坏了,不会crash也不会报错(buf_page_io_complete),启动的时候也不会校验表空间第一个数据页的正确性(fil_check_first_page),表空间无法访问也继续做崩溃恢复(fil_open_single_table_tablespace、fil_load_single_table_tablespace),ddl操作不能进行(check_if_supported_inplace_alter),同时数据库也被不能进行写入操作(row_insert_for_mysql、row_update_for_mysql等),所有的prepare事务也会被回滚(trx_resurrect_insert、trx_resurrect_update_in_prepared_state)。这个选项还是很常用的,数据页可能是因为磁盘坏了而损坏了,设置为1,能保证数据库正常启动。
    innodb_force_recovery = 2。除了设置1之后的操作不会运行,后台的master和purge线程就不会启动了(srv_master_thread、srv_purge_coordinator_thread等),当你发现数据库因为这两个线程的原因而无法启动时,可以设置。
    innodb_force_recovery = 3。除了设置2之后的操作不会运行,undo回滚数据库也不会进行,但是回滚段依然会被扫描,undo链表也依然会被创建(trx_sys_init_at_db_start)。srv_read_only_mode会被打开。
    innodb_force_recovery = 4。除了设置3之后的操作不会运行,ibuf的操作也不会运行(ibuf_merge_or_delete_for_page),表信息统计的线程也不会运行(因为一个坏的索引页会导致数据库崩溃)(info_low、dict_stats_update等)。从这个选项开始,之后的所有选项,都会损坏数据,慎重使用。
    innodb_force_recovery = 5。除了设置4之后的操作不会运行,回滚段也不会被扫描(recv_recovery_rollback_active),undo链表也不会被创建,这个主要用在undo日志被写坏的情况下。
    innodb_force_recovery = 6。除了设置5之后的操作不会运行,数据库前滚操作也不会进行,包括解析和应用(recv_recovery_from_checkpoint_start_func)。

调整InnoDB系统表空间大小

增大InnoDB system tablespace

最简单的增大InnoDB system tablespace大小的方法是在一开始配置的时候就指定为自动扩展. 为innodb_data_file_path参数中的最后一个数据文件指定autoextend选项. InnoDB在空间不足时以64MB为单位自动增加该文件的大小. 可以通过设置innodb_autoextend_increment系统变量的值(以兆字节为单位)来更改增量大小.

您可以通过添加另一个数据文件来扩展系统表空间:

1.关闭MySQL

2.如果上一个数据文件是使用关键字autoextend定义的,则根据实际增长的大小将其定义更改为使用固定大小. 检查数据文件的大小,将其舍入到1024×1024字节(= 1MB)的最接近的倍数,并在innodb_data_file_path中显式指定舍入后的大小.

3.将新的数据文件添加到innodb_data_file_path的末尾,可以指定该文件为自动扩展. 注意,只能将innodb_data_file_path中的最后一个数据文件指定为自动扩展.

4.启动MySQL

实际例子:

初始只有一个ibdata1,现在我们想增加一个数据文件

innodb_data_home_dir =
innodb_data_file_path = /ibdata/ibdata1:10M:autoextend

假设ibdata1此时已经增长到988M,那么修改配置为

innodb_data_home_dir =
innodb_data_file_path = /ibdata/ibdata1:988M;/disk2/ibdata2:50M:autoextend

启动MySQL后,ibdata2会被初始化

2017-08-11T10:27:06.014446+08:00 0 [Note] InnoDB: Need to create a new innodb_system data file 'ibdata2'.
2017-08-11T10:27:06.014567+08:00 0 [Note] InnoDB: Setting file './ibdata2' size to 50 MB. Physically writing the file full; Please wait ...
2017-08-11T10:27:06.182464+08:00 0 [Note] InnoDB: File './ibdata2' size is now 50 MB.

缩小InnoDB system tablespace

您不能从系统表空间中删除数据文件. 要减少系统表空间大小,请使用以下过程:

1.使用mysqldump来转储所有的InnoDB表,包括位于MySQL数据库中的InnoDB表.

mysql> SELECT TABLE_NAME from INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='mysql' and ENGINE='InnoDB';
+---------------------------+
| TABLE_NAME                |
+---------------------------+
| engine_cost               |
| gtid_executed             |
| help_category             |
| help_keyword              |
| help_relation             |
| help_topic                |
| innodb_index_stats        |
| innodb_table_stats        |
| plugin                    |
| server_cost               |
| servers                   |
| slave_master_info         |
| slave_relay_log_info      |
| slave_worker_info         |
| time_zone                 |
| time_zone_leap_second     |
| time_zone_name            |
| time_zone_transition      |
| time_zone_transition_type |
+---------------------------+

2.关闭MySQL

3.删除所有现有的表空间文件( .ibd),包括ibdata和ib_log文件. 不要忘记删除位于MySQL数据库中的表的 .ibd文件.

4.删除InnoDB表的任何.frm文件.

5.配置新的表空间.

6.重启MySQL

7.导入dump文件

Note
如果您的数据库仅使用InnoDB引擎,可能会更容易地转储所有数据库,停止服务器,删除所有数据库和InnoDB日志文件,重新启动服务器以及导入转储文件。

『浅入浅出』MySQL及InnoDB存储引擎

作为一名开发人员,在日常的工作中会难以避免地接触到数据库,无论是基于文件的 sqlite 还是工程上使用非常广泛的 MySQL、PostgreSQL,但是一直以来也没有对数据库有一个非常清晰并且成体系的认知,所以最近两个月的时间看了几本数据库相关的书籍并且阅读了 MySQL 的官方文档,希望对各位了解数据库的、不了解数据库的有所帮助。

未分类

本文中对于数据库的介绍以及研究都是在 MySQL 上进行的,如果涉及到了其他数据库的内容或者实现会在文中单独指出。

数据库的定义

很多开发者在最开始时其实都对数据库有一个比较模糊的认识,觉得数据库就是一堆数据的集合,但是实际却比这复杂的多,数据库领域中有两个词非常容易混淆,也就是数据库和实例:

  • 数据库:物理操作文件系统或其他形式文件类型的集合;
  • 实例:MySQL 数据库由后台线程以及一个共享内存区组成;

对于数据库和实例的定义都来自于 MySQL 技术内幕:InnoDB 存储引擎(https://book.douban.com/subject/24708143/) 一书,想要了解 InnoDB 存储引擎的读者可以阅读这本书籍。

数据库和实例

在 MySQL 中,实例和数据库往往都是一一对应的,而我们也无法直接操作数据库,而是要通过数据库实例来操作数据库文件,可以理解为数据库实例是数据库为上层提供的一个专门用于操作的接口。

未分类

在 Unix 上,启动一个 MySQL 实例往往会产生两个进程,mysqld 就是真正的数据库服务守护进程,而 mysqld_safe 是一个用于检查和设置 mysqld 启动的控制程序,它负责监控 MySQL 进程的执行,当 mysqld 发生错误时,mysqld_safe 会对其状态进行检查并在合适的条件下重启。

MySQL 的架构

MySQL 从第一个版本发布到现在已经有了 20 多年的历史,在这么多年的发展和演变中,整个应用的体系结构变得越来越复杂:

未分类

最上层用于连接、线程处理的部分并不是 MySQL 『发明』的,很多服务都有类似的组成部分;第二层中包含了大多数 MySQL 的核心服务,包括了对 SQL 的解析、分析、优化和缓存等功能,存储过程、触发器和视图都是在这里实现的;而第三层就是 MySQL 中真正负责数据的存储和提取的存储引擎,例如:InnoDB、MyISAM 等,文中对存储引擎的介绍都是对 InnoDB 实现的分析。

数据的存储

在整个数据库体系结构中,我们可以使用不同的存储引擎来存储数据,而绝大多数存储引擎都以二进制的形式存储数据;这一节会介绍 InnoDB 中对数据是如何存储的。

在 InnoDB 存储引擎中,所有的数据都被逻辑地存放在表空间中,表空间(tablespace)是存储引擎中最高的存储逻辑单位,在表空间的下面又包括段(segment)、区(extent)、页(page):

未分类

同一个数据库实例的所有表空间都有相同的页大小;默认情况下,表空间中的页大小都为 16KB,当然也可以通过改变 innodb_page_size 选项对默认大小进行修改,需要注意的是不同的页大小最终也会导致区大小的不同:

未分类

从图中可以看出,在 InnoDB 存储引擎中,一个区的大小最小为 1MB,页的数量最少为 64 个。

如何存储表

MySQL 使用 InnoDB 存储表时,会将表的定义和数据索引等信息分开存储,其中前者存储在 .frm 文件中,后者存储在 .ibd 文件中,这一节就会对这两种不同的文件分别进行介绍。

未分类

.frm 文件

无论在 MySQL 中选择了哪个存储引擎,所有的 MySQL 表都会在硬盘上创建一个 .frm 文件用来描述表的格式或者说定义;.frm 文件的格式在不同的平台上都是相同的。

CREATE TABLE test_frm(
    column1 CHAR(5),
    column2 INTEGER
);

当我们使用上面的代码创建表时,会在磁盘上的 datadir 文件夹中生成一个 test_frm.frm 的文件,这个文件中就包含了表结构相关的信息:

未分类

MySQL 官方文档中的 11.1 MySQL .frm File Format(https://dev.mysql.com/doc/internals/en/frm-file-format.html) 一文对于 .frm 文件格式中的二进制的内容有着非常详细的表述,在这里就不展开介绍了。

.ibd 文件

InnoDB 中用于存储数据的文件总共有两个部分,一是系统表空间文件,包括 ibdata1、ibdata2 等文件,其中存储了 InnoDB 系统信息和用户数据库表数据和索引,是所有表公用的。

当打开 innodb_file_per_table 选项时,.ibd 文件就是每一个表独有的表空间,文件存储了当前表的数据和相关的索引数据。

如何存储记录

与现有的大多数存储引擎一样,InnoDB 使用页作为磁盘管理的最小单位;数据在 InnoDB 存储引擎中都是按行存储的,每个 16KB 大小的页中可以存放 2-200 行的记录。

当 InnoDB 存储数据时,它可以使用不同的行格式进行存储;MySQL 5.7 版本支持以下格式的行存储方式:
未分类

Antelope 是 InnoDB 最开始支持的文件格式,它包含两种行格式 Compact 和 Redundant,它最开始并没有名字;Antelope 的名字是在新的文件格式 Barracuda 出现后才起的,Barracuda 的出现引入了两种新的行格式 Compressed 和 Dynamic;InnoDB 对于文件格式都会向前兼容,而官方文档中也对之后会出现的新文件格式预先定义好了名字:Cheetah、Dragon、Elk 等等。

两种行记录格式 Compact 和 Redundant 在磁盘上按照以下方式存储:

未分类

Compact 和 Redundant 格式最大的不同就是记录格式的第一个部分;在 Compact 中,行记录的第一部分倒序存放了一行数据中列的长度(Length),而 Redundant 中存的是每一列的偏移量(Offset),从总体上上看,Compact 行记录格式相比 Redundant 格式能够减少 20% 的存储空间。

行溢出数据

当 InnoDB 使用 Compact 或者 Redundant 格式存储极长的 VARCHAR 或者 BLOB 这类大对象时,我们并不会直接将所有的内容都存放在数据页节点中,而是将行数据中的前 768 个字节存储在数据页中,后面会通过偏移量指向溢出页。

未分类

但是当我们使用新的行记录格式 Compressed 或者 Dynamic 时都只会在行记录中保存 20 个字节的指针,实际的数据都会存放在溢出页面中。

未分类

当然在实际存储中,可能会对不同长度的 TEXT 和 BLOB 列进行优化,不过这就不是本文关注的重点了。

想要了解更多与 InnoDB 存储引擎中记录的数据格式的相关信息,可以阅读 InnoDB Record Structure(https://dev.mysql.com/doc/internals/en/innodb-record-structure.html)

数据页结构

页是 InnoDB 存储引擎管理数据的最小磁盘单位,而 B-Tree 节点就是实际存放表中数据的页面,我们在这里将要介绍页是如何组织和存储记录的;首先,一个 InnoDB 页有以下七个部分:

未分类

每一个页中包含了两对 header/trailer:内部的 Page Header/Page Directory 关心的是页的状态信息,而 Fil Header/Fil Trailer 关心的是记录页的头信息。

在页的头部和尾部之间就是用户记录和空闲空间了,每一个数据页中都包含 Infimum 和 Supremum 这两个虚拟的记录(可以理解为占位符),Infimum 记录是比该页中任何主键值都要小的值,Supremum 是该页中的最大值:

未分类

User Records 就是整个页面中真正用于存放行记录的部分,而 Free Space 就是空余空间了,它是一个链表的数据结构,为了保证插入和删除的效率,整个页面并不会按照主键顺序对所有记录进行排序,它会自动从左侧向右寻找空白节点进行插入,行记录在物理存储上并不是按照顺序的,它们之间的顺序是由 next_record 这一指针控制的。

B+ 树在查找对应的记录时,并不会直接从树中找出对应的行记录,它只能获取记录所在的页,将整个页加载到内存中,再通过 Page Directory 中存储的稀疏索引和 n_owned、next_record 属性取出对应的记录,不过因为这一操作是在内存中进行的,所以通常会忽略这部分查找的耗时。

InnoDB 存储引擎中对数据的存储是一个非常复杂的话题,这一节中也只是对表、行记录以及页面的存储进行一定的分析和介绍,虽然作者相信这部分知识对于大部分开发者已经足够了,但是想要真正消化这部分内容还需要很多的努力和实践。

索引

索引是数据库中非常非常重要的概念,它是存储引擎能够快速定位记录的秘密武器,对于提升数据库的性能、减轻数据库服务器的负担有着非常重要的作用;索引优化是对查询性能优化的最有效手段,它能够轻松地将查询的性能提高几个数量级。

索引的数据结构

在上一节中,我们谈了行记录的存储和页的存储,在这里我们就要从更高的层面看 InnoDB 中对于数据是如何存储的;InnoDB 存储引擎在绝大多数情况下使用 B+ 树建立索引,这是关系型数据库中查找最为常用和有效的索引,但是 B+ 树索引并不能找到一个给定键对应的具体值,它只能找到数据行对应的页,然后正如上一节所提到的,数据库把整个页读入到内存中,并在内存中查找具体的数据行。

未分类

B+ 树是平衡树,它查找任意节点所耗费的时间都是完全相同的,比较的次数就是 B+ 树的高度;在这里,我们并不会深入分析或者动手实现一个 B+ 树,只是对它的特性进行简单的介绍。

聚集索引和辅助索引

数据库中的 B+ 树索引可以分为聚集索引(clustered index)和辅助索引(secondary index),它们之间的最大区别就是,聚集索引中存放着一条行记录的全部信息,而辅助索引中只包含索引列和一个用于查找对应行记录的『书签』。

  • 聚集索引

InnoDB 存储引擎中的表都是使用索引组织的,也就是按照键的顺序存放;聚集索引就是按照表中主键的顺序构建一颗 B+ 树,并在叶节点中存放表中的行记录数据。

CREATE TABLE users(
    id INT NOT NULL,
    first_name VARCHAR(20) NOT NULL,
    last_name VARCHAR(20) NOT NULL,
    age INT NOT NULL,
    PRIMARY KEY(id),
    KEY(last_name, first_name, age)
    KEY(first_name)
);

如果使用上面的 SQL 在数据库中创建一张表,B+ 树就会使用 id 作为索引的键,并在叶子节点中存储一条记录中的所有信息。

未分类

图中对 B+ 树的描述与真实情况下 B+ 树中的数据结构有一些差别,不过这里想要表达的主要意思是:聚集索引叶节点中保存的是整条行记录,而不是其中的一部分。

聚集索引与表的物理存储方式有着非常密切的关系,所有正常的表应该有且仅有一个聚集索引(绝大多数情况下都是主键),表中的所有行记录数据都是按照聚集索引的顺序存放的。

当我们使用聚集索引对表中的数据进行检索时,可以直接获得聚集索引所对应的整条行记录数据所在的页,不需要进行第二次操作。

  • 辅助索引

数据库将所有的非聚集索引都划分为辅助索引,但是这个概念对我们理解辅助索引并没有什么帮助;辅助索引也是通过 B+ 树实现的,但是它的叶节点并不包含行记录的全部数据,仅包含索引中的所有键和一个用于查找对应行记录的『书签』,在 InnoDB 中这个书签就是当前记录的主键。

辅助索引的存在并不会影响聚集索引,因为聚集索引构成的 B+ 树是数据实际存储的形式,而辅助索引只用于加速数据的查找,所以一张表上往往有多个辅助索引以此来提升数据库的性能。

一张表一定包含一个聚集索引构成的 B+ 树以及若干辅助索引的构成的 B+ 树。

未分类

如果在表 users 中存在一个辅助索引 (first_name, age),那么它构成的 B+ 树大致就是上图这样,按照 (first_name, age) 的字母顺序对表中的数据进行排序,当查找到主键时,再通过聚集索引获取到整条行记录。

未分类

上图展示了一个使用辅助索引查找一条表记录的过程:通过辅助索引查找到对应的主键,最后在聚集索引中使用主键获取对应的行记录,这也是通常情况下行记录的查找方式。

索引的设计

索引的设计其实是一个非常重要的内容,同时也是一个非常复杂的内容;索引的设计与创建对于提升数据库的查询性能至关重要,不过这不是本文想要介绍的内容,有关索引的设计与优化可以阅读 数据库索引设计与优化 一书,书中提供了一种非常科学合理的方法能够帮助我们在数据库中建立最适合的索引,当然作者也可能会在之后的文章中对索引的设计进行简单的介绍和分析。

我们都知道锁的种类一般分为乐观锁和悲观锁两种,InnoDB 存储引擎中使用的就是悲观锁,而按照锁的粒度划分,也可以分成行锁和表锁。

并发控制机制

乐观锁和悲观锁其实都是并发控制的机制,同时它们在原理上就有着本质的差别;

  • 乐观锁是一种思想,它其实并不是一种真正的『锁』,它会先尝试对资源进行修改,在写回时判断资源是否进行了改变,如果没有发生改变就会写回,否则就会进行重试,在整个的执行过程中其实都没有对数据库进行加锁;
  • 悲观锁就是一种真正的锁了,它会在获取资源前对资源进行加锁,确保同一时刻只有有限的线程能够访问该资源,其他想要尝试获取资源的操作都会进入等待状态,直到该线程完成了对资源的操作并且释放了锁后,其他线程才能重新操作资源;

虽然乐观锁和悲观锁在本质上并不是同一种东西,一个是一种思想,另一个是一种真正的锁,但是它们都是一种并发控制机制。

未分类

乐观锁不会存在死锁的问题,但是由于更新后验证,所以当冲突频率和重试成本较高时更推荐使用悲观锁,而需要非常高的响应速度并且并发量非常大的时候使用乐观锁就能较好的解决问题,在这时使用悲观锁就可能出现严重的性能问题;在选择并发控制机制时,需要综合考虑上面的四个方面(冲突频率、重试成本、响应速度和并发量)进行选择。

锁的种类

对数据的操作其实只有两种,也就是读和写,而数据库在实现锁时,也会对这两种操作使用不同的锁;InnoDB 实现了标准的行级锁,也就是共享锁(Shared Lock)和互斥锁(Exclusive Lock);共享锁和互斥锁的作用其实非常好理解:

  • 共享锁(读锁):允许事务对一条行数据进行读取;
  • 互斥锁(写锁):允许事务对一条行数据进行删除或更新;

而它们的名字也暗示着各自的另外一个特性,共享锁之间是兼容的,而互斥锁与其他任意锁都不兼容:

未分类

稍微对它们的使用进行思考就能想明白它们为什么要这么设计,因为共享锁代表了读操作、互斥锁代表了写操作,所以我们可以在数据库中并行读,但是只能串行写,只有这样才能保证不会发生线程竞争,实现线程安全。

锁的粒度

无论是共享锁还是互斥锁其实都只是对某一个数据行进行加锁,InnoDB 支持多种粒度的锁,也就是行锁和表锁;为了支持多粒度锁定,InnoDB 存储引擎引入了意向锁(Intention Lock),意向锁就是一种表级锁。

与上一节中提到的两种锁的种类相似的是,意向锁也分为两种:

  • 意向共享锁:事务想要在获得表中某些记录的共享锁,需要在表上先加意向共享锁;
  • 意向互斥锁:事务想要在获得表中某些记录的互斥锁,需要在表上先加意向互斥锁;

随着意向锁的加入,锁类型之间的兼容矩阵也变得愈加复杂:

未分类

意向锁其实不会阻塞全表扫描之外的任何请求,它们的主要目的是为了表示是否有人请求锁定表中的某一行数据。

有的人可能会对意向锁的目的并不是完全的理解,我们在这里可以举一个例子:如果没有意向锁,当已经有人使用行锁对表中的某一行进行修改时,如果另外一个请求要对全表进行修改,那么就需要对所有的行是否被锁定进行扫描,在这种情况下,效率是非常低的;不过,在引入意向锁之后,当有人使用行锁对表中的某一行进行修改之前,会先为表添加意向互斥锁(IX),再为行记录添加互斥锁(X),在这时如果有人尝试对全表进行修改就不需要判断表中的每一行数据是否被加锁了,只需要通过等待意向互斥锁被释放就可以了。

锁的算法

到目前为止已经对 InnoDB 中锁的粒度有一定的了解,也清楚了在对数据库进行读写时会获取不同的锁,在这一小节将介绍锁是如何添加到对应的数据行上的,我们会分别介绍三种锁的算法:Record Lock、Gap Lock 和 Next-Key Lock。

  • Record Lock

记录锁(Record Lock)是加到索引记录上的锁,假设我们存在下面的一张表 users:

CREATE TABLE users(
    id INT NOT NULL AUTO_INCREMENT,
    last_name VARCHAR(255) NOT NULL,
    first_name VARCHAR(255),
    age INT,
    PRIMARY KEY(id),
    KEY(last_name),
    KEY(age)
);

如果我们使用 id 或者 last_name 作为 SQL 中 WHERE 语句的过滤条件,那么 InnoDB 就可以通过索引建立的 B+ 树找到行记录并添加索引,但是如果使用 first_name 作为过滤条件时,由于 InnoDB 不知道待修改的记录具体存放的位置,也无法对将要修改哪条记录提前做出判断就会锁定整个表。

  • Gap Lock

记录锁是在存储引擎中最为常见的锁,除了记录锁之外,InnoDB 中还存在间隙锁(Gap Lock),间隙锁是对索引记录中的一段连续区域的锁;当使用类似 SELECT * FROM users WHERE id BETWEEN 10 AND 20 FOR UPDATE; 的 SQL 语句时,就会阻止其他事务向表中插入 id = 15 的记录,因为整个范围都被间隙锁锁定了。

间隙锁是存储引擎对于性能和并发做出的权衡,并且只用于某些事务隔离级别。

虽然间隙锁中也分为共享锁和互斥锁,不过它们之间并不是互斥的,也就是不同的事务可以同时持有一段相同范围的共享锁和互斥锁,它唯一阻止的就是其他事务向这个范围中添加新的记录。

  • Next-Key Lock

Next-Key 锁相比前两者就稍微有一些复杂,它是记录锁和记录前的间隙锁的结合,在 users 表中有以下记录:

+------+-------------+--------------+-------+
|   id | last_name   | first_name   |   age |
|------+-------------+--------------+-------|
|    4 | stark       | tony         |    21 |
|    1 | tom         | hiddleston   |    30 |
|    3 | morgan      | freeman      |    40 |
|    5 | jeff        | dean         |    50 |
|    2 | donald      | trump        |    80 |
+------+-------------+--------------+-------+

如果使用 Next-Key 锁,那么 Next-Key 锁就可以在需要的时候锁定以下的范围:

(-∞, 21]
(21, 30]
(30, 40]
(40, 50]
(50, 80]
(80, ∞)

既然叫 Next-Key 锁,锁定的应该是当前值和后面的范围,但是实际上却不是,Next-Key 锁锁定的是当前值和前面的范围。

当我们更新一条记录,比如 SELECT * FROM users WHERE age = 30 FOR UPDATE;,InnoDB 不仅会在范围 (21, 30] 上加 Next-Key 锁,还会在这条记录后面的范围 (30, 40] 加间隙锁,所以插入 (21, 40] 范围内的记录都会被锁定。

Next-Key 锁的作用其实是为了解决幻读的问题,我们会在下一节谈事务的时候具体介绍。

死锁的发生

既然 InnoDB 中实现的锁是悲观的,那么不同事务之间就可能会互相等待对方释放锁造成死锁,最终导致事务发生错误;想要在 MySQL 中制造死锁的问题其实非常容易:

未分类

两个会话都持有一个锁,并且尝试获取对方的锁时就会发生死锁,不过 MySQL 也能在发生死锁时及时发现问题,并保证其中的一个事务能够正常工作,这对我们来说也是一个好消息。

事务与隔离级别

在介绍了锁之后,我们再来谈谈数据库中一个非常重要的概念 —— 事务;相信只要是一个合格的软件工程师就对事务的特性有所了解,其中被人经常提起的就是事务的原子性,在数据提交工作时,要么保证所有的修改都能够提交,要么就所有的修改全部回滚。

但是事务还遵循包括原子性在内的 ACID 四大特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability);文章不会对这四大特性全部展开进行介绍,相信你能够通过 Google 和数据库相关的书籍轻松获得有关它们的概念,本文最后要介绍的就是事务的四种隔离级别。

几种隔离级别

事务的隔离性是数据库处理数据的几大基础之一,而隔离级别其实就是提供给用户用于在性能和可靠性做出选择和权衡的配置项。

ISO 和 ANIS SQL 标准制定了四种事务隔离级别,而 InnoDB 遵循了 SQL:1992 标准中的四种隔离级别:READ UNCOMMITED、READ COMMITED、REPEATABLE READ 和 SERIALIZABLE;每个事务的隔离级别其实都比上一级多解决了一个问题:

  • RAED UNCOMMITED:使用查询语句不会加锁,可能会读到未提交的行(Dirty Read);
  • READ COMMITED:只对记录加记录锁,而不会在记录之间加间隙锁,所以允许新的记录插入到被锁定记录的附近,所以再多次使用查询语句时,可能得到不同的结果(Non-Repeatable Read);
  • REPEATABLE READ:多次读取同一范围的数据会返回第一次查询的快照,不会返回不同的数据行,但是可能发生幻读(Phantom Read);
  • SERIALIZABLE:InnoDB 隐式地将全部的查询语句加上共享锁,解决了幻读的问题;

MySQL 中默认的事务隔离级别就是 REPEATABLE READ,但是它通过 Next-Key 锁也能够在某种程度上解决幻读的问题。

未分类

接下来,我们将数据库中创建如下的表并通过个例子来展示在不同的事务隔离级别之下,会发生什么样的问题:

CREATE TABLE test(
    id INT NOT NULL,
    UNIQUE(id)
);

脏读

当事务的隔离级别为 READ UNCOMMITED 时,我们在 SESSION 2 中插入的未提交数据在 SESSION 1 中是可以访问的。

未分类

不可重复读

当事务的隔离级别为 READ COMMITED 时,虽然解决了脏读的问题,但是如果在 SESSION 1 先查询了一个范围的数据,在这之后 SESSION 2 中插入一条数据并且提交了修改,在这时,如果 SESSION 1 中再次使用相同的查询语句,就会发现两次查询的结果不一样。

未分类

不可重复读的原因就是,在 READ COMMITED 的隔离级别下,存储引擎不会在查询记录时添加间隙锁,锁定 id < 5 这个范围。

幻读

重新开启了两个会话 SESSION 1 和 SESSION 2,在 SESSION 1 中我们查询全表的信息,没有得到任何记录;在 SESSION 2 中向表中插入一条数据并提交;由于 REPEATABLE READ 的原因,再次查询全表的数据时,我们获得到的仍然是空集,但是在向表中插入同样的数据却出现了错误。

未分类

这种现象在数据库中就被称作幻读,虽然我们使用查询语句得到了一个空的集合,但是插入数据时却得到了错误,好像之前的查询是幻觉一样。

在标准的事务隔离级别中,幻读是由更高的隔离级别 SERIALIZABLE 解决的,但是它也可以通过 MySQL 提供的 Next-Key 锁解决:

未分类

REPERATABLE READ 和 READ UNCOMMITED 其实是矛盾的,如果保证了前者就看不到已经提交的事务,如果保证了后者,就会导致两次查询的结果不同,MySQL 为我们提供了一种折中的方式,能够在 REPERATABLE READ 模式下加锁访问已经提交的数据,其本身并不能解决幻读的问题,而是通过文章前面提到的 Next-Key 锁来解决。

总结

文章中的内容大都来自于 高性能 MySQL、MySQL 技术内幕:InnoDB 存储引擎、数据库索引设计与优化 以及 MySQL 的 官方文档。

无备份情况下MySQL innodb表被意外删除的恢复

这里我们首先来测试innodb_file_per_table为off的情况,即表结构和数据存在同一个文件中。这里我分别测试了表存在主键和不存在主键的情况,供参考。

innodb_file_per_table参数为off(有主键的情况)

1、创建测试表

mysql> use recover;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> create table test_drop0801(id int);
Query OK, 0 rows affected (0.02 sec)

mysql> insert into test_drop0801 values(100);
Query OK, 1 row affected (0.01 sec)

mysql> insert into test_drop0801 values(101);
Query OK, 1 row affected (0.00 sec)

mysql> insert into test_drop0801 values(102);
Query OK, 1 row affected (0.00 sec)

mysql> alter table test_drop0801 add primary key(id);
Query OK, 3 rows affected (0.02 sec)
Records: 3  Duplicates: 0  Warnings: 0

mysql> explain select * from test_drop0801 where id=102;
+----+-------------+---------------+-------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table        | type  | possible_keys | key    | key_len | ref  | rows | Extra      |
+----+-------------+---------------+-------+---------------+---------+---------+-------+------+-------------+
|  1 | SIMPLE      | test_drop0801 | const | PRIMARY      | PRIMARY | 4      | const |    1 | Using index |
+----+-------------+---------------+-------+---------------+---------+---------+-------+------+-------------+
1 row in set (0.00 sec)


mysql> show global variables like '%file_per%';
+-----------------------+-------+
| Variable_name        | Value |
+-----------------------+-------+
| innodb_file_per_table | OFF  |
+-----------------------+-------+
1 row in set (0.00 sec)

mysql> show create table test_drop0801 G;
*************************** 1. row ***************************
      Table: test_drop0801
Create Table: CREATE TABLE `test_drop0801` (
  `id` int(11) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)

ERROR:
No query specified

2、备份表结构

[root@killdb ~]# mysqldump --opt -d -uroot -proger recover test_drop0801 > /tmp/innodb_recovery/recover/test_drop0801.sql
[root@killdb ~]#
1
2

[root@killdb ~]# mysqldump --opt -d -uroot -proger recover test_drop0801 > /tmp/innodb_recovery/recover/test_drop0801.sql
[root@killdb ~]#

3、删除表

mysql> drop table test_drop0801;
Query OK, 0 rows affected (0.00 sec)

4、扫描数据文件

[root@killdb innodb_recovery]# ./stream_parser -f /var/lib/mysql/ibdata1
Opening file: /var/lib/mysql/ibdata1
File information:

ID of device containing file:        64768
inode number:                      924765
protection:                        100660 (regular file)
number of hard links:                    1
user ID of owner:                      496
group ID of owner:                    491
device ID (if special file):            0
blocksize for filesystem I/O:        4096
number of blocks allocated:          69632
time of last access:            1496441095 Sat Jun  3 06:04:55 2017
time of last modification:      1496464241 Sat Jun  3 12:30:41 2017
time of last status change:    1496464241 Sat Jun  3 12:30:41 2017
total size, in bytes:            35651584 (34.000 MiB)

Size to process:                  35651584 (34.000 MiB)
All workers finished in 1 sec

5、创建用于恢复的数据字典

[root@killdb innodb_recovery]# ./recover_dictionary.sh
Generating dictionary tables dumps... OK
Creating test database ... OK
Creating dictionary tables in database test:
SYS_TABLES ... OK
SYS_COLUMNS ... OK
SYS_INDEXES ... OK
SYS_FIELDS ... OK
All OK
Loading dictionary tables data:
SYS_TABLES ... 234 recs OK
SYS_COLUMNS ... 324 recs OK
SYS_INDEXES ... 123 recs OK
SYS_FIELDS ... 248 recs OK
All OK

6、查询需要恢复表的信息

mysql> use test;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql>
mysql> select * from SYS_TABLES where name like 'recover/test_drop0801%';
+-----------------------+-----+--------+------+--------+---------+--------------+-------+
| NAME                  | ID  | N_COLS | TYPE | MIX_ID | MIX_LEN | CLUSTER_NAME | SPACE |
+-----------------------+-----+--------+------+--------+---------+--------------+-------+
| recover/test_drop0801 | 187 |      1 |    1 |      0 |      0 |              |    0 |
+-----------------------+-----+--------+------+--------+---------+--------------+-------+
1 row in set (0.00 sec)

mysql> select * from SYS_INDEXES where table_id=187;
+----------+-----+---------+----------+------+-------+------------+
| TABLE_ID | ID  | NAME    | N_FIELDS | TYPE | SPACE | PAGE_NO    |
+----------+-----+---------+----------+------+-------+------------+
|      187 | 184 | PRIMARY |        1 |    3 |    0 | 4294967295 |
+----------+-----+---------+----------+------+-------+------------+
1 row in set (0.00 sec)

7、确认数据page中数据是否存在

[root@killdb innodb_recovery]#  ./c_parser -6f pages-ibdata1/FIL_PAGE_INDEX/0000000000000184.page -t recover/test_drop0801.sql |head -5
Line 22: syntax error at 'DROP'
21:
22: DROP TABLE IF EXISTS `test_drop0801`;
Failed to parse table structure
[root@killdb innodb_recovery]#

这里的报错是因为脚本的问题,需要修改备份脚本(mysqldump产生的).

[root@killdb innodb_recovery]#  ./c_parser -6f pages-ibdata1/FIL_PAGE_INDEX/0000000000000184.page -t recover/test_drop0801.sql |head -5
-- Page id: 562, Format: COMPACT, Records list: Valid, Expected records: (3 3)
000000001517    94000001800110test_drop0801    100
000000001517    9400000180011Dtest_drop0801    101
000000001517    9400000180012Atest_drop0801    102
SET FOREIGN_KEY_CHECKS=0;
LOAD DATA LOCAL INFILE '/tmp/innodb_recovery/dumps/default/test_drop0801' REPLACE INTO TABLE `test_drop0801` FIELDS TERMINATED BY 't' OPTIONALLY ENCLOSED BY '"' LINES STARTING BY 'test_drop0801t' (`id`);
-- Page id: 562, Found records: 3, Lost records: NO, Leaf page: YES
[root@killdb innodb_recovery]#

8、抽取page中的数据

[root@killdb innodb_recovery]#  ./c_parser -6f pages-ibdata1/FIL_PAGE_INDEX/0000000000000184.page -t recover/test_drop0801.sql > dumps/default/test_drop0801 2> dumps/default/test_drop0801_load.sql          
[root@killdb innodb_recovery]#
[root@killdb innodb_recovery]# ls -ltr dumps/default/test_drop0801*
-rw-r--r--. 1 root root 232 Jun  3 12:34 dumps/default/test_drop0801_load.sql
-rw-r--r--. 1 root root 285 Jun  3 12:34 dumps/default/test_drop0801

9、加载数据到数据库

mysql> use recover
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> source recover/test_drop0801.sql
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.02 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)


mysql> source dumps/default/test_drop0801_load.sql
Query OK, 0 rows affected (0.00 sec)

Query OK, 3 rows affected (0.00 sec)
Records: 3  Deleted: 0  Skipped: 0  Warnings: 0

mysql> select * from test_drop0801;
+-----+
| id  |
+-----+
| 100 |
| 101 |
| 102 |
+-----+
3 rows in set (0.00 sec)

mysql> 

我们可以看到,顺利完成了drop table的恢复,而且数据完好无损。实际上我这里还同时测试了无主键的情况,经过测试都类似,可以进行完美的恢复。这里不再累述。

InnoDB关键特性之刷新邻接页-异步IO

一、刷新邻接页功能

1、工作原理

当刷新一个脏页时,innodb存储引擎会检测该页所在区(extent)的所有页,如果是脏页,那么一起进行刷新。这样做的好处显而易见,通过AIO可以将多个IO写入操作合并为一个IO操作,增大写入量,减少了物理写IO,故该工作机制在传统机械磁盘下有着显著的优势。

  • 在写入次数基本不增加的情况下,增加了写入的量;

  • 加速了脏页的回收;

  • 充分利用double write每次1M写入的特征;

  • 这个功能打开以后会发现iostat里面的wrqm(合并写)这个值会比较高;

2、问题考虑

2.1 是不是可能将不怎么脏的页进行了写入,而该页之后又会很快变成脏页?

2.2 固态硬盘有着较高的 IOPS,是否还需要这个特性?

为此,InnoDB 存储引擎从 1.2.x 版本开始提供了参数

innodb_flush_neighbors,用来控制是否启用该特性。对于传统机械硬盘建议启用该特性,而对于固态硬盘有着超高 IOPS 性能的磁盘,则建议将该参数设置为 0、即关闭此特性。

3、参数控制:innodb_flush_neighbors

mysql> show variables like '%neigh%';
+------------------------+-------+
| Variable_name          | Value |
+------------------------+-------+
| innodb_flush_neighbors | 1     |  #默认刷新邻接页功能开启
+------------------------+-------+
1 row in set (0.00 sec)
  • 1,表示打开了刷新邻接页的功能,顺带着刷新在 buffer pool 中位于磁盘上相同的 extend 区的相邻的脏页。

  • 0,表示关闭刷新邻接页

  • 2 ,表示刷新在 buffer pool 中位于磁盘上相同的 extend 区的脏页。

4、Flush neighbor page的影响

  • 对于insert频繁的系统,这个功能比较适合。

  • 对于update频繁的系统,这个功能可能会带来一些副作用。

  • update顺带着刷新其他页。

  • 对于update频繁的表,这些页马上就脏了,白白浪费写负载。

二、异步IO功能

1、关于AIO与SIO

为了提高磁盘操作性能,当前的数据库系统都采用异步IO的方式来处理磁盘操作。

  • 异步IO:用户可以在发出一个IO请求后立即再发出另外一个IO请求,当全部IO请求发送完毕后,等待所有IO操作完成,这就是AIO。

  • 与AIO对应的是Sync IO,即每进行一次IO操作,需要等待此次操作结束才能继续接下来的操作。

2、开启异步IO

首先OS要有异步io,且开启,然后mysqld要链接,要不然OS异步io没有开启,数据库的异步io也起不来。(this variable applies to Linux systems only, and cannot be changed while the server is running.)

2.1 文件系统层面需要打开这个功能:一般都是默认开启的。

[root@localhost /]# ldconfig -v|grep libaio
    libaio.so.1.0.0 -> libaio.so.1.0.0
    libaio.so.1 -> libaio.so.1.0.1

2.2 AIO是数据库层面的一个特性需要打开:默认是开启,开启的native aio性能提升,可以提高到75%。

mysql> show variables like 'innodb_use_native_aio';
+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| innodb_use_native_aio | ON    |
+-----------------------+-------+
1 row in set (0.00 sec)

3、异步IO的好处  

  • 不用等待直接响应上一个用户的请求;

  • 多次的请求在一起排序,请求的数据页是在一起的,一次读出来,减少多次读。(数据库的读写请求队列放在文件系统中单独分配的一块小内存结构里,非文件系统的缓存)

4、wio:wait io

  • 同步IO一定会产生wait IO

  • 异步IO会降低wait IO,但是也可能会有wait IO

  • 尽量采用异步IO(性能高于同步IO)

  • 数据库层面启用异步IO

  • 文件系统层面启用异步IO,Linux具备异步IO的能力

  • 操作系统层面wio的含义理解

[root@localhost /]# sar 1
Linux 2.6.32-431.el6.x86_64 (one)     07/14/2017     _x86_64_    (6 CPU)

04:23:25 AM     CPU     %user     %nice   %system   %iowait    %steal     %idle
04:23:26 AM     all      0.17      0.00      0.33      0.00      0.00     99.50
04:23:27 AM     all      0.00      0.00      1.00      0.00      0.00     99.00
^C
[root@localhost /]# iostat 1
Linux 2.6.32-431.el6.x86_64 (one)     07/14/2017     _x86_64_    (6 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           0.00    0.00    0.02    0.02    0.00   99.96

Device:            tps   Blk_read/s   Blk_wrtn/s   Blk_read   Blk_wrtn
sda               0.26         6.70         4.07     619320     376232
scd0              0.00         0.00         0.00        352          0

说明进程或是线程等待io的时间,值最好是小于5,大于25一定是io有问题。

在InnoDB存储引擎中,read ahead方式的读取都是通过AIO完成,脏页的刷新,也是通过AIO完成。