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

MySQL · 引擎特性 · InnoDB 事务系统

$
0
0

前言

关系型数据库的事务机制因其有原子性,一致性等优秀特性深受开发者喜爱,类似的思想已经被应用到很多其他系统上,例如文件系统等。本文主要介绍InnoDB事务子系统,主要包括,事务的启动,事务的提交,事务的回滚,多版本控制,垃圾清理,回滚段以及相应的参数和监控方法。代码主要基于RDS 5.6,部分特性已经开源到AliSQL。事务系统是InnoDB最核心的中控系统,涉及的代码比较多,主要集中在trx目录,read目录以及row目录中的一部分,包括头文件和IC文件,一共有两万两千多行代码。

基础知识

事务ACID:原子性,指的是整个事务要么全部成功,要么全部失败,对InnoDB来说,只要client收到server发送过来的commit成功报文,那么这个事务一定是成功的。如果收到的是rollback的成功报文,那么整个事务的所有操作一定都要被回滚掉,就好像什么都没执行过一样。另外,如果连接中途断开或者server crash事务也要保证会滚掉。InnoDB通过undolog保证rollback的时候能找到之前的数据。一致性,指的是在任何时刻,包括数据库正常提供服务的时候,数据库从异常中恢复过来的时候,数据都是一致的,保证不会读到中间状态的数据。在InnoDB中,主要通过crash recovery和double write buffer的机制保证数据的一致性。隔离性,指的是多个事务可以同时对数据进行修改,但是相互不影响。InnoDB中,依据不同的业务场景,有四种隔离级别可以选择。默认是RR隔离级别,因为相比于RC,InnoDB的RR性能更加好。持久性,值的是事务commit的数据在任何情况下都不能丢。在内部实现中,InnoDB通过redolog保证已经commit的数据一定不会丢失。

多版本控制:指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。在内部实现中,与Postgres在数据行上实现多版本不同,InnoDB是在undolog中实现的,通过undolog可以找回数据的历史版本。找回的数据历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在InnoDB内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。

垃圾清理:对于用户删除的数据,InnoDB并不是立刻删除,而是标记一下,后台线程批量的真正删除。类似的还有InnoDB的二级索引的更新操作,不是直接对索引进行更新,而是标记一下,然后产生一条新的。这个线程就是后台的Purge线程。此外,过期的undolog也需要回收,这里说的过期,指的是undo不需要被用来构建之前的版本,也不需要用来回滚事务。

回滚段:可以理解为数据页的修改链,链表最前面的是最老的一次修改,最后面的最新的一次修改,从链表尾部逆向操作可以恢复到数据最老的版本。在InnoDB中,与之相关的还有undo tablespace, undo segment, undo slot, undo log这几个概念。undo log是最小的粒度,所在的数据页称为undo page,然后若干个undo page构成一个undo slot。一个事务最多可以有两个undo slot,一个是insert undo slot, 用来存储这个事务的insert undo,里面主要记录了主键的信息,方便在回滚的时候快速找到这一行。另外一个是update undo slot,用来存储这个事务delete/update产生的undo,里面详细记录了被修改之前每一列的信息,便于在读请求需要的时候构造。1024个undo slot构成了一个undo segment。然后若干个undo segemnt构成了undo tablespace。

历史链表: insert undo可以在事务提交/回滚后直接删除,没有事务会要求查询新插入数据的历史版本,但是update undo则不可以,因为其他读请求可能需要使用update undo构建之前的历史版本。因此,在事务提交的时候,会把update undo加入到一个全局链表(history list)中,链表按照事务提交的顺序排序,保证最先提交的事务的update undo在前面,这样Purge线程就可以从最老的事务开始做清理。这个链表如果太长说明有很多记录没被彻底删除,也有很多undolog没有被清理,这个时候就需要去看一下是否有个长事务没提交导致Purge线程无法工作。在InnoDB具体实现上,history list其实只是undo segment维度的,全局的history list采用最小堆来实现,最小堆的元素是某个undo segment中最小事务no对应的undopage。当这个undolog被Purge清理后,通过history list找到次小的,然后替换掉最小堆元素中的值,来保证下次Purge的顺序的正确性。

回滚点:又称为savepoint,事务回滚的时候可以指定回滚点,这样可以保证回滚到指定的点,而不是回滚掉整个事务,对开发者来说,这是一个强大的功能。在InnoDB内部实现中,每打一个回滚点,其实就是保存一下当前的undo_no,回滚的时候直接回滚到这个undo_no点就可以了。

核心数据结构

在分析核心的代码之前,先介绍一下几个核心的数据结构。这些结构贯穿整个事务系统,理解他们对理解整个InnoDB的工作原理也颇有帮助。

trx_t:整个结构体每个连接持有一个,也就是在创建连接后执行第一个事务开始,整个结构体就被初始化了,后续这个连接的所有事务一直复用里面的数据结构,直到这个连接断开。同时,事务启动后,就会把这个结构体加入到全局事务链表中(trx_sys->mysql_trx_list),如果是读写事务,还会加入到全局读写事务链表中(trx_sys->rw_trx_list)。在事务提交的时候,还会加入到全局提交事务链表中(trx_sys->trx_serial_list)。state字段记录了事务四种状态:TRX_STATE_NOT_STARTED, TRX_STATE_ACTIVE, TRX_STATE_PREPARED, TRX_STATE_COMMITTED_IN_MEMORY。 这里有两个字段值得区分一下,分别是id和no字段。id是在事务刚创建的时候分配的(只读事务永远为0,读写事务通过一个全局id产生器产生,非0),目的就是为了区分不同的事务(只读事务通过指针地址来区分),而no字段是在事务提交前,通过同一个全局id生产器产生的,主要目的是为了确定事务提交的顺序,保证加入到history list中的update undo有序,方便purge线程清理。 此外,trx_t结构体中还有自己的read_view用来表示当前事务的可见范围。分配的insert undo slot和update undo slot。如果是只读事务,read_only也会被标记为true。

trx_sys_t:这个结构体用来维护系统的事务信息,全局只有一个,在数据库启动的时候初始化。比较重要的字段有:max_trx_id,这个字段表示系统当前还未分配的最小事务id,如果有一个新的事务,直接把这个值作为新事务的id,然后这个字段递增即可。descriptors,这个是一个数组,里面存放着当前所有活跃的读写事务id,当需要开启一个readview的时候,就从这个字段里面拷贝一份,用来判断记录的对事务的可见性。rw_trx_list,这个主要是用来存放当前系统的所有读写事务,包括活跃的和已经提交的事务。按照事务id排序,此外,奔溃恢复后产生的事务和系统的事务也放在上面。mysql_trx_list,这里面存放所有用户创建的事务,系统的事务和奔溃恢复后的事务不会在这个链表上,但是这个链表上可能会有还没开始的用户事务。trx_serial_list,按照事务no(trx_t->no)排序的已经提交的事务。rseg_array,这个指向系统所有可以用的回滚段(undo segments),当某个事务需要回滚段的时候,就从这里分配。rseg_history_len, 所有提交事务的update undo的长度,也就是上文提到的历史链表的长度,具体的update undo链表是存放在这个undo log中以文件指针的形式管理起来。view_list,这个是系统当前所有的readview, 所有开启的readview的事务都会把自己的readview放在这个上面,按照事务no排序。

trx_purge_t: Purge线程使用的结构体,全局只有一个,在系统启动的时候初始化。view,是一个readview,Purge线程不会尝试删除所有大于view->low_limit_no的undolog。limit,所有小于这个值的undolog都可以被truncate掉,因为标记的日志已经被删除且不需要用他们构建之前的历史版本。此外,还有rseg,page_no, offset,hdr_page_no, hdr_offset这些字段,主要用来保存最后一个还未被purge的undolog。

read_view_t: InnDB为了判断某条记录是否对当前事务可见,需要对此记录进行可见性判断,这个结构体就是用来辅助判断的。每个连接都的trx_t里面都有一个readview,在事务需要一致性的读时候(不同隔离级别不同),会被初始化,在读结束的时候会释放(缓存)。low_limit_no,这个主要是给purge线程用,readview创建的时候,会把当前最小的提交事务id赋值给low_limit_no,这样Purge线程就可以把所有已经提交的事务的undo日志给删除。low_limit_id, 所有大于等于此值的记录都不应该被此readview看到,可以理解为high water mark。up_limit_id, 所有小于此值的记录都应该被此readview看到,可以理解为low water mark。descriptors, 这是一个数组,里面存了readview创建时候所有全局读写事务的id,除了事务自己做的变更外,此readview应该看不到descriptors中事务所做的变更。view_list,每个readview都会被加入到trx_sys中的全局readview链表中。

trx_id_t:每个读写事务都会通过全局id产生器产生一个id,只读事务的事务id为0,只有当其切换为读写事务时候再分配事务id。为了保证在任何情况下(包括数据库不断异常恢复),事务id都不重复,InnoDB的全局id产生器每分配256(TRX_SYS_TRX_ID_WRITE_MARGIN)个事务id,就会把当前的max_trx_id持久化到ibdata的系统页上面。此外,每次数据库重启,都从系统页上读取,然后加上256(TRX_SYS_TRX_ID_WRITE_MARGIN)。

trx_rseg_t: undo segment内存中的结构体。每个undo segment都对应一个。update_undo_list表示已经被分配出去的正在使用的update undo链表,insert_undo_list表示已经被分配出去的正在使用的insert undo链表。update_undo_cached和insert_undo_cached表示缓存起来的undo链表,主要为了快速使用。last_page_no, last_offset, last_trx_no, last_del_marks表示这个undo segment中最后没有被Purge的undolog。

事务的启动

在InnoDB里面有两种事务,一种是读写事务,就是会对数据进行修改的事务,另外一种是只读事务,仅仅对数据进行读取。读写事务需要比只读事务多做以下几点工作:首先,需要分配回滚段,因为会修改数据,就需要找地方把老版本的数据给记录下来,其次,需要通过全局事务id产生器产生一个事务id,最后,把读写事务加入到全局读写事务链表(trx_sys->rw_trx_list),把事务id加入到活跃读写事务数组中(trx_sys->descriptors)。因此,可以看出,读写事务确实需要比只读事务多做不少工作,在使用数据库的时候尽可能把事务申明为只读。

start transaction语句启动事务。这种语句和begin work,begin等效。这些语句默认是以只读事务的方式启动。start transaction read only语句启动事务。这种语句就把thd->tx_read_only置为true,后续如果做了DML/DDL等修改数据的语句,会返回错误ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTIONstart transaction read write语句启动事务。这种语句会把thd->tx_read_only置为true,此外,允许super用户在read_only参数为true的情况下启动读写事务。start transaction with consistent snapshot语句启动事务。这种启动方式还会进入InnoDB层,并开启一个readview。注意,只有在RR隔离级别下,这种操作才有效,否则会报错。

上述的几种启动方式,都会先去检查前一个事务是否已经提交,如果没有则先提交,然后释放MDL锁。此外,除了with consistent snapshot的方式会进入InnoDB层,其他所有的方式都只是在Server层做个标记,没有进入InnoDB做标记,在InnoDB看来所有的事务在启动时候都是只读状态,只有接受到修改数据的SQL后(InnoDB接收到才行。因为在start transaction read only模式下,DML/DDL都被Serve层挡掉了)才调用trx_set_rw_mode函数把只读事务提升为读写事务。 新建一个连接后,在开始第一个事务前,在InnoDB层会调用函数innobase_trx_allocate分配和初始化trx_t对象。默认的隔离级别为REPEATABLE_READ,并且加入到mysql_trx_list中。注意这一步仅仅是初始化trx_t对象,但是真正开始事务的是函数trx_start_low,在trx_start_low中,如果当前的语句只是一条只读语句,则先以只读事务的形式开启事务,否则按照读写事务的形式,这就需要分配事务id,分配回滚段等。

事务的提交

相比于事务的启动,事务的提交就复杂许多。这里只介绍事务在InnoDB层的提交过程,Server层涉及到与Binlog的XA事务暂时不介绍。入口函数为innobase_commit

函数有一个参数commit_trx来控制是否真的提交,因为每条语句执行结束的时候都会调用这个函数,而不是每条语句执行结束的时候事务都提交。如果这个参数为true,或者配置了autocommit=1, 则进入提交的核心逻辑。否则释放因为auto_inc而造成的表锁,并且记录undo_no(回滚单条语句的时候用到,相关参数innodb_rollback_on_timeout)。 提交的核心逻辑:

  1. 依据参数innobase_commit_concurrency来判断是否有过多的线程同时提交,如果太多则等待。
  2. 设置事务状态为committing,我们可以在show processlist看到(trx_commit_for_mysql)。
  3. 使用全局事务id产生器生成事务no,然后把事务trx_t加入到trx_serial_list。如果当前的undo segment没有设置最后一个未Purge的undo,则用此事务no更新(trx_serialisation_number_get)。
  4. 标记undo,如果这个事务只使用了一个undopage且使用量小于四分之三个page,则把这个page标记为(TRX_UNDO_CACHED)。如果不满足且是insert undo则标记为TRX_UNDO_TO_FREE,否则undo为update undo则标记为TRX_UNDO_TO_PURGE。标记为TRX_UNDO_CACHED的undo会被回收,方便下次重新利用(trx_undo_set_state_at_finish)。
  5. 把update undo放入所在undo segment的history list,并递增trx_sys->rseg_history_len(这个值是全局的)。同时更新page上的TRX_UNDO_TRX_NO, 如果删除了数据,则重置delete_mark(trx_purge_add_update_undo_to_history)。
  6. 把undate undo从update_undo_list中删除,如果被标记为TRX_UNDO_CACHED,则加入到update_undo_cached队列中(trx_undo_update_cleanup)。
  7. 在系统页中更新binlog名字和偏移量(trx_write_serialisation_history)。
  8. mtr_commit,至此,在文件层次事务提交。这个时候即使crash,重启后依然能保证事务是被提交的。接下来要做的是内存数据状态的更新(trx_commit_in_memory)。
  9. 如果是只读事务,则只需要把readview从全局readview链表中移除,然后重置trx_t结构体里面的信息即可。如果是读写事务,情况则复杂点,首先需要是设置事务状态为TRX_STATE_COMMITTED_IN_MEMORY,其次,释放所有行锁,接着,trx_t从rw_trx_list中移除,readview从全局readview链表中移除,另外如果有insert undo则在这里移除(update undo在事务提交前就被移除,主要是为了保证添加到history list的顺序),如果有update undo,则唤醒Purge线程进行垃圾清理,最后重置trx_t里的信息,便于下一个事务使用。

事务的回滚

InnoDB的事务回滚是通过undolog来逆向操作来实现的,但是undolog是存在undopage中,undopage跟普通的数据页一样,遵循bufferpool的淘汰机制,如果一个事务中的很多undopage已经被淘汰出内存了,那么在回滚的时候需要重新把这些undopage从磁盘中捞上来,这会造成大量io,需要注意。此外,由于引入了savepoint的概念,事务不仅可以全部回滚,也可以回滚到某个指定点。

回滚的上层函数是innobase_rollback_trx,主要流程如下:

  1. 如果是只读事务,则直接返回。
  2. 判断当前是回滚整个事务还是部分事务,如果是部分事务,则记录下需要保留多少个undolog,多余的都回滚掉,如果是全部回滚,则记录0(trx_rollback_step)。
  3. 从update undo和insert undo中找出最后一条undo,从这条undo开始回滚(trx_roll_pop_top_rec_of_trx)。
  4. 如果是update undo则调用row_undo_mod进行回滚,标记删除的记录清理标记,更新过的数据回滚到最老的版本。如果是insert undo则调用row_undo_ins进行回滚,插入操作,直接删除聚集索引和二级索引。
  5. 如果是在奔溃恢复阶段且需要回滚的undolog个数大于1000条,则输出进度。
  6. 如果所有undo都已经被回滚或者回滚到了指定的undo,则停止,并且调用函数trx_roll_try_truncate把undolog删除(由于不需要使用unod构建历史版本,所以不需要留给Purge线程)。 此外,需要注意的是,回滚的代码由于是嵌入在query graphy的框架中,因此有些入口函数不太好找。例如,确定回滚范围的是在函数trx_rollback_step中,真正回滚的操作是在函数row_undo_step中,两者都是在函数que_thr_step被调用。

多版本控制MVCC

数据库需要做好版本控制,防止不该被事务看到的数据(例如还没提交的事务修改的数据)被看到。在InnoDB中,主要是通过使用readview的技术来实现判断。查询出来的每一行记录,都会用readview来判断一下当前这行是否可以被当前事务看到,如果可以,则输出,否则就利用undolog来构建历史版本,再进行判断,知道记录构建到最老的版本或者可见性条件满足。

在trx_sys中,一直维护这一个全局的活跃的读写事务id(trx_sys->descriptors),id按照从小到大排序,表示在某个时间点,数据库中所有的活跃(已经开始但还没提交)的读写(必须是读写事务,只读事务不包含在内)事务。当需要一个一致性读的时候(即创建新的readview时),会把全局读写事务id拷贝一份到readview本地(read_view_t->descriptors),当做当前事务的快照。read_view_t->up_limit_id是read_view_t->descriptors这数组中最小的值,read_view_t->low_limit_id是创建readview时的max_trx_id,即一定大于read_view_t->descriptors中的最大值。当查询出一条记录后(记录上有一个trx_id,表示这条记录最后被修改时的事务id),可见性判断的逻辑如下(lock_clust_rec_cons_read_sees):

如果记录上的trx_id小于read_view_t->up_limit_id,则说明这条记录的最后修改在readview创建之前,因此这条记录可以被看见。

如果记录上的trx_id大于等于read_view_t->low_limit_id,则说明这条记录的最后修改在readview创建之后,因此这条记录肯定不可以被看家。

如果记录上的trx_id在up_limit_id和low_limit_id之间,且trx_id在read_view_t->descriptors之中,则表示这条记录的最后修改是在readview创建之时,被另外一个活跃事务所修改,所以这条记录也不可以被看见。如果trx_id不在read_view_t->descriptors之中,则表示这条记录的最后修改在readview创建之前,所以可以看到。

基于上述判断,如果记录不可见,则尝试使用undo去构建老的版本(row_vers_build_for_consistent_read),直到找到可以被看见的记录或者解析完所有的undo。

针对RR隔离级别,在第一次创建readview后,这个readview就会一直持续到事务结束,也就是说在事务执行过程中,数据的可见性不会变,所以在事务内部不会出现不一致的情况。针对RC隔离级别,事务中的每个查询语句都单独构建一个readview,所以如果两个查询之间有事务提交了,两个查询读出来的结果就不一样。从这里可以看出,在InnoDB中,RR隔离级别的效率是比RC隔离级别的高。此外,针对RU隔离级别,由于不会去检查可见性,所以在一条SQL中也会读到不一致的数据。针对串行化隔离级别,InnoDB是通过锁机制来实现的,而不是通过多版本控制的机制,所以性能很差。

由于readview的创建涉及到拷贝全局活跃读写事务id,所以需要加上trx_sys->mutex这把大锁,为了减少其对性能的影响,关于readview有很多优化。例如,如果前后两个查询之间,没有产生新的读写事务,那么前一个查询创建的readview是可以被后一个查询复用的。

垃圾回收Purge线程

Purge线程主要做两件事,第一,数据页内标记的删除操作需要从物理上删除,为了提高删除效率和空间利用率,由后台Purge线程解析undolog定期批量清理。第二,当数据页上标记的删除记录已经被物理删除,同时undo所对应的记录已经能被所有事务看到,这个时候undo就没有存在的必要了,因此Purge线程还会把这些undo给truncate掉,释放更多的空间。

Purge线程有两种,一种是Purge Worker(srv_worker_thread), 另外一种是Purge Coordinator(srv_purge_coordinator_thread),前者的主要工作就是从队列中取出Purge任务,然后清理已经被标记的记录。后者的工作除了清理删除记录外,还需要把Purge任务放入队列,唤醒Purge Worker线程,此外,它还要truncate undolog。

我们先来分析一下Purge Coordinator的流程。启动线程后,会进入一个大的循环,循环的终止条件是数据库关闭。在循环内部,首先是自适应的sleep,然后才会进入核心Purge逻辑。sleep时间与全局历史链表有关系,如果历史链表没有增长,且总数小于5000,则进入sleep,等待事务提交的时候被唤醒(srv_purge_coordinator_suspend)。退出循环后,也就是数据库进入关闭的流程,这个时候就需要依据参数innodb_fast_shutdown来确定在关闭前是否需要把所有记录给清除。接下来,介绍一下核心Purge逻辑。

  1. 首先依据当前的系统负载来确定需要使用的Purge线程数(srv_do_purge),即如果压力小,只用一个Purge Cooridinator线程就可以了。如果压力大,就多唤醒几个线程一起做清理记录的操作。如果全局历史链表在增加,或者全局历史链表已经超过innodb_max_purge_lag,则认为压力大,需要增加处理的线程数。如果数据库处于不活跃状态(srv_check_activity),则减少处理的线程数。
  2. 如果历史链表很长,超过innodb_max_purge_lag,则需要重新计算delay时间(不超过innodb_max_purge_lag_delay)。如果计算结果大于0,则在后续的DML中需要先sleep,保证不会太快产生undo(row_mysql_delay_if_needed)。
  3. 从全局视图链表中,克隆最老的readview,所有在这个readview开启之前提交的事务所产生的undo都被认为是可以清理的。克隆之后,还需要把最老视图的创建者的id加入到view->descriptors中,因为这个事务修改产生的undo,暂时还不能删除(read_view_purge_open)。
  4. 从undo segment的最小堆中,找出最早提交事务的undolog(trx_purge_get_rseg_with_min_trx_id),如果undolog标记过delete_mark(表示有记录删除操作),则把先关undopage信息暂存在purge_sys_t中(trx_purge_read_undo_rec)。
  5. 依据purge_sys_t中的信息,读取出相应的undo,同时把相关信息加入到任务队列中。同时更新扫描过的指针,方便后续truncate undolog。
  6. 循环第4步和第5步,直到全局历史链表为空,或者接下到view->low_limit_no,即最老视图创建时已经提交的事务,或者已经解析的page数量超过innodb_purge_batch_size
  7. 把所有的任务都放入队列后,就可以通知所有Purge Worker线程(如果有的话)去执行记录删除操作了。删除记录的核心逻辑在函数row_purge_record_func中。有两种情况,一种是数据记录被删除了,那么需要删除所有的聚集索引和二级索引(row_purge_del_mark),另外一种是二级索引被更新了(总是先删除+插入新记录),所以需要去执行清理操作。
  8. 在所有提交的任务都已经被执行完后,就可以调用函数trx_purge_truncate去删除update undo(insert undo在事务提交后就被清理了)。每个undo segment分别清理,从自己的histrory list中取出最早的一个undo,进行truncate(trx_purge_truncate_rseg_history)。truncate中,最终会调用fseg_free_page来清理磁盘上的空间。

事务的复活

在奔溃恢复后,也就是所有的前滚redo都应用完后,数据库需要做undo回滚,至于哪些事务需要提交,哪些事务需要回滚,这取决于undolog和binlog的状态。启动阶段,事务相关的代码逻辑主要在函数trx_sys_init_at_db_start中,简单分析一下。

  1. 首先创建管理undo segment的最小堆,堆中的元素是每个undo segment提交最早的事务id和相应undo segment的指针,也就是说通过这个元素可以找到这个undo segment中最老的未被Purge的undo。通过这个最小堆,可以找到所有undo segment中最老未被Purge的undo,方便Purge线程操作。
  2. 创建全局的活跃读写事务id数组。主要是给readview使用。
  3. 初始化所有的undo segment。主要是从磁盘读取undolog的内容,构建内存中的undo slot和undo segment,同时也构建每个undo segment中的history list,因为如果是fast shutdown,被标记为删除的记录可能还没来得及被彻底清理。此外,也构建每个undo segment中的inset_undo_list和update_undo_list,理论上来说,如果数据库关闭的时候所有事务都正常提交了,这两个链表都为空,如果数据库非正常关闭,则链表非空(trx_undo_mem_create_at_db_start, trx_rseg_mem_create)。
  4. 从系统页里面读取max_trx_id,然后加上TRX_SYS_TRX_ID_WRITE_MARGIN来保证trx_id不会重复,即使在很极端的情况下。
  5. 遍历所有的undo segment,针对每个undo segment,分别遍历inset_undo_list和update_undo_list,依据undo的状态来复活事务。
  6. insert/update undo的处理逻辑:如果undolog上的状态是TRX_UNDO_ACTIVE,则事务也被设置为TRX_STATE_ACTIVE,如果undolog上的状态是TRX_UNDO_PREPARED,则事务也被设置为TRX_UNDO_PREPARED(如果force_recovery不为0,则设置为TRX_STATE_ACTIVE)。如果undolog状态是TRX_UNDO_CACHED,TRX_UNDO_TO_FREE,TRX_UNDO_TO_PURGE,那么都任务事务已经提交了(trx_resurrect_inserttrx_resurrect_update)。
  7. 除了从undolog中复活出事务的状态信息,还需要复活出当前的锁信息(trx_resurrect_table_locks),此外还需要把事务trx_t加入到rw_trx_list中。
  8. 所有事务信息复活后,InnoDB会做个统计,告诉你有多少undo需要做,因此可以在错误日志中看到类似的话: InnoDB: 120 transaction(s) which must be rolled back or cleaned up. InnoDB: in total 20M row operations to undo。
  9. 如果事务中操作了数据字典,比如创建删除表和索引,则这个事务会在奔溃恢复结束后直接回滚,这个是个同步操作,会延长奔溃恢复的时间(recv_recovery_from_checkpoint_finish)。如果事务中没有操作数据字典,则后台会开启一个线程,异步回滚事务,所以我们常常发现,在数据库启动后,错误日志里面依然会有很多事务正在回滚的信息。

事务运维相关命令和参数

  1. 首先介绍一下information_schema中的三张表: innodb_trx, innodb_locks和innodb_lock_waits。由于这些表几乎需要查询所有事务子系统的核心数据结构,为了减少查询对系统性能的影响,InnoDB预留了一块内存,内存里面存了相关数据的副本,如果两次查询的时间小于0.1秒(CACHE_MIN_IDLE_TIME_US),则访问的都是同一个副本。如果超过0.1秒,则这块内存会做一次更新,每次更新会把三张表用到的所有数据统一更新一遍,因为这三张表经常需要做表连接操作,所以一起更新能保证数据的一致性。这里简单介绍一下innodb_trx表中的字段,另外两张表涉及到事物锁的相关信息,由于篇幅限制,后续有机会在介绍。

    trx_id: 就是trx_t中的事务id,如果是只读事务,这个id跟trx_t的指针地址有关,所以可能是一个很大的数字(trx_get_id_for_print)。

    trx_weight: 这个是事务的权重,计算方法就是undolog数量加上事务已经加上锁的数量。在事务回滚的时候,优先选择回滚权重小的事务,有非事务引擎参与的事务被认为权重是最大的。

    trx_rows_modified:这个就是当前事务已经产生的undolog数量,每更新一条记录一次,就会产生一条undo。

    trx_concurrency_tickets: 每次这个事务需要进入InnoDB层时,这个值都会减一,如果减到0,则事务需要等待(压力大的情况下)。

    trx_is_read_only: 如果是以start transaction read only启动事务的,那么这个字段是1,否则为0。

    trx_autocommit_non_locking: 如果一个事务是一个普通的select语句(后面没有跟for update, share lock等),且当时的autocommit为1,则这个字段为1,否则为0。

    trx_state: 表示事务当前的状态,只能有RUNNING, LOCK WAIT, ROLLING BACK, COMMITTING这几种状态, 是比较粗粒度的状态。

    trx_operation_state: 表示事务当前的详细状态,相比于trx_state更加详细,例如有rollback to a savepoint, getting list of referencing foreign keys, rollback of internal trx on stats tables, dropping indexes等。

  2. 与事务相关的undo参数

    innodb_undo_directory: undo文件的目录,建议放在独立的一块盘上,尤其在经常有大事务的情况下。

    innodb_undo_logs: 这个是定义了undo segment的个数。在给读写事务分配undo segment的时候,拿这个值去做轮训分配。

    Innodb_available_undo_logs: 这个是一个status变量,在启动的时候就确定了,表示的是系统上分配的undo segment。举个例子说明其与innodb_undo_logs的关系:假设系统初始化的时候innodb_undo_logs为128,则在文件上一定有128个undo segment,Innodb_available_undo_logs也为128,但是启动起来后,innodb_undo_logs动态被调整为100,则后续的读写事务只会使用到前100个回滚段,最后的20多个不会使用。

    innodb_undo_tablespaces: 存放undo segment的物理文件个数,文件名为undoN,undo segment会比较均匀的分布在undo tablespace中。

  3. 与Purge相关的参数

    innodb_purge_threads: Purge Worker和Purge Coordinator总共的个数。在实际的实现中,使用多少个线程去做Purge是InnoDB根据实时负载进行动态调节的。

    innodb_purge_batch_size: 一次性处理的undolog的数量,处理完这个数量后,Purge线程会计算是否需要sleep。

    innodb_max_purge_lag: 如果全局历史链表超过这个值,就会增加Purge Worker线程的数量,也会使用sleep的方式delay用户的DML。

    innodb_max_purge_lag_delay: 这个表示通过sleep方式delay用户DML最大的时间。

  4. 与回滚相关的参数

    innodb_lock_wait_timeout: 等待行锁的最大时间,如果超时,则会滚当前语句或者整个事务。发生回滚后返回类似错误:Lock wait timeout exceeded; try restarting transaction。

    innodb_rollback_on_timeout: 如果这个参数为true,则当发生因为等待行锁而产生的超时时,回滚掉整个事务,否则只回滚当前的语句。这个就是隐式回滚机制。主要是为了兼容之前的版本。

总结

本文简单介绍了InnoDB事务子系统的几个核心模块,在MySQL 5.7上,事务模块还有很多特性,例如高优先级事务,事务对象池等。与事务相关的还有事务锁系统,由于篇幅限制,本文不介绍,详情可以参考本期月报的这篇。此外,在阿里云最新发布的POLARDB for MySQL的版本中,由于涉及到共享存储架构,我们对事务子系统又进行了大量的改造,后续的月报会详细介绍。


MySQL · 引擎特性 · Innodb 锁子系统浅析

$
0
0

锁类型

Innodb 的锁从锁粒度上大致可以分为行锁和表锁,之前接触过的Berkeley DB(MySQL 5.1前的事务储存引擎,后被 Innodb 取代)只对存储格式为 Hash 的定长数据支持行锁,对于 Btree 格式的仅支持页锁,作为 KV 类型的存储引擎,锁的类型也相对简单。Innodb 根据官方文档的描述,除了基本的共享锁和排他锁,还有意向锁,Gap锁,Next key锁等类型,最开始接触的时候确实有些眼花缭乱,关于各种锁类型的使用场景描述可以参考早期月报前两个小节。

在 Innodb 内部用一个 unsiged long 类型数据表示锁的类型, 如图所示,最低的 4 个 bit 表示 lock_mode, 5-8 bit 表示 lock_type, 剩下的高位 bit 表示行锁的类型。

record_lock typelock_typelock_mode

lock_mode 描述了锁的基本类型,在代码中的定义如下:

/* Basic lock modes */
enum lock_mode {
	LOCK_IS = 0,	/* intention shared */
	LOCK_IX,	/* intention exclusive */
	LOCK_S,		/* shared */
	LOCK_X,		/* exclusive */
	LOCK_AUTO_INC,	/* locks the auto-inc counter of a table
			in an exclusive mode */
	LOCK_NONE,	/* this is used elsewhere to note consistent read */
	LOCK_NUM = LOCK_NONE, /* number of lock modes */
	LOCK_NONE_UNSET = 255
};
#define LOCK_MODE_MASK 0xFUL /* mask used to extact lock type from the
						type_mode field in a lock*/

