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

Database · 新特性 · 映射队列

$
0
0

这篇文章主要介绍一个能够高效使用内存并且线程安全的数据结构——映射队列。这个队列是在写并发 B+ 树的时候想出来的。

这个队列最大的特点在于它在不伤害性能的前提下大规模地减少了内存的分配以及释放,从而非常优雅的内存使用

这个队列的建议使用环境

  • 队列元素不适合深拷贝(deep copy),不适合深拷贝的可能原因有,元素占用内存较大,或者元素使用内存不定
  • 程序对内存的使用很敏感

传统线程安全队列存在的问题

不定长队列:不定长队列存在的问题是如果消费者的速度远小于生产者的速度,那么会导致大量的元素堆积,造成大量的内存被浪费。

循环队列:循环队列存在的问题是队列中各个 slot 的元素使用时间是不均衡的,也就是说可能 slot 1 使用的时间比较长,但是 slot 2 可能使用的时间比较短,导致 slot 2 不能被后续线程提前使用。

所以映射队列解决的问题不光是能够更少地使用内存,同时能够让队列中的资源能够更加高效地被利用。一个非常自然的想法是维护一个自己的内存池,每份内存加个标签表示是否正在被使用。也就是说我们插入索引时首先向内存池申请,寻找到一份空闲的内存,然后将要被放入到队列中的元素拷贝到这份内存,最后放入循环队列等待调用。

这个方法我尝试过,但行不通。为什么,因为每个线程执行任务的花费的时间可能差距很大,这会造成内存池的空洞。为什么?因为没有线程能保证先开始的先结束,尤其是在遇到 B+ 树页面分裂的时候,所以我们自己的内存池在使用一段时间后会可能变成下面这个样子。

img

也就是说我们每次获取索引内存时还需要对内存池来一次遍历,这显然行不通。

为了解决以上的问题,映射队列横空出世。

  • 什么是映射队列?映射队列是通过三个指针维护两个映射数组的循环队列,它同时起到了线程安全队列以及内存资源管理器的作用。以下是核心结构:
template<typename Fuck>
class BoundedMappingQueue
{
  private:
    vector<Fuck> elements_;
    vector<int>  avail_;
    vector<int>  work_; 
    int          front_;
    int          avail_back_;
    int          work_back_;
}

elements_:存放实际队列元素

avail_:空闲元素的位置,avail_[0]表示第一个空闲元素在elements_中的位置,也就是说,elements_[avail_[0]]才表示空闲元素的具体位置

work_:使用中元素的位置,和avail_相对应

front_:avail_和work_的指针,avail_[front_]表示下一个空闲元素的位置,work_[front_]表示可以被使用的元素的位置

avail_back_:avail_的指针,avail_[avail_back_]表示最后一个空闲元素的位置

work_back_:work_的指针,work_[work_back_]同样表示可以被使用的元素的位置

  • 这个队列如何工作?

首先,队列会这样被初始化。假设队列有N个元素,则avail_中的元素依次初始化为0,1,……N-1,而work_中的元素都初始化为-1。

当我们需要往队列中插入某个元素时,我们首先从avail_中获取元素的实际位置,然后取得实际元素并进行我们需要的操作(初始化、赋值等等),然后我们更新avail_,将avail_[front_]赋值给work_[front_],之后将avail_[front_]赋值为-1,并且将front_+1。

当我们的线程池中的线程发现work_[work_back_]大于等于0时,则结束等待,获取实际元素,并且记录下此时work_[work_back_]的值,同时将work_[work_back_]赋值为-1,并且work_back_+1,然后进行具体工作的执行。

当工作执行完毕后,利用我们之前记录的work_[work_back_]的值,将其赋值给avail_[avail_back_],然后avail_back_+1。

至此一次完整的放置任务、执行任务的过程就结束了。

这个队列成功地解决了所有在上面被描述过的问题:

  1. 在初始化完毕后不需要任何new和delete
  2. 队列占用内存很少
  3. 不会出现执行任务的线程时间差异而导致的队列内存的空洞
  4. 不损失性能

我们已经介绍完了为什么会有映射队列以及它相对于不定长队列、循环队列的优势。评论里有同学说可以用链表,我发现一个空闲链表加上一个工作链表确实可以发挥映射队列的功能,而且很好理解也很好实现,但是一次完整的操作需要加锁4次,解锁4次,而映射队列只需要3次,所以又发现了一个映射队列的优点。

与传统的队列不同,映射队列有4个API,分成两对。分别是:

template<typename Fuck>
BoundedMappingQueue
{
  public:
    Fuck* Get();

    void Push();

    Fuck* Pop(int *pos);

    void Put(int pos);
};

其中Get和Push由用户调用,Pop和Put由线程池调用。

// 用户
Fuck *shit = queue.Get();
// 对shit进行初始化或赋值
// 初始化或赋值需要快
// 因为此时队列处于加锁状态
queue.Push();

// 线程池
int pos;
Fuck *shit = queue.Pop(&pos);
(*shit)();// 干活
queue.Put(pos);

下面我们具体介绍映射队列的每个API

template<typename Fuck>
Fuck* Get()
{
    mutex_.Lock();
    // 等待空闲的元素
    while (avail_[front_] < 0)
        empty_.Wait(mutex_);
    return queue_[avail_[front_]];
    // 不解锁,因为我们对参数进行初始化
    // 后会立刻调用Push
}

template<typename Fuck>
void Push()
{
    // 将初始化完毕的参数位置传给工作槽
    work_[front_] = avail_[front_];
    // 从空闲槽将元素位置删除
    avail_[front_] = -1;
    if (++front_ == capacity_)
        front_ = 0;

    mutex_.Unlock();
    // 唤醒工作线程
    ready_.Signal();
}
template<typename Fuck>
Fuck* Pop(int *pos)
{
    mutex_.Lock();
    // 等待可以工作的元素
    while (work_[work_back_] < 0)
        ready_.Wait(mutex_);
    
    // 获取可以工作的元素位置并保存于pos
    // 我们需要保存pos因为在完成工作后
    // 需要将元素位置重新放入空闲槽
    *pos = work_[work_back_];

    Fuck *shit = queue_[*pos];
   
    // 从工作槽中将元素位置删除
    work_[work_back_] = -1;
    if (++work_back_ == capacity_)
        work_back_ = 0;

    mutex_.Unlock();
    return shit;
}


template<typename T>
void Put(int pos)
{
    mutex_.Lock();
    // 将空闲元素的位置放入空闲槽
    avail_[avail_back_] = pos;
    if (++avail_back_ == capacity_)
        avail_back_ = 0;

    mutex_.Unlock();
    // 唤醒入列线程
    empty_.Signal();
}

这个队列的源码 https://github.com/UncP/aili,在 blink/mapping_arry.*中。


MySQL · 源码分析 · 子查询优化源码分析

$
0
0

子查询定义

在一个完整的查询语句中包含的子查询块被称为子查询。通常情况下,我们可以将出现在SELECT、WHERE和HAVING语法中的子查询块称为嵌套子查询,出现在FROM语法后的子查询块称为内联视图或派生表。

本篇文章将会结合源码介绍在MySQL中针对子查询的几种优化策略。

子查询在执行计划中的表示

Semijoin/Antijoin

对于表示是否存在语义的查询语句,在语法上表示为IN/=ANY/EXISTS,优化器会尝试转换为semijoin/antijoin进行优化。与普通join会将左表和右表的记录连接在一起不同,semijoin/antijoin仅关心右表中是否存在可以与左表记录连接的记录,而返回左表记录。

在prepare阶段,优化器会首先检查当前查询是否可以转换为semijoin/antijoin的条件(由于antijoin是semijoin的相反,在代码层面也是一块处理的,所以之后的论述以semijoin为主),这部分代码在SELECT_LEX::resolve_subquery中,具体的条件总结如下:

  1. 子查询必须是谓词IN/=ANY/EXISTS的一部分,并且出现在WHERE或ON语法的最高层,可以被包含在AND表达式中。
  2. 必须是单个查询块,不带有UNION。
  3. 不包含HAVING语法。
  4. 不包含任何聚合函数。
  5. 不包含LIMIT语法。
  6. 外查询语句没有使用STRAIGHT_JOIN语法。

如果满足条件,将会把当前谓词加入到外查询的SELECT_LEX::sj_candidates中作为semijon的备选。

由于优化器对查询块的处理是一种递归的方式,在完成对子查询的判断之后,在外层查询的prepare阶段,会调用SELECT_LEX::flatten_subqueries函数完成子查询到semijoin的最终转换,这个过程在整个查询的生命周期只会发生一次,且不可逆。在SQL语法上等价为:

从一个带有备选semijoin子查询判断条件的查询块:
    SELECT ...
    FROM ot, ...
    WHERE oe IN (SELECT ie FROM it1 ... itN WHERE subq_where) AND outer_where
转换为:
    SELECT ...
    FROM ot SEMI JOIN (it1 ... itN), ...
    WHERE outer_where AND subq_where AND oe=ie

为了实现上述过程,需要进行以下步骤:

  1. 创建SEMI JOIN (it1 ... itN)语以部分,并加入到外层查询块的执行计划中。
  2. 将子查询的WHERE条件以及JOIN条件,加入到父查询的WHERE条件中。
  3. 将子查询谓词从父查询的判断谓词中消除。

具体的伪代码如下:

SELECT_LEX::flatten_subqueries()
     /* Semijoin flattening is bottom-up. Indeed, we have this execution flow,
        for SELECT#1 WHERE X IN (SELECT #2 WHERE Y IN (SELECT#3)) :

        SELECT_LEX::prepare() (select#1)
           -> fix_fields() on IN condition
               -> SELECT_LEX::prepare() on subquery (select#2)
                   -> fix_fields() on IN condition
                        -> SELECT_LEX::prepare() on subquery (select#3)
                        <- SELECT_LEX::prepare()
                   <- fix_fields()
                   -> flatten_subqueries: merge #3 in #2
                   <- flatten_subqueries
               <- SELECT_LEX::prepare()
           <- fix_fields()
           -> flatten_subqueries: merge #2 in #1

        Note that flattening of #(N) is done by its parent JOIN#(N-1), because
        there are cases where flattening is not possible and only the parent can
        know.*/
   |--子查询层层嵌套中采用bottom-up的方式去展开。在fix_fields()的过程中依次从里往外。仅支持IN和EXISTS的子查询,且内层的sj_candidates为空。
   |--由于在WHERE条件同一层可能存在多个可以展开的子查询判断,首先会计算优先级来决定semijoin展开顺序:
      1. 依赖外层查询的子查询优先于不相关子查询。
      2. 有着更多表的子查询优先于更少表的子查询。
      3. 顺序上先计算的子查询优先于后计算的。
   |--semijoin子查询不能和antijoin子查询相互嵌套。
   |--判断子查询的WHERE条件是否为常量。
      如果判断条件永远为FALSE,那么子查询结果永远为空。该情况下,可以将子查询直接清除,不用转换成semijoin。
   |--替换外层查询的WHERE条件中子查询判断的条件
      1. 子查询内条件并不永远为FALSE,或者永远为FALSE的情况下,需要改写为antijoin(antijoin情况下,子查询结果永远为空,外层查询条件永远通过)。
         此时将条件改为永远为True。
      2. 子查询永远为FALSE,且不是antijoin。那么将外层查询中的条件改成永远为False。
      /* 子查询判断条件可能为IN/=ANY/EXISTS,或者对应的否定。参数为Item_exists_subselect *。
         The following transformations are performed:

         1. IN/=ANY predicates on the form:

         SELECT ...
         FROM ot1 ... otN
         WHERE (oe1, ... oeM) IN (SELECT ie1, ..., ieM
                                  FROM it1 ... itK
                                 [WHERE inner-cond])
          [AND outer-cond]
         [GROUP BY ...] [HAVING ...] [ORDER BY ...]

         are transformed into:

         SELECT ...
         FROM (ot1 ... otN) SJ (it1 ... itK)
                           ON (oe1, ... oeM) = (ie1, ..., ieM)
                              [AND inner-cond]
         [WHERE outer-cond]
         [GROUP BY ...] [HAVING ...] [ORDER BY ...]

         Notice that the inner-cond may contain correlated and non-correlated
         expressions. Further transformations will analyze and break up such
         expressions.

         2. EXISTS predicates on the form:

         SELECT ...
         FROM ot1 ... otN
         WHERE EXISTS (SELECT expressions
                       FROM it1 ... itK
                       [WHERE inner-cond])
          [AND outer-cond]
         [GROUP BY ...] [HAVING ...] [ORDER BY ...]

         are transformed into:

         SELECT ...
         FROM (ot1 ... otN) SJ (it1 ... itK)
                            [ON inner-cond]
         [WHERE outer-cond]
         [GROUP BY ...] [HAVING ...] [ORDER BY ...]

         3. Negated EXISTS predicates on the form:

         SELECT ...
         FROM ot1 ... otN
         WHERE NOT EXISTS (SELECT expressions
                       FROM it1 ... itK
                       [WHERE inner-cond])
          [AND outer-cond]
         [GROUP BY ...] [HAVING ...] [ORDER BY ...]

         are transformed into:

         SELECT ...
         FROM (ot1 ... otN) AJ (it1 ... itK)
                            [ON inner-cond]
         [WHERE outer-cond AND is-null-cond(it1)]
         [GROUP BY ...] [HAVING ...] [ORDER BY ...]

         where AJ means "antijoin" and is like a LEFT JOIN; and is-null-cond is
         false if the row of it1 is "found" and "not_null_compl" (i.e. matches
         inner-cond).

         4. Negated IN predicates on the form:

         SELECT ...
         FROM ot1 ... otN
         WHERE (oe1, ... oeM) NOT IN (SELECT ie1, ..., ieM
                                  FROM it1 ... itK
                                  [WHERE inner-cond])
         [AND outer-cond]
         [GROUP BY ...] [HAVING ...] [ORDER BY ...]

         are transformed into:

         SELECT ...
         FROM (ot1 ... otN) AJ (it1 ... itK)
                            ON (oe1, ... oeM) = (ie1, ..., ieM)
                            [AND inner-cond]
         [WHERE outer-cond]
         [GROUP BY ...] [HAVING ...] [ORDER BY ...]

         5. The cases 1/2 (respectively 3/4) above also apply when the predicate is
         decorated with IS TRUE or IS NOT FALSE (respectively IS NOT TRUE or IS FALSE).*/
   |--SELECT_LEX::convert_subquery_to_semijoin() // 将当前查询块中包含的子查询判断转换成TABLE_LIST中的semijoin嵌套,antijoin也在里面完成。
     |--生成一个新的semijoin嵌套的TABLE_LIST表
     |--TABLE_LIST::merge_underlying_tables() // 将子查询中潜在的表合并到上述join表中
     |--将子查询的叶子表插入到当前查询块的叶子表后面,重新设置子查询的叶子表的序号和依赖的外表。将子查询的叶子表重置。
     |--如果是outer join的话,在join链表中传递可空性。
     |--SELECT_LEX::decorrelate_condition()
       |--将内层子查询中的关联条件去关联化,这些条件被加入到semijoin的列表里。这些条件必须是确定的,仅支持简单判断条件或者由简单判断条件组成的AND条件。
       |--decorrelate_equality()
         |--判断左右条件是否仅依赖于内外层表,将其表达式分别加入到semijoin内外表的表达式列表中。
     |--decorrelate_join_conds() // 解关联内层查询的join条件
     |--Item_cond_and::fix_after_pullout() // 将子查询的WHERE条件上拉,更新使用表的信息
     |--SELECT_LEX::build_sj_cond() // 根据semijoin的条件列表创建AND条件,如果有条件为常量True,则去除该条件;如果常量为False,则整个条件都去除。
     |--将创建出来的semijoin条件加入到外层查询的WHERE条件中

物化执行 or 迭代式循环执行

对于不能采用semijoin/antijoin执行的存在式语义的子查询,在MySQL源码的表示含义下,会做IN->EXISTS的转换,其实本质是在物化执行和迭代式循环执行中做选择。IN语法代表非相关子查询仅执行一次,将查询结果物化成临时表,之后需要结果时候就去物化表中查找;EXISTS代表对于外表的每一条记录,子查询都会执行一次,是迭代式循环执行。

MySQL会在prepare阶段尝试做IN->EXISTS的转换,然后在optimize阶段,比较IN or EXISTS执行的代价,最后根据代价决定采用哪种执行策略完成最终转换。

在prepare阶段IN->EXISTS的转换主要是将IN语法的左表达式与右表达式中子查询的输出列对应组合,加入到子查询的WHERE或者HAVING条件中,在SQL语义上表示为:

outer_expr IN (SELECT inner_expr FROM ... WHERE subquery_where)
转换为:
EXISTS (SELECT 1 FROM ... WHERE subquery_where AND outer_expr=inner_expr)

这一过程主要发生在Item_in_subselect::single_value_in_to_exists_transformer中,详细过程为:

/* 通过判断条件注入将IN语法转换为EXISTS语法
   向子查询中注入额外的判断条件,并将子查询标记为关联子查询。*/
|--Item_in_subselect::single_value_in_to_exists_transformer()
  |--如果子查询包含聚合函数、窗口函数、GROUP语法、HAVING语法,将判断条件加入到HAVING语法中。
  |--如果我们想区分NULL和False的结果的话,将这个条件封装到触发器中。
     SELECT ie FROM ... HAVING subq_having AND
               trigcond(oe $cmp$ ref_or_null_helper<ie>)
  |--创建指向子查询唯一列的Item_ref_null_helper对象,与之前注入的左表达式Item_ref共同创建比较表达式
  |--如果子查询的第一个列为包含聚合列的表达式,那么WHERE和HAVING语法中可能通过不同的Item_ref引用到这个Item,存入到Item_sum::ref_by数组中
  |--and_items() // 加入到HAVING条件中
|--如果不包含聚合函数、窗口函数、GROUP语法、HAVING语法,将判断条件加入WHERE语句中
  |--如果不需要区分NULL与False的结果:
     SELECT 1 FROM ... WHERE (oe $cmp$ ie) AND subq_where
  |--如果需要区分上述结果的差别,使用触发器
     SELECT 1 FROM ...
     WHERE subq_where AND trigcond((oe $cmp$ ie) OR (ie IS NULL))
           HAVING trigcond(@<is_not_null_test@>(ie))
  |--其他,单个查询块,没有表及上述语法,直接用条件表达式在外查询中替代

总结

以上就是MySQL中针对子查询所做的大部分优化和转换的工作,代码分析基于MySQL 8.0.19版本。

参考:https://dev.mysql.com/doc/refman/8.0/en/subquery-optimization.html

MySQL · 源码分析 · undo tablespace 的发展

$
0
0

首先我们介绍一下mysql5.6 undo tablespace独立表空间的内容,然后我们看一下后续5.6 8.0,mysql对独立表空间做出了那些修改。

mysql5.6

从mysql5.6开始支持把undo log分开配置到独立的表空间,并放到单独的文件中;这给我们带来很多便利,对于该并发的写入,我们可以把undo文件单独部署到高速存储设备上。

参数

1,innodb_undo_tablespaces

用于设定undo独立表空间的个数,在install db时初始化并创建,之后便不能修改。

默认值为0,表示不独立设置undo tablespace,默认记录到ibdata中;否则创建多个undo文件;加入设定值为8,那么就会创建命名为undo001~undo08的undo tablespace文件,每个文件大小10M。

2,innodb_undo_logs

用于设定回滚段的个数;该变量动态可调,但是物理上的回滚段不会减少,只是控制当前使用回滚段的个数;默认值128。

3,innodb_undo_directory

当我们开启独立undo tablespace 独立表空间时,这个用来设定存放undo文件的目录。

相关实现

在inndo启动时(innobase_start_or_create_for_mysql),会调用srv_undo_tablespaces_init来对undo表空间进行初始化。具体流程如下:

  1. 如果是新实例,并且打开了独立表空间;则会去创建undo log文件,表空间的space id从1开始,文件大小默认10M,由SRV_UNDO_TABLESPACE_SIZE_IN_PAGES来控制;并记录space id。

  2. 如果不是新实例,则读取当前实例所有undo表空间的space id(trx_rseg_get_n_undo_tablespaces) trx_rseg_get_n_undo_tablespaces 函数会首先从ibdata中读取事务系统的文件头,然后从中拿到回滚段的信息;最后找到回滚段对应的space id(trx_sysf_rseg_get_space)和page no(trx_sysf_rseg_get_page_no),最后按照space id排序返回。
  3. 按照上面两步拿到的space id依次打开undo 文件(srv_undo_tablespace_open),并且space id要保证连续;如果space id不连续或者打开undo失败,则实例启动/初始化失败。
  4. 最后,如果是新实例,则对所有space header进行初始化(fsp_header_init).

mysql5.7

mysql5.7对于undo tablespace独立表空间的改动不大;只增加了一个功能,在线truncate undo log文件。具体参数可查看官方文档:innodb_undo_log_truncate,官方博客对online-truncate-of-innodb-undo-tablespaces的介绍。

这个功能在生产环境的某些情况下比较有用,尤其是如果有长时间未提交事务导致浪费大量undo空间的情形。

相关实现

  1. 新的truncate管理类。
  • 新的类undo::Trunc被引入,来管理tablespace truncate的过程;具体挂载在purge_sys->undo_trunc中。
  1. 标记需要truncate的undo tablespace。

    • 这个动作实际上是由purge的协调线程发起的,默认情况下每做128次purge后,会调用函数trx_purge_truncate进行清理,trx_purge_truncate的调用流程如下:

      trx_purge_truncate()
      |
      ->trx_purge_truncate_history()
      	|
      	->trx_purge_truncate_rseg_history
      		|
      		->trx_purge_mark_undo_for_truncate()
      		|
      		->trx_purge_initiate_truncate()
      
    • trx_purge_mark_undo_for_truncate 是标记truncate undo tablespace的入口函数,主要步骤如下。

      1. 检查是否开启truncate参数,已经有tablespace标记为truncate。
      2. 检查是否可以进行安全的truncate,也就是innodb_undo_tablespaces>=2, innodb_undo_logs>=35。
      3. 一次遍历当前活跃的undo tablespace,看看那些tablespace可以被truncate。
      4. 遍历被选中的回滚段,将其设置为不可分配。
    • 在标记truncate完成后,需要检查需要删除的回滚段是否是可释放的,也就是没有任何活跃的事务会应用到启动的undo log,入口函数为trx_purge_initiate_truncate,此函数的流程如下:

      1. 检查是否有undo tablespace标记需要truncate。
      2. 扫描所有需要truncate回滚段,不可以有任何活跃事务使用其中undo。
      3. 做一次redo checkpoint。
      4. 清理对应的purge queue,无需继续做purge操作。
      5. 调用trx_undo_truncate_tablespace执行真正的truncate。
      6. 再做一次redo checkpoint,然后做一些清理操作即可完成。

mysql8.0

mysql8.0对undo tablespace做了进一步的优化;不仅仅支持更多的回滚段,而且还可以动态的增删undo tablespace。具体可查看mysql8.0 undo tablespace 官方文档,以及 官方博客 More Flexible Undo Tablespace Management, 官方博客 CREATE UNDO TABLESPACE

SQL语句

  1. 在安装实例时,会默认创建两个undo tablespace,可以使用如下语句查看:
SELECT * FROM  INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE ROW_FORMAT = 'Undo';

SHOW GLOBAL STATUS LIKE '%UNDO_TABLESPACE%';
  1. 可以通过如下语句来创建undo tablespace, 文件后缀必须以ibu结尾,新创建的tablespace为active状态,在创建undo tablespace时,可以使用绝对路径,也可以放在实例配置配置的undo目录下。
CREATE UNDO TABLESPACE myundo ADD DATAFILE 'myundo.ibu';
  1. 如果不想使用某个undo tablespace了,可以将其设置为inactive状态,但需要保证至少有两个active的undo tablespace;这个原因是,当有一个tablespace被truncate时,还有一个tablespace可用。当被设置了inactive后事务就不会从中分配回滚段。
ALTER UNDO TABLESPACE myundo SET INACTIVE;
  1. 在删除一个undo tablespace之前,需要先将其设置为inactive。
DROP UNDO TABLESPACE myundo;

具体实现

  1. undo tablespace的创建。

    1. server层接口类:Sql_cmd_create_undo_tablespace
    2. 为undo tablespace预留的space id。
      1. s_min_undo_space_id = 0xFFFFFFF0UL - 127 * 512
      2. s_max_undo_space_id = 0xFFFFFFF0UL - 1
    3. innodb_create_undo_tablespace为创建undo tablespace的函数,具体流程如下:
      1. 先调用undo::get_next_available_space_num()分配一个空闲可用的space id。
      2. 调用srv_undo_tablespace_create创建undo tablespace。
      3. 提交变更,并把此tablespace设置为active状态。
  2. undo tablespace的修改

    1. server层接口类:Sql_cmd_alter_undo_tablespace

    2. 在崩溃恢复dd后,需要调用apply_dd_undo_state将undo tablespace状态更新到内存。

    3. innodb的接口是innodb_alter_undo_tablespace

      innodb_alter_undo_tablespace()
      |
      ->innodb_alter_undo_tablespace_active()
      |
      ->innodb_alter_undo_tablespace_inactive()
      
      1. innodb_alter_undo_tablespace_active流程:
        1. 设置dd为active。
        2. 调用undo space的alter_active,如果当前tablespace没有被标记为trcaunte,则把回滚段设置为active。
      2. innodb_alter_undo_tablespace_inactive流程:
        1. 如果undo space为空,直接返回。
        2. 判断除此tablespace之外,还有没有两个活跃的tablespace,如果没有报错返回。
        3. 设置dd为inactive。
        4. 设置truncate frequency为1并唤醒purge线程, 这样purge线程会更频繁的去做purge操作,加快undo space的回收。
  3. undo tablespace的删除

    1. Server层接口类:Sql_cmd_drop_undo_tablespace
    2. 入口函数:innodb_drop_undo_tablespace
      1. 首先判断此tablespace是否可见,以及是否是undo tablespace。
      2. 如果当前tablepace格式小于2,活跃,或者正在truncate,都不能进行删除。
      3. invalidate buffer pool中该tablespace的page
      4. 写一条删除的ddl log,来删除这个tablespace。
  4. undo tablespace的truncate

    1. 由purge线程发起,入口函数:trx_purge_truncate_marked_undo()

    2. 具体流程如下:

      1. 获取MDL锁,防止space被alter/drop。
      2. 调用trx_purge_truncate_marked_undo_low()来truncate,其流程如下:
        1. 调用trx_undo_truncate_tablespace()来truncate,其流程如下:
          1. 先用 undo::use_next_space_id来获取一个新的space id。
          2. 调用fil_replace_tablespace,用新的space id来替换掉旧的space id。fil_replace_tablespace流程如下:
            1. 先调用fil_delete_tablespace删除旧的tablespace。
            2. 然后用之前的file_name创建新的文件。
            3. 然后调用fil_space_create 创建新的tablespace, 并且把新tablespace 与文件联系起来。
          3. 重新初始化回滚段与内存信息。
      3. 释放MDL锁,完成操作。

      这里使用新的space id的原因是删除重建的过程中没有做checkpoint,这时如果crash,那么可能就会存在redo修改已经不存在的page。

小结

InndoDB的undo log从5.6开始可以存储到单独的tablespace文件中。到5.7版本支持了在线undo文件truncate,解决了undo膨胀问题。而到了8.0,对undo tablespace做了进一步的优化,每个undo tablespace可以有128个回滚段,以此来减少事务使用回滚段时的锁冲突;可以在线动态增删undo tablespace,使得undo tablespace管理更加灵活。总体来看undo tablespace是朝着更加灵活的方向发展,以后会慢慢废弃掉通过配置文件配置的方式。

MySQL · 最佳实践 · How to read the lock information from debugger

$
0
0

MySQL version (8.0)

It’s quite common for database kernel engineers to debug the lock related issue. Hence it’s import for us to understand the lock information from debugger.

Basic Data Structure

