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

MySQL · 引擎特性 · InnoDB崩溃恢复

$
0
0

前言

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

基础知识

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

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

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

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

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

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

redo日志前滚数据库

前滚数据库,主要分为两阶段,首先是日志扫描阶段,扫描阶段按照数据页的space_id和page_no分发redo日志到hash_table中,保证同一个数据页的日志被分发到同一个哈希桶中,且按照lsn大小从小到大排序。扫描完后,再遍历整个哈希表,依次应用每个数据页的日志,应用完后,在数据页的状态上至少恢复到了崩溃之前的状态。我们来详细分析一下代码。
首先,打开所有的ibdata文件(open_or_create_data_files)(ibdata可以有多个),每个ibdata文件有个flush_lsn在头部,计算出这些文件中的max_flush_lsn和min_flush_lsn,因为ibdata也有可能有数据没写完整,需要恢复,后续(recv_recovery_from_checkpoint_start_func)通过比较checkpoint_lsn和这两个值来确定是否需要对ibdata前滚。
接着,打开系统表空间和日志表空间的所有文件(fil_open_log_and_system_tablespace_files),防止出现文件句柄不足,清空buffer pool(buf_pool_invalidate)。接下来就进入最最核心的函数: (recv_recovery_from_checkpoint_start_func),注意,即使数据库是正常关闭的,也会进入。
虽然recv_recovery_from_checkpoint_start_func看过去很冗长,但是很多代码都是为了LOG_ARCHIVE特性而编写的,真正数据崩溃恢复的代码其实不多。
首先,初始化一些变量,查看srv_force_recovery这个变量,如果用户设置跳过前滚阶段,函数直接返回。
接着,初始化recv_sys结构,分配hash_table的大小,同时初始化flush list rbtree。recv_sys结构主要在崩溃恢复前滚阶段使用。hash_table就是之前说的用来存不同数据页日志的哈希表,哈希表的大小被初始化为buffer_size_in_bytes/512, 这个是哈希表最大的长度,超过就存不下了,幸运的是,需要恢复的数据页的个数不会超过这个值,因为buffer poll最多(数据库崩溃之前脏页的上线)只能存放buffer_size_in_bytes/16KB个数据页,即使考虑压缩页,最多也只有buffer_size_in_bytes/1KB个,此外关于这个哈希表内存分配的大小,可以参考bug#53122。flush list rbtree这个主要是为了加入插入脏页列表,InnoDB的flush list必须按照数据页的最老修改lsn(oldest_modifcation)从小到大排序,在数据库正常运行时,可以通过log_sys->mutex和log_sys->log_flush_order_mutex保证顺序,在崩溃恢复则没有这种保证,应用数据的时候,是从第一个元素开始遍历哈希表,不能保证数据页按照最老修改lsn(oldest_modifcation)从小到大排序,这样就需要线性遍历flush_list来寻找插入位置,效率太低,因此引入红黑树,加快查找插入的位置。
接着,从ib_logfile0的头中读取checkpoint信息,主要包括checkpoint_lsn和checkpoint_no。由于InnoDB日志是循环使用的,且最少要有2个,所以ib_logfile0一定存在,把checkpoint信息存在里面很安全,不用担心被删除。checkpoint信息其实会写在文件头的两个地方,两个checkpoint域轮流写。为什么要两个地方轮流写呢?假设只有一个checkpoint域,一直更新这个域,而checkpoint域有512字节(OS_FILE_LOG_BLOCK_SIZE),如果刚好在写这个512字节的时候,数据库挂了,服务器也挂了(先不考虑硬件的原子写特性,早期的硬件没有这个特性),这个512字节可能只写了一半,导致整个checkpoint域不可用。这样数据库将无法做崩溃恢复,从而无法启动。如果有两个checkpoint域,那么即使一个写坏了,还可以用另外一个尝试恢复,虽然有可能这个时候日志已经被覆盖,但是至少提高了恢复成功的概率。两个checkpoint域轮流写,也能减少磁盘扇区故障带来的影响。checkpoint_lsn之前的数据页都已经落盘,不需要前滚,之后的数据页可能还没落盘,需要重新恢复出来,即使已经落盘也没关系,因为redo日志是幂等的,应用一次和应用两次都一样(底层实现: 如果数据页上的lsn大于等于当前redo日志的lsn,就不应用,否则应用。checkpoint_no可以理解为checkpoint域写盘的次数,每次刷盘递增1,同时这个值取模2可以用来实现checkpoint_no域的轮流写。正常逻辑下,选取checkpoint_no值大的作为最终的checkpoint信息,用来做后续崩溃恢复扫描的起始点。
接着,使用checkpoint域的信息初始化recv_sys结构体的一些信息后,就进入日志解析的核心函数recv_group_scan_log_recs,这个函数后续我们再分析,主要作用就是解析redo日志,如果内存不够了,就直接调用应用(recv_apply_hashed_log_recs)日志,然后再接着解析。如果需要应用的日志很少,就仅仅解析分发日志,到recv_recovery_from_checkpoint_finish函数中在应用日志。
接着,依据当前刷盘的数据页状态做一次checkpoint,因为在recv_group_scan_log_recs里可能已经应用部分日志了。至此recv_recovery_from_checkpoint_start_func函数结束。
recv_recovery_from_checkpoint_finish函数中,如果srv_force_recovery设置正确,就开始调用函数recv_apply_hashed_log_recs应用日志,然后等待刷脏的线程退出(线程是崩溃恢复是临时启动的),最后释放recv_sys的相关资源以及hash_table占用的内存。
至此,数据库前滚结束。接下来,我们详细分析一下redo日志解析函数以及redo日志应用函数的实现细节。

redo日志解析函数

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

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

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

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

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

redo日志应用函数

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

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

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

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

undo日志回滚数据库

recv_recovery_from_checkpoint_start_func之后,recv_recovery_from_checkpoint_finish之前,调用了trx_sys_init_at_db_start,这个函数做了上述三步中的前两步。
第一步在函数trx_rseg_array_init中处理,遍历整个undo日志空间(最多TRX_SYS_N_RSEGS(128)个segment),如果发现某个undo segment非空,就进行初始化(trx_rseg_create_instance)。整个每个undo segment,如果发现undo slot非空(最多TRX_RSEG_N_SLOTS(1024)个slot),也就行初始化(trx_undo_lists_init)。在初始化undo slot后,就把不同类型的undo日志放到不同链表中(trx_undo_mem_create_at_db_start)。undo日志主要分为两种:TRX_UNDO_INSERT和TRX_UNDO_UPDATE。前者主要是提供给insert操作用的,后者是给update和delete操作使用。之前说过,undo日志有两种作用,事务回滚时候用和MVCC快照读取时候用。由于insert的数据不需要提供给其他线程用,所以只要事务提交,就可以删除TRX_UNDO_INSERT类型的undo日志。TRX_UNDO_UPDATE在事务提交后还不能删除,需要保证没有快照使用它的时候,才能通过后台的purge线程清理。
第二步在函数trx_lists_init_at_db_start中进行,由于第一步中,已经在内存中建立起了undo_insert_list和undo_update_list(链表每个undo segment独立),所以这一步只需要遍历所有链表,重建起事务的状态(trx_resurrect_inserttrx_resurrect_update)。简单的说,如果undo日志的状态是TRX_UNDO_ACTIVE,则事务的状态为TRX_ACTIVE,如果undo日志的状态是TRX_UNDO_PREPARED,则事务的状态为TRX_PREPARED。这里还要考虑变量srv_force_recovery的设置,如果这个变量值为非0,所有的事务都会回滚(即事务被设置为TRX_ACTIVE),即使事务的状态应该为TRX_STATE_PREPARED。重建起事务后,按照事务id加入到trx_sys->trx_list链表中。最后,在函数trx_sys_init_at_db_start中,会统计所有需要回滚的事务(事务状态为TRX_ACTIVE)一共需要回滚多少行数据,输出到错误日志中,类似:5 transaction(s) which must be rolled back or cleaned up。InnoDB: in total 342232 row operations to undo的字样。
第三步的操作在两个地方被调用。一个是在recv_recovery_from_checkpoint_finish的最后,另外一个是在recv_recovery_rollback_active中。前者主要是回滚对数据字典的操作,也就是回滚DDL语句的操作,后者是回滚DML语句。前者是在数据库可提供服务之前必须完成,后者则可以在数据库提供服务(也即是崩溃恢复结束)之后继续进行(通过新开一个后台线程trx_rollback_or_clean_all_recovered来处理)。因为InnoDB认为数据字典是最重要的,必须要回滚到一致的状态才行,而用户表的数据可以稍微慢一点,对外提供服务后,慢慢恢复即可。因此我们常常在会发现数据库已经启动起来了,然后错误日志中还在不断的打印回滚事务的信息。事务回滚的核心函数是trx_rollback_or_clean_recovered,逻辑很简单,只需要遍历trx_sys->trx_list,按照事务不同的状态回滚或者提交即可(trx_rollback_resurrected)。这里要注意的是,如果事务是TRX_STATE_PREPARED状态,那么在InnoDB层,不做处理,需要在Server层依据binlog的情况来决定是否回滚事务,如果binlog已经写了,事务就提交,因为binlog写了就可能被传到备库,如果主库回滚会导致主备数据不一致,如果binlog没有写,就回滚事务。

崩溃恢复相关参数解析

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

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

总结

InnoDB实现了一套完善的崩溃恢复机制,保证在任何状态下(包括在崩溃恢复状态下)数据库挂了,都能正常恢复,这个是与文件系统最大的差别。此外,崩溃恢复通过redo日志这种物理日志来应用数据页的方法,给MySQL Replication带来了新的思路,备库是否可以通过类似应用redo日志的方式来同步数据呢?阿里云RDS MySQL团队在后续的产品中,给大家带来了类似的特性,敬请期待。


PgSQL · 应用案例 · 阿里云RDS金融数据库(三节点版) - 背景篇

$
0
0

背景

提到金融级数据库,大家可能不约而同的会想到Oracle,DB2等商业数据库。但是随着开源数据库的发展,开源数据库正在逐渐成为数据库产业的核心,比如MySQL、PostgreSQL数据库 ,已经深入阿里、平安科技、苏宁、高德、国家电网(还有很多)的核心。可以看到,不管是MySQL还是PostgreSQL,有越来越多成功的核心应用案例。

目前还有一些金融企业核心数据库依旧是老牌的商业数据库,个人认为并不是这些商业数据库比开源数据库有多优秀,而是牵一发而动全身,非单纯技术层面的问题。特别是关系民生的金融行业,更换数据库可不是那么容易。

开源数据库在新生业务中是有巨大机会的,毕竟社会是在不断进步和发展的,老物件会逐渐成为人们的回忆,消失在历史的长河里。

不管是商业数据库,还是开源数据库,在金融行业混,都必须跨过一道坎:高可用。

(当然,不可否认,解决金融问题,除了高可用,还有更多,包括 功能,性能,SQL标准 方方面面。不在本系列文章讨论范畴)

硬件为王 - 传统数据库高可用架构

实际上扛起金融核心大旗的还不算Oracle,背后的硬件才是真正的王者,估计也是Oracle收购SUN的原因之一(感叹一下,SUN的ZFS至今无人能及)。

IBM 大机、小机、高端存储,以其稳定性、可用性、性能等方面的卓越表现征服了当时的市场。而软件层面,实际上更多的是围绕硬件来进行设计,包括Oracle的RAC架构,也是需要依赖共享存储的。

生态的原因,在硬件为王时代的数据库,由于硬件的强势,数据库软件依附这些硬件,这也是为什么又这么多基于共享存储的高可用的架构。

pic

传统数据库的高可用架构存在的问题

价格昂贵,集中式存储单点故障(好的存储可能会在 链路、机头、存储介质、电源模块、内部背板等 层面全面解决单点问题)

pic

如果存储层存在单点(不管是机头还是链路或者其他),软件层面需要再做一层mirror或RAID冗余,例如LVM,ZFS,ASM等技术,但是存储的强一致一定会引入RT(需要软件层弥补,例如事务分组提交、异步WAL等)。

pic

甚至大量的容灾方案,也是出自存储硬件厂商之手,因为除了硬件厂商,没有人更了解如何对存储实现异地冗余了。

弯道超车 - 开源数据库高可用架构

随着x86硬件架构(以及对应的软件生态freebsd,linux等)、SSD硬盘的发展,到现在GPU\FPGA\TPU等芯片及其软件生态的成长。开放性硬件在功能、软件生态、硬件性能等方面全面提升,以IBM为代表的封闭式硬件逐渐失去了核心地位。

业务的发展和开放性硬件生态的发展,助长了开源数据库的发展,MySQL、PostgreSQL数据库就是非常典型的代表。

开放性使得更多的用户可以获取到,更多的用户又助长了软件本身的发展,这使得最近10年开源数据库已经开始全面超越商业数据库。最典型的例子是PostgreSQL,从SQL兼容性,硬件生态对接(LLVM,向量计算,多核并行,GPU计算等),软件生态对接(PL/R, PL/JAVA, PL/Python, PL/CUDA, 机器学习库等等),扩展性(9种扩展索引接口支持各种类型的检索,扩展类型支持DNA、图像特征值、化学类型等,扩展语言接口、扩展外部数据源接口等),云生态(RDS PG OSS可并行读写OSS海量存储外部表)等各个方面全面超越商业数据库。

开源数据库通过内部的复制,实现了高可用架构的弯道超车。以MySQL为代表的binlog复制,以PostgreSQL为代表的stream replication。

开源数据库采样通用硬件,多节点,更低的成本,更优秀的扩展性,解决了用户的高可用问题。

两节点方案

pic

两节点的HA方案,属于廉价的解决方案,无法同时保证高可用和高可靠。

要保证高可靠(0数据丢失),就必须等BINLOG或WAL复制到备库才返回,备库只要稍有抖动或者备库故障,就会导致可用性下降。(也就是说,主备任何一个异常都会影响可用性)。

两节点方案采用自动降级机制,在备库正常的情况下,采用同步模式(数据需要写双份才返回给用户),保证可用性和可靠性。在备库异常时,则自动降级为异步,只能保证可用性(可靠性无法保证,如果此时主库挂了,备库恢复,发生HA切换,可能导致部分未同步的数据丢失)。

阿里云RDS率先推出三节点方案,同时保证数据库的高可靠和高可用,满足了金融行业高可用和零数据丢失的需求。

三节点方案

pic

可靠性保证:三节点方案中,用户在提交事务时,需要等待至少一个备库收到日志副本,才返回给用户事务成功结束的信号,确保数据库的可靠性(用户收到确认的事务,已持久化到多数派主机中)。

可用性保证:三节点方案中,即使一台服务器挂掉(无论哪台),也不影响业务的可用性,因为已提交的数据至少有2份副本,挂掉一台,还有至少1台主机是包含了已提交事务的持久化内容的。

多节点引入的世界问题

多节点同时解决了可用性、可靠性的问题。但是实现并非易事,在解决可用性问题时,会涉及到另一个问题,因为异常时需要选出一个新的主库,什么情况下开始选举?选谁?都是问题。

选主问题有一个非常著名的典故,拜占庭将军的问题。

以下截取自互联网:

拜占庭位于如今的土耳其的伊斯坦布尔,是东罗马帝国的首都。由于当时拜占庭罗马帝国国土辽阔,为了防御目的,军队相隔很远,将军与将军之间靠信差传消息。进行军事决策时,所有将军必需达成 “一致的共识”。但是,在军队内有可能存有叛徒和敌军的间谍,左右将军们的决定,在进行共识时,结果并不一定代表大多数人的意见。于是在已知有成员不可靠的情况下,其余忠诚的将军在不受叛徒或间谍的影响下如何达成一致的协议,拜占庭问题就此形成。

拜占庭假设是对现实世界的模型化,由于硬件错误、网络拥塞或断开以及遭到恶意攻击,计算机和网络可能出现不可预料的行为。和我们提到的三节点要解决的问题是一致的。

下一篇《阿里云RDS金融数据库(三节点版) - 背景篇》将讲解RDS三节点的理论基础 - Raft协议。

pic

系列文章

《阿里云RDS金融数据库(三节点版) - 背景篇》

《阿里云RDS金融数据库(三节点版) - 理论篇》(敬请期待)

《阿里云RDS金融数据库(三节点版) - 实现篇》(敬请期待)

《阿里云RDS金融数据库(三节点版) - 性能篇》(敬请期待)

《阿里云RDS金融数据库(三节点版) - 案例篇》(敬请期待)

阿里云RDS金融数据库(三节点版)

阿里云RDS金融数据库 - MySQL三节点版

阿里云RDS金融数据库 - PostgreSQL三节点版(敬请期待)

AliSQL · 特性介绍 · 支持 Invisible Indexes

$
0
0

前言

MySQL 8.0 引入了 Invisible Indexes 这一个特性,对于 DBA 同学来说是一大福音,索引生命周期管理除了有和无外,又多了一种形态–可见和不可见,进而对业务SQL的调优又多了一种手段。

关于 Invisible Indexes,不管是官方还是第三方,都有非常多的介绍文档,这里推荐大家可以先看下:

  1. 官方文档: Invisible Indexes
  2. 官方 server 层团队博客: MySQL 8.0: Invisible Indexes
  3. 官方 worklog: WL#8697: Support for INVISIBLE indexes
  4. Percona blog: Thoughts on MySQL 8.0 Invisible Indexes
  5. 我们的 weixiang 同学的文章:MySQL · 8.0新特性· Invisible Index

简单来说,Invisible Indexes 的特点是:对优化器来说是不可见的,但是引擎内部还是会维护这个索引,并且不可见属性的修改操只改了元数据,所以可以非常快。
当我们发现某个索引不需要,想要去掉的话,可以先把索引设置为不可见,观察下业务的反应,如果一切正常,就可以 drop 掉;如果业务有受影响,那么说明这个索引删掉会有问题,就可以快速改回来。所以相对于 DROP/ADD 索引这种比较重的操作,Invisible Indexes 就会显得非常灵活方便。

Invisible Indexes 是 server 层的特性,和引擎无关,因此所有引擎(InnoDB, TokuDB, MyISAM, etc.)都可以使用。

MySQL 官方只在 8.0 版本中支持了这一特性,考虑到 8.0 的普及还比较遥远,为了让大家能早日用上这么好的功能,我们将 Invisible Indexes 这一特性 backport 到 AliSQL分支,目前开源分支已经支持,大家可以下载使用。

用法介绍

虽然官方文档里有详细的使用介绍,本文为了完整性,也简单介绍下使用方法。

  1. CREATE TABLE: 我们可以在建表时指定索引的不可见属性,默认是可见的。

     CREATE TABLE `t1` (
       `id` int(11) DEFAULT NULL,
       `tid` int(11) DEFAULT NULL,
       KEY `idx_tid` (`tid`) INVISIBLE
     ) ENGINE=InnoDB;
    
  2. ADD INDEX: 我们可以在后续加索引时,指定加的索引是否可见

     CREATE TABLE `t1` (
       `id` int(11) DEFAULT NULL,
       `tid` int(11) DEFAULT NULL
       ) ENGINE=InnoDB;
     CREATE INDEX idx_tid ON t1(tid) INVISIBLE;
     ALTER TABLE t1 ADD INDEX idx_tid(tid) INVISIBLE;
    
  3. ALTER INDEX: 我们可以在后续使用时,更改已有索引的可见性

     CREATE TABLE `t1` (
       `id` int(11) DEFAULT NULL,
       `tid` int(11) DEFAULT NULL,
       KEY `idx_tid` (`tid`) INVISIBLE
     ) ENGINE=InnoDB;
     ALTER TABLE t1 ALTER INDEX idx_tid VISIBLE;
    
  4. 展示信息增加:INFORMATION_SCHEMA.STATISTICS内存表和 SHOW INDEX结果里,分别多了一个 Visible/IS_VISIBLE 字段,表示索引是否可见:

     mysql> SHOW INDEX FROM t1\G
     *************************** 1. row ***************************
     Table: t1
     Non_unique: 1
     Key_name: idx_tid
     Seq_in_index: 1
     Column_name: tid
     Collation: A
     Cardinality: 0
     Sub_part: NULL
     Packed: NULL
     Null: YES
     Index_type: BTREE
     Comment:
     Index_comment:
     Visible: NO
    
     mysql> SELECT * FROM INFORMATION_SCHEMA.STATISTICS where table_name='t1' AND index_name='idx_tid'\G
     *************************** 1. row ***************************
     TABLE_CATALOG: def
     TABLE_SCHEMA: test
     TABLE_NAME: t1
     NON_UNIQUE: 1
     INDEX_SCHEMA: test
     INDEX_NAME: idx_tid
     SEQ_IN_INDEX: 1
     COLUMN_NAME: tid
     COLLATION: A
     CARDINALITY: 0
     SUB_PART: NULL
     PACKED: NULL
     NULLABLE: YES
     INDEX_TYPE: BTREE
     COMMENT:
     INDEX_COMMENT:
     IS_VISIBLE: NO
     1 row in set (0.00 sec)
    

下面我们用一例子来看下:

CREATE TABLE `t1` (
  `id` int(11) DEFAULT NULL,
  `tid` int(11) DEFAULT NULL,
  KEY `idx_tid` (`tid`) /*!50616 INVISIBLE */
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
INSERT INTO t1 VALUES(1, 2), (3, 4), (5, 6), (7, 8), (9, 10);

可以看到下面的 EXPLAIN 结果,用的是全表扫描:

mysql> EXPLAIN SELECT * FROM t1 WHERE tid=4;
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows | Extra       |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
|  1 | SIMPLE      | t1    | ALL  | NULL          | NULL | NULL    | NULL |    5 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.00 sec)

如果用 FORCE INDEX 强制指定的话,会报索引不存在的错(这个官方早期版本是不会报错的,最新新版本已经fix):

mysql> EXPLAIN SELECT * FROM t1 FORCE INDEX(idx_tid) WHERE tid=4;
ERROR 1176 (42000): Key 'idx_tid' doesn't exist in table 't1'

索引改为可见之后,优化器就可以用了:

mysql> ALTER TABLE t1 ALTER INDEX idx_tid VISIBLE;
mysql> EXPLAIN SELECT * FROM t1 WHERE tid=4;
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key     | key_len | ref   | rows | Extra |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------+
|  1 | SIMPLE      | t1    | ref  | idx_tid       | idx_tid | 5       | const |    1 | NULL  |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------+
1 row in set (0.00 sec)

虽然索引对优化器不可见,但是 MySQL 内部还是会维护索引的,包括约束条件,可以看下面这个例子:

CREATE TABLE `t2` (
`id` int(11) NOT NULL DEFAULT '0',
`tid` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_tid` (`tid`) INVISIBLE
) ENGINE=InnoDB;

mysql> INSERT INTO t2 VALUES (1, 2), (3, 4);
Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> INSERT INTO t2 VALUES (5, 2);
ERROR 1062 (23000): Duplicate entry '2' for key 'idx_tid'

可以看到虽然 idx_tid索引不可见,但是 UNIQUE 约束还是被遵守的。

使用注意和实现区别

使用注意:
1. PK 不能设置为不可见,这里的 PK 包括显示的PK,或者因为PK不存在,被提升为 PK 的 UK;
2. 虽然设置索引的不可见属性不需要重建表,但是改变了表定义(frm),需要重新打开表,因此会请求 MDL 排它锁,如果有大事务或者长SQL,会被 block,这点使用时需要注意;
3. INFORMATION_SCHEMA.STATISTICS 内存表和 SHOW INDEX 结果里多一个字段,如果有用到的话,需要做好兼容。

另外 AliSQL 支持索引使用统计(INFORMATION_SCHEMA.INDEX_STATISTICS),和 Invisible Indexes 配合使用效果更佳,比如我们可以根据索引使用找出使用频率低的索引,然后快速设置为不可见,如果业务没有影响的话,就可以进一步 DROP 掉索引。

实现上区别:
官方的 INVISIBLE INDEX 是实现在 8.0 里的,而在 8.0 其中一个重大改变,就是引入了 Data Dictionary,把原来在 Server 层放的元文件(.frm, .par, etc.)里的信息,全放在 InnoDB 里了。AliSQL 是 5.6 版本的,因此在元信息还是存储在 frm 文件里。这里有一个问题是,其中索引标志位占2个字节,目前16个 bit 已经全部被定义,如果扩展标志位的话,会造成不兼容,因为这里用了一个原先不会存在 frm 里flag HA_SORT_ALLOWS_SAME来存储在 frm 表示索引不可见,这是为了保证兼容性,实现上比较 trick 的地方。

TokuDB · 引擎特性 · HybridDB for MySQL高压缩引擎TokuDB 揭秘

$
0
0

HybridDB for MySQL(原名petadata)是面向在线事务(OLTP)和在线分析(OLAP)混合场景的关系型数据库。HybridDB采用一份数据存储来进行OLTP和OLAP处理,解决了以往需要把一份数据多次复制来分别进行业务交易和数据分析的问题,极大地降低了数据存储的成本,缩短了数据分析的延迟,使得实时分析决策称为可能。

HybridDB for MySQL兼容MySQL的语法及函数,并且增加了对Oracle常用分析函数的支持,100%完全兼容TPC-H和TPC-DS测试标准,从而降低了用户的开发、迁移和维护成本。

TokuDB是TokuTek公司(已被 Percona收购)研发的新引擎,支持事务/MVCC,有着出色的数据压缩功能,支持异步写入数据功能。

TokuDB索引结构采用fractal tree数据结构,是buffer tree的变种,写入性能优异,适合写多读少的场景。除此之外,TokuDB还支持在线加减字段,在线创建索引,锁表时间很短。

Percona Server和Mariadb支持TokuDB作为大数据场景下的引擎,目前官方MySQL还不支持TokuDB。ApsaraDB for MySQL从2015年4月开始支持TokuDB,在大数据或者高并发写入场景下推荐使用。

TokuDB优势

数据压缩

TokuDB最显著的优势就是数据压缩,支持多种压缩算法,用户可按照实际的资源消耗修改压缩算法,生产环境下推荐使用zstd,实测的压缩比是4:1。

目前HybridDB for MySQL支持6中压缩算法:

  • lzma: 压缩比最高,资源消耗高
  • zlib:Percona默认压缩算法,最流行,压缩比和资源消耗适中
  • quicklz:速度快,压缩比最低
  • snappy:google研发的,压缩比较低,速度快
  • zstd:压缩比接近zlib,速度快
  • uncompressed:不压缩,速度最快

Percona建议6核以下场景使用默认压缩算法zlib,6核以上可以使用压缩率更高的压缩算法,大数据场景下推荐使用zstd压缩算法,压缩比高,压缩和解压速度快,也比较稳定。

用户可以在建表时使用ROW_FORMAT子句指定压缩算法,也可用使用ALTER TABLE修改压缩算法。ALTER TABLE执行后新数据使用新的压缩算法,老数据仍是老的压缩格式。

mysql> CREATE TABLE t_test (column_a INT NOT NULL PRIMARY KEY, column_b INT NOT NULL) ENGINE=TokuDB ROW_FORMAT=tokudb_zstd;

mysql> SHOW CREATE TABLE t_test\G
       Table: t_test
Create Table: CREATE TABLE `t_test` (
  `column_a` int(11) NOT NULL,
  `column_b` int(11) NOT NULL,
  PRIMARY KEY (`column_a`)
) ENGINE=TokuDB DEFAULT CHARSET=latin1 ROW_FORMAT=TOKUDB_ZSTD

mysql> ALTER TABLE t_test ROW_FORMAT=tokudb_snappy;

mysql> SHOW CREATE TABLE t_test\G
       Table: t_test
Create Table: CREATE TABLE `t_test` (
  `column_a` int(11) NOT NULL,
  `column_b` int(11) NOT NULL,
  PRIMARY KEY (`column_a`)
) ENGINE=TokuDB DEFAULT CHARSET=latin1 ROW_FORMAT=TOKUDB_SNAPPY

TokuDB采用块级压缩,每个块大小是4M,这是压缩前的大小;假设压缩比是4:1,压缩后大小是1M左右。比较tricky地方是:TokuDB压缩单位是partition,大小是64K。相比innodb16K的块大小来说要大不少,更有利压缩算法寻找重复串。

上面提到,修改压缩算法后新老压缩格式的数据可以同时存在。如何识别呢?

每个数据块在压缩数据前预留一个字节存储压缩算法。从磁盘读数据后,会根据那个字节的内容调用相应的解压缩算法。

另外,TokuDB还支持并行压缩,数据块包含的多个partition可以利用线程池并行进行压缩和序列化工作,极大加速了数据写盘速度,这个功能在数据批量导入(import)情况下开启。

在线增减字段

TokuDB还支持在轻微阻塞DML情况下,增加或删除表中的字段或者扩展字段长度。

执行在线增减字段时表会锁一小段时间,一般是秒级锁表。锁表时间短得益于fractal tree的实现。TokuDB会把这些操作放到后台去做,具体实现是:往root块推送一个广播msg,通过逐层apply这个广播msg实现增减字段的操作。

需要注意的:
- 不建议一次更新多个字段
- 删除的字段是索引的一部分会锁表,锁表时间跟数据量成正比
- 缩短字段长度会锁表,锁表时间跟数据量成正比

mysql> ALTER TABLE t_test ADD COLUMN column_c int(11) NOT NULL;

mysql> SHOW CREATE TABLE t_test\G
       Table: t_test
Create Table: CREATE TABLE `t_test` (
  `column_a` int(11) NOT NULL,
  `column_b` int(11) NOT NULL,
  `column_c` int(11) NOT NULL,
  PRIMARY KEY (`column_a`),
  KEY `ind_1` (`column_b`)
) ENGINE=TokuDB DEFAULT CHARSET=latin1 ROW_FORMAT=TOKUDB_SNAPPY

mysql> ALTER TABLE t_test DROP COLUMN column_b;

mysql> SHOW CREATE TABLE t_test\G

       Table: t_test
Create Table: CREATE TABLE `t_test` (
  `column_a` int(11) NOT NULL,
  `column_c` int(11) NOT NULL,
  PRIMARY KEY (`column_a`)
) ENGINE=TokuDB DEFAULT CHARSET=latin1

稳定高效写入性能

TokuDB索引采用fractal tree结构,索引修改工作由后台线程异步完成。TokuDB会把每个索引更新转化成一个msg,在server层上下文只把msg加到root(或者某个internal)块msg buffer中便可返回;msg应用到leaf块的工作是由后台线程完成的,此后台线程被称作cleaner,负责逐级apply msg直至leaf块

DML语句被转化成FT_INSERT/FT_DELETE,此类msg只应用到leaf节点。

在线加索引/在线加字段被转化成广播msg,此类msg会被应用到每个数据块的每个数据项。

实际上,fractal tree是buffer tree的变种,在索引块内缓存更新操作,把随机请求转化成顺序请求,缩短server线程上下文的访问路径,缩短RT。所以,TokuDB在高并发大数据量场景下,可以提供稳定高效的写入性能。

除此之外,TokuDB实现了bulk fetch优化,range query性能也是不错的。

在线增加索引

TokuDB支持在线加索引不阻塞更新语句 (insert, update, delete) 的执行。可以通过变量 tokudb_create_index_online 来控制是否开启该特性, 不过遗憾的是目前只能通过 CREATE INDEX 语法实现在线创建;如果用ALTER TABLE创建索引还是会锁表的。

mysql> SHOW CREATE TABLE t_test\G
       Table: t_test
Create Table: CREATE TABLE `t_test` (
  `column_a` int(11) NOT NULL,
  `column_b` int(11) NOT NULL,
  PRIMARY KEY (`column_a`)
) ENGINE=TokuDB DEFAULT CHARSET=latin1 ROW_FORMAT=TOKUDB_SNAPPY

mysql> SET GLOBAL tokudb_create_index_online=ON;

mysql> CREATE INDEX ind_1 ON t_test(column_b);

mysql> SHOW CREATE TABLE t_test\G
       Table: t_test
Create Table: CREATE TABLE `t_test` (
  `column_a` int(11) NOT NULL,
  `column_b` int(11) NOT NULL,
  PRIMARY KEY (`column_a`),
  KEY `ind_1` (`column_b`)
) ENGINE=TokuDB DEFAULT CHARSET=latin1 ROW_FORMAT=TOKUDB_SNAPPY

写过程

如果不考虑unique constraint检查,TokuDB写是异步完成的。每个写请求被转化成FT_insert类型的msg,记录着要写入的<key,value>和事务信息用于跟踪。

Server上下文的写路径很短,只要把写请求对应的msg追加到roo数据块的msg buffer即可,这是LSM数据结构的核心思想,把随机写转换成顺序写,LevelDB和RocksDB也是采用类似实现。

由于大家都在root数据块缓存msg,必然造成root块成为热点,也就是性能瓶颈。

为了解决这个问题,TokuDB提出promotion概念,从root数据块开始至多往下看2层。如果当前块数据块是中间块并且msg buffer是空的,就跳过这层,把msg缓存到下一层中间块。

下面我们举例说明write过程。

假设,insert之qiafractal tree状态如下图所示:

image.png

  • insert 300

root数据块上300对应的msg buffer为空,需要进行inject promotion,也就是说会把msg存储到下面的子树上。下一级数据块上300对应的msg buffer非空(msg:291),不会继续promotion,msg被存储到当前的msg buffer。

image.png

  • insert 100

root数据块上100对应的msg buffer为空,需要进行inject promotion,也就是说会把msg存储到下面的子树上。下一级数据块上100对应的msg buffer也为空,需要继续promotion。再下一级数据块上100对应的msg buffer非空(msg:84),不会继续promotion,msg被存储到当前的msg buffer。

image.png

  • insert 211

root数据块上211对应的msg buffer为空,需要进行inject promotion,也就是说会把msg存储到下面的子树上。下一级数据块上211对应的msg buffer也为空,需要继续promotion。再下一级数据块上211对应的msg buffer也为空,但是不会继续promotion,msg被存储到当前的msg buffer。这是因为promotion至多向下看2层,这么做是为了避免dirty的数据块数量太多,减少checkpoint刷脏的压力。

image.png

行级锁

TokuDB提供行级锁处理并发读写数据。

所有的INSERT、DELETE或者SELECT FOR UPDATE语句在修改索引数据结构fractal tree之前,需要先拿记录(也就是key)对应的行锁,获取锁之后再去更新索引。与InnoDB行锁实现不同,InnoDB是锁记录数据结构的一个bit。

由此可见,TokuDB行锁实现导致一些性能问题,不适合大量并发更新的场景。

为了缓解行锁等待问题,TokuDB提供了行锁timeout参数(缺省是4秒),等待超时会返回失败。这种处理有助于减少deadlock发生。

读过程

由于中间数据块(internal block)会缓存更新操作的msg,读数据时需要先把上层msg buffer中的msg apply到叶子数据块(leaf block)上,然后再去leaf上把数据读上来。

image.png

3,4,5,6,7,8,9是中间数据块,10,11,12,13,14,15,16,17是叶子数据块;

上图中,每个中间数据块的fanout是2,表示至多有2个下一级数据块;中间节点的msg buffer用来缓存下一级数据块的msg,橘黄色表示有数据,黄绿色表示msg buffer是空的。

如果需要读block11的数据,需要先把数据块3和数据块6中的msg apply到叶子数据块11,然后去11上读数据。

Msg apply的过程也叫合并(merge),所有基于LSM原理的k-v引擎(比方LevelDB,RocksDB)读数据时都要先做merge,然后去相应的数据块上读数据。

读合并

image.png

如上图所示,绿色是中间数据块,紫色是叶数据块;中间数据块旁边的黄色矩形是msg buffer。

如要要query区间[5-18]的数据
- 以5作为search key从root到leaf搜索>=5的数据,每个数据块内部做binary search,最终定位到第一个leaf块。读数据之前,判断第一个leaf块所包含的[5,9]区间存在需要apply的msg(上图中是6,7,8),需要先做msg apply然后读取数据(5,6,7,8,9);
- 第一个leaf块读取完毕,以9作为search key从root到leaf搜索>9的数据,每个数据块内部做binary search,最终定位到第二个leaf块。读数据之前,判断第二个leaf块所包含的[10,16]区间存在需要apply的msg(上图中是15),需要先做msg apply然后读取数据(10,12,15,16);
- 第二个leaf块读取完毕,以16作为search key从root到leaf搜索>16的数据,每个数据块内部做binary search,最终定位到第三个leaf块。第三个数据块所包含的[17,18]区间不存在需要apply的msg,直接读取数据(17,18)。

优化range query

为了减少merge代价,TokuDB提供bulk fetch功能:每个basement node大小64K(这个是数据压缩解压缩的单位)只要做一次merge操作;并且TokuDB的cursor支持批量读,一个batch内读取若干行数据缓存在内存,之后每个handler::index_next先去缓存里取下一行数据,只有当缓存数据全部被消费过之后发起下一个batch读,再之后handler::index_next操作还是先去缓存里取下一行数据。

image.png

Batch读过程由cursor的callback驱动,直接把数据存到TokuDB handler的buffer中,不仅减少了merge次数,也减少了handler::index_next调用栈深度。

异步合并

TokuDB支持后台异步合并msg,把中间数据块中缓存的msg逐层向下刷,直至leaf数据块。

这过程是由周期运行的cleaner线程完成的,cleaner线程每秒被唤醒一次。每次执行扫描一定数目的数据块,寻找缓存msg最多的中间数据块;扫描结束后,把msg buffer中的msg刷到(merge)下一层数据块中。

image.png

前面提到,大部分写数据并不会把msg直接写到leaf,而是把msg缓存到root或者某一级中间数据块上。虽然promotion缓解了root块热点问题,局部热点问题依然存在。

假设某一个时间段大量并发更新某范围的索引数据,msg buffer短时间内堆积大量msg;由于cleaner线程是单线程顺序扫描,很可能来不及处理热点数据块,导致热点数据msg堆积,并且数据块读写锁争抢现象越来越严重。

为了解决这个问题,TokuDB引入了专门的线程池来帮助cleaner线程快速处理热点块。大致处理是:如果msg buffer缓存了过多的msg,写数据上下文就会唤醒线程池中的线程帮助cleaner快速合并当前数据块。

刷脏

为了加速数据处理过程,TokuDB在内存缓存数据块,所有数据块组织成一个hash表,可以通过hash计算快速定位,这个hash表被称作cachetable。InnoDB也有类似缓存机制,叫做buffer pool(简记bp)。

内存中数据块被修改后不会立即写回磁盘,而是被标记成dirty状态。Cachetable满会触发evict操作,选择一个victim数据块释放内存。如果victim是dirty的,需要先把数据写回。Evict操作是由后台线程evictor处理的,缺省1秒钟运行一次,也可能由于缓存满由server上下文触发。

TokuDB采用激进的缓存策略,尽量把数据保留在内存中。除了evictor线程以外,还有一个定期刷脏的checkpoint线程,缺省60每秒运行一次把内存中所有脏数据回刷到磁盘上。Checkpoint结束后,清理redo log文件。

TokuDB采用sharp checkpoint策略,checkpoint开始时刻把cachetable中所有数据块遍历一遍,对每个数据块打上checkpoint_pending标记,这个过程是拿着client端exclusive锁的,所有INSERT/DELETE操作会被阻塞。标记checkpoint_pending过程结束后,释放exclusive锁,server的更新请求可以继续执行。

随后checkpoint线程会对每个标记checkpoint_pending的脏页进行回写。为了减少I/O期间数据块读写锁冲突,先把数据clone一份,然后对cloned数据进行回写;clone过程是持有读写锁的write锁,clone结束后释放读写锁,数据块可以继续提供读写服务。Cloned数据块写回时,持有读写I/O的mutex锁,保证on-going的I/O至多只有一个。

更新数据块发现是checkpoint_pending并且dirty,那么需要先把老数据写盘。由于checkpoint是单线程,可能来不及处理这个数据块。为此,TokuDB提供一个专门的线程池,server上下文只要把数据clone一份,然后把回写cloned数据的任务扔给线程池处理。

Cachetable

所有缓存在内存的数据块按照首次访问(cachemiss)时间顺序组织成clock_list。TokuDB没有维护LRU list,而是使用clock_list和count(可理解成age)来模拟数据块使用频率。

Evictor,checkpoint和cleaner线程(参见异步合并小结)都是扫描clock_list,每个线程维护自己的head记录着下次扫描开始位置。

image.png

如上图所示,hash中黑色连线表示bucket链表,蓝色连线表示clock_list。Evictor,checkpoint和cleaner的header分别是m_clock_head,m_checkpoint_head和m_cleaner_head。

数据块被访问,count递增(最大值15);每次evictor线程扫到数据块count递减,减到0整个数据块会被evict出去。

TokuDB块size比较大,缺省是4M;所以按照块这个维度去做evict不是特别合理,有些partition数据比较热需要在内存多呆一会,冷的partition可以尽早释放。

为此,TokuDB还提供partial evict功能,数据块被扫描时,如果count>0并且是clean的,就把冷partition释放掉。Partial evict对中间数据块(包含key分布信息)做了特殊处理,把partition转成压缩格式减少内存使用,后续访问需要先解压缩再使用。Partial evict对leaf数据块的处理是:把partition释放,后续访问需要调用pf_callback从磁盘读数据,读上来的数据也是先解压缩的。

写优先

这里说的写优先是指并发读写数据块时,写操作优先级高,跟行级锁无关。

image.png

假设用户要读区间[210, 256],需要从root->leaf每层做binary search,在search之前要把数据块读到内存并且加readlock。

如上图所示,root(height 3)和root子数据块(height 2)尝试读锁(try_readlock)成功,但是在root的第二级子数据块(height 1)尝试读锁失败,这个query会把root和root子数据块(height 2)读锁释放掉,退回到root重新尝试读锁。

日志

TokuDB采用WAL(Write Ahead Log),每个INSERT/DELETE/CREATE INDEX/DROP INDEX操作之前会记redo log和undo log,用于崩溃恢复和事务回滚。

TokuDB的redo log是逻辑log,每个log entry记录一个更新事件,主要包含:
- 长度1
- log command(标识操作类型)
- lsn
- timestamp
- 事务id
- crc
- db
- key
- val
- 长度2

其中,db,key和val不是必须的,比如checkpoint就没有这些信息。

长度1和长度2一定是相等的,记两个长度是为了方便前向(backward)和后向(forward)扫描。

Recory过程首先前向扫描,寻找最后一个有效的checkpoint;从那个checkpoint开始后向扫描回放redo log,直至最后一个commit事务。然后把所有活跃事务abort掉,最后做一个checkpoint把数据修改同步到磁盘上。

TokuDB的undo日志是记录在一个单独的文件上,undo日志也是逻辑的,记录的是更新的逆操作。独立的undo日志,避免老数据造成数据空间膨胀问题。

事务和MVCC

相对RocksDB,TokuDB最显著的优势就是支持完整事务,支持MVCC。

TokuDB还支持事务嵌套,可以用来实现savepoint功能,把一个大事务分割成一组小事务,小事务失败只要重试它自己就好了,不用回滚整个事务。

ISOLATION LEVEL

TokuDB支持隔离级别:READ UNCOMMITTED, READ COMMITTED (default), REPEATABLE READ, SERIALIZABLE。SERIALIZABLE是通过行级锁实现的;READ COMMITTED (default),和REPEATABLE READ是通过snapshot实现。

TokuDB支持多版本,多版本数据是记录在页数据块上的。每个leaf数据块上的<key,value>二元组,key是索引的key值(其实是拼了pk的),value是MVCC数据。这与oracle和InnoDB不同,oracle的多版本是通过undo segment计算构造出来的。InnoDB MVCC实现原理与oracle近似。

事务的可见性

每个写事务开始时都会获得一个事务id(TokuDB记做txnid,InnoDB记做trxid)。其实,事务id是一个全局递增的整数。所有的写事务都会被加入到事务mgr的活跃事务列表里面。

所谓活跃事务就是处于执行中的事务,对于RC以上隔离界别,活跃事务都是不可见的。前面提到过,SERIALIZABLE是通过行级锁实现的,不必考虑可见性。

一般来说,RC可见性是语句级别的,RR可见性是事务级别的。这在TokuDB中是如何实现的呢?

每个语句执行开始都会创建一个子事务。如果是RC、RR隔离级别,还会创建snapshot。Snapshot也有活跃事务列表,RC隔离级别是复制事务mgr在语句事务开始时刻的活跃事务列表,RR隔离级别是复制事务mgr在server层事务开始时刻的活跃事务列表。

Snapshot可见性就是事务id比snapshot的事务id更小,意味着更早开始执行;但是不在snapshot活跃事务列表的事务。

GC

随着事务提交snapshot结束,老版本数据不在被访问需要清理,这就引入了GC的问题。

为了判断写事务的更新是否被其他事务访问,TokuDB的事务mgr维护了reference_xids数组,记录事务提交时刻,系统中处于活跃状态snapshot个数,作用相当于reference_count。

以上描述了TokuDB如何跟踪写事务的引用者。那么GC是何时执行的呢?

可以调用OPTIMIZE TABLE显式触发,也可以在后续访问索引key时隐式触发。

典型业务场景

以上介绍了TokuDB引擎内核原理,下面我们从HybridDB for MySQL产品的角度谈一下业务场景和性能。

HybridDB for MySQL设计目标是提供低成本大容量分布式数据库服务,一体式处理OLTP和OLAP混合业务场景,提供存储和计算能力;而存储和计算节点在物理上是分离的,用户可以根据业务特点定制存储计算节点的配比,也可以单独购买存储和计算节点。

HybridDB for MySQL数据只存储一份,减少数据交换成本,同时也降低了存储成本;所有功能集成在一个实例之中,提供统一的用户接口,一致的数据视图和全局统一的SQL兼容性。

HybridDB for MySQL支持数据库分区,整体容量和性能随分区数目增长而线性增长;用户可先购买一个基本配置,随业务发展后续可以购买更多的节点进行扩容。HybridDB for MySQL提供在线的扩容和缩容能力,水平扩展/收缩存储和计算节点拓扑结构;在扩展过程中,不影响业务对外提供服务,优化数据分布算法,减少重新分布数据量;采用流式迁移,备份数据不落地。

除此之外,HybridDB for MySQL还支持高可用,复用链路高可用技术,采用一主多备方式实现三副本。HybridDB for MySQL复用ApsaraDB for MySQL已有技术框架,部署、升级、链路管理、资源管理、备份、安全、监控和日志复用已有功能模块,技术风险低,验证周期短,可以说是站在巨人肩膀上的创新。

image.png

低成本大容量存储场景

HybridDB for MySQL使用软硬件整体方案解决大容量低成本问题。

软件方面,HybridDB for MySQL是分布式数据库,摆脱单机硬件资源限制,提供横向扩展能力,容量和性能随节点数目增加而线性增加。存储节点MySQL实例选择使用TokuDB引擎,支持块级压缩,压缩算法以表单位进行配置。用户可根据业务自身特点选择使用压缩效果好的压缩算法比如lzma,也可以选择quicklz这种压缩速度快资源消耗低的压缩算法,也可以选择像zstd这种压缩效果和压缩速度比较均衡的压缩算法。如果选用zstd压缩算法,线上实测的压缩比是3~4。

硬件方面,HybridDB for MySQL采用分层存储解决方案,大量冷数据存储在SATA盘上,少量温数据存储在ssd上,热数据存储在数据库引擎的内存缓存中(TokuDB cachetable)。SATA盘和ssd上数据之间的映射关系通过bcache驱动模块来管理,bcache可以配置成WriteBack模式(写路径数据写ssd后即返回,ssd中更新数据由bcache负责同步到SATA盘上),可加速数据库checkpoint写盘速度;也可以配置成WriteThrough模式(写路径数据同时写到ssd和SATA上,两者都ack写才算完成)。

持续高并发写入场景

TokuDB采用fractal tree(中文译作分型树)数据结构,优化写路径,大部分二级索引的写操作是异步的,写被缓存到中间数据块即返回。写操作同步到叶数据块可以通过后台cleaner线程异步完成,也可能由后续的读操作同步完成(读合并)。Fractal tree在前面的内核原理部分有详尽描述,这里就不赘述了。

细心的朋友可能会发现,我们在异步写前加了个前缀:大部分二级索引。那么大部分是指那些情况呢?这里大部分是指不需要做quickness检查的索引,写请求直接扔给fractal tree的msg buffer即可返回。如果二级索引包含unique索引,必须先做唯一性检查保证不存在重复键值。否则,异步合并(或者读合并)无法通知唯一性检查失败,也无法回滚其他索引的更新。Pk字段也有类似的唯一性语义,写之前会去查询pk键值是否已存在,顺便做了root到leaf数据块的预读和读合并。所以,每条新增数据执行INSERT INTO的过程不完全是异步写。

ApsaraDB for MySQL对于日志场景做了优化,利用INSERT IGNORE语句保证pk键值唯一性,并且通过把二级索引键值1-1映射到pk键值空间的方法保证二级索引唯一性,将写操作转换成全异步写,大大降低了写延迟。由于省掉唯一性检查的读过程,引擎在内存中缓存的数据量大大减少,缓存写请求的数据块受读干扰被释放的可能性大大降低,进而写路径上发生cachetable miss的可能性降低,写性能更加稳定。

分布式业务场景

HybridDB for MySQL同时提供单分区事务和分布式事务支持,支持跨表、跨引擎、跨数据库、跨MySQL实例,跨存储节点的事务。HybridDB for MySQL使用两阶段提交协议支持分布式事务,提交阶段proxy作为协调者将分布式事务状态记录到事务元数据库;分区事务恢复时,proxy从事务元数据库取得分布式事务状态,并作为协调者重新发起失败分区的事务。

HybridDB for MySQL还可以通过判断WHERE条件是否包含分区键的等值条件,决定是单分区事务还是分布式事务。如果是单分区事务,直接发送给分区MySQL实例处理。

在线扩容/缩容场景

HybridDB for MySQL通过将存储分区无缝迁移到更多(或更少的)MySQL分区实例上实现弹性数据扩展(收缩)的功能,分区迁移完成之后proxy层更新路由信息,把请求切到新分区上,老分区上的数据会自动清理。Proxy切换路由信息时会保持连接,不影响用户业务。

数据迁移是通过全量备份+增量备份方式实现,全量备份不落地直接流式上传到oss。增量备份通过binlog方式同步,HybridDB for MySQL不必自行实现binlog解析模块,而是利用ApsaraDB for MySQL优化过的复制逻辑完成增量同步,通过并行复制提升性能,并且保证数据一致性。

image.png

聚合索引提升读性能

TokuDB支持一个表上创建多个聚合索引,以空间代价换取查询性能,减少回pk取数据。阿里云ApsaraDB for MySQL在优化器上对TokuDB聚合索引做了额外支持,在cost接近时可以优先选择聚合索引;存在多个cost接近的聚合索引,可以优先选择与WHERE条件最匹配的聚合索引。

与单机版ApsaraDB for MySQL对比

image.png

与阿里云OLTP+OLAP混合方案对比

image.png

性能报告

高并发业务

压测配置:
- 4节点,每节点8-core,32G,12000 iops,ssd盘

image.png

高吞吐业务

压测配置:
- 8节点,每节点16-core,48G,12000 iops,ssd盘

image.png

最后,HybridDB for MySQL目前处于快速发展阶段,正在承接阿里集团内外各种日志和分析报表业务。欢迎大家使用,欢迎多提宝贵意见!

MySQL · myrocks · myrocks写入分析

$
0
0

写入流程

myrocks的写入流程可以简单的分为以下几步来完成

  1. 将解析后的记录(kTypeValue/kTypeDeletion)写入到WriteBatch中
  2. 将WAL日志写入log文件
  3. 将WriteBatch中的内容写到memtable中,事务完成

其中第2,3步在提交时完成

WriteBatch与Myrocks事务处理密切相关,事务中的记录提交前都以字符串的形式存储在WriteBatch->rep_中,要么都提交,要么都回滚。 回滚的逻辑比较简单,只需要清理WriteBatch->rep_即可。详见TransactionImpl::Rollback

一个简单的insert 写入WriteBatch堆栈如下

#0  rocksdb::WriteBatchInternal::Put
#1  rocksdb::WriteBatch::Put
#2  myrocks::ha_rocksdb::update_pk
#3  myrocks::ha_rocksdb::update_indexes
#4  myrocks::ha_rocksdb::update_write_row
#5  myrocks::ha_rocksdb::write_row
#6  handler::ha_write_row
#7  write_record
#8  mysql_insert
#9  mysql_execute_command
#10 mysql_parse
#11 dispatch_command
#12 do_command
#13 do_handle_one_connection

一个简单的insert commit堆栈如下

#0  rocksdb::InlineSkipList<rocksdb::MemTableRep::KeyComparator const&>::Insert
#1  rocksdb::(anonymous namespace)::SkipListRep::Insert
#2  rocksdb::MemTable::Add
#3  rocksdb::MemTableInserter::PutCF
#4  rocksdb::WriteBatch::Iterate
#5  rocksdb::WriteBatch::Iterate
#6  rocksdb::WriteBatchInternal::InsertInto
#7  rocksdb::DBImpl::WriteImpl
#8  rocksdb::DBImpl::Write 
#9  rocksdb::TransactionImpl::Commit
#10 myrocks::Rdb_transaction_impl::commit_no_binlog
#11 myrocks::Rdb_transaction::commit
#12 myrocks::rocksdb_commit
#13 ha_commit_low
#14 TC_LOG_MMAP::commit 
#15 ha_commit_trans
#16 trans_commit_stmt
#17 mysql_execute_command
#18 mysql_parse
#19 dispatch_command
#20 do_command
#21 do_handle_one_connection

提交流程及优化

这里只分析rocksdb引擎的提交流程,实际MyRocks提交时还需先写binlog(binlog开启的情况).

rocksdb引擎提交时就完成两个事情
1. 写WAL日志(WAL开启的情况下rocksdb_write_disable_wal=off)
2. 将之前的WriteBatch写入到memtable中

然而,写WAL是一个串行操作。为了提高提交的效率, rocksdb引入了group commit机制。

待提交的事务都依次加入到提交的writer队列中,这个writer队列被划分为一个一个group. 每个group有一个leader, 其他为follower,leader负责批量写WAL。每个group由双向链表link_older, link_newer链接。如下图所示

屏幕快照 2017-07-11 下午7.46.22.png

每个writer可能的状态如下

  • Init: writer的初始状态
  • Header: writer被选为leader
  • Follower: writer被选为follower
  • LockedWating: writer在等待自己转变为指定的状态
  • Completed:writer操作完成

writer的状态变迁跟group是否并发写memtable有关
当开启并发写memtable(rocksdb_allow_concurrent_memtable_write=on)且group中的writer至少有两个时,group才会并发写。

group并发写时writer的状态变迁图如下:

屏幕快照 2017-07-14 下午1.25.27.png

group非并发写时writer的状态变迁图如下:

屏幕快照 2017-07-11 下午7.46.50.png

源码结构图如下(图片来自林青)
屏幕快照 2017-07-14 下午1.44.46.png

上面的图是在group内writer并发写memtable的情形。
非并发写memtable时,没有LaunchParallelFollowers/CompleteParallelWorker, Insertmemtable是由leader串行写入的。
这里group commit有以下要点
1. 同一时刻只有一个leader, leader完成操作后,才设置下一个leader
2. 需要等一个group都完成后,才会进行下一个group
3. group中最后一个完成的writer负责完成提交和设置下一个leader
4. Leader 负责批量写WAL
5. 只有leader才会去调整双向链表link_older,link_newer.

注意这里2,3 应该可以优化改进为

  • 不需要等一个group完成再进行下一个group
  • 不同group的follower可以并发执行
  • 只有leader负责完成提交和设置下一个leader

写入控制

rocksdb在提交写入时,需考虑以下几种情况,详见PreprocessWrite

  • WAL日志满,WAL日志超过rocksdb_max_total_wal_size,会从所有的colomn family中找出含有最老日志(the earliest log containing a prepared section)的column family进行flush, 以释放WAL日志空间
  • Buffer满,全局的write buffer超过rocksdb_db_write_buffer_size时,会从所有的colomn family中找出最先创建的memtable进行切换,详见HandleWriteBufferFull
  • 某些条件会触发延迟写
    • max_write_buffer_number > 3且 未刷immutable memtable总数 >=max_write_buffer_number-1
    • 自动compact开启时,level0的文件总数 >= level0_slowdown_writes_trigger
  • 某些条件会触发停写
    • 未刷immutable memtable总数 >=max_write_buffer_number
    • 自动compact开启时,level0的文件总数 >= level0_stop_writes_trigger

具体可参考RecalculateWriteStallConditions

总结

rocksdb写入流程还有优化空间,Facebook也有相关的优化。

MSSQL · 实现分析 · Extend Event实现审计日志对SQL Server性能影响

$
0
0

背景

在上一篇月报分享中,我们介绍了SQL Server实现审计日志功能的四种方法,最终的结论是使用Extend Event(中文叫扩展事件)实现审计日志方法是最优选择,详情参见MSSQL · 实现分析 · SQL Server实现审计日志的方案探索。那么,使用Extend Event实现审计日志的方案会面对如下疑问:

  • Extend Event是否满足可靠性要求

  • Extend Event是否满足吞吐量要求

  • Extend Event对SQL Server本身语句查询性能影响到底有多大

这篇文章就是围绕这几个问题的量化分析来展开的。

测试环境介绍

首先,需要说明一下测试环境,我的所有测试数据量化结果都是基于我的测试环境的而得出来的。如果用户测试环境的配置不同,可能会得到不同的测试量化数据。我的测试环境介绍如下。

环境配置

主机: Mac OS X 10.11.6系统上VM主机
CPU:i7-4770 2.2GHz 4 Cores 4 Logical Processor
Memory: 5GB
Storage: SSD
SQL Server:SQL Server 2008R2
测试工具:SQLTest 1.0.45.0
SQL Server几个关键的配置:max degree of parallelism和max server memory (MB)均采用默认值。

测试环境详情截图如下:
01.png

Extend Event Session对象创建

使用Create Event Session On Server语句创建基于实例级别的Extended Event。语句如下:

USE master
GO

CREATE EVENT SESSION [svrXEvent_User_Define_Testing] ON SERVER 
ADD EVENT sqlserver.sql_statement_completed
( 
	ACTION 
	( 
		sqlserver.database_id,
		sqlserver.database_name,
		sqlserver.session_id, 
		sqlserver.username, 
		sqlserver.client_hostname,
		sqlserver.client_app_name,
		sqlserver.sql_text, 
		sqlserver.query_hash,
		sqlserver.query_plan_hash,
		sqlserver.plan_handle,
		sqlserver.tsql_stack,
		sqlserver.is_system,
		package0.collect_system_time
	) 
	WHERE sqlserver.username <> N'NT AUTHORITY\SYSTEM'
		AND sqlserver.username <> 'sa'
		AND (NOT sqlserver.like_i_sql_unicode_string(sqlserver.client_app_name, '%IntelliSense'))
		AND sqlserver.is_system = 0
		
)
ADD TARGET package0.asynchronous_file_target
( 
	SET 
		FILENAME = N'C:\Temp\svrXEvent_User_Define_Testing.xel', 
		MAX_FILE_SIZE = 10,
		MAX_ROLLOVER_FILES = 500
)
WITH (
	EVENT_RETENTION_MODE = NO_EVENT_LOSS,
	MAX_DISPATCH_LATENCY = 5 SECONDS
);
GO

启用Extended Event Session

Extended Event Session对象创建完毕后,需要启动这个session对象,方法如下:

USE master
GO

-- We need to enable event session to capture event and event data 
ALTER EVENT SESSION [svrXEvent_User_Define_Testing]
ON SERVER STATE = START;
GO

可靠性和吞吐量测试

在选择使用Extend Event实现审计日志功能的解决方案之前,该技术方案可行性和吞吐量直接关系到产品的稳定性和功能延续性,这些特性对于审计日志功能都非常重要,我们需要经过严格的可靠性和吞吐量测试,以确保Extend Event技术方案满足这两方面的要求的同时,又不会对SQL Server本身性能和吞吐量造成大的影响(假设条语句性能和吞吐量影响超过5%定义为大的影响)。

可靠性

可靠性测试的方法是,我们使用SQLTest工具开4个并发线程执行查询语句,持续运行10分钟时间,同时,使用Profiler抓取SQL:stmtCompleted事件(功能和Extend Event事件sql_statement_completed功能相同),来校验Extend Event抓取的记录数,如果两者的记录数相同说明Extend Event满足可靠性要求。在测试的短短10分钟左右时间内,查看Profiler抓取到的记录数为3189637,总共310多万条记录,参见如下截图:
02.png
而,Extend Event总共生成了341个审计日志文件,每个日志文件最大大小为10MB(这里调整了最多的文件数量为500,以满足测试产生的数据要求),总共大小为近3.18GB。
03.png
使用系统提供的函数sys.fn_xe_file_target_read_file读取Extend Event生成的审计日文件记录总数,展示也是3189637条,这个记录总数和SQL Profiler抓取到的记录数是恰好吻合。
04.png
从测试的结果来看,Extend Event实现审计日志功能可靠性有保证,在10分钟310多万条语句执行的压力下,依然可以工作良好。

吞吐量

可靠性测试是保证Extend Event在抓取审计日志时的稳定性和功能健壮性,简单讲就是“不丢数据”,而吞吐量的测试是要回答“Extend Event到底在多大的查询吞吐量时,依然能够工作良好?”。就可靠性测试的我们来简单推算一下:10分钟的测试,共执行3189637条查询,生成了3.18GB的审计日志文件,以此来推算每秒,每分钟,每小时,每天可以抓取到的查询记录数和产生的日志文件大小,如下图计算所示:
05.png

  • 平均每秒抓取5316条审计日志和记录5.43MB日志文件

  • 平均每分钟抓取318963条审计日志和记录325.6MB日志文件

  • 平均每小时抓取19137822条审计日志和记录19.08GB日志文件

  • 平均每天抓取459307728条审计日志和记录457.92GB日志文件

从这个数量级别来看,Extend Event实现审计日志功能平均每天吞吐量可以达到4亿5千万条审计日志记录;生成457.92GB审计日志文件,完全可以满足我们的业务要求吞吐量了。

对SQL Server性能影响

为了测试Extend Event对用户SQL Server实例的性能影响,我们的思路是在停止和启用Extend Event Session的场景下,统计一千条相同查询(简称千查询)在不同数量并发线程情况下时间消耗和单位时间内(以1分钟为单位)的迭代次数,最终以得到的测试数据做为标准。

定性分析

测试之前,对测试数据的定性分析逻辑是:

  • 单位时间内迭代千查询的次数越多,性能越优

  • 千查询消耗时间越少,性能越优

  • 停止和启用Extend Event Session情况下,以上两个指标越接近,差异越小,说明Extend Event对SQL Server性能影响越小,因此也就越好

测试方法

建立测试对象表:创建测试表tab72,并初始化5万条数据。

--Create database sqlworkshops
use master
if (db_id('sqlworkshops') is NULL)
begin
	create database sqlworkshops
	alter database sqlworkshops set recovery simple
end
go
--Create table tab72
use sqlworkshops
if (object_id('tab72') is not NULL)
	drop table tab72
go
use sqlworkshops
create table tab72 (c1 int primary key clustered, c2 int, c3 nchar(2000))
insert into tab72 with (tablock) select c1 as c1, c1 as c2, replicate('a', 2000) as c3 from (select top (50000) row_number() over(order by t1.column_id) as c1 from sys.all_columns t1 cross apply sys.all_columns t2) as t order by c1
update tab72 set c3 = 'Advanced SQL Server Performance Monitoring & Tuning Hands-on Workshop from SQLWorkshops.com' where c1 = 10000
update statistics tab72 with fullscan
checkpoint
go

千查询测试语句:就是针对某个查询语句循环1000次。

use sqlworkshops
set nocount on
declare @i int
set @i = 1
while @i <= 1000
begin
	select * from tab72 where c3 like '%SQLWorkshops%' and c1 between 1 and 10
		option (maxdop 1)
	set @i = @i + 1
end
go

测试方法:使用SQLTest,分别测试在1、2、4、8、16、32个并发线程情况下,单位时间内(1分钟)千查询的平均迭代次数和时间消耗。

千查询平均耗时

使用SQLTest,在开启不同数量的并发查询线程情况下,获取到的千查询平均时间消耗数据统计如下:
千查询平均耗时统计数据表格
06_tb1.png
其中:

  • AT_PK_XE:启用Extend Event Session场景下,使用主键千查询的平均耗时。

  • AT_PK_nonXE:停用Extend Event Session场景下,使用主键千查询的平均耗时。

  • AT_PK_Range_XE:启用Extend Event Session场景下,使用主键范围查找,千查询的平均耗时。

  • AT_PK_Range_nonXE:停用Extend Event Session场景下,使用主键范围查找,千查询的平均耗时。

  • AT_nonXE_XE_Gap:使用主键千查询,在启用和停用Extend Event Session两种场景的平均耗时差异。

  • AT_Range_nonXE_XE_Gap:使用主键范围千查询,在启用和停用Extend Event Session两种场景的平均耗时差异。

将“千查询平均耗时统计数据表格”数据,做成EChart图,我直观的来看看平均时间消耗差异。

单主键千查询

使用单主键查找的千查询平均时间消耗图
07.png
从这个图,我们可以做如下总结:

  • 线条AT_nonXE_XE_Gap表示停止和启用Extend Event Session两个场景,千查询平均时间消耗差异,总体差异不大;但差异会随着线程数量的不断增加,而拉大。

  • 在并发线程为4的时候(这个数字和我的测试机CPU Cores个数惊人的相等),千查询平均时间消耗差异最小,仅为29毫秒,千查询平均耗时影响为29*100/270 = 10.74%,即单语句查询的平均耗时影响为0.01074%。

主键范围千查询

使用主键范围查找的千查询平均时间消耗图
08.png
从这个图,我们可以做如下总结:

  • 线条AT_Range_nonXE_XE_Gap表示停止和启用Extend Event Session两个场景,千查询平均时间消耗差异,总体差异不大(除开2个线程情况下);但差异会随着线程数量的不断增加,而拉大。

  • 同样,在并发线程为4的时候,千查询平均时间消耗差异最小,仅为58毫秒,千查询平均耗时影响为58*100/1712=3.39%,即单语句查询平均耗时影响为0.00339%。

平均耗时总结

根据以上对千查询平均耗时统计数据和做图,总结如下:
无论是基于主键的单值查询语句,还是主键的范围查询语句,禁用和启用Extend Event Session,对于千查询的平均耗时差异不大,在并发线程为4的时候,差异最小;千查询平均耗时差异为29毫秒和58毫秒,性能影响为10.74%和3.39%;单语句查询平均耗影响分别为0.01074%和0.00339%。

千查询迭代次数

这一小节从另外一个角度来看Extend Event对SQL Server性能的影响,让我们来看看在单位时间内(1分钟内)千查询迭代次数。千查询迭代次数统计表格
09_tb2.png
其中,表格每行数据表示千查询迭代次数,第一列与千查询平均时间消耗表达含义类似,这里不再累述。

单主键千查询

使用单主键千查询在单位时间内的迭代次数统计数据,做图如下:
10.png
从图表直观反映,我们可以发现如下规律:

  • AI_nonXE_XE_Gap线条表示千查询迭代次数差异,随着并发线程数增加,差异被拉大。

  • 禁用和启用Extend Event Session场景下,当并发线程数为4的时候,千查询迭代次数差异最小,这个规律和单主键千查询平均时间消耗规律相似。启用Extend Event Session,对迭代次数影响是85*100/897=9.47%,换算成单个查询语句的迭代次数影响为0.00947%。

主键范围千查询

使用主键范围查找的千查询迭代次数做图如下:
11.png
同样,我们可以直观的发现以下规律:

  • 迭代次数随着线程数量的增加而增加,在16个并发线程时达到顶峰,迭代次数开始下降。

  • 禁用和启用Extend Event Session场景下,千查询迭代次数差异在并发4个线程时(忽略并发线程为2的情况),最小值为8,这个规律和千查询平均时间消耗规律十分类似。启动Extend Event Session后,对千查询的迭代次数影响为8*100/142=5.63%,换算成单个查询语句的迭代次数影响为0.00563%。

千查询迭代次数总结

根据以上对千查询迭代次数数据和做图,总结如下:
无论是基于主键的单值查询语句,还是主键的范围查询语句,禁用和启用Extend Event Session,千查询的迭代次数差异并不大,在并发线程为4的时候,差异达到最小值;千查询迭代次数差异为85和8次,启用Extend Event Session后,对千查询在主键查找和主键范围查找场景下,迭代次数影响为9.47%和5.63%;单查询平均迭代次数影响分别为0.00947%和0.00563%。

性能影响总结

在启用了Extend Event Session抓取审计日志以后,对用户SQL Server实例性能影响的量化分析总结如下:

  • 单主键查找千查询,平均耗时影响为10.74%;换算为单主键单语句查询,性能影响为0.01074%。

  • 单主键查找千查询,单位时间内(1分钟)迭代次数影响(吞吐量)为9.47%;换算为单主键单语句查询,性能影响为0.00947%。

  • 主键范围查找千查询,平均耗时影响为3.39%;换算成单主键单语句查询,性能影响为0.00339%。

  • 主键范围查找千查询,单位时间内(1分钟)迭代次数影响(吞吐量)为5.63%;换算成单主键单语句查询,性能影响为0.00563%。

将以上文字描述的数字解决做成一个直观的图形,我们发现在开启Extend Event实现审计日志功能时,对于单条语句查询性能的影响最大约为0.01%;而对于单语句查询吞吐量的影响不超过0.01%。
12.png
从这个量化分析的总结来看,Extend Event对用户SQL Server性能影响是,千查询语句的性能影响在3% ~ 10%之间;单条语句查询性能和吞吐量损失均在0.01%小幅波动,这个影响相对于Profiler已经非常小了。因此,方案可行,并且影响在可控的范围内。

参考文章

  1. Measuring “Observer Overhead” of SQL Trace vs. Extended Events

  2. SQLTest测试方法参考链接

HybridDB · 源码分析 · MemoryContext 内存管理和内存异常分析

$
0
0

背景

最近排查和解决了几处 HybridDB for PostgreSQL 内存泄漏的BUG。觉得有一定通用性。
这期分享给大家一些实现细节和小技巧。

阿里云上的 HybridDB for PostgreSQL 是基于 PostgreSQL 开发,定位于 OLAP 场景的 MPP 架构数据库集群。它不少的内部机制沿用了 PostgreSQL 的实现。其中就包括了内存管理机制 MemoryContext。

一:PostgreSQL 内存管理机制

PostgreSQL 对内存的使用方式主要分两大块

1. shared_buffer 和同类 buffer。 简单的说 shared_buffer 用于存放数据页面对应数据文件中的 block,这部分内存是 PostgreSQL 中各进程共享。这部分不在本文讨论。
2. MemoryContext 以功能为单位组织起来的树形数据结构,不同的阶段使用不同的 MemoryContext。

1. MemoryContext 的作用

简单的说 MemoryContext 的存在是为了更清晰的管理内存

  • 合理管理碎片小内存。频繁的向 OS 申请和释放内存效率是很差的。MemoryContext 会以 trunk 为单位向 OS 申请成块的内存,并管理起来。当程序需求小内存时从 trunk 中分配,用完后归还给对应的 MemoryContext ,并不归还给 OS。
  • 赋予内存功能和生命周期属性
    • 以功能为单位管理内存。不同功能和阶段使用对应的 MemoryContext。
    • TopTransactionContext:一个事务的生命周期,事务管理相关数据放在 TopTransactionContext,当一个事务提交时该上下文被整个释放。
    • ExprContext PostgreSQL 以行为单位处理数据,每一行数据的表达式计算都会在 ExprContext 完成,每处理完一行都会重置对应的 ExprContext。
  • 树形的 MemoryContext 结构
    • 不同功能间的 MemoryContext 是以为树为单位组织起来的
    • 每个数据库后端进程顶层是 TopMemoryContext
    • TopMemoryContext 下有很多子 Context
      • 缓存相关的 CacheMemoryContext;
      • 本地锁相关的 LOCALLOCK hash;
      • 当前事务相关的 TopTransactionContext
      • 注意 CacheMemoryContext 为何不属于 TopTransactionContext,那是由于 Cache 是独立于事务存在的,事务提交不影响 Cache 的存在。
    • 删除或重置一个 MemoryContext,它的子 MemoryContext 也一并被删除或重置。

2. 不同模块的 MemoryContext

你可能明白了,实现不同的模块时,对待内存的方式可能区别很大。
比如:

1. 执行器在做表达式计算时,一些诸如字符串类型数据处理的函数,大多会比较随意的使用 palloc 分配内存,但直到函数返回,却并没有释放它们。
2. 在处理缓存模块处理数据时,却倍加小心的释放内存。

这是由于:

1. 执行器对数据的处理是以行为单位,都在 ExprContext 中,每处理完一行,会重置 ExprContext,以此释放相关的内存。
2. 缓存的生命周期很长,不会定期重置整个 MemoryContext。哪怕少量的内存泄漏,积攒的后果都很严重。这部分的实现容易出问题,也不好排查。

3. 常见的内存问题

虽然有很好的内存管理机制,但进程中内存间没有强隔离,也可能出现内存问题。

造成内存泄漏的原因很大可能是:

1. 在较长生存周期的 MemoryContext 中正常处理流程中没有释放内存。
2. 由于发生了异常,跳转到在异常处理阶段没有释放内存。
3. 没有使用内存管理机制,使用 OS 调用 malloc,free 处理内存(某些实现不合理的插件中可能出现)。
4. 在不正确的 MemoryContext 分配了内存,导致内存泄漏或数据丢失。
5. 写内存越界,这是最难找的问题,很容易造成数据库崩溃。

4. 问题排查小技巧

针对内存泄漏,常用两种方法排查

1. valgrind 最常见的大杀器,开发人员都懂的。这里就不详细介绍了。

2. 使用 GDB 也能大致定位问题

2.1 这是一段脚本,我们把它保存成文本文件(pg_debug_cmd)

define sum_context_blocks
set $context = $arg0
set $block = ((AllocSet) $context)->blocks
set $size = 0
while ($block)
set $size = $size + (((AllocBlock) $block)->endptr - ((char *) $block))
set $block = ((AllocBlock) $block)->next
end
printf "%s: %d\n",((MemoryContext)$context)->name, $size
end

define walk_contexts
set $parent_$arg0 = ($arg1)
set $indent_$arg0 = ($arg0)
set $i_$arg0 = $indent_$arg0
while ($i_$arg0)
printf ""
set $i_$arg0 = $i_$arg0 - 1
end
sum_context_blocks $parent_$arg0
set $child_$arg0 = ((MemoryContext) $parent_$arg0)->firstchild
set $indent_$arg0 = $indent_$arg0 + 1
while ($child_$arg0)
walk_contexts $indent_$arg0 $child_$arg0
set $child_$arg0 = ((MemoryContext) $child_$arg0)->nextchild
end
end

walk_contexts 0 TopMemoryContext

2.2 获得疑似内存泄漏的进程PID,定时触发执行下面的 shell

gdb -p $PID < pg_debug_cmd > memchek/MemoryContextInfo_$(time).log

2.3 分析日志文件

日志文件以 MemoryContext 树的形式展示了一个时间点该进程的内存分配情况。根据时间的积累,可以很容易判断出哪一些 MemoryContext 可能存在异常,从而为内存泄漏指明一个方向。

(gdb)
TopMemoryContext: 149616
 pgstat TabStatusArray lookup hash table: 8192
 TopTransactionContext: 8192
 TableSpace cache: 8192
 Type information cache: 24480
 Operator lookup cache: 24576
 MessageContext: 32768
 Operator class cache: 8192
 smgr relation table: 24576
 TransactionAbortContext: 32768
 Portal hash: 8192
 PortalMemory: 8192
  PortalHeapMemory: 1024
   ExecutorState: 24576
    SRF multi-call context: 1024
    ExprContext: 0
    ExprContext: 0
    ExprContext: 0
 Relcache by OID: 24576
 CacheMemoryContext: 1040384
  pg_toast_2619_index: 1024
  ....
  pg_authid_rolname_index: 1024
 WAL record construction: 49776
 PrivateRefCount: 8192
 MdSmgr: 8192
 LOCALLOCK hash: 8192
 Timezones: 104128
 ErrorContext: 8192

最后,文章的参考资料中也提供了一种类似的方法,供各位参考。

总结

PostgreSQL 内存管理机制的实现比较复杂,但用起来确却很简单,有一种特别的美感,推荐大家了解一下。

参考资料

  1. PostgreSQL Developer_FAQ

MySQL · 实现分析 · HybridDB for MySQL 数据压缩

$
0
0

概述

数据压缩是一个把输入数据集按照一定的算法变换成更小的数据集的过程,解压是压缩的逆过程。如果算法对数据本身的语义了解得越多,则越可能利用语义信息进行针对性的处理,获得更好的压缩效果。数据库系统中用得比较多的压缩算法可以分为两大类:基于块的压缩、基于值的压缩。前者更为常见一些,在 OLTP 以及 OLAP 系统中都会用到,例如 InnoDB、TokuDB、HybridDB 中的块压缩;后者更多的用在 OLAP 的列存引擎内,例如 HybridDB for MySQL 中的列压缩。为了区别它们,这里把块压缩简称为压缩(Compression)、而把基于值的压缩称为编码(Encoding)。此外,在存储系统中比较常见的重复数据删除功能也可以被视为一种特殊形式的压缩。不过它不属于本文要考虑的范围。

通常来说,列存格式对压缩要更友好。大概而言,行存的数据压缩率一般为3:1(采用通用压缩算法);列存的数据压缩率为10:1(采用编码以及通用的压缩算法)。

无论是哪种形式的压缩,衡量算法本身是否适用的指标主要有:
1. 压缩率,也就是压缩前后数据大小的比率。
2. 吞吐量,也就是压缩和解压的速度。典型单位为 GB/s。
3. 资源消耗,压缩解压一般是计算密集型的算法,因此主要考虑的是 CPU 消耗。

压缩

压缩算法可以说是无处不在。常见的例子如各种文件压缩工具背后的压缩算法,包括 zip、rar 等等;各种图片格式对应的压缩算法,包括 png、jpeg 等等。数据库系统中用的都是无损压缩,图片压缩则可以采用有损压缩。很多算法都属于 lz 系列,例如:lz、lzo、quicklz 等等。多年以前 Google 推出的 Snappy ,虽然压缩率不是特别出众,但是吞吐量比较大、资源消耗比较小,因此获得了广泛的应用。最近几年 Facebook 推出的 zstd 算法具有类似的特征,也获得了很多应用。zstd 的主页上有一些测试的数据,可以作为参考:

pic

编码

编码则是利用数据的语义信息进行更加有针对性的压缩。当然,很多算法也在通用的压缩算法中被采用了。常见的编码算法有:行程编码(Run Length Encoding)、字典编码(Dictionary)、差值编码(Delta)、变长整数编码(Varint)、位变换(Bit Shuffle)、前缀编码(Prefix)、异或(XOR)等等。甚至可以说有多少种数据的规律就可以发明出多少种编码算法。例如:InfoBright 就可以对一系列的数字除以最大公约数,获得更小的数字,从而达到数据压缩的目的。

产品

下面让我们来看一看典型的几个 OLAP 产品对压缩算法的支持。

Apache Kudu

Apache Kudu 是一个比较有意思的项目,它支持多副本、列存,试图解决实时分析的需求。下图是它支持的编码/压缩方法:

pic

相对其他系统而言,Kudu 编码中比较特殊的一种是 BitShuffle 编码。假设输入的是类型 T 的一个数组,该编码的算法是:先保存每个值的 MSB 位(最高位),然后下一个 bit 位,一直到最后的 LSB(最低位);然后对数据进行 LZ4 压缩。该编码适合与重复值较多的列或者列值变化不大的情况。除了上述的编码之外,Kudu 也支持通用压缩算法,例如:lz4、snappy、zlib。默认情况下,列是不压缩的。而且 Bitshuffle 编码后的列总是自动采用 lz4 压缩。

Amazon RedShift

Amazon RedShift 支持的编码/压缩算法如下:

pic

从图中可以看出,RedShift 支持 Delta、字典、RLE、Mostly、Text255 等编码。比较特别的是 Text255 和 text32k,它们适合与单词重复出现的 VARCHAR 列。实际上,它就是对每个 1MB 块中的单词创建了一个字典。字典容纳 245 个唯一的单词,数据实际存储的时候用一个字节的索引代替对应的单词。

Pivotal GPDB

Pivotal GPDB 的 Append Only Table 也支持压缩算法 。

pic

相对而言,GPDB 支持的编码和压缩种类要稍少一些。但是它允许设置算法的压缩级别以及块的大小。

总结

不同的通用压缩算法在压缩率和速度以及资源消耗之间做了不同程度的权衡,有些算法(例如 zlib)还提供了一些压缩级别的参数可供调整。针对不同的数据集合,压缩率也存在较大的区别。例如:在采用某个特定数据集的测试中,snappy 的压缩率接近 3,而 zlib 和 zstd 的压缩率大约为 4。编码算法的压缩率对数据集的类型和取值更为敏感,例如 delta 算法对整数类型,并且相邻数据之间差别较小的情况下(例如自增列),压缩比就很好。对于浮点数而言,提高要缩率更为困难,Facebook 等曾经做过一些针对性的优化。

如果想要了解数据压缩的基本背景,请参考:Data compression tutorial 。如果想要获得对列存系统的更多知识(包括列存对数据压缩的优化),则建议移步:Column store tutorial 。

参考资料

1.Snappy
2.Zstd
3.Apache Kudu
4.Amazon RedShift
5.GreenPlum Database
6.Gorilla
7.Data compression tutorial
8.Column store tutorial


PgSQL · 最佳实践 · CPU满问题处理

$
0
0

前言

在数据库运维当中,一个DBA比较常遇到又比较紧急的问题,就是突发的CPU满(CPU利用率达到100%),导致业务停滞。DBA不一定非常熟悉业务实现逻辑,也不能掌控来自应用的变更或负载变化情况。 所以,遇到CPU满,往往只能从后端数据库开始排查,追溯到具体SQL,最终定位到业务层。这里我们总结下这个问题具体的处理方法。

查看连接数变化

CPU利用率到达100%,首先怀疑,是不是业务高峰活跃连接陡增,而数据库预留的资源不足造成的结果。我们需要查看下,问题发生时,活跃的连接数是否比平时多很多。对于RDS for PG,数据库上的连接数变化,可以从控制台的监控信息中看到。而当前活跃的连接数可以直接连接数据库,使用下列查询语句得到:

select count( * ) from pg_stat_activity where state not like '%idle';

追踪慢SQL

如果活跃连接数的变化处于正常范围,则很大概率可能是当时有性能很差的SQL被大量执行导致。由于RDS有慢SQL日志,我们可以通过这个日志,定位到当时比较耗时的SQL来进一步做分析。但通常问题发生时,整个系统都处于停滞状态,所有SQL都慢下来,当时记录的慢SQL可能非常多,并不容易排查罪魁祸首。这里我们介绍几种在问题发生时,即介入追查慢SQL的方法。

1. 第一种方法是使用pg_stat_statements插件定位慢SQL,步骤如下。

1.1. 如果没有创建这个插件,需要手动创建。我们要利用插件和数据库系统里面的计数信息(如SQL执行时间累积等),而这些信息是不断累积的,包含了历史信息。为了更方便的排查当前的CPU满问题,我们要先重置计数器。

create extension pg_stat_statements;
select pg_stat_reset();
select pg_stat_statements_reset();

1.2. 等待一段时间(例如1分钟),使计数器积累足够的信息。

1.3. 查询最耗时的SQL(一般就是导致问题的直接原因)。

select * from pg_stat_statements order by total_time desc limit 5;

1.4. 查询读取Buffer次数最多的SQL,这些SQL可能由于所查询的数据没有索引,而导致了过多的Buffer读,也同时大量消耗了CPU。

select * from pg_stat_statements order by shared_blks_hit+shared_blks_read desc limit 5;

2. 第二种方法是,直接通过pg_stat_activity视图,利用下面的查询,查看当前长时间执行,一直不结束的SQL。这些SQL对应造成CPU满,也有直接嫌疑。

 select datname, usename, client_addr, application_name, state, backend_start, xact_start, xact_stay, query_start, query_stay, replace(query, chr(10), '') as query from (select pgsa.datname as datname, pgsa.usename as usename, pgsa.client_addr client_addr, pgsa.application_name as application_name, pgsa.state as state, pgsa.backend_start as backend_start, pgsa.xact_start as xact_start, extract(epoch from (now() - pgsa.xact_start)) as xact_stay, pgsa.query_start as query_start, extract(epoch from (now() - pgsa.query_start)) as query_stay , pgsa.query as query from pg_stat_activity as pgsa where pgsa.state != 'idle' and pgsa.state != 'idle in transaction' and pgsa.state != 'idle in transaction (aborted)') idleconnections order by query_stay desc limit 5;

3. 第3种方法,是从数据表上表扫描(Table Scan)的信息开始查起,查找缺失索引的表。数据表如果缺失索引,大部分热数据又都在内存时(例如内存8G,热数据6G),此时数据库只能使用表扫描,并需要处理已在内存中的大量的无关记录,而耗费大量CPU。特别是对于表记录数超100的表,一次表扫描占用大量CPU(基本把一个CPU占满),多个连接并发(例如上百连接),把所有CPU占满。

3.1. 通过下面的查询,查出使用表扫描最多的表:

select * from pg_stat_user_tables where n_live_tup > 100000 and seq_scan > 0 order by seq_tup_read desc limit 10;

3.2. 查询当前正在运行的访问到上述表的慢查询:

select * from pg_stat_activity where query ilike '%<table name>%' and query_start - now() > interval '10 seconds';

3.3. 也可以通过pg_stat_statements插件定位涉及到这些表的查询:

select * from pg_stat_statements where query ilike '%<table>%'order by shared_blks_hit+shared_blks_read desc limit 3;

处理慢SQL

对于上面的方法查出来的慢SQL,首先需要做的可能是Cancel或Kill掉他们,使业务先恢复:

select pg_cancel_backend(pid) from pg_stat_activity where  query like '%<query text>%' and pid != pg_backend_pid();
select pg_terminate_backend(pid) from pg_stat_activity where  query like '%<query text>%' and pid != pg_backend_pid();

如果这些SQL确实是业务上必需的,则需要对他们做优化。这方面有“三板斧”:

1. 对查询涉及的表,执行ANALYZE <table>或VACUUM ANZLYZE <table>,更新表的统计信息,使查询计划更准确。注意,为避免对业务影响,最好在业务低峰执行。

2. 执行explain 或explain (buffers true, analyze true, verbose true) 命令,查看SQL的执行计划(注意,前者不会实际执行SQL,后者会实际执行而且能得到详细的执行信息),对其中的Table Scan涉及的表,建立索引。

3. 重新编写SQL,去除掉不必要的子查询、改写UNION ALL、使用JOIN CLAUSE固定连接顺序等到,都是进一步深度优化SQL的手段,这里不再深入说明。

总结

需要说明的是,这些方法对于RDS for PPAS产品同样适用,但在使用我们所列的命令时,由于权限限制,需要把上面提到的视图、函数、命令做如下转换:

pg_stat_statements_reset() => rds_pg_stat_statements_reset()

pg_stat_statements => rds_pg_stat_statements()

pg_stat_reset() => rds_pg_stat_reset()

pg_cancel_backend() => rds_pg_cancel_backend()

pg_terminate_backend() => rds_pg_terminate_backend()

pg_stat_activity => rds_pg_stat_activity()

create extension pg_stat_statements => rds_manage_extension('create', 'pg_stat_statements')

上面我们分析了处理CPU满,追查问题SQL的一些方法。大家可以按部就班的尝试我们列出的命令,定位问题。

MySQL · 源码分析 · InnoDB 异步IO工作流程

$
0
0

之前的一篇内核月报InnoDB IO子系统中介绍了InnoDB IO子系统中包含的同步IO以及异步IO。本篇文章将从源码层面剖析一下InnoDB IO子系统中,数据页的同步IO以及异步IO请求的具体实现过程。

在MySQL5.6中,InnoDB的异步IO主要是用来处理预读以及对数据文件的写请求的。而对于正常的页面数据读取则是通过同步IO进行的。到底二者在代码层面上的实现过程有什么样的区别? 接下来我们将以Linux native io的执行过程为主线,对IO请求的执行过程进行梳理。

重点数据结构

  • os_aio_array_t
/** 用来记录某一类(ibuf,log,read,write)异步IO(aio)请求的数组类型。每一个异步IO请求都会在类型对应的数组中注册一个innodb
  aio对象。*/

os_aio_array_t  {
	
  os_ib_mutex_t mutex;  // 主要用来控制异步read/write线程的并发操作。对于ibuf,log类型,由于只有一个线程,所以不存在并发操作问题
  os_event_t  not_full; // 一个条件变量event,用来通知等待获取slot的线程是否os_aio_array_t数组有空闲的slot供aio请求

  os_event_t  is_empty; // 条件变量event,用来通知IO线程os_aio_array_t数组是否有pening的IO请求。
  
  ulint   n_slots; // 数组容纳的IO请求数。= 线程数 * 每个segment允许pending的请求数(256)
  
  ulint   n_segments; // 允许独立wait的segment数,即某种类型的IO的允许最大线程数

  ulint   cur_seg; /* IO请求会按照round robin的方式分配到不同的segment中,该变量指示下一个IO请求可以分配的segment */
 ulint   n_reserved; // 已经Pending的IO请求数

  os_aio_slot_t*  slots; // 用来记录具体的每个IO请求对象的数组,也即n_segments 个线程共用n_slots个槽位来存放pending io请求  

  \#ifdef __WIN__

  HANDLE*   handles;
        /*!< Pointer to an array of OS native
        event handles where we copied the
        handles from slots, in the same
        order. This can be used in
        WaitForMultipleObjects; used only in
		Windows */

 \#endif __WIN__

 \#if defined(LINUX_NATIVE_AIO)

  io_context_t*   aio_ctx; // aio上下文的数组,每个segment拥有独立的一个aio上下文数组,用来记录以及完成的IO请求上下文

  struct io_event*  aio_events; // 该数组用来记录已经完成的IO请求事件。异步IO通过设置事件通知IO线程处理完成的IO请求

  struct iocb**  pending; // 用来记录pending的aio请求

  ulint*         count; // 该数组记录了每个segment对应的pending aio请求数量

 \#endif /* LINUX_NATIV_AIO */

 }
  • os_aio_slot_t
// os_aio_array_t数组中用来记录一个异步IO(aio)请求的对象
 os_aio_slot_t {

  ibool   is_read;  /*!< TRUE if a read operation */

  ulint   pos;    // os_aio_array_t数组中所在的位置 

  ibool   reserved; // TRUE表示该Slot已经被别的IO请求占用了

  time_t    reservation_time; // 占用的时间

  ulint   len;    // io请求的长度

  byte*   buf;    // 数据读取或者需要写入的buffer,通常指向buffer pool的一个页面,压缩页面有特殊处理

  ulint   type;   /* 请求类型,即读还是写IO请求 */ 

  os_offset_t offset;   /*!< file offset in bytes */

  os_file_t file;   /*!< file where to read or write */

  const char* name;   /*!< 需要读取的文件及路径信息 */

  ibool   io_already_done; /* TRUE表示IO已经完成了

  fil_node_t* message1; /* 该aio操作的innodb文件描述符(f_node_t)*/

  void*   message2; /* 用来记录完成IO请求所对应的具体buffer pool bpage页 */

 \#ifdef WIN_ASYNC_IO

  HANDLE    handle;   /*!< handle object we need in the
          OVERLAPPED struct */

  OVERLAPPED  control;  /*!< Windows control block for the
          aio request */

  \#elif defined(LINUX_NATIVE_AIO)

  struct iocb control;  /* 该slot使用的aio请求控制块iocb */

  int   n_bytes;  /* 读写bytes */

  int   ret;    /* AIO return code */

  \#endif /* WIN_ASYNC_IO */

}

流程图

flow-aio.png

源码分析

  • 物理数据页操作入口函数os_aio_func
ibool
os_aio_func(
/*========*/
  ulint   type, /* IO类型,READ还是WRITE IO */
  ulint   mode, /* 这里表示是否使用SIMULATED aio执行异步IO请求 */
  const char* name, /* IO需要打开的tablespace路径+名称 */
  os_file_t file, /* IO操作的文件 */
  void*   buf,  // 数据读取或者需要写入的buffer,通常指向buffer pool的一个页面,压缩页面有特殊处理
  os_offset_t offset, /*!< in: file offset where to read or write */
  ulint   n,  /* 读取或写入字节数 */
  fil_node_t* message1, /* 该aio操作的innodb文件描述符(f_node_t),只对异步IO起作用 */ 
  void*   message2, /* 用来记录完成IO请求所对应的具体buffer pool bpage页,只对异步IO起作用 */
  ibool   should_buffer, // 是否需要缓存aio请求,该变量主要对预读起作用
  ibool   page_encrypt,
        /*!< in: Whether to encrypt */
  ulint   page_size)
       /*!< in: Page size */
{
...

  wake_later = mode & OS_AIO_SIMULATED_WAKE_LATER;
  mode = mode & (~OS_AIO_SIMULATED_WAKE_LATER);

  if (mode == OS_AIO_SYNC
#ifdef WIN_ASYNC_IO
      && !srv_use_native_aio
#endif /* WIN_ASYNC_IO */
      ) {
    /* This is actually an ordinary synchronous read or write:
    no need to use an i/o-handler thread. NOTE that if we use
    Windows async i/o, Windows does not allow us to use
    ordinary synchronous os_file_read etc. on the same file,
    therefore we have built a special mechanism for synchronous
    wait in the Windows case.
    Also note that the Performance Schema instrumentation has
    been performed by current os_aio_func()'s wrapper function
    pfs_os_aio_func(). So we would no longer need to call
    Performance Schema instrumented os_file_read() and
    os_file_write(). Instead, we should use os_file_read_func()
    and os_file_write_func() */

	/* 这里如果是同步IO,并且native io没有开启的情况下,直接使用os_file_read/write函数进行读取,
       不需要经过IO线程进行处理 */

    if (type == OS_FILE_READ) {
      if (page_encrypt) {
        return(os_file_read_decrypt_page(file, buf, offset, n, page_size));
      } else {
        return(os_file_read_func(file, buf, offset, n));
      }
    }
    ut_ad(!srv_read_only_mode);
    ut_a(type == OS_FILE_WRITE);
    if (page_encrypt) {
      return(os_file_write_encrypt_page(name, file, buf, offset, n, page_size));
    } else {
      return(os_file_write_func(name, file, buf, offset, n));
    }
  }
try_again:
  switch (mode) {
	// 根据访问类型,定位IO请求数组
  case OS_AIO_NORMAL:
    if (type == OS_FILE_READ) {
      array = os_aio_read_array;
    } else {
      ut_ad(!srv_read_only_mode);
      array = os_aio_write_array;
    }
    break;
  case OS_AIO_IBUF:
    ut_ad(type == OS_FILE_READ);
    /* Reduce probability of deadlock bugs in connection with ibuf:
    do not let the ibuf i/o handler sleep */

    wake_later = FALSE;

    if (srv_read_only_mode) {
      array = os_aio_read_array;
   }
    break;
  case OS_AIO_LOG:
    if (srv_read_only_mode) {
      array = os_aio_read_array;
    } else {
      array = os_aio_log_array;
    }
    break;
  case OS_AIO_SYNC:
    array = os_aio_sync_array;
#if defined(LINUX_NATIVE_AIO)
    /* In Linux native AIO we don't use sync IO array. */
    ut_a(!srv_use_native_aio);
#endif /* LINUX_NATIVE_AIO */
    break;
  default:
    ut_error;
    array = NULL; /* Eliminate compiler warning */
  }
  // 阻塞为当前IO请求申请一个用来执行异步IO的slot
  slot = os_aio_array_reserve_slot(type, array, message1, message2, file,
           name, buf, offset, n, page_encrypt, page_size);

        DBUG_EXECUTE_IF("simulate_slow_aio",
                        {
                          os_thread_sleep(1000000);
                        }
                        );
  if (type == OS_FILE_READ) {
    if (srv_use_native_aio) {
      os_n_file_reads++;
      os_bytes_read_since_printout += n;
#ifdef WIN_ASYNC_IO
	 // 这里是Windows用来处理异步IO读请求
      ret = ReadFile(file, buf, (DWORD) n, &len,
               &(slot->control));

#elif defined(LINUX_NATIVE_AIO)
	  // 这里是Linux来处理native io
      if (!os_aio_linux_dispatch(array, slot, should_buffer)) {
        goto err_exit;
#endif /* WIN_ASYNC_IO */
    } else {
      if (!wake_later) {
		// 唤醒simulated aio处理线程
        os_aio_simulated_wake_handler_thread(
          os_aio_get_segment_no_from_slot(
            array, slot));
      }
    }
  } else if (type == OS_FILE_WRITE) {
    ut_ad(!srv_read_only_mode);
    if (srv_use_native_aio) {
      os_n_file_writes++;
#ifdef WIN_ASYNC_IO
	  // 这里是Windows用来处理异步IO写请求
      ret = WriteFile(file, buf, (DWORD) n, &len,
          &(slot->control));

#elif defined(LINUX_NATIVE_AIO)
	  // 这里是Linux来处理native io
      if (!os_aio_linux_dispatch(array, slot, false)) {
        goto err_exit;
      }
#endif /* WIN_ASYNC_IO */
    } else {
      if (!wake_later) {
		// 唤醒simulated aio处理线程
        os_aio_simulated_wake_handler_thread(
          os_aio_get_segment_no_from_slot(
            array, slot));
      }
    }
  } else {
    ut_error;
  }

...
}

  • 负责通知Linux内核执行native IO请求的函数os_aio_linux_dispatch
static
ibool
os_aio_linux_dispatch(
/*==================*/
  os_aio_array_t* array,  /* IO请求函数 */
  os_aio_slot_t*  slot, /* 申请好的slot */
        ibool           should_buffer)  // 是否需要缓存aio 请求,该变量主要对预读起作用
{
	...

  /* Find out what we are going to work with.
  The iocb struct is directly in the slot.
  The io_context is one per segment. */

  // 每个segment包含的slot个数,Linux下每个segment包含256个slot
  slots_per_segment = array->n_slots / array->n_segments;
  iocb = &slot->control;
  io_ctx_index = slot->pos / slots_per_segment;
  if (should_buffer) {
  	/* 这里也可以看到aio请求缓存只对读请求起作用 */
  	ut_ad(array == os_aio_read_array);
  
    ulint n;
    ulint count;
    os_mutex_enter(array->mutex);
    /* There are array->n_slots elements in array->pending, which is divided into
     * array->n_segments area of equal size.  The iocb of each segment are 
     * buffered in its corresponding area in the pending array consecutively as
     * they come.  array->count[i] records the number of buffered aio requests in
     * the ith segment.*/
  	 n = io_ctx_index * slots_per_segment
      + array->count[io_ctx_index];
      array->pending[n] = iocb;
      array->count[io_ctx_index] ++; 
      count = array->count[io_ctx_index];
      os_mutex_exit(array->mutex);
	  // 如果当前segment的slot都已经被占用了,就需要提交一次异步aio请求
      if (count == slots_per_segment) {
            os_aio_linux_dispatch_read_array_submit(); //no cover line
      }   
	  // 否则就直接返回
  	  return (TRUE);                  
   } 
	// 直接提交IO请求到内核
  ret = io_submit(array->aio_ctx[io_ctx_index], 1, &iocb);
  ...
}
  • IO线程负责监控aio请求的主函数fil_aio_wait
void
fil_aio_wait(
/*=========*/
  ulint segment)  /*!< in: the number of the segment in the aio
        array to wait for */
{
  ibool   ret; 
  fil_node_t* fil_node;
  void*   message;
  ulint   type;

  ut_ad(fil_validate_skip());

  if (srv_use_native_aio) { // 使用native io
    srv_set_io_thread_op_info(segment, "native aio handle");
#ifdef WIN_ASYNC_IO
    ret = os_aio_windows_handle( // Window监控入口
      segment, 0, &fil_node, &message, &type);
#elif defined(LINUX_NATIVE_AIO)
    ret = os_aio_linux_handle( // Linux native io监控入口
	  segment, &fil_node, &message, &type);
#else
    ut_error;
    ret = 0; /* Eliminate compiler warning */
#endif /* WIN_ASYNC_IO */
  } else {
    srv_set_io_thread_op_info(segment, "simulated aio handle");

    ret = os_aio_simulated_handle( // Simulated aio监控入口
      segment, &fil_node, &message, &type);
  }

  ut_a(ret);
  if (fil_node == NULL) {
    ut_ad(srv_shutdown_state == SRV_SHUTDOWN_EXIT_THREADS);
    return;
  }
  srv_set_io_thread_op_info(segment, "complete io for fil node");
  mutex_enter(&fil_system->mutex);

  // 到这里表示至少有一个IO请求已经完成,该函数设置状态信息
  fil_node_complete_io(fil_node, fil_system, type);

  mutex_exit(&fil_system->mutex);

  ut_ad(fil_validate_skip());

  /* Do the i/o handling */
  /* IMPORTANT: since i/o handling for reads will read also the insert
  buffer in tablespace 0, you have to be very careful not to introduce
  deadlocks in the i/o system. We keep tablespace 0 data files always
  open, and use a special i/o thread to serve insert buffer requests. */

  if (fil_node->space->purpose == FIL_TABLESPACE) { // 数据文件读写IO
    srv_set_io_thread_op_info(segment, "complete io for buf page");
    // IO请求完成后,这里处理buffer pool对应的bpage相关的一些状态信息并根据checksum验证数据的正确性
    buf_page_io_complete(static_cast<buf_page_t*>(message));
  } else { // 日志文件的读写IO
    srv_set_io_thread_op_info(segment, "complete io for log");
    log_io_complete(static_cast<log_group_t*>(message));
  }
}
#endif /* UNIV_HOTBACKUP */
  • IO线程负责处理native IO请求的函数os_aio_linux_handle
ibool
os_aio_linux_handle(ulint	global_seg, // 属于哪个segment
					fil_node_t**message1, /* 该aio操作的innodb文件描述符(f_node_t)*/
					void**	message2, /* 用来记录完成IO请求所对应的具体buffer pool bpage页 */
					ulint*	type){ // 读or写IO
	// 根据global_seg获得该aio 的os_aio_array_t数组,并返回对应的segment
	segment = os_aio_get_array_and_local_segment(&array, global_seg); 
	n = array->n_slots / array->n_segments; //获得一个线程可监控的io event数
	/* Loop until we have found a completed request. */
	for (;;) {
		ibool	any_reserved = FALSE;
		os_mutex_enter(array->mutex);
		for (i = 0; i < n; ++i) {  // 遍历该线程所发起的所有aio请求
			slot = os_aio_array_get_nth_slot(
				array, i + segment * n); 
			if (!slot->reserved) {  // 该slot是否被占用
				continue;
			} else if (slot->io_already_done) {  // IO请求已经完成,可以通知主线程返回数据了
				/* Something for us to work on. */
				goto found;
			} else {
				any_reserved = TRUE;
			}
		}
		os_mutex_exit(array->mutex);
       // 到这里说明没有找到一个完成的io,则再去collect
		os_aio_linux_collect(array, segment, n); 
found:   // 找到一个完成的io,将内容返回
	*message1 = slot->message1;  
	*message2 = slot->message2; // 返回完成IO所对应的bpage页
	*type = slot->type;
	if (slot->ret == 0 && slot->n_bytes == (long) slot->len) {
		if (slot->page_encrypt
        && slot->type == OS_FILE_READ) {
      	os_decrypt_page(slot->buf, slot->len, slot->page_size, FALSE);
    }    

    ret = TRUE;
  } else {
    errno = -slot->ret;
    /* os_file_handle_error does tell us if we should retry
    this IO. As it stands now, we don't do this retry when
    reaping requests from a different context than
    the dispatcher. This non-retry logic is the same for
    windows and linux native AIO.
    We should probably look into this to transparently
    re-submit the IO. */
    os_file_handle_error(slot->name, "Linux aio");

    ret = FALSE;
  }

  os_mutex_exit(array->mutex);

  os_aio_array_free_slot(array, slot);
  return(ret);
}


  • 等待native IO请求完成os_aio_linux_collect
os_aio_linux_collect(os_aio_array_t* array,
 					ulint segment, 
					ulint seg_size){
	events = &array->aio_events[segment * seg_size]; // 定位segment所对应的io event的数组位置
	/* 获得该线程的aio上下文数组 */
	io_ctx = array->aio_ctx[segment];
	/* Starting point of the segment we will be working on. */
	start_pos = segment * seg_size;
	/* End point. */
	end_pos = start_pos + seg_size;


retry: 
	/* Initialize the events. The timeout value is arbitrary.
	  We probably need to experiment with it a little. */
	memset(events, 0, sizeof(*events) * seg_size);
	timeout.tv_sec = 0;
	timeout.tv_nsec = OS_AIO_REAP_TIMEOUT;

	ret = io_getevents(io_ctx, 1, seg_size, events, &timeout); // 阻塞等待该IO线程所监控的任一IO请求完成

	if (ret > 0) { // 有IO请求完成
		for (i = 0; i < ret; i++) {
       // 记录完成IO的请求信息到对应的os_aio_slot_t 对象
			os_aio_slot_t*	slot;
			struct iocb*	control;
			control = (struct iocb*) events[i].obj; // 获得完成的aio的iocb,即提交这个aio请求的iocb
			ut_a(control != NULL);
			slot = (os_aio_slot_t*) control->data; // 通过data获得这个aio iocb所对应的os_aio_slot_t
			/* Some sanity checks. */
			ut_a(slot != NULL);
			ut_a(slot->reserved);
			os_mutex_enter(array->mutex);
			slot->n_bytes = events[i].res; // 将该io执行的结果保存到slot里
			slot->ret = events[i].res2;
			slot->io_already_done = TRUE; // 标志该io已经完成了,这个标志也是外层判断的条件
			os_mutex_exit(array->mutex);
		}
		return;
	}
…
}

综上重点对InnoDB navtive IO读写数据文件从源码角度进行了分析,有兴趣的读者也可以继续了解InnoDB自带的simulated IO的实现过程,原理雷同native IO,只是在实现方式上自己进行了处理。本篇文章对InnoDB IO请求的执行流程进行了梳理,对重点数据结构以及函数进行了分析,希望对读者日后进行源码阅读及修改有所帮助。

MySQL · 引擎特性 · Group Replication内核解析

$
0
0

背景

为了创建高可用数据库系统,传统的实现方式是创建一个或多个备用的数据库实例,原有的数据库实例通常称为主库master,其它备用的数据库实例称为备库或从库slave。当master故障无法正常工作后,slave就会接替其工作,保证整个数据库系统不会对外中断服务。master与slaver的切换不管是主动的还是被动的都需要外部干预才能进行,这与数据库内核本身是按照单机来设计的理念悉悉相关,并且数据库系统本身也没有提供管理多个实例的能力,当slave数目不断增多时,这对数据库管理员来说就是一个巨大的负担。

MySQL的传统主从复制机制

MySQL传统的高可用解决方案是通过binlog复制来搭建主从或一主多从的数据库集群。主从之间的复制模式支持异步模式(async replication)和半同步模式(semi-sync replication)。无论哪种模式下,都是主库master提供读写事务的能力,而slave只能提供只读事务的能力。在master上执行的更新事务通过binlog复制的方式传送给slave,slave收到后将事务先写入relay log,然后重放事务,即在slave上重新执行一次事务,从而达到主从机事务一致的效果。
pic
上图是异步复制(Async replication)的示意图,在master将事务写入binlog后,将新写入的binlog事务日志传送给slave节点,但并不等待传送的结果,就会在存储引擎中提交事务。
pic
上图是半同步复制(Semi-sync replication)的示意图,在master将事务写入binlog后,将新写入的binlog事务日志传送给slave节点,但需要等待slave返回传送的结果;slave收到binlog事务后,将其写入relay log中,然后向master返回传送成功ACK;master收到ACK后,再在存储引擎中提交事务。
MySQL基于两种复制模式都可以搭建高可用数据库集群,也能满足大部分高可用系统的要求,但在对事务一致性要求很高的系统中,还是存在一些不足,主要的不足就是主从之间的事务不能保证时刻完全一致。

  • 基于异步复制的高可用方案存在主从不一致乃至丢失事务的风险,原因在于当master将事务写入binlog,然后复制给slave后并不等待slave回复即进行提交,若slave因网络延迟或其它问题尚未收到binlog日志,而此时master故障,应用切换到slave时,本来在master上已经提交的事务就会丢失,因其尚未传送到slave,从而导致主从之间事务不一致。
  • 基于semi-sync复制的高可用方案也存在主备不一致的风险,原因在于当master将事务写入binlog,尚未传送给slave时master故障,此时应用切换到slave,虽然此时slave的事务与master故障前是一致的,但当主机恢复后,因最后的事务已经写入到binlog,所以在master上会恢复成已提交状态,从而导致主从之间的事务不一致。

Group Replication应运而生

为了应对事务一致性要求很高的系统对高可用数据库系统的要求,并且增强高可用集群的自管理能力,避免节点故障后的failover需要人工干预或其它辅助工具干预,MySQL5.7新引入了Group Replication,用于搭建更高事务一致性的高可用数据库集群系统。基于Group Replication搭建的系统,不仅可以自动进行failover,而且同时保证系统中多个节点之间的事务一致性,避免因节点故障或网络问题而导致的节点间事务不一致。此外还提供了节点管理的能力,真正将整个集群做为一个整体对外提供服务。

Group Replication的实现原理

Group Replication由至少3个或更多个节点共同组成一个数据库集群,事务的提交必须经过半数以上节点同意方可提交,在集群中每个节点上都维护一个数据库状态机,保证节点间事务的一致性。Group Replication基于分布式一致性算法Paxos实现,允许部分节点故障,只要保证半数以上节点存活,就不影响对外提供数据库服务,是一个真正可用的高可用数据库集群技术。
Group Replication支持两种模式,单主模式和多主模式。在同一个group内,不允许两种模式同时存在,并且若要切换到不同模式,必须修改配置后重新启动集群。
在单主模式下,只有一个节点可以对外提供读写事务的服务,而其它所有节点只能提供只读事务的服务,这也是官方推荐的Group Replication复制模式。单主模式的集群如下图所示:
pic
在多主模式下,每个节点都可以对外提供读写事务的服务。但在多主模式下,多个节点间的事务可能有比较大的冲突,从而影响性能,并且对查询语句也有更多的限制,具体限制可参见使用手册。多主模式的集群如下图所示:
pic
MySQL Group Replication是建立在已有MySQL复制框架的基础之上,通过新增Group Replication Protocol协议及Paxos协议的实现,形成的整体高可用解决方案。与原有复制方式相比,主要增加了certify的概念,如下图所示:
pic
certify模块主要负责检查事务是否允许提交,是否与其它事务存在冲突,如两个事务可能修改同一行数据。在单机系统中,两个事务的冲突可以通过封锁来避免,但在多主模式下,不同节点间没有分布式锁,所以无法使用封锁来避免。为提高性能,Group Replication乐观地来对待不同事务间的冲突,乐观的认为多数事务在执行时是没有并发冲突的。事务分别在不同节点上执行,直到准备提交时才去判断事务之间是否存在冲突。下面以具体的例子来解释certify的工作原理:
pic
在上图中由3个节点形成一个group,当在节点s1上发起一个更新事务UPDATE,此时数据库版本dbv=1,更新数据行之后,准备提交之前,将其修改的数据集(write set)及事务日志相关信息发送到group,Write set中包含更新行的主键和此事务执行时的快照(由gtid_executed组成)。组内的每个节点收到certification请求后,进入certification环节,每个节点的当前版本cv=1,与write set相关的版本dbv=1,因为dbv不小于cv,也就是说事务在这个write set上没有冲突,所以可以继续提交。
下面是一个事务冲突的例子,两个节点同时更新同一行数据。如下图所示,
pic
在节点s1上发起一个更新事务T1,几乎同时,在节点s2上也发起一个更新事务T2,当T1在s1本地完成更新后,准备提交之前,将其writeset及更新时的版本dbv=1发送给group;同时T2在s2本地完成更新后,准备提交之前,将其writeset及更新时的版本dbv=1也发送给group。
此时需要注意的是,group组内的通讯是采用基于paxos协议的xcom来实现的,它的一个特性就是消息是有序传送,每个节点接收到的消息顺序都是相同的,并且至少保证半数以上节点收到才会认为消息发送成功。xcom的这些特性对于数据库状态机来说非常重要,是保证数据库状态机一致性的关键因素。
本例中我们假设先收到T1事务的certification请求,则发现当前版本cv=1,而数据更新时的版本dbv=1,所以没有冲突,T1事务可以提交,并将当前版本cv修改为2;之后马上又收到T2事务的certification请求,此时当前版本cv=2,而数据更新时的版本dbv=1,表示数据更新时更新的是一个旧版本,此事务与其它事务存在冲突,因此事务T2必须回滚。

核心组件XCOM的特性

MySQL Group Replication是建立在基于Paxos的XCom之上的,正因为有了XCom基础设施,保证数据库状态机在节点间的事务一致性,才能在理论和实践中保证数据库系统在不同节点间的事务一致性。
Group Replication在通讯层曾经历过一次比较大的变动,早期通讯层采用是的Corosync,而后来才改为XCom。
pic
主要原因在于corosync无法满足MySQL Group Replication的要求,如
1. MySQL支持各种平台,包括windows,而corosync不都支持;
2. corosync不支持SSL,而只支持对称加密方式,安全性达不到MySQL的要求;
3. corosync采用UDP,而在云端采用UDP进行组播或多播并不是一个好的解决方案。

此外MySQL Group Replication对于通讯基础设施还有一些更高的要求,最终选择自研xcom,包括以下特性:

  • 闭环(closed group):只有组内成员才能给组成员发送消息,不接受组外成员的消息。
  • 消息全局有序(total order):所有XCOM传递的消息是全局有序(在多主集群中或是偏序),这是构建MySQL 一致性状态机的基础。
  • 消息的安全送达(Safe Delivery):发送的消息必须传送给所有非故障节点,必须在多数节点确认收到后方可通知上层应用。
  • 视图同步(View Synchrony):在成员视图变化之前,每个节点都以相同的顺序传递消息,这保证在节点恢复时有一个同步点。实际上,组复制并不强制要求消息传递必须在同一个节点视图中。

总结

MySQL Group Replication旨在打造一款事务强一致性金融级的高可用数据库集群产品,目前还存在一些功能限制和不足,但它是未来数据库发展的一个趋势,从传统的主从复制到构建数据库集群,MySQL也在不断的前进,随着产品的不断完善和发展,必将成为引领未来数据库系统发展的潮流。

PgSQL · 特性介绍 · 列存元数据扫描介绍

$
0
0

摘要

本文通过对于阿里云分析型数据库HybridDB for postgresql 数据库的列存扫描的优化特征的解析,让大家了解列存元数据扫描是如何达到提升查询扫描的速度的效果。从而使的分析型查询执行时间进一步缩短。最终能够更好的为阿里云的用户提供更高性价比的服务。

关键字

Meta data scan,HybridDB for postgresql, GreenPlum,column store,MPP
元数据扫描,列存

一、前言

人类社会已经进入了大数据时代,在这个时代人们置身于数据的海洋里,谁能够比别人更好,更有效率的, 将对自己有用的数据提取出来,谁就能更有力的获得先机,通过对数据的处理分析,来达到对未来的决策。可以毫不夸张的说,谁能够更准确,更快的掌握数据,谁就能够领先他人。

分析型数据库就是对人们已经掌握的数据,进行分析汇总,实时计算并输出结果供人们在做决策的时候进行参考或者回顾。目前人们对于分析型数据库的要求越来越高,希望它能够处理越来越大规模的数据量,但是处理时间能够越来越短。正是基于这样的需求,分析型数据库的数据处理能力的提升便成为我们数据库内核研发人员的永恒的话题。

这篇文章,我们将为大家介绍分析型数据库提升扫描能力的一个特征:元数据扫描,通过对列存储格式的数据表增加元数据信息,从而达到提升数据扫描速度的效果。在第2部分,为大家简单介绍一下元数据扫描的原理与技术实现;
第3部分,为大家介绍元数据存储的设计原理与实现;第4部分为大家介绍 元数据扫描逻辑的设计原理与实现;第5部分,提供给大家一下性能测试的结果供大家参考。第6部分,为大家介绍一下后续我们还可以持续优化的部分。

二、 元数据扫描简介

所谓元数据扫描,就是通过对数据表增加额外的信息,从而在扫描数据的时候先利用这部分数据对整个扫描数据集进行粗力度的过滤,达到减少扫描数据表的IO总量,提升扫描效率的目标。

元数据扫描可以看为是一种比索引需要更低的维护成本,在某些场景中超越纯索引扫描或者纯数据表扫描的一种扫描模式。为什么元数据扫描拥有这些优点呢?
 1,首先元数据收集不需要完全精确,只需要收集一个集合元组在某一列上的最大,最小值(其中,最大,最小值可以是这一列上现有的值,也可以是不存在的值,当元组对应列值已被删除或被修改,只要原来所表示的最大,最小值范围仍然可以覆盖这一集合元组的最大,最小值,则我们不需要修改集合范围信息)。
 2,使用元数据扫描可以达到索引的效果,对于数据的过滤有提升的作用,同时他对于相对返回结果较大的扫描(分析型数据扫描)又能够优于全表扫描的效果。

元数据扫描还可以对与条件的过滤采取不同的算法,可以进一步提升过滤效率,这块内容我们会在以后的文章中给大家介绍。

三、元数据扫描存储设计与实现

为了达到元数据扫描过滤的高过滤能力,我们在元数据扫描的存储设计上采用了两层结构的设计,何为两层存储结构?
1. 两层存储结构就是首先以一万个元组为第一层,收集最大值,最小值
2. 在这一万个元组中,每1000个元组最为一个集合,单独收集这个集合中相应列的最大值,最小值。
这样设计为什么能够提升过滤能力?首先我们对整个10000个元组进行条件应用,如果不满足,则直接过滤掉这10000个元组。如果满足条件,我们在将这10000个元组划分为10个组,再对这10个组进行条件过滤,最大程度的减少实际扫描的数据量。数据存储示意图如图1显示:
pic

四、元数据扫描扫描逻辑设计于实现

收集完元数据之后,我们就要利用元数据信息在查询中获得性能的提升,那么如何在原来的列存扫描逻辑中应用元数据扫描逻辑呢?
1, 首先我们需要判断是否使用元数据扫描?
a) 参数rds_enable_cs_enhancement为on;
b) 参数rds_enable_column_meta_scan为on;
c) 元数据已经被收集;
d) 查询中含有条件过滤(目前只支持部分条件的元数据扫描,后面会详细介绍),如果查询无过滤条件,也不会应用元数据扫描;
2, 当我们选择使用元数据扫描之后,我们将会按照如下逻辑进行元数据扫描准备工作。
a) 首先在表查询条件中选择可以下推的条件;
i. 目前我们只支持数据类型与字符类型数据;
ii. 简单比较操作符条件的下推(<, > , = );
iii. 不支持OR条件的下推;
iv. 字符串类型我们只支持 = 操作符下推;
b) 对条件进行解析构造新的满足元数据扫描的新的条件;
我们将原生条件分为以下几类,分别生成新的过滤条件供元数据扫描使用;其中col表示元组某一个列,colmin, colmax表示该列在某一个元数据集合中的最小值与最大值。
i. col > 1 转换为 colmax > 1
pic
ii. col < 1 转换为 colmin <1
pic
iii. col = 1 转化为 colmin >1 and colmax <
pic
c) 读取元数据信息并拼装成元数据tuple;
这里拼装好新的元数据tuple之后,我们建立好colmin与colmax的在元数据tuple的位置关系与之前我们构造的过滤条件列的对应关系,然后使用数据库自带的条件判断逻辑进行条件过滤。

	d)	在找到满足条件的元数据集合之后,定位到数据文件的对应位置;
		直接根据元数据信息过滤后的记录号,定位到相应的实际数据文件相对应的位置开始扫描。

五、测试结果

我们选择了一些scan耗时占比较大的查询来进行测试,我们选出Q3, Q6, Q7, Q11, Q12, Q14, Q15, Q19, Q20,这几个查询。进行测试。

  1. 挑选出scan耗费时间相对较长的查询进行测试;
    在这次测试中,我们还针对第一轮测试性能未能提升的结果,得到了一个推论,原生数据可能分布比较均匀,Meta scan过滤没有起到作用,因此我们分析了以上选取的查询,将lineitem表数据按照字段L_SHIPDATE排序,这样我们的meta信息将会更好的过滤数据。下面我们利用第二轮测试来验证我们的分析。
    测试结果如图:
    pic

我们构筑图标连对比结果:
pic

Q12, Q19 因为过滤条件没有l_shipdate,其他字段过滤效率同样取决于这些字段的分布状态。总结一下,Meta scan要发挥最大作用,最好谓词上每个block的数据相对有序。并且返回结果集最好较全量数据有较大缩小。

  1. 比对scan部分提升结果;
    接着我们来看看scan部分的性能提升数据如图:
    pic
    从上图上来看,scan提升的幅度普遍大于,查询性能整体提升的幅度,这说明scan的时间占查询总时间比越大,meta scan可以发挥作用的空间就越大。

六、 总结与后续工作

元数据扫描作为列存扫描方式的优化补充,对于当前OLAP分析型查询的优化具有比较好的扫描提升效果。后续我们将继续对元数据扫描在文件定位方面进行优化。

MySQL · 源码分析 · MySQL replication partial transaction

$
0
0

replication 概述

目前MySQL支持的replication方式多种多样
1. 普通的master-slave 异步replication
2. 半同步的semi-sync replication
3. 支持多通道的group replication和double binlog

如果按连接协议来区分,又可以分为

  1. 非GTID模式,通过binlog文件名和文件的偏移来决定replication位点信息
  2. GTID模式,通过GTID信息来决定replication位点信息

如果按apply binglog的方式来区分,又可以分为

  1. 串行,按binlog event顺序依次执行
  2. 并行,以db, table或transaction为粒度的并行复制,以及基于group commit的LOGICAL_CLOCK并行复制

不论哪种replication, 都离不开replication最基本的组件,

  1. IO thread,负责从master拉取binlog.
  2. SQL thread,负责apply relay log binlog.

replication 异常

复制过程中,由于网络或者master主机宕机,都会造成slave IO thread异常中断。
例如以下事务在复制过程中发生上述异常,

SET GTID_NEXT;        # GTID设置为ON时           
BEGIN;  
INSERT row1;
INSERT row2;
COMMIT;

那么备库接收的binlog可能不包含完整的事务,备库可能仅接收到BEGIN,也可能只接收到INSERT row1.

然而,当IO thread恢复后,SQL线程怎么正确处理这种异常呢?

异常恢复

IO thread 异常中断后,SQL线程是正常工作的,SQL执行了部分事务, 它会等待IO 线程发送新的binlog. IO thread 线程恢复后,SQL线程可以选择继续执行事务或者回滚事务重新执行事务,这是由replication协议决定的。

  1. GTID模式下,设置auto_position=1时,slave会根据GTID信息,从事务起点开始,重新将事务完整binlog发给备库。此时,备库需要回滚之前的部分事务。

  2. GTID模式下,设置auto_position=0或非GTID模式下,slave会根据位点信息从master续传之前的binlog。此时,备库可以继续完成之前的部分事务。

继续执行事务比较简单,但是回滚之前的部分事务就比较复杂.

分为两种情况来分析:

  • 串行复制

    串行复制时,完整的事务会由SQL thread来执行,当执行到GTID_LOG_EVENT时,会发这个GTID已经分配过了,这时候就可以回滚事物。具体参考

Gtid_log_event::do_apply_event()

  if (thd->owned_gtid.sidno)
  {
    /*
      Slave will execute this code if a previous Gtid_log_event was applied
      but the GTID wasn't consumed yet (the transaction was not committed
      nor rolled back).
      On a client session we cannot do consecutive SET GTID_NEXT without
      a COMMIT or a ROLLBACK in the middle.
      Applying this event without rolling back the current transaction may
      lead to problems, as a "BEGIN" event following this GTID will
      implicitly commit the "partial transaction" and will consume the
      GTID. If this "partial transaction" was left in the relay log by the
      IO thread restarting in the middle of a transaction, you could have
      the partial transaction being logged with the GTID on the slave,
      causing data corruption on replication.
    */
    if (thd->transaction.all.ha_list)
    {
      /* This is not an error (XA is safe), just an information */
      rli->report(INFORMATION_LEVEL, 0,
                  "Rolling back unfinished transaction (no COMMIT ""or ROLLBACK in relay log). A probable cause is partial ""transaction left on relay log because of restarting IO ""thread with auto-positioning protocol.");
      const_cast<Relay_log_info*>(rli)->cleanup_context(thd, 1);
    }
    gtid_rollback(thd);
  }
  • 并行复制

    并行复制有别于串行复制,binlog event由worker线程执行。按串行复制的方式来回滚事务是行不通的,因为重新发送的事务binlog并不一定会分配原来的worker来执行。因此,回滚操作需交给coordinate线程(即sql线程)来完成。
    GTID模式下,设置auto_position=1时. IO thread重连时,都会发送
    ROTATE_LOG_EVENT和FORMAT_DESCRIPTION_EVENT. 并且FORMAT_DESCRIPTION_EVENT的log_pos>0. 通过非auto_position方式重连的FORMAT_DESCRIPTION_EVENT的log_pos在send之前会被置为0. SQL线程通过执行FORMAT_DESCRIPTION_EVENT且其log_pos>0来判断是否应进入回滚逻辑。而回滚是通过构造Rollback event让work来执行的。
    具体参考

exec_relay_log_event()
/*
      GTID protocol will put a FORMAT_DESCRIPTION_EVENT from the master with
      log_pos != 0 after each (re)connection if auto positioning is enabled.
      This means that the SQL thread might have already started to apply the
      current group but, as the IO thread had to reconnect, it left this
      group incomplete and will start it again from the beginning.
      So, before applying this FORMAT_DESCRIPTION_EVENT, we must let the
      worker roll back the current group and gracefully finish its work,
      before starting to apply the new (complete) copy of the group.
    */
    if (ev->get_type_code() == FORMAT_DESCRIPTION_EVENT &&
        ev->server_id != ::server_id && ev->log_pos != 0 &&
        rli->is_parallel_exec() && rli->curr_group_seen_gtid)
    {
      if (coord_handle_partial_binlogged_transaction(rli, ev))
        /*
          In the case of an error, coord_handle_partial_binlogged_transaction
          will not try to get the rli->data_lock again.
        */
        DBUG_RETURN(1);
    }

MySQL官方针对此问题有过多次改进,详见以下commit

666aec4a9e976bef4ddd90246c4a31dd456cbca3
3f6ed37fa218ef6a39f28adc896ac0d2f0077ddb
9e2140fc8764feeddd70c58983a8b50f52a12f18

异常case处理

当slave SQL线程处于部分事务异常时,按上节的逻辑,IO thread恢复后,复制是可以正常进行的。但如果IO thread如果长时间不能恢复,那么SQL apply线程会一直等待新的binlog, 并且会一直持有事务中的锁。当slave切换为master后,新master会接受用户连接处理事务,这样SQL apply线程持有的事务锁,可能阻塞用户线程的事务。这是我们不希望看到的。

此时可以通过stop slave来停止SQL apply线程,让事务回滚释放锁。

另一种更好的方案是让SQL apply 线程自动识别这种情况,并加以处理。比如,增加等待超时机制,超时后自动kill sql 线程或回滚SQL线程的部分事务。

MySQL · 特性分析 · 到底是谁执行了FTWL

$
0
0

什么是FTWL

FTWRL是FLUSH TABLES WITH READ LOCK的简称(FTWRL),该命令主要用于保证备份一致性备份。为了达到这个目的,它需要关闭所有表对象,因此这个命令的杀伤性很大,执行命令时容易导致库hang住。如果它在主库执行,则业务无法正常访问;如果在备库,则会导致SQL线程卡住,主备延迟。 FTWRL通过持有以下两把全局的MDL(MetaDataLock)锁:

  • 全局读锁(lock_global_read_lock) 会导致所有的更新操作被堵塞
  • 全局COMMIT锁(make_global_read_lock_block_commit) 会导致所有的活跃事务无法提交

FLUSH TABLES WITH READ LOCK执行后整个系统会一直处于只读状态,直到显示执行UNLOCK TABLES。这点请切记。

如何高效定位FTWL的执行会话

由于FTWL持有的是MDL锁,所以一旦它执行完成,你将无法以定位DML锁的方式来定位它。即在show processlist的结果和information_schema相关的表中找不到任何相关的线索。我们来看下面的一个例子:

[test]> flush tables with read lock;
Query OK, 0 rows affected (0.06 sec)

[test]> show full processlist\G
*************************** 1. row ***************************
      Id: 10
    User: root
    Host: localhost
      db: test
 Command: Query
    Time: 0
   State: init
    Info: show full processlist
Progress: 0.000
*************************** 2. row ***************************
      Id: 11
    User: root
    Host: localhost
      db: test
 Command: Query
    Time: 743
   State: Waiting for global read lock
    Info: delete from t0
Progress: 0.000
2 rows in set (0.00 sec)

[test]> select * from information_schema.processlist\G
*************************** 1. row ***************************
           ID: 11
         USER: root
         HOST: localhost
           DB: test
      COMMAND: Query
         TIME: 954
        STATE: Waiting for global read lock
         INFO: delete from t0
      TIME_MS: 954627.587
        STAGE: 0
    MAX_STAGE: 0
     PROGRESS: 0.000
  MEMORY_USED: 67464
EXAMINED_ROWS: 0
     QUERY_ID: 1457
  INFO_BINARY: delete from t0
          TID: 8838
*************************** 2. row ***************************
           ID: 10
         USER: root
         HOST: localhost
           DB: test
      COMMAND: Query
         TIME: 0
        STATE: Filling schema table
         INFO: select * from information_schema.processlist
      TIME_MS: 0.805
        STAGE: 0
    MAX_STAGE: 0
     PROGRESS: 0.000
  MEMORY_USED: 84576
EXAMINED_ROWS: 0
     QUERY_ID: 1461
  INFO_BINARY: select * from information_schema.processlist
          TID: 8424
2 rows in set (0.02 sec)

从上的输出中,我们只发现了会话11 在等候一个全局读锁。但这个锁被谁持有,从这个输出里面我们找不到任何线索。我现在再来看看INNODB STATUS输出:

...
------------
TRANSACTIONS
------------
Trx id counter 20439
Purge done for trx's n:o < 20422 undo n:o < 0 state: running but idle
History list length 176
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 0, not started
MySQL thread id 11, OS thread handle 0x7f7f5cdb8b00, query id 1457 localhost root Waiting for global read lock
delete from t0
---TRANSACTION 0, not started
MySQL thread id 10, OS thread handle 0x7f7f5ce02b00, query id 1462 localhost root init
show engine innodb status
--------
...

我们从引擎层也没有找到相关的线索。这个毫无疑问,在本文开始的时候就已经指出了FTWL持有的事MDL锁。
当然因为这个例子中只有两个会话,你一眼就可以看出来谁持有了全局读锁。如果是线上的环境,将会有成百上千个会话。那又怎么办呢?请继续往下看。那我们如何快速定位FTWL的锁呢?主要有下面三种方法:

  • 如果你用的Mysql 5.7,那么你可以使用performance_schema.metadata_locks

  • 如果你用的Mysql 5.6,那么你可以使用performance_schema.events_statements_history

  • 如果你用的Mysql版本比较老,那么可以使用genearal log或者一些sql审计的日志来定位

以上三种方法都是要开启的,默认情况这些方法是没有开启的。所以在工作中,我们会经常遇到这种情况。
整个库都被堵住了。数据库里出现了大量的Waiting for global read lock等待。但上面提到的三种方法又不适用于我们。所以接下来我会为大家用展示一种利用gdb去快速定位执行FTWL的会话。我们来看下面的例子:

会话1:

flush tables with read lock;
Query OK, 0 rows affected (0.00 sec)

会话2:
mysql> delete from t;  --被hang住

会话3:
mysql> show processlist;
+----+------+-----------+------+---------+------+------------------------------+------------------+
| Id | User | Host      | db   | Command | Time | State                        | Info             |
+----+------+-----------+------+---------+------+------------------------------+------------------+
|  7 | root | localhost | test | Query   |  227 | Waiting for global read lock | delete from t    |
|  8 | root | localhost | NULL | Sleep   |  215 |                              | NULL             |
|  9 | root | localhost | NULL | Query   |    0 | init                         | show processlist |
+----+------+-----------+------+---------+------+------------------------------+------------------+

由于会话1执行了FTWL,导致了会话2中的DML无法执行。接下来,我们演示如何通过gdb去定位执行了FTWL的会话。见下面的步骤

  1. 找出myql的进程id, ps -efgrep mysql

root 7743 2366 0 05:07 ? 00:00:01 /u02/mysql/bin/mysqld

2.利用gdb来跟踪mysql进程 执行 gdb -p 7743

3.在mysql把已经连接的会话保存在一个叫global_thread_list的全局变量中在这个变量中的thread有一个叫global_read_lock的变量来表示持有锁的情况。所以我们只有在gdb中找global_read_lock不为空的thread即可。所以我们在gdb中执行下面的语句

(gdb) pset global_thread_list THD*
elem[0]: $1 = (THD *) 0x4a55de0
elem[1]: $2 = (THD *) 0x4a5cf10
elem[2]: $3 = (THD *) 0x4b24aa0
Set size = 3

上面的命令输出了三个会话的内存地址。接下来我们根据这些内存地址去查找每个会话各自对应的global_read_lock

4.依次在dgb中打印上面三个会话中的global_read_lock和thread_id的值

(gdb) p ((THD *) 0x4a55de0)->global_read_lock
$4 = {
  static m_active_requests = 1, 
  m_state = Global_read_lock::GRL_NONE, 
  m_mdl_global_shared_lock = 0x0, 
  m_mdl_blocks_commits_lock = 0x0
}   //这个会话的Global_read_lock为空,不是我们要找的


(gdb) p ((THD *) 0x4a5cf10)->global_read_lock
$5 = {
  static m_active_requests = 1, 
  m_state = Global_read_lock::GRL_NONE, 
  m_mdl_global_shared_lock = 0x0, 
  m_mdl_blocks_commits_lock = 0x0
}   //这个会话的Global_read_lock也为空,不是我们要找的


(gdb) p ((THD *) 0x4b24aa0)->global_read_lock
$6 = {
  static m_active_requests = 1, 
  m_state = Global_read_lock::GRL_ACQUIRED_AND_BLOCKS_COMMIT, 
  m_mdl_global_shared_lock = 0x7f6034002bb0, 
  m_mdl_blocks_commits_lock = 0x7f6034002c20
}   
//这个会话的Global_read_lock不为空,GRL_ACQUIRED_AND_BLOCKS_COMMIT表示全局读锁与commit锁,这个就是我们要好的。我接下来打印出它的thread_id
p ((THD *) 0x4b24aa0)->thread_id
$7 = 8 //8号会话执行了FTWL

5.我们可以通过执行kill 8结束这个会话来释放全局的锁。让被堵住的会话,继续运行下去。

在新开的mysql会话中,执行下面的语句

mysql> kill 8

以前被堵在的会话中,会看到下面的结果
mysql> delete from t;
Query OK, 0 rows affected (40 min 20.73 sec)

小结

由于FTWL持有的是MetaDataLock类型的锁,所以给我们定位问题的源头带来很大的困难。很多同学在解决类似的问题的时候,会把运行时间最长的几个会话杀掉。这种方法并不可取。因为造成拥堵的源头并没有找到。所以我给大家提供了一个利用调试工具抓取mysql内部状态变量的方法来定位这类问题的源头。希望大家喜欢。

MySQL · 源码分析 · mysql认证阶段漫游

$
0
0

client发起一个连接请求, 到拿到server返回的ok包之间, 走三次握手, 交换了[不可告人]的验证信息, 这期间mysql如何完成校验工作?

过程(三次握手)

没加滤镜的三次握手

信息是如何加密的

client:

hash_stage1 = sha1(password)
hash_stage2 = sha1(hash_stage1)
reply = sha1(scramble, hash_stage2) ^ hash_stage1

server: (逻辑位于sql/password.c:check_scramble_sha1中, 下文亦有提及)

// mysql.user表中, 对应user的passwd实际上是hash_stage2
res1 = sha1(scramble, hash_stage2)
hash_stage1 = reply ^ res1
hash_stage2_reassured = sha1(hash_stage1)
再根据hash_stage2_reassured == hash_stage2(from mysql.user)是否一致来判定是否合法

涉事函数们

如图, client发起连接请求, server创建新的线程, 并进入acl_authenticate(5.7位于sql/auth/sql_authentication.cc, 5.6位于sql/sql_acl.cc)函数完成信息验证, 并把包里读出的信息更新到本线程.

流程堆栈:

#0  parse_client_handshake_packet 
#1  server_mpvio_read_packet 
#2  native_password_authenticate
#3  do_auth_once 
#4  acl_authenticate 
#5  check_connection 
#6  login_connection 
#7  thd_prepare_connection
#8  do_handle_one_connection

接下来考察这些函数中做了哪些事.
check_connection(sql/sql_connect.cc)
当接收到client的建连接请求时, 进入check_connection, 先对连接本身上下文分析(socket, tcp/ip的v4/6 哇之类的)
当然你用very long的host连进来, 也会在这里被cut掉防止overflow.
不合法的ip/host也会在这里直接返回, 如果环境ok, 就进入到acl_authenticate的逻辑中
acl_authenticate:
初始化MPVIO_EXT, 用于保存验证过程的上下文; 字符集, 挑战码, …的坑位, 上锁, 根据command进行分派, (新建链接为COM_CONNECT

COM_CONNECT下会进入函数do_auth_once(), 返回值直接决定握手成功与否.
先对authentication plugin做判定, 咱们这里基于”mysql_native_password”的情况

if (plugin)
  {
    st_mysql_auth *auth= (st_mysql_auth *) plugin_decl(plugin)->info;
    res= auth->authenticate_user(mpvio, &mpvio->auth_info);  
    ...

在mysql_native_password时会进入native_password_authenticate 逻辑:

  /* generate the scramble, or reuse the old one */
  if (mpvio->scramble[SCRAMBLE_LENGTH])
    create_random_string(mpvio->scramble, SCRAMBLE_LENGTH, mpvio->rand);

  /* send it to the client */
  if (mpvio->write_packet(mpvio, (uchar*) mpvio->scramble, SCRAMBLE_LENGTH + 1)) 
    DBUG_RETURN(CR_AUTH_HANDSHAKE);

  /* read the reply with the encrypted password */
  if ((pkt_len= mpvio->read_packet(mpvio, &pkt)) < 0)                                                                                        
    DBUG_RETURN(CR_AUTH_HANDSHAKE);
  DBUG_PRINT("info", ("reply read : pkt_len=%d", pkt_len));

可见这里才生成了挑战码并发送到client, 再调用mpvio->read_packet等待client回包,
进入server_mpvio_read_packet,
这里的实现则调用常见的my_net_read读包,
当拿到auth包时, 逻辑分派到parse_client_handshake_packet, 对包内容进行parse, 这里会根据不同client protocol, 去掉头和尾, 还对client是否设置了ssl做判定. 接着:

  if (mpvio->client_capabilities & CLIENT_SECURE_CONNECTION)                                                                                 
  {
    /*    
      Get the password field.
    */
    passwd= get_length_encoded_string(&end, &bytes_remaining_in_packet,
                                      &passwd_len);
  }
  else  
  {
    /*    
      Old passwords are zero terminatedtrings.
    */
    passwd= get_string(&end, &bytes_remaining_in_packet, &passwd_len);
  }
  ...

在拿到了client发来的加密串(虽然叫passwd), 暂时存放在内存中, 返回native_password_authenticate,
当判定为需要做password check时(万一有人不设置密码呢), 进入check_scramble, 这个函数中才实现了对密码的验证:

// server decode回包中的加密信息
// 把上面提到的三个公式包在函数中
my_bool
check_scramble_sha1(const uchar *scramble_arg, const char *message,
                    const uint8 *hash_stage2)
{
  uint8 buf[SHA1_HASH_SIZE];
  uint8 hash_stage2_reassured[SHA1_HASH_SIZE];

  /* create key to encrypt scramble */
  compute_sha1_hash_multi(buf, message, SCRAMBLE_LENGTH,
                          (const char *) hash_stage2, SHA1_HASH_SIZE);
  /* encrypt scramble */
  my_crypt((char *) buf, buf, scramble_arg, SCRAMBLE_LENGTH);

  /* now buf supposedly contains hash_stage1: so we can get hash_stage2 */
  compute_sha1_hash(hash_stage2_reassured, (const char *) buf, SHA1_HASH_SIZE);

  return MY_TEST(memcmp(hash_stage2, hash_stage2_reassured, SHA1_HASH_SIZE));
}

native_password_authenticate拿到check_scamble的返回值, 返回OK,
再返回到acl_authenticate, 讲mpvio中环境信息更新到线程信息THD中, successful login~

(所以可以魔改这块代码搞事, 密码什么的, 权限什么的….
(我就说说, 别当真


MySQL · 源码分析 · 内存分配机制

$
0
0

前言

内存资源由操作系统管理,分配与回收操作可能会执行系统调用(以 malloc 算法为例,较大的内存空间分配接口是 mmap, 而较小的空间 free 之后并不归还给操作系统 ),频繁的系统调用必然会降低系统性能,但是可以最大限度的把使用完毕的内存让给其它进程使用,相反长时间占有内存资源可以减少系统调用次数,但是内存资源不足会导致操作系统频繁换页,降低服务器的整体性能。

数据库是使用内存的“大户”,合理的内存分配机制就尤为重要,上一期月报介绍了 PostgreSQL 的内存上下文,本文将介绍在 MySQL 中又是怎么管理内存的。

基础接口封装

MySQL 在基本的内存操作接口上面封装了一层,增加了控制参数 my_flags

void *my_malloc(size_t size, myf my_flags)
void *my_realloc(void *oldpoint, size_t size, myf my_flags)
void my_free(void *ptr)

my_flags 的值目前有:

MY_FAE 		/* Fatal if any error */
MY_WME			/* Write message on error */
MY_ZEROFILL	/* Fill array with zero */

MY_FAE 表示内存分配失败就退出整个进程,MY_WME 表示内存分配失败是否需要记录到日志中,MY_ZEROFILL 表示分配内存后初始化为0。

MEM_ROOT

基本结构

在 MySQL 的 Server 层中广泛使用 MEM_ROOT 结构来管理内存,避免频繁调用封装的基础接口,也可以统一分配和管理,防止发生内存泄漏。不同的 MEM_ROOT 之间互相没有影响,不像 PG 中不同的内存上下文之间还有关联。这可能得益于 MySQL Server 层是面向对象的代码,MEM_ROOT 作为类中的一个成员变量,伴随着对象的整个生命周期。比较典型的类有: THD,String, TABLE, TABLE_SHARE, Query_arena, st_transactions 等。

MEM_ROOT 分配内存的单元是 Block,使用 USED_MEM 结构体来描述。结构比较简单,Block 之间相互连接形成内存块链表,left 和 size 表示对应 Block 还有多少可分配的空间和总的空间大小。

typedef struct st_used_mem
{				   /* struct for once_alloc (block) */
  struct st_used_mem *next;	   /* Next block in use */
  unsigned int	left;		   /* memory left in block  */
  unsigned int	size;		   /* size of block */
} USED_MEM;

而 MEM_ROOT 结构体负责管理 Block 链表 :

typedef struct st_mem_root
{
  USED_MEM *free;                  /* blocks with free memory in it */
  USED_MEM *used;                  /* blocks almost without free memory */
  USED_MEM *pre_alloc;             /* preallocated block */
  /* if block have less memory it will be put in 'used' list */
  size_t min_malloc;
  size_t block_size;               /* initial block size */
  unsigned int block_num;          /* allocated blocks counter */
  /* 
     first free block in queue test counter (if it exceed 
     MAX_BLOCK_USAGE_BEFORE_DROP block will be dropped in 'used' list)
  */
  unsigned int first_block_usage;

  void (*error_handler)(void);
} MEM_ROOT;

整体结构就是两个 Block 链表,free 链表管理所有的仍然存在可分配空间的 Block,used 链表管理已经没有可分配空间的所有 Block。pre_alloc 类似于 PG 内存上下文中的 keeper,在初始化 MEM_ROOT 的时候就可以预分配一个 Block 放到 free 链表中,当 free 整个 MEM_ROOT 的时候可以通过参数控制,选择保留 pre_alloc 指向的 Block。min_malloc 控制一个 Block 剩余空间还有多少的时候从 free 链表移除,加入到 used 链表中。block_size 表示初始化 Block 的大小。block_num 表示 MEM_ROOT 管理的 Block 数量。first_block_usage 表示 free 链表中第一个 Block 不满足申请空间大小的次数,是一个调优的参数。err_handler 是错误处理函数。

分配流程

使用 MEM_ROOT 首先需要初始化,调用 init_alloc_root, 通过参数可以控制初始化的 Block 大小和 pre_alloc_size 的大小。其中比较有意思的点是 min_block_size 直接指定一个值 32,个人觉得不太灵活,对于小内存的申请可能会有比较大的内存碎片。另一个是 block_num 初始化为 4,这个和决定新分配的 Block 大小策略有关。

void init_alloc_root(MEM_ROOT *mem_root, size_t block_size,
                     size_t pre_alloc_size __attribute__((unused)))
{
  mem_root->free= mem_root->used= mem_root->pre_alloc= 0;
  mem_root->min_malloc= 32;
  mem_root->block_size= block_size - ALLOC_ROOT_MIN_BLOCK_SIZE;
  mem_root->error_handler= 0;
  mem_root->block_num= 4;                       /* We shift this with >>2 */
  mem_root->first_block_usage= 0;

  if (pre_alloc_size)
  {
    if ((mem_root->free= mem_root->pre_alloc=
         (USED_MEM*) my_malloc(pre_alloc_size+ ALIGN_SIZE(sizeof(USED_MEM)),
                               MYF(0))))
    {
      mem_root->free->size= pre_alloc_size+ALIGN_SIZE(sizeof(USED_MEM));
      mem_root->free->left= pre_alloc_size;
      mem_root->free->next= 0;
      rds_update_query_size(mem_root, mem_root->free->size, 0);
    }
  }
  DBUG_VOID_RETURN;
}

初始化完成就可以调用 alloc_root 进行内存申请,整个分配流程并不复杂,代码也不算长,为了方便阅读贴出来,也可以略过直接看分析。

void *alloc_root( MEM_ROOT *mem_root, size_t length )
{
    size_t        get_size, block_size;
    uchar        * point;
    reg1 USED_MEM    *next = 0;
    reg2 USED_MEM    **prev;

    length = ALIGN_SIZE( length );
    if ( (*(prev = &mem_root->free) ) != NULL ) // 判断 free 链表是否为空
    {
        if ( (*prev)->left < length &&
             mem_root->first_block_usage++ >= ALLOC_MAX_BLOCK_USAGE_BEFORE_DROP &&
             (*prev)->left < ALLOC_MAX_BLOCK_TO_DROP ) // 优化策略
        {
            next                = *prev;
            *prev                = next->next; /* Remove block from list */
            next->next            = mem_root->used;
            mem_root->used            = next;
            mem_root->first_block_usage    = 0;
        }
        // 找到一个空闲空间大于申请内存空间的 Block 
        for ( next = *prev; next && next->left < length; next = next->next )
            prev = &next->next;
    }
    if ( !next ) // free 链表为空,或者没有满足可分配条件 Block
    {       /* Time to alloc new block */
        block_size    = mem_root->block_size * (mem_root->block_num >> 2);
        get_size    = length + ALIGN_SIZE( sizeof(USED_MEM) );
        get_size    = MY_MAX( get_size, block_size );

        if ( !(next = (USED_MEM *) my_malloc( get_size, MYF( MY_WME | ME_FATALERROR ) ) ) )
        {
            if ( mem_root->error_handler )
                (*mem_root->error_handler)();
            DBUG_RETURN( (void *) 0 );                              /* purecov: inspected */
        }
        mem_root->block_num++;
        next->next    = *prev;
        next->size    = get_size;
        next->left    = get_size - ALIGN_SIZE( sizeof(USED_MEM) );    
        *prev        = next;		// 新申请的 Block 放到 free 链表尾部
    }

    point = (uchar *) ( (char *) next + (next->size - next->left) );
    if ( (next->left -= length) < mem_root->min_malloc )  // 分配完毕后,Block 是否还能在 free 链表中继续分配
    {                                                                       /* Full block */
        *prev                = next->next;                   /* Remove block from list */
        next->next            = mem_root->used;
        mem_root->used            = next;
        mem_root->first_block_usage    = 0;
    }
}

首先判断 free 链表是否为空,如果不为空,按逻辑应该遍历整个链表,找到一个空闲空间足够大的 Block,但是看代码是先执行了一个判断语句,这其实是一个空间换时间的优化策略,因为free 链表大多数情况下都是不为空的,几乎每次分配都需要从 free 链表的第一个 Block 开始判断,我们当然希望第一个 Block 可以立刻满足要求,不需要再扫描 free 链表,所以根据调用端的申请趋势,设置两个变量:ALLOC_MAX_BLOCK_USAGE_BEFORE_DROP 和 ALLOC_MAX_BLOCK_TO_DROP,当 free 链表的第一个 Block 申请次数超过 ALLOC_MAX_BLOCK_USAGE_BEFORE_DROP 而且剩余的空闲空间小于 ALLOC_MAX_BLOCK_TO_DROP,就把这个 Block 放到 used 链表里,因为它已经一段时间无法满足调用端的需求了。

如果在 free 链表中没有找到合适的 Block,就需要调用基础接口申请一块新的内存空间,新的内存空间大小当然至少要满足这次申请的大小,同时预估的新 Block 大小是 : mem_root->block_size * (mem_root->block_num >> 2)也就是初始化的 Block 大小乘以当前 Block 数量的 1/4,所以初始化 MEM_ROOT 的 block_num 至少是 4。

找到合适的 Block 之后定位到可用空间的位置就行了,返回之前最后需要判断 Block 分配之后是否需要移动到 used 链表。

归还内存空间的接口有两个:mark_blocks_free(MEM_ROOT *root)free_root(MEN_ROOT *root,myf MyFlags),可以看到两个函数的参数不像基础封装的接口,没有直接传需要归还空间的指针,传入的是 MEM_ROOT 结构体指针,说明对于 MEM_ROOT 分配的内存空间,是统一归还的。mark_blocks_free不真正的归还 Block,而是放到 free 链表中标记可用。free_root真正归还空间给操作系统,MyFlages 可以控制是否和标记删除的函数行为一样,也可以控制 pre_alloc 指向的 Block 是否归还。

总结

  • 从空间利用率上来讲,MEM_ROOT 的内存管理方式在每个 Block 上连续分配,内部碎片基本在每个 Block 的尾部,由 min_malloc 成员变量和参数 ALLOC_MAX_BLOCK_USAGE_BEFORE_DROP,ALLOC_MAX_BLOCK_TO_DROP 共同决定和控制,但是 min_malloc 的值是在代码中写死的,有点不够灵活,可以考虑写成可配置的,同时如果写超过申请长度的空间,就很有可能会覆盖后面的数据,比较危险。但相比 PG 的内存上下文,空间利用率肯定是会高很多的。
  • 从时间利用率上来讲,不提供 free 一个 Block 的操作,基本上一整个 MEM_ROOT 使用完毕才会全部归还给操作系统,可见 MySQL 在内存上面还是比较“贪婪”的。
  • 从使用方式上来讲,因为 MySQL 拥有多个存储引擎,引擎之上的 Server 层是面向对象的 C++ 代码,MEM_ROOT 常常作为对象中的一个成员变量,在对象的生命周期内分配内存空间,在对象析构的时候回收,引擎的内存申请使用封装的基本接口。相比之下 MySQL 的使用方式更加多元,PG 的统一性和整体性更好。

PgSQL · 源码分析 · PG 优化器中的pathkey与索引在排序时的使用

$
0
0

概要

SQL在PostgreSQL中的处理,是类似于流水线方式的处理,先后由:

  • 词法、语法解析,生成解析树后,将其交给语义解析
  • 语义解析,生成查询树,将其交给Planner
  • Planner根据查询树,生成执行计划,交给执行器
  • 执行器执行完成后返回结果

数据库优化器在生成执行计划的时候,优化器会考虑是否需要使用索引,而使用了索引之后,则会考虑如何利用索引已经排过序的特点,来优化相关的排序,比如ORDER BY / GROUP BY等。

先来看个索引对ORDER BY起作用的例子:

postgres=# create table t(id int, name text, value int);
CREATE TABLE
postgres=# create index t_value on t(value);
CREATE INDEX
postgres=# explain select * from 
postgres-# t order by value;
                              QUERY PLAN                              
----------------------------------------------------------------------
 Index Scan using t_value on t  (cost=0.15..61.55 rows=1160 width=40)
(1 row)

postgres=# explain select * from 
t order by name;
                         QUERY PLAN                         
------------------------------------------------------------
 Sort  (cost=80.64..83.54 rows=1160 width=40)
   Sort Key: name
   ->  Seq Scan on t  (cost=0.00..21.60 rows=1160 width=40)
(3 rows)

由此可见,通过索引进行查询后,是可以直接利用已经索引的有序需不需要再次进行排序。

本文将介绍优化器如何在已有索引的基础上,优化排序的。

SQL的流水线处理

数据库以流水线的方式处理SQL请求,当一个SQL到来后:

pic

以SQL中SELECT语句的基本表达形式为例:

SELCT $targets FROM $tables_or_sub_queries WHERE $quals GROUP BY $columns ORDER BY $columns LIMIT $num OFFSET $columns;

为了表示一个SELECT的语句,语义解析之前是SelectStmt结构,其中包括targetlist、FROM 子句、WHERE子句、GROUP BY子句等。

在语义解析之后,会引入一个Query结构,该Query结构只表示当前语句中的内容,并不直接包括需要递归的子句,比如子查询(子查询用RangeTblEntry描述,存放在Query->rtable列表中)等。在Query之后,优化器根据其中的内容生成RelOptInfo,作为整个执行计划的入口。

Query 结构如下,此处我们着重关注rtablejointree

   95	/*
   96  * Query -
   97  *    Parse analysis turns all statements into a Query tree
   98  *    for further processing by the rewriter and planner.
   99  *
  100  *    Utility statements (i.e. non-optimizable statements) have the
  101  *    utilityStmt field set, and the rest of the Query is mostly dummy.
  102  *
  103  *    Planning converts a Query tree into a Plan tree headed by a PlannedStmt
  104  *    node --- the Query structure is not used by the executor.
  105  */
  106 typedef struct Query
  107 {
  108     NodeTag     type;
  109 
  110     CmdType     commandType;    /* select|insert|update|delete|utility */
  111 
  112     QuerySource querySource;    /* where did I come from? */
  113 
  114     uint32      queryId;        /* query identifier (can be set by plugins) */
  115 
  116     bool        canSetTag;      /* do I set the command result tag? */
  117 
  118     Node       *utilityStmt;    /* non-null if commandType == CMD_UTILITY */
  119 
  120     int         resultRelation; /* rtable index of target relation for
  121                                  * INSERT/UPDATE/DELETE; 0 for SELECT */
  122 
  ...
  
  
  133     List       *cteList;        /* WITH list (of CommonTableExpr's) */
  134 
  135     List       *rtable;         /* list of range table entries */
  136     FromExpr   *jointree;       /* table join tree (FROM and WHERE clauses) */
  137 
  138     List       *targetList;     /* target list (of TargetEntry) */
  139 
  
  ...
  
  146     List       *groupClause;    /* a list of SortGroupClause's */
  147 

  156     List       *sortClause;     /* a list of SortGroupClause's */
  157 
  158     Node       *limitOffset;    /* # of result tuples to skip (int8 expr) */
  159     Node       *limitCount;     /* # of result tuples to return (int8 expr) */

  ...
  
  180     int         stmt_len;       /* length in bytes; 0 means "rest of string" */
  181 } Query;  

在Query中,此次最关注的是由$tables_or_sub_queries解析得来的Query->rtable。Query->rtable是一个RangeTblEntry的列表,用于表示$tables_or_sub_queries中的以下几种类型:

  • 子查询,表示出另外一个子句,但不包含子句中的Query,而是由RangeTblEntry中的subquery来描述其对应的Query
  • JOIN,除了将JOIN相关的表添加到Query->rtable外,还会加入一个RangeTblEntry表示JOIN表达式用于后面的执行计划
  • 函数

同时也会添加到相应的ParseState->p_joinlist,后转换为FromExpr作为Query->jointree。后面的执行计划生成阶段主要依赖Query->jointree和Query->rtable用于处理pathkey相关的信息

执行计划生成

在SQL的操作中,几乎所有的操作(比如查询)最终都会落在实际的表上,那么在执行计划中表的表示就比较重要。PostgreSQL用RelOptInfo结构体来表示,如下:


  518 typedef struct RelOptInfo
  519 {
  520     NodeTag     type;
  521 
  522     RelOptKind  reloptkind;
  523 
  524     /* all relations included in this RelOptInfo */
  525     Relids      relids;         /* set of base relids (rangetable indexes) */
  526 

  ...  

  537 
  538     /* materialization information */
  539     List       *pathlist;       /* Path structures */
  540     List       *ppilist;        /* ParamPathInfos used in pathlist */
  541     List       *partial_pathlist;   /* partial Paths */
  542     struct Path *cheapest_startup_path;
  543     struct Path *cheapest_total_path;
  544     struct Path *cheapest_unique_path;
  545     List       *cheapest_parameterized_paths;
  
  ...
  
  552     /* information about a base rel (not set for join rels!) */
  553     Index       relid;
  554     Oid         reltablespace;  /* containing tablespace */
  555     RTEKind     rtekind;        /* RELATION, SUBQUERY, FUNCTION, etc */
  
  ...
  
  562     List       *indexlist;      /* list of IndexOptInfo */
  563     List       *statlist;       /* list of StatisticExtInfo */

  ...
  584     /* used by various scans and joins: */
  585     List       *baserestrictinfo;   /* RestrictInfo structures (if base rel) */
  586     QualCost    baserestrictcost;   /* cost of evaluating the above */
  587     Index       baserestrict_min_security;  /* min security_level found in
  588                                              * baserestrictinfo */
  589     List       *joininfo;       /* RestrictInfo structures for join clauses

  ...
  595 } RelOptInfo;

事实上,RelOptInfo是执行计划路径生成的主要数据结构,同样用于表述表、子查询、函数等。

在SQL查询中,JOIN是最为耗时,执行计划的生成首先考虑JOIN。因此,整个执行计划路径的入口即为一个JOIN类型的RelOptInfo。当只是单表的查询时,则执行计划入口为这张表的RelOptInfo。

执行计划的生成过程,就是从下往上处理到最上层的RelOptInfo->pathlist的过程,选择有成本较优先节点、删除无用节点,最后得到一个成本最优的执行计划。

在整个过程中,大约分为以下几步:

  • 获取表信息
  • 创建表RelOptInfo,将所有该表的扫瞄路径加入到该表的RelOptInfo->pathlist
  • 创建JOIN的RelOptInfo,将所有可能的JOIN顺序和方式以Path结构体添加到RelOptInfo->pathlist
  • 针对JOIN的RelOptInfo,添加GROUP BY、ORDER BY等节点

生成范围表的扫瞄节点

执行计划一开始,即首先将获取所有的表信息,并以RelOptInfo(baserel)存放在PlannerInfo结构体中的simple_rel_array中,如RelOptInfo中的indexlist用于表示这张表的索引信息,用于判断是否可以用上索引。

为每张表建立扫瞄路径,一般有顺序扫瞄和索引扫瞄两种。扫瞄路径用Path结构体来表示,并存放在该表对应的RelOptInfo->pathlist中。Path结构体如下:

  948 typedef struct Path
  949 {
  950     NodeTag     type;
  951 
  952     NodeTag     pathtype;       /* tag identifying scan/join method */
  953 
  954     RelOptInfo *parent;         /* the relation this path can build */
  955     PathTarget *pathtarget;     /* list of Vars/Exprs, cost, width */
  956 
  957     ParamPathInfo *param_info;  /* parameterization info, or NULL if none */
  958 
  959     bool        parallel_aware; /* engage parallel-aware logic? */
  960     bool        parallel_safe;  /* OK to use as part of parallel plan? */
  961     int         parallel_workers;   /* desired # of workers; 0 = not parallel */
  962 
  963     /* estimated size/costs for path (see costsize.c for more info) */
  964     double      rows;           /* estimated number of result tuples */
  965     Cost        startup_cost;   /* cost expended before fetching any tuples */
  966     Cost        total_cost;     /* total cost (assuming all tuples fetched) */
  967 
  968     List       *pathkeys;       /* sort ordering of path's output */
  969     /* pathkeys is a List of PathKey nodes; see above */
  970 } Path;

在添加表的扫瞄路径时,会首先添加顺序扫瞄(seqscan)到这张表的RelOptInfo->pathlist,保证表数据的获取。而后考虑indexscan扫瞄节点等其他方式。

当RelOptInfo->indexlist满足RelOptInfo->baserestrictinfo中的过滤条件,或满足RelOptInfo->joininfo等条件时,则认为index是有效的。然后根据统计信息(如过滤性等)计算成本后,建立index扫瞄节点。

在建立index扫瞄节点时,根据索引建立时的情况(排序顺序、比较操作符等),创建PathKeys的列表(可能多个字段),存放在IndexPath->Path->pathkeys中。PathKeys的结构体如下:

  830 /*
  831  * PathKeys
  832  *
  833  * The sort ordering of a path is represented by a list of PathKey nodes.
  834  * An empty list implies no known ordering.  Otherwise the first item
  835  * represents the primary sort key, the second the first secondary sort key,
  836  * etc.  The value being sorted is represented by linking to an
  837  * EquivalenceClass containing that value and including pk_opfamily among its
  838  * ec_opfamilies.  The EquivalenceClass tells which collation to use, too.
  839  * This is a convenient method because it makes it trivial to detect
  840  * equivalent and closely-related orderings. (See optimizer/README for more
  841  * information.)
  842  *
  843  * Note: pk_strategy is either BTLessStrategyNumber (for ASC) or
  844  * BTGreaterStrategyNumber (for DESC).  We assume that all ordering-capable
  845  * index types will use btree-compatible strategy numbers.
  846  */
  847 typedef struct PathKey
  848 {
  849     NodeTag     type;
  850 
  851     EquivalenceClass *pk_eclass;    /* the value that is ordered */
  852     Oid         pk_opfamily;    /* btree opfamily defining the ordering */
  853     int         pk_strategy;    /* sort direction (ASC or DESC) */
  854     bool        pk_nulls_first; /* do NULLs come before normal values? */
  855 } PathKey;
  856 

事实上,PathKeys可以用于所有已排过序的RelOptInfo中,用于表示这个表、函数、子查询、JOIN等是有序的,作为上层判断选择Path的依据之一。

在建立除seqscan之外的其他节点时,会与pathlist中已有的每个节点根据启动成本和总体成本做对比(相差在一定比值,默认1%),则分为四种情况:

  • 新建节点和已有节点,其中一方启动成本和总成本都更优,且其pathkeys也更优,那么删除另外一个

  • 新建节点和所有已有节点的启动成本和总成本两方面的对比不一致(如总成本高但启动成本较低,或反过来),且新建节点总成本较低,则会全部保留并添加到RelOptInfo->pathlist中。

  • 新节点和已有节点,其中一方启动成和总成本都更优,但其pathkeys不够,则两者都保留,由上层Path节点来判断

  • 当新建节点和已有节点成本相同时,则对比两者的pathkeys,选择保留更优pathkeys的节点

此时,即完成一张表所有的Path的生成,保存在该表的RelOptInfo->pathlist中,并从中选择一条成本最低的Path,作为RelOptInfo->cheapest_total_path。索引扫瞄节点的pathkeys将会被上层路径在与排序相关节点中用到,如ORDER BY、GROUP BY、MERGE JOIN等。

生成JOIN节点

JOIN节点生成的算法较为复杂,简单来说,会针对所有参与JOIN的表,动态规划不同的顺序和JOIN方式,然后生成不同的Path加到这个JOIN的RelOptInfo->pathlist中。

最终执行计划的生成

在完成JOIN的各个路径判断后,针对各路径选择成本最低的Path(表的JOIN顺序和JOIN方式)作为最优路径,并依据这个路径上的pathkeys处理ORDER BY、GROUP BY等其他子句的计划,从而完成最终的执行计划。

在前面的介绍中,每张表的RelOptInfo->pathlist中的indexscan的Path都带有pathkeys信息,即表明这个节点执行完之后的结果是按pathkeys来排序的。那么在以下几个地方则可以用到该特性:

  • MERGE JOIN

    在建立JOIN节点时,会有多种JOIN方式可以选择,如NESTLOOP、MERGE JOIN等。当建立了MERGE JOIN节点之后,一般是需要对两张表进行排序。但当某张表的扫瞄节点返回的是有序的,且该顺序与查询所需完全一致,则会去除这个排序节点,从而在成本上占据优势。

  • ORDER BY

    当最终的RelOptInfo节点建立完成后,会拿表RelOptInfo->pathlist中成本最低的Path,与带有pathkeys的Path做成本上的对比,选择成本更低的路径。如果最终是pathkeys的路径,那么该RelOptInfo的pathkeys会保留。

    若该SQL语句中带ORDER BY,则可以判断该RelOptInfo的pathkeys是否对ORDER BY(字段和排列顺序一致),则不必再建立ORDER BY节点。如果pathkeys没有帮助,则会建立排序节点

  • GROUP BY

    GROUP BY有多种方式。如果RelOptInfo中的pathkeys与在解析阶段产生的GROUP BY的pathkeys一致,则从成本上对RelOptInfo结果集的pathkeys对该GROUP BY是否有效,从而可以考虑选用SORT加AGG的方式。这种方式,因为pathkeys的存在,则不必再建SORT 节点。然后再对比与其他方式的成本,择优采用。

  • 子查询

    如果JOIN中包含子查询,那么则在JOIN的RelOptInfo->pathlist中添加一个subquery类型的Path,并把子查询中的排序的结果指定为pathkeys放在该Path中。从而上层节点,可以用上面同样的方法,选用该RelOptInfo中最优的Path,并根据pathkeys决定是否需要排序。

总结

通过以上表述,可以说明一条SQL语句的执行计划入口是一个RelOptInfo结构,其中成员pathlist则标示所有不同的查找路径,在这些路径中最终会落在表的RelOptInfo->pathlist中最优的Path中。如果该Path带有pathkeys,那么上层在处理ORT相关的操作时,可以根据pathkeys是否对排序有效而决定是否需要排序节点,从而选择成本更低的路径。

MSSQL· 实现分析 · Extend Event日志文件的分析方法

$
0
0

背景

在前两篇月报分享中,6月份月报我们分享了SQL Server实现审计日志功能的方法探索,最终从可靠性、对象级别、可维护性、开销和对数据库系统影响五个方面得出最佳选项Extend Event;7月份月报我们量化分析了使用Extend Event实现审计日志功能对SQL Server本身的性能和吞吐量的影响,结论是对系统性能和吞吐量影响均在0.01%左右;8月份的月报分享是SQL Server审计日志专题的最后一期,探讨Extend Event实现审计日志功能的分析方法汇总,以及这些方法的优缺点。

6月份月报,详情请戳:MSSQL · 实现分析 · SQL Server实现审计日志的方案探索

7月份月报,详情请戳:MSSQL · 实现分析 · Extend Event实现审计日志对SQL Server性能影响

问题引入

为了兼容SQL Server 2008R2版本,我们稍微对实现审计日志功能的扩展事件创建方法稍微修改如下:

USE master
GO

CREATE EVENT SESSION [svrXEvent_User_Define_Testing] ON SERVER 
ADD EVENT sqlserver.sql_statement_completed
( 
	ACTION 
	( 
		sqlserver.database_id,
		sqlserver.session_id, 
		sqlserver.username, 
		sqlserver.client_hostname,
		sqlserver.client_app_name,
		sqlserver.sql_text, 
		sqlserver.plan_handle,
		sqlserver.tsql_stack,
		sqlserver.is_system,
		package0.collect_system_time
	) 
	WHERE sqlserver.username <> N'NT AUTHORITY\SYSTEM'
		AND sqlserver.username <> 'sa'
		AND sqlserver.is_system = 0		
)
ADD TARGET package0.asynchronous_file_target
( 
	SET 
		FILENAME = N'C:\Temp\svrXEvent_User_Define_Testing.xel', 
		METADATAFILE = N'C:\Temp\svrXEvent_User_Define_Testing.xem',
		MAX_FILE_SIZE = 10,
		MAX_ROLLOVER_FILES = 500
)
WITH (
	EVENT_RETENTION_MODE = NO_EVENT_LOSS,
	MAX_DISPATCH_LATENCY = 5 SECONDS,
    STARTUP_STATE=ON
);
GO


-- We need to enable event session to capture event and event data 
ALTER EVENT SESSION [svrXEvent_User_Define_Testing]
ON SERVER STATE = START;
GO

扩展事件创建完毕并启动以后,发生在SQL Server数据库服务端的所有sql_statement_completed事件信息都会被扩展事件异步滚动记录在日志文件svrXEvent_User_Define_Testing.xel文件中,日志文件格式是svrXEvent_User_Define_Testing_0_TimeStamp.xel,比如svrXEvent_User_Define_Testing_0_131465070445690000.xel。这里就引入了这期月报分享的重点问题了:

审计日志有哪些分析方法

这些方法各自的优缺点是什么

我们该如何选择哪种适用的方法

使用DMF

SQL Server扩展事件(Extend Event,简称为XE)采用异步的方式将审计日志记录写入目标日志文件中,且每个事件以XML格式单行写入日志文件,因此我们可以采用SQL Server提供的动态管理函数sys.fn_xe_file_target_read_file来读取和分析升级日志文件。

全量读取

全量审计日志读取是指使用SQL Server DMF sys.fn_xe_file_target_read_file ( path, mdpath, initial_file_name, initial_offset ) 中,不传入initial_file_name和initial_offset。这种方法读取的是审计日志目录下所有的审计日志文件中的内容。比如,以下是使用DMF全量读取所有审计日志文件记录的例子:

USE master
GO

SELECT *
FROM sys.fn_xe_file_target_read_file('C:\Temp\svrXEvent_User_Define_Testing*.xel', 
		'C:\Temp\svrXEvent_User_Define_Testing*.xem', null, null)

展示的结果如下:
01.png

从这个结果来看,我们无法明确的知道哪个用户在哪个时间点执行了哪些SQL语句,执行耗时多少等更为详细有价值的信息。这里我们需要采用XML解析的方法来分析Event_data字段中更为丰富的内容。请使用下面的查询语句获取更为详细的信息:

-- This is SQL 2008R2
;WITH events_cte
AS (
	SELECT
		[event_data] = T.C.query('.'),
		[event_name] = T.C.value('(event/@name)[1]','varchar(100)'),
		[event_time] = DATEADD(mi, DATEDIFF(mi, GETUTCDATE(), CURRENT_TIMESTAMP),T.C.value('(event/@timestamp)[1]','datetime2')),
		[client app name] = T.C.value('(event/action[@name="client_app_name"]/value/text())[1]', 'sysname'),
		[client host name] = T.C.value('(event/action[@name="client_hostname"]/value/text())[1]', 'sysname'),
		[database_id]= T.C.value('(event/action[@name="database_id"]/value/text())[1]', 'int'),
		[cpu time (ms)] = T.C.value('(event/data[@name="cpu"]/value/text())[1]', 'bigint'),
		[logical reads] = T.C.value('(event/data[@name="reads"]/value/text())[1]', 'bigint'),
		[logical writes] = T.C.value('(event/data[@name="writes"]/value/text())[1]', 'bigint'),
		[duration (ms)] = T.C.value('(event/data[@name="duration"]/value/text())[1]', 'bigint'),
		[row count] = T.C.value('(event/data[@name="row_count"]/value/text())[1]', 'bigint'),
		[sql_text] = T.C.value('(event/action[@name="sql_text"]/value/text())[1]','nvarchar(max)'),
		[session_id] = T.C.value('(event/action[@name="session_id"]/value/text())[1]','int'),
		[user_name] = T.C.value('(event/action[@name="username"]/value/text())[1]','sysname'),
		[is_system] = T.C.value('(event/action[@name="is_system"]/value/text())[1]','sysname'),
		[query_timestamp] = T.C.value('(event/action[@name="collect_system_time"]/value/text())[1]','bigint'),
		[query_time] = DATEADD(mi, DATEDIFF(mi, GETUTCDATE(), CURRENT_TIMESTAMP),T.C.value('(event/action[@name="collect_system_time"]/text/text())[1]','datetime2'))
	FROM sys.fn_xe_file_target_read_file('C:\Temp\svrXEvent_User_Define_Testing*.xel', 
		'C:\Temp\svrXEvent_User_Define_Testing*.xem', null, null)
		CROSS APPLY (SELECT CAST(event_data as XML) AS event_data) as T(C)
)
SELECT 
	
	cte.session_id,
	--cte.query_timestamp,
	--cte.[event_time],
	cte.[query_time],
	--cte.[event_name],
	cte.user_name,
	[database_name] = db.name,
	cte.[database_id],
	cte.[client host name],
	
	cte.[logical reads],
	cte.[logical writes],
	cte.[cpu time (ms)],
	cte.[duration (ms)],
	--cte.[plan_handle],
	cte.sql_text,
	sql_text_hash = CHECKSUM(cte.sql_text),
	cte.[client app name],
	cte.[event_data],
	cte.is_system
FROM events_cte as cte
	LEFT JOIN sys.databases as db
	on cte.database_id = db.database_id
ORDER BY [query_time] ASC
;

执行结果展示如下:
02.png

从这个结果集中,我们可以很清楚的知道每一条SQL语句执行的详细情况,包括:用户名、执行时间点、客户机名、逻辑读、逻辑写、CPU消耗、执行时间消耗、查询语句详情等非常重要的信息。

部分读取

使用DMF sys.fn_xe_file_target_read_file ( path, mdpath, initial_file_name, initial_offset )实现审计日志除了全量读取外,还可以实现部分读取,我可以传入initial_file_name和initial_offset来实现从某个日志文件的特定offset(文件内容偏移量)开始读取。以此来减小每次读取审计日志文件的大小。比如,我们从文件C:\Temp\svrXEvent_User_Define_Testing_0_131471065758970000.xel中的偏移量为开始94720开始读取,执行方法如下:

USE master
GO

SELECT *
FROM sys.fn_xe_file_target_read_file('C:\Temp\svrXEvent_User_Define_Testing*.xel', 
		'C:\Temp\svrXEvent_User_Define_Testing*.xem', 'C:\Temp\svrXEvent_User_Define_Testing_0_131471065758970000.xel', 94720)

执行结果截图如下:
03.png

当然,你也可以使用“全量读取”中的分析方法来获取部分读取到的审计日志详细信息,在此不再累述。

XEReader API

我们除了使用SQL Server本身提高的DMF来分析审计日志以外,还可以使用XE Reader API,通过编程的方式来读取审计日志文件。从SQL Server 2012开始,在Shared(C:\Program Files\Microsoft SQL Server\110\Shared)目录下,提供了XEvent相关的两个dll文件,可以使用XEReader的API接口来读取审计日志文件。
SQL 2012: Microsoft.SqlServer.XEvent.Linq.dll
SQL 2014: Microsoft.SqlServer.XEvent.Linq.dll和Microsoft.SqlServer.XE.Core.dll
SQL 2016: Microsoft.SqlServer.XEvent.Linq.dll和Microsoft.SqlServer.XE.Core.dll
以下是Visual Studio 2015编程工具,使用C#编程语言,编写的控制台应用程序项目,来详细看看如何使用XEReader API来实现分析审计日志文件。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.SqlServer.XEvent.Linq;

namespace MonthlyShareExtendEventDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            string[] xelFiles = new string[] { @"C:\Temp\svrXEvent_User_Define_Testing*.xel" };
            string[] xemFiles = new string[] { @"C:\Temp\svrXEvent_User_Define_Testing*.xem" };
            QueryableXEventData events = new QueryableXEventData(xelFiles, xemFiles);
            foreach (PublishedEvent evt in events)
            {
                Console.WriteLine("=>>>>>>>>>>>>>>>>>>" + evt.Name);

                foreach (PublishedEventField fld in evt.Fields)
                {
                    Console.WriteLine("\tField: {0} = {1}", fld.Name, fld.Value);
                }

                foreach (PublishedAction act in evt.Actions)
                {
                    Console.WriteLine("\tAction: {0} = {1}", act.Name, act.Value);
                }
                Console.WriteLine("=<<<<<<<<<<<<<<<" + evt.Name);
            }

            Console.ReadKey();

        }
    }
}

我截图其中一条得到的审计日志如下图所示:
04.png

注意:
在使用XEReader API分析审计日志,需要依赖两个安装包:SQLSysClrTypes.msi和SharedManagementObjects.msi,请提前安装完毕。

事件流读取

当然我们也可以采用XEReader API事件流的方式读取审计日志,当客户端有查询语句提交到SQL Server 后台服务,事件流会捕获到这个查询行为,并加以分析。事例代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.SqlServer.XEvent.Linq;

namespace XEStreamDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            string connString = string.Format("Data Source=.,{0};Initial Catalog={1};Integrated Security=SSPI", 1433, "master");
            string xeSessionName = @"svrXEvent_User_Define_Testing";
            using (QueryableXEventData eventData = new QueryableXEventData(connString, xeSessionName, EventStreamSourceOptions.EventStream, EventStreamCacheOptions.DoNotCache))
            {
                foreach (PublishedEvent evt in eventData)
                {
                    Console.WriteLine("=>>>>>>>>>>>>>>>>>>" + evt.Name);

                    foreach (PublishedEventField fld in evt.Fields)
                    {
                        Console.WriteLine("\tField: {0} = {1}", fld.Name, fld.Value);
                    }

                    foreach (PublishedAction act in evt.Actions)
                    {
                        Console.WriteLine("\tAction: {0} = {1}", act.Name, act.Value);
                    }
                    Console.WriteLine("=<<<<<<<<<<<<<<<" + evt.Name);
                }
            }

            Console.ReadKey();
        }
    }
}

当执行查询的时候,这个控制台应用程序会捕获到SQL Server 服务端执行的查询语句,如下截图:
05.png

注意:
基于事件流分析SQL Server审计日志功能的方法不支持SQL Server 2008以及SQL Server 2008R2版本,最低的版本要求是SQL Server 2012。因为在SQL Server 2012以下版本中会报告“Invalid object name ‘sys.fn_MSxe_read_event_stream”异常信息,错误信息如下所示。
An unhandled exception of type ‘System.Data.SqlClient.SqlException’ occurred in System.Data.dll
Additional information: Invalid object name ‘sys.fn_MSxe_read_event_stream’.
异常信息截图如下:
06.png

三种方法对比

这一章节介绍三种审计日志分析方法的对比,我们将会从以下几个角度来衡量这三种方法:

是否依赖SQL Server Service

分析延迟性

稳定性

对SQL Server的影响

DMF

DMF sys.fn_xe_file_target_read_file是SQL Server本身内置的对象,所以使用这种方法分析审计日志信息,无需过多的编程处理,门槛较低,甚至可以直接使用SSMS都可以分析审计日志文件。这些是使用DMF分析审计日志的优点。当然,这个方法的缺点也很明显:使用DMF方式读取审计日志,需要连接到SQL Server服务,所以要求SQL Server服务本身是启动的,因为这个是使用SQL Server内置的动态管理函数来实现的;而且这种分析方法需要使用SQL Server对XML操作技术来解析event_data,解析XML是一个CPU密集型操作,非常消耗系统CPU资源。在我之前的测试案例中,使用DMF方法分析审计日志详情导致了50%多的额外CPU开销。如下截图所示:
07.png

XEReader API

使用SQL Server XEReader提供的API读取审计日志文件的方法,完全是基于审计日志文件的操作方式,可以独立于SQL Server的服务。换句话说,不管SQL Server是处于关闭还是启动状态,对我们审计日志的分析不会受到任何影响。这些是使用XEReader API分析审计日志的优点。而这个方法也有它的缺点:当我们分析当前(正在被Extend Event Session对象写入的日志文件)审计日志文件时,我们不知道(或者很难知道)哪些记录是我们分析过的,哪些是还未分析的?如果这个问题解决不了的话,很可能就会导致审计日志记录的重复或者丢失。当然,我们也可以采用XE循环写入审计日志文件的方法,每次读取Archive出来的审计日志文件,跳过当前文件的读取,等待当前文件写满固定大小,Archive出来以后,再来读取分析。这个改进方法会引入另外一个问题是,可能会导致审计日志的分析延迟,而且延迟的时间还不确定。比如:用户查询在10分钟后才写满当前审计日志文件,那么延迟是10分钟;如果用户查询在1个小时之内才写满当前审计日志文件,那么延迟将是1个小时。

事件流读取

基于用户查询事件流式分析审计日志的方法,优点也特别明显:延迟非常小,可以控制在秒级内,实时性表现良好,它解决了XEReader API查询事件延迟的问题。然而缺点是:也需要依赖SQL Service的启动状态,否则会报告异常;在大量查询瞬间(短时间内)执行的时候(比如用户不小心写了一个死循环查询),重启SQL Service或者Extend Event Session状态时,根据我测试的情况来看,这种场景会导致审计日志记录丢失,可靠性得不到保证。

最后总结

基于以上三种审计日志分析方法的优缺点总结来看,我们综合打分汇总如下:

DMF:对SQL Service有依赖,得分0;延迟取决于Offset的移动效率,得分80;稳定性有保证,得分100;对SQL Server CPU影响较大,得分为0;

XEReader API:对SQL Service无依赖,得分100;延迟取决于查询产生的速度,得分50;稳定性有保证,得分100;对SQL Server 影响很小,得分为0;

XEReader Stream:对SQL Service有依赖,得分0;延迟非常低,得分100;有不稳定的场景存在,得分50;对SQL Server 影响较小,得分为100;

08.png

将综合打分做成雷达图,如下:
09.png

从这个汇总图来看,XEReader API直接分析审计日志文件的方法在依赖性,延迟性,稳定性和影响方面,综合表现最佳。

参考文章

Introducing the Extended Events Reader

MySQL · 源码分析 · SHUTDOWN过程

$
0
0

ORACLE 中的SHUTDOWN

MySQL SHUTDOWN LEVEL 暂时只有一种,源码中留了 LEVEL 的坑还没填

在此借用 Oracle 的 SHUTDOWN LEVEL 分析

Oracle SHUTDOWN LEVEL 共有四种:ABORT、IMMEDIATE、NORMAL、TRANSACTIONAL

ABORT
  • 立即结束所有SQL
  • 回滚未提交事务
  • 断开所有用户连接
  • 下次启动实例时,需要recovery
IMMEDIATE
  • 允许正在运行的SQL执行完毕
  • 回滚未提交事务
  • 断开所有用户连接
NORMAL
  • 不允许建立新连接
  • 等待当前连接断开
  • 下次启动实例时,不需要recovery
TRANSACTIONAL
  • 等待事务提交或结束
  • 不允许新建连接
  • 事务提交或结束后断开连接

MySQL 中的 SHUTDOWN 实际相当于 Oracle 中的 SHUTDOWN IMMEDIATE,重启实例时无需recovery,但回滚事务的过程可能耗时很长

MySQL SHUTDOWN过程分析

  • mysql_shutdown 发送SHUTDOWN命令
  • dispatch_command() 接受到 COM_SHUTDOWN command,调用kill_mysql()
  • kill_mysql()创建 kill_server_thread
  • kill_server_thread 调用 kill_server()
  • kill_server()
    • close_connections()
      • 关闭端口
      • 断开连接
      • 回滚事务(可能耗时很长)
    • unireg_end
      • clean_up
        • innobase_shutdown_for_mysql
        • delete_pid_file

InnoDB shutdown 速度取决于参数 innodb_fast_shutdown

  • 0: 最慢,需等待purge完成,change buffer merge完成
  • 1: default, 不需要等待purge完成和change buffer merge完成
  • 2: 不等待后台删除表完成,row_drop_tables_for_mysql_in_background 不等刷脏页,如果设置了innodb_buffer_pool_dump_at_shutdown,不需要去buffer dump.
  case COM_SHUTDOWN: // 接受到SHUTDOWN命令
  {
    if (packet_length < 1)
    {    
      my_error(ER_MALFORMED_PACKET, MYF(0));
      break;
    }    
    status_var_increment(thd->status_var.com_other);
    if (check_global_access(thd,SHUTDOWN_ACL)) // 检查权限
      break; /* purecov: inspected */
    /*   
      If the client is < 4.1.3, it is going to send us no argument; then
      packet_length is 0, packet[0] is the end 0 of the packet. Note that
      SHUTDOWN_DEFAULT is 0. If client is >= 4.1.3, the shutdown level is in
      packet[0].
    */
    enum mysql_enum_shutdown_level level; // 留的坑,default以外的LEVEL都没实现
    if (!thd->is_valid_time())
      level= SHUTDOWN_DEFAULT;                                                                                                                                                                              
    else 
      level= (enum mysql_enum_shutdown_level) (uchar) packet[0];
    if (level == SHUTDOWN_DEFAULT)
      level= SHUTDOWN_WAIT_ALL_BUFFERS; // soon default will be configurable
    else if (level != SHUTDOWN_WAIT_ALL_BUFFERS)
    {    
      my_error(ER_NOT_SUPPORTED_YET, MYF(0), "this shutdown level");
      break;
    }    
    DBUG_PRINT("quit",("Got shutdown command for level %u", level));
    general_log_print(thd, command, NullS); // 记录general_log
    my_eof(thd);
    kill_mysql(); // 调用kill_mysql()函数,函数内部创建 kill_server_thread 线程
    error=TRUE;
    break;
  }
  

kill_server() 先调用 close_connections(),再调用 unireg_end()

static void __cdecl kill_server(int sig_ptr)
{
	......
	close_connections();
   	if (sig != MYSQL_KILL_SIGNAL &&
        sig != 0)                                      
      unireg_abort(1);        /* purecov: inspected */
    else
      unireg_end();

结束线程的主要逻辑在 mysqld.cc:close_connections() 中

  static void close_connections(void)

  	......
    
  /* 下面这段代码结束监听端口 */
  /* Abort listening to new connections */
  DBUG_PRINT("quit",("Closing sockets"));
  if (!opt_disable_networking )
  {
    if (mysql_socket_getfd(base_ip_sock) != INVALID_SOCKET)
    {
      (void) mysql_socket_shutdown(base_ip_sock, SHUT_RDWR);
      (void) mysql_socket_close(base_ip_sock);
      base_ip_sock= MYSQL_INVALID_SOCKET;
    }
    if (mysql_socket_getfd(extra_ip_sock) != INVALID_SOCKET)
    {
      (void) mysql_socket_shutdown(extra_ip_sock, SHUT_RDWR);
      (void) mysql_socket_close(extra_ip_sock);
      extra_ip_sock= MYSQL_INVALID_SOCKET;
    }
  }
  
  	......

  /* 第一遍遍历线程列表 */
  sql_print_information("Giving %d client threads a chance to die gracefully",
                        static_cast<int>(get_thread_count()));

  mysql_mutex_lock(&LOCK_thread_count);
  
  Thread_iterator it= global_thread_list->begin();
  for (; it != global_thread_list->end(); ++it)
  {
    THD *tmp= *it;
    DBUG_PRINT("quit",("Informing thread %ld that it's time to die",
                       tmp->thread_id));
    /* We skip slave threads & scheduler on this first loop through. */
    
    /* 跳过 slave 相关线程,到 end_server() 函数内处理 */
    if (tmp->slave_thread) 
      continue;
    if (tmp->get_command() == COM_BINLOG_DUMP ||
        tmp->get_command() == COM_BINLOG_DUMP_GTID)
    {
      ++dump_thread_count;
      continue;
    }
    
    /* 先标记为 KILL 给连接一个自我了断的机会 */
    tmp->killed= THD::KILL_CONNECTION;
    
    ......
    
  }
  mysql_mutex_unlock(&LOCK_thread_count);

  Events::deinit();

  sql_print_information("Shutting down slave threads");
  /* 此处断开 slave 相关线程 */
  end_slave();
  
  /* 第二遍遍历线程列表 */
  if (dump_thread_count)
  {                                                                                                                                                                                                         
    /*
      Replication dump thread should be terminated after the clients are
      terminated. Wait for few more seconds for other sessions to end.
     */
    while (get_thread_count() > dump_thread_count && dump_thread_kill_retries)
    {
      sleep(1);
      dump_thread_kill_retries--;
    }
    mysql_mutex_lock(&LOCK_thread_count);
    for (it= global_thread_list->begin(); it != global_thread_list->end(); ++it)
    {
      THD *tmp= *it;
      DBUG_PRINT("quit",("Informing dump thread %ld that it's time to die",
                         tmp->thread_id));
      if (tmp->get_command() == COM_BINLOG_DUMP ||
          tmp->get_command() == COM_BINLOG_DUMP_GTID)
      {
      	/* 关闭DUMP线程 */
        tmp->killed= THD::KILL_CONNECTION;
        
        ......
        
      }
    }
    mysql_mutex_unlock(&LOCK_thread_count);
  }
  
  ......
  
  /* 第三遍遍历线程列表 */
  for (it= global_thread_list->begin(); it != global_thread_list->end(); ++it)
  {
    THD *tmp= *it;
    if (tmp->vio_ok())
    {
      if (log_warnings)
        sql_print_warning(ER_DEFAULT(ER_FORCING_CLOSE),my_progname,
                          tmp->thread_id,
                          (tmp->main_security_ctx.user ?
                           tmp->main_security_ctx.user : ""));
      /* 关闭连接,不等待语句结束,但是要回滚未提交线程 */
      close_connection(tmp);
    }
  }
                                         

close_connection() 中调用 THD::disconnect() 断开连接
连接断开后开始回滚事务

bool do_command(THD *thd)
{
	......
	packet_length= my_net_read(net); // thd->disconnect() 后此处直接返回
	......                        
}

void do_handle_one_connection(THD *thd_arg)
{
	......
	while (thd_is_connection_alive(thd))
	{
  		if (do_command(thd)) //do_command 返回 error,跳出循环
  			break;
	}
    end_connection(thd);
 
end_thread:
    close_connection(thd);
    /* 此处调用one_thread_per_connection_end() */
    if (MYSQL_CALLBACK_ELSE(thd->scheduler, end_thread, (thd, 1), 0))
      return;                                 // Probably no-threads


	......
}

事务回滚调用链

trans_rollback(THD*) ()
THD::cleanup() ()
THD::release_resources() ()
one_thread_per_connection_end(THD*, bool) ()
do_handle_one_connection(THD*) ()
handle_one_connection ()

unireg_end 调用 clean_up()

void clean_up(bool print_message)
{
	/* 这里是一些释放内存和锁的操作 */	
 	......
 	
 	/*
 		这里调用 innobase_shutdown_for_mysql
 		purge all			(innodb_fast_shutdown = 0)
 		merge change buffer	(innodb_fast_shutdown = 0)
 		flush dirty page	(innodb_fast_shutdown = 0,1)
 		flush log buffer
 		都在这里面做 
 	*/
  plugin_shutdown();
  
  /* 这里是一些释放内存和锁的操作 */
  ......
  
  /* 
  	删除 pid 文件,删除后 mysqld_safe不会重启 mysqld,
  	不然会认为 mysqld crash,尝试重启
  */
  delete_pid_file(MYF(0));
  
  /* 这里是一些释放内存和锁的操作 */
  ......

innodb shutdown 分析

innodb shutdown 的主要操作在 logs_empty_and_mark_files_at_shutdown() 中

  • 等待后台线程结束
    • srv_error_monitor_thread
    • srv_lock_timeout_thread
    • srv_monitor_thread
    • buf_dump_thread
    • dict_stats_thread
  • 等待所有事物结束 trx_sys_any_active_transactions
  • 等待后台线程结束
    • worker threads: srv_worker_thread
    • master thread: srv_master_thread
    • purge thread: srv_purge_coordinator_thread
  • 等待 buf_flush_lru_manager_thread 结束
  • 等待 buf_flush_page_cleaner_thread 结束
  • 等待 Pending checkpoint_writes, Pending log flush writes 结束
  • 等待 buffer pool pending io 结束
  • if (innodb_fast_shutdown == 2)
    • flush log buffer 后 return
  • log_make_checkpoint_at
    • flush buffer pool
    • write checkpoint
  • 将 lsn 落盘 fil_write_flushed_lsn_to_data_files()
  • 关闭所有文件

logs_empty_and_mark_files_at_shutdown() 结束后,innobase_shutdown_for_mysql() 再做一些资源清理工作即结束 shutdown 过程

PgSQL · 应用案例 · HDB for PG特性(数据排盘与任意列高效率过滤)

$
0
0

背景

数据也有生辰八字,你信吗?列与列之间,行与行之间,元素与元素之间如何相生相克?查询慢?不要信什么这都是上天注定的,如何给数据改运?看完本文,你也可以做到。

pic

一份天赋,九份努力。缘分天注定。命由天定。又有说我命由我不由天的。看样子中国古人对先天注定的东西研究还挺透彻,看的还挺开,但是也有通过后天努力,或者后天改运等等手段来弥补先天不足的。

pic

实际上在准备写本文时,我发现数据库的数据编排,数据存放和中国的命理相关的传统文化还很相似,也存在先天因素和后天补救的说法。

怎么回事呢?且听我细细道来。

为了加速数据的检索效率,我们通常需要对数据创建索引,提高数据定位的精准性。例如查询某人某个月的通话流水数据,没有索引的话,我们需要搜索所有的数据,逐条匹配。通过索引,可以直接定位到需要查询的记录。

特别是在存储和计算分离时,如果搜索量越大,网络中传输的数据量就越大。瓶颈很明显。

另外,在OLAP领域,需要对大量的数据进行处理,如果都建索引,索引引入的开销还是蛮大的。

那么有没有其他方法,不建索引降低扫描量呢?

存储层统计和过滤下推

相信大家一定已经想到了,统计信息,没错我们可以对存储的数据,按块进行数据统计,例如每个块内的数据范围。

有几个非常常见的技术实现:

1、PostgreSQL BRIN索引。

《PostgreSQL 物联网黑科技 - 瘦身几百倍的索引(BRIN index)》

https://www.postgresql.org/docs/10/static/brin-intro.html

PostgreSQL brin索引就是块级索引,记录的是每个块、或者每一批连续的块的统计信息。

在按这个列搜索时,通过元数据,过滤不相干的块。

2、cstore_fdw列存储插件。实际上它也是按BATCH编排的列存储,每个BATCH的元数据(最大值、最小值)可以用于扫描时的过滤。

https://github.com/citusdata/cstore_fdw

Skip indexes: Stores min/max statistics for row groups, and uses them to skip over unrelated rows.

Using Skip Indexes

cstore_fdw partitions each column into multiple blocks. Skip indexes store minimum and maximum values for each of these blocks. While scanning the table, if min/max values of the block contradict the WHERE clause, then the block is completely skipped. This way, the query processes less data and hence finishes faster.

To use skip indexes more efficiently, you should load the data after sorting it on a column that is commonly used in the WHERE clause. This ensures that there is a minimum overlap between blocks and the chance of them being skipped is higher.

In practice, the data generally has an inherent dimension (for example a time field) on which it is naturally sorted. Usually, the queries also have a filter clause on that column (for example you want to query only the last week’s data), and hence you don’t need to sort the data in such cases.

在按这个列搜索时,通过元数据,过滤不相干的块。

例子

某个300GB的外部表,采样skip index扫描,加速扫描。  
耗时103毫秒。      
      
explain (analyze,verbose,timing,costs,buffers) select c400,sum(c2) from ft_tbl1 where c400=1 group by c400;      
      
         Filter: (ft_tbl1.c400 = 1)      
         Rows Removed by Filter: 89996        
         CStore File: /data01/digoal/pg_root1921/cstore_fdw/13146/41038      
         CStore File Size: 325166400400      
         Buffers: shared hit=8004      
 Planning time: 52.524 ms      
 Execution time: 103.555 ms      
(13 rows)      
      
不使用where c400=1,  
耗时89秒      
explain (analyze,verbose,timing,costs,buffers) select c400,sum(c2) from ft_tbl1  group by c400;      
      
         CStore File: /data01/digoal/pg_root1921/cstore_fdw/13146/41038      
         CStore File Size: 325166400400      
         Buffers: shared hit=8004      
 Planning time: 52.691 ms      
 Execution time: 89428.721 ms      

需要提一下,目前cstore_fdw这个插件没有支持并行计算,而实际上PostgreSQL的fdw接口已经支持了并行计算,cstore_fdw只需要改造一下,即可支持并行计算。

如下

https://www.postgresql.org/docs/10/static/fdw-callbacks.html#fdw-callbacks-parallel

过滤效率与线性相关性

注意,由于数据存储的关系,并不是所有列的统计信息过滤性都很好。举个例子:

某列的写入很随机,导致值的分布很随机,那么在一个数据块里面包含的数据范围可能比较大,这种列的存储元信息过滤性就很差。

create table a(id int, c1 int);      
insert into a select generate_series(1,1000000), random()*1000000;      

数据的分布如下

postgres=# select substring(ctid::text, '(\d+),')::int8 blkid, min(c1) min_c1, max(c1) max_c1, min(id) min_id, max(id) max_id from a group by 1 order by 1;      
 blkid | min_c1 | max_c1 | min_id | max_id        
-------+--------+--------+--------+---------      
     0 |   2697 | 998322 |      1 |     909      
     1 |   1065 | 998817 |    910 |    1818      
     2 |    250 | 998025 |   1819 |    2727      
     3 |     62 | 997316 |   2728 |    3636      
     4 |   1556 | 998640 |   3637 |    4545      
     5 |    999 | 999536 |   4546 |    5454      
     6 |   1385 | 999196 |   5455 |    6363      
     7 |   1809 | 999042 |   6364 |    7272      
     8 |   3044 | 999606 |   7273 |    8181      
     9 |   1719 | 999186 |   8182 |    9090      
    10 |    618 | 997031 |   9091 |    9999      
    11 |     80 | 997581 |  10000 |   10908      
    12 |    781 | 997710 |  10909 |   11817      
    13 |   1539 | 998857 |  11818 |   12726      
    14 |   2097 | 999932 |  12727 |   13635      
    15 |    114 | 999913 |  13636 |   14544      
    16 |    136 | 999746 |  14545 |   15453      
    17 |   2047 | 997439 |  15454 |   16362      
    18 |   1955 | 996937 |  16363 |   17271      
    19 |   1487 | 999705 |  17272 |   18180      
    20 |     97 | 999549 |  18181 |   19089      
    21 |    375 | 999161 |  19090 |   19998      
    22 |    645 | 994457 |  19999 |   20907      
    23 |   4468 | 998612 |  20908 |   21816      
    24 |    865 | 996342 |  21817 |   22725      
    25 |    402 | 998151 |  22726 |   23634      
    26 |    429 | 998823 |  23635 |   24543      
    27 |   1305 | 999521 |  24544 |   25452      
    28 |    974 | 998874 |  25453 |   26361      
    29 |   1056 | 999271 |  26362 |   27270      
。。。。。。      

对于ID列,分布非常清晰(线性相关性好),存储元数据的过滤性好。而C1列,分布非常散,存储元数据的过滤性差。

例如我要查id=10000的数据,直接查11号数据块,跳过其他数据块的扫描。

而如果我要查c1=10000的数据,那么要查很多个数据块,因为能跳过的数据块很少。

如何提升每一列的过滤性 - 存储编排

对于单列来说,提升过滤性的方法非常简单,按顺序存储即可。

例如前面的测试表,我们要提高C1的过滤性,按C1重排一下即可实现。

重排后,C1列与物理存储(行号)的相关性会变成1或-1,即线性相关,因此过滤性就特别好。

postgres=# create temp table tmp_a (like a);      
CREATE TABLE      
postgres=# insert into tmp_a select * from a order by c1;      
INSERT 0 1000000      
postgres=# truncate a;      
TRUNCATE TABLE      
postgres=# insert into a select * from tmp_a;      
INSERT 0 1000000      
postgres=# end;      
COMMIT      
postgres=# select substring(ctid::text, '(\d+),')::int8 blkid, min(c1) min_c1, max(c1) max_c1, min(id) min_id, max(id) max_id from a group by 1 order by 1;      
 blkid | min_c1 | max_c1 | min_id | max_id        
-------+--------+--------+--------+---------      
     0 |      0 |    923 |   2462 |  999519      
     1 |    923 |   1846 |   1487 |  997619      
     2 |   1847 |   2739 |    710 |  999912      
     3 |   2741 |   3657 |   1930 |  999053      
     4 |   3658 |   4577 |   1635 |  999579      
     5 |   4577 |   5449 |    852 |  999335      
     6 |   5450 |   6410 |    737 |  998277      
     7 |   6414 |   7310 |   3262 |  999024      
     8 |   7310 |   8245 |    927 |  997907      
     9 |   8246 |   9146 |    441 |  999209      
    10 |   9146 |  10015 |    617 |  999828      
    11 |  10016 |  10920 |   1226 |  998264      
    12 |  10923 |  11859 |   1512 |  997404      
    13 |  11862 |  12846 |    151 |  998737      
    14 |  12847 |  13737 |   1007 |  999250      
。。。。。。      
      
c1列和物理存储(行号)的线性相关性      
postgres=# select correlation from pg_stats where tablename='a' and attname='c1';      
 correlation       
-------------      
           1      
(1 row)      

糟糕的是,这么编排后,ID这个字段的过滤性就变差了。

这是为什么呢?

全局/全表 两列相对线性相关性

实际上是ID和C1列的相关性,它控制了按C1排序后ID列变离散的问题。

ID和C1的相关性如何呢?

postgres=# select corr(c1,id) from (select row_number() over(order by c1) c1, row_number() over(order by id) id from a) t;      
         corr                
-----------------------      
 -0.000695987373950136      
(1 row)      

c1和id的全局(全表)相关性极差,导致了这个问题。

(可以理解为这两个字段的八字不合)

pic

局部/部分记录 两列相对线性相关性

如果全表按C1或ID排序,那么另一列的离散度就会变得很高。

但是,某些情况下,可能存在这样的情况,某些记录A和B字段的相关性很好,而其他记录他们的相关性不好。

例子

在之前的记录基础上,再插入一批记录。

postgres=# insert into a select id, id*2 from generate_series(1,100000) t(id);      
INSERT 0 100000      

这部分数据id, c1字段的相关性为1。(局部相关性)

postgres=# select ctid from a offset 1000000 limit 1;      
    ctid          
------------      
 (1113,877)      
(1 row)      
      
postgres=# select corr(c1,id) from (select row_number() over(order by c1) c1, row_number() over(order by id) id from a where ctid >'(1113,877)') t;      
 corr       
------      
    1      
(1 row)      

全局相关性一下也提升了不少

postgres=# select corr(c1,id) from (select row_number() over(order by c1) c1, row_number() over(order by id) id from a) t;      
       corr              
-------------------      
 0.182542794451908      
(1 row)      

局部按需改命法

数据散落存储,带来的问题:即使访问少量数据,也会造成大量的IO读取,原理如下:

《索引顺序扫描引发的堆扫描IO放大背后的统计学原理与解决办法》

数据存储是上天注定的(写入时就决定了),但是我们可以按需改命,例如有个业务是运营商的通话流水,查询需求通常是按某个手机号码查询一个月的流水。而实际上数据是产生时即时写入数据库的,所以存放散乱。查询时耗费大量IO。

例子

用户通话数据即时写入,用户数据呈现布朗分布。

create table phone_list(phone_from char(11), phone_to char(11), crt_time timestamp, duration interval);  
create index idx_phone_list on phone_list(phone_from, crt_time);  
  
insert into phone_list   
select   
  lpad((random()*1000)::int8::text, 11, '1'),   
  lpad((random()*1000)::int8::text, 11, '1'),   
  now()+(id||' second')::interval,  
  ((random()*1000)::int||' second')::interval  
from generate_series(1,10000000) t(id);  
  
postgres=# select * from phone_list limit 10;  
 phone_from  |  phone_to   |          crt_time          | duration   
-------------+-------------+----------------------------+----------  
 14588832692 | 11739044013 | 2017-08-11 10:17:04.752157 | 00:03:25  
 15612918106 | 11808103578 | 2017-08-11 10:17:05.752157 | 00:11:33  
 14215811756 | 15983559210 | 2017-08-11 10:17:06.752157 | 00:08:05  
 13735246090 | 15398474974 | 2017-08-11 10:17:07.752157 | 00:13:18  
 19445131039 | 17771201972 | 2017-08-11 10:17:08.752157 | 00:00:10  
 11636458384 | 16356298444 | 2017-08-11 10:17:09.752157 | 00:06:30  
 15771059012 | 14717265381 | 2017-08-11 10:17:10.752157 | 00:13:45  
 19361008150 | 14468133189 | 2017-08-11 10:17:11.752157 | 00:05:58  
 13424293799 | 16589177297 | 2017-08-11 10:17:12.752157 | 00:16:29  
 12243665890 | 13538149386 | 2017-08-11 10:17:13.752157 | 00:16:03  
(10 rows)  

查询效率低下,按手机查询通话记录,返回29937条记录需要26毫秒。

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from phone_list where phone_from='11111111111' order by crt_time;  
                                                                   QUERY PLAN                                                                     
------------------------------------------------------------------------------------------------------------------------------------------------  
 Index Scan using idx_phone_list on public.phone_list  (cost=0.56..31443.03 rows=36667 width=48) (actual time=0.016..24.348 rows=29937 loops=1)  
   Output: phone_from, phone_to, crt_time, duration  
   Index Cond: (phone_list.phone_from = '11111111111'::bpchar)  
   Buffers: shared hit=25843  
 Planning time: 0.082 ms  
 Execution time: 25.821 ms  
(6 rows)  

改命方法,局部按需调整。

需求是高效的按手机和月查询通话详单,所以我们需要将用户一个月的数据(通常是按月分区)进行重排即可。

分区表用法见:《PostgreSQL 10.0 preview 功能增强 - 内置分区表》

postgres=# cluster phone_list using idx_phone_list ;  

查询效率骤然提升,改命成功。

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from phone_list where phone_from='11111111111' order by crt_time;  
                                                                  QUERY PLAN                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------  
 Index Scan using idx_phone_list on public.phone_list  (cost=0.56..31443.03 rows=36667 width=48) (actual time=0.012..4.590 rows=29937 loops=1)  
   Output: phone_from, phone_to, crt_time, duration  
   Index Cond: (phone_list.phone_from = '11111111111'::bpchar)  
   Buffers: shared hit=432  
 Planning time: 0.038 ms  
 Execution time: 5.968 ms  
(6 rows)  

你就是上帝之手,数据的命运掌握在你的手中。

如何提升每一列的过滤性 - 存储编排

为了获得最好的过滤性(每个列都能很好的过滤),采用全局排序满足不了需求。

实际上需要局部排序,例如前面的例子,前面100万行,按C1排序,后面10万行再按ID排序。

这样的话有10万记录的ID的过滤性很好,有110万记录的C1的过滤性也很好。

但是数都是有命理的,就好像人的姓名也分为五格。

pic

通过后天的补救,可以改运。道理和数据编排一样,数据重排,可以影响全局过滤性,局部过滤性,是不是很有意思呢?

根据你的查询目标需求,重排数据,一起来改运吧。

复合排序 多列相对线性相关性

多列如何做到每列都具备良好的聚集性呢?

1、最土的方法,多列排序,但是效果其实并不一定好。为了达到更好的效果,需要调整列的顺序,算法如下:

我记得以前写过一篇这样的文档:

《一个简单算法可以帮助物联网,金融 用户 节约98%的数据存储成本 (PostgreSQL,Greenplum帮你做到)》

这里讲的实际上也是存储编排的精髓,通过排列组合,计算每两列的线性相关性,根据这个找出最佳的多列排序组合,从而提高整体相关性(提高压缩比)。

同样适用于本文提到的提高所有列的过滤性。

2、k-means算法,针对多列进行聚集计算,完成最佳的局部分布,这样做就能达到每个列的过滤性都很赞了。

《K-Means 数据聚集算法》

精髓

1、局部、全局 两列相对相关性。决定了按某列排序后,另一列的离散度。

2、编排的目的是,可以尽可能的让更多的列有序的存储,从而可以过滤最多的行。

3、全局相关性,决定了按某一列排序时,另一列的离散度。

4、局部相关性,决定了在某些记录中,两列的线性相关性。

5、按局部相关性编排,可以尽可能的让更多的列有序的存储,从而可以过滤最多的行。但是算法较复杂,需要算出什么样的行在一起,按什么排序存放才能获得最佳过滤性。

6、关于多列(或数组)的数据编排,方法1,通过排列组合,计算每两列(元素)的线性相关性,根据这个找出最佳的多列排序组合,从而提高整体相关性(提高压缩比)。

7、编排后,与存储(行号)线性相关性差的列,如果选择性较好(DISTINCT VALUE较多)时,并且业务有过滤数据的需求,建议还是需要建索引。

8、关于多列(或数组)的数据编排,方法2,通过kmean,算出数据归为哪类,每类聚合存放,从而提高数据的局部聚集性,过滤性。这个方法是最优雅的。

9、经过编排,结合PG的BRIN索引,就可以实现任意列的高效过滤。

给数据改命的案例

1、多列改命

低级方法,《一个简单算法可以帮助物联网,金融 用户 节约98%的数据存储成本 (PostgreSQL,Greenplum帮你做到)》

高级方法,《K-Means 数据聚集算法》

pic

pic

pic

高级方法举例

-- 写入 1亿 记录
-- 天命,各列散落,五行紊乱,查询效率低下。 
postgres=# create table tab(c1 int, c2 int, c3 int, c4 int, c5 int);
CREATE TABLE
postgres=# insert into tab select * from (select id,100000000-id,50000000-id, sqrt(id*2), sqrt(id) from generate_series(1,100000000) t(id)) t order by random();
INSERT 0 100000000
postgres=# select ctid,* from tab limit 10;
  ctid  |    c1    |    c2    |    c3     |  c4   |  c5  
--------+----------+----------+-----------+-------+------
 (0,1)  | 76120710 | 23879290 | -26120710 | 12339 | 8725
 (0,2)  | 98295593 |  1704407 | -48295593 | 14021 | 9914
 (0,3)  | 56133647 | 43866353 |  -6133647 | 10596 | 7492
 (0,4)  |   787639 | 99212361 |  49212361 |  1255 |  887
 (0,5)  | 89844299 | 10155701 | -39844299 | 13405 | 9479
 (0,6)  | 92618459 |  7381541 | -42618459 | 13610 | 9624
 (0,7)  | 93340303 |  6659697 | -43340303 | 13663 | 9661
 (0,8)  | 17164665 | 82835335 |  32835335 |  5859 | 4143
 (0,9)  |  2694394 | 97305606 |  47305606 |  2321 | 1641
 (0,10) | 41736122 | 58263878 |   8263878 |  9136 | 6460
(10 rows)
  
-- 改命,按K-MEAN聚集调整五行,采用BRIN索引实现任意列高效率过滤。
-- 让每列在各个方向上保持一致,例如(a,b) (1,100)(2,101), (100,9)(105,15),如果归为两类,在过滤A字段时选择性很好,过滤B字段时选择性也很好。  
postgres=# create table tbl1(like tab);
CREATE TABLE

-- 由于数据按块存储,BRIN索引最小粒度为块,所以我们的聚类数最多可以为表的块数即可。例如636943个数据块,那么我们可以归类为636943类。
-- 归为超过636943类就没有意义了,归类为更少是可以的,例如BRIN索引每10个连续的数据块存储一个元信息,那么我们可以选择归为63694类。  
postgres=# select relpages from pg_class where relname='tab';
 relpages 
----------
    636943
(1 row)
postgres=# insert into tbl1 select c1,c2,c3,c4,c5 from (select kmeans(array[c1,c2,c3,c4,c5],63694) over() km, * from tab) t order by km;
  
-- 创建任意列BRIN索引
create index idx_tab_1 on tab using brin(c1,c2,c3) with (pages_per_range=1);
create index idx_tbl1_1 on tbl1 using brin(c1,c2,c3) with (pages_per_range=1);

使用BRIN索引,在给数据改命后,任意列范围搜索,提升高效,赞

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from tab where c1 between 1 and 100000;
                                                             QUERY PLAN                                                              
-------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.tab  (cost=4184.33..906532.40 rows=83439 width=20) (actual time=165.626..1582.402 rows=100000 loops=1)
   Output: c1, c2, c3, c4, c5
   Recheck Cond: ((tab.c1 >= 1) AND (tab.c1 <= 100000))
   Rows Removed by Index Recheck: 14427159
   Heap Blocks: lossy=92530
   Buffers: shared hit=96745
   ->  Bitmap Index Scan on idx_tab_1  (cost=0.00..4163.47 rows=17693671 width=0) (actual time=165.307..165.307 rows=925300 loops=1)
         Index Cond: ((tab.c1 >= 1) AND (tab.c1 <= 100000))
         Buffers: shared hit=4215
 Planning time: 0.088 ms
 Execution time: 1588.852 ms
(11 rows)

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from tbl1 where c1 between 1 and 100000;
                                                            QUERY PLAN                                                             
-----------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.tbl1  (cost=4159.34..111242.78 rows=95550 width=20) (actual time=157.084..169.314 rows=100000 loops=1)
   Output: c1, c2, c3, c4, c5
   Recheck Cond: ((tbl1.c1 >= 1) AND (tbl1.c1 <= 100000))
   Rows Removed by Index Recheck: 9
   Heap Blocks: lossy=637
   Buffers: shared hit=4852
   ->  Bitmap Index Scan on idx_tbl1_1  (cost=0.00..4135.45 rows=95613 width=0) (actual time=157.074..157.074 rows=6370 loops=1)
         Index Cond: ((tbl1.c1 >= 1) AND (tbl1.c1 <= 100000))
         Buffers: shared hit=4215
 Planning time: 0.083 ms
 Execution time: 174.069 ms
(11 rows)

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from tab where c2 between 1 and 100000;
                                                             QUERY PLAN                                                              
-------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.tab  (cost=4183.50..902041.63 rows=82011 width=20) (actual time=165.901..1636.587 rows=100000 loops=1)
   Output: c1, c2, c3, c4, c5
   Recheck Cond: ((tab.c2 >= 1) AND (tab.c2 <= 100000))
   Rows Removed by Index Recheck: 14446835
   Heap Blocks: lossy=92655
   Buffers: shared hit=96870
   ->  Bitmap Index Scan on idx_tab_1  (cost=0.00..4163.00 rows=17394342 width=0) (actual time=165.574..165.574 rows=926550 loops=1)
         Index Cond: ((tab.c2 >= 1) AND (tab.c2 <= 100000))
         Buffers: shared hit=4215
 Planning time: 0.087 ms
 Execution time: 1643.089 ms
(11 rows)

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from tbl1 where c2 between 1 and 100000;
                                                            QUERY PLAN                                                             
-----------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.tbl1  (cost=4156.97..101777.70 rows=86127 width=20) (actual time=157.245..169.934 rows=100000 loops=1)
   Output: c1, c2, c3, c4, c5
   Recheck Cond: ((tbl1.c2 >= 1) AND (tbl1.c2 <= 100000))
   Rows Removed by Index Recheck: 115
   Heap Blocks: lossy=638
   Buffers: shared hit=4853
   ->  Bitmap Index Scan on idx_tbl1_1  (cost=0.00..4135.44 rows=86193 width=0) (actual time=157.227..157.227 rows=6380 loops=1)
         Index Cond: ((tbl1.c2 >= 1) AND (tbl1.c2 <= 100000))
         Buffers: shared hit=4215
 Planning time: 0.084 ms
 Execution time: 174.692 ms
(11 rows)

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from tab where c3 between 1 and 10000;
                                                             QUERY PLAN                                                              
-------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.tab  (cost=4141.01..672014.67 rows=9697 width=20) (actual time=191.075..10765.038 rows=10000 loops=1)
   Output: c1, c2, c3, c4, c5
   Recheck Cond: ((tab.c3 >= 1) AND (tab.c3 <= 10000))
   Rows Removed by Index Recheck: 99990000
   Heap Blocks: lossy=636943
   Buffers: shared hit=641158
   ->  Bitmap Index Scan on idx_tab_1  (cost=0.00..4138.58 rows=2062044 width=0) (actual time=190.292..190.292 rows=6369430 loops=1)
         Index Cond: ((tab.c3 >= 1) AND (tab.c3 <= 10000))
         Buffers: shared hit=4215
 Planning time: 0.086 ms
 Execution time: 10766.036 ms
(11 rows)

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from tbl1 where c3 between 1 and 10000;
                                                           QUERY PLAN                                                            
---------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.tbl1  (cost=4137.85..17069.21 rows=10133 width=20) (actual time=150.710..152.040 rows=10000 loops=1)
   Output: c1, c2, c3, c4, c5
   Recheck Cond: ((tbl1.c3 >= 1) AND (tbl1.c3 <= 10000))
   Rows Removed by Index Recheck: 205
   Heap Blocks: lossy=65
   Buffers: shared hit=4280
   ->  Bitmap Index Scan on idx_tbl1_1  (cost=0.00..4135.32 rows=10205 width=0) (actual time=150.692..150.692 rows=650 loops=1)
         Index Cond: ((tbl1.c3 >= 1) AND (tbl1.c3 <= 10000))
         Buffers: shared hit=4215
 Planning time: 0.083 ms
 Execution time: 152.546 ms
(11 rows)

2、数组改命

《索引扫描优化之 - GIN数据重组优化(按元素聚合) 想象在玩多阶魔方》

《从一维编排到多维编排,从平面存储到3D存储 - 数据存储优化之路》

《K-Means 数据聚集算法》

3、时空数据改命

《时间、空间、对象多维属性 海量数据任意多维 高效检索 - 阿里云RDS PostgreSQL最佳实践》

4、证券系统改命

《PostgreSQL 时序最佳实践 - 证券交易系统数据库设计 - 阿里云RDS PostgreSQL最佳实践》

相关技术

1、列存储插件 cstore

https://github.com/citusdata/cstore_fdw

https://www.postgresql.org/docs/10/static/fdw-callbacks.html#fdw-callbacks-parallel

2、《一个简单算法可以帮助物联网,金融 用户 节约98%的数据存储成本 (PostgreSQL,Greenplum帮你做到)》

3、《PostgreSQL 物联网黑科技 - 瘦身几百倍的索引(BRIN index)》

https://www.postgresql.org/docs/10/static/brin-intro.html

4、metascan是阿里云PostgreSQL内核团队研发的一个数据库功能,已用于RDS PostgreSQLHybridDB for PostgreSQL,将来亦可整合到存储引擎层面,将数据的FILTER下推到存储层,根据用户提供的查询条件,在不建索引的情况下可以减少数据的扫描量,提高效率。

我们已测试,查询性能有3到5倍的提升(相比不建索引)。同时写入性能有至少1倍的提升(相比建立索引)。

云端产品

阿里云 RDS PostgreSQL

阿里云 HybridDB for PostgreSQL

Viewing all 692 articles
Browse latest View live