lock_type 占用 5-8 bit 位,目前只用了 5 和 6 位,大小为 16 和 32 ,表示 LOCK_TABLE 和 LOCK_REC,使用宏定义 #define LOCK_TYPE_MASK 0xF0UL来获取值。

record_lock_type 对于 LOCK_TABLE 类型来说都是空的,对于 LOCK_REC 目前值有:

#define LOCK_WAIT   256		/* 表示正在等待锁 */
#define LOCK_ORDINARY 0 	/* 表示 next-key lock ,锁住记录本身和记录之前的 gap*/
#define LOCK_GAP    512		/* 表示锁住记录之前 gap(不锁记录本身) */
#define LOCK_REC_NOT_GAP 1024	/* 表示锁住记录本身,不锁记录前面的 gap */
#define LOCK_INSERT_INTENTION 2048	/* 插入意向锁 */
#define LOCK_CONV_BY_OTHER 4096		/* 表示锁是由其它事务创建的(比如隐式锁转换) */

使用位操作来设置和判断是否设置了对应的值。

静态数据结构

对于每个锁对象,有两个存在的纬度:一个是事务纬度,每个事务都可以获得锁结构和等待某些锁。另一个是全局纬度,所有的锁都保存在 Lock_sys->hash 哈希表中。无论是表锁还是行锁,都是用结构 lock_t 来描述:

/** Lock struct; protected by lock_sys->mutex */
struct lock_t {
    trx_t*      trx;        /*!< transaction owning the
                    lock */
    UT_LIST_NODE_T(lock_t)
            trx_locks;  /*!< list of the locks of the
                    transaction */
    ulint       type_mode;  /*!< lock type, mode, LOCK_GAP or
                    LOCK_REC_NOT_GAP,
                    LOCK_INSERT_INTENTION,
                    wait flag, ORed */
    hash_node_t hash;       /*!< hash chain node for a record
                    lock */
    dict_index_t*   index;      /*!< index for a record lock */
    union {
        lock_table_t    tab_lock;/*!< table lock */
        lock_rec_t  rec_lock;/*!< record lock */
    } un_member;            /*!< lock details */
};

对于每个变量的意义注释已经说的比较清楚了,其中 type_mode 就是第一小节中 lock_type | type_mode,两个锁是否冲突就是使用它们各自的 type_mode 根据锁兼容矩阵来判断的,后面会详细说。

变量 hash 是 Inodb 中构造 Hash 表需要,当锁插入到 Lock_sys->hash 中,Hash 值相同就形成链表,使用变量 hash 相连。

un_member 表示 lock_t 不是表锁就是行锁,看下行锁的结构:

/** Record lock for a page */
struct lock_rec_t {
    ulint   space;          /*!< space id */
    ulint   page_no;        /*!< page number */
    ulint   n_bits;         /*!< number of bits in the lock
                    bitmap; NOTE: the lock bitmap is
                    placed immediately after the
                    lock struct */
};

[space, page_no] 可以确定锁对应哪个页,参考下上个月月报最后两个小节,页上每行数据紧接着存放,内部使用一个 heap_no 来表示是第几行数据。因此[space, page_no, heap_no]可以唯一确定一行。Innodb 使用位图来表示锁具体锁住了那几行,在函数 lock_rec_create 中为 lock_t 分配内存空间的时候,会在对象地址后分配一段内存空间(当前行数 + 64)用来保存位图。n_bits 表示位图大小。

/* Make lock bitmap bigger by a safety margin */
n_bits = page_dir_get_n_heap(page) + LOCK_PAGE_BITMAP_MARGIN;
n_bytes = 1 + n_bits / 8;

lock = static_cast<lock_t*>(
    mem_heap_alloc(trx->lock.lock_heap, sizeof(lock_t) + n_bytes));

锁创建完成后首先会插入到全局 Hash 表中,然后放到对应的事务的锁链表中。相同(space,page_no)的锁会被 Hash 到同一个桶里,使用 lock_t->hash 串成链表。

HASH_INSERT(lock_t, hash, lock_sys->rec_hash,
            lock_rec_fold(space, page_no), lock);
....
	
UT_LIST_ADD_LAST(trx_locks, trx->lock.trx_locks, lock);

2016/01 月月报有一张比较直观的图:

imag

加锁分析

对于行数据的加锁是由函数 lock_rec_lock 完成,简单点来看,主要的参数是 mode(锁类型),block(包含该行的 buffer 数据页),heap_no(具体哪一行)。就可以确定加什么样的锁,以及在哪一行加。对于 mode 的值,来源于查询的逻辑,索引和二级索引的定义,隔离级别等等,可以参考这篇文章,其中简单介绍了基本的语句加什么类型的锁,对于更加复杂的情况,可以设断点调试来看。

lock fast

lock_rec_lock 首先走 lock_rec_lock_fast 逻辑,判断能否快速完成加锁。如果对应 block 上面一个锁都没有( lock_rec_get_first_on_page(block)==NULL ),那么就创建一个锁( lock_rec_create ),返回加锁成功。如果 block 上已经存在锁,满足下面代码的逻辑就返回 LOCK_REC_FAIL, 快速加锁失败。

if (lock_rec_get_next_on_page(lock)  /* 页上是否只有一个锁 */
        || lock->trx != trx  /* 拥有锁的事务不是当前事务 */
        || lock->type_mode != (mode | LOCK_REC)/* 已有锁和要加的锁模式是否相同 */
        || lock_rec_get_n_bits(lock) <= heap_no) { /* 已有锁的 n_bits 是否满足 heap_no */
            status = LOCK_REC_FAIL;
}else if (!impl) {
       /* If the nth bit of the record lock is already set
       then we do not set a new lock bit, otherwise we do
	   set */
       if (!lock_rec_get_nth_bit(lock, heap_no)) {
           lock_rec_set_nth_bit(lock, heap_no);
     	   status = LOCK_REC_SUCCESS_CREATED;
    }

如果上述条件都为 false,说明:

  • page 上只有一个锁
  • 拥有该锁的事务是当前事务
  • 锁模式相同
  • n_bits 也足够描述大小为 heap_no 的行

那么只需要设置一下 bitmap 就可以了(impl 表示加隐式锁,其实也就是不加锁)。

注:上述函数 lock_rec_get_first_on_page(block) 是从全局 Lock_sys->hash 中拿到第一个锁的,也就是 Hash 桶的第一个 node。

lock slow

lock fast 逻辑失败后就会走 lock slow 逻辑,也就是上述 lock fast 判断的四个条件中有一个或多个为 true的时候。

lock slow 首先判断当前事务上是否已经加了同等级或者更强级别的锁,函数 lock_rec_has_expl,循环取出对应行上的所有锁,它们要满足以下几个条件,就认为行上有更强的锁。

  1. 基本锁类型更强,就比如加了 LOCK_X 就不必要加 LOCK_S 了。lock_mode 基本锁类型之间的强弱关系使用 lock_strength_matrix 判断(lock_mode_stronger_or_eq)
      	static const byte lock_strength_matrix[5][5] = {
     	/**         IS     IX       S     X       AI */
     	/* IS */ {  TRUE,  FALSE, FALSE,  FALSE, FALSE},
     	/* IX */ {  TRUE,  TRUE,  FALSE, FALSE,  FALSE},
     	/* S  */ {  TRUE,  FALSE, TRUE,  FALSE,  FALSE},
     	/* X  */ {  TRUE,  TRUE,  TRUE,  TRUE,   TRUE},
     	/* AI */ {  FALSE, FALSE, FALSE, FALSE,  TRUE}
     };
    
  2. 不是插入意向锁。
  3. 没有等待,LOCK_WAIT 位为0
  4. LOCK_REC_NOT_GAP 位为0。(没有这个标记默认就是 NEXT KEY LOCK,锁住行前面的gap) 或者 要加锁的 LOCK_REC_NOT_GAP 位为 1 或者 当前行为 PAGE_HEAD_NO_SUPREMUM, 表示上界。
  5. LOCK_GAP 位为0 或者 要加锁的 LOCK_GAP 为 1 或者 当前行为 PAGE_HEAD_NO_SUPREMUM, 表示上界。

如果没有更强级别的锁,就要进行锁冲突判断,如果有锁冲突就需要入队列等待,并且还要进行死锁检测。冲突判断调用函数 lock_rec_other_has_conflicting,循环的拿出对应行上的每一个锁,调用 lock_rec_has_to_wait 进行冲突判断。以下描述 “锁” 表示循环拿出的每个锁,“当前锁” 表示要加的锁。

  • 如果锁和当前锁是相同的事务,返回 false,不需要等待。
  • 如果锁和当前锁的基本锁类型兼容,返回 false,不需要等待。兼容性根据锁的兼容矩阵判断(感觉终于和大学课本联系起来了 T-T)。兼容矩阵:
      static const byte lock_compatibility_matrix[5][5] = {
     	/**         IS     IX       S     X       AI */
     	/* IS */ {  TRUE,  TRUE,  TRUE,  FALSE,  TRUE},
     	/* IX */ {  TRUE,  TRUE,  FALSE, FALSE,  TRUE},
     	/* S  */ {  TRUE,  FALSE, TRUE,  FALSE,  FALSE},
     	/* X  */ {  FALSE, FALSE, FALSE, FALSE,  FALSE},
     	/* AI */ {  TRUE,  TRUE,  FALSE, FALSE,  FALSE}
      };
    
  • 如果上述两条都不满足,不是相同的事务,基本锁类型也不兼容,那么满足下面任意一条,同样返回false,不需要等待,否则返回 true,需要等待。
    • 如果当前锁锁住的是 supremum 或者 LOCK_GAP 为 1 并且 LOCK_INSERT_INTENTION 为 0。因为不带 LOCK_INSERT_INTENTION 的 GAP 锁不需要等待任何东西,不同的用户可以在 gap 上持有冲突的锁。
    • 如果当前锁 LOCK_INSERT_INTENTION 为 0 并且锁是 LOCK_GAP 为 1。因为行锁(LOCK_ORDINARY LOCK_REC_NOT_GAP)不需要等待一个 gap 锁。
    • 如果当前锁 LOCK_GAP 为 1,锁 LOCK_REC_NOT_GAP 为 1。同样的,因为 gap 锁没有必要等待一个 LOCK_REC_NOT_GAP 锁。
    • 如果锁 LOCK_INSERT_INTENTION 为 1。此处是最后一步,说明之前的条件都不满足,源码中备注描述如下:

      No lock request needs to wait for an insertintention lock to be removed. This is ok since our rules allow conflicting locks on gaps. This eliminates a spurious deadlock caused by a next-key lock waiting for an insert intention lock; when the insert intention lock was granted, the insert deadlocked on the waiting next-key lock. Also, insert intention locks do not disturb eachother.

举个简单的例子,如果一行数据上已经加了 LOCK_S | LOCK_REC_NOT_GAP, 再尝试去加 LOCK_X | LOCK_GAP,LOCK_S 和 LOCK_X 本身是冲突的,但是满足上述第 3 个条件,返回 FALSE,不需要等待。

如果行数据上没有更强级别的锁,也没有冲突的锁,并且加的不是隐式锁,就调用 lock_rec_add_to_queue。核心思想是复用锁对象,如果要加锁的行数据上当前没有其它锁等待,并且行所在的数据页上有相似的锁对象(lock_rec_find_similar_on_page)就可以直接设置对应行的 bitmap 位,表示加锁成功。如果有其它锁等待,就重新创建一个锁对象。

死锁检测

死锁检测的入口函数是 lock_deadlock_check_and_resolve,算法是深度优先搜索,如果在搜索过程中发现有环,就说明发生了死锁,为了避免死锁检测开销过大,如果搜索深度超过了 200(LOCK_MAX_DEPTH_IN_DEADLOCK_CHECK)也同样认为发生了死锁。

稍早版本的时候,Innodb 使用的是递归方式搜索,为了减少栈空间的开销,改为使用入栈的方式(是否还记得大学时候严蔚敏的数据结构,有两种深度搜索的方法 T-T)。MySQL 5.7 之后增加了更多面向对象的代码结构,但是实际算法并没有改变。

两个辅助数据结构:

/** Deadlock check context. */
struct lock_deadlock_ctx_t {
    const trx_t*    start;      /*!< Joining transaction that is
                    requesting a lock in an incompatible
                    mode */

    const lock_t*   wait_lock;  /*!< Lock that trx wants */

    ib_uint64_t mark_start; /*!<  Value of lock_mark_count at
                    the start of the deadlock check. */

    ulint       depth;      /*!< Stack depth */

    ulint       cost;       /*!< Calculation steps thus far */

    ibool       too_deep;   /*!< TRUE if search was too deep and
                    was aborted */
};

/** DFS visited node information used during deadlock checking. */
struct lock_stack_t {
    const lock_t*   lock;           /*!< Current lock */
    const lock_t*   wait_lock;      /*!< Waiting for lock */
    ulint       heap_no;        /*!< heap number if rec lock */
};

lock_stack_t 就是辅助的栈结构,使用一个 lock_stack_t 类型的数组来作为数据栈,初始化在创建 Lock_sys 的时候,大小为 LOCK_STACK_SIZE, 实际上是 srv_max_n_thread,最大的线程数。

lock_deadlock_ctx_t 中的 start 始终保持不变,是第一个请求锁的事务,如果深度搜索过程中锁对应的事务等于 start,那么就说明产生了环,发生死锁。wait_lock 表示搜索中的事务等待的锁。

举个简单的例子: 未命名文件 (1).png

有三个事务 A,B,C 已经获得了三行数据 1,2,3 上的 X 锁。现在事务 A 去拿数据 2 的 X 锁,阻塞等待。同样事务 B 也去拿数据 3 的 X 锁,同样阻塞等待。当事务 C 尝试去拿 数据 1 的 X 锁时,发生死锁。看下此时的死锁检测流程:

  1. ctx 中的 start 初始化为 C,wait_lock 初始化为 X1(数据1上的X锁)
  2. 根据 wait_lock=X1,调用函数 lock_get_first_lock 拿到加在数据 1 上的第一个锁 lock。在例子中就是事务 A 已经获得的 X1 锁。
  3. 然后判断 lock 对应的事务(A)是否也在等待其它锁:lock->trx->lock.que_state == TRX_QUE_LOCK_WAIT。当前事务 A 确实在等待 X2 锁。所以为 true,把当前的 lock 入栈(lock_dead_lock_push)。
  4. ctx 中的 wait_lock 更新为 lock->trx->lock.wait_lock, 也就是 X1 锁的持有者事务 A 所等待的锁 X2。
  5. 同步骤 2 ,根据 wait_lock=X2, 拿到加在数据 2 上的第一个锁赋值给 lock,也就是事务 B 持有的 X2 锁。完成一次循环。
  6. 再次进入循环,lock 对应的事务(B)同样在等待其它锁,所以把当前的 lock 入栈。
  7. ctx 中的 wait_lock 更新为 lock->trx->lock.wait_lock, 也就是 X2 锁持有者事务 B 所等待的锁 X3。
  8. 同步骤 2,根据 wait_lock=X3, 拿到加在数据 3 上的第一个锁赋值给 lock,也就是事务 C 持有的 X3 锁。完成一次循环。
  9. 再次进入循环,此时 lock->trx = C = ctx->start。死锁形成。

上述例子较为简单,没有涉及到一行数据上有多个锁,也没有出栈操作,一次深度遍历就找到了死锁,实际情况会复杂点,其它分支可以参看源码理解。

victim 选择

当发生死锁后,会选择一个代价较少的事务进行回滚操作,选择函数:lock_deadlock_select_victim(ctx)。Innodb 中的 victim 选择比较粗暴,不论死锁链条有多长,只会在 ctx->start 和 ctx->wait_lock->trx 二者中选择其一。对应上述例子,就是在事务 B 和事务 C 中选择。

具体的权重比较函数是 trx_weight_ge, 如果一个事务修改了不支持事务的表,那么认为它的权重较高,否则认为 undo log 数加持有的锁数之和较大的权重较高。

死锁信息分析

当发生死锁之后,会调用 lock_deadlock_notify 写入死锁信息,SHOW ENGINE INNODB STATUS 语句可以看到最近一次发生的死锁信息,因为死锁信息是默认写到 temp 目录的临时文件中,每次发生死锁都会覆盖写。如果打开 innodb_print_all_deadlocks可以把历史所有的死锁信息打印到 errlog 中。

关于打印出来的内容具体含义有文章已经讲的比较清楚了:mysql loverpercona。其中推荐 percona 的文章,其实发生死锁后想找出原因的话,只有死锁信息是不够的,因为 1.只显示最近两条事务的信息 2.只显示事务最近执行的一条语句。如文中推荐的做法,配合 general log 和 binlog 进行排查。

锁等待以及唤醒

锁的等待以及唤醒实际上是线程的等待和唤醒,调用函数 lock_wait_suspend_thread 挂起当前线程,配合 OS_EVENT 机制,实现唤醒和锁超时等功能,这块暂且不展开,后续仔细研究后单独写一篇文章。

Test Case 实践

在完成一个锁相关 patch 的时候发现 test case 中比较诡异的点,在执行应该产生死锁的语句时,不是每次都会产生死锁,也会发生锁超时的情况。以死锁检测中描述的例子,test case 如下:

--disable_warnings
DROP TABLE IF EXISTS t1;
--enable_warnings
create table t1(a int primary key, b int) engine=innodb;
insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9);
# ************* 3 Transactions cause deadlock. **************** #
# Hold locks
connection con1;
begin;
select * from t1 where a=1 for update;
connection con2;
begin;
select * from t1 where a=2 for update;
connection con3;
begin;
select * from t1 where a=3 for update;

# Request locks as a circle
connection con1;
insert into t1 values(11,11);
send select * from t1 where a=2 for update;
connection con2;
insert into t1 values(12,12);
send select * from t1 where a=3 for update;
connection con3;
--error ER_LOCK_DEADLOCK
select * from t1 where a=1 for update;

其中插入语句是为了产生 undo log,控制那一个事务会被选为 victim。上述 test case 预期产生死锁的语句有时会报锁超时,也就是没有正确发生死锁。起初以为是 victim 选择算法的原因,后来才发现是因为 send 语句,它只保证语句发出去,并不保证执行完毕,所以在最后一条 select 语句执行的时候也许前面的语句还没执行完,无法产生死锁。

使用 wait condition 语句等待下就没问题了:

let $wait_condition=
  SELECT COUNT(*) = 2 FROM information_schema.innodb_trx
  WHERE trx_operation_state = 'starting index read' AND
  trx_state = 'LOCK WAIT';
--source include/wait_condition.inc
--error ER_LOCK_DEADLOCK
select * from t1 where a=1 for update;

总结

Innodb 的锁系统实际上是封装了一层逻辑,和行本身数据一点关系也没有,了解之前以为会像文件锁一样,锁的粒度越小,维护起来越复杂,所以开头提到的 Berkeley DB 才只有页锁,了解之后很迷惑为什么不支持行锁… 区分一下 Innodb 同步机制使用的锁和本文介绍的锁是不同的,可以参考这篇月报, 有一个最不可思议的死锁问题,就是这两种锁之间切换导致的。锁系统作为事务中一个重要模块,需要配合其它模块,对于事务系统可以参考本期月报这篇文章

MySQL · 特性分析 · LOGICAL_CLOCK 并行复制原理及实现分析

$
0
0

在MySQL5.7 引入基于Logical clock的并行复制方案前,MySQL使用基于Schema的并行复制,使不同db下的DML操作可以在备库并发回放。在优化后,可以做到不同表table下并发。但是如果业务在Master端高并发写入一个库(或者优化后的表),那么slave端就会出现较大的延迟。基于schema的并行复制,Slave作为只读实例提供读取功能时候可以保证同schema下事务的因果序(Causal Consistency,本文讨论Consistency的时候均假设Slave端为只读),而无法保证不同schema间的。例如当业务关注事务执行先后顺序时候,在Master端db1写入T1,收到T1返回后,才在db2执行T2。但在Slave端可能先读取到T2的数据,才读取到T1的数据。

MySQL 5.7的LOGICAL CLOCK并行复制,解除了schema的限制,使得在主库对一个db或一张表并发执行的事务到slave端也可以并行执行。Logical Clock并行复制的实现,最初是Commit-Parent-Based方式,同一个commit parent的事务可以并发执行。但这种方式会存在可以保证没有冲突的事务不可以并发,事务一定要等到前一个commit parent group的事务全部回放完才能执行。后面优化为Lock-Based方式,做到只要事务和当前执行事务的Lock Interval都存在重叠,即保证了Master端没有锁冲突,就可以在Slave端并发执行。LOGICAL CLOCK可以保证非并发执行事务,即当一个事务T1执行完后另一个事务T2再开始执行场景下的Causal Consistency。

LOGICAL_CLOCK Commit-Parent-Based 模式

由于在MySQL中写入是基于锁的并发控制,所以所有在Master端同时处于prepare阶段且未提交的事务就不会存在锁冲突,在Slave端执行时都可以并行执行。因此可以在所有的事务进入prepare阶段的时候标记上一个logical timestamp(实现中使用上一个提交事务的sequence_number),在Slave端同样timestamp的事务就可以并发执行。

Master端

在SQL层实现一个全局的logical clock: commit_clock。

当事务进入prepare阶段的时候,从commit_clock获取timestamp并存储在事务中。

在transaction在引擎层提交之前,推高commit_clock。这里如果在引擎层提交之后,即释放锁后操作commit_clock,就可能出现冲突的事务拥有相同的commit-parent,所以一定要在引擎层提交前操作。

Slave端

如果事务拥有相同的commit-parent就可以并行执行,不同commit-parent的事务,需要等前面的事务执行完毕才可以执行。

LOGICAL_CLOCK Lock-Based模式原理及实现分析

