mysql知识总结

Posted 分享录

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了mysql知识总结相关的知识,希望对你有一定的参考价值。

概述

mysql 可以分为 Server 层和存储引擎层两部分。

  • Server 层包括连接器(和客户端交互、权限管理)、查询缓存(已经查询的请求会缓存在内存中,但是mysql经常会遇到缓存失效频繁的问题,例如table scan)、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

  • 而存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。在create table的时候可以指定存储引擎

分析器

分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。做完了这些识别以后,就要做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。

优化器

为何需要优化器来优化查询时间?归根到底是因为 SQL 是一种 declarative language(声明式语言),它只是告知了数据库系统,它希望数据以什么形式返回,但并没有告诉系统,要怎么去一步一步执行算子来得到最终结果。这和我们通常使用的编程语言是有所不同的。

优化器可以优化的维度有很多,除了最直观的查询时间,还有比如计算资源,一个查询语句,需要发生多少次 IO,使用多少内存,总共运行多少个 CPU cycle,耗费了多少电量,等等。虽然,绝大部分情况下,这些资源都是正比于运行时间。再如,对于一个高并发的数据库系统,单个语句的运行时间可能并不是最好的优化指标,优化整体的吞吐量才是王道:相较于让每个语句都能在 5 秒内完成,可能更希望每 10 秒能运行完 100 个语句。

单个语句优化执行时间是一个NP-HARD问题

日志

不管是哪个数据库产品,一定会有日志文件。在MariaDB/MySQL中,主要有5种日志文件:

  1. 错误日志(error log):记录mysql服务的启停时正确和错误的信息,还记录启动、停止、运行过程中的错误信息。使用--log-error=[file_name]指定日志文件

  2. 查询日志(general log):记录建立的客户端连接和执行的语句。不属于慢查询的日志记录到这里,默认关闭。--general_log={0|1}

  3. 二进制日志(bin log):记录所有更改数据的语句,可用于数据复制。

  4. 慢查询日志(slow log):记录所有执行时间超过long_query_time的所有查询或不使用索引的查询。mysql> set @@global.slow_query_log=on;

  5. 中继日志(relay log):主从复制时使用的日志。

除了这5种日志,在需要的时候还会创建DDL日志。

读写流程如何操作日志

例如,update T set c=c+1 where ID=2;

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。

  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。

  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于prepare状态。然后告知执行器执行完成了,随时可以提交事务

  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

两阶段提交

上面提到的prepare/commit,是为了保证redolog和binlog的一致性。redo-log和binlog在系统节点恢复、扩容时需要。

  1. prepare redo log

  2. 写bin log

  3. 提交事务,commit redo log

InnoDB事务日志

innodb事务日志包括redo log和undo log。redo log是重做日志,提供前滚操作,undo log是回滚日志,提供回滚操作。

undo log不是redo log的逆向过程,其实它们都算是用来恢复的日志:

  1. redo log通常是物理日志,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。

  2. undo用来回滚行记录到某个版本。undo log一般是逻辑日志,根据每行记录进行记录。

redo log

redo log包括两部分:一是内存中的日志缓冲(redo log buffer),该部分日志是易失性的;二是磁盘上的重做日志文件(redo log file),该部分日志是持久的

在概念上,innodb通过force log at commit机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的redo log file和undo log file中进行持久化。

为了确保每次日志都能写入到事务日志文件中,在每次将log buffer中的日志写入日志文件的过程中都会调用一次操作系统的fsync操作(即fsync()系统调用)。因为MariaDB/MySQL是工作在用户空间的,MariaDB/MySQL的log buffer处于用户空间的内存中。要写入到磁盘上的log file中(redo:ib_logfileN文件,undo:share tablespace或.ibd文件),中间还要经过操作系统内核空间的os buffer,调用fsync()的作用就是将OS buffer中的日志刷到磁盘上的log file中。(mysql打开redo log没有使用 O_DIRECT,应该是为了减少实际落盘的次数,只有调用fsync的时候才会真正落盘)

LSNlog sequence number)日志序列号,5.6.3之后占用8字节,LSN主要用于发生crash时对数据进行recovery,LSN是一个一直递增的整型数字,表示事务写入到日志的字节总量。可以使用show engine innodb status命令查看

LSN不仅只存在于重做日志中,在每个数据页头部也会有对应的LSN号,该LSN记录当前页最后一次修改的LSN号,用于在recovery时对比重做日志LSN号决定是否对该页进行恢复数据。checkpoint也是有LSN号记录的,LSN号串联起一个事务开始到恢复的过程。

在主从复制结构中,要保证事务的持久性和一致性,需要对日志相关变量设置为如下:

  • 如果启用了二进制日志,则设置sync_binlog=1,即每提交一次事务同步写到磁盘中。

  • 总是设置innodb_flush_log_at_trx_commit=1,即每提交一次事务都写到磁盘中。

下刷日志的规则
  • innodb_flush_log_at_trx_commit这个变量只是控制commit动作是否刷新log buffer到磁盘。

    • 0:事务提交不写入磁盘,只写到log buffer中(和2写到os buffer中对比,应该是2多调用了一个write()),写入磁盘在master thread进行(一个后台任务线程),默认一秒下刷一次,当系统崩溃,会丢失1秒钟的数据。

    • 1:事务提交必须调用fsync。事务每次提交都会将log buffer中的日志写入os buffer并调用fsync()刷到log file on disk中。这种方式即使系统崩溃也不会丢失任何数据,但是因为每次提交都写入磁盘,IO的性能较差。

    • 2:每次提交都仅写入到os buffer(就是buffer),然后是每秒调用fsync()os buffer中的日志写入到log file on disk。With a value of 2, the contents of the InnoDB log buffer are written to the log file after each transaction commit and the log file is flushed to disk approximately once per second

  • innodb_flush_log_at_timeout:该变量表示的是刷日志的频率

  • innodb_log_files_group默认是2,循环轮询写入,redo log file的大小对innodb的性能影响非常大,设置的太大,恢复的时候就会时间较长,设置的太小,就会导致在写redo log的时候循环切换redo log file。

刷日志到磁盘有以下几种规则:

  1. 发出commit动作时。已经说明过,commit发出后是否刷日志由变量 innodb_flush_log_at_trx_commit 控制。 

    1. 假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。

  2. 每秒刷一次。这个刷日志的频率由变量 innodb_flush_log_at_timeout 值决定,默认是1秒。要注意,这个刷日志频率和commit动作无关。

  3. 当log buffer中已经使用的内存(innodb_log_buffer_size)超过一半时。注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。

  4. 当有checkpoint时,checkpoint在一定程度上代表了刷到磁盘时日志所处的LSN位置。checkpoint到了会将日志和数据都刷盘

数据刷盘的规则

在innodb中,数据刷盘的规则只有一个:checkpoint。但是触发checkpoint的情况却有几种。不管怎样,checkpoint触发后,会将buffer中脏数据页和脏日志页都刷到磁盘

innodb存储引擎中checkpoint分为两种:

  • sharp checkpoint:在重用redo log文件(例如切换日志文件)的时候,将所有已记录到redo log中对应的脏数据刷到磁盘。

  • fuzzy checkpoint:一次只刷一小部分的日志到磁盘,而非将所有脏日志刷盘。有以下几种情况会触发该检查点: 

    • master thread checkpoint:由master线程控制,每秒或每10秒刷入一定比例的脏页到磁盘。

    • flush_lru_list checkpoint:从MySQL5.6开始可通过 innodb_page_cleaners 变量指定专门负责脏页刷盘的page cleaner线程的个数,该线程的目的是为了保证lru列表有可用的空闲页。

    • async/sync flush checkpoint:同步刷盘还是异步刷盘。例如还有非常多的脏页没刷到磁盘(非常多是多少,有比例控制),这时候会选择同步刷到磁盘,但这很少出现;如果脏页不是很多,可以选择异步刷到磁盘,如果脏页很少,可以暂时不刷脏页到磁盘

    • dirty page too much checkpoint:脏页太多时强制触发检查点,目的是为了保证缓存有足够的空闲空间。too much的比例由变量 innodb_max_dirty_pages_pct 控制,MySQL 5.6默认的值为75,即当脏页占缓冲池的百分之75后,就强制刷一部分脏页到磁盘。
      由于刷脏页需要一定的时间来完成,所以记录检查点的位置是在每次刷盘结束之后才在redo log中标记的。

InnoDB的恢复行为

在启动innodb的时候,不管上次是正常关闭还是异常关闭,总是会进行恢复操作。

因为redo log记录的是数据页的物理变化,因此恢复的时候速度比逻辑日志(如bin log)要快很多。而且,innodb自身也做了一定程度的优化,让恢复速度变得更快。

重启innodb时,checkpoint表示已经完整刷到磁盘上data page上的LSN,因此恢复时仅需要恢复从checkpoint开始的日志部分。例如,当数据库在上一次checkpoint的LSN为10000时宕机,且事务是已经提交过的状态。启动数据库时会检查磁盘中数据页的LSN,如果数据页的LSN小于日志中的LSN,则会从检查点开始恢复。

还有一种情况,在宕机前正处于checkpoint的刷盘过程,且数据页的刷盘进度超过了日志页的刷盘进度。这时候一宕机,数据页中记录的LSN就会大于日志页中的LSN,在重启的恢复过程中会检查到这一情况,这时超出日志进度的部分将不会重做,因为这本身就表示已经做过的事情,无需再重做。