There are two major data structures we need to know. One is lock_t, another one is trx_t.

The main structure of lock_t is as follow:

/** Lock struct; protected by lock_sys->mutex */
struct lock_t {

  /** True if the lock has been removed from transaction's lock
  list. For example, while the transaction is releasing lock, the
  background purge thread or someother thread may move the lock
  object to some otherplace by delete + insert. This may happen
  especially when we partitioned the lock system  */    
  bool discard;

  /** transaction owning the lock */    
  trx_t *trx;

  /** list of the locks of the transaction */    
  UT_LIST_NODE_T(lock_t) trx_locks;

  /** Index for a record lock */    
  dict_index_t *index;

  /** Hash chain node for a record lock. The link node in a singly
  linked list, used by the hash table. */    
  lock_t *hash;

  union {    
    /** Table lock */    
    lock_table_t tab_lock;    
    /** Record lock */    
    lock_rec_t rec_lock;    

  }; 
  
  /** The lock type and mode bit flags.
  LOCK_GAP or LOCK_REC_NOT_GAP, LOCK_INSERT_INTENTION, wait flag, ORed */    
  uint32_t type_mode;    

  /** Timestamp when it was created. */    
  uint64_t m_seq;    
}

The main strucutre of trx_t is as follow:

struct trx_t {    
  ib_uint32_t in_depth;     /*!< Track nested TrxInInnoDB count */    
  ib_uint32_t in_innodb;     /*!< if the thread is executing in the InnoDB context count > 0. */    
  bool abort;      /*!< if this flag is set then this transaction must abort when it can */    
  trx_id_t id;      /*!< transaction id */    
  trx_id_t no;     /*!< transaction serialization number: max trx id shortly before the transaction is moved to    
               COMMITTED_IN_MEMORY state. Protected by trx_sys_t::mutex when trx->in_rw_trx_list. Initially set to TRX_ID_MAX. */
  trx_state_t state;    
  trx_lock_t lock;    /*!< Information about the transaction locks and state. Protected by trx->mutex or lock_sys->mutex    
  lock_pool_t rec_pool;     /*!< Pre-allocated record locks */    
  lock_pool_t table_pool;     /*!< Pre-allocated table locks */    
  ulint rec_cached;   /*!< Next free rec lock in pool */    
  ulint table_cached;   /*!< Next free table lock in pool */ 
  ...... 
} 

As we can see, lock_t->trx will point to the corresponding transaciton. On the other hand, trx_t->rec_pool/table_pool will point back to the lock information.

Real Lif Examples

Here is an live example of a lock from debugger LR 3

(gdb) p *(ib_lock_t *)0x7f66462e4d60 
$8 = {trx = 0x7f6653f51ac0, trx_locks = {prev = 0x7f66462e4c18, next = 0x0}, 
  index = 0x7f665da63b08,    
  hash = 0x0,    
  un_member = {tab_lock = {table = 0xb00000000, locks = {prev = 0x100, next = 0x2300002000}},    
  rec_lock = {space = 0, page_no = 11,n_bits = 256}},     
  type_mode = 291}    

First of all, type_mode = 291 = 256 (LOCK_WAIT) + 32 (LOCK_REC) + 3(LOCK_X). This means we are waiting for Exclusive Record Lock.

We then check the corresponding transaction information.

(gdb) p *((ib_lock_t *)0x7f66462e4018)->trx    
$9 = {mutex = {m_impl = {m_lock_word = 0, m_waiters = 0, m_event = 0x7f6653b43798, m_policy = {m_count = {m_spins = 0, m_waits = 0,
m_calls = 0, m_enabled = false},    
m_id = LATCH_ID_TRX}},    
sm_ptr = 0x7f6644c585c0},    
in_depth = 0,    
in_innodb = 536870912,     
abort = false,    
id = 236410,    
no = 18446744073709551615,    
state = TRX_STATE_ACTIVE,    
skip_lock_inheritance = false,    
read_view = 0x0,    
trx_list = {prev = 0x7f6653f51ac0, next = 0x0},    
no_list = {prev = 0x0, next = 0x0},    
lock = {n_active_thrs = 0,    
que_state = TRX_QUE_RUNNING, wait_lock = 0x0, deadlock_mark = 0, was_chosen_as_deadlock_victim = false, wait_started = 0,
wait_thr = 0x0,    
rec_pool = std::vector of length 8, capacity 8 = {0x7f66462e4018, 0x7f66462e4160, 0x7f66462e42a8,
0x7f66462e43f0, 0x7f66462e4538, 0x7f66462e4680, 0x7f66462e47c8, 0x7f66462e4910},     
table_pool = std::vector of length 8,    
capacity 8 = {0x7f664003ae18, 0x7f664003ae60, 0x7f664003aea8, 0x7f664003aef0,
0x7f664003af38, 0x7f664003af80, 0x7f664003afc8, 0x7f664003b010},    
rec_cached = 1,    
table_cached = 6,    
dict_operation = TRX_DICT_OP_TABLE,    

We can see this transaciton have 1 rec lock (rec_cached) and 6 table locks (table_cached). The detail informaiton of record locks can be foudn in rec_pool and table locks information can be then found in table_pool. From dict_operation, we can see we are dropping table. We need to lock a bunch of system catalog tables. By using information from table_pool, we can find the corresponding system tables are: SYS_TABLES, SYS_COLUMNS, SYS_INDEXES, SYS_FIELDS, SYS_FIELDS, SYS_TABLESPACES, SYS_DATAFILES. By checking the informaiton from rec_pool, we can find the space id is 0, page number is 11.
rec_lock = {space = 0, page_no = 11, n_bits = 256}}

Hence the above debugger information can be tranlated into human readable information: When we drop table, we hold a bunch of table locks on system catalog tables. For each system catalog table, we then need to hold a record lock to do real update. In this case, we are waiting on a rec lock to update SYS_INDEXES’s root page.

We can actually read even more from it. But that will be covered next time. :-)

MySQL · 源码分析 · MySQL Statement Digest

$
0
0

什么是statement digest

在MySQL中,performance_schema库中存储server执行过程中各种”event”相关的数据,通过这些数据,可以从多维度分析数据库的性能,比如SQL执行,文件I/O,锁等待等。

一些数据表会存储执行过的SQL语句和其digest,比如表events_statements_summary_by_digest中的第二个和第三个列,digest其实是一个MD5 hash值,接下来简单介绍下digest的生成过程。

MySQL [test]> describe performance_schema.events_statements_summary_by_digest;
+-----------------------------+---------------------+------+-----+---------------------+-------+
| Field                       | Type                | Null | Key | Default             | Extra |
+-----------------------------+---------------------+------+-----+---------------------+-------+
| SCHEMA_NAME                 | varchar(64)         | YES  |     | NULL                |       |
| DIGEST                      | varchar(32)         | YES  |     | NULL                |       |
| DIGEST_TEXT                 | longtext            | YES  |     | NULL                |       |
| COUNT_STAR                  | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_TIMER_WAIT              | bigint(20) unsigned | NO   |     | NULL                |       |
| MIN_TIMER_WAIT              | bigint(20) unsigned | NO   |     | NULL                |       |
| AVG_TIMER_WAIT              | bigint(20) unsigned | NO   |     | NULL                |       |
| MAX_TIMER_WAIT              | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_LOCK_TIME               | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_ERRORS                  | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_WARNINGS                | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_ROWS_AFFECTED           | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_ROWS_SENT               | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_ROWS_EXAMINED           | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_CREATED_TMP_DISK_TABLES | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_CREATED_TMP_TABLES      | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SELECT_FULL_JOIN        | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SELECT_FULL_RANGE_JOIN  | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SELECT_RANGE            | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SELECT_RANGE_CHECK      | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SELECT_SCAN             | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SORT_MERGE_PASSES       | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SORT_RANGE              | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SORT_ROWS               | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_SORT_SCAN               | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_NO_INDEX_USED           | bigint(20) unsigned | NO   |     | NULL                |       |
| SUM_NO_GOOD_INDEX_USED      | bigint(20) unsigned | NO   |     | NULL                |       |
| FIRST_SEEN                  | timestamp           | NO   |     | 0000-00-00 00:00:00 |       |
| LAST_SEEN                   | timestamp           | NO   |     | 0000-00-00 00:00:00 |       |
+-----------------------------+---------------------+------+-----+---------------------+-------+

statement digest如何计算

digest是基于一串字节文本做MD5计算出来的hash值,这个字节文本在parser解析SQL时根据识别出来的token和identifier构造。下面以MySQL 5.7的代码为例,简单介绍digest的生成过程。

当一个token被识别时,调用store_token()构造字节文本,

File sql/sql_digest.cc

 71 /**
 72   Store a single token in token array.
 73 */
 74 inline void store_token(sql_digest_storage* digest_storage, uint token)
 75 {
 76   DBUG_ASSERT(digest_storage->m_byte_count <= digest_storage->m_token_array_length);
 77 
 78   if (digest_storage->m_byte_count + SIZE_OF_A_TOKEN <= digest_storage->m_token_array_length)
 79   {
 80     unsigned char* dest= & digest_storage->m_token_array[digest_storage->m_byte_count];
 81     dest[0]= token & 0xff;
 82     dest[1]= (token >> 8) & 0xff;
 83     digest_storage->m_byte_count+= SIZE_OF_A_TOKEN;
 84   }
 85   else
 86   {
 87     digest_storage->m_full= true;
 88   }
 89 }
 90 

当一个identifier被识别时,调用store_token_identifier(),传入token值,identifier name以及其长度,根据一定的规则构造字节文本,并append到之前构造的文本后面。

File sql/sql_digest.cc

135 inline void store_token_identifier(sql_digest_storage* digest_storage,
136                                    uint token,
137                                    size_t id_length, const char *id_name)
138 {
139   DBUG_ASSERT(digest_storage->m_byte_count <= digest_storage->m_token_array_length);
140 
141   size_t bytes_needed= 2 * SIZE_OF_A_TOKEN + id_length;
142   if (digest_storage->m_byte_count + bytes_needed <= (unsigned int)digest_storage->m_token_array_length)
143   {
144     unsigned char* dest= & digest_storage->m_token_array[digest_storage->m_byte_count];
145     /* Write the token */
146     dest[0]= token & 0xff;
147     dest[1]= (token >> 8) & 0xff;
148     /* Write the string length */
149     dest[2]= id_length & 0xff;
150     dest[3]= (id_length >> 8) & 0xff;
151     /* Write the string data */
152     if (id_length > 0)
153       memcpy((char *)(dest + 4), id_name, id_length);
154     digest_storage->m_byte_count+= bytes_needed;
155   }
156   else
157   {
158     digest_storage->m_full= true;
159   }
160 }

可以看到,

  • 前两个字节,函数中的dest[0] / dest[1] ,根据token值计算而来;
  • 第三和第四个字节,dest[2] / dest[3],根据id_length计算而来;
  • 之后的地址存放id_name对应的文本。

store_token()store_token_identifier()可以被调用多次,从而把不断识别出的token和identifier拼接成一个最终的字节文本,存放在digest_storage->m_token_array中。

相关的函数调用路径如下,

Breakpoint 1, store_token_identifier (digest_storage=0x7ff428002848, token=945, id_length=18, id_name=0x7ff428006118 "performance_schema")
    at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:139
139   DBUG_ASSERT(digest_storage->m_byte_count <= digest_storage->m_token_array_length);
(gdb) bt
#0  store_token_identifier (digest_storage=0x7ff428002848, token=945, id_length=18, id_name=0x7ff428006118 "performance_schema")
    at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:139
#1  0x000000000166049f in digest_add_token (state=0x7ff428002840, token=945, yylval=0x7ff4c0281a60) at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:590
#2  0x0000000001675311 in Lex_input_stream::add_digest_token (this=0x7ff4c0283568, token=488, yylval=0x7ff4c0281a60) at /disk6/lefeng/porting/polardb571/sql/sql_lex.cc:382
#3  0x00000000016777ab in MYSQLlex (yylval=0x7ff4c0281a60, yylloc=0x7ff4c0281a40, thd=0x7ff428000950) at /disk6/lefeng/porting/polardb571/sql/sql_lex.cc:1362
#4  0x00000000017fd83b in MYSQLparse (YYTHD=0x7ff428000950) at /disk6/lefeng/porting/polardb571/sql/sql_yacc.cc:20171
#5  0x00000000016b8801 in parse_sql (thd=0x7ff428000950, parser_state=0x7ff4c0283560, creation_ctx=0x0) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:7578
#6  0x00000000016b5300 in mysql_parse (thd=0x7ff428000950, parser_state=0x7ff4c0283560) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:5924
#7  0x00000000016a9e3e in dispatch_command (thd=0x7ff428000950, com_data=0x7ff4c0283dd0, command=COM_QUERY) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:1550
#8  0x00000000016a8a5b in do_command (thd=0x7ff428000950) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:1011
#9  0x00000000017eeb6e in handle_connection (arg=0x5cec640) at /disk6/lefeng/porting/polardb571/sql/conn_handler/connection_handler_per_thread.cc:303
#10 0x0000000001a9f465 in pfs_spawn_thread (arg=0x5d87a50) at /disk6/lefeng/porting/polardb571/storage/perfschema/pfs.cc:2188
#11 0x00007ff4c9795e25 in start_thread () from /lib64/libpthread.so.0
#12 0x00007ff4c865cbad in clone () from /lib64/libc.so.6

在SQL stament执行完后,调用函数find_or_create_digest()计算MD5 hash,

File storage/perfschema/pfs_digest.cc

188 PFS_statement_stat*
189 find_or_create_digest(PFS_thread *thread,
190                       const sql_digest_storage *digest_storage,
191                       const char *schema_name,
192                       uint schema_name_length)
193 {
      ...
      
202   LF_PINS *pins= get_digest_hash_pins(thread);
203   if (unlikely(pins == NULL))
204     return NULL;
205 
206   /*
207     Note: the LF_HASH key is a block of memory,
208     make sure to clean unused bytes,
209     so that memcmp() can compare keys.
210   */
211   PFS_digest_key hash_key;
212   memset(& hash_key, 0, sizeof(hash_key));
213   /* Compute MD5 Hash of the tokens received. */
214   compute_digest_md5(digest_storage, hash_key.m_md5);
215   memcpy((void*)& digest_storage->m_md5, &hash_key.m_md5, MD5_HASH_SIZE);
216   /* Add the current schema to the key */
217   hash_key.m_schema_name_length= schema_name_length;
218   if (schema_name_length > 0)
219     memcpy(hash_key.m_schema_name, schema_name, schema_name_length);
220 
221   ...

在storage/perfschema/pfs_digest.cc第214行,find_or_create_digest()会调用compute_digest_md5()compute_digest_md5()会从digest_storage->m_token_array读取构造好的字节文本,完成hash计算。

File sql/sql_digest.cc

162 void compute_digest_md5(const sql_digest_storage *digest_storage, unsigned char *md5)
163 {   
164   compute_md5_hash((char *) md5,
165                    (const char *) digest_storage->m_token_array,
166                    digest_storage->m_byte_count);
167 } 
168  

statement digest计算示例

接下来,我们以SQL语句 “TRUNCATE TABLE performance_schema.events_statements_summary_by_digest” 为例介绍 digest计算过程。

  1. 首先识别出的token是859,对应的token定义如下,其token值用于填充字节文本的前2个字节,
File sql/sql_yacc.h

#define TRUNCATE_SYM 859

函数调用栈如下,

Breakpoint 2, store_token (digest_storage=0x7ff428002848, token=859) at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:76
76    DBUG_ASSERT(digest_storage->m_byte_count <= digest_storage->m_token_array_length);
(gdb) n
78    if (digest_storage->m_byte_count + SIZE_OF_A_TOKEN <= digest_storage->m_token_array_length)
(gdb) 
80      unsigned char* dest= & digest_storage->m_token_array[digest_storage->m_byte_count];
(gdb) 
81      dest[0]= token & 0xff;
(gdb) 
82      dest[1]= (token >> 8) & 0xff;
(gdb) 
83      digest_storage->m_byte_count+= SIZE_OF_A_TOKEN;
(gdb) 
89  }
(gdb) p digest_storage->m_byte_count
(gdb) 2

(gdb) bt
#0  store_token (digest_storage=0x7ff428002848, token=859) at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:76
#1  0x00000000016604c2 in digest_add_token (state=0x7ff428002840, token=859, yylval=0x7ff4c0281a60) at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:599
#2  0x0000000001675311 in Lex_input_stream::add_digest_token (this=0x7ff4c0283568, token=859, yylval=0x7ff4c0281a60) at /disk6/lefeng/porting/polardb571/sql/sql_lex.cc:382
#3  0x00000000016777ab in MYSQLlex (yylval=0x7ff4c0281a60, yylloc=0x7ff4c0281a40, thd=0x7ff428000950) at /disk6/lefeng/porting/polardb571/sql/sql_lex.cc:1362
#4  0x00000000017fd83b in MYSQLparse (YYTHD=0x7ff428000950) at /disk6/lefeng/porting/polardb571/sql/sql_yacc.cc:20171
#5  0x00000000016b8801 in parse_sql (thd=0x7ff428000950, parser_state=0x7ff4c0283560, creation_ctx=0x0) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:7578
#6  0x00000000016b5300 in mysql_parse (thd=0x7ff428000950, parser_state=0x7ff4c0283560) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:5924
#7  0x00000000016a9e3e in dispatch_command (thd=0x7ff428000950, com_data=0x7ff4c0283dd0, command=COM_QUERY) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:1550
#8  0x00000000016a8a5b in do_command (thd=0x7ff428000950) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:1011
#9  0x00000000017eeb6e in handle_connection (arg=0x5cec640) at /disk6/lefeng/porting/polardb571/sql/conn_handler/connection_handler_per_thread.cc:303
#10 0x0000000001a9f465 in pfs_spawn_thread (arg=0x5d87a50) at /disk6/lefeng/porting/polardb571/storage/perfschema/pfs.cc:2188
#11 0x00007ff4c9795e25 in start_thread () from /lib64/libpthread.so.0
#12 0x00007ff4c865cbad in clone () from /lib64/libc.so.6
  1. 其次识别出的token是835,其对应的token定义如下,其token值用于填充接下来的2字节,
File sql/sql_yacc.h

#define TABLE_SYM 835
  1. 然后识别出来并用于构造字节文本的token是488,对应的token定义是,
#define IDENT_QUOTED 488

该token在digest_add_token()中被转换为token 945 (参考588行)

File sql/sql_digest.cc

379 sql_digest_state* digest_add_token(sql_digest_state *state,
380                                    uint token,
381                                    LEX_YYSTYPE yylval)
...
401   switch (token)
402   {
403     case NUM:
404     case LONG_NUM:
405     case ULONGLONG_NUM:
406     case DECIMAL_NUM:
407     case FLOAT_NUM:
408     case BIN_NUM:
409     case HEX_NUM:
...
571     case IDENT:
572     case IDENT_QUOTED:
573     case TOK_IDENT_AT:
574     {
575       YYSTYPE *lex_token= yylval;
576       char *yytext= lex_token->lex_str.str;
577       size_t yylen= lex_token->lex_str.length;
578 
579       /*
580         REDUCE:
581           TOK_IDENT := IDENT | IDENT_QUOTED
582         The parser gives IDENT or IDENT_TOKEN for the same text,
583         depending on the character set used.
584         We unify both to always print the same digest text,
585         and always have the same digest hash.
586       */
587       if (token != TOK_IDENT_AT)
588         token= TOK_IDENT;
589       /* Add this token and identifier string to digest storage. */
590       store_token_identifier(digest_storage, token, yylen, yytext);
591 
592       /* Update the index of last identifier found. */
593       state->m_last_id_index= digest_storage->m_byte_count;
594       break;
595     }

根据token值和id_name的长度构造4字节文本数据,之后把”performance_schema”追加到其后。

Breakpoint 1, store_token_identifier (digest_storage=0x7ff428002848, token=945, id_length=18, id_name=0x7ff428006118 "performance_schema")
    at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:139
139   DBUG_ASSERT(digest_storage->m_byte_count <= digest_storage->m_token_array_length);
(gdb) p digest_storage->m_byte_count
$3 = 4
(gdb) n
141   size_t bytes_needed= 2 * SIZE_OF_A_TOKEN + id_length;
(gdb) 
142   if (digest_storage->m_byte_count + bytes_needed <= (unsigned int)digest_storage->m_token_array_length)
(gdb) 
144     unsigned char* dest= & digest_storage->m_token_array[digest_storage->m_byte_count];
(gdb) 
146     dest[0]= token & 0xff;
(gdb) 
147     dest[1]= (token >> 8) & 0xff;
(gdb) 
149     dest[2]= id_length & 0xff;
(gdb) 
150     dest[3]= (id_length >> 8) & 0xff;
(gdb) 
152     if (id_length > 0)
(gdb) 
153       memcpy((char *)(dest + 4), id_name, id_length);
(gdb) 
154     digest_storage->m_byte_count+= bytes_needed;
(gdb) 
160 }
(gdb) p digest_storage->m_byte_count
$4 = 26
  1. 接下来识别出的token是46
Breakpoint 2, store_token (digest_storage=0x7ff428002848, token=46) at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:76
76    DBUG_ASSERT(digest_storage->m_byte_count <= digest_storage->m_token_array_length);
78    if (digest_storage->m_byte_count + SIZE_OF_A_TOKEN <= digest_storage->m_token_array_length)
(gdb) 
80      unsigned char* dest= & digest_storage->m_token_array[digest_storage->m_byte_count];
(gdb) 
81      dest[0]= token & 0xff;
(gdb) 
82      dest[1]= (token >> 8) & 0xff;
(gdb) 
83      digest_storage->m_byte_count+= SIZE_OF_A_TOKEN;
(gdb) 
89  }
(gdb) p digest_storage->m_byte_count
$11 = 28
  1. 最后识别出来且用于构造字节文本的token是945 (同上,488转换而来),”events_statements_summary_by_digest”会被追加到文本末尾。
Breakpoint 1, store_token_identifier (digest_storage=0x7ff428002848, token=945, id_length=35, id_name=0x7ff428006130 "events_statements_summary_by_digest")
    at /disk6/lefeng/porting/polardb571/sql/sql_digest.cc:139
139   DBUG_ASSERT(digest_storage->m_byte_count <= digest_storage->m_token_array_length);
(gdb) n
141   size_t bytes_needed= 2 * SIZE_OF_A_TOKEN + id_length;
(gdb) 
142   if (digest_storage->m_byte_count + bytes_needed <= (unsigned int)digest_storage->m_token_array_length)
(gdb) 
144     unsigned char* dest= & digest_storage->m_token_array[digest_storage->m_byte_count];
(gdb) 
146     dest[0]= token & 0xff;
(gdb) 
147     dest[1]= (token >> 8) & 0xff;
(gdb) 
149     dest[2]= id_length & 0xff;
(gdb) 
150     dest[3]= (id_length >> 8) & 0xff;
(gdb) 
152     if (id_length > 0)
(gdb) 
153       memcpy((char *)(dest + 4), id_name, id_length);
(gdb) 
154     digest_storage->m_byte_count+= bytes_needed;
(gdb) 
160 }
(gdb) p digest_storage->m_byte_count
$12 = 67
  1. 至此,字节文本构造完毕,接下来计算MD5 hash,
Breakpoint 3, find_or_create_digest (thread=0x7ff4c7cc2c00, digest_storage=0x7ff428002848, schema_name=0x7ff428002930 "", schema_name_length=0)
    at /disk6/lefeng/porting/polardb571/storage/perfschema/pfs_digest.cc:194
194   DBUG_ASSERT(digest_storage != NULL);
(gdb) n
196   if (statements_digest_stat_array == NULL)
(gdb) 
199   if (digest_storage->m_byte_count <= 0)
(gdb) 
202   LF_PINS *pins= get_digest_hash_pins(thread);
(gdb) 
203   if (unlikely(pins == NULL))
(gdb) 
212   memset(& hash_key, 0, sizeof(hash_key));
(gdb) 
214   compute_digest_md5(digest_storage, hash_key.m_md5);
(gdb) 
215   memcpy((void*)& digest_storage->m_md5, &hash_key.m_md5, MD5_HASH_SIZE);
(gdb) 
217   hash_key.m_schema_name_length= schema_name_length;
(gdb) p /x hash_key.m_md5
$13 = {0xf8, 0x37, 0x3f, 0x7b, 0xed, 0x47, 0x77, 0x3d, 0x4c, 0xd1, 0xd5, 0xc0, 0xab, 0xb7, 0x88, 0xc9}
(gdb) bt
#0  find_or_create_digest (thread=0x7ff4c7cc2c00, digest_storage=0x7ff428002848, schema_name=0x7ff428002930 "", schema_name_length=0)
    at /disk6/lefeng/porting/polardb571/storage/perfschema/pfs_digest.cc:217
#1  0x0000000001aa648e in pfs_end_statement_v1 (locker=0x7ff428002888, stmt_da=0x7ff428003890) at /disk6/lefeng/porting/polardb571/storage/perfschema/pfs.cc:5405
#2  0x00000000016a5c46 in inline_mysql_end_statement (locker=0x7ff428002888, stmt_da=0x7ff428003890) at /disk6/lefeng/porting/polardb571/include/mysql/psi/mysql_statement.h:228
#3  0x00000000016ab574 in dispatch_command (thd=0x7ff428000950, com_data=0x7ff4c0283dd0, command=COM_QUERY) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:2023
#4  0x00000000016a8a5b in do_command (thd=0x7ff428000950) at /disk6/lefeng/porting/polardb571/sql/sql_parse.cc:1011
#5  0x00000000017eeb6e in handle_connection (arg=0x5cec640) at /disk6/lefeng/porting/polardb571/sql/conn_handler/connection_handler_per_thread.cc:303
#6  0x0000000001a9f465 in pfs_spawn_thread (arg=0x5d87a50) at /disk6/lefeng/porting/polardb571/storage/perfschema/pfs.cc:2188
#7  0x00007ff4c9795e25 in start_thread () from /lib64/libpthread.so.0
#8  0x00007ff4c865cbad in clone () from /lib64/libc.so.6
  1. 最后,查询performance_schema.events_statements_summary_by_digest,显示计算出的MD5 hash值。
MySQL [(none)]> SELECT SCHEMA_NAME, DIGEST, DIGEST_TEXT FROM performance_schema.events_statements_summary_by_digest;
+-------------+----------------------------------+------------------------------------------------------------------------------+
| SCHEMA_NAME | DIGEST                           | DIGEST_TEXT                                                                  |
+-------------+----------------------------------+------------------------------------------------------------------------------+
| NULL        | f8373f7bed47773d4cd1d5c0abb788c9 | TRUNCATE TABLE `performance_schema` . `events_statements_summary_by_digest`  |
+-------------+----------------------------------+------------------------------------------------------------------------------+
1 row in set (11.13 sec)

Database · 理论基础 · B-tree 物理结构的并发控制

$
0
0

本文介绍 B-tree 物理结构的并发控制,主要讨论设计 B-tree 加锁规则时需要解决几个问题。

参考论文《A Survey of B-Tree Locking Techniques》

InnoDB 的相关实现参考这篇月报:http://mysql.taobao.org/monthly/2020/06/02/

lock 和 latch 的区别

B-tree 索引的并发控制需要考虑两个方面:

  1. 事务 transaction 并发访问数据内容时的并发控制;

  2. 线程 thread 并发访问内存数据结构时的并发控制;

为了更容易区分这两种情况,我们举一个更清楚的例子:一个事务被分配给多个线程并发地执行时,当一个线程在分裂一个 B-tree 节点时,其他线程不能去访问这种中间状态的数据结构;相反的,当单一线程去服务多个事务时,同样需要考虑数据内容在多个事务间的一致性。在数据库系统中,这两个目标通常用两个不同的机制 lock 和 latch 来实现,而操作系统中常用的 lock 这个词,在数据库中其实指的是 latch。

