Quantcast
Channel: 数据库内核月报
Viewing all 687 articles
Browse latest View live

MySQL · 引擎特性 · Innodb WAL物理格式

$
0
0

概述

任何对Innodb表的更新,Innodb都会将更新操作转化为WAL(write ahead log)并写入日志文件,WAL中记录了修改的详细信息。WAL日志在事务提交时会保证被写入持久化存储设备以保证事务的可靠性,WAL技术是保证数据库可靠存储以及提升性能的最重要手段。本文将详细描述WAL日志在磁盘上的物理组织格式。

WAL文件

MySQL WAL日志是一组日志文件集合,它们在数据库实例创建时预创建,并在数据库运行中被循环使用。WAL文件大小和数目可以通过参数设置,见innodb_log_file_size 和 innodb_log_files_in_group 。

日志文件

每个WAL文件的前2048字节存放文件头信息。文件头后面是WAL内容,按照BLOCK为单位分割,BLOCK大小默认为512字节。日志文件布局如下图所示:

wal_file_format

其中几个重要的字段:

日志文件头共占用4个OS_FILE_LOG_BLOCK_SIZE的大小,即2048,有以下字段:

  • LOG_GROUP_ID:该log文件所属的日志组,占用4个字节,当前均为0;
  • LOG_FILE_START_LSN: 该log文件记录的开始日志的lsn,占用8字节;
  • LOG_FILE_WAS_CRATED_BY_HOT_BACKUP 备份程序所占用的字节数,共占用32字节
  • LOG_CHECKPOINT_1/LOG_CHECKPOINT_2 两个记录InnoDB checkpoint信息的字段,分别从文件头的第二个和第四个block开始记录,只使用日志文件组的第一个日志文件。 从地址2KB偏移量开始,其后就是顺序写入的各个日志块(log block)。

日志块结构

所有WAL是以日志块为单位组织在日志文件中,日志块默认为512字节。所有的日志以块为单位顺序写入文件。每一条记录都有自己的LSN(log sequence number, 表示从日志记录创建开始到特定的日志记录已经写入的字节数)。每个日志块包含一个日志头(12字节)、一个尾部(4字节,主要是Block内容的crc校验),以及一组日志记录(最多512 – 12 – 4 = 496字节) 。

wal_block_format

日志块头包含以下几个字段:

  • block_number:4B,表示这是第几个block块。 可通过LSN计算: log_block_convert_lsn_to_no
  • block_data_len:2B,表示该block中已经写入的字节数,若是整个块都写满了的话,该值是 512
  • first_rec_offset:2B,表示该block中第一个全新的log record的开始log record的偏移量
  • log_record_data:496B,存放真正的redo日志内容
  • checksum:4B,此block数据校验和,用于正确性校验

这里需要特别解释的是first_rec_offset字段:在innodb事务层产生的wal日志其实是被组织成为一条条的log record,每个record都有特定的类型以及相应的内容。当log record被存储至日志文件中时,可能会出现一个log record跨Block存储的情况,因而需要在Block Header中存储first_rec_offset来代表该Block中第一个起始log record在该Block内的偏移,需要注意的是:first_rec_offset包含Block Header的大小。如下例:

wal_block_format_2

该例中,MLOG_RENAME该log record跨了两个Block,其中8B落在了第二个Block,于是第二个Block的Block Header字段中的first_rec_offset值为12 + 8 = 20。

存在两种特殊情况:1. Block中的紧接着Header的就是一个新log record的起始字节,那么first_rec_offset便是Block Header Size,即12;2. 若Block内的所有有效数据存储的都是上一个Block内最后一个log record的内容,那么first_rec_offset便为0。

LSN和文件偏移量之间转换

在Innodb中LSN是一个非常重要的概念,表示某个log record从日志记录创建开始已经写入的字节数。LSN的计算是包含BLOCK的头和尾字段的。

那如何由一个给定LSN的日志,在日志文件中找到它存储的位置(文件以及在文件内的偏移)是一个比较有意思的问题,原理也比较简单,感兴趣的朋友可以研究下函数log_files_real_offset_for_lsn,这里便不再赘述。


MySQL · 引擎特性 · 庖丁解InnoDB之REDO LOG

$
0
0

数据库故障恢复机制的前世今生中介绍了,磁盘数据库为了在保证数据库的原子性(A, Atomic) 和持久性(D, Durability)的同时,还能以灵活的刷盘策略来充分利用磁盘顺序写的性能,会记录REDO和UNDO日志,即ARIES方法。本文将重点介绍REDO LOG的作用,记录的内容,组织结构,写入方式等内容,希望读者能够更全面准确的理解REDO LOG在InnoDB中的位置。本文基于MySQL 8.0代码。

1. 为什么需要记录REDO

为了取得更好的读写性能,InnoDB会将数据缓存在内存中(InnoDB Buffer Pool),对磁盘数据的修改也会落后于内存,这时如果进程或机器崩溃,会导致内存数据丢失,为了保证数据库本身的一致性和持久性,InnoDB维护了REDO LOG。修改Page之前需要先将修改的内容记录到REDO中,并保证REDO LOG早于对应的Page落盘,也就是常说的WAL,Write Ahead Log。当故障发生导致内存数据丢失后,InnoDB会在重启时,通过重放REDO,将Page恢复到崩溃前的状态。

2. 需要什么样的REDO

那么我们需要什么样的REDO呢?首先,REDO的维护增加了一份写盘数据,同时为了保证数据正确,事务只有在他的REDO全部落盘才能返回用户成功,REDO的写盘时间会直接影响系统吞吐,显而易见,REDO的数据量要尽量少。其次,系统崩溃总是发生在始料未及的时候,当重启重放REDO时,系统并不知道哪些REDO对应的Page已经落盘,因此REDO的重放必须可重入,即REDO操作要保证幂等。最后,为了便于通过并发重放的方式加快重启恢复速度,REDO应该是基于Page的,即一个REDO只涉及一个Page的修改。

熟悉的读者会发现,数据量小是Logical Logging的优点,而幂等以及基于Page正是Physical Logging的优点,因此InnoDB采取了一种称为Physiological Logging的方式,来兼得二者的优势。所谓Physiological Logging,就是以Page为单位,但在Page内以逻辑的方式记录。举个例子,MLOG_REC_UPDATE_IN_PLACE类型的REDO中记录了对Page中一个Record的修改,方法如下:

(Page ID,Record Offset,(Filed 1, Value 1) … (Filed i, Value i) … )

其中,PageID指定要操作的Page页,Record Offset记录了Record在Page内的偏移位置,后面的Field数组,记录了需要修改的Field以及修改后的Value。

由于Physiological Logging的方式采用了物理Page中的逻辑记法,导致两个问题:

1,需要基于正确的Page状态上重放REDO

由于在一个Page内,REDO是以逻辑的方式记录了前后两次的修改,因此重放REDO必须基于正确的Page状态。然而InnoDB默认的Page大小是16KB,是大于文件系统能保证原子的4KB大小的,因此可能出现Page内容成功一半的情况。InnoDB中采用了Double Write Buffer的方式来通过写两次的方式保证恢复的时候找到一个正确的Page状态。这部分会在之后介绍Buffer Pool的时候详细介绍。

2,需要保证REDO重放的幂等

Double Write Buffer能够保证找到一个正确的Page状态,我们还需要知道这个状态对应REDO上的哪个记录,来避免对Page的重复修改。为此,InnoDB给每个REDO记录一个全局唯一递增的标号LSN(Log Sequence Number)。Page在修改时,会将对应的REDO记录的LSN记录在Page上(FIL_PAGE_LSN字段),这样恢复重放REDO时,就可以来判断跳过已经应用的REDO,从而实现重放的幂等。

3. REDO中记录了什么内容

知道了InnoDB中记录REDO的方式,那么REDO里具体会记录哪些内容呢?为了应对InnoDB各种各样不同的需求,到MySQL 8.0为止,已经有多达65种的REDO记录。用来记录这不同的信息,恢复时需要判断不同的REDO类型,来做对应的解析。根据REDO记录不同的作用对象,可以将这65中REDO划分为三个大类:作用于Page,作用于Space以及提供额外信息的Logic类型。

1,作用于Page的REDO

这类REDO占所有REDO类型的绝大多数,根据作用的Page的不同类型又可以细分为,Index Page REDO,Undo Page REDO,Rtree PageREDO等。比如MLOG_REC_INSERT,MLOG_REC_UPDATE_IN_PLACE,MLOG_REC_DELETE三种类型分别对应于Page中记录的插入,修改以及删除。这里还是以MLOG_REC_UPDATE_IN_PLACE为例来看看其中具体的内容:

redo_insert

其中,Type就是MLOG_REC_UPDATE_IN_PLACE类型,Space ID和Page Number唯一标识一个Page页,这三项是所有REDO记录都需要有的头信息,后面的是MLOG_REC_UPDATE_IN_PLACE类型独有的,其中Record Offset用给出要修改的记录在Page中的位置偏移,Update Field Count说明记录里有几个Field要修改,紧接着对每个Field给出了Field编号(Field Number),数据长度(Field Data Length)以及数据(Filed Data)。

2,作用于Space的REDO

这类REDO针对一个Space文件的修改,如MLOG_FILE_CREATE,MLOG_FILE_DELETE,MLOG_FILE_RENAME分别对应对一个Space的创建,删除以及重命名。由于文件操作的REDO是在文件操作结束后才记录的,因此在恢复的过程中看到这类日志时,说明文件操作已经成功,因此在恢复过程中大多只是做对文件状态的检查,以MLOG_FILE_CREATE来看看其中记录的内容:

redo_space

同样的前三个字段还是Type,Space ID和Page Number,由于是针对Page的操作,这里的Page Number永远是0。在此之后记录了创建的文件flag以及文件名,用作重启恢复时的检查。

3,提供额外信息的Logic REDO

除了上述类型外,还有少数的几个REDO类型不涉及具体的数据修改,只是为了记录一些需要的信息,比如最常见的MLOG_MULTI_REC_END就是为了标识一个REDO组,也就是一个完整的原子操作的结束。

4. REDO是如何组织的

所谓REDO的组织方式,就是如何把需要的REDO内容记录到磁盘文件中,以方便高效的REDO写入,读取,恢复以及清理。我们这里把REDO从上到下分为三层:逻辑REDO层、物理REDO层和文件层。

逻辑REDO层

这一层是真正的REDO内容,REDO由多个不同Type的多个REDO记录收尾相连组成,有全局唯一的递增的偏移sn,InnoDB会在全局log_sys中维护当前sn的最大值,并在每次写入数据时将sn增加REDO内容长度。如下图所示:

logic_redo

物理REDO层

磁盘是块设备,InnoDB中也用Block的概念来读写数据,一个Block的长度OS_FILE_LOG_BLOCK_SIZE等于磁盘扇区的大小512B,每次IO读写的最小单位都是一个Block。除了REDO数据以外,Block中还需要一些额外的信息,下图所示一个Log Block的的组成,包括12字节的Block Header:前4字节中Flush Flag占用最高位bit,标识一次IO的第一个Block,剩下的31个个bit是Block编号;之后是2字节的数据长度,取值在[12,508];紧接着2字节的First Record Offset用来指向Block中第一个REDO组的开始,这个值的存在使得我们对任何一个Block都可以找到一个合法的的REDO开始位置;最后的4字节Checkpoint Number记录写Block时的next_checkpoint_number,用来发现文件的循环使用,这个会在文件层详细讲解。Block末尾是4字节的Block Tailer,记录当前Block的Checksum,通过这个值,读取Log时可以明确Block数据有没有被完整写盘。

image-20200216201419532

Block中剩余的中间498个字节就是REDO真正内容的存放位置,也就是我们上面说的逻辑REDO。我们现在将逻辑REDO放到物理REDO空间中,由于Block内的空间固定,而REDO长度不定,因此可能一个Block中有多个REDO,也可能一个REDO被拆分到多个Block中,如下图所示,棕色和红色分别代表Block Header和Tailer,中间的REDO记录由于前一个Block剩余空间不足,而被拆分在连续的两个Block中。

physical_redo

由于增加了Block Header和Tailer的字节开销,在物理REDO空间中用LSN来标识偏移,可以看出LSN和SN之间有简单的换算关系:

constexpr inline lsn_t log_translate_sn_to_lsn(lsn_t sn) {
  return (sn / LOG_BLOCK_DATA_SIZE * OS_FILE_LOG_BLOCK_SIZE +
          sn % LOG_BLOCK_DATA_SIZE + LOG_BLOCK_HDR_SIZE);
}

SN加上之前所有的Block的Header以及Tailer的长度就可以换算到对应的LSN,反之亦然。

文件层

最终REDO会被写入到REDO日志文件中,以ib_logfile0、ib_logfile1…命名,为了避免创建文件及初始化空间带来的开销,InooDB的REDO文件会循环使用,通过参数innodb_log_files_in_group可以指定REDO文件的个数。多个文件收尾相连顺序写入REDO内容。每个文件以Block为单位划分,每个文件的开头固定预留4个Block来记录一些额外的信息,其中第一个Block称为Header Block,之后的3个Block在0号文件上用来存储Checkpoint信息,而在其他文件上留空:

image-20200216222949045

其中第一个Header Block的数据区域记录了一些文件信息,如下图所示,4字节的Formate字段记录Log的版本,不同版本的LOG,会有REDO类型的增减,这个信息是8.0开始才加入的;8字节的Start LSN标识当前文件开始LSN,通过这个信息可以将文件的offset与对应的lsn对应起来;最后是最长32位的Creator信息,正常情况下会记录MySQL的版本。

redo_file_header

现在我们将REDO放到文件空间中,如下图所示,逻辑REDO是真正需要的数据,用sn索引,逻辑REDO按固定大小的Block组织,并添加Block的头尾信息形成物理REDO,以lsn索引,这些Block又会放到循环使用的文件空间中的某一位置,文件中用offset索引:

redo_file

虽然通过LSN可以唯一标识一个REDO位置,但最终对REDO的读写还需要转换到对文件的读写IO,这个时候就需要表示文件空间的offset,他们之间的换算方式如下:

const auto real_offset =
      log.current_file_real_offset + (lsn - log.current_file_lsn);

切换文件时会在内存中更新当前文件开头的文件offset,current_file_real_offset,以及对应的LSN,current_file_lsn,通过这两个值可以方便地用上面的方式将LSN转化为文件offset。注意这里的offset是相当于整个REDO文件空间而言的,由于InnoDB中读写文件的space层实现支持多个文件,因此,可以将首位相连的多个REDO文件看成一个大文件,那么这里的offset就是这个大文件中的偏移。

5. 如何高效地写REDO

作为维护数据库正确性的重要信息,REDO日志必须在事务提交前保证落盘,否则一旦断电将会有数据丢失的可能,因此从REDO生成到最终落盘的完整过程成为数据库写入的关键路径,其效率也直接决定了数据库的写入性能。这个过程包括REDO内容的产生,REDO写入InnoDB Log Buffer,从InnoDB Log Buffer写入操作系统Page Cache,以及REDO刷盘,之后还需要唤醒等待的用户线程完成Commit。下面就通过这几个阶段来看看InnoDB如何在高并发的情况下还能高效地完成写REDO。

REDO产生

我们知道事务在写入数据的时候会产生REDO,一次原子的操作可能会包含多条REDO记录,这些REDO可能是访问同一Page的不同位置,也可能是访问不同的Page(如Btree节点分裂)。InnoDB有一套完整的机制来保证涉及一次原子操作的多条REDO记录原子,即恢复的时候要么全部重放,要不全部不重放,这部分将在之后介绍恢复逻辑的时候详细介绍,本文只涉及其中最基本的要求,就是这些REDO必须连续。InnoDB中通过min-transaction实现,简称mtr,需要原子操作时,调用mtr_start生成一个mtr,mtr中会维护一个动态增长的m_log,这是一个动态分配的内存空间,将这个原子操作需要写的所有REDO先写到这个m_log中,当原子操作结束后,调用mtr_commit将m_log中的数据拷贝到InnoDB的Log Buffer。

写入InnoDB Log Buffer

高并发的环境中,会同时有非常多的min-transaction(mtr)需要拷贝数据到Log Buffer,如果通过锁互斥,那么毫无疑问这里将成为明显的性能瓶颈。为此,从MySQL 8.0开始,设计了一套无锁的写log机制,其核心思路是允许不同的mtr,同时并发地写Log Buffer的不同位置。不同的mtr会首先调用log_buffer_reserve函数,这个函数里会用自己的REDO长度,原子地对全局偏移log.snfetch_add,得到自己在Log Buffer中独享的空间。之后不同mtr并行的将自己的m_log中的数据拷贝到各自独享的空间内。

/* Reserve space in sequence of data bytes: */
const sn_t start_sn = log.sn.fetch_add(len);

写入Page Cache

写入到Log Buffer中的REDO数据需要进一步写入操作系统的Page Cache,InnoDB中有单独的log_writer来做这件事情。这里有个问题,由于Log Buffer中的数据是不同mtr并发写入的,这个过程中Log Buffer中是有空洞的,因此log_writer需要感知当前Log Buffer中连续日志的末尾,将连续日志通过pwrite系统调用写入操作系统Page Cache。整个过程中应尽可能不影响后续mtr进行数据拷贝,InnoDB在这里引入一个叫做link_buf的数据结构,如下图所示:

link_buf

link_buf是一个循环使用的数组,对每个lsn取模可以得到其在link_buf上的一个槽位,在这个槽位中记录REDO长度。另外一个线程从开始遍历这个link_buf,通过槽位中的长度可以找到这条REDO的结尾位置,一直遍历到下一位置为0的位置,可以认为之后的REDO有空洞,而之前已经连续,这个位置叫做link_buftail。下面看看log_writer和众多mtr是如何利用这个link_buf数据结构的。这里的这个link_buflog.recent_written,如下图所示:

link_buf2

图中上半部分是REDO日志示意图,write_lsn是当前log_writer已经写入到Page Cache中日志末尾,current_lsn是当前已经分配给mtr的的最大lsn位置,而buf_ready_for_write_lsn是当前log_writer找到的Log Buffer中已经连续的日志结尾,从write_lsnbuf_ready_for_write_lsn是下一次log_writer可以连续调用pwrite写入Page Cache的范围,而从buf_ready_for_write_lsncurrent_lsn是当前mtr正在并发写Log Buffer的范围。下面的连续方格便是log.recent_written的数据结构,可以看出由于中间的两个全零的空洞导致buf_ready_for_write_lsn无法继续推进,接下来,假如reserve到中间第一个空洞的mtr也完成了写Log Buffer,并更新了log.recent_written*,如下图:

redo-next-write-to-log-buffer

这时,log_writer从当前的buf_ready_for_write_lsn向后遍历log.recent_written,发现这段已经连续:

redo-next-write-to-log-buffer-2

因此提升当前的buf_ready_for_write_lsn,并将log.recent_written的tail位置向前滑动,之后的位置清零,供之后循环复用:

redo-next-write-to-log-buffer-3

紧接log_writer将连续的内容刷盘并提升write_lsn

刷盘

log_writer提升write_lsn之后会通知log_flusher线程,log_flusher线程会调用fsync将REDO刷盘,至此完成了REDO完整的写入过程。

唤醒用户线程

为了保证数据正确,只有REDO写完后事务才可以commit,因此在REDO写入的过程中,大量的用户线程会block等待,直到自己的最后一条日志结束写入。默认情况下innodb_flush_log_at_trx_commit = 1,需要等REDO完成刷盘,这也是最安全的方式。当然,也可以通过设置innodb_flush_log_at_trx_commit = 2,这样,只要REDO写入Page Cache就认为完成了写入,极端情况下,掉电可能导致数据丢失。

大量的用户线程调用log_write_up_to等待在自己的lsn位置,为了避免大量无效的唤醒,InnoDB将阻塞的条件变量拆分为多个,log_write_up_to根据自己需要等待的lsn所在的block取模对应到不同的条件变量上去。同时,为了避免大量的唤醒工作影响log_writerlog_flusher线程,InnoDB中引入了两个专门负责唤醒用户的线程:log_wirte_notifierlog_flush_notifier,当超过一个条件变量需要被唤醒时,log_writerlog_flusher会通知这两个线程完成唤醒工作。下图是整个过程的示意图:

innodb_notify

多个线程通过一些内部数据结构的辅助,完成了高效的从REDO产生,到REDO写盘,再到唤醒用户线程的流程,下面是整个这个过程的时序图:

log_sequence

6. 如何安全地清除REDO

由于REDO文件空间有限,同时为了尽量减少恢复时需要重放的REDO,InnoDB引入log_checkpointer线程周期性的打Checkpoint。重启恢复的时候,只需要从最新的Checkpoint开始回放后边的REDO,因此Checkpoint之前的REDO就可以删除或被复用。

我们知道REDO的作用是避免只写了内存的数据由于故障丢失,那么打Checkpiont的位置就必须保证之前所有REDO所产生的内存脏页都已经刷盘。最直接的,可以从Buffer Pool中获得当前所有脏页对应的最小REDO LSN:lwm_lsn。 但光有这个还不够,因为有一部分min-transaction的REDO对应的Page还没有来的及加入到Buffer Pool的脏页中去,如果checkpoint打到这些REDO的后边,一旦这时发生故障恢复,这部分数据将丢失,因此还需要知道当前已经加入到Buffer Pool的REDO lsn位置:dpa_lsn。取二者的较小值作为最终checkpoint的位置,其核心逻辑如下:

/* LWM lsn for unflushed dirty pages in Buffer Pool */
lsn_t lwm_lsn = buf_pool_get_oldest_modification_lwm();

/* Note lsn up to which all dirty pages have already been added into Buffer Pool */
const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log);

lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);

MySQL 8.0中为了能够让mtr之间更大程度的并发,允许并发地给Buffer Pool注册脏页。类似与log.recent_writtenlog_writer,这里引入一个叫做recent_closedlink_buf来处理并发带来的空洞,由单独的线程log_closer来提升recent_closedtail,也就是当前连续加入Buffer Pool脏页的最大LSN,这个值也就是上面提到的dpa_lsn。需要注意的是,由于这种乱序的存在,lwm_lsn的值并不能简单的获取当前Buffer Pool中的最老的脏页的LSN,保守起见,还需要减掉一个recent_closed的容量大小,也就是最大的乱序范围,简化后的代码如下:

/* LWM lsn for unflushed dirty pages in Buffer Pool */
const lsn_t lsn = buf_pool_get_oldest_modification_approx();
const lsn_t lag = log.recent_closed.capacity();
lsn_t lwm_lsn = lsn - lag;

/* Note lsn up to which all dirty pages have already been added into Buffer Pool */
const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log);

lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);

这里有一个问题,由于lwm_lsn已经减去了recent_closedcapacity,因此理论上这个值一定是小于dpa_lsn的。那么再去比较lwm_lsndpa_lsn来获取Checkpoint位置或许是没有意义的。

上面已经提到,ib_logfile0文件的前三个Block有两个被预留作为Checkpoint Block,这两个Block会在打Checkpiont的时候交替使用,这样来避免写Checkpoint过程中的崩溃导致没有可用的Checkpoint。Checkpoint Block中的内容如下:

log_checkpoint

首先8个字节的Checkpoint Number,通过比较这个值可以判断哪个是最新的Checkpiont记录,之后8字节的Checkpoint LSN为打Checkpoint的REDO位置,恢复时会从这个位置开始重放后边的REDO。之后8个字节的Checkpoint Offset,将Checkpoint LSN与文件空间的偏移对应起来。最后8字节是前面提到的Log Buffer的长度,这个值目前在恢复过程并没有使用。

7. 总结

本文系统的介绍了InnoDB中REDO的作用、特性、组织结构、写入方式已经清理时机,基本覆盖了REDO的大多数内容。关于重启恢复时如何使用REDO将数据库恢复到正确的状态,将在之后介绍InnoDB故障恢复机制的时候详细介绍。

参考

[1] MySQL 8.0.11Source Code Documentation: Format of redo log

[2] MySQL 8.0: New Lock free, scalable WAL design

[3] How InnoDB handles REDO logging

[4] MySQL Source Code

[5] 数据库故障恢复机制的前世今生

MySQL · 引擎特性 · InnoDB Buffer Pool 浅析

$
0
0

Buffer Pool简介

InnoDB中的数据访问是以Page为单位的,每个Page的大小默认为16KB,Buffer Pool是用来管理和缓存这些Page的。InnoDB将一块连续的内存大小划分给Buffer Pool来使用,并将其划分为多个Buffer Pool Instance来更好地管理这块内存,每个Instance的大小都是相等的,通过算法保证一个Page只会在一个特定的Instance中,划分为多个Instance的模式提升了Buffer Pool的并发性能。

在每一个Buffer Pool Instance中,实际都会维护一个自己的Buffer Pool模块,InnoDB通过16KB Page的方式将数据从文件中读取到Buffer Pool中,并通过一个LRU List来缓存这些Page,经常访问的Page在LRU List的前面,不经常访问的Page在后面。InnoDB访问一个Page时,首先会从Buffer Pool中获取,如果未找到,则会访问数据文件,读取到Page,并将其put到LRU List中,当一个Instance的Buffer Pool中没有可用的空闲Page时,会对LRU List中的Page进行淘汰。

由于Buffer Pool中夹杂了很多Page压缩的逻辑,即将实际的16KB Page压缩为8KB、4KB、2KB、1KB,这块逻辑暂时先跳过不去做分析,我们先按照默认Page就是16KB的逻辑来梳理Buffer Pool相关的逻辑。

主要组件介绍

Buffer Pool Instance:

InnoDB启动时会加载配置srv_buf_pool_size和srv_buf_pool_instances,分别是Buffer Pool总大小和需要划分的Instance数量,当srv_buf_pool_size小于1G时,srv_buf_pool_instances会被重置为1,单个Buffer Pool Instance的大小计算规则为:size=srv_buf_pool_size/srv_buf_pool_instances,每个Buffer Pool Instance的大小均相等。在Mysql 8.0中,最大支持64个Buffer Pool Instance,实际Instance在初始化时,为了加快分配速度,会根据运行环境进行调整并行初始化的数量,详细流程见Buffer Pool初始化。

在每个Buffer Pool Instance中都有包含自己的锁,mutex,Buffer chunks,各个页链表(如下面所介绍),每个Instance之间都是独立的,支持多线程并发访问,且一个page只会被存放在一个固定的Instance中,后续会详细介绍这个算法。

在每个Buffer Pool Instance中还包含一个page_hash的hash table,通过这个page_hash能快速找到LRU List中的page,避免扫描整个LRU List,极大提升了Page的访问效率。

Buffer chunks:

Buffer chunks是每个Buffer Pool Instance中实际的物理存储块数组,一个Buffer Pool Instance中有一个或多个chunk,每个chunk的大小默认为128MB,最小为1MB,且这个值在8.0中时可以动态调整生效的。每个Buffer chunk中包含一个buf_block_t的blocks数组(即Page),Buffer chunk主要存储数据页和数据页控制体,blocks数组中的每个buf_block_t是一个数据页控制体,其中包含了一个指向具体数据页的*frame指针,以及具体的控制体buf_page_t,后面在数据结构中详细阐述他们的关系。

页链表:

以下所有的链表中的每个节点都是数据页控制体(buf_page_t)。

  • Free List:如其名,Free List中存放的都是未曾使用的空闲Page,InnoDB需要Page时从Free List中获取,如果Free List为空,即没有任何空闲Page,则会从LRU List和Flush List中通过淘汰旧Page和Flush脏Page来回收Page。在InnoDB初始化时,会将Buffer chunks中的所有Page加入到Free List中。

  • LRU List:所有从数据文件中新读取进来的Page都会缓存在LRU List,并通过LRU策略对这些Page进行管理。LRU List实际划分为Young和Old两个部分,其中Young区保存的是较热的数据,Old区保存的是刚从数据文件中读取出来的数据,如果LRU List的长度小于512,则不会将其拆分为Young和Old区。当InnoDB读取Page时,首先会从当前Buffer Pool Instance的page_hash查找,并分为三种情况来处理:

    1. 如果在page_hash找到,即Page在LRU List中,则会判断Page是在Old区还是Young区,如果是在Old区,在读取完Page后会把它添加到Young区的链表头部
    2. 如果在page_hash找到,并且Page在Young区,需要判断Page所在Young区的位置,只有Page处于Young区总长度大约1/4的位置之后,才会将其添加到Young区的链表头部
    3. 如果未能在page_hash找到,则需要去数据文件中读取Page,并将其添加到Old区的头部

    LRU List采用非常精细的LRU淘汰策略来管理Page,并且用以上机制避免了频繁对LRU 链表的调整。

  • Flush List:所有被修改过且还没来得及被flush到磁盘上的Page(脏页),都会被保存在这个链表中。所有保存在Flush List上的数据都会在LRU List中,但在LRU List中的数据不一定都在Flush List中。在Flush List上的每个Page都会保存其最早修改的lsn,即oldest_modification,虽然一个Page可能被修改多次,但只记录最早的修改。Flush List上的Page会按照其各自的oldest_modification进行降序排序,链表尾部保存oldest_modification最小的Page,在需要从Flush List中回收Page时,从尾部开始回收。