另外,事务日志具有幂等性,所以多次操作得到同一结果的行为在日志中只记录一次。而二进制日志不具有幂等性,多次操作会全部记录下来,在恢复的时候会多次执行二进制日志中的记录,速度就慢得多。例如,某记录中id初始值为2,通过update将值设置为了3,后来又设置成了2,在事务日志中记录的将是无变化的页,根本无需恢复;而二进制会记录下两次update操作,恢复时也将执行这两次update操作,速度比事务日志恢复更慢

redo log和bin log的区别
  1. bin log是在存储引擎的上层产生的,不管是什么存储引擎,对数据库进行了修改都会产生bin log。而redo log是innodb层产生的,只记录该存储引擎中表的修改。并且bin log先于redo log被记录。

  2. bin log记录操作的方法是逻辑性的语句即便它是基于行格式的记录方式,其本质也还是逻辑的SQL设置,如该行记录的每列的值是多少。而redo log是在物理格式上的日志,它记录的是数据库中每个页的修改

  3. bin log只在每次事务提交的时候一次性写入缓存中的日志"文件"(对于非事务表的操作,则是每次执行语句成功后就直接写入)。而redo log在数据准备修改前写入缓存中的redo log中,然后才对缓存中的数据执行修改操作;而且保证在发出事务提交指令时,先向缓存中的redo log写入日志,写入完成后才执行提交动作。

  4. 因为bin log只在提交的时候一次性写入,所以bin log中的记录方式和提交顺序有关,且一次提交对应一次记录。而redo log中是记录的物理页的修改,redo log文件中同一个事务可能多次记录,最后一个提交的事务记录会覆盖所有未提交的事务记录。例如事务T1,可能在redo log中记录了 T1-1,T1-2,T1-3,T1* 共4个操作,其中 T1* 表示最后提交时的日志记录,所以对应的数据页最终状态是 T1* 对应的操作结果。而且redo log是并发写入的(这个地方应该只是讲group commit,真正下刷的时候肯定只有一个线程下刷),不同事务之间的不同版本的记录会穿插写入到redo log文件中,例如可能redo log的记录方式如下: T1-1,T1-2,T2-1,T2-2,T2*,T1-3,T1* 。

  5. 事务日志记录的是物理页的情况,它具有幂等性,因此记录日志的方式极其简练。幂等性的意思是多次操作前后状态是一样的,例如新插入一行后又删除该行,前后状态没有变化。而bin log记录的是所有影响数据的操作,记录的内容较多。例如插入一行记录一次,删除该行又记录一次。

undo log

undo log有两个作用:提供回滚多个行版本控制(MVCC)

在数据修改的时候,不仅记录了redo,还记录了相对应的undo,如果因为某些原因导致事务失败或回滚了,可以借助该undo进行回滚。

undo log和redo log记录物理日志不一样,undo log逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。

当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。有时候应用到行版本控制的时候,也是通过undo log来实现的:当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。

undo log是采用段(segment)的方式来记录的,每个undo操作在记录的时候占用一个undo log segment。

另外,undo log也会产生redo log,因为undo log也要实现持久性保护。

undo log的存储方式

innodb存储引擎对undo的管理采用段的方式。rollback segment称为回滚段,每个回滚段中有1024个undo log segment。

在以前老版本,只支持1个rollback segment,这样就只能记录1024个undo log segment。后来MySQL5.5可以支持128个rollback segment,即支持128*1024个undo操作,还可以通过变量 innodb_undo_logs (5.6版本以前该变量是 innodb_rollback_segments )自定义多少个rollback segment,默认值为128。

undo log默认存放在共享表空间中。

$ ll /mydata/data/ib*-rw-rw---- 1 mysql mysql 79691776 Mar 31 01:42 /mydata/data/ibdata1-rw-rw---- 1 mysql mysql 50331648 Mar 31 01:42 /mydata/data/ib_logfile0-rw-rw---- 1 mysql mysql 50331648 Mar 31 01:42 /mydata/data/ib_logfile1

如果开启了 innodb_file_per_table ,将放在每个表的.ibd文件中。

在MySQL5.6中,undo的存放位置还可以通过变量 innodb_undo_directory 来自定义存放目录,默认值为"."表示datadir。

默认rollback segment全部写在一个文件中,但可以通过设置变量 innodb_undo_tablespaces 平均分配到多少个文件中。该变量默认值为0,即全部写入一个表空间文件。该变量为静态变量,只能在数据库示例停止状态下修改,如写入配置文件或启动时带上对应参数。但是innodb存储引擎在启动过程中提示,不建议修改为非0的值,如下:

2017-03-31 13:16:00 7f665bfab720 InnoDB: Expected to open 3 undo tablespaces but was able2017-03-31 13:16:00 7f665bfab720 InnoDB: to find only 0 undo tablespaces.2017-03-31 13:16:00 7f665bfab720 InnoDB: Set the innodb_undo_tablespaces parameter to the2017-03-31 13:16:00 7f665bfab720 InnoDB: correct value and retry. Suggested value is 0

可以使用命令行show variables like "%undo%";查看undo log的相关参数,上面已经说明。

bin log 二进制日志

bin log包含了引起或可能引起数据库改变(如delete语句但没有匹配行)的事件信息,但绝不会包括select和show这样的查询语句。语句以"事件"的形式保存,所以包含了时间、事件开始和结束位置等信息。

bin log是以事件形式记录的,不是事务日志(但可能是基于事务来记录bin log),不代表它只记录innodb日志,myisam表也一样有bin log。

对于事务表的操作,bin log只在事务提交的时候一次性写入(基于事务的innodb bin log),提交前的每个bin log记录都先cache提交时写入

所以,对于事务表来说,一个事务中可能包含多条二进制日志事件,它们会在提交时一次性写入。而对于非事务表的操作,每次执行完语句就直接写入

MariaDB/MySQL默认没有启动二进制日志,要启用二进制日志使用 --log-bin=[on|off|file_name] 选项指定,如果没有给定file_name,则默认为datadir下的主机名加"-bin",并在后面跟上一串数字表示日志序列号,如果给定的日志文件中包含了后缀(logname.suffix)将忽略后缀部分。

每mysql个线程有自己 binlog cache但是共用同一份 binlog 文件

mysqld还创建一个二进制日志索引文件,当二进制日志文件滚动的时候会向该文件中写入对应的信息。所以该文件包含所有使用的二进制日志文件的文件名。

因为二进制日志文件增长迅速,但官方说明因此而损耗的性能小于1%,且二进制目的是为了恢复定点数据库和主从复制,所以出于安全和功能考虑,极不建议将二进制日志和datadir放在同一磁盘上。

二进制日志可以使用mysqlbinlog命令查看。详情

delete、insert 或者 update 语句导致的数据操作错误,需要恢复到操作之前状态的情况,也时有发生。MariaDB 的Flashback工具就是基于binlog回滚的原理来回滚数据的。

bin log相关参数

注意:在配置binlog相关变量的时候,相关变量名总是搞混,因为有的是binlog,有的是log_bin,当他们分开的时候,log在前,当它们一起的时候,bin在前。在配置文件中也同样如此。

log_bin = {on | off | base_name} #指定是否启用记录二进制日志或者指定一个日志路径(路径不能加.否则.后的被忽略)sql_log_bin ={ on | off } #指定是否启用记录二进制日志,只有在log_bin开启的时候才有效expire_logs_days = #指定自动删除二进制日志的时间,即日志过期时间binlog_do_db = #明确指定要记录日志的数据库binlog_ignore_db = #指定不记录二进制日志的数据库log_bin_index = #指定mysql-bin.index文件的路径binlog_format = { mixed | row | statement } #指定二进制日志基于什么模式记录binlog_rows_query_log_events = { 1|0 } # MySQL5.6.2添加了该变量,当binlog format为row时,默认不会记录row对应的SQL语句,设置为1或其他true布尔值时会记录,但需要使用mysqlbinlog -v查看,这些语句是被注释的,恢复时不会被执行。max_binlog_size = #指定二进制日志文件最大值,超出指定值将自动滚动。但由于事务不会跨文件,所以并不一定总是精确。binlog_cache_size = 32768 #基于事务类型的日志会先记录在缓冲区,当达到该缓冲大小时这些日志会写入磁盘max_binlog_cache_size = #指定二进制日志缓存最大大小,硬限制。默认4G,够大了,建议不要改binlog_cache_use:# 使用缓存写二进制日志的次数(这是一个实时变化的统计值)binlog_cache_disk_use: #使用临时文件写二进制日志的次数,当日志超过了binlog_cache_size的时候会使用临时文件写日志,如果该变量值不为0,则考虑增大binlog_cache_size的值binlog_stmt_cache_size = 32768 # 一般等同于且决定binlog_cache_size大小,所以修改缓存大小时只需修改这个而不用修改binlog_cache_sizebinlog_stmt_cache_use:# 使用缓存写二进制日志的次数binlog_stmt_cache_disk_use: # 使用临时文件写二进制日志的次数,当日志超过了binlog_cache_size的时候会使用临时文件写日志,如果该变量值不为0,则考虑增大binlog_cache_size的值log_slave_updates # 表示备库执行 relay log 后生成 binlogsync_binlog = { 0 | n } #这个参数直接影响mysql的性能和完整性 sync_binlog=0:#不同步,日志何时刷到磁盘由FileSystem决定,这个性能最好。 sync_binlog=n:#每写n次事务(注意,对于非事务表来说,是n次事件,对于事务表来说,是n次事务,而一个事务里可能包含多个二进制事件),MySQL将执行一次磁盘同步指令fdatasync()将缓存日志刷新到磁盘日志文件中。Mysql中默认的设置是sync_binlog=0,即不同步,这时性能最好,但风险最大。一旦系统奔溃,缓存中的日志都会丢失。 # 在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。