latch 一般称为闩锁,只作用于内存数据结构,例如,控制多个线程互斥访问内存池中的 B-tree 节点。latch 是低级别、轻量级的锁,线程通常只在临界区内读写共享内存数据结构时持有 latch,因此 latch 的锁定时间一般很短并且频繁使用,latch 的获取和释放需要尽量小的消耗和更好的性能。latch 最简单的形式就是互斥锁(mutex),它不允许任何的并发访问,在数据库系统中通常还会使用共享(shared)和互斥(exclusive)两种类型的 latch。latch 的死锁不能使用复杂的死锁检测或回滚机制,而是需要通过设计编码规范来避免死锁发生,例如多个线程都以规定好的顺序申请 latch。

lock 用于隔离多个事务,锁定的对象是数据库逻辑内容,例如 table、record、B-tree keys、key range 等,通常锁定时间很长,在事务结束时才释放。lock 会参与死锁检测,也支持复杂的调度策略,例如使用队列来排队加锁请求,因此 lock 申请和释放是比较耗时的,通常要上千的 CPU 周期。数据库系统通常会实现 lock table,因为 lock table 是共享的内存数据结构并且会有多个线程并发访问,因此访问 lock table 也就需要 latch 来保护。

latch 可以直接嵌入要保护的数据结构,例如,对于内存池中的磁盘数据页,每个数据页都有一个内存描述符结构记录 page id 等信息,而数据页对应的 latch 就可以嵌入到这个描述符结构中。 lock 用于保护逻辑的数据库内容,被保护的数据可能都不在内存里出现,因此 lock 也就无法像 latch 一样嵌入要保护的对象。

单个节点并发控制

如果要修改 B-tree 的内容或结构,必须先把 B-tree 节点读取到内存池中,修改后再写回磁盘。在多线程场景下,内存池中的一个 B-tree 节点在被一个线程读取时,不能被另一个线程修改,这种场景就是多线程编程中共享数据的临界区问题。数据库中使用 latch 来控制单个 B-tree 节点的访问,从而保持 B-tree 物理结构的一致性,通常在每个节点的描述符中嵌入一个对应的 latch。

latch coupling

当一个线程沿着一个指针从一个节点到另一个节点时,例如从 B-tree 的一个父节点到一个子节点,在这期间不能有其它线程去改变这个指针,例如删除这个子节点等操作。这个时候需要持有父节点的 latch 直到获得了子节点的 latch,这种方法通常称为 ”latch coupling”。

持有父节点 latch 去请求子节点 latch 时,如果子节点没有在内存池中,就需要进行一次磁盘 IO 来获取子节点,因为 latch 的持有时间必须很短,等待读取子节点时,不应该长时间占据父节点的 latch,而是应该释放父节点的 latch。在获取子节点的 IO 操作结束之后,需要重新进行一次从 root 到 leaf 的遍历来获得父节点的 latch,这样可以避免释放父节点的期间 B-tree 的结构改变导致的不一致问题。这种重新遍历的操作代价并不大,因为可以通过检查上一次遍历时保存的路径是否还有效,来重用之前的路径,当需要的节点已经从磁盘读取到内存池中时,它的祖先节点可能还没有被其它线程修改过。

反向遍历 level list

latch coupling 除了上面提到的从父节点到子节点遍历的情况,还有一种是同层相邻节点遍历的场景,例如范围查询时需要沿着叶节点的链表正向或反向遍历,并发查询可能会由于遍历的方向不同导致死锁。为了避免相反方向遍历产生的死锁,一种方法是让 latch 支持立即重试的模式,即当一个 latch 被其它线程占有而获取不到时,立即返回失败而不是继续等待 latch 被其它线程释放。在正向或反向遍历 B-tree 同层节点时,只要遇到 latch 获取失败,就立即释放掉自己占有的 latch,从而让冲突的对方能继续执行下去,而自己进行一次从 root 到 leaf 的重试。这里要考虑的一个问题是,如何避免两个冲突的线程同时重试的情况,因为同时重试后有可能还在相同的地方发生冲突,可以规定一个遍历方向的优先级,这样可以保证冲突时只有一个线程会重试,另一个线程会继续执行。

递归向上更新

在向 B-tree 插入记录时可能导致叶节点 overflow,需要将 overflow 的节点分裂成两个,并将新的节点指针插入到父节点,这时父节点可能也会 overflow,因此又会触发插入到祖父节点的操作,最极端的情况,这种递归向上插入会一直执行到 root 节点。除了插入操作,删除或者更新记录操作也可能发生这种从叶节点到根节点的变更,这个过程需要从下到上的加锁顺序,因此可能会与从上到下的遍历操作形成死锁。最简单的解决办法是对整个 B-tree 加一个互斥锁,但是这样太影响多线程并发,最好的方法应该是只对 B-tree 节点加锁,下面总结了几种针对这个问题的策略:

  1. 在从上到下查找目标节点时,就把整个路径节点加互斥锁,这样在从下到上的节点变更时就不再需要加锁。很明显的这种方法每次都会锁住 root 节点,跟锁住整个 B-tree 没有本质区别,严重影响 B-tree 的并发性;

  2. 在从上到下遍历时给查找路径加共享锁,在必要时再将共享锁升级成互斥锁,升级过程中需要检查死锁的风险。由于这种方法是可能失败的,因此需要有额外的备选方案,这就增加了逻辑的复杂度。

  3. 引入一种共享互斥锁(SX latch),从上到下遍历时给路径上的节点都加 SX latch,这种类型的 latch 可以与共享锁相容,从而不会阻塞其它线程的读请求。但是 SX latch 无法与自身相容,因此对于并发更新来说 B-tree 的根节点依然是一个瓶颈,只允许一个线程进行修改 B-tree 结构的操作。

  4. 上面三种方法都需要一直持有遍历路径上节点的 latch,直到一个节点不再会触发向上更新才释放路径上的所有 latch。实际情况是大多数节点都不是满的,因此大多数插入操作都不会触发节点分裂并向上变更,锁住整个路径的节点是没有必要的。如果在插入操作的从上到下遍历时主动进行节点分裂,就能避免了根节点的瓶颈问题,也没有升级 latch 时失败的问题。缺点是需要在实际分裂之前预先分配空间,造成一定的空间浪费,并且在可变长记录更新时无法准确地判断是否需要预先分裂。

  5. 为了解决不必要的对节点加互斥锁,可以在第一次从上到下遍历时加共享锁,直到一个节点需要分裂时,重新回到 root 做一次遍历,这次给要分裂的节点加互斥锁,并进行实际的分裂操作。第二次遍历时可以通过检查第一次遍历保存的路径来进行重用,而不必从根节点重新遍历。

MySQL · 源码阅读 · 创建二级索引

$
0
0

InnoDB的二级索引创建进入的主要函数为row_merge_build_indexes(),其整个过程大致可以分为三个步骤:扫描主建索引row_merge_read_clustered_index()、按照新的key排序row_merge_sort()、建立新的索引树row_merge_insert_index_tuples()。我们将按照这三个步骤来介绍二级索引的创建过程。

扫描主键索引——row_merge_read_clustered_index()

由于在InnoDB中,所有的记录都保存在主键的B+树中,所以在建立新的二级索引之前,先要去主键索引的B+树中把全部的记录都读取出来。

使用btr_pcur_open_at_index_side()函数以BTR_SEARCH_LEAF的模式打开一个b+树最左边的叶子结点的游标(cursor)。这个过程首先获取整个index的S锁,从根节点开始,一层一层向下,每一层调用buf_page_get_gen()并获取途径的page和它们的S锁,直到到达最后一层,获取到B+树叶子层最左边的一个page,获取该page的锁。并调用mtr_release_s_latch_at_savepoint()释放index的S锁和mtr_release_block_at_savepoint()释放掉沿途page的锁。

在一个page内,每次调用page_cur_get_rec()函数获取cursor位置的一条记录。读完一个page的所有记录之后,就会调用btr_block_get()函数把cursor定位到下一个page,并获取下一个page的S锁,并调用btr_leaf_page_release()函数释放当前page的S锁,直到读完主键索引的所有记录。

读取记录的时候,如果记录已经被标记了删除的标志,则会跳过这条记录。如果是online的方式创建索引,为了保证索引创建完成之后,row_log_table_apply()应用新增log时不会看到更新版本的记录,在读取记录时,还需要判断该记录对当前事务视图的可见性,如果记录的版本对当前的视图不可见,则需要去获取老版本的记录。

从主键索引的B+树上直接拿到的record记录是物理记录,需要调用row_build_w_add_vcol()函数把他们转化为逻辑记录。为了减少之后外部排序的次数,在读取记录时会做一些规模很小的排序。row_merge_buf_add()把逻辑记录添加到sort buffer里面,sort buffer的大小保证至少可以放得下一条记录。当sort buffer内存放的记录满了之后,就会对sort buffer内的记录进行一次排序。

如果主键索引中全部的记录只用了一个sort buffer就存下了,那么就不用把sort buffer里这些记录写入到临时文件了,跳过第二步的row_merge_sort()函数,直接用这些记录执行row_merge_insert_index_tuples()函数来插入到新的二级索引建树。否则,需要创建临时文件来保存这些在sort buffer内部完成排序的记录。每次把存满记录的sort buffer写入文件之前,先调用row_merge_buf_write()函数把sort buffer里面的记录写入到一个block里面,这个过程中,会调用row_merge_buf_encode()函数将每条记录转化为COMPACT格式,然后写入block中。之后再调用row_merge_write()函数把block的内容写入临时文件,并清空sort buffer。

重复这样的操作,直到扫描完主键索引中的全部记录。

按照新的key排序——row_merge_sort()

在完成了扫描主键索引的工作之后,我们就得到了一个保存着所有记录的临时文件(除了记录的数量未达到一个sort buffer大小的情况)。这个临时文件由一个一个内部有序的block组成,而这个步骤的任务,就是将这些block进行归并排序,从而达到全局有序。

除了存储了全部记录的临时文件之外,排序中还需要借助另一个临时文件来辅助排序。每个局部有序的连续内容被称为一个run,排序的终点是要把run的数量变成为1,也就是全局有序的状态了。

每一轮的排序row_merge()中,会调用row_merge_blocks()把前一半的所有run与后一半的所有run进行合并,而两个run合并的时候,也是一对记录一对记录的比较,按照从小到大的顺序插入到辅助临时文件中。当一轮合并排序结束之后,run的数量就会减少一半,辅助临时文件中就写满了一个个局部有序的run。在下一轮排序中,就会原本的临时文件作为辅助临时文件,而原本的辅助临时文件,则调换身份为存储全部记录的临时文件。重复这个过程,直到全部的run都合并为一个run,外部排序就完成了。

建立新的索引树——row_merge_insert_index_tuples()

在这个阶段,要用所有已经排好序的记录,为新的二级索引建立b+树。从MySQL 5.7.5开始,提供了Bulk Load的建树方式,采用自下而上的方式完成整个索引树的建立过程。在这里,我们以Bulk Load的方式为例,介绍这个过程。

每次从临时文件中读取一条记录,调用row_rec_to_index_entry_low()函数把记录转化为dtuple_t类型,然后就可以用BtrBulk::insert()来把记录插入索引树,这个插入索引树的过程具体是这么做的。首先,每当需要插入一个dtuple_t记录时,会先找到b+树的最右边的叶子结点,BtrBulk的m_page_bulks是一个记录了b+树每一层最右边一个page的vector,从m_page_bulks中就可以拿到最右边的叶子结点。接下来需要调用prepareSpace()为即将插入的记录准备空间。这个准备空间的过程,是优先用PageBulk::isSpaceAvailable()判断该叶子结点是否具有足够的剩余空间支持插入该条记录,一条记录的插入除了会增加这条记录的数据本身占据的大小,还会增加它带来的directory slots大小的增量,此外,剩余空间还需要考虑fill factor的预留空间和为压缩页预留的padding空间。如果剩余空间足够,就可以往这个page里面插入该条记录,否则,就需要创建新的page来存储记录。PageBulk::init()可以进行新的page的创建与初始化,这个过程中会先调用fsp_reserve_free_extents()申请一个free page的预留空间,预留空间申请成功就会调用btr_page_alloc()函数申请一个新的page,然后调用fil_space_release_free_extents()释放预留的位置。

创建完新的page之后,就把新的page和老的page之间用指针连起来,并把老的page的指针插入到上一层的父节点中,这个插入的过程和前面的一样,同样有可能触发新page的申请和继续递归插入父节点。之后还需要更新m_page_bulks,把所有产生了新的page的层对应的page指针做一个修改,保证m_page_bulks中记录的依然是每一层最右边的page的指针。对于叶子结点的新page产生,还会唤醒page cleaner进行刷脏操作。

经过这一系列的操作之后,我们就拥有了b+树上可以容纳当前记录的最右侧的叶子结点,调用rec_convert_dtuple_to_rec()把dtuple_t类型的记录转化为物理记录,并插入到page中。

当临时表中全部记录都插入到新的b+树之后,创建新的二级索引就完成了。

MySQL · 源码阅读 · Secondary Engine

$
0
0

背景

MySQL默认的存储引擎是InnoDB,而引入Secondary Engine,用来实现同时支持多引擎,在同一个MySQL Server上挂多个存储引擎,在支持InnoDB的同时,还可以把数据存放在其他的存储引擎上。 全量的数据都存储在Primary Engine上,某些指定数据在Secondary Engine 上也存放了一份,然后在访问这些数据的时候,会根据系统参数和cost选择存储引擎,提高查询效率。

在最新版本8.0.22上还支持了启动和停止某个Secondary Engine。

MySQL官方集成了RAPID来为MySQL提供实时的数据分析服务,同时支持InnoDB和RAPID,但未开源,开源MySQL引入Secondary Engine,有助于我们集成其他存储引擎或者数据库。

本文是基于最新版本的MySQL-8.0.22源码解读的。

  • 安装一个secondary engine MOCK 使用Secondary Engine之前需要安装插件,目前源码中有模拟Secondary Engine的插件ha_mock.so
    INSTALL PLUGIN mock SONAME "ha_mock.so";
    
  • 与Secondary Engine有关的系统变量
    SET @use_secondary_engine= "ON";
    SET @use_secondary_engine= "OFF";
    SET @use_secondary_engine= "FORCED";
    

    use_secondary_engine设置为“ON”,表示在使用primary engine的cost大于Secondary engine的情况下使用secondary engine; use_secondary_engine设置为“OFF”,表示不使用Secondary engine; use_secondary_engine设置为“FORCED”,表示强制使用Secondary engine。

  • 表定义时需要指明使用Secondary Engine,如:
    CREATE TABLE t1 (a INT NOT SECONDARY, b INT) SECONDARY_ENGINE MOCK;
    
  • 加载和卸载数据的语法如下:
    ALTER TABLE T1 SECONDARY_LOAD; 
    ALTER TABLE T1 SECONDARY_UNLOAD;
    

    内核的实现

    2020-11-wenjing1.png如图所示,Secondary Engine实际上是MySQL sever上同时支持两个存储引擎,把一部分主引擎上的数据,在Secondary Engine上也保存一份,然后查询的时候会根据优化器的的选择决定在哪个引擎上处理数据。

    定义和声明

    Sql_cmd {
    // The handlerton will be assigned in open_tables_for_query()
    const handlerton *m_secondary_engine;
    }
    TABLE_SHARE {
    /// Secondary storage engine
    LEX_CSTRING secondary_engine;
    
    /// Does this TABLE_SHARE represent a table in a secondary storage engine?
    bool m_secondary_engine{false};
    }
    //Column has an option NOT SECONDARY
    Field {
    // Engine specific attributes
    LEX_CSTRING m_secondary_engine_attribute;
    }
    

    加载/卸载数据

TABLE::read_set