Mutex:

为保证各个页链表访问时的互斥,Buffer Pool中提供了对几个List的Mutex,如LRU_list_mutex用来保护LRU List的访问,free_list_mutex用来保护Free List的访问,flush_list_mutex用来保护Flush List的访问。

Page_hash:

在每个Buffer Pool Instance中都会包含一个独立的Page_hash,其作用主要是为了避免对LRU List的全链表扫描,通过使用space_id和page_no就能快速找到已经被读入Buffer Pool的Page。

Buffer Pool代码分析

初步了解了Buffer Pool在InnoDB中扮演的角色后,接下来我们从以下几个方面来探讨一下在Mysql 8.0中InnoDB Buffer Pool的具体实现:

  • Buffer Pool 数据结构
  • Buffer Pool 初始化
  • Buffer Pool 读取和写入
  • 页链表的访问

Buffer Pool 数据结构

Buffer Pool主要包含三个核心的数据结构buf_pool_t、buf_block_t和buf_page_t,其定义都在include/buf0buf.h中,分别看一下其具体实现:

struct buf_pool_t { //保存Buffer Pool Instance级别的信息
    ...
    ulint instance_no; //当前buf_pool所属instance的编号
    ulint curr_pool_size; //当前buf_pool大小
    buf_chunk_t *chunks; //当前buf_pool中包含的chunks
    hash_table_t *page_hash; //快速检索已经缓存的Page
    UT_LIST_BASE_NODE_T(buf_page_t) free; //空闲Page链表
    UT_LIST_BASE_NODE_T(buf_page_t) LRU; //Page缓存链表,LRU策略淘汰
    UT_LIST_BASE_NODE_T(buf_page_t) flush_list; //还未Flush磁盘的脏页保存链表
    BufListMutex XXX_mutex; //各个链表的互斥Mutex
    ...
}
struct buf_block_t { //Page控制体
    buf_page_t page; //这个字段必须要放到第一个位置,这样才能使得buf_block_t和buf_page_t的指针进行						 转换
    byte *frame; //指向真正存储数据的Page
    BPageMutex mutex; //block级别的mutex
    ...
}
class buf_page_t {
	...
    page_id_t id; //page id
    page_size_t size; //page 大小
    ib_uint32_t buf_fix_count; //用于并发控制
    buf_io_fix io_fix; //用于并发控制
    buf_page_state state; //当前Page所处的状态,后续会详细介绍
    lsn_t newest_modification; //当前Page最新修改lsn
    lsn_t oldest_modification; //当前Page最老修改lsn,即第一条修改lsn
    ...
}

主要的三个数据结构就都已经罗列在上面了,还有个比较重要的buf_page_state,这是一个枚举类型,标识了每个Page所处的状态,在读取和访问时都会对应不同的状态转换,接下来我们简单看一下:

enum buf_page_state {
  BUF_BLOCK_POOL_WATCH, //看注释是给Purge使用的,先不关注
  BUF_BLOCK_ZIP_PAGE, //压缩Page状态,暂略过
  BUF_BLOCK_ZIP_DIRTY, //压缩页脏页状态,暂略过
  BUF_BLOCK_NOT_USED, //保存在Free List中的Page
  BUF_BLOCK_READY_FOR_USE, //当调用到buf_LRU_get_free_block获取空闲Page,此时被分配的Page就处于							这个状态
  BUF_BLOCK_FILE_PAGE, //正常被使用的状态,LRU List中的Page都是这个状态
  BUF_BLOCK_MEMORY, //用于存储非用户数据,比如系统数据的Page处于这个状态
  BUF_BLOCK_REMOVE_HASH //在Page从LRU List和Flush List中被回收并加入Free List时,需要先从							Page_hash中移除,此时Page处于这个状态
};

在不考虑压缩Page的情况下,buf_page_state的状态转换一般为:


Buffer Pool 初始化

要说起Buffer Pool的初始化,就不得不先提到InnoDB的启动流程,我们首先从srv/srv0start.cc的srv_start函数看起,这里是整个InnoDB启动的地方。

srv_start()->buf_pool_init(srv_buf_pool_size, srv_buf_pool_instances){//初始化Buffer Pool
    const ulint size = total_size / n_instances; //计算单个instance的大小
    buf_pool_ptr =
      (buf_pool_t *)ut_zalloc_nokey(n_instances * sizeof *buf_pool_ptr); //初始化buf_pool_t																		 数组
    #ifdef UNIV_LINUX //该宏定义主要为了加快Buffer Pool的并行初始化
  	ulint n_cores = sysconf(_SC_NPROCESSORS_ONLN);
    if (n_cores > 8) {
        n_cores = 8; //Linux环境下最大并行度为8个
    }
	#else
  	ulint n_cores = 4; //其他环境最大并行度为4个
	#endif /* UNIV_LINUX */
    
    //循环初始化Instance
    for (i = 0; i < n_instances; ) {
        ulint n = i + n_cores;
        if (n > n_instances) { //判断初始化最大并行度是否超过n_instances
          n = n_instances;
        }

        std::vector<std::thread> threads;

        std::mutex m;
		//并行创建Instance,调用buf_pool_create()函数
        for (ulint id = i; id < n; ++id) {
          threads.emplace_back(std::thread(buf_pool_create, &buf_pool_ptr[id], size,
                                           id, &m, std::ref(errs[id])));
        }
        i = n; //从n开始继续初始化
		...
    }
} 

在buf0buf.cc::buf_pool_create()函数中会完成对Buffer Pool Instance的初始化,主要是Buffer Chunks的初始化,即调用buf_chunk_init()函数:

buf_pool_create()->buf_chunk_init(){
    ...
    //分配内存,默认每个chunk的大小为128M,默认通过mmap来分配
    chunk->mem = buf_pool->allocator.allocate_large(mem_size, &chunk->mem_pfx);
    //从内存的头部开始分配block控制信息
    chunk->blocks = (buf_block_t *)chunk->mem;
    //frame是指向实际Page的指针,需要将其通过UNIV_PAGE_SIZE对齐,此时frame也指向内存区域的头部
    frame = (byte *)ut_align(chunk->mem, UNIV_PAGE_SIZE);
    //计算出该chunk能分配出多少个Page,
    chunk->size = chunk->mem_pfx.m_size / UNIV_PAGE_SIZE - (frame != chunk->mem);
    ulint size = chunk->size;
    /*
      一个Page包含一个的16KB的Page和一个对应的控制信息(buf_block_t),一个buf_block_t对应一个Page
      所有的Page页面都是连续在一起存储的组成了Page区,buf_block_t也是连续存储的组成了控制信息区
      控制信息区处于这块内存的前半部分,Page区域位于后半部分
      为了更容易理解这个循环所做的事情,我们先理一理思路
      如何把一块连续的内存分为两个区域,即控制信息区和Page区,且每个Page必须要有一个对应的buf_block_t	 	   我们把整个连续内存拆分为一个个16KB大小的Page,然后把其中第一个Page用于存储所有的buf_block_t
      如果buf_block_t的数量太多导致第一个Page放不下,则需要把第二个Page也用于存储buf_block_t
      依次类推,每使用一个Page页用于存储buf_block_t,那么chunk的Page size就要减1
      frame是一个指向Page页的指针,它从chunk的头部出发,当有足够的空间用于存储buf_block_t,        	   即frame的地址大于整个buf_block_t控制信息需要的总长度,就会跳出While循环
      反之,空间不足则需要再花费一片Page,同时size--
      这样的分配模式能减少内存碎片的产生,能提高内存的使用率
    */
    while (frame < (byte *)(chunk->blocks + size)) {
      frame += UNIV_PAGE_SIZE;
      size--;
    }
    //最终获得的size是准确的Page数量
    chunk->size = size;
    
    block = chunk->blocks;
    //循环初始化所有的控制信息buf_block_t和Page
    for (i = chunk->size; i--;) {
      //初始化控制信息buf_block_t,并将其frame指针指向对应的Page地址
      buf_block_init(buf_pool, block, frame);
      UNIV_MEM_INVALID(block->frame, UNIV_PAGE_SIZE);
	  //把所有的空闲Page添加到Buffer Pool Instance的Free List中
      UT_LIST_ADD_LAST(buf_pool->free, &block->page);
      //标记当前控制信息buf_block_t所指向的Page是在Free List中
      ut_d(block->page.in_free_list = TRUE);
      ut_ad(buf_pool_from_block(block) == buf_pool);
		
      block++;
      //frame指针指向下一个Page
      frame += UNIV_PAGE_SIZE;
    }
    //互斥量lock
    if (mutex != nullptr) {
      mutex->lock();
    }
	//注册chunk
    buf_pool_register_chunk(chunk);
	//互斥量unlock
  	if (mutex != nullptr) {
      mutex->unlock();
    }
    ...
}

Buffer Pool 读取和写入

Buffer Pool的读取逻辑和写入逻辑是混合在一起的,InnoDB需要访问一个Page时,必须要通过Buffer Pool进行获取,主要需要以下几个步骤:

  1. 获取Page对应的Buffer Pool Instance
  2. 从对应的Buffer Pool Instance的Page_hash中查找是否存在该Page,如存在,直接返回该Page的地址,并可能需要修改LRU List中的数据
  3. 如果未能查找到,则需要读取数据文件,并从Free List中申请新的Page将其添加到LRU List中

接下来我们围绕这个主题逻辑,来分析一下Buffer Pool 读取和写入流程,实际读取Page的函数为buf0buf.cc::buf_page_get_gen():

buf_page_get_gen(const page_id_t &page_id, //page id
                              const page_size_t &page_size, ulint rw_latch,
                              buf_block_t *guess, ulint mode, const char *file,
                              ulint line, mtr_t *mtr,
                              bool dirty_with_no_latch) {
	...    
    /*
      这个mode代表了访问Page的不同模式,会有不同的动作发生在后续的读取和写入流程中
      BUF_GET_NO_LATCH:对Page是读取还是修改,都不加锁。
      BUF_GET:默认获取Page的方式,如果Page不在LRU List中,则从数据文件读取,如果已经在LRU List中,	  需要判断是否要把他加入到Young区的头部和是否需要线性预读。如果是读取则加读锁,修改则加写锁。
      BUF_GET_IF_IN_POOL:只在Buffer Pool中查找,如果Page在LRU List中,判断是否要把它加入到加入到	  Young区的头部和是否需要线性预读,如果不在则直接返回空。如果是读取则加读锁,修改则加写锁。
      BUF_PEEK_IF_IN_POOL:与BUF_GET_IF_IN_POOL类似,只是不去调整LRU List链表
      BUF_GET_IF_IN_POOL_OR_WATCH:purge线程使用,暂时跳过一下
      BUF_GET_POSSIBLY_FREED:这个先跳过...
  	*/
    //通过page_id获取Page对应的Buffer Instance
    buf_pool_t *buf_pool = buf_pool_get(page_id);
    /*
      page_no_t ignored_page_no = page_id.page_no() >> 6;
	  page_id_t id(page_id.space(), ignored_page_no);
	  ulint i = id.fold() % srv_buf_pool_instances;
	  return (&buf_pool_ptr[i]);
      实际就是将page_no右移6位,并计算一个fold值,然后取模Buffer Pool Instance数量,拿到一个Index之		 后,再从buf_pool_ptr数组中获取。其中page_no的后六位被移除,是为了保证一个extent的数据能被缓存到   	  同一个Buffer Pool Instance中,便于后面的预读操作。
    */
loop:
    //调用buf_page_hash_get_low()从Page_hash中获取block,即Page
    block = (buf_block_t *)buf_page_hash_get_low(buf_pool, page_id);
    if(block == NULL){
      //如果未能从Page_hash中找到该Page,即Page不在LRU List中,则调用buf_read_page()从文件中读取
      if (buf_read_page(page_id, page_size)) {
        //读取成功,触发随机预读
        buf_read_ahead_random(page_id, page_size, ibuf_inside(mtr));
        retries = 0;
      } else if (retries < BUF_PAGE_READ_MAX_RETRIES) {
        //不成功,且小于最大重试次数,则重试
        //默认最大重试次数为100次
        ++retries;
        DBUG_EXECUTE_IF("innodb_page_corruption_retries",
                        retries = BUF_PAGE_READ_MAX_RETRIES;);
      } else {
        //重试100次之后还是失败,报告错误
        ...
      }
      //重新去LRU中获取  
      goto loop;
    }else{
      fix_block = block;
    }
    
    //根据Page的类型进行不同的操作
    switch (buf_block_get_state(fix_block)) {
      buf_page_t *bpage;
      //正常的在LRU中的Page
      case BUF_BLOCK_FILE_PAGE:
        bpage = &block->page;
        //如果该Page正处于被Flush的状态,是不能被返回的
        if (fsp_is_system_temporary(page_id.space()) &&
          buf_page_get_io_fix_unlocked(bpage) != BUF_IO_NONE) {
          buf_block_unfix(fix_block);
          os_thread_sleep(WAIT_FOR_WRITE);
          goto loop;
        }
        break;
      case BUF_BLOCK_ZIP_PAGE:
      case BUF_BLOCK_ZIP_DIRTY:
            ...
      
    }
    //mode类型除BUF_PEEK_IF_IN_POOL外,都会进行判断是否需要把Page插入Young区的头部
    if (mode != BUF_PEEK_IF_IN_POOL) {
      buf_page_make_young_if_needed(&fix_block->page);
    }
    
    //为除了BUF_GET_NO_LATCH以外的操作加锁
    switch (rw_latch) {
      //不加锁
      case RW_NO_LATCH:
        fix_type = MTR_MEMO_BUF_FIX;
        break;
      //RW锁
      case RW_S_LATCH:
        rw_lock_s_lock_inline(&fix_block->lock, 0, file, line);
        fix_type = MTR_MEMO_PAGE_S_FIX;
        break;
      //RW SX 锁
      case RW_SX_LATCH:
        rw_lock_sx_lock_inline(&fix_block->lock, 0, file, line);
        fix_type = MTR_MEMO_PAGE_SX_FIX;
        break;
      default:
        ut_ad(rw_latch == RW_X_LATCH);
        rw_lock_x_lock_inline(&fix_block->lock, 0, file, line);
        fix_type = MTR_MEMO_PAGE_X_FIX;
        break;
    }
    //mode类型不为BUF_PEEK_IF_IN_POOL,且Page的是第一次被访问,需要进行线性预读操作
    if (mode != BUF_PEEK_IF_IN_POOL && !access_time) {
   	  //触发线性预读操作
      buf_read_ahead_linear(page_id, page_size, ibuf_inside(mtr));
    }
    //返回Page的控制信息
    return (fix_block);
} 

其中未在Page_hash中找到Page,且mode不为BUF_GET_IF_IN_POOL时,需要调用buf0rea.cc::buf_read_page()区文件中读取Page。

buf0rea.cc::buf_read_page(...)
    |->buf_read_page_low(...)
    	|->buf_page_init_for_read(...) //初始化Page,实际会从Free List中获取空闲Page
    	|->fil_io(...) //从文件中读取数据,并填充Page

至此,Buffer Pool的读写操作大致流程就分析完了,但细节性的页链表的访问,如LRU List和Flush List的管理和淘汰,以及关于随机预读和线性预读操作的部分,还需要分析一下。我们先从页链表的访问看起:

页链表的访问:

获取一个新的Page,并对其完成初始化工作,以便于后续的fil_io(…)将从数据文件中读取到的数据填充到该Page,其中会涉及到从Free List中获取空闲Page,如果无空闲Page则需要对LRU List和Flush List进行淘汰操作,我们先从buf_page_init_for_read(…)函数看起:

buf_page_init_for_read(){
    ...
    //核心函数,用于获取一个空闲的Page,其中可能会触发LRU List和Flush List的淘汰
    block = buf_LRU_get_free_block(buf_pool);
    //LRU_list_mutex进入互斥状态
    mutex_enter(&buf_pool->LRU_list_mutex);
    //初始化Page
    buf_page_init(buf_pool, page_id, page_size, block);
    //加入至LRU List中
    buf_LRU_add_block(bpage, TRUE /* to old blocks */);
    //LRU_list_mutex退出互斥状态
    mutex_exit(&buf_pool->LRU_list_mutex);
    ...
    return (bpage);
}

当Buffer Pool Instance去获取一个空闲Page时,大多数情况下都会直接从Free List中获取一个空闲Page直接返回,除非Free List是空的,则需要去进行回收LRU List和Flush List中的Page,在进行查找和回收Page时,在buf_LRU_get_free_block()函数中,定义了一个n_iterations,这个参数用于标识是第几次进行迭代获取空闲Page,当第一次来获取Page时,n_iterations为0,总共分为三种情况作处理,具体如下:

  • n_iterations = 0:

    1. 直接调用buf_LRU_get_free_only()函数从Free List中获取Page;
    2. 如果未Free List中获取到空闲Page,且try_LRU_scan设置为True,则开始扫描LRU List尾部的BUF_LRU_SEARCH_SCAN_THRESHOLD(默认为100)数量个Page,找到一个可以被回收的Page(即没有事务在使用这个Page),调用buf_LRU_free_page()函数回收Page,并将其加入到Free List中,然后再调用buf_LRU_get_free_only()函数从Free List中获取Page;
    3. 如果在上一步操作中还是未找到空闲Page,则尝试从LRU List的尾部Flush一个Page到数据文件中,调用buf_flush_single_page_from_LRU()来完成对Page的Flush,并将其加入到Free List中,然后再调用buf_LRU_get_free_only()函数从Free List中获取Page;
    4. 如果还是未找到空闲Page,则将n_iterations++,并重复1-3的步骤从最开始继续循环获取Page。
  • n_iterations = 1:

    此时和n_iterations = 0的执行流程几乎是一样的,只是在扫描LRU List时是扫描整个链表而不是只扫描尾部的一部分了,其余流程完全一致。如果未找到则将n_iterations++,并重复n_iterations = 0中1-3的步骤从最开始继续循环获取Page。

  • n_iterations > 1:

    此时和n_iterations = 1流程完全一致,只是会在在flush之前每次sleep 10ms。如果还是找不到空闲Page,则继续将n_iterations++,并重复n_iterations = 0中1-3的步骤从最开始继续循环获取Page。

    当n_iterations > 20时,会打印一条频繁获取不到空闲Page的log。

到此,Buffer Pool的介绍就暂时告一段落了,后续会继续尝试从源码的角度来剖析压缩Page相关的逻辑,敬请关注。

MySQL · 最佳实践 · RDS 三节点企业版热点组提交

$
0
0

RDS 5.7三节点企业版提供热点组提交功能,对“电商秒杀”等热点更新场景有大幅性能优化。

前提条件

当前仅RDS for MySQL 5.7三节点企业版1.5.0.4及以上版本支持该功能。

功能设计

“电商秒杀”等业务场景会对某一行数据短时间进行大量并发更新,这种热点操作对数据库的性能有很大的挑战。MySQL传统的更新模式“lock-update-unlock”,性能上基本无法满足实际的需求。业界针对这个问题有很多优化方案:基于缓存的方案不能保证数据一致性,有丢数据和超卖的风险;基于排队的方案仅仅缓解了大并发下的雪崩问题,依然受限于引擎层的lock/unlock性能损耗;预扣减、异步扣减等业务优化,增加了业务逻辑的复杂度,一定程度上也影响了客户体验。

热点组提交功能是RDS三节点企业版自研的特性。用户开启参数后,并为热点更新的SQL添加相关优化器hint,组提交模块会将同一数据行的热点请求自动合并成合适大小的Group,把多次逻辑更新映射成单次物理更新,最终下发到引擎层。该方法彻底的突破了InnoDB引擎层的性能上限,在单行更新的场景下测试,相较原生的MySQL,随着并发数上升,有十倍甚至上百倍的性能提升。

使用方式

热点功能涉及到三种新的优化器hint:

commit_on_success更新成功自动提交必选
rollback_on_fail更新失败自动回滚可选
target_affect_row(1)显式指定该请求只会更新一行,若不符合,更新失败可选

同时需要打开hotspot相关参数:

set global hotspot=ON;
set global hotspot_lock_type=ON;

需要注意的是,只有在打开参数配置的基础上,同时使用commit_on_success的hint,才能激活该功能。 样例SQL如下:

mysql> create table test (id int primary key, data int);
Query OK, 0 rows affected (0.01 sec)

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

mysql> update /*+ commit_on_success */ test set data = data + 1 where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from test;
+----+------+
| id | data |
+----+------+
|  1 |    2 |
+----+------+
1 row in set (0.00 sec)

mysql> update /*+ commit_on_success rollback_on_fail target_affect_row(1) */ test set data = data + 1 where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from test;
+----+------+
| id | data |
+----+------+
|  1 |    3 |
+----+------+
1 row in set (0.00 sec)

此外也支持select ... from update的语法,可以直接返回更新后的数据。

mysql> select * from test;
+----+------+
| id | data |
+----+------+
|  1 |    3 |
+----+------+
1 row in set (0.00 sec)

mysql> select id, data from update /*+ commit_on_success */ test set data = data + 1 where id = 1;
+----+------+
| id | data |
+----+------+
|  1 |    4 |
+----+------+
1 row in set (0.01 sec)

通过show global status like "%Group_update%"可以查询组提交状态。当Group_update_leader_count增加的时候,说明触发了热点组提交的优化逻辑。

mysql> show global status like "%Group_update%";
+---------------------------------------+-------+
| Variable_name                         | Value |
+---------------------------------------+-------+
| Group_update_fail_count               | 0     |
| Group_update_follower_count           | 0     |
| Group_update_free_count               | 1     |
| Group_update_gu_leak_count            | 0     |
| Group_update_gu_lock_fail_count       | 0     |
| Group_update_ignore_count             | 0     |
| Group_update_insert_dup               | 0     |
| Group_update_leader_count             | 2     |
| Group_update_mgr_recycle_queue_length | 0     |
| Group_update_recycle_queue_length     | 0     |
| Group_update_reuse_count              | 1     |
| Group_update_total_count              | 1     |
+---------------------------------------+-------+
12 rows in set (0.00 sec)

使用限制

  • 只支持基于主键的单行更新。
  • 无热点hint的SQL持有行锁的时间内,热点hint的SQL更新同一行会立刻冲突报错。因此不建议热点非热点混用。

相关参数

参数说明
hotspotON,OFF 热点组提交功能开关。
hotspot_lock_typeON,OFF 热点组提交锁优化开关。一般情况下,hotspot和hotspot_lock_type会同时开启。
hotspot_update_max_wait_time热点组提交Group收集时间,一般保留默认参数即可。
innodb_hotspot_kill_lock_holderON,OFF 带有热点标记的SQL发现行锁被不带热点标记的事务持有后,主动kill持有锁的事务。

性能测试

测试不同并发数下,单行更新性能,统计tps和95%rt。

准备数据:

root@test 03:34:13>create table t1(id int primary key auto_increment, data int);
Query OK, 0 rows affected (0.00 sec)

root@test 03:34:15>insert into t1(data) values (1);
Query OK, 1 row affected (0.00 sec)

压测SQL:

UPDATE /*+ commit_on_success rollback_on_fail target_affect_row(1) */ t1 SET data=data+1 WHERE id=1;

64core 256G实例,测试结果:

线程数hotspot=OFFhotspot=ON
16399.59 tps 0.17 ms3145.12 tps 0.33 ms
415473.29 tps 0.29 ms12009.01 tps 0.35 ms
814906.54 tps 0.58 ms22498.85 tps 0.38 ms
1614930.81 tps 1.12 ms51153.38 tps 0.40 ms
3214032.86 tps 2.38 ms77760.79 tps 0.46 ms
6411334.73 tps 6.04 ms88099.79 tps 0.98 ms
1285912.53 tps 22.15 ms90054.17 tps 1.75 ms
2561869.35 tps 139.29 ms87724.28 tps 3.43 ms
512379.01 tps 1495.24 ms89820.75 tps 6.57 ms

perf

MySQL · 引擎特性 · 8.0 heap table 介绍

$
0
0

什么是内存表

内存表,就是放在内存中的表,所使用内存的大小可通过My.cnf中的max_heap_table_size指定,如max_heap_table_size=1024M 内存表满后,会提示数据满错误。 ERROR 1114 (HY000): The table ‘abc’ is full

内存表的特性

  • 内存表的表定义是存放在磁盘上的,扩展名为.frm, 所以重启不会丢失。
  • 内存表的数据是存放在内存中的,所以重启会丢失数据
  • 内存表支持AUTO_INCREMENT列 (ps, 一些网站资源说不支持)
mysql> show create table heap_test;
| heap_test | CREATE TABLE `heap_test` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `a1` char(8) DEFAULT NULL,
  `a2` char(8) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `a1` (`a1`(2))
) ENGINE=MEMORY AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
  • 内存表表在所有客户端之间共享
  • 在数据库复制时,如果主机当掉,则会在binLog中自动加入delete from [内存表],将slave的数据也删除掉,以保证两边的数据一致性
  • 内存表不支持事务
  • 内存表是表锁,当修改频繁时,性能可能会下降。

内存表使用场景

因为内存表有两个主要的特性

  1. 多线程共享,对所有的用户连接是可见的,这一点和临时表完全不同。
  2. 数据存储在内存中,有很快的访问速度。

所以内存表很适合作为缓存,存储中间结果,和需要频繁访问的数据。

缺点也很明显,数据存在内存中, 服务器重启后数据会丢失。

内存表源码说明

创建内存表的相关参数 internal_tmp_mem_storage_engine

  • set internal_tmp_mem_storage_engine = memory, create_tmp_table 创建出的临时表引擎是 memory (heap 表)
  • set internal_tmp_mem_storage_engine=default , create_tmp_table 创建出的临时表引擎是 TempTable (temporary 表)

以下代码调研 临时表引擎为 memory

创建 heap 表

代码调用栈

Sql_cmd_create_table::execute -> mysql_create_table -> mysql_create_table_no_lock -> create_table_impl -> rea_create_base_table -> ha_create_table -> handler::ha_create -> ha_heap::create

代码接口:ha_heap::create 这个接口做的事情主要是

  1. Prepare HP_CREATE_INFO (HP_CREATE_INFO 是存储了 heap 表的一些表结构定义信息)
  2. 创建 heap 表,把之前 准备好的 表定义信息, 并且赋值给 HP_SHARE 类的指针 (HP_SHARE 是一个描述每一个内存中的存储文件的类) 初始化 HP_KEYDEF, 和 HP_BLOCK,这两个类作为 HP_SHARE 的类成员变量 2.1 HP_KEYDEF 定义了 其索引描述符 2.2 在create_heap 表内,会调用一个 static 的方法 init_block, HP_BLOCK 是 memory 引擎树型存储结构的描述类

PS: MEMORY 引擎会将数据记录在一些定长的内存块中,每个内存块中记录数目存储在 HP_BLOCK 类中的

uint records_in_block{0}; /* Records in one heap-block */

每条记录的长度存储在 HP_BLOCK 中的 uint recbuffer{0}; / * Length of one saved record * / 给每条记录分配空间的内存长度为 recbuffer + 1, 最后一位是标记位,value = 1 为未删除,value = 0 为删除。

查询 heap 表

Memory 有两个全局变量,heap_open_list and heap_share_list.

  • 每一个 HP_SHARE 对应一个物理表
  • 每个表会有一个或者多个表描述类,所以每一个表描述类对应着一个 handler 实例和HP_INFO 实例 (这也是多个线程可以共享 heap 表的原因,每一个线程会有一个自己的表描述类)
  • 每一个handler 实例中有一个 HP_SHARE 的引用
  • hp_share_list 保存所有的 hp_share , hp_open_list 保存所有的 hp_info

查询时内存表主要会做三个步骤 开表 -> 预算有多少记录->读记录 代码接口: ha_heap::open -> ha_heap::info ->ha_heap::rnd_init

开表代码调用栈

open_tables_for_query -> open_tables -> open_and_process_table -> open_table -> open_table_from_share -> handler::ha_open -> ha_heap::open

在 ha_heap::open 这个接口里主要做的事情是

  1. 开表,调用 HP_INFO* heap_open(const char* name, int mode) 根据表名打开对应的 heap 表 这里主要是检查 hp_share_list 中是否有要打开表的 hp_share 信息,如果找到了,则根据找到的 hp_share 信息初始化 新的 hp_info 信息,并将其加入到 hp_open_list 中
  2. 如果没有找到heap 表,create one 通过调用 heap_create

释放 heap 表

代码接口: ha_heap::close

这是一个释放临时表的接口,主要做的事情是从hp_open_list 中删除相应的HP_INFO, 然后–info->s->open_count, 将于 hp_info 关联的 hp_share 中的技术变量 open_count 减1 并且调用 my_free 释放 hp_info 当这个值减为0时候并且 hp_share 的 delete_on_close 为 true, 则调用 hp_free 释放 hp_share

