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

MySQL · 引擎特性 · InnoDB IO子系统

$
0
0

前言

InnoDB做为一款成熟的跨平台数据库引擎,其实现了一套高效易用的IO接口,包括同步异步IO,IO合并等。本文简单介绍一下其内部实现,主要的代码集中在os0file.cc这个文件中。本文的分析默认基于MySQL 5.6,CentOS 6,gcc 4.8,其他版本的信息会另行指出。

基础知识

WAL技术 :日志先行技术,基本所有的数据库,都使用了这个技术。简单的说,就是需要写数据块的时候,数据库前台线程把对应的日志先写(批量顺序写)到磁盘上,然后就告诉客户端操作成功,至于真正写数据块的操作(离散随机写)则放到后台IO线程中。使用了这个技术,虽然多了一个磁盘写入操作,但是由于日志是批量顺序写,效率很高,所以客户端很快就能得到相应。此外,如果在真正的数据块落盘之前,数据库奔溃,重启时候,数据库可以使用日志来做崩溃恢复,不会导致数据丢失。
数据预读 :与数据块A“相邻”的数据块B和C在A被读取的时候,B和C也会有很大的概率被读取,所以可以在读取B的时候,提前把他们读到内存中,这就是数据预读技术。这里说的相邻有两种含义,一种是物理上的相邻,一种是逻辑上的相邻。底层数据文件中相邻,叫做物理上相邻。如果数据文件中不相邻,但是逻辑上相邻(id=1的数据和id=2的数据,逻辑上相邻,但是物理上不一定相邻,可能存在同一个文件中不同的位置),则叫逻辑相邻。
文件打开模式 : Open系统调用常见的模式主要三种:O_DIRECT,O_SYNC以及default模式。O_DIRECT模式表示后续对文件的操作不使用文件系统的缓存,用户态直接操作设备文件,绕过了内核的缓存和优化,从另外一个角度来说,使用O_DIRECT模式进行写文件,如果返回成功,数据就真的落盘了(不考虑磁盘自带的缓存),使用O_DIRECT模式进行读文件,每次读操作是真的从磁盘中读取,不会从文件系统的缓存中读取。O_SYNC表示使用操作系统缓存,对文件的读写都经过内核,但是这个模式还保证每次写数据后,数据一定落盘。default模式与O_SYNC模式类似,只是写数据后不保证数据一定落盘,数据有可能还在文件系统中,当主机宕机,数据有可能丢失。
此外,写操作不仅需要修改或者增加的数据落盘,而且还需要文件元信息落盘,只有两部分都落盘了,才能保证数据不丢。O_DIRECT模式不保证文件元信息落盘(但大部分文件系统都保证,Bug #45892),因此如果不做其他操作,用O_DIRECT写文件后,也存在丢失的风险。O_SYNC则保证数据和元信息都落盘。default模式两种数据都不保证。
调用函数fsync后,能保证数据和日志都落盘,因此使用O_DIRECT和default模式打开的文件,写完数据,需要调用fsync函数。
同步IO :我们常用的read/write函数(Linux上)就是这类IO,特点是,在函数执行的时候,调用者会等待函数执行完成,而且没有消息通知机制,因为函数返回了,就表示操作完成了,后续直接检查返回值就可知道操作是否成功。这类IO操作,编程比较简单,在同一个线程中就能完成所有操作,但是需要调用者等待,在数据库系统中,比较适合急需某些数据的时候调用,例如WAL中日志必须在返回客户端前落盘,则进行一次同步IO操作。
异步IO :在数据库中,后台刷数据块的IO线程,基本都使用了异步IO。数据库前台线程只需要把刷块请求提交到异步IO的队列中即可返回做其他事情,而后台线程IO线程,则定期检查这些提交的请求是否已经完成,如果完成再做一些后续处理工作。同时异步IO由于常常是一批一批的请求提交,如果不同请求访问同一个文件且偏移量连续,则可以合并成一个IO请求。例如,第一个请求读取文件1,偏移量100开始的200字节数据,第二个请求读取文件1,偏移量300开始的100字节数据,则这两个请求可以合并为读取文件1,偏移量100开始的300字节数据。数据预读中的逻辑预读也常常使用异步IO技术。
目前Linux上的异步IO库,需要文件使用O_DIRECT模式打开,且数据块存放的内存地址、文件读写的偏移量和读写的数据量必须是文件系统逻辑块大小的整数倍,文件系统逻辑块大小可以使用类似sudo blockdev --getss /dev/sda5的语句查询。如果上述三者不是文件系统逻辑块大小的整数倍,则在调用读写函数时候会报错EINVAL,但是如果文件不使用O_DIRECT打开,则程序依然可以运行,只是退化成同步IO,阻塞在io_submit函数调用上。

InnoDB常规IO操作以及同步IO

在InnoDB中,如果系统有pread/pwrite函数(os_file_read_funcos_file_write_func),则使用它们进行读写,否则使用lseek+read/write方案。这个就是InnoDB同步IO。查看pread/pwrite文档可知,这两个函数不会改变文件句柄的偏移量且线程安全,所以多线程环境下推荐使用,而lseek+read/write方案则需要自己使用互斥锁保护,在高并发情况下,频繁的陷入内核态,对性能有一定影响。

在InnoDB中,使用open系统调用打开文件(os_file_create_func),模式方面除了O_RDONLY(只读),O_RDWR(读写),O_CREAT(创建文件)外,还使用了O_EXCL(保证是这个线程创建此文件)和O_TRUNC(清空文件)。默认情况下(数据库不设置为只读模式),所有文件都以O_RDWR模式打开。innodb_flush_method这个参数比较重要,重点介绍一下:

  • 如果innodb_flush_method设置了O_DSYNC,日志文件(ib_logfileXXX)使用O_SYNC打开,因此写完数据不需要调用函数fsync刷盘,数据文件(ibd)使用default模式打开,因此写完数据需要调用fsync刷盘。
  • 如果innodb_flush_method设置了O_DIRECT,日志文件(ib_logfileXXX)使用default模式打开,写完数据需要调用fsync函数刷盘,数据文件(ibd)使用O_DIRECT模式打开,写完数据需要调用fsync函数刷盘。
  • 如果innodb_flush_method设置了fsync或者不设置,数据文件和日志文件都使用default模式打开,写完数据都需要使用fsync来刷盘。
  • 如果innodb_flush_method设置为O_DIRECT_NO_FSYNC,文件打开方式与O_DIRECT模式类似,区别是,数据文件写完后,不调用fsync函数来刷盘,主要针对O_DIRECT能保证文件的元数据也落盘的文件系统。
    InnoDB目前还不支持使用O_DIRECT模式打开日志文件,也不支持使用O_SYNC模式打开数据文件。
    注意,如果使用linux native aio(详见下一节),innodb_flush_method一定要配置成O_DIRECT,否则会退化成同步IO(错误日志中不会有任务提示)。

InnoDB使用了文件系统的文件锁来保证只有一个进程对某个文件进行读写操作(os_file_lock),使用了建议锁(Advisory locking),而不是强制锁(Mandatory locking),因为强制锁在不少系统上有bug,包括linux。在非只读模式下,所有文件打开后,都用文件锁锁住。

InnoDB中目录的创建使用递归的方式(os_file_create_subdirs_if_neededos_file_create_directory)。例如,需要创建/a/b/c/这个目录,先创建c,然后b,然后a,创建目录调用mkdir函数。此外,创建目录上层需要调用os_file_create_simple_func函数,而不是os_file_create_func,需要注意一下。

InnoDB也需要临时文件,临时文件的创建逻辑比较简单(os_file_create_tmpfile),就是在tmp目录下成功创建一个文件后直接使用unlink函数释放掉句柄,这样当进程结束后(不管是正常结束还是异常结束),这个文件都会自动释放。InnoDB创建临时文件,首先复用了server层函数mysql_tmpfile的逻辑,后续由于需要调用server层的函数来释放资源,其又调用dup函数拷贝了一份句柄。

如果需要获取某个文件的大小,InnoDB并不是去查文件的元数据(stat函数),而是使用lseek(file, 0, SEEK_END)的方式获取文件大小,这样做的原因是防止元信息更新延迟导致获取的文件大小有误。

InnoDB会预分配一个大小给所有新建的文件(包括数据和日志文件),预分配的文件内容全部置为零(os_file_set_size),当前文件被写满时,再进行扩展。此外,在日志文件创建时,即install_db阶段,会以100MB的间隔在错误日志中输出分配进度。

总体来说,常规IO操作和同步IO相对比较简单,但是在InnoDB中,数据文件的写入基本都用了异步IO。

InnoDB异步IO

由于MySQL诞生在Linux native aio之前,所以在MySQL异步IO的代码中,有两种实现异步IO的方案。
第一种是原始的Simulated aio,InnoDB在Linux native aio被import进来之前以及某些不支持aio的系统上,自己模拟了一条aio的机制。异步读写请求提交时,仅仅把它放入一个队列中,然后就返回,程序可以去做其他事情。后台有若干异步io处理线程(innobase_read_io_threads和innobase_write_io_threads这两个参数控制)不断从这个队列中取出请求,然后使用同步IO的方式完成读写请求以及读写完成后的工作。
另外一种就是Native aio。目前在linux上使用io_submit,io_getevents等函数完成(不使用glibc aio,这个也是模拟的)。提交请求使用io_submit, 等待请求使用io_getevents。另外,Windows平台上也有自己对应的aio,这里就不介绍了,如果使用了Windows的技术栈,数据库应该会选用sqlserver。目前,其他平台(Linux和Windows之外)都只能使用Simulate aio。

首先介绍一下一些通用的函数和结构,接下来分别详细介绍一下Simulate alo和Linux上的Native aio。
在os0file.cc中定义了全局数组,类型为os_aio_array_t,这些数组就是Simulate aio用来缓存读写请求的队列,数组的每一个元素是os_aio_slot_t类型,里面记录了每个IO请求的类型,文件的fd,偏移量,需要读取的数据量,IO请求发起的时间,IO请求是否已经完成等。另外,Linux native io中的struct iocb也在os_aio_slot_t中。数组结构os_aio_slot_t中,记录了一些统计信息,例如有多少数据元素(os_aio_slot_t)已经被使用了,是否为空,是否为满等。这样的全局数组一共有5个,分别用来保存数据文件读异步请求(os_aio_read_array),数据文件写异步请求(os_aio_write_array),日志文件写异步请求(os_aio_log_array),insert buffer写异步请求(os_aio_ibuf_array),数据文件同步读写请求(os_aio_sync_array)。日志文件的数据块写入是同步IO,但是这里为什么还要给日志写分配一个异步请求队列(os_aio_log_array)呢?原因是,InnoDB日志文件的日志头中,需要记录checkpoint的信息,目前checkpoint信息的读写还是用异步IO来实现的,因为不是很紧急。在Windows平台中,如果对特定文件使用了异步IO,就这个文件就不能使用同步IO了,所以引入了数据文件同步读写请求队列(os_aio_sync_array)。日志文件不需要读异步请求队列,因为只有在做奔溃恢复的时候日志才需要被读取,而做崩溃恢复的时候,数据库还不可用,因此完全没必要搞成异步读取模式。这里有一点需要注意,不管变量innobase_read_io_threads和innobase_write_io_threads两个参数是多少,os_aio_read_arrayos_aio_write_array都只有一个,只不过数据中的os_aio_slot_t元素会相应增加,在linux中,变量加1,元素数量增加256。例如,innobase_read_io_threads=4,则os_aio_read_array数组被分成了四部分,每一个部分256个元素,每个部分都有自己独立的锁、信号量以及统计变量,用来模拟4个线程,innobase_write_io_threads类似。从这里我们也可以看出,每个异步read/write线程能缓存的读写请求是有上限的,即为256,如果超过这个数,后续的异步请求需要等待。256可以理解为InnoDB层对异步IO并发数的控制,而在文件系统层和磁盘层面也有长度限制,分别使用cat /sys/block/sda/queue/nr_requestscat /sys/block/sdb/queue/nr_requests查询。
os_aio_init在InnoDB启动的时候调用,用来初始化各种结构,包括上述的全局数组,还有Simulate aio中用的锁和互斥量。os_aio_free则释放相应的结构。os_aio_print_XXX系列的函数用来输出aio子系统的状态,主要用在show engine innodb status语句中。

Simulate aio

Simulate aio相对Native aio来说,由于InnoDB自己实现了一套模拟机制,相对比较复杂。

  • 入口函数为os_aio_func,在debug模式下,会校验一下参数,例如数据块存放的内存地址、文件读写的偏移量和读写的数据量是否是OS_FILE_LOG_BLOCK_SIZE的整数倍,但是没有检验文件打开模式是否用了O_DIRECT,因为Simulate aio最终都是使用同步IO,没有必要一定要用O_DIRECT打开文件。
  • 校验通过后,就调用os_aio_array_reserve_slot,作用是把这个IO请求分配到某一个后台io处理线程(innobase_xxxx_io_threads分配的,但其实是在同一个全局数组中)中,并把io请求的相关信息记录下来,方便后台io线程处理。如果IO请求类型相同,请求同一个文件且偏移量比较接近(默认情况下,偏移量差别在1M内),则InnoDB会把这两个请求分配到同一个io线程中,方便在后续步骤中IO合并。
  • 提交IO请求后,需要唤醒后台io处理线程,因为如果后台线程检测到没有IO请求,会进入等待状态(os_event_wait)。
  • 至此,函数返回,程序可以去干其他事情了,后续的IO处理交给后台线程了。
    介绍一下后台IO线程怎么处理的。
  • InnoDB启动时,后台IO线程会被启动(io_handler_thread)。其会调用os_aio_simulated_handle从全局数组中取出IO请求,然后用同步IO处理,结束后,需要做收尾工作,例如,如果是写请求的话,则需要在buffer pool中把对应的数据页从脏页列表中移除。
  • os_aio_simulated_handle首先需要从数组中挑选出某个IO请求来执行,挑选算法并不是简单的先进先出,其挑选所有请求中offset最小的请求先处理,这样做是为了后续的IO合并比较方便计算。但是这也容易导致某些offset特别大的孤立请求长时间没有被执行到,也就是饿死,为了解决这个问题,在挑选IO请求之前,InnoDB会先做一次遍历,如果发现有请求是2s前推送过来的(也就是等待了2s),但是还没有被执行,就优先执行最老的请求,防止这些请求被饿死,如果有两个请求等待时间相同,则选择offset小的请求。
  • os_aio_simulated_handle接下来要做的工作就是进行IO合并,例如,读请求1请求的是file1,offset100开始的200字节,读请求2请求的是file1,offset300开始的100字节,则这两个请求可以合并为一个请求:file1,offset100开始的300字节,IO返回后,再把数据拷贝到原始请求的buffer中就可以了。写请求也类似,在写操作之前先把需要写的数据拷贝到一个临时空间,然后一次写完。注意,只有在offset连续的情况下IO才会合并,有间断或者重叠都不会合并,一模一样的IO请求也不会合并,所以这里可以算是一个可优化的点。
  • os_aio_simulated_handle如果发现现在没有IO请求,就会进入等待状态,等待被唤醒

综上所述,可以看出IO请求是一个一个的push的对立面,每push进一个后台线程就拿去处理,如果后台线程优先级比较高的话,IO合并效果可能比较差,为了解决这个问题,Simulate aio提供类似组提交的功能,即一组IO请求提交后,才唤醒后台线程,让其统一进行处理,这样IO合并的效果会比较好。但这个依然有点小问题,如果后台线程比较繁忙的话,其就不会进入等待状态,也就是说只要请求进入了队列,就会被处理。这个问题在下面的Native aio中可以解决。
总体来说,InnoDB实现的这一套模拟机制还是比较安全可靠的,如果平台不支持Native aio则使用这套机制来读写数据文件。

Linux native aio

如果系统安装了libaio库且在配置文件里面设置了innodb_use_native_aio=on则启动时候会使用Native aio。

  • 入口函数依然为os_aio_func,在debug模式下,依然会检查传入的参数,同样不会检查文件是否以O_DIRECT模式打开,这算是一个有点风险的点,如果用户不知道linux native aio需要使用O_DIRECT模式打开文件才能发挥出aio的优势,那么性能就不会达到预期。建议在此处做一下检查,有问题输出到错误日志。
  • 检查通过之后,与Simulated aio一样,调用os_aio_array_reserve_slot,把IO请求分配给后台线程,分配算法也考虑了后续的IO合并,与Simulated aio一样。不同之处,主要是需要用IO请求的参数初始化iocb这个结构。IO请求的相关信息除了需要初始化iocb外,也需要在全局数组的slot中记录一份,主要是为了在os_aio_print_XXX系列函数中统计方便。
  • 调用io_submit提交请求。
  • 至此,函数返回,程序可以去干其他事情了,后续的IO处理交给后台线程了。
    接下来是后台IO线程。
  • 与Simulate aio类似,后台IO线程也是在InnoDB启动时候启动。如果是Linux native aio,后续会调用os_aio_linux_handle这个函数。这个函数的作用与os_aio_simulated_handle类似,但是底层实现相对比较简单,其仅仅调用io_getevents函数等待IO请求完成。超时时间为0.5s,也就是说如果即使0.5内没有IO请求完成,函数也会返回,继续调用io_getevents等待,当然在等待前会判断一下服务器是否处于关闭状态,如果是则退出。

在分发IO线程时,尽量把相邻的IO放在一个线程内,这个与Simulate aio类似,但是后续的IO合并操作,Simulate aio是自己实现,Native aio则交给内核完成了,因此代码比较简单。
还要一个区别是,当没有IO请求的时候,Simulate aio会进入等待状态,而Native aio则会每0.5秒醒来一次,做一些检查工作,然后继续等待。因此,当有新的请求来时,Simulated aio需要用户线程唤醒,而Native aio不需要。此外,在服务器关闭时,Simulate aio也需要唤醒,Native aio则不需要。

可以发现,Native aio与Simulate aio类似,请求也是一个一个提交,然后一个一个处理,这样会导致IO合并效果比较差。Facebook团队提交了一个Native aio的组提交优化:把IO请求首先缓存,等IO请求都到了之后,再调用io_submit函数,一口气提交先前的所有请求(io_submit可以一次提交多个请求),这样内核就比较方便做IO优化。Simulate aio在IO线程压力大的情况下,组提交优化会失效,而Native aio则不会。注意,组提交优化,不能一口气提交太多,如果超过了aio等待队列长度,会强制发起一次io_submit。

总结

本文详细介绍了InnoDB中IO子系统的实现以及使用需要注意的点。InnoDB日志使用同步IO,数据使用异步IO,异步IO的写盘顺序也不是先进先出的模式,这些点都需要注意。Simulate aio虽然有比较大的学习价值,但是在现代操作系统中,推荐使用Native aio。


PgSQL · 特性分析 · Write-Ahead Logging机制浅析

$
0
0

WAL机制简介

WAL即 Write-Ahead Logging,是一种实现事务日志的标准方法。WAL 的中心思想是先写日志,再写数据,数据文件的修改必须发生在这些修改已经记录在日志文件中之后。采用WAL日志的数据库系统在事务提交时,WAL机制可以从两个方面来提高性能:

  • 多个client写日志文件可以通过一次 fsync()来完成
  • 日志文件是顺序写的,同步日志的开销要远比同步数据页的开销要小

总体来说,使用了WAL机制之后,磁盘写操作只有传统的回滚日志的一半左右,大大提高了数据库磁盘I/O操作的效率,从而提高了数据库的性能。

采用了WAL机制,就不需要在每次事务提交的时候都把数据页冲刷到磁盘,如果出现数据库崩溃, 我们可以用日志来恢复数据库,任何尚未附加到数据页的记录都将先从日志记录中重做(这叫向前滚动恢复,也叫做 REDO)。对于PostgreSQL来说,未采用WAL机制之前,如果数据库崩溃,可能存在数据页不完整的风险,而WAL 在日志里保存整个数据页的内容,完美地解决了这个问题。

WAL机制实现

实现WAL机制,需要保证脏页在刷新到磁盘前,该数据页相对应的日志记录已经刷新到磁盘中。为了实现WAL机制,当PostgreSQL进行事务提交(脏数据页需要刷新到磁盘)时,需要进行如下操作:

  • 生成该事务提交的日志记录(唯一标示为LSN–Log sequence number)
  • 将该LSN之前的xlog日志刷入到磁盘中

LSN标记

为了标记每个数据页最后修改它的日志记录号,在每个数据页的PageHeaderData结构中引入了一个LSN标记,如下:

typedef struct PageHeaderData
{
	PageXLogRecPtr 	pd_lsn; //指向最后修改页面的日志记录
	uint16			pd_checksum;	
	uint16		    pd_flags;			
	LocationIndex  	pd_lower;			
	LocationIndex  	pd_upper;			
	LocationIndex  	pd_special;	
	uint16   		pd_pagesize_version;
	TransactionId     pd_prune_xid; 	
	ItemIdData       pd_linp[1];		
} PageHeaderData;

其中PageXLogRecPtr结构是一个无符号的64位整数,它的含义如下:

32位8位13位11位
逻辑日志文件号段号块号块内偏移

PageXLogRecPtr结构和每个日志记录一一对应,同时LSN是全局统一管理,顺序增加的。

当缓冲区管理器(Bufmgr)写出脏数据页时,必须确保小于页面PageHeaderData中pd_lsn指向的Xlog日志已经刷写到磁盘上了。这里的LSN检查只用于共享缓冲区,用于临时表的local缓冲区不需要,因此临时表是没有WAL日志的,不受WAL机制的保护。

WAL共享缓存区

为了进一步的减少xlog日志文件的I/O操作,PostgreSQL中引入了WAL共享缓存区,对产生的xlog日志进行缓存,合并I/O操作。

WAL共享缓存区的大小可以通过设置postgresql.conf文件参数wal_buffers来设置,官方解释如下:

  • 表示WAL共享缓存区的大小,和oracle中的log buffer类似,单位可以是kB, MB
  • 默认值为-1,表示大小占shared_buffers大小的1/32,但是大于64kB,小于16MB
  • 用户可以自己设置,小于32kB的值会被转化为32kB
  • 只能在服务启动之前设置

可以看出,当多个client同时进行事务提交时,如果这个缓存区比较大,相应地会更大程度地合并I/O,提高性能,但是如果过大,同时也存在断电后,这部分数据丢失的风险。所以,这里比较推荐使用默认值,即shared_buffers的1/32。

为了标示当前WAL共享缓存区的状态,引入了XLogCtlData结构如下:

typedef struct XLogCtlData
{
	XLogwrtRqst 	LogwrtRqst;/* 表示当前请求写入系统缓冲区或同步写入磁盘的日志位置*/
	XLogRecPtr	asyncXactLSN;	/*最近需要异步提交的日志位置*/
	XLogwrtResult LogwrtResult;	/*当前已经写入系统缓冲区或者同步写入磁盘的日志位置*/
	XLogRecPtr   *xlblocks;	/* LSN数组*/
	int			XLogCacheBlck;/* WAL缓存区的大小,单位为页 */
        char	       	       *pages;      /* 指向WAL缓存Buffer的首地址 */
......
}

其中,LogwrtRqst表示当前请求写入系统缓冲区或同步写入磁盘的日志位置,由info_lck轻量锁保护,结构如下:

typedef struct XLogwrtRqst
{
XLogRecPtr   	  Write;
XLogRecPtr	  Flush;
} XLogwrtRqst;

这里需要注意的是,因为很多操作系统会维护一个操作系统缓存,用来对磁盘的I/O操作进行合并,这就可能造成操作系统返回给内核写文件成功的地址和真实文件写到磁盘的地址是有差异的。为了区分这个差异,这里引入了2个变量,其中:

  • Write表示在此位置之前的日志记录已经写出Wal缓冲区,可能在操作系统缓存区
  • Flush表示的是在此位置之前的日志已经写入到磁盘

asyncXactLSN是一个XLogRecPtr类型的成员变量,表示最近需要异步提交的日志位置,并且也是由info_lck锁来保护。

在PostgreSQL中,日志记录刷写到磁盘有两种提交方式:同步和异步。其区别如下:

  • synchronous_commit参数(默认值为ON)为ON,则为同步方式。事务提交时,对应的Xlog日志必须马上刷新回磁盘事务才能返回成功
  • synchronous_commit参数为OFF,则为异步方式。事务提交时,立刻返回用户成功,同时更新asyncXactLSN

异步提交的提出主要是为了很多短事务(本身执行时间非常短)能立即提交。但是同时,也会打破WAL机制,造成数据库崩溃后数据丢失的危险。在PostgreSQL中,这个参数用户可以在连接中使用SET语句直接设置,实现异步日志提交和同步日志提交的切换,既减少数据丢失风险又能兼顾效率。

LogwrtResult表示当前已经写入系统缓冲区或者同步写入磁盘的日志位置,由info_lck 和 WALWriteLock 锁保护,结构如下:

typedef struct XLogwrtResult
{
XLogRecPtr   Write;
XLogRecPtr	  Flush;
} XLogwrtResult;

可以看出,XLogwrtResult和XLogwrtRqst的结构相同,其原因也是为了区分是否真正写入到磁盘。

Xlblocks是一个XLogRecPtr *类型的成员变量,表示指向每个WAL缓存开始的LSN数组的首地址,可以根据这个变量加上日志缓存的偏移量就可以得到具体的对应LSN。

XLogCacheBlck是一个int类型的成员变量,表示缓冲区的大小,单位为块,系统会根据此块进行日志缓存Buffer的具体分配。

pages是一个char *类型的成员变量,表示指向日志缓存Buffer的首地址的指针,通过偏移量直接定位到哪块日志缓存Buffer,不过需要注意的是,内存日志缓存中存放的日志块,大小不固定,所以2个Buffer的大小可以不一致。

WAL缓存区可以理解为一个环形的共享缓存,每次缓存空间满后,会将头部的页面刷新到磁盘,同时写入新的页面,并将头部往后移动一位。具体来说,需要写入新的日志记录时:

  • 当WAL缓存区中有足够的空间,顺序写入到缓存区中。
  • 当WAL缓存区写到尾部且空间不足时,从头部刷出信息后重复利用。

因为WAL缓存区是顺序刷出的,这样日志文件中的信息必然是连续的。

为了更好的维护和管理WAL的刷盘,PostgreSQL提供辅助进程Walwriter 预发式日志写进程,Walwriter会周期性地将xlog日志块写入到磁盘。此时,刷新xlog日志页到磁盘的时机有以下几个:

  • 事务提交
  • Walwriter进程到达间歇时间
  • 创建checkpoint
  • WAL缓存区满

其中,checkpoint会强制将所有数据缓存中的脏页刷新到磁盘,所以对应的xlog日志页必须在这之前刷新到磁盘。

Walwriter 预发式日志写进程

数据库启动时,通过调用main()->PostmasterMain()->StartupDataBase()->WalWriterMain()启动Walwriter 辅助进程,Walwriter进程从Postmaster进程中startup子进程一结束就启动,一直保留到Postmaster进程命令其结束。

其中WalWriterMain()的具体步骤如下:

  1. 变量初始化

  2. 注册信号函数

  3. 运行环境初始化

    a. 通过ResourceOwnerCreate函数创建一个名为”Wal Writer”的资源跟踪器
    b. 为WalWriter创建运行内存上下文,并将运行环境切换到新创建的内存上下文中

  4. 注册异常处理
  5. 进入服务循环

    a. 处理信号分支
    b. 调用XLogBackgroundFlush()
    c. 完成写Xlog后进入休眠,休眠时间可以根据对应的Xlog日志写出频率进行调节

  6. Walwriter进程退出。

接下来,具体讲下核心函数XLogBackgroundFlush()。它主要是定位需要刷回磁盘的Xlog日志的开始位置和结束位置传递给XLogWrite()函数进行写日志操作,具体步骤如下:

  1. 初始化局部变量

  2. 判断数据库是否在恢复过程中,如果是,则返回false,无需写回磁盘。

  3. 在锁的保护下访问日志控制信息xlogctl

    a. 初始化已经写入系统缓冲区和磁盘的日志位置LogwrtResult = xlogctl->LogwrtResult
    b. 初始化当前请求写入系统缓冲区的日志位置 WriteRqstPtr = xlogctl->LogwrtRqst.Write
    c. 为了保证WalWriter以页为单位进行写日志操作,这里会把请求写入系统缓冲区的日志位置WriteRqstPtr调整为页尾

  4. 比较WriteRqstPtr 和LogwrtResult.Flush,确定刷新到磁盘的头尾地址

    a. 如果前者大于后者,表示当前请求有需要刷回磁盘的日志,转到6
    b. 如果前者小于等于后者,即当前请求写入系统缓冲区的日志位置已经刷新至磁盘,没有需要刷回磁盘的日志,则考虑异步提交的日志情况,将WriteRqstPtr 赋值为xlogctl->asyncXactLSN
    c. 再一次比较现在的WriteRqstPtr 和LogwrtResult.Flush,如果依然是前者小于等于后者,则关闭当前的日志文件,并返回false,否则转到6

  5. 调用XLogWrite刷写页面到磁盘

    a. 等待日志写锁WALWriteLock,获得该锁之后,重新把LogwrtResult 赋值为XLogCtl->LogwrtResult
    b. 比较WriteRqstPtr 和LogwrtResult.Flush,如果前者大于后者,调用XLogWrite,并释放锁。否则,释放锁,返回false。

XLogWrite函数的具体步骤如下:

  1. 初始化局部变量

  2. 进入循环:

    a. 初始化需要刷新到磁盘的最后位置,并且打开相应的日志文件openLogFile,如果不存在该日志文件,则创建一个对应的日志文件

    b. 判断当前是否满足以下三个条件之一,转入c,否则转入d:
    - 当前位置满一段
    - 缓冲区全部刷新到磁盘
    - 请求之前的日志记录全部刷新到磁盘

    c. 刷新缓存到磁盘
    - 执行系统函数_write,将标记位置的日志记录刷新到磁盘(可能是缓冲区)
    - 其中如果满一段,为了减少切换日志段文件带来的开销,应立即执行系统函数_commit强制将日志记录刷新到磁盘(真正的磁盘)
    - 唤醒WalSender进程和XlogArch日志归档进程,判断是否需要创建检查点。

    d. 判断这次写是否不足页,如果不足页则更新已经写出的日志位置为当前请求写日志的位置,不足页仅发生在这次写日志写最后一页的时候,因此请求写日志之前的日志已经全部写出,跳出循环。

    e. 更新需要刷新到磁盘的最后位置为下一页

  3. 更新全局变量XLogCtl;释放锁

除了Walwriter 预发式日志写进程之外,其他的进程如果满足刷新xlog日志页到磁盘的时机,也可以直接调用XLogWrite函数。

至此,我们已经完成对PostgreSQL WAL机制原理和实现的大体介绍。WAL机制的思想比较简单,先日志落盘,后数据落盘,但是在实现过程中会有很多细节的考量,至于涉及到的checkpoint机制以及数据库崩溃的处理,这些将在后续逐步分享。

MySQL · 性能优化 · MySQL常见SQL错误用法

$
0
0

前言

MySQL在2016年仍然保持强劲的数据库流行度增长趋势。越来越多的客户将自己的应用建立在MySQL数据库之上,甚至是从Oracle迁移到MySQL上来。但也存在部分客户在使用MySQL数据库的过程中遇到一些比如响应时间慢,CPU打满等情况。阿里云RDS专家服务团队帮助云上客户解决过很多紧急问题。现将《ApsaraDB专家诊断报告》中出现的部分常见SQL问题总结如下,供大家参考。

常见SQL错误用法

1. LIMIT 语句

分页查询是最常用的场景之一,但也通常也是最容易出问题的地方。比如对于下面简单的语句,一般DBA想到的办法是在type, name, create_time字段上加组合索引。这样条件排序都能有效的利用到索引,性能迅速提升。

SELECT * 
FROM   operation 
WHERE  type = 'SQLStats' 
       AND name = 'SlowLog' 
ORDER  BY create_time 
LIMIT  1000, 10; 

好吧,可能90%以上的DBA解决该问题就到此为止。但当 LIMIT 子句变成 “LIMIT 1000000,10” 时,程序员仍然会抱怨:我只取10条记录为什么还是慢?

要知道数据库也并不知道第1000000条记录从什么地方开始,即使有索引也需要从头计算一次。出现这种性能问题,多数情形下是程序员偷懒了。在前端数据浏览翻页,或者大数据分批导出等场景下,是可以将上一页的最大值当成参数作为查询条件的。SQL重新设计如下:

SELECT   * 
FROM     operation 
WHERE    type = 'SQLStats' 
AND      name = 'SlowLog' 
AND      create_time > '2017-03-16 14:00:00' 
ORDER BY create_time limit 10;

在新设计下查询时间基本固定,不会随着数据量的增长而发生变化。

2. 隐式转换

SQL语句中查询变量和字段定义类型不匹配是另一个常见的错误。比如下面的语句:

mysql> explain extended SELECT * 
     > FROM   my_balance b 
     > WHERE  b.bpn = 14000000123 
     >       AND b.isverified IS NULL ;
mysql> show warnings;
| Warning | 1739 | Cannot use ref access on index 'bpn' due to type or collation conversion on field 'bpn'

其中字段bpn的定义为varchar(20),MySQL的策略是将字符串转换为数字之后再比较。函数作用于表字段,索引失效。

上述情况可能是应用程序框架自动填入的参数,而不是程序员的原意。现在应用框架很多很繁杂,使用方便的同时也小心它可能给自己挖坑。

3. 关联更新、删除

虽然MySQL5.6引入了物化特性,但需要特别注意它目前仅仅针对查询语句的优化。对于更新或删除需要手工重写成JOIN。

比如下面UPDATE语句,MySQL实际执行的是循环/嵌套子查询(DEPENDENT SUBQUERY),其执行时间可想而知。

UPDATE operation o 
SET    status = 'applying' 
WHERE  o.id IN (SELECT id 
                FROM   (SELECT o.id, 
                               o.status 
                        FROM   operation o 
                        WHERE  o.group = 123 
                               AND o.status NOT IN ( 'done' ) 
                        ORDER  BY o.parent, 
                                  o.id 
                        LIMIT  1) t); 

执行计划:

+----+--------------------+-------+-------+---------------+---------+---------+-------+------+-----------------------------------------------------+
| id | select_type        | table | type  | possible_keys | key     | key_len | ref   | rows | Extra                                               |
+----+--------------------+-------+-------+---------------+---------+---------+-------+------+-----------------------------------------------------+
| 1  | PRIMARY            | o     | index |               | PRIMARY | 8       |       | 24   | Using where; Using temporary                        |
| 2  | DEPENDENT SUBQUERY |       |       |               |         |         |       |      | Impossible WHERE noticed after reading const tables |
| 3  | DERIVED            | o     | ref   | idx_2,idx_5   | idx_5   | 8       | const | 1    | Using where; Using filesort                         |
+----+--------------------+-------+-------+---------------+---------+---------+-------+------+-----------------------------------------------------+

重写为JOIN之后,子查询的选择模式从DEPENDENT SUBQUERY变成DERIVED,执行速度大大加快,从7秒降低到2毫秒。

UPDATE operation o 
       JOIN  (SELECT o.id, 
                            o.status 
                     FROM   operation o 
                     WHERE  o.group = 123 
                            AND o.status NOT IN ( 'done' ) 
                     ORDER  BY o.parent, 
                               o.id 
                     LIMIT  1) t
         ON o.id = t.id 
SET    status = 'applying'

执行计划简化为:

+----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------------------------------+
| id | select_type | table | type | possible_keys | key   | key_len | ref   | rows | Extra                                               |
+----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------------------------------+
| 1  | PRIMARY     |       |      |               |       |         |       |      | Impossible WHERE noticed after reading const tables |
| 2  | DERIVED     | o     | ref  | idx_2,idx_5   | idx_5 | 8       | const | 1    | Using where; Using filesort                         |
+----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------------------------------+

4. 混合排序

MySQL不能利用索引进行混合排序。但在某些场景,还是有机会使用特殊方法提升性能的。

SELECT * 
FROM   my_order o 
       INNER JOIN my_appraise a ON a.orderid = o.id 
ORDER  BY a.is_reply ASC, 
          a.appraise_time DESC 
LIMIT  0, 20 

执行计划显示为全表扫描:

+----+-------------+-------+--------+-------------+---------+---------+---------------+---------+-+
| id | select_type | table | type   | possible_keys     | key     | key_len | ref      | rows    | Extra    
+----+-------------+-------+--------+-------------+---------+---------+---------------+---------+-+
|  1 | SIMPLE      | a     | ALL    | idx_orderid | NULL    | NULL    | NULL    | 1967647 | Using filesort |
|  1 | SIMPLE      | o     | eq_ref | PRIMARY     | PRIMARY | 122     | a.orderid |       1 | NULL           |
+----+-------------+-------+--------+---------+---------+---------+-----------------+---------+-+

由于is_reply只有0和1两种状态,我们按照下面的方法重写后,执行时间从1.58秒降低到2毫秒。

SELECT * 
FROM   ((SELECT *
         FROM   my_order o 
                INNER JOIN my_appraise a 
                        ON a.orderid = o.id 
                           AND is_reply = 0 
         ORDER  BY appraise_time DESC 
         LIMIT  0, 20) 
        UNION ALL 
        (SELECT *
         FROM   my_order o 
                INNER JOIN my_appraise a 
                        ON a.orderid = o.id 
                           AND is_reply = 1 
         ORDER  BY appraise_time DESC 
         LIMIT  0, 20)) t 
ORDER  BY  is_reply ASC, 
          appraisetime DESC 
LIMIT  20; 

5. EXISTS语句

MySQL对待EXISTS子句时,仍然采用嵌套子查询的执行方式。如下面的SQL语句:

SELECT *
FROM   my_neighbor n 
       LEFT JOIN my_neighbor_apply sra 
              ON n.id = sra.neighbor_id 
                 AND sra.user_id = 'xxx' 
WHERE  n.topic_status < 4 
       AND EXISTS(SELECT 1 
                  FROM   message_info m 
                  WHERE  n.id = m.neighbor_id 
                         AND m.inuser = 'xxx') 
       AND n.topic_type <> 5 

执行计划为:

+----+--------------------+-------+------+-----+------------------------------------------+---------+-------+---------+ -----+
| id | select_type        | table | type | possible_keys     | key   | key_len | ref   | rows    | Extra   |
+----+--------------------+-------+------+ -----+------------------------------------------+---------+-------+---------+ -----+
|  1 | PRIMARY            | n     | ALL  |  | NULL     | NULL    | NULL  | 1086041 | Using where                   |
|  1 | PRIMARY            | sra   | ref  |  | idx_user_id | 123     | const |       1 | Using where          |
|  2 | DEPENDENT SUBQUERY | m     | ref  |  | idx_message_info   | 122     | const |       1 | Using index condition; Using where |
+----+--------------------+-------+------+ -----+------------------------------------------+---------+-------+---------+ -----+

去掉exists更改为join,能够避免嵌套子查询,将执行时间从1.93秒降低为1毫秒。

SELECT *
FROM   my_neighbor n 
       INNER JOIN message_info m 
               ON n.id = m.neighbor_id 
                  AND m.inuser = 'xxx' 
       LEFT JOIN my_neighbor_apply sra 
              ON n.id = sra.neighbor_id 
                 AND sra.user_id = 'xxx' 
WHERE  n.topic_status < 4 
       AND n.topic_type <> 5 

新的执行计划:

+----+-------------+-------+--------+ -----+------------------------------------------+---------+ -----+------+ -----+
| id | select_type | table | type   | possible_keys     | key       | key_len | ref   | rows | Extra                 |
+----+-------------+-------+--------+ -----+------------------------------------------+---------+ -----+------+ -----+
|  1 | SIMPLE      | m     | ref    | | idx_message_info   | 122     | const    |    1 | Using index condition |
|  1 | SIMPLE      | n     | eq_ref | | PRIMARY   | 122     | ighbor_id |    1 | Using where      |
|  1 | SIMPLE      | sra   | ref    | | idx_user_id | 123     | const     |    1 | Using where           |
+----+-------------+-------+--------+ -----+------------------------------------------+---------+ -----+------+ -----+

6. 条件下推

外部查询条件不能够下推到复杂的视图或子查询的情况有:

  1. 聚合子查询;
  2. 含有LIMIT的子查询;
  3. UNION 或UNION ALL子查询;
  4. 输出字段中的子查询;

如下面的语句,从执行计划可以看出其条件作用于聚合子查询之后:

SELECT * 
FROM   (SELECT target, 
               Count(*) 
        FROM   operation 
        GROUP  BY target) t 
WHERE  target = 'rm-xxxx'
+----+-------------+------------+-------+---------------+-------------+---------+-------+------+-------------+
| id | select_type | table      | type  | possible_keys | key         | key_len | ref   | rows | Extra       |
+----+-------------+------------+-------+---------------+-------------+---------+-------+------+-------------+
|  1 | PRIMARY     | <derived2> | ref   | <auto_key0>   | <auto_key0> | 514     | const |    2 | Using where |
|  2 | DERIVED     | operation  | index | idx_4         | idx_4       | 519     | NULL  |   20 | Using index |
+----+-------------+------------+-------+---------------+-------------+---------+-------+------+-------------+

确定从语义上查询条件可以直接下推后,重写如下:

SELECT target, 
       Count(*) 
FROM   operation 
WHERE  target = 'rm-xxxx' 
GROUP  BY target

执行计划变为:

+----+-------------+-----------+------+---------------+-------+---------+-------+------+--------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-----------+------+---------------+-------+---------+-------+------+--------------------+
| 1 | SIMPLE | operation | ref | idx_4 | idx_4 | 514 | const | 1 | Using where; Using index |
+----+-------------+-----------+------+---------------+-------+---------+-------+------+--------------------+

关于MySQL外部条件不能下推的详细解释说明请参考以前文章:MySQL · 性能优化 · 条件下推到物化表

7. 提前缩小范围

先上初始SQL语句:

SELECT * 
FROM   my_order o 
       LEFT JOIN my_userinfo u 
              ON o.uid = u.uid
       LEFT JOIN my_productinfo p 
              ON o.pid = p.pid 
WHERE  ( o.display = 0 ) 
       AND ( o.ostaus = 1 ) 
ORDER  BY o.selltime DESC 
LIMIT  0, 15 

该SQL语句原意是:先做一系列的左连接,然后排序取前15条记录。从执行计划也可以看出,最后一步估算排序记录数为90万,时间消耗为12秒。

+----+-------------+-------+--------+---------------+---------+---------+-----------------+--------+----------------------------------------------------+
| id | select_type | table | type   | possible_keys | key     | key_len | ref             | rows   | Extra                                              |
+----+-------------+-------+--------+---------------+---------+---------+-----------------+--------+----------------------------------------------------+
|  1 | SIMPLE      | o     | ALL    | NULL          | NULL    | NULL    | NULL            | 909119 | Using where; Using temporary; Using filesort       |
|  1 | SIMPLE      | u     | eq_ref | PRIMARY       | PRIMARY | 4       | o.uid |      1 | NULL                                               |
|  1 | SIMPLE      | p     | ALL    | PRIMARY       | NULL    | NULL    | NULL            |      6 | Using where; Using join buffer (Block Nested Loop) |
+----+-------------+-------+--------+---------------+---------+---------+-----------------+--------+----------------------------------------------------+

由于最后WHERE条件以及排序均针对最左主表,因此可以先对my_order排序提前缩小数据量再做左连接。SQL重写后如下,执行时间缩小为1毫秒左右。

SELECT * 
FROM (
SELECT * 
FROM   my_order o 
WHERE  ( o.display = 0 ) 
       AND ( o.ostaus = 1 ) 
ORDER  BY o.selltime DESC 
LIMIT  0, 15
) o 
     LEFT JOIN my_userinfo u 
              ON o.uid = u.uid 
     LEFT JOIN my_productinfo p 
              ON o.pid = p.pid 
ORDER BY  o.selltime DESC
limit 0, 15

再检查执行计划:子查询物化后(select_type=DERIVED)参与JOIN。虽然估算行扫描仍然为90万,但是利用了索引以及LIMIT 子句后,实际执行时间变得很小。


+----+-------------+------------+--------+---------------+---------+---------+-------+--------+----------------------------------------------------+
| id | select_type | table      | type   | possible_keys | key     | key_len | ref   | rows   | Extra                                              |
+----+-------------+------------+--------+---------------+---------+---------+-------+--------+----------------------------------------------------+
|  1 | PRIMARY     | <derived2> | ALL    | NULL          | NULL    | NULL    | NULL  |     15 | Using temporary; Using filesort                    |
|  1 | PRIMARY     | u          | eq_ref | PRIMARY       | PRIMARY | 4       | o.uid |      1 | NULL                                               |
|  1 | PRIMARY     | p          | ALL    | PRIMARY       | NULL    | NULL    | NULL  |      6 | Using where; Using join buffer (Block Nested Loop) |
|  2 | DERIVED     | o          | index  | NULL          | idx_1   | 5       | NULL  | 909112 | Using where                                        |
+----+-------------+------------+--------+---------------+---------+---------+-------+--------+----------------------------------------------------+

8. 中间结果集下推

再来看下面这个已经初步优化过的例子(左连接中的主表优先作用查询条件):

SELECT    a.*, 
          c.allocated 
FROM      ( 
              SELECT   resourceid 
              FROM     my_distribute d 
                   WHERE    isdelete = 0 
                   AND      cusmanagercode = '1234567' 
                   ORDER BY salecode limit 20) a 
LEFT JOIN 
          ( 
              SELECT   resourcesid, sum(ifnull(allocation, 0) * 12345) allocated 
              FROM     my_resources 
                   GROUP BY resourcesid) c 
ON        a.resourceid = c.resourcesid

那么该语句还存在其它问题吗?不难看出子查询 c 是全表聚合查询,在表数量特别大的情况下会导致整个语句的性能下降。

其实对于子查询 c,左连接最后结果集只关心能和主表resourceid能匹配的数据。因此我们可以重写语句如下,执行时间从原来的2秒下降到2毫秒。

SELECT    a.*, 
          c.allocated 
FROM      ( 
                   SELECT   resourceid 
                   FROM     my_distribute d 
                   WHERE    isdelete = 0 
                   AND      cusmanagercode = '1234567' 
                   ORDER BY salecode limit 20) a 
LEFT JOIN 
          ( 
                   SELECT   resourcesid, sum(ifnull(allocation, 0) * 12345) allocated 
                   FROM     my_resources r, 
                            ( 
                                     SELECT   resourceid 
                                     FROM     my_distribute d 
                                     WHERE    isdelete = 0 
                                     AND      cusmanagercode = '1234567' 
                                     ORDER BY salecode limit 20) a 
                   WHERE    r.resourcesid = a.resourcesid 
                   GROUP BY resourcesid) c 
ON        a.resourceid = c.resourcesid

但是子查询 a 在我们的SQL语句中出现了多次。这种写法不仅存在额外的开销,还使得整个语句显的繁杂。使用WITH语句再次重写:

WITH a AS 
( 
         SELECT   resourceid 
         FROM     my_distribute d 
         WHERE    isdelete = 0 
         AND      cusmanagercode = '1234567' 
         ORDER BY salecode limit 20)
SELECT    a.*, 
          c.allocated 
FROM      a 
LEFT JOIN 
          ( 
                   SELECT   resourcesid, sum(ifnull(allocation, 0) * 12345) allocated 
                   FROM     my_resources r, 
                            a 
                   WHERE    r.resourcesid = a.resourcesid 
                   GROUP BY resourcesid) c 
ON        a.resourceid = c.resourcesid

AliSQL即将推出WITH语法,敬请期待。

总结

  1. 数据库编译器产生执行计划,决定着SQL的实际执行方式。但是编译器只是尽力服务,所有数据库的编译器都不是尽善尽美的。上述提到的多数场景,在其它数据库中也存在性能问题。了解数据库编译器的特性,才能避规其短处,写出高性能的SQL语句。
  2. 程序员在设计数据模型以及编写SQL语句时,要把算法的思想或意识带进来。
  3. 编写复杂SQL语句要养成使用WITH语句的习惯。简洁且思路清晰的SQL语句也能减小数据库的负担 ^^。
  4. 使用云上数据库遇到难点(不局限于SQL问题),随时寻求阿里云原厂专家服务的帮助。

MSSQL · 特性分析 · 列存储技术做实时分析

$
0
0

摘要

数据分析指导商业行为的价值越来越高,使得用户对数据实时分析的要求变得越来越高。使用传统RDBMS数据分析架构,遇到了前所未有的挑战,高延迟、数据处理流程复杂和成本过高。这篇文章讨论如何利用SQL Server 2016列存储技术做实时数据分析,解决传统分析方法的痛点。

传统RDBMS数据分析

在过去很长一段时间,企业均选择传统的关系型数据库做OLAP和Data Warehouse工作。这一节讨论传统RDBMS数据分析的结构和面临的挑战。

传统RDBMS分析架构

传统关系型数据库做数据分析的架构,按照功能模块可以划分为三个部分:

  • OLTP模块:OLTP的全称是Online Transaction Processing,它是数据产生的源头,对数据的完整性和一致性要求很高;对数据库的反应时间(RT: Response Time)非常敏感;具有高并发,多事务,高响应等特点。

  • ETL模块:ETL的全称是Extract Transform Load。他是做数据清洗、转化和加载工作的。可以将ETL理解为数据从OLTP到Data Warehouse的“搬运工”。ETL最大的特定是具有延时性,为了最大限度减小对OLTP的影响,一般会设计成按小时,按天或者按周来周期性运作。

  • OLAP模块:OLAP的全称是Online Analytic Processing,它是基于数据仓库(Data Warehouse)做数据分析和报表呈现的终端产品。数据仓库的特点是:数据形态固定,几乎或者很少发生数据变更,统计查询分析读取数据量大。
    传统的RDBMS分析模型图,如下图展示(图片直接截取自微软的培训材料):
    01.png

从这个图,我们可以非常清晰的看到传统RDBMS分析模型的三个大的部分:在图的最左边是OLTP业务场景,负责采集和产生数据;图的中部是ETL任务,负责“搬运”数据;图的右边是OLAP业务场景,负责分析数据,然后将分析结果交给BI报表展示给最终用户。企业使用这个传统的架构长达数年,遇到了不少的挑战和困难。

面临的挑战

商场如战场,战机随息万变,数据分析结果指导商业行为的价值越来越高,使得数据分析结果变得越来越重要,用户对数据实时分析的要求变得越来越高。使用传统RDBMS分析架构,遇到了前所未有的挑战,主要的痛点包括:

  • 数据延迟大

  • 数据处理流程冗长复杂

  • 成本过高

数据延迟大:为了减少对OLTP模块的影响,ETL任务往往会选择在业务低峰期周期性运作,比如凌晨。这就会导致OLAP分析的数据源Data Warehouse相对于OLTP有至少一天的时间差异。这个时间差异对于某些实时性要求很高的业务来说,是无法接受的。比如:银行卡盗刷的检查服务,是需要做到秒级别通知持卡人的。试想下,如果你的银行卡被盗刷,一天以后才收到银行发过来的短信提醒,会是多么糟糕的体验。

数据处理流程冗长复杂:数据是通过ETL任务来抽取、清洗和加载到Data Warehouse中的。为了保证数据分析结果的正确性,ETL还必须要解决一系列的问题。比如:OLTP变更数据的捕获,并同步到Data Warehouse;周期性的进行数据全量和增量更新来确保OLTP和Data Warehouse中数据的一致性。整个数据流冗长,实现逻辑异常复杂。

成本过高:为了实现传统的RDBMS数据分析功能,必须新增Data Warehouse角色来保存所有的OLTP数据冗余,专门提供分析服务功能。这势必会加大了硬件、软件和维护成本投入;随之还会到来ETL任务做数据抓取、清洗、转换和加载的开发成本和时间成本投入。

那么,SQL Server有没有一种技术既能解决以上所有痛点的方法,又能实现数据实时分析呢?当然有,那就是SQL Server 2016列存储技术。

SQL Server 2016列存储技术做实时分析

为了解决OLAP场景的查询分析,微软从SQL Server 2012开始引入列存储技术,大大提高了OLAP查询的性能;SQL Server 2014解决了列存储表只读的问题,使用场景大大拓宽;而SQL Server 2016的列存储技术彻底解决了实时数据分析的业务场景。用户只需要做非常小规模的修改,便可以可以非常平滑的使用SQL Server 2016的列存储技术来解决实时数据分析的业务场景。这一节讨论以下几个方面:

  • SQL Server 2016数据分析架构

  • Disk-based Tables with Nonclustered Columnstore Index

  • Memory-based Tables with Columnstore Index

  • Minimizing impacts of OLTP

SQL Server 2016数据分析架构

SQL Server 2016数据分析架构相对于传统的RDBMS数据分析架构有了非常大的改进,变得更加简单。具体体现在OLAP直接接入OLTP数据源,如此就无需Data Warehouse角色和ETL任务这个“搬运工”了。

OLAP直接接入OLTP数据源:让OLAP报表数据源直接接入OLTP的数据源头上。SQL Server会自动选择合适的列存储索引来提高数据分析查询的性能,实现实时数据分析的场景。

不再需要ETL任务:由于OLAP数据源直接接入OLTP的数据,没有了Data Warehouse角色,所以不再需要ETL任务,从而大大简化了数据处理流程中的各环节,没有了相应的开发维护和时间成本。
SQL Server 2016实时分析架构图,展示如下(图片来自微软培训教程):
02.png

SQL Server 2016之所以能够实现如此简化的实时分析,底气是来源于SQL Server 2016的列存储技术,我们可以建立基于磁盘存储或者基于内存存储的列存储表来进行实时数据分析。

Disk-based Tables with Nonclustered Columnstore Index

使用SQL Server 2016列存储索引实现实时分析的第一种方法是为表建立非聚集列存储索引。在SQL Server 2012版本中,仅支持非聚集列存储索引,并且表会成为只读,而无法更新;在SQL Server 2014版本中,支持聚集列存储索引表,且数据可更新;但是非聚集列存储索引表还是只读;而在SQL Server 2016中,完全支持非聚集列存储索引和聚集列存储索引,并且表可更新。所以,在SQL Server 2016版本中,我们完全可以建立非聚集列存储索引来实现OLAP的查询场景。创建方法示例如下:

DROP TABLE IF EXISTS dbo.SalesOrder;
GO
CREATE TABLE dbo.SalesOrder
(
    OrderID BIGINT IDENTITY(1,1) NOT NULL
    ,AutoID INT NOT NULL
    ,UserID INT NOT NULL
    ,OrderQty INT NOT NULL
    ,Price DECIMAL(8,2) NOT NULL
    ,OrderDate DATETIME NOT NULL
	,OrderStatus SMALLINT NOT NULL
	CONSTRAINT PK_SalesOrder PRIMARY KEY NONCLUSTERED (OrderID)
) ;
GO

--Create the columnstore index with a filtered condition  
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_SalesOrder 
ON dbo.SalesOrder (OrderID, AutoID, UserID, OrderQty, Price, OrderDate, OrderStatus)
;
GO

在这个实例中,我们创建了SalesOrder表,并且为该表创建了非聚集列存储索引,当进行OLAP查询分析的时候,SQL Server会直接从该列存储索引中读取数据。

Memory-based Tables with Columnstore Index

SQL Server 2014版本引入了In-Memory OLTP,又或者叫着Hekaton,中文称之为内存优化表,内存优化表完全是Lock Free、Latch Free的,可以最大限度的增加并发和提高响应时间。而在SQL Server 2016中,如果你的服务器内存足够大的话,我们完全可以建立基于内存优化表的列存储索引,这样的表数据会按列存储在内存中,充分利用两者的优势,最大程度的提高查询查询效率,降低数据库响应时间。创建方法实例如下:


DROP TABLE IF EXISTS dbo.SalesOrder;
GO
CREATE TABLE dbo.SalesOrder
(
    OrderID BIGINT IDENTITY(1,1) NOT NULL
    ,AutoID INT NOT NULL
    ,UserID INT NOT NULL
    ,OrderQty INT NOT NULL
    ,Price DECIMAL(8,2) NOT NULL
    ,OrderDate DATETIME NOT NULL
	,OrderStatus SMALLINT NOT NULL
	CONSTRAINT PK_SalesOrder PRIMARY KEY NONCLUSTERED HASH (OrderID) WITH (BUCKET_COUNT = 10000000)
) WITH(MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_AND_DATA) ;
GO

ALTER TABLE dbo.SalesOrder
	ADD INDEX CCSI_SalesOrder CLUSTERED COLUMNSTORE
;
GO

在这个实例中,我们创建了基于内存的优化表SalesOrder,持久化方案为表结构和数据;然后在这个内存表上建立聚集列存储索引。当OLAP查询分析执行的时候,SQL Server可以直接从基于内存的列存储索引中获取数据,大大提高查询分析的能力。

Minimizing impacts of OLTP

考虑到OLTP数据源的高并发,低延迟要求的特性,在某些非常高并发事务场景中,我们可以采用以下方法最大限度减少对OLTP的影响:

  • Filtered NCCI + Clustered B-Tree Index

  • Compress Delay

  • Offloading OLAP to AlwaysOn Readable Secondary

Filtered NCCI + Clustered B-Tree Index

带过滤条件的索引在SQL Server产品中并不是什么全新的概念,在SQL Server 2008及以后的产品版本中,均支持创建过滤索引,这项技术允许用户创建存在过滤条件的索引,以加速特定条件的查询语句使用过滤索引。而在SQL Server 2016中支持存在过滤条件的列存储索引,我们可以使用这项技术来区分数据的冷热程度(数据冷热程度是指数据的修改频率;冷数据是指几乎或者很少被修改的数据;热数据是指经常会被修改的数据。比如在订单场景中,订单从生成状态到客户收到货物之间的状态,会被经常更新,属于热数据;而客人一旦收到货物,订单信息几乎不会被修改了,就属于冷数据)。利用过滤列存储索引来区分冷热数据的技术,是使用聚集B-Tree索引来存放热数据,使用过滤非聚集列存储索引来存放冷数据,这样SQL Server 2016的优化器可以非常智能的从非聚集列存储索引中获取冷数据,从聚集B-Tree索引中获取热数据,这样使得OLAP操作与OLTP事务操作逻辑隔离开来,最终OLAP最大限度的减少对OLTP的影响。

下图直观的表示了Filtered NCCI + Clustered B-Tree Index的结构图(图片来自微软培训教程):

03.png

实现方法参见以下代码:

-- create demo table SalesOrder
DROP TABLE IF EXISTS dbo.SalesOrder;
GO
CREATE TABLE dbo.SalesOrder
(
    OrderID BIGINT IDENTITY(1,1) NOT NULL
    ,AutoID INT NOT NULL
    ,UserID INT NOT NULL
    ,OrderQty INT NOT NULL
    ,Price DECIMAL(8,2) NOT NULL
    ,OrderDate DATETIME NOT NULL
	,OrderStatus SMALLINT NOT NULL
	CONSTRAINT PK_SalesOrder PRIMARY KEY NONCLUSTERED (OrderID)
) ;
GO
/*
— OrderStatus Description
— 0 => ‘Placed’ 
— 1 => ‘Closed’
— 2 => ‘Paid’
— 3 => ‘Pending’
— 4 => ‘Shipped’
— 5 => ‘Received’
*/

CREATE CLUSTERED INDEX  CI_SalesOrder 
ON dbo.SalesOrder(OrderStatus)
;
GO
 
--Create the columnstore index with a filtered condition  
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_SalesOrder 
ON dbo.SalesOrder (AutoID, Price, OrderQty, orderstatus)  
WHERE orderstatus = 5  
;  
GO

在这个实例中,我们创建了SalesOrder表,并在OrderStatus字段上建立了Clustered B-Tree结构的索引CI_SalesOrder,然后再建立了带过滤条件的非聚集列存储索引NCCI_SalesOrder。当客人还未收到货物的订单,会处于前面五中状态,属于需要经常更新的热数据,SQL Server查询会根据Clustered B-Tree索引CI_SalesOrder来查询数据;客人已经收货的订单,处于第六种状态,属于冷数据,SQL Server查询冷数据会直接从非聚集列存储索引中获取数据。从而最大限度减少对OLTP影响的同时,提高查询效率。

Compress Delay

如果按照业务逻辑层面很难明确划分出数据的冷热程度,也就是说很难从过滤条件来逻辑区分数据的冷热。这种情况下,我们可以使用延迟压缩(Compress Delay)技术从时间层面来区分冷热数据。比如:我们定义超过60分钟的数据为冷数据,60分钟以内的数据为热数据,那么我们可以在创建列存储索引的时候添加WITH选项COMPRESSION_DELAY = 60 Minutes。当数据产生超过60分钟以后,数据会被压缩存放到列存储索引中(冷数据),60分钟以内的数据会驻留在Delta Store的B-Tree结构中,这种延迟压缩的技术不但能够达到隔离OLAP对OLTP作用,还能最大限度的减少列存储索引碎片的产生。
实现方法参见以下例子:

-- create demo table SalesOrder
DROP TABLE IF EXISTS dbo.SalesOrder;
GO
CREATE TABLE dbo.SalesOrder
(
    OrderID BIGINT IDENTITY(1,1) NOT NULL
    ,AutoID INT NOT NULL
    ,UserID INT NOT NULL
    ,OrderQty INT NOT NULL
    ,Price DECIMAL(8,2) NOT NULL
    ,OrderDate DATETIME NOT NULL
	,OrderStatus SMALLINT NOT NULL
	CONSTRAINT PK_SalesOrder PRIMARY KEY NONCLUSTERED (OrderID)
) ;
GO

--Create the columnstore index with a filtered condition  
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_SalesOrder 
ON dbo.SalesOrder (AutoID, Price, OrderQty, orderstatus)  
WITH(COMPRESSION_DELAY = 60 MINUTES)
;  
GO

SELECT name
		,type_desc
		,compression_delay 
FROM sys.indexes
WHERE object_id = object_id('SalesOrder')
	AND name = 'NCCI_SalesOrder'
;

检查索引信息截图如下:
04.png

Offloading OLAP to AlwaysOn Readable Secondary

另外一种减少OLAP对OLTP影响的方法是利用AlwaysOn只读副本,这种情况,可以将OLAP数据源从OLTP剥离出来,接入到AlwaysOn的只读副本上。AlwaysOn的主副本负责事务处理,只读副本可以作为OLAP的数据分析源,这样实现了OLAP与OLTP的物理隔离,将影响减到最低。架构图如下所示(图片来自微软培训教程):
05.png

一个实际例子

在订单系统场景中,用户收到货物过程,每个订单会经历6中状态,假设为Placed、Canceled、Paid、Pending、Shipped和Received。在前面5中状态的订单,会被经常修改,比如:打包订单,出库,更新快递信息等,这部分经常被修改的数据称为热数据;而订单一旦被客人接受以后,订单数据就几乎不会被修改,这部分数据称为冷数据。这个例子就是使用SQL Server 2016 Filtered NCCI + Clustered B-Tree索引的方式来逻辑划分出数据的冷热程度,SQL Server在查询过程中,会从非聚集列存储索引中取冷数据,从B-Tree索引中取热数据,最大限度提高OLAP查询效率,减少对OLTP的影响。
具体建表代码实现如下:

-- create demo table SalesOrder
DROP TABLE IF EXISTS dbo.SalesOrder;
GO
CREATE TABLE dbo.SalesOrder
(
    OrderID BIGINT IDENTITY(1,1) NOT NULL
    ,AutoID INT NOT NULL
    ,UserID INT NOT NULL
    ,OrderQty INT NOT NULL
    ,Price DECIMAL(8,2) NOT NULL
    ,OrderDate DATETIME NOT NULL
	,OrderStatus SMALLINT NOT NULL
	CONSTRAINT PK_SalesOrder PRIMARY KEY NONCLUSTERED (OrderID)
) ;
GO
/*
— OrderStatus Description
— 0 => ‘Placed’ 
— 1 => ‘Closed’
— 2 => ‘Paid’
— 3 => ‘Pending’
— 4 => ‘Shipped’
— 5 => ‘Received’
*/

CREATE CLUSTERED INDEX  CI_SalesOrder 
ON dbo.SalesOrder(OrderStatus)
;
GO
 
--Create the columnstore index with a filtered condition  
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_SalesOrder 
ON dbo.SalesOrder (AutoID, Price, OrderQty, orderstatus)  
WHERE orderstatus = 5  
;  
GO

为了能够直观的看到利用SQL Server 2016列存储索引实现实时分析的效果,我虚拟了一个网络汽车销售订单系统,使用NodeJs + SQL Server 2016 Columnstore Index + Socket.IO来实现实时订单销量和销售收入的分析页面。详情参加Youku视屏:SQL Server 2016列存储索引实现实时数据分析

总结

这篇文章讲解利用SQL Server 2016列存储索引技术实现数据实时分析的两种方法,以解决传统RDBMS数据分析的高延迟、高成本的痛点。第一种方法是Hekaton + Clustered Columnstore Index;第二种方法是Filtered Nonclustered Columnstore Index + Clustered B-Tree。本文并以此理论为基础,展示了一个网络汽车在线销售系统的实时订单分析页面。

参考文章

Real-Time Operational Analytics: Filtered nonclustered columnstore index (NCCI)

Real-Time Operational Analytics: Memory-Optimized Tables and Columnstore Index

Real-Time Operational Analytics Using In-Memory Technology

MySQL · 新特性分析 · 5.7中Derived table变形记

$
0
0

Derived table实际上是一种特殊的subquery,它位于SQL语句中FROM子句里面,可以看做是一个单独的表。MySQL5.7之前的处理都是对Derived table进行Materialize,生成一个临时表保存Derived table的结果,然后利用临时表来协助完成其他父查询的操作,比如JOIN等操作。MySQL5.7中对Derived table做了一个新特性。该特性允许将符合条件的Derived table中的子表与父查询的表合并进行直接JOIN。下面我们看一下DBT-3中的一条被新特性优化过的执行计划:

SELECT t2.o_clerk, t1.price - t2.o_totalprice
FROM
    (SELECT l_orderkey, SUM( l_extendedprice * (1 - l_discount)) price
     FROM lineitem GROUP by l_orderkey) t1
JOIN
    (SELECT o_clerk, o_orderkey, o_totalprice
     FROM orders 
     WHERE o_orderdate BETWEEN '1995-01-01' AND '1995-12-31') t2
ON t1.l_orderkey = t2.o_orderkey WHERE t1.price > t2.o_totalprice;

MySQL5.6执行计划如下图所示(下图通过WorkBench的Visual Explain直观的对执行计划进行了展示):

MySQL5.6执行计划

对应的explain输出结果为:

ID SELECT_TYPE	  TABLE	    TYPE	POSSIBLE_KEYS	KEY	    KEY_LEN	REF	            ROWS	    EXTRA
1	PRIMARY  	<derived3>	ALL  	NULL	        NULL	NULL	NULL	        4812318	    NULL
1	PRIMARY	    <derived2>	ref	   <auto_key0>	 <auto_key0>   4	t2.o_orderkey	599860	    Using where; Using index
3	DERIVED       orders	ALL	   i_o_orderdate	NULL	NULL	NULL	        15000000    Using where
2	DERIVED	      lineitem	index	PRIMARY, i_l_shipdate, …	PRIMARY	8	NULL	 59986052	NULL   

MySQL5.7 Merge derived table特性应用之后,执行计划变成了如下所示:

derived-57.png

同样explain的输出结果为:

ID  SELECT_TYPE	TABLE	    PARTITIONS  TYPE	POSSIBLE_KEYS	        KEY	    KEY_LEN	REF    	ROWS	    FILTERED	EXTRA
1	PRIMARY	    <derived2>	NULL	    ALL	    NULL	                NULL	NULL	NULL	59986052	100.00	    NULL
1	PRIMARY	    orders 	    NULL	    eq_ref	PRIMARY, i_o_orderdate	PRIMARY	4	    t1.l_orderkey	1	10.69	    Using where
2	DERIVED	    lineitem	NULL	    index	PRIMARY, i_l_shipdate, …PRIMARY	8	    NULL	59986052	100.00	    NULL

可以看到orders已经从Derived table的子表里面merge到了父查询中,尽而简化了执行计划,同时也提高了执行效率。看一下MySQL5.6与MySQL5.7对于上面的DBT-3中的这条Query执行性能的对比图:

derived-tutorial.png

Merge Derived table有两种方式进行控制。第一种,通过开关optimizer_switch=’derived_merge=on|off’来进行控制。第二种,在CREATE VIEW的时候指定ALGORITHM=MERGE | TEMPTABLE, 默认是MERGE方式。如果指定是TEMPTABLE,将不会对VIEW进行Merge Derived table操作。只要Derived table里不包含如下条件就可以利用该特性进行优化:

  • UNION clause
  • GROUP BY
  • DISTINCT
  • Aggregation
  • LIMIT or OFFSET
  • Derived table里面包含用户变量的设置。

那么Merge Derived table在MySQL中是如何实现的呢?下面我们分析一下源码。
对于Derived table的merge过程是在MySQL的resolve阶段完成的,这意味着对于Merge操作是永久性的,经过resolve阶段之后就不会再对Derived table进行其他的变换。执行的简单流程如下:

SELECT_LEX::prepare

       |

TABLE_LIST::resolve_derived // 这里首先递归对每个Derived table自身进行变换,经过变换后的Derived table开始考虑和最外层的父查询进行Merge

       |

SELECT_LEX::merge_derived // 将Derived table与父查询进行Merge

下面我们重点研究一下merge_derived这个函数实现过程:

bool SELECT_LEX::merge_derived(THD *thd, TABLE_LIST *derived_table)
{   
  DBUG_ENTER("SELECT_LEX::merge_derived");
  
  // 这里首先会判断是不是Derived table(这里view看做是带有名字的Derived table),同时也会看该Derived table是否已经被合并过了
  if (!derived_table->is_view_or_derived() || derived_table->is_merged())
    DBUG_RETURN(false);

  SELECT_LEX_UNIT *const derived_unit= derived_table->derived_unit();
  
  // A derived table must be prepared before we can merge it
  DBUG_ASSERT(derived_unit->is_prepared());

  LEX *const lex= parent_lex;
  
  // Check whether the outer query allows merged views
  if ((master_unit() == lex->unit && // 只会在父查询进行merge Derived table操作。
 	   // 这里会查看当前命令是否需要进行merge操作,比如CREATE VIEW,SHOW CREATE VIEW等。如果需要再继续
       !lex->can_use_merged()) ||  
      lex->can_not_use_merged()) 
    DBUG_RETURN(false);

 // 查看当前的Derived table是否满足merge条件
  if (!derived_unit->is_mergeable() ||
      derived_table->algorithm == VIEW_ALGORITHM_TEMPTABLE ||
      (!thd->optimizer_switch_flag(OPTIMIZER_SWITCH_DERIVED_MERGE) &&
       derived_table->algorithm != VIEW_ALGORITHM_MERGE))
    DBUG_RETURN(false);

  SELECT_LEX *const derived_select= derived_unit->first_select();
  /*
	当前不会对包含 STRAIGHT_JOIN,且Derived table中包含semi-join的query进行merge操作。
	这是因为MySQL为了保证正确性,必须先做semi-join之后才可以与其他表继续做JOIN。
	例如:select straight_join * from tt , (select * from tt where a in (select a from t1)) 	as ttt;
  */
  if ((active_options() & SELECT_STRAIGHT_JOIN) && derived_select->has_sj_nests)
    DBUG_RETURN(false);

	...

  // 利用Nested_join结构来辅助处理OUTER-JOIN的情况。如果Derived table是OUTER-JOIN的内表,需要将Derived table中的每个表设置为JOIN的时候可以为空。具体请参考propagate_nullability。
  if (!(derived_table->nested_join=
       (NESTED_JOIN *) thd->mem_calloc(sizeof(NESTED_JOIN))))
    DBUG_RETURN(true);        /* purecov: inspected */
  // 这里确保NESTED_JOIN结构是空的,在构造函数处理比较合适
  derived_table->nested_join->join_list.empty();
  // 该函数会将所有Derived table中的表merge到NESTED_JOIN结构体中
  if (derived_table->merge_underlying_tables(derived_select))
    DBUG_RETURN(true);       /* purecov: inspected */

  // 接下来需要将Derived table中的所有表连接到父查询的table_list列表中,进而将Derived table从父查询中剔除。
  for (TABLE_LIST **tl= &leaf_tables; *tl; tl= &(*tl)->next_leaf)
  {
    if (*tl == derived_table)
    {
      for (TABLE_LIST *leaf= derived_select->leaf_tables; leaf;
           leaf= leaf->next_leaf)
      {
        if (leaf->next_leaf == NULL)
        {
          leaf->next_leaf= (*tl)->next_leaf;
          break;
        }
      }
      *tl= derived_select->leaf_tables;
      break;
    }
  }
  // 下面会对父查询的所有相关数据结构进行重新计算,进而包含所有从Derived table merge之后的表的相关信息。
  leaf_table_count+= (derived_select->leaf_table_count - 1);
  derived_table_count+= derived_select->derived_table_count;
  materialized_derived_table_count+=
    derived_select->materialized_derived_table_count;
  has_sj_nests|= derived_select->has_sj_nests;
  partitioned_table_count+= derived_select->partitioned_table_count;
  cond_count+= derived_select->cond_count;
  between_count+= derived_select->between_count;

  // Propagate schema table indication:
  // @todo: Add to BASE options instead
  if (derived_select->active_options() & OPTION_SCHEMA_TABLE)
    add_base_options(OPTION_SCHEMA_TABLE);

  // Propagate nullability for derived tables within outer joins:
  if (derived_table->is_inner_table_of_outer_join())
    propagate_nullability(&derived_table->nested_join->join_list, true);

  select_n_having_items+= derived_select->select_n_having_items;

  // 将Derived table的where条件合并到父查询
  if (derived_table->merge_where(thd))
    DBUG_RETURN(true);        /* purecov: inspected */
  // 将Derived table的结构从父查询中删除
  derived_unit->exclude_level();

  // 这里用来禁止对Derived table的继续访问
  derived_table->set_derived_unit((SELECT_LEX_UNIT *)1);

  // 建立对Derived table需要获取的列的引用。在后续函数中会对引用列进行相关处理,请参考函数setup_natural_join_row_types函数
  if (derived_table->create_field_translation(thd))  
    DBUG_RETURN(true); 

  // 将Derived table中的列或者表的重命名合并到父查询
  merge_contexts(derived_select);
  repoint_contexts_of_join_nests(derived_select->top_join_list);

  // 因为已经把Derived table中包含的表merge到了父查询,所以需要对TABLE_LIST中的表所在的位置进行重新定位。
  remap_tables(thd);

  // 将Derived table合并到父查询之后,需要重新修改原来Derived table中所有对Derived table中所有列的引用,
  fix_tables_after_pullout(this, derived_select, derived_table, table_adjust);

  // 如果Derived table中包含ORDER By语句,处理原则和正常SubQuery的处理方式类似:
  //  1. 如果Derived table只包含一个表
  //  2. 并且Derived table不包含聚集函数
  // 满足上述两个条件之后,Derived table将会保留ORDER BY。其他情况subquery中的ORDER BY将会被忽略掉,这也是MySQL5.7区别于MySQL5.6的一点。

  //  当Derived table保留了Order by,是否能合并到父查询,需要满足如下条件:
  // 	1. 父查询允许做Derived table中的ORDER BY。下面几种情况不允许做ORDER BY
  // 		a) 如果父查询包含有自己的ORDER BY
  // 		b) 如果父查询包含GROUP BY
  //         c) 如果父查询包含未被优化掉的DISTINCT
  // 	2. 父查询不能是UNION操作,因为UNION默认会做DISTINCT操作
  //     3. 为了简化操作,只有当父查询只包含Derived table的时候(即FROM子句里面只有Derived table一个表)才可以保留ORDER BY。这里有相当大的改进空间可以尽量的来按照Derived table定义的ORDER BY操作来进行父查询的操作。比如有两个表以上,如果父查询没有ORDER BY的要求,也可以按照Derived table来对结果进行排序。
  if (derived_select->is_ordered())
  {
    if ((lex->sql_command == SQLCOM_SELECT ||
         lex->sql_command == SQLCOM_UPDATE ||
         lex->sql_command == SQLCOM_DELETE) &&
        !(master_unit()->is_union() ||
          is_grouped() ||
          is_distinct() ||
          is_ordered() ||
          get_table_list()->next_local != NULL))
      order_list.push_back(&derived_select->order_list);
  }

  // 对于Derived table中包含的full-text functions需要添加到父查询的查询列表中。
  if (derived_select->ftfunc_list->elements &&
      add_ftfunc_list(derived_select->ftfunc_list))
    DBUG_RETURN(true);        /* purecov: inspected */

  DBUG_RETURN(false);
}



综上所述,本篇文章简要的分析了MySQL Merge Derived table的作用以及实现方式。Merge Derived table的引入可以有效的提升Subquery query的执行效率,更重要的是为以后应对复杂查询提供了新的优化手段。

MySQL · 实现分析 · 对字符集和字符序支持的实现

$
0
0

前言

在使用MySQL数据库的时候,常常会发现由于charset或collation设置不正确导致的各种问题。一方面由于数据在client和server之间传输需要做转换会导致CPU使用率增加;另一方面由于charset或collation设置的不一致在查询过程中无法使用索引而导致全表扫描。比如数据库的charset是utf8,collation是utf8_general_ci,而client或connection设置的collation是utf8_unicode_ci,就会导致性能问题。所以我们在创建及使用数据库的时候一定要当心,尽可能减少由于charset或collation设置不对,而造成的不必要的麻烦。这篇文章就简单的介绍一下charset和collation在MySQL中的实现和几个关键的数据结构,以加深对MySQL中charset和collation的理解。

基础知识

字符和字符集(Character and Character set):那什么是字符呢?在计算机领域,我们把诸如文字、标点符号、图形符号、数字等统称为字符,包括各国家文字、标点符号、图形符号、数字等。而由字符组成的集合则成为字符集,是一个系统支持的所有抽象字符的集合。字符集由于包含字符的多少与异同而形成了各种不同的字符集,字符集种类较多,每个字符集包含的字符个数不同。我们知道,所有字符在计算机中都是以二进制来存储的。那么一个字符究竟由多少个二进制位来表示呢?这就涉及到字符编码的概念了。常见字符集名称:ASCII字符集、GB2312字符集、GBK字符集、GB18030字符集、Unicode字符集等。

字符编码(Character Encoding):字符编码也称字符码,是把字符集中的字符编码为指定集合中某一对象(例如:比特模式、自然数序列、8位组),以便文本在计算机中存储和通过通信网络传输。我们规定字符编码必须完成如下两件事:1)规定一个字符集中的字符由多少个字节表示;2)制定该字符集的字符编码表,即该字符集中每个字符对应的(二进制)值。

