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

MySQL · 引擎特性 · 8.0 Window Functions 剖析

$
0
0

什么是window function

window function 是在满足某种条件的记录集合上执行一个特殊的函数。这一句话,记录集合就是窗口,特殊的函数就是在这个窗口上执行的函数。

SELECT
function_name OVER ( window_definition )
1. FROM (...)

window_definition : 定义要计算的记录的集合。 function_name : 指定对于集合要使用的函数。 接下来我们通过一个简单的例子看一个窗口函数:

SELECT
  f.id, f.release_year, 
  f.category_id, f.rating,
  AVG(rating) OVER 
  (PARTITION BY release_year) AS year_avg
FROM films f

这个语句是使用了partition by 作为了集合的定义,即把release year 相同的放在了一起,然后对集合进行 avg() 函数运算。 可以通过这样一个图来较为清晰的看到 undefined.

窗口函数的分类

我个人的理解是窗口函数的分类可以通过两个基准分,一个是窗口大小是否固定,一个是按照函数功能分类

按照窗口大小是否固定划分可以分为:

窗口大小固定:静态窗口函数 窗口大小不固定,不同的记录对应着不同的窗口:动态(滑动)窗口函数

按照功能划分

序号函数:row_number(); rank(); dense_rank(); 分布函数:percent_rank(); cume_dist(); 前后函数:lag(expr, n); // 返回当前行的前 n 行expr 的值; lead(expr, n); // 返回当前行的后n 行expr 的值 头尾函数:first(expr); // 返回第一个 expr 的值; last_value(expr); //返回最后一个expr 的值 其他函数:nth_value(expr, n); // 返回第 n 个expr 的值; ntile(n); // 将有序数据分为n 个桶,记录等级数

窗口集合的定义

窗口函数的基本用法是

函数名 over 子句

over 是定义窗口集合的关键字; 一般有四种定义集合的方法

  1. 什么也不写; 这样意味着窗口包含满足where 所有的行,窗口是基于所有的行进行计算。
  2. partition字句: 窗口按照哪些字段进行分组。
  3. order by字句: 按照哪些字段进行排序。
  4. frame字句: frame 是当前分区的一个子集,子句用来定义子集的规则通常用来作为滑动窗口使用。

滑动窗口

滑动窗口有两种指定范围的方式,一种是基于行,一种是基于范围。

基于行

通常使用BETWEEN frame_start AND frame_end语法来表示行范围,frame_start和frame_end可以支持如下关键字,来确定不同的动态行记录, 也可以自己定义行来表示范围

CURRENT ROW 边界是当前行,一般和其他范围关键字一起使用 UNBOUNDED PRECEDING 边界是分区中的第一行 UNBOUNDED FOLLOWING 边界是分区中的最后一行 expr PRECEDING 边界是当前行减去expr的值 expr FOLLOWING 边界是当前行加上expr的值

select avg(amount) over(partition by user_no  ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) as row_num from order_info;

基于范围

有一些范围不是可以直接使用行数表示,这种情况就要用范围,比如窗口范围是一周前的订单开始,截止到当前行; INTERVAL 7 DAY PRECEDING

Hint: 有些函数不管有没有用frame 子句,它的窗口都是固定的,使用这种函数一定是静态窗口函数

cume_dist() dense_rank() lag() lead() ntile() percent_rank() rank() row_number()

mysql8.0 源码分析窗口函数的执行过程

以下内容通过源码角度分析执行过程

优化阶段:

  1. setup windows : 在优化阶段判断出select_lex->m_windows 不为空,就首先调用 Window::setup_windows; 这个方法里面的核心接口是 Window::check_window_functions(THD *thd, SELECT_LEX *select)

a. 这个方法中首先判断的是当前是静态窗口还是动态窗口; 静态窗口即判断了 frame 的定义是否有定义上下边界。m_static_aggregates为 true, 意味着是静态窗口,同时对每一个分区都可以进行一次评估。

如果 ma_static_aggregates为 false, 则进一步判断其滑动窗口使用的是基于范围还是基于行。 m_row_optimizable基于行 m_range_optimizable基于范围 b. 获取聚合函数作为窗口函数时候窗口的特殊规格要求 wfs->check_wf_semantics(thd, select, &reqs)这个方法其实就是判断是不是需要 row_buffer 作为评估,什么时候需要使用 row_buffer 呢:如果我们只看当前分区的行无法进行正确的计算,我们需要看之后的或者之前的行,就需要使用row_buffer。

bool Window::check_window_functions(THD *thd, SELECT_LEX *select) {
  List_iterator<Item_sum> li(m_functions);
  Item *wf;

  m_static_aggregates =
      (m_frame->m_from->m_border_type == WBT_UNBOUNDED_PRECEDING &&
       m_frame->m_to->m_border_type == WBT_UNBOUNDED_FOLLOWING);

  // If static aggregates, inversion isn't necessary
  m_row_optimizable = (m_frame->m_unit == WFU_ROWS) && !m_static_aggregates;
  m_range_optimizable = (m_frame->m_unit == WFU_RANGE) && !m_static_aggregates;
  . ..
  while ((wf = li++)) {
    Evaluation_requirements reqs;

    Item_sum *wfs = down_cast<Item_sum *>(wf);
    if (wfs->check_wf_semantics(thd, select, &reqs)) return true;

    m_needs_frame_buffering |= reqs.needs_buffer;
	....
}
  1. Optimize-> make_tmp_tables_info: 这里是看是否需要创建一个临时表作为 window frame buffer. 而是否创建的判断条件就是之前 Window::check_window_functions(THD *thd, SELECT_LEX *select)接口中 求得的 row_buffer 决定的,如果row_buffer 为 true 则需要创建一个 temp table.
...
      if (m_windows[wno]->needs_buffering()) {
        /*
          Create the window frame buffer tmp table.  We create a
          temporary table with same contents as the output tmp table
          in the windowing pipeline (columns defined by
          curr_all_fields), but used for intermediate storage, saving
          the window's frame buffer now that we know the window needs
          buffering.
        */
        Temp_table_param *par =
            new (thd->mem_root) Temp_table_param(tmp_table_param);
        par->m_window = nullptr;  // Only OUT table needs access to Window

        List<Item> tmplist(*curr_all_fields, thd->mem_root);
        TABLE *table =
            create_tmp_table(thd, par, tmplist, nullptr, false, false,
                             select_lex->active_options(), HA_POS_ERROR, "");
        if (table == nullptr) DBUG_RETURN(true);

        if (alloc_ref_item_slice(thd, fbidx)) DBUG_RETURN(true);

        if (change_to_use_tmp_fields(
                thd, ref_items[fbidx], tmp_fields_list[fbidx],
                tmp_all_fields[fbidx], curr_fields_list->elements,
                *curr_all_fields))
          DBUG_RETURN(true);

        m_windows[wno]->set_frame_buffer_param(par);
        m_windows[wno]->set_frame_buffer(table);
      }
...

执行阶段

执行调用栈

unit->first_select()->join->exec()->evaluate_join_record()->sub_select_op()->QEP_tmp_table::put_record()->end_write_wf()

重点在 end_write_wf() 这个接口上面 undefined.

整个window function 的计算是要有两个或者三个temp table 参与的 分别为: Input table: 对应于 qep_tab-1这个表中是准备进行计算的window 窗口记录 output table: 对用于 qep_tab这个表是用来写入窗口函数计算结果的 buffer_tmp_table: 如果之前setup 时候判断出需要使用 row_buffer, 那么在优化阶段 make_tmp_tables_info也会创建一个临时表。

下面简述整个计算过程入下图所示: undefined

在这个过程中比较难理解的是第三个临时表作为 frame buffer 的使用和 process_buffered_windowing_record从代码中注释给出的一个例子简述。

 SUM(A+FLOOR(B)) OVER (ROWS 2 FOLLOWING)

首先要做的事情是先把和 window frame 无关的函数计算都做完,然后把结果放入到 frame buffer 中,在 frame buffer 中判断 是否满足window frame 的集合 row 都已经计算了,(这个就是 process_buffered_windowing_record做的事情)如果当前的结果不满足 window frame 的定义,我们把结果拿出来,再继续计算。整个过程是一个循环处理,直到最后 窗口函数确实计算完毕了,把结果也放回 frame buffer 中。进而继续计算一些非window function 算子。

  • Tips :process_buffered_windowing_record这个方法中涉及了两种 move 滑动窗口的策略 分别是 native strategy 和 optimizable strategy; 而具体选择哪一种策略,正是在优化阶段 对 m_row_optimizable 和 m_row_optimizable 的赋值。 if (m_row_optimizable || m_row_optimizable)== true就选择使用optimizable strategy. 两种策略的滑动方式如代码注释所述。
    Moving (sliding) frames can be executed using a naive or optimized strategy
    for aggregate window functions, like SUM or AVG (but not MAX, or MIN).
    In the naive approach, for each row considered for processing from the buffer,
    we visit all the rows defined in the frame for that row, essentially leading
    to N*M complexity, where N is the number of rows in the result set, and M is
    the number for rows in the frame. This can be slow for large frames,
    obviously, so we can choose an optimized evaluation strategy using inversion.
    This means that when rows leave the frame as we move it forward, we re-use
    the previous aggregate state, but compute the *inverse* function to eliminate
    the contribution to the aggregate by the row(s) leaving the frame, and then
    use the normal aggregate function to add the contribution of the rows moving
    into the frame
    

MySQL · 引擎特性 · Performance_schema 内存分配

$
0
0

概述

Performance Schema(pfs)是对MySQL的细力度的性能监控诊断工具,覆盖statement/io/memory/lock 等各个性能相关的模块。Pfs采集到的性能数据使用 performance_Schema 引擎存储,全部保存在内存。 本文关注 pfs 的内存管理。首先从代码中分析 pfs 内存管理机制,然后以一个监控项为例介绍 pfs 的流程,最后介绍下 pfs 内存相关的参数。本文代码基于 MySQL 8.0.18版本。

Pfs内存管理

核心数据结构

PFS_buffer_scalable_container PFS_buffer_scalable_container 用于内存管理(申请,扩容,释放),内部结构如下图。 其中,global*container (以下称为 container )为全局单例变量,下面是其示意图以及结构定义代码。Container 存储上分两层: page 和 record。 以 global_thread_container 为例,默认global_thread_container中包含 多个 PFS_thread_array(page), page 内部包含多个 PFS_thread(record)。 PFS_buffer_scalable_container PFS_buffer_scalable_container 代码

template <class T, int PFS_PAGE_SIZE, int PFS_PAGE_COUNT,
          class U = PFS_buffer_default_array<T>,
          class V = PFS_buffer_default_allocator<T>>
class PFS_buffer_scalable_container {
  typedef T value_type;			// record 类型
  typedef U array_type;			// page 类型
  typedef V allocator_type;		// page 分配器,需实现 alloc_array/free_array
  value_type *allocate(pfs_dirty_state *dirty_state);		// 分配记录
  void deallocate(value_type *pfs) { m_array.deallocate(pfs); }  // 释放记录
  array_type m_array; 								// 内存起始位置
  size_t m_max; 									// PFS_PAGE_SIZE* PFS_PAGE_COUNT
  allocator_type *m_allocator;                      // 分配器
 }

class PFS_thread_allocator {
 public:
  int alloc_array(PFS_thread_array *array);
  void free_array(PFS_thread_array *array);
};

实例化后的 container 对象复制管理 pfs 各个模块的内存分配,其与系统表对应关系如下:

global_account_containerevents_%_summary_by_account_by_event_name
global_host_containerevents_%_summary_by_host_by_event_name
global_thread_containerevents_%_summary_by_thread_by_event_name
global_user_containerevents_%_summary_by_user_by_event_name
global_mutex_containermutex_instances
global_rwlock_containerrwlock_instances
global_cond_containercond_instances
global_socket_containersocket_instances
global_mdl_containermetadata_locks

Pfs内存管理模型

1) 系统启动的时候预先分配内存,系统运行期间根据需要重新分配内存

Pfs 的内存分配发生在 page 分配(即alloc_array函数),启动时初始化会分配部分page ,系统运行期间若 page 用满会分配新的 page。 在 page 内部分配 record 时,使用原子操作避免加锁。 下面是  global_thread_container  运行期间分配thread 的伪代码。

PFS_thread *pfs = global_thread_container.allocate(&dirty_state)
{
    if (m_full) { m_lost++; return NULL; } // 如果container 满了直接返回
    while (monotonic < monotonic_max){
        array= m_pages[index]
            pfs = array->allocate(dirty_state);     // 从现有 page 中分配
            pfs->m_page= reinterpret_cast<PFS_opaque_container_page *> (array);
        return pfs;
    }
    array = new array_type();  						// 分配新 page
    int rc= m_allocator->alloc_array(array); // 内部调用PFS_MALLOC_ARRAY分配内存
}

2) Record 采用定长方式存储,每次申请固定数量长度的内存,并初始化填0

真正的内存分配由m_allocator->alloc_array进行,我们以PFS_thread_allocator::alloc_array为例展开代码,PFS_thread中保存了线程粒度下的 statement/wait/error 等数据。 每个PFS_thread对象申请的内存为固定的,以statement为例,MySQL 支持的 statement 类型为220个,每个PFS_thread内会为220个类型提前分配位置并初始化为0,这也是 pfs 内存消耗的重要原因。

int PFS_thread_allocator::alloc_array(PFS_thread_array *array) {
  size_t size = array->m_max; 		 // 单个 page 内保存的记录(即 PFS_thread)数

  size_t index;
  size_t waits_sizing = size * wait_class_max;   // wait_class_max 为等待事件的种类
  size_t statements_sizing = size * statement_class_max; // statement_class_max 语句类型个数
  size_t transactions_sizing = size * transaction_class_max; // 事务类型个数
  size_t errors_sizing = (max_server_errors != 0) ? size * error_class_max : 0; // error 类型个数
  ...
  array->m_ptr =
        PFS_MALLOC_ARRAY(&builtin_memory_thread, size, sizeof(PFS_thread),
                         PFS_thread, MYF(MY_ZEROFILL));
  array->m_instr_class_waits_array = PFS_MALLOC_ARRAY(
        &builtin_memory_thread_waits, waits_sizing, sizeof(PFS_single_stat),
        PFS_single_stat, MYF(MY_ZEROFILL));
  array->m_instr_class_statements_array = PFS_MALLOC_ARRAY(
        &builtin_memory_thread_statements, statements_sizing,
        sizeof(PFS_statement_stat), PFS_statement_stat, MYF(MY_ZEROFILL));
  array->m_instr_class_errors_array = PFS_MALLOC_ARRAY(
        &builtin_memory_host_errors, errors_sizing, sizeof(PFS_error_stat),
        PFS_error_stat, MYF(MY_ZEROFILL));    
  ...    
}

3) 系统运行期间不释放内存,只在shutdown时 释放内存

下面是thread_container 释放thread 的代码逻辑

global_thread_container.deallocate(pfs);
{	// 只是标记回收,并不会实际释放空间
    safe_pfs->m_lock.allocated_to_free(); 
    page->m_full = false;
    m_full = false;
}

4) 数据在不同粒度的维度汇总

Pfs 数据库下可以看到对同一个监控指标有很多个不同的表,每个表代表一个统计的维度。 

mysql> show tables like '%statement%summary%';
+----------------------------------------------------+
| Tables_in_performance_schema (%statement%summary%) |
+----------------------------------------------------+
| events_statements_summary_by_account_by_event_name |
| events_statements_summary_by_digest                |
| events_statements_summary_by_digest_supplement     |
| events_statements_summary_by_host_by_event_name    |
| events_statements_summary_by_program               |
| events_statements_summary_by_thread_by_event_name  |
| events_statements_summary_by_user_by_event_name    |
| events_statements_summary_global_by_event_name     |
+----------------------------------------------------+

在内部,不同的统计维度被称为集合(aggregates),对同一条数据在内部只会保存一份,运行期间会进行从细维度到高纬度的汇总。 pfs.cc代码注释中用这种图表的方式进行了说明,下面 以statement 为例介绍下汇总的过程,读者可以自己理解下。

  statement_locker(T, S)
   |
   | [1]
   |
1a |-> pfs_thread(T).event_name(S)            =====>> [A], [B], [C], [D], [E]
   |    |
   |    | [2]
   |    |
   | 2a |-> pfs_account(U, H).event_name(S)   =====>> [B], [C], [D], [E]
   |    .    |
   |    .    | [3-RESET]
   |    .    |
   | 2b .....+-> pfs_user(U).event_name(S)    =====>> [C]
   |    .    |
   | 2c .....+-> pfs_host(H).event_name(S)    =====>> [D], [E]
   |    .    .    |
   |    .    .    | [4-RESET]
   | 2d .    .    |
1b |----+----+----+-> pfs_statement_class(S)  =====>> [E]
   |
1c |-> pfs_thread(T).statement_current(S)     =====>> [F]
   |
1d |-> pfs_thread(T).statement_history(S)     =====>> [G]
   |
1e |-> statement_history_long(S)              =====>> [H]
   |
1f |-> statement_digest(S)                    =====>> [I]

@endverbatim

  Implemented as:
  - [1] #pfs_start_statement_v2(), #pfs_end_statement_v2()
       (1a, 1b) is an aggregation by EVENT_NAME,
        (1c, 1d, 1e) is an aggregation by TIME,
        (1f) is an aggregation by DIGEST
        all of these are orthogonal,
        and implemented in #pfs_end_statement_v2().
  - [2] #pfs_delete_thread_v1(), #aggregate_thread_statements()
  - [3] @c PFS_account::aggregate_statements()
  - [4] @c PFS_host::aggregate_statements()
  - [A] EVENTS_STATEMENTS_SUMMARY_BY_THREAD_BY_EVENT_NAME,
        @c table_esms_by_thread_by_event_name::make_row()
  - [B] EVENTS_STATEMENTS_SUMMARY_BY_ACCOUNT_BY_EVENT_NAME,
        @c table_esms_by_account_by_event_name::make_row()
  - [C] EVENTS_STATEMENTS_SUMMARY_BY_USER_BY_EVENT_NAME,
        @c table_esms_by_user_by_event_name::make_row()
  - [D] EVENTS_STATEMENTS_SUMMARY_BY_HOST_BY_EVENT_NAME,
        @c table_esms_by_host_by_event_name::make_row()
  - [E] EVENTS_STATEMENTS_SUMMARY_GLOBAL_BY_EVENT_NAME,
        @c table_esms_global_by_event_name::make_row()
  - [F] EVENTS_STATEMENTS_CURRENT,
        @c table_events_statements_current::make_row()
  - [G] EVENTS_STATEMENTS_HISTORY,
        @c table_events_statements_history::make_row()
  - [H] EVENTS_STATEMENTS_HISTORY_LONG,
        @c table_events_statements_history_long::make_row()
  - [I] EVENTS_STATEMENTS_SUMMARY_BY_DIGEST
        @c table_esms_by_digest::make_row()

Pfs性能监控过程

这里以statement 的一个监控项为例来介绍 pfs 性能数据采集的整个过程。 监控数据最终记录在 events_statements_summary_by_thread_by_event_name 表中,需提前打开 setup_consumers.thread_instrumentation 开关。

线程创建

调用入口:  PSI_THREAD_CALL(new_thread)  线程启动时进行在全局container( global_thread_container )中申请内存空间,并进行一系列的监控数据初始化。 首先尝试在现有的 page 中申请空闲的record, 找不到的话申请新的page。

语句开始前

调用入口:  MYSQL_START_STATEMENT  在语句开始的位置调用进行,比如 在dispatch_command函数中,进行statement 统计的初始化,记录 sql 启动时间。

语句结束后

调用入口: MYSQL_END_STATEMENT 

pfs_end_statement_v2(PSI_statement_locker *locker, void *stmt_da)
{
    PSI_statement_locker_state *state =
      reinterpret_cast<PSI_statement_locker_state *>(locker);
    // 填充 pfs
    PFS_events_statements *pfs =
          reinterpret_cast<PFS_events_statements *>(state->m_statement);
    insert_events_statements_history(thread, pfs); // 写入到 EVENTS_STATEMENTS_HISTORY
    insert_events_statements_history_long(pfs);    // 写入到 EVENTS_STATEMENTS_HISTORY_LONG
    // 获取写入的位置
    event_name_array = thread->write_instr_class_statements_stats(); // PFS_statement_stat*
    stat = &event_name_array[index];
    // 开始填充 stat,写入汇总表
    stat->m_lock_time += state->m_lock_time; 
}

线程结束

调用入口: PSI_THREAD_CALL(delete_current_thread)

void pfs_delete_current_thread_vc(void) {
    // 将线程的数据汇总到 account 或者 host 统计中
    aggregate_thread(thread, thread->m_account, thread->m_user, thread->m_host);
	...
    // 销毁 pfs thread, global_thread_container 收回空间
    global_thread_container.deallocate(pfs);

}

Pfs内存参数设置

主要看下影响pfs内存使用的相关参数

performance_schema%max%instance

控制监控实体的个数,内部即限制对应 container 的容量。

+------------------------------------------------------+-------+
| Variable_name                                        | Value |
+------------------------------------------------------+-------+
| performance_schema_max_cond_instances                | -1    |
| performance_schema_max_file_instances                | -1    |
| performance_schema_max_mutex_instances               | -1    |
| performance_schema_max_prepared_statements_instances | -1    |
| performance_schema_max_program_instances             | -1    |
| performance_schema_max_rwlock_instances              | -1    |
| performance_schema_max_socket_instances              | -1    |
| performance_schema_max_table_instances               | -1    |
| performance_schema_max_thread_instances              | -1    |
+------------------------------------------------------+-------+
performance_schema_max_cond_instances  global_cond_container
performance_schema_max_file_instances  global_file_container
performance_schema_max_mutex_instances global_mutex_container
performance_schema_max_prepared_statements_instances global_prepared_stmt_container
performance_schema_max_program_instances global_program_container
performance_schema_max_rwlock_instances global_rwlock_container
performance_schema_max_socket_instances global_socket_container
performance_schema_max_table_instances global_table_share_container
performance_schema_max_thread_instances global_thread_container    

performance_schema_%_size

影响对应表的记录上限

ysql> show global variables like 'performance_schema_%_size';
+----------------------------------------------------------+-------+
| Variable_name                                            | Value |
+----------------------------------------------------------+-------+
| performance_schema_accounts_size                         | -1    |
| performance_schema_digests_size                          | 100   |
| performance_schema_error_size                            | 20    |
| performance_schema_events_stages_history_long_size       | 10000 |
| performance_schema_events_stages_history_size            | 10    |
| performance_schema_events_statements_history_long_size   | 10000 |
| performance_schema_events_statements_history_size        | 10    |
| performance_schema_events_transactions_history_long_size | 10000 |
| performance_schema_events_transactions_history_size      | 10    |
| performance_schema_events_waits_history_long_size        | 10000 |
| performance_schema_events_waits_history_size             | 10    |
| performance_schema_hosts_size                            | -1    |
| performance_schema_session_connect_attrs_size            | 512   |
| performance_schema_setup_actors_size                     | -1    |
| performance_schema_setup_objects_size                    | -1    |
| performance_schema_users_size                            | -1    |
+----------------------------------------------------------+-------+

其他参数:

performance_schema_error_size: 监控的系统错误码个数,如果对错误码没有监控需求,建议调低 performance_schema_digests_size: events_statements_summary_by_digest 表的最大容量

MySQL · 引擎特性 · 手动分析InnoDB B+Tree结构

$
0
0

说明

本文用查找一条数据库表记录的例子来分析InnoDB B+Tree的结构

先用sysbench插入一千万条记录:

sysbench --db-driver=mysql --mysql-user=username --mysql-password=password --mysql-db=sbtest \
	--table_size=10000000 --tables=1 oltp_read_write  --mysql-host=127.0.0.1 prepare

生成的sbtest1.ibd大小为2.3G,用hexdump来查看内容

随意选一条记录,比如id=3905000,通过手动找到这条记录来分析B+Tree的结构。

mysql> select * from sbtest1 where id=3905000 \G
*************************** 1. row ***************************
 id: 3905000
  k: 7152454
  c: 33061989913-01978996152-96897051302-66804054532-36658200903-75265952777-90162670547-62113775555-84037309450-68725639441
pad: 93314475890-72810819110-74153294523-75348581725-15287112137
1 row in set (0.00 sec)

Page format

详细请查看: https://dev.mysql.com/doc/internals/en/innodb-page-overview.html

NameSize(bytes)
File Header38
Page Header56
Infimum + Supremum Records16
User Records不定
Free Space不定
Page Directory不定
Fil Trailer8

查看B+Tree root page

page 3是root page,先用hexdump查看file header

page 3的offset是3161024 = 49152,file header size是38 bytes

$hexdump -s 49152 -n 38 -C sbtest1.ibd
0000c000  58 0f 5c bd 00 00 00 03  ff ff ff ff ff ff ff ff  |X.\.............|
0000c010  00 00 00 00 99 4a f4 5a  45 bf 00 00 00 00 00 00  |.....J.ZE.......|
0000c020  00 00 00 00 00 20                                 |..... |

page type是0x45bf,表示B+Tree节点

再看page header,offset是3161024 + 38 = 49190,page header size是56 bytes

$hexdump -s 49190 -n 56 sbtest1.ibd
0000c026  00 1d 06 4f 80 75 00 00  00 00 06 47 00 02 00 72  |...O.u.....G...r|
0000c036  00 73 00 00 00 00 00 00  00 00 00 02 00 00 00 00  |.s..............|
0000c046  00 00 00 38 00 00 00 20  00 00 00 02 00 f2 00 00  |...8... ........|
0000c056  00 20 00 00 00 02 00 32                           |. .....2|

page directory slots数目为29(0x001d)

page level为2(0x0002),这个是根节点。叶子节点level总是为0,所以这棵B+Tree深度为3。

root page directory

每个slot占2 bytes,29个slot共58 bytes。 所以root page directory offset为 3(161024) - 58 - 8(trailer) = 65470

$hexdump -s 65470 -n 58 -C sbtest1.ibd
0000ffbe  00 70 05 ec 05 b8 05 84  05 50 05 1c 04 e8 04 b4  |.p.......P......|
0000ffce  04 80 04 4c 04 18 03 e4  03 b0 03 7c 03 48 03 14  |...L.......|.H..|
0000ffde  02 e0 02 ac 02 78 02 44  02 10 01 dc 01 a8 01 74  |.....x.D.......t|
0000ffee  01 40 01 0c 00 d8 00 a4  00 63                    |.@.......c|

page directory按primary key逆序排列,0x0063是infimum record offset,0x0070是supremum record offset

$hexdump -s 49246 -n 12 -C sbtest1.ibd
0000c05e  01 00 02 00 1a 69 6e 66  69 6d 75 6d              |.....infimum|

“01 00 02 00 1a” 是record header,最后1个byte 0x1a表示next record offset

3161024+0x63+0x1a=49277,找到第1条记录

$hexdump -s 49277 -n 8 sbtest1.ibd
0000c07d  80 00 00 01 00 00 00 24                           |.......$|
slotoffsetrecord(hex)primary keypage no
00x006369 6e 66 69 6d 75 6d 00136
10x00a480 03 59 53 00 00 00 2721947539
20x00d880 08 b5 7f 00 00 00 2b57075143
30x010c80 0e 11 ab 00 00 00 2f92202747
40x014080 13 6d d7 00 00 00 33127330351
50x017480 18 ca 03 00 00 00 37162457955
60x01a880 1e 26 2f 00 00 00 3b197585559
70x01dc80 23 82 5b 00 00 00 3f232713163
80x021080 28 de 87 00 00 90 00267840736864
90x024480 2e 3a b3 00 00 90 04302968336868
100x027880 33 96 df 00 00 90 08338095936872
110x02ac80 38 f3 0b 00 00 90 0c373223536876
120x02e080 3e 4f 37 00 00 90 10408351136880
130x031480 43 ab 63 00 00 90 14443478736884
140x034880 49 07 8f 00 00 90 18478606336888
150x037c80 4e 63 bb 00 00 90 1c513733936892
160x03b080 53 bf e7 00 00 90 20548861536896
170x03e480 59 1c 13 00 00 90 24583989136900
180x041880 5e 78 3f 00 00 90 28619116736904
190x044c80 63 d4 6b 00 00 90 2c654244336908
200x048080 69 30 97 00 00 90 30689371936912
210x04b480 6e 8c c3 00 00 90 34724499536916
220x04e880 73 e8 ef 00 00 90 38759627136920
230x051c80 79 45 1b 00 00 90 3c794754736924
240x055080 7e a1 47 00 01 be 008298823114176
250x058480 83 fd 73 00 01 be 048650099114180
260x05b880 89 59 9f 00 01 be 089001375114184
270x05ec80 8e b5 cb 00 01 be 0c9352651114188
280x007073 75 70 72 65 6d 75 6d  