innodb的主从复制结构中,如果启用了二进制日志(肯定会启用),要保证事务的一致性和持久性的时候,必须将sync_binlog的值设置为1,因为每次事务提交都会写入二进制日志,设置为1就保证了每次事务提交时二进制日志都会写入到磁盘中,从而立即被从服务器复制过去。

bin log模式

使用binlog_format参数可以指定binlog的格式,三种,可以看这篇

  • 如果使用statement,则binlog里记录的就是sql语句原文,如果主库与备库不一致(如索引不一致),则有可能导致binlog主库执行和备库执行的结果有可能不一致

  • 使用row,binlog里记录的是event,制定了操作的表和行为,会记录真实操作的主键ID之类,保证主备的操作相同

  • 使用mixed是因为row格式的binlog很大,MySQL 自己会判断这条 SQL 语句是否可能引起主备不一致,如果有可能,就用 row 格式,否则就用 statement 格式。

简述MySQL复制

mysql复制是指从一个mysql服务器(MASTER)将数据通过日志的方式经过网络传送到另一台或多台mysql服务器(SLAVE),然后在slave上重放(replay或redo)传送过来的日志,以达到和master数据同步的目的。

  1. 数据修改写入master数据库的binlog中。

  2. slave的IO线程复制这些变动的binlog到自己的relay log中。

  3. slave的SQL线程读取并重新应用relay log到自己的数据库上,让其和master数据库保持一致。这里需要说明,后来由于多线程复制方案的引入,sql_thread 演化成为了多个线程。

MySQL支持四种不同的同步方式:

  1. 同步复制:客户端发送DDL/DML语句给master,master执行完毕后还需要等待所有的slave都写完了relay log才认为此次DDL/DML成功,然后才会返回成功信息给客户端。同步复制的问题是master必须等待,所以延迟较大,在MySQL中不使用这种复制方式。

  2. 半同步复制semi-sync:客户端发送DDL/DML语句给master,master执行完毕后还要等待一个slave写完relay log并返回确认信息给master,master才认为此次DDL/DML语句是成功的,然后才会发送成功信息给客户端。半同步复制只需等待一个slave的回应,且等待的超时时间可以设置,超时后会自动降级为异步复制,所以在局域网内(网络延迟很小)使用半同步复制是可行的。

  3. 异步复制:客户端发送DDL/DML语句给master,master执行完毕立即返回成功信息给客户端,而不管slave是否已经开始复制。这样的复制方式导致的问题是,当master写完了binlog,而slave还没有开始复制或者复制还没完成时,slave上和master上的数据暂时不一致,且此时master突然宕机,slave将会丢失一部分数据。如果此时把slave提升为新的master,那么整个数据库就永久丢失这部分数据。

  4. 延迟复制:顾名思义,延迟复制就是故意让slave延迟一段时间再从master上进行复制。

双M结构

一个mysql集群选出两个节点为master,互相同步binlog,这个是实际生产上用的比较多的结构。但是如果打开log_slave_updates,有可能会造成循环复制的问题。们可以用下面的逻辑,来解决两个节点间的循环复制的问题:

  1. 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系;

  2. 一个备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog;

  3. 每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。

按照这个逻辑,如果我们设置了双 M 结构,日志的执行流就会变成这样:

  1. 从节点 A 更新的事务,binlog 里面记的都是 A 的 server id;

  2. 传到节点 B 执行一次以后,节点 B 生成的 binlog 的 server id 也是 A 的 server id;

  3. 再传回给节点 A,A 判断到这个 server id 与自己的相同,就不会再处理这个日志。所以,死循环在这里就断掉了。

当然,这么做还是有可能能产生循环复制,一种场景是主库修改了service id,一种场景是数据库迁移,可以看这个文章

主备切换与主备延迟

与数据同步有关的时间点主要包括以下三个:

  1. 主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;

  2. 之后传给备库 B,我们把备库 B 接收完这个 binlog 的时刻记为 T2;

  3. 备库 B 执行完成这个事务,我们把这个时刻记为 T3。

主备时延就是T3-T1,也就是mysql中seconds_behind_masters

主备时延的根源:

  1. 备库机器较差

  2. 备库承担了大量的读操作,压力太大

  3. 大事务。主库上必须等事务执行完成才会写入 binlog,再传给备库。比如: 

    1. 一次性delete太多数据

    2. 大表DDL

一般情况下可以用如下方式化解前两个问题:

  1. 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。

  2. 通过 binlog 输出到外部系统,比如 Hadoop 这类系统,让外部系统提供统计类查询的能力。

由于主备延迟的存在,所以在主备切换的时候,就相应的有不同的策略。

  1. 可靠性优先策略,备升主前,先更改主库为readonly状态,然后等备库的seconds_behind_master=0,然后提升备库为主

  2. 可用性优先策略,不等数据同步,直接将备库提升为主,这种策略会导致数据不一致。

备库并行复制

如果备库执行日志的速度持续低于主库生成日志的速度,那这个延迟就有可能成了小时级别。而且对于一个压力持续比较高的主库来说,备库很可能永远都追不上主库的节奏。这时需要引入并行复制,用多线程复制。在官方的 5.6 版本之前,MySQL 只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主备延迟问题。

多线程复制,就是一个线程(cordinator)接收relay log,然后按照一定的规则将日志分发到slave_parallel_workers个线程里。同一个事务必须放到同一个线程,同一行的不同事务,也必须放到同一个线程,有以下几种策略:

  1. 按库分发:mysql 5.6的选择,5.7之后使用参数slave-parallel-type=DATABASE

  2. 按表分发

  3. 按行分发:MySQL 5.7.22的WRITESET

  4. 模拟主库的并行模式:MariaDB根据特性:能够在同一组里提交的事务,一定不会修改同一行;主库上可以并行执行的事务,备库上也一定是可以并行执行的。将redolog的组提交一起放在备库里并行复制 

    1. 在mysql 5.7中使用slave-parallel-type=LOGICAL_CLOCK配置这种方式

官方 MySQL5.7 版本新增的备库并行策略,修改了 binlog 的内容,也就是说 binlog 协议并不是向上兼容的,在主备切换、版本升级的时候需要把这个因素也考虑进去。

一主多从

为了方便描述,我把会在 HA 过程中被选成新主库的,称为备库,其他的称为从库

一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。

相比于一主一备的切换流程,一主多从结构在切换完成后,备库会成为新的主库,从库会指向备库。所以主备切换的复杂性增加了,从库切换主库需要用到语句change master。切主的时候要找到新的主库的同步位点,但是这个位点很难精确的取到,只能取一个大概的位置,考虑到切换过程中不能丢数据,所以我们找位点的时候,总是要找一个“稍微往前”的,然后再通过判断跳过那些在从库 B 上已经执行过的事务(执行事务失败了说明执行过了,就跳过)。通过指定MASTER_LOG_FILE 和 MASTER_LOG_POS参数,指定同步位点。

实际操作的时候,可以设置slave_skip_errors=1032,1062,跳过两类在同步阶段是无损的错误,同步完成之后记得去掉这个参数

  • 1062 错误是插入数据时唯一键冲突;

  • 1032 错误是删除数据时找不到行。

GTID

MySQL 5.6 版本引入了 GTID,彻底解决了从库切主困难。GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。记录在binlog中。它由两部分组成,格式是:GTID=server_uuid:gno其中:(有点像paxos里面的proposal ID了)

  • server_uuid 是一个实例第一次启动时自动生成的,是一个全局唯一的值;

  • gno 是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。注意:这个参数和transactionid也就是事务ID不同,事务 id 是在事务执行过程中分配的,如果这个事务回滚了,事务 id 也会递增,而 gno 是在事务提交的时候才会分配

每个 MySQL 实例都维护了一个 GTID 集合,用来对应“这个实例执行过的所有事务”。如果当前实例已经做了了某个GTID对应的事务,就会跳过它。

并且,可以通过set GTID_NEXT="server_uuid_of_Y:gno";在从库/备库跳过某条binlog的更新。

读写分离

读写分离的主要目标就是分摊主库的压力,在一主多从的mysql架构下,客户端的实现主要有两种方式:

  1. 客户端直连方案,因为少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如 Zookeeper,尽量让业务端只专注于业务逻辑开发。

  2. 带 proxy 的架构,对客户端比较友好。客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由 proxy 完成的。但这样的话,对后端维护团队的要求会更高。而且,proxy 也需要有高可用架构。因此,带 proxy 架构的整体就相对比较复杂。