Commit-Parent-Based 模式,用事务commit的点将clock分隔成了多个intervals。在同一个time interval中进入prepare状态的事务可以被并发。例如下面这个例子(引自WL#7165):

Trx1 ------------P----------C-------------------------------->
                            |
Trx2 ----------------P------+---C---------------------------->
                            |   |
Trx3 -------------------P---+---+-----C---------------------->
                            |   |     |
Trx4 -----------------------+-P-+-----+----C----------------->
                            |   |     |    |
Trx5 -----------------------+---+-P---+----+---C------------->
                            |   |     |    |   |
Trx6 -----------------------+---+---P-+----+---+---C---------->
                            |   |     |    |   |   |
Trx7 -----------------------+---+-----+----+---+-P-+--C------->
                            |   |     |    |   |   |  |

每一个水平线代表一个事务。时间从左到右。P表示prepare阶段读取commit-parent的时间点。C表示事务提交前增加全局counter的时间点。垂直线表示每个提交划分出的time interval。

从上图可以看到因为Trx5和Trx6的commit-parent都是Trx2提交点,所以可以并行执行。但是Commit-Parent-Based模式下Trx4和Trx5不可以并行执行,因为Trx4的commit-parent是Trx1的提交点。Trx6和Trx7也不可以并行执行,Trx7的commit-parent是Trx5的提交点。但Trx4和Trx5有一段时间同时持有各自的所有锁,Trx6和Trx7也是,即它们之间并不存在冲突,是可以并发执行的。

针对上面的情况,为了进一步增加复制性能,MySQL将LOGICAL_CLOCK优化为Lock-Based模式,使同时hold住各自所有锁的事务可以在slave端并发执行。

Master端

  • 添加全局的事务计数器产生事务timestamp和记录当前最大事务timestamp的clock。

    class MYSQL_BIN_LOG: public TC_LOG
    {
      ...
      public:
      /* Committed transactions timestamp */
      Logical_clock max_committed_transaction;
      /* "Prepared" transactions timestamp */
      Logical_clock transaction_counter;
      ...
    }
    
  • 对每个事务定义其lock interval,并记录到binlog中。

    在每个transaction中添加下面两个member。

    class Transaction_ctx
    {
      ...
      int64 last_committed;
      int64 sequence_number;
      ...
    }
    

    其中last_committed表示事务lock interval的起始点,是所有锁都获得时候的max-commited-timestamp。由于在一个事务执行过程中,数据库无法知道当前的锁是否为最后一个,在实际实现的时候,对每次DML操作都更新一次last_committed。

    static int binlog_prepare(handlerton *hton, THD *thd, bool all)
    {
      ...
      if (!all)//DML操作
        {
          Logical_clock& clock= mysql_bin_log.max_committed_transaction;
            thd->get_transaction()->
            store_commit_parent(clock.get_timestamp());//更新transaction中的last_committed
            sql_print_information("stmt prepare");
        }
      ...
    }
    
    class Transaction_ctx
    {
      ...
      void store_commit_parent(int64 last_arg)
      {
        last_committed= last_arg;
      }
      ...
    }
    

    sequence_number为lock interval的结束点。从理论上在最后更新last_committed后,引擎层commit前的一个时刻即可,满足这一条件的情况下时间点越靠后越能获得更大lock interval,后面在Slave执行也就能获得更大并发度。由于我们需要把该信息记录到binlog中,所以实现中在flush binlog cache到binlog文件中的时候记录。而且当前的MySQL5.7已经disable掉了设置GTID_MODE为OFF的功能,会强制记录GTID_EVENT。这样事务的last_committed和sequence_number记录在事务开头的Gtid_log_event中。

    int
    binlog_cache_data::flush(THD *thd, my_off_t *bytes_written, bool *wrote_xid)
    {
      ...
      if (flags.finalized)
      {
         trn_ctx->sequence_number= mysql_bin_log.transaction_counter.step();//获取sequence_number
    
      if (!error)
        if ((error= mysql_bin_log.write_gtid(thd, this, &writer)))//记录Gtid_log_event
      ...
    }
    
    bool MYSQL_BIN_LOG::write_gtid(THD *thd, binlog_cache_data *cache_data,
                                   Binlog_event_writer *writer)
    {
      ...
      Transaction_ctx *trn_ctx= thd->get_transaction();
      Logical_clock& clock= mysql_bin_log.max_committed_transaction;
    
      DBUG_ASSERT(trn_ctx->sequence_number > clock.get_offset());
    
      int64 relative_sequence_number= trn_ctx->sequence_number - clock.get_offset();                               
      int64 relative_last_committed=
        trn_ctx->last_committed <= clock.get_offset() ?
        SEQ_UNINIT : trn_ctx->last_committed - clock.get_offset();
      ...
      Gtid_log_event gtid_event(thd, cache_data->is_trx_cache(),
                            relative_last_committed, relative_sequence_number,//Gtid_log_event中记录relative_last_committed和relative_sequence_number
                            cache_data->may_have_sbr_stmts());
      ...
    }
    
    

    同时可以看到记录在Gtid_log_event(即binlog file中)的sequence_number和last_committed使用的是相对当前binlog文件的clock的值。即每个binlog file中事务的last_commited起始值为0,sequence_number为1。由于binlog切换后,需要等待上一个文件的事务执行完,所以这里记录相对值并不会引起冲突事务并发执行。这样做一个明显的好处是由于server在每次启动的时候都会生成新的binlog文件,max_committed_transaction和transaction_counter不需要持久化。

  • 更新max_committed_transaction。

    max_committed_transaction的更新一定要在引擎层commit(即锁释放)之前,如果之后更新,释放的锁被其他事务获取到并且获取到last_committed小于该事务的sequence_number,就会导致有锁冲突的事务lock interval却发生重叠。

    void
    MYSQL_BIN_LOG::process_commit_stage_queue(THD *thd, THD *first)
    {
      ...
      if (head->get_transaction()->sequence_number != SEQ_UNINIT)
        update_max_committed(head);
      ...
      if (head->get_transaction()->m_flags.commit_low)
      {
        if (ha_commit_low(head, all, false))
          head->commit_error= THD::CE_COMMIT_ERROR;
      ...
    
    }
    

Slave端

当事务的lock interval存在重叠,即代表他们的锁没有冲突,可以并发执行。下图中L代表lock interval的开始,C代表lock interval的结束。

- 可并发执行:
  Trx1 -----L---------C------------>
  Trx2 ----------L---------C------->

- 不可并发执行:
  Trx1 -----L----C----------------->
  Trx2 ---------------L----C------->

slave端在并行回放时候,worker的分发逻辑在函数Slave_worker *Log_event::get_slave_worker(Relay_log_info *rli)中,MySQL5.7中添加了schedule_next_event函数来决定是否分配下一个event到worker线程。对于DATABASE并行回放该函数实现为空。

bool schedule_next_event(Log_event* ev, Relay_log_info* rli)
{
  ...
  error= rli->current_mts_submode->schedule_next_event(rli, ev);
  ...
}

int
Mts_submode_database::schedule_next_event(Relay_log_info *rli, Log_event *ev)
{
  /*nothing to do here*/
  return 0;
}

Mts_submode_logical_clock的相关实现如下。

在Mts_submode_logical_clock中存储了回放事务中已经提交事务timestamp(sequence_number)的low-water-mark lwm。low-water-mark表示该事务已经提交,同时该事务之前的事务都已经提交。

class Mts_submode_logical_clock: public Mts_submode
{
  ...
  /* "instant" value of committed transactions low-water-mark */
  longlong last_lwm_timestamp;
  ...
  longlong last_committed;
  longlong sequence_number;

在Mts_submode_logical_clock的schedule_next_event函数实现中会检查当前事务是否和正在执行的事务冲突,如果当前事务的last_committed比last_lwm_timestamp大,同时该事务前面还有其他事务执行,coordinator就会等待,直到确认没有冲突事务或者前面的事务已经执行完,才返回。这里last_committed等于last_lwm_timestamp的时候,实际这两个值拥有事务的lock interval是没有重叠的,也可能有冲突。在前面lock-interval介绍中,这种情况是前面一个事务执行结束,后面一个事务获取到last_committed为前面一个的sequence_number的情况,他们的lock interval没有重叠。但由于last_lwm_timestamp更新表示事务已经提交,所以等于的时候,该事务也可以执行。

int
Mts_submode_logical_clock::schedule_next_event(Relay_log_info* rli,
                                               Log_event *ev)
{
  ...
  switch (ev->get_type_code())
  {
    case binary_log::GTID_LOG_EVENT:
    case binary_log::ANONYMOUS_GTID_LOG_EVENT:
    // TODO: control continuity
    ptr_group->sequence_number= sequence_number=
      static_cast<Gtid_log_event*>(ev)->sequence_number;
    ptr_group->last_committed= last_committed=
      static_cast<Gtid_log_event*>(ev)->last_committed;
      break;

      default:

        sequence_number= last_committed= SEQ_UNINIT;

        break;
  }
  ...
  if (!is_new_group)
  {
    longlong lwm_estimate= estimate_lwm_timestamp();
    if (!clock_leq(last_committed, lwm_estimate) && //如果last_committed > lwm_estimate
        rli->gaq->assigned_group_index != rli->gaq->entry) //当前事务前面还有执行的事务
    {
      ...
      if (wait_for_last_committed_trx(rli, last_committed, lwm_estimate))
      ...
    }
    ...
  }
}

@return   true  when a "<=" b,
          false otherwise
*/
static bool clock_leq(longlong a, longlong b)
{
if (a == SEQ_UNINIT)
  return true;
else if (b == SEQ_UNINIT)
  return false;
else
  return a <= b;
}

bool Mts_submode_logical_clock::
wait_for_last_committed_trx(Relay_log_info* rli,
                            longlong last_committed_arg,
                            longlong lwm_estimate_arg)
{
  ...
  my_atomic_store64(&min_waited_timestamp, last_committed_arg);//设置min_waited_timestamp
  ...
  if ((!rli->info_thd->killed && !is_error) &&
    !clock_leq(last_committed_arg, get_lwm_timestamp(rli, true)))//真实获取lwm并检查当前是否有冲突事务
  {

    //循环等待直到没有冲突事务
    do
    {
      mysql_cond_wait(&rli->logical_clock_cond, &rli->mts_gaq_LOCK);
    }
    while ((!rli->info_thd->killed && !is_error) &&
          !clock_leq(last_committed_arg, estimate_lwm_timestamp()));      
  ...                        
  }
}

上面循环等待的时候,会等待logical_clock_cond条件然后做检查。该条件的唤醒逻辑是:当回放事务结束,如果存在等待的事务,即检查min_waited_timestamp和当前curr_lwm(lwm同时会被更新),如果min_waited_timestamp小于等于curr_lwm,则唤醒等待的coordinator线程。

void Slave_worker::slave_worker_ends_group(Log_event* ev, int error)
{
  ...
  if (mts_submode->min_waited_timestamp != SEQ_UNINIT)
  {
    longlong curr_lwm= mts_submode->get_lwm_timestamp(c_rli, true);//获取并更新当前lwm。

    if (mts_submode->clock_leq(mts_submode->min_waited_timestamp, curr_lwm))
    {
      /*
        There's a transaction that depends on the current.
      */
      mysql_cond_signal(&c_rli->logical_clock_cond);//唤醒等待的coordinator线程
    }
  }
  ...
}

LOGICAL_CLOCK Consistency的分析

无论是Commit-Parent-Based还是Lock-Based,Master端一个事务T1和其commit后才开始的事务T2在Slave端都不会被并发回放,T2一定会等T1执行结束才开始回放。因此LOGICAL_CLOCK并发方式在Slave端只读时候的上述场景中能够保证Causal Consistency。但如果事务T2只是等待事务T1执行commit成功后再执行commit操作,那么事务T1和T2在Slave端的执行顺序就无法得到保证,用户在Slave端读取可能先读到T2再读到T1的提交。这种场景就无法满足Causal Consistency。

slave_preserve_commit_order的简要介绍

我们在前面的介绍中了解到,当slave_parallel_type为DATABASE和LOGICAL_CLOCK的时候,在Slave端的读取操作都存在场景无法满足Causal Consistency,都可能存在Slave端并行回放时候事务顺序发生变化。复制进行中时业务方可能会在某一时刻观察到Slave的GTID_EXECUTED有空洞。那如果业务需要完整的保证Causal Consistency呢,除了使用单线程复制,是否可以在并发回放的情况下满足这一需求?

MySQL提供了slave_preserve_commit_order,使LOGICAL_CLOCK的并发执行时候满足Causal Consistency,实际获得Sequential Consistency。这里Sequential Consistency除了满足之前分析的客户端事务T1、T2先后执行操作的场景外,还满足即使T1\T2均并发执行的时候,第三个客户端在主库观察到T1先于T2发生,在备库也会观察到T1先于T2发生,即在备库获得和主库完全一致的执行顺序。

slave_preserve_commit_order实现的关键是添加了Commit_order_manager类,开启该参数会在获取worker时候向Commit_order_manager注册事务。

Slave_worker *
Mts_submode_logical_clock::get_least_occupied_worker(Relay_log_info *rli,
                                                     Slave_worker_array *ws,
                                                     Log_event * ev)
{
  ...
  if (rli->get_commit_order_manager() != NULL && worker != NULL)
    rli->get_commit_order_manager()->register_trx(worker);
  ...
}

void Commit_order_manager::register_trx(Slave_worker *worker)
{
  ...
  queue_push(worker->id);
  ...
}

在事务进入FLUSH_STAGE前, 会等待前面的事务都进入FLUSH_STAGE。

int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit)
{
  ...
  if (has_commit_order_manager(thd))
  {
    Slave_worker *worker= dynamic_cast<Slave_worker *>(thd->rli_slave);
    Commit_order_manager *mngr= worker->get_commit_order_manager();

    if (mngr->wait_for_its_turn(worker, all)) //等待前面的事务都进入FLUSH\_STAGE
    {
      thd->commit_error= THD::CE_COMMIT_ERROR;
      DBUG_RETURN(thd->commit_error);
    }

    if (change_stage(thd, Stage_manager::FLUSH_STAGE, thd, NULL, &LOCK_log))
      DBUG_RETURN(finish_commit(thd));
    }
  ...
}

bool Commit_order_manager::wait_for_its_turn(Slave_worker *worker,
                                                  bool all)
{
  ...
  mysql_cond_t *cond= &m_workers[worker->id].cond;
  ...
  while (queue_front() != worker->id)
  {
    ...
    mysql_cond_wait(cond, &m_mutex);//等待condition
  }
...                                                    
}

当该事务进入FLUSH_STAGE后,会通知下一个事务的worker可以进入FLUSH_STAGE。

bool
Stage_manager::enroll_for(StageID stage, THD *thd, mysql_mutex_t *stage_mutex)
{
    bool leader= m_queue[stage].append(thd);
    if (stage == FLUSH_STAGE && has_commit_order_manager(thd))
    {
      Slave_worker *worker= dynamic_cast<Slave_worker *>(thd->rli_slave);
      Commit_order_manager *mngr= worker->get_commit_order_manager();

      mngr->unregister_trx(worker);
    }
    ...
}

void Commit_order_manager::unregister_trx(Slave_worker *worker)
{
  ...
  queue_pop();//退出队列
  if (!queue_empty())
    mysql_cond_signal(&m_workers[queue_front()].cond);//唤醒下一个
  ...
}

在保证binlog flush的顺序后,通过binlog_order_commit即可获取同样的提交顺序。

浅谈LOGICAL_CLOCK依然存在的不足

LOGICAL_CLOCK为了准确性和实现的需要,其lock interval实际实现获得的区间比理论值窄,会导致原本一些可以并发执行的事务在Slave中没有并发执行。当使用级联复制的时候,这会后面层级的Slave并发度会越来越小。

实际很多业务中,虽然事务没有Lock Interval重叠,但这些事务操作的往往是不同的数据行,也不会有锁冲突,是可以并发执行,但LOGICAL_CLOCK的实现无法使这部分事务得到并发回放。

虽然有上述不足,LOGICAL_CLOCK的复制方式在有多客户端写入同样database的场景中相比DATABASE能够获得很大的复制性能提升,实际场景中很多业务的写入也都是在一个database下。

PgSQL · 源码分析 · AutoVacuum机制之autovacuum launcher

$
0
0

背景

根据之前月报的分析,PostgreSQL中的MVCC机制(详见月报)同时存储新旧版本的元组,对于经常更新的表来说,会造成表膨胀的情况。为了解决这个问题,PostgreSQL 引入了VACUUMANALYZE命令,并且引入了AutoVacuum自动清理。

在PostgreSQL中,AutoVacuum自动清理操作包括:

  • 删除或重用无效元组的磁盘空间
  • 更新数据统计信息,保证执行计划更优
  • 更新visibility map,加速index-only scans (详见文档
  • 避免XID 回卷造成的数据丢失(详见文档

为了实现自动清理,PostgreSQL引入了两种类型的辅助进程:

  • autovacuum launcher
  • autovacuum worker

本文主要分析autovacuum launcher进程相关操作,autovacuum worker比较复杂和重要,我们将在下期月报详细分析。

autovacuum launcher

autovacuum launcher 进程可以理解为AutoVacuum机制的守护进程,周期性地调度autovacuum worker进程。

相关参数

autovacuum launcher 进程在postgresql.conf文件中的相关配置参数(支持对每个表单独配置参数,方法见文档)如下:

  • track_counts:是否开启统计信息收集功能。
  • autovacuum:是否启动系统自动清理功能,默认值为on。
  • autovacuum_max_workers:设置系统自动清理工作进程的最大数量。
  • autovacuum_naptime:设置两次系统自动清理操作之间的间隔时间。
  • autovacuum_vacuum_cost_limit:声明将在自动VACUUM操作里使用的开销限制数值。
  • autovacuum_vacuum_cost_delay :声明如果超过了上面的开销限制,则需要延迟清理的时间。
  • autovacuum_freeze_max_age:设置需要强制对数据库进行清理的XID上限值。
  • autovacuum_multixact_freeze_max_age:设置需要强制对数据库进行清理的multi XID上限值。

因为AutoVacuum依赖于统计信息,所以只有track_counts=on 且autovacuum=on 时,PostgreSQL才启动autovacuum launcher 进程。

autovacuum launcher 进程会周期性地创建autovacuum worker 进程,最多能够创建autovacuum_max_workers个autovacuum worker 进程。我们将会从下面二个方面来分析autovacuum launcher:

  • 执行周期,即autovacuum launcher进程的休眠时间
  • autovacuum worker 调度管理

执行周期

上文的参数autovacuum_naptime决定了autovacuum launcher 的基本执行周期。在PostgreSQL中,理想状态是在autovacuum_naptime的时间内对所有的数据库进行一次清理,即每个数据库希望能够分配到autovacuum_naptime/(数据库的个数) 的时间片去创建一个autovacuum worker进行自动清理。这就要求autovacuum launcher 进程每经过autovacuum_naptime/(数据库的个数) 的时间就要被唤醒,并启动对应的autovacuum worker 进程。

基于此设计思想,autovacuum launcher 进程中维护了一个数据库列表DatabaseList,其中维护了各个database的期望AutoVacuum时间等信息,具体的元素结构如下:

/* struct to keep track of databases in launcher */
typedef struct avl_dbase
{
	Oid			adl_datid;		/* hash key -- must be first */
	TimestampTz adl_next_worker;
	int			adl_score;
	dlist_node	adl_node;
} avl_dbase;

其中:

  • adl_datid 表示对应database oid,是该Hash表结构的key
  • TimestampTz 表示该database下次将要进行AutoVacuum的时间
  • adl_score 表示该database对应的分值,该分值决定该database启动worker 的时间
  • adl_node 表示该avl_dbase对应的在列表中的位置信息包括:
struct dlist_node
{
	dlist_node *prev;
	dlist_node *next;
};

当autovacuum launcher初始化时,DatabaseList为空,需要重建,具体步骤如下:

  • 刷新统计信息
  • 建立一个Hash表dbhash存储adl_datid,即数据库ID和avl_dbase(上面的结构体)的对应关系
  • 获取当前所有的数据库和数据库的统计信息,如果存在统计信息且不存在dbhash中,则插入到dbhash中,并将其avl_dbase->adl_score加1,adl_score最后统计存在统计信息且不存在dbhash中的database数量
  • 将Hash 表中的avl_dbase按照adl_score排序,按照顺序给每个database的adl_next_worker赋值为当前时间+每个数据库分到的时间片*其在列表中的顺序。其中每个数据库分到的时间片= autovacuum_naptime/adl_score

可以看出,创建完成之后,DatabaseList中存储按照期望执行自动化清理的时间从大到小排序的数据库信息。

通过分析代码发现,autovacuum launcher进程的执行周期主要是由launcher_determine_sleep 函数来决定的:

  1. 如果autovacuum worker 空闲列表(详见下文autovacuum worker 管理中的分析)为空,autovacuum launcher进程睡眠autovacuum_naptime 后唤醒,否则进行下面的判断
  2. 如果当前DatabaseList不为空,则将DatabaseList列表尾部的数据库期望AutoVacuum的时间戳作为下次唤醒的时间
  3. 除上面之外的情况,用autovacuum_naptime作为执行周期

如果当前的时间已经晚于第2种情况得到的时间戳,则纠正为autovacuum launcher最小的休眠时间100ms。

综上所述,我们知道:

  • autovacuum launcher 基本周期是autovacuum_naptime。如果当前不存在空闲的autovacuum worker,则休眠autovacuum_naptime
  • 在一个autovacuum_naptime工作周期里,每个database 数据库期望占用autovacuum_naptime/adl_score 时间(adl_score可以简单理解为当前存在统计信息的database总数),当该时间到达时,launch a worker,自动清理该数据库

autovacuum worker 管理

因为AutoVacuum的具体过程会消耗数据库资源(比如CPU),可能影响性能,在PostgreSQL中规定,autovacuum launcher可以启动最多autovacuum_max_workers个autovacuum worker 进程。为了管理autovacuum worker 进程,PostgreSQL维护了共享内存AutoVacuumShmemStruct来存储当前所有autovacuum worker的情况,其结构如下:

typedef struct
{
	sig_atomic_t av_signal[AutoVacNumSignals];
	pid_t		av_launcherpid;
	dlist_head	av_freeWorkers;
	dlist_head	av_runningWorkers;
	WorkerInfo	av_startingWorker;
	AutoVacuumWorkItem av_workItems[NUM_WORKITEMS];
} AutoVacuumShmemStruct;

其中:

  • av_signal目前是由长度为2的int 数组组成,分别用0,1来表示是否启动worker失败,是否需要重新计算对每个autovacuum worker 的资源限制
  • av_launcherpid代表autovacuum launcher 的pid
  • av_freeWorkers代表空闲的autovacuum woker 相应的WorkerInfoData 列表,WorkerInfoData的具体结构如下:
/*-------------
 * This struct holds information about a single worker's whereabouts.  We keep
 * an array of these in shared memory, sized according to
 * autovacuum_max_workers.
 *
 * wi_links		entry into free list or running list
 * wi_dboid		OID of the database this worker is supposed to work on
 * wi_tableoid	OID of the table currently being vacuumed, if any
 * wi_sharedrel flag indicating whether table is marked relisshared
 * wi_proc		pointer to PGPROC of the running worker, NULL if not started
 * wi_launchtime Time at which this worker was launched
 * wi_cost_*	Vacuum cost-based delay parameters current in this worker
 *
 * All fields are protected by AutovacuumLock, except for wi_tableoid which is
 * protected by AutovacuumScheduleLock (which is read-only for everyone except
 * that worker itself).
 *-------------
 */
typedef struct WorkerInfoData
{
	dlist_node	wi_links;
	Oid			wi_dboid;
	Oid			wi_tableoid;
	PGPROC	   *wi_proc;
	TimestampTz wi_launchtime;
	bool		wi_dobalance;
	bool		wi_sharedrel;
	int			wi_cost_delay;
	int			wi_cost_limit;
	int			wi_cost_limit_base;
} WorkerInfoData;
  • av_runningWorkers代表正在运行的autovacuum woker 相应的WorkerInfoData 列表
  • av_startingWorker代表正在启动的autovacuum woker 相应的WorkerInfoData
  • av_workItems存储着一组(256个)AutoVacuumWorkItem。AutoVacuumWorkItem存储着每个autovacuum worker的item 信息。

从上面可以看出,autovacuum launcher中维护三种不同状态的autovacuum worker 进程列表:

  • 空闲的autovacuum worker进程列表
  • 正在启动的autovacuum worker进程
  • 运行中的autovacuum worker进程列表

autovacuum launcher 通过维护AutoVacuumShmemStruct的信息,达到调度autovacuum worker的作用,具体如下:

  • 初始化共享内存时,初始化长度为autovacuum_max_workers的空闲autovacuum worker进程列表。
  • 如果autovacuum launcher进程需要一个worker进程,空闲列表为不空且没有启动中的autovacuum worker进程,则启动一个autovacuum worker进程,并从空闲列表取出一个autovacuum worker 进程,将共享内存中的av_startingWorker赋值为该autovacuum worker的WorkerInfoData。
  • 如果autovacuum worker启动成功,将该autovacuum worker 的WorkerInfoData放入共享内存的av_runningWorkers列表中。
  • autovacuum worker进程退出,将该autovacuum worker 的WorkerInfoData放入共享内存的av_freeWorkers列表中

其中需要注意的是autovacuum launcher进程中只允许存在一个“启动中”状态的autovacuum worker进程,如果启动超时(状态一直为“启动中”时间超过autovacuum_naptime)将被取消启动。autovacuum launcher进程调用launch_worker函数来选择一个database,并为其启动相应的autovacuum worker。launch_worker主要做两件事情:

  • 选取合适的database,并且向postmaster 发送信号创建worker进程
  • 更新该database的期望autovaccum的时间为当前时间+autovacuum_naptime/adl_score

其中,符合下面条件的database将会启动一个worker,进行自动清理:

  • 数据库的最大xid超过配置的autovacuum_freeze_max_age或者最大multixact超过autovacuum_multixact_freeze_max_age。
  • 没有符合上面条件的数据库,则选择数据库列表中最长时间未执行过自动清理操作的数据库。

至此,我们可以概括出autovacuum launcher的大致操作:

  • 调用函数rebuild_database_list,初始化时DatabaseList。DatabaseList保存每个database的laucher期望运行时间等信息
  • 设置进程休眠时间。根据空闲autovacuum worker列表和数据库列表DatabaseList来计算休眠的时间。同时autovacuum launcher休眠也可以被其他信号中断。
  • 处理失败的autovacuum worker进程列表,重新向Postmaster发送信号创建autovacuum worker进程。
  • 处理一直启动的autovacuum worker进程,如果超时,则重置该autovacuum worker信息。
  • 如果DatabaseList为空或者当前时间已经晚于DatabaseList中存储的各个数据库的期望执行autovacuum的最早时间,则会调用launch_worker。launch_worker会去选择合适的数据库并向Postmaster发送信号创建autovacuum worker进程。

总结

经过上面的分析,我们可以得出以下结论:

  • 优先对xid或者multixact 超限的数据库进行自动清理
  • 越长时间没有经过自动清理的数据库优先被清理
  • autovacuum launcher两次启动autovacuum worker的时间间隔不会大于autovacuum_naptime
  • 最多只能启动autovacuum_max_workers个autovacuum worker 进程

除此之外,autovacuum launcher中还涉及到对各个autovacuum_worker的资源限制,这部分内容我们将会和autovacuum_worker进程一起在下次月报进行分析。

MSSQL · 最佳实践 · SQL Server备份策略

$
0
0

摘要

在上一期月报中我们分享了SQL Server三种常见的备份技术及工作方式,本期月报将分享如何充分利用三者的优点来制定SQL Server数据库的备份和还原策略以达到数据库快速灾难恢复能力。

上期月报:MSSQL · 最佳实践 · SQL Server三种常见备份

三个术语

在详细介绍SQL Server的灾备策略之前,我们先简要介绍三个重要的术语:

  • RTO (Recovery Time Objective)恢复时间目标,是指出现灾难后多长时间能成功恢复数据库,即企业可容许服务中断的最大时间长度。比如说灾难发生后一天内恢复成功,则RTO值就是二十四小时;

  • RPO (Recovery Point Objective)恢复点目标,是指服务恢复后,恢复回来的数据所对应的最新时间点。比如企业每天凌晨零晨进行完全备份一次,那么这个全备恢复回来的系统数据只会是最近灾难发生当天那个凌晨零时的资料;

  • ERT(Estimated Recovery Time)预估恢复时间,是指根据备份链路的长度和备份文件的大小以及设备的还原效率来估算的服务恢复时间。 从以上的三个术语解释来看,最优的灾备方案是RTO极小,即出现故障能够立马恢复数据;RPO无线接近故障时间点,即最少的数据丢失;ERT最小,即可快速恢复服务。但是,现实场景中的灾备方案往往很难达到如此优化的方案。

制定灾备策略

以上三个术语是衡量灾备方案和还原策略优劣的重要指标,我们的灾备策略的目标也是无限的靠近RTO、RPO和ERT的最优值。以下我们列举一个典型的灾备场景来分析和解答: 假设某个企业对SQL Server数据库DBA提出的灾难恢复要求是数据丢失不超过一小时(RPO不超过一小时),在尽可能短的时间内(RTO尽可能短)恢复应用数据库服务,且灾备策略必须具备任意时间点还原的能力。 综合上一期月报分享,我们先抛开灾备策略的优劣来看,我们看看三种典型的灾备策略方案是否可以实现RPO?

  • 每个小时一次完全备份:备份文件过大,备份还原效率低下,这种方案无法实现任意时间点的还原;
  • 每天一个完全备份 + 每小时一个日志备份:解决了备份文件过大和效率问题,也可以实现任意时间点还原,但是拉长了日志还原链条;
  • 每天一个完全备份 + 每六个小时一个差异备份 + 每小时一个日志备份:具备任意时间点还原的能力,综合了备份文件大小、效率和备份链条长度。 从这个分析来看,也恰好应证了上一期的月报中的结论,即:完全备份集是所有备份的基础,但数据量大且备份耗时;事务日志备份集相对较小且快速,但会拉长备份文件还原链条,增大还原时间开销;差异备份解决了事务日志备份链条过长的问题。

时间点恢复

我们假设备份数据增量为每小时1GB,初始完全备份大小为100GB,按照时间维度计算每小时产生的备份集大小,统计如下: 01.png

典型场景

假设我们非常重要的订单数据库,在13:30被人为的错误删除掉了,灾备系统在14:00进行了一个事务日志备份。那么,这个事务日志备份对我们业务的灾难恢复就非常关键和重要了,它使得我们有能力将数据库还原到13:29:59这个时间点。如此,我们只会丢失13:30 - 14:00之间的这半个小时的数据(实际上我们也有能力找回13:30 - 14:00)。但是,如果没有14:00这个事务日志备份文件,但存在13:00的事务日志备份文件的话,我们的系统数据会丢失13:00 - 14:00之间这一个小时的数据,一个小时的数据丢失是公司不被允许的。场景如下图展示:

02.png

模拟备份策略

我们可以使用以下方法模拟灾备方案和灾难恢复的步骤:

  • 第一步:创建测试数据库并修改为FULL模式
  • 第二步:创建一个完全备份
  • 第三步:每一个小时做一次事务日志备份
  • 第四步:每六个小时做一个差异备份 详细的模拟方法和语句如下所示:
-- Create testing DB
IF DB_ID('TestDR') IS NULL
	CREATE DATABASE TestDR;
GO

-- Change Database to FULL Recovery Mode 
-- for time point recovery supporting
ALTER DATABASE [TestDR] SET RECOVERY FULL WITH NO_WAIT
GO

USE TestDR
GO

-- Create Testing Table
IF OBJECT_ID('dbo.tb_DR', 'U') IS NOT NULL
	DROP TABLE dbo.tb_DR
GO

CREATE TABLE dbo.tb_DR
(
	ID INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
	Comment VARCHAR(100) NULL,
	Indate DATETIME NOT NULL DEFAULT(GETDATE())
);
GO

USE TestDR
GO
-- Init data
INSERT INTO dbo.tb_DR(Comment)
SELECT 'Full Backup @ 00:00';
-- Take Full Backup
BACKUP DATABASE [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@00:00_FULL.bak' WITH COMPRESSION,INIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 01:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@01:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 02:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@02:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 03:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@03:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 04:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@04:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 05:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@05:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;


INSERT INTO dbo.tb_DR(Comment)
SELECT 'DIFF Backup @ 06:00';
-- Take DIFF Backup
BACKUP DATABASE [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@06:00_DIFF.bak' WITH DIFFERENTIAL,COMPRESSION,NOINIT,STATS=5;



INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 07:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@07:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 08:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@08:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 09:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@09:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 10:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@10:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 11:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@11:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;


INSERT INTO dbo.tb_DR(Comment)
SELECT 'DIFF Backup @ 12:00';
-- Take DIFF Backup
BACKUP DATABASE [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@12:00_DIFF.bak' WITH DIFFERENTIAL,COMPRESSION,NOINIT,STATS=5;


INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 13:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@13:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

-- This record is similate for point time recovery
INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 13:29:59';

WAITFOR DELAY '00:00:02'

INSERT INTO dbo.tb_DR(Comment)
SELECT 'Transaction Log Backup @ 14:00';
-- Take TRN Backup
BACKUP LOG [TestDR] TO 
DISK =N'C:\Temp\TestDR_20171217@14:00_LOG.trn' WITH COMPRESSION,NOINIT,STATS=5;

-- Query Data
SELECT * FROM dbo.tb_DR;

我们看看测试表的数据情况,方框选中的这条数据是需要我们恢复出来的: 03.png

我们也可以再次检查数据库备份历史记录,来确保灾备信息准确性:

SELECT
bs.database_name AS 'Database Name',
bs.backup_start_date AS 'Backup Start',
bs.backup_finish_date AS 'Backup Finished',
DATEDIFF(MINUTE, bs.backup_start_date, bs.backup_finish_date) AS 'Duration (min)',
bmf.physical_device_name AS 'Backup File',
CASE 
	WHEN bs.[type] = 'D' THEN 'Full Backup' 
	WHEN bs.[type] = 'I' THEN 'Differential Database' 
	WHEN bs.[type] = 'L' THEN 'Log' 
	WHEN bs.[type] = 'F' THEN 'File/Filegroup' 
	WHEN bs.[type] = 'G' THEN 'Differential File'
	WHEN bs.[type] = 'P' THEN 'Partial'  
	WHEN bs.[type] = 'Q' THEN 'Differential partial' 
END
AS 'Backup Type'
FROM msdb.dbo.backupmediafamily bmf WITH(NOLOCK)
	INNER JOIN msdb..backupset bs WITH(NOLOCK)
	ON bmf.media_set_id = bs.media_set_id
WHERE bs.database_name = 'TestDR'
ORDER BY bs.backup_start_date ASC

查询的灾备历史记录展示如下: 04.png

从这个备份历史记录来看,和我们的测试表中的数据是吻合且对应起来的。

灾难恢复步骤

接下来,我们需要根据TestDR数据库的备份文件,将数据库恢复到模拟时间点2017-12-17 23:04:45.130(即真实场景中的发生人为操作失误的时间点13:30),为了包含ID为15的这条数据,我们就恢复到2017-12-17 23:04:46.130时间点即可,然后检查看看ID等于15的这条记录是否存在,如果这条记录存在,说明我们备份和还原策略工作正常,否则无法实现公司的要求。为了试验的目的,我们先把TestDR数据库删除掉(真实环境,请不要随意删除数据库,这很危险):

-- for testing, drop db first.
USE [master]
GO
ALTER DATABASE [TestDR] SET  SINGLE_USER WITH ROLLBACK IMMEDIATE
GO
DROP DATABASE [TestDR]
GO

恢复方案一:全备 + 日志备份

为了实现灾难恢复,我们需要先把完全备份文件恢复,然后一个接一个的事务日志备份按时间升序恢复,在最后一个事务日志恢复的时候,使用STOPAT关键字恢复到时间点并把数据库Recovery回来带上线,详细的代码如下:

USE [master]
GO
-- restore from full backup
RESTORE DATABASE TestDR
FROM DISK = 'C:\Temp\TestDR_20171217@00:00_FULL.bak' WITH NORECOVERY, REPLACE

-- restore from log backup
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@01:00_LOG.trn' WITH NORECOVERY
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@02:00_LOG.trn' WITH NORECOVERY
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@03:00_LOG.trn' WITH NORECOVERY
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@04:00_LOG.trn' WITH NORECOVERY
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@05:00_LOG.trn' WITH NORECOVERY

-- skip diff backup at 06:00
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@07:00_LOG.trn' WITH NORECOVERY
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@08:00_LOG.trn' WITH NORECOVERY
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@09:00_LOG.trn' WITH NORECOVERY
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@10:00_LOG.trn' WITH NORECOVERY
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@11:00_LOG.trn' WITH NORECOVERY

-- skip diff backup at 12:00
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@13:00_LOG.trn' WITH NORECOVERY

-- restore from log and stop at 2017-12-17 23:04:46.130
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@14:00_LOG.trn' WITH STOPAT = '2017-12-17 23:04:46.130', RECOVERY

-- Double check test data
USE TestDR
GO
SELECT * FROM dbo.tb_DR

从测试表中的数据展示来看,我们已经成功的将ID为15的这条数据还原回来,即发生人为失误导致的数据丢失(灾难)已经恢复回来了。 05.png

细心的你一定发现了这个恢复方案,使用的是完全备份 + 很多个事务日志备份来恢复数据的,这种方案的恢复链条十分冗长,在这里,恢复到第13个备份文件才找回了我们想要的数据。有没有更为简单,恢复更为简洁的灾难恢复方案呢?请看恢复方案二。

恢复方案二:全备 + 差备 + 日志备份

为了解决完全备份 + 日志备份恢复链条冗长的问题,我们接下来采取一种更为简洁的恢复方案,即采用完全备份 + 差异备份 + 事务日志备份的方法来实现灾难恢复,方法如下:

--=========FULL + DIFF + TRN LOG
USE [master]
GO
-- restore from full backup
RESTORE DATABASE TestDR
FROM DISK = 'C:\Temp\TestDR_20171217@00:00_FULL.bak' WITH NORECOVERY, REPLACE

-- restore from diff backup
RESTORE DATABASE TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@12:00_DIFF.bak' WITH NORECOVERY

-- restore from trn log
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@13:00_LOG.trn' WITH NORECOVERY

-- restore from log and stop at 2017-12-17 23:04:46.130
RESTORE LOG TestDR 
FROM DISK = 'C:\Temp\TestDR_20171217@14:00_LOG.trn' WITH STOPAT = '2017-12-17 23:04:46.130', RECOVERY

-- Double check test data
USE TestDR
GO
SELECT * FROM dbo.tb_DR

从这个灾难恢复链路来看,将灾难恢复的步骤从13个备份文件减少到4个备份文件,链路缩短,方法变得更为简洁快速。当然同样可以实现相同的灾难恢复效果,满足公司的对数据RPO的要求。

06.png

恢复方案三:使用SSMS

当然灾难恢复的方法除了使用脚本以外,微软的SSMS工具通过IDE UI操作也是可以达到相同的效果,可以实现相同的功能,方法如下:右键点击你需要还原的数据库 => Tasks => Restore => Database,如下如所示: 07.png选择Timeline => Specific date and time => 设置你需要还原到的时间点(这里选择2017-12-17 23:04:46) => 确定。 08.png时间点恢复还原时间消耗取决于你数据库备份文件的大小,在我的例子中,一会功夫,就已经还原好你想要的数据库了。

最后总结

本期月报是继前一个月分享SQL Server三种常见的备份技术后的深入,详细讲解了如何制定灾备策略来满足企业对灾难恢复能力的要求,并以一个具体的例子来详细阐述了SQL Server灾备的策略和灾难恢复的方法,使企业在数据库灾难发生时,数据损失最小化。但是,这里还是有一个疑问暂时留给读者:为什么我们可以使用多种灾难恢复(我们这里只谈到了两种,实际上还有其他方法)的方法呢?到底底层的原理是什么的?预知后事如何,我们下期月报分享。

参考

典型场景中的场景图

Point-in-time recovery

MySQL · 最佳实践 · 一个“异常”的索引选择

$
0
0

背景

在处理一个用户性能问题的时候, 发现有一个全表扫描语句, 上下文如下: 这是很奇怪的,

Tips: MySQL在执行条件判断时,若参数类型与字段类型不匹配, 则会作类型转换, 符合转换规则的, 转换完成后可以利用索引

而当参数为字符串,字段类型为整型时, 这个转换是成立的, 比如这个case.

因此我们有必要查一下是什么原因.

分析

在优化器执行流程中, 要选择索引, 需要先判断where的部分的条件操作能否使用索引.经典的有,

Tips: 在字段上作函数操作,是无法使用索引的. 比如 where c+1 = 10

因此怀疑是在判断过程出了什么岔子.

调用栈 mysql_select->mysql_execute_select->JOIN::optimize->get_quick_record_count->SQL_SELECT::test_quick_select->get_mm_tree->get_full_func_mm_tree->get_func_mm_tree

一路下来都是预期内调用,直到 get_func_mm_tree返回了 NULL, 表示这个语句无法使用索引.

然后我们发现了这样一段代码,

简单说,就是在IN的入口有一个判断, 如果in中的字段类型不兼容, 则认为不可使用索引. 而这个arg_types_compatible 的赋值逻辑是: if (type_cnt == 1) arg_types_compatible = TRUE;也就是说,当IN列表中出现超过一个字段类型时, 就认为类型不兼容.

这个本来是一个优化操作, 提前判断避免额外的后续判断.

对照实验

至此,我们就清楚, 问题来自于IN和列表中的类型问题. 以下几个对照试验可以作为验证:

where id in ('1', '1') 可以使用索引

where id = '1' or id = 1 可以使用索引

是不是bug?

如果把他当作bug处理,只需要在get_func_mm_tree函数中去掉上述最后一个图中的判断逻辑即可, 有兴趣的读者可以自己注释后验证.

前面的用户场景使用的是5.6,作为验证,我们测试了5.7版本,结果发现,5.7中可以相同的语句序列使用此索引!

翻了代码发现果然是去掉了这个判断.

但有趣的是, 官方并不是为了解决这个问题而作的修改, 实际上是在支持(a,b) in (X, Y)这样的语法时,由于这个语句就不能要求XY必须相同类型,因此去掉了这个判断, “阴差阳错”把这个问题改过来了.

翻了下commit log, 发现在5.7.3修复的, 因5.7.3之后,文章开头的这个例子的优化器结果就变了.

更多

所以我们看到, 实际上即使是小版本, 也是可能改变程序行为的.

而版本更新,尤其是要使用官方最新版本的特性, 有时候是必须作程序升级的.

而即使是小版本号的不同, 也可能对业务造成影响.

上述这个需求和现状,其实是有矛盾的, 运维的同学们可以考虑下, 如何处理这种潜在风险呢?

PgSQL · 内核开发 · 利用一致性快照迁移你的数据

$
0
0

一.背景

众所周知 PostgreSQL 的物理复制以稳定可靠著称,目前经成为默认的高可用方案。但较少有人关注到,PostgreSQL 的逻辑复制经过几个大版本迭代,已异常的强大。它结合了很多黑科技,本文就一一介绍给大家。

二. MVCC 和 snapshot

大家都知道,PostgreSQL 使用 MVCC 和 WAL 两项技术实现 ACID 特性。 MVCC 即多版本并发控制,简单的说,是数据库中的同一份有效数据,会同时保留多个修改版;用 snapshot 即快照,来定义一个事务能看到哪些版本的数据。

2.1 导出一个快照的唯一标识,即 snapshotid

一个连接进行查询或修改数据时会开启一个事务(只读事务或读写事务),事务的可见性由事务中的快照决定。 也就是说,两个事务的快照相同,那么看到的数据是完全相同的。 PostgreSQL 9.3 开始,支持函数:pg_export_snapshot,它能返回一个快照的唯标识,便于其他事务也使用相同的快照查看数据。

 BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT pg_export_snapshot();
 pg_export_snapshot
--------------------
 000003A1-1
(1 row)

详见:1.如何导出一个快照 Table 9-64. Snapshot Synchronization Functions

2.2 启动对应快照的事务

从 PostgreSQL 9.3 开始,支持开启一个事务到一个指定的的快照版本。

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION SNAPSHOT '000003A1-1';

2.开启指定一个存在的快照的详细说明 SET TRANSACTION

2.3 利用导出快照实现严格一致的并行逻辑备份

如果使用多个连接并行的备份数据库中的不同的表,在数据库还在同时修改数据库的状态下是无法获得一个完全一致的数据的。

这时,使用上述连个特性,我们可以在 PostgreSQL 9.3 和以上版本做到拿到一份一致版本的数据。 大致的步骤是

  • 1. 备份程序主进程连接数据库,开启只读事务,并使用函数 pg_export_snapshot 导出对应的snapshotid。
  • 2. 开启 N 个子进程,连接数据库,分别设置主进程中获得的 snapshotid,开始并行备份数据。

实际上 PostgreSQL 自带的逻辑备份和恢复工具在 9.3 和之后的版本就已经这么做了

1) pg_dump 的并行备份开关 -j

-j njobs
--jobs=njobs
Run the dump in parallel by dumping njobs tables simultaneously. This option reduces the time of the dump but it also increases the load on the database server. You can only use this option with the directory output format because this is the only output format where multiple processes can write their data at the same time.
  • 注意:备份的并行粒度是以表为单位,且需要以 directory 模式备份数据,也就是备份数据到一个目录中,目录中每个表的数据都用一个或多个文件存放。
  • 该模式加上备份压缩开关,是效率最高的逻辑备份方式。

3.并行逻辑备份 pg_dump

2) pg_resotre 也支持并行恢复

-j number-of-jobs
--jobs=number-of-jobs
Run the most time-consuming parts of pg_restore — those which load data, create indexes, or create constraints — using multiple concurrent jobs. This option can dramatically reduce the time to restore a large database to a server running on a multiprocessor machine.

4.并行逻辑恢复 pg_restore

三.严格一致的全量+增量数据迁移

然而,事情到这里还没有结束,如果用户的数据较多(例如TB级或以上),上述存全量的数据迁移方法对业务造成的影响也会很大(业务停机时间较长)。

PostgreSQL 9.4 开始,支持逻辑复制特性,能和上述特性完美的结合起来,做到接近 0 停机时间的业务迁移。

下面,让我慢慢道来。

3.1 逻辑复制原理

PostgreSQL 逻辑复制(logical replication),原理是类似 MySQL binlog 同步,PostgreSQL 会启动一组后端进程,把数据库产生的 redo 日志(物理日志),解析成逻辑日志,并发送给客户端。转换成的逻辑数据可以通过插件的方式自定义。这个模块叫 logical decoding。

这份逻辑数据是灵活,完全由用户自定义,用户可以根据自己的需求定制

  • 存放一个比较简单的中间数据结构,做 PostgreSQL 到 PostgreSQL 间部分对象的数据迁移
  • 解析成标准的 SQL 语句,做 PostgreSQL 到异构数据库的增量数据迁移或持续同步

5. 逻辑流复制介绍

3.2 全量加增量的结合

PostgreSQL 利用上述技术点,把一致的全量数据迁移和增量逻辑复制结合起来,做到在保证严格的数据一致性基础上的,接近 0 停业务时间的数据迁移服务。

数据迁移的步骤:

  • 1. 迁移程序主进程使用 CREATE_REPLICATION_SLOT 创建用户增量的逻辑 SLOT,记录数据同步的启始一致点(snapshotid)。这是数据一致性迁移的关键点,也是全量迁移和增量迁移的重要衔接点。

注意:

1) MySQL 早期版本中,使用 binlog 中一个 unix 时间做数据一致的位点,后期版本被 GTID 替换。

2) CREATE_REPLICATION_SLOT 不是一个 SQL 语句,是一个前后端的协议命令,它的返回结果集第四列是一个 snapshotid。snapshotid 的获取方法可以参考 pg_recvlogical –create-slot 的实现。

  • 2. 客户端开启 N 个子进程,连接到数据库,设置到主进程中获得的 snapshotid,开始并行数据迁移。

  • 3. 于此同时,为了避免数据库的 redo 日志在主库的堆积,客户端启动一个连接拉取增量日志,并解析成能够被目的数据库识别的数据,且持久化保存。

  • 4. 当任务 2 中的所有的全量同步任务完成之后,开始同步全量同步期间产生的增量数据(任务3中)。直到现存的增量数据都同步完,追平和主库的数据差异。

  • 5. 当数据接近追上时,停止主库写数据。

  • 6. 当数据完全追上后,开始数据校验。

  • 7. 数据校验完成后,应用开始连接目的端数据库,数据迁移完成,开始正常业务。

6. 创建逻辑复制SLOT

7. pg_recvlogical

总结

  1. 上述该方案能做到严格数据一致的数据同步,且全量数据迁移和增量数据迁移无缝衔接。

  2. 上述解决方案,我们可以用来完成数据迁移和数据长时间同步这两类常用的数据基本操作。但是,它们关注的实现重点不同。

  • 1)数据迁移任务需要做到尽量的高效转移数据,迁移过程中可以不能做 DDL;
  • 2)长时间的数据同步需要保存更多的状态信息,能做到任务的长期可运维,需要做到 DDL 的变更。

PgSQL · 应用案例 · 手机行业分析、决策系统设计-实时圈选、透视、估算

$
0
0

背景

经营分析、决策支持是现代企业的一个让数据发挥有效价值的分析型系统。

在各个行业中随处可见,例如共享充电宝中,协助销售了解实时的设备租赁情况,销售业绩。在电商中,协助小二和商户发掘目标用户群体。金融行业中,协助输出国民的存款、消费、贷款的画像。

PostgreSQL, Greenplum都是非常适合于经营分析、决策支持的数据库。因为它们具备了一些特性,适合实时的分析透视。(流式计算、合并写入、阅后即焚、GIN倒排索引、varbit类型、列存储、BITMAP合并扫描、HLL估值类型、采样算法等等)。

我也写过很多实际的应用案例,可以参考本文末尾。

经营分析系统的需求大同小异,在手机行业中,以imei或imsi为KEY,每个手机根据它的用户的行为,生成一些属性,针对每个属性,划分出不同的标签,形成了手机用户的画像。再针对画像进行人群的圈选、透视,协助分析。

例如,基于PostgreSQL数组以及GIN索引的设计:

pic

经营分析设计示例

1、目标设计

2、表结构设计

3、属性表

4、标签表

5、标签表索引设计

6、打标签(含新增、更新、删除标签)测试

7、圈选测试

8、透视测试

9、决策设计示例

流式+函数式计算

结构设计

1、手机用户属性表

create table tbl1 (  
  imei text primary key,     -- 手机唯一标识  
  v1 int,        -- 年龄  
  v2 float8,     -- 收入  
  v3 geometry,   -- 住址经纬  
  v4 geometry,   -- 公司经纬  
  v5 char(1),    -- 性别  
  v6 timestamp,  -- 最后活跃时间  
  v7 int2,       -- 每日在线时长  
  v8 int2,       -- 星座  
  v9 text,       -- 其他标签。。。。。  
  ......  
);  

2、标签元数据表

create table tbl2 (  
  tagid int primary key,    -- 标签名  
  desc text,    -- 描述,例如性别,年龄分段,收入分段,区域等等,作为一个标签标识。  
);  

3、标签表

create table tbl3 (  
  imei text primary key,   -- 手机唯一标识  
  tagids int[],            -- 标签数组  
  ins_tags int[],          -- 合并操作需要的中间字段  
  del_tags int[]           -- 合并操作需要的中间字段  
);  
  
create index idx_tbl3_tagids on tbl3 using gin (tagids gin__int_ops);  
或
create index idx_tbl3_tagids on tbl3 using gist (tagids gist__intbig_ops);  
或
create index idx_tbl3_tagids on tbl3 using gist (tagids gist__int_ops);  

4、标签表与属性表实际上可以合一,在透视时,可以避免一次JOIN(降低透视的耗时),但是会引入更新IO放大的问题,因为属性表可能是宽表。

根据实际的性能情况来选择是否合一。

需求与SQL设计

1、圈人

select imei from tbl3 where tagids @> array[标签1, 标签2];  -- 查找包含标签1,标签2的人群。  
  
select imei from tbl3 where tagids && array[标签1, 标签2];  -- 查找包含标签1,标签2中任意一个或多个的人群。  
  
select imei from tbl3 where tagids && array[标签1, 标签2] and tagid @> array[标签3, 标签4];  -- 查找包含标签3,标签4。同时包含标签1,标签2中任意一个或多个的人群。  

2、针对圈出人群的精准透视

select v8,count(*) from tbl1 where  
  imei = any (array(  
                     select imei from tbl3 where tagids @> array[标签1, 标签2]  
             )     )  
group by v8;  

3、新增或追加标签

使用intarray插件,简化数组交、并、差操作。

create extension intarray;  
insert into tbl3 (imei, tagids) values (?, ?[]) on conflict (imei) do update set tagids=tbl3.tagids|excluded.tagids;  

4、删标签

update tbl3 set tagids = tagids - ?[] where imei=?;  

5、更新标签

update tbl3 set tagids = ?[] where imei=?;  

6、批量并行新增、追加、删除、更新标签优化

如果要一次性操作很多条记录(例如1000万条记录),并且有并行的贴标签操作(同一条用户被多个SQL更新)。需要注意两个问题:

6.1 大事务导致膨胀的问题,建议分段操作。

6.2 行锁冲突问题,建议新增(插入),然后合并到标签表。

优化方法,

实现标签最终一致性。

将直接增、删、改标签表,改成写行为日志tag_log,采用任务调度,批量合并到标签表:

create table tag_log (  
  imei text,    -- 手机唯一标识  
  action text,  -- insert, delete  表示增加、删除标签  (更新需求应该没有,如有,直接到标签表操作)  
  tagids int[], -- 标签IDs  
  crt_time timestamp default clock_timestamp()   -- 时间  
);  
  
create index idx_tag_log_1 on tag_log (crt_time);  
  
-- 16个分区表
do language plpgsql $$
declare
begin
  for i in 0..15 loop
    execute format('create table tag_log%s (like tag_log including all) inherits(tag_log)', i);
  end loop;
end;
$$;

串行任务,阅后即焚(假设-99999999是一个永远不存在的TAGID)

-- CTE语法,支持阅后即焚的批量合并方法  
with tmp as (delete from tag_log where ctid = any ( array (  
  select ctid from tag_log order by crt_time limit 10000  -- 按时序,批量取1万条  
  )) returning * )  
, tmp1 as (select imei,  
             uniq(sort(array_agg(case when action='insert' then tagids else -99999999 end))) - (-99999999) AS ins_tags,  
             uniq(sort(array_agg(case when action='delete' then tagids else -99999999 end))) - (-99999999) AS del_tags  
           from (select imei, action, unnest(tagids) as tagids from tmp) t group by imei)  
insert into tbl3 (imei, tagids, ins_tags, del_tags)  
select imei, ins_tags-del_tags, ins_tags, del_tags from tmp1  
 on conflict (imei) do update set tagids=((tbl3.tagids | excluded.ins_tags) - excluded.del_tags), ins_tags=excluded.ins_tags, del_tags=excluded.del_tags;  

并行任务,阅后即焚

例如开启16个并行  
  
abs(mod(hashtext(imei), 16))=?  
-- CTE语法,支持阅后即焚的批量合并方法  
with tmp as (delete from tag_log where ctid = any ( array (  
  select ctid from tag_log where abs(mod(hashtext(imei), 16))=0 order by crt_time limit 10000  -- 按时序,批量取1万条,按HASH并行  
  )) returning * )  
, tmp1 as (select imei,  
             uniq(sort(array_agg(case when action='insert' then tagids else -99999999 end))) - (-99999999) AS ins_tags,  
             uniq(sort(array_agg(case when action='delete' then tagids else -99999999 end))) - (-99999999) AS del_tags  
           from (select imei, action, unnest(tagids) as tagids from tmp) t group by imei)  
insert into tbl3 (imei, tagids, ins_tags, del_tags)  
select imei, ins_tags-del_tags, ins_tags, del_tags from tmp1  
 on conflict (imei) do update set tagids=((tbl3.tagids | excluded.ins_tags) - excluded.del_tags), ins_tags=excluded.ins_tags, del_tags=excluded.del_tags;  

写成函数,方便调用

create or replace function consume_tag_log(mo int, mov int, lim int) returns void as $$  
declare  
begin  
  execute format($_$with tmp as (delete from tag_log where ctid = any ( array (  
  select ctid from tag_log where abs(mod(hashtext(imei), %s))=%s order by crt_time limit %s  
  )) returning * )  
, tmp1 as (select imei,  
             uniq(sort(array_agg(case when action='insert' then tagids else -99999999 end))) - (-99999999) AS ins_tags,  
             uniq(sort(array_agg(case when action='delete' then tagids else -99999999 end))) - (-99999999) AS del_tags  
           from (select imei, action, unnest(tagids) as tagids from tmp) t group by imei)  
insert into tbl3 (imei, tagids, ins_tags, del_tags)  
select imei, ins_tags-del_tags, ins_tags, del_tags from tmp1  
 on conflict (imei) do update set tagids=((tbl3.tagids | excluded.ins_tags) - excluded.del_tags), ins_tags=excluded.ins_tags, del_tags=excluded.del_tags$_$,  
 mo, mov, lim);  
end;  
$$ language plpgsql strict;  
  
  
select consume_tag_log(16,0,10000);   -- 并行处理  
select consume_tag_log(16,1,10000);  
.....  
select consume_tag_log(16,15,10000);  
create or replace function consume_tag_log(lim int) returns void as $$  
declare  
begin  
  execute format($_$with tmp as (delete from tag_log where ctid = any ( array (  
  select ctid from tag_log order by crt_time limit %s  
  )) returning * )  
, tmp1 as (select imei,  
             uniq(sort(array_agg(case when action='insert' then tagids else -99999999 end))) - (-99999999) AS ins_tags,  
             uniq(sort(array_agg(case when action='delete' then tagids else -99999999 end))) - (-99999999) AS del_tags  
           from (select imei, action, unnest(tagids) as tagids from tmp) t group by imei)  
insert into tbl3 (imei, tagids, ins_tags, del_tags)  
select imei, ins_tags-del_tags, ins_tags, del_tags from tmp1  
 on conflict (imei) do update set tagids=((tbl3.tagids | excluded.ins_tags) - excluded.del_tags), ins_tags=excluded.ins_tags, del_tags=excluded.del_tags$_$,  
 lim);  
end;  
$$ language plpgsql strict;  
  
  
select consume_tag_log(10000);  -- 每次处理1万条  

创建调度任务,执行消费函数调度即可。

阅后即焚的处理速度,每秒 百万行。

《(OLTP) 高吞吐数据进出(堆存、行扫、无需索引) - 阅后即焚(读写大吞吐并测)》

性能验证

1、标签取值范围5万,正态分布

pic

2、多表批量写入函数

create or replace function ins(
  imei text,
  tagids int[]
) returns void as $$
declare
  suffix int := abs(mod(hashtext(imei),16)); 
begin
  execute format($_$insert into tag_log%s values ('%s', 'insert', '%s'::int[])$_$, suffix, imei, tagids);
end;
$$ language plpgsql strict;

3、多表批量消费

标签表分表

do language plpgsql $$
declare
begin
  for i in 0..15 loop
    execute format('create table tbl3_%s (like tbl3 including all) inherits(tbl3)', i);
  end loop;
end;
$$;

多表批量消费

CREATE OR REPLACE FUNCTION public.consume_tag_log(suffix int, lim integer)
 RETURNS void
 LANGUAGE plpgsql
 STRICT
AS $function$
declare
begin
  execute format($_$with tmp as (delete from tag_log%s where ctid = any ( array (  
  select ctid from tag_log%s order by crt_time limit %s  -- 按时序,批量取1万条,按HASH并行
  )) returning * )   
, tmp1 as (select imei, 
             uniq(sort(array_agg(case when action='insert' then tagids else -99999999 end))) - (-99999999) AS ins_tags,   
     uniq(sort(array_agg(case when action='delete' then tagids else -99999999 end))) - (-99999999) AS del_tags
   from (select imei, action, unnest(tagids) as tagids from tmp) t group by imei)  
insert into tbl3_%s (imei, tagids, ins_tags, del_tags) 
select imei, ins_tags-del_tags, ins_tags, del_tags from tmp1 
 on conflict (imei) do update set tagids=((tbl3_%s.tagids | excluded.ins_tags) - excluded.del_tags), ins_tags=excluded.ins_tags, del_tags=excluded.del_tags$_$,
 suffix, suffix, lim, suffix, suffix);
end;
$function$;

4、数据写入压测脚本

vi test.sql  
  
\set tag1 random_gaussian(1, 50000, 20)  
\set tag2 random_gaussian(1, 50000, 20)  
\set tag3 random_gaussian(1, 50000, 20)  
\set tag4 random_gaussian(1, 50000, 20)  
\set tag5 random_gaussian(1, 50000, 20)  
\set tag6 random_gaussian(1, 50000, 20)  
\set tag7 random_gaussian(1, 50000, 20)  
\set tag8 random_gaussian(1, 50000, 20)  
\set imei random(1,1000000000)  
select ins(:imei, (array[:tag1,:tag2,:tag3,:tag4,:tag5,:tag6,:tag7,:tag8])::int[]);
  
nohup pgbench -M prepared -n -r -P 1 -f ./test.sql -c 28 -j 28 -T 3000 >./tag.log 2>&1 &  

5、数据消费,并行调度

用秒杀技术实现并行调度,避免单个HASH被重复调用。

《HTAP数据库 PostgreSQL 场景与性能测试之 30 - (OLTP) 秒杀 - 高并发单点更新》

这里直接用分区表写入的话,性能会更爽,原理请看如下:

《阿里云RDS PostgreSQL OSS 外部表 - (dblink异步调用封装)并行写提速案例》

vi test1.sql  
  
\set mov random(0,15)  
select consume_tag_log(:mov,10000) where pg_try_advisory_xact_lock(:mov);  
  
nohup pgbench -M prepared -n -r -P 1 -f ./test1.sql -c 16 -j 16 -T 3000 >./consume.log 2>&1 &  

6、压测结果

写入速度

单条单步写入,约 14.3万 行/s  
  
改成多表批量写入,可以提高到100万+ 行/s  

消费速度

单表并行批量消费,约 25.5万 行/s  
  
改成多表并行批量消费,可以提高到 100万+ 行/s  

查询速度,毫秒级

postgres=# explain (analyze,verbose,timing,costs,buffers) select count(imei) from tbl3 where tagids @> (array[25281,25288])::int[];
                                                           QUERY PLAN                                                           
--------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=224.50..224.51 rows=1 width=8) (actual time=2.745..2.746 rows=1 loops=1)
   Output: count(imei)
   Buffers: shared hit=193
   ->  Bitmap Heap Scan on public.tbl3  (cost=218.44..224.49 rows=5 width=33) (actual time=2.716..2.738 rows=9 loops=1)
         Output: imei, tagids, ins_tags, del_tags
         Recheck Cond: (tbl3.tagids @> '{25281,25288}'::integer[])
         Heap Blocks: exact=9
         Buffers: shared hit=193
         ->  Bitmap Index Scan on idx_tbl3_tagids  (cost=0.00..218.44 rows=5 width=0) (actual time=2.707..2.707 rows=9 loops=1)
               Index Cond: (tbl3.tagids @> '{25281,25288}'::integer[])
               Buffers: shared hit=184
 Planning time: 0.165 ms
 Execution time: 2.797 ms
(13 rows)

除了以上基于数组、GIN索引的设计,PostgreSQL还有一些技术,可以用在经营分析系统。

技术1 实时透视 - 技术之 - 流式统计

通过insert on conflict,流式的统计固定模型的维度数据。

《PostgreSQL 流式统计 - insert on conflict 实现 流式 UV(distinct), min, max, avg, sum, count …》

满足这类查询的实时流式统计:

select a,count(*),sum(b),avg(b),min(b),max(b) from tbl group by a;  

技术2 实时透视、估算 - 技术之 - 流式统计 + HLL

通过insert on conflict,流式的统计固定模型的维度数据。这里要用到hll插件,存储count(dinstinct x)的估值

《PostgreSQL 流式统计 - insert on conflict 实现 流式 UV(distinct), min, max, avg, sum, count …》

《PostgreSQL hll (HyperLogLog) extension for “State of The Art Cardinality Estimation Algorithm” - 3》

《PostgreSQL hll (HyperLogLog) extension for “State of The Art Cardinality Estimation Algorithm” - 2》

《PostgreSQL hll (HyperLogLog) extension for “State of The Art Cardinality Estimation Algorithm” - 1》

满足这类查询的实时流式统计:

select a, count(distinct b) from tbl group by a;  

技术3 实时透视、估算 - 技术之 - 计划估算

根据执行计划得到评估行。

《妙用explain Plan Rows快速估算行》

如果输入多个字段条件,为了提高行估算准确度,可以定义多字段统计信息,10新增的功能:

《PostgreSQL 10 黑科技 - 自定义统计信息》

满足这类查询的估算需求:

select count(*) from tbl where xxxx;  
  
SQL换算成  
  
select * from tbl where xxxx; -- 通过explain的行估算拿结果  

技术4 实时透视、估算 - 技术之 - 采样估算

《秒级任意维度分析1TB级大表 - 通过采样估值满足高效TOP N等统计分析需求》

采样估算,适合求TOP N。

满足这类查询的估算需求:

select a from tbl group by a order by count(*) desc limit N;  

技术5 实时圈选、透视 - 技术之 - GIN倒排

倒排索引针对多值类型,例如 hstore, array, tsvector, json, jsonb。

主树的K-V分别为:

element-ctid(行号)list or tree  

辅树为

ctid list or tree  

从而高效的满足这类查询的需求:

-- 包含哪些元素  
select * from tbl where arr @> array[xx,xx];  
  
-- 包含哪些任意元素之一  
select * from tbl where arr && array[xx,xx];  

内部使用BITMAP扫描方法,过滤到少量数据块。

《PostgreSQL 9种索引的原理和应用场景》

技术6 实时圈选、透视 - 技术之 - bitmap

这个方法非常的巧妙,将tag和imei做了倒转,以tag为key, imei为bitmap来存储。

create table tag_users (  
  tagid int primary key, -- 标签  
  imeibitmap varbit,     -- 每个imei用一个BIT位表示  
);  

查询换算:

-- 包含某些标签的用户  
select bitand(imeibitmap) from tag_users where tagid in (?,?,...);  
  
-- 包含任意标签的用户  
select bitor(imeibitmap) from tag_users where tagid in (?,?,...);  

案例参考:

《阿里云RDS for PostgreSQL varbitx插件与实时画像应用场景介绍》

《基于 阿里云 RDS PostgreSQL 打造实时用户画像推荐系统(varbitx)》

技术7 实时圈选、透视 - 技术之 - 并行计算

PostgreSQL 10加入了并行计算的能力,在join , filter, seqscan, order by, agg, group等方面都支持并行。

性能指标参考:

《HTAP数据库 PostgreSQL 场景与性能测试之 23 - (OLAP) 并行计算》

技术8 实时圈选、透视 - 技术之 - MPP, 列存储, 位图索引

基于PostgreSQL的MPP 数据仓库Greenplum,支持列存储,位图索引。

用资源,暴力解决问题。

没有太多的设计技巧,堆机器就可以,但是本身的效率远比impalar, hive好很多。

Greenplum是非常值得推荐的OLAP数据库。在金融、政府、航空等大数据领域有众多案例。

决策支持技术

流式数据处理+UDF函数计算技术。可以满足实时决策的需求。

案例如下:

《HTAP数据库 PostgreSQL 场景与性能测试之 32 - (OLTP) 高吞吐数据进出(堆存、行扫、无需索引) - 阅后即焚(JSON + 函数流式计算)》

《HTAP数据库 PostgreSQL 场景与性能测试之 27 - (OLTP) 物联网 - FEED日志, 流式处理 与 阅后即焚 (CTE)》

相似案例举例

1、实时统计 count(distinct)估值,min, max, avg, sum, count精确值。

《PostgreSQL 流式统计 - insert on conflict 实现 流式 UV(distinct), min, max, avg, sum, count …》

2、

《PostgreSQL 异步消息实践 - Feed系统实时监测与响应(如 电商主动服务) - 分钟级到毫秒级的实现》

《(OLTP) 物联网 - FEED日志, 流式处理 与 阅后即焚 (CTE)》

3、让explain产生精确的多字段输入条件行数估值(select * from table where a=? and|or b=? ....)

《PostgreSQL 10 黑科技 - 自定义统计信息》

4、《恭迎万亿级营销(圈人)潇洒的迈入毫秒时代 - 万亿user_tags级实时推荐系统数据库设计》

《阿里云RDS for PostgreSQL varbitx插件与实时画像应用场景介绍》

《基于 阿里云 RDS PostgreSQL 打造实时用户画像推荐系统(varbitx)》

5、决策支持,流式函数计算

《(OLTP) 高吞吐数据进出(堆存、行扫、无需索引) - 阅后即焚(JSON + 函数流式计算)》

《(OLTP) 高吞吐数据进出(堆存、行扫、无需索引) - 阅后即焚(读写大吞吐并测)》

6、圈人案例

《(OLAP) 用户画像圈人场景 - 数组相交查询与聚合》

《(OLAP) 用户画像圈人场景 - 数组包含查询与聚合》

7、时间、空间、多维圈人、透视案例

《空间|时间|对象 圈人 + 透视 - 暨PostgreSQL 10与Greenplum的对比和选择》

《PostgreSQL\GPDB 毫秒级海量 时空数据透视 典型案例分享》

《PostgreSQL\GPDB 毫秒级海量 多维数据透视 案例分享》

8、视频网站透视案例

《音视图(泛内容)网站透视分析 DB设计 - 阿里云(RDS、HybridDB) for PostgreSQL最佳实践》

9、

《画像圈人 + 人群行为透视》

《奔跑吧,大屏 - 时间+空间 实时四维数据透视》

《数据透视 - 商场(如沃尔玛)选址应用》

《海量用户实时定位和圈人 - 团圆社会公益系统(位置寻人\圈人)》

《万亿级电商广告 - brin黑科技带你(最低成本)玩转毫秒级圈人(视觉挖掘姊妹篇) - 阿里云RDS PostgreSQL, HybridDB for PostgreSQL最佳实践》

《多字段,任意组合条件查询(无需建模) - 毫秒级实时圈人 最佳实践》

10、

《经营、销售分析系统DB设计之PostgreSQL, Greenplum - 共享充电宝 案例实践》


MySQL · 最佳实践 · 如何索引JSON字段

$
0
0

概述

MySQL从5.7.8起开始支持JSON字段,这极大的丰富了MySQL的数据类型。也方便了广大开发人员。但MySQL并没有提供对JSON对象中的字段进行索引的功能,至少没有直接对其字段进行索引的方法。本文将介绍利用MySQL 5.7中的虚拟字段的功能来对JSON对象中的字段进行索引。

示例数据

我们将基于下面的JSON对象进行演示

{
    "id": 1,  
    "name": "Sally",  
    "games_played":{    
       "Battlefield": {
          "weapon": "sniper rifle",
          "rank": "Sergeant V",
          "level": 20
        },                                                                                                                          
       "Crazy Tennis": {
          "won": 4,
          "lost": 1
        },  
       "Puzzler": {
          "time": 7
        }
     }
 }

表的基本结构


CREATE TABLE `players` (  
    `id` INT UNSIGNED NOT NULL,
    `player_and_games` JSON NOT NULL,
    PRIMARY KEY (`id`)
);

如果只是基于上面的表的结构我们是无法对JSON字段中的Key进行索引的。接下来我们演示如何借助虚拟字段对其进行索引

增加虚拟字段

虚拟列语法如下

<type> [ GENERATED ALWAYS ] AS ( <expression> ) [ VIRTUAL|STORED ]
[ UNIQUE [KEY] ] [ [PRIMARY] KEY ] [ NOT NULL ] [ COMMENT <text> ]

在MySQL 5.7中,支持两种Generated Column,即Virtual Generated Column和Stored Generated Column,前者只将Generated Column保存在数据字典中(表的元数据),并不会将这一列数据持久化到磁盘上;后者会将Generated Column持久化到磁盘上,而不是每次读取的时候计算所得。很明显,后者存放了可以通过已有数据计算而得的数据,需要更多的磁盘空间,与Virtual Column相比并没有优势,因此,MySQL 5.7中,不指定Generated Column的类型,默认是Virtual Column。

如果需要Stored Generated Golumn的话,可能在Virtual Generated Column上建立索引更加合适,一般情况下,都使用Virtual Generated Column,这也是MySQL默认的方式

加完虚拟列的建表语句如下:

CREATE TABLE `players` (  
   `id` INT UNSIGNED NOT NULL,
   `player_and_games` JSON NOT NULL,
   `names_virtual` VARCHAR(20) GENERATED ALWAYS AS (`player_and_games` ->> '$.name') NOT NULL, 
   PRIMARY KEY (`id`)
);

Note: 利用操作符-» 来引用JSON字段中的KEY。在本例中字段names_virtual为虚拟字段,我把它定义成不可以为空。在实际的工作中,一定要集合具体的情况来定。因为JSON本身是一种弱结构的数据对象。也就是说的它的结构不是固定不变的。

我们插入数据

INSERT INTO `players` (`id`, `player_and_games`) VALUES (1, '{  
    "id": 1,  
    "name": "Sally",
    "games_played":{    
       "Battlefield": {
          "weapon": "sniper rifle",
          "rank": "Sergeant V",
          "level": 20
        },                                                                                                                          
       "Crazy Tennis": {
          "won": 4,
          "lost": 1
        },  
       "Puzzler": {
          "time": 7
        }
      }
   }'
);
...