我们要找的记录primary key为3905000,在以上29个slots中做binary search,发现可能在slot 11和slot 12之间 slot 11的primary key为3732235,并不是我们要找的3905000,需要继续在slot 11的record list中查找。

查看page 0 slot 11的record list

record header共5个bytes,第5个byte记录了next record的相对于current record的offset, 即next record offset = current record offset + current record header的第5个byte 非叶子节点record body为8 bytes

offsetrecord(hex)primary keypage no
68480 38 f3 0b 00 00 90 0c373223536876
69780 3a 4a 16 00 00 90 0d382005436877
71080 3b a1 21 00 00 90 0e390787336878
72380 3c f8 2c 00 00 90 0f399569236879
73680 3e 4f 37 00 00 90 10408351136880
152980 90 0c d6 00 01 be 0d9440470114189
154280 91 63 e1 00 01 be 0e9528289114190
155580 92 ba ec 00 01 be 0f9616108114191
156880 94 11 f7 00 01 be 109703927114192
158180 95 69 02 00 01 be 119791746114193
159480 96 c0 0d 00 01 be 129879565114194
160780 98 17 18 00 01 be 139967384114195

到此发现我们要找的记录(primary key 3905000)可能在offset 697所指向的page 36877

查看page 36877

先看file header, offset = 36877 * (16*1024) = 604192768, size为38 bytes

$hexdump -s 604192768 -n 38 -C sbtest1.ibd
24034000  d1 d6 61 2a 00 00 90 0d  00 00 90 0c 00 00 90 0e  |..a*............|
24034010  00 00 00 00 3b ff 8b 03  45 bf 00 00 00 00 00 00  |....;...E.......|
24034020  00 00 00 00 00 20                                 |..... |

page type是0x45bf,确认了是B+Tree节点

再看page header, offset = 36877 * (16*1024) + 38 = 604192806, size为56 bytes

$hexdump -s 604192806 -n 56 -C sbtest1.ibd
24034026  01 2d 3d 8f 84 b5 00 00  00 00 3d 87 00 02 04 b2  |.-=.......=.....|
24034036  04 b3 00 00 00 00 00 00  00 00 00 01 00 00 00 00  |................|
24034046  00 00 00 38 00 00 00 00  00 00 00 00 00 00 00 00  |...8............|
24034056  00 00 00 00 00 00 00 00                           |........|

page directory slots数目为301(0x012d)

page level为1(0x0001),还不是叶子节点。

再看page diretory 每个slot占2 bytes,301个slot共602 bytes。 所以page directory offset为 36877(161024) - 602 - 8(trailer) = 604208542

$hexdump -s 604208542 -n 602 -C sbtest1.ibd
24037d9e  00 70 3d 2c 3c f8 3c c4  3c 90 3c 5c 3c 28 3b f4  |.p=,<.<.<.<\<(;.|
24037dae  3b c0 3b 8c 3b 58 3b 24  3a f0 3a bc 3a 88 3a 54  |;.;.;X;$:.:.:.:T|
24037dbe  3a 20 39 ec 39 b8 39 84  39 50 39 1c 38 e8 38 b4  |: 9.9.9.9P9.8.8.|
24037dce  38 80 38 4c 38 18 37 e4  37 b0 37 7c 37 48 37 14  |8.8L8.7.7.7|7H7.|
24037dde  36 e0 36 ac 36 78 36 44  36 10 35 dc 35 a8 35 74  |6.6.6x6D6.5.5.5t|
24037dee  35 40 35 0c 34 d8 34 a4  34 70 34 3c 34 08 33 d4  |5@5.4.4.4p4<4.3.|
24037dfe  33 a0 33 6c 33 38 33 04  32 d0 32 9c 32 68 32 34  |3.3l383.2.2.2h24|
24037e0e  32 00 31 cc 31 98 31 64  31 30 30 fc 30 c8 30 94  |2.1.1.1d100.0.0.|
24037e1e  30 60 30 2c 2f f8 2f c4  2f 90 2f 5c 2f 28 2e f4  |0`0,/./././\/(..|
24037e2e  2e c0 2e 8c 2e 58 2e 24  2d f0 2d bc 2d 88 2d 54  |.....X.$-.-.-.-T|
24037e3e  2d 20 2c ec 2c b8 2c 84  2c 50 2c 1c 2b e8 2b b4  |- ,.,.,.,P,.+.+.|
24037e4e  2b 80 2b 4c 2b 18 2a e4  2a b0 2a 7c 2a 48 2a 14  |+.+L+.*.*.*|*H*.|
24037e5e  29 e0 29 ac 29 78 29 44  29 10 28 dc 28 a8 28 74  |).).)x)D).(.(.(t|
24037e6e  28 40 28 0c 27 d8 27 a4  27 70 27 3c 27 08 26 d4  |(@(.'.'.'p'<'.&.|
24037e7e  26 a0 26 6c 26 38 26 04  25 d0 25 9c 25 68 25 34  |&.&l&8&.%.%.%h%4|
24037e8e  25 00 24 cc 24 98 24 64  24 30 23 fc 23 c8 23 94  |%.$.$.$d$0#.#.#.|
24037e9e  23 60 23 2c 22 f8 22 c4  22 90 22 5c 22 28 21 f4  |#`#,"."."."\"(!.|
24037eae  21 c0 21 8c 21 58 21 24  20 f0 20 bc 20 88 20 54  |!.!.!X!$ . . . T|
24037ebe  20 20 1f ec 1f b8 1f 84  1f 50 1f 1c 1e e8 1e b4  |  .......P......|
24037ece  1e 80 1e 4c 1e 18 1d e4  1d b0 1d 7c 1d 48 1d 14  |...L.......|.H..|
24037ede  1c e0 1c ac 1c 78 1c 44  1c 10 1b dc 1b a8 1b 74  |.....x.D.......t|
24037eee  1b 40 1b 0c 1a d8 1a a4  1a 70 1a 3c 1a 08 19 d4  |.@.......p.<....|
24037efe  19 a0 19 6c 19 38 19 04  18 d0 18 9c 18 68 18 34  |...l.8.......h.4|
24037f0e  18 00 17 cc 17 98 17 64  17 30 16 fc 16 c8 16 94  |.......d.0......|
24037f1e  16 60 16 2c 15 f8 15 c4  15 90 15 5c 15 28 14 f4  |.`.,.......\.(..|
24037f2e  14 c0 14 8c 14 58 14 24  13 f0 13 bc 13 88 13 54  |.....X.$.......T|
24037f3e  13 20 12 ec 12 b8 12 84  12 50 12 1c 11 e8 11 b4  |. .......P......|
24037f4e  11 80 11 4c 11 18 10 e4  10 b0 10 7c 10 48 10 14  |...L.......|.H..|
24037f5e  0f e0 0f ac 0f 78 0f 44  0f 10 0e dc 0e a8 0e 74  |.....x.D.......t|
24037f6e  0e 40 0e 0c 0d d8 0d a4  0d 70 0d 3c 0d 08 0c d4  |.@.......p.<....|
24037f7e  0c a0 0c 6c 0c 38 0c 04  0b d0 0b 9c 0b 68 0b 34  |...l.8.......h.4|
24037f8e  0b 00 0a cc 0a 98 0a 64  0a 30 09 fc 09 c8 09 94  |.......d.0......|
24037f9e  09 60 09 2c 08 f8 08 c4  08 90 08 5c 08 28 07 f4  |.`.,.......\.(..|
24037fae  07 c0 07 8c 07 58 07 24  06 f0 06 bc 06 88 06 54  |.....X.$.......T|
24037fbe  06 20 05 ec 05 b8 05 84  05 50 05 1c 04 e8 04 b4  |. .......P......|
24037fce  04 80 04 4c 04 18 03 e4  03 b0 03 7c 03 48 03 14  |...L.......|.H..|
24037fde  02 e0 02 ac 02 78 02 44  02 10 01 dc 01 a8 01 74  |.....x.D.......t|
24037fee  01 40 01 0c 00 d8 00 a4  00 63                    |.@.......c|

解析page directory

slotoffsetrecord(hex)primary keypage no
10x00a480 3a 4a f1 00 00 cd 8d382027352621
20x00d880 3a 4c 15 00 00 cd 91382056552625
30x010c80 3a 4d 39 00 00 cd 95382085752629
40x014080 3a 4e 5d 00 00 cd 99382114952633
50x017480 3a 4f 81 00 00 cd 9d382144152637
60x01a880 3a 50 a5 00 00 cd a1382173352641
70x01dc80 3a 51 c9 00 00 cd a5382202552645
80x021080 3a 52 ed 00 00 cd a9382231752649
90x024480 3a 54 11 00 00 cd ad382260952653
100x027880 3a 55 35 00 00 cd b1382290152657
2880x3af080 3b 92 4d 00 00 d2 09390407753769
2890x3b2480 3b 93 71 00 00 d2 0d390436953773
2900x3b5880 3b 94 95 00 00 d2 11390466153777
2910x3b8c80 3b 95 b9 00 00 d2 15390495353781
2920x3bc080 3b 96 dd 00 00 d2 19390524553785
2930x3bf480 3b 98 01 00 00 d2 1d390553753789
2940x3c2880 3b 99 25 00 00 d2 21390582953793
2950x3c5c80 3b 9a 49 00 00 d2 25390612153797
2960x3c9080 3b 9b 6d 00 00 d2 29390641353801
2970x3cc480 3b 9c 91 00 00 d2 2d390670553805
2980x3cf880 3b 9d b5 00 00 d2 31390699753809
2990x3d2c80 3b 9e d9 00 00 d2 35390728953813

在以上slots中做binary search,发现我们要找的记录(primary key 3905000)可能在slot 291和slot 292之间 继续查看slot 291的第1个record所指向的page 53781

查看page 53781

先看file header, offset = 53781 * (16*1024) = 881147904, size为38 bytes

$hexdump -s 881147904 -n 38 -C sbtest1.ibd
34854000  09 d0 e4 a7 00 00 d2 15  00 00 d2 14 00 00 d2 16  |................|
34854010  00 00 00 00 3b f4 ac 4b  45 bf 00 00 00 00 00 00  |....;..KE.......|
34854020  00 00 00 00 00 20                                 |..... |

page type是0x45bf,确认了是B+Tree节点

再看page header, offset = 53781 * (16*1024) + 38 = 881147942, size为56 bytes

$hexdump -s 881147942 -n 56 -C sbtest1.ibd
34854026  00 13 3b c8 80 4b 00 00  00 00 3a ff 00 02 00 48  |..;..K....:....H|
34854036  00 49 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |.I..............|
34854046  00 00 00 38 00 00 00 00  00 00 00 00 00 00 00 00  |...8............|
34854056  00 00 00 00 00 00 00 00                           |........|

page directory slots数目为19(0x0013)

page level为0(0x0000),这是叶子节点了,是真正存数据的page

再看page diretory 每个slot占2 bytes,19个slot共38 bytes。 所以page directory offset为 53781(161024) - 38 - 8(trailer) = 881164242

$hexdump -s 881164242 -n 38 -C sbtest1.ibd
34857fd2  00 70 36 ef 33 af 30 6f  2d 2f 29 ef 26 af 23 6f  |.p6.3.0o-/).&.#o|
34857fe2  20 2f 1c ef 19 af 16 6f  13 2f 0f ef 0c af 09 6f  | /.....o./.....o|
34857ff2  06 2f 02 ef 00 63                                 |./...c|

解析page directory

slot | offset | record(hex) | primary key | page no
----- | ------ | ------ | -------- | --------
1 | 0x02ef | 80 3b 95 bc 00 00 00 00 | 3904956 | 0
2 | 0x062f | 80 3b 95 c0 00 00 00 00 | 3904960 | 0
3 | 0x096f | 80 3b 95 c4 00 00 00 00 | 3904964 | 0
4 | 0x0caf | 80 3b 95 c8 00 00 00 00 | 3904968 | 0
5 | 0x0fef | 80 3b 95 cc 00 00 00 00 | 3904972 | 0
6 | 0x132f | 80 3b 95 d0 00 00 00 00 | 3904976 | 0
7 | 0x166f | 80 3b 95 d4 00 00 00 00 | 3904980 | 0
8 | 0x19af | 80 3b 95 d8 00 00 00 00 | 3904984 | 0
9 | 0x1cef | 80 3b 95 dc 00 00 00 00 | 3904988 | 0
10 | 0x202f | 80 3b 95 e0 00 00 00 00 | 3904992 | 0
11 | 0x236f | 80 3b 95 e4 00 00 00 00 | 3904996 | 0
12 | 0x26af | 80 3b 95 e8 00 00 00 00 | 3905000 | 0
13 | 0x29ef | 80 3b 95 ec 00 00 00 00 | 3905004 | 0
14 | 0x2d2f | 80 3b 95 f0 00 00 00 00 | 3905008 | 0
15 | 0x306f | 80 3b 95 f4 00 00 00 00 | 3905012 | 0
16 | 0x33af | 80 3b 95 f8 00 00 00 00 | 3905016 | 0
17 | 0x36ef | 80 3b 95 fc 00 00 00 00 | 3905020 | 0

OK,在slot 12找到了primary key 3905000

查看primary key 3905000的记录

mysql> select * from sbtest1 where id=3905000 \G
*************************** 1. row ***************************
 id: 3905000
  k: 7152454
  c: 33061989913-01978996152-96897051302-66804054532-36658200903-75265952777-90162670547-62113775555-84037309450-68725639441
pad: 93314475890-72810819110-74153294523-75348581725-15287112137
1 row in set (0.00 sec)

打印256 bytes验证下是不是我们要找的记录 offset为 53781(161024)+0x26af=881157807 id = 3905000 = 0x3b95e8 k = 7152454 = 0x6d2346 剩下的是c和pad的字符串,内容是对的。

hexdump -s 881157807 -n 256 -C /home/ming.lin/one_key_env_57/run_primary/dbs/testdb/sbtest1.ibd
348566af  80 3b 95 e8 00 00 00 00  0e d3 cb 00 00 00 0f 24  |.;.............$|
348566bf  ec 80 6d 23 46 33 33 30  36 31 39 38 39 39 31 33  |..m#F33061989913|
348566cf  2d 30 31 39 37 38 39 39  36 31 35 32 2d 39 36 38  |-01978996152-968|
348566df  39 37 30 35 31 33 30 32  2d 36 36 38 30 34 30 35  |97051302-6680405|
348566ef  34 35 33 32 2d 33 36 36  35 38 32 30 30 39 30 33  |4532-36658200903|
348566ff  2d 37 35 32 36 35 39 35  32 37 37 37 2d 39 30 31  |-75265952777-901|
3485670f  36 32 36 37 30 35 34 37  2d 36 32 31 31 33 37 37  |62670547-6211377|
3485671f  35 35 35 35 2d 38 34 30  33 37 33 30 39 34 35 30  |5555-84037309450|
3485672f  2d 36 38 37 32 35 36 33  39 34 34 31 20 39 33 33  |-68725639441 933|
3485673f  31 34 34 37 35 38 39 30  2d 37 32 38 31 30 38 31  |14475890-7281081|
3485674f  39 31 31 30 2d 37 34 31  35 33 32 39 34 35 32 33  |9110-74153294523|
3485675f  2d 37 35 33 34 38 35 38  31 37 32 35 2d 31 35 32  |-75348581725-152|
3485676f  38 37 31 31 32 31 33 37  20 3c 78 00 01 90 00 d0  |87112137 <x.....|
3485677f  80 3b 95 e9 00 00 00 00  0e d3 cb 00 00 00 0f 24  |.;.............$|
3485678f  f9 80 57 82 ca 37 38 33  32 36 37 32 37 31 32 39  |..W..78326727129|
3485679f  2d 32 30 30 39 36 39 37  37 37 38 30 2d 38 34 31  |-20096977780-841

至此我们手动找到了primary key为3905000的记录。

小结

page directory很关键,先在page directory中做binary search,定位到某个slot,再遍历slot上的record list

Redis · 最佳实践 · 集群配置:Redis Cluster

$
0
0

在数据库领域,当数据量大到一定程度后,我们总是绕不开分布式这个话题。这个问题牵扯很多方面,

  • 分片策略(Sharding):分库分表?水平切片?垂直切片?
  • 数据备份:数据备份什么时候做?粒度是什么?怎样备份?
  • 数据迁移:当数据分布发生拓扑变化的时候,怎么把数据从原来的节点迁移到新的节点上?
  • 集群管理:如何管理整个集群,如何把用户请求定向到某个特定的节点上?

这些问题很很多不同的解法,在不同的使用场景,不同的数据库设计结构下有不同的选择。大体上讲,因为相对简单,NoSQL在这个方面的解决方案较传统SQL数据库使用更广泛。我们不妨来看看开源社区中使用最普遍的分布式解决方案之一:Redis Cluster,看看它是如何解决分布式的问题。

Redis Cluster集群

Redis Cluster是一个Redis的分布式部署形式,使用数据分片的办法把数据分配到不同的节点;每个节点可以有自己的备份节点(一个或多个)。整个集群之上另有一个叫做Redis Sentinel的分布式组件用以提供更丰富的HA能力。

下面我们就前一个章节提到的问题来看看Redis Cluster的解决方案。

数据分片

Redis Cluster使用 Slot的概念:作为一个KV系统,它把每个key的值hash0 ~ 16383之间的一个数。这个hash值被用来确定对应的数据存储在哪个节点中。集群中的每个节点都存储了一份类似路由表的东西,描述每个节点所拥有的 Slots;当用户请求一个不在本机的key的时候,它可以根据这个路由表找到正确的服务节点,然后回复给用户一个moved,告知用户正确的服务节点。

  • slot = CRC16(key) % 16383
  • 是集群内数据管理和迁移的最小单位,保证数据管理的粒度易于管理;
  • 每个节点都知道slot在集群中的分布,并能把对应信息回复给无法服务的请求。
  • 节点之间保持Gossip通信

下面图例就是一个简单的、最小的3节点Redis Cluster数据分配例子。

Figure-1: Redis Cluster Example

数据备份

Redis的备份是最简单的Master-Slave备份。每个主节点都可以有若干个从节点跟随;从节点(Replica)可以提供高可靠性(HA),也可以用作只读节点提供高吞吐量。

通过加入针对每个节点的复制备份能力,Redis Cluster在单个数据粒度上提供了高可用性。整个部署架构从前一张图中的简单分布式Sharding结构演变为下图中所示的结构。

Figure-2: Redis Cluster with Replication

数据备份架构中,主节点把自己的状态通过AOF异步(缺省方式)传送给从节点。多个从节点可以使用级联的方式传输数据,而不用全部都从主节点获得,以此减轻对主节点的性能压力。从节点不光可以用来备份数据保证高可用,也可以担任只读节点的任务,提供压力分流。

数据迁移

Redis Cluster中的数据迁移又称作Reshard,一般是因为有节点的变化或者是做load balancing。简单的讲,Reshard就是把一些slots从一个节点转移到另一个节点。

Reshard的原理并不复杂:

  1. 外部工具向某分片发出migrate命令,触发一个或者多个(3.2开始支持)key的迁移。
  2. 接收到migrate命令的分片,即迁出分片,将对应的key进行序列化后发往迁入分片,并阻塞等待迁入分片的返回。
  3. 迁入分片通过restore-asking命令将收到的key进行应用,并返回成功给迁出分片。
  4. 迁出分片收到应答后,删除对应的key,并将migrate命令转化为del命令并同步给同步和记录到AOF中供replicas消费,完成迁移。

Figure-3: Redis Cluster with Replication

上面图例就是一个Reshard的流程示意,我们用一个string b来指代若干个slots;图中的数字代表步骤的顺序。

集群管理

Redis Cluster集群管理引入了一个新的组件,叫做Redis Sentinel,在整个集群的纬度上提供高可用的能力。简单的讲,它类似一个集群的Registry,包含监控、报警、自动切换、配置管理等常见功能。另外,Sentinel本身也是分布式部署,采用多数派算法维持状态的一致性。

  • Sentinels监视所有的数据节点
  • Sentinels监视所有其他Sentinels
  • 当Sentinels对节点宕机达成共识之后,选举出一个新的master(升级)并完成各种配置方面的联动

以我们在上面《数据备份》小节中的系统架构为基础,加上Sentinel,以及高可用的代理节点(HAProxy),就是一个典型的Redis Cluster部署形态。

Figure-4: Redis Cluster Typical Deployment

小结

Less is more这个概念重要且真实。Redis Cluster的分布式设计非常简单,也正因为如此非常容易维护。它的每个组件都很简单,功能不多;可以很简单的实现分布式设计。整个系统设计方面,它省略了很多一致性方面的考虑,用以换取高性能和健壮性。

MongoDB · 引擎特性 · 大量集合启动加载优化原理

$
0
0

背景

启动数据加载时间对于很多数据库来说是一个不容忽视的因素,启动加载慢直接导致数据库恢复正常服务的RTO时间变长,影响服务可用性。比如Redis,启动时要加载RDB和AOF文件,把所有数据加载到内存中,根据节点内存数据量的不同,加载时间可能达到几十分钟甚至更长。MongoDB在启动时同样需要加载一些元数据,结合阿里云MongoDB云上运维的经验,在集合数量不多时,这个加载时间不会很长,但是对于大量集合场景、特别是MongoDB进程资源受限的情况下(比如虚机、容器、cgroup隔离场景),这个加载时间就变得无法预测,有可能会遇到节点本身内存小无法完成加载或者进程OOM的情况。经测试,在MongoDB 4.2.0之前(包括)的版本,加载10W集合耗时达到10分钟以上。MongoDB 在最新开发版本里针对这个问题进行了优化,尤其是对于大量集合场景,效果非常明显。在完全相同的测试条件下,该优化使得启动加载10W集合的时间由10分钟降低到2分钟,并且启动后初始内存占用降低为之前的四分之一。这个优化目前已经backport到4.2和4.0最新版本,阿里云MongoDB 4.2也已支持。

鉴于该优化带来的效果和好处明显,有必要对其背后的技术原理和细节进行深入的探究和学习,本文主要基于MongoDB 4.2社区版优化前后的版本进行对比分析,对MongoDB的启动加载过程、具体优化点、优化原理进行阐述,希望和对MongoDB内部实现有兴趣的同学一起探讨和学习。

MongoDB启动加载过程

MongoDB在启动时,WiredTiger引擎层需要将所有集合/索引的元数据加载到内存中,而MongoDB的集合/索引实际上就是对应WiredTiger中的表,加载集合/索引就需要打开WiredTiger对应表的cursor。

WiredTiger Cursor介绍

WiredTiger是MongoDB的默认存储引擎,负责管理和存储MongoDB的各种数据,WiredTiger支持多种数据源(data sources),包括表、索引、列组(column groups)、LSM Tree、状态统计等,此外,还支持用户通过实现WiredTiger定义好的接口来扩展自定义的数据源。

WiredTiger对各个数据源中数据的访问和管理是由对应的cursor来提供的,WiredTiger内部提供了用于数据访问和管理的基本cursor类型(包括table cursor、column group cursor、index cursor、join cursor)、以及一些专用cursor(包括metadata cursor、backup cursor、事务日志cursor、以及用于状态统计的cursor),专用cursor可以访问由WiredTiger管理的数据,用于完成某些管理类任务。另外,WiredTiger还提供了两种底层cursor类型:file cursor和LSM cursor。

WiredTiger cursor的通用实现通常会包含:暂存数据源中数据存储位置的变量,对数据进行迭代遍历或查找的方法,对key、value各字段进行设置的getter/setters,对字段进行编码的方法(便于存储到对应数据源)。

MongoDB和WiredTiger数据组织方式介绍

为了能够管理所有的集合、索引,MongoDB将集合的Catalog信息(包括对应到WiredTiger中的表名、集合创建选项、集合索引信息等)组织存放在一个_mdb_catalog的WiredTiger表中(对应到一个_mdb_catalog.wt的物理文件)。因此,这个_mdb_catalog表可以认为是MongoDB的『元数据表』,普通的集合都是『数据表』。MongoDB在启动时需要先从WiredTiger中加载这个元数据表的信息,然后才能加载出其他的数据表的信息。 同样,在WiredTiger层,每张表也有一些元数据需要维护,这包括表创建时的相关配置,checkpoint信息等。这也是使用『元数据表』和『数据表』的管理组织方式。在WiredTiger中,所有『数据表』的元数据都会存放在一个WiredTiger.wt的表中,这个表可以认为是WiredTiger的『元数据表』。而这个WiredTiger.wt表本身的元数据,则是存放在一个WiredTiger.turtle的文本文件中。在WiredTiger启动时,会先从WiredTiger.turtle文件中加载出WiredTiger.wt表的数据,然后就能加载出其他的数据表了。

启动过程分析

再回到_mdb_catalog表,虽然对MongoDB来说,它是一张『元数据表』,但是在WiredTiger看来,它只是一张普通的数据表,因此启动时候,需要先等WiredTiger加载完WiredTiger.wt表后,从这个表中找到它的元数据。根据_mdb_catalog表的元数据可以对这个表做对应的初始化,并遍历出MongodB的所有数据表(集合)的Catalog信息元数据,对它们做进一步的初始化。

在上述这个过程中,对WiredTiger中的表做初始化,涉及到几个步骤,包括: 1)检查表的存储格式版本是否和当前数据库版本兼容 2)确定该表是否需要开启journal,这是在该表创建时的配置中指定的 这两个步骤都需要从WiredTiger.wt表中读取该表的元数据进行判断。

此外,结合目前的已知信息,我们可以看到,对MongoDB层可见的所有数据表,在_mdb_catalog表中维护了MongoDB需要的元数据,同样在WiredTiger层中,会有一份对应的WiredTiger需要的元数据维护在WiredTiger.wt表中。因此,事实上这里有两份数据表的列表,并且在某些情况下可能会存在不一致,比如,异常宕机的场景。因此MongoDB在启动过程中,会对这两份数据进行一致性检查,如果是异常宕机启动过程,会以WiredTiger.wt表中的数据为准,对_mdb_catalog表中的记录进行修正。这个过程会需要遍历WiredTiger.wt表得到所有数据表的列表。

综上,可以看到,在MongoDB启动过程中,有多处涉及到需要从WiredTiger.wt表中读取数据表的元数据。对这种需求,WiredTiger专门提供了一类特殊的『metadata』类型的cursor。

metadata cursor使用优化原理

metadata cursor简介

WiredTiger的metadata cursor是WiredTiger用于读取WiredTiger.wt表(元数据表)的cursor,它底层封装了用于查找WiredTiger.wt对应的内存btree结构的file cursor。File cursor实际上就是用于查找数据文件对应的btree结构的一种cursor,读取索引和集合数据文件也都是通过file cursor。

WiredTiger通过cursor的URI前缀来识别cursor的类型,对于metadata cursor类型,它的前缀是『metadata:』。根据cursor打开方式的不同,metadata cursor又可以分为metadata: cursor和metadata:create cursor两种。看下这两种cursor打开方式的区别:

  • 打开metadata:create cursor
WT_CURSOR* c = NULL;
int ret = session->open_cursor(session, "metadata:create", NULL, NULL, &c);
  • 打开metadata: cursor
WT_CURSOR* c = nullptr;
int ret = session->open_cursor(session, "metadata:", nullptr, nullptr, &c);