TABLE::read_set 用来标记哪些coloum的数据需要load到Secondary Engine上。默认这个表的所有columns都需要load到Secondary Engine上,除非这个column上标记了NOT SECONDARY的属性。

  bool Sql_cmd_secondary_load_unload::mysql_secondary_load_or_unload(
    THD *thd, TABLE_LIST *table_list) {
	...
  // Omit hidden generated columns and columns marked as NOT SECONDARY from
  // read_set. It is the responsibility of the secondary engine handler to load
  // only the columns included in the read_set.
  bitmap_clear_all(table_list->table->read_set);
  for (Field **field = table_list->table->field; *field != nullptr; ++field) {
    // Skip hidden generated columns.
    if (bitmap_is_set(&table_list->table->fields_for_functional_indexes,
                      (*field)->field_index))
      continue;

    // Skip columns marked as NOT SECONDARY.
    if ((*field)->flags & NOT_SECONDARY_FLAG) continue;

    // Mark column as eligible for loading.
    table_list->table->mark_column_used(*field, MARK_COLUMNS_READ);
	//   bitmap_set_bit(read_set, field->field_index);
  }
  ...
  // Initiate loading into or unloading from secondary engine.
  const bool error =
      is_load
          ? secondary_engine_load_table(thd, *table_list->table)
          : secondary_engine_unload_table(
                thd, table_list->db, table_list->table_name, *table_def, true);
  ...

两阶段加载

加载数据到Secondary Engine的过程有两阶段组成:

  • 第一阶段, ha_prepare_load_table
    prepare阶段通常是很短的一段时间,需要持有一把MDL_EXCLUSIVE 表锁,持有锁的时间很短,主要是为了保证在开始数据加载之前提交对于该表的所有DML操作。
  DBUG_ASSERT(thd->mdl_context.owns_equal_or_stronger_lock(
      MDL_key::TABLE, table.s->db.str, table.s->table_name.str, MDL_EXCLUSIVE));
  DBUG_ASSERT(table.s->has_secondary_engine());

  // At least one column must be loaded into the secondary engine.
  if (bitmap_bits_set(table.read_set) == 0) {
    my_error(ER_SECONDARY_ENGINE, MYF(0),
             "All columns marked as NOT SECONDARY");
    return true;
  }
  • 第二阶段, ha_load_table 这个阶段是真正加载数据的阶段,使用InnoDB的Parallel_reader_adapter实现并行扫描数据以提高加载效率。
// InnoDB parallel scan context
Parallel_reader_adapter

struct handler {
// Parallel scan interface, could be explored to speed up offload process 
int parallel_scan_init(void *&scan_ctx, size_t &num_threads);
int parallel_scan(void *scan_ctx, void **thread_ctxs,
                  Load_init_cbk init_fn, Load_cbk load_fn, Load_end_cbk end_fn);
void parallel_scan_end(void *scan_ctx);

// Allows concurrent DML in the offload process
int ha_prepare_load_table(const TABLE &table);
int ha_load_table(const TABLE &table);
int ha_unload_table(const char *db_name, const char *table_name,
                    bool error_if_not_loaded);

void ha_set_primary_handler(handler *primary_handler);
};

优化器

一般情况下,如果访问主引擎获取这些数据的cost大于某一个特定值threshold的时候,会选择通过Secondary Engine访问这些数据,而其他只存储在主引擎的数据,还是通过主引擎访问。Secondary Engine用于Primary Engine上执行时间过长的查询,会尝试在Secondary Engine上执行。

2020-11-wenjing2.png

在query执行之前,优化器的最后阶段增加了optimize_secondary_engine,但并不是所有的query都要经过optimize_secondary_engine。目的主要是避免在Primary engine上执行很快的query经过secondary engine执行。

  • 首先先走正常的优化流程  unit->optimize()
  • 然后估算当前查询的current_query_cost accumulate current_query_cost
  • 如果current_query_cost大于secondary_engine_cost_threshold If (current_query_cost < variables.secondary_engine_cost_threshold) return false; optimize_secondary_engine

Mock

Mock是为了对MySQL进行与Secondary Engine相关的功能测试而写的一个Secondary Engine的demo。他定义了用于适配Secondary Engine的接口。 mock 的源码在 router/src/mock_server目录下, 与secondary engine适配的接口在 storage/secondary_engine_mock/ha_mock.h storage/secondary_engine_mock/ha_mock.cc

系统价值

Secondary Engine是MySQL为了支持多引擎提供一种方法和实现框架。在此基础上,MySQL可以根据不同存储引擎对数据处理的特点来把不同的查询计划匹配到合适的存储引擎的上执行,从而发挥多种存储引擎各自的优点,优化整个SQL的查询效率。为多模架构和异构数据库的实现提供了一种框架。 比如,我们可以利用Secondary Engine接入ClickHouse,来承接分析型的查询。

现状

Secondary Engine的基础框架已经搭建起来了,实现了一个用于功能测试demo mock。但是还不够完善: 目前load数据只支持存量数据的加载,还不能支持增量数据的加载。不能支持实时的数据同步到secondary engine上。 目前还没有直接可以作为MySQL的Secondary Engine的存储引擎,如果接入,需要做一些适配的研发工作。

前景

随着数据库的发展,人们对处理异构数据的需求 越来越强烈,需要这种支持多引擎的数据库出现,Secondary engine还有很多事情要做 1、支持增量数据的loading,除了一次性把所有标记需要存储到Secondary Engine的数据加载过去,还需要支持实时的把主引擎产生的增量数据同步过去。 2、支持只写Secondary Engine,支持某些指定的数据只写在Secondary Engine上,而不需要先写主引擎,再同步到Secondary Engine。 3、支持hint。查询时可以通过hint指定在某个特定的Secondary Engine上执行某一部分执行计划。 4、可以使用Secondary Engine 来实现多模、异构数据库。


Database · 发展前沿 · NewSQL数据库概述

$
0
0

传统的关系型数据库有着悠久的历史,从上世纪60年代开始就已经在航空领域发挥作用。因为其严谨的强一致保证以及通用的关系型数据模型接口,获得了越来越多的应用,大有一统天下的气势。这期间,涌现出了一批佼佼者,其中有优秀的商业化数据库如Oracle,DB2,SQL Server等,也有我们耳熟能详的开源数据库MySQL及PostgreSQL。这里不严谨的将这类传统数据库统称为SQL数据库。

SQL to NoSQL

2000年以后,随着互联网应用的出现,很多场景下,并不需要传统关系型数据库提供的强一致性以及关系型数据模型。相反,由于快速膨胀和变化的业务场景,对可扩展性(Scalability)以及可靠性(Reliable)更加需要,而这个又正是传统关系型数据库的弱点。自然地,新的适合这种业务特点的数据库NoSQL开始出现,其中最句代表性的是Amazon的Dynamo以及Google的BigTable,以及他们对应的开源版本像Cassandra以及HBase。由于业务模型的千变万化,以及抛弃了强一致和关系型,大大降低了技术难度,各种NoSQL版本像雨后春笋一样涌现,基本成规模的互联网公司都会有自己的NoSQL实现。主要可以从两个维度来对各种NoSQL做区分:

  1. 按元信息管理方式划分:以Dynamo[1]为代表的对等节点的策略,由于没有中心节点的束缚有更高的可用性。而采用有中心节点的策略的,以BigTable[2]为代表的的数据库,则由于减少全网的信息交互而获得更好的可扩展性。
  2. 按数据模型划分:针对不同业务模型出现的不同数据模型的数据库,比较知名的有文档型数据库MongoDB,KV数据库Redis、Pika,列数据库Cassandra、HBase,图数据库Neo4J。

NoSQL to New SQL

NoSQL也有很明显的问题,由于缺乏强一致性及事务支持,很多业务场景被NoSQL拒之门外。同时,缺乏统一的高级数据模型、访问接口,又让业务代码承担了很多的负担。图灵奖得主Michael Stonebraker甚至专门发文声讨,”Why Enterprises Are ­Uninterested in NoSQL” [3]一文中,列出了NoSQL的三大罪状:No ACID Equals No Interest, A Low-Level Query Language is Death,NoSQL Means No Standards。数据库的历史就这样经历了否定之否定,又螺旋上升的过程。而这一次,鱼和熊掌我们都要

核心问题:分片(Partition)

如何能在获得SQL的强一致性、事务支持的同时,获得NoSQL的可扩展性及可靠性。答案显而易见,就是要在SQL的基础上像NoSQL一样做分片。通过分片将数据或计算打散到不同的节点,来摆脱单机硬件对容量和计算能力的限制,从而获得更高的可用性、性能以及弹性:

partition

那么如何做分片呢,上图是一个高度抽象的数据库分片示意图,我们将数据库系统划分为上下两个部分,Part 1保持不动,将Part 2进行分片打散,由不同的节点负责,并在分片间通过副本方式保证高可用。同时,Part 2部分的功能由于被多个节点分担,也可以获得并行执行带来的性能提升。

所以现在实现NewSQL的核心问题变成了:确定一条分割线将数据库系统划分为上下两个部分,对Part 2做分片打散。而这条分割线的确定就成了主流NewSQL数据库的不同方向,不同的选择带来的不同的ACID实现方式,以及遇到的问题也大不相同。下图展示了更详细的数据库系统内部结构,以及主流的分界线选择及工业实现代表:

partition_line

为了方便说明,本文中根据这个分片分割线的位置,将不同的方案命名为:Partition All、Partition Engine、Partiton Storage 以及 Partition Disk。这里先说结论:随着分片层次的下降,可扩展性会降低,但易用性和生态兼容性会增大。下面就分别介绍每种选择中需要解决的问题,优缺点,使用场景以及代表性工业实现的实现方式。

Partition All:分库分表

最直观的想法,就是直接用多个DB实例共同服务,从而缓解单机数据库的限制,这也是很多大公司内部在业务扩张期的第一选择。这种方式相当于是在数据库系统的最顶层就做了Partition,理想情况下,整个数据库的各个模块可以全部并发执行起来。

partition all

采用分库分表的首要问题就是如何对数据进行分片,常见的就是在表或者库的维度水平或垂直的进行分片。这里分片的选择是非常关键的,良好的,适应业务模式的分片可以让多DB实例尽量的并发起来获得最好的扩展性能。而不合适的分片则可能导致大量的跨节点访问,导致负载不均衡,或引入访问瓶颈。除分片策略之外,由于跨节点的访问需要,会有一些通用的问题需要解决,比如如何在分片之间支持分布式事务,处理分布式Query拆分和结果合并,以及全局自增ID的生成等。而且,最重要的,所有这些新增的负担全部要业务层来承担,极大的增加了业务的成本。

因此,分库分表的模式,在良好的业务侧设计下可以获得极佳的扩展性,取得高性能、大容量的数据库服务。但业务耦合大,通用性差,需要用户自己处理分片策略、分布式事务、分布式Query。这也是为什么在各大公司内部都有成熟稳定的分库分表的数据库实现的情况下,这些实现却很难对外通用的输出。

Partition Engine: Spanner

我们将Part 1和Part2的分界线下移,到Server层之下,也就是只在引擎层做Partition。这种模式由于节点间相对独立,也被称作Share Nothing架构。相对于传统分库分表,Partition Engine的方式将之前复杂的分布式事务,分布式Query等处理放到了数据库内部来处理。

本文就以分布式事务为例,来尝试解释这种分片层次所面对的问题和解决思路。要支持事务就需要解决事务的ACID问题。而ACID的问题在分布式的环境下又变得复杂很多:

partition engine

A(Atomicity),在传统数据库系统中,通过REDO加UNDO的方式容易解决这个问题[4],但当有多个不同的节点参与到同一个事务中的时候问题变的复杂起来,如何能把保证不同节点上的修改同时成功或同时回滚呢,这个问题有成熟的解决方案,就是2PC(Two-Phase Commit Protocol),引入Coordinator角色和prepare阶段来在节点间协商。

D (Durability),单机数据库中,我们通过REDO配合Buffer Pool的刷脏策略可以保证节点重启后可以看到已经提交的事务[4]。而在分布式环境中通常会需要更高的可用性,节点宕机后马上需要有新的节点顶上。这里的解决方案也比较成熟,就是给分片提供多个副本(通常是3个),每个副本由不同的节点负责,并且采用一致性算法Multi-Paxos[6]或其变种来保证副本间的一致性。下图是Spanner的实现方式,每个spanserver节点负责不同partition的多个分片,每个partition的副本之间用Paxos来保证其一致性,而跨Partition的事务则用2PC来保证原子。

partition engine

I(Isolation),“数据库并发控控制”[5]一文中介绍过,是实现并发控制最直观的做法是2PL(两阶段锁),之后为了减少读写之间的加锁冲突,大多数数据库都采取了2PL + MVCC的实现方式,即通过维护多版本信息来让写请求和读请求可以并发执行。而MVCC的实现中有十分依赖一个全局递增的事务执行序列,用来判断事务开始的先后,从而寻找正确的可读历史版本。而这个全局递增的序列在分布式的数据库中变的十分复杂,原因是机器间的时钟存在误差,并且这种误差的范围不确定。常见的做法是通过节点间的信息交互来确定跨节点的时间先后,如Lamport时钟[7]。但更多的信息交互带来了瓶颈,从而限制了集群的扩展规模以及跨地域的复制。Spanner[8]提出了一种新的思路,通过引入GPS和原子钟的校准,在全球范围内,将不同节点的时钟误差限制到一个确定的范围内。这个确定的误差范围非常重要,因为他支持了一种可能:通过适度的等待保证事务的正确性。

具体的说,这里Spanner需要保证External Consistency,即如果事务T2开始在T1 commit之后, 那么T2拿到的Commit Timestamp一定要大于T1的Timestamp。Spanner实现这个保证的做法,是让事务等待其拿到的Commit Timestamp真正过去后才真正提交(Commit Wait):

spanner

如上图[9]所示,事务Commit时,首先通过TrueTime API获取一个当前时间now,这个时间是一个范围now = [t - ε, t + ε],那么这个t - ε就是作为这个事务的Commit Timestamp,之后要一直等待到TrueTime API返回的当前时间now.earliest > s时,才可以安全的开始做真正的comimt,这也就保证,这个事务commit以后,其他事务再也不会拿到更小的Timestamp。

总结下, 采用Partition Engine策略的NewSQL,向用户屏蔽了分布式事务等细节,提供统一的数据库服务,简化了用户使用。Spanner,CockroachDB,Oceanbase,TIDB都属于这种类型。这里有个值得探讨的问题:由于大多数分库分表的实现也会通过中间件的引入来屏蔽分布式事务等实现细节,同样采用类Multi Paxos这样的一致性协议来保证副本一致,同样对用户提供统一的数据库访问,那么相较而言,Partition Engine的策略优势又有多大呢?在大企业,银行等场景下, 这两种方案或许正在正面竞争,我们拭目以待[11]

Partition Storage: Aurora、PolarDB

继续将分片的分界线下移,到事务及索引系统的下层。这个时候由于Part 1部分保留了完整的事务系统,已经不是无状态的,通常会保留单独的节点来处理服务。这样Part 1主要保留了计算相关逻辑,而Part 2负责了存储相关的像REDO,刷脏以及故障恢复。因此这种结构也就是我们常说的计算存储分离架构,也被称为Share Storage架构。

这种策略由于关键的事务系统并没有做分片处理,也避免了分布式事务的需要。而更多的精力放在了存储层的数据交互及高效的实现。这个方向最早的工业实现是Amazon的Aurora[12]。Aurora的计算节点层保留了锁,事务管理,死锁检测等影响请求能否执行成功的模块,对存储节点来说只需要执行持久化操作而不需要Vote。另外,计算节点还维护了全局递增的日志序列号LSN,通过跟存储节点的交互可以知道当前日志在所有分片上完成持久化的LSN位置,来进行实物提交或缓存淘汰的决策。因此,Aurora可以避免Partition Engine架构中面临的分布式事务的问题[18]

Auraro认为计算节点与存储节点之间,存储节点的分片副本之间的网络交互会成为整个系统的瓶颈。而这些数据交互中的大量Page信息本身是可以通过REDO信息构建的,也就是说有大量的网络交互是冗余的,因此Aurora提出了“Log is Database”,也就是所有的节点间网络交互全部只传输REDO,每个节点本地自己在通过REDO的重放来构建需要的Page信息。

aurora

从上图可以看出,计算节点和存储节点之间传输的只有REDO,也就是是说,传统数据库中存储相关的部分从计算节点移到了存储节点中,这些功能包括:

  • REDO日志的持久化
  • 脏页的生成与持久化
  • Recovery过程的REDO重放
  • 快照及备份

这些功能对数据库整体的性能有非常大的影响:首先,根据ARIES[17]安全性要求,只有当REDO落盘后事务才能提交,这使得REDO的写速度很容易成为性能瓶颈;其次,当Buffer Pool接近满时,如果不能及时对Page做刷脏,后续的请求就会由于获取不到内存而变慢;最后,节点发生故障重启时,在完成REDO重放之前是无法对外提供服务的,因此这个时间会直接影响数据库的可用性。而在Aurora中,由于存储节点中对数据页做了分片打散,这些功能可以由不同的节点负责,得以并发执行,充分利用多节点的资源获得更大的容量,更好的性能。

PolarDB

2017年,由于RDMA的出现及普及,大大加快了网络间的网络传输速率,PolarDB[15]认为未来网络的速度会接近总线速度,也就是瓶颈不再是网络,而是软件栈。因此PolarDB采用新硬件结合Bypass Kernel的方式来实现高效的共享盘实现,进而支撑高效的数据库服务。由于PolarDB的分片层次更低,也就能做到更好的生态兼容,也就是为什么PolarDB能够很快的做到社区版本的全覆盖。副本间PoalrDB采用了ParalleRaft来允许一定范围内的乱序确认,乱序Commit以及乱序Apply。

polardb

采用Partition Storage的策略的NewSQL,由于保持了完整的计算层,所以相对于传统数据库需要用户感知的变化非常少,能过做到更大程度的生态兼容。同时也因为只有存储层做了分片和打散,可扩展性不如上面提到的两种方案。在计算存储分离的基础上,Microsoft的Socrates[13]提出了进一步将Log模块拆分,实现Durability和Available的分离;Oracal的Cache Fusion[14]通过增加计算节点间共享的Memory来获得多点写及快速Recovery,总体来讲他们都属于Partition Storage这个范畴。

对比

newsql diff

可以看出,如果我们以可扩展性为横坐标,易用及兼容生态作为纵坐标,可以得到如上图所示的坐标轴,越往右上角当然约理想,但现实是二者很难兼得,需要作出一定的取舍。首先来看传统的单节数据库其实就是不易扩展的极端,同时由于他自己就是生态所以兼容生态方面满分。另一个极端就是传统的分库分表实现,良好的分片设计下这种策略能一定程度下获得接近线性的扩展性。但需要做业务改造,并且需要外部处理分布式事务,分布式Query这种棘手的问题。之后以Spanner为代表的的Partition Engine类型的NewSQL由于较高的分片层次,可以获得接近传统分库分表的扩展性,因此容易在TPCC这样的场景下取得好成绩,但其需要做业务改造也是一个大的限制。以Aurora及PolarDB为代表的的Partition Storage的NewSQL则更倾向于良好的生态兼容,几乎为零的业务改造,来交换了一定程度的可扩展性。

使用场景上来看,大企业,银行等对用户对扩展性要求较高,可以接受业务改造的情况下,类Spanner的NewSQL及传统分库分表的实现正在正面竞争。而Aurora和PolarDB则在云数据库的场景下一统江湖。

参考

[1] DeCandia, Giuseppe, et al. “Dynamo: amazon’s highly available key-value store.” ACM SIGOPS operating systems review 41.6 (2007): 205-220.

[2] Chang, Fay, et al. “Bigtable: A distributed storage system for structured data.” ACM Transactions on Computer Systems (TOCS) 26.2 (2008): 1-26.

[3] Why Enterprises Are ­Uninterested in NoSQL

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

[5]浅析数据库并发控制

[6] Chandra, Tushar D., Robert Griesemer, and Joshua Redstone. “Paxos made live: an engineering perspective.” Proceedings of the twenty-sixth annual ACM symposium on Principles of distributed computing. 2007.

[7] Lamport, Leslie. “Time, clocks, and the ordering of events in a distributed system.” Concurrency: the Works of Leslie Lamport. 2019. 179-196.

[8] Corbett, James C., et al. “Spanner: Google’s globally distributed database.” ACM Transactions on Computer Systems (TOCS) 31.3 (2013): 1-22.

[9] Spanner的分布式事务实现

[10] Pavlo, Andrew, and Matthew Aslett. “What’s really new with NewSQL?.” ACM Sigmod Record 45.2 (2016): 45-55.

[11] 分库分表 or NewSQL数据库?

[12] Verbitski, Alexandre, et al. “Amazon aurora: Design considerations for high throughput cloud-native relational databases.” Proceedings of the 2017 ACM International Conference on Management of Data. 2017.

[13] Antonopoulos, Panagiotis, et al. “Socrates: the new SQL server in the cloud.” Proceedings of the 2019 International Conference on Management of Data. 2019.

[14] Lahiri, Tirthankar, et al. “Cache fusion: Extending shared-disk clusters with shared caches.” VLDB. Vol. 1. 2001.

[15] Cao, Wei, et al. “PolarFS: an ultra-low latency and failure resilient distributed file system for shared storage cloud database.” Proceedings of the VLDB Endowment 11.12 (2018): 1849-1862.

[16] Depoutovitch, Alex, et al. “Taurus Database: How to be Fast, Available, and Frugal in the Cloud.” Proceedings of the 2020 ACM SIGMOD International Conference on Management of Data. 2020.

[17] Mohan, Chandrasekaran, et al. “ARIES: a transaction recovery method supporting fine-granularity locking and partial rollbacks using write-ahead logging.” ACM Transactions on Database Systems (TODS) 17.1 (1992): 94-162.

[18] Amazon Aurora: On Avoiding Distributed Consensus for I/Os, Commits, and Membership Changes

更多了不起的数据库

AliSQL · 内核新特性 · 2020技术总结

$
0
0

这么快到2020年底了,看到Oracle 21c文档中的一些功能,想想AliSQL在2020年也做了不少事情,也有必要总结分享一下。为了让大家更好地知道有哪些特性,可以在哪些业务场景中使用到,也是为了2021年更好的向前发展。在年初时计划的一些企业级功能基本上都实现了,并且在过程中特别强调了功能的场景通用性,不再是从某个行业某个特定业务或应用场景设计(比如电商秒杀),而是从云上众多用户的不同场景出发,并且不需要用户应用或SQL改造配合(直接一个开关就可以开启的),还要求在RDS 56/57/80三个主流版本上都有同样的体验,从云场景而生并为云场景服务的技术,都是云原生技术。这一目标角度的调整的确是给自己加了不少难度,但研发让所有云上用户都能轻松受益享受技术红利的新技术和功能,仍然让整个AliSQL团队兴奋不已

先来看一下2020年已经上线并且使用非常广泛几个功能和技术:

  • InnoDB Mutex Tuning,InnoDB使用B+ Tree来组织数据,当树的Branch节点需要分裂时,可能会层层向上直到根部,这个分裂是非常昂贵并且难以并发的操作,会严重影响性能。AliSQL在InnoDB的Index Mutex上做了优化,减少了所有页面分列的代价,改进算法优化了分裂的频率和深度,使得TPCC的性能测试提升了35-45%,并且在56/57/80三个版本上都已支持,已在100000+用户的数十万实例上平稳运行一周年多了。

  • Dynamic Thread Pool,AliSQL团队通过技术创新和改进,于年初在云上默认打开了线程池,56/57/80三个版本都已支持,已有100000+用户的数十万实例运行在线程池下,也就是让线程池不再挑场景,可以轻松胜任高并发的混合请求场景,而不像其他分支(如Percona或MariaDB)的线程池那样要求都是短平快的查询。到目前为止,我们应当是唯一一家默认开启线程池功能的RDS供应商,可以让实例支持更高的连接数,可以承受更强的短连接能力,可以节约宝贵的CPU和内存资源,可以提升上千并发(真实应用中连接数上千很容易)下的性能,让RDS客户在用更少资源得到更高QPS&TPS,享受到实实在在的技术红利。

  • Faster DDL,在遇到几次用户业务高峰对小表做DDL的性能抖动后,AliSQL团队深入分析了整个DDL过程,发现在DDL过程中Buffer Pool的管理方式不够优雅,就对其进行了改进,并在56/57/80三个版本上同步实现并发布上线,目前已有100000+用户的数十万实例开启了此功能,极大的缩短了业务高峰进行DDL所需的时间,有效地消除了稳定性风险,让阿里云RDS客户享受到了实实在在的好处。

  • Performance Agent,为了更好地排查性能问题,AliSQL团队研发了一个Performance Agent插件,以秒级频率和完全无锁的方式(相比在SQL中执行show global status命令)输出了数百个性能指标,并且提供了内存视图来查询和分析这些性能数据,使得我们和客户可以基于同一份性能数据来快速高效地分析性能问题。同样在56/57/80三个版本上都实现了Performance Agent,已有100000+用户的数十万实例每秒钟都在记录实时性能数据。

再来看一下2020年上线但还未达到上述使用量的功能和技术:

  • Binlog In Redo,MySQL为了保证Binlog和InnoDB数据的一至性,使用两阶段XA事务来协调Binlog的落盘和InnoDB Redo Log的落盘,这时每一个事务的提交需要做两次刷盘操作,这对一些对时延(RT,Response Time)敏感的应用,或者使用云盘的实例是不够理想的。AliSQL团队对事务提交过程进行了仔细严谨的梳理,提出了将Binlog写到Redo Log的方案,使得事务提交时不再需要同步Binlog文件,仅需要同步Redo Log,相当于减少了一次落盘操作,从而在保证数据一致性的基础上缩短了事务提交操作的延时,并且提升了事务提交的性能。

  • Fast Query Cache,在架构设计中Cache为王,看Oracle 21中增加了对持久化内存的支持,累计达到7层缓存设计,我们当然不能放过Query Cache。Query Cache具有对应用完全透明的优势,在仔细压测分析原生Query Cache功能后,发现性能不够理想后,设计出了版本化的Fast Query Cache技术,解决了原生Query Cache中的并发性限制,使得纯读性能最高可以提升100%+,并且在写场景中基本没有额外性能损耗。欢迎大家来使用Fast Query Cache,如今已经可以自行调整内存参数进行开启,如果你还在用原生的Query Cache,则可以升级到较新的版本,来享受AliSQL带来了Cache技术红利。

  • Recycle Bin & Data Protect,在2020年业界发生了几次由删库删表引起的安全事故,我们深表痛惜和警觉,AliSQL团队设计了Recycle Bin和Data Protect功能,以主动应对类似风险。Recycle Bin会自动将删除的表先移到回收站,直到进一步清空回收站(可以额外控制权限)时才会真正删除表,在Purge之前都可以从回收站中还原数据;而Data Protect则可以严格限制可以执行删除操作的用户,从权限和目标两个维度进行控制,以帮助大家保障数据安全(需要自行开启)。

再来提前看一下2020年研发完成还未上线的功能和技术(会在2021年Q1发布上线):

  • Flashback Query,有时侯可能面临执行错误DML语句(比如Delete/Update没有准确的Where条件)的紧急场景,会要求我们快速将数据恢复到错误DML语句执行之前的数据状态,过去我们需要去下载、分析Binlog文件,再利用工具去生成反向操作的SQL语句进行恢复,步骤比较复杂。在具备Flashback Query技术后,可以直接穿越到过去的时间点(误操作之前的)执行SQL语句,将准确的数据找出来进行快速恢复。

  • Buffer Pool Freely Resize,为了提升RDS的内存弹性,AliSQL团队仔细研究分析了InnoDB Buffer Pool动态调整的逻辑,识别并优化了相关逻辑,使得在线缩减Buffer Pool大小变得基本没有风险,让不重启实例的内存规格弹性在技术上先成为可能

  • MultiBlock Read,针对用户反馈的大表DDL或大查询慢的问题,AliSQL团队仔细分析了Buffer IO的代码逻辑,并找到了其中的欠缺,每次只读一个块是非常低效的行为,对于连续的数据块访问(Range Scan或Full Table Scan)可以一次性读取多个块来提升IO效率,从而使得大表扫描的速度提以大幅度提升,也就是说大查询或DDL的时间有望缩短25-50%左右

  • Faster LRU Scan,有相当一部份客户的业务发展非常好,数据量激增,给系统带来压力,在分析和支持客户发展的过程中,AliSQL团队发现原生MySQL的Buffer Pool LRU淘汰机制有欠缺,在访问的数据量远大于内存规格时,会进入低效的Singe Page Flush淘汰机制,存在着逻辑上的欠缺。对此我们优化和设计了一种更好的淘态机制,使得同等情况下QPS & TPS可以提升10-20%,让客户在同等的资源下可以有更高的性能,为客户切切实实地节约成本。

  • Automatic Hot Queue,这是对电商热杀秒杀功能的一个技术升级,原来需要应用更改SQL传入热点队列的标识,现在则可以自动分析DML语句中的Where条件,如果是PK或UK访问,则自动计算一个热点队列标识,进行并发控制排队,无须应用更改SQL传入热点标识,只需要简简单单地在后台打开开关

我们是第一个提供RDS 80服务的云产商,给社区排查和反馈了不少问题和缺陷,其中有一些是比较严重的会导致Crash的,在这里就不一一细说了。回顾2020年,真是相当忙而快乐的一年,忙是我们在努力为客户创建价值(提供技术红利),快乐是我们的一些技术和功能在云上得到大量的使用,并且还有一些非常有意义的事情(空间压缩、安全审计等方面)在等着我们去做。这就是RDS AliSQL 2020年的技术总结!谢谢。

MySQL · 引擎特性 · page cleaner 算法

$
0
0

Page cleaner

刷脏流程

主要的代码和流程在参考文档 3,4 这种已经讲解的比较清楚了,一个 Coordinator 线程负责处理刷脏请求,计算刷脏的量,然后分配给几个 Worker 线程去刷不同的 Buffer Pool Instance, 完成刷脏后,Coordinator 线程进入下一轮刷脏。

Coordinator 和 Worker 之间通过 page_cleaner->slots[i]->state 来协同,page_cleaner_state_t 有四种状态,代码注释说明了状态之间的迁移。

/** State for page cleaner array slot */
enum page_cleaner_state_t {
  /** Not requested any yet.
    Moved from FINISHED by the coordinator. */
  PAGE_CLEANER_STATE_NONE = 0,
  /** Requested but not started flushing.
    Moved from NONE by the coordinator. */
  PAGE_CLEANER_STATE_REQUESTED,
  /** Flushing is on going.
    Moved from REQUESTED by the worker. */
  PAGE_CLEANER_STATE_FLUSHING,
  /** Flushing was finished.
    Moved from FLUSHING by the worker. */
  PAGE_CLEANER_STATE_FINISHED
};

Coordinate 入口函数 buf_flush_page_coordinator_thread,主循环刷脏逻辑: image.png

pc_sleep_if_needed

page cleaner 的循环刷脏周期是 1s,如果不足 1s 就需要 sleep,超过 1s 可能是刷脏太慢,不足 1s 可能是被其它线程唤醒的。

/* The page_cleaner skips sleep if the server is
   idle and there are no pending IOs in the buffer pool
   and there is work to do. */
if (srv_check_activity(last_activity) /*和上次循环对比,有没有新增的 activity */
    || buf_get_n_pending_read_ios() || n_flushed == 0) { /* 有没有 pending io */
  ret_sleep = pc_sleep_if_needed(next_loop_time, sig_count);

  if (srv_shutdown_state != SRV_SHUTDOWN_NONE) {
    break;
  }
} else if (ut_time_ms() > next_loop_time) {
  ret_sleep = OS_SYNC_TIME_EXCEEDED;
} else {
  ret_sleep = 0;
}

是否持续缓慢刷脏

错误日志里有时候会看到这样的日志:

Page cleaner took xx ms to flush xx and evict xx pages

这个表示上一轮刷脏进行的比较缓慢,首先 ret_sleep == OS_SYNC_TIME_EXCEEDED, 并且本轮刷脏和上一轮刷脏超过 3s,warn_interval 控制输出日志的频率,如果持续打日志,就要看看 IO 延迟了。

sync flush

Sync flush 不受 io_capacity/io_capacity_max 的限制,所以会对性能产生比较大的影响。

/* Note that the buf_flush_sync_lsn which is the maximum lsn that
 * primary must flush to disk, so if it greater than the oldest_lsn,
 * we still need to wake up page cleaner thread to flush. */
oldest_lsn = buf_pool_get_oldest_modification_lwm();
if (ret_sleep != OS_SYNC_TIME_EXCEEDED && srv_flush_sync &&
    oldest_lsn < buf_flush_sync_lsn) {

  /* Request flushing for threads */
  pc_request(ULINT_MAX, buf_flush_sync_lsn);

  /* Coordinator also treats requests */
  while (pc_flush_slot() > 0) {
  }

  pc_wait_finished(&n_flushed_lru, &n_flushed_list);
}

pc_request 是 Coordinate 分发的入口,有两个限制参数,page 数量或者 lsn,sync flush 只有对 lsn 的限制。 pc_flush_slot 和 pc_wait_finished 是刷脏和等待 worker 线程返回。

TIPS: pc_ 前缀是 page cleaner 的缩写

normal flush

当系统有负载的时候,为了避免频繁刷脏影响用户,会计算出每次刷脏的 page 数量

else if (srv_check_activity(last_activity)) {
  ulint n_to_flush;
  lsn_t lsn_limit = 0;

  /* Estimate pages from flush_list to be flushed */
  if (ret_sleep == OS_SYNC_TIME_EXCEEDED) {
    last_activity = srv_get_activity_count();
    n_to_flush =
      page_cleaner_flush_pages_recommendation(&lsn_limit, last_pages);
  } else {
    n_to_flush = 0;
  }
            
  /* Request flushing for threads */
  pc_request(n_to_flush, lsn_limit);

  /* Coordinator also treats requests */
  while (pc_flush_slot() > 0) {
  }

  pc_wait_finished(&n_flushed_lru, &n_flushed_list);
}

idle flush

系统空闲的时候不用担心刷脏影响用户线程,可以使用最大的 io_capacity 刷脏。RDS 有参数 srv_idle_flush_pct 控制刷脏比例,默认是 100%。

} else if (ret_sleep == OS_SYNC_TIME_EXCEEDED) {
  /* no activity, slept enough */
  buf_flush_lists(PCT_IO(100), LSN_MAX, &n_flushed);
  ...
}

异步刷脏算法

这篇文章 中已经把刷脏算法讲解的非常清楚了,这块就把公式列一下。

/* 总的计算公式,n_pages 是本轮尝试刷脏的量,是三个值的平均 */
#define PCT_IO(p) ((ulong)(innodb_io_capacity * ((double)(p) / 100.0)))
n_pages = (PCT_IO(pct_total) + avg_page_rate + pages_for_lsn) / 3;
if (n_pages > innodb_max_io_capacity) {
     n_pages = innodb_max_io_capacity;
}

avg_page_rate 

page_rate = sum_pages / time_elapsed; // 一个计算周期内的刷脏速度
avg_page_rate = (avg_page_rate + page_rate) / 2; // 平均速度

其中 page_rate 和 lsn_rate 都是 srv_flushing_avg_loops 秒去尝试更新一次,避免刷脏抖动太快。avg_page_rate 加入计算,也是为了平缓刷脏。

F(avg_page_rate) = F(page_rate, srv_flushing_avg_loops);

pages_for_lsn 

lsn_rate = cur_lsn - prev_lsn / time_elapsed; // 一个计算周期内的lsn产生速度
lsn_avg_rate = (lsn_avg_rate + lsn_rate) / 2; // 平均速度

// lsn_avg_rate转换为脏页数
lsn_t target_lsn = oldest_lsn + lsn_avg_rate * buf_flush_lsn_scan_factor;
sum_pages_for_lsn = 计算flush list中所有小于targe_lsn的脏页数
sum_pages_for_lsn /= buf_flush_lsn_scan_factor;
pages_for_lsn = min(sum_pages_for_lsn, innodb_max_io_capacity * 2);

LSN 的平均产生速度包含了多少个脏页,这个参考因素可以快速 Get 到流量的变化,一定程度上增大或者减缓刷脏。

F(pages_for_lsn) = 
F(**lsn_rate**, srv_flushing_avg_loops, buf_flush_lsn_scan_factor, innodb_max_io_capacity)

Note: 这部分扫描每个 buffer pool instance 找脏页数量的时候,5.7.6 做了优化(参考文档2),每一批刷的脏页数,在各个 buffer pool instance 中根据里面脏页数量的比列分配,这样就可以做到均衡刷脏。因为各个 buffer pool instance 中的脏页比例可能是不一样的。

PCT_IO(pct_total) 

pct_total = max(pct_for_dirty, pct_for_lsn);

因为 Redo Log 的空间是有限的,Buffer Pool 的资源是有限的,并且 Buffer Pool 中的脏页 oldest_modification_lsn 限制了 checkpoint lsn, 间接的限制了 Redo 空间的使用。所以脏页的推进会释放 buffer pool 和 redo 的可使用空间,因此在刷脏的时候也需要参考当前脏页的比例和 Redo log 的 ‘age’。

pct_for_dirty
double dirty_pct = buf_get_modified_ratio_pct();
  pct_for_dirty = (dirty_pct * 100) /
(srv_max_buf_pool_modified_pct + 1)

除了 dirty_pct 之外,srv_max_dirty_pages_pct_lwm 和 srv_max_buf_pool_modified_pct 也影响着 pct_for_dirty 的值。具体逻辑:

if (srv_max_dirty_pages_pct_lwm == 0) {
  /* The user has not set the option to preflush dirty
     pages as we approach the high water mark. */
  if (dirty_pct >= srv_max_buf_pool_modified_pct) {
    /* We have crossed the high water mark of dirty
       pages In this case we start flushing at 100% of
       innodb_io_capacity. */
    return (100);
  }
} else if (dirty_pct >= srv_max_dirty_pages_pct_lwm) {
  /* We should start flushing pages gradually. */
  return (static_cast<ulint>((dirty_pct * 100) /
        (srv_max_buf_pool_modified_pct + 1)));
}

return (0);
F(pct_for_dirty) = F(dirty_pct, srv_max_dirty_pages_pct_lwm, srv_max_buf_pool_modified_pct);
pct_for_lsn
#define PCT_IO(p) ((ulong)(srv_io_capacity * ((double)(p) / 100.0)))
age = cur_lsn > adjusted_oldest_lsn ? cur_lsn - adjusted_oldest_lsn : 0;
auto limit_for_age = log_get_max_modified_age_async();
lsn_age_factor = (age * 100) / limit_for_age;

  pct_for_lsn = (srv_max_io_capacity / srv_io_capacity) *
(lsn_age_factor * sqrt((double)lsn_age_factor)) /
  7.5

n_pages = PCT_IO(pct_for_lsn)
  = srv_io_capacity *
  (srv_max_io_capacity / srv_io_capacity) *
(lsn_age_factor * sqrt((double)lsn_age_factor)) /
  7.5 / 100
  = srv_max_io_capacity *
(lsn_age_factor * sqrt((double)lsn_age_factor)) /
  7.5 / 100

自适应刷脏主要影响的值是 pct_for_lsn,由开关 srv_adaptive_flushing 控制,但也不完全由开关控制。完整的逻辑,还是看代码比较直观:

static ulint af_get_pct_for_lsn(lsn_t age) /*!< in: current age of LSN. */
{
  const lsn_t log_margin =
    log_translate_sn_to_lsn(log_free_check_margin(*log_sys));

  ut_a(log_sys->lsn_capacity_for_free_check > log_margin);

  const lsn_t log_capacity = log_sys->lsn_capacity_for_free_check - log_margin;

  lsn_t lsn_age_factor;
  lsn_t af_lwm = (srv_adaptive_flushing_lwm * log_capacity) / 100;

  if (age < af_lwm) {
    /* No adaptive flushing. */
    return (0);
  }

  auto limit_for_age = log_get_max_modified_age_async();
  ut_a(limit_for_age >= log_margin);
  limit_for_age -= log_margin;

  if (age < limit_for_age && !srv_adaptive_flushing) {
    /* We have still not reached the max_async point and
       the user has disabled adaptive flushing. */
    return (0);
  }

  /* If we are here then we know that either:
     1) User has enabled adaptive flushing
     2) User may have disabled adaptive flushing but we have reached
     max_async_age. */
  lsn_age_factor = (age * 100) / limit_for_age;

  ut_ad(srv_max_io_capacity >= srv_io_capacity);
  return (static_cast<ulint>(((srv_max_io_capacity / srv_io_capacity) *
          (lsn_age_factor * sqrt((double)lsn_age_factor))) /
        7.5));
}
F(pct_for_lsn) = F(**age**, log_capacity, srv_adaptive_flushing_lwm,
                  log_sys->max_modified_age_async, srv_adaptive_flushing, srv_max_io_capacity);