字符序(Collation):是一组在指定字符集中进行字符比较的规则,比如是否忽略大小写,是否按二进制比较字符等等。

MySQL中的字符集和字符序

MySQL服务器可以支持多种字符集,不同的库,不同的表盒不同的字段都可以使用不同的字符集。MySQL中的字符序名称遵从命名惯例:以字符序对应的字符集名称开头;以_ci(表示大小写不敏感)、_cs(表示大小写敏感)或_bin(表示按编码值比较)结尾。例如:在字符序“utf8_general_ci”下,字符“a”和“A”是等价的。MySQL可以使用SHOW CHARACTER SET; 命令查看支持哪些字符集和SHOW COLLATION则会显示出所有支持的字符序。

mysql> show character set;
+----------+-----------------------------+---------------------+--------+
| Charset  | Description                 | Default collation   | Maxlen |
+----------+-----------------------------+---------------------+--------+
| big5     | Big5 Traditional Chinese    | big5_chinese_ci     |      2 |
| dec8     | DEC West European           | dec8_swedish_ci     |      1 |
| cp850    | DOS West European           | cp850_general_ci    |      1 |
| hp8      | HP West European            | hp8_english_ci      |      1 |
| koi8r    | KOI8-R Relcom Russian       | koi8r_general_ci    |      1 |
| latin1   | cp1252 West European        | latin1_swedish_ci   |      1 |
| latin2   | ISO 8859-2 Central European | latin2_general_ci   |      1 |
| swe7     | 7bit Swedish                | swe7_swedish_ci     |      1 |
| ascii    | US ASCII                    | ascii_general_ci    |      1 |
| ujis     | EUC-JP Japanese             | ujis_japanese_ci    |      3 |
| sjis     | Shift-JIS Japanese          | sjis_japanese_ci    |      2 |
| hebrew   | ISO 8859-8 Hebrew           | hebrew_general_ci   |      1 |
| tis620   | TIS620 Thai                 | tis620_thai_ci      |      1 |
| euckr    | EUC-KR Korean               | euckr_korean_ci     |      2 |
| koi8u    | KOI8-U Ukrainian            | koi8u_general_ci    |      1 |
| gb2312   | GB2312 Simplified Chinese   | gb2312_chinese_ci   |      2 |
| greek    | ISO 8859-7 Greek            | greek_general_ci    |      1 |
| cp1250   | Windows Central European    | cp1250_general_ci   |      1 |
| gbk      | GBK Simplified Chinese      | gbk_chinese_ci      |      2 |
| latin5   | ISO 8859-9 Turkish          | latin5_turkish_ci   |      1 |
| armscii8 | ARMSCII-8 Armenian          | armscii8_general_ci |      1 |
| utf8     | UTF-8 Unicode               | utf8_general_ci     |      3 |
| ucs2     | UCS-2 Unicode               | ucs2_general_ci     |      2 |
| cp866    | DOS Russian                 | cp866_general_ci    |      1 |
| keybcs2  | DOS Kamenicky Czech-Slovak  | keybcs2_general_ci  |      1 |
| macce    | Mac Central European        | macce_general_ci    |      1 |
| macroman | Mac West European           | macroman_general_ci |      1 |
| cp852    | DOS Central European        | cp852_general_ci    |      1 |
| latin7   | ISO 8859-13 Baltic          | latin7_general_ci   |      1 |
| utf8mb4  | UTF-8 Unicode               | utf8mb4_general_ci  |      4 |
| cp1251   | Windows Cyrillic            | cp1251_general_ci   |      1 |
| utf16    | UTF-16 Unicode              | utf16_general_ci    |      4 |
| utf16le  | UTF-16LE Unicode            | utf16le_general_ci  |      4 |
| cp1256   | Windows Arabic              | cp1256_general_ci   |      1 |
| cp1257   | Windows Baltic              | cp1257_general_ci   |      1 |
| utf32    | UTF-32 Unicode              | utf32_general_ci    |      4 |
| binary   | Binary pseudo charset       | binary              |      1 |
| geostd8  | GEOSTD8 Georgian            | geostd8_general_ci  |      1 |
| cp932    | SJIS for Windows Japanese   | cp932_japanese_ci   |      2 |
| eucjpms  | UJIS for Windows Japanese   | eucjpms_japanese_ci |      3 |
+----------+-----------------------------+---------------------+--------+
40 rows in set (0.00 sec)

