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

MySQL · 内核特性 · 8.0 新的火山模型执行器

$
0
0

MySQL的总体架构

通常我们认为MySQL的整体架构如下,

1.png

官方10年前开始就一直在致力于优化器代码的重构工作,目的是能确保在SQL的执行过程中有清晰的阶段,包括分离Parse和Resolve阶段、更好的支持更多新的语法、保证Name和Type的解析、尽量在Prepare阶段做完Transformations,这样能够很容易的支持CTEs、Windows函数、LATERAL语法、JSON的Table函数和Windows函数。当然,最重要的重构当数Iterator执行器。这个早期MySQL版本的优化器执行器逻辑:

2.png

不过本文还要普及一些MySQL基础的知识。

MySQL的AST枝干(基于8.0.20)

首先我们先了解Parse后的AST树到底是长什么样子?先了解重要的两个结构: SELECT_LEX & SELECT_LEX_UNIT

SELECT_LEX: 代表了SELECT本身,也就是SQL SELECT的关键字就会对应一个SELECT_LEX的结构。 SELECT_LEX_UNIT: 代表了带UNION的一堆SELECT,当然也可以代表一个SELECT。

下面这段是SELECT_LEX_UNIT/SELECT_LEX的类结构中,和查询层次相关的一些成员变量

class SELECT_LEX_UNIT {
  /**
    Intrusive double-linked list of all query expressions
    immediately contained within the same query block.
  */
  SELECT_LEX_UNIT *next;
  SELECT_LEX_UNIT **prev;
  /**
    The query block wherein this query expression is contained,
    NULL if the query block is the outer-most one.
  */
  SELECT_LEX *master;
  /// The first query block in this query expression.
  SELECT_LEX *slave;
  ......
  /**
    Helper query block for query expression with UNION or multi-level
    ORDER BY/LIMIT
  */
  SELECT_LEX *fake_select_lex
  ......
    /// @return the query block this query expression belongs to as subquery
  SELECT_LEX *outer_select() const { return master; }
  /// @return the first query block inside this query expression
  SELECT_LEX *first_select() const { return slave; }
  /// @return the next query expression within same query block (next subquery)
  SELECT_LEX_UNIT *next_unit() const { return next; }
  ......
}
class SELECT_LEX {
 ......
 private:
  /**
    Intrusive double-linked list of all query blocks within the same
    query expression.
  */
  SELECT_LEX *next;
  SELECT_LEX **prev;
  /// The query expression containing this query block.
  SELECT_LEX_UNIT *master;
  /// The first query expression contained within this query block.
  SELECT_LEX_UNIT *slave;
  /// Intrusive double-linked global list of query blocks.
  SELECT_LEX *link_next;
  SELECT_LEX **link_prev;
  ......
  SELECT_LEX_UNIT *master_unit() const { return master; }
  void set_master(SELECT_LEX_UNIT *src) {  master = src; }
  SELECT_LEX_UNIT *first_inner_unit() const { return slave; }
  SELECT_LEX *outer_select() const { return master->outer_select(); }
  SELECT_LEX *next_select() const { return next; }
  ......
}

我们来拿一个真实的例子来举例说明:

  (SELECT *
   FROM ttt1)
UNION ALL
  (SELECT *
   FROM
     (SELECT *
      FROM ttt2) AS a,
     (SELECT *
      FROM ttt3
      UNION ALL SELECT *
      FROM ttt4) AS b)

实际中该查询的标准内存结构就是这样的,

3.png

这里需要注意的是,MySQL官方的 [WL#5275: Process subqueries in FROM clause in the same way as] https://dev.mysql.com/worklog/task/?id=5275 view支持可以把子查询提升到上层查询中。优化器会调用SELECT_LEX::resolve_placeholder_tables -> SELECT_LEX::merge_derived来避免materialize那些可以提升到上层查询的子查询。外部也可以通过set optimizer_switch=’derived_merge=on/off’来进行开关,下面来对比下8.0.13和8.0.18对于该优化的执行计划展现。

8.0.13 derived_merge off vs on
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+
| id | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra                                 |
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+
|  1 | PRIMARY     | ttt1       | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | NULL                                  |
|  2 | UNION       | <derived3> | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    2 |   100.00 | NULL                                  |
|  2 | UNION       | <derived4> | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    4 |   100.00 | Using join buffer (Block Nested Loop) |
|  4 | DERIVED     | ttt3       | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | NULL                                  |
|  5 | UNION       | ttt4       | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | NULL                                  |
|  3 | DERIVED     | ttt2       | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | NULL                                  |
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+
6 rows in set, 1 warning (0.01 sec)
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+
| id | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra                                 |
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+
|  1 | PRIMARY     | ttt1       | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | NULL                                  |
|  2 | UNION       | ttt2       | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | NULL                                  |
|  2 | UNION       | <derived4> | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    4 |   100.00 | Using join buffer (Block Nested Loop) |
|  4 | DERIVED     | ttt3       | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | NULL                                  |
|  5 | UNION       | ttt4       | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | NULL                                  |
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+
5 rows in set, 1 warning (0.02 sec)
8.0.18 derived_merge off vs on
| -> Append
    -> Stream results
        -> Table scan on ttt1  (cost=0.35 rows=1)
    -> Stream results
        -> Inner hash join
            -> Table scan on b
                -> Union materialize
                    -> Table scan on ttt3  (cost=0.35 rows=1)
                    -> Table scan on ttt4  (cost=0.35 rows=1)
            -> Hash
                -> Table scan on a
                    -> Materialize
                        -> Table scan on ttt2  (cost=0.35 rows=1)
 
 | -> Append
    -> Stream results
        -> Table scan on ttt1  (cost=0.35 rows=1)
    -> Stream results
        -> Inner hash join
            -> Table scan on b
                -> Union materialize
                    -> Table scan on ttt3  (cost=0.35 rows=1)
                    -> Table scan on ttt4  (cost=0.35 rows=1)
            -> Hash
                -> Table scan on ttt2  (cost=0.35 rows=1)

通过优化后,该查询的内存结构就变成这样了 4.png

MySQL的执行流程对比

本文由于不是介绍整个优化器的详细优化过程,所以我们这里简单介绍下优化器的一些步骤和方法,根据MySQL官方网站的介绍我们可以知道具体步骤如下:

handle_select()
   mysql_select()
     JOIN::prepare()
       setup_fields()
     JOIN::optimize()            /* optimizer is from here ... */
       optimize_cond()
       opt_sum_query()
       make_join_statistics()
         get_quick_record_count()
         choose_plan()
           /* Find the best way to access tables */
           /* as specified by the user.          */
           optimize_straight_join()
             best_access_path()
           /* Find a (sub-)optimal plan among all or subset */
           /* of all possible query plans where the user    */
           /* controls the exhaustiveness of the search.   */
           greedy_search()
             best_extension_by_limited_search()
               best_access_path()
           /* Perform an exhaustive search for an optimal plan */
           find_best()
       make_join_select()        /* ... to here */
     JOIN::exec()

不过这个文档比较老,没有来的及更新,其中JOIN::prepare()函数已经在 WL#7082 - Move permanent transformations from JOIN::optimize () to JOIN::prepare (). As one single patch.增加了SELECT_LEX::prepare代替JOIN::prepare. 另外从8.0.1对整个DML的操作进行了重构,让结构更加清晰,让Prepare和Execute过程清楚的分开,这里只列出一些和查询相关的部分,参考 WL#5094: Create SQL command classes for DML statements

5.png

Class Sql_cmd_dml
  Sql_cmd_dml::prepare() walks through these common steps:
  precheck() - performs a coarse-grained authorization check of the statement.
  open_tables_for_query() - opens the tables and views mentioned in the statement. Views are expanded so that underlying views and tables are opened too.
  resolve_var_assignments() - resolves variable assignments in the statement.
  prepare_inner() - performs statement-specific preparation of the statement and is implemented for every subclass of Sql_cmd_dml.
  Sql_cmd_dml::execute() walks through these common steps:
  set_statement_timer() - is called if a time limit is applicable to the statement.
  prepare() is called if the statement is a regular (not preparable) statement.
  If prepare() is not called, precheck() and open_tables_for_query() are still called since these actions are required also when executing already prepared statements.
  run_before_dml_hook() is called if the statement is a data change statement, in order to prepare replication actions for the statement.
  An IGNORE or STRICT mode error handler is set up if applicable. It will be active for the duration of the execution of the statement.
  lock_tables() is called, unless the statement affects no rows or produces no rows in any tables.
  Query_cache::store_query() is called to register the statement in the query cache, if applicable.
  execute_inner() - performs statement-specific optimization and execution of the statement. Sql_cmd_dml::execute_inner() is an implementation for all SELECT statements, all INSERT statements that are based on a SELECT and all multi-table UPDATE and DELETE statements (ie all statements that are implemented using a JOIN object). For all other types of DML statements, a separate implementation for execute_inner() exists.
Class Sql_cmd_select
  This is a new class used to implement SELECT statements.
  It has an implementation of prepare_inner() to prepare SELECT statements.
  It uses Sql_cmd_dml::execute_inner() to execute SELECT statements.

Sql_cmd_dml是LEX的成员变量m_sql_cmd,而lex->m_sql_cmd大部分会在sql/sql_yacc.yy中new出来,所以目前8.0.13版本整个的流程就变成了下面的流程

8.0.13
mysql_execute_command()
  lex->m_sql_cmd->execute()
  Sql_cmd_dml::execute()
    Sql_cmd_dml::prepare()
      Sql_cmd_select::precheck()
      Sql_cmd_select::open_tables_for_query()
      Sql_cmd_select::prepare_inner()
        SELECT_LEX_UNIT::prepare_limit()
        SELECT_LEX_UNIT::prepare() (not simple or simple SELECT_LEX::prepare)
          SELECT_LEX::prepare()
            ......
      Sql_cmd_dml::execute_inner
        SELECT_LEX_UNIT::optimize() (not simple or simple SELECT_LEX::optimize)
          SELECT_LEX::optimize()  
            JOIN::optimize()
            SELECT_LEX_UNIT::optimize()
              ......
        SELECT_LEX_UNIT::execute() (not simple or simple SELECT_LEX::optimize)
          SELECT_LEX::execute()  
            JOIN::exec()
              JOIN::prepare_result()
              do_select()
                sub_select()
                  ......
            SELECT_LEX_UNIT::execute()
              ......
  SELECT_LEX_UNIT::cleanup(false)       

打开set debug=”+d,info,error,query,enter,general,where:O,/tmp/mysqld.trace”可以看到更详细的执行步骤

T@8: | | | | | | | >do_select
T@8: | | | | | | | | >sub_select
T@8: | | | | | | | | | >innobase_trx_init
T@8: | | | | | | | | | <innobase_trx_init 3269
T@8: | | | | | | | | | >handler::ha_index_init
T@8: | | | | | | | | | | >index_init
T@8: | | | | | | | | | | <index_init 10243
T@8: | | | | | | | | | | >change_active_index
T@8: | | | | | | | | | | | >innobase_get_index
T@8: | | | | | | | | | | | <innobase_get_index 11071
T@8: | | | | | | | | | | <change_active_index 11172
T@8: | | | | | | | | | <handler::ha_index_init 2793
T@8: | | | | | | | | | >handler::ha_index_first
T@8: | | | | | | | | | | >index_first
T@8: | | | | | | | | | | | >index_read
T@8: | | | | | | | | | | | | >row_search_mvcc
T@8: | | | | | | | | | | | | | >row_sel_store_mysql_rec
T@8: | | | | | | | | | | | | | | >row_sel_store_mysql_field_func
T@8: | | | | | | | | | | | | | | <row_sel_store_mysql_field_func 2921
T@8: | | | | | | | | | | | | | <row_sel_store_mysql_rec 3080
T@8: | | | | | | | | | | | | <row_search_mvcc 5881
T@8: | | | | | | | | | | | <index_read 11012
T@8: | | | | | | | | | | <index_first 11308
T@8: | | | | | | | | | <handler::ha_index_first 3293
T@8: | | | | | | | | | >evaluate_join_record
T@8: | | | | | | | | | | enter: join: 0x7fff99d92d68 join_tab index: 0 table: cat cond: (nil)
T@8: | | | | | | | | | | >sub_select_op
T@8: | | | | | | | | | | <sub_select_op 1365

这里不在赘述这个stack是因为,我们下面要引入了我们重要的主题部分8.0.18的Iterator执行部分,看看这个与之前的执行有何不同。官方用了很多Worklogs来实现Iterator的执行器。

PluginREADMEREADME
WL#12074Volcano iterator executor base2019-03-31 09:57:10
WL#11785Volcano iterator design2019-03-29 13:46:51
WL#12470Volcano iterator semijoin2019-06-24 14:41:06
WL#13476BKA outer/semi/anti join in iterator executor2020-04-08 12:13:42
WL#13002BKA in iterator executor2019-12-20 10:13:33
WL#13000Iterator UNION2019-09-16 10:57:16
WL#12788Iterator executor analytics queries2019-06-24 14:42:47
WL#4168Implement EXPLAIN ANALYZE2019-09-16 10:24:32
WL#13377Add support for hash outer, anti and semi join2020-04-08 12:02:15
WL#2241Hash join2019-09-16 10:15:21
WL#4245Subquery optimization: Transform NOT EXISTS and NOT IN to anti-join2019-06-24 13:12:53

MySQL的具体执行步骤对比

先来了解下术语: QEP:全称(Query Execution Plan)查询执行计划。 QEP_TAB:全称(Query Execution Plan Table) 查询执行计划表 熟悉我们知道在8.0开始,官方已经慢慢的用Iterator的执行类来替换原有的一些和执行相关的类,所以原有的流程中bool JOIN::optimize(),用于生成一个Query块的执行计划(QEP)就增加了生成Iterator的部分。

6.png

最终要的JOIN::create_iterators主要分两个步骤: 1) 通过create_table_iterators,对于每个特定表,生成对应的基本的RowIterators的继承子类。 2) 通过调用create_root_iterator_for_join,生成组合的iterators,合并每个表的组合行。

然后将生成的iterator赋值到JOIN::m_root_iterator。

表访问对比

JOIN::create_table_iterators里面可以看到需要去轮询所有的表来构建访问方式,调用了最重要的方法QEP_TAB::make_join_readinfo和QEP_TAB::pick_table_access_method,我们来看看和之前非Iterator访问方式有何不同。 在8.0之前,我们看到QEP_TAB是通过一些函数指针和READ_RECORD来设定访问的函数指针:

class QEP_TAB : public Sql_alloc, public QEP_shared_owner
{
  ......
  READ_RECORD::Setup_func materialize_table;
  /**
     Initialize table for reading and fetch the first row from the table. If
     table is a materialized derived one, function must materialize it with
     prepare_scan().
  */
  READ_RECORD::Setup_func read_first_record;
  Next_select_func next_select;
  READ_RECORD read_record;
  /*
    The following two fields are used for a [NOT] IN subquery if it is
    executed by an alternative full table scan when the left operand of
    the subquery predicate is evaluated to NULL.
  */
  READ_RECORD::Setup_func save_read_first_record;/* to save read_first_record */
  READ_RECORD::Read_func save_read_record;/* to save read_record.read_record */  
  ......
}
struct READ_RECORD
{
  typedef int (*Read_func)(READ_RECORD*);
  typedef void (*Unlock_row_func)(QEP_TAB *);
  typedef int (*Setup_func)(QEP_TAB*);
  
  TABLE *table;                                 /* Head-form */
  Unlock_row_func unlock_row;
  Read_func read_record;
  ......
}
bool init_read_record(READ_RECORD *info, THD *thd,
                      TABLE *table, QEP_TAB *qep_tab,
                      int use_record_cache,
                      bool print_errors, bool disable_rr_cache);
bool init_read_record_idx(READ_RECORD *info, THD *thd, TABLE *table,
                          bool print_error, uint idx, bool reverse);
void end_read_record(READ_RECORD *info);
QEP_TAB::pick_table_access_method设置流程大体如下:
void QEP_TAB::pick_table_access_method(const JOIN_TAB *join_tab) {
  ......
  switch (type())
  {
  case JT_REF:
    if (join_tab->reversed_access)
    {
      read_first_record= join_read_last_key;
      read_record.read_record= join_read_prev_same;
    }
    else
    {
      read_first_record= join_read_always_key;
      read_record.read_record= join_read_next_same;
    }
    break;
  case JT_REF_OR_NULL:
    read_first_record= join_read_always_key_or_null;
    read_record.read_record= join_read_next_same_or_null;
    break;
  case JT_CONST:
    read_first_record= join_read_const;
    read_record.read_record= join_no_more_records;
    read_record.unlock_row= join_const_unlock_row;
    break;
  ......
}

执行的流程如下:

    if (in_first_read)
    {
      in_first_read= false;
      error= (*qep_tab->read_first_record)(qep_tab); //设定合适的读取函数,如设定索引读函数/全表扫描函数
    }
    else
      error= info->read_record(info);

那么对于第一次QEP_TAB::read_first_record和后续读指针READ_RECORD::read_record可以为下列函数的实现,其中rr代表read record:

int join_init_quick_read_record(QEP_TAB *tab);
int join_init_read_record(QEP_TAB *tab);
int join_read_first(QEP_TAB *tab);
int join_read_last(QEP_TAB *tab);
int join_read_last_key(QEP_TAB *tab);join_read_next_same
int join_materialize_derived(QEP_TAB *tab);
int join_materialize_semijoin(QEP_TAB *tab);
int join_read_prev_same(READ_RECORD *info);
static int join_read_const(QEP_TAB *tab);
static int read_const(TABLE *table, TABLE_REF *ref);
static int join_read_key(QEP_TAB *tab);
static int join_read_always_key(QEP_TAB *tab);
static int join_no_more_records(READ_RECORD *info);
static int join_read_next(READ_RECORD *info);
static int join_read_next_same(READ_RECORD *info);
static int join_read_prev(READ_RECORD *info);
static int join_ft_read_first(QEP_TAB *tab);
static int join_ft_read_next(READ_RECORD *info);
static int join_read_always_key_or_null(QEP_TAB *tab);
static int join_read_next_same_or_null(READ_RECORD *info);
int rr_sequential(READ_RECORD *info)
static int rr_quick(READ_RECORD *info);
int rr_sequential(READ_RECORD *info);
static int rr_from_tempfile(READ_RECORD *info);
template<bool> static int rr_unpack_from_tempfile(READ_RECORD *info);
template<bool> static int rr_unpack_from_buffer(READ_RECORD *info);
static int rr_from_pointers(READ_RECORD *info);
static int rr_from_cache(READ_RECORD *info);
static int init_rr_cache(THD *thd, READ_RECORD *info);
static int rr_index_first(READ_RECORD *info);
static int rr_index_last(READ_RECORD *info);
static int rr_index(READ_RECORD *info);
static int rr_index_desc(READ_RECORD *info);

为什么简单的流程,需要指定不同的函数指针呢?原因是因为优化器需要根据不同的规则(RBO)和代价(CBO)去设计巧妙的访问方法,比如表扫描、索引扫描、稀疏扫描等等,那么这样的组合对于Innodb层提供的简单接口来说非常复杂。Innodb层和Server层的接口也不会根据上层的变化不断的修改和增加,所以Server层的执行层,利用自己规定的方法,来进行组合调用。比如我们举例rr_quick函数。

static int rr_quick(READ_RECORD *info)
{
  int tmp;
  while ((tmp= info->quick->get_next()))
  {
    if (info->thd->killed || (tmp != HA_ERR_RECORD_DELETED))
    {
      tmp= rr_handle_error(info, tmp);
      break;
    }
  }
  return tmp;
}

rr_quick增加了一个新的优化的类就是QUICK_SELECT_I接口实现的具体优化类,顾名思义就是比表扫描和索引扫描快速的访问方式,目前官方有7种方式,

enum {
    QS_TYPE_RANGE = 0,
    QS_TYPE_INDEX_MERGE = 1,
    QS_TYPE_RANGE_DESC = 2,
    QS_TYPE_FULLTEXT   = 3,
    QS_TYPE_ROR_INTERSECT = 4,
    QS_TYPE_ROR_UNION = 5,
    QS_TYPE_GROUP_MIN_MAX = 6
  };

我们这里只列出大约的调用流程,而非具体每一个实现的QUICK类。

  1. Create quick select
    quick= new QUICK_XXX_SELECT(...);
  2. Perform lightweight initialization. This can be done in 2 ways:
  2.a: Regular initialization
    if (quick->init())
    {
      //the only valid action after failed init() call is delete
      delete quick;
    }
  2.b: Special initialization for quick selects merged by QUICK_ROR_*_SELECT
    if (quick->init_ror_merged_scan())
      delete quick;
  3. Perform zero, one, or more scans.
    while (...)
    {
      // initialize quick select for scan. This may allocate
      // buffers and/or prefetch rows.
      if (quick->reset())
      {
        //the only valid action after failed reset() call is delete
        delete quick;
        //abort query
      }
       // perform the scan
      do
      {
        res= quick->get_next();
      } while (res && ...)
    }
  4. Delete the select:
    delete quick;     

显然,rr_quick仍然是执行路径分类下的又一个复杂的路由函数,根据实际READ_RECORD::quick的具体QUICK class来决定剩余的逻辑,那如何对应到Innodb存储的具体函数呢?拿QUICK_RANGE_SELECT这个类来举例,参照如下调用stack:

#x  ha_index_first/ha_index_read_map or ha_index_next_same/ha_index_next
#0  handler::read_range_first or handler::read_range_next
#1  handler::multi_range_read_next (this=0x7f9a78080900, range_info=0x7f9adc38bd40)
#2  DsMrr_impl::dsmrr_next (this=0x7f9a78082628, range_info=0x7f9adc38bd40)
#3  ha_innobase::multi_range_read_next (this=0x7f9a78080900, range_info=0x7f9adc38bd40)
#4  QUICK_RANGE_SELECT::get_next (this=0x7f9a7807b220)
#5  rr_quick (info=0x7f9a78103dd8)
#6  join_init_read_record (tab=0x7f9a78103d48) 
#7  sub_select (join=0x7f9a78005bd8, join_tab=0x7f9a78103d48, end_of_records=false)
#8  do_select (join=0x7f9a78005bd8)
#9  JOIN::exec (this=0x7f9a78005bd8) 

现在回到了我们的8.0 Iterator执行器中,我们看到READ_RECORD m_read_record_info将被unique_ptr_destroy_only m_iterator所代替,包括setup_read_record(), init_read_record() and setup_read_record_idx()都将被各种各样的Iterator代替。在Iterator的执行器下,不用关心函数指针的赋值,也不需要有两个QEP_TAB::read_first_record和后续读指针READ_RECORD::read_record,只需要实现RowIterator的子类并实现其定义的接口。

class RowIterator {
......
  virtual bool Init() = 0;
  virtual int Read() = 0;
  virtual void SetNullRowFlag(bool is_null_row) = 0;
  virtual void UnlockRow() = 0;
......
}

详细可以查看官方的link: https://dev.mysql.com/doc/dev/mysql-server/latest/classTableRowIterator.html https://dev.mysql.com/doc/dev/mysql-server/latest/classRowIterator.html

QEP_TAB::pick_table_access_method设置流程变为了下面的方式:

void QEP_TAB::pick_table_access_method() {
......
   switch (type()) {
     case JT_REF:
       if (is_pushed_child) {
         DBUG_ASSERT(!m_reversed_access);
         iterator = NewIterator<PushedJoinRefIterator>(
             join()->thd, table(), &ref(), use_order(), &join()->examined_rows);
       } else if (m_reversed_access) {
         iterator = NewIterator<RefIterator<true>>(join()->thd, table(), &ref(),
                                                   use_order(), this,
                                                   &join()->examined_rows);
       } else {
         iterator = NewIterator<RefIterator<false>>(join()->thd, table(), &ref(),
                                                    use_order(), this,
                                                    &join()->examined_rows);
       }
       used_ref = &ref();
       break;
     case JT_REF_OR_NULL:
       iterator = NewIterator<RefOrNullIterator>(join()->thd, table(), &ref(),
                                                 use_order(), this,
                                                 &join()->examined_rows);
       used_ref = &ref();
       break;
     case JT_CONST:
       iterator = NewIterator<ConstIterator>(join()->thd, table(), &ref(),
                                             &join()->examined_rows);
       break;
     case JT_EQ_REF:
       if (is_pushed_child) {
         iterator = NewIterator<PushedJoinRefIterator>(
             join()->thd, table(), &ref(), use_order(), &join()->examined_rows);
       } else {
         iterator = NewIterator<EQRefIterator>(
             join()->thd, table(), &ref(), use_order(), &join()->examined_rows);
       }
       used_ref = &ref();
       break;
   ......    
     case JT_ALL:
     case JT_RANGE:
     case JT_INDEX_MERGE:
       if (using_dynamic_range) {
         iterator = NewIterator<DynamicRangeIterator>(join()->thd, table(), this,
                                                      &join()->examined_rows);
       } else {
         iterator =
             create_table_iterator(join()->thd, nullptr, this, false,
                                   /*ignore_not_found_rows=*/false,
                                   &join()->examined_rows, &m_using_table_scan);
       }
       break;
   ...... 
}

执行的流程变成了:

  unique_ptr<RowIterator> iterator(new ...);
  if (iterator->Init())
    return true;
  while (iterator->Read() == 0) {
    ...
  }

Join访问对比

MySQL 的 join 操作主要是采用NestLoop的算法,其中涉及的主要函数有如下 do_select()、sub_select()、evaluate_join_record(),当然还有BNL和BKA等等,我们就不再这里赘述。

static int do_select(JOIN *join)
{
    ... ...
    if (join->plan_is_const() && !join->need_tmp) {
        ... ...
    } else {
      QEP_TAB *qep_tab= join->qep_tab + join->const_tables;
      DBUG_ASSERT(join->primary_tables);
      error= join->first_select(join,qep_tab,0);   ← 非结束选取
      if (error >= NESTED_LOOP_OK)
        error= join->first_select(join,qep_tab,1); ← 结束选取
    }
}
enum_nested_loop_state sub_select(JOIN *join,JOIN_TAB *join_tab,bool end_of_records)
{
  ... ...
  if (end_of_records)
  {
    enum_nested_loop_state nls=
      (*join_tab->next_select)(join,join_tab+1,end_of_records); ← 一般是sub_select/最后一个是end_send/end_send_group
    DBUG_RETURN(nls);
  }
  READ_RECORD *info= &join_tab->read_record;
  ... ...
  while (rc == NESTED_LOOP_OK && join->return_tab >= qep_tab_idx)
  {
    int error;
    if (in_first_read)                         ← 读取第一条记录
    {
      in_first_read= false;
      error= (*qep_tab->read_first_record)(qep_tab);
    }
    else
      error= info->read_record(info);          ← 循环读取记录直到结束位置
......
      rc= evaluate_join_record(join, qep_tab); ← 评估是否符合条件,连接下一个表
  }
  ... ...
}
static enum_nested_loop_state
evaluate_join_record(JOIN *join, JOIN_TAB *join_tab)
{
    ... ...
    Item *condition= join_tab->condition();   ← 查询条件
    bool found= TRUE;
    ... ...
    if (condition)
    {
      found= MY_TEST(condition->val_int());   ← 评估是否符合条件
    ... ...
    
    if (found)
    {
      enum enum_nested_loop_state rc;
      /* A match from join_tab is found for the current partial join. */
      rc= (*join_tab->next_select)(join, join_tab+1, 0);
      join->thd->get_stmt_da()->inc_current_row_for_warning();
      if (rc != NESTED_LOOP_OK)
        DBUG_RETURN(rc);
    ... ...
}

那么整个执行的流程串起来就是:

JOIN::exec()                              ← 执行一个Query Block
  |-THD_STAGE_INFO()                      ← 设置线程的状态为executing
  |-set_executed()                        ← 设置为执行状态,JOIN::executed=true
  |-prepare_result()
  |-send_result_set_metadata()            ← 先将元数据发送给客户端
  |
  |-do_select()                           ←### 查询的实际入口函数,做JOIN操作,会返回给客户端或写入表
    |
    |-join->first_select(join,qep_tab,0)  ← 1. 执行nest loop操作,默认会调用sub_select()函数,
    | |                                   ←    也即循环调用rnd_next()+evaluate_join_record()
    | |
    | |###while循环读取数据###
    | |                                   ← 2. 调用存储引擎接口读取数据
    | |-qep_tab->read_first_record()      ← 2.1. 首次调用,实际为join_init_read_record()
    | | |-tab->quick()->reset()           ← 对于quick调用QUICK_RANGE_SELECT::reset()函数
    | | | |-file->ha_index_init()         ← 会调用存储引擎接口
    | | | | |-index_init()
    | | | |   |-change_active_index()
    | | | |     |-innobase_get_index()
    | | | |-file->multi_range_read_init()
    | | |-init_read_record()              ← 设置read_record指针,在此为rr_quick
    | |
    | |-info->read_record()               ← 2.2 再次调用,如上,该函数在init_read_record()中初始化
    | | |-info->quick->get_next()         ← 实际调用QUICK_RANGE_SELECT::get_next()
    | |   |-file->multi_range_read_next() ← 调用handler.cc文件中函数
    | |     |-read_range_first()          ← 对于第一次调用
    | |     | |-ha_index_read_map()       ← 存储引擎调用
    | |     |   |-index_read()
    | |     |     |-row_search_mvcc()
    | |     |
    | |     |-read_range_next()           ← 对于非第一次调用
    | |       |-ha_index_next()
    | |         |-general_fetch()
    | |           |-row_search_mvcc()
    | |
    | |-evaluate_join_record()            ← 2.3 处理读取的记录,判断是否满足条件,包括了第一条记录
    |   |-qep_tab->next_select()          ← 对于查询,实际会调用end_send()
    |     |-Query_result_send::send_data()
    |
    |-join->first_select(join,qep_tab,1)  ← 3. 一个table已经读取数据结束,同样默认调用sub_select()
    | |-join_tab->next_select()           ← 调用该函数处理下个表或者结束处理
    |
    |-join->select_lex->query_result()->send_eof()

这次我们要对比下新的执行引擎的变化,既然表的访问方式已经从函数指针变为Iterator的Init/Read两个接口,我们来看其实对于Iterator引擎更容易理解了,JOIN::create_table_iterators本身就可以构造出简单的Iterators结构:

| -> Limit: 1 row(s)
    -> Sort: ttt1.c1, limit input to 1 row(s) per chunk  (cost=0.45 rows=2)
        -> Filter: (ttt1.c1 > 2)
            -> Table scan on ttt1

而JOIN::create_root_iterator_for_join可以构造出更为标准的Iterator火山模型结构:

| -> Limit: 1 row(s)
    -> Sort: ttt1.c1, limit input to 1 row(s) per chunk
        -> Stream results
            -> Inner hash join (ttt1.c1 = ttt2.c1)  (cost=0.90 rows=1)
                -> Table scan on ttt1  (cost=0.45 rows=2)
                -> Hash
                    -> Filter: (ttt2.c1 > 0)  (cost=0.35 rows=1)
                        -> Table scan on ttt2  (cost=0.35 rows=1)
 |

create_root_iterator_for_join中最为重要的函数ConnectJoins,里面负责生成相应的Semijoin/Hashjoin/Antijoin/Nestloopjoin等等的组合的Iterator。因为Hashjoin另有篇幅介绍,这里举例来说NestLoopIterator的实现:

/**
  A simple nested loop join, taking in two iterators (left/outer and
  right/inner) and joining them together. This may, of course, scan the inner
  iterator many times. It is currently the only form of join we have.
  The iterator works as a state machine, where the state records whether we need
  to read a new outer row or not, and whether we've seen any rows from the inner
  iterator at all (if not, an outer join need to synthesize a new NULL row).
  The iterator takes care of activating performance schema batch mode on the
  right iterator if needed; this is typically only used if it is the innermost
  table in the entire join (where the gains from turning on batch mode is the
  largest, and the accuracy loss from turning it off are the least critical).
 */
class NestedLoopIterator final : public RowIterator {
  ......
  bool Init() override;
  int Read() override;
  ......
  
 private:  
  ......
  
  unique_ptr_destroy_only<RowIterator> const m_source_outer; ← 外表
  unique_ptr_destroy_only<RowIterator> const m_source_inner; ← 内表
  const JoinType m_join_type;                                ← 连接方式
}
bool NestedLoopIterator::Init() {
  if (m_source_outer->Init()) {                              ← 外表初始化
    return true;
  }
  m_state = NEEDS_OUTER_ROW;                                 ← 先扫描外表
......  
  return false;
}
int NestedLoopIterator::Read() {
  if (m_state == END_OF_ROWS) {
    return -1;
  }
  for (;;) {  // Termination condition within loop.
    if (m_state == NEEDS_OUTER_ROW) {                       ← 开始扫描
      int err = m_source_outer->Read();                     ←  扫描外表
      if (err == 1) {
        return 1;  // Error.
      }
      if (err == -1) {
        m_state = END_OF_ROWS;
        return -1;
      }
......
      // Init() could read the NULL row flags (e.g., when building a hash
      // table), so unset them before instead of after.
      m_source_inner->SetNullRowFlag(false);
      if (m_source_inner->Init()) {                         ← 开始内表初始化
        return 1;
      }
      m_state = READING_FIRST_INNER_ROW;                    ← 扫描第一行内表
    }
    DBUG_ASSERT(m_state == READING_INNER_ROWS ||
                m_state == READING_FIRST_INNER_ROW);
    int err = m_source_inner->Read();                       ← 扫描内表
......
    if (err == -1) {
      // Out of inner rows for this outer row. If we are an outer join
      // and never found any inner rows, return a null-complemented row.
      // If not, skip that and go straight to reading a new outer row.
      if ((m_join_type == JoinType::OUTER &&                ← 内表没有rows
           m_state == READING_FIRST_INNER_ROW) ||
          m_join_type == JoinType::ANTI) {                  ← 内表直接返回NULL
        m_source_inner->SetNullRowFlag(true);
        m_state = NEEDS_OUTER_ROW;
        return 0;
      } else {
        m_state = NEEDS_OUTER_ROW;                          ← 否则继续扫描外表
        continue;
      }
    }
    // An inner row has been found.                         ← 内表返回row
    if (m_join_type == JoinType::ANTI) {
      // Anti-joins should stop scanning the inner side as soon as we see
      // a row, without returning that row.
      m_state = NEEDS_OUTER_ROW;                            ← Anti join只需要一行
      continue;
    }
    // We have a new row. Semijoins should stop after the first row;
    // regular joins (inner and outer) should go on to scan the rest.
    if (m_join_type == JoinType::SEMI) {
      m_state = NEEDS_OUTER_ROW;                            ← Semi join只需要一行
    } else {
      m_state = READING_INNER_ROWS;                         ← 否则继续循环读内表
    }
    return 0;
  }
}

最后我们用Hashjoin看下新的执行流程吧:

SELECT_LEX_UNIT::execute()                    ← 执行一个Query Unit
 |-SELECT_LEX_UNIT::ExecuteIteratorQuery
  |-THD_STAGE_INFO()                          ← 设置线程的状态为executing
  |-query_result->start_execution(thd)        ← 设置为执行状态,Query result execution_started = true;
  |-query_result->send_result_set_metadata()  ← 先将元数据发送给客户端
  |-set_executed();                           ← Unit executed = true;
  |
  |-m_root_iterator->Init()                   ← 所有Iterator递归Init,此处Iterator为HashJoinIterator
  | |-HashJoinIterator::Init()
  | | |-TableScanIterator::Init()
  | | | |-handler::ha_rnd_init()
  |
  | | |-HashJoinIterator::BuildHashTable()      
  | | | |-TableScanIterator::Read()
  | | | | |-handler::ha_rnd_next()
  | | | | | |-ha_innobase::rnd_next()
  |
  | |-HashJoinIterator::InitProbeIterator()
  | | |-TableScanIterator::Init()
  | | | |-handler::ha_rnd_init()
  | | | | |-ha_innobase::rnd_init()
  |
  | ###while循环读取数据###
  |-m_root_iterator->Read()                   ← 所有Iterator递归Read,此处Iterator为HashJoinIterator  
  | |-HashJoinIterator::Read()
  | |-HashJoinIterator::ReadRowFromProbeIterator()
  | | |-TableScanIterator::Read()
  | | | |-handler::ha_rnd_next()
  | | | | |-ha_innobase::rnd_next()
  |
  |-query_result->send_eof()

参考资料:

https://dev.mysql.com/doc/internals/en/select-structure.html

https://dev.mysql.com/doc/internals/en/optimizer-code.html

https://jin-yang.github.io/post/mysql-executor.html


MongoDB · 内核特性 · wiredtiger page逐出

$
0
0

背景

MongoDB默认使用的存储引擎是wiredtiger,而wiredtiger使用MVCC来实现并发控制,会在内存中维护文档的多版本并提供无锁访问,这会带来更好的并发性能,但也会带来更多的内存占用。所以wiredtiger内部使用了多种方法来尽快逐出内存中的数据页,以留下更多的内存给其它读写访问。

下面,我们会先详细介绍wiredtiger中用户表在磁盘和内存中的组织形式,然后通过了解文档的事务可见性,来说明内存page中哪些文档会被逐出到用户表文件中。最后介绍对部分不能逐出到用户表文件的文档,如何使用las逐出方式将其逐出到磁盘中。

page在磁盘的格式

在磁盘上,我们需要一种便于磁盘读写和压缩的格式保存page,这种格式的数据被称为extent。当内存压力小时,extent会在内存中也缓存一份,以减小用户的访问延迟。它由page header、block header和kv列表三部分组成,其中page header存储了extent经过解压和解密后在内存中的大小。block header存储了extent在磁盘中的大小和checksum值。kv列表中的key和value都是由cell和data两部分组成,cell存储了data的数据格式和长度,data就是具体的k/v值。

长度超过leaf_value_max的数据被称为overflow data(MongoDB中leaf_value_max的默认值是64MB,由于mongodb的文档大小不超过16MB,所以不会出现超过该值的情况)。wiredtiger会将该overflow data保存为一个单独的overflow extent存在表文件中,并用overflow extent address(包含overflow extent的offset和size)替换该value值。 wt extent

事务可见性

先看下wiredtiger的事务策略,它的隔离级别是基于snapshot技术来实现,支持read uncommited、read commited和snapshot三种隔离级别,而在MongoDB中使用的是snapshot隔离级别。

在内存中,wiredtiger的page由 在内存中已持久化的文档(上面的extent数据) 和 在内存中未持久化的文档 这两种文档组成,而内存中未持久化的文档又分为未提交的文档 和 已提交的文档。 未提交的文档都是 部分事务可见的文档(修改文档的事务可见,其他read commited或snapshot事务不可见),而已提交的文档分为 所有事务可见的已提交的文档 、 部分事务可见的已提交的文档 和 所有事务都不可见的已提交的文档。对于同个key,按照更新顺序从新到老,已提交文档的顺序是:部分事务可见的已提交的文档 < 所有事务可见的已提交的文档 < 所有事务都不可见的已提交的文档。若一个key在某个时刻的值是所有事务可见的,则早于该时刻的旧值肯定就是所有事务都不可见的,所以对于同一个key,只会有一个文档是所有事务可见的。 wt txn wiredtiger默认是通过内部的txn_id来标识全局顺序,为了保证全局顺序跟MongoDB的server层一致,和支持基于混合逻辑时钟的分布式事务,Wiredtiger还支持事务时间戳txn_ts,所以在判断全局可见性时,既要判断txn_id也要判断txn_ts。

那怎么判断哪些已提交的文档是所有事务可见的,哪些是部分事务可见的,哪些又是所有事务都不可见的呢?判断的规则在wiredtiger中是在__wt_txn_visible_all中实现的,遍历上面的key的更新列表,第一个满足下面条件的文档就是所有事务可见的已提交的文档:不仅要求该文档的txn_id要小于所有正在运行事务(包含可能正在运行的checkpoint事务)的snap_min的最小值(snap_min表示事务t在做snapshot时其他正在运行的事务id的最小值),而且要求该文档的txn_ts要小于所有正在运行事务的read_timestamp(读取数据的时间点)和server层最早可读取数据的时间点。不满足该条件的都是部分事务可见的文档,而满足该条件且除所有事务可见的已提交的文档外的其它文档都是所有事务不可见的已提交文档。

参照下图,我们举例说明,假设所有事务都是使用snapshot隔离级别,现在事务t1、t2、t3、t5已经提交,只有事务t10还在运行中,而在事务t10开始时,事务t1和t2已经提交,但事务t3和t5还未提交。那么事务t10的snap_min就是min(t3,t5)=t3,也就是说事务t10无法看见事务t3和事务t5所更新的文档,即使现在t3和t5事务已经提交,但是事务t3之后已提交的新文档{Key1, Value1-3}和{Key3,Value3-1}对于事务t10也是不可见的,所以只有早于事务t3提交的文档{Key1, Value1-2}、{Key3, Value3-1}才是所有事务可见的文档,而早于它们的{Key1, Value1-1}则是所有事务不可见的文档。 wt update list下图是对应内存中leaf page的结构,它包含了在内存中已持久化的文档(Leaf Extent中的文档)和在内存中未持久化的文档(Modify中的文档),其中的紫色文档是部分事务可见的未提交的文档,红色文档是部分事务可见的已提交的文档,绿色文档是所有事务可见的已提交的文档,灰色文档是所有事务不可见的已提交的文档。 wt update btree

page逐出的方式

在以下4种情况下会进行page逐出: 1)cursor在访问btree的page时,当发现page的内存占用量超过了memory_page_max(mongodb的默认值是10MB),就会对它做逐出操作,以减小page的内存占用量。 2)eviction线程根据lru queue排序逐出page 3)内存压力大时,用户线程会根据lru queue排序逐出page 4)checkpoint会清理它读进cache的page