实际上,WiredTiger在打开metadata: cursor时,默认只需要打开一个读取WiredTiger.wt表的file cursor(源码里命名是file_cursor),对于metadata:create cursor,还需要再打开另一个读取WiredTiger.wt表的file cursor(源码里命名是create_cursor),虽然只多了一个cursor,但是metadata:create cursor的使用代价却比metadata: cursor高得多。从启动加载过程可以看到,主要有三处使用metadata cursor的地方,而MongoDB启动加载优化中一个主要的优化点,就是把前面两处使用『metadata:create』 cursor的地方改成了『metadata:』 cursor。接下来我们分析这背后的原因。

metadata cursor工作原理

metadata cursor的工作流程

以namesapce名称为db2.col1的集合为例,它在WiredTiger中的表名是db2/collection-11–4499452254973778892,来看看对于这个集合,是如何通过metadata cursor获取到实际的journal配置的,通过这个过程来说明metadata cursor的工作流程。下面具体来看:

  • 使用file_cursor查找WiredTiger.wt表的btree结构,查找的cursor key是:

table:db2/collection-11–4499452254973778892 获取到的元信息value: app_metadata=(formatVersion=1),colgroups=,collator=,columns=,key_format=q,value_format=u

  • 其实到这里,metadata:create cursor和metadata: cursor做的事情是一样的,只不过对于metadata: cursor,则到这就结束了。如果是metadata:create cursor,接下来还需要通过create_cursor获取集合的creationString;因为这里要获取creationString中的journal配置,所以必须用metadata:create cursor。

  • 对于metadata:create cursor,使用create_cursor继续查找creationString配置。获取到creationString后,就可以从中拿到实际的journal配置了。create_cursor实际上有两次对WiredTiger.wt表的btree结构进行search的过程:

1)第一次search是为了从WiredTiger.wt表中拿到集合对应的source文件名,查找的cursor key是: colgroup:db2/collection-11–4499452254973778892

获取到的元信息value: app_metadata=(formatVersion=1),collator=,columns=,source=”file:db2/collection-11–4499452254973778892.wt”,type=file

2)第二次search是通过上一步获取到的表元信息中的数据文件名称,继续从WiredTiger.wt表中拿到该表的creationString信息。 cursor key: file:db2/collection-11–4499452254973778892.wt

获取到的元信息value:

access_pattern_hint=none,allocation_size=4KB,app_metadata=(formatVersion=1),assert=(commit_timestamp=none,durable_timestamp=none,read_timestamp=none),block_allocation=best,block_compressor=snappy,cache_resident=false,checksum=on,colgroups=,collator=,columns=,dictionary=0,encryption=(keyid=,name=),exclusive=false,extractor=,format=btree,huffman_key=,huffman_value=,ignore_in_memory_cache_size=false,immutable=false,internal_item_max=0,internal_key_max=0,internal_key_truncate=true,internal_page_max=4KB,key_format=q,key_gap=10,leaf_item_max=0,leaf_key_max=0,leaf_page_max=32KB,leaf_value_max=64MB,log=(enabled=true),lsm=(auto_throttle=true,bloom=true,bloom_bit_count=16,bloom_config=,bloom_hash_count=8,bloom_oldest=false,chunk_count_limit=0,chunk_max=5GB,chunk_size=10MB,merge_custom=(prefix=,start_generation=0,suffix=),merge_max=15,merge_min=0),memory_page_image_max=0,memory_page_max=10m,os_cache_dirty_max=0,os_cache_max=0,prefix_compression=false,prefix_compression_min=4,source="file:db2/collection-11--4499452254973778892.wt"

可以看到这实际上就是wiredTiger在创建表时的schema元信息,可以通过db.collection.stats()命令输出的wiredTiger.creationString字段来查看。获取到creationString信息,就可以从中解析出log=(enabled=true)这个配置了。

metadata:create cursor代价为什么高

从上面的分析可以看出,对于metadata: cursor,只有一次对WiredTiger.wt表的btree search过程。而对于metadata:create cursor,一次元数据配置查找其实会有三次对WiredTiger.wt表的btree进行search的过程,并且每次都是从btree的root节点去查找(因为每次要查找的元数据在btree结构中的存储位置上互相是没有关联的),开销较大。**

启动加载优化细节

优化1:获取集合的存储格式版本号

这里最终目的就是要获取集合元数据中”app_metadata=(formatVersion=1)”里的formatVersion的版本号,从metadata cursor的工作流程可以看到,file_cursor第一次查找的结果里已经包含了这个信息。在优化前,这里用的是metadata:create cursor,是不必要的,所以这里改用一个metadata: cursor就可以了,每个集合的初始化就少了两次『从WiredTiger.wt表对应的btree的root节点开始search』的过程。

优化2:获取所有集合的数据文件名称

以db2.col1集合为例,查找的cursor key是: colgroup:db2/collection-11–4499452254973778892

获取到的元信息value: app_metadata=(formatVersion=1),collator=,columns=,source=”file:db2/collection-11–4499452254973778892.wt”,type=file

获取集合的数据文件名称,实际上就是要获取元信息里的source=”file:db2/collection-11–4499452254973778892.wt”这个配置。优化后,这里改成了metadata: cursor,只要一次file cursor的next调用就好,并且下个集合在获取数据文件名时cursor已经是就位(positioned)的。在优化前,这里用的是metadata:create cursor,多了两次file cursor的search调用过程,并且每次都是从WiredTiger.wt表对应的btree的root节点开始search,开销大得多。

延迟打开cursor优化

MongoDB最新版本中,还有一个针对大量集合/索引场景的特定优化,那就是『延迟打开Cursor』。在优化前,MongoDB在启动时,需要为每个集合都打开对应的WiredTiger表的cursor,这是为了获取NextRecordId。这是干什么的呢?先要说一下RecordId。

我们知道,MongoDB用的是WiredTiger的key-value行存储模式,一个MongoDB中的文档会对应到WiredTiger中的一条KV记录,记录的key被称为RecordId,记录的value就是文档内容。WiredTiger在查找、更新、删除MongoDB文档时都是通过这个RecordId去找到对应文档的。

对于普通数据集合,RecordId就是一个64位自增数字。而对于oplog集合,MongoDB按照时间戳+自增数字生成一个64位的RecordId,高32位代表时间戳,低32位是一个连续增加的数字(时间戳相同情况下)。

比如,下面是针对普通数据集合和oplog集合插入一条数据的记录内容:

  • 普通数据集合中连续插入一条{a:1}和{b:1}的文档
record id:1, record value:{ _id: ObjectId('5e93f4f6c8165093164a940f'), a: 1.0 }
record id:2, record value:{ _id: ObjectId('5e93f78f015050efdb4107b4'), b: 1.0 }
  • oplog中插入一条的记录(向db1.col1这个集合插入一个{c:1}的新文档触发)
record id:6815068270647836673,
record value:{ ts: Timestamp(1586756732, 1), t: 24, h: 0,
v: 2, op: "i", ns: "db1.col1", ui: UUID("ae7cfb6f-8072-4475-b33a-78b88ab72c6c"), wall: new Date(1586756748564), o: { _id: ObjectId('5e93fc7c7dc2edf0b11837ad')
, c: 1.0 } }
注:6815068270647836673实际上就是1586756732 << 32 + 1

优化细节

MongoDB在内存中为每个集合都维护了一个NextRecordId变量,用来在下次插入新的文档时分配RecordId。因此这里在启动时为每个集合都都打开对应的WiredTiger表的cursor,并通过反向遍历到第一个key(也就是最大的一个key),并对其值加一,来得到这个NextRecordId。

而在MongoDB最新版本中,MongoDB把启动时为每个集合获取NextRecordId这个动作给推迟到了该集合第一次插入新文档时才进行,这在集合数量很多的时候就减少了许多开销,不光能提升启动速度,还能减少内存占用。

优化效果

下面我们通过测试来看下实际优化效果如何。

测试条件

事先准备好测试数据,写入10W集合,每个集合包含一个{“a”:”b”}的文档。 然后分别以优化前后的版本(完全相同的配置下)来启动加载准备好的数据,对比启动加载时间和初始内存占用情况。

优化前

启动日志:image.png

加载完的日志:image.png

启动后初始内存占用:

db.serverStatus().mem { “bits” : 64, “resident” : 4863, “virtual” : 6298, “supported” : true }

可以看到优化前版本启动加载10W集合的时间约为 10分钟 左右,启动后初始内存(常驻)占用为4863M。

优化后

启动日志:image.png

加载完的日志:image.png

启动后初始内存占用:

db.serverStatus().mem { “bits” : 64, “resident” : 1181, “virtual” : 2648, “supported” : true }

可以看到优化后版本启动加载10W集合的时间约为 2分钟 左右。启动后初始内存(常驻)占用为1181M。

结论

在同样的测试条件下,优化后版本启动加载时间约为优化前的1/5,优化后版本启动后初始内存占用约为优化前的1/4。

总结

最后,我们来简要总结下MongoDB最新版本对启动加载的优化内容: 1)优化启动时集合加载打开cursor的次数,用metadata:类型cursor替代不必要的metadata:create cursor(代价比较高),将metadata:create cursor的调用次数由每个表3次降到1次。 2)采用『延迟打开cursor』机制,启动时不再为所有集合都打开cursor,将打开cursor的动作延后进行。

可以看到,这个优化本身并没有对底层WiredTiger引擎实现有任何改动,对于上层MongoDB的改动也不大,而是通过深挖底层存储引擎WiredTiger cursor使用上的细节,找到了关键因素,最终取得了非常显著的效果,充分证明了“细节决定成败”这个真理,很值得学习。

尽管已经取得了如此大的优化效果,事实上MongoDB启动加载还有进一步的优化空间,由于启动数据加载目前还是单线程,瓶颈主要在CPU,官方已经有计划将启动数据加载流程并行化,进一步优化启动时间,我们后续也会持续关注。

MySQL · 引擎特性 · 8.0 Lock Manager

$
0
0

Basic Data Structure

struct lock_sys_t {
char pad1[INNOBASE_CACHE_LINE_SIZE];
/*!< padding to prevent other
memory update hotspots from
residing on the same memory
cache line */
LockMutex mutex;              /*!< Mutex protecting the
locks */
hash_table_t *rec_hash;       /*!< hash table of the record
locks */
hash_table_t *prdt_hash;      /*!< hash table of the predicate
lock */
hash_table_t *prdt_page_hash; /*!< hash table of the page
lock */
#ifdef UNIV_DEBUG
/** Lock timestamp counter */
uint64_t m_seq;
#endif /* UNIV_DEBUG */
};

Initialization at server boot up time (i.e., srv_start())

void lock_sys_create(
ulint n_cells) /*!< in: number of slots in lock hash table */
{
mutex_create(LATCH_ID_LOCK_SYS, &lock_sys->mutex);
lock_sys->rec_hash = hash_create(n_cells);
lock_sys->prdt_hash = hash_create(n_cells);
lock_sys->prdt_page_hash = hash_create(n_cells);
}
lock_sys_create(srv_lock_table_size);
/* normalize lock_sys */
srv_lock_table_size = 5 * (srv_buf_pool_size / UNIV_PAGE_SIZE);

Initially, we assume there might be 5 row locks per page. We might need to change the size of lock hash table which is also protected by the mutex.

/** Resize the lock hash tables.
@param[in]  n_cells  number of slots in lock hash table */
void lock_sys_resize(ulint n_cells) {
hash_table_t *old_hash;
lock_mutex_enter();
old_hash = lock_sys->rec_hash;
lock_sys->rec_hash = hash_create(n_cells);
HASH_MIGRATE(old_hash, lock_sys->rec_hash, lock_t, hash, lock_rec_lock_fold);
hash_table_free(old_hash);
lock_mutex_exit();
}

Mutex is used to sync all operations need to acquire rec/prdt locks.

static void row_ins_foreign_trx_print(trx_t *trx) /*!< in: transaction */
{
lock_mutex_enter();
n_rec_locks = lock_number_of_rows_locked(&trx->lock);
n_trx_locks = UT_LIST_GET_LEN(trx->lock.trx_locks);
heap_size = mem_heap_get_size(trx->lock.lock_heap);
lock_mutex_exit();
}
//Create a row lock
lock_t *RecLock::create(trx_t *trx, bool add_to_hash, const lock_prdt_t *prdt) {
//create lock record
lock_t *lock = lock_alloc(trx, m_index, m_mode, m_rec_id, m_size);
//hookup to the lock hash table
lock_add(lock, add_to_hash);
}
/**
Record lock ID */
struct RecID {
/**
Tablespace ID */
space_id_t m_space_id;
/**
Page number within the space ID */
page_no_t m_page_no;
/**
Heap number within the page */?????
uint32_t m_heap_no;
/**
Hashed key value */
ulint m_fold;
};

DeadLock Detection

Not all operations will trigger the deadlock detection. It will only be triggered by If we cannot get the required lock immediately. For example:

dberr_t lock_rec_insert_check_and_lock {
const lock_t *wait_for =
lock_rec_other_has_conflicting(type_mode, block, heap_no, trx);
if (wait_for != NULL) {
RecLock rec_lock(thr, index, block, heap_no, type_mode);
// This will trigger the deadlock detection
err = rec_lock.add_to_waitq(wait_for);
}
}

Basic idea:

find a circle in the wait graph (i.e. directed graph).

Basic data structure:

class DeadlockChecker {
@param trx the start transaction (start node)
@param wait_lock lock that a transaction wants
@param mark_start visited node counter */
DeadlockChecker(const trx_t *trx, const lock_t *wait_lock,
uint64_t mark_start)
}

Basic DFS search structure:

/** DFS state information, used during deadlock checking. */
struct state_t {
const lock_t *m_lock;      /*!< Current lock */
const lock_t *m_wait_lock; /*!< Waiting for lock */
ulint m_heap_no;           /*!< heap number if rec lock */
};

Basic Entry Point

/** Check and resolve any deadlocks
@param[in, out] lock    The lock being acquired
@return DB_LOCK_WAIT, DB_DEADLOCK, or
DB_SUCCESS_LOCKED_REC; DB_SUCCESS_LOCKED_REC means that
there was a deadlock, but another transaction was chosen
as a victim, and we got the lock immediately: no need to
wait then */
dberr_t RecLock::deadlock_check(lock_t *lock) {
const trx_t *victim_trx = DeadlockChecker::check_and_resolve(lock, m_trx);
{
/* Try and resolve as many deadlocks as possible. */
do {
DeadlockChecker checker(trx, lock, s_lock_mark_counter);
victim_trx = checker.search();
} while (victim_trx != NULL && victim_trx != trx);
}

Background reading

INNODB LOCK

https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html

How to create a deadlock

https://stackoverflow.com/questions/31552766/how-to-cause-deadlock-on-mysql

How to detect circle in directed graph

https://www.geeksforgeeks.org/detect-cycle-in-a-graph/https://www.youtube.com/watch?v=joqmqvHC_Bo

Database · 技术方向 · 下一代云原生数据库详解

$
0
0

云原生数据库风起云涌

近十年是数据库市场发展最快的十年,根据Gartner的数据,目前数据库市场营收已经达到整个软件市场的18.4%,而云数据库贡献了其中的68%。 特别是近几年,云原生数据库的理念为市场和各大云厂商所认可,各大厂商纷纷在自研云原生数据库领域持续发力,未来的云数据库市场是自研云原生数据库之间的竞争。

云原生数据库和托管/自建数据库最大的区别就是:云原生数据库是面向独立资源的云化,其CPU、内存、存储等均可实现独立的弹性,利用大型云厂商的海量资源池,最大化其资源利用率,降低成本,同时支持独立扩展特定资源,满足多种用户不断变化的业务需求,实现完全的Serverless; 而托管数据库还是局限于传统的服务器架构,各项资源等比率的限制在一个范围内,其弹性范围,资源利用率都受到较大的限制,无法充分利用云的红利。

当前的云原生数据库现状

目前主流云原生数据库刚走出了第一步,即实现了存储和计算的分离,并基于此实现了一写多读集群架构.

存储和计算分离:

  • 存储和计算分离,打破了存储的单机限制,使得存储独立弹性成为可能,并为后续的一写多读集群奠定了基础

一写多读架构:

  • 基于分布式共享存储的一写多读架构,使得数据库的读能力可以快速Scale Out,同时由于多个读节点和写节点共享同一份存储,降低了成本,提升了资源利用率。

云原生数据库领域,AWS的Aurora是先行者.国内目前PolarDB是一枝独秀,其他厂商正在跟进中:

  • 阿里云的PolarDB为领头羊,早在2017年就进行了公测,2018年实现商业化,并支持最大100T存储,1写15读集群
  • 腾讯云的CynosDB单节点的已开始售卖,最大支持5T存储,一写多读尚不支持
  • 华为云的TaurusDB,目前还处于宣传阶段,尚未公开售卖

下一代云原生数据库架构

当前基于共享存储一写多读的云原生架构虽然解决了存储的弹性问题和读扩展问题,极大的提升了云原生数据库的弹性和扩展能力,但是依然存在2个比较大的瓶颈点,即内存弹性单点写入问题。因此下一代云原生数据库将在如下两个方向实现突破:

基于CPU和内存分离的分布式共享内存池

  • 当前的云原生架构虽然实现了存储和计算分离,存储独立弹性;但是计算节点仍然包含了CPU和内存,因此无法真正实现秒级弹性扩容和Serverless
  • 因此在云原生数据库中实现CPU和内存分离内存独立弹性非常有必要。同时CPU和内存分离可以让多个CPU共享同一份内存,降低内存资源开销。

基于分布式共享内存池的多点可写技术

  • 当前一写多读的云原生架构,虽然实现了读能力的扩展,但是写能力仍然受到单机的限制,无法扩展。而采用分库分表的分布式数据库扩展,又会牺牲兼容性,需要应用感知和改造。
  • 多写架构主要困难在于信息交互的低效,导致线性扩展性低下。在CPU和内存分离以后,多个CPU可以共享同一个内存池来交换页面信息和事务信息,同时结合高性能RDMA网络和NVM,使得高性能多写架构成为可能

因此云原生数据库下一代架构必然是基于分布式共享内存池的多点可写架构。目前AWS也看到了这一个方向,也推出了Aurora Multi-Master产品,但是该产品直接基于共享存储实现多点可写,因此多个写节点间数据/事务信息同步效率低下,导致只能采用乐观冲突机制,同时冲突场景下性能非常低下。

PolarDB团队在发布基于共享存储的一写多读PolarDB的时候,就开始着手研发下一代基于分布式共享内存池的多点可写架构。目前已经在CPU和内存分离,多节点缓存一致性,多节点事务控制等多个关键技术点实现突破。

多写架构一直以来都被认为是数据库技术皇冠上的明珠,目前国内外核心银行/电信/政府等行业仍然被Oracle/IBM等的多写架构所统治着。因多写架构存在较大的技术挑战,因此无论是开源数据库还是新兴的基于分区的分布式NewSQL都绕开了单行多写这个数据库技术高峰。这一次PolarDB团队正在登顶数据库技术高峰的路上,这也是突破数据库核心市场的机会。希望有志之士可以加入我们,共攀技术高峰,共创历史!

欢迎对 数据库,云原生技术,分布式存储,分布式架构,高性能网络,新硬件 感兴趣的同学,一起交流技术,共同进步!

微信: zhangyq_zju 邮箱: yingqiang.zyq@alibaba-inc.com

Database · 理论基础 · 高性能B-tree索引

$
0
0

1. 前言

在关系型数据库系统(RDBMS)中,索引是一个重要组成部分,其主要作用是提升查询性能,侧重OLTP的关系型数据库常用的索引大致可以分为两大类:

  • 基于树(tree-based)的B-tree索引。B-tree索引(这里B-tree代表B+-tree)适合以块或者页为单位的存储,支持高效的点查询(point query)和范围查询(range query),在数据库中已经广泛使用。
  • 基于哈希(hash-based)的哈希索引。哈希索引更适合建在内存中,仅支持点查询,更多使用于内存数据库中,如MS SQL Server Hekaton, SAP ASE In Memory Row Store等。

目前主流数据库产品大都支持B-tree索引, B-tree索引的设计和实现至关重要,直接影响整个数据库系统的性能,本文主要关注高性能B-tree索引在数据库中的设计和实现。

B-tree是一个经典的数据结构,网上关于它的介绍很多,这里不再赘述,下面仅列出一些关键特性以及一个示意图,以帮助更好理解本文接下里的内容,

  • 包含根结点,非叶节点和叶子节点

  • 每一个节点存放在某个固定大小的连续存储空间上,称为索引页

  • 非叶子节点仅存放查询导向信息

  • 叶子节点存放<key, record_id>信息,且按照顺序存放(便于二分查找)

  • 所有叶子节点双向连接(便于前向或后向范围查询)

    B-tree example

B-tree本身并不复杂,实现起来也不是特别困难,这方面的资料有很多,这里不对其细节过多讨论。

本文要重点介绍的是,如何从高性能数据库的角度去设计一个 1)保证原子性 2)能够高效恢复 3)且支持高并发的B-tree索引,因为这里需要考虑的情况更多且更复杂,比如,

  • 如何加锁才能支持高并发?
  • 如何对索引页进行更改才能使对并发任务的影响最小化?
  • 如何记录日志才能保证系统发生故障时能够高效地恢复?
  • 如何保证没有提交的SMO操作在系统发生故障的时候能够恢复到一致性状态?
  • 如何保证已经提交的SMO操作不会因事务的失败而回滚?
  • 如何保证索引遍历遇到了正在进行SMO操作的索引页也依然能正常工作(而非阻塞)?
  • 如何保证事务T1插入到页P1而后又被SMO操作移动到页P2上的数据能够回滚?

这都是一些非常典型的问题,当然,设计一个支持高并发的高性能B-tree索引要考虑和解决的问题远不止这些,针对这些问题,不同的数据库厂商(产品)可能会给出不同的答案。本文基于数据库的基础理论知识(ARIES / IM),并参考SAP ASE数据库的相关资料,对上述问题进行简单解答。

2. 锁同步

锁是并发任务同步的常用方式,锁的粒度越大,持续时间越长,系统越容易实现,但是会影响并发。

为了在数据库系统中支持高并发,数据部分可以选择使用行锁(row lock)来实现事务的隔离,而B-tree索引部分则使用latch,latch类似于mutex,是一种更加轻量级的锁,分为shared和exclusive两种模式,有如下特点,

  • 持有时间短。Reader或writer操作一个索引页前先加latch,操作完后立即释放(Transactional lock则要等到transaction commit/rollback之后才会释放);
  • 只保证物理一致性(physical consistency)。Reader加latch(shared latch)后从索引页读到的数据是可信赖的,writer加latch(exclusive latch)后能保证此刻只有自己能够修改索引页;
  • 不保证逻辑一致性(logical consistency)。Writer对索引页的修改结束后立即释放latch,最新修改在latch释放后对其他事务立即可见;
  • 无死锁检测。使用者需要自己定义加锁顺序以防止死锁,同时,单个任务同时持有的latch数量是有限的。

从实现角度来讲,由于latch是针对单个索引页的,可以把存放latch状态信息的控制块(control block)放在索引页对应的在缓存页中,这样一来,不但能够快速访问给定缓存页的latch状态信息,而且申请和释放latch的操作更高效(不需要为latch控制块分配或释放内存)。

3. Ladder locking

B-tree索引的遍历是个自上而下的操作,一般是从根节点开始,逐层向下遍历,直到找到一个符合条件的叶子节点。

其他一些索引操作,如索引页分裂和索引页删除(统称为SMO – Structure Modification Operation),则是自下而上的,这样一来,就非常容易发生死锁。

避免死锁的一种常用方法是,规定加锁的次序,B-tree索引也使用了这种方法,加锁方向为,自上而下(遍历),从左到右(范围查找)。

对与自上而下的遍历,加锁采用ladder locking(又叫lock coupling)的方式,顾名思义,类似于我们下梯子的过程,即,刚开始两只脚都在第一层,一只脚先下到第二层,等站稳后,另外一只脚离开第一层,以此类推,直至到达目标层。

基于这种方式的B-tree索引遍历过程如下,

  • 先对根节点加latch,从根结点读取数据并找到下一层的叶子节点(即孩子节点);
  • 对下一层的孩子节点加latch,成功后,释放父节点上的latch;如果无法立即获得孩子节点上的latch,等待,直到加latch成功,才能释放父节点上的latch。
  • 重复此操作,直到到达叶子层节点。

这个方法容易理解且易于实现,遍历过程中最多同时对2个索引页加锁,加锁范围小,减少了对其他任务的影响,有利于支持高并发。

4. 逻辑删除(logical delete) vs 物理删除(physical delete)

删除一条数据之前,需要先删除其对应的索引项(index entry),大致过程是,首先遍历索引,找叶子层的目标索引页(加exclusive latch),然后从该索引页上删除对应的索引项。

这里需要考虑的一个问题是,为了有效利用空间,是否可以直接把该索引项物理删除,以便立即释放其占用的空间?

在支持行锁和高并发的数据库系统中,一般不做物理删除,更常用的方法是逻辑删除,便于高效地支持事务,即,删除操作仅在索引项上设置一个deleted或invalid标签,待事务提交后,空间清理由其它异步的任务来做(比如在有些系统中,House Keeper任务会做page compact)。之后,如果其他事务扫描到该索引项,将根据自己的事务隔离级别(transaction isolation level)和索引项的状态(执行删除操作的事务是否已提交)执行相应的操作(忽略该索引项或者锁等待)。

总体来讲,逻辑删除有如下一些好处,

  • 简单高效。仅设置状态位,无其他不必要的数据移动;
  • 简化并发控制。不需要调整其他索引项的存储位置,减少了对并发任务的影响;
  • 保证事务正常回滚。被删除的数据行依然处于加锁(transactional lock)状态,直到事务提交才释放,其对应的索引项(处于逻辑删除状态)所占用的空间得以保持,如果事务回滚,不需要重新分配空间,能够快速完成回滚。

5. SMO和Nested Top Action

SMO(Structure Modification Operation)包括索引页的分裂(split)和删除(shrink),这类操作涉及多个步骤,我们希望其具有“原子性”,并且一旦完成,不应随着整个事务的失败而回滚。下面以索引页分裂操作为例,介绍下背后的原因。

插入一个新索引项(index entry)时,会首先遍历索引找到目标索引页,如果该索引页上的剩余连续空间已经不足以容纳下要插入的索引项,该索引页就需要分裂,腾出空间,大致过程如下,

  • 分配一个新的索引页
  • 移动目标索引页上的部分索引项(分裂点后面的所有索引项)到新索引页
  • 连接新分配的索引页到目标索引页后面(修改相邻索引页的prev/next指针)
  • 在上一级索引页(父节点)中添加指向新索引页的索引项,如果上一级索引页没有足够空间,继续分裂,直到受影响的非叶子节点完成更新。

可以看到,

  • 分裂操作的代价是相对比较大的,如果数据插入过程触发较多索引页分裂,将会影响性能;
  • 并且,如果分裂后的索引页由于触发分裂的事务的失败而回滚,接下来的数据插入可能会再次触发索引页分裂,带来不必要的开销;
  • 更为严重的是,还可能发生数据丢失,考虑如下的情况,

Changes of T2 will be lost if index page slit is undo

事务T1在插入数据的过程中,导致索引页P1发生分裂,新索引页P2被分配出来,分裂完成后,事务T2插入的数据4保存在了P2上,此时事务T1回滚,如果索引页分裂过程回滚,索引页P2的分配将会回滚,导致P2上的所有数据丢失。

可见,索引页分裂的最终结果不应该依赖外层事务的状态,如果分裂失败,需要遵循正常的逻辑回滚整个事务(包括触发索引页分裂的事务),一旦分裂过程完成,即使后面整个事务失败回滚,已经完成的索引页分裂也不应该回滚。

Nested Top Action

为了解决这个问题, ARIES提出了Nested Top Action,其论文中对Nested Top Action的描述如下,

A nested top action, for our purposes, is taken to mean any subsequence of actions of a transaction which should not be undone once the sequence is complete and some later action which is dependent on the nested top action is logged to stable storage, irrespective of the outcome of the enclosing transaction.

A transaction execution performing a sequence of actions which define a nested top actions consists of the following steps: 