查看表里的数据

SELECT * FROM `players`;

+----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+
| id | player_and_games                                                                                                                                                                                           | names_virtual |
+----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+
|  1 | {"id": 1, "name": "Sally", "games_played": {"Puzzler": {"time": 7}, "Battlefield": {"rank": "Sergeant V", "level": 20, "weapon": "sniper rifle"}, "Crazy Tennis": {"won": 4, "lost": 1}}}                  | Sally         |
|  2 | {"id": 2, "name": "Thom", "games_played": {"Puzzler": {"time": 25}, "Battlefield": {"rank": "Major General VIII", "level": 127, "weapon": "carbine"}, "Crazy Tennis": {"won": 10, "lost": 30}}}            | Thom          |
|  3 | {"id": 3, "name": "Ali", "games_played": {"Puzzler": {"time": 12}, "Battlefield": {"rank": "First Sergeant II", "level": 37, "weapon": "machine gun"}, "Crazy Tennis": {"won": 30, "lost": 21}}}           | Ali           |
|  4 | {"id": 4, "name": "Alfred", "games_played": {"Puzzler": {"time": 10}, "Battlefield": {"rank": "Chief Warrant Officer Five III", "level": 73, "weapon": "pistol"}, "Crazy Tennis": {"won": 47, "lost": 2}}} | Alfred        |
|  5 | {"id": 5, "name": "Phil", "games_played": {"Puzzler": {"time": 7}, "Battlefield": {"rank": "Lt. Colonel III", "level": 98, "weapon": "assault rifle"}, "Crazy Tennis": {"won": 130, "lost": 75}}}          | Phil          |
|  6 | {"id": 6, "name": "Henry", "games_played": {"Puzzler": {"time": 17}, "Battlefield": {"rank": "Captain II", "level": 87, "weapon": "assault rifle"}, "Crazy Tennis": {"won": 68, "lost": 149}}}             | Henry         |
+----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+

