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

MySQL · 引擎特性 · InnoDB 表空间加密

$
0
0

背景简介

InnoDB 表空间加密是在引擎内部数据页级别的加密手段,在数据页写入文件系统时加密,从文件读到内存中时解密,目前广泛使用的是 YaSSL/OpenSSL 提供的 AES加密算法,加密前后数据页大小不变,因此也称为透明加密。表空间加密相对于文件系统加密更加灵活,用户可以控制加密重要的表,防止被拖库导致的数据丢失。MySQL 官方在 5.7.11中发布了表空间加密功能,Aliyun RDS 差不多在同时支持了 RDS MySQL 版的表空间加密,通过了“等保三级”的认证,随后 MariaDB 在 10.1 支持了功能增强版的“表空间加密”,除了表空间,还可以对 Redo log 和 Binlog 进行加密,参考这篇详细介绍。本文将详细介绍官方的实现方式。

Keyring Plugin

Keyring Plugin 是用来保存敏感信息的插件,目前官方支持了四种插件: keyring_file, keyring_encrypted_file, keyring_okv, keyring_aws, 社区版目前只支持 keyring_file 类型,本文基于此类型介绍。

如果要使用表空间加密功能,keyring_file 必须在 MySQL 实例初始化之前初始化(使用 –early-plugin-load 参数),因为 keyring_file 里面保存了解密需要的 master_key。我们可以把这个插件理解成一个 K-V 表,可以根据 key 查找到对应的数据。在源码内部提供了以下几个接口:

/* 根据 key_id 查找秘钥 */
STRING keyring_key_fetch(STRING key_id);

/* 以 key_id 生成加密类型为 key_type 长度为 key_length 的秘钥,并存储到文件中 */
STRING keyring_key_generate(STRING key_id, STRING key_type, INTEGER key_length);

/* 返回秘钥长度 */
INTEGER keyring_key_length_fetch(STRING key_id);

/* 移除秘钥 */
INTEGER keyring_key_remove(STRING key_id);

/* 混淆并且存储秘钥 */
INTEGER keyring_key_store(STRING key_id, STRING key_type, STRING key);

/* 返回秘钥加密类型 */
STRING keyring_key_type_fetch(STRING key_id);

用户可以创建 UDF(User Deifined Function) 在 SQL 语句中使用上述接口,作为独立于表空间加密的功能使用,具体使用方式可以参考:General-Purpose Keyring Key_Management Functions。把 keyring_file 放到本地文件显然是不安全的,建议放到类似U盘的地方,启动实例的时候挂载到文件系统,启动之后移除,大概就像原来银行的优盾 :)

流程分析

整体架构

img

为了支持 key rotation,官方的加密用到了两个秘钥,一个是从 kering 生成的 master_key,另一个是用来加密每个表空间的 tablespace_key,master_key 仅仅用来加密解密 tablespace_key。tablespace_key 加密后保存在每个 ibd 文件 page 0 页的尾部,对应图中 Encryption Information 部分,除了 tablespace_key, 还有用来索引 master_key 的 master_key_id 信息,以及 magicnum 和循环冗余校验的数据。关于 InnoDB ibd 文件的页面组织可以参考月报InnoDB 文件系统之文件物理结构,首个页空间利用并不满。

在 server 层,创建一个加密的表后,加密的信息会保存在 frm 文件中,主要作用是 show create table 时可以打印出加密部分的语句。

InnoDB 层除了 Encryption Information,还会在 ibdata 的字典表中的 flags2 字段标识对应的表示加密表,具体存储在 SYS_TABLES 表 MIX_LEN 列中,关于 InnoDB 字典表结构可以参考这篇文章。在 ibd 文件中会在 page 0 页头部 FSP 的 FLAG 标识这个 File Space 是加密表空间,具体位置在 FSP_FLAGS_POS_ENCRYPTION,如图中 Encryption 所示位置。还会在每个 Index page 的 page type 位置标识这个页是加密的,对应图中 page type 位置。

Note: 在 ibdata 中的系统表空间,比如 redo,undo 等是默认不加密的,ibd 文件 page 0 也是不加密的,Index page 只会对数据部分加密,Page Header 不会被加密。

上述部分是整体架构和物理文件页面有哪些变化,相对于 MySQL 5.6 使用了一些预留的标记为空间,迁移的话保证对应的位置不会冲突。

代码分析

基础类介绍

MySQL 5.7 的代码相对于 5.6 版本有了较大的重构,主要是把之前面向过程的代码更多的用类结构封装。表空间加密主要交互是在和文件的 IO 交互,页写入文件之前加密,从文件读出第一时间解密。还有一个 Encryption 类,负责调用 keyring plugin 来维护 master_key, 保存 tablespace key 加密的时候用,还提供页面的加密解密函数等。代码重构后 IO 部分使用类 IORequest 类来控制具体 IO 的行为,比如是 READ 还是 WRITE,是否是对 LOG 的 IO, 是否加密,是否压缩等等。如下类图所示,Encryption 类注入到 IORequest 中。

img

下面首先介绍一下 master_key 的维护,Encryption 类中有两个静态成员变量,master_key_id 是一个递增的值,每次生成新的 master_key 都会更新,uuid 是当前的 server_uuid。 前面介绍了 keyring plugin 提供的接口函数,在 create_master_key, get_master_key * 2, 三个静态函数中调用。对应 keyring plugin 的 key_id 参数,是 [ENCRYPTION_MASTER_KEY_PRIFIX+uuid+master_key_id] 的字符串组合,用变量 key_name 表示。create_master_key 获得一个新的 master_key:

create_master_key
      |
      ---- my_key_generate(key_name, "AES", ENCRYPTION_KEY_LEN)
      |
      ---- my_key_fetch(key_name, &keytype, NULL, master_key, key_len)

get_master_key 是一个重载函数,因为历史原因,为了兼容 5.7.11 版本最初设计的加密格式,需要 uuid 作为一个检索的参数,图中第一个 get_master_key 函数根据传入的参数调用 my_key_fetch 查找,第二个函数的参数都是指针类型,返回之后都会被赋值,如果发现当前的 Encryption::master_key_id 为 0,说明还没有产生过 master_key ,就执行类似 create_master_key 的逻辑创建。

接下来看加密和解密的函数,参数类似,就是传入一个 src 页,然后加密好放到 dst 页中,数据页加密使用的加密算法是 my_aes_256_cbc,这种加密算法要求每个加密块大小是 128 bit(16 Byte),也就是说数据页的大小必须是这个值得整数倍,InnoDB 的页默认大小是 16K,传入整个页进行加密是完全可行的。但是官方选择了最小加密原则,仅仅只对页面中用户数据部分加密,页面头保持明文存储。所以无论是加密还是解密都分成了两次调用,一次对 main_len 大小数据加密,另外一次对 remain_len 加密:

data_len = src_len - FIL_PAGE_DATA;
main_Len = (data_len / MY_AES_BLOCK_SIZE) * MY_AES_BLOCK_SIZE;
remain_len = data_len - main_len;

加密部分会修改页面的 page type 字段,从 src 页固定位置取出当前的页类型,如果是加密过的,就报错,加密结束之后根据原有的页面类型,修改为对应的加密页类型:

if (page_type == FIL_PAGE_COMPRESSED) {
		mach_write_to_2(dst + FIL_PAGE_TYPE,
		FIL_PAGE_COMPRESSED_AND_ENCRYPTED);
	} else if (page_type == FIL_PAGE_RTREE) {
		/* If the page is R-tree page, we need to save original
		type. */
		mach_write_to_2(dst + FIL_PAGE_TYPE, FIL_PAGE_ENCRYPTED_RTREE);
	} else{
		mach_write_to_2(dst + FIL_PAGE_TYPE, FIL_PAGE_ENCRYPTED);
		mach_write_to_2(dst + FIL_PAGE_ORIGINAL_TYPE_V1, page_type);
	}

其中 R-tree 是 MySQL 为了支持 GIS 引入的数据类型。WL#6968.解密开始会判断 page type 是否是压缩类型,需要修改src_len 的大小。

Tablespace key 初始化和读取

Tablespace key 是真正用来加密用户页面数据的,就是上节介绍的 Encryption 类的 m_key,tablespace key 会在 create 一个加密表或者 alter 一个表变为加密表时创建,以 create table 流程为例,因为只支持独立表空间,所以需要递增的产生一个 space id,然后创建一个 ibd 文件,并且初始化为默认的 page 数量大小,把 space id 和 fsp flags 写入第一个页的头部(fsp flags 对应整体架构图中 Encryption 部分,标记一个表空间为加密表空间,flags 由 dict_table_t 结构的 flag 转化而来,而 dict_table_t 的 flag 是从 server 层的 TABLE_SHARE 中获得,源头就是用户执行的 create table 语句语法), 创建 file_space_t,初始化 file_node(虽然表空间只有一个 ibd 文件),接着调用 fil_set_encryption 函数生成 tablespace key,并且保存在刚刚创建的 fil_space_t 中。最终会在 fsp_head_init 函数中把 tablespace key 相关信息写入页面中,对应整体架构图中 Encryption Infomation 部分。函数调用栈:

dict_build_tablespace
      |
      --- fil_ibd_create
      |	    |
      |	    --- os_fil_create
      |	    |
      |	    --- os_file_set_size
      |	    |
      |	    --- fsp_header_init_fields
      |     |
      |     --- os_file_write
      |     |
      |     --- file_space_create
      |     |
      |     --- fil_set_encryption(space_id, Encryption::AES, NULL, NULL)
      |
      --- fsp_header_init
            |
            --- fsp_header_fill_encryption_info
            |
            --- mlog_write_string

重点看一下 fil_set_encryption 是怎么生成 tablespace key 的,还有具体保存在页面中都有哪些东西。上图调用栈最后两个参数为 NULL,对应的就是需要生成的 tablespace key 和加密向量 iv,因为是表空间刚刚创建,NULL 表示需要生成。首先判断是不是系统表空间 is_system_tablespace(space_id) 对于系统表空间不进行加密处理,如果 key 和 iv 为 NULL, 就调用 Encryption::random_value 产生一个随机的值,对于 key 和 iv 都一样,Encryption::random_value 最终会调用 YaSSL/OpenSSL 的 RAND_bytes 函数产生随机值,如果 key 和 iv 不为 NULL,就不必产生,最终赋值到 fil_space_t 中变量即可返回。

真正把 tablespace 加密并且写入到页中的是函数 fsp_header_fill_encryption_info, 从 Encryption::get_master_key 拿到加密的 master_key,从 fil_space_t 中拿到明文的 tablespace key 和 iv,使用 my_aes_256_ecb 加密算法进行加密,ecb 加密算法相对于 cbc 具有更高的安全度,当前开销也更大,看来官方也意识到把 tablespace key 放到文件里存在一定的安全隐患。存储的格式分为两种,是高版本为了向下兼容:

  • ENCRYPTION_INFO_V1: magic number + master_key_id + key + iv + checksum
  • ENCRYPTION_INFO_V1: magic number + master_key_id + key + iv + server_uuid + checksum

上述除了 key + iv 之外都是明文存储,checksum 是 key+iv 的明文使用32位循环冗余校验得到的。

接下来介绍打开一张已经加密的表,tablespace key/iv 是如何初始化的,直接来看下调用堆栈:

  fil_ibd_open
    |
    --- Datafile.validate_to_add
    |       |
    |       --- Datafile::validate_first_page
    |               |
    |               --- fsp_header_get_encryption_key
    |                           |
    |                           --- fsp_header_get_encryption_offset
    |                                        |
    |                                        --- fsp_header_decode_encryption_info
    |
    --- fil_space_create
    |
    --- fil_set_encryption

正常打开一张表首先会根据表名去 ibdata 的字典表里查找元数据信息,例如文件路径,dict_table_t 的 flags/flags2 等等,接下来就是调用 fil_ibd_open 打开文件, 并且做一系列的校验,Datafile 是用来维护文件信息的类,在 validate_first_page 中会根据 flag 判断是否加密表空间,如果是的话,就读出第一个页,传给 fsp_header_get_encryption_key 函数,在函数里首先计算偏移,然后交给 fsp_header_decode_encryption_info 对 key/iv 进行解密。fsp_header_decode_encryption_info 首先校验 magic number,然后读出 master_key_id 和 server_uuid,用来查找 master_key, 然后用 master_key 解密 tablespace key 得到明文,最后一步是用明文再做一次循环冗余校验,和保存的 checksum 对比,值是否相同。至此已经得到了正确的 tablespace key。接着创建 fil_space_t 然后把明文的 key/iv 放进去,以备后面 IO 使用。

IO 路径解析

InnoDB 的 IO 分为同步 IO 和 异步 IO,同步 IO 调用操作系统的 pwrite/pread 函数,异步 IO 又分为 simulate IO 和 Linux native aio, 关于 IO 的详细介绍可以参考 InnoDB 文件系统之 IO 系统和内存管理InnoDB IO 子系统InnoDB 异步 IO 工作流程三篇月报。这里介绍一些加密是在哪里 IO 路径中的,首先是同步 IO 路径, 以 write 为例,read 类似。在 fil_io 中初始化 IORequest 类中的 encryption 相关信息,根据要读写的 page id, fil_space。

fil_io
  |
  --- fil_io_set_encryption
  |
  --- os_file_write
  |       |
  |       --- os_file_write_pfs
  |                 |
  |                 --- os_file_write_func
  |                           |
  |                           --- os_file_write_page
  |                                     |
  |                                     --- os_file_pwrite
  |                                             |
  |                                             --- os_file_io
  |                                                     |
  |                                                     --- os_file_encrypt_page
  |                                                                |
  --- os_file_read                                                 --- Encryption::encrypt
            |
            --- ......
            |
            --- os_file_io
                  |
                  --- os_file_io_complete
                           |
                           --- Encryption::decrypt

异步 IO 无论是 simulate IO 还是 native aio, 都是把请求放到一个 slot 里,由后台异步线程去刷盘, 发起 IO 请求的入口函数是 os_aio_func, 对于同步读写请求(OS_AIO_SYNC),发起请求的线程直接调用os_file_read_func 或者os_file_write_func 去读写文件,然后返回。对于异步请求,用户线程从对应操作类型的任务队列(AIO::select_slot_array)中选取一个slot,将需要读写的信息存储于其中(AIO::reserve_slot), 对于 write 操作,此时把需要写入的数据进行加密。对于Native AIO(使用linux自带的LIBAIO库),调用函数AIO::linux_dispatch,将IO请求分发给kernel层。

fil_io
  |
  --- fil_io_set_encryption
  |
  ---os_aio
       |
       --- os_aio_func
               |
               --- AIO::reserve_slot
               |        |
               |        --- os_file_encrypt_page
               |        |
               |        --- Encryption::encrypt
               |
               --- linux_dispatch(slot)

处理异步 IO 请求的入口函数是 fil_aio_wait, 对于 Native AIO,调用函数os_aio_linux_handle 获取读写请求。IO线程会反复以500ms(OS_AIO_REAP_TIMEOUT)的超时时间通过io_getevents确认是否有任务已经完成了(LinuxAIOHandler::collect()),如果有读写任务完成,找到已完成任务的slot后,释放对应的槽位,写请求已经加密过,直接写入即可,读请求需要进行解密,调用堆栈如下。

fil_aio_wait
     |
     --- os_aio_linux_handle
                |
                --- LinuxAIOHandler::collect
                            |
                            --- io_complete
                                    |
                                    --- os_file_io_complete
                                               |
                                               --- Encryption::decrypt

Master key rotation

Master_key 对于整个实例加密非常重要,官方加密方法最重要的一个特性就是可以更新 master_key, 因为 tablespace_key 的明文不会变,更新 master_key 之后只需要把 tablespace_key 重新加密写入第一个页中即可。入口是 server 层一个类 Rotate_innodb_master_key::execute 函数,这个类继承 Alter_instance 类,execute 函数会调用 innobase_encryption_key_rotation , 这个函数在引擎初始化(innobase_init)的时候注册到 innobase_hton 中。接着创建一个新的 master_key ,由于明文的 tablesapce key 保存在 fil_space_t 中,无需用原来的 master_key 进行解密。然后 fil_encryption_rotate 遍历 fil_system 中的每一个 fil_space_t ,调用 fsp_header_rotate_encrytion 加密 tablespace_key 并存储。

Rotate_innodb_master_key::execute
          |
          --- innobase_encryption_key_rotation
                      |
                      --- Encryption::create_master_key
                      |
                      --- fil_encryption_rotate
                                  |
                                  --- fsp_header_rotate_encryption
                                           |
                                           --- fsp_header_fill_encryption_info
                                           |
                                           --- mlog_write_string

Export/ Import

为了支持 Export/Import 加密表,引入了 transfer_key,在 export 的时候随机生成一个 transfer_key, 把现有的 tablespace_key 用 transfer_key 加密,并将两者同时写入 table_name.cfp 的文件中,注意这里 transfer_key 保存的是明文。Import 会读取 transfer_key 用来解密,然后执行正常的 import 操作即可,一旦 import 完成,table_name.cfg 文件会被立刻删除。写 transfer_key 调用栈:

row_quiesce_write_cfp
        |
        --- row_quiesce_write_transafer_key
                  |
                  --- Encryption::random_value(transfer_key)
                  |
                  --- my_aes_encrypt(my_aes_256_ecp)

import 调用栈:

row_import_for_mysql
    |
    --- row_import_read_cfg
    |       |
    |       --- row_import_read_encryption_data
    |                   |
    |                   --- fread(table_name.cfp)
    |                   |
    |                   --- my_aes_decrypt
    |
    --- fil_tablespace_iterate
            |
            --- fil_iterator
                  |
                  --- IORequest::encryption_key
                  |
                  --- os_file_read
                  |
                  --- os_file_write			  						

崩溃恢复

在数据库进行崩溃恢复的时候 InnoDB 是无法从字典表取得数据的,也就是说正常判断一个表是不是加密表的路径(Dict_table_t::flags2 -> fil_space_t)是行不通的,所以需要在 ibd 文件的头部标记加密,读取 ibd 就知道表空间类型。对于官方的加密方法,因为有 tablespace_key 的相关信息持久化在页面上,受 redo 保护,所以在崩溃恢复的时候需要能够从 redo 中正确解析,需要增加处理逻辑。对于崩溃恢复的详细介绍可以参考早期月报 Innodb 崩溃恢复过程。这部分相关修改并不多,首先需要构建 recv_sys , 在 recv_sys 结构中增加了一个 encryption_list, 保存需要回复的加密表空间信息, 初始化在 recv_parse_or_apply_log_rec_body 中,如果是 page = 0 的页,并且不是系统表空间,就调用 fil_write_encryption_parse -> fsp_header_decode_encryption_info 进行解析,拿到 master_id, 查找 keyring plugin 得到 master_key, 然后解析 tablespace_key,如果是未加载的表空间,就放到 recv_sys->encryption_list 里面。

在构建 recv_spaces 的时候,会调用 fil_name_parse->fil_name_process->fil_ibd_load , 如果是加密表空间,并且在 recv_sys->encryption_list 中,就从 recv_sys->encryption_list 里找到对应的 space id 初始化加密信息,后面应用 redo 日志就可以先对页面进行加解密处理。

总结

官方的这种加密方式优点和缺点都相当明显,优点是 master_key 可以经常更新,能够满足一定的用户需求,并且每个表都可以拥有不同的秘钥 tablespace_key ,即使一张表被破解,其它表也不会立刻丢失数据。缺点也在 tablesapce_key 的存放上,所有的人都知道加密的秘钥保存在哪里,甚至知道明文的 checksum 是什么,对于高安全的用户来说,秘钥不落地是非常重要的,显然官方的这种加密方式无法满足。另外一个很危险的就是 export/import , 虽然重新生成了一个 transfer_key,但是竟然是明文保存在文件里,即使用完就会删除,但是这个时间间隙被利用就相当危险了。


MongoDB · myrocks · mongorocks 引擎原理解析

$
0
0

mongorocks是基于著名的开源KV数据库RocksDB实现的一个MongoDB存储引擎,借助rocksdb的优秀特性,mongorocks能很好的支持一些高并发随机写入、读取的应用场景。

MongoDB 与 mongorocks 的关系

rocks1

mongodb 支持多种引擎,目前官方已经支持了mmapv1、wiredtiger、in-Memory等,而mongorocks则是第三方实现的存储引擎之一(对应上图红框的位置)。

MongoDB KV存储引擎模型

MongoDB 从 3.0 版本 开始,引入了存储引擎的概念,并开放了 StorageEngine 的API 接口,为了方便KV存储引擎接入作为 MongoDB 的存储引擎,MongoDB 又封装出一个 KVEngine 的API接口,比如官方的 wiredtiger 存储引擎就是实现了 KVEngine的接口,本文介绍的 mongorocks 也是实现了KVEngine的接口。

KVEngine 主要需要支持如下接口

创建/删除集合

MongoDB 使用 KVEngine 时,将所有集合的元数据会存储到一个特殊的 _mdb_catalog的集合里,创建、删除集合时,其实就是往这个特殊集合里添加、删除元数据。

_mdb_catalog特殊的集合不需要支持索引,只需要能遍历读取集合数据即可,MongoDB在启动时,会遍历该集合,来加载所有集合的元数据信息。

数据存储及索引

插入新文档时,MongoDB 会调用底层KV引擎存储文档内容,并生成一个 RecordId 的作为文档的位置信息标识,通过 RecordId 就能在底层KV引擎读取到文档的内容。

如果插入的集合包含索引(MongoDB的集合默认会有_id索引),针对每项索引,还会往底层KV引擎插入一个新的 key-value,key 是索引的字段内容,value 为插入文档时生成的 RecordId,这样就能快速根据索引找到文档的位置信息。

rocks2

如上图所示,集合包含{_id: 1}, {name: 1} 2个索引

  1. 用户插入文档时,底层引擎将文档内容存储,返回对应的位置信息,即 RecordId1
  2. 集合包含2个索引
    • 插入 {_id: ObjectId1} ==> RecordId1 的索引
    • 插入 {name: “rose”} ==> RecordId1 的索引

有了上述的数据,在根据_id访问时文档时 (根据其他索引字段类似)

  1. 根据文档的 _id 字段从底层KV引擎读取 RecordId
  2. 根据 RecordId 从底层KV引擎读取文档内容

mongorock 存储管理

_2016_12_16_6_01_01

mongorocks 存储数据时,每个key都会包含一个32位整型前缀,实际存储时将整型转换为big endian格式存储。

  • 所有的元数据的前缀都是0000
  • 每个集合、以及集合的每个索引都包含不同的前缀,集合及索引与前缀的关系存储在 0000metadata-*为前缀的key里
  • _mdb_catalog在mongorocks也是一个普通的集合,有单独的前缀

创建集合、写数据

  1. 创建集合或索引时,mongrocks会为其分配一个前缀,并将对应关系持久化,比如创建集合 bar(默认会创建_id字段的索引),mongorocks 会给集合和索引各分配一个前缀,如上图所示的 0002, 0003,并将对应关系持久化。
  2. 接下来往bar集合里写的所有数据,都会带上 0002前缀;
  3. 往其_id索引里写的数据都会带上前缀 0003

写索引时,有个比较有意思的设计,重点介绍下 (其他的key-value引擎,如wiredtiger也使用类似的机制)

MongoDB 支持复合索引,比如db.createIndex({a: 1, b, -1, c, 1}),这个索引要先按a字段升序、a相同的按b字段降序.. 依此类推,但KV引擎并没有这么强大的接口,如何实现对这种复合索引的支持呢?

MongoDB针对每个索引,会有一个位图来描述索引各个字段的排序方向,比如插入如下2条索引时 (key的部分会转换为BSON格式插入到底层)

{a: 100, b: 200, c: 300}  == > RecordId1
{a: 100, b: 300, c: 400}  ==> RecordId2  

插入到底层 RocksDB,第1条记录会排在第2条记录前面,但我们建立的索引是 {a: 1, b, -1, c, 1},按这个索引,第2条记录应该排在前面才对,否则索引顺序就是错误的。

mongorocks 在存储索引数据时,会根据索引的排序位图,如果方向是逆序(如b: -1),会把key的内容里将b字段对应的bit全部取反,这样在 RocksDB 里第2条记录就会排在第1条前面。

读取数据

根据_id来查找集合数据时,其他访问方式类似

  1. 根据集合的名字,在元数据里找到集合的前缀 0002及其_id索引对应的前缀 0003
  2. 根据 0003 + 文档id生成文档_id索引的key,并根据key读取出文档的RecordId
  3. 根据 0002 + RecordId生成存储文档内容的key,并根据key读取出文档的内容

删除集合

  1. 将集合的元数据从_mdb_catalog移除
  2. 将集合及其索引与前缀的对应关系都删除掉
  3. 将第2步里删除的前缀加入到待删除列表,并通知 RocksDB 把该前缀开头的所有key通过compact来删除掉(通过定制CompactionFilter来实现,这个compact过程是异步做的,所以集合删了,会看到底层的数据量不会立马降下来),同时持久化一条0000droppedprefix-被删除前缀的记录,这样是防止compact被删除前缀的过程中宕机,重启后被删除前缀的key不会被会收掉,直到待删除前缀所有的key都被回收时,最终会把0000droppedprefix-被删除前缀的记录删除掉。

文档原子性

MongoDB 写入文档时,包含如下步骤

  1. 插入文档到集合
  2. 更新集合所有的索引
  3. 记录oplog(如果是复制集模式运行)

MongoDB 保证单文档的原子性,上述3个步骤必须全部成功应用或者全部不应用,mongorocks 借助 RocksDB 的 WriteBatch 接口来保证,将上述3个操作放到一个WriteBatch中,最后一次提交,RocksDB 层面会保证 WriteBatch 操作的原子性。

特殊的oplog

在MongoDB里,oplog是一个特殊的 capped collection(可以理解为环形存储区域),超过配置的大小后,会将最老的数据删除掉,如下是2个oplog的例子,mongorocks在存储oplog时,会以oplog集合前缀 + oplog的ts字段作为key来存储,这样在RocksDB,oplog的数据都是按ts字段的顺序来排序的。

0008:ts_to_uint64 ==> { "ts" : Timestamp(1481860966, 1), "t" : NumberLong(71), "h" : NumberLong("-6964295105894894386"), "v" : 2, "op" : "i", "ns" : "test.tt", "o" : { "_id" : ObjectId("58536766d38c0573d2ff5b90"), "x" : 2000 } }
0008:ts_to_uint64 ==> { "ts" : Timestamp(1481860960, 1), "t" : NumberLong(71), "h" : NumberLong("3883981042971627762"), "v" : 2, "op" : "i", "ns" : "test.tt", "o" : { "_id" : ObjectId("58536760d38c0573d2ff5b8f"), "x" : 1000 } }

capped collection 当集合超出capped集合最大值时,就会逐个遍历最先写入的数据来删除,直到空间降到阈值以下。

mongorocks 为了提升回收oplog的效率,做了一个小的优化。

针对oplog集合,插入的每一个文档,除了插入数据本身,还会往一个特殊的集合(该集合的前缀为oplog集合的前缀加1)里插入一个相同的key,value为文档大小。比如

0008:ts_to_uint64 ==> { "ts" : Timestamp(1481860966, 1), "t" : NumberLong(71), "h" : NumberLong("-6964295105894894386"), "v" : 2, "op" : "i", "ns" : "test.tt", "o" : { "_id" : ObjectId("58536766d38c0573d2ff5b90"), "x" : 2000 } }
0009:ts_to_uint64 ==> 88 (假设88为上面这个文档的大小)

有了这个信息,在删除oplog最老的数据时,就可以先遍历包含oplog文档大小信息的集合,获取被删除文档的大小,而不用把整个oplog的key-value都读取出来,然后统计大小。个人觉得这个优化当oplog文档大小比较大效果会比较好,文档小的时候并不一定能有效。

集合大小元数据管理

MongoDB 针对collection的count()接口,如果是全量的count,默认是O(1)的时间复杂度,但结果不保证准确。mongorocks 为了兼容该特性,也将每个集合的『大小及文档数』也单独的存储起来。

比如集合foo的大小、文档数分别对应2个key

0000datasize-foo ==> 14000  (0000是metadata的前缀)
0000numrecords-foo  ==> 100

上面2个key,当集合里有增删改查时,默认并不是每次都更新,而是累计到一定的次数或大小时更新,后台也会周期性的去更新所有集合对应的这2个key。

mongorocks 也支持每次操作都将 datasize、numrecords 的更新进行持久化存储,配置storage.rocksdb.crashSafeCounters参数为true即可,但这样会对写入的性能有影响。

数据备份

借助 RocksDB 本身的特性,mongorocks能很方便的支持对数据进行物理备份,执行下面的命令,就会将产生一份快照数据,并将对应的数据集都软链接到/var/lib/mongodb/backup/1下,直接拷贝该目录备份即可。

db.adminCommand({setParameter:1, rocksdbBackup: "/var/lib/mongodb/backup/1"})

总结

总体来说,MongoDB 存储引擎需要的功能,mongorocks 都实现了,但因为 RocksDB 本身的机制,还有一些缺陷,比如

  • 集合的数据删除后,存储空间并不是立即回收,RocksDB 要通过后台压缩来逐步回收空间
  • mongorcks 对 oplog 空间的删除机制是在用户请求路径里进行的,这样可能导致写入的延迟上升,应像 wiredtiger 这样当 oplog 空间超出时,后台线程来回收。
  • RocksDB 缺乏批量日志提交的机制,无法将多次并发的写log进行合并,来提升效率。

MySQL · 引擎特性 · InnoDB 数据页解析

$
0
0

前言

之前介绍的月报中,详细介绍了InnoDB Buffer Pool的实现细节,Buffer Pool主要就是用来存储数据页的,是数据页在内存中的动态存储方式,而本文介绍一下数据页在磁盘上的静态存储方式以及相关的操作。由于数据页的结构涉及InnoDB非常底层的代码,因此各个版本的MySQL都可以参考。相关代码主要集中在page目录下。

基础知识

数据库采用数据页的形式组织数据。MySQL默认的非压缩数据页为16KB。在ibd中间中,0-16KB偏移量即为0号数据页,16KB-32KB的为1号数据页,依次类推。数据页的头尾除了一些元信息外,还有Checksum校验值,这些校验值在写入磁盘前计算得到,当从磁盘中读取时,重新计算校验值并与数据页中存储的对比,如果发现不同,则会导致MySQL crash。遇到这种情况,往往需要从备份集中恢复数据,如果备份不可用,只能使用innodb_force_recovery强行启动,然后尽可能多的导出数据。这篇月报中介绍了一种从物理文件中恢复数据的方法,在走投无路的情况下可以使用。

数据页格式

严格来讲,InnoDB的数据页有很多种,比如,索引页,Undo页,Inode页,系统页,BloB页等,一共有10多种。本文主要介绍最常见的索引页。下文中,没有特殊说明,数据页都指索引页。 数据页包括七个部分,数据页文件头,数据页头,最大最小记录,用户记录,空闲空间,数据目录,数据页尾部。 简单的来说,数据页分两部分,一部分存储数据记录,按照记录的大小通过记录的指针连接起来。另外一部分存储数据页的目录,用来加速查找。注意这个目录是稀疏的,即不是所有的记录在目录都有索引,平均是每隔六个记录才有一个目录。数据记录部分是从低地址向高地址空间增长的,而数据目录部分则相反。这种数据结构可以保证比较高的插入删除和查找效率。具体方法详见核心函数小节。 这篇月报的最后有一张图,详细展示了数据页的结构,读者可以先自行了解一下,接下来,本文解释一下各个部分的内容。

数据页文件头(Fil Header)

这个部分主要用来存储表空间相关的信息。主要在fil0fil.h这个文件中。

FIL_PAGE_SPACE_OR_CHKSUM:这个占用四字节,主要用来存储数据页的checksum。注意,计算校验值的时候,并不是整个数据页都计算,有几个地方是不计算进去的(buf_calc_page_crc32buf_calc_page_new_checksum),例如头尾存checksum的地方,存space_id的地方(历史原因导致)。Checksum的计算方式详见数据页Corruption这一小节。