(1) ascertaining the position of the current transaction’s last log record; 
(2) logging the redo and undo information associated with the actions of the nested top action; 
(3) and on completion of the nested top action, writing a dummy CLR whose UndoNxtLSN points to the log record whose position was remembered in step (1). 

ARIES提到,在nested top action结束的时候,写一条dummy CLR日志,它的UndoNxtLSN指向nested top action开始前的最后一条日志,这样一来,在外层事务回滚的时候,会读到该dummy CLR日志,取出UndoNxtLSN,然后跳转到nested top action开始前的日志,继续回滚。

同样使用了nested top action的概念,SAP ASE的处理则稍有不同,大致包括,

  • SMO的所有日志会被包含在一对begin_top_action和end_top_action之间
  • end_top_action表示SMO操作已结束,事务已提交
  • 对于已提交的SMO操作,回滚过程将忽略begin_top_action和end_top_action之间属于该SMO的日志。

SAP ASE基于nested top action的索引页分裂过程大致如下,

Index page split under nested top action

6. SMO和并发

SMO包含一系列操作,在触发SMO的事务提交之前,其它事务可能会访问受SMO影响的索引页,为了支持高并发,SMO采用自底向上的方式(从叶子节点到非叶子结点)。为了避免死锁,需要先释放叶子层节点上的latch,然后从根节点遍历,按照ladder locking的方式找到父节点并进行相应修改(增加或删除index entry)。这里有一个窗口,在释放了叶子层节点上的latch之后,但是找到需要修改的父节点之前,如果一个任务正在遍历索引且正好访问到其父节点,继续往下遍历的时候将可能找不到对应的记录(比如,被移动到了新分裂出来的索引页上),这就是索引的不一致状态(参考图3中的步骤(3))。

ARIES / IM 提出使用exclusive tree latch解决这个问题,即,SMO开始前对当前索引加exclusive tree latch,并在所有受SMO影响的索引页上设置SM标记 (SPLIT或SHRINK),以便其它访问这些索引页的任务能够知道SMO操作正在进行。一旦SMO结束,清除之前设置的SM标记,并释放exclusive tree latch。

ARIES / IM 提到,对于并发的遍历,插入和删除操作,当遍历索引的时候,如果遇到了受设置SM标签的索引页,需要尝试加shared tree latch,如果无法获得,说明SMO操作尚未结束,需要等待,直到SMO操作结束,这样做的原因是,

  • 对于插入和删除操作,如果允许它们对未完成的SMO影响的索引页进行修改,然后,执行插入和删除操作的事务提交,如果SMO失败回滚,之前已提交的修改将会丢失;
  • 对于遍历操作,如果一个非叶子节点的孩子节点正在进行SMO操作,从非叶子节点中得到的导向信息将是不可信的(参考上面提到的不一致状态,对于分裂操作,可能目标索引项在新分配的索引页上,而非叶子节点却指向正在分裂的索引页)。

虽然tree latch能够解决这个问题,但是其粒度较大,对高并发不友好。

SAP ASE则使用了一些更加巧妙的思路,包括,仅在受SMO影响的索引页上加address lock(基于缓存页地址加锁),且支持在这些索引页上做查询(即使SMO未完成),

  • 对于索引页分裂操作,仅对当前分裂的索引页和新分配的索引页加锁;
  • 对于索引页删除操作,仅对要删除的索引页和其前向页加锁);
  • 对于正在分裂的索引页,如果在当前页未找到符合条件的索引行,会查询其右邻居节点,继续遍历(到孩子节点层),直到找到目标索引行(类似于B-link tree)。

这样一来,大大减小了加锁粒度,能够在一个索引上同时支持多个SMO和查询操作,提升了对高并发的支持。

7. 日志和恢复(logging and recovery)

这里所讨论的日志和恢复基于商业数据库中广泛使用的ARIES (WAL based)。

日志(logging),可以分为两大类,逻辑日志和物理日志。

物理日志,侧重数据修改在存储层的表示,比如,受影响的数据在数据页上的偏移量(offset),以及数据的拷贝。对于有些操作,比如更新操作(UPDATE),甚至还需要包含修改前后的两份数据。所有操作(包括page compact)都需要记录日志,产生的日志量较大。

逻辑日志,侧重修改数据的逻辑操作,受影响的数据所在的位置是无关紧要的。比如,对于一条数据行的插入,只需要记录插入的数据,而不需要记录具体插入到了哪个数据页的什么位置。Page compact是没有意义的,所以不需要写日志,虽然日志数量减少了,但是基于这种方法的恢复更加复杂,商业数据库很少使用这种方案。

上述两种方案的优缺点都比较明显,主流数据库更多采用的是Physiological logging,这种方案是物理日志和逻辑日志两种方案的综合,大致思想是,“physical to a page, logical within a page”,日志里面记录数据所在的页面号和行号,不需要记录页内偏移量。这样一来,即使数据在页内发生移动,也不会影响recovery,触发page compact的时间点也更加灵活。

下面是一个简单的例子,给出了一个事务中执行的SQL语句,以及生成的日志(基于未创建索引的数据表)。

Transaction statements and logs

恢复,包括runtime rollback和restart recovery,ARIES在其论文中对recovery的过程做了详细的阐述(restart recovery复杂些,包括analysis,redo和undo阶段),由于内容较多且简单易懂,这里不再赘述,接下来我们看一个有意思的索引操作的回滚场景。

一般情况下,恢复的过程比较直接,给定一条日志,获得里面记录的页面号和行号,读取该数据页,在对应的数据行上做redo或者undo。但是对于索引页,这种方法在有些情况下是行不通的,请看下面的场景(为了便于说明,这里略去了数据部分的日志,仅包含索引部分的日志),

Transaction statements and index page split

插入4的时候导致索引页100分裂,索引页101被分配出来,4插入到了索引页101上,分裂过程结束。接下来,外层事务回滚,倒序扫描日志并逐个回滚,

  • INSRT <page 101, row 2, value 4>

​ 回滚:读取页101,把第二行设置为删除状态(logical delete)

  • End_top_action

​ 回滚: 已提交的SMO操作,忽略所有该事务的日志,直到begin_top_action.

  • INSERT <page 100, row 3, value 3>

​ 回滚:读取页100,但是发现最大行号是2,无法找到row 3

在这种情况下,原始的数据插入位置由于索引页分裂会发生变化,导致数据插入时产生的日志中记录的位置信息失效,所以对于索引来讲,这些记录的位置信息是不可靠的。

为了解决这个问题,ARIES / IM 采用了逻辑回滚(logical undo)的方法。例如,在回滚日志 INSERT <page 100, row 3, value 3>的时候,使用日志中记录的值3作为搜索条件,查找B-tree索引,在 <page 101, row 1> 中找到目标行,然后进行删除。

同样道理,在restart recovery的redo阶段也使用同样的方法重做B-tree上的改动。

8. 结束语

本文简单介绍了设计和实现支持高并发的高性能B-tree索引所要考虑的一些问题,以及业界部分数据库厂商使用的解决方案,随着新技术和新硬件的出现(如NUMA架构,多核CPU和闪存),一些数据库厂商提出了提升B-tree索引性能的新思路,如latch free B-tree, cache-line friendly B-tree等,比较有代表性的有MS SQL Server的Bw-tree,其使用latch free的方式对index page进行delta update,消除了因加latch引起的开销(index page contention,cache line invalidation等)。

参考文献

  1. C. Mohan, Don Handerle. ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging.
  2. C. Mohan, Frank Levine. ARIES/lM: An Efficient and High Concurrency index Management Method Using Write-Ahead Logging.
  3. Goetz Graefe. A Survey of B-Tree Logging and Recovery Techniques.
  4. Goetz Graefe. A Survey of B-Tree Locking Techniques.
  5. Database system with methods providing high-concurrency access in B-Tree structures. https://patents.google.com/patent/US6792432B1/en.

Database · 理论基础 · ARIES/IM (一)

$
0
0

《ARIES/lM: An Efficient and High Concurrency index Management Method Using Write-Ahead Logging》是IBM发表的ARIES系列论文中的一篇,文章提出了一种针对B+树索引的并发控制和故障恢复算法,该算法以ARIES为基础,本文将介绍该算法中并发控制相关的内容,下一篇文章将介绍其中故障恢复相关内容并进行整体的分析,囿于个人水平,如有错误,敬请指正。

基本概念

概念解释
data-only lock将对索引键加锁实现为对相应数据记录加锁的锁方式,区别于index-specific lock
lock 和 latch的区别lock用于保护数据库内容,是常说的表锁/行锁中的锁概念,latch用于保护内存数据结构等临界资源,与mutex, rwlock是一类概念
commit duration 和instant durationlock的持有周期,commit duration 在commit后释放锁,instant duration 在相应操作完成后释放锁
索引节点和索引页针对B+树,本文混用两个概念,不做特别区分

索引数据结构

文章针对的索引按照B+树的方式进行组织,索引树提供单点检索(fetch),范围检索(fetch next),插入(insert)和删除(delete) 4项基本操作,同时索引更新可能导致树结构变更操作即SMOs(structure modification operations)。

索引并发控制和故障恢复面临的问题

索引的并发控制和故障恢复主要面临以下问题:

  1. 如何最小化并发更新索引的影响,提高并发效率
  2. 如何处理死锁问题
  3. 如何支持不同粒度的锁,锁对象该如何选取
  4. 如何解决phantom problem
  5. 唯一索引被删除后,如何确保该事务提交前该索引不被其他事务添加
  6. 如何让SMO和其他操作正确且高效地并行
  7. 如何记录索引变更日志使得故障恢复更加高效
  8. 当SMO操作部分成功时,故障恢复如何进行
  9. 如何保证SMO成功后,即使进行SMO的事务回滚,SMO不会回滚
  10. 事务回滚时如何检测SMO操作导致的数据移动,以保证回滚正确进行

ARIES/IM 能够很好的应对以上问题

并发控制逻辑

Lock逻辑

ARIES/IM 中主要使用data-only lock 逻辑,不同操作下的Lock操作如下表

表1 Lock逻辑

 Next KeyCurrent Key
fetch & fetch next S for commit duration
insertX for instant duration 
deleteX for commit duration 

使用data-only lock,索引管理器在insert/delete时不需要对current key进行显式加锁,而由数据记录管理器对相应数据记录加的X lock。而在fetch/fetch next操作时,索引管理器对current key加S lock,数据记录管理器则不需要再对相应数据记录进行加S lock。因此相比于index-specific lock, data-only lock的锁开销和维护成本更小。该锁逻辑的执行以及作用会在后文相应位置说明。

Latch 逻辑

ARIES/IM 使用index page latch保证数据的物理一致性, 在遍历索引树时使用latch coupling逻辑,其加锁主要步骤为:

Step 1: 对索引树根节点加S latch, 令当前节点等于root
Step 2: 进行检索操作,确定目标子节点,并将当前节点设置为目标子节点,检测当前节点,若
				(1)当前节点为叶节点且操作为fetch/fetch next,对当前节点加S latch;
				(2)当前节点为页节点且操作为insert/delete,对当前节点加X latch;
				(3)当前节点为非页节点,对当前节点加S latch,释放父节点的S latch, 重复Step 2

该逻辑保证任意时刻,一次索引树遍历最多只对两个节点持有锁(latch),同时加锁严格有序,可以避免死锁发生。SMO时的附加逻辑在后文介绍。

SMO

在ARIES/IM 中,SMO前必须先获取索引树的X latch,因此SMO操作是严格串行化的。 此外为了控制SMO和其他操作的并发,ARIES/IM 为每个节点引入了SM_BIT标志,SM_BIT为1时表明SMO 操作正在进行。因此索引树遍历的逻辑拓展为:

S latch root and note root’s page_LSN
Child := Root
Parent := NIL
Descend:
IF child is a leaf AND Op is (insert OR delete) 
THEN
  X latch child
ELSE 
  S latch child
Note child’s page LSN
IF child is a nonleaf page THEN
	IF nonempty child & 
		((input key <= highest key in child) 
			OR (input key > highest key in child) & SM-Bit=0))
  THEN
			IF parent <> NIL THEN unlatch parent
			Parent := Child
			Child := Page-Search (Child)
			Go to Descend
	ELSE 
			Unlatch parent & child
			S latch tree for instant duration
					/* Wait for unfinished SMO to finish */ 
			Unwind recursion as far as necessary based on noted page_LSNs and go down again
ELSE 
	CASE Op 
			Fetch:  . . .  /* invoke fetch action routine */
			Insert: . . .  /* invoke insert action routine */
			Delete: . . .  /* invoke delete action routine */
	END

条件nonempty child & ((input key <= highest key in child) OR (input key > highest key in child) & SM-Bit=0))表明若没有SMO或本次操作的目标key不受SMO的影响,加锁逻辑与前文latch coupling逻辑一致,因此该算法允许部分操作和SMO操作并行执行,而对于必须等待SMO的操作,先释放其已持有的锁,防止阻塞其他事务,同时请求索引树的S latch,并等待。当锁获取成功后,通过调用链上记录的page_lsns判断相应节点是否变更,以快速恢复到断点,并继续进行。若已经遍历到叶节点,则执行相应的操作。

Fetch

Find requested or next higher key (maybe on NextPage) 
Unlatch parent 
Request conditional S lock on found key 
IF lock granted 
THEN 
	Unlatch child & return found key 
ELSE 
	Note LSN and key position and unlatch child 
	Request unconditional 1ock on key 
  Once lock granted backup & search if needed

该算法首先查询目标key或者下一个最小key(next higher key)然后对查找到的key(目标key或者next higher key)加S lock(若next higher key不存在,此时将对该索引树一个特定的key加锁,该key表征索引尾部边界)。之所以要对next higher key加commit-duration的S lock,是为了防止目标Key正被某个未提交的事务删除,或目标key被其他事务插入(回顾表1,insert/delete都需要对next higher key 加X lock)。若查找过程中出现跨页,获取下一个页的latch,同时前一个页的latch不能释放,否则前一个页可能会被插入数据。当锁获取失败时,记录page_lsn,和key的位置然后释放latch并等待lock,当lock获取成功后,根据page_lsn判断页是否被修改,然后执行相应操作。

Fetch next

Fetch next流程首先定位一个key,然后不断顺序遍历符合范围查找要求的key,每次返回一个符合要求的key时都需要记录相应的page_lsn。下一次查找时需要比较page_lsn,若page_lsn变更,说明页被修改,此时需要按照Fetch逻辑查找下一个结果,否则继续顺序遍历。同样的,fetch next每次找到一个key,都需要加S lock,以防止phantom problem。

Insert

IF SM_Bit | Delete-Bit = 1 
THEN
	Instant S latch tree, set Bits to 0
Unlatch parent
Find key > insert key & X lock it for instant duration
	/* Next key may be on next page ‘/
	/* Latch next page while holding latch on current page*/
	/* Lock next key while holding latch on next page also*/
	/* Unlatch next page after acquiring next key lock */
Insert key, 1og and update page-LSN
Release child latch

插入时,若目标key已经存在,且索引为唯一索引,则返回错误,并持有该key的S lock至事务提交,以保证RR;若不为唯一索引,则执行插入操作。

若目标key不存在,则insert操作将定位到next higher key或该索引的尾部边界key。此时将对该key加X lock,持有周期为instant-duration。此后,若索引为唯一索引,则必须检测目标key是否正在被删除。该目的通过判断Delete-Bit(见后文)实现,若Delete-Bit为1,则说明该页存在删除操作,此时需要等待获取索引树的S latch。

此外,插入操作可能面临需要SMO的情况,相应操作流程如下:

Fix needed neighboring pages in buffer pool

X latch tree and unlatch parent
IF key delete THEN do it as "delete"

RememberLSN of last log record of transaction
Perform SMO at leaf, set SMO-Bit = 1, 
		modify neighboring pages’ pointers, 
		log, and unlatch pages

Propagate SMO to higher levels setting SMO-Bit to 1
Write DummyCLR pointing to remembered LSN

Reset SM_Bit to 0 in affected pages (optional)

IF key insert THEN do it as "insert"
Release tree latch

首先获取相应的页,然后对索引树加X latch,同时释放父节点的latch。然后进行split操作,完成后执行insert操作。

Delete

IF SMO-Bit = 1 THEN 
	Instant S latch tree and set SMO-Bit to 0
Set Delete_Bit to 1 

Unlatch parent
Find key > delete key & X lock it for commit duration 

IF delete key is smallest/largest on page 
THEN 
	S latch tree and set Delete-Bit to 0 

Delete key, log and update page_LSN 
Release child latch and tree latch, if held

删除操作开始时需要设置delete_bit标志位,以阻止insert的进行。删除操作还必须找到next higher key,并加X lock,且持有周期为commit-duration以防止phantom problem。此外若被删除的key是索引的边界,还需要对索引树加S latch,该操作是因为索引边界变更会影响故障恢复,具体逻辑将会在下一篇描述故障恢复逻辑时进行描述。如果删除的key是页的最后一个key,则需要SMO,进行一次merge操作,其逻辑见insert部分。

分析

整体而言,针对索引并发控制中面临的问题,ARIES/lM的解决方案如下

问题解决方案
1 如何最小化并发更新索引的影响,提高并发效率使用latch coupling逻辑,使得同一时间持有的latch不超过两个
2 如何处理死锁问题使用latch coupling逻辑,严格有序地加锁
3 如何支持不同粒度的锁,锁对象该如何选取data-only lock,将对索引加锁实现为对相应数据记录加锁。锁的实际粒度等于数据记录中锁的粒度
4 如何解决phantom probleminsert/delete操作都对next higher key加X lock
5 唯一索引被删除后,如何确保该事务提交前该索引不被其他事务添加对next higher key 加X lock,且持有周期为commit-duration
6 如何让SMO和其他操作正确且高效地并行引入SM_BIT和tree latch,同时串行化SMO

AliSQL · 引擎特性 · Fast Query Cache 介绍

$
0
0

背景介绍

Query Cache(查询缓存)是 MySQL 为了提高查询性能而实现的一种缓存策略,基本思想是:对于每个符合条件的查询语句,直接对结果集进行缓存;当下次查询命中时,直接从缓存中取出对应的结果集返回,不需要经过 MySQL 的 Parser / Optimize / Execute / Storage Engine 等复杂的代码执行路径;通过节约 CPU 资源来达到查询加速的目标,是一项非常实用的技术。

QC

但是 MySQL 社区对于 Query Cache 设计和实现上不够理想,存在较多严重的问题,主要包括:

  • 并发处理不够好,无法利用多核能力,并发越高性能退化越严重;
  • 当缓存命中率较低时,性能无提升甚至会出现严重退化;
  • 内存管理问题,内存利用率低并且回收不及时,造成内存浪费;

基于以上问题,MySQL 上的 Query Cache 功能一直没有得到很好的应用,在最新的 MySQL 8.0 版本中,社区直接移除了相关的代码,去掉了此功能。

Fast Query Cache

通过对 Query Cache 的深入分析,阿里云数据库团队对 Query Cache 进行了重新设计,实现了一种更优雅的 Query Cache 机制,从以下几个方面解决了上述问题:

  • 优化并发控制:优化全局锁同步机制,重新设计并发场景下的同步问题,采用无锁机制,能够充分利用多核的处理能力,保证高并发场景下的性能;
  • 优化缓存机制:动态检测缓存利用率,实时调整缓存策略,解决命中率偏低或读写混合等场景下的性能退化问题;
  • 优化内存管理:优化内存预分配机制,采用更加灵活的动态内存分配机制,无效内存及时回收,保证内存的真实利用率;

相比原生的 Query Cache,Fast Query Cache 在保证缓存命中时查询性能的同时,充分控制了缓存带来的副作用,用户可以在不同的业务场景中安心地开启 Fast Query Cache 功能,对查询进行加速。

性能测试

测试环境

  • 测试实例规格:RDS MySQL 5.7 版本 4C8G 独享规格;
  • 测试工具:sysbench;
  • 数据量:25 * 40000,250MB;
  • 测试说明:分别测试不同场景下,QC-OFF(关闭 Query Cache),MySQL-QC(MySQL 原生 Query Cache),RDS-QC(Fast Query Cache)的性能差异(QPS);

高命中率场景

测试场景为 oltp_read_only,用例中包含返回单条记录的点查和返回多条记录的范围查询,将 Query Cache 的 query_cache_size参数设置为 512MB,内存比较充足,整体命中率可以达到 80%+。此场景下主要关注不同并发下 Fast Query Cache 的性能提升效果。

并发数QC-OFFMySQL-QCRDS-QCMySQL-QC 性能提升RDS-QC 性能提升
150996467702226.83%37.71%
8287822865145017-0.46%56.41%
16353333109966770-11.98%88.97%
32348642761067623-20.81%93.96%
64355032751875981-22.49%114.01%
128357442773380396-22.41%124.92%
256356852773880925-22.27%126.78%
512353082739879323-22.40%124.66%
1024340442686175742-22.10%122.48%

测试结果显示,随着并发数的增加,MySQL 原生 Query Cache 的性能出现明显退化;Fast Query Cache 的性能则会不断提升,峰值性能提升能够达到 120%+。

低命中率场景

测试场景为 oltp_read_only,用例中包含返回单条记录的点查和返回多条记录的范围查询,将 Query Cache 的 query_cache_size参数设置为 16MB,内存明显不足,缓存命中率只有 10% 左右。此场景下内存不足会导致缓存项的大量淘汰,影响性能,主要关注不同并发下 Fast Query Cache 的性能退化程度。

并发数QC-OFFMySQL-QCRDS-QCMySQL-QC 性能提升RDS-QC 性能提升
1500447275199-5.54%3.90%
8287952254228578-21.72%-0.75%
16354552406435682-32.13%0.64%
32345262133035871-238.22%3.90%
64355141979136051-44.27%1.51%
128359831951936253-45.75%0.75%
256356951916836337-46.30%1.80%
512351821842035972-47.64%2.25%
1024339152016834546-40.53%1.86%

测试结果显示,内存不足时,MySQL 原生 Query Cache 的性能退化更加明显,最多出现了接近 50% 的性能损失;Fast Query Cache 充分平衡了此种场景,不会带来任何额外的性能损失。

读写混合场景

测试场景为 oltp_read_write,用例中每个事务内都有对表的更新操作将 Query Cache 的 query_cache_size参数设置为 512MB,内存相对比较充足。此场景下缓存基本处于失效状态,主要关注不同并发下 Fast Query Cache 的性能衰减程度。

并发数QC-OFFRDS-QCRDS-QC 性能提升
141524098-1.30%
82135921195-0.77%
162602025548-1.81%
322759526996-2.17%
642922928733-1.70%
1282926528828-1.49%
2562991129616-0.99%
5122914828816-1.14%
10242920428824-1.30 %

测试结果显示,Fast Query Cache 在读写混合场景下不会造成过多的性能衰减,整体对性能的影响控制在 2% 以内。

实践指南

适用场景

Fast Query Cache 的目的是提高读操作的性能,所以建议在读多写少的场景下进行开启,或者使用 SQL_CACHE hint 对读多写少的表单独开启。如果写多读少,数据的更新非常频繁,不建议开启 Query Cache 功能。

需要注意,开启 Fast Query Cache 带来的性能提升和缓存命中率直接相关。在全局开启前建议查看一下 InnoDB Buffer Pool 的命中率情况(100 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests * 100),如果 Buffer Pool 的命中率低于80%,则不建议开启。表级的读写比可以参照 AliSQL 提供的对象统计功能(TABLE_STATISTICS)中的数据,对读写比较高的表通过 SQL_CACHE hint 显式开启 Fast Query Cache。

使用方式

Fast Query Cache 完全兼容 MySQL 原生 Query Cache 的使用逻辑,可通过 query_cache_type参数控制 Query Cache 的开启方式:

  • query_cache_type = OFF,禁用 Query Cache;
  • query_cache_type = ON,默认使用 Query Cache,可通过 SQL_NO_CACHE hint 跳过缓存;
  • query_cache_type = DEMAND,默认不启用 Query Cache,通过 SQL_CACHE hint 开启缓存;

query_cache_type参数支持会话级修改,用户可以根据真实业务场景进行灵活设置:

  • 对于更新频繁,写多读少等不适合开启 Query Cache 的场景,应将 query_cache_type全局设置为 OFF;
  • 对于数据量较小,访问模式比较固定,且命中率较高的场景,可以将 query_cache_type全局设置为 ON;
  • 对于数据量较大,访问模式不固定,且命中率无法保障的场景,可以将 query_cache_type全局设置为 DEMAND,仅对指定的语句通过 SQL_CACHE hint 使用 Query Cache;

缓存大小设置

Fast Query Cache 的性能提升效果和 Cache 的命中率紧密相关,具体的测试情况如下:

  • 测试实例规格:RDS MySQL 5.7 版本 4C8G 独享规格;
  • 测试工具:sysbench;
  • 数据量:100 * 400000,10GB;
  • 测试说明:测试场景为 oltp_point_select,Special 分布(模拟 28 分布,20% 热点),64 并发下测试不同 query_cache_size大小对于性能的影响;
query_cache_size(MB)QC-OFFRDS-QCRDS-QC 命中率RDS-QC 性能提升
64982369944022%1.23%
1289823611415545%16.21%
2569823614066872%43.19%
5129823615126082%53.98%
10249823615386684%56.63%
20489823615959787%62.46%
40969823616941292%72.45%

测试结果显示,Fast Query Cache 在各种 query_cache_size的设置下都不会引起性能退化。由于测试场景是主键点查,所以对缓存命中率要求比较高,Fast Query Cache 的性能提升随着命中率的提高不断提升,当命中率达到 90%+ 时,整体提升效果比较明显。注意:由于阿里云 RDS MySQL 已经对主键点查做了优化,所以 Fast Query Cache 在主键点查下的性能提升不如范围查询明显;对于范围查询或带 ORDER BYGROUP BY等关键字的查询语句,当缓存命中率低于90%时,也能节约大量的 CPU,带来较大的性能提升。

为了保证 Cache 的命中率,对于 query_cache_size的设置,可以参考以下建议:

  • 如果能够评估结果集大小,query_cache_size可以设置为 20% * 结果集大小;
  • 如果无法评估结果集大小,quesry_cache_size可以设置为 20% * innodb_buffer_pool_size
  • 由于内存总量有限,建议 query_cache_sizeinnodb_buffer_pool_size同步调整,避免实例 OOM;

此外,用户也可以通过查看 Fast Query Cache 提供的状态值信息,查看当前的真实命中率,根据情况动态地对 query_cache_size进行调整。

总结

Query Cache 的根本目的是用 Memory 换 CPU 和 IO,通过缓存结果集,减少 MySQL 真实执行查询的 CPU 和 IO 消耗。对于读多写少、数据更新不频繁的场景,使用好 Query Cache 可以取得非常好的性能。Fast Query Cache 已在阿里云 RDS MySQL 5.7 最新版本发布,欢迎试用。

MySQL · 源码分析 · 8.0 · DDL的那些事

$
0
0

引言

MySQL 5.6/5.7 的用户可能会发现,create一张表过程中发生crash,重启后创建一张同名新表时,会发现创建失败。这是因为过去MySQL 5.6/5.7 的DDL操作不是原子的,一张表创建失败后会遗留下ibd文件。MySQL 8.0 对DDL的实现重新进行了设计,最大的改进是DDL操作支持原子特性。由于MySQL是一个多引擎数据库,在engine层(SE)和server层(SL)都维护了自己的数据字典对象,刚接触MySQL 8.0 DDL相关源码,可能会比较困难,特别是SL中用到较多的模板方法。本文对MySQL 8.0 中DDL的进行导读性介绍,并对一些理解上比较困难的地方进行详细阐述。

为了实现DDL原子性,MySQL 8.0 使用Innodb表存储相关的数据字典信息,这些数据字典表默认不可见,查看方法参照https://dev.mysql.com/doc/refman/8.0/en/data-dictionary-schema.html