如果最终选择了 pct_for_lsn, 那么公式中带入会把 srv_io_capacity 约掉。

同步刷脏算法

同步刷脏的触发主要在 checkpoint 线程中,函数:log_consider_sync_flush

lsn_t flush_up_to = oldest_lsn;  

/* Redo 的 age 超过 log.max_modified_age_sync 触发 sync flush */
if (current_lsn - oldest_lsn > log.max_modified_age_sync) {
  ut_a(current_lsn > log.max_modified_age_sync || in_recover_mode());

  flush_up_to = current_lsn - log.max_modified_age_sync;
}

/* 或者其他线程显示的请求到某个 LSN */
const lsn_t requested_checkpoint_lsn = log.requested_checkpoint_lsn;

if (requested_checkpoint_lsn > flush_up_to) {
  flush_up_to = requested_checkpoint_lsn;
}

if (flush_up_to > oldest_lsn) {
  log_preflush_pool_modified_pages(log, flush_up_to);
}
F(flush_up_to) = F(**age**, log.max_modified_age_sync)

开关控制 srv_flush_sync 在 log_preflush_pool_modified_pages 决定是否做真正的 sync_flush.

相关参数

  • innodb_page_cleaners page cleaner 线程的数量,因为每一个 Buffer Pool Instance 同时只会有一个 pager cleaner 线程处理,所以配置的线程数不能超过 innodb_buffer_pool_instances大小,超过就配置相同大小。

  • innodb_max_dirty_pages_pct_lwm 代码中对应变量:srv_max_dirty_pages_pct_lwm, 如果系统中脏页比例超过这个值, 将会计算 pct_for_dirty 纳入到 PCT_IO(pct_total) 中。

  • innodb_max_dirty_pages_pct代码中对应变量:srv_max_buf_pool_modified_pct, 系统中最大脏页比例,和 srv_max_dirty_pages_pct_lwm 一起,影响 pct_for_dirty 的计算结果。

  • innodb_adaptive_flushing_lwm代码中对应变量:srv_adaptive_flushing_lwm,当 age (所有脏页占用的 lsn 大小) 小于 log_capacity 的srv_adaptive_flushing_lwm 比例,pc_for_lsn 为 0,也就是不启用 redo 自适应模式刷脏。

  • innodb_adaptive_flushing代码中对应变量:srv_adaptive_flushing,是否使用 redo 自适应模式刷脏,如果为 OFF, 只有 age 大于 log_sys->max_modified_age_async 才会采用 redo 自适应模式刷脏,如果为 ON, 满足 srv_adaptive_flushing_lwm 条件就采用 redo 自适应模式刷脏。

  • innodb_io_capacity代码中对应变量:srv_io_capacity,是 PCT_IO(pct_total) 的基数(但是不影响 pc_for_lsn),空闲刷脏的最大值。

    #define PCT_IO(p) ((ulong)(srv_io_capacity * ((double)(p) / 100.0)))

  • innodb_io_capacity_max代码中对应变量:srv_max_io_capacity, 表示系统中每次能刷的最大值。会影响 pc_for_lsn 的算法。

  • innodb_flushing_avg_loops代码中对应变量:srv_flushing_avg_loops,计算 lsn_avg_rate 和 avg_page_rate 的频率,为了让刷脏尽可能的平缓,默认 30s 更新一次。lsn_avg_rate 将会影响 pages_for_lsn 的计算,avg_page_rate 直接参数最终的 n_pages 计算。

  • innodb_flush_sync代码中对应变量:srv_flush_sync,是否触发激烈刷脏,如果是 sync_flush 的话,系统刷脏不受 srv_io_capacity 和 srv_max_io_capacity 控制,而是刷脏页到一个指定的 lsn。 checkpoint 线程会不断检测是否需要 sync_flush, 如果当前的 lsn 和 log.available_for_checkpoint_lsn 差距超过 log.max_modified_age_sync 或者有其它指定刷脏的请求(requested_checkpoint_lsn),就尝试激烈刷脏。

  • innodb_lru_scan_depth Free page 不够,从 lru 中刷脏页使用,暂时不考虑刷 lru 的情况。

参考文档

  1. 官方文档 Configuring Buffer Pool Flushing 
  2. 5.7.6 InnoDB page flush 优化 
  3. pager cleaner from 利兵 
  4. Innodb缓冲池刷脏的多线程实现 

PolarDB · 引擎特性 · 历史库

$
0
0

历史数据归档的问题

大部分业务数据的读写特征,都是最新产生的数据会更频繁的被读取或者更新,而更久之前的数据(如1年之前的聊天记录,或者订单信息)则很少会被访问, 而随着业务运行时间的增加,数据库系统中会沉淀大量很少甚至不会被访问到的数据,这部分数据和最新产生的数据混合在一起会产生一系列问题:

  • 历史数据和最新的数据存储在一个数据数据库系统中,导致磁盘空间不足。
  • 大量数据共享数据库内存缓存空间,磁盘IOPS等,导致性能问题。
  • 数据量太大导致数据备份时间过长甚至失败,而且备份出来的数据存放也是一个问题。

针对此问题,一种做法做法是对历史数据做归档,将长期不使用的数据迁移至以文件形式存储的廉价存储设备上,比如阿里云OSS或者阿里云数据库DBS服务。然而实际业务系统中,历史数据并不完全是静态的,针对几个月甚至几年前的“旧”数据依旧存在实时的,低频的查询甚至更新需求,在阿里巴巴内部,类似淘宝/天猫的历史订单查询,企业级办公软件钉钉几年前的聊天信息查询,菜鸟海量物流的历史物流订单详情等。 为了解决历史数据的读取和更新问题,可以使用一个单独的数据库系统作为归档数据的存储目的地,称之为历史库. 业务对单独的历史库系统一般具有如下的诉求:

  • 具有非常巨大的容量,业务可以放心的持续将线上数据保存到历史库中,而不用担心容量问题。
  • 支持和在线数据库系统一样的访问接口,如都是MySQL协议等,业务可以和在线业务相同的接口访问历史库
  • 必须具有低廉的成本,如使用压缩减少数据所占磁盘空间,廉价存储介质等,确保可以使用较大的代价保存海量的数据。
  • 具备一定的读写能力,满足低频的读写需求。

作为世界上使用最广泛的开源数据库系统,MySQL生态中一直缺乏一个好用的历史数据归档存储方案,既满足大容量低成本同时又具备一定的读写能力。虽然业界曾经推出过一些高压缩引擎如TokuDB, MyRocks等,但是受限于单物理机磁盘容量限制,存储的数据量有限。PolarDB历史库的推出即为满足这一需求。

PolarDB历史库产品

阿里云数据库团队将公司内部广泛使用的高压缩引擎X-Engine引擎与PolarDB相结合,使得PolarDB同时支持InnoDB引擎和X-Engine引擎,其中InnoDB引擎负责在线业务的高性能混合读写,X-Engine引擎负责归档数据的低频读写。

polardb_xengine_single_name


在PolarDB双引擎的架构上,我们推出了一款主要基于X-Engine引擎存储的数据库产品:PolarDB历史库。历史库单实例的存储空间上限为200TB,结合X-Engine引擎3~5倍的压缩能力,可提供近600TB~1PB的原始数据存储能力,能满足绝大部分客户的历史数据归档对存储容量的需求。

使用PolarDB 历史库(X-Engine)具有如下几个优势:

  • 超大的容量,200TB和存储空间加上X-Engine数据压缩能力,可提供超500TB以上的原始数据存储容量,同时容量按需付费,不用预先为未来的数据增长预备存储空间。
  • PolarDB历史库与官方MySQL的协议一致, 相比于将历史数据备份到HBase等NoSQL产品, 业务应用程序不用修改代码即可同时访问在线库和历史库。
  • 借助PolarDB底层共享存储提供的快速备份能力,再大的实例也可以实现对数据的快速备份,备份数据上传到OSS等廉价存储设备,确保数据永不丢失。

由于PolarDB 历史库提供了超大存储容量,它可以同时作为多个业务历史数据的汇聚地,以方便对所有历史数据进行集中存储和管理,用户可以在如下几个场景中使用历史库:

  • 将PolarDB 历史库作为线下自建数据库实例的冷数据存储地,线下自建数据库服务包括且不限于MySQL/Postgre/Sql Server等关系数据库。
  • 将PolarDB历史库作为阿里云RDS MySQL或者PolarDB MySQL数据库服务的归档存储地, 将较少访问到的历史数据迁移到PolarDB X-Engine中存储,释放在线实例的空间以降低成本并提升性能。
  • 直接将PolarDB 历史库作为大容量关系数据库使用,以满足一些写入数据量巨大,但读频次较低的业务的需求(如系统监控日志等)

polardb_xengine_single_name

在线库和历史库之间的数据迁移,可以使用阿里云DTS或者DMS进行,其中DTS可以持续试试的将在线库的内容同步到历史库,而DMS则可以周期性的将在线数据批量导入到历史库。

PolarDB历史库技术架构

PolarDB历史库功能的推出依赖阿里巴巴数据团队之前在数据库和存储等方向上的创新和突破:

  • 阿里巴巴自研的基于LSM-tree架构的存储引擎X-Engine提供了强大的数据压缩能力,满足了归档数据库对低存储成本的要求。
  • PolarDB借助于共享分布式存储服务,实现了存储容量在线平滑扩容,同时计算节点和存储节点之间采用高速网络互联,并通过RDMA协议进行数据传输,使I/O性能不再成为瓶颈。集成到PolarDB的X-Engine引擎同样获得了这些技术优势。 下面我们别讲解X-Engine引擎的基础特点,以及如何将X-Engine与PolarDB相结合以提供一个有竞争力的历史库技术方案。

X-Engine存储引擎

PolarDB历史库通过引入X-Engine获得存储空间节省的优势,X-Engine引擎可以用如下几个关键点对其进行描述:

  • X-Engine使用了LSM-Tree的分层架构,最近写入的热点数据和历史写入冷数据分开索引,同时创新性的使用事务流水线技术,把事务处理的几个阶段并行起来,极大提升了写入吞吐。
  • 分层存储底层的数据是大部分时候为静态只读,在数据页中所有记录采用前缀编码,同时每个数据页中的数据都是紧凑排列不会留空洞,最后底层数据都会默认进行压缩,因此相比原始数据可获得数倍的空间压缩。
  • X-Engine对传统LSM-tree性能影响比较大的Compaction过程做了大量优化,如拆分数据存储粒度,利用数据更新热点较为集中的特征,尽可能的在合并过程中复用数据。精细化控制LSM的形状,减少I/O和计算代价,有效缓解了合并过程中的空间增大。 X-Engine本身的实现非常复杂,远非几句话可描述,本篇不对其展开详细讲述,对其技术细节的解读可以参看X-Engine简介polardb_xengine_single_name X-Engine在阿里巴巴集团内部就作为一个自研引擎集成到AliSQL之中,也集成到公有云RDS MySQL当中,作为归档引擎售卖,而现在我们将其集成到了PolarDB当中。

PolarDB双引擎

PolarDB的最初版本是基于InnoDB引擎设计的,其技术架构可以参见文章PolareDB产品架构,在InnoDB引擎上实现物理复制,并在此基础上支持一写多读已经非常具有技术挑战。X-Engine是一个完整独立的事务引擎,具有独立的REDO日志,磁盘数据管理,缓存管理,事务并发控制等模块,将X-Engine移植进PolarDB并实现双引擎的一写多读更具挑战。我们通过大量的工程创新将PolarDB带入双引擎时代:

  • 合并X-Engine的事务WAL日志流和InnoDB的REDO日志流,实现了一套日志流和传输通道同时服务于InnoDB引擎和X-Engine引擎,管控逻辑以及与共享存储的交互逻辑无需做任何改变,同时未来新增其他引擎时也可以复用发这套架构。
  • 将X-Engine的IO模块对接到PolarDB InnoDB所使用的用户态文件系统PFS上,如此实现InnoDB与X-Engine共享同一个分布式块设备. 同时依靠底层分布式存储实现了快速备份。
  • 在X-Engine中实现了基于WAL日志的物理复制功能,并且一步到位的引入并行WAL回放机制,实现了RW节点与RO节点之间毫秒级别的复制延迟。在此基础之上,我们实现了在RO上提供支持事务一致性读的能力。

除了涉及到X-Engine支持一写多读需要支持的功能改造之外,PolarDB X-Engine还有很多项工程改进,如针对历史库场景大表DDL的问题,除了部分支持instant DDL的schema变更操作,X-Engine也支持并行DDL功能,对那些需要copy表的DDL操作进行加速。

polardb_xengine_single_name

在PolarDB双引擎架构下,我们实现了在一套代码下支持两个事务引擎的一写多读,保证了PolarDB产品架构的简洁和一致用户体验。

历史库单计算节点架构

PolarDB集群版基于共享存储实现了一写多读,集群中有一个主节点(可读可写)和至少一个只读节点,但是在历史库场景下,用户一般需要巨大的存储容量,但由于读写量较小,RW节点的计算资源都无法利用完,更无须RO节点提供的读扩展能力。在RW和RO规格相同时,相当于浪费了一半的计算资源。

借助X-Engine引擎带来的数据压缩能力,可以降低客户的存储成本, 而在历史库当中我们使用单RW节点来提供服务,省去了RO节点的计算资源成本。当然去除了RO节点,在灾难场景,如RW节点异常Crash时,需要更长的崩溃恢复时间。但是依靠底层分布式存储提供的高可用能力,我们依然提供了99.95%的可用性。

在历史库这样一个低频读写的场景(很多时候数据为异步批量导入到历史库),用稍低一点的可用性换取成本节省,对很多用户是可以接受的。而对于那些对可用性要求比较高的客户,我们也即将在PolarDB集群版本中提供X-Engine引擎,在降低存储成本的同时,提供与标准版一样的可用性指标。

历史库单节点架构下,日常不提供RO节点, 在需要对节点进行运维操作,如进行节点升级需要重启时,通过部署临时的RO节点并升级为RW节点的方式,可以降低升级操作对客户读写的影响。

polardb_xengine_single_name

单节点时节点替换流程如上图所示,影响业务的时间为替换过程中HA将流量从原RW切换到新的RW的瞬间。

MySQL · 内核特性 · 统计信息的现状和发展

$
0
0

简介

我们知道查询优化问题其实是一个搜索问题。基于代价的优化器 ( CBO ) 由三个模块构成:计划空间、搜索算法和代价估计 [1] ,分别负责“看到”最优执行计划和“看准”最优执行计划。如果不能“看准”最优执行计划,那么优化器基本上就是瞎忙活,甚至会产生严重的影响,出现运算量特别大的 SQL ,造成在线业务的抖动甚至崩溃。

image-20201225034048791

在上图中,代价估计用一个多项式表示,其系数 c 反应了硬件环境和算子特性,而数值 n 则由查询条件基于统计信息计算而得到。

现在主流的评估模型仍可溯源于 selinger 97 代价模型 [2] 。虽然各种机器学习模型从未停止过探索,但其效果上往往还不如极其简单的代价模型和比较精确的行数估计 ( cardinality estimation ) [3] 。统计信息的质量直接影响基数估算的准确性,其重要性是显而易见的。

需要注意的是,基于统计数据和即时采样都可以获得行数估计。事实上 MySQL 的 range optimizer 和 ref optimizer 就是重度依赖于索引采样 (index dive) ,而 join optimizer 则用索引统计信息 (index stats ,又称 record per key 或者 density vector ) 。索引采样需要计算谓词范围内的 page 数和 page 平均密度,对于小范围评估非常准确,对于大范围评估误差就比较大,此外,需要读索引数据, I/O 路径比较长,开销有时也是不可忽视的。

谈到统计信息,就会涉及管理框架和统计数据两个部分。令人遗憾的是,在 MySQL 里这两部分都是非常原始的。本文主要讨论管理框架缺陷,同时会涉及数据质量问题。

统计信息管理

我们知道 MySQL 遵循的是计算 ( SQL ,又称 Server ) 和存储 ( Storage Engine ) 分层的设计,在两层之间有一个 handler 接口层。每个存储引擎都需要提供自己的 handler 实现。MySQL 主流存储引擎仍然是 InnoDB 。本文所讨论的统计信息问题正是与 InnoDB 密切相关的。

由于分层设计,统计信息就会存在两种组织方式: 1) Storage Engine 提供采样接口,而在 Server 层基于样本完成各种指标计算,也可以是 2) Storage Engine 提供统计信息,只在 handler 层中提供一些简单的格式适配。除了 8.0 引入的直方图是在 Server 层基于 handler 采样接口实现的,其他统计信息,都是直接从 Storage Engine 读出并在 hander 层适配的。

需要说明的是,商业数据库里广泛应用的直方图,在 MySQL 内核里还只是个配角,究其原因大概有:基于 index dive 的 range optimizer 在 MySQL 主要业务 TP 业务场景中表现还行 ,而 InnoDB 的采样算法 ( row-based random sampling ) 性能问题也限制了应用场景,直到到比较新的 8.0.19 版本 [4] 才发布了重点改进 ( block-based random sampling ) [5] 。

那么, InnoDB 的统计信息支持,有什么问题和影响呢?

总图

下面这个图中绘制了三层的不同对象和模块,从上到下依次是 Server 、handler 和 InnoDB 。图中包含了 Server 和 InnoDB 的统计信息表示,以及适配函数和更新机制。

image.png

信息表示

在 SQL 层中,统计信息存于 TABLE对象和 TABLE_SHARE对象中。 TABLE是会话级的(在 MySQL 中,一个会话即一个客户连接),TABLE_SHARE是全局共享的,语义上 TABLETABLE_SHARE是多对一的关系。此外, TABLETABLE_SHARE都有相应的缓存,分别称为 table_open_cachetable_definition_cache。 为了优化锁竞争,TABLE缓存做了哈希分区 ( 每个分区称为一个 instance ) 。

统计信息的表示和 open table 逻辑是密切相关的。open table 简单地讲,如果有 TABLE对象就复用,否则根据 TABLE_SHARE构造 ( open_table_from_share ) ,如果 TABLE_SHARE都没有,那就先从数据字典 ( data dictionary ) 构造 TABLE_SHARE,再构造 TABLE。在构造 TABLE时会将文件大小、page 大小和表行数等统计信息放到 handler::stats中,但索引统计信息和单列直方图仍然是放在 TABLE_SHARE中为所有会话 TABLE所共享的。

在 InnoDB 中,统计信息缓存在 dict_table_tdict_index_t中,前者包含表级统计信息 ( 行数、主索引字节数和二级索引总字节数 ) ,后者包含索引级统计信息(密度向量、B+树总页数和叶子页数)。而 InnoDB 采用了聚簇主键索引,所以,行数其实也是从主索引获得的。这些信息会持久化在 mysql.innodb_table_statsmysql.innodb_index_stats两个系统表中。

更新机制

统计信息收集是通过 dict_stats_update_persistent()函数来完成的,具体收集算法这里不展开,其统计指标更新流程是:

1. 持写锁
2. 统计信息缓存清零
3. 收集表和索引统计信息并更新缓存
4. 释放写锁
5. 持读锁获取缓存的快照
6. 将快照持久化到系统表中

显然,这里持写锁时间是会比较长的,这也可能是 HA_STATUS_NO_LOCK需求的来源。在 handler::info()同步信息时通常会带上 HA_STATUS_NO_LOCK标记,表示读 Storage Engine 统计信息时不持读锁。

在 Server 和 Storage Engine 两层之间的信息同步是 handler::info()接口负责的。这个接口函数通过一个参数来标记操作内容:

HA_STATUS_VARIABLE  需要同步表级统计信息到 handler::stats (ha_statistics)
HA_STATUS_CONST     需要同步索引级统计信息到 TABLE_SHARE 里的 rec_per_key 结构
HA_STATUS_TIME      需要重新收集统计信息
HA_STATUS_NO_LOCK   从存储层读数据时不持锁 (dict_table_t::stats_latch)

具体标记由调用方根据场景来决定。由于重新收集统计信息时需要更新 dict_table_tdict_index_t相关字段,而同步统计信息时会读这些字段,这把锁可以保证读写版本是一致。统计信息一般是 8 字节数值,在 64-bit 机器上,这些数值本身的读写可以认为是原子的,统计信息对版本一致性也有一定的容忍度,直观上理解,读的时候不持锁也是可以的。

从总图也可以看到,DML 会将表级统计信息从 InnoDB 同步到 handler::stats,重新构造 TABLE时会同步表和索引统计信息,而 ANALYZE命令除了同步表和索引统计信息之外,还要求重新收集。

Information Schema 和 SHOW INDEX

当发生执行计划回退时,我们通常会试图求证于当前的统计信息,一种办法是使用 SHOW INDEX命令,另一种是直接读 mysql库中的两个统计表,或者 information schema 中的相关视图。但这两个命令是绕过 SQL 层的缓存,直接读 InnoDB 中缓存的统计信息,此外,还有专用的内部缓存表,即 mysql.table_statsmysql.index_stats,其缓存时间由系统变量 information_schema_stats_expiry控制,默认有效期是一天。但优化器使用的是 SQL 层的缓存,也就是说,如果同步机制本身出了问题,那么,这两个命令其实产生欺骗的。事实上,这个同步机制确实也有点问题。目前并没有办法直接查看 SQL层缓存的统计信息,所以,唯一可信的是 optimizer trace 中的数值,虽然并非原始统计信息,但基本上也可以支持一定程度的还原。

更新机制

重新收集统计信息,有多种触发情况:用户可以主动发起 ANALYZE命令来重新收集,重建表结束时也会重新收集,此外, InnoDB 的后台统计线程 ( dict0stats_bg.cc ) 还会在修改行数累积到一定数量时 ( persistent 10% 或 transient 1/16 , 见 row_update_statistics_if_needed() ) 重新收集,样本大小分别由 innodb_stats_persistent_sample_pagesinnodb_stats_transient_sample_pages控制。考虑到采样开销,这两个参数的默认值是 20 和 8 ,也就是说,不管用户表数据量多大,InnoDB 都只采集 20 个 page 。

重新收集的入口函数是 dict_stats_update_persistent()。顺便说一句, InnoDB 持久化统计信息是从 5.6.6 开始成为默认配置的 [7] [8] ,而非持久化统计信息继续应用于一些系统表,这两套逻辑还有一定的重叠度。

但是,对于 SQL 层维护的统计信息 ( 如直方图 ) ,由于没有更新计数的支持,所以,只能通过内部定时任务 ( events ) 或者外部定时任务来驱动更新。

问题和影响

数据质量

InnoDB 统计信息对于无论多大的表,默认都只随机采样 20 个 page 。显然,对于这么小的 block-based sampling 样本,算法上很难产生可靠的统计 ,除非数据是趋向于均匀分布的。直方图虽然可以比较好地拟合数据分布,但也需要足够大的随机样本 [6] 。事实上,生产环境查询性能问题,很多是数据倾斜导致的。

时效性

由于有 TABLE 缓存以及 TABLE_SHARE 缓存,什么时候构造 TABLE 对象,其实是不可预知的,换句话说,密度向量什么时候能够更新是没有保证的。理论上,只要会话缓存足够大,若不主动 ANALYZE ,密度向量可能长期没有更新!而新连接由于无可复用的 TABLE 对象,调用了 open_table_from_share(),其他会话中该表相关的执行计划可能就莫名其妙变了。

顺便说一句, ANALYZE命令不太常见,一方面,可能是因为大家误以为后台统计任务会合理地更新信息,另一方面,可能是因为确实是不知道什么时候需要更新,毕竟除了在批量更新或数据导入场景下可能是比较清晰的,其他时机都无从知晓。而且,它还有阻塞查询的概率风险 [9] 。

一致性

从统计流程可以看到,在收集前有一个外部可见的缓存清零操作。也就是说,同步信息时不持读锁的话,除了版本不一致外,还可能读到零。当然 info()读到零值时会进行一些处理,比如说,对于密度向量,它会认为表中所有记录都是相同的,对于元组数,它会认为是空表。显然,不管是那种处理,对于正常统计规律来讲,都是一个突变。一般来说,统计信息可以容忍一定范围的误差,甚至只要保持统计性质不变,长期不更新都可以,但突变就完全打破了这个基础,业务上就可能有莫名其妙的全表扫描,或者有更好的索引却不选。

在 20 个 page 的默认采样配置下,大概 20~30 ms 就完成了统计更新。但低耗时也掩盖了更多的管理逻辑问题:由于缺乏对统计收集任务的合理协调,实际情况是会有多次毫无意义的重复收集操作。

按说重建表时是要暂停收集统计信息的,但实际上新的统计任务仍然会由修改行数累积触发,当主索引处于 OnlineDDL 状态时,统计指标更新流程清零操作后会跳过搜集,读到零的时间窗口会被急剧放大,直到重建表结束后再恢复正常。随着 OnlineDDL [10] 越来越多的使用,生产环境全表扫描问题越来越多。好消息是,这个问题已经有修复方案了 [11] 。

解决办法

显然,现有更新机制的同步问题和一致性问题,属于程序缺陷,需要修复。 作为短期规避措施, ANALYZE命令可以加到定时任务,但要修复潜在的阻塞风险 [9] 。

从优化器角度来看,InnoDB 统计信息不论在指标丰富程度还是管理框架方面,基本上无法满足各种优化场景的需要。统计质量导致“看不准” 最优执行计划,属于方案缺陷,可以从两个方面来着手:1) 增强估计能力和统计数据支持,2) 限定执行计划搜索空间。虽然都可以有一些人工干预的机制作为短期的过渡方案,但是,在比较大的部署规模下,为产出高效而稳定的执行计划,建立系统化的统计信息管理机制 [12] ,其重要性就是显而易见的了。

image-20201226141033309

参考资料

[1] Chaudhuri, Surajit. “An overview of query optimization in relational systems.” Proceedings of the seventeenth ACM SIGACT-SIGMOD-SIGART symposium on Principles of database systems. 1998.

[2] Selinger, P. Griffiths, et al. “Access path selection in a relational database management system.” Proceedings of the 1979 ACM SIGMOD international conference on Management of data. 1979.

[3] Leis, Viktor, et al. “Query optimization through the looking glass, and what we found running the Join Order Benchmark.” The VLDB Journal 27.5 (2018): 643-668.

[4] Changes in MySQL 8.0.19,

https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-19.html

[5] WL#8777: InnoDB: Support for sampling table data for generating histograms

[6] Chaudhuri, Surajit, Rajeev Motwani, and Vivek Narasayya. “Random sampling for histogram construction: How much is enough?.” ACM SIGMOD Record 27.2 (1998): 436-447.

[7] WL#6189 Turn InnoDB persistent statistics ON by default

[8] Changes in MySQL 5.6.6,

https://dev.mysql.com/doc/relnotes/mysql/5.6/en/news-5-6-6.html

[9] ANALYZE TABLE Is No Longer a Blocking Operation,

https://www.percona.com/blog/2018/03/27/analyze-table-is-no-longer-a-blocking-operation/

[10] WL#5534 Online ALTER

[11] Analyze table leads to empty statistics during online rebuild DDL ,

https://bugs.mysql.com/bug.php?id=98132

[12] Chakkappen, Sunil, et al. “Adaptive statistics in Oracle 12c.” Proceedings of the VLDB Endowment 10.12 (2017): 1813-1824.