FIL_PAGE_OFFSET:这个就是对应数据页的page number,每个表空间从0开始,即这个值乘以数据页的大小就可以得到数据页在文件中的起始偏移量。fio_io函数读取以及写入数据页的时候依赖这个规则。

FIL_PAGE_PREV,FIL_PAGE_NEXT:这两个是指针,分别指向前一个数据页和后一个数据页。注意,这里的前后是指按照用户记录排序的先后顺序,也是逻辑顺序。因为在InnoDB数据页不断的分配和释放中,会导致逻辑上连续的数据页在物理上不连续。所以需要指针链接。前后两个指针共同构建了一个双向链表。

FIL_PAGE_LSN:当前数据页最新被修改的lsn。这个字段非常重要,InnoDB redolog幂等的特性就依赖此字段。在奔溃恢复应用日志阶段,如果发现redolog的lsn小于等于这个值,就不需要再次应用redolog了。

FIL_PAGE_TYPE:当前页面是哪种类型的数据页。包括,索引页,Undo页,Inode页,系统页,BloB页等十几种。

FIL_PAGE_FILE_FLUSH_LSN: ibdata文件第一个数据页才有意义,记录ibdata成功刷到磁盘的lsn。

FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID:现在的版本就是用来存spaceid的。

数据页头(Page Header)

从存储的信息来看,这部分才是存的数据页相关的元信息。定义在page0page.h中。

PAGE_N_DIR_SLOTS:这个表示数据页中数据目录的个数。一个新建的空数据页,就有2个目录,分别指向最大记录和最小记录。在一个非空的数据页中,第一个目录永远指向最小记录,最后一个目录永远指向最大记录。当增加目录的时候,会递增这个值。

PAGE_HEAP_TOP:这个指向数据页中的空闲空间的起始地址。大于这个地址的且小于数据目录的空间都是未分配的,可以被后续使用。但是由于空闲记录链表(PAGE_FREE)的存在,小于这个地址的也可能被重用。

PAGE_N_HEAP:目前已经被使用空间中的记录数量,包括正常的记录和已经被删除(放入PAGE_FREE中)的记录。从代码逻辑看,这个值是不会减少的,每次都空闲空间记录的时候就会增加。在创建新的空页时候,默认被置为2,即最大和最小记录。此外,最高位被用来标记这个数据页是否存了新格式的记录(compact和redundant)。

PAGE_FREE:删除记录的链表,记录被删除,会放到这个链表头上,如果这个页上有记录要插入,可以先从这里分配空间,如果空间不够,才从空闲地址(PAGE_HEAP_TOP)分配。注意放到这个链表里面的,都是被purge线程彻底删除的记录,delete-marked的记录不在这里。

PAGE_GARBAGE:所有已经被删除的记录占用空间的大小。主要是为了方便计算空闲的空间。

PAGE_LAST_INSERT:指向最近一个被插入的记录的。主要用来加速后续插入操作。

PAGE_DIRECTION:最后一个记录插入的方向,目前就两个方向,从左边插入和从右边插入。也是用来加速后续插入操作。

PAGE_N_DIRECTION:同一个方法插入的记录数。主要用来加速后续插入操作。

PAGE_N_RECS:当前数据页中用户的记录,不包括最大和最小记录。与PAGE_N_HEAP不同,如果记录被标记为delete-marked,这个值就会递减。

PAGE_MAX_TRX_ID:修改此数据页的当前最大事务id。

PAGE_LEVEL:这个页是否是B树中的叶子节点。如果是0,就是叶子节点。

PAGE_INDEX_ID:索引页的索引id。

PAGE_BTR_SEG_LEAF,PAGE_BTR_SEG_TOP:分别是叶子节点和非叶子节点的段头页地址。

最大最小记录(Infimum and Supremum Records)

最大记录是这个数据页中逻辑上最大的记录,所有用户的记录都小于它。最小记录是数据页上最小的记录,所有用户记录都大于它。他们在数据页被创建的时候创建,而且不能被删除。引入他们主要是方便页内操作。

用户记录(User Records)

用户所有插入的记录都存放在这里,默认情况下记录跟记录之间没有间隙,但是如果重用了已删除记录的空间,就会导致空间碎片。每个记录都有指向下一个记录的指针,但是没有指向上一个记录的指针。记录按照主键顺序排序。即,用户可以从数据页最小记录开始遍历,直到最大的记录,这包括了所有正常的记录和所有被delete-marked记录,但是不会访问到被删除的记录(PAGE_FREE)。

空闲空间(Free Space)

PAGE_HEAP_TOP开始,到最后一个数据目录,这之间的空间就是空闲空间,都被重置为0,当用户需要插入记录时候,首先在被删除的记录的空间中查找,如果没有找到合适的空间,就从这里分配。空间分配给记录后,需要递增PAGE_N_RECSPAGE_N_HEAP

数据目录(Page Directory)

用户的记录是从低地址向高地址扩展,而数据目录则相反。在数据页被初始化的时候,就会数据页最后(当然在checksum之前)创建两个数据目录,分别指向最大和最小记录。之后插入新的数据的时候,需要维护这个目录,例如必要的时候增加目录的个数。每个数据目录占用两个字节,存储对应记录的页内偏移量。假设目录N,这个目录N管理目录N-1(不包括)和目录N之间的记录,我们称目录N own 这些记录。在目录N指向的记录中,会有字段记录own记录的数量。由此可见,目录own的记录不能太多,因为太多的话,即意味着目录太过稀疏,不能很好的提高查询效率,但同时也不能own太少,这会导致目录数量变多,占用过多的空间。在InnoDB的实现中,目录own的记录数量在4-8之间,包括4和8,平均是6个记录。如果超过这个数量,就需要重新均衡目录的数量。目录的增加和删除可能需要进行内存拷贝,但是由于目录占用的总体空间很小,开销可以忽略不计。

数据页尾部(Fil Trailer)

这个部分处于数据页最后的位置,只有8个字节。低地址的四个字节存储checksum的值,高地址的四个字节存储FIL_PAGE_LSN的低位四字节。注意这里的checksum的值不一定与FIL_PAGE_SPACE_OR_CHKSUM的相同,这个依赖不同的checksum计算方法。

核心函数

本节详细剖析一下数据页相关的几个核心函数。

插入记录

核心入口函数在page_cur_insert_rec_low。核心步骤如下:

  1. 获取记录的长度。函数传入参数就有已经组合好的完整记录,所以只需要从记录的元数据中获取即可。
  2. 首先从PAGE_FREE链表中尝试获取足够的空间。仅仅比较链表头的一个记录,如果这个记录的空间大于需要插入的记录的空间,则复用这块空间(包括heap_no),否则就从PAGE_HEAP_TOP分配空间。如果这两个地方都没有,则返回空。这里注意一下,由于只判断Free链表的第一个头元素,所以算法对空间的利用率不是很高,估计也是为了操作方便。假设,某个数据页首先删除了几条大的记录,但是最后一条删除的是比较小的记录A,那么后续插入的记录大小只有比记录A还小,才能把Free链表利用起来。举个例子,假设先后删除记录的大小为4K, 3K, 5K, 2K,那么只有当插入的记录小于2K时候,这些被删除的空间才会被利用起来,假设新插入的记录是0.5K,那么Free链表头的2K,可以被重用,但是只是用了前面的0.5K,剩下的1.5K依然会被浪费,下次插入只能利用5K记录所占的空间,并不会把剩下的1.5K也利用起来。这些特性,从底层解释了,为什么InnoDB那么容易产生碎片,经常需要进行空间整理。
  3. 如果Free链表不够,就从PAGE_HEAP_TOP分配,如果分配成功,需要递增PAGE_N_HEAP
  4. 如果这个数据页有足够的空间,则拷贝记录到指定的空间。
  5. 修改新插入记录前驱上的next指针,同时修改这条新插入记录的指针next指针。这两步主要是保证记录上链表的连续性。
  6. 递增PAGE_N_RECS。设置heap_no。设置owned值为0。
  7. 更新PAGE_LAST_INSERTPAGE_DIRECTIONPAGE_N_DIRECTION,设置这些参数后,可以一定程度上提高连续插入的性能,因为插入前需要先定位插入的位置,有了这些信息可以加快查找。详见查找记录代码分析。
  8. 修改数据目录。因为增加了一条新的记录,可能有些目录own的记录数量超过了最大值(目前是8条),需要重新整理一下这个数据页的目录(page_dir_split_slot)。算法比较简单,就是找到中间节点,然后用这个中间节点重新构建一个新的目录,为了给这个新的目录腾空间,需要把后续的所有目录都平移,这个涉及一次momove操作(page_dir_split_slotpage_dir_add_slot)。
  9. 写redolog日志,持久化操作。
  10. 如果有blob字段,则处理独立的off-page。

删除记录

注意这里的删除操作是指真正的删除物理记录,而不是标记记录为delete-mark。核心函数入口函数在page_cur_delete_rec。步骤如下:

  1. 如果需要删除的记录是这个数据页的最后一个记录,那么直接把这个数据页重新初始化成空页(page_create_empty)即可。
  2. 如果不是最后一条,就走正常路径。首先记录redolog日志。
  3. 重置PAGE_LAST_INSERT和递增block的modify clock。后者主要是为了让乐观的查询失效。
  4. 找到需要删除记录的前驱和后继记录,然后修改指针,使前驱直接指向后继。这样记录的链表上就没有这条记录了。
  5. 如果一个目录指向这条被删除的记录,那么让这个目录指向删除记录的前驱,同时减少这个目录own的记录数。
  6. 如果这个记录有blob的off-page,则删除。
  7. 把记录放到PAGE_FREE链表头部,然后递增PAGE_GARBAGE的大小,减小PAGE_N_RECS用户记录的值。
  8. 由于第五步中递减了own值,可能导致own的记录数小于最小值(目前是4条)。所以需要重新均衡目录,可能需要删除某些目录(page_dir_balance_slot)。具体算法也比较简单,首先判断是否可以从周围的目录中挪一条记录过来,如果可以直接调整一下前后目录的指针即可。这种简单的调整要求被挪出记录的目录own的记录数量足够多,如果也没有足够的记录,就需要删除其中一个目录,然后把后面的目录都向前平移(page_dir_delete_slot)。

查找记录/定位位置

在InnoDB中,需要查找某条件记录,需要调用函数page_cur_search_with_match,但如果需要定位某个位置,例如大于某条记录的第一条记录,也需要使用同一个函数。定位的位置有PAGE_CUR_G,PAGE_CUR_GE,PAGE_CUR_L,PAGE_CUR_LE四种,分别表示大于,大于等于,小于,小于等于四种位置。 由于数据页目录的存在,查找和定位就相对简单,先用二分查找,定位周边的两个目录,然后再用线性查找的方式定位最终的记录或者位置。 此外,由于每次插入前,都需要调用这个函数确定插入位置,为了提高效率,InnoDB针对按照主键顺序插入的场景做了一个小小的优化。因为如果按照主键顺序插入的话,能保证每次都插入在这个数据页的最后,所以只需要直接把位置直接定位在数据页的最后(PAGE_LAST_INSERT)就可以了。至于怎么判断当前是否按照主键顺序插入,就依赖PAGE_N_DIRECTIONPAGE_LAST_INSERTPAGE_DIRECTION这几个信息了,目前的代码中要求满足5个条件:

  1. 当前的数据页是叶子节点
  2. 位置查询模式为PAGE_CUR_LE
  3. 相同方向的插入已经大于3了(page_header_get_field(page, PAGE_N_DIRECTION) > 3)
  4. 最后插入的记录的偏移量为空(page_header_get_ptr(page, PAGE_LAST_INSERT) != 0)
  5. 从右边插入的(page_header_get_field(page, PAGE_DIRECTION) == PAGE_RIGHT)

其他函数

除了插入删除查找外,还有一些函数也比较重要,例如: page_create,创建新的空页的时候,都需要调用这个函数来初始化元信息。 page_move_rec_list_end,当数据页需要重组时候,需要把数据从一个数据页拷贝到另外一个,这个时候就需要用到。类似的还有函数page_move_rec_list_start,两个函数的拷贝方式不同,一个是从头开始拷贝到指定的记录,另外一个是从指定记录开始拷贝到数据页最后。 page_validate,这种函数主要是debug模式下校验数据页是否损坏的检验函数,不做什么实际工作,但是这些函数非常时候初学者阅读,能快读的理解数据页的结构。 page_cur_open_on_rnd_user_rec,这函数是把位置放到一个随机的记录上,当change buffer的B树满的时候,目前的逻辑是随机选一条记录,进行合并。

数据页Corruption

数据页的checksum值的计算方法依赖参数innodb_checksum_algorithm。目前提供三种计算checksum的方法,第一种是crc校验(buf_calc_page_crc32),这种是一种比较新的计算方法,但是可以使用cpu硬件指令来加速。第二种是innodb校验,是innodb自己开发的一种计算方法,但是有新老两种变体,两种变体计算结果不同,为了兼容老的变体,需要在代码中兼容。第三种是none模式,这种计算方式不计算每个数据页的校验值,而是使用一个指定的值填充checksum字段,这种方式速度很快,但也保证不了数据的正确性。在innodb_checksum_algorithm中,除了innodb,crc32,none三种选项之外,还有strict带头的选项。strict的选项表示,在读取的时候必须是指定的校验方式的校验值才通过,其他的都不行,例如,指定了strict_crc32,那么在数据页被读取计算checksum时候,对应的校验值必须也是crc32的才可通过,但是如果指定crc32,如果存储的是innodb或者none的结果,也是可以通过校验的。之所以提供了这种选项,就是为了兼容老版本的mysql以及防止校验算法被修改而导致的数据不可用。这里提醒一下,使用strict模式由于计算量比较小,因此效率相对较高。

接下里,分析一下checksum写入和读取校验的代码细节。 在一个数据页即将被刷入磁盘的时候,会调用函数buf_flush_init_for_writing进行相关元信息的修改。这里主要包括newest_len和checksum。函数中首先往FIL_PAGE_LSNUNIV_PAGE_SIZE - FIL_PAGE_END_LSN_OLD_CHKSUM分别写入8个字节的newest_lsn,接着计算checksum,填入FIL_PAGE_SPACE_OR_CHKSUM,同时覆盖UNIV_PAGE_SIZE - FIL_PAGE_END_LSN_OLD_CHKSUM起始的四个字节。我们会发现,如果算法是crc32或者none,那么前后两个checksum存储的内容相同,如果是innodb,则后面的checksum会存老版的值。 在一个数据页从磁盘读取的时候,IO线程会回调buf_page_io_complete函数,如果是读取操作,这个函数中会调用函数buf_page_is_corrupted校验数据页的正确性。buf_page_is_corrupted首先会校验头尾的newest_lsn的低四字节是否相同(FIL_PAGE_LSN+4和UNIV_PAGE_SIZE- FIL_PAGE_END_LSN_OLD_CHKSUM+4),如果不相同,直接认为数据页损坏了。接下里会把完整的8字节lsn读取出来,跟系统当前的lsn对比,如果比当前的大,也认为数据页坏了。接下里,会把首尾的checksum都读取出来,如果发现都为0,则进一步判断是否是空页,如果是空页,则认为这个数据页正常(可能是extend文件出来的空页)。接下来,计算数据页内容的校验值,与存储在数据页首尾的值进行比较。在strict算法下,必须完全一致才认为数据页完整,在非strict模式下,只要有一种值匹配即可了。如果校验值通过,则认为数据页完好。如果数据页损坏,则会调用函数buf_page_print输出数据页的信息至错误日志,这也是我们常常看到的数据页错误日志。这个函数会把数据页所有内容,space_id,page_no,头尾lsn,头尾checksum以及各种算法计算的checksum都打印出来,此外,还会依据FIL_PAGE_TYPE推测可能的数据页种类,方便排查问题。

总结

总体来说,InnoDB数据页的结构设计折中了插入,删除以及查找的效率,是一种值得学习的数据结构。此外,了解其的结构和原理,当数据页发生损坏的时候,能不慌不忙,尽最大的努力找出残存的数据,这也是一个优秀DBA不可缺少的素质。

MySQL · MyRocks · TTL特性介绍

$
0
0

概述

MyRocks TTL(Time To Live) 特性允许用户指定表数据的自动过期时间,表数据根据指定的时间在compact过程中进行清理。

MyRocks TTL 简单用法如下,

在comment中通过ttl_duration指定过期时间,ttl_col指定过期时间列

CREATE TABLE t1 (
  a bigint(20) NOT NULL,
  b int NOT NULL,
  ts bigint(20) UNSIGNED NOT NULL,
  PRIMARY KEY (a),
  KEY kb (b)
) ENGINE=rocksdb
COMMENT='ttl_duration=1;ttl_col=ts;';

也可以不指定过期时间列ttl_col,插入数据时会隐式将当前时间做为过期时间列存储到记录中。

CREATE TABLE t1 (
  a bigint(20) NOT NULL,
  PRIMARY KEY (a)
) ENGINE=rocksdb
COMMENT='ttl_duration=1;';

分区表也同样支持TTL

CREATE TABLE t1 (
    c1 BIGINT,
    c2 BIGINT UNSIGNED NOT NULL,
    name VARCHAR(25) NOT NULL,
    event DATE,
    PRIMARY KEY (`c1`) COMMENT 'custom_p0_cfname=foo;custom_p1_cfname=bar;custom_p2_cfname=baz;'
) ENGINE=ROCKSDB
COMMENT="ttl_duration=1;custom_p1_ttl_duration=100;custom_p1_ttl_col=c2;custom_p2_ttl_duration=5000;"
PARTITION BY LIST(c1) (
    PARTITION custom_p0 VALUES IN (1, 2, 3),
    PARTITION custom_p1 VALUES IN (4, 5, 6),
    PARTITION custom_p2 VALUES IN (7, 8, 9)
);


RocksDB TTL

介绍MyRocks TTL实现之前,先来看看RocksDB TTL。
RocksDB 本身也支持TTL, 通过DBWithTTL::Open接口,可以指定每个column_family的过期时间。

每次put数据时,会调用DBWithTTLImpl::AppendTS将过期时间append到value最后。

在Compact时通过自定义的TtlCompactionFilter , 去判断数据是否可以清理。具体参考DBWithTTLImpl::IsStale

bool DBWithTTLImpl::IsStale(const Slice& value, int32_t ttl, Env* env) {
  if (ttl <= 0) {  // Data is fresh if TTL is non-positive
    return false;
  }
  int64_t curtime;
  if (!env->GetCurrentTime(&curtime).ok()) {
    return false;  // Treat the data as fresh if could not get current time
  }
  int32_t timestamp_value =
      DecodeFixed32(value.data() + value.size() - kTSLength);
  return (timestamp_value + ttl) < curtime;
}

RocksDB TTL在compact时才清理过期数据,所以,过期时间并不是严格的,会有一定的滞后,取决于compact的速度。

MyRocks TTL 实现

和RocksDB TTL column family级别指定过期时间不同,MyRocks TTL可表级别指定过期时间。
MyRocks TTL表过期时间存储在数据字典INDEX_INFO中,表中可以指定过期时间列ttl_col, 也可以不指定, 不指定时会隐式生成ttl_col.

对于主键,ttl_col的值存储在value的头8个字节中,对于指定了过期时间列ttl_col的情况,value中ttl_col位置和valule的头8个字节都会存储ttl_col值,这里有一定的冗余。具体参考convert_record_to_storage_format

读取数据会自动跳过ttl_col占用的8个字节,参考convert_record_from_storage_format

对于二级索引,也会存储ttl_col同主键保持一致,其ttl_col存储在value的unpack_info中,

 if (m_index_type == INDEX_TYPE_SECONDARY &&
     m_total_index_flags_length > 0) {
   // Reserve space for index flag fields
   unpack_info->allocate(m_total_index_flags_length);

   // Insert TTL timestamp
   if (has_ttl() && ttl_bytes) {
     write_index_flag_field(unpack_info,
                            reinterpret_cast<const uchar *const>(ttl_bytes),
                            Rdb_key_def::TTL_FLAG);
   }
 }

二级索引ttl_col同主键保持一致。 对于更新显式指定的ttl_col列时,所有的二级索引都需要更新,即使此列不在二级索引列中

MyRocks TTL 清理

MyRocks TTL 清理也发生在compact时,由Rdb_compact_filter定义清理动作, 具体参考should_filter_ttl_rec

RocksDB TTL中过期时间和当前时间做比较,而MyRocks TTL 的过期时间是和最老的快照时间(m_snapshot_timestamp )做比较(当没有快照时,也取当前时间)。

  bool should_filter_ttl_rec(const rocksdb::Slice &key,
                             const rocksdb::Slice &existing_value) const {
    uint64 ttl_timestamp;
    Rdb_string_reader reader(&existing_value);
    if (!reader.read(m_ttl_offset) || reader.read_uint64(&ttl_timestamp)) {
      std::string buf;
      buf = rdb_hexdump(existing_value.data(), existing_value.size(),
                        RDB_MAX_HEXDUMP_LEN);
      // NO_LINT_DEBUG
      sql_print_error("Decoding ttl from PK value failed in compaction filter, ""for index (%u,%u), val: %s",
                      m_prev_index.cf_id, m_prev_index.index_id, buf.c_str());
      abort();
    }

    /*
      Filter out the record only if it is older than the oldest snapshot
      timestamp.  This prevents any rows from expiring in the middle of
      long-running transactions.
    */
    return ttl_timestamp + m_ttl_duration <= m_snapshot_timestamp;
  }

MyRocks TTL 读过滤

前面讲到, RocksDB TTL 过期时间并不严格,取决于compaction速度。MyRocks TTL也有类似问题,因此MyRocks引入参数rocksdb_enable_ttl_read_filtering, 当开启此参数时,过期时间是严格的。 每次读取记录会调用should_hide_ttl_rec判断此记录是否过期,当compact操作不及时而没有清理的过期记录,在读取时会被过滤掉。

bool ha_rocksdb::should_hide_ttl_rec(const Rdb_key_def &kd,
                                     const rocksdb::Slice &ttl_rec_val,
                                     const int64_t curr_ts) {
  DBUG_ASSERT(kd.has_ttl());
  DBUG_ASSERT(kd.m_ttl_rec_offset != UINT_MAX);

  /*
    Curr_ts can only be 0 if there are no snapshots open.
    should_hide_ttl_rec can only be called when there is >=1 snapshots, unless
    we are filtering on the write path (single INSERT/UPDATE) in which case
    we are passed in the current time as curr_ts.

    In the event curr_ts is 0, we always decide not to filter the record. We
    also log a warning and increment a diagnostic counter.
  */
  if (curr_ts == 0) {
    update_row_stats(ROWS_HIDDEN_NO_SNAPSHOT);
    return false;
  }

  if (!rdb_is_ttl_read_filtering_enabled() || !rdb_is_ttl_enabled()) {
    return false;
  }

  Rdb_string_reader reader(&ttl_rec_val);

  /*
    Find where the 8-byte ttl is for each record in this index.
  */
   uint64 ts;
   if (!reader.read(kd.m_ttl_rec_offset) || reader.read_uint64(&ts)) {
     /*
       This condition should never be reached since all TTL records have an
       8 byte ttl field in front. Don't filter the record out, and log an error.
     */
     std::string buf;
     buf = rdb_hexdump(ttl_rec_val.data(), ttl_rec_val.size(),
                       RDB_MAX_HEXDUMP_LEN);
     const GL_INDEX_ID gl_index_id = kd.get_gl_index_id();
     // NO_LINT_DEBUG
     sql_print_error("Decoding ttl from PK value failed, ""for index (%u,%u), val: %s",
                     gl_index_id.cf_id, gl_index_id.index_id, buf.c_str());
     DBUG_ASSERT(0);
     return false;
   }

   /* Hide record if it has expired before the current snapshot time. */
   uint64 read_filter_ts = 0;
 #ifndef NDEBUG
   read_filter_ts += rdb_dbug_set_ttl_read_filter_ts();
 #endif
   bool is_hide_ttl =
       ts + kd.m_ttl_duration + read_filter_ts <= static_cast<uint64>(curr_ts);
   if (is_hide_ttl) {
     update_row_stats(ROWS_FILTERED);
   }
   return is_hide_ttl;
 }

MyRocks TTL 潜在问题

Issue#683中谈到了MyRocks TTL 有个潜在问题, 当更新显式指定的ttl_col列值时,compact时有可能将新的记录清理掉,而老的记录仍然保留,从而有可能读取到本该不可见的老记录。此问题暂时还没有close.

最后

MyRocks TTL 是一个不错的特性,可以应用在历史数据清理的场景。相比传统的Delete数据的方式,更节约空间和CPU资源,同时传统的Delete还会影响查询的效率。目前MyRocks TTL 还不够成熟,还有许多需要改进的地方。

MySQL · 源码分析 · 协议模块浅析

$
0
0

这里调用栈主要基于MySQL5.7, 因为重构了protocol模块的代码, 可能与5.6的函数调用有所差异.

TL;DR (Not that long ..)

我们之前跟踪过三次握手的调用栈, 这里跳过认证, 主要考察验证完成后, server如何监听client发起的操作, 和如何返回一系列响应报文. 以及5.7在这个模块上相比5.6做了哪些扩展.

从网络读取请求

server调用Protocol_classic::read_packet(), 在这里进入网路等待, 封装了my_net_read()来获取client发送的报文.

my_net_read(NET *net): 从网络获得一个或多个报文, 当client发送的报文因为太大, 分成多个报文发送时, 在这个函数中拼接为一个整体; 如果收到压缩过的报文, 也在这个函数中解压缩. 并将读到的完整数据填充到NET *net中, 并返回(解压缩后整体的)packet_length. 对上层屏蔽了网络交互细节. 堆栈如下:

Protocol_classic::read_packet()
Protocol_classic::get_command()
do_command()
...

client发送一条查询时, server从读取报文, 并从read_packet()返回上层函数: Protocol_classic::get_command(). 先验证包完整性, 从报文头部((enum enum_server_command) raw_packet[0])扒出command信息, 然后进入报文解析逻辑(只填充com_data数据结构): Protocol_classic::parse_packet (这里随后会重置一下net_read_timeout) 进入dispatch_command, 指派SQL解析逻辑

回包

常见MySQL返回的报文有Data Packet, OK Packet, EOF Packet, 和ERROR Packet. 回包格式主要取决于查询是否需要返回结果集.

无结果集查询

对于诸如 COM_PING, IUD Query 等, 不需要返回结果集的命令, MySQL server如果正确执行这个查询, 会返回OK 报文给client, OK Packet的结构如下:

来自官方8.0的OK Packet结构

如果查询执行时发生异常, MySQL server返回ERROR Packet给CLIENT

Error Packet结构

以一条INSERT语句insert into t1 (id) values (2333);为例: 堆栈如下:

my_net_write()
net_send_ok (thd=..., server_status=..., statement_warn_count=..., affected_rows=..., id=..., message=0x... "", eof_identifier=false)
0x0000000001aa0892 in Protocol_classic::send_ok (this=..., server_status=..., statement_warn_count=0, affected_rows=1, last_insert_id=0, message=0x... "")
0x0000000001bae46c in THD::send_statement_status (this=0x...)
0x0000000001c5ae84 in dispatch_command
...

语句在执行过程中不会有回包, 执行完释放thread资源前, 调用send_statement_status根据这条statement执行的情况确定回包类型. INSERT可能有ERROR/OK两种状态, 这里我们考察OK的情况. 由堆栈可见, 最终在net_send_ok中构造报文, 调用my_net_write()

有结果集查询

对于像是 SELECT, SHOW, EXPLAIN 等等, 需要返回结果集的查询, 相应会复杂一些, MySQL会返回一系列包(包括metadata, row_data, EOF Packet), 其中EOF报文结构如下:

EOF Packet结构

// 可以看到, 原生的eof包很小巧

填充元信息逻辑入口在函数THD::send_result_metadata(), 填充逻辑还被划分为以下几个部分:

  1. Protocol_classic::start_result_metadata()将列数写入NET buffer, 然后对于每一列, 调用
  2. Protocol_classic::send_field_metadata然后进入循环, 对于每一列, 都会返回: (变长)db_name, table_name, org_table_name , col_name, org_col_name; (定长)charset, type, decimals, 以及2个预留位, 这些信息.
  3. Protocol_classic::end_result_metadata, 这里会调用一个write_eof_packet(), 用一个EOF包标志metadata边界(这里的EOF包内没有状态信息). 对于每一行要返回的数据, 调用THD::send_result_set_row(), 之后thd->inc_sent_row_count(1), 计数+1. 一个常见堆栈:
  THD::send_result_set_row
  Query_result_send::send_data
  end_send
  evaluate_join_record
  sub_select
  do_select
  JOIN::exec
  handle_query
  execute_sqlcom_select
  mysql_execute_command
  mysql_parse
  dispatch_command

然后在 THD::send_result_set_row中逐列调用store(), 将非空的列值转化为String类型, 填入net buffer. 在每一行result in result_set都返回后, server调用Protocol_classic::send_eof返回EOF包, 通常包含查询执行的状态信息(比如说:warning_count…)

一个堆栈:

net_send_ok
Protocol_classic::send_eof
THD::send_statement_status
dispatch_command
do_command

这里有个很好玩的地方是send_eof调用了net_send_ok, 这是因为5.7上有一个deprecate EOF packet的worklog, 其实ok报文和eof报文的发送放在了同一块儿逻辑. 在client和server都支持一个flag位CLIENT_DEPRECATE_EOF后, 就会有如上的栈出现. 如果client或者server有一方太老, 这里可能就只能看到一个send_eof() -> net_send_eof()的堆栈.

重构协议代码

WL#7126: Refactoring of protocol class 5.7大幅度重构了协议模块代码, 风格非常的OO, 结构清楚的一点都不像server层的代码(好像黑到了什么) 抽象了一坨类:

Protocol
|- Protocol_classic
   |- Protocol_binary
   |- Protocol_text

Protocol作为一个注释丰满且只有纯虚函数的抽象类, 非常容易理顺protocol模块能够提供的API(). 细节实现主要在Protocol_classic中(所以上文的调用栈可以看到, 实际逻辑是走到Protocol_classic中的), 而逻辑上还划分出的两个类: Protocol_binary是Prepared Statements使用的协议, Protocol_text场景如Text Protocol所写. 这个worklog对外没有引入行为上的变化, 但是代码变得非常Human Readable >,<

5.7 在ok和eof报文上的改动

上述讲到一个MySQL 5.7 引入的 Deprecate EOF, 实际上5.7上对OK/EOF报文做了大量修改. 使得client可以通过报文拿到更多的会话状态信息. 方便中间层会话保持, 主要涉及几个worklog:

WL#4797: Extending protocol’s OK packet

WL#6885: Flag to indicate session state

WL#6128: Session Tracker: Add GTIDs context to the OK packet

WL#6972: Collect GTIDs to include in the protocol’s OK packet

WL#7766: Deprecate the EOF packet

WL#6631: Detect transaction boundaries

同时新增变量控制报文行为:

  • session_track_schema = [ON | OFF]

    ON时, 如果session中变更了当前database, OK报文中回返回新的database

  • session_track_state_change = [ON | OFF]

ON时, 当发生会话环境改变时, 会给CLIENT返回一个FLAG(1), 环境变化包括:

  1. 当前database;
  2. 系统变量
  3. User-defined 变量
  4. 临时表的变更
  5. prepare xxx