查看表Players的字段

SHOW COLUMNS FROM `players`;

+------------------+------------------+------+-----+---------+-------------------+
| Field            | Type             | Null | Key | Default | Extra             |
+------------------+------------------+------+-----+---------+-------------------+
| id               | int(10) unsigned | NO   | PRI | NULL    |                   |
| player_and_games | json             | NO   |     | NULL    |                   |
| names_virtual    | varchar(20)      | NO   |     | NULL    | VIRTUAL GENERATED |
+------------------+------------------+------+-----+---------+-------------------+

我们看到虚拟字段names_virtual的类型是VIRTUAL GENERATED。MySQL只是在数据字典里保存该字段元数据,并没有真正的存储该字段的值。这样表的大小并没有增加。我们可以利用索引把这个字段上的值进行物理存储。

在虚拟字段上加索引

再添加索引之前,让我们先看下面查询的执行计划

EXPLAIN SELECT * FROM `players` WHERE `names_virtual` = "Sally"\G  
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: players
   partitions: NULL
         type: ALL
possible_keys: NULL  
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 6
     filtered: 16.67
        Extra: Using where

添加索引

CREATE INDEX `names_idx` ON `players`(`names_virtual`);  

再执行上面的查询语句,我们将得到不一样的执行计划

EXPLAIN SELECT * FROM `players` WHERE `names_virtual` = "Sally"\G  
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: players
   partitions: NULL
         type: ref
possible_keys: names_idx  
          key: names_idx
      key_len: 22
          ref: const
         rows: 1
     filtered: 100.00
        Extra: NULL

如我们所见,最新的执行计划走了新建的索引。

小结

本文介绍了如何在MySQL 5.7中保存JSON文档。为了高效的检索JSON中内容,我们可以利用5.7的虚拟字段来对JSON的不同的KEY来建索引。极大的提高检索的速度。

MySQL · myrocks · 相关tools介绍

$
0
0

概述

MyRocks提供了丰富的tools,如sst_dump, mysql_ldb等,这些工具对我们的运维和分析问题非常有用。

sst_dump 可以导出sst中的数据和属性信息。

sst_dump --help

sst_dump --file=<data_dir_OR_sst_file> [--command=check|scan|raw]
    --file=<data_dir_OR_sst_file>
      Path to SST file or directory containing SST files

    --command=check|scan|raw|verify
        check: Iterate over entries in files but dont print anything except if an error is encounterd (default command)
        scan: Iterate over entries in files and print them to screen
        raw: Dump all the table contents to <file_name>_dump.txt
        verify: Iterate all the blocks in files verifying checksum to detect possible coruption but dont print anything except if a corruption is encountered
        recompress: reports the SST file size if recompressed with different
                    compression types

    --output_hex
      Can be combined with scan command to print the keys and values in Hex

    --from=<user_key>
      Key to start reading from when executing check|scan

    --to=<user_key>
      Key to stop reading at when executing check|scan

    --prefix=<user_key>
      Returns all keys with this prefix when executing check|scan
      Cannot be used in conjunction with --from

    --read_num=<num>
      Maximum number of entries to read when executing check|scan

    --verify_checksum
      Verify file checksum when executing check|scan

    --input_key_hex
      Can be combined with --from and --to to indicate that these values are encoded in Hex

    --show_properties
      Print table properties after iterating over the file when executing
      check|scan|raw

    --set_block_size=<block_size>
      Can be combined with --command=recompress to set the block size that will
      be used when trying different compression algorithms

    --compression_types=<comma-separated list of CompressionType members, e.g.,
      kSnappyCompression>
      Can be combined with --command=recompress to run recompression for this
      list of compression types

    --parse_internal_key=<0xKEY>
      Convenience option to parse an internal key on the command line. Dumps the
      internal key in hex format {'key' @ SN: type}

mysql_ldb 工具功能非常强大,可以解析rocksdb各类文件,sst, manifest, wal等; 可以写入和查询数据;还提供了一些维护功能,详见如下help信息

bin/mysql_ldb --help
ldb - RocksDB Tool

commands MUST specify --db=<full_path_to_db_directory> when necessary

The following optional parameters control if keys/values are input/output as hex or as plain strings:
  --key_hex : Keys are input/output as hex
  --value_hex : Values are input/output as hex
  --hex : Both keys and values are input/output as hex

The following optional parameters control the database internals:
  --column_family=<string> : name of the column family to operate on. default: default column family
  --ttl with 'put','get','scan','dump','query','batchput' : DB supports ttl and value is internally timestamp-suffixed
  --try_load_options : Try to load option file from DB.
  --ignore_unknown_options : Ignore unknown options when loading option file.
  --bloom_bits=<int,e.g.:14>
  --fix_prefix_len=<int,e.g.:14>
  --compression_type=<no|snappy|zlib|bzip2|lz4|lz4hc|xpress|zstd>
  --compression_max_dict_bytes=<int,e.g.:16384>
  --block_size=<block_size_in_bytes>
  --auto_compaction=<true|false>
  --db_write_buffer_size=<int,e.g.:16777216>
  --write_buffer_size=<int,e.g.:4194304>
  --file_size=<int,e.g.:2097152>


Data Access Commands:
  put <key> <value>  [--ttl]
  get <key> [--ttl]
  batchput <key> <value> [<key> <value>] [..] [--ttl]
  scan [--from] [--to]  [--ttl] [--timestamp] [--max_keys=<N>q]  [--start_time=<N>:- is inclusive] [--end_time=<N>:- is exclusive] [--no_value]
  delete <key>
  deleterange <begin key> <end key>
  query [--ttl]
    Starts a REPL shell.  Type help for list of available commands.
  approxsize [--from] [--to]
  checkconsistency


Admin Commands:
  dump_wal --walfile=<write_ahead_log_file_path> [--header]  [--print_value]
  compact [--from] [--to]
  reduce_levels --new_levels=<New number of levels> [--print_old_levels]
  change_compaction_style --old_compaction_style=<Old compaction style: 0 for level compaction, 1 for universal compaction> --new_compaction_style=<New compaction style: 0 for level compaction, 1 for universal compaction>
  dump [--from] [--to]  [--ttl] [--max_keys=<N>] [--timestamp] [--count_only] [--count_delim=<char>] [--stats] [--bucket=<N>] [--start_time=<N>:- is inclusive] [--end_time=<N>:- is exclusive] [--path=<path_to_a_file>]
  load [--create_if_missing] [--disable_wal] [--bulk_load] [--compact]
  manifest_dump [--verbose] [--json] [--path=<path_to_manifest_file>]
  list_column_families full_path_to_db_directory
  dump_live_files
  idump [--from] [--to]  [--input_key_hex] [--max_keys=<N>] [--count_only] [--count_delim=<char>] [--stats]
  repair
  backup [--backup_env_uri]  [--backup_dir]  [--num_threads]  [--stderr_log_level=<int (InfoLogLevel)>]
  restore [--backup_env_uri]  [--backup_dir]  [--num_threads]  [--stderr_log_level=<int (InfoLogLevel)>]
  checkpoint [--checkpoint_dir]

Note: ldb是rocksdb自带的工具,其功能和mysql_ldb一致。但mysql_ldb使用了MyRocks的comparactor, 因此推荐使用mysql_ldb

示例初始化

create table t1( c1 int , c2 int, c3 varchar(10), primary key (c1), key
idx1(c2)) engine=rocksdb;
insert t1 values (1,101,'A');
insert t1 values (2,102,'B');
insert t1 values (3,103,'C');
insert t1 values (4,104,'D');
insert t1 values (5,105,'E');


select * from t1;
+----+------+------+
| c1 | c2   | c3   |
+----+------+------+
|  1 |  101 | A    |
|  2 |  102 | B    |
|  3 |  103 | C    |
|  4 |  104 | D    |
|  5 |  105 | E    |
+----+------+------+

SET GLOBAL rocksdb_force_flush_memtable_now=1;

select d.table_schema, d.table_name, d.index_name, d.index_number,f.sst_name, f.NUM_ROWS, f.ENTRY_DELETES, f.ENTRY_SINGLEDELETES,f.ENTRY_MERGES, f.ENTRY_OTHERS from information_schema.ROCKSDB_DDL d, information_schema.rocksdb_index_file_map f where d.index_number=f.index_number and d.table_name='t1';
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
| table_schema | table_name | index_name | index_number | sst_name   | NUM_ROWS | ENTRY_DELETES | ENTRY_SINGLEDELETES | ENTRY_MERGES | ENTRY_OTHERS |
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
| test         | t1         | PRIMARY    |          256 | 000030.sst |        5 |             0 |                   0 |            0 |            0 |
| test         | t1         | idx1       |          257 | 000030.sst |        5 |             0 |                   0 |            0 |            0 |
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+

使用 sst_dump

查看test.t1数据分布情况

select d.table_schema, d.table_name, d.index_name, d.index_number,f.sst_name, f.NUM_ROWS, f.ENTRY_DELETES, f.ENTRY_SINGLEDELETES,f.ENTRY_MERGES, f.ENTRY_OTHERS from information_schema.ROCKSDB_DDL d, information_schema.rocksdb_index_file_map f where d.index_number=f.index_number and d.table_name='t1';
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
| table_schema | table_name | index_name | index_number | sst_name   | NUM_ROWS | ENTRY_DELETES | ENTRY_SINGLEDELETES | ENTRY_MERGES | ENTRY_OTHERS |
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
| test         | t1         | PRIMARY    |          256 | 000030.sst |        5 |             0 |                   0 |            0 |            0 |
| test         | t1         | idx1       |          257 | 000030.sst |        5 |             0 |                   0 |            0 |            0 |
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
  • 导出t1的数据
bin/sst_dump --command=scan --file=data/.rocksdb/000030.sst --output_hex
from [] to []
Process data/.rocksdb/000030.sst
Sst file format: block-based
'0000010080000001' seq:13, type:1 => 00650000000141
'0000010080000002' seq:16, type:1 => 00660000000142
'0000010080000003' seq:19, type:1 => 00670000000143
'0000010080000004' seq:22, type:1 => 00680000000144
'0000010080000005' seq:25, type:1 => 00690000000145
'00000101018000006580000001' seq:14, type:1 =>
'00000101018000006680000002' seq:17, type:1 =>
'00000101018000006780000003' seq:20, type:1 =>
'00000101018000006880000004' seq:23, type:1 =>
'00000101018000006980000005' seq:26, type:1 =>

前五行为主键primary数据

后五行为索引idx1的数据

MyRocks记录格式参考这里

以此行(1,101,’A’)数据为例子

主键为

'0000010080000001' seq:13, type:1 => 00650000000141

key由indexid+c1+seq+type组成

Primary index id: 256 ==>   100
c1:               1   ==>   80000001 (符号反转)
type:1           PUT

value 由c2+c3组成

c2:              101  ==>   65
c3:              A    ==>   141

二级索引idx1数据为

'00000101018000006580000001' seq:14, type:1 =>

key由indexid+c2(主键)+c1(二级索引键)组成

second index id: 257 ==>   101
c2 : 101             ==>   80000065              主键   
c1: 1                ==>  80000001 (符号反转)    二级索引键

value为null

Note type值参考如下定义

enum ValueType : unsigned char {
  kTypeDeletion = 0x0,
  kTypeValue = 0x1,
  kTypeMerge = 0x2,
  kTypeLogData = 0x3,               // WAL only.
  kTypeColumnFamilyDeletion = 0x4,  // WAL only.
  kTypeColumnFamilyValue = 0x5,     // WAL only.
  kTypeColumnFamilyMerge = 0x6,     // WAL only.
  kTypeSingleDeletion = 0x7,
  kTypeColumnFamilySingleDeletion = 0x8,  // WAL only.
  kTypeBeginPrepareXID = 0x9,             // WAL only.
  kTypeEndPrepareXID = 0xA,               // WAL only.
  kTypeCommitXID = 0xB,                   // WAL only.
  kTypeRollbackXID = 0xC,                 // WAL only.
  kTypeNoop = 0xD,                        // WAL only.
  kTypeColumnFamilyRangeDeletion = 0xE,   // WAL only.
  kTypeRangeDeletion = 0xF,               // meta block
  kTypeColumnFamilyBlobIndex = 0x10,      // Blob DB only
  kTypeBlobIndex = 0x11,                  // Blob DB only
  // When the prepared record is also persisted in db, we use a different
  // record. This is to ensure that the WAL that is generated by a WritePolicy
  // is not mistakenly read by another, which would result into data
  // inconsistency.
  kTypeBeginPersistedPrepareXID = 0x12,  // WAL only.
  kMaxValue = 0x7F                       // Not used for storing records.
};
  • 按指定范围导出数据
bin/sst_dump --command=scan --file=data/.rocksdb/000030.sst  --from='0x0000010080000002' --to='0x0000010080000005' --input_key_hex --output_hex
from [0000010080000002] to [0000010080000005]
Process data/.rocksdb/000030.sst
Sst file format: block-based
'0000010080000002' seq:16, type:1 => 00660000000142
'0000010080000003' seq:19, type:1 => 00670000000143
'0000010080000004' seq:22, type:1 => 00680000000144
  • 查看sst属性信息

command=raw可以将数据和属性信息都写到*_dump.txt文件中

bin/sst_dump --command=raw --file=data/.rocksdb/000030.sst  --output_hex
from [] to []
Process data/.rocksdb/000030.sst
Sst file format: block-based
raw dump written to file data/.rocksdb/000030_dump.txt

另外选项–show_properties也可以展示属性信息

mysql_ldb 使用

  • 查看sst数据
bin/mysql_ldb --db=data/.rocksdb scan --hex
0x0000010080000001 : 0x00650000000141
0x0000010080000002 : 0x00660000000142
0x0000010080000003 : 0x00670000000143
0x0000010080000004 : 0x00680000000144
0x0000010080000005 : 0x00690000000145
0x00000101018000006580000001 : 0x
0x00000101018000006680000002 : 0x
0x00000101018000006780000003 : 0x
0x00000101018000006880000004 : 0x
0x00000101018000006980000005 : 0x
  • 写入数据

MyRocks在运行过程中,mysql_ldb是不能写入数据的

bin/mysql_ldb --db=data/.rocksdb put --hex 0x0000010080000006 0x006A0000000146
Failed: IO error: While lock file: data/.rocksdb/LOCK: Resource temporarily unavailable

shutdown myrocks实例后,再写入成功

bin/mysql_ldb --db=data/.rocksdb put --hex 0x0000010080000006 0x006A0000000146
ok

可以看到数据写到了新的sst(000041.sst)中

select d.table_schema, d.table_name, d.index_name, d.index_number,f.sst_name, f.NUM_ROWS, f.ENTRY_DELETES, f.ENTRY_SINGLEDELETES,f.ENTRY_MERGES, f.ENTRY_OTHERS from information_schema.ROCKSDB_DDL d, information_schema.rocksdb_index_file_map f where d.index_number=f.index_number and d.table_name='t1';
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
| table_schema | table_name | index_name | index_number | sst_name   | NUM_ROWS | ENTRY_DELETES | ENTRY_SINGLEDELETES | ENTRY_MERGES | ENTRY_OTHERS |
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
| test         | t1         | PRIMARY    |          256 | 000030.sst |        5 |             0 |                   0 |            0 |            0 |
| test         | t1         | idx1       |          257 | 000030.sst |        5 |             0 |                   0 |            0 |            0 |
| test         | t1         | PRIMARY    |          256 | 000041.sst |        1 |             0 |                   0 |            0 |            0 |
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+

同时可以看到新的数据(6,106,’F’);

select * from t1;
+----+------+------+
| c1 | c2   | c3   |
+----+------+------+
|  1 |  101 | A    |
|  2 |  102 | B    |
|  3 |  103 | C    |
|  4 |  104 | D    |
|  5 |  105 | E    |
|  6 |  106 | F    |
+----+------+------+

二级索引没有写数据,所以查不到

select * from t1 where c2=106;
Empty set (0.00 sec)

explain select * from t1 where c2=106;
+----+-------------+-------+------+---------------+------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key  | key_len | ref   | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+-------+------+-------+
|  1 | SIMPLE      | t1    | ref  | idx1          | idx1 | 5       | const |    4 | NULL  |
+----+-------------+-------+------+---------------+------+---------+-------+------+-------+

接着二级索引插入数据

bin/mysql_ldb --db=data/.rocksdb put --hex 0x00000101018000006A80000006 0x
OK

此时可以从二级索引查出数据了

select * from t1 where c2=106;
+----+------+------+
| c1 | c2   | c3   |
+----+------+------+
|  6 |  106 | F    |
+----+------+------+

select d.table_schema, d.table_name, d.index_name, d.index_number,f.sst_name, f.NUM_ROWS, f.ENTRY_DELETES, f.ENTRY_SINGLEDELETES,f.ENTRY_MERGES, f.ENTRY_OTHERS from information_schema.ROCKSDB_DDL d, information_schema.rocksdb_index_file_map f where d.index_number=f.index_number and d.table_name='t1';
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
| table_schema | table_name | index_name | index_number | sst_name   | NUM_ROWS | ENTRY_DELETES | ENTRY_SINGLEDELETES | ENTRY_MERGES | ENTRY_OTHERS |
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
| test         | t1         | PRIMARY    |          256 | 000030.sst |        5 |             0 |                   0 |            0 |            0 |
| test         | t1         | idx1       |          257 | 000030.sst |        5 |             0 |                   0 |            0 |            0 |
| test         | t1         | PRIMARY    |          256 | 000041.sst |        1 |             0 |                   0 |            0 |            0 |
| test         | t1         | idx1       |          257 | 000058.sst |        1 |             0 |                   0 |            0 |            0 |
+--------------+------------+------------+--------------+------------+----------+---------------+---------------------+--------------+--------------+
  • mysql_ldb 查看MANIFEST文件

MANIFEST文件格式参考这里

bin/mysql_ldb manifest_dump  --path=data/.rocksdb/MANIFEST-000059
--------------- Column family "default"  (ID 0) --------------
log number: 58
comparator: RocksDB_SE_v3.10
--- level 0 --- version# 2 ---
 58:740['
--------------- Column family "__system__"  (ID 1) --------------
log number: 63
comparator: RocksDB_SE_v3.10
--- level 0 --- version# 3 ---
 64:773['
next_file_number 66 last_sequence 36  prev_log_number 0 max_column_family 1
  • mysql_ldb repair

人为将MANIFEST-000059删除,rocksdb数据将无法查看

bin/mysql_ldb --db=data/.rocksdb scan --hex
Failed: IO error: While opening a file for sequentially reading: data/.rocksdb/MANIFEST-000059

mysql_ldb 通过 repair 选项可以恢复出MANIFEST

bin/mysql_ldb repair --db=data/.rocksdb scan
RocksDB version: 5.9.0

Git sha rocksdb_build_git_sha:7c8c83458365f8b359cae13785d15b0bdc9df380
Compile date Dec 16 2017
DB SUMMARY

CURRENT file:  CURRENT

IDENTITY file:  IDENTITY

SST files in data/.rocksdb dir, Total Num: 7, files: 000030.sst 000039.sst 000041.sst 000047.sst 000054.sst 000058.sst 000064.sst

Write Ahead Log file in data/.rocksdb: 000063.log size: 19 ;
.......... //省略部分配置信息
                  
[WARN] [/home/zhangyuan.zy/git/rds6/rocksdb/db/repair.cc:209] **** Repaired rocksdb data/.rocksdb; recovered 7 files; 5847bytes. Some data may have been lost. ****
OK

repair 完成后数据可以查看

bin/mysql_ldb --db=data/.rocksdb scan --hex
0x0000010080000001 : 0x00650000000141
0x0000010080000002 : 0x00660000000142
0x0000010080000003 : 0x00670000000143
0x0000010080000004 : 0x00680000000144
0x0000010080000005 : 0x00690000000145
0x0000010080000006 : 0x006A0000000146
0x00000101018000006580000001 : 0x
0x00000101018000006680000002 : 0x
0x00000101018000006780000003 : 0x
0x00000101018000006880000004 : 0x
0x00000101018000006980000005 : 0x
0x00000101018000006A80000006 : 0x

同时生成了新的MANIFEST文件

bin/mysql_ldb manifest_dump  --path=data/.rocksdb/MANIFEST-000003
--------------- Column family "default"  (ID 0) --------------
log number: 0
comparator: RocksDB_SE_v3.10
--- level 0 --- version# 2 ---
 58:740['
--------------- Column family "__system__"  (ID 1) --------------
log number: 0
comparator: RocksDB_SE_v3.10
--- level 0 --- version# 3 ---
 64:773['
next_file_number 66 last_sequence 36  prev_log_number 0 max_column_family 1

最后

本文简单介绍了MyRocks相关工具sst_dump和mysql_ldb的基本用法,希望对大家有所帮助。

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

$
0
0

背景

前文已经介绍了MySQL的Group Replication的实现机制和原理,本文就Group Replication的具体实现进行详细的阐述,以更深入的理解Group Replication的机制,在实践中更好的应用Group Replication,提升应用系统的可用性,优化其性能。

无缝连接的回调机制

Group Replication是通过MySQL的插件方式实现的,基本上不影响MySQL的原有事务处理过程,只是在流程中选择性的插入一些Group Replication的特殊处理方法,为了尽可能减少对原有代码的影响,采用了回调函数的机制来实现对Group Replication的支持。 其中最重要的两个回调函数集如下所示:

  • 节点状态相关的回调函数集 Server_state_observer server_state_observer = { sizeof(Server_state_observer),

    group_replication_before_handle_connection, //client连接到服务器之前 group_replication_before_recovery, //故障恢复之前 group_replication_after_engine_recovery, //引擎恢复之后 group_replication_after_recovery, //故障恢复之后 group_replication_before_server_shutdown, //server关闭之前 group_replication_after_server_shutdown, //server关闭之后 };

  • 事务处理相关的回调函数集 Trans_observer trans_observer = { sizeof(Trans_observer), group_replication_trans_before_dml, //DML语句执行之前,条件检查 group_replication_trans_before_commit, //事务提交之前 ,节点间事务同步 group_replication_trans_before_rollback, //事务回滚之后 group_replication_trans_after_commit, //事务提交之后 group_replication_trans_after_rollback, //事务回滚之后 }; 其中最重要的回调函数是

  • group_replication_trans_before_dml:用于执行条件检查,判断是否符合Group Replication的限制条件。
  • group_replication_trans_before_commit:在binlog已经写入cache,准备提交之前通过XCOM组件进行事务的节点间同步。

XCOM消息传递机制

XCOM是一个基于paxos协议的分布式同步组件,Group Replication通过它将一个节点上的事务(事务采用binlog来表示),传递到整个分布式系统中的多数节点,保证所有节点最终都会收到此事务,并且不改变事务的执行顺序。 Server在事务提交之前,将事务的binlog打包,并添加此事务执行过程中产生的writeset及基于group replication的全局GTID。然后将其发送给本机的XCOM线程。 XCOM组件内部有一个任务Task线程,主线程通过Task调度队列来调度执行Task,每个Task任务可认为是一个协程,重要的Task有;

  • tcp_server 监听xcom端口,当有新连接时为其创建acceptor_learner_task任务

  • tcp_reaper_task 关闭长时间未使用的连接

  • acceptor_learner_task 此任务是XCOM的核心任务,实现基于paxos协议的消息流转过程,并最终决定accept还是deny此消息。
  • proposer_task 从server的消息请求队列中读取一条消息,封装为paxos的proposal消息,发送给其它节点,尝试此消息被paxos系统accept。每个节点可以有多个proposer_task,XCOM并不保证多个proposer_task以server发出同步消息的顺序来发送proposal消息。当一个消息被accepted后,通过learn消息通知到每个节点,成为learned消息。
  • executor_task 监听learned消息,若此消息尚未被应用,则通知server应用此消息。
  • detector_task 周期性的检测与其它节点间的连接可用性。若有连接不可用,通知server。
  • alive_task 产生心跳消息。 消息被acceptor的过程,如下图所示:

picture_name

1) 源节点的proposal_task从server的消息队列中提取一个消息,若是3phase,封装为prepare消息,开始acceptor的阶段一,将prepare消息发送给的所有acceptor节点;若是2phase,则封装为proposal消息,将proposal消息发送给所有acceptor节点,此时会跳到阶段二;

2) acceptor节点的acceptor_learner_task收到prepare消息后,判断是否可以阶段一的accept,然后返回ACK;

3) 源节点的acceptor_learner_task收到ACK后,更新收到ACK的节点位图,若已经收到多数acceptor的ACK后,进入阶段二,向所有acceptor节点发送proposal消息;

4) acceptor节点的acceptor_learner_task收到proposal消息后,判断是否可以阶段二的accept,然后返回ACK;

5) 源节点的acceptor_learner_task收到ACK后,更新收到ACK的节点位图,若已经收到多数acceptor的ACK后,说明此消息已经被paxos系统accept,然后将此消息通知所有节点,向所有节点发出learn消息;

6) 所有节点的acceptor_learner_task收到learn消息后,将此消息加入到当前节点的learned消息中。

7) 每个节点的executor_task检测本节点是否有新的learned消息,若有则将其通知server层,进行后续处理。

节点间事务的同步

在Group Replication中,若用户在一个节点执行一条SQL,如一条Insert语句,在提交之前已经生成此语句的binlog,并将其写入cache,此时需要通过XCOM来进行节点的事务同步,将此事务同步到多数节点,并判断此事务与其它节点的事务之间是否存在冲突,若存在冲突此事务是否需要回滚等。 下图是一个简单的SQL语句导致的消息传递过程。

picture_name

当节点收到Notification消息后,将其加入incomingQ队列中,然后applier thread读取incomingQ队列中的消息,依次执行event事件的处理过程。对于一个insert语句来说,它的event事件由以下几部分组成:

  • Transaction_context_log_event
  • Gtid_log_event
  • Query_log_event

每个event会依次进入event handler pipeline执行相应的处理,如下图所示:

picture_name

总结

MySQL Group Replication在提交事务之前通过XCOM来进行节点间事务的同步,保证事务提交前,多数节点上已经保存此事务相关的binlog event,从而保证节点间事务的一致性。 为降低MySQL与XCOM代码的耦合性,采用消息通知、回调函数等机制有效的对不同模块进行隔离,了解这些机制有助于我们更好的理解Group Replication的原理和代码。

MySQL · 引擎特性 · MySQL内核对读写分离的支持

$
0
0

读写分离的场景应用

随着业务增长,数据越来越大,用户对数据的读取需求也随之越来越多,比如各种AP操作,都需要把数据从数据库中读取出来,用户可以通过开通多个只读实例,将读请求业务直接连接到只读实例上。使用RDS云数据库的读写分离功能,用户只需要一个请求地址,业务不需要做任何修改,由RDS自带的读写分离中间件服务来完成读写请求的路由及根据不同的只读实例规格进行不同的负载均衡,同时当只读实例出现故障时能够主动摘除,减少对用户的影响。对用户达到一键开通,一个地址,快速使用。 MySQL内核为读写分离的实现提供了支持,包括通过系统variable设置目标节点,session或者是事务的只读属性,等待/检查指定的事务是否已经apply到只读节点上,以及事务状态的实时动态跟踪等的能力。本文会带领大家一起来看看这些特征。说明一下,本文的内容基于RDS MySQL 5.6与RDS MySQL 5.7。

只读属性设定

如下的system variables可以将目标节点,session或者是事务设置为只读

read_only

支持的MySQL版本5.6/5.7
作用范围global
是否支持动态修改
默认值off

如需设置节点为只读状态,将该read_only参数设置为1或TRUE,但设置 read_only=1 状态有几个需要注意的地方:

  1. read_only=1只读模式,不会影响slave同步复制的功能,所以在MySQL slave库中设定了read_only=1后,通过 show slave status\G命令查看salve状态,可以看到salve仍然会读取master上的日志,并且在slave库中应用日志,保证主从数据库同步一致
  2. read_only=1只读模式,可以限定普通用户进行数据修改的操作,但不会限定具有super权限的用户的数据修改操作;在MySQL中设置read_only=1后,普通的应用用户进行insert、update、delete等会产生数据变化的DML操作时,都会报出数据库处于只读模式不能发生数据变化的错误,但具有super权限的用户,例如在本地或远程通过root用户登录到数据库,还是可以进行数据变化的DML操作;
  3. 临时表的操作不受限制
  4. log表(mysql.general_log和mysql.slow_log)的插入不受影响
  5. Performance Schema表的update,例如update和truncate操作
  6. ANALYZE TABLE或者OPTIMIZE TABLE语句 为了让所有的用户都不能进行读写操作,MySQL 5.6就需要执行给所有的表加读锁的命令 flush tables with read lock\G,这样使用具有super权限的用户登录数据库,想要发生数据变化的操作时,也会提示表被锁定不能修改的报错,同时,slave的同步复制也会受到影响。

super_read_only

支持的MySQL版本5.7
作用范围global
是否支持动态修改
默认值off

在5.7之后,我们可以通过设置这个variable, 使得具有super权限的用户也不能对数据做修改操作,而不必通过flush tables with read locK\G的方式了。把super_read_only设置成on, read_only会隐式的被设置成on;反过来,把read_only设置成off,super_read_only就会隐式的被设置成off。