PolarDB · 源码解析 · 深度解析PolarDB的并行查询引擎

$
0
0

PolarDB与开源MySQL及其它类MySQL的产品相比,除了计算与存储分离的先进架构之外,另外一个最核心的技术突破就是开发了其它类MySQL产品没有的并行查询引擎,通过并行查询引擎,PolarDB除了保持自身对OLTP应用的优势之外,还对OLAP的支持能力有了一个质的飞越,遥遥领先于其它类MySQL产品。

用户越来越多的分析统计需求

众所周知,MySQL的优化器目前还不支持并行优化,也不支持并行执行。当然,MySQL自己也在逐渐探索并行执行的可能,比如对count()的并行执行,但整体上,还没有形成一套成熟的并行优化、并行执行的机制,只是针对一些特殊的场景进行局部优化。随着类MySQL产品在云上的蓬勃发展,越来越多的传统用户迁移到类MySQL产品上,对MySQL提出了一些新的挑战。很多传统用户在对OLTP要求的同时,还要求数据库有一些分析、统计、报表的能力,相对传统商业数据库来说,MySQL在这方面有明显的劣势。为了满足用户不断提升的OLTP能力,同时又能进行OLAP分析的需求,PolarDB的并行查询引擎应运而生。自诞生以来,通过强大的并行执行能力,原来需要几百秒的查询,现在只需要几秒,为用户节省了大量的时间和金钱,得到大量用户的极大好评。

PolarDB并行查询引擎应运而生

优化器是数据库的核心,优化器的好坏几乎可以决定一个数据库产品的成败。开发一个全新的优化器,对任何团队都是一个巨大的挑战,技术的复杂度暂且不提,就是想做到产品的足够稳定就是一个非常难以克服的困难。因此即使传统商业数据库,也是在现有优化器的基础上不断改进,逐渐增加对并行的支持,最终成为一个成熟的并行优化器。对PolarDB也是如此,在设计和开发并行查询引擎时,我们充分利用现有优化器的技术积累和实现基础,不断改进,不断打磨,最终形成了一个持续迭代的技术方案,以保证新的优化器的稳定运行和技术革新。 对于一个类OLAP的查询,显而易见的是它通常是对大批量数据的查询,数据量大意味着数据远大于数据库的内存容量,大部分数据可能无法缓存到数据库的buffer中,而必须在查询执行时才动态加载到buffer中,这样就会造成大量IO操作,而IO操作又是最耗时的,因此首先要考虑的就是如何能加速IO操作。由于硬件的限制,每次IO的耗时基本是固定的,虽然还有顺序IO和随机IO的区别,但在SSD已经盛行的今天,两者的差异也在逐渐接近。那么还有没有其它方式可以加速IO呢? 显然并行IO是一个简单易行的方法,如果多个线程可以同时发起IO,每个线程只读取部分数据,这样就可以快速的将数据读到数据库的buffer中。但是如果只是将数据读取到buffer中,而不是立即进行后续处理,那么这些数据就会因buffer爆满导致数据被换出,从而失去加速IO的意义。 pic
图1-并行IO示意图

因此,在并行读取数据的同时,必须同时并行的处理这些数据,这是并行查询加速的基础。因为原有的优化器只能生成串行的执行计划,为了实现并行读取数据,同时并行处理数据,首先必须对现有的优化器进行改造,让优化器可以生成我们需要的并行计划。比如哪些表可以并行读取,并且通过并行读取会带来足够的收益;或者哪些操作可以并行执行,并且可以带来足够的收益。并不是说并行化改造一定会有收益,比如对一个数据量很小的表,可能只是几行,如果也对它进行并行读取的话,并行执行所需要的多线程构建所需要的代价可能远大于所得到的收益,总体来说,并行读取会需要更多的资源和时间,这就得不偿失了,因此并行化的改造必须是基于代价的,否则可能会导致更严重的性能褪化问题。

Fact表的并行扫描

通过基于并行cost的计算和比较,选择可以并行读取的表作为候选,是并行执行计划的第一步。基于新的并行cost,也许会有更优的JOIN的顺序选择,但这需要更多的迭代空间,为防止优化过程消耗太多的时间,保持原有计划的JOIN顺序是一个不错的选择。另外,对于参与JOIN的每张表,因为表的访问方法不同,比如全表扫描、ref索引扫描,range索引扫描等,这些都会影响到最终并行扫描的cost。 通常我们选择最大的那张表作为并行表,这样并行扫描的收益最大,当然也可以选择多个表同时做并行扫描,后面会继续讨论更复杂的情况。 下面以查询年度消费TOP 10的用户为例:

SELECT c.c_name, sum(o.o_totalprice) as s FROM customer c, orders o WHERE c.c_custkey = o.o_custkey AND o_orderdate >= '1996-01-01' AND o_orderdate <= '1996-12-31' GROUP BY c.c_name ORDER BY s DESC LIMIT 10; 

其中orders表为订单表,数据很多,这类表称之为Fact事实表,customer表为客户表,数据相对较少,这类表称之为dimension维度表。那么此SQL的并行执行计划如下图所示: pic从计划中可以看出orders表会做并行扫描,由32个workers线程来执行,每个worker只扫描orders表的某些分片,然后与customer表按o_custkey做eq_ref进行JOIN,JOIN的结果发送到用户session中一个collector组件,然后由collector组件继续做后续的GROUP BY、ORDER BY及LIMIT操作。

多表并行JOIN

将一张表做并行扫描之后,就会想为什么只能选择一张表?如果SQL中有2张或更多的FACT表,能不能可以将FACT表都做并行扫描呢?答案是当然可以。以下面SQL为例:

SELECT o.o_custkey, sum(l.l_extendedprice) as s FROM orders o, lineitem l WHERE o.o_custkey = l.l_orderkey GROUP BY o.o_custkey ORDER BY s LIMIT 10;

其中orders表和lineitem表都是数据量很大的FACT表,此SQL的并行执行计划如下图所示: pic从计划中可以看到orders表和lineitem表都会做并行扫描,都由32个workers线程来执行。那么多个表的并行是如何实现的呢?我们以2个表为例,当2个表执行JOIN时,通常的JOIN方式有Nested Loop JOIN、HASH JOIN等,对于不同的JOIN方式,为保证结果的正确性,必须选择合理的表扫描方式。以HASH JOIN为例,对于串行执行的HASH JOIN来说,首先选择一个表创建HASH表称之谓Build表,然后读取另一个Probe表,计算HASH,并在Build表中进行HASH匹配,若匹配成功,输出结果,否则继续读取。如果改为并行HASH JOIN,并行优化器会对串行执行的HASH JOIN进行并行化改造,使之成为并行HASH JOIN,并行化改造的方案可以有以下两种解决方案。方案一是将2个表都按HASH key进行分区,相同HASH值的数据处于同一个分区内,由同一个线程执行HASH JOIN。方案二是创建一个共享的Build表,由所有执行HASH JOIN的线程共享,然后每个线程并行读取属于自己线程的另外一个表的分片,再执行HASH JOIN。 pic
图2-并行HASH JOIN示意图

对于方案一,需要读取表中的所有数据,根据选中的HASH key,对数据进行分区,并将数据发送到不同的处理线程中,这需要额外增加一个Repartition算子,负责根据分区规则将数据发送到不同的处理线程。为了提高效率,这里通常会采用message queue队列来实现。 对于方案二,需要并行创建共享的HASH build表,当build表创建成功后,每个线程读取Probe表的一个分片,分别执行HASH JOIN,这里的分片并不需要按照HASH key进行分片,每个线程分别读取互不相交的分片即可。

分析统计算子的并行

对于一个分析统计的需求,GROUP BY操作是绕不开的操作,尤其对大量的JOIN结果再做GROUP BY操作,是整个SQL中最费时的一个过程,因此GROUP BY的并行也是并行查询引擎必须优先解决的问题。 以年度消费TOP10客户的SQL为例,对GROUP BY并行化后的并行执行计划如下图所示: pic与之前的执行计划相比,新的执行计划中多了一个collector组件,总共有2个collector组件。首先我们看第二行的collector组件,它的extra信息中有2条”Using temporary; Using filesort”,这表示它是对从workers接收到的数据执行GROUP BY,然后再按ORDER排序,因为只有第一个collector组件在用户的session中,所以这个collector也是在worker中并行执行,也就是说并行的做Group by和Order by以及Limit;然后看第一行的collector组件,它的extra信息中只有一条”Merge sort”,表示session线程对从workers接收到的数据执行一次merge sort,然后将结果返回给用户。这里可能就有人会提出疑问,为什么session线程只做merge sort就可以完成GROUP BY操作呢?另外LIMIT在哪里呢? 首先回答第2个问题,因为explain计划显示的问题,在常规模式下不显示LIMIT操作,但在Tree模式下会显示LIMIT操作。如下所示: pic从Tree型计划树上可以清楚的看到LIMIT操作有2处,一处在计划的顶端,也就是在session上,做完limit后将数据返回给用户;另外一处在计划树的中间位置,它其实是在worker线程的执行计划上,在每个worker线程中在排序完成后也会做一次limit,这样就可以极大减少worker返回给session线程的数据量,从而提升整体性能。 下面来回答第一个问题,为什么GROUP BY只需要在worker线程上执行一次就可以保证结果的正确性。通常来说,每个worker只有所有数据的一个分片,只在一个数据分片上做GROUP BY是有极大的风险得到错误的GROUP BY结果的,因为同一GROUP分组的数据可能不只是在本WORKER的数据分片上,也可能在其它WORKER的数据分片中,被其它WORKER所持有。但是如果我们可以保证同一GROUP分组的数据一定位于同一个数据分片,并且这个数据分片只被一个WORKER线程所持有,那么就可以保证GROUP BY结果的正确性。通过Tree型执行计划可以看到,在并行JOIN之后,将JOIN的结果按GROUP分组的KEY值: c.c_name进行Repartition操作,将相同分组的数据分发到相同的WORKER,从而保证每个WORKER拥有的数据分片互不交叉,保证GROUP BY结果的正确性。 因为每个WORKER的GROUP BY操作已经是最终结果,所以还可以将ORDER BY和LIMIT也下推到WORKER来执行,进一步提升了并行执行的效率。

不断迭代创新的并行查询引擎

总之,通过对并行查询引擎的支持,PolarDB不仅在保持查询引擎稳定的同时,还极大的提升了复杂SQL,尤其是分析统计类型查询的性能。通过有计划的不断迭代,PolarDB在并行查询引擎的道路上越走越远,也越来越强大,为了满足客户日益不断增长的性能需求,为了更多企业用户的数字化升级,PolarDB为您提供革命性的数据引擎,助您加速拥抱万物互联的未来。 


并行查询引擎对TPCH的线性加速

附图是一个并行查询引擎对TPCH的加速效果,TPC-H中100%的SQL可以被加速,70%的SQL加速比超过8倍,总和加速近13倍,Q6和Q12加速甚至超过32倍。 pic

MySQL · 源码阅读 · 内部XA事务

$
0
0

概述

MySQL是一个支持多存储引擎架构的数据库,除了早期默认的存储引擎myisam,目前使用比较多的引擎包括InnoDB,XEngine以及Rocksdb等,这些引擎都是支持事务的引擎,在数据库系统中,存储引擎支持事务基本是标配,所以其它引擎也就慢慢边缘化了。由于支持多事务引擎,为了保证事务一致性,MySQL实现了经典的XA标准,通过XA事务来保证事务的特征。binlog作为MySQL生态的一个重要组件,它记录了数据库操作的逻辑更新,并作为数据传输纽带,可以搭建复杂的MySQL集群,以及同步给下游。除了作为传输纽带,binlog还有一个角色就是XA事务的协调者,协调各个参与者(存储引擎)来实现XA事务的一致性。

XA事务

MySQL的XA事务支持包括内部XA事务和外部XA事务。内部XA事务主要指单节点实例内部,一个事务跨多个存储引擎进行读写,那么就会产生内部XA事务;这里需要指出的是,MySQL内部每个事务都需要写binlog,并且需要保证binlog与引擎修改的一致性,因此binlog是一个特殊的参与者,所以在打开binlog的情况下,即使事务修改只涉及一个引擎,内部也会启动XA事务。外部XA事务与内部XA事务核心逻辑类似,提供给用户一套XA事务的操作命令,包括XA start, XA end,XA prepre和XA commit等,可以支持跨多个节点的XA事务。外部XA的协调者是用户的应用,参与者是MySQL节点,因此需要应用持久化协调信息,解决事务一致性问题。无论外部XA事务还是内部XA事务,存储引擎实现的prepare和commit接口都是同一条路径,本文重点介绍内部XA事务。

协调者

协调者的选择

MySQL内部XA事务,存储引擎是参与者,而协调者则有3个选项,包括binlog,TC_LOG_MMAP和TC_LOG_DUMMY。如果开启binlog,由于每个事务至少涉及一个存储引擎的修改,加上binlog,所以也会走XA事务流程。如果关闭binlog,事务修改涉及多个存储引擎,比如innodb和xengine引擎,那么内部会采用tc_log_map作为协调者。如果关闭binlog,且修改只涉及一个引擎innodb,那么实际上就不是XA事务,mysql内部为了保证接口统一,仍然使用了一个特殊的协调者TC_LOG_DUMMY,TC_LOG_DUMMY实际上什么也没做,只是做简单的转发,将server层的调用路由到引擎层调用,仅此而已。

//协调者选择的逻辑
if (total_ha_2pc > 1 || (1 == total_ha_2pc && opt_bin_log))
{
  if (opt_bin_log)
    tc_log= &mysql_bin_log;
  else
    tc_log= &tc_log_mmap;
}
else
  tc_log= &tc_log_dummy

协调者逻辑

//binlog,tc_log_mmap和tc_log_dummy作为协调者的基本逻辑
binlog作为协调者:
prepare:ha_prepare_low
commit: write-binlog + ha_comit_low

tclog作为协调者:
prepare:ha_prepare_low
commit:wrtie-xid + ha_commit_low

tc_dummy作为协调者:
prepare:ha_prepare_low
commit:ha_commit_low 

//是否支持2PC,是否修改超过了1个以上的引擎
if (!trn_ctx->no_2pc(trx_scope) && (trn_ctx->rw_ha_count(trx_scope) > 1))
  error = tc_log->prepare(thd, all);

执行2PC依据

TC_LOG_MMAP和binlog作为协调者本质是相同的,就是在涉及跨引擎事务时,走2PC事务提交流程,分别调用引擎的prepare接口和commit接口。协调者如何确认是否走2PC逻辑,这里主要根据事务修改是否涉及多个引擎,特殊的是,如果打开binlog,binlog也会作为参与者考虑在内,最终统计事务涉及修改的参与者是否超过1,如果超过1,则进行2PC提交流程(prepare,commit)。注意,这里有一个前提条件是涉及的修改引擎必需都支持2PC。

 struct THD_TRANS {                                         
   /* true is not all entries in the ht[] support 2pc */    
   bool m_no_2pc;    
   
   /* number of engine modify */
   int m_rw_ha_count; 
   
   /* storage engines that registered in this transaction */
   Ha_trx_info *m_ha_list;
 } 
 
//统计打标,是否涉及到多个引擎的修改。
ha_check_and_coalesce_trx_read_only(bool all) {
  //统计打标
  for (ha_info = ha_list; ha_info; ha_info = ha_info->next()) {
    if (ha_info->is_trx_read_write()) ++rw_ha_count;
    
    //语句级统计
    if (!all) {
      Ha_trx_info *ha_info_all =
          &thd->get_ha_data(ha_info->ht()->slot)->ha_info[1];
      DBUG_ASSERT(ha_info != ha_info_all);
      
      /*   
        Merge read-only/read-write information about statement
        transaction to its enclosing normal transaction. Do this
        only if in a real transaction -- that is, if we know
        that ha_info_all is registered in thd->transaction.all.
        Since otherwise we only clutter the normal transaction flags.
      */
      //将语句级的读写修改,同步到事务级的读写修改
      if (ha_info_all->is_started()) /* false if autocommit. */
        ha_info_all->coalesce_trx_with(ha_info);
    } else if (rw_ha_count > 1) { 
      /*   
        It is a normal transaction, so we don't need to merge read/write
        information up, and the need for two-phase commit has been
        already established. Break the loop prematurely.
      */
      break;
    }    
  }   
}

参与者

mysql内部XA事务中,参与者主要指事务型存储引擎。mysql根据引擎是否提供了prepare接口,判断引擎是否支持2PC。引擎的prepare和commit接口有一个bool类型的参数,主要含义是这次prepare/commit是语句级别,还是事务级别。事务的2PC提交流程主要都发生在事务级别,但有一个特殊场景,就是autocommit场景下的单SQL语句,这种会触发自动提交,如果这个SQL语句的修改涉及多个引擎,也会走到2PC流程。主要逻辑如下:

prepare逻辑:
ha_prepare(bool prepare_tx) 这里的prepare_tx由外面传递的all=true/false决定。
if (prepare_tx || (!my_core::thd_test_options(thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN))) {
  tx->prepare
}

commit逻辑:
ha_commit(bool commit_tx) 这里的commit_tx由外面传递的all=true/false决定。
if (commit_tx || (!my_core::thd_test_options(thd, OPTION_NOT_AUTOCOMMIT |  OPTION_BEGIN))) {
  tx->commit
}

XA事务存储引擎接口

innobase_hton->commit = innobase_commit;
innobase_hton->rollback = innobase_rollback;
innobase_hton->prepare = innobase_xa_prepare;
innobase_hton->recover = innobase_xa_recover;
innobase_hton->commit_by_xid = innobase_commit_by_xid;
innobase_hton->rollback_by_xid = innobase_rollback_by_xid;

Server层与引擎层交互

从前面协调者逻辑我们了解到,MySQL内部XA事务,协调者在Server层,参与者在引擎层,因此Server层和引擎层需要有一定的通信机制来确定是否要进行2PC提交。这里主要包括两方面,一个是,事务涉及到的引擎要注册到协调者的事务列表中,二是,如果引擎有修改,要将已修改的信息通知给协调者。在MySQL中主要通过两个接口来实现,xengine_register_tx/innodbase_register_tx注册事务,handler::mark_trx_read_write标记事务读写。

DML事务

注册事务路径

server根据需要访问表进行注册事务。

mysql_lock_tables
 lock_external
   handler::ha_external_lock
     ha_innobase::external_lock
       innobase_register_trx
         trans_register_ha
           Transaction_ctx::set_ha_trx_info

void xengine_register_tx(handlerton *const hton, THD *const thd,
                                       Xdb_transaction *const tx) {
  DBUG_ASSERT(tx != nullptr);
  //注册stmt的trx信息
  trans_register_ha(thd, FALSE, xengine_hton, NULL);
  
  //显示开启的事务,ddl默认将AUTOCOMMIT关掉,符合条件
  if (my_core::thd_test_options(thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)) {
    tx->start_stmt();
    trans_register_ha(thd, TRUE, xengine_hton, NULL);
  }
}

标记事务修改

handler::ha_delete_row
handler::ha_write_row
handler::ha_update_row
   handler::mark_trx_read_write

/**
  A helper function to mark a transaction read-write,
  if it is started.
*/

void handler::mark_trx_read_write() {
  Ha_trx_info *ha_info = &ha_thd()->get_ha_data(ht->slot)->ha_info[0];
  /*
    When a storage engine method is called, the transaction must
    have been started, unless it's a DDL call, for which the
    storage engine starts the transaction internally, and commits
    it internally, without registering in the ha_list.
    Unfortunately here we can't know know for sure if the engine
    has registered the transaction or not, so we must check.
  */
  if (ha_info->is_started()) {
    DBUG_ASSERT(has_transactions());
    /*
      table_share can be NULL in ha_delete_table(). See implementation
      of standalone function ha_delete_table() in sql_base.cc.
    */
    if (table_share == NULL || table_share->tmp_table == NO_TMP_TABLE) {
      /* TempTable and Heap tables don't use/support transactions. */
      ha_info->set_trx_read_write();
    }
  }
}

DDL事务

对于ddl事务,由于涉及到字典的多次修改,为了避免中途提交,临时将自动提交关闭。

/**
  Check if statement (typically DDL) needs auto-commit mode temporarily
  turned off.

  @note This is necessary to prevent InnoDB from automatically committing
        InnoDB transaction each time data-dictionary tables are closed
        after being updated.
*/
static bool sqlcom_needs_autocommit_off(const LEX *lex) {
  return (sql_command_flags[lex->sql_command] & CF_NEEDS_AUTOCOMMIT_OFF) ||
         (lex->sql_command == SQLCOM_CREATE_TABLE &&
          !(lex->create_info->options & HA_LEX_CREATE_TMP_TABLE)) ||
         (lex->sql_command == SQLCOM_DROP_TABLE && !lex->drop_temporary);
}

/*
  For statements which need this, prevent InnoDB from automatically
  committing InnoDB transaction each time data-dictionary tables are
  closed after being updated.
*/
Disable_autocommit_guard(THD *thd) {
  m_thd->variables.option_bits &= ~OPTION_AUTOCOMMIT;
  m_thd->variables.option_bits |= OPTION_NOT_AUTOCOMMIT;
}

ddl注册事务

所有dml操作,都会通过mysql_lock_tables路径来进行注册事务操作,但对于ddl,由于有些操作只涉及数据字典的修改,server层认为不涉及引擎层修改,则不会显示注册事务。xengine通过原子ddl日志和2PC支持xengine表的ddl,需要显示注册事务,通知server层。

ddl标记事务修改

除了主动在server层注册事务,还需要主动将事务标记为read-write,标识这个ddl中xengine引擎有修改,这样server层在统计修改的事务引擎数时,会将xengine计算在內,最后再抉择是采用1PC事务提交还是2PC事务提交。目前,实际上在handler层的所有ddl路径,都主动调用了接口mark_trx_read_write,但由于在之前,并没有将引擎注册到server,导致整个调用对部分DDL操作无效。

典型场景分析

这里考虑不开binlog的场景,因为开binlog情况下,任何一个事务只要有更新,加上binlog就会走内部XA事务。不开binlog场景下,如果同时启用xengine和innodb引擎,根据事务实际情况,可能会走到2PC流程。

| 1 | 场景 | 类别 | 是否走2PC流程 | 备注 | | — | — | — | — | — |
| 2 | (one-stmt)+(modify xengine) | DML事务 | no | 隐式事务,autocommit=on,单语句自动提交事务|
| 3 | (one-stmt)(modify xengine+innodb) | | yes | 隐式事务,autocommit=on,单语句自动提交事务 |
| 4 | (multi-stmt)+(modify xengine) | | no | 显示事务, 结合begin/commit |
| 5 | (multi-stmt)+(modify xengine+innodb) | | yes | 显示事务, 结合begin/commit |
| 6 | create table | DDL事务 | yes | storage engine mark_read_write |
| 7 | drop table | | yes | storage engine mark_read_write |
| 8 | rename table | | yes | storage engine mark_read_write |
| 9 | alter table online | | yes | |
| 10 | alter table copy-offline | | yes | |
说明,目前tc_log作为协调者,对于双引擎XA事务在部分路径存在问题。比如,对于场景3,应该走2PC流程没有走;对于场景4,不需要走2PC流程的场景反而走了2PC。


PolarDB · 优化改进 · DDL的优化和演进

$
0
0

在过去的几年里,我们观察到,当数据达到一定规模后,PolarDB的部分用户(包括集团内部用户和公有云上的外部客户)更愿意使用gh-ost/pt-osc这样的外部工具来进行DDL操作。PolarDB内核团队为用户case by case地解决了很多DDL使用带来的问题,在处理这些问题的同时,我们也在不断地思考和讨论,云上客户越来越多,中小客户群体不断扩大,我们究竟要如何在内核层面解决DDL日益凸显的繁重弊端,让客户少为DDL担忧。

DDL面临的问题

DDL在生产环境下面临的问题主要来自两个方面:一个是MDL导致的阻塞问题,一个是全量数据复制带来的资源使用问题。

为了保证DD的一致性,MDL被引入来同步DDL,DML和DQL,这使得同一个表上的各种操纵必须在MDL这一粗粒度锁上汇聚,由此引发了各种超时问题,严重影响了上层业务。此外,在PolarDB共享存储结构下,多节点间的DD一致性要求使得这一问题拓展到了读写节点之间,也为用户带来了诸多困扰。

在PolarDB内部,数据物理存储和数据定义是分离的,因此DDL操作常常需要进行全量数据的重建,由此导致了单次DDL操作耗时甚至可以达到天级。这种操作的潜藏风险让用户不得不焦躁地在客户群里反复和研发同学沟通确认。同时,全量数据的重建会占用大量的系统资源。PolarDB的云原生优势已经在相当程度上为客户规避了这一问题,资源的快速弹性伸缩防止了OOM,磁盘空间不足等问题,但是系统资源的大量占用将提高其他操作的耗时,降低数据库的整体吞吐,最终将影响上层业务的稳定性。

此外,在全面上云的大背景下,云上中小客户群体不断扩大,他们中很多还缺乏处理数据库复杂生产环境下的各种细节问题的经验。在我们的观察里,这些客户的DDL操作频率显著高于集团内部用户和其他大客户,DDL使用过程中的很多问题让这些用户焦头烂额.

优化和演进方向

解决DDL带来的问题,我们需要做到一点:降低DDL执行耗时。如果DDL可以在瞬间完成,那么DDL带来的诸多问题都将迎刃而解。于是在这样一种思路的指导下,我们提出了Instant DDL + Parallel DDL + 物理复制链路优化的整体解决方案。

img

对于可通过变更数据定义完成的DDL类型,如加列,减列等,我们将其Instant化,使其无需修改存量数据,因而可在瞬间完成;对于必须全量扫描并构建数据的DDL类型,如重建主键索引,新建二级索引等,我们允许其在引擎内部被并行地处理,从而充分利用系统资源,降低执行耗时。

此外,我们还使用了并行MDL同步方案,解决DDL过程MDL在读写节点上的阻塞问题,同时优化了物理复制使用的Redo Log,降低了DDL操作时读节点同步Redo Log的负载。这些物理复制链路上的优化和DDL执行链路上的整体演进共同作用,构成了攻克DDL难关的主力军和护卫队。

Instant DDL

像add column这类DDL,原有的执行逻辑包含两个部份的操作,分别涉及数据字典和存量数据。其中数据字典的修改是非常快速的,但是表全量数据的重建则耗时漫长。

img

Instant DDL则仅改变数据字典中的表定义信息,而不修改任何存量数据,从而使得DDL操作可以在瞬间完成。

img

目前add column at last instantly已经在PolarDB 5.7和8.0上得到支持, add column at any position instantly和drop column instantly等也将在随后的版本中上线,未来所有逻辑上可Instant 化的DDL操作都将支持Instant算法。用户只需热升级到相应的版本,即可让原本耗时达到小时级甚至天级的DDL操作在瞬间完成。

Parallel DDL

新建二级索引这类DDL操作,执行时必须扫描全量数据,并构建新的索引树,整体耗时非常长。

img

Parallel DDL则将Data Scan和B+ Tree Build操作划分成多个子任务,通过内部的并行服务子系统进行调度并适时地执行,最后将各个子任务的执行结果进行合并得到最终结果。

img

Parallel DDL 通过存储引擎内部的并行执行,充分利用系统资源,使得部分DDL的执行效率最高可提高十倍以上,从而将整个DDL的时间窗口缩小到原来的十分之一。目前parallelly create secondary index 已经在PolarDB 8.0上得到支持,后续将陆续上线到其他版本,同时其他类型的Parallel DDL支持也将在随后的版本中发布。

img

物理复制链路上的DDL优化