但是,不论使用哪种架构,你都会碰到我们今天要讨论的问题:由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,就有可能读到刚刚的事务更新之前的状态。我们暂且称这种现象为“过期读”。接下来,我们就来讨论怎么处理过期读问题。

  1. 强制走主库方案;用的最多的方案,将请求做分类,必须要拿到最新请求的去主库读。

  2. sleep 方案;主库更新后,读从库之前先 sleep 一下。具体的方案就是,类似于执行一条 select sleep(1) 命令。

  3. 判断主备无延迟方案;通过查看show slave statusseconds_behind_master参数,或者也可以看位点或者GTID参数: 

    1. Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是备库执行的最新位点。两组只相同说明主从同步完成,可以读了

    2. Auto_Position=1 ,表示这对主备关系使用了 GTID 协议。Retrieved_Gtid_Set,是备库收到的所有日志的 GTID 集合;Executed_Gtid_Set,是备库所有已经执行完成的 GTID 集合。如果这两个集合相同,也表示备库接收到的日志都已经同步完成。

    3. 很明显,这种方法也不是完全同步的,有可能主库的状态、日志号还没有同步到备库。

  4. 配合 semi-sync 方案;半同步复制

  5. 等主库位点方案select master_pos_wait(file, pos[, timeout]);使用这个语句要求从库同步到binlog的具体某个位点,保证这个位点之前的数据全都同步到备库了。

  6. 等 GTID 方案select wait_for_executed_gtid_set(gtid_set, 1);同上,等待直到这个库执行的事务中包含传入的 gtid_set

双主热备

双主热备的关键参数就是:log_slave_updates。让某个从节点开启这个参数,生成在收到relaylog的时候生成binlog。也就是说这个节点即使从节点,又是主节点(可以同步给其他节点binlog)

redo log和bin log的异同

  1. undo log负责原子性,保护事务在exception或手动rollback时可以回滚到历史版本数据。

  2. redo log负责落盘式持久性,保证事务提交后新的数据不会丢失。 

    • 数据库请求来的时候,先写redolog这种WAL log,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面。InnoDB 的 redo log 是固定可配置的,类似一个循环链表的结构。头是write pos,尾是check point。

  3. binlog负责副本式持久性,可以将主节点上的数据复制到从节点,主节点crash后业务可以正常运转。 

    • redo log是存储引擎独有的日志。binlog是server层的归档日志,没有crash-safe的能力,所有存储引擎都可以使用

    • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。

    • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

  4. 由于redo log和 binlog都是按照时间顺序的,所以如果能找到某时刻数据库的的全量备份,然后按照时间顺序恢复redo-log和binlog,就可以恢复数据库的状态到任意一秒。

  5. binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的 

    1. binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。

    2. 而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。

undo log只关心过去,redo log只关心未来

实践中,对于面向个人业务的互联网在线业务,推荐Read Committed;对于分析性业务,推荐Repeatable Read(InnoDB的默认事务隔离级别)

binlog和事务日志的先后顺序及group commit

为了提高性能,通常会将有关联性的多个数据修改操作放在一个事务中,这样可以避免对每个修改操作都执行完整的持久化操作。这种方式,可以看作是人为的组提交(group commit)。

除了将多个操作组合在一个事务中,记录binlog的操作也可以按组的思想进行优化:将多个事务涉及到的binlog一次性flush,而不是每次flush一个binlog。

事务在提交的时候不仅会记录事务日志,还会记录二进制日志,但是它们谁先记录呢?二进制日志是MySQL的上层日志,先于存储引擎的事务日志被写入。

这里要讲日志逻辑序列号(log sequence number,LSN)的概念。LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。

在MySQL5.6以前,当事务提交(即发出commit指令)后,MySQL接收到该信号进入commit prepare阶段;进入prepare阶段后,立即写内存中的二进制日志,写完内存中的二进制日志后就相当于确定了commit操作;然后开始写内存中的事务日志;最后将二进制日志和事务日志刷盘,它们如何刷盘,分别由变量 sync_binlog 和 innodb_flush_log_at_trx_commit 控制。

但因为要保证二进制日志和事务日志的一致性,在提交后的prepare阶段会启用一个prepare_commit_mutex锁来保证它们的顺序性和一致性。但这样会导致开启二进制日志后group commmit失效,特别是在主从复制结构中,几乎都会开启二进制日志。

在MySQL5.6中进行了改进。提交事务时,在存储引擎层的上一层结构中会将事务按序放入一个队列,队列中的第一个事务称为leader,其他事务称为follower,leader控制着follower的行为。虽然顺序还是一样先刷二进制,再刷事务日志,但是机制完全改变了:删除了原来的prepare_commit_mutex行为,也能保证即使开启了二进制日志,group commit也是有效的。

MySQL5.6中分为3个步骤:flush阶段、sync阶段、commit阶段。

  • flush阶段:向内存中写入每个事务的二进制日志。

  • sync阶段:将内存中的二进制日志刷盘。若队列中有多个事务,那么仅一次fsync操作就完成了二进制日志的刷盘操作。这在MySQL5.6中称为BLGC(binary log group commit)

  • commit阶段leader根据顺序调用存储引擎层事务的提交,由于innodb本就支持group commit,所以解决了因为锁 prepare_commit_mutex 而导致的group commit失效问题。

在flush阶段写入二进制日志到内存中,但是不是写完就进入sync阶段的,而是要等待一定的时间,多积累几个事务的binlog一起进入sync阶段,等待时间由变量 binlog_max_flush_queue_time 决定,默认值为0表示不等待直接进入sync,设置该变量为一个大于0的值的好处是group中的事务多了,性能会好一些,但是这样会导致事务的响应时间变慢,所以建议不要修改该变量的值,除非事务量非常多并且不断的在写入和更新

进入到sync阶段,会将binlog从内存中刷入到磁盘,刷入的数量和单独的二进制日志刷盘一样,由变量 sync_binlog 控制。

当有一组事务在进行commit阶段时,其他新事务可以进行flush阶段,它们本就不会相互阻塞,所以group commit会不断生效。当然,group commit的性能和队列中的事务数量有关,如果每次队列中只有1个事务,那么group commit和单独的commit没什么区别,当队列中事务越来越多时,即提交事务越多越快时,group commit的效果越明显。


简单来讲,就是某个事务需要持久化redo log的时候,发现LSN已经被其他几个并发事务推进了,因此直接将最大的LSN持久化进去。这样其他几个事务就不需要落盘,可以直接返回了。所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。

在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。

MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了写完bin log之后,如果想进一步提升bin log组提交的效果,可以设置:

  1. binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;

  2. binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。

这两个条件是或的关系,也就是说只要有一个满足条件就会调用 fsync。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。其他两种方法都有丢数据的风险:

  1. 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。

  2. 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。

上面这两个称为MySQL 的“双 1”配置,一般情况下都是要设置为1的

什么情况下设置“非双1”

  1. 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”。

  2. 备库延迟,为了让备库尽快赶上主库。当然只是备库日志大幅落后的时候,如果快要追上了,设置“非双1”反而会成为累赘

  3. 用备份恢复主库的副本,应用 binlog 的过程,这个跟上一种场景类似。批量导入数据的时候。

索引

常见的三种索引哈希表、有序数组(有序数组索引只适用于静态存储引擎,表中的内容一旦保存便不会修改)和有序表。

InnoDB的索引模型

每一个索引在 InnoDB 里面对应一棵 B+ 树。每一个索引都会建立一颗B+ tree。

根据叶子节点的内容,索引类型分为主键索引和非主键索引。

主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引clustered index)。

非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引secondary index)。

所以,基于非主键索引的查询需要多扫描一棵索引树(先搜索二级索引,再搜索聚簇索引,这个过程称为回表)。因此,我们在应用中应该尽量使用主键查询。

索引维护

B+ tree在操作过程中存在页的分裂与合并。

下面我们主要讨论什么场景下使用自增主键,什么情况下不使用:

  1. 建表时使用NOT NULL PRIMARY KEY AUTO_INCREMENT创建自增主键

  2. 自增主键每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。

  3. 并且自增主键的存储类型可以按需指定,不会造成空间浪费

  4. 而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。

  5. 但是某些业务场景,一是只有一个索引,二是该索引必须是唯一索引,比如KV系统。这时候就直接指定业务字段作为主键就行了。

重建索引

重建索引 k 的:

alter table T drop index k;alter table T add index(k);alter table T add index2(email(6)); -- 使用email的前六位作为索引(前缀索引)

重建主键:

alter table T drop primary key;alter table T add primary key(id);

上面重建主键的过程不合理。不论是删除主键还是创建主键,都会将整个表重建。所以连着执行这两个语句的话,第一个语句就白做了。所以这两个语句应该用这个语句代替 : alter table T engine=InnoDB

索引优化

覆盖索引

如果where里面限定的不是主键,而select里面又需要除了主键和where里面的键,每次搜索都会造成回表,也就是说搜一个值要查2次表,第一次查二级索引,第二次查聚簇索引。如果select里面只需要主键,那么久不需要回表了。这个技巧称为覆盖索引

另外,如果select里真的需要除了主键和where里面的键的其他键值,并且对其操作是高频的,我们可对针对这几个键建立联合索引

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

最左前缀原则

单独为一个不频繁的请求创建一个的索引感觉有点浪费。应该怎么做呢?对于B+ 树这种索引结构,可以利用索引的“最左前缀”,来定位记录。也就是说,在建立联合索引的时候,适当的安排索引内部的字段,可以加速索引检索。

索引下推

在 MySQL 5.6 之前,回表需要一个个回表。到主键索引上找出数据行,再对比字段值。

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

索引类型