mysql> SELECT name, schema_id, hidden, type FROM mysql.tables where schema_id=1 AND hidden='System';
+------------------------------+-----------+--------+------------+
| name                         | schema_id | hidden | type       |
+------------------------------+-----------+--------+------------+
| catalogs                     |         1 | System | BASE TABLE |
| character_sets               |         1 | System | BASE TABLE |
| check_constraints            |         1 | System | BASE TABLE |
| collations                   |         1 | System | BASE TABLE |
| column_statistics            |         1 | System | BASE TABLE |
| column_type_elements         |         1 | System | BASE TABLE |
| columns                      |         1 | System | BASE TABLE |
| dd_properties                |         1 | System | BASE TABLE |
| events                       |         1 | System | BASE TABLE |
| foreign_key_column_usage     |         1 | System | BASE TABLE |
| foreign_keys                 |         1 | System | BASE TABLE |
| index_column_usage           |         1 | System | BASE TABLE |
| index_partitions             |         1 | System | BASE TABLE |
| index_stats                  |         1 | System | BASE TABLE |
| indexes                      |         1 | System | BASE TABLE |
| innodb_ddl_log               |         1 | System | BASE TABLE |
| innodb_dynamic_metadata      |         1 | System | BASE TABLE |
| parameter_type_elements      |         1 | System | BASE TABLE |
| parameters                   |         1 | System | BASE TABLE |
| resource_groups              |         1 | System | BASE TABLE |
| routines                     |         1 | System | BASE TABLE |
| schemata                     |         1 | System | BASE TABLE |
| st_spatial_reference_systems |         1 | System | BASE TABLE |
| table_partition_values       |         1 | System | BASE TABLE |
| table_partitions             |         1 | System | BASE TABLE |
| table_stats                  |         1 | System | BASE TABLE |
| tables                       |         1 | System | BASE TABLE |
| tablespace_files             |         1 | System | BASE TABLE |
| tablespaces                  |         1 | System | BASE TABLE |
| triggers                     |         1 | System | BASE TABLE |
| view_routine_usage           |         1 | System | BASE TABLE |
| view_table_usage             |         1 | System | BASE TABLE |
+------------------------------+-----------+--------+------------+

源码导读

SL的相关源码都存放在sql/dd中,目录下dd_xxx.cc / dd_xxx.h,为SL数据字典操作的入口。以创建表以及表数据字典对象为例,dd_table.ccdd::create_table创建一个server层的,表的,数据字典对象。其类型为dd::Table,定义在sql/dd/types/table.h中。而dd::Table真正的实现放在sql/dd/impl/table_impl.h / sql/dd/impl/table_impl.cc中。

sql/dd/impl/cache中主要是操作数据字典缓存,大都是模板类或模板方法,模板成员为SL中的各种数据字典对象(如dd::Table)。sql/dd/impl/cache/dictionary_client.cc中实现了缓存对象的操作,包括从缓存获取、存取、丢弃等。

SE的相关源码主要存放在storage/innobase/dict/中,主要的innodb层的数据字典内存对象是dict_index_t, dict_table_t

数据字典持久化

由于篇幅有限,不能将所有的细节都阐述清楚,下面以创建一张基础表为例,简单介绍存数据字典的调用过程:

rea_create_base_table
  --> dd::create_tabl  // 创建数据字典对象dd::Table
     --> dd::create_dd_system_table / dd::create_dd_user_table
  --> dd::cache::Dictionary_client::store<dd::Table> // 存入数据字典信息
     --> dd::cache::Storage_adapter::store<dd::Table>
       --> dd::Table_impl::store_attributes
	   --> dd::Raw_record::update
       --> dd::Table_impl::store_children
  --> ha_create_table // 实际创建表

这里比较难以理解的是store_attributesstore_childrenstore_attributes会存本数据字典对象的属性值; dd::Raw_record::update调用Innodb接口,将数据字典修改持久化。同时dd::cache::Storage_adapter::store<dd::Table>会递归调用store_children,将与建表相关的数据字典表的也存起来:

bool Table_impl::store_children(Open_dictionary_tables_ctx *otx) {
  // ...
  return Abstract_table_impl::store_children(otx) ||
         m_indexes.store_items(otx) || m_foreign_keys.store_items(otx) ||
         m_partitions.store_items(otx) || store_triggers(otx) ||
         (!skip_check_constraints && m_check_constraints.store_items(otx));
}

即调用dd::Table_impl::store_children会同时将indexesforeign_keys等数据字典表更新

原子DDL

介绍MySQL 8.0 原子DDL的资料有很多,这里以创建表为例,简单阐述创建表过程与原子DDL相关的关键流程。

为了实现原子DDL,MySQL 8.0借助mysql.innodb_ddl_log,将DDL过程划分成以下四个步骤:

  1. Prpare: 写DDL logs到mysql.innodb_ddl_log,如对应一个per-file-table建表操作,会写入write_delete_space_log, write_remove_cache_log, write_free_tree_log三条记录。
  2. Perform: 执行DDL操作
  3. Commit: 更新数据字典并且提交数据字典事务
  4. Post-DDL: 重放并且移除mysql.innodb_ddl_log对应的DDL logs,重命名和移除大数据文件都放在这个过程中完成。

MySQL 8.0 借助Innodb的事务特性完成DDL操作。所有的DDL操作都会起一个innodb层的事务,对数据字典进行增删查改的操作,如果DDL事务执行失败,则进行回滚,这部分与正常事务是一致的。但由于DDL操作涉及文件操作,MySQL 8.0 通过DDL logs来辅助实现原子性,相关源码主要在storage/innobase/log/log0ddl.cc。当创建一张数据表的时候,需要写一条删除索引树的记录。假设DDL操作的事务为TRX_A,则在write_free_tree_log中另起一个事务TRX_B插入一条记录free tree log并马上提交,随后以TRX_A的身份将这条记录删除。当事务commit的时候会有两种情况:

  1. DDL事务TRX_A正常提交,mysql.innodb_ddl_log中没有记录,不需要进行重放
  2. DDL事务TRX_A回滚,则mysql.innodb_ddl_log中存在一条free tree log,重放删除对应的数据文件,并移除这条记录(Log_DDL::replay)

对于drop操作,其处理逻辑也是类似。但write_free_tree_log中会以DDL事务TRX_A的身份写入一条free tree log,则在Post-DDL中会真正地将表删掉并移除对应的记录。

所以,mysql.innodb_ddl_log只会在DDL过程中才会有记录。

Online DDL

Online DDL指的是在DDL期间,允许用户进行DML操作。并非所有DDL操作都支持Online DDL,官方文档 https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html详细展示了所有DDL在执行期间是否允许进行DML操作。