page逐出在wiredtiger代码中是在__wt_evict中实现的。__wt_evict会依据当前cache使用率情况,分为内存使用低逐出和内存使用高逐出两种。若内存使用率bytes_inmem/cache_size < (eviction_target+eviction_trigger)/200 且 脏页使用率bytes_dirty_leaf/cache_size < (eviction_dirty_target+eviction_dirty_trigger)/200,则被认为是内存使用低,反之是内存使用高。

在wiredtiger和mongodb中,eviction_target默认值是80,eviction_trigger默认值是95,eviction_dirty_target默认值是5,eviction_dirty_trigger默认值是20,所以内存使用低逐出的判断规则也就是:内存使用率<87.5%且脏页使用率<17.5%。

内存使用低逐出

它主要有3个目标: 1) 从page在内存中未持久化的文档(Modify中的文档)里,删除“所有事务不可见的已提交文档”,减少内存的占用。 2) 将page中最新的“已提交文档”持久化到磁盘,以减少下一次checkpoint所需的时间。 3) 在内存中依然保留该page,避免下次操作读到该page时需要访问磁盘,增大访问延迟。

内存使用低逐出会先将最新的“已提交文档”以extent格式逐出到表文件tablename.wt中,同个key的文档只会持久化一个value值。如果page的modify中有新写的key或者对已有key的更新,那么取出最新的已提交value值,若没有,则从leaf extent中取出其原始的value值。当内存使用率低时,最新的已提交文档在内存中会组成新的extent并替代老的extent关联到page上,并释放内存中老的extent。最后从modify中删除“所有事务不可见的已提交文档”。

如下图所示,最新的已提交文档是“部分事务可见的已提交的文档”(红色文档),也就是说红色文档{Key1,Value1-3}和{Key3,Value3-1}会被更新到新的leaf extent中,删除所有事务不可见的文档Value1-1,而其他的文档仍然要保留在modify中。wt mem low那最新的已提交的文档Value1-3和Value3-1都已持久化了,为什么还要在modify中保留它们呢?因为Value1-3和Value3-1是部分事务可见的,对于其它不可见的事务来说,它们还需要看到其之前的文档Value1-2和Value3,所以这些文档依然都要保留。

内存使用高逐出

它主要有2个目标: 1) 尽可能删除内存中page的modify和extent,大幅减少内存的占用。如果因含有未提交的文档,而无法删除modify和extent。那么也要降级为内存使用低逐出,适当减少内存的占用。 2) 将page中最新的“已提交文档”持久化到磁盘,并减少下一次checkpoint的时间

内存使用高逐出根据page的modify包含的文档种类不同,对应有不同的处理方式。具体有以下三种情况: 1)page的modify不包含“部分事务可见的文档” 2)page的modify不包含“部分事务可见的未提交的文档” 3)page的modify包含“部分事务可见的未提交的文档”

我们先介绍page的modify不包含“部分事务可见的文档”的情况,也就是说page的modify中只包含所有事务可见的已提交文档(绿色文档),和所有事务不可见的已提交文档(灰色文档),如下图所示。由于所有事务不可见的已提交文档(灰色文档)是需要被删除的,这样modify中所剩下的“所有事务可见的已提交文档”(绿色文档)就是最新的“已提交的文档”,而内存使用高逐出会将这些绿色文档组成新的extent逐出到表文件tablename.wt中,所以modify中的数据都已被逐出,不再需要保留。最后清理内存中page的extent和modify,并将新的extent在文件中的offset和size关联到page上,便于下次访问时从磁盘中读出extent。 wt mem high1对于page的modify不包含“部分事务可见的未提交的文档”,也就是说page中只包含部分事务可见的已提交的文档(红色文档),所有事务可见的已提交文档(绿色文档),和所有事务不可见的已提交文档(灰色文档),如下图所示。内存使用高逐出不仅会将最新的“已提交文档”逐出到表文件tablename.wt中,而且还会判断是否满足使用las逐出的条件,如果满足,那么las逐出还会将modify中除“所有事务不可见的已提交文档”(灰色文档)之外的其它文档逐出到表WiredTigerLAS中,后续会被持久化到表文件WiredTigerLAS.wt中。

在下图中,最新的“已提交文档”是部分事务可见的已提交的文档(红色文档),所以红色文档{Key1,Value1-3}和{Key3,Value3-1}会被更新到新的leaf extent中。除“所有事务不可见的已提交文档”(灰色文档)之外的其它文档就是部分事务可见的已提交的文档(红色文档)、所有事务可见的已提交文档(绿色文档),包括{Key1,Value1-3}、{Key1,Value1-2}、{Key3,Value3-1}、{Key3,Value3},它们会被写到表WiredTigerLAS中。 wt mem high2如果page的modify包含“部分事务可见的未提交的文档”,或者page的modify不包含“部分事务可见的未提交的文档”但不满足las逐出的条件,那么modify中的数据就不能被逐出,这就导致内存使用高逐出会降级为内存使用率低逐出。而内存使用率低在删除“所有事务不可见的已提交文档”后,还需要在内存中保留modify和extent,使得该page就只能释放少量的内存,但一个表中有很多page,可能某些page满足第一种情况page的modify不包含“部分事务可见的文档”,释放这种page后再次从磁盘中读取的代价较低,优先被逐出。

读取逐出的page

根据内存使用率低逐出和内存使用率高逐出的结果,逐出后的page有以下三种形式: 1) page的modify和extent仍然在内存中 2) page的extent在磁盘文件tablename.wt中 3) page的extent在磁盘文件tablename.wt中,modify在表WiredTigerLAS中

第1种情况最简单,操作直接访问内存中page的文档即可。第2种情况需要先从磁盘上的tablename.wt文件中读出extent并关联到page上,然后才能访问。第3种情况在第2种情况的基础上,还要从表WiredTigerLAS中读取出该page的所有相关文档,并重建出page的modify。

LAS逐出

las逐出既然可以确保清理内存中的page,为什么内存高逐出方法不都采用las逐出呢?一方面是因为wiredtiger只有redo日志,要求文档只有提交后才能被持久化,而las逐出的文档在写入表WiredTigerLAS后就可以被持久化,所以包含“部分事务可见的未提交的文档”的page不可以执行las逐出。另一方面las逐出的代价较高,需要将“包含部分事务可见的已提交的文档”和“所有事务可见的已提交文档”一个一个写入表WiredTigerLAS中,后续若有操作访问该page时,还需要一个一个文档从表WiredTigerLAS中读取出来,所以基于读写性能考虑,进行las逐出的条件很苛刻。

las逐出不仅要page符合las逐出的条件,而且要整个cache的使用也符合las逐出的条件。先看下整个cache使用所需符合的las逐出条件:内存卡主超过2s(内存卡主是指 内存使用率bytes_inmem/cache_size > eviction_trigger/100 或 脏页使用率bytes_dirty_leaf/cache_size > eviction_dirty_trigger/100) 或 近期逐出的page更适合做las逐出(page中“部分事务可见的文档”/“在内存中未持久化的文档”>80%)。而page需符合las逐出的条件是page逐出不需要分页,且page的modify中所有文档都是“已提交的文档”。

las逐出过程通过cursor,在一个事务中将一个page的modify中“包含部分事务可见的已提交的文档”和“所有事务可见的已提交文档”写入表WiredTigerLAS。为了便于之后读取或者清理表WiredTigerLAS中的数据,写入表WiredTigerLAS的文档格式除了包含原始的key和value外,还需要保存更多的数据,如下图所示。page在las逐出时会有一个唯一的las逐出自增id,便于读取page时查找。page在las逐出时是先按照key从小到大遍历,对于每个key又按照update从新到旧遍历,且每个key最新的update类型会特殊标记为BIRTHMARK(例如文档{Key1,Value1-3}和{Key3,Value3-1}的类型),为了让las清理便于识别不同key的分界点。

表WiredTigerLAS文档的key:las逐出自增id,btree的id,本次las逐出的序号,原始key 表WiredTigerLAS文档的value:update的事务id,update的事务开始时间,update的事务持久化时间,update的事务状态,update类型,原始value wt las为了性能考虑,使用了read uncommited隔离级别(由于read committed需要访问全局事务表,来分析哪些事务可见)。las逐出一个page的所有文档时,是放在一个事务中的,为了保证原子性。

LAS清理

las清理的目的是为了确保WiredTigerLAS文件大小不会持续增加。las清理线程每隔2s会通过cursor遍历一遍表WiredTigerLAS,当它发现标记为BIRTHMARK的update时,它会检查该update对应的文档当前是不是所有事务可见的,如果是的话,那么就删除该key对应的update列表。

由于las逐出和las清理可能并发执行,如果las清理也使用read uncommited隔离级别,就可能导致las清理该key的update列表中的部分update的情况(例如清理了{Key1,Value1-3},保留了{Key1,Value1-2})。这是因为当las逐出在一个事务中先写完{Key1,Value1-3}时,las清理就能看到刚写的{Key1,Value1-3},这时如果发现{Key1,Value1-3}已经全局可见了,las清理就会清理update列表,而这时只清理{Key1,Value1-3},等las清理完并遍历到下一个key后,las逐出才继续写了{Key1,Value1-2},这样就导致在表WiredTigerLAS中残留value1-1的情况。

这样之后访问该page时会出现读不到{Key1,Value1-3},只能读到{Key1,Value1-2}的情况,造成数据不一致(虽然在extent中有{Key1,Value1-3},但查找时会优先访问modify中的文档),所以las清理要使用read commited隔离级别。

AliSQL · 内核特性 · 快速 DDL

$
0
0

优化背景

DDL是数据库运行期间不可避免的操作,MySQL用户经常会遇到DDL相关的问题包括:

  • 为什么加个索引会造成实例的抖动,影响正常的业务读写?
  • 为什么一个不到 1G 的表执行 DDL 有时需要十几分钟?
  • 为什么使用了 temp table 的连接退出会造成实例抖动?

针对这些问题,RDS内核团队进行分析后发现 MySQL 在 DDL 运行期间的缓存维护逻辑存在性能缺陷。在AliSQL上对这个问题进行了深入分析后,引入了针对性的buffer pool页面管理策略的优化,大大降底DDL操作带来的相关锁争用,解决或有效地缓解了上述问题,让AliSQL在正常业务压力下可以安心地做DDL操作。

启用快速 DDL功能,只需在 RDS 实例上打开 innodb_rds_faster_ddl 参数即可。

测试验证

这里针对 inplace类型DDL 执行时间, 以及临时表清理两个场景进行压测验证。

DDL场景

选取 RDS8.0 支持的两种 inplace online ddl 操作进行验证, 其中 create index 操作不需要重建表,optimize teble 操作需要重建表。 

操作InstantIn Place重建表可并发执行DML只修改元数据
create index
opitmize table

测试过程:

测试使用 8c 64g 的 8.0.18 实例进行,执行DDL操作的表大小600M 用sysbench 发起压测请求模拟线上业务,在 sysbench 压测期间执行 DDL 操作,进行反复对比测试。

结果对比:

 关闭优化启用优化提升倍数
create index56s4.9s11.4
optimize table220s17s12.9

在该场景下,优化后的AliSQL相比8.0社区版本 DDL 执行时间缩短了90%以上.

临时表场景

MySQL 在很多情况下会使用临时表:查询 information_schema 库下面的表, SQL 中执行 create temporary table 保存中间结果,优化器为了加速某些复杂SQL的执行也会自动创建和删除临时表。在线程退出时会集中清理用到过的临时表,基实是属于一种特殊类型的DDL,同样会导致实例的性能抖动。 更详细的背景有兴趣可以参考这个bug: Temp ibt tablespace truncation at disconnection stuck InnoDB under large BP

测试过程:

使用 8c 64g 的 8.0.18 实例正常 tpcc 压测,先提前预热,将 bp 基本用满,并发起单线程短连接的 temp table 请求。

结果对比:

原生MySQL在每次 temp table 线程退出时出现剧烈的抖动,tps 下降超过70%,开启优化之后性能影响降低至5%。

 无DDL操作开启优化关闭优化
tps42k40k<10k

压测过程中的秒级性能数据如下图所示(红线处开始关闭DDL加速功能): fasterddl

优化效果

DDL加速功能覆盖 RDS 线上的5.6/5.7/8.0 三个版本,不同版本的性能收益见下表。

分类DDLRDS 56RDS 57RDS 80
Inplace DDLInplaceDdl 范围 5.7 8.0
Tablespace 管理alter tablespace encryption
 truncate/drop tablespace
 discard tablespace
Drop Table drop/truncate table 
Undo 操作truncate/drop undo
Flush tableflush table for export

DDL加速功能可以缩短表中 DDL 的执行时间,降低 DDL操作对实例运行的影响。

Temp缺陷

  1. DDL using bulk load is very slow under long flush_list
  2. Temp ibt tablespace truncation at disconnection stuck InnoDB under large BP
  3. BUF_REMOVE_ALL_NO_WRITE is not needed for undo tablespace
  4. InnoDB temp table could hurt InnoDB perf badly

AliSQL的快速DDL特性(RC_20200630版本)完美地解决了以上MySQL Temp缺陷。

MySQL · 内核特性 · semi-join四个执行strategy

$
0
0

一 semi-join介绍

所谓的semi-join就是一个子查询,它主要用于去重,当外表查找在内表满足条件的records时,返回外表的records,也就是说它只返回存在内表中的外表的记录,如下图所示:
1.png

对应的语法:

SELECT ... From Outer_tables WHERE expr in (SELECT ... From Inner_tables ...) And ...
SELECT ... From Outer_tables WHERE expr exist (SELECT ... From Inner_tables ...) And ...

语法的特征

  • semi-join子查询必须EXSIT和IN语句组成的布尔表达式,并且在外层查询的WHERE或者ON子句中出现。
  • 外层查询也可以有其他的搜索条件,只不过和IN子查询的搜索条件必须使用AND连接起来。
  • semi-join子查询必须是一个单一的查询,不能是由若干查询由UNION连接起来的形式。
  • semi-join子查询不能包含GROUP BY或者HAVING语句或者聚集函数。

二 semi-join的策略

这里以mysql8.0.13为例,讲一下semi-join的几个执行策略:

DuplicateWeedout strategy

使用tmp_table, 按照join order, 选择记录qep_tab table的rowid作为唯一key进行去重,为了更好的理解,举一个例子:
SQL:

SELECT Country.Name
FROM Country
WHERE Code IN
    (SELECT CountryCode
     FROM City
     WHERE Population > 1M)

Duplicateweedout示意图:
2.png

处理过程:

  1. 找到duplicate weedout的qep_tab range
/*找到sj的一个tab, 即第一个non-constant table*/
QEP_TAB *const first_sj_tab = qep_array + first_table;
/*这里由于last duplicate weedout table是outer join中的一个inner tables,
  这里遍历找到last_sj_tab*/
if (last_sj_tab->first_inner() != NO_PLAN_IDX &&
            first_sj_tab->first_inner() != last_sj_tab->first_inner()) {
   QEP_TAB *tab2 = &qep_array[last_sj_tab->first_inner()]; 
     
   while (tab2->first_upper() != NO_PLAN_IDX &&
           tab2->first_upper() != first_sj_tab->first_inner())
     tab2 = qep_array + tab2->first_upper();
  if (qep_array[tab2->first_inner()].last_inner() > last_sj_tab->idx())
      last_sj_tab =
                &qep_array[qep_array[tab2->first_inner()].last_inner()];
 }
  1. 记录需要记录rowid的qep_tab和jt_rowid_offset(duplicate_weedout_tmp_table的column长度)
    for (QEP_TAB *tab_in_range = qep_array + first_table;
      tab_in_range <= last_sj_tab; tab_in_range++) {
     if (sj_table_is_included(join, join->best_ref[tab_in_range->idx()])) {
       ...
       jt_rowid_offset += tab_in_range->table()->file->ref_length;
       ...
       tab_in_range->keep_current_rowid = true;
     }
    }
    
  2. 当发现需要记录jt_rowid_offset > 0时:
    • 如果jt_rowid_offset + null_bytes为长度 > 512,就创建名为的Field_longlong做为tmp_table的hash_field来去重
    • 如果jt_rowid_offset + null_bytes为长度 <= 512,就创建名为rowids的Field_varstring作为weedout_key的tmp_table来去重
  3. 执行是在do_sj_dups_weedout函数中会将需要记录rowid的qep_tab->table的h->ref(rowid存在这里)写入tmp_table中的visible_field_ptr()[0]中,在ha_write_row的时候会根据record是否与上一条record重复来决定是否写入,外层调用会对返回值判断来查看是否是需要写入的记录,从而达到去重的目的

执行计划start temporary 和 end temporary表示duplicateweedout的qep_tab range区间9.png

Firstmatch Strategy

只扫描外表同样结果的一条记录用于匹配内表,其好处就是避免单条记录匹配内表多条记录而产生的重复的记录,也是一种去重的方式,其示意图如下:
3.png

处理流程:

  1. 找到sj range的跳转的qep_tab和记录比较qep_tab
    plan_idx jump_to = tab->idx() - 1;
    for (QEP_TAB *tab_in_range = tab; tab_in_range <= last_sj_tab;
      tab_in_range++) {
      if (!join->best_ref[tab_in_range->idx()]->emb_sj_nest) {
     jump_to = tab_in_range->idx();
      } else {
     if (tab_in_range == last_sj_tab ||
         !join->best_ref[tab_in_range->idx() + 1]->emb_sj_nest) {
       /*记录match后跳转的qep_tap*/
       tab_in_range->firstmatch_return = jump_to;
       /*记录比较的位置*/
       tab_in_range->match_tab = last_sj_tab->idx();
     }
      }
    }
    
  2. 执行期间匹配到满足的记录就直接跳到last outer table(ot2)
    if(table condition satisfied) {
      do join with next tables;
      jump out to the jump_to;
    } else {
      discard row combination;
      continue current table scan;
    }
    

    执行计划
    last semijoin table的qep_tab记录firstmatch标记
    4.png

Loosescan Strategy

inner table 基于index进行分组, 分组后与outer table join, 进行condition的匹配,如果匹配到了的记录,提取outer table的记录,inner table 选取下一个分组继续进行计算。
Query:

SELECT ...
FROM ot1, ...
WHERE outer_expr IN
    (SELECT it1.key
     FROM it1,
          it2
     WHERE cond(it1, it2))

5.png

处理过程

  1. 选择loosescan_parts作为内表的index
  2. 计算 loosescan parts的长度 loosescan_key_len
  3. alloc loosescan_buf挂在执行loosescan的qep_tab上
  4. 执行过程中如果发现有匹配的记录,则key_copy copy key_info到loosescan_buf上,后续用key_cmp对loosescan_buf进行比较来做分组的过滤

执行计划
与firstmatch相同在last semijoin table的qep_tab记录looseScan标记
6.png ###

Materialize scan/Materialize lookup Strategy

将inner tables物化成temp table,通过扫描物化表或者对物化表查找的方式来避免重复record
!7.png