索引分为普通索引和唯一索引

  • 普通索引:普通索引(由关键字KEY或INDEX定义的索引)的唯一任务是加快对数据的访问速度。ALTER TABLE {table_name} ADD INDEX index_name ( {column} )

  • 唯一索引:它与前面的普通索引类似,不同的就是:索引列的值必须唯一,但允许有空值。如果是组合索引,则列值的组合必须唯一。另外主键ID应该也是唯一索引。ALTER TABLE {table_name} ADD UNIQUE index_name ( {lolumn} )

那么,问题来了,这两者间如何选择呢?

唯一索引和普通索引最直观的不同在于,唯一索引一旦查询到满足条件的记录后,就会停止继续检索,而普通索引因为会重复,会继续向下检索。那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。InnoDB 的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在 InnoDB 中,每个数据页的大小默认是 16KB。

更新操作

当需要更新一个数据页时,如果数据页在内存缓冲池buffer pool)中就直接更新,并同时记录redo log,但是如果这个数据页不在内存中的话。在不影响一致性的前提下,InnoDB会将更新操作缓存在写缓冲(change buffer)中,同时记录redo log。

change buffer 插入缓冲 写缓冲

change buffer的主要目的是将对二级索引的数据操作缓存下来,以此减少二级索引的随机IO,并达到操作合并的效果。

在MySQL5.5之前的版本中,由于只支持缓存insert操作,所以最初叫做insert buffer,只是后来的版本中支持了更多的操作类型(操作类型包括insert、update、delete)缓存,才改叫change buffer。

change buffer的数据结构上是一颗b+树,存储在ibdata系统表空间中,根页为ibdata的第4个page(FSP_IBUF_TREE_ROOT_PAGE_NO)。将change buffer中的操作应用到原数据页从而得到最新结果的过程被称为merge。merge 的时候才是是真正进行数据更新的时刻,change buffer 将条目的变更动作进行缓存。在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。一般来说,触发merge的操作主要有以下几种:

  1. 访问这个数据页;

  2. master thread线程每秒或每10秒进行一次merge insert buffer的操作;

  3. 在数据库正常关闭的时候。

此外,虽然名字叫做change buffer,但实际上它是可以持久化的数据,也就说它在内存中有拷贝,也会被写入到磁盘上。

如果MySQL承担大量的DML操作,则change buffer是必不可少的,他的存在就是尽量减小I/O的消耗,通过内存进行数据的合并操作,将多次操作操作尽量变为少量的I/O操作,从而提升了更新操作的速度。

开启change buffer的读写流程

这里讨论的是普通索引

写:

  1. 如果更新的对应的页在内存(数据表空间(t.ibd))中,则直接更新内存

  2. 如果没在内存中,则在change buffer中记录

  3. 更新redo log,并且将change buffer(系统表空间(ibdata1))的这个动作也记录在redo log中,并持久化

  4. 事务完成

读:

  1. 如果对应页本来就在内存中,直接从内存返回

  2. 如果记录在change buffer中存在,则首先将对应的页从磁盘读入内存,再与change buffer merge,生成一个新版本并返回。

change buffer merge流程

merge 的执行流程是这样的:

  1. 从磁盘读入数据页到内存(老版本的数据页);

  2. 从 change buffer 里找出这个数据页的 change buffer 记录 (可能有多个),依次应用,得到新版数据页;

  3. 写 redo log。这个 redo log 包含了数据的变更和 change buffer 的变更。

到这里 merge 过程就结束了。这时候,数据页和内存中 change buffer 对应的磁盘位置都还没有修改,属于脏页,之后各自刷回自己的物理数据,就是另外一个过程了。

什么场景适合开启change buffer?

唯一索引的更新就不能使用 change buffer,只有普通索引可以。因为唯一索引要判断键值是否重复,必须要把磁盘中的页读上来,如果已经读上来了,直接更新buffer pool就行了,也不用使用change buffer了。所以唯一索引会增加对磁盘的访问,降低内存的命中率。

普通索引并不是所有场景使用change buffer都能受益,

  1. write intensive业务:页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。

  2. 但是假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。所以,对于这样类似的业务模式来说,change buffer 反而起到了副作用。这时候应该关闭change buffer

  3. 所以在实际业务中,在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。

总的来说我感觉普通索引的性能更好,唯一索引的的作用更多的是为业务场景考虑

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类。业务的更新操作一般有两种:增删改数据 DMLData Manipulation Language),加字段等修改表结构的操作 DDLData Definition Language)。

全局锁

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

全局锁的典型使用场景是,做全库逻辑备份。InnoDB支持MVCC,可以使用官方自带的逻辑备份工具mysqldump,加上参数–single-transaction开启事务,保证一致性读。这种情况下逻辑备份可以不需要FTWRL吗,如果使用不支持事务的存储引擎myISAM,就只能加全局锁备份了

为什么不使用 set global readonly=true

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

  1. 在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大,我不建议你使用。

  2. 在异常处理机制上有差异。如果执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。

表级锁

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

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

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

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

MDL

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

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

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。

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


读锁 写锁
读锁 兼容 冲突
写锁 冲突 冲突

原文举了例子为什么读写锁会造成死锁

行锁

顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,比如事务 A 更新了一行,而这时候事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。

两阶段锁协议:在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。两阶段锁对事务的影响是:如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

死锁检测

出现死锁之后有两种策略:

  1. 事务直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。这个参数默认是50s,这在实际业务中基本上是不可接受的

  2. 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect设置为 on,表示开启这个逻辑。 

    • 如果业务知道这个操作一定不会出现死锁,就临时将死锁检测关掉,这有可能导致业务请求的超时

    • 另一个思路是控制并发度,让业务方把并发度控制在一个死锁检测可以接受的范围内

    • 另外一个方法,就是将一行拆为多行,业务随机选择一行进行更新,最后算总账。。。

    • 死锁检测的机制是,如果请求加锁访问的行上有锁,则进行死锁检测。

    • 死锁检测的问题是,这种机制有可能要耗费大量测cpu资源,每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。1000个并发线程同时更新同一行,那死锁检测就要消耗100万的时间复杂度。而最终的检测结果却是没有死锁。

    • 如何解决热点行更新导致的性能问题 

间隙锁 Gap Lock

为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。间隙锁只有

顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表 t,初始化插入了 6 个记录,这就产生了 7 个间隙。

比如,执行语句select * from t where d=5 for update加锁的时候,不仅加行锁,还给行两边的空隙也加间隙锁,这样,如果其他的事务执行”往这个间隙中插入一个记录“这样的操作,就会

间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。也就是说,我们的表 t 初始化以后,如果用 select * from t for update 要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

加锁规则

21 | 为什么我只改一行的语句,锁这么多? 讲了很多好的例子,我没看完

因为间隙锁在可重复读隔离级别下才有效,所以本篇文章接下来的描述,若没有特殊说明,默认是可重复读隔离级别(读提交隔离级别下,锁的范围更小,锁的时间更短,这也是不少业务都默认使用读提交隔离级别的原因)。加锁规则总结如下,包含了两个“原则”、两个“优化”和一个“bug”。

  1. 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。

  2. 原则 2:查找过程中访问到的对象才会加锁。不会给自己没有访问到的索引加锁

  3. 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。

  4. 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。

  5. 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

MySQL事务实战

数据库的几种视图view

  1. 一个是 view。���是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。

  2. 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。但是,begin/start transaction 命令并不是一个事务的起点,启动一个事务有以下两种方式: 

    1. 使用 start transaction with consistent snapshot启动一个事务

    2. 在start transaction之后的第一个快照读语句(无论是select还是update,反正只要读就启动事务了)

  3. 在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要 注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行 化”隔离级别下直接用加锁的方式来避免并行访问。

不同隔离级别下的一致性视图view

row trx_id

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。事务需要旧版本数据源时,从最新的版本依次做undo log,直到读到符合事务要求的版本。

如何实现可重复读

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。

当事务启动时(生成一致性视图),某行的数据版本的row trx_id有以下几种可能:

  1. 如果row trx_id落在低水位之前,则数据可见

  2. 如果row trx_id落在高水位之后,则肯定不可见

  3. 如果落在高水位和低水位之间,则 

    1. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;

    2. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

操作

以下面的表t为例

+----+------+| id | k |+----+------+| 1 | 1 || 2 | 2 |+----+------+

有下面三个事务

事务A 事务B 事务C
start transaction with consistent snapshot;


start transaction with consistent snapshot;


update t set k=k+1 where id=1;

update t set k=k+1 where id=1; select k from t where id=1;
select k from t where id=1;commit;


commit;

update 操作

虽然当前的隔离级别是RR,但是事务B的update其实是能看到事务C的操作的。这是因为更新数据不能在历史版本上更新,否则事务c的更新就丢失了。这里用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”current read)。当前读有可能会导致一些似是而非的问题,因为事务开始的时候读到的值和update的时候读到的值有可能是不一样的。(也就是说MySQL的Repeatable Read 隔离级别允许出现不可重复读)

更进一步说,事务C如果不提交,则其使用“两阶段锁协议”对id=1的hang加的写锁还没有被释放,而事务B是当前读,必须要读最新版本,而且必须加锁,因此只有等事务C退出之后,事务B才能继续进行。

除了update,带有锁的select也是当前读,如下面两个语句

-- 读锁(S锁,共享锁)mysql> select k from t where id=1 lock in share mode;
-- 写锁(X锁,排它锁)mysql> select k from t where id=1 for update;

select 操作

如果隔离级别是RR(可重复读),则事务中的普通select会通过MVCC找到对应的版本去读。(上图的事务A返回k=1)