内存表多实例共享

我们已知内存表的一个特性就是多实例共享,以下从三个方面描述多实例共享。 1.MySQL 建立连接 connect_a,连接的数据库中有 heap 表, MySQL 建立连接 connect_b, 选择与 connect_a 同一个库

这种场景下,当connect_a 建立时,系统会调用

  open_table_from_share -> handler::ha_open -> ha_heap::open 

打开内存表,同时给当前的线程创建一个属于自己的表描述类,这个表描述类,对应着一个handler 和 HP_INFO 实例。 当 connect_b 建立时,系统的做法同 connect_a 建立时一样,会为当前的线程创建一个属于自己的表描述类,用于操做内存表

2.MySQL 建立连接 connect_a,连接的数据库中没有heap表, MySQL 建立连接 connect_b,选择与 connect_a 同一个库,创建内存表, connect_a, 查询该内存表

这种场景下,当connect_a 建立,系统调用open_table_from_share, 因为库中没有内存表,所以不会调用开启内存表接口的方法,connect_b 建立,也不会调用开启内存表接口的方法,当 connect_b 创建内存表后,系统会调用 创建内存表的接口 ha_heap::create。 表创建好后,connect_a 查询内存表,这个时候系统会为connect_a 的线程创建一个属于自己的表描述类,代码路径是 open_table_from_share -> handler::ha_open -> ha_heap::open

3.MYSQL 建立连接 connect_a, 连接的数据库中有heap表,MySQL 建立连接 connect_b, 选择与 connect_a 同一个表,connect_b 断开连接,MySQL 建立连接 connect_c, 选择与 connect_a 同一个库,并drop 内存表。connect_a 查询内存表 这种场景下,connect_a 和 connect_b,connect_c 建立后,系统都会为他们创建属于自己线程的表描述类,用于操作内存表。 connect_b 断开连接后,不会对内存表有任何影响。connect_c 调用 drop table 后,系统会调用 ha_heap::close 方法,这个方法具体的描述在上文已经阐述,connect_a 再去查内存表会抛出 ERROR 1146, table doesn’t exist 错误。

内存表设计思考

MySQL 数据库内部的多线程机制,提高了系统的吞吐量,并且提供的是插件式的存储引擎结构,这样就使得每一个表都可以设置自己的存储引擎。内存表也作为一种存储引擎可以供系统的表做选择,每一个表都有自己的一个表描述类(TABLE),多个线程中每个线程在处理请求的时候都会有自己的表描述类,每个表描述类都会分配一个自己的handler类实例,这不仅仅适用与内存表,也适用了其他的存储引擎。这样设计内存表,就是提供一种快速访问的存储引擎。大大提供数据的访问速率。

参考

  1. https://dev.mysql.com/doc/refman/8.0/en/memory-storage-engine.html
  2. https://www.cnblogs.com/lihaozy/p/3226962.html

MySQL · 存储引擎 · MySQL的字段数据存储格式

$
0
0

概述

MySQL支持多种存储引擎,而InnoDB是MySQL事务型数据库的首选引擎,也是MySQL从5.6版本以来的默认存储引擎。

InnoDB的存储格式已经有太多介绍性的文章,讲述了Tablespaces, Segments, Exents, Pages, Records等概念。其中很少有人对行存Record的不同数据字段进行介绍。本文讨论分析一下常见的字段数据在MySQL和InnoDB种不同的存储格式,并给出方法,大家可以自行学习其他没有涉及的字段。这里讨论的MySQL和InnoDB都是MySQL 5.7或MySQL 8.0, 过早的版本不在本文讨论范围。

字段数据格式学习方法

因为MySQL可以对接不同独立的存储引擎,MySQL和其对应的存储引擎对数据的存储方式就可能不同。因此MySQL必然会在计算层和存储层有不同的存储格式,也会有对应的数据转化方法。

对于InnoDB而言,有两个方法对于数据格式的转化最为关键。

  1. MySQL数据 -> InnoDB数据  (row0mysql.cc)
    /** Stores a non-SQL-NULL field given in the MySQL format in the InnoDB format. */
    row_mysql_store_col_in_innobase_format()
    
  2. InnoDB数据 -> MySQL数据  (row0sel.cc)
    /** Convert a field from Innobase format to MySQL format. */
    row_sel_store_mysql_field
    

大家可以使用GDB设置断点在以上两个函数,就可以清楚的认识到不同字段数据的存储格式了。

常见字段数据格式

Numeric Data Types

INTEGER, INT, SMALLINT, TINYINT, MEDIUMINT, BIGINT

Table:  Required Storage and Range for Integer Types Supported by MySQL

TypeStorage (Bytes)Minimum Value SignedMinimum Value UnsignedMaximum Value SignedMaximum Value Unsigned
TINYINT1-1280127255
SMALLINT2-3276803276765535
MEDIUMINT3-83886080838860716777215
INT4-2147483648021474836474294967295
BIGINT8-202-12-1

MySQL使用little-endian格式存储integer数据,InnoDB使用big-endian格式,并且符号为是取反处理。InnoDB这样设计存储Integer的好处是在数据比较的时候,可以直接一个一个byte去比较 - memcmp。

举例:BIGINT value 1000 InnoDB format 1000 stored as bigint (8 bytes) in Hex as: 0x80 0x00 0x00 0x00 0x00 0x00 0x03 0xe8 -1000 stored as bigint (8 bytes) in Hex as: 0x7f 0xff 0xff 0xff 0xff 0xff 0xfc 0x18

MySQL format: 1000 stored in Hex as:  0xe8    0x03    0x00    0x00    0x00    0x00    0x00    0x00

DECIMAL

这里MySQL和InnoDB存储格式一致,不需要做特别转换。Decimal需要声明precision和scale,例如decimal(30,15)。精度表示值存储的有效位数,小数位数表示小数点后可以存储的位数。

举例:decimal(30,15) value
1000.01 stored (14 bytes) in Hex as:
0x80    0x00    0x00    0x00    0x00    0x03    0xe8    0x00
0x98    0x96    0x80    0x00    0x00    0x00

FLOAT, DOUBLE

FLOAT/DOUBLE类型表示近似数字数据值。这里MySQL和InnoDB存储格式一致,不需要做特别转换。MySQL将四个字节用于单精度值,并将八个字节用于双精度值。单精度FLOAT列存储精度范围是从0到23, 双精度FLOAT列存储精度范围是从24到53。

举例:double  1000.01 stored (8 bytes) in Hex as: 0xae    0x47    0xe1    0x7a    0x14    0x40    0x8f    0x40

Date and Time Data Types

时间相关的存储格式:

TypeStorage as of MySQL 5.6.4
YEAR1 byte, little endian
DATE3 bytes, little endian
TIME3 bytes + fractional-seconds storage, big endian
TIMESTAMP4 bytes + fractional-seconds storage, big endian
DATETIME5 bytes + fractional-seconds storage, big endian
  • TIME encoding for non-fractional part:
 1 bit sign    (1= non-negative, 0= negative)
 1 bit unused  (reserved for future extensions)
10 bits hour   (0-838)
 6 bits minute (0-59) 
 6 bits second (0-59) 
---------------------
24 bits = 3 bytes
  • DATETIME encoding for non-fractional part:
     1 bit  sign           (1= non-negative, 0= negative)
    17 bits year*13+month  (year 0-9999, month 0-12)
     5 bits day            (0-31)
     5 bits hour           (0-23)
     6 bits minute         (0-59)
     6 bits second         (0-59)
    ---------------------------
    40 bits = 5 bytes
    

举例: Datetime value: ‘1970-1-1 00:00:00’

  • innodb data: 0x99    0x02    0xc2    0x00    0x00

Datetime value: ‘2019-12-19 03:14:07’

  • innodb data: 0x99    0xa4    0xe6    0x33    0x87

查看落盘数据格式

还有其他常用字段大家可以通过前文的方法,自行学习下。在了解了不同字段的存储格式后,我们也可以从InnoDB落盘数据上得到验证。

hexdump -C -v table.ibd > table.txt

找到对应table的数据文件,用hexdump把table数据以hex方式打印到一个文本文件内,然后就可以用编辑器打开浏览。这里可以结合上文中得到的不同字段hex的表示,在文本文件中搜寻。

MySQL · 引擎特性 · MYSQL Binlog Cache详解

$
0
0

MYSQL Binlog Cache详解

最近在线上遇到一个突发情况:某客户出现了超大事务,该事务运行时占据的磁盘空间超过800GB,但du -sh时未发现任何线索。于是刨根溯源,找到了最终的原因并紧急处理了该问题。本文便是对该问题涉及的binlog cache知识进行整理,希望也能造福更多的朋友。本文会涉及到如下几个概念:

  • binlog cache:它是用于缓存binlog event的内存,大小由binlog_cache_size控制
  • binlog cache 临时文件:是一个临时磁盘文件,存储由于binlog cache不足溢出的binlog event,该文件名字由”ML”打头,由参数max_binlog_cache_size控制该文件大小
  • binlog file:代表binglog 文件,由max_binlog_size指定大小
  • binlog event:代表binlog中的记录,如MAP_EVENT/QUERY EVENT/XID EVENT/WRITE EVENT等

事务binlog event写入流程

binlog cache和binlog临时文件都是在事务运行过程中写入,一旦事务提交,binlog cache和binlog临时文件都会释放掉。而且如果事务中包含多个DML语句,他们共享binlog cache和binlog 临时文件。整个binlog写入流程:

  1. 事务开启
  2. 执行dml语句,在dml语句第一次执行的时候会分配内存空间binlog cache
  3. 执行dml语句期间生成的event不断写入到binlog cache
  4. 如果binlog cache的空间已经满了,则将binlog cache的数据写入到binlog临时文件,同时清空binlog cache。如果binlog临时文件的大小大于了max_binlog_cache_size的设置则抛错ERROR 1197
  5. 事务提交,整个binlog cache和binlog临时文件数据全部写入到binlog file中,同时释放binlog cache和binlog临时文件。但是注意此时binlog cache的内存空间会被保留以供THD上的下一个事务使用,但是binlog临时文件被截断为0,保留文件描述符。其实也就是IO_CACHE(参考后文)保留,并且保留IO_CACHE中的分配的内存空间,和物理文件描述符
  6. 客户端断开连接,这个过程会释放IO_CACHE同时释放其持有的binlog cache内存空间以及持有的binlog 临时文件。 本文主要关注步骤3和4过程中对binlog cache以及binlog 临时文件的写入细节。

数据结构

binlog_cache_mngr

这个类中包含了两个cache:binlog cache和binlog stmt cache。同时包含了将binlog event flush到binlog file的方法。

binlog_trx_cache_data

暂时不表

Binlog_cache_storage

暂时不表

IO_CACHE_binlog_cache_storage

暂时不表

IO_CACHE

将binlog event写入到binlog cache 或者 binlog临时文件都是由 IO_CACHE子系统实现的。IO_CACHE子系统实现了写缓存以及在缓存不足时写入物理文件的功能。它包含读缓存,写缓存以及访问物理文件等信息。其维护的核心成员有:

  • 读缓存 uchar *buffer;
  • 写缓存 uchar *write_buffer;
  • 物理文件 File file;

同时IO_CACHE也支持多种访问模式如READ_CACHE/WRITE_CACHE/SEQ_READ_APPEND,这里就暂时不表。

binlog_cache_size & max_binlog_cache_size

如果开启binlog,那么binlog_cache_size用来在事务运行期间在内存中缓存binlog event。如果经常使用大事务应该加大这个缓存,避免过多的磁盘使用影响性能。

当binlog_cache_size不足以容纳所有的binlog event时,便转而使用临时文件来缓存binlog event。从Binlog_cache_use和Binlog_cache_disk_use可以看出是否使用了binlog cache或binlog 临时文件用于保存binlog event。

binlog cache创建

事务开启时,如果开启binlog功能,便会创建binlog cache。

void *handler_create_thd(
    bool enable_binlog) /*!< in: whether to enable binlog */
{
  ...
  if (enable_binlog) {
    thd->binlog_setup_trx_data();
  }
  return (thd);
}

int THD::binlog_setup_trx_data() {
  binlog_cache_mngr *cache_mngr = thd_get_cache_mngr(this);
  cache_mngr = (binlog_cache_mngr *)my_malloc(key_memory_binlog_cache_mngr,
                                              sizeof(binlog_cache_mngr),
                                              MYF(MY_ZEROFILL));
  cache_mngr = new (cache_mngr)
      binlog_cache_mngr(&binlog_stmt_cache_use, &binlog_stmt_cache_disk_use,
                        &binlog_cache_use, &binlog_cache_disk_use);
  if (cache_mngr->init()) {
    ...
  }
}

class binlog_cache_mngr {
 public:
  bool init() {
    return stmt_cache.open(binlog_stmt_cache_size,
                           max_binlog_stmt_cache_size) ||
           trx_cache.open(binlog_cache_size, max_binlog_cache_size);
  }
}

class binlog_cache_data {
 public:
  bool open(my_off_t cache_size, my_off_t max_cache_size) {
    return m_cache.open(cache_size, max_cache_size);
  }
}

// 分配binlog cache内存缓存空间以及创建临时文件
// 最终是进入了函数init_io_cache_ext处理,暂且不表
bool Binlog_cache_storage::open(my_off_t cache_size, my_off_t max_cache_size) {
  const char *LOG_PREFIX = "ML";
  if (m_file.open(mysql_tmpdir, LOG_PREFIX, cache_size, max_cache_size))
    return true;
  m_pipeline_head = &m_file;
  return false;
}

binlog临时文件会被存放到tmpdir的目录下,并以”ML”作为文件名开头。但该文件无法用ls命令看到,因为使用了LINUX创建临时API(mkstemp),以避免其他进程破坏文件内容。也就是说,这个文件是mysqld进程内部专用的,我们在后面会给出访问该文件的方法。

binlog写入cache和临时文件

binlog event写入binlog cache和临时文件是通过函数_my_b_write进行的:

bool IO_CACHE_binlog_cache_storage::write(const unsigned char *buffer,
                                          my_off_t length) {
  return my_b_safe_write(&m_io_cache, buffer, length);
}

int my_b_safe_write(IO_CACHE *info, const uchar *Buffer, size_t Count) {
  if (info->type == SEQ_READ_APPEND) return my_b_append(info, Buffer, Count);
  return my_b_write(info, Buffer, Count);
}

// 如果binlog cache缓存当前写入的位置加上本次写入的总量大于了binlog cache的内存地址的边界
// 则我们需要进行通过*(info)->write_function将binlog cache的内容写到磁盘了
// 这样才能腾出空间给新的binlog event存放。这个回调函数就是_my_b_write。
#define my_b_write(info, Buffer, Count)                         \
  ((info)->write_pos + (Count) <= (info)->write_end             \
       ? (memcpy((info)->write_pos, (Buffer), (size_t)(Count)), \
          ((info)->write_pos += (Count)), 0)                    \
       : (*(info)->write_function)((info), (uchar *)(Buffer), (Count)))

int _my_b_write(IO_CACHE *info, const uchar *Buffer, size_t Count) {
  size_t rest_length, length;
  my_off_t pos_in_file = info->pos_in_file;
  // 如果超过临时文件大小设置,则报错
  if (pos_in_file + info->buffer_length > info->end_of_file) {
    errno = EFBIG;
    set_my_errno(EFBIG);
    return info->error = -1;
  }

  // 首先将binlog内容拷贝至内存cache,将cache填满
  rest_length = (size_t)(info->write_end - info->write_pos);
  memcpy(info->write_pos, Buffer, (size_t)rest_length);
  Buffer += rest_length;
  Count -= rest_length;
  info->write_pos += rest_length;

  if (my_b_flush_io_cache(info, 1)) return 1;
  if (Count >= IO_SIZE) { /* Fill first intern buffer */
    length = Count & (size_t) ~(IO_SIZE - 1);
    ...
    if (mysql_file_write(info->file, Buffer, length, info->myflags | MY_NABP))
      return info->error = -1;
    ...
    Count -= length;
    Buffer += length;
    info->pos_in_file += length;
  }
  memcpy(info->write_pos, Buffer, (size_t)Count);
  info->write_pos += Count;
  return 0;
}

运维技巧:查看binlog 临时文件

因为没法直接通过ls来查看binlog临时缓存文件,但可以使用lsof|grep delete来观察到这种文件

[root@test ~]# lsof|grep delete|grep ML
mysqld  21414 root 77u  REG  252,3  65536  1856092 /var/tmp/mysqld.1/MLUFzokf

MySQL · 引擎特性 · 8.0 Instant Add Column功能解析

$
0
0

概述

DDL(Data Definition Language)是数据库内部的对象进行创建、删除、修改的操作语言,主要包括:加减列、更改列类型、加减索引等类型。数据库的模式(schema)会随着业务的发展不断变化,如果没有高效的DDL功能,每一次变更都有可能影响业务,甚至产生故障。MySQL在8.0以前就已经支持Online DDL,在执行时能够不阻塞其它DML(Insert/Update/Delete)操作,但许多重要的DDL操作,如加列、减列等,仍旧需要等待很长时间(根据数据量的大小)才会生效。为了提高表结构变更的效率,MySQL在8.0.12版本支持了Instant DDL功能,不需要修改存储层数据就可以快速完成DDL。

语法

执行DDL的ALTER语句增加了新的关键字INSTANT,用户可以显式地指定,MySQL也会自动选择合适的算法,因此Instant DDL对用户是透明的。

ALTER TABLE tbl_name
    [alter_specification [, alter_specification] ...]
    [partition_options]

alter_specification:
    table_options
  | ADD [COLUMN] col_name column_definition
        [FIRST | AFTER col_name]
  | ADD [COLUMN] (col_name column_definition,...)
  ....
  | ALGORITHM [=] {DEFAULT|INSTANT|INPLACE|COPY}

备注:
 1.DEFAULT:MySQL自己选择锁定资源最少的方式
 2.INSTANT:只需要更新数据字典中的元数据, 很快完成
 3.INPLACE:此变更由InnoDB引擎独立完成, 不需要使用Redo log等, 可以节省开销
 4.COPY:此变更会重建聚簇索引, 执行DDL的时候会创建临时表

快速DDL支持类型

  • Instant add column
    • 当一条alter语句中同时存在不支持instant的ddl时,则无法使用
    • 只能顺序加列
    • 不支持压缩表、不支持包含全文索引的表
    • 不支持临时表,临时表只能使用copy的方式执行DDL
    • 不支持那些在数据词典表空间中创建的表
  • 修改索引类型
  • 修改ENUM/SET类型的定义
    • 存储的大小不变时
    • 向后追加成员
  • 增加或删除类型为virtual的generated column
  • RENAME TABLE操作

Instant Add Column

简介

随着业务的发展,加字段是最常见表结构变更类型。Instant add column功能不需要修改存储层数据,更不需要重建表,只改变了存储在系统表中的表结构,其执行效率非常高。解决了以下业务上的痛点:

  • 对大表的加字段操作通常需要耗时十几个小时甚至数天的时间
  • 加字段过程中需要创建临时表,消耗大量存储资源
  • binlog复制是事务维度的,DDL会造成主备延时

在实现上,MySQL并没有在系统表中记录多个版本的schema,而是非常取巧的扩展了存储格式。在已有的info bits区域和新增的字段数量区域记录了instant column信息,instant add column之前的数据不做任何修改,之后的数据按照新格式存储。同时在系统表的private_data字段存储了instant column的默认值信息。查询时,读出的老记录只需要增加instant column默认值,新记录则按照新的存储格式进行解析,做到了新老格式的兼容。当然,这种实现方式带来的限制就是只能顺序加字段。

官方设计文档详见:https://dev.mysql.com/worklog/task/?spm=a2c4e.10696291.0.0.439f19a4hwOoes&id=11250。本文主要梳理了新的存储格式、DDL、查询以及插入的执行流程。

Online Add Column流程

8.0.12版本之前的MySQL在进行加列操作时,需要更新数据字典并重建表空间,所有的数据行都必须改变长度用于存放增加的数据,DDL操作运行时间很长,占用大量系统资源,更需要额外的磁盘空间(建立临时表),影响系统吞吐,而且一旦执行过程中发生crash,恢复时间也很长。

主要流程: online_add_column

Instant Add Column流程

Instant add column在增加列时,实际上只是修改了schema,并没有修改原来存储在文件中的行记录,不需要执行最耗时的rebuild和apply row log过程,因此效率非常高。

主要流程: instant_add_column

新的数据字典信息

在执行instant add column的过程中,MySQL会将第一次intant add column之前的字段个数以及每次加的列的默认值保存在tables系统表的se_private_data字段中。

  • dd::Table::se_private_data::instant_col: 第一次instant ADD COLUMN之前表上面的列的个数, 具体过程详见函数dd_commit_instant_table。
  • dd::Column::se_private_data::default_null: 标识instant column的默认值是否为NULL,具体过程详见函数dd_add_instant_columns。
  • dd::Column::se_private_data::default:当instant column的默认值不是NULL时存储具体的默认值,column default value需要从innodb类型byte转换成se_private_data中的char类型,具体过程详见函数dd_add_instant_columns。

载入数据字典

MySQL从系统表读取表定义时,会将instant column相关的信息载入到InnoDB的表对象dict_table_t和索引对象dict_index_t中。

  • dict_table_t::n_instant_cols: 第一次instant add column之前的非虚拟字段个数(包含系统列), 具体过程详见函数dd_fill_dict_table
  • dict_index_t::instant_cols: 用于标示是否存在Instant column,具体过程详见函数dict_index_add_to_cache_w_vcol
  • dict_index_t::n_instant_nullable:第一次instant add column之前的可为NULL的字段个数,具体过程详见函数dict_index_add_to_cache_w_vcol
  • dict_col_t::instant_default: 存储默认值及其长度,具体过程详见函数dd_fill_instant_columns

记录格式

InnoDB存储引擎支持的行格式包括REDUNDANT,COMPACT以及DYNAMIC,REDUNDANT类型的行记录了完整的元数据信息,可以自解析,但对于COMPACT和DYNAMIC类型,为了减少存储空间,其行内并不包括元数据,尤其是列的个数,因此解析记录时需要额外的元数据辅助。

以COMPACT为例,其行格式为: row_format

变长字段长度列表

COMPACT行格式的首部是一个变长字段长度列表,这个列表是按照字段的顺序逆序放置的。如果字段的字义长度大于255个字节,或者字段的数据类型为BLOB的,则用2个字节来存储该字段的长度;如果定义长度小于128个字节,或者小于256个字节,但类型不是BLOB类型的,则用一个字节来存储该字段的长度,除此之外都用2个字节来存储。

NULL字段标志位

变长字段长度列表之后是NULL字段标志位,这个标志位用于记录中哪些字段的值是null,只存储nullable属性的字段,不会存储属性为not nulll的字段。每一bit都表示一个nullable的字段的null属性,如果为null则设置为1,这个bit vector也是按照字段的顺序逆序放置的,整个标志位长度取决于记录中nullable字段的个数,而是以8为单位,满8个null字段就多1个字节,不满8个也占用1个字节,高位用0补齐。

记录头信息

记录头信息最开始的4个bit组成了info bits, 目前只使用了两个bit,具体含义如下:

名称大小(bit)描述
()1预留
()1预留
delete_flag1该字段是否已被删除
min_rec_flag1该记录是否为预先定义的最小记录
n_owned4当前slot拥有的记录数
heap_no13索引中该记录的排序记录
record_type3记录类型,REC_STATUS_ORDINARY(000):叶子节点记录 REC_STATUS_NODE_PTR(001):非叶子节点记录 REC_STATUS_INFIMUM(010):最小记录 REC_STATUS_SUPREMUM(011)最大记录
next_record16页中下一条记录的相对位置
总数40 

新的记录格式

为了支持instant add column, 针对COMPACT和DYNAMIC类型,引入了新的记录格式,主要为了记录字段的个数信息。

  • 如果没有执行过instant add column操作,则表的行记录格式保持不变。
  • 如果执行过instant add column操作,则所有新的记录都会设置一个特殊的标记,同时在记录内存储字段的个数。

new_format

这个特殊的INSTANT_FLAG使用了info bits中的一个bit位,如果记录是第一次instant add column之后插入的,该flag被设置为1,且记录中会使用1或2个字节来存储字段的个数,如果字段个数小于等于127,则使用1个字节存储,否则使用2个字节存储。 相关代码:

// 返回用于存储字段数量的字节数
uint8_t rec_get_n_fields_length(ulint n_fields) {
  return (n_fields > REC_N_FIELDS_ONE_BYTE_MAX ? 2 : 1);
}

// 设置字段数量
uint8_t rec_set_n_fields(rec_t *rec, ulint n_fields) {
  // 指向记录头信息的前一个字节
  byte *ptr = rec - (REC_N_NEW_EXTRA_BYTES + 1);

  ut_ad(n_fields < REC_MAX_N_FIELDS);
  
  // 如果字段数量小于或等于127
  if (n_fields <= REC_N_FIELDS_ONE_BYTE_MAX) {
    // 在当前位置存储字段数量
    *ptr = static_cast<byte>(n_fields);
    // 存储字段数量的字节数是1
    return (1);
  }
  
  // 如果字段数量大于127,向前移动一个字节
  --ptr;
  // 第一个字节记录低8位数据
  *ptr++ = static_cast<byte>(n_fields & 0xFF);
  // 第二个字节记录高8位数据
  *ptr = static_cast<byte>(n_fields >> 8);
  ut_ad((*ptr & 0x80) == 0);
  *ptr |= REC_N_FIELDS_TWO_BYTES_FLAG;
  
  // 存储字段数量的字节数是2
  return (2);
}

表结构和初始化数据

> create table t1(id int, c1 varchar(10), c2 varchar(10), c3 char(10), c4 varchar(10), primary key(id)) row_format=compact;
Query OK, 0 rows affected (0.24 sec)
    
> insert into t1 values(1, 'a','ab','ab','ccc');
Query OK, 1 row affected (0.01 sec)
    
> insert into t1 values(2, 'b', NULL, NULL, 'ddd');
Query OK, 1 row affected (0.01 sec)
    
> select * from t1;
+----+------+------+------+------+
| id | c1   | c2   | c3   | c4   |
+----+------+------+------+------+
|  1 | a    | ab   | ab   | ccc  |
|  2 | b    | NULL | NULL | ddd  |
+----+------+------+------+------+
2 rows in set (0.00 sec)

idb文件解析

$ hexdump -C -v t1.ibd > t1.txt

00010070  73 75 70 72 65 6d 75 6d  03 0a 02 01 00 00 00 10  |supremum........|
00010080  00 29 80 00 00 01 00 00  00 00 07 d8 9e 00 00 00  |.)..............|
00010090  94 01 10 61 61 62 61 62  20 20 20 20 20 20 20 20  |...aabab        |
000100a0  63 63 63 03 01 06 00 00  18 00 1f 80 00 00 02 00  |ccc.............|
000100b0  00 00 00 07 d9 9f 00 00  00 94 01 10 62 64 64 64  |............bddd|
  • 第一行记录从0x00010078开始
起始地址数据长度(字节)解析
0x0001007803 0a 02 014变长字段长度列表,逆序存储(03表示c4字段的值ccc的长度,0a表示c3字段的长度,02表示c2字段的值ab的长度,01表示c1字段的值a的长度)
0x0001007c001NULL标志位,第一行没有NULL值
0x0001007f00 00 10 00 295记录头信息固定5字节,next_record=0x29,表示从这条记录的真实数据的地址往后找41个字节就是下一条记录的真实数据,即0x000100ab
0x0001008280 00 00 014主键
0x0001008600 00 00 00 07 d86事务ID
0x0001008c9e 00 00 00 94 01 107回滚指针
0x00010093611字段c1的数据’a’
0x0001009461 622字段c2的数据’ab’
0x0001009661 62 20 20 20 20 20 20 20 2010字段c3的数据’ab’(使用0x20填充固定长度的未使用部分)
0x000100a063 63 633字段c4的数据’ccc’
  • 第二行记录从0x000100a3开始
起始地址数据长度(字节)解析
0x000100a303 012变长字段长度列表,逆序存储(03表示c4字段的值ddd的长度,01表示c1字段的值b的长度)
0x000100a5061NULL标志位,第二行有NULL值。其二进制为00000110,表示第2,3列是null
0x000100a600 00 18 00 1f5记录头信息固定5字节
0x000100ab80 00 00 024主键
0x000100af00 00 00 00 07 d96事务ID
0x000100b59f 00 00 00 94 01 107回滚指针
0x000100bc621字段c1的数据’b’
0x000100bd64 64 643字段c4的数据’ddd’

执行instant add column

> alter table t1 add column (c5 varchar(10)), ALGORITHM = INSTANT;
Query OK, 0 rows affected (0.28 sec)
    
> insert into t1 values (3, 'c', NULL, NULL, 'eee', 'eeee');
Query OK, 1 row affected (0.06 sec)
    