默认的字符集和字符序可以在实例启动时在命令行指定,也可以在启动之前在my.cnf或my.ini里配置,然后启动实例。

在[client]下添加

default-character-set=utf8

default-collation=utf8_general_ci

在[mysqld]下添加

collation-server=utf8_general_ci

character-set-server=utf8

也可以分别在创建数据库、表时指定。

CREATE TABLE `mysqlcode` (
`id` TINYINT( 255 ) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY ,
`content` VARCHAR( 255 ) NOT NULL
) ENGINE = INNODB CHARACTER SET gbk COLLATE gbk_chinese_ci;

MySQL字符集源码实现的关键结构体

在MySQL中,每个字符集可以有多个字符序与之对应,而一个字符序只能对应一个字符集。根据字符序的命名规则我们也可以很直观的看出来某个字符序与哪个字符集对应。每种字符集都要对应某个字符序,才能够进行字符之间的比较和排序等处理,所以在MySQL实现中会为每个字符集和其对应的每个字符序组成一对。若是在使用中只指定了字符集而没有指定字符序,就会使用这个字符集的默认字符序。 在内部使用CHARSET_INFO结构 来表示,在5.6版本中此结构定义如下:

typedef struct charset_info_st
{
  uint      number;
  uint      primary_number;
  uint      binary_number;
  uint      state;
  const char *csname;
  const char *name;
  const char *comment;
  const char *tailoring;
  uchar    *ctype;
  uchar    *to_lower;
  uchar    *to_upper;
  uchar    *sort_order;
  MY_UCA_INFO *uca;
  uint16      *tab_to_uni;
  MY_UNI_IDX  *tab_from_uni;
  MY_UNICASE_INFO *caseinfo;
  uchar     *state_map;
  uchar     *ident_map;
  uint      strxfrm_multiply;
  uchar     caseup_multiply;
  uchar     casedn_multiply;
  uint      mbminlen;
  uint      mbmaxlen;
  my_wc_t   min_sort_char;
  my_wc_t   max_sort_char; /* For LIKE optimization */
  uchar     pad_char;
  my_bool   escape_with_backslash_is_dangerous;
  uchar     levels_for_compare;
  uchar     levels_for_order;

  MY_CHARSET_HANDLER *cset;
  MY_COLLATION_HANDLER *coll;

} CHARSET_INFO;

name字段,定义了这个字符集和字符序对的名字。

ctype字段是一个指向长度为257的一个字符数组,每个值记录了在这个字符集相对应的字符的属性掩码。比如,这个字符是否是数字、字符、分隔符等。这些值都是经过预计算的,第一个0是无效的,这也是为什么my_isalpha(s, c)定义里面ctype要先+1的原因。通过MY_U、_MY_L、_MY_NMR 、_MY_SPC、_MY_PNT 等的定义,可以知道,这些值肯定是按照相应的ASCII码的具体意义进行置位的。比如字符’A’,其ASCII码为65,其实大写字母,故必然具有MY_U,即第0位必然为1,找到ctype里面第66个(略过第一个无意义的0)元素,为129 = 10000001,显然第0位为1(右边起),说明为大写字母。

to_lowerto_uppper:分别是指向字符集小写和大写字符数组的指针。


sort_order则记录了此字符集排序比较时每个字符对应使用的编码。

其实对于以上几个字段主要是用来处理字符集中的ASCII字符的。而对于像中文、日文、韩文这样的多字节字符是没有大小写之分的。

在CHARSET_INFO结构 结构中,还有两个重要的字段是csetcoll,它们分别为这个字符集定义了处理字符和进行排序比较等所需要函数的句柄集合。字符集句柄结构MY_CHARSET_HANDLER主要提供了处理这个字符集字符串所需要的函数,一共有二十多个,比如判断一个字符串中字符的个数、查找一个字符在字符串的位置、字符串大小写的转换以及将此字符集编码的数字字符转换成数字等。在字符集句柄中有两个函数指针mb_wc和wc_mb,这里特别提一下,它们分别是将此字符集中的字符转换成unicode字符的函数和将unicode字符转换成此字符集中对应字符的函数,每一个字符集都要实现这两个函数,这样才能保证此字符集和其它字符集之间的转换。

typedef struct my_charset_handler_st
{
  // ......
  /* Unicode conversion */
  my_charset_conv_mb_wc mb_wc;
  my_charset_conv_wc_mb wc_mb;

  // ......
}

而字符序句柄主要提供了这个字符集中字符串排序、比较等操作所需要的函数。在字符集和字符序处理句柄里包含了要处理这种字符所需要的所有函数指针,我们可以理解成是虚函数,每个字符集和字符序有自己的实现。我们要实现一个新的字符集或字符序时,就要提供这个函数的实现,这样当用到指定的字符集和字符序时就会调用到具体的实现的函数了。

MySQL字符集之间的转换

在MySQL的server和client之间、server和connection之间、已经connection和result set之间、所使用的字符集可能不一致,这就需要字符集之间的转换,才能保证字符存储和显示的正确。在MySQL中字符集之间的转换,主要是通过my_convert()->my_convert_internal()。在my_convert_internal()中的实现代逻辑如下:

my_convert_internal(char *to, uint32 to_length,
                    const CHARSET_INFO *to_cs,
                    const char *from, uint32 from_length,
                    const CHARSET_INFO *from_cs, uint *errors)
{
   // ......
  my_charset_conv_mb_wc mb_wc= from_cs->cset->mb_wc;
  my_charset_conv_wc_mb wc_mb= to_cs->cset->wc_mb;
  uint error_count= 0;

  while (1)
  {
    if ((cnvres= (*mb_wc)(from_cs, &wc, (uchar*) from, from_end)) > 0)
    // ......

outp:
    if ((cnvres= (*wc_mb)(to_cs, wc, (uchar*) to, to_end)) > 0)
    // ......

  return (uint32) (to - to_start);
}

mb_wc是一个函数指针,它是要转换的源字符集句柄的mb_wc函数指针,目的是将源字符集中的字符转换成对应的unicode字符;wb_mb函数指针是要转换成目标字符集句柄的wc_mb函数,目的是将unicode字符转换成目的字符函数。 通过这段代码可以看出在MySQL中两个字符集之间的转换不是直接进行的,而是通过unicode间接转换的。

GBK字符集的实现

我们以GBK字符集和它默认的字符gbk_chinese_ci序为例,看看它的实现是怎么样的。首先它的字符集和字符序对的结构定义如下:

CHARSET_INFO my_charset_gbk_chinese_ci=
{
    28,0,0,     /* number */
    MY_CS_COMPILED|MY_CS_PRIMARY|MY_CS_STRNXFRM,    /* state      */
    "gbk",      /* cs name    */
    "gbk_chinese_ci",   /* name */
    "",         /* comment    */
    NULL,       /* tailoring */
    ctype_gbk,
    to_lower_gbk,
    to_upper_gbk,
    sort_order_gbk,
    //   ...
    &my_charset_handler,
    &my_collation_ci_handler
};

我们可以看到上面介绍过的ctypeto_lowerto_upppersort_order数组的实现,它们分别是ctype_gbkto_lower_gbkto_upper_gbk,sort_order_gbk外,还有t非常重要的句柄cset的实现,我们可以进一步去看看gbk的字符集句柄的实现:

static MY_CHARSET_HANDLER my_charset_handler=
{
  // ......
  my_mb_wc_gbk,
  my_wc_mb_gbk,
  // ......
};

其中的my_mb_wc_gbkmy_wc_mb_gbk函数的实现,就是实现gbk字符集和其它字符集转换用到的函数。就像MySQL字符集之间的转换节所讲的,任意两个字符集之间的转换在MySQL中并不是直接进行的,而是中间通过unicode编码实现的,都要先转换成unicode,然后再转换成目标编码。my_mb_wc_gbk就是用来实现讲gbk字符转换成unicode字符的函数,相反,my_wc_mb_gbk函数则是用来讲unicode字符转换成gbk字符的函数。通过这些函数的实现就可以将gbk编码的字符转换成数据、转换大小写、查找字符在字符串中的位置等常规的字符串操作了。

通过配置实现一个新字符序的例子

从MySQL的角度来讲,字符集分成简单字符集和复杂字符集。简单字符集就是排序时不需要特殊的字符串排序函数,也不包含多字节字符;否则,就是复杂字符集。对于简单字符集,MySQL提供了简单的配置接口,通过这个接口不需要改动源代码,就可以支持新的字符集和其字符序,实例在启动时会自动把配置的简单字符集装载进来,其实现核心源代码在charset.c中,把所有通过配置添加的字符集和字符序转载进实例里,其核心也是为这些字符集和字符序对创建CHARSET_INFO,MY_CHARSET_HANDLER和MY_COLLATION_HANDLER结构体。而复杂字符集就需要改动源代码,通过实现以上所介绍的主要三个接口结构(CHARSET_INFO,MY_CHARSET_HANDLER和MY_COLLATION_HANDLER)。

我们经常看到电话号码,但写法格式不统一。比如电话号码18612345678,可以有如下等多种写法: +86-18612345678,(86)18612345678,86-186-1234-5678, +8618612345678,其实都是表示一个电话号码。若电话号码用上述各种格式存储在数据库中,查找某个电话号码时会变得比较困难。为了解决这个问题,我们可以定义一个电话号码的字符序,使得这个字符序会忽略其中的+、-、()及空格等字符。这样就比较容易找的一个特定的电话号码了。下面的例子是为utf8字符集添加一个电话号码比较的字符序。

具体方法如下:

1)先查找一个空闲的字符序ID。通过查找INFORMATION_SCHEMA.COLLATIONS表中的ID,可以发现那些ID已经被使用了,找一个空闲未使用的即可。这个我们可以选择1029.

2)修改Index.xml文件。将要定义的字符序加入到指定的字符集中。character_sets_dir指定了Index.xml所在的目录。

mysql> SHOW VARIABLES LIKE 'character_sets_dir';
+--------------------+----------------------------------------------------------+
| Variable_name      | Value                                                    |
+--------------------+----------------------------------------------------------+
| character_sets_dir | /home/guangbao.ngb/mysql_polar/u01/mysql/share/charsets/ |
+--------------------+----------------------------------------------------------+
1 row in set (0.01 sec)

3)为新定义的字符序定义一个名字,然后把这个字符序加入到Index.xml的utf8字符集下面的一个新的字符序段落中。比如: utf8_phone_ci

<charset name="utf8">
  ...
  <collation name="utf8_phone_ci" id="1029">
    <rules>
      <reset>\u0000</reset>
      <i>\u0020</i> <!-- space -->
      <i>\u0028</i> <!-- left parenthesis -->
      <i>\u0029</i> <!-- right parenthesis -->
      <i>\u002B</i> <!-- plus -->
      <i>\u002D</i> <!-- hyphen -->
    </rules>
  </collation>
  ...
</charset>

4)重启实例,然后你就可以看到新加入的字符序了。

mysql> SHOW COLLATION WHERE Collation = 'utf8_phone_ci';
+---------------+---------+------+---------+----------+---------+
| Collation     | Charset | Id   | Default | Compiled | Sortlen |
+---------------+---------+------+---------+----------+---------+
| utf8_phone_ci | utf8    | 1029 |         |          |       8 |
+---------------+---------+------+---------+----------+---------+
1 row in set (0.02 sec)

这个字符序就可以使用了,比如:

mysql> CREATE TABLE phonebook (
         name VARCHAR(64),
         phone VARCHAR(64) CHARACTER SET utf8 COLLATE utf8_phone_ci
       );
Query OK, 0 rows affected (0.09 sec)
mysql> INSERT INTO phonebook VALUES ('ngbao','+86-18612345678');

查询字符串你可以写成任何一种,都能够查到这条记录。比如+8618612345678 、8618612345678或86-18612345678。

MySQL · 源码分析 · MySQL BINLOG半同步复制数据安全性分析

$
0
0

半同步复制(semisynchronous replication)MySQL使用广泛的数据复制方案,相比于MySQL内置的异步复制它保证了数据的安 全,本文从主机在Server层提交事务开始一直到主机确认收到备机回复进行一步步解析,来看MySQL的半同步复制是怎么保证数 据安全的。本文基于MySQL 5.6源码,为了简化本文只分析DML的核心的事务处理过程,并假定事务只涉及innodb存储引擎。

MySQL的事务提交流程

在MySQL中事务的提交Server层最后会调用函数ha_commit_trans(),该函数负责处理binlog层和存储引擎层的提交,它先调用 tc_log->prepare()在引擎层生成一个XA事务,然后再调用tc_log->commit()来提交事务,这里的tc_log是在mysqld启动时就生 成的一个MYSQL_BIN_LOG类的对象。简化后代码片断类似:

int ha_commit_trans(THD *thd, bool all, bool ignore_global_read_lock)
{
  //...
  error= tc_log->prepare(thd, all);

  if (error || (error= tc_log->commit(thd, all)))
  {
    ha_rollback_trans(thd, all);
    error= 1;
    goto end;
  }
  //...
}

MYSQL_BIN_LOG::prepare()函数调用ha_prepare_low(),该函数再调用存储引擎层(这里指innodb)的prepare在存储层生成XA 事务。MYSQL_BIN_LOG::commit()先在binlog层加入一个Xid_log_event类型的日志作为XA事务在binlog层提交的标志,注意这 里并没有调用操作系统的fsync。该函数最后调用会调用MYSQL_BIN_LOG::ordered_commit(),做binlog文件的磁盘fsync和提交 到存储引擎。

MYSQL_BIN_LOG::ordered_commit()是比较重要的函数,该函数的处理步骤如下:

  1. 将binlog数据刷写到文件中
  2. 将当前的binlog文件名和位点注册到semisync模块中,以便后面等待备机的回复
  3. 调用函数MYSQL_BIN_LOG::sync_binlog_file()将binlog文件sync到磁盘,到这里事务将不能回滚,即使mysqld崩溃了事务 也会最终提交。
  4. 调用MYSQL_BIN_LOG::update_binlog_end_pos()更新binlog最后sync的位点信息,这时为备库复制服务的binlog dump线程 才可以读到这个事务,可参考Log_event::read_log_event()
  5. 如果semisync模块配置了rpl_semi_sync_master_wait_point为 after_sync,那么当前Session将在这里等待备机回复再继 续。
  6. ordered_commit()接下来会最终调用到 ha_commit_low()在存储引擎层提交
  7. 如果rpl_semi_sync_master_wait_point参数为after_commit,当前Session就会在ordered_commit()接下来调用的 MYSQL_BIN_LOG::finish_commit()函数里等待备机的回复,

以上可以看出after_sync和after_commit的主要区别是,当备机确认收到日志时,主机上的该事务是否对其他session可见, after_sync是不可见(因为在存储引擎层还没有提交),after_commit是可见。after_commit可能导致在主机上被其他事务看 见了的事务在切换到备机后又消失了,所以MySQL 5.7默认使用after_sync。

MySQL的事务恢复流程

mysqld崩溃之后的事务恢复最终是通过MYSQL_BIN_LOG::recover()进行的,调用栈: mysqld_main() -> init_server_components() -> MYSQL_BIN_LOG::open() -> MYSQL_BIN_LOG::open_binlog() -> MYSQL_BIN_LOG::recover()。open_binlog()函数通过binlog文件头上的标志可以知道该文件在mysqld退出时没有正常关闭,然 后就调用recover()函数进行恢复。

MYSQL_BIN_LOG::recover()首先扫描binlog日志扫出在binlog里已经提交的事务加到一个commitlist里,然后调用 ha_recover()函数,该函数先调用innodb层的相关函数扫描出在innodb层已经prepare的事务,然后将在commitlist里的事务全 部提交。

从以上MySQL事务提交和恢复流程可以看出,在最终备机提交事务,必然在主机上是提交的,也就是主机的事务必然比备机更全。

主机和备机同步的处理流程

前文已经提到在MYSQL_BIN_LOG::ordered_commit()函数中,用户session会将要等待备机回复的事务对应的binlog文件名和位 点注册到semisync模块中,然后在向备机发送binlog的主函数里mysql_binlog_send()中,将这些事务对应的binlog event数据 包加上要求备机回复的标志,见函数ReplSemiSyncMaster::updateSyncHeader()。主机在mysqld启动时就启动了一个 ack_receiver线程,每次有新的备机连接上来,就把对应的服务线程注册到ack_receiver中,见函数 ReplSemiSyncMaster::dump_start(),ack_receiver负责接收所有备机的回复。备机在handle_slave_io()函数中读到一个 event的数据包就会检查是否有要求回复的标志,如果有则在将binlog刷到本地磁盘后向主机发送回复报文,回复的报文的内容 包含收到的binlog文件名和位点。流程大致如下:

while (!io_slave_killed(thd,mi))
{
  // ...
  event_len= read_event(mysql, mi, &suppress_warnings);
  mi->repl_semisync_slave.slaveReadSyncHeader((const char*)mysql->net.read_pos + 1,
					      event_len, &(mi->semi_ack), &event_buf,
					      &event_len);
  // ...
  if (queue_event(mi, event_buf, event_len))
    {
      mi->report(ERROR_LEVEL, ER_SLAVE_RELAY_LOG_WRITE_FAILURE,
		 ER(ER_SLAVE_RELAY_LOG_WRITE_FAILURE),
		 "could not queue event from master");
      goto err;
    }
  // ...
  if((mi->semi_ack & SEMI_SYNC_NEED_ACK) &&
     mi->repl_semisync_slave.slaveReply(mi))
    {
      mi->report(ERROR_LEVEL, ER_SLAVE_FATAL_ERROR,
		 ER(ER_SLAVE_FATAL_ERROR),
		 "Failed to call 'slaveReply'");
      goto err;
    }
  // ...
 }

ack_receiver线程的主线程函数是Ack_receiver::run(),该函数调用poll()监听在所有已注册的slave服务线程的socket上, 接听slave的回复报文,当接收到一个回复报文后,ack_receiver会记下当前的回复报文中的binlog文件名和位点,并在自己的 注册列表中删除在这个位点之前的事务,然后通过cond_broadcast()唤醒等待备机回复的用户session线程,这些线程通过比较 自己的等待位点和ack_receiver记下的回复报文位点决定是否结束等待。

总结

通过以上分析可以看出在同步复制的模式上,MySQL通过非常严格的流程保证了用户Session执行完事务返回给客户端后,该事 务也必然已同步到了备机的磁盘上。同时保证了出现在备机的事务必然在主机上已经是安全提交了的,也就是在任何时刻主机 上的事务一定是大于等于备机的。

HybridDB · 性能优化 · Count Distinct的几种实现方式

$
0
0

前言

最近遇到几个客户在HybridDB上做性能测试时,都遇到Count Distinct的性能调优问题。这里我们总结一下HybridDB中,对Count Distinct的几种处理方式。

我们以一个客户的案例来做说明。客户的典型的业务场景是,在用户行为日志中统计对应类别的行为数,类别有几千个,独立的行为的总量很多,有几千万;为分析行为,要查询一段时间内的基于类别的独立行为数,查询如下(test的建表语句见附录):

select category, count(distinct actionId) as ct from test_user_log
where receivetime between '2017-03-07 11:00:00' and '2017-03-07 12:00:00' group by category
order by ct desc limit 10;

下面我们针对这个查询,来看一下Count Distinct是怎么处理的。

Count Distinct的基本处理方式

利用explain analyze命令,看一下这个查询执行过程的信息:

test=# explain analyze select category, count(distinct actionId) as ct from test_user_log
where receivetime between '2017-03-07 11:00:00' and '2017-03-07 12:00:00' group by category
order by ct desc limit 10;
                                                                                            QUERY PLAN

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-------------------
 Limit  (cost=0.00..431.00 rows=10 width=16)
 Gather Motion 16:1  (slice2; segments: 16)  (cost=5968.98..5968.99 rows=1 width=40)
   Merge Key: ct
   Rows out:  745 rows at destination with 2469 ms to end, start offset by 77 ms.
   ->  Sort  (cost=5968.98..5968.99 rows=1 width=40)
         Sort Key: ct
         Rows out:  Avg 46.6 rows x 16 workers.  Max 55 rows (seg0) with 2461 ms to end, start offset by 85 ms.
         Executor memory:  58K bytes avg, 58K bytes max (seg0).
         Work_mem used:  58K bytes avg, 58K bytes max (seg0). Workfile: (0 spilling, 0 reused)
         ->  GroupAggregate  (cost=5968.94..5968.97 rows=1 width=40)
               Group By: public.test_user_log.category
               Rows out:  Avg 46.6 rows x 16 workers.  Max 55 rows (seg0) with 2460 ms to end, start offset by 85 ms.
               ->  Sort  (cost=5968.94..5968.94 rows=1 width=40)
                     Sort Key: public.test_user_log.category
                     Rows out:  Avg 461.6 rows x 16 workers.  Max 572 rows (seg4) with 2458 ms to end, start offset by 88 ms.
                     Executor memory:  85K bytes avg, 145K bytes max (seg4).
                     Work_mem used:  85K bytes avg, 145K bytes max (seg4). Workfile: (0 spilling, 0 reused)
                     ->  Redistribute Motion 16:16  (slice1; segments: 16)  (cost=5960.60..5968.93 rows=1 width=40)
                           Hash Key: public.test_user_log.category
                           Rows out:  Avg 461.6 rows x 16 workers at destination.  Max 572 rows (seg4) with 2316 ms to first row, 2458 ms to end, start offset by 88 ms.
                           ->  GroupAggregate  (cost=5960.60..5968.91 rows=1 width=40)
                                 Group By: public.test_user_log.category
                                 Rows out:  Avg 461.6 rows x 16 workers.  Max 472 rows (seg7) with 536 ms to first row, 2455 ms to end, start offset by 89 ms.
                                 Executor memory:  318587K bytes avg, 330108K bytes max (seg7).
                                 Work_mem used:  8544K bytes avg, 8544K bytes max (seg0).
                                 Work_mem wanted: 8414K bytes avg, 8472K bytes max (seg14) to lessen workfile I/O affecting 16 workers.
                                 ->  Sort  (cost=5960.60..5963.37 rows=70 width=64)
                                       Sort Key: public.test_user_log.category
                                       Rows out:  Avg 367982.3 rows x 16 workers.  Max 369230 rows (seg8) with 527 ms to first row, 625 ms to end, start offset by 90 ms.
                                       Executor memory:  61433K bytes avg, 61433K bytes max (seg0).
                                       Work_mem used:  61433K bytes avg, 61433K bytes max (seg0). Workfile: (0 spilling, 0 reused)
                                       ->  Append  (cost=0.00..5904.72 rows=70 width=64)
                                             Rows out:  Avg 367982.3 rows x 16 workers.  Max 369230 rows (seg8) with 2.710 ms to first row, 265 ms to end, start offset by 91 ms
.
                                             ->  Append-only Columnar Scan on test_user_log_1_prt_usual test_user_log  (cost=0.00..0.00 rows=1 width=64)
                                                   Filter: receivetime >= '2017-03-07 11:00:00'::timestamp without time zone AND receivetime <= '2017-03-07 12:00:00'::timestamp
 without time zone
                                                   Rows out:  0 rows (seg0) with 0.542 ms to end, start offset by 93 ms.
                                             ->  Append-only Columnar Scan on test_user_log_1_prt_157 test_user_log  (cost=0.00..3134.45 rows=37 width=64)
                                                   Filter: receivetime >= '2017-03-07 11:00:00'::timestamp without time zone AND receivetime <= '2017-03-07 12:00:00'::timestamp
 without time zone
                                                   Rows out:  Avg 367882.1 rows x 16 workers.  Max 369131 rows (seg8) with 2.178 ms to first row, 132 ms to end, start offset by
 91 ms.
                                             ->  Append-only Columnar Scan on test_user_log_1_prt_158 test_user_log  (cost=0.00..2770.27 rows=33 width=64)
                                                   Filter: receivetime >= '2017-03-07 11:00:00'::timestamp without time zone AND receivetime <= '2017-03-07 12:00:00'::timestamp
 without time zone
                                                   Rows out:  Avg 100.2 rows x 16 workers.  Max 124 rows (seg11) with 2.135 ms to first row, 73 ms to end, start offset by 394 m
s.
 Slice statistics:
   Settings:  effective_cache_size=8GB; gp_statistics_use_fkeys=on; optimizer=off
 Optimizer status: legacy query optimizer
 Total runtime: 2546.862 ms
(51 rows)


可以发现,看似很简单的查询,处理流程却有点复杂。整个处理流程大致如下:

Scan (Columnar Scan + Append) -> Sort(category) -> Group by(category) -> Redistribute -> Sort(category) -> Group by(category) -> Sort -> Gather

从各个节点的实际执行时间的记录可以看出,主要的时间花在了前三步,因为这三步完成后,中间结果只有几百行了。我们重点关注这三个步骤。其实这几个步骤的逻辑比较好理解,扫描出来的数据,直接做排序,排序后,再把结果扫描一遍,同时进行聚合运算。