但是只通知变更发生, 具体值为多少, 需要配合session_track_schema, session_track_system_variables使用, 所以这里限制还是很多…

  • session_track_system_variables = [“list of string, seperated bt ‘,’”]

    这个参数用来追踪的变量, 目前只有time_zone, autocommit, character_set_client, character_set_results, character_set_connection可选. 当这些变量的值变动时, client可以收到variable_name: new_value的键值对

  • session_track_gtids = [OFF | OWN_GTID | ALL_GTIDS]

    OWN_GTID时, 在会话中产生新GTIDs(当然只读操作不会后推GTID位点)时, 以字符串形式返回新增的GTIDs. ALL_GTIDS时, 在每个包中返回当前的executed_gtid值. 但是这样报文的payload很高, 不推荐(>. <)

  • session_track_transaction_info = [ON | OFF]打开后, 通过标志位表示当前会话状态. 有8bit可以表示状态信息(其中使用字符’_‘表示FALSE):

    1. T: 显示开启事务; I: 隐式开启事务(autocommit = 0)
    2. r: 有非事务表读
    3. R: 有事务表读
    4. w: 非事务表写
    5. W: 事务表写
    6. s: 不安全函数(比如 select uuid())
    7. S: server返回结果集
    8. L: 显示锁表(LOCK TABLES) 一个事务内, 返回的状态值是累加的, 举个栗子:

    有innodb表t1, myisam表t2,

    START TRANSACTION;               // T_______
    INSERT INTO t1 VALUES (1);       // T___W___
    INSERT INTO t2 VALUES (1);       // T__wW___
    SELECT f1 FROM t1;               // T_RwW_S_
    ...
    COMMIT/ROLLBACK;
    

    OK和EOF报文在5.6上是走不同的逻辑构造报文, 但实际上都是返回一些执行状态, 5.7中的Deprecated EOF报文, 实际上是复用了OK报文中新增的状态, 但是实际上这两个报文还是不同的: OK: header = 0 and length of packet > 7; EOF: header = 0xfe and length of packet < 9, 只是复用了在net_send_ok里的扩充逻辑.

在有这些信息的基础上我们可以做很多中间层的开发工作.

举个栗子, 我们读写分离上就用这个状态追踪, 对外提供透明的…读写分离 来自笔者的安利, 请吃

8.0 GA了… (5.7也步入了时代的眼泪ω・))

MSSQL · 最佳实践 · 如何监控备份还原进度

$
0
0

摘要

本期月报是SQL Server备份还原专题分享系列的第六期,打算分享给大家如何监控SQL Server备份还原进度。

场景引入

由于SQL Server备份还原操作是重I/O读写操作,尤其是当数据库或数据库备份文件比较大的到时候。那么,我们就有强烈的需求去监控备份还原的过程,时时刻刻把握备份还原的进度,以获取备份还原操作完成时间的心理预期以及对系统的影响。本期月报分享如何监控SQL Server备份还原进度。

监控备份还原进度

在SQL Server数据库中,监控数据库备份还原进度方法主要有以下三种:

利用SSMS的备份、还原进度百分比

利用T-SQL的stats关键字展示百分比

利用动态视图监控备份、还原完成百分比

利用SSMS

监控数据库备份进度

在SSMS中,右键点击你需要备份的数据库 => Tasks => Back Up…

01.png

在Destination中选择Disk => Add… => 选择备份文件本地存储路径 => OK 02.png

在该窗口的左下角部分,会有Process的进度展示,比如截图中的进度表示数据库已经备份完成了30%。 这种方法可以看到数据库备份进程进度的百分比,但是没有更多的详细信息。

监控数据库还原进度

监控数据库还原进度方法与上面的方法十分类似,只是入口不同。还原数据库入口:右键点击你需要还原的数据库 => Tasks => Restore => Database… 03.png

在Restore Database页面,选择Device => 点击右侧的预览按钮 => Add => 添加本地备份文件 => OK 04.png

在接下来的数据库还原页面中的最右上角部分,有数据库的还原进度条,以及还原百分比。比如,图中的数据库还原进度是50%,参见如下截图: 05.png

利用T-SQL

以上方法介绍使用SSMS来备份或者还原数据库进度监控查看方法。当然,有的人喜欢使用T-SQL脚本的方式来备份或者还原数据库。我们同样可以实现备份还原数据库的进度监控,方法是在语句中增加stats关键字,比如stats=10,那么系统在完成每个百分之十以后,都会在Messages中打印出** percent processed的字样。

BACKUP DATABASE [TestBackUpRestore]
TO DISK='C:\BACKUP1\TestBackUpRestore_FULL.bak' WITH STATS=10;

参见如下截图,在Messages窗口中,每个10%,都有** percent processed的进度提示。 06.png

注意: 还原数据库的方法相同,同样也是添加stats关键字。比如:

USE [master]
RESTORE DATABASE [TestBackUpRestore] FROM  DISK = N'C:\BACKUP1\TestBackUpRestore_FULL.bak' WITH  FILE = 4,  NOUNLOAD,  STATS = 10

GO

利用DMV

有的人可能会遇到这样的情况:我在做数据库备份还原的时候,忘记添加stats关键字了,Messages窗口什么也没有提示。这种情况下,我该如何去监控我的备份或者还原数据库进度呢? 其实,这种情况也无需紧张,我们同样有办法来监控数据库备份还原的进度,方法是使用动态管理视图sys.dm_exec_requests配合一些关键信息字段来监控进度。方法如下:

USE master
GO

SELECT 
	req.session_id, 
	database_name = db_name(req.database_id),
	req.status,
	req.blocking_session_id, 
	req.command,
	[sql_text] = Substring(txt.TEXT, (req.statement_start_offset / 2) + 1, (
				(
					CASE req.statement_end_offset
						WHEN - 1 THEN Datalength(txt.TEXT)
						ELSE req.statement_end_offset
					END - req.statement_start_offset
					) / 2
				) + 1),
	req.percent_complete,
	req.start_time,
	cpu_time_sec = req.cpu_time / 1000,
	granted_query_memory_mb = CONVERT(NUMERIC(8, 2), req.granted_query_memory / 128.),
	req.reads,
	req.logical_reads,
	req.writes,
	eta_completion_time = DATEADD(ms, req.[estimated_completion_time], GETDATE()),
	elapsed_min = CONVERT(NUMERIC(6, 2), req.[total_elapsed_time] / 1000.0 / 60.0),
	remaning_eta_min = CONVERT(NUMERIC(6, 2), req.[estimated_completion_time] / 1000.0 / 60.0),
	eta_hours = CONVERT(NUMERIC(6, 2), req.[estimated_completion_time] / 1000.0 / 60.0/ 60.0),
	wait_type,
	wait_time_sec = wait_time/1000, 
	wait_resource
FROM sys.dm_exec_requests as req WITH(NOLOCK)
	CROSS APPLY sys.dm_exec_sql_text(req.sql_handle) as txt 
WHERE req.session_id>50
	AND command IN ('BACKUP DATABASE', 'BACKUP LOG', 'RESTORE DATABASE', 'RESTORE LOG')

由于结果集宽度过宽,人为分割为两个部分来展示查询结果集: 07.png

08.png

这个结果中有非常多重要的字段信息,比如:

Command: 表示命令种类,此处表示备份数据库命令

sql_text: 语句详细信息,此处展示了完整的T-SQL语句

percent_complete: 进度完成百分比,此处已经完成了59.67%

start_time:进程开始执行时间

eta_completion_time:进程预计结束时间

等等。这种方法除了可以监控数据库备份还原进度外,还可以获取更多的进程信息,是比较推荐的方法。

提示: 这种方法不仅仅是可以用来监控你的备份还原进程,任何其他的用户进程都可以使用类似的方法来监控,你只需要把WHERE语句稍作修改即可。比如:想要监控某一个进程的进度情况,你只需要把WHERE语句修改为WHERE req.session_id=xxx即可。

获取备份历史信息

以上章节是介绍如何监控SQL Server备份还原进程的进度,我们有时也会遇到如下场景是:我们需要如何去探索或者发现某个数据库的备份历史记录信息?参见如下代码可以获取到数据库TestBackUpRestore的历史备份记录信息。

use msdb
GO
DECLARE
	@database_name sysname
;

SELECT
	@database_name = N'TestBackUpRestore'
;

SELECT
	bs.server_name,
	bs.user_name,
	database_name = bs.database_name,
	start_time = bs.backup_start_date,
	finish_tiem = bs.backup_finish_date,
	time_cost_sec = DATEDIFF(SECOND, bs.backup_start_date, bs.backup_finish_date),
	back_file = bmf.physical_device_name,
	backup_type = 
	CASE 
		WHEN bs.[type] = 'D' THEN 'Full Backup' 
		WHEN bs.[type] = 'I' THEN 'Differential Database' 
		WHEN bs.[type] = 'L' THEN 'Log' 
		WHEN bs.[type] = 'F' THEN 'File/Filegroup' 
		WHEN bs.[type] = 'G' THEN 'Differential File'
		WHEN bs.[type] = 'P' THEN 'Partial'  
		WHEN bs.[type] = 'Q' THEN 'Differential partial' 
	END,
	backup_size_mb = ROUND(((bs.backup_size/1024)/1024),2),
	compressed_size_mb = ROUND(((bs.compressed_backup_size/1024)/1024),2),
	bs.first_lsn,
	bs.last_lsn,
	bs.checkpoint_lsn,
	bs.database_backup_lsn,
	bs.software_major_version,
	bs.software_minor_version,
	bs.software_build_version,
	bs.recovery_model,
	bs.collation_name,
	bs.database_version
FROM msdb.dbo.backupmediafamily bmf WITH(NOLOCK)
	INNER JOIN msdb.dbo.backupset bs WITH(NOLOCK)
	ON bmf.media_set_id = bs.media_set_id
WHERE bs.database_name = @database_name
ORDER BY bs.backup_start_date DESC

截图如下: 09.png

这里需要特别注意: 如果你删除数据库时,使用了msdb.dbo.sp_delete_database_backuphistory存储过程清空数据库的备份历史,将无法再获取到该数据库的备份历史。比如:

EXEC msdb.dbo.sp_delete_database_backuphistory @database_name = N'TestBackUpRestore'
GO

最后总结

继前面五篇SQL Server备份还原专题系列月报分享后,我们完成了:三种常见的数据库备份、备份策略的制定、查找备份链、数据库的三种恢复模式与备份之间的关系、利用文件组实现冷热数据隔离备份方案以及本期月报分享的如何监控备份还原进度总共六篇。

MySQL · 特性分析 · MySQL的预编译功能

$
0
0

背景

目前大部分关系型数据库执行sql的过程如下

  1. 对SQL语句进行词法和语义解析,生成抽象语法树
  2. 优化语法树,生成执行计划
  3. 按照执行计划执行,并返回结果

绝大部分的常用SQL语句都可以被分解成静态部分和动态部分。静态部分主要包括sql语句的关键字(如DML,DDL等)以及数据库的对象及其相关信息(如表名,视图名,字段名等)。动态部分主要是由数据里的存储的数据构成。一个稳定运行的数据库中执行的所有sql语句,如果我们只关注静态部分,而忽略动态部分(以问号或者占位符对动态部分进行替换)。那么将会发现该系统执行的SQL语句的数量非常有限,只是相同的sql被反复的执行。这些反复执行的sql语句都用相同的执行计划。如果能让sql语句共享执行计划,将极大的提高执行的效率。很多主流的关系型数据库都支持sql语句以绑定变量的方式来共享执行计划。即编译一次,执行多次。遗憾的是mysql并不支持绑定变量。但是MySQL的预编译功能可以达到和绑定变量相同的效果。

开启MySQL的预编译功能

1.先建一张下面的表名为mytab