这里阐述rebuild表时,Online DDL的关键流程():

  1. 持有MDL_SHARED_UPGRADABLE锁,检测表时是否存在
  2. 升级到MDL_EXCLUSIVE锁,禁止读写
  3. 更新数据字典对象
  4. 分配row_log对象记录增量
  5. 生成新表
  6. 降级为MDL_SHARED_UPGRADABLE,允许对原表进行读写(wait_while_table_is_used
  7. 用DDL事务的上下文,扫描老表的中,对该事务可见的数据,并用merge排序,最后插入到新表,详细见row_merge_build_indexes。PS:由于使用到merge外排序,所以会受到innodb_sort_buffer_size的限制。
  8. 在执行期间,原表的读写不阻塞,增量应用到原表中,并且会记录到row_log中
  9. 进入commit阶段,升级到MDL_EXCLUSIVE锁,禁止读写
  10. 在新表中apply row_log里的增量(row_log_apply)
  11. 更新innodb的数据字典表
  12. 提交DDL事务
  13. 重命名新表的ibd文件

值得注意的是:

  1. 这里row_log与mysql.innodb_ddl_log不一样,前者的源码主要在storage/innobase/row/row0log.cc,后者的相关代码在storage/innobase/log/log0ddl.cc。row_log是一个append形式的增量日志,里面有三种类型的记录,insert/update类型的如下所示:
    type: insert/update, 1 byte
    old pk extra size, 1 byte
    old pk, old pk size
    extra size, 1~2 byte
    extra data, extra size
    old record data, data size
    virtual column
    
  2. 上述第10步之后,即将锁升级为互斥锁,则可以认为已经没有事务在操作原表,否则无法升锁。所以在后续apply logs的时候,当发现delete类型的记录,则直接对新表进行purge,而不是标记为deleted mark。
  3. 了解上述inpalce rebuild类型的流程后,可以对官方DDL操作是否能支持online ddl有直观的理解。如drop primary key不支持online ddl,而drop primary的同时add primary却支持online ddl,两种方式都需要rebuild。但是对于前者,采用的是copy的方式而不是inplace,主要原因是新表没有主键,这种情况下innodb会默认为其申请一个隐藏的rowid作为主键,当apply logs的时候,由于log中的增量没有rowid信息,所以没法进行下去。

MySQL · 内核分析 · InnoDB Buffer Pool 并发控制

$
0
0

InnoDB 对buffer pool 的访问除了包含了用户线程会并发访问buffer pool 以外, 同时还有其他的后台线程也在访问buffer pool, 比如刷脏, purge, IO 模块等等, InnoDB 主要通过5个不同维度的mutex, rw_lock, io_fix 进行并发访问的控制

  1. free/LRU/flush list Mutex
  2. hash_lock rw_lock (在5.6 之前, 只会有一个大的buffer pool Mutex)
  3. BPageMutex mutex
  4. io_fix, buf_fix_count
  5. BPageLock lock rw_lock

free/LRU/flush list Mutex

所有的page 都在free list, LRU list, flush list 上, 所以大部分操作第一步如果需要操作这几个list, 需要首先获得这几个list mutex, 然后在进行IO 操作的过程, 是会把list Mutex 放开.

InnoDB 也是尽可能让持有LRU list, flush list 的时间尽可能短

hash_lock rw_lock

一个buffer pool instance 下面的buffer block 都存在一个hash table上

这个hash_lock 是这个hash_table 上面的slot/segment 的rw_lock, 也就是这个hash table 有多少个slot, 就有多少个这个hash_lock, 这个hash_lock 的引入也是为了尽可能的减少锁冲突, 这样可以做到需要写入的时候锁的只是这个hash_table 的slot/segment 级别

这里InnoDB 优化这个lock level 从整个hash table 到hash table slot 级别, 在5.6 之前的版本, 是一个整个hash table mutex.

从代码里面可以看到, 总是先拿 hash_lock, 然后才是 buffer block mutex 或者是 page frame mutex

BPageMutex mutex

我们也叫做buffer block mutex, 在buf_block_t 结构体里面.

BPageMutex mutex 保护的是io_fix, state, buf_fix_count, state 等等变量, 引入这个mutex 是为了减少早期版本直接使用buffer pool->mutex 的开销

io_fix, buf_fix_count

io_fix, buf_fix_count 受 pager block mutex的保护.

io_fix 表示当前的page frame 正在进行的IO 操作状态, 主要有 BUF_IO_READ, BUF_IO_WRITE, BUF_IO_PIN.

buf_fix_count 表示当前这个block 被引用了多少次, 每次访问一个page 的时候, 都会对buf_fix_count++, 最后在mtr:commit() 的最后资源释放阶段, 会对这个buf_fix_count–, 进行资源的释放.

比如: 在flush 一个page 的时候, 会检测一个page 是否可以被flush, 这里为了减少拿 page frame rw_lock, 直接通过判断 io_fix 即可

if (bpage->oldest_modification == 0 ||
    buf_page_get_io_fix_unlocked(bpage) != BUF_IO_NONE) {
  return (false);
}

比如: 在检查一个block 能否被replace 的时候, 除了确定当前这个block io_fix == BUF_IO_NONE, 还需要确保当前没有其他的线程在引用这个block, 当然还需要保证当前block oldest_modification ==0. 来确定当前这个block 是否可以允许被replace

ibool buf_flush_ready_for_replace(buf_page_t *bpage) {
  if (buf_page_in_file(bpage)) {
    return (bpage->oldest_modification == 0 && bpage->buf_fix_count == 0 &&
            buf_page_get_io_fix(bpage) == BUF_IO_NONE);
  }
}

可以理解, 引入io_fix, buf_fix_count 是为了减少调用page frame rw_lock 的开销, 因为page frame 的调用是在btree search 的核心路径

如果io_fix 处于BUF_IO_READ, BUF_IO_WRITE 那我们可以知道, 当前page 处于IO 状态, 如果要进行replace, flush 操作是不可以的, 这样就不需要去获得page frame rw_lock, 然后再检查当前page frame 是否允许这样的操作

所以代码里面我们会看到在设置了io_fix 的状态以后, 我们就可以把之前的几个mutex, rw_lock 都完全放开, 因为被设置了io_fix 状态的page 是不可以从list 上面删除或者replace, 需要等IO 操作完成以后, 将io_fix 设置成BUF_IO_NONE 才可以进行操作

BPageLock lock rw_lock

在获得一个page 的函数buf_page_get_gen() 里面, 一般同时会执行获得这个page 的rw_lock 类型, 这里的rw_lock 值得是这个page frame rw_lock.

因此在buf_page_get_gen() 的最后, 是需要获得这个page 的rw_lock.

在InnoDB 访问btree 的过程中, btr_cur_search_to_nth_level() 函数里面, 在乐观访问的时候, 会对一个page 加s lock, 在有可能修改的时候, 先加sx lock, 然后确认要修改的时候加 x lock.

但是后台操作比如刷脏, 或者当前page frame 不在buffer pool 中, 同样需要拿 page frame rw_lock, 那么是会对前台的page 访问有非常大的性能影响. 因此上述的io_fix, page block mutex 也是为了尽可能减少持有page frame rw_lock 的机会

我们看到官方做了很多优化, 比如尽可能减少访问btree 的时候, 拿着btree index lock, 在访问btree 的时候, 不会像在5.6 时候一样, 拿着整个btree index lock, 尽可能的只拿着会引起树结构变化的子树. 比如引入sx lock, 在真正要修改的时候, 才会获得x lock 去修改btree. (其实引入sx lock 是对读取的优化, 对写入并没有优化. 因为持有sx lock 的时候, s lock 操作是可以进行的, 但是x lock 操作是不可以进行的. 跟原先需要修改就直接拿着x lock 对比, 允许更多的读取了, 但是x lock 和之前是一样的)

但是这些优化只是优化了用户访问路径上page frame rw_lock 的获取, 但是在后台的路径并没有过多的优化.

比如: page frame rw_lock 是在buf_page_io_complete 之后才会放开的

在page flush, read ahead 的时候, 在走simulated AIO 的时候, page 操作被放入队列即可, 但是并没有执行完成.

执行完成的通知是在simulated AIO fil_aio_wait:buf_page_io_complete() 里面完成, 在buf_page_io_complete() 操作里面, 会把page 上的rw_lock 给释放.

所以一个page 在进行IO 操作的时候, 是在调用simulated AIO 之前, 给page frame rw_lock 加 x/sx lock, 但是释放page frame rw_lock 需要等到IO 操作结束才可以完成, 而fio_io() 只是将IO 放到的队列中, 这个IO 并没有执行完成. 是在simulated io handler 的 fil_aio_wait() 函数里面, 这个操作才会完成, 然后调用buf_page_io_complete() 进行通知操作.

因此page frame 的rw_lock 的持有周期是整个异步IO 的周期, 直到IO 操作完成, 这个page frame 才会释放.

而page frame 的rw_lock 又是用户访问btree 路径上面的 btr_cur_search_to_nth_level() 必须要获得的lock, 因此就可能出现大量的page frame由于刷脏或者read ahead 的时候, 持有了page frame x lock/sx lock, 当用户的访问路径需要x/sx lock 的时候, 被堵塞住的情况.

这种堵塞住的情况, 如果是非leaf page 的时候, 影响会更明显, 而且目前InnoDB simulated AIO 的队列长度是*(n_read_thread + n_write_thread) * 256, 那么会可能出现大量的page 因为在IO 等待队列中等待, 造成更多的btree search 操作被堵住, 特别是如果底层存储IO latency 比较长的情况, 这里问题会更加的明显.

当然我们也通过simulated AIO 优化, copy page等等减少持有page frame 的时长.

buf_page_io_complete 主要做什么呢?

将page io_fix 设置成NONE, 表示这个page 的io 操作已经完成了

buf_page_set_io_fix(bpage, BUF_IO_NONE);

将page 上面的rw_lock 放开, 如果是read, 把 x lock 放开, 如果是write, 把sx lock 放开.

为什么是这样? 那么什么时候拿s lock?

读操作要拿 x lock 主要是为了避免多个线程同时去读这个page, 然后另外一个线程如果需要访问该page, 那么会通过buf_wait_for_read(block) 操作, 尝试给这个page frame 加s lock, 如果加成功, 这说明这个page 已经被获得了

总结:

free/LRU/flush List 相关mutex 主要是是否操作 list 时候持有.

而后面4个mutex 一般操作都是加hash_lock rw_lock, 然后获得buf block mutex, 放开hash_lock rw_lock, 然后修改 io_fix, buf_fix_count,然后放开 buf block mutex, 最后持有page frame rw_lock.

如上面所说寻找block 在hash table 的位置, 通过hash_lock slot 级别的Lock 来进行了优化, 减少了修改和查找hash table 的冲突

引入 buf block mutex, io_fix, buf_fix_count 将IO操作通过判断io_fix, buf_fix_count 避免不必要的获得page frame rw_lock 的开销.

具体代码流程

buf_page_init_for_read

以 buf_read_page_low() => buf_page_init_for_read() 来举例并发过程

  1. // 根据page_id 返回对应的buf_pool instance buf_pool_t *buf_pool = buf_pool_get(page_id);

    // 先尝试从LRU list 获得一个free block block = buf_LRU_get_free_block(buf_pool);

    // 持有我们说的第一层 LRU_list_mutex mutex_enter(&buf_pool->LRU_list_mutex);

  2. // 然后持有我们说的第二层 hash_lock hash_lock = buf_page_hash_lock_get(buf_pool, page_id); rw_lock_x_lock(hash_lock);

  3. // 持有page block mutex buf_page_mutex_enter(block);

  4. // 在持有page block mutex 的情况下, 会修改 block->state, io_fix 等等

    buf_page_init(buf_pool, page_id, page_size, block);

    buf_page_set_io_fix(bpage, BUF_IO_READ);

    // 将当前Block 加入到LRU list 中

    buf_LRU_add_block(bpage, TRUE /* to old blocks */);

    // 释放 LRU list mutex, 这里持有LRU list mutex 到现在, 是因为要把page block 加入到LRU list中

    mutex_exit(&buf_pool->LRU_list_mutex);

  5. // 这里给page frame 加了rw_lock x lock, // 保证同一时刻只会有一个线程从磁盘去读取这个page

    rw_lock_x_lock_gen(&block->lock, BUF_IO_READ);
    // 依次放开hash_lock rw_lock rw_lock_x_unlock(hash_lock); // page block mutex buf_page_mutex_exit(block);

buf_page_try_get_func

比如在 buf_page_try_get_func() 函数里面, 也是这样顺序获得mutex 的操作.

// 1. 首先获得这个bp, 因此这里不涉及到各个list 相关操作, 因此没有list // 相关Mutex buf_pool_t *buf_pool = buf_pool_get(page_id);

// 2. 获得这个page 在hash table 上面的slot 上面的block, // 同时在这个函数里面, 已经把这个hash_lock 给s lock 了 block = buf_block_hash_get_s_locked(buf_pool, page_id, &hash_lock);

// 3. 或者这个page block block mutex, 同时将这里的hash_lock 给释放 buf_page_mutex_enter(block); rw_lock_s_unlock(hash_lock);

// 4. 在持有page block mutex 之后, 给这个block buf_fix_count++, 同时把这个page block mutex 释放 // 这里设置了buf_fix_count 之后, 上述的mutex, rw_lock 都放开了, 因为这个page frame 在buf_fix_count != 0 的情况下, 是不能被replace 的, 会议在在buffer pool 里面, 因此后续的page frame s lock 操作可以放心操作

buf_block_buf_fix_inc(block, file, line); buf_page_mutex_exit(block);

// 5. 获得这个page frame 的rw_lock mtr_memo_type_t fix_type = MTR_MEMO_PAGE_S_FIX; success = rw_lock_s_lock_nowait(&block->lock, file, line);

在写入操作里面

buf_flush_page_and_try_neighbors

在执行刷脏的时候, 可能从LRU_list, flush_list 上面刷脏, 分别是

buf_do_LRU_batch, buf_do_flush_list_batch

这两个函数都会调用 buf_flush_page_and_try_neighbors 进行刷脏操作, 这里在进行具体page 刷脏操作过程中是会将 lru_list_mutex/flush_list_mutex 放开, 然后操作完成以后再持有

if (flush_type == BUF_FLUSH_LRU) {
  mutex_exit(&buf_pool->LRU_list_mutex);
}
if (flush_type == BUF_FLUSH_LRU) {
  mutex_exit(block_mutex);
} else {
  buf_flush_list_mutex_exit(buf_pool);
}
// 在进行具体flush 操作的时候, 是会将LRU_list_mutex/buf_flush_list mutex放开
*count += buf_flush_try_neighbors(page_id, flush_type, *count, n_to_flush);
if (flush_type == BUF_FLUSH_LRU) {
   mutex_enter(&buf_pool->LRU_list_mutex);
} else {
   buf_flush_list_mutex_enter(buf_pool);
}

具体的page flush 操作

buf_flush_try_neighbors => buf_flush_page


// 1. 首先获得 hash_lock rw_lock
/* We only want to flush pages from this buffer pool. */
bpage = buf_page_hash_get_s_locked(buf_pool, cur_page_id, &hash_lock);

// 2. 然后是获得page header mutex, 同事释放hash_lock 
block_mutex = buf_page_get_mutex(bpage);
mutex_enter(block_mutex);
rw_lock_s_unlock(hash_lock);

// => 进入buf_flush_page()

// 3. 修改 io_fix 设置成 BUF_IO_WRITE
buf_page_set_io_fix(bpage, BUF_IO_WRITE);
// 4. 放开buf block mutex
// 因为已经修改了 io_fixed 和 oldest_modification
// 因此到这里已经不需要持有任何mutex 了
mutex_exit(block_mutex);
// 5. 获得这个page frame 的 rw_lock
rw_lock_sx_lock_gen(rw_lock, BUF_IO_WRITE);
// 对这个page 进行flush 操作的时候, 不需要持有mutex
buf_flush_write_block_low(bpage, flush_type, sync);

MySQL · 源码分析 · 内部 XA 和组提交

$
0
0

XA 两阶段提交

在分布式事务处理中,全局事务(global transaction)会访问和更新多个局部数据库中的数据,如果要保证全局事务的原子性,执行全局事务 T 的所有节点必须在执行的最终结果上取得一致。X/Open 组织针对分布式事务处理而提出了 XA 规范,使用两阶段提交协议(two-phase commit protocol,2PC)来保证一个全局事务 T 要么在所有节点都提交(commit),要么在所有节点都中止。

提交协议

考虑一个全局事务 T 的事务协调器(transaction coordinator)是 C,当执行 T 的所有事务管理器(transaction manager)都通知 C 已经完成了执行,C 开始启动两阶段提交协议,分为 prepare 和 commit 两个阶段:

prepare 阶段

事务协调器 C 将一条 prepare 消息发送到执行 T 的所有节点上。当各个节点的事务管理器收到 prepare 消息时,确定是否愿意提交事务 T 中自己的部分:如果可以提交,就将所有与 T 相关的日志记录强制刷盘,并记录事务 T 的状态为 prepared,然后事务管理器返回 ready 作为应答;如果无法提交,就发送 abort 消息。

commit 阶段

当事务协调器 C 收到所有节点对 prepare 消息的回应后进入 commit 阶段,C 可以决定是将事务 T 进行提交还是中止,如果所有参与的节点都返回了 ready 应答,则事务 T 可以提交,否则,事务 T 需要中止。之后,协调器向所有节点发送 commit 或 abort 消息,各节点收到这个消息后,将事务最终的状态更改为 commit 或 abort,并写入日志。

优缺点

XA 使用两阶段提交协议的主要优点是原理简介清晰、实现方便。

主要缺点是各个节点需要阻塞等待事务协调器来决定提交或中止。如果事务协调器出现故障,那全局事务就无法获得最终的状态,各个节点可能需要持有锁并等待事务协调器的恢复,这种情况称为阻塞问题,因为事务 T 需要等待协调器恢复而被阻塞。

MySQL 内部 XA

我们知道 MySQL 存在两个日志系统:server 层的 binlog 日志和 storage 层的事务日志(例如,InnoDB 的 redolog 日志),并且支持多个存储引擎。这样产生的问题是,如何保证事务在多个日志中的原子性,即,要么都提交,要么都中止。

在单个 MySQL 实例中,使用了内部 XA 的方式来解决上述问题,其中,server 层作为事务协调器,而多个存储引擎作为事务参与者。

协调器对象

在实例启动时,执行初始化函数 init_server_components 中指定了谁来作为事务协调器:

	tc_log = &tc_log_dummy;
	...
	if (total_ha_2pc > 1 || (1 == total_ha_2pc && opt_bin_log)) {
		if (opt_bin_log)
	    tc_log = &mysql_bin_log;
	  else
	    tc_log = &tc_log_mmap;
	}

TC_LOG 这个抽象类的意思是 Transaction Coordinator Log,即 XA 事务协调者日志。以 TC_LOG 为基类实现了三种不同的事务协调器子类:

  • MYSQL_BIN_LOG 类:如果开启了 binlog,并且有事务引擎,则 XA 协调器为 mysql_bin_log 对象,使用 binlog 物理文件记录事务状态;
  • TC_LOG_MMAP 类:如果关闭了 binlog,且存在多个事务引擎,则 XA 协调器为 tc_log_mmap 对象,使用内存数据结构来记录事务状态;
  • TC_LOG_DUMMY 类:其他情况,则不需要 XA,tc_log 设置为 tc_log_dummy 对象,但是不做任何事情。

本文主要关注于如何通过内部 XA 保证 binlog 和 InnoDB redolog 的一致性,即,以 binlog 作为协调器的场景。

两阶段提交过程

MySQL 采用了如下的过程实现内部 XA 的两阶段提交:

  1. Prepare 阶段:InnoDB 将回滚段设置为 prepare 状态;将 redolog 写文件并刷盘;
  2. Commit 阶段:Binlog 写入文件;binlog 刷盘;InnoDB commit;

两阶段提交保证了事务在多个引擎和 binlog 之间的原子性,以 binlog 写入成功作为事务提交的标志,而 InnoDB 的 commit 标志并不是事务成功与否的标志。

在崩溃恢复中,是以 binlog 中的 xid 和 redolog 中的 xid 进行比较,xid 在 binlog 里存在则提交,不存在则回滚。我们来看崩溃恢复时具体的情况:

  1. 在 prepare 阶段崩溃,即已经写入 redolog,在写入 binlog 之前崩溃,则会回滚;

  2. 在 commit 阶段,当没有成功写入 binlog 时崩溃,也会回滚;

  3. 如果已经写入 binlog,在写入 InnoDB commit 标志时崩溃,则重新写入 commit 标志,完成提交。

崩溃恢复过程

当 XA 控制对象为 binlog 时,MYSQL_BIN_LOG::open_binlog 实现了纯虚函数 TC_LOG::open,作用是初始化并打开 XA 协调者,进入崩溃恢复流程。

首先通过 index 文件找到最后一个 binlog 文件,因为每次在 rotate 到新的 binlog 文件时,会保证没有正在提交的事务,然后将 redolog 进行一次刷盘,这样可以保证之前的 binlog 文件中的事务在 InnoDB 总是提交的。

崩溃恢复时,InnoDB 中会存在一些 prepared 状态的事务,但是还没有进入 committed 状态。调用 binlog_recover 函数,该函数使用 binlog 作为协调者来决定这些事务哪些需要回滚,哪些需要提交。

具体的,这个函数将最后一个 binlog 中完整写入的事务 XID 添加到一个 hash,这些 XID 标志着对应的事务已经完成。实现上,遍历并解析 binlog 文件中的每个 event,遇到 XID-event 时,将其中的 xid 提取出来并加入 hash。

接下来,通过 handler 接口中的 ha_recover 函数将这个 hash 传递给 InnoDB,以此告诉 InnoDB 哪些事务需要回滚。

	/*
	  Call ha_recover if and only if there is a registered engine that
	  does 2PC, ...
	 */
	if (total_ha_2pc > 1 && ha_recover(&xids)) goto err1;

在 InnoDB 拿到这个 hash 后,首先调用 innobase_xa_recover 函数得到 InnoDB 中处于 prepared 状态的 xid 集合,然后遍历其中每个 prepared 状态的事务,确定是否需要回滚:

	// recovery mode
	if (info->commit_list
	        ? info->commit_list->count(x) != 0
	        : tc_heuristic_recover == TC_HEURISTIC_RECOVER_COMMIT) {

	  // 1. 如果 XID 在 hash 里,说明 redolog 和 binlog 都已经完成了事务的刷盘,可以提交
	  hton->commit_by_xid(hton, &info->list[i].id);
	} else {
	
		// 2. 如果 XID 不在 hash 里,说明 redolog 完成刷盘,但是 binlog 还没有刷盘,2PC 没有成功,需要回滚
	  hton->rollback_by_xid(hton, &info->list[i].id);
	}

组提交 group commit

事务提交的顺序

MySQL 的内部 XA 机制保证了单个事务在 binlog 和 InnoDB 之间的原子性,接下来我们需要考虑,在多个事务并发执行的情况下,怎么保证在 binlog 和 redolog 中的顺序一致?

早期解决方法

在 MySQL 5.6 版本之前,使用 prepare_commit_mutex 对整个 2PC 过程进行加锁,只有当上一个事务 commit 后释放锁,下个事务才可以进行 prepare 操作,这样完全串行化的执行保证了顺序一致。

存在的问题是,prepare_commit_mutex 的锁机制会严重影响高并发时的性能,在每个事务执行过程中, 都会至少调用 3 次刷盘操作(写 redolog,写 binlog,写 commit),多个小 IO 是非常低效的方式。

组提交

为了提高并发性能,肯定要细化锁粒度。MySQL 5.6 引入了 binlog 的组提交(group commit)功能,prepare 阶段不变,只针对 commit 阶段,将 commit 阶段拆分为三个过程:

  1. flush stage:多个线程按进入的顺序将 binlog 从 cache 写入文件(不刷盘);
  2. sync stage:对 binlog 文件做 fsync 操作(多个线程的 binlog 合并一次刷盘);
  3. commit stage:各个线程按顺序做 InnoDB commit 操作。

其中,每个阶段有 lock 进行保护,因此保证了事务写入的顺序。

实现方法是,在每个 stage 设置一个队列,第一个进入该队列的线程会成为 leader,后续进入的线程会阻塞直至完成提交。leader 线程会领导队列中的所有线程执行该 stage 的任务,并带领所有 follower 进入到下一个 stage 去执行,当遇到下一个 stage 为非空队列时,leader 会变成 follower 注册到此队列中。

这种组提交的优势在于锁的粒度减小,三个阶段可以并发执行,从而提升效率。

5.7 组提交优化:

延迟写 redo 到 group commit 阶段

MySQL 5.6 的组提交逻辑中,每个事务各自做 prepare 并写 redo log,只有到了 commit 阶段才进入组提交,因此每个事务的 redolog sync 操作成为性能瓶颈。

在 5.7 版本中,修改了组提交的 flush 阶段,在 prepare 阶段不再让线程各自执行 flush redolog 操作,而是推迟到组提交的 flush 阶段,flush stage 修改成如下逻辑:

  1. 收集组提交队列,得到 leader 线程,其余 follower 线程进入阻塞;
  2. leader 调用 ha_flush_logs 做一次 redo write/sync,即,一次将所有线程的 redolog 刷盘;
  3. 将队列中 thd 的所有 binlog cache 写到 binlog 文件中。

这个优化是将 redolog 的刷盘延迟到了 binlog group commit 的 flush stage 之中,sync binlog 之前。通过延迟写 redolog 的方式,为 redolog 做了一次组写入,这样 binlog 和 redolog 都进行了优化。

为了更好的理解组提交的过程,可以参考这篇文章中的图解:[图解MySQL]MySQL组提交(group commit)

代码分析

在 MySQL 8.0 中,binlog 组提交逻辑的主要函数是 MYSQL_BIN_LOG::ordered_commit ,此时引擎层事务已经 prepare,但是还没有写 redolog,并发情况下多个线程将不断涌入这个函数中。

ordered_commit 函数明确地分为了三个阶段,组提交过程中,每个阶段的进入都要调用 MYSQL_BIN_LOG::change_stage 函数。

首先,将当前线程加入 stage 对应的 queue,如果队列为空,则当前线程成为这个 stage 的 leader 线程,负责整个 queue 的执行,如果队列非空,则当前线程进入阻塞状态,等待 commit 完成再被唤醒。

change_stage 的流程是:线程先入队,在释放上一阶段的 lock,最后申请下一阶段的 lock。这样保证了每个时刻,每个 stage 都只有一个线程在执行,从而保证了线程的顺序性。反之,如果先释放上一个 stage lock,再申请入队,后面的线程就可能赶上来,同时申请入队,从而无法保证顺序性。

bool MYSQL_BIN_LOG::change_stage(THD *thd MY_ATTRIBUTE((unused)),
                                 Stage_manager::StageID stage, THD *queue,
                                 mysql_mutex_t *leave_mutex,
                                 mysql_mutex_t *enter_mutex) {
  // 入队,并释放上一个 stage lock
  // 这里 stage leader 的选举通过 leave_mutex 保证
  // Follower 线程会等待直到被 Leader 线程唤醒,然后返回 true
  if (!stage_manager.enroll_for(stage, queue, leave_mutex)) {
    DBUG_ASSERT(!thd_get_cache_mngr(thd)->dbug_any_finalized());
    DBUG_RETURN(true);
  }
	...
    
  // leader 申请 stage lock,不会有多个线程同时申请
  // 因为,只有每个 stage queue 的 leader 会申请 stage lock
  // 第一个执行到这里的线程是 leader,后续线程都会在上面的 enroll_for 中等待
  if (need_lock_enter_mutex)
    mysql_mutex_lock(enter_mutex);
}

具体入队操作在函数 Stage_manager::enroll_for 中:

bool Stage_manager::enroll_for(StageID stage, THD *thd,
                               mysql_mutex_t *stage_mutex) {
                               
 	// 参数 queue 可能是一个由 leader 带领的链表
  
  // If the queue was empty: we're the leader for this batch
  // 主要执行链表操作
  bool leader = m_queue[stage].append(thd);

  // 释放上一个 stage 的lock
  if (stage_mutex && need_unlock_stage_mutex) mysql_mutex_unlock(stage_mutex);

  /*
    If the queue was not empty, we're a follower and wait for the
    leader to process the queue. If we were holding a mutex, we have
    to release it before going to sleep.
  */
  if (!leader) {
    mysql_mutex_lock(&m_lock_done);
    // m_cond_done 条件变量,用于接收 signal
    // thd->tx_commit_pending 判断提交是否成功
    while (thd->tx_commit_pending) mysql_cond_wait(&m_cond_done, &m_lock_done);
    mysql_mutex_unlock(&m_lock_done);
  }
  return leader;
}

Flush 阶段

change_stage 后 leader 线程进入到 flush 阶段,leader 线程获得 LOCK_log 锁,然后执行 MYSQL_BIN_LOG::process_flush_stage_queue 函数:

int MYSQL_BIN_LOG::process_flush_stage_queue(my_off_t *total_bytes_var,
                                             bool *rotate_var,
                                             THD **out_queue_var) {
  my_off_t total_bytes = 0;

  // leader 线程在这里取出了当前的 flush queue,将 flush queue 重置为空
  // 这个时刻之后进入 ordered_commit 的第一个线程会在 change_stage 里面成为 leader
  // 但是会在 change_stage 里等待当前线程释放 flush 阶段的 lock
  // 因此,当前执行 flush 的时候,新的 flush queue 中会不断积累多个 follower thd
  THD *first_seen = stage_manager.fetch_queue_for(Stage_manager::FLUSH_STAGE);

  // redo log 批量刷盘 
  // log_buffer_flush_to_disk 将 innodb 中 prepared 状态的事务刷入 redolog
  // 即,这些事务已经填充了 mtr,并已经申请 logbuffer 的位置了
  // 通知 log_writer 线程和 log_flusher 线程将 redolog 刷到指定 LSN
  ha_flush_logs(true);
  
  // binlog 批量刷盘 
  /* Flush thread caches to binary log. */
  for (THD *head = first_seen; head; head = head->next_to_commit) {
    // 队列中每一个 thd 都进行 cache 刷盘
    // 每个线程有两个 binlog cache,分别对应事务型 event 和非事务型 event
    std::pair<int, my_off_t> result = flush_thread_caches(head);
    // 更新总共的写入bytes
    total_bytes += result.second;
  }

  *out_queue_var = first_seen;
  *total_bytes_var = total_bytes;

  // 如果 binlog 文件超过了 max_size,则准备 rotate binlog,设置 rotate_var=true
  if (total_bytes > 0 &&
      (m_binlog_file->get_real_file_size() >= (my_off_t)max_size ||
       DBUG_EVALUATE_IF("simulate_max_binlog_size", true, false)))
    *rotate_var = true;
}

如果在这一步完成后数据库崩溃,由于协调者 binlog 中不保证有该组事务的记录,所以 MySQL 可能会在重启后回滚该组事务。

Sync 阶段

flush 阶段的 leader 线程带着一个链表进入 sync 阶段的 change_stage 函数,可能成为 sync leader,也可能成为 follower,因为上一个进入 sync stage 的线程,可能还在等更之前的 sync 线程释放 lock,从而在 sync 队列里堆积,这里相当于多个 flush queue 组成了一个 sync queue。

  // 每次执行到这里,说明 group leader 进来了,即一个新的group  
  // sync_counter 是之前进入这里,但是没 sync 的次数,不包括这一次
  // get_sync_period() = sync_binlog 表示几个 group 提交一次,而不是几个 thd 提交一次

	// if 判断逻辑:
  // 1. 如果 (sync_counter + 1 >= get_sync_period()),说明这次会执行 sync
  // 那么,稍等一会,更多的 thd 进入到 sync queue,再一同提交
  // 2. 如果这次不执行 sync,没有必要等待
  //
  // 特殊情况:
  // 1. sync_binlog=0:每次 sync 都要等待,增加组内 thd 个数
  // 2. sync_binlog=1:每次 sync 都要等待,因为每次都要提交

  if (!flush_error && (sync_counter + 1 >= get_sync_period()))
    stage_manager.wait_count_or_timeout(
        opt_binlog_group_commit_sync_no_delay_count,
        opt_binlog_group_commit_sync_delay, Stage_manager::SYNC_STAGE);

  // leader 线程在这里取出了当前的 sync queue
  // 当前 queue sync 的时候,新的 sync queue 中会积累多个 flush queue
  // 可以预料,没到达 sync_period 的时候,当前线程快速通过 sync stage
  // 新的 sync queue 比较短就会被取出
  // 如果到达了 sync_period,新的 sync queue 就会积压更多的 flush queue
  final_queue = stage_manager.fetch_queue_for(Stage_manager::SYNC_STAGE);

  if (flush_error == 0 && total_bytes > 0) {
    // 每调用一次 sync 把 sync_counter +1
    // 如果 sync_counter 没到达 sync_period 直接进入 commit stage
    std::pair<bool, bool> result = sync_binlog_file(false);
    sync_error = result.first;
  }

如果在这一步完成后数据库崩溃,由于协调者 binlog 中已经有了事务记录,MySQL 会在重启后通过 flush 阶段中 redolog 刷盘的数据继续进行事务的提交。

Commit 阶段

依次将 redolog 中已经 prepare 的事务在引擎层提交,commit 阶段不用刷盘,因为 flush 阶段中的 redolog 刷盘已经足够保证数据库崩溃时的数据安全了。

commit 阶段队列的作用是承接 sync 阶段的事务,完成最后的引擎提交,使得 sync 可以尽早的处理下一组事务,最大化组提交的效率。

  // opt_binlog_order_commits 是否由 leader 一起做 commit
  if (opt_binlog_order_commits &&
      (sync_error == 0 || binlog_error_action != ABORT_SERVER)) {

    // 由commit leader对队列中的所有thd进行commit
    if (change_stage(thd, Stage_manager::COMMIT_STAGE, final_queue,
                     leave_mutex_before_commit_stage, &LOCK_commit)) {
      DBUG_RETURN(finish_commit(thd));
    }
    THD *commit_queue =
        stage_manager.fetch_queue_for(Stage_manager::COMMIT_STAGE);
		
    // 对 queue 中每个线程执行 ha_commit_low,完成事务提交
    process_commit_stage_queue(thd, commit_queue);
    mysql_mutex_unlock(&LOCK_commit);
  } else {
    // 如果不进行 order commit,那么 sync leader 还没有 change stage
    // 需要我们手动释放 sync lock
    if (leave_mutex_before_commit_stage)
      mysql_mutex_unlock(leave_mutex_before_commit_stage);
  }

  // 通知队列中所有等待的线程
  // 通过 thd->tx_commit_pending 标志来通知 thd
	// follower 线程被唤醒后调用 finish_commit
  // 如果发现事务没有提交,会调用 ha_commit_low, 此时就不能保证 commit 的顺序了。
  stage_manager.signal_done(final_queue);

  (void)finish_commit(thd);
 
  // do_rotate 标志位在 flush 阶段被设置
  if (DBUG_EVALUATE_IF("force_rotate", 1, 0) ||
      (do_rotate && thd->commit_error == THD::CE_NONE &&
       !is_rotating_caused_by_incident)) {
    bool check_purge = false;
    mysql_mutex_lock(&LOCK_log);
    // 进行 binlog rotate 操作
    int error = rotate(false, &check_purge);
    mysql_mutex_unlock(&LOCK_log);
  }

MySQL · 插件分析 · Connection Control

$
0
0

本文基于mysql 8.0.13.

插件介绍

MySQL 5.6.35 开始提供Connnection Control 插件;

如果客户端在连续失败登陆一定次数后,那么此插件可以给客户端后续登陆行为的响应增加一个延迟。该插件可以防止恶意暴力破解MySQL账户。该插件包含以下2个组件:

- CONNECTION_CONTROL:检查mysql的刚建立连接的响应是否需要延迟,并且提供一些系统变量和状态参数;方便用户配置插件和查看此插件基本的状态。
- CONNECTION_CONTROL_FAILED_LOGIN_ATTEMPTS:提供了一个INFORMATION_SCHEMA类型的表,用户在此表中可以查看更详细关于登陆失败连接的信息。

基本使用

插件的安装与卸载

安装可以通过配置文件静态安装,也可以在MySQL中动态安装。

静态安装

-- 配置文件增加以下配置
[mysqld]
plugin-load-add                                 = connection_control.so

动态安装

-- 插件动态安装启用
mysql> INSTALL PLUGIN CONNECTION_CONTROL SONAME 'connection_control.so';
mysql> INSTALL PLUGIN CONNECTION_CONTROL_FAILED_LOGIN_ATTEMPTS SONAME 'connection_control.so';

-- 验证是否正常安装
mysql> SHOW PLUGINS;

卸载

-- 插件卸载
UNINSTALL PLUGIN CONNECTION_CONTROL;
UNINSTALL PLUGIN CONNECTION_CONTROL_FAILED_LOGIN_ATTEMPTS;

更多关于插接件安装/卸载的信息 请点击

插件参数

  • connection_control_failed_connections_threshold:失败登陆次数达到此值后触发延迟。
    • 值域:[0, INT_MAX32(2147483647)],0表示关闭此功能。
    • 默认值:3
  • connection_control_max_connection_delay:登陆发生延迟时,延迟的最大时间;此值必须大于等于connection_control_min_connection_delay
    • 值域:[1, INT_MAX32(2147483647)]
    • 默认值:INT_MAX32
    • 单位:毫秒
  • connection_control_min_connection_delay:登陆发生延迟时,延迟的最小时间,此值必须小于等于connection_control_max_connection_delay
    • 值域:[1000, INT_MAX32(2147483647)]
    • 默认值:1000
    • 单位:毫秒

基本原理

  • Connection Control 插件通过订阅MYSQL_AUDIT_CONNECTION_CLASSMASK 来处理 MYSQL_AUDIT_CONNECTION_CONNECT(完成认证后触发)和MYSQL_AUDIT_CONNECTION_CHANGE_USER(完成COM_CHANGE_USER RPC后触发)子事件;通过这两种子事件的处理来检查给客户端发送回包时是否需要延迟。
  • Connection Control 插件通过 LF hash来存储不同账户的失败登陆信息。LF hash中的key为user@host **,这里的userhost**将遵循以下条件:
    • 如果在MySQL的security context有proxy user信息,那么这个信息将用于userhost
    • 否则,查看security context是否有priv_user 和 priv_host信息,如果有则用于userhost
    • 否则,将security context中已经连接的user 和 host信息用于userhost
  • LF hash的更新:对于每次失败的登陆通过user@host **的key值对其value加1;对于每次成功的登陆,如果需要延迟,处理完延迟后将user@host **从LF hash中删除。
  • 为什么在达到connection_control_failed_connections_threshold失败登陆次数后的第一次成功登陆需要延迟?
    • 这其实还是出于对攻击者开销的考虑;如果成功登陆后马上返回,不需要延迟,那么攻击者就可以使用更少的连接数,进一步攻击者所消耗的资源就会更少;为了增加攻击者的开销,在连续失败登陆后的第一次成功登陆,还是会产生延迟。
  • 具体延迟的时间如何计算?
    • 一旦连续的失败登陆次数超过设定阈值,那么就会产生延迟,并且延迟随着失败次数增加而增加,上限为connection_control_max_connection_delay;具体的计算方式如下:
    • MIN ( MAX((failed_attempts - threshold), MIN_DELAY), MAX_DELAY)

实现分析

从上一小节的基本原理我们知道Connection Control插件主要是通过订阅处理MYSQL_AUDIT_CONNECTION_CONNECT与MYSQL_AUDIT_CONNECTION_CHANGE_USER事件来实现的。

主要处理流程如下:

//创建一个新线程,处理新连接
handle_connection() in connection_handler_per_thread.cc
|
| //准备工作
->thd_prepare_connection() in sql_connect.cc
  | 
  | //进行登陆操作
  ->login_connection() in sql_connect.cc
    |
    | //对此连接的有效性进行验证
    ->check_connection() in sql_connect.cc
      |
  		| //验证登陆
  		->acl_authenticate() in sql_authentication.cc
  		|
  		| //对登陆连接事件进行处理
      ->mysql_audit_notify() in sql_audit.cc
        |
  			| //对登陆连接事件进行处理,并获得错误码
        ->mysql_audit_notify() in sql_audit.cc
          |
  				| //获取需要处理登陆事件的插件
  				->mysql_audit_acquire_plugins() in sql_audit.cc
  				|
  				| //将连接事件分发,并按照需求是都获取插件处理的返回值
          ->event_class_dispatch_error() in sql_audit.cc
            |
  					| //将连接事件分发
            ->event_class_dispatch() in sql_audit.cc
              |
  						| // 调用插件的相关处理函数处理连接事件
              ->plugins_dispatch() in sql_audit.cc
              	|
  							| //检查当前插件是否需要处理此事件
  							->check_audit_mask()in sql_audit.cc
  							|
  							| //connection_control处理连接事件
              	->connection_control_notify() in connection_control.cc
              	  |
                  | //依次遍历订阅了连接事件的订阅者处理此事件
              	  ->notify_event() in connection_control_coordinator.cc
              	    |
                  	| //处理连接事件
              	    ->notify_event() in connection_delay.cc

下面我们主要看一下最终Connection Control插件是怎么处理连接事件的。


/**
  @brief  Handle a connection event and if requried,
  wait for random amount of time before returning.
  We only care about CONNECT and CHANGE_USER sub events.
  @param [in] thd                THD pointer
  @param [in] coordinator        Connection_event_coordinator
  @param [in] connection_event   Connection event to be handled
  @param [in] error_handler      Error handler object
  @returns status of connection event handling
    @retval false  Successfully handled an event.
    @retval true   Something went wrong.
                   error_buffer may contain details.
*/

bool Connection_delay_action::notify_event(
    MYSQL_THD thd, Connection_event_coordinator_services *coordinator,
    const mysql_event_connection *connection_event,
    Error_handler *error_handler) {
  
  ...

	// 只关注CONNECT与CHANGE_USER事件
  if (subclass != MYSQL_AUDIT_CONNECTION_CONNECT &&
      subclass != MYSQL_AUDIT_CONNECTION_CHANGE_USER)
    DBUG_RETURN(error);

  RD_lock rd_lock(m_lock);

  int64 threshold = this->get_threshold();

  // 拿到当前阈值检查阈值是否有效,DISABLE_THRESHOLD=0
  if (threshold <= DISABLE_THRESHOLD) DBUG_RETURN(error);

  int64 current_count = 0;
  bool user_present = false;
  Sql_string userhost;

  make_hash_key(thd, userhost);

  DBUG_PRINT("info", ("Connection control : Connection event lookup for: %s",
                      userhost.c_str()));

  // 获取到当前失败登陆的次数
  user_present = m_userhost_hash.match_entry(userhost, (void *)&current_count)
                     ? false
                     : true;

  // 如果失败次数超过阈值,无论这次连接成功与否,都需要延迟
  // 同时更新统计信息
  if (current_count >= threshold || current_count < 0) {
    
    ulonglong wait_time = get_wait_time((current_count + 1) - threshold);

    if ((error = coordinator->notify_status_var(
             &self, STAT_CONNECTION_DELAY_TRIGGERED, ACTION_INC))) {
      error_handler->handle_error(
          ER_CONN_CONTROL_STAT_CONN_DELAY_TRIGGERED_UPDATE_FAILED);
    }

    // 在产生延迟时,需要释放读写锁,以减少锁的粒度
    // 防止阻塞对于IS table的数据访问
    rd_lock.unlock();
    conditional_wait(thd, wait_time);
    rd_lock.lock();
  }

  if (connection_event->status) {
    
    // 如果此次登陆失败,那么更新LF Hash
    if (m_userhost_hash.create_or_update_entry(userhost)) {
      error_handler->handle_error(
          ER_CONN_CONTROL_FAILED_TO_UPDATE_CONN_DELAY_HASH, userhost.c_str());
      error = true;
    }
  } else {
    
    // 如果此次登陆成功并且LF Hash中有数据,那么就删除LF Hash中的数据
    if (user_present) {
      (void)m_userhost_hash.remove_entry(userhost);
    }
  }

  DBUG_RETURN(error);
}

小结

1,通过分析Connection Control处理流程与具体实现,我们可以知道插件是如何来处理连接事件的。

2,该插件虽然可以防止恶意暴力破解MySQL账户,但是可能会浪费MySQL的资源;

- 比如如果短时间内有大量的恶意攻击,该插件虽然可以防止破解mysql账户,但是会消耗主机资源(每一个连接创建一个线程);
- 如果这里使用了线程池,虽然可以避免消耗主机资源,但是等线程池中的线程被消耗光,再有新连接来就会拒绝服务。

MySQL · 引擎特性 · 基于GTID复制实现的工作原理

$
0
0

GTID (Global Transaction IDentifier) 是全局事务标识。它具有全局唯一性,一个事务对应一个GTID。唯一性不仅限于主服务器,GTID在所有的从服务器上也是唯一的。一个GTID在一个服务器上只执行一次,从而避免重复执行导致数据混乱或主从不一致。

在传统的复制里面,当发生故障需要主从切换时,服务器需要找到binlog和pos点,然后将其设定为新的主节点开启复制。相对来说比较麻烦,也容易出错。在MySQL 5.6里面,MySQL会通过内部机制自动匹配GTID断点,不再寻找binlog和pos点。我们只需要知道主节点的ip,端口,以及账号密码就可以自动复制。

GTID的组成部分:

GDIT由两部分组成:GTID = source_id:transaction_id。 其中source_id是产生GTID的服务器,即是server_uuid,在第一次启动时生成(sql/mysqld.cc: generate_server_uuid()),并保存到DATADIR/auto.cnf文件里。transaction_id是序列号(sequence number),在每台MySQL服务器上都是从1开始自增长的顺序号,是事务的唯一标识。例如:3E11FA47-71CA-11E1-9E33-C80AA9429562:23 GTID 的集合是一组GTIDs,可以用source_id+transaction_id范围表示,例如:3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5 复杂一点的:如果这组 GTIDs 来自不同的 source_id,各组 source_id 之间用逗号分隔;如果事务序号有多个范围区间,各组范围之间用冒号分隔,例如:3E11FA47-71CA-11E1-9E33-C80AA9429562:23,3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5

GTID如何产生:

GTID的生成受GTID_NEXT控制。

在主服务器上,GTID_NEXT默认值是AUTOMATIC,即在每次事务提交时自动生成GTID。它从当前已执行的GTID集合(即gtid_executed)中,找一个大于0的未使用的最小值作为下个事务GTID。同时在实际的更新事务记录之前,将GTID写入到binlog(set GTID_NEXT记录)。 在Slave上,从binlog先读取到主库的GTID(即get GTID_NEXT记录),而后执行的事务采用该GTID。

GTID的工作原理:

GTID在所有主从服务器上都是不重复的。所以所有在从服务器上执行的事务都可以在bnlog找到。一旦一个事务提交了,与拥有相同GTID的后续事务都会被忽略。这样可以保证从服务器不会重复执行同一件事务。

当使用GTID时,从服务器不需要保留任何非本地数据。使用数据都可以从replicate data stream。从DBA和开发者的角度看,从服务器无保留file-offset pairs以决定如何处理主从服务器间的数据流。

GTID的生成和使用由以下几步组成:

主服务器更新数据时,会在事务前产生GTID,一同记录到binlog日志中。

binlog传送到从服务器后,被写入到本地的relay log中。从服务器读取GTID,并将其设定为自己的GTID(GTID_NEXT系统)。

sql线程从relay log中获取GTID,然后对比从服务器端的binlog是否有记录。

如果有记录,说明该GTID的事务已经执行,从服务器会忽略。

如果没有记录,从服务器就会从relay log中执行该GTID的事务,并记录到binlog。

GTID相关的变量

GTID_NEXT:

SESSION级别变量,表示下一个将被使用的GTID。

  • Scope : Session
  • Dynamic : Yes
  • Type : Enumeration
  • Default Value : AUTOMATIC
  • Valid Values :
-- AUTOMATIC: 使用自动产生的下一个GTID。
-- ANONYMOUS: 事务没有GTID, 只使用 file and position 作为标识。
-- UUID:NUMBER:GTID in UUID:NUMBER format.

GTID_MODE:

Log 是否使用GTID或使用anonymous。anonymous transaction用binlog file 和position来标识事务。

  • Scope : Global
  • Dynamic : Yes
  • Type : Enumeration
  • Default Value : OFF
  • Valid Values
    -- OFF:新的和复制事务都使用anonymous。
    -- OFF_PERMISSIVE:新的事务都使用anonymous,而复制事务可以使用GTID或anonymous。
    -- ON_PERMISSIVE:复制事务都使用anonymous,而新事务可以使用GTID或anonymous。
    -- ON: 新的和复制事务都使用GTID
    

GTID_EXECUTED:

包含已经在该实例上执行过的事务; 执行RESET MASTER 会将该变量置空; 我们还可以通过设置GTID_NEXT在执行一个空事务,来影响GTID_EXECUTED。使用 SHOW MASTER STATUS and SHOW SLAVE STATUS,其中Executed_Gtid_Set会显示GTID_EXECUTED里的GTIDs。5.7.7之前,GTID_EXECUTED可以是seesion变量。它包含当前session写入缓存的一组事务。

  • Scope : Global, Session
  • Dynamic : No
  • Type : String

GTID_PURGED:

已经被删除了binlog的事务,它是GTID_EXECUTED的子集,只有在GTID_EXECUTED为空时才能设置该变量,修改GTID_PURGED会同时更新GTID_EXECUTED和GTID_PURGED的值。

  • Scope : Global
  • Dynamic : Yes
  • Type : String

GTID_OWNED:

表示正在执行的事务的GTID以及其对应的线程ID。

  • Scope : Global, Session
  • Dynamic : No
  • Type : String

如果GDIT_OWNED是全局变量,它包含所有当前服务器上正在使用的GTIDs和使用它们的线程IDs。这个变量主要用于多线程从服务器复制,从而可以查看一个事务是否已经被另一个线程处理。这个线程会拥有所处理事务的ownership。@@global.grid_owned会显示出GTID和它的owner。当事务处理完成,线程会释放ownership. 如果GDIT_OWNED是session变量,它包含一个seesion正在使用的GTID。这个变量对测试和debug会很有帮助。

Reference:

https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.htmlhttps://dev.mysql.com/doc/refman/5.6/en/replication-options-gtids.html


AliSQL · 内核特性 · Binlog In Redo

$
0
0

背景

MySQL-8.0在InnoDB的性能方面做了很多改进,其中一个非常重要的改进是对Redo的改进。 MySQL-8.0对Redo的写和持久化(Flush)过程进行了重新的设计,因此MySQL-8.0上,写的性 能有很大的提升。但是这个性能的提升是在关闭Binlog的情形下的性能提升。当开启Binlog 后,性能的提升并不明显。详细的性能对比,感兴趣的同学可以访问Dimitri博客。他的文 章《MySQL Performance: 8.0 RW & Binlog impact》对开启binlog和关闭binlog的性能做了对比。

Binlog是MySQL高可用、备份的基础。绝大多数的场景下,都需要开启Binlog。因此AliSQL 团队对Binlog的性能优化方面做了很多的探索和尝试,希望在开启Binlog时,也能够有很好 的性能提升。

Commit过程中的IO瓶颈

Commit Process

如上图所示,在事务的提交过程中有两次存储IO的写操作。第一次是Redo的写操作,将事务 的Prepare状态持久化。第二次是Binlog的写操作,将事务的Binlog Events持久化。这是一 个精心设计的过程,2次IO操作,保证了Binlog和引擎中数据的一致性。但是在事务的提交 过程中,存储IO的写操作是一个比较慢的过程,尤其对于网络存储更是如此。2次IO写操作 对事务的提交性能有很大的影响。

那么有没有办法在减少1次IO的情况下,又能保证Binlog和数据的一致性呢?答案是有的, 而且无论去掉Redo的Sync还是Binlog的Sync都可以保证Binlog和数据的一致性。

Binlog In Redo

最终我们选择去掉Binlog Sync。这个方案中需要将Binlog写入InnoDB Redo Log。因此称作 Binlog In Redo。之所以选择这个方案,除了性能更好之外,也是因为这个设计对Polar DB 也非常重要。

设计

design

  • 当事务提交时,将事务的Binlog Events写入到Redo中,然后将Redo持久化。而Binlog文件 则采用异步的方式,用单独的线程周期性的持久化到存储中。因此事务的提交过程中,减少 了一次IO。

  • 在主机宕机发生时,Binlog中可能会丢失Binlog Events. 重新启动时,Recovery过程会用 Redo Log中的Binlog Events来补齐Binlog文件。

  • 这个设计从数据保护上保持了双1配置的含义,从性能上则去掉了Binlog的刷盘。由于减少 了一次IO操作,性能得到了提升,RT变的更小。Binlog文件刷盘次数的减少,极大地减少了 文件系统因文件长度实时变化带来的fsync压力,也提升了文件系统的性能。

性能

测试环境

RDS规格: 32Core, 64G Ram, ESSD存储。

测试工具: sysbench

oltp_update_non_index

oltp_update_non_index qpsoltp_update_non_index latency

oltp_insert

oltp_insert qpsoltp_insert latency

oltp_write_only

oltp_write_only qpsoltp_write_only latency

olpt_update_non_index和oltp_insert都属于单语句事务。oltp_write_only是多语句事务, 包含2个UPDATE,一个DELETE,一个INSERT。olpt_update_non_index和oltp_insert的事务提 交次数比oltp_write_only要多很多,所以olpt_update_non_index和oltp_insert的性能提 升比oltp_write_only更为明显。

Binlog Fsync 次数对比

fsync times

开起binlog in redo功能时,Binlog fsync的次数要少非常多。

结论

Binlog In Redo功能在不损失可靠性的前提下,减少了1次存储IO. 在不超过256并发的情况 下,Binlog In Redo功能对性能的提升和延迟的降低都非常显著。对绝大多数的实际使用场 景来说,这个功能的效果非常明显。

Binlog In Redo 已经在RDS 8.0 20200430版本中发布,欢迎使用。

MySQL · 内核特性 · InnoDB btree latch 优化历程

$
0
0

(一般在数据库里面latch 指的是物理Lock, Lock 指的是事务的逻辑lock, 这里混用)

在InnoDB 的实现中, btree 主要有两种lock: index lock 和 page lock

index lock 就是整个Index 的lock, 具体在代码里面就是 dict_index->lock

page lock 就是我们在btree 里面每一个page 的变量里面都会有的 lock

当我们说btree lock的时候, 一般同时包含 index lock 和 page lock 来一起实现

在5.6 的实现里面比较简单,btree latch 大概是这样的流程

  1. 如果是一个查询请求
    • 那么首先把btree index->lock S LOCK

    • 然后直到找到 leaf node 以后, 对leaft node 也是 S LOCK, 然后把index-> lock 放开

      Imgur

  2. 如果是一个修改leaf page 请求
    • 同样把btree index-> lock S LOCK
    • 然后直到找到leaf node 以后, 对leaf node 执行 X LOCK, 因为需要修改这个page. 然后把index->lock 放开. 到这里又分两种场景了, 对于这个page 的修改是否会引起 btree 的变化
      • 如果不会, 那么很好, 对leaf node 执行了X LOCK 以后, 修改完数据返回就可以

      • 如果会, 那么需要执行悲观插入操作, 重新遍历btree.

        对btree inex 加X LOCK, 执行btr_cur_search_to_nth_level 到指定的page.

        因为leaft node 修改, 可能导致整个沿着leaf node 到root node 的btree 都会随着修改, 因此必须让其他的线程不能访问到, 因此需要整个btree 加X LOCK, 那么其他任何的查询请求都不能访问了, 并且加了index X LOCK 以后, 进行record 插入到page, 甚至可能导致上一个Level 的page 也需要改变, 这里需要从磁盘中读取数据, 因此可能有磁盘IO, 这就导致了加X LOCK 可能需要很长一段时间, 这段时间sread 相关的操作就都不可访问了

        这里具体的代码在 row_ins_clust_index_entry

        首先尝试乐观的插入操作

        err = row_ins_clust_index_entry_low( 0, BTR_MODIFY_LEAF, index, n_uniq, entry, n_ext, thr, &page_no, &modify_clock);

        然后这里如果插入失败, 再尝试悲观的插入操作,

        return(row_ins_clust_index_entry_low( 0, BTR_MODIFY_TREE, index, n_uniq, entry, n_ext, thr, &page_no, &modify_clock));

        从这里可以看到, 唯一的区别在于这里latch_mode = BTR_MODIFY_LEAF 或者 BTR_MODIFY_TREE. 并且由于btr_cur_search_to_nth_level 是在函数 row_ins_clust_index_entry_low 执行, 那么也就是尝试了乐观操作失败以后, 重新进行悲观插入的时候, 需要重新遍历btree

        Imgur

从上面可以看到, 5.6 里面只有对整个btree 的index lock, 以及在btree 上面的leaf node page 会有lock, 但是btree 上面non-leaf node 并没有 lock.

这样的实现带来的好处是代码实现非常简单, 但是缺点也很明显由于在SMO 操作的过程中, 读取操作也是无法进行的, 并且SMO 操作过程可能有IO 操作, 带来的性能抖动非常明显, 我们在线上也经常观察到这样的现象.

所以有了官方的改动, 其实这些改动在5.7 就引入, 我们这里以8.0 为例子:

主要有这两个改动

  1. 引入了sx lock
  2. 引入了non-leaf page lock

引入SX Lock 以后

首先介绍一下 SX Lock, SX Lock 在index lock 和 page lock 的时候都可能用到.

SX Lock 是和 S LOCK 不冲突, 但是和 X LOCK 冲突的, SX LOCK 和 SX LOCK 之间是冲突的.

SX LOCK 的意思我有意向要修改这个保护的范围, 但是现在还没开始修改, 所以还可以继续访问, 但是要修改以后, 就无法访问了. 因为我有意向要修改, 因此不能允许其他的改动发生, 因此和 X LOCK 是冲突的.

目前主要用途是标记搜索路径上的page sx lock, 当确认需要修改了以后, 见这些page sx lock 转换成x lock

SX LOCK 的引入由这个 WL 加入 WL#6363

可以认为 SX LOCK 的引入是为了对读操作更加的优化, SX lock 是和 X lock 冲突, 但是是和 S lock 不冲突的, 将以前需要加X lock 的地方改成了SX lock, 因此对读取更加友好了

引入non-leaf page lock

其实这也是大部分商业数据库都是这样, 除了leaf page 有page lock, non-leaf page 也有page lock.

主要的想法还是 Latch coupling, 在从上到下遍历btree 的过程中, 持有了子节点的page lock 以后, 再把父节点的page lock 放开, 这样就可以尽可能的减少latch 的范围. 这样的实现就必须保证non-leaf page 也必须持有page lock.

不过这里InnoDB 并未把index->lock 完全去掉, 这就导致了现在InnoDB 同一时刻仍然只有同时有一个 BTR_MODIFY_TREE 操作在进行, 从而在激烈并发修改btree 结构的时候, 性能下降明显.

回到5.6 的问题

可以看到在5.6 里面, 最差的情况是如果要修改一个btree leaf page, 这个btree leaf page 可能会触发btree 结构的改变, 那么这个时候就需要加一整个index X LOCK, 但是其实我们知道有可能这个改动只影响当前以及上一个level 的btree page, 如果我们能够缩小LOCK 的范围, 那么肯定对并发是有帮助的.

那么到了8.0

  1. 如果是一个查询请求

    • 那么首先把btree index->lock S LOCK

    • 然后沿着搜索btree 路径, 遇到的non-leaf node page 都加 S LOCK

    • 然后直到找到 leaf node 以后, 对leaft node page 也是 S LOCK, 然后把index-> lock 放开

      Imgur

  2. 如果是一个修改leaf page 请求

    • 同样把btree index-> lock S LOCK, 通过对non-leaf node page 都加S LOCK

    • 然后直到找到leaf node 以后, 对leaf node 执行 X LOCK, 因为需要修改这个page. 然后把index->lock 放开. 到这里又分两种场景了, 对于这个page 的修改是否会引起 btree 的变化

      • 如果不会, 那么很好, 对leaf node 执行了X LOCK 以后, 修改完数据返回就可以

      • 如果会, 那么需要执行悲观插入操作, 重新遍历btree. 这时候给index->lock 是加 SX LOCK

        因为已经给btree 加上sx lock, 那么搜索路径上的btree 的page 都不需要加 lock, 但是需要把搜索过程中的page 保存下来, 最后阶段给搜索路径上有可能发生结构变化的page 加x lock.

        这样就保证了在搜索的过程中, 对于read 操作的影响降到最低.

        只有在最后阶段确定了本次修改btree 结构的范围, 对可能发生结构变化的page 加X lock 以后, 才会有影响.

      • 8.0 里面, SMO 操作过程中, 拿着sx lock 的持续时间是

        持有sx lock 的时间:

        第一次btr_cur_optimistic_insert insert 失败以后, 在 row_ins_clust_index_entry 会调用

        row_ins_clust_index_entry_low(flags, BTR_MODIFY_TREE …) 进行插入, 在 row_ins_clust_index_entry_low 里面, 在btr_cur_search_to_nth_level 函数里面加上 sx lock, 到这里btree 因为已经加了sx lock, 就已经无法进行smo 操作了, 然后接下来仍然会尝试先乐观插入,这个时候sx lock 依然持有, 失败的话, 再尝试悲观插入操作.

        释放sx lock 的时间:

        在悲观插入操作里面会一直持有sx lock, 直到在 btr_page_split_and_insert 内部, 将新的page2 已经产生, 同时page2 已经连接上father node 之后. 并且这次发生SMO 的page 还需要是leaf page, 否则一直持有sx lock, 直到SMO 操作完成, 并且insert 成功才会释放

        Imgur

        具体执行SMO 操作并且insert 的函数是 btr_page_split_and_insert

        btr_page_split_and_insert 操作大概有8个流程:

        1. 从要分裂的page 中, 找到要split 的record, split 的时候要保证split 的位置是record 的边界
        2. 分配一个新的索引页
        3. 分别计算page, 和new_page 的边界record
        4. 在上一级索引页(父节点)添加新的索引页的索引项, 如果上一级没有足够的空间, 那么就触发父节点的分裂操作了
        5. 连接当前索引页, 当前索引页prev_page, next_page, father_page, 新创建的 page. 当前的连接顺序是先连接父节点, 然后是prev_page/next_page, 最后是 page 和 new_page (在这一步结束之后就可以放开index->sx lock)
        6. 将当前索引页上的部分Record 移动到新的索引页
        7. SMO 操作已经结束, 计算本次insert 要插入的page 位置
        8. 进行insert 操作, 如果insert 失败, 通过reorgination page 重新尝试插入

现有代码里面只有一个场景会对index->lock X lock. 也就是

  if (lock_intention == BTR_INTENTION_DELETE &&
      trx_sys->rseg_history_len > BTR_CUR_FINE_HISTORY_LENGTH &&
      buf_get_n_pending_read_ios()) { 如果这次lock_intention 是BTR_INTENTION_DELETE, 并且history list 过长, 才会对 index 加 x lock

总结:

8.0 比5.6 改进的地方

在5.6 里面, 写入的时候, 如果有SMO 在进行, 那么就需要把整个index->lock x lock, 那么在SMO 期间所有的read 操作也是无法进行的.

在8.0 里面SMO 操作的过程中是允许有read 和 乐观写入操作的.

但是8.0 里面还有一个约束就是同一时刻只能有一个SMO 正在进行, 因为SMO 的时候需要拿 sx lock. sx lock 和 sx lock 是冲突的, 这也是目前8.0 主要问题.

优化点

当然这里还是有优化点.

  1. 依然有全局的index->lock, 虽然是sx lock, 但是理论上按照8.0 的实现, 可以完全将index lock 放开, 当然很多细节需要处理

  2. 在执行具体的分裂操作过程中, btr_page_split_and_insert 里面的持有index lock 是否还可以优化?

    • 比如按照一定的顺序的话, 是否将新创建page 连接到new_page 以后就可以放开index->lock

    • 还可以考虑发生SMO 的page 持有x lock 的时间.

      目前会持有整个路径上的page x lock 直到SMO 操作结束, 并且这次insert 完成, 同时需要一直持有fater_page, prev_page, next_page 的x lock, 是否可以减少持有page 的个数, 比如这个优化 BUG#99948

    • btr_attach_half_pages 中多次通过btr_cur_search_to_nth_level 将father link, prev link, next link 等建立好的操作 新执行一次 btr_page_get_father_block 对btree 进行遍å 在该函数里面有需要重新执行 btr_cur_search_to_n¥的..

  3. 每次进行btr_cur_search_to_nth_level, 搜索路徣么就不需要重新遍历.

  4. 是否还需要保留先乐½的避免悲观insert, 因此沿用到了目前的8.0 实现ä

    InnoDB btree latch 优化历程

(一般在数据库里面latch 指的是物理Lock, Lock 指的是事务的逻辑lock, 这里混用)

在InnoDB 的实现中, btree 主要有两种lock: index lock 和 page lock

index lock 就是整个Index 的lock, 具体在代码里面就是 dict_index->lock

page lock 就是我们在btree 里面每一个page 的变量里面都会有的 lock

当我们说btree lock的时候, 一般同时包含 index lock 和 page lock 来一起实现

在5.6 的实现里面比较简单,btree latch 大概是这样的流程

  1. 如果是一个查询请求
    • 那么首先把btree index->lock S LOCK

    • 然后直到找到 leaf node 以后, 对leaft node 也是 S LOCK, 然后把index-> lock 放开

      Imgur

  2. 如果是一个修改leaf page 请求
    • 同样把btree index-> lock S LOCK
    • 然后直到找到leaf node 以后, 对leaf node 执行 X LOCK, 因为需要修改这个page. 然后把index->lock 放开. 到这里又分两种场景了, 对于这个page 的修改是否会引起 btree 的变化
      • 如果不会, 那么很好, 对leaf node 执行了X LOCK 以后, 修改完数据返回就可以

      • 如果会, 那么需要执行悲观插入操作, 重新遍历btree.

        对btree inex 加X LOCK, 执行btr_cur_search_to_nth_level 到指定的page.

        因为leaft node 修改, 可能导致整个沿着leaf node 到root node 的btree 都会随着修改, 因此必须让其他的线程不能访问到, 因此需要整个btree 加X LOCK, 那么其他任何的查询请求都不能访问了, 并且加了index X LOCK 以后, 进行record 插入到page, 甚至可能导致上一个Level 的page 也需要改变, 这里需要从磁盘中读取数据, 因此可能有磁盘IO, 这就导致了加X LOCK 可能需要很长一段时间, 这段时间sread 相关的操作就都不可访问了

        这里具体的代码在 row_ins_clust_index_entry

        首先尝试乐观的插入操作

        err = row_ins_clust_index_entry_low( 0, BTR_MODIFY_LEAF, index, n_uniq, entry, n_ext, thr, &page_no, &modify_clock);

        然后这里如果插入失败, 再尝试悲观的插入操作,

        return(row_ins_clust_index_entry_low( 0, BTR_MODIFY_TREE, index, n_uniq, entry, n_ext, thr, &page_no, &modify_clock));

        从这里可以看到, 唯一的区别在于这里latch_mode = BTR_MODIFY_LEAF 或者 BTR_MODIFY_TREE. 并且由于btr_cur_search_to_nth_level 是在函数 row_ins_clust_index_entry_low 执行, 那么也就是尝试了乐观操作失败以后, 重新进行悲观插入的时候, 需要重新遍历btree

        Imgur

从上面可以看到, 5.6 里面只有对整个btree 的index lock, 以及在btree 上面的leaf node page 会有lock, 但是btree 上面non-leaf node 并没有 lock.

这样的实现带来的好处是代码实现非常简单, 但是缺点也很明显由于在SMO 操作的过程中, 读取操作也是无法进行的, 并且SMO 操作过程可能有IO 操作, 带来的性能抖动非常明显, 我们在线上也经常观察到这样的现象.

所以有了官方的改动, 其实这些改动在5.7 就引入, 我们这里以8.0 为例子:

主要有这两个改动

  1. 引入了sx lock
  2. 引入了non-leaf page lock

引入SX Lock 以后

首先介绍一下 SX Lock, SX Lock 在index lock 和 page lock 的时候都可能用到.

SX Lock 是和 S LOCK 不冲突, 但是和 X LOCK 冲突的, SX LOCK 和 SX LOCK 之间是冲突的.

SX LOCK 的意思我有意向要修改这个保护的范围, 但是现在还没开始修改, 所以还可以继续访问, 但是要修改以后, 就无法访问了. 因为我有意向要修改, 因此不能允许其他的改动发生, 因此和 X LOCK 是冲突的.

目前主要用途是标记搜索路径上的page sx lock, 当确认需要修改了以后, 见这些page sx lock 转换成x lock

SX LOCK 的引入由这个 WL 加入 WL#6363

可以认为 SX LOCK 的引入是为了对读操作更加的优化, SX lock 是和 X lock 冲突, 但是是和 S lock 不冲突的, 将以前需要加X lock 的地方改成了SX lock, 因此对读取更加友好了

引入non-leaf page lock

其实这也是大部分商业数据库都是这样, 除了leaf page 有page lock, non-leaf page 也有page lock.

主要的想法还是 Latch coupling, 在从上到下遍历btree 的过程中, 持有了子节点的page lock 以后, 再把父节点的page lock 放开, 这样就可以尽可能的减少latch 的范围. 这样的实现就必须保证non-leaf page 也必须持有page lock.

不过这里InnoDB 并未把index->lock 完全去掉, 这就导致了现在InnoDB 同一时刻仍然只有同时有一个 BTR_MODIFY_TREE 操作在进行, 从而在激烈并发修改btree 结构的时候, 性能下降明显.

回到5.6 的问题

可以看到在5.6 里面, 最差的情况是如果要修改一个btree leaf page, 这个btree leaf page 可能会触发btree 结构的改变, 那么这个时候就需要加一整个index X LOCK, 但是其实我们知道有可能这个改动只影响当前以及上一个level 的btree page, 如果我们能够缩小LOCK 的范围, 那么肯定对并发是有帮助的.

那么到了8.0

  1. 如果是一个查询请求

    • 那么首先把btree index->lock S LOCK

    • 然后沿着搜索btree 路径, 遇到的non-leaf node page 都加 S LOCK

    • 然后直到找到 leaf node 以后, 对leaft node page 也是 S LOCK, 然后把index-> lock 放开

      Imgur

  2. 如果是一个修改leaf page 请求

    • 同样把btree index-> lock S LOCK, 通过对non-leaf node page 都加S LOCK

    • 然后直到找到leaf node 以后, 对leaf node 执行 X LOCK, 因为需要修改这个page. 然后把index->lock 放开. 到这里又分两种场景了, 对于这个page 的修改是否会引起 btree 的变化

      • 如果不会, 那么很好, 对leaf node 执行了X LOCK 以后, 修改完数据返回就可以

      • 如果会, 那么需要执行悲观插入操作, 重新遍历btree. 这时候给index->lock 是加 SX LOCK

        因为已经给btree 加上sx lock, 那么搜索路径上的btree 的page 都不需要加 lock, 但是需要把搜索过程中的page 保存下来, 最后阶段给搜索路径上有可能发生结构变化的page 加x lock.

        这样就保证了在搜索的过程中, 对于read 操作的影响降到最低.

        只有在最后阶段确定了本次修改btree 结构的范围, 对可能发生结构变化的page 加X lock 以后, 才会有影响.

      • 8.0 里面, SMO 操作过程中, 拿着sx lock 的持续时间是

        持有sx lock 的时间:

        第一次btr_cur_optimistic_insert insert 失败以后,>lock 是加 SX LOCK

        因为已经给btree 加上sx lock, 那么搜索路径上的btree 的page 都不需要加 lock, 但是需要把搜索过程中的page 保存下来, 最后阶段给搜索路径上有可能发生结构变化的page 加x lock.

        这样就保证了在搜索的过程中, 对于read 操作的影响降到最低.

,这个时候sx lock 依然持有, 失败的话, 再尝试悲观æ™搜索路径上有可能发生结构变化的page 加x lock.

这样就保证了在搜索的过程中,  对于resp. 

   这样就保证了在搜索的过程中,  对于ree2 已经连接上father node 之后.  并且这次发生SMO 的page 还需要是leaf page, 否则一直持有sx lock, 直到SMO 操作完成, 并且insert 成功才会释放

   ![Imgur](https://i.imgur.com/ye4VVpc.png)

   具体执行SMO 操作并且insert 的函数是 btr_page_split_and_insert

   btr_page_split_and_insert 操作大概有8个流程:

   1. 从要分裂的page 中, 找到要split 的record, split 的时候要保证split 的位置是record 的边界
   2. 分配一个新的索引页
   3. 分别计算page, 和new_page 的边界record
   4. 在上一级索引页(父节点)添加新的索引页的索引项, 如果上一级没有足够的空间, 那么就触发父节点的分裂操作了
   5. 连接当前索引页, 当前索引页prev_page, next_page, father_page, 新创建的 page. 当前的连接顺序是先连接父节点, 然后是prev_page/next_page, 最后是 page 和 new_page  (在这一步结束之后就可以放开index->sx lock)
   6. 将当前索引页上的部分Record 移动到新的索引页
   7. SMO 操作已经结束, 计算本次insert 要插入的page 位置
   8. 进行insert 操作, 如果insert 失败, 通过reorgination page 重新尝试插入

现有代码里面只有一个场景会对index->lock X lock. 也就是

  if (lock_intention == BTR_INTENTION_DELETE &&
      trx_sys->rseg_history_len > BTR_CUR_FINE_HISTORY_LENGTH &&
      buf_get_n_pending_read_ios()) { 如果这次lock_intention 是BTR_INTENTION_DELETE, 并且history list 过长, 才会对 index 加 x lock

总结:

8.0 比5.6 改进的地方

在5.6 里面, 写入的时候, 如果有SMO 在进行, 那么就需要把整个index->lock x lock, 那么在SMO 期间所有的read 操作也是无法进行的.

在8.0 里面SMO 操作的过程中是允许有read 和 乐观写入操作的.

但是8.0 里面还有一个约束就是同一时刻只能有一个SMO 正在进行, 因为SMO 的时候需要拿 sx lock. sx lock 和 sx lock 是冲突的, 这也是目前8.0 主要问题.

优化点

当然这里还是有优化点.

  1. 依然有全局的index->lock, 虽然是sx lock, 但是理论上按照8.0 的实现, 可以完全将index lock 放开, 当然很多细节需要处理

  2. 在执行具体的分裂操作过程中, btr_page_split_and_insert 里面的持有index lock 是否还可以优化?

    • 比如按照一定的顺序的话, 是否将新创建page 连接到new_page 以后就可以放开index->lock

    • 还可以考虑发生SMO 的page 持有x lock 的时间.

      目前会持有整个路径上的page x lock 直到SMO 操作结束, 并且这次insert 完成, 同时需要一直持有fater_page, prev_page, next_page 的x lock, 是否可以减少持有page 的个数, 比如这个优化 BUG#99948

    • btr_attach_half_pages 中多次通过°是将father link, prev link, next link 等建立好的操作 其实这一步操作是可以避免的. 因为这时inlock 了, father 肯定不会变了的, 那么可以将上次œ索路径都没有改变, 那么就不需要重新遍历.

  3. 需要保留先乐观insert 再悲观insert 的操作过程?

    我理解现有的流程是因为在5.6 的实现中, 悲观insert 操作的开销太大, 从而尽可能的避免悲观insert, 因此沿用到了目前的8.0 实现中.这种多次insert 需要多次遍历btree, 带来额外开销

talking

https://dom.as/2011/07/03/innodb-index-lock/

https://dev.mysql.com/worklog/task/?id=6326

MySQL · 内核特性 · Attachable transaction

$
0
0

目的

在学习代码的过程中经常看到attachable transaction,它到底是做什么的,目的是什么呢。这篇文章简单的介绍一下它的作用和用法,以帮助大家理解代码。

简介

Attachable transaction是从5.7引入的一个概念,主要用来对事务类型的系统表访问的接口,从事务的系统表查询得到一致的数据。目前主要是对innodb类型的系统表访问的接口,也只有innodb引擎实现了attachable transaction的支持。Attachable transaction 主要是为访问事务类型的系统表而设计的,它是一个嵌入用户事务的内部事务,当用户事务用到元信息时就需要开启一个attachable transaction去访问数据字典,得到用户表的元信息后,要结束attachable transaction,用户会话要能恢复到用户事务之前的状态。 Attachable transaction 是一个AC-RO-RC-NL (auto-commit, read-only, read-committed, non-locking) 事务。引入Attachable transaction主要有以下几个原因: 1) 如果用户开启的一个事务需要访问系统表获取表的元信息,而访问系统表可能和用户事务指定的隔离级别不一致,这时就需要开启一个独立的访问数据字典的事务,要求访问数据字典事务的隔离级别必须是READ COMMITTED,其隔离级别可能和用户指定的隔离级别不一致。 2) 对数据字典的访问必须是非锁定的。 3) 即时用户事务已经打开和锁定了用户表,在执行SQL语句的在任何时候也应该能对数据字典打开,来查询用户表的各种元信息。

核心数据结构

在每个会话的THD结构里,添加了一个 Attachable_trx *m_attachable_trx;字段,用来指向当前会话的内嵌事务。Attachable_trx 类型定义如下:

  /**  
    Class representing read-only attachable transaction, encapsulates
    knowledge how to backup state of current transaction, start
    read-only attachable transaction in SE, finalize it and then restore
    state of original transaction back. Also serves as a base class for
    read-write attachable transaction implementation.
  */
  class Attachable_trx
  {
  public:
    Attachable_trx(THD *thd);
    virtual ~Attachable_trx();
    virtual bool is_read_only() const { return true; }
  protected:
    /// THD instance.
    THD *m_thd;

    /// Transaction state data.
    Transaction_state m_trx_state;

  private:
    Attachable_trx(const Attachable_trx &);
    Attachable_trx &operator =(const Attachable_trx &);
  };

其中最重要的是m_trx_state字段,期存放着attachable transaction的重要信息,就是用它来保存外部用户事务的状态,以便在着attachable transaction结束后能恢复到原来的用户事务状态。其定义如下:

  /** An utility struct for @c Attachable_trx */
  struct Transaction_state
  {
    void backup(THD *thd);
    void restore(THD *thd);

    /// SQL-command.
    enum_sql_command m_sql_command;

    Query_tables_list m_query_tables_list;

    /// Open-tables state.
    Open_tables_backup m_open_tables_state;

    /// SQL_MODE.
    sql_mode_t m_sql_mode;

    /// Transaction isolation level.
    enum_tx_isolation m_tx_isolation;

    /// Ha_data array.
    Ha_data m_ha_data[MAX_HA];

    /// Transaction_ctx instance.
    Transaction_ctx *m_trx;

    /// Transaction read-only state.
    my_bool m_tx_read_only;

    /// THD options.
    ulonglong m_thd_option_bits;

    /// Current transaction instrumentation.
    PSI_transaction_locker *m_transaction_psi;

    /// Server status flags.
    uint m_server_status;
  };

核心接口API

启动一个attachable transaction

主要有这几个函数THD::begin_attachable_transaction()/begin_attachable_ro_transaction()/begin_attachable_rw_transaction(), 启动attachable transaction的过程中主要完成以下功能: 1) 在开始一个attachable_transaction之前,先要保存当前已经开始的用户的正常事务状态。 2) 开始设置一个新的事务所需要的各种状态。 3) 重新设置THD::ha_data。通过重制THD::ha_data值使InnoDB在接下来的操作去创建以下新的事务。 4) 执行对系统表的操作。 5) 当执行到存储引擎层时,InnoDB从传进来的THD指针中发现事务还没开启 (因为THD::ha_data重置了),InnoDB就会新启一个事务。 6) InnoDB通过调用trans_register_ha()通知server它已经创建了一个新事务。 7) InnoDB执行请求的操作,返回给server层。