这里需要注意的一个细节是,查询的表test_user_log是按actionId做分布键的,相同的actionId都会分布在同一个节点上,所以每个节点本地按category做分组后,会在每个分组记录分组中出现的不同actionId值,最终的聚合的结果是category加上一个对应的actionId的计数。

这里有个疑问,其实category的唯一值很少(只有几百个),很适合利用Hash的方式做聚合呢,那么为什么没有选择Hash的方式而是采用了Sort的方式呢?

观察上述查询计划中test_user_log_1_prt_157这个表分区的中间结果估计((cost=0.00..3134.45 rows=37 width=64)),可以发现预估的结果只有37行,而实际是Rows out: Avg 367882.1 rows,即36万多,这说明表的统计信息不准确。

执行一下Analyze来更新统计信息:

test=# analyze test_user_log_1_prt_157;
analyze test_user_log_1_prt_158;ANALYZE
test=# analyze test_user_log_1_prt_158;
ANALYZE

更新统计信息后,再执行一下查询,查询计划果然发生了变化:排序聚合变成了Hash方式聚合!执行时间也由2546.862ms缩短到2099.144ms。

                                                                                               QUERY PLAN

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-------------------------
 Limit  (cost=0.00..431.00 rows=10 width=16)
   Gather Motion 16:1  (slice2; segments: 16)  (cost=320695.70..320695.92 rows=10 width=40)
         Merge Key: ct
         ->  Limit  (cost=320695.70..320695.72 rows=1 width=40)
               ->  Sort  (cost=320695.70..320695.94 rows=7 width=40)
                     Sort Key (Limit): ct
                     ->  HashAggregate  (cost=320691.23..320692.46 rows=7 width=40)
                           Group By: partial_aggregation.category
                           ->  HashAggregate  (cost=303756.92..311454.34 rows=38488 width=64)
                                 Group By: public.test_user_log.category, public.test_user_log.actionId
                                 ->  Redistribute Motion 16:16  (slice1; segments: 16)  (cost=280664.69..292980.55 rows=38488 width=64)
                                       Hash Key: public.test_user_log.category
                                       ->  HashAggregate  (cost=280664.69..280664.69 rows=38488 width=64)
                                             Group By: public.test_user_log.category, public.test_user_log.actionId
                                             ->  Append  (cost=0.00..236527.23 rows=367813 width=43)
                                                   ->  Append-only Columnar Scan on test_user_log_1_prt_usual test_user_log  (cost=0.00..0.00 rows=1 width=64)
                                                         Filter: receivetime >= '2017-03-07 11:00:00'::timestamp without time zone AND receivetime <= '2017-03-07 12:00:00'::tim
estamp without time zone
                                                   ->  Append-only Columnar Scan on test_user_log_1_prt_157 test_user_log  (cost=0.00..124267.69 rows=367813 width=42)
                                                         Filter: receivetime >= '2017-03-07 11:00:00'::timestamp without time zone AND receivetime <= '2017-03-07 12:00:00'::tim
estamp without time zone
                                                   ->  Append-only Columnar Scan on test_user_log_1_prt_158 test_user_log  (cost=0.00..112259.54 rows=1 width=42)
                                                         Filter: receivetime >= '2017-03-07 11:00:00'::timestamp without time zone AND receivetime <= '2017-03-07 12:00:00'::tim
estamp without time zone

上面的计划主要流程如下。两次Group by(Hash(category,actionId))都是为了去除重复值,保证(category,actionId)组合字段值唯一。

Scan (Columnar Scan + Append) -> Group by(Hash(category,actionId)) -> Redistribute(category) -> Group by(Hash(category, acitonId)) -> Group by(Hash(category)) -> Sort -> Gather

那么还有其他可能的处理方式吗?我们知道,HybridDB支持新型的orca优化器,orca考虑更多的查询执行方式。我们下面试试使用orca来生成查询计划。

test=# set optimizer=on;
SET
test=# explain analyze select category, count(distinct actionId) as ct from test_user_log
where receivetime between '2017-03-07 11:00:00' and '2017-03-07 12:00:00' group by category
order by ct desc limit 10;
                                                                                                  QUERY PLAN

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-------------------------------
 Limit  (cost=0.00..431.00 rows=1 width=16)
   Rows out:  10 rows with 2690 ms to end, start offset by 0.500 ms.
   ->  Gather Motion 16:1  (slice2; segments: 16)  (cost=0.00..431.00 rows=1 width=16)
         Merge Key: ct
         Rows out:  10 rows at destination with 2690 ms to end, start offset by 0.501 ms.
         ->  Sort  (cost=0.00..431.00 rows=1 width=16)
               Sort Key: ct
               Rows out:  Avg 46.6 rows x 16 workers.  Max 55 rows (seg0) with 2688 ms to end, start offset by 2.356 ms.
               Executor memory:  33K bytes avg, 33K bytes max (seg0).
               Work_mem used:  33K bytes avg, 33K bytes max (seg0). Workfile: (0 spilling, 0 reused)
               ->  GroupAggregate  (cost=0.00..431.00 rows=1 width=16)
                     Group By: category
                     Rows out:  Avg 46.6 rows x 16 workers.  Max 55 rows (seg0) with 2687 ms to first row, 2688 ms to end, start offset by 2.372 ms.
                     ->  Sort  (cost=0.00..431.00 rows=1 width=16)
                           Sort Key: category
                           Rows out:  Avg 461.6 rows x 16 workers.  Max 572 rows (seg4) with 2687 ms to end, start offset by 3.104 ms.
                           Executor memory:  85K bytes avg, 145K bytes max (seg4).
                           Work_mem used:  85K bytes avg, 145K bytes max (seg4). Workfile: (0 spilling, 0 reused)
                           ->  Redistribute Motion 16:16  (slice1; segments: 16)  (cost=0.00..431.00 rows=1 width=16)
                                 Hash Key: category
                                 Rows out:  Avg 461.6 rows x 16 workers at destination.  Max 572 rows (seg4) with 2442 ms to first row, 2687 ms to end, start offset by 3.113 ms
.
                                 ->  Result  (cost=0.00..431.00 rows=1 width=16)
                                       Rows out:  Avg 461.6 rows x 16 workers.  Max 472 rows (seg7) with 1070 ms to first row, 2583 ms to end, start offset by 3.898 ms.
                                       ->  GroupAggregate  (cost=0.00..431.00 rows=1 width=16)
                                             Group By: category
                                             Rows out:  Avg 461.6 rows x 16 workers.  Max 472 rows (seg7) with 1070 ms to first row, 2583 ms to end, start offset by 3.898 ms.
                                             Executor memory:  316808K bytes avg, 328245K bytes max (seg7).
                                             Work_mem used:  8184K bytes avg, 8184K bytes max (seg0).
                                             Work_mem wanted: 8414K bytes avg, 8472K bytes max (seg14) to lessen workfile I/O affecting 16 workers.
                                             ->  Sort  (cost=0.00..431.00 rows=1 width=16)
                                                   Sort Key: category, actionId
                                                   Rows out:  Avg 367982.3 rows x 16 workers.  Max 369230 rows (seg8) with 1064 ms to first row, 1143 ms to end, start offset by
 3.812 ms.
                                                   Executor memory:  143353K bytes avg, 143353K bytes max (seg0).
                                                   Work_mem used:  143353K bytes avg, 143353K bytes max (seg0). Workfile: (0 spilling, 0 reused)
                                                   ->  Sequence  (cost=0.00..431.00 rows=1 width=24)
                                                         Rows out:  Avg 367982.3 rows x 16 workers.  Max 369230 rows (seg8) with 1.905 ms to first row, 241 ms to end, start off
set by 4.032 ms.
                                                         ->  Partition Selector for test_user_log (dynamic scan id: 1)  (cost=10.00..100.00 rows=7 width=4)
                                                               Filter: receivetime >= '2017-03-07 11:00:00'::timestamp without time zone AND receivetime <= '2017-03-07 12:00:00
'::timestamp without time zone
                                                               Partitions selected:  3 (out of 745)
                                                               Rows out:  0 rows (seg0) with 0.013 ms to end, start offset by 4.033 ms.
                                                         ->  Dynamic Table Scan on test_user_log (dynamic scan id: 1)  (cost=0.00..431.00 rows=1 width=24)
                                                               Filter: receivetime >= '2017-03-07 11:00:00'::timestamp without time zone AND receivetime <= '2017-03-07 12:00:00
'::timestamp without time zone
                                                               Rows out:  Avg 367982.3 rows x 16 workers.  Max 369230 rows (seg8) with 1.891 ms to first row, 202 ms to end, sta
rt offset by 4.046 ms.
                                                               Partitions scanned:  Avg 3.0 (out of 745) x 16 workers.  Max 3 parts (seg0).
 Slice statistics:
   (slice0)    Executor memory: 351K bytes.
   (slice1)  * Executor memory: 152057K bytes avg x 16 workers, 152057K bytes max (seg0).  Work_mem: 143353K bytes max, 8472K bytes wanted.
   (slice2)    Executor memory: 363K bytes avg x 16 workers, 423K bytes max (seg4).  Work_mem: 145K bytes max.
 Statement statistics:
   Memory used: 2047000K bytes
   Memory wanted: 34684K bytes
 Settings:  effective_cache_size=8GB; gp_statistics_use_fkeys=on; optimizer=on
 Optimizer status: PQO version 1.609
 Total runtime: 2690.675 ms
(54 rows)

 

使用orca生成的查询计划,又回到了使用Sort+Groupby的方式来做聚合(这是因为,我们使用Analyze只更新了子分区表的统计信息,而orca只会考虑主表上的统计信息,要想是orca的计划转为使用Hash方式,需要在主表上使用Analyze,这里我们不继续讨论)。而上述使用orca生成的计划,与使用缺省优化器有很大不同。orca的查询计划采用了下面的流程:

Scan (Dynamic Scan) -> Sort (category, actionId) -> Group by (category) -> Redistribute -> Sort (category) -> Group by(category) -> Sort -> Gather

注意,第一次Sort用了(category, actionId)两个字段的组合,但后面的Group by时只适应了category一个字段!这是一种特殊的聚合方式。在做这种聚合时,对应一个不同的category,只需保留一个actionId的计数即可,而不是像在缺省优化器计划中那样,对每个不同的category,需要保留所有不同的actionId值,这样省去了建立类似Hash表的数据结构的时间。但由于Sort的时候用了两个字段,时间消耗比使用一个字段高,导致整个查询计划的性能不如缺省优化器产生的计划。

延伸

上面的讨论所举的例子中的表,正好是以Count Distinct的字段(即actionId)作为分布键的。如果以其他字段作为分布键,会产生不一样的查询计划,但基本原理都是类似的。

另外,我们没有涉及一个查询中涉及多个字段上有Count Distinct的情况,读者可以自行尝试。

附录

  • 建表语句
create  table test_user_log
(
        actionId text,
        code text,
        receiveTime timestamp,
        gmtCreate timestamp,
        category text,
        version text,
        tag text,
        siteId int4
) 
with  (APPENDONLY=true, ORIENTATION=column, BLOCKSIZE=524288)
distributed by (actionId)
PARTITION BY RANGE (receivetime) 
(START ('2017-03-07') INCLUSIVE END ('2017-03-07') EXCLUSIVE EVERY (INTERVAL '1 hour' ), DEFAULT PARTITION usual);


PgSQL · 应用案例 · PostgreSQL OLAP加速技术之向量计算

$
0
0

背景

在主流的OLTP数据库产品中,毫无疑问,PostgreSQL已经具备非常强大的竞争力(性能、功能、稳定性、成熟度、案例、跨行业应用等)。

通过这些文章我们可以了解更细致的情况。

《数据库十八摸 - 致 架构师、开发者》

《数据库界的华山论剑 tpc.org》

《PostgreSQL 前世今生》

在OLAP领域,PostgreSQL社区也是豪情万丈的,比如内核已经实现了基于CPU的多核并行计算、算子复用等。

在社区外围的插件如 GPU运算加速、LLVM、列存储、多机并行执行插件 等也层出不穷。

虽然如此,PostgreSQL在OLAP领域还有非常巨大的提升潜力。

pic

OLAP profiling 分析 (OLAP哪些过程最耗资源)

OLAP单个查询就会涉及大量数据的处理,与OLTP有非常鲜明的差别,那么数据库在OLAP场景会有哪些明显的瓶颈呢?

1. unpack row(tuple) 带来的开销

在PostgreSQL中,数据以行存储,变长类型可能存储在TOAST中,由于它是变长的,当访问第N列的数据时,也需要unpack前N-1列的内容数据(不过这块有优化空间,比如在行头记录每列的OFFSET,但是这样又引入另一个问题,增加了OFFSET必然会增加空间开销)。

另一种优化方法是业务层面的,比如将定长类型和变长类型拆分成两张或多张表,或者将不怎么访问的大字段拆开到其他表,通过JOIN关联它们。

不要小看这笔开销,这笔开销是O(N)的,所以数据量越大,开销越大,比如TPCH的Q6,40%是ROW格式化带来的开销。

2. 解释执行引入的开销

PostgreSQL的优化器通过构建树的方式来表述执行计划,所以执行器必须以递归的方式从树的最边缘节点一直往上执行。

解释执行的好处是弹性,容易改写,比如PG的customize scan ,GPU运算就用到了它。

通常解释执行比native code慢10倍,特别是在表达式非常多时。

你可以通过这种方式观察

postgres=# set debug_print_plan=true;  
  
postgres=# set client_min_messages ='log'; 

3. 抽象层开销,PostgreSQL 的一个非常强悍的特性是允许用户自定义数据类型、操作符、UDF、索引方法等。为了支持这一特性,PostgreSQL将操作符与操作数剥离开来,通过调用FUNCTION的形式支持操作数的操作,譬如两个INT的加减运算,是通过调用FUNCTION来支持的。

a+b  

可能就变成了