CREATE TABLE `mytab` (
  `a` int(11) DEFAULT NULL,
  `b` varchar(20) DEFAULT NULL,
  UNIQUE KEY `ab` (`a`,`b`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

2.通过PREPARE stmt_name FROM preparable_stmt 对下面的sql进行预编译


mysql> prepare sqltpl from 'insert into mytab select ?,?';
Query OK, 0 rows affected (0.00 sec)
Statement prepared

3.使用 EXECUTE stmt_name [USING @var_name [, @var_name] …] 来执行预编译语句

mysql> set @a=999,@b='hello';
Query OK, 0 rows affected (0.00 sec)

mysql> execute ins using @a,@b;
Query OK, 1 row affected (0.01 sec)
Records: 1  Duplicates: 0  Warnings: 0

mysql> select * from mytab;
+------+-------+
| a    | b     |
+------+-------+
|  999 | hello |
+------+-------+
1 row in set (0.00 sec)

在MySQL中预编译语句作用域是session级,参数max_prepared_stmt_count可以控制全局最大的存储的预编译语句的数量。

当预编译条数已经达到阈值时可以看到MySQL会报错,如下。

mysql> set @@global.max_prepared_stmt_count=1;
Query OK, 0 rows affected (0.00 sec)

mysql> prepare sel from 'select * from t';
ERROR 1461 (42000): Can't create more than max_prepared_stmt_count statements (current value: 1)

利用MySQL JDBC进行预编译

上面介绍了直接在MySQL上通过sql命令进行预编译/缓存sql语句。接下来我们以MySQL Java驱动Connector/J(版本5.1.45)测试通过MySQL驱动进行预编译。 ###开启服务端预编译和客户端本地缓存 JDBC的连接串如下

jdbc:mysql://localhost/test?useServerPrepStmts=true&cachePrepStmts=true,

并用下面的程序向表中插入两条记录

public class PreparedStatementTest {
    public static void main(String[] args) throws Throwable {
        Class.forName("com.mysql.jdbc.Driver");

        String url = "jdbc:mysql://localhost/test?useServerPrepStmts=true&cachePrepStmts=true";
        try (Connection con = DriverManager.getConnection(url, "root", null)) {
            insert(con, 123, "abc");
            insert(con, 321, "def");
        }
    }

    private static void insert(Connection con, int arg1, String arg2) throws SQLException {
        String sql = "insert into mytab select ?,?";
        try (PreparedStatement statement = con.prepareStatement(sql)) {
            statement.setInt(1, arg1);
            statement.setString(2, arg2);
            statement.executeUpdate();
        }
    }
}

将会在mysql的后台日志中发现以下内容


2018-04-19T14:11:09.060693Z        45 Prepare   insert into mytab select ?,?
2018-04-19T14:11:09.061870Z        45 Execute   insert into mytab select 123,'abc'
2018-04-19T14:11:09.086018Z        45 Execute   insert into mytab select 321,'def'

性能测试

我们来做一个简易的性能测试。首先写个存储过程向表中初始化大约50万条数据,然后使用同一个连接做select查询(查询条件走索引)。

CREATE PROCEDURE init(cnt INT)
  BEGIN
    DECLARE i INT DEFAULT 1;
    TRUNCATE t;
    INSERT INTO mytab SELECT 1, 'stmt 1';
    WHILE i <= cnt DO
      BEGIN
        INSERT INTO t SELECT a+i, concat('stmt ',a+i) FROM mytab;
        SET i = i << 1;
      END;
    END WHILE;
  END;
mysql> call init(1<<18);
Query OK, 262144 rows affected (3.60 sec)

mysql> select count(0) from t;
+----------+
| count(0) |
+----------+
|   524288 |
+----------+
1 row in set (0.14 sec)


public static void main(String[] args) throws Throwable {
    Class.forName("com.mysql.jdbc.Driver");

    String url = "";

    long start = System.currentTimeMillis();
    try (Connection con = DriverManager.getConnection(url, "root", null)) {
        for (int i = 1; i <= (1<<19); i++) {
            query(con, i, "stmt " + i);
        }
    }
    long end = System.currentTimeMillis();

    System.out.println(end - start);
}
private static void query(Connection con, int arg1, String arg2) throws SQLException {
    String sql = "select a,b from t where a=? and b=?";
    try (PreparedStatement statement = con.prepareStatement(sql)) {
        statement.setInt(1, arg1);
        statement.setString(2, arg2);
        statement.executeQuery();
    }
}

以下几种情况,经过3测试取平均值,情况如下:

本地预编译:65769 ms 本地预编译+缓存:63637 ms 服务端预编译:100985 ms 服务端预编译+缓存:57299 ms 本地预编译加不加缓存其实差别不是太大,服务端预编译不加缓存性能明显会降低很多,但是服务端预编译加缓存的话性能还是会比本地好很多。主要原因是服务端预编译不加缓存的话本身prepare也是有开销的,另外多了大量的round-trip。

小结

经过实际测试,对于频繁使用的语句,使用服务端预编译+缓存效率还是能够得到可观的提升的。但是对于不频繁使用的语句,服务端预编译本身会增加额外的round-trip,因此在实际开发中可以视情况定夺使用本地预编译还是服务端预编译以及哪些sql语句不需要开启预编译等。

MySQL · 特性分析 · (deleted) 临时空间

$
0
0

1. 简介

在运行 MySQL 的服务器上,偶尔出现 du 和 df 统计空间大小差别很大的情况,原因之一是 MySQL 的临时空间过大

在Linux或者Unix系统中,通过rm或者文件管理器删除文件将会从文件系统的目录结构上解除链接(unlink),然而如果文件是被打开的(有一个进程正在使用),那么进程将仍然可以读取该文件,磁盘空间也一直被占用,这样就会导致我们明明删除了文件,但是磁盘空间却未被释放

这些已经删除,但空间未释放的文件,可以通过 lsof 看到,末尾标明 (deleted) 就是这种文件

如下图

image.png

MySQL 服务器中,已删除未释放空间的文件主要存在于 tmp 目录,执行以下命令可以查看实例的 tmp 目录

show variables like 'tmpdir'

如果 deleted 文件过大,会对实例的性能产生负面影响,因为写文件的磁盘IO操作远慢于内存操作,所以要尽量避免使用临时文件

如果 deleted 文件太多导致磁盘空间满,会造成 MySQL crash,这是后表现为 df -h 看到磁盘 100%,但是 du -h 看到磁盘 90% 或者更少,这时候重启占用文件句柄的进程可以快速解决问题

2.产生临时空间的原因

MySQL产生的临时文件主要有以下几类:

查询产生的临时表文件

MySQL 执行带有 order by,group by 的复制查询时,经常需要建立一个或两个临时表

临时表所需的最大空间取决于以下公式

(length of what is sorted + sizeof(row pointer))
* number of matched rows
* 2

临时表较小时,可以存放在内存中,较大时则会存在在磁盘中,这取决于两个参数 tmp_table_size 和 max_heap_table_size

tmp_table_size 和 max_heap_table_size 较小的一个决定了内存临时表的上线,临时表小于上限则为内存表,大于上限为磁盘表

执行以下命令可以查看创建内存临时表和磁盘临时表的数量

mysql> show status like '%tmp%';
+-------------------------+-------+
| Variable_name           | Value |
+-------------------------+-------+
| Created_tmp_disk_tables | 1     |
| Created_tmp_files       | 10    |
| Created_tmp_tables      | 1     |
+-------------------------+-------+

原则上讲,临时表的数量越少越好,磁盘临时表的数量应该远远少于内存临时表的

正常情况下 Created_tmp_disk_tables/Created_tmp_tables < 25%

如果磁盘临时表过多,可能需要优化 sql

alter table 也会产生临时表,由于alter table执行频率较低,不容易引起空间问题

binlog cache 文件

事务产生的 binlog 先缓存在 binlog cache 中,事务提交后再刷到磁盘

MySQL 为每个连接分配一个 binlog cache,cache 大小由参数 binlog_cache_size 决定

如果事务使用的 binlog_cache 超过 binlog_cache_size,则在 tmpdir 下建立临时文件缓存 binlog,binlog_cache 临时文件以 ML 开头

以下两个status展示了 binlog_cache 和磁盘binlog_cache 的使用量

mysql> show status like 'binlog_cache%';
+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| Binlog_cache_disk_use | 6     |
| Binlog_cache_use      | 19    |
+-----------------------+-------+

我们先建一张表,插入很多条记录

mysql> show create table t;
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                    |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------+
| t     | CREATE TABLE `t` (
  `i` int(11) DEFAULT NULL,
  `c` char(20) DEFAULT NULL,
  `c2` char(20) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1 |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> select count(*) from t;
+----------+
| count(*) |
+----------+
|    65536 |
+----------+
1 row in set (28.08 sec)


插入一条记录,我们看到 Binlog_cache_use 加了1,Binlog_cache_disk_use 没变

mysql> insert into t values(1,'a','a');
Query OK, 1 row affected (0.01 sec)

mysql> show status like 'binlog_cache%';
+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| Binlog_cache_disk_use | 6     |
| Binlog_cache_use      | 20    |
+-----------------------+-------+
2 rows in set (0.37 sec)

插入大量记录,可以看到 Binlog_cache_disk_use 和 Binlog_cache_use 各加一

mysql> insert into t select * from t;
Query OK, 65537 rows affected (1 min 26.58 sec)
Records: 65537  Duplicates: 0  Warnings: 0

mysql> show status like 'binlog_cache%';
+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| Binlog_cache_disk_use | 7     |
| Binlog_cache_use      | 21    |
+-----------------------+-------+
2 rows in set (0.35 sec)


如下图,临时目录下 MLjogklw (deleted) 就是 binlog cache 文件

image.png

换一个连接再次执行大量插入

mysql> insert into t select * from t limit 65536;
Query OK, 65536 rows affected (2 min 11.09 sec)
Records: 65536  Duplicates: 0  Warnings: 0

image.png

如下图,一共有两个 binlog cache文件

连接断开后,binlog cache释放,空间会自动回收

image.png

innodb临时文件

innodb 启动时也需要建立一些临时文件,innodb 临时文件以 ib 开头

srv_monitor_file

srv_dict_tmpfile

srv_misc_tmpfile

dict_foreign_err_file

lock_latest_err_file

主要保存一些监控、字典、错误日志和无法归类的信息,这部分文件占用空间较少,无须特殊关注

3.如何避免临时空间过大

针对查询产生的临时文件,应该避免频繁使用 order by、group by 操作,即使需要排序也应该尽量减少排序行数

出于提高性能的考虑,如果确实经常需要对记录排序,可以适当调大 tmp_table_size 和 max_heap_table_size

但是为了减少磁盘使用而调高 tmp_table_size 和 max_heap_table_size 并不明智,因为内存资源远比磁盘资源宝贵,不能为了节省磁盘空间而增加内存消耗

针对 binlog cache,应该少执行大事务,尤其应该减少在多个连接同时执行大事务

如果大事务比较多,可以适当调大 binlog_cache_size,但是同样不应该为了节省磁盘调整这个参数

因为连接断开后 binlog_cache 空间会释放,使用短连接执行大事务可以有效降低临时空间开销


MySQL · RocksDB · WAL(WriteAheadLog)介绍

$
0
0

概述

在RocksDB中每一次数据的更新都会涉及到两个结构,一个是内存中的memtable(后续会刷新到磁盘成为SST),第二个是WAL(WriteAheadLog)。 本篇文章主要就是来介绍WAL.

WAL主要的功能是当RocksDB异常退出后,能够恢复出错前的内存中(memtable)数据,因此RocksDB默认是每次用户写都会刷新数据到WAL. 每次当当前WAL对应的内存数据(memtable)刷新到磁盘之后,都会新建一个WAL.

所有的WAL文件都是保存在WAL目录(options.wal_dir),为了保证数据的状态,所有的WAL文件的名字都是按照顺序的(log_number).

WAL文件格式

WAL文件由一堆变长的record组成,而每个record是由kBlockSize(32k)来分组,比如某一个record大于kBlockSize的话,他就会被切分为多个record(通过type来判断).

       +-----+-------------+--+----+----------+------+-- ... ----+
 File  | r0  |        r1   |P | r2 |    r3    |  r4  |           |
       +-----+-------------+--+----+----------+------+-- ... ----+
       <--- kBlockSize ------>|<-- kBlockSize ------>|

  rn = variable size records
  P = Padding

record的格式如下:

+---------+-----------+-----------+--- ... ---+
|CRC (4B) | Size (2B) | Type (1B) | Payload   |
+---------+-----------+-----------+--- ... ---+

CRC = 32bit hash computed over the payload using CRC
Size = Length of the payload data
Type = Type of record
       (kZeroType, kFullType, kFirstType, kLastType, kMiddleType )
       The type is used to group a bunch of records together to represent
       blocks that are larger than kBlockSize
Payload = Byte stream as long as specified by the payload size

最后是WAL的payload的格式.

// WriteBatch::rep_ :=
//    sequence: fixed64
//    count: fixed32
//    data: record[count]
// record :=
//    kTypeValue varstring varstring
//    kTypeDeletion varstring
//    kTypeSingleDeletion varstring
//    kTypeMerge varstring varstring
//    kTypeColumnFamilyValue varint32 varstring varstring
//    kTypeColumnFamilyDeletion varint32 varstring varstring
//    kTypeColumnFamilySingleDeletion varint32 varstring varstring
//    kTypeColumnFamilyMerge varint32 varstring varstring
//    kTypeBeginPrepareXID varstring
//    kTypeEndPrepareXID
//    kTypeCommitXID varstring
//    kTypeRollbackXID varstring
//    kTypeNoop
// varstring :=
//    len: varint32
//    data: uint8[len]

上面的格式中可以看到有一个sequence的值,这个值主要用来表示WAL中操作的时序,这里要注意每次sequence的更新是按照WriteBatch来更新的.

Status DBImpl::WriteToWAL(const WriteThread::WriteGroup& write_group,
                          log::Writer* log_writer, uint64_t* log_used,
                          bool need_log_sync, bool need_log_dir_sync,
                          SequenceNumber sequence) {
  Status status;
.........................................
  WriteBatchInternal::SetSequence(merged_batch, sequence);

创建WAL

首先是一个新的DB被打开的时候会创建一个WAL;

Status DB::Open(const DBOptions& db_options, const std::string& dbname,
                const std::vector<ColumnFamilyDescriptor>& column_families,
                std::vector<ColumnFamilyHandle*>* handles, DB** dbptr) {
......................................................................
  s = impl->Recover(column_families);
  if (s.ok()) {
    uint64_t new_log_number = impl->versions_->NewFileNumber();
.............................................
    s = NewWritableFile(
        impl->immutable_db_options_.env,
        LogFileName(impl->immutable_db_options_.wal_dir, new_log_number),
        &lfile, opt_env_options);
................................................

第二个情况是当一个CF(column family)被刷新到磁盘之后,也会创建新的WAL,这种情况下创建WAL是用过SwitchMemtable函数. 这个函数主要是用来切换memtable,也就是做flush之前的切换(生成新的memtable,然后把老的刷新到磁盘)

Status DBImpl::SwitchMemtable(ColumnFamilyData* cfd, WriteContext* context) {
..................................................
  {
    if (creating_new_log) {
...............................................
      } else {
        s = NewWritableFile(
            env_, LogFileName(immutable_db_options_.wal_dir, new_log_number),
            &lfile, opt_env_opt);
      }
.................................
    }
...............................................
  return s;
}

通过上面的两个函数我们可以看到每次新建WAL都会有一个new_log_number,这个值就是对应的WAL的文件名前缀,可以看到每次生成新的log_number, 基本都会调用NewFileNumber函数.这里注意如果option设置了recycle_log_file_num的话,是有可能重用老的log_number的。我们先来看下NewFileNumber函数:

uint64_t NewFileNumber() { return next_file_number_.fetch_add(1); }

可以看到函数实现很简单,就是每次log_number加一,因此一般来说WAL的文件格式都是类似0000001.LOG这样子.

WAL的清理

WAL的删除只有当包含在此WAL中的所有的数据都已经被持久化为SST之后(也有可能会延迟删除,因为有时候需要master发送transcation Log到slave来回放). 先来看DBImpl::FIndObsoleteFiles函数,这个函数很长,我们只关注对应的WAL部分,这里逻辑很简单,就是遍历所有的WAL,然后找出log_number小于当前min_log_number的文件然后加入到对应的结构(log_delete_files).

 if (!alive_log_files_.empty() && !logs_.empty()) {
    uint64_t min_log_number = job_context->log_number;
    size_t num_alive_log_files = alive_log_files_.size();
    // find newly obsoleted log files
    while (alive_log_files_.begin()->number < min_log_number) {
      auto& earliest = *alive_log_files_.begin();
      if (immutable_db_options_.recycle_log_file_num >
          log_recycle_files.size()) {
        ROCKS_LOG_INFO(immutable_db_options_.info_log,
                       "adding log %" PRIu64 " to recycle list\n",
                       earliest.number);
        log_recycle_files.push_back(earliest.number);
      } else {
        job_context->log_delete_files.push_back(earliest.number);
      }
.....................................................................
    }
    while (!logs_.empty() && logs_.front().number < min_log_number) {
      auto& log = logs_.front();
      if (log.getting_synced) {
        log_sync_cv_.Wait();
        // logs_ could have changed while we were waiting.
        continue;
      }
      logs_to_free_.push_back(log.ReleaseWriter());
      {
        InstrumentedMutexLock wl(&log_write_mutex_);
        logs_.pop_front();
      }
    }
    // Current log cannot be obsolete.
    assert(!logs_.empty());
  }

这里可以看到有两个核心的数据结构alive_log_files和logs_,他们的区别就是前一个表示有写入的WAL,而后一个则是包括了所有的WAL(比如open一个DB,而没有写入数据,此时也会生成WAL).

最终删除WAL的操作是在DBImpl::DeleteObsoleteFileImpl这个函数,而WAL删除不会单独触发,而是和temp/sst这类文件一起被删除的(PurgeObsoleteFiles).

查看WAL的工具

我们可以使用RocksDB自带的ldb工具来查看对应的WAL内容

pagefault@god ~/tools/rocksdb/data/.rocksdb $ ../../bin/ldb dump_wal --walfile=./000285.log --header
Sequence,Count,ByteSize,Physical Offset,Key(s)
1255,1,110,0,PUT(1) : 0x00000006000000000000013C

PgSQL · 应用案例 · 相似文本识别与去重

$
0
0

背景

在云栖社区的问答区,有一位网友提到有一个问题:

表里相似数据太多,想删除相似度高的数据,有什么办法能实现吗?  
例如:  
银屑病怎么治?  
银屑病怎么治疗?  
银屑病怎么治疗好?  
银屑病怎么能治疗好?  
等等  

解这个问题的思路

1. 首先如何判断内容的相似度,PostgreSQL中提供了中文分词,pg_trgm(将字符串切成多个不重复的token,计算两个字符串的相似度) .

对于本题,我建议采取中文分词的方式,首先将内容拆分成词组。

2. 在拆分成词组后,首先分组聚合,去除完全重复的数据。

3. 然后自关联生成笛卡尔(矩阵),计算出每条记录和其他记录的相似度。相似度的算法很简单,重叠的token数量除以集合的token去重后的数量。

4. 根据相似度,去除不需要的数据。

这里如果数据量非常庞大,使用专业的分析编程语言会更好例如 PL/R。

实操的例子

首先要安装PostgreSQL 中文分词插件

(阿里云AliCloudDB PostgreSQL已包含这个插件,用法参考官方手册)

git clone https://github.com/jaiminpan/pg_jieba.git  
mv pg_jieba $PGSRC/contrib/  
export PATH=/home/digoal/pgsql9.5/bin:$PATH  
cd $PGSRC/contrib/pg_jieba  
make clean;make;make install  
  
git clone https://github.com/jaiminpan/pg_scws.git  
mv pg_jieba $PGSRC/contrib/  
export PATH=/home/digoal/pgsql9.5/bin:$PATH  
cd $PGSRC/contrib/pg_scws  
make clean;make;make install  

创建插件

psql  
# create extension pg_jieba;  
# create extension pg_scws;  

创建测试CASE

create table tdup1 (id int primary key, info text);  
create extension pg_trgm;  
insert into tdup1 values (1, '银屑病怎么治?');  
insert into tdup1 values (2, '银屑病怎么治疗?');  
insert into tdup1 values (3, '银屑病怎么治疗好?');  
insert into tdup1 values (4, '银屑病怎么能治疗好?');  

这两种分词插件,可以任选一种。

postgres=# select to_tsvector('jiebacfg', info),* from tdup1 ;  
     to_tsvector     | id |         info           
---------------------+----+----------------------  
 '治':3 '银屑病':1   |  1 | 银屑病怎么治?  
 '治疗':3 '银屑病':1 |  2 | 银屑病怎么治疗?  
 '治疗':3 '银屑病':1 |  3 | 银屑病怎么治疗好?  
 '治疗':4 '银屑病':1 |  4 | 银屑病怎么能治疗好?  
(4 rows)  
  
postgres=# select to_tsvector('scwscfg', info),* from tdup1 ;  
            to_tsvector            | id |         info           
-----------------------------------+----+----------------------  
 '治':2 '银屑病':1                 |  1 | 银屑病怎么治?  
 '治疗':2 '银屑病':1               |  2 | 银屑病怎么治疗?  
 '好':3 '治疗':2 '银屑病':1        |  3 | 银屑病怎么治疗好?  
 '好':4 '治疗':3 '能':2 '银屑病':1 |  4 | 银屑病怎么能治疗好?  
(4 rows)  

创建三个函数,

计算2个数组的集合(去重后的集合)

postgres=# create or replace function array_union(text[], text[]) returns text[] as $$  
  select array_agg(c1) from (select c1 from unnest($1||$2) t(c1) group by c1) t;  
$$ language sql strict;  
CREATE FUNCTION  

数组去重

postgres=# create or replace function array_dist(text[]) returns text[] as $$           
  select array_agg(c1) from (select c1 from unnest($1) t(c1) group by c1) t;      
$$ language sql strict;  
CREATE FUNCTION  

计算两个数组的重叠部分(去重后的重叠部分)

postgres=# create or replace function array_share(text[], text[]) returns text[] as $$  
  select array_agg(unnest) from (select unnest($1) intersect select unnest($2) group by 1) t;  
$$ language sql strict;  
CREATE FUNCTION  

笛卡尔结果是这样的:

regexp_split_to_array((regexp_replace(to_tsvector('jiebacfg',info)::text,'(:\d+)', '', 'g')),' ')用于将info转换成数组。

postgres=# with t(c1,c2,c3) as   
(select id,info,array_dist(regexp_split_to_array((regexp_replace(to_tsvector('jiebacfg',info)::text,'(:\d+)', '', 'g')),' ')) from tdup1)   
select * from (select t1.c1 t1c1,t2.c1 t2c1,t1.c2 t1c2,t2.c2 t2c2,t1.c3 t1c3,t2.c3 t2c3,round(array_length(array_share(t1.c3,t2.c3),1)::numeric/array_length(array_union(t1.c3,t2.c3),1),2)   
simulate from t t1,t t2) t;  
 t1c1 | t2c1 |         t1c2         |         t2c2         |       t1c3        |       t2c3        | simulate   
------+------+----------------------+----------------------+-------------------+-------------------+----------  
    1 |    1 | 银屑病怎么治?       | 银屑病怎么治?       | {'银屑病','治'}   | {'银屑病','治'}   |     1.00  
    1 |    2 | 银屑病怎么治?       | 银屑病怎么治疗?     | {'银屑病','治'}   | {'银屑病','治疗'} |     0.33  
    1 |    3 | 银屑病怎么治?       | 银屑病怎么治疗好?   | {'银屑病','治'}   | {'银屑病','治疗'} |     0.33  
    1 |    4 | 银屑病怎么治?       | 银屑病怎么能治疗好? | {'银屑病','治'}   | {'银屑病','治疗'} |     0.33  
    2 |    1 | 银屑病怎么治疗?     | 银屑病怎么治?       | {'银屑病','治疗'} | {'银屑病','治'}   |     0.33  
    2 |    2 | 银屑病怎么治疗?     | 银屑病怎么治疗?     | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    2 |    3 | 银屑病怎么治疗?     | 银屑病怎么治疗好?   | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    2 |    4 | 银屑病怎么治疗?     | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    3 |    1 | 银屑病怎么治疗好?   | 银屑病怎么治?       | {'银屑病','治疗'} | {'银屑病','治'}   |     0.33  
    3 |    2 | 银屑病怎么治疗好?   | 银屑病怎么治疗?     | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    3 |    3 | 银屑病怎么治疗好?   | 银屑病怎么治疗好?   | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    3 |    4 | 银屑病怎么治疗好?   | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    4 |    1 | 银屑病怎么能治疗好? | 银屑病怎么治?       | {'银屑病','治疗'} | {'银屑病','治'}   |     0.33  
    4 |    2 | 银屑病怎么能治疗好? | 银屑病怎么治疗?     | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    4 |    3 | 银屑病怎么能治疗好? | 银屑病怎么治疗好?   | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    4 |    4 | 银屑病怎么能治疗好? | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
(16 rows)  

以上生成的实际上是一个矩阵,simulate就是矩阵中我们需要计算的相似度:

pic

我们在去重计算时不需要所有的笛卡尔积,只需要这个矩阵对角线的上部分或下部分数据即可。

所以加个条件就能完成。

postgres=# with t(c1,c2,c3) as   
(select id,info,array_dist(regexp_split_to_array((regexp_replace(to_tsvector('jiebacfg',info)::text,'(:\d+)', '', 'g')),' ')) from tdup1)   
select * from (select t1.c1 t1c1,t2.c1 t2c1,t1.c2 t1c2,t2.c2 t2c2,t1.c3 t1c3,t2.c3 t2c3,round(array_length(array_share(t1.c3,t2.c3),1)::numeric/array_length(array_union(t1.c3,t2.c3),1),2)   
simulate from t t1,t t2 where t1.c1<>t2.c1 and t1.c1<t2.c1) t;  
 t1c1 | t2c1 |        t1c2        |         t2c2         |       t1c3        |       t2c3        | simulate   
------+------+--------------------+----------------------+-------------------+-------------------+----------  
    1 |    2 | 银屑病怎么治?     | 银屑病怎么治疗?     | {'银屑病','治'}   | {'银屑病','治疗'} |     0.33  
    1 |    3 | 银屑病怎么治?     | 银屑病怎么治疗好?   | {'银屑病','治'}   | {'银屑病','治疗'} |     0.33  
    1 |    4 | 银屑病怎么治?     | 银屑病怎么能治疗好? | {'银屑病','治'}   | {'银屑病','治疗'} |     0.33  
    2 |    3 | 银屑病怎么治疗?   | 银屑病怎么治疗好?   | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    2 |    4 | 银屑病怎么治疗?   | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    3 |    4 | 银屑病怎么治疗好? | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
(6 rows)  

开始对这些数据去重,去重的第一步,明确simulate, 例如相似度大于0.5的,需要去重。

postgres=# with t(c1,c2,c3) as   
(select id,info,array_dist(regexp_split_to_array((regexp_replace(to_tsvector('jiebacfg',info)::text,'(:\d+)', '', 'g')),' ')) from tdup1)   
select * from (select t1.c1 t1c1,t2.c1 t2c1,t1.c2 t1c2,t2.c2 t2c2,t1.c3 t1c3,t2.c3 t2c3,round(array_length(array_share(t1.c3,t2.c3),1)::numeric/array_length(array_union(t1.c3,t2.c3),1),2)   
simulate from t t1,t t2 where t1.c1<>t2.c1 and t1.c1<t2.c1) t where simulate>0.5;  
 t1c1 | t2c1 |        t1c2        |         t2c2         |       t1c3        |       t2c3        | simulate   
------+------+--------------------+----------------------+-------------------+-------------------+----------  
    2 |    3 | 银屑病怎么治疗?   | 银屑病怎么治疗好?   | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    2 |    4 | 银屑病怎么治疗?   | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    3 |    4 | 银屑病怎么治疗好? | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
(3 rows)  

去重第二步,将t2c1列的ID对应的记录删掉即可。

delete from tdup1 where id in (with t(c1,c2,c3) as   
(select id,info,array_dist(regexp_split_to_array((regexp_replace(to_tsvector('jiebacfg',info)::text,'(:\d+)', '', 'g')),' ')) from tdup1)   
select t2c1 from (select t1.c1 t1c1,t2.c1 t2c1,t1.c2 t1c2,t2.c2 t2c2,t1.c3 t1c3,t2.c3 t2c3,round(array_length(array_share(t1.c3,t2.c3),1)::numeric/array_length(array_union(t1.c3,t2.c3),1),2)   
simulate from t t1,t t2 where t1.c1<>t2.c1 and t1.c1<t2.c1) t where simulate>0.5);  
  
例如 :   
postgres=# insert into tdup1 values (11, '白血病怎么治?');  
INSERT 0 1  
postgres=# insert into tdup1 values (22, '白血病怎么治疗?');  
INSERT 0 1  
postgres=# insert into tdup1 values (13, '白血病怎么治疗好?');  
INSERT 0 1  
postgres=# insert into tdup1 values (24, '白血病怎么能治疗好?');  
INSERT 0 1  
postgres=#   
postgres=# with t(c1,c2,c3) as                               
(select id,info,array_dist(regexp_split_to_array((regexp_replace(to_tsvector('jiebacfg',info)::text,'(:\d+)', '', 'g')),' ')) from tdup1)   
select * from (select t1.c1 t1c1,t2.c1 t2c1,t1.c2 t1c2,t2.c2 t2c2,t1.c3 t1c3,t2.c3 t2c3,round(array_length(array_share(t1.c3,t2.c3),1)::numeric/array_length(array_union(t1.c3,t2.c3),1),2)   
simulate from t t1,t t2 where t1.c1<>t2.c1 and t1.c1<t2.c1) t where simulate>0.5;  
 t1c1 | t2c1 |        t1c2        |         t2c2         |       t1c3        |       t2c3        | simulate   
------+------+--------------------+----------------------+-------------------+-------------------+----------  
    2 |    3 | 银屑病怎么治疗?   | 银屑病怎么治疗好?   | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    2 |    4 | 银屑病怎么治疗?   | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
    3 |    4 | 银屑病怎么治疗好? | 银屑病怎么能治疗好? | {'银屑病','治疗'} | {'银屑病','治疗'} |     1.00  
   22 |   24 | 白血病怎么治疗?   | 白血病怎么能治疗好? | {'治疗','白血病'} | {'治疗','白血病'} |     1.00  
   13 |   22 | 白血病怎么治疗好? | 白血病怎么治疗?     | {'治疗','白血病'} | {'治疗','白血病'} |     1.00  
   13 |   24 | 白血病怎么治疗好? | 白血病怎么能治疗好? | {'治疗','白血病'} | {'治疗','白血病'} |     1.00  
(6 rows)  
  
postgres=# begin;  
BEGIN  
postgres=# delete from tdup1 where id in (with t(c1,c2,c3) as   
postgres(# (select id,info,array_dist(regexp_split_to_array((regexp_replace(to_tsvector('jiebacfg',info)::text,'(:\d+)', '', 'g')),' ')) from tdup1)   
postgres(# select t2c1 from (select t1.c1 t1c1,t2.c1 t2c1,t1.c2 t1c2,t2.c2 t2c2,t1.c3 t1c3,t2.c3 t2c3,round(array_length(array_share(t1.c3,t2.c3),1)::numeric/array_length(array_union(t1.c3,t2.c3),1),2)   
postgres(# simulate from t t1,t t2 where t1.c1<>t2.c1 and t1.c1<t2.c1) t where simulate>0.5);  
DELETE 4  
postgres=# select * from tdup1 ;  
 id |        info          
----+--------------------  
  1 | 银屑病怎么治?  
  2 | 银屑病怎么治疗?  
 11 | 白血病怎么治?  
 13 | 白血病怎么治疗好?  
(4 rows)  

用数据库解会遇到的问题, 因为我们的JOIN filter是<>和<,用不上hashjoin。

数据量比较大的情况下,耗时会非常的长。

postgres=# explain delete from tdup1 where id in (with t(c1,c2,c3) as   
(select id,info,array_dist(regexp_split_to_array((regexp_replace(to_tsvector('jiebacfg',info)::text,'(:\d+)', '', 'g')),' ')) from tdup1)   
select t2c1 from (select t1.c1 t1c1,t2.c1 t2c1,t1.c2 t1c2,t2.c2 t2c2,t1.c3 t1c3,t2.c3 t2c3,round(array_length(array_share(t1.c3,t2.c3),1)::numeric/array_length(array_union(t1.c3,t2.c3),1),2)   
simulate from t t1,t t2 where t1.c1<>t2.c1 and t1.c1<t2.c1) t where simulate>0.5);  
                                                      QUERY PLAN                                                        
----------------------------------------------------------------------------------------------------------------------  
 Delete on tdup1  (cost=10005260133.58..10005260215.84 rows=2555 width=34)  
   ->  Hash Join  (cost=10005260133.58..10005260215.84 rows=2555 width=34)  
         Hash Cond: (tdup1.id = "ANY_subquery".t2c1)  
         ->  Seq Scan on tdup1  (cost=0.00..61.10 rows=5110 width=10)  
         ->  Hash  (cost=10005260131.08..10005260131.08 rows=200 width=32)  
               ->  HashAggregate  (cost=10005260129.08..10005260131.08 rows=200 width=32)  
                     Group Key: "ANY_subquery".t2c1  
                     ->  Subquery Scan on "ANY_subquery"  (cost=10000002667.20..10005252911.99 rows=2886838 width=32)  
                           ->  Subquery Scan on t  (cost=10000002667.20..10005224043.61 rows=2886838 width=4)  
                                 Filter: (t.simulate > 0.5)  
                                 CTE t  
                                   ->  Seq Scan on tdup1 tdup1_1  (cost=0.00..2667.20 rows=5110 width=36)  
                                 ->  Nested Loop  (cost=10000000000.00..10005113119.99 rows=8660513 width=68)  
                                       Join Filter: ((t1.c1 <> t2.c1) AND (t1.c1 < t2.c1))  
                                       ->  CTE Scan on t t1  (cost=0.00..102.20 rows=5110 width=36)  
                                       ->  CTE Scan on t t2  (cost=0.00..102.20 rows=5110 width=36)  
(16 rows)  

其他更优雅的方法,使用PLR或者R进行矩阵运算,得出结果后再进行筛选。

PLR

R

或者使用MPP数据库例如Greenplum加上R和madlib可以对非常庞大的数据进行处理。

MADLIB

MPP

小结

这里用到了PG的什么特性?

1. 中文分词

2. 窗口查询功能

(本例中没有用到,但是如果你的数据没有主键时,则需要用ctid和row_number来定位到一条唯一记录)

参考

《[未完待续] PostgreSQL 全文检索 大结果集优化 - fuzzy match》

《PostgreSQL 全文检索 - 词频统计》

《[未完待续] PostgreSQL 流式fft傅里叶变换 (plpython + numpy + 数据库流式计算)》

《PostgreSQL UDF实现tsvector(全文检索), array(数组)多值字段与scalar(单值字段)类型的整合索引(类分区索引) - 单值与多值类型复合查询性能提速100倍+ 案例 (含,单值+多值列合成)》

《PostgreSQL 全文检索之 - 位置匹配 过滤语法(例如 ‘速度 <1> 激情’)》

《多流实时聚合 - 记录级实时快照 - JSON聚合与json全文检索的功能应用》

《PostgreSQL - 全文检索内置及自定义ranking算法介绍 与案例》

《用PostgreSQL 做实时高效 搜索引擎 - 全文检索、模糊查询、正则查询、相似查询、ADHOC查询》

《HTAP数据库 PostgreSQL 场景与性能测试之 14 - (OLTP) 字符串搜索 - 全文检索》

《HTAP数据库 PostgreSQL 场景与性能测试之 7 - (OLTP) 全文检索 - 含索引实时写入》

《[未完待续] 流式机器学习(online machine learning) - pipelineDB with plR and plPython》

《PostgreSQL 中英文混合分词特殊规则(中文单字、英文单词) - 中英分明》

《在PostgreSQL中使用 plpythonu 调用系统命令》

《多国语言字符串的加密、全文检索、模糊查询的支持》

《全文检索 不包含 优化 - 阿里云RDS PostgreSQL最佳实践》

《PostgreSQL 10.0 preview 功能增强 - JSON 内容全文检索》

《PostgreSQL 中如何找出记录中是否包含编码范围内的字符,例如是否包含中文》

《PostgreSQL Python tutorial》

《如何解决数据库分词的拼写纠正问题 - PostgreSQL Hunspell 字典 复数形容词动词等变异还原》

《聊一聊双十一背后的技术 - 毫秒分词算啥, 试试正则和相似度》

《聊一聊双十一背后的技术 - 分词和搜索》

《PostgreSQL 全文检索加速 快到没有朋友 - RUM索引接口(潘多拉魔盒)》

《PostgreSQL 如何高效解决 按任意字段分词检索的问题 - case 1》

《如何加快PostgreSQL结巴分词加载速度》

《中文模糊查询性能优化 by PostgreSQL trgm》

《PostgreSQL 行级 全文检索》

《使用阿里云PostgreSQL zhparser中文分词时不可不知的几个参数》

《一张图看懂MADlib能干什么》

《PostgreSQL Greenplum 结巴分词(by plpython)》

《NLPIR 分词准确率接近98.23%》

《PostgreSQL chinese full text search 中文全文检索》

《PostgreSQL 多元线性回归 - 1 MADLib Installed in PostgreSQL 9.2》

《PostgreSQL USE plpythonu get Linux FileSystem usage》

《PostgreSQL 使用 nlpbamboo chinesecfg 中文分词》

https://github.com/jaiminpan/pg_jieba

https://github.com/jaiminpan/pg_scws

http://joeconway.com/plr/

https://www.postgresql.org/docs/devel/static/plpython.html

http://madlib.apache.org/

POLARDB · 最佳实践 · POLARDB不得不知道的秘密

$
0
0

前言

POLARDB作为阿里云下一代关系型云数据库,自去年9月份公测以来,收到了不少客户的重点关注,今年5月份商业化后,许多大客户开始陆续迁移业务到POLARDB上,但是由于POLARDB的很多默认行为与RDS MySQL兼容版不一样,导致很多用户有诸多使用上的困惑,本来总结了几点,给大家答疑解惑。另外,本文提到的参数,在新版本上,用户都可以通过控制台修改,如果没有,可以联系售后服务修改。本文适合读者:阿里云售后服务,POLARDB用户,POLARDB内核开发者,需要有基本的数据库知识,最好对MySQL源码有部分了解。

磁盘空间问题

RDS MySQL在购买的时候需要指定购买的磁盘大小,最大为3TB。如果空间不够,需要升级磁盘空间。具体来说,如果实例所在的物理机磁盘空间充足,这个升级磁盘的任务很快就可以完成,但是如果空间不足,就需要在其他物理机上重建实例,大实例需要几天的时间。为了解决这个问题,POLARDB底层使用存储集群的方式,做到磁盘动态扩容,且磁盘扩容过程对用户无感知,具体来说,默认磁盘空间分配为规格内存的10倍,当使用了70%,系统就会自动扩容一部分空间,而且扩容不需要停止实例。

有了这种机制,POLARDB的存储可以做到按照使用量来收费,真正做到使用多少就收多少钱,计费周期是一小时。同时,由于存储按量收费,导致许多用户对存储的使用量非常敏感,在我们的控制台上,有五种空间统计,分别是磁盘空间使用量,数据空间使用量,日志空间使用量,临时空间使用量和系统文件空间使用量。

磁盘空间使用量是后四者之和,数据空间使用量包括用户创建的所有库,mysql库,test库,performance_schema库,日志空间使用量包括redolog,undolog,ibdata1,ib_checkpoint(存储checkpoint信息),innodb_repl.info(存储切换信息,物理复制信息等),临时空间使用量包括socket文件,pid文件,临时表(大查询排序用),审计日志文件,系统文件空间使用量包括错误日志,慢日志,general日志以及主库信息(用于构建复制关系)。虽然有四部分空间使用量,但大多数主要被数据空间和日志空间占用,数据空间比较好理解,主要就是表空间聚集索引和二级索引的占用量,但是这个日志空间很多用户不是很了解,常常提上来的问题是,为什么我的日志空间占了100多个G,而数据空间就几个G,这里简单解释一下。

日志空间使用量,如上所述,有很多组成部分。redolog,主要用来构建物理复制,同时也可以被当做增量日志来支持还原到时间点/克隆实例的任务,类似原生的binlog,文件名按顺序递增,主节点产生日志,只读节点/灾备节点应用日志,同时后台管控任务会定时上传redolog(只要发现新的就立即上传)并且定时删除(目前一小时触发一次删除任务),具体大小与DML总量有关。undolog,主要用来构建数据历史版本以支持MVCC机制和回滚机制,不同于RDS MySQL的undolog都在ibdata1文件中,POLARDB的undolog大部分是以独立表空间/文件存在,具体大小与用户使用习惯有关。ibdata1,主要存储系统元数据信息等系统信息,具体大小与用户表数量有关,但是一般不会太大。ib_checkpoint,这个是POLARDB特有的,用于存储checkpoint信息,大小固定。innodb_repl.info也是POLARDB独有的,存储物理复制相关的信息以及切换信息,一般不会太大。由此可见,日志空间使用量虽然也有很多组成部分,但主要是被redolog日志和undolog日志占用。

redolog日志占用

redolog日志,由于对数据的修改都会记录redolog,所以对数据修改的越快,redolog产生的速度就会越快,而且上传OSS(保留下来做增量日志)的速度有限,所以在实例导数据阶段,会导致redolog堆积,当导入完成后,redolog会慢慢上传完然后删除,这样空间就会降下来,但是不会完全降为0。具体原因需要介绍一下:目前所有规格,redolog大小都为1G,被删除的redolog不会马上被删除,而是放入一个缓冲池(rename成一个临时文件),当需要新的redolog时候,先看看缓冲池里面还有没有可用的文件,如果有直接rename成目标文件,如果没有再创建,这个优化主要是为了减少创建新文件时的io对系统的抖动,缓冲池的大小由参数loose_innodb_polar_log_file_max_reuse控制,默认是8,如果用户想减少缓存池的文件个数,就可以减少这个参数从而减少日志空间占用量,但是在压力大的情况下,性能可能会出现周期性的小幅波动。所以当写入大量数据后,即使redolog都被上传,默认也有8G的空间用作缓存。注意,调整这个参数后,缓冲池不会立刻被清空,随着dml被执行,才会慢慢减少,如果需要立即清空,建议联系售后服务

另外,POLARDB会提前创建好下一个需要写的redolog日志(每个日志都是固定的1G,即使没有被写过),主要目的是当当前的redolog被写完后,能快速的切换到下一个,因此,也会占用额外1G空间。此外,后台定时删除任务目前是一个小时清理一次(还有优化的空间),但是不会清理到最后一个日志,会保留一个日志,主要用来做按时间点还原任务。

接下来,举个经典的例子,方便理解上述的策略:

mysql> show polar logs;
+-----------------+----------------+-------------+
| Log_name        | Start_lsn      | Log_version |
+-----------------+----------------+-------------+
| ib_logfile41008 | 19089701633024 | 100         |
| ib_logfile41009 | 19090775372800 | 100         |
+-----------------+----------------+-------------+
2 rows in set (0.00 sec)

mysql> show polar status\G
......
-----------------
Log File Info
-----------------
2 active ib_logfiles
The oldest log file number: 41008, start_lsn: 19089701633024
The newest log file number: 41009, start_lsn: 19090775372800
Log purge up to file number: 41008
8 free files for reallocation
Lastest(Doing) checkpoint at lsn 19091025469814(ib_logfile41009, offset 250099062)
......

show polar logs这条命令可以查看系统中的redolog日志,上个例子中,ib_logfile41008这文件已经被写完,但是这个日志需要被保留用来支持按照时间点还原和克隆实例任务,ib_logfile41009是最后一个redolog,表示目前正在写的redolog。

show polar status\G可以显示POLARDB很多内部信息,这里只截取了redolog相关的一部分,前四行就是字面的意思,不具体解释了。第五行表示缓冲池目前有8个redolog。

另外,上文提到过,POLARDB会提前创建一个redolog用以快速的切换,名字一般是最后一个文件编号加一,所以是ib_logfile41010。

结合这些信息,就可以推断出,目前系统中redolog占用量为11G = 8G(缓冲池中的)+1G(保留的ib_logfile41008)+1G(正在被写的ib_logfile41009)+1G(提前创建的ib_logfile41010)。

另外,透露一个好消息,我们内部正在调研redolog日志不收费的可行性,如果通过验证,这部分占用的空间将不会收取用户费用。

undolog日志占用

讲完了redolog日志,接下里讲讲undolog日志。上文说过在POLARDB中undolog大部分是以独立表空间存在的,也就是说是独立的文件,而不是聚集在ibdata1文件中。目前分了8个文件,文件名为undo001-undo008,每个文件默认初始大小为10M,会随着使用增大,在某些不推荐的用法下,会导致undolog空间增长很快。这里简单举个例子,可以使undolog撑的很大:使用START TRANSACTION WITH consistent snapshot开启一个事务,注意要在RR隔离级别下,然后开启另外一个连接,对库中的表进行高频率的更新,可以使用sysbench辅助,很快,就会发现undolog膨胀。从数据库内核的角度来讲,就是由于一个很老的readview,导致需要把很多的历史版本都保留下来,从而导致undolog膨胀。在线上,往往是一个大查询或者一个长时间不提交的事务导致undolog膨胀。undolog膨胀后,即使所有事务都结束后,也不会自动缩小,需要使用下文的方法进行在线truncate。

目前,用户还不能直接查看undolog的占用量,后续我们会在information_schema加上,方便用户查看,但是可以通过间接的方法:如果控制台上显示日志占用量很大,但是redolog占用量很小,那么一般就是undolog了,因为其他几个都占用很小的空间,几乎可以忽略不计。

如果发现undolog占用量比较大,POLARDB也有办法清理。原理是,等undolog所对应的事务都结束后,把清理开关打开,如果发现大小超过执行大小的undo tablespace,就会在purge线程中进行undo的truncate。尽量在业务低峰期进行,并且保证没有大事务长事务。具体操作方法就两步,首先调整innodb_max_undo_log_size大小,这个参数表示当每个undo tablespace大于这个值时候,后续会把它缩小,重新调整为10M。接着,打开truncate开关innodb_undo_log_truncate,这样,后台线程就会把所有大于innodb_max_undo_log_size设置的undo tablespace调整为10M。注意,这里要保证没有大事务长事务,因为后台线程会等待undo tablespace中所有事务都提交后,才会下发命令,同时也要保证innodb_undo_logs大于等于2。另外,不建议这个功能长期开着,如果在控制台发现日志占用量减少了,建议关闭truncate功能,因为其有可能在您业务高峰期运行,导致数据库延迟

DDL与大事务问题

如果有一个大事务或者长事务长时间未提交,由于其长期持有MDL读锁,这个会带来很多问题。在RDS MySQL上,如果后续对这张表又有DDL操作,那么这个操作会被这个大事务给堵住。在POLARDB上,这个问题更加严重,简单的说,如果只读实例上有一个大事务或者长期未提交的事务,会影响主实例上的DDL,导致其超时失败。纠其本质的原因,是因为POLARDB基于共享存储的架构,因此在对表结构变更前,必须保证所有的读操作(包括主实例上的读和只读实例上的读)结束。

具体解释一下POLARDB上DDL的过程。在DDL的不同阶段,当需要对表进行结构变更前,主实例自己获取MDL锁后,会写一条redolog日志,只读实例解析到这个日志后,会尝试获取同一个表上的MDL锁,如果失败,会反馈给主实例。主实例会等待所有只读实例同步到最新的复制位点,即所有实例都解析到这条加锁日志,主实例同时判断是否有实例加锁失败,如果没有,DDL就成功,否则失败回滚。

这里涉及到两个时间,一个是主实例等待所有只读实例同步的超时时间,这个由参数loose_innodb_primary_abort_ddl_wait_replica_timeout控制,默认是一个小时。另外一个是只读实例尝试加MDL锁的超时时间,由参数loose_replica_lock_wait_timeout控制,默认是50秒。可以调整这两个参数来提前结束回滚DDL,通过返回的错误信息,来判断是否有事务没结束。 loose_innodb_primary_abort_ddl_wait_replica_timeout建议比loose_replica_lock_wait_timeout 大。

举个实际例子方便理解: 用户可以通过命令show processlist中的State列观察,如果发现Wait for syncing with replicas字样,那么表示这条DDL目前处在等待只读节点同步的阶段。如果超过loose_innodb_primary_abort_ddl_wait_replica_timeout设置的时间,那么主节点会返回错误:

ERROR HY000: Rollback the statement as connected replica(s) delay too far away. You can kick out the slowest replica or increase variable 'innodb_abort_ddl_wait_replica_timeout'

如果没有超时,那么主节点会检查是否所有只读节点都成功获取MDL锁了,如果失败,那么主节点依然会返回错误:

ERROR HY000: Fail to get MDL on replica during DDL synchronize

如果主实例返回第二个错误,那么建议用户检查一下主实例以及所有只读实例上是否有未结束的大查询或者长时间未提交的事务。

这里顺便介绍一下大事务长事务的防范手段。参数loose_max_statement_time可以控制大查询的最大执行时间,超过这个时间后,会把查询kill掉。参数loose_rds_strict_trx_idle_timeout可以控制空闲事务的最长存活时间,当一个事务空闲状态超过这个值时候,会主动把这个连接断掉,从而结束事务,注意,这个参数应该比wait_timeout/interactive_timeout小,否则无效。

查询缓存问题

在MySQL低版本,查询缓存(Query Cache)能提高查询的性能,尤其是更新少的情况下,但是由于其本身也容易成为性能瓶颈,所以在最新的MySQL中此特性已经被移除。POLARDB目前的版本兼容MySQL 5.6,所以用户依然可以使用查询缓存,但是我们还是建议不使用,因为我们在引擎存储层做了很多优化,即使不用查询缓存依然有很好的性能。

由于POLARDB使用了物理复制,不同于binlog的逻辑复制,查询缓存在只读实例上的失效,依然需要通过redolog来保证,即当某条查询缓存失效的时候,需要通过redolog来通知所有只读节点,让他们把对应的查询记录也失效掉,否则通过只读节点会读到历史版本的数据。

当查询缓存失效时,会写redolog通知所有只读节点,这个机制默认是关闭的,通过参数loose_innodb_primary_qcache_invalid_log来控制。

综上所示,如果在只读节点上开启了查询缓存(只要有一个开启),那么必须在主节点上开启loose_innodb_primary_qcache_invalid_log,否则只读节点会读到历史版本的数据。考虑到HA切换会切换到任意一个只读节点,因此建议如果开启了查询缓存,在所有只读节点上也把loose_innodb_primary_qcache_invalid_log开启。

读写分离问题

POLARDB自带一个只读实例,增减只读实例非常快速,所以用户非常适合使用读写分离的功能,但是从目前用户的反馈来看,如果在插入数据后立刻查询,很容易查询到之前旧版的数据,为了解决这个问题,我们给出两种解法。一种是通过POLARDB数据库内核的强同步保证主实例和只读节点数据一致,另外一种是通过数据库前面的PROXY层来解决。下面简单介绍一下。

POLARDB集群基于物理复制构建,目前复制除了支持常规的异步复制(默认),半同步复制之外,还有强同步复制,即当事务提交时,只有当指定的只读实例应用完redolog日志后,主实例才给用户返回成功。这样即使后续的读请求发送到了只读节点,也能保证读到最新的数据。但是这个配置会导致性能大幅度下降,只有默认异步复制的三分之一左右,在使用之前请做详细的测试。简单说一下配置过程:

首先需要在主实例上设置:设置loose_innodb_primary_sync_slave为3,目的是告诉主实例,它连接的只读实例会有强同步的需求。接着在需要强同步的只读实例上把参数loose_slave_trans_sync_level 设置为2,注意这个参数需要重启实例。另外,先设置主实例,再设置只读实例的顺序不能乱。设置成功后,在主实例上执行show polar replicas;(这个命令可以查看所有的只读实例),在sync_level这一列,可以发现由默认的0变成了2,这就表示强同步开启成功了。如果需要关闭强同步,在主实例上设置loose_innodb_primary_sync_slave为0,只读节点上设置loose_slave_trans_sync_level 设置为0即可,注意设置的顺序依然不能乱。此外,如果强同步的只读实例在loose_innodb_primary_sync_slave_timeout后还没返回,强同步复制退化为异步复制,还可以通过loose_innodb_primary_sync_slave参数控制当只读节点掉线时是否立刻退化为异步复制。

另外一种解决办法是通过PROXY来解决。主实例每次做完更新就会把当前的日志位点发给PROXY,同时PROXY也会定期去轮询最大的日志位点,当PROXY需要把后续的查询发到只读实例上时,首先会判断只读实例是否应用到了最新的位点,如果不是,就把请求转发到主实例。这个策略操作的单位是连接,即通过这种方法能保证同一个连接中读到的一定是最新的数据。这种方法虽然会导致主库的压力变大,但是其对性能影响较小,是一种推荐的方法。如果用户需要使用,联系售后做一次小版本升级,即可开放这个功能。

BINLOG问题

POLARDB使用基于redolog的物理复制来构建复制关系,不依赖BINLOG,因此BINLOG默认是关闭的,但是许多用户需要使用BINLOG将数据同步到第三方数据仓库以方便做复杂的数据分析,所以有很多开启BINLOG的需求。用户可以开启BINLOG,但是与RDS不同,后台不但不会有任务定时上传备份BINLOG,而且也不会有定期删除BINGLOG的任务,完全需要用户自己控制何时删除,否则会导致BINLOG堆积,从而造成更多的存储成本。

用户可以通过主实例(考虑到HA切换,最好把所有只读实例也打开)参数loose_polar_log_bin打开BINLOG(需要重启),BINLOG就会自动存储在日志目录下,空间统计在日志空间使用量里面。可以通过常规的show master logs查看BINLOG。每个BINLOG的大小,BINLOG cache的大小,BINLOG格式等参数都可以通过控制台调整。这里注意下,由于POLARDB使用的是自研的存储系统,sync_binlog参数无效,因此没有开放。

如果用户需要删除无用的BINLOG,目前有两种方法,一种是通过调节参数loose_expire_logs_hours来控制BINLOG自动删除的时间,这个参数表示自BINLOG创建后多久系统自动将它删除,单位是小时。另外一种方法是通过执行purge binlog的命令,类似PURGE MASTER LOGS BEFORE/TO XXX来手动删除BINLOG。注意,删除前请务必确保BINLOG已经无效,误删除后将无法恢复。

目前,开启BINLOG有一个限制:当底层存储系统升级的时,开启BINLOG的实例不可服务时间目前是分钟级别的,不开启BINLOG的实例是秒级别的。所以,如果用户对实例可用性要求比较高,可以等我们优化后再开启BINLOG

限制问题

POLARDB由于使用了自研的文件系统和自研的块设备存储系统,因此在一些限制上与RDS MySQL有所不同。

由于文件系统是集成在数据库里面的,即数据库与文件系统共用一个进程,所以文件系统会占用一部分的规格内存。另外不同规格的文件个数也有上限。目前存储最大支持到10000GB。

此外,文件名,即数据库中的表名和库名都不能超过63个字符,实际使用的时候最好控制在55个字符以下,因为还有.frm,.ibd后缀,中间过程临时表等。详细的说明见这里

并发连接问题

数据库最佳性能的线程数一般是CPU核数的2-3倍,线程数太少,不容易发挥出多线程的优势,线程数太多,又容易导致上下文切换过多以及锁争抢严重的问题。不幸的是,很多用户往往会创建很多并发连接,导致数据库CPU打满,性能低下。

为了解决频繁创建释放连接的问题,即高频短连接问题,可以调大thread_cache_size,从而减少频繁创建连接的开销。另外,也建议用户使用客户端连接池来代替高频短连接的方案。

为了解决高并发连接的问题,可以使用Thread Pool功能。在Thread Pool模式下,用户连接和处理线程不再是一一对应关系。处理线程的数量是一个可控的数量,不会随着用户连接数的增多而大幅增加,这样可以减少高并发场景下线程上下文切换的消耗。

用户可以通过调整参数loose_thread_handlingpool-of-threads来打开Thread Pool功能。同时,建议调整参数thread_pool_size为实例CPU核数,其他参数保持默认即可。

Thread Pool比较适合短小的查询和更新,大事务大查询会降低其效果。用户需要依据业务模型来斟酌。另外需要注意一点,Thread Pool不会提高性能,但是其能稳定高并发场景下的性能。

总结

本文简单介绍了POLARDB常见的几种问题,大多数来源于用户真实的反馈。我们也在不断的探索更多的功能以及更好的交互。如果在使用POLARDB中遇到疑惑,不要犹豫请立刻联系我们,我们会给您最满意的答复,谢谢对POLARDB的支持。

MySQL · 引擎特性 · Cost Model,直方图及优化器开销优化

$
0
0

MySQL当前已经发布到MySQL8.0版本,在新的版本中,可以看到MySQL之前被人诟病的优化器部分做了很多的改动,由于笔者之前的工作环境是5.6,最近切换到最新的8.0版本,本文涵盖了一些本人感兴趣的和优化器相关的部分,主要包括MySQL5.7的cost model以及MySQL8.0的直方图功能。

本文基于当前最新的MySQL8.0.12版本,主要是讲下cost model 和 histogram的用法和相关代码

Cost Model

Configurable cost constants

为什么需要配置cost model常量 ? 我们知道MySQL已经发展了好几十年的历史,但是在优化器中依然使用了hardcode的权重值来衡量io, cpu等资源情况,而这些权重值实际上是基于多年前甚至十来年前的经验设定的。想想看,这么多年硬件的发展多么迅速。几十上百个核心的服务器不在少数甚至在某些大型公司大规模使用,ssd早就成为主流,NVME也在崛起。高速RDMA网络正在走入寻常百姓家。这一切甚至影响到数据库系统的实现和变革。显而易见,那些hardcode的权值已经过时了,我们需要提供给用户可定义的方式,甚至更进一步的,能够智能的根据硬件环境自动设定。

MySQL5.7引入两个新的系统表, 通过这两个系统表暴露给用户来进行更新,如下:

root@(none) 04:05:24>select * from mysql.server_cost;
+------------------------------+------------+---------------------+---------+---------------+
| cost_name                    | cost_value | last_update         | comment | default_value |
+------------------------------+------------+---------------------+---------+---------------+
| disk_temptable_create_cost   |       NULL | 2018-04-23 13:55:20 | NULL    |            20 |
| disk_temptable_row_cost      |       NULL | 2018-04-23 13:55:20 | NULL    |           0.5 |
| key_compare_cost             |       NULL | 2018-04-23 13:55:20 | NULL    |          0.05 |
| memory_temptable_create_cost |       NULL | 2018-04-23 13:55:20 | NULL    |             1 |
| memory_temptable_row_cost    |       NULL | 2018-04-23 13:55:20 | NULL    |           0.1 |
| row_evaluate_cost            |       NULL | 2018-04-23 13:55:20 | NULL    |           0.1 |
  +------------------------------+------------+---------------------+---------+---------------+
6 rows in set (0.00 sec)

  其中default_value是generated column,其表达式已经固定死了默认值:

  `default_value` float GENERATED ALWAYS AS (
      (case `cost_name` 
       when _utf8mb3'disk_temptable_create_cost' then 20.0 
       when _utf8mb3'disk_temptable_row_cost' then 0.5 
       when _utf8mb3'key_compare_cost' then 0.05 
       when _utf8mb3'memory_temptable_create_cost' then 1.0 
       when _utf8mb3'memory_temptable_row_cost' then 0.1 
       when _utf8mb3'row_evaluate_cost' then 0.1 else NULL end)) VIRTUAL

  root@(none) 04:05:35>select * from mysql.engine_cost;
  +-------------+-------------+------------------------+------------+---------------------+---------+---------------+
  | engine_name | device_type | cost_name              | cost_value | last_update         | comment | default_value |
  +-------------+-------------+------------------------+------------+---------------------+---------+---------------+
  | default     |           0 | io_block_read_cost     |       NULL | 2018-04-23 13:55:20 | NULL    |             1 |
  | default     |           0 | memory_block_read_cost |       NULL | 2018-04-23 13:55:20 | NULL    |          0.25 |
  +-------------+-------------+------------------------+------------+---------------------+---------+---------------+

你可以通过update语句来进行更新, 例如:

  root@(none) 04:05:52>update mysql.server_cost set cost_value = 40 where cost_name = 'disk_temptable_create_cost';
Query OK, 1 row affected (0.05 sec)
  Rows matched: 1  Changed: 1  Warnings: 0

  root@(none) 04:07:13>select * from mysql.server_cost where cost_name = 'disk_temptable_create_cost';
  +----------------------------+------------+---------------------+---------+---------------+
  | cost_name                  | cost_value | last_update         | comment | default_value |
  +----------------------------+------------+---------------------+---------+---------------+
  | disk_temptable_create_cost |         40 | 2018-06-23 16:07:05 | NULL    |            20 |
  +----------------------------+------------+---------------------+---------+---------------+
1 row in set (0.00 sec)


  //更新后执行一次flush optimizer_costs操作来更新内存
  //但老的session还是会用老的cost数据
  root@(none) 10:10:12>flush optimizer_costs;
Query OK, 0 rows affected (0.00 sec)

可以看到用法也非常简单,上面包含了两张表:server_cost及engine_cost,分别对server层和引擎层进行配置

相关代码:

全局cache Cost_constant_cache

全局cache维护了一个当前的cost model信息, 用户线程在lex_start时会去判断其有没有初始化本地指针,如果没有的话就去该cache中将指针拷贝到本地

初始化全局cache:

  Cost_constant_cache::init
  :

  创建Cost_model_constants, 其中包含了两类信息: server层cost model和引擎层cost model, 类结构如下:


  Cost_constant_cache ----> Cost_model_constants
                       ---> Server_cost_constants
                            //server_cost
                       ---> Cost_model_se_info
                            --->SE_cost_constants
                            //engine_cost 如果存储引擎提供了接口函数get_cost_constants的话,则从存储引擎那取

从系统表读取配置,适用于初始化和flush optimizer_costs并更新cache:

read_cost_constants()
  |--> read_server_cost_constants
  |--> read_engine_cost_constants

由于用户可以动态的更新系统表,执行完flush optimizer_costs后,有可能老的版本还在被某些session使用,因此需要引用计数,老的版本ref counter被降为0后才能被释放

线程cost model初始化

  • Cost_model_server

在每个线程的thd上,挂了一个Cost_model_server的对象THD::m_cost_model, 在lex_start()时,如果发现线程的m_cost_model没有初始化,就会去获取全局的指针,存储到本地:

  Cost_model_server::init

  const Cost_model_constants *m_cost_constants = cost_constant_cache->get_cost_constants();
  // 会增加一个引用计数,以确保不会在引用时被删除

  const Server_cost_constants *m_server_cost_constants = m_cost_constants->get_server_cost_constants();
  // 同样获取的是全局指针

可见thd不创建自己的cost model, 只引用cache中的指针

Table Cost Model

struct TABLE::m_cost_model, 类型:Cost_model_table

其值取自上述thd中存储的cost model对象

Cost_estimate

统一的对象类型cost_estimate来存储计算的cost结果,包含四个维度:

  double io_cost;      ///< cost of I/O operations
  double cpu_cost;     ///< cost of CPU operations
  double import_cost;  ///< cost of remote operations
  double mem_cost;     ///< memory used (bytes)

未来

目前来看,除非根据工作负载,经过充分的测试才能得出合理的配置值,但如何配置,什么是合理的值,个人认为应该是可以自动调整配置的。关键是找出配置和硬件条件的对应关系。 这也是我们未来可以努力的一个方向。

reference:

1. Cost Model官方文档2. 官方博客1:The MySQL Optimizer Cost Model Project3. 官方博客2: A new dimension to MySQL query optimizations 4. Optimizer Cost Model Improvements in MySQL 5.7.5 DMR5.Slide: MySQL Cost Model

Related Worklog: WL#7182: Optimizer Cost Model API
WL#7209: Handler interface changes for new cost modelWL#7276: Configuration data base for Optimizer Cost ModelWL#7315 Optimizer cost model: main memory management of cost constantsWL#7316 Optimizer cost model: Command for online updating of cost model constants

Histogram

直方图也是MySQL一个万众期待的功能了,这个功能实际上在其他数据库产品中是很常见的,可以很好的指导优化器选择执行路径。利用直方图存储了指定列的数据分布。MariaDB从很早的10.0.2版本支持这个功能, 而MySQL在最新的8.0版本中也开始支持

使用

MySQL里使用直方图是通过ANALYZE TABLE语法来执行:

  ANALYZE [NO_WRITE_TO_BINLOG | LOCAL]
  TABLE tbl_name
  UPDATE HISTOGRAM ON col_name [, col_name] ...
  [WITH N BUCKETS]

  ANALYZE [NO_WRITE_TO_BINLOG | LOCAL]
  TABLE tbl_name
  DROP HISTOGRAM ON col_name [, col_name] ...

举个简单的例子:

我们以普通的sysbench表为例:

  root@sb1 05:16:33>show create table sbtest1\G
  *************************** 1. row ***************************
  Table: sbtest1
  Create Table: CREATE TABLE `sbtest1` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `k` int(11) NOT NULL DEFAULT '0',
      `c` char(120) NOT NULL DEFAULT '',
      `pad` char(60) NOT NULL DEFAULT '',
      PRIMARY KEY (`id`),
      KEY `k_1` (`k`)
      ) ENGINE=InnoDB AUTO_INCREMENT=200001 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.01 sec)


# 创建直方图并存储到数据词典中

  root@sb1 05:16:38>ANALYZE TABLE sbtest1 UPDATE HISTOGRAM ON k with 10 BUCKETS;
  +-------------+-----------+----------+----------------------------------------------+
  | Table       | Op        | Msg_type | Msg_text                                     |
  +-------------+-----------+----------+----------------------------------------------+
  | sb1.sbtest1 | histogram | status   | Histogram statistics created for column 'k'. |
  +-------------+-----------+----------+----------------------------------------------+
1 row in set (0.55 sec)

  root@sb1 05:17:03>ANALYZE TABLE sbtest1 UPDATE HISTOGRAM ON k,pad with 10 BUCKETS;
  +-------------+-----------+----------+------------------------------------------------+
  | Table       | Op        | Msg_type | Msg_text                                       |
  +-------------+-----------+----------+------------------------------------------------+
  | sb1.sbtest1 | histogram | status   | Histogram statistics created for column 'k'.   |
  | sb1.sbtest1 | histogram | status   | Histogram statistics created for column 'pad'. |
  +-------------+-----------+----------+------------------------------------------------+
2 rows in set (7.98 sec)

  删除pad列上的histogram:
  root@sb1 05:17:51>ANALYZE TABLE sbtest1 DROP HISTOGRAM ON pad;
  +-------------+-----------+----------+------------------------------------------------+
  | Table       | Op        | Msg_type | Msg_text                                       |
  +-------------+-----------+----------+------------------------------------------------+
  | sb1.sbtest1 | histogram | status   | Histogram statistics removed for column 'pad'. |
  +-------------+-----------+----------+------------------------------------------------+
1 row in set (0.06 sec)

  root@sb1 05:58:12>ANALYZE TABLE sbtest1 DROP HISTOGRAM ON k;
  +-------------+-----------+----------+----------------------------------------------+
  | Table       | Op        | Msg_type | Msg_text                                     |
  +-------------+-----------+----------+----------------------------------------------+
  | sb1.sbtest1 | histogram | status   | Histogram statistics removed for column 'k'. |
  +-------------+-----------+----------+----------------------------------------------+
1 row in set (0.08 sec)


# 如果不指定bucket的话,默认Bucket的数量是100

  root@sb1 05:58:27>ANALYZE TABLE sbtest1 UPDATE HISTOGRAM ON k;
  +-------------+-----------+----------+----------------------------------------------+
  | Table       | Op        | Msg_type | Msg_text                                     |
  +-------------+-----------+----------+----------------------------------------------+
  | sb1.sbtest1 | histogram | status   | Histogram statistics created for column 'k'. |
  +-------------+-----------+----------+----------------------------------------------+
1 row in set (0.56 sec)


直方图统计信息存储于InnoDB数据词典中,可以通过information_schema表来获取

  root@information_schema 05:34:49>SHOW CREATE TABLE INFORMATION_SCHEMA.COLUMN_STATISTICS\G
  *************************** 1. row ***************************
  View: COLUMN_STATISTICS
Create View: CREATE ALGORITHM=UNDEFINED DEFINER=`mysql.infoschema`@`localhost` SQL SECURITY DEFINER VIEW `COLUMN_STATISTICS` AS select `mysql`.`column_statistics`.`schema_name` AS `SCHEMA_NAME`,`mysql`.`column_statistics`.`table_name` AS `TABLE_NAME`,`mysql`.`column_statistics`.`column_name` AS `COLUMN_NAME`,`mysql`.`column_statistics`.`histogram` AS `HISTOGRAM` from `mysql`.`column_statistics` where can_access_table(`mysql`.`column_statistics`.`schema_name`,`mysql`.`column_statistics`.`table_name`)
  character_set_client: utf8
  collation_connection: utf8_general_ci
1 row in set (0.00 sec)

从column_statistics表的定义可以看到,有一个名为mysql.column_statistics系统表,但被隐藏了,没有对外暴露

以下举个简单的例子:

  root@sb1 05:58:55>ANALYZE TABLE sbtest1 UPDATE HISTOGRAM ON k WITH 4 BUCKETS;
  +-------------+-----------+----------+----------------------------------------------+
  | Table       | Op        | Msg_type | Msg_text                                     |
  +-------------+-----------+----------+----------------------------------------------+
  | sb1.sbtest1 | histogram | status   | Histogram statistics created for column 'k'. |
  +-------------+-----------+----------+----------------------------------------------+
1 row in set (0.63 sec)

# 查询表上的直方图信息

  root@sb1 06:00:43>SELECT JSON_PRETTY(HISTOGRAM) FROM INFORMATION_SCHEMA.COLUMN_STATISTICS WHERE SCHEMA_NAME='sb1' AND TABLE_NAME = 'sbtest1'\G
  *************************** 1. row ***************************
  JSON_PRETTY(HISTOGRAM): {
    "buckets": [
      [
      38671,
      99756,
      0.249795,
      17002
        ],
      [
        99757,
      100248,
      0.500035,
      492
        ],
      [
        100249,
      100743,
      0.749945,
      495
        ],
      [
        100744,
      172775,
      1.0,
      16630
        ]
        ],
      "data-type": "int",
      "null-values": 0.0,
      "collation-id": 8,
      "last-updated": "2018-09-22 09:59:30.857797",
      "sampling-rate": 1.0,
      "histogram-type": "equi-height",
      "number-of-buckets-specified": 4
  }
1 row in set (0.00 sec)

从输出的json可以看到,在执行了上述语句后产生的直方图,有4个bucket,数据类型为Int, 类型为equi-height,即等高直方图(另外一种是等宽直方图,即SINGLETON)。每个Bucket中,描述的信息包括:数值的上界和下界, 频率以及不同值的个数。通过这些信息可以获得比较精确的数据分布情况,从而优化器来根据这些统计信息决定更优的执行计划。

如果列上存在大量的重复值,那么MySQL也可能选择等宽直方图,例如上例,我们将列k上的值更新为一半10一半为20, 那么出来的直方图数据如下:

  root@sb1 10:41:17>SELECT JSON_PRETTY(HISTOGRAM) FROM INFORMATION_SCHEMA.COLUMN_STATISTICS WHERE SCHEMA_NAME='sb1' AND TABLE_NAME = 'sbtest1'\G
  *************************** 1. row ***************************
  JSON_PRETTY(HISTOGRAM): {
    "buckets": [
      [
      10,
      0.499995
        ],
      [
        20,
      1.0
        ]
        ],
      "data-type": "int",
      "null-values": 0.0,
      "collation-id": 8,
      "last-updated": "2018-09-22 14:41:17.312601",
      "sampling-rate": 1.0,
      "histogram-type": "singleton",
      "number-of-buckets-specified": 100
  }
1 row in set (0.00 sec)

如上,对于SINGLETON类型,每个bucket只包含两个值:列值,及对应的累计频率(即百分之多少的数据比当前Bucket里的值要小或相等)

注意这里的sampling-rate, 这里的值为1,表示读取了表上所有的数据来进行统计,但通常对于大表而言,我们可能不希望读太多的数据,因为可能产生过度的内存消耗,因此MySQL还提供了一个参数histogram_generation_max_mem_size来限制内存的使用上限。

如果表上的DML不多,那直方图基本是稳定的,但频繁写入的话,那我们就可能需要去定期更新直方图,MySQL本身不会去主动更新。

优化器通过histogram来计算列的过滤性,大多数的谓词都可以使用到。具体参阅官方文档

关于直方图影响查询计划,这篇博客这篇博客

相关代码

代码结构:以MySQL8.0.12为例,主要代码在sql/histogram目录下:

  ls sql/histograms/
  equi_height_bucket.cc  
  equi_height_bucket.h  
  equi_height.cc  
  equi_height.h  histogram.cc  
  histogram.h  singleton.cc  
  singleton.h  
  value_map.cc  
  value_map.h  
  value_map_type.h


  类结构:

  namespace histograms
  |---> Histogram  //基类
           |--> Equi_height //等高直方图,模板类,实例化参数为数据类型,需要针对类型显示定义
           // 见文件 "equi_height.cc"
           |--> Singleton
           //等宽直方图,只有值和其出现的频度被存储下来

创建及存储histogram:

处理histogram的相关函数和堆栈如下:

  Sql_cmd_analyze_table::handle_histogram_command
  |--> update_histogram  //更新histogram
     |-->histograms::update_histogram  //调用namespace内的接口函数
           a. 判断各个列:
           //histograms::field_type_to_value_map_type:  检查列类型是否支持
           //covered_by_single_part_index: 如果列是Pk或者uk,不会为其创建histogram
           //如果是generated column, 则找到其依赖的列加入到set中
           b. 判断取样的半分比,这主要受参数histogram_generation_max_mem_size限制,如果设的足够大,则会去读取全表数据进行分析
           |-> fill_value_maps   //开始从表上读取需要分析的列数据
               |->ha_sample_init
               |->ha_sample_next
                   |-->  handler::sample_next //读取下一条记录,通过随机数的方式来进行取样
               Value_map<T>::add_values // 将读到的数据加入到map中
               |->...
               |->ha_sample_end

           |-> build_histogram //创建histogram对象
           a. 确定histogram类型:如果值的个数小于桶的个数,则使用Singleton,否则使用Equi_height类型
               |->Singleton<T>::build_histogram
               |->Equi_height<T>::build_histogram

           |-> Histogram::store_histogram //将histogram信息存储到column_statistic表中
               |-> dd::cache::Dictionary_client::update<dd::Column_statistics>

  |--> drop_histogram //删除直方图

使用histogram

使用的方式就比较简单了:

首先在表对象TABLE_SHARE中,增加成员m_histograms,其结构为一个unordered map,key值为field index, value为相应的histogram对象

获取列值过滤性的相关堆栈如下:

  get_histogram_selectivity
       |-->Histogram::get_selectivity
           |->get_equal_to_selectivity_dispatcher
           |->get_greater_than_selectivity_dispatcher
           |->get_less_than_selectivity_dispatcher
       |-->write_histogram_to_trace // 写到optimizer_trace中

MySQL支持多种操作类型对直方图的使用,包括:

  col_name = constant
  col_name <> constant
  col_name != constant
  col_name > constant
  col_name < constant
  col_name >= constant
  col_name <= constant
  col_name IS NULL
  col_name IS NOT NULL
  col_name BETWEEN constant AND constant
  col_name NOT BETWEEN constant AND constant
  col_name IN (constant[, constant] ...)
col_name NOT IN (constant[, constant] ...)

通过直方图,我们可以根据列上的条件判断出列值的过滤性,来辅助选择更优的执行计划。在没有直方图之前我们需要通过在列上建立索引来获得相对精确的列值分布。但我们知道索引是有很大的维护开销的,而直方图则可以灵活的按需创建。

reference

WL#5384 PERFORMANCE_SCHEMA, HISTOGRAMSWL#8706 Persistent storage of Histogram dataWL#8707 Classes/structures for HistogramsWL#8943 Extend ANALYZE TABLE with histogram supportWL#9223 Using histogram statistics in the optimizer

其他

优化rec_per_key

相关worklog: WL#7338: Interface for improved records per key estimatesWL#7339 Use improved records per key estimate interface in optimizer

MySQL通过rec_per_key 接口来估算记录的个数(暗示每个索引Key对应的记录个数),但在早前版本中这个数字是整数,对于小数会取整,不能表示准确的rec_per_key,从而影响到索引的选择,因此在5.7版本中,将其记录的值改成了float类型

引入数据cache状态计算开销

相关worklog:

WL#7168 API for estimates for how much of table and index data that is in memory bufferWL#7170: InnoDB buffer estimates for tables and indexesWL#7340 IO aware cost estimate function for data access

在之前的版本中,优化器是无法知道数据的状态,是否是cache在内存中,还是需要从磁盘读出来的,缺乏这部分信息,导致优化器统一认为数据属于磁盘的来计算开销。这可能导致低效的执行计划。

相关代码:

server层新增api,用于获取表或索引上有百分之多少的数据是存储在cache中的

  handler::table_in_memory_estimate
  handler::index_in_memory_estimate

而在innodb层,增加了一个全局变量buf_stat_per_index (对应类型为buf_stat_per_index_t) 来维护每个索引在内存中的leaf page个数, 其内部实现了一个lock-free的hash结构,Key值为(m_space_id) << 32 | m_index_id), 在读入page时或者内存中创建新page时, 如果对应的page是leaf page,就递增计数;当从page hash中移除时,则递减计数。

为了减少性能的影响,计数器是通过lock-free hash的结构存储的,对应的结构为ut_lock_free_hash_t。 基本的实现思路是:hash是一个定长的数组,数组元素为(key, val), 根据Key计算一个hash值再模上array size, 找到对应的槽位, 如果槽位被占用了,则向右查找一个空闲的slot。 当数组满了的时候,会创建一个新的更大的数组,在数据还没Move到这个新hash之前,所有的search都需要查询两个数组。当所有的记录到迁移到新数组,并且没有线程访问老的数组时,就可以把老的hash删除掉了。

在hash中存储的counter本身,也考虑到多核和numa架构,避免同时更新引起的cpu cache失效。在大量core的场景下这个问题可能很明显。Innodb封装计数操作到类ut_lock_free_cnt_t中,使用数组维护counter, 按照cpu no作为index更新,需要获取counter值时则累加数组中的值。

这个Lock free hash并不是个通用场景的hash结构:例如处理冲突的时候,可能占用其他key的槽位,hash不够用时,需要迁移到新的array中。实际上mysql本身实现了一个lf_hash,在扩展Hash时无需迁移数据,有空单独开篇博客讲一下。

你可以从information_schema.innodb_cached_indexes表中读取到每个索引cache的page个数。

当定义好接口,并且Innodb提供相应的统计数据后,优化器就可以利用这些信息来计算开销:

  • Cost_model_table::page_read_cost
  • Cost_model_table::page_read_cost_index

MSSQL · 最佳实践 · 使用混合密钥实现列加密

$
0
0

摘要

在SQL Server安全系列专题的上两期月报分享中,我们分别分享了:如何使用对称密钥实现SQL Server列加密技术和使用非对称密钥加密方式实现SQL Server列加密。本期月报我们分享使用混合密钥加密方式实现SQL Server列加密技术,最大限度减少性能损失,最大程度保护用户数据安全。

场景引入

对称加密是指加密和解密过程使用同一个密钥的加密算法,非对称加密是指加密和解密过程使用不同的密钥进行的加密算法。因此,通常来说对称加密安全性较弱,非对象加密安全性相对较高。凡事都具有两面性,非对称密钥加密的安全性较好,但通常算法相比对称密钥复杂许多,因此会带来性能上的损失也更大。有没有一种方法既可以最大限度保证数据安全性,又能够最大限度的减少性能损失呢?这便是本期月报分享的价值所在:SQL Server使用混合密钥实现列加密技术。

具体实现

在SQL Server 2005及以后版本,在支持对称密钥实现列加密的同时,也同样支持非对称密钥实现列加密,以下是使用混合密钥加密用户手机号码的具体实现步骤以及详细过程,以此最大限度满足数据库安全性和减少加密解密过程的性能损失。

创建测试数据库

创建一个专门的测试数据库,名为:TestDb。

--Step 1 - Create MSSQL sample database
USE master
GO
IF DB_ID('TestDb') IS NOT NULL
	DROP DATABASE [TestDb];
GO
CREATE DATABASE [TestDb];
GO

创建测试表

在TestDb数据库下,创建一张专门的测试表,名为:CustomerInfo。

--Step 2 - Create Test Table, init data & verify
USE [TestDb]
GO
IF OBJECT_ID('dbo.CustomerInfo', 'U') IS NOT NULL
	DROP TABLE dbo.CustomerInfo
CREATE TABLE dbo.CustomerInfo
(
CustomerId		INT IDENTITY(10000,1)	NOT NULL PRIMARY KEY,
CustomerName	VARCHAR(100)			NOT NULL,
CustomerPhone	CHAR(11)				NOT NULL
);

-- Init Table
INSERT INTO dbo.CustomerInfo 
VALUES ('CustomerA','13402872514')
,('CustomerB','13880674722')
,('CustomerC','13487759293')
GO

-- Verify data
SELECT * 
FROM dbo.CustomerInfo
GO

原始数据中,用户的电话号码为明文存储,任何有权限查看表数据的用户,都可以清楚明了的获取到用户的电话号码信息,展示如下:

01.png

创建实例级别Master Key

在SQL Server数据库实例级别创建Master Key(在Master数据库下,使用CREATE MASTER KEY语句):

-- Step 3 - Create SQL Server Service Master Key
USE master;
GO
IF NOT EXISTS(
	SELECT *
	FROM sys.symmetric_keys
	WHERE name = '##MS_ServiceMasterKey##')
BEGIN
	CREATE MASTER KEY ENCRYPTION BY 
	PASSWORD = 'MSSQLSerivceMasterKey'
END;
GO

创建数据库级别Master Key

在用户数据库TestDb数据库下,创建Master Key:

-- Step 4 - Create MSSQL Database level master key
USE [TestDb]
GO
IF NOT EXISTS (SELECT * 
				FROM sys.symmetric_keys 
				WHERE name LIKE '%MS_DatabaseMasterKey%')
BEGIN		
	CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'TestDbMasterKey@3*';
END
GO

创建非对称密钥

在用户数据库下,创建非对称密钥,并使用密码对非对称密钥进行加密:

-- Step 5 - Create MSSQL Asymmetric Key
USE [TestDb]
GO
IF NOT EXISTS (SELECT * 
				FROM sys.asymmetric_keys 
				WHERE name = 'AsymKey_TestDb')
BEGIN
	CREATE ASYMMETRIC KEY AsymKey_TestDb 
	WITH ALGORITHM = RSA_512 
	ENCRYPTION BY PASSWORD = 'Password4@Asy'
	;
END
GO

USE [TestDb]
GO
SELECT *
FROM  sys.asymmetric_keys

查看非对称密钥

您可以使用如下查询语句查看非对称密钥:

USE [TestDb]
GO
SELECT *
FROM  sys.asymmetric_keys

结果展示如下:

02.png

当然,您也可以用SSMS图形界面来查看证书和非对称密钥对象,方法是在用户数据库下,打开Security => Certificates => Asymmetric Keys,如下图所示:

03.png

创建对称密钥

使用非对称密钥AsymKey_TestDb来加密对称密钥SymKey_TestDb,然后使用这个对称密钥SymKey_TestDb来加密用户数据。这样既可以利用非对称密钥的安全性来保护对称密钥,又能兼顾对称密钥加密数据的高效性,两全其美。这种使用非对称密钥加密对称密钥,然后使用对称密钥加密用户敏感数据的方式,我且称之为“混合密钥”加密,这一步是本篇文章的关键点,也是很多人没有关注到的点。

--Step 6 - Create Symmetric Key Encrypted by symmetic key
USE [TestDb]
GO
IF NOT EXISTS (SELECT * 
				FROM sys.symmetric_keys 
				WHERE name = 'SymKey_TestDb')
BEGIN
	CREATE SYMMETRIC KEY SymKey_TestDb 
	WITH ALGORITHM = AES_256 
	ENCRYPTION BY ASYMMETRIC KEY AsymKey_TestDb;  -- Asymmetric Key
	;
END
GO


USE [TestDb]
GO
SELECT *
FROM  sys.symmetric_keys

对称密钥展示如下: 04.png

修改表结构

接下来,我们需要修改表结构,添加一个数据类型为varbinary(max)的新列,假设列名为EncryptedCustomerPhone ,用于存储加密后的手机号码密文。

-- Step 7 - Change your table structure
USE [TestDb]
GO 
ALTER TABLE CustomerInfo 
ADD EncryptedCustomerPhone varbinary(MAX) NULL
GO

新列数据初始化

新列添加完毕后,我们将表中历史数据的用户手机号CustomerPhone,加密为密文,并存储在新字段EncryptedCustomerPhone中。方法是使用EncryptByKey函数加密CustomerPhone列,如下语句所示:

-- Step 8 - init the encrypted data into the newly column
USE [TestDb]
GO 
-- Opens the symmetric key: SymKey_TestDb
OPEN SYMMETRIC KEY SymKey_TestDb
DECRYPTION BY ASYMMETRIC KEY AsymKey_TestDb WITH PASSWORD = 'Password4@Asy';
GO
UPDATE A
SET EncryptedCustomerPhone = EncryptByKey (Key_GUID('SymKey_TestDb'), CustomerPhone)
FROM dbo.CustomerInfo AS A;
GO
-- Closes the symmetric key: SymKey_TestDb
CLOSE SYMMETRIC KEY SymKey_TestDb;
GO
-- Double check the encrypted data of the new column
SELECT * FROM dbo.CustomerInfo

查看表中EncryptedCustomerPhone列的数据,已经变成CustomerPhone对称加密后的密文,如下展示: 05.png

查看加密数据

手机号被加密为密文后,我们需要使用DecryptByKey函数将其解密为明文(解密前,需要打开对称密钥),让我们尝试看看能否成功解密EncryptedCustomerPhone字段。

-- Step 9 - Reading the SQL Server Encrypted Data
USE [TestDb]
GO 
-- Opens the symmetric key: SymKey_TestDb
OPEN SYMMETRIC KEY SymKey_TestDb
DECRYPTION BY ASYMMETRIC KEY AsymKey_TestDb WITH PASSWORD = 'Password4@Asy';
GO

-- Now, it's time to list the original phone, encrypted phone and the descrypted phone.
SELECT 
	*,
	DescryptedCustomerPhone = CONVERT(CHAR(11), DecryptByKey(EncryptedCustomerPhone))
FROM dbo.CustomerInfo;
 
-- Close the symmetric key
CLOSE SYMMETRIC KEY SymKey_TestDb;
GO

查询语句执行结果如下,CustomerPhone和DescryptedCustomerPhone字段数据内容是一模一样的,因此加密和解密成功。 06.png

添加新数据

历史数据加密解密后的数据保持一致,然后,让我们看看新添加的数据:

-- Step 10 - What if we add new record to table.
USE [TestDb]
GO 
OPEN SYMMETRIC KEY SymKey_TestDb
DECRYPTION BY ASYMMETRIC KEY AsymKey_TestDb WITH PASSWORD = 'Password4@Asy';
GO
-- Performs the update of the record
INSERT INTO dbo.CustomerInfo (CustomerName, CustomerPhone, EncryptedCustomerPhone)
VALUES ('CustomerD', '13880975623', EncryptByKey( Key_GUID('SymKey_TestDb'), '13880975623'));  

-- Close the symmetric key
CLOSE SYMMETRIC KEY SymKey_TestDb;
GO

更新数据手机号

接下来,我们尝试更新用户手机号:

-- Step 11 - So, what if we upadate the phone
USE [TestDb]
GO 
OPEN SYMMETRIC KEY SymKey_TestDb
DECRYPTION BY ASYMMETRIC KEY AsymKey_TestDb WITH PASSWORD = 'Password4@Asy';

-- Performs the update of the record
UPDATE A
SET EncryptedCustomerPhone = EncryptByKey( Key_GUID('SymKey_TestDb'), '13880971234')
FROM dbo.CustomerInfo AS A
WHERE CONVERT(CHAR(11), DecryptByKey(EncryptedCustomerPhone)) = '13880975623'

-- Close the symmetric key
CLOSE SYMMETRIC KEY SymKey_TestDb;
GO

删除手机号明文列

一切没有问题,我们可以将用户手机号明文列CustomerPhone删除:

-- Step 12 - Remove old column
USE [TestDb]
GO 
ALTER TABLE CustomerInfo
DROP COLUMN CustomerPhone;
GO

再次查看加密数据

将用户手机号码的明文列删除后,我们再次查看解密用户手机号码明文列

--Step 13 - verify again
USE [TestDb]
GO 
OPEN SYMMETRIC KEY SymKey_TestDb
DECRYPTION BY ASYMMETRIC KEY AsymKey_TestDb WITH PASSWORD = 'Password4@Asy';

SELECT 
	*,
	DescryptedCustomerPhone = CONVERT(CHAR(11), DecryptByKey(EncryptedCustomerPhone))
FROM dbo.CustomerInfo
 
CLOSE SYMMETRIC KEY SymKey_TestDb;
GO

结果展示如下: 07.png

一切正常,历史数据、新添加的数据、更新的数据,都可以工作完美。按理,文章到这里也就结束。但是有一个问题我们是需要搞清楚的,那就是:如果我们新创建了用户,他能够访问这个表的数据吗?以及我们如何让新用户能够访问该表的数据呢?

添加新用户

模拟新添加一个用户EncryptedDbo:

-- Step 14 - Create a new user & access the encrypted data
USE [TestDb]
GO
IF EXISTS(
	SELECT TOP 1 *
	FROM sys.server_principals
	WHERE name = 'EncryptedDbo'
)
BEGIN
	DROP LOGIN EncryptedDbo;
END
GO

CREATE LOGIN EncryptedDbo
	WITH PASSWORD=N'EncryptedDbo@3*', CHECK_POLICY = OFF;
	
GO

CREATE USER EncryptedDbo FOR LOGIN EncryptedDbo;

GRANT SELECT ON OBJECT::dbo.CustomerInfo TO EncryptedDbo;
GO

新用户查询数据

使用刚才创建的用户,在SSMS中新打开一个新连接,查询数据:

-- Step 15 -- OPEN a new connection query window using the new user and query data 
USE [TestDb]
GO

OPEN SYMMETRIC KEY SymKey_TestDb
DECRYPTION BY ASYMMETRIC KEY AsymKey_TestDb WITH PASSWORD = 'Password4@Asy';

SELECT 
	*,
	DescryptedCustomerPhone = CONVERT(CHAR(11), DecryptByKey(EncryptedCustomerPhone))
FROM dbo.CustomerInfo

CLOSE SYMMETRIC KEY SymKey_TestDb;
GO

新用户也无法解密EncryptedCustomerPhone,解密后的DescryptedCustomerPhone 字段值为NULL,即新用户无法查看到用户手机号明文,避免了未知用户获取用户手机号等核心数据信息。 08.png

而且,还会因为权限的问题,OPEN SYMMETRIC KEY和CLOSE SYMMETRIC KEY报错,可以在Messages窗口中看到: 09.png

为新用户赋权限

新用户没有查看加密列数据的权限,如果需要赋予权限,这里需要授权对称密钥DEFINITION权限和非对称密钥CONTROL权限,方法如下:

--Step 16 - Grant permissions to EncryptedDbo
USE [TestDb]
GO

GRANT VIEW DEFINITION ON 
	SYMMETRIC KEY::[SymKey_TestDb] TO [EncryptedDbo];
GO

GRANT CONTROL ON 
	ASYMMETRIC KEY::[AsymKey_TestDb] TO [EncryptedDbo];
GO

新用户再次查询

赋权限完毕后,新用户再次执行“新用户查询数据”中的查询语句,已经可以正常获取到加密列的明文数据了。

-- Step 15 -- OPEN a new connection query window using the new user and query data 
USE [TestDb]
GO

OPEN SYMMETRIC KEY SymKey_TestDb
DECRYPTION BY ASYMMETRIC KEY AsymKey_TestDb WITH PASSWORD = 'Password4@Asy';

SELECT 
	*,
	DescryptedCustomerPhone = CONVERT(CHAR(11), DecryptByKey(EncryptedCustomerPhone))
FROM dbo.CustomerInfo

CLOSE SYMMETRIC KEY SymKey_TestDb;
GO

再次查询结果展示如下: 10.png

最后总结

本篇月报分享了如何利用非对称密钥加密对称密钥,然后使用对称密钥加密用户数据,即混合密钥的方式实现SQL Server列加密技术,以此来最大限度保护用户核心数据信息安全的同时,又最大限度降低了加密解密对的性能损失。

MongoDB · 引擎特性 · 复制集原理

$
0
0

复制集简介

Mongodb复制集由一组Mongod实例(进程)组成,包含一个Primary节点和多个Secondary节点,Mongodb Driver(客户端)的所有数据都写入Primary,Secondary从Primary同步写入的数据,以保持复制集内所有成员存储相同的数据集,提供数据的高可用。

下图(图片源于Mongodb官方文档)是一个典型的Mongdb复制集,包含一个Primary节点和2个Secondary节点。

Mongodb复制集

Primary选举

复制集通过replSetInitiate命令(或mongo shell的rs.initiate())进行初始化,初始化后各个成员间开始发送心跳消息,并发起Priamry选举操作,获得『大多数』成员投票支持的节点,会成为Primary,其余节点成为Secondary。

初始化复制集

	config = {
	    _id : "my_replica_set",
	    members : [
	         {_id : 0, host : "rs1.example.net:27017"},
	         {_id : 1, host : "rs2.example.net:27017"},
	         {_id : 2, host : "rs3.example.net:27017"},
	   ]
	}
	
	rs.initiate(config)

『大多数』的定义

假设复制集内投票成员(后续介绍)数量为N,则大多数为 N/2 + 1,当复制集内存活成员数量不足大多数时,整个复制集将无法选举出Primary,复制集将无法提供写服务,处于只读状态。

投票成员数大多数容忍失效数
110
220
321
431
542
642
743

通常建议将复制集成员数量设置为奇数,从上表可以看出3个节点和4个节点的复制集都只能容忍1个节点失效,从『服务可用性』的角度看,其效果是一样的。(但无疑4个节点能提供更可靠的数据存储)

特殊的Secondary

正常情况下,复制集的Seconary会参与Primary选举(自身也可能会被选为Primary),并从Primary同步最新写入的数据,以保证与Primary存储相同的数据。

Secondary可以提供读服务,增加Secondary节点可以提供复制集的读服务能力,同时提升复制集的可用性。另外,Mongodb支持对复制集的Secondary节点进行灵活的配置,以适应多种场景的需求。

Arbiter

Arbiter节点只参与投票,不能被选为Primary,并且不从Primary同步数据。 比如你部署了一个2个节点的复制集,1个Primary,1个Secondary,任意节点宕机,复制集将不能提供服务了(无法选出Primary),这时可以给复制集添加一个Arbiter节点,即使有节点宕机,仍能选出Primary。

Arbiter本身不存储数据,是非常轻量级的服务,当复制集成员为偶数时,最好加入一个Arbiter节点,以提升复制集可用性。

Priority0

Priority0节点的选举优先级为0,不会被选举为Primary。

比如你跨机房A、B部署了一个复制集,并且想指定Primary必须在A机房,这时可以将B机房的复制集成员Priority设置为0,这样Primary就一定会是A机房的成员。(注意:如果这样部署,最好将『大多数』节点部署在A机房,否则网络分区时可能无法选出Primary)

Vote0

Mongodb 3.0里,复制集成员最多50个,参与Primary选举投票的成员最多7个,其他成员(Vote0)的vote属性必须设置为0,即不参与投票。

Hidden

Hidden节点不能被选为主(Priority为0),并且对Driver不可见。 因Hidden节点不会接受Driver的请求,可使用Hidden节点做一些数据备份、离线计算的任务,不会影响复制集的服务。

Delayed

Delayed节点必须是Hidden节点,并且其数据落后与Primary一段时间(可配置,比如1个小时)。 因Delayed节点的数据比Primary落后一段时间,当错误或者无效的数据写入Primary时,可通过Delayed节点的数据来恢复到之前的时间点。

数据同步

Primary与Secondary之间通过oplog来同步数据,Primary上的写操作完成后,会向特殊的local.oplog.rs特殊集合写入一条oplog,Secondary不断的从Primary取新的oplog并应用。

因oplog的数据会不断增加,local.oplog.rs被设置成为一个capped集合,当容量达到配置上限时,会将最旧的数据删除掉。另外考虑到oplog在Secondary上可能重复应用,oplog必须具有幂等性,即重复应用也会得到相同的结果。

如下oplog的格式,包含ts、h、op、ns、o等字段

{
  "ts" : Timestamp(1446011584, 2),
  "h" : NumberLong("1687359108795812092"), 
  "v" : 2, 
  "op" : "i", 
  "ns" : "test.nosql", 
  "o" : { "_id" : ObjectId("563062c0b085733f34ab4129"), "name" : "mongodb", "score" : "100" } 
}

上述oplog里各个字段的含义如下

  • ts: 操作时间,当前timestamp + 计数器,计数器每秒都被重置
  • h:操作的全局唯一标识
  • v:oplog版本信息
  • op:操作类型
    • i:插入操作
    • u:更新操作
    • d:删除操作
    • c:执行命令(如createDatabase,dropDatabase)
    • n:空操作,特殊用途
  • ns:操作针对的集合
  • o:操作内容,如果是更新操作
  • o2:操作查询条件,仅update操作包含该字段

Secondary初次同步数据时,会先进行init sync,从Primary(或其他数据更新的Secondary)同步全量数据,然后不断通过tailable cursor从Primary的local.oplog.rs集合里查询最新的oplog并应用到自身。

init sync过程包含如下步骤

  1. T1时间,从Primary同步所有数据库的数据(local除外),通过listDatabases + listCollections + cloneCollection敏命令组合完成,假设T2时间完成所有操作。
  2. 从Primary应用[T1-T2]时间段内的所有oplog,可能部分操作已经包含在步骤1,但由于oplog的幂等性,可重复应用。
  3. 根据Primary各集合的index设置,在Secondary上为相应集合创建index。(每个集合_id的index已在步骤1中完成)。

oplog集合的大小应根据DB规模及应用写入需求合理配置,配置得太大,会造成存储空间的浪费;配置得太小,可能造成Secondary的init sync一直无法成功。比如在步骤1里由于DB数据太多、并且oplog配置太小,导致oplog不足以存储[T1, T2]时间内的所有oplog,这就Secondary无法从Primary上同步完整的数据集。

修改复制集配置

当需要修改复制集时,比如增加成员、删除成员、或者修改成员配置(如priorty、vote、hidden、delayed等属性),可通过replSetReconfig命令(rs.reconfig())对复制集进行重新配置。 比如将复制集的第2个成员Priority设置为2,可执行如下命令

cfg = rs.conf();
cfg.members[1].priority = 2;
rs.reconfig(cfg);

细说Primary选举

Primary选举除了在复制集初始化时发生,还有如下场景

  • 复制集被reconfig
  • Secondary节点检测到Primary宕机时,会触发新Primary的选举
  • 当有Primary节点主动stepDown(主动降级为Secondary)时,也会触发新的Primary选举

Primary的选举受节点间心跳、优先级、最新的oplog时间等多种因素影响。

节点间心跳

复制集成员间默认每2s会发送一次心跳信息,如果10s未收到某个节点的心跳,则认为该节点已宕机;如果宕机的节点为Primary,Secondary(前提是可被选为Primary)会发起新的Primary选举。

节点优先级

  • 每个节点都倾向于投票给优先级最高的节点
  • 优先级为0的节点不会主动发起Primary选举
  • 当Primary发现有优先级更高Secondary,并且该Secondary的数据落后在10s内,则Primary会主动降级,让优先级更高的Secondary有成为Primary的机会。

Optime

拥有最新optime(最近一条oplog的时间戳)的节点才能被选为主。

网络分区

只有更大多数投票节点间保持网络连通,才有机会被选Primary;如果Primary与大多数的节点断开连接,Primary会主动降级为Secondary。当发生网络分区时,可能在短时间内出现多个Primary,故Driver在写入时,最好设置『大多数成功』的策略,这样即使出现多个Primary,也只有一个Primary能成功写入大多数。

复制集的读写设置

Read Preference

默认情况下,复制集的所有读请求都发到Primary,Driver可通过设置Read Preference来将读请求路由到其他的节点。

  • primary: 默认规则,所有读请求发到Primary
  • primaryPreferred: Primary优先,如果Primary不可达,请求Secondary
  • secondary: 所有的读请求都发到secondary
  • secondaryPreferred:Secondary优先,当所有Secondary不可达时,请求Primary
  • nearest:读请求发送到最近的可达节点上(通过ping探测得出最近的节点)

Write Concern

默认情况下,Primary完成写操作即返回,Driver可通过设置[Write Concern(https://docs.mongodb.org/manual/core/write-concern/)来设置写成功的规则。

如下的write concern规则设置写必须在大多数节点上成功,超时时间为5s。

db.products.insert(
  { item: "envelopes", qty : 100, type: "Clasp" },
  { writeConcern: { w: majority, wtimeout: 5000 } }
)

上面的设置方式是针对单个请求的,也可以修改副本集默认的write concern,这样就不用每个请求单独设置。

cfg = rs.conf()
cfg.settings = {}
cfg.settings.getLastErrorDefaults = { w: "majority", wtimeout: 5000 }
rs.reconfig(cfg)

异常处理(rollback)

当Primary宕机时,如果有数据未同步到Secondary,当Primary重新加入时,如果新的Primary上已经发生了写操作,则旧Primary需要回滚部分操作,以保证数据集与新的Primary一致。 旧Primary将回滚的数据写到单独的rollback目录下,数据库管理员可根据需要使用mongorestore进行恢复。

Redis · lazyfree · 大key删除的福音

$
0
0

背景

redis重度使用患者应该都遇到过使用 DEL 命令删除体积较大的键, 又或者在使用 FLUSHDB 和 FLUSHALL 删除包含大量键的数据库时,造成redis阻塞的情况;另外redis在清理过期数据和淘汰内存超限的数据时,如果碰巧撞到了大体积的键也会造成服务器阻塞。

为了解决以上问题, redis 4.0 引入了lazyfree的机制,它可以将删除键或数据库的操作放在后台线程里执行, 从而尽可能地避免服务器阻塞。

lazyfree机制

lazyfree的原理不难想象,就是在删除对象时只是进行逻辑删除,然后把对象丢给后台,让后台线程去执行真正的destruct,避免由于对象体积过大而造成阻塞。redis的lazyfree实现即是如此,下面我们由几个命令来介绍下lazyfree的实现。

1. UNLINK命令

首先我们来看下新增的unlink命令:

void unlinkCommand(client *c) {
    delGenericCommand(c, 1);
}

入口很简单,就是调用delGenericCommand,第二个参数为1表示需要异步删除。

/* This command implements DEL and LAZYDEL. */
void delGenericCommand(client *c, int lazy) {
    int numdel = 0, j;

    for (j = 1; j < c->argc; j++) {
        expireIfNeeded(c->db,c->argv[j]);
        int deleted  = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
                              dbSyncDelete(c->db,c->argv[j]);
        if (deleted) {
            signalModifiedKey(c->db,c->argv[j]);
            notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,
                "del",c->argv[j],c->db->id);
            server.dirty++;
            numdel++;
        }
    }
    addReplyLongLong(c,numdel);
}

delGenericCommand函数根据lazy参数来决定是同步删除还是异步删除,同步删除的逻辑没有什么变化就不细讲了,我们重点看下新增的异步删除的实现。

#define LAZYFREE_THRESHOLD 64
// 首先定义了启用后台删除的阈值,对象中的元素大于该阈值时才真正丢给后台线程去删除,如果对象中包含的元素太少就没有必要丢给后台线程,因为线程同步也要一定的消耗。
int dbAsyncDelete(redisDb *db, robj *key) {
    if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
    //清除待删除key的过期时间

    dictEntry *de = dictUnlink(db->dict,key->ptr);
    //dictUnlink返回数据库字典中包含key的条目指针,并从数据库字典中摘除该条目(并不会释放资源)
    if (de) {
        robj *val = dictGetVal(de);
        size_t free_effort = lazyfreeGetFreeEffort(val);
        //lazyfreeGetFreeEffort来获取val对象所包含的元素个数

        if (free_effort > LAZYFREE_THRESHOLD) {
            atomicIncr(lazyfree_objects,1);
            //原子操作给lazyfree_objects加1,以备info命令查看有多少对象待后台线程删除
            bioCreateBackgroundJob(BIO_LAZY_FREE ,val,NULL,NULL);
            //此时真正把对象val丢到后台线程的任务队列中
            dictSetVal(db->dict,de,NULL);
            //把条目里的val指针设置为NULL,防止删除数据库字典条目时重复删除val对象
        }
    }

    if (de) {
        dictFreeUnlinkedEntry(db->dict,de);
        //删除数据库字典条目,释放资源
        return 1;
    } else {
        return 0;
    }
}

以上便是异步删除的逻辑,首先会清除过期时间,然后调用dictUnlink把要删除的对象从数据库字典摘除,再判断下对象的大小(太小就没必要后台删除),如果足够大就丢给后台线程,最后清理下数据库字典的条目信息。

由以上的逻辑可以看出,当unlink一个体积较大的键时,实际的删除是交给后台线程完成的,所以并不会阻塞redis。

2. FLUSHALL、FLUSHDB命令

4.0给flush类命令新加了option——async,当flush类命令后面跟上async选项时,就会进入后台删除逻辑,代码如下:

/* FLUSHDB [ASYNC]
 *
 * Flushes the currently SELECTed Redis DB. */
void flushdbCommand(client *c) {
    int flags;

    if (getFlushCommandFlags(c,&flags) == C_ERR) return;
    signalFlushedDb(c->db->id);
    server.dirty += emptyDb(c->db->id,flags,NULL);
    addReply(c,shared.ok);

    sds client = catClientInfoString(sdsempty(),c);
    serverLog(LL_NOTICE, "flushdb called by client %s", client);
    sdsfree(client);
}

/* FLUSHALL [ASYNC]
 *
 * Flushes the whole server data set. */
void flushallCommand(client *c) {
    int flags;

    if (getFlushCommandFlags(c,&flags) == C_ERR) return;
    signalFlushedDb(-1);
    server.dirty += emptyDb(-1,flags,NULL);
    addReply(c,shared.ok);
    ...
}

flushdb和flushall逻辑基本一致,都是先调用getFlushCommandFlags来获取flags(其用来标识是否采用异步删除),然后调用emptyDb来清空数据库,第一个参数为-1时说明要清空所有数据库。

long long emptyDb(int dbnum, int flags, void(callback)(void*)) {
    int j, async = (flags & EMPTYDB_ASYNC);
    long long removed = 0;

    if (dbnum < -1 || dbnum >= server.dbnum) {
        errno = EINVAL;
        return -1;
    }

    for (j = 0; j < server.dbnum; j++) {
        if (dbnum != -1 && dbnum != j) continue;
        removed += dictSize(server.db[j].dict);
        if (async) {
            emptyDbAsync(&server.db[j]);
        } else {
            dictEmpty(server.db[j].dict,callback);
            dictEmpty(server.db[j].expires,callback);
        }
    }
    return removed;
}

进入emptyDb后首先是一些校验步骤,校验通过后开始执行清空数据库,同步删除就是调用dictEmpty循环遍历数据库的所有对象并删除(这时就容易阻塞redis),今天的核心在异步删除emptyDbAsync函数。

/* Empty a Redis DB asynchronously. What the function does actually is to
 * create a new empty set of hash tables and scheduling the old ones for
 * lazy freeing. */
void emptyDbAsync(redisDb *db) {
    dict *oldht1 = db->dict, *oldht2 = db->expires;
    db->dict = dictCreate(&dbDictType,NULL);
    db->expires = dictCreate(&keyptrDictType,NULL);
    atomicIncr(lazyfree_objects,dictSize(oldht1));
    bioCreateBackgroundJob(BIO_LAZY_FREE,NULL,oldht1,oldht2);
}

这里直接把db->dict和db->expires指向了新创建的两个空字典,然后把原来两个字典丢到后台线程的任务队列就好了,简单高效,再也不怕阻塞redis了。

lazyfree线程

接下来介绍下真正干活的lazyfree线程。

首先要澄清一个误区,很多人提到redis时都会讲这是一个单线程的内存数据库,其实不然。虽然redis把处理网络收发和执行命令这些操作都放在了主工作线程,但是除此之外还有许多bio后台线程也在兢兢业业的工作着,比如用来处理关闭文件和刷盘这些比较重的IO操作,这次bio家族又加入了新的小伙伴——lazyfree线程。

void *bioProcessBackgroundJobs(void *arg) {
    ...
        if (type == BIO_LAZY_FREE) {
            /* What we free changes depending on what arguments are set:
             * arg1 -> free the object at pointer.
             * arg2 & arg3 -> free two dictionaries (a Redis DB).
             * only arg3 -> free the skiplist. */
            if (job->arg1)
                lazyfreeFreeObjectFromBioThread(job->arg1);
            else if (job->arg2 && job->arg3)
                lazyfreeFreeDatabaseFromBioThread(job->arg2, job->arg3);
            else if (job->arg3)
                lazyfreeFreeSlotsMapFromBioThread(job->arg3);
        }
    ...
}

redis给新加入的lazyfree线程起了个名字叫BIO_LAZY_FREE,后台线程根据type判断出自己是lazyfree线程,然后再根据bio_job里的参数情况去执行相对应的函数。

  1. 后台删除对象,调用decrRefCount来减少对象的引用计数,引用计数为0时会真正的释放资源。

     void lazyfreeFreeObjectFromBioThread(robj *o) {
         decrRefCount(o);
         atomicDecr(lazyfree_objects,1);
     }
    

    这里也要额外补充一下,自redis 4.0开始,redis存储的key-value对象的引用计数只有1或者shared两种状态,换句话说交给lazyfree线程处理的对象必然是1,这样也就避免了多线程竞争问题。

  2. 后台清空数据库字典,调用dictRelease循环遍历数据库字典删除所有对象。

     void lazyfreeFreeDatabaseFromBioThread(dict *ht1, dict *ht2) {
         size_t numkeys = dictSize(ht1);
         dictRelease(ht1);
         dictRelease(ht2);
         atomicDecr(lazyfree_objects,numkeys);
     }
    
  3. 后台删除key-slots映射表,原生redis如果运行在集群模式下会用,云redis使用的自研集群模式这一函数目前并不会调用。

     void lazyfreeFreeSlotsMapFromBioThread(rax *rt) {
     size_t len = rt->numele;
     raxFree(rt);
     atomicDecr(lazyfree_objects,len);
     }
    

过期与逐出

redis支持设置过期时间以及逐出,而由此引发的删除动作也可能会阻塞redis。

所以redis 4.0这次除了显示增加unlink、flushdb async、flushall async命令之外,还增加了4个后台删除配置项,分别为:

  • slave-lazy-flush:slave接收完RDB文件后清空数据选项
  • lazyfree-lazy-eviction:内存满逐出选项
  • lazyfree-lazy-expire:过期key删除选项
  • lazyfree-lazy-server-del:内部删除选项,比如rename oldkey newkey时,如果newkey存在需要删除newkey

以上4个选项默认为同步删除,可以通过config set [parameter] yes打开后台删除功能。

后台删除的功能无甚修改,只是在原先同步删除的地方根据以上4个配置项来选择是否调用dbAsyncDelete或者emptyDbAsync进行异步删除,具体代码可见:

  1. slave-lazy-flush

     void readSyncBulkPayload(aeEventLoop *el, int fd, void *privdata, int mask) {
         ...
         if (eof_reached) {
             ...
             emptyDb(
                 -1,
                 server.repl_slave_lazy_flush ? EMPTYDB_ASYNC : EMPTYDB_NO_FLAGS,
                 replicationEmptyDbCallback);
             ...
         }
         ...
     }
    
  2. lazyfree-lazy-eviction

     int freeMemoryIfNeeded(long long timelimit) {
         ...
                 /* Finally remove the selected key. */
                 if (bestkey) {
                     ...
                     propagateExpire(db,keyobj,server.lazyfree_lazy_eviction);
                     if (server.lazyfree_lazy_eviction)
                         dbAsyncDelete(db,keyobj);
                     else
                         dbSyncDelete(db,keyobj);
                     ...
                 }
         ...
     }         
    
  3. lazyfree-lazy-expire

     int activeExpireCycleTryExpire(redisDb *db, struct dictEntry *de, long long now) {
         ...
         if (now > t) {
             ...
             propagateExpire(db,keyobj,server.lazyfree_lazy_expire);
             if (server.lazyfree_lazy_expire)
                 dbAsyncDelete(db,keyobj);
             else
                 dbSyncDelete(db,keyobj);
             ...
         }
         ...
     }
    
  4. lazyfree-lazy-server-del

     int dbDelete(redisDb *db, robj *key) {
         return server.lazyfree_lazy_server_del ? dbAsyncDelete(db,key) :
                                                  dbSyncDelete(db,key);
     }
    

此外云redis对过期和逐出做了一点微小的改进。

expire及evict优化

redis在空闲时会进入activeExpireCycle循环删除过期key,每次循环都会率先计算一个执行时间,在循环中并不会遍历整个数据库,而是随机挑选一部分key查看是否到期,所以有时时间不会被耗尽(采取异步删除时更会加快清理过期key),剩余的时间就可以交给freeMemoryIfNeeded来执行。

void activeExpireCycle(int type) {
    ...
afterexpire:
    if (!g_redis_c_timelimit_exit &&
        server.maxmemory > 0 &&
        zmalloc_used_memory() > server.maxmemory)
    {
        long long time_canbe_used = timelimit - (ustime() - start);
        if (time_canbe_used > 0) freeMemoryIfNeeded(time_canbe_used);
    }
}


Database · 理论基础 · 数据库事务隔离发展历史

$
0
0

事务隔离是数据库系统设计中根本的组成部分,本文主要从标准层面来讨论隔离级别的发展历史,首先明确隔离级别划分的目标;之后概述其否定之否定的发展历程;进而引出 Adya给出的比较合理的隔离级别定义,最终总结隔离标准一路走来的思路。

目标

事务隔离是事务并发产生的直接需求,最直观的、保证正确性的隔离方式,显然是让并发的事务依次执行,或是看起来像是依次执行。但在真实的场景中,有时并不需要如此高的正确性保证,因此希望牺牲一些正确性来提高整体性能。通过区别不同强度的隔离级别使得使用者可以在正确性和性能上自由权衡。随着数据库产品数量以及使用场景的膨胀,带来了各种隔离级别选择的混乱,数据库的众多设计者和使用者亟需一个对隔离级别划分的共识,这就是标准出现的意义。一个好的隔离级别定义有如下两个重要的目标

  • 正确:每个级别的定义,应该能够将所有损害该级别想要保证的正确性的情况排除在外。也就是说,只要实现满足某一隔离级别定义,就一定能获得对应的正确性保证。
  • 实现无关:常见的并发控制的实现方式包括,锁、OCC以及多版本 。而一个好的标准不应该限制其实现方式。

ANSI SQL标准(1992):基于异象

1992年ANSI首先尝试指定统一的隔离级别标准,其定义了不同级别的异象(phenomenas), 并依据能避免多少异象来划分隔离标准。异象包括:

  • 脏读(Dirty Read): 读到了其他事务还未提交的数据;
  • 不可重复读(Non-Repeatable/Fuzzy Read):由于其他事务的修改或删除,对某数据的两次读取结果不同;
  • 幻读(Phantom Read):由于其他事务的修改,增加或删除,导致Range的结果失效(如where 条件查询)。

通过阻止不同的异象发生,得到了四种不同级别的隔离标准:

ANSI Define ANSI SQL标准看起来是非常直观的划分方式,不想要什么就排除什么,并且做到了实现无关。然而,现实并不像想象美好。因为它并不正确

A Critique of ANSI(1995):基于锁

几年后,微软的研究员们在A Critique of ANSI SQL Isolation Levels一文中对ANSI的标准进行了批判,指出其存在两个致命的问题:

1,不完整,缺少对Dirty Write的排除

ANSI SQL标准中所有的隔离级别都没有将Dirty Write这种异象排除在外,所谓Dirty Write指的是两个未提交的事务先后对同一个对象进行了修改。而Dirty Write之所以是一种异象,主要因为他会导致下面的一致性问题:

H0: w1[x] w2[x] w2[y] c2 w1[y] c1

这段历史中,假设有相关性约束x=y,T1尝试将二者都修改为1,T2尝试将二者都修改为2,顺序执行的结果应该是二者都为1或者都为2,但由于Dirty Write的发生,最终结果变为x=2,y=1,不一致。

2,歧义

ANSI SQL的英文表述有歧义。以Phantom为例,如下图历史H3:

H3:r1[P] w2[insert y to P] r2[z] w2[z] c2 r1[z] c1

假设T1根据条件P查询所有的雇员列表,之后T2增加了一个雇员并增加了雇员人数值z,之后T1读取雇员人数z,最终T1的列表中的人数比z少,不一致。但T1并没有在T2修改链表后再使用P中的值,是否就不属于ANSI中对Phantom的定义了呢?这也导致了对ANSI的表述可能有严格和宽松两种解读。对于Read Dirty和Non-Repeatable/Fuzzy Read也有同样的问题。

那么,如何解决上述两个问题呢?Critique of ANSI的答案是:宁可错杀三千,不可放过一个,即给ANSI标准中的异象最严格的定义。Critique of ANSI改造了异象的定义:

P0: w1[x]…w2[x]…(c1 or a1) (Dirty Write)

P1: w1[x]…r2[x]…(c1 or a1) (Dirty Read)

P2: r1[x]…w2[x]…(c1 or a1) (Fuzzy or Non-Repeatable Read)

P3: r1[P]…w2[y in P]…(c1 or a1) (Phantom)

此时定义已经很严格了,直接阻止了对应的读写组合顺序。仔细可以看出,此时得到的其实就是基于锁的定义:

  • Read Uncommitted,阻止P0:整个事务阶段对x加长写锁
  • Read Commited,阻止P0,P1:短读锁 + 长写锁
  • Repeatable Read,阻止P0,P1,P2:长读锁 + 短谓词锁 + 长写锁
  • Serializable,阻止P0,P1,P2,P3:长读锁 + 长谓词锁 + 长写锁

问题本质

可以看出,这种方式的隔离性定义保证了正确性,但却产生了依赖实现方式的问题:太过严格的隔离性定义,阻止了Optimize或Multi-version的实现方式中的一些正常的情况

  • 针对P0:Optimize的实现方式可能会让多个事务各自写自己的本地副本,提交的时候只要顺序合适是可以成功的,只在需要的时候才abort,但这种选择被P0阻止;
  • 针对P2:只要T1没有在读x,后续没有与x相关的操作,且先于T2提交。在Optimize的实现中是可以接受的,却被P2阻止。

回忆Critique of ANSI中指出的ANSI标准问题,包括Dirty Write和歧义,其实都是由于多Object之间有相互约束关系导致的,如下图所示,图中黑色部分表示的是ANSI中针对某一个异象描述的异常情况,灰色部分由于多Object约束导致的异常部分,但这部分在传统的异象定义方式中并不能描述,因此其只能退而求其次,扩大限制的范围到黄色部分,从而限制了正常的情况。:

Isolation Cover

由此,可以看出问题的本质由于异象的描述只针对单个object,缺少描述多object之间的约束关系,导致需要用锁的方式来作出超出必须的限制。相应地,解决问题的关键:要有新的定义异象的模型,使之能精准的描述多object之间的约束关系,从而使得我们能够精准地限制上述灰色部分,而将黄色的部分解放出来。Adya给出的答案是序列化图。

A Generalized Theory(1999):基于序列化图

Adya在Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions中给出了基于序列化图得定义,思路为先定义冲突关系;并以冲突关系为有向边形成序列化图;再以图中的环类型定义不同的异象;最后通过阻止不同的异象来定义隔离级别。

序列化图(Direct Serialization Graph, DSG)

序列化图是用有向图的方式来表示事务相互之间的依赖关系,图中每个节点表示一个事务,有向边表示存在一种依赖关系,事务需要等到所有指向其的事务先行提交,如下图所示历史的合法的提交顺序应该为:T1,T2,T3:

GSG

这里的有向边包括三种情况:

  • 写写冲突ww(Directly Write-Depends):表示两个事务先后修改同一个数据库Object(w1[x]…w2[x]…);
  • 先写后读冲突wr(Directly Read-Depends):一个事务修改某个数据库Object后,另一个对该Object进行读操作(w1[x]…r2[x]…);
  • 先读后写冲突rw(Directly Anti-Depends):一个事务读取某个Object或者某个Range后,另一个事务进行了修改(r1[x]…w2[x]… or r1[P]…w2[y in P]);

GSG Edge

基于序列化图的异象定义:

根据有向图的定义,我们可以将事务对不同Object的依赖关系表示到一张同一张图中,而所谓异象就是在图中找不到一个正确的序列化顺序,即存在某种环。而这种基于环的定义其实就是将基于Lock定义的异象最小化到图中灰色部分:

1,P0(Dirty Write) 最小化为 G0(Write Cycles):序列化图中包含两条边都为ww冲突组成的环,如H0:

H0: w1[x] w2[x] w2[y] c2 w1[y] c1

可以看出T1在x上与T2写写冲突,T2又在y上与T1写写冲突,形成了如下图所示的环。

Write Cycle

2,P1(Dirty Read) 最小化为 G1:Dirty Read异象的最小集包括三个部分G1a(Aborted Reads),读到的uncommitted数据最终被abort;G1b(Intermediate Reads) :读到其他事务中间版本的数据;以及G1c(Circular Information Flow):DSG中包含ww冲突和wr冲突形成的环。

3,P2(Fuzzy or Non-Repeatable Read) 最小化为 G2-item(Item Anti-dependency Cycles):DSG中包含环,且其中至少有一条关于某个object的rw冲突

4,P3(Phantom) 最小化为 G2(Anti-dependency Cycles): DSG中包含环,并且其中至少有一条是rw冲突,仍然以上面的H3为例:

H3:r1[P] w2[insert y to P] r2[z] w2[z] c2 r1[z] c1

T1在谓词P上与T2 rw冲突,反过来T2又在z上与T1wr冲突,如下图所示:

Anti-dependency Cycles

对应的隔离级别:

通过上面的讨论可以看出,通过环的方式我们成功最小化了异象的限制范围,那么排除这些异象就得到了更宽松的,通用的隔离级别定义:

  • PL-1(Read Uncommitted):阻止G0
  • PL-2(Read Commited):阻止G1
  • PL-2.99(Repeatable Read):阻止G1,G2-item
  • PL-3(Serializable):阻止G1,G2

其他隔离级别:

除了上述的隔离级别外,在正确性的频谱中还有着大量空白,也就存在着各种其他隔离级别的空间,商业数据库的实现中有两个比较常见:

1,Cursor Stability

该隔离界别介于Read Committed和Repeatable Read之间,通过对游标加锁而不是对object加读锁的方式避免了Lost Write异象。

2, Snapshot Ioslation

事务开始的时候拿一个Start-Timestamp的snapshot,所有的操作都在这个snapshot上做,当commit的时候拿Commit-Timestamp,检查所有有冲突的值不能再[Start- Timestamp, Commit-Timestamp]被提交,否则abort。长久以来,Snapshot Ioslation一直被认为是Serializable,但其实Snapshot Ioslation下还会出现Write Skew的异象。之后的文章会详细介绍如何从Snapshot Ioslation出发获得Serializable。

总结

对于事务隔离级别的标准,数据库的前辈们进行了长久的探索:

  • ANSI isolation levels定义了异象标准,并根据所排除的异象,定义了,Read Uncommitted、Read Committed、Repeatable Read、Serializable四个隔离级别;
  • A Critique of ANSI SQL Isolation Levels认为ANSI的定义并没将有多object约束的异象排除在外,并选择用更严格的基于Lock的定义扩大了每个级别限制的范围;
  • Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions认为基于Lock的定义过多的扩大了限制的范围,导致正常情况被排除在外,从而限制了Optimize类型并行控制的使用;指出解决该问题的关键是要有模型能准确地描述这种多Object约束;并给出了基于序列化图的定义方式,将每个级别限制的范围最小化。

参考

A History of Transaction Histories

ANSI isolation levels

A Critique of ANSI SQL Isolation Levels

Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions

Generalized Isolation Level Definitions

Database · 理论基础 · 关于一致性协议和分布式锁

$
0
0

关于一致性协议, 分布式锁以及如何使用分布式锁

最近看antirez 和 Martin 关于redlock 的分布式锁是否安全的问题的争吵, 非常有意思

http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

http://antirez.com/news/101

https://news.ycombinator.com/item?id=11065933

https://news.ycombinator.com/item?id=11059738

背景

关于分布式锁其实这里面是包含3个问题, 每一个都是独立的不相关的问题

  1. 一致性协议的问题 consensus
  2. 如何通过一致性协议实现分布式锁的问题 lock
  3. 如何结合业务场景使用分布式锁 usage

所以我们一般称这三个问题为”ULC”

问题1和问题2 容易混淆, 其实实现一个分布式锁只是需要一个key-value store 就可以了, 但是因为需要这个key-value store 高可用, 那么就必然需要这个key-value store 多副本, 多副本又需要强一致, 那么就必然引入一致性协议了

chubby 的论文里面讲的就是如何基于一致性协议paxos 实现一个分布式锁服务, 跟一致性协议一点关系都没有. 里面很重要的一个观点是为什么要实现一个锁服务, 用户使用一个锁服务相比于直接提供一个一致性协议的库有哪些地方更方便

  1. lock 提供的高可用性肯定比直接提供一致性协议的库要来的低, 相当于通过lock 实现强一致和直接通过一致性协议提供强一致的业务逻辑
  2. 有些场景很难改成通过一致性协议的场景
  3. 分布式锁的使用方式和之前单机是使用Lock 的方式一样的直观, 对使用人员的要求无成本
  4. 有时候用户只有少量的机器, 比如只有两个机器, 就无法通过一致性协议提供强一致, 但是通过外部的锁服务可以

在分布式锁里面有两个要求

  1. safety

    任意时刻只能有一个 node 去获得这个lock

  2. liveness

    这个lock 是在某一个时刻是会终止的

其实这两点是互相矛盾的, 存在这个问题的本质原因是因为在分布式系统里面我们无法判断一个节点真的挂掉还是只是网络分区一会这个网络又会恢复

所有的distribute lock 的实现都存在的一个问题是, 在获得这个锁以后, 如果拿了这个锁的节点死掉了, 或者网络永久断开了, 那么这个锁也就死锁了. 就违背了 liveness 的问题, 为了解决这个问题, 几乎所有的distribute lock 都会加上一个时间的限制, 但是这个时间的限制又会有一个问题就是如果获得这个锁的节点, 在拿到锁以后, 执行的操作的时候超过了这个时间的限制, 那么我们改怎么办? 那么这个时候就有可能被其他的节点也获得这个锁, 那么就违背了 safety 的限制.

因此在我们操作系统的lock 里面选择的是死锁, 所以操作系统有liveness 的问题, 而大部分的distribute lock 的实现选择的是给这个lock 加上lease, 如果超过了这个lease, 依然返回, 我认为你这个lock 已经失效了, 可以把这个lock 给其他的节点. 因此在使用distribute lock 的时候需要注意的是尽可能在锁区间的操作应该是可预期的, 尽可能时间短的.

观点

主要喷antirez 有问题的地方在于两个:

  1. 这种auto release lock 会存在的问题是, 用户获得lock 操作以后, redlock 的做法有一个lease, 如果在这个lease 里面不执行unlock 操作, 系统只能认为你已经挂掉. 那么在过了lease 时间以后, 另外一个node 获得了这个Lock, 那么有可能第一个节点并没有挂掉(这里是java gc 黑的最惨的地方哈哈哈), 那么这个时候系统就无法保证只有一个leader, 这个lock 也就没用了
  2. 第二个就比较直接了, 就是通过时间戳来保证底下的强一致. 这个是被喷的最惨的, 这个就没什么好解释的了

那么Martin 提供的带递增Token 的方法是不是解决了这个问题呢?

Imgur

其实我觉得Martin 说的带fencing Token 的方法是通过拿到锁的系统必须能够保证提供一个支持cas 操作检查的系统才行, 能够检查写入的Token 是否比之前的token 都大, zk 使用的也是类似的方法. 但是不解决刚才我们说的safety 的问题, 因为在使用带Token 的方法里面也是无法保证某一个时刻只能有一个节点获得这个lock, 只是通过额外的一个系统里面写入的时候检查这个Token 来避免这个问题. 所以从整体上来看是解决了这个问题, 但是其实是需要使用lock 的服务提供 cas 操作才行.

所以我认为从整体上看, 除非你底下获得Lock 操作以后, 需要做的事情非常简单, 那么可以通过fencing token 来做保证, 但是更多的工程里面获得lock 以后的操作比较复杂, 所以很难想Martin 说的那样能够实现这个cas 操作. 所以floyd 提供的lock 基本就是基于一致性协议raft + lease 实现的auto release lock

结论

回头开头的3个问题, 如果实现一致性协议就不说了.

如何实现分布式锁呢?

那么大体的实现就是给在节点lock 的时候, 对于这个lock 操作有一个lease的时候, 如果在这个租约的时间内, 这个节点没有来续租, 那么就认为这个操作是超时的, 一般情况为了实现的可靠性保证, 会在这个租约失效前就提前续租, 比如租约的时间是 10s, 我在0s 的时候就获得这个lock, 那么我在6s 的时候就必须去做这个续租操作, 如果没有执行成功的话, 那么我就认为你这个lease 失效了, 其他的节点可以在6s 时刻就获得这个lock, 但是只能在10s以后提供服务, 新的节点的租约时间是10s~20s. 那么从6s~10s 这段时间即使新节点获得了lock, 但是也无法提供服务的, 这个是典型的CAP 场景里面系统availability 换取consistenty 的例子.

那么如何使用分布式锁呢?

在pika_hub 的场景里面, 有一个专门的线程每3s去获得这个lock, 在获得这个lock 以后, 就认为自己是 pika_hub 的leader, 然后建立与所有的pika 节点的连接. 如果在某一个时刻其他的pika_hub 节点抢到了这个lock, 那么就说明之前的pika_hub 节点已经挂掉, 或者超时. 那么如何Pika_hub 节点在6s的时刻发现自己获得这个lock 失败, 那么该如何操作呢? 这个时刻 pika_hub 将与 pika 建立连接的线程都杀死, 这个时候其实有6s~10s 这一段4s 的时间, 我们认为在工程实现里面4s 可以完成这个操作. 那么其他节点就算在6s的时候执行Lock() 操作依然是获得不了这个lock(), 因为这个lock() 虽然没有被更新lease, 但是lease 依然在有效期内的. 那么等到10s 以后才有一个新的节点抢到这个lock(). 这个时候新的节点成为leader 与其他的Pika 节点建立连接, 所以系统中可能存在最多13s的没有leader 的时间

在zeppelin 里面, 也一样有专门的线程去获得这个lock, 在获得这个lock 以后, 将自己是leader 的信息写入到floyd 里面, 然后做leader 该做的事情. 和pika_hub 的处理方式一样, 如果发现无法和floyd 交互了, 那么就把自己改成follower 的信息. 不一样的地方在于因为这里使用floyd 作为storage, 那么其实可以通过类似Martin 提供的方式进行类似cas 的操作来更新. 这里也可以看出就像 antirez 喷 Martin 一样, 并不是所有的获得锁以后的操作都可以改成cas 的操作, 比如pika_hub 就不可以

最后, 看到最后的肯定是真爱, 这个项目: floyd

MySQL · RocksDB · Level Compact 分析

$
0
0

综述

在RocksDB中,将MemTable刷新到磁盘之后,将会有很多sstable,而这些sstable则是可能包含了相同的key的不同时间的值,这样子就会导致两个问题:

  1. 浪费磁盘空间
  2. 读取内容将会非常慢.

而compact就是用来解决上面两个问题的,简单来说compact就是读取几个sstable然后合并为一个(或者多个)sstable. 而什么时候合并,合并的时候如何来挑选sstable,这个就是compcation strategy.一般来说compact strategy的目的都是为了更低的amplification:

  • 避免一次读请求读取太多的sstables.
    • 读放大
  • 避免一些临时数据(deleted/overwritten/expired)在磁盘上停留时间过长
  • 避免磁盘上临时空间过大
    • 空间放大
  • 避免compact相同的数据太多次
    • 写放大

而在RockDB中实现了多种compact strategy,不同的strategy有不同的侧重,这里我们只分析默认的strategy, 那就是leveled-N compaction.

在Leveled compaction中,所有的SSTables被分为很多levels(level0/1/2/3…).

  • 最新的SSTable(从memtable中刷新下来的)是属于Level0
    • 每一个SSTable都是有序的
    • 只有Level0的SSTable允许overlap
  • 除了level0之外其他的level的总的SSTable大小有一个最大的限制
    • 通过level_compaction_dynamic_level_bytes来计算
  • 在Level0,如果积攒够了足够的(level0_file_num_compaction_trigger)SSTable,则就会进行compact.
    • 一般来说会把全部的SSTables compact到下一个level(Level1).
    • 不会写一个很大的SSTable,
  • 一般来说百分之90的空间都是给最后一级level的.

源码

Compact运行的条件

先来看在RocksDB中是什么时候会引起compact.在RocksDB中所有的compact都是在后台线程中进行的,这个线程就是BGWorkCompaction.这个线程只有在两种情况下被调用,一个是 手动compact(RunManualCompaction),一个就是自动(MaybeScheduleFlushOrCompaction),我们主要来看自动的compact,而MaybeScheduleFlushOrCompaction这个函数我们在之前介绍flush的时候已经介绍过了,简单来说就是会在切换WAL(SwitchWAL)或者writebuffer满的时候(HandleWriteBufferFull)被调用.

我们来看在MaybeScheduleFlushOrCompaction中compact的调用.这里可以看到RocksDB中后台运行的compact会有一个限制(max_compactions).而我们可以看到这里还有一个变量 unscheduled_compactions_,这个变量表示需要被compact的columnfamily的队列长度.

  while (bg_compaction_scheduled_ < bg_job_limits.max_compactions &&
         unscheduled_compactions_ > 0) {
    CompactionArg* ca = new CompactionArg;
    ca->db = this;
    ca->prepicked_compaction = nullptr;
    bg_compaction_scheduled_++;
    unscheduled_compactions_--;
    env_->Schedule(&DBImpl::BGWorkCompaction, ca, Env::Priority::LOW, this,
                   &DBImpl::UnscheduleCallback);
  }

类似flush的逻辑,compact的时候RocksDB也有一个队列叫做DBImpl::compaction_queue_.

  std::deque<ColumnFamilyData*> compaction_queue_;

然后我们来看这个队列何时被更新,其中unscheduled_compactions_和队列的更新是同步的,因此只有compaction_queue_更新之后,调用compact后台线程才会进入compact处理.

void DBImpl::SchedulePendingCompaction(ColumnFamilyData* cfd) {
  if (!cfd->queued_for_compaction() && cfd->NeedsCompaction()) {
    AddToCompactionQueue(cfd);
    ++unscheduled_compactions_;
  }
}

上面的核心函数是NeedsCompaction,通过这个函数来判断是否有sst需要被compact,因此接下来我们就来详细分析这个函数.当满足下列几个条件之一就将会更新compact队列

  • 有超时的sst(ExpiredTtlFiles)
  • files_marked_for_compaction_或者bottommost_files_marked_for_compaction_都不为空
    • 后面会介绍这两个队列
  • 遍历所有的level的sst,然后判断是否需要compact
    • 最核心的条件(上面两个队列都是在这里更新的).
bool LevelCompactionPicker::NeedsCompaction(
    const VersionStorageInfo* vstorage) const {
  if (!vstorage->ExpiredTtlFiles().empty()) {
    return true;
  }
  if (!vstorage->BottommostFilesMarkedForCompaction().empty()) {
    return true;
  }
  if (!vstorage->FilesMarkedForCompaction().empty()) {
    return true;
  }
  for (int i = 0; i <= vstorage->MaxInputLevel(); i++) {
    if (vstorage->CompactionScore(i) >= 1) {
      return true;
    }
  }
  return false;
}

因此接下来我们来分析最核心的CompactionScore,这里将会涉及到两个变量,这两个变量分别保存了level以及每个level所对应的score(这里score越高表示compact优先级越高),而score小于1则表示不需要compact.

  std::vector<double> compaction_score_;
  std::vector<int> compaction_level_;

这两个vector是在VersionStorageInfo::ComputeCompactionScore中被更新,因此我们来看这个函数,这个函数中会对level-0和其他的level区别处理。 首先来看level-0的处理:

  1. 首先会计算level-0下所有文件的大小(total_size)以及文件个数(num_sorted_runs).
  2. 用文件个数除以level0_file_num_compaction_trigger来得到对应的score
  3. 如果当前不止一层level,那么将会从上面的score和(total_size/max_bytes_for_level_base)取最大值.

之所以要做第三步,主要还是为了防止level-0的文件size过大,那么当它需要compact的时候有可能会需要和level-1 compact,那么此时就有可能会有一个很大的compact.

if (level == 0) {
      int num_sorted_runs = 0;
      uint64_t total_size = 0;
      for (auto* f : files_[level]) {
        if (!f->being_compacted) {
          total_size += f->compensated_file_size;
          num_sorted_runs++;
        }
      }
.........................
      score = static_cast<double>(num_sorted_runs) /
                mutable_cf_options.level0_file_num_compaction_trigger;
        if (compaction_style_ == kCompactionStyleLevel && num_levels() > 1) {
          score = std::max(
              score, static_cast<double>(total_size) /
                     mutable_cf_options.max_bytes_for_level_base);
        }
      }

然后是非level-0的处理,这里也是计算level的文件大小然后再除以MaxBytesForLevel,然后得到当前level的score.

      uint64_t level_bytes_no_compacting = 0;
      for (auto f : files_[level]) {
        if (!f->being_compacted) {
          level_bytes_no_compacting += f->compensated_file_size;
        }
      }
      score = static_cast<double>(level_bytes_no_compacting) /
              MaxBytesForLevel(level);

上面我们看到有一个MaxBytesForLevel,这个函数的作用就是得到当前level的最大的文件大小.而这个函数实现也很简单.

uint64_t VersionStorageInfo::MaxBytesForLevel(int level) const {
  // Note: the result for level zero is not really used since we set
  // the level-0 compaction threshold based on number of files.
  assert(level >= 0);
  assert(level < static_cast<int>(level_max_bytes_.size()));
  return level_max_bytes_[level];
}

可以看到核心就是level_max_bytes_这个数组,接下来我们就来看这个数组是在哪里被初始化的。level_max_bytes这个数组是在VersionStorageInfo::CalculateBaseBytes 这个函数中被初始化,这里RocksDB有一个option叫做level_compaction_dynamic_level_bytes,这个配置如果被设置,那么level_max_bytes将会这样 设置(这里我们只关注level):

  • 如果是level-1那么level-1的的文件大小限制为options.max_bytes_for_level_base.
  • 如果level大于1那么当前level-i的大小限制为(其中max_bytes这两个变量都是options中设置的)
    Target_Size(Ln+1) = Target_Size(Ln) * max_bytes_for_level_multiplier * max_bytes_for_level_multiplier_additional[n].
    

    举个例子,如果max_bytes_for_level_base=1024,max_bytes_for_level_multiplier=10,然后max_bytes_for_level_multiplier_additional未设置,那么L1, L2,L3的大小限制分别为1024,10240,102400.

下面是对应代码.

  if (!ioptions.level_compaction_dynamic_level_bytes) {
    base_level_ = (ioptions.compaction_style == kCompactionStyleLevel) ? 1 : -1;

    // Calculate for static bytes base case
    for (int i = 0; i < ioptions.num_levels; ++i) {
      if (i == 0 && ioptions.compaction_style == kCompactionStyleUniversal) {
        level_max_bytes_[i] = options.max_bytes_for_level_base;
      } else if (i > 1) {
        level_max_bytes_[i] = MultiplyCheckOverflow(
            MultiplyCheckOverflow(level_max_bytes_[i - 1],
                                  options.max_bytes_for_level_multiplier),
            options.MaxBytesMultiplerAdditional(i - 1));
      } else {
        level_max_bytes_[i] = options.max_bytes_for_level_base;
      }
    }
  }

然后我们来看如果设置了level_compaction_dynamic_level_bytes会如何来计算.如果设置了dynamic,那么就说明每次计算出来的每个level的最大值都是不一样的, 首先我们要知道调用CalculateBaseBytes是在每次创建version的时候。因此他是这样计算的.最大的level(num_levels -1 )的大小限制是不计入计算的,然后就是这样计算.

Target_Size(Ln-1) = Target_Size(Ln) / max_bytes_for_level_multiplier

举个例子,假设调用CalculateBaseBytes的时候,max_bytes_for_level_base是1G,然后num_levels = 6,然后当前最大的level的大小为256G,那么从L1-L6的大小是 0, 0, 0.276GB, 2.76GB, 27.6GB 和 276GB.

首先计算第一个非空的level.

    for (int i = 1; i < num_levels_; i++) {
      uint64_t total_size = 0;
      for (const auto& f : files_[i]) {
        total_size += f->fd.GetFileSize();
      }
      if (total_size > 0 && first_non_empty_level == -1) {
        first_non_empty_level = i;
      }
      if (total_size > max_level_size) {
        max_level_size = total_size;
      }
    }

得到最小的那个非0的level的size.

      uint64_t base_bytes_max = options.max_bytes_for_level_base;
      uint64_t base_bytes_min = static_cast<uint64_t>(
          base_bytes_max / options.max_bytes_for_level_multiplier);

      // Try whether we can make last level's target size to be max_level_size
      uint64_t cur_level_size = max_level_size;
      for (int i = num_levels_ - 2; i >= first_non_empty_level; i--) {
        // Round up after dividing
        cur_level_size = static_cast<uint64_t>(
            cur_level_size / options.max_bytes_for_level_multiplier);
      }

找到base_level_size,一般来说也就是cur_level_size.

        // Find base level (where L0 data is compacted to).
        base_level_ = first_non_empty_level;
        while (base_level_ > 1 && cur_level_size > base_bytes_max) {
          --base_level_;
          cur_level_size = static_cast<uint64_t>(
              cur_level_size / options.max_bytes_for_level_multiplier);
        }
        if (cur_level_size > base_bytes_max) {
          // Even L1 will be too large
          assert(base_level_ == 1);
          base_level_size = base_bytes_max;
        } else {
          base_level_size = cur_level_size;
        }

然后给level_max_bytes_ 赋值

      uint64_t level_size = base_level_size;
      for (int i = base_level_; i < num_levels_; i++) {
        if (i > base_level_) {
          level_size = MultiplyCheckOverflow(
              level_size, options.max_bytes_for_level_multiplier);
        }
        // Don't set any level below base_bytes_max. Otherwise, the LSM can
        // assume an hourglass shape where L1+ sizes are smaller than L0. This
        // causes compaction scoring, which depends on level sizes, to favor L1+
        // at the expense of L0, which may fill up and stall.
        level_max_bytes_[i] = std::max(level_size, base_bytes_max);
      }
    }

Compact实现细节

分析完毕何时会触发Compact,那么我们接下来来分析如何Compact.其中Compact的所有操作都在DBImpl::BackgroundCompaction中进行,因此接下来我们来分析 这个函数. 首先是从compaction_queue_队列中读取第一个需要compact的column family.

    // cfd is referenced here
    auto cfd = PopFirstFromCompactionQueue();
    // We unreference here because the following code will take a Ref() on
    // this cfd if it is going to use it (Compaction class holds a
    // reference).
    // This will all happen under a mutex so we don't have to be afraid of
    // somebody else deleting it.
    if (cfd->Unref()) {
      delete cfd;
      // This was the last reference of the column family, so no need to
      // compact.
      return Status::OK();
    }

然后就是选取当前CF中所需要compact的内容.

      c.reset(cfd->PickCompaction(*mutable_cf_options, log_buffer));

从上面可以看到PickCompaction这个函数,而这个函数会根据设置的不同的Compact策略调用不同的方法,这里我们只看默认的LevelCompact的对应函数.

Compaction* LevelCompactionBuilder::PickCompaction() {
  // Pick up the first file to start compaction. It may have been extended
  // to a clean cut.
  SetupInitialFiles();
  if (start_level_inputs_.empty()) {
    return nullptr;
  }
  assert(start_level_ >= 0 && output_level_ >= 0);

  // If it is a L0 -> base level compaction, we need to set up other L0
  // files if needed.
  if (!SetupOtherL0FilesIfNeeded()) {
    return nullptr;
  }

  // Pick files in the output level and expand more files in the start level
  // if needed.
  if (!SetupOtherInputsIfNeeded()) {
    return nullptr;
  }

  // Form a compaction object containing the files we picked.
  Compaction* c = GetCompaction();

  TEST_SYNC_POINT_CALLBACK("LevelCompactionPicker::PickCompaction:Return", c);

  return c;
}

这里PickCompaction分别调用了三个主要的函数.

  • SetupInitialFiles 这个函数主要用来初始化需要Compact的文件.
  • SetupOtherL0FilesIfNeeded 如果需要compact的话,那么还需要再设置对应的L0文件
  • SetupOtherInputsIfNeeded 选择对应的输出文件

先来看SetupInitialFiles,这个函数他会遍历所有的level,然后来选择对应需要compact的input和output.

这里可看到,他会从之前计算好的的compact信息中得到对应的score.

void LevelCompactionBuilder::SetupInitialFiles() {
  // Find the compactions by size on all levels.
  bool skipped_l0_to_base = false;
  for (int i = 0; i < compaction_picker_->NumberLevels() - 1; i++) {
    start_level_score_ = vstorage_->CompactionScore(i);
    start_level_ = vstorage_->CompactionScoreLevel(i);
    assert(i == 0 || start_level_score_ <= vstorage_->CompactionScore(i - 1));
................................................................
  }

只有当score大于一才有必要进行compact的处理(所有操作都在上面的循环中).这里可以看到如果是level0的话,那么output_level 则是vstorage_->base_level(),否则就是level+1. 这里base_level()可以认为就是level1或者是最小的非空的level(之前CalculateBaseBytes中计算).

if (start_level_score_ >= 1) {
      if (skipped_l0_to_base && start_level_ == vstorage_->base_level()) {
        // If L0->base_level compaction is pending, don't schedule further
        // compaction from base level. Otherwise L0->base_level compaction
        // may starve.
        continue;
      }
      output_level_ =
          (start_level_ == 0) ? vstorage_->base_level() : start_level_ + 1;
      if (PickFileToCompact()) {
        // found the compaction!
        if (start_level_ == 0) {
          // L0 score = `num L0 files` / `level0_file_num_compaction_trigger`
          compaction_reason_ = CompactionReason::kLevelL0FilesNum;
        } else {
          // L1+ score = `Level files size` / `MaxBytesForLevel`
          compaction_reason_ = CompactionReason::kLevelMaxLevelSize;
        }
        break;
      } else {
        // didn't find the compaction, clear the inputs
  ......................................................
        }
      }
    }

上面的代码中我们可以看到最终是通过PickFileToCompact来选择input以及output文件.因此我们接下来就来分这个函数.

首先是得到当前level(start_level_)的未compacted的最大大小的文件

  // Pick the largest file in this level that is not already
  // being compacted
  const std::vector<int>& file_size =
      vstorage_->FilesByCompactionPri(start_level_);
  const std::vector<FileMetaData*>& level_files =
      vstorage_->LevelFiles(start_level_);

紧接着就是这个函数最核心的功能了,它会开始遍历当前的输入level的所有待compact的文件,然后选择一些合适的文件然后compact到下一个level.

unsigned int cmp_idx;
  for (cmp_idx = vstorage_->NextCompactionIndex(start_level_);
       cmp_idx < file_size.size(); cmp_idx++) {
..........................................    
  }

然后我们来详细分析上面循环中所做的事情 首先选择好文件之后,将会扩展当前文件的key的范围,得到一个”clean cut”的范围, 这里”clean cut”是这个意思,假设我们有五个文件他们的key range分别为:

    f1[a1 a2] f2[a3 a4] f3[a4 a6] f4[a6 a7] f5[a8 a9]

如果我们第一次选择了f3,那么我们通过clean cut,则将还会选择f2,f4,因为他们都是连续的. 选择好之后,会再做一次判断,这次是判断是否正在compact的out_level的文件范围是否和我们选择好的文件的key有重合,如果有,则跳过这个文件. 这里之所以会有这个判断,主要原因还是因为compact是会并行的执行的.

int index = file_size[cmp_idx];
    auto* f = level_files[index];

    // do not pick a file to compact if it is being compacted
    // from n-1 level.
    if (f->being_compacted) {
      continue;
    }

    start_level_inputs_.files.push_back(f);
    start_level_inputs_.level = start_level_;
    if (!compaction_picker_->ExpandInputsToCleanCut(cf_name_, vstorage_,
                                                    &start_level_inputs_) ||
        compaction_picker_->FilesRangeOverlapWithCompaction(
            {start_level_inputs_}, output_level_)) {
      // A locked (pending compaction) input-level file was pulled in due to
      // user-key overlap.
      start_level_inputs_.clear();
      continue;
    }

选择好输入文件之后,接下来就是选择输出level中需要一起被compact的文件(output_level_inputs). 实现也是比较简单,就是从输出level的所有文件中找到是否有和上面选择好的input中有重合的文件,如果有,那么则需要一起进行compact.

    InternalKey smallest, largest;
    compaction_picker_->GetRange(start_level_inputs_, &smallest, &largest);
    CompactionInputFiles output_level_inputs;
    output_level_inputs.level = output_level_;
    vstorage_->GetOverlappingInputs(output_level_, &smallest, &largest,
                                    &output_level_inputs.files);
    if (!output_level_inputs.empty() &&
        !compaction_picker_->ExpandInputsToCleanCut(cf_name_, vstorage_,
                                                    &output_level_inputs)) {
      start_level_inputs_.clear();
      continue;
    }
    base_index_ = index;
    break;

继续分析PickCompaction,我们知道在RocksDB中level-0会比较特殊,那是因为只有level-0中的文件是无序的,而在上面的操作中, 我们是假设在非level-0,因此接下来我们需要处理level-0的情况,这个函数就是SetupOtherL0FilesIfNeeded.

这里如果start_level_为0,也就是level-0的话,才会进行下面的处理,就是从level-0中得到所有的重合key的文件,然后加入到start_level_inputs中.

  if (start_level_ == 0 && output_level_ != 0) {
    // Two level 0 compaction won't run at the same time, so don't need to worry
    // about files on level 0 being compacted.
    assert(compaction_picker_->level0_compactions_in_progress()->empty());
    InternalKey smallest, largest;
    compaction_picker_->GetRange(start_level_inputs_, &smallest, &largest);
    // Note that the next call will discard the file we placed in
    // c->inputs_[0] earlier and replace it with an overlapping set
    // which will include the picked file.
    start_level_inputs_.files.clear();
    vstorage_->GetOverlappingInputs(0, &smallest, &largest,
                                    &start_level_inputs_.files);

    // If we include more L0 files in the same compaction run it can
    // cause the 'smallest' and 'largest' key to get extended to a
    // larger range. So, re-invoke GetRange to get the new key range
    compaction_picker_->GetRange(start_level_inputs_, &smallest, &largest);
    if (compaction_picker_->IsRangeInCompaction(
            vstorage_, &smallest, &largest, output_level_, &parent_index_)) {
      return false;
    }
  }

假设start_level_inputs被扩展了,那么对应的output也需要被扩展,因为非level0的其他的level的文件key都是不会overlap的. 那么此时就是会调用SetupOtherInputsIfNeeded.

  if (output_level_ != 0) {
    output_level_inputs_.level = output_level_;
    if (!compaction_picker_->SetupOtherInputs(
            cf_name_, mutable_cf_options_, vstorage_, &start_level_inputs_,
            &output_level_inputs_, &parent_index_, base_index_)) {
      return false;
    }

    compaction_inputs_.push_back(start_level_inputs_);
    if (!output_level_inputs_.empty()) {
      compaction_inputs_.push_back(output_level_inputs_);
    }

    // In some edge cases we could pick a compaction that will be compacting
    // a key range that overlap with another running compaction, and both
    // of them have the same output level. This could happen if
    // (1) we are running a non-exclusive manual compaction
    // (2) AddFile ingest a new file into the LSM tree
    // We need to disallow this from happening.
    if (compaction_picker_->FilesRangeOverlapWithCompaction(compaction_inputs_,
                                                            output_level_)) {
      // This compaction output could potentially conflict with the output
      // of a currently running compaction, we cannot run it.
      return false;
    }
    compaction_picker_->GetGrandparents(vstorage_, start_level_inputs_,
                                        output_level_inputs_, &grandparents_);
  }

最后就是构造一个compact然后返回.

  // Form a compaction object containing the files we picked.
  Compaction* c = GetCompaction();

  TEST_SYNC_POINT_CALLBACK("LevelCompactionPicker::PickCompaction:Return", c);

  return c;

最后再回到BackgroundCompaction中,这里就是在得到需要compact的文件之后,进行具体的compact. 这里我们可以看到核心的数据结构就是CompactionJob,每一次的compact都是一个job,最终对于文件的compact都是在 CompactionJob::run中实现.

CompactionJob compaction_job(
        job_context->job_id, c.get(), immutable_db_options_,
        env_options_for_compaction_, versions_.get(), &shutting_down_,
        preserve_deletes_seqnum_.load(), log_buffer, directories_.GetDbDir(),
        GetDataDir(c->column_family_data(), c->output_path_id()), stats_,
        &mutex_, &bg_error_, snapshot_seqs, earliest_write_conflict_snapshot,
        snapshot_checker, table_cache_, &event_logger_,
        c->mutable_cf_options()->paranoid_file_checks,
        c->mutable_cf_options()->report_bg_io_stats, dbname_,
        &compaction_job_stats);
    compaction_job.Prepare();

    mutex_.Unlock();
    compaction_job.Run();
    TEST_SYNC_POINT("DBImpl::BackgroundCompaction:NonTrivial:AfterRun");
    mutex_.Lock();

    status = compaction_job.Install(*c->mutable_cf_options());
    if (status.ok()) {
      InstallSuperVersionAndScheduleWork(
          c->column_family_data(), &job_context->superversion_context,
          *c->mutable_cf_options(), FlushReason::kAutoCompaction);
    }
    *made_progress = true;

在RocksDB中,Compact是会多线程并发的执行,而这里怎样并发,并发多少线程都是在CompactionJob中实现的,简单来说,当你的compact的文件range不重合的话,那么都是可以并发执行的。

我们先来看CompactionJob::Prepare函数,在这个函数中主要是做一些执行前的准备工作,首先是取得对应的compact的边界,这里每一个需要并发的compact都被抽象为一个sub compaction.因此在GenSubcompactionBoundaries会解析到对应的sub compaction以及边界.解析完毕之后,则将会把对应的信息全部加入sub_compact_states中。

void CompactionJob::Prepare() {
  ..........................
  if (c->ShouldFormSubcompactions()) {
    const uint64_t start_micros = env_->NowMicros();
    GenSubcompactionBoundaries();
    MeasureTime(stats_, SUBCOMPACTION_SETUP_TIME,
                env_->NowMicros() - start_micros);

    assert(sizes_.size() == boundaries_.size() + 1);

    for (size_t i = 0; i <= boundaries_.size(); i++) {
      Slice* start = i == 0 ? nullptr : &boundaries_[i - 1];
      Slice* end = i == boundaries_.size() ? nullptr : &boundaries_[i];
      compact_->sub_compact_states.emplace_back(c, start, end, sizes_[i]);
    }
    MeasureTime(stats_, NUM_SUBCOMPACTIONS_SCHEDULED,
                compact_->sub_compact_states.size());
  }
......................................
}

因此我们来详细分析GenSubcompactionBoundaries,这个函数比较长,我们来分开分析,首先是遍历所有的需要compact的level,然后取得每一个level的边界(也就是最大最小key)。

void CompactionJob::GenSubcompactionBoundaries() {
...........................
  // Add the starting and/or ending key of certain input files as a potential
  // boundary
  for (size_t lvl_idx = 0; lvl_idx < c->num_input_levels(); lvl_idx++) {
    int lvl = c->level(lvl_idx);
    if (lvl >= start_lvl && lvl <= out_lvl) {
      const LevelFilesBrief* flevel = c->input_levels(lvl_idx);
      size_t num_files = flevel->num_files;
.....................
      if (lvl == 0) {
        // For level 0 add the starting and ending key of each file since the
        // files may have greatly differing key ranges (not range-partitioned)
        for (size_t i = 0; i < num_files; i++) {
          bounds.emplace_back(flevel->files[i].smallest_key);
          bounds.emplace_back(flevel->files[i].largest_key);
        }
      } else {
        // For all other levels add the smallest/largest key in the level to
        // encompass the range covered by that level
        bounds.emplace_back(flevel->files[0].smallest_key);
        bounds.emplace_back(flevel->files[num_files - 1].largest_key);
        if (lvl == out_lvl) {
          // For the last level include the starting keys of all files since
          // the last level is the largest and probably has the widest key
          // range. Since it's range partitioned, the ending key of one file
          // and the starting key of the next are very close (or identical).
          for (size_t i = 1; i < num_files; i++) {
            bounds.emplace_back(flevel->files[i].smallest_key);
          }
        }
      }
    }
  }
......................

然后则是对取得的bounds进行排序以及去重.

  std::sort(bounds.begin(), bounds.end(),
            [cfd_comparator](const Slice& a, const Slice& b) -> bool {
              return cfd_comparator->Compare(ExtractUserKey(a),
                                             ExtractUserKey(b)) < 0;
            });
  // Remove duplicated entries from bounds
  bounds.erase(
      std::unique(bounds.begin(), bounds.end(),
                  [cfd_comparator](const Slice& a, const Slice& b) -> bool {
                    return cfd_comparator->Compare(ExtractUserKey(a),
                                                   ExtractUserKey(b)) == 0;
                  }),
      bounds.end());

接近着就来计算理想情况下所需要的subcompactions的个数以及输出文件的个数.

  // Group the ranges into subcompactions
  const double min_file_fill_percent = 4.0 / 5;
  int base_level = v->storage_info()->base_level();
  uint64_t max_output_files = static_cast<uint64_t>(std::ceil(
      sum / min_file_fill_percent /
      MaxFileSizeForLevel(*(c->mutable_cf_options()), out_lvl,
          c->immutable_cf_options()->compaction_style, base_level,
          c->immutable_cf_options()->level_compaction_dynamic_level_bytes)));
  uint64_t subcompactions =
      std::min({static_cast<uint64_t>(ranges.size()),
                static_cast<uint64_t>(c->max_subcompactions()),
                max_output_files});

最后更新boundaries_,这里会根据根据文件的大小,通过平均的size,来吧所有的range分为几份,最终这些都会保存在boundaries_中.

  if (subcompactions > 1) {
    double mean = sum * 1.0 / subcompactions;
    // Greedily add ranges to the subcompaction until the sum of the ranges'
    // sizes becomes >= the expected mean size of a subcompaction
    sum = 0;
    for (size_t i = 0; i < ranges.size() - 1; i++) {
      sum += ranges[i].size;
      if (subcompactions == 1) {
        // If there's only one left to schedule then it goes to the end so no
        // need to put an end boundary
        continue;
      }
      if (sum >= mean) {
        boundaries_.emplace_back(ExtractUserKey(ranges[i].range.limit));
        sizes_.emplace_back(sum);
        subcompactions--;
        sum = 0;
      }
    }
    sizes_.emplace_back(sum + ranges.back().size);
  }

然后我们来看CompactJob::Run的实现,在这个函数中,就是会遍历所有的sub_compact,然后启动线程来进行对应的compact工作,最后等到所有的线程完成,然后退出.

 // Launch a thread for each of subcompactions 1...num_threads-1
  std::vector<port::Thread> thread_pool;
  thread_pool.reserve(num_threads - 1);
  for (size_t i = 1; i < compact_->sub_compact_states.size(); i++) {
    thread_pool.emplace_back(&CompactionJob::ProcessKeyValueCompaction, this,
                             &compact_->sub_compact_states[i]);
  }

  // Always schedule the first subcompaction (whether or not there are also
  // others) in the current thread to be efficient with resources
  ProcessKeyValueCompaction(&compact_->sub_compact_states[0]);

  // Wait for all other threads (if there are any) to finish execution
  for (auto& thread : thread_pool) {
    thread.join();
  }

  if (output_directory_) {
    output_directory_->Fsync();
  }

最后我们可以看到最终compact工作是在CompactionJob::ProcessKeyValueCompaction是实现的,这个函数我们暂时就不分析了,我们只需要知道所有的compact工作都是在这个函数中执行的.

MySQL · RocksDB · TransactionDB 介绍

$
0
0

1. 概述

得益于LSM-Tree结构,RocksDB所有的写入并非是update in-place,所以他支持起来事务的难度也相对较小,主要原理就是利用WriteBatch将事务所有写操作在内存缓存打包,然后在commit时一次性将WriteBatch写入,保证了原子,另外通过Sequence和Key锁来解决冲突实现隔离。

RocksDB的Transaction分为两类:Pessimistic和Optimistic,类似悲观锁和乐观锁的区别,PessimisticTransaction的冲突检测和加锁是在事务中每次写操作之前做的(commit后释放),如果失败则该操作失败;OptimisticTransaction不加锁,冲突检测是在commit阶段做的,commit时发现冲突则失败。

2. 用法

介绍实现原理前,先来看一下用法:

1. 基本用法

Options options;
TransactionDBOptions txn_db_options;
options.create_if_missing = true;
TransactionDB* txn_db;

// 打开DB(默认Pessimistic)
Status s = TransactionDB::Open(options, txn_db_options, kDBPath, &txn_db);
assert(s.ok());

// 创建一个事务
Transaction* txn = txn_db->BeginTransaction(write_options);
assert(txn);

// 事务txn读取一个key
s = txn->Get(read_options, "abc", &value);
assert(s.IsNotFound());

// 事务txn写一个key
s = txn->Put("abc", "def");
assert(s.ok());

// 通过TransactionDB::Get在事务外读取一个key
s = txn_db->Get(read_options, "abc", &value);

// 通过TrasactionDB::Put在事务外写一个key
// 这里并不会有影响,因为写的不是"abc",不冲突
// 如果是"abc"的话
// 则Put会一直卡住直到超时或等待事务Commit(本例中会超时)
s = txn_db->Put(write_options, "xyz", "zzz");

s = txn->Commit();
assert(s.ok());
// 析构事务
delete txn;
delete txn_db;

通过BeginTransaction打开一个事务,然后调用Put、Get等接口进行事务操作,最后调用Commit进行提交。

2. 回滚

...
// 事务txn写入abc
s = txn->Put("abc", "def");
assert(s.ok());

// 设置回滚点
txn->SetSavePoint();

// 事务txn写入cba
s = txn->Put("cba", "fed");
assert(s.ok());
// 回滚至回滚点
s = txn->RollbackToSavePoint();

// 提交,此时事务中不包含对cba的写入
s = txn->Commit();
assert(s.ok());
...

3. GetForUpdate

...
// 事务txn读取abc并独占该key,确保不被外部事务再修改
s = txn->GetForUpdate(read_options, “abc”, &value);
assert(s.ok());

// 通过TransactionDB::Put接口在事务外写abc
// 不会成功
s = txn_db->Put(write_options, “abc”, “value0”);

s = txn->Commit();
assert(s.ok());
...

有时候在事务中需要对某一个key进行先读后写,此时则不能在写时才进行该key的独占及冲突检测操作,所以使用GetForUpdate接口读取该key并进行独占

4. SetSnapshot

txn = txn_db->BeginTransaction(write_options);
// 设置事务txn使用的snapshot为当前全局Sequence Number
txn->SetSnapshot();

// 使用TransactionDB::Put接口在事务外部写abc
// 此时全局Sequence Number会加1
db->Put(write_options, “key1”, “value0”);
assert(s.ok());

// 事务txn写入abc
s = txn->Put(“abc”, “value1”);
s = txn->Commit();
// 这里会失败,因为在事务设置了snapshot之后,事务后来写的key
// 在事务外部有过其他写操作,所以这里不会成功
// Pessimistic会在Put时失败,Optimistic会在Commit时失败

前面说过,TransactionDB在事务中需要写入某个key时才对其进行独占或冲突检测,有时希望在事务一开始就对其之后所有要写入的所有key进行独占,此时可以通过SetSnapshot来实现,设置了Snapshot后,外部一旦对事务中将要进行写操作key做过修改,则该事务最终会失败(失败点取决于是Pessimistic还是Optimistic,Pessimistic因为在Put时就进行冲突检测,所以Put时就失败,而Optimistic则会在Commit是检测到冲突,失败)

3. 实现

3.1 WriteBatch & WriteBatchWithIndex

WriteBatch就不展开说了,事务会将所有的写操作追加进同一个WriteBatch,直到Commit时才向DB原子写入。

WriteBatchWithIndex在WriteBatch之外,额外搞一个Skiplist来记录每一个操作在WriteBatch中的offset等信息。在事务没有commit之前,数据还不在Memtable中,而是存在WriteBatch里,如果有需要,这时候可以通过WriteBatchWithIndex来拿到自己刚刚写入的但还没有提交的数据。

事务的SetSavePoint和RollbackToSavePoint也是通过WriteBatch来实现的,SetSavePoint记录当前WriteBatch的大小及统计信息,若干操作之后,若想回滚,则只需要将WriteBatch truncate到之前记录的大小并恢复统计信息即可。

3.2 PessimisticTransaction

PessimisticTransactionDB通过TransactionLockMgr进行行锁管理。事务中的每次写入操作之前都需要TryLock进Key锁的独占及冲突检测,以Put为例:

Status TransactionBaseImpl::Put(ColumnFamilyHandle* column_family,
                                const Slice& key, const Slice& value) {
  // 调用TryLock抢锁及冲突检测
  Status s =
      TryLock(column_family, key, false /* read_only */, true /* exclusive */);

  if (s.ok()) {
    s = GetBatchForWrite()->Put(column_family, key, value);
    if (s.ok()) {
      num_puts_++;
    }
  }

  return s;
}

可以看到Put接口定义在TransactionBase中,无论Pessimistic还是Optimistic的Put都是这段逻辑,二者的区别是在对TryLock的重载。先看Pessimistic的,TransactionBaseImpl::TryLock通过TransactionBaseImpl::TryLock -> PessimisticTransaction::TryLock -> PessimisticTransactionDB::TryLock -> TransactionLockMgr::TryLock一路调用到TransactionLockMgr的TryLock,在里面完成对key加锁,加锁成功便实现了对key的独占,此时直到事务commit之前,其他事务是无法修改这个key的。

锁是加成功了,但这也只能说明从此刻起到事务结束前这个key不会再被外部修改,但如果事务在最开始执行SetSnapshot设置了快照,如果在打快照和Put之间的过程中外部对相同key进行了修改(并commit),此时已经打破了snapshot的保证,所以事务之后的Put也不能成功,这个冲突检测也是在PessimisticTransaction::TryLock中做的,如下:

Status PessimisticTransaction::TryLock(ColumnFamilyHandle* column_family,
                                       const Slice& key, bool read_only,
                                       bool exclusive, bool skip_validate) {
  ...
  // 加锁
  if (!previously_locked || lock_upgrade) {
    s = txn_db_impl_->TryLock(this, cfh_id, key_str, exclusive);
  }

  SetSnapshotIfNeeded();

  ...
  
    // 使用事务一开始拿到的snapshot的sequence1与这个key在DB中最新
    // 的sequence2进行比较,如果sequence2 > sequence1则代表在snapshot
    // 之后,外部有对key进行过写入,有冲突!
    s = ValidateSnapshot(column_family, key, &tracked_at_seq);

      if (!s.ok()) {
        // 检测到冲突,解锁
        // Failed to validate key
        if (!previously_locked) {
          // Unlock key we just locked
          if (lock_upgrade) {
            s = txn_db_impl_->TryLock(this, cfh_id, key_str,
                                      false /* exclusive */);
            assert(s.ok());
          } else {
            txn_db_impl_->UnLock(this, cfh_id, key.ToString());
          }
        }
      }
  
  if (s.ok()) {
    // 如果加锁及冲突检测通过,记录这个key以便事务结束时释放掉锁
    // We must track all the locked keys so that we can unlock them later. If
    // the key is already locked, this func will update some stats on the
    // tracked key. It could also update the tracked_at_seq if it is lower than
    // the existing trackey seq.
    TrackKey(cfh_id, key_str, tracked_at_seq, read_only, exclusive);
  }
}

其中ValidateSnapshot就是进行冲突检测,通过将事务设置的snapshot与key最新的sequence进行比较,如果小于key最新的sequence,则代表设置snapshot后,外部事务修改过这个key,有冲突!获取key最新的sequence也是简单粗暴,遍历memtable,immutable memtable,memtable list history及SST文件来拿。总结如下图:

1

GetForUpdate的逻辑和Put差不多,无非就是以Get之名行Put之事(加锁及冲突检测),如下图:

2

接着介绍下TransactionLockMgr,如下图:

3

最外层先是一个std::unordered_map,将每个ColumnFamily映射到一个LockMap,每个LockMap默认有16个LockMapStripe,然后每个LockMapStripe里包含一个std::unordered_map<std::string, LockInfo> keys,这就是存放每个key对应的锁信息的。所以每次加锁过程大致如下:

  1. 首先通过ThreadLocal拿到lock_maps指针
  2. 通过column family ID 拿到对应的LockMap
  3. 对key hash映射到某个LockMapStripe,对该LockMapStripe加锁(同一LockMapStripe下的所有key会抢同一把锁,粒度略大
  4. 操作LockMapStripe里的std::unordered_map完成加锁

3.3 OptimisticTransaction

OptimisticTransactionDB不使用锁进行key的独占,只在commit是进行冲突检测。所以OptimisticTransaction::TryLock如下:

Status OptimisticTransaction::TryLock(ColumnFamilyHandle* column_family,
                                      const Slice& key, bool read_only,
                                      bool exclusive, bool untracked) {
  if (untracked) {
    return Status::OK();
  }
  uint32_t cfh_id = GetColumnFamilyID(column_family);

  SetSnapshotIfNeeded();
  // 如果设置了之前事务snapshot,这里使用它作为key的seq
  // 如果没有设置snapshot,则以当前全局的sequence作为key的seq
  SequenceNumber seq;
  if (snapshot_) {
    seq = snapshot_->GetSequenceNumber();
  } else {
    seq = db_->GetLatestSequenceNumber();
  }

  std::string key_str = key.ToString();
  // 记录这个key及其对应的seq,后期在commit时通过使用这个seq和
  // key当前的最新sequence比较来做冲突检测
  TrackKey(cfh_id, key_str, seq, read_only, exclusive);

  // Always return OK. Confilct checking will happen at commit time.
  return Status::OK();
}

这里TryLock实际上就是给key标记一个sequence并记录,用作commit时的冲突检测,commit实现如下:

Status OptimisticTransaction::Commit() {
  // Set up callback which will call CheckTransactionForConflicts() to
  // check whether this transaction is safe to be committed.
  OptimisticTransactionCallback callback(this);

  DBImpl* db_impl = static_cast_with_check<DBImpl, DB>(db_->GetRootDB());
  // 调用WriteWithCallback进行冲突检测,如果没有冲突就写入DB
  Status s = db_impl->WriteWithCallback(
      write_options_, GetWriteBatch()->GetWriteBatch(), &callback);

  if (s.ok()) {
    Clear();
  }

  return s;
}

冲突检测的实现在OptimisticTransactionCallback里,和设置了snapshot的PessimisticTransaction一样,最终还是会调用TransactionUtil::CheckKeysForConflicts来检测,也就是比较sequence。整体如下图:

4

3.4 两阶段提交(Two Phase Commit)

在分布式场景下使用PessimisticTransaction时,我们可能需要使用两阶段提交(2PC)来确保一个事务在多个节点上执行成功,所以PessimisticTransaction也支持2PC。具体做法也不难,就是将之前commit拆分为prepare和commit,prepare阶段进行WAL的写入,commit阶段进行Memtable的写入(写入后其他事务方可见),所以现在一个事务的操作流程如下:

BeginTransaction
GetForUpdate
Put
...
Prepare
Commit

使用2PC,我们首先要通过SetName为一个事务设置唯一的标识并注册到全局映射表里,这里记录着所有未完成的2PC事务,当Commit后再从映射表里删除。

接下来具体2PC实现无非就是在WriteBatch上做文章,通过特殊的标记来控制写WAL和Memtable,简单说一下:

正常的WriteBatch结构如下:

Sequence(0);NumRecords(3);Put(a,1);Merge(a,1);Delete(a);

2PC一开始的WriteBatch如下:

Sequence(0);NumRecords(0);Noop;

先使用一个Noop占位,至于为什么,后面再说。紧接着就是一些操作,操作后,WriteBatch如下:

Sequence(0);NumRecords(3);Noop;Put(a,1);Merge(a,1);Delete(a);

然后执行Prepare,写WAL,在写WAL之前,先会队WriteBatch做一些改动,插入Prepare和EndPrepare记录,如下:

Sequence(0);NumRecords(3);Prepare();Put(a,1);Merge(a,1);Delete(a);EndPrepare(xid)

可以看到这里将之前的Noop占位换成Prepare,然后在结尾插入EndPrepare(xid),构造好WriteBatch后就直接调用WriteImpl写WAL了。注意,此时往WAL里写的这条日志的sequence虽然比VersionSet的last_sequence大,但写入WAL之后并不会调用SetLastSequence来更新VersionSet的last_sequence,它只有在最后写入Memtable之后才更新,具体做法就是给VersionSet除了last_sequence_之外,再加一个last_allocated_sequence_,初始相等,写WAL是加后者,后者对外不可见,commit后再加前者。如果使用two_write_queues_,不管是Prepare -> Commit还是直接Commit,sequence的增长都是以last_allocated_sequence_为准,最后用它来调整last_sequence_;如果不使用two_write_queues_则直接以last_sequence_为准,总之不会出现sequence混错,所以可以Prepare -> Commit和Commit混用。

WAL写完之后,即使没有commit就宕机也没事,重启后Recovery会将事务从WAL恢复记录到全局recovered_transaction中,等待Commit

最后就是Commit,Commit阶段会使用一个新的CommitTime WriteBatch,和之前的WriteBatch合并整理后最终使用CommitTime WriteBatch写Memtable

整理后的CommitTime WriteBatch如下:

Sequence(0);NumRecords(3);Commit(xid);
Prepare();Put(a,1);Merge(a,1);Delete(a);EndPrepare(xid);

将CommitTime WriteBatch的WALTerminalPoint设置到Commit(xid)处,告诉Writer写WAL时写到这里就可以停了,其实就是只将Commit记录写进WAL(因为其后的记录在Prepare阶段就已经写到WAL了);

在最后就是MemTableInserter遍历这个CommitTime WriteBatch向memtable写入,具体就不说了。写入成功后,更新VersionSet的last_sequence_,至此,事务成功提交。

4. WritePrepared & WriteUnprepared

我们可以看到无论是Pessimistic还是Optimistic,都有一个共同缺点,那就是在事务最终Commit之前,所以数据都是缓存在内存(WriteBatch)里,对于很大的事务来说,这非常耗费内存并且将所有实际写入压力都扔给Commit阶段来搞,性能有瓶颈,所以RocksDB正在支持WritePolicy为WritePrepared和WriteUnprepared的PessimisticTransaction,主要思想就是将对Memtable的写入提前,

如果放到Prepare阶段那就是WritePrepared

如果再往前,每次操作直接写Memtable那就是WriteUnprepared

可以看到WriteUnprepared无论内存占用还是写入压力点的分散都做的最好,WritePrepared稍逊。

支持这俩新的WritePolicy的难点在于如何保证写入到Memtable但还未Commit的数据不被其他事物看到,这里就需要在Sequence上大做文章了,目前官方已经支持了WriteUnprepared。

5. 隔离级别

看了前面的介绍,这里就不用展开说了

TransactionDB支持ReadCommitted和RepeatableReads级别的隔离

PgSQL · 应用案例 · 相似人群圈选,人群扩选,向量相似 使用实践

$
0
0

背景

PostgreSQL 相似插件非常多,插件的功能以及用法如下:

《PostgreSQL 相似搜索插件介绍大汇总 (cube,rum,pg_trgm,smlar,imgsmlr,pg_similarity) (rum,gin,gist)》

相似人群分析在精准营销,推荐系统中的需求很多。

人的属性可以使用向量来表达,每个值代表一个属性的权重值,通过向量相似,可以得到一群相似的人群。

例如

create table tt (  
  uid int8 primary key,  
  att1 float4,  -- 属性1 的权重值   
  att2 float4,  -- 属性2 的权重值  
  att3 float4,  -- 属性3 的权重值  
  ...  
  attn float4   -- 属性n 的权重值  
);  

使用cube表示属性

create table tt (  
  uid int8 primary key,  
  att cube  -- 属性  
);  

使用cube或imgsmlr可以达到类似的目的。

a <-> b float8  Euclidean distance between a and b.  
a <#> b float8  Taxicab (L-1 metric) distance between a and b.  
a <=> b float8  Chebyshev (L-inf metric) distance between a and b.  

但是如果向量很大(比如属性很多),建议使用一些方法抽象出典型的特征值,压缩向量。 类似图层,图片压缩。实际上imgsmlr就是这么做的:

pic

例如256256的像素,压缩成44的像素,存储为特征值。

例子

1、创建插件

create extension cube;  

2、创建测试表

create table tt (id int , c1 cube);  

3、创建GIST索引

create index idx_tt_1 on tt using gist(c1);  

4、创建生成随机CUBE的函数

create or replace function gen_rand_cube(int,int) returns cube as $$  
  select ('('||string_agg((random()*$2)::text, ',')||')')::cube from generate_series(1,$1);  
$$ language sql strict;  

5、CUBE最多存100个维度

postgres=# \set VERBOSITY verbose  
  
postgres=# select gen_rand_cube(1000,10);  
  
ERROR:  22P02: invalid input syntax for cube  
DETAIL:  A cube cannot have more than 100 dimensions.  
CONTEXT:  SQL function "gen_rand_cube" statement 1  
LOCATION:  cube_yyparse, cubeparse.y:111  

6、写入测试数据

insert into tt select id, gen_rand_cube(16, 10) from generate_series(1,10000) t(id);  

7、通过单个特征值CUBE查询相似人群,以点搜群

select * from tt order by c1 <-> '(1,2,3,4,5,6,7)' limit x;  -- 个体搜群体  

8、通过多个特征值CUBE查询相似人群,以群搜群

select * from tt order by c1 <-> '[(1,2,3,4,5,6,7),(1,3,4,5,6,71,3), ...]' limit x; -- 群体搜群体  
postgres=# explain select * from tt order by c1 <-> '[(1,2,3),(2,3,4)]' limit 1;  
                                QUERY PLAN                                  
--------------------------------------------------------------------------  
 Limit  (cost=0.11..0.14 rows=1 width=44)  
   ->  Index Scan using idx_tt_1 on tt  (cost=0.11..0.16 rows=2 width=44)  
         Order By: (c1 <-> '(1, 2, 3),(2, 3, 4)'::cube)  
(3 rows)  

9、如果需要再计算压缩前的特征值的相似性,可以使用原始值再计算一遍。

《PostgreSQL 遗传学应用 - 矩阵相似距离计算 (欧式距离,…XX距离)》

select *,   
  c1 <-> ?1,   -- c1表示压缩后的特征值浮点数向量,比如(4*4)  
  distance_udf(detail_c1,?2)   -- deatil_c1 表示原始特征值浮点数向量(比如128*128)    
from tt order by c1 <-> ?1 limit xx;  

参考

https://www.postgresql.org/docs/devel/static/cube.html

https://github.com/postgrespro/imgsmlr

https://github.com/eulerto/pg_similarity

《PostgreSQL 相似搜索插件介绍大汇总 (cube,rum,pg_trgm,smlar,imgsmlr,pg_similarity) (rum,gin,gist)》

《PostgreSQL 11 相似图像搜索插件 imgsmlr 性能测试与优化 3 - citus 8机128shard (4亿图像)》

《PostgreSQL 11 相似图像搜索插件 imgsmlr 性能测试与优化 2 - 单机分区表 (dblink 异步调用并行) (4亿图像)》

《PostgreSQL 11 相似图像搜索插件 imgsmlr 性能测试与优化 1 - 单机单表 (4亿图像)》

Viewing all 689 articles
Browse latest View live