> select * from t1;
+----+------+------+------+------+------+
| id | c1   | c2   | c3   | c4   | c5   |
+----+------+------+------+------+------+
|  1 | a    | ab   | ab   | ccc  | NULL |
|  2 | b    | NULL | NULL | ddd  | NULL |
|  3 | c    | NULL | NULL | eee  | eeee |
+----+------+------+------+------+------+
3 rows in set (0.00 sec)

idb文件解析

$ hexdump -C -v t1.ibd > t1.txt

00010070  73 75 70 72 65 6d 75 6d  03 0a 02 01 00 00 00 10  |supremum........|
00010080  00 29 80 00 00 01 00 00  00 00 07 d8 9e 00 00 00  |.)..............|
00010090  94 01 10 61 61 62 61 62  20 20 20 20 20 20 20 20  |...aabab        |
000100a0  63 63 63 03 01 06 00 00  18 00 1f 80 00 00 02 00  |ccc.............|
000100b0  00 00 00 07 d9 9f 00 00  00 94 01 10 62 64 64 64  |............bddd|
000100c0  04 03 01 06 08 80 00 20  ff a6 80 00 00 03 00 00  |....... ........|
000100d0  00 00 07 e7 a0 00 00 00  95 01 10 63 65 65 65 65  |...........ceeee|
000100e0  65 65 65 00 00 00 00 00  00 00 00 00 00 00 00 00  |eee.............|
  • 前两行记录没有变化

  • 第三行记录从0x000100c0开始

起始地址数据长度(字节)解析
0x000100c004 03 013变长字段长度列表,逆序存储(04表示c5字段的值eeee的长度,03表示c4字段的值ddd的长度,01表示c1字段的值b的长度)
0x000100c3061NULL标志位,第二行有NULL值。其二进制为00000110,表示第2,3列是null
0x000100c4081字段数量,表示这一行插入时表有8个字段(包括事务ID和回滚指针字段)
0x000100c580 00 20 ff a65第一个bit设置为1,表示这一行是在执行instant add column后插入的
0x000100ca80 00 00 034主键
0x000100ce00 00 00 00 07 e76事务ID
0x000100d4a0 00 00 00 95 01 107回滚指针
0x000100db631字段c1的数据’c’
0x000100dc65 65 653字段c4的数据’eee’
0x000100df65 65 65 654字段c5的数据’eeee’

查询

查询的流程没有变化,关键点在于如何准确地解析记录,对于没有存储在记录中的instant column, 直接填默认值即可,关键函数是rec_init_null_and_len_comp。 主要流程:

  |-mysql_execute_command
    |-Sql_cmd_dml::execute
      |-Sql_cmd_dml::execute_inner
        |-JOIN::exec
          |-do_select
            |-sub_select
              |-TableScanIterator::Read
                |-handler::ha_rnd_next
                  |-ha_innobase::rnd_next
                    |-ha_innobase::index_first
                      |-ha_innobase::index_read
                        |-row_search_mvcc
                          |-rec_get_offsets_func
                            |-rec_init_offsets
                              |-rec_init_offsets_comp_ordinary
                                |-rec_init_null_and_len_comp
                                  |-*nulls = rec - (REC_N_NEW_EXTRA_BYTES + 1); // REC_N_NEW_EXTRA_BYTES = 5, 
                                  |-if (!index->has_instant_cols())
                                    |-*n_null = index->n_nullable;
                                  |-else if (rec_get_instant_flag_new(rec) /* Row inserted after first instant ADD COLUMN */
                                    |-non_default_fields = rec_get_n_fields_instant
                                    |-*nulls -= length;
                                    |-*n_null = index->get_n_nullable_before(non_default_fields);
                                  |-else /* Row inserted before first instant ADD COLUMN */
                                    |-*n_null = index->n_instant_nullable;
                                    |-non_default_fields = index->get_instant_fields();
                          |-row_sel_store_mysql_rec
                            |-for (i = 0; i < prebuilt->n_template; i++) 
                              |-row_sel_store_mysql_field // row_sel_store_mysql_field_func
                                |-rec_get_nth_field_instant // 如果是记录中的,则从记录中读取,否则返回其默认值
                                |-row_sel_field_store_in_mysql_format_func

插入

执行instant add column后,老数据的格式没有变化,新插入的数据按照新格式存储,关键函数是rec_convert_dtuple_to_rec_comp,该函数将MySQL逻辑记录转换为COMPACT格式的物理记录。此外,函数rec_set_instant_flag_new在记录的Info bits字段设置REC_INFO_INSTANT_FLAG,表示这个记录是instant add column之后创建的。

bool rec_convert_dtuple_to_rec_comp(rec_t *rec, const dict_index_t *index,
                                    const dfield_t *fields, ulint n_fields,
                                    const dtuple_t *v_entry, ulint status,
                                    bool temp) {
  const dfield_t *field;
  const dtype_t *type;
  byte *end;
  byte *nulls;
  byte *lens = NULL;
  ulint len;
  ulint i;
  ulint n_node_ptr_field;
  ulint fixed_len;
  ulint null_mask = 1;
  ulint n_null = 0;
  ulint num_v = v_entry ? dtuple_get_n_v_fields(v_entry) : 0;
  bool instant = false;

  ut_ad(temp || dict_table_is_comp(index->table));

  if (n_fields != 0) {
    // 获得nullable字段个数
    n_null = index->has_instant_cols()
                 ? index->get_n_nullable_before(static_cast<uint32_t>(n_fields))
                 : index->n_nullable;
  }

  if (temp) {
    ut_ad(status == REC_STATUS_ORDINARY);
    ut_ad(n_fields <= dict_index_get_n_fields(index));
    n_node_ptr_field = ULINT_UNDEFINED;
    nulls = rec - 1;
    if (dict_table_is_comp(index->table)) {
      /* No need to do adjust fixed_len=0. We only
      need to adjust it for ROW_FORMAT=REDUNDANT. */
      temp = false;
    }
  } else {
    ut_ad(v_entry == NULL);
    ut_ad(num_v == 0);
    // 指向指向记录头信息的前一个字节
    nulls = rec - (REC_N_NEW_EXTRA_BYTES + 1);

    switch (UNIV_EXPECT(status, REC_STATUS_ORDINARY)) {
      case REC_STATUS_ORDINARY:
        ut_ad(n_fields <= dict_index_get_n_fields(index));
        n_node_ptr_field = ULINT_UNDEFINED;
        
        // 如果存在instant column,那么还存在字段个数信息,调用rec_set_n_fields设置
        // 字段数量,并返回存储字节数, 如果字段数量不大于127,存储长度为1字节,否则为2字节
        if (index->has_instant_cols()) {
          uint32_t n_fields_len;
          n_fields_len = rec_set_n_fields(rec, n_fields);
          // nulls指向存储字段数量信息的前一个字节,也就是null标志位最后一个字节开始的位置
          nulls -= n_fields_len;
          instant = true;
        }
        break;
      case REC_STATUS_NODE_PTR:
        ut_ad(n_fields ==
              static_cast<ulint>(
                  dict_index_get_n_unique_in_tree_nonleaf(index) + 1));
        n_node_ptr_field = n_fields - 1;
        n_null = index->n_instant_nullable;
        break;
      case REC_STATUS_INFIMUM:
      case REC_STATUS_SUPREMUM:
        ut_ad(n_fields == 1);
        n_node_ptr_field = ULINT_UNDEFINED;
        break;
      default:
        ut_error;
        return (instant);
    }
  }

  end = rec;

  if (n_fields != 0) {
    // 指向变长字段长度列表最后一个字节开始的位置
    lens = nulls - UT_BITS_IN_BYTES(n_null);
    /* clear the SQL-null flags */
    memset(lens + 1, 0, nulls - lens);
  }

  /* Store the data and the offsets */
  
  // 遍历所有字段
  for (i = 0; i < n_fields; i++) {
    const dict_field_t *ifield;
    dict_col_t *col = NULL;

    field = &fields[i];

    type = dfield_get_type(field);
    len = dfield_get_len(field);

    if (UNIV_UNLIKELY(i == n_node_ptr_field)) {
      ut_ad(dtype_get_prtype(type) & DATA_NOT_NULL);
      ut_ad(len == REC_NODE_PTR_SIZE);
      memcpy(end, dfield_get_data(field), len);
      end += REC_NODE_PTR_SIZE;
      break;
    }
    
    // 如果不是not null类型的字段
    if (!(dtype_get_prtype(type) & DATA_NOT_NULL)) {
      /* nullable field */
      ut_ad(n_null--);
      
      // 如果写满8个,则offset向左移1位,并将null_mask置为1
      if (UNIV_UNLIKELY(!(byte)null_mask)) {
        nulls--;
        null_mask = 1;
      }

      ut_ad(*nulls < null_mask);

      // 如果字段是null
      if (dfield_is_null(field)) {
        // 将null标志位设为1
        *nulls |= null_mask;
        // 向前移1位
        null_mask <<= 1;
        continue;
      }

      null_mask <<= 1;
    }
    /* only nullable fields can be null */
    ut_ad(!dfield_is_null(field));

    ifield = index->get_field(i);
    fixed_len = ifield->fixed_len;
    col = ifield->col;
    if (temp && fixed_len && !col->get_fixed_size(temp)) {
      fixed_len = 0;
    }

    /* If the maximum length of a variable-length field
    is up to 255 bytes, the actual length is always stored
    in one byte. If the maximum length is more than 255
    bytes, the actual length is stored in one byte for
    0..127.  The length will be encoded in two bytes when
    it is 128 or more, or when the field is stored externally. */
    if (fixed_len) {
#ifdef UNIV_DEBUG
      ulint mbminlen = DATA_MBMINLEN(col->mbminmaxlen);
      ulint mbmaxlen = DATA_MBMAXLEN(col->mbminmaxlen);

      ut_ad(len <= fixed_len);
      ut_ad(!mbmaxlen || len >= mbminlen * (fixed_len / mbmaxlen));
      ut_ad(!dfield_is_ext(field));
#endif /* UNIV_DEBUG */
    } else if (dfield_is_ext(field)) {
      ut_ad(DATA_BIG_COL(col));
      ut_ad(len <= REC_ANTELOPE_MAX_INDEX_COL_LEN + BTR_EXTERN_FIELD_REF_SIZE);
      *lens-- = (byte)(len >> 8) | 0xc0;
      *lens-- = (byte)len;
    } else {
      /* DATA_POINT would have a fixed_len */
      ut_ad(dtype_get_mtype(type) != DATA_POINT);
#ifndef UNIV_HOTBACKUP
      ut_ad(len <= dtype_get_len(type) ||
            DATA_LARGE_MTYPE(dtype_get_mtype(type)) ||
            !strcmp(index->name, FTS_INDEX_TABLE_IND_NAME));
#endif /* !UNIV_HOTBACKUP */
      if (len < 128 ||
          !DATA_BIG_LEN_MTYPE(dtype_get_len(type), dtype_get_mtype(type))) {
        *lens-- = (byte)len;
      } else {
        ut_ad(len < 16384);
        // 设置变长字段长度信息
        *lens-- = (byte)(len >> 8) | 0x80;
        *lens-- = (byte)len;
      }
    }
    if (len > 0) memcpy(end, dfield_get_data(field), len);
    end += len;
  }

  if (!num_v) {
    return (instant);
  }

  /* reserve 2 bytes for writing length */
  byte *ptr = end;
  ptr += 2;

  ......
  mach_write_to_2(end, ptr - end);

  return (instant);
}

总结

MySQL的instant add column功能极大地提高了增加字段的效率,执行过程中不需要修改存储中的数据,只改变了存储在系统表中的表结构。期待MySQL能支持更多更实用的instant DDL类型,例如任意顺序加字段、删字段、修改字段类型等,这可能需要引入更复杂的多版本schema技术,设置将更多的schema信息下沉到存储层,实现难度无疑会大大增加。


PgSQL · 引擎特性 · PostgreSQL 通信协议

$
0
0

我们在使用数据库服务时,通常需要使用客户端连接数据库服务端,以 PostgreSQL 为例,常用的客户端有自带的 psql,JAVA 应用的数据库驱动 JDBC,可视化工具 PgAdmin 等,这些客户端都需要遵守 PostgreSQL 的通信协议才能与之 “交流”。所谓协议,可以理解为一套信息交互规则或者规范,最为我们熟知的莫过于 TCP/IP 协议和 HTTP 协议。

protocol

PostgreSQL 在 TCP/IP 协议之上实现了一套基于消息的通信协议,同时,为避免客户端和服务端在同一台机器时的网络通信代价,也支持在 Unix 域套接字上使用该协议。PostgreSQL 至今共实现了三个版本的通信协议,现在普遍使用的是从 7.4 版本开始使用的 3.0 版本,其他版本的协议依然支持。一个 PostgreSQL 数据库实例同时支持所有版本的协议,具体使用那个版本取决于客户端的选择,无论选择哪个版本,客户端和服务端需要匹配,否则可能无法正常 “交流”。本文介绍 PostgreSQL 3.0 版本的通信协议。

PostgreSQL 是多进程架构,守护进程 Postmaster 为每个连接分配一个后台进程(backend),后台进程的分配是在协议处理之前进行的,每个后台进程自行负责协议的处理。在 PostgreSQL 源码或者文档中,通常认为 ‘backend’ 和 ‘server’ 是等价的,表示服务端;同样,’frontend’ 和 ‘client’ 是等价的,表示客户端。

协议基础

PostgreSQL 通信协议包括两个阶段: startup 阶段和 normal 阶段。 startup 阶段,客户端尝试创建连接并发送授权信息,如果一切正常,服务端会反馈状态信息,连接成功创建,随后进入 normal 阶段。 normal 阶段,客户端发送请求至服务端,服务端执行命令并将结果返回给客户端。客户端请求结束后,可以主动发送消息断开连接。

normal 阶段,客户端可以通过两种 “子协议” 来发送请求,分别是 simpel query 和 extened query。使用 simple query 时,客户端发送字符串文本请求,后端收到后立即处理并返回结果;使用 extened query 时,发送请求的过程被分为若干步骤,通常包括 Parse,Bind 和 Execute。

本节介绍通信协议的基础,包括消息格式和基本的消息流, normal 阶段的两种 “子协议” 在下一节详细介绍。

消息

消息格式

客户端和服务端所有通信都通过消息流进行。消息的第一个字节标识消息类型,随后四个字节标识消息内容的长度(该长度包括这四个字节本身),具体的消息内容由消息类型决定。

message

需要注意的是,客户端创建连接时,发送的第一条消息,即启动(startup)消息格式有所不同。它没有最开始的消息类型字段,以消息长度开始,随后紧跟协议版本号,然后是键值对形式的连接信息,如用户名、数据库以及其他 GUC 参数和值。

startup

startup 消息的处理流程可以参考 ProcessStartupPacket

消息类型

PostgreSQL 目前支持如下客户端消息类型:

case 'Q':			/* simple query */
case 'P':			/* parse */
case 'B':			/* bind */
case 'E':			/* execute */
case 'F':			/* fastpath function call */
case 'C':			/* close */
case 'D':			/* describe */
case 'H':			/* flush */
case 'S':			/* sync */
case 'X':
case EOF:
case 'd':			/* copy data */
case 'c':			/* copy done */
case 'f':			/* copy fail */

服务端收到如上消息的处理流程可以参考 PostgresMain。服务端发送给客户端的消息有如下类型(不完全):

case 'C':		/* command complete */
case 'E':		/* error return */
case 'Z':		/* backend is ready for new query */
case 'I':		/* empty query */
case '1':		/* Parse Complete */
case '2':		/* Bind Complete */
case '3':		/* Close Complete */
case 'S':		/* parameter status */
case 'K':		/* secret key data from the backend */
case 'T':		/* Row Description */
case 'n':		/* No Data */
case 't':		/* Parameter Description */
case 'D':		/* Data Row */
case 'G':		/* Start Copy In */
case 'H':		/* Start Copy Out */
case 'W':		/* Start Copy Both */
case 'd':		/* Copy Data */
case 'c':		/* Copy Done */
case 'R':		/* Authentication Request */

客户端处理如上服务端消息的流程可以参考 PostgreSQL libqp 的实现 pqParseInput3

消息流

Startup

startup 阶段是客户端和服务端创建连接的阶段,消息流如下:

startup-flow

客户端首先发送 startup 消息至服务端,服务端判断是否需要授权信息,如若需要,则发送 AuthenticationRequest ,客户端随后发送密码至服务端,权限验证之后,服务端给客户端发送一些参数信息,即 ParameterStatus ,包括 server_version , client_encoding 和 DateStyle 等。最后,服务端发送一个 ReadyForQuery 消息,告知客户端一切就绪,可以发送请求了。至此,连接创建成功。

取消请求

startup 阶段,服务端还会给客户端发送一个 BackendKeyData 消息,该消息中包含服务端的进程 ID 和一个取消码(MyCancelKey)。如果客户端想取消当前正在执行的请求,则可以发送一个 CancelRequset 消息,该消息中包括 startup 阶段服务端提供的进程 ID 和取消码。

取消请求并不是通过当前正在处理请求的连接发送的,而是会创建一个新的连接,创建该连接发送的消息与之前创建连接的消息不同,不再发送 startup 消息,而是发送一个 CancelReqeust 消息,该消息同样没有消息类型字段。

cancel

取消请求不保证一定成功,可能服务端接收到取消请求时,当前的查询请求已经结束。取消请求只能在一定程度上加速当前查询结束,如果当前请求被取消,客户端会收到一条错误消息。

发送请求

连接创建之后,通信协议进入 normal 阶段,该阶段的大体流程是:客户端发送查询请求,服务端接收请求、处理请求并将结果返回给客户端。上文提到,该阶段有两种 “子协议”,本节分别介绍这两种 “子协议” 的消息流。

Simple Query

客户端通过 Query 消息发送一个文本命令给服务端,服务端处理请求,回复查询结果。查询结果通常包括两部分内容:结构和数据。结构通过 RowDescription 消息传递,包括列名、类型 OID 和长度等;数据通过 DataRow 消息传递,每个 DataRow 消息中包含一行数据。

simple-query

每个命令的结果发送完成之后,服务端会发送一条 CommandComplete 消息,表示当前命令执行完成。客户端的一条查询请求可能包含多条 SQL 命令,每个 SQL 命令执行完都会回复一条 CommandComplete 消息,查询请求执行结束后会回复一条 ReadyForQuery 消息,告知客户端可以发送新的请求。消息流如下:

simple-query-flow

注意,一个请求中的多条 SQL 命令会被当做一个事务来执行,如果有命令执行失败,整个事务都会回滚。用户可以在请求中显式添加 BEGIN 和 COMMIT ,将一个请求划分为多个事务,避免事务全部回滚。显式添加事务控制语句的方式无法避免请求有语法错误的情况,如果请求有语法错误,整个请求都不会被执行。

ReadyForQuery 消息会反馈当前事务的执行状态,客户端可以根据事务状态做相应的处理,目前有如下三种事务状态:

'I';			/* idle --- not in transaction */
'T';			/* in transaction */
'E';			/* in failed transaction */

Extended Query

Extended Query 协议将以上 Simple Query 的处理流程分为若干步骤,每一步都由单独的服务端消息进行确认。该协议可以使用服务端的 perpared-statement 功能,即先发送一条参数化 SQL,服务端收到 SQL(Statement)之后对其进行解析、重写并保存,这里保存的 Statement 也就是所谓 Prepared-statement,可以被复用;执行 SQL 时,直接获取事先保存的 Prepared-statement 生成计划并执行,避免对同类型 SQL 重复解析和重写。

如下例, SELECT * FROM users u, logs l WHERE u.usrid=$1 AND u.usrid=l.usrid AND l.date = $2; 是一条参数化 SQL,执行 PREPARE 时,服务端对该 SQL 进行解析和重写;执行 EXECUTE 时,为 Prepared Statement 生成计划并执行。第二次执行 EXECUTE 时无需再对 SQL 进行解析和重写,直接生成计划并执行即可。PostgreSQL Prepared Statement 的具体细节可以参考[3],PostgreSQL JDBC 的相关介绍可以参考[4]。

PREPARE usrrptplan (int) AS
    SELECT * FROM users u, logs l WHERE u.usrid=$1 AND u.usrid=l.usrid
    AND l.date = $2;
EXECUTE usrrptplan(1, current_date);
EXECUTE usrrptplan(2, current_date);

可见,Extended Query 协议通过使用服务端的 Prepared Statement,提升同类 SQL 多次执行的效率。但与 Simple Query 相比,其不允许在一个请求中包含多条 SQL 命令,否则会报语法错误。

Extended Query 协议通常包括 5 个步骤,分别是 Parse,Bind,Describe,Execute 和 Sync。以下分别介绍各个阶段的处理流程。

Parse

客户端首先向服务端发送一个 Parse 消息,该消息包括参数化 SQL,参数占位符以及每个参数的类型,还可以指定 Statement 的名字,若不指定名字,即为一个 “未命名” 的 Statement,该 Statement 会在生成下一个 “未命名” Statement 时予以销毁,若指定名字,则必须在下次发送 Parse 消息前将其显式销毁。

parse

PostgreSQL 服务端收到该消息后,调用 exec_parse_message 函数进行处理,进行语法分析、语义分析和重写,同时会创建一个 Plan Cache 的结构,用于缓存后续的执行计划。

Bind

客户端发送 Bind 消息,该消息携带具体的参数值、参数格式和返回列的格式,如下:

bind

PostgreSQL 收到该消息后,调用 exec_bind_message 函数进行处理。为之前保存的 Prepared Statement 创建执行计划并将其保存在 Plan Cache 中,创建一个 Portal 用于后续执行。关于 Plan Cache 的具体实现和复用逻辑在此不细述,以后单独撰文介绍。

在 PostgreSQL 内核中,Portal 是对查询执行状态的一种抽象,该结构贯穿执行器运行的始终。

Describe

客户端可以发送 Describe 消息获取 Statment 或 Portal 的元信息,即返回结果的列名,类型等信息,这些信息由 RowDescription 消息携带。如果请求获取 Statement 的元信息,还会返回具体的参数信息,由 ParameterDescription 消息携带。

desc

Execute

客户端发送 Execute 消息告知服务端执行请求,服务端收到消息后,执行 Bind 阶段创建的 Portal,执行结果通过 DataRow 消息返回给客户端,执行完成后发送 CommandComplete 。

exec

Execute 消息中可以指定返回的行数,若行数为 0,表示返回所有行。

Sync

使用 Extended Query 协议时,一个请求总是以 Sync 消息结束,服务端接收到 Sync 消息后,关闭隐式开启的事务并回复 ReadyForQuery 消息。

Extended Query 完整的消息流如下:

extended

Copy 子协议

为高效地导入/导出数据,PostgreSQL 支持 COPY 命令, COPY 操作会将当前连接切换至一种截然不同的子协议。

Copy 子协议对应三种模式:

  • copy-in 导入数据,对应命令 COPY FROM STDIN
  • copy-out 导出数据,对应命令 COPY TO STDOUT
  • copy-both 用于 walsender,在主备间批量传输数据

copy-in 为例,服务端收到 COPY命令后,进入 COPY 模式,并回复 CopyInResponse。随后客户端通过 CopyData消息传输数据,CopyComplete消息标识数据传输完成,服务端收到该消息后,发送 CommandComplete 和 ReadyForQuery 消息,消息流如下:

copy

总结

本文简要介绍了 PostgreSQL 的通信协议,包括消息格式、消息类型和常见通信过程的消息流。一般通信过程分为两个阶段: startup 阶段创建连接, normal 阶段发送请求并返回结果。 normal 阶段又包括两种子协议, Simple Query 一次性发送查询请求; Extended Query 分阶段发送请求,利用服务端的 prepared statement 特性,提升反复执行同类请求的效率。

PostgreSQL 通信协议中,除本文介绍的 COPY 子协议,还有一些其他的子协议,如主备流复制子协议,限于篇幅,本文并未给出详尽的描述,感兴趣的同学可以参考相关文档[5]。

最后,本文严重参考了 2014 年 PG 大会这篇[6]分享,推荐大家阅读。

参考文献

  1. https://www.net.t-labs.tu-berlin.de/teaching/computer_networking/01.02.htm
  2. https://www.postgresql.org/docs/current/protocol.html
  3. https://www.postgresql.org/docs/12/sql-prepare.html
  4. https://jdbc.postgresql.org/documentation/head/server-prepare.html
  5. https://www.postgresql.org/docs/current/protocol-replication.html
  6. https://www.pgcon.org/2014/schedule/attachments/330_postgres-for-the-wire.pdf

MySQL · 产品特性 · RDS三节点企业版的高可用体系

$
0
0

理解高可用问题

高可用(High Availability)是软件设计中的一个很古老的概念。高可用的问题由来已久,信息系统需要持续为用户提供服务,但是又要面对软硬件不可靠的事实。因此,一个优秀的高可用系统会通过组件的冗余来规避单点故障。当意外发生时,错误检测模块必须能够及时发现故障,并自动切换到备份系统,这个切换时间一般会要求在几秒或者几分钟之内完成。大家经常谈论的SLA、RPO、RTO,都是衡量高可用标准的计算方法,这里就不再赘述。

当前的时代背景,云计算逐渐成为水电煤气般的基础设施。对于数据库而言,高可用不仅仅是提供一个可以访问的SQL服务,还要保证数据的正确性和一致性。当前企业级客户对强一致、高可用数据库的需求越来越大,特别是金融、保险等业务场景。阿里云因此推出了RDS MySQL三节点企业版,提供极致的高可用方案。

MySQL的高可用技术方案

首先我们简要回顾一下一些常见的MySQL高可用技术方案,整理一下各自的优缺点。

image.png

主备异步复制,当主库不可用的时候,可以切换到备库提供服务。由于是异步复制,可能存在备库比主库少数据的情况,相当于牺牲了数据一致性来保证可用性。

主备半同步复制,是异步复制的升级版。半同步要求主库binlog同步到备库之后,主库事务才允许提交。由于主备的网络延迟,主库的响应时间会有一定程度的受损。对于一主一备的半同步复制来说,如果备库不可用,主库要么暂停服务要么退化成异步复制。

由于一主一备半同步带来了备库可用性的问题,我们可以对备库再做一次冗余,采用一主多从的方案。该方案要求,binlog同步只要收到其中一个slave的ack,master即可提交事务,这就规避了单个备库不可用给主库带来的风险。但是当集群超过两个节点后,我们引入了一个新的挑战,如何保证只有一个master,以及如何保证所有slave都知道谁是master?所以还需要一个中心化的方案,即外部的HA模块,执行选主。然后我们会发现,为了解决一个问题,我们引入了一个新的问题,如何保证外部HA模块的高可用?比如像MHA,它自己也必须搭建成Master-Slave架构,有种跌入了递归深渊的感觉。为了更好地解决外部HA模块的高可用,一致性协议进入了视野,ZooKeeper方案应运而生,用一套ZooKeeper来管理HA,系统也变得越来越复杂。

实际上半同步复制技术也存在主备不一致的风险,原因在于当master将事务写入binlog,尚未传送给slave时master故障,应用切换到slave,虽然现在slave的事务与master故障前是一致的,但当主机恢复后,因最后的事务已经写入到binlog,所以在master上会恢复成已提交状态,从而导致主从之间的数据不一致。因此要做到强一致性,HA模块还要分析新老主库的binlog和relaylog,实现一系列复杂的回滚回补逻辑。

与其在外部HA这条路越走越远,不如把Paxos/Raft等一致性协议直接集成到MySQL内核中。Galera Cluster、MySQL Group Replication(MGR)等方案在社区中开始涌现出来。2016年,阿里巴巴数据库团队开始在集团内部沉淀一致性协议的实践经验。我们自研了一致性协议库X-Paxos并集成到AliSQL 5.7中,称之为X-Cluster。2017年第一年支持了双十一大促,此后开始大范围推广。经过2年的内部打磨,该版本改名三节点企业版在2019年7月正式上线公有云售卖。本文暂不比较三节点企业版和社区MGR等方案的优劣,下一章节会通过介绍我们的高可用体系,来说明即使数据库深度集成了一致性协议,依然存在很多挑战。

RDS三节点的高可用体系

上一节列举了一些已有的MySQL高可用技术方案,可以看到各个方案对于高可用的理论标准越来越高。尽管不要求做到100%的高可用,这些方案都存在一些明显的问题。任何一个使用类似方案的架构,都有必要在此之上继续做一些缝缝补补。套用一句老话,“理想很丰满,现实很骨感”。总的来说,我们用冗余来保证故障容灾,同时又要在性能上和一致性上做权衡,必然要做出一些牺牲。很明显,高可用是一个高度依赖工程实践的问题。RDS三节点企业版,正是基于阿里巴巴丰富的业务场景实践,在社区MySQL的基础上,深入集成自研模块,打造出了一套完善可靠的高可用体系。

RDS三节点的高可用体系包含如下图的五大模块:

image.png

选举租约

文章《RDS三节点企业版 一致性协议》中我们讲了三节点企业版的核心模块:一致性协议。基于X-Paxos和AliSQL的集成,在100%兼容MySQL的基础上,实现了数据库的自动选主,日志同步,数据强一致,在线配置变更等功能。X-Paxos原理上是采用了unique proposer的Multi-Paxos方案。三节点的配置下,协议需要确保在同一时刻,只有一个Leader对外提供服务。

image.png

一般来说,基于Paxos/Raft等一致性协议的选主,都会采用租约(lease)的方案。选主最大的问题是要避免“双主”出现。如果当前Leader被网络隔离,其他节点在租约到期之后,会自动重新发起选主。而那个被隔离的Leader,发送心跳时会发现多数派节点不再响应,从而续租失败,解除Leader的状态,这也避免了“双Leader”现象出现。Follower约定在lease期间不发起新的选主,Leader先于Follower lease超时,从时序上最大程度上规避了“双主”问题的出现。不过实际上,从一致性协议角度来说,老主降级流程即使由于各种原因略微延后,也不会造成正确性的问题。比如Raft协议中,新主选出后,老主的term发起的提案是无法达成多数派的。避免“双主”本质上是为了尽快的通知外部Client主库已经变化,从而及时进行链路切换。

权重选主

权重选主理解上很简单,就是给每一个节点一个权重值,在主库不可用时,优先选取权重高的节点当新的主库。从实现上来说,权重是对Leader lease的一个微小修正,对于低权重的节点,每次会在lease原有timeout数值的基础上随机增加一个小的△t,权重越低,△t越大。基于这样的timeout策略,就可以保证在lease超时后,高权重的节点大概率会优先发起选主投票。权重选主主要应用在在三地五副本的场景下,在中心机房主库不可用时,我们希望优先切换到同城的另一个节点,来避免不必要的App跨城调用延迟。这样的设计既能保证高可用,也能保证高质量的服务。(三地五副本部署架构参考:《RDS三节点企业版 一致性协议》中跨域五副本一节)

image.png

这里要补充说明的是,分布式环境下的通信有很多不确定性,因此权重选主只是一个弱限制。在某些情况下依然可能选出一个低权重的节点当Leader,这并不影响整体系统的可用性。在实践过程中,我们发现目前的机制已经可以满足大部分的需求。

状态机诊断

从数据一致性角度来说,选主流程结束后,新的Leader必须回放完所有的老日志才能接受新的数据写入。因此,成功选主并不等价于服务可用,实际的不可用时间是选主时间(10s)和日志回放追平时间之和。三节点企业版在状态机应用日志的逻辑中,复用了MySQL的Slave和Worker线程,支持基于Table、Commit Order和Write Set三种模式的并行回放。在遇到大事务、DDL等场景下,和传统的Master-Slave复制模式一样,Follower可能会产生比较大的回放延迟。假如此时恰好主库出现故障,新选主的节点由于回放延迟,服务不可用时间充满了不确定性,那又如何保证SLA呢?

故障有可恢复和不可恢复之分,通过我们观察,除了那种机器宕机、磁盘坏块这类彻底恢复不了的场景,大部分故障都是短期的。比如网络抖动,一般情况下网络架构也是冗余设计的,可能过一小段时间链路就重新正常了。比如主库OOM、Crash等场景,mysqld_safe会迅速的重新拉起实例。恢复后的老主一定是没有延迟的,对于重启的场景来说,会有一个Crash Recovery的时间。这个时候,最小不可用时间变成了一个数学问题,到底是新主追回放延迟的速度快,还是老主恢复正常的速度快。因此,三节点企业版中,我们做了一个状态机诊断和主动切换的功能。

image.png

在三节点企业版的内核中,通过状态机诊断接口,服务层有能力向协议层汇报当前状态机的健康状况,包括回放延迟、Crash Recovery、系统负载等状态。当状态机健康状况影响服务可用性时,会尝试找一个更合适的节点主动切换出去。主动切换功能和权重选主也是深度整合的,在挑选节点的时候,也会考虑权重的信息。最终,服务恢复可用后诊断逻辑会自动停止,避免在稳定Leader的情况下产生不必要的切换。

磁盘探活

对于数据库这样有状态的服务来说,存储是影响可用性的重要因素之一。在本地盘部署模式下,数据库系统会遇到Disk Failure或者Data Corruption这样的问题。我们曾经遇到过这样的情况,磁盘故障导致IO卡住,Client完全无法写入新的数据。由于网络是连通状态,节点之前的选举租约可以正常维持,三节点自动容灾失效导致故障。有时候还会发生一些难以捉摸的事情,由于IO已经完全不正常了,进程在kernel态处于waiting on I/O的状态,无法正常kill,只有重启宿主机才能让节点间通信完全断掉触发选主。此外对于云服务的RDS来说,即使使用云盘等分布式存储,也有底层服务不可用的风险。

针对这类问题,我们实现了磁盘探活功能。对于本地盘,系统自动创建了一个iostate临时文件,定期向其中执行随机数据读写操作。对于云盘这类分布式存储,我们对接了底层的IO采样数据,定期来感知IO hang或者Slow IO的问题。探测失败次数达到某个阈值后,系统会第一时间断开协议层的网络监听端口,之后尝试重启实例。

反向心跳

基于已有的策略,我们已经可以覆盖99%的常规可用性问题。在长时间的线上实践中,我们发现有些问题从节点内部视角发现不了,比如主库连接数被占满,open files limit配置不合理导致“Too many open files”报错,以及代码bug导致的各种问题……对于这些场景,从选举租约、状态机、磁盘探活的角度,都无法正确的检测故障,因此最好有一个能从App视角去建连接、执行SQL、返回结果的全链路检测流程。因此催生了Follower反向心跳的需求,即Follower通过SQL接口去主动探测Leader的可用性。该设计有两个优势:首先是内核自封闭,借助三节点的其他非Leader节点,不依赖外部的HA agent进行选主判定,就不用再考虑HA agent本身的可用性问题;其次和内核逻辑深度整合,不破坏原有的选主逻辑。

image.png

整个流程如图所示,假设Node 1是Leader,给其他两个Follower正常发送心跳,但是对外的App视角已经不可服务。当Node 2和Node 3通过反向心跳多次尝试发现Leader的SQL接口不可服务之后,这两个Follower不再承认Leader发来的Heartbeat续租消息。之后若Node 2的选举权重相对较高,他会首先超时,并用新的term发起requestVote由Node 3投票选成主,对外开始提供服务。这个时候Node 2作为新主,也会同时开始给Node 1和Node 3发续租心跳。Node 1在收到新主发来的心跳消息之后,发现term比自己当前term大,就会主动降级成Follower。整个三节点又能回到正常的状态。

总结

本文从数据库面临的高可用问题入手,介绍了当前MySQL社区常用的技术架构。RDS三节点企业版基于长期业务实践的积累,建立了完善的自动容灾体系。通过对高可用技术的深度集成,在保证数据一致性的基础上,不仅能够为使用方提供及时的故障恢复,也解放了管控和运维。最后打一个广告,除了5.7,目前MySQL 8.0版本的三节点也已经在公有云上线,欢迎试用。

AliSQL · 最佳实践 · Performance Agent

$
0
0

背景

性能数据是云上数据库服务的一个重要组成部分,对于性能数据,当前云厂商的一般做法是:由单独的采集系统进行性能数据的收集和处理,然后通过用户控制台进行性能数据的展示。借助控制台的将性能数据以图表的形式进行展示,比较直观,但是用户很难与自己的监控平台进行集成。特别是对于企业级用户,这些用户在上云之前往往有比较成熟的自建性能监控平台,虽然部分云厂商开始提供OpenAPI等方式对外开放性能数据,但是与自建平台的整合依然有诸多限制。

AliSQL解决方案

基于上述背景,AliSQL提出了一种全内聚的性能数据解决方案,直接通过系统表的方式对外提供性能数据。用户可以像查询普通数据一样,直接查询INFORMATION_SCHEMA库下的PERF_STATISTICS表得到性能数据。

设计实现

对于MySQL,用户关心的性能数据主要可以分为以下三种:

  1. Host层性能数据,包括主机的CPU占用、内存使用情况、IO调用等;
  2. Server层性能数据,包括各类连接信息、QPS信息、网络流量等;
  3. Engine层性能数据,包括数据读写情况、事务提交情况等;

MySQL内核内部,除了没有统计Host层的性能数据外,Server层和Engine层的性能数据都有统计并且提供了获取方法,例如:

MySQL [information_schema]> show status like "Com_select";
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Com_select    | 3     |
+---------------+-------+
1 row in set (0.00 sec)

MySQL [information_schema]> show status like "Innodb_data_read";
+------------------+-----------+
| Variable_name    | Value     |
+------------------+-----------+
| Innodb_data_read | 470798848 |
+------------------+-----------+
1 row in set (0.00 sec)

AliSQL Performance Agent需要解决的问题就是:1)整合MySQL内核统计的Server层和Engine层性能指标;2)增加Host层的性能统计;3)提供便捷的外部访问方式。具体实现上:

  1. 新增Performance Agent Plugin,在Plugin内部启动一个性能采集线程,按照指定的采样周期,采集Host层、Server层和Engine层的性能数据;
  2. Host层性能数据的获取方式:根据PID信息,直接读取/proc以及/proc/PID目录下的系统文件;
  3. Server层性能数据获取方式:Plugin内调用Server层统计接口,获取Server层性能数据;
  4. Engine层性能数据获取方式:以InnoDB为例,Plugin内调用InnoDB对外接口,获取InnoDB内部性能数据;
  5. 性能数据的汇总计算:不同的性能指标,计算单个采样周期内的差值或者实时值;
  6. 数据保存方式:以CSV文件格式本地保存,同时在INFORMATION_SCHEMA库下新增一张PERF_STATISTICS表,保存最近1小时的性能数据;