Instant DDL 和Parallel DDL 是DDL执行链路上的演进方案。但是在PolarDB共享存储架构下,复制链路上的问题同样制约着DDL的能力。例如为了保证各节点的一致性,必须在读写节点间通过Redo Log同步MDL信息,然而MDL锁的阻塞将影响Redo Log的同步,为此我们采用了并行MDL同步方案,将MDL信息的同步和Redo Log的同步解偶,提高了整个集群在DDL时的吞吐能力。此外我们改进了DDL过程中的Redo Log同步路径,不仅优化了写节点在产生DDL Redo Log时的IO开销,同时让只读节点有选择的同步Redo Log,降低DDL 操作时只读节点的负载,从而降低DDL过程中的读写节点间的同步代价。这些物理复制链路上的优化为DDL执行链路上的优化效果保驾护航,两者协同使得整个集群处理DDL的能力显著增强。

最后

DDL是PolarDB所有操作中最繁重的一种,曾经为用户带去了很多不好的使用体验。而Instant DDL + Parallel DDL + 物理复制链路优化是切实解决DDL 繁重弊端的重要组合拳。相信经过未来若干版本的迭代和演进,PolarDB DDL将为客户带来体验上翻天覆地的变化。期待未来用户执行DDL操作像执行简单查询一样淡定坦然,PolarDB内核团队将始终如一地为用户打造最佳的云原生关系性数据库管理系统。

Database · 最佳实践 · 内存索引指南

$
0
0

背景

如何选择内存索引结构是设计和优化数据库时的一个关键问题。因为无论是内存数据库还是磁盘数据库,内存索引都是一个至关重要的部分,极大地影响了数据库的性能表现。对于内存数据库,内存索引是读写路径上的核心结构,而对于磁盘数据库,在服务器内存足以缓存绝大部分热数据的场景下,内存索引可以大幅加速热数据的读写。

然而,选择合适的内存索引结构并不是一件简单的事情。首先,一直以来内存索引的设计在学术界和工业界都是一个万家争鸣的局面,内存索引结构的种类非常多,并且没有哪一种索引能在各个方面都实现领先,因此在选择时就需要根据不同的场景进行权衡。其次,一些内存索引的论文中使用的工作负载与实际应用场景存在一些差距,或者数据量较小,或者索引键的组成较为简单,仅使用整型或者反转的email地址,因此这些论文中的评估对索引结构选择的参考价值不大。另外,不同内存索引的实验评估中使用的硬件系统环境和工作负载都不同,甚至存在较大差异,难以比较它们之间的性能优劣。

因此,给各类内存索引一个“同场竞技”的机会是非常必要的。一方面,在相同的硬件环境和工作负载下对内存索引进行评估,可以给内存索引的选择提供参考。另一方面,对内存索引的评估也有助于更深入的理解不同内存索引的设计理念对性能的影响,为设计新的内存索引提供一些启发和借鉴。

在本文中,我们根据一定标准选择了五种内存索引,评估了它们在不同场景下的性能表现。由于内存索引结构种类很多,对所有的内存索引进行评估显然不可行,因此我们根据如下的标准来选择进行评估的内存索引:

  • 支持数据库的通用语义:随机写入,点查询,范围查询
  • 具有代表性:同一类的内存索引可能有很多变体,比如字典树就有Patricia Trie、ART、HOT等等,我们仅选择同一类中具有代表性的内存索引结构
  • 已经集成进数据库系统:一些内存索引虽然性能优异,但是存在诸多限制,难以在数据库系统中应用

根据这样的标准,我们选择了Skiplist,BwTree,BTree,Masstree和ART这五种内存索引。我们将它们集成进了PolarDB X-Engine的MemTable中,使用db_bench评估了它们在不同数据量,不同索引键长度下的随机写入、点查询、范围查询这几方面的性能表现。

实验评估

实验环境

  • Intel(R) Xeon(R) CPU E5-2682 v4 @ 2.50GHz, 32core 64threads, 2Socket.
  • L1-Cache:32KB Data Cache,32KB Instruction Cache
  • L2 Cache:256KB
  • L3 Cache:32MB
  • RAM:512GB,2133 MHz DDR4

实验设计

关于我们评估的五种内存索引,其中Skiplist是PolarDB X-Engine的MemTable使用的无锁实现,BwTree是OpenBwTree这篇paper中的开源实现,In-memory BTree是基于Logical and Physical Versioning的无锁BTree,Masstree也是论文中的开源实现,ART是我们根据论文实现的。

我们分别使用db_bench的fillrandom,readrandom,seekrandom来评估内存索引的性能,通过系统参数控制不发生SwitchMemTable和Flush,使用32线程压测一个ColumnFamily。db_bench原有的索引键生成方式是在64位整型后补ASCII字符0,我们修改了索引键的生成方式,使较长的索引键和数据库中复合索引的键模式接近,而较短的索引键和数据库中整型索引键一致。

实验结果

点查询

改变数据量

我们使用长度为64Bytes的字符串类型的key和128Bytes的value,测试了五种内存索引在不同数据量大小的场景下的点查询吞吐(200M-10G,对应1million-50million的键值对)。实验结果显示,五种内存索引中In-memory BTree,Masstree和ART都能达到千万级别的吞吐,其中ART在不同数据量的场景下性能表现都是最佳的。

从结果中我们观察到,在我们的测试场景下,ART的点查询性能显著地高于其他的树形索引,我们为此深入研究了ART的实现,并结合测试负载的特点,认为ART性能突出的原因主要有两点:

  • 数据结构自身查找性能的差距。字典树的查找开销是常量级O(k),而搜索树的查找开销是对数级O(LlogN)。在较为理想的情况下,一个朴素的字典树,每一层对应key中一个位置的字节,考虑每个节点的扇出最高是256,最少只需要搜索4层即可覆盖超过40亿个key的索引空间,无论是计算开销还是访存开销都很低。
  • 使用自适应的节点格式,并在不同的节点格式中使用不同的查找方式。字典树中不同的节点可能有不同的扇出,ART共有四种不同的节点格式:Node4、Node16、Node48和Node256,分别存储不同扇出的节点。采用这样的设计有两个好处:
    • 提高缓存效率。针对扇出较小的节点,如果统一使用包含256个指针的节点,可能会有较多的空闲空间,如果使用变长的节点形式,内存分配过程中可能会产生大量的内存碎片。采用不同格式的节点,并预分配固定的空间,可以在节省内存的同时降低写入时内存分配的开销、减少内存碎片。
    • 可以根据不同格式的节点选择最优的查询方式。除了Node256可以直接通过下标找到子节点的指针外,其他节点的查询都需要额外开销,这里针对不同格式的节点,选择了合适的查询方式。对于Node4,节点中最多存储4个字符和4个指针,因为数据量小,只需要顺序遍历即可。对于Node16,节点中最多存储16个字符和16个指针,可以使用SIMD指令集,在几个CPU cycle内并行比较节点包含的所有字符,找出子节点的指针。对于Node48,节点中使用一个长度256的uint8数组和最多存储48个指针,uint8数组作为一个hashmap,将字符映射到指针数组中的对应位置。

我们也观察到,在我们的测试场景下,Masstree的性能介于ART和In-memory BTree之间,这也是符合我们的预期的。Masstree实际上是介于字典树和搜索树之间的结构,以8字节为单位形成字典树,每层节点最高扇出为2,每层节点对所涵盖的key的8字节内容使用BTree来索引。相对于In-memory BTree,这样的设计可以在一定程度上减少索引键前缀的重复比较,在存在公共前缀的场景下带来性能提升。同时Masstree在每层节点内的BTree中,做了更精细的优化,将8字节字符串转换为整型进行比较,并在border node中引入特殊的存储组织形式。然而,即便64Bytes的较长的索引键并非ART索引的理想场景,而对Masstree非常有利,Masstree的性能相对于ART依然存在明显差距。因为Masstree虽然形似字典树,但在每层节点中搜索的代价依然是对数级O(logN),而ART在这样的场景下虽然层数更多,但在每层节点中搜索的代价仅为O(1)。

另外,我们还观察到BwTree的性能是最差的,原因是尽管BwTree在读到一个包含多版本的节点时会做Node Consolidate,其最终形态和BTree差不多,但是BwTree中节点之间使用间接指针相连,访问节点需要进行一次哈希查找才能获得节点的实际地址,这样的间接引用在纯内存场景会带来相当大的开销,对性能的影响往往是致命的。当然,开源版本的BwTree实现可能比较朴素,缺少工程优化,我们了解到Hekaton中使用的BwTree做了非常多的工程优化来弥补因间接引用等设计上的缺陷带来的性能损失。然而,实际上Hekaton发表的关于BwTree的论文中也没有和In-memory BTree进行性能对比,而是和磁盘BTree(BerkeleyDB)进行了对比。

对于Skiplist和BTree这两类传统的索引结构,其中BTree在纯内存场景依然具有比较好的性能表现,原因是因为BTree读路径的空间局部性和时间局部性相似,对CPU cache比较友好,能很好利用硬件prefetch带来的性能增益。而Skiplist虽然查询的算法复杂度也是对数级,然而读路径几乎每次访问都会发生一次cache miss,带来很大的访存开销。

改变索引键长度

我们测试了五种内存索引在不同索引键长度下的点查询吞吐(8Bytes-64Bytes),测试中使用的数据量均为50million的键值对。实验结果显示,五种索引在索引键长度增加时吞吐都有所下降。其中搜索树类型的内存索引性能下降的原因是索引键每次比较的开销更大,而字典树类型的内存索引性能下降的原因则是索引层数增加。

值得注意的是,Masstree的性能随着索引键变长,下降幅度相对较小。原因并不是因为Masstree在不同索引键长度的场景下可扩展性更好,而是因为Masstree在索引键较短(或索引键公共前缀较短)的场景下(如实验中8Bytes索引键的场景)性能表现较差,所以64Bytes索引键的场景相对于8Bytes索引键的场景下键幅度不大。因为在索引键较短(或索引键公共前缀较短)的场景下,Masstree在结构上退化为了BTree,无法通过减少前缀重复比较提升性能,相对于BTree的性能提升仅仅来自于设计上更精细的优化,例如将8字节字符串转换为整型进行比较。

而对于ART来说,不论索引键的公共前缀长度如何,都能获得极好的性能表现,这得益于使用path compression和lazy expansion来优化字典树查询。在较不理想的场景(索引键较长且公共前缀也较长)下,索引键可能有很多很长的公共子串和唯一后缀,如果使用朴素实现,可能会产生非常多的扇出为1的节点,造成额外的访存开销。使用path compression和lazy expansion可以较好地解决这样的问题。而索引键较短或公共前缀较短的场景则是ART的理想场景,自然可以获得极佳的性能。

范围查询

改变数据量

我们在点查询的对应实验场景下测试了不同数据量大小的场景下五种内存索引的范围查询性能。实验结果显示,五种内存索引中In-memory BTree在不同数据量的场景下性能表现都是最佳的。另一个值得注意的地方是,在所有场景下点查询性能最佳的ART的范围查询性能并不好。

数据结构特点和代码实现影响了五种内存索引的范围查询性能。从数据结构特点的角度来看,键值对在BTree的叶节点中全局有序紧密排列,范围查询在一次点查询后顺序迭代,迭代时CPU cache的效率很高。而对于ART,即便我们做了一些调整,使用有序双向链表连接ART的值节点,在链表中迭代的过程中每次访问都会发生一次cache miss。

改变索引键长度

我们在点查询的对应实验场景下测试了不同索引键长度下五种内存索引的范围查询性能。和对应场景的点查询性能表现类似,五种索引在索引键长度增加时吞吐都略有下降。

写入

我们使用长度为64Bytes的字符串类型的key和128Bytes的value,测试了五种内存索引在50million数据量的场景下的写入吞吐。实验结果显示,五种内存索引的写入性能较为接近。尽管内存索引的写入性能和点查询性能是有一定关联的,因为每次写入时找到插入位置的过程都类似一次点查询,然而写路径上还有一些加锁、修改数据结构、分配内存的操作,所以五种内存索引的实际写入性能差异不大。

Database · 最佳实践 · 高性能 Hash Join 算法实现简述

$
0
0

Hash Join 算法

Hash join 是利用 hash 函数来实现和加速数据库中 join 操作的一类算法。其主要优势来源于 hash 函数可以只通过一次运算就将任意键值映射到固定大小、固定值域的 hash 值。在实践中,针对等值 join 所需的等值比较,一般数据库系统会仔细选择和优化 hash 函数或函数簇,使其能够快速缩小需要和一个键值进行等值比较的其它键值的数量或范围,从而实现了通过减少计算量、内外存访问量等手段来降低 join 算法的执行开销。

Simple Hash Join

经典的 hash join 算法(又称 Simple Hash Join,SHJ)包括两步:

  • Build:选择两个输入 relation 中 cardinality 较小的一个(一般称其为 build relation),使用一个或一簇 hash 函数将其中的每一条记录的主键 key 值计算为一个 hash 值,然后根据 hash 值将该记录插入到一张表中,这张表就叫做 hash 表;
  • Probe:选择另一个 cardinality 较大的 relation (一般称为 probe relation),针对其中的每一条记录,使用和 build 中相同的 hash 函数,计算出相应的 hash 值,然后根据 hash 值在 hash 表中寻找到需要比较的记录,一一比较,得到最终结果。 经典的 SHJ 算法步骤简单直接,在过去的很长一段时间内是首选的 hash join 算法,但也需要拥抱变化。进入 21 世纪后,随着现代处理器的发展逐渐遇到了 frequency wall, memory wall 等瓶颈,处理器从高频单核架构逐渐演化为较低频的多核架构。此外,随着内存容量的不断增高,纯内存的分析型数据库查询逐渐流行,join 算法的执行开销瓶颈从 I/O 逐渐变为 CPU 和内存开销。因为 SHJ 算法中往 hash 表中插入记录和从 hash 表中读取记录往往都有大量的随机内存读写,而随机访问的代价在各个软硬件层面上都比顺序访问高,SHJ 算法在多核 CPU 处理器上逐渐尽显疲态。

    Partitioned Hash Join

    Partitioned Hash Join (分区 hash join,PHJ)算法在多核架构上逐渐取得了性能上的优势。和 SHJ 算法相比,PHJ 算法共用了完全相同的 build 和 probe 两个步骤,但是 PHJ 在 build 之前会先对输入 relation 进行 partition,然后针对每个 partition 应用 SHJ 算法。在这个算法过程中,我们有很多不同的选择:

  • 只对单个 relation 进行分区,还是对两个 relation 同时进行分区?
  • 对于一个 relation,将其分出多少个区来?
  • 给定分区总数,我们是一次性全部分配完成,还是逐级完成(比如从一个国家先分出省,再分出城市,最后分出区县)? 除此以外,在决定了上述 3 个问题以后,在算法的执行层次,也有很多优化空间可以研究(比如多并发、向量化、NUMA-aware、FPGA/GPU 加速等)。所以 hash join 算法的设计和实现在过去的很多年间都是系统界研究的热点。目前我们已经拥有众多详实和成熟的研究成果。

    常见性能问题

    Hash join 算法的常见性能问题包括同步所需的锁开销、cache miss、TLB miss、多线程间负载不均衡、NUMA 等等,下文简述一二。

  • 锁开销:在多线程的 SHJ 算法实现中,由于记录未经过分区,多个线程将操作一个相同的 hash 表,那么很有可能出现多个线程争抢同一个 hash bucket 的情况。为了实现线程间的同步,往往需要为每一个 hash bucket 加锁,那么等锁的开销就会成为一大热点。为了减少锁竞争,可以提高 hash bucket 的数量,减少线程的数量,或者使用乐观的无锁算法。此处往往存在各种 trade-off,比如更多的 hash bucket 数量对于一些 hash 表实现来说(如 linear hash 表)会带来更大的内存空间占用。
  • Cache miss:当 hash 表的数据量超过处理器 cache 大小的时候,对 hash 表的频繁访问有很大概率会遭受 cache miss,不得不访问内存。以 Intel 的某款 CPU 处理器为例,单个 CPU 上,L1 cache 有 32 KB 的数据空间(对比指令 cache),L2 cache 有 256 KB;同时存在多个 CPU 共享的 60 MB 的 L3 cache。对于 GB 级别以上的 hash 表来说,如此的 cache 大小杯水车薪。常见的有效解法就是使用 PHJ 算法,将 relation 不断分区,直至一个分区的 hash 表可以放进 cache 中,但此处分区本身也拥有较高的执行代价开销。实践中依然需要仔细处理此处的 trade-off。有的研究认为在拥有超线程能力的处理器中,因为同个 CPU core 上的多个(如 2 个)硬件线程可以轮流执行,那么可以通过执行一个线程中的计算指令来隐藏另一条线程中的访存 latency 开销,从而减少 cache miss 对总执行代价带来的影响。但对于数据库中的各种操作来说,它们绝大部分的工作往往就是读写数据,这样一来通过计算隐藏访存开销的效果就因为机会不足而十分有限了。
  • TLB miss:在一个 CPU 处理器的内存管理单元(MMU)中,有一个 Translation Lookaside Buffer (TLB)。TLB 往往缓存了最近的从虚拟内存地址到其相应的内存页的转换。如果一个访存操作命中了 TLB,那么就可以直接获得相应的内存页;反之,就必须去内存页表中查询相应的内存页,此处代价就高出很多了。由于 TLB 的 slot 条数是固定的,对于 PHJ 算法来说,如果一次分区操作的扇出(目标分区数量,fanout)高于 TLB 的 slot size,那么除去第一次访存时的 compulsory miss 以后,后续访存也会出现 TLB miss;fanout 越高,TLB miss 越多,多到我们不如我们少分一些区的地步。一种调优办法是进行 multi-pass partition,每个 pass 少分一些区,但是多跑几个 pass 来完成同样的分区总数。显然,multi-pass 会带来更多重复的内存读写,所以此处也存在 trade-off,需要根据实际情况进行优化。
  • ……

    关键实现细节举例

    上面我们简述了基本的算法概念和常见性能问题,现在我们举一个具体的性能优化的代码实现例子:如何使用 SIMD 指令对 hash 冲突的处理进行向量化。 Hash 冲突是 hash 表上的常见现象,即多个记录 hash 到了相同的 hash bucket 中。Hash 冲突对执行开销和内存空间开销的影响在不同的 hash 表实现中是不同的,但常见共性问题包括需要额外的 overflow space 来存储 hash 表本身装不下的有冲突的记录,以及冲突造成的互斥锁等待等。 向量化是常见的 hash join 实现优化技术,根据 Amdahl’s law,其加速效果取决于 hash join 算法的全过程中有多少部分能够被完完全全的向量化,数据尽可能多地存储在向量寄存器中,从而得到更高的加速比。如果对 hash 冲突的处理不能实现向量化,那么向量化的加速效果就会打折扣了。向量化指令一般属于 SIMD 类型(Single Instruction Multiple Data),如果一个向量中的多个 key 值内出现了 hash conflict,理论上就出现了至少两个 branch(无冲突 key 的处理和有冲突 key 的处理),而 SIMD 一定永远在向量上使用相同的指令,此处如何处理理论上的 branch 呢? 解决办法是使用 SIMD 的 gather 和 scatter 命令,以下是 AVX512 中处理 32位整形数据的 SIMD gether 和 scatter 的相应伪代码。

    __m512i _mm512_i32gather_epi32 (__m512i vindex, void const* base_addr, int scale)
    {
    FOR j := 0 to 15
        i := j*32
        m := j*32
        addr := base_addr + SignExtend64(vindex[m+31:m]) * ZeroExtend64(scale) * 8
        dst[i+31:i] := MEM[addr+31:addr]
    ENDFOR
    dst[MAX:512] := 0
    }
    
    void _mm512_mask_i32scatter_epi32 (void* base_addr, __mmask16 k, __m512i vindex, __m512i a, int scale)
    {
    FOR j := 0 to 15
        i := j*32
        m := j*32
        IF k[j]
            addr := base_addr + SignExtend64(vindex[m+31:m]) * ZeroExtend64(scale) * 8
            MEM[addr+31:addr] := a[i+31:i]
        FI
    ENDFOR
    }
    

    从中可以看出,对于一个装有 16 个 32 位整形数的 512 位向量来说,虽然表面上看一条读指令在这 16 个数(可以理解为地址 offset)应该同时并行执行,但指令内部其实是存在一个 for loop 的。也就是说,假如这个 for loop 的第 5 个和第 10 个 iteration 都往相同的 offset 写入数据,那么靠后的第 10 个 iteration 会覆盖前面的第 5 个 iteration 写入的结果。 利用这个特性,对于一个存储了 16 个 hash 值(每一个 hash 值对应 array 中的一个 offset 位置)的向量 V0 来说,我们只需要把它按照 scatter 的方式先写进内存中,再通过 gather 读回来获得 V1,然后比较 V0 和 V1 的内容,凡是不相等的 SIMD lane 即为因 hash 冲突而被覆盖的地方。我们只需利用 mask 将其标记出来,放在待处理的向量中留到下个 iteration 再插入 hash 表即可。具体的实现举例如下:

    if (size >= 16) do {
    // replace keys & payloads processed in the previous iteration with new values from the memory
    key = _mm512_mask_expandloadu_epi32  (key, k, &keys[i]);
    val = _mm512_mask_expandloadu_epi32  (val, k, &vals[i]); 
    off = _mm512_mask_xor_epi32(off, k, off, off);
    i += _mm_countbits_64(_mm512_kconcatlo_64(blend_0000, k));
    // hash keys
    __m512i factors = _mm512_mask_blend_epi32(k, mask_factor_2, mask_factor_1);
    __m512i buckets = _mm512_mask_blend_epi32(k, mask_buckets_minus_1, mask_buckets);
    __m512i hash = simd_hash(key, factors, buckets);
    // combine with old offset and fix overflows
    off = _mm512_add_epi32(off, hash);
    k = _mm512_cmpge_epu32_mask(off, mask_buckets);
    off = _mm512_mask_sub_epi32(off, k, off, mask_buckets);
    // load keys from table and detect conflicts
    __m512i tab = _mm512_i32gather_epi32(off, table, 8);
    k = _mm512_cmpeq_epi32_mask(tab, mask_empty);
    _mm512_mask_i32scatter_epi32(table, k, off, mask_pack, 8);
    tab = _mm512_mask_i32gather_epi32(tab, k, off, table, 8);
    k = _mm512_mask_cmpeq_epi32_mask(k, tab, mask_pack);
    // mix keys and payloads in pairs
    __m512i key_tmp = _mm512_permutevar_epi32(mask_pack, key);
    __m512i val_tmp = _mm512_permutevar_epi32(mask_pack, val);
    __m512i lo = _mm512_mask_blend_epi32(blend_AAAA, key_tmp, _mm512_swizzle_epi32(val_tmp, _MM_SWIZ_REG_CDAB));
    __m512i hi = _mm512_mask_blend_epi32(blend_5555, val_tmp, _mm512_swizzle_epi32(key_tmp, _MM_SWIZ_REG_CDAB));
    // store valid pairs
    _mm512_mask_i32loscatter_epi64(table, k, off, lo, 8);
    __mmask16 rev_k = _mm512_kunpackb (k,k>>8);
    __m512i rev_off = _mm512_permute4f128_epi32(off, _MM_PERM_BADC);
    _mm512_mask_i32loscatter_epi64(table, rev_k, rev_off, hi, 8);
    off = _mm512_add_epi32(off, mask_1);
    } while (i <= size_minus_16);
    

MySQL · 源码阅读 · Innodb内存管理解析

$
0
0

本文主要介绍innodb的内存管理,涉及基础的内存分配结构、算法以及buffer pool的实现细节,提及change buffer、自适应hash index和log buffer的基本概念和内存基本配比,侧重点在内存的分配和管理方式。本文所述内容基于mysql8.0版本。

基础内存分配

在5.6以前的版本中,innodb内部实现了除buffer pool外的额外内存池,那个时期lib库中的分配器在性能和扩展性上表现比较差,缺乏针对多核系统优化的内存分配器,像linux下最通用的ptmalloc的前身是Doug Lea Malloc,也是因为不支持多线程而被弃用了。所以innodb自己实现了内存分配器,使用额外的内存池来响应那些原本要发给系统的内存请求,用户可以通过设置参数innodb_use_sys_malloc 来选择使用innodb的分配器还是系统分配器,使用innodb_additional_mem_pool_size参数设定额外内存池的大小。随着多核系统的发展,一些分配器对内部实现进行了优化和扩展,已经可以很好的支持多线程,相较于innodb特定的内存分配器可以提供更好的性能和扩展性,所以这个实现在5.6版本已经弃用,5.7版本删除。本文所讨论的内容和涉及到的代码基于mysql8.0版本。

innodb内部封装了基础分配释放方式malloc,free,calloc,new,delete等,在开启pfs模式下,封装内部加入了内存追踪信息,使用者可传入对应的key值来记录某个event或者模块的内存分配信息,这部分信息通过pfs内部表来对外展示,以便分析内存泄漏、内存异常等问题。

innodb内部也提供了对系统基础分配释放函数封装的allocator,用于std::*容器内部的内存分配,可以让这些容器内部的隐式分配走innodb内部封装的接口,以便于内存信息的追踪。基础的malloc/calloc等封装后也会走allocator的接口。 基础封装:

非UNIV_PFS_MEMORY编译模式
#define UT_NEW(expr, key) ::new (std::nothrow) expr
#define UT_NEW_NOKEY(expr) ::new (std::nothrow) expr
#define UT_DELETE(ptr) ::delete ptr
#define UT_DELETE_ARRAY(ptr) ::delete[] ptr
#define ut_malloc(n_bytes, key) ::malloc(n_bytes)
#define ut_zalloc(n_bytes, key) ::calloc(1, n_bytes)
#define ut_malloc_nokey(n_bytes) ::malloc(n_bytes)
...

打开UNIV_PFS_MEMORY
#define UT_NEW(expr, key)                                                \
  ::new (ut_allocator<byte>(key).allocate(sizeof expr, NULL, key, false, \
                                          false)) expr
#define ut_malloc(n_bytes, key)                         \
  static_cast<void *>(ut_allocator<byte>(key).allocate( \
      n_bytes, NULL, UT_NEW_THIS_FILE_PSI_KEY, false, false))

#define ut_zalloc(n_bytes, key)                         \
  static_cast<void *>(ut_allocator<byte>(key).allocate( \
      n_bytes, NULL, UT_NEW_THIS_FILE_PSI_KEY, true, false))

#define ut_malloc_nokey(n_bytes)               \
  static_cast<void *>(                         \
      ut_allocator<byte>(PSI_NOT_INSTRUMENTED) \
          .allocate(n_bytes, NULL, UT_NEW_THIS_FILE_PSI_KEY, false, false))
  ...

可以看到在非UNIV_PFS_MEMORY编译模式下,直接调用系统的分配函数,忽略传入的key,而UNIV_PFS_MEMORY编译模式下使用ut_allocator分配,下面有ut_allocator的介绍,比较简单的封装。

memory heap

主要管理结构为mem_heap_t,8.0中的实现比较简单,内部维护block块的链表,包含指向链表开始和尾部的指针可以快速找到链表头部和尾部的节点,每个节点都是mem_heap_t的结构。mem_heap在创建的时候会初始一块内存作为第一个block,大小可由使用者指定。mem_heap_alloc响应基本的内存分配请求,先尝试从block中切分出满足请求大小的内存,如果不能满足则创建一个新的block,新的block size至少为上一个block的两倍(last block),直到达到规定的上限值,新创建的block总是链到链表的尾部。mem_heap_t中记录了block的size和链表中block的总size以及分配模式(type)等信息,基本结构如下图: innodb在内部定义了三种分配block模式供选择:

  1. MEM_HEAP_DYNAMIC 使用malloc动态分配,比较通用的分配模式
  2. MEM_HEAP_BUFFER  size满足一定条件,使用buffer pool中的内存块
  3. MEM_HEAP_BTR_SEARCH  保留额外的内存,地址保存在free_block中 
    一般的使用模式为MEM_HEAP_DYNAMIC,也可以使用b|c的模式,在某些情况下使用free_block中的内存。mem heap链表中的block在最后统一free,按照分配模式走不同的free路径。