op1 func(a,b) {  
  c=a+b  
  return c   
{  
  
a op1 b  

通过这种方式,支持了允许用户自定义数据类型、操作符。

通过函数调用的方式支持操作符,还引入了另一个问题,参数传递的memory copy操作。

所以,函数调用引入的overhead相比直接使用操作符(INT的加减乘除等)要大很多。

对于OLAP,不容小视。

4. PULL模型引入的overhead,PostgreSQL的executor是经典的Volcano-style query执行模型 - pull模型。操作数的值是操作符通过pull的模式来获取的。这样简化了执行器和操作符的设计和工程实现。

但是对性能有一定的影响,比如从一个node跳到另一个node时(比如seqscan或index scan节点,每获取一条tuple,跳到操作符函数),都需要保存或还原它们的上下文,对于OLAP,需要大批量的记录处理,这个开销放大就很厉害。

pull , push , pull on demand等模型,参考

https://www.infoq.com/news/2015/09/Push-Pull-Search

5. 多版本并发控制,这个基本上任何OLTP RDBMS都支持的特性,多版本主要用来解决高并发数据处理的问题。也成为了OLAP场景的一个包袱。

因为多版本并发控制,需要在每个TUPLE头部增加一些信息,比如infomask等(大概有20几个字节),通过infomask判断行的可见性。除了空间开销,同时判断行的可见性也引入了CPU的开销。

业界有哪些手段提升OLAP性能

1. 使用JIT(just in time)编译器,生成query的native code,消除tuple翻译瓶颈,即tuple deform的瓶颈,从而提高大批量数据处理的效率。

PostgreSQL-LLVM版本,就是使用的这种手段

《分析加速引擎黑科技 - LLVM、列存、多核并行、算子复用 大联姻 - 一起来开启PostgreSQL的百宝箱》

2. 重写优化器,将pull模型改写为push模型。

3. 面向OLAP,优化tuple的存储结构,提升deform tuple的效率。

4. 将query execution plan转换为二进制执行代码,消除执行树递归调用的方式引入的效率问题。

以上方法均需要重写PostgreSQL代码,甚至大改PostgreSQL架构。

那么有没有更友好的方法呢,不修改PostgreSQL内核,不动它的架构呢:

向量化计算。

向量化执行 与 列存储

传统的QUERY执行器在处理表达式时,是一行一行的处理模式。

比如在处理(x+y)这个表达式时,首先读取一条记录的X值,然后读取这条记录的Y值,然后执行+操作符。然后将结果返回给上层node。

然而,向量化执行器,一个操作符可以处理多个值,比如(x+y) ,x, y并不是scalar值,而是一批值的向量,向量化执行的结果也不是scalar值,而是向量。

向量化执行模型的会引入一定的解释和函数调用overhead,但是对于OLAP场景的大批量数据处理,这个overhead可以忽略。

既然向量化执行倾向于每次处理大批量的数据,那么在数据存放方面,也需要注意,比如OLAP经常需要对单列进行处理,使用列存储可以更好的和向量化执行模型结合起来。

OLAP场景,列存储有以下几个好处

  • 减少每次计算时,需要读取或载入的数据大小(原来是以行来读取和载入),现在只需要载入计算需要用到的列。

  • 压缩比更高,因为列的属性(类型)是固定的,数据按列存储时,在对应数据文件中所有值的类型是一样的,压缩比相比行存储高很多。

  • deform时,开销大大降低,不需要像行存储那样解释目标列以前的所有列(而且前面的列类型可能也不一致,引入更多的对应column 类型的deform的函数调用)。

  • 可以使用CPU向量化指令(SIMD),处理批量数据

已经有一些数据库使用了列存储引擎,并且获得了很好的OLAP效率。比如Vertical, MonetDB。

既然向量化执行函数每次处理的是一类值的集合(向量),那么这个集合(向量)大小多大合适呢?

比如一张表有几亿条记录,我们需要计算sum((x+y)(x-y)),如果这几亿条记录作为一个(集合)向量,开始执行,会有什么后果呢?

因为CPU的CACHE大小是有限的,装不下这么大的数据,所以在算完一批数据的(x+y)后,再要算(x-y)时,前面的数据集合已经从CPU CACHE出去了,所以又要将数据LOAD一遍到CPU CACHE。

Table can be very large (OLAP queries are used to work with large data sets), so vector can also be very big and even doesn't fit in memory.  
  
But even if it fits in memory, working with such larger vectors prevent efficient utilization of CPU caches (L1, L2,...).  
  
Consider expression (x+y)(x-y).   
  
Vector executor performs addition of two vectors : "x" and "y" and produces result vector "r1".  
  
But when last element of vector "r" is produced, first elements of vector "r1" are already thrown from CPU cache, as well as first elements of "x" and "y" vectors.  
  
So when we need to calculate (x-y) we once again have to load data for "x" and "y" from slow memory to fast cache.  
  
Then we produce "r2" and perform multiplication of "r1" and "r2".   
  
But here we also need first to load data for this vectors into the CPU cache.  

将数据拆分成小的集合(chunk),分批运算是最好的,数据只需要进出CPU CACHE一遍。

也就是说,数据进入CPU CACHE后,算完所有的表达式,保存中间结果向量,然后再将下一批数据LOAD进CPU CACHE,继续运算。

CHUNK最好和CPU CACHE的大小匹配。

MonetDB x100上已经验证这种方法,性能提升了10倍。

https://www.monetdb.org/Home

So it is more efficient to split column into relatively small chunks (or tiles - there is no single notion for it accepted by everyone).  
  
This chunk is a unit of processing by vectorized executor.  
  
Size of such chunk is chosen to keep all operands of vector operations in cache even for complex expressions.  
  
Typical size of chunk is from 100 to 1000 elements.  
  
So in case of (x+y)(x-y) expression, we calculate it not for the whole column but only for 100 values (assume that size of the chunk is 100).  
  
Splitting columns into chunks in successors of MonetDB x100 and HyPer allows to increase speed up to ten times.  

PostgreSQL 向量化之路

前面讲了,向量化执行是打开OLAP性能之门的金钥匙之一,而要让向量化发挥效果,首先要使用列存储。

PostgreSQL 有两个列存储引擎,分别是cstore_fdw和imcs。

首先说说cstore_fdw,它是基于PostgreSQL的FDW接口实现的列存储插件,可以有效的减少tuple deform的开销,但是它使用标准的PostgreSQL raw-based 执行器,所以无法使用向量化处理。

https://github.com/citusdata/cstore_fdw

在CitusData公司内部,有一个基于cstore_fdw列存储引擎的项目,该项目通过PostgreSQL执行器的钩子接口,开发了一套基于cstore列存储的向量化执行器。数据聚合有4到6倍的性能提升,分组聚合有3倍的性能提升。

https://github.com/citusdata/postgres_vectorization_test

另一个项目是imcs, in-memory columnar store, 基于内存打造的一个列存引擎,操作时需要使用imcs项目提供的函数接口,不能使用标准的SQL,IMCS提供了向量计算(通过数据瓦片(tile)实现),以及并行执行的支持。

https://github.com/knizhnik/imcs

cstore和imcs插件虽然挺好的,但是数据毕竟不是使用的PostgreSQL内部存储,一个是FDW一个是内存。

如果我们想让数据存在PostgreSQL中,同时还要它支持向量计算,必须要改造PostgreSQL现有的行存储。

那么到底有没有更好的方法呢?其实是有的,新增瓦片式数据类型,瓦片本身支持向量计算。

前面讲到了向量计算的本质是一次计算多个值,减少函数调用,上下文切换,尽量的利用CPU的缓存以及向量化执行指令提高性能。

而为了达到这个目的,列存储是最适合的,为什么这么说,本质上列存储只是将数据做了物理的聚合,在使用时不需要像行存那样deform该列前面的其他列。

瓦片式存储类型,和列存储类似,可以达到类似的目的,你可以理解为单行存储了多行的数据。(比如你将某个字段的多行记录,按分组聚合为一个数组或JSONB,这只是个比喻,效果类似)

PostgreSQL VOPS插件就是这么做的,为每个数据类型,新增了一个对应的瓦片数据类型,比如real类型对应新增了一个vops_float4类型,它可以表述最多64个real值。

为什么是64呢,PostgreSQL VOPS给出了如下理由

  • 为了有效的访问瓦片,size_of_tile * size_of_attribute * number_of_attributes这几个属性相乘不能超过PostgreSQL单个数据块的大小。典型的一张表假设有10个字段即attribute,PostgreSQL默认的数据块为8KB。

  • 我们需要用掩码来标记空值,64 BIT的整型来表示这个掩码效率是最高的,所以这也是一个瓦片最多存储64个VALUE的理由之一。

  • 最后一点是和CPU CACHE有关的,通常CPU CACHE可以用来一次处理100到1000(经验值)个VALUE(向量化处理),所以瓦片的大小也要考虑这一点,一个瓦片最好能刚好吧CPU CACHE用尽。将来如果CPU CACHE做大了,我们可以再调整瓦片的大小。

PostgreSQL VOPS权衡了CSTORE和IMCS的弊端,在不改造PostgreSQL存储引擎,不改执行器,不使用钩子的情况下,利用瓦片式数据类型,有效的利用CPU CACHE以及向量化执行指令,将OLAP场景的性能提升了10倍。

新增的瓦片式类型,对应的还新增了对应的向量化执行操作符,所以使用VOPS和正常的使用SQL语法是一样的。

使用VOPS,总共分三步。

1. 创建基础表的VOPS表(使用对应的瓦片类型)。

2. 调用VOPS提供的转换函数,将基础表的数据转移到VOPS表(即转换、规整为瓦片存储的过程)。

3. 使用正常的SQL,访问VOPS表即可,但是需要注意,使用VOPS提供的操作符哦。

PostgreSQL VOPS新增 瓦片类型

VOPS 目前支持的数据类型如下

SQL typeC typeVOPS tile type
boolboolvops_bool
“char”, char or char(1)charvops_char
int2int16vops_int2
int4int32vops_int4
int8int64vops_int8
float4float4vops_float4
float8float8vops_float8
dateDateADTvops_date
timestampTimestampvops_timestamp

VOPS 目前不支持string,如果你使用了STRING字段,并且需要用来统计的话,建议可以使用INT8将其字典化,然后就可以利用VOPS特性了。

PostgreSQL VOPS新增 瓦片类型操作符

前面说了,正规的VOPS使用方法,总共分三步,这第三步涉及的就是操作符,因为是瓦片类型,VOPS重写了对应的操作符来支持向量计算。

1. VOPS数值瓦片类型,支持的+ - / *保持原样不变。

2. 比较操作符= <> > >= < <=保持原样不变。

这些操作符支持瓦片类型之间的运算,支持瓦片类型与scalar(原有标量类型)的常量之间的运算。

3. 逻辑操作符 and, or, not 是SQL PARSER部分,没有开放重写,所以VOPS定义了3个操作符来对应他们的功能。

& | !,相信一看就明白了吧。

例如

x=1 or x=2   
  
改写成  
  
(x=1) | (x=2)  
  
注意把他们当成逻辑操作符时,必须用括号,因为m&,|,! 几个原始操作符的优先级很高, 会误认为是原始操作符,报语法错误  

注意逻辑操作符,返回的数据类型为vops_boolean,并不是boolean。

4. x BETWEEN a AND b 等同于 x >=a AND x<=b,但是由于BETWEEN AND无法重写,所以VOPS使用betwixt函数来代替这个语法。

x BETWEEN a AND b   
  
改写成  
  
betwixt(x, a , b)  

betwixt函数,返回的数据类型为vops_boolean,也不是boolean。

5. 因为向量计算中,逻辑操作返回的是vops_boolean类型而不是boolean类型,所以PostgreSQL对vops_boolean类型进行断言,VOPS为了解决这个场景,使用了filter函数来处理断言。

例如

where (x=1) | (x=2)  
  
改写成  
  
where filter( (x=1) | (x=2) )  
  
所有的断言,都需要使用filter()函数  

filter()函数处理的是vops_boolean,返回的是boolean,但是filter()会对传入参数的内容设置filter_mask,用于后面的向量处理选择符合条件的数据。filter()任何情况下都返回boolean:true(让PostgreSQL执行器继续下去),这不影响整个向量计算的过程和结果。

6. 除了函数调用,所有的向量化计算操作符,在传入字符串常量时,必须显示转换。

例如

select sum(price) from trades where filter(day >= '2017-01-01'::date);  
  
不能写成  
  
select sum(price) from trades where filter(day >= '2017-01-01');  
  
而函数内,可以不使用显示转换  
  
select sum(price) from trades where filter(betwixt(day, '2017-01-01', '2017-02-01'));  

7. 对于char, int2, int4类型,VOPS提供了一个连接符||,用于如下合并

(char || char) -> int2, (int2 || int2) -> int4, (int4 || int4) -> int8.  

这么做,目的是用于多个字段的GROUP BY,提高GROUP BY效率。

8. VOPS提供的操作符,整合如下

OperatorDescription
+Addition
-Binary subtraction or unary negation
*Multiplication
/Division
=Equals
<>Not equals
<Less than
<=Less than or Equals
>Greater than
>=Greater than or equals
&Boolean AND
|Boolean OR
!Boolean NOT
betwixt(x,low,high)Analog of BETWEEN
is_null(x)Analog of IS NULL
is_not_null(x)Analog of IS NOT NULL
ifnull(x,subst)Analog of COALESCE

PostgreSQL VOPS 聚合函数

VOPS目前已经支持的聚合向量化计算,包括一些常用的聚合函数,如下

count, min, max, sum, avg  

聚合分为两种,一种是非分组聚合,一种是分组聚合。

1. VOPS处理非分组聚合,和PostgreSQL原有用法一样,例如:

select sum(l_extendedpricel_discount) as revenue  
from vops_lineitem  
where filter(betwixt(l_shipdate, '1996-01-01', '1997-01-01')  
        & betwixt(l_discount, 0.08, 0.1)  
        & (l_quantity < 24));  

2. VOPS处理带有group by的分组聚合稍微复杂一些,需要用到map和reduce函数。

map函数实现聚合操作,并且返回一个HASH TABLE(包含了所有GROUP的聚合后的信息),reduce函数则将hash table的数据一条条解析并返回。

所以可以看出MAP函数是运算,reduce函数是返回结果。

map函数语法

map(group_by_expression, aggregate_list, expr {, expr })  
  
可见map是一个vardic参数函数,所以expr的数据类型必须一致。  

reduce函数语法

reduce(map(...))  

例子

select   
    sum(l_quantity),  
    sum(l_extendedprice),  
    sum(l_extendedprice(1-l_discount)),  
    sum(l_extendedprice(1-l_discount)(1+l_tax)),  
    avg(l_quantity),  
    avg(l_extendedprice),  
    avg(l_discount)  
  from vops_lineitem   
  where l_shipdate <= '1998-12-01'::date  
  group by l_returnflag, l_linestatus;  
  
改写为  
  
select reduce(map(l_returnflag||l_linestatus, 'sum,sum,sum,sum,avg,avg,avg',  
    l_quantity,  
    l_extendedprice,  
    l_extendedprice(1-l_discount),  
    l_extendedprice(1-l_discount)(1+l_tax),  
    l_quantity,  
    l_extendedprice,  
    l_discount))   
  from vops_lineitem   
  where filter(l_shipdate <= '1998-12-01'::date);  

VOPS限制:

1. 目前VOPS只支持INT类型的聚合操作(int2, int4, int8)。

2. 由于map函数是vardic参数函数,所以末尾的所有expr类型必须一致(请参考PostgreSQL vardic函数的编写),所有的expr类型必须一致,即 sum(expr1),avg(expr2),…,这些expr类型必须一致。

map(group_by_expression, aggregate_list, expr {, expr })  
  
expr类型必须一致  

map, reduce 函数详解 :

1. map函数的第二个参数是聚合函数,用逗号隔开,count(*)不需要写明,因为reduce函数默认就会返回每个分组的count(*)。

count(字段)则需要写明。

例如

map(c1||c2, 'count,max,avg,max', c3, c4, c5, c6)  
  
表示  
  
count(*),  
count(c3),  
max(c4).  
avg(c5),  
max(c6)  
  
group by  
  
c1, c2;  

2. reduce函数,返回的是vops_aggregate类型的多条记录集合,即returns setof vops_aggregate;

vops_aggregate类型是复合类型,包括三个部分,

分组字段的值,例如group by a,那么它表示每个A字段分组的值。

分组的记录数,例如group by a,那么它表示每个A字符分组有多少条记录。

聚合结果数组(因为可能有多个聚合表达式,所以返回的是数组),因为PG的数组必须是单一类型,所以VOPS的聚合结果必须返回统一类型,目前vops定义的所有vops聚合函数返回的都是float类型。

定义如下

create type vops_aggregates as(group_by int8, count int8, aggs float8[]);  
  
create function reduce(bigint) returns setof vops_aggregates;  

VOPS分组聚合最佳实践,

将需要执行聚合操作的表,按分组字段分区,即选择分组字段,作为分区字段。(并非分区表的意思哦,而是对分区字段使用scalar类型,即非vops类型,后面将populate会讲)

那么在VOPS执行分组聚合时,实际上PostgreSQL的优化器会让其在各自的分区执行表级聚合,而非分组聚合。

所以分组聚合就变成了非分组聚合操作,也就不需要使用map, reduce的写法了。

如下

select  
    l_returnflag,  
    l_linestatus,  
    sum(l_quantity) as sum_qty,  
    sum(l_extendedprice) as sum_base_price,  
    sum(l_extendedprice(1-l_discount)) as sum_disc_price,  
    sum(l_extendedprice(1-l_discount)(1+l_tax)) as sum_charge,  
    avg(l_quantity) as avg_qty,  
    avg(l_extendedprice) as avg_price,  
    avg(l_discount) as avg_disc,  
    countall(*) as count_order  
from  
    vops_lineitem_projection  
where  
    filter(l_shipdate <= '1998-12-01'::date)  
group by  
    l_returnflag,  
    l_linestatus  
order by  
    l_returnflag,  
    l_linestatus;  
以上SQL例子,分组字段l_returnflag, l_linestatus字段为”char”类型,使用连接符 可以将其变成vops_int2类型。

其他字段是vops_float4类型。

以上SQL执行速度比reduce(map(…))快很多,问题是我们必须实现规划好分区,分区键与GROUP BY分组键一致。

VOPS 瓦片索引

通常情况下,OLAP场景并不需要使用索引,因为数据分区,列存储,以及向量计算相比索引可以更为有效的解决OLAP的性能问题。

但是索引作为数据过滤,也不失为一种好的选择。

但是VOPS已经将数据改为瓦片式存储(在一个瓦片中存储了原有的多条记录的某列的值),那么如何才能对瓦片数据创建索引呢?

我们当然不能将瓦片先解开,再来创建索引,这样无法取得好的效果,但是我们可以结合瓦片的特点,创建BRIN索引。

BRIN索引用于记录某些连续的数据块中的元数据,最大值,最小值,平均值,多少空值等。非常适合瓦片数据,因为瓦片本身就是个聚集。

另外BRIN更适合于存储顺序,与实际的逻辑值 线性相关的数据,比如递增字段。流式数据的递增字段,实际字段,序列字段等。。

关于BRIN索引的原理和应用场景,可以参考

《PostgreSQL 聚集存储 与 BRIN索引 - 高并发行为、轨迹类大吞吐数据查询场景解说》

《PostgreSQL 9.5 new feature - lets BRIN be used with R-Tree-like indexing strategies For “inclusion” opclasses》

《PostgreSQL 9.5 new feature - BRIN (block range index) index》

为了让PostgreSQL支持瓦片,我们需要将每个瓦片内的最大值和最小值取出,使用函数的方式,返回scalar类型,然后对scalar类型创建BRIN索引。

VOPS提供4个函数,可以将瓦片内的最大值,最小值取出

当VOPS表按某个字段排序存储时(即物理存储于逻辑值 完全线性相关),使用first, last函数,取出该字段的 最小值和最大值。  
  
当VOPS表没有按某个字段排序存储时,则需要使用high和low,取出该字段的 最大值和最小值。  

注意high, low需要额外的排序操作,效率略低。

first和last不需要排序,因为瓦片内的内容已经排序,所以效率很高。

例子

create index low_boundary on trades using brin(first(day)); -- trades table is ordered by day  
  
create index high_boundary on trades using brin(last(day)); -- trades table is ordered by day  

first, last, high, low返回的是PostgreSQL原生boolean,而不是vops_boolean,所以不需要加filter,可以直接写在where表达式中。

那么下面这个QUERY可以用到以上索引

select sum(price) from trades where first(day) >= '2015-01-01' and last(day) <= '2016-01-01'  
                                               and filter(betwixt(day, '2015-01-01', '2016-01-01'));  

普通表转换为 VOPS瓦片表

前面讲了,VOPS的核心是向量计算,而向量计算依赖数据的BATCH存储,(例如列存储),vops在不改动现有PostgreSQL代码的情况下,要支持向量计算,使用的是新增瓦片数据类型(一个瓦片存储原同一字段的多条记录的值)的方法。

所以,首先,必须要将表转换为VOPS的瓦片存储表。

vops提供了populate函数来完成这个工作。

过程如下

1. 创建原始表的VOPS对应表,数据类型使用vops提供的瓦片数据类型对应。

这个动作和vertica数据库的projection工作类似(它只将常用字段转换)。

例如,原始表如下

create table lineitem(  
   l_orderkey integer,  
   l_partkey integer,  
   l_suppkey integer,  
   l_linenumber integer,  
   l_quantity real,  
   l_extendedprice real,  
   l_discount real,  
   l_tax real,  
   l_returnflag char,  
   l_linestatus char,  
   l_shipdate date,  
   l_commitdate date,  
   l_receiptdate date,  
   l_shipinstruct char(25),  
   l_shipmode char(10),  
   l_comment char(44));  

VOPS projection of this table如下 , 我们只选择了需要用到的字段,进行转换,所以我们也称为projection吧:

create table vops_lineitem(  
   l_shipdate vops_date not null,  
   l_quantity vops_float4 not null,  
   l_extendedprice vops_float4 not null,  
   l_discount vops_float4 not null,  
   l_tax vops_float4 not null,  
   l_returnflag vops_char not null,  
   l_linestatus vops_char not null  
);  

我们可以把原始表称为写优化表(write optimized storage (WOS)),它不需要任何索引,写入速度可以做到非常非常快。

而VOPS表,我们称为读优化表(read-optimized storage (ROS)),对OLAP的查询请求进行优化,速度有10倍以上的提升。

2. 使用VOPS提供的populate函数,完成projection数据转换的动作

create function populate(destination regclass,  
                        source regclass,  
                        predicate cstring default null,  
                        sort cstring default null);  

前两个参数是强制参数,第一个表示VOPS表的regclass,第二个是原始表的regclass,

predicate参数用于合并数据,将最近接收的数据合并到已有数据的VOPS表。

sort是告诉populate,按什么字段排序,前面讲索引时有讲到为什么需要排序,就是要线性相关性,好创建first, last的brin索引。

当然,还有一个用途,就是QUERY返回结果时按照sort指定的字段order by,可以不需要显示的sort。

例子

select populate(destination := 'vops_lineitem'::regclass, source := 'lineitem'::regclass);  

vops表全表扫描的速度比普通表要快,一方面是瓦片式存储,另一方面,它可以使用向量计算,提高了filter和聚合操作的速度。

分组聚合需要reduce(map(…)),非分组聚合不需要使用reduce(map(…)),也没有map,reduce的诸多限制(请参考前面章节)。

如果我们需要分组聚合,并且想使用普通的QUERY写法(而非map,reduce),那么可以将分组字段,作为分区字段。

在选择分区字段时,我们的建议是,分区字段不要选择唯一字段(重复值更好,因为对它排序后,它可以单一值存储,而同时更加有利于其他字段的瓦片化),你总不会拿唯一字段取做group by 吧。

另一方面,我们建议对分区字段排序存储,这样可以帮助降低存储空间,查询效率也更高。(虽然它scalar类型,非瓦片类型)

例子

create table vops_lineitem_projection(  
   l_shipdate vops_date not null,  
   l_quantity vops_float4 not null,  
   l_extendedprice vops_float4 not null,  
   l_discount vops_float4 not null,  
   l_tax vops_float4 not null,  
   l_returnflag "char" not null,  
   l_linestatus "char" not null  
);  

分区字段选择了 l_returnflag and l_linestatus ,其他字段则为瓦片字段类型.

projection操作如下

select populate(destination := 'vops_lineitem_projection'::regclass, source := 'lineitem_projection'::regclass, sort := 'l_returnflag,l_linestatus');  

现在,你可以对分区字段创建普通索引,使用普通的group by, order by,标准达到prediction( where …),不需要使用filter。

查询瓦片表,如何返回正常记录

我们在查询projection后的vops表时,返回的都是瓦片记录,那么如何转换为正常记录呢?

类似聚合后的数据,如何进行行列变化,变成多行返回。

使用unnest函数即可(unnest函数也常被用于数组,JSON,HSTORE等类型的行列变换),如下

postgres=# select unnest(l.) from vops_lineitem l where filter(l_shipdate <= '1998-12-01'::date) limit 3;  
                unnest  
---------------------------------------  
 (1996-03-13,17,33078.9,0.04,0.02,N,O)  
 (1996-04-12,36,38306.2,0.09,0.06,N,O)  
 (1996-01-29,8,15479.7,0.1,0.02,N,O)  
(3 rows)  

好消息,标准SQL支持 - 自动化转换与parser analyze钩子

前面看了一大堆VOPS特定的类型,操作符,函数等,好像VOPS用起来要改写一堆的SQL对吧。

好消息来了,VOPS提供了parser analyze hook,另外PostgreSQL本身提供了自定义类型、自定义操作符、自定义类型隐式转换的功能。

User defined types  
  
User defined operator  
  
User defined implicit type casts  
  
Post parse analyze hook which performs query transformation  

HOOK与之结合,我们就可以让VOPS支持标准SQL,而不需要考虑前面的那些SQL写法了,让标准SQL支持向量计算。

(PS: VOPS的projection (population)过程是省不掉的,这是开发或者DBA的职责。)

具体如何做呢?

1. VOPS 已经定义了瓦片类型,以及瓦片对象的标准SQL操作符,我们不需要做什么。

2. VOPS 定义了vops_bool瓦片类型的隐式转换,vops_bool转换为boolean(scalar)类型。

3. 开发人员不需要使用filter(vops_boolean)了。

4. 最后,VOPS的parse analyze hook,帮助你将标准的SQL,改写为VOPS的执行SQL(就是我们前面学的那一堆),解放了程序猿的大脑。

转换前后对比,左边为程序猿写的,右边是parse analyze hook转换后的

Original expressionResult of transformation
NOT filter(o1)filter(vops_bool_not(o1))
filter(o1) AND filter(o2)filter(vops_bool_and(o1, o2))
filter(o1) OR filter(o2)filter(vops_bool_or(o1, o2))

5. 我们也不需要使用betwixt函数来代替between and了,只需要写标准的between and即可。(但是依旧建议使用betwixt(c,v1,v2),单个函数比较两个值,效率更高)

6. (x>=1)(x<=0),这个括号也不需要了。

7. 如果查询包含了向量聚合操作,count(*)会自动转换为countall(*)。而不会报错。

8. 当我们在瓦片类型的操作符后吗传入STRING时,目前还需要使用显示转换。

例如

l_shipdate如果是瓦片类型,那么以下必须加显示转换  
  
l_shipdate <= '1998-12-01'加显示转换  
  
l_shipdate <= '1998-12-01'::date  

因为有两个一样的重载操作符

vops_date <= vops_date  
  
vops_date <= date  

post parse analyze hook使用注意 - 首次使用,需通过vops_initialize载入或者配置shared_preload_libraries

由于VOPS使用的是post parse analyze hook,并且通过 _PG_init 函数加载。

如果你没有将vops.so配置进shared_preload_libraries,那么 _PG_init 函数是在会话使用到它是才会被载入,然而他的载入是晚于parse analyze的,因为一次调用QUERY,parse analyze就结束了。所以这种情况QUERY可能得到错误的结果。

因此你要么将vops.so配置进shared_preload_libraries,让数据库启动时就加载钩子。

要么,你可以在执行QUERY前,先执行一下vops_initialize()函数,人为的载入钩子。

VOPS 使用例子

既然VOPS 向量计算是为OLAP而生的,所以自然,我需要测试的是OLAP领域的TPC-H。

tpc-h包含了21个QUERY,接下来的例子测试的是Q1和Q6,没有使用JOIN,前面说了VOPS目前还不支持JOIN。

代码如下

-- Standard way of creating extension  
create extension vops;  
  
-- Original TPC-H table  
create table lineitem(  
   l_orderkey integer,  
   l_partkey integer,  
   l_suppkey integer,  
   l_linenumber integer,  
   l_quantity real,  
   l_extendedprice real,  
   l_discount real,  
   l_tax real,  
   l_returnflag char,  
   l_linestatus char,  
   l_shipdate date,  
   l_commitdate date,  
   l_receiptdate date,  
   l_shipinstruct char(25),  
   l_shipmode char(10),  
   l_comment char(44),  
   l_dummy char(1)); -- this table is needed because of terminator after last column in generated data  
  
-- Import data to it  
copy lineitem from '/mnt/data/lineitem.tbl' delimiter '|' csv;  
  
-- Create VOPS projection  
create table vops_lineitem(  
   l_shipdate vops_date not null,  
   l_quantity vops_float4 not null,  
   l_extendedprice vops_float4 not null,  
   l_discount vops_float4 not null,  
   l_tax vops_float4 not null,  
   l_returnflag vops_char not null,  
   l_linestatus vops_char not null  
);  
  
-- Copy data to the projection table  
select populate(destination := 'vops_lineitem'::regclass, source := 'lineitem'::regclass);  
  
-- For honest comparison creates the same projection without VOPS types  
create table lineitem_projection as (select l_shipdate,l_quantity,l_extendedprice,l_discount,l_tax,l_returnflag::"char",l_linestatus::"char" from lineitem);  
  
-- Now create mixed projection with partitioning keys:  
create table vops_lineitem_projection(  
   l_shipdate vops_date not null,  
   l_quantity vops_float4 not null,  
   l_extendedprice vops_float4 not null,  
   l_discount vops_float4 not null,  
   l_tax vops_float4 not null,  
   l_returnflag "char" not null,  
   l_linestatus "char" not null  
);  
  
-- And populate it with data sorted by partitioning key:  
select populate(destination := 'vops_lineitem_projection'::regclass, source := 'lineitem_projection'::regclass, sort := 'l_returnflag,l_linestatus');  
  
  
-- Let's measure time  
\timing  
  
-- Original Q6 query performing filtering with calculation of grand aggregate  
select  
    sum(l_extendedpricel_discount) as revenue  
from  
    lineitem  
where  
    l_shipdate between '1996-01-01' and '1997-01-01'  
    and l_discount between 0.08 and 0.1  
    and l_quantity < 24;  
  
-- VOPS version of Q6 using VOPS specific operators  
select sum(l_extendedpricel_discount) as revenue  
from vops_lineitem  
where filter(betwixt(l_shipdate, '1996-01-01', '1997-01-01')  
        & betwixt(l_discount, 0.08, 0.1)  
        & (l_quantity < 24));  
  
-- Yet another vectorized version of Q6, but now in stadnard SQL:  
select sum(l_extendedpricel_discount) as revenue  
from vops_lineitem  
where l_shipdate between '1996-01-01'::date AND '1997-01-01'::date  
   and l_discount between 0.08 and 0.1  
   and l_quantity < 24;  
  
  
  
-- Original version of Q1: filter + group by + aggregation  
select  
    l_returnflag,  
    l_linestatus,  
    sum(l_quantity) as sum_qty,  
    sum(l_extendedprice) as sum_base_price,  
    sum(l_extendedprice(1-l_discount)) as sum_disc_price,  
    sum(l_extendedprice(1-l_discount)(1+l_tax)) as sum_charge,  
    avg(l_quantity) as avg_qty,  
    avg(l_extendedprice) as avg_price,  
    avg(l_discount) as avg_disc,  
    count() as count_order  
from  
    lineitem  
where  
    l_shipdate <= '1998-12-01'  
group by  
    l_returnflag,  
    l_linestatus  
order by  
    l_returnflag,  
    l_linestatus;  
  
-- VOPS version of Q1, sorry - no final sorting  
select reduce(map(l_returnflag||l_linestatus, 'sum,sum,sum,sum,avg,avg,avg',  
    l_quantity,  
    l_extendedprice,  
    l_extendedprice(1-l_discount),  
    l_extendedprice(1-l_discount)(1+l_tax),  
    l_quantity,  
    l_extendedprice,  
    l_discount)) from vops_lineitem where filter(l_shipdate <= '1998-12-01'::date);  
  
-- Mixed mode: let's Postgres does group by and calculates VOPS aggregates for each group  
select  
    l_returnflag,  
    l_linestatus,  
    sum(l_quantity) as sum_qty,  
    sum(l_extendedprice) as sum_base_price,  
    sum(l_extendedprice(1-l_discount)) as sum_disc_price,  
    sum(l_extendedprice(1-l_discount)(1+l_tax)) as sum_charge,  
    avg(l_quantity) as avg_qty,  
    avg(l_extendedprice) as avg_price,  
    avg(l_discount) as avg_disc,  
    count() as count_order  
from  
    vops_lineitem_projection  
where  
    l_shipdate <= '1998-12-01'::date  
group by  
    l_returnflag,  
    l_linestatus  
order by  
    l_returnflag,  
    l_linestatus;  

测试性能对比

测试环境和配置如下,数据可以放入整个内存,所以不用考虑IO影响,同时测试了单核与多核并行模式(因为PostgreSQL支持一个QUERY使用多个CPU核)。

All measurements were performed at desktop with 16Gb of RAM and quad-core i7-4770 CPU @ 3.40GHz processor with enabled hyper-threading.  
  
Data set for benchmark was generated by dbgen utility included in TPC-H benchmark.  
  
Scale factor is 100 which corresponds to about 8Gb database.   
  
It can completely fit in memory, so we are measuring best query execution time for warm data.  
  
Postgres was configured with shared buffer size equal to 8Gb.  
  
For each query we measured time of sequential and parallel execution with 8 parallel workers.  

性能对比如下

QuerySequential execution (msec)Parallel execution (msec)
Original Q1 for lineitem3802810997
Original Q1 for lineitem_projection338729656
Vectorized Q1 for vops_lineitem3372951
Mixed Q1 for vops_lineitem_projection1490396
Original Q6 for lineitem167964110
Original Q6 for lineitem_projection42791171
Vectorized Q6 for vops_lineitem875284

结论

从测试结果来看,使用VOPS向量计算后,性能有了10倍的提升,相比LLVM的4倍和3倍,取得了更好的效果。

同时,使用瓦片式的方法,不需要修改数据库内核,可以以插件的形式装载,适合更多的用户使用。

As you can see in performance results, VOPS can provide more than 10 times improvement of query speed.  
  
And this result is achieved without changing something in query planner and executor.  
  
It is better than any of existed attempt to speed up execution of OLAP queries using JIT (4 times for Q1, 3 times for Q6):  
  
Speeding up query execution in PostgreSQL using LLVM JIT compiler.  
  
Definitely VOPS extension is just a prototype which main role is to demonstrate potential of vectorized executor.  
  
But I hope that it also can be useful in practice to speedup execution of OLAP aggregation queries for existed databases.  
  
And in future we should think about the best approach of integrating vectorized executor in Postgres core.  
  
ALL sources of VOPS project can be obtained from this GIT repository.  
  
Please send any feedbacks, complaints, bug reports, change requests to Konstantin Knizhnik.  

小结

PostgreSQL数据库近几年的发展让很多商业数据库都汗颜,在OLTP领域担当了开源关系数据库领头雁的职责,在OLAP领域又在不断地发力。

从多核并行、GPU加速、FPGA加速到JIT,再到向量计算。在计算能力方面不断的提高。

同时,PostgreSQL设置在分布式方面也已经小有成就,例如已支持聚合下推、算子下推、WHERE子句下推,SORT\JOIN下推等等一系列的postgres_fdw的完善。(还有基于PG的分布式数据库greenplum, redshift, pgxc, pgxz等等就不多说了)

PostgreSQL势必要在OLTP和OLAP领域大放异彩。

参考

https://github.com/citusdata/postgres_vectorization_test

https://github.com/citusdata/cstore_fdw

https://github.com/knizhnik/imcs

https://www.monetdb.org/Home

https://github.com/postgrespro/vops

MySQL · myrocks · myrocks监控信息

$
0
0

rocksdb本身提供了丰富的监控信息,myrocks通过information_schema下的表和show命令等将这些信息展示出来,下面主要以示例的形式来简单介绍下

先创建测试表

CREATE TABLE t1 (a INT, b CHAR(8), pk INT AUTO_INCREMENT ,PRIMARY KEY(pk) comment 'cf_1', key idx2(b) comment 'cf_2') engine=rocksdb;

SHOW STATUS

show status 也展示了部分rocksdb引擎的信息

show status like '%rock%';
+---------------------------------------+------------------------------------------+
| Variable_name                         | Value                                    |
+---------------------------------------+------------------------------------------+
| rocksdb_rows_deleted                  | 0                                        |
| rocksdb_rows_inserted                 | 1048579                                  |
| rocksdb_rows_read                     | 3145755                                  |
| rocksdb_rows_updated                  | 7                                        |
| rocksdb_system_rows_deleted           | 0                                        |
| rocksdb_system_rows_inserted          | 0                                        |
| rocksdb_system_rows_read              | 0                                        |
| rocksdb_system_rows_updated           | 0                                        |
| rocksdb_block_cache_add               | 16                                       |
| rocksdb_block_cache_data_hit          | 76                                       |
| rocksdb_block_cache_data_miss         | 6                                        |
| rocksdb_block_cache_filter_hit        | 0                                        |
| rocksdb_block_cache_filter_miss       | 6                                        |
| rocksdb_block_cache_hit               | 76                                       |
| rocksdb_block_cache_index_hit         | 0                                        |
| rocksdb_block_cache_index_miss        | 6                                        |
| rocksdb_block_cache_miss              | 18                                       |
| rocksdb_block_cachecompressed_hit     | 0                                        |
| rocksdb_block_cachecompressed_miss    | 0                                        |
| rocksdb_bloom_filter_prefix_checked   | 0                                        |
| rocksdb_bloom_filter_prefix_useful    | 0                                        |
| rocksdb_bloom_filter_useful           | 0                                        |
| rocksdb_bytes_read                    | 13631762                                 |
| rocksdb_bytes_written                 | 108009584                                |
| rocksdb_compact_read_bytes            | 142                                      |
| rocksdb_compact_write_bytes           | 0                                        |
| rocksdb_compaction_key_drop_new       | 0                                        |
| rocksdb_compaction_key_drop_obsolete  | 4                                        |
| rocksdb_compaction_key_drop_user      | 4                                        |
| rocksdb_flush_write_bytes             | 7211                                     |
| rocksdb_getupdatessince_calls         | 0                                        |
| rocksdb_git_date                      | %cI                                      |
| rocksdb_git_hash                      | bc5d7b70299b763127f3714055a63ebe7e04ad47 |
| rocksdb_l0_num_files_stall_micros     | 0                                        |
| rocksdb_l0_slowdown_micros            | 0                                        |
| rocksdb_memtable_compaction_micros    | 0                                        |
| rocksdb_memtable_hit                  | 1048593                                  |
| rocksdb_memtable_miss                 | 1048609                                  |
| rocksdb_no_file_closes                | 0                                        |
| rocksdb_no_file_errors                | 0                                        |
| rocksdb_no_file_opens                 | 6                                        |
| rocksdb_num_iterators                 | 0                                        |
| rocksdb_number_block_not_compressed   | 0                                        |
| rocksdb_number_deletes_filtered       | 0                                        |
| rocksdb_number_keys_read              | 2097202                                  |
| rocksdb_number_keys_updated           | 0                                        |
| rocksdb_number_keys_written           | 2097220                                  |
| rocksdb_number_merge_failures         | 0                                        |
| rocksdb_number_multiget_bytes_read    | 0                                        |
| rocksdb_number_multiget_get           | 0                                        |
| rocksdb_number_multiget_keys_read     | 0                                        |
| rocksdb_number_reseeks_iteration      | 0                                        |
| rocksdb_number_sst_entry_delete       | 12                                       |
| rocksdb_number_sst_entry_merge        | 0                                        |
| rocksdb_number_sst_entry_other        | 0                                        |
| rocksdb_number_sst_entry_put          | 30                                       |
| rocksdb_number_sst_entry_singledelete | 0                                        |
| rocksdb_number_stat_computes          | 0                                        |
| rocksdb_number_superversion_acquires  | 21                                       |
| rocksdb_number_superversion_cleanups  | 1                                        |
| rocksdb_number_superversion_releases  | 1                                        |
| rocksdb_rate_limit_delay_millis       | 0                                        |
| rocksdb_snapshot_conflict_errors      | 0                                        |
| rocksdb_wal_bytes                     | 54006676                                 |
| rocksdb_wal_group_syncs               | 0                                        |
| rocksdb_wal_synced                    | 13                                       |
| rocksdb_write_other                   | 0                                        |
| rocksdb_write_self                    | 58                                       |
| rocksdb_write_timedout                | 0                                        |
| rocksdb_write_wal                     | 58                                       |
+---------------------------------------+------------------------------------------+

INFORMATION_SCHMEA

information_schema下rocksdb相关的表如下

select table_name from INFORMATION_SCHEMA.tables where table_name like '%rock%';
+-----------------------------+
| table_name                  |
+-----------------------------+
| ROCKSDB_PERF_CONTEXT        |
| ROCKSDB_GLOBAL_INFO         |
| ROCKSDB_COMPACTION_STATS    |
| ROCKSDB_INDEX_FILE_MAP      |
| ROCKSDB_CF_OPTIONS          |
| ROCKSDB_PERF_CONTEXT_GLOBAL |
| ROCKSDB_CFSTATS             |
| ROCKSDB_TRX                 |
| ROCKSDB_DBSTATS             |
| ROCKSDB_DDL                 |
| ROCKSDB_LOCKS               |
+-----------------------------+

  • 数据字典相关
show create table INFORMATION_SCHEMA.ROCKSDB_INDEX_FILE_MAP\G
*************************** 1. row ***************************
       Table: ROCKSDB_INDEX_FILE_MAP
Create Table: CREATE TEMPORARY TABLE `ROCKSDB_INDEX_FILE_MAP` (
  `COLUMN_FAMILY` int(4) NOT NULL DEFAULT '0',
  `INDEX_NUMBER` int(4) NOT NULL DEFAULT '0',
  `SST_NAME` varchar(193) NOT NULL DEFAULT '',
  `NUM_ROWS` bigint(8) NOT NULL DEFAULT '0',
  `DATA_SIZE` bigint(8) NOT NULL DEFAULT '0',
  `ENTRY_DELETES` bigint(8) NOT NULL DEFAULT '0',
  `ENTRY_SINGLEDELETES` bigint(8) NOT NULL DEFAULT '0',
  `ENTRY_MERGES` bigint(8) NOT NULL DEFAULT '0',
  `ENTRY_OTHERS` bigint(8) NOT NULL DEFAULT '0'
) ENGINE=MEMORY DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

mysql> show create table INFORMATION_SCHEMA.ROCKSDB_DDL\G
*************************** 1. row ***************************
       Table: ROCKSDB_DDL
Create Table: CREATE TEMPORARY TABLE `ROCKSDB_DDL` (
  `TABLE_SCHEMA` varchar(193) NOT NULL DEFAULT '',
  `TABLE_NAME` varchar(193) NOT NULL DEFAULT '',
  `PARTITION_NAME` varchar(193) DEFAULT NULL,
  `INDEX_NAME` varchar(193) NOT NULL DEFAULT '',
  `COLUMN_FAMILY` int(4) NOT NULL DEFAULT '0',
  `INDEX_NUMBER` int(4) NOT NULL DEFAULT '0',
  `INDEX_TYPE` smallint(2) NOT NULL DEFAULT '0',
  `KV_FORMAT_VERSION` smallint(2) NOT NULL DEFAULT '0',
  `CF` varchar(193) NOT NULL DEFAULT ''
) ENGINE=MEMORY DEFAULT CHARSET=utf8

例如查询t1表的数据字典信息

 select d.*,i.* from INFORMATION_SCHEMA.ROCKSDB_INDEX_FILE_MAP i,INFORMATION_SCHEMA.ROCKSDB_DDL d where i.INDEX_NUMBER=d.INDEX_NUMBER\G
*************************** 1. row ***************************
       TABLE_SCHEMA: test
         TABLE_NAME: t1
     PARTITION_NAME: NULL
         INDEX_NAME: PRIMARY
      COLUMN_FAMILY: 2
       INDEX_NUMBER: 263
         INDEX_TYPE: 1
  KV_FORMAT_VERSION: 11
                 CF: cf_1
      COLUMN_FAMILY: 2
       INDEX_NUMBER: 263
           SST_NAME: 000039.sst
           NUM_ROWS: 2
          DATA_SIZE: 42
      ENTRY_DELETES: 0
ENTRY_SINGLEDELETES: 0
       ENTRY_MERGES: 0
       ENTRY_OTHERS: 0
*************************** 2. row ***************************
       TABLE_SCHEMA: test
         TABLE_NAME: t1
     PARTITION_NAME: NULL
         INDEX_NAME: idx2
      COLUMN_FAMILY: 3
       INDEX_NUMBER: 264
         INDEX_TYPE: 2
  KV_FORMAT_VERSION: 11
                 CF: cf_2
      COLUMN_FAMILY: 3
       INDEX_NUMBER: 264
           SST_NAME: 000040.sst
           NUM_ROWS: 2
          DATA_SIZE: 45
      ENTRY_DELETES: 0
ENTRY_SINGLEDELETES: 0
       ENTRY_MERGES: 0
       ENTRY_OTHERS: 0
2 rows in set (0.00 sec)

  • 事务相关
begin;
select * from INFORMATION_SCHEMA.ROCKSDB_LOCKS;
INSERT INTO t1 (a,b) VALUES (1,'a');
select * from INFORMATION_SCHEMA.ROCKSDB_LOCKS;
+------------------+----------------+------------------+------+
| COLUMN_FAMILY_ID | TRANSACTION_ID | KEY              | MODE |
+------------------+----------------+------------------+------+
|                2 |             14 | 0000010780000001 | X    |
+------------------+----------------+------------------+------+

select * from INFORMATION_SCHEMA.ROCKSDB_TRX\G
*************************** 1. row ***************************
          TRANSACTION_ID: 89
                   STATE: STARTED
                    NAME:
             WRITE_COUNT: 2
              LOCK_COUNT: 2
             TIMEOUT_SEC: 2
             WAITING_KEY:
WAITING_COLUMN_FAMILY_ID: 0
          IS_REPLICATION: 0
            SKIP_TRX_API: 0
               READ_ONLY: 0
  HAS_DEADLOCK_DETECTION: 0
    NUM_ONGOING_BULKLOAD: 0
               THREAD_ID: 13
                   QUERY: select * from INFORMATION_SCHEMA.ROCKSDB_TRX

其中KEY 0000010780100002表示indexnum:107(263)pk: 80000001(1)
表示(1,’a’,1)这条记录,具体参考myrocks记录格式分析

  • 统计信息相关
select * from INFORMATION_SCHEMA.ROCKSDB_GLOBAL_INFO;
+--------------+--------------+-----------------------------------------+
| TYPE         | NAME         | VALUE                                   |
+--------------+--------------+-----------------------------------------+
| BINLOG       | FILE         | mysql-bin.000003                        |
| BINLOG       | POS          | 18957545                                |
| BINLOG       | GTID         | b89fb268-0b22-11e7-a0ce-2c44fd7a5210:27 |
| MAX_INDEX_ID | MAX_INDEX_ID | 264                                     |
| CF_FLAGS     | 0            | default [0]                             |
| CF_FLAGS     | 1            | __system__ [0]                          |
| CF_FLAGS     | 2            | cf_1 [0]                                |
| CF_FLAGS     | 3            | cf_2 [0]                                |
+--------------+--------------+-----------------------------------------+

select * from INFORMATION_SCHEMA.ROCKSDB_DBSTATS;
+-------------------------+-------+
| STAT_TYPE               | VALUE |
+-------------------------+-------+
| DB_BACKGROUND_ERRORS    |     0 |
| DB_NUM_SNAPSHOTS        |     0 |
| DB_OLDEST_SNAPSHOT_TIME |     0 |
| DB_BLOCK_CACHE_USAGE    |  1119 |
+-------------------------+-------+

select * from INFORMATION_SCHEMA.ROCKSDB_CFSTATS where CF_NAME='cf_1';
+---------+-------------------------------+----------+
| CF_NAME | STAT_TYPE                     | VALUE    |
+---------+-------------------------------+----------+
| cf_1    | NUM_IMMUTABLE_MEM_TABLE       |        0 |
| cf_1    | MEM_TABLE_FLUSH_PENDING       |        0 |
| cf_1    | COMPACTION_PENDING            |        0 |
| cf_1    | CUR_SIZE_ACTIVE_MEM_TABLE     | 44739520 |
| cf_1    | CUR_SIZE_ALL_MEM_TABLES       | 44739520 |
| cf_1    | NUM_ENTRIES_ACTIVE_MEM_TABLE  |  1048574 |
| cf_1    | NUM_ENTRIES_IMM_MEM_TABLES    |        0 |
| cf_1    | NON_BLOCK_CACHE_SST_MEM_USAGE |        0 |
| cf_1    | NUM_LIVE_VERSIONS             |        1 |
+---------+-------------------------------+----------+


  • 性能相关

INFORMATION_SCHEMA.ROCKSDB_PERF_CONTEXT_GLOBAL 是全局的性能信息,
而INFORMATION_SCHEMA.ROCKSDB_PERF_CONTEXT是以表为单位的性能信息。

性能统计由参数rocksdb_perf_context_level控制,取值范围如下

enum PerfLevel : unsigned char {
  kUninitialized = 0,             // unknown setting
  kDisable = 1,                   // disable perf stats
  kEnableCount = 2,               // enable only count stats
  kEnableTimeExceptForMutex = 3,  // Other than count stats, also enable time
                                  // stats except for mutexes
  kEnableTime = 4,                // enable count and time stats
  kOutOfBounds = 5                // N.B. Must always be the last value!
};

示例如下:


select * from INFORMATION_SCHEMA.ROCKSDB_PERF_CONTEXT_GLOBAL;
+---------------------------------+-------------+
| STAT_TYPE                       | VALUE       |
+---------------------------------+-------------+
| USER_KEY_COMPARISON_COUNT       |   565061179 |
| BLOCK_CACHE_HIT_COUNT           |          26 |
| BLOCK_READ_COUNT                |           2 |
| BLOCK_READ_BYTE                 |         145 |
| BLOCK_READ_TIME                 |      684522 |
| BLOCK_CHECKSUM_TIME             |        8380 |
| BLOCK_DECOMPRESS_TIME           |       10825 |
| INTERNAL_KEY_SKIPPED_COUNT      |     3371079 |
| INTERNAL_DELETE_SKIPPED_COUNT   |           0 |
| GET_SNAPSHOT_TIME               |  2409821566 |
| GET_FROM_MEMTABLE_TIME          | 68354733245 |
| GET_FROM_MEMTABLE_COUNT         |     4194309 |
| GET_POST_PROCESS_TIME           |  3421224444 |
| GET_FROM_OUTPUT_FILES_TIME      |  8016972510 |
| SEEK_ON_MEMTABLE_TIME           |      277621 |
| SEEK_ON_MEMTABLE_COUNT          |          33 |
| SEEK_CHILD_SEEK_TIME            |     1700582 |
| SEEK_CHILD_SEEK_COUNT           |          54 |
| SEEK_IN_HEAP_TIME               |      101201 |
| SEEK_INTERNAL_SEEK_TIME         |     2019275 |
| FIND_NEXT_USER_ENTRY_TIME       |  3997301676 |
| WRITE_WAL_TIME                  |   410899041 |
| WRITE_MEMTABLE_TIME             | 23580852751 |
| WRITE_DELAY_TIME                |           0 |
| WRITE_PRE_AND_POST_PROCESS_TIME |     1117611 |
| DB_MUTEX_LOCK_NANOS             |      237804 |
| DB_CONDITION_WAIT_NANOS         |           0 |
| MERGE_OPERATOR_TIME_NANOS       |           0 |
| READ_INDEX_BLOCK_NANOS          |           0 |
| READ_FILTER_BLOCK_NANOS         |           0 |
| NEW_TABLE_BLOCK_ITER_NANOS      |     1109437 |
| NEW_TABLE_ITERATOR_NANOS        |      308214 |
| BLOCK_SEEK_NANOS                |  1290004508 |
| FIND_TABLE_NANOS                |           0 |
| IO_THREAD_POOL_ID               |         102 |
| IO_BYTES_WRITTEN                |    54016973 |
| IO_BYTES_READ                   |         145 |
| IO_OPEN_NANOS                   |           0 |
| IO_ALLOCATE_NANOS               |           0 |
| IO_WRITE_NANOS                  |   116163102 |
| IO_READ_NANOS                   |      664547 |
| IO_RANGE_SYNC_NANOS             |           0 |
| IO_LOGGER_NANOS                 |           0 |
+---------------------------------+-------------+

 select * from INFORMATION_SCHEMA.ROCKSDB_PERF_CONTEXT where table_name='t1' limit 1;
+--------------+------------+----------------+---------------------------+-----------+
| TABLE_SCHEMA | TABLE_NAME | PARTITION_NAME | STAT_TYPE                 | VALUE     |
+--------------+------------+----------------+---------------------------+-----------+
| test         | t1         | NULL           | USER_KEY_COMPARISON_COUNT | 565060904 |
+--------------+------------+----------------+---------------------------+-----------+

  • COMPACTION相关
select * from INFORMATION_SCHEMA.ROCKSDB_COMPACTION_STATS where CF_NAME='cf_1' limit 3;
+---------+-------+-----------+-------+
| CF_NAME | LEVEL | TYPE      | VALUE |
+---------+-------+-----------+-------+
| cf_1    | L0    | AvgSec    |     0 |
| cf_1    | L0    | CompCount |     2 |
| cf_1    | L0    | CompSec   |     0 |
+---------+-------+-----------+-------+

具体可以参考下节SHOW ENGINE ROCKSDB STATUS。

  • 参数配置

每个column family 都是独立的配置信息

select * from INFORMATION_SCHEMA.ROCKSDB_CF_OPTIONS where CF_NAME='cf_1' limit 3;
+---------+-------------------+------------------+
| CF_NAME | OPTION_TYPE       | VALUE            |
+---------+-------------------+------------------+
| cf_1    | COMPARATOR        | RocksDB_SE_v3.10 |
| cf_1    | MERGE_OPERATOR    | NULL             |
| cf_1    | COMPACTION_FILTER | NULL             |
+---------+-------------------+------------------+

SHOW ENGINE ROCKSDB STATUS

show engine rocksdb status.结果主要分为三部分
1)DB Stats
2)Compaction Stats
3)Memory_Stats

show engine rocksdb status\G结果节选

show engine rocksdb status\G
*************************** 1. row ***************************
  Type: DBSTATS
  Name: rocksdb
Status:
** DB Stats **
Uptime(secs): 211548.0 total, 8140.1 interval
Cumulative writes: 58 writes, 2097K keys, 58 commit groups, 1.0 writes per commit group, ingest: 0.10 GB, 0.00 MB/s
Cumulative WAL: 58 writes, 13 syncs, 4.14 writes per sync, written: 0.05 GB, 0.00 MB/s
Cumulative stall: 00:00:0.000 H:M:S, 0.0 percent
Interval writes: 0 writes, 0 keys, 0 commit groups, 0.0 writes per commit group, ingest: 0.00 MB, 0.00 MB/s
Interval WAL: 0 writes, 0 syncs, 0.00 writes per sync, written: 0.00 MB, 0.00 MB/s
Interval stall: 00:00:0.000 H:M:S, 0.0 percent

......(省略)

*************************** 3. row ***************************
  Type: CF_COMPACTION
  Name: cf_1
Status:
** Compaction Stats [cf_1] **
Level    Files   Size(MB} Score Read(GB}  Rn(GB} Rnp1(GB} Write(GB} Wnew(GB} Moved(GB} W-Amp Rd(MB/s} Wr(MB/s} Comp(sec} Comp(cnt} Avg(sec} KeyIn KeyDrop
----------------------------------------------------------------------------------------------------------------------------------------------------------
  L0      1/0       0.00   0.2      0.0     0.0      0.0       0.0      0.0       0.0   0.0      0.0      0.3         0         2    0.004       0      0
 Sum      1/0       0.00   0.0      0.0     0.0      0.0       0.0      0.0       0.0   1.0      0.1      0.2         0         3    0.004       2      2
 Int      0/0       0.00   0.0      0.0     0.0      0.0       0.0      0.0       0.0   0.0      0.0      0.0         0         0    0.000       0      0
Uptime(secs): 210665.0 total, 210665.0 interval
Flush(GB): cumulative 0.000, interval 0.000
AddFile(GB): cumulative 0.000, interval 0.000
AddFile(Total Files): cumulative 0, interval 0
AddFile(L0 Files): cumulative 0, interval 0
AddFile(Keys): cumulative 0, interval 0
Cumulative compaction: 0.00 GB write, 0.00 MB/s write, 0.00 GB read, 0.00 MB/s read, 0.0 seconds
Interval compaction: 0.00 GB write, 0.00 MB/s write, 0.00 GB read, 0.00 MB/s read, 0.0 seconds
Stalls(count): 0 level0_slowdown, 0 level0_slowdown_with_compaction, 0 level0_numfiles, 0 level0_numfiles_with_compaction, 0 stop for pending_compaction_bytes, 0 slowdown for pending_compaction_bytes, 0 memtable_compaction, 0 memtable_slowdown, interval 0 total count

......(省略)

*************************** 6. row ***************************
  Type: Memory_Stats
  Name: rocksdb
Status:
MemTable Total: 93675232
MemTable Unflushed: 93673184
Table Readers Total: 0
Cache Total: 1119
Default Cache Capacity: 0
6 rows in set (0.00 sec)

  • DB Stats