THD::Attachable_trx::Attachable_trx(THD *thd)
 :m_thd(thd)
{
  m_trx_state.backup(m_thd); //保存当前已经开始的用户的正常事务状态

  ......

  m_thd->reset_n_backup_open_tables_state(&m_trx_state.m_open_tables_state); //保存一些打开表的状态信息,并且重新为新的事物重置表状态

  // 为attachable transaction创建一个新的事物上下文
  m_thd->m_transaction.release(); // it's been backed up.
  m_thd->m_transaction.reset(new Transaction_ctx());

  ......

  for (int i= 0; i < MAX_HA; ++i) 
    m_thd->ha_data[i]= Ha_data(); //重新设置THD::ha_data

  m_thd->tx_isolation= ISO_READ_COMMITTED; // attachable transaction 必须是read committed

  m_thd->tx_read_only= true; // attachable transaction 必须是只读的

  // attachable transaction 必须是 AUTOCOMMIT
  m_thd->variables.option_bits|= OPTION_AUTOCOMMIT;
  m_thd->variables.option_bits&= ~OPTION_NOT_AUTOCOMMIT;
  m_thd->variables.option_bits&= ~OPTION_BEGIN;

  ......
}

结束一个attachable transaction

THD::end_attachable_transaction()函数。因为attachable transaction事务是一个只读的自提交事务,所以它不需要调用任何事务需要提交火回滚的函数,比如: ha_commit_trans() / ha_rollback_trans() / trans_commit() / trans_rollback()。所以定义了此函数用来结束当前的attachable transaction。它主要完成以下功能: 1) 调用close_thread_tables()关闭在attachable transaction中打开的表。 2) 调用close_connection()通知引擎层去销毁为attachable transaction创建的事务。 3) InnoDB调用trx_commit_in_memory()去销毁readview等操作。 4) 最后要恢复之前正常的用户事务,包括THD::ha_data的恢复,这个通过调用下面提到的事务状态的backup()/restore()接口。