我理解基本思想也是使用一次性分配大内存块,再从大内存块中切分来响应小内存分配请求,以避免多次调用malloc/free,减少overhead。实现上比较简单,内部只是将多个大小可能不一的内存块使用链表链起来,大多场景只有一个block。没有内存归还、复用和合并等机制,使用过程中不会将内存free,会有一定程度的内存浪费,但有效减少了内存碎片,比较适用于短周期多次分配小内存的场景。

基础allocator

ut_allocator

innodb内部提供ut_allocator用来为std::* 容器分配内存,内部封装了基础的内存分配释放函数,以便于内存追踪和统一管理。 ut_allocator提供基本的allocate/deallocate的分配释放函数,用于分配n个对象所需内存,内部使用malloc/free。另外也提供了大内存块的分配,开启LINUX_LARGE_PAGES时使用HugePage,在服务器内存比较大的情况可以减少页表条目提高检索效率,未开启时使用mmap/munmap内存映射的方式。 更多细节见如下代码:

/** Allocator class for allocating memory from inside std::* containers. */
template <class T>
class ut_allocator {
	// 分配n个elements所需内存大小,内部使用malloc/calloc分配
  // 不开启PFS_MEMORY分配n_elements * sizeof(T)大小的内存
  // 开启PFS_MEMORY多分配sizeof(ut_new_pfx_t)大小的内存用于信息统计
  pointer allocate(size_type n_elements, const_pointer hint = NULL,
                   PSI_memory_key key = PSI_NOT_INSTRUMENTED,
                   bool set_to_zero = false, bool throw_on_error = true);
                   
  // 释放allocated()分配的内存,内部使用free释放                  
  void deallocate(pointer ptr, size_type n_elements = 0)
  
  // 分配一块大内存,如果开启了LINUX_LARGE_PAGES使用HugePage
  // 否则linux下使用mmap
  pointer allocate_large(size_type n_elements, ut_new_pfx_t *pfx)
  // 对应allocate_large,linux下使用munmap
  void deallocate_large(pointer ptr, const ut_new_pfx_t *pfx)
  
  // 提供显示构造/析构func
  void construct(pointer p, const T &val) { new (p) T(val); }
  void destroy(pointer p) { p->~T(); }
  
  开启PFS_MEMORY场景下提供更多可用func:
  pointer reallocate(void *ptr, size_type n_elements, PSI_memory_key key);
  // allocate+construct的封装, 分配n个单元的内存并构造相应对象实例
  pointer new_array(size_type n_elements, PSI_memory_key key);
  // 同上,deallocate+destroy的封装
  void delete_array(T *ptr);
}

mem_heap_allocator

mem_heap_t的封装,可以作为stl allocator来使用,内部使用mem_heap_t的内存管理方式。

/** A C++ wrapper class to the mem_heap_t routines, so that it can be used
as an STL allocator */
template <typename T>
class mem_heap_allocator {
  mem_heap_allocator(mem_heap_t *heap) : m_heap(heap) {}
  pointer allocate(size_type n, const_pointer hint = 0) {
    return (reinterpret_cast<pointer>(mem_heap_alloc(m_heap, n * sizeof(T))));
  }
  void deallocate(pointer p, size_type n) {}
  // 提供显示构造/析构func
  void construct(pointer p, const T &val) { new (p) T(val); }
  void destroy(pointer p) { p->~T(); }
  
 private:
  mem_heap_t *m_heap;
}

buddy

innodb支持创建压缩页以减少数据占用的磁盘空间,支持1K, 2K, 4K 、8K和16k大小的page。buddy allocator用于管理压缩page的内存空间,提高内存使用率和性能。

buffer pool中:
UT_LIST_BASE_NODE_T(buf_buddy_free_t) zip_free[BUF_BUDDY_SIZES_MAX];

/** Struct that is embedded in the free zip blocks */
struct buf_buddy_free_t {
  union {
    ulint size; /*!< size of the block */
    byte bytes[FIL_PAGE_DATA];
  } stamp;
  buf_page_t bpage; /*!< Embedded bpage descriptor */
  UT_LIST_NODE_T(buf_buddy_free_t) list;
};

伙伴系统也是比较经典的内存分配算法,也是linux内核用于解决外部碎片的一种手段。innodb的实现在算法上与buddy的基本实现并无什么区别,所支持最小的内存块为1k(2^10),最大为16k,每种内存块维护一个链表,多种内存块链表组成了zip_free链表。 分配入口在buf_buddy_alloc_low,先尝试从zip_free[i]中获取所需大小的内存块,如果当前链表中没有,则尝试从更大的内存块链表中获取,获取成功则进行切分,一部分返回另一块放入对应free链表中,实际上是buf_buddy_alloc_zip的一个递归调用,只是传入的i不断增加。如果一直递归到16k的块都没法满足,则从buffer pool中新申请一块大内存块,并将其按照伙伴关系进行(比如现分配了16,需要2k,先切分8k,8k,再将其中一个8k切分为4k,4k,再将其中4k切分为2k,2k)切分直到满足分配请求。 释放入口在buf_buddy_free_low,为了避免碎片在释放的时候多做了一些事情。在释放一个内存块的时候没有直接放回对应链表中,而是先查看其伙伴是不是free的,如果是则进行合并,再尝试对合并后的内存块进行合并。如果其伙伴是在USED的状态,这里做了一次relocate操作,将其内容拷贝到其它free的block块上,再进行对它合并。这种做法有效减少了碎片的存在,但拷贝这种操作也降低了性能。

buffer pool

buffer pool是innodb主内存中一块区域,用于缓存主表和索引中的数据,读线程可以直接从buffer pool中读取相应数据从而避免io提升读取性能,当一个页面需要修改时,先在buffer pool中进行修改,另有后台线程来负责刷脏页。一般在专用服务器中,会将80%的内存分配给buffer pool使用。数据库启动时就会将内存分配给buffer pool,不过内存有延迟分配的优化,这部分内存在未真正使用前是没有进行物理映射的,所以只会影响虚存大小。buffer pool的内存在运行期间不会收缩还给系统,在数据库关闭时将这部分内存统一释放。可以设置多个buffer pool实例。

buffer pool中使用chunk内存块来管理内存,每个buffer pool实例包含一个或多个chunk,chunks在buffer pool初始化时使用mmap分配,并初始化为多个block。每个block地址相差UNIV_PAGE_SIZE,UNIV_PAGE_SIZE一般是16kb,这块内存包含了page相关控制信息和真正的数据page两部分,之后将这些page加入free list中供使用。这里直接使用了mmap而非malloc,是因为在glibc的ptmalloc内存分配器中,大于MMAP_THRESHOLD阀值的内存请求也是使用mmap,MMAP_THRESHOLD默认值是128k,buffer pool的配置大小一般会远大于128k。

数据结构

buffer pool进行内存管理的主要数据结构。

buf_pool_t

控制buffer pool的主结构,内部包含多种逻辑链表以及相关锁信息、统计信息、hash table、lru和flush算法相关等信息。

struct buf_pool_t {
	//锁相关 
  BufListMutex chunks_mutex;    /*!< protects (de)allocation of chunks*/
  BufListMutex LRU_list_mutex;  /*!< LRU list mutex */
  BufListMutex free_list_mutex; /*!< free and withdraw list mutex */
  BufListMutex zip_free_mutex;  /*!< buddy allocator mutex */
  BufListMutex zip_hash_mutex;  /*!< zip_hash mutex */
  ib_mutex_t flush_state_mutex; /*!< Flush state protection mutex */
  BufPoolZipMutex zip_mutex;    /*!< Zip mutex of this buffer */

  // index、各种size、数量统计
  ulint instance_no;            /*!< Array index of this buffer pool instance */
  ulint curr_pool_size;         /*!< Current pool size in bytes */
  ulint LRU_old_ratio;          /*!< Reserve this much of the buffer pool for "old" blocks */
#ifdef UNIV_DEBUG
  ulint buddy_n_frames; 
#endif
  ut_allocator<unsigned char> allocator; // 用于分配chunks
  volatile ulint n_chunks;     /*!< number of buffer pool chunks */
  volatile ulint n_chunks_new; /*!< new number of buffer pool chunks */
  buf_chunk_t *chunks;         /*!< buffer pool chunks */
  buf_chunk_t *chunks_old;    
  ulint curr_size;             /*!< current pool size in pages */
  ulint old_size;              /*!< previous pool size in pages */
  
  // hash table, 用于索引相关数据页
  page_no_t read_ahead_area;   
  hash_table_t *page_hash;    
  hash_table_t *page_hash_old; 
  hash_table_t *zip_hash;     
  
  // 统计信息相关
  ulint n_pend_reads;         
  ulint n_pend_unzip;         
  time_t last_printout_time;
  buf_buddy_stat_t buddy_stat[BUF_BUDDY_SIZES_MAX + 1];
  buf_pool_stat_t stat;     /*!< current statistics */
  buf_pool_stat_t old_stat; /*!< old statistics */
  
  // flush相关
  BufListMutex flush_list_mutex; 
  FlushHp flush_hp;             
  UT_LIST_BASE_NODE_T(buf_page_t) flush_list;
  ibool init_flush[BUF_FLUSH_N_TYPES];
  ulint n_flush[BUF_FLUSH_N_TYPES];
  os_event_t no_flush[BUF_FLUSH_N_TYPES];
  ib_rbt_t *flush_rbt;   
  ulint freed_page_clock; 
  ibool try_LRU_scan;     
  lsn_t track_page_lsn; /* Pagge Tracking start LSN. */
  lsn_t max_lsn_io;
  UT_LIST_BASE_NODE_T(buf_page_t) free;
  UT_LIST_BASE_NODE_T(buf_page_t) withdraw;
  ulint withdraw_target; 
  
  // lru 相关
  LRUHp lru_hp;
  LRUItr lru_scan_itr;
  LRUItr single_scan_itr;
  UT_LIST_BASE_NODE_T(buf_page_t) LRU;
  buf_page_t *LRU_old; 
  ulint LRU_old_len;   
  UT_LIST_BASE_NODE_T(buf_block_t) unzip_LRU;
#if defined UNIV_DEBUG || defined UNIV_BUF_DEBUG
  UT_LIST_BASE_NODE_T(buf_page_t) zip_clean;
#endif /* UNIV_DEBUG || UNIV_BUF_DEBUG */
  UT_LIST_BASE_NODE_T(buf_buddy_free_t) zip_free[BUF_BUDDY_SIZES_MAX];
  buf_page_t *watch;
};

buf_page_t

数据页的控制信息,包含数据页的大部分信息,page_id、size、引用计数(io,buf),access_time(用于lru调整),page_state以及压缩页的一些信息。

class buf_page_t {
 public:
  page_id_t id;
  page_size_t size;
  uint32_t buf_fix_count;
  buf_io_fix io_fix;
  buf_page_state state;
  the flush_type.  @see buf_flush_t */
  unsigned flush_type : 2;
  unsigned buf_pool_index : 6;
  page_zip_des_t zip; 
#ifndef UNIV_HOTBACKUP
  buf_page_t *hash; 
#endif              /* !UNIV_HOTBACKUP */
#ifdef UNIV_DEBUG
  ibool in_page_hash; /*!< TRUE if in buf_pool->page_hash */
  ibool in_zip_hash;  /*!< TRUE if in buf_pool->zip_hash */
#endif                /* UNIV_DEBUG */
  UT_LIST_NODE_T(buf_page_t) list;
#ifdef UNIV_DEBUG
  ibool in_flush_list; 
  ibool in_free_list;  
#endif                 /* UNIV_DEBUG */
  FlushObserver *flush_observer; /*!< flush observer */
  lsn_t newest_modification;
  lsn_t oldest_modification;
  UT_LIST_NODE_T(buf_page_t) LRU;
#ifdef UNIV_DEBUG
  ibool in_LRU_list; 
#endif               /* UNIV_DEBUG */
#ifndef UNIV_HOTBACKUP
  unsigned old : 1;               
  unsigned freed_page_clock : 31; 
  unsigned access_time; 
#ifdef UNIV_DEBUG
  ibool file_page_was_freed;
#endif /* UNIV_DEBUG */
#endif /* !UNIV_HOTBACKUP */
};

逻辑链表

free list

包含空闲的pages,当需要从buffer pool中分配空闲块时从free list中摘取,当free list为空时需要从LRU、unzip_LRU或者flush list中进行淘汰或刷脏以填充free list。buffer pool初始化时创建多个chunks,划分的pages都加入free list中待使用。

LRU list

最重要的链表,也是缓存占比最高的链表。buffer pool使用lru算法来管理缓存的数据页,所有从磁盘读入的数据页都会先加入到lru list中,也包含压缩的page。新的page默认加入到链表的3/8处(old list),等待下次读取并满足一定条件后再从old list加入到young list中,以防止全表扫描污染lru list。需要淘汰时从链表的尾部进行evict。

unzip_LRU list

是LRU list的一个子集,每个节点包含一个压缩的page和指向对应解压后的page的指针。

flush list 

已修改过还未写到磁盘的page list,按修改时间排序,带有最老的修改的page在链表的最尾部。当需要刷脏时,从flush list的尾部开始遍历。

zip_clean list

包含从磁盘读入还未解压的page,page一旦被解压就从zip_clean list中删除并加入到unzip_LRU list中。

zip_free

用于buddy allocator的空闲block list,buddy allcator是专门用于压缩的page(buf_page_t)和压缩的数据页的分配器。

主要操作

初始化buffer pool

buffer pool在db启动时调用buffer_pool_create进行初始化,使用mmap创建配置大小的虚拟内存,并划分为多个chunks,其它字段基本使用calloc分配(memset)。

buf_page_get_gen

从buffer pool中读取page,比较重要的操作。通过page_id获取对应的page,如果buffer pool中有这个page则封装一些信息后返回,如果没有则需要从磁盘中读入。读取模式分为以下7种:

NORMAL

在page hash table中查找(hash_lock s模式),如果找到增加bufferfix cnt并释放hash_lock,如果该page不在buffer pool中则以sync模式从磁盘中读取,并加入对应的逻辑链表中,判读是否需要线性预读。如果第一次访问buffer pool中的该page,设置访问时间并判断是否需要线性预读。判断是否需要加入到young list中。

SCAN

如果page不在buffer pool中使用异步读取磁盘的模式,不做随机预读和线性预读,不设置访问时间不加入young list,其它与normal一样。

IF_IN_POOL

只在buffer pool中查找page,如果没有找到则返回NOT_FOUND。

PEEK_IF_IN_POOL

这种模式仅仅用来drop 自适应hash index,跟IF_IN_POOL类似,只是不加入young list,不做线性预读。

NO_LATCH

读取并设置buffefix,但是不加锁。

IF_IN_POOL_OR_WATCH

与IF_IN_POOL类似,只在buffer pool中查找此page,如果没有则设置watch。

POSSIBLY_FREED

与normal类似,只是允许执行过程中page被释放。

buf_LRU_get_free_block

从buffer pool的free list中摘取一个空闲页,如果free list为空,移除lru链表尾部的block到free list中。这个函数主要是用于用户线程需要一个空闲block来读取数据页。具体操作如下:

  1. 如果free list不为空,从中摘取并返回。否则转下面的操作。
  2. 如果buffer pool设置了try_LRU_scan,遍历lru链表尝试从尾部释放一个空闲块加入free list中。如果unzip_LRU链表不为空,则先尝试从unzip_LRU链表中释放。如果没有找到再从lru链表中淘汰。
  3. 如果没有找到则尝试从lru中flush dirty page并加入到free list中。
  4. 没有找到,设置scan_all重复上述过程,与第一遍不同的地方在于需要scan整个lru链表。
  5. 如果遍历了整个lru链表依然没有找到可以淘汰的block,则sleep 10s等待page cleaner线程做一批淘汰或者刷脏。
  6. 重复上述过程直到找到一个空闲block。超过20遍设置warn信息。

page cleaner

系统线程,负责定期清理空闲页放入free list中和flush dirty page到磁盘上,dirty page指那些已经被修改但是还未写到磁盘上的数据页。使用innodb_page_cleaners 参数设定page cleaner的线程数,默认是4。通过特定参数控制dirty page所占buffer pool的空间比维持在一定水位下,默认是10%。

flush list

上面章节提到过flush_list是包含dirty page并按修改时间有序的链表,在刷脏时选择从链表的尾部进行遍历淘汰,代码主体在buf_do_flush_list_batch中。这里不得不提的一个巧妙的操作,叫作Hazard Pointer,buf_pool_t中的flush_hp,将整体遍历复杂度由最差O(nn)降到了O(n)。之所以复杂度最差会变为O(nn)是由于flush list允许多个线程并发刷脏,每次从链表尾部进行遍历,使用异步io的方式刷盘,在io完成后将page从链表中摘除,每次提交异步io后从链表尾部再次扫描,在刷盘速度比较慢的情况下,可能每次都需要跳过之前已经flush过的page,最差会退化为O(n*n)。 flush_hp: 用于刷脏遍历flush list过程,flush_list_mutex保护下修改,在处理当前page之前,将hazard pointer设置为下一个要遍历的buf_page_t的指针,为线程指定下一个需要处理的page。当前page刷盘时会释放flush_list_mutex,刷盘完成后重新获得锁,处理flush_hp指向的page,无论这中间发生过什么,链表如何变动,flush_hp总是被设置成为下一个有效的buf_page_t指针。所以复杂度总能保证为O(n)。 刷脏的过程中也做了一些优化,代码在buf_flush_page_and_try_neighbors可以将当前page相邻的dirty page页也一起刷盘,目的是将多个随机io转为顺序io减少overhead,这在传统HHD的设备上比较有用,在SSD上seek time已经不是显著的影响因素。可以使用参数innodb_flush_neighbors进行设置和关闭:

  1. 为0则关闭flush neighbors的优化
  2. 默认值为1,flush与当前page相同extent(1M)上的连续dirty page
  3. 为2则flush与当前page相同extent上的dirty page

    flush LRU list

    buf_flush_LRU_list主要完成两件事:

  4. 将lru list尾部的可以移除的pages放入到free list中
  5. 将lru list尾部的dirty page刷到磁盘。

同一时刻只会有一个page cleaner线程对同一个LRU list操作。lru list遍历的深度由动态参数innodb_lru_scan_depth决定,用于优化io敏感的场景,默认值1024,设置比默认值小的值可以适应大多数work load,设置过大会影响性能,尤其是在buffer pool足够大的情况。操作过程在LRU_list_mutex的保护下,代码主体在buf_do_LRU_batch其中涉及到unzip_LRU list和LRU list中的淘汰,unzip_LRU list不一定每次都会做淘汰操作,衡量内存大小和负载情况,只有在size超出buffer pool的1/10以及当前负载为io bound的情况才会做。代码主体在buf_free_from_unzip_LRU_list_batch中, 将uncompressed page移除到free list中,并不会将任何数据刷盘,只是将解压缩的frames与压缩page分离。 如果在上述操作后仍无法达到需要释放的page数量(遍历深度),则继续从lru list尾部进行遍历,操作在buf_flush_LRU_list_batchlru list的遍历同样使用了Hazard Pointer,buf_pool_t中的lru_hp。当前page如果是clear并且没有被io fixed和buffer fixed,则从lru list中移除并加入free list中,否则如果page是已经修改过的并且满足flush的条件则对其进行刷脏。

小结

innodb设定了buffer pool的总大小,空闲page不够用时会将lru链表中可替换的页面移到free list中,根据统计信息估计负载情况来决定淘汰的策略。所有的block在几种状态之间进行转换,unzip_LRU、flush list设置一定的上限,设置多个影响淘汰和刷脏策略的参数,以达到不同负载不同buffer pool size下的性能和内存之间的平衡。

change buffer

change buffer是用于缓存不在buffer pool中的二级索引页改动的数据结构,insert、update或者delete这些DML操作会引起buffer的改变,page被读入时与change buffer中的修改合并加入buffer pool中。引入buffer pool的目的主要减少随机io,对于二级索引的更新经常是比较随机的,当页面不在buffer pool中时将其对应的修改缓存在change buffer中可有效地减少磁盘的随机访问。可以通过参数 innodb_change_buffering设置对应缓存的操作:all, none, inserts, deletes, changes(inserts+deletes), purges。change buffer的内存也是buffer pool中的一部分,可以通过参数innodb_change_buffer_max_size来设置内存占比,默认25%,最多50%。

Adaptive Hash Index

innodb的索引组织结构为btree,当查询的时候会根据条件一直索引到叶子节点,为了减少寻路的开销,AHI使用索引键的前缀建立了一个哈希索引表,在实现上就是多个个hash_tables(分片)。哈希索引是为那些频繁被访问的索引页而建立的,可以理解为btree上的索引。看代码初始创建的数组大小为buf_pool_get_curr_size() / sizeof(void *) / 64,其实是比较小的一块内存,使用malloc分配。

log buffer

log buffer是日志未写到磁盘的缓存,大小由参数innodb_log_buffer_size指定,一般来说这块内存都比较小,默认是16M。在有大事务的场景下,在事务未commited之前可以将redo日志数据一直缓存,避免多次写磁盘,可以将log buffer调大。

参考资料:
https://dev.mysql.com/doc/refman/5.6/en/innodb-performance-use_sys_malloc.htmlhttps://dev.mysql.com/doc/refman/8.0/en/innodb-in-memory-structures.html

X-Engine · 引擎特性 · 并行DDL

$
0
0

概述

X-Engine是阿里基于LSM-tree的自研存储引擎,作为MySQL生态的存储引擎,目前MySQL(XEngine)已经完整支持所有的DDL类型,对于OnlineDDL的特性,XEngine引擎也具备了与InnoDB引擎同样的能力,包括instant-Online DDL,Inplace-OnlineDDL等。OnlineDDL特性解决了DDL过程堵塞写的问题,但是目前DDL过程仍然还是比较慢。这主要是因为新建索引/修改主键这类DDL需要扫描全表数据,是一个常规且耗时的操作,一个大表的DDL完成时间甚至可能要以天计。

PolarDB最近发布的并行DDL特性就是为了解决这个问题,目前PolarDB同时支持InnoDB和X-Engine两种存储引擎,而且两种引擎都支持并行DDL。本文重点XEngine引擎如何通过并行来加速DDL执行。X-Engine的DDL主要包括两个部分:基线数据的新建索引构造,增量数据的双写(写入旧表的同时构造要新建的索引),具体可参考X-Engine OnlineDDL。本文所做工作是并行化基线数据索引构造过程,基于X-Engine现有的并行扫描功能实现并行DDL,X-Engine的并行扫描功能可以参考X-Engine并行扫描

串行DDL

串行DDL本质上是一个串行外部排序的过程。首先根据主键扫描所有record,构造新索引的record。每扫描生成一段,排序后输出为一个run,最后将所有run(s)归并排序后输出。当然如果run(s)个数过多,即归并排序路数过多,无法容纳于内存工作区,则需要多轮归并。

就第一个步骤生成run(s),目前典型有两种方式:

  1. 内部排序,如快排
  2. 选择-置换排序

选择-置换排序是一种树形排序算法,平均和最差时间复杂度均为O(nlogw),w为树的大小。快排的平均时间复杂度为O(nlogn),最差时间复杂度为O(n^2)。当用于排序的内存工作区大小相同时,两者的平均时间复杂度可以视为相同。虽然快排的最差时间复杂度等同于O(nw),但其cache友好性更强,排序效率更高。选择-置换排序算法的优点在于能产生大于工作区内存大小的run(平均为两倍工作区内存大小),run的size更大,则run个数更少,则第二步的merge sort的路数更少,merge sort速度更快。

就第二个步骤merge sort而言,目前通常使用tournament tree,tournament tree分为两种:winner tree和looser tree。winner tree也就是我们普遍使用的堆排算法,looser tree是winner tree的优化版本,两者在时间复杂度上是一样的,都为O(nlogw),但looser tree叶子节点上浮时除第一次需要和兄弟节点对比外,之后只需要和父节点做对比,效率更高。

考虑到实现新的数据结构的效果可能不如STL库内现有数据结构,同时可能会引入一些内存分配问题,目前X-Engine中使用STL中的sort算法(混合内存排序)排序生成run,用STL的优先队列实现第二步merge sort。

并行DDL

并行DDL本质上就是并行外部排序的过程。

步骤一生成runs(s)的并行化比较直接,通过并行扫描排序生成run,X-Engine的并行扫描以extent为粒度,粒度较小,分割数据分片很均匀。因此步骤一的性能基本可以随并行度得到线性提升。

步骤二merge sort run(s)的并行化会稍微复杂一些。第一种方案是两两并行merge,假设有m个run(s),总records个数为n,这种方式的内存会放大(m-1)倍,如果中间结果是以输出到临时文件的方式,那么IO也会放大(m-1)倍。该方案的时间复杂度为O(nlog2)。当然为减少内存和IO放大,也可以用并行n-way merge。

如果最终需要merge一次全量数据的话,第一种方案已经是速度最快的方案(假设资源充分)。第二种思路就是基于partition和redistribution,典型代表为sample sort算法。sample sort的核心思想是通过采样选取出splitters,然后根据splitters将数据分配到子区间内,此时子区间间互相有序,子区间内部无序,这是每个子区间单独排序,最后就可以做到全局有序。这里我们借鉴了Polardb(Innodb)将sample sort应用于外部排序的做法。具体步骤如下(以并行扫描和merge sort并发度等于4为例):

  1. Scan:并行扫描全表,构造生成内部有序的run(s),每个run会等距采样一些点。
  2. Local merge: 对所有选出的采样点排序,最终等距选出三个splitters。每个线程做一次local merge,并根据splitters将输出结果分成4片。
  3. Global merge: 同一子区间的分片做一次global merge,完成排序,全局数据有序。

pddl

非unique二级索引优化

对于不需要判重的二级索引,不同于innodb需要保证数据完全有序,X-Engine的LSM分层架构中,某一层的extent的数据范围可以存在overlap。X-Engine的OnlineDDL实现中,基线数据构建完毕后会写入L2,因此对于不需要判重的二级索引,只需要完成第一个步骤,并行扫描生成内部有序的run(s)(此时需要直接以extent形式组织这个run),然后就可以直接输出到L2中。这可以极大地提高DDL的响应时间,但带来的tradeoff是在L2中访问数据时可能会访问多个extent,但后续L2触发compaction会不断归并overlap的extent,从而减少overlap对读的影响。由于X-Engine目前的现有设计是只允许L0层存在overlap,L1,L2设定为严格有序,因此本方案对系统的整体改动比较大,目前暂时还未实际应用。

性能测试

测试环境

硬件:96core,768G内存

操作系统:linux centos7

数据库:MySQL8.0.17

sysbench导入一亿条数据,分别设置并发线程数(三个步骤的并发度相同)为1,2,4,8,16,32,64,测试添加一个二级索引的时间开销。统一各层的压缩方式,这里选择关闭压缩,这样每个extent中分布的kv个数接近,并行扫描的分片粒度会更加均匀。所有主键数据都load到内存中,并行扫描是纯内存操作。

测试结果

test result XEngine并行DDL效果非常不错,对比串行ddl,2线程得到1.7倍性能提升,4线程提升3.38倍,8线程提升5.84倍,16线程提升8.72倍。在32线程并发下,1亿条记录的表,添加一个二级索引只需要15.63s。

结果分析

  1. 并行扫描构建索引速度随线程呈线程增长,这是因为X-Engine并行扫描分片粒度很小,分片效果很好。
  2. Local merge时间开销先减小后增大,原因是随着线程数增加,数据分片增加,某些子区间可能数据量很少,则少量数据就写出去一个block,IO请求增多。
  3. Global merge时间开销在1-32线程速度基本是成倍提升,但64线程时未得到成倍提升。这是因为global merge的归并路数为local merge的线程数,则64线程时,global merge的归并路数显著增加,单条数据排序速度减慢的负向影响超过了数据分片增加的正向影响。

总结与展望

目前X-Engine的并行DDL已在RDS-XEngine中完成开发并上线,之后这项功能会移植到PolarDB-XEngine历史库中,并根据Polarfs进行优化,希望能给更多的X-Engine用户带来更好的使用体验。

Viewing all 692 articles
Browse latest View live