其中:
Interval stall: 此值受max_write_buffer_number,level0_slowdown_writes_trigger、soft_pending_compaction_bytes_limit等参数的影响, 具体参考(SetupDelay)

  • Compaction Stats

其中
Rn(GB} = bytes_read_non_output_levels / kGB
Rnp1(GB} = bytes_read_output_level / kGB
W-Amp = bytes_written/bytes_read_non_output_levels

此部分内容与 INFORMATION_SCHEMA.ROCKSDB_COMPACTION_STATS有部分重合。

  • Memory_Stats

MemTable Total: 对应DB::Properties::kSizeAllMemTables
MemTable Unflushed:对应DB::Properties::kCurSizeAllMemTables
Table Readers Total: 对应DB::Properties::kEstimateTableReadersMem
Cache Total: 表示已使用的内存
Default Cache Capacity: 使用默认blockcache的总量(basetable没有指定blockcache时使用默认的8M的blockcache)

SHOW ENGING ROKSDB TRANCTION STATUS

显示当前正在运行的事务语句

 show engine rocksdb transaction status\G
*************************** 1. row ***************************
  Type: SNAPSHOTS
  Name: rocksdb
Status:
============================================================
2017-03-20 07:49:22 ROCKSDB TRANSACTION MONITOR OUTPUT
============================================================
---------
SNAPSHOTS
---------
LIST OF SNAPSHOTS FOR EACH SESSION:
---SNAPSHOT, ACTIVE 5 sec
MySQL thread id 12, OS thread handle 0x7fd23a1d0700, query id 187 127.0.0.1 root Searching rows for update
update t1 set b='cc' where a=2
lock count 72822, write count 2
-----------------------------------------
END OF ROCKSDB TRANSACTION MONITOR OUTPUT
=========================================

总结

以上粗略介绍了myrocks的监控信息,具体还需要在实践中灵活运用。myrocks的监控信息也在不断完善中。

MySQL · 源码分析 · MySQL 半同步复制数据一致性分析

$
0
0

简介

MySQL Replication为MySQL用户提供了高可用性和可扩展性解决方案。本文介绍了MySQL Replication的主要发展历程,然后通过三个参数rpl_semi_sync_master_wait_point、sync_binlog、sync_relay_log的配置简要分析了MySQL半同步的数据一致性。

MySQL Replication的发展

在2000年,MySQL 3.23.15版本引入了Replication。Replication作为一种准实时同步方式,得到广泛应用。

这个时候的Replicaton的实现涉及到两个线程,一个在Master,一个在Slave。Slave的I/O和SQL功能是作为一个线程,从Master获取到event后直接apply,没有relay log。这种方式使得读取event的速度会被Slave replay速度拖慢,当主备存在较大延迟时候,会导致大量binary log没有备份到Slave端。

在2002年,MySQL 4.0.2版本将Slave端event读取和执行独立成两个线程(IO线程和SQL线程),同时引入了relay log。IO线程读取event后写入relay log,SQL线程从relay log中读取event然后执行。这样即使SQL线程执行慢,Master的binary log也会尽可能的同步到Slave。当Master宕机,切换到Slave,不会出现大量数据丢失。

MySQL在2010年5.5版本之前,一直采用的是异步复制。主库的事务执行不会管备库的同步进度,如果备库落后,主库不幸crash,那么就会导致数据丢失。

MySQL在5.5中引入了半同步复制,主库在应答客户端提交的事务前需要保证至少一个从库接收并写到relay log中。那么半同步复制是否可以做到不丢失数据呢。

在2016年,MySQL在5.7.17中引入了Group Replication。

MySQL 半同步复制的数据一致性

源码剖析

以下源码版本均为官方MySQL 5.7。
MySQL semi-sync是以插件方式引入,在plugin/semisync目录下。这里以semi-sync主要的函数调用为入口,学习semi-sync源码。

plugin/semisync/semisync_master.cc
403 /*******************************************************************************
404  *
405  * <ReplSemiSyncMaster> class: the basic code layer for sync-replication master.
406  * <ReplSemiSyncSlave>  class: the basic code layer for sync-replication slave.
407  *
408  * The most important functions during semi-syn replication listed:
409  *
410  * Master:
          //实际由Ack_receiver线程调用,处理semi-sync复制状态,获取备库最新binlog位点,唤醒对应线程
411  *  . reportReplyBinlog():  called by the binlog dump thread when it receives
412  *                          the slave's status information.
          //根据semi-sync运行状态设置数据包头semi-sync标记
413  *  . updateSyncHeader():   based on transaction waiting information, decide
414  *                          whether to request the slave to reply.
          //存储当前binlog 文件名和偏移量,更新当前最大的事务 binlog 位置
415  *  . writeTranxInBinlog(): called by the transaction thread when it finishes
416  *                          writing all transaction events in binlog.
          //实现客户端同步等待逻辑
417  *  . commitTrx():          transaction thread wait for the slave reply.
418  *
419  * Slave:
          //确认网络包头是否有semi-sync标记
420  *  . slaveReadSyncHeader(): read the semi-sync header from the master, get the
421  *                           sync status and get the payload for events.
          //给Master发送ACK报文
422  *  . slaveReply():          reply to the master about the replication progress.
423  *
424  ******************************************************************************/

Ack_receiver线程,不断遍历slave,通过select监听slave网络包,处理semi-sync复制状态,唤醒等待线程。
plugin/semisync/semisync_master_ack_receiver.cc Ack_receiver::run()
->plugin/semisync/semisync_master.cc ReplSemiSyncMaster::reportReplyPacket
  ->plugin/semisync/semisync_master.cc ReplSemiSyncMaster::reportReplyBinlog

binlog Dump线程。如果slave是semi-slave,通过add_slave将slave添加到监听队列,在发送网络包时根据semi-sync运行状态设置包头的semi-sync标记。
sql/rpl_binlog_sender.cc Binlog_sender::run()
->sql/rpl_binlog_sender.cc Binlog_sender::send_binlog
  ->sql/rpl_binlog_sender.cc Binlog_sender::send_events
    ->sql/rpl_binlog_sender.cc Binlog_sender::before_send_hook
      ->plugin/semisync/semisync_master_plugin.cc repl_semi_before_send_event
        ->plugin/semisync/semisync_master.cc ReplSemiSyncMaster::updateSyncHeader

事务提交阶段,在flush binlog后,存储当前binlog 文件名和偏移量,更新当前最大的事务 binlog 位置。
sql/binlog.cc MYSQL_BIN_LOG::ordered_commit
 ->plugin/semisync/semisync_master_plugin.cc repl_semi_report_binlog_update//after_flush
   ->plugin/semisync/semisync_master.cc repl_semisync.writeTranxInBinlog

事务提交阶段,客户端等待处理逻辑,分为after_sync和after_commit两种情况
sql/binlog.cc MYSQL_BIN_LOG::ordered_commit
  ->sql/binlog.cc process_after_commit_stage_queue || call_after_sync_hook
    ->plugin/semisync/semisync_master_plugin.cc repl_semi_report_commit || repl_semi_report_binlog_sync
      ->plugin/semisync/semisync_master.cc ReplSemiSyncMaster::commitTrx

Slave IO线程,读取数据后后检查包头是否有semi-sync标记。
sql/rpl_slave.cc handle_slave_io
  ->plugin/semisync/semisync_slave_plugin.cc repl_semi_slave_read_event
    ->plugin/semisync/semisync_slave.cc ReplSemiSyncSlave::slaveReadSyncHeader

Slave IO线程,在queue event后,在需要回复Master ACK报文的时候,回复Master ACK报文。
sql/rpl_slave.cc handle_slave_io
  ->plugin/semisync/semisync_slave_plugin.cc repl_semi_slave_queue_event
    ->plugin/semisync/semisync_slave.cc ReplSemiSyncSlave::slaveReply

首先半同步方式,主库在等待备库ack时候,如果超时会退化为异步,这就可能导致数据丢失。在接下来分析中,先假设rpl_semi_sync_master_timeout足够大,不会退化为异步方式。

这里通过三个参数rpl_semi_sync_master_wait_point、sync_binlog、sync_relay_log的配置来对semi-sync做数据一致性的分析。

rpl_semi_sync_master_wait_point的配置

源码剖析:

plugin/semisync/semisync_master_plugin.cc

68 int repl_semi_report_binlog_sync(Binlog_storage_param *param,
69                                  const char *log_file,
70                                  my_off_t log_pos)
71 {
72   if (rpl_semi_sync_master_wait_point == WAIT_AFTER_SYNC)
73     return repl_semisync.commitTrx(log_file, log_pos);
74   return 0;
75 }

97 int repl_semi_report_commit(Trans_param *param)
   ...