THD::Attachable_trx::~Attachable_trx()
{
  ......

  close_thread_tables(m_thd); //调用close_thread_tables()关闭在attachable transaction中打开的

  ha_close_connection(m_thd); //调用close_connection()通知引擎层去销毁为attachable transaction创建的事务

  // 恢复之前正常的用户事状态
  m_trx_state.restore(m_thd);

  m_thd->restore_backup_open_tables_state(&m_trx_state.m_open_tables_state);

  ......
}

事务的保存、恢复接口函数 Transaction_state::backup()/restore()

由于一个会话同时只允许有一个活跃事务,当需要访问内部的事务系统表时,就需要开启一个Attachable transaction事务,这时就要先把外部的主事务状态先保存起来,等内部开启的Attachable transaction事务执行完,再把外部用户执行的事务恢复回来。为了实现这个功能,提供了backup和restore的接口。

Handler API的改动

为了让存储层知道server层开启的是一个attachable transaction,handler API新加了一个HA_ATTACHABLE_TRX_COMPATIBLE 标志。设置这个标志的存储引擎类型表示引擎层已经意识到开启了attachable transaction的事务类型。目前InnoDB和MyISAM引擎都能处理这种attachable transaction。但处理方式不同: 1)对InnoDB而言,完全支持attachable transaction事务,能够感知到THD::ha_data 的变化并开启一个attachable transaction事务。在close_connection时结束一个attachable transaction事务,然后恢复用户正常的事务继续处理。 2) 对MyISAM而言,虽然知道server开启了一个attachable transaction事务但也不做任何处理,就是简单但忽律掉 THD::ha_data and close_connection handlerton相关但处理。

在初始化一个InnoDB表时,设置HA_ATTACHABLE_TRX_COMPATIBLE 标志的代码如下:

ha_innobase::ha_innobase(
/*=====================*/
    handlerton* hton, 
    TABLE_SHARE*    table_arg)
    :handler(hton, table_arg),
    m_prebuilt(),
    m_prebuilt_ptr(&m_prebuilt),
    m_user_thd(),
    m_int_table_flags(HA_REC_NOT_IN_SEQ
    ......
              | HA_ATTACHABLE_TRX_COMPATIBLE
              | HA_CAN_INDEX_VIRTUAL_GENERATED_COLUMN
          ),    
    m_start_of_scan(),
    m_num_write_row(),
        m_mysql_has_locked()
{}

MySQL · 内核特性 · Link buf

$
0
0

目的

8.0 中使用 link buf 管理并发写 redo 时如何获取到连续的 lsn,在 log_writer 线程中才能把这段对应的日志刷到文件系统 page cache 中。

代码文件:storage/innobase/include/ut0link_buf.h

参数

innodb_log_recent_written_size

分析

初始化

capacity 表示 link buf 能够管理的无序范围。比如有多个 mtr 并发的写入,其对应的 LSN 如下:
mtr1:  [4096 ~ 5096]
mtr2: [5097 ~ 8022]
mtr3: [8022 ~ 8048]
mtr4: [8049 ~ 9033]

假如 capacity 是 4K,那么能够处理的 max(LSN) - min(LSN) = 4K. 因为每一个 LSN 都要 HASH 到 link_buf 的一个数组中。而 capacity 就是数组的大小。数组元素的类型由 Position 决定。

  template <typename Position>
Link_buf<Position>::Link_buf(size_t capacity)
  : m_capacity(capacity), m_tail(0) {
    if (capacity == 0) {
      m_links = nullptr;
      return;
    }

    ut_a((capacity & (capacity - 1)) == 0);

    m_links = UT_NEW_ARRAY_NOKEY(std::atomic<Distance>, capacity);

    for (size_t i = 0; i < capacity; ++i) {
      m_links[i].store(0);
    }
  }

初始化后:
image.png

m_link[capacity] 其中每一个值都是 0

添加元素

多个 mtr 写 redo log 的时候会去 reserve 一段连续的空间,如初始化部分介绍,mtr 的起始和结束的 LSN。添加到 link buf 中

template <typename Position>
inline void Link_buf<Position>::add_link(Position from, Position to) {
  ut_ad(to > from);
  ut_ad(to - from <= std::numeric_limits<Distance>::max());

  const auto index = slot_index(from);

  auto &slot = m_links[index];

  ut_ad(slot.load() == 0);

  slot.store(to - from);
}

根据 from ,也就是起始的位置寻找到 m_link 数组中的一个位置。算法比较简单,对 capacity 取模即可,因为要保证一个并发空间内的起始 LSN 取模唯一,所以 capacicy 会限制并发空间。

template <typename Position>
inline size_t Link_buf<Position>::slot_index(Position position) const {
  return position & (m_capacity - 1);
}

假如 mtr1 和 mtr 3 先加入 link buf:

image.png

mtr1 落到 index 0 的位置,在这个数组元素中写入 mtr1 的 数据长度,也就是 LSN 区间大小,是 1000. 同样 mtr3 也落到 index 3926 的位置,写入区间大小,26。数组的其余位置都为 0。

寻找连续

上面添加完元素之后,其实 mtr1 和 mtr3 的 LSN 是接不上的,因为 mtr2 还没并发的写到 redo log buffer 中,因为不能最多只能刷盘的 mtr1 的 end LSN。Log writer 线程会在刷盘的时候通过 Link buf 找到连续位置:

template <typename Position>
template <typename Stop_condition>
bool Link_buf<Position>::advance_tail_until(Stop_condition stop_condition) {
  auto position = m_tail.load();

  while (true) {
    Position next;

    bool stop = next_position(position, next);

    if (stop || stop_condition(position, next)) {
      break;
    }

    /* Reclaim the slot. */
    claim_position(position);

    position = next;
  }

  if (position > m_tail.load()) {
    m_tail.store(position);

    return true;

  } else {
    return false;
  }
}

template <typename Position>
inline bool Link_buf<Position>::advance_tail() {
  auto stop_condition = [](Position from, Position to) { return (to == from); };

  return advance_tail_until(stop_condition);
}

用变量 m_tail 表示最后一个找到的连续位置。函数 advance_tail 尝试推进这个值,其中 next_positon 会根据当前的 LSN 获得下一个连续位置的 LSN。

template <typename Position>
bool Link_buf<Position>::next_position(Position position, Position &next) {
  const auto index = slot_index(position);

  auto &slot = m_links[index];

  const auto distance = slot.load();

  ut_ad(position < std::numeric_limits<Position>::max() - distance);

  next = position + distance;

  return distance == 0;
}

代码比较简单,就是根据数组中的长度,加上当前的 LSN。对于上述列子,假如此时 m_tail 是 4096 也就是 mtr1 之前,此时 4096 找到 mtr1 的 index,然后加上 mtr1 的长度,得到 next_position 是 5096,也就是 mtr2 的起始 LSN。但是此时 mtr2 还未假如 link buf, index[1001] 是 0。表示此时无法继续推进。如果 mtr2 也加入了 linkbuf,则可以顺着大小找到 mtr3 的结尾。

是否能使用 link buf

link buf 长度有限,肯定存在两个 LSN 指向同一个 index slot,所以在添加元素之前要先检测一下是否能够放进去。

template <typename Position>
inline bool Link_buf<Position>::has_space(Position position) const {
  return tail() + m_capacity > position;
}

看起来很简单,就是加入的 position 是不是落到 tail() + capacity 里面。

在 log_buffer_write_completed 函数中的注释有解释:

 /* Let M = log.recent_written_size (number of slots).  For any integer k, all lsn values equal to: start_lsn + k*M  correspond to the same slot, and only the smallest of them  may use the slot. At most one of them can fit the range  [log.buf_ready_for_write_lsn..log.buf_ready_ready_write_lsn+M).  Any smaller values have already used the slot. Hence, we just  need to wait until start_lsn will fit the mentioned range. */

uint64_t wait_loops = 0;

while (!log.recent_written.has_space(start_lsn)) {
  ++wait_loops;
  os_thread_sleep(20);
}

因为 LSN 是递增的,总是较小的 LSN 先进入 link buf,所以检测 slot 是否可用,就能阻止相同 slot 的进入。

重新理解 capacity

capacity 表示容量,其实上面的例子容易有误导,认为 mtr1 到 mtr2 中间隔着 1000 个 slot,这些 slot 就用不到,一直为 0 了。其实不然,举个例子:

假如来了一个 mtr 5 [9034 - 14062], 此时 tail 仍然是 4095。mtr5 落到了 index 842 的位置,其实 mtr5 的长度已经大于 capacity 了,如果又来一个 mtr7 [14063 - 14070],实际上会落到 index 751 的位置。

由此看 link buf 的 mtr 之间的 slot 并不一定是 0. 所以 capacity 可以这么理解,就是最大的 mtr 并发数,当然一般很难达到,必须要所有的 mtr 的 start lsn 都不冲突才行。

Note: mtr 的 LSN 应该是 SN,也就是有效的数据的 LSN,不包括 Log block 的 hearder 和 tail,而 block 又是 512 对齐的,所以 capacity 的大小配置如果是 512 对齐的,可能就有某些 slot 就一直用不上。default 值是 16 MB 通过参数 log_recent_written_size 配置。

PgSQL · 新版本调研 · 13 Beta 1 初体验

$
0
0

背景

从PostgreSQL 10 开始,社区保持每年一个大版本的节奏,表现出了超强的社区活力和社区创造力。 image.png

在2020-05-21 这个特殊的日子里,PostgreSQL 全球开发组宣布PostgreSQL 13 的Beta 1 正式对外开放,可提供下载。这个版本包含将来PostgreSQL 13正式版本中的所有特性和功能,当然一些功能的细节在正式版本发布时可能会有些变化。 下面我们将详细了解下PostgreSQL 13 版本新的特性和功能。

PostgreSQL 13 新增特性

在社区的对外发布的文档中,把PostgreSQL 13 的新增特性分为了几部分:

  • 数据库功能相关
  • 数据库运维管理相关
  • 数据库安全性相关
  • 其他亮点

我们也按照这几部分来详细介绍,并对一些功能做一些实际的测试,看下具体的效果。

数据库功能相关

在PostgreSQL 13 版本中有许多地方的改进来实现性能的提升,同时对应用开发人员来说,应用开发更加容易。

B树索引优化

表的列如果不是唯一的,可能会有很多相同的值,对应的B树索引也会有很多重复的索引记录。在PostgreSQL 13 中B树索引借鉴了GIN 索引的做法,将相同的Key 指向的对应行的ctid 链接起来。这样既减小了索引的大小,又减少了很多不必要的分裂,提高了索引的检索速度,我们把该优化称为B树索引的deduplicate 功能。另外,B树索引的deduplicate 功能是异步进行的,只有在B 树索引需要分裂的时候才会去做该操作,减少了该功能的日常开销。

B树索引的deduplicate 功能的使用方法如下: image.png

可以看出,需要在创建B 树索引的时候增加deduplicate_items 存储参数为on(PG 13 中index 该存储参数目前默认为on),deduplicate 功能才会开启。

为了测试deduplicate 功能的效果,我们拿12 和13 版本做了下对比。 PostgreSQL 12 含有 image.pngimage.png PostgreSQL 13: image.pngimage.png

可以看出:

  • PostgreSQL 13 和PostgreSQL 12相比,相同类型且没有重复值的B树索引,大小相同。
  • PostgreSQL 13 和PostgreSQL 12相比,相同类型且有大量重复值的B树索引更小。
  • PostgreSQL 13 和PostgreSQL 12相比,如果索引有大量重复值且执行计划走index only scan 的话,需要读取的索引页更少,效率更高。

除了数据级别的重复之外,因为PostgreSQL 的MVCC 实现会带来B树索引中有重复Key 的不同快照,13 版本中B树索引的deduplicate 功能同样对其有效。不过有一定的局限:

  • text, varchar, char 类型数据,如果使用了特殊的collation,则可能需要大小写和口音不同来定义数据是否相等,无法使用deduplicate 功能。
  • numeric 类型数据无法使用deduplicate 功能,因为numeric 类型需要结合不同的展示范围才能定义数值是否相等。
  • jsonb 类型数据无法使用deduplicate 功能,因为jsonb B树操作类型内部使用了numeric 类型。
  • float4 和float8 类型数据无法使用该优化,因为在这两个类型中-0 和0 数值被认为是相等的。

总的来说,这些不支持的数据类型主要是因为判断他们Key 是否相同不能只通过数值来判断,还需要额外的条件。

B树索引是PostgreSQL 中默认的索引类型,社区几个大版本都对其占用空间和执行效率不断地进行优化。其实,PostgreSQL 12 中已经对B 树索引做了一定的deduplicate 优化,详见之前的文章,13 版本是对该优化的进一步完善和增强。

增量排序

PostgreSQL 13 版本增加了增量排序。这个优化其实起源于一个很朴素的算法,当对一组数据集(X,Y)按照X、Y两列进行组合排序,如果当前数据集已经按X 列进行了排序,如下:

(1, 5)
  (1, 2)
  (2, 9)
  (2, 1)
  (2, 5)
  (3, 3)
  (3, 7)

这时,只需要对数据集按照X 分组,并在每组中对Y 列继续排序,就可以得到按照X、Y 排序的结果集,如下:

   (1, 5) (1, 2)
   (2, 9) (2, 1) (2, 5)
   (3, 3) (3, 7)
  =====================
   (1, 2)
   (1, 5)
   (2, 1)
   (2, 5)
   (2, 9)
   (3, 3)
   (3, 7)

这种算法的好处是显而易见的,特别是对大的数据集来说,这样会减少每次排序的数据量,这里可以通过一定的策略控制让每次排序的数据量更适应当前设置的work_mem。另外一方面,在PostgreSQL 的瀑布模型执行器中,我们可以不用全部数据的排序结果就可以得到部分结果集,非常适合带Limit 关键字的top-N 的查询。

当然,在数据库的优化器中,要远比上面的情况复杂的多。如果每个分组较大,分组数量较少的话,增量排序的代价会比较高。如果每个分组较小,分组数量较多的话,我们使用增量排序利用之前排好序的结果需要的代价比较小。为了中和两者的影响,PostgreSQL 13 中采用了2种模式:

  1. 抓取相对安全的行数不需要检查之前的排序键进行全排,这里的安全是基于一些代价的考虑。
  2. 抓取所有的行,基于之前的排序键上再进行分组排序。

PostgreSQL 优化器是优先会去使用模式1,然后启发式地使用模式2。

增量排序具体的使用方法以及开启关闭该功能的执行计划对比如下:

  postgres=# create table t (a int, b int, c int);
  CREATE TABLE
  postgres=# insert into t select mod(i,10),mod(i,10),i from generate_series(1,10000) s(i);
  INSERT 0 10000
  postgres=# create index on t (a);
  CREATE INDEX
  postgres=# analyze t;
  ANALYZE
  postgres=# set enable_incrementalsort = off;
  SET
  postgres=# explain analyze select a,b,sum(c) from t group by 1,2 order by 1,2,3 limit 1;
                                                         QUERY PLAN
                                                         ------------------------------------------------------------------------------------------------------------------------
                                                          Limit  (cost=231.50..231.50 rows=1 width=16) (actual time=2.814..2.815 rows=1 loops=1)
     ->  Sort  (cost=231.50..231.75 rows=100 width=16) (actual time=2.813..2.813 rows=1 loops=1)
           Sort Key: a, b, (sum(c))
           Sort Method: top-N heapsort  Memory: 25kB
                    ->  HashAggregate  (cost=230.00..231.00 rows=100 width=16) (actual time=2.801..2.804 rows=10 loops=1)
                 Group Key: a, b
                                Peak Memory Usage: 37 kB
                                               ->  Seq Scan on t  (cost=0.00..155.00 rows=10000 width=12) (actual time=0.012..0.951 rows=10000 loops=1)
   Planning Time: 0.169 ms
    Execution Time: 2.858 ms
    (10 rows)

  postgres=# set enable_incrementalsort = on;
  SET
  postgres=# explain analyze select a,b,sum(c) from t group by 1,2 order by 1,2,3 limit 1;
                                                                   QUERY PLAN
                                                                   ---------------------------------------------------------------------------------------------------------------------------------------------
                                                                    Limit  (cost=133.63..146.52 rows=1 width=16) (actual time=1.177..1.177 rows=1 loops=1)
     ->  Incremental Sort  (cost=133.63..1422.16 rows=100 width=16) (actual time=1.176..1.176 rows=1 loops=1)
           Sort Key: a, b, (sum(c))
           Presorted Key: a, b
                    Full-sort Groups: 1  Sort Method: quicksort  Average Memory: 25kB  Peak Memory: 25kB
                             ->  GroupAggregate  (cost=120.65..1417.66 rows=100 width=16) (actual time=0.746..1.158 rows=2 loops=1)
                 Group Key: a, b
                                ->  Incremental Sort  (cost=120.65..1341.66 rows=10000 width=12) (actual time=0.329..0.944 rows=2001 loops=1)
                       Sort Key: a, b
                                            Presorted Key: a
                                                                 Full-sort Groups: 3  Sort Method: quicksort  Average Memory: 28kB  Peak Memory: 28kB
                                                                                      Pre-sorted Groups: 3  Sort Method: quicksort  Average Memory: 71kB  Peak Memory: 71kB
                                                                                                           ->  Index Scan using t_a_idx on t  (cost=0.29..412.65 rows=10000 width=12) (actual time=0.011..0.504 rows=3001 loops=1)
   Planning Time: 0.164 ms
    Execution Time: 1.205 ms
    (15 rows)

分区表增强

PostgreSQL 13 版本中对PostgreSQL中的分区表功能有多处增强,包括在多分区表中进行直接连接,以减少总的查询执行时间。分区表现在支持BEFORE关键字的行级触发器,并且现在一个分区表也可以通过逻辑复制的方式进行复制, 而不必像以前那个发布单个分区表。

其他

  • 支持了带有OR选项或是IN/ANY选项的查询使用扩展的统计信息(通过 CREATE STATISTICS 创建),这样就可以得到更合理的查询规划和性能提升。
  • 支持大数据集查询时,hashagg 使用磁盘存储(enable_hashagg_disk=on)。在之前的版本中,如果hashagg 使用的内存预测要大于work_mem,则不使用hashagg。
  • 对jsonpath的查询增加了.datetime()函数, 它可以将日期或时间的字符串自动转换为对应的PostgreSQL日期或时间数据类型。
  • 支持 gen_random_uuid() 内置函数生成随机的UUID,而不依赖外部插件。

数据库管理

并行VACUUM

PostgreSQL 13 版本中最令人期待的特性之一就是并行VACUUM。在之前的版本中,每个表的VACUUM 操作并不能并行,当表比较大的时候,VACUUM 的时间就会很长。在PostgreSQL 13中,支持了对索引的并行VACUUM,目前有很多的限制:

  • 目前仅限于索引,每个索引可以分配一个vacuum worker。
  • 不支持在加上FULL选项后使用。
  • 只有在至少有2个以上索引的表上使用parallel选项才有效。

我们使用并行VACUUM 和12 版本进行了一个简单的对比,如下:

  =================================PG 13 parallel vacuum===============================
  postgres=#  create table testva(id int,info text);
  CREATE TABLE
  Time: 2.334 ms
  postgres=#  insert into testva select generate_series(1,1000000),md5(random()::text);
  INSERT 0 1000000
  Time: 1448.098 ms (00:01.448)
  postgres=#  create index idx_testva on testva(id);
  CREATE INDEX
  Time: 364.988 ms
  postgres=#  create index idx_testva_info on testva(info);
  CREATE INDEX
  Time: 873.416 ms
  postgres=#  vacuum (parallel 4) testva;
  VACUUM
  Time: 114.846 ms
  =================================PG 12 normal vacuum===============================
  postgres=#  create table testva(id int,info text);
  CREATE TABLE
  Time: 5.817 ms
  postgres=#  insert into testva select generate_series(1,1000000),md5(random()::text);
  INSERT 0 1000000
  Time: 3023.958 ms (00:03.024)
  postgres=#  create index idx_testva on testva(id);
  CREATE INDEX
  Time: 631.632 ms
  postgres=#  create index idx_testva_info on testva(info);
  CREATE INDEX
  Time: 1374.849 ms (00:01.375)
  postgres=#  vacuum  testva;
  VACUUM
  Time: 216.944 ms

可以看出,PostgreSQL 13 要比12 版本VACUUM 速度有了很大的提升。但是,相对来说,做的不够彻底。不过。社区邮件中正在积极地讨论块级别的并行VACUUM,详见链接

其他

  • reindexdb 命令增加–jobs 选项,允许用户并行重建索引。
  • 引入了“可信插件”的概念,它允许超级用户指定一个普通用户可以安装的扩展,当然,该用户需要具有CREATE权限。
  • 增强数据库状态的监控,跟踪WAL日志的使用统计、基于流式备份的进度和ANALYZE指令的执行进度。
  • 支持pg_basebackup 命令生成辅助清单文件,可以使用pg_verifybackup 工具来验证备份的完整性。
  • 可以限制为流复制槽所保留的WAL空间。
  • 可以为standby 设置临时流复制槽。
  • pg_dump 命令新增了–include-foreign-data参数,可以实现在导出数据时导出外部数据封装器所引用的其他服务器上的数据。
  • pg_rewind 命令不仅可以在宕机后自动恢复,并且可以通过–write-recover-conf选项来配置PostgreSQL备库。 支持在目标实例上使用restore_command来获取所需的WAL日志。

这几项功能大大提高了PostgreSQL 数据库的运维能力,尤其是pg_rewind 大大提高了可玩性,这里不再详细介绍,我们会在后面的文章中介绍这样的功能增强会带来哪些可能性的玩法。

安全性

PostgreSQL 持续提升安全方面的能力,新版本引入了以下几个特性以提高安全性:

  • 用于强大的psql工具和很多PostgreSQL连接驱动的libpq库,现在新增了几项参数用于安全的服务器连接。引入了channel_binding的参数,可以让客户端指定通道绑定作为SCRAM的组成部分, 并且,使用一个含密码保护的TLS证书的客户端现在可以通过sslpassword参数来指定密码。PostgreSQL现在也支持DER算法编码的证书。
  • PostgreSQL的外部文件封装器postgres_fdw现在也新增了几个参数来实现安全的连接,包括使用基于证书进行身份验证去连接其他数据库集群。 另外,非特权的帐号现在可以通过postgre_fdw直接连接另一个PostgreSQL数据库而不必使用密码。

其他亮点

  • PostgreSQL 13 继续提升在Windows平台上的操作性,现在Windows平台上的用户也有了可以通过UDS通讯方式来连接PostgreSQL服务的选项。
  • PostgreSQL 13文档中增加了术语汇总表,来帮助用户了解PostgreSQL和一些通用的数据库概念。同时对函数和表中的操作符的显示也进行了优化,以帮助提升在线文档和PDF文档的可阅读性。
  • 用于性能测试的pgbench,现在支持系统用户表的分区操作,这样在对包含分区表的性能测试时更加容易。
  • psql工具现在包括类似于\echo的\warn 指令用于输出数据,差别是\warn会将输出重定向至stderr标准错误的虚拟设备。现在用户在需要了解更多PostgreSQL指令时,使用-help选项会包含一个网络链接,指向:https://www.postgresql.org.

其他的PostgreSQL 13 的详细功能列表,见链接

总结

虽然PostgreSQL 13 这次没有引入计划中的TDE 功能和zheap 功能,但是还是有很多亮眼的功能,包括B树索引的deduplicate 功能,并行VACUUM,hashagg 可以使用磁盘,支持pg_verifybackup 工具验证备份完整性,pg_rewind 支持配置成备库和restore_command 获取所需的WAL 日志。有兴趣的同学可以下载下源码,编译下,分析下自己感兴趣的特性的实现,说不定可以发现更好的想法。

Viewing all 687 articles
Browse latest View live