处理过程

  1. setup_semijoin_materialized_table创建一个tmp table放在qep_tab的sjm_exec->table
  2. 在prepare_scan的join_materialize_semijoin(materialize钩子函数)物化semijoin nested table
      /*semijoin 第一个内层物化table所在的qep_tab*/
      QEP_TAB *const first = tab->join()->qep_tab + sjm->inner_table_index;
      /*semijoin 最后一个内层物化table所在的qep_tab*/
      QEP_TAB *const last = first + (sjm->table_count - 1);
      last->next_select = end_sj_materialize;
      last->set_sj_mat_exec(sjm);  // TODO: This violates comment for sj_mat_exec!
      if (tab->table()->hash_field) tab->table()->file->ha_index_init(0, 0);
      int rc;
      /*物化过程*/
      if ((rc = sub_select(tab->join(), first, false)) < 0) DBUG_RETURN(rc);
      if ((rc = sub_select(tab->join(), first, true)) < 0) DBUG_RETURN(rc);
      if (tab->table()->hash_field) tab->table()->file->ha_index_or_rnd_end();
    
  3. end_sj_materialize 将物化结果写入sjm->table中


Materialization-scan与Materialization-lookup的区别:

  • Materialization-Scan:

        temporary table–> outer tabl

  • Materialization-lookup

         outer table–>temporary table

执行计划
MATERIALIZED代表使用了物化策略
8.png

三 如何使用semi-join的策略

  • optimizer_switch

    optimizer_switch 可以对semi-join使用的策略进行,其配置参数有:
    semijoin : on/off
        materialization : on/off
    firstmatch : on/off
    loosescan : on/off
    duplicateweedout : on/off

  • Optimizer Hints

    Optimizer Hints 可以支持在SQL中hint方式指定semi-join使用的策略, 如:
    指定TPCH Q20 使用duplicateweedout策略:

    SELECT /*+ JOIN_PREFIX(nation) */ s_name,
                                    s_address
    FROM supplier,
       nation
    WHERE s_suppkey IN
      (SELECT /*+ SEMIJOIN(DUPSWEEDOUT) */ ps_suppkey
       FROM partsupp
       WHERE ps_partkey IN
           (SELECT p_partkey
            FROM part
            WHERE p_name LIKE 'peru%' )
         AND ps_availqty >
           (SELECT 0.5 * sum(l_quantity)
            FROM lineitem
            WHERE l_partkey = ps_partkey
              AND l_suppkey = ps_suppkey
              AND l_shipdate >= '1993-01-01'
              AND l_shipdate < date_add('1993-01-01' ,interval '1' YEAR) ) )
    AND s_nationkey = n_nationkey
    AND n_name = 'PERU'
    ORDER BY s_name;
    

四 并行执行中的semi-join

对于选择semi-join策略的查询,PolarDB产品对semi-join所有策略实现了并行加速,根据代价评估,通过拆分semi-join的任务,多线程模型并行运行任务集,加速去重,使查询性能得到了显著的提升,以Q20为例