102   if (rpl_semi_sync_master_wait_point == WAIT_AFTER_COMMIT &&
106     return repl_semisync.commitTrx(binlog_name, param->log_pos);

配置为WAIT_AFTER_COMMIT

after_commit.png
当rpl_semi_sync_master_wait_point为WAIT_AFTER_COMMIT时,commitTrx的调用在engine层commit之后(在ordered_commit函数中process_after_commit_stage_queue调用),如上图所示。即在等待Slave ACK时候,虽然没有返回当前客户端,但事务已经提交,其他客户端会读取到已提交事务。如果Slave端还没有读到该事务的events,同时主库发生了crash,然后切换到备库。那么之前读到的事务就不见了,出现了幻读,如下图所示。图片引自Loss-less Semi-Synchronous Replication on MySQL 5.7.2

failover.png

配置为WAIT_AFTER_SYNC

MySQL针对上述问题,在5.7.2引入了Loss-less Semi-Synchronous,在调用binlog sync之后,engine层commit之前等待Slave ACK。这样只有在确认Slave收到事务events后,事务才会提交。在commit之前等待Slave ACK,同时可以堆积事务,利于group commit,有利于提升性能。如下图所示,图片引自Loss-less Semi-Synchronous Replication on MySQL 5.7.2

after_sync.png

其实上图流程中存在着会导致主备数据不一致,使主备同步失败的情形。见下面sync_binlog配置的分析。

sync_binlog的配置

源码剖析:

sql/binlog.cc ordered_commit
       //当sync_period(sync_binlog)为1时,在sync之后update binlog end pos
9002   update_binlog_end_pos_after_sync= (get_sync_period() == 1);
       ...
9021     if (!update_binlog_end_pos_after_sync)
           //更新binlog end position,dump线程会发送更新后的events
9022       update_binlog_end_pos();
       ...
         //
9057     std::pair<bool, bool> result= sync_binlog_file(false);
       ...
9061   if (update_binlog_end_pos_after_sync)
9062   {
       ...
9068       update_binlog_end_pos(tmp_thd->get_trans_pos());
9069   }



sql/binlog.cc sync_binlog_file
8618 std::pair<bool, bool>
8619 MYSQL_BIN_LOG::sync_binlog_file(bool force)
8620 {
8621   bool synced= false;
8622   unsigned int sync_period= get_sync_period();//sync_binlog值
       //sync_period为0不做sync操作,其他值为达到sync调用次数后sync
8623   if (force || (sync_period && ++sync_counter >= sync_period))
8624   {

配置分析

当sync_binlog为0的时候,binlog sync磁盘由操作系统负责。当不为0的时候,其数值为定期sync磁盘的binlog commit group数。当sync_binlog值大于1的时候,sync binlog操作可能并没有使binlog落盘。如果没有落盘,事务在提交前,Master掉电,然后恢复,那么这个时候该事务被回滚。但是Slave上可能已经收到了该事务的events并且执行,这个时候就会出现Slave事务比Master多的情况,主备同步会失败。所以如果要保持主备一致,需要设置sync_binlog为1。

WAIT_AFTER_SYNC和WAIT_AFTER_COMMIT两图中Send Events的位置,也可能导致主备数据不一致,出现同步失败的情形。实际在rpl_semi_sync_master_wait_point分析的图中是sync binlog大于1的情况。根据上面源码,流程如下图所示。Master依次执行flush binlog, update binlog position, sync binlog。如果Master在update binlog position后,sync binlog前掉电,Master再次启动后原事务就会被回滚。但可能出现Slave获取到Events,这也会导致Slave数据比Master多,主备同步失败。

sync_after_update.png

由于上面的原因,sync_binlog设置为1的时候,MySQL会update binlog end pos after sync。流程如下图所示。这时候,对于每一个事务都需要sync binlog,同时sync binlog和网络发送events会是一个串行的过程,性能下降明显。

update_after_sync.png

sync_relay_log的配置

源码剖析

sql/rpl_slave.cc handle_slave_io

5764       if (queue_event(mi, event_buf, event_len))
           ...
5771       if (RUN_HOOK(binlog_relay_io, after_queue_event,
5772                    (thd, mi, event_buf, event_len, synced)))

after_queue_event
->plugin/semisync/semisync_slave_plugin.cc repl_semi_slave_queue_event
->plugin/semisync/semisync_slave.cc ReplSemiSyncSlave::slaveReply

queue_event
->sql/binlog.cc MYSQL_BIN_LOG::append_buffer(const char* buf, uint len, Master_info *mi)
->sql/binlog.cc after_append_to_relay_log(mi);
->sql/binlog.cc flush_and_sync(0)
->sql/binlog.cc sync_binlog_file(force)

配置分析

在Slave的IO线程中get_sync_period获得的是sync_relay_log的值,与sync_binlog对sync控制一样。当sync_relay_log不是1的时候,semisync返回给Master的position可能没有sync到磁盘。在gtid_mode下,在保证前面两个配置正确的情况下,sync_relay_log不是1的时候,仅发生Master或Slave的一次Crash并不会发生数据丢失或者主备同步失败情况。如果发生Slave没有sync relay log,Master端事务提交,客户端观察到事务提交,然后Slave端Crash。这样Slave端就会丢失掉已经回复Master ACK的事务events。

slave_crash.png

但当Slave再次启动,如果没有来得及从Master端同步丢失的事务Events,Master就Crash。这个时候,用户访问Slave就会发现数据丢失。

slave_up_master_down.png

通过上面这个Case,MySQL semisync如果要保证任意时刻发生一台机器宕机都不丢失数据,需要同时设置sync_relay_log为1。对relay log的sync操作是在queue_event中,对每个event都要sync,所以sync_relay_log设置为1的时候,事务响应时间会受到影响,对于涉及数据比较多的事务延迟会增加很多。

MySQL 三节点

在一主一从的主备semisync的数据一致性分析中放弃了高可用,当主备之间网络抖动或者一台宕机的情况下停止提供服务。要做到高可用,很自然我们可以想到一主两从,这样解决某一网络抖动或一台宕机时候的可用性问题。但是,前文叙述要保证数据一致性配置要求依然存在,即正常情况下的性能不会有改善。同时需要解决Master宕机时候,如何选取新主机的问题,如何避免多主的情形。

tri_nodes.png

选取新主机时一定要读取两个从机,看哪一个从机有最新的日志,否则可能导致数据丢失。这样的三节点方案就类似分布式Quorum机制,写的时候需要保证写成功三节点中的法定集合,确定新主的时候需要读取法定集合。利用分布式一致性协议Paxos/Raft可以解决数据一致性问题,选主问题和多主问题,因此近些年,国内数据库团队大多实现了基于Paxos/Raft的三节点方案。近来MySQL官方也以插件形式引入了支持多主集群的Group Replication方案。

总结

可以看到从replication功能引入后,官方MySQL一直在不停的完善,前进。同时我们可以发现当前原生的MySQL主备复制实现实际上很难在满足数据一致性的前提下做到高可用、高性能。

MYSQL · 新特性 · MySQL 8.0对Parser所做的改进

$
0
0

背景介绍

众所周知,MySQL Parser是利用C/C++实现的开源yacc/lex组合,也就是 GNU bison/flex。Flex负责生成tokens, Bison负责语法解析。开始介绍MySQL 8.0的新特新之前,我们先简单了解一下通用的两种Parser。一种是Bottom-up parser,另外一种是Top-down parser。

Bottom-up parser

Bottom-up解析是从parse tree底层开始向上构造,然后将每个token移进(shift),进而规约(reduce)为较大的token,最终按照语法规则的定义将所有token规约(reduce)成为一个token。移进过程是有先后顺序的,如果按照某种顺序不能将所有token规约为一个token,解析器将会回溯重新选定规约顺序。如果在规约(reduce)的过程中出现了既可以移进生成一个新的token,也可以规约为一个token,这种情况就是我们通常所说的shift/reduce conflicts.

Top-down parser

Top-down解析是从parse tree的顶层开始向下构造历。这种解析的方法是假定输入的解析字符串是符合当前定义的语法规则,按照规则的定义自顶开始逐渐向下遍历。遍历的过程中如果出现了不满足语法内部的逻辑定义,解析器就会报出语法错误。

如果愿意详细了解这两种parser的却别,可以参考https://qntm.org/top。

MySQL8.0对parser所做的改进

Bison是一个bottom-up的parser。但是由于历史原因,MySQL的语法输入是按照Top-down的方式来书写的。这样的方式导致MySQL的parser语法上有包含了很多的reduce/shift conflicts;另外由于一些空的或者冗余的规则定义也使得的MySQL parser越来越复杂。为了应对未来越来越多的语法规则,以及优化MySQL parser的解析性能,MySQL 8.0对MySQL parser做了非常大的改进。当前的MySQL 8.0.1 Milestone release的代码中对于Parser的改进仍未全部完成,还有几个相关的worklog在继续。

改进之后,MySQL parser可以达到如下状态:

  1. MySQL parser将会成为一个不涉及状态信息(即:不包含执行状态的上下文信息)的bottom-up parser;
  2. 减少parse tree上的中间节点,减少冗余规则
  3. 更少的reduce/shift conflicts
  4. 语法解析阶段,只包含以下简单操作:
    • 创建parse tree node
    • 返回解析的最终状态信息
    • 有限的访问系统变量
  5. MySQL parser执行流程将会由

SQL input -> lex. scanner -> parser -> AST (SELECT_LEX, Items etc) -> executor

变成

SQL input -> lex. scanner -> parser -> parse tree -> AST -> executor

下面我们通过看一个MySQL 8.0 中对SELECT statement所做的修改来看一下MySQL parser的改进。

SELECT statement可以说是MySQL中用处非常广泛的一个语句,比如CREATE VIEW, SELECT, CREATE TABLE, UNION, SUBQUERY等操作。 通过下图我们看一下MySQL8.0之前的版本是如何支持这些语法规则的。
5.7-select.png

MySQL8.0中对于这些语法规则的支持如下图:
select-8.0.png

通过如上两个图的对比,显然MySQL8.0的parser清爽了许多。当然我们也清晰的看到MySQL8.0中对于MySQL parser所做的改进。相同的语法规则只有一处定义,消除了过去版本中按照top-down方式书写的冗余语法定义。当然通过这样的简化也可以看到实际的效果, shift/reduce conflicts也减少了很多:
conflicts.png

下面我们看看MySQL 8.0是如何将所有的SELECT statement操作定义为一个Query specification,并为所有其他操作所引用的:

Parse tree上所有的node都定义为Parse_tree_node的子类。Parse_tree_node的结构体定义如下:

typedef Parse_tree_node_tmpl<Parse_context> Parse_tree_node; 
template<typename Context>
class Parse_tree_node_tmpl
{
...
private:
  /*
    False right after the node allocation. The contextualize/contextualize_
    function turns it into true.
  */
#ifndef DBUG_OFF
  bool contextualized;
#endif//DBUG_OFF
  /*
    这个变量是由于当前仍旧有未完成的相关worklog,parser的refactor还没有彻底完成。当前的parser中还有一部分上下文依赖的关系没有独立出来。
    等到整个parse refactor完成之后该变量就会被移除。
  */
  bool transitional; 
public:
  /*
    Memory allocation operator are overloaded to use mandatory MEM_ROOT
    parameter for cheap thread-local allocation.
    Note: We don't process memory allocation errors in refactored semantic
    actions: we defer OOM error processing like other error parse errors and
    process them all at the contextualization stage of the resulting parse
    tree.
  */
  static void *operator new(size_t size, MEM_ROOT *mem_root) throw ()
  { return alloc_root(mem_root, size); }
  static void operator delete(void *ptr,size_t size) { TRASH(ptr, size); }
  static void operator delete(void *ptr, MEM_ROOT *mem_root) {}

protected:
  Parse_tree_node()
  {
#ifndef DBUG_OFF
    contextualized= false;
    transitional= false;
#endif//DBUG_OFF
  }

public:
   ...

  /*
    True if contextualize/contextualized function has done:
  */
#ifndef DBUG_OFF
  bool is_contextualized() const { return contextualized; }
#endif//DBUG_OFF

  /*
   这个函数是需要被所有子类继承的,所有子类需要定义属于自己的上下文环境。通过调用子类的重载函数,进而初始化每个Parse tree node。
  */
  virtual bool contextualize(THD *thd);

  /**
    my_parse_error() function replacement for deferred reporting of parse
    errors

    @param      thd     current THD
    @param      pos     location of the error in lexical scanner buffers
  */
  void error(THD *thd) const;
};

当前MySQL8.0的源码中执行流程为:

mysql_parse
|
parse_sql
|
MYSQLparse
|
Parse_tree_node::contextualize() /* 经过Bison进行语法解析之后生成相应的Parse tree node。然后调用contextualize对Parse tree node进行上下文初始化。
                                   初始化上下文后形成一个AST(Abstract Syntax Tree)节点。*/

接下来我们以SELECT statement来看一下PT_SELECT_STMT::contexualize()做些什么工作:

class PT_select_stmt : public Parse_tree_node
{
	bool contextualize(Parse_context *pc)
	{
	// 这里初始化Parse_tree_node
    if (super::contextualize(pc))
      return true;

    pc->thd->lex->sql_command= m_sql_command;

	// 调用PT_query_specification来进行上下文初始化
    return m_qe->contextualize(pc) ||
      contextualize_safe(pc, m_into);
	}
private:
	PT_query_expression *m_qe;//通过m_qe来引用query_expression
}

class PT_query_expression : public Parse_tree_node
{
	...
	bool contextualize(Parse_context *pc)
	{
	  // 判断是否需要独立的名空间
      pc->select->set_braces(m_parentheses || pc->select->braces);
      m_body->set_containing_qe(this);
      if (Parse_tree_node::contextualize(pc) ||
      // 初始化SELECT主体上下文
        m_body->contextualize(pc))
      return true;
	  // 这里会初始化ORDER, LIMIT子句
      if (!contextualized && contextualize_order_and_limit(pc))
        return true;

	  // 这里会对SELECT表达式里包含的存储过程或者UDF继续进行上下文初始化
      if (contextualize_safe(pc, m_procedure_analyse))
        return true;

      if (m_procedure_analyse && pc->select->master_unit()->outer_select() != NULL)
        my_error(ER_WRONG_USAGE, MYF(0), "PROCEDURE", "subquery");

      if (m_lock_type.is_set && !pc->thd->lex->is_explain())
      {
        pc->select->set_lock_for_tables(m_lock_type.lock_type);
        pc->thd->lex->safe_to_cache_query= m_lock_type.is_safe_to_cache_query;
      }
	}
	...
private: 
  bool contextualized;
  PT_query_expression_body *m_body; /* 这个类包含了SELECT语句的主要部分,select_list, FROM, GROUP BY, HINTs等子句。
                                      这里m_body变量其实是PT_query_expression_body的子类 PT_query_expression_body_primary */
  PT_order *m_order; // ORDER BY node
  PT_limit_clause *m_limit; // LIMIT node
  PT_procedure_analyse *m_procedure_analyse; //存储过程相关
  Default_constructible_locking_clause m_lock_type;
  bool m_parentheses;

}

class PT_query_expression_body_primary : public PT_query_expression_body
{
	{
		if (PT_query_expression_body::contextualize(pc) ||
			m_query_primary->contextualize(pc))
			return true;
		return false;
	}
private:
  PT_query_primary *m_query_primary; // 这里是SELECT表达式的定义类PT_query_specification的父类
}

// PT_query_specification是SELECT表达式的定义类,它定义了SELECT表达式中绝大部分子句
class PT_query_specification : public PT_query_primary
{
  typedef PT_query_primary super;
private:
  PT_hint_list *opt_hints;
  Query_options options;
  PT_item_list *item_list;
  PT_into_destination *opt_into1;
  Mem_root_array_YY<PT_table_reference *> from_clause; // empty list for DUAL
  Item *opt_where_clause;
  PT_group *opt_group_clause;
  Item *opt_having_clause;

bool PT_query_specification::contextualize(Parse_context *pc)
{
  if (super::contextualize(pc))
    return true;

  pc->select->parsing_place= CTX_SELECT_LIST;

  if (options.query_spec_options & SELECT_HIGH_PRIORITY)
  {
    Yacc_state *yyps= &pc->thd->m_parser_state->m_yacc;
    yyps->m_lock_type= TL_READ_HIGH_PRIORITY;
    yyps->m_mdl_type= MDL_SHARED_READ;
  } 
  if (options.save_to(pc))
    return true;
  
  // 这里开始初始化SELECT list项
  if (item_list->contextualize(pc))
    return true;
  // Ensure we're resetting parsing place of the right select
  DBUG_ASSERT(pc->select->parsing_place == CTX_SELECT_LIST);
  pc->select->parsing_place= CTX_NONE;

  // 初始化SELECT INTO子句
  if (contextualize_safe(pc, opt_into1))
    return true;

  // 初始化FROM子句
  if (!from_clause.empty())
  {
    if (contextualize_array(pc, &from_clause))
      return true;
    pc->select->context.table_list=
      pc->select->context.first_name_resolution_table=
        pc->select->table_list.first;
  }

  // 初始化WHERE条件
  if (itemize_safe(pc, &opt_where_clause) ||
  // 初始化GROUP子句   
      contextualize_safe(pc, opt_group_clause) ||
  // 初始化HAVING子句
      itemize_safe(pc, &opt_having_clause))
    return true;

  pc->select->set_where_cond(opt_where_clause);
  pc->select->set_having_cond(opt_having_clause);

  // 初始化HINTs
  if (opt_hints != NULL)
  {
    if (pc->thd->lex->sql_command == SQLCOM_CREATE_VIEW)
    { // Currently this also affects ALTER VIEW.
      push_warning_printf(pc->thd, Sql_condition::SL_WARNING,
                          ER_WARN_UNSUPPORTED_HINT,
                          ER_THD(pc->thd, ER_WARN_UNSUPPORTED_HINT),
                          "CREATE or ALTER VIEW");
    }
    else if (opt_hints->contextualize(pc))
      return true;
  }
  return false;
}

综上我们以SELECT statement为例对MySQL8.0在MySQL parser方面所做的改进进行了简单介绍。这样的改进对于MySQL parser也许是一小步,但对于MySQL未来的可扩展确实是迈出了一大步。Parse tree独立出来,通过Parse tree再来构建AST,这样的方式下将简化MySQL对于Parse tree的操作,最大的受益者就是Prepared statement。等到MySQL parse的所有worklog完成之后,MySQL用户期盼多年的global prepared statement也就顺其自然实现了。

当然MySQL parser的改进让我们已经看到Oracle MySQL在对MySQL optimizier方面对于PARSER,optimizer, executor三个阶段的松解耦工作已经展开了。未来期待Optimizer生成的plan也可以像当前的parser一样成为一个纯粹的Plan,执行上下文与Plan也可以独立开来。只有到了executor阶段才生成相应的执行上下文。这样一来对于MySQL optimizer未来的可扩展势必会起到如虎添翼的作用。

MySQL · 引擎介绍 · Sphinx源码剖析(二)

$
0
0

在本节中,我将会介绍索引文件sph的生成,从上一节我们得知sph文件保存了Sphinx的索引元信息以及一些索引相关的配置信息

SPH文件生成

先来看代码,其中sph文件的生成是在CSphIndex_VLN::WriteHeader这个函数中:

	 bool CSphIndex_VLN::WriteHeader ( const BuildHeader_t & tBuildHeader, CSphWriter & fdInfo ) const
{
	// version
	fdInfo.PutDword ( INDEX_MAGIC_HEADER );
	fdInfo.PutDword ( INDEX_FORMAT_VERSION );

	// bits
	fdInfo.PutDword ( USE_64BIT );

	// docinfo
	fdInfo.PutDword ( m_tSettings.m_eDocinfo );

	// schema
	WriteSchema ( fdInfo, m_tSchema );

	// min doc
	fdInfo.PutOffset ( tBuildHeader.m_uMinDocid ); // was dword in v.1
	if ( m_tSettings.m_eDocinfo==SPH_DOCINFO_INLINE )
		fdInfo.PutBytes ( tBuildHeader.m_pMinRow, m_tSchema.GetRowSize()*sizeof(CSphRowitem) );

	// wordlist checkpoints
	fdInfo.PutOffset ( tBuildHeader.m_iDictCheckpointsOffset );
	fdInfo.PutDword ( tBuildHeader.m_iDictCheckpoints );
	fdInfo.PutByte ( tBuildHeader.m_iInfixCodepointBytes );
	fdInfo.PutDword ( (DWORD)tBuildHeader.m_iInfixBlocksOffset );
	fdInfo.PutDword ( tBuildHeader.m_iInfixBlocksWordsSize );

	// index stats
	fdInfo.PutDword ( (DWORD)tBuildHeader.m_iTotalDocuments ); // FIXME? we don't expect over 4G docs per just 1 local index
	fdInfo.PutOffset ( tBuildHeader.m_iTotalBytes );
	fdInfo.PutDword ( tBuildHeader.m_iTotalDups );

	// index settings
	SaveIndexSettings ( fdInfo, m_tSettings );

	// tokenizer info
	assert ( m_pTokenizer );
	SaveTokenizerSettings ( fdInfo, m_pTokenizer, m_tSettings.m_iEmbeddedLimit );

	// dictionary info
	assert ( m_pDict );
	SaveDictionarySettings ( fdInfo, m_pDict, false, m_tSettings.m_iEmbeddedLimit );

	fdInfo.PutDword ( tBuildHeader.m_uKillListSize );
	fdInfo.PutOffset ( tBuildHeader.m_iMinMaxIndex );

	// field filter info
	SaveFieldFilterSettings ( fdInfo, m_pFieldFilter );

	// average field lengths
	if ( m_tSettings.m_bIndexFieldLens )
		ARRAY_FOREACH ( i, m_tSchema.m_dFields )
			fdInfo.PutOffset ( m_dFieldLens[i] );

	return true;
}

然后按顺序来解释下每一项字段的含义.

  • 前两个字段INDEX_MAGIC_HEADER和INDEX_FORMAT_VERSION分别是magic number和索引版本号
  • 第三个字段USE_64BIT表示是否使用64位的document和word id(默认是使用).
  • 然后是写入docinfo,这个字段也就是配置中的docinfo字段(index block中)
  • 接下来将会写入schema,也就是索引的schema信息,比如当前索引的字段名,当前需要建立的属性名等等.
	void WriteSchema ( CSphWriter & fdInfo, const CSphSchema & tSchema )
{
	// schema
	fdInfo.PutDword ( tSchema.m_dFields.GetLength() );
	ARRAY_FOREACH ( i, tSchema.m_dFields )
		WriteSchemaColumn ( fdInfo, tSchema.m_dFields[i] );

	fdInfo.PutDword ( tSchema.GetAttrsCount() );
	for ( int i=0; i<tSchema.GetAttrsCount(); i++ )
		WriteSchemaColumn ( fdInfo, tSchema.GetAttr(i) );
}
  • 然后是写入当前索引集的最小doc id(m_uMinDocid)
  • 接下来是根据docinfo(也就是属性存储)的配置来选择是否写入行信息(当docinfo为inline的话,表示attribute value 将会存储在spd文件中).
  • 然后是写入wordlist的checkpoint.
  • 然后是索引的统计信息(m_iTotalDocuments/m_iTotalBytes/m_iTotalDups).
  • 接下来是写入对应的索引配置信息
void SaveIndexSettings ( CSphWriter & tWriter, const CSphIndexSettings & tSettings )
{
	tWriter.PutDword ( tSettings.m_iMinPrefixLen );
	tWriter.PutDword ( tSettings.m_iMinInfixLen );
	tWriter.PutDword ( tSettings.m_iMaxSubstringLen );
	tWriter.PutByte ( tSettings.m_bHtmlStrip ? 1 : 0 );
	tWriter.PutString ( tSettings.m_sHtmlIndexAttrs.cstr () );
	tWriter.PutString ( tSettings.m_sHtmlRemoveElements.cstr () );
	tWriter.PutByte ( tSettings.m_bIndexExactWords ? 1 : 0 );
	tWriter.PutDword ( tSettings.m_eHitless );
	tWriter.PutDword ( tSettings.m_eHitFormat );
	tWriter.PutByte ( tSettings.m_bIndexSP );
	tWriter.PutString ( tSettings.m_sZones );
	tWriter.PutDword ( tSettings.m_iBoundaryStep );
	tWriter.PutDword ( tSettings.m_iStopwordStep );
	tWriter.PutDword ( tSettings.m_iOvershortStep );
	tWriter.PutDword ( tSettings.m_iEmbeddedLimit );
	tWriter.PutByte ( tSettings.m_eBigramIndex );
	tWriter.PutString ( tSettings.m_sBigramWords );
	tWriter.PutByte ( tSettings.m_bIndexFieldLens );
	tWriter.PutByte ( tSettings.m_eChineseRLP );
	tWriter.PutString ( tSettings.m_sRLPContext );
	tWriter.PutString ( tSettings.m_sIndexTokenFilter );
}
  • 写入对应的tokenizer的配置信息,
void SaveTokenizerSettings ( CSphWriter & tWriter, ISphTokenizer * pTokenizer, int iEmbeddedLimit )
{
	assert ( pTokenizer );

	const CSphTokenizerSettings & tSettings = pTokenizer->GetSettings ();
	tWriter.PutByte ( tSettings.m_iType );
	tWriter.PutString ( tSettings.m_sCaseFolding.cstr () );
	tWriter.PutDword ( tSettings.m_iMinWordLen );

	bool bEmbedSynonyms = pTokenizer->GetSynFileInfo ().m_uSize<=(SphOffset_t)iEmbeddedLimit;
	tWriter.PutByte ( bEmbedSynonyms ? 1 : 0 );
	if ( bEmbedSynonyms )
		pTokenizer->WriteSynonyms ( tWriter );

	tWriter.PutString ( tSettings.m_sSynonymsFile.cstr () );
	WriteFileInfo ( tWriter, pTokenizer->GetSynFileInfo () );
	tWriter.PutString ( tSettings.m_sBoundary.cstr () );
	tWriter.PutString ( tSettings.m_sIgnoreChars.cstr () );
	tWriter.PutDword ( tSettings.m_iNgramLen );
	tWriter.PutString ( tSettings.m_sNgramChars.cstr () );
	tWriter.PutString ( tSettings.m_sBlendChars.cstr () );
	tWriter.PutString ( tSettings.m_sBlendMode.cstr () );
}
  • 写入dictionary的配置信息(比如stop word之类).
void SaveDictionarySettings ( CSphWriter & tWriter, CSphDict * pDict, bool bForceWordDict, int iEmbeddedLimit )
{
	assert ( pDict );
	const CSphDictSettings & tSettings = pDict->GetSettings ();

	tWriter.PutString ( tSettings.m_sMorphology.cstr () );
.............................

	bool bEmbedStopwords = uTotalSize<=(SphOffset_t)iEmbeddedLimit;
	tWriter.PutByte ( bEmbedStopwords ? 1 : 0 );
	if ( bEmbedStopwords )
		pDict->WriteStopwords ( tWriter );

	tWriter.PutString ( tSettings.m_sStopwords.cstr () );
	tWriter.PutDword ( dSWFileInfos.GetLength () );
	ARRAY_FOREACH ( i, dSWFileInfos )
	{
		tWriter.PutString ( dSWFileInfos[i].m_sFilename.cstr () );
		WriteFileInfo ( tWriter, dSWFileInfos[i] );
	}

	const CSphVector <CSphSavedFile> & dWFFileInfos = pDict->GetWordformsFileInfos ();
	uTotalSize = 0;
	ARRAY_FOREACH ( i, dWFFileInfos )
		uTotalSize += dWFFileInfos[i].m_uSize;

	bool bEmbedWordforms = uTotalSize<=(SphOffset_t)iEmbeddedLimit;
	tWriter.PutByte ( bEmbedWordforms ? 1 : 0 );
	if ( bEmbedWordforms )
		pDict->WriteWordforms ( tWriter );

	tWriter.PutDword ( dWFFileInfos.GetLength() );
	ARRAY_FOREACH ( i, dWFFileInfos )
	{
		tWriter.PutString ( dWFFileInfos[i].m_sFilename.cstr() );
		WriteFileInfo ( tWriter, dWFFileInfos[i] );
	}

	tWriter.PutDword ( tSettings.m_iMinStemmingLen );
	tWriter.PutByte ( tSettings.m_bWordDict || bForceWordDict );
	tWriter.PutByte ( tSettings.m_bStopwordsUnstemmed );
	tWriter.PutString ( pDict->GetMorphDataFingerprint() );
}
  • 然后是写入killlist的size(m_uKillListSize)
  • 写入m_iMinMaxIndex,这个选项也就是表示document size.
	CSphFixedVector<CSphRowitem> dMinRow ( tNewSchema.GetRowSize() );
	...............
	int iNewStride = DOCINFO_IDSIZE + tNewSchema.GetRowSize();

	int64_t iNewMinMaxIndex = m_iDocinfo * iNewStride;
..............................
	tBuildHeader.m_iMinMaxIndex = iNewMinMaxIndex;
  • 写入regex相关配置(regexp_filter)
void SaveFieldFilterSettings ( CSphWriter & tWriter, ISphFieldFilter * pFieldFilter )
{
	if ( !pFieldFilter )
	{
		tWriter.PutDword ( 0 );
		return;
	}

	CSphFieldFilterSettings tSettings;
	pFieldFilter->GetSettings ( tSettings );

	tWriter.PutDword ( tSettings.m_dRegexps.GetLength() );
	ARRAY_FOREACH ( i, tSettings.m_dRegexps )
		tWriter.PutString ( tSettings.m_dRegexps[i] );

	tWriter.PutByte(1); // deprecated utf8 flag
}
  • 最后是写入对应的schema field长度.

PgSQL · 特性分析 · checkpoint机制浅析

$
0
0

背景

上期月报PgSQL · 特性分析 · Write-Ahead Logging机制浅析中简单介绍了PostgreSQL中WAL机制,其中讲到如果是创建checkpoint会触发刷新xlog日志页到磁盘,本文主要分析下PostgreSQL中checkpoint机制。

checkpoint又名检查点,一般checkpoint会将某个时间点之前的脏数据全部刷新到磁盘,以实现数据的一致性与完整性。目前各个流行的关系型数据库都具备checkpoint功能,其主要目的是为了缩短崩溃恢复时间,以Oracle为例,在进行数据恢复时,会以最近的checkpoint为参考点执行事务前滚。而在WAL机制的浅析中,也提过PostgreSQL在崩溃恢复时会以最近的checkpoint为基础,不断应用这之后的WAL日志。

检查点发生时机

在xlog.h文件中,有如下代码对checkpoint进行了相应的分类:

/*
 * OR-able request flag bits for checkpoints.  The "cause" bits are used only
 * for logging purposes.  Note: the flags must be defined so that it's
 * sensible to OR together request flags arising from different requestors.
 */

/* These directly affect the behavior of CreateCheckPoint and subsidiaries */
#define CHECKPOINT_IS_SHUTDOWN	0x0001	/* Checkpoint is for shutdown */
#define CHECKPOINT_END_OF_RECOVERY	0x0002		/* Like shutdown checkpoint,
												 * but issued at end of WAL
												 * recovery */
#define CHECKPOINT_IMMEDIATE	0x0004	/* Do it without delays */
#define CHECKPOINT_FORCE		0x0008	/* Force even if no activity */
/* These are important to RequestCheckpoint */
#define CHECKPOINT_WAIT			0x0010	/* Wait for completion */
/* These indicate the cause of a checkpoint request */
#define CHECKPOINT_CAUSE_XLOG	0x0020	/* XLOG consumption */
#define CHECKPOINT_CAUSE_TIME	0x0040	/* Elapsed time */
#define CHECKPOINT_FLUSH_ALL	0x0080	/* Flush all pages, including those
										 * belonging to unlogged tables */

也就是说,以下几种情况会触发数据库操作系统做检查点操作:

  1. 超级用户(其他用户不可)执行CHECKPOINT命令
  2. 数据库shutdown
  3. 数据库recovery完成
  4. XLOG日志量达到了触发checkpoint阈值
  5. 周期性地进行checkpoint
  6. 需要刷新所有脏页

为了能够周期性的创建检查点,减少崩溃恢复时间,同时合并I/O,PostgreSQL提供了辅助进程checkpointer。它会对不断检测周期时间以及上面的XLOG日志量阈值是否达到,而周期时间以及XLOG日志量阈值可以通过参数来设置大小,接下来介绍下与checkpoints相关的参数。

与检查点相关参数

  • checkpoint_segments
    • WAL log的最大数量,系统默认值是3。超过该数量的WAL日志,会自动触发checkpoint。
  • checkpoint_timeout
    • 系统自动执行checkpoint之间的最大时间间隔。系统默认值是5分钟。
  • checkpoint_completion_target
    • 该参数表示checkpoint的完成时间占两次checkpoint时间间隔的比例,系统默认值是0.5,也就是说每个checkpoint需要在checkpoints间隔时间的50%内完成。
  • checkpoint_warning
    • 系统默认值是30秒,如果checkpoints的实际发生间隔小于该参数,将会在server log中写入写入一条相关信息。可以通过设置为0禁用。

创建检查点具体过程

CreateCheckPoint具体过程

当PostgreSQL触发checkpoint发生的条件后,会调用CreateCheckPoint函数创建具体的检查点,具体过程如下:

  1. 遍历所有的数据buffer,将脏页块状态从BM_DIRTY改为BM_CHECKPOINT_NEEDED,表示这些脏页将要被checkpoint刷新到磁盘
  2. 调用CheckPointGuts函数将共享内存中的脏页刷出到磁盘
  3. 生成新的Checkpoint 记录写入到XLOG中
  4. 更新控制文件、共享内存里XlogCtl的检查点相关成员、检查点的统计信息结构

PostgreSQL 控制文件pg_control里存储的数据是一个ControlFileData结构,具体如下:

typedefstruct ControlFileData
{
    uint64    system_identifier;
    uint32    pg_control_version;     /*PG_CONTROL_VERSION */
    uint32    catalog_version_no;     /* seecatversion.h */
    DBState      state;       /* see enum above */
    pg_time_t time;        /* time stamp of last pg_control update */

        XLogRecPtr	checkPoint;		/* 最近一次创建checkpoint的LSN*/
        XLogRecPtr	prevCheckPoint; /* 最近一次之前创建checkpoint的LSN */
        /*由于一个检查点的时间比较长,所以有可能系统在所有页面写完之前崩溃,这样磁盘上的检查点可能是不完全的,因此将最后一个完全检查点位置写在prevCheckPoint上*/

	CheckPoint	checkPointCopy; /* 最近一次checkpoint对应的CheckPoint对象 */

	XLogRecPtr	minRecoveryPoint;
	TimeLineID	minRecoveryPointTLI;
	XLogRecPtr	backupStartPoint;
	XLogRecPtr	backupEndPoint;
	bool		backupEndRequired;
   ......

其中,minRecoveryPoint和minRecoveryPointTLI确定数据库启动前,如果做归档恢复,我们必须恢复到的最小检查点。其中minRecoveryPoint指向该检查点对应的LSN位置,minRecoveryPointTLI指向该检查点对应的时间线。其具体的用法,我们将在之后的PostgreSQL崩溃恢复中分析,这里我们主要分析下PostgreSQL中的时间线概念。

PostgreSQL中WAL日志段名称,由时间线ID、日志ID、段ID的八位16进制数依次构成。例如:

00000001000000010000008F
时间线TimeLineID逻辑日志ID段ID

其中时间线是作为日志段名称的一部分,用来标识数据库归档恢复后产生的一系列新的WAL记录。在每次归档恢复完成后,都会产生一个新的时间线和新的WAL日志段。时间线可以理解为平行时空中的各个平行宇宙,我们完全可以恢复到某个时间点,重开一条时间线,继续进行数据操作,这样就可以实现完全的PTIR。

在PostgreSQL中,一个新的时间线产生,系统伴随它会建立一个以“新TimeLineID+.history”命名的“时间线历史”文件(timeline history),它是一个类似于txt的文件,其中包含所有在当前时间线以前的时间线,同时记录了每个时间线开始时的第一个WAL段,这样数据库恢复时,通过读取时间线历史文件文件,根据目标时间点可以快速找到正确的日志段文件。如果上一次恢复是恢复到具体某时刻,在时间线历史文件中还会记录该时间线对应的具体时刻。

在PITR恢复时,无需扫描所有WAL日志文件,而是通过时间线直接定位某个WAL段,再从该WAL段中找到符合该时间点的日志记录,这样就大大提高了效率。同时数据库恢复时,默认是沿着基备份开始时的时间点进行,即利用从基备份完成后产生的第一个日志段文件做恢复,如果想恢复到指定时间点(时间线),需要在recovery.conf配置文件中设置目标时间线(target timeline ID),但是target timeline ID不能指定为基备份以前的时间线。

CheckPointGuts函数

CheckPointGuts函数将共享内存里的数据刷出并文件同步到磁盘,具体定义如下:

staticvoid
CheckPointGuts(XLogRecPtrcheckPointRedo,int flags)
{
   CheckPointCLOG();
   CheckPointSUBTRANS();
   CheckPointMultiXact();
   CheckPointPredicate();
   CheckPointRelationMap();
   CheckPointBuffers(flags);   /* performs all required fsyncs */
   /* We deliberately delay 2PC checkpointingas long as possible */
   CheckPointTwoPhase(checkPointRedo);
}

可以看出,CheckPointGuts根据不同的缓存类型,把clog、subtrans、multixact、predicate、relationmap、buffer(数据文件)和twophase相应缓存分别调用不同的方法,将缓存刷到磁盘中:

  • 提交事务日志管理器的方法CheckPointClog
  • 子事务日志管理器的方法CheckPointSUBTRANS
  • 多事务日志管理器的方法CheckPointMultiXact
  • 支持序列化事务隔离级别的谓词锁模块的方法CheckPointPredicate
  • 目录/系统表到文件节点映射模块的方法CheckPointRelationMap
  • 缓存管理器的方法CheckPointBuffers
  • 两阶段提交模块的方法CheckPointTwoPhase

其中,前四个函数最后都调用了SLRU模块的SimpleLruFlush(简单最近最少使用)方法,把相应的共享内存数据写到磁盘,并通过调用pg_fsync方法把相应文件刷到磁盘上对应文件。

后二个函数没有使用SLRU算法,直接调用pg_fsync方法把相应文件刷到磁盘上对应文件。

而目录/系统表到文件节点映射模块的方法CheckPointRelationMap,会将共享内存里系统表和对应物理文件映射的map文件刷到磁盘。

总结

至此,我们大体了解了checkpoint的用法和整个实现过程,但是还需要对一些特别的地方做出说明。

  • 每个检查点后,第一次数据页的变化会导致整个页面会被记录在XLOG日志中
  • 检查点的开销比较高,可以用checkpoint_warning自检,相应调大checkpoint_segments
  • 检查点的位置保存在文件 pg_control,pg_control文件被损坏可能会导致数据库不可用

其中,如果pg_control文件损坏,在数据库崩溃恢复时可能出现一些问题,这些问题我们将在分析PostgreSQL数据库崩溃恢复时具体分析。

MySQL · 特性分析 · common table expression

$
0
0

common table expression

Common table expression简称CTE,由SQL:1999标准引入,
目前支持CTE的数据库有Teradata, DB2, Firebird, Microsoft SQL Server, Oracle (with recursion since 11g release 2), PostgreSQL (since 8.4), MariaDB (since 10.2), SQLite (since 3.8.3), HyperSQL and H2 (experimental), MySQL8.0.

CTE的语法如下:

WITH [RECURSIVE] with_query [, ...]
SELECT...

with_query:
query_name [ (column_name [,...]) ] AS (SELECT ...)

以下图示来自MariaDB

Non-recursive CTEs
screenshot.png

Recursive CTEs
screenshot.png

CTE的使用

  • CTE使语句更加简洁

例如以下两个语句表达的是同一语义,使用CTE比未使用CTE的嵌套查询更简洁明了。

1) 使用嵌套子查询

SELECT MAX(txt), MIN(txt)
FROM
(
  SELECT concat(cte2.txt, cte3.txt) as txt
  FROM
  (
    SELECT CONCAT(cte1.txt,'is a ') as txt
    FROM
    (
      SELECT 'This ' as txt
    ) as cte1
  ) as cte2,
  (
    SELECT 'nice query' as txt
    UNION
    SELECT 'query that rocks'
    UNION
    SELECT 'query'
  ) as cte3
) as cte4;

2) 使用CTE

WITH cte1(txt) AS (SELECT "This "),
     cte2(txt) AS (SELECT CONCAT(cte1.txt,"is a ") FROM cte1),
     cte3(txt) AS (SELECT "nice query" UNION
                   SELECT "query that rocks" UNION
                   SELECT "query"),
     cte4(txt) AS (SELECT concat(cte2.txt, cte3.txt) FROM cte2, cte3)
SELECT MAX(txt), MIN(txt) FROM cte4;
  • CTE 可以进行树形查询
    树
    初始化这颗树
create table t1(id int, value char(10), parent_id int);
insert into t1 values(1, 'A', NULL);
insert into t1 values(2, 'B', 1);
insert into t1 values(3, 'C', 1);
insert into t1 values(4, 'D', 1);
insert into t1 values(5, 'E', 2);
insert into t1 values(6, 'F', 2);
insert into t1 values(7, 'G', 4);
insert into t1 values(8, 'H', 6);

1) 层序遍历

with recursive cte as (
  select id, value, 0 as level from t1 where parent_id is null
  union all
  select t1.id, t1.value, cte.level+1 from cte join t1 on t1.parent_id=cte.id)
select * from cte;
+------+-------+-------+
| id   | value | level |
+------+-------+-------+
|    1 | A     |     0 |
|    2 | B     |     1 |
|    3 | C     |     1 |
|    4 | D     |     1 |
|    5 | E     |     2 |
|    6 | F     |     2 |
|    7 | G     |     2 |
|    8 | H     |     3 |
+------+-------+-------+

2) 深度优先遍历

with recursive cte as (
  select id, value, 0 as level, CAST(id AS CHAR(200)) AS path  from t1 where parent_id is null
  union all
  select t1.id, t1.value, cte.level+1, CONCAT(cte.path, ",", t1.id)  from cte join t1 on t1.parent_id=cte.id)
select * from cte order by path;
+------+-------+-------+---------+
| id   | value | level | path    |
+------+-------+-------+---------+
|    1 | A     |     0 | 1       |
|    2 | B     |     1 | 1,2     |
|    5 | E     |     2 | 1,2,5   |
|    6 | F     |     2 | 1,2,6   |
|    8 | H     |     3 | 1,2,6,8 |
|    3 | C     |     1 | 1,3     |
|    4 | D     |     1 | 1,4     |
|    7 | G     |     2 | 1,4,7   |
+------+-------+-------+---------+

Oracle

Oracle从9.2才开始支持CTE, 但只支持non-recursive with, 直到Oracle 11.2才完全支持CTE。但oracle 之前就支持connect by 的树形查询,recursive with 语句可以与connect by语句相互转化。 一些相互转化案例可以参考这里.

Oracle recursive with 语句不需要指定recursive关键字,可以自动识别是否recursive.

Oracle 还支持CTE相关的hint,

WITH dept_count AS (
  SELECT /*+ MATERIALIZE */ deptno, COUNT(*) AS dept_count
  FROM   emp
  GROUP BY deptno)
SELECT ...

WITH dept_count AS (
  SELECT /*+ INLINE */ deptno, COUNT(*) AS dept_count
  FROM   emp
  GROUP BY deptno)
SELECT ...

“MATERIALIZE”告诉优化器产生一个全局的临时表保存结果,多次引用CTE时直接访问临时表即可。而”INLINE”则表示每次需要解析查询CTE。

PostgreSQL

PostgreSQL从8.4开始支持CTE,PostgreSQL还扩展了CTE的功能, CTE的query中支持DML语句,例如

create table t1 (c1 int, c2 char(10));
 insert into t1 values(1,'a'),(2,'b');
 select * from t1;
 c1 | c2
----+----
  1 | a
  2 | b


 WITH cte AS (
     UPDATE t1 SET c1= c1 * 2 where c1=1
     RETURNING *
 )
 SELECT * FROM cte; //返回更新的值
 c1 |     c2
----+------------
  2 | a

 truncate table t1;
 insert into t1 values(1,'a'),(2,'b');
 WITH cte AS (
     UPDATE t1 SET c1= c1 * 2 where c1=1
     RETURNING *
 )
 SELECT * FROM t1;//返回原值
 c1 |     c2
----+------------
  1 | a
  2 | b


 truncate table t1;
 insert into t1 values(1,'a'),(2,'b');
 WITH cte AS (
     DELETE FROM t1
     WHERE c1=1
     RETURNING *
 )
 SELECT * FROM cte;//返回删除的行
 c1 |     c2
----+------------
  1 | a


 truncate table t1;
 insert into t1 values(1,'a'),(2,'b');
 WITH cte AS (
     DELETE FROM t1
     WHERE c1=1
     RETURNING *
 )
 SELECT * FROM t1;//返回原值
 c1 |     c2
----+------------
  1 | a
  2 | b
(2 rows)

MariaDB

MariaDB从10.2开始支持CTE。10.2.1 支持non-recursive CTE, 10.2.2开始支持recursive CTE。 目前的GA的版本是10.1.

MySQL

MySQL从8.0开始支持完整的CTE。MySQL8.0还在development
阶段,RC都没有,GA还需时日。

AliSQL

AliSQL基于mariadb10.2, port了no-recursive CTE的实现,此功能近期会上线。

以下从源码主要相关函数简要介绍其实现,

//解析识别with table引用
find_table_def_in_with_clauses

//检查依赖关系,比如不能重复定义with table名字
With_clause::check_dependencies

// 为每个引用clone一份定义
With_element::clone_parsed_spec

//替换with table指定的列名
With_element::rename_columns_of_derived_unit

此实现对于多次引用CTE,CTE会解析多次,因此此版本CTE有简化SQL的作用,但效率上没有效提高。

select count(*) from t1 where c2 !='z';
+----------+
| count(*) |
+----------+
|    65536 |
+----------+
1 row in set (0.25 sec)

//从执行时间来看是进行了3次全表扫描
 with t as (select count(*) from t1 where c2 !='z')
     select * from t union select * from t union select * from t;
+----------+
| count(*) |
+----------+
|    65536 |
+----------+
1 row in set (0.59 sec)

 select count(*) from t1 where c2 !='z'
     union
     select count(*) from t1 where c2 !='z'
     union
    select count(*) from t1 where c2 !='z';
+----------+
| count(*) |
+----------+
|    65536 |
+----------+
1 row in set (0.57 sec)

 explain with t as (select count(*) from t1 where c2 !='z')
    -> select * from t union select * from t union select * from t;
+------+-----------------+--------------+------+---------------+------+---------+------+-------+-------------+
| id   | select_type     | table        | type | possible_keys | key  | key_len | ref  | rows  | Extra       |
+------+-----------------+--------------+------+---------------+------+---------+------+-------+-------------+
|    1 | PRIMARY         | <derived2>   | ALL  | NULL          | NULL | NULL    | NULL | 65536 |             |
|    2 | SUBQUERY        | t1           | ALL  | NULL          | NULL | NULL    | NULL | 65536 | Using where |
|    3 | RECURSIVE UNION | <derived5>   | ALL  | NULL          | NULL | NULL    | NULL | 65536 |             |
|    5 | SUBQUERY        | t1           | ALL  | NULL          | NULL | NULL    | NULL | 65536 | Using where |
|    4 | RECURSIVE UNION | <derived6>   | ALL  | NULL          | NULL | NULL    | NULL | 65536 |             |
|    6 | SUBQUERY        | t1           | ALL  | NULL          | NULL | NULL    | NULL | 65536 | Using where |
| NULL | UNION RESULT    | <union1,3,4> | ALL  | NULL          | NULL | NULL    | NULL |  NULL |             |
+------+-----------------+--------------+------+---------------+------+---------+------+-------+-------------+
7 rows in set (0.00 sec)

 explain  select count(*) from t1 where c2 !='z'
    union
    select count(*) from t1 where c2 !='z'
    union
    select count(*) from t1 where c2 !='z';
+------+--------------+--------------+------+---------------+------+---------+------+-------+-------------+
| id   | select_type  | table        | type | possible_keys | key  | key_len | ref  | rows  | Extra       |
+------+--------------+--------------+------+---------------+------+---------+------+-------+-------------+
|    1 | PRIMARY      | t1           | ALL  | NULL          | NULL | NULL    | NULL | 65536 | Using where |
|    2 | UNION        | t1           | ALL  | NULL          | NULL | NULL    | NULL | 65536 | Using where |
|    3 | UNION        | t1           | ALL  | NULL          | NULL | NULL    | NULL | 65536 | Using where |
| NULL | UNION RESULT | <union1,2,3> | ALL  | NULL          | NULL | NULL    | NULL |  NULL |             |
+------+--------------+--------------+------+---------------+------+---------+------+-------+-------------+
4 rows in set (0.00 sec)

以下是MySQL8.0 只扫描一次的执行计划

mysql> explain select count(*) from t1 where c2 !='z' union select count(*) from t1 where c2 !='z' union select count(*) from t1 where c2 !='z';
+----+--------------+--------------+------------+------+---------------+------+---------+------+-------+----------+-----------------+
| id | select_type  | table        | partitions | type | possible_keys | key  | key_len | ref  | rows  | filtered | Extra           |
+----+--------------+--------------+------------+------+---------------+------+---------+------+-------+----------+-----------------+
|  1 | PRIMARY      | t1           | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 62836 |    90.00 | Using where     |
|  2 | UNION        | t1           | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 62836 |    90.00 | Using where     |
|  3 | UNION        | t1           | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 62836 |    90.00 | Using where     |
| NULL | UNION RESULT | <union1,2,3> | NULL       | ALL  | NULL          | NULL | NULL    | NULL |  NULL |     NULL | Using temporary |
+----+--------------+--------------+------------+------+---------------+------+---------+------+-------+----------+-----------------+
4 rows in set, 1 warning (0.00 sec)

以下是PostgreSQL9.4 只扫描一次的执行计划

postgres=# explain with t as (select count(*) from t1 where c2 !='z')
postgres-# select * from t union select * from t union select * from t;
 HashAggregate  (cost=391366.28..391366.31 rows=3 width=8)
   Group Key: t.count
   CTE t
     ->  Aggregate  (cost=391366.17..391366.18 rows=1 width=0)
           ->  Seq Scan on t1  (cost=0.00..384392.81 rows=2789345 width=0)
                 Filter: ((c2)::text <> 'z'::text)
   ->  Append  (cost=0.00..0.09 rows=3 width=8)
         ->  CTE Scan on t  (cost=0.00..0.02 rows=1 width=8)
         ->  CTE Scan on t t_1  (cost=0.00..0.02 rows=1 width=8)
         ->  CTE Scan on t t_2  (cost=0.00..0.02 rows=1 width=8)

AliSQL还有待改进。


PgSQL · 应用案例 · 逻辑订阅给业务架构带来了什么?

$
0
0

背景

逻辑订阅是PostgreSQL 10.0的新特性。

具体的原理,使用方法可以参考如下文章。

《PostgreSQL 10.0 preview 逻辑订阅 - 原理与最佳实践》

《PostgreSQL 10.0 preview 逻辑订阅 - pg_hba.conf变化,不再使用replication条目》

《PostgreSQL 10.0 preview 逻辑订阅 - 备库支持逻辑订阅,订阅支持主备漂移了》

《PostgreSQL 10.0 preview 逻辑订阅 - 支持并行COPY初始化数据》

PostgreSQL 早在2010年就支持了物理流式复制,可以用来支持容灾、读写分离、HA等业务场景。为什么还需要逻辑订阅的功能呢?

逻辑订阅和物理流复制有什么差别

1. 差别1,物理复制目前只能做到整个集群的复制。逻辑订阅可以做到表级。

物理流式复制是基于REDO的块级别复制,复制出来的数据库与上游数据库一模一样,每个块都是一样的,就好像克隆的一样。

物理流式复制,目前只能做到整个集群的复制,虽然技术上来讲也可以做到按表空间、按数据库级别的复制,目前PG社区还没有这么做。

PS:外围的公司有这样的插件,walbouncer,支持物理的partial replication。

http://www.cybertec.at/en/products/walbouncer-enterprise-grade-partial-replication/

pic

2. 差别2,物理复制的备库只读,不能写入。逻辑订阅读写都可以。

3. 差别3,物理复制不需要等待事务提交,即可将REDO发往备库,备库也可以直接apply它。逻辑订阅,目前需要等待事务提交后,发布端才会使用wal_sender进程将decode后的数据发送给订阅端,订阅端流式接收与流式apply。

将来逻辑订阅也可能不需要等事务结束就apply,因为在CLOG中是有2个BIT记录事务状态的。

/*
 * Possible transaction statuses --- note that all-zeroes is the initial
 * state.
 *
 * A "subcommitted" transaction is a committed subtransaction whose parent
 * hasn't committed or aborted yet.
 */
typedef int XidStatus;

#define TRANSACTION_STATUS_IN_PROGRESS          0x00
#define TRANSACTION_STATUS_COMMITTED            0x01
#define TRANSACTION_STATUS_ABORTED                      0x02
#define TRANSACTION_STATUS_SUB_COMMITTED        0x03

4. 差别4,物理复制,需要复制所有的REDO(包括回滚的)。逻辑订阅,不需要复制所有的REDO,仅仅需要复制”订阅表产生的REDO解析后的数据”(并且回滚的事务不会被复制)。

5. 差别5,如果要支持逻辑订阅,需要配置wal_level=logical,如果仅仅需要物理复制,则配置wal_level=replica即可。

逻辑订阅需要产生额外的REDO信息(通过alter或create table指定pk, 或 full row)。而物理复制,不需要在REDO中记录这些信息。

逻辑订阅对主库的性能影响比物理复制更较大的。

REPLICA IDENTITY

This form changes the information which is written to the write-ahead log to identify rows which are updated or deleted. 

This option has no effect except when logical replication is in use. 

DEFAULT (the default for non-system tables) records the old values of the columns of the primary key, if any.  -- 普通表默认使用PK作为old value

USING INDEX records the old values of the columns covered by the named index, which must be unique, not partial, not deferrable, and include only columns marked NOT NULL. 

FULL records the old values of all columns in the row. 

NOTHING records no information about the old row. (This is the default for system tables.) -- 默认情况下系统表的变更不记录old row

In all cases, no old values are logged unless at least one of the columns that would be logged differs between the old and new versions of the row.

6. 差别6,对于大事务,物理复制的延迟比逻辑订阅更低。因为差别3。

7. 差别7,逻辑订阅需要用到发布端的catalog,将REDO翻译为可供订阅者使用的ROW格式,如果在订阅端与发布端没有同步前,发布端的TABLE定义发生了变化或者被删除了,翻译REDO的工作将无法进行下去。PostgreSQL使用多版本来解决这样的问题,允许CATALOG的变更,但是版本被保留到订阅端不需要它为止(PG内部通过LSN来实现)。如果你发现发布端的CATALOG膨胀了,可以从这方面找一下原因(是不是订阅太慢或者订阅者停止订阅了,同时期间发布端产生了大量的DDL操作)。

物理复制不存在这样的问题。

8. 差别8,物理复制的备库,如果要被用来只读的话,为了避免备库LONG QUERY与vacuum redo发生冲突,有两种解决方案,都有一定的损伤。1,主库延迟VACUUM,一定程度上导致主库膨胀。2,备库APPLY礼让QUERY,一定程度上导致备库APPLY延迟。

逻辑订阅不存在以上情形的冲突。

逻辑订阅与物理流复制的定位差别

逻辑订阅,适合于发布端与订阅端都有读写的情况。

逻辑订阅,更适合于小事务,或者低密度写(轻度写)的同步。如果有大事务、高密度写,逻辑订阅的延迟相比物理复制更高。

逻辑订阅,适合于双向,多向同步。

物理复制,适合于单向同步。

物理复制,适合于任意事务,任意密度写(重度写)的同步。

物理复制,适合于HA、容灾、读写分离。

物理复制,适合于备库没有写,只有读的场景。

逻辑订阅给业务架构带来了什么

1. 多个业务之间,有少量的数据需要同步时,逻辑订阅可以解决这样的问题。

例如A业务和B业务,分别使用两个数据库,但是他们有少量的数据是共用的。而且都要对这部分共享数据进行读写。

pic

2. 数据汇总,例如多个业务库的FEED数据,要汇总到一个分析库。以往可能要构建庞大的ETL和调度系统,并且很难做到实时的同步。现在有了逻辑订阅,可以方便的应对这样的场景。

(PostgreSQL 的多个特性表名,它正在朝着HTAP的方向发展,既能高效的处理OLTP在线业务,也能处理分析业务。(LLVM、向量计算、列存储、多核并行、算子复用等一系列的特性支持OLAP))

pic

3. 数据拆分,与数据汇总刚好相反,比如在垂直拆分时,使用逻辑订阅,可以将一个集中式的数据库拆分成多个数据库。由于逻辑订阅是增量的,可以节约拆分过程的停机时间。

另外还有些业务场景,在端上可能不会部署那么多的小数据库,统统往一个库里写。下游接一些小的数据库,是要逻辑订阅,也能很好的满足此类需求。

pic

4. 多活架构中,最痛苦的实际上是数据库,为什么呢?

比如一个游戏业务,可能在全国都有IDC,而认证或者账务系统可能还是集中式的,如果要拆分成多个库,就会涉及到数据一致性和完整性的问题。

比如按用户的首次注册地,将数据库分为多个区域。根据用户的登陆来源IP,路由到相应的IDC(访问这个IDC中的数据库),这个用户如果是固定用户还好,因为注册地和使用地基本是不变的。对于手机游戏就扯淡了,因为登陆地不断的变化,比如出差,原来在杭州登陆的,跑北京登陆了。而北京机房并没有该用户的信息。业务层面就需要解决这样的问题。

使用逻辑订阅,可以很好的解决这个场景的问题,每个IDC中的数据都是完整的,当用户在杭州时,读写杭州的数据库,通过订阅数据复制到北京的机房。当用户漫游到北京时,读写北京的数据库,通过订阅复制到杭州的机房。

pic

5. 有些企业,在云上有数据库,在线下也有数据库,甚至在多个云厂商都有数据库。应了一句话,不要将鸡蛋放在同一个篮子里。

那么这些数据库的数据如何在多个域之间同步呢?使用物理复制是很难做到的,存在一些不可避免的问题:1,云厂商的数据库内核可能修改过,物理复制不一定兼容。2,不同厂商的版本可能不兼容。3,数据库编译时的数据块大小可能不一样导致不兼容。4,背后使用的插件可能不一样,导致不兼容。5,云厂商不一定会开放物理复制的接口。

逻辑订阅规避了以上问题:1,逻辑订阅可以跨版本。2,逻辑订阅不管数据块的大小是否一样,都没有问题。

pic

6. 从云上将数据迁移到线下,或者从线下将数据迁移到云上。

使用逻辑订阅,可以实现增量的迁移。减少迁移的业务停机时间。

7. 跨版本、跨平台升级。

跨版本升级,使用逻辑订阅,增量迁移,可以减少升级版本的业务停机时间。

8. 数据分享给其他的产品,例如缓存、搜索引擎、流计算平台。

使用逻辑订阅,可以实时将数据分享给其他的业务平台,BottledWater-pg就是一个很好的例子。

pic

9. 数据库的热插拔。类似Oracle 12C的cdb架构。PostgreSQL cluster 对应Oracle 12c的CDB,cluster中的database对应Oracle 12c的PDB。

当需要将一个CLUSTER的database拔出时,通过订阅方式复制到其他的CLUSTER。

pic

例子

1. 创建订阅

2. 接近同步后将数据库设置为只读
postgres=# alter database src set default_transaction_read_only =true;
ALTER DATABASE
 
3. 断开已有连接
pg_terminate_backend(pid) 断开所有与被迁移库连接的已有连接。
  
4. 一致性迁移完成

逻辑订阅例子

逻辑订阅只需简单两步即可完成。

1. 建表、发布

src=# create table public.t1(id int primary key, info text, crt_time timestamp);  
CREATE TABLE  
  
src=# create publication pub1 for table public.t1;  
CREATE PUBLICATION  

2. 建表、订阅

dst=# create table public.t1(id int primary key, info text, crt_time timestamp);  
CREATE TABLE  
  
dst=# create subscription sub1_from_pub1 connection 'hostaddr=xxx.xxx.xxx.xxx port=1922 user=postgres dbname=src' publication pub1 with (enabled, create slot, slot name='sub1_from_pub1');  
NOTICE:  created replication slot "sub1_from_pub1" on publisher  
CREATE SUBSCRIPTION  

详情请参考

《PostgreSQL 10.0 preview 逻辑订阅 - 原理与最佳实践》

逻辑订阅的冲突解决

逻辑订阅,本质上是事务层级的复制,需要在订阅端执行SQL。

如果订阅端执行SQL失败(或者说引发了任何错误,包括约束等),都会导致该订阅暂停。

注意,update, delete没有匹配的记录时,不会报错,也不会导致订阅暂停。

用户可以在订阅端数据库日志中查看错误原因。

冲突修复方法

1. 通过修改订阅端的数据,解决冲突。例如insert违反了唯一约束时,可以删除订阅端造成唯一约束冲突的记录先DELETE掉。然后使用ALTER SUBSCRIPTION name ENABLE让订阅继续。

2. 在订阅端调用pg_replication_origin_advance(node_name text, pos pg_lsn)函数,node_name就是subscription name,pos指重新开始的LSN,从而跳过有冲突的事务。

pg_replication_origin_advance(node_name text, pos pg_lsn)           
    
Set replication progress for the given node to the given position.     
    
This primarily is useful for setting up the initial position or a new position after configuration changes and similar.     
    
Be aware that careless use of this function can lead to inconsistently replicated data.    

当前的lsn通过pg_replication_origin_status.remote_lsn查看。

https://www.postgresql.org/docs/devel/static/view-pg-replication-origin-status.html

参考

《PostgreSQL 10.0 preview 逻辑订阅 - 原理与最佳实践》

《PostgreSQL 10.0 preview 逻辑订阅 - pg_hba.conf变化,不再使用replication条目》

《PostgreSQL 10.0 preview 逻辑订阅 - 备库支持逻辑订阅,订阅支持主备漂移了》

《PostgreSQL 10.0 preview 逻辑订阅 - 支持并行COPY初始化数据》

MSSQL · 应用案例 · 基于内存优化表的列存储索引分析Web Access Log

$
0
0

问题引入

在日常的网站运维工作中,我们需要对网站客户端访问情况做统计、汇总、分析和报表展示,以数据来全面掌控网站运营和访问情况。当不可预知的意外情况发生时,我们可以快速发现问题以及采取相应的措施。比如:当网站受到黑客攻击时的流量陡增,又或者是网站某个资源发生意外抛异常等情况。
在提供Web服务的服务器上,比如IIS、Apache都存在访问日志记录,这篇是文章是以SQL Server 2016基于内存优化表的列存储索引来分析Apache Web Access Log为例,讲解分析网站访问情况,因此,我们需要解决以下几个问题:

  • Apache Web Access Log格式介绍

  • 列存储索引表结构的设计

  • Apache Web Access Log导入到列存储索引表

  • 网站访问流量统计

  • 客户端主机访问的分布情况

  • 客户端主机访问的资源统计

  • 异常URI访问统计

  • Response Code分布情况

日志格式介绍

在设计基于内存优化表的列存储索引表结构之前,我们首先必须要对Apache Web Access Log服务器普通日志格式了解得非常清楚,以日志结构来建立我们SQL Server 2016的列存储索引表结构,在此,仅以一条日志记录格式来分析:

## 通用日志格式
LogFormat "%h %l %u %t \"%r\" %>s %b" common

## 其中一条日志举例
64.242.88.10 - - [07/Mar/2004:16:47:12 -0800] "GET /robots.txt HTTP/1.1" 200 68
......

其中:

  • %h:发送请求到服务器的客户端主机(IP或者是主机名),本例中的64.242.88.10;

  • %l:确定访问用户的标识(因为不可靠,通常不会使用,用中划线来填充),本例中的第一个中划线;

  • %u:由HTTP认证确定的用户名称,本例中的第二个中划线;

  • %t:服务器端收到客户端请求的时间点,格式为:[day/month/year:hour:minute:second zone],本例中的[07/Mar/2004:16:47:12 -0800];

  • %r:置于双引号之间的请求详细信息,包括三部分:请求方法、请求的资源和客户端协议及版本。本例中的”GET /robots.txt HTTP/1.1”;

  • %>s:返回的Response Code,比如本例中200表示访问成功;

  • %b:返回给客户端的对象大小,不包含HTTP Header,单位为byte,本例中获取了68 byte资源。

基于内存优化表的列存储索引表结构设计

基于以上对Apache Web Access Log格式的分析,我们可以建立格式对等的基于内存优化表的列存储索引表。这种类型的表数据会按列压缩存放在内存中,可以大大减少OLAP查询对IOPS的消耗,提高OLAP分析查询的性能。其表结构如下所示:

USE CCSI2016
GO
DROP TABLE IF EXISTS dbo.WebAccessLOG
GO

CREATE TABLE dbo.WebAccessLOG (
[LogId] BIGINT IDENTITY(1,1) NOT NULL,
[RemoteHost] [varchar](100) NULL,
[UserIdentity] varchar(10) NULL,
[UserName] varchar(10) NULL,
[RequestTime] varchar(50) NULL,
[Timezone] varchar(10) NULL,
[Action] varchar(10) NULL,
[URI] VARCHAR(1000) NULL,
[Version] VARCHAR(20) NULL,
[StatusCode] varchar(5) NULL,
[Size_Byte] INT NULL,
[Indate] DATETIME NOT NULL CONSTRAINT DF_Indate DEFAULT(GETDATE()),
CONSTRAINT PK_WebAccessLOG PRIMARY KEY NONCLUSTERED HASH ([LogId]) WITH (BUCKET_COUNT = 10000000)
)WITH(MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_AND_DATA) ;
GO

ALTER TABLE dbo.WebAccessLOG
	ADD INDEX CCSI_WebAccessLOG CLUSTERED COLUMNSTORE
;
GO

在建表过程中,我们设置MEMORY_OPTIMIZED = ON,表示该表为内存优化表,此类表数据会存放在内存中;DURABILITY = SCHEMA_AND_DATA表示,我们需要持久化表结构和数据到磁盘上,以防止服务意外终止而导致的数据丢失;最后一句ALTER TABLE ADD INDEX CLUSTERED COLUMNSTORE表示为该内存优化表建立聚集列存储索引,此类型表数据会被压缩存放在内存中。

导入日志信息到列存储索引表

我们完成了基于内存优化表的列存储索引表设计以后,接下来,我们需要将Apache Web Access Log文件导入到该表中。由于Log文件不带表头,第一行就直接是数据;每行之间的信息以空格分割;行与行之间以换行分割,所以,我们可以使用BULK INSERT的方式将Log文件导入列存储索引表。方法如下:

USE CCSI2016
GO
-- Create view base on log table
DROP VIEW IF EXISTS dbo.[UV_WebAccessLOG]
GO
CREATE VIEW [dbo].[UV_WebAccessLOG]
AS
SELECT [RemoteHost]
	,[UserIdentity]
	,[UserName]
	,[RequestTime]
	,[Timezone]
	,[Action]
	,[URI]
	,[Version]
	,[StatusCode]
	,[Size_Byte]
FROM CCSI2016.dbo.WebAccessLOG WITH(NOLOCK)
GO

-- BULK INSERT Log into view
BULK INSERT dbo.[UV_WebAccessLOG]
FROM 'C:\Temp\access_log'
WITH (
 FIRSTROW = 1,
 FIELDTERMINATOR = '',
 ROWTERMINATOR = '\n'
)

-- Init data
;WITH DATA
AS(
	SELECT TOP (1545) LogId
	FROM CCSI2016.dbo.WebAccessLOG AS A
	ORDER BY Indate DESC
)
UPDATE TOP(1545) A
SET RequestTime = REPLACE(RequestTime, '[', '')
FROM CCSI2016.dbo.WebAccessLOG AS A
WHERE LogId IN(SELECT LogId FROM DATA)

代码解释:由于列存储索引表增加了自增列LogId和时间字段Indate,我们无法直接将数据BULK INSERT到正式表,需要建立视图dbo.[UV_WebAccessLOG]来作为中间桥梁;数据导入完毕后,由于RequestTime字段含有中括号左半部分,我们需要将中括号刷洗掉。至此,列存储索引表创建完毕,访问Log日志也已经导入,接下来就是详细的统计分析了。

网站流量统计分析

网站的流量统计是以时间为单位统计所有客户端访问网站的点击数量和以此获取到的资源总流量大小。时间单位可以小到每秒,大到每小时或者每天为单位来统计,这个统计值可以数据化网站的访问流量,随时监控网站是否有意外发生,或者是意外的突发访问,比如:被黑客攻击导致流量突然增大。在此,仅以天为时间单位,描述网站流量统计分析的方法。

USE CCSI2016
GO
DROP PROCEDURE IF EXISTS dbo.UP_LoadingAnalysis
GO
CREATE PROCEDURE dbo.UP_LoadingAnalysis 
WITH NATIVE_COMPILATION, SCHEMABINDING, EXECUTE AS OWNER 
AS BEGIN ATOMIC 
WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english')
	SELECT
		Day = CONVERT(CHAR(10), RequestTime, 120) 
		, minSize = CAST(MIN(Size_Byte) / 1024. AS DECIMAL(8, 2))
		, maxSize = CAST(MAX(Size_Byte) / 1024. AS DECIMAL(8, 2))
		, avgSize = CAST(AVG(Size_Byte) / 1024. AS DECIMAL(8, 2))
		, sumSize = CAST(SUM(Size_Byte) / 1024. AS DECIMAL(8, 2))
		, NoOfHits = COUNT(1)
	FROM dbo.WebAccessLOG
	GROUP BY CONVERT(CHAR(10), RequestTime, 120)
	ORDER BY 1 ASC
END
GO

单独执行该存储过程,返回的结果如下图所示:

01.png

将返回的结果,做成一个Chart图表,如下图所示:

02.png

从返回的数据结果集和做出的图表展示,我们很容易发现2004年3月8号这一天无论是点击率还是网站流量都是6天内最高的。那么,对这些流量贡献排名前十的是哪些客户端机器呢?请看下一小节。

客户端主机访问分布情况

流量统计部分只能回答“哪个时间段流量超标”的问题,如果我们需要知道流量超标时间段内,到底是哪个或者哪些客户端主机访问量过大,客户端主机访问流量分布情况如何?在此,我们以2004年3月8号为例,分析客户端主机访问分布情况,代码如下所示:

USE CCSI2016
GO
DROP PROCEDURE IF EXISTS dbo.UP_FrequentAccessHosts
GO
CREATE PROCEDURE dbo.UP_FrequentAccessHosts 
WITH NATIVE_COMPILATION, SCHEMABINDING, EXECUTE AS OWNER 
AS BEGIN ATOMIC 
WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english')

	SELECT RemoteHost
		, NoOfAccess = COUNT(1)
		, Size = cast(SUM(Size_Byte)/ 1024. as decimal(8,2))
	FROM dbo.WebAccessLOG
	WHERE [RequestTime] >= '08/Mar/2004' 
		AND [RequestTime] <= '09/Mar/2004'
	GROUP BY RemoteHost
	HAVING COUNT(1) >= 10
	ORDER BY RemoteHost ASC
	
END 
GO

执行该存储过程,返回如下的结果集:

03.png

将这个返回的结果集,做成图表展示如下图所示:

04.png

从返回的结果集和图表展示,我们很容易得出,来自客户端机器64.242.88.10的点击率和访问流量远远高于其他的客户端。至此,我们已经成功的找到了访问量最大的客户端机器IP地址。我们可以针对这个客户端主机进行分析,看看是否存在黑客攻击行为,如果存在,可以考虑从网络层禁止这个IP访问网站资源。那么,客户端主机访问的是哪些网站资源呢?请继续查看下一节。

客户端主机访问的资源

根据客户端主机访问分布情况部分,我们已经找到访问量最大的某个或者某几个客户端主机,接下来我们需要回答“客户端主机访问的Web资源是哪些?经常被频繁访问的资源集中在哪些URI上?”。如果能够找出这两个问题,我们可以考量将对应的资源放到缓存设备中,以此来增加缓存的命中率,提高客户机访问网站资源的速度。方法如下:

USE CCSI2016
GO
DROP PROCEDURE IF EXISTS dbo.UP_FrequentAccessResouceURI
GO
CREATE PROCEDURE dbo.UP_FrequentAccessResouceURI 
WITH NATIVE_COMPILATION, SCHEMABINDING, EXECUTE AS OWNER 
AS BEGIN ATOMIC 
WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english')
	-- TOP 10 URI
	SELECT TOP 10
			URI
			, NoOfHits = COUNT(1)
			, Size = CAST(SUM(Size_Byte)/ 1024. as decimal(8,2))
	FROM dbo.WebAccessLOG
	GROUP BY URI
	ORDER BY 2 DESC
END
GO

执行该存储过程,返回如下结果集:

05.png

依据该结果集,做成图表,展示如下图所示:

06.png

从结果集和图表展示的统计结果来看,点击率最高的是获取/twiki/pub/TWiki/TWikiLogos/twikiRobot46x50.gif资源的时候,而流量最大集中在对资源/twiki/bin/view/Main/WebHome的访问上。

Response Code分布情况

在另一个方面,网站客户端主机访问成功率是衡量一个网站是否正常工作很重要的指标,我们可以统计客户端访问HTTP服务的Response Code分布情况,来获取客户端主机访问成功率,以此来判断HTTP服务工作情况是否良好。方法如下:

USE CCSI2016
GO
DROP PROCEDURE IF EXISTS dbo.UP_ResponseCodeAnalysis
GO
CREATE PROCEDURE dbo.UP_ResponseCodeAnalysis 
WITH NATIVE_COMPILATION, SCHEMABINDING, EXECUTE AS OWNER 
AS BEGIN ATOMIC 
WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english')
	SELECT
		 StatusCode
		, ResponseCodeCount = COUNT(1)
	FROM dbo.WebAccessLOG
	GROUP BY StatusCode
	ORDER BY 1 ASC
END
GO

执行该存储过程,返回的结果集如下所示:

07.png

将该存储过程返回的结果集,做成图表如下所示:

08.png

从存储过程执行的结果集和展示的图表来看,资源访问成功率(返回为200的概率)仅为82.46%,换句话说,100个客户端访问中,仅有82.46个是成功访问,成功率过低,还有很大的提升空间。因此,我们需要深入调查到底是访问哪些URI导致了错误发生?请看下一小节。

报错排名前十的URI

有时候,访问我们的Web服务资源的时候,会发生很多意外情况(返回值不是200),我们需要对这些错误的发生有全面的掌控,比如:统计Web站点上发生错误次数排名前十的资源有哪些?分析出这个问题的答案以后,我们就可针对错误的资源,定向查找访问失败的原因。

USE CCSI2016
GO
DROP PROCEDURE IF EXISTS dbo.UP_FrequentExceptionURI
GO
CREATE PROCEDURE dbo.UP_FrequentExceptionURI
WITH NATIVE_COMPILATION, SCHEMABINDING, EXECUTE AS OWNER 
AS BEGIN ATOMIC 
WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english')
	SELECT TOP 10
			URI
			, NoOfHits = COUNT(1)
			, Size = CAST(SUM(Size_Byte)/ 1024. as decimal(8,2))
	FROM dbo.WebAccessLOG
	WHERE StatusCode <> 200
	GROUP BY URI
	ORDER BY 2 DESC
END
GO

执行该存储过程,返回如下结果集:

09.png

将该结果集,做成图表,展示如下所示:

10.png

从存储过程返回的结果集和图表展示的统计结果来看,资源/twiki/pub/TWiki/TWikiLogos/twikiRobot46x50.gif点击发生的错误最多,而资源/twiki/bin/edit/Main/PostConf?topicparent=Main.PostfixCommands发生的错误流量最大。所以最终,我们找到了经常报错的一些URI资源,我们需要解决这些错误,最终达到提高客户端访问成功率的目的。

最后总结

本篇月报是SQL Server列存储索引系列月报的最后一篇,介绍SQL Server 2016基于内存优化表的列存储索引的应用案例,分析Apache Web Access Log,以此来帮助我们分析和掌控网站的运行情况。至此,我们总共分析了四篇关于SQL Server列存储技术,跨度从SQL Server 2012到SQL Server 2014,最终到SQL Server 2016。

SQL Server · 特性分析 · 2012列存储索引技术:介绍SQL Server 2012列存储索引技术。

SQL Server · 特性介绍 · 聚集列存储索引:介绍SQL Server 2014中的聚集列存储索引技术。

MSSQL · 特性分析 · 列存储技术做实时分析:介绍了SQL Server 2016列存储索引技术在实时分析场景中应用。

参考文档

Log Files:Apache Web Access Log的日志格式介绍。

Import and analyze IIS Log files using SQL Server:基于内存优化表的列存储索引表结构设计。

Apache (Unix) Log Samples:本篇文章分析的Apache Web Access Log样例数据。

TokuDB · 捉虫动态 · MRR 导致查询失败

$
0
0

问题背景

最近有用户在使用 TokuDB 时,遇到了一个查询报错的问题,这里给大家分享下。

具体的报错信息是这样的:

mysql> select * from t2 where uid > 1 limit 10;
ERROR 1030 (HY000): Got error 1 from storage engine

表结构如下:

CREATE TABLE `t2` (
  `id` bigint(20) NOT NULL,
  `uid` bigint(20) DEFAULT NULL,
  `post` text,
  `note` text,
  PRIMARY KEY (`id`),
  KEY `idx_uid` (`uid`)
) ENGINE=TokuDB DEFAULT CHARSET=utf8

问题分析

从报错信息来看,是引擎层返回错误的,难道是 TokuDB 数据出问题了么,我们首先要确认的是用户数据是否还能访问。

从表结构来看,出错的语句应该走了二级索引,那么我们强制走 PK 是否能访问数据呢。

select * from t2 force index(primary) where uid > 1 limit 3;
xxx
xxx
xxx
3 rows in set (0.00 sec)

上面的测试可以说明走 PK 是没问题呢,那么问题可能在二级索引。

同时我们在观察用户的其它 SQL 时发现,二级索引也是可以访问数据的。

比如下面这种:

select * from t2  where uid > 1 order by uid limit 3;
xxx
xxx
xxx
3 rows in set (0.00 sec)

都是走二级索引,为什么有的会报错呢,这 2 条语句有啥区别呢,explain 看下:

mysql> explain select * from t2  where uid > 1 limit 3;
+----+-------------+-------+-------+---------------+---------+---------+------+--------+-----------------------------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows   | Extra                                         |
+----+-------------+-------+-------+---------------+---------+---------+------+--------+-----------------------------------------------+
|  1 | SIMPLE      | t2    | range | idx_uid       | idx_uid | 9       | NULL | 523677 | Using index condition; Using where; Using MRR |
+----+-------------+-------+-------+---------------+---------+---------+------+--------+-----------------------------------------------+
1 row in set (0.00 sec)

mysql> explain select * from t2  where uid > 1 order by uid limit 3;
+----+-------------+-------+-------+---------------+---------+---------+------+--------+------------------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows   | Extra                              |
+----+-------------+-------+-------+---------------+---------+---------+------+--------+------------------------------------+
|  1 | SIMPLE      | t2    | range | idx_uid       | idx_uid | 9       | NULL | 523677 | Using index condition; Using where |
+----+-------------+-------+-------+---------------+---------+---------+------+--------+------------------------------------+
1 row in set (0.00 sec)

可以看到出错的语句,用到了 MRR(不了解 MRR 的可以看下我们之前的月报 优化器 MRR & BKA),这是优化器在走二级索引时,为了减少回表的磁盘 IO 的一个优化。

把这个优化关掉呢?

set optimizer_switch='mrr=off';
mysql> explain select id from t2  where uid > 1 limit 3;
+----+-------------+-------+-------+---------------+---------+---------+------+--------+--------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows   | Extra                    |
+----+-------------+-------+-------+---------------+---------+---------+------+--------+--------------------------+
|  1 | SIMPLE      | t2    | range | idx_uid       | idx_uid | 9       | NULL | 523677 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------+---------+------+--------+--------------------------+
1 row in set (0.00 sec)

select * from t2  where uid > 1 limit 3;
xxx
xxx
xxx
3 rows in set (0.00 sec)

可以看到,关掉优化器的 MRR 后,语句就返回正常了。因此基本可以判断是 MRR 导致的。

下面我们从源码层面分析下看,到底是怎么回事。

根据报错信息,来 gdb 跟踪,发现导致报错的栈是这样的,可以看到是在 mrr 执行初始化阶段:

#0  DsMrr_impl::dsmrr_init()
#1  ha_tokudb::multi_range_read_init()
#2  QUICK_RANGE_SELECT::reset()
#3  join_init_read_record()
#4  sub_select()
#5  do_select()
#6  JOIN::exec()
#7  mysql_execute_select()
#8  mysql_select()
#9  handle_select()
#10 execute_sqlcom_select()
#11 mysql_execute_command()
...

具体在 DsMrr_impl::dsmrr_init 中的逻辑是这样的:

// Transfer ICP from h to h2
if (mrr_keyno == h->pushed_idx_cond_keyno)
{
  if (h2->idx_cond_push(mrr_keyno, h->pushed_idx_cond))
  {
    retval= 1;
    goto error;
  }
}

我们对应看下 TokuDB 里条件下推接口实现:

// we cache the information so we can do filtering ourselves,
// but as far as MySQL knows, we are not doing any filtering,
// so if we happen to miss filtering a row that does not match
// idx_cond_arg, MySQL will catch it.
// This allows us the ability to deal with only index_next and index_prev,
// and not need to worry about other index_XXX functions
Item* ha_tokudb::idx_cond_push(uint keyno_arg, Item* idx_cond_arg) {
    toku_pushed_idx_cond_keyno = keyno_arg;
    toku_pushed_idx_cond = idx_cond_arg;
    return idx_cond_arg;
}

可以看到 ha_tokudb::idx_cond_push是会将原条件在返回给 server 的。因此就导致了 DsMrr_impl::dsmrr_init返回错误码 1 (Got error 1 from storage engine)。

handler:idx_cond_push()接口是允许引擎层返回非 NULL 值的,引擎层认为自己没有完全过滤结果集,那么是可以返回条件给 server 层,让 server 层再做一次过滤的:

/**
  Push down an index condition to the handler.

  The server will use this method to push down a condition it wants
  the handler to evaluate when retrieving records using a specified
  index. The pushed index condition will only refer to fields from
  this handler that is contained in the index (but it may also refer
  to fields in other handlers). Before the handler evaluates the
  condition it must read the content of the index entry into the
  record buffer.

  The handler is free to decide if and how much of the condition it
  will take responsibility for evaluating. Based on this evaluation
  it should return the part of the condition it will not evaluate.
  If it decides to evaluate the entire condition it should return
  NULL. If it decides not to evaluate any part of the condition it
  should return a pointer to the same condition as given as argument.

  @param keyno    the index number to evaluate the condition on
  @param idx_cond the condition to be evaluated by the handler

  @return The part of the pushed condition that the handler decides
          not to evaluate
 */

virtual Item *idx_cond_push(uint keyno, Item* idx_cond) { return idx_cond; }

因此这个问题是 MRR 在实现上的一个 bug,没有考虑引擎在ICP时返回非 NULL 的情况。

另外我们在查问题时发现,如果 mysqld 重启或者通过 flush table 关闭表的话,查询是不会出错的:

mysql> flush table t2;
mysql> explain  select * from t2  where uid > 1 limit 3;
+----+-------------+-------+-------+---------------+---------+---------+------+--------+------------------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows   | Extra                              |
+----+-------------+-------+-------+---------------+---------+---------+------+--------+------------------------------------+
|  1 | SIMPLE      | t2    | range | idx_uid       | idx_uid | 9       | NULL | 523677 | Using index condition; Using where |
+----+-------------+-------+-------+---------------+---------+---------+------+--------+------------------------------------+

从 explain 结果看,是因为没有用到 MRR,这又是为什么呢?

我们看下优化器是如何选择是否用MRR优化的,在 DsMrr_impl::choose_mrr_impl()这个函数里是有这样的逻辑的:

 /*
   If @@optimizer_switch has "mrr_cost_based" on, we should avoid
   using DS-MRR for queries where it is likely that the records are
   stored in memory. Since there is currently no way to determine
   this, we use a heuristic:
   a) if the storage engine has a memory buffer, DS-MRR is only
      considered if the table size is bigger than the buffer.
   b) if the storage engine does not have a memory buffer, DS-MRR is
      only considered if the table size is bigger than 100MB.
   c) Since there is an initial setup cost of DS-MRR, so it is only
      considered if at least 50 records will be read.
 */
 if (thd->optimizer_switch_flag(OPTIMIZER_SWITCH_MRR_COST_BASED))
 {
   /*
     If the storage engine has a database buffer we use this as the
     minimum size the table should have before considering DS-MRR.
   */
   longlong min_file_size= table->file->get_memory_buffer_size();
   if (min_file_size == -1)
   {
     // No estimate for database buffer
     min_file_size= 100 * 1024 * 1024;    // 100 MB
   }

   if (table->file->stats.data_file_length <
       static_cast<ulonglong>(min_file_size) ||
       rows <= 50)
     return true;                 // Use the default implementation
 }

可以看到,MRR 选择条件是这样的:

  1. 如果引擎的 cache 比表大的话,是不会用 MRR 优化的;
  2. 如果引擎没有 cache,默认用 100M,用于自己不管理 cache 引擎,如 MyISAM;
  3. 如果要查询的行数不超过50的话,也是不会用 MRR 优化的;

这个 cache 对 InnoDB 来说,就是 innodb_buffer_pool_size;对 TokuDB 来说,就是 tokudb_cache_size。但是 TokuDB handler 层没有实现 get_memory_buffer_size()这个接口,导致一直用 100M 做为 cache 来判断,这个是 TokuDB handler 实现的上的一个bug。

data_file_length这个是值是内存信息,在表刚关闭重新打开的时候,是0,所以不会用MRR优化。

另外还有一个判断条件时,如果要求排序的话,也是不会用 MRR 优化的,这也就是为什么我们刚开始发现的,语句中用了 order by 后,explain 结果中就没有 MRR了。

问题影响和解决

从上面的分析来看,满足下面条件语句会被影响:

  1. 语句访问的是 TokuDB 表,并且走的二级索引,有回表操作;
  2. 表大小超过 100M;

简单的判断方法是,explain 结果中有 Using index condition; Using where; Using MRR,并且语句报错 Got error 1 from storage engine。

临时的解决方法是关闭优化器的 MRR 或者 ICP:

set optimizer_switch='mrr=off';
or
set optimizer_switch='index_condition_pushdown=off';

HybridDB · 稳定性 · HybridDB如何优雅的处理Out Of Memery问题

$
0
0

前言

你是否遇到过数据库服务器的Out Of Memory(OOM)现象?就是数据库的进程把操作系统内存耗尽,触发操作系统对数据库进程执行Kill -9操作。操作系统对某个数据库进程的Kill,会导致整个数据库实例所有实例重启,所有连接会断开,造成一定时间的数据库不可用。OOM对数据库服务影响较大,应该尽量避免。

在我们的HybridDB for PG 云服务中,也可能遇到用户实例耗尽所有可用内存的情况。一般情况下,我们会采用CGROUP机制来限制用户的内存使用,同一实例在同一主机上的所有进程会放入一个CGROUP。当数据库实例使用的内存总量超出限制时,会触发操作系统从CGROUP中找出耗内存较多的进程,执行Kill -9,这会导致我们上面说的数据库服务暂时不可用。这里操作系统的Kill操作我们称为OOM Kill。

发生OOM的因素一般是应用的并发连接数过多、大对象的存取操作、查询用到过多的临时内存,实例内存过小等。从云服务提供者的角度看,我们无法提前预知或限制用户的使用行为。通常是发生OOM Kill后,监控程序检测到操作系统错误日志,我们才会进行处理,而此时往往已经对用户业务造成了影响。就是说,我们无法避免OOM的发生。但能不能更好的处理OOM,避免OOM Kill这种严重后果呢?本文将对此进行讨论。

Greenplum的处理方式

HybridDB for PG 是基于Greenplum开发的。为尽量避免OOM Kill,Greenplum提供了Resource Manager内存限制功能。通过设置Resource Manager参数,可以限制整个实例可以申请的内存。其背后的机制是,在每次数据库内核执行Malloc系统调用,申请操作系统内存时,用一个全局变量累加记录申请的内存量,并它记录的实例内存申请总量是否已经超限,如果超限则报错。

Greenplum这种方法看似可行,但在我们的云服务中却不宜采用。这是因为,操作系统并不是在Malloc被调用的时候,把实际的物理内存返回给调用者,而是等到调用者实际使用(例如做内存拷贝操作)时,才分配物理内存给它。也就是说。记录的Malloc内存总量,是虚拟内存使用量(可以从top命令输出中VIRT字段,查看一个进程的虚拟内存),并不能反映数据库实际使用的内存(实际使用内存量可以从top命令输出中的RSS字段得到)。如果按这种方法做限制,用户实际可用内存会比他购买的规格内存内存少的多。在我们的一个测试中发现,Malloc记录的内存有时是实例实际使用内存的两倍以上!

既然使用Resource Manager记录和限制内存使用的方法不可行,有没有更优雅的方式尽量避免OOM Kill呢?使用HybridDB的用户,如果线下使用过Greenplum,可能会发现,在大量使用内存时,线下会触发OOM Kill的场景在HybridDB可能并未触发(虽然还是会有SQL报错,但并没有触发实例重启)。另一方面,用户可以实际使用到HybridDB规格标称的内存。实际上HybridDB使用了独特的的方式处理OOM,较大程度上避免了OOM Kill被触发。下面我们介绍一下HybridDB的方法。

HybridDB如何处理OOM

下图是个HybridDB实例的例子。主节点、两个子节点分布位于不同的Linux主机上。每个节点都使用CGROUP限制了内存,例如,每个节点限制使用8G内存。

pic

我们假设上述实例的某个节点使用了超过8G的内存,如果按原有机制,此节点所在主机的操作系统,会找出此实例对应CGROUP中使用内存较多的进程,执行Kill -9,此节点的所有进程重启,暂时不可用。更严重的是,这可能使整个HybridDB集群不可用!为避免这种情况,HybridDB使用了如下方法:

a. 创建一个独立的CGROUP,限制例如10G的内存,如下图所示。

pic

b. 将实例的CGROUP内存限制提升20%,即如果用户实例的节点规格是8G的内存,则在CGROUP中,提升为9.6G。这样做的目的是为下面的操作留存空间。

c. 启动一个脚本,实时监控(例如每秒钟扫描一次)主机所有CGROUP的内存水位。操作系统提供了机制,在CGOUP状态信息中可以查到内存的水位信息。当发现某个CGROUP的水位过高(例如超过了100%的规格可用内存,如8G)时,将内存使用最高的进程移入公用CGROUP。如果内存水位未降低到指定水位,如规格内存的80%,则继续在此CGROUP中取出内存占用高的进程,放入公用CGROUP。如下图所示。

pic

d. 启动另一个独立的脚本,不断获取公用CGROUP中的进程,对这些进程发送特殊的信号;HybridDB进程收到这些信号,将执行类似Cancel Query一样的操作,撤销当前正在进行的查询,同时返回给用户如下的提示:

ERROR: out of memory, no enough instance memory available to run this query.

这个步骤如下图所示。

pic

e. b中提到的内存监控脚本在内存降低到指定水位(如规格内存的80%)后,则进入到另一状态,即开始将进程从公用CGROUP移动回原CGROUP,直到水位上升到预设水位(如100%规格内存)。如下图所示。

pic

上述方法的优势有:

1)由于在实例内存不断增长的过程中,实时的监控内存使用,在内存超限时,触发查询的撤销操作,保护了实例,避免的实例的整体不可用。