/** 获取Server层性能数据 **/

typedef struct system_status_var STATUS_VAR;

struct system_status_var {
  ...
  ulonglong created_tmp_disk_tables;
  ulonglong created_tmp_tables;
  ...
  ulong com_stat[(uint) SQLCOM_END];
  ...
}

void calc_sum_of_all_status(STATUS_VAR *to)
{
  DBUG_ENTER("calc_sum_of_all_status");
  mysql_mutex_assert_owner(&LOCK_status);
  /* Get global values as base. */
  *to= global_status_var;
  Add_status add_status(to);
  Global_THD_manager::get_instance()->do_for_all_thd_copy(&add_status);
  DBUG_VOID_RETURN;
}


/** 获取InnoDB层性能数据 **/

/** Status variables to be passed to MySQL */
extern struct export_var_t export_vars;

struct export_var_t {
  ...
  ulint innodb_data_read;           /*!< Data bytes read */
  ulint innodb_data_writes;         /*!< I/O write requests */
  ulint innodb_data_written;        /*!< Data bytes written */
  ...
}

/* Function to pass InnoDB status variables to MySQL */
void srv_export_innodb_status(void)
{
  ...
  mutex_enter(&srv_innodb_monitor_mutex);
  ...
  export_vars.innodb_data_read = srv_stats.data_read;
  export_vars.innodb_data_writes = os_n_file_writes;
  export_vars.innodb_data_written = srv_stats.data_written;
  ...
}

性能测试

AliSQL Performance Agent启动一个独立的线程用于性能数据的采集和处理,不干扰用户线程的处理。Sysbench下oltp_read_write场景的性能测试结果显示,开启Performance Agent带来的性能损失在1%以内,对性能的影响可以忽略。

并发数关闭Performance Agent开启Performance AgentOverhead
141214101-0.49%
83083430740-0.30%
165802757774-0.44%
326497264321-1.00%
645703556945-0.16%
1285034349990-0.70%
2564836048307-0.11%
51245347454000.12%
10244364943272-0.86%

使用说明

相比通过外部系统获取MySQL的性能数据,直接读取INFORMATION_SCHEMA库下的PERF_STATISTICS表不仅更加方便,而且数据的实时性也更好。

参数说明

MySQL [information_schema]> show variables like "%performance_agent%";
+----------------------------------------+-----------------+
| Variable_name                          | Value           |
+----------------------------------------+-----------------+
| performance_agent_enabled              | ON              |
| performance_agent_file_size            | 100             |
| performance_agent_interval             | 1               |
| performance_agent_perfstat_volume_size | 3600            |
+----------------------------------------+-----------------+
4 rows in set (0.00 sec)

其中:

  1. performance_agent_enabled: plugin启动开关,支持动态开启/关闭;
  2. performance_agent_file_size: 本地CSV文件大小,单位MB;
  3. performance_agent_interval: 采样周期,单位Second;
  4. performance_agent_perfstat_volume_size: PERF_STATISTICS表大小;

表结构说明

INFORMATION_SCHEMA库下的PERF_STATISTICS表结构如下:

CREATE TEMPORARY TABLE `PERF_STATISTICS` (
  `TIME` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `PROCS_MEM_USAGE` double NOT NULL DEFAULT '0',
  `PROCS_CPU_RATIO` double NOT NULL DEFAULT '0',
  `PROCS_IOPS` double NOT NULL DEFAULT '0',
  `PROCS_IO_READ_BYTES` bigint(21) NOT NULL DEFAULT '0',
  `PROCS_IO_WRITE_BYTES` bigint(21) NOT NULL DEFAULT '0',
  `MYSQL_CONN_ABORT` int(11) NOT NULL DEFAULT '0',
  `MYSQL_CONN_CREATED` int(11) NOT NULL DEFAULT '0',
  `MYSQL_USER_CONN_COUNT` int(11) NOT NULL DEFAULT '0',
  `MYSQL_CONN_COUNT` int(11) NOT NULL DEFAULT '0',
  `MYSQL_CONN_RUNNING` int(11) NOT NULL DEFAULT '0',
  `MYSQL_LOCK_IMMEDIATE` int(11) NOT NULL DEFAULT '0',
  `MYSQL_LOCK_WAITED` int(11) NOT NULL DEFAULT '0',
  `MYSQL_COM_INSERT` int(11) NOT NULL DEFAULT '0',
  `MYSQL_COM_UPDATE` int(11) NOT NULL DEFAULT '0',
  `MYSQL_COM_DELETE` int(11) NOT NULL DEFAULT '0',
  `MYSQL_COM_SELECT` int(11) NOT NULL DEFAULT '0',
  `MYSQL_COM_COMMIT` int(11) NOT NULL DEFAULT '0',
  `MYSQL_COM_ROLLBACK` int(11) NOT NULL DEFAULT '0',
  `MYSQL_COM_PREPARE` int(11) NOT NULL DEFAULT '0',
  `MYSQL_LONG_QUERY` int(11) NOT NULL DEFAULT '0',
  `MYSQL_TCACHE_GET` bigint(21) NOT NULL DEFAULT '0',
  `MYSQL_TCACHE_MISS` bigint(21) NOT NULL DEFAULT '0',
  `MYSQL_TMPFILE_CREATED` int(11) NOT NULL DEFAULT '0',
  `MYSQL_TMP_TABLES` int(11) NOT NULL DEFAULT '0',
  `MYSQL_TMP_DISKTABLES` int(11) NOT NULL DEFAULT '0',
  `MYSQL_SORT_MERGE` int(11) NOT NULL DEFAULT '0',
  `MYSQL_SORT_ROWS` int(11) NOT NULL DEFAULT '0',
  `MYSQL_BYTES_RECEIVED` bigint(21) NOT NULL DEFAULT '0',
  `MYSQL_BYTES_SENT` bigint(21) NOT NULL DEFAULT '0',
  `MYSQL_BINLOG_OFFSET` int(11) NOT NULL DEFAULT '0',
  `MYSQL_IOLOG_OFFSET` int(11) NOT NULL DEFAULT '0',
  `MYSQL_RELAYLOG_OFFSET` int(11) NOT NULL DEFAULT '0',
  `EXTRA` json NOT NULL DEFAULT 'null'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

注:EXTRA字段为json类型,记录Engine层统计信息:

{
  "INNODB_LOG_LSN":0,
  "INNODB_TRX_CNT":0,
  "INNODB_DATA_READ":0,
  "INNODB_IBUF_SIZE":0,
  "INNODB_LOG_WAITS":0,
  "INNODB_MAX_PURGE":0,
  "INNODB_N_WAITING":0,
  "INNODB_ROWS_READ":0,
  "INNODB_LOG_WRITES":0,
  "INNODB_IBUF_MERGES":0,
  "INNODB_DATA_WRITTEN":0,
  "INNODB_DBLWR_WRITES":0,
  "INNODB_IBUF_MERGEOP":0,
  "INNODB_IBUF_SEGSIZE":0,
  "INNODB_ROWS_DELETED":0,
  "INNODB_ROWS_UPDATED":0,
  "INNODB_COMMIT_TRXCNT":0,
  "INNODB_IBUF_FREELIST":0,
  "INNODB_MYSQL_TRX_CNT":0,
  "INNODB_ROWS_INSERTED":0,
  "INNODB_ACTIVE_TRX_CNT":0,
  "INNODB_COMMIT_TRXTIME":0,
  "INNODB_IBUF_DISCARDOP":0,
  "INNODB_OS_LOG_WRITTEN":0,
  "INNODB_ACTIVE_VIEW_CNT":0,
  "INNODB_LOG_FLUSHED_LSN":0,
  "INNODB_RSEG_HISTORY_LEN":0,
  "INNODB_AVG_COMMIT_TRXTIME":0,
  "INNODB_LOG_CHECKPOINT_LSN":0,
  "INNODB_MAX_COMMIT_TRXTIME":0,
  "INNODB_DBLWR_PAGES_WRITTEN":0
}

系统集成

AliSQL Performance Agent通过对外提供INFORMATION_SCHEMA库下的PERF_STATISTICS表的方式,让用户可以像查询普通数据的一样直接查询性能数据。

-- 查询最近30S内的内存和CPU使用情况 --
MySQL [information_schema]> SELECT TIME, PROCS_MEM_USAGE, PROCS_CPU_RATIO
    -> FROM PERF_STATISTICS ORDER BY TIME DESC LIMIT 30;
+---------------------+-----------------+-----------------+
| TIME                | PROCS_MEM_USAGE | PROCS_CPU_RATIO |
+---------------------+-----------------+-----------------+
| 2020-03-19 15:09:50 |      6070943744 |          101.11 |
| 2020-03-19 15:09:49 |      6070837248 |          100.99 |
| 2020-03-19 15:09:48 |      6070546432 |          101.11 |
| 2020-03-19 15:09:47 |      6071123968 |          101.17 |
| 2020-03-19 15:09:46 |      6070509568 |          101.23 |
| 2020-03-19 15:09:45 |      6070030336 |          101.63 |
| 2020-03-19 15:09:44 |      6069497856 |          100.72 |
| 2020-03-19 15:09:43 |      6069764096 |          100.85 |
| 2020-03-19 15:09:42 |      6069522432 |          101.23 |
| 2020-03-19 15:09:41 |      6068592640 |          101.25 |
| 2020-03-19 15:09:40 |      6069272576 |          100.87 |
| 2020-03-19 15:09:39 |      6069297152 |          101.31 |
| 2020-03-19 15:09:38 |      6069706752 |          101.04 |
| 2020-03-19 15:09:37 |      6069907456 |           100.8 |
| 2020-03-19 15:09:36 |      6069907456 |          103.72 |
| 2020-03-19 15:09:35 |      6069235712 |           99.05 |
| 2020-03-19 15:09:34 |      6068707328 |          101.32 |
| 2020-03-19 15:09:33 |      6068723712 |          100.66 |
| 2020-03-19 15:09:32 |      6069379072 |          101.25 |
| 2020-03-19 15:09:31 |      6069243904 |          103.62 |
| 2020-03-19 15:09:30 |      6069567488 |          101.17 |
| 2020-03-19 15:09:29 |      6069641216 |           98.15 |
| 2020-03-19 15:09:28 |      6069968896 |          101.12 |
| 2020-03-19 15:09:27 |      6070087680 |          104.15 |
| 2020-03-19 15:09:26 |      6069633024 |           101.3 |
| 2020-03-19 15:09:25 |      6069846016 |          100.94 |
| 2020-03-19 15:09:24 |      6068805632 |          101.26 |
| 2020-03-19 15:09:23 |      6068228096 |           98.45 |
| 2020-03-19 15:09:22 |      6067957760 |          103.89 |
| 2020-03-19 15:09:21 |      6067544064 |           98.66 |
+---------------------+-----------------+-----------------+
30 rows in set (0.26 sec)

-- 查询最近30S内InnoDB层的读取和插入行数 --
MySQL [information_schema]> SELECT TIME, EXTRA->'$.INNODB_ROWS_READ' AS INNODB_ROWS_READ,
    -> EXTRA->'$.INNODB_ROWS_INSERTED' AS INNODB_ROWS_INSERTED
    -> FROM information_schema.PERF_STATISTICS ORDER BY TIME DESC LIMIT 30;
+---------------------+------------------+----------------------+
| TIME                | INNODB_ROWS_READ | INNODB_ROWS_INSERTED |
+---------------------+------------------+----------------------+
| 2020-03-19 15:09:50 | 1588696          | 6309                 |
| 2020-03-19 15:09:49 | 1534831          | 22712                |
| 2020-03-19 15:09:48 | 1445766          | 25011                |
| 2020-03-19 15:09:47 | 1455092          | 25038                |
| 2020-03-19 15:09:46 | 1427958          | 24966                |
| 2020-03-19 15:09:45 | 1460370          | 25054                |
| 2020-03-19 15:09:44 | 1441310          | 24989                |
| 2020-03-19 15:09:43 | 1430437          | 25963                |
| 2020-03-19 15:09:42 | 1512929          | 24179                |
| 2020-03-19 15:09:41 | 1432366          | 24979                |
| 2020-03-19 15:09:40 | 1471565          | 25075                |
| 2020-03-19 15:09:39 | 1440499          | 24995                |
| 2020-03-19 15:09:38 | 1442158          | 24996                |
| 2020-03-19 15:09:37 | 1457681          | 25035                |
| 2020-03-19 15:09:36 | 1401060          | 24865                |
| 2020-03-19 15:09:35 | 1538809          | 25281                |
| 2020-03-19 15:09:34 | 1465982          | 25073                |
| 2020-03-19 15:09:33 | 1441252          | 24997                |
| 2020-03-19 15:09:32 | 1478242          | 24235                |
| 2020-03-19 15:09:31 | 1449499          | 22237                |
| 2020-03-19 15:09:30 | 1460754          | 25021                |
| 2020-03-19 15:09:29 | 1461106          | 25029                |
| 2020-03-19 15:09:28 | 1471250          | 22653                |
| 2020-03-19 15:09:27 | 1453101          | 21005                |
| 2020-03-19 15:09:26 | 1468384          | 21649                |
| 2020-03-19 15:09:25 | 1413783          | 28213                |
| 2020-03-19 15:09:24 | 1510981          | 16213                |
| 2020-03-19 15:09:23 | 1432580          | 27732                |
| 2020-03-19 15:09:22 | 1486866          | 20387                |
| 2020-03-19 15:09:21 | 1430200          | 26969                |
+---------------------+------------------+----------------------+
30 rows in set (0.20 sec)

BI集成

由于INFORMATION_SCHEMA库下的PERF_STATISTICS表中保存了标准的时间信息,所以用户可以直接与BI系统进行集成,例如:Grafana。以下是利用Grafana实现的实时监控平台。

PERF_STATISTICS

参考SQL如下:

-- 实时监控CPU和内存使用情况 --
SELECT
  $__timeGroupAlias(TIME,1s),
  sum(PROCS_CPU_RATIO) AS "PROCS_CPU_RATIO",
  sum(PROCS_MEM_USAGE) AS "PROCS_MEM_USAGE"
FROM PERF_STATISTICS
GROUP BY 1
ORDER BY $__timeGroup(TIME,1s);

MySQL · 内核分析 · InnoDB mutex 实现分析

$
0
0

InnoDB 中的mutex 和 rw_lock 在早期的版本都是通过系统提供的cas, tas 语义自己进行实现, 并没有使用pthread_mutex_t, pthread_rwlock_t, 这样实现的好处在于便于统计, 以及为了性能考虑, 还有解决早期操作系统的一些限制.

大概是原理是:

在mutex_enter 之后, 在spin 的次数超过 innodb_sync_spin_loops=30 每次最多 innodb_spin_wait_delay=6如果还没有拿到Mutex, 会主动yield() 这个线程, 然后wait 在自己实现的wait array 进行等待.

这里每次spin 时候, 等待的时候执行的是ut_delay, 在ut_delay 中是执行 “pause” 指定, 当innodb_spin_wait_delay = 6 的时候, 在当年100MHz Pentium cpu, 这个时间最大是1us.

wait array 也是InnoDB 实现的一种cond_wait 的实现, 为什么要自己实现?

早期的MySQL 需要wait array 是因为操作系统无法提供超过100000 event, 因此wait array 在用户态去进行这些event 维护, 但是到了MySQL 5.0.30 以后, 大部分操作系统已经能够处理100000 event, 那么现在之所以还需要 wait array, 主要是为了统计.

在wait array 的实现里面其实有一把大wait array mutex, 是一个pthread_mutex_t, 然后在wait array 里面的每一个wait cell 中, 包含了os_event_t, wait 的时候调用了os_event_wait_low(), 然后在 os_event_t 里面也包含了一个mutex, 因此在一次wait 里面就有可能调用了两次pthread_mutex_t 的wait.

并且在os_event_t 唤醒的机制中是直接通过pthread_cond_boradcast(), 当有大量线程等待在一个event 的时候, 会造成很多无谓的唤醒.

大致代码实现:

  void enter(uint32_t max_spins, uint32_t max_delay, const char *filename,
             uint32_t line) UNIV_NOTHROW {
    // 在try_lock 中通过 TAS 比较是否m_lock_word = LOCKED
    // TAS(&m_lock_word, MUTEX_STATE_LOCKED) == MUTEX_STATE_UNLOCKED
    // 在InnoDB 自己实现的mutex 中, 使用m_lock_word = 0, 1, 2 分别来比较unlock,
    // lock, wait 状态
    // 在InnoDB 自己实现的rw_lock 中, 同样使用 m_lock_word 来标记状态,
    // 不过rw_lock 记录的状态就不止lock, unlock, 需要记录有多少read 等待,
    // 多少write 等待等待, 不过大体都一样
    if (!try_lock()) {
      // 如果try_lock 失败, 就进入spin 然后同时try_lock 的逻辑
      spin_and_try_lock(max_spins, max_delay, filename, line);
    }
  }

  void spin_and_try_lock(uint32_t max_spins, uint32_t max_delay,
                         const char *filename, uint32_t line) UNIV_NOTHROW {
    for (;;) {
      /* If the lock was free then try and acquire it. */

      // is_free 的逻辑很简单, 每spin 一次, 就检查一下这个lock 是否可以获得,
      // 如果不可以获得, 那么就delay (0, max_delay] 的时间
      if (is_free(max_spins, max_delay, n_spins)) {
......
      }
        // 如果尝试了max_spins 次, 那么就将当前cpu 时间片让出
      os_thread_yield();

      // 最后进入到wait 逻辑, 这个wait 是基于InnoDB 自己实现的wait array 来实现
      if (wait(filename, line, 4)) {
        n_spins += 4;

  }

2012 年的时候, Mark 在这边文章中说, 现有的mutex 实现会导致cpu 利用过高, 差不多比使用pthread mutex 高16%, 并且上下文切换也会更高

https://www.facebook.com/notes/mysql-at-facebook/green-mutexes/10151060544265933/

主要的原因是:

  1. 因为Mutex 的唤醒在os_event 里面, os_event 实现中, 如果需要执行唤醒操作, 那么需要执行pthread_cond_boradcast() 操作, 需要把所有等待的pthread 都唤醒, 而不是只唤醒一个.

    Innam 在底下回复: 当然只唤醒一个也并不能完全解决问题, 如果使用 pthread_cond_signal, 那么等待的线程就是一个一个的被唤醒, 那么所有等待的线程执行的时间就是串行的

    在当前InnoDB 的实现中, 如果使用pthread_cond_boradcase 会让所有的线程都唤醒, 然后其中的一个线程获得mutex, 但是其他线程并不会因为拿不到mutex马上进入wait, 而是依然会通过spin 一段时间再进入wait,这样就可以减少一些无谓的wait.

    所以这里官方到现在一直也都没有改.

  2. 在wait array 的实现中, 需要有一个全局的pthread_mutex_t 保护 sync array,

  3. 在默认的配置中, innodb_spin_wait_delay=6 是ut_delay 执行1us, innodb_sync_spin_loops=30 会执行30次, 那么每次mutex 有可能都需要spin 30us, 这个太暴力了

然后 sunny 在这个文章里面回复,

https://www.facebook.com/notes/mysql-at-facebook/green-mutexes-part-2/10151061901390933

sunny 的回复很有意思:

It is indeed an interesting problem. I think different parts of the code have different requirements. Therefore, I’ve designed and implemented something that allows using the mutex type that best suits the sub-system. e.g., mutexes that are held very briefly like the page mutexes can be pure spin locks, this also makes them space efficient. I’ve also gotten rid of the distinction between OS “fast” mutexes and InnoDB mutexes. We can use any type of mutexes in any part of the code. We can also add new mutexe types. I’ve also been experimenting with Futexes, implemented a mutex type for Linux which uses Futexes to schedule the next thread instead of the sync array

这里主要核心观点有两个

  1. 不同场景需要的mutex 是不一样的, 比如buffer pool 上面的page 的mutex 希望的就是一直spin. 有些mutex 其实则是希望立刻就进入等待, 只用使用这些mutex 的使用者知道接下来哪一个策略更合适
  2. 操作系统提供了futex 可能比InnoDB 自己通过wait array 的实现方式, 对于通知机制而言会做的更好.

所以就有了这个worklog:

worklog: https://dev.mysql.com/worklog/task/?id=6044

总结了现有的 mutex 实现存在的问题

  1. 只有自己实现的ib_mutex_t, 并没有支持futex 的实现
  2. 所有的ib_mutex_t 的行为都是一样的, 通过两个变量 innodb_spin_wait_delay(控制在Test 失败以后, 最多会delay 的时间), innodb_sync_spin_loops(控制spin 的次数). 不可以对某一个单独的ib_mutex_t 设置单独的wait + loop 次数
  3. 所有的ib_mutex_t 由两个全局的变量控制, 因为mutex 在尝试了innodb_sync_spin_loops 次以后, 会等待在一个wait array 里面的一个wait cell 上, 所有的wait cell 都会注册到一个叫wait array 的队列中进行等待, 然后等

最后到现在在 InnoDB 8.0 的代码中总共实现了4种mutex 的实现方式, 2种的策略

  1. TTASFutexMutex 是spin + futex 的实现, 在mutex_enter 之后, 会首先spin 然后在futex 进行wait

  2. TTASMutex 全spin 方式实现, 在spin 的次数超过 innodb_sync_spin_loops=30 每次最多 innodb_spin_wait_delay=6us 以后, 会主动yield() 这个线程, 然后通过TAS(test and set 进行判断) 是否可以获得

  3. OSTrackMutex, 在系统自带的mutex 上进行封装, 增加统计计数等等

  4. TTASEevntMutex, InnoDB 一直使用的自己实现的Mutex, 如上文所说使用spin + event 的实现.

#ifdef HAVE_IB_LINUX_FUTEX
UT_MUTEX_TYPE(TTASFutexMutex, GenericPolicy, FutexMutex)
UT_MUTEX_TYPE(TTASFutexMutex, BlockMutexPolicy, BlockFutexMutex)
#endif /* HAVE_IB_LINUX_FUTEX */

UT_MUTEX_TYPE(TTASMutex, GenericPolicy, SpinMutex)
UT_MUTEX_TYPE(TTASMutex, BlockMutexPolicy, BlockSpinMutex)

UT_MUTEX_TYPE(OSTrackMutex, GenericPolicy, SysMutex)
UT_MUTEX_TYPE(OSTrackMutex, BlockMutexPolicy, BlockSysMutex)

UT_MUTEX_TYPE(TTASEventMutex, GenericPolicy, SyncArrayMutex)
UT_MUTEX_TYPE(TTASEventMutex, BlockMutexPolicy, BlockSyncArrayMutex)

同时在8.0 的实现中定义了两种策略, GenericPolicy, BlockMutexPolicy. 这两种策略主要的区别在于在show engine innodb mutex 的时候不同的统计方式.

BlockMutexPolicy 用于统计所有buffer pool 使用的mutex, 因此该Mutex 特别多, 如果每一个bp 单独统计, 浪费大量的内存空间, 因此所有bp mutex 都在一起统计, 事实上buffer pool 的rw_lock 也是一样

GenericPolicy 用于除了buffer pool mutex 以外的其他地方

使用方式

目前InnoDB 里面都是使用 TTASEventMutex

只不过buffer pool 的mutex 使用的是 BlockMutexPolicy, 而且他的mutex 使用的是 GenericPolicy, 不过从目前的代码来看, 也只是统计的区别而已

问题

但是从目前来看, 并没有实现sunny 说的, 不同场景使用不同的mutex, Buffer pool 使用 TTASMutex 实现, 其他mutex 使用 TTASEventMutex, 并且新加入的 TTASFutexMutex, 也就是spin + futex 的实现方式其实也不是默认使用的 而且wai array 的实现方式也并没有改动

Database · 理论基础 · B link Tree

$
0
0

这篇文章将会介绍一份B+树并发控制协议。

论文链接:Efficient Locking for Concurrent Operations on B-Trees

文章分为2部分,第一部分通过伪代码介绍这份协议,第二部分证明这份协议是正确的

严格意义上来说这是B link树而不是B+树,具体区别在于B link树每个节点上都会附加一个key和指针。附加的key(如下图左边的红色圆点)值等于同一层下一节点的第一个key(右边红色圆点),附加的指针(红色箭头)指向同一层下一节点。附加部分用于在同层节点之间进行移动。

img

协议介绍(插入操作):

下降函数:
锁住根节点(读锁)
while 此节点不为叶子节点
    获取下降位置
    解锁此节点(读锁)
    if 需要右移
        获取同一层右节点,加锁(读锁)
    else
        将此节点入栈
        获取下一层节点,加锁(读锁)
返回叶子节点(处于读锁状态)


插入函数:
调用下降函数,获取叶子节点
当前节点升级锁(读锁->写锁)
while True
    获取插入位置
    if 需要右移
        获取同一层右节点,加锁(写锁)
    else
        break
    解锁当前节点(写锁)
    令当前节点等于右节点
if 节点不满
    插入key,解锁(写锁)
else
    调用分裂函数


分裂函数:
while 当前节点已满
    生成新节点(不加锁)
    分裂当前节点
    if 当前节点不为根节点
        获取栈内父亲节点,加锁(写锁)
    else
        生成新的根节点,使之为父亲节点
    将新节点插入父亲节点(可能需要右移)
    当前节点解锁(写锁)
    令当前节点等于父亲节点
当前节点解锁(写锁)

在不进行任何优化的情况下多线程B link树比单线程快25%(多线程队列很大程度上限制了这速度),最终内存会增加约9%。这是个比较保守的数据所以还是具备一定参考价值的,尽管性能还受影响于其他的具体实现,比如页面缓存策略,锁管理器的实现等。

下面是具体协议正确性的具体证明。

预警:证明篇幅大而且枯燥,如果不想深究的话直接使用协议就行了。

———————————————————

协议证明:

为了证明协议的正确性,我们需要对以下两点进行证明:

  1. 不会发生死锁(定理 1)
  2. 正确进行每一次操作 
  • 每一次操作在结束时都保证树结构的正确性(定理 2)
  • 除了对树进行修改的那个线程外其他线程看到的树是一致的(定理 3)

首先我们证明此协议不会发生死锁

引理1:锁被插入线程按照一定的顺序施加

我们定义B link树节点具有以下大小顺序:

  • 两节点在不同层,若节点a在节点b下方,则a < b
  • 两节点在同一层,若节点a在节点b左侧,则a < b

所以对于插入操作而言,如果在[公式]时刻a < b,那么在任意[公式]时刻都有a < b。

因为树中节点的增加仅仅通过某个节点x的分裂,假设x分裂形成x’和x’‘,显然满足[公式],以及[公式]

img

当插入操作加锁节点时,永远不会在拥有当前节点锁的情况下,对小于(在其下方或左侧)这个节点的节点进行加锁,所以插入操作以一个良好的顺序进行加锁。

所以此协议不会产生死锁,接下来我们证明此协议正确进行每一次操作

为了保证树结构的正确性,我们需要对每一个小操作进行检查,首先我们检查以下三个对于单个节点进行的操作:

  • 修改一个非满节点
  • 将满节点的右半部分写入新节点
  • 将满节点的左半部分写入当前节点

当修改一个非满节点时,此节点不涉及分裂且处于写锁保护中,所以此操作没问题。

引理2:分裂某个节点等于对树结构进行单次改变。

当将满节点的右半部分写入新节点时,此时新节点不加锁但是在这一个时刻没有任何其他节点指向这个新节点,所以此操作没问题。

当将满节点的左半部分写入当前节点时,此节点处于写锁保护中,所以操作没有问题,还有一个需要考虑的是,分裂过程中会修改边界指针,即A->B变成A->N->B,此时N(即满节点右半部分)已经被写入,所以改变指针刚好可以完美地将新节点引入到树结构中(但直到在父亲节点中插入才算完整地引入新节点)。

所以我们得到每一个操作都能够被正确执行。

最后我们证明其它线程可以正确地被执行而不需要考虑当前正在对树结构进行修改的线程。

为了证明这个定理我们首先考虑一个查找线程和一个插入线程交互,然后考虑两个插入线程进行交互。

引理3:[公式]时刻节点a被插入线程I进行修改,当读取线程P在时刻[公式]读取a节点时,P的正确性没有被I所影响

首先在P到达a节点之前的路径并没有被I所影响。另外,通过定理2,任何I操作对树结构作出的改变必定生成正确的树,所以,P在[公式]时刻之后进行的操作会正确进行。

通过引理3,我们只需要考虑当查找或者插入线程P在插入线程I对树结构进行改变之前的情况。

Part 1

考虑插入线程I在[公式]时刻改变节点a,以及查找线程S在[公式]读取a节点,令a’表示改变后的节点。因为查找线程在读取时持有读锁,所以只有等读取完毕后a节点才可能被I改变,所以交互不会出现问题。

Part 2

当两个插入线程交互时,I’会出现以下几种情况:

  1. 查找正确节点来插入
  2. 向上回溯
  3. 试图在当前节点插入

对于情况1,等同于Part 1中讨论的情况。

对于情况2,I’正在回溯,回溯时使用保存在栈上的上一层节点,考虑下面这个情况,在我们从下降到回溯的这段时间内,栈内的节点有可能已经发生了多次分裂,但是我们通过附加指针仍然可以到达正确的父亲节点。

对于情况3,I’此时不会拥有任何锁,因为I正在对此节点进行修改,所以等到I释放锁时,I’才可以修改此节点,然后I’修改此节点或者随着附加指针到达正确节点。

证明完毕。

这个协议有可能会发生活锁。在随着附加指针在同一层进行移动时,同一层的节点不断地进行分裂,也就以为着我们可能永远也到不了那个我们可以进行下降的节点。但是这几乎是不可能的,因为CPU每个核运行速度是几乎一致的而且分裂发生的概率还是比较小的,所以完全不需要担心,提出这个只是让你对这个协议有个全面的了解罢了。

PS:

这个算法的具体实现,UncP/aili

MySQL · 引擎特性 · Latch 持有分析

$
0
0

Introduction

mysql中latch没有死锁检测机制,通常指的是server层、innodb层的互斥锁和读写锁。当出现问题后,需要从现场core文件排查,下面介绍如何排查锁被谁持有了

Mutex in Server

除了win之外都采用了glibc中的pthread_mutex_t,如server层中LOCK_status, LOCK_thd_remove

方法一:

(gdb) p LOCK_status
$11 = {m_mutex = {__data = {__lock = 2, __count = 0, __owner = 102188, __nusers = 1, __kind = 3, __spins = 85, __list = {__prev = 0x0, __next = 0x0}},
    __size = "\002\000\000\000\000\000\000\000,\217\001\000\001\000\000\000\003\000\000\000U", '\000' <repeats 18 times>, __align = 2}, m_psi = 0x0}

这里的__owner为core中LWP XXXX后的值

server_mutex_stack_example

方法二:

切换到__lll_lock_wait这样frame上,对于64 bit系统:

(gdb) p *(pthread_mutex_t*)$rdi
$12 = {__data = {__lock = 2, __count = 0, __owner = 102188, __nusers = 1, __kind = 3, __spins = 85, __list = {__prev = 0x0, __next = 0x0}},
  __size = "\002\000\000\000\000\000\000\000,\217\001\000\001\000\000\000\003\000\000\000U", '\000' <repeats 18 times>, __align = 2}

同样能找到pthread_mutex中的owner


RW_lock in server

除了win之外都采用了glibc中的pthread_rwlock_t

(gdb) frame 1
#1  0x0000000000ec2059 in native_rw_wrlock (rwp=0x7f5faf078298) at /home/admin/129_20200113173827294_121311408_code/rpm_workspace/include/thr_rwlock.h:101
101     /home/admin/129_20200113173827294_121311408_code/rpm_workspace/include/thr_rwlock.h: No such file or directory.
(gdb) p rwp
$13 = (native_rw_lock_t *) 0x7f5faf078298
(gdb) p *rwp
$14 = {__data = {__lock = 0, __nr_readers = 0, __readers_wakeup = 0, __writer_wakeup = 0, __nr_readers_queued = 0, __nr_writers_queued = 15, __writer = 61789, __shared = 0, __pad1 = 0, __pad2 = 0, __flags = 0},
  __size = '\000' <repeats 20 times>, "\017\000\000\000]\361", '\000' <repeats 29 times>, __align = 0}
  • __nr_readers: 当前有多少个线程持有读锁
  • __nr_readers_queued: 当前有多少个线程在等待获得读锁
  • __nr_writers_queued: 当前有多少个线程在等待获得写锁,PS:写锁的优先级比读锁要高。即如果线程想获得读锁,当发现__nr_writers_queued不为0时,哪怕当前没有人获得写锁,也会将自己阻塞。目的是防止写锁饿死。
  • __writer:写锁持有者的LWP #

如果有线程持有写锁,通过__writer很容易找到该线程;如果有线程持有了读锁,持有读锁的线程和位置可能有多个,则可以尝试通过下述方法进行排查:

$ gdb <binary> <coredump> -ex "thread apply all bt" -ex "quit"> core.bt
$ pt-pmp core.bt > pt-pmp.log

pt-pmp.log中,排除:

  1. 出现频次高于__nr_readers的堆栈
  2. 阻塞在获取该锁的写锁的所有线程
  3. 带有poll()epoll_wait的堆栈
  4. 带有pthread_cond_wait的堆栈持有该读锁的可能性也比较低

由于持有读锁的线程和位置可能有多个,排查读锁持有者需要根据具体情况分析。


RW_lock in Innodb

innodb层的读写锁,如dict_operation_lockbtr_search_latchescheckpoint_lock

(gdb) p *dict_operation_lock
$16 = {lock_word = -2, waiters = 1, recursive = true, sx_recursive = 0, writer_is_wait_ex = false, writer_thread = 140042102085376, event = 0x7f5faf05aab8, wait_ex_event = 0x7f5faf05ab58,
  cfile_name = 0x162c6d8 "/home/admin/129_20200113173827294_121311408_code/rpm_workspace/storage/innobase/dict/dict0dict.cc",
  last_s_file_name = 0x1619240 "/home/admin/129_20200113173827294_121311408_code/rpm_workspace/storage/innobase/row/row0undo.cc",
  last_x_file_name = 0x1614968 "/home/admin/129_20200113173827294_121311408_code/rpm_workspace/storage/innobase/row/row0mysql.cc", cline = 1186, is_block_lock = 0, last_s_line = 322, last_x_line = 4290, count_os_wait = 20559,
  list = {prev = 0x7f5faea79150, next = 0x7f5faea87428}, pfs_psi = 0x0}
  • lock_word = X_LOCK_DECR时,意味着当前锁没有被任何人持有
  • X_LOCK_HALF_DECR < lock_word < X_LOCK_DECR,意味着当前有一个或多个线程持有读锁
  • 0 < lock_word <= X_LOCK_HALF_DECR时,意味着当前有一个线程持有SX锁,有0个(lock_word = X_LOCK_HALF_DECR)或多个线程(lock_word < X_LOCK_HALF_DECR)持有读锁
  • lock_word = 0时表示没有线程持有读锁,下一个写锁已经加上(并已获得)
  • lock_word < 0是表示有线程持有一个或多个读锁,下一个写锁已经预定(仍未获得,在等待读锁释放)
  1. 这里SX锁是一种介于X锁和S锁的锁,它阻塞XSX锁,但不阻塞S
  2. 为了更好理解lock_word的含义,下面简单介绍rw_lock_t获取写锁的操作
// lock_word 的初始值,意味着最多允许0x20000000个读锁同时持有
#define X_LOCK_DECR     0x20000000
// 当上SX锁时,会尝试将lock_word减少X_LOCK_HALF_DECR
#define X_LOCK_HALF_DECR    0x10000000

rw_lock_x_lock_low(rw_lock_t*  lock, ulint pass, const char* file_name, ulint line) {

  // 如果lock_word>X_LOCK_HALF_DECR,尝试将lock_word减少X_LOCK_DECR
  // 如果成功,则至少预定自己为下一个写锁的持有者,返回true,否则返回false
  if (rw_lock_lock_word_decr(lock, X_LOCK_DECR, X_LOCK_HALF_DECR)) {
  
    // 预定自己为下一个写锁持有者,此时lock_word<=0,last_x_file_name:last_x_line 为上一个写锁持有者的上锁位置
    // 将自己的线程标识写入writer_thread,
    rw_lock_set_writer_id_and_recursion_flag(lock, !pass);)

    // 如果lock_word<0,说明有线程持有读锁,必须等待读锁释放
    // 阻塞直到 lock_word==0, 
    rw_lock_x_lock_wait(lock, pass, 0, file_name, line);

  } else {
    ......
  }
  
  // 成功获得写锁,last_x_file_name:last_x_line指向加锁的位置
  lock->last_x_file_name = file_name;
  lock->last_x_line = (unsigned int) line;

  return true;
}

再回到上述的例子:

  • lock_word=-2,说明这里有两个线程持有了读锁,从last_s_file_name : last_s_line可以看到加读锁的位置;
  • 同时,下一个写锁已经预定,预定者由writer_thread指明;
  • 但是,last_x_file_name : last_x_line并不是预订者的位置,因为此时写锁还没有真正持有
  • writer_thread指明了持有或即将持有写锁的线程id,将其转成16进制可以在堆栈中搜出:

innodb_rw_lock_stack_example

另外:

  • 如果拿不到锁,线程会尝试自旋一段时间,如果自旋后还是拿不到锁,则让出处理器
  • 自旋的时间由innodb参数innodb_sync_spin_loopsinnodb_spin_wait_delay决定
  • 如果发现所有的拿锁的线程都处于自旋状态,则可以尝试减少innodb_sync_spin_loopsinnodb_spin_wait_delay

Mutex in Innodb

innodb层最常见的mutexlatchPolicyMutex<TTASEventMutex<GenericPolicy>,这种锁和rw_lock_t一样是spin锁,当拿不到锁时会尝试自旋一段时间:

spin_and_try_lock(...)
{
  ...
  for (;;) {
    // 尝试自旋,自旋的时间同样由由`innodb_sync_spin_loops`、`innodb_spin_wait_delay`决定
    is_free(max_spins, max_delay, n_spins) {
      if (try_lock()) {
        break;
      } else {
        ...
      }
    } else {
      max_spins = n_spins + step;
    }
    os_thread_yield();
    ...
  }
  ...
}

这种锁一般持有时间很短,在innodb上采用atomic来实现,目前没有好的办法排查加这种锁的线程和位置,但是core文件仍然提供了许多有用的信息:

(gdb) p *this
$19 = {m_impl = {m_lock_word = 0, m_waiters = 0, m_event = 0x7f5faea51358, m_policy = {m_count = {m_spins = 0, m_waits = 0, m_calls = 0, m_enabled = false}, m_id = LATCH_ID_FLUSH_LIST}}, m_ptr = 0x0}

m_lock_word对应值的含义:

/** Mutex is free */
 MUTEX_STATE_UNLOCKED = 0
 
 /** Mutex is acquired by some thread. */
 MUTEX_STATE_LOCKED = 1
 
 /** Mutex is contended and there are threads waiting on the lock. */
 MUTEX_STATE_WAITERS = 2

另外m_waiters = 0并不意味着目前没有等锁的线程,如果拿该锁的线程都处于自旋状态,m_waiters仍然等于0

如果有线程持有该锁,想要排查,同样可以用pt-pmp排查:

  1. 排除堆栈重复次数超过1次的所有线程
  2. 排除阻塞在获取该锁的所有线程
  3. 排除带有poll()epoll_wait的堆栈
  4. 带有pthread_cond_wait的堆栈持有该锁的可能性也比较低
  5. 阻塞在__lll_lock_wait的线程持有该锁的可能性比较低,持有innodb层mutex锁的线程阻塞在server层锁的可能性比较低

持有该锁的堆栈只可能出现1次,排查持有者需要根据具体情况分析

MySQL · 内核分析 · InnoDB 的统计信息

$
0
0

前言

MySQL 的InnoDB引擎会维护着用户表每个索引的统计信息, 来帮助查询优化器选择最优的执行计划,详细的来说, key的分布情况能决定多表join的顺序, 也能够决定查询使用哪一个索引。这些统计信息可以由专门的后台线程刷新,也可以由用户也可以显示的调用Analyze table的命令来刷新统计信息, 本文基于最新的MySQL 8.0来具体分析一下刷新统计信息的具体实现。

统计信息收集触发以及查看

MySQL有多种方法会触发统计信息的收集,显示的最典型就是Analyze Table 语法,并且由于在MySQL 8.0 中支持了直方图统计信息, 因此analyze table 还扩充了Histogram语法

ANALYZE [NO_WRITE_TO_BINLOG | LOCAL]
    TABLE tbl_name [, tbl_name] ...

ANALYZE [NO_WRITE_TO_BINLOG | LOCAL]
    TABLE tbl_name
    UPDATE HISTOGRAM ON col_name [, col_name] ...
        [WITH N BUCKETS]

ANALYZE [NO_WRITE_TO_BINLOG | LOCAL]
    TABLE tbl_name
    DROP HISTOGRAM ON col_name [, col_name] ...

执行Analyze table 的用户需要拥有表的 SELECT 和 INSERT 权限,由于Analyze table会更新数据字典里的统计信息表(8.0)因此在innodb_read_only 开关被打开时有可能会导致执行失败。 在analyze table的过程中会持有InnoDB 表的 read only 锁, 因此会存在短暂的阻塞用户写入更新删除的操作。 除此之外analyze table 要把table 从 table definition cache 刷出来, 因此还会需要一个flush lock, 此时如果有长事务使用了这张表, 那么必须等待长事务结束。

其次还有自动触发的场景, InnoDB的表在做rebuild index, add column, truncate等涉及数据修改的DDL时会需要设置正确的统计信息。 除此之外在后台有专门的线程,叫做dict_stats_thread 来处理统计信息, InnoDB会长期追踪每一张表的行数, 判断条件是发现更新的记录超过表记录总数的1/10,那么就把这张表加入到后台的recalc pool 中, 而如果变更的行数超过 16+n_rows/16(6.25%),更新非持久化统计信息 。

具体的统计信息可以通过以下语句观察到

SHOW [EXTENDED] {INDEX | INDEXES | KEYS}
    {FROM | IN} tbl_name
    [{FROM | IN} db_name]
    [WHERE expr]

如果开启了InnoDB统计信息持久化,也可以通过查询 innodb_table_stats 和 innodb_index_stats看到

列名描述
database_name库名
table_name表名
last_update最后更新这张表的时间
n_rows表中的数据行数
clustered_index_size聚集索引的页面数
sum_of_other_index_sizes其他非主键索引的页面数
列名描述
database_name库名
table_name表名
index_name索引名
last_update最后更新这张表的时间
stat_name统计项名称
stat_value统计项值
sample_size采样的页面数
stat_description统计的说明

其中stat_name 包括:

  • n_diff_pfxNN (不同前缀列的cardinality)
  • n_leaf_page (索引叶子节点数目)
  • size (索引页面数目)

执行计划的相关变量

  • innodb_stats_persistent变量控制统计信息是否持久化。统计信息在早期的MySQL中是不持久化, 在新版本的MySQL中持久化是默认的选项。当变量打开时,统计信息就会被持久化到物理表中,统计信息会更加的稳定和精确。否则表的统计信息会在诸如每次重启前周期性的计算。持久化的统计信息也可以手动修改, 修改完成后, 使用FLUSH TABLE 命令可以刷新统计信息(不推荐线上如此操作, 可能会引发一系列的SQL执行计划问题)

  • innodb_stats_auto_recalc 变量控制表多少比例的行被修改后自动更新统计信息,默认是10%, 也可以在create 或者alter table 时通过STATS_AUTO_RECALC语法来指定比率。

  • innodb_stats_include_delete_marked 变量控制是否在分析索引时包含打上删除标记的记录,在默认的情况下, InnoDB计算统计信息会读未提交的数据, 如果遇到有事务在删除表中的记录,会影响到统计信息的准确度

  • innodb_stats_method 统计信息遇到NULL值如何处理, 可以认为相等,也可以认为不想等,或者忽略它们

  • innodb_stats_on_metadata 在关闭持久化统计信息时,是否在show table status/查看information_schema的TABLES,STATISTICS表时更新统计信息

  • innodb_stats_persistent_sample_pages 开启索引信息持久化后索引统计时采样的页面数, 默认20个页面

  • innodb_stats_transient_sample_pages 关闭索引信息持久化后索引统计时采样的页面书, 默认8个页面

不带直方图的analyze

Analyze table 是可以探测key的分布情况,并且将其记录到系统表,在每次analyze的时候也会检测数据表是否发生过变化

统计信息会获取非常多的信息, 包括索引的修改时间、大小,等等在诸多的统计信息中其中Cardinality是一个很特殊的维度, 对于Cardinality的评估是通过采样评估的方式对表的每一个索引进行统计, 所以得到的是一个估算值而不是精确值。很多的查询选择到了错误的执行计划也是如此原因。

具体Analyze的代码路径为:

sql_admin.cc:ql_cmd_analyze_table::execute
  sql_admin.cc:mysql_admin_table
    handler.cc:ha_analyze
      ha_innodb.cc:ha_innodbase::analyze
        ha_innnodb.cc:ha_innodbase::info_low
          dict0stats.cc:dict_stats_update
            dict0stats.cc:dict_stats_update_persistent
              dict0stats.cc:dict_stats_analyze_index
                dict0stats.cc:dict_stats_analyze_index_level
            dict0stats.cc:dict_stats_update_transient

在这条路径中我们发现了一个非常有意思的BUG,涉及到最新的5.6/5.7/8.0,在InnoDB的rebuild table 类型的Online DDL 过程中,如果恰好此时有用户做了analyze table 或者InnoDB的后台刷新统计信息的线程刷新到这张表的主键,此时会导致 dict0stats.cc:dict_stats_analyze_index 在调用 btr_get_size 时返回一个空的统计值,这样的后果是让查询优化器会选择全表扫描, 从而导致大量的慢SQL, 直到做完online DDL再此刷新统计信息以后才能恢复正常, 具体的BUG描述可见 https://bugs.mysql.com/bug.php?id=98132

整个统计信息刷新的过程, 如果是主动发起的Analyze table, 会加上server层的MDL_SHARED_READ 锁并且将表从Table Definition Cache中淘汰出去。 以下几类情况比较特殊

  • innodb_force_recovery 大于等于4
  • innodb_read_only_mode 那么,统计信息不会持久化, 而是走内存
  • rtree索引是不采集统计信息的

线程首先获取树的高度, 然后自顶向下, 逐层分析, 如果是复合索引,那么通过逐渐增加前缀依次计算cardinality, 每一层最多扫描N_SAMPLE_PAGES(index)个页面, 如果diff值超过 N_DIFF_REQUIRED(index) = (N_SAMPLE_PAGES(index) * 10), 那么认为是found_level, 停止扫描, 然后从该层开始(很可能是非叶节点层), 根据扫描过的记录对数据进行分组,分成若干个Segment, 随机选择每个segment中的一条记录向下探测, 然后计算叶节点的diff值以及external pages。

8.0 中InnoDB的统计做了进一步的细化, 会统计索引页面在缓存Buffer中的比率, Buffer中一个根据Index ID作为Key的哈希结构存储着页面数目, 缓存中的数据和外存中的冷数据不同, 访问的代价差别也是巨大的, 因此这个数据有助于进一步细化

直方图的最新变化

直方图是MySQL 8.0 中新增的统计信息方式, Analyze table 加上直方图语句就可以操作直方图的信息, 直方图并不是存储引擎层实现的, 而是在Server层利用InnoDB存储引擎实现的系统表mysql.column_stats,MySQL利用JSON类型的字段来保存直方图的信息,其实现的核心代码在sql/histogram 目录下

具体的操作包括:更新直方图以及drop 直方图, 其中更新直方图还可以重新指定bucket的数目, 需要注意的是直方图不支持加密表, 不支持GIS列以及JSON列,以及不支持单列唯一索引的列。

通过 histogram_generation_max_mem_size参数可以调整用于生成直方图的采样记录内存大小,通过查看information_schema的column_statistic表可以查看 sampling-rate

具体的MySQL 8.0的直方图分析的文章可参考往期的月报文章

http://mysql.taobao.org/monthly/2016/10/09/

最新的MySQL-8.0.19 中, InnoDB实现了自己的采样算法,来避免全表扫描。在MySQL计算直方图填充时会调用Handler层的ha_sample_init, ha_sample_next 以及 ha_sample_end 接口。 在8.0.19前 InnoDB并没有实现 sample的接口, 而是用的Handler层的默认实现rnd_next,也就是全表扫描, 直到独到采样比率的数据为止。这里有一个问题,如果采样率设置为10%, 那采样只是读前10%的记录。 更科学的做法是在整棵索引树上均匀的采样。 在新版本中终于有了InnoDB引擎层的sample实现。 目前的代码只支持单线程的采样, 但是从代码架构看已经实现了parallel_reader的接口,不久后一定会实现多线程并行的采样。InnoDB的采样是交给了单独的worker线程来实现的,一般是对主键进行。整体思路就是根据采样比率相对平均的选择叶子节点页面,假设采样率是10%, 那么会选择一个叶子页面后跳过9个叶子页面, 被选中的页面中会对所有的记录进行采样。


MySQL · 引擎特性 · 排序实现

$
0
0

背景:

order by/group by作为mysql一个高频使用的语法,日常运维中经常遇到慢sql,内存使用不符合预期,临时文件的问题很多都和它们相关,本文通过介绍mysql 排序的具体实现,希望对排序可能引起这些问题的原因进行说明,为解决它们提供理论依据。同时也希望对功能有改进需求的同学提供帮助。以下基于mysql 5.7代码。 order by/group by 在mysql内部主要分为两个思路:

  • 通过在order by/group by c1 … cN上的索引有序性,通过空间换时间,直接用索引的顺序返回结果。
  • 如果没有索引可用那就行sorting。

其中通过索引排序主要就是在sql的处理过程中正确的判断是否有合适的索引可用

索引优化排序

   在优化阶段对排序的处理主要有:

  • 判断是否可以通过某个表完成排序并记录下这个表
    get_sort_by_table // 判断排序是否只涉及到一个表,而且order和group的列是兼容的
    
  • 判断order by 或者group by是否在join的第一张表(优化后的非const表),从而决定是否需要临时表
  • 判断是否可以用index代替排序
// Test if we can use an index instead of sorting
test_skip_sort();
-->test_if_skip_sort_order

 主要的逻辑:

  1. 遍历所有的sort field把它的part_of_sortkey map和这张表可用的keys的map做交集,获取所有可用的排序索引,这个交集保存在usable_keys中;
  2. 通过选择的表的quick访问方法(ref, range, index_merge …) 获取ref_key;
  3. 判断当前表选择的最优访问方式是否就在能用于排序的usable_keys中,如果是保留原有的方法,否则修改表的访问方式到可用的usable_keys中, 包括选择扫描索引的方式;

如果没有合适的索引可用,mysql选择对查询需要的数据进行排序,这个主要由filesort来实现。

filesort

主要流程

首先准备filesort的sort fields, 这里面很重要的结构是st_sort_field

struct st_sort_field {
  Field *field;                         /* Field to sort */
  Item  *item;                          /* Item if not sorting fields */
  uint   length;                        /* Length of sort field */
  uint   suffix_length;                 /* Length suffix (0-4) */
  Item_result result_type;              /* Type of item */
  enum_field_types field_type;          /* Field type of the field or item */
  bool reverse;                         /* if descending sort */
  bool need_strxnfrm;                   /* If we have to use strxnfrm() */
};

比如 select * from t order by c1,c2,c3 将生成3个st_sort_field的数组 紧接着需要初始化排序需要的参数结构体:

class Sort_param {
public:
  uint rec_length;            // Length of sorted records.
  uint sort_length;           // Length of sorted columns.
  uint ref_length;            // Length of record ref.
  uint addon_length;          // Length of added packed fields.
  uint res_length;            // Length of records in final sorted file/buffer.
  Addon_fields *addon_fields; ///< Descriptors for addon fields.
  bool using_pq;

这里只对几个比较关键的成员进行介绍,其中前面几个xxx_length决定了一个sort key的长度,addon_length和addon_fields是对排序的一个优化,去除一次扫表,using_pq是另一个优化,表示排序是否可以用优先级队列来完成,后面都会进行详细介绍。 然后判断是可以用优先级队列处理排序, 同时初始化优先级队列。 这些准备完成后,就生成排序用的key。 最后根据找到所有key的数量决定用什么样的方式进行排序,如果是用优先级队列,在生成key的时候就完成了排序,如果需要排序的key比较少,这个判断依据就是key填满了多少chunk(sort_buffer), 这个buffer的大小由sort_buffer_size配置。如果只有一个chunk, 就对它进行排序就可以了,不然就需要对这些chunk进行归并排序,归并排序采用的7路归并,直到最终小于等于15个chunk, 进行最后一轮排序获得有序的ref pointers,通过这些pointers读取结果。流程图如下: filesort filesort流程

排序key的生成

   读取每个符合条件的record, 然后调用排序参数的make_sortkey生成一个record的排序key。基本的逻辑就是调用每个排序field的make_sort_key方法,如果是其他item同样调用对应的生成sort_key的方法,然后把它们拼接在一起作为一个record的排序key. 而每个排序field需要多长的数据,通过初始化排序参数阶段调用sortlength计算得出,如果排序的类型是表的field, 通过sort_length接口获取单个列的排序长度,同时加上该列是否允许为NULL的一个字节,这儿的长度还受参数max_sort_length控制。    在拼接单个record的排序key时,遍历每个排序field, 如果这个field为NULL值,则填充全0,如果是反向排序,填充全1,对非NULL值的正常数据,如果是反向排序,还要对生成的排序数据每个字节取反, 整个key在sort_buffer中存储像定长的堆表,而生成的key可以通过memcmp进行比较。

create table t(id int primary key, c1 int, c2 varchar(10);
select * from t order by c1,c2;

sortkeys

key的收集   每个sortkey末尾的ref id分两种情况:

  • 把可以查询得到整个record的主键值拷贝到里面, 通过这个值排序完成后再次查询获取record返回。
  • 减少一次读表,把结果集涉及到的列拷贝到排序key的后面,直接从排序好的结果中读取数据返回。

 当收集满一个sort_buffer后,对它进行排序然后转储到临时文件。

优先级队列排序

   为了优化带有limit n的order by查询语句:    

SELECT ... FROM t ORDER BY a1,...,an LIMIT max_rows;

引入优先级队列排序算法,它通过在收集key的过程中维护一个优先级队列,将符合条件的n个key保留在这个队列中,key收集结束也就完成了排序。    首先需要评估用优先级队列和merge-sort的代价,从而选择最优的算法,merge-sort代价的估算模拟merge过程,优先级队列的代价主要包括队列维护代价加上扫表读取数据的代价。    如果选择了优先级队列排序,初始化优先级队列的buffer, 这儿需要多少内存通过limit的数量和每个key的长度计算出来了,初始化的主要工作就是生成key在buffer中offset的数组。然后把这个数组传给优先级队列进行排序,需要指定比较的函数和比较数据的长度:

Bounded_queue<uchar *, uchar *, Sort_param, Mem_compare>
    pq((Malloc_allocator<uchar*>
        (key_memory_Filesort_info_record_pointers)));

   当初始化完成后,就可以在收集key的过程中把找到的record通过优先级队列的push接口放入,如果不满足优先级就淘汰,直到扫描结束,满足limit的n条记录保留在队列中:

void push(Element_type element)
  {
    if (m_queue.size() == m_queue.capacity())
    {
      const Key_type &pq_top= m_queue.top();
      m_sort_param->make_sortkey(pq_top, element);
      m_queue.update_top();
    } else {
      m_sort_param->make_sortkey(m_sort_keys[m_queue.size()], element);
      m_queue.push(m_sort_keys[m_queue.size()]);
    }
  }

  这里生成排序key的逻辑和前面说明的一样。

多路归并排序

  如果不能用优先级队列,就要进行merge-sort,   当key比较少,全部在sort_buffer中时,首先需要对sort_buffer排序,然后把每个sort_key末尾的ref pointer拷贝到结果集的buffer中,通过这些ref pointer返回最后的结果。   如果生成的key存储到了多个chunk中,则需要进行merge, 这儿分两步,第一步是进行7路归并排序,把chunk数减少到<=15, 第二步将最后剩下的chunk merge成一个chunk, 这个chunk里面只有ref pointer用于读取数据。   返回结果ref pointer分两种:

总结

  mysql内部排序实现主要分两种:

  1. 通过在排序列上的索引避免排序,这个就需要在表上新建必要的索引,它会占用一定的空间而且会对写入有影响。
  2. 通过filesort进行排序,如果有limit n可以选择优先级队列排序,在内存中完成排序,如果n比较大,则进行merge-sort, 这个会需要sort_buffer存储排序key和内存排序,同时需要临时表存储中间merge chunk。

PgSQL · 插件分析 · plProfiler

$
0
0

插件介绍

在进行postgres的服务端编程的时候,常常会发现pg的函数和存储过程是一个黑盒,内部的任何问题都有可能造成性能瓶颈。通常会遇到以下情况:

  1. 出现问题的语句,其实执行地非常快,但是调用过次数多导致变慢
  2. 随机出现的性能瓶颈问题
  3. 生产系统上出现了性能问题(尽管我们不愿意直接上生产系统排查)

以上的出现的性能问题只能采取人肉分析(分析schema、统计信息、SQL语句)、断点(pldebugger)的形式进行排查,排查时间长且不直观、问题时隐时现(甚至对于问题1,根本无法排查出来),因此需要有一个更好的排查方式,同时具备良好的展示方式,来帮助我们找到性能瓶颈的点。

plprofiler(https://github.com/bigsql/plprofiler)提供了一个简洁的postgres函数和存储过程的性能采集方式,用于发现pg的函数和存储过程性能瓶颈,从而让dba和开发人员能够进行针对性地对函数和存储过程、schema等进行优化。

为方便表达,后面统称 函数和存储过程 为 函数。

基本原理

在执行函数/语句前后加入钩子函数,进入的时候记录时间,出来的时候记录时间,两者相减即可得到该函数/语句的执行时间。

注意一下,这里的时间是Wall-Clock time,即真实时间,区分于CPU时间。例如pg_sleep(10),真实时间为10s,cpu时间只有0.001s。这里,该函数的真实时间为10s。

后续所有的数据分析都会基于这部分数据进行,分析运行时间的基本原理是:
self_time = total_time - children_time

基于该算式可以得到该函数的实际消耗时间。例如上述的pg_sleep(10)函数,它不包含语句粒度的子调用,因此children_time为0s,计算得到self_time是10s。

火焰图

plprofiler使用了火焰图作为其展示方式,需要搭配其python客户端使用,可以自动生成火焰图。

火焰图出自Brendan Gregg之手,感兴趣的话可以了解一下他的博客和书籍

火焰图是一个可视化地进行性能分析的利器。性能问题同样遵循着二八定律,即大部分的性能瓶颈是由少部分的问题导致的,因此,基于这个前提,找到导致大部分瓶颈的少部分问题成为了性能优化的关键。

火焰图
http://www.brendangregg.com/FlameGraphs/cpu-mysql-updated.svg

上图是Brendan Gregg博客的示例,这是一个mysql内核的CPU火焰图。

火焰图是可以用鼠标进行交互的,有需要的话可以点击上述链接,进行交互操作

可以看一下这个火焰图,X轴显示该层的总体堆栈,按字母顺序排序,Y轴显示堆栈深度,从底部的零开始计数。每个矩形代表一个栈帧。矩形越宽,它在堆栈中出现的频率就越高。顶部边缘显示了on-CPU的内容,在它下面是它的父函数。颜色通常不重要,它只是随机选择的,用来以区分矩形。

在该CPU火焰图中,矩形宽度对应着CPU的周期,矩形越宽,代表消耗的CPU周期越多(描述的并不完全准确,因为该图是基于采样的,但是采样足够多,基本上可以认为是正确的)可以观察到该图中大部分的性能消耗主要是在两处row_search_for_mysql函数内,可以点击该函数,继续放大视图查找性能瓶颈点从而发现问题。

发现导致性能瓶颈的头部问题后,就可以针对这些头部问题进行优化操作。大部分情况下,优化完这部分问题后,性能瓶颈就能够得到消除。如果还是存在问题,就重复以上流程,因此一个性能优化的工作可以按照以下范式进行:

1. 发现性能瓶颈
2. 找到头部问题
3. 消除头部问题
4. 重新观测性能表现,如果存在问题,回到1

在plprofiler的火焰图中,区别于CPU火焰图,矩形宽度的含义略有变化,代表的是在该函数内的停留时间,即上面所述的Wall-Clock time。同样区别于CPU火焰图,产生的火焰图可以基于采样也可以基于统计,看在性能分析时候所使用的参数,具体可见官方文档。

 CPU火焰图plprofiler火焰图
矩形宽度CPU周期真实时间
统计形式采样采样或统计(取决于参数)

基本使用

这里的例子摘自官方README,先从https://github.com/bigsql/plprofiler获取源码。

准备

首先导出环境变量,为了运行demo,你只需要修改这部分内容:

export PGHOST=localhost
export PGPORT=5432
export PGUSER=postgres
export PGPASSWORD=password
export PGDATABASE=pgbench_plprofiler
export PLPROFILER_PATH=/path-to-plprofiler/
export USE_PGXS=1
export PATH=/path-to-pgsql/bin:$PATH

进入到源码目录下,安装服务端插件和客户端插件。

cd $PLPROFILER_PATH
make install #sudo make install
cd $PLPROFILER_PATH/python-plprofiler
python setup.py install #sudo python setup.py install

创建数据库,创建插件。

psql postgres
> CREATE DATABASE pgbench_plprofiler;
> \c pgbench_plprofiler
> CREATE EXTENSION plprofiler;

准备表、数据和函数。

cd $PLPROFILER_PATH/examples
bash prepdb.sh

进行分析

运行plprofiler命令进行性能分析:

plprofiler run --command "SELECT tpcb(1, 2, 3, -42)" --output tpcb-test1.html

编辑
命令完成后,会进入编辑界面,你可以在这里编辑输出网页的标题、长宽、描述等信息。可以不用编辑,直接退出,在执行命令的路径下,可以看到输出的网页 tpcb-test1.html,使用浏览器打开这个网页。
优化前
网页的最上方是火焰图,下面是函数列表,再下面是各个函数的签名以及执行时间的详细信息,从火焰图中我们可以发现最影响性能的函数是tpcb_fetch_abalance。继而我们可以分析出,是由于没有创建索引导致性能很差。(尽管tpcb_upd_accounts性能也很差,但它有可能只是它的子函数的受害者,我们需要优化完子函数再观察情况)。

我们可以创建索引完成这次优化:

psql
> CREATE INDEX pgbench_accounts_aid_idx ON pgbench_accounts (aid);

再次运行plprofiler命令进行性能分析,得到以下结果:
![优化后]](/monthly/pic/202003/2020-03-zhuyuan-result-after.png)
可以看到火焰图发生了变化,tpcb_fetch_abalance不再是性能瓶颈,新的性能瓶颈出现了。由于优化了tpcb_fetch_abalance,可以在下面看到tpcb_upd_accounts的执行时间也大大缩短了,它确实只是子函数的受害者。

如果符合期望,这次的优化可能就到此为止了。如果仍然不满足,那可以用新的火焰图继续分析出性能瓶颈。

总结:

可以看到plprofiler不能告诉我们该如何去做优化,它只能告诉我们在pg里的一个复杂的函数中,到底是哪一行出现了性能问题,得到这个信息后,我们再针对性地对这一行进行优化,有可能是索引问题,有可能是SQL问题,需要具体问题具体分析。 但是很多时候我们仅仅是难以发现是哪一行,这时候需要使用plprofiler,当发现哪一行存在问题后,问题往往很快就能解决了。

实现分析

钩子函数

plprofiler主要是通过插件的形式,hook函数和存储过程的执行的关键路径进行实现的。
PG内核的说明:src/pl/plpgsql/src/plpgsql.h:1061

结构体为PLpgSQL_plugin,主要有5个钩子函数:

void	(*func_setup) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
void	(*func_beg) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
void	(*func_end) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
void	(*stmt_beg) (PLpgSQL_execstate *estate, PLpgSQL_stmt *stmt);
void	(*stmt_end) (PLpgSQL_execstate *estate, PLpgSQL_stmt *stmt);

func_setup函数在调用函数的时候进行调用,在初始化函数定义的局部参数之前。
func_beg函数在调用函数的时候进行调用,在初始化函数定义的局部参数之后。
func_end函数在调用函数结束的时候进行调用。
stmt_beg函数在调用语句之前进行调用。
stmt_end函数在调用语句之后进行调用。

在开始钩子函数中,会获取当前开始时间、以及哪一行,在结束钩子函数中,会获取当前结束时间,并将该行的信息统计记录下来。

为了完成数据的记录和分析,还有部分辅助的数据结构,包括函数的信息、调用链等。这些数据结构的内容和关系如下:
数据结构

数据收集

数据收集部分以stmt的开始和结束为例。

static void
profiler_stmt_beg(PLpgSQL_execstate *estate, PLpgSQL_stmt *stmt)
{
	profilerLineInfo   *line_info;
	profilerInfo	   *profiler_info;

    /* 检查profiler是否启用 */
	if (!profiler_active)
		return;

    /* plugin_info储存的是profilerInfo信息,即当前函数的执行信息,如果为空,则说明是匿名代码块 */
	/* Ignore anonymous code block. */
	if (estate->plugin_info == NULL)
		return;

	/* Set the start time of the statement */
	profiler_info = (profilerInfo *)estate->plugin_info;
	if (stmt->lineno < profiler_info->line_count)
	{
		line_info = profiler_info->line_info + stmt->lineno;
        /* 在这里记录开始时间 */
		INSTR_TIME_SET_CURRENT(line_info->start_time);
	}

	/* Check the call graph stack. */
	callgraph_check(profiler_info->fn_oid);
}
static void
profiler_stmt_end(PLpgSQL_execstate *estate, PLpgSQL_stmt *stmt)
{
	...

    /* 如果没有新数据,就不进行分析,防止每次都进行分析 */
	/* Tell collect_data() that new information has arrived locally. */
	have_new_local_data = true;

    /* 计算经历的时间,记录最大时间、总时间,增加执行次数 */
	INSTR_TIME_SET_CURRENT(end_time);
	INSTR_TIME_SUBTRACT(end_time, line_info->start_time);

	elapsed = INSTR_TIME_GET_MICROSEC(end_time);

	if (elapsed > line_info->us_max)
		line_info->us_max = elapsed;

	line_info->us_total += elapsed;
	line_info->exec_count++;
}

数据分析

分析部分主要是profiler_collect_data函数,该函数会在func_end结束时运行,意味着每执行一次pl/pgSQL函数会收集一次的数据,或者通过手动触发。

可以看到收集了两部分数据:

  1. 调用图数据,即callGraphEntry,对应了火焰图的原始数据
  2. 行统计数据,即linestatsEntry,对应了每个函数的统计数据
static int32
profiler_collect_data(void)
{
	...
    /* 如果没有新数据,就不进行分析,防止每次都进行分析 */
	if (!have_new_local_data)
		return 0;
	have_new_local_data = false;

    /* 分析前需要获取hash table锁 */
	LWLockAcquire(plpss->lock, LW_SHARED);

    /* 1.将调用图数据分析进入共享内存 */
	hash_seq_init(&hash_seq, callgraph_hash);
	while ((cge1 = hash_seq_search(&hash_seq)) != NULL)
	{
		/* 将cge1导入callgraph_hash,并将相关信息记录进去 */
        ...
		cge2->callCount += cge1->callCount;
		cge2->totalTime += cge1->totalTime;
		cge2->childTime += cge1->childTime;
		cge2->selfTime  += cge1->selfTime;
        
        /* 清空已经记录过的信息 */
        cge1->callCount = 0;
		cge1->totalTime = 0;
		cge1->childTime = 0;
		cge1->selfTime = 0;
        ...
	}

    /* 2.将行统计数据导入共享内存 */
	hash_seq_init(&hash_seq, functions_hash);
	while ((lse1 = hash_seq_search(&hash_seq)) != NULL)
	{
		/* 将cge1导入functions_hash,并将相关信息记录进去 */
        ...
		for (i = 0; i < lse1->line_count && i < lse2->line_count; i++)
		{
            /* 更新每一行的最大执行时间、总执行时间、执行次数 */
			if (lse1->line_info[i].us_max > lse2->line_info[i].us_max)
				lse2->line_info[i].us_max = lse1->line_info[i].us_max;
			lse2->line_info[i].us_total += lse1->line_info[i].us_total;
			lse2->line_info[i].exec_count += lse1->line_info[i].exec_count;
		}
        ...
	}

	...
}

对外接口

对外接口比较多,主要是将数据吐给前端,也比较乏善可陈,仅在这里列出,不做具体分析。

接口名称INOUT描述
pl_profiler_callgraph_localSETOF recordOUT stack oid[], OUT call_count bigint, OUT us_total bigint, OUT us_children bigint, OUT us_self bigintReturns the content of the local call graph hash table as a set of rows.
pl_profiler_callgraph_overflowboolean Return the flag callgraph_overflow from the shared state.
pl_profiler_callgraph_sharedSETOF recordOUT stack oid[], OUT call_count bigint, OUT us_total bigint, OUT us_children bigint, OUT us_self bigintReturns the content of the shared call graph hash table as a set of rows.
pl_profiler_collect_datainteger SQL level callable function to collect profiling data from the local tables into the shared hash tables.
pl_profiler_func_oids_localoid[] Returns an array of all function Oids that we have linestat information for in the local hash table.
pl_profiler_func_oids_sharedoid[] Returns an array of all function Oids that we have linestat information for in the shared hash table.
pl_profiler_funcs_sourceSETOF recordfunc_oids oid[], OUT func_oid oid, OUT line_number bigint, OUT source textReturn the source code of a number of functions specified by an input array of Oids.
pl_profiler_functions_overflowboolean Return the flag functions_overflow from the shared state.
pl_profiler_get_collect_intervalboolean Report pid profiling state.
pl_profiler_get_enabled_globalboolean Report global profiling state.
pl_profiler_get_enabled_localboolean Report local profiling state.
pl_profiler_get_enabled_pidboolean Report pid profiling state.
pl_profiler_get_stacktext[]stack oid[]Converts a stack in Oid[] format into a text[].
pl_profiler_lines_overflowboolean Return the flag lines_overflow from the shared state.
pl_profiler_linestats_localSETOF recordOUT func_oid oid, OUT line_number bigint, OUT exec_count bigint, OUT total_time bigint, OUT longest_time bigintReturns the content of the local line stats hash table as a set of rows.
pl_profiler_linestats_sharedSETOF recordOUT func_oid oid, OUT line_number bigint, OUT exec_count bigint, OUT total_time bigint, OUT longest_time bigintReturns the content of the shared line stats hash table as a set of rows.
pl_profiler_reset_localvoid Drop all data collected in the local hash tables.
pl_profiler_reset_sharedvoid Drop all data collected in the shared hash tables and the shared state.
pl_profiler_set_collect_intervalbooleanseconds integerTurn pid profiling on or off.
pl_profiler_set_enabled_globalbooleanenabled booleanTurn global profiling on or off.
pl_profiler_set_enabled_localbooleanenabled booleanTurn local profiling on or off.
pl_profiler_set_enabled_pidbooleanpid integerTurn pid profiling on or off.
pl_profiler_versioninteger Get int version.
pl_profiler_versionstrtext Get text version.

PostgreSQL · 源码分析 · 回放分析(一)

$
0
0

基本原理

在数据库的运行过程中,难免会遇到各种非预期的问题,例如:

  • 硬件错误,例如突然断电、磁盘错误、有人拔了你的内存条 :P
  • 软件问题,例如操作系统崩溃、数据库内部存在bug等等
  • 操作错误,例如误删数据、插入了不符合预期的数据、应用程序异常等等
  • … …


在这些情况下,我们不希望我们的数据异常甚至丢失,有的情况下我们不能进行修复,例如火灾(这类问题依赖于备份存储介质的方式解决,需要异地容灾),但有的情况下我们可以进行解决,例如断电、崩溃。我们希望当数据库重新启动时,能够恢复其崩溃的那一瞬间的状态,能够恢复出“一致的”、“完整的”数据。

由于内存是易失性的,当数据库发生断电、崩溃等情况时,存储在内存中的数据会丢失,因此不能寄希望于存储在内存中的数据,我们希望找到一种方式,能够帮助数据库系统完成崩溃恢复,同时不那么影响性能。

 REDOUNDO
未提交事务不允许未提交的数据写入允许未提交的数据写入(Steal)
已提交事务已提交的数据可以延迟写入已提交的数据必须写入(Force)
优点可以延迟数据写入,减弱随机写可以直接inplace修改,减少膨胀

表1 REDO和UNDO的对比

WAL(Write-Ahead Logging,预写式日志),就是完成这一工作的重要方式,数据库在执行事务的过程中,会将对数据的操作过程记录在WAL中,当数据库发生崩溃的时候,能够使用这个操作记录,将数据库恢复到崩溃前的状态。日志有几种记录方式,一是记录REDO,二是UNDO,还有一种是REDO/UNDO日志,REDO允许我们重新进行对数据的修改,UNDO允许我们撤销对数据的修改,REDO/UNDO日志是以上两种日志的结合。

除了WAL以外,还有Shadow Pagging的技术,是System R和sqlite所使用到的技术,看上去有点像COW(Copy On Write,写时复制)技术;此外还存在WBL(Write-Behind Logging,结合NVM所产生的技术)等技术出现。

数据库的基本组件

图1 数据库基本组件的联系,I/O是围绕着缓冲区管理器进行的《数据库系统实现》

在数据库系统的内部,存在一个叫做 日志管理器 的基本组件,当数据库在正常运行的时候,事务管理器将对数据的操作发送到日志管理器中,日志管理器会将日志顺序写入到缓冲区管理器中,缓冲区管理器将日志刷入到磁盘中,事务管理器只有在确认这条事务的最后一条日志被刷入到磁盘后,才会向客户端返回事务提交的信息。

    当崩溃发生时,在重启的时候,恢复管理器就会开始工作,它会读取事务的状态,将已经提交的数据重新回放,将已经放弃或者中断的事务进行回滚,将数据库内不一致的数据恢复到一致的状态。在恢复的时候,恢复管理器有一套算法逻辑在其中,决定如何进行回放,大名鼎鼎的ARIES就是这方面的一个算法。

ARIES的算法,是IBM提出的一整套关于日志记录和恢复处理的算法,后续的数据库管理系统都多少参考了该算法。


可以预见的是,如果数据库长时间运行了很久,突然崩溃了,在重启的时候可能需要从数天前开始进行恢复,需要花费数个小时甚至上天的时间。这时候需要使用到检查点技术,将脏数据刷入到磁盘中,记录检查点刷下的最旧的数据页的,可以保证我们在恢复的时候从相对较新的位置开始。同时让我们可以清理掉旧的日志文件(或者复用),让日志不会无限制地增长。

日志所提供的功能不仅于崩溃恢复,它还能提供复制(包括主备复制、外部订阅复制等)、主备状态同步、按时间点还原等功能。

实现简述

在记录日志时

  • 每个数据页面 (堆或索引) 都标有影响页面的最新XLog记录的LSN
  • 在缓冲区管理器能够写出一个脏页面之前,它必须确保XLog已经被刷新到磁盘,至少达到页面的LSN


在写XLog、写数据页面的时候,都只写入到缓冲区中,而不等待写入到磁盘中,以提供很快的写入速度,只在事务提交时会进行等待(当打开同步提交时)。

LSN检查仅存在于共享缓冲区管理器中,不存在于临时表使用的本地缓冲区管理器中,因此,对临时表的操作不能被 WAL记录。

XLog:Transaction log,事务的日志,通常指的是记录时的在内存中的事务日志,WAL指的是持久化后的日志 LSN:Log sequence number,日志序列号,这是WAL日志唯一的、全局的标识 bgwriter:PostgreSQL负责将脏页面刷入磁盘的进程 walwriter:PostgreSQL负责将WAL刷入磁盘的进程


在崩溃恢复时

  • 从检查点开始,回放WAL日志,如果数据页面的LSN小于WAL记录的LSN,则说明数据页面比较旧,需要进行回放,反之则不需要回放,就会跳过回放过程。

在回放的过程中,checkpointer会持续地做检查点,让数据页面向前更新,这样万一又重启了,能更快地恢复。

checkpointer:PostgreSQL中的检查点进程

日志内容

PostgreSQL的WAL是REDO类型的。我们看一下PostgreSQL的日志的格式和包含的信息。

PG社区还在实现Zheap的特性,这是PG的新的日志格式,是一种REDO/UNDO日志,届时将能够很好地解决PG数据库的膨胀问题,我们将在后续的文章中介绍这一特性。

WAL文件

PostgreSQL的WAL文件存放于数据目录下的pg_wal目录里,ls一下可以看到以下文件:

-rw------- 1 postgres users 1073741824 Apr 17 08:41 000000010000000000000001
-rw------- 1 postgres users 1073741824 Apr 14 11:09 000000010000000000000002
-rw------- 1 postgres users 1073741824 Apr 14 11:09 000000010000000000000003
drwx------ 2 postgres users       4096 Apr 14 11:09 archive_status #和备份有关,表示日志文件的备份状态,这里不做介绍

可以看到这里每个WAL文件大小为1GB(这和我们configure、initdb时的参数有关),命名为一串16进制的串,这个串和时间线以及LSN紧密相关,每个WAL文件都包含了特定时间线内,从某个LSN开始到某个LSN结束的WAL日志。根据一个特定的LSN,可以知道对应的WAL日志的文件名,以及在文件中所处的位置。
PostgreSQL WAL文件
事务日志与WAL段文件 《PostgreSQL指南:内幕探索》

WAL日志

使用pg_waldump工具我们可以看到PostgreSQL的日志,每一条日志可以理解为一次对数据库的操作记录:

rmgr: Standby     len (rec/tot):     42/    42, tx:        699, lsn: 0/410E21B8, prev 0/410E2180, desc: LOCK xid 699 db 13933 rel 221196
rmgr: Heap        len (rec/tot):     59/    59, tx:        699, lsn: 0/410E21E8, prev 0/410E21B8, desc: INSERT off 4, blkref #0: rel 1663/13933/221196 blk 0
rmgr: Transaction len (rec/tot):     38/    38, tx:        699, lsn: 0/410E2228, prev 0/410E21E8, desc: COMMIT 2020-04-17 08:38:04.881890 UTC


这是一条id为699的事务所产生的三条日志,做了锁表、插入数据、提交的操作,让我们对照着SQL看一下这条日志是怎么生成的:

postgres=# begin;
BEGIN      --开启一个新的事务,此时不会分配事务ID,也不会生成WAL
postgres=# lock table t;
LOCK TABLE --锁住表t,生成事务ID 699,生成锁表的日志0/410E21B8
					 --锁住了(db:13933, rel:221196)的表(我们后续会聊这条日志如何在热备模式下发挥作用)
postgres=# insert into t select 1; 
INSERT 0 1 --向表t插入一条数据,生产插入数据的日志0/410E21E8
					 --向表(1663,13933,221196),BlockNumber为0的page,offset为4的tupe的位置,写入了一条数据,该页面的LSN会被更新为这条日志的LSN
postgres=# end;
COMMIT     --提交,生成提交日志0/410E2228(数据库会等待这条日志刷盘再返回给客户端,这是保证持久化的关键,当然得设置同步提交为on)
					 --这条日志包含了事务的提交状态,以及提交的时间(我们后续会聊这个时间如何在时间点还原下发挥作用)


在上面产生了三条不同类型的日志,有Standby,Heap,Transaction三种类型,这里的类型指的是资源管理器的类型。在PostgreSQL中,对数据不同的操作被进行了分类,例如对序列号的操作、对BTree索引的操作,每一类操作类型会使用对应的资源管理器进行管理,包括进行记录和回放。

下图展示了在PostgreSQL 10中所包含的资源管理器的类型,共计有22种(在最新的PostgreSQL 12中,资源管理器的类型未增加),涉及到了堆元组操作、索引操作、序列号操作等。

PostgreSQL的资源管理器
PostgreSQL 10的资源管理器 《PostgreSQL指南:内幕探索》

记录流程

在数据库的运行过程中,很多操作需要记录WAL日志,一个标准的记录流程是这样的:

  1. 对需要修改的页面进行PIN和LOCK操作
  2. START_CRIT_SECTION() 开启临界区,此时不允许任何错误,若发生错误,直接报PANIC错误
  3. 将需要的修改应用到页面上
  4. 将页面标记为脏,这必须发生在WAL日志插入前
  5. 如果该表需要进行插入WAL记录的操作,初始化一条XLOG并插入,然后设置页面的LSN
  6. END_CRIT_SECTION() 结束临界区。
  7. 对需要修改的页面进行UNPIN和UNLOCK操作

buffer和page的区别在于buffer是内存中的,page是在存储中的,buffer中有块区域叫做frame(页框), page会被读取到frame中以供读写 PIN buffer表示从磁盘中置换入page到frame中,并且不能被置换出去 LOCK > buffer表示锁定住buffer,使其他进程无法读写frame(page)


我们可以结合插入数据的代码看一下插入数据是WAL是如何记录的:

调用顺序:PostgresMain->exec_simple_query->PortalRun->PortalRunMulti->ProcessQuery->
    	standard_ExecutorRun->ExecutePlan->ExecModifyTable->ExecInsert->
    	heapam_tuple_insert->heap_insert

heap_insert(Relation relation, HeapTuple tup, CommandId cid,
			int options, BulkInsertState bistate)
{
    // 获取将要插入的heaptup
	heaptup = heap_prepare_insert(relation, tup, xid, cid, options);

    // 读取buffer,在内部会自动PIN buffer,LOCK buffer
	buffer = RelationGetBufferForTuple(relation, heaptup->t_len,
									   InvalidBuffer, options, bistate,
									   &vmbuffer, NULL);

	// 开始临界区
	START_CRIT_SECTION();

    // 插入数据
	RelationPutHeapTuple(relation, buffer, heaptup,
						 (options & HEAP_INSERT_SPECULATIVE) != 0);

	// 将页面标记为脏页
	MarkBufferDirty(buffer);

	// 开始记录WAL日志,RelationNeedsWAL,如果是临时表,就不需要WAL日志了
	if (!(options & HEAP_INSERT_SKIP_WAL) && RelationNeedsWAL(relation))
	{
        // info信息,标记记录为XLOG_HEAP_INSERT类型的,将来将会使用heap_xlog_insert回放
        // 如果是新页,还会标记这个为XLOG_HEAP_INIT_PAGE,就表示回放时需要先初始化新页
		uint8		info = XLOG_HEAP_INSERT;
		if (ItemPointerGetOffsetNumber(&(heaptup->t_self)) == FirstOffsetNumber &&
			PageGetMaxOffsetNumber(page) == FirstOffsetNumber)
		{
			info |= XLOG_HEAP_INIT_PAGE;
			bufflags |= REGBUF_WILL_INIT;
		}

        // 初始化一条XLog记录,并插入
		XLogBeginInsert();
		XLogRegisterData((char *) &xlrec, SizeOfHeapInsert);
        ...
        // 这是一条RM_HEAP_ID类型的日志,将来回放的时候,将会根据这个ID使用heap_redo进行回放
		recptr = XLogInsert(RM_HEAP_ID, info);

        // 设置页面的LSN,值得注意的是这里的LSN用的是EndRecPtr,为什么要在最后设置?
		PageSetLSN(page, recptr);
	}

    //结束临界区
	END_CRIT_SECTION();

    //UNLOCK buffer,UNPIN buffer,之后buffer可以被其他事务使用,或者置换出去
	UnlockReleaseBuffer(buffer);
	if (vmbuffer != InvalidBuffer)
		ReleaseBuffer(vmbuffer);
}

上述代码是一个典型的插入数据、写WAL的一个流程,但关于这个流程还是有不少疑问:

  1. 先修改buffer里的数据,再写WAL,会不会导致数据落盘而写WAL不成功
    回到前面的 缓冲区管理器能够写出一个脏页面 的前提,这个是数据库需要确保不能发生的。需要这个前提的原因在于,PostgreSQL的日志类型时REDO的,数据只能往前回放,无法向后恢复,因此数据页面不能比WAL“新”
  2. 为什么将buffer标记为脏要发生在WAL日志插入前 如果在WAL日志插入后将buffer标记为脏,有可能做检查点时,使用了新的LSN,但是由于该页不是脏页导致跳过刷脏,导致该页数据在磁盘中的是旧的,但是检查点已经超前了,后续崩溃恢复时,该页面就会存在这条WAL日志未回放的情况
  3. 为什么要使用EndRecPtr,可以使用RecPtr吗
    不只是页面的LSN,包括检查点的LSN、刷数据的LSN(flushPtr)等也是使用的EndRecPtr,以刷数据的LSN为例,使用EndRecPtr就能表示已经刷完了到哪个LSN结束的WAL日志对应的数据,要是使用RecPtr就很费解了;检查点的LSN使用EndRecPtr,就能方便地在下次回放时,找到下一条需要回放的日志的LSN。在页面的LSN中,使用就EndRecPtr可以和上述逻辑维持一致了;而且RecPtr在影响完页面后,对这个页面来说已经不重要了,我们关心的是下一条影响这个页面的WAL记录


另外,这里仅仅展示了最简单的插入数据的流程,生成的WAL日志也比较简单,有一些比较复杂的对数据库的修改,比如涉及到索引的分裂,需要创建一个新页面,再写入新key,这需要至少记录两个WAL(涉及到连续分裂会更多),当回放处于这两个WAL日志之间时,数据库处于一个“中间状态”,这就需要一些技巧来隐藏这种状态。

恢复流程

数据库从崩溃中重启,从控制文件中,获知上一次没有正常停库,进入崩溃恢复状态,从控制文件中读取到上一次检查点的位置,从检查点开始进行严格的串行回放。

  1. 读取到新的日志,解析日志头部,根据日志的类型,将日志交由对应资源管理器回放
  2. 解析该WAL日志,根据具体的操作类型,交由具体的函数进行回放
  3. 解析WAL日志内容
  4. XLogReadBufferForRedo,读取需要修改的页面,进行PIN和LOCK操作,并根据LSN确认是否需要REDO
  5. 如果需要REDO,则将日志应用到页面上,更新页面的LSN,标记页面为脏页
  6. 对需要修改的页面进行UNPIN和UNLOCK操作,其他进程可以使用该页面,bgwriter可以向下刷该页面


我们可以结合插入数据的代码看一下redo是如何工作的:

调用顺序:StartupXLOG->heap_redo->heap_xlog_insert

heap_xlog_insert(XLogReaderState *record)
{
	// 如果xl_info中存在XLOG_HEAP_INIT_PAGE,则说明需要初始化页
	if (XLogRecGetInfo(record) & XLOG_HEAP_INIT_PAGE)
	{
		buffer = XLogInitBufferForRedo(record, 0);
		page = BufferGetPage(buffer);
		PageInit(page, BufferGetPageSize(buffer), 0);
		action = BLK_NEEDS_REDO;
	}
	else
        // action是根据page LSN和record LSN计算得到的
        // 如果page LSN<record LSN,说明页面比较旧,需要进行redo
		action = XLogReadBufferForRedo(record, 0, &buffer);
	if (action == BLK_NEEDS_REDO)
	{
		...

        // 构建htup (HeapTuple),这个就是新插入的数据
        htup = &tbuf.hdr;
		...

        // 向page中插入这条htup
		if (PageAddItem(page, (Item) htup, newlen, xlrec->offnum,
						true, true) == InvalidOffsetNumber)
			elog(PANIC, "failed to add tuple");

		// 将该page的LSN设置为这条记录的LSN
		PageSetLSN(page, lsn);

		if (xlrec->flags & XLH_INSERT_ALL_VISIBLE_CLEARED)
			PageClearAllVisible(page);

		// 将该buffer标记为脏
		MarkBufferDirty(buffer);
	}

    // UNLOCK buffer,UNPIN buffer
	if (BufferIsValid(buffer))
		UnlockReleaseBuffer(buffer);
}


这是一条插入数据的WAL日志的回放流程,我们可以看到,记录WAL日志的代码和回放部分的代码是高度一致的,这也该过程被叫做回放的原因。

在崩溃恢复的过程中,数据库已经看不到具体的SQL语句了,只有一条条操作记录,恢复管理器只负责机械地将这些记录应用到数据上,将数据库还原到崩溃前的状态。

部分写问题

现在的磁盘/文件系统大多是4KB对齐的(部分老的磁盘甚至是512字节的扇区),这样就只能保证4KB的原子读写。这就导致了当写入一个较大页面时,会在文件系统、磁盘驱动里被拆分为几次I/O,当写入到一半时,就会发生部分写问题,导致数据页面或者WAL文件损坏。

MySQL也存在类似的问题,它采用了一个叫做double write buffer技术解决了这个问题,但也带来了额外的开销。

PostgreSQL有自己的一套解决的方法:

  • 当数据页面损坏时,有一个叫做FullPageWrite(FPW)的特性来保证数据的完整性
  • 数据文件可以打开checksum用于校验,由于较为影响性能,所以需要在初始化数据库时手动指定开启
  • 每条WAL记录都包含crc校验码,来检查WAL记录是否正确
  • 每个WAL页面,都包含magic,来检查页面的有效性


FullPageWrite(FPW)的原理是,当做了checkpoint后,如果某个数据页面是第一次被修改,那么就会记录完整的数据页面到WAL文件中,当恢复时,就能够获取完整数据页面重新进行修复,因此哪怕数据页面被写坏了,也能够修复出来。当然这也会带来写放大的开销,尤其是当checkpoint十分频繁时,写放大会十分地严重。

该特性需要手动开启,如果数据页大小大于文件系统所提供的原子写粒度的话,就不需要这个特性了。


当WAL也出现错误时,又不巧碰上了崩溃恢复,需要这段WAL日志,很不幸就不能进行恢复了,数据库会及时地崩溃并告诉你无能为力。

但是WAL日志是预分配且一直是顺序写入的,因此也最多由于部分写会丢失尾部的部分WAL日志,且这部分WAL文件没落盘成功,数据库也不会返回事务成功(当同步提交为on时),因此WAL文件遇到部分写问题也没啥影响,直接丢弃这段不完整的WAL日志就行了。

至于更加麻烦的磁盘静默错误和内存错误的话,就很难在数据库层面解决了,一般会通过冗余校验的方式进行解决,例如磁盘的RAID技术(部分RAID级别),ECC内存等。

总结

本文简单描述了数据库崩溃恢复的基本原理,以及PostgreSQL是如何记录日志、进行崩溃恢复的。

本文严重参考了PG源码中的src/backend/access/transam/README,README的原理部分讲的十分清晰,以至于该文在这部分的原理只做了翻译,以及结合源码进行了分析,该README中还包含更多的细节,如果对这部分原理感兴趣,强烈建议去阅读这篇文档。

在下一篇文章中,我将会详细描述在热备的情况下备库如何进行恢复,以及如何做到按时间点还原(PITR),这部分README没有进行描述,希望能将这部分原理清晰地带给大家。

参考

《Intro to Database Systems》CMU Database Group
《数据库系统实现》机械工业出版社
https://github.com/postgres/postgres/blob/master/src/backend/access/transam/README
https://www.pgcon.org/2012/schedule/track/Hacking/408.en.html
https://www.enterprisedb.com/blog/zheap-storage-engine-provide-better-control-over-bloat
http://www.vldb.org/pvldb/vol10/p337-arulraj.pdf
https://chenhuajun.github.io/2017/09/02/PostgreSQL如何保障数据的一致性.html

MySQL · 源码分析 · InnoDB读写锁实现分析

$
0
0

1 背景

在InnoDB中,当多线程需要访问共享数据结构时,InnoDB使用互斥锁(mutex)和读写锁(rwlock)来同步这些并发操作。InnoDB的读写锁实现并不是对pthread rwlock的直接封装,而是基于原子操作,自旋锁和条件变量进行实现,大大减少了进入内核态进行同步操作的概率,提高了性能,和在现代多核处理器架构下的可扩展性。

本文分析了InnoDB读写锁的具体实现,所有分析基于MySQL 8.0.18代码。

2 锁模式

InnoDB的读写锁有三种基本模式:S(Shared),X(Excluded)和SX(Shared Excluded)。它们的锁兼容性关系如下表所示:

 SSXX
S兼容兼容冲突
SX兼容冲突冲突
X冲突冲突冲突

2.1 SX锁的含义

S和X模式比较好理解是经典的读写锁两种模式。SX模式是对X模式的一种优化,它与读操作的S模式兼容,但是多个SX锁之间是冲突的。

典型的应用场景是对dict_index_t.lock冲突的优化。在过去,当插入操作会造成B+ Tree Node分裂时,使用悲观模式插入记录。此时,需要在dict_index_t.lock上加X锁,要修改的所有相关Leaf Page上加X锁,完成后开始对Branch Node进行修改,而Branch Node上不需要加任何锁。当以这种模式插入时,将阻塞所有在该 B+ Tree上的搜索操作,因为搜索操作的第一步就是在dict_index_t.lock上加S锁。

通过SX锁可以优化该场景:悲观模式的插入操作在dict_index_t.lock上加SX锁,同时在需要修改的Branch Node上加X锁,此时因为在dict_index_t.lock上加的是SX锁,就不会阻塞所有在B+ Tree上的搜索操作,把阻塞范围缩小到访问同一个Branch Node的插入和搜索操作之间。

3 锁状态的维护

InnoDB rw_lock_t 仅使用一个64 bit整型的lock_word就维护了绝大部分的锁状态,其取值含义如下图所示。

4 加解锁的实现

4.1 锁的重入

InnoDB的每个读写锁都可以设置是否开启可重入模式(Recursive)。当使用可重入模式时,同一个线程可以多次获得锁,只需保证加锁总次数与解锁总次数相等即可。更强大的是,可重入模式下,同一个线程可以同时获得一个读写锁的X锁和SX锁,也可以同时获得一个读写锁的SX锁和S锁,但是不能同时获得X锁和S锁。

4.2 加锁逻辑的实现

InnoDB读写锁实现的核心思想是避免使用pthread rwlock,而尽量使用原子操作+自旋的模式来实现加解锁,这样可以在低冲突的场景下,以尽量小的开销实现加解锁。遇到实在是冲突高的读写锁,再使用InnoDB条件变量实现等待。

下面以X锁的加锁逻辑来举例说明InnoDB读写锁加锁的实现。SX锁和S锁的加锁逻辑比较类似,对应代码可以参照阅读。X锁加锁的最终入口函数是rw_lock_x_lock_func,位于sync/sync0rw.cc中。函数签名如下:

void rw_lock_x_lock_func(rw_lock_t *lock, ulint pass, const char *file_name, ulint line);

其中pass参数的含义是如果当前锁上已经有X锁或者是SX锁,是否进入可重入模式。加锁逻辑可以用下面的流程图总结。

4.3 解锁逻辑的实现

下面以X锁的解锁逻辑来举例说明InnoDB读写锁解锁的实现。SX锁和S锁的解锁逻辑比较类似,对应代码可以参照阅读。X锁解锁的最终入口函数是rw_lock_x_unlock_func,位于include/sync0rw.ic中。解锁逻辑可以用下面的流程图总结。

5 X锁所有权的转移

InnoDB读写锁上的X锁所有权是可以在不同线程间转移的,主要用于支持Change Buffer的场景。Change Buffer是一棵全局共享的B+树,存储在系统表空间中。在读取二级索引Page的时候Change Buffer需要与二级索引Page进行合并,这时如果所有IO线程都在读取二级索引Page,将没有IO线程读取Change Buffer Page,因此Change Buffer Page的读取被放到单独的IO线程。而读取二级索引Page的时候,已经对Page加上了X锁,当在异步IO线程需要把Change Buffer合并到二级索引的Page的时候,必须在不解锁的情况下让异步线程获得Page的X锁,这就是X锁所有权转移需要实现的功能。

实现函数是rw_lock_x_lock_move_ownership,实现的逻辑也非常简单,使用CAS原子操作把读写锁的write_thread字段设置为当前线程。

os_thread_id_t curr_thread = os_thread_get_curr_id();
...
local_thread = lock->writer_thread; 
os_compare_and_swap_thread_id(&lock->writer_thread, local_thread, curr_thread);
...

6 总结

本文分析整理了InnoDB读写锁的实现,InnoDB读写锁在兼顾性能和多核可扩展性的同时,提供了强大的功能,包括在典型的读锁和写锁的基础上增加了SX锁来优化锁冲突,可重入的锁语义以及X锁所有权的转移等等,是非常有参考意义的高性能并发同步基础代码。

MySQL · 最佳实践 · X-Engine并行扫描

$
0
0

概述

目前RDS(X-Engine)主打的优势是低成本,高性价比,在MySQL生态下帮助解决用户的成本问题。使用X-Engine的用户一般数据量都较大,比如已经在集团大规模部署的交易历史库,钉钉历史库以及图片空间库等。数据既然存储到了X-Engine,当然也少不了计算需求,因此如何高效执行查询是未来X-Engine一定要解决的问题。目前,在MySQL体系下,每个SQL都至多只能使用一个CPU,由一个线程完成串行扫描行并进行计算。X-Engine引擎需要提供并行扫描能力,这样让一个SQL具备利用多核扫描数据能力,整体缩短SQL执行的响应时间。

串行扫描

在具体介绍X-Engine并行扫描之前,先简单介绍下目前X-Engine串行扫描的逻辑。大家知道数据库引擎的核心区别在于数据结构,比如InnoDB引擎采用B+Tree结构,而X-Engine则采用LSM-Tree结构,两者各有特点,B+Tree结构对读更友好,而LSM-Tree则对写则更友好。

对于一条SQL,在优化器选择好了执行计划以后,扫描数据的方式就确定了,或是走索引覆盖扫描,或是走全表扫描,或者走range扫描等,这点通过执行计划就可以直观的看到。无论哪种方式,本质来说就是两种,一种是点查询,一种是范围查询,查询的数据要么来自索引,要么来自主表。X-Engine的存储架构是一个类LSM-tree的4层结构,包括memtable,L0,L1和L2,每一个层数据都可能有重叠,因此查询时(这里主要讨论范围查询),需要将多层数据进行归并,并根据快照来确定用户可见的记录。

如下图所示,从存储的层次上来看,X-Engine存储架构采用分层思想,包括memtable,L0,L1和L2总共4层,结合分层存储和冷热分离等技术,在存储成本和性能达到一个平衡。数据天然在LSM结构上存在多版本,这种结构对写非常友好,直接追加写到内存即可;而对读来说,则需要合并所有层的数据。

image.png

并行扫描

X-Engine并行扫描要做的就是,将本来一个大查询扫描,拆分成若干小查询扫描,各个查询扫描的数据不存在重叠,所有小查询扫描的并集就是单个大查询需要扫描的记录。并行扫描的依据是,上层计算对于扫描记录的先后顺序没有要求,那么就可以并行对扫描的记录做处理,比如count操作,每扫描一条记录,就对计数累加,多个并发线程可以同时进行。其它类型的聚集操作比如sum,avg等,实际上都符合这个特征,对扫描记录的先后顺序没有要求,最终归结到一点都是需要引擎层支持并行扫描。

如下图所示,X-Engine整个包括4层,其中内存数据用memtable表示,磁盘数据用L0,L1和L2表示,memtable这一层是一个简单的skiplist,每个方框表示一条记录;L0,L1和L2上的每个方框表示一个extent,extent是X-Engine的概念,表示一个大的有序数据块,extent内部由若干个小的block组成,block里面的记录按key有序排列。

根据分区算法得到若干分区后,可以划分分区,不同颜色代表不同的分区,每个分区作为一个并行task投入到队列,worker线程从TaskQueue中获取task扫描,各个worker不需要协同,只需要互斥的访问TaskQueue即可。每个扫描任务与串行执行时并无二样,也是需要合并多路数据,区别在于查询的范围变小了。

image.png

分区算法

将查询按照逻辑key大小划分成若干个分区,各个分区不存在交叠,用户输入一个range[start, end),转换后输出若干[start1,end1),[start2, end2)…[startn,endn),分区数目与线程数相关,将分区数设定在线程数的倍数(比如2倍,具体倍数可调),目的是分区数相对于线程数多一点,以均衡各个线程并行执行速度,提升整体响应时间。

数据按冷热分散存储在memtable,L0,L1和L2这4层,如果数据量很少,可能没有L1或者L2,甚至数据在全内存中。我们讨论大部分情况下,磁盘都是有数据的,并且memtable中的数据相对于磁盘数据较小,主要以磁盘上数据为分区依据。分区逻辑如下:

1).预估查询范围的extent数目

2).根据extent数目与并发线程数比例,预估每个task需要扫描的extent数目

3).对于第一个task,以查询起始key为start_key,根据每个task要处理的extent数目,以task中最后一个extent的largest_key信息作为end_key

4).对于其它task,以区间内第一个extent的smallest_key作为start_key,最后一个extent的largest_key作为end_key