如果隔离级别是RC(读提交),则可以读到已经commit的最新的数据(上图的事务A返回k=2,因为事务B还未提交)

mysql不支持表结构的RR可重复读

你也可以想一下,为什么表结构不支持“可重复读”?这是因为表结构没有对应的行数据,也没有 row trx_id,因此只能遵循当前读的逻辑。

当然,MySQL 8.0 已经可以把表结构放在 InnoDB 字典里了,也许以后会支持表结构的可重复读。

为什么不见使用长事务

长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的undo log都必须保留,这就会导致大量占用存储空间。(还有一个原因是两阶段加锁会导致锁在事务结束才会释放)

在 MySQL 5.5 及以前的版本,undo log是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。我见过数据只有 20GB,而回滚段有 200GB 的库。最终只好为了清理回滚段,重建整个库。

除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。

MySQL事务启动方式

如前面所述,长事务有这些潜在风险,我当然是建议你尽量避免。其实很多时候业务开发同学并不是有意使用长事务,通常是由于误用所致。MySQL 的事务启动方式有以下几种:

  1. 显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。

  2. set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。

有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。因此,我会建议你总是使用 set autocommit=1, 通过显式语句的方式来启动事务。

但是有的开发同学会纠结“多一次交互”的问题。对于一个需要频繁使用事务的业务,第二种方式每个事务在开始时都不需要主动执行一次 “begin”,减少了语句的交互次数。如果你也有这个顾虑,我建议你使用 commit work and chain 语法。

在 autocommit 为 1 的情况下,用 begin 显式启动的事务,如果执行 commit 则提交事务。如果执行 commit work and chain则是提交事务并自动启动下一个事务,这样也省去了再次执行 begin 语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。

你可以在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。

select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
小例子
--set autocommit=0begin transaction;INSERT INTO T (ID, c) VALUES (3, 3);commit;--注意,如果执行set autocommit=1,最后一步commit不需要执行,更新完成之后之后自动提交
-- 或者begin transaction;INSERT INTO T (ID, c) VALUES (3, 3),(5, 5);commit work and chain;INSERT INTO T (ID, c) VALUES (4, 4);commit work and chain;

如何避免长事务对业务的影响?

首先,从应用开发端来看:

  1. 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。

  2. 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。

  3. 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)

其次,从数据库端来看:

  1. 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;

  2. Percona 的 pt-kill 这个工具不错,推荐使用;

  3. 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;

  4. 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

存储引擎

InnoDB

  1. InnoDB默认使用MVCC来支持高并发,默认的隔离级别是REPEATABLE READ(可重复读),并通过间隙所(next-key locking)策略防止幻读出现

  2. InnoDB表是基于聚簇索引建立的,聚簇索引对主键的查询有很高的性能

  3. InnoDB将undo log作为数据的一部分存储到了redo log中,因此很多时候不太区分它们

  4. 幻读:同在一个事务里面,后一个请求看到的比之前相同请求看到的,多了记录出来

myISAM

  1. 不支持事务和行级锁,只有表锁,会极大的影响性能。

如何安全的给一个表加字段

对某个表中执行增加字段操作:alter table T add f int;

首先我们要解决长事务,事务不提交,就会一直占着 MDL 锁,导致alter table无法进行,从而阻塞后面所以的查询操作。

在 MySQL 的 information_schema 库的 innodb_trx 表中,你可以查到当前执行中的事务。如果你要做 DDL 变更的表刚好有长事务在执行,要考虑先暂停 DDL,或者 kill 掉这个长事务。

但考虑一下这个场景:如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你该怎么做呢?这时候 kill 可能未必管用,因为新的请求马上就来了。比较理想的机制是,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员或者 DBA 再通过重试命令重复这个过程。MariaDB 已经合并了 AliSQL 的这个功能,所以这两个开源分支目前都支持 DDL NOWAIT/WAIT n 这个语法。

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

如何高效的删除表中数据

如果你要删除一个表里面的前 10000 行数据,有以下三种方法可以做到:

  1. 第一种,直接执行 delete from T limit 10000;

  2. 第二种,在一个连接中循环执行 20 次 delete from T limit 500;

  3. 第三种,在 20 个连接中同时执行 delete from T limit 500;

第二种方式比较好,第一种方式单个语句时间长,锁占用也长,而且这种大事务会造成主从延迟,第三种并发做会人为造成锁冲突

如何高效的给字符串加索引

  1. 前缀索引,这种方式会增加查询扫描次数,并且不能使用覆盖索引(从前缀索引中检索到之后,还需要回表去判断整个字符串字段的值是否符合规则)

  2. 如果前缀索引的区分度不够好: 

    1. 倒序存储,正序区分度不够,倒序存储有可能区分度就够了,检索的时候再reverse回来。select field_list from t where id_card = reverse('input_id_card_string');

    2. 使用hash字段,在表里增加一个字段,存储字符转的哈希值。alter table t add id_card_crc int unsigned, add index(id_card_crc);。然后每次插入的时候更新id_card_crc字段。查询的时候也直接查crc。select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string'

倒序索引和hash字段比较

  1. 从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而 hash 字段方法需要增加一个字段。当然,倒序存储方式使用 4 个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个 hash 字段也差不多抵消了。

  2. 在 CPU 消耗方面,倒序方式每次写和读的时候,都需要额外调用一次 reverse 函数,而 hash 字段的方式需要额外调用一次 crc32() 函数。如果只从这两个函数的计算复杂度来看的话,reverse 函数额外消耗的 CPU 资源会更小些。

  3. 从查询效率上看,使用 hash 字段方式的查询性能相对更稳定一些。因为 crc32 算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近 1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。

mysql内存下刷

mysql对数据的修改会首先修改到buffer pool中,等到合适的实际时间下刷。当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。

有两种情况是会导致内存下刷脏页:

  1. buffer pool 满了,系统需要淘汰部分脏页,才能空出内存给别的数据页使用。 

    1. 第一种是,还没有使用的

    2. 第二种是,使用了并且是干净页

    3. 第三种是,使用了并且是脏页

    4. InnoDB使用缓冲池(buffer pool)管理内存,缓冲池有三种状态: 

    5. 而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。如果这时要淘汰的脏页比较多,就会影响性能。

  2. redo log写满了,系统就会停止所有的更新操作,把 checkpoint 往前推进,redo log 留出空间可以继续写。如果checkpoint推进,则对应的所有脏页都需要flush到磁盘上。 

    1. 这种情况是 InnoDB 要尽量避免的。因为出现这种情况的时候,整个系统就不能再接受更新了,所有的更新都必须堵住。如果你从监控上看,这时候更新数会跌为 0。

  3. 系统定时或者在觉得系统不繁忙的时候下刷脏页

  4. mysql正常关闭

InnoDB刷脏页的控制策略

  • innodb_io_capacity 告诉 InnoDB 你的磁盘能力。这个值我建议你设置成磁盘的 IOPS(通过fio获取)。如果设置的速率和磁盘不匹配,比如配置的比较低,就会导致刷脏页较慢。

  • innodb_max_dirty_pages_pct,buffer pool中脏页的最大比率,最大75%

  • innodb_flush_neighbors,值为1则在下刷脏页的时候,如果旁边的页也是脏页,则一起下刷掉,并且这个策略还会继续扩散。这个优化在机械硬盘上可以减少随机IO。如果在ssd上,IOPS往往不是瓶颈,可以配置成0,这样可以减少刷脏页的操作时间,降低SQL语句的响应时间。在 MySQL 8.0 中,innodb_flush_neighbors 参数的默认值已经是 0 了。

  • 还有一些下刷日志的参数,写在别处了innodb_flush_log_at_trx_commit

  • innodb_page_size,InnoDB页大小,默认16k

InnoDB通过查看当前redo-log的水位、脏页比率(两者越高,下刷速率越快),同时参考innodb_io_capacity,控制下刷脏页的速度

通过下面查询可以获取脏页比率

mysql> select VARIABLE_VALUE into @a from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty';select VARIABLE_VALUE into @b from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_total';select @a/@b;

如果redo-log设置的太小,会怎样

一个内存配置为 128GB、innodb_io_capacity 设置为 20000 的大规格实例,正常会建议你将 redo log 设置成 4 个 1GB 的文件。

但如果你在配置的时候不慎将 redo log 设置成了 1 个 100M 的文件,会发生什么情况呢?又为什么会出现这样的情况呢?

次事务提交都要写 redo log,如果设置太小,很快就会被写满,导致系统频繁的停下所有的更新,去推进checkpoint,看到的现象就是磁盘压力很小,但是数据库出现间歇性的性能下跌。

据库表的空间回收,以及重建表

InnoDB 表包含两部分,即:表结构定义和数据。在 MySQL 8.0 版本以前,表结构是存在以.frm 为后缀的文件里。而 MySQL 8.0 版本,则已经允许把表结构定义放在系统数据表中了。

  • innodb_file_per_table:OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起(ibdata文件);ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。从 MySQL 5.6.6 版本开始,它的默认值就是 ON 了。 

    • 建议不论使用 MySQL 的哪个版本,都将这个值设置为 ON。因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的

数据删除流程

由于数据存储在B+ tree中,删除只是标记删除,其位置会被复用

  1. 删除某行,空间只能被当前表复用

  2. 如果删除表,或者表上的页合并,空出来的数据页可以被任何表复用。

  3. 也就是说,delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。

  4. 这些可复用的位置就叫做空洞

  5. 插入操作有可能造成表分裂,也有可能空洞