2)用户可以通过出错信息获知内存不足导致查询失败,进而做进一步处理。如果触发了OOM Kill,用户只能看到连接断开、实例重启,但无法获知发生现象的原因,所以通常会误以为是网络连接问题。

3)由于一台机器的所有实例都共享一个公用CGROUP,而这个CGROUP一般10G左右就满足使用了,所以它并不会增加多少成本。另外虽然为用户提升了20%的内存,但由于超出规格内存时就开始做查询撤销操作了,所以实际上实例并不会长时间多用内存。

4)这种方法实现简单。仅需要编写两个监控脚本,并在HybridDB内核中增加对特殊信号的支持。

需要注意的是,如果公用CGROUP的内存也耗尽了,是会触发OOM Kill的。也就是说这种方法并不会完全避免OOM Kill,但从实践看,大大降低了OOM Kill的发生几率。

总结

HybridDB对OOM的处理方式,虽然没能避免OOM,但大大减少了OOM Kill的发生,避免了整个HybridDB集群不可用的危险后果。可以说这是一种更优雅的OOM处理方式。当然,作为HybridDB的用户,仍然要从应用层做工作,提前降低OOM发生的几率,例如,降低并发度、调优大量使用临时内存的查询、升级实例规格等。

MySQL · 捉虫动态 · 5.7 mysql_upgrade 元数据锁等待

$
0
0

问题描述

如下图,mysql_upgrade 过程中,执行 DROP DATABASE IF EXISTS performance_schema 一直在等待 metadata lock

屏幕快照 2017-04-01 14.30.03.png

问题排查

简单粗暴的方法

有一种简单的解决方法,把其他连接kill掉,释放 metadata lock

对于这个案例,占用元数据锁的是 Id = 107768,User = xx1 的连接

但是这种方法指标不治本,案例中占用元数据锁的连接,是一个agent服务建立的

mysql_upgrade也是程序执行,不能每次都手工kill连接,需要查明为什么占用锁

详细查明问题原因

据业务方反馈,agent服务和调用mysql_upgrade的代码和5.6也在用,没有出现问题。

怀疑是5.7引入的bug

根据上述现象,显然是agent占了metadata lock,大概率不是mysql的bug

为了说服业务方,我们继续排查是在等待什么锁

查询 performance_schema.metadata_locks

首先想到5.7的 performance_schema.metadata_locks ,很遗憾这张表里并没有记录

screenshot.png

gdb 获取元数据锁信息

我们尝试使用 gdb 获取锁等待信息

ps aux | grep 端口号,找出mysqld进程号 pid,pstack pid > stack.log

在stack.log中搜索 acquire_lock(请求mdl锁的函数),可以看出是 thread 3 在请求元数据锁

screenshot.png

gdb -p pid
thread 3
切换到目标线程

#0  0x0000003fe940ba5e in pthread_cond_timedwait@@GLIBC_2.3.2 () from /lib64/libpthread.so.0
#1  0x0000000000bd3fb2 in native_cond_timedwait (this=0x7eff640e05d8, owner=0x7eff640e0540, abs_timeout=0x7effa83b2ce0, set_status_on_timeout=Unhandled dwarf expression opcode 0xf3
) 
#2  my_cond_timedwait (this=0x7eff640e05d8, owner=0x7eff640e0540, abs_timeout=0x7effa83b2ce0, set_status_on_timeout=Unhandled dwarf expression opcode 0xf3
) 
#3  inline_mysql_cond_timedwait (this=0x7eff640e05d8, owner=0x7eff640e0540, abs_timeout=0x7effa83b2ce0, set_status_on_timeout=Unhandled dwarf expression opcode 0xf3
) 
#4  MDL_wait::timed_wait (this=0x7eff640e05d8, owner=0x7eff640e0540, abs_timeout=0x7effa83b2ce0, set_status_on_timeout=Unhandled dwarf expression opcode 0xf3
) 
#5  0x0000000000bd6048 in MDL_context::acquire_lock (this=0x7eff640e05d8, mdl_request=0x7eff640aa870, lock_wait_timeout=Unhandled dwarf expression opcode 0xf3
) 

f 5
跳转到  MDL_context::acquire_lock
acquire_lock 函数参数中有 MDL_request
MDL_request::MDL_key 中有详细的锁信息

p mdl_request->key

{m_length = 34, m_db_name_length = 18, m_ptr = "\003performance_schema\000global_status", '\000'<repeats 353 times>, static m_namespace_to_wait_state_name = \{ \{m_key = 0, 
  m_name = 0x130aa9b "Waiting for global read lock", m_flags = 0}, {m_key = 0, m_name = 0x130abb0 "Waiting for tablespace metadata lock", m_flags = 0}, {m_key = 0, 
  m_name = 0x130abd8 "Waiting for schema metadata lock", m_flags = 0}, {m_key = 0, m_name = 0x130ac00 "Waiting for table metadata lock", m_flags = 0}, {m_key = 0, 
  m_name = 0x130ac20 "Waiting for stored function metadata lock", m_flags = 0}, {m_key = 0, m_name = 0x130ac50 "Waiting for stored procedure metadata lock", m_flags = 0}, {m_key = 0, 
  m_name = 0x130ac80 "Waiting for trigger metadata lock", m_flags = 0}, {m_key = 0, m_name = 0x130aca8 "Waiting for event metadata lock", m_flags = 0}, {m_key = 0, 
  m_name = 0x130aab8 "Waiting for commit lock", m_flags = 0}, {m_key = 0, m_name = 0x130aad0 "User lock", m_flags = 0}, {m_key = 0, m_name = 0x130acc8 "Waiting for locking service lock", 
  m_flags = 0} } }

上述信息可以看出,正在请求performance_schema.global_status这张表的锁

排查业务代码

和业务方确认,agent中确实执行了 “show global status” , 但是已经设置了autocommit

简化逻辑后,agent代码如下

import MySQLdb
from time import sleep
conn = MySQLdb.connect(host='47.93.49.119', port=3001, user='xx1')
conn.autocommit = True
cur=conn.cursor()
cur.execute("show global status")
while 1:
    sleep(1)

代码中确实设置了autocommit,但是并没有生效(如果执行了commit,不可能不释放元数据锁)

MySQLdb.connect 返回 Connection 类,根据上述代码,autocommit是 Connection的成员属性

class Connection(_mysql.connection):

Connection 继承自_mysql.connection,_mysql 是c语言实现的python库,查看_mysql.c

static PyMethodDef _mysql_ConnectionObject_methods[] = {
    {
        "affected_rows",
        (PyCFunction)_mysql_ConnectionObject_affected_rows,
        METH_VARARGS,
        _mysql_ConnectionObject_affected_rows__doc__
    },
    {
        "autocommit",
        (PyCFunction)_mysql_ConnectionObject_autocommit,
        METH_VARARGS,
        _mysql_ConnectionObject_autocommit__doc__
    },
    {
        "commit",
        (PyCFunction)_mysql_ConnectionObject_commit,
        METH_VARARGS,
        _mysql_ConnectionObject_commit__doc__
    },

autommit 并不是成员属性,而是一个成员方法

结论

conn.autocommit = True 强行将 autocommit 的函数指针赋值为 True,并没有真正设置autocommit

5.6中没有发现这个问题

一是 agent 中只有查询语句,不设autocommit也能返回查询结果

二是 5.6中 “show global status” 查询的是 information_shcema,5.7中是performance_schema,5.6中不会影响 drop database performance_schema

Viewing all 691 articles
Browse latest View live