5).对于最后一个task,以区间内第一个extent的smallest_key作为start_key,以用户输入的end_key作为end_key

至此,我们根据配置并发数,将扫描的数据范围划分成了若干分区。在某些情况下,数据可能在各个层次可能分布不均匀,比如写入递增的场景,各层的数据完全没有交集,导致分区不均,因此需要二次拆分,将每个task的粒度拆地更小更均匀。同时,对于重IO的场景(扫描的大部分数据都无法cache命中),X-Engine内部会通过异步IO机制来预读,将计算和IO并行起来,充分发挥机器IO能力和多核计算能力。

对比InnoDB并行扫描

InnoDB引擎也提供了并行扫描的能力,目前主要支持主索引的count和check table操作,而实际上X-Engine通用性更好,无论是主表,还是二级索引都能支持并行扫描。InnoDB与X-Engine一样是索引组织表,InnoDB的每个索引是一个B+tree,每个节点是一个固定大小的page(一般为16k),通过LRU缓存机制实现内外存交换,磁盘上空间通过段/簇/页三级机制管理。InnoDB的更新是原地更新,因此访问具体某个page时,需要latch保护。X-Engine的每个索引是一个LSM-tree,内存中以skiplist存在;外存中数据包括3层L0,L1和L2,按extent划分,通过copy-on-write多版本元数据机制索引extent,每次查询对应的一组extent都是静态的,因此访问时,没有并发冲突,不需要latch保护extent。