select
s_name,
s_address
from
supplier, nation
where
s_suppkey in (
select
ps_suppkey
from
partsupp
where
ps_partkey in (
select
p_partkey
from
part
where
p_name like '[COLOR]%'
)
and ps_availqty > (
select
0.5 * sum(l_quantity)
from
lineitem
where
l_partkey = ps_partkey
and l_suppkey = ps_suppkey
and l_shipdate >= date('[DATE]’)
and l_shipdate < date('[DATE]’) + interval ‘1’ year
)
)
and s_nationkey = n_nationkey
and n_name = '[NATION]'
order by
s_name;

我们将物化处理提前,并且达到了32个worker的并行处理能力,后续的处理通过共享之前的物化表,同样充分发挥CPU的处理能力,启动32个worker将主查询的并行能力最大化,如下图的执行计划所示,在数据量1S,开启并行后,双重并行处理能力:
12.png
在1s数据情况下,串行的执行时间:
10.png
并行开启情况下的执行时间:
11.png
以如下自定义SQL为例, 该SQL并行使用了semi-join下推的并行方式,在max_parallel_degree=32的情况下,并行使用32个worker执行,执行时间从2.59s减少到0.34s:

mysql> SELECT c1,d1 FROM t1 WHERE c1 IN ( SELECT t2.c1 FROM t2 WHERE t2.c1 = 'f'      OR t2.c2 < 'y' ) and t1.c1 and d1 > '1900-1-1' like "R1%" ORDER BY t1.c1 DESC, t1.d1 DESC;
Empty set, 1024 warnings (0.34 sec)
mysql> set max_parallel_degree=0;
Query OK, 0 rows affected (0.00 sec)
mysql>  SELECT c1,d1 FROM t1 WHERE c1 IN ( SELECT t2.c1 FROM t2 WHERE t2.c1 = 'f'      OR t2.c2 < 'y' ) and t1.c1 and d1 > '1900-1-1' like "R1%" ORDER BY t1.c1 DESC, t1.d1 DESC;
Empty set, 65535 warnings (2.69 sec)
mysql> explain SELECT c1,d1 FROM t1 WHERE c1 IN ( SELECT t2.c1 FROM t2 WHERE t2.c1 = 'f'      OR t2.c2 < 'y' ) and t1.c1 and d1 > '1900-1-1' like "R1%" ORDER BY t1.c1 DESC, t1.d1 DESC;
+----+--------------+-------------+------------+--------+---------------+------------+---------+----------+--------+----------+---------------------------------------------------------+
| id | select_type  | table       | partitions | type   | possible_keys | key        | key_len | ref      | rows   | filtered | Extra                                                   |
+----+--------------+-------------+------------+--------+---------------+------------+---------+----------+--------+----------+---------------------------------------------------------+
|  1 | SIMPLE       | <gather1>   | NULL       | ALL    | NULL          | NULL       | NULL    | NULL     |  33464 |   100.00 | Merge sort                                              |
|  1 | SIMPLE       | t1          | NULL       | ALL    | NULL          | NULL       | NULL    | NULL     |  62802 |    30.00 | Parallel scan (32 workers); Using where; Using filesort |
|  1 | SIMPLE       | <subquery2> | NULL       | eq_ref | <auto_key>    | <auto_key> | 103     | sj.t1.c1 |      1 |   100.00 | NULL                                                    |
|  2 | MATERIALIZED | t2          | p0,p1      | ALL    | c1,c2         | NULL       | NULL    | NULL     | 100401 |    33.33 | Using where                                             |
+----+--------------+-------------+------------+--------+---------------+------------+------

MySQL · 引擎特性 · InnoDB redo log thread cpu usage

$
0
0

InnoDB 在8.0 里面把写redo log 角色的各个线程都独立出来, 每一个thread 都处于wait 状态, 同样用户thread 调用log_write_up_to 以后, 也会进入wait 状态.

这里的wait 等待最后都是通过调用 os_event_wait_for 来实现, 而 os_event_wait_for 是先spin + wait 的方式实现.

os_event_wait_for

inline static Wait_stats os_event_wait_for(os_event_t &event,
​                                           uint64_t spins_limit,
​                                           uint64_t timeout,
​                                           Condition condition = {})

所以这里有两个参数会影响os_event_wait_for 函数

  1. spins_limit
  2. timeout

在include/os0event.ic 里面, os_event_wait_for 是把spin 和 os_event_t 结合起来使用的一个例子.

简单来说就是先spin 一段时间, 然后在进入pthread_cond_wait() 函数, 所以spins_limit 控制spin 的次数, timeout 控制pthread_cond_wait() wait的时间

具体来说

在进行pthread_cond_wait 之前, 先通过PAUSE 指令来做spin loop, 在每一次的spin loop 的时候, 同时检查当前的条件是否满足了, 如果满足 当前这个os_event_wait_for 就不经过pthread_cond_wait 的sleep 就可以直接退出了, 如果不满足, 才会进入到 pthread_cond_wait

在具体执行pthread_cond_wait 的时候, 当超时被唤醒的时候, 也会动态调整pthread_cond_wait 的timeout 时间, 在每4次超时返回以后, 会把当前的timeout 时间* 2. 然后最大的timeout 时间是100ms

// 1. timeout
// 2. timeout
// 3. timeout
// 4. timeout
// 5. 2 * timeout
// ...
// 9. 4 * timeout
// ...
// 13. 8 * timeout

InnoDB 这里做的很细致, 8.0 新增加的这几个log_writer, log_flusher, log_write_notifier, log_flusher_notifier, log_closer 等等thread 都可以调整spin 的次数, 以及每次spin 的时间.

线程名称innodb_log_xxx_spin_delayinnodb_log_xxx_timeout
log_writerinnodb_log_writer_spin_delayinnodb_log_xxx_timeout
log_flusherinnodb_log_flusher_spin_delayinnodb_log_flusher_timeout
log_write_notifierinnodb_log_write_notifier_spin_delayinnodb_log_write_notifier_timeout
log_flush_notifierinnodb_log_flush_notifier_spin_delayinnodb_log_flush_notifier_timeout
log_closerinnodb_log_closer_spin_delayinnodb_log_closer_timeout

srv_log_spin_cpu_abs_lwm && srv_log_spin_cpu_pct_hwm

同时 InnoDB 也会统计运行过程中的cpu 利用率来判断spin 最多可以执行多少次.

struct Srv_cpu_usage {
  int n_cpu;
  double utime_abs;
  double stime_abs;
  double utime_pct;
  double stime_pct;
};

在这里主要用到 srv_cpu_usage.utime_abs和srv_cpu_usage.utime_pct.

innodb主线程会每隔一段时间(>= 100ms), 执行 srv_update_cpu_usage() 更新cpu_usage, 记录在srv_cpu_usage内.

srv_cpu_usage.utime_abs表示平均每微秒时间内所有用户态cpu 执行的时间总和, 比如一个进程使用了16core, 那么就会统计16core 上总的时间

比如说,系统是4核,这一次的更新间隔是200us,cpu 0-3的用户态时间分别为100us, 80us, 110us, 110us,则srv_cpu_usage.utime_abs 为(100+80+110+110)* 100 / 200 = 200;

srv_cpu_usage.utime_pct则是平均每微秒时间内用户态cpu 的百分比, 其实就是总的时间除以cpu 个数, srv_cpu_usage.utime_pct = srv_cpu_usage.utime_abs / n_cpus.

可以调整的两个参数对于cpu 的利用率限制:

srv_log_spin_cpu_abs_lwm(默认值80)

srv_log_spin_cpu_pct_hwm (默认值50)

srv_log_spin_cpu_abs_lwm: 表示的是平均每微秒时间内, 用户态cpu 时间的最小值, 平均每微秒用户态cpu 时间超过这个值, 才会spin

srv_log_spin_cpu_pct_hwm: 表示的是用户态cpu 利用率, 当cpu 使用率小于这个值的时候, 才会spin

在 log_wait_for_write 和log_wait_for_flush 中

会同时判断 srv_log_spin_cpu_abs_lwm 和 srv_log_spin_cpu_pct_hwm 这两个参数,

729 static Wait_stats log_wait_for_write(const log_t &log, lsn_t lsn) {
......
738   if (srv_flush_log_at_trx_commit == 1 ||
739       srv_cpu_usage.utime_abs < srv_log_spin_cpu_abs_lwm ||
740       srv_cpu_usage.utime_pct >= srv_log_spin_cpu_pct_hwm) {
741     max_spins = 0;
742   }
......
763 }

769 static Wait_stats log_wait_for_flush(const log_t &log, lsn_t lsn) {
......
775   if (log.flush_avg_time >= srv_log_wait_for_flush_spin_hwm ||
776       srv_flush_log_at_trx_commit != 1 ||
777       srv_cpu_usage.utime_abs < srv_log_spin_cpu_abs_lwm ||
778       srv_cpu_usage.utime_pct >= srv_log_spin_cpu_pct_hwm) {
779     /* Average flush time is too big, don't spin,
780     also don't spin when trx != 1. */
781     max_spins = 0;
782   }
......
809 }

同时所有的 log_writer, log_flusher, log_write_notifier, log_flush_notifier, log_closer 等等8.0 新增加的redo log 相关的线程在执行os_event_wait_for 之前都会判断

如果 srv_cpu_usage.utime_abs < srv_log_spin_cpu_abs_lwm

也就是当前cpu 用户态执行的时间小于就不允许进行spin 了,

auto max_spins = srv_log_writer_spin_delay;

if (srv_cpu_usage.utime_abs < srv_log_spin_cpu_abs_lwm) {
  max_spins = 0;
}

const auto wait_stats = os_event_wait_for(
    log.writer_event, max_spins, srv_log_writer_timeout, stop_condition);

在低core 的场景中, cpu 本身就少, 所以要尽可能避免cpu 的使用, 因此线上可以把这两个参数设置成规格参数

因此可以综合调整这两个参数, 当cpu 利用率高的时候 调大 srv_log_spin_cpu_abs_lwm, 调小 srv_log_spin_cpu_pct_hwm 降低资源的利用率

PgSQL · 引擎特性 · SQL防火墙使用说明与内核浅析

$
0
0

背景简介

SQL注入通常是业务层做的事情,例如使用绑定变量,使用关键字过滤等手段,避免被SQL注入。SQL防火墙便是数据库层面的防火墙功能。该插件可以用来学习一些定义好的SQL规则,并将这些规则储存在数据库中作为白名单。当用户学习完成后,可以限制用户执行这些定义规则之外的风险操作。

使用说明

认识学习模式,预警模式与防火墙模式

image.png SQL防火墙共有三种模式,学习模式、预警模式与防火墙模式。 • 学习模式,防火墙会记录用户的SQL查询,作为用户常用查询的预期白名单,此时防火墙打开不做校验。 • 预警模式,此模式下,防火墙会对用户的SQL进行判断,如果用户的SQL不在白名单中,仍然会执行该SQL,但是会给用户一个报警,告知用户这条SQL不符合白名单记录的业务规则。 • 防火墙模式,此模式下,防火墙会对用户的SQL进行判断。如果用户的SQL不在白名单中,防火墙会拒绝该SQL的执行并告知用户这是一个错误。

SQL防火墙的使用

基于以上认识,防火墙的使用一般分为以下三个步骤。 • 1 打开防火墙的学习模式,这个过程中防火墙会记录用户的SQL,并加入白名单。这个过程建议持续一段较长的时间,使得用户所有的可能SQL尽可能的在数据库中执行过。 • 2 切换防火墙为预警模式,这个过程防火墙会对用户的一些不符合规则的SQL进行告警,用户结合自己的业务判断是否为风险SQL,如果这些SQL确实是用户需要的业务语句,则记录这些SQL,之后统一打开学习模式进行二次学习。 • 3 经过前两步,用户常用SQL已经被记录完毕,打开防火墙模式。此时不符合规则的SQL均不能被执行。

内核设计

简介

• PostgreSQL内置了很多的HOOK,这些HOOK可以方便开发者加入一些功能,例如在SQL parser阶段的HOOK,可以加入一些SQL统计,SQL篡改,SQL REWRITE的功能。在SQL执行阶段的HOOK,可以用来拒绝执行等。共享内存分配阶段的HOOK,可以用来分配用户自定义进程的共享内存等。 • SQL_FIREWALL是PostgreSQL的一个SQL防火墙插件,利用了一些HOOK,实现了SQL防火墙的功能。

image.png SQL防火墙大致可以分为三个模块,HOOK注入、用户接口与存储模块。 • HOOK 注入 采用pg_stat_statements的HOOK方式,对DDL与DML等SQL语句进行解析正则化。 • 用户接口 提供给用户若干操作函数,包括 统计SQL、导入导出白名单、重置白名单等功能 • 存储模块 提供一个运行时内存hash表与系统停止时文件的对等映射 下面分模块详细介绍

HOOK 注入设计

image.png SQL_FIREWALL一共改写了7个hook函数,关系如上图所示,其方式几乎等同于pg_stat_statments。 • pgss_shmem_startup与pgss_shmem_shutdown 分别负责在其启动时将文件中的内容加载到共享内存汇总与关闭时将共享内存中的内容存储回文件。 • pgss_ProcessUtility与 pgss_post_parse_analyze 分别负责DDL与DML的解析与记录,被记录到hash表中。 • pgss_ExecutorStart/pgss_ExecutorRun/pgss_ExecutorFinish/pgss_ExecutorStart 记录SQL的统计信息

用户接口设计

image.png SQL_FIREWALL提供了7个用户行数接口,供用户进行操作。 • sql_firewall_reset() 重置 所有的防火墙规则 • sql_firewall_statements() 展示所有的防火墙规则 • sql_firewall_stat_error_count() 查看强制模式下的错误数量 • sql_firewall_stat_warning_count() 查看宽容模式下的警告数量 • sql_firewall_stat_reset() 重置 错误与警告数量 • sql_firewall_export_rule() 导出 防火墙规则 • sql_firewall_import_rule() 导入 防火墙规则

存储模块设计

image.png SQL_FIREWALL 的数据和规则交替存储与共享内存与文件系统中。当系统运行时,SQL_FIREWALL读取本地文件到内存中,并生成一个临时文件;而当系统关闭时,SQL_FIREWALL将内存中的信息存储到文件中。

MySQL · 引擎特性 · truncate table在大buffer pool下的优化

$
0
0

背景:

目前5.7仍然是使用最为广泛的版本,但是在实际的业务运维中,我们经常碰到truncate表时导致tps/qps抖动从而影响业务的情况,如果truncate的表比较多,监控就会像下图这样:
monitor
通过抓取堆栈发现:

  0000000001237cce buf_LRU_flush_or_remove_pages(unsigned long, buf_remove_t, trx_t const*) 
  0000000001286bdc fil_reinit_space_header_for_table(dict_table_t*, unsigned long, trx_t*) 
  000000000114ca57 row_truncate_table_for_mysql(dict_table_t*, trx_t*) 
  0000000001046a64 ha_innobase::truncate() 
  0000000000e77398 Sql_cmd_truncate_table::handler_truncate(THD*, TABLE_LIST*, bool) 
  0000000000e77810 Sql_cmd_truncate_table::truncate_table(THD*, TABLE_LIST*) 
  0000000000e779d4 Sql_cmd_truncate_table::execute(THD*) 
  0000000000ce40d8 mysql_execute_command(THD*, bool) 
  0000000000ce6fdd mysql_parse(THD*, Parser_state*) 
  0000000000ce7a3a dispatch_command(THD*, COM_DATA const*, enum_server_command) 
  0000000000ce92cf do_command(THD*) 
  0000000000d92a60 threadpool_process_request(THD*) 
  0000000000da6137 worker_main(void*) 
  0000000000f617b1 pfs_spawn_thread 
  00002b498fd64e25 start_thread

正在删除buffer pool中的数据页, 而且这个过程会加buffer pool的锁,影响对buffer pool的读写访问,从而影响服务。
官方这个问题由来已久,已经有很多相关的issue:
https://bugs.mysql.com/bug.php?id=51325
https://bugs.mysql.com/bug.php?id=64284
这个提到了drop table过程中删除自适应hash需要scan buffer pool 的LRU链表,5.7最新的版本已经修复了这个问题
https://bugs.mysql.com/bug.php?id=68184
而在这个bug中,分析了truncate table会比drop table在删除buffer pool page慢的本质原因,是因为truncate table 需要复用space id, 这导致必须把buffer pool中的老的表中的页全部删除,而drop table因为新旧表的页可用通过space id区分,只需要把flush list中的脏页删除就可以了,也就是可以用drop+create代替truncate来解决大buffer pool夯的问题,很遗憾这个修改实际上是在8.0上做的,也就是5.7我们需要自己实现。
当然这个问题还有一个解法就是在buffer pool中新增按照表为单位的管理结构(通常也是链表),这样删除旧表的数据页时就不用锁住整个buffer pool去scan了,但这个实现也有两个问题:1.链表的维护本身是会影响正常的dml的,2 对现有的buffer pool实现侵入比较大。
所以我们选择了truncate = drop + create的思路,这儿可能有人有点儿小疑问:能否直接让DBA drop+create, 这个当然可以操作,但是sql由一条变成了两条,同时这个操作不是一个statement的,中间可能会引起业务的报错,如果只是在服务器语法层做简单的替换应该也是类似的。

设计:

首先为了保证修改能尽量的稳定,在满足需求的前提下,需要能够动态开关和尽量减少对原有逻辑的侵入。8.0之前的ddl都不是原子的,但是为了尽可提 高ddl的原子性,在分析了innodb层的几个相关接口后,如果选择直接把delete和create接口修改字典数据放到一个事务里改动比较大, 尤其是对delete接口的 改造,而把rename+create放到一个事务里相对简单,这样我们就可以把truncate修改为 rename + create 一个事务里修改字典数据,它成功后再把rename的 临时表删除。 truncate table t 修改为:rename t to #sqlxxxx; // 重命名到临时表 create table t;这个修改字典表和rename在一个事务里,如果失败字典表就还是老表 delete #sqlxxxx; // 删除之前的临时表减少对原有代码的侵入 选择判断一些前置条件:

  • 不是临时表
  • 是独立表空间(file_per_table)
  • 表中不包含外键,这个主要是简化修改字典信息的逻辑


flow

实现:

新增一个innodb系统变量:
truncate_algorithm // 决定是走老的原地truncate还是用drop_with_create的方式
增加一个判断table是否含有外键的接口,用于前缀检查

ha_innobase::truncate()
/*===================*/	
{
	DBUG_ENTER("ha_innobase::truncate");
	if (truncate_algorithm == TRUNCATE_DROP_WITH_CREATE) {
		if (!dict_table_is_temporary(m_prebuilt->table) &&
			 dict_table_is_file_per_table(m_prebuilt->table) &&
			!is_refed_by_fk(m_prebuilt->table)) {
			DBUG_RETURN(drop_with_create_to_truncate());
		}
		else
		{
			ib::warn()<<table->s->table_name.str<<" can't use drop_with_create to truncate"<<
					"change to default in_place method";
		}

新加一个innodb的create接口,提供外部传入trx, 这样它就可以和rename共用一个trx修改字典表了

ha_innobase::drop_with_create_to_truncate()
{
	DBUG_ENTER("ha_innobase::drop_with_create_to_truncate");
	... ...
	int err = convert_error_code_to_mysql(
		innobase_rename_table(m_user_thd, trx, ib_table->name.m_name,
		temp_name, false),
		ib_table->flags, m_user_thd);
    ... ...
		err = create(name, table, &info, trx);
		DBUG_EXECUTE_IF("truncate_crash_after_create", DBUG_SUICIDE(););
		if (err) {
			ib::error()<<"Create table "<<name<<" failed.";
		}
	}
	trx_free_for_mysql(trx);
 
	if (!err) {
		... ...
		err = open(table_name, 0, 0);
		if (!err) {
			... ... 
			delete_table(temp_name);
			my_free(upd_buf);
		} 
    
    ... ...
	}
	mem_heap_free(heap);
	DBUG_RETURN(err);
}

后记:

8.0的truncate因为ddl已经支持原子性,所以实现更加优美,但思路和上面的类似。透过这个case,我也想表达一些多年来patch开源大妈解决用户痛点的一点儿小感悟:很多时候在权衡解决实现方案时我会把对原有实现的侵入作为一个很重要的考量,更多的复用久经考验的代码,保持兼容性,最大限度的让用户敢用你的代码,当然同时也是让自己少担风险,毕竟线上无小事,而不要为了追求所谓的完美重复造轮子。

MySQL · 引擎特性 · INNODB UNDO LOG分配

$
0
0

INNODB UNDO LOG分配

本文只描述独立UNDO表空间下的undo log的分配算法以及实现。根据我们之前的UNDO LOG物理格式描述,分配undo log得先分配回滚段,然后再从回滚段内分配undo log。

分配undo回滚段

每个独立UNDO表空间存在若干个(默认128)个回滚段,而每个回滚段又默认存在1024个UNDO LOG SLOT,分配undo log其实质便是在所有UNDO表空间中找到一个空闲的UNDO LOG SLOT。

分配回滚段的工作在函数trx_assign_rseg_durable进行,分配策略是采用round-robin方式。

void trx_start_low(trx_t *trx, bool read_write)
{
  trx->state = TRX_STATE_ACTIVE;
  if (!trx->read_only && (read_write || trx->ddl_operation)) {
    trx_assign_rseg_durable(trx);
  }
}

void trx_assign_rseg_durable(trx_t *trx) {
  trx->rsegs.m_redo.rseg = srv_read_only_mode ? nullptr : get_next_redo_rseg();
}

trx_rseg_t *get_next_redo_rseg() {
  ulong target = srv_undo_tablespaces;

  // 从系统表空间分配
  if (target == 0) {
    return (get_next_redo_rseg_from_trx_sys());
  } else {
    // 从独立UNDO表空间分配
    return (get_next_redo_rseg_from_undo_spaces(target));
  }
}

trx_rseg_t *get_next_redo_rseg_from_undo_spaces(...)
{
  ulong target_rollback_segments = srv_rollback_segments;
  static ulint rseg_counter = 0;
  ulint current = rseg_counter;
  // rseg_counter表示下一次要分配的回滚段编号
  // 然后根据该编号来计算space id和segment id
  os_atomic_increment_ulint(&rseg_counter, 1);
  while (rseg == nullptr) {
    ulint window =
        current % (target_rollback_segments * target_undo_tablespaces);
    ulint spaces_slot = window % target_undo_tablespaces;
    ulint rseg_slot = window / target_undo_tablespaces;

    current++;

    undo_space = undo::spaces->at(spaces_slot);

    rseg = undo_space->rsegs()->at(rseg_slot);
    rseg->trx_ref_count++;
  }
  return (rseg);
}

‌分配成功时,递增rseg->trx_ref_count,保证rseg的表空间不会被truncate。

‌临时表操作不记redo log,最终调用get_next_noredo_rseg函数进行分配;其他情况调用get_next_redo_rseg。

分配undo log

‌一旦分配好回滚段,接下来就是在回滚段内分配undo log了,这在函数trx_undo_assign_undo内完成:

dberr_t trx_undo_assign_undo(
    trx_t *trx,            
    trx_undo_ptr_t *undo_ptr, 
    ulint type)      
{
  // 分配的回滚段
  rseg = undo_ptr->rseg;

  // 首先尝试从回滚段缓存中分配
  undo = trx_undo_reuse_cached(trx, rseg, type, trx->id, trx->xid, &mtr);
  if (undo == NULL) {
    err = trx_undo_create(trx, rseg, type, trx->id, trx->xid, &undo, &mtr);
  }

  // 加分配的回滚段根据其类型(insert/update)加入至特定链表
  if (type == TRX_UNDO_INSERT) {
    UT_LIST_ADD_FIRST(rseg->insert_undo_list, undo);
    // 该事务后续所有的insert涉及的undo log都会使用这个
    undo_ptr->insert_undo = undo;
  } else {
    UT_LIST_ADD_FIRST(rseg->update_undo_list, undo);
    // 该事务后续所有的update涉及的undo log都会使用这个
    undo_ptr->update_undo = undo;
  }
}

从回滚段缓存中分配

trx_undo_t *trx_undo_reuse_cached(...)
{
  if (type == TRX_UNDO_INSERT) {
    undo = UT_LIST_GET_FIRST(rseg->insert_undo_cached);
    UT_LIST_REMOVE(rseg->insert_undo_cached, undo);
  } else {
    undo = UT_LIST_GET_FIRST(rseg->update_undo_cached);
    UT_LIST_REMOVE(rseg->update_undo_cached, undo);
  }

  undo_page = trx_undo_page_get(page_id_t(undo->space, undo->hdr_page_no),
                                undo->page_size, mtr);
  if (type == TRX_UNDO_INSERT) {
    offset = trx_undo_insert_header_reuse(undo_page, trx_id, mtr);
    trx_undo_header_add_space_for_xid(undo_page, undo_page + offset, mtr);
  } else {
    // 被cache的update undo log,其内容可能尚未被purge
    // 因而我们不能直接复用,需要在其后创建一个新的update undo log
		// 这也导致了一个undo page中存在多个update undo log情况
    offset = trx_undo_header_create(undo_page, trx_id, mtr);
    trx_undo_header_add_space_for_xid(undo_page, undo_page + offset, mtr);
  }

  trx_undo_mem_init_for_reuse(undo, trx_id, xid, offset);
  return (undo);
}

‌使用cache是为了提升undo log的分配效率。一个undo log在使用完成变得不再有效后便会被释放,一旦满足某些条件,它会被加入到回滚段的undo cache链表,insert 和update undo log有自己独立的链表。

从cache分配就很简单了,只需要从相应类型的缓存链表中取出第一项,然后初始化这个被复用的undo log即可。这里的逻辑比较简单,就不再赘述了。感兴趣的读者请自行研究。

如果undo log类型是update,这时候还要创建一个新的undo log header,而不能复用之前undo log。这是因为:这个被缓存的update undo log可能还在history list中尚未被purge。因而,我们只能在原来的undo page中创建一个新的undo log header,这就导致了每个update undo log page上可能会存在多个undo log,与我之前想象的有所不同。

创建新的undo log

如果无法从缓存中分配undo log,那也只能退化成来实际分配了,在函数trx_undo_create中执行:

dberr_t trx_undo_create(...)
{
  rseg_header =
      trx_rsegf_get(rseg->space_id, rseg->page_no, rseg->page_size, mtr);

  // 创建undo segment, undo page为segment的第一个page
  err = trx_undo_seg_create(rseg, rseg_header, type, &id, &undo_page, mtr);

  page_no = page_get_page_no(undo_page);

  // 创建undo log header
  offset = trx_undo_header_create(undo_page, trx_id, mtr);

  trx_undo_header_add_space_for_xid(undo_page, undo_page + offset, mtr);

  *undo = trx_undo_mem_create(rseg, id, type, trx_id, xid, page_no, offset);

  return (err);
}

‌我们在前面的章节“UNDO LOG物理格式”中说过,创建undo log的关键是分配undo segment。它是个独立的段,每个undo segment包含1个header page(第1个undo page)和若干个记录undo日志的undo page。

‌第1个undo page中存储的是元信息: 首先存储的是undo page的元信息,位于TRX_UNDO_PAGE_HDR到TRX_UNDO_SEG_HDR之间。

‌因此,如果理解了undo log的物理格式,上面的过程就非常简单了,这里不作过多描述。‌

UNDO LOG空间不足时如何处理

在函数trx_undo_report_row_operation中,如果出现了已分配的undo log的空间不足以容纳当前记录的老版本,这时候就需要对UNDO LOG进行扩充。

dberr_t trx_undo_report_row_operation(...)
{
  // 省略分配undo逻辑
  page_no = undo->last_page_no;

  undo_block = buf_page_get_gen(
      page_id_t(undo->space, page_no), undo->page_size, RW_X_LATCH...);

  do {
    page_t *undo_page;
    ulint offset;
    undo_page = buf_block_get_frame(undo_block);

    // 开始正式在undo log page内写入旧版本记录内容
    switch (op_type) {
      case TRX_UNDO_INSERT_OP:
        offset = trx_undo_page_report_insert(undo_page, trx, index, clust_entry, &mtr);
        break;
      default:
        offset = trx_undo_page_report_modify(undo_page, trx, index, rec, offsets,
                                             update, cmpl_info, clust_entry, &mtr);
    }

    // offset 返回值为0表示失败
    if (UNIV_UNLIKELY(offset == 0)) {
      // 不确定这是在干什么
      if (!trx_undo_erase_page_end(undo_page, &mtr)) { ... }
    } else {
      // 返回值不为0表示成功,此时直接返回即可,在这里不作讨论
      ...
      return (DB_SUCCESS);
    }
    // 走到这里意味着空间不足,我们需要扩充一个新page
    // 然后尝试用这个新page继续写入
    // 调用函数trx_undo_add_page
    undo_block = trx_undo_add_page(trx, undo, undo_ptr, &mtr);
    page_no = undo->last_page_no;
  } while (undo_block != NULL);
  ...
}

buf_block_t *trx_undo_add_page(...)
{
  rseg = undo_ptr->rseg;

  // UNDO LOG SEGMENT header page
  header_page = trx_undo_page_get(page_id_t(undo->space, undo->hdr_page_no),
                                  undo->page_size, mtr);

  if (!fsp_reserve_free_extents(&n_reserved, undo->space, 1, FSP_UNDO, mtr)) {
    return (NULL);
  }

  // 分配新的空闲page其no为undo->top_page_no + 1
  new_block = fseg_alloc_free_page_general(
      TRX_UNDO_SEG_HDR + TRX_UNDO_FSEG_HEADER + header_page,
      undo->top_page_no + 1, FSP_UP, TRUE, mtr, mtr);

  fil_space_release_free_extents(undo->space, n_reserved);

  undo->last_page_no = new_block->page.id.page_no();

  new_page = buf_block_get_frame(new_block);

  // 初始化新分配的page
  trx_undo_page_init(new_page, undo->type, mtr);

  // 将新分配的page加入至UNDO SEGMENT PAGE的page list中
  flst_add_last(header_page + TRX_UNDO_SEG_HDR + TRX_UNDO_PAGE_LIST,
                new_page + TRX_UNDO_PAGE_HDR + TRX_UNDO_PAGE_NODE, mtr);
  undo->size++;
  rseg->curr_size++;

  return (new_block);
}

MySQL · 内核特性 · Redo Logging动态开关

$
0
0

前言

我们都知道数据库利用write-ahead logging(WAL)的机制,来保证异常宕机后数据的持久性。即提交事务之前,不仅要更新所有事务相关的Page,也要确保所有的WAL日志都写入磁盘。在InnoDB引擎中,这个WAL就是InnoDB的redo log,一般存储在ib_logfilexxx文件中,文件数量可通过my.cnf配置。

在MySQL 8.0官方发布了新版本8.0.21中,支持了一个新特性“Redo Logging动态开关”。借助这个功能,在新实例导数据的场景下,相关事务可以跳过记录redo日志和doublewrite buffer,从而加快数据的导入速度。同时,付出的代价是短时间牺牲了数据库的ACID保障。

用法介绍

新增内容

  • SQL语法ALTER INSTANCE {ENABLE | DISABLE} INNODB REDO_LOG
  • INNODB_REDO_LOG_ENABLE权限,允许执行Redo Logging动态开关的操作。
  • Innodb_redo_log_enabled的status,用于显示当前Redo Logging开关状态。

操作步骤

  • 创建新的MySQL实例,账号赋权
    mysql> GRANT INNODB_REDO_LOG_ENABLE ON *.* to 'data_load_admin';
    
  • 关闭redo logging
    mysql> ALTER INSTANCE DISABLE INNODB REDO_LOG;
    
  • 检查redo logging是否成功关闭
    mysql> SHOW GLOBAL STATUS LIKE 'Innodb_redo_log_enabled';
    +-------------------------+-------+
    | Variable_name           | Value |
    +-------------------------+-------+
    | Innodb_redo_log_enabled |  OFF  |
    +-------------------------+-------+
    
  • 导数据
  • 重新开启redo logging
    mysql> ALTER INSTANCE ENABLE INNODB REDO_LOG;
    
  • 确认redo logging状态
    mysql> SHOW GLOBAL STATUS LIKE 'Innodb_redo_log_enabled';
    +-------------------------+-------+
    | Variable_name           | Value |
    +-------------------------+-------+
    | Innodb_redo_log_enabled |  ON   |
    +-------------------------+-------+
    

注意事项

  • 该特性仅用于新实例导数据场景,不可用于线上的生产环境;
  • Redo logging关闭状态下,支持正常流程的关闭和重启实例;但在异常宕机情况下,可能会导致丢数据和页面损坏;Redo logging关闭后异常宕机的实例需要废弃重建,直接重启会有如下报错:[ERROR] [MY-013578] [InnoDB] Server was killed when Innodb Redo logging was disabled. Data files could be corrupt. You can try to restart the database with innodb_force_recovery=6.
  • Redo logging关闭状态下,不支持cloning operations和redo log archiving这两个功能;
  • 执行过程中不支持其他并发的ALTER INSTANCE操作;

代码分析

新增handler接口如下

/**
  @brief
  Enable or Disable SE write ahead logging.

  @param[in] thd    server thread handle
  @param[in] enable enable/disable redo logging

  @return true iff failed.
*/
typedef bool (*redo_log_set_state_t)(THD *thd, bool enable);

struct handlerton {
  ...
  redo_log_set_state_t redo_log_set_state;
  ...
}

MySQL上层链路是常见的SQL执行链路。

mysql_parse
  mysql_execute_command
    Sql_cmd_alter_instance::execute
      // case ALTER_INSTANCE_ENABLE_INNODB_REDO
      // 或者 case ALTER_INSTANCE_DISABLE_INNODB_REDO
      Innodb_redo_log::execute
        /*
          Acquire shared backup lock to block concurrent backup. Acquire exclusive
          backup lock to block any concurrent DDL. This would also serialize any
          concurrent key rotation and other redo log enable/disable calls.
        */
        // 通过mdl锁阻止并发
        if (acquire_exclusive_backup_lock(m_thd, m_thd->variables.lock_wait_timeout,
                                          true) ||
            acquire_shared_backup_lock(m_thd, m_thd->variables.lock_wait_timeout)) {
          DBUG_ASSERT(m_thd->get_stmt_da()->is_error());
          return true;
        }
        hton->redo_log_set_state(m_thd, m_enable)

hton->redo_log_set_state在InnoDB引擎对应函数innobase_redo_set_state,最终分别调用mtr_t::s_logging.disable和mtr_t::s_logging.enable。

static bool innobase_redo_set_state(THD *thd, bool enable) {
  if (srv_read_only_mode) {
    my_error(ER_INNODB_READ_ONLY, MYF(0));
    return (true);
  }

  int err = 0;

  if (enable) {
    err = mtr_t::s_logging.enable(thd); // 开启redo
  } else {
    err = mtr_t::s_logging.disable(thd); // 关闭redo
  }

  if (err != 0) {
    return (true);
  }

  // 设置global status
  set_srv_redo_log(enable);
  return (false);
}

在InnoDB引擎层的mtr模块中,新增了一个Logging子模块。该子模块有四种状态,分别的含义如下:

ENABLEDRedo log打开。
ENABLED_DBLWRRedo log打开,所有关闭redo状态的mtr对应的page都已经刷盘,doublewrite buffer打开,但是仍有部分page走非doublewrite模式刷盘。
ENABLED_RESTRICTRedo log打开,但是仍有部分关闭redo状态的mtr,且doublewrite buffer未打开。
DISABLEDRedo log关闭。

除了ENABLED,其他都是不crash safe的状态。其中,开启redo的状态变化为[DISABLED] -> [ENABLED_RESTRICT] -> [ENABLED_DBLWR] -> [ENABLED],对应函数mtr::Logging::enable;关闭redo的状态变化为[ENABLED] -> [ENABLED_RESTRICT] -> [DISABLED],对应函数mtr::Logging::disable。
同时该模块也包含一个Shards类型的m_count_nologging_mtr统计值,记录当前正在运行的关闭redo状态的mtr数量。该统计值使用shared counter类型(Shards),可以减少CPU缓存失效,起到性能优化的作用。

Redo log关闭流程(mtr::Logging::disable)

int mtr_t::Logging::disable(THD *) {
  // 检查是否已经是DISABLED状态
  if (is_disabled()) {
    return (0);
  }

  /* Disallow archiving to start. */
  ut_ad(m_state.load() == ENABLED);
  m_state.store(ENABLED_RESTRICT);

  /* Check if redo log archiving is active. */
  // 检查是否有redo archive正在进行
  if (meb::redo_log_archive_is_active()) {
    m_state.store(ENABLED);
    my_error(ER_INNODB_REDO_ARCHIVING_ENABLED, MYF(0));
    return (ER_INNODB_REDO_ARCHIVING_ENABLED);
  }

  /* Concurrent clone is blocked by BACKUP MDL lock except when
  clone_ddl_timeout = 0. Force any existing clone to abort. */
  // 停止clone功能
  clone_mark_abort(true);
  ut_ad(!clone_check_active());

  /* Mark that it is unsafe to crash going forward. */
  // 设置redolog的m_disable和m_crash_unsafe标志位
  // 内部调用log_files_header_fill将标志位持久化
  log_persist_disable(*log_sys);

  ib::warn(ER_IB_WRN_REDO_DISABLED);
  m_state.store(DISABLED);

  clone_mark_active();

  /* Reset sync LSN if beyond current system LSN. */
  reset_buf_flush_sync_lsn();

  return (0);
}

Redo log打开流程(mtr::Logging::enable)

int mtr_t::Logging::enable(THD *thd) {
  if (is_enabled()) {
    return (0);
  }
  /* Allow mtrs to generate redo log. Concurrent clone and redo
  log archiving is still restricted till we reach a recoverable state. */
  ut_ad(m_state.load() == DISABLED);
  m_state.store(ENABLED_RESTRICT);

  /* 1. Wait for all no-log mtrs to finish and add dirty pages to disk.*/
  // 等待m_count_nologging_mtr计数器为0或者thd被kill
  auto err = wait_no_log_mtr(thd);
  if (err != 0) {
    m_state.store(DISABLED);
    return (err);
  }

  /* 2. Wait for dirty pages to flush by forcing checkpoint at current LSN.
  All no-logging page modification are done with the LSN when we stopped
  redo logging. We need to have one write mini-transaction after enabling redo
  to progress the system LSN and take a checkpoint. An easy way is to flush
  the max transaction ID which is generally done at TRX_SYS_TRX_ID_WRITE_MARGIN
  interval but safe to do any time. */
  trx_sys_mutex_enter();
  // 通过更新trx_id的接口生成一个mtr,目的是提供一个lsn推进的位点
  trx_sys_flush_max_trx_id();
  trx_sys_mutex_exit();

  /* It would ensure that the modified page in previous mtr and all other
  pages modified before are flushed to disk. Since there could be large
  number of left over pages from LAD operation, we still don't enable
  double-write at this stage. */
  // 不开double-write的状态checkpoint到最新的lsn
  log_make_latest_checkpoint(*log_sys);
  m_state.store(ENABLED_DBLWR);

  /* 3. Take another checkpoint after enabling double write to ensure any page
  being written without double write are already synced to disk. */
  // 再次checkpoint到最新的lsn
  log_make_latest_checkpoint(*log_sys);

  /* 4. Mark that it is safe to recover from crash. */
  // 设回m_disable和m_crash_unsafe标志位,并持久化
  log_persist_enable(*log_sys);

  ib::warn(ER_IB_WRN_REDO_ENABLED);
  m_state.store(ENABLED);

  return (0);
}

从以上代码我们可以看到,redo开启的过程中为了优化状态切换的性能,专门增加了ENABLED_DBLWR阶段,并在前后分别执行了一次checkpoint。
然后我们来看下关闭redo logging的行为对其他子模块的影响。Logging系统里面定义了如下几个返回bool类型的函数:

bool dblwr_disabled() const {
  auto state = m_state.load();
  return (state == DISABLED || state == ENABLED_RESTRICT);
}
bool is_enabled() const { return (m_state.load() == ENABLED); }
bool is_disabled() const { return (m_state.load() == DISABLED); }

追溯这些函数的调用方发现:dblwr_disabled用于限制doublewrite buffer的写入。is_enabled用于调整adaptive flush,和阻止cloning operations和redo log archiving这两个功能。is_disabled调用的地方多一些,包含以下几个判断点:

  • 调整adaptive flush的速度,加快刷脏;
  • page cleaner线程正常退出时在redo header标记当前是crash-safe状态;
  • 当innodb_fast_shutdown=2时,自动调整为1确保正常shutdown的时候是crash-safe的;
  • 开启新的mtr的时候,调整m_count_nologging_mtr统计值,标记当前mtr为MTR_LOG_NO_REDO状态;

由于adaptive flush依据redo的lsn推进速度才决策刷盘脏页数量,因此adaptive flush的算法需要微调,这一块的逻辑可以参考Adaptive_flush::page_recommendation中的set_flush_target_by_page

ulint page_recommendation(ulint last_pages_in, bool is_sync_flush) {
  ...
  /* Set page flush target based on LSN. */
  auto n_pages = skip_lsn ? 0 : set_flush_target_by_lsn(is_sync_flush);

  /* Estimate based on only dirty pages. We don't want to flush at lesser rate
  as LSN based estimate may not represent the right picture for modifications
  without redo logging - temp tables, bulk load and global redo off. */
  n_pages = set_flush_target_by_page(n_pages);
  ...
}

参考资料

MySQL · 引擎特性 · InnoDB Buffer Page 生命周期

$
0
0

前言

InnoDB 没有使用操作系统自己的 Page Cache 机制,而是自己设计了一套 Buffer Pool 来进行 Page 的管理,关于 InnoDB Buffer Pool 的介绍,可以参考这篇文章,里面对 InnoDB Buffer Pool 作了比较深入的介绍。本文尝试从另外一个角度介绍一下一个 Buffer Page 的生命周期。本文给出的所有示例代码均基于 MySQL 8.0.18 版本。

申请

Page 读取

Page 的读取有一个统一的入口函数 buffer_page_get_gen,该方法的主要入参为 page_id,即获取指定的页,MySQL 8.0 中的主要流程如下:

/* 以 Buf_fetch_normal 为例 */
|--> fetch.single_page
|    |--> get(block) // loop
|    |    |--> lookup
|    |    |    |--> buf_page_hash_get_low // 检查 page_hash 中是否存在
|    |    |--> buf_block_fix // buf_fix_count 计数 +1
|    |    |
|    |    |--> read_page
|    |    |    |--> buf_read_page // 从文件中读取 page
|    |    |    |    |--> buf_read_page_low
|    |    |    |    |    |--> buf_page_init_for_read
|    |    |    |    |    |    |--> buf_LRU_get_free_block // 申请 1 个 block
|    |    |    |    |    |    |--> buf_page_hash_get_low // 再次检查 page_hash 中是否存在
|    |    |    |    |    |    |--> buf_page_init
|    |    |    |    |    |    |    |--> buf_block_init_low
|    |    |    |    |    |    |    |--> buf_page_init_low
|    |    |    |    |    |    |    |--> HASH_INSERT // 插入 page_hash
|    |    |    |    |    |    |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_READ
|    |    |    |    |    |    |--> buf_LRU_add_block // 添加到 LRU
|    |    |    |    |    |
|    |    |    |    |    |--> _fil_io // 读取文件
|    |    |    |    |    |--> buf_page_io_complete // 同步模式 IO 完成
|    |    |    |    |    |    |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_NONE
|    |
|    |--> buf_page_make_young_if_needed
|    |
|    |--> buf_read_ahead_linear

读取 1 个 Page 时,首先会检查 page_hash,如果 page_hash中存在,则直接读取并设置 buf_fix_count后返回;否则需要从文件中读取 Page,从文件中读取 Page 时首先需要申请 1 个 Block(具体申请过程在后面介绍),然后添加到 page_hashLRU列表中,最后进行数据的读取。对于 1 个新的 Page 的创建过程,入口函数为 buf_page_create,基本流程如下:

/* buf_page_create */
|--> buf_page_create
|    |--> buf_LRU_get_free_block // 申请 1 个 block
|    |--> buf_page_hash_get_low // 检查 page_hash 中是否存在
|    |
|    |--> buf_page_init
|    |    |--> buf_block_init_low
|    |    |--> buf_page_init_low
|    |    |--> HASH_INSERT // 插入 page_hash
|    |--> buf_block_buf_fix_inc // buf_fix_count 计数 +1
|    |--> buf_LRU_add_block // 添加到 LRU

Block 申请

Block 申请的入口函数为 buf_LRU_get_free_block,该方法会从 Buffer Pool 中申请 1 个 Block 供后续的 Page 读取使用。Block 申请的主要流程如下:

|--> buf_LRU_get_free_block // loop
|    |--> buf_LRU_get_free_only // 从 free_list 分配
|    |
|    |--> buf_LRU_scan_and_free_block // 从 LRU 中回收
|    |    |--> buf_LRU_free_from_unzip_LRU_list
|    |    |    |--> buf_LRU_free_page
|    |    |--> buf_LRU_free_from_common_LRU_list
|    |    |    |--> buf_flush_ready_for_replace
|    |    |    |--> buf_LRU_free_page
|    |
|    |--> os_event_set(buf_flush_event) // 唤醒刷脏线程
|    |
|    |--> buf_flush_single_page_from_LRU // 从 LRU 中刷脏
|    |    |--> buf_LRU_free_page
|    |    |--> buf_flush_page

Buffer Pool 中维护了三个列表:free_listLRUflush_list。其中 free_list列表是当前可供使用的 Block,LRU列表中保存了当前所有已经使用的 Block,flush_list列表中保存了所有脏页 Block。申请 1 个 Block 时:

  1. 首先判断当前 free_list列表是否为空,若 free_list列表非空,则直接从 free_list列表中进行分配。若无法直接从 free_list列表分配,则会尝试从 LRU列表中进行回收。
  2. LRU是一个非严格的最近使用列表,从 LRU列表回收时会从列表尾部往前遍历(加入 LRU列表时从头部加入),如果找到可回收的 Page(遇到脏页会跳过),则会释放 Page 并将对应的 Block 重新放入 free_list列表中。LRU列表的遍历过程并不是无限的,例如:在第一次遍历时,当检查的 Page 数目达到 BUF_LRU_SEARCH_SCAN_THRESHOLD时会退出遍历过程。
  3. 如果无法从 LRU列表中回收 Block,则会唤醒刷脏线程,刷脏线程的处理流程在下面会做介绍。
  4. 同时还会从 LRU列表中进行刷脏,该过程是同步的,依然是遍历 LRU列表,但此时不会跳过脏页,遇到脏页直接进行刷脏。

管理

加入 flush_list

前面提到过 flush_list列表中保存的是所有脏页 Block,脏页在 mtr 提交时会加入 flush_list中,基本过程如下:

|--> mtr_t::Command::execute()
|    |--> add_dirty_page_to_flush_list
|    |    |--> buf_flush_note_modification
|    |    |    |--> buf_flush_insert_into_flush_list
|    |    |    |    |--> UT_LIST_ADD_FIRST // 插入 flush_list 头部

注意:flush_list是一个非严格有序的列表(可以看做按照 oldest_modification有序),脏页插入列表后位置不再修改,再次修改时仅修改 newest_modification

加入 LRU

LRU列表中保存了当前所有已经使用的 Block,申请完 1 个 Block 并完成初始化后会加到 LRU列表中(默认会加入到 old 区域头部),加入 LRU列表的基本过程如下:

|--> buf_LRU_add_block
|    |--> buf_LRU_add_block_low
|    |    |--> UT_LIST_ADD_FIRST // 插入 young 区域头部
|    |    |--> UT_LIST_INSERT_AFTER // 插入 old 区域头部
|    |    |
|    |    |--> buf_LRU_old_adjust_len // 调整 LRU

管理 LRU

前面提到 LRU是一个非严格的最近使用列表,InnoDB 将 LRU列表划分为两个区域:young 区域和 old 区域。LRU列表的示意图如下:

/** LRU 列表示意图
                                                LRU_old
                                                   |
**********************young************************|********old*********
|==================================================|===================|

几个主要的常量:
BUF_LRU_OLD_TOLERANCE      20
BUF_LRU_NON_OLD_MIN_LEN    5
BUF_LRU_OLD_MIN_LEN        512
BUF_LRU_OLD_RATIO_DIV      1024

参数控制:
innodb_old_block_pct       old 区域占比

*/

buf_LRU_old_adjust_len方法会根据 innodb_old_block_pct参数,维护 young 区域和 old 区域的长度,主要逻辑如下:

  1. LRU长度小于 ` BUF_LRU_OLD_MIN_LEN` 时,不划分区域。
  2. 不是每次操作 LRU列表后都需要立即调整,BUF_LRU_OLD_TOLERANCE可以看成是容忍范围。
  3. 当 old 区域变大时,LRU_old 指针向前移动;反之向后移动。

当 Block 被再次访问时,会触发 buf_page_make_young_if_needed函数进行 Block 位置的调整,基本过程如下:

|--> buf_page_make_young_if_needed
|    |--> buf_page_peek_if_too_old // 判断访问间隔
|    |    |--> buf_page_peek_if_young // 判断 young 区域位置
|    |
|    |--> buf_page_make_young
|    |    |--> buf_LRU_make_block_young
|    |    |    |--> buf_LRU_remove_block
|    |    |    |--> buf_LRU_add_block_low

buf_page_make_young_if_needed移动 Block 时需要考虑:

  1. 访问间隔需要大于 buf_LRU_old_threshold_ms
  2. 当 Block 在 young 区域前 1/4 时,不需要移动。

InnoDB 中 LRU列表的设计虽然简单,但是也有许多优化在里面,感兴趣的同学可以仔细研究,本文仅是一个简单的介绍。

回收

释放 Page

前面提到,当 free_list列表为空时,会首先尝试从 LRU列表中进行回收,Page 的释放入口函数为 buf_LRU_free_page,该方法的主要处理流程如下:

|--> buf_LRU_free_page
|    |--> buf_page_can_relocate // 检查 buf_fix_count 计数和 io_fix 状态
|    |
|    |--> buf_LRU_block_remove_hashed // 从 LRU 和 page_hash 中删除
|    |    |--> buf_LRU_remove_block
|    |    |    |--> buf_LRU_old_adjust_len
|    |    |--> HASH_DELETE
|    |
|    |--> btr_search_drop_page_hash_index // 从 AHI 中删除
|    |
|    |--> buf_LRU_block_free_hashed_page // 放回 free_list
|    |    |--> buf_LRU_block_free_non_file_page

释放 1 个 Page 时,首先需要检查 io_fix状态和 buf_fix_count计数,确保当前 Page 没有被使用,然后将 Block 从依次从 LRU列表、page_hashAHI中删除,最后将 Block 重新放入到 free_list列表中。

同步刷脏

同步刷脏的入口函数为 buf_flush_page,同步刷脏过程仅会刷 1 个 Page,保证能够获取到 1 个可用的 Block,主要处理流程如下:

|--> buf_flush_page // 刷单个 page
|    |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_WRITE
|    |--> buf_flush_write_block_low
|    |    |--> log_write_up_to // 写 redo
|    |    |
|    |    |--> fil_io
|    |    |--> buf_dblwr_write_single_page // 写数据页
|    |    |
|    |    |--> fil_flush
|    |    |--> buf_page_io_complete
|    |    |    |--> buf_flush_write_complete
|    |    |    |    |--> buf_flush_remove
|    |    |    |    |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_NONE
|    |    |    |
|    |    |    |--> buf_LRU_free_page

InnoDB 通过严格的 WAL 机制保证数据的一致性,刷脏过程同样如此。首先需要保证对应的日志文件落盘,然后再写入数据页。最后将 Block 从 flush_list列表中移除,此时 Page 变成可回收状态,再次调用 buf_LRU_free_page进行回收。

同步刷脏的过程不仅在获取 Block 时会被调用,在表删除的时候同样会被调用,表删除时会根据 space_id进行批量的刷脏,入口函数为 buf_LRU_flush_or_remove_pages,处理流程如下:

|--> buf_LRU_flush_or_remove_pages // 根据 space_id 刷脏
|    |--> buf_LRU_drop_page_hash_for_tablespace // 遍历 LRU
|    |    |--> buf_LRU_drop_page_hash_batch
|    |    |    |--> btr_search_drop_page_hash_when_freed
|    |    |    |    |--> buf_page_get_gen
|    |    |    |    |--> btr_search_drop_page_hash_index // 从 AHI 中删除
|    |
|    |--> buf_LRU_remove_pages
|    |    |--> buf_LRU_remove_all_pages // 遍历 LRU
|    |    |    |--> buf_LRU_block_remove_hashed // 从 LRU 和 page_hash 中删除
|    |    |    |--> buf_LRU_block_free_hashed_page // 放回 free_list
|    |    |
|    |    |--> buf_flush_dirty_pages
|    |    |    |--> buf_flush_or_remove_pages // 遍历 flush_list
|    |    |    |    |--> buf_flush_or_remove_page
|    |    |    |    |    |--> buf_flush_remove
|    |    |    |    |    |
|    |    |    |    |    |--> buf_flush_page

具体的过程在此不再赘述,大家可以自己去阅读相应的代码。需要注意的是:如果单个 session 中使用了临时表,那么在 session 退出的时候,也会进入到上述的刷脏流程,当 LRU列表很大时,session 退出的性能将会受到很大的影响。AliSQL 对此进行了优化,欢迎试用。

异步刷脏

除了同步刷脏之外,MySQL 中还引入单独的刷脏线程进行异步刷脏。刷脏线程按照功能划分包括两种:coordinator 线程和 cleaner 线程。coordinator 线程会计算最大的刷脏量,然后分配刷脏任务给 cleaner 线程,cleaner 线程进行实际的刷脏工作(coordinator 线程本身也会参与刷脏)。异步刷脏的入口函数为 buf_flush_page_cleaner_init,基本流程如下:

|--> buf_flush_page_coordinator_thread
|    |--> os_event_wait(buf_flush_event)
|    
|    /* loop */
|    |--> page_cleaner_flush_pages_recommendation // 计算最大刷脏量
|    |--> pc_request // 任务分发,slot 数目等于 bp_instance 数目
|    |    |--> os_event_set(page_cleaner->is_requested)
|    |--> pc_flush_slot // 参与刷脏
|    |--> pc_wait_finished


|--> buf_flush_page_cleaner_thread
|    |--> os_event_wait(page_cleaner->is_requested)
|    |--> pc_flush_slot // 1 个线程处理 1 个 bp_instance
|    |    |--> buf_flush_LRU_list // 从 LRU 中刷脏
|    |    |    |--> buf_flush_do_batch(BUF_FLUSH_LRU)
|    |    |
|    |    |--> buf_flush_do_batch(BUF_FLUSH_LIST) // 从 flush_list 刷脏
|    |    |    |--> buf_flush_batch
|    |    |    |    |--> buf_do_LRU_batch
|    |    |    |    |    |--> buf_free_from_unzip_LRU_list_batch
|    |    |    |    |    |--> buf_flush_LRU_list_batch
|    |    |    |    |    |    |--> buf_LRU_free_page
|    |    |    |    |    |    |--> buf_flush_page_and_try_neighbors
|    |    |    |    |    |    |    |--> buf_flush_try_neighbors
|    |    |    |    |    |    |    |    |--> buf_flush_page
|    |    |    |    |
|    |    |    |    |--> buf_do_flush_list_batch
|    |    |    |    |    |--> buf_flush_page_and_try_neighbors

异步刷脏的具体过程可以参考这篇文章,异步刷脏过程中有一个非常重要的点就是 page_cleaner_flush_pages_recommendation计算最大刷脏量,相关的细节在此不再展开,后面有机会再单独整理一篇各种后台线程的更新逻辑。

总结

本文从申请、管理、回收三部分对 InnoDB Buffer Page 的生命周期管理进行了介绍,文中的内容只是一个基本概要,更多的细节还需要读者在阅读代码的过程中慢慢发掘。

MySQL · 引擎特性 · InnoDB UNDO LOG写入

$
0
0

INNODB UNDO LOG写入

我们在之前的章节中提到,UNDO LOG分为INSERT和UPDATE两种类型。接下来我们分别描述这两种记录如何写入UNDO LOG RECORD。

行记录在插入或者更新时,除写入索引记录外,还会同时写入UNDO LOG。对于新插入的行记录,会将新插入的行记录写入UNDO LOG,而对于被更新的行记录,则是将更新前的记录旧值写入UNDO LOG,并在新记录的隐藏字段rollptr中记录该UNDO LOG位置信息。

接下来我们分别描述INSERT和UPDATE时的UNDO LOG写入实现。

INSERT记录

新插入的记录会写到undo log中,在函数btr_cur_ins_lock_and_undo中实现:

db_err_t btr_cur_ins_lock_and_undo(...)
{
  rec = btr_cur_get_rec(cursor);
  index = cursor->index;
  ...
  err = trx_undo_report_row_operation(flags, TRX_UNDO_INSERT_OP, thr, index,
                                      entry, NULL, 0, NULL, NULL, &roll_ptr);

  row_upd_index_entry_sys_field(entry, index, DATA_ROLL_PTR, roll_ptr);
  return (DB_SUCCESS);
}

// 最终走到这里
ulint trx_undo_page_report_insert(...)
{
  // 找到该undo page下一个空闲位置
  first_free = mach_read_from_2(undo_page + TRX_UNDO_PAGE_HDR + TRX_UNDO_PAGE_FREE);
  ptr = undo_page + first_free;

  // 预留的2字节用于记录下一个undo log record位置
  ptr += 2;

  *ptr++ = TRX_UNDO_INSERT_REC;
  ptr += mach_u64_write_much_compressed(ptr, trx->undo_no);
  ptr += mach_u64_write_much_compressed(ptr, index->table->id);

  // index->n_uniq代表主键中包含的column数量
  // 如果是联合主键,则>1
  for (i = 0; i < dict_index_get_n_unique(index); i++) {
    const dfield_t *field = dtuple_get_nth_field(clust_entry, i);
    ulint flen = dfield_get_len(field);
    ptr += mach_write_compressed(ptr, flen);
    ut_memcpy(ptr, dfield_get_data(field), flen);
  }
  return (trx_undo_page_set_next_prev_and_add(undo_page, ptr, mtr));
}

‌对于insert 操作,会将新记录写入至undo log。写入的只是聚簇索引包含的column。例如,创建的表结构如下:

create table t(id1 int, id2 int, value int, primary key (id1, id2));
insert into t values(1, 2, 3);

‌此时写入内容其实只包含列id1和id2的内容。完成后,会生成一个rollptr,且记录在dtuple_t的rollptr列中。

UPDATE记录

更新一个已存在记录时,会将该记录的老版本(即修改前版本)记录在UNDO LOG。与INSERT操作类似:只记录其聚簇索引中包含的column的值以及更新涉及的column值。记完UNDO LOG后,再将数据页中的记录更新为新值,同时将UNDO LOG位置记录在更新后行记录的rollptr字段,形成一个历史更新链。

dberr_t
btr_cur_upd_lock_and_undo(...)
{
    // rec指向了待更新记录的内容(更新前value)
    rec = btr_cur_get_rec(cursor);
    index = cursor->index;

    // 更新非聚簇索引不记录undo log
    if (!dict_index_is_clust(index)) {
        return(lock_sec_rec_modify_check_and_lock(
                   flags, btr_cur_get_block(cursor), rec,
                   index, thr, mtr));
    }
    // 记录undo log
    return(trx_undo_report_row_operation(
               mtr, flags, TRX_UNDO_MODIFY_OP, thr,
               index, NULL, update,
               cmpl_info, rec, offsets, roll_ptr));
}

dberr_t trx_undo_report_row_operation(...)
{
  do {
    undo_page = buf_block_get_frame(undo_block);
    switch (op_type) {
    default:
      offset = trx_undo_page_report_modify(
                undo_page, trx, index, rec, offsets, update,
                cmpl_info, &mtr);
    }
  }
}

ulint
trx_undo_page_report_modify(...)
{
    table = index->table;
    first_free = mach_read_from_2(undo_page + TRX_UNDO_PAGE_HDR + TRX_UNDO_PAGE_FREE);
    ptr = undo_page + first_free;
    ptr += 2;

    // 需要注意这玩意儿:如果update为null时,undo log record中的type会被设置为
    // TRX_UNDO_DEL_MARK_REC
    // 那什么时候update会为null呢?
    // 跟踪了一下发现可能有以下两种场景:
    // 1. btr_cur_ins_lock_and_undo:即插入一条新记录
    // 2. btr_cur_del_mark_set_clust_rec: 从聚簇索引中删除一条老记录
    // 而2可能会发生在两种场景下:
    // 1. 更新一个已有主键值的主键:此时会先插入新的主键,再将老的主键值标记删除
    // 2. 删除一个已有主键值
    if (!update) {
        // 对已有记录标记删除
        type_cmpl = TRX_UNDO_DEL_MARK_REC;
    } else if (rec_get_deleted_flag(rec, dict_table_is_comp(table))) {
        // 更新删除记录,什么时候会这样呢?
        type_cmpl = TRX_UNDO_UPD_DEL_REC;
    } else {
        // 更新一个已存在的记录
        type_cmpl = TRX_UNDO_UPD_EXIST_REC;
    }

    type_cmpl |= cmpl_info * TRX_UNDO_CMPL_INFO_MULT;
    type_cmpl_ptr = ptr;

    *ptr++ = (byte) type_cmpl;
    ptr += mach_ull_write_much_compressed(ptr, trx->undo_no);

    ptr += mach_ull_write_much_compressed(ptr, table->id);
    // 不确定info_bits内到底存储什么玩意
    *ptr++ = (byte) rec_get_info_bits(rec, dict_table_is_comp(table));

    // 获得老记录的trx id和roll ptr值
    // 并将其记录在该UNDO LOG的rollptr和trx_id字段
    // 这样才可以将所有的更新串成一个更新链
    // ----------        ----------        ----------        -----------
    // | UNDO-1 |  <--   | UNDO-2 |  <--   | UNDO-3 |  <--   | row rec |
    // ----------        ----------        ----------        -----------
    // 假如现在来了一次更新记录在UNDO-4中,那形成的历史链应该如下:
    // ----------        ----------        ----------        ----------       ----------- 
    // | UNDO-1 |  <--   | UNDO-2 |  <--   | UNDO-3 |  <--   | UNDO-4 |  <--  | row rec |
    // ----------        ----------        ----------        ----------       -----------
    // 更新前的row rec中记录的rollptr指向UNDO-3
    // 因此UNDO-4中记录的内容是row rec,且其rollptr指向UNDO-3
    field = rec_get_nth_field(rec, offsets, dict_index_get_sys_col_pos(index, DATA_TRX_ID), &flen);

    trx_id = trx_read_trx_id(field);
    ptr += mach_ull_write_compressed(ptr, trx_id);
    field = rec_get_nth_field(rec, offsets,
                  dict_index_get_sys_col_pos(
                      index, DATA_ROLL_PTR), &flen);
    ptr += mach_ull_write_compressed(ptr, trx_read_roll_ptr(field));

    // 记录聚簇索引包含的column的旧值,如果是联合索引,那么可能会包含多个column
    for (i = 0; i < dict_index_get_n_unique(index); i++) {
        field = rec_get_nth_field(rec, offsets, i, &flen);
        ptr += mach_write_compressed(ptr, flen);
        if (flen != UNIV_SQL_NULL) {
            ut_memcpy(ptr, field, flen);
            ptr += flen;
        }
    }

    // 接下来记录被更新column的旧值
    // 注意:只需要记录旧值即可,更新后的值无需记录,因为无论的回滚还是MVCC,都只需要旧值即可
    // 每个column记录三个字段: 
    // 1. column的field no
    // 2. column field的长度
    // 3. column field的值
    if (update) {
        ptr += mach_write_compressed(ptr, upd_get_n_fields(update));
        for (i = 0; i < upd_get_n_fields(update); i++) {
            // pos保存field no
            ulint pos = upd_get_nth_field(update, i)->field_no;
            ptr += mach_write_compressed(ptr, pos);
            // field保存被更新column的旧值, flen记录其长度
            field = rec_get_nth_field(rec, offsets, pos, &flen);
            ptr += mach_write_compressed(ptr, flen);
            if (flen != UNIV_SQL_NULL) {
                ut_memcpy(ptr, field, flen);
                ptr += flen;
            }
        }
    }
}

删除记录

在innodb中,删除一个行记录实现上是标记删除,即不立即从物理页面中删除该记录(因为该记录很有可能还被其他事务所访问),只是将该行记录标记为删除,并记录UNDO LOG,以后在回收UNDO LOG时判断该行记录不再被访问时再清理该行记录。

dberr_t btr_cur_del_mark_set_clust_rec(...)
{
  ...
  // 为标记删除记录undo log
  // 注意倒数第五个参数为nullptr,代表的是删除
  err =
      trx_undo_report_row_operation(flags, TRX_UNDO_MODIFY_OP, thr, index,
                                    entry, nullptr, 0, rec, offsets, &roll_ptr);

  // 更新记录的trx_id和rollptr列
  row_upd_rec_sys_fields(rec, page_zip, index, offsets, trx, roll_ptr);

  return (err);
}

// 在记录删除时传入的update为nullptr
ulint
trx_undo_page_report_modify(ulint flags,                
    ulint op_type,              
    que_thr_t *thr,
    dict_index_t *index,
    const dtuple_t *clust_entry,
    const upd_t *update,
    const rec_t *rec,
    const ulint *offsets,
    roll_ptr_t *roll_ptr)
{
    // 需要注意这玩意儿:如果update为null时,undo log record中的type会被设置为
    // TRX_UNDO_DEL_MARK_REC
    // 那什么时候update会为null呢?
    // 跟踪了一下发现可能有以下两种场景:
    // 1. btr_cur_ins_lock_and_undo:即插入一条新记录
    // 2. btr_cur_del_mark_set_clust_rec: 从聚簇索引中删除一条老记录
    // 不过对于1是insert场景,最终会走trx_undo_page_report_insert()
  	// 而2可能会发生在两种场景下:
    // 1. 更新一个已有主键值的主键:此时会先插入新的主键,再将老的主键值标记删除
    // 2. 删除一个已有主键值
    if (!update) {
        // 对已有记录标记删除
        type_cmpl = TRX_UNDO_DEL_MARK_REC;
    } else if (rec_get_deleted_flag(rec, dict_table_is_comp(table))) {
        // 更新删除记录,什么时候会这样呢?
        type_cmpl = TRX_UNDO_UPD_DEL_REC;
    } else {
        // 更新一个已存在的记录
        type_cmpl = TRX_UNDO_UPD_EXIST_REC;
    }
  	...
    // 获得老记录的trx id和roll ptr值
    // 并将其记录在该UNDO LOG的rollptr和trx_id字段
  	// 这个与上面的update记录流程一致,不再重复介绍

    // 记录聚簇索引包含的column的旧值,如果是联合索引,那么可能会包含多个column
   	// 这个也与上面的update记录流程一致,不再重复介绍
  	...
    // 如果是删除,那要将所有的column都记录在undo log中
    if (!update || !(cmpl_info & UPD_NODE_NO_ORD_CHANGE)) {
        ...
        trx->update_undo->del_marks = TRUE;
        ptr += 2;
        for (col_no = 0; col_no < dict_table_get_n_cols(table); col_no++)
        {
          ...
        }
        mach_write_to_2(old_ptr, ptr - old_ptr);
    }
}

删除记录的内部实现其实也比较简单:

  1. 将行记录设置标记删除
  2. 记录UNDO LOG RECORD,需要搞清楚里面到底记录了哪些内容
  3. 最后,更新原纪录的系统列:trx_id和rollptr

MySQL · 引擎特性 · InnoDB 数据文件简述

$
0
0

通常,我们在使用Mysql时,Mysql将数据文件都封装为了逻辑语义Database和Table,用户只需要感知并操作Database和Table就能完成对数据库的CRUD操作,但实际这一系列的访问请求最终都会转化为实际的文件操作,那这些过程具体是如何完成的呢,具体的Database和Table与文件的真实映射关系又是怎样的呢,下面笔者将通过对Mysql8.0 InnoDB引擎中的文件来剖析一下这个过程。

InnoDB 文件简介

“.ibd”文件:

在InnoDB中,逻辑语义中的Database被转换为了一个独立的目录,也就是说不同Database的Table实际在物理存储时也是天然隔离的,需要关注的是一个很重要的配置项”innodb_file_per_table”,该参数控制在InnoDB中,是否将每个Table独立存储为一个单独的”.ibd”文件,在Mysql 8.0中该参数的默认值为True,即需要将每一个用户所创建的逻辑Table单独存储为一个”.ibd”文件,如果将该参数置为False的话,默认会将所有Table的数据放入同一个”.ibd”文件中,这种方式在多表场景中,在删除表之后回收空间等操作中会带来很大的不便,所以在正常使用中,更推荐使用每个Table单独存储为一个”.ibd”文件的方式。

TableSpace:

每个逻辑语义的Table在InnoDB中都被映射为了一个独立的TableSpace,具有唯一的Space_id,从Mysql8.0开始,所有的系统表也都使用InnoDB作为默认引擎,因此每个系统表,以及Undo也会有一个唯一的Space_ID来标识,而为了快速通过Space_id来识别具体的TableSpace类型,InnoDB特地按照不同的Space_id区段划分给了不同的TableSpace来使用:

Table Space ID 分布
0x0                    : SYSTEM_TABLE_SPACE
0x1 ~        0xFFF9E108:  USER SPACE
0xFFF9E108 ~ 0xFFFFFB88:  session temp table 
0xFFFFFF70 ~ 0xFFFFFFEF:  undo tablespace ID
0xFFFFFFF0:  redo log pseudo-tablespace
0xFFFFFFF1:   checkpoint file space 
0xFFFFFFFD:  innodb_temporary tablespace
0xFFFFFFFE:  data dictionary tablespace
0xFFFFFFFF : invalid space

“.ibd” 文件结构:

众所周知,InnodDB采用Btree作为存储结构,当用户创建一个Table的时候,就会根据显示或隐式定义的主键构建了一棵Btree,而构成Btree的叶子节点被称为Page,默认大小为16KB,每个Page都有一个独立的Page_no。在我们对数据库中的Table进行修改时,最终产生的影响都是去修改对应TableSpace所对应的Btree上的一个或多个Page。这中间还涉及到BufferPool的联动,Page的修改都是在Buffer Pool中进行的,当Page被修改后,即被标记为Dirty Page,这些Page会从Buffer pool中flush到磁盘上,最终保存在”.ibd”文件中,完成对数据的持久化,BufferPool的细节我们就不在这里展开了,详情可以关注之前的月报InnoDB Buffer Pool浅析。

“.ibd”文件为了把一定数量的Page整合为一个Extent,默认是64个16KB的Page(共1M),而多个Extent又构成了一个Segment,默认一个Tablespace的文件结构如图所示:

其中,Segment可以简单理解为是一个逻辑的概念,在每个Tablespace创建之初,就会初始化两个Segment,其中Leaf node segment可以理解为InnoDB中的INode,而Extent是一个物理概念,每次Btree的扩容都是以Extent为单位来扩容的,默认一次扩容不超过4个Extent。

“.ibd”文件的管理Page:

为了更加方便管理和维护Extent和Page,设置了一些特殊的Page来索引它们,也就是大家常常提起的Page0,Page1,Page2,Page3,从代码的注释来看,各个Page的作用如下:

/* We create a new generic empty tablespace.
  We initially let it be 4 pages:
  - page 0 is the fsp header and an extent descriptor page,
  - page 1 is an ibuf bitmap page,
  - page 2 is the first inode page,
  - page 3 will contain the root of the clustered index of the
  first table we create here. */

Page0和Extent 描述页:

我们今天主要展开一下Page0和Page2这两个特殊的Page,Page0即”.ibd”文件的第一个Page,这个Page是在创建一个新的Tablespace的时候初始化,类型为FIL_PAGE_TYPE_FSP_HDR,这个Page用来跟踪后续256个Extent(约256M)的空间管理,所以每隔256M空间大小就需要创建相仿于Page0的Page,这个Page被称之为Extent的描述页,这个Extent的描述页和Page0除了文件头部信息有些不同外,有着相同的数据结构,且大小都是为16KB,而每个Extent Entry占用40字节,总共分配出了256个Extent Entry,所以Page0和Extent描述页只管理后续256个Extent,具体结构如下:

而每个Extent entry中又通过2个字节来描述一个Page,其中一个字节表示其是否被使用,另外一个字节暂为保留字节,尚未使用,具体的结构如下图所示:

Page0会在Header的FSP_HEADER_SIZE字段中记录整个”.ibd”文件的相关信息,具体如下:

其中最主要的信息就是几个用于描述Tablespace内所有Extent和INode的链表,当InnoDB在写入数据的时候,会从这些链表上进行分配或回收Extent和Page,便于高效的利用文件空间。

Page2(INode Page):

接下来我们再谈谈Page2,也就是INode Page,先来看看结构:

在INode Page的每一个INode Entry对应一个Segment,结构如下:

InnoDB通过Inode Entry来管理每个Segment占用的Page,Inode Entry所在的inode page有可能存放满,因此在Page0中维护了Inode Page链表。

Page0中维护了表空间内Extent的FREE、FREE_FRAG、FULL_FRAG三个Extent链表,而每个Inode Entry也维护了对应的FREE、NOT_FULL、FULL三个Extent链表。这些链表之间存在着转换关系,以便于更高效的利用数据文件空间。

当用户创建一个新的索引时,在InnoDB内部会构建出一棵新的btree(btr_create),先为Non-leaf Node Segment分配一个INode Entry,再创建Root Page,并将该Segment的位置记录到Root Page中,然后分配Leaf Segment的Inode entry,也记录到root page中。

InnoDB 内存中对”.ibd”文件的管理

前文中简单叙述了一下”.ibd”文件的结构和管理,接下来继续探讨一下在InnoDB内存中是如何维护各个Tablespace的信息的,而每个Tablespace又是如何和具体的”.ibd”文件映射起来的。

之前提到在”innodb_file_per_table”为ON的情况下,当用户创建一个表时,实际就会在datadir目录下创建一个对应的”.ibd”文件。在InnoDB启动时,会先从datadir这个目录下scan所有的”.ibd”文件,并且解析其中的Page0-3,读取对应的Space_id,检查是否存在相同Space_ID但文件名不同的”.ibd”文件,并且和文件名也就是Tablespace名做一个映射,保存在Fil_system的Tablespace_dirs midrs中,这个mdirs主要用来在InnoDB的crash recovery阶段解析log record时,会通过log record中记录的Space_id去mdirs中获取对应的ibd文件并打开,并根据Page_no去读取对应的Page,并最终Apply对应的redo,恢复数据库到crash的那一刻。

在InnoDB运行过程中,在内存中会保存所有Tablesapce的Space_id,Space_name以及相应的”.ibd”文件的映射,这个结构都存储在InnoDB的Fil_system这个对象中,在Fil_system这个对象中又包含64个shard,每个shard又会管理多个Tablespace,整体的关系为:Fil_system -> shard -> Tablespace。

在这64个shard中,一些特定的Tablesapce会被保存在特定的shard中,shard0是被用于存储系统表的Tablespace,58-61的shard被用于存储Undo space,最后一个,也就是shard63被用于存储redo,而其余的Tablespace都会根据Space_ID来和UNDO_SHARDS_START取模,来保存其Tablespace,具体可以查看shard_by_id()函数。

Fil_shard *shard_by_id(space_id_t space_id) const
      MY_ATTRIBUTE((warn_unused_result)) {
#ifndef UNIV_HOTBACKUP
    if (space_id == dict_sys_t::s_log_space_first_id) {
      /* space_id为dict_sys_t::s_log_space_first_id, 返回m_shards[63] */
      return (m_shards[REDO_SHARD]);

    } else if (fsp_is_undo_tablespace(space_id)) {
      /* space_id介于
         dict_sys_t::s_min_undo_space_id 和
      	 dict_sys_t::s_max_undo_space_id之间,返回m_shards[UNDO_SHARDS_START + limit] */
      const size_t limit = space_id % UNDO_SHARDS;

      return (m_shards[UNDO_SHARDS_START + limit]);
    }

    ut_ad(m_shards.size() == MAX_SHARDS);

    /* 剩余的Tablespace根据space_id取模获得对应的shard */
    return (m_shards[space_id % UNDO_SHARDS_START]);
#else  /* !UNIV_HOTBACKUP */
    ut_ad(m_shards.size() == 1);

    return (m_shards[0]);
#endif /* !UNIV_HOTBACKUP */
  }

其中,在每个shard上会保存一个Space_id和fil_space_t的map m_space,以及Space_name和fil_space_t的map m_names,分别用于通过Space_id和Space_name来查找对应的ibd文件。而每个fil_space_t对应一个Tablespace,在fil_space_t中包含一个fil_node_t的vector,意味着每个Tablesace对应一个或多个fil_node_t,也就是其中的”.ibd”文件,默认用户的Tablespace只有一个”.ibd”文件,但某些系统表可能存在多个文件的情况,这里要特别注明的一个情况是:分区表实际是由多个Tablespace组成的,每个Tablespace有独立的”.ibd”文件和Space_id,其中”.ibd”文件的名字会以分区名加以区分,但给用户返回的是一个统一的逻辑表

之前提到InnoDB会将Tablesapce的Space_id,Space_name以及相应的”.ibd”文件的映射一直保存在内存中,实际就是在shard的m_space和m_names中,但这两个结构并非是在InnoDB启动的时候就把所有的Tablespace和对应的”.ibd”文件映射都保存下来,而是只按需去open,比如去初始化redo和Undo等,而用户表只有在crash recovery中解析到了对应Tablespace的redo log,才会去调用fil_tablespace_open_for_recovery,在scan出mdirs中找到对应的”.ibd”文件来打开,并将其保存在m_space和m_names中,方便下次查找。也就是说,在crash recovery阶段,实际在Fil_system并不会保存全量的”.ibd”文件映射,这个概念一定要记住,在排查crash recovery阶段ddl问题的时非常重要

在crash recovery结束后,InnoDB的启动就已经基本结束了,而此时在启动阶段scan出的保存在mdirs中的”.ibd”文件就可以清除了,此时会通过ha_post_recovery()函数最终释放掉所有scan出的”.ibd”文件。那此时就会有小伙伴要问了,如果不保存全量的文件映射,难不成用户的读请求进来时,还需要重新去查找ibd文件并打开嘛?这当然不会,实际在InnoDB启动之后,还会去初始化Data Dictionary Table(数据字典,简称DD,后文中的DD代称数据字典),在DD的初始化过程中,会把DD中所保存的Tablesapce全部进行validate check一遍,用于检查是否有丢失ibd文件或者数据有残缺等情况,在这个过程中,会把所有保存在DD中的Tablespace信息,且在crash recovery中未能open的Tablespace全部打开一遍,并保存在Fil_system中,至此,整个InnoDB中所有的Tablespace的映射信息都会加载到内存中。具体的调用逻辑为:

sql/dd/impl/bootstrapper.cc
|--initialize
	|--initialize_dictionary
		|--DDSE_dict_recover
storage/innobase/handler/ha_innodb.cc
			|--innobase_dict_recover
				|--boot_tablespaces
					|--Validate_files.validate
						|--alidate_files::check
							|--fil_ibd_open

当用户发起Create Table或Drop Table时,实际也会联动到Fil_system中m_space和m_names的信息,会对应的在其中添加或者删除”.ibd”文件的映射,并且也会持久化在DD中。

数据字典(DD)和”.ibd”文件的关系

接下来我们讨论一下数据字典和”.ibd”文件的关系,首先我们来介绍一下什么是数据字典。

数据字典是有关数据库对象的信息的集合,例如作为表,视图,存储过程等,也称为数据库的元数据信息。换一种说法来讲,数据字典存储了有关例如表结构,其中每个表具有的列,索引等信息。数据字典还将有关表的信息存储在INFORMATION_SCHEMA中 和PERFORMANCE_SCHEMA中,这两个表都只是在内存中,他们有InnoDB运行过程中动态填充,并不会持久化在存储中引擎中。 从Mysql 8.0开始,数据字典不在使用MyISAM作为默认存储引擎,而是直接存储在InnoDB中,所以现在DD表的写入和更新都是支持ACID的。

每当我们执行show databases或show tables时,此时就会查询数据字典,更准确的说是会从数据字典的cache中获取出相应的表信息,但show create table并不是访问数据字典的cache,这个操作或直接访问到schema表中的数据,这就是为什么有时候我们会遇到一些表在show tables能看到而show create table却看不到的问题,通常都是因为一些bug使得在DD cache中还保留的是旧的表信息导致的。

当我们执行一条SQL访问一个表时,在Mysql中首先会尝试去Open table,这个过程首先会访问到DD cache,通过表名从中获取Tablespace的信息,如果DD cache中没有,就会尝试从DD表中读取,一般来说DD cache和DD表中的数据,以及InnoDB内部的Tablespace是完全对上的。

在我们执行DDL操作的时候,一般都会触发清理DD cache的操作,这个过程是必须要先持有整个Tablespace的MDL X锁,在对DDL操作完成之后,最终还会修改DD表中的信息,而在用户发起下一次读取的时候会将该信息从DD表中读取出来并缓存在DD cache中。

鉴于篇幅有限,且数据字典涉及到的模块和逻辑也较多,今天的讨论就到此为止了,后续会专门写一个专题来详细讲一下数据字典,敬请期待。

Database · 案例分析 · UTF8与GBK数据库字符集

$
0
0

问题背景

现有数据库A与数据库B,数据库A服务端由GBK编码,数据库B服务端由UTF8编码,需要完成数据库A至数据库B的数据导入,测试中发现A库数据插入B数据库时的部分数据进行查询时存在编码转换报错。

问题分析

角色分析

首先阐述影响字符编码的几个要素 • Terminal-encoding(用户客户端编码,Iterm编码,终端编码):该编码格式不参与编码转换,其负责将一个字符串映射成字符编码。例如‘鎵’这个字,如果被作为GBK解析,会解析成 0xE689; 如果被当成UTF8解析,会被解析成 0xE98EB5 。这些二进制编码用户感知不到,而被数据库储存。 • Client-encoding(数据库客户端编码,client_coding):该编码格式是数据库识别该编码格式的一个参考。由于字符串被Server-encoding解析进入数据库后以二进制编码的形式存储,由Client-encoding唯一标识数据库中的二进制编码原本是什么编码格式。⚠️如果数据库只有二进制编码是毫无意义的,因为数据库只储存了类似于0xe689abe68f8f这样的二进制,如果没有编码格式甚至不知道其可以被解析为几个字符,解析的规则由字符集指定。 • Database-encoding(数据库服务器编码) 对数据库B来说是 UTF-8模式且不支持GBK格式。含义就是所有的二进制编码如果不是UTF8编码,会被转义成UTF8编码入库;同理如果读出时client_encoding不是UTF8会被转义成其他二进制编码出库。

场景分析

场景a 终端字符集为GBK,数据库client_encoding为GBK,database_encoding 为 UTF8 该场景下,鎵弿 被还原出了正确的原编码,然而这个编码被当成GBK去转义UTF8,发现 0xabe6这个编码(原场景中的 )无法作为一个GBK去转UTF8 导致转义失败。image.png场景b 终端字符集为UTF8,数据库client_encoding为UTF8,database_encoding 为 UTF8 该场景下,鎵弿 被按照UTF编码的格式还原出了UTF8编码并入库。如果仍旧按照这个格式读出,可以得到原字符;如果按照GBK的格式转码,发现 0xEE82A3 这个编码没有GBK对应的字符。 image.png场景c 终端字符集为GBK,数据库client_encoding为UTF8,database_encoding 为 UTF8 该场景下,鎵弿 被还原出了正确的原编码,并被当成UTF8去入库。这种情况下,不管是UTF8去读,还是GBK去读,都可以读出正确的字符串。 image.png

问题小节

• 在一个合规的流程中,Terminal_encoding 及 Client_encoding 应该是完全一致的。这两这其实是一体的两面,分别代表了一个字符串应该被如何编码,和一个编码如何被解析成字符串。这就对应了场景a和场景b的1234 。场景a问题是由于鎵弿 这个字符串本身不能被GBK编码。 • 由于鎵弿 这个字符串本身就是‘扫描’错误解析下的产物,场景c 通过这种不合规的实验还原出了原字符。

问题原因

出现这些问题的根本原因是A库中的“GBK”范围大于B库中设置的GBK。A所谓“GBK”编码的字符集实际上是GB18030。

编码背景资料

GB2312、GBK与GB18030

• GB 2312 或 GB 2312-80 是中国国家标准简体中文字符集,全称《信息交换用汉字编码字符集·基本集》,又称 GB 0,由中国国家标准总局发布,1981 年 5 月 1 日实施。GB 2312 编码通行于中国大陆;新加坡等地也采用此编码。中国大陆几乎所有的中文系统和国际化的软件都支持 GB 2312。GB 2312 标准共收录 6763 个汉字,GB 2312 对任意一个图形字符都采用两个字节表示 • GBK 即汉字内码扩展规范,K 为汉语拼音 Kuo Zhan(扩展)中“扩”字的声母。英文全称 Chinese Internal Code Specification。GBK 共收入 21886 个汉字和图形符号,包括:GB 2312 中的全部汉字、非汉字符号。BIG5 中的全部汉字。与 ISO 10646 相应的国家标准 GB 13000 中的其它 CJK 汉字,以上合计 20902 个汉字。其它汉字、部首、符号,共计 984 个。 • GB 18030,全称:国家标准 GB 18030-2005《信息技术中文编码字符集》,是中华人民共和国现时最新的内码字集,是 GB 18030-2000《信息技术信息交换用汉字编码字符集基本集的扩充》的修订版。GB 18030 与 GB 2312-1980 和 GBK 兼容,共收录汉字70244个。与 UTF-8 相同,采用多字节编码,每个字可以由 1 个、2 个或 4 个字节组成。编码空间庞大,最多可定义 161 万个字符。支持中国国内少数民族的文字,不需要动用造字区。汉字收录范围包含繁体汉字以及日韩汉字。GB 18030 编码是一二四字节变长编码。 • 国家标准GB18030-2000《信息交换用汉字编码字符集基本集的补充》是我国继GB2312-1980和GB13000-1993之后最重要的汉字编码标准,是我国计算机系统必须遵循的基础性标准之一。GB18030-2000编码标准是由信息产业部和国家质量技术监督局在2000年 3月17日联合发布的,并且将作为一项国家标准在2001年的1月正式强制执行。GB18030-2005《信息技术中文编码字符集》是我国制订的以汉字为主并包含多种我国少数民族文字(如藏、蒙古、傣、彝、朝鲜、维吾尔文等)的超大型中文编码字符集强制性标准,其中收入汉字70000余个。

编码小节

• GB2312 -> GBK -> GB18030 是逐渐扩充的集合,其向下兼容 • 我国现有的汉字编码字符集标准是 GB18030

解决方案

在导入与导出数据时,如果A库是“GBK“或类“GBK”字符集传输或存储数据,B库设置客户端字符集为“GB18030”。

MySQL · 性能优化 · PageCache优化管理

$
0
0

背景

监控线上实例时,曾出现可用内存不足,性能发生抖动的情况。研究后发现是日志文件的page cache占用了大量的内存(200G+),导致系统可立即分配的内存不足,影响了系统性能。

查看linux内核文档发现,操作系统在内存的使用未超过上限时,不会主动释放page cache,以求达到最高的文件访问效率;当遇到较大的内存需求,操作系统会当场淘汰一些page cache以满足需求。由于page cache的释放较为费时,新的进程不能及时得到内存资源,发生了阻塞。

据此,考虑能否设计一个优化,在page cache占据大量内存前,使用linux内核中提供的posix_fadvise等缓存管理方法,由Mysql主动释放掉无用的page cache,来缓解内存压力。本文先介绍文件的page cache机制,并介绍应用程序级的管理方法,最后介绍针对Mysql日志文件的内存优化。

Page Cache机制

页面缓存(Page Cache)是Linux内核中针对文件I/O的一项优化,Linux从内存中划出了一块区域来缓存文件页,如果要访问外部磁盘上的文件页,首先将这些页面拷贝到内存中,再进行读写。由于硬件结构限制,磁盘的I/O速度比内存慢很多,因此使用Page cache能够大大加速文件的读写速度。

Page Cache的机制如上图所示,具体来说,当应用程序读文件时,系统先检查读取的文件页是否在缓存中;如果在,直接读出即可;如果不在,就将其从磁盘中读入缓存,再读出。此时如果内存有足够的内存空间,该页可以在page cache中驻留,其他进程再访问该部分数据时,就不需要访问磁盘了。

同样,在写文件之前,系统先检查对应的页是否已经在缓存中;如果在,就直接将数据写入page cache,使其成为脏页(drity page)等待刷盘;如果不在,就在缓存中新增一个页面并写入数据(这一页面也是脏页)。真正的磁盘I/O会由操作系统调用fsync等方法来实现,这一调用可以是异步的,保证磁盘I/O不影响文件读写的效率。 在Mysql中,我们说的写文件(write)通常是指将数据写入page cache中,而刷盘或落盘(fsync)才真正将数据写入磁盘中的文件。

程序将数据写入page cache后,可以主动进行刷脏(如调用fsync),也可以放手不管,等待内核帮忙刷脏。在linux内核中,有关自动刷脏的参数如下。

dirty_background_ratio
// 触发文件系统异步刷脏的脏页占总可用内存的最高百分比,当脏页占总可用内存的比例超过该值,后台回写进程被触发进行异步刷脏。

dirty_ratio
// 触发文件系统同步刷脏的脏页占总可用内存的最高百分比,当脏页占总可用内存的比例超过该值,生成新的写文件操作的进程会先执行刷脏。

dirty_background_bytes & dirty_bytes
// 上述两种刷脏条件还可通过设置最高字节数而非比例触发。如果设置bytes版本,则ratio版本将变为0,反之亦然。

dirty_expire_centisecs
// 这个参数指定了脏页多长时间后会被周期性刷脏。下次周期性刷脏时,脏页存活时间超过该值的页面都将被刷入磁盘。

dirty_writeback_centisecs 
// 这个参数指定了多长时间唤醒一次刷脏进程,检查缓存并刷下所有可以刷脏的页面。该参数设为零内核会暂停周期性刷脏。

Page Cache默认由系统调度分配,当free的内存高于内核的低水位线(watermark[WMARK_MIN])时,系统会尽量让用户充分使用缓存,因为它认为这样内存的利用效率最高;当低于低水位线时,就按照LRU的顺序回收page cache。正是这种策略,使得内存的free的部分越来越小,cache的部分越来越大,造成了文章开头提到的问题。

实际上,Mysql中许多文件有着固定的访问模式,它们的页面不会被短时间内多次访问,例如redo log和binlog文件。在实例正常运行的状态下,Redo log只是持久化每次操作的物理日志,写入文件后就没有读操作;binlog文件在写入后,也只会被dump线程所访问。

Page Cache监控与管理

vmtouch

vmtouch工具可以用来查看指定文件page cache使用情况,也可以手动将文件换入或换出缓存。下面是其常用功能的使用方法

# 显示文件的page cache使用情况
$ vmtouch -v [filename]

# 换出文件的page cache
# 即使换出成功,内核也可能在vmtouch命令完成时将页面分页回内存。
$ vmtouch -ve [filename]

# 换入文件的page cache
# 保证文件的page cache都换入内存,但是在vmtouch命令完成时,该页面可能被内核逐出
$ vmtouch -vt [filename]

posix_fadvise

posix_fadvise是linux上控制页面缓存的系统函数,应用程序可以使用它来告知操作系统,将以何种模式访问文件数据,从而允许内核执行适当的优化。其中一些建议可以只针对文件的指定范围,文件的其他部分不生效。 这一函数对内核提交的是建议,在特殊情况下也可能不会被内核所采纳。

函数在内核的mm/fadvise.c中实现,函数的声明如下:

SYSCALL_DEFINE(fadvise64_64)(int fd, loff_t offset, loff_t len, int advice)

其中fd是函数句柄;offset是建议开始生效的起始字节到文件头的偏移量;len是建议生效的字节长度,值为0时代表直到文件末尾;advice是应用程序对文件页面缓存管理的建议,共有六种合法建议。下面根据代码,对六种建议进行分析。

switch (advice) {    
    /*
    该文件未来的读写模式位置,应用程序没有关于page cache管理的特别建议,这是advice参数的默认值
    将文件的预读窗口大小设为下层设备的默认值
    */
    case POSIX_FADV_NORMAL:
        file->f_ra.ra_pages = bdi->ra_pages;
        spin_lock(&file->f_lock);
        file->f_mode &= ~FMODE_RANDOM;
        spin_unlock(&file->f_lock);
        break;
    
    /* 该文件将要进行随机读写,禁止预读 */
    case POSIX_FADV_RANDOM:
        spin_lock(&file->f_lock);
        file->f_mode |= FMODE_RANDOM;
        spin_unlock(&file->f_lock);
        break;
    
    /*
    该文件将要进行顺序读写操作(从文件头顺序读向文件尾)
    将文件的预读窗口大小设为默认值的两倍
    */
    case POSIX_FADV_SEQUENTIAL:
        file->f_ra.ra_pages = bdi->ra_pages * 2;
        spin_lock(&file->f_lock);
        file->f_mode &= ~FMODE_RANDOM;
        spin_unlock(&file->f_lock);
        break;
    
    /* 该文件只会被访问一次,收到此建议时,什么也不做 */    
    case POSIX_FADV_NOREUSE:
        break;
    
    /* 该文件将在近期被访问,将其换入缓存中 */
    case POSIX_FADV_WILLNEED:
        ...
        ret = force_page_cache_readahead(mapping, file,
                                         start_index,
                                         nrpages);
        ...
        break;
    
    /* 该文件在近期内不会被访问,将其换出缓存 */
    case POSIX_FADV_DONTNEED:
        if (!bdi_write_congested(mapping->backing_dev_info))
            __filemap_fdatawrite_range(mapping, offset, endbyte,
                                       WB_SYNC_NONE);
        ...
        if (end_index >= start_index)
            invalidate_mapping_pages(mapping, start_index,
                                     end_index);
        break;
    default:
        ret = -EINVAL;
}

针对POSIX_FADV_NORMAL,POSIX_FADV_RANDOM和POSIX_FADV_SEQUENTIAL这三个建议,内核会对文件的预读窗口大小做调整,具体调整策略见代码注释。这些建议的影响范围是整个文件(无视offset和len参数),但不影响该文件的其他句柄。针对POSIX_FADV_WILLNEED和POSIX_FADV_DONTNEED,内核会尝试直接对page cache做调整,这里不是强制的换入或换出,内核会根据情况采纳建议。

当建议为POSIX_FADV_WILLNEED时,内核调非阻塞读force_page_cache_readahead方法,将数据页换入缓存。这里根据内存负载的情况,内核可能会减少读取的数据量。

当建议为POSIX_FADV_DONTNEED时,内核先调用fdatawrite将脏页刷盘。这里刷脏页用的参数是非同步的WB_SYNC_NONE。刷完脏后,会调用invalidate_mapping_pages清除相关页面,该函数在mm/truncate.c中实现,代码如下。

unsigned long invalidate_mapping_pages(struct address_space *mapping,
        pgoff_t start, pgoff_t end)
{
    ...
    while (index <= end && pagevec_lookup(&pvec, mapping, index,
            min(end - index, (pgoff_t)PAGEVEC_SIZE - 1) + 1)) {
        mem_cgroup_uncharge_start();
        for (i = 0; i < pagevec_count(&pvec); i++) {
            ...
            ret = invalidate_inode_page(page);
            ...
        }
        ...
    }
    return count;
}

int invalidate_inode_page(struct page *page)
{
    struct address_space *mapping = page_mapping(page);
    if (!mapping)
        return 0;
    if (PageDirty(page) || PageWriteback(page))
        return 0;
    if (page_mapped(page))
        return 0;
    return invalidate_complete_page(mapping, page);
}

可以看到,invalidate_mapping_pages调用了下层函数invalidate_inode_page,其中的判断逻辑是,如果页脏或页正在写回,则什么也不做;如果没有映射关系或页不再缓存中,则什么也不做。所以这里内核只会尽力而为,清除掉自己能清除的缓存,而不会等待刷脏完成后再清除文件的全部缓存。

因此,在使用POSIX_FADV_DONTNEED参数清除page cahce时,应当先执行fsync将数据落盘,这样才能确保page cache全部释放成功。posix_fadvise函数包含于头文件fcntl.h中,清除一个文件的page cache的方法如下:

#include <fcntl.h>
#include <unistd.h>
...
fsync(fd);
int error = posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
...

posix_fadvise成功时会返回0,失败时可能返回的error共有三种,分别是

EBADF  // 参数fd(文件句柄)不合法,值为9

EINVAL // 参数advise不是六种合法建议之一,值为22

ESPIPE // 输入的文件描述符指向了管道或FIFO,值为29

Mysql日志优化策略

Mysql中不同文件有着不同的访问行为,日志文件是一种顺序读写占绝大多数的文件,因此我们可以为binlog和redo log设计相应的管理策略,来清除暂时不会使用的page cache。

Page cache是系统资源,不属于某个进程管理,因此无法通过进程内存使用的情况来观察优化效果。我们可以使用vmtouch工具来查看page cache的使用情况。

Redo log

在Innodb层,Redo log的主要职责是数据持久化,实现先写日志再写数据的WAL机制。Redo log文件大小和个数固定,由innodb_log_file_size和innodb_log_files_in_group参数控制,这些文件连在一起,被Innodb当成一个整体循环使用。

Redo log写page cache和刷盘分别由线程log_writer和log_flusher异步执行,8.0版本中还实现了写log buffer的无锁化,其具体可参见往期月报:http://mysql.taobao.org/monthly/2019/02/05/

在正常运行的实例中,redo log只有写操作。在写入某个文件的某页后,需要较长的一段时间log_flusher才会再次推进到该页,因此无需保留page cache。

Redo Log的刷盘由log_flusher线程异步执行,因此可以将page cache的释放操作放在log_flusher线程上,每次flush刷脏后执行。这样每次需要释放的page cache较少,耗时较短;

Binlog

在mysql实例正常运行过程中,binlog主要用来做主从复制。Binlog文件的大小由参数max_binlog_size指定,数量没有限制。Binlog的刷盘由参数sync_binlog控制,当sync_binlog为0的时候,刷盘由操作系统负责,异步执行;当不为0的时候,其数值为定期sync磁盘的binlog commit group数,由主线程同步执行刷盘。

没有从库挂载时,binlog只有写操作,保留page cache意义不大。sync_binlog大于0时,刷盘操作以事务为单位,在主线程中拿LOCK_log锁同步执行,如果在每次刷盘后进行fadvise,会阻塞较多的主线程。

因此,将page cache的释放延后到rotate执行,即在关闭旧文件并且成功开启新文件,放掉LOCK_log锁后,释放旧文件的page cache。这样,fadvise操作只会阻塞负责rotate的线程,不会影响到其他线程(因为其他线程都在新的binlog文件中操作)。Rotate执行过程中会调用sync方法刷脏,因此在rotate后释放page cache无需提前刷脏。

有从库挂载时,每次binlog刷盘后,会有dump线程来读取binlog文件的更新,并将更新内容发送到从库。当binlog文件的最后写入位置与dump线程的读取位置比较近(如相距3个文件以内)时,在dump线程读完binlog后再释放page cache效率较高,因为dump可以从page cache中读到更新内容,无需磁盘I/O。这种情况下,将page cache的释放延后到dump线程rotate成功后执行。Dump线程切换binlog时,旧文件已被主线程刷脏,而dump线程只会做读操作,因此不会产生脏页,释放page cache前无需再次刷脏。

测试

测试机规格:96core,750GB memory 设置binlog的大小为1GB,redo log的大小为1GB,数量为10个;设置innodb_flush_log_at_trx_commit和sync_binlog为双1。使用sysbench工具进行压测,线程数为128,模式为OLTP_read_write,结果如下图所示。 上面两个图分别是有从库挂载和没有从库挂载时的性能测试结果,蓝色曲线是不加page cache优化时的QPS折线图,橙色曲线是加page cache优化时的QPS折线图。可以发现,释放page cache的优化基本不会导致性能的下降。使用write only模式sysbench压测也无明显性能损失,这里不再展开叙述。

使用vmtouch工具测试page cache释放的速度,如上图所示。在测试机上清理实例写满的1GB的redo log file的page cache约耗时0.28秒。

上面两个图是无从库挂载,无page cache优化时,压测300s后data文件夹的cache使用情况和系统的内存使用情况统计。使用vmtouch命令查看实例的data文件夹,可以看到不使用优化时,cache激增到20G。使用free命令查看系统内存的使用情况,发现大部分的内存被cache占用,比free的部分多6.8倍。

上面两个图是有page cache优化时,压测300s后内存的使用情况。可以看到,使用优化后,实例的data文件夹使用cache的大小显著减小。使用free命令,发现减少的cache全部计入了free中,可以被自由使用。

挂载从库进行同样的测试,可得到相同的结论。

总结

系统对page cache的管理,在一些情况下可能有所欠缺,我们可以通过内核提供的posix_fadvise予以干预。在几乎不损失性能的前提下,可以通过主动释放Mysql日志文件的page cache的方法,达到减缓内存压力的目的。对于极端内存需求的场景,这一优化能够很好的预防性能抖动的发生。

MySQL · 分布式系统 · 一致性协议under the hood

$
0
0

Imgur

在我看来包含 log, state machine, consensus algorithm 这3个部分, 并且是有 electing, normal case, recovery 这3个阶段都可以称为paxos 协议一族.

为什么说raft 也是3个阶段, 因为其实raft 在重新选举成leader 以后, 也是需要一段recovery 的时间, 如果超过半数的follower 没有跟上leader 的日志, 其实这个时候raft 写入都是超时的, 只不过raft 把这个recovery 基本和normal case 合并在一起了, zab 不用说有一个synchronization 阶段, multi-paxos 因为选举的是任意一个节点作为leader, 那么需要有一个对日志重确认的阶段

  1. 选择是primary-backup system 或者是 state machine system

    对于具体primary-backup system 和 state machine system 的区别可以看这个文章 http://baotiao.github.io/2017/11/08/state-machine-vs-primary-backup/ , 在这里primary-backup system 也叫做 passive replication, state machine system 也叫做 active replication

  2. 是否支持乱序提交日志和乱序apply 状态机

    其实这两个乱系提交是两个完全不一样的概念.

    是否支持乱序提交日志是区分是raft 协议和不是raft 协议很重要的一个区别

    在我看来multi-paxos 与raft 对比主要是能够乱序提交日志, 但是不能够乱序apply 状态机. 当然也有paxos 实现允许乱序apply 状态机, 这个我们接下来说, 但是乱序提交日志带来的只是写入性能的提升, 是无法带来读性能的提升的, 因为只要提交的日志还没有apply, 那么接下来的读取是需要等待前面的写入apply 到状态机才行. 并且由于允许乱序提交日志, 带来的问题是选举leader 的时候, 无法选举出拥有最多日志的leader, 并且也无法确认当前这个term/epoch/ballot 的操作都提交了, 所以就需要做重确认的问题. 因此raft/zab/vsp 都是要求日志连续, 因为新版本的zab 的FLE 算法也是默认选举的是拥有最长日志的节点作为leader

    支持乱序apply 日志是能够带来读取性能的提升, 这个也是Generalized Paxos 和 Egalitarian Paxos 所提出的做法. 但是这个就需要在应用层上层去做这个保证, 应用层需要确定两次操作A, B 完全没有重叠. 如果大量的操作都互相依赖的话, 这个优化也很难执行下来. 换个角度来考虑的话, 其实支持乱序 apply 日志, 其实是和multi group 类似的一个做法, 都是在应用层已经确定某些操作是互相不影响的. 比如PhxPaxos 团队就是用的是multi group 的做法, 所以有些宣传raft 不适合在数据库领域使用, 其实我觉得有点扯, 乱序提交日志带来的收益其实不高, 想要乱序apply 状态机的话, multi group 基本是一样的

  3. 是否支持primary order

    primary order, 也叫做FIFO client order, 这是一个非常好的功能, 也是zab 特别大的宣称的一个功能. 但是这里要主要这里所谓的FIFO client order 指的是针对单个tcp 连接, 所以说如果一个client 因为重试建立了多个channel, 是无法保证FIFO order. 其实想做到client 级别的FIFO order 也是挺简单, 就是需要给每一个client request 一个id, 然后在server 去等待这个id 递增. 目前基于raft 实现的 Atomix 做了这个事情 http://atomix.io/copycat/docs/client-interaction/#preserving-program-order

    具体讨论: http://mail-archives.apache.org/mod_mbox/zookeeper-user/201711.mbox/%3cCAGbZs7g1Dt6QXZo1S0DLFrJ6X5SxvXXFR+j2OJeyksGBVyGe-Q@mail.gmail.com%3e

    所以我认为 tcp + 顺序apply 状态机都能够做到单个tcp连接级别的FIFO order, 但是如果需要支持 client 级别的FIFO order, 那么就需要在client 上记录一些东西.

  4. 在选举leader 的时候, 是否支持 designated 大多数. 选举leader 的时候如何选举出leader

    designated 有什么用呢? 比如在 VSR 的场景里面, 我们可以指定某几个节点必须apply 成功才可以返回. 现实中的场景比如3个城市5个机房, 那么我们可以配置每个城市至少有一个机房在这个designated 里面, 那么就不会出现有一个城市完全没有拷贝到数据的情况.

    如何选举出leader 这个跟是否支持乱序提交日志有关, 像raft/zab/vsr 这样的场景里面, 只能让拥有最长日志的节点作为leader, 而paxos 可以在这里增加一些策略, 比如让成为leader 的节点有优先级顺序等等

  5. 也是和选举相关, 在选举完成以后, 如何执行recovery 的过程. 以及这个rocovery 的过程是如何进行的, 是只同步日志, 还是根据快照进行同步, 是单向同步, 还是双向同步.

    早期的zab 实现就是双向同步, 任意选取一个节点作为新的leader, 那么这个时候带来的问题就是需要找到其他的follower 里面拥有最长日志的节点, 把他的日志内容拉取过来, 然后再发送给其他的节点, 不过后来zab 也改成FLE(fast leader election), 也只需要单向同步日志内容. raft/vsr 也都只需要单向的同步日志了. paxos 因为允许乱序提交日志, 因此需要和所有的节点进行重确认, 因此需要双向的同步日志.

    这里要注意的是 paxos 这种做法需要重新确认所有的他不确认是否有提交的日志, 不只是包含他没有, 还包含他有的也有可能没有提交. 因此一般paxos 会保存一下当前已经提交到哪里了, 然后成为新的leader 以后, 需要重新确认从当前的commitIndex 以后的所有的日志. 这个成本还是很高的, 因此就是我们所说的重确认问题. 那么这个重确认什么时候到头呢? 需要确认到所有的server 都没有某一条日志了才行

  6. 读取的时候的策略, 包括lease 读取或者在任意一个replica 读取, 那么可能读到旧数据. 然后通过版本号进行判断是否读取到的是最新数据.

    lease 做法其实和协议无关, 任意一种的paxos 都可以通过lease 的优化读取的性能, 当然lease 做法其实是违背分布式系统的基础理论, 就是分布式系统是处于一个asynchronization network 里面, 无法保证某一条消息是到达还是在网络中延迟

    zookeeper 的实现里面为了提高读取的性能, 就允许client 直接去读取follower 的内容, 但是这样的读取是可能读取到旧数据, 所以有提供了一个sync 语义, 在sync 完之后的读取一定能够读取到最新的内容, 其实sync 就是写入操作, 写入操作成功以后, 会返回一个最新的zxid, 那么client 拿这个zxid 去一个follower 去读取的时候, 如果发现follower 的zxid 比当前的client 要来的小, 那么这个follower 就会主动去拉取数据

    目前在raft phd thesis 里面读取的优化就包含了 lease 读取和只需要通过Heartbeat 确认leader 信息进行读取

    如何尽可能减少leader 的压力, 是一致性协议都在做的一个事情, 想zookeeper 这种通过上层应用去保证, 允许读取旧数据也是一个方向, 当然还有的比如像Rotating leader, Fast Paxos 给client 发送当前的proposal number 的做法.

  7. 在检测leader 是否存活的时候是单向检测还是双向检测

    比如在raft 里面的心跳只有leader 向follower 发送心跳, 存在这样的场景. 如果有5个节点, 只有leader 节点被割裂了, 其实4个节点网络正常, 新的这4个节点选举出一个leader, 旧的leader 由于完全与其他节点割裂, 所有的AppendEntry 都是失败的, 不会收到一个新的Term号, 因此一直保持着自己是leader 的状态. 那么这样系统就会同时存在两个leader, 但是这个不影响协议正确性, 因为旧的leader 是无法写入成功的.

    在zab 里面心跳是双向的, 也就是说leader 向follower 发送心跳, 如果超过半数的follower 没有应答, 那么leader 会进入到electing 状态. 同时follower 也会向leader 发送心跳, 如果leader 没有回应, 那么这个follower 节点同样会进入到electing 状态. 那么对应于上述的场景, zab 就不会出现像raft 一样, 长期同时存在两个leader 的情况.

    通过上述对比, 我还是觉得raft 实现更简洁, 而双向心跳检测这种做法增加了大量的复杂度

最后的结论是这样

对于计算密集型任务, 也就是写入量比较大的场景, 建议选择passive replication strategy, 那么也就是选择VSR 或者 ZAB 这样.其实主要就是考虑到passive replication 只需要更新的是state 的修改, 而不是用于操作, 那么就可以少做很多操作.

对于对性能比较敏感的场景, 那么应该选择active replication stategy, 那么也就是选择mulit-paxos, raft 这样, 并且不需要designated majorities. 因为passive replication strategy 在rocovery 需要更多的时间, 因为client 的操作是需要写入到状态机, 如果这个client 的操作最后没有被提交, 因为log 可以提供一个回滚操作, 而状态机很少能够提供这种回滚操作, 因此就需要将这个节点的状态机的内容重写, 所以会导致recovery 需要较长的时间.

Reference:

  1. http://www.tcs.hut.fi/Studies/T-79.5001/reports/2012-deSouzaMedeiros.pdf
  2. https://arxiv.org/pdf/1309.5671.pdf
  3. https://ramcloud.stanford.edu/raft.pdf
  4. http://www.read.seas.harvard.edu/~kohler/class/08w-dsi/chandra07paxos.pdf
  5. http://research.microsoft.com/en-us/um/people/lamport/pubs/paxos-simple.pdf
  6. http://research.microsoft.com/en-us/um/people/lamport/pubs/lamport-paxos.pdf
  7. http://baotiao.github.io/2017/11/08/state-machine-vs-primary-backup/

X-Engine · 性能优化 · Parallel WAL Recovery for X-Engine

$
0
0

背景

数据库的Crash Recovery时长关系到数据库的可用性SLA、故障止损时间、升级效率等多个方面。本文描述了针对X-Engine数据库存储引擎的一种Crash Recovery优化手段,在典型场景下可以显著缩短数据库实例的故障恢复时间,提升用户使用感受。

当前面临的问题

X-Engine是阿里自研的基于LSM-tree架构的数据库存储引擎,对X-Engine的数据更新是将变更数据并行插入无锁内存表(MemTable),同时为了防止MemTable过大影响数据查找和宕机恢复的效率,系统会不定期将MemTable转换成不可修改的内存表(ImmutableMemTables),并将ImmutableMemTables整体Flush落盘;对无锁内存表的持久化通过WAL机制保证。

关于LSM-tree结构和X-Engine的数据分层存储机制,可以阅读文章:X-Engine 一条数据的漫游

X-Engine存储引擎的故障恢复需要读取所有WAL文件并重新生成所有ImmutableMemTables和MemTables,整体Crash Recovery流程分为以下两步:

  • X-Engine存储引擎元信息回放
  • WAL文件回放,恢复所有的ImmutableMemTables和MemTables到shutdown前状态

当前阿里云MySQL(XEngine)线上实例有异常宕机后重启缓慢的现象,通过对相关现场和线下试验分析,异常宕机时,通常有大量内存表未Flush落盘,因此在宕机恢复时需要进行WAL文件回放,这一操作占据了X-Engine宕机重启的绝大部分时间。结合现有工程实现分析,当前X-Engine的WAL文件回放逻辑主要有以下几个问题:

  • 单线程恢复内存表,无法发挥多核优势
  • 使用同步IO接口读WAL文件,等待磁盘IO耗时较多
  • 恢复过程中存在过多将ImmutableMemTables Flush落盘操作,虽然可以释放被Flush的内存表,节省内存开销,但磁盘操作进一步加大了X-Engine存储引擎宕机重启的耗时

WAL Recovery性能优化方案

针对上述问题,我们制定了XEngine WAL Recovery的优化方案。总体思路如下:

  • 由于X-Engine存储引擎的所有数据都是存储在线程安全的无锁内存表中的,该内存表支持多线程并发更新,因此可以将单线程恢复MemTable优化成多线程并行恢复,充分利用多核的优势,让整个宕机恢复过程并行起来
  • 将同步磁盘IO改成异步IO,缩减读文件耗时
  • WAL恢复过程中尽可能减少ImmutableMemTables的Flush落盘操作,只有在超过内存配置阈值时,为了防止数据库实例OOM,才会触发Flush逻辑。实际上如果数据库重启前后没有内存相关的配置变更,那么宕机重启过程应该几乎没有Flush操作。

总体设计

Parallel WAL Recovery的总体设计如下: parallel-recovery

Recovery主线程可作为回放任务的生产者,通过aio接口读WAL文件,构造WAL回放任务存入回放线程池的任务队列中;

回放任务的主要成员是从WAL文件中解析出的一条完整WAL Record,里面包含了一批连续的数据更新记录,每个更新记录都有各自对应的全局序、操作类型和操作值等。

回放线程池中的回放线程从任务队列中获取回放任务并发更新内存表。

上述回放过程,我们通过一个回放线程池将读WAL文件操作和回放内存表操作完全并行起来,可以有效提升CPU利用率,降低回放时长。但是在实际设计过程中,还需要考虑以下几个问题:

  • 宕机恢复过程中LSM-Tree的SwitchMemtable过程
  • 2PC事务的正确回放
  • 防御OOM

以下三节将具体阐述X-Engine的Paralle Recovery机制如何解决上述问题。

宕机恢复过程中LSM-Tree的SwitchMemtable过程

LSM-Tree的SwitchMemtable过程是X-Engine存储引擎中驱动数据由内存转移到磁盘的重要操作,具体逻辑是新建一个空MemTable,将当前正在更新的内存表标记为Immutable,后续数据库实例的新的更新操作将插入新建的MemTable中,已经被标记为Immutable的MemTable不可修改,等待一段时间后Flush落盘并从内存中释放。

SwitchMemtable发生在X-Engine的写入流水线中。触发SwitchMemtable的因素有很多,如单个MemTable内存超过一定阈值、单个MemTable中delete操作超过一定百分比、系统内存占用达到全局内存限制等。

在X-Engine的宕机恢复过程中,也需要根据一定条件触发SwitchMemtable,防止出现单个MemTable内存过大或全局内存超限等情况。触发SwitchMemtable的时间点无需和宕机前完全一致,但需要保证一个原则:SwitchMemtable发生的时刻,待Switch的内存表的最高Sequence之前的WAL Record已经回放完整,否则可能出现数据正确性问题,如下所示: switch-memtable-problem

上图中,在Sequence(11-25)对应的WAL Record还未被Thread1插入MemTable时,MemTable被switch为Immutable MemTable0,Sequence(11-25)的WAL Record稍后被插入到新建内存表中,Immutable MemTable0和MemTable之间的WAL Record Sequence不再严格递增。LSM-Tree的读路径是先读MemTable,匹配不到对应的key再读ImmutableMemTable。假设Sequence_11和Sequence_26都是对同一个Key_A的修改操作,WAL回放完成后用户将获取到Sequence_11版本下的Key_A对应的value,最新的Sequence_26版本被隐藏,数据出现正确性问题。

解决方案

解决上述问题,并发WAL回放中的SwitchMemtable过程需要有一个全局barrier。

当回放主线程判断需要执行SwitchMemtable时,暂停任务队列中回放任务出队并等待所有并发回放线程执行完已经从任务队列中取出的回放任务,即等待上图中Sequence(11-25)对应的WAL Record被插入到memtable中后,才可以安全进行Switch;待SwitchMemtable过程结束后,回放主线程将通知回放线程池可以继续工作。

在并行回放过程中,回放主线程是照WAL Record的全局序将回放任务投递到任务队列中,因此只要回放线程池执行完从任务队列中取出的回放任务,就可以保证当前MemTable的最大Sequence之前的WAL Record已经回放完整。

同时,由于SwichMemtable需要在全局barrier中进行,频繁的SwtichMemtable操作将导致X-Engine的并发回放效率降低,因此需要调整并发回放过程中触发SwichMemtable的策略。

我们倾向于积攒一批SwitchMemtable任务后,在一个全局Barrier中完成所有的SwitchMemtable操作,以避免频繁地暂停回放线程池。目前在并发回放过程中触发SwitchMemtable需要满足以下三个条件之一:

  • 系统中已经积攒了超过一定数量内存表的SwitchMemtable任务(默认3个)
  • 某个内存表已经达到触发SwitchMemtable的条件阈值并等待SwitchMemtable超过一定时间(默认10s)
  • X-Engine存储引擎全局内存占用超过阈值(此时不仅仅需要触发SwitchMemtable,还需要触发Flush落盘操作,将在防御OOM章节中介绍)

2PC事务的正确回放

X-Engine存储引擎提供了2PC事务接口,主要用于binlog和X-Engine引擎的原子性提交保证以及跨引擎事务的原子性提交保证。

2PC事务在X-Engine中的实现如下:

  • prepare阶段向WAL文件中写入事务更新记录和对应事务的prepare日志
  • commit阶段将事务数据更新到内存表中并在WAL文件中写入对应事务的commit事件
  • 事务通过XID来进行标识

2PC事务在X-Engine的恢复流程实现如下:

  • 根据事务的prepare日志在内存中构造对应事务的数据更新上下文,用XID来标识
  • 在读取到对应XID事务的commit日志时,根据XID从内存中获取事务上下文并更新内存表

上述流程在X-Engine原有的串行回放流程中可以正常跑通,但在并行回放中存在问题,原因是并行WAL回放过程中无法确定同一个事务的prepare日志和commit日志的回放顺序,可能出现commit日志先被回放到,而此时内存中的事务上下文尚未构建的情况;

另外,X-Engine的2PC事务接口的XID由SQL层传入,X-Engine内部要求不允许出现XID重叠的情况,即不允许一个XID标识的事务未被commit又再次以此XID发起事务prepare;但允许XID重复利用,即已经prepare、commit完成的XID允许再次发起prepare,同样,上述机制在并行WAL回放中也存在问题:假设WAL文件中有如下记录(X-Engine中对2PC事务日志分配的全局序与下表中不完全一致,下表仅作为示例说明):

Sequence操作类型操作值
1BeginPreparenull
2PutKey=a, Value=b
3EndPrepareXID=aabb
4CommitXID=aabb
5BeginPreparenull
6PutKey=c, Value=d
7EndPrepareXID=aabb

上表中,XID为aabb的事务在序号3的记录中完成prepare,在序号4完成commit,在序号7又再次完成prepare,X-Engine的2PC机制允许这种情况发生。在Crash Recovery过程中,上述WAL正确的回放顺序是序号3对应的aabb事务被成功提交,<a, b>被插入到MemTable中,而序号7标记的事务由于没有commit日志需要等待SQL层裁决是否提交还是回滚。

但是在并行回放时,所有回放任务可能同时被多个线程并行执行,无法准确按照3、4、7的序号完成回放,那么可能出现如下乱序执行问题:

  1. 序号7的事务parepare日志先被回放,在内存中构造出事务上下文:Put(c, d)
  2. 序号4的的commit日志随后被回放,此时在内存中根据XID=aabb获取到的事务上下文是Put(c, d),<c, d>被插入到MemTable中
  3. 序号3标记的事务prepare日志在X-Engine宕机恢复完成后作为未决事务等待SQL层裁决
  4. X-Engine回放出现数据正确性问题

解决方案

常规的解决思路是根据XID进行hash,将相同XID事务的回放任务按序投递给同一个回放线程执行,但由于不同事务包含的更新数据量差异悬殊,不同回放线程容易产生负载不均的问题。X-Engine的并行WAL回放采用的解决方案如下:

  • 同一个事务的prepare、commit乱序执行问题解决方案:如果某个事务的commit日志被先行回放,此时由于prepare日志暂未回放,事务上下文尚未构造,无法实际提交对应事务,那么在内存中标记对应XID事务已经被commit;一个事务的prepare日志被回放时,需要检查内存中的事务状态,如果发现事务已经被标记为commit,那么直接提交当前事务,根据构造出的事务上下文更新MemTable
  • 同名事务的prepare、commit乱序执行问题解决方案:同名事务问题的本质实际上是WAL并行回放过程中无法准确根据XID识别出不同事务的prepare、commit日志记录。考虑到LSM-Tree的所有日志记录全局有序且唯一递增,可以利用XID+PrepareLogSequence来唯一标记一个X-Engine 2PC事务。X-Engine宕机恢复主线程在读取WAL Record后,需要解析出对应的XID和XID对应的Prepare日志全局序,回放线程根据XID+PrepareLogSequence来唯一标记一个事务的回放状态,以此规避同名事务问题。

防御OOM

在数据库实例重启前后内存配置不变的情况下,宕机恢复后OOM的情况不太可能发生,但为了防止异常情况的发生,依然需要在并行WAL回放过程中考虑规避OOM问题。

解决方案

在SwitchMemtable章节我们简述了X-Engine并行WAL回放过程中触发SwitchMemtable的策略,为了降低SwitchMemtable过程造成的回放暂停的频率,需要积累一批SwitchMemtable任务并在必要的情况下一次性完成所有switch。Recovery主线程在每次向线程池任务队列提交回放任务之前,都会判断当前全局内存用量是否已经达到指定阈值,如果是,那么强制暂停回放线程池,触发SwitchMemtable,然后对SwitchMemtable过程产生的ImmutableMemTable进行Flush落盘操作,并释放这部分内存使用,以此来避免MemTable占用内存过多导致的OOM。

性能测试

测试环境

CPU配置:Intel(R) Xeon(R) CPU E5-2682 v4 @ 2.50GHz 16核64线程

内存配置:512G

数据库:MySQL 8.0.18

WAL回放线程数对回放性能的影响分析

使用dbbench工具灌入约7900万行X-Engine数据,产生25G X-Engine WAL文件(无2PC相关日志);X-Engine内存阈值100G(实际使用约25G),数据写入和重启过程中都没有触发SwitchMemtable或Flush落盘;该测试的目的是比对极致场景下WAL回放线程数对回放性能的影响。

测试结果如下: switch-memtable-problem

并行回放时间在150s以内,对比同步IO串行回放608s有4-5倍左右的性能提升、对比异步IO串行回放419s有2.5倍性能提升

CPU 64线程情况下,8-32并行回放线程可以取得比较好的性能

进一步分析:

4线程的性能瓶颈主要在多线程回放阶段

64线程的性能瓶颈主要在回放主线程读WAL Record及解析WAL Record阶段

典型场景WAL回放性能对比

使用TPCC工具灌入一定量数据(含2PC相关日志),在多种场景、多种数据库实例规格下比对同步IO串行回放、异步IO串行回放、异步IO并行回放三种回放方式的回放性能,测试结果如下: switch-memtable-problem

上述测试中场景一在灌数据过程中暂停Flush落盘,所有WAL Record都是有效日志,需要在回放过程中插入到内存表中,这将导致在宕机恢复过程中会触发多次内存表的SwitchMemtable和Flush落盘逻辑;

场景二指在灌数据过程中允许正常Flush落盘,测试仅部分WAL日志有效的典型场景下并行WAL回放和串行回放的性能对比,回放过程中可能触发SwitchMemtable和少量Flush落盘逻辑。

从上述实验结果可得,在大部分场景下,X-Engine的异步IO串行回放相比原始的同步IO串行回放有超过30%的性能提升,而并行WAL回放相比原始的串行回放有2-5倍的性能提升。

但在WAL文件积攒过多而绝大部分是无效日志的情况下,如上图中的64C200G规格场景一的测试中,并行回放性能表现反而不如异步IO串行回放,进一步分析得出导致性能退化的原因是绝大部分wal日志解析后无需插入内存表,因此并行回放的优势无法体现,同时由于并行回放在回放任务构造阶段需要进行更多的字符串解析、内存分配、字符串拷贝,向无锁队列提交task等步骤,逻辑比较复杂,因此耗时反而更高,后续将针对这种场景进行进一步优化。

测试结论

经过上述性能分析,在大部分场景下,X-Engine的并行WAL回放相比原始的串行回放性能上有2-5倍的性能提升。但在WAL文件积攒过多而绝大部分是无效日志的情况下,性能表现不如异步IO串行回放,这也是我们后续需要进一步解决的问题。

目前并行WAL回放的性能瓶颈主要在WAL文件解析和MemTable Flush落盘阶段。

后续工作

后续将针对并行WAL回放的性能瓶颈做进一步的优化迭代,如引入并行WAL文件解析机制、Crash Recovery过程中MemTable后台Flush机制等,以实现线上绝大部分场景可以在1分钟内重启数据库实例的性能目标。

同时X-Engine存储引擎的WAL并行回放也将应用在PolarDB(X-Engine)单机版和一写多读版本上,并针对PolarDB场景进行优化。

MySQL · 源码阅读 · InnoDB伙伴内存分配系统实现分析

$
0
0

1 Why?

问题一:InnoDB为什么会需要伙伴内存分配系统?

InnoDB使用的内存分为以下几块:

  1. Buffer pool

  2. Redo log buffer

  3. DD cache

  4. Adaptive hash index

  5. 每个事务用到的Lock需要的内存

  6. SQL执行过程中需要的临时内存

其中占用内存最多的是Buffer Pool和Redo Log Buffer,都有自己专门的内存管理机制,基于定长的Page Frame或Log Block对内存进行管理。与之形成对比的,其他的内存使用项目要求进行灵活的动态分配和释放,灵活性主要体现在两方面:

  1. 分配的内存长度是变长的,什么size都有可能,很难标准化为Page Frame Size或者是Log Block Size这样统一的长度去管理;
  2. 内存分配和释放的时机也很灵活,在整个执行流程中,随时要使用,使用完了随时要释放;

这样灵活的内存管理需求就需要一个类似伙伴分配系统的完整内存管理机制来负责管理。

问题二:灵活的内存使用需求完全可以用系统已有的malloc/free动态分配机制来实现,为什么InnoDB还需要自己实现伙伴分配系统?

系统提供的malloc/free动态内存管理机制对应用代码逻辑完全无感知,在释放内存时,除了很少的进程内暂留外会尽快把内存还给系统,以保证其他进程在分配内存时有足够多的内存可使用,这是作为OS的公平原则。这样做的一个显著问题是在使用动态内存总量波动比较大的场景中,会反复的出现Page Fault,影响系统的性能。所以像InnoDB这样,对内存管理有比较高控制力需求的系统,就需要结合自己的逻辑,来专门设计实现动态内存管理机制。

2 InnoDB伙伴分配系统的实现分析

下面开始分析InnoDB伙伴分配系统的具体实现,本文基于MySQL 5.6的代码来分析。

InnoDB的伙伴分配系统封装在对象mem_pool_t中,提供的主要操作是四个如下表所列。除此之外还有一些Debug和状态审计的能力。

函数作用
mem_pool_create创建一个mem_pool_t
mem_pool_free销毁一个mem_pool_t
mem_area_alloc从mem_pool_t中分配一块指定大小的内存
mem_area_free释放一块之前由mem_area_alloc分配的内存

2.1 free list的管理

伙伴分配系统把所有相同大小的空闲内存块都通过一个链表串起来,形成一个free list。每块空闲内存头部都会划出一块额外的空间(MEM_AREA_EXTRA_SIZE)作为header,用于保存三个字段:

  • 该内存块是否空闲
  • 该内存块的大小
  • 处于同一free list上的下一个内存块

如下图所示:

2.2 mem_pool_create的实现

mem_pool_create只接受一个参数:size,是在所创建的mem_pool_t中所能分配的最大内存总量。mem_pool_create时会直接向系统分配一块size大小的连续内存,之后所有的内存分配都在这块内存上展开,我们把这块内存称作pool。pool的起始地址称作base。

1个mem_pool_t内部维护了64条链表,分别是free_list[0 ~ 63]。链表free_list[n]上串起的都是长度为2的n次方的空闲内存块,可供分配。

输入的参数size可以是任意正整数,mem_pool_create会找到2的最大整数次幂N,满足2的N次方小于等于pool size。把这一大块内存首先切下来,设置好相关的header值,挂到free_list[N]上面。pool上面剩余的空间再重复上述步骤,分别挂到各个free_list上面,直到剩余的长度非常小(小于MEM_AREA_MIN_SIZE),最后的这一小段内存就会被弃置不用。

2.3 mem_area_alloc的实现

mem_area_alloc接受两个参数:在哪个mem_pool_t上进行内存分配和要分配多大的内存。

需要分配的内存大小可能是任意正整数。首先找到2的整数次方n,满足2的n次方大于等于要分配的大小加上MEM_AREA_EXTRA_SIZE。在free_list[n]上寻找空闲的内存块,如果free_list[n]不为空,则从上面摘下第一块空闲内存,如果free _list[n]为空,则需要启动空闲块的Split流程,从更大的空闲内存块中去进行切割,Split流程稍后介绍。

在找到了对应大小的空闲内存块后需要将其标记为已占用,指针跳过MEM_AREA_EXTRA_SIZE的范围后向上层用户返回。

2.4 Split操作的实现

当需要长度为2的n次方大小的内存块时,如果free_list[n]为空,说明当前2的n次方大小的空闲内存块已用完,需要把一块2的n+1次方大小的空闲内存块进行对切,来形成两块2的n次方大小的内存块供分配。典型的伙伴分配系统在进行split时总是进行对切,这也是伙伴系统的精髓,被对切形成的两块内存互为buddy关系。具体流程为:

  • 检查free_list[n+1]是否为空,如果free_list[n+1]也为空,则需要进一步进入free_list[n+2]的Split流程;如果直到free_list[63]都为空,则触发OOM
  • 当free_list[n+1]不为空时,从free_list[n+1]头部摘下第一个空闲块,将其切分为相同大小的两块,分别设置两块内存的header,更新size的大小为2的n次方,然后把这两块内存都加入free_list[n]
  • 至此就完成了从free_list[n+1]到free_list[n]的split流程,可以返回 mem_are_alloc继续完成内存分配

2.5 mem_area_free的实现(Coalescing操作的实现)

用于释放一块之前通过mem_area_alloc分配的内存,接受两个参数:要把内存释放到哪个mem_pool_t和要释放的内存指针。

mem_area_free的核心关键是Coalescing流程,也就是当两块相邻的buddy内存都为空闲状态时,需要将其合并为一块大的空闲内存,这样才能不断减少系统中的碎片内存,否则当系统需要一块较大的连续内存时将出现无内存可分配的情况。

当一块内存被释放时,首先把指针倒退MEM_AREA_EXTRA_SIZE字节,找到内存块真正的开始地址。下一步就是找到这块内存的buddy内存块,如果buddy内存块也为空闲状态,就可以进行Coalescing了。

通过分析mem_area_alloc流程不难发现,伙伴分配系统中任意的内存块都是从两倍大小的内存对切产生,所以对于任意一块给定的内存块,它的buddy块一定只会出现在两个位置:当前内存块结束的地方,或者是当前内存块往前,当前内存块大小的位置,如下图所示。

这两个位置哪个才是正确的buddy呢,要知道如果寻找的buddy块地址不正确,那当我们去查询它的header信息时,因为里面存储的是用户数据,查询的结果将会是undefined,完全无法定位元信息。这里出现伙伴分配系统最核心的Trick,从mem_pool_create的初始化过程开始,到mem_area_alloc分配流程,可以保证:

每一个内存块的Offset(内存块地址减去pool base)都是其size的整数倍。

初始化过程很好理解:切下的第一块内存Offset是0。后续每切下的一块内存之前,都有远大于自己Size的2的整数次幂长度的已切内存块在前面。如下图所示。

初始化状态满足每一个内存块的Offset(内存块地址减去pool base)都是其size的整数倍。之后因为每次Split时都是对切,一块长度为2SIZE的内存块,起始地址Offset是2SIZE * K,对切为两块小内存的起始Offset分别为2SIZE * K和2SIZE * K + SIZE,都是SIZE的整数倍。

综上所述,再次总结一下伙伴分配系统中的一个重要Invariant:每一个内存块的Offset(内存块地址减去pool base)都是其size的整数倍

有了这个重要Invariant,对于给定的内存块,找到它的Buddy块就变得容易。假设给定的内存块的长度为SIZE,那它的起始地址Offset一定是K * SIZE,当它的右侧相邻内存块是buddy块时,要求K*SIZE是2SIZE的整数倍,也就是K是偶数。当它的左侧相邻内存块是buddy块时,要求(K-1) * SIZE是2SIZE的整数倍,也就是K是奇数。上述两个条件最多只有一个成立。

当找到正确的Buddy块地址后就可以通过Header信息定位其原信息。这里还有一个比较有意思的点,当我们已经手握2的n次方大小的内存块,试图往2的n+1次方大小的内存块进行Coalescing时,2的n+1次方的内存块元信息一定是存在的。反之当我们只有较大的内存块时,内存块内部的所有字节都可能存着用户的数据,较小class内存块上的元信息完全可能不存在,它只是较大内存块的一部分。这也是伙伴分配系统的特点。

如果一对buddy内存块都是空闲时,就可以把它们进行Coalescing,也就是从free_list[n]中摘除下来,更新header信息后,再插入free_list[n+1]。完成后需要继续检查 n+1级别的内存块是否能继续Coalescing到n+2级别的内存块,由此递归进行下去,直到无法Coalescing。

2.6 mem_pool_free的实现

mem_pool_free的过程非常简单,直接调用系统接口把pool释放即可。

3 并发分配/释放的支持

最后探讨一下从mem_pool_t中并发分配内存的实现。InnoDB做的比较简单,对于每一个mem_pool_t都有一个mem_pool_t.mutex进行保护,对mem_pool_t内部结构做出修改时都通过这一把大锁进行保护。

业界更高效的做法是通过thread cache来实现支持并发分配的伙伴分配系统。核心思想是对于每一个线程都通过thread local变量维护一个线程私有的内存pool,当线程私有pool中还有空闲内存时就从线程私有pool中进行分配,否则就从全局内存pool中,在获得大锁的保护下进行分配,如下图所示。

在并行伙伴分配系统中有下面几个问题是实现的挑战和艺术:

  • 怎么尽可能的减少在Global Pool中的分配从而减少锁冲突?
  • 当线程数量特别大时,怎么控制Thread Local Pool中缓存的内存大小,或者是控制Local Pool的数量,从而避免内存过多浪费,甚至被耗尽的问题?
  • 当某个Local Pool中的内存被释放到一定量的时候,如何选择恰当的时机把它还回Global Pool,以高效的供其他Local Pool使用?

PgSQL · 新特性探索 · 浅谈postgresql分区表实现并发创建索引

$
0
0

背景

在数据库中索引可谓是司空见惯,优化查询的利器,其常用程度和表已无差别且更有甚之。索引建立的最合适时间是在定义表之后在表中插入数据之前,当一个表中已经包含了大量的数据再去建立索引意味着需要对全表做一次扫描,这将是一个很耗时的过程。 在Postgres中,在一个表上创建索引时会在表上加一个ShareLock锁,这个锁会阻塞DML(INSERT, UPDATE, DELETE)语句,对于一个具有大数据量的表来说, 建立一个索引通常需要很长时间,如果在这段时间内不允许做DML,也就是说再执行CREATE INDEX语句时所有的insert/update/delete语句都会等待,这让用户是难以忍受的。所以在CREATE INDEX CONCURRENTLY(CIC) 功能解决了这个问题,在创建索引时将会加一个ShareUpdateExclusiveLock, 这样将会不阻塞DML(INSERT, UPDATE, DELETE)。然而这样一个重要的功能在具有巨大数据量上的分区表上却是不支持的。 pg文档中给出的解决方案是先在每一个分区上使用CIC,最后再在分区表上创建索引,这种方案在一个具有大量分区的分区表中显然不是友好的。

Postgres中CIC的实现

名词定义

CIC create index concurrently 并发创建索引

  • hot 断链 (Broken HOT Chain) 一个HOT链,其中索引的键值已更改。 这通常不允许发生,但是如果创建了新索引,则可能发生。 在那种情况下,可以使用各种策略来确保没有任何可见较早的元组的事务可以使用索引。
  • 冷更新 (Cold update) 正常的非HOT更新,其中为元组的新版本创建索引条目。
  • HOT 安全 (HOT-safe ) 如果目的元组更新不更改元组的索引列,则被称为HOT安全的。
  • HOT更新 (HOT update) 一个UPDATE,其中新的元组成为仅堆元组,并且不创建新的索引条目。

HOT介绍

在PostgreSQL中为了消除了冗余索引条目,并允许在不执行table-wide vacuum的情况下重复使用DELETED或废弃的UPDATED元组占用的空间,提出了The Heap Only Tuple (HOT) 功能。众所周知的,postgresql为了实现读不阻塞写,写不阻塞读使用多版本的方案,即在update和delete元组时并不是直接的删除一行,而是通过xid和标记手段来设置这个元组对以后的事务不可见。当所有的事务都不可见以后,vacuum会对其标记和清理。 如果没有HOT,即使所有索引列都相同,更新链中行的每个版本都有其自己的索引条目。 使用HOT时,放置在同一页面上且所有索引列与其父行版本相同的新元组不会获得新的索引条目。 这意味着堆页面上的整个更新链只有一个索引条目。 无索引条目的元组标记有HEAP_ONLY_TUPLE标志。 先前的行版本标记为HEAP_HOT_UPDATED,并且(始终像在更新链中一样)其t_ctid字段链接到较新的版本。

  1. 没有HOT时的update no_update_hot
  2. 实现HOT时的update update_hot

并发创建索引

而HOT链会在如下两种下发生断链:

  • 当更新的元组存储在另一页中,不存储旧元组同一页时。
  • 当索引元组的键值更新时

举个例子: example1如上图所示, 当我们在table上的a 列创建索引时,并发的update 事务将第三行的a更新了,如果我们不做处理,这样建成的索引将会是错误的,索引中没有a=4的值。这种情况在普通的create index 不需要考虑,因为它会持有sharelock阻塞所有update/insert/delete事务。而在CIC中我们必须要考虑了。 CIC为了解决这种问题,使用了三个事务与pg_index中的如下三个flag,整个过程可以分为三个阶段来介绍。

  • indislive 设置为true 后,新的事务需要考虑hot-safe
  • indisready 设置为true后,新的事务要维护索引主要指update/insert/delete事务
  • indisvalid 设置为true后,新的事务可以使用此索引进行查询
Phase1 of CIC
  1. precheck 各种语法和功能行检查
  2. 构建catalog , 主要包括 relcache,pg_class, pg_index(indislive=true, indisready=false, indisvalid= false)
  3. 加一个会话锁(ShareUpdateExclusiveLock),提交事务

自此,索引的catalog已经建立成功,新的事务将会看到表中有一个invalid索引,因此在插入数据时将会考虑HOT-safe。

Phase2 of CIC
  1. 使用ShareLock等待表上所有的dml事务结束。(这里是为了等待,Phase1中事务结束前开始的事务,这些事务无法看到invalid索引)
  2. 开始事务,获取快照,扫描表中的所有可见元组构建索引,
  3. 更新pg_index indisready=true ,提交事务

此时,索引已经建立,但是无法用于查询,因为在phase2事务后开始的事务做插入的数据没有建立索引。新的update/insert/delete事务将会维护此索引。

Phase3 of CIC
  1. 使用ShareLock等待表上所有的dml事务结束。(这里是为了等待,Phase2中事务结束前开始的事务,这些事务无法看到isready索引)
  2. 获取快照,为Phase2事务开始到现在所缺少的事务补充索引。
  3. 记下当前快照的xmin, 提交事务
  4. 获取所有早于当前快照xmin的快照的vxid,等待它们结束。(这里是为了等待所有在Phase3 事务结束前开始的读写事务,它们无法看到Phase3 事务)
  5. 更新更新pg_index indisvalid=true
  6. 更新cache,释放会话锁

至此,索引已经对所有事务可用。

分区表中实现CIC

通过上述的介绍可以了解普通表中的CIC原理,与标准索引构建相比,此方法需要更多的总工作量,并且需要花费更长的时间才能完成。 但是,由于它允许在建立索引时继续进行正常操作,因此该方法对于在生产环境中添加新索引很有用。 当然,索引创建带来的额外CPU和I / O负载可能会减慢其他操作的速度。而且,如果在扫描表时出现问题,例如死锁或唯一索引中的唯一性冲突,则CREATE INDEX命令将失败,但会留下 “invalid” 索引。 出于查询目的,该索引将被忽略,因为它可能不完整。 但是它将仍然消耗更新开销。 这些问题将会在分区表中被放大,因为分区表相当于n个普通表的集合,我们的设计方案将以此为出发点。

例如: 一个分区表table1 ,有3个分区 part1, part2, part3, 
 			其中part2 有两个分区 part21,part22
table1
├── part1
├── part2
│   ├── part21
│   ├── part22
├── part3

对于第一阶段:我们使用一个事务进行提交,这样可以保证catalog的一致性。 对于分区树的每一个分区,我们分别递归的自底向上的执行第二阶段,和第三阶段。 让前面的分区首先创建完成并可用。当出现失败后,后面的索引虽然没有创建完成,但是其catalog已经创建完成。

start transaction
	Phase1 of  table1
	Phase1 of part1
	Phase1 of part2
	...
commit transaction
start transaction
	Phase2 of part1
commit transaction
	Phase3 of part1
commit transaction
start transaction
	Phase2 of part21
commit transaction
start transaction
	Phase3 of part21
commit transaction
	...
start transaction
	Phase2 of table1
commit transaction
start transaction
	Phase3 of table1
commit transaction

MySQL · 引擎特性 · InnoDB隐式锁功能解析

$
0
0

隐式锁概述

在数据库中,通常使用锁机制来协调多个线程并发访问某一资源。MySQL的锁类型分为表锁和行锁,表示对整张表加锁,主要用在DDL场景中,也可以由用户指定,主要由server层负责管理;而行锁指的是锁定某一行或几行,或者是行与行之间的间隙,行锁由存储引擎管理,例如最常使用的InnoDB。表锁占用系统资源小,实现简单,但锁定粒度大,发生锁冲突概率高,并发度比较低。行锁占用系统资源大,锁定粒度小,发生锁冲突概率低,并发度比较高。

InnoDB将锁分为锁类型和锁模式两类。锁类型包括表锁和行锁,而行锁还细分为记录锁、间隙锁、插入意向锁、Next-Key等更细的子类型。锁模式描述的是加什么锁,例如读锁和写锁, 在源码中的定义如下(基于MySQL 8.0):

/* Basic lock modes */
enum lock_mode {
    LOCK_IS = 0, /* intention shared */
    LOCK_IX,    /* intention exclusive */
    LOCK_S,     /* shared */
    LOCK_X,     /* exclusive */
    LOCK_AUTO_INC,  /* locks the auto-inc counter of a table in an exclusive mode*/
    ...
};

当事务需要加锁的时,如果这个锁不可能发生冲突,InnoDB会跳过加锁环节,这种机制称为隐式锁。隐式锁是InnoDB实现的一种延迟加锁机制,其特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统整体性能。另外,隐式锁是针对被修改的B+ Tree记录,因此都是记录类型的锁,不可能是间隙锁或Next-Key类型。

Insert语句的加锁流程

隐式锁主要用在插入场景中。在Insert语句执行过程中,必须检查两种情况,一种是如果记录之间加有间隙锁,为了避免幻读,此时是不能插入记录的,另一中情况如果Insert的记录和已有记录存在唯一键冲突,此时也不能插入记录。除此之外,insert语句的锁都是隐式锁,但跟踪代码发现,insert时并没有调用lock_rec_add_to_queue函数进行加锁, 其实所谓隐式锁就是在Insert过程中不加锁。

只有在特殊情况下,才会将隐式锁转换为显示锁。这个转换动作并不是加隐式锁的线程自发去做的,而是其他存在行数据冲突的线程去做的。例如事务1插入记录且未提交,此时事务2尝试对该记录加锁,那么事务2必须先判断记录上保存的事务id是否活跃,如果活跃则帮助事务1建立一个锁对象,而事务2自身进入等待事务1的状态,可以参考如下例子:

1. 创建测试表
root@localhost : (none) 14:24:01> Ceate table t(a int not null, b blob, primary key(a));
Query OK, 1 row affected (0.01 sec)
    
2. 事务1插入数据
root@localhost : mytest 14:24:16> begin;
Query OK, 0 rows affected (0.00 sec)

// 创建隐式锁,不需要创建锁结构,也不需要添加到lock hash table中
root@localhost : mytest 14:24:21> insert into t values (2, repeat('b',7000)); 
Query OK, 1 row affected (0.02 sec)

root@localhost : mytest 14:35:20> select * from performance_schema.data_locks; // 此时只有表锁,没有行锁
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
| ENGINE | ENGINE_LOCK_ID                     | ENGINE_TRANSACTION_ID | THREAD_ID | EVENT_ID | OBJECT_SCHEMA | OBJECT_NAME | PARTITION_NAME | SUBPARTITION_NAME | INDEX_NAME | OBJECT_INSTANCE_BEGIN | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
| INNODB | 47865673030896:1063:47865663453848 |                  1811 |        75 |        1 | mytest        | t           | NULL           | NULL              | NULL       |        47865663453848 | TABLE     | IX        | GRANTED     | NULL      |
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
1 row in set (0.01 sec

3. 事务2插入相同的数据
root@localhost : mytest 14:29:45> begin;                                                                                                                                                                          
Query OK, 0 rows affected (0.01 sec)

root@localhost : mytest 14:29:48> insert into t values (2, repeat('b',7000)); // 主键冲突,将事务1的隐式锁转换为显示锁,事务2则创建S锁并等待

root@localhost : mytest 14:36:04> select * from performance_schema.data_locks;
+--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
| ENGINE | ENGINE_LOCK_ID                      | ENGINE_TRANSACTION_ID | THREAD_ID | EVENT_ID | OBJECT_SCHEMA | OBJECT_NAME | PARTITION_NAME | SUBPARTITION_NAME | INDEX_NAME | OBJECT_INSTANCE_BEGIN | LOCK_TYPE | LOCK_MODE     | LOCK_STATUS | LOCK_DATA |
+--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
| INNODB | 47865673032032:1063:47865663454744  |                  1816 |        77 |        1 | mytest        | t           | NULL           | NULL              | NULL       |        47865663454744 | TABLE     | IX            | GRANTED     | NULL      |
| INNODB | 47865673032032:2:4:2:47865661626392 |                  1816 |        77 |        1 | mytest        | t           | NULL           | NULL              | PRIMARY    |        47865661626392 | RECORD    | S,REC_NOT_GAP | WAITING     | 2         |
| INNODB | 47865673030896:1063:47865663453848  |                  1811 |        75 |        1 | mytest        | t           | NULL           | NULL              | NULL       |        47865663453848 | TABLE     | IX            | GRANTED     | NULL      |
| INNODB | 47865673030896:2:4:2:47865661623320 |                  1811 |        77 |        1 | mytest        | t           | NULL           | NULL              | PRIMARY    |        47865661623320 | RECORD    | X,REC_NOT_GAP | GRANTED     | 2         |
+--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
4 rows in set (0.01 sec)
              
root@localhost : mytest 14:36:54> select * from performance_schema.data_lock_waits;
+--------+-------------------------------------+----------------------------------+----------------------+---------------------+----------------------------------+-------------------------------------+--------------------------------+--------------------+-------------------+--------------------------------+
| ENGINE | REQUESTING_ENGINE_LOCK_ID           | REQUESTING_ENGINE_TRANSACTION_ID | REQUESTING_THREAD_ID | REQUESTING_EVENT_ID | REQUESTING_OBJECT_INSTANCE_BEGIN | BLOCKING_ENGINE_LOCK_ID             | BLOCKING_ENGINE_TRANSACTION_ID | BLOCKING_THREAD_ID | BLOCKING_EVENT_ID | BLOCKING_OBJECT_INSTANCE_BEGIN |
+--------+-------------------------------------+----------------------------------+----------------------+---------------------+----------------------------------+-------------------------------------+--------------------------------+--------------------+-------------------+--------------------------------+
| INNODB | 47865673032032:2:4:2:47865661626392 |                             1816 |                   77 |                   1 |                   47865661626392 | 47865673030896:2:4:2:47865661623320 |                           1811 |                 77 |                 1 |                 47865661623320 |
+--------+-------------------------------------+----------------------------------+----------------------+---------------------+----------------------------------+-------------------------------------+--------------------------------+--------------------+-------------------+--------------------------------+
1 row in set (0.00 sec)

如何判断隐式锁是否存在

InnoDB的每条记录中都一个隐含的trx_id字段,这个字段存在于聚集索引的B+Tree中。假设只有主键索引,则在进行插入时,行数据的trx_id被设置为当前事务id;假设存在二级索引,则在对二级索引进行插入时,需要更新所在page的max_trx_id。

因此对于主键,只需要通过查看记录隐藏列trx_id是否是活跃事务就可以判断隐式锁是否存在。 对于对于二级索引会相对比较麻烦,先通过二级索引页上的max_trx_id进行过滤,如果无法判断是否活跃则需要通过应用undo日志回溯老版本数据,才能进行准确的判断。

隐式锁转换

将记录上的隐式锁转换为显示锁是由函数lock_rec_convert_impl_to_expl完成的,代码如下:

static void lock_rec_convert_impl_to_expl(const buf_block_t *block,
                                          const rec_t *rec, dict_index_t *index,
                                          const ulint *offsets) {
  trx_t *trx;

  ut_ad(!LockMutexOwner::own(LOCK_REC_SHARD, block->page.id));
  ut_ad(page_rec_is_user_rec(rec));
  ut_ad(rec_offs_validate(rec, index, offsets));
  ut_ad(!page_rec_is_comp(rec) == !rec_offs_comp(offsets));

  if (index->is_clustered()) {
    trx_id_t trx_id;
    // 对于主键,获取记录上的DB_TRX_ID系统隐藏列,获取事务ID
    trx_id = lock_clust_rec_some_has_impl(rec, index, offsets);
    // 根据事务 ID,判断当前事务是否为活跃事务,若为活跃事务,则返回此活跃事务对象
    trx = trx_rw_is_active(trx_id, NULL, true);
  } else {
    ut_ad(!dict_index_is_online_ddl(index));
    // 对于二级索引,通过Page的MAX_TRX_ID判断事务是否活跃
    trx = lock_sec_rec_some_has_impl(rec, index, offsets);

    if (trx && !can_trx_be_ignored(trx)) {
      ut_ad(!lock_rec_other_trx_holds_expl(LOCK_S | LOCK_REC_NOT_GAP, trx, rec,
                                           block));
    }
  }

  if (trx != 0) {
    ulint heap_no = page_rec_get_heap_no(rec);

    ut_ad(trx_is_referenced(trx));

    /* If the transaction is still active and has no
    explicit x-lock set on the record, set one for it.
    trx cannot be committed until the ref count is zero. */
    
    // 如果是活跃事务,则将隐式锁转换为显示锁
    lock_rec_convert_impl_to_expl_for_trx(block, rec, index, offsets, trx,
                                          heap_no);
  }
}

主键的隐式锁转换

对于主键,通过lock_clust_rec_some_has_impl函数读取记录上的事务ID,然后再判断该事务是否活跃,判断事务是否提交由函数trx_rw_is_active完成,代码如下:

UNIV_INLINE
trx_t *trx_rw_is_active(trx_id_t trx_id,   /*!< in: trx id of the transaction */
                        ibool *corrupt,    /*!< in: NULL or pointer to a flag
                                           that will be set if corrupt */
                        bool do_ref_count) /*!< in: if true then increment the
                                           trx_t::n_ref_count */
{
  trx_t *trx;

  /* Fast checking. If it's smaller than minimal active trx id, just
  return NULL. */
  if (trx_sys->min_active_id.load() > trx_id) {
    return (NULL);
  }

  trx_sys_mutex_enter();

  trx = trx_rw_is_active_low(trx_id, corrupt);

  if (trx != 0) {
    trx = trx_reference(trx, do_ref_count);
  }

  trx_sys_mutex_exit();

  return (trx);
}

MySQL早期版本在判断事务活跃并且转换隐式锁的全过程都要持有lock_sys mutex全局锁,目的是防止在此期间事务提交或回滚,但在读写事务并发很高的情况下,这种开销是非常大的。MySQL在5.7版本引入了隐式锁转换的优化:http://dev.mysql.com/worklog/task/?id=6899,通过在事务对象上增加引用计数,可以在不全程持有lock_sys mutex全局锁的情况下,保证进行隐式锁转换的事务不会提交或回滚。lock_rec_convert_impl_to_expl_for_trx负责将隐式锁转化为显示锁,创建显示锁结构并且加入到lock hash table中。锁模式为LOCK_REC | LOCK_X | LOCK_REC_NOT_GAP,由于隐式锁针对的是被修改的B+树记录,因此不是Gap或Next-Key类型,都是Record类型的锁。

二级索引的隐式锁转换

由于二级索引的记录不包含事务ID,如何判断二级索引记录上是否有隐式锁呢?前面提到二级索引页的PAGE_MAX_TRX_ID字段保存了一个最大事务ID,当二级索引页中的任何记录更新后,都会更新PAGE_MAX_TRX_ID的值。因此,我们先可以通过PAGE_MAX_TRX_ID进行判断,如果当前PAGE_MAX_TRX_ID的值小于当前活跃事务的最新ID,说明修改这条记录的事务已经提交,则不存在隐式锁,反之则可能存在隐式锁,需要通过聚集索引进行判断,其判断过程由函数row_vers_impl_x_locked_low完成,关键代码如下:

trx_t *row_vers_impl_x_locked_low(
    const rec_t *clust_rec,    /*!< in: clustered index record */
    dict_index_t *clust_index, /*!< in: the clustered index */
    const rec_t *rec,          /*!< in: secondary index record */
    dict_index_t *index,       /*!< in: the secondary index */
    const ulint *offsets,      /*!< in: rec_get_offsets(rec, index) */
    mtr_t *mtr)                /*!< in/out: mini-transaction */
{
  trx_id_t trx_id;
  ibool corrupt;
  ulint comp;
  ulint rec_del;
  const rec_t *version;
  rec_t *prev_version = NULL;
  ulint *clust_offsets;
  mem_heap_t *heap;
  dtuple_t *ientry = NULL;
  mem_heap_t *v_heap = NULL;
  const dtuple_t *cur_vrow = NULL;

  DBUG_ENTER("row_vers_impl_x_locked_low");

  ut_ad(rec_offs_validate(rec, index, offsets));

  heap = mem_heap_create(1024);

  clust_offsets =
      rec_get_offsets(clust_rec, clust_index, NULL, ULINT_UNDEFINED, &heap);
  
  // 获取保存在聚集索引记录上的事务ID
  trx_id = row_get_rec_trx_id(clust_rec, clust_index, clust_offsets);
  corrupt = FALSE;
  
  // 判断事务是否活跃
  trx_t *trx = trx_rw_is_active(trx_id, &corrupt, true);
  
  // 事务已提交,返回0
  if (trx == 0) {
    DBUG_RETURN(0);
  }

  comp = page_rec_is_comp(rec);
  
  // 获取deleted_flag
  rec_del = rec_get_deleted_flag(rec, comp);

  for (version = clust_rec;; version = prev_version) {
    // 通过undo日志获取老版本记录
    trx_undo_prev_version_build(clust_rec, mtr, version, clust_index,
                                clust_offsets, heap, &prev_version, NULL,
                                dict_index_has_virtual(index) ? &vrow : NULL, 0,
                                nullptr);
    
    // 没有之前老版本的记录,即是当前事务插入的记录,则二级索引记录rec含有implicit lock
    if (prev_version == NULL) {
      if (rec_del) {
        trx_release_reference(trx);
        trx = 0;
      }

      break;
    }
    
    // 获取获取lao'ban'b的各个字段的偏移量
    clust_offsets = rec_get_offsets(prev_version, clust_index, NULL,
                                    ULINT_UNDEFINED, &heap);
    
    // 获取老版本记录的deleted_flag
    vers_del = rec_get_deleted_flag(prev_version, comp);
    
    // 获取老版本记录的事务ID
    prev_trx_id = row_get_rec_trx_id(prev_version, clust_index, clust_offsets);
    
    // 构造老版本tuple
    row = row_build(ROW_COPY_POINTERS, clust_index, prev_version, clust_offsets,
                    NULL, NULL, NULL, &ext, heap);
    
    // 构造老版本二级索引tuple
    entry = row_build_index_entry(row, ext, index, heap);

    // 两个版本的二级索引记录相等
    if (0 == cmp_dtuple_rec(entry, rec, index, offsets)) {
      // 两个记录的deleted_flag位不同,则表示某活跃事务删除了记录,因此二级索引记录含有隐式锁
      if (rec_del != vers_del) {
        break;
      }
      
      dtuple_set_types_binary(entry, dtuple_get_n_fields(entry));

      if (0 != cmp_dtuple_rec(entry, rec, index, offsets)) {
        break;
      }

    } else if (!rec_del) {
      // 两个版本的二级索引不相同,且记录rec的deleted_flag为0, 表示某活跃事务
      // 更新了二级索引记录,因此二级索引记录含有隐式锁
      break;
    }

  result_check:
    // 如果两个版本的二级索引记录相等,并且两个记录的deleted_flag位是相同的, 
    // 或者两个版本的二级索引不相同,且记录rec的deleted_flag为1,此时判断trx->id
    // 和prev_trx_id,如果不相等则表示之前的事务已经修改了记录,因此记录上不含有隐式锁。
    // 否则,需要通过再之前的记录版本进行判断。
    if (trx->id != prev_trx_id) {
      /* prev_version was the first version modified by
      the trx_id transaction: no implicit x-lock */

      trx_release_reference(trx);
      trx = 0;
      break;
    }
  }

  DBUG_PRINT("info", ("Implicit lock is held by trx:" TRX_ID_FMT, trx_id));

  if (v_heap != NULL) {
    mem_heap_free(v_heap);
  }

  mem_heap_free(heap);
  DBUG_RETURN(trx);
}

二级索引在判断出隐式锁存在后,也是调用lock_rec_convert_impl_to_expl_for_trx函数将隐式锁转化为显示锁,并将其加入到lock hash table中。

判重过程

基于隐式锁,如何保证插入数据时主键或唯一二级索引的unique特性呢 ? 对于主键,插入时判重主要调用流程如下:

|-row_ins_step 插入记录
    |-memset(node->trx_id_buf, 0, DATA_TRX_ID_LEN);
    |-trx_write_trx_id(node->trx_id_buf, trx->id)
    |-lock_table 给表加IX锁
    |-row_ins 插入记录
      |-if (node->state == INS_NODE_ALLOC_ROW_ID)
        |-row_ins_alloc_row_id_step
          |-if (dict_index_is_unique())
            |-return
          |-dict_sys_get_new_row_id 分配一个rowid
            |-mutex_enter(&dict_sys-|-mutex);
            |-if (0 == (id % DICT_HDR_ROW_ID_WRITE_MARGIN))
              |-dict_hdr_flush_row_id()
            |-dict_sys-|-row_id++
            |-PolicyMutex::exit()
          |-dict_sys_write_row_id
        |-node->state = INS_NODE_INSERT_ENTRIES;
      |-while (node->index != NULL)
        |-row_ins_index_entry_step 向索引中插入记录,把 innobase format field 的值赋给对应的index entry field
          |-n_fields = dtuple_get_n_fields(entry); // 包含系统列
          |-dtuple_check_typed 检查要插入的行的每个列的类型有效性
          |-row_ins_index_entry_set_vals 根据该索引以及原记录,将组成索引的列的值组成一个记录
            |-for (i = 0; i < n_fields + num_v; i++)
              |-field = dtuple_get_nth_field(entry, i);
              |-row_field = dtuple_get_nth_field(row, ind_field->col->ind);
              |-dfield_set_data(field, dfield_get_data(row_field), len);
                |-field->data = (void *)data;
          |-dtuple_check_typed 检查组成的记录的有效性
          |-row_ins_index_entry 插入索引项
            |-dict_index_t::is_clustered()
            |-row_ins_clust_index_entry 插入聚集索引
              |-dict_index_is_unique
              |-log_free_check
              |-row_ins_clust_index_entry_low  先尝试乐观插入,修改叶子节点 BTR_MODIFY_LEAF
                |-mtr_t::mtr_t()
                |-mtr_t::start()
                  |-初始化mtr的各个状态变量
                  |-默认模式为MTR_LOG_ALL,表示记录所有的数据变更
                  |-mtr状态设置为ACTIVE状态(MTR_STATE_ACTIVE)
                  |-为锁管理对象和日志管理对象初始化内存(mtr_buf_t),初始化对象链表
                |-btr_pcur_t::open() btr_pcur_open_low
                  |-btr_cur_search_to_nth_level 将cursor移动到索引上待插入的位置
                    |-取得根页页号
                    |-page_cursor = btr_cur_get_page_cur(cursor);
                      space = dict_index_get_space(index);
                      page_no = dict_index_get_page(index);
                    |-buf_page_get_gen 取得本层页面,首次为根页面
                      |-mtr_memo_push
                    |-page_cur_search_with_match_bytes 在本层页面进行游标定位
                |-btr_cur_get_page 取得本层页面,首次为根页面
                |-page_get_infimum_offset
                |-page_rec_get_next
                |-page_rec_is_supremum
                |-row_ins_must_modify_rec
                |-row_ins_duplicate_error_in_clust // Checks if a unique key violation error would occur at an index entry insert
                  |-row_ins_set_shared_rec_lock 对cursor 对应的已有记录加 S 锁(可能会等待)保证记录上的操作,包括:Insert/Update/Delete
                    |-lock_clust_rec_read_check_and_lock 判断 cursor 对应的记录上是否存在隐式锁(有活跃事务), 若存在,则将隐式锁转化为显示锁
                      |-lock_rec_convert_impl_to_expl 如果是活跃事务,则将隐式锁转换为显示锁
                      |-lock_rec_lock 如果上面的隐式锁转化成功,此处加S锁将会等待,直到活跃事务释放锁。
                  |-row_ins_dupl_err_with_rec // S锁加锁完成之后,再次判断最终决定是否存在unique冲突, 1. 判断insert 记录与 cursor 对应的记录取值是否相同, 
                    2.二级唯一键值锁引,可以存在多个NULL值, 3.最后判断记录的delete_bit状态,判断记录是否被删除提交
                    |-cmp_dtuple_rec_with_match
                    |-return !rec_get_deleted_flag();
                |-btr_cur_optimistic_insert // 插入记录
                |-mtr_t::commit() // 提交mtr

插入主键时如果出现了重复的行,持有重复行数据的事务并没有提交或者回滚,需要等其事务完成提交或者回滚,如果存在重复行则报错,否则继续插入。在判重过程中,对游标对应的已有记录加S锁,保证记录上的操作(包括Insert/Update/Delete) 已经提交或者回滚, 在真正进行insert操作进行时,会尝试对下一个record加X锁。

当更新修改聚簇索引记录时,将对受影响的二级索引记录加隐式锁,在插入新的二级索引记录之前执行duplicate check, 如果修改二级索引的记录是活跃的,则先将隐式锁转换成显示锁,然后对二级索引记录尝试加S锁,加锁成功后再进行duplicate check。

MySQL · Optimizer · Optimizer Hints

$
0
0

背景

优化器是关系数据库的重要模块 [1] [2],它决定 SQL 执行计划的好坏。但是,优化器的影响因素很多,由于数据变化和估计准确性等因素,它不能总是产出最优的执行计划 [3] 。选择了不同的执行计划,执行效果差异可能非常大,甚至达到数量级差异,可能对生产系统产生严重影响。虽然学术和业界长期致力于优化器的改进,但对于业务系统而言,在优化器犯错的时候,需要有一些直接有效的干预办法。

Optimizer Hints (下文简称 Hints ) 是一套干预优化器的实用机制,不同数据库厂商都有各自的实现方式。Oracle 可能是将 Hints 机制发挥到极致的数据库大厂。而即使像 PostgreSQL 这样拒绝 hints 的学院派数据库,“民间”也自发搞了个 pg_hint_plan 插件,让大家能够尽快地解决执行计划走错的问题。

Hints 的干预方式是向优化器提供现成的优化决策,从而缩小执行计划的选择范围。通常在人为干预优化器时,只需要在关键决策点提供具体决策,就可以规避错误的执行计划;当然也可以提供所有决策,这样可以产生确定的执行计划。

MySQL 新一代 Hints,是在 5.7.7 (2015-04-08 RC) 作为比 optimizer_switch更精细的优化器干预机制而引入的,直到 8.0.20 (2020-04-27 GA) 引入 Index-Level Optimizer Hints取代古董级的 Index Hints,终于完成了“统一大业”,成为 MySQL 社区唯一推荐的优化器干预机制。

使用

在使用 hints 的时候,有一个非常重要的概念,就是标定被干预对象,也就是说优化决策是如何匹配的。然后才是施以具体动作,影响优化器的行为。从这个视角来看, hints 也是一套支持“匹配-动作”的规则系统。

被干预对象分为四个层次:语句、查询块、表和索引(如下图灰色节点)。一条简单的语句可能只有一个查询块,而 UNION 和子查询都会引入新的查询块。虽然语义上查询块是可以套嵌的,但由于 MySQL 里使用统一编号,所以,在 Hint 视角其实是一视同仁的。

像下面这个语句就包含了两个查询块,即 select#1 和 select#2 ,分别对应两个 UNION 分支:

/* select#1 */ SELECT c1 FROM t1 WHERE c2 = 1 UNION ALL /* select#2 */ SELECT c1 FROM t1 WHERE c3 >= 1;

如果把第一个 UNION 分支单独拿出来,加一个索引选择的 hint (注:大写表达概念,小写表示使用),那就是这样:

SELECT /*+ INDEX(t1 idx_1) */ c1 FROM t1 WHERE c2 = 1;

如果要全表扫描,那就是这样

SELECT /*+ NO_INDEX(t1) */ c1 FROM t1 WHERE c2 = 1;

从这两个例子出发,可以简单归纳一下 Hint 的表达方式:

  1. /*+ */是新一代 Hints 的专用注释格式
  2. INDEX是决策动作,即干预索引选择,而 NO_INDEX表示反向决策,即禁止选择指定索引
  3. t1 表示被干预对象是当前查询块的 t1 表(注:这是简写,完整写法是 t1@qb,其中 qb 是 QB_NAME 起的别名)
  4. idx_1 是动作参数,即要选 idx_1 索引

Hint 的种类

MySQL Hints 目前已经支持干预的优化决策有:变形策略、表连接顺序、表连接算法、表访问路径和一些特殊决策。除此之外, 它还可以用于其他场景,例如设置系统变量等。下表是 8.0.22 支持的 Hint 列表(详见官方文档)。

干预的类型Hints
变形策略SEMIJOIN, SUBQUERY, MERGE, ICP, DERIVED_CONDITION_PUSHDOWN
表连接顺序JOIN_ORDER, JOIN_PREFIX, JOIN_SUFFIX, JOIN_FIXED_ORDER
表连接算法BNL, HASH_JOIN
表访问路径BKA, MRR, INDEX, INDEX_MERGE, SKIP_SCAN, JOIN_INDEX, GROUP_INDEX, ORDER_INDEX
特殊控制NO_RANGE_OPTIMIZATION
其他QB_NAME, SET_VAR, RESOURCE_GROUP, MAX_EXEC_TIME

因为实际应用场景中,绝大部分执行计划错误,都是表序和索引选择导致,所以,最常用的是 JOIN_ORDER 和 INDEX ,它们分别指定连接顺序和候选索引。此外, 8.0 增加了视图合并的功能(默认开启),有时候需要用 NO_MERGE 来关掉该特性,发挥物化表的一些优势,这主要发生 5.7 迁移场景中。

当然,MySQL 优化器的决策点不仅限于这个列表,而且社区也在不断加强优化器。可以预知的是,随着业务场景的强烈诉求和优化器特性的不断丰富, hint 种类会越来越多,这样才能精细地干预优化器。比如说,DERIVED_CONDITION_PUSHDOWN 就是 8.0.22 新增的。由此我们也可以看到,MySQL 优化器的发展策略基本上还是实用主义至上,侧重于增强变形能力而不是变形决策,并没有在优化器框架上进行较大的改进。不过,可能是高级特性开发受制于现有框架,最近社区也开始了新优化框架的尝试,让我们拭目以待吧。

内核实现

前面讲到,Hints 其实是一套干预机制,它匹配被干预对象,施以动作来影响优化器行为。

内核实现分为三个部分:统一的 Hint 语法支持、Hint 的内部组织形式和对优化器的影响方式。

语法支持和组织形式,也称为新一代 Hint 基础架构,新开发的 Hint 只需要按照约定在其中增加声明和校验机制。但影响方式则是因 Hint 相关的优化器行为而异的,简单的只需要查一下 hint 参数来决定是否启用一段代码分支(例如 MERGE ),而复杂的就需要修改优化器数据结构,像 JOIN_ORDER 就要根据参数来建立表依赖关系,并修改相应的运行期数据结构内容。

统一的 Hint 语法支持

语法支持分为两部分,即在客户端的专用注释类型和在服务端语法解析,都在 WL#8016 设计范围里。

客户端其实没有做语法解析,只是在 client/mysql.cc 的 add_line() 函数里,将新一代 hints 的注释转发到服务端。顺便说一句,虽然在 8.0.20 里已经支持了以系统化命名机制来引用查询块,但在客户端代码里却未做相应的处理,所以,这个还是未公开行为。

在服务端解析代码设计上,为了尽量避免修改 main parser (sql_yacc.yy) , WL#8016 选择了共用 token 空间,但独立的 Hint parser 和 lexer 的方式。只在遇到特定的 token 才切换到 Hint parser 消费掉所有新一代 hints 注释 (consume_optimizer_hints) ,产生的 hints 列表 (PT_hint_list) 则返回给 main parser 。只有 5 种子句支持 hint ,即 SELECT INSERT DELETE UPDATE REPLACE 。

相关源代码文件:

sql/lex.h                    // symbol
sql/gen_lex_token.{h,cc}     // token
sql/sql_lex_hints.{h,cc}     // hint lexer
sql/sql_hints.yy             // hint parser
sql/parse_tree_hints.{h,cc}  // PT_hint_list, PT_hint, PT_{qb,table,key}_level_hint, PT_hint_sys_var, ...
sql/sql_lex.cc               // consume_optimizer_hints()

Hint 的内部组织形式

Hints 会在 parse 后的 contextualization 阶段注册到一个称为 hints tree 的四层树状结构中。每个 PT_hint 子类都需要提供相应的 contextualize() 实现,它主要作用是检查 hint 的合法性和相互是否有冲突,然后转成 hints tree 表达形式。这些在 WL#8017 的设计范围里。

Hints tree 的节点类型是 Opt_hints ,四个层次分别是语句、查询块、表和索引,相应的子类是 Opt_hints_global, Opt_hints_qb, Opt_hints_table, Opt_hints_key 。也就是说,每个被干预对象,都是 hints tree 的一个节点。然后在优化过程中的每个决策点,优化器都会到这个 hints tree (lex->opt_hints_global) 查找匹配的 hint ,并采取相应的动作,而查找结果还会缓存在被干预对象中,例如 SELECT_LEX::opt_hints_qb 和 TABLE_LIST::opt_hints_table 。如下图所示:

下面是 Opt_hints 结构。每个节点都有一个 hints_map ,用于表示每个类型的 hint 是否指定以及开关状态。可以看到,MySQL Hints 目前最多支持 64 种。

class Opt_hints_map {
  Bitmap<64> hints;
  Bitmap<64> hints_specified;
};
class Opt_hints {
  const LEX_CSTRING *name;  // 用于匹配的名字
  Opt_hints *parent;
  Mem_root_array<Opt_hints *> child_array;
  Opt_hints_map hints_map;  // 每个 Hint 是否指定,及其开关状态
  // ...
};

而每个层级都可以有相应的额外信息,例如,语句级 hint 记录全局设定,查询块级 hint 会有相应的变形和表序决策,表级 hint 则有索引选择的决策。索引级 hint 没有额外信息,因为索引上的 hint 都是开关类型的。

class Opt_hints_global : public Opt_hints {
  PT_hint_max_execution_time *max_exec_time;
  Sys_var_hint *sys_var_hint;
};
class Opt_hints_qb : public Opt_hints {
  uint select_number;
  PT_qb_level_hint *subquery_hint, *semijoin_hint;
  Mem_root_array<PT_qb_level_hint *> join_order_hints;
  //...
};
class Opt_hints_table : public Opt_hints {
  Glob_index_key_hint index;
  Compound_key_hint index_merge;
  Compound_key_hint skip_scan;
  // ...
};

对优化器行为的影响方式

不同的 Hint 对优化器的干预方式是不同的(详见附录)。大体上,可以分为开关型、枚举型和复杂 Hint 三类。

开关型

开关型影响方式就是直接启用特定的代码路径。大部分 hint 都是开关型的。下面是开关型查找函数。查找时会考虑两级继承逻辑(称为 Applicable Scopes ,详见社区文档),不过,从上级对象继承干预方式的情况是几乎是没有的。Index merge 因为涉及多个索引,在处理上会特别一些。

hint_table_state()  // 表级 hint 状态
hint_key_state()    // 索引级 hint 状态
compound_hint_key_enabled()  // 主要用于检测 index merge 涉及的索引是否被禁掉
idx_merge_hint_state()  // 用于表访问路径是否强制为 index merge

举例来说, 视图合并的决策点是在 SELECT_LEX::merge_derived() 函数里,在这里根据该引用位置(占位表)所匹配的 MERGE hint ,来决定启用相关代码路径:

SELECT_LEX::resolve_placeholder_tables
  SELECT_LEX::merge_derived
    hint_table_state  // 是否启用视图合并

枚举型

枚举型相对于开关型,主要是支持多种状态。例如 semijoin 有两个决策点,在第一个决策点会调用 Opt_hints_qb::semijoin_enabled() 决定是否启用 semijion,在第二个决策点会查采用什么 semijoin 策略,调用 Opt_hints_qb::sj_enabled_strategies() 获得具体 semijoin 策略( FIRSTMATCH, LOOSESCAN 或 DUPSWEEDOUT ),然后设置到 NESTED_JOIN 运行时结构中。

SELECT_LEX::resolve_subquery
  SELECT_LEX::semijoin_enabled
    Opt_hints_qb::semijoin_enabled  // 是否启用 semijoin

JOIN::optimize
  JOIN::make_join_plan
    SELECT_LEX::update_semijoin_strategies
      Opt_hints_qb::sj_enabled_strategies  // 获取具体的 semijoin 策略,更新 NESTED_JOIN

复杂型

其他 Hint 处理会相对复杂一点,不过,处理逻辑都包装在对应的函数中了。

干预连接顺序的 Hint 会在决策点调用 Opt_hints_qb::apply_join_order_hints() ,根据 hint 参数设置连接表的依赖关系,即修改 JOIN_TAB::dependent 表依赖位图,增加额外的依赖关系(表的相对顺序)。基于代价优化阶段产生的表序,会遵守设定的依赖关系。

JOIN::optimize
  JOIN::make_join_plan
    Opt_hints_qb::apply_join_order_hints
      set_join_hint_deps  // 修改表依赖位图 JOIN_TAB::dependent ,增加额外的相对顺序关系
    Optimize_table_order::choose_table_order()  // 基于代价确定表序时,遵守已设定相对顺序

干预索引选择的 Hint 会在决策点调用 Opt_hints_qb::adjust_table_hints() 和 Opt_hints_table::update_index_hint_maps() 修改 TABLE 结构里的候选索引位图。在优化过程中,候选索引位图决定了哪些索引是可用的。

SELECT_LEX::setup_tables
  Opt_hints_qb::adjust_table_hints  // 查找索引干预决策
  Opt_hints_table::update_index_hint_maps  // 修改候选索引位图 TABLE::keys_in_use_for_query 等

系统价值

现状

虽然已经一统江湖,但 MySQL Hints 仍然有不少需要完善的地方。例如视图的支持还是很欠缺的,对特定场景下的名字处理有歧义,也有一些决策点并没有覆盖到。不过,这些都可以在新一代 hints 的基础框架上逐步完善。

前景

由于优化器存在理论上的不确定性,简单直接的干预方式通常是有效的,这就可以构成稳定性系统的基础。比如说,将一个业务负载的执行计划全部记录下来,而在系统环境变化时(如系统升级或刷新统计信息)只考虑这些性能已知的执行计划,这样就可以减少升级带来的执行计划变差的风险。在通常是手工干预的机制上建立自动系统,在完整性和处理效率等方面会有很多挑战,不过,在大规模部署场景下也是值得尝试的。

附录

优化器参考文献

[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] Is Query Optimization A “Solved” Problem? http://wp.sigmod.org/?p=1075#reference

MySQL Hints 设计文档

WL#3996: Add more hints (5.7)

WL#8016: Parser for optimizer hints

WL#8017: Infrastructure for Optimizer Hints

WL#9158: Join Order Hints (8.0)

WL#681: Hint to temporarily set session variable for current statement (8.0)

WL#9307: Enabling merging a derived table or view through a optimizer hint (8.0)

WL#9467: Resource Groups (8.0)

WL#9167: Index merge hints (8.0)

WL#11322: SUPPORT LOOSE INDEX RANGE SCANS FOR LOW CARDINALITY (8.0)

WL#8241: Hints for Join Buffering and Batched Key Access (5.7)

WL#8243: Index Level Hints for MySQL 5.7 (5.7)

WL#8244: Hints for subquery strategies (5.7)

WL#3527: Extend IGNORE INDEX so places where index is ignored can be specified (5.1)

WL#2241 Implement hash join (8.0.18)

WL#13538 Add index hints based on new hint infrastructure (8.0.20)

Viewing all 691 articles
Browse latest View live