tx_read_only

支持的MySQL版本5.6/5.7
作用范围global
是否支持动态修改
默认值off

如果这个variable设置为ON,事务的访问模式就变成了只读,不能对表做更新,但对临时表的更新操作仍然是允许的。设置只读事务在引擎层可以走优化过的逻辑,相比读写事务的开销更小,例如不用分配事务id,不用分配回滚段,不用维护到全局事务链表中。

rds_rw_mode

支持的MySQL版本5.6
作用范围session
是否支持动态修改
默认值off

RDS MySQL 5.6特有的variable,可以通过set @@session.rds_rw_mode=on/off\G或者 set rds_current_connection rds_rw_mode=on/off\G来设置,如果这个variable被设置成ON, 当前的session上的操作就只允许只读, 执行更改命令会被报错,例如

select * from t1 for update;
select  f2(); // 用户自定义函数f2的实现里面包含insert/update/delete等语句
select a from update t1 set a=a+10 where a > 0;
lock table t1 write;
lock table t1 low_priority write;
select * from t1 into outfile '$MYSQLTEST_VARDIR/tmp/t1.txt';  //query结果写出到文件
call p1();  //函数调用不允许,即使p1实现是read only的
prepare stmt1 from “select 1”
execute stmt1
deallocate stmt1

读一致性保证

读写节点之间的数据通常是有gap的,如果有办法知道在主节点上的执行的事务已经被复制到了只读节点,对这(些)事务敏感的读操作就可以被路由到只读节点上,这就是“读一致性”。MySQL 5.6 引入了GTID (Global transaction Identifier),提升了MySQL节点复制的功能。(关于GTID的详细信息,请参看云栖文章)

MySQL 5.6提供了WAIT_UNTIL_SQL_THREAD_AFTER_GTIDS(GTID_SET[,TIMEOUT])函数来等待从节点把GTID_SET指定事务都执行完毕,除非timeout(以秒为单位)的时间已经耗费而超时。 这个方法存在一些缺点,例如:

  1. 该功能依赖于slave来运行,如果复制线程没有启动或者出错了,就会返回错误。在某些情况下我们需要一直等待;
  2. 返回的是执行的事件的个数,这通常是没有意义的,返回成功或者失败即可。

MySQL 5.7为解决上面的几个问题,又添加了新的函数 WAIT_FOR_EXECUTED_GTID_SET\(GTID_SET[,TIMEOUT])。当GTID_SUBSET(GTID_SET, @@global.gtid_executed)成立时,即指定的GTID是gtid_executed的子集时,返回0表示成功,否则返回1,表示失败,如果超时,也会失败。

事务精细拆分路由

在MySQL 5.7中,我们可以通过设置session_track_transaction_info变量来跟踪事务的状态。在一个负载均衡系统中,你需要知道哪些statement开启或处于一个事务中,哪些statement允许连接分配器调度到另外一个connection。session_track_transaction_info是多个字符组成的字符串,各个位置的字符代表特定的状态。比如place 3的字符如果是’R’就代表事务里有一个或者多个的事务性table被读,’_’代表没有这样的table被事务读;place 5的字符如果是’W’就代表事务里有一个或多个的事务性table被写,‘_’就代表没有。前面我们提到的通过system variables设置只读属性的操作,也可能会改变session_track_transaction_info的值。 关于MySQL 5.7跟踪事务状态功能的详情请参考 MySQL的WL文档

总结

读写分离是MySQL实现负载均衡,保证高可用和高扩展性的重要手段,MySQL内核提供了对读写分离的多种手段的支持,从通过设置系统variable在事务,session,以及节点级别设置只读属性,到通过使用GTID和WAIT_FOR_EXECUTED_GTID_SET函数,可以保证只读节点与主几点的读一致性,再到MySQL 5.7事务状态字的方式精细记录,给事务的精细拆分路由提供了更多的支持, RDS的读写分离中间件与MySQL内核有深度的整合,来改善用户体验,提高系统吞吐。

参考资料

  1. https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.htm1l
  2. https://yq.aliyun.com/articles/41155

PgSQL · 内核解析 · 同步流复制实现分析

$
0
0

摘要

PostgreSQL 的流复制自引入以来以稳定著称,近几年的几个大版本陆续完成了好几个大特性,例如

  • 1.远程物理备份
  • 2.同步流复制
  • 3.级联流复制
  • 4.逻辑流复制

让流复制在整个 PostgreSQL 技术方案中扮演越来越重要的角色。 本文将剖析 PostgreSQL 同步流复制的关键实现细节,希望大家喜欢。

相关概念

一.物理流复制

物理流复制是一种数据库主备同步技术,该特性同步的数据是数据库中物理页面变化数据(WAL),该模式备库的底层数据页面状态和主库完全相同,这样的实现方案让数据库主备以及同步状态都非常稳定。

二.流复制中的角色

  • 1. 主库 backend 进程,它负责执行用户的 SQL,在修改数据前会先记录 WAL(Write-Ahead Logging)日志。这些日志中事物提交日志(CommitTransaction)由 backend 进程负责写到磁盘。
  • 2. 主库 WALsender 进程,负责把 WAL 日志发送给备库的 WALreceiver 进程。
  • 3. 备库 WALreceiver 进程,负责接收 WALsender 发送的 WAL 日志,并持久化到存储。
  • 4. 备库 startup 进程,负责恢复 WALsender 写到磁盘上的 WAL 日志,把数据 apply 到数据页面上。

异步流复制和同步流复制

一.异步流复制

默认状态下的流复制是以异步方式工作的,也就是说主库写本地数据和 WAL 日志,WALsender 异步的把数据发送给备库,备库收到数据后再异步的做数据恢复。

异步模式可以做到较好的性能,它的劣势是:极端情况下,主库如果当机,被库被激活成主库,部分 WAL 没有发送到备库,可能造成数据丢失。

二.同步流复制

相对于异步模式,PostgreSQL 还支持同步模式的流复制。同模模式可以细分为三级

  • 1. REMOTE_WRITE 保证该事务的所有数据被备库收到(备库收到数据并调用 write 写磁盘,但并未持久化到磁盘)
  • 2. REMOTE_FLUSH 保证该事务的所有数据在备库持久化到磁盘(调用 flush,但只读查询看不到)
  • 3. REMOTE_APPLY 保证该事务的所有数据在备库被恢复到数据页面(恢复进程读取并解析 WAL,再 APPLY 到数据页面,在备库上执行的只读查询能看到数据的变化)

三. 同步流复制源码解析

1. MVCC 机制和数据可见性

简单的说 PostgreSQL ACID 是基于 MVCC 和 WAL 技术。数据的修改过程可以简单描述为

  • 1. 首先 backend 开启是一个事务,获得一个事务号 XID;
  • 2. 在这个事务中对数据的任意修改,都被 XID 标记。
  • 3. 其他 backend 在扫描数据时,会看到被这个 XID 修改过的数据,根据当前的隔离级别,选择对这些数据是否可见(默认的读已提交隔离级别看不到这些数据)。
  • 4. 只有当此 XID 最后被标记成 commit (写 WAL commit log 和写 clog)后,其他的 backend 才能看到这个 XID 修改的数据。

2. 同模流复制的关键点

总结一下,实现流复制的同步模式,关键点在每个事务提交或回滚时,保证它产生的所有数据变化日志,即 WAL 都“同步”到备库。最后一条 WAL commit log 尤为关键。

3. 如何实现同步流复制

铺垫完所有概念和前提技术,我们看看同步模式具体是怎么实现的。 以事务提交流程为例:

  • 1. [主库 backend 进程]调用 RecordTransactionCommit 中写 WAL commit log,获得这条日志在在 WAL 中的位置 XLogRecPtr
  • 2. [主库 backend 进程]完成写 WAL 后,进入 SyncRepWaitForLSN 等待 WAL 日志“同步”到备库。具体做法是:在共享内存中创建一个等待队列 SHMQueue 记录 XLogRecPtr,并调动 WaitLatch,让出 CPU 等待被唤醒。
  • 3. [主库 WALsender 进程]相应所有备库的 WALreceiver 拉取 WAL 的请求。把 WAL 发送给所有备库。
  • 4. [备库 WALreceiver 进程]写 WAL 的偏移(LogstreamResult.Write)和持久化 WAL 偏移(LogstreamResult.Flush)记录下来。
  • 5. [备库 startup 进程]不断的恢复数据,把当前恢复到的 WAL 位点放在共享内存 xlogctl->lastReplayedEndRecPtr 中。
  • 6. [备库 WALreceiver 进程]不断通过 r 报文和主库 WALsender 进程同步的状态,即 XLOG_WRITE_LSN XLOG_REMOTE_LSN XLOG_APPLY_LSN(XLogWalRcvSendReply)
  • 7. [主库 WALsender 进程]收到备库发送的 r 报文后,检查共享内存中的等待队列 SHMQueue, 根据备库反馈的位点结合 SHMQueue,唤醒那些等待队列中睡眠的 主库 backend 进程(WalSndWaitForWal)。
  • 8. [主库 backend 进程]被唤醒,当前事务成功提交,SQL 执行完成返回给客户端。

最后总结

本文简要分析了 PostgreSQL 同步流复制实现的关键点。整个流程比较复杂,涉及到数据库多个角色进程间的相互协作,并且使用了多种数据结构和多种进程间 IPC 通信方法。对我们了解 PostgreSQL 底层实现很有帮助。 希望能帮到想了解这部分的实现细节的朋友,谢谢。

MySQL · 捉虫动态 · UK 包含 NULL 值备库延迟分析

$
0
0

前言

在之前的月报 RDS 只读实例延迟分析中,我们介绍了一些常见的备库延迟的场景,今天给大家分享一个比较少见的特殊场景。

简单的来说,就是在 UK 索引中存在大量 NULL 值情况下,如果备库选用这个 UK 来同步更新,会导致非常大的延迟。

背景知识

UK 中有大量 NULL 值,第一次看到这个可能会觉得有点奇怪,但是这确实是允许的,官方文档写的非常清楚:

A UNIQUE index creates a constraint such that all values in the index must be distinct. An error occurs if you try to add a new row with a key value that matches an existing row. For all engines, a UNIQUE index permits multiple NULL values for columns that can contain NULL.

同时这个也是SQL92标准定义的:

A unique constraint is satisfied if and only if no two rows in a table have the same non-null values in the unique columns. In addition, if the unique constraint was defined with PRIMARY KEY, then it requires that none of the values in the specified column or columns be the null value.

关于这个问题,官方bug list 也有激烈的讨论: Bug #8173 unique index allows duplicates with null values,有的人认为是 feature,有的人认为是 bug。

NULL 和 NULL 是不一样的,我们可以将 NULL 理解为未知,虽然现在不知道,但它未来有很多可能性,只是我们现在还不知道而已。

MySQL 对于 NULL 也有专门的处理,例如比较运算符,以及一些函数在 NULL 上是失效的,结果可能出乎意料,详细情况可以参考官方文档 Working with NULL ValuesProblems with NULL Values

问题描述

介绍完背景知识,我们来看下具体问题吧。问题来源于真实用户 case,下面的表结结构和数据是为了说明方便特殊构造的。

表结构如下:

ds_logs_uk
Create Table: CREATE TABLE `rds_logs_uk` (
  `id` bigint(20) NOT NULL,
  `ins_name` varchar(32) NOT NULL DEFAULT 'ins',
  `ins_uuid` varchar(36) DEFAULT NULL,
  UNIQUE KEY `idx_uuid` (`ins_uuid`),
  KEY `idx_name` (`ins_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

可以看到 UK 的 column 是允许 NULL的,我们看下数据分布情况:

mysql> select count(*) from rds_logs_uk where ins_uuid is NULL;
+----------+
| count(*) |
+----------+
| 24549655 |
+----------+

mysql>  select count(ins_uuid) from rds_logs_uk where ins_uuid is not NULL;
+-----------------+
| count(ins_uuid) |
+-----------------+
|        20505406 |
+-----------------+

可以看到 NULL 基本占了一半多。

我们用脚本在主库上,每隔 1s 执行下面的 SQL:

delete from rds_logs_uk where ins_name >= '888' and ins_name <= '899' and ins_uuid is NULL limit 1;

然后监控主备的 InnoDB rows 统计信息:

image.pngimage.png

可以看到备库在做大量的查询,还没有删到一条记录,这就导致了备库延迟。

问题分析

为什么会差别这么大呢,我们在主库只删除一条记录,扫了317行,但是到了备库后,扫了很多行,连一条匹配的记录都没找到,一个比较可能的解释是走的索引不一样了。主库上 explain 的结果如下:

image.png

主库用了 idx_name 这个普通二级索引,而没有用UK,因为主库优化器算出来,走 UK 的代价更大。

image.pngimage.png

从上面的现象,我们可以大致推出,备库跑的更慢,是因为备库在同步更新时,用错了索引,用 UK 来更新。

为什么会选错索引呢,在 ROW 格式下,备库在同步更新时,索引的选择是基于简单规则的,没有走优化器的代价模型,规则如下:

  1. PK
  2. UK without NULL
  3. other keys(include UK with NULL)
  4. table_scan

从1到4,优先级依次递减,在选择时,只要有索引满足规则,就选择这个索引,并不再往下找了。具体的逻辑在 sql/log_event.cc:search_key_in_table()中,大家可以自己看下代码,这里就不在贴了。

按照我们的表结构,是会用第3条规则来选出索引的,按索引的先后顺序,遍历一遍,找到第一个可用的。什么样的索引是可用呢,只要索引对应的字段,在 event row 中存在,就是可用的,而我们的 binlog_row_image = FULL,这样每一个索引都是用到的,而 UK 是排在普通二级索引前面的,所以就选了 UK。做删除时,需要先查到和 delete image 匹配的记录,然后再删除。用NULL值在UK上扫描,虽然 NULL 和 NULL 值是不一样的,但是在实现表示上都是一样的,也就是说所有的 NULL 在索引在是排列在一起的,这样通过在 UK 查找很多 NULL 然后回表拿到全字段记录,发现和 delete image 中记录不匹配,最终导致在找到匹配记录前,扫描了大量的NULL。

为什么这里没用优化器的代码计算呢,小编认为有正面的原因,也有反面的原因:

正面来看,因为在 row 格式 full image 下,相当于 where 条件所有字段都有的,PK/UK 这种索引如果有是肯定能用上的,而通常都会有PK的。

反面来看,因为每个 row 都做一次代价评估,太不划算了。。。主库上一条 SQL 可能更新了 n 条记录,只需要做一次代价计算,而备库在同步row binlog 时,每个 row 都要做一次代价计算,这样代价计算的成本,就会非常高。

问题解决

如果遇到这种情况,怎么解呢?解法有多种,目的都是一样,让备库不选择 UK 来做同步。

  1. 加法:在备库加一个比当前 UK 更好的索引,需要更好的UK和PK,不能是普通二级索引,因为普通二级索引,是排在UK后面的,不会被选择。
  2. 减法:直接 drop 掉 UK,在做读写分离的备库上,不建议这么做,因为可能影响业务的查询。
  3. 比 2 轻量一点,用 Invisible Index 特性。MySQL 官方 8.0 和 AliSQL 都支持 Invisible Index,我们可以在备库上把 UK 临时改成 Invisible,等备库同步完后,再改回 Visible。不过我们在实际测试中,发现这个方法不起作用,因为备库应用 row 时,根本不认 invisible 属性,这个已经跟官方提 bug#88847,AliSQL 在即将发布的新版本中,会 fix 掉这个问题,欢迎大家使用。

MySQL · 捉虫动态 · Error in munmap() "Cannot allocate memory"

$
0
0

前言

最近线上遇到一个问题,一个MySQL实例报错 Error in munmap(): Cannot allocate memory 造成进程异常退出

背景介绍

MySQL 使用 jemalloc 进行内存分配,报错的原因是 MySQL 进程的 VMA 数量大于操作系统上限

这里先介绍几个前序概念

虚拟内存区域 VMA

Linux进程通过vma进行管理,每个进程都有一个结构体中维护一个vma链表,其中每个vma节点对应一段连续的进程内存。这里的连续是指在进程空间中连续,物理空间中不一定连续。如果进程申请一段内存,则内核会给进程增加vma节点

/proc/pid/maps

/proc/pid/maps 记录了进程的虚拟内存使用情况

举个例子,进程b.out的maps如下,每一行代表一个VMA(删除了一部分重复的行

00400000-00401000 r-xp 00000000 fd:01 1574192                            /u01/b.out
00602000-00701000 rw-p 00000000 00:00 0                                  [heap]
7ffff71f8000-7ffff73b0000 r-xp 00000000 fd:01 1049989                    /usr/lib64/libc-2.17.so
7ffff75b6000-7ffff75bb000 rw-p 00000000 00:00 0
7ffff75bb000-7ffff75d0000 r-xp 00000000 fd:01 1052643                    /usr/lib64/libgcc_s-4.8.5-20150702.so.1
7ffff77d1000-7ffff78d2000 r-xp 00000000 fd:01 1049997                    /usr/lib64/libm-2.17.so
fff7ad3000 rw-p 00101000 fd:01 1049997                    /usr/lib64/libm-2.17.so
7ffff7ad3000-7ffff7bbc000 r-xp 00000000 fd:01 1050280                    /usr/lib64/libstdc++.so.6.0.19
dc6000 rw-p 000f1000 fd:01 1050280                    /usr/lib64/libstdc++.so.6.0.19
7ffff7dc6000-7ffff7ddb000 rw-p 00000000 00:00 0
7ffff7ddb000-7ffff7dfc000 r-xp 00000000 fd:01 1049982                    /usr/lib64/ld-2.17.so
7ffff7fce000-7ffff7ff4000 rw-p 00000000 00:00 0
7ffff7ff9000-7ffff7ffa000 rw-p 00000000 00:00 0
7ffff7ffa000-7ffff7ffc000 r-xp 00000000 00:00 0                          [vdso]
7ffff7ffc000-7ffff7ffd000 r--p 00021000 fd:01 1049982                    /usr/lib64/ld-2.17.so
7ffff7ffd000-7ffff7ffe000 rw-p 00022000 fd:01 1049982                    /usr/lib64/ld-2.17.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]
  • 第一列,如00400000-00401000
    • 虚拟空间的起始和终止地址
  • 第二列,如rw-p
    • VMA的权限,前三位rwx分别代表可读、可写、可执行,“-”代表没有该权限;第四位p/s代表私有/共享段
  • 第三列,如00021000
    • 虚拟内存起始地址在文件中的偏移量,匿名映射为0
  • 第四列,如fd:01
    • 映射文件所属设备好,匿名映射为0
  • 第五列,如1049982
    • 映射文件所属节点号,匿名映射为0
  • 第六列,如/u01/b.out /usr/lib64/libstdc++.so.6.0.19 [stack]
    • 映射文件名,[heap]代表堆,[stack]代表栈

vm.max_map_count

max_map_count 是一个进程内存能拥有的VMA最大数量

当进程达到了VMA上限但又只能释放少量的内存给其他的内核进程使用时,操作系统会抛出内存不足的错误

Error in munmap(): Cannot allocate memory 就是触发了这个错误

问题复现

操作系统 vm.max_map_count=65530

执行以下代码,可以复现munmap无法分配内存的错误

#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

#define VM_MAX_MAP_COUNT (65530)
#define VM_SIZE (4096)
#define VM_CNT (VM_MAX_MAP_COUNT * 2)


static void* vma[VM_CNT];

int main(void)
{
    int i;
    for (i = 0; i < VM_CNT; i++)
    {
        vma[i] = mmap(0, VM_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0 );
    }

    for (i = 0; i < VM_CNT; i++)
    {
        if (munmap(vma[i], VM_SIZE) != 0)
            printf("mumap() ERROR");
    }
}

先用 mmap 分配 65530 * 2 个虚拟内存空间

因为是连续分配的,操作系统会合并成一个VMA,下图可以看出,/proc/pid/maps 文件中多了一个VMA

另有两个已存在的VMA被修改

image.png

多出来的VMA 7fffd73fc000-7ffff71f8000 共有130566个 VM_SIZE

7ffff7fef000-7ffff7ff4000(0x5000) -> 7ffff7dfc000-7ffff7ff5000(0x1f9000) 多出 500 个 VM_SIZE

7ffff7ff9000-7ffff7ffa000 -> 7ffff7ff5000-7ffff7ffa000 起始地址前移 0x4000, 多出 4 个 VM_SIZE 

130566 + 500 + 4 = 131070 = 65536 * 2 正好是程序中申请的内存大小

下面再用munmap每隔一个VM_SIZE释放一个VM_SIZE,将原本连续的虚拟内存空间变得不连续,这样就会形成65536个VMA,再加上本来存在的若干个VMA,超过了操作系统设定的VMA上限 65530

实际执行时,VMA数量到达65530时,再执行munmap就会报错

jemalloc 和 glibc malloc

MySQL 使用 jemalloc 分配内存,jemalloc 默认采用mmap()/munmap()分配和释放内存,已经验证 jemalloc 在 max_map_count较小时会触发无法分配内存的异常

使用同样场景验证 glibc malloc 是否存在同样问题,glibc malloc 分配 128k 以上内存是默认使用mmap,128k以下时默认使用sbrk,所以这里把VM_SIZE 改为 129k

#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

#define VM_MAX_MAP_COUNT (65530)
#define VM_SIZE (129 * 1024)
#define VM_CNT (VM_MAX_MAP_COUNT * 2)


static void* vma[VM_CNT];

int main(void)
{
    int i;
    for (i = 0; i < VM_CNT; i++)
    {
        vma[i] = malloc(VM_SIZE);
    }

    for (i = 0; i < VM_CNT; i++)
    {
        free(vma[i])
    }
}

image.png

上图可以看出,除了申请新的VMA,heap空间也增长了

新增VMA 7ffde73e7000-7ffff71f8000 共 67044个VM_SIZE
	
	7ffff7fef000-7ffff7ff4000 -> 7ffff7e00000-7ffff7ff4000 共15个VM_SIZE
	
	[heap] 00602000-00701000 -> 00602000-20467e000 共 65531 个VM_SIZE

多分配出1530个VM_SIZE 这里应该是glibc 内部机制控制的,不做深入研究

重点在于申请了65531个VM_SIZE的[heap]空间,这部分空间由sbrk()申请

mmap() 和 sbrk()

malloc 申请小于 128k 内存时,使用 sbrk() 分配,大于 128k 默认使用 mmap()

同时,mmap() 分配内存最多65536次,超过后使用 sbrk() 分配

mmap()在虚拟地址空间中找一块空闲地址分配,分配的内存可以被随意释放

sbrk() 将指向数据段的最高地址的_edata指针上推,释放时将_edata下推;显然,sbrk()无法随意释放内存,释放一块内存时,必须把比它地址高的内存全部释放

如图,初始时 _edata 在 heap 最下方,分配A时 _edata 推到 A 下方,分配 B/ C时 _edata 推到 B/C 下方

释放 C 时,再将 _edata 上推到 B 下方,但是在释放 B 之前释放 A 只能讲 A 的内存块标记为未使用,供下次分配,不能移动 _edata

.text
.data
heap
A
B
C

显然,释放sbrk()申请的内存时,不会增加VMA数量,所以 glibc malloc 不会使 VMA 数量超过 max_map_count,不会触发上述问题

总结

问题的原因很明显,VMA 数量超过 max_map_count,调大 max_map_count 可以简单粗暴的解决这个问题

jemalloc 在大多数情况下比 glibc malloc 性能更好,但也不能适用全部场景。业务场景合适时适用 glibc malloc 也可以规避这个问题


MSSQL · 最佳实践 · 数据库备份链

$
0
0

摘要

在SQL Server备份专题分享中,前两期我们分享了三种常见的备份以及备份策略的制定,在第三期分享中,我们将要分享SQL Server的数据库备份链。完整的数据库备份链是保证数据库能够实现灾难恢复的基础,如果备份链条被打断或者备份链条上的文件损坏,势必会导致数据恢复不完整或者不能满足预期,而造成数据丢失,危害数据完整性生命线,后果非常严重。所以,理解SQL Server数据库备份链非常重要。

MSSQL· 最佳实践· SQL Server三种常见备份

MSSQL · 最佳实践 · SQL Server备份策略

场景引入

在开始今天关于SQL Server数据库备份链的分享之前,我们还是以上一期SQL Server备份策略分享的场景引入。假设我们存在如下图所示的备份文件列表,为什么我们就可以使用“全备 + 差备 + 日志备份”来实现快速的数据库还原操作呢?我们又是如何快速的查找到TestDR数据库的备份链条,并以此来还原的呢?这两个话题是我们今天要解答和解决的。 TestDR数据库备份文件列表如下图所示: 01.png

LSN介绍

在解决今天的问题之前,我需要首先介绍一个非常重要的概念:LSN。SQL Server的每一个事务日志都有自己的唯一标识号Log Sequence Number(简写为LSN)。LSN会随着时间的推移和事务日志的产生而不断增大。那么在SQL Server中如何查看以及有几种类型的LSN呢?

备份文件LSN

在SQL Server中,查看备份的LSN非常简单,我们可以使用RESTORE HEADERONLY命令跟上备份文件路径,即可查看。

RESTORE HEADERONLY FROM DISK='C:\Temp\TestDR_20171217@00:00_FULL.bak'
RESTORE HEADERONLY FROM DISK='C:\Temp\TestDR_20171217@01:00_LOG.trn'

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

LSN种类

从以上截图红色方框中我们可以看出来,SQL Server的LSN分为以下四类:

  • FirstLSN:备份集中第一个事务日志的LSN;
  • LastLSN:备份集中最后一个事务日志的下一个LSN;
  • CheckpointLSN:最后一个Checkpoint发生时的LSN;
  • DatabaseBackupLSN:最后一个FULL Backup备份的LSN。

备份链原理

LSN是查找SQL Server 数据库备份链不可或缺的神一般存在的关键信息。我们可以利用前面讲到的四类LSN,使用如下五步骤来实现SQL Server备份链的查找。

  • 获取Full Backup: 查找最新一个FULL Backup文件,拿到对应CheckpointLSN;
  • 获取Diff Backup:查找Diff Backup文件,Diff Backup文件的DatabaseBackupLSN等于第一步中的CheckpointLSN,如果有多个Diff Backup文件,取FirstLSN最大者,然后拿到相应LastLSN;
  • 获取第一个Log Backup:查找第二步中的LastLSN位于Log Backup文件的FirstLSN和LastLSN之间的Log Backup,并获取相应的LastLSN;
  • 获取下一个Log Backup: 下一个Log Backup文件的FirstLSN等于第三步中获取到额LastLSN;
  • 获取第N个Log Backup:按照步骤四中的方法查找,直到所有日志文件查找完毕后,一个完整的备份链条查找完毕。

以上的理论解释稍显枯燥,我们用具体的示例来解释,就显得更为丰满和易于理解。我们把“场景引入”中的十五个备份文件,参照“备份文件LSN”中的方法来获取到LSN,如下面的截图所示: 03.png

那么,TestDR数据库的备份链可以通过如下的方法来查找:

  • 获取Full Backup: 查找最新一个FULL Backup(BackType=1)文件,拿到CheckpointLSN:24000000012800197,图中红色方框标示;
  • 获取Diff Backup:查找Diff Backup(BackType=5)文件,Diff Backup文件的DatabaseBackupLSN等于第一步中的CheckpointLSN:24000000012800197,图中黄色方框标示,取FirstLSN最大者: 24000000037600007,然后拿到LastLSN: 24000000039200001;
  • 获取第一个Log Backup:查找第二步中的LastLSN(24000000039200001)位于Log Backup(BackType=5)的FirstLSN(24000000036800001)与LastLSN(24000000040000001)之间的Log Backup,如图中绿色方框标示,并获取到LastLSN:24000000040000001;
  • 获取下一个Log Backup: 下一个Log Backup文件的FirstLSN(24000000040000001)应该等于上一步获取到额LastLSN,如图中粉红色标示;
  • 获取第N个Log Backup:按照步骤四中的方法查找,直到所有日志文件查找完毕。 所以,我们最终获取到的TestDR数据库的最简单,快速恢复的备份链是:
C:\Temp\TestDR_20171217@00:00_FULL.bak => C:\Temp\TestDR_20171217@12:00_DIFF.bak => C:\Temp\TestDR_20171217@13:00_LOG.trn => C:\Temp\TestDR_20171217@14:00_LOG.trn。

这个链条和我们上一期分享的数据库备份文件还原操作是完全吻合的。

具体实现

在“备份链原理”章节中,我们找出备份链是通过“用眼睛看”的查找方式来实现的。但是现实中如果数据库实例众多,数据库纷繁复杂,用眼睛看的方法就显得低效而准确率不高。那么,如何实现数据库备份链的自动查找呢?不着急,我已经为大家写好了,你只需要把相同数据库的多个备份文件路径放入输入参数中,我们就非常轻松、快捷的查找到数据库的备份链,并以此为依据来还原数据库。还是以TestDR的十五个备份文件为例来查找备份链条,代码如下:

USE master
GO


SET NOCOUNT ON
DECLARE
-- all the backup files
	@backup_file_list NVARCHAR(MAX) = N'
C:\Temp\TestDR_20171217@00:00_FULL.bak
C:\Temp\TestDR_20171217@01:00_LOG.trn
C:\Temp\TestDR_20171217@02:00_LOG.trn
C:\Temp\TestDR_20171217@03:00_LOG.trn
C:\Temp\TestDR_20171217@04:00_LOG.trn
C:\Temp\TestDR_20171217@05:00_LOG.trn
C:\Temp\TestDR_20171217@06:00_DIFF.bak
C:\Temp\TestDR_20171217@07:00_LOG.trn
C:\Temp\TestDR_20171217@08:00_LOG.trn
C:\Temp\TestDR_20171217@09:00_LOG.trn
C:\Temp\TestDR_20171217@10:00_LOG.trn
C:\Temp\TestDR_20171217@11:00_LOG.trn
C:\Temp\TestDR_20171217@12:00_DIFF.bak
C:\Temp\TestDR_20171217@13:00_LOG.trn
C:\Temp\TestDR_20171217@14:00_LOG.trn
'
;

-- Create temp table to save headeronly info
IF OBJECT_ID('tempdb..#headeronly') IS NOT NULL
	DROP TABLE #headeronly
CREATE TABLE #headeronly(
	RowId INT IDENTITY(1,1) NOT NULL PRIMARY KEY
	,BackupName	nvarchar(128) NULL
	,BackupDescription	nvarchar(255) NULL	
	,BackupType	smallint  NULL
	,ExpirationDate	datetime
	,Compressed	bit  NULL
	,Position	smallint  NULL	
	,DeviceType	tinyint	 NULL
	,UserName	nvarchar(128)  NULL	
	,ServerName	nvarchar(128)  NULL	
	,DatabaseName	nvarchar(128)  NULL
	,DatabaseVersion	int	  NULL
	,DatabaseCreationDate	datetime  NULL
	,BackupSize	numeric(20,0)  NULL
	,FirstLSN	numeric(25,0)  NULL
	,LastLSN	numeric(25,0)  NULL
	,CheckpointLSN	numeric(25,0)  NULL
	,DatabaseBackupLSN	numeric(25,0)  NULL
	,BackupStartDate	datetime  NULL
	,BackupFinishDate	datetime  NULL
	,SortOrder	smallint  NULL
	,CodePage	smallint  NULL
	,UnicodeLocaleId	int  NULL
	,UnicodeComparisonStyle	int  NULL
	,CompatibilityLevel	tinyint  NULL
	,SoftwareVendorId	int  NULL
	,SoftwareVersionMajor	int  NULL
	,SoftwareVersionMinor	int  NULL
	,SoftwareVersionBuild	int  NULL
	,MachineName	nvarchar(128)  NULL
	,Flags	int  NULL
	,BindingID	uniqueidentifier  NULL
	,RecoveryForkID	uniqueidentifier  NULL
	,Collation	nvarchar(128)  NULL
	,FamilyGUID	uniqueidentifier  NULL
	,HasBulkLoggedData	bit  NULL
	,IsSnapshot	bit  NULL
	,IsReadOnly	bit  NULL
	,IsSingleUser	bit  NULL
	,HasBackupChecksums	bit  NULL
	,IsDamaged	bit  NULL
	,BeginsLogChain	bit  NULL
	,HasIncompleteMetaData	bit  NULL
	,IsForceOffline	bit  NULL
	,IsCopyOnly	bit  NULL
	,FirstRecoveryForkID	uniqueidentifier   NULL
	,ForkPointLSN	numeric(25,0) NULL
	,RecoveryModel	nvarchar(60)  NULL
	,DifferentialBaseLSN	numeric(25,0)  NULL
	,DifferentialBaseGUID	uniqueidentifier  NULL
	,BackupTypeDescription	nvarchar(60)  NULL
	,BackupSetGUID	uniqueidentifier NULL
	,CompressedBackupSize	bigint  NULL
);

-- private variables
DECLARE
	@backup_file_list_xml xml
	,@backup_file sysname
	,@row_count int
	,@do int = 0
	,@sql NVARCHAR(MAX) = N''
	,@full_backup_CheckPointLSN numeric(25,0)
	,@full_backup_familyGUID uniqueidentifier
	,@diff_backup_LastLSN numeric(25,0)
	,@log_backup_LastLSN numeric(25,0)
	,@back_chain_level_number INT = 0
	,@full_backup_rowid INT
	,@diff_backup_rowid INT
;

SELECT
	@backup_file_list_xml = '<V><![CDATA[' + REPLACE(
													REPLACE(
																REPLACE(
																			@backup_file_list,CHAR(10),']]></V><V><![CDATA['
																		),',',']]></V><V><![CDATA['
															),CHAR(13),']]></V><V><![CDATA['
												  ) + ']]></V>'
;

DECLARE
	@tb_back_files TABLE(
	Rowid INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
	backup_file sysname not null
);

DECLARE
	@tb_back_file_rowid TABLE(
	Bak_row_id INT NOT NULL,
	Level_Number INT NOT NULL
);

-- split backup files into @table
INSERT INTO @tb_back_files
SELECT 
	DISTINCT	
	backup_file = RTRIM(LTRIM(T.C.value('(./text())[1]','sysname'))) 
FROM @backup_file_list_xml.nodes('./V') AS T(C)
WHERE T.C.value('(./text())[1]','sysname') IS NOT NULL
	AND LEFT(RTRIM(LTRIM(T.C.value('(./text())[1]','sysname'))), 2) <> '--'
ORDER BY 1 ASC

-- cursor for each backup file to get headeronly info 
DECLARE CUR_backup_file CURSOR LOCAL STATIC READ_ONLY FORWARD_ONLY
FOR
SELECT 	
	 backup_file
FROM @tb_back_files
ORDER BY Rowid ASC

OPEN CUR_backup_file
FETCH NEXT FROM CUR_backup_file INTO @backup_file

SET @row_count = @@CURSOR_ROWS

WHILE @@FETCH_STATUS = 0
BEGIN
	RAISERROR('--=== %d/%d ===Processing on %s', 10, 1, @do, @row_count, @backup_file) WITH NOWAIT
	SET
		@sql = N'RESTORE HEADERONLY FROM DISK = N' + QUOTENAME(@backup_file, '''') + N';'
	; 
	INSERT INTO #headeronly
	EXEC sys.sp_executesql @sql
	;

	SET @do = @do + 1
	FETCH NEXT FROM CUR_backup_file INTO @backup_file
END

CLOSE CUR_backup_file
DEALLOCATE CUR_backup_file

/**
* FULL backup file checking
*1. make sure those backup files came from the same database.
*2. there is  no full backup
*3. There have more than one FULL backup files.
*/
-- make sure backup files came from the same database.
IF EXISTS(
	SELECT TOP 1 1
	FROM #headeronly
	GROUP BY FamilyGUID
	HAVING COUNT(DISTINCT FamilyGUID) >= 2
)
BEGIN
	RAISERROR('Those backup fils didn''t backup from the same database, process terminated.', 16, 1) WITH NOWAIT
	RETURN
END

-- There is no full backup file
IF NOT EXISTS(
	SELECT TOP 1 1
	FROM #headeronly
	WHERE BackupType = 1 -- FULL Backup
)
BEGIN
	RAISERROR('There is no FULL BACKUP included, process terminated.', 16, 1) WITH NOWAIT
	RETURN
END

-- Have more than one FULL Backup, we don't know use which one.
IF EXISTS(
	SELECT TOP 1 1
	FROM #headeronly
	WHERE BackupType = 1
	GROUP BY BackupType
	HAVING COUNT(1) >= 2
)
BEGIN
	RAISERROR('Those backup fils are more than one FULL BACKUP including, we may don''t know use which one, process terminated.', 16, 1) WITH NOWAIT
	RETURN
END

-- We have only one full backup, get FULL Backup file CheckPointLSN
SELECT TOP 1 
	@full_backup_CheckPointLSN = CheckPointLSN
	,@full_backup_familyGUID = FamilyGUID
	,@back_chain_level_number = 0
	,@full_backup_rowid = RowId
FROM #headeronly
WHERE BackupType = 1

INSERT INTO @tb_back_file_rowid
SELECT @full_backup_rowid, @back_chain_level_number;

PRINT 'We got full backup check point LSN: ' + cast(@full_backup_CheckPointLSN as varchar)


/**
*get the very last diff backup file
**/
-- if exists diff backup, get the newest one whose DatabaseBackupLSN equals @full_backup_CheckPointLSN got above
IF EXISTS(
	SELECT TOP 1 1
	FROM #headeronly
	WHERE BackupType = 5 -- DIFF Backup
		AND DatabaseBackupLSN = @full_backup_CheckPointLSN
		AND FamilyGUID = @full_backup_familyGUID
)
BEGIN
	RAISERROR('We are searching the Differential Database Backup.', 10, 1) WITH NOWAIT

	SELECT TOP 1 
		@diff_backup_LastLSN = LastLSN
		,@back_chain_level_number = @back_chain_level_number + 1
		,@diff_backup_rowid = RowId
	FROM #headeronly
	WHERE BackupType = 5 -- DIFF Backup
		AND DatabaseBackupLSN = @full_backup_CheckPointLSN
		AND FamilyGUID = @full_backup_familyGUID
	ORDER BY FirstLSN DESC -- get the max firstLSN (means newest one)

	INSERT INTO @tb_back_file_rowid
	SELECT @diff_backup_rowid, @back_chain_level_number;

	PRINT 'We got newest Differential backup Last LSN: ' + cast(@diff_backup_LastLSN as varchar)
END
ELSE
	RAISERROR('We didn''t find any differential backups yet match the full backup.', 10, 1) WITH NOWAIT

-- get the very first log backup whose FirstLSN littler and LastLSN greater than @diff_backup_LastLSN 
-- when exists differential backup
IF @diff_backup_LastLSN IS NOT NULL
BEGIN
	IF EXISTS(
		SELECT TOP 1 1 
		FROM #headeronly
		WHERE BackupType = 2 -- Log backup
			AND FirstLSN <@diff_backup_LastLSN
			AND LastLSN>= @diff_backup_LastLSN
			AND FamilyGUID = @full_backup_familyGUID
	)
	BEGIN
		SELECT TOP 1 
			@log_backup_LastLSN = LastLSN
			,@back_chain_level_number = @back_chain_level_number + 1
		FROM #headeronly
		WHERE BackupType = 2
			AND FirstLSN <@diff_backup_LastLSN
			AND LastLSN>= @diff_backup_LastLSN
			AND FamilyGUID = @full_backup_familyGUID

		PRINT 'We got log backup Last LSN: ' + cast(@log_backup_LastLSN as varchar)
	END
END
ELSE
-- There is no differential backup, we have to find between log backups.
-- Find the very first log backup LastLSN
BEGIN
	SELECT TOP 1 
		@log_backup_LastLSN = LastLSN
		,@back_chain_level_number = @back_chain_level_number + 1
	FROM #headeronly
	WHERE BackupType = 2 -- Log Backup
		AND FamilyGUID = @full_backup_familyGUID
		AND DatabaseBackupLSN = @full_backup_CheckPointLSN
		AND FirstLSN = @full_backup_CheckPointLSN
END

-- There is no log backup belongs to this full backup
IF @log_backup_LastLSN IS NULL
BEGIN
	RAISERROR('There is no any log backups belong to this full backup.', 10, 1) WITH NOWAIT
	RETURN
END

--SELECT 
--	full_backup_CheckPointLSN = @full_backup_CheckPointLSN
--	,diff_backup_LastLSN = @diff_backup_LastLSN	
--	,log_backup_LastLSN = @log_backup_LastLSN
--	,back_chain_level_number = @back_chain_level_number
--;

-- find out all the log backup need to be restore
;
WITH backup_files
AS
(
	SELECT lv = @back_chain_level_number,*
	FROM #headeronly
	WHERE LastLSN = @log_backup_LastLSN
		AND FamilyGUID = @full_backup_familyGUID
	UNION ALL
	SELECT bf.lv + 1,lg.*
	FROM #headeronly AS lg
		INNER JOIN backup_files as bf
		ON lg.FirstLSN = bf.LastLSN AND lg.FamilyGUID = bf.FamilyGUID
	WHERE lg.BackupType = 2
		AND lg.FamilyGUID = @full_backup_familyGUID
)

INSERT INTO @tb_back_file_rowid
SELECT RowId, lv FROM backup_files

SELECT 
	bkf.Backup_file, 
	hd.BackupType,
	BackupTypeDescription = 
		CASE hd.BackupType
			WHEN 1 THEN 'FULL'
			WHEN 2 THEN 'LOG'
			WHEN 5 THEN 'DIFF'
			ELSE ''
		END,
	hd.IsDamaged,
	restore_order = bkr.Level_Number 
FROM @tb_back_file_rowid AS bkr
	INNER JOIN @tb_back_files AS bkf
	ON bkr.Bak_row_id = bkf.Rowid
	INNER  JOIN #headeronly AS hd
	ON bkr.Bak_row_id = hd.RowId
ORDER BY bkr.Level_Number

--SELECT * 
--FROM #headeronly
--ORDER BY rowid asc

SELECT 
	bkf.backup_file,
	hd.BackupType, 
	hd.Compressed, 
	hd.DatabaseName, 
	hd.FirstLSN, 
	hd.LastLSN, 
	hd.CheckpointLSN, 
	hd.DatabaseBackupLSN, 
	hd.IsDamaged, 
	hd.BackupTypeDescription
FROM #headeronly AS hd
	INNER JOIN @tb_back_files AS bkf
	ON hd.RowId = bkf.Rowid
ORDER BY hd.RowId

备份链结果截图展示如下: 04.png

从这个截图中,restore_order和Backup_file字段可以很清楚的看到数据库还原的顺序,以及相应的备份文件路径。所有备份文件的LSN分布如下截图: 05.png

差异备份文件损坏

从备份链条我们发现,12:00的这个差异备份C:\Temp\TestDR_20171217@12:00_DIFF.bak非常关键,假设现实中,我们发现这个文件恰恰损坏掉了,那么我们的可以实现应用的数据库还原吗?答案是肯定的,我们把刚才的脚本输入参数修改如下:

DECLARE
-- all the backup files
	@backup_file_list NVARCHAR(MAX) = N'
C:\Temp\TestDR_20171217@00:00_FULL.bak
C:\Temp\TestDR_20171217@01:00_LOG.trn
C:\Temp\TestDR_20171217@02:00_LOG.trn
C:\Temp\TestDR_20171217@03:00_LOG.trn
C:\Temp\TestDR_20171217@04:00_LOG.trn
C:\Temp\TestDR_20171217@05:00_LOG.trn
C:\Temp\TestDR_20171217@06:00_DIFF.bak
C:\Temp\TestDR_20171217@07:00_LOG.trn
C:\Temp\TestDR_20171217@08:00_LOG.trn
C:\Temp\TestDR_20171217@09:00_LOG.trn
C:\Temp\TestDR_20171217@10:00_LOG.trn
C:\Temp\TestDR_20171217@11:00_LOG.trn
C:\Temp\TestDR_20171217@13:00_LOG.trn
C:\Temp\TestDR_20171217@14:00_LOG.trn
'
;

得到如下备份还原链条: 06.png

从这个结果,我们可以看到,系统选择了06:00这个差异备份文件,然后一步步使用接下来的事务日志备份文件进行还原。

最后总结

本期是SQL Server备份专题的第三期,在前两期基础上分享了如何通过备份文件查找SQL Server数据库备份链,并以此来还原数据库的理论方法和具体实现,可以帮助用户实现简单、快速查找数据库备份还原链条。

MySQL · 捉虫动态 · 字符集相关变量介绍及binlog中字符集相关缺陷分析

$
0
0

MySQL字符集相关变量介绍及binlog中字符集相关缺陷分析

MySQL支持多种字符集(character set)提供用户存储数据,同时允许用不同排序规则(collation)做比较。

本文基于MySQL5.7介绍了字符集相关变量的使用,通过例子描述了这些变量具体意义。分析了MySQL binlog中字符集相关处理的缺陷,这些缺陷会导致复制中断或者主备不一致。最后给出了修复上述缺陷的建议。

MySQL字符集相关基础知识介绍

character_set_system

character_set_system为元数据的字符集,即所有的元数据都使用同一个字符集。试想如果元数据采用不同字符集,INFORMATION_SCHEMA中的相关信息在不同行之间就很难展示。同时该字符集要能够支持多种语言,方便不同语言人群使用自己的语言命名database、table、column。MySQL选择UTF-8作为元数据编码,用源码固定。

sql/mysqld.cc
int mysqld_main(int argc, char **argv)
{
  ...
  system_charset_info= &my_charset_utf8_general_ci;
}
> select @@global.character_set_system;
+-------------------------------+
| @@global.character_set_system |
+-------------------------------+
| utf8                          |
+-------------------------------+

MySQL会将identifier转换为system_charset_info(utf8)。

sql/sql_lex.cc
static int lex_one_token(YYSTYPE *yylval, THD *thd)
{
 case MY_LEX_IDENT:
   ...
 lip->body_utf8_append_literal
   ...
}

void Lex_input_stream::body_utf8_append_literal(THD *thd,
                                                const LEX_STRING *txt,
                                                const CHARSET_INFO *txt_cs,
                                                const char *end_ptr)
{
  ...
  if (!my_charset_same(txt_cs, &my_charset_utf8_general_ci))
  {
    thd->convert_string(&utf_txt,
                       &my_charset_utf8_general_ci,
                       txt->str, txt->length,
                       txt_cs);
  }
  else
  {
    utf_txt.str= txt->str;
    utf_txt.length= txt->length;
  }
  ...
}

sql/sql_yacc.yy

IDENT_sys:
IDENT { $$= $1; }
| IDENT_QUOTED
{
  THD *thd= YYTHD;

  if (thd->charset_is_system_charset)
  {
    ...
  }
  else
  {
    if (thd->convert_string(&$$, system_charset_info,
                        $1.str, $1.length, thd->charset()))
      MYSQL_YYABORT;
  }
}
;

character_set_server/collation_server

当create database没有指定charset/collation就会用character_set_server/collation_server,这两个变量可以动态设置,有session/global级别。

在源码中character_set_server/collation_server实际对应一个变量,因为一个collation对应着一个charset,所以源码中只记录CHARSET_INFO结构的collation_server即可。当修改character_set_server,会选择对应charset的默认collation。对于其他同时有charset和collation的变量,源码记录也都是记录collation。

static Sys_var_struct Sys_character_set_server(
       "character_set_server", "The default character set",
       SESSION_VAR(collation_server), NO_CMD_LINE,
       offsetof(CHARSET_INFO, csname), DEFAULT(&default_charset_info),
       NO_MUTEX_GUARD, IN_BINLOG, ON_CHECK(check_charset_not_null));

static Sys_var_struct Sys_collation_server(
       "collation_server", "The server default collation",
       SESSION_VAR(collation_server), NO_CMD_LINE,
       offsetof(CHARSET_INFO, name), DEFAULT(&default_charset_info),
       NO_MUTEX_GUARD, IN_BINLOG, ON_CHECK(check_collation_not_null));

通过下面case可以看到通过设置session中不同的character_set_server使创建database的默认charset和collation不同。

> set character_set_server='utf8';

> create database cs_test1;

> select * from SCHEMATA where SCHEMA_NAME='cs_test1';
+--------------+-------------+----------------------------+------------------------+----------+
| CATALOG_NAME | SCHEMA_NAME | DEFAULT_CHARACTER_SET_NAME | DEFAULT_COLLATION_NAME | SQL_PATH |
+--------------+-------------+----------------------------+------------------------+----------+
| def          | cs_test1    | utf8                       | utf8_general_ci        | NULL     |
+--------------+-------------+----------------------------+------------------------+----------+

> set character_set_server='latin1';

> create database cs_test2;

> select * from SCHEMATA where SCHEMA_NAME='cs_test2';
+--------------+-------------+----------------------------+------------------------+----------+
| CATALOG_NAME | SCHEMA_NAME | DEFAULT_CHARACTER_SET_NAME | DEFAULT_COLLATION_NAME | SQL_PATH |
+--------------+-------------+----------------------------+------------------------+----------+
| def          | cs_test2    | latin1                     | latin1_swedish_ci      | NULL     |
+--------------+-------------+----------------------------+------------------------+----------+

character_set_database/collation_database

该变量值session级别表示当前database的charset/collation,在后面的源码版本中该变量可能修正为只读,不建议修改该值。其global级别变量后面也会移除。

> use cs_test1;

> select @@character_set_database;
+--------------------------+
| @@character_set_database |
+--------------------------+
| utf8                     |
+--------------------------+

> use cs_test2;

> select @@character_set_database;
+--------------------------+
| @@character_set_database |
+--------------------------+
| latin1                   |
+--------------------------+

character_set_client

客户端发送到server的字符串使用的字符集,server会按照该变量值来解析客户端发来的语句。如果指定值和语句实际编码字符集不符就会解析出错,报语法错误或者得到非预期结果,例如下面的两个case。

case1:实际使用utf8编码且包含中文字符,但设置character_set_client为latin1。

> set character_set_client='latin1';

> create table 字符集(c1 varchar(10));
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '­—符集(c1 varchar(10))' at line 1

> set character_set_client='utf8';

> create table 字符集(c1 varchar(10));
Query OK, 0 rows affected (0.14 sec)


case2:实际使用utf8编码且包含中文字符,但设置character_set_client为gbk。

> create database cs_test;

> use cs_test;

> set character_set_client='gbk';

> create table 收费(c1 varchar(10));

> show tables;
+-------------------+
| Tables_in_cs_test |
+-------------------+
| 鏀惰垂            |
+-------------------+

> set character_set_client='utf8';

> create table 收费(c1 varchar(10));

> show tables;
+-------------------+
| Tables_in_cs_test |
+-------------------+
| 收费              |
| 鏀惰垂            |
+-------------------+
2 rows in set (0.00 sec)

character_set_connection/collation_connection

没有指定字符集的常量字符串使用时的字符集,例如下面两个case。

case1中当设置为utf8_general_ci比较时候忽略大小写,导致’a’=’A’结果为1,如果设置为utf8_bin不忽略大小写,’a’ = ‘A’的结果就是0。

case2中当设置character_set_connection为’latin1’的时候,’你好’ = ‘我好’返回结果为1,如果设置为’utf8’,返回结果就是0。设置为’latin1’返回结果为1的原因是utf8编码的中文字符是无法转换为latin1字符的。这里MySQL就把’你好’和’我好’都转换成了’??’。

case3中character_set_connection的不同导致create table语句中column的实际default value不同。

case1:设置collation_connection是否忽略大小写导致结果不一致。

> set collation_connection=utf8_general_ci;

> select 'a' = 'A';
+-----------+
| 'a' = 'A' |
+-----------+
|         1 |
+-----------+

> set collation_connection=utf8_bin;

> select 'a' = 'A';
+-----------+
| 'a' = 'A' |
+-----------+
|         0 |
+-----------+

case2:设置character_set_connection不同导致结果不一致。

> set character_set_connection='latin1';
Query OK, 0 rows affected (0.00 sec)

> select '你好' = '我好';
+---------------------+
| '你好' = '我好'     |
+---------------------+
|                   1 |
+---------------------+
1 row in set, 2 warnings (0.00 sec)

> set character_set_connection='utf8';
Query OK, 0 rows affected (0.00 sec)

> select '你好' = '我好';
+---------------------+
| '你好' = '我好'     |
+---------------------+
|                   0 |
+---------------------+


> set character_set_connection='latin1';


> select '你好';
+----+
| ?? |
+----+
| ?? |
+----+

case3:设置character_set_connection导致实际default value不同。

> set character_set_connection='utf8';

> create table cs_t(c1 varchar(10) default '你好')charset=utf8;

> insert into cs_t values();

> select * from cs_t;
+--------+
| c1     |
+--------+
| 你好   |
+--------+

> set character_set_connection='latin1';

> create table cs_t1(c1 varchar(10) default '你好')charset=utf8;

> insert into cs_t1 values();

> select * from cs_t1;
+------+
| c1   |
+------+
| ??   |
+------+

character_set_results

查询结果和错误信息的字符集,server会把返回给客户端的结果转换为对应字符集。例如下面case,当设置character_set_results为’latin1’的时候,会导致返回的中文变成’?’。

> set character_set_results='utf8';

> select '你好';
+--------+
| 你好   |
+--------+
| 你好   |
+--------+

> set character_set_results='latin1';

> select '你好';
+----+
| ?? |
+----+
| ?? |
+----+

> create table cs_test(c1 varchar(10)) charset=utf8;

> insert into cs_test values('你好'),('我好');

> select * from cs_test;
+------+
| c1   |
+------+
| ??   |
| ??   |
+------+


> set character_set_results='utf8';


> select * from cs_test;
+--------+
| c1     |
+--------+
| 你好   |
| 我好   |
+--------+

binlog 中字符集相关缺陷

binlog当前字符集相关实现

对于很多DDL语句,binlog都是直接记录客户端发来的字符串,对于这些语句只要记录语句执行时候的环境变量就可以在备库正确执行。binlog中Query_log_event记录了character_set_client、collation_connection和collation_server,代码如下。记录这三个变量的原因读者可以参考前面各个变量的介绍和case。

int THD::binlog_query(THD::enum_binlog_query_type qtype, const char *query_arg,
                      size_t query_len, bool is_trans, bool direct,
                      bool suppress_use, int errcode)
{
  ...
  case THD::STMT_QUERY_TYPE:
  /*
    The MYSQL_BIN_LOG::write() function will set the STMT_END_F flag and
    flush the pending rows event if necessary.
  */
  {
    Query_log_event qinfo(this, query_arg, query_len, is_trans, direct,
                          suppress_use, errcode);
    /*
      Binlog table maps will be irrelevant after a Query_log_event
      (they are just removed on the slave side) so after the query
      log event is written to the binary log, we pretend that no
      table maps were written.
     */
    int error= mysql_bin_log.write_event(&qinfo);
    binlog_table_maps= 0;
    DBUG_RETURN(error);
  }
  ...
}

Query_log_event::Query_log_event(THD* thd_arg, const char* query_arg,
         size_t query_length, bool using_trans,
         bool immediate, bool suppress_use,
                                 int errcode, bool ignore_cmd_internals)
{
  ...
  int2store(charset, thd_arg->variables.character_set_client->number);
  int2store(charset+2, thd_arg->variables.collation_connection->number);
  int2store(charset+4, thd_arg->variables.collation_server->number);
  ...
}

例如前面创建表cs_t1的case我们可以看到binlog如下。

> set character_set_connection='latin1';
> create table cs_t1(c1 varchar(10) default '你好')charset=utf8;

SET TIMESTAMP=1516089074/*!*/;
/*!\C utf8 *//*!*/;
SET @@session.character_set_client=33,@@session.collation_connection=8,@@session.collation_server=8/*!*/;
create table cs_t1(c1 varchar(10) default '你好')charset=utf8

binlog字符集相关缺陷

对于Query_log_event如果记录的query仅仅是客户端的输入,上面记录字符集变量的方法没有问题。但如果query是server内部生成或者拼接成的,上面直接从thread中获取变量值得方法就可能导致错误。

例如下面的testcase,这里为便于观察和理解case没有使用mysql-test方式,后面有mysql-test。这里主库执行成功,成功创建了表t和视图’收费明细表’,但备库在创建视图的时候却报语法错误。

用gbk编码写如下sql文本
cs_test.sql

use test;
set @@session.character_set_client=gbk;
set @@session.collation_connection=gbk_chinese_ci;
create table t(c1 int);
create view `收费明细表` as select * from t;

在主库执行
> source path/cs_test.sql;

> set character_set_results='gbk';

> use test;

> show tables;
+----------------+
| Tables_in_test |
+----------------+
| 收费明细表             |
| t              |
+----------------+

备库

> show slave status\G
...
Last_SQL_Errno: 1064
Last_SQL_Error: Error 'You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '`閺鎯板瀭閺勫海绮忕悰鈺? AS select * from t' at line 1' on query. Default database: 'test'. Query: 'CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost` SQL SECURITY DEFINER VIEW `鏀惰垂鏄庣粏琛╜ AS select * from t'
...

缺陷分析,MySQL记录create view的binlog代码如下。由前面基础知识可以知道对于db、table这些元数据MySQL会先转换为system_charset_info(utf8)。因此在下面代码中append_identifier添加的table name为utf8编码的’收费明细表’,但是views->source.str又是client端原始的gbk编码方式,binlog_query记录的是thd中的character_set_client。即binlog中的query可能是由system_charset_info和character_set_client两种编码方式组成的字符串,记录的是当前character_set_client的值。

sql/sql_view.cc
bool mysql_create_view(THD *thd, TABLE_LIST *views,
                       enum_view_create_mode mode)
{
  ...
  if (views->db && views->db[0] &&
    (thd->db().str == NULL || strcmp(views->db, thd->db().str)))
  {
    append_identifier(thd, &buff, views->db,
                      views->db_length);
                      buff.append('.');
  }
  append_identifier(thd, &buff, views->table_name,
                    views->table_name_length);
  if (lex->view_list.elements)
  {
    List_iterator_fast<LEX_STRING> names(lex->view_list);
    LEX_STRING *name;
    int i;

    for (i= 0; (name= names++); i++)
    {
      buff.append(i ? ", " : "(");
      append_identifier(thd, &buff, name->str, name->length);
    }
    buff.append(')');
  }
  buff.append(STRING_WITH_LEN(" AS "));
  buff.append(views->source.str, views->source.length);

  int errcode= query_error_code(thd, TRUE);
  thd->add_to_binlog_accessed_dbs(views->db);
  if (thd->binlog_query(THD::STMT_QUERY_TYPE,
                        buff.ptr(), buff.length(), FALSE, FALSE, FALSE, errcode))
    res= TRUE;
  ...
}

在MySQL源码中搜索binlog_query还可以找到多处类似的bug,可参考下面的testcase。

--disable_warnings
--source include/master-slave.inc
--enable_warnings

# case1:创建gbk编码中文名视图

create table t(c1 int);
SET @@session.character_set_client=gbk;
set @@session.collation_connection=gbk_chinese_ci;
set @@session.collation_server=utf8_general_ci;
create view `收费明细` as select * from t;
drop view `收费明细`;
show tables;

--sync_slave_with_master

connection slave;
show tables;


connection master;
drop table t;

# case2:创建gbk编码中文名视图,且view body中包含中文
connection master;
SET @@session.character_set_client=gbk;
create table 视图(c1 int);
create view 视图信息 as select * from 视图;
drop view 视图信息;

# case3: drop table 语句会是generated by server.
drop table 视图;
--sync_slave_with_master

# case4:内存表,重启后再次访问时会生成delete from tableName语句.
connection master;
SET @@session.character_set_client=utf8;
set @@session.collation_connection=utf8_general_ci;
set @@session.collation_server=utf8_general_ci;
create table `收费明细表`(c1 int) engine=memory;
create view tv as select * from `收费明细表`;
--connection slave
-- source include/stop_slave.inc

--let $rpl_server_number= 1
--source include/rpl_restart_server.inc
# access memory table after restarting server cause binlog 'delete from tableName'
connection master;
SET @@session.character_set_client=gbk;
set @@session.collation_connection=gbk_chinese_ci;
set @@session.collation_server=utf8_general_ci;
select * from tv;



--connection slave
-- source include/start_slave.inc
connection master;
--sync_slave_with_master
connection slave;

# case5:character_set_client为gbk时中文名的procedure

connection master;
delimiter $$;
create procedure 收费明细()
begin
  select 'hello world';
end $$
delimiter ;$$
drop procedure `收费明细`;

connection master;
SET @@session.character_set_client=utf8;
set @@session.collation_connection=utf8_general_ci;
set @@session.collation_server=utf8_general_ci;
drop view tv;
drop table `收费明细表`;
--sync_slave_with_master

connection slave;
show tables;


# case6: 不同环境变量下create table like/as 表中有中文default value的

set character_set_client = utf8;
set character_set_connection = utf8;
set character_set_database = utf8;
set character_set_results = utf8;
set character_set_server = utf8;

CREATE TABLE `t1` (
  `id` int(11) NOT NULL,
  `orderType` char(6) NOT NULL DEFAULT '已创建',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

create temporary table `tm` (c1 varchar(10) default '你好');

show create table t1;

## switch client charset
set character_set_client = latin1;
set character_set_connection = latin1;
set collation_server = utf8_bin;
CREATE TABLE t2 SELECT * FROM t1;
create table t3 like tm;
show create table t2;
show create table t3;

--sync_slave_with_master

connection slave;
show tables;
set character_set_client = utf8;
set character_set_connection = utf8;
set character_set_database = utf8;
set character_set_results = utf8;
set character_set_server = utf8;
show create table t1;
show create table t2;
show create table t3;

connection master;
drop table t1;
drop table t2;
drop table t3;

--sync_slave_with_master

--source include/rpl_end.inc

修复方法

对于create view/create procedure等一个query包含两种编码的可以将system_charset_info的部分转换为thread中的character_set_client。这里的转换需要考虑当前thread中character_set_client不支持utf8字符的问题,当转换失败需要报错,否则主备会不一致。

对于完全由server生成的query,例如delete from和drop table语句,其query实际可以理解为system_charset_info,这种语句可以直接使binlog中character_set_client部分记录system_charset_info,而不是thread中的变量值。

该bug在MariaDB中也存在,可以见MDEV-14249,参考链接中的fix diff或者MariaDB的修复。

PgSQL · 应用案例 · 传统分库分表(sharding)的缺陷与破解之法

$
0
0

背景

随着互联网的发展,数据爆炸性的增长,数据库逐渐成为了很多业务的绊脚石,很多业务也哭着喊着要上分布式数据库。

pic

但是,传统的分库分表(sharding)带来的问题较多,得不偿失

传统分库分表问题

1、 扩容不方便(需要重分布数据)

2、 分布键变更很麻烦

3、 分布键选择(架构设计)需要谨慎,甚至很多sharding产品不支持多个分布键、或者不支持随机分布,导致业务不得不使用没有任何意义的自增序列来作为分布键。

4、 无法支持复杂查询。跨库JOIN性能差,甚至只能按分布键JOIN,其他字段不支持JOIN。(因为这种产品架构数据节点之间是孤岛,数据需要在孤岛之间交互,需要通过上层的中间件节点,而这样的话,如果有跨库JOIN,就需要将数据收到中间件节点再JOIN,性能差是可想而知的,甚至打爆中间节点。)

5、 当需要写入、返回大量结果集时,可能把中间件打爆。可能性非常大。

6、 分布式事务性能差,甚至不支持分布式事务。

7、 由于各个数据节点各自为政,实际上这种模式带来的SQL限制多、功能缺失多

8、 SQL功能缺失,导致应用改造成本巨大,(实际上就是限制多)。

9、 全局一致性时间点恢复几乎不可实现,不同的数据节点处于不同的状态,没有一个全局统一的快照管理和恢复机制。

传统分库分表最大的问题实际上还是孤岛问题,导致了一系列的问题。

HybridDB for PG如何破解这些问题

pic

1、实时写入,通过直接写segment(可以做成对业务透明),实现了单机25万行/s的写入能力。通过扩展计算节点,可以扩展整个集群的写入能力。

2、批量导入,通过OSS_EXT,走OSS通道实时写入,我们测试过50台机器的机器,达到了100亿(5.5TB)数据,1251秒导入的性能。

3、全局一致性,HDB PG基于数据库的ACID标准设计,是一个整体,支持全局事务。支持全局一致性。

4、点查,点查能力,每个节点可以实现100万TPS。通过扩展计算节点,可以扩展整个集群的点查TPS能力。

5、复杂查询,核心是MASTER节点的分布式执行计划,MASTER节点收到用户请求后,生成分布式执行计划,并下发给计算节点并行执行。

6、大结果集查询,通过游标,实现大结果集的查询,分页,接收等。

7、UDF,用户可以使用java, python, plpgsql等语言,在HDB PG中实现业务逻辑,实现复杂的查询场景需求。

8、任意列JOIN,由于HDB PG数据节点直接可以重分布数据,不需要走MASTER节点,因此,不需要维表,就可以实现任意列的JOIN,GROUP BY,DISTINCT等。

9、任意distinct,同上。

10、任意group by,同上。

《HybridDB PostgreSQL “Sort、Group、distinct 聚合、JOIN” 不惧怕数据倾斜的黑科技和原理 - 多阶段聚合》

11、MASTER不承担计算,由于MASTER节点不承担计算,所以不会成为计算瓶颈,包括排序在内(MASTER节点采用MERGE SORT,几乎不耗费资源)。

12、机器学习,通过madlib插件,实现了数据库内部的机器学习。

相关资料

http://madlib.incubator.apache.org/

https://pypi.python.org/pypi/pymadlib/0.1.4

https://github.com/pivotalsoftware/PivotalR

https://cran.r-project.org/web/packages/PivotalR/PivotalR.pdf

https://cran.r-project.org/web/packages/PivotalR/vignettes/pivotalr.pdf

13、扩展功能:

资源隔离,通过资源队列,可以管理不同的用户资源使用情况。

HLL,是一个估值插件,可以存储估值数据。

行列混合存储,行列混合存储,支持压缩。

分布键,支持任意键作为分布键,同时支持随机分布,支持多列作为分布键。不需要强制分布键。

分区表,支持多级分区,范围分区,枚举分区。

空间数据,支持PostGIS,可以管理空间数据。

JSON,支持JSON数据类型。

数组,支持多值类型。

全文检索,支持全文检索类型。

正则表达式,支持正则表达式查询语法。

OSS,支持冷热分离存储。

总结

HybridDB for PostgreSQL属于MPP架构,解决了几类问题,实现了HTAP(OLTP和OLAP混合业务):

1、高并发小事务(实时写入、点查),

2、实时复杂大型计算,

3、批处理,

4、冷热数据分离,

5、资源隔离,

6、容量、功能扩展性,

容量水平扩展(支持两种扩容模式,一种原地扩容,一种跨集群扩容),功能(UDF,plpython, pljava, plpgsql,插件)。

7、机器学习。

HybridDB for PG 性能指标

50台机器的集群,一些性能指标如下:

通过增加机器,可以实现线性性能提升。

pic

pic

pic

参考

《HTAP数据库(OLTP+OLAP) - sharding 和 共享分布式存储 数据库架构 优缺点》

MySQL · MyRocks · MyRocks参数介绍

$
0
0

以下参数是db级别的,全局有效

参数
说明
备注
rocksdb_block_cache
缓存uncompressed blocks,此cache有分区优化,分区数由table_cache_numshardbits控制,默认为6即64个分区。 每个分区至少大于512k(rocksdb::LRUCache::LRUCache)
默认为512M
rocksdb_max_total_wal_size
如果WAL超过rocksdb_max_total_wal_size,会swich memtable并flush memtable
默认为0, 表示大小不能超过所有 columnfamily write_buffer的4倍
rocksdb_wal_size_limit_mb
purge wal时最多可以保留wal的最大大小 (对应DBOptions::WAL_size_limit_MB)
默认为0,表示不控制保留wal数量, 只要memtable flush了wal都可以 purge
rocksdb_wal_ttl_seconds
控制purge wal的频率,每隔rocksdb_wal_ttl_seconds/2 purge一次
。如果rocksdb_wal_size_limit_mb > 0, 那么每600s purge一次(kDefaultIntervalToDeleteObsoleteWAL)
默认为0
rocksdb_manual_wal_flush
If true WAL is not flushed automatically after each write. Instead it relies on manual invocation of FlushWAL to write the WAL buffer to its file.
默认为true
rocksdb_deadlock_detect
是否开启死锁检测
默认是关闭的
rocksdb_wal_bytes_per_sync
每rocksdb_wal_bytes_per_sync字节sync一次WAL(WritableFileWriter::Flush)
默认为0, 每次都刷
rocksdb_wal_recovery_mode
重启时recovery模式
1: Fail to start, do not recover
0: If corrupted last entry: truncate and start
2: Truncate everything after corrupted entry
• Even not corrupted entries
• Acceptable on slaves
3: Truncate only corrupted entry • Most dangerous option
rocksdb_strict_collation_exceptions
可以取非memcompare类型collation的表
取值为正则表达式,如"t1,t2*"
rpl_skip_tx_api
Use write batches for replication thread instead of tx api
作用于备库
rocksdb_master_skip_tx_api
Disables Transaction API Enables WriteBatch API, There is no row lock,UPDATE and DELETEs are faster
You must ensure no concurrent operation running
rocksdb_read_free_rpl_tables
用正则表达式指定使用read free replication的库表,如.*或t.*
默认为空
rocksdb_info_log_level
日志级别,数值越小越详细
0:debug_level 1:info_level 2:warn_level 3:error_level 4:fatal_level 5:header_level
rocksdb_perf_context_level
指定 perf context的级别
0,1: disable
2: enable only count stats
3: Other than count stats, also enable time stats except for mutexes
4: enable count and time stats
默认0
rocksdb_max_background_jobs
后台工作线程数
老版本还分为rocksdb_max_background_jobs和max_background_compactions,新版合为一个,会自动分配两者数量。 https://github.com/facebook/rocksdb/wiki/Thread-Pool
rocksdb_commit_in_the_middle
Commit rows implicitly every rocksdb_bulk_load_size, 设置rocksdb_bulk_load为on时自动commit in middle
默认OFF,
不建议全局设置,应回话级别设置
rocksdb_blind_delete_primary_key
通过主键delete 有且仅有主键索引的表时,不需要读取数据,直接通过指定的主键来删除
默认OFF,
DELETES by Primary Key Works:
DELETE FROM t WHERE id IN (1, 2, 3, 4, 5, 6, ...., 10000)
Does not work:
DELETE .. WHERE id < 10
rocksdb_use_direct_reads
use O_DIRECT for reading data
默认OFF
rocksdb_use_direct_io_for_flush_and_compaction
use O_DIRECT for flush and compact
默认OFF
rocksdb_skip_fill_cache
Skip filling block cache on read requests
默认OFF,
DDL load 时使用
gap_lock_raise_error
Using Gap Lock without full unique key in multi-table or multi-statement transactions is not allowed.
违法以上情况使用gap lock会记入错误日志
默认false
gap_lock_write_log
Using Gap Lock without full unique key in multi-table or multi-statement transactions is not allowed.
违法以上情况使用gap lock会记入gap_lock_log_file指定的文件中
默认false
gap_lock_log_file
指定记录gap lock的文件
rocksdb_stats_dump_period_sec

控制Statistic信息记录到LOG中的频率(DBImpl::PrintStatistics)
默认600,
Note that currently it is only dumped after a compaction. So if the database doesn't serve any write for a long time, statistics may not be dumped, despite of options.stats_dump_period_sec.
rocksdb_compaction_readahead_size
If non-zero, we perform bigger reads when doing compaction. If you're running RocksDB on spinning disks, you should set this to at least 2MB. That way RocksDB's compaction is doing sequential instead of random reads.
默认为0
rocksdb_advise_random_on_open
If set true, will hint the underlying file system that the file access pattern is random, when a sst file is opened.
默认ON
rocksdb_max_row_locks
事务最多可以持有锁的个数
默认1M
rocksdb_bytes_per_sync
每rocksdb_wal_bytes_per_sync字节sync一次sst文件(WritableFileWriter::Flush)
默认为0, 每次都刷
You may consider using rate_limiter to regulate write rate to device.
When rate limiter is enabled, it automatically enables bytes_per_sync
to 1MB.
rocksdb_enable_ttl
Enable expired TTL records to be dropped during compaction
默认ON
rocksdb_enable_ttl_read_filtering
For tables with TTL, expired records are skipped/filtered out during processing and in query results. Disabling this will allow these records to be seen, but as a result rows may disappear in the middle of transactions as they are dropped during compaction. Use with caution.
默认ON
rocksdb_bulk_load
bulk_load开关
默认OFF,
rocksdb_bulk_load_allow_unsorted
支持非主键排序数据的bulk_load
默认OFF
rocksdb_bulk_load_size
每rocksdb_bulk_load_size次write进行一次bulk_load
默认1000次
rocksdb_enable_bulk_load_api
Enables using SstFileWriter for bulk loading
默认ON
rocksdb_enable_2pc
是否开启2pc
默认ON
rocksdb_rate_limiter_bytes_per_sec
控制读写sst的速度
DBOptions::rate_limiter bytes_per_sec for RocksDB
默认0
rocksdb_sst_mgr_rate_bytes_per_sec
控制删除sst的速度
DBOptions::sst_file_manager rate_bytes_per_sec for RocksDB
默认0
rocksdb_delayed_write_rate
WriteStall时delay的时间,单位微秒(DBOptions::delayed_write_rate)
默认0
rocksdb_write_disable_wal
是否关闭WAL
默认为OFF
rocksdb_flush_log_at_trx_commit
Sync wal on transaction commit
Similar to innodb_flush_log_at_trx_commit.
1: sync on commit,
0,2: not sync on commit
默认1
rocksdb_cache_index_and_filter_blocks
index和filter blocks是否缓存到block cache
默认ON
rocksdb_pin_l0_filter_and_index_blocks_in_cache
if cache_index_and_filter_blocks is true and the below is true, then filter and index blocks are stored in the cache, but a reference is held in the "table reader" object so the blocks are pinned and only evicted from cache when the table reader is freed.
默认ON

以上参数可以通过show variables查看

更详细可以参考代码 db_options_type_info

include/rocksdb/options.h 

以下参数是column family级别的,可以分别对每个column family设置

参数
说明
备注
write_buffer_size
memtable内存大小
默认
max_write_buffer_number
memtable的最大个数
默认2
min_write_buffer_number_to_merge
it is the minimum number of memtables to be merged before flushing to storage. For example, if this option is set to 2, immutable memtables are only flushed when there are two of them
默认1
target_file_size_base
level1 sst大小
默认64M
target_file_size_multiplier
level L(L>1) sst大小
target_file_size_base * (target_file_size_multiplier ^ (L-1))
默认1,

For example, if target_file_size_base is 2MB and
target_file_size_multiplier is 10, then each file on level-1 will
be 2MB, and each file on level-2 will be 20MB,
and each file on level-3 will be 200MB
max_bytes_for_level_base
level1的sst总大小
默认256M
max_bytes_for_level_multiplier
level L的sst总大小为 max_bytes_for_level_base*(max_bytes_for_level_multiplier)^(L-1))*max_bytes_for_level_multiplier_additional(L-1)
(VersionStorageInfo::CalculateBaseBytes)
默认10
max_bytes_for_level_multiplier_additional
Different max-size multipliers for different levels.

(VersionStorageInfo::CalculateBaseBytes)
默认:1:1:1:1:1:1:1
num_levels
level数量
默认7
level0_file_num_compaction_trigger
当level0文件数量超过此值时触发level0 compact
默认4
level0_slowdown_writes_trigger
当level0文件数量超过此值时触发x写delay
默认20
level0_stop_writes_trigger
当level0文件数量超过此值时触发停写
默认36
pin_l0_filter_and_index_blocks_in_cache
if cache_index_and_filter_blocks is true and the below is true, then filter and index blocks are stored in the cache, but a reference is held in the "table reader" object so the blocks are pinned and only evicted from cache when the table reader is freed.
默认1,
column family单独设置会覆盖rocksdb_pin_l0_filter_and_index_blocks_in_cache
cache_index_and_filter_blocks
index和filter blocks是否缓存到block cache
默认1,
column family单独设置会覆盖rocksdb_cache_index_and_filter_blocks
optimize_filters_for_hits
设置为True, 最后一层不保存filter信息,最后一层bloomfilter实际没有用处
默认OFF
filter_policy
指定filter策略
filter_policy=bloomfilter:10:false
表示使用bloomfilter,
bits_per_key_=10, hash函数个数为10*ln2,
false:use_block_based_builder_=false,表示使用full filter
prefix_extractor
指定filter使用前缀
prefix_extractor=capped:24表示最多取前缀24个字节,另外还有fixed:n方式表示只取前缀n个字节,忽略小于n个字节的key. 具体可参考CappedPrefixTransform,FixedPrefixTransform
partition_filters
表示时否使用partitioned filter
默认false
filter 参数优先级如下 block base > partitioned > full. 比如说同时指定use_block_based_builder_=true和partition_filters=true实际使用的block based filter
whole_key_filtering
If true, place whole keys in the filter (not just prefixes)
默认1
level_compaction_dynamic_level_bytes
In this mode, size target of levels are changed dynamically based on size of the last level.
减少写放大
memtable
指定memtable类型(skiplist/vector/hash_linkedlist/prefix_hash/cuckoo)
默认skiplist
compaction_pri
compact选择文件策略
kByCompensatedSize
Slightly prioritize larger files by size compensated by #deletes
kOldestLargestSeqFirst
First compact files whose data's latest update time is oldest
kOldestSmallestSeqFirst
First compact files whose range hasn't been compacted to the next level for the longest
kMinOverlappingRatio
First compact files whose ratio between overlapping size in next level and its size is the smallest
默认kByCompensatedSize
compression_per_level
指定每个level的压缩策略
It usually makes sense to avoid compressing levels 0 and 1 and to compress data only in higher levels. You can even set slower compression in highest level and faster compression in lower levels (by highest we mean Lmax).
bottommost_compression
指定最底level的压缩策略
arena_block_size
rocksdb内存分配单位KBlockSize由参数arena_block_size指定
arena_block_size不指定时默认为write_buffer_size的1/8.
soft_pending_compaction_bytes_limit
All writes will be slowed down to at least delayed_write_rate if estimated
bytes needed to be compaction exceed this threshold
默认64G
hard_pending_compaction_bytes_limit
All writes are stopped if estimated bytes needed to be compaction exceed this threshold.
默认256G

以上参数可以通过select * from information_schema.rocksdb_cf_options查看

更详细可以参考代码ParseColumnFamilyOption, cf_options_type_info

include/rocksdb/table.h
rocksdb/util/options_helper.h
rocksdb/options/options_helper.cc
include/rocksdb/advanced_options.h

参数配置示例

rocksdb_default_cf_options=memtable=vector;
arena_block_size=10M;
disable_auto_compactions=1;
min_write_buffer_number_to_merge=1;
write_buffer_size=100000m;
target_file_size_base=32m;
max_bytes_for_level_base=512m;
level0_file_num_compaction_trigger=20;
level0_slowdown_writes_trigger=30;
level0_stop_writes_trigger=30;
max_write_buffer_number=5;
compression_per_level=kNoCompression:kNoCompression:kNoCompression:kNoCompression:kNoCompression:kNoCompression;
bottommost_compression=kNoCompression;
block_based_table_factory={cache_index_and_filter_blocks=1;filter_policy=bloomfilter:10:false;whole_key_filtering=1};
level_compaction_dynamic_level_bytes=false;
optimize_filters_for_hits=true

参数修改示例

SET @@global.rocksdb_update_cf_options='cf1={write_buffer_size=8m;target_file_size_base=2m};cf2={write_buffer_size =16m;max_bytes_for_level_multiplier=8};cf3={target_file_size_base=4m};';

注意:此方式可以动态修改,但没有持久化到OPTIONS文件中, 需手动修改OPTIONS文件

PgSQL · 应用案例 · 惊天性能!单RDS PostgreSQL实例支撑 2000亿

$
0
0

背景

20亿用户,每个用户1000个标签,基于任意标签组合圈选、透视(业务上的需求是一次最多计算100个标签的组合)。

相当于要处理2000亿记录。

1、实时求标签组合的记录数。(即满足标签组合的用户有多少)

2、用户ID。(级满足标签组合的用户ID。)

要求实时响应。

通常你肯定会想,这个至少需要上百台机器来支撑。

但是我要给你一个惊喜,这个数据量,一台RDS PG实例即可。怎么做呢?听我道来,用最少的资源解决业务问题,用到RDS PG黑科技。

RDS PG 解决方案

方案如下:

《阿里云RDS PostgreSQL varbitx实践 - 流式标签 (阅后即焚流式批量计算) - 万亿级,任意标签圈人,毫秒响应》

优化方案,提高响应速度

1、bitmap切段

2、计算满足条件的USER COUNT值时,并行计算(使用dblink异步调用)

3、求用户ID时,使用游标,流式返回。

DEMO

1、需要用到的插件

create extension dblink;  
create extension varbitx;  

2、创建标签表,切段,例如20亿个用户,切成400段,每一段5000万个用户BIT。

postgres=# create table t_bitmap (  
  tagid int,   -- 标签ID  
  ofid int,    -- 偏移值, 乘以5000万  
  v varbit     -- userid 比特  
);  
CREATE TABLE  

3、创建索引(约束)

create unique index idx_t_bitmap_1 on t_bitmap (tagid, ofid);  

4、创建1000个标签的BITMAP数据,每一个标签400条,每条的BIT长度为5000万位。

postgres=# do language plpgsql $$  
declare v varbit := repeat('1',5000000)::varbit;  
begin  
  for i in 1..100 loop  
    for x in 0..399 loop  
      insert into t_bitmap values (i, x, v);                            
    end loop;  
  end loop;  
end;  
$$;  
  
  
DO  
Time: 150468.359 ms (02:30.468)  

5、创建生成dblink连接的函数,重复创建不报错。

create or replace function conn(  
  name,   -- dblink名字  
  text    -- 连接串,URL  
) returns void as $$    
declare    
begin    
  perform dblink_connect($1, $2);   
  return;    
exception when others then    
  return;    
end;    
$$ language plpgsql strict;    

6、AND标签组合的并行计算函数(dblink 异步并行),返回USERID透视数。

create or replace function get_bitcount_and(  
  and_tagids int[],   -- 输入标签ID数组  
  v_bit int,          -- 求1或0的比特个数  
  conn text,          -- 连接串
  OUT cnt int8        -- 返回值, 多少个1或0  
) returns setof int8 as $$   
declare  
begin  
for i in 0..399 loop   -- 生成400个链接,因为每行5000万,20亿个BIT,刚好400条。并LOOP  
  perform conn('link'||i,  conn);   -- 连接  
  perform dblink_get_result('link'||i);                        -- 消耗掉上一次异步连接的结果,否则会报错。  
    
  -- 发送异步DBLINK调用  
  -- 每次操作一个bit分段,返回BIT为0或1的位数  
  perform dblink_send_query('link'||i, format('select bit_count(bit_and(v), %s) from t_bitmap where tagid = any (%L) and ofid=%s', v_bit, and_tagids, i));    
end loop;  
  
for i in 0..399 loop  
  -- 返回异步调用结果,包括所有分段  
  return query SELECT * FROM dblink_get_result('link'||i) as t(cnt int8);  
end loop;  
end;  
$$ language plpgsql strict;  

7、OR标签组合的并行计算函数(dblink 异步并行),返回USERID透视数。

create or replace function get_bitcount_or(  
  or_tagids int[],   
  v_bit int,   
  conn text,          -- 连接串
  OUT cnt int8  
) returns setof int8 as $$   
declare  
begin  
for i in 0..399 loop   
  perform conn('link'||i,  conn);   
  perform dblink_get_result('link'||i);  
  perform dblink_send_query('link'||i, format('select bit_count(bit_or(v), %s) from t_bitmap where tagid = any (%L) and ofid=%s', v_bit, or_tagids, i));  
end loop;  
  
for i in 0..399 loop  
  return query SELECT * FROM dblink_get_result('link'||i) as t(cnt int8);  
end loop;  
end;  
$$ language plpgsql strict;  

8、AND,OR 标签组合的并行计算函数(dblink 异步并行),返回USERID透视数。

create or replace function get_bitcount_and_or(  
  and_tagids int[],   
  or_tagids int[],   
  v_bit int,   
  conn text,          -- 连接串
  OUT cnt int8  
) returns setof int8 as $$   
declare  
begin  
for i in 0..399 loop   
  perform conn('link'||i,  conn);   
  perform dblink_get_result('link'||i);  
  perform dblink_send_query('link'||i, format('  
    with t1 as (select bit_and(v) b from t_bitmap where tagid = any (%L) and ofid=%s),   
         t2 as (select bit_or(v) b from t_bitmap where tagid = any (%L) and ofid=%s)   
    select bit_count(bitor(t1.b, t2.b), %s) from t1,t2',   
    and_tagids, i, or_tagids, i, v_bit));  
end loop;  
  
for i in 0..399 loop  
  return query SELECT * FROM dblink_get_result('link'||i) as t(cnt int8);  
end loop;  
end;  
$$ language plpgsql strict;  
-- 更复杂的QUERY,可以自行修改函数。实际业务中这种需求较少。  
-- (a and b andc or d) or (a and c) or (d and not b)..........  

9、计数透视的性能如下,50个标签组合,仅1.5秒,100个标签组合,仅2.6秒:

我们统计2000亿个user_tags组合(每个用户一条记录,每条记录1000个标签时的换算),仅仅需要2.6秒。

一个标签:  
postgres=# select sum(cnt) from (select get_bitcount_and(array_agg(id),1,'dbname=postgres user=postgres') cnt from generate_series(1,1) t(id)) t;  
    sum       
------------  
 2000000000  
(1 row)  
  
Time: 791.392 ms  
  
10个标签组合:  
postgres=# select sum(cnt) from (select get_bitcount_and(array_agg(id),1,'dbname=postgres user=postgres') cnt from generate_series(1,10) t(id)) t;  
    sum       
------------  
 2000000000  
(1 row)  
  
Time: 847.427 ms  
  
50个标签组合:  
postgres=# select sum(cnt) from (select get_bitcount_and(array_agg(id),1,'dbname=postgres user=postgres') cnt from generate_series(1,50) t(id)) t;  
    sum       
------------  
 2000000000  
(1 row)  
  
Time: 1478.847 ms (00:01.479)  
  
100个标签组合:  
postgres=# select sum(cnt) from (select get_bitcount_and(array_agg(id),1,'dbname=postgres user=postgres') cnt from generate_series(1,100) t(id)) t;  
    sum       
------------  
 2000000000  
(1 row)  
  
Time: 2574.761 ms (00:02.575)  

10、AND 、 OR组合性能如下,性能一样:

postgres=# select sum(cnt) from (select get_bitcount_and_or(array_agg(case mod(id,2) when 0 then id end), array_agg(case mod(id,2) when 1 then id end), 1,'dbname=postgres user=postgres') cnt from generate_series(1,1) t(id)) t;  
 sum   
-----  
      
(1 row)  
  
Time: 854.934 ms  
postgres=# select sum(cnt) from (select get_bitcount_and_or(array_agg(case mod(id,2) when 0 then id end), array_agg(case mod(id,2) when 1 then id end), 1,'dbname=postgres user=postgres') cnt from generate_series(1,10) t(id)) t;  
    sum       
------------  
 2000000000  
(1 row)  
  
Time: 889.472 ms  
postgres=# select sum(cnt) from (select get_bitcount_and_or(array_agg(case mod(id,2) when 0 then id end), array_agg(case mod(id,2) when 1 then id end), 1,'dbname=postgres user=postgres') cnt from generate_series(1,50) t(id)) t;  
    sum       
------------  
 2000000000  
(1 row)  
  
Time: 1519.031 ms (00:01.519)  
postgres=# select sum(cnt) from (select get_bitcount_and_or(array_agg(case mod(id,2) when 0 then id end), array_agg(case mod(id,2) when 1 then id end), 1,'dbname=postgres user=postgres') cnt from generate_series(1,100) t(id)) t;  
    sum       
------------  
 2000000000  
(1 row)  
  
Time: 2597.701 ms (00:02.598)  

11、求USERID,AND 函数如下,我们为了达到高速响应,使用游标返回。

create or replace function get_pos_and(  
  and_tagids int[],    -- 标签组合  
  v_bit int            -- 求1或0的BIT位,返回游标,游标包含ofid与位置下标(当然了,这个翻译动作也可以交给程序,那么返回BIT和ofid即可)  
) returns setof refcursor as $$  
declare  
  ref refcursor[];    -- 返回游标数组  
  res refcursor;      -- 返回游标  
  sql text;           -- 游标对应的SQL,即取USERID位置的SQL  
begin  
  for x in 1..400 loop   -- 生成400个游标名  
    ref[x] := 'cur'||x;  
  end loop;  
  
  for i in 0..399 loop   
    -- 使用0到399的偏移值, 乘以5000万系数。  
      
    -- 赋予游标名  
    res := ref[i+1];   
    -- 生成游标对应的动态SQL(ofid, bit位置),注意bit位置可以不翻译,交给程序翻译也没问题。程序翻译的话,翻译好之后,再使用in查询字典  
    -- select uid from uid_mapping where pos in (pos_array);  
    -- 1亿,in 100万, 380毫秒  
    -- [《HTAP数据库 PostgreSQL 场景与性能测试之 25 - (OLTP) IN , EXISTS 查询》](201711/20171107_26.md)    
    sql := format('select %s, bit_posite(bit_and(v), %s, true) from t_bitmap where tagid = any (%L) and ofid=%s', i, v_bit, and_tagids, i);   
    -- 打开游标  
    open res for execute sql ;  
    -- 返回游标  
    return next res;  
  end loop;  
end;  
$$ language plpgsql strict;  

12、求USERID,OR 函数如下,我们为了达到高速响应,使用游标返回。

create or replace function get_pos_or(  
  or_tagids int[],   
  v_bit int   
) returns setof refcursor as $$  
declare  
  ref refcursor[];    
  res refcursor;   
  sql text;  
begin  
  for x in 1..400 loop  
    ref[x] := 'cur'||x;  
  end loop;  
  
  for i in 0..399 loop   
    res := ref[i+1];   
    sql := format('select %s, bit_posite(bit_or(v), %s, true) from t_bitmap where tagid = any (%L) and ofid=%s', i, v_bit, or_tagids, i);  
    open res for execute sql ;  
    return next res;  
  end loop;  
end;  
$$ language plpgsql strict;  

13、求USERID,AND OR 函数如下,我们为了达到高速响应,使用游标返回。

create or replace function get_pos_and_or(  
  and_tagids int[],   
  or_tagids int[],   
  v_bit int   
) returns setof refcursor as $$  
declare  
  ref refcursor[];    
  res refcursor;   
  sql text;  
begin  
  for x in 1..400 loop  
    ref[x] := 'cur'||x;  
  end loop;  
  
  for i in 0..399 loop   
    res := ref[i+1];   
    sql := format('with t1 as  
                   (select bit_and(v) v from t_bitmap where tagid = any (%L) and ofid=%s),  
                   t2 as  
                   (select bit_or(v) v from t_bitmap where tagid = any (%L) and ofid=%s)  
                   select %s, bit_posite(bitor(t1.v, t2.v), %s, true) from t1,t2',   
                  and_tagids, i, or_tagids, i, i, v_bit);  
    open res for execute sql ;  
    return next res;  
  end loop;  
end;  
$$ language plpgsql strict;  

14、求USERID例子,88毫秒响应,极端速度。

postgres=# begin;  
BEGIN  
Time: 0.031 ms  
postgres=# select * from get_pos_and_or(array[1,2,3], array[4,5,6], 1);  
 get_pos_and_or   
----------------  
 cur1  
 cur2  
 cur3  
 cur4  
 cur5  
 cur6  
 cur7  
 ....  
 cur399  
 cur400  
(400 rows)  
  
Time: 88.069 ms  

获取游标值,5000万ID,仅692毫秒:

fetch 1 from cur1;  
Time: 692.408 ms  

15、如果我们把位置翻译放到客户端做,那么只需要获取结果BITMAP,那就更快了,224毫秒就可以获取5000万BIT走。 这块也能做成并发,每个客户端获取不同的ofid。

CREATE OR REPLACE FUNCTION public.get_pos_and(and_tagids integer[])
 RETURNS SETOF refcursor
 LANGUAGE plpgsql
 STRICT
AS $function$
declare
  ref refcursor[];  
  res refcursor; 
  sql text;
begin
  for x in 1..400 loop
    ref[x] := 'cur'||x;
  end loop;

  for i in 0..399 loop 
    res := ref[i+1]; 
    -- sql := format('select %s, bit_posite(bit_and(v), %s, true) from t_bitmap where tagid = any (%L) and ofid=%s', i, v_bit, and_tagids, i);
    sql := format('select %s, bit_and(v) from t_bitmap where tagid = any (%L) and ofid=%s', i, and_tagids, i);
    open res for execute sql ;
    return next res;
  end loop;
end;
$function$;


postgres=# \timing
Timing is on.
postgres=# begin;
BEGIN
Time: 0.045 ms
postgres=# select get_pos_and(array_agg(id)) from generate_series(1,100) t(id);
 get_pos_and 
-------------
 cur1
 cur2
 cur3
...
 cur397
 cur398
 cur399
 cur400
(400 rows)


fetch 1 from cur1;
Time: 224.776 ms

16、如果要求包含某标签,但是不包含某标签的用户,同样使用BIT操作即可。

例子:

包含b1,同时不包含b2的用户

postgres=# select b1 & bitxor(b1,b2) from (values (bit'11001100', bit'11000001')) as t(b1,b2);
 ?column? 
----------
 00001100
(1 row)
  
使用这个方法,新增一个UDF即可。

小结

varbitx是阿里云RDS PG提供的一个插件,使用它,单个RDS PG就可以实现万亿级别USER_TAGS的实时圈选。

使用BITMAP分段、DBLINK异步查询、游标等技术,提高性能。

性能指标:

1、求COUNT,2000亿(20亿用户,100个标签组合)USER_IDS,响应速度2.6秒。

2、求USERID明细,返回5000万用户ID位置,仅692毫秒。

3、求USERID明细,如果只返回BITMAP,5000万个BIT仅需224毫秒。

参考

《阿里云RDS PostgreSQL varbitx实践 - 流式标签 (阅后即焚流式批量计算) - 万亿级,任意标签圈人,毫秒响应》

《阿里云RDS for PostgreSQL varbitx插件与实时画像应用场景介绍》

《基于 阿里云 RDS PostgreSQL 打造实时用户画像推荐系统(varbitx)》

Viewing all 687 articles
Browse latest View live