存储结构差异导致分区和扫描的逻辑也不一样,InnoDB的分区是基于B+tree物理结构拆分,根据线程数和B+tree的层数来划分,最小粒度能到block级别。X-Engine的分区分为两部分,内存中memtable粒度是记录级;外存中数据是extent级,当然也可以做到block级别。两者最终的目的都是希望充分利用多核CPU资源来进行扫描。下图是InnoDB的分区图。

image.png

InnoDB和X-Engine都是通过MVCC机制解决读不上锁的问题,进行扫描时需要过滤不可见记录和已删除的记录。InnoDB的delete记录有delete-mark,多版本记录存储在特殊的undo段中,并通过指针与原始记录建立关联,事务的可见性通过活跃事务链表判断。X-Engine是追加写方式更新,没有undo机制,多版本数据分布在LSM-tree结构中,delete记录通过delete-type过滤,事务的可见性通过全局提交版本号判断。

InnoDB扫描时,根据key搜索B+tree,定位到叶子节点起始点,通过游标向前遍历;因此分区后,第一次根据start_key搜索到叶子节点的指定记录位置,然后继续往后遍历直到end_key为止。X-Engine扫描时,会先拿一个事务snapshot,然后再拿一个meta-snapshot(访问extent的索引),前者用于记录的可见性判断,后者用于“锁定”一组extent,这样我们有了一个“静态”的LSM-tree。基于分区的范围[start_key,end_key],构建堆进行多路归并,从start_key开始输出记录,到end_key截止。

性能测试

通过配置参数xengine_parallel_read_threads来设置并发线程数,就能开始并行扫描功能,默认这个值为4。我这里做一个简单的实验,通过sysbench导入2亿条数据,分别配置xengine_parallel_read_threads为1,2,4,8,16,32,64,测试下并行执行的效果。测试语句为select count(*) from sbtest1;

测试环境

硬件:96core,768G内存

操作系统:linux centos7

数据库:MySQL8.0.17

配置:xengine_block_cache_size=200G, innodb_buffer_pool_size=200G

采用纯内存测试,所有数据都装载进内存,主要测试并发效果。

测试结果

image.png

横轴是配置并发线程数,纵轴是语句执行时间,蓝色轴是xengine的执行时间,绿色轴是innodb执行的时间,当配置32个并发时,扫描2亿条数据只需要1s左右。

结果分析

可以看到X-Engine和Innodb都具有较好的并发扫描能力,X-Engine表现地更好,尤其是在16线程以后,InnoDB随着线程数上升,执行时间并没有显著下降。这个主要原因是InnoDB的并行是基于物理Btree的拆分,而X-Engine的并行是基于逻辑key的拆分,因此拆分更均匀,基本能随着线程数增加,响应时间成倍地减少。

总结与展望

目前X-Engine的并行扫描还只支持简单count操作,但已经显示出了充分利用CPU多核的能力。我们将继续向上改造执行器接口,以支持更多的并行操作。无论是RDS(X-Engine)还是我们的分布式产品PolarDB-X都将在X-Engine的基础上,让单条SQL跑地更快。

Viewing all 687 articles
Browse latest View live