重建表

如果我们可以将这些空洞去掉,就可以收缩表空间。(需要注意的是,在重建表的时候,InnoDB 不会把整张表占满,每个页留了 1/16 给后续的更新用。也就是说,其实重建表之后不是“最”紧凑的。)

alter table A engine=InnoDB;

上面就是重建表的过程,在5.6之间,整个DDL的过程会锁表,不能有更新。

MySQL 5.6 版本开始引入的 Online DDL,对这个操作流程做了优化。过程如下:

  1. 建立一个临时文件,扫描表 A 主键的所有数据页;

  2. 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中;

  3. 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态;

  4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3 的状态;

  5. 用临时文件替换表 A 的数据文件。

online DDL持有的MDL锁是读锁,而不是写锁,这样,就不会阻塞删改操作了。并且可以禁止其他线程对这个表同时做DDL,具体步骤如下:

  1. 首选,在开始进行 DDL 时,需要拿到对应表的 MDL X 锁,然后进行一系列的准备工作;

  2. 然后将 MDL X 锁降级为 MDL S 锁,进行真正的 DDL 操作;

  3. 最后再次将 MDL S 锁升级为 MDL X 锁,完成 DDL 操作,释放 MDL 锁;

需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的。因此,如果是线上服务,你要很小心地控制操作时间。如果想要比较安全的操作的话,我推荐你使用 GitHub 开源的 gh-ost 来做。

optimize table、analyze table 和 alter table 这三种方式重建表的区别

  • 从 MySQL 5.6 版本开始,alter table t engine = InnoDB(也就是 recreate)默认就是online了。

  • analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了 MDL 读锁;

  • optimize table t 等于 recreate+analyze。

count(*)

  1. MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count() 的时候会直接返回这个数,效率很高;MyISAM 表虽然 count() 很快,但是不支持事务;

  2. show table status 命令虽然返回很快,但是不准确;

  3. InnoDB 表直接 count(*) 会遍历全表,数据一行一行地从引擎里面读出来,然后累积计数。虽然结果准确,但会导致性能问题。

如何查询表中所有行

首先你要弄清楚 count() 的语义。count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。

所以,count(*)count(主键 id) 和 count(1) 都表示返回满足条件的结果集的总行数;而 count(字段),则表示返回满足条件的数据行里面,参数“字段”不为 NULL 的总个数。

至于分析性能差别的时候,你可以记住这么几个原则:

  1. server 层要什么就给什么;

  2. InnoDB 只给必要的值;

  3. 现在的优化器只优化了 count(*) 的语义为“取行数”,其他“显而易见”的优化并没有做。

这是什么意思呢?接下来,我们就一个个地来看看。

  • 对于 count(主键 id) 来说,InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。

  • 对于 count(1) 来说,InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。单看这两个用法的差别的话,你能对比出来,count(1) 执行得要比 count(主键 id) 快。因为从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作。

  • 对于 count(字段)来说: 

    • 如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;

    • 如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。也就是前面的第一条原则,server 层要什么字段,InnoDB 就返回什么字段。

    • 但是 count(*)是例外,并不会把全部字段取出来,而是专门做了优化,不取值。count() 肯定不是 null,按行累加。看到这里,你一定会说,优化器就不能自己判断一下吗,主键 id 肯定非空啊,为什么不能按照 count() 来处理,多么简单的优化啊。当然,MySQL 专门针对这个语句进行优化,也不是不可以。但是这种需要专门优化的情况太多了,而且 MySQL 已经优化过 count(*) 了,你直接使用这种用法就可以了。

所以结论是:按照效率排序的话,count(字段)<count(主键 id)<count(1)≈count(*),所以我建议你,尽量使用 count(*)

order by

order by的SQL语句应该都会用到排序,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。实际任务中,排序所需的内存和参数 sort_buffer_size,有可能会用到外排。sort_buffer_size 越小,需要分成的份数越多,number_of_tmp_files 的值就越大。

  • max_length_for_sort_data:控制用于排序的行数据的长度的一个参数。它的意思是,如果单行的长度超过这个值,MySQL 就认为单行太大,要从全字段排序P换成rowid算法。具体实现中,涉及到多次回表读取行的信息。

  • sort_buffer_sizesort_buffer的大小

/* 打开optimizer_trace,只对本线程有效 */SET optimizer_trace='enabled=on'; 
/* @a保存Innodb_rows_read的初始值 */select VARIABLE_VALUE into @a from performance_schema.session_status where variable_name = 'Innodb_rows_read';
/* 执行语句 */select city, name,age from t where city='杭州' order by name limit 1000;
/* 查看 OPTIMIZER_TRACE 输出 */SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
/* @b保存Innodb_rows_read的当前值 */select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read';
/* 计算Innodb_rows_read差值 */select @b-@a;

查看OPTIMIZER_TRACE 的 number_of_tmp_files,可以确认是否使用了外排(创建临时文件)

排序方法

  • 全字段排序:执行全字段排序会减少磁盘访问,但是窄用空间比较多

  • rowid排序:rowid 方式和全字段方式一样,需要先把查询到的结果全部放在内存或硬盘中,再使用相关算法进行排序。而排序后由于没有保存所需的字段,需要按顺序使用主键再从索引树上查询,查到一个就返回一个,而不用把所有内容查完放到内存上再一并返回。

如何显示随机值

-- 建表mysql> CREATE TABLE `words` ( `id` int(11) NOT NULL AUTO_INCREMENT, `word` varchar(64) DEFAULT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
delimiter ;;create procedure idata()begin declare i int; set i=0; while i<10000 do insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10)))); set i=i+1; end while;end;;delimiter ;
call idata();
  1. order by rand() limit,需要先排序再随机,overhead比较大

  2. 应用层做

-- 随机算法1:然后获取主键的上限和下限,然后在应用层随机查询几个,但是id中可能有空洞,导致每行选中的概率不均。mysql> select max(id),min(id) into @M,@N from t ;set @X= floor((@M-@N+1)*rand() + @N);select * from t where id >= @X limit 1;
-- 使用limit语句,解决了上面的概率不均匀的问题mysql> select count(*) into @C from t;set @Y = floor(@C * rand());set @sql = concat("select * from t limit ", @Y, ",1");prepare stmt from @sql;execute stmt;DEALLOCATE prepare stmt;
-- 上面是随机取一个,下面我们随机取三个
mysql> select count(*) into @C from t;set @Y1 = floor(@C * rand());set @Y2 = floor(@C * rand());set @Y3 = floor(@C * rand());select * from t limit @Y11//在应用代码里面取Y1、Y2、Y3值,拼出SQL后执行select * from t limit @Y21select * from t limit @Y31

慢查询

慢查询的现象:

  1. 等锁

  2. 其他的长事务影响当前查询

慢查询的可能:

  1. 索引没有设计好;alter table 增加索引

  2. SQL 语句没写好; flush_rewirte_rules() 改写规则

  3. MySQL 选错了索引。给语句加上force index

幻读

幻读的定义:在同一个事务里面,后一个请求看到的比之前相同请求看到的,多了记录出来

  • 可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。

  • 幻读仅指读出了多的行,某行的数据读出来不一致不是幻读,最多算是当前读的现象。

首先建表,主键id,索引c

CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`)) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);

幻读有什么问题?

  1. 首先在语义上有问题,多出的行说明其他的事务操作了当前事务应该锁住的行,把当前事务的语义破坏了

  2. 破坏了数据一致性。比如一个事务A要update所有id=5的行,但是事务执行完之后发现有些id=5的行没有被修改。因为其他事务B插入了id=5的行(就算加锁,事务B也会阻塞等待A释放锁,释放锁之后仍然会执行事务),导致了幻读。

如何解决幻读

为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。间隙锁只有

顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表 t,初始化插入了 6 个记录,这就产生了 7 个间隙。

比如,执行语句select * from t where d=5 for update加锁的时候,不仅加行锁,还给行两边的空隙也加间隙锁,这样,如果其他的事务执行”往这个间隙中插入一个记录“这样的操作,就会

间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。也就是说,我们的表 t 初始化以后,如果用 select * from t for update 要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

幻读带来的问题

间隙锁会引发额外的死锁问题,考虑下面这个语句,只要有并发就会死锁

事务A 事务B
1. begin;
2. select * from t where id=9 for update;

1. begin;

2. select * from t where id=9 for update;

3. insert into t value(9, 9, 9);

(Blocked)
3. insert into t value(9, 9, 9);
(ERROR 1213(40001): Deadlock found)
  • 事务B.3在试图插入的时候被事务A的间隙锁A挡住了

  • 事务A.3在试图插入的时候被事务B的间隙锁A挡住了

  • 产生思索

间隙锁是在可重复读隔离级别下才会生效的。所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,你要解决可能出现的数据和日志不一致问题,需要把 binlog 格式设置为 row (binlog_format=row)。这,也是现在不少公司使用的配置组合。

主从高延迟解决思路

slave通过IO线程获取master的binlog,并通过SQL线程来应用获取到的日志。因为各个方面的原因,经常会出现slave的延迟(即Seconds_Behind_Master的值)非常高(动辄几天的延迟是常见的,几个小时的延迟已经算短的),使得主从状态不一致。

一个很容易理解的延迟示例是:假如master串行执行一个大事务需要30分钟,那么slave应用这个事务也大约要30分钟,从master提交的那一刻开始,slave的延迟就是30分钟,更极端一点,由于binlog的记录时间点是在事务提交时,如果这个大事务的日志量很大,比如要传输10多分钟,那么很可能延迟要达到40分钟左右。而且更严重的是,这种延迟具有滚雪球的特性,从延迟开始,很容易导致后续加剧延迟。

所以,第一个优化方式是不要在mysql中使用大事务,这是mysql主从优化的第一口诀

在回归正题,要解决slave的高延迟问题,先要知道Second_Behind_Master是如何计算延迟的:SQL线程比IO线程慢多少(其本质是NOW()减去Exec_Master_Log_Pos处设置的TIMESTAMP)。在主从网络状态良好的情况下,IO线程和master的binlog大多数时候都能保持一致(也即是IO线程没有多少延迟,除非事务非常大,导致二进制日志传输时间久,但mysql优化的一个最基本口诀就是大事务切成小事务),所以在这种理想状态下,可以认为主从延迟说的是slave上的数据状态比master要延迟多少。它的计数单位是秒。

  1. 从产生Binlog的master上考虑,可以在master上应用group commit的功能,并设置参数binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count,前者表示延迟多少秒才提交事务,后者表示要堆积多少个事务之后再提交。这样一来,事务的产生速度降低,slave的SQL线程相对就得到缓解。

  2. 再者从slave上考虑,可以在slave上开启多线程复制(MTS)功能,让多个SQL线程同时从一个IO线程中取事务进行应用,这对于多核CPU来说是非常有效的手段。但是前面介绍多线程复制的时候说过,没有掌握多线程复制的方方面面之前,千万不要在生产环境中使用多线程复制,要是出现gap问题,很让人崩溃。

  3. 最后从架构上考虑。主从延迟是因为slave跟不上master的速度,那么可以考虑对master进行节流控制,让master的性能下降,从而变相提高slave的能力。这种方法肯定是没人用的,但确实是一种方法,提供了一种思路,比如slave使用性能比master更好的硬件。另一种比较可取的方式是加多个中间slave层(也就是master->slaves->slaves),让多个中间slave层专注于复制(也可作为非业务的他用,比如用于备份)。

  4. 使用组复制或者Galera/PXC的多写节点,此外还可以设置相关参数,让它们对延迟自行调整。但一般都不需要调整,因为有默认设置。

还有比较细致的方面可以降低延迟,比如设置为row格式的Binlog要比statement要好,因为不需要额外执行语句,直接修改数据即可。比如master设置保证数据一致性的日志刷盘规则(sync_binlog/innodb_flush_log_at_trx_commit设置为1),而slave关闭binlog或者设置性能优先于数据一致性的binlog刷盘规则。再比如设置slave的隔离级别使得slave的锁粒度放大,不会轻易锁表(多线程复制时避免使用此方法)。还有很多方面,选择好的磁盘,设计好分库分表的结构等等,这些都是直接全局的,实在没什么必要在这里多做解释。

MySQL crash-safe保证

  1. 如果客户端收到事务成功的消息,事务就一定持久化了;

  2. 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;

  3. 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。

工作中的小技巧

  • 22 | MySQL有哪些“饮鸩止渴”提高性能的方法?

TPS与QPS

  • TPS Transactions Per Second,每次事务两次落盘,可以使用组提交优化

  • QPS Queries Per Second,这种就算是update操作也不一定会及时落盘的。

外键 FOREIGN KEY

外键与主键相对,作用就是通过主外键的之间关系使对张表中的数据更好的关联。

  • 从表外键的值是对主表主键的引用。

  • 从表外键类型,必须与主表主键类型一致。

  • FOREIGN KEY 约束也能防止非法数据插入外键列,因为它必须是它指向的那个表中的值之一。

外键的构造有两种方法:

-- 构造表时指定外键[constraint 外键约束关系的名称] foreign key 从表(外键字段名称) references 主表(主键字段名称)
-- alter tablealter table 从表 add [constraint] [外键名称] foreign key (从表外键字段名) references 主表 (主表的主键);

下面举一个小例子:

-- 部门表:CREATE TABLE dept(id INT PRIMARY KEY AUTO_INCREMENT,dname VARCHAR(20) UNIQUE NOT NULL,dcode INT UNIQUE NOT NULL);-- 员工表:CREATE TABLE emp(id INT PRIMARY KEY AUTO_INCREMENT,ename VARCHAR(20) NOT NULL,ecode INT UNIQUE NOT NULL,did INT,CONSTRAINT dept_emp FOREIGN KEY emp(did) REFERENCES dept(id));

随后插入数据,如果emp表中插入了dept表中没有的主键,则会报错。

-- 插入数据:INSERT INTO dept VALUES(NULL,'技术部',100);INSERT INTO dept VALUES(NULL,'财务部',200);INSERT INTO dept VALUES(NULL,'人事部',300);INSERT INTO emp VALUES(NULL,'张三',123,1);INSERT INTO emp VALUES(NULL,'李四',124,2);INSERT INTO emp VALUES(NULL,'王五',125,1);INSERT INTO emp VALUES(NULL,'田七',126,1);

如何判断一个数据库是不是出问题了

  1. select 1成功返回,只能说明这个库的进程还在,并不能说明主库没问题。

  2. 查表判断,使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。但是空间满了以后(update/insert被阻塞,但是select正常),这种方法又会变得不好使。

  3. 更新判断,节点可用性的检测都应该包含主库和备库。如果用更新来检测主库的话,那么备库也要进行更新检测。

  4. 内部统计,使用 select * from performance_schema.file_summary_by_event_name,查看统计信息。但是打开这个统计,性能会下降10%左右 

    1. 可以用这种方法监控redo-logupdate setup_instruments set ENABLED='YES', Timed='YES' where name like '%wait/io/file/innodb/innodb_log_file%';

join

在 join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索(如果能用到索引的话)。

  1. 使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好;

  2. 如果使用 join 语句的话,需要让小表做驱动表。

  3. 如果可以使用被驱动表的索引,join 语句还是有其优势的;

  4. 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用; 

    1. 多次扫表性能不好

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

Block Nested-Loop Join

从NLJ(Nested-Loop Join)衍化而来,NLJ的算法执行的逻辑是:从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做 join。也就是说,对于表 t2 来说,每次都是匹配一个值。

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

  1. 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;

  2. 扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回。

最后的效果是,对两个表都执行了全表扫描

join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下被驱动表的所有数据话,策略很简单,就是分段放。分段导致的后果是,另外一个表要被扫多次

Multi-Range Read(MRR)

由于mysql使用B+tree顺序存储,因此查询的时候如果能将搜索的行按照主键顺序排列再读取,可以优化读速度,这就是MRR,具体的操作步骤是:

  1. 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中;这里read_rnd_buffer 的大小是由 read_rnd_buffer_size 参数控制的。

  2. 将 read_rnd_buffer 中的 id 进行递增排序;

  3. 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。

另外需要说明的是,如果你想要稳定地使用 MRR 优化的话,需要设置set optimizer_switch="mrr_cost_based=off"。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR,把 mrr_cost_based 设置为 off,就是固定使用 MRR 了。)

MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。

Batched Key Access(BKA)

5.6之后引入,是NLJ算法的优化,从驱动表中中后期多行,放到join buffer中,执行join

Group by

Group by的一些优化方法:

  1. 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null;(group by 默认排序)

  2. 尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Using filesort;

  3. 如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;

  4. 如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。

MySQL的问题

  • 实例数据量太大,单实例几个TB的数据,这样即使使用xtrabackup物理备份,也需要很长的备份时间,且备份期间写入量大的话可能导致redo日志被覆盖引起备份失败;

  • 大实例故障恢复需要重建时,耗时太长,影响服务可用性(此时存活节点也挂了,那么完蛋了)。时间长有2个原因,一是备份需要很长时间,二是恢复的时候回放redo也需要较长时间;

  • 大实例做只读扩展麻烦,因为只读实例的数据是单独一份的,所以也需要通过备份来重建;

  • RDS实例集群很大,包括成千上万个实例,可能同时有很多实例同时在备份,会占用云服务巨大的网络和IO带宽,导致云服务不稳定;

  • 云服务一般使用云硬盘,导致数据库的性能没有物理机实例好,比如IO延时过高;

  • 主库写入量大的时候,会导致主从复制延迟过大,semi-sync/半同步复制也没法彻底解决,这是由于mysql基于binlog复制,需要走完整的mysql事务处理流程。

  • 对于需要读写分离,且要求部署多个只读节点的用户,最明显的感觉就是每增加一个只读实例,成本是线性增长的。

------------END-----------

更多原创文章请扫描上面(微信内长按可识别)二维码访问我的个人网站(https://www.xubingtao.cn),或者打开我的微信小程序:可以评论以及在线客服反馈问题,其他平台小程序和APP请访问:https://www.xubingtao.cn/?p=1675。祝大家生活愉快!

以上是关于mysql知识总结的主要内容,如果未能解决你的问题,请参考以下文章

MySQL——基础知识总结超详细版本做一个简易的图书馆系系统附源代码

MySQL事务基础知识总结与实践操作

MySql知识体系总结(SQL优化篇)

MYSQL事务超全知识总结#yyds干货盘点#

python常用代码片段总结

#yyds干货盘点#愚公系列2023年02月 .NET/C#知识点-程序运行计时的总结